45 동적 메모리
먼저 알아야 할 것
돌아보기
44장에서 자동 수명(함수와 함께 죽는다)과 정적 수명(프로그램 내내 산다)을 배웠다. 그러면 “입력에 따라 크기가 정해지고, 필요한 동안만 살아야 하는” 기억은 — 둘 중 어디에 두는가?
답. 어느 쪽도 맞지 않는다. 크기가 실행 중에 정해지고 수명이 프로그램의 논리에 따라 갈리는 기억이 필요하다 — 그래서 세 번째 자리가 있다: 동적 저장 기간, 흔히 힙(heap)이라 부르는 창고다. 이 자리의 규칙은 앞의 둘과 결정적으로 다르다 — 태어남과 죽음을 프로그래머가 직접 지시한다.
이 장의 필요성과 맥락
이 장이 끝나면
malloc과 free, 소유라는 개념, 그리고 이 세계의 세 가지 대표 사고(누수·이중 해제·해제 후 사용)까지. 기억의 배치가 여기서 완성된다.이 장에서 답할 질문
- 그러면 캐스트가 맞는 자리는 없는가?
- 그래서 이 책은 어느 쪽인가?
- 포인터의 배열(
int *)로 만들면m[i][j]문법을 그대로 쓸 수 있지 않은가? - 그러면 동적 메모리를 되도록 피하는 것이 상책인가?
45.1 빌리고, 돌려준다
문법은 함수 두 개다. malloc(바이트 수)는 창고에서 그만큼의 연속된 칸을 빌려 그 시작 주소를 돌려주고, free(주소)는 빌린 것을 돌려준다.
examples/ch45/dyn.c
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int count = 5;
int *scores = malloc(count * sizeof *scores); /* 창고에서 빌린다 */
if (scores == nullptr) { /* 빌리기는 실패할 수 있다 */
printf("allocation failed\n");
return 1;
}
int sum = 0;
for (int i = 0; i < count; i += 1) {
scores[i] = (i + 1) * 10;
sum += scores[i];
}
printf("total: %d\n", sum);
free(scores); /* 빌린 것은 돌려준다 */
return 0;
}
실행 결과
total: 150
세 가지 관행이 시연에 박혀 있다.
크기 계산은 sizeof *포인터로. count * sizeof *scores는 “원소 하나의 크기 × 개수”인데, 타입 이름 대신 대상으로 크기를 물었다 — 나중에 타입이 바뀌어도 이 줄은 고칠 필요가 없다.
빌리기는 실패할 수 있다. malloc은 창고가 부족하면 널 포인터를 돌려준다 — 그래서 쓰기 전에 확인한다(36장의 수칙이 여기서 실전이 된다). 확인 없이 쓰면 널 역참조로 붕괴다.
빌린 것은 돌려준다. free를 잊으면 그 칸은 프로그램이 끝날 때까지 묶여 있다 — 메모리 누수(leak)다. 짧은 프로그램에서는 티가 안 나지만, 오래 도는 서버에서는 조금씩 새어 나가 결국 기계를 잡아먹는다.
흔한 오해. “calloc으로 잡으면 포인터가 널로 초기화된다”
calloc이 약속하는 것은 모든 비트를 0으로 채운다는 것 하나다. 그것이 「널 포인터」나 「0.0」을 뜻하는 것은 아니다 — 36장에서 본 표기와 표현의 구별이 여기서도 그대로 걸린다.
calloc이 약속하는 것 | 약속하지 않는 것 |
|---|---|
| 잡은 기억의 모든 비트가 0 | 그 비트가 널 포인터를 뜻한다는 것 |
| 정수 0을 담은 것과 같은 비트 | 부동소수 0.0을 뜻한다는 것(IEEE 754에서는 우연히 맞다) |
표 45.1
오늘날의 주류 기계에서는 셋이 전부 일치하므로 사고가 나지 않는다. 다만 포인터 배열을 calloc으로 잡고 “전부 널이니 검사만 하면 된다”고 적는 코드는, 그 우연 위에 서 있다는 것을 알고 쓰는 편이 낫다. 이식성이 걸린 코드라면 잡은 뒤 루프로 nullptr를 채우거나, 애초에 「비어 있음」을 0이 아닌 다른 방법으로 표시한다.
45.2 malloc을 어떻게 적을 것인가 — 오래된 논쟁
같은 할당을 적는 방법이 여럿이고, 어느 쪽이 옳은가를 두고 C 세계가 30년째 다투고 있다. 인터넷에서 이 주제의 글을 만나면 대개 격앙되어 있는데, 양쪽 다 근거가 있어서 그렇다. 실제로 재어 보고 판단하자.
| 표기 | 모양 | 누가 권하는가 |
|---|---|---|
| ① 대상에서 크기를 가져오기 | p = malloc(n * sizeof *p); | K&R 이래의 관행, 리눅스 커널, C FAQ |
| ② 타입을 적고 캐스트 | p = (T *)malloc(n * sizeof(T)); | CERT MEM02-C, C++ 호환을 중시하는 코드베이스 |
| ③ 절충 | p = (T *)malloc(n * sizeof *p); | 캐스트는 필요하되 크기는 어긋나지 않게 |
표 45.2
examples/ch45/malloc_style.c
/* malloc 을 적는 세 가지 표기 — 무엇이 다른가. */
#include <stdio.h>
#include <stdlib.h>
struct node { int id; double weight; };
int main(void)
{
/* ── 권장 표기: 타입 이름이 한 번도 안 나온다 ── */
struct node *a = malloc(4 * sizeof *a);
if (!a) return 1;
/* ── C++ 호환을 노린 표기: 타입 이름이 두 번 ── */
struct node *b = (struct node *)malloc(4 * sizeof(struct node));
if (!b) { free(a); return 1; }
/* ── 절충: 캐스트는 붙이되 크기는 대상에서 가져온다 ── */
struct node *c = (struct node *)malloc(4 * sizeof *c);
if (!c) { free(a); free(b); return 1; }
printf("all three reserve the same size: %zu, %zu, %zu bytes\n",
4 * sizeof *a, 4 * sizeof(struct node), 4 * sizeof *c);
puts("\n[sizeof *p does not read p - an unevaluated operand]");
struct node *nul = NULL;
printf(" even with p null, sizeof *p works out to %zu\n", sizeof *nul);
puts(" the size comes from the *type*, not from the value.");
puts("\n[write the name twice and the two can drift apart]");
puts(" struct node *p = (struct node *)malloc(n * sizeof(struct gadget));");
puts(" ^ the type name is in two places, so it is possible to fix only one.");
puts(" written as sizeof *p, that mistake *cannot be written*.");
puts("\n[a risk both spellings carry - overflow in the multiplication]");
size_t huge = (size_t)-1 / sizeof(struct node) + 1;
printf(" huge * sizeof *a wraps around to %zu\n", (size_t)(huge * sizeof *a));
puts(" -> malloc can then succeed with a 'tiny' size. Check the count first, or");
puts(" use calloc(count, size), which checks the multiplication itself.");
free(a); free(b); free(c);
return 0;
}
실행 결과
all three reserve the same size: 64, 64, 64 bytes
[sizeof *p does not read p - an unevaluated operand]
even with p null, sizeof *p works out to 16
the size comes from the *type*, not from the value.
[write the name twice and the two can drift apart]
struct node *p = (struct node *)malloc(n * sizeof(struct gadget));
^ the type name is in two places, so it is possible to fix only one.
written as sizeof *p, that mistake *cannot be written*.
[a risk both spellings carry - overflow in the multiplication]
huge * sizeof *a wraps around to 0
-> malloc can then succeed with a 'tiny' size. Check the count first, or
use calloc(count, size), which checks the multiplication itself.
45.2.1 ①이 권장되는 이유 넷
첫째, 타입이 바뀌어도 고칠 자리가 하나다. 이것이 가장 큰 이유다. 리눅스 커널의 코딩 스타일 문서가 그 이유를 이렇게 적는다 — 타입 이름을 풀어 적는 형태는 「읽기를 해치고, 포인터 변수의 타입이 바뀌었는데 할당자에 넘기는 sizeof 는 바뀌지 않을 때 생기는 버그의 기회를 만든다」.
int * 였던 것을 struct node * 로 바꾸는 흔한 일을 생각해 보라. ①은 선언 한 줄만 고치면 크기가 저절로 따라오지만, ②는 두 곳을 고쳐야 하고 한쪽을 잊으면 크기가 모자란 채로 컴파일된다.
둘째, 타입 이름을 아예 적지 않으니 어긋날 수가 없다. 「고칠 자리가 하나」보다 한 걸음 더 나아간 성질이다 — ①에는 틀리게 쓸 방법 자체가 없다.
셋째, sizeof *p 는 p 를 읽지 않는다. 처음 보면 「아직 할당도 안 된 포인터를 역참조하는 것 아닌가」 싶은데, 아니다. sizeof 의 피연산자는 평가되지 않는다 (49장). 시연이 그것을 확인한다 — p 가 널이어도 sizeof *p 는 멀쩡히 계산된다. 크기는 타입에서 나오지 값에서 나오지 않기 때문이다.
넷째, 캐스트가 옛날에는 버그를 숨겼다. 표준 이전과 C89 시절, <stdlib.h> 를 포함하지 않고 malloc 을 부르면 컴파일러가 「int 를 돌려주는 함수」로 가정했다. 그 int 를 포인터에 넣으면 진단이 나오는데, 캐스트가 그 진단을 지워 버린다. 실측으로 재현하면 이렇다.
| 선언 없이 부를 때(C89, 내장 지식 끄고) | 컴파일러가 하는 말 |
|---|---|
p = (int *)f(4); | cast to pointer from integer of different size — 캐스트 탓으로 뭉개진다 |
p = f(4); | initialization of 'int *' from 'int' makes pointer from integer without a cast — 정확히 짚는다 |
표 45.3
다만 이 넷째 논거는 오늘날 거의 힘을 잃었다는 것을 정직하게 적어 둔다. C99 가 암묵적 함수 선언을 없앴고, C23 에서는 아예 오류다 — 실측하면 캐스트가 있든 없든 error: implicit declaration of function 'malloc' 이 난다. 「캐스트가 버그를 숨긴다」는 말은 이제 역사적 설명이지 현재의 이유가 아니다. 이 논거를 지금도 결정타처럼 드는 글을 보면, 그 글이 몇 년도의 사정을 말하는지 확인하는 편이 좋다.
45.2.2 ②를 권하는 쪽의 근거 — CERT MEM02-C
반대편에도 규격의 지지가 있다. SEI CERT C 코딩 표준의 MEM02-C 는 제목부터 「메모리 할당 함수의 결과를 즉시 할당된 타입의 포인터로 캐스트하라」이고, 근거는 이것이다 — 「malloc() 의 결과를 알맞은 포인터 타입으로 캐스트하면 뒤이은 부주의한 포인터 변환을 컴파일러가 잡을 수 있다.」
이 주장은 실제로 성립한다. CERT 가 든 예를 그대로 컴파일해 보면 차이가 분명하다.
| 코드 | GCC 14 가 내는 것 |
|---|---|
widget *p = malloc(sizeof(gadget)); | 경고 — allocation of insufficient size '1' for type 'struct widget' with size '40' |
widget *p = (gadget *)malloc(sizeof(gadget)); | 오류 — assignment to 'struct widget *' from incompatible pointer type |
widget *p = malloc(sizeof *p); | 아무것도 — 그 버그를 쓸 수가 없다 |
표 45.4
캐스트가 경고를 오류로 격상시키는 것은 사실이다. 다만 표의 셋째 줄이 이 논쟁의 핵심을 보여 준다 — ①은 진단이 필요 없다. 진단할 잘못을 애초에 만들 수 없기 때문이다. 그리고 CERT 자신도 이 권고의 위험도를 심각도 낮음 / 가능성 희박 / 우선순위 P3 로 매겨 두었다.
45.2.3 ③과 「C++ 호환」이라는 이유 — 정말 그런가
가장 자주 듣는 캐스트의 이유는 「C++ 컴파일러로도 빌드해야 해서」다. C++ 는 void * 에서 다른 포인터로의 암묵 변환을 허용하지 않으므로, 캐스트가 없으면 정말로 컴파일이 안 된다 — 실측하면 error: invalid conversion from 'void*' to 'int*' 다.
그런데 이 이유를 그대로 받아들이기 전에 세 가지를 따져야 한다.
첫째, C++ 는 애초에 malloc 을 권하지 않는다. C++ 에서 메모리를 잡는 길은 new, 더 정확히는 컨테이너와 스마트 포인터다. 그러니 「C++ 로도 빌드되는 malloc 호출」이라는 것은 C++ 에서 권장되지 않는 코드를 C++ 문법에 맞춰 두는 일이다. 목적이 무엇인지 스스로 물어야 한다.
둘째, 그 호환이 실제로는 얻어지지 않는다. 이것이 결정적이다. 캐스트를 성실히 붙인 현대 C 파일을 C++ 로 넘겨 보았다.
| 파일에 든 것 | C++ 컴파일러가 하는 말 |
|---|---|
(struct pt *)malloc(n * sizeof *p) | 통과 — 캐스트 덕에 |
struct pt a = { .z = 3, .x = 1 }; | 오류 — designator order for field 'pt::x' does not match declaration |
_Generic(n, int: 1, default: 0) | 오류 — '_Generic' was not declared in this scope |
int vla[n]; | 표준 C++ 에는 없다(GNU 확장으로만 통과) |
표 45.5
즉 캐스트를 붙여도 그 파일은 C++ 로 넘어가지 않는다. 지정 초기화의 순서 제약, _Generic, 가변 길이 배열, restrict, 복합 리터럴, 유연 배열 멤버 — 두 언어는 1989년 이후 꾸준히 갈라졌고, C23 과 C++23 의 거리는 그 어느 때보다 멀다(96장). 캐스트 하나로 사는 것은 호환이 아니라 호환의 착각이다.
셋째, 진짜로 공유되는 것은 헤더이지 .c 파일이 아니다. C 와 C++ 가 만나는 현실적인 자리는 헤더이고(extern "C", 56장), 헤더는 malloc 을 부르지 않는다. malloc 호출은 구현 파일에 있고, 구현 파일은 C 컴파일러가 컴파일한다.
문. 그러면 캐스트가 맞는 자리는 없는가?
답. 있다. 다만 「혹시 모르니」가 아니라 실제 사정이 있을 때다.
.c파일을 정말로 C++ 로 빌드하는 프로젝트. 드물지만 존재한다 — 일부 임베디드 툴체인, 옛 MSVC 관행, C 코드를 C++ 번역 단위에 통째로 끌어다 쓰는 빌드. 이때는 선택이 아니라 요구다.- CERT 를 따르는 규약 아래. 96장에서 본 대로, 규약이 있는 자리에서는 규약이 이긴다.
그런 자리라면 ③ (T *)malloc(n * sizeof *p) 을 권한다 — 캐스트로 C++ 를 만족시키되 크기는 대상에서 가져와 ①의 안전을 잃지 않는다. 실측에서 이 형태는 C 와 C++ 양쪽에서 다 통과했다. 둘 중 하나를 버려야 한다면 「타입 이름을 두 번 적는 것」을 버리는 것이 낫다.
반례. 두 표기가 함께 지는 진짜 위험 — 곱셈의 넘침
캐스트 논쟁에 가려 잘 이야기되지 않는 것이 있다. n * sizeof *p 의 곱셈이 넘칠 수 있다.
p = malloc(n * sizeof *p); /* n 이 크면 곱이 감아 돈다 */시연이 보인 대로, 감아 돈 결과가 아주 작은 수가 되면 malloc 은 성공한다. 그리고 그 작은 그릇에 n 개를 쓰는 순간 힙이 무너진다. 캐스트를 붙이든 안 붙이든 똑같이 열려 있는 구멍이고, 실제 취약점으로 여러 번 이어졌다.
막는 길은 셋이다.
- 개수를 먼저 검사한다 —
if (n > SIZE_MAX / sizeof *p) return NULL; calloc(n, sizeof *p)를 쓴다 — 표준이 곱셈을 검사하도록 요구한다. 실측에서calloc은 넘치는 요청을 실패로 돌려주었다(0으로 채우는 비용은 덤이자 대가다).- 검사 산술을 쓴다 — C23 의
<stdckdint.h>(81장).
캐스트를 붙일지 말지보다 이쪽이 훨씬 값진 논쟁이다.
문. 그래서 이 책은 어느 쪽인가?
답. ①이다. p = malloc(n * sizeof *p);
이유를 한 줄로 줄이면 — 틀릴 방법이 없는 표기가, 틀리면 잡아 주는 표기보다 낫다. 캐스트는 「어긋남을 진단으로 바꿔 주는」 장치인데, ①은 어긋남 자체를 없앤다. 12장의 사다리로 말하면 3단(빌드가 알리게)보다 좋은 것은 애초에 그 상태가 되지 않는 설계다.
다만 이것을 규칙으로 강요할 생각은 없다. .c 를 C++ 로 빌드해야 하는 사정이 있는 코드베이스, CERT 를 따르는 규약 아래의 코드에서 ②나 ③은 옳은 선택이다. 알고 고르는 것과 관행을 물려받는 것은 다르다 — 이 논쟁에서 가장 흔한 잘못은 어느 표기를 고르느냐가 아니라, 「예전에 그렇게 배워서」 이유를 모른 채 캐스트를 붙이는 것이다.
45.3 돌려받은 주소의 정렬 — 무엇을 담을지 모르므로
examples/ch45/alloc_cost.c
/* malloc 이 돌려주는 주소의 정렬, 그리고 빌리고 돌려주는 값 */
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define N 300000
static double seconds_since(struct timespec t0)
{
struct timespec t1;
timespec_get(&t1, TIME_UTC);
return (double)(t1.tv_sec - t0.tv_sec) + (double)(t1.tv_nsec - t0.tv_nsec) / 1e9;
}
static void *volatile sink; /* 최적화가 할당을 지우지 못하게 붙잡는다 */
int main(void)
{
/* ── ① 정렬: 무엇을 담을지 모르므로 가장 엄격한 정렬로 준다 ── */
printf("alignment of max_align_t = %zu bytes\n", alignof(max_align_t));
size_t misaligned = 0;
void *p[8];
for (int i = 0; i < 8; i++) {
p[i] = malloc(1); /* 1바이트만 요청해도 */
if ((uintptr_t)p[i] % alignof(max_align_t) != 0) misaligned++;
}
printf("of 8 one-byte requests, ones breaking the default alignment: %zu\n", misaligned);
printf("address gap between two neighbouring blocks: %td bytes (though 1 was asked for)\n",
(char *)p[1] - (char *)p[0]);
for (int i = 0; i < 8; i++) free(p[i]);
/* ── ② 값: 같은 일을 세 방식으로 ── */
struct timespec t0;
unsigned long long acc = 0;
timespec_get(&t0, TIME_UTC);
for (int i = 0; i < N; i++) { /* 매번 빌리고 돌려준다 */
char *b = malloc(64);
if (!b) return 1;
b[0] = (char)i;
acc += (unsigned char)b[0];
sink = b;
free(b);
}
double t_alloc = seconds_since(t0);
char *reused = malloc(64);
if (!reused) return 1;
timespec_get(&t0, TIME_UTC);
for (int i = 0; i < N; i++) { /* 한 번 빌려 계속 쓴다 */
reused[0] = (char)i;
acc += (unsigned char)reused[0];
sink = reused;
}
double t_reuse = seconds_since(t0);
free(reused);
timespec_get(&t0, TIME_UTC);
for (int i = 0; i < N; i++) { /* 아예 스택에 둔다 */
char b[64];
b[0] = (char)i;
acc += (unsigned char)b[0];
sink = b;
}
double t_stack = seconds_since(t0);
printf("\n%d iterations\n", N);
printf(" malloc + free every time : %7.4f s (%5.1f ns each)\n", t_alloc, t_alloc / N * 1e9);
printf(" borrow once and reuse : %7.4f s (%5.1f ns each)\n", t_reuse, t_reuse / N * 1e9);
printf(" a stack array : %7.4f s (%5.1f ns each)\n", t_stack, t_stack / N * 1e9);
printf("\n(total %llu - a value we use so the work is not optimized away)\n", acc);
return 0;
}
실행 결과
alignment of max_align_t = 16 bytes
of 8 one-byte requests, ones breaking the default alignment: 0
address gap between two neighbouring blocks: 32 bytes (though 1 was asked for)
300000 iterations
malloc + free every time : 0.0036 s ( 12.1 ns each)
borrow once and reuse : 0.0006 s ( 2.0 ns each)
a stack array : 0.0007 s ( 2.2 ns each)
(total 114739248 - a value we use so the work is not optimized away)
예제의 첫 부분이 답하는 질문은 이것이다. malloc(1)이라고 겨우 1바이트를 빌렸는데, 그 주소는 아무 데나 놓여도 되는가?
안 된다. malloc은 무엇을 담을지 모른 채 자리를 내주기 때문이다. 돌려준 자리에 double을 놓을 수도, 포인터를 놓을 수도, 큰 구조체를 놓을 수도 있다. 그래서 표준은 이렇게 약속한다 — malloc 계열이 돌려주는 주소는 어떤 기본 타입에도 맞는 정렬을 만족한다. 그 “가장 엄격한 기본 정렬”에 붙은 이름이 max_align_t이고, 이 기계에서는 16바이트다(6장의 정렬 규칙이 여기서 실물이 된다).
두 가지가 따라 나온다.
첫째, 작게 빌려도 자리는 그만큼 작지 않다. 예제에서 1바이트씩 여덟 번 빌렸는데 이웃 블록의 주소 간격이 32바이트였다. 정렬을 맞추느라 남는 자리와, 할당자가 블록마다 붙이는 관리 정보(크기·상태) 때문이다. 작은 조각을 수없이 많이 빌리는 프로그램은 요청한 것보다 훨씬 많은 기억을 쓴다는 뜻이고, 이것이 뒤에서 볼 아레나·풀 같은 방식이 태어난 이유다(84장).
둘째, 더 엄격한 정렬이 필요하면 따로 요청해야 한다. SIMD 명령이나 하드웨어 DMA가 64바이트·4096바이트 정렬을 요구하는 경우가 있는데, 이때 쓰는 것이 C11의 aligned_alloc(정렬, 크기)이다. 규칙 두 가지를 지켜야 한다 — 정렬은 2의 거듭제곱이어야 하고, C11에서는 크기가 정렬의 배수여야 했다(C23에서 이 제약이 풀렸다). 돌려주는 것은 여전히 free로 반납한다.
플랫폼 노트. 정렬 할당의 이름은 플랫폼마다 다르다
표준의aligned_alloc은 비교적 최근(C11)에 들어왔고, 그 전에는 플랫폼마다 다른 함수를 썼다 — POSIX의 posix_memalign, 윈도우의 _aligned_malloc (그리고 짝이 되는 _aligned_free. 윈도우에서는 이것을 free로 반납하면 안 된다). 옛 코드에서 이 이름들을 만나면 “정렬을 지정한 할당이구나”로 읽으면 된다.45.4 값싸 보이는 두 줄의 값 — 할당은 왜 비싼가
예제의 뒷부분은 같은 일을 세 방식으로 30만 번 반복해 시간을 잰다. 매번 빌리고 돌려주는 쪽이 재사용이나 스택보다 눈에 띄게 느리다 — 이 기계에서 회당 열 나노초 남짓이다. 값 자체는 기계와 할당자에 따라 다르지만, 두 자릿수 배의 차이가 난다는 사실은 어디서나 같다.
왜 비싼가. malloc은 “칸을 준다”는 한 줄로 보이지만 실제로는 창고 관리 사무소를 한 번 다녀오는 일이다.
- 맞는 크기의 빈 조각을 찾는다. 할당자는 반납된 조각들을 크기별 목록으로 관리하고, 요청이 오면 그중 맞는 것을 고른다. 목록을 뒤지고, 필요하면 큰 조각을 잘라 쓰고, 남은 조각을 다시 목록에 넣는다 — 전부 자료구조 조작이다.
- 관리 정보를 쓴다. 블록마다 크기와 상태를 적어 두어야 나중에
free가 “이게 몇 바이트였는지” 알 수 있다. 그래서 할당은 쓰기를 동반한다. - 모자라면 운영체제에 손을 벌린다. 창고가 비면 시스템 호출(
brk나mmap)로 주소 공간을 더 받아 온다. 이것은 커널로 들어갔다 나오는 일이라 훨씬 비싸다. 다행히 자주 일어나지는 않는다 — 할당자가 넉넉히 받아 두고 잘라 쓰기 때문이다. - 여러 갈래가 있으면 잠금이 걸린다. 창고 장부는 공유 자원이라 갈래마다 다투면 느려진다(80장의 경쟁 이야기다). 현대 할당자들이 갈래마다 작은 캐시를 따로 두는 이유가 이것이다.
- 캐시가 식는다. 갓 받은 주소는 대개 캐시에 없어서 첫 접근이 느리다 (11장의 사다리). 반대로 재사용한 버퍼는 이미 캐시에 올라와 있다 — 예제에서 재사용 쪽이 빠른 이유의 절반은 여기에 있다.
그래서 실무 규칙이 나온다. 뜨거운 루프 안에서 할당하지 않는다. 미리 한 번 빌려 재사용하거나, 크기를 아는 것은 스택에 두거나, 여러 개를 한꺼번에 빌려 잘라 쓴다. 마지막 방식이 아레나이고, 84장과 제12부가 그 이야기다.
흔한 오해. “free는 운영체제에 메모리를 돌려주는 것이다”
대개 아니다. free는 할당자의 장부에 “이 조각은 다시 쓸 수 있다”고 적는 것이지, 운영체제에 반납하는 것이 아니다. 그래서 큰 데이터를 해제한 뒤에도 작업 관리자에서 프로그램의 메모리 사용량이 그대로인 것이 정상이다 — 할당자가 다음 요청에 쓰려고 들고 있는 것이다(큰 블록은 반납되기도 한다).
이 사실이 두 가지를 설명한다. 첫째, “메모리를 해제했는데 왜 안 줄어드느냐” 는 흔한 오해. 둘째, 오래 도는 서버가 누수가 없는데도 메모리를 계속 붙들고 있는 현상 — 조각들이 흩어져 큰 덩어리를 만들지 못하는 단편화 (fragmentation)다. 84장에서 정면으로 다룬다.
45.5 1차원으로 빌려 2차원으로 쓰기
38장에서 int m[3][4] 같은 진짜 다차원 배열을 보았다. 그런데 크기가 실행 중에 정해지면 그 문법을 그대로 쓸 수 없다 — 그래서 실무에서 가장 흔한 형태는 한 줄로 빌린 뒤 두 축으로 읽는 것이다.
핵심은 한 줄짜리 산수다. rows × cols개의 칸을 한 번에 빌리고, (i, j)번째 칸을 i * cols + j로 찾는다. 38장에서 본 진짜 2차원 배열의 배치(행 우선)와 정확히 같은 순서다 — 그래서 성능도 같고, 캐시도 같은 방식으로 작동한다(11장).
examples/ch45/flat2d.c
/* 1차원으로 할당해 2차원으로 쓰기 — 실무에서 가장 흔한 형태.
첨자 계산을 손으로 하지 않고 매크로 한 줄에 가둔다. */
#include <stdckdint.h>
#include <stdio.h>
#include <stdlib.h>
/* 첨자 매크로: 인자를 모두 괄호로 감싸고(57장), 곱을 size_t 로 올린다.
i 와 j 를 두 번 평가하지 않으므로 부수효과가 있는 인자도 안전하다. */
#define AT(p, cols, i, j) ((p)[(size_t)(i) * (size_t)(cols) + (size_t)(j)])
/* 크기 계산은 넘칠 수 있다 — 넘치면 할당을 시도하지 않는다(52·80장). */
static int *alloc_grid(size_t rows, size_t cols)
{
size_t cells, bytes;
if (ckd_mul(&cells, rows, cols)) return NULL;
if (ckd_mul(&bytes, cells, sizeof(int))) return NULL;
return malloc(bytes);
}
int main(void)
{
const size_t rows = 3, cols = 4;
int *g = alloc_grid(rows, cols);
if (!g) { puts("allocation failed"); return 1; }
/* ── ① 채우기와 읽기 — 첨자는 매크로 하나로 ─────────────── */
for (size_t i = 0; i < rows; i++)
for (size_t j = 0; j < cols; j++)
AT(g, cols, i, j) = (int)(i * 10 + j);
printf("a 3x4 stored row-major:\n");
for (size_t i = 0; i < rows; i++) {
printf(" ");
for (size_t j = 0; j < cols; j++)
printf("%3d", AT(g, cols, i, j));
printf("\n");
}
/* ── ② 기억은 한 줄로 이어져 있다 ───────────────────────── */
printf("\nwalking the same memory as one dimension: ");
for (size_t k = 0; k < rows * cols; k++)
printf(k ? " %d" : "%d", g[k]);
printf("\n(the same layout as a real 2-D array int m[3][4] - row-major)\n");
/* ── ③ 흔한 사고: 행과 열을 바꿔 쓰기 ───────────────────── */
printf("\nAT(g, cols, 1, 2) = %d (row 2, third element)\n", AT(g, cols, 1, 2));
printf("AT(g, cols, 2, 1) = %d (row 3, second element - a different cell)\n", AT(g, cols, 2, 1));
/* ── ④ 넘치는 크기 요청은 거절된다 ──────────────────────── */
int *huge = alloc_grid((size_t)-1 / 2, 8);
printf("\nan absurd size request: %s\n", huge ? "allocated" : "the multiplication overflowed, refused");
free(huge);
free(g);
return 0;
}
실행 결과
a 3x4 stored row-major:
0 1 2 3
10 11 12 13
20 21 22 23
walking the same memory as one dimension: 0 1 2 3 10 11 12 13 20 21 22 23
(the same layout as a real 2-D array int m[3][4] - row-major)
AT(g, cols, 1, 2) = 12 (row 2, third element)
AT(g, cols, 2, 1) = 21 (row 3, second element - a different cell)
an absurd size request: the multiplication overflowed, refused
여기서 조심할 것이 셋이다.
첫째, 첨자 계산을 손으로 흩어 놓지 않는다. p[i * cols + j]를 코드 곳곳에 적으면 cols를 한 군데서 빠뜨리는 순간 조용히 다른 칸을 읽는다. 예제처럼 매크로 하나에 가두면 고칠 자리가 하나가 된다. 매크로를 쓸 때는 57장의 규칙을 지킨다 — 인자를 모두 괄호로 감싸고, 곱은 size_t로 올려 넘침을 피한다.
#define AT(p, cols, i, j) ((p)[(size_t)(i) * (size_t)(cols) + (size_t)(j)])둘째, 크기 계산 자체가 넘칠 수 있다. rows * cols * sizeof(int)는 세 수의 곱이라 넘치기 쉽고, 넘치면 작은 그릇을 빌려 놓고 큰 배열로 쓰는 최악의 사고가 된다. 예제는 ckd_mul(52·81장)로 먼저 검사하고, 넘치면 할당을 아예 시도하지 않는다.
셋째, 행과 열의 순서를 문서로 못박는다. AT(g, cols, 1, 2)와 AT(g, cols, 2, 1)은 다른 칸이다. 실수의 절반은 여기서 나오므로, 함수의 매개변수 이름을 rows·cols로 분명히 하고 순서를 문서에 적는다.
문. 포인터의 배열(int *)로 만들면 m[i][j] 문법을 그대로 쓸 수 있지 않은가?
답. 쓸 수 있고, 실제로 흔한 방법이다 — 행마다 따로 할당하고 그 주소들을 배열에 담는 것이다. 그러나 대가가 크다.
기억이 흩어진다. 행들이 서로 멀리 떨어져 있으면 11장의 지역성이 깨져 캐시 적중률이 떨어진다. 할당이 여러 번 일어난다. 행 수만큼 malloc을 부르고 그만큼 실패 경로가 생기며, 해제도 역순으로 여러 번 해야 한다. 간접 참조가 한 겹 더 있다. m[i][j]는 주소를 두 번 따라간다.
그리고 결정적으로, int[3][4]와 int *는 서로 다른 타입이다. 진짜 2차원 배열의 이름을 int *를 받는 함수에 넘기면 컴파일 오류이고, 강제로 캐스트해 넘기면 계약 밖이다 — 38장에서 본 대로 int m[3][4]가 무너지면 int (*)[4]이지 int *가 아니기 때문이다. 이 오해가 다차원 배열에서 가장 자주 나는 사고다.
정리하면 — 크기가 고정이면 진짜 2차원 배열, 실행 중에 정해지면 1차원 할당 + 첨자 매크로(또는 VLA 매개변수, 38장)가 기본이고, int *는 행마다 길이가 다른 자료(들쭉날쭉한 배열)일 때 고른다.
45.6 소유 — 누가 돌려줄 책임을 지는가
malloc이 준 주소는 여러 변수에 복사될 수 있고, 함수 사이를 오갈 수도 있다. 그런데 free는 정확히 한 번 불려야 한다 — 여기서 C 프로그래밍 의 핵심 규율이 나온다: 어느 시점에든 그 기억을 해제할 책임을 지는 주체 하나를 정해 두는 것, 소유(ownership)라는 관념이다. C에는 소유를 강제하는 문법이 없다 — 그래서 소유는 주석과 이름과 규약으로 표현 된다(“이 함수는 반환한 포인터의 소유권을 넘긴다”처럼). 현대 언어들이 소유를 타입 체계로 끌어올린 것(Rust의 소유권, C++의 스마트 포인터)은 이 규율을 기계가 강제하게 만든 결과다 — 1장에서 본 이웃 언어들의 문제의식이 바로 이 자리에서 나왔다.
실제 사례. 세 가지 대표 사고 — 누수, 이중 해제, 해제 후 사용
동적 메모리의 사고는 세 얼굴로 온다. 누수는 돌려주기를 잊는 것 — 느리게 죽는 병이다. 이중 해제(double free)는 같은 주소를 두 번 free하는 것으로, 창고 관리 장부를 망가뜨려 그 뒤의 모든 할당이 오염된다. 가장 위험한 것은 해제 후 사용(use-after-free) — 돌려준 칸의 주소를 계속 쓰는 것이다. 44장의 댕글링 포인터가 힙에서 재현되는 것인데, 창고는 그 칸을 곧 다른 요청에 내주므로 공격자가 그 자리에 자기 데이터를 놓을 수 있다. 브라우저와 커널의 심각한 취약점 목록에서 use-after-free는 오늘도 최상위 단골이고, 그래서 현대 시스템 프로그래밍 전체가 “소유를 언어가 강제하게 하자”는 방향으로 움직여 왔다. C에서의 방어는 규율 + 도구다 — free 직후 포인터에 nullptr를 대입하는 관행, 그리고 17장의 새니타이저.
다만 둘 다 만능은 아니다. nullptr 대입은 그 변수 하나를 통한 재사용과 이중 해제를 막을 뿐, 같은 주소를 들고 있는 다른 별칭 포인터는 그대로 남는다 — 별칭이 없을 때의 국소 방어라고 이해해야 한다. ASan 은 해제 후 사용과 이중 해제를 아주 잘 잡지만, 누수 검출은 LeakSanitizer 가 함께 켜져 있어야 하고 플랫폼과 설정에 따라 달라진다(리눅스 x86-64 의 기본 빌드에서는 대개 함께 켜진다).
문. 그러면 동적 메모리를 되도록 피하는 것이 상책인가?
답. 실제로 그것이 첫 번째 전략이고, 이 책이 여기까지 malloc 없이 온 이유이기도 하다 — 크기를 아는 데이터는 자동 변수(스택)로 두는 것이 가장 빠르고 가장 안전하다(해제할 것이 없으니 세 사고가 원천 봉쇄된다). 임베디드나 안전 필수 분야에서는 아예 동적 할당을 금지하는 규약도 흔하다. 그러나 실행 중에 크기가 정해지는 데이터(사용자가 준 목록, 파일 내용)는 결국 창고가 필요하고 — 그때는 소유를 명확히 하고, 도구로 검사하고, 잘 만들어진 부품(43장의 proven처럼 할당과 경계를 함께 관리하는 계열)을 쓰는 것이 실무의 답이다.
45.7 제7부를 닫으며
기억의 배치가 완성됐다 — 레지스터와 캐시라는 사다리(11장) 위에, C의 세 저장 기간(자동·정적·동적)이 놓이고, 그 위를 포인터가 다닌다. 35장에서 개념을 얻고, 36장~37장에서 규칙을, 38장~42장에서 연속된 기억과 문자열을, 43장에서 안전한 부품을, 44장~45장에서 수명과 창고를 배웠다. C의 힘과 위험이 함께 사는 자리를 한 바퀴 돈 것이다.
동적 메모리의 문법과 규율, 그리고 값까지 보았다. 할당자가 창고를 실제로 어떻게 관리하는지, 요즘 널리 쓰이는 대안 할당자와 대안 표준 라이브러리는 무엇인지, 그리고 프로그램의 기억이 운영체제와 임베디드 칩에서 각각 어떤 배치로 놓이는지는 제11부의 끝에서 두 장을 들여 다룬다(83·84장).
다음 부는 짧지만 오래 미뤄 온 곳이다 — 제5부에서 “타입을 만드는 선언은 메모리 모델을 갖춘 뒤”라며 미뤄 두었던 구조체와 공용체. 이제 그 조건이 갖춰졌다.