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
목차
- Segmentation fault란?
- 원인 1: 널(NULL) 포인터 역참조
- 원인 2: 댕글링 포인터 (Use-After-Free)
- 원인 3: 스택 오버플로우
- 원인 4: 버퍼 오버런 (배열 범위 초과)
- 원인 5: 잘못된 타입 캐스팅
- GDB/LLDB로 core dump 분석하기
- 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 확인
결론: ptr이 0x0(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 후 *ptr | GDB에서 print ptr → 0x0 | 사용 전 nullptr 체크 |
| 댕글링 포인터 | delete 후 사용, 지역 변수 주소 반환 | ASan: use-after-free | 스마트 포인터, 컨테이너 사용 |
| 스택 오버플로우 | 무한 재귀, 큰 배열 스택 할당 | bt에서 같은 함수 반복 | 재귀 종료 조건, 힙 할당 |
| 버퍼 오버런 | 배열 범위 초과 (arr[10]) | ASan: stack/heap-buffer-overflow | at() 사용, 반복문 조건 확인 |
| 잘못된 캐스팅 | 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·빌드·디버깅