이 글은 어셈블리를 어느 정도 알고 있다고 가정하고 쓰여졌다. 최소한 레지스터 정도는 알고 와야 이해가 가능할거임.
1. 환경 세팅 및 컴파일
백준은 Ubuntu를 사용하고, 어셈블리는 플랫폼 종속적인 언어이기 때문에 백준 제출용이라면 반드시 리눅스에서 빌드 및 실행을 해야 한다.
이 환경을 갖추지 못한다면 유감. 리눅스 환경을 갖췄다면 build-essential과 nasm과 텍스트 에디터를 설치하자. 난 vim씀.
백준에선 x64 Aseembly는 다음의 커맨드로 어셈블링을 한다.
nasm -f elf64 -o Main.o Main.asm && gcc -o Main Main.o
저거대로 해보고 만일 relocation ~ PIE object 뭐시기 하는 에러가 나오면 gcc에 -static 옵션도 같이 주면 된다.
이제부터 본격적으로 코드를 보자. 내가 제출한 구구단 코드를 기준으로 설명함.
http://boj.kr/b030da4498484d99a594a4bbf6b6a3c42. Section
순수 어셈블리로 코드를 짜려면 메모리를 segment로 나눠야 한다. 흔히 말하는 segfault의 seg가 이 segment를 말하는 것이고, 이걸 수동으로 나눠줘야 함.
NASM에서는 section이라는 키워드로 접근한다. segment 키워드로 거의 동일한 일을 수행할 수 있지만 완벽히 동일하진 않으니 section만 쓴다고 생각하자.
section .bss -> 초기화되지 않은 변수를 선언한다. 실제로 실행되면서 0으로 초기화됨.
section .data -> 초기화된 변수를 선언한다. 따라서 크기가 이미 고정되어 있으며 값을 반드시 지정해야 한다.
section .text -> 코드가 위치한다. text section을 선언한 후부터 코드를 작성해야 꼬일 확률이 적음.
그리고 어셈블리는 모든 것을 조작할 수 있기 때문에 사용자 정의 section도 만들 수 있음.
section .special write 이런 식으로 말이지. 그런데 이거까지 필요할 일은 없을 것 같음.
bss, data section에서 변수를 선언할 때는 각각 resx, dx를 사용한다. x에는 기준 단위에 따라 다음의 값들이 들어갈 수 있다.
b(byte), w(word), d(dword), q(qword)
당연히 각각 1, 2, 4, 8byte를 기준으로 삼겠다는 이야기임.
코드 2~4번째 줄에 있는 문장은 각각 n, res, cur를 8byte씩 예약하겠다는 이야기이다. resb 8이니까 1byte 기준으로 8개.
왜 하필 8byte냐면 이후에 보겠지만 저 변수에 레지스터 값을 그대로 복사할거고 x64에서 레지스터 크기는 8byte이기 때문이다.
4byte로 잡을거면 RAX 대신 EAX를 쓰는 등 사이즈에 맞춰서 사용할 레지스터 크기도 생각해야 한다.
코드 8~10번째 줄에 있는 문장은 각각 해당하는 변수들을 저 값으로 초기화하겠다는 이야기이다. 역시 db로 선언했으니 1byte씩 잘라서 저장한다.
만약에 한글을 써야 한다면 dw로 선언해서 2byte 단위로 잘라야겠지?
여기서 참고해야할건 db ' = ', 0은 db ' ', '=', ' ', 0의 syntactic sugar이다. 마지막 0은 당연히 아스키코드 0인 널 문자를 뜻함.
termi는 db 10, 0으로 되어있는데, 아스키코드표를 참고하면 알겠지만 아스키코드 10은 개행문자다. 자주 쓰게 될테니 이정도는 외우는게 좋음.
3. Code
드디어 코드를 볼 때가 됐다. 코드의 시작은 section .text이고, 그 밑에 global과 extern 선언이 있다.
global은 해당 라벨을 프로그램 밖으로 노출시키겠다는 뜻이고, 그 말은 곧 해당 라벨을 이 코드의 entry point로 삼겠다는 이야기와 동일하다.
그리고 실제로 80번째 줄에 있는 main 라벨이 이 프로그램의 시작점임.
extern은 프로그램 외부에 존재하는 해당 심볼을 여기서 사용하겠다는 뜻이고 C 함수를 이렇게 선언해서 사용할 수 있다.
만약에 printf scanf같은거 쓰는것도 편법이라고 생각하면 syscall을 사용해서 불러오면 되지만 난 귀찮아서 extern printf 쓸련다.
어셈블리는 명확한 함수가 정해져있지 않다. 대신 라벨로 코드의 특정 위치를 표시할 수 있는데 그게 위 코드에서 main, _loop, multiply 등과 같은 부분이다.
하지만 jmp 계열이 아니라 call을 통해 부르고 싶으면 함수의 프롤로그와 에필로그를 쓰면 된다.
함수의 프롤로그는 push rbp; mov rbp, rsp를 뜻하고, 에필로그는 leave; ret을 뜻한다. leave 대신 mov rsp, rbp; pop rbp를 해도 된다.
함수 호출 스택과 관련된 작업인데, 아마 아키텍처에서 다루지 않나...?
그래도 결국은 서브루틴이라는게 딱히 없기 때문에 지역변수 그딴거 없다.
만일 extern을 통해 가져온 C 함수를 써야 한다면 다음의 순서대로 인자를 넣으면 된다.
Linux, 부동소수점 제외 - RDI, RSI, RDX, RCX, R8, R9, 이 이후로는 스택
Linux, 부동소수점 - XMM0, XMM1, XMM2, XMM3, XMM4, XMM5, 이 이후로는 스택
Windows, 부동소수점 제외 - RCX, RDX, R8, R9, 이 이후로는 스택
Windows, 부동소수점 - XMM0, XMM1, XMM2, XMM3, 이 이후로는 스택
리턴의 경우 부동소수점이라면 XMM0로 들어오고 그 이외의 경우 RAX로 들어온다.
x86에서는 호출 규약에 따라 넣는 순서도 달라서 골아팠는데 x64에선 fastcall로 통일되어서 편해졌다고 할 수 있다.
printf는 위 코드에서 많이 쓰고, 다 그놈이 그놈이니 24~27번째 줄 한 개만 보자.
위의 호출 규약을 참고해서 보면 첫번째 인자에 format("%d")을, 두번째 인자에 변수 n의 값을 넣고 리턴값으로 들어올 RAX를 0으로 초기화하고 printf를 콜한다.
bss section에서 선언된 n은 엄밀히 말하면 8바이트로 예약된 공간의 주소이므로 n을 그대로 넣으면 이 변수의 주소를 출력하게 된다.
따라서 이 주소가 가리키는 값을 갖고온다는 의미로 NASM에서는 [ ]를 사용한다.
format은 char 배열이고, 이건 C에서도 포인터랑 동일한 놈이기 때문에 주소를 전달해야 한다.
이정도면 scanf를 사용하는 84~87번째 줄도 이해가 가능하리라고 생각함.
혹시나 해서 첨언하자면 여기서 [n]이 아니라 n을 넣는 이유는 당연히 주소가 필요하기 때문임.
마지막으로 실제 루프를 돌면서 값을 계산하는 multiply를 보자.
59 ~ 62 - RAX와 RBX를 1로 초기화함.
64 ~ 66 - RBX가 9보다 크면 루프를 종료한다. 빠져나가는 _end를 보면 그냥 에필로그 쓰고 main으로 돌아감을 알 수 있다.
67 ~ 69 - n과 RBX를 곱한다. mul은 주어진 값과 RAX를 곱해서 그 결과를 RAX에 저장하므로 RAX를 거쳐서 값을 계산해야 한다.
70 ~ 71 - RBX와 위에서 계산한 결과를 각각 cur와 res에 넣는다.
72 ~ 74 - 계산된 변수를 가지고 제시한 포맷에 맞게 출력하고 루프카운트를 1 증가시키고 루프를 다시 돌린다.
몇몇 부분은 날림으로 설명해서 어찌어찌 글 하나에 담을 만한 분량이 된 것 같다.
이걸로 실제 코딩을 하다보면 다른 언어는 별거 아닌거처럼 여겨질테니 이 자신감으로 F#을 쓰면 된다.
끝!
리눅스에 막혔네 이런
사실 NASM은 윈도우즈도 지원하기 떄문에 윈도우즈에서도 환경 세팅하는게 불가능하진 않다. 그리고 구구단 정도의 간단한 프로그램이면 호출 규약 정도만 바꿔도 통과가 가능할거임. 그래도 가상머신으로 리눅스 환경을 하나 세팅하는게 훨 이득이라고 본다.
끄아아아아아악
"진짜 광기"
안읽었지만 개추드림
유익한 글이지만 실제로 해볼 사람은 한명도 없을 예정...
사실 나도 이거 쓸 생각은 없었는데 써달라는 분이 계셔가지고...
이거 cpp코드를 컴파일한 nasm코드도 낼수있음?
nasm은 어셈블러인데 이걸로 cpp 코드를 어케 컴파일함
cpp를 컴파일해서 얻은 nasm 얘기하는거 아닐까
F#이 더쉬운데
F#으로 다이아푸쉴?
F# 가이드를 이렇게 쓰시면 F# 뉴비 100명 양산될 듯