← 모든 기록
Operating System · 2026.10.02

CSAPP 9.11 - C 프로그램에서 만나는 메모리 버그 10가지

잘못된 역참조, 초기화 누락, 경계 초과, 포인터 연산, 수명과 해제 문제까지 메모리 버그의 원인과 증상이 멀어지는 이유를 정리했다.

CSAPP 9.11 - C 프로그램에서 만나는 메모리 버그 10가지 대표 이미지

C의 메모리 버그는 잘못된 접근이 일어난 곳과 문제가 드러나는 곳이 멀리 떨어질 수 있다. 어느 함수에서 메모리를 덮어썼는데, 한참 뒤 다른 함수의 malloc이 실패하는 식이다.

CSAPP 9.11.1부터 9.11.10까지의 학습 기록을 한 글로 묶었다. 아래의 “잘못된 예”는 버그를 설명하기 위한 코드다. 정의되지 않은 동작은 항상 같은 방식으로 실패하거나 즉시 종료된다고 보장할 수 없다.

9.11.1: 잘못된 포인터의 역참조

가상 주소 공간에는 매핑되지 않은 영역이 있고, 매핑된 영역에도 읽기·쓰기 등의 권한이 있다. 잘못된 주소를 읽거나 읽기 전용 영역에 쓰면 프로세스가 종료될 수 있다.

하지만 잘못 계산한 주소가 우연히 쓰기 가능한 영역을 가리키면 즉시 실패하지 않고 다른 데이터를 손상시킬 수도 있다.

scanf에는 값을 저장할 주소를 전달한다

int val;
scanf("%d", &val);  // val에 정수를 저장할 주소를 전달

다음은 잘못된 호출이다.

int val = 123;
scanf("%d", val);   // 잘못된 예: int를 넘겼지만 %d는 int *를 요구

scanf는 %d에 대응하는 인자가 int *라고 기대한다. 값 자체를 넘기면 인자 타입이 맞지 않아 정의되지 않은 동작이다. 흔히 그 값에 해당하는 주소로 쓰기를 시도하는 식으로 문제가 나타날 수 있다.

읽기가 성공했는지도 확인하는 형태는 다음과 같다.

int val;
if (scanf("%d", &val) == 1) {
    // 이 지점에서는 읽어 들인 val을 사용할 수 있다.
}

9.11.2: 초기화되지 않은 메모리 읽기

정적 저장 기간을 가지는 객체는 명시적인 초기화가 없으면 0으로 초기화된다. 반면 malloc이 반환한 메모리에 원하는 초기값이 들어 있다고 가정하면 안 된다.

int *y = malloc(n * sizeof *y);

할당에 성공했다는 것은 공간을 얻었다는 뜻이다. 배열 원소들이 모두 0이라는 뜻이 아니다. 사용하기 전에 직접 값을 대입하거나, 0으로 채워진 저장 공간이 필요하면 calloc을 사용할 수 있다.

int *y = calloc(n, sizeof *y);
if (y == NULL) {
    // 할당 실패 처리
}

calloc은 할당한 바이트를 0으로 초기화한다. 위 정수 배열에는 적합하지만, 모든 타입의 의미상 초기값이 언제나 바이트 0과 같다고 일반화하지는 말아야 한다.

9.11.3: 스택 버퍼 오버플로우 허용

버퍼보다 긴 입력을 복사하면 배열 바깥의 메모리를 덮어쓴다. 스택에 있는 지역 배열이라면 주변 지역 변수나 함수 실행에 필요한 정보가 손상될 수 있다.

gets는 버퍼 크기를 전달받지 않아 입력 길이를 제한할 수 없다. 크기를 전달하는 입력 함수를 사용해야 한다.

char buf[64];
if (fgets(buf, sizeof buf, stdin) != NULL) {
    // buf 안에 들어온 문자열 처리
}

이 호출은 최대 63개의 문자를 읽고 종료 널 문자를 위한 공간을 남긴다. 입력이 길면 나머지가 입력 스트림에 남을 수 있으므로, 한 줄 전체를 처리하려는 프로그램은 잘린 입력에 대한 정책도 필요하다.

핵심은 함수 이름만 바꾸는 것이 아니라 실제 버퍼의 용량을 정확하게 전달하는 것이다.

9.11.4: 포인터와 가리키는 객체가 같은 크기라고 가정하기

정수 배열 m개를 가리키는 포인터 n개를 만들려는 상황을 생각해보자. 먼저 필요한 것은 int n개의 공간이 아니라 int * n개의 공간이다.

int **A = malloc(n * sizeof(int));  // 잘못된 예

포인터가 8바이트이고 int가 4바이트인 환경에서는 필요한 공간의 절반만 확보하게 된다. A[i]에 포인터를 기록하다 배열 끝을 넘어갈 수 있다.

int **A = malloc(n * sizeof *A);    // int * n개

각 행에는 정수 m개를 담아야 한다.

A[i] = malloc(m * sizeof *A[i]);    // int m개

위 조각은 할당 크기를 비교하기 위한 것이다. 실제 구현에서는 n과 m의 유효 범위, 곱셈 오버플로, 할당 실패를 검사해야 한다. 중간 행의 할당에 실패하면 이미 만든 행들도 반환해야 한다.

이런 경계 초과가 이웃 블록의 헤더·푸터나 연결 정보를 망가뜨리면, 원인은 배열 초기화인데 증상은 나중의 할당기 동작에서 나타날 수 있다.

9.11.5: Off-by-one 에러

n개의 원소를 가진 배열의 유효한 인덱스는 0부터 n - 1까지다. 마지막 조건을 하나 잘못 쓰면 n개를 확보하고 n+1개를 쓰게 된다.

for (i = 0; i <= n; i++) {  // 잘못된 예: i == n에서도 실행
    A[i] = malloc(m * sizeof *A[i]);
}

올바른 경계는 다음과 같다.

for (i = 0; i < n; i++) {
    A[i] = malloc(m * sizeof *A[i]);
}

Notion 기록에서는 설명에 n+1회 초기화라고 적혀 있었지만 코드에는 i < n이 들어 있었다. 여기서는 잘못된 예를 i <= n으로 구분해 설명과 맞췄다.

같은 실수는 문자열에서도 발생한다. 문자 n개 외에 종료 널 문자를 저장할 자리까지 고려해야 한다.

9.11.6: 포인터가 가리키는 값 대신 포인터를 변경하기

호출한 쪽의 크기 변수를 함수 안에서 줄이기 위해 int *size를 받았다고 해보자. 원문의 힙 삭제 예시에서는 아래 표현으로 개수를 줄이려 했다.

*size--;  // 잘못된 예

그러나 후위 --가 단항 *보다 우선순위가 높으므로 다음과 같이 해석된다.

*(size--);

원하는 동작은 정숫값 감소인데, 실제로 바뀌는 것은 로컬 포인터 size다. 이후 변경된 size를 역참조하면 원래의 개수 변수를 읽지 못한다. 포인터가 허용된 배열 범위를 벗어나도록 감소하는 것 자체도 문제가 될 수 있다.

가리키는 값을 줄이려면 괄호로 대상을 명확히 한다.

(*size)--;

원문의 보충 예시에는 증가 표현이 있었지만, 여기서 의도한 동작은 감소이므로 (*size)--로 정리했다.

9.11.7: 포인터 연산의 단위를 오해하기

포인터에 1을 더하면 1바이트가 아니라 가리키는 타입의 객체 하나만큼 이동한다.

sizeof(int) == 4이고, 충분한 크기의 배열 안에서 p가 가상의 주소 1000을 가리킨다고 해보자.

p + 1 → 1004: int 하나 뒤
p + 4 → 1016: int 네 개 뒤

바이트 크기를 한 번 더 곱하면 의도보다 멀리 이동한다.

p += sizeof(int);  // sizeof(int)가 4이면 int 네 개만큼 이동
인덱스: [0]  [1]  [2]  [3]  [4]  [5]  [6]  [7]  [8]
주소:   1000 1004 1008 1012 1016 1020 1024 1028 1032
         ↑                   ↑                   ↑
       검사                 검사                검사

정수 배열을 하나씩 순회하려면 p++ 또는 p += 1을 사용한다. 바이트 단위로 객체 표현을 살펴보려면 문자 타입 포인터로 다루되, 어느 경우든 유효한 객체 범위와 수명을 지켜야 한다.

9.11.8: 수명이 끝난 지역 변수 참조하기

int *stackref(void)
{
    int val;
    return &val;  // 잘못된 예
}

val은 함수 안의 자동 지역 변수다. 함수가 반환하면 수명이 끝나므로, 그 주소를 받았다고 계속 유효하게 사용할 수 있는 것은 아니다.

메모리에 이전 값이 잠시 남아 보이더라도 안전성을 보장하지 않는다. 다른 함수의 실행에서 같은 스택 공간을 재사용할 수 있고, 해당 포인터로 읽거나 쓰는 것은 유효한 객체에 대한 접근이 아니다.

상황에 따라 값을 직접 반환하거나, 호출자가 제공한 저장 공간에 결과를 쓰거나, 수명을 명확히 관리할 힙 객체를 사용해야 한다.

9.11.9: 반환한 힙 블록의 데이터 참조하기

이것이 use-after-free 다. 메모리를 반환한 뒤에도 이전 포인터로 계속 접근하는 경우다.

free(x);
y = malloc(m * sizeof *y);
for (i = 0; i < m; i++) {
    y[i] = x[i]++;  // 잘못된 예: 이미 반환한 x를 읽고 씀
}

free는 원래 데이터를 계속 보존해 달라는 요청이 아니다. 그 공간은 다른 요청에 할당되거나, 할당기 자신의 관리 정보 저장에 쓰일 수 있다.

예를 들어 명시적 가용 리스트에서는 이전 페이로드 공간에 연결 포인터를 넣을 수 있다.

반환 전: [헤더][       사용자 데이터       ][푸터]
반환 후: [헤더][pred][succ][남은 가용 공간][푸터]

그 이후의 읽기는 이전 사용자 데이터가 아니라 바뀐 내용을 볼 수 있다. 쓰기는 가용 리스트 포인터나 새로 할당된 다른 객체를 손상시킬 수 있다.

이때 pred와 succ이 차지하는 크기는 포인터 크기에 따라 달라진다. 정수 원소 하나를 증가시켰다고 연결 포인터 하나가 정확히 1 증가한다고 단정할 수는 없다. 반환한 저장 공간을 건드리는 것 자체가 잘못이다.

원인의 위치에서는 조용히 지나가고, 나중에 할당기가 손상된 연결 정보를 따라갈 때 실패할 수 있다. 또 x = NULL로 바꾸는 것은 해당 변수의 실수를 줄이는 데 도움이 되지만, 같은 객체를 가리키던 다른 별칭 포인터까지 바꾸지는 않는다.

9.11.10: 메모리 누수

할당한 메모리를 더 이상 사용하지 않는데도 반환하지 않으면 재사용할 공간을 잃는다. 특히 블록을 가리키는 마지막 포인터를 잃으면 일반적인 명시적 관리 코드에서는 반환할 방법도 사라진다.

짧게 실행되는 프로그램에서는 잘 드러나지 않더라도, 데몬이나 서버처럼 오래 살아 있는 프로세스에서는 누적된다. 끝내 메모리 제한이나 가상 주소 공간의 한계에 도달해 할당이 실패할 수 있다.

중요한 것은 정상 경로에 free 하나를 추가하는 데서 끝내지 않는 것이다. 오류로 중간에 반환하는 경로에서도, 이미 확보한 자원을 누가 정리하는지 정해야 한다.

버그의 증상보다 먼저 확인할 것

구분확인할 질문
주소와 타입이 함수는 값과 주소 중 무엇을 받는가?
초기화공간을 할당한 뒤 필요한 값을 썼는가?
크기와 경계실제 저장할 객체 크기와 반복 횟수가 맞는가?
연산포인터와 가리키는 값 중 무엇을 바꾸고 있는가?
수명이 객체가 아직 존재하며 내가 접근할 권한이 있는가?
소유권마지막 사용 이후 누가, 어떤 경로에서 반환하는가?

메모리 오류가 발생한 함수만 살펴보면 원인을 놓칠 수 있다. 그 함수가 사용한 메모리를 누가 할당했고, 어디에서 썼으며, 언제 반환했는지까지 거슬러 올라가야 하는 이유다.


함께 읽기

이어 읽으면 좋은 기록

Week 6 Malloc Lab의 Place 함수 개선

Place 함수의 블록 배치 방향을 바꿔 91점에서 94점으로 개선한 과정과 임계값 최적화가 Util 향상으로 이어지지 않는 이유.