C++ Segmentation fault 원인 5가지와 디버깅 방법 | GDB로 추적하기
이 글의 핵심
Segmentation fault는 메시지 한 줄만 남기고 프로그램이 죽기 때문에 로그만으로는 원인을 찾기 어렵습니다. 운영체제의 메모리 보호가 어떻게 이 신호를 만드는지 이해하고, core 파일에서 backtrace를 읽어 문제 줄을 찾는 순서, 원인별로 스마트 포인터나 경계 검사로 재발을 막는 방법을 익힐 수 있습니다.
들어가며: “Segmentation fault (core dumped)“가 뜨면
Segmentation fault는 프로그램이 접근 권한이 없는 메모리를 읽거나 쓰려 할 때 운영체제가 프로세스를 강제 종료하면서 남기는 메시지입니다. 컴파일은 성공했는데 실행 중에 죽고, 메시지에는 어디서 죽었는지 정보가 없어서 처음 만나면 막막합니다.
이 글은 대표 원인 다섯 가지(널 포인터, 댕글링 포인터, 스택 오버플로, 버퍼 오버런, 잘못된 캐스팅)를 코드로 재현하고, core dump와 GDB, AddressSanitizer(ASan)로 원인을 좁히는 방법을 설명합니다. 예제는 Linux나 macOS에서 g++/Clang으로 빌드하는 것을 기준으로 합니다. Windows에서는 WSL에서 같은 명령을 쓰거나, Visual Studio 디버거와 MSVC ASan(/fsanitize=address)으로 대응할 수 있습니다. core 설정과 운영 환경에서 core를 수집하는 절차는 Segmentation fault 디버깅 글에서 따로 다룹니다.
Segmentation fault란?
그림: 실제 실행 결과 — 그냥 실행하면 한 줄로 죽지만, AddressSanitizer를 켜면 몇 번째 줄에서 어떤 주소를 읽다 죽었는지 바로 나온다. (환경: Ubuntu 24.04 · g++ 13.3)
메모리 보호 위반
운영체제는 각 프로세스에 독립된 가상 주소 공간을 주고, 그중 실제로 매핑된 영역과 각 영역의 권한(읽기/쓰기/실행)을 관리합니다. 프로그램이 다음과 같은 주소에 접근하면 커널이 SIGSEGV 시그널을 보내고, 시그널을 처리하지 않은 프로세스는 종료됩니다.
- 매핑되지 않은 주소(널 포인터가 가리키는 0번지 근처 포함)
- 이미 해제되어 운영체제에 반납된 메모리
- 읽기 전용 영역에 대한 쓰기(문자열 리터럴 수정 등)
- 스택 한계를 넘어선 영역
접근 검사는 하드웨어가 합니다. CPU의 MMU가 가상 주소를 물리 주소로 변환하다가 매핑이 없거나 권한이 맞지 않으면 페이지 폴트 예외를 일으키고, 커널이 그 주소가 정말 잘못된 접근인지 판단한 뒤 SIGSEGV를 보냅니다. 페이지 폴트 자체는 평범한 사건이라(처음 건드리는 힙 페이지, 스왑된 페이지 등) 대부분은 커널이 조용히 처리하고, 정당한 이유가 없는 접근만 시그널로 이어집니다.
“Segmentation”이라는 이름은 메모리를 세그먼트 단위로 나눠 보호하던 옛 하드웨어에서 온 말입니다. 오늘날 Linux와 macOS는 사실상 페이지 단위로 보호하지만, 보호 위반을 가리키는 이름은 그대로 남았습니다.
에러 메시지
Segmentation fault (core dumped)
“core dumped”는 크래시 시점의 메모리 상태가 core 파일로 저장되었다는 뜻입니다. 이 파일을 GDB나 LLDB로 열면 어느 줄에서 죽었는지, 그때 변수 값이 무엇이었는지 확인할 수 있습니다. 다만 core 생성이 꺼져 있으면 “(core dumped)” 없이 “Segmentation fault”만 출력됩니다.
원인 1: 널(NULL) 포인터 역참조
문제 코드
int* ptr = nullptr;
*ptr = 42; // ❌ Segmentation fault
nullptr가 가리키는 0번지를 역참조했습니다. 운영체제는 주소 0 근처를 일부러 매핑하지 않고 비워 둡니다. Linux에서는 vm.mmap_min_addr(보통 65536) 아래 영역에 프로그램이 매핑을 만들 수 없습니다. 그래서 널 포인터 역참조는 조용히 다른 데이터를 덮어쓰는 대신 그 자리에서 바로 죽고, 이 덕분에 오히려 원인을 찾기 쉽습니다.
단, 널 포인터에서 아주 큰 오프셋만큼 떨어진 멤버나 배열 원소에 접근하면 이 보호 영역을 벗어나 매핑된 메모리에 닿을 수도 있습니다. 또 널 역참조는 정의되지 않은 동작이라, 최적화 컴파일러는 “이 포인터는 널이 아니다”라고 가정하고 뒤쪽의 널 검사를 제거하기도 합니다. 크래시 위치가 생각보다 뒤에서 나타나는 이유입니다.
실전 예시
#include <iostream>
#include <string>
std::string* getName() {
return nullptr; // 조건에 따라 nullptr 반환
}
int main() {
std::string* name = getName();
std::cout << *name << '\n'; // ❌ name이 nullptr이면 크래시
return 0;
}
해결법
가장 직접적인 방법은 사용 전에 검사하는 것입니다.
std::string* name = getName();
if (name != nullptr) {
std::cout << *name << '\n';
}
더 나은 방법은 “값이 없을 수 있음”을 타입으로 드러내는 것입니다. 반환값이 없을 수 있다면 포인터 대신 std::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) 한 줄이 출력됩니다.
원인 2: 댕글링 포인터 (Use-After-Free)
문제 코드
#include <iostream>
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은 createArray의 스택 프레임에 있는 지역 변수입니다. 함수가 반환하면 그 프레임은 더 이상 유효하지 않고, ptr은 수명이 끝난 객체를 가리키는 댕글링 포인터가 됩니다.
스택 메모리는 반환 후에도 프로세스에 매핑된 채로 남아 있고, 다음 함수 호출이 그 자리를 다시 씁니다. 그래서 이 코드는 대개 바로 죽지 않습니다. 아직 덮어쓰이지 않았으면 원래 값이 보이고, 그 사이 다른 함수가 호출되었으면 쓰레기 값이 보입니다. 실행 환경이나 최적화 수준에 따라 결과가 달라지므로 재현이 어려운 버그가 됩니다. 참고로 이 패턴은 컴파일러가 -Wreturn-local-addr(GCC) 같은 경고를 내고, GCC는 최적화 시 이런 함수가 아예 널 포인터를 반환하도록 코드를 만들기도 해서, 그 경우에는 원인 1과 같은 모습으로 죽습니다. 경고를 무시하지 않는 것이 가장 싼 예방책입니다.
실전 예시: delete 후 사용
int* ptr = new int(42);
delete ptr;
std::cout << *ptr << '\n'; // ❌ Use-after-free
힙에서 해제된 작은 블록은 대부분 할당자의 해제 목록으로 돌아갈 뿐 운영체제에 반납되지 않으므로, 이 접근도 보통은 segfault 없이 이상한 값을 읽거나 다른 객체를 망가뜨립니다. 진짜 크래시는 한참 뒤 엉뚱한 곳에서 납니다. 이런 버그는 core dump보다 ASan이 훨씬 잘 잡습니다.
해결법
지역 배열을 꼭 포인터로 넘겨야 한다면 힙에 할당할 수는 있습니다.
#include <iostream>
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;
}
하지만 이렇게 하면 누가 언제 해제하는지가 코드 밖의 약속으로 남습니다. 소유권을 타입으로 표현하는 스마트 포인터가 낫습니다.
#include <iostream>
#include <memory>
std::unique_ptr<int[]> createArray() {
return std::make_unique<int[]>(5); // 0으로 초기화된 int 5개
}
int main() {
auto ptr = createArray();
ptr[0] = 1;
std::cout << ptr[0] << '\n';
return 0; // 자동 해제
}
배열이라면 대부분의 경우 std::vector를 값으로 반환하는 것이 가장 단순합니다. 반환값 최적화와 이동 덕분에 복사 비용도 걱정할 필요가 없습니다.
#include <iostream>
#include <vector>
std::vector<int> createArray() {
return {1, 2, 3, 4, 5};
}
int main() {
auto arr = createArray();
std::cout << arr[0] << '\n';
return 0;
}
원인 3: 스택 오버플로
문제 코드
int depth(int n) {
return depth(n + 1) + 1; // ❌ 종료 조건 없는 재귀
}
int main() {
return depth(0); // Segmentation fault
}
함수를 호출할 때마다 매개변수, 반환 주소, 지역 변수를 담은 스택 프레임이 쌓입니다. 재귀가 끝나지 않으면 스택이 계속 자라다가 한계(Linux 기본 8MB, ulimit -s로 확인)를 넘고, 스택 아래쪽의 가드 페이지에 닿는 순간 SIGSEGV가 납니다. 한 프레임이 100바이트라면 8MB 스택에서 대략 8만 단계 정도 들어가며, 지역 배열이 큰 함수일수록 훨씬 빨리 넘칩니다.
재귀 호출 뒤에 할 일이 없는 꼬리 재귀라면 최적화(-O2)에서 컴파일러가 반복문으로 바꿔 버려, 같은 코드가 크래시 대신 무한 루프가 되기도 합니다. 디버그 빌드에서만 죽는 재귀 버그는 이런 경우가 많습니다. 위 예제는 재귀 호출 결과에 + 1을 하므로 꼬리 재귀가 아닙니다.
실전 예시: 큰 배열을 스택에 할당
int main() {
int arr[10000000]; // ❌ 스택에 약 40MB 할당 시도
arr[0] = 1;
return arr[0];
}
int 1000만 개는 약 40MB라서 기본 8MB 스택에 들어가지 않습니다. 재귀가 없어도 지역 변수 하나로 스택 오버플로가 납니다. 스레드의 스택은 보통 메인 스레드보다 작게 잡히므로, 메인에서는 괜찮던 코드가 작업 스레드로 옮기면 죽는 경우도 있습니다.
해결법
재귀에는 종료 조건을 두고, 깊이가 입력 크기에 비례해 커질 수 있는 재귀(긴 연결 리스트 순회, 치우친 트리 탐색 등)는 명시적 스택을 쓰는 반복문으로 바꿉니다.
int depth(int n) {
if (n >= 1000) return 0; // 종료 조건
return depth(n + 1) + 1;
}
큰 배열은 힙에 둡니다.
#include <vector>
int main() {
std::vector<int> arr(10000000); // 힙 할당
return 0;
}
스택 한계를 늘리는 방법도 있지만 임시방편입니다. Linux에서는 ulimit -s로 셸의 한계를 바꿀 수 있고, macOS는 하드 한계가 64MB 정도로 묶여 있어 unlimited를 설정할 수 없습니다. 스레드 스택은 ulimit이 아니라 pthread_attr_setstacksize 같은 API로 정해야 합니다. 근본적으로는 데이터 크기에 비례하는 메모리를 스택에 두지 않는 쪽으로 고치는 것이 맞습니다.
원인 4: 버퍼 오버런 (배열 범위 초과)
문제 코드
int arr[5] = {1, 2, 3, 4, 5};
arr[10] = 100; // ❌ 범위 초과
배열 크기는 5인데 인덱스 10에 썼습니다. C++의 내장 배열과 vector::operator[]는 범위를 검사하지 않으므로, 이 쓰기는 배열 뒤에 있는 다른 변수를 덮어씁니다. 그 자리가 매핑되지 않은 페이지일 때만 segfault가 나고, 대부분은 조용히 데이터를 망가뜨립니다.
범위 검사를 기본으로 하지 않는 것은 C++가 “쓰지 않는 기능에는 비용을 내지 않는다”는 원칙을 따르기 때문입니다. 반복문 안의 인덱스 접근마다 비교와 분기가 들어가면 벡터화 같은 최적화도 방해받습니다. 대신 검사하는 vector::at()을 따로 제공하고, 표준 라이브러리 구현들은 디버그 모드(-D_GLIBCXX_DEBUG, libc++ hardening 모드, MSVC 디버그 빌드)에서 operator[]도 검사하도록 바꿀 수 있습니다.
스택에서 범위를 넘는 쓰기는 보안 문제로도 이어집니다. 지역 버퍼 뒤에 있는 반환 주소를 덮어쓰면 함수가 반환할 때 공격자가 원하는 주소로 점프하게 만들 수 있고, 이것이 고전적인 스택 버퍼 오버플로 공격입니다. 요즘 컴파일러의 스택 보호(-fstack-protector)는 이런 덮어쓰기를 감지하면 *** stack smashing detected ***를 출력하고 SIGABRT로 프로그램을 멈춥니다.
실전 예시: 반복문 실수
std::vector<int> v(5);
for (size_t i = 0; i <= v.size(); ++i) { // ❌ <= 때문에 i=5도 접근
v[i] = static_cast<int>(i);
}
해결법
범위를 검사하는 at()은 위반 시 std::out_of_range 예외를 던집니다.
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';
}
인덱스가 필요 없다면 range-based for를 쓰면 경계 실수가 원천적으로 사라집니다.
for (auto& x : v) {
x = 0;
}
인덱스를 쓸 때는 비교 조건을 <로 두고, 부호 있는 int와 size()를 비교해 경고가 나는 코드는 size_t로 맞춥니다. 실행 중 검출은 아래 ASan 절에서 다룹니다.
원인 5: 잘못된 타입 캐스팅
문제 코드
#include <iostream>
#include <string>
struct Base {
virtual ~Base() = default;
};
struct Derived : Base {
std::string name = "derived";
void print() const { std::cout << name << '\n'; }
};
int main() {
Base* b = new Base();
Derived* d = static_cast<Derived*>(b); // ❌ 실제로는 Base 객체
d->print(); // 정의되지 않은 동작: 존재하지 않는 name 멤버를 읽음
delete b;
return 0;
}
static_cast는 실행 중에 실제 타입을 확인하지 않습니다. b가 가리키는 것은 Base 객체인데 Derived*로 바꿨으니, d->print()는 Base 객체 뒤에 있다고 가정한 name 멤버를 읽습니다.
64비트 환경에서 Base는 vptr 하나(8바이트)뿐이고, Derived는 그 뒤에 std::string(구현에 따라 24~32바이트)을 가집니다. 캐스팅된 포인터로 name에 접근하면 Base 객체 끝 너머의 힙 메모리를 문자열 객체로 해석하게 되고, 그 안의 쓰레기 값을 포인터로 따라가다 segfault가 나거나 이상한 출력이 나옵니다. 멤버 함수 자체는 객체 크기에 포함되지 않으므로, 멤버 변수를 건드리지 않는 비가상 함수라면 잘못 캐스팅해도 겉보기에 멀쩡히 동작할 수 있습니다. 그래서 이 버그는 오래 숨어 있다가 멤버가 추가될 때 드러나곤 합니다.
해결법
실제 타입이 확실하지 않은 다운캐스트에는 dynamic_cast를 씁니다. 포인터 버전은 실패하면 nullptr를 반환합니다.
Base* b = new Base();
if (auto* d = dynamic_cast<Derived*>(b)) {
d->print();
} else {
std::cerr << "Not a Derived object\n";
}
delete b;
dynamic_cast는 가상 함수가 하나 이상 있는 다형성 클래스에서만 쓸 수 있고, 아니면 컴파일 에러가 납니다. 다운캐스트가 자주 필요하다면 가상 함수로 동작을 옮기는 설계를 먼저 검토하는 것이 좋습니다. Clang의 -fsanitize=vptr(UBSan의 일부)를 켜면 잘못된 static_cast 다운캐스트도 실행 중에 보고받을 수 있습니다.
GDB/LLDB로 core dump 분석하기
코드만 보고 원인을 알기 어렵다면 크래시 순간의 상태를 봐야 합니다. g++ -g로 디버그 심볼을 넣어 빌드하고, ulimit -c unlimited로 core 생성을 허용한 뒤 다시 실행해 core 파일을 얻습니다. gdb ./your_program core로 열어 bt로 호출 스택을 보고, 크래시 프레임에서 print로 관련 포인터 값을 확인하면 원인 후보가 좁혀집니다.
(gdb) bt
#0 0x... in crash() at test.cpp:5
#1 0x... in main() at test.cpp:9
(gdb) print ptr
$1 = (int *) 0x0
포인터가 0x0이거나 아주 작은 값이면 널 포인터(원인 1)입니다. bt 출력에 같은 함수가 수천 번 반복되면 스택 오버플로(원인 3)입니다. 포인터 값은 그럴듯한데 가리키는 내용이 이상하다면 댕글링 포인터나 버퍼 오버런으로 메모리가 망가진 경우일 수 있지만, core만으로는 “이미 해제된 메모리였는지”를 판단하기 어렵습니다. MSVC 디버그 힙은 해제된 메모리를 0xDD나 0xFEEEFEEE 같은 패턴으로 채우므로 그런 값이 보이면 해제 후 사용을 의심할 수 있지만, glibc는 이런 패턴을 쓰지 않습니다. 이때는 ASan으로 다시 재현하는 편이 빠릅니다.
core가 생성되지 않는 경우(systemd-coredump가 가로채거나 core_pattern이 다른 곳을 가리키는 경우), macOS의 LLDB 명령, 운영 서버에서 core를 모으고 디버그 심볼을 따로 보관하는 방법은 Segmentation fault 디버깅: core dump·GDB·LLDB에서 단계별로 다룹니다.
Ubuntu 24.04 컨테이너(aarch64, g++ 13.3, GDB 15.1, Valgrind 3.22.0)에서 위 설명을 직접 확인했습니다. nullptr을 역참조하는 함수를 -g로 빌드해 ulimit -c unlimited로 실행하면 Segmentation fault (core dumped)와 함께 core 파일이 생겼고, gdb -batch -ex bt ./n core는 Program terminated with signal SIGSEGV와 #0 deref (p=0x0) at null.cpp:2, #1 main () at null.cpp:3을 보여 주었습니다. 이 컨테이너의 core_pattern은 그냥 core라서 현재 디렉터리에 파일이 만들어졌습니다. 도구 비교도 글대로였습니다. 지역 배열 a[4]를 a[argc+5]로 쓰는 코드에서 ASan은 stack-buffer-overflow를 보고했지만 Valgrind는 0 errors였습니다. 반대로 초기화하지 않은 int x로 분기하는 코드에서는 ASan이 아무 말 없이 small을 출력했고 Valgrind는 Conditional jump or move depends on uninitialised value(s)를 보고했습니다.
AddressSanitizer로 사전에 잡기
ASan이란?
AddressSanitizer는 컴파일할 때 모든 메모리 접근 앞에 검사 코드를 넣고, 할당된 영역 주변에 접근 금지 구역(redzone)을 두어 실행 중 잘못된 접근을 그 자리에서 보고하는 도구입니다. segfault는 잘못된 접근이 우연히 매핑되지 않은 주소에 닿았을 때만 나지만, ASan은 범위를 몇 바이트만 넘어도, 해제된 블록을 읽기만 해도 즉시 멈추고 어떤 접근이 어떤 객체를 넘었는지 알려 줍니다.
사용법
g++ -g -fsanitize=address -fno-omit-frame-pointer test.cpp -o test
./test
Clang도 같은 옵션을 씁니다.
clang++ -g -fsanitize=address -fno-omit-frame-pointer 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 72 in frame
#0 0x... in main test.cpp:1
This frame has 1 object(s):
[32, 52) 'arr' (line 2) <== Memory access at offset 72 overflows this variable
arr은 프레임의 32~52 오프셋(int 5개, 20바이트)에 있고, arr[10]은 32 + 40 = 72 오프셋이라 범위를 넘었다는 내용입니다. 어느 줄의 어떤 쓰기가 어느 변수를 넘었는지가 한 번에 나오고, 힙 버그라면 해당 블록이 어디서 할당되고 어디서 해제되었는지의 스택 트레이스까지 함께 출력됩니다. GDB가 “어디서 죽었는가”를 보여 준다면, ASan은 “어디서 처음 잘못되었는가”를 보여 준다는 점이 다릅니다.
성능 오버헤드
ASan 문서 기준으로 실행 속도는 대략 2배 느려지고 메모리는 더 많이 씁니다. 그래서 릴리스 빌드에서는 끄고, 디버그 빌드와 CI 테스트에서 켜 두는 것이 일반적입니다. 메모리 버그는 발견이 늦을수록 증상이 원인에서 멀어지므로, 개발 중 상시로 켜 두는 쪽이 결국 시간을 아낍니다.
CMake에서 ASan 활성화
add_executable(myapp main.cpp)
target_compile_options(myapp PRIVATE -fsanitize=address -fno-omit-frame-pointer -g)
target_link_options(myapp PRIVATE -fsanitize=address)
그 밖에 자주 만나는 원인과 헷갈리는 증상
위 다섯 가지만큼 흔하지는 않지만 코드 리뷰에서 자주 보이는 원인이 몇 가지 더 있습니다. 공통점은 항상 바로 죽지는 않는다는 것이라, 증상만 보고 원인을 추측하기 어렵습니다.
초기화하지 않은 포인터부터 보겠습니다. int* ptr; *ptr = 42;에서 ptr에는 스택에 남아 있던 아무 값이나 들어 있습니다. 그 값이 우연히 매핑되지 않은 주소면 segfault가 나고, 매핑된 주소면 다른 변수를 조용히 덮어쓰고 한참 뒤 엉뚱한 곳에서 죽습니다. 디버그 빌드에서는 멀쩡하다가 릴리스 빌드에서만 죽는 전형적인 원인입니다. 선언과 동시에 초기화하는 습관(int* ptr = nullptr;)이 가장 확실하고, -Wall의 -Wuninitialized/-Wmaybe-uninitialized가 상당수를 잡아 줍니다. ASan은 초기화되지 않은 값 읽기를 탐지하지 못하므로, 이 경우는 Valgrind memcheck나 Clang의 MemorySanitizer(-fsanitize=memory)가 필요합니다.
이중 해제도 있습니다. 같은 포인터를 두 번 delete하면 segfault보다 free(): double free detected 메시지와 함께 SIGABRT(Aborted)로 끝나는 경우가 많습니다. glibc 할당자가 해제 목록을 검사하다가 스스로 프로그램을 멈추기 때문입니다. 검사를 통과하면 힙 메타데이터가 망가져 나중의 new/malloc에서 죽기도 합니다. “Aborted (core dumped)“도 메모리 버그일 수 있다는 점을 기억해 두는 것이 좋습니다. 소유권을 unique_ptr 하나에 두면 구조적으로 사라지는 버그입니다.
문자열 리터럴 수정도 흔한 원인입니다. 문자열 리터럴은 읽기 전용 영역(.rodata)에 놓이므로 쓰려고 하면 segfault가 납니다. C++11부터 char* s = "Hello";는 표준상 잘못된 코드지만, GCC와 Clang은 호환성을 위해 경고만 내고 컴파일해 주므로 경고를 무시하면 그대로 남습니다. C 라이브러리 경계에서 const_cast<char*>로 const를 벗겨 넘긴 버퍼를 라이브러리가 수정하는 경우도 똑같이 터집니다. 수정할 문자열은 char s[] = "Hello";나 std::string으로 복사해서 넘깁니다.
마지막으로 같은 종류의 오류를 플랫폼마다 다르게 부릅니다. Linux는 Segmentation fault(SIGSEGV), Windows는 Access violation(예외 코드 0xC0000005), macOS는 EXC_BAD_ACCESS로 보고합니다. 크래시 리포트나 이슈를 검색할 때 세 이름을 함께 넣으면 결과가 훨씬 많이 나옵니다.
원인별 확인 방법
| 원인 | 증상 | 확인 방법 | 해결법 |
|---|---|---|---|
| 널 포인터 | nullptr를 그대로 역참조 | GDB에서 print ptr → 0x0 | 사용 전 검사, std::optional |
| 댕글링 포인터 | delete 후 사용, 지역 변수 주소 반환 | ASan: heap-use-after-free, stack-use-after-return | 스마트 포인터, 컨테이너 사용 |
| 스택 오버플로 | 무한 재귀, 큰 배열 스택 할당 | bt에서 같은 함수 반복 | 재귀 종료 조건, 힙 할당 |
| 버퍼 오버런 | 배열 범위 초과 | ASan: stack/heap-buffer-overflow | at(), 반복문 조건 확인 |
| 잘못된 캐스팅 | static_cast로 잘못된 다운캐스트 | UBSan -fsanitize=vptr | dynamic_cast + nullptr 검사 |
| 미초기화 포인터 | 빌드 설정에 따라 증상이 바뀜 | -Wall 경고, Valgrind, MSan | 선언 시 nullptr로 초기화 |
| 이중 해제 | double free detected + Aborted | ASan: attempting double-free | unique_ptr로 소유권 단일화 |
순서로 정리하면, 먼저 core를 확보해 bt로 크래시 위치를 보고, 널이나 무한 재귀처럼 core만으로 분명한 경우는 바로 고칩니다. 원인이 분명하지 않으면 ASan을 켜고 다시 재현해 처음 잘못된 접근을 찾고, 고친 뒤에도 ASan을 켠 채로 같은 시나리오를 다시 돌려 오류가 사라졌는지 확인합니다. 다음으로는 LNK2019 해결이나 C++ 메모리 기초를 읽어 보면 좋습니다.