1/44
Looks like no tags are added yet.
Name | Mastery | Learn | Test | Matching | Spaced | Call with Kai | Chat |
|---|
No analytics yet
Send a link to your students to track their progress
What problem does this project solve?
It gives a hands-on, from-scratch implementation of what tools like gdb do: attach to a running program, control its execution, and inspect its state, so I could understand debugging as an OS-level mechanism instead of a black box.
What are the major components of the debugger?
Process control (fork/exec + ptrace), a breakpoint manager (instruction patching), memory/register access, an ELF/DWARF parser for debug info, and a command loop that ties them together.
Why build this instead of just using gdb?
The goal wasn't to replace gdb, it was to learn the underlying mechanisms - ptrace, ELF, DWARF, signals - that gdb itself is built on.
What can the debugger actually do at a high level?
Launch a target program under trace, set breakpoints by address, source line, or function name, step through execution at instruction or source level, and inspect registers and memory.
What is the core OS-level insight that makes any of this possible?
Linux lets one process (the tracer) control another process (the tracee) through the ptrace system call, which the kernel mediates - this is the foundation everything else is built on.
What are the two processes involved and how are they related?
The debugger forks a child process; the child calls PTRACE_TRACEME and then execs the target program, becoming the tracee, while the parent becomes the tracer and issues ptrace calls to control it.
How does the debugger "communicate" with the target process?
Not through normal IPC - the kernel mediates everything via the ptrace syscall, letting the tracer read/write the tracee's memory and registers and control its execution, while the tracee is stopped and reported to the tracer via signals like SIGTRAP.
What are the major subsystems and how do they interact?
Process control starts and stops the tracee, the breakpoint manager patches/restores instructions at addresses the debug-info layer resolves, and the command loop translates user input into calls across all of these.
How does a breakpoint actually work at a low level?
The debugger saves the original byte at the target address, writes the INT3 (0xCC) trap instruction in its place, and when the CPU executes it the process raises SIGTRAP, the debugger catches it, rewinds PC, and later restores the original byte to step past it.
How does the debugger know where to put a breakpoint for a given source line?
It parses the DWARF line number program, which maps ranges of source file/line to instruction addresses, and looks up the address for the requested line.
How does the debugger resolve a breakpoint by function name?
It walks the DWARF DIE tree for subprogram entries matching the name and uses that entry's low_pc as the starting address.
How does step-over avoid single-stepping every instruction?
It uses the DWARF line table to find the address range of the current source line and sets temporary breakpoints at line boundaries and the return address, instead of stepping one instruction at a time.
What is libelfin's role in the architecture?
It parses ELF sections and DWARF debug info programmatically, so the debugger doesn't need a hand-rolled binary/DWARF parser to get line tables, symbol locations, and DIE trees.
Why fork/exec the target instead of attaching to an already-running process?
It's a simpler model for a from-scratch debugger - you control the full lifecycle from the very first instruction, including the initial load address, though PTRACE_ATTACH to a running process is a legitimate alternative real debuggers also support.
Why use software breakpoints (INT3 patching) instead of hardware breakpoints?
Hardware breakpoints use a small fixed number of CPU debug registers (typically 4) and are more fiddly to program, while software breakpoints via INT3 are effectively unlimited and simpler, at the cost of temporarily modifying the target's memory.
Why does the debugger need to handle PIE binaries specially?
Modern Linux binaries default to Position Independent Executables for ASLR, so the link-time addresses in the ELF/DWARF data don't match where the binary actually loads at runtime, requiring the debugger to read /proc/pid/maps and add a load bias.
What's the tradeoff of investing in DWARF parsing versus skipping source-level debugging?
DWARF parsing is a lot of added complexity, but it's what turns the tool from a raw register/memory poker into something resembling a real debugger, and it's where most of the interesting binary/compiler knowledge shows up.
Why a synchronous command loop instead of an event-driven or multithreaded design?
Debugging is inherently a request/wait cycle already - you send a ptrace request, then block on waitpid for the tracee to stop - so a blocking REPL matches that flow naturally without added concurrency complexity.
What are the current limitations compared to production debuggers like gdb?
Single-threaded targets only, no remote debugging, limited/no expression evaluation, unreliable variable inspection, and no type-aware value formatting.
Why is variable inspection harder than breakpoints or stepping?
It requires evaluating DWARF location expressions, a small stack-based bytecode language that can describe a variable's location as a register, a computed frame-relative offset, or more complex composite locations, not just a fixed address.
What is ptrace and what can it do?
A Linux syscall that gives one process privileged control over another: reading/writing memory and registers, single-stepping, continuing execution, and intercepting signals, after the tracee has consented via PTRACE_TRACEME or been attached to.
Why does a breakpoint hit deliver SIGTRAP specifically?
Because the injected INT3 instruction causes a CPU trap when executed, which the kernel converts into a SIGTRAP delivered to the tracer.
Why must the debugger rewind the program counter after a breakpoint trap?
INT3 is one byte, so executing it advances the instruction pointer past the patched byte; the debugger resets PC back by one so execution resumes at the correct original instruction.
What is a load address / load bias and why does it matter?
For PIE binaries the OS picks a randomized base address at load time; the load bias is the difference between the address the binary was linked for and where it actually sits in memory, and it must be applied to translate between static DWARF/ELF addresses and live runtime addresses.
What is DWARF and what does it contain?
A standardized debug-info format embedded in the ELF binary, encoding a tree of Debug Info Entries for functions, variables, and types, plus a line number program mapping instruction addresses to source file/line.
What's the difference between ELF and DWARF?
ELF is the binary container format itself (headers, sections, symbol tables); DWARF is debug metadata usually stored inside specific ELF sections, describing source-level structure on top of the raw binary.
How does frame-pointer based stack unwinding work?
With frame pointers preserved, each stack frame stores the caller's saved rbp and return address at fixed offsets from the current rbp, so walking that chain of saved rbp values reconstructs the call stack.
Why compile the target with -O0 and -fno-omit-frame-pointer?
Optimizations can reorder or eliminate variables and inline functions, breaking the mapping from source lines to instructions, and omitting frame pointers breaks the simple rbp-chain stack walking the debugger relies on.
If asked to design this from scratch, what's the first architectural decision?
How the debugger will control the target's execution - on Linux that means committing to the ptrace-based tracer/tracee model via fork, PTRACE_TRACEME, and exec.
After launching and stopping the target, what's the next capability to build?
Basic execution control - issuing continue and waiting on signals, so you can run the program and reliably detect when and why it stops.
After basic execution control, what's the next natural feature and why?
Breakpoints at raw memory addresses, since instruction patch/restore is the simplest debugging primitive, before adding any symbol or source-level knowledge on top.
How would you evolve raw address breakpoints into source-line breakpoints?
Add ELF/DWARF parsing to read the line number program, mapping a requested file/line to an address, then feed that resolved address into the existing address-breakpoint mechanism.
How would you design stepping, building up from simplest to most capable?
Start with raw single-instruction stepping since it's the cheapest primitive, then layer source-line-aware stepping on top by repeatedly single-stepping until the line table shows a new line, then optimize step-over using temporary breakpoints instead of stepping every instruction.
How would you add PIE support if you'd only built for static binaries first?
Introduce a load-address concept from the start, even if zero for static binaries, read /proc/pid/maps once the child is running for dynamic binaries, and route every address translation through one consistent helper rather than scattering the math.
What would you build last in this system and why?
Variable and expression inspection, since it depends on everything else - reliable breakpoints, stepping, and address translation - and adds the most complex layer, DWARF location-expression evaluation, on top.
Why did you write this in C++ instead of using an existing debugger?
The point wasn't to replace gdb or lldb, it was to learn how OS-level debugging primitives, binary formats, and debug info actually work by implementing them directly.
What happens if the target crashes with a signal like SIGSEGV?
wait_for_signal detects it through WIFSIGNALED/WTERMSIG and reports it, though the debugger doesn't yet do a full crash-time backtrace on unexpected signals - a clear extension point.
What happens if you try to set a breakpoint at an invalid or read-only address?
The underlying ptrace POKETEXT call would fail; the current implementation doesn't robustly surface or handle that failure, which is a real gap I'd call out and fix with proper error checking.
Why does variable inspection sometimes produce wrong values?
DWARF location expressions for locals are often frame-relative, and evaluating them correctly means combining that offset with the live frame pointer at the exact breakpoint moment - getting the offset direction or sign wrong produces garbage addresses, which is a concrete bug I hit and diagnosed.
What would you change if you rebuilt this today?
Centralize PIE address translation into a single well-tested layer instead of repeating the offset math in multiple functions, and add proper error handling around ptrace calls and DWARF expression evaluation.
How would you extend this to support multithreaded programs?
ptrace operates per-thread at the kernel level, so you'd need to track multiple tids, use PTRACE_O_TRACECLONE to catch new threads as they're created, and manage breakpoint state per thread instead of assuming one flow of control.
How would you support remote debugging?
Split the tool into a client that handles commands/UI and a stub that runs on the target machine performing the actual ptrace calls, communicating over a protocol similar to gdb's remote serial protocol.
Why is a debugger fundamentally an OS-specific tool?
Because it depends on kernel-provided process control primitives, ptrace on Linux versus different APIs on Windows or macOS, and on platform-specific binary and debug-info formats like ELF/DWARF versus PE/PDB or Mach-O, so the core mechanism isn't portable without a full separate backend.
What was the hardest bug you hit and what did it teach you?
An address double-offsetting bug where the PIE load address was applied twice across different code paths, corrupting addresses during stepping - it taught me to have one single source of truth for address translation instead of repeating the same logic in multiple places.
Tell me about this project (STAR)
Situation: I wanted to understand how debuggers work internally instead of just using gdb, so I built a Linux debugger in C++ from scratch on top of ptrace. Task: My goal was to support the core debugging workflow - launching a traced process, setting breakpoints, stepping through source, and inspecting registers and memory - while handling real complications like PIE binaries under ASLR. Action: I implemented the tracer/tracee model with fork and PTRACE_TRACEME, built a breakpoint system using INT3 instruction patching with byte save/restore, and integrated libelfin to parse ELF and DWARF so breakpoints could be set by source line or function name instead of raw addresses; I added load-address translation from /proc/pid/maps for PIE binaries, and implemented instruction and source-level stepping plus basic frame-pointer based stack unwinding. Result: I ended up with a working command-line debugger that traces a program, hits breakpoints, steps through source, and dumps register and memory state, and along the way I diagnosed real bugs like a PIE address double-offsetting issue and unreliable DWARF location-expression evaluation for locals, which taught me a lot about disciplined address handling and how much machinery underlies something as simple as printing a variable.