synth_core/backend.rs
1//! Backend trait and registry for multi-backend compilation
2//!
3//! Every compiler backend (ARM, aWsm, wasker, w2c2) implements the `Backend`
4//! trait, allowing the CLI and verification framework to treat them uniformly.
5
6use crate::target::TargetSpec;
7use crate::wasm_decoder::DecodedModule;
8use crate::wasm_op::WasmOp;
9use crate::wsc_facts::WscFact;
10use std::collections::HashMap;
11use thiserror::Error;
12
13/// Errors from backend compilation
14#[derive(Debug, Error)]
15pub enum BackendError {
16 #[error("compilation failed: {0}")]
17 CompilationFailed(String),
18
19 #[error("backend not available: {0}")]
20 NotAvailable(String),
21
22 #[error("unsupported configuration: {0}")]
23 UnsupportedConfig(String),
24
25 #[error("external tool error: {0}")]
26 ExternalToolError(String),
27}
28
29/// Memory-bounds safety strategy. Phase 1 of `docs/binary-safety-design.md` §3.1.
30///
31/// - `Mpu`/PMP: rely on hardware (ARM MPU or RV32 PMP) — no inline check.
32/// - `Software`: emit a `CMP/BHS Trap_Handler` (ARM) or `bgeu addr, mem_size, ebreak` (RV32)
33/// before every load/store.
34/// - `Mask`: emit `AND addr, addr, #(mem_size - 1)` — only valid when memory size
35/// is a power of two. Wraps on OOB rather than trapping (fuzz-profile semantics).
36/// - `None`: no bounds enforcement.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
38pub enum SafetyBounds {
39 /// No bounds check (caller assumes the WASM module is trusted)
40 #[default]
41 None,
42 /// ARM MPU / RV32 PMP — hardware enforcement, no inline guard
43 Mpu,
44 /// Software CMP/BHS (ARM) or BGEU+EBREAK (RV32) per access
45 Software,
46 /// AND-mask, requires power-of-two memory size
47 Mask,
48}
49
50impl SafetyBounds {
51 /// Parse the `--safety-bounds` argument value.
52 pub fn parse(s: &str) -> std::result::Result<Self, String> {
53 match s {
54 "none" => Ok(SafetyBounds::None),
55 "mpu" | "pmp" => Ok(SafetyBounds::Mpu),
56 "software" | "soft" => Ok(SafetyBounds::Software),
57 "mask" | "masking" => Ok(SafetyBounds::Mask),
58 other => Err(format!(
59 "unknown --safety-bounds value '{}'; expected one of: none, mpu, software, mask",
60 other
61 )),
62 }
63 }
64
65 /// String form used in the safety manifest.
66 pub fn as_str(self) -> &'static str {
67 match self {
68 SafetyBounds::None => "none",
69 SafetyBounds::Mpu => "mpu",
70 SafetyBounds::Software => "software",
71 SafetyBounds::Mask => "mask",
72 }
73 }
74}
75
76/// The absolute SRAM address the OPTIMIZED (non-relocatable) ARM path
77/// materializes as its linear-memory base (`MOVW/MOVT R12, #base` before each
78/// const-address access, and the #468 base-CSE R11 hoist). Historical value:
79/// 256 bytes above the SRAM start — the differential-harness contract for
80/// optimized-path fixtures maps linmem here. `CompileConfig::linmem_base`
81/// defaults to this; `--stack-layout=low` (#687) shifts it up by the reserved
82/// stack size so the moved layout reaches user code, not just the startup.
83pub const OPTIMIZED_LINMEM_BASE: u32 = 0x2000_0100;
84
85/// Configuration for a compilation run
86#[derive(Debug, Clone)]
87pub struct CompileConfig {
88 /// Optimization level (0 = none, 1 = fast, 2 = default, 3 = aggressive)
89 pub opt_level: u8,
90 /// Target specification
91 pub target: TargetSpec,
92 /// Legacy: enable software bounds checking for memory operations.
93 /// Deprecated in favor of `safety_bounds`. When set, equivalent to
94 /// `SafetyBounds::Software`. Kept for backwards compatibility with
95 /// callers that haven't migrated yet.
96 pub bounds_check: bool,
97 /// Phase-1 unified safety-bounds knob. If `bounds_check` is `true` and
98 /// this is `None`, the legacy field wins (back-compat). If both are set,
99 /// `safety_bounds` wins.
100 pub safety_bounds: SafetyBounds,
101 /// Hardware profile name (e.g. "nrf52840", "stm32f407")
102 pub hardware: String,
103 /// Skip optimization passes (direct instruction selection)
104 pub no_optimize: bool,
105 /// Use Loom-compatible optimization preset
106 pub loom_compat: bool,
107 /// Number of imported functions (calls to indices below this use Meld dispatch)
108 pub num_imports: u32,
109 /// AAPCS integer-argument count per function, indexed by full WASM function
110 /// index (imports first, then locals). Lets `Call` marshal the right number
111 /// of operand-stack values into R0–R3 (issue #195). Empty = pass no args
112 /// (pre-#195 behaviour).
113 pub func_arg_counts: Vec<u32>,
114 /// AAPCS integer-argument count per function type, indexed by type index.
115 /// Used by `call_indirect` (issue #195).
116 pub type_arg_counts: Vec<u32>,
117 /// Produce relocatable (ET_REL) host-link output. When set, the backend
118 /// uses the direct instruction selector (`select_with_stack`) rather than
119 /// the optimized path: the optimizer materializes an *absolute* linear-
120 /// memory base (0x20000100) and does not preserve caller-saved registers
121 /// across calls, both wrong for a host-linked object where the linmem base
122 /// is supplied via `fp` at runtime and callees follow AAPCS. Imports are
123 /// also emitted as direct `func_N` BLs (resolved to the wasm field name)
124 /// instead of `__meld_dispatch_import`. (#197 — follow-up to #188/#171.)
125 pub relocatable: bool,
126
127 /// #687 (`--stack-layout=low`): the absolute linear-memory base the
128 /// OPTIMIZED ARM path materializes into user code. Defaults to
129 /// [`OPTIMIZED_LINMEM_BASE`] (`0x2000_0100`, byte-identical to every
130 /// pre-#687 compile). Under the low stack layout the CLI shifts it up by
131 /// the reserved stack size so const-address loads/stores land in the moved
132 /// linear memory instead of the stack region. Only the optimized
133 /// (non-relocatable) path consumes it — the direct selector is R11/fp
134 /// - relative and follows the startup's R11 init instead.
135 pub linmem_base: u32,
136
137 /// #237: emit wasm function-static data as a base-independent `.data`
138 /// section (`__synth_wasm_data`) addressed via MOVW/MOVT symbol relocations,
139 /// so a host-pointer drop-in (linmem base = 0 for native `*ptr` derefs)
140 /// doesn't mis-resolve the statics. Off by default — only the leaves'
141 /// base-relative `[R11+const]` path is used unless explicitly requested.
142 pub native_pointer_abi: bool,
143
144 /// #237: wasm linear-memory minimum size in bytes — the full static-data
145 /// extent (initialized `(data)` segments plus the zero-init/BSS region).
146 /// Under `native_pointer_abi`, a const memory address below this is a wasm
147 /// static → symbol-relative; any address beyond it is a runtime host pointer
148 /// → `[R11=0 + addr]`.
149 pub linear_memory_bytes: u32,
150
151 /// #237: the wasm stack-pointer global as `(index, init_value)`, if the
152 /// module has one. Under `native_pointer_abi` the backend register-promotes
153 /// it: `global.get` materializes `__synth_wasm_data + init` (the real stack
154 /// top) and the init value doubles as the static-data base that separates
155 /// pointer consts (`>= init`) from frame-size scalars (`< init`).
156 pub stack_pointer_global: Option<(u32, i32)>,
157 /// #311: per-function (full index) / per-type "returns i64" — the call
158 /// lowering must tag i64 results as a register pair or the hi half is
159 /// invisible to liveness.
160 pub func_ret_i64: Vec<bool>,
161 pub type_ret_i64: Vec<bool>,
162 /// #643: byte width of each defined global's storage slot, indexed by
163 /// global index — 4 for i32/f32, 8 for i64/f64, 16 for v128 (from the
164 /// module's global section). The globals table is laid out by SUMMING
165 /// these widths: an i64 global needs a register-PAIR store/load at
166 /// `[R9, off]`/`[R9, off+4]`, and every later global's offset shifts.
167 /// Empty ⇒ every global assumed 4 bytes (the legacy `idx * 4` layout;
168 /// hand-built op streams and i32-only modules are byte-identical).
169 pub global_widths: Vec<u32>,
170 /// #359: declared parameter widths per *function* (full index, imports
171 /// first): `func_params_i64[f][k]` is true when param `k` of function `f` is
172 /// i64/f64. The AAPCS stack-argument path needs the *declared* widths
173 /// (op-stream inference can't see an unused i64 param that still shifts the
174 /// incoming-stack layout). The source of truth — a per-function driver loop
175 /// (`compile_module` / the CLI loop) indexes it by `func.index` and copies
176 /// the slice into [`current_func_params_i64`] before each `compile_function`.
177 /// Empty → every param assumed i32 (the legacy path; keeps every function
178 /// with <=4 params, or all-i32 params, byte-identical).
179 pub func_params_i64: Vec<Vec<bool>>,
180 /// #359: declared parameter widths of the function CURRENTLY being compiled
181 /// — `current_func_params_i64[k]` is true when param `k` is i64/f64. Set per
182 /// function (a cheap clone of the config) from [`func_params_i64`] by the
183 /// driver loop, because `compile_function` is shared across backends and
184 /// carries no function index. Empty → assume i32.
185 pub current_func_params_i64: Vec<bool>,
186 /// GI-FPU-002 (#619/#369): per-function declared f32-param mask (full index,
187 /// imports first). The driver copies `func_params_f32[f]` into
188 /// [`current_func_params_f32`] before each `compile_function`. Empty ⇒
189 /// all-non-f32 (byte-identical to before).
190 pub func_params_f32: Vec<Vec<bool>>,
191 /// GI-FPU-002: declared f32-param mask of the function CURRENTLY being
192 /// compiled — `current_func_params_f32[k]` is true when param `k` is f32.
193 /// Set per function from [`func_params_f32`], mirroring
194 /// [`current_func_params_i64`]. Empty ⇒ no f32 params.
195 pub current_func_params_f32: Vec<bool>,
196 /// GI-FPU-002 phase 2 (#369): per-function declared f64-param mask (full
197 /// index, imports first) and the CURRENT function's slice. Hard-float
198 /// targets decline f64-param functions loudly — the legacy width
199 /// inference treats an f64 param as an i64 CORE-register pair, which
200 /// reads the wrong registers under AAPCS-VFP (the caller put it in a
201 /// D-register). Empty ⇒ no f64 params (byte-identical legacy path).
202 pub func_params_f64: Vec<Vec<bool>>,
203 /// See [`func_params_f64`](Self::func_params_f64).
204 pub current_func_params_f64: Vec<bool>,
205 /// GI-FPU-002 phase 2 (#719/#369): whether the function CURRENTLY being
206 /// compiled returns f32. Set per function from the decoder's `func_ret_f32`.
207 /// The direct selector's epilogue uses it to loudly decline a result that
208 /// reaches the return in a core register instead of an S-register (a call
209 /// that returned f32 as integer-tagged R0 would otherwise be a silent
210 /// miscompile — the AAPCS-VFP caller reads S0). `false` for hand-built op
211 /// streams / non-f32 returns (byte-identical to before).
212 pub current_func_ret_f32: bool,
213 /// GI-FPU-002 phase 2 (#719/#369): whether the function CURRENTLY being
214 /// compiled returns f64 (D0 under AAPCS-VFP). Same epilogue-soundness role.
215 pub current_func_ret_f64: bool,
216 /// GI-FPU-002 phase 2 (#719/#369): per-function (full index, imports first)
217 /// "returns f32/f64" tables. The direct selector declines a `call` to an
218 /// f32/f64-returning callee LOUDLY at the call site — the result arrives in
219 /// S0/D0 (AAPCS-VFP), which this increment does not marshal into the operand
220 /// stack; tagging it as an integer R0 would be a silent miscompile. Also the
221 /// source for [`current_func_ret_f32`]/[`current_func_ret_f64`] in the
222 /// per-function driver loops. Empty ⇒ callees assumed non-float-returning
223 /// (hand-built op streams; byte-identical legacy behaviour).
224 pub func_ret_f32: Vec<bool>,
225 /// See [`func_ret_f32`](Self::func_ret_f32).
226 pub func_ret_f64: Vec<bool>,
227 /// GI-FPU-002 phase 2 (#719/#369): per-type "returns f32/f64" — the
228 /// `call_indirect` analogue of [`func_ret_f32`](Self::func_ret_f32).
229 pub type_ret_f32: Vec<bool>,
230 /// See [`type_ret_f32`](Self::type_ret_f32).
231 pub type_ret_f64: Vec<bool>,
232 /// #457: DECLARED parameter count of the function CURRENTLY being compiled,
233 /// from the module's type section (`func_arg_counts[func.index]`). Set per
234 /// function by the driver loops like [`current_func_params_i64`].
235 ///
236 /// The backends otherwise INFER the param count from local-access patterns
237 /// (`count_params`: a local whose first access is a read is assumed to be a
238 /// param) — which cannot distinguish a param from a read-before-write
239 /// non-param local. WASM zero-initializes non-param locals, so such a local
240 /// must read 0; the inference instead homed it in a parameter register and
241 /// read caller garbage (#457). The backends cap the inferred count at this
242 /// declared count when it is present, which reclassifies exactly the
243 /// read-before-write locals (an inferred count can only exceed the declared
244 /// one via a read-first index >= the declared count) and leaves every other
245 /// function's codegen byte-identical.
246 ///
247 /// `None` → declared signature unknown (hand-built op streams, direct
248 /// `compile_function` callers) → pure inference, the legacy behaviour.
249 pub current_func_param_count: Option<u32>,
250 /// #509: blocktype-arity side-table of the function CURRENTLY being compiled
251 /// — `(param_count, result_count)` of the k-th `Block`/`Loop`/`If` in its op
252 /// stream (ordinal-keyed; see [`FunctionOps::block_arity`]). Set per function
253 /// by the driver loop (like [`current_func_params_i64`]). The direct selector
254 /// uses it to land a value carried by `br`/`br_if`/`br_table` in the target
255 /// block's designated result register instead of dropping it. Empty → every
256 /// block treated as void (the legacy lowering; hand-built op streams).
257 ///
258 /// [`FunctionOps::block_arity`]: crate::wasm_decoder::FunctionOps::block_arity
259 pub current_func_block_arity: Vec<(u8, u8)>,
260
261 /// #543 Phase 1 — integrator-marked volatile linear-memory segments (the DMA
262 /// transfer window). Each range `[base, base+len)` names a region of the fused
263 /// linear memory that an EXTERNAL agent (the DMA engine, modelled by gale as a
264 /// Component-Model `own<buffer>` handoff — gale decision `DD-DMA-REGION-001`,
265 /// gale#124) rewrites out-of-band. Loads and stores whose address falls inside
266 /// a marked range must eventually be treated as VOLATILE: not cached, hoisted,
267 /// or reordered across the transfer boundary.
268 ///
269 /// PHASE-2 CONTRACT (implemented — issue #543): the optimizer's
270 /// address-caching passes HONOR these ranges. Consumption points:
271 /// - the #468 base-CSE / const-address-fold
272 /// (`optimizer_bridge::plan_base_cse`, DEFAULT-ON, opt-out
273 /// `SYNTH_BASE_CSE=0`): a const-address access whose 4-byte window
274 /// intersects a marked range is EXCLUDED from the fold set — it keeps
275 /// its verbatim per-access materialize-and-access codegen, while
276 /// accesses outside the range still fold;
277 /// - const-CSE (`liveness::apply_const_cse` wired in `arm_backend.rs`,
278 /// DEFAULT-ON, opt-out `SYNTH_CONST_CSE=0`; the former bridge-level
279 /// inline cache is retired, #242): declines WHOLESALE while any range is
280 /// marked — a cached constant cannot be classified address-vs-data at
281 /// that level, so the conservative stance for statically-unknown
282 /// addressing is to re-materialize every constant at each occurrence.
283 ///
284 /// Passes that only touch SP-relative frame slots (stack-reload forwarding,
285 /// frame-slot DCE, spill re-choice) are unaffected by design: these ranges
286 /// are LINEAR-MEMORY addresses, and frame slots are never linmem. Nothing on
287 /// the pipeline deletes, forwards, or reorders a linear-memory access (IR CSE
288 /// deliberately never CSEs `MemLoad`s; DCE removes only unreachable blocks),
289 /// so every marked access is issued verbatim, in program order.
290 ///
291 /// Empty (the default): zero behavior change by construction — every gate
292 /// reduces to the pre-#543 path, so the emitted `.text` is byte-identical
293 /// with or without this code (the frozen-codegen gate holds). See rivet
294 /// `VCR-DMA-001`.
295 pub volatile_segments: Vec<VolatileRange>,
296
297 /// VCR-PERF-002 Phase 1 (#494) — proven invariants forwarded by loom in
298 /// the `wsc.facts` custom section (encoding:
299 /// `docs/design/wsc-facts-encoding.md`; program:
300 /// `docs/design/proof-carrying-specialization.md`), whole-module table
301 /// keyed by `(func_index, value_id)`. The compile driver copies the
302 /// current function's slice into [`current_func_facts`] (the
303 /// `func_params_i64` → `current_func_params_i64` pattern), because
304 /// `compile_function` carries no function index.
305 ///
306 /// PHASE-1 CONTRACT: threaded but NOT consumed — no codegen path reads
307 /// facts, so emitted bytes are unchanged whether or not the module
308 /// carries the section (locked by `wsc_facts_ingestion_494.rs`). Phase 2
309 /// turns each fact into a premise for a flag-gated (`SYNTH_FACT_SPEC`),
310 /// per-elision ordeal-validated specialization; the facts-absent compile
311 /// stays byte-identical by construction (empty ⇒ every gate vacuous).
312 ///
313 /// [`current_func_facts`]: CompileConfig::current_func_facts
314 pub wsc_facts: Vec<WscFact>,
315 /// VCR-PERF-002 Phase 1 (#494): the `wsc.facts` invariants of the function
316 /// CURRENTLY being compiled (`fact.func_index == func.index`), set per
317 /// function by the driver loops like [`current_func_params_i64`]. This is
318 /// the field a Phase-2 selector pass will read its premises from. Empty →
319 /// no facts → no specialization may ever fire (the fail-safe default).
320 ///
321 /// [`current_func_params_i64`]: CompileConfig::current_func_params_i64
322 pub current_func_facts: Vec<WscFact>,
323 /// VCR-PERF-002 Phase 2b (#494, divisor-nonzero): op indices (into the op
324 /// stream passed to `compile_function`) of `div`/`rem` ops whose
325 /// DIVIDE-BY-ZERO trap guard is proven dead — the fact-spec pass
326 /// discharged `UNSAT(P ∧ divisor == 0)` per site through the
327 /// certificate-checked ordeal solver BEFORE the driver set this field.
328 /// Consumed by the ARM direct selector (`select_with_stack`); every other
329 /// path ignores it (guards stay — sound). Empty (the default) ⇒ every
330 /// guard is emitted, byte-identical to today.
331 pub fact_div_zero_elide: Vec<usize>,
332 /// VCR-PERF-002 Phase 2b (#494): op indices of `div_s` ops whose
333 /// `INT_MIN / -1` OVERFLOW trap guard is proven dead — a SEPARATE
334 /// obligation (`UNSAT(P ∧ dividend == INT_MIN ∧ divisor == -1)`). A
335 /// divisor-nonzero fact alone NEVER lands here: divisor ≠ 0 does not
336 /// exclude -1 (#633/#634 two-guard distinction). Empty ⇒ guard emitted.
337 pub fact_div_ovf_elide: Vec<usize>,
338 /// #642: `call_indirect` guard inputs — the compile-time table size for
339 /// the runtime bounds check and the per-expected-type closed-world type
340 /// verdicts — computed from the decoded module by
341 /// [`crate::wasm_decoder::DecodedModule::call_indirect_guards`] and set by
342 /// the driver loops. The default (`table_size: None`, empty verdicts)
343 /// DECLINES every `call_indirect` lowering: an unchecked indirect branch
344 /// is never emitted (WASM Core §4.4.8 requires OOB/type-mismatch traps).
345 pub call_indirect_guards: crate::wasm_decoder::CallIndirectGuards,
346}
347
348/// #543 — an integrator-marked volatile linear-memory segment (the DMA transfer
349/// window): the half-open byte range `[base, base + len)` of the fused linear
350/// memory that an external agent rewrites out-of-band. Parsed from the CLI
351/// `--volatile-segment <base>:<len>` flag. See [`CompileConfig::volatile_segments`]
352/// for the Phase-1/Phase-2 split.
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
354pub struct VolatileRange {
355 /// Start address of the volatile region, in linear-memory bytes.
356 pub base: u32,
357 /// Length of the volatile region, in bytes. The region is `[base, base+len)`.
358 pub len: u32,
359}
360
361impl CompileConfig {
362 /// Resolve the effective safety-bounds setting, honouring the legacy
363 /// `bounds_check` field as a fallback. Used by backends to pick the
364 /// inline-check shape.
365 pub fn effective_safety_bounds(&self) -> SafetyBounds {
366 match (self.safety_bounds, self.bounds_check) {
367 (SafetyBounds::None, true) => SafetyBounds::Software,
368 (s, _) => s,
369 }
370 }
371}
372
373impl Default for CompileConfig {
374 fn default() -> Self {
375 Self {
376 opt_level: 2,
377 target: TargetSpec::cortex_m4(),
378 bounds_check: false,
379 safety_bounds: SafetyBounds::None,
380 hardware: String::new(),
381 no_optimize: false,
382 loom_compat: false,
383 num_imports: 0,
384 func_arg_counts: Vec::new(),
385 type_arg_counts: Vec::new(),
386 relocatable: false,
387 // #687: the historical optimized-path absolute base — every
388 // default compile stays byte-identical.
389 linmem_base: OPTIMIZED_LINMEM_BASE,
390 native_pointer_abi: false,
391 linear_memory_bytes: 0,
392 stack_pointer_global: None,
393 func_ret_i64: Vec::new(),
394 type_ret_i64: Vec::new(),
395 // #643: empty ⇒ legacy all-4-byte global slots (i32-only modules).
396 global_widths: Vec::new(),
397 func_params_i64: Vec::new(),
398 current_func_params_i64: Vec::new(),
399 func_params_f32: Vec::new(),
400 current_func_params_f32: Vec::new(),
401 // GI-FPU-002 phase 2 (#719/#369): false ⇒ non-float return (or a
402 // hand-built op stream); driver loops set it per function.
403 current_func_ret_f32: false,
404 current_func_ret_f64: false,
405 // GI-FPU-002 phase 2 (#719/#369): empty ⇒ callees assumed
406 // non-float-returning (hand-built op streams).
407 func_params_f64: Vec::new(),
408 current_func_params_f64: Vec::new(),
409 func_ret_f32: Vec::new(),
410 func_ret_f64: Vec::new(),
411 type_ret_f32: Vec::new(),
412 type_ret_f64: Vec::new(),
413 // #457: None ⇒ declared signature unknown ⇒ param-count inference
414 // only (unit tests / hand-built op streams); driver loops fill it.
415 current_func_param_count: None,
416 // #509: empty ⇒ legacy void-block lowering (unit tests / hand-built
417 // op streams); the driver loops fill it per function.
418 current_func_block_arity: Vec::new(),
419 // #543 Phase 1: no volatile segments unless the CLI flag names them.
420 // Empty ⇒ inert ⇒ emitted bytes unchanged.
421 volatile_segments: Vec::new(),
422 // VCR-PERF-002 Phase 1 (#494): no facts unless the module carries
423 // a parseable `wsc.facts` section. Empty ⇒ inert (and Phase 1 has
424 // no consumer anyway) ⇒ emitted bytes unchanged.
425 wsc_facts: Vec::new(),
426 current_func_facts: Vec::new(),
427 // VCR-PERF-002 Phase 2b (#494): no guard-elision marks unless the
428 // fact-spec pass discharged the per-site obligations. Empty ⇒
429 // every div/rem trap guard is emitted, byte-identical.
430 fact_div_zero_elide: Vec::new(),
431 fact_div_ovf_elide: Vec::new(),
432 // #642: no guard inputs ⇒ every call_indirect lowering declines
433 // loudly (never an unchecked indirect branch). Driver loops fill
434 // this from the decoded module.
435 call_indirect_guards: crate::wasm_decoder::CallIndirectGuards::default(),
436 }
437 }
438}
439
440/// A relocation entry produced during compilation
441///
442/// Records that a BL instruction at `offset` bytes into the function's code
443/// targets an external symbol (e.g., `__meld_dispatch_import`). The linker
444/// resolves these when combining the Synth object with the Kiln bridge.
445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
446pub enum RelocKind {
447 /// R_ARM_THM_CALL — a Thumb BL call site (the default; #167).
448 ThmCall,
449 /// R_ARM_MOVW_ABS_NC — the MOVW half of a symbol-relative address (#237).
450 MovwAbs,
451 /// R_ARM_MOVT_ABS — the MOVT half of a symbol-relative address (#237).
452 MovtAbs,
453 /// R_ARM_ABS32 — a 32-bit absolute address held in a `.text` literal-pool
454 /// word, loaded via `LDR rX, [pc, #off]` (#345). The link-survivable
455 /// replacement for the inline-immediate MOVW/MOVT-ABS pair: `ld`/bfd patches
456 /// the data word at link time (`S + A`, the addend living in the word, REL
457 /// semantics), which survives placement into a large multi-object image —
458 /// whereas an inline-instruction MOVW_ABS immediate can be mangled.
459 Abs32,
460}
461
462#[derive(Debug, Clone, PartialEq, Eq)]
463pub struct CodeRelocation {
464 /// Byte offset within the function's machine code where the reloc applies
465 pub offset: u32,
466 /// Target symbol name (e.g., "__meld_dispatch_import", "__synth_wasm_data")
467 pub symbol: String,
468 /// Which ARM relocation type to emit for this site.
469 pub kind: RelocKind,
470}
471
472/// VCR-DBG-001: a per-instruction source map — `(machine_offset_within_code,
473/// wasm_op_index)` pairs, one per emitted machine instruction. A `None` op-index
474/// marks an instruction with no originating wasm op (prologue/epilogue, literal
475/// pool). Consumed by the DWARF `.debug_line` emitter; empty when no source map
476/// was produced.
477pub type LineMap = Vec<(u32, Option<usize>)>;
478
479/// A single compiled function
480#[derive(Debug, Clone)]
481pub struct CompiledFunction {
482 /// Function name (from WASM export or generated)
483 pub name: String,
484 /// Raw machine code bytes
485 pub code: Vec<u8>,
486 /// Original WASM ops (retained for verification)
487 pub wasm_ops: Vec<WasmOp>,
488 /// Relocations for external symbol references (BL to bridge functions)
489 pub relocations: Vec<CodeRelocation>,
490 /// VCR-DBG-001: per-instruction source map for DWARF `.debug_line` emission —
491 /// `(machine_offset_within_code, wasm_op_index)` captured at encode time, one
492 /// entry per emitted machine instruction. A `None` op-index marks an
493 /// instruction with no originating wasm op (prologue/epilogue, literal-pool
494 /// word). This is purely additive metadata: it is never serialized unless
495 /// `.debug_line` emission is requested, so the emitted `.text` is
496 /// byte-identical with or without it. Empty for backends/paths that do not
497 /// yet produce a source map (RISC-V, the optimized ARM path).
498 pub line_map: LineMap,
499}
500
501/// Result of compiling a full module
502#[derive(Debug)]
503pub struct CompilationResult {
504 /// Compiled functions
505 pub functions: Vec<CompiledFunction>,
506 /// Complete ELF binary (if backend produces one directly)
507 pub elf: Option<Vec<u8>>,
508 /// Name of the backend that produced this result
509 pub backend_name: String,
510}
511
512/// What a backend can and cannot do
513#[derive(Debug, Clone)]
514pub struct BackendCapabilities {
515 /// Backend produces complete ELF files (external backends like aWsm)
516 pub produces_elf: bool,
517 /// Backend supports per-rule verification (only our custom ARM backend)
518 pub supports_rule_verification: bool,
519 /// Backend supports binary-level verification (all backends via disassembly)
520 pub supports_binary_verification: bool,
521 /// Backend is an external tool (not a library)
522 pub is_external: bool,
523}
524
525/// Trait that every compilation backend implements
526pub trait Backend: Send + Sync {
527 /// Human-readable backend name
528 fn name(&self) -> &str;
529
530 /// What this backend can do
531 fn capabilities(&self) -> BackendCapabilities;
532
533 /// Which targets this backend supports
534 fn supported_targets(&self) -> Vec<TargetSpec>;
535
536 /// Compile an entire decoded WASM module
537 fn compile_module(
538 &self,
539 module: &DecodedModule,
540 config: &CompileConfig,
541 ) -> std::result::Result<CompilationResult, BackendError>;
542
543 /// Compile a single function from WASM ops to machine code
544 fn compile_function(
545 &self,
546 name: &str,
547 ops: &[WasmOp],
548 config: &CompileConfig,
549 ) -> std::result::Result<CompiledFunction, BackendError>;
550
551 /// Check if this backend is available (external tools installed, etc.)
552 fn is_available(&self) -> bool;
553}
554
555/// Registry of available backends
556pub struct BackendRegistry {
557 backends: HashMap<String, Box<dyn Backend>>,
558}
559
560impl BackendRegistry {
561 pub fn new() -> Self {
562 Self {
563 backends: HashMap::new(),
564 }
565 }
566
567 /// Register a backend under its name
568 pub fn register(&mut self, backend: Box<dyn Backend>) {
569 let name = backend.name().to_string();
570 self.backends.insert(name, backend);
571 }
572
573 /// Get a backend by name
574 pub fn get(&self, name: &str) -> Option<&dyn Backend> {
575 self.backends.get(name).map(|b| b.as_ref())
576 }
577
578 /// List all registered backends
579 pub fn list(&self) -> Vec<&dyn Backend> {
580 self.backends.values().map(|b| b.as_ref()).collect()
581 }
582
583 /// List backends that are actually available (installed and working)
584 pub fn available(&self) -> Vec<&dyn Backend> {
585 self.backends
586 .values()
587 .filter(|b| b.is_available())
588 .map(|b| b.as_ref())
589 .collect()
590 }
591}
592
593impl Default for BackendRegistry {
594 fn default() -> Self {
595 Self::new()
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 #[test]
604 fn test_registry_empty() {
605 let reg = BackendRegistry::new();
606 assert!(reg.list().is_empty());
607 assert!(reg.available().is_empty());
608 assert!(reg.get("arm").is_none());
609 }
610
611 #[test]
612 fn test_compile_config_default() {
613 let config = CompileConfig::default();
614 assert_eq!(config.opt_level, 2);
615 assert!(!config.bounds_check);
616 assert_eq!(config.safety_bounds, SafetyBounds::None);
617 assert!(!config.no_optimize);
618 }
619
620 #[test]
621 fn safety_bounds_parse_round_trip() {
622 for s in ["none", "mpu", "software", "mask"] {
623 let sb = SafetyBounds::parse(s).unwrap();
624 assert_eq!(sb.as_str(), s);
625 }
626 assert_eq!(SafetyBounds::parse("pmp").unwrap(), SafetyBounds::Mpu);
627 assert_eq!(SafetyBounds::parse("soft").unwrap(), SafetyBounds::Software);
628 assert!(SafetyBounds::parse("nonsense").is_err());
629 }
630
631 #[test]
632 fn effective_safety_bounds_legacy_promotes_to_software() {
633 let cfg = CompileConfig {
634 bounds_check: true,
635 ..Default::default()
636 };
637 assert_eq!(cfg.effective_safety_bounds(), SafetyBounds::Software);
638 }
639
640 #[test]
641 fn effective_safety_bounds_new_field_wins() {
642 let cfg = CompileConfig {
643 bounds_check: true,
644 safety_bounds: SafetyBounds::Mpu,
645 ..Default::default()
646 };
647 assert_eq!(cfg.effective_safety_bounds(), SafetyBounds::Mpu);
648 }
649}