commit 6d3a2b1b89cd31db1855b9a57a11abeb1602c9d1
parent bee661f4e7b2b2b9fb0da3c8947dea48402a8e39
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Sat, 13 Jun 2026 09:53:49 -0700
doc(plan): emu as dynamic-analysis substrate (INSTRUMENT.md)
Architecture roadmap for Valgrind/callgrind/asan/ubsan-style debugging and
profiling tools, with the user-mode emulator as the dynamic-binary-
instrumentation substrate. Settles the design now, while only rv64 has a real
emu lifter (aa64/x64 are emu_stubs.c) and block chaining is undesigned-in-code,
so the instrumentation contract is built in rather than retrofitted.
- Three substrates delineated: A = emu DBI tools (focus), B = cross-frontend
compiler sanitizers via the kit_cg_* waist, C = the existing host-JIT
dbg/prof (DEBUG.md) — same vocabulary, different substrate.
- EmuToolHooks vtable as the spine (one-core-many-tools); fast-path-degrades-
under-tools memory seam; shadow planes generalizing the per-page dirty/
translated precedent; tiered stepping (1-insn blocks now, interp per-op
yield later); reference impls for callgrind / memcheck / guest-dbg.
- Substrate punch-list (§10): the time-sensitive items that must precede the
aa64/x64 lifters and the chaining loop (freeze the lifter memory contract,
add the lift-time shim, decide the chaining/tool flag).
- Profiling: keep `kit prof` as host sampling, add a deterministic emu
collector; one profile core, two collectors (perf-vs-callgrind split).
Cross-linked from DEBUG.md §7; indexed in doc/plan/README.md.
Diffstat:
3 files changed, 676 insertions(+), 0 deletions(-)
diff --git a/doc/plan/DEBUG.md b/doc/plan/DEBUG.md
@@ -186,6 +186,16 @@ into a pre-allocated ring buffer and returns **without parking** — the one
property that keeps sampling cheap and guest timing undisturbed — and PCs are
symbolicated after the guest exits.
+> **Scope — this is the *sampling* profiler, deliberately on the host-native
+> substrate.** It is one of two complementary profilers: this one measures real
+> wall/CPU time by statistically sampling natively-run kit code; the
+> *deterministic* instruction-counting profiler (callgrind-style — exact,
+> reproducible, any guest arch) lives on the emulator substrate
+> ([INSTRUMENT.md](INSTRUMENT.md) §9, §12.1). `kit prof` is **not** being
+> rebased onto the emulator: the two share a back-end (symbolication + folded /
+> flat output), not a collector. See INSTRUMENT.md §9 for the perf-vs-callgrind
+> split and the one-core-two-collectors factoring.
+
Public API (`include/kit.h`):
- Add `on_sample(void* session, void* ucontext)` to `KitDbgSignalOps` (NULL =
diff --git a/doc/plan/INSTRUMENT.md b/doc/plan/INSTRUMENT.md
@@ -0,0 +1,665 @@
+# 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](../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](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](DEBUG.md) / [../DBG.md](../DBG.md);
+ cross-referenced here only where seams are shared (disassembly, DWARF
+ symbolication, the `KitProfBuf` ring, 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.
+
+1. **Tool seam = a hook vtable at the runtime boundary** (not an IR pass, not an
+ interpreter-only plane). `EmuToolHooks` is 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.
+
+2. **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).
+
+3. **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).
+
+4. **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
+
+```c
+/* 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_block` and `kit_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**.
+
+```c
+/* 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:
+
+1. The emu is the *only* module that knows the VM layout (`EmuAddrSpace` is the
+ sole guest→host translator, [../EMU.md](../EMU.md)). A tool that tracked maps
+ itself would duplicate split/merge/brk/mmap logic and drift.
+2. The generation-counter invalidation that handles self-modifying guest code
+ already touches per-page metadata; shadow planes ride the same machinery, so
+ SMC and `mprotect` stay 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 through `emu_fault_deliver` so an OS
+ personality can synthesize a guest signal. A watchpoint or a poisoned-redzone
+ hit reuses that exact path: `on_mem_access` returns 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-`KitEmu` flag `mem_tools_active` selects 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](../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:
+
+1. **Block/branch/insn counting lives in the block body** (prologue/epilogue
+ shim, §4.3), never in the dispatcher. Counting is then invariant to chaining.
+2. **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_dispatch` flag 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.
+3. **`on_branch` is 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 `DENY` verdict from `on_mem_access` when
+ 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](DEBUG.md): in the emu there is no hardware to
+ need — every access is already software-mediated.
+- **Register / memory inspection** uses the arch-neutral `ArchEmuOps`
+ accessors (`get_gpr`/`set_gpr`/`get_sp`/`get_tp`, `src/arch/arch.h`) and
+ `emu_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 decoder `objdump` and `dbg` use), so listings come for free.
+- **Symbolication** reuses the DWARF consumer (`src/debug/dwarf_*`) against the
+ guest image's debug sections — the same `kit_dwarf_addr_to_line` path 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](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](DEBUG.md) 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](DEBUG.md)) 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.
+
+1. **Freeze the memory-access contract for lifters.** Specify (in
+ [../EMU.md](../EMU.md) and the `ArchEmuOps` doc-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.
+2. **Add the lift-time shim points** (`tool_prologue`/`tool_epilogue` in
+ `translate_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.
+3. **Generalize per-page metadata into shadow planes** (§5), built on the
+ existing `dirty_pages`/`translated_pages` precedent, wired to
+ map/unmap/protect.
+4. **Route memory faults uniformly through `emu_fault_deliver`** for *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.
+5. **Land the 1-insn-block step mode** (§8 Tier 1) — small, unblocks the whole
+ debugger class, and exercises `on_insn_retire` end-to-end.
+6. **Define `KitEmuToolHooks` in `include/kit/emu.h`** and a
+ `kit_emu_set_tool_hooks(KitEmu*, const KitEmuToolHooks*)` setter; thread the
+ active vtable through the runtime trampolines and the syscall trampoline.
+7. **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_call` nodes — zero per-frontend code, because C's `a[i]`, wasm's
+ `i32.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-sticky `kit_cg_set_loc` for the report location.
+- **Shared runtime + reporting**: `__kit_{ubsan,asan}_*` handlers, shadow setup,
+ and malloc replacement live once in `rt/`; 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](../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](DEBUG.md) already
+calls for, so the guest debugger and the host-JIT debugger present one command
+surface to editors/IDEs.
+
+---
+
+## 13. Phasing
+
+1. **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.
+2. **Tier-1 tools.** `emu callgrind` (§12.1), `emu strace` (`on_syscall_*` only —
+ nearly free), and the `emu dbg` watchpoint/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).
+3. **Memory checker.** `emu memcheck` addressability + redzones (§12.2) on the
+ shadow-plane + import-binding seams.
+4. **Tier-2 interpreter yield** (§8) → bit-precise definedness (memcheck V-bits /
+ MSan-for-guest), taint, and sub-instruction stepping.
+5. **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](DEBUG.md) flags for Family C. Out of scope until a guest needs it,
+ but the `EmuThread` already 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_access` path) but is meaningless without realistic
+ cache parameters per guest µarch; decide whether to ship a single generic model
+ or make it configurable.
+</content>
+</invoke>
diff --git a/doc/plan/README.md b/doc/plan/README.md
@@ -14,6 +14,7 @@ shrinks to whatever remains open.
| [LINKER.md](LINKER.md) | Incremental linking: the file-based object-link redesign and remaining non-ELF format coverage. | [../LINK.md](../LINK.md) |
| [JIT.md](JIT.md) | Function-level hot reload, Go-runtime-style codegen support, and remaining JIT host-portability work. | [../JIT.md](../JIT.md) |
| [DEBUG.md](DEBUG.md) | The Windows debugger host adapter, x64/rv64 displaced single-step, profiling, and DWARF gaps. | [../DBG.md](../DBG.md), [../DWARF.md](../DWARF.md) |
+| [INSTRUMENT.md](INSTRUMENT.md) | Debugging/profiling tools in the Valgrind/callgrind/asan/ubsan tradition: the emulator as a dynamic-binary-instrumentation substrate (the `EmuToolHooks` ABI, shadow planes, the memory seam, a guest debugger), plus the cross-frontend compiler-sanitizer story. | [../EMU.md](../EMU.md) |
| [WASM.md](WASM.md) | Completing the Wasm object backend and remaining parser/validator coverage. | [../WASM.md](../WASM.md) |
| [ARCH.md](ARCH.md) | Remaining native-backend completeness for x64/rv64 relative to the aa64 reference, and per-call cost follow-ups. | [../ARCH.md](../ARCH.md) |
| [BOOTSTRAP.md](BOOTSTRAP.md) | The 3-stage self-build reproducibility goal and the open `-O1` issues blocking it. | [../BUILD.md](../BUILD.md) |