QEMU와 GDB로 Linux 커널 디버깅하기

2025. 12. 9. 21:48Linux/리눅스 커널 모듈

Claud AI로 정리한 저장용 내용입니다. (고마워요 클로드 선생님)

목차

  1. 환경 준비
  2. 커널 빌드
  3. initramfs 생성
  4. QEMU로 커널 실행
  5. GDB 디버깅
  6. 실전 디버깅 팁
  7. 문제 해결

환경 준비

필수 패키지 설치

sudo apt-get update
sudo apt-get install -y \
    qemu-system-x86 \
    gdb \
    busybox-static \
    build-essential \
    flex \
    bison \
    libssl-dev \
    libelf-dev

커널 소스 다운로드

wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.10.8.tar.xz
tar -xf linux-6.10.8.tar.xz
cd linux-6.10.8

커널 빌드

디버깅을 위한 커널 설정

GDB로 제대로 디버깅하려면 디버그 심볼이 포함된 커널을 빌드해야 합니다.

make menuconfig

다음 옵션들을 활성화하세요:

Kernel hacking --->
    Compile-time checks and compiler options --->
        [*] Compile the kernel with debug info (CONFIG_DEBUG_INFO=y)
        [*] Provide GDB scripts for kernel debugging (CONFIG_GDB_SCRIPTS=y)
        [*] Compile the kernel with frame pointers (CONFIG_FRAME_POINTER=y)
    
    Generic Kernel Debugging Instruments --->
        [*] KGDB: kernel debugger (CONFIG_KGDB=y) (선택사항)

또는 간단하게:

# 기본 설정 사용
make defconfig

# 디버그 옵션 추가
scripts/config --enable DEBUG_INFO
scripts/config --enable DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT
scripts/config --enable GDB_SCRIPTS
scripts/config --enable FRAME_POINTER
scripts/config --disable DEBUG_INFO_REDUCED

커널 컴파일

make -j$(nproc)

컴파일이 완료되면 다음 파일들이 생성됩니다:

  • vmlinux: 디버그 심볼이 포함된 ELF 파일 (GDB용)
  • arch/x86/boot/bzImage: 부팅 가능한 압축 커널 이미지 (QEMU용)

initramfs 생성

커널이 부팅되면 루트 파일시스템이 필요합니다. 간단한 initramfs를 만들어봅시다.

# initramfs 디렉토리 구조 생성
mkdir -p initramfs/{bin,sbin,etc,proc,sys,usr/bin,usr/sbin}
cd initramfs

# busybox 복사
cp /bin/busybox bin/

# init 스크립트 생성
cat > init << 'EOF'
#!/bin/busybox sh

# busybox 명령어 링크 생성
/bin/busybox --install -s

# 기본 파일시스템 마운트
mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs none /dev

# 환경 설정
export PATH=/bin:/sbin:/usr/bin:/usr/sbin

# 메시지 출력
echo "Welcome to minimal Linux system!"
echo "Type 'help' for available commands"

# 쉘 실행
exec /bin/sh
EOF

chmod +x init

# initramfs 이미지 생성
find . | cpio -o -H newc | gzip > ../initramfs.img
cd ..

QEMU로 커널 실행

기본 실행

qemu-system-x86_64 \
  -kernel arch/x86/boot/bzImage \
  -initrd initramfs.img \
  -append "console=ttyS0 nokaslr" \
  -nographic

옵션 설명:

  • -kernel: 부팅할 커널 이미지
  • -initrd: 초기 램디스크
  • -append: 커널 부트 파라미터
    • console=ttyS0: 시리얼 콘솔 사용
    • nokaslr: KASLR(커널 주소 랜덤화) 비활성화 (디버깅 용이)
  • -nographic: 그래픽 창 없이 터미널에서 실행

네트워크 포함 실행

qemu-system-x86_64 \
  -kernel arch/x86/boot/bzImage \
  -initrd initramfs.img \
  -append "console=ttyS0 nokaslr" \
  -nographic \
  -netdev user,id=net0 \
  -device e1000,netdev=net0

네트워크가 추가되면 QEMU 안에서:

# 네트워크 인터페이스 확인
ip link

# 인터페이스 활성화
ip link set eth0 up
ip addr add 10.0.2.15/24 dev eth0

# 테스트
ping 10.0.2.2

QEMU 종료 방법

QEMU는 일반적인 Ctrl+C로 종료되지 않습니다. 다음 방법을 사용하세요:

방법 1: 키보드 단축키

Ctrl+A를 누른 후 X 입력

방법 2: QEMU 모니터

Ctrl+A를 누른 후 C 입력 (모니터 모드 진입)
(qemu) quit

기타 유용한 단축키:

  • Ctrl+A H: 도움말
  • Ctrl+A S: 화면 캡처
  • Ctrl+A C: 모니터와 콘솔 간 전환

GDB 디버깅

GDB 서버 모드로 QEMU 실행

디버깅을 위해 QEMU를 GDB 서버 모드로 실행합니다.

qemu-system-x86_64 \
  -kernel arch/x86/boot/bzImage \
  -initrd initramfs.img \
  -append "console=ttyS0 nokaslr" \
  -nographic \
  -s \     # GDB 서버를 포트 1234에서 시작
  -S       # 시작 시 CPU를 일시정지 (선택사항)

옵션 설명:

  • -s: GDB 서버를 localhost:1234에서 시작
  • -S: CPU를 일시정지 상태로 시작 (GDB 연결 후 수동으로 실행)

GDB 연결

새 터미널을 열어서:

cd ~/linux-6.10.8
gdb vmlinux

GDB에서:

# QEMU에 연결
(gdb) target remote :1234
Remote debugging using :1234
0x000000000000fff0 in ?? ()

# 브레이크포인트 설정
(gdb) break start_kernel
Breakpoint 1 at 0xffffffff82b4e320: file init/main.c, line 879.

# 실행 계속
(gdb) continue
Continuing.

Breakpoint 1, start_kernel () at init/main.c:879
879     {

TUI 모드로 소스 코드 보기

GDB의 TUI(Text User Interface) 모드를 사용하면 소스 코드를 보면서 디버깅할 수 있습니다.

# TUI 모드 활성화
(gdb) layout src        # 소스 코드만
(gdb) layout split      # 소스 + 어셈블리
(gdb) layout regs       # 레지스터 + 소스

# TUI 모드 비활성화
(gdb) tui disable

# TUI 모드 토글
Ctrl+X A

TUI 모드 단축키:

  • Ctrl+X A: TUI 모드 토글
  • Ctrl+X 1: 단일 창
  • Ctrl+X 2: 분할 창
  • Ctrl+L: 화면 새로고침
  • 화살표 키: 소스 코드 스크롤

브레이크포인트 설정

# 함수 이름으로 설정
(gdb) break start_kernel
(gdb) break do_fork

# 파일:라인번호로 설정
(gdb) break init/main.c:879
(gdb) break net/core/dev.c:4550

# 특정 조건에서만 멈춤
(gdb) break netif_receive_skb if skb->len > 1500

# 브레이크포인트 목록 확인
(gdb) info breakpoints
(gdb) info break
(gdb) i b              # 축약형

# 브레이크포인트 삭제
(gdb) delete 1         # 1번 삭제
(gdb) delete 1 2 3     # 여러 개 삭제
(gdb) delete           # 모두 삭제

# 브레이크포인트 비활성화/활성화
(gdb) disable 1        # 1번 비활성화
(gdb) enable 1         # 1번 활성화

실행 제어

# 계속 실행
(gdb) continue
(gdb) c                # 축약형

# 한 줄 실행 (함수 내부로 들어가지 않음)
(gdb) next
(gdb) n                # 축약형

# 한 줄 실행 (함수 내부로 들어감)
(gdb) step
(gdb) s                # 축약형

# 현재 함수 끝까지 실행
(gdb) finish
(gdb) fin              # 축약형

# 특정 라인까지 실행
(gdb) until 100        # 100번 라인까지
(gdb) u 100            # 축약형

변수 및 메모리 확인

# 변수 값 출력
(gdb) print cpu
(gdb) p cpu            # 축약형

# 다양한 형식으로 출력
(gdb) print/x hash     # 16진수
(gdb) print/d hash     # 10진수
(gdb) print/t hash     # 2진수
(gdb) print/c hash     # 문자

# 포인터가 가리키는 값
(gdb) print *ptr
(gdb) print ptr->member

# 구조체 전체 출력
(gdb) print *skb
(gdb) print *(struct sk_buff *)skb

# 배열 출력
(gdb) print array[0]@10    # 10개 원소 출력

# 자동으로 값 표시 (매 스텝마다)
(gdb) display cpu
(gdb) display min_util

# 메모리 직접 확인
(gdb) x/10x 0xffff888000000000    # 16진수 10개
(gdb) x/s 0xffff888000000000      # 문자열
(gdb) x/10i $rip                  # 명령어 10개

스택 추적 및 프레임

# 콜 스택 확인
(gdb) backtrace
(gdb) bt               # 축약형
(gdb) bt full          # 로컬 변수 포함

# 특정 프레임으로 이동
(gdb) frame 0
(gdb) f 0              # 축약형

# 현재 프레임의 정보
(gdb) info frame
(gdb) info locals      # 로컬 변수
(gdb) info args        # 함수 인자

소스 코드 보기

# 현재 위치 주변 코드
(gdb) list
(gdb) l                # 축약형

# 특정 함수
(gdb) list start_kernel

# 특정 파일:라인
(gdb) list init/main.c:100

# 라인 범위
(gdb) list 100,120

실전 디버깅 팁

예제 1: 네트워크 패킷 처리 함수 디버깅

커널에 커스텀 RPS(Receive Packet Steering) 함수를 추가했고, 이를 디버깅하려고 합니다.

// net/core/dev.c
static int custom_get_rps_cpu(struct net_device *dev, struct sk_buff *skb,
                               struct rps_dev_flow **rflowp)
{
    struct rps_map *map;
    int cpu = -1;
    int min_cpu = -1;
    unsigned long min_util = ~0UL;
    
    map = rcu_dereference(dev->_rx->rps_map);
    if (!map)
        goto done;
    
    for (int i = 0; i < map->len; i++) {
        unsigned long util = my_cpu_util(map->cpus[i]);
        printk(KERN_INFO "cpu = %d / util = %ld\n", map->cpus[i], util);
        
        if (util < min_util) {
            min_util = util;
            min_cpu = map->cpus[i];
        }
    }
    
    if (min_cpu != -1)
        cpu = min_cpu;
    
done:
    return cpu;
}

디버깅 세션:

# 터미널 1: QEMU 실행
qemu-system-x86_64 \
  -kernel arch/x86/boot/bzImage \
  -initrd initramfs.img \
  -append "console=ttyS0 nokaslr" \
  -nographic \
  -netdev user,id=net0 -device e1000,netdev=net0 \
  -s -S
# 터미널 2: GDB 연결
cd ~/linux-6.10.8
gdb vmlinux
(gdb) target remote :1234

# 브레이크포인트 설정
(gdb) break custom_get_rps_cpu
Breakpoint 1 at 0xffffffff81a2b3c0: file net/core/dev.c, line 4523.

# TUI 모드 활성화
(gdb) layout src

# 실행 시작
(gdb) continue

QEMU에서 네트워크 트래픽 생성:

/ # ip link set eth0 up
/ # ip addr add 10.0.2.15/24 dev eth0
/ # ping 10.0.2.2 -c 1

GDB에서 브레이크포인트 히트:

Breakpoint 1, custom_get_rps_cpu (dev=0xffff888003a00000, skb=0xffff888003b00000, 
    rflowp=0xffffc90000013e78) at net/core/dev.c:4523

# 변수 확인
(gdb) print dev->name
$1 = "eth0"

(gdb) print map->len
$2 = 4

# for 루프로 진입
(gdb) next
(gdb) next

# 각 iteration에서 값 확인
(gdb) print i
$3 = 0

(gdb) print map->cpus[i]
$4 = 0

(gdb) print util
$5 = 1234567

# 조건 변경 확인
(gdb) print min_util
$6 = 18446744073709551615

(gdb) continue

예제 2: 특정 코드 라인에 브레이크포인트

소스 코드의 특정 라인을 디버깅하고 싶을 때:

# 터미널에서 라인 번호 찾기
cd ~/linux-6.10.8
grep -n "ops->get_rps_cpu" net/core/dev.c

# 출력: 4550:		cpu = ops->get_rps_cpu(skb->dev, skb, &rflow);
# GDB에서
(gdb) break net/core/dev.c:4550
Breakpoint 1 at 0xffffffff81a2b400: file net/core/dev.c, line 4550.

(gdb) continue

 

예제 3: 조건부 브레이크포인트

특정 조건에서만 멈추게 하기:

# 패킷 길이가 1500보다 클 때만
(gdb) break netif_receive_skb if skb->len > 1500

# CPU 번호가 2일 때만
(gdb) break custom_get_rps_cpu if cpu == 2

# 문자열 비교
(gdb) break net/core/dev.c:4550 if strcmp(dev->name, "eth0") == 0

예제 4: 워치포인트 (변수 값 변경 감지)

# 변수가 변경될 때 멈춤
(gdb) watch min_util
Hardware watchpoint 2: min_util

(gdb) continue
...
Hardware watchpoint 2: min_util

Old value = 18446744073709551615
New value = 1234567

커널 전용 GDB 명령어

Linux 커널에는 자체 GDB 스크립트가 포함되어 있습니다:

# 커널 스크립트 로드
(gdb) source scripts/gdb/vmlinux-gdb.py

# 프로세스 목록
(gdb) lx-ps

# 현재 태스크
(gdb) lx-task

# dmesg 출력
(gdb) lx-dmesg

# 커널 모듈 목록
(gdb) lx-lsmod

# 디바이스 목록
(gdb) lx-device-list-bus

자동 로드 설정:

echo "add-auto-load-safe-path ~/linux-6.10.8/scripts/gdb/vmlinux-gdb.py" >> ~/.gdbinit

GDB 설정 파일 (.gdbinit)

편의를 위한 설정:

cat > ~/.gdbinit << 'EOF'
# 페이징 비활성화 (긴 출력을 한번에 표시)
set pagination off

# 구조체를 보기 좋게 출력
set print pretty on
set print array on

# 배열 출력 제한 해제
set print elements 0

# 커널 소스 디렉토리
directory ~/linux-6.10.8

# 커널 GDB 스크립트 자동 로드
add-auto-load-safe-path ~/linux-6.10.8/scripts/gdb/vmlinux-gdb.py

# 유용한 alias
define psk
  print *(struct sk_buff *)$arg0
end

define pdev
  print *(struct net_device *)$arg0
end

define ptask
  print *(struct task_struct *)$arg0
end
EOF

문제 해결

문제 1: "No symbol table is loaded"

증상:

(gdb) break start_kernel
No symbol table is loaded. Use the "file" command.

원인: vmlinux 파일을 로드하지 않음

해결:

(gdb) file vmlinux
Reading symbols from vmlinux...
(gdb) target remote :1234

또는 처음부터:

gdb vmlinux
(gdb) target remote :1234

문제 2: 소스 코드를 찾을 수 없음

증상:

(gdb) list
No source file for address 0xffffffff81a2b3c0.

해결:

(gdb) directory ~/linux-6.10.8
(gdb) directory ~/linux-6.10.8/net/core

또는 .gdbinit에 추가:

echo "directory ~/linux-6.10.8" >> ~/.gdbinit

문제 3: 브레이크포인트가 걸리지 않음

가능한 원인:

  1. 함수가 인라인됨
  2. # 함수가 존재하는지 확인 (gdb) info functions netif_receive_skb # 상위 함수에 브레이크포인트 시도 (gdb) break __netif_receive_skb_core
  3. 해당 코드 경로를 거치지 않음
    • loopback 패킷은 특별한 경로를 탈 수 있음
    • 여러 지점에 브레이크포인트를 걸어 확인:
    (gdb) break netif_rx
    (gdb) break netif_receive_skb
    (gdb) break ip_rcv
    
  4. 컴파일 최적화
    • 디버그 빌드 확인: CONFIG_DEBUG_INFO=y
    • 최적화 레벨 낮추기: CONFIG_CC_OPTIMIZE_FOR_SIZE=n

문제 4: QEMU가 너무 느림

해결 방법:

  1. KVM 사용 (호스트가 Linux인 경우)
  2. qemu-system-x86_64 -enable-kvm ...
  3. CPU 코어 수 줄이기
  4. qemu-system-x86_64 -smp 2 ...
  5. 메모리 늘리기
  6. qemu-system-x86_64 -m 2G ...

문제 5: 디버그 심볼이 없음

증상: 함수 이름이나 변수가 보이지 않음

확인:

# vmlinux에 디버그 정보가 있는지 확인
file vmlinux
# 출력에 "not stripped"가 있어야 함

readelf -S vmlinux | grep debug
# .debug_* 섹션들이 보여야 함

해결: 디버그 옵션을 활성화하고 재빌드

scripts/config --enable DEBUG_INFO
scripts/config --disable DEBUG_INFO_REDUCED
make -j$(nproc)

문제 6: GDB가 QEMU에 연결되지 않음

증상:

(gdb) target remote :1234
:1234: Connection refused.

확인:

  1. QEMU가 -s 옵션으로 실행 중인지 확인
  2. 포트가 사용 중인지 확인:
    netstat -tlnp | grep 1234
    
  3. 다른 포트 사용:
    # QEMUqemu-system-x86_64 -gdb tcp::5678 ...# GDB(gdb) target remote :5678
    

추가 리소스

QEMU 유용한 옵션

# 더 많은 메모리
-m 4G

# SMP (멀티코어)
-smp 4

# 시리얼 포트를 파일로 리다이렉트
-serial file:serial.log

# 그래픽 모드
-vga std  # -nographic 대신

# 스냅샷 모드 (디스크 변경사항 저장 안함)
-snapshot

# USB 장치
-usb -device usb-tablet

GDB 치트시트

명령어 축약형 설명

break b 브레이크포인트 설정
continue c 실행 계속
next n 다음 줄 (함수 내부로 안들어감)
step s 다음 줄 (함수 내부로 들어감)
finish fin 현재 함수 끝까지
print p 변수 출력
backtrace bt 콜 스택
info breakpoints i b 브레이크포인트 목록
delete d 브레이크포인트 삭제
watch - 변수 변경 감지
list l 소스 코드 보기
info locals - 로컬 변수 목록
info args - 함수 인자 목록
frame f 스택 프레임 선택
up/down - 스택 프레임 이동

참고 자료