자자 스택프레임을 할 차례이다. 내가 적을게 많다.
스택은 abstract한 데이터 구조이다. 우리가 코드를 적고 함수를 만들면 그 함수들이 사용하는 데이터들의 일부는 스택이란 영역에서 저장된다고 볼 수 있다. 메모리가 정적으로 할당되는 경우 스택이 사용되고, 동적으로 할당될 경우 Heap이라는 다른 영역이 사용된다.
스택의 특징은 바로 LIFO, 또는 FILO 라는 데이터의 입출력 순서이다. 스택과 queue의 가장 큰 차이 중에 하나이기도 하다. 스택은 마지막에 들어온 데이터가 첫번째로 빠져나간다. 즉 쉽게 말하면 편의점 사장님이 싫어하는 알바생이다. 가장 최신의 사이다 맨 앞에 진열하는 그런 알바생 스타일이 바로 스택이고 LIFO이다.
우리가 코드를 작성하게 되면, 스택 영역은 어떤 것들을 담게 되는가.
-지역변수, 스택프레임, 함수의 복귀 주소, SEH(Structured Exception Handler) 등이 들어가게 되는데 여기서 중요한 건 바로 스택 프레임이다.
스택은 보통 직사각형으로 공간을 그리면 아랫쪽이 가장 높은 주소값, 윗쪽이 가장 낮은 주소값으로 표현한다. 위에 있다고 주소가 높은 것이 아니다. 스택은 '쌓는다'는 것인데, 즉 무언가를 계속 쌓고 싶으면 주소를 '감소시키거나 빼줘야'한다는 것을 잘 기억해야 한다.
함수 호출 정보를 스택에 저장하는 구조이다.
함수에 전달된 파라미터, 종료 후 복귀할 리턴 주소, 이전 함수의 스택 주소 등이 모여 하나의 스택 프레임
즉, 메인 함수 뿐만 아니라 우리가 선언한 함수는 호출될 때마다 하나의 스택 프레임을 가진다. 그리고 그 스택 프레임은 함수가 종료되면 다시 사라진다. 먼저 호출되는 함수부터 스택프레임이 만들어져 스택에 쌓인다.
이렇게 함수가 작성되었다면 스택프레임은
요렇게 만들어져서 쌓인다구여.
스택프레임이 생기는 원리를 이해하기 위해서는 먼저 레지스터를 알아둘 필요가 있다.
32비트 머신 기준으로는 EBP, ESP, EIP, EAX, EBX, ECX 가 되고
64비트 머신 기준으로는 RBP, RSP, RIP, RAX, RBX, RCX가 된다.
여기서는 32비트를 기준으로 설명하겠다. 왜냐, 쉬우니까
물론 이것 이외에도 EDX, ESI, EDI 등등 사용되는 것들은 훨씬 많다.
참고로 32비트에서 E는 Expanded, 기존의 16비트 데이터 규격을 확장해서 늘린 거라 E가 붙은 것이고, 64비트에서 R은 Re-expanded, 그니까 한번 더 늘렸다고 해서 R이 붙은 것이다. 컴과식 네이밍은 직관적이어서 약자들이 나오면 한번씩 찾아보면 이해나 암기에 되게 도움된다.
ESP: 스택 포인터라는 뜻이다. 쉽게 말하면, 현재의 스택 프레임의 윗쪽 틀이라고 보면 된다.
EBP: 베이스 포인터, 스택 프레임의 아랫쪽 틀을 담당하게 된다.
EIP: 인스트럭션 포인터, 지금 현재 실행되고 있는 명령어의 주소가 담겨있다고 보면 된다. 즉 eip는 스택 프레임의 현재, 그리고 다음 동작을 알려주는 거얌
EAX: 지금 당장 EAX, EBX, ECX의 모든 목적과 기능을 설명하긴 어렵다. 가장 직관적으로 이해할 수 있는 설명은, 스택이 필요한 연산을 하기 위해서 값들을 담아둘 수 있는 통이라고 생각하면 적당하겠다. EBX, ECX, EDX 등도 마찬가지. 이따가 return 값을 얘기할 땐 한마디 더 해야할 것이 있다.
스택프레임의 과정을 이해할 때 몇가지 어셈블리 명령어를 미리 알아두면 좋다.
일단 명령어의 구조는 아래와 같다.
Operation Code | Operand 1 Operand 2 ...
알았어 기다려바 한글로 해주께
명령 코드 + 인자 1 + 인자 2...(인자는 없을 수도 있고 기분 좋으면 3개까지도 돼)
PUSH | Operand
: 오퍼랜드를 스택의 최상단에 밀어넣어.
POP | Operpand
: 스택의 최상단에 있는 값을 오퍼랜드에 넣어서 치워줘.
MOV | OP1 OP2
: OP2에 있는 값을 OP1에 넣어라.
라고 하면 잘 못 알아듣겠더라. 개인적으로 더 좋아하는 설명은
: OP1을 OP2랑 같게 값을 복사해.
ADD | OP1 OP2
OP1+OP2를 계산해서 그 결과를 OP1에 저장해.
SUB | OP1 OP2
OP1-OP2를 계산해서 그 결과를 OP1에 저장해
INC | OP1
OP1의 값을 1 증가해
DEC | OP1
OP1의 값을 1 감소시켜
곱셉 나눗셈은 EAX 등을 같이 곁들여서 설명해야 해서 나중에.
CMP | OP1 OP2
: OP1과 OP2를 비교해서 서로 같은지 True/False로 답해
XOR | OP1 OP2
: OP1과 OP2를 XOR 연산해
나머지 논리연산들도 마찬가지로 알잘딱깔센
JMP | OP(주소)
지금 실행을 OP에 있는 주소로 점프하세여
JE | OP(주소)
앞선 연산의 결과가 Equal하면 그 주소로 점프해라.
점프는 진짜 이것말고도 엄청 다양하게 있어서 차라리 나중에 OP CODE 만 따로 모아서 포스팅 해보도록 하겠다. 이것보다 call이랑 return을 알려줘야해
CALL | Operand
: operand 함수를 호출해.
말로는 단순하지만 사실 call은 두개의 동작을 담고 있다.
1st. Push | return address
: 호출된 함수가 종료되었을 때 돌아갈 리턴 주소, call 다음의 명령어가 들어있는 주소를 스택에 push한다.
2nd. Jmp | operand
: operand에 있는 함수의 주소로 점프
RETN
: 함수를 종료해.
이것도 두가지 중요한 절차를 거친다.
1st. Pop | eip
: 이때쯤이면 스택 최상단에는 호출된 함수가 종료되고서 돌아갈 리턴주소가 담겨있다. 그 리턴주소는 call 다음 명령어가 있던 주소이다. 이걸 eip에 pop하면 retn 다음 실행은 그 다음 명령어가 되겠지
*실제로 pop eip를 실행하냐 하면 그것은 아니다. 마치 pop eip'처럼'보이는 것이다. eip는 굉장히 중요한 레지스터라 함부로 그 값이 변형되서는 안되기에 pop 같은 명령어로는 값이 바뀌지 않는다.
2nd. Jmp | eip
그 리턴주소가 있는 곳으로 가도록 eip로 점프하자~~
이 절차는 아래에서 스택 레이아웃의 전과정에 대한 설명을 훑고 나서 이해하는 게 훨씬 도움이 될 것이다.
사용할 코드는 다음과 같다.
#include "stdio.h"
long add(long a, long b)
{
long x = a, y = b;
return (x + y);
}
int main(int argc, char* argv[])
{
long a = 1, b = 2;
printf("%d\n", add(a, b));
return 0;
}
이 stackframe.exe를 디버깅하기 전에 visual studio의 최적화 옵션을 끄고 빌드해야 스택프레임의 적용을 쉽게 볼 수 있다.
x32dbg에 stackframe.exe를 로드하고 실행하면 나오는 화면은 다음과 같다.
F8로 빠르게 넘어가면은 Call 401020을 만날 수 있다.
F7로 들어가보자.
현재 eip가 401020을 가르키고 있다. 여기는 main함수가 시작되는 부분이다. 사용되는 환경에 따라서 조금씩 다를 수 있다.
main 함수의 전체 코드를 보여주고, 한줄씩 설명을 하겠다.
ebp를 스택 최상위에 집어넣는 것이다. push ebp가 실행되기 전 상태의 레지스터 상태를 보자.
지금의 ebp는 19FF28를 가르키고 있다. 이제 main 함수의 스택프레임이 시작되기 위해선 먼저 메인함수 스택프레임의 아래쪽 틀을 지정해줘야 한다.
push ebp는 그 베이스를 까는 동작이다. 정확히는 함수가 마쳤을 때 돌아가는 리턴주소를 스택에 집어넣는 것인데 이것은 이따가 우리가 add함수를 호출할 때 설명하겠다.
ebp를 esp와 같게 만들어주는 것이다.
언젠가 메인함수도 종료될 것이다. 메인함수마저 종료되면 돌아가야 할 주소는 어디일까. 그게 아까 ebp에 들어있었다. 그 중요한 리턴주소를 우리는 스택에 push 함으로써 백업해두었다. 이제는 스택 공간의 가장 위에 'main'이라는 함수의 베이스를 깔아주고 그 위에 메인함수 스택프레임을 만들어갈 것이다.
안전하게 이전 함수의 ebp를 스택에 백업해뒀으니 이제 ebp를 esp까지 끌어올려도 된다. 지금의 esp는 기존 스택프레임의 윗쪽 틀이었을거고 그 지점까지 베이스를 끌어올리는 것이다.
_*쉽게 생각하면 계단을 천천히 한칸씩 양발로 오르는 것과 같다. 지금 현재 오른발이 윗 계단에 있다고 하자. 새로 한 칸 오르기 위해서는 (새로 main을 시작하기 위해선) 왼발(ebp)을 오른발(esp) 높이까지 올려줘야 한다. 그럼 한층을 오른 것이다.
그리고 다음 계단으로 오르는 동작(함수의 실행)을 위해선 다시 오른발만 한칸 위로 올라가서 다리 사이의 공간을 만들겠지.
사진으로 보면 ebp가 esp까지 값이 '내려갔다'고 스택프레임 공간 상에선 끌어올려진 것이다. (19FF28->19FF24)
아까 스택프레임의 공간을 늘리려면 주소를 빼야한다는 것을 기억한다. 그리고 계단을 예시로 들었을 때, 다리가 움직이기 위한 공간을 만들기 위해선 오른발을 한계단 위로 올려야 한다는 것을 기억할 거다.
esp를 빼준다는 것은, esp가 스택 공간에서 ebp보다 더 윗쪽으로 올라간다는 것이다. 그럼 이제 아래쪽 틀과 위쪽 틀 사이의 간격이 생기니 이 프레임 사이에 무언가를 집어넣을 수 있는 것이다.
어릴 때 쓰던 수채화 물통을 생각하면 된다. 아코디언같이 생겨가지고 위로 잡아당기면 쭉 늘어나서 물 담을 공간이 생기던 그 물통. 그것처럼 스택프레임의 공간이 늘어나게 만드는 것이다.
그럼 왜 8만큼 빼는가. 그것은 우리가 선언한 변수가 long 타입 변수 2개이기 때문이다.
long a = 1, b = 2;
long 타입 변수는 4바이트짜리이다. 4바이트 변수 2개를 선언하기에 8바이트 만큼의 공간이 필요하므로 8(바이트)만큼 스택프레임에 공간을 만들어주는 것이다.
이렇게 ebp와 esp 사이의 공간이 8만큼 생기는 것이다. (16진법 계산에 유의)
mov dword ptr ss:[ebp-4], 1
해석하면 다음과 같다. 'ebp의 주소-4'의 주소에 들어있는 값에다가 1을 새겨넣어.
[ ]은 그 주소가 아닌, 주소에 담긴 값을 지칭하게 된다ㅏ.
포인터 같은 것까지 지금 설명하진 않을게. 그냥 거기에 있는 dword(4바이트)크기의 메모리 내용인 거야.
mov dword ptr ss:[ebp-8],2
이것도 ebp-8의 주소에 있는 값을 2로 집어넣으라는 것이다.
스택프레임을 도식화하면 이렇게 될 것이야.
2 |
1 |
ebp |
아주 전형적이다.
mov eax, [ebp-8] -> push eax
: ebp-8 주소에 있는 값을 eax에 옮기고, 그러고나면 그걸 스택 최상단에 집어넣어.
[ebp-8]에 있는 것은 '2'였다. (위 표 참고)
mov ecx, dword [ebp-4] -> push ecx
: [ebp-4]에 있던 건 '1'이었지. eax는 이미 썼으니까 ecx 쓴거야. '1'을 쌓은거야.
1 |
2 |
esp |
---|
2 |
1 |
ebp |
왜 이미 1과 2를 집어넣었는데 한번 더 쌓는지 궁금할 수 있다.
아래에 있는 1과 2는 메인함수에서 선언한 변수이고, 위에 쌓인 1과 2는 이제 호출할 add 함수에게 넘겨줄 파라미터이다.
중요한 내용이다. 지금 표에서 위 두칸을 보면 우리는 2를 먼저 스택에 쌓고 그 위에 1을 쌓았다. 즉 2부터 먼저 저장해줬다.
스택이 LIFO 구조라는 걸 기억해야 한다. add 함수는 실제로는 인자를 1부터 가져오고, 그 다음 2를 가져올 것이다.
long add(long a, long b)
나중에 들어온 데이터가 먼저 나가는 LIFO 구조에서 a변수부터 가져오기 위해선 a 인자를 나중에 스택에 쌓아야 하는 것이다. 이게 스택프레임에서 중요한 파라미터 전달 순서이다.
드디어 call을 이용해서 add 함수를 호출한다.
아까 설명했듯이 call은 두 가지 과정을 거쳐야 한다.
우선 add 함수로 새로운 스택프레임이 만들어져도 add 함수가 종료되고 난 다음의 명령을 기억하기 위해 그 다음 주소를 스택 상단에 넣는다. 그리고 나서 add 함수로 이동하는 것이다. 여기선 401000이 add 함수의 주소이다.
add 함수의 시작도 똑같다. ebp에는 지금 main 함수의 베이스가 담겨있다. 이걸 push함으로써 스택에 백업해둔다. 안전하게 백업되었으니 이제 ebp를 esp까지 끌어올려서 새로운 add 함수의 스택프레임을 만들 준비를 한다. 아코디언이 펼쳐지기 위해서 오므려졌다고 생각해보자.
이제는 이해가 더 쉬울 것이다.
sub esp,8
: long type 변수 두개를 담을 수 있도록 esp를 감소시켜서 위로 늘려놓는다. 얼만큼? 8만큼
mov eax, [ebp+8]
: [ebp+8]에 있는 값 뭐니 a=1이네, 이거 eax에 넣어두자. 왜? 이따 곧 연산할거거든
mov [ebp-8], eax
: eax에 넣은거 이제 add 스택프레임 안에 있는 [ebp-8] 위치에 위치에 집어넣어.
esp->================== |
---|
[ebp-8] |
[ebp-4] |
ebp->__________main base pointer |
main 리턴주소=call 다음명령어 |
[ebp+8] a=1 |
[ebp+C] b=2 |
esp->================== |
---|
[ebp-8]=1 |
[ebp-4]=2 |
ebp->__________main base pointer |
main 리턴주소=call 다음명령어 |
[ebp+8] a=1 |
[ebp+C] b=2 |
mov eax, [ebp-8]
2를 eax에 집어넣어
add eax, [ebp-4]
eax에 있는 값 1 + [ebp-4]에 있는 값 2 = 3
결과값 3을 eax에 새로 저장해.
이제 eax에는 이 add 함수의 연산 결과인 3이 기록되었다.
여기서 eax의 중요한 기능이 하나 나타난다.
함수가 리턴되기 직전에 eax가 연산되었다면, 그 결과가 이 함수의 리턴값이 된다. 즉 add 함수의 리턴값은 3이 된다.
이게 우리가 코드로 작성한 리턴을 담당하는 것이다.
return (x + y);
호출된 함수가 종료하는 건 이렇게 세가지의 명령어로 이루어져 있다. 하나씩 뜯어보자.
mov esp, ebp
여태까지 본 것과 다르다. 여태본 것은 스택프레임을 잡아늘리는 것이다. 이건 esp를 ebp의 값과 같게 만들라는 것이다. 즉, 윗쪽을 아랫쪽과 같은 높이로 만들라는 것이므로 스택프레임을 찌그러뜨리라는 것이다.
이 코드가 담당하는 것은 굉장히 중요하다.
이제 스택프레임 다 썼으니까 셔터 내려
실제로 이 코드를 실행하기 전 레지스터를 보자
add 함수의 스택프레임 공간 사이즈인 8만큼 차이가 있다.
이 코드를 실행시키면 어떻게 될까.
esp가 ebp만큼 커졌다. 즉, 스택 공간이 줄어들었다.
POP ebp
굉장히 중요하다.
add의 스택프레임 공간을 다 줄였다면 현재의 스택공간은 이렇게 되어있다.
esp,ebp->================== |
---|
main base pointer |
main 리턴주소=call 다음명령어 |
[ebp+8] a=1 |
[ebp+C] b=2 |
스택 최상단에는 main 함수의 base pointer 주소가 백업되어 있다. 이것을 빼서 ebp에 pop해준다. 그럼 이제 ebp는 메인 함수의 스택프레임 아랫쪽 틀을 가지고 있게 된다.
Ret
이제 return이 나왔다. 아까 설명하듯이 ret는 두가지 절차가 있다.
1st 스택 최상단의 값을 eip에 전해준다.
esp->================== |
---|
main 리턴주소=call 다음명령어 |
a=1 |
b=2 |
2nd eip로 점프해~~
이제 이 길었던 add를 마치고 main으로 돌아간다.
call add 다음에 처음으로 나오는 명령어는 esp 8이다.
esp보다 밑에 8바이트엔 무엇이 있었을까?
esp->------------------------ |
---|
a=1 |
b=2 |
add함수에 전달해줄 파라미터들이었다. add 함수가 종료되었기 때문에 이 파라미터들도 이제 필요하지 않다. esp를 8만큼 더해줘서 스택공간을 아래로 8만큼 잡아내려서 스택공간을 청소하자.
push eax
: eax의 값을 스택에 쌓아라.
eax에는 뭐가 들어있을까? 아까 add 함수에서 계산한 1+2=3이라는 결과값을 add의 리턴값으로 가지고 있다. 이 3이라는 값을 스택에 쌓는다. 왜냐면 우리의 코드에서
printf("%d\n", add(a, b));
printf에 들어갈 숫자는 add(a,b)의 리턴값이었으니까 스택에 넣어준 게 전부이다.
push stackframe.40B384
Call stackframe.401067
: printf 함수는 C표준 라이브러리에서 지정된 함수이다. 이 단계에서 printf는 아까의 값 3을 잘 가져와서 %d\n을 읽고 그 값을 잘 넣을 것이다. 내용이 방대해서 나도 모른다. 넘어가자.
add esp, 8
: printf가 사용한 파라미터는 2개로 총 8바이트이다. 4바이트짜리 레지스터와 4바이트짜리 상수를 쓴다고 한다. printf도 엄연히 호출된 함수니까, main 함수에서 printf가 파라미터를 불러오기 위해 쓴 공간을 청소해준다.
XOR eax, eax
밑에 3개는 아까 add랑 같은 과정이라 설명할 필요가 없지만 이건 설명해야 한다.
main함수에서 우리는 이런 국룰 리턴을 사용한다.
return 0;
즉 main 함수가 리턴하는 값을 우리는 0으로 만들어야 한다. 그리고 우리는 아까, 함수가 리턴하기 직전에 연산되는 eax 값이 함수의 리턴값이라는 걸 배웠다. 결국, 우리가 해야하는 연산은
'eax를 0으로 만들어라'이다.
어떤 값을 0으로 만드는 가장 단순한 방법 하나는 바로 자기들끼리 XOR 연산을 시키는 것이다. 암호학에서도 쓰이고 중요하니까 알아두자.
EAX와 EAX(자기자신)을 XOR 연산하면 그 결과는 0이 된다. 결과값 0은 eax에 새로 저장된다. 이렇게 우린 main 함수의 리턴값 0을 만들었다.
mov esp, ebp
: 메인함수 스택프레임 윗공간 찌그러뜨려서 스택공간 정리
pop ebp
: 메인함수가 이전의 베이스 포인터를 복원시켜준다.
ret
: 이제 메인함수를 종료하고 어딘가 있을 리턴주소로 점프해.
그게 어디냐면 바로 visual studio의 stub code 영역인데, 사용자에겐 더 이상 궁금하지 않은 영역이다. 여기까지가면 프로세스를 종료하는 코드가 실행된다.
한눈에 보는 전체과정은 이런 모습이다. 주석을 보면 알 수 있다.
고생했습니다.