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/// Configuration for a compilation run
77#[derive(Debug, Clone)]
78pub struct CompileConfig {
79 /// Optimization level (0 = none, 1 = fast, 2 = default, 3 = aggressive)
80 pub opt_level: u8,
81 /// Target specification
82 pub target: TargetSpec,
83 /// Legacy: enable software bounds checking for memory operations.
84 /// Deprecated in favor of `safety_bounds`. When set, equivalent to
85 /// `SafetyBounds::Software`. Kept for backwards compatibility with
86 /// callers that haven't migrated yet.
87 pub bounds_check: bool,
88 /// Phase-1 unified safety-bounds knob. If `bounds_check` is `true` and
89 /// this is `None`, the legacy field wins (back-compat). If both are set,
90 /// `safety_bounds` wins.
91 pub safety_bounds: SafetyBounds,
92 /// Hardware profile name (e.g. "nrf52840", "stm32f407")
93 pub hardware: String,
94 /// Skip optimization passes (direct instruction selection)
95 pub no_optimize: bool,
96 /// Use Loom-compatible optimization preset
97 pub loom_compat: bool,
98 /// Number of imported functions (calls to indices below this use Meld dispatch)
99 pub num_imports: u32,
100 /// AAPCS integer-argument count per function, indexed by full WASM function
101 /// index (imports first, then locals). Lets `Call` marshal the right number
102 /// of operand-stack values into R0–R3 (issue #195). Empty = pass no args
103 /// (pre-#195 behaviour).
104 pub func_arg_counts: Vec<u32>,
105 /// AAPCS integer-argument count per function type, indexed by type index.
106 /// Used by `call_indirect` (issue #195).
107 pub type_arg_counts: Vec<u32>,
108 /// Produce relocatable (ET_REL) host-link output. When set, the backend
109 /// uses the direct instruction selector (`select_with_stack`) rather than
110 /// the optimized path: the optimizer materializes an *absolute* linear-
111 /// memory base (0x20000100) and does not preserve caller-saved registers
112 /// across calls, both wrong for a host-linked object where the linmem base
113 /// is supplied via `fp` at runtime and callees follow AAPCS. Imports are
114 /// also emitted as direct `func_N` BLs (resolved to the wasm field name)
115 /// instead of `__meld_dispatch_import`. (#197 — follow-up to #188/#171.)
116 pub relocatable: bool,
117
118 /// #237: emit wasm function-static data as a base-independent `.data`
119 /// section (`__synth_wasm_data`) addressed via MOVW/MOVT symbol relocations,
120 /// so a host-pointer drop-in (linmem base = 0 for native `*ptr` derefs)
121 /// doesn't mis-resolve the statics. Off by default — only the leaves'
122 /// base-relative `[R11+const]` path is used unless explicitly requested.
123 pub native_pointer_abi: bool,
124
125 /// #237: wasm linear-memory minimum size in bytes — the full static-data
126 /// extent (initialized `(data)` segments plus the zero-init/BSS region).
127 /// Under `native_pointer_abi`, a const memory address below this is a wasm
128 /// static → symbol-relative; any address beyond it is a runtime host pointer
129 /// → `[R11=0 + addr]`.
130 pub linear_memory_bytes: u32,
131
132 /// #237: the wasm stack-pointer global as `(index, init_value)`, if the
133 /// module has one. Under `native_pointer_abi` the backend register-promotes
134 /// it: `global.get` materializes `__synth_wasm_data + init` (the real stack
135 /// top) and the init value doubles as the static-data base that separates
136 /// pointer consts (`>= init`) from frame-size scalars (`< init`).
137 pub stack_pointer_global: Option<(u32, i32)>,
138 /// #311: per-function (full index) / per-type "returns i64" — the call
139 /// lowering must tag i64 results as a register pair or the hi half is
140 /// invisible to liveness.
141 pub func_ret_i64: Vec<bool>,
142 pub type_ret_i64: Vec<bool>,
143 /// #359: declared parameter widths per *function* (full index, imports
144 /// first): `func_params_i64[f][k]` is true when param `k` of function `f` is
145 /// i64/f64. The AAPCS stack-argument path needs the *declared* widths
146 /// (op-stream inference can't see an unused i64 param that still shifts the
147 /// incoming-stack layout). The source of truth — a per-function driver loop
148 /// (`compile_module` / the CLI loop) indexes it by `func.index` and copies
149 /// the slice into [`current_func_params_i64`] before each `compile_function`.
150 /// Empty → every param assumed i32 (the legacy path; keeps every function
151 /// with <=4 params, or all-i32 params, byte-identical).
152 pub func_params_i64: Vec<Vec<bool>>,
153 /// #359: declared parameter widths of the function CURRENTLY being compiled
154 /// — `current_func_params_i64[k]` is true when param `k` is i64/f64. Set per
155 /// function (a cheap clone of the config) from [`func_params_i64`] by the
156 /// driver loop, because `compile_function` is shared across backends and
157 /// carries no function index. Empty → assume i32.
158 pub current_func_params_i64: Vec<bool>,
159 /// #509: blocktype-arity side-table of the function CURRENTLY being compiled
160 /// — `(param_count, result_count)` of the k-th `Block`/`Loop`/`If` in its op
161 /// stream (ordinal-keyed; see [`FunctionOps::block_arity`]). Set per function
162 /// by the driver loop (like [`current_func_params_i64`]). The direct selector
163 /// uses it to land a value carried by `br`/`br_if`/`br_table` in the target
164 /// block's designated result register instead of dropping it. Empty → every
165 /// block treated as void (the legacy lowering; hand-built op streams).
166 ///
167 /// [`FunctionOps::block_arity`]: crate::wasm_decoder::FunctionOps::block_arity
168 pub current_func_block_arity: Vec<(u8, u8)>,
169
170 /// #543 Phase 1 — integrator-marked volatile linear-memory segments (the DMA
171 /// transfer window). Each range `[base, base+len)` names a region of the fused
172 /// linear memory that an EXTERNAL agent (the DMA engine, modelled by gale as a
173 /// Component-Model `own<buffer>` handoff — gale decision `DD-DMA-REGION-001`,
174 /// gale#124) rewrites out-of-band. Loads and stores whose address falls inside
175 /// a marked range must eventually be treated as VOLATILE: not cached, hoisted,
176 /// or reordered across the transfer boundary.
177 ///
178 /// PHASE-2 CONTRACT (implemented — issue #543): the optimizer's
179 /// address-caching passes HONOR these ranges. Consumption points:
180 /// - the #468 base-CSE / const-address-fold
181 /// (`optimizer_bridge::plan_base_cse`, DEFAULT-ON, opt-out
182 /// `SYNTH_BASE_CSE=0`): a const-address access whose 4-byte window
183 /// intersects a marked range is EXCLUDED from the fold set — it keeps
184 /// its verbatim per-access materialize-and-access codegen, while
185 /// accesses outside the range still fold;
186 /// - const-CSE (`liveness::apply_const_cse` wired in `arm_backend.rs`,
187 /// DEFAULT-ON, opt-out `SYNTH_CONST_CSE=0`; the former bridge-level
188 /// inline cache is retired, #242): declines WHOLESALE while any range is
189 /// marked — a cached constant cannot be classified address-vs-data at
190 /// that level, so the conservative stance for statically-unknown
191 /// addressing is to re-materialize every constant at each occurrence.
192 ///
193 /// Passes that only touch SP-relative frame slots (stack-reload forwarding,
194 /// frame-slot DCE, spill re-choice) are unaffected by design: these ranges
195 /// are LINEAR-MEMORY addresses, and frame slots are never linmem. Nothing on
196 /// the pipeline deletes, forwards, or reorders a linear-memory access (IR CSE
197 /// deliberately never CSEs `MemLoad`s; DCE removes only unreachable blocks),
198 /// so every marked access is issued verbatim, in program order.
199 ///
200 /// Empty (the default): zero behavior change by construction — every gate
201 /// reduces to the pre-#543 path, so the emitted `.text` is byte-identical
202 /// with or without this code (the frozen-codegen gate holds). See rivet
203 /// `VCR-DMA-001`.
204 pub volatile_segments: Vec<VolatileRange>,
205
206 /// VCR-PERF-002 Phase 1 (#494) — proven invariants forwarded by loom in
207 /// the `wsc.facts` custom section (encoding:
208 /// `docs/design/wsc-facts-encoding.md`; program:
209 /// `docs/design/proof-carrying-specialization.md`), whole-module table
210 /// keyed by `(func_index, value_id)`. The compile driver copies the
211 /// current function's slice into [`current_func_facts`] (the
212 /// `func_params_i64` → `current_func_params_i64` pattern), because
213 /// `compile_function` carries no function index.
214 ///
215 /// PHASE-1 CONTRACT: threaded but NOT consumed — no codegen path reads
216 /// facts, so emitted bytes are unchanged whether or not the module
217 /// carries the section (locked by `wsc_facts_ingestion_494.rs`). Phase 2
218 /// turns each fact into a premise for a flag-gated (`SYNTH_FACT_SPEC`),
219 /// per-elision ordeal-validated specialization; the facts-absent compile
220 /// stays byte-identical by construction (empty ⇒ every gate vacuous).
221 ///
222 /// [`current_func_facts`]: CompileConfig::current_func_facts
223 pub wsc_facts: Vec<WscFact>,
224 /// VCR-PERF-002 Phase 1 (#494): the `wsc.facts` invariants of the function
225 /// CURRENTLY being compiled (`fact.func_index == func.index`), set per
226 /// function by the driver loops like [`current_func_params_i64`]. This is
227 /// the field a Phase-2 selector pass will read its premises from. Empty →
228 /// no facts → no specialization may ever fire (the fail-safe default).
229 ///
230 /// [`current_func_params_i64`]: CompileConfig::current_func_params_i64
231 pub current_func_facts: Vec<WscFact>,
232}
233
234/// #543 — an integrator-marked volatile linear-memory segment (the DMA transfer
235/// window): the half-open byte range `[base, base + len)` of the fused linear
236/// memory that an external agent rewrites out-of-band. Parsed from the CLI
237/// `--volatile-segment <base>:<len>` flag. See [`CompileConfig::volatile_segments`]
238/// for the Phase-1/Phase-2 split.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub struct VolatileRange {
241 /// Start address of the volatile region, in linear-memory bytes.
242 pub base: u32,
243 /// Length of the volatile region, in bytes. The region is `[base, base+len)`.
244 pub len: u32,
245}
246
247impl CompileConfig {
248 /// Resolve the effective safety-bounds setting, honouring the legacy
249 /// `bounds_check` field as a fallback. Used by backends to pick the
250 /// inline-check shape.
251 pub fn effective_safety_bounds(&self) -> SafetyBounds {
252 match (self.safety_bounds, self.bounds_check) {
253 (SafetyBounds::None, true) => SafetyBounds::Software,
254 (s, _) => s,
255 }
256 }
257}
258
259impl Default for CompileConfig {
260 fn default() -> Self {
261 Self {
262 opt_level: 2,
263 target: TargetSpec::cortex_m4(),
264 bounds_check: false,
265 safety_bounds: SafetyBounds::None,
266 hardware: String::new(),
267 no_optimize: false,
268 loom_compat: false,
269 num_imports: 0,
270 func_arg_counts: Vec::new(),
271 type_arg_counts: Vec::new(),
272 relocatable: false,
273 native_pointer_abi: false,
274 linear_memory_bytes: 0,
275 stack_pointer_global: None,
276 func_ret_i64: Vec::new(),
277 type_ret_i64: Vec::new(),
278 func_params_i64: Vec::new(),
279 current_func_params_i64: Vec::new(),
280 // #509: empty ⇒ legacy void-block lowering (unit tests / hand-built
281 // op streams); the driver loops fill it per function.
282 current_func_block_arity: Vec::new(),
283 // #543 Phase 1: no volatile segments unless the CLI flag names them.
284 // Empty ⇒ inert ⇒ emitted bytes unchanged.
285 volatile_segments: Vec::new(),
286 // VCR-PERF-002 Phase 1 (#494): no facts unless the module carries
287 // a parseable `wsc.facts` section. Empty ⇒ inert (and Phase 1 has
288 // no consumer anyway) ⇒ emitted bytes unchanged.
289 wsc_facts: Vec::new(),
290 current_func_facts: Vec::new(),
291 }
292 }
293}
294
295/// A relocation entry produced during compilation
296///
297/// Records that a BL instruction at `offset` bytes into the function's code
298/// targets an external symbol (e.g., `__meld_dispatch_import`). The linker
299/// resolves these when combining the Synth object with the Kiln bridge.
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
301pub enum RelocKind {
302 /// R_ARM_THM_CALL — a Thumb BL call site (the default; #167).
303 ThmCall,
304 /// R_ARM_MOVW_ABS_NC — the MOVW half of a symbol-relative address (#237).
305 MovwAbs,
306 /// R_ARM_MOVT_ABS — the MOVT half of a symbol-relative address (#237).
307 MovtAbs,
308 /// R_ARM_ABS32 — a 32-bit absolute address held in a `.text` literal-pool
309 /// word, loaded via `LDR rX, [pc, #off]` (#345). The link-survivable
310 /// replacement for the inline-immediate MOVW/MOVT-ABS pair: `ld`/bfd patches
311 /// the data word at link time (`S + A`, the addend living in the word, REL
312 /// semantics), which survives placement into a large multi-object image —
313 /// whereas an inline-instruction MOVW_ABS immediate can be mangled.
314 Abs32,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct CodeRelocation {
319 /// Byte offset within the function's machine code where the reloc applies
320 pub offset: u32,
321 /// Target symbol name (e.g., "__meld_dispatch_import", "__synth_wasm_data")
322 pub symbol: String,
323 /// Which ARM relocation type to emit for this site.
324 pub kind: RelocKind,
325}
326
327/// VCR-DBG-001: a per-instruction source map — `(machine_offset_within_code,
328/// wasm_op_index)` pairs, one per emitted machine instruction. A `None` op-index
329/// marks an instruction with no originating wasm op (prologue/epilogue, literal
330/// pool). Consumed by the DWARF `.debug_line` emitter; empty when no source map
331/// was produced.
332pub type LineMap = Vec<(u32, Option<usize>)>;
333
334/// A single compiled function
335#[derive(Debug, Clone)]
336pub struct CompiledFunction {
337 /// Function name (from WASM export or generated)
338 pub name: String,
339 /// Raw machine code bytes
340 pub code: Vec<u8>,
341 /// Original WASM ops (retained for verification)
342 pub wasm_ops: Vec<WasmOp>,
343 /// Relocations for external symbol references (BL to bridge functions)
344 pub relocations: Vec<CodeRelocation>,
345 /// VCR-DBG-001: per-instruction source map for DWARF `.debug_line` emission —
346 /// `(machine_offset_within_code, wasm_op_index)` captured at encode time, one
347 /// entry per emitted machine instruction. A `None` op-index marks an
348 /// instruction with no originating wasm op (prologue/epilogue, literal-pool
349 /// word). This is purely additive metadata: it is never serialized unless
350 /// `.debug_line` emission is requested, so the emitted `.text` is
351 /// byte-identical with or without it. Empty for backends/paths that do not
352 /// yet produce a source map (RISC-V, the optimized ARM path).
353 pub line_map: LineMap,
354}
355
356/// Result of compiling a full module
357#[derive(Debug)]
358pub struct CompilationResult {
359 /// Compiled functions
360 pub functions: Vec<CompiledFunction>,
361 /// Complete ELF binary (if backend produces one directly)
362 pub elf: Option<Vec<u8>>,
363 /// Name of the backend that produced this result
364 pub backend_name: String,
365}
366
367/// What a backend can and cannot do
368#[derive(Debug, Clone)]
369pub struct BackendCapabilities {
370 /// Backend produces complete ELF files (external backends like aWsm)
371 pub produces_elf: bool,
372 /// Backend supports per-rule verification (only our custom ARM backend)
373 pub supports_rule_verification: bool,
374 /// Backend supports binary-level verification (all backends via disassembly)
375 pub supports_binary_verification: bool,
376 /// Backend is an external tool (not a library)
377 pub is_external: bool,
378}
379
380/// Trait that every compilation backend implements
381pub trait Backend: Send + Sync {
382 /// Human-readable backend name
383 fn name(&self) -> &str;
384
385 /// What this backend can do
386 fn capabilities(&self) -> BackendCapabilities;
387
388 /// Which targets this backend supports
389 fn supported_targets(&self) -> Vec<TargetSpec>;
390
391 /// Compile an entire decoded WASM module
392 fn compile_module(
393 &self,
394 module: &DecodedModule,
395 config: &CompileConfig,
396 ) -> std::result::Result<CompilationResult, BackendError>;
397
398 /// Compile a single function from WASM ops to machine code
399 fn compile_function(
400 &self,
401 name: &str,
402 ops: &[WasmOp],
403 config: &CompileConfig,
404 ) -> std::result::Result<CompiledFunction, BackendError>;
405
406 /// Check if this backend is available (external tools installed, etc.)
407 fn is_available(&self) -> bool;
408}
409
410/// Registry of available backends
411pub struct BackendRegistry {
412 backends: HashMap<String, Box<dyn Backend>>,
413}
414
415impl BackendRegistry {
416 pub fn new() -> Self {
417 Self {
418 backends: HashMap::new(),
419 }
420 }
421
422 /// Register a backend under its name
423 pub fn register(&mut self, backend: Box<dyn Backend>) {
424 let name = backend.name().to_string();
425 self.backends.insert(name, backend);
426 }
427
428 /// Get a backend by name
429 pub fn get(&self, name: &str) -> Option<&dyn Backend> {
430 self.backends.get(name).map(|b| b.as_ref())
431 }
432
433 /// List all registered backends
434 pub fn list(&self) -> Vec<&dyn Backend> {
435 self.backends.values().map(|b| b.as_ref()).collect()
436 }
437
438 /// List backends that are actually available (installed and working)
439 pub fn available(&self) -> Vec<&dyn Backend> {
440 self.backends
441 .values()
442 .filter(|b| b.is_available())
443 .map(|b| b.as_ref())
444 .collect()
445 }
446}
447
448impl Default for BackendRegistry {
449 fn default() -> Self {
450 Self::new()
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 #[test]
459 fn test_registry_empty() {
460 let reg = BackendRegistry::new();
461 assert!(reg.list().is_empty());
462 assert!(reg.available().is_empty());
463 assert!(reg.get("arm").is_none());
464 }
465
466 #[test]
467 fn test_compile_config_default() {
468 let config = CompileConfig::default();
469 assert_eq!(config.opt_level, 2);
470 assert!(!config.bounds_check);
471 assert_eq!(config.safety_bounds, SafetyBounds::None);
472 assert!(!config.no_optimize);
473 }
474
475 #[test]
476 fn safety_bounds_parse_round_trip() {
477 for s in ["none", "mpu", "software", "mask"] {
478 let sb = SafetyBounds::parse(s).unwrap();
479 assert_eq!(sb.as_str(), s);
480 }
481 assert_eq!(SafetyBounds::parse("pmp").unwrap(), SafetyBounds::Mpu);
482 assert_eq!(SafetyBounds::parse("soft").unwrap(), SafetyBounds::Software);
483 assert!(SafetyBounds::parse("nonsense").is_err());
484 }
485
486 #[test]
487 fn effective_safety_bounds_legacy_promotes_to_software() {
488 let cfg = CompileConfig {
489 bounds_check: true,
490 ..Default::default()
491 };
492 assert_eq!(cfg.effective_safety_bounds(), SafetyBounds::Software);
493 }
494
495 #[test]
496 fn effective_safety_bounds_new_field_wins() {
497 let cfg = CompileConfig {
498 bounds_check: true,
499 safety_bounds: SafetyBounds::Mpu,
500 ..Default::default()
501 };
502 assert_eq!(cfg.effective_safety_bounds(), SafetyBounds::Mpu);
503 }
504}