C 동적 메모리 - malloc realloc free 와 누수 이중 해제 찾기 (C 기초 9장)
이 장에서 배우는 것
지금까지 쓴 메모리는 두 종류였다. 함수가 끝나면 사라지는 스택(5장), 프로그램 내내 사는 정적 영역(static, 전역)이다. 그런데 "입력이 몇 개 들어올지 모른다", "함수가 만든 데이터를 함수가 끝난 뒤에도 쓰고 싶다"는 요구는 둘 다 풀지 못한다. 세 번째 공간인 힙은 실행 중에 필요한 만큼 빌리고 다 쓰면 직접 돌려주는 메모리다. 돌려주는 책임이 프로그래머에게 있다는 점이 C 버그의 큰 몫을 만든다.
malloc,calloc,realloc,free의 역할과 반환값 검사를 익힌다.- 용량을 두 배씩 늘리는 동적 배열을 만든다.
- 메모리 누수, 해제 후 사용(use-after-free), 이중 해제를 실행 결과와 정적 분석기로 확인한다.
- AddressSanitizer가 무엇을 잡는지, 이 책의 검증 환경에서 무엇을 확인하지 못했는지 안다.
문제 상황
온도 센서 값을 계속 받아 저장하는 수집 프로그램을 만든다. 하루에 몇 개가 들어올지 모르니 배열 크기를 정할 수 없다. 크게 잡으면 메모리가 아깝고, 작게 잡으면 넘친다. 또 동료가 만든 라벨 처리 코드를 오래 돌리면 메모리 사용량이 계속 늘고, 가끔은 "이미 해제한 메모리"를 다시 해제해서 프로그램이 죽는다.
메모리 그림: 스택의 포인터, 힙의 데이터
IntList temps; push 를 7번 한 뒤
스택 (main 프레임) 힙 (malloc/realloc 이 준 블록)
┌─ temps ─────────────┐
│ data ───────────────┼──────▶ ┌────┬────┬────┬────┬────┬────┬────┬────┐
│ len = 7 │ │ 20 │ 21 │ 22 │ 23 │ 24 │ 25 │ 26 │ ?? │
│ cap = 8 │ └────┴────┴────┴────┴────┴────┴────┴────┘
└─────────────────────┘ 8칸 × 4바이트 = 32바이트, 마지막 칸은 아직 안 씀
용량이 찰 때마다 realloc: 0 → 2 → 4 → 8
더 큰 블록을 잡고 기존 내용을 복사한 뒤, 옛 블록은 돌려줄 수 있다
(그래서 realloc 뒤에는 주소가 바뀔 수 있다)
free(temps.data) 뒤
┌─ temps ─────────────┐
│ data = (옛 주소 그대로) ─────▶ ░░░░░░░░ 이미 반납한 블록: 읽기·쓰기·재해제 금지
└─────────────────────┘
→ temps.data = NULL; 로 끊어 둔다
힙 블록 자체에는 이름이 없다. 스택이나 정적 영역에 있는 포인터 변수가 그 주소를 기억하고 있을 뿐이다. 그 포인터를 잃어버리면 블록을 돌려줄 방법이 없고(누수), 블록을 돌려준 뒤에도 포인터가 옛 주소를 들고 있으면 잘못 쓰게 된다(해제 후 사용, 이중 해제).
IntList 는 스택에 있고 data 포인터만 힙 블록을 가리킨다. 용량이 차면 realloc 이 더 큰 블록으로 옮겨 2, 4, 8 로 늘린다.
누수는 블록을 가리키는 포인터를 잃은 것, 해제 후 사용은 반납한 블록을 읽는 것, 이중 해제는 같은 블록을 두 번 반납하는 것이다.
완성 코드
늘어나는 정수 목록
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int *data;
size_t len;
size_t cap;
} IntList;
static int push(IntList *list, int value) {
if (list->len == list->cap) {
size_t new_cap = list->cap == 0 ? 2 : list->cap * 2;
int *grown = realloc(list->data, new_cap * sizeof *grown);
if (grown == NULL) {
return -1;
}
list->data = grown;
list->cap = new_cap;
printf(" 용량 %zu -> %zu\n", list->len, new_cap);
}
list->data[list->len++] = value;
return 0;
}
int main(void) {
IntList temps = {NULL, 0, 0};
for (int i = 0; i < 7; i++) {
if (push(&temps, 20 + i) != 0) {
fprintf(stderr, "메모리 부족\n");
free(temps.data);
return 1;
}
}
printf("len=%zu cap=%zu 마지막=%d\n", temps.len, temps.cap, temps.data[temps.len - 1]);
free(temps.data);
temps.data = NULL;
temps.len = temps.cap = 0;
printf("해제 뒤 data=%s\n", temps.data == NULL ? "NULL" : "?");
return 0;
}
문자열 복사본과 calloc
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char *copy_string(const char *src) {
size_t n = strlen(src) + 1;
char *dst = malloc(n);
if (dst == NULL) {
return NULL;
}
memcpy(dst, src, n);
return dst;
}
int main(void) {
char buffer[16] = "sensor-01";
char *saved = copy_string(buffer);
if (saved == NULL) {
return 1;
}
strcpy(buffer, "sensor-02");
printf("buffer=%s saved=%s\n", buffer, saved);
int *counts = calloc(4, sizeof *counts);
if (counts == NULL) {
free(saved);
return 1;
}
printf("calloc 은 0 으로 채운다: %d %d %d %d\n", counts[0], counts[1], counts[2], counts[3]);
free(counts);
free(saved);
return 0;
}
줄별 해설
IntList temps = {NULL, 0, 0};— 처음에는 블록이 없다.realloc(NULL, n)은malloc(n)과 같게 동작하므로 첫 push도 같은 코드로 처리된다.new_cap = list->cap == 0 ? 2 : list->cap * 2;— 한 칸씩 늘리면 push마다 복사가 일어나 느리다. 두 배씩 늘리면 복사 횟수가 로그 수준으로 줄어든다.int *grown = realloc(list->data, new_cap * sizeof *grown);— 결과를 새 변수에 받는다. 실패하면realloc은 NULL을 돌려주고 옛 블록은 그대로 둔다.list->data = realloc(list->data, ...)로 바로 덮어쓰면 실패할 때 옛 블록의 주소를 잃어 누수가 된다.sizeof *grown— 원소 하나의 크기를 변수에서 얻는다. 나중에 자료형을long으로 바꿔도 이 줄은 고칠 필요가 없다. 곱셈new_cap * sizeof *grown이 넘칠 만큼 큰 수라면 먼저 검사해야 한다.list->data[list->len++] = value;— 현재 길이 위치에 쓰고 길이를 하나 늘린다.->는 "포인터가 가리키는 구조체의 필드"다(10장).free(temps.data); temps.data = NULL;— 해제한 즉시 포인터를 NULL로 만든다.free(NULL)은 아무 일도 하지 않는 것이 표준이므로, 실수로 한 번 더free해도 안전해진다.copy_string—strlen + 1로 널 문자 자리까지 잡고memcpy로 복사한다. 이 함수가 돌려준 블록은 호출한 쪽이free할 책임이 있다. 이 소유권 약속을 함수 주석이나 이름으로 분명히 해 둔다. POSIX의strdup도 같은 일을 하며, C 표준에는 C23에서야 들어갔다.calloc(4, sizeof *counts)— 개수와 크기를 따로 받아 곱셈 넘침을 검사하고, 블록을 0으로 채운다.malloc한 블록의 내용은 쓰레기 값이다.- 오류 경로의
free(saved)— 중간에 실패해서 빠져나갈 때도 이미 빌린 블록은 돌려준다. 함수가 커지면 이 정리를 한곳(함수 끝의 정리 구간)에 모으는 것이 C의 흔한 관례다.
실제 실행 결과
$ ./dynarr
용량 0 -> 2
용량 2 -> 4
용량 4 -> 8
len=7 cap=8 마지막=26
해제 뒤 data=NULL
7개를 넣는 동안 복사를 동반하는 확장은 세 번뿐이다.
$ ./copy_name
buffer=sensor-02 saved=sensor-01
calloc 은 0 으로 채운다: 0 0 0 0
buffer를 바꿔도 saved는 힙에 있는 별도 복사본이라 그대로다.
힙 버그 세 가지
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char *make_label(const char *name) {
char *label = malloc(strlen(name) + 1);
if (label == NULL) {
return NULL;
}
strcpy(label, name);
return label;
}
static void leak(void) {
char *tmp = make_label("tmp");
if (tmp == NULL) {
return;
}
printf("leak: %s\n", tmp);
}
static void use_after_free(void) {
char *label = make_label("temp");
if (label == NULL) {
return;
}
free(label);
printf("use_after_free: %c\n", label[0]);
}
static void double_free(void) {
char *label = make_label("humid");
if (label == NULL) {
return;
}
free(label);
free(label);
}
int main(int argc, char *argv[]) {
const char *which = argc > 1 ? argv[1] : "leak";
if (strcmp(which, "leak") == 0) {
leak();
} else if (strcmp(which, "uaf") == 0) {
use_after_free();
} else if (strcmp(which, "double") == 0) {
double_free();
}
printf("%s 끝\n", which);
return 0;
}
이 파일은 컴파일러 경고가 하나도 나오지 않는다. -Wall -Wextra는 함수 하나 안의 흐름을 따라 해제 여부를 추적하지 않기 때문이다.
$ clang -std=c17 -Wall -Wextra bugs.c -o bugs
실행해 보면
$ ./bugs leak; echo "종료 코드: $?"
leak: tmp
leak 끝
종료 코드: 0
$ ./bugs double; echo "종료 코드: $?"
/bin/sh: line 1: <pid> Trace/BPT trap: 5 ./bugs double
종료 코드: 133
누수는 겉으로 아무 증상이 없다. 한 번 새는 4바이트("tmp"와 널 문자)는 티가 나지 않지만, 요청마다 새는 서버나 몇 달씩 켜 두는 장비에서는 결국 메모리가 바닥난다. 이중 해제는 이 환경(macOS)의 할당기가 알아채고 프로그램을 멈췄다(종료 코드 133 = 128 + 신호 5). 리눅스 glibc는 보통 free(): double free detected 같은 메시지와 함께 중단한다. 다만 할당기가 모든 경우를 잡아 준다고 기대하면 안 된다. 해제 후 사용은 이 실행에서 증상이 드러나지 않을 수도 있어 결과를 싣지 않았다. 그래서 도구가 필요하다.
clang 정적 분석기로 찾기
$ clang --analyze -std=c17 -o /dev/null bugs.c
bugs.c:20:1: warning: Potential leak of memory pointed to by 'tmp' [unix.Malloc]
20 | }
| ^
bugs.c:28:36: warning: Use of memory after it is freed [unix.Malloc]
28 | printf("use_after_free: %c\n", label[0]);
| ^~~~~~~~
bugs.c:37:5: warning: Attempt to free released memory [unix.Malloc]
37 | free(label);
| ^~~~~~~~~~~
3 warnings generated.
clang --analyze는 프로그램을 실행하지 않고 코드의 경로를 따라가며 세 버그를 모두 짚었다. 실행 경로에 의존하지 않으므로 테스트가 닿지 않는 오류 경로의 누수도 찾아낸다. 반대로 경로가 복잡해지면 놓치거나 거짓 경보를 내기도 한다.
AddressSanitizer에 대해
실행 중에 힙 버그를 잡는 표준 도구는 AddressSanitizer(ASan)다. clang -g -fsanitize=address bugs.c -o bugs_asan으로 빌드해 실행하면, 해제 후 사용은 heap-use-after-free, 이중 해제는 attempting double-free, 배열 넘침은 heap-buffer-overflow라는 제목의 보고와 함께 문제가 된 줄, 그 블록을 할당·해제한 줄을 알려 주고 멈춘다. 리눅스에서는 종료 시 누수도 detected memory leaks로 보고한다(LeakSanitizer). macOS의 Apple clang은 누수 검사를 지원하지 않으므로 macOS에서는 leaks 도구나 정적 분석기를 함께 쓴다.
검증 한계: 이 책의 검증 환경(macOS 26.6, Apple clang 17, arm64)에서는 ASan을 켠 실행 파일이 빈 main조차 시작 단계에서 멈춰(런타임이 그림자 메모리 위치를 찾는 단계에서 진행되지 않음) ASan 보고서를 실제로 받지 못했다. 그래서 이 장에는 ASan 출력을 싣지 않았다. 지어낸 출력을 싣지 않기 위해서다. 리눅스(예: Ubuntu의 gcc/clang)나 다른 macOS 버전에서는 위 명령이 그대로 동작한다. 12장에서 도구 사용 흐름을 정리한다.
한눈에 보기
| 함수 | 하는 일 | 내용 초기값 | 실패하면 |
|---|---|---|---|
malloc(n) | n바이트 블록 | 쓰레기 값 | NULL |
calloc(k, size) | k × size 블록, 곱셈 넘침 검사 | 0 | NULL |
realloc(p, n) | 크기 변경, 필요하면 옮겨 복사 | 늘어난 부분은 쓰레기 값 | NULL, 옛 블록 유지 |
free(p) | 블록 반납 | 해당 없음 | free(NULL) 은 아무 일 없음 |
| 버그 | 이 장에서 본 증상 | 찾는 도구 | 예방 |
|---|---|---|---|
| 누수 | 아무 증상 없음 | 정적 분석기, (리눅스) ASan | 소유자 하나, 오류 경로 정리 |
| 해제 후 사용 | 값이 우연에 달림 | 정적 분석기, ASan | free 직후 NULL |
| 이중 해제 | 종료 코드 133 으로 중단 | 정적 분석기, ASan | free 직후 NULL |
| 블록 넘침 | 조용히 옆 메모리 손상 | ASan | 크기 계산에 +1, snprintf |
실무에서 자주 틀리는 것
malloc반환값을 검사하지 않는다. 메모리가 부족하면 NULL이 온다. 임베디드처럼 메모리가 작은 곳에서는 실제로 일어난다.p = realloc(p, n)— 실패하면 원래 블록의 유일한 주소를 NULL로 덮어써 누수가 된다. 임시 변수로 받는다.- 크기 계산에서 널 문자를 빼먹는다.
malloc(strlen(s))는 한 바이트 모자라다.strcpy가 블록 끝을 한 바이트 넘어 쓴다. - 해제한 포인터를 다른 곳에 복사해 두었다. 한 변수를 NULL로 만들어도 복사본은 옛 주소를 들고 있다. 블록마다 "누가 해제할 책임이 있는가"(소유자)를 하나로 정한다.
- 임베디드에서 동적 할당을 무분별하게 쓴다. 오래 도는 장비에서는 조각화로 큰 블록을 못 얻는 일이 생긴다. 안전 규격을 따르는 펌웨어는 초기화 이후 동적 할당을 금지하고 정적 풀을 쓰기도 한다.
연습 문제
dynarr.c에 원소를 1,000개 넣으면 최종cap은 얼마이고, 확장(realloc)은 몇 번 일어나는가?- 다음 함수의 버그 두 가지를 찾아라.
char *join(const char *a, const char *b) { char *r = malloc(strlen(a) + strlen(b)); strcpy(r, a); strcat(r, b); return r; } - 함수
load()가 버퍼 A를 할당한 뒤 버퍼 B 할당에 실패했다. A는 어떻게 해야 하는가? 이를 코드 구조로 보장하는 흔한 방법을 설명하라.
정답
- 용량은 2, 4, 8, …, 512, 1024로 커진다. 1,000개를 담으려면 cap = 1024이고, 0→2부터 512→1024까지 10번 확장한다(2의 1제곱부터 10제곱까지).
- ① 널 문자 자리가 없다.
strlen(a) + strlen(b) + 1이어야 한다. ②malloc이 NULL을 돌려줄 때 검사 없이strcpy한다.if (r == NULL) return NULL;을 넣는다. 또한 이 함수가 돌려준 블록은 호출한 쪽이free해야 한다는 약속을 문서로 남긴다. - A를
free한 뒤 실패를 반환해야 한다. 흔한 구조는 함수 끝에 정리 구간을 두는 것이다. 포인터를 모두 NULL로 초기화하고, 실패하면goto cleanup;으로 뛰어가free(b); free(a);를 한 번에 한다.free(NULL)이 안전하므로 어디서 실패했든 같은 정리 코드가 통한다. 리눅스 커널 같은 대형 C 코드에서 널리 쓰는 패턴이다.
다음 장에서는 서로 다른 자료형을 한 덩어리로 묶는 구조체와, 그 덩어리가 메모리에서 생각보다 커지는 이유(패딩)를 본다.