s-nova 님의 블로그
스택 메모리 본문
1. CPU와 RAM의 역할
본격적인 이야기 전에 무대를 먼저 정리하고 가겠습니다.
- CPU$^{\text{Central Processing Unit}}$: 계산을 담당하는 프로세서입니다. 덧셈, 비교, 분기 등 모든 연산을 처리합니다.
- RAM$^{\text{Random Access Memory}}$: 데이터를 임시로 저장하는 주기억장치입니다. 프로그램이 실행되는 동안 코드, 변수, 함수 호출 정보 등이 여기에 올라옵니다.
CPU는 연산 속도가 매우 빠르지만 저장 공간이 작은 레지스터$^{\text{Register}}$를 내부에 가지고 있습니다. 계산할 데이터는 RAM에서 레지스터로 가져온 뒤 처리하고, 결과를 다시 RAM에 쓰는 방식으로 동작합니다.
그리고 이 RAM 안에 여러 영역이 나뉘어 있는데, 그 중 함수 호출과 지역 변수를 관리하는 영역이 바로 스택$^{\text{Stack}}$입니다.
2. 스택 메모리의 특징
스택 메모리에는 중요한 특징이 있습니다. 바로 높은 주소에서 낮은 주소 방향으로 증가한다는 점입니다.
일반적으로 주소는 아래쪽이 낮고 위쪽이 높다고 표현하는데, 스택은 거꾸로 자랍니다. 함수가 호출되면 스택이 아래로 확장되고, 함수가 종료되면 다시 위로 줄어드는 방식입니다.
📌 스택 오버플로우$^{\text{Stack Overflow}}$: 스택이 계속 아래로 확장되다가 할당된 공간을 모두 소진하면 스택 오버플로우가 발생합니다. 재귀 함수를 탈출 조건 없이 무한히 호출하는 경우가 대표적인 원인입니다.
스택 메모리의 전체 크기는 런타임에 동적으로 결정되는 것이 아니라, 빌드 시 컴파일러 설정에 따라 고정됩니다. Visual Studio 기준으로는 기본 1MB이며, 링커 옵션에서 변경할 수 있습니다. 각 함수가 사용하는 스택 공간 크기는 컴파일러가 함수 내 지역 변수들을 분석해서 자동으로 결정합니다.
3. 스택 프레임이란?
함수가 호출될 때마다 스택에는 그 함수만을 위한 전용 공간이 할당됩니다. 이 공간을 스택 프레임$^{\text{Stack Frame}}$ 혹은 활성화 레코드$^{\text{Activation Record}}$라고 부릅니다.
함수가 종료되면 해당 스택 프레임은 통째로 사라집니다. 이것이 지역 변수의 수명이 함수 실행 기간과 일치하는 이유입니다. 그리고 같은 함수를 호출하더라도 매번 새로운 프레임이 생성되기 때문에, 스택 프레임이 위치하는 실제 주소는 호출할 때마다 달라집니다.
그렇다면 하나의 스택 프레임 안에는 무엇이 들어 있을까요? 대략 다음 순서로 구성됩니다.
| 순서 $($높은 주소 → 낮은 주소$)$ | 내용 | 역할 |
| ① $($가장 먼저 쌓임$)$ | 매개변수$^{\text{Arguments}}$ | 함수를 호출한 쪽에서 전달한 인자 값 |
| ② | 반환 주소$^{\text{Return Address}}$ | 함수가 끝난 뒤 실행을 이어갈 코드 위치 |
| ③ | 이전 베이스 포인터$^{\text{Saved BP}}$ | 호출자의 스택 프레임 위치 복원용 |
| ④ $($가장 나중에 쌓임$)$ | 지역 변수$^{\text{Local Variables}}$ | 함수 내에서 선언된 변수들 |
반환 주소가 스택 프레임에 저장된다는 점이 핵심입니다. 함수가 종료될 때 CPU는 이 주소를 꺼내 해당 위치로 점프함으로써 호출자 함수의 다음 코드 실행을 이어갑니다.
아래 간단한 예시로 확인해 보겠습니다.
int add(int a, int b) // a, b는 매개변수 → 스택 프레임에 저장
{
int result = a + b; // result는 지역 변수 → 스택 프레임에 저장
return result; // 반환 후 스택 프레임 소멸
}
int main()
{
int x = add(3, 5); // add() 호출 → 새 스택 프레임 생성
// add() 종료 → 해당 프레임 제거, 반환 주소로 복귀
return 0;
}
4. 베이스 포인터와 스택 포인터
앞서 스택 프레임의 위치가 호출마다 달라진다고 했습니다. 그렇다면 CPU는 지역 변수나 매개변수의 위치를 어떻게 찾을까요? 답은 두 개의 특수 레지스터에 있습니다.
-
베이스 포인터$^{\text{Base Pointer, BP}}$: 현재 스택 프레임의 기준점$^{\text{Base}}$을 가리킵니다. x86-64에서는
RBP레지스터가 이 역할을 합니다. -
스택 포인터$^{\text{Stack Pointer, SP}}$: 현재 스택의 최상단 $($가장 낮은 주소$)$을 가리킵니다. x86-64에서는
RSP레지스터가 이 역할을 합니다. 데이터가 스택에 쌓일수록 이 값은 감소합니다.
BP가 필요한 이유를 조금 더 살펴보겠습니다. 지역 변수나 매개변수의 주소를 절대 주소로 저장해두면, 함수가 호출될 때마다 위치가 달라지므로 쓸 수 없습니다. 대신 컴파일러는 이들을 BP로부터의 상대 주소$^{\text{Offset}}$로 기억합니다.
예를 들어 컴파일러는 지역 변수 result를 "RBP - 4" 처럼 표현합니다. 어떤 주소에 스택 프레임이 놓이더라도 BP를 기준으로 항상 같은 상대 위치에서 변수를 찾을 수 있습니다.
아래는 위 add() 함수 호출 시 스택의 대략적인 상태입니다.
// add(3, 5) 호출 직후 스택 상태 (높은 주소 → 낮은 주소)
//
// +-------------------+ ← 높은 주소
// | 매개변수 b = 5 | RBP + 16
// | 매개변수 a = 3 | RBP + 8
// | 반환 주소 | RBP + 0 (main으로 돌아갈 주소)
// | 이전 RBP 값 | ← RBP 가 여기를 가리킴 (현재 프레임의 기준점)
// | result = ? | RBP - 4
// +-------------------+ ← RSP (스택 최상단, 가장 낮은 주소)
//
// add() 종료 시:
// 1. RSP를 RBP 위치로 복원 (지역 변수 영역 제거)
// 2. 이전 RBP 값을 꺼내 RBP 복원 (호출자 프레임으로 복귀)
// 3. 반환 주소를 꺼내 해당 위치로 점프
💡 64비트 시스템의 최적화: x86-64 호출 규약$^{\text{Calling Convention}}$에서는 처음 몇 개의 매개변수를 스택이 아닌 레지스터에 담아 전달합니다. $($Windows 기준:
RCX, RDX, R8, R9$)$ 스택 접근보다 레지스터 접근이 훨씬 빠르기 때문입니다. 매개변수가 많거나 복잡한 경우에는 스택을 사용합니다.
5. 함수 호출의 전체 흐름
지금까지 배운 내용을 묶어 함수 호출 시 실제로 어떤 일이 벌어지는지 순서대로 살펴보겠습니다.
- 매개변수 전달: 호출자$^{\text{Caller}}$가 인자를 레지스터 또는 스택에 올립니다.
- 반환 주소 저장:
CALL명령어가 현재 명령 포인터$^{\text{Instruction Pointer}}$ 값 $($돌아올 주소$)$을 스택에 푸시$^{\text{push}}$하고 피호출 함수로 점프합니다. - 이전 BP 저장 & BP 갱신: 피호출 함수$^{\text{Callee}}$가 시작되면서 현재 RBP를 스택에 저장하고, RSP 값을 RBP에 복사하여 새 프레임의 기준점을 설정합니다.
- 지역 변수 공간 확보: RSP를 필요한 크기만큼 감소시켜 지역 변수 영역을 확보합니다.
- 함수 실행: 지역 변수와 매개변수는 RBP 기준 오프셋으로 접근합니다.
- 함수 종료 및 복귀: RSP를 RBP로 복원하고, 저장해둔 이전 RBP를 꺼내 복원한 뒤, 반환 주소로 점프합니다.
이 과정이 함수 호출마다 자동으로 반복되며, 여러 함수가 중첩 호출된 경우 스택에는 각 함수의 프레임이 차곡차곡 쌓입니다. Visual Studio의 호출 스택$^{\text{Call Stack}}$ 창에서 이 프레임들을 직접 확인할 수 있습니다.
6. 카나리아 기법 - 스택 보호 메커니즘
지역 변수 사이나 반환 주소 근처에 쓰레기 값$^{\text{garbage value}}$처럼 보이는 특정 값이 삽입되어 있는 것을 본 적이 있을 겁니다. 이것이 바로 스택 카나리아$^{\text{Stack Canary}}$입니다.
이름의 유래는 옛날 광부들이 탄광에 카나리아 새를 데려간 것에서 왔습니다. 유독가스가 차면 카나리아가 먼저 쓰러지므로 광부들이 위험을 미리 감지할 수 있었습니다. 스택 카나리아도 같은 원리입니다.
컴파일러는 스택 프레임에서 반환 주소 바로 앞에 특정 값$^{\text{Canary Value}}$을 심어 둡니다. 함수가 종료될 때 이 값이 변조되었는지 확인하고, 바뀌어 있으면 프로그램을 강제 종료$^{\text{crash}}$합니다.
// 스택 프레임 내 카나리아 배치 (개념적 표현)
//
// +-------------------+
// | 반환 주소 | ← 공격자가 덮어쓰려는 목표
// | [카나리아 값] | ← 변조 감지용 파수꾼
// | 지역 변수 buf | ← 버퍼 오버플로우 발생 지점
// +-------------------+
//
// 버퍼 오버플로우로 buf를 넘쳐 쓰면
// 카나리아 값이 먼저 덮어씌워짐 → 함수 종료 시 감지 → 크래시
버퍼 오버플로우$^{\text{Buffer Overflow}}$ 공격은 배열의 경계를 넘어 쓰기를 함으로써 반환 주소를 악성 코드 위치로 덮어쓰는 기법입니다. 카나리아는 반환 주소에 닿기 전에 이 시도를 감지하는 파수꾼 역할을 합니다.
📌 디버그 빌드의 쓰레기 값: Visual Studio의 디버그 빌드에서는 초기화되지 않은 스택 변수를
0xCC로, 해제된 힙 메모리를0xDD로 채워놓습니다. 이는 카나리아와 별개로, 초기화 누락이나 해제 후 접근 버그를 빠르게 발견하기 위한 디버깅 보조 수단입니다.
7. 32비트 vs 64비트
32비트와 64비트의 근본적인 차이는 CPU가 한 번에 처리할 수 있는 데이터의 크기입니다. 이것이 레지스터의 폭$^{\text{Width}}$을 결정하고, 자연스럽게 주소 공간의 크기도 결정합니다.
| 구분 | 32비트 | 64비트 |
| 레지스터 크기 | 32비트 $($4바이트$)$ | 64비트 $($8바이트$)$ |
| 포인터 크기 | 4바이트 | 8바이트 |
| 베이스 포인터 레지스터 | EBP |
RBP |
| 스택 포인터 레지스터 | ESP |
RSP |
| 매개변수 전달 방식 | 모두 스택 사용 | 앞 4개는 레지스터 사용 $($Windows$)$ |
| 최대 주소 공간 | 약 4GB | 이론상 16EB $($실질적으로 수십 TB$)$ |
레지스터 이름의 접두사 E는 Extended$^{\text{확장}}$, R은 64-bit의 관용적 표기입니다. RBP는 EBP의 64비트 확장 버전입니다.
8. 정리
스택 메모리와 스택 프레임의 핵심을 한데 모아 정리합니다.
- 스택은 높은 주소 → 낮은 주소 방향으로 자라며, 크기는 빌드 시 결정됩니다.
- 함수가 호출되면 스택 프레임이 생성되고, 종료되면 제거됩니다. 프레임에는 매개변수 → 반환 주소 → 이전 BP → 지역 변수 순으로 데이터가 담깁니다.
- BP$^{\text{Base Pointer}}$는 현재 프레임의 기준점을 가리키며, 지역 변수와 매개변수는 BP 기준 상대 주소로 접근합니다.
- SP$^{\text{Stack Pointer}}$는 스택의 최상단을 가리키며, 데이터가 쌓일수록 감소합니다.
- 카나리아$^{\text{Canary}}$는 반환 주소 앞에 심어진 감시 값으로, 버퍼 오버플로우 공격을 감지합니다.
- 64비트 시스템에서는 앞 몇 개의 매개변수를 레지스터로 전달해 스택 접근 비용을 줄입니다.
'CS' 카테고리의 다른 글
| 프로세스, 스레드 (0) | 2025.08.24 |
|---|---|
| [네트워크] TCP vs UDP (0) | 2025.08.17 |
| [운영체제] 메모리 구조 $($코드, 데이터, 스택, 힙$)$ (0) | 2025.08.03 |