Modular binary translation and emulation implemented in V.
v install --git https://github.com/tinted-software/volt
module main
import volt.ir
import volt.aarch64
type Fn2 = fn (i64, i64) i64
fn main() {
mut b := ir.new_builder('mul_add')
blk := b.block()
a := b.param(blk, b.i64())
y := b.param(blk, b.i64())
prod := b.arith(blk, b.i64(), .mul, a, y)
b.ret(blk, b.arith(blk, b.i64(), .add, prod, a))
mut f := b.take()
mut img := aarch64.compile_module([&f], aarch64.host_caps())!
defer {
img.deinit()
}
entry := unsafe { Fn2(img.func_ptr('mul_add')!) }
assert entry(6, 7) == 48
}Requires V 0.5.x on an arm64 host (the JIT executes its own output, so the tests only run where the code can run).
v test . # every test file, including the emulator's guest programs
v -prod test . # same, optimized (note: -prod disables `assert`)
v -prod run tools/bench.v # IR compile-latency benchmark
v -prod run tools/guest_bench.v # guest throughput (translate + run)
v -prod run tools/tb_demo.v # translation-block dispatch demo
v -prod run tools/guest_demo.v # runs guest fib(20), prints the lifted IR and checks it
v -prod run tools/boot_kernel.v # boots an arm64 Linux kernel on the emulated virt machine
The boot tool takes -t to trace dispatcher round trips, -x to trace exceptions, and
-p to count memory traps, MMU walks and descriptor reads and time block translation.
-P also records exact no-trap dispatcher entry-PC counts and prints the top ten;
-d dumps registers and nearby instruction words at the stopping PC.
Use --arch aarch64 (-a) and --host-arch aarch64 to select the guest and
host JIT architecture independently; AArch64 is currently the only implemented choice.
--limit N (-l N, default 50,000,000) limits dispatcher trips, not guest instructions
or chained blocks. Run tools/boot_kernel.v --help for all options. Unknown options,
unsupported architectures, and non-positive limits are rejected.
The emulator's code backend boundary owns IR compilation, executable memory, and chaining;
ArchConfig and the fallible new_machine_for_arch / new_virt_machine_for_arch
constructors provide a selection point for future backends. A guest frontend supplies the
lifter and its CPU retry-flag ABI offset; instruction fetching is supplied by the machine
through InstructionSource, so lifting does not depend on a particular address space.
AArch64 CPU state, ABI offsets, decoder, lifter, system registers, exception model, MMU walk,
trap servicing, interrupt sampling, PSCI policy and Linux image/boot-protocol handling live
in guest/arm64; emu retains compatibility aliases and wrappers. The dispatcher,
machine/device models, memory placement and device-tree generation remain AArch64-specific;
those are the next separation points.
Profiling adds overhead; translation time includes lifting, compilation and arena insertion.
Dispatches without traps include ordinary unchained exits, not just memory-free guest work.
The tool also prints the emulator features the guest asked for that do not exist yet.
| Vulcan (Zig) | vemu (V) | Notes |
|---|---|---|
vulcan-ir/types.zig |
ir/types.v |
+ size_of/align_of |
vulcan-ir/function.zig |
ir/function.v |
storage, opcodes, queries, printing |
vulcan-ir/builder.zig |
ir/build.v |
convenient typed builder |
vulcan-target/aarch64/encode.zig |
aarch64/encode.v |
all 148 encoders, golden-word tests |
vulcan-target/aarch64/isel.zig |
aarch64/isel.v |
selection, frame, ABI, calls, spills |
vulcan-target/wimmer.zig |
regalloc/linscan.v |
linear scan, not the Wimmer splitter |
vulcan-target/aarch64/link.zig |
aarch64/link.v |
module image, PC-relative + GOT fixups |
vulcan-target/aarch64/jit.zig |
aarch64/jit.v |
W^X mapping, icache sync, entry points |
vulcan-target/jit_platform.zig (posix half) |
aarch64/jit.v |
mmap RW → mprotect RX, data stays RW |
vulcan-target/aarch64/simd.zig (selection subset) |
aarch64/isel.v, aarch64/encode.v |
<4 x f32>/<4 x i32> |
aarch64/tests/native.zig (subset) |
aarch64/isel_test.v |
build IR → JIT → call natively |
vulcan-target/aarch64/cpu.zig (subset) |
guest/arm64/cpu.v, emu/cpu.v |
guest state and flat memory |
vulcan-target/aarch64/decode.zig (subset) |
guest/arm64/decode.v |
guest instruction classification |
vulcan-target/aarch64/lift.zig (subset) |
guest/arm64/lift.v |
guest → IR, one block at a time |
vulcan-target/aarch64/jit.zig run loop (subset) |
emu/machine.v |
TB cache, trap servicing, dispatch |
emu/ is new work, modelled on the same Vulcan files: it is the guest side this port
needed to become an emulator rather than a code generator.
aarch64/half.v is new: V has no f16 type, so the IEEE binary16 rounding/widening that
Vulcan gets from Zig's f16 is implemented directly (needed for f16 constants, memory
traffic, and the fcvt-based emulation).
Selection lowers: iconst, fconst, arith (+arith_imm), icmp, select, convert,
unary (reinterpret/sqrt/ceil/floor/trunc/nearest), alloca, load/store, prefetch,
global_addr (direct and via_got), call, call_indirect, splat, reduce.add,
extract, struct_new, dot, and the ret/jmp/condbr terminators.
Scalar types: i8/i16/i32/i64, u8/u16/u32/u64, pointers, f16 (emulated and native
FEAT_FP16), f32, f64. Vectors: <4 x f32> and <4 x i32> lane-wise arithmetic, compares,
blends, packs/extracts, horizontal add, SDOT/UDOT.
Everything else fails with an explicit error(...) rather than emitting wrong code:
f128 arithmetic/compares/converts (Vulcan lowers these to soft-fp libcalls in
vulcan-ir/softfp.zig, not ported), floating-point stack arguments, multi-register
(struct) returns, va_start/va_arg, and aggregates beyond a vector pack.
- Conditional branches are terminators. Vulcan models
ifas a non-terminating instruction followed by a terminator. Its emitter setsterminatedon that instruction and always emits it last, so the two shapes are semantically identical; one CFG location is simpler to keep correct.ir/function.vstates this. - No Wimmer live-range splitting.
regalloc/linscan.vbuilds the same intervals (holes from block liveness, call clobbers, callee-saved bookkeeping) and applies the same "free until" scan, but an interval that cannot be placed is spilled whole and reloaded at each use. A JIT is optimizing for compile latency too, and this keeps the pass at ~40 µs for 600 instructions. Splitting is additive: it turns one location per value into segments, which the emitter can consume without changing anything else. - No address folding, no
ldp/stppairing, no compare/fma/shift fusions. These are code-quality passes over already-correct selection. Vulcan gates them onModelCaps; the same seam exists asCapsfields, unread for now. They are the first follow-ups if code quality matters more than compile latency for a given guest. - Callee-saved FP registers save their low 64 bits, exactly as Vulcan does; the
allocator therefore never keeps a 128-bit vector in
v8..v15across a call. - The f16 emulation rounds per operation (
fcvt h,sthenfcvt s,h), matching Vulcan's base-ISA path; passCaps{ fp16: true }for native H-form arithmetic.
tools/bench.v, -prod, Apple M-series, steady state, translation blocks shaped like
lifted guest code:
| IR instructions | machine words | compile | compile + map |
|---|---|---|---|
| 46 | 61 | 20 µs | 25 µs |
| 157 | 172 | 59 µs | 66 µs |
| 604 | 619 | 222 µs | 235 µs |
Interval construction dominates; the pass is flat-indexed and allocation-free per operand, with a counting sort over program positions. The remaining hot spots are the backward liveness fixed point and the per-value interval scan.
emu is a guest AArch64 front end for the JIT. It is a real (if partial) interpreter
core: decode, lift, translate once, execute natively.
guest memory ──decode──> Op ──lift──> ir.Func ──isel/encode/link/mmap──> native block
▲ │
└────────── TB cache keyed by guest pc ◄──────┘
Guest state is emu.Cpu: x0..x30, sp, pc, packed NZCV flags, and the fields a
block leaves for the run loop (trap kind, address, value, destination, width, sign,
writeback). The field order is the ABI, since translated code addresses every field by
offset, and emu_test.v asserts each offset.
Blocks end at every memory access and every control-flow change. Control flow because the next address is not known until the block runs; memory because the access is serviced by the run loop, not by JIT'd code. That keeps guest addresses bounds-checked (a bad access is a V error with the address in it, never a host segfault) and puts device models where they belong — the same split Vulcan's run loop uses.
Within a block: guest registers are loaded from Cpu on first use, kept in IR values,
and written back once at the end; the flags live in an IR value and are packed into PSTATE
layout only when they have to be materialised. Arithmetic is done on 64-bit IR values and
masked for W forms, so the guest's zero-extension rules fall out of the lowering.
Translation blocks live in one growing code arena (aarch64/arena.v). The arena is a
single mapping with a writable data section at the start (globals, GOT entries, and a
scratch slot) and a code section after it that blocks are appended to. Code stays
position-independent, so appending never disturbs existing blocks; the only absolute values
are GOT entries, written once the mapping address is known. W^X is exact: the arena seals
code pages to R+X after every append. Normally only the tail page is reopened for the next
block; when older relocations or chains need patching, the existing code region is reopened.
The affected range is synchronized with the instruction cache before execution.
Each block has two forms. The fast form inlines guest memory accesses behind a bounds
check and keeps going through them, so a block ends only at a control-flow change. The
trap form is the conservative one: it stops at the first memory access and leaves it for
the run loop. The two exist because a fast block that finds an out-of-range address cannot
just continue: it stores a retry flag and returns to its own entry address, and the run
loop immediately re-runs the block in trap form, which stops at that access so it can be
reported (or, later, sent to a device). Retrying is safe because a block is straight-line
code — but only if the failure path leaves Cpu exactly as it found it. The register cache is
therefore flushed after the retry split, on the success path only: flushing before it (as an
earlier version did) applies every register update of the block twice, which for something as
common as sub sp, sp, #0x90 walks the stack pointer off into the weeds.
The fast form's inline accesses are to physical memory, so the bounds check subtracts
ram_base first: guest addresses are physical addresses, and subtracting up front also makes
the unsigned comparison reject addresses below RAM, which would otherwise wrap into a "valid"
offset.
Blocks chain into each other. A fast block whose next pc is statically known (an
unconditional or conditional branch — conditional branches are lifted as two exits, each with
its own constant) ends with a branch that initially points at its own return. When the block
that implements that pc is translated, the arena rewrites the branch into a direct jump to its
entry, so a loop never leaves JIT code. The patch is guarded by one load and a compare against
the retry flag, because a block that found an out-of-range access must return to the
dispatcher instead; the retry exit is never chainable. Chained entries keep the ordinary
function ABI — the exit's epilogue has already restored the caller's registers, so the callee
prologue simply runs again — which is why nothing else had to change.
The run loop then services the trap if there was one, applies any base-register writeback,
and jumps to the returned address. run(limit) therefore counts dispatcher trips, not blocks:
a loop that chains never comes back. (A guest loop that never traps will not be interrupted —
that is what the interrupt/exit-request work is for.)
Lowered: ADD/SUB (immediate, shifted register, extended register, with flags),
AND/ORR/EOR/ANDS (immediate and shifted register), MOVN/MOVZ/MOVK, SBFM/UBFM
(LSL/LSR/ASR immediate, SXTB/SXTH/SXTW/UXTB/UXTH, UBFX), EXTR (ROR immediate),
MADD/MSUB/MUL/SMULH/UMULH, SDIV/UDIV, LSLV/LSRV/ASRV/RORV, CSEL/CSINC/CSINV/CSNEG
(so CSET/CSETM/CINC/CINV/CNEG and NEG/MVN via XZR), ADR/ADRP, all LDR/STR
unsigned-offset and unscaled/pre/post-index forms with byte/half/word/64-bit and sign
extension, LDP/STP (all addressing modes, LDPSW), LDR literal, B, BL, B.cond,
CBZ/CBNZ, TBZ/TBNZ, BR/BLR/RET, NOP, hints, barriers, SVC, BRK.
Also lowered: CCMP/CCMN, MRS/MSR of the system registers below, MSR <pstate field>, #imm (DAIFSet/DAIFClr/SPSel), ERET, HVC/SMC (the PSCI conduit), DSB/DMB/ISB,
cache/TLB maintenance (SYS, accepted as no-ops), CLREX, REV/REV16/REV32/RBIT/CLZ/CLS,
BFM (BFI/BFXIL), PRFM (a hint), LDAR/STLR and LDNP/STNP, loads and stores with
a register offset (ldr xt, [xn, xm, sxtw #3]), and the exclusive family
(LDXR/STXR/LDAXR/STLXR, LDXP/STXP and their acquire/release forms) with a
single-threaded monitor.
Refused with an error rather than miscompiled: ADC/SBC (carry input), all SIMD/floating
point, LSE atomics (LDADD/SWP/CAS), pointer-authenticated branches.
emu also models a machine, not just a CPU: tools/boot_kernel.v boots an arm64 Linux
kernel the way qemu-system-aarch64 -M virt does.
v -prod run tools/boot_kernel.v [kernel [initrd]]
What a boot needs, and where it lives:
| piece | file | what it does |
|---|---|---|
| address space | emu/bus.v |
RAM at 0x4000_0000, devices behind it, faults instead of host crashes |
| console | emu/pl011.v |
PL011 UART: TX to stdout, FR always ready, registers a driver can set |
| interrupts | emu/gic.v |
single-CPU GICv2 subset with timer PPIs and PL011 TX interrupt |
| hardware description | emu/dtb.v |
flattened device tree writer plus the virt tree (RAM, cpu, psci, timer, PL011, GICv2) |
| boot protocol | guest/arm64/boot.v |
unwraps an EFI zboot PE (gzip payload) or a raw Image, parses the header, sets EL1/MMU-off/x0 = DTB |
| kernel placement | emu/boot.v |
places the kernel, initrd and DTB in RAM and resets the machine for boot |
| privileged state | guest/arm64/sysreg.v |
system registers by encoded name, exception entry/ERET, counter reads |
| translation | guest/arm64/mmu.v |
stage-1 EL1/EL0 walk, 4 KiB granule, block and page descriptors, AF and read-only permission |
| traps | guest/arm64/trap.v |
memory, sysreg, SVC/BRK, ERET and exclusive servicing through TrapHost |
| interrupts | guest/arm64/interrupt.v |
timer/UART line sampling and IRQ exception entry through InterruptHost |
| firmware | guest/arm64/psci.v |
SMCCC and single-CPU PSCI policy through FirmwareHost |
Two machine details are worth knowing:
- With the MMU off, blocks are compiled in the normal fast form and inline their RAM
accesses. With the MMU on, every access needs a translation, so blocks are compiled in
trap form and the run loop translates, dispatches to RAM or a device, and raises a data
abort with
ESR/FARon failure. That is slow but correct, and it is where an inline TLB-checked fast path will go. - System register accesses, PSCI calls and exceptions are the seam between JIT code and the
machine: a block records what it wants in
Cpuand returns, so all of the policy lives in V code where it can be printed, single-stepped and unit tested.
emu/virt_test.v covers the pieces without needing a kernel: device tree round-trip,
UART writes through the bus, exception entry with ESR/FAR/ELR and ERET, system
register round-trips, the exclusive monitor, and MMU translation including a permission
fault.
emu_test.v assembles small guest programs with the host encoder and runs them:
mut m := emu.new_machine(1 << 16)
m.load(0x1000, emu.words_bytes([
aarch64.movz64(.x0, 0, 0),
aarch64.movz64(.x1, 10, 0),
aarch64.add64(.x0, .x0, .x1),
aarch64.subs_imm64(.x1, .x1, 1),
aarch64.bcc(.ne, -8),
aarch64.svc(0),
]))!
m.cpu.pc = 0x1000
exit_code := m.run(10_000)!
assert exit_code == 55svc #0 is the one system call: it halts the machine with x0 as the exit status.
tools/guest_demo.v does this for real guest code: it lifts a Fibonacci loop, prints the IR
one block compiles to, runs it, and checks the answer against the host.
Three pieces make it work, and each is small:
- The IR has a terminator for it.
Term.Retreturns a computed value (used byRET/BR, which cannot be chained);Term.Exit{target, chain}returns a statically known pc and marks it chainable. The lifter emitsExitfor branches, and splits a conditional branch into twoExitblocks so both arms have a constant target. - The selector reports patch sites. For a chainable
Exitit emitsepilogue; ldr guard; cbnz …; b <placeholder>; mov x0, target; retand records the offset of the placeholder inCompiled.chains. The jump deliberately happens beforex0is written: a chained entry reads its own arguments fromx0-x2, so the fallback is the only path allowed to materialize the return value. - The arena retargets them.
Chain{at, target}is resolved against the symbol table when the target block is translated; if it is not there yet, the site is queued and patched when it appears. Unresolved sites stayb +4, i.e. an exact no-op that falls through to the return, so a block whose successor is never translated behaves exactly as before.
tools/guest_bench.v, -prod, Apple M-series, 10 000-iteration loops, best of five runs:
| loop | ns/iteration | ns/guest instruction | MIPS | dispatches for 10 000 iterations |
|---|---|---|---|---|
| 3-instruction register loop | 3.8 | 1.27 | 789 | 1 |
| 4-instruction loop with one store | 4.5 | 1.13 | 889 | 1 |
The history of the second column is the roadmap: trapping every access cost 21.6 ns/iteration, the inline memory path took it to 8.0, and chaining took it to 4.5 — a whole loop now runs in a single dispatch. Per guest instruction the figures are now in the same ballpark as a simple interpreter's dispatch cost but with real code generation, and the remaining overhead is per-block, not per-instruction: the frame save/restore, homing the block arguments, and reloading the guest registers the block touches. That is what the register-mapped guest state item below is for.
A Linux kernel is the target that drives the order now. Where the boot currently stands:
- The image is unwrapped, placed and entered, the kernel relocates itself, builds its page tables, turns the MMU on, jumps to its link addresses, installs its exception vectors and reaches the console with formatted kernel messages. With the supplied Alpine 6.18 image, the GIC and architected timer initialize, timer IRQs drive the scheduler, and boot progresses through network initialization. A 150-million-dispatch run stops at its limit; it has not yet reached userspace.
-ttraces dispatcher round trips,-xtraces exceptions, and the tool prints the guestpc, the trapping instruction, the faulting access and the translation registers when the run stops. QEMU's-d in_asmlog of the same image is the reference to diff against; a useful trick is to compare QEMU's block starts against the set of pcs the emulator reaches, which is how theLDAR/STLRbug below was found.
The pieces a kernel needs, in the order they block it:
- Complete interrupt handling. The single-CPU GIC subset supports level interrupts, priorities, acknowledgement and EOI; nested priority preemption, security groups, edge inputs, multicore routing and efficient WFI sleep are not yet implemented. WFI currently behaves as a spurious wakeup. The virt machine disables block chaining so interrupts can be sampled at block boundaries; active timers sample the host clock every 64 dispatches.
- Complete PSCI and SMCCC. Version and feature discovery plus a single-CPU subset are implemented; suspend and real system reset are not. Unknown calls return NOT_SUPPORTED.
- Guest FP/SIMD. arm64 Linux has no FP-less configuration: context switch saves/restores
FP state, and checksums/crypto use NEON. The host backend already selects
<4 x f32>/<4 x i32>, so this is lift-and-policy work. - The long tail of Linux's instruction mix:
CRC32,LSEatomics if advertised (we do not advertise them, soLDADD/SWP/CASnever appear),DC ZVA(advertised as prohibited throughDCZID_EL0),IC IALLU,TLBI. - Self-modifying code / TB invalidation. Needed as soon as user space appears (and for
kprobes-free early boot it is not), plus per-process address-space tagging onceTTBR0changes. - Register-mapped guest state and an inline TLB fast path. Today an MMU-on access costs a full dispatcher round trip; a software TLB check in JIT code plus address folding is what makes it fast.
- Quality passes, if worth their latency:
ldp/stppairing, compare-into-branch, fused multiply-add, shift-add, and the Wimmer splitter.
The arena is a fixed 64 MiB of code and 64 KiB of data, reserved once (virtual memory only; pages are committed as they are written). Exhausting it is an error, not a crash. Growing it would mean a second mapping, which is also what a chaining implementation has to handle.