C 포인터와 malloc: 해제 후 사용·이중 해제·메모리 누수
C 강좌 3. Apple clang 21 로 실행: & 와 *, malloc/free, 해제 후 사용과 이중 해제의 ASan 보고, macOS leaks 로 누수 찾기, realloc 확장 방식 실측.
시리즈: C 강좌 3/6 (중급). C 로 함수와 반복문은 쓸 수 있는데(2강) 실제 코드 곳곳의 * 와 & 앞에서 멈칫하는 분을 위한 글입니다. 다 읽으면 포인터가 무엇을 담는지 설명하고, 메모리를 올바르게 할당하고 해제하며, 힙 버그 네 가지에 대한 AddressSanitizer 보고서를 읽고, macOS 에 들어 있는 leaks 로 누수를 찾을 수 있습니다.
전공 수업이든 국비 과정이든 C 를 배우다 포기하는 지점은 대개 포인터입니다. 개념이 어려워서라기보다, 포인터 버그는 틀린 곳에서 죽지 않기 때문입니다. 다른 곳을 망가뜨려 나중에 죽거나, 아예 죽지 않고 낡은 데이터를 계속 돌려줍니다. 임베디드 펌웨어, 게임, 네이티브 라이브러리, Python 이나 PHP 확장 모듈을 다룬다면 며칠씩 잡아먹는 것이 바로 이런 버그입니다. 아래 결과는 모두 Apple Silicon Mac(M1 Pro), macOS 26, Apple clang 21.0.0 에서 실행했고, macOS 에 없는 Valgrind 와 gdb 는 쓰지 않았습니다. 출력 속 경로는 ~/demo/ 로 바꿨습니다.
포인터가 무엇인지, 프로그램 하나로
포인터는 값으로 메모리 주소를 담는 변수입니다. &x 는 x 의 주소, *p 는 p 에 든 주소를 따라간 곳(역참조, dereference)입니다.
#include <stdio.h>
void swap_wrong(int a, int b) { int t = a; a = b; b = t; }
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
int main(void) {
int x = 10;
int *p = &x; /* p holds the address of x */
printf("x=%d &x=%p p=%p *p=%d\n", x, (void *)&x, (void *)p, *p);
*p = 20; /* write through the pointer */
printf("after *p = 20: x=%d\n", x);
printf("sizeof x=%zu sizeof p=%zu\n", sizeof x, sizeof p);
int a = 1, b = 2;
swap_wrong(a, b);
printf("swap_wrong: a=%d b=%d\n", a, b);
swap(&a, &b);
printf("swap: a=%d b=%d\n", a, b);
int arr[4] = {10, 20, 30, 40};
int *q = arr;
printf("*q=%d *(q+2)=%d q[3]=%d (q+1)-q=%td\n", *q, *(q + 2), q[3], (q + 1) - q);
return 0;
}
x=10 &x=0x16dcba578 p=0x16dcba578 *p=10
after *p = 20: x=20
sizeof x=4 sizeof p=8
swap_wrong: a=1 b=2
swap: a=2 b=1
*q=10 *(q+2)=30 q[3]=40 (q+1)-q=1
기억할 것은 세 가지입니다.
- C 의 인자는 값으로 전달된다.
swap_wrong은 자기 복사본만 바꿨습니다. 호출한 쪽 변수를 바꾸려면 주소를 넘겨야 합니다.scanf("%d", &n)에&가 붙는 이유입니다. - 포인터는 여기서 8 바이트다. 무엇을 가리키든 같고,
int는 4 바이트입니다. - 포인터 연산은 바이트가 아니라 원소 단위다.
q + 2는 int 두 개(8 바이트)를 건너뛰고,q[3]은*(q + 3)으로 정의됩니다. 2강의 "배열이 포인터가 된다"가 성립하는 이유입니다.
메모리는 어디에 사는가: 스택, 힙, 정적 영역
global 0x104d00000
stack 0x16b1064a8
heap 0x1054bd970
code 0x104cf84f8
전역 변수, 지역 변수, malloc 한 블록, 함수의 주소를 찍은 결과입니다(실행마다 다름). C 변수가 사는 곳은 세 군데입니다.
| 영역 | 얻는 방법 | 수명 | 흔한 실수 |
|---|---|---|---|
| 스택 | 지역 변수 int local; |
함수가 반환될 때까지 | &local 반환 |
| 힙 | malloc, calloc, realloc |
free 할 때까지 |
free 누락, free 뒤 사용 |
| 정적 영역 | 전역 변수, static 변수 |
프로그램 전체 | 눈에 안 보이는 공유 상태 |
스택은 알아서 정리되기 때문에, 지역 변수의 주소를 반환하면 안 됩니다.
int *make_counter(void) {
int count = 0;
return &count;
}
dangling.c:4:13: warning: address of stack memory associated with local variable 'count' returned [-Wreturn-stack-address]
실행하면 0 이 나왔습니다. 그 스택 칸이 아직 덮어써지지 않았을 뿐입니다. 기본 ASan 도 조용했고, ASAN_OPTIONS=detect_stack_use_after_return=1 을 줬을 때만 ERROR: AddressSanitizer: stack-use-after-return ... in main dangling.c:8 이 나왔습니다. 컴파일러 경고를 믿으세요.
malloc 과 free 를 올바르게
int n = 5;
int *a = malloc(n * sizeof *a); /* 바이트 수 = 개수 × 원소 크기 */
if (a == NULL) { perror("malloc"); return 1; }
for (int i = 0; i < n; i++) a[i] = i * i;
printf("a[4]=%d\n", a[4]); /* a[4]=16 */
free(a);
a = NULL; /* 매달린 포인터를 남기지 않기 */
malloc은 바이트 단위 크기를 받아 초기화되지 않은 블록을, 실패하면NULL을 돌려줍니다. 0 으로 채운 메모리가 필요하면calloc(n, sizeof *a).sizeof *a(a 가 가리키는 것의 크기)로 쓰면 나중에int를long으로 바꿔도 맞습니다.sizeof(int)는 그렇지 않습니다.- 성공한
malloc하나에free는 정확히 한 번.free뒤에도 포인터는 옛 주소를 그대로 들고 있는데, 이것을 매달린 포인터(dangling pointer)라고 합니다.NULL을 넣어 두면 이후의 실수가 요란하게 죽거나(NULL 역참조) 무해해집니다(free(NULL)은 표준상 아무것도 하지 않음).
힙 버그 네 가지: ASan 없이, ASan 과 함께
네 가지 버그를 한 파일에 넣고 명령줄 인자로 고르게 한 뒤, 일반 빌드(-g)와 -fsanitize=address -g 두 가지로 만들었습니다.
if (mode == 1) { /* forgot sizeof(int) */
int *a = malloc(n);
for (int i = 0; i < n; i++) a[i] = i;
printf("a[4]=%d\n", a[4]);
free(a);
} else if (mode == 2) { /* use after free */
int *a = malloc(n * sizeof *a);
a[0] = 42;
free(a);
printf("a[0]=%d\n", a[0]);
} else if (mode == 3) { /* double free */
int *a = malloc(n * sizeof *a);
free(a);
free(a);
printf("freed twice\n");
}
| 버그 | 일반 빌드 | ASan |
|---|---|---|
malloc(n * sizeof *a) 대신 malloc(n) |
a[4]=4, 종료 코드 0 |
heap-buffer-overflow, 11 번째 줄 |
| 해제 후 사용 | 처음엔 a[0]=381779559, 이후 a[0]=7, 종료 코드 0 |
heap-use-after-free, 18 번째 줄 |
| 이중 해제 | 메시지 없음, Trace/BPT trap: 5(133) 또는 Abort trap: 6(134) |
attempting double-free, 22 번째 줄 |
NULL 인 hit 에 *hit |
Segmentation fault: 11(139) |
SEGV on unknown address 0x000000000000, 13 번째 줄 |
위의 두 개가 위험합니다. 일반 빌드는 그럴듯한 숫자를 찍고 종료 코드 0 으로 끝납니다. malloc(n) 버그는 5 바이트 블록에 20 바이트를 썼지만, macOS 할당기가 마침 뒤에 여유를 두고 있어서 겉으로는 아무 일도 없었습니다. 해제 후 사용 보고서(줄임):
==76039==ERROR: AddressSanitizer: heap-use-after-free on address 0x603000001c30 ...
READ of size 4 at 0x603000001c30 thread T0
#0 0x000100ed4ac4 in main heapbugs.c:18
0x603000001c30 is located 0 bytes inside of 20-byte region [0x603000001c30,0x603000001c44)
freed by thread T0 here:
#0 0x000101971368 in free+0x7c (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41368)
#1 0x000100ed4a74 in main heapbugs.c:17
previously allocated by thread T0 here:
#0 0x000101971274 in malloc+0x78 (libclang_rt.asan_osx_dynamic.dylib:arm64e+0x41274)
#1 0x000100ed4a08 in main heapbugs.c:15
SUMMARY: AddressSanitizer: heap-use-after-free heapbugs.c:18 in main
스택 트레이스 세 개, 줄 번호 세 개: 읽은 곳(18), 해제한 곳(17), 할당한 곳(15). 실제 프로그램에서는 이 셋이 서로 다른 파일에 있는 경우가 많아서, 이 보고서 하나가 하루치 추적을 덜어 줍니다.
더 확인한 것 두 가지. NULL 포인터와 초기화하지 않은 포인터는 죽는 방식이 다릅니다. int *p; *p = 5; 는 컴파일할 때 -Wuninitialized 경고가 나왔고, 실행하면 세그폴트가 아니라 Bus error: 10(종료 코드 138)으로 죽었습니다. lldb 로 보니 남아 있던 값은 p = 0x00000001f5578100 이었고, 동적 로더의 읽기 전용(r--) 페이지를 가리키고 있었습니다. macOS 는 읽기 전용 메모리에 쓰는 것을 버스 오류로 보고합니다. 남은 값이 달랐다면 시그널도 달랐을 겁니다.
그리고 clang 의 정적 분석기(clang --analyze, 같은 툴체인에 들어 있음)는 실행 없이 쓸 수 있습니다. 이 글의 버그 여덟 개 중 다섯 개, 즉 초기화 안 된 포인터, 스택 주소 반환, 해제 후 사용 두 건(Use of memory after it is freed [unix.Malloc]), 이중 해제(Attempt to free released memory)를 짚었습니다. malloc(n) 넘침, find() 가 돌려준 NULL, 다음 절의 누수는 놓쳤습니다.
누수: macOS 에서는 이렇게
누수(leak)는 할당했지만 포인터를 잃어버려 다시는 free 할 수 없게 된 메모리입니다.
char *greet(const char *name) {
char *buf = malloc(64);
if (!buf) return NULL;
snprintf(buf, 64, "Hello, %s!", name);
return buf; /* caller must free */
}
int main(void) {
for (int i = 0; i < 3; i++) {
char *msg = greet("leak");
printf("%s\n", msg);
/* free(msg); <- forgotten */
}
return 0;
}
리눅스라면 Valgrind 나 ASan 의 LeakSanitizer 를 쓰겠지만, Apple Silicon macOS 에서는 둘 다 되지 않았습니다.
$ ASAN_OPTIONS=detect_leaks=1 ./leak_asan
==77114==AddressSanitizer: detect_leaks is not supported on this platform.
$ clang -std=c17 -g -fsanitize=leak -o leak_lsan leak.c
clang: error: unsupported option '-fsanitize=leak' for target 'arm64-apple-darwin25.6.0'
게다가 detect_leaks=1 은 옵션을 무시하는 게 아니라 프로그램을 비정상 종료시켰습니다(종료 코드 134). 팀이 공유하는 ASAN_OPTIONS 에 넣어 두지 마세요. 되는 것은 /usr/bin 의 leaks 명령입니다(줄임).
$ leaks --atExit -- ./leak
Hello, leak!
Hello, leak!
Hello, leak!
Process 77064: 192 nodes malloced for 31 KB
Process 77064: 3 leaks for 240 total leaked bytes.
STACK OF 3 INSTANCES OF 'ROOT LEAK: <malloc in greet>':
2 leak 0x104684508 main + 52 leak.c:14
1 leak 0x104684478 greet + 24 leak.c:6
====
3 (240 bytes) << TOTAL >>
1 (80 bytes) ROOT LEAK: <malloc in greet 0xb14c08050> [80]
malloc(64) 인데 80 bytes 인 것은 할당기가 크기 등급(size class)으로 올려 잡기 때문입니다. leaks 는 종료 코드 1 을 돌려줬고, free(msg); 를 넣자 0 leaks for 0 total leaked bytes. 와 함께 0 으로 끝났습니다. 테스트 스크립트의 통과 조건으로 쓸 수 있습니다. leak.c:14 같은 줄 번호를 보려면 -g 로 빌드합니다.
어떤 버그에 어떤 도구를
| 증상·버그 | 먼저 쓸 도구 | 실행해 보고 알게 된 것 |
|---|---|---|
&local 반환, 초기화 안 된 포인터 |
컴파일러 경고 -Wall -Wextra |
컴파일할 때 잡힘 |
| 힙 넘침, 해제 후 사용, 이중 해제 | -fsanitize=address -g |
읽은 줄, 해제한 줄, 할당한 줄 |
| 반환 후 스택 사용 | ASan + detect_stack_use_after_return=1 |
기본은 꺼져 있음 |
| macOS 의 누수 | leaks --atExit -- ./prog |
누수 있으면 종료 코드 1. ASan 은 불가 |
| 실행 없이 빠르게 점검 | clang --analyze |
8 개 중 5 개 지적 |
| 큰 프로그램의 크래시 | lldb (6강) |
백트레이스와 변수 |
흔한 실수 → 실제로 일어난 일 → 해결
| 실수 | 실제로 본 것 | 해결 |
|---|---|---|
int n 개에 malloc(n) |
조용한 넘침, 종료 코드 0 | malloc(n * sizeof *p) 또는 calloc(n, sizeof *p) |
malloc 결과를 확인 안 함 |
NULL 역참조로 종료 코드 139 | 매번 if (p == NULL) |
p = realloc(p, bigger) |
실패하면 원래 블록이 누수 | tmp = realloc(p, ...); if (!tmp) {...} p = tmp; |
p = p->next 로 돌며 노드 free |
Abort trap: 6, 134 (연습 문제 2) |
free 전에 next 저장 |
free 뒤에도 p 를 계속 들고 있음 |
낡은 데이터 또는 나중의 크래시 | free(p); p = NULL; |
실측: 힙 배열을 어떻게 늘릴까
realloc(p, size) 는 블록 크기를 바꾸고, 필요하면 옮깁니다. 문제는 몇 번 부르느냐입니다. int 100 만 개를 추가하는 작업을 세 가지 방식으로, -O2, 각 5 회 중앙값으로 쟀고, 블록이 실제로 옮겨진 횟수(tmp != a)도 셌습니다.
size_t newcap = strategy == 0 ? cap + 1 : (cap ? cap * 2 : 4);
int *tmp = realloc(a, newcap * sizeof *tmp);
if (!tmp) { free(a); exit(1); }
if (tmp != a) moves++;
a = tmp;
| 방식 | realloc 횟수 | 이동 횟수 | 중앙값 |
|---|---|---|---|
| 원소 하나씩 늘리기 | 1,000,000 | 42 | 20.33 ms |
| 용량 두 배씩 | 19 | 14 | 0.76 ms |
최종 크기로 malloc 한 번 |
0 | 0 | 0.61 ms |
놀라운 점 두 가지. 하나씩 늘리는 방식은 두 배 방식보다 27 배"밖에" 느리지 않았습니다. macOS 할당기가 100 만 번 중 999,958 번은 제자리에서 블록을 넓혔고, 옮긴 것은 42 번뿐이었기 때문입니다. 여기에 기대면 안 됩니다. glibc, Windows, 임베디드 힙에서는 훨씬 자주 복사할 수 있습니다(실행하지 않음). 그리고 두 배 방식은 "처음부터 크기를 아는 경우"와 25% 이내 차이였습니다. std::vector, Python 리스트 등 대부분의 가변 배열이 두 배씩(기하급수적으로) 늘어나는 이유입니다.
솔직한 한계와 버전 메모
- 실행은 arm64 macOS 26 의 Apple clang 21.0.0 에서만 했습니다. Valgrind, gdb, 리눅스 LeakSanitizer 는 설명만 했고 실행하지 않았습니다.
leaks는 macOS 개발자 도구의 일부이고 출력 형식은 Apple 의 것입니다. 여기서 동작한 것은--atExit형태입니다.- 리눅스 x86-64 에서는
-fsanitize=address에 누수 검출이 기본으로 들어 있습니다(실행하지 않음). - 해제 후 사용에서 읽히는 쓰레기 값과 이중 해제의 시그널(133 또는 134)은 같은 기계에서도 실행마다 달랐습니다. 그 들쭉날쭉함이 바로 문제의 본질입니다.
- 포인터 차이의
%td와sizeof의%zu는 C99 부터입니다.nullptr는 C23(-std=c23)이고, 이 글은NULL과 C17 을 씁니다.
연습 문제
1. 새 힙 배열 {start, start+1, ..., start+n-1} 을 돌려주는 int *make_range(int start, int n) 과, 그것을 출력하고 해제하는 main 을 작성하세요. ASan 과 leaks --atExit 로 확인해 보세요.
2. 아래 리스트 해제 코드는 Abort trap: 6 으로 죽습니다. 이유를 설명하고 고쳐 보세요.
for (struct node *p = head; p != NULL; p = p->next)
free(p);
정답
1.
int *make_range(int start, int n) {
if (n <= 0) return NULL;
int *a = malloc((size_t)n * sizeof *a);
if (a == NULL) return NULL;
for (int i = 0; i < n; i++)
a[i] = start + i;
return a; /* 호출한 쪽에서 free */
}
make_range(10, 5) 는 10 11 12 13 14 를 출력했고, ASan 과 UBSan 은 조용했으며, leaks 는 0 leaks for 0 total leaked bytes. 였습니다. (size_t) 캐스트는 곱셈을 malloc 이 기대하는 타입으로 해서, 큰 n 에서 int 가 먼저 넘치지 않게 합니다(1강).
2. p = p->next 가 free(p) 뒤에 실행되므로 이미 해제한 노드를 읽습니다. ASan 은 heap-use-after-free ... READ of size 8 ... ex_free.c:15 와 freed by ... ex_free.c:16 을 보고했습니다. 8 바이트는 next 포인터의 크기입니다. 먼저 저장해 둡니다.
while (head != NULL) {
struct node *next = head->next; /* free 전에 읽기 */
free(head);
head = next;
}
이 버전은 done 을 출력했고 ASan 에서도 깨끗했습니다. 같은 종류의 경계 사례는 링크드 리스트 글(C++, ASan)에도 정리해 두었습니다.
다음 강의
4강은 문자열입니다. NUL 종료 문자, strcpy 뿐 아니라 strncpy 도 위험한 이유, sizeof 와 offsetof 로 재는 구조체 패딩, 그리고 ASan 이 잡아내는 스택 버퍼 오버플로를 다룹니다.
- 이전: C 강좌 2/6: 제어문·함수·배열
- 다음: C 강좌 4/6: 문자열·구조체·버퍼 오버플로
- 이어지는 강의: 정의되지 않은 동작과 최적화, 캐시·make·lldb. 시리즈 처음은 1강
- 관련 글: 스택 C++ 구현과 ASan 결과, 프로그래밍 학습 로드맵
자주 묻는 질문
macOS 에서 C 메모리 누수는 어떻게 찾나요?
macOS 에 들어 있는 leaks 명령을 씁니다: leaks --atExit -- ./prog. 예제 프로그램에서 "3 leaks for 240 total leaked bytes" 와 함께, -g 로 빌드하면 malloc 한 줄까지 보여 줬습니다. 누수가 있으면 종료 코드 1 을 돌려주므로 스크립트에도 넣을 수 있습니다. Apple Silicon macOS 에서는 AddressSanitizer 의 누수 검출을 쓸 수 없어서, ASAN_OPTIONS=detect_leaks=1 은 "detect_leaks is not supported on this platform", -fsanitize=leak 은 Apple clang 21 이 오류로 거부했습니다.
free 한 메모리를 다시 쓰면 어떻게 되나요?
도구 없이는 대개 눈에 띄는 일이 없습니다. 실험에서는 a[0]=381779559 가 나온 적도, a[0]=7 이 나온 적도 있었고 둘 다 종료 코드 0 이었습니다. 그 메모리는 할당기에 돌아가 언제든 다시 쓰일 수 있습니다. -fsanitize=address 로 빌드하면 heap-use-after-free 로 즉시 멈추고, 읽은 줄, free 한 줄, malloc 한 줄을 보여 줍니다.
같은 포인터를 두 번 free 해도 되나요?
안 됩니다. 정의되지 않은 동작입니다. macOS 26 에서는 메시지도 없이 어떤 때는 Trace/BPT trap: 5(종료 코드 133), 어떤 때는 Abort trap: 6(134)으로 죽었습니다. ASan 은 "attempting double-free" 로 보고합니다. free 직후 포인터에 NULL 을 넣어 두면, free(NULL) 은 아무 일도 하지 않도록 정해져 있어 안전한 쪽으로 넘어갑니다.