커널이 패닉 없이 그냥 멈추거나, 특정 syscall 경로에서만 간헐적으로 이상 동작을 보이는 경우 printk만으로는 원인을 좁히기 어렵다. dmesg에 로그를 더 찍고 재현하고 다시 빌드하는 사이클은 타이밍에 민감한 버그일수록 무력해진다. 이럴 때 필요한 건 실행 중인 커널을 그 자리에서 멈추고 레지스터와 메모리를 직접 들여다보는 것인데, kgdb가 바로 이 역할을 한다. 이 블로그에 이미 있는 GDB 글들은 gdb/strace로 유저스페이스 프로그램을 디버깅하는 내용이었고, kgdb는 대상이 커널 자체라는 점에서 완전히 다른 계층의 도구다. 이 글에서는 kgdb의 동작 원리와 kgdboc 기반 원격 연결 설정을 정리하고, kgdb가 쓰는 것과 동일한 GDB Remote Serial Protocol이 실제로 어떻게 동작하는지 QEMU의 gdbstub으로 직접 검증한다.
kgdb란: 커널 익셉션 훅과 GDB 원격 프로토콜
kgdb는 커널 안에 내장된 디버그 스텁(stub)이다. 브레이크포인트(int3)나 예외가 발생하면 커널의 die 경로를 가로채 CPU를 멈추고, 호스트의 gdb와 GDB Remote Serial Protocol(RSP) 패킷을 주고받으며 레지스터/메모리 읽기·쓰기, 브레이크포인트 설정 요청을 처리한다. kdb가 콘솔 커맨드 셸이라면, kgdb는 같은 예외 처리 경로에 gdb라는 풀스펙 프론트엔드를 붙인 것이다. 이 RSP는 kgdb 전용이 아니라 QEMU의 gdbstub(-s -S)도 동일하게 쓰는 표준 프로토콜이라, 전송 계층만 다를 뿐 패킷 형식은 같다.
| 연결 방식 | 전송 계층 | 용도 |
|---|---|---|
| kgdboc | 시리얼 케이블 | 실제 커널 원격 디버깅 (호스트 ↔ 대상 머신) |
| kgdboe | 이더넷 | 시리얼 없는 환경에서의 커널 원격 디버깅 |
| QEMU gdbstub | TCP (localhost:1234) | 이 글에서 RSP 왕복을 검증하는 용도 |
아래 실습은 이 RSP 왕복이 실제로 동작하는지를 커스텀 부트섹터로 검증한 것이다.
실전 코드
QEMU gdbstub 검증 (직접 실행 확인)
512바이트 x86 부트섹터를 GNU as/ld로 직접 만들어 QEMU에 -s -S로 띄우고, 로컬에 내려받은 gdb 15.1(WSL2에 gdb 패키지가 없어 apt-get download로 .deb만 받아 압축 해제해 사용)로 붙여봤다.
.code16
.text
.global _start
_start:
cli
xor %ax, %ax
mov %ax, %ds
mov %ax, %es
mov %ax, %ss
mov $0x7c00, %sp
mov $0x1234, %bx # 브레이크포인트에서 확인할 표식 값
mov $msg, %si
print_loop:
lodsb
test %al, %al
jz halt
mov $0x0e, %ah
int $0x10
jmp print_loop
halt:
hlt
jmp halt
msg:
.asciz "KGDB TEST BOOT\r\n"
. = _start + 510
.word 0xaa55
as --32 boot.S -o boot.o
ld -m elf_i386 -N -e _start -Ttext 0x7c00 --oformat binary -o boot.bin boot.o
# gdbstub을 1234 포트에 열고 리셋 직후 정지(-S)
qemu-system-x86_64 -fda boot.bin -s -S -display none -serial file:serial.log &
gdb -nx -batch \
-ex "target remote localhost:1234" \
-ex "break *0x7c00" \
-ex "break *0x7c1d" \
-ex "continue" \
-ex "print/x \$rip" \
-ex "x/4xb \$rip" \
-ex "stepi 8" \
-ex "print/x \$rbx" \
-ex "x/1i \$rip" \
-ex "continue" \
-ex "print/x \$rip" \
-ex "x/1i \$rip"실제 출력이다. break *0x7c00으로 부트섹터 진입점에 소프트웨어 브레이크포인트를 걸고, continue로 BIOS POST를 지나 정확히 그 주소에서 멈추는지, stepi로 8개 명령어를 밟은 뒤 소스에서 넣어둔 mov $0x1234, %bx가 실제로 rbx에 반영됐는지, 두 번째 브레이크포인트(halt: 라벨, 0x7c1d)에서 hlt 명령까지 도달하는지를 확인했다.
Breakpoint 1 at 0x7c00
Breakpoint 2 at 0x7c1d
Breakpoint 1, 0x0000000000007c00 in ?? ()
$1 = 0x7c00
0x7c00: 0xfa 0x31 0xc0 0x8e
0x0000000000007c12 in ?? ()
$2 = 0x1234
=> 0x7c12: lods %ds:(%rsi),%al
Breakpoint 2, 0x0000000000007c1d in ?? ()
$3 = 0x7c1d
=> 0x7c1d: hlt
0xfa(cli), 0x7c12(print_loop 라벨), rbx(0x1234) 모두 소스와 정확히 일치했다. gdb는 이걸 Z0/g/s RSP 패킷으로 만들어 보낸 것뿐이다 — 그렇다면 gdb 없이 그 패킷을 직접 만들어 보내도 똑같이 동작해야 한다.
gdb 없이 RSP 패킷 직접 만들어 검증하기
gdb를 거치지 않고 파이썬 소켓으로 QEMU의 gdbstub(TCP 1234)에 RSP 패킷을 직접 조립해서 보냈다. 패킷 형식은 $데이터#체크섬이고 체크섬은 데이터 바이트 합의 mod 256을 16진수 2자리로 쓴 것뿐이라 계산이 단순하다.
import socket
def rsp_packet(data: str) -> bytes:
checksum = sum(data.encode()) % 256
return f"${data}#{checksum:02x}".encode()
def send_and_wait(sock, data: str) -> bytes:
sock.sendall(rsp_packet(data))
buf = b""
while True:
buf += sock.recv(4096)
if b"$" in buf and b"#" in buf.split(b"$", 1)[1]:
break
sock.sendall(b"+") # ack
print(f"{data!r:14} -> {buf}")
return buf
sock = socket.create_connection(("127.0.0.1", 1234), timeout=5)
send_and_wait(sock, "?") # 리셋 직후 정지 이유
send_and_wait(sock, "m7c00,10") # 리셋 직후: BIOS가 아직 부트섹터를 안 올렸다
send_and_wait(sock, "Z0,7c00,1") # 0x7c00에 소프트웨어 브레이크포인트
send_and_wait(sock, "c") # 계속 실행 -> BIOS POST -> 부트섹터 로드 -> 0x7c00
send_and_wait(sock, "m7c00,10") # 이제 진짜 코드가 보여야 한다
sock.close()'?' -> b'+$T05thread:01;#07'
'm7c00,10' -> b'+$00000000000000000000000000000000#00'
'Z0,7c00,1' -> b'+$OK#9a'
'c' -> b'+$T05thread:01;#07'
'm7c00,10' -> b'+$fa31c08ed88ec08ed0bc007cbb3412be#6c'
리셋 직후 0x7c00를 읽으면 전부 0이다 — BIOS가 아직 디스크에서 부트섹터를 메모리로 올리지 않은 시점이라는 뜻이다. Z0로 브레이크포인트를 심고 c로 계속 실행하면 BIOS POST가 끝나고 부트섹터가 0x7c00에 로드된 뒤 그 자리에서 정지하고, 그제서야 같은 주소를 읽으면 fa 31 c0 8e d8 ...(cli; xor ax,ax; mov ds,ax; mov es,ax; ...)가 그대로 나온다. gdb가 만들던 패킷을 직접 손으로 조립해도 똑같은 결과가 나온다는 것을, gdb라는 중간 도구 없이 프로토콜 레벨에서 확인한 것이다.
실제 kgdb 설정: kgdboc
실제 kgdb는 물리 머신(또는 VM) 두 대를 시리얼로 연결해 쓴다. 이 환경엔 그 하드웨어가 없어 직접 재현하지 못했고, 아래는 커널 문서 기준 설정이다.
# 대상(target) 머신 커널 부트 파라미터
kgdboc=ttyS0,115200 kgdbwait
# 대상 머신에서 즉시 진입 (sysrq 경유)
echo g > /proc/sysrq-trigger
# 호스트 머신에서 vmlinux 심볼과 함께 연결
gdb ./vmlinux
(gdb) set serial baud 115200
(gdb) target remote /dev/ttyS1CONFIG_KGDB, CONFIG_KGDB_SERIAL_CONSOLE이 커널에 활성화되어 있어야 하고, kgdbwait를 주면 부팅 초기부터 스텁이 연결을 기다린다. 부팅 이후라면 echo g > /proc/sysrq-trigger로 임의 시점에 진입할 수 있다. gdb 쪽에서 vmlinux를 심볼과 함께 로드하면 bt, list, print 같은 명령이 함수/변수 이름으로 그대로 동작한다 — 위 QEMU 실습에서 심볼 없이 ?? ()로만 보이던 것과 대비된다.
주의사항
- kgdb는 임의 메모리 읽기/쓰기를 원격에서 허용하는 것과 같아서 사실상 root 권한과 동급이다. 운영 환경에서
CONFIG_KGDB를 켠 채로 배포하지 말 것. - 스텁이 멈춰 있는 동안 워치독(watchdog)이나 다른 CPU의 NMI가 개입하면 시스템이 리부팅되거나 데드락처럼 보일 수 있다.
nowatchdog커널 파라미터를 함께 고려한다. - QEMU 실습에서 확인했듯 실모드에서는 세그먼트가 0이라 물리 주소와 오프셋이 같아
break *0x7c00처럼 바로 걸리지만, 보호모드/커널 공간에서는 심볼 주소(가상 주소) 기준으로 걸어야 한다. - 시리얼 연결은 케이블 품질이나 보레이트 불일치에 취약하다. 재현성이 중요하면 kgdboe(이더넷) 또는 QEMU/가상 시리얼 기반의 사전 리허설을 권장한다.
- 이미 죽어서 재부팅이 안 되는 상황(패닉 후 자동 리부팅)에는 kgdb보다 kdump/crash 조합이 더 적합하다. kgdb는 “살아있지만 멈춰서 들여다봐야 하는” 상황에 맞는 도구다.
마무리
kgdb는 새로운 프로토콜을 발명한 게 아니라 gdb가 이미 쓰던 RSP를 커널 예외 경로에 연결한 것이다. 그래서 QEMU의 -s -S처럼 훨씬 접근하기 쉬운 환경에서 같은 프로토콜의 브레이크포인트/스텝/레지스터 읽기가 실제로 오간다는 걸 먼저 확인해보면 실제 kgdboc 설정을 할 때도 무엇이 오가는지 감을 잡기 쉽다. printk 로그만으로 막힌 커널 버그를 만난다면 QEMU로 먼저 연습해보고 실제 타깃으로 넘어가는 순서를 권장한다.
참고
Using kgdb, kdb and the kernel debugger internals — kernel.org