· 6 min read ·

From debug/elf to wasmir: Pure-Go Binary Tooling Reaches WebAssembly

Source: eli-bendersky

Go’s standard library has been quietly providing pure, zero-dependency binary format handling for over a decade. The debug package contains readers for ELF (debug/elf), DWARF (debug/dwarf), Mach-O (debug/macho), and PE (debug/pe) object files. The archive package handles zip and tar. encoding/asn1 handles DER-encoded data. None of these require CGo. None of them pull in system libraries. All of them give you typed Go structs representing the semantic content of their respective format, derived directly from the relevant specification.

watgo, Eli Bendersky’s new pure-Go WebAssembly toolkit, fits into this tradition. The announcement positions it as a peer to wabt (C++) and wasm-tools (Rust), which is accurate from a functional standpoint. But the more useful comparison for understanding why watgo’s design choices are correct for Go is with what the standard library has already been doing with binary formats for years.

What debug/elf Actually Does

debug/elf reads ELF binaries and gives you an *elf.File with typed fields: Sections []Section, Progs []Prog, FileHeader. A Section has a Type field of type elf.SectionType, an integer constant representing SHT_NULL, SHT_PROGBITS, SHT_SYMTAB, and the rest of the spec-defined values. You navigate the file’s structure through Go’s type system, not through manual byte offsets and casts.

debug/dwarf goes further: it parses DWARF debugging information out of an ELF or Mach-O or PE binary and gives you a structured view of the compilation unit tree, with typed Entry objects carrying Field values, each field having an attribute constant (AttrName, AttrLowpc, AttrHighpc) and a typed Go value.

The pattern across all of these is consistent:

  • Zero C dependency; the entire parser is the Go standard library
  • Semantic types that correspond to spec-defined concepts, not raw bytes
  • Read-only by design; debug/elf gives you a reader, not a writer

That last property is where watgo makes a meaningful departure from the standard library pattern, and the departure is justified by the different requirements of WebAssembly tooling.

wasmir as a Round-Trip IR

watgo introduces wasmir, a semantic representation of a WebAssembly module that functions as the pivot for all four core operations: parse WAT text format into wasmir, validate the wasmir module against the WebAssembly specification, encode wasmir to WASM binary, decode a WASM binary back to wasmir.

The debug packages are one-directional: read a binary, get a structured view. They have no path from a modified in-memory representation back to a binary. For their use cases, that’s appropriate. Tools like objcopy and strip that manipulate object files are C programs, and Go tooling that consumes debug information is mostly inspecting it. WebAssembly tooling has different requirements.

A build pipeline that compiles source to WASM and post-processes the output, patching imports, stripping unused exports, or linking multiple modules, needs both read and write. The wasmir IR supports this directly: decode a binary into wasmir, modify the structs, encode it back out. The round-trip is the core design, not an afterthought.

For practical WASM tooling in Go, this means you can build things the standard library pattern never supported:

  • A module registry that decodes uploaded WASM binaries into wasmir, validates them against the spec, and either accepts or rejects them on ingestion, with no subprocess and no CGo
  • A build tool that inspects compiler output by walking mod.Exports and mod.Funcs to verify the output matches expectations
  • A test harness that asserts on WASM structure: the correct functions exported, with the correct type signatures, with the expected imported host functions

The Binary Format: What Encoding and Decoding Involve

The WASM binary format is section-based. A valid module is a sequence of sections in a defined order: type section, import section, function section, table section, memory section, global section, export section, element section, code section, data section. Each section begins with a one-byte section ID, followed by a LEB128-encoded byte count, followed by its content.

LEB128 (Little-Endian Base-128) is a variable-length integer encoding used throughout the WASM binary format: section sizes, function indices, instruction immediates, vector lengths. Unsigned LEB128 encodes small integers in one byte and expands as needed; signed LEB128 handles negative integers. Go’s encoding/binary package handles fixed-width integers but has no LEB128 implementation, so watgo provides its own under the zero-dependency constraint.

The type section encodes function signatures as pairs of byte vectors: parameter types then result types, where each value type is a single byte (0x7F for i32, 0x7E for i64, 0x7D for f32, 0x7C for f64). Every function reference in the binary uses an index into the type section, not an inline type. The import section pairs UTF-8 string tuples (module name and item name) with import descriptors carrying the corresponding type index.

Writing a correct WASM encoder means managing these cross-references, maintaining section ordering, and emitting LEB128 correctly throughout. The debug/elf reader never faced this problem because ELF binaries are not typically constructed from a modified in-memory representation in Go. WASM binaries are, and watgo’s encoder handles this.

Zero Dependencies as a Distribution Property

The CLI installs with:

go install github.com/eliben/watgo/cmd/watgo@latest

The core use case is parsing a WAT source file, validating it, and writing the binary:

watgo parse stack.wat -o stack.wasm

Bendersky has already migrated his wasm-wat-samples repository from wasm-tools to watgo, which demonstrates the compatibility claim concretely. For a Go developer who has been calling out to wasm-tools in a build script, the swap is mechanical.

The zero-dependency property matters most in distribution. A Go tool that distributes as a single static binary but shells out to wasm-tools for one step is not actually self-contained; it has an implicit Rust toolchain dependency in its environment. When that tool runs in CI, it needs either a cargo install wasm-tools step or a pre-built binary download in the workflow. watgo removes that dependency entirely; the WASM processing capability travels with the binary.

Cross-compilation is the other side of this. GOOS=linux GOARCH=arm64 go build works without modification when the build depends only on pure Go. CGo breaks cross-compilation. A Go-based toolchain with watgo can be cross-compiled to any supported target platform; a Go-based toolchain with CGo bindings to wabt cannot.

What watgo Adds That debug/* Never Needed

The standard library’s debug packages never needed a validation step, because ELF, Mach-O, and PE formats have no equivalent to WebAssembly’s formal type system. WebAssembly validation is defined in Section 3 of the spec as a type-checking pass over a stack machine, with inference rules for every instruction. It is not ad hoc field checking; it is a formal type system that every conforming module must satisfy.

For a tool that accepts WASM modules from external sources, running the validator before execution is the correct design. You validate on ingestion, reject modules that fail, and hand valid modules to wazero for execution. Neither step requires leaving the Go ecosystem.

wasm-tools has the Component Model, WIT interfaces, and WASI 0.2 tooling surface that watgo currently lacks. wabt has a decade of fuzzing and is the reference oracle for spec compliance. These are meaningful advantages for specific workloads. For Go developers building tools that inspect, validate, or transform core WASM modules, the comparison that actually determines the right choice is not against wabt or wasm-tools. It is against what was available before watgo: nothing pure-Go, nothing installable without a system dependency, nothing that fits the distribution model that Go developers expect from their dependencies.

Go’s standard library pattern has always been: if a format is important enough to build tooling around, give it a pure-Go library with typed semantic structs and no external dependencies. ELF debugging information, Mach-O binaries, PE files, and ASN.1 data structures all got that treatment. WebAssembly was the gap. watgo closes it, and extends the pattern with the round-trip write capability that WASM build pipelines need but that the standard library’s read-only approach never provided.

Was this interesting?