티스토리 뷰

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 외의 변수에 대해서 입출력이 어떻게 이루어지는지 알아보고자 한다.

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함