Menu

C 언어 세그멘테이션 폴트: 원인과 해결법

세그폴트는 프로그램이 소유하지 않은 메모리를 건드렸다는 뜻입니다. 거의 모든 세그폴트를 설명하는 다섯 가지 원인을 최소 예제와 해법과 함께 정리하고, gdb와 AddressSanitizer로 정확한 줄을 찾는 법도 다룹니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

Segmentation fault (core dumped)

이 한 줄이 C에서 가장 많이 검색되는 오류이고, 보이는 것만큼 신비롭지 않습니다. 프로그램이 프로세서에 어떤 메모리 주소를 요구했고, 운영체제가 여러분의 프로세스가 그 주소를 건드려도 되는지 확인했고, 답이 아니오였습니다. 그래서 커널이 SIGSEGV 신호로 프로세스를 죽였습니다.

핵심 통찰. 크래시는 증상이고, 그 위치가 버그인 경우는 많지 않습니다. 잘못된 포인터는 보통 앞선 다른 어딘가에서 만들어졌고, 여기는 그것이 처음 쓰인 자리일 뿐입니다. 이 페이지는 거의 모든 세그폴트를 설명하는 다섯 원인을 다루고, 그다음 몇 초 만에 진짜 줄을 찾아 주는 두 도구를 다룹니다.

아래의 죽는 예제들은 일부러 실행 가능한 블록이 아닙니다. 설계상 죽으니까요. 읽고 나서 그 뒤의 고친 판을 읽으세요.

"소유하지 않은 메모리"란

프로그램이 시작할 때 운영체제는 주소 공간에 몇 개의 영역을 매핑합니다. 코드, 전역 변수, 스택, 그리고 힙이 자란 만큼입니다. 주소 0을 포함한 주소 공간의 나머지 전부는 매핑되지 않습니다. 매핑되지 않은 주소를 건드리거나 읽기 전용 주소에 쓰면 하드웨어가 그것을 잡아챕니다.

그래서 세그폴트는 컴파일러가 여러분을 잡는 것이 아닙니다. 런타임 난간이고, 잘못된 주소가 우연히 매핑된 페이지 밖에 있을 때만 발동합니다. 그래서 같은 버그가 한 기계에서는 죽고 다른 기계에서는 잘 되는 것처럼 보일 수 있습니다. 메모리 배치가 다르니까요.

원인 1: NULL 포인터 역참조

가장 흔한 원인이자 고치기 가장 쉬운 것입니다. 주소 0은 절대 매핑되지 않으므로 널 포인터로 읽거나 쓰면 항상 폴트가 납니다.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = NULL;
    *p = 42;              /* 크래시: 주소 0에 쓰기 */
    printf("%d\n", *p);
    return 0;
}

현실적인 판은 확인하지 않은 할당입니다.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *data = malloc(1000000000000UL * sizeof(int));  /* 실패, NULL 반환 */
    data[0] = 1;                                        /* 크래시 */
    free(data);
    return 0;
}

malloc은 요청을 들어줄 수 없으면 NULL을 반환합니다. 파일이 없을 때의 fopen도, 문자가 없을 때의 strchr도 그렇습니다. NULL을 반환할 수 있는 모든 함수의 결과를 쓰기 전에 확인하세요.

포인터를 초기화하지 않은 채 두지 말고 NULL로 초기화하세요. 널 포인터는 즉시, 분명하게 죽습니다. 쓰레기 포인터는 무언가를 망가뜨리고 훨씬 나중에 죽을 수 있습니다. 이 패턴에 대한 자세한 내용은 널 포인터에 있습니다.

원인 2: 배열 끝을 넘어 쓰기

C는 배열 경계를 검사하지 않습니다. 원소 10개짜리 배열의 인덱스 10은 그저 배열 뒤의 메모리이고, 컴파일러는 불평 없이 그 주소를 계산해 줍니다.

#include <stdio.h>

int main(void) {
    int arr[10];

    for (int i = 0; i <= 10; i++) {   /* < 대신 <= : 하나 많다 */
        arr[i] = i;
    }

    printf("done\n");
    return 0;
}

그것이 죽을지는 운입니다. 지역 배열 끝을 4바이트 넘어 쓰는 것은 보통 다른 스택 데이터 - 저장된 레지스터, 다른 변수, 반환 주소 - 에 떨어지므로, 프로그램이 스스로를 망가뜨리고 나중에 상관없는 곳에서 죽습니다. 크게 넘어서면 매핑된 페이지를 벗어나 곧바로 세그폴트가 납니다.

더 과격한 판은 항상 죽습니다.

#include <stdio.h>

int main(void) {
    int arr[10];
    arr[1000000] = 42;      /* 매핑된 어떤 것보다 한참 바깥: 크래시 */
    return 0;
}

해법은 i < n 습관과, 두 번 타이핑하는 대신 n을 계산하는 것입니다.

문자열에는 이것의 자기 판이 있습니다. 종료 '\0'이 들어갈 자리가 없는 버퍼입니다.

#include <string.h>

int main(void) {
    char name[5];
    strcpy(name, "Alexander");   /* 9글자 + 종료 문자를 5바이트에 */
    return 0;
}

strcpyname이 얼마나 큰지 전혀 모릅니다. 여러분이 알려 주기 때문에 아는 snprintf를 쓰세요.

원인 3: free 후에 포인터 쓰기 (댕글링 포인터)

free(p) 뒤에 그 메모리는 할당자에게 돌아갑니다. 포인터는 여전히 옛 주소를 담고 있지만 그 주소는 더 이상 여러분 것이 아닙니다.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof *p);
    *p = 42;
    free(p);

    printf("%d\n", *p);   /* 해제 후 사용: 쓰레기를 출력할 수도, 죽을 수도 */
    free(p);              /* 이중 해제: 보통 중단되거나 힙이 망가진다 */
    return 0;
}

관련된 함정은 지역 변수의 주소를 반환하는 것입니다. 함수가 반환하는 순간 그 스택 프레임이 사라집니다.

#include <stdio.h>

int *make_number(void) {
    int value = 42;
    return &value;      /* 프레임이 여기서 죽는다; 포인터가 댕글링 상태 */
}

int main(void) {
    int *p = make_number();
    printf("%d\n", *p);   /* 미정의: 쓰레기, 또는 크래시 */
    return 0;
}

의도에 따라 해법이 둘입니다. 포인터가 아니라 값을 반환하거나, 힙에 할당해 호출자가 해제하게 하는 것입니다.

free 직후에 포인터를 NULL로 두는 것은 값싼 방어 습관입니다. 조용한 해제 후 사용을 즉각적이고 명백한 널 역참조 크래시로 바꾸고, free(NULL)이 아무 일도 하지 않도록 정의되어 있으므로 두 번째 free(p)를 무해하게 만듭니다. 이 이야기의 할당 쪽은 동적 메모리와 메모리 누수에 있습니다.

원인 4: 폭주하는 재귀로 인한 스택 오버플로

모든 함수 호출은 스택에 프레임을 놓고, 스택은 크기가 고정된 영역입니다(보통 8MB). 기저 사례가 없거나 결코 닿지 못하는 재귀는 그 끝을 넘어갑니다.

#include <stdio.h>

int countdown(int n) {
    printf("%d\n", n);
    return countdown(n - 1);    /* 기저 사례 없음: 멈추지 않는다 */
}

int main(void) {
    return countdown(5);
}

재귀가 기저 사례를 건너뛰어도 같은 일이 벌어집니다.

int f(int n) {
    if (n == 0) return 1;
    return n * f(n - 2);    /* 홀수 n에서 시작하면 0이 되지 않는다 */
}

모든 재귀 함수에는 모든 입력에서 닿을 수 있는 기저 사례가 필요합니다.

거대한 지역 배열도 마찬가지입니다. 함수 안의 int buffer[10000000];은 40MB의 스택을 요구하고 첫 쓰기에서 폴트가 납니다. 큰 버퍼는 malloc으로 힙에 할당하세요. 관련된 크기는 스택과 힙을, 기저 사례 설계는 재귀를 보세요.

원인 5: 문자열 리터럴에 쓰기

이것은 코드가 무해해 보여서 사람들을 놀라게 합니다.

#include <stdio.h>

int main(void) {
    char *s = "hello";
    s[0] = 'H';          /* 크래시: 문자열 리터럴은 읽기 전용이다 */
    printf("%s\n", s);
    return 0;
}

문자열 리터럴은 실행 파일의 읽기 전용 구역에 삽니다. char *s = "hello"는 그리로 가리키고, 그 포인터를 통해 쓰는 것은 보호 위반이며, OS는 매핑되지 않은 접근과 똑같이 세그폴트로 보고합니다.

해법은 배열을 만드는 것이고, 그러면 수정 가능한 자기 사본을 얻습니다.

리터럴 포인터를 const char *로 선언하면 이 런타임 크래시가 컴파일 타임 오류로 바뀌는데, 엄밀히 더 낫습니다. 습관으로 만드세요.

진짜 줄 찾기: gdb

실행 파일이 디버그 심볼을 지니도록 -g로 컴파일한 뒤 디버거로 실행하세요.

gcc -g program.c -o program
gdb ./program

gdb 안에서.

(gdb) run
Program received signal SIGSEGV, Segmentation fault.
0x0000555555555151 in process_item (item=0x0) at program.c:14
14          return item->count * 2;

(gdb) backtrace
#0  process_item (item=0x0) at program.c:14
#1  0x000055555555518a in main () at program.c:23

(gdb) print item
$1 = (struct Item *) 0x0

명령 셋이 일의 대부분을 합니다. run은 프로그램을 시작해 폴트가 나는 자리에서 멈춥니다. backtrace(또는 bt)는 거기까지 온 호출 사슬을 보여 주는데, 프레임 #1이 보통 잘못된 포인터가 실제로 만들어진 곳입니다. print는 변수를 들여다보고, item = 0x0이 문제를 대놓고 말해 줍니다.

macOS에서는 lldb ./program 뒤에 runbt가 대응됩니다.

더 빨리 찾기: AddressSanitizer

더 좋은 방법은 컴파일러가 프로그램을 계측하게 하는 것입니다. AddressSanitizer는 유효하지 않은 접근을 그것이 일어나는 순간 잡아냅니다. 죽지 않았을 것들까지 포함해서요.

gcc -g -fsanitize=address program.c -o program
./program
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
WRITE of size 4 at 0x602000000010 thread T0
    #0 0x4011f6 in main program.c:11

0x602000000010 is located 0 bytes inside of 4-byte region
freed by thread T0 here:
    #1 0x4011c9 in main program.c:10
previously allocated by thread T0 here:
    #2 0x4011a6 in main program.c:8

그 보고는 버그 부류, 그것을 저지른 줄, 메모리를 해제한 줄, 그리고 할당한 줄을 이름 붙여 알려 줍니다. C 메모리 버그에 가장 효과적인 단 하나의 디버깅 도구이고, 리눅스와 macOS의 GCC와 clang에서 동작하며, 실행 시간이 대략 2배 듭니다. 개발 중에는 상관없는 비용이죠.

부호 있는 오버플로와 다른 미정의 동작을 동시에 잡으려면 -fsanitize=undefined와 짝지으세요.

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

valgrind ./program은 다시 컴파일할 필요가 없는 대안으로, 같은 부류의 오류에 누수까지 보고합니다.

세그폴트를 만났을 때의 체크리스트

  1. -g -Wall -Wextra -fsanitize=address,undefined로 다시 빌드해 실행하세요. 대개는 보고가 줄을 짚어 주고 끝납니다.
  2. 새니타이저를 쓸 수 없다면 gdb로 실행해 backtrace를 뜨세요. 프레임 0만이 아니라 프레임 1을 보세요.
  3. 죽는 줄의 모든 포인터를 확인하세요. 각각을 출력해 보세요. 0x0은 널이고, 0x7fff5fc01000 같은 엉뚱한 값은 보통 초기화되지 않았거나 해제된 것입니다.
  4. 그 포인터가 어디서 왔는지 물으세요. 확인하지 않은 malloc이나 fopen? 이미 반환된 지역 변수의 주소? free 뒤에 쓴 포인터?
  5. 크래시 근처의 모든 반복문 경계에서 <를 뜻했는데 <=인 곳이 없는지 확인하세요.
  6. 스택 추적이 수천 프레임 깊이라면 포인터 버그가 아니라 폭주하는 재귀입니다.

예방하기

세그폴트를 드물게 만드는 습관들.

  • 항상 -Wall -Wextra로 컴파일하고, 경고를 버그로 취급하세요.
  • 모든 포인터를 초기화하고, 더 나은 값이 없으면 NULL로 두세요.
  • malloc, calloc, realloc, fopen의 반환값을 확인하세요.
  • 해제 직후에 포인터를 NULL로 설정하세요.
  • sprintfgets 대신 snprintffgets를 쓰세요.
  • 문자열 리터럴 포인터는 const char *로 선언하세요.
  • 하드코딩한 길이보다 sizeof arr / sizeof arr[0]을 선호하세요.
  • CI에서 테스트 모음을 AddressSanitizer 아래에서 돌리세요.

세그폴트는 친절한 실패 방식입니다. 무언가 잘못됐다고 말해 주니까요. 이웃 변수를 조용히 망가뜨리고 세 함수 뒤에서 틀린 답을 내놓는 같은 부류의 버그가 훨씬 나쁘고, 위의 도구들은 둘 다 잡아 줍니다.

자주 묻는 질문

C에서 세그멘테이션 폴트란 무엇인가요?

프로그램이 건드려서는 안 되는 메모리에 접근할 때 운영체제가 일으키는 크래시입니다. 유효하지 않은 포인터로 읽거나 쓰기, 배열 끝을 지나 매핑되지 않은 페이지로 넘어가기, 스택 넘치기가 그렇습니다. 커널이 프로세스에 SIGSEGV 신호를 보내 종료시키고 "Segmentation fault (core dumped)"를 출력합니다.

세그멘테이션 폴트가 어디서 일어나는지 어떻게 찾나요?

디버그 심볼을 넣어 컴파일하고 디버거로 실행하세요. gcc -g program.c -o program 뒤에 gdb ./program, run, 그리고 죽으면 backtrace입니다. 정확한 파일과 줄을 출력해 줍니다. 메모리 버그에는 gcc -g -fsanitize=address program.c -o program이 더 빠릅니다. 그냥 실행하면 무엇이 어디서 잘못됐는지 전체 보고가 나옵니다.

제 C 프로그램이 왜 가끔만 세그폴트를 내나요?

유효하지 않은 접근이 보장된 크래시가 아니라 미정의 동작이기 때문입니다. 배열 끝을 하나 넘어 쓰는 것은 종종 프로세스가 실제로 소유한 메모리에 떨어지므로 아무것도 막지 않고, 대신 이웃 변수를 망가뜨립니다. 잘못된 주소가 우연히 매핑된 페이지 밖에 떨어질 때만 세그폴트가 나고, 그것은 그 빌드와 실행의 배치에 달려 있습니다.

세그멘테이션 폴트는 메모리 누수가 있다는 뜻인가요?

아닙니다. 정반대 문제입니다. 누수는 할당하고 해제하지 않은 메모리로, 프로그램이 계속 돌면서 서서히 커집니다. 세그폴트는 소유하지 않은 메모리를 건드리는 것입니다. 메모리를 두 번 해제하거나 해제한 뒤 포인터를 쓰면 세그폴트가 나고, 해제를 잊으면 누수가 납니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기