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