Computer/C

디스어셈블을 통한 함수 호출 이해

알찬돌삐 2012. 8. 10. 16:28

 

  C 언어는 전통적으로 다음과 같은 방식으로 실행파일이 만들어진다.

(1) 편집기를 통한 소스 작성
(2) 전처리기를 통해 소스로부터 매크로 확장 및 주석 제거
(3) 컴파일러를 통해 전처리된 코드로부터 어셈블코드 생성
(4) 어셈블러를 통해 어셈블코드로부터 목적파일 생성
(5) 링커를 통해 목적파일들 (라이브러리 포함)의 결합에 의한 실행파일 생성

이 중에서 (3) 번과정을 재미로 살펴보면서 어떤일들이 일어나는지 알아보고자 한다.

테스트한 환경은 다음과 같다.

* x86 CPU
* gcc 3.3.2

테스트 코드는 다음과 같다.

void func1()
{
}

void func2( int x )
{
x = 0;
}

int func3()
{
return 7;
}

void func4()
{
int y;
y = 7;
}

void func5( int x1, int x2, int x3 )
{
int y1;
int y2;
int y3;
y1 = 1;
y2 = 2;
y3 = 3;
x1 = 4;
x2 = 5;
x3 = 6;
}

int main()
{
return 0;
}



위 코드는 다음과 같이 컴파일된다.
$ gcc -save-temps a.c
$ cat a.s

-save-temps 를 옵션으로 주면, 중간 파일인 전처리된 파일(.i)과 어셈블코드(.s)가 생성이 된다.

func1: 아무것도 하지 않는 함수로 함수의 가장 기본적인 구조에 대해서 파악하고자한다.
func2: 한 개의 인자를 받을 때 어떤 방법으로 처리되는지 이해한다.
func3: 한 개의 값을 되돌릴 때 어떤 방법으로 처리되는지 이해한다.
func4: 변수가 선언될 때 어떤 방법으로 처리되는지 이해한다.
func5: 여러개의 변수를 받고, 여러개의 변수가 선언될 때 어떻게 처리되는지 이해한다.

void func1()
{
}

5 func1:
6 pushl %ebp
7 movl %esp, %ebp
8 popl %ebp
9 ret


5 번줄은 label이라고하여 컴파일되는 코드의 주소값으로 사용된다.
6 번줄은 %ebp 라는 프레임 포인터(frame pointer, base pointer)로 사용되는 레지스터를 스택에 보관하게 되며, 모든 함수의 시작 부분에서 항상 일어나게 된다. 디스어셈블 코드가 깔끔하게 되는 중요한 부분인데, 디스어셈블을 교란하거나 조금더 최적화를 하기 위해서 -fomit-frame-pointer 라는 옵션을 넣어 주면 이렇게 생긴 코드가 없어진다.
7 번줄은 스택포인터(stack pointer)를 프레임 포인터에 대입하여, 이 함수에서의 스택 베이스를 설정하는 부분이된다.
8 번줄은 함수를 나가기전 프레임 포인터를 복원하는 것이다.
9 번줄은 호출한 곳으로 돌아가는 코드이다.


mov 등 gnu assembler의 모든 명령의 방향은 첫째 인자를 소스로 둘째 인자를 대상으로 한다. MS의 어셈블코드는 반대로 두번째 인자를 소스로 첫째 인자를 대상으로 표시된다.

void func2( int x )
{
x = 0;
}

13 func2:
14 pushl %ebp
15 movl %esp, %ebp
16 movl $0, 8(%ebp)
17 popl %ebp
18 ret

16 번줄은 상수($) 0 을 프레임 포인터(%ebp)에 8 을 더한 곳(괄호로 감싸면 포인터가 가리키는 곳을 의미한다)에 넣으라는 의미이다.


나머지는 func1과 동일하며, 소스도 거의 비슷하다. 왜 8을 더하는지는 func5를 다룰때 다시 알아보자.

int func3()
{
return 7;
}


23 pushl %ebp
24 movl %esp, %ebp
25 movl $7, %eax
26 popl %ebp
27 ret
25 번줄은 상수 7을 %eax 레지스터에 대입하라는 의미이다.


리턴이라는 것이 단지 %eax에 대입하는 것이다. 그렇다면 호출한 쪽에서는 %eax에 담겨있기를 기대하고 코드만 작성하면 되는 것이다. 만약 int보다 메모리를 더 차지하는 것들은 어떻게 해야 넘길 수 있을까? double을 되돌릴 수도 있고 struct 를 되돌릴 수도 있다. 궁금한 문제는 다음에 다시 다뤄보기로 하자.

void func4()
{
int y;
y = 7;
}

31 func4:
32 pushl %ebp
33 movl %esp, %ebp
34 subl $4, %esp
35 movl $7, -4(%ebp)
36 leave
37 ret


34번째 줄에서는 자동 변수(y)가 하나 추가되면서, 스택 포인터(%esp)값을 그 크기인 상수 4 만큼 빼는(substract) 코드가 들어간다. 스택은 무언가를 채울때 그 포인터 값이 작아지는 것으로 이해되고 있다. 즉 스택포인터는 큰 값으로 시작하여 그 값이 줄어 들면서 뭔가를 채우는 용도로 사용되는데, 이처럼 자동 변수를 취급할 때는 스택 포인터를 줄여줌으로써 강제로 스택의 공간을 확보하는 코드가 생성이 된다.

%esp에 대한 이해가 처음 이해가 어려운 부분은 스택에 뭔가를 집어 넣을 때,
(1) 스택 포인터가 줄어든다음 스택포인터가 가리키는 곳에 넣느냐,
(2) 스택 포인터가 가리키는 곳에 넣은 뒤에 스택포인터가 줄어 드느냐
라는 가장 기본적인 행동에 대한 이해가 있어야한다. (1)번이 정답이다.

35번째 줄에서는 상수를 %ebp에서 -4를 한 뒤 가리키는 곳(괄호)에 넣으라는 의미인데, 즉, 방금 할당이 된 스택 영역에 7을 집어 넣으라는 의미를 가지게 된다.

func3과 func4의 가장 큰 차이는 %esp 값이 변했느냐인데, %ebp에 복사된 %esp 가 바뀌었으면, %esp는 %ebp값으로 다시 원상 복원해야한다. 그리고 %ebp는 스택에서 끄집어내어 (26번 줄의 popl 처럼) 원상복원해야한다.
함수에서 가장 빈번하게 반복되는 것은 이와 같이 함수에 진입할 때 %ebp를 설정하는 것과 %esp 를 자동변수 영역만큼 할당하는 것 그리고 %esp와 %ebp 값을 원상 복구한다음 돌아가는 일이다.
들어올때 반복되는 일을 간단히 처리하기 위해 enter 라는 명령이 있고, 나갈 때 %esp와 %ebp 값을 복원하기 위한 방법으로 leave라는 기계어 명령이 있다.
실제 36번째 줄의 leave는 32, 33 줄의 대응인
movl %ebp, %esp
popl %ebp

와 같은 코드이다.

void func5( int x1, int x2, int x3 )
{
int y1;
int y2;
int y3;
y1 = 1;
y2 = 2;
y3 = 3;
x1 = 4;
x2 = 5;
x3 = 6;
}


41 func5:
42 pushl %ebp
43 movl %esp, %ebp
44 subl $12, %esp
45 movl $1, -4(%ebp)
46 movl $2, -8(%ebp)
47 movl $3, -12(%ebp)
48 movl $4, 8(%ebp)
49 movl $5, 12(%ebp)
50 movl $6, 16(%ebp)
51 leave
52 ret


func5는 앞으로 나올 코드 분석에 대한 훈련을 하기 위한 종합훈련 코드라 생각하면 좋을 듯하다. 이것은 하나의 함수에서 프레임포인터를 기준으로하는 인자와 자동변수에 대한 접근 방법을 연습하기 위한 코드이다.

y1이 가장 먼저 생성되었으므로 프레임 포인터에 가장 가까이 있으며, 가장 최상위에 나중에 만들어진 y3가 존재한다. 이들은 4 byte짜리 공간을 가지고 있으므로 %ebp에 대하여 상대적으로 계산하면 (이를 변위(displacement)라고 한다.) -4, -8, -12로 접근하게 된다.
0 의 위치는 계속 보아왔던 코드에서 알수 있듯이, 함수를 호출하기 전 %ebp이 저장되어 있다. 즉 이 함수를 호출한 프레임 포인터가 저장되어 있다. (잘 생각해보시라. %ebp는 체인으로 가리키는곳의 가리키는 곳의... 로 올라가면 갈 수록 프레임포인터들을 거슬러 올라가는 셈이 된다. 디버깅에서 호출 스택을 볼 수 있는 것도 이와 같은 연산으로 가능하다.)
자, 그러면 func2에서도 궁금했던, 인자를 접근하는 방법은 %ebp에 양수를 더함으로써, 즉 함수를 부르기 이전부터 스택에 존재했던 위치에 접근하여 가능하다.
0 위치는 이전 함수의 프레임포인터가 저장되어 있고, 함수를 호출하면 돌아갈 위치(instruction pointer)가 일단 스택에 저장되고 점프해들어오는 것이므로 +4 위치에는 돌아갈 함수의 리턴 포인터가 들어 있을 것이다. 그렇다면, +8의 위치에는 인자중 하나가 들어 있을텐데, 맨처음 인자 아니면 맨 나중 인자가 들어 있을 것이다.
코드를 보고 확인해보면? x1, 맨 처음 인자가 바로 나타난다.
즉, 스택에는

y3 : -12
y2 : -8
y1 : -4
이전 프레임포인터 : 0
돌아갈 위치 : 4
x1 : 8
x2 : 12
x3 : 16

순서로 들어 있다. y3가 가장 나중에 들어간 것이며, x3가 가장 먼저 들어간 것이다. 즉, 함수를 호출할 때 맨처음 스택에 들어가는 것은 인자의 맨 마지막 값이된다.

하나만 외우자, 8(%ebp)는 처음 인자를 나타낸다.
스택에 존재하는 아름다움을 감상하시라. 돌아갈 위치에 관하여는 그 번지가 어떤 함수 대역에 존재하는지 알 수 있는 방법이 있다면, 나중에 디버깅 용도로 사용할 수 있게 된다.

다음에는 int 외의 변수에 대해서 입출력이 어떻게 이루어지는지 알아보고자 한다.

 

 

이번에도 지난번 글과 같이 간단한 함수의 디스어셈블을 통하여 함수호출시에 일어나는 일들을 살펴보고자 한다.

참고로 이와 같은 학문(?)의 범주는 다음과 같은 위치에 있다.

* C 언어 표준 스펙
* intel x86 CPU의 System V Application Binary Interface
* gcc의 구현

System V ABI 를 검색어로 찾아 보면 원하는 글을 찾을 수 있을 것이다.

void func6( char x1, char x2, char x3)
{
x1 = 1;
x2 = 2;
x3 = 3;
}

struct _p
{
int a1;
int a2;
int a3;
};

void func7( struct _p p1, int p2 )
{
p1.a1 = 1;
p1.a2 = 2;
p1.a3 = 3;
p2 = 4;
}

struct _p func8(void)
{
struct _p y1;
y1.a1 = 1;
y1.a2 = 2;
y1.a3 = 3;
return y1;
}

int main()
{
return 0;
}



위 코드를 다음과 같이 컴파일한다.

$ gcc -save-temps b.c

이렇게 하고 나면 b.s 라는 파일이 생성되며 그 파일을 분석한다.

void func6( char x1, char x2, char x3)
{
x1 = 1;
x2 = 2;
x3 = 3;
}
5 func6:
6 pushl %ebp
7 movl %esp, %ebp
8 subl $4, %esp
9 movl 8(%ebp), %eax
10 movl 12(%ebp), %edx
11 movl 16(%ebp), %ecx
12 movb %al, -1(%ebp)
13 movb %dl, -2(%ebp)
14 movb %cl, -3(%ebp)
15 movb $1, -1(%ebp)
16 movb $2, -2(%ebp)
17 movb $3, -3(%ebp)
18 leave
19 ret


인자가 char 라고해서 넘기는 값도 8 bit라고 생각하면 오산이되기 쉽다. gcc만 아니라 대개의 구현에서 char 를 char 크기만큼 넘기는 것 보다 CPU word 크기인 int로 변환된 크기를 넘기며, 위 코드에서는
8(%ebp), 12(%ebp) 와 같이 인자의 위치가 4 byte 씩 떨어져서 접근하는 것을 보아 알 수 있다.
그리고 신기한 것은

8 subl $4, %esp

에서 알 수 있듯이 만들지도 않은 자동변수가 있듯이 스택을 4 byte 확보를 하고, 그곳에 일단 인자들의 사본을 만들어 놓고 연산을 수행함을 알 수 있다.

만약 gcc를 쓴다면, 인자를 char로 넘기는 것보다 int로 넘기는 것이 오히려 연산 수가 줄어드는 것을 알 수 있다.

void func7( struct _p p1, int p2 )
{
p1.a1 = 1;
p1.a2 = 2;
p1.a3 = 3;
p2 = 4;
}
23 func7:
24 pushl %ebp
25 movl %esp, %ebp
26 movl $1, 8(%ebp)
27 movl $2, 12(%ebp)
28 movl $3, 16(%ebp)
29 movl $4, 20(%ebp)
30 popl %ebp
31 ret


struct 로 넘기는 것은 스택에 상당히 많은 양이 들어가게 된다. 단지 두개의 변수를 넘기지만, 스택접근하는 것을 보면 struct의 각 변수들을 모두 직접 접근하게 되며, 두번째 인자가 20(%ebp)임을 확인할 수 있다.

struct _p func8(void)
{
struct _p y1;
y1.a1 = 1;
y1.a2 = 2;
y1.a3 = 3;
return y1;
}
35 func8:
36 pushl %ebp
37 movl %esp, %ebp
38 subl $24, %esp
39 movl 8(%ebp), %edx
40 movl $1, -24(%ebp)
41 movl $2, -20(%ebp)
42 movl $3, -16(%ebp)
43 movl -24(%ebp), %eax
44 movl %eax, (%edx)
45 movl -20(%ebp), %eax
46 movl %eax, 4(%edx)
47 movl -16(%ebp), %eax
48 movl %eax, 8(%edx)
49 movl %edx, %eax
50 leave
51 ret $4


struct 입력, 출력의 재미는 여기에 있다. 지난 강좌에서 int의 리턴값은 스택을 사용하지 않고 %eax를 이용함을 보았는데, 그렇다면 32bit 레지스터의 크기를 넘는 구조체는 어떻게 넘어갈까였다.

맨 마지막을 먼저 보자
49 movl %edx, %eax
50 leave
51 ret $4
%edx를 %eax에 넘기는 것으로 보아 이번에도 %eax에 뭔가가 담기고 있다. 그리고 특이한 것은 ret 뒤에 $4가 있다는 것이다. 이것은 ret는 현재의 서브루틴을 탈출할때 stack pointer를 그만큼 더하라는 얘기가 된다. 즉 stack pointer에 뭔가가 더해진다는 것은 그 영역을 해제한다는 뜻인데, 여기에서는 인자의 크기중 4 byte를 제거하는데 사용된다. 왜 이런 코드가 있을까.

38 번째 줄은 %esp에서 24 byte를 빼는 (stubstrct) 행위로, y1 struct 크기만큼을 스택에 잡는 코드이며,
39 번째 줄은 우리가 지난 강좌에서 외웠던 8(%ebp)라는 첫번째 인자에 해당하는 값을 %edx에 복사하고
40에서 42는 C 코드 대로 y1 struct 값을 채우는 일을 하고 있으며,
43 movl -24(%ebp), %eax
44 movl %eax, (%edx)

45 movl -20(%ebp), %eax
46 movl %eax, 4(%edx)

47 movl -16(%ebp), %eax
48 movl %eax, 8(%edx)

이 코드들은 방금 채워넣은 y1의 각각의 값을 %edx가 가리키는 곳에 하나씩 복사하는 것을 한다. 이것이 바로 리턴하기 직전에 이 함수를 부른 녀석에게 struct 의 내용을 넘기는 곳인데, %edx가 마치 임시로 생성되어 있는 리턴용 struct의 위치인듯한다. 함수 호출시에 이름없는 임시 값으로 알려져 있는 것이다. 그 값이 바로 8(%ebp), 즉 첫번째 인자가 위치해야할 곳에 있는 것이다.

49 movl %edx, %eax
처음 저장했던 %edx를 다시 돌려주기 위해 복사한다.

중요한 것은 return 값이 int 혹은 그 이하 크기를 돌리는 함수들은 인자의 순서가 그대로 넘어 오며, %eax에는 그 돌리는 값이 들어가지만, struct에 대해서는 인자들의 맨 앞에 가상의 포인터가 하나 넘어오게 되고 그 다음부터 인자의 순서가 그대로 넘어오고 %eax에는 첫번째 인자로 넘겨줬던 값이 다시 돌아간다는 것이다.
즉, return 값이 struct인경우 스택의 첫째 값은 특별 취급된다.

그러면 return 할 때 스택포인터에 4를 더함으로써, 뭔가 해제하는 것은 아마도 처음인자를 제거하는데 사용하는 것 같다. 이것은 실제 func8을 호출하는 코드를 보면 자세히 알 수 있을 것이다.



자, 여기까지 분석한 것을 가지고 재밌는 장난을 해보자.

#include <stdio.h>

void func_a( int x, int y, int z )
{
printf("%d %d %d\n", x, y, z );
}

struct _p
{
int a1;
int a2;
int a3;
};

typedef void (*func_p)( struct _p w );

int main()
{
struct _p x;
func_p func_b;
func_b = (func_p) & func_a;

x.a1 = 1;
x.a2 = 2;
x.a3 = 3;

func_b( x );
return 0;
}

$ ./a.out
1 2 3

 

위 코드는 struct 를 넘기는 방식에 대한 이해가 있는 상황에서 만들어진 코드인데, func_a의 프로토타잎을 강제로 struct로 만든뒤 넘겨도 예상한 결과가 나온다는 예제이다.

이 글은 스프링노트에서 작성되었습니다.

.

'Computer > C' 카테고리의 다른 글

소켓 프로그래밍  (0) 2012.08.10
소켓 플래쉬 xml  (0) 2012.08.10
fastcall  (0) 2012.08.10