공책 한 권을 두 과목이 같이 쓸 때, 한 과목은 앞장부터 쓰고 다른 과목은 뒷장부터 거꾸로 쓰면 어느 쪽이 얼마나 쓸지 미리 정하지 않아도 가운데 빈 장을 끝까지 나눠 쓴다. 프로그램이 받은 메모리도 이렇게 쓴다. 맨 아래에는 명령들과, 프로그램 내내 자리가 바뀌지 않는 값을 둔다. 함수를 부를 때 쌓는 쪽지 더미는 맨 위에서 아래로 자라고, 실행 중에 달라고 해서 받는 자리는 아래에서 위로 자란다. 둘이 가운데서 만나면 더 쓸 곳이 없다.
예시로 보기
데이터 정렬과 패딩에서 변수 하나의 모양을, 활성 레코드에서 함수 호출 하나의 모양을 정했다. 남은 물음은 “그것들을 메모리의 어디에 둘 것인가”다. 코드 생성기는 변수를 읽는 명령을 만들 때 그 변수가 어느 구역의 몇 번지에 있는지 알아야 한다. 이 배치도 컴파일러가 정한다.
C 코드 한 조각에 든 것들이 어디로 가는지 본다.
int count = 0; // 전역 변수
int main() {
int a = 3; // 지역 변수
int* p = (int*)malloc(sizeof(int) * 10); // 실행 중에 받는 자리
free(p);
}
프로그램이 이어진 메모리 한 덩어리를 쓴다고 치고, 낮은 번지부터 네 구역으로 나눈다.
높은 번지 +--------------------------------------+
| 스택: 활성 레코드 | main의 레코드 (a, p)
| | |
| v 아래로 자란다 |
| |
| (빈 곳) |
| |
| ^ 위로 자란다 |
| | |
| 힙: 실행 중에 받은 자료 | malloc이 준 int 10칸
+--------------------------------------+
| 전역 데이터: 번지가 고정된 자리 | count
+--------------------------------------+
| 코드 | main의 명령들
낮은 번지 +--------------------------------------+
p라는 변수 자체는 main의 레코드(스택)에 있고, p에 든 주소가 가리키는 열 칸은 힙에 있다. main이 끝나면 레코드와 함께 p는 사라지지만, 힙의 열 칸은 free를 부를 때까지 남는다.
네 구역
컴파일러는 코드와 자료를 메모리에 어떻게 둘지 정한다. 여기서는 프로그램이 이어진 메모리 공간 하나를 쓴다고 가정한다.
| 구역 | 든 것 | 크기가 정해지는 때 | 자리가 생기고 없어지는 때 |
|---|---|---|---|
| 코드 | 기계어 명령 | 컴파일할 때 | 프로그램 시작부터 끝까지 |
| 전역 데이터 | 전역 변수 | 컴파일할 때 | 프로그램 시작부터 끝까지 |
| 힙(heap) | 실행 중에 달라고 해서 받은 자료 | 실행 중 | malloc으로 받을 때 생기고 free로 돌려줄 때 없어진다 |
| 스택 | 활성 레코드 | 실행 중 | 함수를 부를 때 생기고 함수가 끝나면 없어진다 |
전역 변수. 전역 변수는 어디서 읽든 같은 하나를 가리켜야 한다. 호출마다 새로 생기는 활성 레코드에는 둘 수 없다. 그래서 바뀌지 않는 번지를 하나 정해 준다. 컴파일할 때 자리를 정해 두는 것을 정적 할당(static allocation)이라 부른다.
활성 레코드. 스택으로 관리하고, 스택은 높은 번지에서 낮은 번지 쪽으로 자란다.
실행 중에 받는 자료. C에서 int* a = (int*)malloc(sizeof(int) * 10);을 실행하면 a가 가리킬 자리가 실행 중에 생긴다. free(a)를 부르면 그 자리가 실행 중에 없어진다. 이런 자료를 동적 할당된 자료라 부르고, 힙에서 관리한다. 힙은 낮은 번지에서 높은 번지 쪽으로 자란다. 스택과 힙이 겹치지 않게 하려는 것이다.
왜 마주 보고 자라게 하는가. 스택과 힙은 얼마나 커질지 실행해 봐야 안다. 빈 곳을 반으로 갈라 한쪽씩 주면, 한쪽이 자기 몫을 넘는 순간 다른 쪽이 텅 비어 있어도 못 쓴다. 양 끝에서 마주 보고 자라게 하면 둘의 합이 빈 곳의 크기를 넘지 않는 한 어떤 비율로든 쓸 수 있다. 빈 곳이 12칸일 때 힙 10칸과 스택 2칸은 마주 보는 배치에서는 들어가고, 6칸씩 갈라 준 배치에서는 들어가지 않는다1.
지켜 주는 것과 지켜 주지 않는 것
지켜 주는 것.
- 코드와 전역 변수의 번지는 실행 내내 바뀌지 않는다. 컴파일러는 그 번지를 명령에 바로 적어 넣을 수 있다.
- 스택과 힙이 만나기 전까지는 네 구역이 서로 겹치지 않는다.
- 컴파일러는 컴파일할 때 코드와 자료의 배치를 정하고, 목표 자료가 있는 곳을 맞게 찾아가는 코드를 만든다.
지켜 주지 않는 것.
- 가운데 빈 곳이 바닥나지 않는다는 보장은 없다. 재귀가 너무 깊거나 힙에서 너무 많이 받으면 둘이 만난다1.
- 힙에서 받은 자리는 저절로 없어지지 않는다. C에서는
free를 불러야 한다. 부르지 않으면 그 자리는 가리키는 변수가 사라진 뒤에도 차지된 채로 남는다1. - 지역 변수의 번지는 고정되어 있지 않다. 같은 함수라도 스택이 얼마나 쌓인 뒤에 불렸느냐에 따라 레코드가 놓이는 번지가 다르다. 그래서 지역 변수는 번지가 아니라 “스택 꼭대기에서 몇 칸”으로 찾는다(단순 연산과 분기의 코드 생성).
활용
- 변수가 어느 구역에 가는지는 선언된 자리와 만드는 방법이 정한다. 함수 밖에 선언하면 전역 데이터, 함수 안에 선언하면 스택,
malloc으로 받으면 힙이다. - 흔한 실수:
int* p = malloc(...)의p도 힙에 있다고 답한다.p는 주소를 담은 지역 변수라 스택에 있고,p가 가리키는 곳이 힙이다. - 흔한 실수: 스택이 “위로 쌓인다”고 번지가 커진다고 생각한다. 그림에서 스택은 높은 번지에서 시작해 번지가 줄어드는 쪽으로 자란다. 스택에 한 칸을 올리는 명령이 스택 꼭대기 번지에서 4를 빼는 까닭이다(함수 호출의 코드 생성).
연결
- 선수: 활성 레코드(스택에 쌓이는 것), 데이터 정렬과 패딩(한 구역 안에서 변수가 놓이는 번지)
- 스택에 넣고 빼는 기계 명령과 꼭대기 번지가 줄어드는 모습: 런타임 스택과 PUSH·POP
- Java 가상 기계가 나눈 세 영역(메서드, 힙, 스택)과 견주기: 참조 타입과 메모리 영역. Java에서는
free대신 가상 기계가 안 쓰는 객체를 치운다. - 운영체제가 프로세스 하나에 주는 메모리의 모습: 프로세스 제어 블록
확인 문제
C1 메모리의 네 구역을 낮은 번지부터 쓰고, 스택과 힙이 자라는 방향을 쓰라.
답: 코드, 전역 데이터(정적으로 할당한 자리), 힙, 스택. 힙은 낮은 번지에서 높은 번지로, 스택은 높은 번지에서 낮은 번지로 자란다.
이유: 크기가 컴파일할 때 정해지는 둘을 아래에 고정하고, 실행 중에 크기가 바뀌는 둘을 남은 공간의 양 끝에 둔다.
C2 스택과 힙을 같은 방향으로 나란히 자라게 하지 않고 마주 보게 하는 까닭은?
답: 둘 다 얼마나 커질지 미리 모른다. 마주 보게 하면 빈 곳을 미리 가르지 않아도 되고, 둘의 합이 빈 곳을 넘지 않는 한 한쪽이 크게 자라도 된다.
이유: 같은 방향으로 자라게 하려면 둘 사이의 경계를 미리 정해야 한다. 그러면 한쪽이 경계에 닿았을 때 다른 쪽에 빈자리가 남아 있어도 쓰지 못한다. 흔한 오답은 “스택이 더 중요해서 위에 둔다”인데, 위아래가 아니라 서로 반대쪽 끝에서 출발한다는 것이 요점이다.
C3 다음이 각각 어느 구역에 있는지 말하라. ① 함수 밖에 선언한 int total; ② fib 안의 임시 변수 t0 ③ malloc(40)이 돌려준 주소가 가리키는 40바이트 ④ 그 주소를 받아 둔 지역 변수 p ⑤ fib의 명령들 ⑥ fib가 끝난 뒤 돌아갈 주소
답: ① 전역 데이터 ② 스택(그 호출의 활성 레코드) ③ 힙 ④ 스택 ⑤ 코드 ⑥ 스택(활성 레코드의 저장한 기계 상태 칸).
이유: ③과 ④를 가르는 것이 요점이다. 주소가 가리키는 곳과 주소를 담은 변수는 다른 구역에 있다. ⑥의 돌아갈 주소는 코드 구역의 한 곳을 가리키는 값이지만, 그 값을 적어 둔 칸은 스택에 있다.