스택 vs 힙
목표 학습 시간: 105분 + 복습 15분 · 전공 수준 정독용
이 챕터를 마치면: (1) 함수 호출 시 스택 프레임이 어떻게 구성되는지(호출 규약·프롤로그/에필로그·복귀 주소) 그릴 수 있고, (2) 스택이 왜 빠른지와 어떻게 넘치는지 설명하고, (3) 힙 할당의 계약과 정렬 보장을 이해하고, (4) 댕글링/UAF가 왜 "가끔만" 터지는지 설명할 수 있다.
0. 학습 지도 (105분)
| 구간 | 분 | 내용 |
|---|---|---|
| 1 | 10 | 두 개의 수명 문제 |
| 2 | 35 | 스택 메커니즘: 프레임·호출 규약·프롤로그 |
| 3 | 15 | 스택 한계: 크기·가드 페이지·오버플로 |
| 4 | 20 | 힙의 계약: malloc/free·정렬·realloc |
| 5 | 15 | 선택 기준 + VLA/alloca 함정 |
| 6 | 10 | 수명 버그의 씨앗 |
| 복습 | 15 | 인출 + 연습문제 |
1. 두 개의 수명(lifetime) 문제
프로그램이 만드는 데이터는 "언제까지 살아야 하는가"가 다르다.
- 함수 안에서만 잠깐 쓰는 값(루프 변수, 임시 계산): 함수가 끝나면 사라져도 된다. → 스택.
- 함수가 끝나도 살아남아야 하거나, 크기가 런타임에 정해지는 값(자료구조, 큰 버퍼): 명시적으로 살리고 죽여야 한다. → 힙.
이 "자동 수명 vs 수동 수명"의 구분이 스택과 힙을 가른다. 나머지는 전부 이 한 문장의 귀결이다.
2. 스택 메커니즘 — 함수 호출의 실제
2.1 스택 포인터와 프레임
스택은 LIFO(후입선출) 구조이고, 스레드마다 하나씩 있다. CPU에는 스택의 현재 꼭대기를 가리키는 스택 포인터 레지스터(x86-64에서 rsp) 가 있다. 함수를 호출할 때마다 그 함수가 쓸 메모리 한 덩어리, 스택 프레임(stack frame, activation record) 이 쌓인다.
x86-64에서 스택은 높은 주소 → 낮은 주소 방향으로 자란다. 프레임을 하나 "밀어 넣는다(push)"는 건 rsp를 빼는 것이다(주소가 작아진다).
한 프레임이 담는 것:
- 호출한 곳으로 돌아갈 주소(return address)
- (선택) 이전 프레임 포인터(
rbp) 저장값 - 함수의 지역 변수
- 레지스터로 다 못 넘긴 인자, 그리고 호출이 보존해야 할 레지스터 저장값
스택 (아래로 성장):
높은 주소
┌────────────────────────┐
│ main 프레임 │ main의 지역변수 등
├────────────────────────┤
│ 복귀 주소(→ main) │ square가 끝나면 여기로 돌아간다
│ 저장된 rbp │
│ square 지역변수 (n, r) │
└────────────────────────┘ ← rsp (현재 꼭대기)
낮은 주소
2.2 호출 규약(calling convention) — SysV AMD64 ABI
"누가 무엇을 어디에 두는가"의 약속이 호출 규약이다. 리눅스/맥의 x86-64는 System V AMD64 ABI를 쓴다. 핵심만:
- 정수/포인터 인자 처음 6개는 레지스터로 전달:
rdi, rsi, rdx, rcx, r8, r9. 7번째부터는 스택으로. - 반환값은
rax(작은 정수/포인터). - 호출 보존(callee-saved) 레지스터(
rbx, rbp, r12~r15)는 호출된 함수가 망가뜨리면 복원해야 한다. - 호출 파괴(caller-saved) 레지스터는 호출하는 쪽이 필요하면 미리 저장한다.
- 함수 진입 시 스택은 16바이트 정렬되어 있어야 한다(SIMD 명령 정렬 요구). 엄밀히는
call직전에rsp가 16의 배수이고, 호출 직후엔 복귀 주소(8B)가 막 push돼rsp ≡ 8 (mod 16)상태다 — 프롤로그가 이를 다시 16B로 맞춘다. - 레드존(red zone):
rsp아래 128바이트는 리프 함수(다른 함수를 호출 안 하는 함수)가rsp조정 없이 임시로 쓸 수 있는 영역.
이 규약 덕분에 서로 다른 컴파일러로 빌드한 코드도 함수 호출로 협력할 수 있다(ABI 호환).
2.3 프롤로그와 에필로그 — 프레임의 탄생과 소멸
컴파일러가 함수 시작/끝에 끼워 넣는 정형 코드:
; 프롤로그 (개념적)
push rbp ; 호출자의 프레임 포인터 저장
mov rbp, rsp ; 새 프레임 기준점 설정
sub rsp, N ; 지역변수용 공간 N바이트 확보 (rsp를 내림)
; ... 함수 본문 ...
; 에필로그
mov rsp, rbp ; 지역변수 공간 회수
pop rbp ; 호출자의 프레임 포인터 복원
ret ; 복귀 주소로 점프 (스택에서 pop)
call 명령은 다음 명령의 주소(복귀 주소)를 스택에 push하고 함수로 점프한다. ret은 그 복귀 주소를 pop해 돌아간다. 즉 "어디로 돌아갈지"가 스택에 데이터로 저장된다 — 이 사실이 버퍼 오버플로 공격(복귀 주소 덮어쓰기)과 가드/카나리 방어의 출발점이다(세션 9와 연결).
2.4 단계별 추적
int square(int n) {
int r = n * n;
return r;
}
int main(void) {
int x = square(5);
return 0;
}
main실행 중,x를 위한 공간이main프레임에 있다.square(5)호출:5가edi(=rdi 하위)에 들어가고,call이 복귀 주소를 스택에 push → 제어가square로.square프롤로그: 프레임을 만들고n,r공간 확보.r = 25계산.- 반환값
25를eax(=rax 하위)에 넣고 에필로그 → 프레임을 걷어내고ret으로main에 복귀. main은eax의25를 받아x에 저장.
핵심: square의 프레임은 4단계에서 통째로 사라진다. n, r은 그 순간 수명이 끝난다. 이게 "자동 수명"의 정확한 의미다.
2.5 스택이 빠른 이유
스택 할당/해제는 rsp를 더하거나 빼는 한 번의 연산이다. 빈 공간을 탐색하지도, 메타데이터를 관리하지도 않는다. 게다가 스택 꼭대기는 거의 항상 캐시에 살아 있어(시간적 지역성) 접근도 빠르다. 그래서 "임시 데이터는 스택"이 기본값이다.
3. 스택의 한계 — 크기와 오버플로
3.1 스택은 작고 고정적이다
힙이 수 GB까지 자랄 수 있는 것과 달리, 스택은 작은 고정 한도를 갖는다(리눅스 기본 보통 8MB, ulimit -s로 확인/조정). 스레드마다 별도 스택이 있어 한도가 더 빠듯할 수 있다.
3.2 스택 오버플로와 가드 페이지
프레임을 너무 많이/크게 쌓아 한도를 넘으면 스택 오버플로다. 대표 원인:
- 무한/과도한 재귀(종료 조건 누락, 너무 깊은 깊이).
- 거대한 지역 배열(
char buf[10*1024*1024];처럼 프레임 하나가 큰 경우).
OS는 스택 끝에 가드 페이지(접근 금지 페이지)를 둬, 넘쳐서 그 페이지를 건드리는 순간 폴트를 일으켜 조용한 메모리 오염 대신 명확한 크래시로 잡는다.
// 두 가지 오버플로 유발 패턴
int recurse(int n) { // ① 종료 없는 재귀
return recurse(n + 1);
}
void big(void) { // ② 거대한 단일 프레임
char buf[16 * 1024 * 1024]; // 16MB > 8MB 한도
buf[0] = 1;
}
실무 함의: 깊은 재귀는 반복(loop)+명시적 스택으로 바꾸거나 꼬리 재귀 최적화에 기대고, 큰 버퍼는 힙(
malloc)에 둔다.
4. 힙의 계약 — malloc/free를 정확히
세션 7에서 할당자 내부를 해부하므로, 여기선 사용자가 알아야 할 계약에 집중한다.
4.1 malloc/free의 약속
void *malloc(size_t n): 연속된 n바이트 이상을 확보해 시작 주소를 반환. 실패하면NULL(반드시 검사!).- 반환 메모리의 내용은 불정(쓰레기). 0으로 원하면
calloc. void free(void *p):malloc/calloc/realloc이 준 포인터만 넘겨야 한다. 그 외(스택 주소, 중간 주소)는 미정의 동작. 한 번만 free(이중 해제 금지).free(NULL)은 안전(아무 일도 안 함).
4.2 정렬 보장
malloc이 주는 주소는 그 플랫폼의 어떤 타입에도 적합한 정렬(max_align_t, 64비트에서 보통 16바이트)로 맞춰져 있다. 그래서 받은 메모리에 double이든 SIMD 타입이든 안전하게 놓을 수 있다(세션 2의 정렬과 연결).
4.3 realloc의 미묘함
int *a = malloc(4 * sizeof(int));
// ...
int *tmp = realloc(a, 8 * sizeof(int)); // 크기 확장 시도
if (!tmp) { /* 실패: a는 그대로 유효, 누수 방지 처리 */ }
else { a = tmp; } // 성공: a는 옮겨졌을 수 있다(주소 변경 가능!)
realloc은 자리가 모자라면 새 블록으로 옮기고 옛 블록을 free할 수 있다 → 반환된 새 주소를 쓰고, 옛 포인터(a)는 더는 쓰면 안 된다. realloc 결과를 원래 변수에 바로 대입했다가 실패(NULL)하면 원본까지 잃어 누수가 나므로, 위처럼 임시 변수로 받는다.
4.4 힙이 비싼 이유(요약, 자세히는 세션 7)
할당자는 free list에서 적당한 빈 블록을 찾고, 헤더를 갱신하고, free 시 인접 블록과 병합한다. 멀티스레드면 락/스레드 캐시까지 얽힌다. 그래서 스택의 "rsp 가감"과는 비교가 안 되게 무겁다.
5. 무엇을 어디에 둘까 + 위험한 도구
5.1 결정 기준
| 질문 | 예 → |
|---|---|
| 함수보다 오래 살아야 하나? | 힙 |
| 크기가 런타임에만 정해지나? | 힙 |
| 매우 크나(수 MB+)? | 힙 |
| 작고 함수 안에서만 쓰나? | 스택 |
5.2 VLA와 alloca — 조심해서
- VLA(가변 길이 배열):
int buf[n];처럼 런타임 크기 배열을 스택에 잡는다.n이 크거나 사용자 입력이면 스택 오버플로 위험 → 보안상 지양(C11에서 선택적 기능). alloca(n): 스택에서 즉석 할당. 함수 종료 시 자동 해제라 빠르지만, 크기 통제가 안 되면 마찬가지로 위험하고 이식성도 낮다.
규칙: 크기가 작고 상한이 확실할 때만 스택 동적 할당을 고려하고, 의심되면 힙을 써라.
6. 여기서 자라는 버그들 (세션 9 예고)
6.1 댕글링 — 사라진 스택 값을 반환
int *broken(void) {
int r = 42;
return &r; // ✗ r은 함수가 끝나면 사라진다.
} // 반환 주소는 곧 다른 호출이 덮어쓸 무효 프레임을 가리킨다.
왜 가끔은 "되는 것처럼" 보일까? 함수가 끝나도 그 스택 메모리의 옛 값은 당장은 남아 있다. 다음 함수 호출이 같은 위치를 덮어쓰기 전까지는 42가 읽혀 "되는 듯" 보인다. 그러다 다른 호출이 그 자리를 쓰는 순간 값이 깨진다 — 타이밍 의존적 버그(하이젠버그). 이 비결정성이 메모리 버그 디버깅을 어렵게 만든다(세션 9에서 도구로 잡는다).
6.2 use-after-free — free된 힙을 사용
int *p = malloc(sizeof(int));
*p = 10;
free(p);
*p = 20; // ✗ 이미 반납. p는 댕글링 포인터.
free 직후엔 옛 값이 남아 멀쩡해 보이다가, 그 블록이 다른 malloc에 재사용되어 덮이면 그때부터 오작동한다. 댕글링과 같은 비결정성을 갖는다.
7. 한 장 정리
- 스택 vs 힙은 자동 수명 vs 수동 수명의 문제.
- 스택은 프레임을 쌓고(
rsp가감), 호출 규약(SysV: 인자 6개 레지스터, 반환 rax, 16B 정렬, 레드존)으로 협력하며, 프롤로그/에필로그로 프레임을 만들고 건다. 복귀 주소가 스택에 데이터로 저장된다. - 스택은 빠르지만 작고 고정적이라 깊은 재귀·큰 지역배열이면 오버플로(가드 페이지가 크래시로 잡음).
- 힙의 계약:
malloc은 정렬 보장·내용 불정·NULL 검사,free는 한 번만·정확한 포인터만,realloc은 주소가 바뀔 수 있다. - 댕글링/UAF는 타이밍 의존적이라 "가끔만" 터진다.
8. 복습 (15분) — 답을 가리고
Q1. SysV AMD64에서 처음 세 정수 인자와 반환값은 각각 어느 레지스터로?
인자: rdi, rsi, rdx (이어서 rcx, r8, r9). 반환값: rax.
Q2. call과 ret이 스택과 어떻게 상호작용하나?
call은 복귀 주소(다음 명령 주소)를 스택에 push하고 대상 함수로 점프.ret은 그 복귀 주소를 pop해 거기로 돌아간다. 즉 "어디로 돌아갈지"가 스택에 저장된다.
Q3. 스택이 힙보다 싼 두 가지 이유.
① 할당/해제가 rsp를 더하고 빼는 한 번의 연산(탐색·메타데이터 없음). ② 스택 꼭대기가 거의 항상 캐시에 있어 접근도 빠름.
Q4. return &local;이 위험한데 가끔 "되는 것처럼" 보이는 이유는?
local의 스택 메모리는 함수 종료 후에도 옛 값이 잠시 남는다. 다음 호출이 그 자리를 덮기 전까지는 옳은 값이 읽혀 동작하는 듯 보이다가, 덮이는 순간 깨진다(타이밍 의존).
Q5. realloc을 a = realloc(a, n)처럼 바로 대입하면 안 되는 이유는?
realloc이 NULL을 반환(실패)하면 원래 블록은 여전히 유효한데 a를 NULL로 덮어버려 그 블록을 잃는다(누수). 임시 변수로 받아 성공 시에만 a에 대입해야 한다. 또한 성공해도 주소가 바뀔 수 있으니 옛 포인터는 버려야 한다.
Q6. 스택 오버플로를 일으키는 두 패턴과 OS의 방어 장치는?
① 종료 없는/과도한 재귀, ② 너무 큰 지역 배열. OS는 스택 끝에 가드 페이지를 둬 넘침을 즉시 폴트(크래시)로 잡는다.
Q7. 다음 중 힙에 두어야 하는 것은? (a) 3개짜리 임시 int 배열 (b) 사용자가 정한 N개 원소의 자료구조 (c) 함수가 만들어 호출자에게 돌려줄 버퍼
(b), (c). (b)는 크기가 런타임 결정, (c)는 함수보다 오래 살아야 함. (a)는 작고 임시라 스택이 적합.
다음: 세션 4 — 캐시와 캐시 친화적 코드 (다음 배치)