Create your own
Lesson illustration

Debugging a Freestanding Kernel with QEMU and GDB

Hello. Last lesson established the first reliable observation channel for your freestanding Rust kernel: serial output from QEMU’s emulated COM1 port. That told you the bootloader reached _start and that your code was running. This lesson adds the complementary tool: a debugger that can freeze the virtual CPU and show its exact machine state.

By the end, you will be able to connect GDB to QEMU, stop the running kernel, inspect the instruction at RIP, read general-purpose registers, and interpret the active stack. These are the core techniques you will use when serial output stops before a useful message, a fault corrupts control flow, or a later exception handler receives an unexpected frame.


The debugging model: one guest, two host-side views

QEMU emulates the guest machine and exposes a GDB remote-debugging server. GDB connects to that server over a local TCP socket. The two programs have distinct roles:

ComponentWhat it provides
QEMUA virtual x86-64 CPU, RAM, devices, and the current guest state
GDB server inside QEMUA protocol for stopping the virtual CPU and reading or changing its state
GDB on the hostSymbol lookup, source display, disassembly, register and stack inspection
Kernel ELF fileDebug symbols and source mappings that translate addresses into function names and source locations
Bootable disk imageThe raw artifact that QEMU boots

The last distinction is essential. Your bootimage-*.bin file is a bootable disk image; it is not normally the file to give GDB. Give GDB the unstripped Rust ELF executable from target/x86_64-kernel/debug/, since that file contains DWARF debugging information and symbols such as _start.

A debugger does not make a kernel understandable by itself. It provides raw state. Symbols turn an address such as 0x... into _start, and disassembly lets you verify what the CPU will actually execute.

Read Philipp Oppermann’s Set Up GDB for the QEMU/GDB connection model and the basic commands. Its build paths and older Makefile examples differ from your Cargo-and-bootimage workflow, but the -s, -S, target remote, breakpoint, and inspection concepts transfer directly.

Set Up GDB | Writing an OS in Rust

Read “Set Up GDB” by Philipp Oppermann. It explains the QEMU GDB server, why GDB needs the kernel binary with symbols, and the command vocabulary you will apply to the Rust kernel built in the previous lesson.

In the “QEMU parameters” section, read from the explanation of the two QEMU modes, including both command blocks: -s opens the GDB server and -S prevents the guest CPU from starting. Then, in “Connecting GDB,” read the discussion beginning with symbol loading. Translate its kernel-binary path to your unstripped Cargo ELF, not the bootimage disk image. Finally, read all of “Using GDB,” beginning the command overview. Focus on break, continue, step, list, print, and the optional TUI.


Start QEMU as a debuggable target

Build the disk image again after any source changes:

cargo +nightly bootimage

In one terminal, launch QEMU with serial logging, a GDB server, and an initially paused CPU:

qemu-system-x86_64 \
  -drive format=raw,file=target/x86_64-kernel/debug/bootimage-kernel.bin \
  -display none \
  -serial stdio \
  -s \
  -S

The options now mean:

  • -serial stdio keeps your kernel log in this terminal.
  • -s is QEMU shorthand for opening a GDB server on local TCP port 1234.
  • -S starts QEMU with the CPU halted. Firmware, bootloader, and kernel code will not execute until GDB tells it to continue.

The terminal should appear to do nothing. That is correct: no serial text is possible while the guest CPU is frozen.

In a second terminal, start GDB with the kernel ELF. Replace kernel with your Cargo package’s executable name if it differs:

gdb target/x86_64-kernel/debug/kernel

At the GDB prompt, connect to QEMU:

(gdb) target remote :1234

The :1234 form means localhost port 1234. GDB should report that it connected and show the current program counter, though at this earliest point the CPU may still be in firmware startup code rather than your kernel.

Now ask GDB whether it knows the symbol you care about:

(gdb) info functions _start

Set a breakpoint and allow the virtual machine to boot:

(gdb) break _start
(gdb) continue

At a successful stop, GDB should report _start and ideally a Rust source file and line number. QEMU’s first terminal should remain quiet until you continue past the serial-printing instructions.

A realistic early-boot caveat

The referenced GDB setup article documents a historical problem: some GDB versions fail when attached while QEMU transitions from early x86 startup into 64-bit long mode, often with a message resembling Remote 'g' packet reply is too long. This is a toolchain interaction, not evidence that your kernel is wrong.

If it occurs, use the robust “attach to a running kernel” workflow instead:

  1. Relaunch QEMU with -s but without -S.
  2. Wait until serial output confirms that _start has run and the kernel is spinning.
  3. Start GDB with the ELF and run target remote :1234.
  4. Press Ctrl-C in the GDB terminal to interrupt the guest CPU.

You will stop in the kernel’s idle spin loop rather than at its first instruction, but you can still inspect instructions, registers, and the stack. This is often the most useful workflow when investigating a kernel that has already reached a stable state.

If GDB says it cannot find _start, check that you launched it with the ELF in target/x86_64-kernel/debug/, not with bootimage-kernel.bin. The latter is for QEMU’s virtual disk, while the former is for GDB’s symbols.


Inspect the instruction stream and registers

Once stopped at _start, start with a compact machine-state snapshot:

(gdb) info registers rip rsp rbp eflags
(gdb) x/12i $rip

The first command shows selected registers. The second examines twelve instructions beginning at the instruction pointer.

The GDB command x/12i means:

  • x: examine memory;
  • 12: display twelve units;
  • i: render each unit as a machine instruction;
  • $rip: begin at the current instruction pointer.

For a fuller source-oriented view, use:

(gdb) disassemble /m _start

The /m option asks GDB to interleave source lines and assembly where debug information permits. If you want to see the raw machine-code bytes too, use:

(gdb) disassemble /r _start

A useful initial register set is:

RegisterMeaning while debugging
RIPAddress of the current instruction
RSPCurrent stack pointer; the active end of the stack
RBPConventional frame-base register, if the compiler preserved one
RFLAGS / EFLAGSCondition and control flags, including interrupt-enable state
RAX, RBX, RCX, RDXGeneral-purpose values; their meaning depends on nearby instructions
RSI, RDIOften used for arguments by the System V ABI, but do not assume this for arbitrary internal Rust calls

The register values are not self-explanatory. A value in RAX becomes meaningful only when you relate it to the instructions around RIP. For example, a mov instruction may be constructing a UART port value; a call instruction may be about to enter formatting code; a pause instruction usually indicates the deliberate spin_loop in your kernel’s idle loop.

A GDB text-interface session showing x86-64 general-purpose registers above the disassembly. The highlighted instruction is the current location identified by `RIP`; this is the same relationship you will inspect in the freestanding kernel.

GDB can make this relationship more visible:

(gdb) set disassembly-flavor intel
(gdb) set disassemble-next-line on
(gdb) display/i $rip

These settings use Intel assembly syntax, show an instruction after stepping, and continuously display the instruction at RIP.

To advance at instruction granularity, use:

(gdb) stepi

For a call instruction, nexti generally steps over the callee:

(gdb) nexti

Use this sparingly in early kernel code. Stepping into serial_println! can quickly move from your _start function into formatting, locking, and UART-driver internals. A breakpoint plus disassemble /m is usually a faster way to understand the broad control flow; single-stepping is for verifying a specific instruction-level hypothesis.


Read the stack as evidence, not as a list of variables

The x86-64 stack grows toward lower addresses. RSP marks its current active edge. Calls, saved registers, local variables, compiler spills, and return addresses occupy memory at or above that edge.

Ask GDB for its symbolic reconstruction first:

(gdb) backtrace
(gdb) info frame

backtrace, abbreviated bt, uses debug information and unwind rules to reconstruct active calls. For your small kernel it may show only _start and bootloader-related frames, or it may be partially incomplete. That is still useful information: an incomplete trace often tells you where compiler metadata, unusual entry code, or stack corruption prevents unwinding.

Then inspect the raw words directly:

(gdb) x/16gx $rsp

Here g means an eight-byte “giant word” and x means hexadecimal. Each row is a raw 64-bit memory value. GDB cannot inherently know whether a given word is a local integer, a saved register, a pointer, padding, or a return address.

A common unoptimized function prologue creates a layout approximately like this:

Location relative to RBPTypical meaning
RBP + 8Return address into the caller
RBPSaved caller frame pointer
Below RBPLocals, temporary spills, and saved registers
RSPLowest currently allocated address in this frame

This is a convention, not a promise. The compiler may omit the frame pointer, move values into registers, reserve more stack space than expected for alignment, or inline a function entirely. In particular, a compact no_std kernel must not assume every function has a visible RBP chain.

That is why a sound stack-reading procedure has three stages:

  1. Use bt and info frame to obtain GDB’s symbolic interpretation.
  2. Dump the raw stack at RSP.
  3. Compare plausible code-address-looking words with a disassembly or a named frame, rather than declaring every large hexadecimal value a return address.

For example, if info frame reports a saved return address, examine it as code:

(gdb) x/8i ADDRESS_REPORTED_BY_INFO_FRAME

Replace ADDRESS_REPORTED_BY_INFO_FRAME with the actual address GDB printed. If the result disassembles into instructions in _start or a caller, you have corroborated the stack interpretation. If it does not, it might be data, a corrupted return address, or an address from a different mapping.

Making future stack traces more legible

For debug builds, preserving frame pointers can make low-level stack inspection more robust, especially when source-level unwind information is incomplete. You can build with:

RUSTFLAGS="-C force-frame-pointers=yes" cargo +nightly bootimage

This is a debugging aid, not a substitute for understanding the stack. DWARF unwind metadata remains valuable, and optimized builds can still make source variables disappear or move between locations. Keep the main kernel development loop in debug mode until you have a reason to measure optimized behavior.


A practical inspection pass

Use this short pass whenever you attach to the spinning kernel:

(gdb) target remote :1234
(gdb) info registers rip rsp rbp eflags
(gdb) x/8i $rip
(gdb) bt
(gdb) info frame
(gdb) x/16gx $rsp

Interpret the result in this order:

  1. Where is the CPU? RIP plus x/8i $rip tells you whether you are in _start, serial output, a spin loop, or unexpected code.
  2. Does the control state look plausible? Check RSP is a sensible, stable-looking guest address and that RIP is in executable kernel code.
  3. What call chain led here? Read bt, treating it as a hypothesis supported by symbols and unwind metadata.
  4. Does the raw stack support that hypothesis? Use x/16gx $rsp and info frame together.
  5. Only then inspect application values. Commands such as print can be helpful, but optimized or low-level code may make a named local unavailable.

When finished, resume execution:

(gdb) continue

Or detach cleanly while letting QEMU keep running:

(gdb) detach

GDB breakpoints are not yet kernel breakpoint handlers

It is important not to confuse two distinct mechanisms:

  • A GDB breakpoint is controlled by the external debugger. QEMU stops the guest and reports its state to GDB.
  • An x86 breakpoint exception, usually caused by the int3 instruction, is delivered inside the guest through interrupt vector 3. Handling it requires an Interrupt Descriptor Table entry and an exception handler.

At this point, do not insert int3 into the kernel as a debugging shortcut. You have not installed an IDT breakpoint handler yet, so the CPU would be unable to dispatch the exception safely.

QEMU serial output from a later-stage kernel that has handled a breakpoint exception and printed its CPU-created `ExceptionStackFrame`, including instruction pointer, code segment, flags, stack pointer, and stack segment. This is guest-side exception handling, distinct from an external GDB stop.

The distinction will matter immediately in the next lesson: you will install a real IDT entry so that a breakpoint exception becomes an observable kernel event with a well-defined saved machine-state frame.


You now have a debugger-driven observation loop for the freestanding kernel. QEMU supplies the running virtual machine and exposes a GDB server; GDB loads symbols from the unstripped kernel ELF, freezes the guest, and lets you relate RIP to instructions, RSP to raw stack words, and symbolic backtraces to physical memory.

The key discipline is to treat registers and stack contents as evidence interpreted in context. A hexadecimal value only acquires meaning when you connect it to the surrounding instructions, symbol table, calling convention, and stack-frame metadata. Next, you will turn an x86 breakpoint from an external debugging convenience into a guest-visible exception by installing an IDT entry and breakpoint handler.

Can't find a good explanation? Sign up and we'll make it for you

Sign up