Devin.KR

C · 기본

메모리 그림으로 배우는 C

C 동적 메모리 - malloc realloc free 와 누수 이중 해제 찾기 (C 기초 9장)

힙에서 빌리고 돌려주는 메모리로 늘어나는 배열을 만들고, 누수·해제 후 사용·이중 해제를 실행 결과와 clang 정적 분석기로 찾는다.

개발자 · 원고 갱신

이 장에서 배우는 것

지금까지 쓴 메모리는 두 종류였다. 함수가 끝나면 사라지는 스택(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 로 늘린다.

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_stringstrlen + 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 블록, 곱셈 넘침 검사0NULL
realloc(p, n)크기 변경, 필요하면 옮겨 복사늘어난 부분은 쓰레기 값NULL, 옛 블록 유지
free(p)블록 반납해당 없음free(NULL) 은 아무 일 없음
힙 버그 비교
버그이 장에서 본 증상찾는 도구예방
누수아무 증상 없음정적 분석기, (리눅스) ASan소유자 하나, 오류 경로 정리
해제 후 사용값이 우연에 달림정적 분석기, ASanfree 직후 NULL
이중 해제종료 코드 133 으로 중단정적 분석기, ASanfree 직후 NULL
블록 넘침조용히 옆 메모리 손상ASan크기 계산에 +1, snprintf

실무에서 자주 틀리는 것

  • malloc 반환값을 검사하지 않는다. 메모리가 부족하면 NULL이 온다. 임베디드처럼 메모리가 작은 곳에서는 실제로 일어난다.
  • p = realloc(p, n) — 실패하면 원래 블록의 유일한 주소를 NULL로 덮어써 누수가 된다. 임시 변수로 받는다.
  • 크기 계산에서 널 문자를 빼먹는다. malloc(strlen(s))는 한 바이트 모자라다. strcpy가 블록 끝을 한 바이트 넘어 쓴다.
  • 해제한 포인터를 다른 곳에 복사해 두었다. 한 변수를 NULL로 만들어도 복사본은 옛 주소를 들고 있다. 블록마다 "누가 해제할 책임이 있는가"(소유자)를 하나로 정한다.
  • 임베디드에서 동적 할당을 무분별하게 쓴다. 오래 도는 장비에서는 조각화로 큰 블록을 못 얻는 일이 생긴다. 안전 규격을 따르는 펌웨어는 초기화 이후 동적 할당을 금지하고 정적 풀을 쓰기도 한다.

연습 문제

  1. dynarr.c에 원소를 1,000개 넣으면 최종 cap은 얼마이고, 확장(realloc)은 몇 번 일어나는가?
  2. 다음 함수의 버그 두 가지를 찾아라. char *join(const char *a, const char *b) { char *r = malloc(strlen(a) + strlen(b)); strcpy(r, a); strcat(r, b); return r; }
  3. 함수 load()가 버퍼 A를 할당한 뒤 버퍼 B 할당에 실패했다. A는 어떻게 해야 하는가? 이를 코드 구조로 보장하는 흔한 방법을 설명하라.

정답

  1. 용량은 2, 4, 8, …, 512, 1024로 커진다. 1,000개를 담으려면 cap = 1024이고, 0→2부터 512→1024까지 10번 확장한다(2의 1제곱부터 10제곱까지).
  2. ① 널 문자 자리가 없다. strlen(a) + strlen(b) + 1이어야 한다. ② malloc이 NULL을 돌려줄 때 검사 없이 strcpy한다. if (r == NULL) return NULL;을 넣는다. 또한 이 함수가 돌려준 블록은 호출한 쪽이 free해야 한다는 약속을 문서로 남긴다.
  3. A를 free한 뒤 실패를 반환해야 한다. 흔한 구조는 함수 끝에 정리 구간을 두는 것이다. 포인터를 모두 NULL로 초기화하고, 실패하면 goto cleanup;으로 뛰어가 free(b); free(a);를 한 번에 한다. free(NULL)이 안전하므로 어디서 실패했든 같은 정리 코드가 통한다. 리눅스 커널 같은 대형 C 코드에서 널리 쓰는 패턴이다.

다음 장에서는 서로 다른 자료형을 한 덩어리로 묶는 구조체와, 그 덩어리가 메모리에서 생각보다 커지는 이유(패딩)를 본다.

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.