요약

프로시저를 한 번 부를 때마다 스택에 그 호출만 쓰는 작업 책상을 하나 편다. 책상에는 넘겨받은 값, 일이 끝나면 돌아갈 곳, 잠시 치워 둔 남의 물건, 자기 메모지가 정해진 순서로 놓인다. 책상 한가운데에 말뚝을 하나 박아 두고 “말뚝에서 위로 두 칸”, “아래로 한 칸”처럼 물건을 찾기 때문에, 스택 꼭대기가 오르내려도 자리가 헷갈리지 않는다. 대신 프로시저는 들어올 때 말뚝을 박고 나갈 때 뽑는 일을 어긋나지 않게 해야 하고, 하나라도 빠뜨리면 엉뚱한 주소로 돌아간다.

예시로 보기

지금까지는 프로시저에 값을 넘길 때 레지스터에 담았다. 레지스터는 몇 개 없어서 넘길 값이 많으면 모자라고, 프로시저가 자기를 다시 부르면 같은 레지스터를 덮어쓴다. 그래서 값을 스택에 넣어 넘긴다. 그러면 새 문제가 생긴다. 프로시저 안에서 push를 한 번 할 때마다 ESP가 4씩 내려가서, “ESP에서 몇 칸 위”로 값을 찾으면 그 거리가 계속 바뀐다.

두 수를 더하는 AddTwo를 부르는 코드로 본다.

main PROC
    push 6              ; 둘째 인수
    push 5              ; 첫째 인수
    call AddTwo         ; EAX = 11
    ...
main ENDP

AddTwo PROC
    push ebp            ; ① 부른 쪽의 EBP를 스택에 맡긴다
    mov  ebp,esp        ; ② EBP를 지금 꼭대기에 고정한다 (말뚝)
    mov  eax,[ebp+12]   ; 둘째 인수 6
    add  eax,[ebp+8]    ; 첫째 인수 5를 더한다
    pop  ebp            ; ③ 맡겨 둔 EBP를 돌려놓는다
    ret  8              ; ④ 돌아가면서 인수 8바이트를 치운다
AddTwo ENDP

ESP가 00001000h, main이 쓰던 EBP가 00001010h인 상태에서 시작하면 명령마다 이렇게 바뀐다1.

명령 ESP EBP EAX 일어난 일
(시작) 00001000 00001010    
push 6 00000FFC 00001010   6을 넣는다
push 5 00000FF8 00001010   5를 넣는다
call AddTwo 00000FF4 00001010   복귀 주소를 넣고 AddTwo로 간다
push ebp 00000FF0 00001010   main의 EBP를 넣는다
mov ebp,esp 00000FF0 00000FF0   말뚝을 박는다
mov eax,[ebp+12] 00000FF0 00000FF0 00000006 0FFCh의 값을 읽는다
add eax,[ebp+8] 00000FF0 00000FF0 0000000B 0FF8h의 값을 더한다
pop ebp 00000FF4 00001010 0000000B main의 EBP가 돌아온다
ret 8 00001000 00001010 0000000B 복귀 주소를 꺼내고 8을 더 올린다

mov ebp,esp 직후의 스택이다. 주소는 위로 갈수록 크다.

   주소        든 값                     EBP에서 본 자리
 00001000   (main이 쓰던 곳)
 00000FFC   00000006   둘째 인수          [EBP+12]
 00000FF8   00000005   첫째 인수          [EBP+8]
 00000FF4   복귀 주소                     [EBP+4]
 00000FF0   00001010   main의 EBP         [EBP]     ← EBP, ESP
            (아직 안 쓴 곳)

0FF0h부터 0FFCh까지의 네 칸이 이 호출의 스택 프레임이다. 끝나고 나면 ESP와 EBP가 모두 처음 값으로 돌아와 있다.

검증: 위 추적 표의 모든 ESP·EBP·EAX, 그림의 주소별 값, 레지스터를 맡기는 예, 정리를 빠뜨린 고장 두 가지, 카드 C4 — 41_stack-frame_verify.py (실제 어셈블러가 아니라 파이썬 흉내로 확인)

정의

스택 프레임은 프로시저 호출 하나가 스택에서 차지하는 구역이다. 활성 레코드(activation record)라고도 부른다. 여기에 복귀 주소, 넘겨받은 매개변수, 맡아 둔 레지스터, 지역 변수가 들어간다.

프레임은 세 걸음으로 만들어진다.

  1. 부르는 쪽이 인수를 스택에 넣고 프로시저를 부른다(push, call).
  2. 불린 프로시저가 EBP를 스택에 넣고, EBP에 ESP 값을 넣는다(push ebp, mov ebp,esp).
  3. 지역 변수가 필요하면 ESP에서 상수를 빼서 자리를 만든다(sub esp,n).

EBP를 베이스 포인터 또는 프레임 포인터라고 부른다. 프레임의 기준 주소를 들고 있기 때문이다. 프로시저는 EBP에서 일정한 거리만큼 떨어진 자리로 매개변수를 읽는다. EBP는 프로시저가 도는 동안 바뀌지 않고, 돌아갈 때는 원래 값으로 되돌려야 한다.

기호로 쓰면, 4바이트 인수를 \(m\)개 넣고 부른 프로시저가 2번 걸음까지 마쳤을 때 EBP 기준의 자리는 다음과 같다. \([\mathrm{EBP}+d]\)는 “EBP에 든 주소에 \(d\)를 더한 곳의 4바이트”라고 읽는다.

자리 든 것
\([\mathrm{EBP}+4+4k]\), \(k = 1, \dots, m\) 마지막에서 \(k\)번째로 넣은 인수
\([\mathrm{EBP}+4]\) 복귀 주소
\([\mathrm{EBP}]\) 부른 쪽의 EBP
\([\mathrm{EBP}-4]\), \([\mathrm{EBP}-8]\), … 지역 변수, 맡아 둔 레지스터

\(k = 1\)을 넣으면 \([\mathrm{EBP}+8]\)이다. 가장 나중에 넣은 인수가 EBP에서 가장 가깝다. 위 예에서 마지막에 넣은 5가 \([\mathrm{EBP}+8]\), 그 앞에 넣은 6이 \([\mathrm{EBP}+12]\)에 있다1.

지키는 것과 지키지 못하는 것

주장. 프로시저가 ① push ebp, mov ebp,esp로 시작하고 ② 몸통에서 EBP에 값을 쓰지 않고 ③ 끝에서 ESP를 EBP와 같은 자리로 되돌린 뒤 pop ebp, ret을 하면, 다음 둘이 늘 맞다1.

  • 몸통의 어느 줄에서든 \([\mathrm{EBP}+4+4k]\)는 같은 인수다. ESP가 얼마든 상관없다.
  • 돌아간 직후 EBP는 부르기 전 값이고, 실행은 call 다음 명령에서 이어진다.
왜 그런가

인수를 넣기 직전의 ESP를 \(E\)라 하자.

  1. 인수 \(m\)개를 넣으면 ESP는 \(E - 4m\)이다. — push는 ESP를 4 줄이고 그 자리에 쓴다
  2. call이 복귀 주소를 넣어 ESP는 \(E - 4m - 4\)다. — call의 정의
  3. push ebp가 부른 쪽 EBP를 넣어 ESP는 \(E - 4m - 8\)이다.
  4. mov ebp,esp로 EBP \(= E - 4m - 8\)이 된다. 이때 \([\mathrm{EBP}]\)는 3에서 넣은 옛 EBP, \([\mathrm{EBP}+4]\)는 2의 복귀 주소, \([\mathrm{EBP}+8]\)은 1에서 마지막에 넣은 인수다. — 넣은 순서의 거꾸로
  5. 몸통은 EBP에 쓰지 않으므로(가정 ②) EBP는 계속 \(E - 4m - 8\)이다. 몸통의 push와 pop은 ESP와 EBP 아래쪽 메모리만 바꾼다. 그래서 4의 세 자리는 몸통 내내 같은 값이다. — 첫째 주장
  6. 끝에서 ESP가 EBP와 같으면(가정 ③) pop ebp는 \([\mathrm{EBP}]\), 곧 옛 EBP를 꺼낸다. ESP는 \(E - 4m - 4\)가 되고 ret은 그 자리의 복귀 주소를 꺼낸다. — 둘째 주장 ∎

스스로 설명해 보기

1. 4번 줄에서 마지막에 넣은 인수가 왜 하필 +8인가

EBP가 가리키는 칸에 옛 EBP(4바이트), 그 위 칸에 복귀 주소(4바이트)가 있다. 두 칸을 건너야 인수가 나온다. 4 + 4 = 8이다.

2. 5번 줄에서 몸통의 push가 매개변수 자리를 망가뜨리지 않는 이유

push는 ESP를 먼저 4 줄인 뒤 그 자리에 쓴다. ESP는 EBP와 같거나 더 낮은 주소에 있으므로, 쓰는 곳은 늘 EBP보다 아래다. 매개변수는 EBP보다 위에 있다.

3. 6번 줄에서 ESP가 EBP와 다르면 무슨 일이 생기는가

pop ebp가 옛 EBP가 아니라 그 순간 꼭대기에 있던 다른 값을 꺼낸다. 이어지는 ret도 한 칸 어긋난 값을 복귀 주소로 꺼낸다.

이 논증의 핵심 아이디어는?

움직이는 기준(ESP) 대신 움직이지 않는 기준(EBP)을 하나 세우고, 모든 자리를 그 기준에서의 거리로 적는다. 기준이 그대로면 거리도 그대로다.

이 방법을 쓸 수 있는 다른 상황은?

지역 변수(지역 변수)는 같은 기준에서 아래쪽 거리로 적는다. 컴파일러가 함수마다 변수 자리를 정할 때도 프레임 포인터에서의 거리를 표에 적어 둔다(활성 레코드).

지키지 못하는 것.

  • 인수를 스택에서 누가 치우는지는 프레임이 정해 주지 않는다. 부르는 쪽과 불린 쪽이 따로 약속해야 한다. → 호출 규약
  • 인수의 개수, 크기, 넣는 순서가 맞는지 아무도 검사하지 않는다. 하나를 덜 넣으면 프로시저는 그 자리에 있던 다른 값을 인수로 읽는다.
  • EAX, ECX 같은 다른 레지스터는 저절로 지켜지지 않는다. 지키려면 프로시저가 직접 맡아 둬야 한다(아래).

고장 나는 길. 같은 코드에서 한 줄씩 빼고 흉내 내 보면 이렇게 된다1.

빠뜨린 것 일어나는 일
끝의 pop ebp ret이 꼭대기에 남은 옛 EBP 값 00001010h를 복귀 주소로 알고 그 주소로 뛴다
몸통에서 push eax를 하고 pop을 안 함 pop ebp가 EAX 값(11)을 EBP에 넣고, ret이 옛 EBP 값으로 뛴다

두 경우 모두 어셈블러는 아무 경고도 하지 않는다. 실행해 봐야 프로그램이 엉뚱한 곳에서 죽는다.

레지스터 맡아 두기

프로시저가 안에서 고쳐 쓰는 레지스터는 mov ebp,esp 바로 다음에 스택에 넣어 두고, 끝에서 거꾸로 순서로 꺼낸다.

MySub PROC
    push ebp
    mov  ebp,esp
    push ecx            ; 고쳐 쓸 레지스터를 맡긴다
    push edx
    mov  ecx,[ebp+8]    ; ESP가 8 내려갔어도 매개변수는 여전히 [ebp+8]
    ...
    pop  edx            ; 넣은 순서의 거꾸로
    pop  ecx
    pop  ebp
    ret  4
MySub ENDP

push 7 다음 call MySub로 부르면 push edx 직후의 프레임은 이렇다1.

   주소        든 값                     EBP에서 본 자리
 00000FFC   00000007   매개변수           [EBP+8]
 00000FF8   복귀 주소                     [EBP+4]
 00000FF4   main의 EBP                    [EBP]     ← EBP
 00000FF0   맡아 둔 ECX                   [EBP-4]
 00000FEC   맡아 둔 EDX                   [EBP-8]   ← ESP

EBP를 먼저 세운 뒤에 레지스터를 넣었기 때문에 매개변수의 자리는 레지스터를 몇 개 맡기든 [ebp+8]이다. USES ecx edx를 쓰면 어셈블러가 프로시저 맨 앞에 push ecx, push edx를 만들어 넣는다. 이 두 줄이 직접 쓴 push ebp보다 먼저 실행되면 매개변수까지의 거리가 8만큼 늘어난다1. → 레지스터 매개변수와 USES

활용

  • 고급 언어의 함수 호출은 모두 이 틀로 번역된다. 컴파일러가 함수마다 push ebp, mov ebp,esp와 같은 일을 하는 코드를 만들어 붙인다1. → 함수 호출의 코드 생성
  • 프레임마다 “부른 쪽의 EBP”가 들어 있어서, 이 값을 따라 올라가면 누가 누구를 불렀는지 차례로 알 수 있다. 재귀에서 이 사슬이 그대로 보인다. → 재귀 프로시저
  • 비용: 호출 한 번에 스택 8바이트(복귀 주소와 옛 EBP)가 기본으로 들고, 인수·지역 변수·맡긴 레지스터만큼 더 든다1.
  • 흔한 실수: 첫 매개변수를 [ebp+4]로 읽는다(거기는 복귀 주소다). pop 순서를 push와 같은 순서로 쓴다. 몸통에서 EBP를 다른 계산에 쓴다.
  • 연습: 스택 프레임 그리기 예제 사다리

연결

자주 하는 오해

“매개변수는 ESP로 읽어도 똑같다. EBP는 없어도 된다”

틀렸다. 프로시저에 막 들어온 순간에는 첫 매개변수가 [esp+4]에 있으니 그럴듯해 보인다. 그런데 몸통에서 push를 한 번 하면 같은 매개변수가 [esp+8]로, 두 번 하면 [esp+12]로 밀려난다. 위의 MySub에서 push ecx, push edx 뒤에 ESP는 8 내려갔지만 [ebp+8]은 여전히 7을 읽는다. ESP 기준으로 쓰려면 push와 pop이 나올 때마다 거리를 다시 계산해야 하고, 한 군데만 틀려도 다른 값을 읽는다. EBP를 고정해 두면 거리를 한 번만 정하면 된다. 확인하는 법: 추적 표에서 ESP 열은 줄마다 바뀌고 EBP 열은 몸통 내내 같은 값인지 본다.

확인 문제

C1 스택 프레임에 들어가는 네 가지와, 프레임이 만들어지는 세 걸음을 쓰라.

답: 복귀 주소, 넘겨받은 매개변수, 맡아 둔 레지스터, 지역 변수. ① 부르는 쪽이 인수를 스택에 넣고 call ② 불린 쪽이 push ebp, mov ebp,esp ③ 지역 변수가 필요하면 sub esp,n.

C2 push ebp와 mov ebp,esp 직후에 첫 매개변수가 [ebp+4]가 아니라 [ebp+8]에 있는 이유는?

답: [ebp]에는 방금 넣은 부른 쪽의 EBP가, [ebp+4]에는 call이 넣은 복귀 주소가 있다. 인수는 그 둘보다 먼저 넣었으므로 두 칸(8바이트) 위에서 시작한다.
이유: 스택에는 나중에 넣은 것이 낮은 주소에 놓인다. 넣은 순서는 인수 → 복귀 주소 → 옛 EBP다.

C3 몸통에서 push와 pop을 아무리 해도 [ebp+8]이 같은 매개변수를 가리키는 것은 어떤 두 사실에서 나오는가?

답: ① 몸통이 EBP에 값을 쓰지 않으므로 EBP가 그대로다. ② push는 ESP를 줄인 뒤 그 자리에 쓰므로, 쓰이는 곳은 늘 EBP보다 낮은 주소다. 매개변수는 EBP보다 높은 주소에 있으므로 덮이지 않는다.

C4 ESP = 00001000h에서 push 10, push 20, push 30, call F를 하고, F가 push ebp, mov ebp,esp를 마쳤다. EBP의 값, [ebp+8]과 [ebp+16]의 값을 쓰라. F가 pop ebp, ret 12로 끝나면 돌아온 뒤 ESP는?

답: EBP = 00000FECh. [ebp+8] = 30, [ebp+16] = 10. 돌아온 뒤 ESP = 00001000h.
이유: push 세 번에 0FF4h, call에 0FF0h, push ebp에 0FECh. 마지막에 넣은 30이 가장 가깝다. pop ebp로 0FF0h, ret이 복귀 주소를 꺼내 0FF4h, 12를 더해 1000h1.

C5 “끝에서 pop ebp를 빼먹어도 EBP만 틀어질 뿐 돌아가기는 한다”가 틀렸음을 위의 AddTwo로 보여라.

답: pop ebp를 빼면 ret을 실행할 때 스택 꼭대기(0FF0h)에 든 것은 복귀 주소가 아니라 맡겨 둔 EBP 값 00001010h다. ret은 이 값을 주소로 알고 꺼내 그리로 뛴다. main의 call 다음 명령으로 돌아가지 못한다.
이유: ret은 꼭대기에 무엇이 있든 그것을 복귀 주소로 쓴다. 복귀 주소가 꼭대기에 오도록 맞추는 것은 프로시저의 일이다1.

  1. 시작 주소 00001000h와 EBP 00001010h는 설명용으로 정한 값이다. 실행 결과는 실제 어셈블러가 아니라 파이썬 흉내로 확인했다(검증 줄의 코드). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10