C++ Segmentation fault 원인 5가지와 디버깅 방법 | GDB로 추적하기

C++ Segmentation fault 원인 5가지와 디버깅 방법 | GDB로 추적하기

이 글의 핵심

C++ Segmentation fault 원인 5가지와 디버깅 방법에 대한 실전 가이드입니다. GDB로 추적하기 등을 예제와 함께 상세히 설명합니다.

들어가며: “Segmentation fault (core dumped)“가 뜨면

Segmentation fault(세그멘테이션 폴트—허용되지 않은 메모리 접근 시 OS가 프로세스를 강제 종료하는 오류)는 C++ 개발자가 가장 자주 마주치는 런타임 에러 중 하나입니다. 프로그램이 접근 권한이 없는 메모리를 읽거나 쓰려고 할 때, 운영체제가 프로세스를 강제 종료하며 나타나는 메시지입니다. 컴파일은 성공했지만 실행 중에 크래시가 나므로, 에러 메시지만으로는 원인을 찾기 어렵습니다.

이 글에서 다루는 것:

  • Segmentation fault의 5가지 주요 원인 (널 포인터, 댕글링 포인터, 스택 오버플로우, 버퍼 오버런, 잘못된 캐스팅)
  • GDB/LLDB로 core dump를 분석하는 방법
  • AddressSanitizer(ASan)로 실행 전에 잡는 법
  • 각 원인별 실전 예제와 해결법

요구 환경: Linux 또는 macOS에서 g++/Clang으로 빌드·실행하는 예제가 많습니다. GDB(Linux/WSL), LLDB(macOS), ASan(-fsanitize=address) 사용. Windows에서는 WSL로 동일 명령 실행하거나, Visual Studio 디버거·ASan(/fsanitize:address)로 대응 가능.

Segfault의 5가지 주요 원인디버깅 흐름을 요약하면 아래와 같습니다.

flowchart LR
  subgraph cause["원인"]
    N[널 포인터]
    D[댕글링]
    S[스택 오버플로우]
    B[버퍼 오버런]
    C[잘못된 캐스팅]
  end
  subgraph debug["대응"]
    G[GDB/LLDB]
    A[ASan]
  end
  cause --> debug

목차

  1. Segmentation fault란?
  2. 원인 1: 널(NULL) 포인터 역참조
  3. 원인 2: 댕글링 포인터 (Use-After-Free)
  4. 원인 3: 스택 오버플로우
  5. 원인 4: 버퍼 오버런 (배열 범위 초과)
  6. 원인 5: 잘못된 타입 캐스팅
  7. GDB/LLDB로 core dump 분석하기
  8. AddressSanitizer로 사전에 잡기

1. Segmentation fault란?

메모리 보호 위반

운영체제는 각 프로세스에 메모리 영역을 할당하고, 그 범위를 벗어난 접근을 막습니다. 프로그램이 다음과 같은 메모리에 접근하려 하면 SIGSEGV 시그널을 보내 프로세스를 강제 종료합니다.

  • 할당되지 않은 주소 (예: NULL, 0x0)
  • 이미 해제된 메모리 (댕글링 포인터)
  • 읽기 전용 영역을 쓰려고 할 때 (예: 문자열 리터럴 수정)
  • 스택 범위를 벗어난 접근

왜 운영체제가 막나요?
만약 프로그램이 다른 프로세스의 메모리를 마음대로 읽고 쓸 수 있다면, 보안 문제가 발생합니다. 예를 들어, 브라우저가 실수로 은행 앱의 메모리를 덮어쓰면 큰일입니다. 운영체제는 각 프로세스를 가상 메모리 공간으로 격리하고, 허용되지 않은 접근을 하드웨어 레벨(MMU, Memory Management Unit)에서 차단합니다. 이때 CPU가 페이지 폴트(Page Fault) 예외를 발생시키고, 운영체제가 SIGSEGV 시그널을 프로세스에 보냅니다.

Segmentation의 의미:
“Segmentation”은 메모리를 세그먼트(segment)로 나누는 것을 의미합니다. 옛날 컴퓨터(x86 리얼 모드)에서는 메모리를 코드 세그먼트, 데이터 세그먼트, 스택 세그먼트로 나눴습니다. 세그먼트 경계를 넘는 접근을 “Segmentation Violation” 또는 “Segmentation Fault”라고 불렀고, 이 용어가 지금까지 남아 있습니다.

에러 메시지

Segmentation fault (core dumped)

core dumped: 크래시 시점의 메모리 상태가 core 파일로 저장되었다는 뜻입니다. 이 파일을 GDB/LLDB로 열면 어느 줄에서 크래시났는지, 변수 값이 무엇인지 확인할 수 있습니다.


2. 원인 1: 널(NULL) 포인터 역참조

문제 코드

int* ptr = nullptr;
*ptr = 42;  // ❌ Segmentation fault

원인: nullptr(주소 0)을 역참조하려고 했습니다. 주소 0은 보호된 영역이므로 접근 시 segfault가 발생합니다.

왜 주소 0은 특별한가요?
운영체제는 주소 0 근처(보통 첫 4KB~64KB)의도적으로 비워 둡니다. 이렇게 하면 널 포인터 역참조를 즉시 탐지할 수 있습니다. 만약 주소 0이 유효한 메모리였다면, 널 포인터 버그가 조용히 다른 데이터를 덮어쓰는 더 심각한 문제가 될 수 있습니다. Segfault는 오히려 빨리 실패(Fail Fast)해서 버그를 찾기 쉽게 만들어 줍니다.

실전 예시

std::string* getName() {
    return nullptr;  // 조건에 따라 nullptr 반환
}

int main() {
    std::string* name = getName();
    std::cout << *name << '\n';  // ❌ name이 nullptr이면 크래시
    return 0;
}

해결법

1. 사용 전에 nullptr 체크:

std::string* name = getName();
if (name != nullptr) {  // ✅ 체크
    std::cout << *name << '\n';
}

2. 포인터 대신 참조 또는 optional 사용:

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o optional_safe optional_safe.cpp && ./optional_safe
#include <optional>
#include <string>
#include <iostream>

std::optional<std::string> getName() {
    return std::nullopt;  // 없을 때
}

int main() {
    auto name = getName();
    if (name.has_value()) {
        std::cout << *name << '\n';
    } else {
        std::cout << "(no name)\n";
    }
    return 0;
}

실행 결과: (no name) 이 한 줄 출력됩니다.


3. 원인 2: 댕글링 포인터 (Use-After-Free)

문제 코드

int* createArray() {
    int arr[5] = {1, 2, 3, 4, 5};
    return arr;  // ❌ 지역 배열의 주소 반환
}

int main() {
    int* ptr = createArray();
    std::cout << ptr[0] << '\n';  // ❌ 이미 해제된 메모리 접근
    return 0;
}

원인: arr스택에 할당된 지역 변수입니다. 함수가 리턴하면 스택 프레임이 해제되어, ptr이미 무효화된 주소를 가리킵니다. 이를 댕글링 포인터(dangling pointer)라고 합니다.

스택 메모리의 생명주기:
함수가 호출되면 스택 프레임(Stack Frame)이 생성되고, 지역 변수가 여기에 할당됩니다. 함수가 리턴하면 스택 프레임이 팝(pop)되어 사라집니다. 이때 메모리 자체는 물리적으로 남아 있을 수 있지만, 운영체제가 이 영역을 다른 용도로 재사용할 수 있습니다. 따라서 댕글링 포인터로 접근하면:

  • 운이 좋으면: 아직 덮어쓰이지 않아서 원래 값이 보임 (버그가 숨어 있음)
  • 운이 나쁘면: 다른 함수가 스택을 덮어써서 쓰레기 값이 보임
  • 최악의 경우: 보호된 영역을 건드려서 segfault

왜 위험한가요?
댕글링 포인터 버그는 재현이 어렵습니다. 실행할 때마다 스택 상태가 다르므로, 어떨 때는 정상 작동하고 어떨 때는 크래시가 납니다. 이런 비결정적(Non-deterministic) 버그는 디버깅이 매우 어렵습니다.

실전 예시: delete 후 사용

int* ptr = new int(42);
delete ptr;
std::cout << *ptr << '\n';  // ❌ Use-after-free

해결법

1. 동적 할당 사용:

int* createArray() {
    int* arr = new int[5]{1, 2, 3, 4, 5};
    return arr;  // ✅ 힙 메모리는 명시적으로 delete 전까지 유효
}

int main() {
    int* ptr = createArray();
    std::cout << ptr[0] << '\n';
    delete[] ptr;  // ✅ 사용 후 해제
    return 0;
}

2. 스마트 포인터 사용 (권장):

std::unique_ptr<int[]> createArray() {
    return std::make_unique<int[]>(5);
}

int main() {
    auto ptr = createArray();
    ptr[0] = 1;
    std::cout << ptr[0] << '\n';
    // ✅ 자동 해제
    return 0;
}

3. 컨테이너 사용 (가장 권장):

std::vector<int> createArray() {
    return {1, 2, 3, 4, 5};
}

int main() {
    auto arr = createArray();
    std::cout << arr[0] << '\n';
    return 0;
}

4. 원인 3: 스택 오버플로우

문제 코드

void recursive() {
    recursive();  // ❌ 무한 재귀
}

int main() {
    recursive();  // Segmentation fault
    return 0;
}

원인: 재귀 호출이 끝나지 않아 스택 메모리가 고갈됩니다. 스택 크기를 넘어서면 segfault가 발생합니다.

재귀 호출과 스택:
함수를 호출할 때마다 스택 프레임이 쌓입니다. 각 프레임은 지역 변수, 매개변수, 반환 주소를 저장합니다. 재귀가 깊어질수록 스택이 계속 쌓이고, 결국 스택 한계(Stack Limit)를 넘으면 segfault가 발생합니다.

예시 계산:

  • 함수 하나당 스택 프레임 크기: 약 100바이트
  • 기본 스택 크기: 8MB = 8,388,608바이트
  • 최대 재귀 깊이: 약 80,000번

실제로는 지역 변수 크기에 따라 달라집니다. 지역 변수가 크면 재귀 깊이가 훨씬 줄어듭니다.

실전 예시: 큰 배열을 스택에 할당

int main() {
    int arr[10000000];  // ❌ 스택에 40MB 할당 시도
    return 0;
}

대부분 시스템의 기본 스택 크기는 8MB 정도입니다. 이를 초과하면 segfault가 발생합니다.

해결법

1. 재귀 종료 조건 추가:

void recursive(int depth) {
    if (depth > 1000) return;  // ✅ 종료 조건
    recursive(depth + 1);
}

2. 큰 배열은 힙에 할당:

int main() {
    std::vector<int> arr(10000000);  // ✅ 힙 할당
    return 0;
}

또는:

int main() {
    int* arr = new int[10000000];
    // ...
    delete[] arr;
    return 0;
}

3. 스택 크기 늘리기 (임시 방편):

ulimit -s unlimited  # Linux/macOS

하지만 근본 원인을 고치는 것이 좋습니다.


5. 원인 4: 버퍼 오버런 (배열 범위 초과)

문제 코드

int arr[5] = {1, 2, 3, 4, 5};
arr[10] = 100;  // ❌ 범위 초과

원인: 배열 크기는 5인데 인덱스 10에 접근하려 했습니다. C++는 범위 체크를 하지 않으므로, 다른 메모리 영역을 덮어쓰거나 보호된 영역에 접근해 segfault가 발생할 수 있습니다.

왜 C++는 범위 체크를 안 하나요?
성능 때문입니다. 배열 접근은 매우 빈번한 연산이므로, 매번 범위를 체크하면 5~10% 성능 저하가 발생합니다. C++는 “프로그래머가 알아서 조심하겠지”라는 철학으로, 범위 체크를 하지 않습니다. 대신 vector::at()처럼 명시적으로 체크하는 함수를 제공합니다.

버퍼 오버런의 위험성:
범위를 벗어난 쓰기는 인접한 변수를 덮어쓸 수 있습니다. 이게 보안 취약점으로 이어질 수 있습니다. 예를 들어, 스택에 있는 반환 주소(Return Address)를 덮어쓰면, 공격자가 임의의 코드를 실행할 수 있습니다(Buffer Overflow Attack).

실전 예시: 반복문 실수

std::vector<int> v(5);
for (int i = 0; i <= v.size(); ++i) {  // ❌ <= 때문에 i=5도 접근
    v[i] = i;  // v[5]는 범위 초과
}

해결법

1. 범위 체크하는 at() 사용:

std::vector<int> v(5);
try {
    v.at(10) = 100;  // ✅ 범위 초과 시 예외 발생
} catch (const std::out_of_range& e) {
    std::cerr << "Out of range: " << e.what() << '\n';
}

2. 반복문 조건 확인:

for (int i = 0; i < v.size(); ++i) {  // ✅ < 사용
    v[i] = i;
}

또는 range-based for:

for (auto& x : v) {
    x = 0;
}

3. AddressSanitizer로 검출 (아래 섹션 참고).


6. 원인 5: 잘못된 타입 캐스팅

문제 코드

class Base { virtual void foo() {} };
class Derived : public Base { void bar() {} };

int main() {
    Base* b = new Base();
    Derived* d = static_cast<Derived*>(b);  // ❌ 실제로는 Base 객체
    d->bar();  // Segmentation fault 가능
    delete b;
    return 0;
}

원인: b는 실제로 Base 객체인데, Derived*로 캐스팅했습니다. bar()Derived에만 있으므로, 잘못된 메모리를 접근하게 됩니다.

왜 segfault가 나나요?
Derived 클래스는 Base보다 크기가 큽니다 (추가 멤버 변수·함수가 있음). Base 객체를 Derived*로 캐스팅하면, 컴파일러는 “Derived의 멤버는 이 오프셋에 있겠지”라고 가정하고 메모리를 읽습니다. 하지만 실제로는 Base 객체이므로, 존재하지 않는 메모리 영역을 읽게 됩니다.

메모리 레이아웃 예시:

Base 객체 (8바이트):
[vptr: 8바이트]

Derived 객체 (16바이트):
[vptr: 8바이트][추가 멤버: 8바이트]

Base*Derived*로 캐스팅하면, 컴파일러는 “오프셋 8에 추가 멤버가 있다”고 생각하지만, 실제로는 다른 변수 또는 보호된 영역입니다.

해결법

dynamic_cast 사용 (런타임 타입 체크):

Base* b = new Base();
Derived* d = dynamic_cast<Derived*>(b);
if (d != nullptr) {  // ✅ 캐스팅 실패 시 nullptr
    d->bar();
} else {
    std::cerr << "Not a Derived object\n";
}
delete b;

주의: dynamic_cast가상 함수가 있는 다형성 클래스에서만 작동합니다. 가상 함수가 없으면 컴파일 에러가 납니다.


7. GDB/LLDB로 core dump 분석하기

Core dump 활성화

기본적으로 core 파일 생성이 꺼져 있을 수 있습니다.

ulimit -c unlimited  # 현재 세션에서 core 생성 허용

영구 적용:

# ~/.bashrc 또는 ~/.zshrc에 추가
ulimit -c unlimited

Core 파일 생성 확인

프로그램이 segfault로 종료되면 core 또는 core.pid 파일이 생성됩니다.

ls -lh core*

GDB로 core 분석

gdb ./your_program core

주요 명령:

(gdb) bt              # Backtrace: 호출 스택 확인
(gdb) bt full         # 각 프레임의 지역 변수까지 출력
(gdb) frame 0         # 크래시 프레임으로 이동
(gdb) list            # 해당 소스 코드 출력
(gdb) print ptr       # 변수 값 확인
(gdb) info locals     # 현재 프레임의 모든 지역 변수

LLDB로 core 분석 (macOS)

lldb -c core ./your_program

주요 명령:

(lldb) bt             # Backtrace
(lldb) frame select 0 # 프레임 선택
(lldb) frame variable # 지역 변수 출력
(lldb) p ptr          # 변수 출력

실전 예시

프로그램(test.cpp):

#include <iostream>

void crash() {
    int* ptr = nullptr;
    *ptr = 42;  // ❌ Segmentation fault
}

int main() {
    crash();
    return 0;
}

빌드 (디버그 심볼 포함):

g++ -g test.cpp -o test
ulimit -c unlimited
./test
# Segmentation fault (core dumped)

GDB로 분석:

gdb ./test core
(gdb) bt
#0  0x... in crash() at test.cpp:5
#1  0x... in main() at test.cpp:9

(gdb) frame 0
(gdb) list
3       void crash() {
4           int* ptr = nullptr;
5           *ptr = 42;  // ← 여기서 크래시
6       }

(gdb) print ptr
$1 = (int *) 0x0  # ← nullptr 확인

결론: ptr0x0(nullptr)인 상태에서 역참조했음을 확인했습니다.


8. AddressSanitizer로 사전에 잡기

ASan이란?

AddressSanitizer(ASan)컴파일 타임에 계측 코드를 삽입해서, 실행 중에 메모리 에러를 즉시 탐지하는 도구입니다. Segfault가 나기 전에 정확한 줄 번호와 원인을 알려 줍니다.

사용법

g++ -g -fsanitize=address test.cpp -o test
./test

또는 Clang:

clang++ -g -fsanitize=address test.cpp -o test
./test

예시: 버퍼 오버런 탐지

코드:

int main() {
    int arr[5];
    arr[10] = 100;  // ❌ 범위 초과
    return 0;
}

ASan 출력:

=================================================================
==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc...
WRITE of size 4 at 0x7ffc... thread T0
    #0 0x... in main test.cpp:3
    
Address 0x7ffc... is located in stack of thread T0 at offset 60 in frame
    #0 0x... in main test.cpp:1

  This frame has 1 object(s):
    [32, 52) 'arr' (line 2) <== Memory access at offset 60 overflows this variable

해석: arr[10] 접근이 배열 범위를 벗어났음을 정확한 줄 번호(line 3)와 함께 알려 줍니다.

ASan의 장점:

  • 정확한 줄 번호: GDB보다 훨씬 정확합니다.
  • 스택 트레이스: 어떤 함수 호출 경로로 에러가 발생했는지 보여 줍니다.
  • 메모리 상태: 해당 메모리가 언제 할당·해제되었는지 추적합니다.

성능 오버헤드:
ASan은 2~3배 느려지고, 메모리 사용량도 2~3배 증가합니다. 따라서 디버그 빌드에서만 사용하고, 릴리스 빌드에서는 끄는 것이 일반적입니다. 하지만 개발 단계에서는 항상 켜 두는 것을 강력히 권장합니다. 버그를 조기에 발견할수록 수정 비용이 줄어듭니다.

CMake에서 ASan 활성화

add_executable(myapp main.cpp)
target_compile_options(myapp PRIVATE -fsanitize=address -g)
target_link_options(myapp PRIVATE -fsanitize=address)

원인별 빠른 체크리스트

원인증상확인 방법해결법
널 포인터ptr = nullptr*ptrGDB에서 print ptr0x0사용 전 nullptr 체크
댕글링 포인터delete 후 사용, 지역 변수 주소 반환ASan: use-after-free스마트 포인터, 컨테이너 사용
스택 오버플로우무한 재귀, 큰 배열 스택 할당bt에서 같은 함수 반복재귀 종료 조건, 힙 할당
버퍼 오버런배열 범위 초과 (arr[10])ASan: stack/heap-buffer-overflowat() 사용, 반복문 조건 확인
잘못된 캐스팅static_cast로 다운캐스트dynamic_cast 실패dynamic_cast + nullptr 체크

실전 디버깅 프로세스

1단계: Core dump 확보

  • ulimit -c unlimited 설정
  • 프로그램 재실행해서 core 파일 생성

2단계: GDB/LLDB로 분석

  • gdb ./program core 실행
  • bt 명령으로 호출 스택 확인
  • 크래시 프레임에서 print 명령으로 변수 값 확인

3단계: 원인 파악

  • 포인터가 0x0이면 → 널 포인터
  • 이상한 주소(0xdeadbeef 등)면 → 댕글링 포인터
  • 같은 함수가 스택에 반복되면 → 무한 재귀

4단계: ASan으로 재현

  • -fsanitize=address로 재빌드
  • 실행하면 정확한 줄 번호와 원인 출력

5단계: 수정 후 재테스트

  • 원인을 고친 뒤, ASan으로 다시 실행해서 에러가 사라졌는지 확인

한 줄 요약: Segfault는 널·댕글링·범위 초과 등 잘못된 메모리 접근에서 나오므로, core dump·gdb·ASan으로 원인을 좁히면 됩니다. 다음으로 LNK2019 해결이나 C++ 메모리 기초를 읽어보면 좋습니다.


자주 묻는 질문 (FAQ)

Q. Segmentation fault와 Bus error의 차이는?

A: 둘 다 메모리 접근 에러지만:

  • Segmentation fault: 잘못된 주소 접근 (널 포인터, 범위 초과)
  • Bus error: 정렬되지 않은(Unaligned) 메모리 접근 (예: 4바이트 정렬이 필요한데 홀수 주소 접근)

Bus error는 ARM 같은 일부 아키텍처에서 발생하고, x86/x64에서는 드뭅니다.

Q. Core dump 파일이 너무 큽니다. 줄일 수 있나요?

A: ulimit -c 10000 (10MB 제한)으로 크기를 제한할 수 있습니다. 또는 /proc/sys/kernel/core_pattern에서 압축 옵션을 설정할 수 있습니다.

Q. ASan을 켰는데 에러가 안 나옵니다.

A: 에러가 발생하는 코드 경로가 실행되지 않았을 수 있습니다. 모든 분기를 테스트하세요. 또한 ASan은 컴파일 타임 에러는 못 잡습니다. 런타임에 실제로 실행되어야 탐지합니다.

Q. Valgrind vs AddressSanitizer, 뭘 쓰나요?

A:

  • ASan: 빠름 (2~3배 느림), 정확한 줄 번호, 컴파일 필요
  • Valgrind: 느림 (10~50배), 재컴파일 불필요, 더 많은 에러 탐지

개발 중에는 ASan, 최종 테스트에는 Valgrind를 권장합니다.


관련 글

  • C++ 디버거 GDB/LLDB 사용법: GDB 명령어 상세 가이드
  • C++ AddressSanitizer(ASan): ASan·TSan 활용법
  • C++ 스마트 포인터: 댕글링 포인터 방지
  • C++ 메모리 누수 디버깅: Valgrind 사용법

Segmentation fault는 무서워 보이지만, core dump + GDB + ASan 조합으로 체계적으로 접근하면 원인을 찾을 수 있습니다. 특히 ASan은 실행만 하면 정확한 줄 번호를 알려 주므로, 개발 단계에서 항상 켜 두는 것을 권장합니다. 프로덕션 빌드에서는 성능 오버헤드 때문에 끄지만, 디버그 빌드에서는 필수입니다.

검색 시 참고 키워드: Segmentation fault, segfault, C++ 널 포인터, 댕글링 포인터, GDB core dump, AddressSanitizer, use-after-free


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ GDB/LLDB | cout 100개 찍어도 못 찾은 버그, 디버거로 5분 만에 해결
  • C++ Sanitizers | ASan·TSan으로 메모리 버그·data race 자동 탐지
  • VS Code C++ 설정 | IntelliSense·빌드·디버깅