commit 0980aa9bd5b779ecfa4c0e35d2f5c5b18a4306b6
parent 4e4c6a08c1906ea9af6dae7157b9d2b27b72e9c6
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Fri, 29 May 2026 19:17:49 -0700
emu: add interpreter execution mode (cfree emu -interp)
Run each lifted guest block through the threaded IR interpreter instead of
JITing it, behind CfreeEmuMode {JIT,INTERP} on CfreeEmuOptions (driver flag
`-interp`, forces -O1). This is doc/INTERPRETER.md Phase 4, rv64-only (the only
arch with an emu lifter).
The interp frame stays host-identity: the rv64 lifter lowers guest loads/stores
to FFI calls into the bounds-checked __emu_* helpers and its block locals are
pure host scratch, so no guest-VA translate hook or guest-stack carving is
needed (the design doc's original SP-carving plan would have corrupted host
memory and is dropped). The whole JIT/INTERP fork is one branch at
cfree_emu_step: emu_interp_run_block resets a long-lived InterpStack, seeds
param0 with the EmuThread*, resumes, and returns the scalar result as next_pc.
cfree_emu_new attaches an InterpProgram sink (capturing each block as an
InterpFunc) and binds only resolve_sym = emu_runtime_extern_resolver;
translate_block caches the InterpFunc* (payload disambiguated by e->mode). A
non-interpretable block hard-fails with its reason. Guest faults/exits are
delivered in-band by the helpers and observed by the existing post-dispatch
check. New additive engine API: cfree_interp_stack_reset / _call_args_on /
_stack_trap_reason.
cfree_interp_lookup now returns the LAST same-named capture: under self-modifying
guest code the code cache is flushed on a generation bump but interp_prog is
append-only, so a stale first-match would diverge from the JIT (last == first
for the --no-jit path). Found by an adversarial review of the diff; guarded by a
mutation-verified differential test.
test/emu/rv64_interp_smoke_test.c (make test-interp-emu) runs two guests under
both modes at -O1 and asserts identical exit codes: an SD/LD/ecall guest
(exercises the helper memory path) and a self-modifying guest (exercises
code-cache + interp-capture invalidation).
Also fix the pre-existing test-emu link break (the visibility-hidden ld -r
archive localizes internal symbols, which the test reached directly): rewrite
rv64_smoke_test.c to drive the emulator entirely through the public cfree_emu_run
API so it links the public archive as a real consumer, and split the white-box
unit tests (rv64 decoder, EmuAddrSpace, Linux syscall handler) into
rv64_vm_unit_test.c / make test-emu-unit (links the library objects).
Diffstat:
12 files changed, 1252 insertions(+), 268 deletions(-)
diff --git a/doc/INTERPRETER.md b/doc/INTERPRETER.md
@@ -359,16 +359,56 @@ Implemented and validated differentially against the JIT:
design); wasm `N` `.wat`/`.wasm` match; C (incl. libc via FFI — f32 args, multi-register
struct returns) matches; `141_threadlocal_mutate` exercises TLS define+mutate.
+## Phase 4: emu interpret-mode (implemented, rv64)
+
+The emulator can now run each lifted guest block through the interpreter instead of JITing it,
+behind `CfreeEmuMode {JIT,INTERP}` on `CfreeEmuOptions` (driver flag `cfree emu -interp`, which
+forces `-O1`). The key simplification — verified against the rv64 lifter — is that **the interp
+frame stays host-identity**: the rv64 lifter (`src/arch/rv64/emu.c`) lowers guest loads/stores to
+FFI calls into `__emu_*_checked` host helpers that bounds/perm-check the guest VA internally, and
+the lifted block's frame locals are pure host scratch passed as host out-pointers. So no guest-VA
+`translate` hook, no guest-stack frame carving, and no frame-model changes are needed (the doc's
+original "carve `frame_bytes` off the guest SP" plan would have corrupted host memory and was
+dropped). Concretely:
+
+- `cfree_emu_new` attaches an `InterpProgram` as the compiler's interp sink (so each translated
+ block is also captured as an `InterpFunc`) and stands up one long-lived `InterpStack`; it binds
+ only `resolve_sym = emu_runtime_extern_resolver` (translate hook stays NULL). The per-block JIT
+ image is still built to resolve helper externs and validate the lifted IR.
+- `translate_block` caches the captured `InterpFunc*` (payload disambiguated by `e->mode`).
+- The single JIT/INTERP fork is at `cfree_emu_step`: INTERP runs `emu_interp_run_block`, which
+ resets the stack, seeds param0 with `(u64)EmuThread*`, resumes, and returns the scalar result as
+ `next_pc`. Guest faults/exits are delivered in-band by the helpers (CPU trap + `next_pc`) and
+ observed by the existing post-dispatch check, exactly as in JIT mode.
+- Chosen UX: a block the interpreter can't run (lower-time reject or runtime trap) **hard-fails**
+ via `compiler_panic` with the reason — no silent JIT fallback.
+- New additive engine API: `cfree_interp_stack_reset`, `cfree_interp_call_args_on`,
+ `cfree_interp_stack_trap_reason` (so one stack is reused across blocks without realloc).
+- **Self-modifying code:** when a guest writes a translated code page the addr-space generation
+ bumps and the code cache is flushed, but `interp_prog` is append-only, so re-translation appends
+ a new same-named `InterpFunc`. `cfree_interp_lookup` therefore returns the **last** (freshest)
+ name match, giving INTERP the same fresh-code semantics the JIT gets from its per-block object
+ lookup. (`--no-jit` captures each name once, so last == first.)
+- Tests: `test/emu/rv64_interp_smoke_test.c` (`make test-interp-emu`) runs two guests under both
+ modes at `-O1` and asserts identical exit codes — an `SD`/`LD`/`ecall` guest (exercises the
+ helper memory path) and a self-modifying guest (exercises code-cache + interp-capture
+ invalidation; verified by mutation to catch a stale-lookup regression). Scope is rv64: it is the
+ only arch with an emu lifter, and that lifter currently implements only
+ `ADDI/ADD/AUIPC/LD/SD/JALR/ECALL` (others hit a re-dispatch-this-pc default).
+
Not yet implemented (diagnosed → SKIP, not miscompiled): inline asm (by design; needs
machinize's constraint resolution), FFI signatures beyond the register-thunk family
(`vararg_on_stack` external variadics, 3+-register struct returns, 32-bit-fp struct-return
fields, and an aggregate/>8-byte scalar in an external variadic-argument position — these have
-no per-call ABI classification, so they are rejected rather than marshalled), thread-locals on
-non-Mach-O images or via foreign/dyld descriptors (extern thread-locals), and **Phase 4 emu
-integration** (interpret-mode in `emu_translate_block`).
+no per-call ABI classification, so they are rejected rather than marshalled), and thread-locals on
+non-Mach-O images or via foreign/dyld descriptors (extern thread-locals). Emu interpret-mode is
+rv64-only (no other arch has an emu lifter) and does not reclaim stale `InterpFunc` captures under
+churny self-modifying code (bounded leak until emu teardown, like the JIT's `e->jits[]`).
Known limitations (correct results, not bugs): the threaded-dispatch per-site branch benefit
only materializes at optimization — the `-O0`+sanitizer test build merges the dispatch sites
(still computed-goto through the handler field, never a switch). The `g_mem_fault` latch is
-re-checked on straight-line ops and on branch selectors; full coverage matters once emu mode
-(where `translate` can fault) lands.
+re-checked on straight-line ops and on branch selectors; in emu mode this latch is not exercised
+by guest memory (the interp frame is host-identity and guest loads/stores go through the bounds-
+checked `__emu_*` helpers, not the interp `translate` hook), so guest faults surface in-band as a
+block return value rather than through `g_mem_fault`.
diff --git a/driver/emu.c b/driver/emu.c
@@ -25,6 +25,7 @@ typedef struct EmuOptions {
size_t argv_bound;
int opt_level;
+ int interp; /* -interp: run translated blocks through the IR interpreter */
CfreeEmuTraceFlags trace;
CfreeArchKind guest_arch;
int guest_arch_set;
@@ -72,6 +73,9 @@ void driver_help_emu(void) {
"\n"
"OPTIONS\n"
" -O0 -O1 -O2 Translator optimization level (default -O0)\n"
+ " -interp Run translated blocks through the IR "
+ "interpreter\n"
+ " instead of JITing them (forces -O1)\n"
" -arch ARCH Force guest arch: aarch64 (alias arm64) or\n"
" riscv64 (alias rv64). When omitted the arch is\n"
" auto-detected from the executable.\n"
@@ -162,6 +166,11 @@ static int emu_parse(int argc, char** argv, EmuOptions* o) {
continue;
}
+ if (driver_streq(a, "-interp")) {
+ o->interp = 1;
+ continue;
+ }
+
if (driver_streq(a, "-arch")) {
if (++i >= argc) {
driver_errf(EMU_TOOL, "-arch requires an argument");
@@ -309,6 +318,7 @@ int driver_emu(int argc, char** argv) {
opts.has_guest_target = eo.guest_target_set != 0;
opts.jit_host = &jhost;
opts.optimize = eo.opt_level;
+ opts.mode = eo.interp ? CFREE_EMU_MODE_INTERP : CFREE_EMU_MODE_JIT;
opts.trace = eo.trace;
opts.argv = (const char* const*)guest_argv;
opts.envp = 0;
diff --git a/include/cfree/emu.h b/include/cfree/emu.h
@@ -87,6 +87,16 @@ typedef struct CfreeEmuExternalBindings {
void* user;
} CfreeEmuExternalBindings;
+/* Execution strategy for translated guest blocks. JIT lowers each block to host
+ * machine code and calls it; INTERP runs each block through the cfree IR
+ * interpreter (no executable memory needed for the guest code itself, though the
+ * current INTERP path still builds a per-block JIT image to resolve helper
+ * externs). INTERP requires CFREE_INTERP_ENABLED and forces opt_level >= 1. */
+typedef enum CfreeEmuMode {
+ CFREE_EMU_MODE_JIT = 0,
+ CFREE_EMU_MODE_INTERP = 1,
+} CfreeEmuMode;
+
typedef struct CfreeEmuOptions {
CfreeSlice guest_name;
CfreeSlice guest_bytes;
@@ -94,6 +104,7 @@ typedef struct CfreeEmuOptions {
bool has_guest_target;
const CfreeJitHost* jit_host;
int optimize;
+ CfreeEmuMode mode;
CfreeEmuTraceFlags trace;
CfreeEmuExternalBindings bindings;
const char* const* argv;
diff --git a/include/cfree/interp.h b/include/cfree/interp.h
@@ -105,4 +105,25 @@ CFREE_API CfreeInterpStatus cfree_interp_call_args(CfreeInterpProgram*,
uint32_t nargs,
int64_t* out_ret);
+/* Reset a stack to empty so it can be reseeded and resumed again, keeping its
+ * allocated arenas (no realloc). Clears the frame stack, arena bump tops, the
+ * return shuttle, and any status/trap from a prior run. Use this to reuse one
+ * long-lived stack across many entries (e.g. one interp stack per emu thread,
+ * reset before each guest block). */
+CFREE_API CfreeStatus cfree_interp_stack_reset(CfreeInterpStack* stack);
+
+/* Seed an already-allocated `stack` with an entry frame for `fn`, binding
+ * `nargs` raw register-width values to its first parameters. Unlike
+ * cfree_interp_call_args this neither allocates nor frees the stack and does not
+ * run — follow with cfree_interp_resume. Reset the stack first if reusing it. */
+CFREE_API CfreeStatus cfree_interp_call_args_on(CfreeInterpStack* stack,
+ CfreeInterpFunc* fn,
+ const uint64_t* args,
+ uint32_t nargs);
+
+/* The reason string for the most recent non-DONE resume on `stack` — an
+ * unsupported operation (the function's reject reason) or a runtime trap — or
+ * NULL if none. Borrowed; valid until the next resume/reset. */
+CFREE_API const char* cfree_interp_stack_trap_reason(CfreeInterpStack* stack);
+
#endif
diff --git a/src/emu/emu.c b/src/emu/emu.c
@@ -10,6 +10,8 @@
#include "emu/emu.h"
+#include <cfree/config.h>
+#include <cfree/interp.h>
#include <cfree/link.h>
#include <setjmp.h>
#include <string.h>
@@ -42,6 +44,13 @@ struct CfreeEmu {
u32 njits;
u32 jits_cap;
+ /* Execution strategy. In INTERP mode each cache payload is an InterpFunc*
+ * (run via interp_prog/interp_stack) instead of a host code entry. The
+ * pointers stay NULL in JIT mode. */
+ CfreeEmuMode mode;
+ CfreeInterpProgram* interp_prog;
+ CfreeInterpStack* interp_stack;
+
int done;
int exit_code;
};
@@ -248,6 +257,10 @@ CfreeStatus cfree_emu_new(CfreeCompiler* c, const CfreeEmuOptions* opts,
memset(e, 0, sizeof(*e));
e->c = c;
e->opt_level = opts->optimize;
+ e->mode = opts->mode;
+ /* The interpreter consumes the O1 PReg-path IR (opt_run_o1_interp); force at
+ * least -O1 so the optimizer runs and each block is captured. */
+ if (e->mode == CFREE_EMU_MODE_INTERP && e->opt_level < 1) e->opt_level = 1;
e->trace = opts->trace;
e->bindings = opts->bindings;
e->host = opts->jit_host;
@@ -324,6 +337,34 @@ CfreeStatus cfree_emu_new(CfreeCompiler* c, const CfreeEmuOptions* opts,
compiler_panic(c, no_loc(), "emu: failed to initialize guest CPU state");
}
+ /* 3. In INTERP mode, attach an interp sink so each translated block is also
+ * captured as an InterpFunc, and stand up the long-lived stack used to run
+ * blocks. The sink stays attached for the whole emu lifetime (every
+ * translate_block compiles a fresh block that must be captured). External
+ * helper calls in a lifted block resolve through the same runtime resolver
+ * the JIT path uses; guest memory is reached only via those helpers, so no
+ * address translate hook is bound (host-identity frame). */
+#if CFREE_INTERP_ENABLED
+ if (e->mode == CFREE_EMU_MODE_INTERP) {
+ CfreeInterpHost ihost;
+ e->interp_prog = cfree_interp_program_new((CfreeCompiler*)c);
+ if (!e->interp_prog)
+ compiler_panic(c, no_loc(), "emu: failed to create interpreter program");
+ cfree_interp_program_attach(e->interp_prog, (CfreeCompiler*)c);
+ memset(&ihost, 0, sizeof(ihost));
+ ihost.resolve_sym = emu_runtime_extern_resolver;
+ ihost.ctx = e;
+ cfree_interp_program_set_host(e->interp_prog, &ihost);
+ e->interp_stack = cfree_interp_stack_new(e->interp_prog);
+ if (!e->interp_stack)
+ compiler_panic(c, no_loc(), "emu: failed to create interpreter stack");
+ }
+#else
+ if (e->mode == CFREE_EMU_MODE_INTERP)
+ compiler_panic(c, no_loc(),
+ "emu: interpreter mode not enabled in this build");
+#endif
+
compiler_panic_restore(c, &saved);
*out = e;
return CFREE_OK;
@@ -334,6 +375,13 @@ void cfree_emu_free(CfreeEmu* e) {
if (!e) return;
heap = e->c->ctx->heap;
+#if CFREE_INTERP_ENABLED
+ /* Detach the sink before freeing the program so a later compile on the
+ * (borrowed) compiler can't write into freed interp state. */
+ if (e->interp_prog) cfree_interp_program_attach(NULL, (CfreeCompiler*)e->c);
+ if (e->interp_stack) cfree_interp_stack_free(e->interp_stack);
+ if (e->interp_prog) cfree_interp_program_free(e->interp_prog);
+#endif
while (e->njits) cfree_jit_free(e->jits[--e->njits]);
if (e->jits) heap->free(heap, e->jits, sizeof(*e->jits) * e->jits_cap);
if (e->cache) emu_cache_free(e->cache);
@@ -497,6 +545,21 @@ static void* translate_block(CfreeEmu* e, u64 guest_pc) {
}
emu_keep_jit(e, jit);
+#if CFREE_INTERP_ENABLED
+ /* INTERP mode: the JIT image above still resolved the block's helper externs
+ * and validated the lifted IR, but dispatch runs the captured InterpFunc
+ * (lowered during cfree_cg_end_obj, above) instead of the host code. Cache the
+ * InterpFunc*; cfree_emu_step disambiguates the payload by e->mode. A rejected
+ * block is still captured (ifn->ok == 0) and is reported with its reason when
+ * dispatched, so only a genuine capture miss yields NULL here. */
+ if (e->mode == CFREE_EMU_MODE_INTERP) {
+ CfreeInterpFunc* ifn =
+ cfree_interp_lookup(e->interp_prog, *(CfreeSlice*)&block_slice);
+ if (!ifn) return NULL;
+ entry = (void*)ifn;
+ }
+#endif
+
emu_cache_insert(e->cache, guest_pc, entry);
emu_addr_space_mark_translated(&e->process.image.addr_space, guest_pc,
decode_len);
@@ -540,6 +603,44 @@ void* cfree_emu_lookup(CfreeEmu* e, uint64_t guest_pc) {
/* ---- Dispatcher ---- */
+#if CFREE_INTERP_ENABLED
+/* Run one lifted guest block through the IR interpreter on the emu's long-lived
+ * stack, returning the next guest pc. The block's host function takes the
+ * EmuThread* and returns next_pc, so we seed param0 with the thread pointer
+ * (host-identity: the interpreter never translates it) and shuttle the scalar
+ * return back. Guest registers and memory are reached only through the __emu_*
+ * helpers the block calls — the interpreter holds no guest state itself.
+ *
+ * A non-DONE result means the block could not be interpreted (an op the lowerer
+ * rejected) or trapped inside the interpreter; per the chosen UX we hard-fail
+ * with the reason rather than falling back to the JIT. A guest fault/exit is NOT
+ * such a case: the helpers deliver those in-band by setting the CPU trap reason
+ * and returning a next_pc, which the post-dispatch check below observes exactly
+ * as in JIT mode. */
+static u64 emu_interp_run_block(CfreeEmu* e, CfreeInterpFunc* ifn,
+ EmuThread* thread) {
+ u64 arg = (u64)(uintptr_t)thread;
+ int64_t ret = 0;
+ CfreeInterpStatus s;
+
+ cfree_interp_stack_reset(e->interp_stack);
+ if (cfree_interp_call_args_on(e->interp_stack, ifn, &arg, 1u) != CFREE_OK)
+ compiler_panic(e->c, no_loc(), "emu: failed to seed interpreter frame");
+
+ s = cfree_interp_resume(e->interp_stack, &ret);
+ if (s == CFREE_INTERP_DONE) return (u64)ret;
+
+ {
+ const char* why = cfree_interp_stack_trap_reason(e->interp_stack);
+ compiler_panic(e->c, no_loc(),
+ "emu: cannot interpret block at guest_pc=0x%llx: %s",
+ (unsigned long long)emu_cpu_pc(thread->cpu),
+ why ? why : "unsupported operation");
+ }
+ return 0; /* unreachable: compiler_panic longjmps */
+}
+#endif
+
CfreeStatus cfree_emu_step(CfreeEmu* e, uint32_t nblocks) {
PanicSave saved;
uint32_t i;
@@ -575,8 +676,15 @@ CfreeStatus cfree_emu_step(CfreeEmu* e, uint32_t nblocks) {
(unsigned long long)pc);
}
- fn = (EmuBlockFn)entry;
- next_pc = fn(thread);
+#if CFREE_INTERP_ENABLED
+ if (e->mode == CFREE_EMU_MODE_INTERP) {
+ next_pc = emu_interp_run_block(e, (CfreeInterpFunc*)entry, thread);
+ } else
+#endif
+ {
+ fn = (EmuBlockFn)entry;
+ next_pc = fn(thread);
+ }
emu_cpu_set_pc(cpu, next_pc);
trap = emu_cpu_trap_reason(cpu);
diff --git a/src/interp/engine.c b/src/interp/engine.c
@@ -1611,3 +1611,36 @@ CfreeInterpStatus cfree_interp_call_args(CfreeInterpProgram* pp,
cfree_interp_stack_free((CfreeInterpStack*)st);
return rc;
}
+
+CfreeStatus cfree_interp_stack_reset(CfreeInterpStack* s) {
+ InterpStack* st = (InterpStack*)s;
+ if (!st) return CFREE_INVALID;
+ /* Keep the (fixed, non-relocating) arenas; rewind their bump tops and drop
+ * all frames + the return shuttle + any prior status/trap. */
+ st->nframes = 0;
+ st->regs_top = 0;
+ st->mem_top = 0;
+ st->scalar_ret = 0;
+ st->ret_is_fp = 0;
+ st->status = CFREE_INTERP_DONE;
+ st->trap_reason = NULL;
+ g_mem_fault = 0;
+ return CFREE_OK;
+}
+
+CfreeStatus cfree_interp_call_args_on(CfreeInterpStack* s, CfreeInterpFunc* ff,
+ const uint64_t* args, uint32_t nargs) {
+ InterpStack* st = (InterpStack*)s;
+ InterpFunc* fn = (InterpFunc*)ff;
+ u32 idx, i;
+ if (!st || !fn) return CFREE_INVALID;
+ idx = frame_push(st, fn);
+ if (idx == 0xffffffffu) return CFREE_NOMEM;
+ for (i = 0; i < nargs; ++i) bind_entry_param(st, fn, idx, i, args[i]);
+ return CFREE_OK;
+}
+
+const char* cfree_interp_stack_trap_reason(CfreeInterpStack* s) {
+ InterpStack* st = (InterpStack*)s;
+ return st ? st->trap_reason : NULL;
+}
diff --git a/src/interp/interp_program.c b/src/interp/interp_program.c
@@ -157,7 +157,13 @@ CfreeInterpFunc* cfree_interp_lookup(CfreeInterpProgram* pp, CfreeSlice name) {
InterpProgram* p = (InterpProgram*)pp;
u32 i;
if (!p || !name.s) return NULL;
- for (i = 0; i < p->nfuncs; ++i)
+ /* Newest-first: the emu re-lifts a block (same guest_pc-derived name) after
+ * self-modifying code bumps the addr-space generation and flushes the code
+ * cache. Capture is append-only, so the LAST same-named InterpFunc is the
+ * freshest re-translation — returning it keeps INTERP in step with the fresh
+ * code the JIT runs via its per-block object lookup. For the host-identity
+ * --no-jit path each name is captured exactly once, so last == first. */
+ for (i = p->nfuncs; i-- > 0;)
if (name_matches(p->funcs[i]->name, name))
return (CfreeInterpFunc*)p->funcs[i];
return NULL;
diff --git a/src/interp/interp_stubs.c b/src/interp/interp_stubs.c
@@ -73,3 +73,22 @@ CfreeInterpStatus cfree_interp_call_args(CfreeInterpProgram* p,
(void)out_ret;
return CFREE_INTERP_ERROR;
}
+
+CfreeStatus cfree_interp_stack_reset(CfreeInterpStack* s) {
+ (void)s;
+ return CFREE_UNSUPPORTED;
+}
+
+CfreeStatus cfree_interp_call_args_on(CfreeInterpStack* s, CfreeInterpFunc* fn,
+ const uint64_t* args, uint32_t nargs) {
+ (void)s;
+ (void)fn;
+ (void)args;
+ (void)nargs;
+ return CFREE_UNSUPPORTED;
+}
+
+const char* cfree_interp_stack_trap_reason(CfreeInterpStack* s) {
+ (void)s;
+ return NULL;
+}
diff --git a/test/emu/rv64_interp_smoke_test.c b/test/emu/rv64_interp_smoke_test.c
@@ -0,0 +1,604 @@
+/* Differential smoke test for the emu's INTERP execution mode (doc/INTERPRETER.md
+ * Phase 4). Builds a tiny static rv64 ELF whose _start computes a data-page
+ * address with auipc, stores a value there (SD), loads it back (LD), and exits
+ * via ecall with the loaded value. The SD/LD exercise the guest-memory helper
+ * path (__emu_*_checked) — the part a wrong "guest-VA frame" model would corrupt
+ * — which the addi/ecall-only smoke fixture cannot reach.
+ *
+ * The guest is run twice through the PUBLIC cfree_emu_run API, once in JIT mode
+ * and once in INTERP mode, BOTH at -O1, and the two exit codes are asserted
+ * equal (and equal to the expected memory-derived value). This isolates
+ * interpreter-vs-JIT semantics rather than O0-vs-O1.
+ *
+ * Self-contained: uses only the public API plus its own ELF builder and rv64
+ * encoders, so it links cleanly against the (visibility-hidden) library objects
+ * without reaching internal emu symbols. */
+
+#include <cfree/compile.h>
+#include <cfree/core.h>
+#include <cfree/emu.h>
+#include <cfree/jit.h>
+#include "emu/emu.h"
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/mman.h>
+#include <unistd.h>
+#if defined(__APPLE__)
+#include <mach/mach.h>
+#include <mach/mach_vm.h>
+#define XM_DUAL_APPLE 1
+#else
+#define XM_DUAL_APPLE 0
+#endif
+#if defined(__linux__)
+#include <sys/syscall.h>
+#define XM_DUAL_LINUX 1
+#else
+#define XM_DUAL_LINUX 0
+#endif
+
+/* Not in the public header; provided by the linked library objects. */
+EmuCPUState* emu_internal_cpu(CfreeEmu*);
+
+/* ---- Host heap / diag glue ---- */
+static void* h_alloc(CfreeHeap* h, size_t n, size_t a) {
+ (void)h;
+ (void)a;
+ return n ? malloc(n) : NULL;
+}
+static void* h_realloc(CfreeHeap* h, void* p, size_t o, size_t n, size_t a) {
+ (void)h;
+ (void)o;
+ (void)a;
+ return realloc(p, n);
+}
+static void h_free(CfreeHeap* h, void* p, size_t n) {
+ (void)h;
+ (void)n;
+ free(p);
+}
+static CfreeHeap g_heap = {h_alloc, h_realloc, h_free, NULL};
+
+static void diag_emit(CfreeDiagSink* s, CfreeDiagKind k, CfreeSrcLoc loc,
+ const char* fmt, va_list ap) {
+ (void)s;
+ (void)loc;
+ fprintf(stderr, "diag %d: ", (int)k);
+ vfprintf(stderr, fmt, ap);
+ fputc('\n', stderr);
+}
+static CfreeDiagSink g_diag = {diag_emit, NULL, 0, 0};
+static CfreeContext g_ctx;
+
+/* ---- Dual-mapped (W^X) exec memory for the per-block JIT image (still built
+ * in INTERP mode to resolve helper externs). Same shape as rv64_smoke_test. ---- */
+static int xm_to_posix(int p) {
+ int q = 0;
+ if (p & CFREE_PROT_READ) q |= PROT_READ;
+ if (p & CFREE_PROT_WRITE) q |= PROT_WRITE;
+ if (p & CFREE_PROT_EXEC) q |= PROT_EXEC;
+ return q;
+}
+
+typedef struct XmTok {
+ void* w;
+ void* r;
+ size_t n;
+} XmTok;
+
+static CfreeStatus xm_reserve_single(size_t n, CfreeExecMemRegion* out) {
+ void* m =
+ mmap(NULL, n, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
+ if (m == MAP_FAILED) return CFREE_NOMEM;
+ out->write = m;
+ out->runtime = m;
+ out->size = n;
+ out->token = NULL;
+ return CFREE_OK;
+}
+
+static CfreeStatus xm_reserve(void* user, size_t n, int prot,
+ CfreeExecMemRegion* out) {
+ (void)user;
+ if (!out || !n) return CFREE_INVALID;
+ if (!(prot & CFREE_PROT_EXEC)) return xm_reserve_single(n, out);
+#if XM_DUAL_APPLE
+ {
+ void* w =
+ mmap(NULL, n, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
+ mach_vm_address_t r = 0;
+ vm_prot_t cur = 0, max = 0;
+ XmTok* tok;
+ if (w == MAP_FAILED) return CFREE_NOMEM;
+ if (mach_vm_remap(mach_task_self(), &r, (mach_vm_size_t)n, 0,
+ VM_FLAGS_ANYWHERE, mach_task_self(),
+ (mach_vm_address_t)(uintptr_t)w, FALSE, &cur, &max,
+ VM_INHERIT_NONE) != KERN_SUCCESS) {
+ munmap(w, n);
+ return CFREE_NOMEM;
+ }
+ if (mprotect((void*)(uintptr_t)r, n, PROT_READ) != 0) {
+ munmap((void*)(uintptr_t)r, n);
+ munmap(w, n);
+ return CFREE_NOMEM;
+ }
+ tok = (XmTok*)malloc(sizeof(*tok));
+ if (!tok) {
+ munmap((void*)(uintptr_t)r, n);
+ munmap(w, n);
+ return CFREE_NOMEM;
+ }
+ tok->w = w;
+ tok->r = (void*)(uintptr_t)r;
+ tok->n = n;
+ out->write = w;
+ out->runtime = (void*)(uintptr_t)r;
+ out->size = n;
+ out->token = tok;
+ return CFREE_OK;
+ }
+#elif XM_DUAL_LINUX
+ {
+ int fd = (int)syscall(SYS_memfd_create, "cfree-emu-interp-test", 0u);
+ void *w, *r;
+ XmTok* tok;
+ if (fd < 0) return CFREE_NOMEM;
+ if (ftruncate(fd, (off_t)n) != 0) {
+ close(fd);
+ return CFREE_NOMEM;
+ }
+ w = mmap(NULL, n, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
+ if (w == MAP_FAILED) {
+ close(fd);
+ return CFREE_NOMEM;
+ }
+ r = mmap(NULL, n, PROT_READ, MAP_SHARED, fd, 0);
+ close(fd);
+ if (r == MAP_FAILED) {
+ munmap(w, n);
+ return CFREE_NOMEM;
+ }
+ tok = (XmTok*)malloc(sizeof(*tok));
+ if (!tok) {
+ munmap(r, n);
+ munmap(w, n);
+ return CFREE_NOMEM;
+ }
+ tok->w = w;
+ tok->r = r;
+ tok->n = n;
+ out->write = w;
+ out->runtime = r;
+ out->size = n;
+ out->token = tok;
+ return CFREE_OK;
+ }
+#else
+ return xm_reserve_single(n, out);
+#endif
+}
+
+static CfreeStatus xm_protect(void* user, void* addr, size_t n, int prot) {
+ (void)user;
+ return mprotect(addr, n, xm_to_posix(prot)) == 0 ? CFREE_OK : CFREE_IO;
+}
+
+static void xm_release(void* user, CfreeExecMemRegion* r) {
+ (void)user;
+ if (!r || !r->size) return;
+ if (r->token) {
+ XmTok* tok = (XmTok*)r->token;
+ if (tok->r && tok->r != tok->w) munmap(tok->r, tok->n);
+ if (tok->w) munmap(tok->w, tok->n);
+ free(tok);
+ } else if (r->write) {
+ munmap(r->write, r->size);
+ }
+ memset(r, 0, sizeof(*r));
+}
+
+static void xm_flush(void* user, void* addr, size_t n) {
+ (void)user;
+#if defined(__aarch64__) || defined(__arm__) || defined(__riscv)
+ __builtin___clear_cache((char*)addr, (char*)addr + n);
+#else
+ (void)addr;
+ (void)n;
+#endif
+}
+
+static CfreeExecMem g_execmem = {
+ 16 * 1024, xm_reserve, xm_protect, xm_release, xm_flush, NULL,
+};
+
+static int g_fail;
+#define EXPECT(cond, ...) \
+ do { \
+ if (!(cond)) { \
+ ++g_fail; \
+ fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fputc('\n', stderr); \
+ } \
+ } while (0)
+
+static CfreeCompiler* new_host_compiler(void) {
+ CfreeTarget t;
+ CfreeCompiler* c = NULL;
+ memset(&t, 0, sizeof t);
+#if defined(__x86_64__) || defined(_M_X64)
+ t.arch = CFREE_ARCH_X86_64;
+#elif defined(__aarch64__) || defined(_M_ARM64)
+ t.arch = CFREE_ARCH_ARM_64;
+#elif defined(__riscv) && __riscv_xlen == 64
+ t.arch = CFREE_ARCH_RV64;
+#else
+ return NULL;
+#endif
+#if defined(__APPLE__)
+ t.os = CFREE_OS_MACOS;
+ t.obj = CFREE_OBJ_MACHO;
+#elif defined(__linux__)
+ t.os = CFREE_OS_LINUX;
+ t.obj = CFREE_OBJ_ELF;
+#else
+ return NULL;
+#endif
+ t.ptr_size = 8;
+ t.ptr_align = 8;
+ memset(&g_ctx, 0, sizeof g_ctx);
+ g_ctx.heap = &g_heap;
+ g_ctx.diag = &g_diag;
+ if (cfree_compiler_new(t, &g_ctx, &c) != CFREE_OK || !c) {
+ fprintf(stderr, "host compiler_new failed\n");
+ exit(2);
+ }
+ return c;
+}
+
+/* ---- Minimal rv64 ELF + instruction encoders (self-contained) ---- */
+static void put16(unsigned char* b, size_t off, unsigned v) {
+ b[off + 0] = (unsigned char)v;
+ b[off + 1] = (unsigned char)(v >> 8);
+}
+static void put32(unsigned char* b, size_t off, uint32_t v) {
+ b[off + 0] = (unsigned char)v;
+ b[off + 1] = (unsigned char)(v >> 8);
+ b[off + 2] = (unsigned char)(v >> 16);
+ b[off + 3] = (unsigned char)(v >> 24);
+}
+static void put64(unsigned char* b, size_t off, uint64_t v) {
+ size_t i;
+ for (i = 0; i < 8; ++i) b[off + i] = (unsigned char)(v >> (8 * i));
+}
+
+/* The rv64 emu lifter implements a minimal op set (src/arch/rv64/emu.c):
+ * ADDI, ADD, AUIPC, LD, SD, JALR, ECALL. The guests below use only those —
+ * calls go through JALR, and constants come from LD'd data, not LUI. */
+enum {
+ RV_ZERO = 0,
+ RV_RA = 1,
+ RV_T0 = 5,
+ RV_T1 = 6,
+ RV_T2 = 7,
+ RV_A0 = 10,
+ RV_A7 = 17
+};
+
+static uint32_t enc_addi(uint32_t rd, uint32_t rs1, int32_t imm) {
+ return (((uint32_t)imm & 0xfffu) << 20) | (rs1 << 15) | (0x0u << 12) |
+ (rd << 7) | 0x13u;
+}
+static uint32_t enc_auipc(uint32_t rd, uint32_t imm20) {
+ return ((imm20 & 0xfffffu) << 12) | (rd << 7) | 0x17u;
+}
+static uint32_t enc_ld(uint32_t rd, uint32_t rs1, int32_t imm) {
+ return (((uint32_t)imm & 0xfffu) << 20) | (rs1 << 15) | (0x3u << 12) |
+ (rd << 7) | 0x03u;
+}
+static uint32_t enc_sd(uint32_t rs2, uint32_t rs1, int32_t imm) {
+ uint32_t u = (uint32_t)imm;
+ return (((u >> 5) & 0x7fu) << 25) | (rs2 << 20) | (rs1 << 15) | (0x3u << 12) |
+ ((u & 0x1fu) << 7) | 0x23u;
+}
+static uint32_t enc_jalr(uint32_t rd, uint32_t rs1, int32_t imm) {
+ return (((uint32_t)imm & 0xfffu) << 20) | (rs1 << 15) | (0x0u << 12) |
+ (rd << 7) | 0x67u;
+}
+static uint32_t enc_ecall(void) { return 0x00000073u; }
+
+/* Build a 2-segment rv64 ELF (text R+X at 0x10000, data R+W at 0x20000) whose
+ * _start does: SD value -> data; LD data -> a0; exit(a0). Returns a heap buffer
+ * (free with free()); *out_len gets the size. */
+static unsigned char* build_sd_ld_elf(size_t* out_len, unsigned value) {
+ enum {
+ PAGE = 0x1000u,
+ TEXT_VA = 0x10000ull,
+ TEXT_OFF = 0x1000u,
+ DATA_VA = 0x20000ull,
+ DATA_OFF = 0x2000u,
+ DATA_LEN = 0x1000u,
+ };
+ uint64_t entry = TEXT_VA + TEXT_OFF;
+ int64_t delta = (int64_t)DATA_VA - (int64_t)entry; /* auipc is at entry */
+ uint32_t hi20 = (uint32_t)(((uint64_t)(delta + 0x800)) >> 12) & 0xfffffu;
+ int32_t lo12 = (int32_t)(delta - ((int64_t)hi20 << 12));
+ size_t text_len;
+ size_t total = DATA_OFF + DATA_LEN;
+ unsigned char* b = (unsigned char*)calloc(1, total);
+ uint32_t code[8];
+ size_t n = 0;
+ if (!b) return NULL;
+
+ code[n++] = enc_auipc(RV_T0, hi20); /* t0 = hi(data) */
+ code[n++] = enc_addi(RV_T0, RV_T0, lo12); /* t0 = &data */
+ code[n++] = enc_addi(RV_T1, RV_ZERO, (int)value); /* t1 = value */
+ code[n++] = enc_sd(RV_T1, RV_T0, 0); /* [data] = t1 */
+ code[n++] = enc_ld(RV_A0, RV_T0, 0); /* a0 = [data] */
+ code[n++] = enc_addi(RV_A7, RV_ZERO, 94); /* a7 = exit_group */
+ code[n++] = enc_ecall(); /* exit(a0) */
+ text_len = n * 4u;
+
+ /* ELF64 header. */
+ b[0] = 0x7f;
+ b[1] = 'E';
+ b[2] = 'L';
+ b[3] = 'F';
+ b[4] = 2; /* ELFCLASS64 */
+ b[5] = 1; /* ELFDATA2LSB */
+ b[6] = 1; /* EV_CURRENT */
+ b[7] = 0; /* ELFOSABI_NONE */
+ put16(b, 16, 2); /* e_type = ET_EXEC */
+ put16(b, 18, 243); /* e_machine = EM_RISCV */
+ put32(b, 20, 1); /* e_version */
+ put64(b, 24, entry); /* e_entry */
+ put64(b, 32, 64); /* e_phoff */
+ put64(b, 40, 0); /* e_shoff */
+ put32(b, 48, 0); /* e_flags */
+ put16(b, 52, 64); /* e_ehsize */
+ put16(b, 54, 56); /* e_phentsize */
+ put16(b, 56, 2); /* e_phnum */
+ put16(b, 58, 0); /* e_shentsize */
+ put16(b, 60, 0); /* e_shnum */
+ put16(b, 62, 0); /* e_shstrndx */
+
+ /* PT_LOAD #0: text, R+X, [0, TEXT_OFF+text_len) at VA TEXT_VA. */
+ put32(b, 64 + 0, 1); /* p_type = PT_LOAD */
+ put32(b, 64 + 4, 0x4u | 0x1u); /* p_flags = PF_R|PF_X */
+ put64(b, 64 + 8, 0); /* p_offset */
+ put64(b, 64 + 16, TEXT_VA); /* p_vaddr */
+ put64(b, 64 + 24, TEXT_VA); /* p_paddr */
+ put64(b, 64 + 32, TEXT_OFF + text_len); /* p_filesz */
+ put64(b, 64 + 40, TEXT_OFF + text_len); /* p_memsz */
+ put64(b, 64 + 48, PAGE); /* p_align */
+
+ /* PT_LOAD #1: data, R+W, [DATA_OFF, DATA_OFF+DATA_LEN) at VA DATA_VA. */
+ put32(b, 120 + 0, 1); /* p_type = PT_LOAD */
+ put32(b, 120 + 4, 0x4u | 0x2u); /* p_flags = PF_R|PF_W */
+ put64(b, 120 + 8, DATA_OFF); /* p_offset */
+ put64(b, 120 + 16, DATA_VA); /* p_vaddr */
+ put64(b, 120 + 24, DATA_VA); /* p_paddr */
+ put64(b, 120 + 32, DATA_LEN); /* p_filesz */
+ put64(b, 120 + 40, DATA_LEN); /* p_memsz */
+ put64(b, 120 + 48, PAGE); /* p_align */
+
+ /* .text. */
+ {
+ size_t i;
+ for (i = 0; i < n; ++i) put32(b, TEXT_OFF + i * 4u, code[i]);
+ }
+
+ *out_len = total;
+ return b;
+}
+
+/* Build a self-modifying rv64 ELF (single R+W+X segment at 0x10000, entry
+ * 0x11000) to exercise the code-cache + interp-capture invalidation path. Uses
+ * only lifter-supported ops (calls via JALR; the replacement instruction word is
+ * LD'd from a data slot rather than built with LUI):
+ *
+ * entry 0x11000: auipc t0,0; addi t0,t0,0x100 # t0 = &target (0x11100)
+ * jalr ra, t0, 0 # 1st call -> translates target, a0=1
+ * 0x1100c: auipc t1,0; addi t1,t1,0x1f4 # t1 = &patchword (0x11200)
+ * ld t2, 0(t1) # t2 = [addi a0,2 | jalr ra]
+ * sd t2, 0(t0) # overwrite target IN A TRANSLATED PAGE
+ * 0x1101c: jalr ra, t0, 0 # 2nd call -> must re-decode, a0=2
+ * 0x11020: addi a7,zero,94; ecall # exit(a0)
+ * target 0x11100: addi a0,zero,1; jalr zero,ra,0 # patched in place to addi a0,zero,2
+ * patch 0x11200: u64 = (jalr<<32)|addi-a0-2 # 8 bytes covering both target words
+ *
+ * The store to the already-translated code page bumps the addr-space generation
+ * and flushes the code cache, so the 2nd call must run the FRESH block. Both JIT
+ * and INTERP must exit 2; a stale interp-capture lookup would re-run the old
+ * block and exit 1 — the divergence this guards. */
+static unsigned char* build_smc_elf(size_t* out_len) {
+ enum {
+ PAGE = 0x1000u,
+ TEXT_VA = 0x10000ull,
+ TEXT_OFF = 0x1000u,
+ TARGET_OFF = 0x1100u, /* VA 0x11100 */
+ PATCH_OFF = 0x1200u, /* VA 0x11200 */
+ TEXT_END = 0x1208u, /* end of the 8-byte patch word */
+ };
+ uint64_t entry = TEXT_VA + TEXT_OFF; /* 0x11000 */
+ uint64_t target_va = TEXT_VA + TARGET_OFF; /* 0x11100 */
+ uint64_t patch_va = TEXT_VA + PATCH_OFF; /* 0x11200 */
+ /* 8 bytes = [addi a0,zero,2 ; jalr zero,ra,0], so the SD rewrites target's
+ * first word (1 -> 2) and preserves its ret. */
+ uint64_t patch_word = ((uint64_t)enc_jalr(RV_ZERO, RV_RA, 0) << 32) |
+ (uint64_t)enc_addi(RV_A0, RV_ZERO, 2);
+ unsigned char* b = (unsigned char*)calloc(1, TEXT_END);
+ if (!b) return NULL;
+
+ /* ELF64 header. */
+ b[0] = 0x7f;
+ b[1] = 'E';
+ b[2] = 'L';
+ b[3] = 'F';
+ b[4] = 2;
+ b[5] = 1;
+ b[6] = 1;
+ b[7] = 0;
+ put16(b, 16, 2); /* ET_EXEC */
+ put16(b, 18, 243); /* EM_RISCV */
+ put32(b, 20, 1);
+ put64(b, 24, entry);
+ put64(b, 32, 64); /* e_phoff */
+ put64(b, 40, 0);
+ put32(b, 48, 0);
+ put16(b, 52, 64);
+ put16(b, 54, 56);
+ put16(b, 56, 1); /* e_phnum = 1 */
+ put16(b, 58, 0);
+ put16(b, 60, 0);
+ put16(b, 62, 0);
+
+ /* One PT_LOAD: R+W+X, [0, TEXT_END) at VA TEXT_VA (writable so the guest can
+ * patch its own code; executable so it runs). */
+ put32(b, 64 + 0, 1); /* PT_LOAD */
+ put32(b, 64 + 4, 0x4u | 0x2u | 0x1u); /* PF_R|PF_W|PF_X */
+ put64(b, 64 + 8, 0); /* p_offset */
+ put64(b, 64 + 16, TEXT_VA);
+ put64(b, 64 + 24, TEXT_VA);
+ put64(b, 64 + 32, TEXT_END); /* p_filesz */
+ put64(b, 64 + 40, TEXT_END); /* p_memsz */
+ put64(b, 64 + 48, PAGE);
+
+ /* entry: compute &target, call it, load patch word, patch, call again, exit. */
+ put32(b, TEXT_OFF + 0x00, enc_auipc(RV_T0, 0)); /* t0 = 0x11000 */
+ put32(b, TEXT_OFF + 0x04,
+ enc_addi(RV_T0, RV_T0, (int32_t)(target_va - entry))); /* t0 = &target */
+ put32(b, TEXT_OFF + 0x08, enc_jalr(RV_RA, RV_T0, 0)); /* call target */
+ put32(b, TEXT_OFF + 0x0c, enc_auipc(RV_T1, 0)); /* t1 = 0x1100c */
+ put32(b, TEXT_OFF + 0x10,
+ enc_addi(RV_T1, RV_T1, (int32_t)(patch_va - (entry + 0x0c)))); /* &patch */
+ put32(b, TEXT_OFF + 0x14, enc_ld(RV_T2, RV_T1, 0)); /* t2 = patch word */
+ put32(b, TEXT_OFF + 0x18, enc_sd(RV_T2, RV_T0, 0)); /* patch target page */
+ put32(b, TEXT_OFF + 0x1c, enc_jalr(RV_RA, RV_T0, 0)); /* call target again */
+ put32(b, TEXT_OFF + 0x20, enc_addi(RV_A7, RV_ZERO, 94));
+ put32(b, TEXT_OFF + 0x24, enc_ecall());
+
+ /* target. */
+ put32(b, TARGET_OFF + 0x00, enc_addi(RV_A0, RV_ZERO, 1));
+ put32(b, TARGET_OFF + 0x04, enc_jalr(RV_ZERO, RV_RA, 0));
+
+ /* patch word data. */
+ put64(b, PATCH_OFF, patch_word);
+
+ *out_len = TEXT_END;
+ return b;
+}
+
+/* Run the guest to completion in `mode` at -O1; returns the exit code, or -1 on
+ * a non-OK run (and sets *ok = 0). */
+static int run_guest(const unsigned char* elf, size_t elf_len, CfreeEmuMode mode,
+ int* ok) {
+ CfreeCompiler* c = new_host_compiler();
+ CfreeJitHost host;
+ CfreeEmuOptions opts;
+ CfreeTarget gt;
+ int exit_code = -1;
+ CfreeStatus st;
+ long ps;
+
+ *ok = 0;
+ if (!c) return -1;
+ ps = sysconf(_SC_PAGESIZE);
+ if (ps > 0) g_execmem.page_size = (size_t)ps;
+
+ memset(&host, 0, sizeof(host));
+ host.execmem = &g_execmem;
+ memset(>, 0, sizeof(gt));
+ gt.arch = CFREE_ARCH_RV64;
+ gt.os = CFREE_OS_LINUX;
+ gt.obj = CFREE_OBJ_ELF;
+ gt.ptr_size = 8u;
+ gt.ptr_align = 8u;
+ memset(&opts, 0, sizeof(opts));
+ opts.guest_bytes.data = elf;
+ opts.guest_bytes.len = elf_len;
+ opts.guest_target = gt;
+ opts.has_guest_target = true;
+ opts.jit_host = &host;
+ opts.optimize = 1; /* both arms at -O1: isolate interp-vs-jit, not O0-vs-O1 */
+ opts.mode = mode;
+
+ /* Bounded stepping (instead of the unbounded cfree_emu_run) so a guest that
+ * fails to terminate surfaces as a finite failure, not a hang. */
+ {
+ CfreeEmu* emu = NULL;
+ EmuCPUState* cpu;
+ EmuTrapReason trap = EMU_TRAP_NONE;
+ uint32_t i;
+ enum { MAX_BLOCKS = 256u };
+ st = cfree_emu_new(c, &opts, &emu);
+ if (st != CFREE_OK || !emu) {
+ cfree_compiler_free(c);
+ return -1;
+ }
+ for (i = 0; i < MAX_BLOCKS; ++i) {
+ st = cfree_emu_step(emu, 1);
+ if (st != CFREE_OK) break;
+ cpu = emu_internal_cpu(emu);
+ trap = emu_cpu_trap_reason(cpu);
+ if (trap != EMU_TRAP_NONE) break;
+ }
+ cpu = emu_internal_cpu(emu);
+ trap = emu_cpu_trap_reason(cpu);
+ if (st == CFREE_OK && trap == EMU_TRAP_EXIT) {
+ exit_code = emu_cpu_exit_code(cpu);
+ *ok = 1;
+ } else {
+ fprintf(stderr, " run: st=%d trap=%d after %u blocks, pc=0x%llx\n",
+ (int)st, (int)trap, i, (unsigned long long)emu_cpu_pc(cpu));
+ }
+ cfree_emu_free(emu);
+ }
+ cfree_compiler_free(c);
+ return exit_code;
+}
+
+/* Run `elf` under both modes (both -O1), asserting both complete, both equal
+ * `expect`, and the two agree. Takes ownership of `elf` (frees it). */
+static void check_differential(const char* name, unsigned char* elf,
+ size_t elf_len, int expect) {
+ int jit_ok = 0, interp_ok = 0;
+ int jit_exit, interp_exit;
+
+ if (!elf) {
+ EXPECT(0, "%s: ELF buffer allocation failed", name);
+ return;
+ }
+ jit_exit = run_guest(elf, elf_len, CFREE_EMU_MODE_JIT, &jit_ok);
+ interp_exit = run_guest(elf, elf_len, CFREE_EMU_MODE_INTERP, &interp_ok);
+ free(elf);
+
+ EXPECT(jit_ok, "%s: JIT run did not complete cleanly", name);
+ EXPECT(interp_ok, "%s: INTERP run did not complete cleanly", name);
+ EXPECT(jit_exit == expect, "%s: JIT exit should be %d, got %d", name, expect,
+ jit_exit);
+ EXPECT(interp_exit == expect, "%s: INTERP exit should be %d, got %d", name,
+ expect, interp_exit);
+ EXPECT(jit_exit == interp_exit,
+ "%s: JIT vs INTERP exit codes differ: jit=%d interp=%d", name, jit_exit,
+ interp_exit);
+ if (jit_ok && interp_ok && jit_exit == expect && interp_exit == expect)
+ fprintf(stderr, "PASS %s (jit=%d interp=%d)\n", name, jit_exit, interp_exit);
+}
+
+int main(void) {
+ size_t len = 0;
+
+ /* (1) SD/LD/ecall: exercises the guest-memory helper path. */
+ check_differential("sd-ld-ecall", build_sd_ld_elf(&len, 99u), len, 99);
+
+ /* (2) Self-modifying code: exercises code-cache + interp-capture
+ * invalidation (a stale interp lookup would make INTERP diverge from JIT). */
+ check_differential("self-modifying-code", build_smc_elf(&len), len, 2);
+
+ fprintf(stderr, "interp-emu-smoke: %d failure%s\n", g_fail,
+ g_fail == 1 ? "" : "s");
+ return g_fail ? 1 : 0;
+}
diff --git a/test/emu/rv64_smoke_test.c b/test/emu/rv64_smoke_test.c
@@ -19,6 +19,7 @@
#include <cfree/compile.h>
#include <cfree/core.h>
+#include <cfree/emu.h>
#include <cfree/jit.h>
#include <stdio.h>
#include <stdlib.h>
@@ -39,14 +40,16 @@
#define XM_DUAL_LINUX 0
#endif
-#include "arch/arch.h"
+/* Internal headers used only for header-only helpers (no internal link symbols
+ * are referenced — this test drives the emulator entirely through the public
+ * cfree_emu_* API, so it links the public archive): rv64 instruction encoders
+ * (static inline) and ELF64 layout constants. The accompanying white-box unit
+ * tests for the decoder / address space / syscall units live in
+ * rv64_vm_unit_test.c, which links the library objects directly. */
#include "arch/rv64/isa.h"
#include "core/core.h"
-#include "emu/emu.h"
#include "obj/elf/elf.h"
-EmuCPUState* emu_internal_cpu(CfreeEmu*);
-
/* Host heap glue (same shape as test/api). */
static void* h_alloc(CfreeHeap* h, size_t n, size_t a) {
(void)h;
@@ -227,25 +230,6 @@ static int g_fail;
} \
} while (0)
-static CfreeCompiler* new_compiler(void) {
- CfreeTarget t;
- CfreeCompiler* c = NULL;
- memset(&t, 0, sizeof t);
- t.arch = CFREE_ARCH_RV64;
- t.os = CFREE_OS_LINUX;
- t.obj = CFREE_OBJ_ELF;
- t.ptr_size = 8;
- t.ptr_align = 8;
- memset(&g_ctx, 0, sizeof g_ctx);
- g_ctx.heap = &g_heap;
- g_ctx.diag = &g_diag;
- if (cfree_compiler_new(t, &g_ctx, &c) != CFREE_OK || !c) {
- fprintf(stderr, "compiler_new failed\n");
- exit(2);
- }
- return c;
-}
-
static CfreeCompiler* new_host_compiler(void) {
CfreeTarget t;
CfreeCompiler* c = NULL;
@@ -1045,209 +1029,6 @@ static unsigned char* build_signal_sigreturn_elf(size_t* out_len) {
* Decoder smoke (sanity-check a handful of encodings before the
* end-to-end JIT run).
* ============================================================ */
-static void decoder_smoke(void) {
- CfreeCompiler* c = new_compiler();
- const ArchImpl* arch = arch_lookup(CFREE_ARCH_RV64);
- CfreeDecodedInsn insts[8];
- CfreeStatus st;
- u32 n;
- unsigned char buf[16];
- put32(buf, 0, rv_addi(RV_A0, RV_ZERO, 42));
- put32(buf, 4, rv_addi(RV_A7, RV_ZERO, 94));
- put32(buf, 8, rv_ecall());
- put32(buf, 12, rv_add(RV_T0, RV_A0, RV_A1));
- EXPECT(arch && arch->decode && arch->decode->decode_block,
- "rv64 ArchDecodeOps unavailable");
- if (!arch || !arch->decode || !arch->decode->decode_block) {
- cfree_compiler_free(c);
- return;
- }
- st = arch->decode->decode_block((Compiler*)c, buf, sizeof(buf), 0x10000,
- insts, 8, &n);
- EXPECT(st == CFREE_OK, "decode_block returned %d", (int)st);
- EXPECT(n >= 3u, "decode block returned %u insts", n);
- EXPECT(insts[0].opcode == RV64_DEC_ADDI, "first insn must be ADDI, got %u",
- insts[0].opcode);
- EXPECT(insts[0].operands[0].reg == RV_A0, "rd should be a0");
- EXPECT(insts[0].operands[2].imm == 42, "imm should be 42");
- EXPECT(insts[1].opcode == RV64_DEC_ADDI, "second insn must be ADDI");
- EXPECT(insts[1].operands[2].imm == 94, "imm should be 94");
- EXPECT(insts[2].opcode == RV64_DEC_ECALL, "third insn must be ECALL, got %u",
- insts[2].opcode);
- EXPECT(insts[2].flags & CFREE_DECODE_TERMINATOR,
- "ECALL must be marked terminator");
- /* The block stops at ECALL; the ADD at offset 12 should not have
- * been decoded. */
- EXPECT(n == 3u, "decoder must stop at the terminator (got n=%u)", n);
- cfree_compiler_free(c);
-}
-
-static void vm_unit_smoke(void) {
- CfreeCompiler* c = new_host_compiler();
- EmuAddrSpace as;
- u8* p;
- const EmuMemFault* fault;
- CfreeStatus st;
- if (!c) return;
- memset(&as, 0, sizeof(as));
- st = emu_addr_space_init(&as, (Compiler*)c, 0x1000u);
- EXPECT(st == CFREE_OK, "vm: init returned %d", (int)st);
- if (st != CFREE_OK) {
- cfree_compiler_free(c);
- return;
- }
-
- st = emu_addr_space_map(&as, 0x10000u, 0x3000u, EMU_MEM_READ | EMU_MEM_WRITE,
- EMU_MAP_ANON);
- EXPECT(st == CFREE_OK, "vm: initial anon map returned %d", (int)st);
- EXPECT(as.nmaps == 1u, "vm: expected one map, got %u", as.nmaps);
-
- st = emu_addr_space_map(&as, 0x11000u, 0x1000u, EMU_MEM_READ, EMU_MAP_ANON);
- EXPECT(st == CFREE_INVALID, "vm: overlapping map must be rejected");
-
- p = emu_addr_space_ptr(&as, 0x10ff8u, 16u, EMU_MEM_WRITE);
- EXPECT(p != NULL, "vm: cross-page access inside one map should succeed");
- EXPECT(as.maps[0].dirty_pages[0] && as.maps[0].dirty_pages[1],
- "vm: cross-page write should dirty both pages");
-
- st = emu_addr_space_map(&as, 0x20000u, 0x1000u, 0, EMU_MAP_GUARD);
- EXPECT(st == CFREE_OK, "vm: guard map returned %d", (int)st);
- p = emu_addr_space_ptr(&as, 0x20000u, 1u, EMU_MEM_READ);
- fault = emu_addr_space_last_fault(&as);
- EXPECT(p == NULL && fault && fault->kind == EMU_FAULT_PROT,
- "vm: guard read should be a protection fault");
-
- st = emu_addr_space_protect(&as, 0x11000u, 0x1000u, EMU_MEM_READ);
- EXPECT(st == CFREE_OK, "vm: middle-page protect returned %d", (int)st);
- EXPECT(as.nmaps == 4u, "vm: protect should split maps, got %u", as.nmaps);
- p = emu_addr_space_ptr(&as, 0x11000u, 1u, EMU_MEM_WRITE);
- fault = emu_addr_space_last_fault(&as);
- EXPECT(p == NULL && fault && fault->kind == EMU_FAULT_PROT,
- "vm: write to read-only split page should fault");
- p = emu_addr_space_ptr(&as, 0x11000u, 1u, EMU_MEM_READ);
- EXPECT(p != NULL, "vm: read from read-only split page should succeed");
-
- st = emu_addr_space_unmap(&as, 0x11000u, 0x1000u);
- EXPECT(st == CFREE_OK, "vm: middle-page unmap returned %d", (int)st);
- p = emu_addr_space_ptr(&as, 0x11000u, 1u, EMU_MEM_READ);
- fault = emu_addr_space_last_fault(&as);
- EXPECT(p == NULL && fault && fault->kind == EMU_FAULT_UNMAPPED,
- "vm: read from unmapped hole should fault as unmapped");
-
- {
- u64 gap = 0;
- st = emu_addr_space_find_gap(&as, 0x1000u, 0x1000u, 0x10000u, 0x30000u,
- &gap);
- EXPECT(st == CFREE_OK && gap == 0x11000u,
- "vm: find_gap should return the unmapped hole, got st=%d gap=0x%llx",
- (int)st, (unsigned long long)gap);
- }
-
- emu_addr_space_destroy(&as);
- cfree_compiler_free(c);
-}
-
-static void linux_vm_syscall_smoke(void) {
- CfreeCompiler* c = new_host_compiler();
- EmuProcess process;
- EmuThread thread;
- EmuCPUState* cpu;
- const CfreeOsImpl* os;
- const ArchImpl* arch;
- u64 addr;
- u8* p;
- const EmuMemFault* fault;
- if (!c) return;
- memset(&process, 0, sizeof(process));
- memset(&thread, 0, sizeof(thread));
- os = os_lookup(CFREE_OS_LINUX);
- arch = arch_lookup(CFREE_ARCH_RV64);
- EXPECT(os && os->emu_default_syscall, "linux vm syscall: OS hook missing");
- EXPECT(arch && arch->emu, "linux vm syscall: arch hook missing");
- if (!os || !os->emu_default_syscall || !arch || !arch->emu) {
- cfree_compiler_free(c);
- return;
- }
- process.compiler = (Compiler*)c;
- process.os = os;
- process.arch = arch;
- process.guest_target.arch = CFREE_ARCH_RV64;
- process.guest_target.os = CFREE_OS_LINUX;
- process.bindings.syscall = os->emu_default_syscall;
- if (os->emu_init_process_private) {
- EXPECT(os->emu_init_process_private((Compiler*)c, &process) == CFREE_OK,
- "linux vm syscall: process private init failed");
- }
- thread.process = &process;
- if (os->emu_init_thread_private) {
- EXPECT(os->emu_init_thread_private((Compiler*)c, &process, &thread) ==
- CFREE_OK,
- "linux vm syscall: thread private init failed");
- }
- EXPECT(emu_addr_space_init(&process.image.addr_space, (Compiler*)c,
- 0x1000u) == CFREE_OK,
- "linux vm syscall: address-space init failed");
- cpu = arch->emu->cpu_new((Compiler*)c, 0, 0);
- EXPECT(cpu != NULL, "linux vm syscall: cpu alloc failed");
- if (!cpu) {
- if (os->emu_destroy_thread_private)
- os->emu_destroy_thread_private((Compiler*)c, &thread);
- if (os->emu_destroy_process_private)
- os->emu_destroy_process_private((Compiler*)c, &process);
- emu_addr_space_destroy(&process.image.addr_space);
- cfree_compiler_free(c);
- return;
- }
- thread.cpu = cpu;
- emu_cpu_set_thread(cpu, &thread);
- emu_cpu_attach_addr_space(cpu, &process.image.addr_space);
-
- arch->emu->set_gpr(&thread, RV_A0, 0);
- arch->emu->set_gpr(&thread, RV_A1, 0x1000u);
- arch->emu->set_gpr(&thread, RV_A2, 3u);
- arch->emu->set_gpr(&thread, RV_A3, 0x22u);
- arch->emu->set_gpr(&thread, RV_A4, ~(u64)0);
- arch->emu->set_gpr(&thread, RV_A5, 0);
- arch->emu->set_gpr(&thread, RV_A7, 222u);
- emu_syscall(&thread);
- addr = arch->emu->get_gpr(&thread, RV_A0);
- EXPECT((i64)addr > 0, "linux vm syscall: mmap returned 0x%llx",
- (unsigned long long)addr);
- p = emu_addr_space_ptr(&process.image.addr_space, addr, 8u, EMU_MEM_WRITE);
- EXPECT(p != NULL, "linux vm syscall: mmap result should be writable");
-
- arch->emu->set_gpr(&thread, RV_A0, addr);
- arch->emu->set_gpr(&thread, RV_A1, 0x1000u);
- arch->emu->set_gpr(&thread, RV_A2, 1u);
- arch->emu->set_gpr(&thread, RV_A7, 226u);
- emu_syscall(&thread);
- EXPECT(arch->emu->get_gpr(&thread, RV_A0) == 0,
- "linux vm syscall: mprotect failed");
- p = emu_addr_space_ptr(&process.image.addr_space, addr, 8u, EMU_MEM_WRITE);
- fault = emu_addr_space_last_fault(&process.image.addr_space);
- EXPECT(p == NULL && fault && fault->kind == EMU_FAULT_PROT,
- "linux vm syscall: mprotect should deny writes");
-
- arch->emu->set_gpr(&thread, RV_A0, addr);
- arch->emu->set_gpr(&thread, RV_A1, 0x1000u);
- arch->emu->set_gpr(&thread, RV_A7, 215u);
- emu_syscall(&thread);
- EXPECT(arch->emu->get_gpr(&thread, RV_A0) == 0,
- "linux vm syscall: munmap failed");
- p = emu_addr_space_ptr(&process.image.addr_space, addr, 1u, EMU_MEM_READ);
- fault = emu_addr_space_last_fault(&process.image.addr_space);
- EXPECT(p == NULL && fault && fault->kind == EMU_FAULT_UNMAPPED,
- "linux vm syscall: munmap should remove the mapping");
-
- emu_cpu_free(cpu);
- if (os->emu_destroy_thread_private)
- os->emu_destroy_thread_private((Compiler*)c, &thread);
- if (os->emu_destroy_process_private)
- os->emu_destroy_process_private((Compiler*)c, &process);
- emu_addr_space_destroy(&process.image.addr_space);
- cfree_compiler_free(c);
-}
-
static void emu_fixture_expect_exit_with_bindings(
const char* name, unsigned char* elf, size_t elf_len, int want_exit,
uint32_t max_blocks, const CfreeEmuExternalBindings* bindings);
@@ -1268,12 +1049,10 @@ static void emu_fixture_expect_exit_with_bindings(
CfreeJitHost host;
CfreeEmuOptions opts;
CfreeTarget guest_target;
- CfreeEmu* emu = NULL;
- EmuCPUState* cpu;
- EmuTrapReason trap;
CfreeStatus st;
- uint32_t i;
+ int exit_code = -1;
long ps;
+ (void)max_blocks; /* cfree_emu_run drives the guest to its exit syscall */
c = new_host_compiler();
if (!c) {
@@ -1299,31 +1078,15 @@ static void emu_fixture_expect_exit_with_bindings(
opts.jit_host = &host;
if (bindings) opts.bindings = *bindings;
- st = cfree_emu_new(c, &opts, &emu);
- EXPECT(st == CFREE_OK, "%s: cfree_emu_new returned %d", name, (int)st);
- if (st == CFREE_OK && emu) {
- trap = EMU_TRAP_NONE;
- for (i = 0; i < max_blocks && trap == EMU_TRAP_NONE; ++i) {
- st = cfree_emu_step(emu, 1);
- EXPECT(st == CFREE_OK, "%s: cfree_emu_step returned %d at block %u", name,
- (int)st, i);
- if (st != CFREE_OK) break;
- cpu = emu_internal_cpu(emu);
- trap = emu_cpu_trap_reason(cpu);
- }
- cpu = emu_internal_cpu(emu);
- trap = emu_cpu_trap_reason(cpu);
- EXPECT(trap == EMU_TRAP_EXIT, "%s: expected exit trap within %u blocks",
- name, max_blocks);
- EXPECT(emu_cpu_exit_code(cpu) == want_exit,
- "%s: exit_code should be %d, got %d", name, want_exit,
- emu_cpu_exit_code(cpu));
- if (trap == EMU_TRAP_EXIT && emu_cpu_exit_code(cpu) == want_exit) {
- fprintf(stderr, "PASS %s\n", name);
- }
- }
+ /* Public end-to-end entry: loads the guest, runs it to its exit syscall, and
+ * returns the code. A guest fault makes cfree_emu_run return non-OK. */
+ st = cfree_emu_run(c, &opts, &exit_code);
+ EXPECT(st == CFREE_OK, "%s: cfree_emu_run returned %d", name, (int)st);
+ EXPECT(exit_code == want_exit, "%s: exit_code should be %d, got %d", name,
+ want_exit, exit_code);
+ if (st == CFREE_OK && exit_code == want_exit)
+ fprintf(stderr, "PASS %s\n", name);
- if (emu) cfree_emu_free(emu);
free(elf);
cfree_compiler_free(c);
}
@@ -1471,7 +1234,6 @@ static void signal_sigreturn_smoke(void) {
}
int main(void) {
- decoder_smoke();
jit_vertical_smoke();
dynamic_import_tls_red();
host_import_bridge_smoke();
@@ -1480,8 +1242,6 @@ int main(void) {
signal_perms_red();
signal_load_fault_smoke();
signal_sigreturn_smoke();
- vm_unit_smoke();
- linux_vm_syscall_smoke();
if (g_fail) {
fprintf(stderr, "FAILED %d check(s)\n", g_fail);
return 1;
diff --git a/test/emu/rv64_vm_unit_test.c b/test/emu/rv64_vm_unit_test.c
@@ -0,0 +1,340 @@
+/* RV64 emulator white-box unit tests.
+ *
+ * These exercise INTERNAL units that have no public API surface — the rv64
+ * decoder (ArchDecodeOps), the guest address space (EmuAddrSpace), and the
+ * Linux syscall handler (mmap/mprotect/munmap) — so this binary links the
+ * library objects directly (test.mk), unlike rv64_smoke_test.c which drives the
+ * emulator end-to-end through the public cfree_emu_* API and links the archive.
+ */
+
+#include <cfree/compile.h>
+#include <cfree/core.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "arch/arch.h"
+#include "arch/rv64/isa.h"
+#include "core/core.h"
+#include "emu/emu.h"
+
+/* ---- Host heap / diag glue ---- */
+static void* h_alloc(CfreeHeap* h, size_t n, size_t a) {
+ (void)h;
+ (void)a;
+ return n ? malloc(n) : NULL;
+}
+static void* h_realloc(CfreeHeap* h, void* p, size_t o, size_t n, size_t a) {
+ (void)h;
+ (void)o;
+ (void)a;
+ return realloc(p, n);
+}
+static void h_free(CfreeHeap* h, void* p, size_t n) {
+ (void)h;
+ (void)n;
+ free(p);
+}
+static CfreeHeap g_heap = {h_alloc, h_realloc, h_free, NULL};
+
+static void diag_emit(CfreeDiagSink* s, CfreeDiagKind k, CfreeSrcLoc loc,
+ const char* fmt, va_list ap) {
+ (void)s;
+ (void)loc;
+ fprintf(stderr, "diag %d: ", (int)k);
+ vfprintf(stderr, fmt, ap);
+ fputc('\n', stderr);
+}
+static CfreeDiagSink g_diag = {diag_emit, NULL, 0, 0};
+static CfreeContext g_ctx;
+
+static int g_fail;
+#define EXPECT(cond, ...) \
+ do { \
+ if (!(cond)) { \
+ ++g_fail; \
+ fprintf(stderr, "FAIL %s:%d: ", __FILE__, __LINE__); \
+ fprintf(stderr, __VA_ARGS__); \
+ fputc('\n', stderr); \
+ } \
+ } while (0)
+
+static CfreeCompiler* new_compiler(void) {
+ CfreeTarget t;
+ CfreeCompiler* c = NULL;
+ memset(&t, 0, sizeof t);
+ t.arch = CFREE_ARCH_RV64;
+ t.os = CFREE_OS_LINUX;
+ t.obj = CFREE_OBJ_ELF;
+ t.ptr_size = 8;
+ t.ptr_align = 8;
+ memset(&g_ctx, 0, sizeof g_ctx);
+ g_ctx.heap = &g_heap;
+ g_ctx.diag = &g_diag;
+ if (cfree_compiler_new(t, &g_ctx, &c) != CFREE_OK || !c) {
+ fprintf(stderr, "compiler_new failed\n");
+ exit(2);
+ }
+ return c;
+}
+
+static CfreeCompiler* new_host_compiler(void) {
+ CfreeTarget t;
+ CfreeCompiler* c = NULL;
+ memset(&t, 0, sizeof t);
+#if defined(__x86_64__) || defined(_M_X64)
+ t.arch = CFREE_ARCH_X86_64;
+#elif defined(__aarch64__) || defined(_M_ARM64)
+ t.arch = CFREE_ARCH_ARM_64;
+#elif defined(__riscv) && __riscv_xlen == 64
+ t.arch = CFREE_ARCH_RV64;
+#else
+ return NULL;
+#endif
+#if defined(__APPLE__)
+ t.os = CFREE_OS_MACOS;
+ t.obj = CFREE_OBJ_MACHO;
+#elif defined(__linux__)
+ t.os = CFREE_OS_LINUX;
+ t.obj = CFREE_OBJ_ELF;
+#else
+ return NULL;
+#endif
+ t.ptr_size = 8;
+ t.ptr_align = 8;
+ memset(&g_ctx, 0, sizeof g_ctx);
+ g_ctx.heap = &g_heap;
+ g_ctx.diag = &g_diag;
+ if (cfree_compiler_new(t, &g_ctx, &c) != CFREE_OK || !c) {
+ fprintf(stderr, "host compiler_new failed\n");
+ exit(2);
+ }
+ return c;
+}
+
+static void put32(unsigned char* b, size_t off, u32 v) {
+ b[off + 0] = (unsigned char)v;
+ b[off + 1] = (unsigned char)(v >> 8);
+ b[off + 2] = (unsigned char)(v >> 16);
+ b[off + 3] = (unsigned char)(v >> 24);
+}
+
+/* ---- The rv64 decoder: ADDI/ECALL decode + terminator stop. ---- */
+static void decoder_smoke(void) {
+ CfreeCompiler* c = new_compiler();
+ const ArchImpl* arch = arch_lookup(CFREE_ARCH_RV64);
+ CfreeDecodedInsn insts[8];
+ CfreeStatus st;
+ u32 n;
+ unsigned char buf[16];
+ put32(buf, 0, rv_addi(RV_A0, RV_ZERO, 42));
+ put32(buf, 4, rv_addi(RV_A7, RV_ZERO, 94));
+ put32(buf, 8, rv_ecall());
+ put32(buf, 12, rv_add(RV_T0, RV_A0, RV_A1));
+ EXPECT(arch && arch->decode && arch->decode->decode_block,
+ "rv64 ArchDecodeOps unavailable");
+ if (!arch || !arch->decode || !arch->decode->decode_block) {
+ cfree_compiler_free(c);
+ return;
+ }
+ st = arch->decode->decode_block((Compiler*)c, buf, sizeof(buf), 0x10000,
+ insts, 8, &n);
+ EXPECT(st == CFREE_OK, "decode_block returned %d", (int)st);
+ EXPECT(n >= 3u, "decode block returned %u insts", n);
+ EXPECT(insts[0].opcode == RV64_DEC_ADDI, "first insn must be ADDI, got %u",
+ insts[0].opcode);
+ EXPECT(insts[0].operands[0].reg == RV_A0, "rd should be a0");
+ EXPECT(insts[0].operands[2].imm == 42, "imm should be 42");
+ EXPECT(insts[1].opcode == RV64_DEC_ADDI, "second insn must be ADDI");
+ EXPECT(insts[1].operands[2].imm == 94, "imm should be 94");
+ EXPECT(insts[2].opcode == RV64_DEC_ECALL, "third insn must be ECALL, got %u",
+ insts[2].opcode);
+ EXPECT(insts[2].flags & CFREE_DECODE_TERMINATOR,
+ "ECALL must be marked terminator");
+ /* The block stops at ECALL; the ADD at offset 12 should not have
+ * been decoded. */
+ EXPECT(n == 3u, "decoder must stop at the terminator (got n=%u)", n);
+ cfree_compiler_free(c);
+}
+
+/* ---- The guest address space: map/protect/unmap/find_gap + fault kinds. ---- */
+static void vm_unit_smoke(void) {
+ CfreeCompiler* c = new_host_compiler();
+ EmuAddrSpace as;
+ u8* p;
+ const EmuMemFault* fault;
+ CfreeStatus st;
+ if (!c) return;
+ memset(&as, 0, sizeof(as));
+ st = emu_addr_space_init(&as, (Compiler*)c, 0x1000u);
+ EXPECT(st == CFREE_OK, "vm: init returned %d", (int)st);
+ if (st != CFREE_OK) {
+ cfree_compiler_free(c);
+ return;
+ }
+
+ st = emu_addr_space_map(&as, 0x10000u, 0x3000u, EMU_MEM_READ | EMU_MEM_WRITE,
+ EMU_MAP_ANON);
+ EXPECT(st == CFREE_OK, "vm: initial anon map returned %d", (int)st);
+ EXPECT(as.nmaps == 1u, "vm: expected one map, got %u", as.nmaps);
+
+ st = emu_addr_space_map(&as, 0x11000u, 0x1000u, EMU_MEM_READ, EMU_MAP_ANON);
+ EXPECT(st == CFREE_INVALID, "vm: overlapping map must be rejected");
+
+ p = emu_addr_space_ptr(&as, 0x10ff8u, 16u, EMU_MEM_WRITE);
+ EXPECT(p != NULL, "vm: cross-page access inside one map should succeed");
+ EXPECT(as.maps[0].dirty_pages[0] && as.maps[0].dirty_pages[1],
+ "vm: cross-page write should dirty both pages");
+
+ st = emu_addr_space_map(&as, 0x20000u, 0x1000u, 0, EMU_MAP_GUARD);
+ EXPECT(st == CFREE_OK, "vm: guard map returned %d", (int)st);
+ p = emu_addr_space_ptr(&as, 0x20000u, 1u, EMU_MEM_READ);
+ fault = emu_addr_space_last_fault(&as);
+ EXPECT(p == NULL && fault && fault->kind == EMU_FAULT_PROT,
+ "vm: guard read should be a protection fault");
+
+ st = emu_addr_space_protect(&as, 0x11000u, 0x1000u, EMU_MEM_READ);
+ EXPECT(st == CFREE_OK, "vm: middle-page protect returned %d", (int)st);
+ EXPECT(as.nmaps == 4u, "vm: protect should split maps, got %u", as.nmaps);
+ p = emu_addr_space_ptr(&as, 0x11000u, 1u, EMU_MEM_WRITE);
+ fault = emu_addr_space_last_fault(&as);
+ EXPECT(p == NULL && fault && fault->kind == EMU_FAULT_PROT,
+ "vm: write to read-only split page should fault");
+ p = emu_addr_space_ptr(&as, 0x11000u, 1u, EMU_MEM_READ);
+ EXPECT(p != NULL, "vm: read from read-only split page should succeed");
+
+ st = emu_addr_space_unmap(&as, 0x11000u, 0x1000u);
+ EXPECT(st == CFREE_OK, "vm: middle-page unmap returned %d", (int)st);
+ p = emu_addr_space_ptr(&as, 0x11000u, 1u, EMU_MEM_READ);
+ fault = emu_addr_space_last_fault(&as);
+ EXPECT(p == NULL && fault && fault->kind == EMU_FAULT_UNMAPPED,
+ "vm: read from unmapped hole should fault as unmapped");
+
+ {
+ u64 gap = 0;
+ st = emu_addr_space_find_gap(&as, 0x1000u, 0x1000u, 0x10000u, 0x30000u,
+ &gap);
+ EXPECT(st == CFREE_OK && gap == 0x11000u,
+ "vm: find_gap should return the unmapped hole, got st=%d gap=0x%llx",
+ (int)st, (unsigned long long)gap);
+ }
+
+ emu_addr_space_destroy(&as);
+ cfree_compiler_free(c);
+}
+
+/* ---- The Linux syscall handler: mmap -> mprotect -> munmap. ---- */
+static void linux_vm_syscall_smoke(void) {
+ CfreeCompiler* c = new_host_compiler();
+ EmuProcess process;
+ EmuThread thread;
+ EmuCPUState* cpu;
+ const CfreeOsImpl* os;
+ const ArchImpl* arch;
+ u64 addr;
+ u8* p;
+ const EmuMemFault* fault;
+ if (!c) return;
+ memset(&process, 0, sizeof(process));
+ memset(&thread, 0, sizeof(thread));
+ os = os_lookup(CFREE_OS_LINUX);
+ arch = arch_lookup(CFREE_ARCH_RV64);
+ EXPECT(os && os->emu_default_syscall, "linux vm syscall: OS hook missing");
+ EXPECT(arch && arch->emu, "linux vm syscall: arch hook missing");
+ if (!os || !os->emu_default_syscall || !arch || !arch->emu) {
+ cfree_compiler_free(c);
+ return;
+ }
+ process.compiler = (Compiler*)c;
+ process.os = os;
+ process.arch = arch;
+ process.guest_target.arch = CFREE_ARCH_RV64;
+ process.guest_target.os = CFREE_OS_LINUX;
+ process.bindings.syscall = os->emu_default_syscall;
+ if (os->emu_init_process_private) {
+ EXPECT(os->emu_init_process_private((Compiler*)c, &process) == CFREE_OK,
+ "linux vm syscall: process private init failed");
+ }
+ thread.process = &process;
+ if (os->emu_init_thread_private) {
+ EXPECT(os->emu_init_thread_private((Compiler*)c, &process, &thread) ==
+ CFREE_OK,
+ "linux vm syscall: thread private init failed");
+ }
+ EXPECT(emu_addr_space_init(&process.image.addr_space, (Compiler*)c,
+ 0x1000u) == CFREE_OK,
+ "linux vm syscall: address-space init failed");
+ cpu = arch->emu->cpu_new((Compiler*)c, 0, 0);
+ EXPECT(cpu != NULL, "linux vm syscall: cpu alloc failed");
+ if (!cpu) {
+ if (os->emu_destroy_thread_private)
+ os->emu_destroy_thread_private((Compiler*)c, &thread);
+ if (os->emu_destroy_process_private)
+ os->emu_destroy_process_private((Compiler*)c, &process);
+ emu_addr_space_destroy(&process.image.addr_space);
+ cfree_compiler_free(c);
+ return;
+ }
+ thread.cpu = cpu;
+ emu_cpu_set_thread(cpu, &thread);
+ emu_cpu_attach_addr_space(cpu, &process.image.addr_space);
+
+ arch->emu->set_gpr(&thread, RV_A0, 0);
+ arch->emu->set_gpr(&thread, RV_A1, 0x1000u);
+ arch->emu->set_gpr(&thread, RV_A2, 3u);
+ arch->emu->set_gpr(&thread, RV_A3, 0x22u);
+ arch->emu->set_gpr(&thread, RV_A4, ~(u64)0);
+ arch->emu->set_gpr(&thread, RV_A5, 0);
+ arch->emu->set_gpr(&thread, RV_A7, 222u);
+ emu_syscall(&thread);
+ addr = arch->emu->get_gpr(&thread, RV_A0);
+ EXPECT((i64)addr > 0, "linux vm syscall: mmap returned 0x%llx",
+ (unsigned long long)addr);
+ p = emu_addr_space_ptr(&process.image.addr_space, addr, 8u, EMU_MEM_WRITE);
+ EXPECT(p != NULL, "linux vm syscall: mmap result should be writable");
+
+ arch->emu->set_gpr(&thread, RV_A0, addr);
+ arch->emu->set_gpr(&thread, RV_A1, 0x1000u);
+ arch->emu->set_gpr(&thread, RV_A2, 1u);
+ arch->emu->set_gpr(&thread, RV_A7, 226u);
+ emu_syscall(&thread);
+ EXPECT(arch->emu->get_gpr(&thread, RV_A0) == 0,
+ "linux vm syscall: mprotect failed");
+ p = emu_addr_space_ptr(&process.image.addr_space, addr, 8u, EMU_MEM_WRITE);
+ fault = emu_addr_space_last_fault(&process.image.addr_space);
+ EXPECT(p == NULL && fault && fault->kind == EMU_FAULT_PROT,
+ "linux vm syscall: mprotect should deny writes");
+
+ arch->emu->set_gpr(&thread, RV_A0, addr);
+ arch->emu->set_gpr(&thread, RV_A1, 0x1000u);
+ arch->emu->set_gpr(&thread, RV_A7, 215u);
+ emu_syscall(&thread);
+ EXPECT(arch->emu->get_gpr(&thread, RV_A0) == 0,
+ "linux vm syscall: munmap failed");
+ p = emu_addr_space_ptr(&process.image.addr_space, addr, 1u, EMU_MEM_READ);
+ fault = emu_addr_space_last_fault(&process.image.addr_space);
+ EXPECT(p == NULL && fault && fault->kind == EMU_FAULT_UNMAPPED,
+ "linux vm syscall: munmap should remove the mapping");
+
+ emu_cpu_free(cpu);
+ if (os->emu_destroy_thread_private)
+ os->emu_destroy_thread_private((Compiler*)c, &thread);
+ if (os->emu_destroy_process_private)
+ os->emu_destroy_process_private((Compiler*)c, &process);
+ emu_addr_space_destroy(&process.image.addr_space);
+ cfree_compiler_free(c);
+}
+
+int main(void) {
+ decoder_smoke();
+ vm_unit_smoke();
+ linux_vm_syscall_smoke();
+ if (g_fail) {
+ fprintf(stderr, "FAILED %d check(s)\n", g_fail);
+ return 1;
+ }
+ fprintf(stderr, "OK\n");
+ return 0;
+}
diff --git a/test/test.mk b/test/test.mk
@@ -63,7 +63,9 @@ TEST_TARGETS = \
test-dwarf \
test-elf \
test-emu \
+ test-emu-unit \
test-interp \
+ test-interp-emu \
test-interp-toy \
test-ir-recorder \
test-isa \
@@ -122,7 +124,9 @@ DEFAULT_TEST_TARGETS = \
test-rv64-jit \
test-rv64-tls-link \
test-emu \
+ test-emu-unit \
test-interp \
+ test-interp-emu \
test-x64-inline \
test-x64-dbg \
test-rt-headers \
@@ -284,9 +288,11 @@ $(AA64_SWEEP_GEN): test/arch/aa64_sweep_gen.c $(LIB_OBJS)
@mkdir -p $(dir $@)
$(CC) $(TEST_HOST_CFLAGS) -Isrc test/arch/aa64_sweep_gen.c $(LIB_OBJS) -o $@
-# test-emu: emulator unit tests. The rv64 lane builds a tiny in-memory rv64
-# ELF and asserts the lifted/JIT path exits through the syscall handler with
-# the expected code. Internal arch/emu surface — needs -Isrc.
+# test-emu: emulator end-to-end integration test. Builds tiny in-memory rv64
+# ELFs and runs them to their exit syscall, asserting the exit code — entirely
+# through the PUBLIC cfree_emu_* API, so it links the public archive ($(LIB_AR),
+# whose `ld -r` step localizes internal symbols). -Isrc is only for the
+# header-only rv64 encoders / ELF constants the in-memory ELF builders use.
EMU_RV64_TEST_BIN = build/test/emu_rv64_test
test-emu: $(EMU_RV64_TEST_BIN)
@@ -307,6 +313,18 @@ test-link-reloc-uleb128: $(RELOC_ULEB128_TEST_BIN)
$(RELOC_ULEB128_TEST_BIN): test/link/reloc_uleb128_unit.c $(LIB_OBJS)
@mkdir -p $(dir $@)
$(CC) $(TEST_HOST_CFLAGS) -Isrc test/link/reloc_uleb128_unit.c $(LIB_OBJS) -o $@
+# test-emu-unit: white-box unit tests for the emulator's INTERNAL units (rv64
+# decoder, EmuAddrSpace, Linux syscall handler) that have no public API. Reaches
+# internal symbols -> links $(LIB_OBJS) (mirrors test-interp), not the archive.
+EMU_RV64_UNIT_TEST_BIN = build/test/emu_rv64_unit_test
+
+test-emu-unit: $(EMU_RV64_UNIT_TEST_BIN)
+ $(EMU_RV64_UNIT_TEST_BIN)
+
+$(EMU_RV64_UNIT_TEST_BIN): test/emu/rv64_vm_unit_test.c $(LIB_OBJS)
+ @mkdir -p $(dir $@)
+ $(CC) $(TEST_HOST_CFLAGS) -Isrc test/emu/rv64_vm_unit_test.c $(LIB_OBJS) -o $@
+
# test-interp: threaded-bytecode interpreter unit smoke test. Builds tiny CG IR
# by hand, runs opt_run_o1_interp + interp_lower + the engine, asserts the
# returned value. Reaches internal opt/interp symbols -> links $(LIB_OBJS) and
@@ -320,6 +338,20 @@ $(INTERP_SMOKE_TEST_BIN): test/interp/interp_smoke_test.c $(LIB_OBJS)
@mkdir -p $(dir $@)
$(CC) $(TEST_HOST_CFLAGS) -Isrc test/interp/interp_smoke_test.c $(LIB_OBJS) -o $@
+# test-interp-emu: differential test of the emulator's INTERP execution mode
+# (doc/INTERPRETER.md Phase 4). Builds a tiny rv64 ELF with SD/LD/ecall and runs
+# it through cfree_emu_run in JIT and INTERP modes (both -O1), asserting the exit
+# codes match. Public-API only, but links $(LIB_OBJS) like test-interp to dodge
+# the visibility-hidden archive's localized internals.
+INTERP_EMU_TEST_BIN = build/test/rv64_interp_smoke_test
+
+test-interp-emu: $(INTERP_EMU_TEST_BIN)
+ $(INTERP_EMU_TEST_BIN)
+
+$(INTERP_EMU_TEST_BIN): test/emu/rv64_interp_smoke_test.c $(LIB_OBJS)
+ @mkdir -p $(dir $@)
+ $(CC) $(TEST_HOST_CFLAGS) -Isrc test/emu/rv64_interp_smoke_test.c $(LIB_OBJS) -o $@
+
# test-interp-toy: run the toy suite's interpreter (--no-jit) path only,
# asserting it matches the golden exit codes (and SKIPping unimplemented ops).
test-interp-toy: bin