DEV Community

Harrison Guo
Harrison Guo

Posted on Originally published at harrisonsec.com

Debugging a Real-Mode Bootloader in GDB — When CS Changes, Symbols Break

Set a breakpoint by name in a real-mode bootloader and it works. Step forward, hit a far jump, set the same kind of breakpoint again, and it silently misses. Function names go to bare hex, stack traces turn to noise, and GDB gives you no error at all. Nothing crashed. The CS register changed, and in real mode that quietly moves every address out from under GDB's symbol table. This is one of the most instructive five minutes in low-level debugging, because the mechanism behind it is the same one that will lie to you in a production coredump years later.

The setup

A hand-written assembly bootloader — the BIOS loads the first 512-byte sector to physical 0x7c00 and jumps to it in 16-bit real mode. To debug it you run it under QEMU with the GDB stub, frozen before the first instruction:

qemu-system-i386 -drive format=raw,file=boot.bin -s -S
Enter fullscreen mode Exit fullscreen mode

Then attach, and — the first thing most tutorials omit — tell GDB it's looking at 16-bit code, because it will assume 32- or 64-bit otherwise and disassemble garbage:

(gdb) target remote :1234
(gdb) set architecture i8086
(gdb) break *0x7c00
(gdb) continue
Enter fullscreen mode Exit fullscreen mode

At this point everything works. You can step, disassemble, and inspect. Then the boot code does a far jump.

What breaks

A jmp far (or the classic canonicalizing jmp 0x0000:0x7c00, or a jump into a second stage) reloads CS. After it, GDB's prompt still responds, but:

  • breakpoints set by symbol name miss,
  • disassembly around "known" functions is wrong,
  • backtraces are meaningless.

No error, no crash. GDB is confidently pointing at the wrong place.

Why: in real mode, CS is part of the address

Real mode has no page tables and no flat address space. A physical address is computed from two 16-bit values:

physical = CS << 4 + IP
Enter fullscreen mode Exit fullscreen mode

Your symbol file was built assuming one code segment — say everything relative to CS = 0x07c0 (which puts 0x7c00 at offset 0), or relative to CS = 0. GDB stores each symbol as an offset within that assumed segment. The instant a far jump loads a different CS, the same IP now resolves to a different physical byte, and every symbol GDB knows is off by a fixed amount: (old_CS − new_CS) << 4. The names didn't change; the ground under them did. GDB has no way to notice, because the segment register is CPU state it isn't folding into its symbol math.

This is why set architecture i8086 matters and why it still isn't enough: it fixes decoding (16-bit instructions), but it doesn't teach GDB that a segment change relocates the symbol table.

Keeping symbols after the jump

Three moves, from crude to clean:

  1. Break on absolute physical addresses across the boundary. break *0x7c00, break *0x8000 — physical addresses don't care what CS is, so they survive the far jump when named breakpoints don't.
  2. Inspect by computing the address yourself. x/10i $cs*16+$pc disassembles from the real current location regardless of what the symbol table thinks. It's the ground truth when names have drifted.
  3. Re-base the symbols after the jump. Once you know the new segment, reload the symbol file at the new base with add-symbol-file (or symbol-file with the offset applied) so names line up with the new CS. Now breakpoints-by-name work again in the new segment.

The fix is mechanical. The understanding is the point: a far jump in real mode isn't just control flow, it's an address-space change, and any tool that models addresses without modeling the segment will silently fall out of sync.

Why this outlives real mode

You will likely never ship a bootloader. You will absolutely hit its shape again, because "the debugger's model of where code lives diverged from where it actually lives" is one of the most common ways debugging goes wrong:

  • PIE binaries under ASLR. Your Go or C binary loads at a randomized base. Symbolicate a coredump against the wrong base — a different build, a stale symbol file — and you get a clean-looking backtrace pointing at functions that never ran. Same drift, different cause.
  • Containers. The address space a process sees inside a container isn't the one a host-side profiler assumes; offsets differ and the tooling has to account for it or mislead you.
  • JIT-compiled code (Python, Node, ML runtimes). Functions are generated and moved at runtime; static symbol resolution simply doesn't apply, and tools must call into runtime introspection to recover names at all.

The through-line: a debugger runs on a model of the program, and when the model and the runtime diverge, the debugger doesn't crash — it lies, fluently. Real-mode CS is the cleanest possible demonstration because the divergence is exact and you can watch it happen in one instruction. Learn to recognize it there and you'll recognize it in a coredump that's been wasting your afternoon.

Related

Top comments (0)