Writing a debugger from scratch: Breakpoints
timdbg.com
timdbg.com
He was on a team building a Modula-2 compiler for OS/2, and his group was working on the debugger.
At some point a debugger becomes feature complete enough that you can use the debugger to ... debug the debugger.
But this was OS/2 which has true multiple processes (unlike it’s contemporary Windows 3.1). So you could, naturally, run the debugger in one process and attached it to another process which, just so happens to be another instance of the debugger.
As with all things, while doing this they encountered bugs in the debugger that, well, needed to be debugged.
He said there was a certain epiphany when they realized, because of the multi process nature of OS/2, that they could debug the debugger debugging the debugger.
I would imagine this took a bit of focus. Turn away for a moment and probably really messes with your head.
I honestly think one of the best parts of writing a debugger is being your own recursive customer. I think that's something you only get to do for a few things. Debuggers, languages/compilers, and operating systems. And probably a few others.
I think I once had to debug the debugger debugging the compiler compiling itself which felt like another really weird kind of recursion.
That said, even today when debugging Chrome DevTools with Chrome DevTools, window placement is key!!! Ideally, different screens. That keeps the mind clear.
I noticed that the author was using https://github.com/hydro-project/rust-sitter as a parser. Which is based on https://tree-sitter.github.io/tree-sitter/. I've been hearing about Tree-sitter a lot recently, so I dug into it.
Tree-sitter is a tool for generating fast, incremental parsers. In particular, the algorithm is suited towards writing "language servers" for IDEs, which re-parse code incrementally as the user works. These kinds of incremental parsers have historically been a huge problem. It looks like Tree-sitter is an enormous practical advance in this area.
And discovering that there's a way to use Tree-sitter from Rust is fantastic. From the post:
#[rust_sitter::language]
pub enum EvalExpr {
Number(
#[rust_sitter::leaf(
pattern = r"(\d+|0x[0-9a-fA-F]+)",
transform = parse_int
)]
u64
),
Symbol(
#[rust_sitter::leaf(
pattern = r"(([a-zA-Z0-9_@#.]+!)?[a-zA-Z0-9_@#.]+)",
transform = parse_sym
)]
String
),
// ...
Getting easy access to fast, incremental parsing is a huge win. And Tree-Sitter has support for being used from a huge list of languages, not just Rust.https://github.com/edmundito/tree-sitter-ags-script/issues/1
If this could be solved, we could port this AGS Script parser to the AGS Editor. Today, the parser Adventure Game Studio uses for the needs like auto-complete and it's very simple refactor like things uses a custom handmade parser built in C#. I think if we could leverage tree-sitter we could speed things up and repurpose it to build things like a LSP for AGS Script.
Basically you overwrite the instruction you want to break at with a breakpoint instruction (e.g. int 3 on x86). This will cause the process to trap and the OS will then let the debugger process know about out somehow, e.g. via the SIGTRAP signal on Unix.
The debugger then replaces the int 3 opcode (which is a single byte conveniently) with the first byte of the original instruction so that the execution can continue.
In a trivial example, the breaking instruction could be a jump to itself, which you’d expect to immediately break into the debugger again.
I thought the debugger had to emulate the instruction instead, but it’s not like I’ve ever implemented one…
I really only implemented a debugger for the esp8266 and it was just good enough for me and my team to get our job done so it didn't handle many edge cases like that
1. Overwrite instruction with int 3.
2. When you hit the breakpoint, restore the original instruction.
3. Single-step over the original instruction by changing the thread's EFlags (Intel).
4. Restore the breakpoint with int 3.
5. Resume normally.
You could also do something like have a clean mapping table (i.e. the code with no breakpoints installed) that you install for just the thread doing the step. You then revert back to the normal mapping table with the breakpoint after the step. As you are only modifying the executable section, as long as you are not using self-modifying code, there should be no data inconsistency with having a multiple copys of the executable transiently.
* Restore the original instruction byte.
* Find the next instruction, and set a temporary software breakpoint there.
* Resume the one instruction
* Restore the original instruction byte at the temporary software breakpoint.
* Set the software breakpoint in the original instruction
* Resume running
The other thing to keep in mind is dealing with JMP, CALL and conditional branch instructions. It can get pretty messy pretty quick, which is why I find low level debuggers on old 8-bit CPUs a marvel as they had to deal with only software breakpoints.
- https://github.com/parttimenerd/python-dbg/ - Part 1: https://mostlynerdless.de/blog/2023/09/20/lets-create-a-pyth... - Part 2: https://mostlynerdless.de/?p=1102&preview=1&_ppp=a17cda3e36
--
1: https://github.com/munificent/craftinginterpreters/issues/92...
https://github.com/munificent/craftinginterpreters/blob/mast...
I learned a lot.
I like "Advanced Windows Debugging" by Mario Hewardt and Daniel Pravat.
It's a debugger for Windows (and Wine), like the one in the article, written in C. It uses software breakpoints (infinite breakpoints)
And more generally there is "The Debugging Book" in python. https://www.debuggingbook.org/
Debugger knowledge seems to be scattered across the internet and language implementations. Also I never found a language implementation book that talks about how to make the implementation friendlier/compatible with writing a debugger.