printf 디버깅에서 벗어나기: GDB·LLDB 브레이크포인트, 워치포인트, 단계 실행 기초

💡 초보자를 위한 한 줄: 브레이크포인트로 멈춘 뒤 next/step, print/bt만 익혀도 cout 백 줄보다 빨리 좁힐 수 있습니다. watch는 “이 값이 왜 바뀌지?”에 강합니다. -g 없이 최적화만 켠 바이너리는 심볼이 빠져 답답해질 수 있습니다. 15-3 컴파일 타임 최적화 다음 흐름에 맞습니다.

들어가며: printf 디버깅의 한계

세그폴트가 발생하는 버그를 찾고 있었습니다. std::cout을 수십 개 추가했지만 원인을 찾지 못했습니다. 출력이 버퍼링되어 정확한 크래시 위치를 알 수 없었으며, 재컴파일·재실행을 반복하는 데 시간이 많이 들었습니다. 디버거는 “어느 줄에서 멈췄는지, 그때 변수 값과 스택이 어떤지”를 멈춘 상태에서 직접 볼 수 있게 해 줍니다. 브레이크포인트(실행을 멈출 지점)·워치포인트(특정 변수 변경 시 자동 중단)·조건부 중단·백트레이스(호출 스택)만 익혀도 printf 디버깅보다 훨씬 빠르게 버그 위치를 좁힐 수 있습니다. 비유하면: printf 디버깅은 “사진 한 장씩 찍어서 사고 현장을 추측하는 것”이고, 디버거는 “시간을 멈추고 그 순간의 모든 상태를 직접 들여다보는 것”입니다.

printf 디버깅이 크래시 앞에서 특히 약한 이유는 출력 버퍼링입니다. std::cout은 버퍼에 모아 두었다가 내보내므로, 프로세스가 SIGSEGV로 죽으면 버퍼에 남아 있던 마지막 몇 줄이 출력되지 않습니다. 그래서 “3”까지 찍힌 것처럼 보여도 실제로는 훨씬 뒤에서 죽었을 수 있습니다. std::cerr는 버퍼링되지 않으니 임시 추적용으로는 그쪽이 낫지만, 매번 코드를 고치고 다시 빌드해야 한다는 근본 문제는 그대로입니다.

void processData(int* arr, int size) {
    std::cout << "1\n";
    for (int i = 0; i < size; ++i) {
        std::cout << "2: i=" << i << "\n";
        arr[i] *= 2;
        std::cout << "3\n";
    }
    std::cout << "4\n";
}
// 어디선가 크래시... 하지만 출력이 버퍼링되어 정확한 위치를 모름

같은 문제를 디버거로 보면, 크래시 순간에 멈춘 상태에서 스택과 변수를 바로 읽을 수 있습니다.

$ gdb ./myapp
(gdb) run
Program received signal SIGSEGV
(gdb) backtrace
#0  processData() at main.cpp:15
#1  main() at main.cpp:30
(gdb) print i
$1 = 100
(gdb) print size   # size가 실제 배열 크기보다 크게 전달됨 → 호출부를 확인
flowchart TB
    subgraph printf[printf/cout 디버깅]
        P1[코드 수정] --> P2[재컴파일]
        P2 --> P3[실행]
        P3 --> P4[출력 확인]
        P4 --> P5{버그 발견?}
        P5 -->|아니오| P1
        P5 -->|예| P6[추가한 출력 제거]
    end
    subgraph debugger[디버거 사용]
        D1[브레이크포인트 설정] --> D2[실행]
        D2 --> D3[멈춘 시점에서 변수/스택 확인]
        D3 --> D4{버그 발견?}
        D4 -->|아니오| D5[조건 변경 후 재실행]
        D5 --> D2
        D4 -->|예| D6[즉시 수정]
    end

요구 환경: GDB(Linux/WSL: apt install gdb) 또는 LLDB(macOS: xcode-select --install). 빌드 시 -g 옵션 필수. Linux·WSL에서는 GDB, macOS에서는 LLDB를 쓰는 경우가 많고 명령 흐름(멈추고 → 한 줄씩 → 스택 보기)은 같습니다. 이 글의 예시는 GDB 기준이며 LLDB 대응 명령을 함께 적었습니다. 이 글을 읽으면:

  • GDB/LLDB의 핵심 명령어를 실전에서 사용할 수 있습니다.
  • 브레이크포인트·워치포인트를 상황에 맞게 설정할 수 있습니다.
  • 변수·메모리·스택을 검사하고 원인을 추적할 수 있습니다.
  • 프로덕션 환경에서의 디버깅 패턴을 적용할 수 있습니다.

세그폴트, 특정 반복에서만 나는 버그, 데드락: 디버거가 필요한 순간

시나리오 1: “세그폴트가 나는데 어디서 터지는지 모르겠어요”

"프로그램이 Segmentation fault로 죽습니다. 로그에는 아무것도 안 나와요."
"어느 함수에서 문제인지 전혀 감이 안 잡혀요."

상황: 배열 인덱스 범위 초과, null 포인터 역참조 등으로 크래시합니다. printf를 여러 곳에 넣었지만 출력이 버퍼링되거나, 크래시 직전 로그가 안 찍혀서 위치를 좁히지 못합니다. 해결 포인트: GDB/LLDB로 실행 → 크래시 시 backtrace로 호출 스택 확인 → frame N으로 해당 프레임 이동 → print 변수로 원인 파악.

시나리오 2: “1000번 루프 중 500번째에서만 버그가 나요”

"대부분은 정상인데, 가끔 잘못된 결과가 나와요."
"조건을 재현하기 어려워서 printf로 찾기 힘듭니다."

상황: 특정 반복 횟수, 특정 입력에서만 버그가 발생합니다. 매번 1000번 출력하는 것은 비효율적이며, 조건부 printf는 코드를 지저분하게 만듭니다. 해결 포인트: 조건부 브레이크포인트 (break main.cpp:20 if i == 500)로 해당 조건에서만 중단.

시나리오 3: “변수 값이 어디선가 바뀌는데 찾을 수 없어요”

"초기화한 변수가 나중에 이상한 값으로 바뀌어 있습니다."
"어디서 덮어쓰는지 전혀 모르겠습니다."

상황: 메모리 오염, 버퍼 오버런, 잘못된 포인터로 인해 변수 값이 예기치 않게 변경됩니다. 수백 개의 함수 중 어디서 변경되는지 추적하기 어렵습니다. 해결 포인트: 워치포인트 (watch variable_name)로 해당 변수 변경 시 자동 중단.

시나리오 4: “무한 루프에 빠졌어요”

"프로그램이 멈춘 것처럼 보여요. Ctrl+C로 중단했는데 원인을 모르겠습니다."

상황: while 조건 오류, i++ 누락 등으로 무한 루프에 빠집니다. printf를 넣으면 출력이 너무 많아서 의미 있는 정보를 얻기 어렵습니다. 해결 포인트: Ctrl+C로 중단 → backtrace로 현재 위치 확인 → print 루프 변수로 값 확인.

시나리오 5: “멀티스레드에서 데드락이 발생해요”

"두 스레드가 서로를 기다리다 멈춘 것 같아요."
"어느 스레드가 어느 뮤텍스를 잡고 있는지 모르겠습니다."

상황: 뮤텍스 잠금 순서 불일치로 데드락 발생. 단일 스레드에서는 재현되지 않습니다. 해결 포인트: Ctrl+C로 중단 → thread apply all backtrace로 모든 스레드의 호출 스택 확인.

시나리오 6: “프로덕션에서만 가끔 크래시해요”

"개발 PC에서는 절대 안 나는데, 서버에서 가끔 죽습니다."
"core dump가 없어서 분석을 못 해요."

상황: 특정 부하, 특정 데이터에서만 발생하는 버그. 재현이 어렵으며, 프로덕션에서는 디버거를 직접 붙이기 어렵습니다. 해결 포인트: core dump 활성화 → 크래시 시 core 파일 수집 → GDB로 core 파일 분석.

문제 시나리오별 도구 선택

flowchart TD
    A[버그 발생] --> B{유형?}
    B -->|크래시| C[GDB/LLDB run → backtrace]
    B -->|조건부 버그| D[조건부 브레이크포인트]
    B -->|변수 오염| E[워치포인트]
    B -->|무한 루프| F[Ctrl+C → backtrace]
    B -->|데드락| G[thread apply all bt]
    B -->|프로덕션| H[core dump 분석]

디버그 빌드와 GDB·LLDB 시작

디버그 빌드 (필수)

-g 옵션을 주면 실행 파일에 디버그 심볼(소스 줄 번호, 변수 이름)이 들어갑니다. -O0로 최적화를 끄면 변수가 최적화로 사라지거나 코드 순서가 바뀌는 일이 줄어듭니다.

# 디버그 정보 포함 (-g), 최적화 끄기 (-O0)
g++ -g -O0 main.cpp -o myapp
# CMake 사용 시
cmake -DCMAKE_BUILD_TYPE=Debug ..
make
# 최적화 + 디버그 심볼 (릴리스에서만 나는 버그 재현용)
cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo ..

-O0와 -g는 역할이 다릅니다. -g는 “이 기계어가 소스 몇 번째 줄인지, 이 레지스터가 어떤 변수인지”를 담은 정보를 추가할 뿐 실행 코드를 바꾸지 않고, -O0는 코드 자체를 최적화하지 않게 합니다. 그래서 -O2 -g(RelWithDebInfo) 빌드도 디버깅은 가능하지만, 변수가 <optimized out>으로 나오거나 next가 소스 줄을 앞뒤로 건너뛰는 일이 생깁니다. 버그가 릴리스 빌드에서만 재현될 때 이 조합을 씁니다.

GDB 시작

# 프로그램 로드
gdb ./myapp
# 실행
(gdb) run
# 인자와 함께 실행
(gdb) run arg1 arg2
# 환경 변수 설정 후 실행
(gdb) set env LD_LIBRARY_PATH=/path/to/libs
(gdb) run
# 종료
(gdb) quit

주의사항: GDB는 기본적으로 디버깅 대상 프로세스의 ASLR(주소 무작위화)을 끈 채 실행합니다(set disable-randomization on). 그래서 디버거 밖에서는 크래시가 나는데 GDB 안에서는 재현되지 않는 주소 의존 버그가 있다면 set disable-randomization off로 바꾼 뒤 다시 실행해 보세요.

LLDB 시작 (macOS)

# 프로그램 로드
lldb ./myapp
# 실행
(lldb) run
# 인자와 함께 실행
(lldb) run arg1 arg2
# 종료
(lldb) quit

디버깅 워크플로우

sequenceDiagram
    participant Dev as 개발자
    participant GDB as GDB/LLDB
    participant App as 대상 프로그램
    Dev->>GDB: gdb ./myapp
    GDB->>App: 로드 (디버그 심볼)
    Dev->>GDB: break main
    Dev->>GDB: run
    GDB->>App: 실행 시작
    App->>GDB: main() 도달 → 중단
    GDB->>Dev: 프롬프트 반환
    Dev->>GDB: next / step / print
    GDB->>Dev: 결과 출력
    Dev->>GDB: continue
    GDB->>App: 다음 브레이크포인트까지 실행

브레이크포인트와 조건부 브레이크포인트

브레이크포인트란?

실행을 특정 위치에서 멈추게 하는 지점입니다. 멈춘 상태에서 변수 값, 호출 스택, 메모리를 검사할 수 있습니다.

브레이크포인트 내부 동작 메커니즘:

소프트웨어 브레이크포인트 (Software Breakpoint):

1. 디버거가 브레이크포인트 설정:
   
   (gdb) break main.cpp:20
   
   디버거 동작:
   a. 소스 파일:라인 → 기계어 주소 변환
      디버그 심볼 테이블 조회:
      main.cpp:20 → 0x400540
   
   b. 해당 주소의 원본 명령어 저장:
      0x400540: mov rax, [rbp-8]  # 원본 명령어
      → 백업: saved_instruction[0x400540] = 0x48 8b 45 f8
   
   c. int3 명령어로 교체 (1바이트):
      0x400540: int3  # 0xCC
      → 메모리에 0xCC 기록

2. 프로그램 실행:
   
   CPU가 0x400540에 도달:
   → int3 명령어 실행
   → SIGTRAP 시그널 발생
   → 커널이 디버거에 통지

3. 디버거 처리:
   
   디버거가 SIGTRAP 수신:
   a. 대상 프로세스는 시그널로 정지된 상태 (waitpid로 확인)
   b. 원본 명령어 복원:
      0x400540: mov rax, [rbp-8]  # 복원
   c. PC(Program Counter) 1바이트 되돌리기:
      RIP = 0x400540 (int3 실행 전 위치)
   d. 사용자 프롬프트 반환:
      (gdb) 
   
   사용자가 continue 입력:
   → 원본 명령어 실행 (한 단계)
   → 다시 int3로 교체 (재사용 가능)
   → PTRACE_CONT로 계속 실행

ptrace 시스템 콜 사용:

프로그램 시작:
fork() → 자식 프로세스
↓
자식: ptrace(PTRACE_TRACEME, 0, NULL, NULL)
      → "나를 디버깅해줘" 표시
      exec(myapp)
↓
부모(GDB): ptrace(PTRACE_CONT, child_pid, ...)
          → 자식 프로세스 실행 제어

주요 ptrace 명령:
- PTRACE_TRACEME: 자식이 디버깅 요청
- PTRACE_PEEKTEXT: 메모리 읽기 (print 명령)
- PTRACE_POKETEXT: 메모리 쓰기 (int3 삽입)
- PTRACE_GETREGS: 레지스터 읽기
- PTRACE_SETREGS: 레지스터 쓰기
- PTRACE_CONT: 계속 실행
- PTRACE_SINGLESTEP: 기계어 한 명령만 실행 (stepi, 그리고 브레이크포인트 재설치 전 한 걸음)

하드웨어 브레이크포인트 (Hardware Breakpoint):

CPU 디버그 레지스터 사용 (x86: DR0-DR7):

(gdb) hbreak main.cpp:20

DR0 = 0x400540  # 브레이크포인트 주소
DR7 = 0x00000001  # DR0 활성화

CPU가 0x400540 접근:
→ 하드웨어가 자동 감지
→ Debug Exception 발생
→ 디버거에 통지

장점:
- 코드 수정 없음 (int3 불필요)
- ROM, 실행 전용 메모리에도 설정 가능
- 4개 제한 (DR0-DR3)

조건부 브레이크포인트 구현:

(gdb) break main.cpp:20 if i == 500

내부 동작:
1. 무조건 브레이크포인트 설정 (int3)
2. SIGTRAP 발생 시:
   a. 조건 평가:
      i 변수 값 읽기 (PTRACE_PEEKDATA)
      i == 500? → false
   b. false면:
      - 원본 명령어 실행
      - int3 재설치
      - PTRACE_CONT (계속 실행)
   c. true면:
      - 사용자 프롬프트 반환

→ 조건 체크 500번 반복 (오버헤드)
→ 조건이 자주 false면 느림

성능 비교:

소프트웨어 BP:
- 무제한 개수
- 코드 수정 (int3)
- 약간 느림 (SIGTRAP 처리)

하드웨어 BP:
- 4개 제한
- 코드 수정 없음
- 빠름 (CPU 자동 감지)

조건부 BP:
- 조건 평가 반복
- 매우 느림 (조건 체크 오버헤드)

실제 예시:

원본 코드:
int main() {
    int x = 10;  // 0x400540
    int y = 20;  // 0x400545
    return 0;
}

디버거 없이:
0x400540: mov dword ptr [rbp-4], 10
0x400545: mov dword ptr [rbp-8], 20
0x40054c: xor eax, eax
0x40054e: ret

break main.cpp:2 설정 후:
0x400540: int3  # 0xCC (원본: C7 45 FC 0A)
0x400545: mov dword ptr [rbp-8], 20
0x40054c: xor eax, eax
0x40054e: ret

실행 흐름:
1. CPU가 0x400540 도달
2. int3 실행 → SIGTRAP
3. 커널 → GDB에 통지
4. GDB: 프로그램 중단
5. 사용자: (gdb) 프롬프트
6. continue 입력
7. GDB: 원본 명령어 복원 & 실행
8. 다시 int3 설치
9. 계속 실행

GDB 브레이크포인트 명령어

# 함수에 브레이크포인트
(gdb) break main
(gdb) break processData
(gdb) b main  # 단축
# 파일:라인에 브레이크포인트
(gdb) break main.cpp:15
(gdb) b main.cpp:15
# 조건부 브레이크포인트 (i가 50일 때만 멈춤)
(gdb) break main.cpp:20 if i == 50
# 포인터가 null일 때만
(gdb) break main.cpp:25 if ptr == nullptr
# 브레이크포인트 목록
(gdb) info breakpoints
(gdb) i b
# 브레이크포인트 삭제
(gdb) delete 1        # 번호로 삭제
(gdb) delete          # 모두 삭제
(gdb) clear main.cpp:15  # 위치로 삭제
# 브레이크포인트 비활성화/활성화
(gdb) disable 1
(gdb) enable 1
# 일시 중단 횟수 설정 (N번째 도달 시에만 멈춤)
(gdb) ignore 1 99    # 1번 브레이크포인트를 99번 무시 후 멈춤

LLDB 브레이크포인트 명령어

# 함수에 브레이크포인트
(lldb) breakpoint set --name main
(lldb) b main  # 단축
# 파일:라인
(lldb) breakpoint set --file main.cpp --line 15
(lldb) b main.cpp:15
# 조건부
(lldb) breakpoint set --name processData --condition 'i == 50'
(lldb) breakpoint set -f main.cpp -l 20 -c 'ptr == nullptr'
# 목록
(lldb) breakpoint list
(lldb) br list
# 삭제
(lldb) breakpoint delete 1
(lldb) breakpoint delete  # 모두 삭제
# 비활성화/활성화
(lldb) breakpoint disable 1
(lldb) breakpoint enable 1

조건부 브레이크포인트 활용 예

// conditional_bug.cpp - 1000번 중 500번째에서만 버그
for (int i = 0; i < 1000; ++i) {
    process(i);  // i=500일 때만 잘못된 동작
}
# GDB: i가 500일 때만 main.cpp 10번 줄에서 멈춤
(gdb) break main.cpp:10 if i == 500
(gdb) run
# LLDB
(lldb) breakpoint set -f main.cpp -l 10 -c 'i == 500'
(lldb) run

조건식에는 프로그램의 변수와 함수 호출을 쓸 수 있고, 이미 만든 브레이크포인트에 나중에 조건을 붙일 수도 있습니다.

# 문자열 비교 (C 문자열)
(gdb) break main.cpp:30 if strcmp(name, "buggy") == 0
# 이미 만든 1번 브레이크포인트에 조건 추가/변경
(gdb) condition 1 ptr == 0
# 조건 없이 "N번째 도달"만 필요하면 ignore가 조건식보다 가볍다
(gdb) ignore 1 499

조건식에서 함수를 호출하면 브레이크포인트에 도달할 때마다 대상 프로세스 안에서 그 함수가 실행됩니다. strcmp처럼 부작용이 없는 함수는 괜찮지만, 상태를 바꾸는 함수를 조건에 넣으면 디버깅 자체가 프로그램 동작을 바꿔 버리니 주의하세요. 또 std::string의 == 연산자처럼 인라인되어 바이너리에 실체가 없는 함수는 조건식에서 호출이 실패할 수 있습니다.


워치포인트로 메모리 추적

워치포인트란?

특정 변수나 메모리 주소가 변경될 때 자동으로 실행을 중단하는 기능입니다. “어디서 이 값이 바뀌는가?”를 찾을 때 유용합니다.

GDB 워치포인트

# 변수 변경 시 멈춤 (쓰기)
(gdb) watch variable_name
# 포인터가 가리키는 값 변경 시
(gdb) watch *ptr
# 읽기 시 멈춤 (rwatch)
(gdb) rwatch variable_name
# 읽기 또는 쓰기 시 멈춤 (awatch)
(gdb) awatch variable_name
# 워치포인트는 break main 등으로 프로그램 시작 후 설정
(gdb) break main
(gdb) run
(gdb) watch arr[5]   # 이제 arr[5]가 스코프에 있음
(gdb) continue

LLDB 워치포인트

# 변수 변경 시 멈춤
(lldb) watchpoint set variable variable_name
(lldb) w s v variable_name  # 단축
# 표현식(포인터 역참조) 감시
(lldb) watchpoint set expression -- ptr
(lldb) watchpoint set expression -w write -- *(int*)0x7fff1234
# 목록
(lldb) watchpoint list
# 삭제
(lldb) watchpoint delete 1

워치포인트 실전 예: 메모리 오염 찾기

// memory_corruption.cpp
#include <iostream>
struct State {
    int buffer[10];
    int counter;   // buffer 바로 뒤에 배치됨
};
State g_state{};
void suspiciousFunction() {
    // 버퍼 오버런: buffer[10]은 존재하지 않는 원소 → 바로 뒤 counter를 덮어씀 (UB)
    for (int i = 0; i <= 10; ++i) {
        g_state.buffer[i] = i;  // i=10일 때 범위 초과!
    }
}
int main() {
    std::cout << "Before: " << g_state.counter << "\n";
    suspiciousFunction();
    std::cout << "After: " << g_state.counter << "\n";  // 10이 찍힘
    return 0;
}
# GDB로 counter 변경 지점 찾기
$ g++ -g -O0 -o corrupt memory_corruption.cpp
$ gdb ./corrupt
(gdb) break main
(gdb) run
(gdb) watch g_state.counter
(gdb) continue
# counter가 변경되면 멈춤
Hardware watchpoint 2: g_state.counter
Old value = 0
New value = 10
suspiciousFunction () at memory_corruption.cpp:11

예제가 구조체를 쓰는 이유가 있습니다. 흔히 “지역 배열이 넘치면 전역 변수를 덮는다”고 설명하지만, 지역 배열은 스택에, 전역 변수는 데이터 영역에 있어 서로 멀리 떨어져 있으므로 실제로는 스택의 다른 지역 변수나 반환 주소가 망가집니다. 구조체 멤버는 선언 순서대로 배치되므로 buffer[10]이 counter 자리를 덮는다는 것이 (여전히 미정의 동작이지만) 재현 가능하게 드러납니다. 워치포인트의 핵심은 “누가 썼는지”를 쓰는 순간에 잡아 준다는 점입니다. 오염이 발견된 시점에 backtrace를 보면 이미 범인 함수는 반환한 뒤라서, 워치포인트 없이는 추적이 훨씬 어렵습니다.

지역 변수에 워치포인트를 걸면 그 변수의 스코프를 벗어날 때 GDB가 “Watchpoint deleted because the program has left the block”이라며 워치포인트를 자동으로 지웁니다. 함수가 여러 번 호출되는 경우라면 매번 다시 걸거나, 주소를 직접 감시(watch -l var 또는 watch *(int*)0x...)해야 합니다.


next, step, finish로 단계별 실행

next vs step

명령GDBLLDB동작
다음 줄 (함수 건너뜀)next / nnext / n현재 줄 실행 후 다음 줄로. 함수 호출 시 함수 안으로 들어가지 않음
함수 진입step / sstep / s함수 호출 시 함수 내부로 들어감
함수 끝까지finishfinish현재 함수 반환까지 실행
계속continue / ccontinue / c다음 브레이크포인트까지 실행

GDB 실행 제어

# 다음 줄 실행 (함수 안으로 들어가지 않음)
(gdb) next
(gdb) n
# 함수 안으로 들어감
(gdb) step
(gdb) s
# 현재 함수 끝까지 실행
(gdb) finish
# 계속 실행 (다음 브레이크포인트까지)
(gdb) continue
(gdb) c
# N번 next/step 실행
(gdb) next 5
(gdb) step 3
# 현재 위치 소스 코드 보기
(gdb) list
(gdb) l

LLDB 실행 제어

(lldb) next
(lldb) n
(lldb) step
(lldb) s
(lldb) finish
(lldb) continue
(lldb) c
(lldb) list
(lldb) l

단계별 실행 흐름

flowchart TD
    A[현재 위치] --> B{next vs step?}
    B -->|next| C[다음 소스 줄로]
    B -->|step| D{함수 호출?}
    D -->|예| E[함수 내부로 진입]
    D -->|아니오| C
    C --> F[다음 명령 대기]
    E --> F
    F --> G{finish?}
    G -->|예| H[현재 함수 반환까지 실행]
    G -->|아니오| A
    H --> F

변수 출력

# GDB
(gdb) print x
(gdb) p x
# 포인터 역참조
(gdb) print *ptr
# 배열 10개 원소
(gdb) print arr[0]@10
# 구조체
(gdb) print person
(gdb) print person.name
# STL vector (GDB 7.0+)
(gdb) print vec
(gdb) print vec.size()
# LLDB
(lldb) frame variable
(lldb) fr v
(lldb) frame variable x
(lldb) p x
(lldb) expression ptr->member
(lldb) expr vec.size()
(lldb) expression x + 1   # 표현식 평가

타입 확인 (ptype)

값만 봐서는 원인이 안 보일 때, 변수의 실제 타입이나 구조체 정의를 확인하면 “signed/unsigned 비교였다”, “생각한 구조체가 아니라 다른 오버로드였다” 같은 단서가 나옵니다.

(gdb) ptype x          # 변수 타입
(gdb) ptype Person     # 구조체 정의 (멤버 목록)
(gdb) whatis expr      # 표현식의 타입만 한 줄로
(lldb) type lookup Person

메모리 검사 (x 명령)

# GDB: 메모리 내용 보기
# 형식: x/[개수][형식][크기] 주소
(gdb) x/10x ptr   # 16진수 10개 (4바이트씩)
(gdb) x/10d ptr   # 10진수 10개
(gdb) x/10s ptr   # 문자열 10개
(gdb) x/20xb ptr  # 1바이트 20개, 16진수
# 형식: x(16진), d(10진), s(문자열), i(명령어)
# 크기: b(1바이트), h(2바이트), w(4바이트), g(8바이트)

스택 추적 (백트레이스)

# GDB
(gdb) backtrace
(gdb) bt
# 모든 프레임의 지역 변수 포함
(gdb) backtrace full
(gdb) bt full
# 특정 프레임으로 이동
(gdb) frame 2
(gdb) f 2
# 현재 프레임 정보
(gdb) info frame
# 현재 프레임의 지역 변수
(gdb) info locals
# 함수 인자
(gdb) info args
# LLDB
(lldb) thread backtrace
(lldb) bt
(lldb) frame select 2
(lldb) f 2
(lldb) frame variable

스택 프레임 구조

flowchart TB
    subgraph stack["호출 스택 (아래→위)"]
        F0["main() - 프레임 2"]
        F1["processData() - 프레임 1"]
        F2["buggyFunction() - 프레임 0 (현재)"]
    end
    F0 --> F1
    F1 --> F2
    subgraph vars[프레임 0 지역 변수]
        V1["i = 10"]
        V2["size = 10"]
        V3["arr = 0x7fff..."]
    end

세그폴트, 무한 루프, 데드락을 GDB로 추적하는 예제

예제 1: 배열 범위 초과 (세그폴트)

// buggy_array.cpp - g++ -g -O0 -o buggy buggy_array.cpp
#include <iostream>
void buggyFunction(int* arr, int size) {
    for (int i = 0; i <= size; ++i) {  // ❌ <= 버그 (i==size일 때 범위 초과)
        arr[i] = i * 2;
    }
}
int main() {
    int arr[10];
    buggyFunction(arr, 10);  // 크래시!
    std::cout << "done\n";
    return 0;
}

GDB 디버깅 과정:

$ g++ -g -O0 -o buggy buggy_array.cpp
$ gdb ./buggy
(gdb) run
Program received signal SIGSEGV, Segmentation fault.
(gdb) backtrace
#0  buggyFunction (arr=0x7fffffffe2a0, size=10) at buggy_array.cpp:5
#1  main () at buggy_array.cpp:12
(gdb) frame 0
(gdb) print i
$1 = 10   # ← 버그! 유효 인덱스는 0-9
(gdb) print size
$2 = 10
(gdb) print arr[10]
Cannot access memory at address 0x...

수정:

// ✅ i < size
for (int i = 0; i < size; ++i) {
    arr[i] = i * 2;
}

예제 2: Null 포인터 역참조

// null_ptr.cpp
struct Node { int value; Node* next; };
int sumList(Node* head) {
    int sum = 0;
    while (head != nullptr) {
        sum += head->value;  // head가 null이면 크래시
        head = head->next;
    }
    return sum;
}
(gdb) break sumList
(gdb) run
(gdb) next
(gdb) print head
$1 = (Node *) 0x0   # null 포인터!
(gdb) backtrace full

예제 3: 무한 루프

// infinite_loop.cpp
void processData() {
    int i = 0;
    while (i < 100) {
        process(i);
        // i++ 누락!
    }
}
# Ctrl+C로 중단
(gdb) run
^C
Program received signal SIGINT, Interrupt.
(gdb) backtrace
#0  processData () at main.cpp:5
(gdb) print i
$1 = 0   # 변하지 않음 → i++ 누락 발견

예제 4: 조건부 브레이크 (1000번 중 500번째만)

(gdb) break main.cpp:10 if i == 500
(gdb) run
# 멈춘 후
(gdb) print i
$1 = 500
(gdb) step
(gdb) backtrace

예제 5: 워치포인트로 메모리 오염

(gdb) break main
(gdb) run
(gdb) watch arr[5]
(gdb) continue
# arr[5] 변경 시
Hardware watchpoint 2: arr[5]
Old value = 0
New value = 999
someFunction () at memory_corruption.cpp:15

예제 6: STL vector 범위 초과

// vector_bug.cpp
std::vector<int> vec = {1, 2, 3};
for (size_t i = 0; i <= vec.size(); ++i) {  // ❌ <= 버그
    std::cout << vec[i] << "\n";
}
(gdb) run
Program received signal SIGSEGV
(gdb) print i
$1 = 3
(gdb) print vec.size()
$2 = 3
# vec[3] 접근 → 범위 초과

예제 7: 멀티스레드 데드락

// deadlock.cpp
std::mutex m1, m2;
void thread1() { m1.lock(); /* ... */ m2.lock(); /* ... */ }
void thread2() { m2.lock(); /* ... */ m1.lock(); /* ... */ }
# Ctrl+C 후
(gdb) thread apply all backtrace
Thread 1:
#0  __lll_lock_wait ()
#1  thread1 () at deadlock.cpp:10
Thread 2:
#0  __lll_lock_wait ()
#1  thread2 () at deadlock.cpp:20
# 두 스레드가 서로의 뮤텍스를 기다림 → 데드락 확인

예제 8: 재귀 스택 오버플로우

int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}
int main() {
    int result = factorial(-5);  // ❌ 음수 → 무한 재귀
    return 0;
}
(gdb) run
Program received signal SIGSEGV
(gdb) backtrace
#0  factorial (n=-1048576) at stack_overflow.cpp:3
#1  factorial (n=-1048575) at stack_overflow.cpp:4
...
#1048575  factorial (n=-5) at stack_overflow.cpp:4
#1048576  main () at stack_overflow.cpp:8
# n이 음수로 전달되어 종료 조건 미도달 → 스택 오버플로우

프레임 번호와 깊이는 스택 크기(ulimit -s)와 프레임 크기에 따라 달라집니다. 스택 오버플로우 backtrace는 수십만 줄이 될 수 있으니 bt 10(안쪽 10개), bt -10(바깥쪽 10개)으로 양 끝만 보면 됩니다. 바깥쪽 끝에서 “처음 잘못된 인자가 들어온 호출”을 찾는 것이 핵심입니다.

예제 9: 지역 변수 주소 반환 (dangling pointer)

int* createValue() {
    int x = 42;
    return &x;  // ❌ 함수가 끝나면 x의 스택 공간은 재사용됨
}
int main() {
    int* p = createValue();
    std::cout << *p << "\n";  // 42가 나올 수도, 쓰레기 값이 나올 수도 있음
}

이 버그는 디버거로 잡기 까다롭습니다. 크래시가 나지 않고 값만 틀리는 경우가 많고, -O0에서는 우연히 42가 그대로 남아 있어 멀쩡해 보이기도 합니다. 컴파일러 경고(-Wall의 -Wreturn-local-addr)가 먼저 알려 주며, 실행 중 탐지는 AddressSanitizer(-fsanitize=address, stack-use-after-return 탐지 옵션 포함)가 디버거보다 훨씬 확실합니다. 자세한 사용법은 #16-2 Sanitizers에서 다룹니다.

예제 10: 포인터 산술 오류

void processBuffer(char* buf, int len) {
    char* end = buf + len;
    while (buf < end) {
        *buf = toupper(*buf);
        buf += sizeof(int);  // ❌ char*인데 4바이트씩 건너뜀
    }
}

len이 4의 배수가 아니면 buf가 end를 건너뛰어 버퍼 밖을 씁니다. 디버거에서는 루프 안에 브레이크포인트를 걸고 print buf - start(원래 시작 주소와의 차이)나 x/8xb buf로 실제 가리키는 위치를 확인하면, 포인터가 한 번에 4씩 움직이는 것이 바로 보입니다. 결과 문자열이 “한 글자 건너 한 글자만 대문자”처럼 이상하다면 이런 증가량 실수를 의심해 볼 만합니다.


No symbol table, optimized out 같은 디버거 에러

“No symbol table” / “No debugging symbols found”

원인: -g 옵션 없이 빌드함. 해결법:

# 재빌드 시 -g 추가
g++ -g -O0 main.cpp -o myapp
# 바이너리에 심볼 있는지 확인
file myapp
# "not stripped" 또는 "with debug_info" 확인

“Cannot access memory at address 0x0”

원인: Null 포인터 역참조. 해결법:

(gdb) backtrace
(gdb) frame 0
(gdb) info args
(gdb) print ptr   # 0x0인지 확인

“optimized out” (변수 값이 표시되지 않음)

원인: -O2, -O3 등 최적화로 변수가 레지스터에만 있거나 제거됩니다. 해결법:

# -O0으로 재빌드
g++ -g -O0 main.cpp -o myapp
# 또는 volatile 사용 (해당 변수만)
volatile int debug_var = value;

“Program received signal SIGSEGV” - 원인 불명

해결법:

# 1. backtrace로 크래시 위치 확인
(gdb) backtrace full
# 2. 크래시 직전에 브레이크포인트 설정 후 next로 한 줄씩 진행
(gdb) break [크래시 함수]
(gdb) run
(gdb) next   # 변수 확인하며 진행

GDB가 “run” 후 즉시 종료

원인: 프로그램 정상 종료, 또는 자식 프로세스에서 크래시. 해결법:

# 자식 프로세스 추적
(gdb) set follow-fork-mode child
# fork 전에 브레이크포인트
(gdb) break fork
(gdb) run
(gdb) continue

LLDB “variable not found”

원인: 최적화로 변수 제거, 또는 스코프 밖. 해결법:

(lldb) frame variable
(lldb) expr *(int*)0x7fff...

워치포인트 “Hardware watchpoint” 제한

원인: x86에서 하드웨어 워치포인트는 보통 4개로 제한됩니다. 해결법:

# 소프트웨어 워치포인트 사용 (느리지만 제한 없음)
# GDB는 자동으로 fallback. 너무 많으면 일부 삭제
(gdb) delete 2

크래시 직후 확인 순서와 .gdbinit 활용

항상 -g -O0로 디버그 빌드

g++ -g -O0 main.cpp -o myapp

최적화가 켜져 있으면 변수가 사라지고, 줄 번호가 어긋나며, 디버깅이 어려워집니다.

크래시 시 즉시 backtrace

(gdb) run
# SIGSEGV 수신 후
(gdb) backtrace full
(gdb) info locals
(gdb) info args

조건부 브레이크포인트로 시간 절약

# 1000번 루프 중 500번째만 확인
(gdb) break main.cpp:20 if i == 500

워치포인트로 메모리 오염 추적

(gdb) watch suspicious_var
(gdb) continue

.gdbinit 활용

# ~/.gdbinit 또는 프로젝트/.gdbinit
set pagination off
set print pretty on
set print array on

로그 저장

(gdb) set logging file debug.log
(gdb) set logging enabled on    # GDB 12 이전 버전은 set logging on
(gdb) run
# ... 디버깅 ...
(gdb) set logging enabled off

크래시 직후 한 번에 볼 것들

(gdb) bt full                          # 모든 프레임의 지역 변수까지
(gdb) info registers                   # 레지스터 상태 (최적화 빌드에서 유용)
(gdb) handle SIGPIPE nostop noprint    # 네트워크 코드에서 SIGPIPE로 매번 멈추는 것 방지

서버 프로그램을 GDB로 돌리면 클라이언트가 연결을 끊을 때마다 SIGPIPE로 멈춰서 정작 보려던 크래시까지 가기 어렵습니다. 이럴 때 handle로 관심 없는 시그널을 넘기도록 해 두면 흐름이 끊기지 않습니다. 같은 설정을 .gdbinit에 넣어 두면 매번 입력할 필요가 없습니다.

디버깅 체크리스트

- [ ] -g -O0로 빌드했는가?
- [ ] backtrace로 크래시 위치 확인
- [ ] frame N으로 해당 프레임 이동
- [ ] info locals, info args로 변수 확인
- [ ] 조건부 브레이크로 특정 케이스만 추적
- [ ] 워치포인트로 메모리 변경 추적
- [ ] 멀티스레드 시 thread apply all bt

core dump, gdbserver, 디버그 심볼 분리

Core Dump 분석

프로덕션에서 크래시 시 core dump를 저장해 나중에 분석합니다.

# core dump 활성화 (Linux)
ulimit -c unlimited
echo /tmp/core.%e.%p | sudo tee /proc/sys/kernel/core_pattern
# 크래시 후
$ gdb ./myapp /tmp/core.myapp.12345
(gdb) backtrace
(gdb) backtrace full

원격 디버깅 (gdbserver)

# 대상 머신 (프로덕션/임베디드)
gdbserver :1234 ./myapp
# 개발 머신
gdb ./myapp
(gdb) target remote 192.168.1.100:1234
(gdb) continue

디버그 심볼 분리

Release 빌드에서 디버그 정보를 별도 파일로 보관합니다.

# Release 빌드 + 별도 .debug 파일
objcopy --only-keep-debug myapp myapp.debug
strip -g myapp
objcopy --add-gnu-debuglink=myapp.debug myapp
# 분석 시
gdb -s myapp.debug -e myapp -c core.12345

프로덕션 디버깅 흐름

flowchart TD
    A[프로덕션 크래시] --> B{디버그 빌드 있음?}
    B -->|예| C[core dump 수집]
    B -->|아니오| D[재현 환경 구축]
    C --> E[gdbserver 또는 로컬 분석]
    D --> E
    E --> F[backtrace 분석]
    F --> G[원인 파악]
    G --> H[수정 및 배포]

주의사항:

  • 프로덕션 바이너리와 core dump의 빌드가 정확히 일치해야 함
  • -O2 빌드에서는 변수/줄 번호가 어긋날 수 있음 → RelWithDebInfo 권장
  • gdbserver는 프로세스를 중단하므로 트래픽 적은 시간에 사용

실무 디버깅 워크플로우

  1. 재현: 최소 입력으로 버그 재현
  2. 격리: 브레이크포인트로 범위 좁히기
  3. 가설: 변수 값으로 원인 추측
  4. 검증: 수정 후 재테스트
  5. 문서화: 원인과 해결법 기록

GDB, LLDB, Visual Studio 명령어 대응표

GDB 치트시트

# 실행
run, r                    # 실행
run arg1 arg2             # 인자와 함께
kill                      # 프로그램 종료
# 브레이크포인트
break, b                  # 브레이크포인트 설정
info breakpoints          # 목록
delete 1                  # 삭제
# 실행 제어
next, n                   # 다음 줄
step, s                   # 함수 안으로
finish                    # 함수 끝까지
continue, c               # 계속
# 검사
print, p                  # 변수 출력
ptype                     # 타입 확인
backtrace, bt             # 스택 추적
info locals               # 지역 변수
info args                 # 함수 인자
# 워치포인트
watch var                 # 변수 변경 시 멈춤
# 기타
list, l                   # 소스 코드
quit, q                   # 종료

GDB vs LLDB 명령어 대응표

기능GDBLLDB
실행runrun
브레이크포인트break mainbreakpoint set -n main
조건부 브레이크break f.c:10 if i==5breakpoint set -f f.c -l 10 -c 'i==5'
다음 줄nextnext
함수 진입stepstep
변수 출력print xframe variable x
스택backtracebt
워치포인트watch varwatchpoint set variable var
종료quitquit

Visual Studio에서 같은 작업

Windows에서 MSVC로 개발한다면 GDB 대신 Visual Studio 디버거를 쓰게 됩니다. 개념은 같고 조작만 다릅니다.

작업단축키 / 방법
브레이크포인트 설정/해제F9
디버깅 시작 / 계속F5
한 줄 실행(Step Over) / 함수 안으로(Step Into) / 밖으로(Step Out)F10 / F11 / Shift+F11
조건부 브레이크포인트브레이크포인트 우클릭 → 조건 (예: i == 500)
워치포인트(데이터 브레이크포인트)디버깅 중 변수 우클릭 → “값이 변경되면 중단”
크래시 덤프 분석.dmp 파일을 Visual Studio로 열고 “네이티브로 디버그”

자주 묻는 질문 (FAQ)

Q. 디버그 빌드에서는 멀쩡한데 릴리스 빌드에서만 버그가 나요.

A. 대부분 미정의 동작(UB)이 최적화 때문에 드러난 경우입니다. 가장 흔한 게 초기화하지 않은 변수인데, 디버그 빌드는 우연히 다른 값을 넣어 둡니다(MSVC 디버그 빌드는 스택을 0xCC, 힙을 0xCD 같은 패턴으로 채움). 릴리스에선 그 자리에 남아 있던 아무 값이나 읽힙니다. 범위를 벗어난 배열 접근, 부호 있는 정수 오버플로, 반환값이 없는 함수 경로도 최적화기가 “일어나지 않는다”고 가정하고 코드를 바꿔 버려 릴리스에서만 증상이 납니다. 순서는 이렇게 합니다: (1) -Wall -Wextra(MSVC /W4) 경고부터 없애기, (2) -O2 -g나 CMake RelWithDebInfo처럼 최적화를 켠 채 심볼을 넣은 빌드로 재현, (3) 그 빌드에 ASan·UBSan을 붙여 실행. 디버그 빌드로 돌아가서 찾으려 하면 증상이 사라져 오래 걸립니다.


같이 보면 좋은 글