이미지 로딩 중...
AI Generated
2025. 11. 14. · 4 Views
Rust로 만드는 나만의 OS 디버깅 환경 구축 GDB 완벽 가이드
운영체제 개발 과정에서 필수적인 GDB 디버깅 환경 구축 방법을 다룹니다. QEMU와 GDB를 연동하여 커널 레벨 디버깅을 수행하는 방법부터, 브레이크포인트 설정, 메모리 검사, 레지스터 분석까지 실무에 바로 적용할 수 있는 내용을 제공합니다.
목차
- GDB 원격 디버깅 환경 설정 - QEMU와 GDB 연동하기
- 소스 레벨 디버깅 설정 - 디버그 심볼과 최적화 제어
- 브레이크포인트 전략 - 함수, 주소, 조건부 중단점 설정
- 메모리와 레지스터 검사 - 커널 상태 분석 기법
- 스택 추적과 콜 프레임 분석 - 실행 흐름 역추적
- GDB 스크립트와 자동화 - 반복 작업 효율화
- TUI 모드와 레이아웃 - 시각적 디버깅 인터페이스
- 어셈블리 레벨 디버깅 - 저수준 코드 분석
- 코어 덤프 분석 - 사후 디버깅 기법
- 하드웨어 디버그 기능 활용 - 워치포인트와 하드웨어 브레이크포인트
- 멀티코어 디버깅 - SMP 환경에서의 디버깅 전략
- 성능 분석과 프로파일링 - GDB를 활용한 병목 지점 찾기
1. GDB 원격 디버깅 환경 설정 - QEMU와 GDB 연동하기
시작하며
여러분이 Rust로 운영체제를 개발하다가 갑자기 화면이 멈추거나 예상치 못한 동작을 겪어본 적 있나요? println!이나 로그만으로는 정확히 어디서 무엇이 잘못되었는지 파악하기 어려운 상황이 자주 발생합니다.
이런 문제는 실제 OS 개발 현장에서 가장 큰 난관 중 하나입니다. 커널 레벨에서는 일반 애플리케이션처럼 쉽게 디버거를 붙일 수 없고, 크래시가 발생하면 전체 시스템이 멈춰버리기 때문입니다.
바로 이럴 때 필요한 것이 QEMU와 GDB를 연동한 원격 디버깅 환경입니다. 이를 통해 커널의 실행을 한 줄씩 추적하고, 레지스터와 메모리 상태를 실시간으로 확인할 수 있습니다.
개요
간단히 말해서, 이 개념은 가상 머신(QEMU)에서 실행 중인 커널을 호스트 시스템의 GDB로 원격 제어하는 디버깅 기법입니다. 실제 하드웨어에서 OS를 디버깅하려면 JTAG 같은 하드웨어 디버거가 필요하지만, QEMU는 gdbserver 기능을 내장하고 있어 훨씬 간편합니다.
예를 들어, 부팅 과정에서 페이지 테이블 초기화가 실패하는 상황이라면, GDB로 정확히 어떤 주소에서 문제가 발생했는지 즉시 확인할 수 있습니다. 기존에는 시리얼 포트로 로그를 출력하거나, 화면에 직접 문자를 찍어가며 디버깅했다면, 이제는 브레이크포인트를 설정하고 단계별로 실행하면서 모든 상태를 검사할 수 있습니다.
이 환경의 핵심 특징은 첫째, 실제 하드웨어 없이도 정교한 디버깅이 가능하다는 점, 둘째, 소스 코드 레벨에서 디버깅할 수 있다는 점, 셋째, 시스템 전체를 일시정지하고 상태를 분석할 수 있다는 점입니다. 이러한 특징들이 OS 개발 생산성을 몇 배나 향상시켜줍니다.
코드 예제
# Makefile에 디버그 타겟 추가
debug: build
# QEMU를 gdbserver 모드로 실행 (-s는 :1234 포트, -S는 시작 시 일시정지)
qemu-system-x86_64 \
-drive format=raw,file=target/x86_64-unknown-none/debug/bootimage-my_os.bin \
-s -S &
# Rust용 GDB 실행
rust-gdb target/x86_64-unknown-none/debug/my_os \
-ex 'target remote :1234' \
-ex 'break _start' \
-ex 'continue'
설명
이것이 하는 일: QEMU 가상 머신에서 실행되는 OS 커널을 GDB로 제어할 수 있는 디버깅 환경을 만듭니다. 첫 번째로, QEMU를 실행할 때 -s 옵션을 사용하면 내장된 gdbserver가 localhost:1234 포트에서 대기합니다.
-S 옵션은 CPU 실행을 즉시 시작하지 않고 GDB의 명령을 기다리도록 합니다. 이렇게 하는 이유는 부팅 초기 단계부터 디버깅하기 위함입니다.
그 다음으로, rust-gdb를 실행하면서 디버그 심볼이 포함된 ELF 바이너리를 로드합니다. target remote :1234 명령으로 QEMU에 연결하고, break _start로 커널 진입점에 브레이크포인트를 설정합니다.
내부에서는 GDB가 원격 프로토콜을 통해 QEMU의 CPU 상태를 읽고 쓸 수 있게 됩니다. 마지막으로, continue 명령을 실행하면 QEMU의 CPU가 동작하기 시작하고, _start 지점에 도달하는 순간 자동으로 멈춥니다.
이때부터 step, next, print 등 모든 GDB 명령을 사용하여 커널 코드를 한 줄씩 실행하며 분석할 수 있습니다. 여러분이 이 코드를 사용하면 복잡한 하드웨어 설정 없이도 전문적인 커널 디버깅 환경을 갖출 수 있습니다.
특히 페이지 폴트, 인터럽트 핸들러, 메모리 할당 등 까다로운 부분을 디버깅할 때 실행 흐름을 정확히 추적할 수 있어 버그 해결 시간이 크게 단축됩니다.
실전 팁
💡 .gdbinit 파일을 프로젝트 루트에 만들어 자주 사용하는 GDB 명령들을 자동 실행되도록 설정하세요. 매번 타이핑하는 수고를 덜 수 있습니다.
💡 rust-gdb 대신 일반 gdb를 사용하면 Rust 타입이 제대로 표시되지 않으니, 반드시 rust-gdb나 rust-lldb를 사용하세요.
💡 디버깅 중 QEMU 창을 닫으면 GDB 연결이 끊기므로, 항상 GDB에서 quit 명령으로 먼저 종료한 후 QEMU를 종료하세요.
💡 -S 옵션 없이 디버깅하려면 GDB 연결 후 Ctrl+C로 실행을 중단시킬 수 있지만, 부팅 초기 단계는 놓칠 수 있습니다.
💡 여러 CPU 코어를 사용하는 경우 info threads로 스레드 목록을 확인하고, thread 명령으로 특정 CPU를 선택하여 디버깅할 수 있습니다.
2. 소스 레벨 디버깅 설정 - 디버그 심볼과 최적화 제어
시작하며
여러분이 GDB로 커널을 디버깅하려고 했는데, 소스 코드 대신 어셈블리만 보이거나, 변수 값을 확인할 수 없는 상황을 겪어본 적 있나요? 이는 컴파일 시 디버그 정보가 제대로 포함되지 않았거나 최적화로 인해 발생하는 문제입니다.
이런 문제는 특히 릴리스 모드로 빌드했을 때 심각합니다. 컴파일러 최적화가 코드 구조를 변경하고, 변수를 레지스터에만 저장하거나 인라인화하면서 원본 소스와의 매핑이 깨지기 때문입니다.
바로 이럴 때 필요한 것이 적절한 디버그 심볼 설정과 최적화 레벨 조정입니다. Cargo.toml에서 프로파일을 올바르게 설정하면 디버깅 효율성이 극대화됩니다.
개요
간단히 말해서, 이 개념은 컴파일 시 소스 코드 매핑 정보를 바이너리에 포함시키고, 디버깅에 방해되는 최적화를 제어하는 설정입니다. Rust 컴파일러는 기본적으로 debug 프로파일에서는 디버그 심볼을 포함하지만, OS 개발에서는 추가적인 조정이 필요합니다.
예를 들어, 부트로더와 커널 간의 인터페이스나 인라인 어셈블리 코드가 많은 경우, 과도한 최적화는 실제 실행 흐름을 파악하기 어렵게 만듭니다. 기존에는 printf 디버깅으로 변수 값을 일일이 출력했다면, 이제는 GDB의 print, watch, backtrace 명령으로 모든 상태를 즉시 확인할 수 있습니다.
이 설정의 핵심 특징은 첫째, DWARF 형식의 디버그 정보를 최대한 포함한다는 점, 둘째, 성능과 디버깅 편의성 사이의 균형을 맞출 수 있다는 점, 셋째, 개발 단계에 따라 유연하게 조정 가능하다는 점입니다. 이를 통해 복잡한 커널 자료구조도 쉽게 검사할 수 있습니다.
코드 예제
# Cargo.toml
[profile.dev]
# 패닉 발생 시 스택 언와인딩 대신 즉시 중단 (OS에서는 필수)
panic = "abort"
[profile.release]
panic = "abort"
# 디버그 심볼 포함 (2: 최대 정보, 1: 라인 정보만, 0: 없음)
debug = 2
# 최적화 레벨 (0: 없음, 1: 기본, 2: 전체, 3: 최대, 's': 크기, 'z': 최소 크기)
opt-level = 2
# 특정 크레이트만 최적화 제외 (디버깅이 어려운 부분)
[profile.dev.package.my_os]
opt-level = 0
설명
이것이 하는 일: 컴파일된 바이너리에 소스 코드 위치, 변수명, 타입 정보를 포함시켜 GDB가 소스 레벨 디버깅을 수행할 수 있게 합니다. 첫 번째로, debug = 2 설정은 DWARF 디버그 정보를 최대한 상세하게 생성합니다.
이 정보에는 각 기계어 명령이 어떤 소스 라인에서 유래했는지, 각 변수가 어느 레지스터나 메모리 위치에 저장되어 있는지 등이 포함됩니다. 이렇게 하는 이유는 GDB가 list 명령으로 소스 코드를 보여주고, print 명령으로 변수를 검사할 수 있게 하기 위함입니다.
그 다음으로, opt-level 설정이 실제 코드 생성 방식을 결정합니다. opt-level = 0이면 변수들이 메모리에 그대로 유지되고 함수 인라인화가 거의 일어나지 않아 디버깅이 쉽지만, 실행 속도는 느립니다.
opt-level = 2는 상당한 최적화를 수행하면서도 디버그 심볼을 유지하므로, 성능 문제를 재현하면서 디버깅할 때 유용합니다. 마지막으로, panic = "abort" 설정은 OS 환경에서 필수적입니다.
일반 Rust 프로그램은 패닉 시 스택을 언와인드하며 정리 코드를 실행하지만, OS 커널에는 언와인드를 처리할 런타임이 없기 때문에 즉시 중단해야 합니다. 이 설정이 없으면 컴파일 자체가 실패하거나 런타임에 정의되지 않은 동작이 발생할 수 있습니다.
여러분이 이 설정을 사용하면 GDB에서 변수명으로 직접 접근할 수 있고, backtrace가 함수명과 파일 위치를 정확히 표시하며, step 명령이 소스 코드 라인 단위로 동작합니다. 특히 복잡한 제네릭 타입이나 트레이트 객체를 다룰 때 타입 정보가 명확히 보여 디버깅 효율이 크게 향상됩니다.
실전 팁
💡 릴리스 빌드에서도 디버깅이 필요하다면 debug = 2를 유지하세요. 바이너리 크기는 증가하지만 strip 명령으로 나중에 제거할 수 있습니다.
💡 특정 함수만 최적화를 막으려면 #[inline(never)] 또는 #[optimize(speed/size)] 속성을 사용할 수 있습니다.
💡 디버그 빌드가 너무 느리다면 일부 의존성 크레이트만 [profile.dev.package.*]로 최적화하여 균형을 맞추세요.
💡 LLVM의 -C debuginfo 플래그를 직접 제어하려면 RUSTFLAGS 환경변수를 사용할 수 있습니다.
💡 split-debuginfo = "unpacked" 설정을 사용하면 디버그 정보를 별도 파일로 분리하여 빌드 시간과 링크 시간을 단축할 수 있습니다.
3. 브레이크포인트 전략 - 함수, 주소, 조건부 중단점 설정
시작하며
여러분이 커널 부팅 과정에서 특정 함수가 호출되는 순간을 정확히 포착하고 싶거나, 메모리 주소에 특정 값이 쓰이는 시점을 찾고 싶은 적이 있나요? 무작정 실행하다가는 원하는 지점을 놓치거나, 수천 번 반복되는 루프에서 원하는 조건만 찾기 어렵습니다.
이런 문제는 인터럽트 핸들러나 페이지 폴트 처리 같은 비동기적 이벤트를 디버깅할 때 특히 심각합니다. 타이밍에 민감한 코드에서는 어떤 순서로 함수가 호출되었는지, 어떤 값이 전달되었는지 정확히 파악하는 것이 버그 해결의 핵심입니다.
바로 이럴 때 필요한 것이 전략적인 브레이크포인트 설정입니다. 함수명, 메모리 주소, 조건식 등 다양한 방법으로 원하는 지점에서 정확히 실행을 멈출 수 있습니다.
개요
간단히 말해서, 브레이크포인트는 프로그램 실행 중 특정 지점에서 CPU를 멈추게 하는 디버깅 기법입니다. GDB는 하드웨어와 소프트웨어 브레이크포인트를 모두 지원하며, OS 개발에서는 두 방식을 적재적소에 활용해야 합니다.
예를 들어, 페이지 테이블 초기화 함수의 시작점에 브레이크포인트를 설정하면, 해당 함수가 호출되는 순간 레지스터와 스택 상태를 즉시 검사할 수 있습니다. 기존에는 코드 곳곳에 시리얼 출력을 넣어 실행 흐름을 추적했다면, 이제는 브레이크포인트로 원하는 지점에서만 멈추고 모든 상태를 대화형으로 확인할 수 있습니다.
브레이크포인트의 핵심 특징은 첫째, 실행을 방해하지 않고 설정/해제할 수 있다는 점, 둘째, 조건부 중단으로 특정 상황만 포착할 수 있다는 점, 셋째, 워치포인트로 메모리 변경을 감지할 수 있다는 점입니다. 이를 통해 수만 줄의 코드에서도 정확히 원하는 순간을 포착할 수 있습니다.
코드 예제
# GDB 세션 내에서
# 함수명으로 브레이크포인트 설정
(gdb) break kernel_main
Breakpoint 1 at 0x100234: file src/main.rs, line 45.
# 특정 파일의 라인에 설정
(gdb) break src/memory.rs:89
# 메모리 주소에 직접 설정 (심볼이 없을 때)
(gdb) break *0xffff800000100000
# 조건부 브레이크포인트 (변수 값이 특정 조건을 만족할 때만)
(gdb) break allocate_frame if frame_count > 1000
# 워치포인트 (메모리 값이 변경될 때 중단)
(gdb) watch *(int*)0xffff800000201000
# 브레이크포인트 목록 확인
(gdb) info breakpoints
# 브레이크포인트 삭제
(gdb) delete 1
설명
이것이 하는 일: 프로그램이 특정 코드 지점에 도달하거나 메모리 값이 변경될 때 자동으로 실행을 중단하여 그 순간의 상태를 검사할 수 있게 합니다. 첫 번째로, break kernel_main 같은 함수명 기반 브레이크포인트는 디버그 심볼을 활용하여 해당 함수의 진입점 주소를 찾아 INT 3 명령(0xCC)을 삽입합니다.
CPU가 이 명령을 실행하면 디버그 예외가 발생하고, QEMU의 gdbserver가 이를 감지하여 GDB에 신호를 보냅니다. 이렇게 하는 이유는 코드 재컴파일 시 주소가 바뀌어도 함수명은 유지되기 때문입니다.
그 다음으로, 조건부 브레이크포인트는 해당 지점에 도달할 때마다 GDB가 조건식을 평가합니다. 조건이 거짓이면 즉시 실행을 계속하고, 참일 때만 중단합니다.
내부적으로는 매번 표현식을 파싱하고 메모리/레지스터를 읽어 계산하므로, 너무 복잡한 조건은 성능에 영향을 줄 수 있습니다. 마지막으로, watch 명령은 하드웨어 브레이크포인트를 사용하여 특정 메모리 주소를 감시합니다.
x86-64는 DR0-DR7 디버그 레지스터를 통해 최대 4개의 워치포인트를 지원하며, 해당 주소에 쓰기가 발생하면 즉시 디버그 예외가 발생합니다. 이는 "누가 이 변수를 변경했는가?"를 추적할 때 매우 강력한 도구입니다.
여러분이 이 기법들을 사용하면 수백 번 호출되는 함수 중 특정 조건에서만 중단시키거나, 데이터 경쟁이나 메모리 손상이 발생하는 정확한 순간을 포착할 수 있습니다. 특히 페이지 폴트 핸들러나 스케줄러 같은 복잡한 코드를 디버깅할 때 실행 컨텍스트를 완벽히 이해할 수 있어 버그 해결 속도가 비약적으로 향상됩니다.
실전 팁
💡 부팅 초기 단계를 디버깅할 때는 _start 심볼에 브레이크포인트를 설정하여 첫 명령부터 추적하세요.
💡 하드웨어 브레이크포인트는 개수 제한(보통 4개)이 있으므로, ROM이나 읽기 전용 영역에는 소프트웨어 브레이크포인트를 사용하세요.
💡 반복문 안에서 특정 반복만 검사하려면 ignore 명령으로 처음 N번은 건너뛰도록 설정할 수 있습니다.
💡 commands 명령으로 브레이크포인트 도달 시 자동 실행할 GDB 명령들을 지정하면, 반복적인 검사 작업을 자동화할 수 있습니다.
💡 임시 브레이크포인트(tbreak)는 한 번 걸리면 자동으로 삭제되므로, 일회성 검사에 유용합니다.
4. 메모리와 레지스터 검사 - 커널 상태 분석 기법
시작하며
여러분이 페이지 테이블 설정 코드를 작성했는데, 실제로 메모리에 올바른 값이 쓰였는지, CR3 레지스터가 정확한 주소를 가리키는지 확인하고 싶은 적이 있나요? 코드는 문법적으로 완벽해 보이지만, 런타임에 실제 메모리 상태가 예상과 다를 수 있습니다.
이런 문제는 저수준 시스템 프로그래밍에서 빈번하게 발생합니다. 포인터 연산 실수, 잘못된 타입 캐스팅, 컴파일러 최적화의 부작용 등으로 인해 메모리 레이아웃이 설계와 달라질 수 있기 때문입니다.
바로 이럴 때 필요한 것이 GDB의 메모리 검사 및 레지스터 분석 기능입니다. 바이트 단위로 메모리를 덤프하고, 시스템 레지스터를 읽어 하드웨어 상태를 정확히 파악할 수 있습니다.
개요
간단히 말해서, 이 기법은 실행 중인 프로그램의 메모리 내용과 CPU 레지스터 값을 직접 읽고 해석하여 내부 상태를 이해하는 방법입니다. OS 개발에서는 일반 변수뿐만 아니라 페이지 테이블, GDT, IDT 같은 하드웨어 자료구조와 CR0, CR3, RFLAGS 같은 제어 레지스터를 검사해야 합니다.
예를 들어, 가상 메모리가 제대로 동작하지 않을 때 CR3 레지스터의 값과 해당 주소의 페이지 테이블 엔트리를 확인하면 즉시 문제를 찾을 수 있습니다. 기존에는 포트 출력이나 화면 덤프로 제한적인 정보만 얻었다면, 이제는 임의의 메모리 영역을 다양한 형식으로 출력하고, 모든 레지스터를 실시간으로 모니터링할 수 있습니다.
이 기법의 핵심 특징은 첫째, 프로그램 실행을 방해하지 않고 상태를 읽을 수 있다는 점, 둘째, 다양한 형식(16진수, 문자열, 명령어 등)으로 데이터를 해석할 수 있다는 점, 셋째, 계산식을 직접 평가하여 복잡한 값도 확인할 수 있다는 점입니다. 이를 통해 버그의 근본 원인을 빠르게 찾아낼 수 있습니다.
코드 예제
# GDB 세션 내에서
# 모든 범용 레지스터 출력
(gdb) info registers
rax 0x1000 4096
rbx 0x0 0
rip 0x100234 0x100234 <kernel_main>
# 특정 레지스터만 출력
(gdb) print/x $cr3
$1 = 0xffff800000001000
# 메모리 내용을 16진수로 64바이트 덤프
(gdb) x/64xb 0xffff800000001000
# 메모리를 64비트 정수 배열로 해석
(gdb) x/8gx 0xffff800000001000
# 메모리를 명령어(instruction)로 디스어셈블
(gdb) x/10i $rip
# 변수의 메모리 주소와 값 확인
(gdb) print &page_table
$2 = (PageTable *) 0xffff800000002000
(gdb) print *page_table
# 구조체 필드 접근
(gdb) print page_table->entries[0].flags
설명
이것이 하는 일: CPU의 현재 상태와 메모리 내용을 읽어 개발자가 이해할 수 있는 형식으로 변환하여 표시합니다. 첫 번째로, info registers 명령은 QEMU의 gdbserver를 통해 가상 CPU의 모든 레지스터 값을 읽어옵니다.
x86-64의 경우 RAX부터 R15까지의 범용 레지스터, RIP(명령 포인터), RSP(스택 포인터), RFLAGS 등을 모두 확인할 수 있습니다. 이렇게 하는 이유는 함수 인자 전달, 반환값, 조건 플래그 등 실행 컨텍스트 전체를 파악하기 위함입니다.
그 다음으로, x(examine) 명령은 메모리를 다양한 형식으로 해석합니다. x/64xb는 64개의 바이트를 16진수로, x/8gx는 8개의 giant(64비트) 워드를 16진수로 출력합니다.
내부적으로 GDB는 원격 프로토콜을 통해 QEMU에 메모리 읽기 요청을 보내고, 받은 바이트들을 지정된 형식에 맞춰 포맷팅합니다. 이는 페이지 테이블 엔트리나 디스크립터 테이블 같은 바이너리 구조를 분석할 때 필수적입니다.
마지막으로, print 명령은 C 스타일 표현식을 평가합니다. print *page_table은 포인터를 역참조하여 구조체 전체를 출력하고, print/x는 16진수 형식을 강제합니다.
Rust의 복잡한 타입도 디버그 심볼이 있으면 필드 단위로 접근할 수 있어, Vec이나 HashMap 같은 컬렉션 내부도 검사 가능합니다. 여러분이 이 기법들을 사용하면 "왜 페이지 폴트가 발생했는가?"를 CR2(폴트 주소) 레지스터로 즉시 확인하거나, "이 함수에 어떤 인자가 전달되었는가?"를 RDI, RSI 레지스터로 파악할 수 있습니다.
특히 어셈블리와 Rust 코드가 혼재된 부분에서 정확히 어떤 값이 전달되고 있는지 바이트 레벨로 검증할 수 있어, 미묘한 ABI 문제도 해결할 수 있습니다.
실전 팁
💡 x/i $rip로 현재 실행하려는 명령어를 확인하면, 소스 코드와 실제 어셈블리의 관계를 이해하는 데 도움이 됩니다.
💡 세그먼트 레지스터(CS, DS 등)는 info registers로는 보이지 않을 수 있으니, print $cs 형식으로 직접 확인하세요.
💡 메모리 맵을 확인하려면 info proc mappings를 사용하지만, 베어메탈 환경에서는 지원되지 않으므로 직접 페이지 테이블을 추적해야 합니다.
💡 display 명령으로 매 중단마다 자동으로 특정 표현식을 출력하도록 설정하면, 변수 변화를 지속적으로 모니터링할 수 있습니다.
💡 x86-64의 제어 레지스터(CR0, CR3, CR4)는 GDB가 직접 지원하지 않을 수 있으니, monitor info registers 명령으로 QEMU에 직접 요청하세요.
5. 스택 추적과 콜 프레임 분석 - 실행 흐름 역추적
시작하며
여러분이 커널 패닉이 발생했는데, 어떤 함수 호출 경로를 통해 그 지점에 도달했는지 알고 싶은 적이 있나요? 특히 깊게 중첩된 함수 호출이나 인터럽트 핸들러에서 크래시가 발생하면, 단순히 현재 위치만으로는 원인을 파악하기 어렵습니다.
이런 문제는 재귀 함수, 콜백 체인, 비동기 이벤트 처리에서 특히 복잡합니다. "누가 이 함수를 호출했는가?", "어떤 인자가 전달되었는가?"를 모르면 버그의 근본 원인을 찾을 수 없습니다.
바로 이럴 때 필요한 것이 스택 추적(backtrace)과 콜 프레임 분석입니다. GDB는 스택 프레임을 역추적하여 전체 함수 호출 체인을 보여주고, 각 프레임의 지역 변수와 인자를 검사할 수 있게 합니다.
개요
간단히 말해서, 이 기법은 현재 실행 지점에서 역으로 거슬러 올라가며 함수 호출 이력을 재구성하는 방법입니다. x86-64 아키텍처는 RBP(베이스 포인터) 레지스터를 사용한 프레임 포인터 체인을 통해 스택을 추적할 수 있습니다.
각 함수는 호출 시 이전 RBP를 스택에 저장하고, 리턴 주소도 함께 저장하므로 GDB는 이를 따라가며 전체 호출 스택을 재구성합니다. 예를 들어, 메모리 할당 함수 내에서 크래시가 발생했다면, backtrace로 어떤 상위 함수가 잘못된 크기를 전달했는지 즉시 확인할 수 있습니다.
기존에는 각 함수에서 수동으로 로그를 남겨 호출 순서를 추적했다면, 이제는 한 번의 명령으로 전체 호출 스택과 각 프레임의 인자를 확인할 수 있습니다. 이 기법의 핵심 특징은 첫째, 컴파일 시 특별한 준비 없이 프레임 포인터만 있으면 동작한다는 점, 둘째, 각 함수 호출의 컨텍스트를 개별적으로 검사할 수 있다는 점, 셋째, 소스 코드 위치와 함께 표시되어 직관적이라는 점입니다.
이를 통해 복잡한 실행 흐름도 한눈에 파악할 수 있습니다.
코드 예제
# GDB 세션 내에서
# 전체 스택 추적 (backtrace)
(gdb) backtrace
#0 panic_handler () at src/panic.rs:12
#1 0x0000000000100567 in allocate_frame () at src/memory.rs:89
#2 0x00000000001006a2 in map_page (addr=0xffff800000400000) at src/memory.rs:145
#3 0x0000000000100234 in kernel_main () at src/main.rs:56
# 상세 정보 포함 (지역 변수와 인자)
(gdb) backtrace full
# 특정 프레임으로 이동 (0이 현재, 숫자가 클수록 상위 호출자)
(gdb) frame 2
#2 0x00000000001006a2 in map_page (addr=0xffff800000400000)
# 해당 프레임의 지역 변수 출력
(gdb) info locals
frame_addr = 0x200000
flags = 0x3
# 해당 프레임의 인자 출력
(gdb) info args
addr = 0xffff800000400000
# 상위/하위 프레임으로 이동
(gdb) up # 호출자로
(gdb) down # 피호출자로
설명
이것이 하는 일: 현재 스택 포인터와 프레임 포인터를 기반으로 함수 호출 체인을 역추적하고, 각 호출 지점의 컨텍스트를 복원합니다. 첫 번째로, backtrace 명령은 현재 RSP와 RBP 레지스터에서 시작하여 스택 메모리를 읽어나갑니다.
각 프레임에는 이전 RBP 값과 리턴 주소가 저장되어 있으므로, GDB는 이 체인을 따라가며 각 함수의 심볼 정보를 디버그 심볼에서 찾아 함수명과 소스 위치를 표시합니다. 이렇게 하는 이유는 복잡한 호출 경로에서도 정확히 어떤 순서로 함수가 실행되었는지 파악하기 위함입니다.
그 다음으로, frame 명령은 특정 스택 프레임을 GDB의 현재 컨텍스트로 설정합니다. 이렇게 하면 print, info locals, info args 같은 명령들이 해당 프레임의 스코프에서 동작하게 됩니다.
내부적으로 GDB는 DWARF 디버그 정보의 프레임 베이스와 변수 위치 정보를 사용하여, 각 변수가 스택의 어느 오프셋에 있는지 계산하여 값을 읽어옵니다. 마지막으로, backtrace full은 각 프레임의 모든 지역 변수까지 출력합니다.
이는 크래시 직전의 완전한 상태 스냅샷을 제공하므로, 사후 분석(post-mortem debugging)에 매우 유용합니다. 특히 최적화된 코드에서는 일부 변수가 레지스터에만 존재하거나 생략될 수 있지만, GDB는 가능한 한 복원하여 보여줍니다.
여러분이 이 기법을 사용하면 "왜 여기서 널 포인터 역참조가 발생했는가?"라는 질문에 대해, 호출 스택을 거슬러 올라가며 어떤 함수가 널을 전달했는지, 그 함수는 어디서 호출되었는지를 단계별로 추적할 수 있습니다. 특히 인터럽트 컨텍스트에서 발생한 문제는 스택 프레임이 두 개로 분리되어 있을 수 있으므로, 인터럽트 전후의 상태를 모두 확인하여 전체 그림을 파악할 수 있습니다.
실전 팁
💡 backtrace에 이상한 주소나 ??가 나타나면 스택이 손상되었을 가능성이 있으니, 스택 오버플로우나 메모리 손상을 의심하세요.
💡 인라인 함수는 별도 프레임을 생성하지 않으므로 backtrace에 나타나지 않을 수 있습니다. #[inline(never)]로 인라인을 막으면 추적이 쉬워집니다.
💡 인터럽트 핸들러에서 발생한 크래시는 커널 스택과 인터럽트 스택이 분리되어 있을 수 있으므로, info frame으로 스택 포인터를 확인하세요.
💡 재귀 함수에서 스택 오버플로우를 의심할 때는 backtrace | wc -l로 프레임 개수를 세면 재귀 깊이를 즉시 파악할 수 있습니다.
💡 최적화된 코드에서 프레임 포인터가 생략되었다면 -fno-omit-frame-pointer 컴파일 옵션을 추가하여 백트레이스를 복원할 수 있습니다.
6. GDB 스크립트와 자동화 - 반복 작업 효율화
시작하며
여러분이 매번 GDB를 시작할 때마다 같은 브레이크포인트를 설정하고, 같은 메모리 영역을 검사하고, 같은 출력 형식을 지정하는 작업을 반복하고 있나요? 수십 개의 명령을 매번 타이핑하는 것은 비효율적이고 실수하기 쉽습니다.
이런 문제는 특히 복잡한 커널 자료구조를 검사하거나, 특정 패턴의 버그를 재현할 때 심각합니다. 매번 수동으로 레지스터를 확인하고 계산하는 것은 시간 낭비이며, 일관성도 떨어집니다.
바로 이럴 때 필요한 것이 GDB 스크립트와 커스텀 명령입니다. Python이나 GDB 내장 스크립트 언어로 복잡한 검사 로직을 자동화하고, 자주 사용하는 명령 시퀀스를 단축키로 만들 수 있습니다.
개요
간단히 말해서, 이 기법은 GDB의 프로그래밍 인터페이스를 활용하여 반복적인 디버깅 작업을 자동화하고 커스터마이징하는 방법입니다. GDB는 강력한 Python API를 제공하여 메모리 읽기, 레지스터 접근, 심볼 조회 등을 프로그래밍 방식으로 수행할 수 있습니다.
예를 들어, 페이지 테이블 전체를 자동으로 파싱하여 가상-물리 주소 매핑을 표 형식으로 출력하는 커스텀 명령을 만들면, 복잡한 계산을 즉시 수행할 수 있습니다. 기존에는 각 단계를 수동으로 실행하고 결과를 메모해가며 분석했다면, 이제는 한 번의 명령으로 전체 분석을 자동화하고 결과를 구조화된 형식으로 얻을 수 있습니다.
이 기법의 핵심 특징은 첫째, Python의 풍부한 라이브러리를 활용할 수 있다는 점, 둘째, .gdbinit 파일로 프로젝트별 설정을 관리할 수 있다는 점, 셋째, 복잡한 조건 로직과 반복문을 사용할 수 있다는 점입니다. 이를 통해 디버깅 생산성이 몇 배나 향상됩니다.
코드 예제
# .gdbinit 파일 (프로젝트 루트에 생성)
# GDB 시작 시 자동 실행되는 명령들
# QEMU 연결 및 초기 설정
target remote :1234
symbol-file target/x86_64-unknown-none/debug/my_os
# 자주 사용하는 브레이크포인트
break kernel_main
break panic_handler
# 커스텀 명령 정의 (GDB 스크립트 언어)
define show_pt
# 페이지 테이블 엔트리를 읽기 쉽게 출력
set $pte = (unsigned long *)$arg0
printf "PTE: 0x%016lx\n", *$pte
printf " Physical: 0x%012lx\n", (*$pte & 0xffffffffff000)
printf " Present: %d, Writable: %d, User: %d\n", \
(*$pte & 1), ((*$pte >> 1) & 1), ((*$pte >> 2) & 1)
end
# Python 스크립트 임베드
python
import gdb
class DumpPageTable(gdb.Command):
"""페이지 테이블 전체를 파싱하여 출력"""
def __init__(self):
super(DumpPageTable, self).__init__("dump_pt", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
# CR3 레지스터에서 페이지 테이블 주소 읽기
cr3 = int(gdb.parse_and_eval("$cr3"))
pt_addr = cr3 & 0xffffffffff000
# 메모리 읽기
inferior = gdb.selected_inferior()
mem = inferior.read_memory(pt_addr, 4096)
# 512개 엔트리 파싱
for i in range(512):
offset = i * 8
entry = int.from_bytes(mem[offset:offset+8], 'little')
if entry & 1: # Present 비트 체크
print(f"[{i}] 0x{entry:016x} -> 0x{entry & 0xffffffffff000:012x}")
DumpPageTable()
end
설명
이것이 하는 일: GDB를 프로그래머블하게 확장하여 프로젝트 특화된 디버깅 도구를 만들고, 반복적인 작업을 자동화합니다. 첫 번째로, .gdbinit 파일은 GDB 시작 시 자동으로 실행되는 스크립트입니다.
여기에 target remote, symbol-file 같은 연결 설정을 넣으면 매번 타이핑할 필요가 없습니다. 보안상의 이유로 GDB는 기본적으로 현재 디렉토리의 .gdbinit을 무시하므로, ~/.config/gdb/gdbinit에 add-auto-load-safe-path 설정을 추가해야 합니다.
그 다음으로, define 명령으로 GDB 자체 스크립트 언어로 커스텀 명령을 만들 수 있습니다. 이는 간단한 매크로에 적합하며, $arg0으로 인자를 받고 printf, set 등의 GDB 명령을 조합할 수 있습니다.
예제의 show_pt는 페이지 테이블 엔트리 주소를 받아 각 비트 필드를 파싱하여 읽기 쉽게 출력합니다. 마지막으로, Python API는 훨씬 강력한 기능을 제공합니다.
gdb.Command를 상속하여 커스텀 명령을 클래스로 정의하고, gdb.parse_and_eval로 표현식을 평가하며, inferior.read_memory로 메모리를 읽어 Python 객체로 처리할 수 있습니다. 예제의 DumpPageTable은 CR3에서 페이지 테이블 주소를 읽어 전체 엔트리를 반복하며 유효한 매핑만 출력합니다.
이는 수백 줄의 수동 작업을 한 명령으로 대체합니다. 여러분이 이 기법을 사용하면 프로젝트별로 특화된 디버깅 환경을 구축할 수 있고, 팀원들과 .gdbinit을 공유하여 일관된 디버깅 경험을 제공할 수 있습니다.
특히 복잡한 커널 자료구조를 다룰 때 커스텀 pretty printer를 만들어 구조체를 자동으로 파싱하고 시각화하면, 디버깅 효율이 극적으로 향상됩니다.
실전 팁
💡 Python 스크립트는 별도 .py 파일로 작성하고 .gdbinit에서 source 명령으로 불러오면 관리가 쉽습니다.
💡 GDB의 convenience variable($변수명)을 사용하면 복잡한 계산 결과를 저장해두고 재사용할 수 있습니다.
💡 set pagination off 명령으로 긴 출력에서 일시정지를 막을 수 있어, 자동화 스크립트에서 유용합니다.
💡 Python에서 gdb.events를 사용하면 브레이크포인트 도달, 프로그램 종료 등의 이벤트에 자동으로 반응하는 핸들러를 등록할 수 있습니다.
💡 GDB의 logging 기능(set logging on)을 사용하면 모든 출력을 파일로 저장하여 나중에 분석하거나 팀원과 공유할 수 있습니다.
7. TUI 모드와 레이아웃 - 시각적 디버깅 인터페이스
시작하며
여러분이 GDB에서 소스 코드, 어셈블리, 레지스터를 동시에 보고 싶은데, 계속 list, disassemble, info registers를 반복 입력하며 스크롤하는 것이 불편하지 않나요? 명령줄 인터페이스만으로는 실행 흐름과 상태 변화를 한눈에 파악하기 어렵습니다.
이런 문제는 복잡한 알고리즘이나 상태 머신을 디버깅할 때 특히 두드러집니다. 현재 위치를 확인하고, 다음 명령을 예측하고, 레지스터 변화를 추적하는 작업이 분산되어 있으면 전체 흐름을 이해하기 어렵기 때문입니다.
바로 이럴 때 필요한 것이 GDB의 TUI(Text User Interface) 모드입니다. 터미널을 여러 패널로 분할하여 소스, 어셈블리, 레지스터를 동시에 표시하고, 실행에 따라 자동으로 업데이트됩니다.
개요
간단히 말해서, TUI 모드는 GDB를 텍스트 기반 멀티 패널 인터페이스로 전환하여 여러 정보를 동시에 시각화하는 기능입니다. ncurses 라이브러리를 사용하여 터미널 화면을 분할하고, 각 패널에 소스 코드, 어셈블리, 레지스터, 커맨드 창을 배치합니다.
예를 들어, step 명령을 실행하면 소스 창의 현재 라인 하이라이트가 자동으로 이동하고, 레지스터 창의 변경된 값이 강조 표시되어 어떤 상태가 바뀌었는지 즉시 알 수 있습니다. 기존에는 각 정보를 순차적으로 조회하며 머릿속으로 조합했다면, 이제는 모든 정보가 화면에 동시에 표시되어 실행 컨텍스트를 직관적으로 이해할 수 있습니다.
TUI 모드의 핵심 특징은 첫째, 추가 소프트웨어 설치 없이 GDB 내장 기능이라는 점, 둘째, 다양한 레이아웃을 제공하여 상황에 맞게 선택할 수 있다는 점, 셋째, 키보드 단축키로 빠르게 탐색할 수 있다는 점입니다. 이를 통해 디버깅 경험이 훨씬 직관적이고 효율적으로 변합니다.
코드 예제
# GDB 시작 후 TUI 모드 활성화
(gdb) tui enable
# 또는 시작 시 옵션으로
$ rust-gdb -tui target/debug/my_os
# 레이아웃 변경
(gdb) layout src # 소스 코드 + 커맨드
(gdb) layout asm # 어셈블리 + 커맨드
(gdb) layout split # 소스 + 어셈블리 + 커맨드
(gdb) layout regs # 현재 레이아웃 + 레지스터 창 추가
# 포커스 전환 (화살표 키로 스크롤할 창 선택)
(gdb) focus src # 소스 창에 포커스
(gdb) focus cmd # 커맨드 창에 포커스
(gdb) focus next # 다음 창으로
# 화면 갱신 (깨졌을 때)
(gdb) refresh
# TUI 모드 종료
(gdb) tui disable
# .gdbinit에서 자동 활성화
set auto-load safe-path /
tui enable
layout split
focus cmd
설명
이것이 하는 일: 터미널 화면을 여러 윈도우로 분할하고, 각 윈도우에 디버깅 정보를 실시간으로 표시하여 시각적 피드백을 제공합니다. 첫 번째로, tui enable 명령은 GDB를 TUI 모드로 전환하여 화면을 상단 패널(정보 표시)과 하단 패널(명령 입력)로 분할합니다.
ncurses를 사용하여 각 패널의 경계를 그리고, 현재 실행 라인을 화살표나 하이라이트로 표시합니다. 이렇게 하는 이유는 명령을 입력하면서 동시에 소스 코드나 레지스터를 계속 볼 수 있도록 하기 위함입니다.
그 다음으로, layout 명령은 상단 패널의 배치를 변경합니다. layout split은 화면을 삼분할하여 위쪽에 소스, 중간에 어셈블리, 아래에 명령창을 배치합니다.
내부적으로는 각 패널에 대한 업데이트 콜백이 등록되어, step이나 next를 실행할 때마다 RIP 레지스터 값을 읽어 해당하는 소스 라인과 어셈블리 명령을 자동으로 스크롤하고 하이라이트합니다. 마지막으로, focus 명령은 화살표 키 입력을 받을 패널을 선택합니다.
소스 창에 포커스가 있으면 위/아래 화살표로 코드를 스크롤할 수 있고, 커맨드 창에 포커스가 있으면 명령 히스토리를 탐색할 수 있습니다. Ctrl+X A 단축키로 TUI 모드를 토글할 수 있어, 필요에 따라 빠르게 전환 가능합니다.
여러분이 TUI 모드를 사용하면 step 명령을 실행할 때마다 소스 코드의 어떤 라인이 실행되는지, 해당 라인이 어떤 어셈블리로 컴파일되었는지, 어떤 레지스터가 변경되었는지를 모두 동시에 볼 수 있습니다. 특히 인라인 어셈블리와 Rust 코드가 혼재된 부분에서 정확한 매핑을 이해하거나, 루프 최적화가 어떻게 이루어졌는지 확인할 때 매우 유용합니다.
실전 팁
💡 터미널 크기가 작으면 TUI가 깨질 수 있으니, 최소 80x24 이상, 가능하면 120x40 정도로 설정하세요.
💡 Ctrl+L로 화면을 재그리기할 수 있어, 다른 프로그램 출력으로 화면이 오염되었을 때 복구할 수 있습니다.
💡 winheight 명령으로 각 윈도우의 높이를 조절할 수 있어, 소스 창을 크게 하거나 레지스터 창을 줄일 수 있습니다.
💡 layout asm에서 set disassembly-flavor intel로 Intel 문법으로 전환하면 읽기 더 편한 경우가 많습니다.
💡 tmux나 screen 안에서 TUI를 사용할 때는 터미널 타입(TERM 환경변수)이 올바르게 설정되어 있는지 확인하세요.
8. 어셈블리 레벨 디버깅 - 저수준 코드 분석
시작하며
여러분이 인라인 어셈블리 코드나 부트로더를 디버깅할 때, 소스 레벨 디버깅으로는 정확히 무슨 일이 일어나는지 파악하기 어려운 적이 있나요? 특히 컴파일러 최적화가 예상과 다르게 동작하거나, 레지스터 할당이 이상할 때 기계어 레벨에서 확인해야 합니다.
이런 문제는 부팅 초기 단계, CPU 모드 전환, 인터럽트 핸들러 같은 저수준 코드에서 필수적입니다. Rust 소스 코드가 실제로 어떤 명령어로 변환되었는지 모르면, 성능 문제나 미묘한 버그를 해결할 수 없기 때문입니다.
바로 이럴 때 필요한 것이 어셈블리 레벨 디버깅입니다. GDB의 stepi, nexti 명령으로 명령어 단위 실행을 하고, disassemble로 기계어를 분석할 수 있습니다.
개요
간단히 말해서, 이 기법은 소스 코드가 아닌 CPU가 실제로 실행하는 기계어 명령어를 직접 보며 디버깅하는 방법입니다. x86-64 명령어 집합은 매우 복잡하지만, GDB는 이를 사람이 읽을 수 있는 어셈블리 니모닉으로 변환하여 표시합니다.
예를 들어, 페이지 테이블을 CR3에 로드하는 코드에서 정확히 어떤 값이 어떤 순서로 레지스터를 거쳐 제어 레지스터에 쓰이는지를 명령어 단위로 추적할 수 있습니다. 기존에는 objdump로 정적 디스어셈블리를 보며 추측했다면, 이제는 실행 중에 각 명령어를 단계별로 실행하며 레지스터와 플래그 변화를 실시간으로 확인할 수 있습니다.
이 기법의 핵심 특징은 첫째, CPU 동작을 가장 정확하게 이해할 수 있다는 점, 둘째, 컴파일러나 링커의 동작을 검증할 수 있다는 점, 셋째, 타이밍에 민감한 코드를 정밀하게 분석할 수 있다는 점입니다. 이를 통해 최적화나 하드웨어 인터랙션 문제를 근본적으로 해결할 수 있습니다.
코드 예제
# GDB 세션 내에서
# 현재 위치 주변의 어셈블리 디스어셈블
(gdb) disassemble
Dump of assembler code for function kernel_main:
0x0000000000100234 <+0>: push %rbp
0x0000000000100235 <+1>: mov %rsp,%rbp
=> 0x0000000000100238 <+4>: sub $0x20,%rsp
0x000000000010023c <+8>: mov %rdi,-0x8(%rbp)
# 특정 주소 범위 디스어셈블
(gdb) disassemble 0x100234,0x100250
# Intel 문법으로 전환 (개인 선호에 따라)
(gdb) set disassembly-flavor intel
(gdb) disassemble
0x0000000000100238 <+4>: sub rsp,0x20
# 명령어 단위 실행 (stepi는 함수 진입, nexti는 함수 건너뜀)
(gdb) stepi # 다음 명령어 하나 실행
(gdb) si # 축약형
(gdb) nexti # 함수 호출은 완료될 때까지 실행
(gdb) ni # 축약형
# 현재 명령어와 다음 몇 개 보기
(gdb) x/10i $rip
# 레지스터와 플래그 동시 확인
(gdb) display/i $rip # 매 중단마다 현재 명령어 표시
(gdb) display/x $rax # RAX 레지스터도 표시
설명
이것이 하는 일: 바이너리 코드를 어셈블리 언어로 변환하여 표시하고, CPU 명령어 하나하나를 개별적으로 실행하며 상태 변화를 관찰합니다. 첫 번째로, disassemble 명령은 메모리에서 기계어 바이트를 읽어 x86-64 명령어로 디코딩합니다.
GDB는 내장된 디스어셈블러(libopcodes)를 사용하여 가변 길이 명령어를 파싱하고, 각 명령의 오퍼랜드를 해석하여 레지스터명, 메모리 주소, 즉시값 등을 표시합니다. 이렇게 하는 이유는 0x48 0x83 0xec 0x20 같은 바이트 시퀀스를 사람이 이해할 수 있는 sub rsp, 0x20으로 변환하기 위함입니다.
그 다음으로, stepi 명령은 CPU를 정확히 한 명령어만큼 진행시킵니다. 내부적으로 GDB는 QEMU에 단일 스텝 요청을 보내고, QEMU는 하드웨어 단일 스텝 기능(EFLAGS의 TF 비트)을 시뮬레이션하여 다음 명령어 실행 직후 중단합니다.
이는 call 명령을 만나면 함수 내부로 진입하며, ret을 실행하면 호출자로 돌아갑니다. 마지막으로, display 명령은 매 중단 시마다 지정된 표현식을 자동으로 평가하고 출력합니다.
display/i $rip는 현재 실행하려는 명령어를, display/x $rax는 RAX 레지스터 값을 16진수로 계속 표시합니다. 이렇게 하면 명령어를 하나씩 실행하면서 어떤 레지스터가 어떻게 변하는지 시각적으로 추적할 수 있어, 복잡한 레지스터 조작 코드도 쉽게 이해할 수 있습니다.
여러분이 이 기법을 사용하면 Rust의 소유권 시스템이 어셈블리 레벨에서 어떻게 구현되는지, 제네릭 함수가 모노모픽화된 후 어떤 코드로 변환되는지를 정확히 확인할 수 있습니다. 특히 unsafe 블록이나 인라인 어셈블리를 사용할 때, 의도한 대로 컴파일되었는지 검증하고, 성능 크리티컬한 코드의 최적화 여부를 판단할 수 있습니다.
실전 팁
💡 AT&T 문법(기본)과 Intel 문법의 차이는 오퍼랜드 순서입니다. Intel은 dest, src 순서라 더 직관적일 수 있습니다.
💡 finish 명령은 현재 함수의 리턴까지 실행하므로, 어셈블리 레벨에서도 함수 단위로 건너뛸 수 있습니다.
💡 set step-mode on을 사용하면 디버그 심볼이 없는 코드에서도 step이 동작하여 라이브러리 내부까지 추적할 수 있습니다.
💡 objdump -d -S 명령으로 소스와 어셈블리를 인터리브하여 정적으로 볼 수 있어, GDB 세션 전에 미리 분석할 때 유용합니다.
💡 벡터 명령어(SSE, AVX)는 레지스터 표시가 복잡하므로, info all-registers로 XMM/YMM 레지스터를 확인하세요.
9. 코어 덤프 분석 - 사후 디버깅 기법
시작하며
여러분의 OS가 재현하기 어려운 상황에서만 크래시하거나, 프로덕션 환경에서 문제가 발생했을 때 어떻게 디버깅하나요? 라이브 디버깅이 불가능한 경우, 크래시 시점의 상태를 보존하여 나중에 분석하는 것이 필수적입니다.
이런 문제는 타이밍 의존적 버그, 경쟁 조건, 간헐적 하드웨어 오류 같은 상황에서 특히 중요합니다. 크래시가 발생하는 순간을 잡기 위해 계속 디버거를 붙이고 기다릴 수 없기 때문입니다.
바로 이럴 때 필요한 것이 코어 덤프 기법입니다. 크래시 순간의 메모리와 레지스터를 파일로 저장하고, 나중에 GDB로 불러와 마치 라이브 디버깅처럼 분석할 수 있습니다.
개요
간단히 말해서, 코어 덤프는 프로그램이 크래시한 순간의 메모리 스냅샷과 CPU 상태를 파일로 저장하여 사후 분석을 가능하게 하는 기법입니다. QEMU에서는 monitor 명령을 통해 VM의 전체 메모리와 CPU 상태를 덤프할 수 있으며, GDB는 이 덤프 파일을 마치 실행 중인 프로세스처럼 분석할 수 있습니다.
예를 들어, 고객 사이트에서 발생한 커널 패닉의 덤프를 받아 개발 환경에서 스택 추적, 메모리 검사, 변수 확인 등을 모두 수행할 수 있습니다. 기존에는 로그 파일에 의존하여 제한적인 정보만 얻었다면, 이제는 크래시 순간의 완전한 상태를 보존하여 모든 디버깅 기법을 적용할 수 있습니다.
코어 덤프의 핵심 특징은 첫째, 재현 불가능한 버그도 분석할 수 있다는 점, 둘째, 프로덕션 환경을 방해하지 않고 정보를 수집할 수 있다는 점, 셋째, 팀원 간 공유하여 협업 디버깅이 가능하다는 점입니다. 이를 통해 까다로운 버그도 체계적으로 해결할 수 있습니다.
코드 예제
# QEMU 모니터에서 메모리 덤프 생성
# QEMU 실행 시 모니터 접근 설정
qemu-system-x86_64 -monitor stdio ...
# 또는
qemu-system-x86_64 -monitor telnet:127.0.0.1:55555,server,nowait ...
# 모니터 명령 (QEMU 콘솔에서)
(qemu) dump-guest-memory /tmp/crash.dump
# 또는 특정 포맷으로
(qemu) dump-guest-memory -z /tmp/crash.dump.gz
# GDB에서 덤프 분석
$ rust-gdb target/x86_64-unknown-none/debug/my_os
(gdb) target core /tmp/crash.dump
# 또는 QEMU 스냅샷을 변환한 ELF 코어 파일
(gdb) core-file crash.core
# 크래시 시점 분석 (일반 디버깅과 동일)
(gdb) backtrace full
(gdb) info registers
(gdb) x/64xb $rsp
(gdb) frame 3
(gdb) print my_variable
# 스크립트로 자동 분석
(gdb) source analyze_crash.gdb
설명
이것이 하는 일: 시스템 크래시 순간의 전체 메모리 상태와 CPU 레지스터를 파일로 직렬화하고, 나중에 디버거로 역직렬화하여 그 시점의 상태를 재현합니다. 첫 번째로, dump-guest-memory 명령은 QEMU가 관리하는 게스트 물리 메모리 전체를 파일로 씁니다.
여기에는 커널 코드, 데이터, 스택, 페이지 테이블, 심지어 비디오 메모리까지 모든 RAM 내용이 포함됩니다. CPU 레지스터 상태도 함께 저장되어, RIP가 어느 주소를 가리키고 있었는지, 스택 포인터가 어디였는지 등이 보존됩니다.
이렇게 하는 이유는 나중에 이 시점으로 "시간 여행"하여 분석하기 위함입니다. 그 다음으로, GDB의 target core 명령은 이 덤프 파일을 파싱하여 내부 구조를 재구성합니다.
ELF 코어 덤프 형식은 프로그램 헤더에 메모리 세그먼트 정보를, 노트 섹션에 레지스터 상태를 저장합니다. GDB는 이를 읽어 마치 프로세스가 일시정지된 것처럼 메모리 주소 공간을 복원하고, 심볼 파일과 결합하여 변수명으로 접근할 수 있게 합니다.
마지막으로, 코어 덤프는 읽기 전용이므로 continue나 step 같은 실행 명령은 사용할 수 없지만, backtrace, print, x 같은 모든 검사 명령은 정상 동작합니다. 여러 덤프를 비교하여 패턴을 찾거나, Python 스크립트로 자동 분석하여 공통 원인을 추출할 수도 있습니다.
특히 메모리 손상 버그는 손상이 발생한 시점과 크래시 시점이 다를 수 있으므로, 여러 시점의 덤프를 비교하면 원인을 찾을 수 있습니다. 여러분이 이 기법을 사용하면 QA 팀이나 베타 테스터로부터 덤프 파일만 받아도 로컬에서 완전한 사후 분석을 수행할 수 있습니다.
특히 간헐적으로 발생하는 버그는 여러 덤프를 수집하여 공통점을 찾는 방식으로 접근할 수 있으며, 덤프를 버전 관리 시스템에 저장하여 회귀 테스트에도 활용할 수 있습니다.
실전 팁
💡 덤프 파일은 메모리 크기만큼 클 수 있으므로, -z 옵션으로 압축하면 저장 공간과 전송 시간을 크게 절약할 수 있습니다.
💡 QEMU의 savevm/loadvm 명령으로 VM 스냅샷을 만들면, 코어 덤프보다 더 완전한 상태(디스크, 네트워크 등)를 보존할 수 있습니다.
💡 panic handler에서 자동으로 덤프를 생성하도록 구현하면, 크래시 시마다 수동 개입 없이 분석 자료를 확보할 수 있습니다.
💡 GDB 스크립트로 덤프 파일들을 일괄 분석하고, 공통 스택 패턴을 추출하여 버그 우선순위를 정하는 자동화 시스템을 구축할 수 있습니다.
💡 DWARF 디버그 정보가 스트립된 바이너리의 덤프를 받았다면, symbol-file 명령으로 별도의 심볼 파일을 로드하여 분석할 수 있습니다.
10. 하드웨어 디버그 기능 활용 - 워치포인트와 하드웨어 브레이크포인트
시작하며
여러분이 특정 메모리 주소가 언제 어디서 변경되는지 추적하고 싶은데, 해당 주소를 쓰는 코드가 수십 곳에 산재되어 있다면 어떻게 하시겠습니까? 모든 곳에 브레이크포인트를 설정하는 것은 비현실적이고, 소프트웨어 브레이크포인트는 ROM에는 사용할 수 없습니다.
이런 문제는 데이터 경쟁, 메모리 손상, 레지스터 덮어쓰기 같은 버그를 추적할 때 흔히 발생합니다. "누가 이 변수를 변경했는가?"는 소프트웨어적으로 추적하기 매우 어려운 질문이기 때문입니다.
바로 이럴 때 필요한 것이 CPU의 하드웨어 디버그 기능입니다. x86-64의 디버그 레지스터를 활용하면 메모리 접근을 하드웨어 레벨에서 감시하여, 쓰기나 읽기가 발생하는 순간 자동으로 중단시킬 수 있습니다.
개요
간단히 말해서, 하드웨어 디버그 기능은 CPU의 전용 레지스터를 사용하여 메모리 접근이나 명령 실행을 감시하고, 조건 만족 시 하드웨어 예외를 발생시켜 디버거에 제어를 넘기는 기법입니다. x86-64는 DR0부터 DR7까지의 디버그 레지스터를 제공하며, 이를 통해 최대 4개의 메모리 주소를 동시에 감시할 수 있습니다.
예를 들어, 페이지 테이블 엔트리가 예상치 못하게 변경되는 문제를 추적할 때, 해당 엔트리의 주소에 워치포인트를 설정하면 어떤 코드가 쓰기를 시도하는지 즉시 포착할 수 있습니다. 기존에는 의심되는 코드마다 브레이크포인트를 설정하고 메모리 값을 수동으로 비교했다면, 이제는 하드웨어가 자동으로 모든 쓰기를 감시하여 정확한 순간을 알려줍니다.
하드웨어 디버그의 핵심 특징은 첫째, 코드 수정 없이 메모리 접근을 감시한다는 점, 둘째, 성능 오버헤드가 거의 없다는 점(하드웨어가 처리), 셋째, 읽기 전용 메모리나 실행 중인 코드도 감시할 수 있다는 점입니다. 이를 통해 찾기 어려운 메모리 버그를 빠르게 해결할 수 있습니다.
코드 예제
# GDB 세션 내에서
# 메모리 쓰기 감시 (4바이트)
(gdb) watch *(int*)0xffff800000201000
Hardware watchpoint 1: *(int*)0xffff800000201000
# 메모리 읽기 감시 (rwatch = read watch)
(gdb) rwatch *(long*)0xffff800000202000
Hardware read watchpoint 2: *(long*)0xffff800000202000
# 읽기와 쓰기 모두 감시 (awatch = access watch)
(gdb) awatch my_variable
Hardware access watchpoint 3: my_variable
# 하드웨어 브레이크포인트 (ROM이나 실행 중인 코드에)
(gdb) hbreak *0x100000
Hardware assisted breakpoint 4 at 0x100000
# 워치포인트 목록 확인
(gdb) info watchpoints
Num Type Disp Enb Address What
1 hw watchpoint keep y *(int*)0xffff800000201000
2 read watchpoint keep y *(long*)0xffff800000202000
# 워치포인트 삭제
(gdb) delete 1
# 실행 시 워치포인트가 걸리면 자동으로 중단되고 위치 표시
(gdb) continue
Hardware watchpoint 1: *(int*)0xffff800000201000
Old value = 0
New value = 4096
modify_page_table () at src/memory.rs:234
설명
이것이 하는 일: CPU의 DR0-DR3 레지스터에 감시할 메모리 주소를 설정하고, DR7 레지스터에 조건(읽기/쓰기/실행)을 설정하여 해당 접근 시 디버그 예외(#DB)를 발생시킵니다. 첫 번째로, watch 명령은 GDB가 QEMU에 하드웨어 워치포인트 설정을 요청합니다.
QEMU는 가상 CPU의 DR0 레지스터에 메모리 주소를, DR7 레지스터에 감시 조건(쓰기, 4바이트)을 설정합니다. CPU는 메모리 쓰기가 발생할 때마다 DR0-DR3의 주소와 비교하고, 일치하면 디버그 예외를 발생시켜 실행을 중단합니다.
이렇게 하는 이유는 소프트웨어가 개입하지 않고도 하드웨어가 모든 메모리 접근을 자동으로 감시하게 하기 위함입니다. 그 다음으로, rwatch는 읽기 접근을 감시합니다.
이는 "누가 이 값을 읽고 있는가?"를 추적할 때 유용하며, 보안 크리티컬한 데이터(암호화 키 등)의 접근을 모니터링할 때 사용됩니다. awatch는 읽기와 쓰기 모두를 감시하여, 해당 메모리에 대한 모든 접근을 포착합니다.
마지막으로, hbreak는 명령 실행을 감시합니다. 소프트웨어 브레이크포인트는 INT 3 명령(0xCC)을 코드에 삽입하는 방식이라 ROM에는 사용할 수 없지만, 하드웨어 브레이크포인트는 DR0-DR3에 명령 주소를 설정하고 실행 조건으로 설정하면 코드를 변경하지 않고도 브레이크포인트를 걸 수 있습니다.
다만 개수 제한(4개)이 있으므로 전략적으로 사용해야 합니다. 여러분이 이 기법을 사용하면 멀티스레드 환경에서 특정 변수가 경쟁 조건으로 손상되는 순간을 포착하거나, DMA 컨트롤러가 메모리를 덮어쓰는 시점을 정확히 찾을 수 있습니다.
특히 버퍼 오버플로우로 인해 스택이나 힙이 손상되는 문제는, 손상되는 메모리 주소에 워치포인트를 설정하여 범인 코드를 즉시 식별할 수 있어 디버깅 시간을 획기적으로 단축시킵니다.
실전 팁
💡 하드웨어 워치포인트는 개수 제한(보통 4개)이 있으므로, 가장 의심되는 주소에만 사용하고 나머지는 소프트웨어 방식으로 추적하세요.
💡 큰 구조체 전체를 감시하려면 watch -l my_struct 형식으로 location을 사용하여 전체 범위를 커버할 수 있습니다.
💡 페이지 크기 단위로 메모리 보호를 설정하려면 CPU의 페이지 테이블 권한 비트를 활용하는 것이 더 효율적일 수 있습니다.
💡 QEMU가 하드웨어 워치포인트를 지원하지 않는 아키텍처의 경우, 소프트웨어 워치포인트로 자동 전환되지만 성능이 크게 저하될 수 있습니다.
💡 워치포인트가 너무 자주 걸려 불편하다면, condition 명령으로 추가 조건을 설정하여 특정 상황에서만 중단되도록 필터링할 수 있습니다.
11. 멀티코어 디버깅 - SMP 환경에서의 디버깅 전략
시작하며
여러분이 멀티코어 OS를 개발하면서 CPU 간 동기화 문제나 경쟁 조건을 디버깅하려고 할 때, 어떤 CPU가 어떤 코드를 실행 중인지 파악하기 어려웠던 적이 있나요? 단일 코어 디버깅과 달리, 여러 CPU가 동시에 실행되면 디버깅 복잡도가 기하급수적으로 증가합니다.
이런 문제는 스핀락, IPI(Inter-Processor Interrupt), CPU 로컬 데이터 같은 SMP 특화 기능을 다룰 때 필수적으로 마주치게 됩니다. 한 CPU에서 브레이크포인트가 걸렸을 때 다른 CPU들은 계속 실행되고 있어서 전체 시스템 상태를 파악하기 어렵기 때문입니다.
바로 이럴 때 필요한 것이 GDB의 멀티스레드/멀티코어 디버깅 기능입니다. 각 CPU를 독립적인 스레드로 취급하여 개별적으로 제어하고, 모든 CPU를 동시에 중단시켜 일관된 스냅샷을 얻을 수 있습니다.
개요
간단히 말해서, 멀티코어 디버깅은 여러 CPU 코어를 각각 독립적으로 제어하거나 동시에 제어하여, SMP 시스템의 복잡한 상호작용을 분석하는 기법입니다. QEMU는 각 가상 CPU를 별도의 스레드로 시뮬레이션하며, GDB는 이를 각각 다른 스레드로 인식합니다.
예를 들어, CPU 0이 스핀락을 기다리고 있고 CPU 1이 크리티컬 섹션을 실행 중일 때, 각 CPU의 스택과 레지스터를 개별적으로 검사하여 데드락 원인을 파악할 수 있습니다. 기존에는 한 CPU에서 중단하면 다른 CPU들은 계속 실행되어 상태가 계속 변했다면, 이제는 모든 CPU를 동시에 멈춰 일관된 전체 시스템 상태를 분석할 수 있습니다.
멀티코어 디버깅의 핵심 특징은 첫째, 각 CPU의 실행 컨텍스트를 독립적으로 검사할 수 있다는 점, 둘째, CPU 간 동기화 문제를 시각적으로 파악할 수 있다는 점, 셋째, 특정 CPU만 선택적으로 실행하거나 중단할 수 있다는 점입니다. 이를 통해 복잡한 병렬 처리 버그도 체계적으로 해결할 수 있습니다.
코드 예제
# QEMU를 멀티코어로 실행
qemu-system-x86_64 -smp 4 -s -S ... # 4개 CPU 코어
# GDB 세션 내에서
# 모든 스레드(CPU) 목록 확인
(gdb) info threads
Id Target Id Frame
* 1 Thread 1 (CPU#0) kernel_main () at src/main.rs:56
2 Thread 2 (CPU#1) ap_main () at src/smp.rs:23
3 Thread 3 (CPU#2) idle_loop () at src/smp.rs:45
4 Thread 4 (CPU#3) idle_loop () at src/smp.rs:45
# 특정 스레드(CPU)로 전환
(gdb) thread 2
[Switching to thread 2 (Thread 2)]
#0 ap_main () at src/smp.rs:23
# 현재 선택된 CPU의 상태 확인
(gdb) info registers
(gdb) backtrace
# 모든 CPU에 동일한 명령 실행
(gdb) thread apply all backtrace
(gdb) thread apply all info registers
# 특정 CPU들에만 명령 실행
(gdb) thread apply 1-2 backtrace
# 브레이크포인트를 특정 CPU에만 적용
(gdb) break smp_lock if $_thread == 2
# 스케줄러 락 모드 설정 (한 CPU 중단 시 모든 CPU 중단)
(gdb) set scheduler-locking on
(gdb) set scheduler-locking off # 다른 CPU 계속 실행
(gdb) set scheduler-locking step # step 시에만 모든 CPU 중단
설명
이것이 하는 일: QEMU의 각 가상 CPU를 GDB 스레드로 매핑하고, 개별 또는 집단으로 제어하여 멀티코어 시스템의 병렬 실행 상태를 분석합니다. 첫 번째로, info threads 명령은 QEMU가 관리하는 모든 vCPU의 현재 상태를 표시합니다.
각 CPU는 독립적인 레지스터 세트(RIP, RSP, 범용 레지스터 등)를 가지므로, GDB는 각각을 별도의 실행 컨텍스트로 취급합니다. 별표(*)는 현재 선택된 CPU를 의미하며, 이 CPU의 컨텍스트에서 print, backtrace 등의 명령이 동작합니다.
이렇게 하는 이유는 각 CPU가 무엇을 하고 있는지 한눈에 파악하기 위함입니다. 그 다음으로, thread 명령은 GDB의 현재 컨텍스트를 다른 CPU로 전환합니다.
전환 후에는 해당 CPU의 스택, 레지스터, 지역 변수를 검사할 수 있습니다. 내부적으로 GDB는 QEMU에 해당 vCPU의 레지스터 읽기 요청을 보내고, 응답받은 값으로 컨텍스트를 업데이트합니다.
이를 통해 CPU 0은 락을 기다리고 있고, CPU 1은 락을 소유한 채 크래시했다는 식의 분석이 가능합니다. 마지막으로, scheduler-locking 설정은 매우 중요합니다.
기본값(off)에서는 한 CPU에서 브레이크포인트가 걸려도 다른 CPU들은 계속 실행되므로, 검사하는 동안 시스템 상태가 변할 수 있습니다. on으로 설정하면 한 CPU가 중단될 때 모든 CPU가 함께 중단되어 일관된 스냅샷을 얻을 수 있습니다.
step 모드는 단계 실행 시에만 다른 CPU를 중단하므로, 타이밍 의존적 버그를 재현할 때 유용합니다. 여러분이 이 기법을 사용하면 CPU 0에서 스핀락을 획득하려고 무한 대기하는 동안 CPU 1이 무엇을 하고 있는지, CPU 2는 왜 IPI에 응답하지 않는지를 정확히 파악할 수 있습니다.
특히 경쟁 조건이나 데드락은 모든 CPU의 상태를 동시에 보지 않으면 원인을 찾기 어려운데, thread apply all backtrace로 전체 CPU의 스택을 한 번에 확인하여 잘못된 락 순서나 누락된 락 해제를 즉시 발견할 수 있습니다.
실전 팁
💡 CPU 로컬 변수를 검사할 때는 각 CPU마다 다른 값을 가지므로, 반드시 올바른 스레드를 선택한 후 검사하세요.
💡 TLB shootdown이나 IPI 같은 CPU 간 통신을 디버깅할 때는 양쪽 CPU를 모두 브레이크포인트로 잡아 순서를 확인하세요.
💡 scheduler-locking on 상태에서 continue하면 현재 CPU만 실행되므로, 타임아웃이나 다른 CPU의 응답을 기다리는 코드는 무한 대기할 수 있습니다.
💡 QEMU의 -smp 옵션으로 코어 수를 조절하여, 문제가 특정 코어 수에서만 발생하는지 확인할 수 있습니다.
💡 CPU 친화성(affinity) 버그를 디버깅할 때는 info threads의 CPU 번호와 코드에서 읽은 APIC ID가 일치하는지 확인하세요.
12. 성능 분석과 프로파일링 - GDB를 활용한 병목 지점 찾기
시작하며
여러분의 OS가 기능적으로는 정상 동작하지만 예상보다 훨씬 느리거나, 특정 작업에서 지연이 발생하는데 어디서 시간을 소비하는지 모르는 적이 있나요? 성능 문제는 기능 버그와 달리 명확한 증상이 없어서 찾기 어렵습니다.
이런 문제는 불필요한 메모리 복사, 비효율적인 알고리즘, 과도한 동기화 같은 다양한 원인으로 발생할 수 있습니다. 단순히 코드를 읽는 것만으로는 실제 실행 시간 분포를 알 수 없기 때문입니다.
바로 이럴 때 필요한 것이 GDB를 활용한 수동 프로파일링과 샘플링 기법입니다. 실행 중 무작위로 중단하여 어떤 함수에서 시간을 보내는지 통계적으로 분석하거나, 특정 구간의 실행 횟수를 측정할 수 있습니다.
개요
간단히 말해서, GDB 프로파일링은 프로그램 실행을 반복적으로 샘플링하거나 계측하여 시간이 소비되는 핫스팟을 찾아내는 기법입니다. 전문 프로파일러처럼 정교하지는 않지만, 추가 도구 없이도 GDB 스크립트로 기본적인 프로파일링을 수행할 수 있습니다.
예를 들어, 1초 동안 100번 샘플링하여 어떤 함수가 가장 많이 나타나는지 세면, 그 함수가 CPU 시간을 가장 많이 소비한다는 것을 알 수 있습니다. 기존에는 printf로 타임스탬프를 찍어 수동으로 계산했다면, 이제는 자동화된 스크립트로 통계를 수집하고 핫스팟을 시각화할 수 있습니다.
이 기법의 핵심 특징은 첫째, 코드 수정 없이 프로파일링할 수 있다는 점, 둘째, 특정 함수나 코드 섹션에 집중할 수 있다는 점, 셋째, 결과를 Python으로 분석하여 그래프나 리포트를 생성할 수 있다는 점입니다. 이를 통해 성능 병목을 과학적으로 식별하고 최적화 효과를 정량적으로 측정할 수 있습니다.
코드 예제
# GDB Python 스크립트로 간단한 프로파일러 구현
# profile.py
import gdb
import time
from collections import Counter
class SimpleProfiler(gdb.Command):
"""샘플링 기반 프로파일러"""
def __init__(self):
super(SimpleProfiler, self).__init__("profile", gdb.COMMAND_USER)
def invoke(self, arg, from_tty):
args = arg.split()
duration = int(args[0]) if args else 10 # 기본 10초
samples = int(args[1]) if len(args) > 1 else 100 # 기본 100 샘플
print(f"Profiling for {duration} seconds with {samples} samples...")
sample_data = Counter()
interval = duration / samples
for i in range(samples):
# 프로그램 실행
gdb.execute("continue &", to_string=True)
time.sleep(interval)
# 중단
gdb.execute("interrupt", to_string=True)
# 현재 위치 기록
frame = gdb.selected_frame()
func = frame.name()
sample_data[func] += 1
# 결과 출력 (상위 10개)
print("\n=== Profiling Results ===")
print(f"{'Function':<40} {'Samples':<10} {'Percentage'}")
print("-" * 60)
for func, count in sample_data.most_common(10):
percentage = (count / samples) * 100
print(f"{func:<40} {count:<10} {percentage:.1f}%")
SimpleProfiler()
# GDB에서 사용
(gdb) source profile.py
(gdb) profile 10 100 # 10초 동안 100번 샘플링
# 또는 간단히 브레이크포인트로 실행 횟수 측정
(gdb) break allocate_frame
(gdb) commands
silent
set $alloc_count = $alloc_count + 1
continue
end
(gdb) set $alloc_count = 0
(gdb) continue
# ... 작업 수행 후
(gdb) print $alloc_count
설명
이것이 하는 일: 일정 간격으로 프로그램을 중단하여 현재 실행 중인 함수를 기록하고, 통계를 내어 가장 자주 나타나는 함수를 핫스팟으로 식별합니다. 첫 번째로, 샘플링 프로파일러는 통계적 접근법을 사용합니다.
일정 간격(예: 100ms)마다 프로그램을 중단하고 RIP 레지스터의 값에서 현재 실행 중인 함수를 역추적합니다. 충분히 많은 샘플(100개 이상)을 수집하면 중심극한정리에 의해, 각 함수가 샘플에 나타나는 비율이 실제 CPU 시간 소비 비율에 근사합니다.
이렇게 하는 이유는 모든 함수 호출을 추적하는 것(계측)보다 오버헤드가 훨씬 작기 때문입니다. 그 다음으로, Python의 Counter 클래스로 함수명을 카운팅합니다.
gdb.selected_frame().name()은 현재 스택의 최상위 프레임, 즉 실제 실행 중인 함수를 반환합니다. 이를 반복하여 딕셔너리에 누적하고, most_common()으로 상위 핫스팟을 추출합니다.
예를 들어, allocate_frame이 50% 샘플에 나타났다면, 전체 CPU 시간의 약 50%를 그 함수에서 소비한다고 추정할 수 있습니다. 마지막으로, 브레이크포인트 commands 기능으로 실행 횟수를 측정할 수도 있습니다.
silent 명령으로 브레이크포인트 도달 시 메시지를 억제하고, 카운터 변수를 증가시킨 후 자동으로 continue합니다. 이는 "이 함수가 몇 번 호출되었는가?"를 정확히 세는 데 유용하며, 예상보다 많이 호출되는 함수를 찾아 불필요한 호출을 제거하는 최적화로 이어질 수 있습니다.
여러분이 이 기법을 사용하면 "왜 부팅이 느린가?"라는 질문에 대해 "page_table_init이 CPU 시간의 40%를 소비하고 있다"는 구체적인 답을 얻을 수 있습니다. 그 후 해당 함수 내부를 더 세밀하게 샘플링하거나, 알고리즘을 개선하고 다시 측정하여 최적화 효과를 정량적으로 검증할 수 있습니다.
특히 I/O 대기와 CPU 집약적 작업을 구분하여 최적화 우선순위를 정하는 데 매우 유용합니다.
실전 팁
💡 샘플 간격이 너무 짧으면 오버헤드가 커지고, 너무 길면 정확도가 떨어지므로 50-200ms 정도가 적당합니다.
💡 재귀 함수나 인라인 함수는 백트레이스 전체를 기록하여 호출 그래프를 만들면 더 정확한 분석이 가능합니다.
💡 perf나 SystemTap 같은 전문 도구를 사용할 수 없는 베어메탈 환경에서 GDB 프로파일링이 유일한 선택일 수 있습니다.
💡 타이머 인터럽트를 활용하여 커널 내부에 자체 프로파일러를 구현하고, GDB로 수집된 데이터를 읽어 분석할 수도 있습니다.
💡 최적화 전후로 동일한 워크로드를 프로파일링하여 결과를 비교하면, 최적화가 실제로 효과가 있었는지 객관적으로 검증할 수 있습니다.