C 프로그램의 모든 변수는 어딘가에 살고, 어디냐가 나중에 바꿀 수 없는 두 가지를 결정합니다. 얼마나 오래 살아남는지, 그리고 얼마나 많이 가질 수 있는지입니다. C는 저장 영역 셋을 주고, 잘못 고르면 크래시이거나 누수입니다. 이 페이지는 그것들을 펼쳐 놓고, 수명을 틀리게 잡아 생기는 고전적인 버그를 보여 줍니다.
세 영역
높은 주소
+---------------------------+
| 스택 | 지역 변수, 매개변수, 반환 주소
| 아래로 자람 | | 반환 시 자동으로 해제됨
| v |
+---------------------------+
| (빈 공간) |
+---------------------------+
| ^ |
| 위로 자람 | |
| 힙 | malloc / calloc / realloc 덩어리
+---------------------------+ free()로만 해제됨
| 정적 / 전역 데이터 | 전역 변수와 static, 실행 내내
+---------------------------+
| 코드 (text) | 기계어, 읽기 전용
+---------------------------+
낮은 주소
- **자동 저장 공간(스택)**은 함수 매개변수와 static이 아닌 지역 변수를 담습니다. 함수에 들어갈 때 스택 한 덩어리를 차지하고 반환할 때 놓아줍니다. 크기는 컴파일 타임에 고정됩니다.
- **동적 저장 공간(힙)**은
malloc,calloc,realloc에서 나온 모든 것을 담습니다. 크기는 실행 중에 결정되고, 수명은free에서만 끝납니다. - 정적 저장 공간은 전역 변수와
static으로 선언된 모든 것을 담습니다. 프로그램이 도는 내내 존재하고main이 시작하기 전에 0으로 초기화됩니다.
그림의 주소 배치는 흔한 배열이지 보장은 아닙니다. 표준은 배치가 아니라 수명을 기술합니다.
작동 중인 자동 저장 공간
demo를 호출할 때마다 새로운 local과 새로운 table을 얻습니다. 손으로 해제하는 것이 없고, 샐 수도 없으며, 할당은 스택 포인터를 옮기는 명령어 하나입니다. 평범한 지역 변수가 기본값이어야 하는 이유입니다. C가 가진 가장 빠르고 가장 안전한 저장 공간입니다.
함정은 닫는 중괄호입니다. 그것이 실행되면 그 메모리는 사라집니다.
댕글링 포인터
모든 C 프로그래머가 한 번은 쓰는 버그입니다.
/* 망가짐: 더 이상 존재하지 않는 메모리의 주소를 반환한다 */
int *make_counter(void) {
int count = 0;
return &count; /* count는 이 중괄호에서 죽는다 */
}
int main(void) {
int *p = make_counter();
*p = 5; /* 죽은 스택 프레임에 쓰기 */
return 0;
}
&count는 make_counter가 도는 동안에는 완벽히 유효한 주소였습니다. 반환하면 그 스택 공간이 다음에 호출되는 함수에 넘어가므로, p는 이제 남의 지역 변수를 가리킵니다. 읽으면 쓰레기가 나오고, 쓰면 그것들이 망가집니다. GCC와 Clang이 정확히 이 모양에 대해 경고하므로(-Wreturn-local-addr), 경고를 켜고 컴파일하세요.
같은 버그가 배열에서는 변장을 하는데, 거기서는 경고가 종종 발동하지 않습니다.
이 함수의 망가진 판은 지역 char buf[64]에 텍스트를 만들고 return buf; 했을 것입니다. 바로 그 순간 존재하기를 멈추는 버퍼의 주소를 반환하는 것이죠.
고치는 세 가지 방법
1. 호출자가 버퍼를 준다(위에 보인 것). 할당도 없고 소유권 문제도 없으며, C 라이브러리에서 가장 흔한 스타일입니다. 함수는 크기 안에 머물 수 있도록 크기를 함께 받습니다.
2. 힙 메모리를 반환하되, 누가 해제하는지 밝힌다.
힙 덩어리는 설계상 함수보다 오래 삽니다. 그것이 동적 메모리의 요점 전부입니다. 비용은 소유권 주석과 호출자의 free입니다.
3. 정적 저장 공간을 쓴다. 공유 버퍼 하나면 충분할 때입니다.
함수 안의 static은 변수의 스코프를 지역으로 유지하면서 프로그램의 수명을 주므로, 그 주소를 반환하는 것이 합법입니다. 대가는 그것이 언제나 하나뿐이라는 점입니다. 모든 호출자가 공유하므로 스레드 코드에서는 쓸 수 없고, 단일 스레드 코드에서도 두 호출자가 동시에 포인터를 쥐면 놀라운 일이 생깁니다.
크기: 스택이 바닥나는 곳
스택 공간은 작고 고정되어 있습니다. 주 스레드는 보통 윈도우에서 1MB, 리눅스에서 8MB를 받고, 생성된 스레드는 종종 훨씬 적게 받습니다. 힙은 시스템의 가용 메모리에 제한됩니다.
void bad(void) {
int huge[1000000]; /* 약 4MB의 스택 - 진입 시점에 죽을 가능성이 크다 */
huge[0] = 1;
}
진단도 없고 확인할 NULL도 없습니다. 프로그램은 본문 첫 줄이 실행되기도 전에 보통 세그멘테이션 폴트로 그냥 죽습니다. 힙 판은 실패를 제대로 보고합니다.
깊은 재귀도 프레임 하나씩 같은 방식으로 스택을 소진합니다. 실무에서 스택 오버플로의 가장 흔한 원인은 폭주하는 재귀 함수입니다.
비용과 지역성
스택 할당은 레지스터에 대한 산술 연산 하나입니다. 힙 할당은 알맞은 덩어리를 찾고, 잠금을 잡을 수도 있고, 가끔 운영체제에 메모리를 더 요청하는 라이브러리 호출입니다. 자주 도는 반복문에서는 그 차이가 측정됩니다.
스택 데이터는 빽빽하고 최근에 건드려졌으므로 캐시에 있는 경향이 있습니다. 힙 덩어리는 흩어져 있을 수 있습니다. 어느 사실도 그 자체로 설계를 이끌어서는 안 됩니다. 수명의 정확성이 먼저입니다. 다만 둘 다 올바른 두 설계 사이에서는 보통 스택 쪽이 더 빠릅니다.
영역 들여다보기
주소를 출력하면 배치가 구체적으로 보입니다. 정확한 값은 실행마다 다르지만(현대 시스템은 무작위로 배치합니다), 무리 짓는 모습은 보입니다.
전역 변수와 정적 변수가 나란히 앉아 있고, 힙 덩어리는 다른 곳에 있으며, 지역 변수는 보통 둘 다에서 멀리 떨어져 있습니다. %p에는 void *로 캐스팅하세요. 형식 지정자가 요구하는 것이 그것입니다.
선택하기
다음일 때 스택을 쓰세요.
- 크기를 컴파일 타임에 알고,
- 데이터가 이 함수와 그것이 부르는 함수들 안에서만 필요하며,
- 작을 때 - 메가바이트가 아니라 몇 킬로바이트.
다음일 때 힙을 쓰세요.
- 크기가 입력이나 파일, 계산에 달려 있거나,
- 데이터가 그것을 만든 함수보다 오래 살아야 하거나,
- 스택 한계를 위협할 만큼 클 때.
다음일 때 정적을 쓰세요.
- 프로그램 전체에 정확히 하나만 존재해야 하고,
- 모든 호출자가 그것을 공유하는 것이 정말로 올바를 때.
기본값은 스택입니다. 힙의 세 이유 중 하나가 해당될 때 힙에 손을 뻗고, 그럴 때는 메모리 누수의 소유권 규칙을 따라 수명을 얻어 낸 그 덩어리가 결국 놓여나게 하세요.
거울상인 두 버그
이 둘은 함께 이름 붙일 만합니다. 같은 수명 질문에 두 가지로 답한 것이기 때문입니다.
- 댕글링 포인터 - 메모리가 포인터보다 먼저 죽었습니다.
&local을 반환하거나free뒤에 포인터를 쓰는 것이죠. 프로그램이 이제 다른 것의 소유가 된 저장 공간을 읽거나 씁니다. - 메모리 누수 - 포인터가 메모리보다 먼저 죽었습니다.
malloc덩어리에 대한 마지막 참조를 잃는 것이죠. 당장 깨지는 것은 없고, 프로세스가 커질 뿐입니다.
둘 다 데이터가 얼마나 오래 살아야 하는지와 그것을 어느 영역에 두었는지가 어긋나 생깁니다. 수명을 먼저 결정하면 영역은 따라옵니다.
자주 묻는 질문
C에서 스택과 힙의 차이는 무엇인가요?
스택은 지역 변수를 담습니다. 컴파일러가 크기를 정하고, 함수에 들어갈 때 만들어지고 반환할 때 없어지며, 할당에 비용이 들지 않습니다. 힙은 malloc 덩어리를 담습니다. 실행 중에 크기를 고르고, 덩어리는 free 할 때까지 살아남으며, 할당에 실제 비용이 듭니다.
C에서 왜 지역 변수의 포인터를 반환할 수 없나요?
함수가 반환하는 순간 그 지역 변수의 저장 공간이 놓여나기 때문입니다. 포인터는 여전히 그 주소를 담고 있지만 그 메모리는 이제 다음 함수 호출의 것입니다. 읽으면 쓰레기가 나오고 쓰면 무관한 데이터가 망가집니다. 그것이 댕글링 포인터입니다. malloc 덩어리를 반환하거나, 호출자가 버퍼를 주게 하세요.
C에서 스택은 얼마나 큰가요?
주 스레드에서 보통 1MB에서 8MB이고, 추가 스레드는 훨씬 적습니다. 지역 변수로 선언한 int big[1000000];이 보통 진입 시점에 프로그램을 죽일 만큼 작습니다. 힙은 사용 가능한 시스템 메모리에 제한되므로, 크거나 크기를 모르는 데이터는 거기에 속합니다.
C에서 언제 스택 대신 힙을 써야 하나요?
세 경우입니다. 크기를 실행 전까지 알 수 없을 때, 데이터가 그것을 만든 함수보다 오래 살아야 할 때, 또는 덩어리가 스택에 넣기에 너무 클 때(대략 수백 킬로바이트를 넘는 것)입니다. 그 밖의 모든 것은 평범한 지역 변수여야 합니다. 더 빠르고 샐 수도 없으니까요.