어셈블리어로 보는 스택 메모리 변화
들어가는 말
소스 코드 수준에서 함수 호출은 단순해 보인다.
인자를 넘기고, 함수를 실행하고, 결과를 받아 오는 흐름처럼 보인다.
하지만 그 흐름이 실제 하드웨어에서 어떻게 실행되는지는 소스 코드만 봐서는 잘 드러나지 않는다.
컴파일러가 만든 어셈블리 코드를 따라가 보면, 함수 호출 과정에서 값이 레지스터와 메모리 위에서 어떻게 오가는지 더 구체적으로 확인할 수 있다.
스택 메모리는 함수 호출을 이해할 때 특히 중요하다.
지역 변수는 어디에 어떻게 저장되는지, 함수에 어떻게 인자를 전달하는지, return 후에는 어떻게 원래 위치로 돌아오는지 모두 스택 메모리와 관련이 있다.
스택 메모리는 자료구조의 스택과 이름이 같은데, 값을 LIFO(Last In, First Out) 방식으로 다루기 때문이다.
함수가 호출되면 그 함수가 사용하는 정보가 스택에 쌓이고, 함수가 종료되면 가장 나중에 쌓인 정보부터 사라진다.
재귀 호출에서 함수가 깊게 호출될수록 각 호출에 필요한 정보가 스택에 계속 쌓이고, 종료 조건을 만나면 가장 마지막 호출부터 차례대로 돌아오는 것도 이 구조와 연결되어 있다.
겉으로는 함수 호출 한 줄이지만, 실제로는 스택 위에서 많은 일이 차례대로 일어난다.
이 글에서는 간단한 C 코드를 32비트 x86 어셈블리어로 바꾸어 보면서, 함수 호출에 따라 스택 메모리가 어떻게 변하는지 살펴본다.
분석할 C 코드
int add(const int x, const int y)
{
int result = x + y;
return result;
}
int main(void)
{
int a = 1;
int b = 2;
int result = add(a,b);
return result;
}
명령어 주소가 포함된 어셈블리 코드 생성
어셈블리(.s) 파일까지만 생성하면 링크 이후 확정되는 최종 가상 주소를 확인할 수 없다.
따라서 실행 파일을 만든 뒤, 그 실행 파일을 디스어셈블해서 명령어 주소가 포함된 코드를 확인한다.
gcc -std=c89 -Wall -Wextra -pedantic-errors \
-O0 \
-m32 \
-no-pie \
-fno-omit-frame-pointer \
-fno-stack-protector \
-fno-pie \
-fno-asynchronous-unwind-tables \
-mpreferred-stack-boundary=2 \
main.c -o main
objdump -d -Mintel main
각 옵션의 의미는 다음과 같다.
-std=c89: C89 표준 문법으로 컴파일한다.-Wall -Wextra: 버그로 이어지기 쉬운 의심스러운 코드 패턴에 대한 경고를 추가로 켠다.-Wall은 대표적인 경고 묶음이고,-Wextra는 그보다 더 엄격한 일부 경고를 추가한다.-pedantic-errors: C 표준에서 벗어난 문법을 경고가 아니라 오류로 처리한다.-O0: 최적화를 끈다.-m32: 32비트 x86 코드로 컴파일한다.-no-pie: 링크 단계에서 위치 독립 실행 파일 생성을 끈다.-fno-omit-frame-pointer: 프레임 포인터를 생략하지 않는다.-fno-stack-protector: 스택 보호 코드를 넣지 않는다.-fno-pie: 컴파일 단계에서 위치 독립 코드 생성을 끈다.-fno-asynchronous-unwind-tables: 예외 처리나 디버깅용 unwind table 생성을 끈다.-mpreferred-stack-boundary=2: 스택을2^2, 즉 4바이트 경계에 맞추도록 한다. 이 옵션을 주지 않으면 GCC의 기본 스택 정렬 경계는 보통2^4, 즉 16바이트다. 이 글에서는 최적화를 끈 상태로 어셈블리 코드를 생성하기 때문에,main이나add함수에서 실제 지역 변수에 필요한 크기보다 더 많은 스택 공간이 확보되어 빈 공간이 생길 수 있다. 이런 빈 공간은 스택 변화를 처음 이해할 때 방해가 되므로, 설명을 단순하게 하기 위해 4바이트 경계로 맞춘다.objdump -d -Mintel main: 실행 파일main을 Intel 문법으로 디스어셈블한다.
위 명령어로 확인한 디스어셈블 결과에서 핵심만 추리면 다음과 같다.
0x08049156 <add>:
0x08049156: push ebp
0x08049157: mov ebp, esp
0x08049159: sub esp, 0x4
0x0804915c: mov edx, DWORD PTR [ebp+0x8]
0x0804915f: mov eax, DWORD PTR [ebp+0xc]
0x08049162: add eax, edx
0x08049164: mov DWORD PTR [ebp-0x4], eax
0x08049167: mov eax, DWORD PTR [ebp-0x4]
0x0804916a: leave
0x0804916b: ret
0x0804916c <main>:
0x0804916c: push ebp
0x0804916d: mov ebp, esp
0x0804916f: sub esp, 0xc
0x08049172: mov DWORD PTR [ebp-0x4], 0x1
0x08049179: mov DWORD PTR [ebp-0x8], 0x2
0x08049180: push DWORD PTR [ebp-0x8]
0x08049183: push DWORD PTR [ebp-0x4]
0x08049186: call 0x08049156 <add>
0x0804918b: add esp, 0x8
0x0804918e: mov DWORD PTR [ebp-0xc], eax
0x08049191: mov eax, DWORD PTR [ebp-0xc]
0x08049194: leave
0x08049195: ret
먼저 알아둘 것
esp(Extended Stack Pointer): 현재 스택의 top 주소를 저장하는 레지스터. 스택 포인터 역할을 하며,push,pop,sub esp,add esp같은 명령이 실행될 때마다 저장된 주소값이 바뀐다.ebp(Extended Base Pointer): 현재 함수의 스택 프레임 기준점을 저장하는 레지스터. 기준점 역할을 하며, 함수 실행 중 매개 변수와 지역 변수를 찾는 기준으로 사용된다.- 스택 프레임(Stack Frame): 함수 하나가 실행되는 동안 스택 메모리에 확보되는 작업 공간.
main함수가 실행되면main의 스택 프레임이 만들어지고,main에서add함수를 호출하면 그 위에add의 스택 프레임이 새로 만들어진다.add가 종료되면add의 스택 프레임이 정리되고, 실행 흐름은 다시main의 스택 프레임으로 돌아온다. - 주소 방향: x86 스택은 일반적으로 높은 주소에서 낮은 주소 방향으로 자란다. 따라서
push를 하면esp값은 감소한다.
함수 호출에 따른 스택 변화
main 스택 프레임 생성
0x0804916c <main>:
0x0804916c: push ebp
현재 함수를 호출한 쪽의 스택 프레임 기준점인 ebp 값을 스택에 저장한다.
C 소스 코드 관점에서는 main 함수가 프로그램의 시작점이다.
하지만 실제 실행 과정에서는 운영체제가 프로그램을 메모리에 올린 뒤, 준비 코드가 먼저 실행되고 그 코드가 main을 호출한다.
따라서 main이 종료될 때 이전 스택 프레임을 복구하려면 기존 ebp 값을 보관해 두어야 한다.
뒤에서 add 함수가 호출될 때 이와 같은 흐름이 한 번 더 나오므로, 그때 이 설명을 다시 읽으면 이해하기 쉬울 것이다.
0x0804916c <main>:
0x0804916d: mov ebp, esp
Intel 문법에서 mov는 왼쪽이 목적지(destination), 오른쪽이 원본(source)이다.
따라서 mov ebp, esp는 esp 값을 ebp에 복사해서, ebp가 esp와 같은 주소를 가리키게 한다.
이 시점부터 ebp는 main 스택 프레임의 기준점 역할을 한다.
0x0804916c <main>:
0x0804916f: sub esp, 0xc
main에서 사용할 지역 변수 공간 12바이트를 확보한다.
x86 스택은 낮은 주소 방향으로 자라기 때문에, 공간을 확보할 때는 esp 값을 증가시키는 add가 아니라 감소시키는 sub를 사용한다.
지금까지의 스택 메모리를 아스키(ASCII) 그림으로 표현하면 다음과 같다.
이후 스택 그림은 모두 4바이트(32비트) 묶음 하나를 한 칸으로 표시한다.
그림에서 위쪽은 높은 주소, 아래쪽은 낮은 주소다.
x86 스택은 낮은 주소 방향으로 자라므로, 새 값이 스택에 들어가면 그림의 아래쪽으로 추가된다.
아스키 그림 속에 한글이 있으면 세로선 정렬이 어긋나서, 칸 안의 설명은 영어로 적었다.
high address
^
| +------------+----------------------------------+------------------+
| | 0xffffc95c | 0xf7dabc75 (return address) | [ebp + 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc958 | 0x00000000 (main caller ebp) | [ebp] |
| +------------+----------------------------------+------------------+
| | 0xffffc954 | local var slot | [ebp - 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc950 | local var slot | [ebp - 8] |
| +------------+----------------------------------+------------------+
| | 0xffffc94c | local var slot | [ebp - 12] [esp] |
| +------------+----------------------------------+------------------+
|
v
low address
main 지역 변수 저장
이제 확보한 지역 변수 공간에 실제 값 1과 2를 저장한다.
0x0804916c <main>:
0x08049172: mov DWORD PTR [ebp-0x4], 0x1
0x08049179: mov DWORD PTR [ebp-0x8], 0x2
DWORD PTR [ebp-0x4]를 살펴보면, DWORD는 4바이트 크기를, PTR은 뒤의 대괄호 안 값을 주소로 해석하라는 지시어다.
즉 ebp - 0x4 주소에서 시작하는 4바이트 메모리 공간을 가리킨다.
따라서 첫 번째 명령은 그 위치에 1을, 두 번째 명령은 ebp - 0x8에서 시작하는 4바이트 공간에 2를 저장한다.
위 두 명령이 실행된 뒤의 스택 메모리는 다음과 같다.
high address
^
| +------------+----------------------------------+------------------+
| | 0xffffc95c | 0xf7dabc75 (return address) | [ebp + 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc958 | 0x00000000 (main caller ebp) | [ebp] |
| +------------+----------------------------------+------------------+
| | 0xffffc954 | 0x00000001 (a = 1) | [ebp - 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc950 | 0x00000002 (b = 2) | [ebp - 8] |
| +------------+----------------------------------+------------------+
| | 0xffffc94c | local var slot | [ebp - 12] [esp] |
| +------------+----------------------------------+------------------+
|
v
low address
add 함수 호출 준비
지역 변수에 값을 저장했으니, 이제 add(a, b) 호출에 사용할 인자를 스택에 넣는다.
0x0804916c <main>:
0x08049180: push DWORD PTR [ebp-0x8]
0x08049183: push DWORD PTR [ebp-0x4]
push 명령은 피연산자의 값을 스택에 올린다.
피연산자가 DWORD PTR [addr] 형태이면 해당 주소에서 4바이트를 읽어 스택에 올린다.
x86 cdecl 호출 규약에서는 인자를 오른쪽에서 왼쪽 순서로 push하므로,
첫 번째 push가 올리는 ebp - 0x8의 값 2는 add의 두 번째 인자 y에, 두 번째 push가 올리는 ebp - 0x4의 값 1은 첫 번째 인자 x에 해당한다.
high address
^
| +------------+----------------------------------+------------------+
| | 0xffffc95c | 0xf7dabc75 (return address) | [ebp + 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc958 | 0x00000000 (main caller ebp) | [ebp] |
| +------------+----------------------------------+------------------+
| | 0xffffc954 | 0x00000001 (a = 1) | [ebp - 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc950 | 0x00000002 (b = 2) | [ebp - 8] |
| +------------+----------------------------------+------------------+
| | 0xffffc94c | local var slot | [ebp - 12] |
| +------------+----------------------------------+------------------+
| | 0xffffc948 | 0x00000002 (add arg y) | [ebp - 16] |
| +------------+----------------------------------+------------------+
| | 0xffffc944 | 0x00000001 (add arg x) | [ebp - 20] [esp] |
| +------------+----------------------------------+------------------+
|
v
low address
call add 실행
add 함수에 넘길 인자를 스택에 넣었으니, 이제 실제로 add 함수를 호출한다.
0x0804916c <main>:
0x08049186: call 0x08049156 <add>
0x0804918b: add esp, 0x8
call은 개념적으로 풀어 쓰면 다음 두 가지 일을 한다.
push 0x0804918b
jmp 0x08049156 <add>
먼저 call은 add 함수가 끝난 뒤 다시 돌아올 명령어 주소를 스택에 저장한다.
여기서 돌아올 명령어 주소는 call이 있는 0x08049186이 아니라, 그 다음 명령어가 있는 0x0804918b다.
0x08049186으로 돌아오면 같은 call 명령을 다시 실행해 무한 루프에 빠진다.
그 다음 call은 실행 흐름을 add 함수의 시작 주소인 0x08049156으로 옮긴다.
high address
^
| +------------+----------------------------------+------------------+
| | 0xffffc95c | 0xf7dabc75 (return address) | [ebp + 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc958 | 0x00000000 (main caller ebp) | [ebp] |
| +------------+----------------------------------+------------------+
| | 0xffffc954 | 0x00000001 (a = 1) | [ebp - 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc950 | 0x00000002 (b = 2) | [ebp - 8] |
| +------------+----------------------------------+------------------+
| | 0xffffc94c | local var slot | [ebp - 12] |
| +------------+----------------------------------+------------------+
| | 0xffffc948 | 0x00000002 (add arg y) | [ebp - 16] |
| +------------+----------------------------------+------------------+
| | 0xffffc944 | 0x00000001 (add arg x) | [ebp - 20] |
| +------------+----------------------------------+------------------+
| | 0xffffc940 | 0x0804918b (return address) | [ebp - 24] [esp] |
| +------------+----------------------------------+------------------+
|
v
low address
add 스택 프레임 생성
call add에 의해 실행 흐름이 add 함수의 시작 주소 0x08049156으로 이동했다.
이제부터는 add 함수의 시작 부분에 있는 명령어들이 실행되면서 add의 스택 프레임이 만들어진다.
0x08049156 <add>:
0x08049156: push ebp
0x08049157: mov ebp, esp
0x08049159: sub esp, 0x4
먼저 push ebp는 현재 ebp 값을 스택에 저장한다.
이 명령어 실행 전 ebp는 main 스택 프레임의 기준점이므로, add 함수가 끝난 뒤 ebp가 다시 main의 기준점으로 돌아가기 위해 저장해 둔다.
그 다음 mov ebp, esp가 실행되면, esp 값이 ebp에 복사되어 ebp와 esp가 같아진다.
이제 ebp는 add 스택 프레임의 기준점이 된다.
마지막으로 sub esp, 0x4는 add 함수의 지역 변수 result를 저장할 4바이트 공간을 확보한다.
high address
^
| +------------+----------------------------------+------------------+
| | 0xffffc95c | 0xf7dabc75 (return address) | [ebp + 32] |
| +------------+----------------------------------+------------------+
| | 0xffffc958 | 0x00000000 (main caller ebp) | [ebp + 28] |
| +------------+----------------------------------+------------------+
| | 0xffffc954 | 0x00000001 (a = 1) | [ebp + 24] |
| +------------+----------------------------------+------------------+
| | 0xffffc950 | 0x00000002 (b = 2) | [ebp + 20] |
| +------------+----------------------------------+------------------+
| | 0xffffc94c | local var slot | [ebp + 16] |
| +------------+----------------------------------+------------------+
| | 0xffffc948 | 0x00000002 (add arg y) | [ebp + 12] |
| +------------+----------------------------------+------------------+
| | 0xffffc944 | 0x00000001 (add arg x) | [ebp + 8] |
| +------------+----------------------------------+------------------+
| | 0xffffc940 | 0x0804918b (return address) | [ebp + 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc93c | 0xffffc958 (saved main ebp) | [ebp] |
| +------------+----------------------------------+------------------+
| | 0xffffc938 | local var slot | [ebp - 4] [esp] |
| +------------+----------------------------------+------------------+
|
v
low address
add 함수 계산과 반환값 준비
다음 명령어들은 C 코드의 int result = x + y;와 return result;를 실행하는 부분이다.
0x08049156 <add>:
0x0804915c: mov edx, DWORD PTR [ebp+0x8]
0x0804915f: mov eax, DWORD PTR [ebp+0xc]
0x08049162: add eax, edx
0x08049164: mov DWORD PTR [ebp-0x4], eax
0x08049167: mov eax, DWORD PTR [ebp-0x4]
스택 프레임 기준으로 ebp + 0x8은 첫 번째 인자 x(값: 1), ebp + 0xc는 두 번째 인자 y(값: 2)가 저장된 위치다.
먼저 1를 edx에, 2를 eax에 각각 읽어온다.
이어서 add eax, edx로 두 값을 더하면 eax에는 3이 남는다.
eax에 들어 있는 값 3을 ebp - 0x4 주소에서 시작하는 4바이트 공간, 즉 지역 변수 result의 공간에 저장한다.
이후 return result;를 위해 result 값을 eax에 넣는다.
반환값을 eax에 저장하는 이유는 x86 호출 규약에서 정수형 반환값을 eax로 전달하기 때문이다.
이후 실행 흐름이 main으로 돌아오면, main은 eax를 통해 add 함수의 반환값을 사용할 수 있다.
high address
^
| +------------+----------------------------------+------------------+
| | 0xffffc95c | 0xf7dabc75 (return address) | [ebp + 32] |
| +------------+----------------------------------+------------------+
| | 0xffffc958 | 0x00000000 (main caller ebp) | [ebp + 28] |
| +------------+----------------------------------+------------------+
| | 0xffffc954 | 0x00000001 (a = 1) | [ebp + 24] |
| +------------+----------------------------------+------------------+
| | 0xffffc950 | 0x00000002 (b = 2) | [ebp + 20] |
| +------------+----------------------------------+------------------+
| | 0xffffc94c | local var slot | [ebp + 16] |
| +------------+----------------------------------+------------------+
| | 0xffffc948 | 0x00000002 (add arg y) | [ebp + 12] |
| +------------+----------------------------------+------------------+
| | 0xffffc944 | 0x00000001 (add arg x) | [ebp + 8] |
| +------------+----------------------------------+------------------+
| | 0xffffc940 | 0x0804918b (return address) | [ebp + 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc93c | 0xffffc958 (saved main ebp) | [ebp] |
| +------------+----------------------------------+------------------+
| | 0xffffc938 | 0x00000003 (result = 3) | [ebp - 4] [esp] |
| +------------+----------------------------------+------------------+
|
v
low address
add 스택 프레임 정리
이 시점에서 add 함수의 계산은 끝났고, 반환값 3은 eax에 들어 있다.
이제 add 함수가 사용하던 스택 프레임을 정리한다.
0x08049156 <add>:
0x0804916a: leave
leave는 현재 함수인 add의 스택 프레임을 정리하는 명령이다.
leave의 동작을 풀어 쓰면 다음 두 명령과 같다.
mov esp, ebp
pop ebp
GCC에서 -mtune-ctrl=^use_leave 옵션을 추가하면 leave 대신 위 두 명령이 실제 디스어셈블 결과에 나타난다.
다만 컴파일러나 대상 플랫폼, 컴파일 옵션에 따라 mov esp, ebp 대신 add esp, N처럼 esp를 필요한 크기만큼 증가시켜 스택 공간을 정리하는 형태가 나올 수도 있다.
mov esp, ebp는 ebp 값을 esp에 복사해서 esp와 ebp가 같아지게 한다.
그 결과 esp가 add 스택 프레임의 기준점으로 이동하면서, add 함수의 지역 변수 공간은 더 이상 현재 스택 프레임의 일부로 사용되지 않는다.
이때 지역 변수 공간에 있던 값이 메모리에서 바로 지워지는 것이 아니다.
esp가 이동하면서 그 공간이 현재 사용 중인 스택 영역에서 벗어날 뿐이다.
이후 다른 함수 호출이나 지역 변수 저장 과정에서 같은 공간이 다시 사용되면, 기존 값은 새 값으로 덮어써진다.
그 다음 pop ebp는 현재 esp가 가리키는 스택 top의 값을 꺼내 ebp에 저장한다.
이때 꺼낸 값은 앞에서 저장해 두었던 main의 ebp 값(0xffffc958)이다.
이제 ebp는 다시 main 스택 프레임의 기준점을 가리킨다.
leave가 끝났지만 아직 ret은 실행되지 않았다.
따라서 이 시점에는 esp가 가리키는 위치에 앞에서 call이 저장해 둔 복귀 주소 0x0804918b가 있다.
high address
^
| +------------+----------------------------------+------------------+
| | 0xffffc95c | 0xf7dabc75 (return address) | [ebp + 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc958 | 0x00000000 (main caller ebp) | [ebp] |
| +------------+----------------------------------+------------------+
| | 0xffffc954 | 0x00000001 (a = 1) | [ebp - 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc950 | 0x00000002 (b = 2) | [ebp - 8] |
| +------------+----------------------------------+------------------+
| | 0xffffc94c | local var slot | [ebp - 12] |
| +------------+----------------------------------+------------------+
| | 0xffffc948 | 0x00000002 (add arg y) | [ebp - 16] |
| +------------+----------------------------------+------------------+
| | 0xffffc944 | 0x00000001 (add arg x) | [ebp - 20] |
| +------------+----------------------------------+------------------+
| | 0xffffc940 | 0x0804918b (return address) | [ebp - 24] [esp] |
| +------------+----------------------------------+------------------+
|
v
low address
ret으로 main 복귀
0x08049156 <add>:
0x0804916b: ret
ret은 스택 top에 있는 4바이트 복귀 주소를 꺼내(pop), 그 주소인 0x0804918b로 실행 흐름을 되돌린다.
main의 인자 정리와 반환값 저장
실행 흐름이 main으로 돌아왔으므로, add 호출을 마무리한다.
0x0804916c <main>:
0x0804918b: add esp, 0x8
x86 cdecl 호출 규약에서는 호출한 쪽이 인자 공간을 정리한다.
여기서는 main이 add를 호출했으므로, main으로 돌아온 뒤 add esp, 0x8로 인자 2개가 차지하던 공간을 정리한다.
0x0804916c <main>:
0x0804918e: mov DWORD PTR [ebp-0xc], eax
0x08049191: mov eax, DWORD PTR [ebp-0xc]
mov DWORD PTR [ebp-0xc], eax는 eax에 남아 있는 add의 반환값 3을 main의 지역 변수 result 공간에 저장한다.
이제 main의 result 값은 3이다.
그 다음 mov eax, DWORD PTR [ebp-0xc]는 main의 return result;를 위해 result 값을 다시 eax에 넣는다.
main도 함수이므로, 정수형 반환값을 eax로 전달한다.
high address
^
| +------------+----------------------------------+------------------+
| | 0xffffc95c | 0xf7dabc75 (return address) | [ebp + 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc958 | 0x00000000 (main caller ebp) | [ebp] |
| +------------+----------------------------------+------------------+
| | 0xffffc954 | 0x00000001 (a = 1) | [ebp - 4] |
| +------------+----------------------------------+------------------+
| | 0xffffc950 | 0x00000002 (b = 2) | [ebp - 8] |
| +------------+----------------------------------+------------------+
| | 0xffffc94c | 0x00000003 (result = 3) | [ebp - 12] [esp] |
| +------------+----------------------------------+------------------+
|
v
low address
main 함수 종료
이제 main은 반환값 3을 eax에 넣은 상태다.
마지막 두 명령어의 흐름은 앞에서 본 add 함수 종료 과정과 같다.
0x0804916c <main>:
0x08049194: leave
0x08049195: ret
C 소스 코드 관점에서는 main이 시작점이지만, 실제 실행 과정에서는 준비 코드가 main을 호출한다.
따라서 main의 종료 과정도 다른 함수와 같은 구조를 따른다.
leave로 esp와 ebp를 복구하고, ret으로 저장된 복귀 주소를 따라 실행 흐름을 옮긴다.