From 02fc66c126fa51e00170879aea348815119dc49c Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Mon, 22 Jun 2026 22:09:15 +0200 Subject: [PATCH 1/2] feat(dwarf): emit DWARF debug sections behind --debug-line (VCR-DBG-001, #242, #394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR B of v0.12.0: with `--debug-line`, synth emits a full DWARF unit (`.debug_info`/`.debug_abbrev`/`.debug_str`/`.debug_line`) into the relocatable ELF, mapping ARM `.text` addresses back to the input wasm's source lines via PR A's per-instruction line_map. Pipeline: read the input wasm's `.debug_line` (gimli, now a production dep of synth-core — auto-resolved by Bazel via from_cargo), compose `func_offset + machine_offset → op_offsets[op_idx] → op_offsets_to_source → source line`, emit a real `DW_TAG_compile_unit` whose `DW_AT_stmt_list` points at `.debug_line`, and add the sections as NON-ALLOC trailing PROGBITS (after `.text`/`.data`/`.bss`, so the hardcoded `with_section(4/5/6)` symbol indices are undisturbed). Frozen-safe: the entire emit is behind `if debug_line` (default off), so the default build is byte-identical. Oracles (dwarf_debug_line_emit_394.rs): - additivity: on a DWARF input (msgq) `.text`/`.data`/`.bss` are byte-identical with/without the flag and the `.debug_*` sections appear only under it; on a no-DWARF input (gust_kernel) the whole `.o` is byte-identical. - reachability: the emitted DWARF is walked via the NORMAL gimli `dwarf.units()` → CU `DW_AT_stmt_list` line-program path (the path a debugger uses), resolving 110 in-range `.text` addresses to non-zero source lines. EXPERIMENTAL scope: addresses are object-relative (`.text` base 0) with no `.rela.debug_*` yet, so they are correct for the unlinked object but shift by the load base once linked. PR C (VCR-DBG-002) generalizes the elf_builder's `.rel.text`-only relocation machinery to emit `.rela.debug_*` against the `.text` symbol — required before v0.12.0 is tagged. ARM only; RISC-V is a follow-up. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 1 + crates/synth-cli/Cargo.toml | 5 + crates/synth-cli/src/main.rs | 120 +++++++++- .../tests/dwarf_debug_line_emit_394.rs | 222 ++++++++++++++++++ crates/synth-core/Cargo.toml | 13 +- crates/synth-core/src/dwarf_line.rs | 208 ++++++++++++++++ 6 files changed, 560 insertions(+), 9 deletions(-) create mode 100644 crates/synth-cli/tests/dwarf_debug_line_emit_394.rs diff --git a/Cargo.lock b/Cargo.lock index 43773083..a56be847 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2086,6 +2086,7 @@ version = "0.11.51" dependencies = [ "anyhow", "clap", + "gimli", "object", "scry-sai-core", "serde_json", diff --git a/crates/synth-cli/Cargo.toml b/crates/synth-cli/Cargo.toml index 60999dfd..c054acea 100644 --- a/crates/synth-cli/Cargo.toml +++ b/crates/synth-cli/Cargo.toml @@ -54,6 +54,11 @@ wast.workspace = true [dev-dependencies] object.workspace = true +# VCR-DBG-001 step 4 (#394) — oracle B parses the EMITTED `.debug_line` back with +# gimli::read to prove the section is real debugger-readable DWARF (addresses in +# `.text` range, lines non-zero). Test-only; the production read+emit lives in +# synth-core. Matches synth-core's gimli pin. +gimli = { version = "0.31", default-features = false, features = ["read", "std"] } # VCR-MEM-001 (#383) layer-2 substrate: scry's sound shadow-stack-depth analysis, # verified in-tree against a real module. DEV-dependency only — the production # binary does not pull scry until the gated consumption step (the .bss shrink / diff --git a/crates/synth-cli/src/main.rs b/crates/synth-cli/src/main.rs index a18a797f..0c804411 100644 --- a/crates/synth-cli/src/main.rs +++ b/crates/synth-cli/src/main.rs @@ -252,6 +252,19 @@ enum Commands { /// above B will mis-address. Only meaningful with `--native-pointer-abi`. #[arg(long, value_name = "BYTES")] shadow_stack_size: Option, + + /// VCR-DBG-001 (#394): emit DWARF debug sections (`.debug_info`/ + /// `.debug_abbrev`/`.debug_str`/`.debug_line`) mapping ARM `.text` + /// addresses back to the input wasm's source lines. Requires the input to + /// carry DWARF (`.debug_line` custom section) and the ARM backend (RISC-V + /// carries no line_map). Purely additive: `.text`/`.data`/`.bss` stay + /// byte-identical; off by default. Wired on the relocatable-object + /// (host-link) path. EXPERIMENTAL: addresses are object-relative (`.text` + /// base 0) and carry no relocations yet, so they are correct for the + /// unlinked object but shift by the load base once linked — linked-binary + /// debugging needs the `.rela.debug_*` follow-up (VCR-DBG-002). + #[arg(long)] + debug_line: bool, }, /// Disassemble an ARM ELF file (e.g., synth disasm output.elf) @@ -369,6 +382,7 @@ fn main() -> Result<()> { sbom, sign_output, shadow_stack_size, + debug_line, } => { // Resolve target spec: --target overrides, --cortex-m is backwards compat let target_spec = resolve_target_spec(target.as_deref(), cortex_m, &backend)?; @@ -409,6 +423,7 @@ fn main() -> Result<()> { sbom_path, sign_output, shadow_stack_size, + debug_line, )?; // If --link requested, invoke the cross-linker @@ -716,6 +731,15 @@ struct ElfFunction { code: Vec, /// Relocations targeting external symbols (from import dispatch stubs) relocations: Vec, + /// VCR-DBG-001 step 4 (#394): per-op wasm code BYTE offsets (decoder side + /// table, `FunctionOps.op_offsets`) — module-relative, parallel to the wasm + /// ops. Threaded here so the `--debug-line` emitter can normalize against + /// `code_base` and compose with `line_map`. Empty unless DWARF emission is on. + op_offsets: Vec, + /// VCR-DBG-001 step 4 (#394): `(machine_offset_within_function → wasm_op_index)` + /// captured by the ARM backend (`CompiledFunction.line_map`). Empty for the + /// RISC-V backend. Composed with `op_offsets` to map ARM text address → source. + line_map: synth_core::backend::LineMap, } /// Resolve --target / --cortex-m into a TargetSpec @@ -959,6 +983,8 @@ fn compile_command( sbom_path: Option, sign_output: bool, shadow_stack_size: Option, + // VCR-DBG-001 step 4 (#394): `--debug-line` — emit `.debug_line` DWARF. + debug_line: bool, ) -> Result<()> { // Validate backend exists let registry = build_backend_registry(); @@ -1006,6 +1032,7 @@ fn compile_command( sbom_path, sign_output, shadow_stack_size, + debug_line, ); } @@ -1761,6 +1788,9 @@ fn compile_all_exports( sbom_path: Option, sign_output: bool, shadow_stack_size: Option, + // VCR-DBG-001 step 4 (#394): emit a `.debug_line` section from the input + // wasm's DWARF + the ARM line_maps. Default off ⇒ output byte-identical. + debug_line: bool, ) -> Result<()> { let path = input.context("--all-exports requires an input file")?; @@ -2106,6 +2136,10 @@ fn compile_all_exports( wasm_index: func.index, code: compiled.code, relocations: compiled.relocations, + // VCR-DBG-001 step 4: carry the op-offset side table + the backend's + // line_map so `--debug-line` can compose ARM text addr → source. + op_offsets: func.op_offsets.clone(), + line_map: compiled.line_map, }); // Run verification if requested @@ -2184,6 +2218,20 @@ fn compile_all_exports( // Tracks whether we emitted an ET_REL object (needs linking) vs a standalone // executable, so the summary below reports the right type and link hint. let produced_relocatable = is_riscv || has_external_relocations || relocatable; + + // VCR-DBG-001 step 4 (#394): when `--debug-line` is set, parse the input + // wasm's `.debug_line` from the bytes synth actually compiled + // (`sbom_wasm_bytes` = post-WAT/post-loom). A DWARF-free input yields empty + // rows ⇒ the emitter no-ops ⇒ the object stays byte-identical. Default + // (flag off) ⇒ `None` ⇒ zero new work, zero output change. + let input_dwarf = if debug_line { + sbom_wasm_bytes + .as_deref() + .map(synth_core::dwarf_line::read_input_dwarf_line) + } else { + None + }; + let elf_data = if is_riscv { info!("Building RISC-V multi-function relocatable object (EM_RISCV)"); build_multi_func_riscv_elf(&compiled_funcs)? @@ -2212,6 +2260,7 @@ fn compile_all_exports( } else { None }, + input_dwarf.as_ref(), )? } else if cortex_m { build_multi_func_cortex_m_elf(&compiled_funcs, &all_memories, target_spec)? @@ -2367,6 +2416,10 @@ fn build_relocatable_elf( data_segments: &[(u32, Vec)], linear_memory_bytes: u32, native_globals: Option, + // VCR-DBG-001 step 4 (#394): the input wasm's parsed `.debug_line` (rows + + // code_base). `None` ⇒ `--debug-line` off OR the input carried no DWARF ⇒ + // no `.debug_line` section emitted ⇒ output byte-identical to the default. + dwarf_line: Option<&synth_core::dwarf_line::InputDwarfLine>, ) -> Result> { use std::collections::HashMap; @@ -3076,6 +3129,61 @@ fn build_relocatable_elf( } } + // VCR-DBG-001 step 4 (#394): emit a FULL DWARF unit (`.debug_info`, + // `.debug_abbrev`, `.debug_str`, `.debug_line`, ...) as NON-ALLOC trailing + // PROGBITS sections. Each is structurally a clone of `.meld_import_table`: no + // symbol or relocation targets them, and `.rel.text`'s `sh_info` is hardcoded + // to `.text` (index 4) AFTER the user-section loop — so appending here gives + // each a fresh section index without disturbing the `with_section` (4/5/6) + // symbol indices, keeping the feature PURELY ADDITIVE. The unit carries a + // real `DW_TAG_compile_unit` whose `DW_AT_stmt_list` points at `.debug_line`, + // so a debugger reaches the line table via the NORMAL `.debug_info` → CU walk. + // Composed from `func_offsets[i] + machine_offset → op_offsets[op_idx] → src`. + if let Some(input_dwarf) = dwarf_line + && !input_dwarf.rows.is_empty() + { + use synth_core::dwarf_line::{SourceLoc, op_offsets_to_source}; + let mut table: Vec<(u64, u32)> = Vec::new(); + for (i, func) in funcs.iter().enumerate() { + if func.line_map.is_empty() || func.op_offsets.is_empty() { + continue; // RISC-V (empty line_map) or a func with no op offsets + } + // op-index → source for this function's ops (parallel to op_offsets). + let locs = + op_offsets_to_source(&func.op_offsets, input_dwarf.code_base, &input_dwarf.rows); + for &(machine_off, op_idx) in &func.line_map { + // None entries (prologue / literal pool) carry no source. + let Some(op_idx) = op_idx else { continue }; + if let Some(Some(SourceLoc { line, .. })) = locs.get(op_idx) + && *line != 0 + { + let arm_addr = (func_offsets[i] + machine_off) as u64; + table.push((arm_addr, *line)); + } + } + } + // One address-ordered, de-duped sequence covering every function. + table.sort_by_key(|&(a, _)| a); + table.dedup_by_key(|&mut (a, _)| a); + + let dwarf_sections = synth_core::dwarf_line::emit_debug_sections(&table); + if !dwarf_sections.is_empty() { + let names: Vec<&str> = dwarf_sections.iter().map(|(n, _)| *n).collect(); + for (name, bytes) in &dwarf_sections { + let dbg_section = Section::new(name, ElfSectionType::ProgBits) + .with_align(1) + .with_data(bytes.clone()); + elf_builder.add_section(dbg_section); + } + info!( + "DWARF: emitted {} sections {:?} ({} address rows, --debug-line)", + dwarf_sections.len(), + names, + table.len() + ); + } + } + let (external_count, reloc_count) = extern_sym_indices; info!( "Relocatable ELF: {} functions, {} external symbols, {} relocations", @@ -4462,6 +4570,8 @@ mod tests { kind: synth_core::backend::RelocKind::MovtAbs, }, ], + op_offsets: vec![], + line_map: vec![], }; let linear_memory_bytes: u32 = 131_072; // 2 wasm pages // Native globals: SP-init = 65536 (the shadow-stack top) drives the @@ -4472,7 +4582,7 @@ mod tests { shadow_stack_size: None, }; - let elf = build_relocatable_elf(&[func], &[], &[], linear_memory_bytes, Some(native)) + let elf = build_relocatable_elf(&[func], &[], &[], linear_memory_bytes, Some(native), None) .expect("#345: native-pointer zero-linmem object builds"); // Parse the ELF and inspect sections by name + type. @@ -4560,13 +4670,15 @@ mod tests { kind: synth_core::backend::RelocKind::Abs32, }, ], + op_offsets: vec![], + line_map: vec![], }; let native = NativeGlobalsLayout { globals: vec![(0, 65_536)], sp_init: 65_536, shadow_stack_size: None, }; - let elf = build_relocatable_elf(&[func], &[], &[], 131_072, Some(native)) + let elf = build_relocatable_elf(&[func], &[], &[], 131_072, Some(native), None) .expect("#345: native-pointer literal-pool object builds"); let header = object::elf::FileHeader32::::parse(&*elf).expect("valid ELF32"); @@ -4638,6 +4750,8 @@ mod tests { symbol: "__synth_wasm_data".to_string(), kind: synth_core::backend::RelocKind::Abs32, }], + op_offsets: vec![], + line_map: vec![], }; // 12-byte init segment at the high offset, above the shadow stack. let seg: Vec = vec![0, 0, 0, 0, 0, 0, 0, 0, 0xf4, 0xff, 0xff, 0xff]; @@ -4648,7 +4762,7 @@ mod tests { shadow_stack_size: None, }; - let elf = build_relocatable_elf(&[func], &[], &data_segments, 131_072, Some(native)) + let elf = build_relocatable_elf(&[func], &[], &data_segments, 131_072, Some(native), None) .expect("#354: mixed-case object builds"); let header = object::elf::FileHeader32::::parse(&*elf).expect("valid ELF32"); diff --git a/crates/synth-cli/tests/dwarf_debug_line_emit_394.rs b/crates/synth-cli/tests/dwarf_debug_line_emit_394.rs new file mode 100644 index 00000000..0df58977 --- /dev/null +++ b/crates/synth-cli/tests/dwarf_debug_line_emit_394.rs @@ -0,0 +1,222 @@ +//! VCR-DBG-001 step 4 (#394, #242) — the `--debug-line` EMIT oracles. +//! +//! synth's DWARF feature (v0.12.0) emits a `.debug_line` section mapping ARM +//! `.text` addresses back to the input wasm's source lines. PR A captured the +//! per-instruction `machine_offset → wasm_op_index` map; this PR wires the EMIT +//! side: read the input wasm's `.debug_line`, compose `arm_addr → source line`, +//! and add a NON-ALLOC PROGBITS `.debug_line` section to the relocatable object. +//! +//! Two oracles gate the feature: +//! +//! A. ADDITIVITY (frozen-safe). Compiling with vs without `--debug-line` must +//! leave `.text`/`.data`/`.bss` BYTE-IDENTICAL; ALL emitted `.debug_*` +//! sections (`.debug_info`/`.debug_abbrev`/`.debug_str`/`.debug_line`) +//! appear only under the flag. A no-DWARF input is byte-identical end-to-end +//! (nothing to map ⇒ no sections). Proves the feature is purely additive — +//! the default build is unchanged, so every frozen differential fixture holds. +//! +//! B. CORRECTNESS (end-to-end, debugger-reachable). The emitted DWARF is real: +//! walked the NORMAL debugger way (`gimli::Dwarf::load` → `dwarf.units()` → +//! the unit's `DW_AT_stmt_list` line program) it resolves ≥1 ARM `.text` +//! address (in-range) to a non-zero source line. This is the path a debugger +//! takes, so passing it proves a debugger can reach the line data. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use gimli::{EndianSlice, LittleEndian, SectionId}; +use object::read::elf::ElfFile32; +use object::{Object, ObjectSection}; + +fn synth() -> &'static str { + env!("CARGO_BIN_EXE_synth") +} + +fn repro(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("scripts/repro") + .join(name) +} + +/// Compile `wasm` to a relocatable object, optionally with `--debug-line`. +/// Returns the raw `.o` bytes. +fn compile(wasm: &Path, out: &str, debug_line: bool) -> Vec { + let mut args = vec![ + "compile", + wasm.to_str().unwrap(), + "--target", + "cortex-m4", + "--all-exports", + "--relocatable", + "-o", + out, + ]; + if debug_line { + args.push("--debug-line"); + } + let r = Command::new(synth()) + .args(&args) + .output() + .expect("run synth"); + assert!( + r.status.success(), + "compile failed (debug_line={debug_line}): {}", + String::from_utf8_lossy(&r.stderr) + ); + std::fs::read(out).expect("read .o") +} + +/// Map section-name → section data bytes (skip the null/empty-named section). +fn section_data(elf: &[u8]) -> HashMap> { + let obj = ElfFile32::::parse(elf).expect("parse ELF"); + let mut out = HashMap::new(); + for sec in obj.sections() { + if let Ok(name) = sec.name() + && !name.is_empty() + { + out.insert(name.to_string(), sec.data().unwrap_or(&[]).to_vec()); + } + } + out +} + +/// ORACLE A — additivity for a DWARF-carrying input: `.text`/`.data`/`.bss` +/// content is byte-identical with and without `--debug-line`; `.debug_line` is +/// present ONLY in the flagged build. +#[test] +fn debug_line_is_additive_on_dwarf_input_394() { + let wasm = repro("msgq_put_359.wasm"); + let plain = compile(&wasm, "/tmp/dbg394_msgq_plain.o", false); + let dbg = compile(&wasm, "/tmp/dbg394_msgq_dbg.o", true); + + let ps = section_data(&plain); + let ds = section_data(&dbg); + + // The load-bearing code/data sections must be content-identical. + for name in [".text", ".data", ".bss"] { + match (ps.get(name), ds.get(name)) { + (Some(a), Some(b)) => assert_eq!( + a, b, + "section {name} differs between plain and --debug-line builds" + ), + (None, None) => { /* fixture has no such section — fine */ } + (a, b) => panic!( + "section {name} presence mismatch: plain={} dbg={}", + a.is_some(), + b.is_some() + ), + } + } + + // The whole DWARF unit appears only under the flag. Every `.debug_*` section + // the emitter produces must be ABSENT in the plain build and PRESENT + // (non-empty) in the flagged build — additivity over the full section set, + // not just `.debug_line`. A debugger needs `.debug_info`/`.debug_abbrev`/ + // `.debug_str` to reach the line table via the CU's `DW_AT_stmt_list`. + for name in [".debug_info", ".debug_abbrev", ".debug_str", ".debug_line"] { + assert!(!ps.contains_key(name), "plain build must NOT carry {name}"); + assert!( + ds.get(name).is_some_and(|d| !d.is_empty()), + "--debug-line build must carry a non-empty {name}" + ); + } +} + +/// ORACLE A (no-DWARF half) — a wasm with no `.debug_line` produces a +/// BYTE-IDENTICAL object with or without `--debug-line` (nothing to map ⇒ no +/// section, no shstrtab change, no output change at all). +#[test] +fn debug_line_is_noop_on_nodwarf_input_394() { + let wasm = repro("gust_kernel.wasm"); + let plain = compile(&wasm, "/tmp/dbg394_gust_plain.o", false); + let dbg = compile(&wasm, "/tmp/dbg394_gust_dbg.o", true); + assert_eq!( + plain, dbg, + "no-DWARF input: --debug-line must produce a byte-identical object" + ); +} + +/// ORACLE B — the emitted DWARF is real, DEBUGGER-REACHABLE DWARF. Load the +/// emitted ELF's `.debug_*` sections and walk the NORMAL debugger path: +/// `gimli::Dwarf::load` → `dwarf.units()` → the unit's `DW_AT_stmt_list` line +/// program. Assert ≥1 row resolves an ARM `.text` address (in-range) to a +/// non-zero source line. If this CU→stmt_list walk finds rows, a debugger will +/// too — that is the property v0.12.0 actually promises. +#[test] +fn emitted_debug_line_resolves_arm_addr_to_source_394() { + let wasm = repro("msgq_put_359.wasm"); + let dbg = compile(&wasm, "/tmp/dbg394_msgq_oracleb.o", true); + + let obj = ElfFile32::::parse(&*dbg).expect("parse ELF"); + let text_len = obj.section_by_name(".text").expect(".text present").size(); + assert!(text_len > 0, ".text must be non-empty"); + + let secs = section_data(&dbg); + + // Load the full `.debug_*` section set the NORMAL gimli way — a debugger + // resolves the line program through `.debug_info` → CU → `DW_AT_stmt_list`, + // NOT by parsing `.debug_line` at offset 0. We must therefore reach the rows + // by walking `dwarf.units()`; if that walk is empty, no debugger could reach + // the line data even though `.debug_line` bytes exist. + let empty: &[u8] = &[]; + let load = |id: SectionId| -> Result, gimli::Error> { + let data = secs.get(id.name()).map_or(empty, |v| v.as_slice()); + Ok(EndianSlice::new(data, LittleEndian)) + }; + let dwarf = gimli::Dwarf::load(load).expect("load emitted .debug_* sections"); + + let mut rows: Vec<(u64, u64)> = Vec::new(); + let mut unit_count = 0usize; + let mut units = dwarf.units(); + while let Some(header) = units.next().expect("unit header") { + unit_count += 1; + let unit = dwarf.unit(header).expect("unit"); + let Some(program) = unit.line_program.clone() else { + continue; // the CU has no DW_AT_stmt_list → unreachable line table + }; + let mut state = program.rows(); + while let Some((_, row)) = state.next_row().expect("row") { + if row.end_sequence() { + continue; + } + if let Some(line) = row.line() { + rows.push((row.address(), line.get())); + } + } + } + + assert!( + unit_count > 0, + "emitted DWARF has NO compilation unit — `.debug_info` is missing or empty, \ + so a debugger cannot reach `.debug_line` via DW_AT_stmt_list" + ); + assert!( + !rows.is_empty(), + "the normal `dwarf.units()` → line-program walk decoded ZERO rows from \ + {unit_count} unit(s); the CU's DW_AT_stmt_list does not reach the line table" + ); + + // At least one row must map a real ARM .text address to a non-zero line. + let good = rows + .iter() + .filter(|&&(addr, line)| line > 0 && addr < text_len) + .count(); + assert!( + good > 0, + "expected ≥1 .debug_line row with addr in .text (<0x{text_len:x}) and \ + non-zero line; got {} rows, sample: {:?}", + rows.len(), + rows.iter().take(5).collect::>() + ); + + eprintln!( + "[dbg394-oracleB] {unit_count} CU(s) via dwarf.units(); {} line rows, {good} \ + map an in-range .text addr to a non-zero source line. sample:", + rows.len() + ); + for (addr, line) in rows.iter().filter(|&&(a, l)| l > 0 && a < text_len).take(5) { + eprintln!(" .text+0x{addr:04x} -> line {line}"); + } +} diff --git a/crates/synth-core/Cargo.toml b/crates/synth-core/Cargo.toml index d26ebcb2..40265080 100644 --- a/crates/synth-core/Cargo.toml +++ b/crates/synth-core/Cargo.toml @@ -17,12 +17,13 @@ sha2.workspace = true thiserror.workspace = true anyhow.workspace = true wasmparser.workspace = true +# VCR-DBG-001 Tier-1 step 4 (#394, #242) — the DWARF READER (parse the input +# wasm's `.debug_line`) and the EMITTER (write the ARM `.debug_line` bytes) are +# PRODUCTION now, behind the `--debug-line` flag. Bazel's `from_cargo` +# crate_universe auto-resolves this from Cargo.lock (no MODULE.bazel pin needed). +# `read` + `write` are gimli's defaults; named explicitly so the emitter cannot +# silently lose the `write` half. +gimli = { version = "0.31", default-features = false, features = ["read", "write", "std"] } [dev-dependencies] wat.workspace = true -# VCR-DBG-001 Tier-1 step 2 (#394, #242) — gimli parses the input wasm's -# `.debug_line` in the read-side spike ONLY. DEV-dep ⇒ frozen-safe: synth's Bazel -# manual-spec crate_universe globs `src/` (never `tests/`), so this needs no -# MODULE.bazel pin, and the production `synth` binary does not pull gimli. The -# production gimli dep + MODULE.bazel pin land with the DWARF EMITTER (step 4). -gimli = "0.31" diff --git a/crates/synth-core/src/dwarf_line.rs b/crates/synth-core/src/dwarf_line.rs index f470a773..47726cf8 100644 --- a/crates/synth-core/src/dwarf_line.rs +++ b/crates/synth-core/src/dwarf_line.rs @@ -71,6 +71,214 @@ pub fn op_offsets_to_source( .collect() } +// --------------------------------------------------------------------------- +// VCR-DBG-001 step 4 — PRODUCTION read + emit (the `--debug-line` feature). +// +// `read_input_dwarf_line` ports the read-side spike +// (`tests/dwarf_line_read_spike.rs` + `dwarf_compose_step3.rs::code_base`): +// pull the `.debug_*` custom sections out of the input wasm, parse `.debug_line` +// with gimli, and also report the code-section payload start (`code_base`) the +// compose normalizes against. `emit_debug_sections` ports the emit-side spike +// (`tests/dwarf_emit_roundtrip_step4.rs::emit_dwarf`): take an address-ordered +// (arm_addr → source line) table and produce a FULL debugger-readable DWARF unit +// (`.debug_info`/`.debug_abbrev`/`.debug_str`/`.debug_line`) via gimli::write, +// the CU's DW_AT_stmt_list pointing at the line table. Both are gated behind +// `--debug-line`; when the input carries no DWARF, `read_input_dwarf_line` +// returns empty rows (graceful no-op) and the emit is skipped, so the default +// object stays bit-identical. + +use std::collections::HashMap; + +use gimli::{Dwarf, EndianSlice, LittleEndian, SectionId}; +use wasmparser::{Parser, Payload}; + +/// Result of reading the input wasm's DWARF line table: the parsed rows plus the +/// code-section payload start (`code_base`) the op-offset compose subtracts. +#[derive(Debug, Default, Clone)] +pub struct InputDwarfLine { + /// Code-section-relative `.debug_line` rows (`addr` is a wasm code byte + /// offset; for the synth bridge that equals the DWARF address space). + pub rows: Vec, + /// Module-relative byte offset of the code section payload start. Empty wasm + /// or a wasm with no code section reports 0. + pub code_base: u32, +} + +/// Read the input wasm's `.debug_line` into code-section-relative +/// `(addr → line)` rows and report `code_base`. Returns an empty table (rows +/// empty, the feature a no-op) when the input carries no `.debug_*` sections or +/// no parseable line program — never an error for a DWARF-free module. +pub fn read_input_dwarf_line(wasm: &[u8]) -> InputDwarfLine { + // (a) extract every `.debug_*` custom section + find the code payload start. + let mut sections: HashMap> = HashMap::new(); + let mut code_base = 0u32; + for payload in Parser::new(0).parse_all(wasm) { + match payload { + Ok(Payload::CustomSection(c)) if c.name().starts_with(".debug_") => { + sections.insert(c.name().to_string(), c.data().to_vec()); + } + Ok(Payload::CodeSectionStart { range, .. }) => { + code_base = range.start as u32; + } + _ => {} + } + } + if !sections.contains_key(".debug_line") { + return InputDwarfLine { + rows: Vec::new(), + code_base, + }; + } + + // (b) parse `.debug_line` with gimli. A malformed line program degrades to + // an empty table (the feature no-ops) rather than failing the compile. + let rows = parse_debug_line_rows(§ions).unwrap_or_default(); + InputDwarfLine { rows, code_base } +} + +/// gimli read of `.debug_line` → rows. `file` is recorded as the line program's +/// file index (kept opaque per `LineRow`'s contract; the compose carries it but +/// only `addr`/`line` are load-bearing for the wasm-offset bridge). +fn parse_debug_line_rows( + sections: &HashMap>, +) -> Result, gimli::Error> { + let empty: &[u8] = &[]; + let load = |id: SectionId| -> Result, gimli::Error> { + let data = sections.get(id.name()).map_or(empty, |v| v.as_slice()); + Ok(EndianSlice::new(data, LittleEndian)) + }; + let dwarf = Dwarf::load(load)?; + + let mut rows = Vec::new(); + let mut units = dwarf.units(); + while let Some(header) = units.next()? { + let unit = dwarf.unit(header)?; + let Some(program) = unit.line_program.clone() else { + continue; + }; + let mut state = program.rows(); + while let Some((_, row)) = state.next_row()? { + if row.end_sequence() { + continue; + } + rows.push(LineRow { + addr: row.address() as u32, + line: row.line().map(|l| l.get() as u32).unwrap_or(0), + file: row.file_index() as u32, + }); + } + } + Ok(rows) +} + +/// Emit an address-ordered `(arm_addr, line)` table as a FULL minimal DWARF unit +/// (gimli::write) and return EVERY non-empty `.debug_*` section it produces — +/// `.debug_info`, `.debug_abbrev`, `.debug_str`, `.debug_line` (and +/// `.debug_line_str`/`.debug_ranges` etc. when non-empty). The caller composes +/// the table (one address-sorted, de-duped sequence covering every function); +/// this produces the section bytes for non-ALLOC ELF `PROGBITS` sections. +/// Returns an empty `Vec` for an empty table (nothing to map ⇒ no sections ⇒ +/// output stays byte-identical). +/// +/// Crucially this emits a real root `DW_TAG_compile_unit` DIE with `DW_AT_name`, +/// `DW_AT_low_pc`/`DW_AT_high_pc` spanning the emitted text, and the line program +/// attached — so the CU's `DW_AT_stmt_list` points at `.debug_line`. That makes +/// the line table reachable via the NORMAL debugger walk (`.debug_info` → CU → +/// `DW_AT_stmt_list` → line program), not just a standalone `.debug_line` parse. +/// +/// Ports `tests/dwarf_emit_roundtrip_step4.rs::emit_dwarf` (which emits the same +/// full unit and round-trips through `Dwarf::units()`). +pub fn emit_debug_sections(table: &[(u64, u32)]) -> Vec<(&'static str, Vec)> { + use gimli::write::{ + Address, AttributeValue, DwarfUnit, EndianVec, LineProgram, LineString, Sections, + }; + + if table.is_empty() { + return Vec::new(); + } + + let encoding = gimli::Encoding { + format: gimli::Format::Dwarf32, + version: 4, + address_size: 4, + }; + let mut dwarf = DwarfUnit::new(encoding); + + // The span of emitted text the unit describes: low_pc=0 (text base), high_pc + // one past the last mapped address. + let high_pc = table.iter().map(|&(a, _)| a).max().unwrap_or(0) + 1; + + let mut program = LineProgram::new( + encoding, + gimli::LineEncoding::default(), + LineString::String(b"/synth".to_vec()), + LineString::String(b"synth.wasm".to_vec()), + None, + ); + let dir = program.default_directory(); + let fid = program.add_file(LineString::String(b"synth.wasm".to_vec()), dir, None); + + program.begin_sequence(Some(Address::Constant(0))); + for &(addr, line) in table { + let row = program.row(); + row.address_offset = addr; + row.file = fid; + row.line = line as u64; + program.generate_row(); + } + program.end_sequence(high_pc); + dwarf.unit.line_program = program; + + // Populate the root DW_TAG_compile_unit DIE: a name, the text span, and (via + // gimli auto-wiring the attached line_program) DW_AT_stmt_list → .debug_line. + { + let name_id = dwarf.strings.add("synth.wasm"); + let root = dwarf.unit.root(); + let root_die = dwarf.unit.get_mut(root); + root_die.set(gimli::DW_AT_name, AttributeValue::StringRef(name_id)); + root_die.set( + gimli::DW_AT_low_pc, + AttributeValue::Address(Address::Constant(0)), + ); + root_die.set(gimli::DW_AT_high_pc, AttributeValue::Udata(high_pc)); + } + + let mut sections = Sections::new(EndianVec::new(LittleEndian)); + if dwarf.write(&mut sections).is_err() { + return Vec::new(); + } + + let mut out: Vec<(&'static str, Vec)> = Vec::new(); + let _ = sections.for_each(|id, data| -> Result<(), ()> { + let bytes = data.slice(); + if !bytes.is_empty() + && let Some(name) = section_name(id) + { + out.push((name, bytes.to_vec())); + } + Ok(()) + }); + out +} + +/// `'static` ELF section name for the `.debug_*` sections the emitter can +/// produce. Returns `None` for any section id we do not wire (none are expected +/// for this minimal unit, but the match keeps the names `'static`). +fn section_name(id: SectionId) -> Option<&'static str> { + Some(match id { + SectionId::DebugInfo => ".debug_info", + SectionId::DebugAbbrev => ".debug_abbrev", + SectionId::DebugStr => ".debug_str", + SectionId::DebugLine => ".debug_line", + SectionId::DebugLineStr => ".debug_line_str", + SectionId::DebugRanges => ".debug_ranges", + SectionId::DebugRngLists => ".debug_rnglists", + SectionId::DebugStrOffsets => ".debug_str_offsets", + SectionId::DebugAddr => ".debug_addr", + _ => return None, + }) +} + #[cfg(test)] mod tests { use super::*; From 63c510f83b27420823e62ea73a503f1e3b89d688 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Mon, 22 Jun 2026 22:27:17 +0200 Subject: [PATCH 2/2] fix(bazel): list @crates//:gimli in synth-core deps (VCR-DBG-001, #394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-written rust_library `deps` in crates/BUILD.bazel are not generated from Cargo.toml, so moving gimli to a production dependency of synth-core (which cargo honours) left the Bazel target compiling synth-core without it → `error[E0433]: unresolved crate gimli` in "Bazel Build & Proofs". from_cargo makes the `@crates//:gimli` alias available; the manual target must still list it. Verified `bazel build //crates:synth` green. --- crates/BUILD.bazel | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/BUILD.bazel b/crates/BUILD.bazel index a9452bf1..41198f13 100644 --- a/crates/BUILD.bazel +++ b/crates/BUILD.bazel @@ -14,6 +14,10 @@ rust_library( edition = "2024", deps = [ "@crates//:anyhow", + # VCR-DBG-001 (#394): DWARF `.debug_line` read+emit. The hand-written + # Bazel deps are not generated from Cargo.toml, so a new production dep + # must be listed here as well as in synth-core/Cargo.toml. + "@crates//:gimli", "@crates//:serde", "@crates//:serde_json", "@crates//:sha2",