Memory Organization — Stack과 Heap 구조
Douglas Thain, Introduction to Compilers and Language Design (2nd ed., 2023) · pp.135–148
📌단원 개요
intermediate code를 assembly language로 번역하기에 앞서, 실행 중인 program의 internal memory가 어떻게 배치되는지를 알아야 한다. process는 메모리를 자유롭게 쓸 수 있지만, 관례적으로 메모리를 여러 logical segment로 나누어 관리한다.
- program memory는 관례적으로 code · data · heap · stack 4개의 logical segment로 나뉜다.
- heap은
malloc/free로 runtime에 동적 관리되고, stack은 현재 실행 상태와 local variable을 기록한다. - function 호출마다 stack frame(= activation record)이 push/pop 되며, 그 내용에 대한 합의가 calling convention이다.
- compiler는 data의 storage class(global/local/heap)에 따라 address computation을 생성한다.
- disk의 program은 binary blob → a.out → ELF 같은 executable format으로 저장되어 memory로 load된다.
4개의 logical segment 한눈에
| segment | 담는 것 | 대응 (C) |
|---|---|---|
| code (text) | program의 machine code | function body |
| data | global data (read-write 변수 / read-only 상수) | global variable |
| heap | runtime에 동적 관리되는 메모리 | malloc / free |
| stack | 현재 실행 상태 + local variable | function 호출 / local 변수 |
9.1Introduction
running program의 internal memory가 어떻게 배치되는지를 먼저 논의한다.
- process는 원칙적으로 메모리를 원하는 대로 쓸 수 있다.
- 하지만 관례적으로 프로그램 영역을 logical segment로 나누고, 각 segment마다 서로 다른 internal management strategy를 적용한다.
- 이 관례를 이해해야 intermediate code → assembly language 번역을 다룰 수 있다.
9.2Logical Segmentation
일반적인 program은 메모리를 address 0부터 시작해 큰 수까지 증가하는 linear sequence of words로 본다 (예: 32-bit processor에서 4GB).
Flat Memory Model (Figure 9.1) → Logical Segments (Figure 9.2)
- code segment (= text segment): program의 machine code. C의 function body에 대응.
- data segment: program의 global data. read-write data(variables)와 read-only data(constants)로 다시 나뉠 수 있다.
- heap segment:
malloc/free(또는new/delete)로 runtime에 동적 관리. heap의 꼭대기는 역사적으로 break라고 부른다. - stack segment: 현재 execution state와 사용 중인 local variable을 기록.
- heap은 낮은 주소 → 높은 주소로 "up" 방향으로 자란다.
- stack은 높은 주소 → 낮은 주소로 "down" 방향으로 자란다.
- 둘 사이에는 invalid region(unused)이 있고, 한쪽 segment가 이를 잠식할 때까지 비어 있다.
memory protection이 없는 경우 vs 있는 경우
| 환경 | 동작 |
|---|---|
| embedded / microcontroller (보호 없음) | segment는 단지 organizational convention. heap이 너무 커지면 stack segment를 침범 → 운 좋으면 crash, 운 나쁘면 silent data corruption. |
| OS + multiprogramming + memory protection | 각 process가 자신만의 private memory space를 가짐. address 0부터 시작하는 착각(illusion)을 가지며, 다른 process 메모리 접근/수정은 차단됨. |
- read-write: data, heap, stack
- read-only: constants
- read-execute: code
- none: unused area
- permission 덕분에 code는 runtime에 수정 불가(read/execute), heap의 항목은 실행 불가(read/write).
- 단 이는 사고(accident) 방지일 뿐 악의(malice) 방지는 아니다. program은 OS에 자기 page의 permission 변경을 요청할 수 있다 (Unix의
mprotectsystem call).
- process가 금지된 방식으로 메모리에 접근하거나 unused area에 접근하면 page fault 발생 → 제어가 OS로 넘어감.
- OS가 logical segmentation 위반이라고 판단하면 process를 강제 종료하고 segmentation fault 에러를 낸다. (이제 이 Unix 용어의 정체를 알게 됨!)
heap·stack 확장: brk와 guard page
heap 확장 — brk
- 처음엔 작은 heap segment를 받아 내부적으로
malloc/free를 구현. - 부족하면 OS에 명시적으로 요청 → Unix의
brksystem call로 heap segment를 새 주소까지 확장. - OS가 동의하면 invalid area 앞부분에 새 page를 할당. 거부하면
brk가 에러 코드 →malloc이 null pointer 반환 → program이 처리해야 함.
stack 확장 — guard page
- stack은 언제 더 필요한지 알기 어렵다 (새 function 호출/local 변수 할당 시마다 발생).
- OS는 invalid area 꼭대기, 현재 stack과 인접한 곳에 guard page를 둔다.
- stack이 invalid area로 확장하려 하면 page fault → OS가 faulting address가 guard page임을 확인하면 page를 더 할당하고 guard page를 새 꼭대기로 이동.
- segment 개념은 수십 년간 hardware로 구현되었다 (CPU가 segment table에 시작 주소·길이·permission 보관).
- 1980년대까지 널리 쓰였으나, 더 단순하고 유연한 paging으로 대체되었다.
- Intel X86은 8086~Pentium까지 32-bit protected mode에서 segmentation을 지원했으나, 최신 64-bit 아키텍처는 paging만 제공하고 segmentation은 없다.
- 그래도 logical segmentation은 program을 메모리에 정리하는 유용한 방법으로 계속 쓰인다.
9.3Heap Management
heap은 runtime에 동적으로 관리되는 메모리다. OS는 총 크기만 제한할 뿐, 내부 구조는 standard library/runtime support software가 관리한다.
- C:
malloc(할당) /free(해제) - C++:
new/delete - 다른 언어: object/array가 생성·삭제될 때 암묵적으로 heap 조작.
가장 단순한 구현: linked list of chunks
heap 전체를 하나의 큰 linked list of memory regions로 다룬다. 각 entry는 상태(free/in use), region의 size, prev·next pointer를 기록한다.
struct chunk {
enum { FREE, USED } state;
int size;
struct chunk *next;
struct chunk *prev;
char data[0];
};
char data[0] 트릭data를 길이 0의 array로 선언했다. 이는 실제 메모리가 존재하는 한, data를 variable length array처럼 다룰 수 있게 하는 작은 트릭이다.
malloc / free 동작
초기 상태: 하나의 free chunk
malloc(100) 호출 시: 유일한 chunk가 free이고 요청보다 훨씬 크므로 → 100 byte짜리 작은 chunk와 나머지 큰 chunk로 split한다. 100 byte 뒤 data 영역에 새 chunk header를 써넣고 linked list로 연결한다.
malloc은 chunk 안의 data element 주소를 반환한다 (linked list node 자체가 아님 — 사용자는 구현 세부를 알 필요 없음).- 충분히 큰 chunk가 없으면
brk로 OS에 heap 확장을 요청. free시 해당 chunk를 FREE로 표시하고, 인접 node가 free면 merge한다.
주어진 memory chunk 밖을 부주의하게 수정하면 다른 chunk뿐 아니라 linked list 자체를 손상시킬 수 있다 → 다음 malloc이나 free에서 wild behavior가 발생한다.
memory fragmentation
할당된 순서의 역순으로만 free하면 heap이 깔끔하게 나뉘지만, 실제로는 임의 순서로 할당·해제된다. 시간이 지나면 heap이 이상한 크기의 chunk들이 뒤섞인 상태로 퇴화한다 — 이것이 memory fragmentation이다.
- 작은 chunk는 많지만 현재
malloc을 만족할 만큼 큰 것이 없으면 → heap을 확장할 수밖에 없고, 작은 조각들은 unused로 남아 virtual memory 소비 압박이 커진다. - C처럼 사용 중인 chunk를 이동할 수 없는 언어에서는, 일단 발생한 fragmentation을 사후에 고칠 수 없다.
- 단, allocator가 새 할당 위치를 신중히 골라 fragmentation을 회피할 수는 있다.
할당 전략 4가지
| 전략 | 방법 | 특징 |
|---|---|---|
| Best Fit | 전체 list를 탐색해 요청보다 큰 것 중 가장 작은 free chunk 선택 | 큰 공간을 남기지만, 너무 작아 쓸 수 없는 leftover fragment 양산 |
| Worst Fit | 전체 list를 탐색해 요청보다 큰 것 중 가장 큰 free chunk 선택 | 직관과 달리, 작고 못 쓰는 fragment 생성을 피해 fragmentation을 줄이는 경향 |
| First Fit | list 처음부터 탐색해 요청을 만족하는 첫 fragment 선택 | Best/Worst보다 일은 적지만, list가 커질수록 작업량 증가 |
| Next Fit | 마지막으로 본 위치부터 탐색해 요청을 만족하는 다음 fragment 선택 | 할당당 작업량 최소화, 할당을 heap 전체에 분산 |
application 동작을 가정할 수 없는 general purpose allocator에서는, Next Fit이 좋은 성능과 허용 가능한 수준의 fragmentation을 낸다는 것이 통념이다.
9.4Stack Management
stack은 running program의 현재 상태를 기록한다. 대부분의 CPU는 다음 push/pop 위치를 담는 전용 register인 stack pointer를 가진다.
- stack은 메모리 꼭대기에서 아래로 자란다.
- push → stack pointer가 더 낮은 주소로 이동.
- pop → stack pointer가 더 높은 주소로 이동.
- stack의 "top"은 실제로는 가장 낮은 주소에 있다!
- 각 function의 호출(invocation)은 stack에서 일정 메모리 범위를 차지한다 → 이를 stack frame이라 한다.
- stack frame은 그 function이 쓰는 parameter와 local variable을 담는다.
- function 호출 시 새 stack frame이 push, return 시 pop되고 caller의 stack frame에서 실행이 이어진다.
- frame pointer(= base pointer): 현재 frame의 시작을 가리키는 전용 register. function 내부 code는 frame pointer를 기준으로 현재 parameter와 local variable의 위치를 찾는다.
예: main → f → g 호출 중 stack 모습
main이 f를, f가 g를 호출하고 g 실행 도중 멈추면 stack은 다음과 같다 (위가 높은 주소).
- stack frame 안 요소들의 순서·세부는 CPU architecture·OS마다 다르다.
- caller와 callee가 stack frame 내용에 합의하기만 하면, 서로 다른 언어로 작성되거나 다른 compiler로 빌드되었더라도 호출 가능하다.
- 이 activation record 내용에 대한 합의를 calling convention이라 하며, 상세 기술 문서로 작성되어 compiler·OS·library 설계자들이 상호 운용성을 보장하는 데 쓴다.
- calling convention은 크게 두 부류: 인자를 stack에 두는 방식 vs register에 두는 방식.
9.4.1Stack Calling Convention
전통적 방식: 인자를 stack에 역순(reverse order)으로 push한 뒤, function 주소로 jump하면서 return address를 stack에 남긴다. 대부분 CPU는 전용 CALL instruction을 가진다.
f(10,20) 호출 assembly
PUSH $20
PUSH $10
CALL f
f가 실행을 시작하면 현재 frame pointer(old frame pointer)를 저장하고, 자신의 local variable 공간을 만든다. 그 결과 f(10,20)의 stack frame:
- f는 인자/local variable을 frame pointer 기준 메모리에서 load한다.
- function arguments는 frame pointer 위(above) 고정 위치에, local variables는 frame pointer 아래(below)에 있다.
- 인자를 역순으로 push하는 이유: 가변 개수 인자를 허용하기 위해. Argument 1은 항상 frame pointer 위 2 word, Argument 2는 항상 3 word 위 ... 로 고정된다.
9.4.2Register Calling Convention
대안: 인자를 register에 넣고 function을 호출한다. 예를 들어 %R10, %R11 ... 을 인자용으로 쓰기로 약속했다고 하자.
f(10,20) 호출 assembly (register convention)
MOVE $10 -> %R10
MOVE $20 -> %R11
CALL f
- f는 여전히 old frame pointer를 저장하고 local variable 공간을 만들어야 한다.
- 하지만 인자를 stack에서 load할 필요가 없다 —
%R10,%R11의 값을 바로 계산에 쓴다. - memory access를 피하므로 속도 이점이 있다.
f가 복잡해 다른 function을 호출해야 하면, 자신이 argument register를 쓰기 위해 현재 register 값을 저장(save)해야 한다. 이를 위해 f의 stack frame은 인자를 저장할 공간을 남겨 두어야 한다. calling convention은 이 인자 위치를 정의해야 하며, 보통 return address와 old frame pointer 아래에 둔다.
- 인자가 register보다 많으면, 초과분은 stack calling convention처럼 stack에 push된다.
- 두 convention의 선택은 큰 차이는 없으나, 모든 당사자가 세부에 합의해야 한다.
- register convention의 장점: leaf function(다른 function을 호출하지 않는 function)은 memory 접근 없이 간단한 결과를 계산 가능.
- 보통 사용하지 않으면 낭비될 register가 많은 아키텍처에서 register convention을 쓴다.
- 한 program 안에서 두 convention 혼용 가능 (caller·callee 모두 알기만 하면). 예: Microsoft X86 compiler —
cdecl=stack convention,fastcall=첫 두 인자를 register로.
9.5Locating Data
각 종류의 data에는 메모리에서 그것을 찾는 명확한 방법이 있어야 한다. compiler는 symbol의 기본 정보로 address computation을 생성하며, 이는 data의 storage class에 따라 달라진다.
storage class별 address computation
- 주소 계산이 가장 쉽다. 보통 compiler가 직접 계산하지 않고 global symbol 이름을 assembler에 넘기면 assembler가 주소 계산을 선택한다.
- 가장 단순한 경우 absolute address (정확한 위치)를 생성.
- 단, absolute address는 full word(예: 64-bit)라 instruction 크기와 같아 비효율적. 여러 instruction(RISC)이나 multi-word instruction(CISC)이 필요.
- base-relative address: register가 주는 base 주소 + assembler가 주는 fixed offset. 예: data segment 시작 register + offset. 동적 로드 library에 유용.
- PC-relative address: 참조 instruction과 target data 사이의 정확한 byte 거리를 계산해 instruction에 인코딩. 상대 거리가 충분히 작을 때(예: 16-bit) 사용. assembler가 처리하며 programmer에게는 보통 보이지 않음.
- local variable은 stack에 저장되므로, 매번 같은 absolute address를 차지하지 않는다.
- function이 recursive하게 호출되면 같은 local variable의 인스턴스가 동시에 여러 개 존재할 수 있다!
- 그래서 local variable은 항상 현재 frame pointer 기준 offset으로 지정한다 (offset은 calling convention에 따라 양수/음수).
- function parameter는 local variable의 특수한 경우: stack 위치는 parameter의 ordinal position으로 정확히 정해진다.
- heap data는 global/local variable에 저장된 pointer를 통해서만 접근 가능.
- compiler는 먼저 pointer 자체의 address computation을 생성한 뒤, pointer를 dereference해 heap의 항목에 도달한다.
복합 자료형: array
array는 global/local/heap 어디든 저장될 수 있고, 시작 위치는 위 방법들로 찾는다. element는 index에 item size를 곱해 array 주소에 더한다.
address(a[i]) = address(a) + sizeof(type) * i
- C 같은 unsafe 언어: 길이를 다루지 않음 → array 밖으로 나가도 그냥 주소를 계산 → chaos. 성능이 최우선인 경우 단순함이 안전성을 능가.
- 더 안전한 방법: array의 base address에 길이를 저장하고, 주소 생성 전에 index를 bounds와 비교.
- array a의 주소 계산
- a의 길이를 register에 load
- index i를 register와 비교
- i가 bounds 밖이면 exception 발생
- 아니면 a[i] 주소 계산 후 진행
이 패턴이 흔해 일부 아키텍처는 전용 지원을 제공. Intel X86은 값을 두 array bound와 비교해 벗어나면 "Array Bounds Exception"을 일으키는 전용 BOUND instruction을 가진다. (단점: 성능 — a[i]마다 위 과정 수행)
복합 자료형: structure
structure는 메모리에서 array와 매우 비슷하지만 불규칙한 크기의 항목을 담을 수 있다. 항목 접근은 structure 시작 주소를 계산한 뒤, 항목 이름(= structure tag)에 해당하는 offset을 더한다. offset이 compile time에 고정되므로 bounds checking은 불필요하다.
중첩 구조 예시
struct card {
int suit;
int rank;
};
struct deck {
int is_shuffled;
struct card cards[52];
};
struct deck d;
d.cards[10].rank = 10;
d.cards[10].rank의 address computation
address(d.card[10].rank) =
address(d)
+ offset(cards)
+ sizeof(struct card)*10
+ offset(rank)
먼저 d의 주소(local/global 여부에 따라)를 계산하고, cards의 offset, 열 번째 항목의 offset, card 내 rank의 offset을 차례로 더한다.
9.6Program Loading
program은 실행 전 disk에 파일로 존재하며, 이를 메모리로 load하는 convention이 필요하다. executable format은 매우 단순한 것부터 복잡한 것까지 다양하다.
① binary blob
- code·data·heap/stack 초기 상태를 구분 없이 하나의 파일에 그대로 덤프.
- 실행: OS가 파일 내용을 메모리에 load하고 program의 첫 위치로 jump.
- uninitialized data에 공간 낭비 (예: 값이 0인 큰 global array의 모든 0이 파일에 저장됨).
- OS가 메모리 사용 의도를 알 수 없어 logical segment별 permission을 설정 불가.
- 실행 파일임을 나타내는 식별 정보가 없음.
그래도 작고 단순함이 최우선인 곳에 쓰임 — PC OS의 첫 boot stage(boot disk의 한 sector를 읽음), 수 KB짜리 embedded system.
② a.out format
고전 Unix에서 오래 쓰인 개선 형식. header 구조 뒤에 text, initialized data, symbol table이 따른다 (이름의 유래: 첫 Unix assembler가 기본으로 a.out 파일에 출력).
header 구조
- Magic Number: 파일이 executable임을 명확히 정의하는 고유 정수. 이게 없으면 OS는 실행 시도조차 안 함. executable·unlinked object file·shared library마다 다른 magic number.
- Text Size: header 뒤 text section의 byte 수.
- Data Size: 파일에 나타나는 initialized data의 양.
- BSS Size: uninitialized data의 양. (BSS = "Block Started by Symbol", 1950년대 IBM 704 assembler에서 유래)
- uninitialized data는 파일에 저장될 필요 없이, load 시 data segment의 일부로 메모리에 할당된다.
- Symbol Table: 변수·function 이름과 code/data segment 내 위치 — debugger가 주소 의미를 해석 가능.
- Entry Point: program 시작점 주소 (보통
main). 시작점이 program 첫 주소가 아니어도 되게 함.
a.out은 binary blob보다 큰 개선이고 지금도 일부 OS에서 쓰이지만, dynamically loaded library 등 현대 언어 기능을 지원하기엔 부족하다.
③ ELF (Extensible Linking Format)
- 오늘날 여러 OS에서 executable·object file·shared library 표현에 널리 쓰임.
- a.out처럼 code·data·bss section을 가지되, debugging 데이터·초기화/종료 code·도구 metadata 등 임의 개수의 추가 section을 가질 수 있다.
- 파일의 section 수가 메모리의 segment 수보다 많으므로, ELF 파일의 section table이 여러 section을 하나의 segment로 매핑하는 방법을 나타낸다.
ELF 파일 구조
9.7Further Reading
메모리 관리는 보통 운영체제 수업에서 상세히 다룬다.
| 문헌 | 특징 |
|---|---|
| Arpaci-Dusseau & Arpaci-Dusseau, "Operating Systems: Three Easy Pieces" (2015) | memory allocator 등 운영체제 전반 복습용 온라인 교재 (ostep.org) |
| John R. Levine, "Linkers and Loaders" (1999) | compiler와 OS 사이 틈에 있는 linker·loader를 상세히 다룸. library 활용에 필수 |
| Paul R. Wilson, "Uniprocessor Garbage Collection Techniques" (1992) | 현대 동적 언어 runtime의 핵심인 garbage collection 기법 개관 |
⭐핵심 정리 / 시험 포인트
- logical segment 4개: code(text) · data · heap · stack.
- heap은 위로(up), stack은 아래로(down) 자란다. 둘 사이는 invalid region.
- heap 꼭대기 = break. heap 확장 = brk system call. stack 확장 = guard page + page fault.
- 금지된 접근 → page fault → OS가 위반 판단 시 segmentation fault.
- permission: code=read/execute, heap=read/write, data/stack=read-write, constants=read-only, unused=none.
mprotect로 변경 가능. - 64-bit 아키텍처는 hardware segmentation 없이 paging만 제공.
- malloc/free = linked list of chunks. free 시 인접 free chunk와 merge. fragmentation 발생.
- 할당 전략: Best / Worst / First / Next Fit(통념상 최선). Worst Fit이 의외로 fragmentation을 줄임.
- push → stack pointer 감소, pop → 증가. stack의 top은 가장 낮은 주소.
- stack frame = activation record: parameter + local variable + return address + old frame pointer. frame pointer가 frame 시작을 가리킴.
- calling convention = activation record 내용에 대한 합의. stack convention(인자를 역순 push) vs register convention(인자를 register에).
- arguments는 frame pointer 위, local variables는 frame pointer 아래.
- storage class: global=absolute/base-relative/PC-relative, local=frame pointer 기준 offset, heap=pointer dereference.
address(a[i]) = address(a) + sizeof(type)*i. structure tag offset은 compile time 고정.- executable format: binary blob → a.out → ELF. a.out header에 magic number·BSS size·entry point.
| 구분 | stack | register |
|---|---|---|
| 인자 위치 | stack (역순 push) | register (%R10...) |
| 장점 | 단순, 가변 인자 | memory 접근 회피, leaf function 빠름 |
| 키워드(MS X86) | cdecl | fastcall |
- Parameters (function arguments)
- Return Address
- Old Frame Pointer ← frame pointer 가리킴
- Local Variables ← stack pointer 근처
- Q. segmentation fault란? → 금지된 접근/unused area 접근 시 page fault → OS가 logical segmentation 위반으로 판단해 process를 강제 종료하며 내는 에러.
- Q. heap과 stack은 각각 어떻게 확장되나? → heap은
brksystem call, stack은 guard page에서의 page fault. - Q. 왜 local variable은 absolute address가 아닌 frame pointer offset으로 지정하나? → recursive 호출 시 같은 변수의 인스턴스가 동시에 여러 개 존재할 수 있어서.
- Q. 인자를 reverse order로 push하는 이유는? → 가변 개수 인자를 허용하기 위해. Argument 1은 항상 frame pointer 위 2 word 고정.
- Q. BSS size를 따로 두는 이유는? → uninitialized data는 파일에 저장하지 않고 load 시 data segment에 할당해 공간을 절약.
- Q. ELF가 a.out보다 나은 점은? → 임의 개수의 section(debug, init 등) 지원, section table로 여러 section을 한 segment로 매핑, dynamically loaded library 지원.
📋한눈에 보기 — Cheat Sheet
| 키워드 / 용어 | 의미 |
|---|---|
| code / text segment | program의 machine code (read/execute) |
| data segment | global data (read-write 변수 + read-only 상수) |
| heap segment | runtime 동적 메모리. malloc/free. 위로 자람 |
| stack segment | 실행 상태 + local variable. 아래로 자람 |
| break | heap의 꼭대기 (historic 용어) |
| brk | heap segment 확장 system call |
| guard page | stack 확장을 감지하는 invalid area 꼭대기 page |
| page fault → segfault | 금지된 접근 시 OS로 제어 이동 → 위반 시 강제 종료 |
| mprotect | 자기 page의 permission 변경 system call |
| paging vs segmentation | 64-bit은 paging만 제공, hardware segmentation 없음 |
| chunk (linked list) | malloc/free의 단순 구현. state·size·prev·next |
| fragmentation | 임의 순서 할당/해제로 chunk가 잘게 흩어짐 |
| Best/Worst/First/Next Fit | 할당 전략. Next Fit이 통념상 최선 |
| stack pointer | 다음 push/pop 위치. push→감소, pop→증가 |
| frame / base pointer | 현재 stack frame 시작을 가리킴 |
| stack frame | = activation record. param+local+return addr+old FP |
| calling convention | activation record 내용에 대한 합의 |
| CALL / PUSH | function 호출 / 인자 stack에 적재 instruction |
| leaf function | 다른 function을 호출하지 않는 function |
| cdecl / fastcall | stack convention / register convention (MS X86) |
| storage class | global / local / heap — address 계산 방식 다름 |
| absolute/base-relative/PC-relative | global data 주소 계산 방식 |
| BOUND | X86의 array bounds checking 전용 instruction |
| structure tag | structure 내 항목 이름 → compile time offset |
| binary blob | 가장 단순한 executable. 식별 정보·permission 없음 |
| a.out | header+text+data+symbols. magic number·BSS·entry point |
| BSS | uninitialized data. 파일 미저장, load 시 할당 |
| ELF | 현대 executable/object/shared library. section table |