RISC-V JIT side-exit and tick contract
Date: 2026-09-15
Status: research note (no Thumb emit). Sources are in-tree code plus QEMU TCG / tlib / Simics docs cited at the end.
This is the missing write-up before any Cortex-M codegen. #1121 (all-bail Thumb walker + lockstep) is plumbing only. Emitting wasm for Thumb is not licensed by that PR.
1. What the industry actually does
| System | Translation | RAM | MMIO | Virtual time |
|---|---|---|---|---|
| QEMU TCG | Basic blocks, cached by physical PC | Softmmu TLB addend; stay in translated code | TLB MMIO flag → helper / often end TB | icount: N ns/instruction, not cycle-accurate (tcg-icount.rst) |
| tlib (Renode) | QEMU-derived TBs, LGPL 2.1 | Inline TLB | Bus callback into C#; pagesAccessedByIo |
PerformanceInMips (default 100) → virtual µs (Renode time framework) |
| Simics | JIT / interpret / idle hypersim | Fast path | Device models | Separate realtime vs as-fast-as-possible |
| LabWired RISC-V JIT | wasmtime blocks | Copy RAM window in/out; inline load/store in window | Side-exit, interpreter does the access | 1 cycle / insn at cpu_hz; peripherals tick every peripheral_tick_interval |
LabWired is closer to QEMU’s RAM-fast / MMIO-slow split than to Renode’s MIPS clock. It is not tlib. Do not import tlib.
2. Interpreter remains the spec
From jit_framework/fallback.rs:
The JIT compiles away per-instruction structure: a block retires many guest instructions as one host call.
Forced interpreter when any of:
- observers non-empty
- breakpoints non-empty
- logic probes armed
requires_cycle_accurate()(HC-SR04, IO-Link, modelled FLASH, …)
Production gate: RiscV::jit_gate_allows (cpu/riscv.rs).
QEMU analogue: one-insn-per-tb / not chaining across MMIO. Same idea: visibility beats speed.
3. Side-exit vocabulary (ISA-neutral)
jit_framework/side_exit.rs:
| Exit | Meaning |
|---|---|
Chain { next_pc } |
Block finished; dispatcher may enter another compiled block |
EnterInterpreter { resume_pc, reason } |
Interpreter must run at least one insn at resume_pc |
Exception { resume_pc, cause } |
Synchronous fault; machine exception path |
BailReason::MemoryFault: load/store effective address outside the bound RAM window (MMIO / unmapped). Interpreter replays that instruction on the real bus.
This matches QEMU: MMIO is never the TB fast path; device models stay in C (here: Rust Bus).
4. RISC-V emit contract (what Thumb must copy if it emits)
From jit_framework/riscv/emit.rs (chunks C/D/E):
- ALU / branches operate on wasm locals mirroring
x0..x31. - RAM is one contiguous window (
bus.ram). In-windowlw/sware inline wasm loads/stores. - Out of window → write faulting PC + retired-before-fault count →
WIRE_MEM_FAULT→ interpreter at that PC (re-execute the load/store; prior insn writeback already flushed). - Compiled blocks never call the bus. So they never see UART, CLINT MMIO, or GPIO. Clock CSRs that are MMIO/mtime are not sampled mid-block.
- After a successful block,
run_jit_loopdoesupdate_mtime_after_elapsed_cycles(actual_n)so CLINTmtime/ MTIP match interpreter+1per insn (cpu/riscv.rs).
Thumb analogue (not implemented): SysTick CURRENT, NVIC pending, DWT CYCCNT if modelled. A block of n instructions is n cycles. If SysTick would underflow in those n cycles, do not run the block — same as block_would_cross_irq.
5. Tick interval and batching (the fidelity rail)
Peripherals tick every peripheral_tick_interval cycles (1 = every insn; 64/512 = batched).
run_jit_loop mirrors the interpreter batch:
- Never retire past
max_count(the batchMachine::runalready clamped to the next event). block_would_cross_irq(n): ifmstatus.MIEand the block’smtimespan would crossmtimecmp(or IRQ already pending), interpret one.- After each dispatch: if
idle_fast_forward_enabledand CPU is parked, return the batch early (same as interpreter). - If
peripheral_tick_interval > 1andretired >= earliest_pending_deadline - batch_start, return. - With event-scheduler and tick > 1:
publish_cycle(batch_start + retired)before each dispatch so an interpreted MMIO read sees the cycle-exact clock. JIT blocks do not read that clock.
Empirical gate: crates/cli/tests/riscv_tick_interval_fidelity_differential.rs — C3 OLED, tick 1 vs 64, UART + inspect framebuffer identical. Register snapshot at halt is allowed to differ (different micro-instant). That is the shipped definition of “tick widening does not change observable firmware.”
Thumb emit without an equivalent gate on a real Cortex-M UART/timer demo is not ready.
6. What landed vs what is still missing
#1120 (jit_framework/cortex_m/) is the emit path: ALU + in-window RAM load/store + common control flow, MMIO/WIRE_MEM_FAULT side-exit, WFI interpreter-owned, tick-512 ALU lockstep, UART MMIO store match.
SysTick countdown clamp: CortexM::block_would_cross_irq is the RISC-V mtime analogue. Systick::ticks_until_fire is the horizon; a compiled block of n cycles is refused when n >= horizon so exception 15 pends on the same instruction as the interpreter. After a block that does not wrap, systick_consume_cycles advances the legacy-walk CVR (scheduler-mode SysTick follows the cycle-clock bump). Dispatch still breaks the batch on an already-takeable exception.
#1121 (jit_framework/thumb/) was an all-bail walker only. Superseded by #1120 — do not merge both.
Still missing from the original “before emit” list:
- Firmware UART hello JIT-on vs JIT-off (Zephyr L0 / nRF) as a CI-weighted tick-interval gate (nRF differential covers hello at tick 512; a C3-style 1-vs-64 UART+inspect gate is the remaining analogue)
That proves dispatch/host/snapshot plumbing. It does not:
- run guest ALU in wasm
- handle RAM vs MMIO
- clamp SysTick/NVIC mid-block
- participate in
Machine::advance
Calling it “DBT” or “as fast as tlib” is false. QEMU/tlib speed comes from staying in translated RAM loops. All-bail never does that.
7. Decision: Thumb emit?
Not yet. Required before any wasm emit for Thumb:
- Write
block_would_cross_irqfor Cortex-M: SysTick countdown + NVIC already-pending, using the same “external lines stable within a batch” fact as RISC-V (peripherals tick between batches). - Copy the RAM-window +
MemoryFaultresume-at-faulting-PC path; do not inline MMIO. - Mirror
publish_cycle/ idle-ff / deadline clamp in the Cortex-Mstep_batchJIT loop (same shape asRiscV::run_jit_loop). - Add a firmware lockstep gate (not a 12-byte ADD loop): nRF or STM32 UART hello, JIT on vs off, same UART bytes — analogue of the C3 tick-interval test.
- Keep
SafetyGate; JIT off when observers/probes/breakpoints/cycle-accurate.
Until (1)–(4) exist on paper as Cortex-M types and tests, do not emit Thumb wasm.
#1121 can stay as the walker + plumbing PR. Do not grow it into emit in the same change.
#1119 (YAML sugar) is unrelated DX. It does not interact with this contract.
8. Sources (code)
crates/core/src/cpu/jit_framework/side_exit.rscrates/core/src/cpu/jit_framework/fallback.rscrates/core/src/cpu/jit_framework/riscv/emit.rscrates/core/src/cpu/riscv.rs(run_jit_loop,block_would_cross_irq,jit_gate_allows)crates/cli/tests/riscv_tick_interval_fidelity_differential.rs
External: QEMU docs/devel/tcg.html (RAM vs MMIO), docs/devel/tcg-icount.rst (not cycle-accurate), Renode time framework (PerformanceInMips), tlib cpu-exec.c / TranslationCPU.SyncTime.