Skip to content

debugging with gdb

Mohiuddin Khan Inamdar edited this page Jun 21, 2026 · 3 revisions

Debugging with GDB

Freeze the CPU at power-on, attach GDB over a socket, and single-step from the boot sector into your C kernel.

QEMU ships a built-in GDB stub. The C stages expose it through make debug, so you can watch the machine execute the boot sector in 16-bit real mode, follow the switch into 32-bit protected mode, and step into the kernel — all from a normal GDB session in a second terminal.

Start QEMU with the GDB stub

In the stage directory you want to debug:

cd helloworld-os-c
make debug

make debug runs (see helloworld-os-c/Makefile:60):

qemu-system-x86_64 -drive format=raw,file=helloworld-c.img -s -S

The two flags are the whole trick:

  • -s — start a GDB stub listening on TCP port 1234. It is shorthand for -gdb tcp::1234.
  • -S — freeze the CPU at reset and do not start executing until you tell it to. The QEMU window will appear black and frozen; that is correct.

💡 Tidbit: -s and -S are independent. -s alone gives you a stub you can attach to a running machine; -S alone freezes the CPU with no way in. You almost always want both for boot debugging.

Attach GDB

Leave make debug running and open a second terminal:

gdb

Then inside GDB:

(gdb) target remote localhost:1234

The CPU is sitting at the reset vector. The very first instruction executed will be at the BIOS, which then loads your boot sector to 0x7C00.

The flat-binary symbol problem

The kernel is linked with OUTPUT_FORMAT(binary) (helloworld-os-c/linker.ld:11), so the bootable image is a flat binary with no symbol table. GDB therefore knows no function names — break kernel_main will fail, and backtraces show raw addresses. This is the single most important thing to understand about debugging this OS.

You have two practical options.

Option A — debug by address

Everything in this OS lives at known fixed addresses, so you can debug entirely with numeric breakpoints. Because the kernel is a flat binary, 0x1000 is both the load address and the entry point — there is no relocation.

(gdb) break *0x7c00      # boot sector entry (BIOS jumps here)
(gdb) break *0x1000      # kernel entry (boot.asm calls here)
(gdb) continue

Option B — load symbols from the ELF

The linker first produces an ELF object before flattening it to a binary. If you keep an unstripped ELF around, point GDB at it for real C symbols. The simplest approach is to load the kernel object file at the kernel's load address:

(gdb) add-symbol-file kernel.o 0x1000

Now break kernel_main, next, and source-level stepping work. The object file kernel.o (or shell.o on stage 3) is left in the build directory after make, so it is already there. If you want a single fully-linked symbol file, build a kernel.elf with the same link line minus OUTPUT_FORMAT(binary) and add-symbol-file kernel.elf.

⚠️ Caveat: add-symbol-file lays the symbols over the addresses you give it. Because the kernel is not relocated and loads exactly at 0x1000, the symbol addresses line up. If you ever change the load address in linker.ld and boot.asm, update the add-symbol-file offset to match.

Set the right architecture for the CPU mode

GDB does not automatically know whether the CPU is in 16-bit real mode or 32-bit protected mode, and it will disassemble incorrectly if you guess wrong. Set it explicitly:

(gdb) set architecture i8086      # before the protected-mode switch (boot sector)
(gdb) set architecture i386       # after the far jump into protected mode

The switch happens in boot.asm when CR0.PE is set and the far jump executes (helloworld-os-c/boot.asm:42-45). Step over that far jump, then switch GDB to i386 and the kernel will disassemble correctly.

A worked session

Debugging stage 2 from the boot sector through the protected-mode switch and into the C kernel:

# Terminal 1
cd helloworld-os-c
make debug
# Terminal 2
$ gdb
(gdb) target remote localhost:1234
(gdb) set architecture i8086
(gdb) break *0x7c00
(gdb) continue
Breakpoint 1, 0x00007c00 in ?? ()

(gdb) x/10i $pc          # disassemble the boot sector entry
(gdb) info registers     # check DS/ES/SS/SP setup

# step until just past the far jump that enters protected mode, then:
(gdb) set architecture i386
(gdb) break *0x1000      # kernel entry
(gdb) continue
Breakpoint 2, 0x00001000 in ?? ()

(gdb) add-symbol-file kernel.o 0x1000   # optional: real C symbols
(gdb) layout asm          # live disassembly view
(gdb) x/20i $pc
(gdb) stepi               # single-step one instruction

The commands you will actually use

Command What it does
target remote localhost:1234 Attach to the QEMU stub
set architecture i8086 / i386 Match real / protected mode
break *0x7c00 Stop at boot-sector entry
break *0x1000 Stop at kernel entry
continue (c) Resume execution
stepi (si) Execute one instruction
x/20i $pc Disassemble 20 instructions at the program counter
info registers Dump all CPU registers
layout asm TUI live disassembly pane
add-symbol-file kernel.o 0x1000 Overlay C symbols on the flat binary

QEMU monitor — the no-GDB alternative

If you only need to inspect state, skip GDB and use the QEMU monitor. Start QEMU with -monitor stdio (no -s -S needed):

qemu-system-x86_64 -drive format=raw,file=helloworld-c.img -monitor stdio

Then at the (qemu) prompt:

(qemu) info registers
(qemu) xp/20i 0x7c00      # disassemble physical memory at the boot sector
(qemu) xp/16xb 0xb8000    # peek at VGA text memory

xp reads physical memory, which is what you want with paging disabled.

💡 Tidbit: A reboot loop in QEMU is almost always a triple fault — a fault raised while handling a fault while handling a fault, which forces the CPU to reset. It is the classic symptom of a broken GDT, a bad stack, or a truncated kernel. Set break *0x1000, step into the kernel, and watch where execution derails. See troubleshooting.md.

See also

Clone this wiki locally