시작하기 전에...
사외교육을 갔다가 너무 내용이 좋아서 정리하면서 글을 올리게 되었다. 교육내용을 모두 올린건 은 아니고 일부분을 재구성해서 올렸다.
강석민 강사님께 감사 드립니다^^
cafe.naver.com/cppmaster
3. 교시 - 어셈으로 함수 호출과 스택프레임, calling convention
이번에는 어셈코드를 본격적으로 짜보면서 어셈으로 함수 호출방법에 대해 알아 보도록 하자. 2교시때와 마찬가지로 함수호출할 때 인자 전달 방법과 리턴방법에 대해 알아 볼 것이고, 함수가 호출될 때 스택의 관리에 대해 고민해 볼 것이다. 마직막으로 이를 토대로 calling convention에 대해 알아 보고, inline함수에 대해 간단히 알아 보도록 하자.
그전에!! 어셈을 컴파일 하고 obj파일을 링크하는 방법을 먼저 알아야 겠지 않겠나.!!
지금 우리는 비주얼 스튜디오로 개발을 하고 있으므로 여기서 컴파일 하는 법을 배워보자.
우리가 C를 컴파일할 때 단축키 F7을 누르면 MS의 컴파일러인 cl.exe를 이용해서 컴파일 하는 것이다. 하지만 이것은 c/c++컴파일러이므로 어셈은 컴파일 할 수가 없다. 어셈파일에 대해서는 따로 컴파일 환경을 구축해줘야 한다. 어셈 컴파일러는 어떤 것들이 있을까?
MS에서 제공하는 masm, GNU에서 제공하는 nasm, 그 외에 Gas, Tasm등이 있는데 우리는 Opensource 진영에서 만든 nasm을 컴파일 해보도록 하자. 컴파일러는 인터넷에서 다운받고, nasm.exe(또는 nasmw.exe)파일을 windows폴더에 복사해놓도록 하자. 계속 사용할 것이므로 patht설정을 따로 하기 보단 windows 아래에 두는 것이 편한 것 같다. 프로젝트에서 어셈 파일을 하나 만든다. 우리는 먼저 커맨드창에서 빌드하는 방법과 비주얼 스튜디오에서 빌드하는 법을 모두 알아 보자(사실 다른 것은 없다.z)
커맨드에서 빌드
우선 비주얼 스튜디오 명령프롬프트를 실행 시키고 컴파일 할 파일이 있는 곳으로 간다.
나는 sample4에 있는 a.c와 first.asm을 build시켜 보겠다.
우선 a.c를 컴파일하기 위해 ‘cl a.c‘를 입력한다. 컴파일은 성공하지만 링크 에러가 날 것이다. first.obj가 없기 때문이다. obj파일만 만들기 위해서는 'cl a.c /c' 라고 하면 된다.
그리고 first.asm을 컴파일하기위해서 'nasm -f win32 -o first.obj first.asm이라고 하면 first.obj가 만들어 진다. a.obj와 first.obj를 링크를 하기 위해서는 ‘link a.obj first.obj'라고 한다. 그렇게 하면 a.exe파일이 생성 될 것이다.
비주얼 스튜디오에서 빌드
여기서는 어셈파일의 컴파일 속성만 바꿔주면 된다. 어셈파일을 우클릭해서 속성을 선택하면 사용자 지정 빌드 단계라는 곳에 “명령줄”, “출력”이라는 곳에 각각 nasm -f win32 -o *.obj *.asm , *.obj를 적어 준다. 그리고 F7을 누르면 빌드가 될 것이다.
다음의 어셈코드(first.asm)과 C코드(a.c)를 보도록 하자. a.c에서는 first.asm파일에 있는 함수를 호출 하고 있다. 우리는 두 파일을 컴파일 해서 링크를 할 것이므로 이런식의 호출은 당연히 가능하다. first.asm을 하나하나 보면서 어셈의 구조를 간단히 파악해 보자.
// a.c
void main()
{
int n = asm_main();
printf("결과 %d\n", n);
}
;first.asm
segment.data
L1DD100
L2DB20
segment.text
global_asm_main
_asm_main:
movDWORD[L1], 200
moveax, L1
ret
segment .data
데이터 섹션에 코드를 넣으라는 뜻이다. 다른 섹션을 지정해 주지 않는 동안 계속 해당 섹션에 코드를 넣는다.
L1DD100
이 코드는 C에서 보면 long L1 = 100; 이라는 코드와 동일 하다고 보면 될 것이다.
DD는 DWORD의 약자로 쓰인 것이다.
global _asm_main
이 코드는 _ams_main함수가 다른 오브젝트파일(?)에서도 쓰일 수 있도록 export 시켜 주는 구문이다. global은 directive로써 이런 일을 할 때 쓰인다.
_asm_main:
함수를 선언한 부분이다. 함수를 선언하는 방법은 함수의 이름 + 콜론(:) 이다.
movDWORD[L1]200
L1의 변수에 200을 넣으라는 뜻이다. L1은 해당 변수의 시작 주소로 보면 될 것인데 기본적으로 void*처럼 타입이 정해지지 않았기 때문에 L1의 주소부터 DWORD만큼의 크기만큼을 기준으로 200을 써넣어라..라는 뜻이다.
mov eax [L1]
L1을 가르키는 값을 eax레지스터에 넣는다. 그냥 L1을 하게 되면 L1의 주소를 eax에 넣는 꼴이 된다. 그래서 L1의 주소에 들어 있는 값을 eax만큼의 크기를 기준으로 eax에 옮겨넣어라 라는 뜻이다.
a.c와 fisrt.asm 코드에서 a.c와 first.asm을 같이 컴파일해서 링크하는 방법을 배워보았다.
이번에는 함수가 호출되었다가 돌아올 때 어셈 코드상에서는 실제로 돌아오는지를 보도록 하자...
일단, 함수를 호출하기 전에 돌아올 주소를 알아야 할 것이다... 그렇다면 함수를 호출하기 전에 현재 위치를 어디다가 저장하는 지가 중요한 문제이다. 돌아올 주소를 저장하는 방법으로 먼저 레지스터에 저장하는 법과, 스택에 넣어두는 법, 그리고 머신이 제공해주는 명령어를 이용하는 방법 3가지가 있다. 먼저 레지스터에 넣는 법을 보도록 하자.
... ...
mov ebx, next
jmp foo
next:
...다음 코드..
foo:
mov eax, 100
jmp ebx
돌아올 주소, 즉 next의 주소를 ebx에 저장하고 foo함수로 점프한 뒤, 수행이 끝나면
ebx를 통해 돌아오는 구조이다.
스택을 이용하는 방법을 레지스터에 넣는 값을 스택에 넣어 주기만 하면 된다.
push next
jmp foo
next:
... 다음 코드...
foo:
mov eax, 100
pop ebx
jmp ebx
다음으로 머신에서 제공하는 명령어를 이용하는 방법을 알아보도록 하자.
call foo
.. 다음 코드
foo:
mov eax, 100
ret
위의 코드는 C언어와 크게 다를 바가 없기 때문에 이해하기가 쉽다.
이번에는 함수의 인자를 전달하고 정리하는 방법에 대해서 알아보도록 하자.
함수의 인자를 전달하고 정리하는 방법은 레지스터와 스택을 이용하는 방법이 있다.
각각의 방법을 int add(int, int ); 함수를 어셈으로 호출할 때 인자를 어떻게 전달하는지를 예로 들면서 생각해 보자.
mov edx, 10
mov ecx, 20
call _add
ret
보통 함수의 인자를 전달할때는 edx, ecx 레지스터를 이용한다.
2. 스택을 이용하는 방법
push 20
push 10
call _add
add esp, 8
ret
여기서 조심해야 할 점은 스택의 정리를 하는 시점이다. 위의 코드는 함수를 호출하는 쪽에서 호출하는 모습이다. add esp, 8을 함으로써 돌아올 주소를 가르키게 하고 있다. 이런 스택 프레임에 대해서는 바로 다음에 나오는데 그전에 인자를 전달하는 방법들의 장단점을 정리 해보자.
레지스터를 이용해서 인자를 정리 하는 방법은 빠른 연산속도를 자랑하지만 많은 인자를 전달 할때는 쓰이기가 힘들다.
반면에 스택을 이용해서 인자를 정리하는 방법은 속도는 느리지만 많은 변수를 받아 올 수 있다.
잠시 미뤄 뒀던 스택 프레임에 대해서 알아보도록 하자.
int add(int a, int b)
{
int x, y;
x = 10;
return a+b;
}
를 어셈으보 바꾸어보면 함수의 호출시 스택정리, 즉 스택 프레임에 대해 알수가 있다.
_asm_main:
push 10
push 20
call _add;add( 20, 10);
add esp, 8;push 두 번했으므로 돌아갈 위치로간다.
ret
_add:
push ebp;여기 세줄을 함수의 prolog라고 한다.
mov ebp, esp
sub esp, 8; int x, y
movdword[ebp-4], 10;x = 10;
moveax, ebp-8; a + b
add eax, ebp-12
mov esp, ebp;여기 세줄을 함수의 epilog라고 한다.
pop ebp
ret
먼저 _add함수를 보자. ebp레지스터를 이용해서 함수 호출전의 스택위치를 저장한다. 물론 ebp위 원래 값을 보호 해야 하므로 그전에 ebp의 값을 push하는 것이다. 그리고 esp를 조정해서 함수에서 지역적으로 사용할 변수의 크기를 할당하고 코드를 수행하고 있다. 그리고 리턴하기 전에 esp의 값을 원래 함수 호출전의 값인 ebp의 값으로 교체하고 리턴하고 있다. 이것이 일반적인 스택 프레임이다. 여기서 추가적으로 생각해볼것이 하나 더 있다. 지금 코드상에서는 함수를 호출할 때 보낸 인자 값의 정리를 함수를 호출한 쪽, 즉, caller가 정리를 했다. 이런 방식을 cdecl 이라고 부르고, 반대로 호출당한쪽, 즉 callee가 정리하는 것을 stdcall 이라고 한다. 이렇게 함수를 호출할 때 쓴 스택 정리하는 것을 calling convention 이라고 하며 각각은 장단점이 있다. cdecl은 표준 C가 사용하는 방법으로 코드의 호환성이 좋아지지만 메모리가 비효율적으로 사용된다. 반면에 stdcall은 인텔에서 제공하는 방식으로 메모리 사용량이 줄어들지만 모든 머신이 이 기능을 제공하는 것은 아니다.stdcall 방식으로 스택을 정리하는 방법은 간단하다. mov esp, 8했던 것을 callee쪽에서 리턴할 때 ret가 아닌 ret 8로 바꾸면 된다. 아~ 그리고 하나 더 있다. fast call 방식인데 이것은 함수를 호출할 때 인자를 스택에 넣는 것이 아니라 레지스터에 넣는 방식이다. 저 위에서 애기 한적이 있기는 하다.ㅋ 참고로 C코드로 짠 소스가 어떻게 어셈블러로 바뀌었는지 보고 싶다면 커맨드 창에서 컴파일 할 때 cl test.c /FAs라고 하면 된다. 이렇게 하면 빌드 할 때 어셈파일도 함께 생성된다.
2교시가 매우 길었다. 주로 하고 싶은 이야기가 여기에 많이 있고 환경설정하는 부분도 여기에서 이야기를 해서 그런 것 같다. 다음 시간에는 어셈블러의 ‘반복문과 jmp’를 살펴보자.