Writing

Building voidwalk

I started voidwalk in April because I wanted to know what a disassembler actually has to do, and reading about it only got me so far. Writing one seemed like the quickest way to find out. Until July it was called DAT, short for dynamic analysis tool. For now it only does static analysis, but the dynamic side is still the plan.

What it does today

You give it a Linux or Windows executable. It reads the first few bytes to tell ELF from PE, parses the section table, and then walks through the .text section, decoding the x86 or x86-64 machine code back into assembly. Each of those steps turned out to be a different kind of problem.

The decoder was the hardest part, and it's still a work in progress. I've restructured it several times as it grew, once to add the 0F opcode map and again to fit in 64-bit support. To check it, I diff its output against GNU objdump. On /bin/ls from Debian 13, all 22,523 instructions start at the same addresses.

No third-party disassembler

Every x86 and x86-64 instruction voidwalk shows is decoded by code I wrote. Using an existing disassembly library would have been faster, but it would have skipped the part I wanted to learn. So far the decoder handles prefixes, the full one-byte opcode map including the x87 FPU instructions, the common 0F instructions, and the 64-bit additions: REX prefixes, the r8 to r15 registers and RIP-relative addressing.

The decoder is also kept apart from the file formats. The ELF and PE readers only work out which architecture a binary was built for and pass that on, and neither of them knows how to decode an instruction. Adding a new architecture means writing one Decoder subclass and adding a case to makeDecoder():

std::unique_ptr<Decoder> makeDecoder(Arch a) {
    switch (a) {
        case Arch::X86:     return std::make_unique<X86Decoder>(false);
        case Arch::X86_64:  return std::make_unique<X86Decoder>(true);
        case Arch::ARM32:   return std::make_unique<Arm32Decoder>();
        case Arch::AArch64: return std::make_unique<AArch64Decoder>();
        default:            return nullptr;
    }
}

The ARM cases are already there. For now they point at stub decoders, which is why ARM code still shows up as raw bytes.

Reading without copying

At first voidwalk read files the ordinary way, with an ifstream: seek to an offset, read the bytes the parser asked for, repeat. That was easy to write. Then I remembered the system call overhead. Every ordinary read is a system call, and the kernel copies the bytes from its page cache into my buffer before I can look at them. A full sweep of .text makes a lot of small reads, so on a big binary that cost adds up.

In August I switched AddressSpace over to memory mapping, with mmap on Linux and MapViewOfFile on Windows. The file's pages are mapped straight into the process, so after the first access a read is just a memory load, and the OS only loads the pages that actually get used. It meant writing two platform-specific versions instead of one portable stream, and I decided the speed on large files was worth that. All file access still goes through AddressSpace, which is also where the bounds checks live.

One core, three front ends

Decoding runs on a background thread in a shared core. The front ends only talk to it through a Session object, which owns that thread:

Interface What it is for
Qt 6 desktop app Syntax-highlighted assembly alongside a hex view
Terminal interface The same thing over SSH
Command-line tool Scripting and one-off questions

Every push runs a GitHub Actions job that builds the test suite and runs its 230+ tests on both Linux and Windows. The tests build their own input binaries in memory as they run.

Keeping the UI responsive

Large binaries used to freeze the desktop app. The first disassembly view was a QTableWidget, which creates an item for every cell, whether it's on screen or not. On a big program that meant building every row up front on the UI thread, and the window stopped responding until it was done.

The switch to memory mapping was part of fixing this. The bigger change was moving the decode to a background thread, so the UI never waits for the whole sweep. The worker publishes how many instructions are ready, and the UI picks up the new rows as they arrive, so the listing fills in while you're already scrolling through it. Opening another file stops the old worker before a new one starts. The terminal UI fills in the same way.

I also replaced the QTableWidget with a QTableView over my own model. The view only asks the model for the cells that are actually visible, so the table can hold millions of rows and still paint instantly. Rows are only ever appended during a decode, so each update just tells the view about the new rows at the end.

The background thread brought a problem of its own. The worker appends instructions while the UI thread reads them, and a std::vector that outgrows its capacity reallocates, freeing the buffer the UI may be in the middle of reading. My first fix was to reserve one slot per byte of .text before the sweep, since no instruction is shorter than a byte. That was safe but wasteful: /bin/ls has 91,566 bytes of .text and 22,523 instructions, so about three quarters of the reserved slots were never used. It also trusted the section size in the file header, so a malformed binary that lied about it could make voidwalk ask for an absurd amount of memory.

So I wrote a small container, ChunkStore, that keeps elements in fixed-size blocks of 8,192. Only the table of block pointers is reserved for the worst case, which makes that reservation 8,192 times smaller, and each block is allocated when the previous one fills up. Blocks never move once they exist, so the UI can keep reading while the worker appends, and memory use follows the number of instructions actually decoded. I looked at std::deque as well, since it also leaves existing elements in place, but its indexing goes through an internal map that can be reallocated as it grows, so reading from another thread would still race.

What it still cannot do

ARM binaries are detected, but their code shows up as raw bytes. SSE and AVX instructions aren't decoded, and only .text gets disassembled: voidwalk doesn't read symbol tables, imports or the entry point yet.

SSE and AVX support is up next. After that comes the debugger, with breakpoints, stepping and instruction tracing. The terminal UI and the desktop app already have run and step controls and register and stack panes, but there's no engine behind them yet. Control-flow graphs, ARM decoding and AI-generated explanations of the disassembly come later.