C++ Segmentation fault 디버깅: core dump·GDB·LLDB·ASan·Valgrind로 원인 찾기

들어가며: Segmentation fault를 추적하는 순서

Segmentation fault는 프로세스가 접근 권한이 없는 메모리 주소를 건드렸을 때 커널이 SIGSEGV 신호를 보내 종료시키는 오류입니다. 널 포인터 역참조, 이미 해제된 메모리 접근(use-after-free), 스택 오버플로우, 버퍼 경계를 넘는 쓰기가 대표적인 원인입니다. 문제는 크래시가 난 줄과 버그가 있는 줄이 다른 경우가 많다는 것입니다. 버퍼 오버런이 엉뚱한 객체를 덮어쓰고, 그 객체를 한참 뒤에 쓰다가 죽는 식입니다.

이 글은 크래시가 이미 났을 때 core dump를 남기고 GDB·LLDB로 읽는 절차, 그리고 재현이 가능할 때 AddressSanitizer(ASan)와 Valgrind로 진짜 원인 지점을 찾는 방법을 정리합니다. 원인 다섯 가지 각각의 재현 코드와 고치는 방법은 Segmentation fault 원인 5가지와 해결법에서 다룹니다. 디버거 기본 사용법은 GDB/LLDB, 새니타이저는 AddressSanitizer를 참고하세요.

flowchart TB
    A[Segmentation fault] --> B{core 파일 있음?}
    B -->|아니오| C[ulimit -c / core_pattern / LimitCORE 확인]
    C --> E[다음 크래시 대기 또는 재현]
    E --> F
    B -->|예| F[gdb ./program core]
    F --> G[bt로 호출 스택]
    G --> H[frame N, print로 변수 확인]
    H --> J{원인이 분명한가?}
    J -->|아니오, 재현 가능| K[ASan 빌드로 재실행]
    J -->|예| L[수정]
    K --> L

core dump 활성화

ulimit

ulimit -c가 0이면 core가 만들어지지 않습니다. 셸에서 ulimit -c unlimited로 제한을 풀면 그 셸에서 실행한 프로그램이 크래시할 때 core를 남깁니다. 이 설정은 현재 셸과 자식 프로세스에만 적용되므로, 로그인 사용자 전체에 적용하려면 /etc/security/limits.conf에 * soft core unlimited를 추가합니다. 하드 제한보다 높이려 하면 ulimit: core file size: cannot modify limit: Operation not permitted가 나는데, 이 경우 root 권한으로 limits.conf의 hard 값을 올려야 합니다.

ulimit -c             # 0이면 core가 생성되지 않음
ulimit -c unlimited   # 현재 셸에서 해제

core가 저장되는 곳

/proc/sys/kernel/core_pattern이 core의 이름과 위치를 정합니다. 값이 core나 core.%e.%p처럼 경로 없는 이름이면 크래시한 프로세스의 작업 디렉터리에 생기고(그 디렉터리에 쓰기 권한이 있어야 합니다), |로 시작하면 core를 그 프로그램에 파이프로 넘깁니다. Ubuntu 데스크톱은 apport, systemd-coredump가 설치된 배포판은 systemd-coredump가 받아 저장하므로, 작업 디렉터리를 아무리 찾아도 core 파일이 보이지 않습니다.

cat /proc/sys/kernel/core_pattern
# 예: |/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h

# 직접 파일로 남기고 싶다면 (재부팅 시 원복됨, 영구 적용은 /etc/sysctl.d/)
echo '/var/crash/core.%e.%p.%t' | sudo tee /proc/sys/kernel/core_pattern

%e는 실행 파일 이름, %p는 PID, %t는 크래시 시각(유닉스 시간)입니다.

systemd 서비스와 coredumpctl

systemd가 실행하는 서비스는 셸의 ulimit를 물려받지 않으므로 유닛 파일에서 제한을 풀어야 합니다. 유닛 파일에 쓰는 지시어는 LimitCORE=infinity입니다. DefaultLimitCORE는 /etc/systemd/system.conf의 [Manager] 섹션에서 모든 유닛의 기본값을 바꾸는 설정이라, 서비스 유닛에 적으면 인식되지 않습니다.

# /etc/systemd/system/myapp.service
[Service]
ExecStart=/usr/local/bin/myapp
LimitCORE=infinity
sudo systemctl daemon-reload && sudo systemctl restart myapp

coredumpctl list                        # 수집된 core 목록
coredumpctl list --since "1 hour ago"
coredumpctl info myapp                  # 가장 최근 myapp core의 요약과 스택
coredumpctl debug myapp                 # 그 core를 GDB로 바로 열기
coredumpctl dump myapp -o /tmp/core.myapp   # core를 파일로 꺼내기

Docker 컨테이너

컨테이너의 core 제한은 docker run --ulimit core=-1로 풉니다. core_pattern은 컨테이너가 아니라 호스트 커널 설정이므로 컨테이너 안에서 바꿀 수 없고, 파이프 핸들러는 호스트에서 실행됩니다. 패턴이 파일 경로라면 그 경로는 크래시한 프로세스(컨테이너) 기준으로 해석되므로, 해당 경로를 볼륨으로 마운트해 두어야 core를 호스트에서 회수할 수 있습니다. Dockerfile에서 /etc/profile에 ulimit를 추가하는 방식은 로그인 셸에서만 적용되어 일반 컨테이너 프로세스에는 효과가 없습니다.


GDB·LLDB로 core 분석하기

core는 크래시 순간의 메모리 스냅샷이고, 그것을 해석하려면 크래시한 바로 그 빌드의 실행 파일이 필요합니다. 다시 빌드한 실행 파일은 주소 배치가 달라 엉뚱한 결과를 보여 줄 수 있습니다. -g로 빌드했거나 디버그 심볼을 따로 보관해 두었다면 소스 줄과 변수 이름이 보입니다.

gdb ./your_program core            # GDB
lldb ./your_program -c core        # LLDB

bt로 크래시 순간의 호출 스택을 보면 #0이 잘못된 접근이 일어난 프레임입니다. frame N으로 원하는 프레임으로 옮겨 print로 변수를 보고, bt full로 모든 프레임의 지역 변수를 한 번에 볼 수 있습니다. 최적화 빌드의 core에서는 변수가 레지스터에만 있다가 사라져 <optimized out>으로 표시되는 일이 흔합니다.

목적GDBLLDB
호출 스택btbt
모든 스레드의 스택thread apply all btbt all
프레임 이동frame Nframe select N
현재 프레임의 지역 변수info localsframe variable
변수 출력print xp x
디스어셈블리disassembledisassemble
주소가 속한 메모리 영역info proc mappingsmemory region <addr>
로드된 공유 라이브러리info sharedlibraryimage list

멀티스레드 서버라면 크래시한 스레드뿐 아니라 thread apply all bt로 다른 스레드가 그 순간 무엇을 하고 있었는지도 봐야 합니다. 다른 스레드가 같은 객체를 해제하는 중이었다면 그것이 원인입니다.


원인별 재현과 분석

Null 포인터 역참조

// g++ -g -O0 -o segfault_demo segfault_demo.cpp
#include <iostream>
int main() {
    int* p = nullptr;
    std::cout << *p << "\n";  // null 역참조
    return 0;
}
$ gdb ./segfault_demo core
...
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x0000555555555189 in main () at segfault_demo.cpp:5
5           std::cout << *p << "\n";  // null 역참조
(gdb) print p
$1 = (int *) 0x0

#0의 줄에서 역참조하는 포인터가 0x0이면 null 역참조입니다. 실제 코드에서 중요한 것은 “왜 null이 되었는가”이므로, 상위 프레임으로 올라가며 그 포인터를 만든 곳(초기화 누락, 실패를 나타내는 반환값을 확인하지 않은 곳)을 찾습니다. 주소가 0x0이 아니라 0x10, 0x28처럼 작은 값이라면 null 포인터를 통해 멤버에 접근한 경우입니다(p->member의 오프셋).

Use-after-free

// g++ -g -O0 -o uaf_demo uaf_demo.cpp
#include <iostream>
int main() {
    int* p = new int(42);
    delete p;
    std::cout << *p << "\n";  // 해제된 메모리 읽기
    return 0;
}

이 코드는 대개 segfault가 나지 않습니다. delete한 메모리는 할당자에게 돌아갈 뿐 프로세스의 주소 공간에서 사라지지 않으므로, 읽기는 성공하고 42나 쓰레기 값이 출력됩니다. 그래서 use-after-free는 한참 뒤 다른 객체가 그 메모리를 재사용한 다음에야 엉뚱한 곳에서 크래시를 일으키고, core에서 포인터 값만 봐서는 그 주소가 해제된 것인지 알 수 없습니다. 이런 버그는 ASan으로 재현하는 것이 가장 확실합니다.

$ g++ -g -fsanitize=address -fno-omit-frame-pointer -o uaf_asan uaf_demo.cpp
$ ./uaf_asan
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
    #0 ... in main uaf_demo.cpp:6
freed by thread T0 here:
    #0 ... in operator delete(void*, unsigned long)
    #1 ... in main uaf_demo.cpp:5
previously allocated by thread T0 here:
    #0 ... in operator new(unsigned long)
    #1 ... in main uaf_demo.cpp:4

ASan은 잘못 읽은 위치뿐 아니라 그 메모리를 해제한 위치와 할당한 위치까지 보여 줍니다. 해결은 소유권을 std::unique_ptr이나 std::shared_ptr로 옮기는 것입니다. delete 뒤 포인터에 nullptr을 넣는 습관은 같은 변수를 통한 재사용만 막을 뿐, 다른 곳에 복사된 포인터는 여전히 댕글링 상태라는 점에 주의해야 합니다.

스택 오버플로우

// g++ -g -O0 -o stack_overflow stack_overflow.cpp
void recurse(int n) {
    int arr[1000];   // 프레임마다 약 4KB
    arr[0] = n;
    if (n > 0) recurse(n - 1);
}
int main() {
    recurse(10000);  // 약 40MB 필요 → 기본 8MB 스택 초과
    return 0;
}
(gdb) bt
#0  0x... in recurse (n=7957) at stack_overflow.cpp:3
#1  0x... in recurse (n=7958) at stack_overflow.cpp:5
#2  0x... in recurse (n=7959) at stack_overflow.cpp:5
...

같은 함수가 수천 단계 반복되면 재귀가 너무 깊은 것입니다(정확히 몇 번째에서 죽는지는 프레임 크기와 스택 한도에 따라 다릅니다). 호출 깊이는 얕은데 큰 지역 배열을 선언한 함수에 들어가자마자 죽는다면, 지역 변수 하나가 스택 한도를 넘은 경우입니다. 스택이 넘친 상태에서는 신호 핸들러조차 실행할 스택이 없어서 로그 없이 바로 죽기 쉽습니다. ulimit -s로 한도를 늘리는 것은 임시방편이고, 큰 버퍼는 std::vector처럼 힙에 두고 깊은 재귀는 명시적인 스택을 쓰는 반복문으로 바꾸는 것이 근본적인 해결책입니다. 스레드의 스택 크기는 메인 스레드와 별도로 정해지므로(pthread_attr_setstacksize), 워커 스레드에서만 죽는 경우도 있습니다.

버퍼 오버런

// g++ -g -O0 -o buffer_overflow buffer_overflow.cpp
#include <cstring>
#include <iostream>
int main() {
    char buf[8];
    std::strcpy(buf, "123456789012345");  // 8바이트 버퍼에 16바이트(널 문자 포함) 쓰기
    std::cout << buf << "\n";
    return 0;
}
$ g++ -g -fsanitize=address -fno-omit-frame-pointer -o buf_asan buffer_overflow.cpp
$ ./buf_asan
==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc...
WRITE of size 16 at 0x7ffc... thread T0
    #0 ... in strcpy
    #1 ... in main buffer_overflow.cpp:6

ASan 없이 실행하면 아무 일 없이 끝나거나, 덮어쓴 반환 주소 때문에 main이 반환할 때 죽거나, 스택 보호(-fstack-protector)가 *** stack smashing detected ***로 프로그램을 중단시킵니다. 어느 쪽이든 크래시 위치는 덮어쓴 줄이 아닙니다. 고정 크기 버퍼 대신 std::string을 쓰는 것이 가장 좋고, C 버퍼가 필요하면 snprintf처럼 크기를 받는 함수를 씁니다. strncpy는 원본이 길면 끝에 널 문자를 넣지 않으므로 대안으로 적합하지 않습니다.

이중 해제

// g++ -g -O0 -o double_free double_free.cpp
int main() {
    int* p = new int(42);
    delete p;
    delete p;  // 이중 해제
    return 0;
}

이중 해제는 보통 SIGSEGV가 아니라 glibc 할당자의 검사에 걸려 free(): double free detected in tcache 2 메시지와 함께 SIGABRT로 종료됩니다. 할당자 내부 자료구조가 이미 망가진 뒤라면 나중에 다른 malloc에서 segfault로 나타나기도 합니다. ASan은 attempting double-free 오류와 함께 두 번째 해제(5번 줄), 첫 번째 해제(4번 줄), 할당(3번 줄) 위치를 모두 보여 줍니다.

라이브러리 안에서 죽을 때

bt의 #0이 libfoo.so 안이라면, 위로 올라가며 내 코드의 프레임을 찾아 그 프레임에서 라이브러리에 넘긴 포인터·크기·형식을 확인합니다. 대부분은 이미 해제된 버퍼나 잘못된 길이를 넘긴 호출자 쪽 버그입니다. info sharedlibrary에서 라이브러리 옆에 (*)가 붙어 있으면 디버그 정보가 없다는 뜻이므로, 배포판의 -dbg/-dbgsym 패키지나 debuginfod로 심볼을 받으면 라이브러리 안쪽 프레임의 소스 줄까지 볼 수 있습니다.

macOS의 LLDB

macOS는 ulimit -c unlimited를 설정한 셸에서 실행한 프로세스의 core를 /cores/core.<pid>에 남깁니다. /cores 디렉터리에 쓰기 권한이 있어야 하고, 최근 macOS에서는 실행 파일에 com.apple.security.get-task-allow 권한(entitlement)이 없으면 core가 생성되지 않는 경우가 있어 코드 서명 설정을 확인해야 할 수 있습니다. ~/Library/Logs/DiagnosticReports/에 남는 것은 core가 아니라 스택 정보가 담긴 크래시 리포트(.ips)이며, core가 없을 때도 첫 단서로 유용합니다.


ASan과 Valgrind

ASan은 컴파일 시점에 메모리 접근마다 검사 코드를 넣는 방식이라, 재현만 되면 core 없이도 원인 지점을 정확히 알려 줍니다. 실행 속도는 대략 2배 정도 느려지고 메모리 사용량도 늘어나므로 테스트·CI용 빌드에 둡니다. 컴파일과 링크 양쪽에 -fsanitize=address를 줘야 합니다.

# 테스트 타깃에만 ASan 적용
add_executable(myapp_test test.cpp)
target_compile_options(myapp_test PRIVATE -fsanitize=address -fno-omit-frame-pointer -g)
target_link_options(myapp_test PRIVATE -fsanitize=address)
# GitHub Actions 예시
- name: Build with ASan
  run: |
    cmake -B build -DCMAKE_BUILD_TYPE=Debug \
          -DCMAKE_CXX_FLAGS="-fsanitize=address -fno-omit-frame-pointer" \
          -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
    cmake --build build
- name: Run tests
  run: ctest --test-dir build --output-on-failure

Valgrind(memcheck)는 다시 빌드하지 않고 기존 바이너리를 가상 CPU 위에서 실행하며 검사하므로, 소스가 없는 라이브러리가 섞여 있거나 ASan 빌드가 어려울 때 유용합니다. 대신 수십 배 느리고, 스택 배열의 경계 초과는 잘 잡지 못합니다. --track-origins=yes를 주면 초기화되지 않은 값이 어디서 왔는지까지 추적합니다.

valgrind --error-exitcode=1 --track-origins=yes ./myapp
valgrind --leak-check=full ./myapp   # 누수 검사까지

운영 환경 준비

디버그 심볼 분리 보관

프로덕션 바이너리에서 디버그 정보를 빼더라도, 같은 빌드의 심볼을 따로 보관해 두면 core를 분석할 수 있습니다.

objcopy --only-keep-debug myapp myapp.debug
objcopy --strip-debug myapp
objcopy --add-gnu-debuglink=myapp.debug myapp
# 분석 시: myapp.debug를 실행 파일 옆(또는 /usr/lib/debug)에 두면 GDB가 자동으로 찾음

빌드 ID(readelf -n myapp의 Build ID)로 심볼 파일을 관리하면, 여러 버전이 섞인 운영 환경에서도 core와 맞는 심볼을 정확히 찾을 수 있습니다.

core에서 자동으로 스택 뽑기

#!/bin/bash
# analyze_core.sh <program> <core>
set -euo pipefail
gdb -batch -ex "thread apply all bt full" "$1" "$2"

core 분석 중 만나는 GDB 메시지

"core" is not a core dump: file format not recognized 같은 메시지는 core 파일이 잘렸거나(디스크 부족, 크기 제한) 다른 아키텍처의 파일일 때 나옵니다. file core와 file ./myapp으로 두 파일의 아키텍처가 같은지 먼저 확인합니다.

warning: core file may not match specified executable file은 core와 실행 파일의 빌드가 다르다는 경고입니다. 이 상태의 백트레이스는 믿을 수 없으므로, 크래시한 바로 그 빌드의 바이너리와 심볼을 구해야 합니다.

No symbol table is loaded는 -g 없이 빌드했거나 심볼 파일을 찾지 못한 경우입니다. 운영 빌드는 RelWithDebInfo처럼 최적화와 디버그 정보를 함께 켜고 심볼만 분리해 두는 것이 좋습니다.

Cannot access memory at address 0x...는 그 주소가 core에 포함되지 않은 영역일 때 나옵니다. 매핑되지 않은 주소(크래시의 원인 주소)이거나, core 크기 제한이나 coredump_filter 설정 때문에 일부 영역이 core에서 빠진 경우입니다. 포인터를 역참조하지 말고 print ptr로 값만 본 뒤, info proc mappings로 그 주소가 어느 영역(힙, 스택, 라이브러리)에 속하는지 확인합니다.


같이 보면 좋은 글


다음 글: [에러 해결·트러블슈팅 #49-2] CMake 빌드 시 흔한 링크 에러 (LNK2019, undefined reference to) 원인과 해결책 이전 글: [실전 딥다이브 #48-3] 커스텀 메모리 할당자(Memory Pool) 제작기