kit

kit
git clone https://git.ryansepassi.com/git/kit.git
Log | Files | Refs | README

WebAssembly

WebAssembly support in kit spans the whole tree but is organized around one shared in-memory model, the WasmModule. A single binary/text/validation layer under src/wasm/ owns all format mechanics; three independent consumers sit on top of it — a frontend that lowers Wasm into native code, a backend that emits Wasm from kit's codegen API, and an object backend that reads/writes .wasm containers. This document describes that layering, the data flow through it, and why the seams sit where they do. See FRONTENDS.md, ARCH.md, and OBJ.md for the surfaces this hangs off.

The two directions

kit treats Wasm as both an input language and an output target, and the same module model serves both:

Neither direction builds a private reader or writer: both go through the one src/wasm layer. That non-duplication is the central design constraint.

                       src/wasm/  (format mechanics + WasmModule model)
                       decode.c  encode.c  wat.c  validate.c  insn.c  module.c
                                          |
                          boundary header: src/wasm/wasm.h
                          /               |                  \
                lang/wasm/cg.c    src/arch/wasm/*     src/obj/wasm/{emit,read}.c
              (Wasm -> native CG)  (CG -> Wasm)        (.wasm container <-> ObjBuilder)

The shared model and the boundary header

src/wasm/wasm.h is the contract that decouples format mechanics from the three consumers. It declares the in-memory WasmModule (types, funcs, memories, tables, globals, element/data segments, exports, custom sections, start function, plus a WasmFeatureSet bitfield) and the small leaf types it is built from (WasmFuncType, WasmFunc, WasmInsn, WasmValType, the WasmInsnKind enum of decoded opcodes). It also declares the entry points the consumers call: wasm_decode_binary, wasm_parse_wat, wasm_validate, wasm_encode, wasm_emit_cg, plus the per-section builder helpers (wasm_add_func, wasm_intern_func_type, wasm_func_add_insn, ...) and the instruction-classification helpers (wasm_insn_is_load, wasm_atomic_rmw_op, wasm_conversion_kind, ...).

Three design choices keep this boundary clean:

Format mechanics (src/wasm/)

This layer knows the wire format and the type system; it knows nothing about who is calling it.

Binary decode (decode.c). A bounds-checked BinReader cursor walks the sectioned binary: magic/version, then size-prefixed sections in order. LEB128 is decoded with explicit overflow guards — bin_uleb/bin_uleb64 reject over-long encodings, and the signed reader accumulates in uint64_t and casts at the end to avoid shifting into the sign bit. wasm_is_binary sniffs the four magic bytes \0asm; this is what kit_detect_fmt (src/api/object_detect.c) keys on to classify link inputs as KIT_BIN_WASM. A second entry point, wasm_decode_one_insn, decodes a single instruction into a caller-owned scratch module — the disassembler uses it so the opcode mapping has exactly one source of truth.

Text parse (wat.c). A from-scratch S-expression tokenizer + parser for the accepted WAT subset: modules, funcs, params/results/locals, folded and flat expressions, $name and numeric index references, type definitions, memories, data/element segments, globals, exports, imports, comments, and the full literal grammar (signed/hex/underscored integers with boundary diagnostics, f32/f64 floats, string and byte escapes). It lowers into the same WasmModule as the binary decoder and then runs the same validation, so text is a developer/test convenience, not a parallel semantic path. wasm_parse_wat_body parses a bare instruction sequence into a caller-supplied function — the backend's inline-asm path reuses it.

Validation (validate.c). A typed operand-stack + control-stack validator. wasm_validate checks module-level invariants (index spaces, start-function signature, section consistency) then validates each function body via wasm_validate_func, which tracks the value stack and a control-frame stack with block result types and unreachable-after-branch handling. Validation runs before any lowering: a malformed module never reaches CG emission or encoding. wasm_validate_func is exposed separately so callers that synthesize scratch functions (the Wasm-target inline-asm path) can validate them in isolation.

Encode (encode.c). The inverse of decode: writes magic/version and the section sequence with LEB128 immediates, through the public KitWriter. This is the single writer used by both kit_wasm_wat_to_wasm and the object-backend emit_wasm.

Model + helpers (module.c, insn.c). module.c owns construction and teardown of WasmModule and its heap-grown sub-vectors. insn.c is the shared classification table: predicates over WasmInsnKind (is-load, is-store, the atomic families), the mnemonic table, feature gating (wasm_feature_enabled, wasm_require_feature), and the maps from opcode kind to CG-level operations that the frontend lowering consumes.

Frontend: lang/wasm (Wasm → native CG)

lang/wasm is a normal kit frontend (kit_wasm_frontend_vtable, registered for .wat/.wasm; the driver also accepts -x wasm/-x wat and the dbg :language switch). It compiles a Wasm module's semantics onto a native target. This is distinct from read_wasm, which treats a .wasm file as a link-time object; the frontend rejects relocatable objects (a linking custom section) as input, telling the user to supply them as objects instead.

lang/wasm/wasm.c is thin: decode-or-parse (binary vs WAT by magic), validate, then hand the module to wasm_emit_cg in cg.c. The interesting work is the whole-module-to-native lowering in lang/wasm/cg.c.

Instance model. A Wasm module instance has state — linear memories, mutable globals, tables, imported functions, and the ability to trap. kit reifies that state as a generated per-module KitWasmInstance record (built field-by-field in wasm_cg_build_runtime): a KitWasmMemory per memory, a function-pointer slot per import, a func-ref entry per defined function, a slot per global, table storage, and passive data/elem segment slots. The runtime ABI types (KitWasmMemory, KitWasmTable, the passive-segment slots) live in lang/wasm/runtime_abi.h, not in the public API.

Every lowered function — including direct calls, call_indirect arms, the start dispatcher, and the generated __kit_wasm_init — receives a hidden KitWasmInstance*. There is no standalone-export fast path: module state is always explicit, and an unused instance pointer is left for later optimization to remove. Imported functions are indirect calls through the instance's import slots. Memory accesses read the active memory base/size from the instance and bounds-check before the native load/store. Traps call non-returning runtime helpers (__kit_wasm_trap_*, one per WasmTrapKind: unreachable, division, invalid-conversion, bounds, table, signature).

Coverage. The MVP numeric/control/memory core, plus mutable globals, imported function declarations, active tables/elements for call_indirect, start functions, growable single-memory state, bulk-memory ops (memory.copy/fill/init, data.drop, the table equivalents) with bounds-check prologues, and non-trapping float-to-int conversion. Bulk ops and non-trapping conversions are gated behind their WasmFeatureSet bits.

Host imports. When a frontend-lowered module declares imports, an embedder binds them by name through the public API in include/kit/wasm.h (kit_wasm_set_host_imports with a static table and/or a dynamic resolver). The lowered image carries three readonly metadata symbols — __kit_wasm_imports, __kit_wasm_nimports, __kit_wasm_types (wire format in runtime_abi.h). The runtime binder in lang/wasm/host_imports.c walks that metadata, looks up each (module, field) pair, validates the bound function's signature against the module's recorded WasmFuncType, and writes the pointer into the instance's import slot. Unbound imports trap on first call. The binder deliberately mirrors the raw WasmValType byte encoding rather than including the internal src/wasm header, keeping the public runtime free of compiler internals.

Backend: src/arch/wasm (CG → Wasm)

The Wasm backend is a codegen target with an unusual shape: it is a CGBackend without the native parts of ArchImpl. arch_impl_wasm (in arch.c) registers cgtarget_new and a disassembler (wasm_disasm_new, which renders the code section as WAT for objdump) but leaves asm_new, link, the label-fixup hook, and all register-file hooks NULL — there is no machine code, no native assembler, and no native image layout for wasm32. It also installs clang-compatible predefined macros (__wasm__, __wasm32__, __ILP32__, ...).

Record-then-replay. Rather than lower CG operations to Wasm directly, the backend uses the shared CG IR recorder (CODEGEN.md, IR.md): wasm_cgtarget_new (in target.c) wraps a private WTarget emitter in a cg_ir_recorder (src/cg/ir_recorder). The frontend's CG calls are first recorded into a CgIrModule; at finalize, wasm_emit_ir_module (ir_emit.c) replays that IR into the WTarget. The replay drives a second, private IR — the WIR list defined in internal.h — one record per emitted operation, kept separate from the final WasmFunc body. This two-stage buffering is what makes deferred, whole-function structuring possible: the WIR can be reordered and rewritten before it is linearized into the structured Wasm a function body requires.

The recorder config also carries the backend's diagnose-before-emit policy: it opts out of local-static-data emission, supplies the &&label-in-static-data diagnostic, and reports why a tail call is unrealizable (e.g. a variadic callee whose vararg buffer lives in a torn-down frame).

WIR and the structurer. Each WTarget SSA Reg becomes a Wasm local, materialized by local.get/local.set. Control flow is the hard problem: Wasm has no arbitrary jumps, only structured block/loop/if with relative branch depths. CG scopes (SCOPE_LOOP/SCOPE_BLOCK/SCOPE_IF) map directly, but frontends also emit free labels and goto. structure.c's wasm_structurize runs over the recorded WIR before linearization and rewrites it so every reachable free label becomes the break target of a synthetic forward SCOPE_BLOCK or the continue target of a backward SCOPE_LOOP; jumps then resolve through the ordinary scope-bound branch machinery. A unroll_switch_islands pass reorders the frontend's switch shape (jump-to-dispatch / case bodies / dispatch / selector / SWITCH) so the selector and switch precede the case bodies, turning case labels into uniform forward references. Irreducible control flow and unliftable cross-scope labels are diagnosed, not miscompiled.

ABI (abi.c). wasm32_vtable implements the tool-conventions BasicCABI for wasm32 (ILP32): void ignored; scalars ≤ 8 bytes direct as i32/i64/f32/f64; pointers as a 4-byte int part; empty structs ignored; a singleton-scalar struct passed as that scalar; all other aggregates and arrays passed indirectly (sret/byval). __int128 and binary128 long double have no Wasm representation and panic at classification with a specific message rather than routing silently through memory. Aggregates use a downward-growing linear-memory frame through a __stack_pointer global; byval params arrive as i32 pointers and are copied into a callee-isolated frame buffer; varargs are caller-packed into a uniform-slot linear-memory buffer with a hidden trailing i32 pointer.

Emit (emit.c). The largest piece, and the WIR→WasmFunc linearizer. It allocates Wasm locals, interns function types (wasm_intern_func_type), lays out the linear-memory data image compactly (SF_ALLOC sections assigned aligned bases from a low null guard upward, with symbol-address fixups patched once every section size is known), builds the func-ref table for address-taken functions, and emits the structured body. It also lowers atomics through the wasm-threads opcodes (promoting the single memory to shared on first use), compiler intrinsics (clz/ctz/popcount/bswap, checked-overflow arithmetic, memcpy/memset via memory.copy/fill), and the standard-runtime conventions: linear memory exported as "memory", functions exported by user name with no synthesized _start, and undefined function symbols promoted to (import "env" "<sym>" ...) honoring __attribute__((import_module/import_name)) overrides (the C frontend records those into a side table under OBJ_EXT_WASM_IMPORTS, read back here). The accumulating WasmModule is attached to the ObjBuilder under OBJ_EXT_WASM for the object backend to flush.

Object backend: src/obj/wasm (minimal)

The object backend is intentionally small and one-directional today. emit.c's emit_wasm reads the WasmModule the codegen backend attached under OBJ_EXT_WASM and flushes it through the shared wasm_encode (or, when no module is attached, writes a bare magic+version header). It does not synthesize a relocatable object: emit_wasm produces a single-TU final module with no linking/reloc.* custom sections and no separate object-metadata structure.

read.c's read_wasm is the reverse glue used by the linker/objdump path (LINK.md): it mirrors each binary section into a format-neutral ObjBuilder section carrying the original payload bytes (so objdump -h/-s show the real container, the code section marked SF_EXEC so -d disassembles it as WAT), and adds one function symbol per defined function (named from the name section, an export, or a synthesized placeholder, with the symbol value the byte offset of the body's locals vector so disassembly lines up). It decodes the full module only to recover names; container metadata beyond raw bytes is not otherwise interpreted. These hooks plug into ObjFormatImpl in src/obj/registry.c under KIT_OBJ_WASM / KIT_BIN_WASM. The reserved Wasm relocation kinds in obj/obj.h (R_WASM_FUNCIDX, R_WASM_TABLEIDX, R_WASM_MEMOFS, R_WASM_TYPEIDX) and the custom-section semantic tag SSEM_WASM_CUSTOM are the format-neutral vocabulary the generic object/link registry holds for Wasm, so relocatable-object support can grow on this layer without disturbing the format-neutral core.

Cross-tree boundary, restated

The whole arrangement holds together because each tier depends only downward, through src/wasm/wasm.h:

Module metadata that does not belong in any one tier rides on the shared ObjBuilder via typed extension payloads (OBJ_EXT_WASM for the module itself, OBJ_EXT_WASM_IMPORTS for import-attribute overrides), keeping the format-neutral object core free of Wasm specifics.