Menno Markus

Part 1: Debugger Setup

May 02, 20269 min read

Introduction

Any systems programmer probably uses a debugger near daily. So when I needed to write my own, I was surprised to find how little information is out there on how they work! Don’t get me wrong, I can highly recommend TimDbg’s series on Windows debugging and Sy Brands equivalent for Linux. They’re excellent for getting started! But my hope is to contribute by delving even deeper into topics.

In this mini series we’ll focus on just one aspect: breakpoints. This means we won’t be looking at setting up a proper debugger loop or parsing debug info. Parsing debug info along would be enough to fill a book! But if you are interested in that, maybe PdbPlus can provide some reference for Windows at least.

The code is aimed at Windows X86_64 as that’s what I’m familiar with, but any ideas should apply more generally. All code will be written in Zig 0.16, using nothing but the std-lib and Windows header. I won’t assume any in depth knowledge of Zig though. As long as you have familiarity with any C-like language you should be able to follow along.

The code for this part can be found here.
Alright enough talk!

The Debugee

First, have a look at the program we’ll be debugging throughout this series:

const std = @import("std");
const win = @import("windows");
const log = std.log.scoped(.debugee);

var counter: u64 = 0;
fn doWork() void {
    log.info("{}", .{counter});
    counter += 1;
}

pub fn main(init: std.process.Init) !void {
    log.info("Address of doWork() == 0x{X}", .{@intFromPtr(&doWork)});
    log.info("Address of counter == 0x{X}", .{@intFromPtr(&counter)});

    // Briefly wait to allow our debugger to setup breakpoints at the above addresses.
    // A real debugger would have gotten these from the debug data and setup on process creation.
    log.info("Waiting for debugger...", .{});
    win.RaiseException(0xE0000001, 0, 0, null);
    log.info("Starting work!", .{});

    while (true) {
        doWork();
        try std.Io.sleep(init.io, .fromSeconds(1), .real);
    }
}

For the most part it’s a simple counter within a 1 second loop. The RaiseException call stands out though. Catching exceptions is one of the main ways debuggers interact with a program. You might be familiar with “Stack Overflow”, “Access Violation” and “Divide By Zero” among others. But programs are allowed to define their own. Here we raise a custom exception code we can catch in the debugger.

The reason we do this is so we can figure out the memory addresses of doWork() and counter to initialise our debugger. You might have heard of address space layout randomization before. This is a security technique where the OS might place code and data at random addresses each run. Normally a debugger finds out where everything is placed by parsing the debug info. But as mentioned we won’t be delving into that topic, so use this slightly crude way instead.

Lets run our program to confirm it’s output.

info(debugee): Address of doWork() == 0x7FF720C71710
info(debugee): Address of counter == 0x7FF720D39600
info(debugee): Waiting for debugger...
info(debugee): Starting work!
info(debugee): 0
info(debugee): 1
info(debugee): 2

Attaching the debugger

To debug a program we’ll have to ask the OS. There are two ways to do this. If you want to attach to an already running program, Windows provides DebugActiveProcess. The alternative is to spawn the debugee ourselves through CreateProcess and pass either the DEBUG_PROCESS or DEBUG_ONLY_THIS_PROCESS creation flag. This will spawn a new process in the attached state.

We’ll be using the later method here. As with many Windows API functions there is an A (ansi string) and a W (wide string) variant. We’ll be using CreateProcessA for simplicity, but any serious debugger should aim to support Unicode wide strings. Using this our debugger can spawn a new process by invoking the command line we give it. You may realise that if we can spawn a new process, there is nothing stopping our debugee from doing the same. It is in fact rather common for processes to spawn other processes, think for example the console used to display output. We only want to focus on our debugee though so will be passing the DEBUG_ONLY_THIS_PROCESS flag. Passing DEBUG_PROCESS would cause our debugger to also attach to any further child processes spawned.

Also note we won’t be passing the CREATE_NEW_CONSOLE flag, so our debugee shares the output console with the debugger.

var launch_args = "debugee.exe".*;
var process_info = std.mem.zeroes(win.PROCESS_INFORMATION);
var startup_info = std.mem.zeroes(win.STARTUPINFOA);
startup_info.cb = @sizeOf(win.STARTUPINFOA);

if (win.CreateProcessA(
    null,                           // lpApplicationName (null, use command line instead).
    &launch_args,                   // lpCommandLine
    null,                           // lpProcessAttributes
    null,                           // lpThreadAttributes
    win.FALSE,                      // bInheritHandles
    win.DEBUG_ONLY_THIS_PROCESS,    // dwCreationFlags (Debug only this process, no child processes).
    null,                           // lpEnvironment
    null,                           // lpCurrentDirectory
    &startup_info,                  // lpStartupInfo
    &process_info,                  // lpProcessInformation
) == win.FALSE) {
    log.err("Unable to launch debugee process!", .{});
    return;
}

Watching debug events

With the debugger attached Windows will send us DEBUG_EVENT messages. To receive them we can call WaitForDebugEvent. When an event is raised the debugee is suspended to allow the debugger to respond. To continue the debugee program, ContinueDebugEvent must be called. Let’s setup a basic event handling loop with this:

while (true) {
    var debug_event = std.mem.zeroes(win.DEBUG_EVENT);
    if (win.WaitForDebugEvent(&debug_event, win.INFINITE) == win.FALSE) {
        log.err("Unexpected error waiting for debug event!", .{});
        break;
    }
    const process_id = debug_event.dwProcessId;
    const thread_id = debug_event.dwThreadId;

    // TODO: Do stuff...

    if (win.ContinueDebugEvent(process_id, thread_id, win.DBG_CONTINUE) == win.FALSE) {
        log.err("Unexpected error continuing from debug event!", .{});
        break;
    }
}

Looking at the ContinueDebugEvent you can see we pass in 3 parameters:

The first, process_id, should make some sense to you already. If debuggers can attach to multiple processes at once, we need to know which process was suspended and continue just that one. Of course we specified the DEBUG_ONLY_THIS_PROCESS flag, so know it will always be the same single process we are attached to.

The second, thread_id, specifies which thread to continue. In multi threaded programs only the thread generating the exception is suspended, the others continue running. This is an important detail! In a real debugger you probably want to suspend all threads. It would be weird if other threads continue to change memory while the user is watching it in the debugger. This could even lead to crashes if we try to modify values. To keep things simple though, we’ll only handle single threaded debugees here.

The third parameter is set to DBG_CONTINUE. When an exception is raised, we might not be the only ones trying to handle it. If we decide that actually, something else should catch this we can pass DBG_EXCEPTION_NOT_HANDLED. But in our case we will always continue running.

Handling exceptions

Looking at the DEBUG_EVENT structure there is a whole bunch of things we could respond to:

pub const DEBUG_EVENT = extern struct {
    dwDebugEventCode: DWORD,
    dwProcessId: DWORD,
    dwThreadId: DWORD,
    u: extern union {
        Exception:          EXCEPTION_DEBUG_INFO,
        CreateThread:       CREATE_THREAD_DEBUG_INFO,
        CreateProcessInfo:  CREATE_PROCESS_DEBUG_INFO,
        ExitThread:         EXIT_THREAD_DEBUG_INFO,
        ExitProcess:        EXIT_PROCESS_DEBUG_INFO,
        LoadDll:            LOAD_DLL_DEBUG_INFO,
        UnloadDll:          UNLOAD_DLL_DEBUG_INFO,
        DebugString:        OUTPUT_DEBUG_STRING_INFO,
        RipInfo:            RIP_INFO,
    },
};

For now, let’s just try and catch the custom exception we raised inside the debugee. You can probably guess this means listening for the EXCEPTION_DEBUG_EVENT. Buried within EXCEPTION_DEBUG_INFO, EXCEPTION_RECORD we find the ExceptionCode that was raised.

pub const EXCEPTION_RECORD = extern struct {
    ExceptionCode: DWORD,
    ExceptionFlags: DWORD,
    ExceptionRecord: *EXCEPTION_RECORD,
    ExceptionAddress: PVOID,
    NumberParameters: DWORD,
    ExceptionInformation: [15]ULONG_PTR,
};

When our custom exception code is detected we should ask the user where they would like to set the breakpoint.

if (debug_event.dwDebugEventCode == win.EXCEPTION_DEBUG_EVENT) {
    const exception_code = debug_event.u.Exception.ExceptionRecord.ExceptionCode;

    // Received the signal the debugee is ready.
    if (exception_code == 0xE0000001) {
        log.info("Set hardware breakpoint at address?", .{});
        const input_line_bare = try stdin.takeDelimiter('\n') orelse unreachable;
        const input_line = std.mem.trim(u8, input_line_bare, "\r");
        const break_at_address = try std.fmt.parseInt(usize, input_line, 0);

        // TODO: setBreakpoint(break_at_address);
    }
}

Going forward, we’ll be implementing setBreakpoint. But for now lets take a breather here and check we successfully caught the exception:

info(debugee): Address of doWork() == 0x7FF70B1F1720
info(debugee): Address of counter == 0x7FF70B2B9600
info(debugee): Waiting for debugger...
info(debugger): Set hardware breakpoint at address?
0x7FF70B1F1720
info(debugee): Starting work!
info(debugee): 0
info(debugee): 1
info(debugee): 2

Excellent! With all of that boilerplate out of the way, in the next part will implement our first type of breakpoint: hardware breakpoints. For now it’s important to realise the basic loop on how debugees raise exceptions that debuggers can catch and tell the debugee what to do.

Part 2: Hardware Breakpoints >
Return to Home