Instrumentation & Dynamic-Analysis Tools
Forward-looking design for a family of debugging and profiling tools in the
Valgrind / callgrind / AddressSanitizer / UBSan tradition. The load-bearing
decision in this document is that kit's user-mode emulator (src/emu,
../EMU.md) becomes the substrate for the dynamic-binary-
instrumentation (DBI) family, and that its design is fixed now, while the
emulator is still partial, so the instrumentation contract is built in from the
start rather than retrofitted.
Status of the substrate at time of writing:
- Only rv64 ships a real
ArchEmuOps; aa64 and x64 are empty stubs (src/arch/emu_stubs.c). Whatever instrumentation contract the lifter must honor can be specified before those two lifters are written. - There is no block chaining yet — every block round-trips through the
dispatcher (
kit_emu_step,src/emu/emu.c:640). That round-trip is a natural per-block hook that will disappear when chaining lands, so any block-level instrumentation must be designed to survive chaining. - Guest memory is reached only through helper calls (
__emu_load*/__emu_store*,src/emu/emu.h:460) funneling through one choke point (emu_addr_space_ptr,src/emu/image.c:333). Guest syscalls funnel through one choke point (emu_syscall,src/emu/runtime.c). These are ideal, stable observation seams today; the design must keep them stable as the emu optimizes.
This is the right moment to get the seams right once.
1. Three substrates, one document's focus
Kit grows three distinct instrumentation substrates. They are complementary, not competing; this document designs A in depth and situates B and C.
| Substrate | What it observes | Mechanism | Status | |
|---|---|---|---|---|
| A | Emu DBI tools (this doc) | Any guest binary, instruction-by-instruction | Software emulation + per-block JIT translation; tool hooks at the runtime/lift boundary | emu partial (rv64 only) |
| B | Compiler sanitizers | kit-compiled programs, from any frontend | Compile-time IR instrumentation + a runtime library | not started |
| C | Host-JIT debugger / profiler | kit-compiled code running natively on the host | Software breakpoints, displaced single-step, FP-walk sampling | kit dbg shipped, kit prof planned (DEBUG.md) |
The three differ on a single axis — where the observed code runs:
- A runs guest code on a synthetic CPU (the emu). Exact and deterministic, ~10–50× slowdown, needs no source and no recompile, works on closed binaries and cross-architecture guests. This is the Valgrind/callgrind/memcheck niche.
- B runs on the real host CPU with checks baked into the binary at compile
time. Near-native speed, needs source + recompile, only sees instrumented
code. This is the ASan/UBSan/MSan/TSan niche. Its design is the
CG-API-as-narrow-waist story (see §11); it is frontend-neutral by
construction because every frontend funnels through the same
kit_cg_*API. - C runs kit-compiled code natively and interrupts it with host debug
primitives. Designed already in DEBUG.md / ../DBG.md;
cross-referenced here only where seams are shared (disassembly, DWARF
symbolication, the
KitProfBufring, folded-stack output).
Why the emu is the right home for Family A. The emu already is a DBI engine: it disassembles guest basic blocks, lifts them through the CG pipeline, JITs them, and dispatches through a code cache — structurally identical to Valgrind's disassemble-and-resynthesize core, with the kit CG/opt IR playing the role of VEX. Adding the DBI tool family is therefore not a new engine; it is a tool-plugin layer over an engine kit already has. The cost of getting it wrong is high, though: the emu's hot path (dispatch, memory helpers, lifting) is exactly where tools must hook, so the seams must be first-class.
2. The architectural thesis: observation is a first-class emu concern
The emu was conceived as a way to run a guest binary. Once the DBI tool family is a primary customer, the emu's job widens: it must let a tool observe every architecturally-visible event — instruction retirement, memory access, control transfer (call/return/branch), and syscall — with enough fidelity to count, check, shadow, or trap on each, while staying arch-neutral (so a tool written once works for rv64, aa64, x64, and any future guest) and format/OS-neutral.
Valgrind's enduring lesson is one core, many tools: a single instrumentation
core exposes a small hook vocabulary, and Memcheck, Callgrind, Cachegrind,
Massif, Helgrind are all plugins over it. Kit adopts the same shape. The
EmuToolHooks vtable (§4) is that hook vocabulary. Everything else in this
document is either (a) making sure the emu's hot seams can drive those hooks
without the hooks evaporating under future optimization, or (b) cataloguing the
tools that ride them.
Two-way freeness is the prize, and it holds on two axes at once:
- New tool → every guest arch for free. Hooks live above the per-arch lifter, so a tool never contains arch-specific code.
- New guest arch → every tool for free. When the aa64/x64 lifters are written to the contract in §10, every existing tool lights up for them.
3. The four load-bearing decisions
These were settled explicitly during design and shape everything below.
Tool seam = a hook vtable at the runtime boundary (not an IR pass, not an interpreter-only plane).
EmuToolHooksis invoked by the runtime helpers, the dispatcher, a thin lift-time shim, and the syscall trampoline. Arch- neutral, shared across JIT and INTERP modes, and — critically — designed to survive block chaining (§7). The CG-IR-pass and interpreter-plane approaches are compositional refinements under this spine (§4.4), not the spine.Memory = stable indirection whose fast path degrades under tools. When no memory tool is active, the lifter may inline a softmmu/TLB fast path. When any memory tool is active, every access routes through the checked helper seam and fires
on_mem_access. "Fast when unobserved, fully observable when needed" — the QEMU/Valgrind answer. Peak throughput is capped only while a memory tool runs (§6).Stepping = tiered. A single-instruction-block mode (cap the block to one guest instruction) delivers JIT-speed single-step, guest-PC breakpoints, and watchpoints immediately, with no interpreter refactor. A later per-IR-op interpreter yield enables rich interpreter-resident tools (§8).
Scope = full-suite design, one reference impl per class. The hook ABI and seams are general enough for the whole suite (callgrind, cachegrind, memcheck, massif, helgrind, asan/ubsan, strace, a guest debugger); §12 carries a worked reference implementation for one tool in each of the three tool classes (profiler, memory-checker, debugger) to prove the seams.
4. The EmuToolHooks ABI — the spine
A tool is a struct of function pointers plus an opaque user context, registered
on a KitEmu before stepping begins. The emu core invokes hooks at fixed
observation points. A NULL hook means "not observed" and costs at most one
predicted-not-taken branch; a NULL vtable means "no tool", and the emu takes
its existing fast paths unchanged.
4.1 The interface
/* include/kit/emu.h — public, embedder-facing */
typedef struct KitEmuToolHooks {
/* Control flow / profiling. block_id is the guest PC of the block head;
* stable across re-translation (the bijective emu_block_<pc> identity). */
void (*on_block_entry)(void* user, KitEmu*, uint64_t block_pc,
uint32_t guest_insn_count);
void (*on_insn_retire)(void* user, KitEmu*, uint64_t guest_pc,
const KitDecodedInsn*); /* step/cachegrind/cov */
void (*on_branch)(void* user, KitEmu*, uint64_t from_pc, uint64_t to_pc,
KitEmuEdgeKind kind); /* CALL/RET/JMP/COND */
/* Memory. addr/size are guest; is_write distinguishes load vs store;
* host_ptr is the resolved host pointer AFTER the bounds check (NULL on a
* faulting access). Return KIT_EMU_TOOL_DENY to convert the access into a
* guest fault (watchpoint / poisoned-redzone hit). */
KitEmuToolVerdict (*on_mem_access)(void* user, KitEmu*, uint64_t addr,
uint32_t size, bool is_write,
void* host_ptr, uint64_t guest_pc);
/* Address-space lifecycle — lets a shadow plane track map/unmap/protect. */
void (*on_map)(void* user, KitEmu*, uint64_t va, uint64_t len, uint8_t perms);
void (*on_unmap)(void* user, KitEmu*, uint64_t va, uint64_t len);
/* Syscalls — strace, and allocation interception via brk/mmap. Fires
* around the existing single choke point. */
void (*on_syscall_entry)(void* user, KitEmu*, const KitEmuSyscallRequest*);
void (*on_syscall_exit)(void* user, KitEmu*, const KitEmuSyscallRequest*,
const KitEmuSyscallResult*);
void* user;
} KitEmuToolHooks;
KitEmuEdgeKind is derived directly from the decode flags already on
KitDecodedInsn (KIT_DECODE_CALL/RET/BRANCH/TERMINATOR,
include/kit/arch.h), so the emu classifies edges without arch-specific code.
4.2 Where each hook is driven
| Hook | Driven from | Frequency |
|---|---|---|
on_block_entry |
block prologue (baked in at lift time by a shared shim) | once per block execution — survives chaining |
on_insn_retire |
only emitted when a retire-level tool is active; per-guest-insn shim in the lifted body, or naturally in 1-insn-block step mode | per instruction |
on_branch |
block epilogue / terminator lowering | once per block exit |
on_mem_access |
the __emu_* memory helpers (src/emu/runtime.c) at the emu_addr_space_ptr choke point |
per guest load/store (tool-active path only) |
on_map / on_unmap |
emu_addr_space_map / _unmap / _protect (src/emu/image.c) |
per VM change |
on_syscall_* |
emu_syscall trampoline (src/emu/runtime.c) |
per guest syscall |
The crucial design point is where on_block_entry lives. It must be emitted
into the block's own prologue (a few CG ops the shared shim prepends to every
lifted block), not into the dispatcher loop. Today the dispatcher sees every
block because there is no chaining; once chaining lands, blocks jump directly to
one another and the dispatcher is bypassed. Counting in the prologue is invariant
to that change — this is exactly how real Callgrind bakes counters into the
translation rather than the dispatch loop. Driving on_block_entry from the
dispatcher would be the easy thing to do today and the wrong thing for the
chaining future; the contract forbids it.
4.3 The lift-time shim (how hooks stay arch-neutral)
The per-arch lift_block must not know about tools. Instead, translate_block
(src/emu/emu.c:420) wraps the arch lifter:
translate_block:
decode_block (arch)
cg_begin block fn
tool_prologue(cg, ctx) <-- shared: emits on_block_entry call iff a
block/profiling tool is active
arch->emu->lift_block(...) <-- unchanged per-arch body
tool_epilogue(cg, ctx) <-- shared: emits on_branch call iff active
cg_finish / link / cache
tool_prologue / tool_epilogue emit ordinary CG calls to new runtime helpers
(__emu_tool_block_entry, __emu_tool_branch) resolved by
emu_runtime_extern_resolver to thin C trampolines that forward to the active
KitEmuToolHooks. Because they are emitted by the shared wrapper, every arch
gets them for free and the rv64 lifter needs no change to acquire block/branch
observation; only the memory hook touches lifter-emitted code, and that is
already a helper call in the contract (§10).
4.4 How the other two strategies compose under the spine
The IR-pass and interpreter-plane strategies are not discarded; they slot in as implementations of specific hooks for tools that need more than the boundary gives:
- CG-IR instrumentation is how a tool that needs per-IR-op work (e.g. a
precise Cachegrind that models every micro-op, or a UBSan-for-guest that traps
on guest-level overflow) injects code: it is a shared pass between
arch->emu->lift_blockandkit_cg_finish, gated by the tool. It rides the same lift-time shim point; the spine just calls a pass instead of emitting a single call. - Interpreter per-op hooks are how the heaviest shadow-value tools (Memcheck-grade definedness / taint) get a uniform per-op execution point (§8). The hook vocabulary is unchanged; only the driver (interpreter vs JIT'd helper) differs.
This is the payoff of choosing the vtable as the spine: a tool is defined by which hooks it implements, and the emu is free to drive those hooks from a helper call, an inlined check, an IR-pass insertion, or an interpreter step, whichever the active configuration makes cheapest.
5. Shadow memory as a first-class, extensible plane
Most Family-A tools (memcheck A/V bits, ASan addressability, MSan definedness,
DRD/Helgrind access history, a watchpoint set) need shadow memory: metadata
indexed by guest address. The emu already has the precedent — every EmuMap
carries per-page dirty_pages and translated_pages bitmaps
(src/emu/emu.h:91). The design generalizes this into named shadow planes.
/* A tool registers a shadow plane describing bytes-of-shadow per guest byte.
* The emu allocates/extends/frees the plane in lockstep with EmuMap lifecycle
* (on_map/on_unmap/protect), so the tool never tracks the VM layout itself. */
typedef struct EmuShadowPlaneDesc {
const char* name;
uint32_t shadow_bits_per_byte; /* memcheck V=8, ASan ~0.125 (1/8), watch=1 */
uint32_t shadow_bits_per_page; /* coarse per-page planes (dirty-style) */
} EmuShadowPlaneDesc;
uint8_t* emu_shadow_for(EmuShadowPlane*, uint64_t va, uint64_t nbytes);
Two reasons this belongs in the emu core, not in each tool:
- The emu is the only module that knows the VM layout (
EmuAddrSpaceis the sole guest→host translator, ../EMU.md). A tool that tracked maps itself would duplicate split/merge/brk/mmap logic and drift. - The generation-counter invalidation that handles self-modifying guest code
already touches per-page metadata; shadow planes ride the same machinery, so
SMC and
mprotectstay correct for shadows automatically.
The fast/slow memory split (§6) reads cleanly against planes: with no plane
registered, the inline TLB path returns the host pointer directly; with a plane
registered, the access takes the helper path, which computes
emu_shadow_for(addr) alongside emu_addr_space_ptr(addr) and calls
on_mem_access with both in scope.
6. The memory seam: stable indirection, fast path degrades under tools
Memory access is simultaneously the emu's hottest path and its richest observation point. The decision is to keep a stable indirection that a tool can always intercept, while permitting an inline fast path when unobserved.
guest load/store in a lifted block
│
├── no memory tool active ──> inline softmmu TLB probe
│ (va→host_ptr, perm check) ──> host access
│
└── memory tool active ─────> __emu_load/store* helper (current path)
│
emu_addr_space_ptr (bounds/perm, image.c:333)
│
on_mem_access(addr,size,is_write,host_ptr,pc)
│ verdict ALLOW ──> host access
└─ verdict DENY ──> emu_fault_deliver
(watchpoint / poison)
Design notes:
- Today the slow path is the only path (the rv64 lifter always calls the checked helper). So Family-A memory tools work the moment the hook is wired — the fast path is a later throughput optimization, not a prerequisite.
- The checked helpers already carry
fault_pc/next_pc(src/emu/emu.h:493) and route faults throughemu_fault_deliverso an OS personality can synthesize a guest signal. A watchpoint or a poisoned-redzone hit reuses that exact path:on_mem_accessreturns DENY, the helper delivers a fault, and the tool (or the OS) decides whether it becomes a guest signal or a host-visible stop. No new fault plumbing. - The fast path must be a true superset relationship: the inline TLB and the
helper must agree byte-for-byte on bounds/permission semantics, so toggling a
tool changes speed and observability only, never guest-visible behavior. The
TLB is a cache over
emu_addr_space_ptr, invalidated by the same generation counter. - A per-
KitEmuflagmem_tools_activeselects which path the lifter emits. Because lifting is lazy and per-block, enabling a memory tool mid-run simply drops the code cache (the generation-bump mechanism already does this) and re-translates on the slow path on demand.
7. Block chaining vs. instrumentation (don't paint the corner)
../EMU.md documents chaining as design intent: bump-allocate
blocks into one growing RX image (EmuCodeRegion) and patch direct jumps between
them, falling back to __emu_dispatch for not-yet-translated edges. Chaining
removes the dispatcher round-trip — and with it, any instrumentation that lived
in the dispatcher.
The contract that keeps both working:
- Block/branch/insn counting lives in the block body (prologue/epilogue shim, §4.3), never in the dispatcher. Counting is then invariant to chaining.
- Chaining is disabled while a tool that needs the dispatcher is active.
Some tools genuinely want the dispatcher boundary (e.g. a coarse single-step
that stops between blocks, or a tool that re-checks a breakpoint set each
edge). For those, a
tool_requires_dispatchflag forces the one-block-per-image strategy. This is cheap and already the current default; chaining is the opt-in fast mode, and "a heavy tool turns chaining off" is an acceptable, explicit trade. on_branchis emitted at the terminator, so even under chaining a profiler sees every edge (the patched jump and the hook call coexist; the hook is just more block-body code).
The single rule: no observation may depend on the dispatcher being on the hot path. Encode it in the shim, gate chaining on the tool, and the futures don't collide.
8. Stepping, breakpoints, watchpoints — a guest debugger
This is Family A's debugger (distinct from the host-JIT kit dbg of Family C —
see §9). It debugs guest code under emulation.
Tier 1 — single-instruction-block mode (ship first). Add a KitEmu step
mode that caps the block to one guest instruction (effectively
EMU_MAX_INSTS_PER_BLOCK = 1 for the translate, plus a terminator after the
single insn). Then:
- Single-step is one
kit_emu_step(e, 1)— the dispatcher already returns control after each block, so a 1-insn block is an instruction step. No interpreter change. - Breakpoints (guest PC) are a set checked in
on_block_entry(or by refusing to chain across a breakpointed PC): when the next block head is in the set, stop. Because block identity is the guest PC, this is an O(1) set probe. - Watchpoints (guest address) are a
DENYverdict fromon_mem_accesswhen the access range intersects the watch set — reusing the fault-delivery path. This is the emu's answer to the "watchpoints need hardware debug registers" blocker noted in DEBUG.md §8: in the emu there is no hardware to need — every access is already software-mediated. - Register / memory inspection uses the arch-neutral
ArchEmuOpsaccessors (get_gpr/set_gpr/get_sp/get_tp,src/arch/arch.h) andemu_addr_space_ptr. A debugger reads guest state without any arch-specific code of its own. - Disassembly for the debugger view reuses the shared
ArchDecodeOps(the same decoderobjdumpanddbguse), so listings come for free. - Symbolication reuses the DWARF consumer (
src/debug/dwarf_*) against the guest image's debug sections — the samekit_dwarf_addr_to_linepath Family C uses.
Tier 1 gives a usable b/r/s/c/x/p/watch REPL over any guest binary,
on any guest arch, with zero interpreter work.
Tier 2 — per-IR-op interpreter yield (later). The interpreter currently runs
a whole frame to completion (kit_interp_resume, no per-op hook). Refactoring
interp_run_stack to yield between IR ops gives (a) sub-instruction stepping for
guest debugging, and (b) the uniform per-op execution point the heaviest
shadow-value tools want (Memcheck definedness, taint). This is a real change and
is deliberately deferred behind Tier 1 so a working debugger does not block on
it.
9. Relationship to the host-JIT debugger and kit prof
Family C (DEBUG.md) and Family A overlap in vocabulary but not in substrate, and the doc must keep them from being conflated:
Family C (kit dbg/kit prof) |
Family A (emu tools) | |
|---|---|---|
| Observed code | kit-compiled, native, on host CPU | any guest binary, on synthetic CPU |
| Breakpoint | software INT3/EBREAK patch, refcounted | guest-PC set in on_block_entry |
| Single-step | displaced step on real CPU (ArchDbgOps) |
1-insn block / interp yield |
| Watchpoint | needs HW debug regs (blocked, DEBUG.md §8) | on_mem_access DENY (free) |
| Sampling | SIGPROF + FP-walk ring (KitProfBuf) |
deterministic block/insn counts |
| Speed | near-native | 10–50× |
Shared seams to reuse, not duplicate: the ArchDecodeOps disassembler, the
src/debug/dwarf_* symbolication, the KitProfBuf folded-stack ring and
flamegraph output (a deterministic emu-callgrind can emit the same folded format
kit prof does), and the REPL command engine (DEBUG.md §4 calls for
a factored, machine-readable command engine — the emu debugger should consume the
same engine so editors get both for one integration cost).
The two are genuinely complementary: Family C answers "how does my native build behave and where is its time going" at native speed; Family A answers "is this binary correct — memory-safe, defined, race-free — and what is its exact instruction-level cost" regardless of source or host arch.
Profiling is one tool with two collectors
A specific and important case of the above: kit's sampling profiler (kit prof,
DEBUG.md §7) and the emu's deterministic profiler (§12.1) are not
substitutes, and kit prof should not be rebased onto the emu substrate.
They answer different questions — the classic perf-vs-callgrind split:
kit prof — sampling (Family C) |
emu callgrind — counting (Family A) |
|
|---|---|---|
| Measures | real wall/CPU time | exact instruction counts |
| µarch effects (cache/branch/TLB) | real, captured | invisible / only ever simulated |
| Determinism | statistical, varies run to run | bit-exact, reproducible |
| Speed | native | 10–50× |
| Target | native kit-JIT'd code | any guest ELF, any guest arch |
Kit's own perf campaign already proves both are needed: macOS sample missed
the biggest −O0 win (the O(n²) strtab_add) because sampling sees self-time
leaves only, while Linux valgrind callgrind caught it through inclusive
instruction attribution. Moving prof onto the emu would lose real timing, the
ability to profile long / IO-bound programs (the 10–50× tax), and native-target
coverage; and sampling inside the emu is pointless — the emu can count exactly,
so exactness is its job.
What they should share is everything downstream of collection:
collectors (front-end) shared profile core
────────────────────── ───────────────────
host sampler: SIGPROF + FP-walk ┐
weight = samples (≈ µs) ├─> weighted call graph / weighted stacks
emu counter: on_block_entry + ┘ → symbolication (kit_dwarf_* / addr_to_sym)
on_branch shadow call stack → folded (flamegraph), flat self/cumul,
weight = instructions (exact) speedscope / pprof / callgrind.out
Only the raw collection (a sample ring vs a per-block count + edge map) and the
cost-unit label (samples ≈ time vs instructions, exact) differ; the two
converge at the weighted-call-tree stage and share symbolication and every output
format from there on. So the architecture is one profile core, two pluggable
collectors: KitProfBuf stays the sampler's raw buffer, the emu counter keeps
its own count/edge map, and both lower into the shared aggregation /
symbolication / render layer.
CLI: keep kit prof as sampling/native; expose the deterministic collector as
kit prof --exact (or kit emu callgrind). The payoff is an in-tree,
host-independent, deterministic callgrind that emits the same flamegraph the
sampler does — retiring the external-Linux-valgrind dependency the perf
campaign currently leans on.
10. Substrate punch-list — make the emu tool-ready now
The highest-value, time-sensitive work, because aa64/x64 lifters are unwritten and chaining is undesigned-in-code. Doing these first means tools are additive later instead of forcing an emu redesign.
- Freeze the memory-access contract for lifters. Specify (in
../EMU.md and the
ArchEmuOpsdoc-comment) that every guest load/store must go through the__emu_*memory helpers — never an inlined host access — unless it goes through the sanctioned softmmu TLB intrinsic (§6) that itself preserves the helper fallback. Write this before aa64/x64 lifters exist so all three honor it. The rv64 lifter already complies. - Add the lift-time shim points (
tool_prologue/tool_epilogueintranslate_block) and the__emu_tool_*runtime trampolines, even if the only tool initially is a no-op. This nails down where block/branch observation enters, arch-neutrally. - Generalize per-page metadata into shadow planes (§5), built on the
existing
dirty_pages/translated_pagesprecedent, wired to map/unmap/protect. - Route memory faults uniformly through
emu_fault_deliverfor all accesses (the checked variants already do; ensure the plain variants and the future TLB path can escalate identically), so a tool DENY verdict has one delivery path. - Land the 1-insn-block step mode (§8 Tier 1) — small, unblocks the whole
debugger class, and exercises
on_insn_retireend-to-end. - Define
KitEmuToolHooksininclude/kit/emu.hand akit_emu_set_tool_hooks(KitEmu*, const KitEmuToolHooks*)setter; thread the active vtable through the runtime trampolines and the syscall trampoline. - Decide the chaining/tool interaction flag (
tool_requires_dispatch, §7) as part of the chaining design rather than after it.
Items 1, 2, and 7 are the irreversible-if-skipped ones: they constrain code that is about to be written (the aa64/x64 lifters, the chaining loop). Everything else can be added incrementally.
11. Family B in brief — compiler sanitizers across all frontends
Out of this document's primary scope but recorded so the substrates stay
coherent. Family B instruments kit-compiled programs at compile time, and its
design centerpiece is that the public CG API is kit's narrow waist: every
frontend (C, wasm, toy, future) funnels through kit_cg_*, so instrumenting
below that surface gives all frontends the tool for free.
- Language-neutral tools (ASan, MSan, coverage): a shared pass over the
opt-IR (late, post-DCE, LLVM-style) walking
kit_cg_load/kit_cg_store/kit_cg_callnodes — zero per-frontend code, because C'sa[i], wasm'si32.load, and toy's load all lower to the same IR node. - Language-specific policy (UBSan): the per-op trapping flags
already exist in the CG API —
KIT_CG_INTOP_TRAP_SIGNED_OVERFLOW/TRAP_UNSIGNED_OVERFLOW(include/kit/cg.h:846). The frontend's only job is to OR the flag in per its language's UB rules (C does; wasm doesn't); the shared lowering turns the flag into a check +__kit_ubsan_handle_*call, reading the already-stickykit_cg_set_locfor the report location. - Shared runtime + reporting:
__kit_{ubsan,asan}_*handlers, shadow setup, and malloc replacement live once inrt/; reports reuse the existing DWARF / addr2line / backtrace machinery, which is already frontend-neutral.
This is a separate roadmap to write when Family A's tier-1 lands; it shares only the philosophy (instrument the narrow waist) with Family A, not the substrate.
12. Reference implementations — one per tool class
Concrete sketches proving the §4 hooks suffice for each class.
12.1 Profiler class — emu callgrind (deterministic)
Goal: exact, reproducible instruction counts with inclusive caller→callee
attribution, output in callgrind.out format for callgrind_annotate /
KCachegrind, and folded stacks for the shared flamegraph path (§9).
Hooks used: on_block_entry (per-block instruction count from
guest_insn_count), on_branch (maintain a shadow call stack: push on CALL, pop
on RET, attribute self-cost to the current frame and inclusive cost up the
stack), optionally on_mem_access for a Cachegrind-style cache model.
state: shadow call stack of (fn_pc, self_count); map (caller,callee)->count
on_block_entry(pc, n): cur.self += n; total += n
on_branch(from,to,kind):
CALL: edges[(cur.fn,to)]++; push(to)
RET: pop()
else: if to starts a known fn, treat as tail edge
on exit: walk frames, write callgrind.out (fn = nearest symbol via DWARF)
Determinism falls out of counting rather than sampling — the same property that
makes this directly useful for kit's own -O0 perf campaign (it retires the
external-Linux-callgrind dependency the perf work currently leans on, and runs on
any host for any guest arch). ~one file (src/emu/tool_callgrind.c) + a driver
verb.
12.2 Memory-checker class — emu memcheck (addressability + redzones)
Goal: detect invalid reads/writes, heap buffer overflows, and use-after-free on an unmodified guest binary — Memcheck's core, minus (initially) bit-precise definedness, which waits for the Tier-2 interpreter (§8).
Hooks used: on_mem_access (consult the A-bit shadow plane; DENY → fault on
an unaddressable byte), on_syscall_entry/exit (intercept brk/mmap to set
addressability), and allocation interception via the import-binding mechanism
the emu already has (emu_call_host_import, ../EMU.md): bind the
guest's malloc/free so the tool can poison redzones around each block and
quarantine freed blocks.
shadow plane "A": 1 bit per guest byte (addressable?)
on_map(va,len): mark addressable per perms
malloc(n) intercept: underflow+overflow redzones poisoned; body addressable
free(p) intercept: poison body; quarantine (delay reuse) -> use-after-free
on_mem_access(addr,size,is_write,_,pc):
if any byte in [addr,addr+size) not addressable: report + DENY
The redzone + quarantine logic is identical to ASan's runtime; here it lives in the tool, driven by the import-binding and shadow-plane seams. Adding the V-bit (definedness) plane later is the same plane mechanism plus per-op propagation in the Tier-2 interpreter — the hook vocabulary does not change.
12.3 Debugger class — emu dbg (guest-level interactive debugger)
Goal: a gdb-style REPL over guest execution: breakpoints, single-step,
watchpoints, register/memory/backtrace inspection, source listing — for any
guest binary and arch.
Hooks used: on_block_entry (breakpoint set probe), 1-insn-block step mode
(single-step), on_mem_access DENY (watchpoints). Inspection uses ArchEmuOps
accessors, emu_addr_space_ptr, the shared ArchDecodeOps disassembler, and the
DWARF consumer for file:line and locals.
b <sym|addr>: resolve via DWARF/symbols -> guest PC into breakpoint set
r: kit_emu_step until on_block_entry hits a breakpoint
s: one 1-insn-block step
watch <addr>: add to watch set -> on_mem_access DENY on intersect
bt: FP-walk the guest stack (arch fp/lr offsets from ArchEmuOps)
p <reg|var>: get_gpr / DWARF loc-expr eval over guest memory
x <addr>: emu_addr_space_ptr + format
This reuses the factored REPL command engine that DEBUG.md §4 already calls for, so the guest debugger and the host-JIT debugger present one command surface to editors/IDEs.
13. Phasing
- Substrate (the §10 punch-list). Hook ABI, lift-time shim, shadow planes, 1-insn step mode, memory-contract freeze, chaining/tool flag. No user-facing tool yet — this is the load-bearing, time-sensitive work.
- Tier-1 tools.
emu callgrind(§12.1),emu strace(on_syscall_*only — nearly free), and theemu dbgwatchpoint/breakpoint/step debugger (§12.3). These exercise every hook except the heaviest and deliver immediate value (including the in-tree deterministic profiler kit's perf work wants). - Memory checker.
emu memcheckaddressability + redzones (§12.2) on the shadow-plane + import-binding seams. - Tier-2 interpreter yield (§8) → bit-precise definedness (memcheck V-bits / MSan-for-guest), taint, and sub-instruction stepping.
- Family B (compiler sanitizers, §11) as its own roadmap once Tier-1 proves the report/runtime patterns.
Each phase is independently shippable; phase 1 is the only one whose omission would force later rework, which is the whole reason this document exists now.
14. Open questions / risks
- Fast-path TLB vs. shadow coherence. The inline TLB (§6) must be provably a
pure cache over
emu_addr_space_ptr; any divergence becomes a heisenbug that appears only with a tool off. Needs a differential test: every access under a "shadow-everything, allow-everything" null tool must match the fast path. - Interpreter yield cost. Yielding per IR-op (§8 Tier 2) may regress INTERP throughput even when no tool is active; gate it so the non-stepping interp stays a straight loop.
- Multi-threaded guests. The emu models one thread today; Helgrind/DRD-class
tools and per-thread profiling need the same thread-enumeration widening
DEBUG.md §8 flags for Family C. Out of scope until a guest needs it,
but the
EmuThreadalready being a first-class struct keeps the door open. - Hook overhead when "inactive". Confirm a NULL vtable truly costs nothing on the hot path (no per-access branch) — the lifter must emit the fast path, not a runtime check, when no tool is registered.
- Where Cachegrind's cache model lives. A precise model wants per-access
observation (the
on_mem_accesspath) but is meaningless without realistic cache parameters per guest µarch; decide whether to ship a single generic model or make it configurable.