diff --git a/crates/synth-backend/src/elf_builder.rs b/crates/synth-backend/src/elf_builder.rs index d5ff1d9a..9a0b5cc0 100644 --- a/crates/synth-backend/src/elf_builder.rs +++ b/crates/synth-backend/src/elf_builder.rs @@ -65,6 +65,9 @@ pub enum SectionType { NoBits = 8, /// Relocation entries Rel = 9, + /// ARM build attributes (`SHT_ARM_ATTRIBUTES`, #637) — the `.ARM.attributes` + /// section every ARM toolchain consults to auto-select the Thumb/A32 decoder. + ArmAttributes = 0x7000_0003, } /// Section flags @@ -366,6 +369,93 @@ pub const EF_ARM_ABI_FLOAT_HARD: u32 = 0x00000400; /// ARM soft-float ABI flag pub const EF_ARM_ABI_FLOAT_SOFT: u32 = 0x00000200; +/// ARM EABI build-attribute tags and values (#637) — "Addenda to, and Errata +/// in, the ABI for the Arm Architecture" (build attributes). Only the tags the +/// synth ELF writer emits; consumers (objdump, gdb, `synth disasm`) use them to +/// auto-select the Thumb vs A32 decoder without a manual `--triple`. +pub mod aeabi { + /// Tag_CPU_arch (uleb value) + pub const TAG_CPU_ARCH: u32 = 6; + /// Tag_CPU_arch_profile (uleb value: 'M', 'R', 'A') + pub const TAG_CPU_ARCH_PROFILE: u32 = 7; + /// Tag_ARM_ISA_use (0 = no A32, 1 = A32 permitted) + pub const TAG_ARM_ISA_USE: u32 = 8; + /// Tag_THUMB_ISA_use (0 = none, 1 = Thumb-1 (16-bit), 2 = Thumb-2) + pub const TAG_THUMB_ISA_USE: u32 = 9; + + /// Tag_CPU_arch value: ARMv7 (Cortex-M3 / Cortex-R profile base) + pub const CPU_ARCH_V7: u32 = 10; + /// Tag_CPU_arch value: ARMv6-M (Cortex-M0) + pub const CPU_ARCH_V6M: u32 = 11; + /// Tag_CPU_arch value: ARMv7E-M (Cortex-M4/M7) + pub const CPU_ARCH_V7EM: u32 = 13; + /// Tag_CPU_arch value: ARMv8.1-M.mainline (Cortex-M55) + pub const CPU_ARCH_V8_1M_MAIN: u32 = 21; + + /// Tag_CPU_arch_profile value: microcontroller + pub const PROFILE_M: u32 = b'M' as u32; + /// Tag_CPU_arch_profile value: real-time + pub const PROFILE_R: u32 = b'R' as u32; +} + +/// Encode a u32 as ULEB128 (build-attribute value encoding). +fn push_uleb128(out: &mut Vec, mut v: u32) { + loop { + let byte = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(byte); + break; + } + out.push(byte | 0x80); + } +} + +/// Build the `.ARM.attributes` section (#637): format-version `'A'`, one +/// `"aeabi"` vendor subsection carrying a single `Tag_File` (1) subsubsection +/// with the given file-scope attributes. Tags with value 0 are omitted (0 is +/// the spec default). Standard toolchains (objdump, gdb, ld) read this to +/// auto-select the Thumb vs A32 decoder — synth objects become self-describing +/// instead of requiring a manual `--triple=thumbv7m`. +pub fn arm_attributes_section( + cpu_arch: u32, + cpu_arch_profile: u32, + arm_isa_use: u32, + thumb_isa_use: u32, +) -> Section { + // File-scope attribute pairs (uleb tag, uleb value). + let mut attrs = Vec::new(); + for (tag, value) in [ + (aeabi::TAG_CPU_ARCH, cpu_arch), + (aeabi::TAG_CPU_ARCH_PROFILE, cpu_arch_profile), + (aeabi::TAG_ARM_ISA_USE, arm_isa_use), + (aeabi::TAG_THUMB_ISA_USE, thumb_isa_use), + ] { + if value != 0 { + push_uleb128(&mut attrs, tag); + push_uleb128(&mut attrs, value); + } + } + + // Tag_File (1) subsubsection: tag byte + u32 length (self-inclusive) + attrs. + let file_len = (1 + 4 + attrs.len()) as u32; + let mut file_sub = vec![1u8]; // Tag_File + file_sub.extend_from_slice(&file_len.to_le_bytes()); + file_sub.extend_from_slice(&attrs); + + // "aeabi" vendor subsection: u32 length (self-inclusive) + NTBS name + data. + let vendor_name = b"aeabi\0"; + let vendor_len = (4 + vendor_name.len() + file_sub.len()) as u32; + let mut blob = vec![b'A']; // format version + blob.extend_from_slice(&vendor_len.to_le_bytes()); + blob.extend_from_slice(vendor_name); + blob.extend_from_slice(&file_sub); + + Section::new(".ARM.attributes", SectionType::ArmAttributes) + .with_align(1) + .with_data(blob) +} + /// ELF file builder pub struct ElfBuilder { /// File class (32 or 64 bit) @@ -534,6 +624,40 @@ impl ElfBuilder { // Build symbol string table let (strtab_data, symbol_name_offsets) = self.build_symbol_string_table(); + // #656: ELF requires every STB_LOCAL symbol to precede all non-local + // symbols in `.symtab`, with the section's `sh_info` set to the index of + // the first non-local symbol. Callers add symbols in whatever order is + // convenient (and hold 1-based indices from `add_symbol_indexed` / + // `add_undefined_symbol` for their relocations), so the LOCAL/GLOBAL + // ordering is established here at build time: a stable locals-first + // permutation, plus an old→new index map every relocation is rewritten + // through. With zero local symbols (every pre-#656 object) the + // permutation is the identity and `sh_info` stays 1 — byte-identical. + let mut sym_order: Vec = (0..self.symbols.len()).collect(); + sym_order.sort_by_key(|&i| self.symbols[i].binding != SymbolBinding::Local); + // old_to_new[old_1based] = new_1based; index 0 (the null symbol) maps to 0. + let mut old_to_new = vec![0u32; self.symbols.len() + 1]; + for (new_pos, &old) in sym_order.iter().enumerate() { + old_to_new[old + 1] = new_pos as u32 + 1; + } + let local_count = self + .symbols + .iter() + .filter(|s| s.binding == SymbolBinding::Local) + .count(); + // The null symbol (index 0) counts as local, so first-global = locals + 1. + let symtab_sh_info = local_count as u32 + 1; + let remap_relocs = |relocs: &[Relocation]| -> Vec { + relocs + .iter() + .map(|r| Relocation { + offset: r.offset, + symbol_index: old_to_new[r.symbol_index as usize], + reloc_type: r.reloc_type, + }) + .collect() + }; + // Calculate section offsets (after ELF header + program headers) let mut current_offset = header_size + ph_table_size; @@ -552,13 +676,14 @@ impl ElfBuilder { current_offset += section.data.len(); } - // Section 3: .symtab (symbol table) + // Section 3: .symtab (symbol table), in locals-first order (#656) let symtab_offset = current_offset; - let symtab_data = self.build_symbol_table(&symbol_name_offsets); + let symtab_data = self.build_symbol_table(&symbol_name_offsets, &sym_order); current_offset += symtab_data.len(); - // Section 4+ (optional): .rel.text (relocations) - let rel_data = self.build_relocation_table(); + // Section 4+ (optional): .rel.text (relocations), symbol indices + // rewritten through the locals-first permutation (#656) + let rel_data = Self::encode_rel_entries(&remap_relocs(&self.relocations)); let rel_offset = current_offset; current_offset += rel_data.len(); @@ -571,7 +696,7 @@ impl ElfBuilder { let Some(target_idx) = self.section_index_by_name(target) else { continue; }; - let data = Self::encode_rel_entries(relocs); + let data = Self::encode_rel_entries(&remap_relocs(relocs)); let name_offset = extra_rel_name_offsets.get(i).copied().unwrap_or(0); extra_rel.push(ExtraRelSection { name_offset, @@ -612,6 +737,7 @@ impl ElfBuilder { rel_offset, &rel_data, &extra_rel, + symtab_sh_info, ); output.extend_from_slice(§ion_headers); @@ -835,11 +961,6 @@ impl ElfBuilder { (strtab, offsets) } - /// Build relocation table (ELF32 REL entries: 8 bytes each) - fn build_relocation_table(&self) -> Vec { - Self::encode_rel_entries(&self.relocations) - } - /// Encode a slice of relocations as ELF32 REL entries (8 bytes each). Shared /// by `.rel.text` and the per-section `.rel.` tables. fn encode_rel_entries(relocs: &[Relocation]) -> Vec { @@ -864,15 +985,18 @@ impl ElfBuilder { .map(|pos| 4 + pos as u32) } - /// Build symbol table - fn build_symbol_table(&self, name_offsets: &[usize]) -> Vec { + /// Build symbol table. `order` is the locals-first permutation of + /// `self.symbols` computed in [`build`] (#656): entry `k` of the emitted + /// table (after the null symbol) is `self.symbols[order[k]]`. + fn build_symbol_table(&self, name_offsets: &[usize], order: &[usize]) -> Vec { let mut symtab = Vec::new(); // First entry is always null symbol symtab.extend_from_slice(&[0u8; 16]); // 16 bytes per symbol in ELF32 - // User symbols - for (i, symbol) in self.symbols.iter().enumerate() { + // User symbols, locals first (#656) + for &i in order { + let symbol = &self.symbols[i]; let name_offset = if i < name_offsets.len() { name_offsets[i] as u32 } else { @@ -930,6 +1054,9 @@ impl ElfBuilder { rel_offset: usize, rel_data: &[u8], extra_rel: &[ExtraRelSection], + // #656: `.symtab` sh_info = index of the first non-LOCAL symbol + // (1 + number of local symbols; the null symbol counts as local). + symtab_sh_info: u32, ) -> Vec { let mut headers = Vec::new(); @@ -980,7 +1107,10 @@ impl ElfBuilder { symtab_offset as u32, symtab_data.len() as u32, 2, - 1, + // #656: was hardcoded 1 (the #430 blocker) — with local symbols + // present that under-reports, and `ld` then treats every local as + // global-bindable. Now the real first-non-local index. + symtab_sh_info, 4, 16, ); @@ -1442,7 +1572,7 @@ mod tests { builder.add_symbol(sym); let (_strtab, offsets) = builder.build_symbol_string_table(); - let symtab = builder.build_symbol_table(&offsets); + let symtab = builder.build_symbol_table(&offsets, &[0]); // Should have null symbol (16 bytes) + 1 symbol (16 bytes) = 32 bytes assert_eq!(symtab.len(), 32); @@ -1492,7 +1622,7 @@ mod tests { builder.add_symbol(func_sym); let (_strtab, offsets) = builder.build_symbol_string_table(); - let symtab = builder.build_symbol_table(&offsets); + let symtab = builder.build_symbol_table(&offsets, &[0]); let value = u32::from_le_bytes(symtab[20..24].try_into().unwrap()); assert_eq!(value, 0x1000, "A32 STT_FUNC st_value must keep bit 0 clear"); @@ -1506,4 +1636,182 @@ mod tests { let builder = ElfBuilder::new_arm32().with_entry(0x8000); assert_eq!(builder.entry, 0x8001, "Thumb e_entry keeps the bit"); } + + /// Minimal ELF32 section-header reader for the tests below: + /// (sh_type, sh_offset, sh_size, sh_link, sh_info) per section. + fn read_section_headers(elf: &[u8]) -> Vec<(u32, u32, u32, u32, u32)> { + let e_shoff = u32::from_le_bytes(elf[32..36].try_into().unwrap()) as usize; + let e_shnum = u16::from_le_bytes(elf[48..50].try_into().unwrap()) as usize; + (0..e_shnum) + .map(|i| { + let base = e_shoff + i * 40; + let f = |off: usize| { + u32::from_le_bytes(elf[base + off..base + off + 4].try_into().unwrap()) + }; + (f(4), f(16), f(20), f(24), f(28)) + }) + .collect() + } + + /// Read symtab entries as (st_value, st_info, st_shndx). + fn read_symtab(elf: &[u8]) -> (Vec<(u32, u8, u16)>, u32) { + let headers = read_section_headers(elf); + let &(_, off, size, _, sh_info) = headers + .iter() + .find(|h| h.0 == SectionType::SymTab as u32) + .expect("symtab present"); + let syms = (0..size as usize / 16) + .map(|i| { + let base = off as usize + i * 16; + ( + u32::from_le_bytes(elf[base + 4..base + 8].try_into().unwrap()), + elf[base + 12], + u16::from_le_bytes(elf[base + 14..base + 16].try_into().unwrap()), + ) + }) + .collect(); + (syms, sh_info) + } + + /// #656: local symbols must be emitted BEFORE globals (stable within each + /// class), `.symtab` `sh_info` must be the first-non-local index, and every + /// relocation's symbol index must be rewritten through the permutation. + #[test] + fn test_locals_sorted_first_sh_info_and_reloc_reindex_656() { + let mut builder = ElfBuilder::new_arm32() + .with_entry(0) + .with_type(ElfType::Rel); + let text = Section::new(".text", SectionType::ProgBits) + .with_flags(SectionFlags::ALLOC | SectionFlags::EXEC) + .with_align(4) + .with_data(vec![0u8; 16]); + builder.add_section(text); + + // Added out of ELF order: global export first, then a LOCAL internal + // helper, then a global undefined external. + builder.add_symbol( + Symbol::new("exported") + .with_value(0) + .with_binding(SymbolBinding::Global) + .with_type(SymbolType::Func) + .with_section(4), + ); // pre-build index 1 + builder.add_symbol( + Symbol::new("func_2") + .with_value(8) + .with_binding(SymbolBinding::Local) + .with_type(SymbolType::Func) + .with_section(4), + ); // pre-build index 2 + let undef_idx = builder.add_undefined_symbol("external"); // pre-build index 3 + assert_eq!(undef_idx, 3); + + // BL at offset 0 → the LOCAL func_2 (pre-build index 2); + // BL at offset 4 → the undefined external (pre-build index 3). + builder.add_relocation(Relocation { + offset: 0, + symbol_index: 2, + reloc_type: ArmRelocationType::ThmCall, + }); + builder.add_relocation(Relocation { + offset: 4, + symbol_index: undef_idx, + reloc_type: ArmRelocationType::ThmCall, + }); + + let elf = builder.build().unwrap(); + let (syms, sh_info) = read_symtab(&elf); + + // Order: null, func_2 (LOCAL), exported (GLOBAL), external (GLOBAL undef). + assert_eq!(syms.len(), 4); + assert_eq!(syms[0], (0, 0, 0), "null symbol first"); + let bind = |info: u8| info >> 4; + assert_eq!(bind(syms[1].1), SymbolBinding::Local as u8, "local first"); + assert_eq!(syms[1].0, 8 | 1, "func_2 st_value (thumb bit)"); + assert_eq!(bind(syms[2].1), SymbolBinding::Global as u8); + assert_eq!(syms[2].0, 1, "exported st_value 0 | thumb bit"); + assert_eq!(bind(syms[3].1), SymbolBinding::Global as u8); + assert_eq!(syms[3].2, 0, "external is SHN_UNDEF"); + assert_eq!(sh_info, 2, "sh_info = index of first non-local symbol"); + + // Relocations rewritten: func_2 is now index 1, external index 3. + let headers = read_section_headers(&elf); + let &(_, rel_off, rel_size, _, rel_info) = headers + .iter() + .find(|h| h.0 == SectionType::Rel as u32) + .expect(".rel.text present"); + assert_eq!(rel_info, 4, ".rel.text still targets .text"); + assert_eq!(rel_size, 16); + let r_info = |i: usize| { + u32::from_le_bytes( + elf[rel_off as usize + i * 8 + 4..rel_off as usize + i * 8 + 8] + .try_into() + .unwrap(), + ) + }; + assert_eq!(r_info(0) >> 8, 1, "BL func_2 reloc remapped to new index 1"); + assert_eq!(r_info(0) & 0xff, ArmRelocationType::ThmCall as u32); + assert_eq!(r_info(1) >> 8, 3, "BL external reloc keeps index 3"); + } + + /// #656 freeze guard: with zero LOCAL symbols the permutation is the + /// identity and `sh_info` stays 1 — the pre-#656 layout, byte-identical. + #[test] + fn test_all_global_symtab_unchanged_sh_info_1_656() { + let mut builder = ElfBuilder::new_arm32() + .with_entry(0) + .with_type(ElfType::Rel); + builder.add_section( + Section::new(".text", SectionType::ProgBits) + .with_flags(SectionFlags::ALLOC | SectionFlags::EXEC) + .with_data(vec![0u8; 8]), + ); + for (name, val) in [("a", 0u32), ("b", 4u32)] { + builder.add_symbol( + Symbol::new(name) + .with_value(val) + .with_binding(SymbolBinding::Global) + .with_type(SymbolType::Func) + .with_section(4), + ); + } + let elf = builder.build().unwrap(); + let (syms, sh_info) = read_symtab(&elf); + assert_eq!(sh_info, 1, "no locals ⇒ sh_info stays 1 (pre-#656 layout)"); + assert_eq!(syms[1].0, 1, "a first (insertion order preserved)"); + assert_eq!(syms[2].0, 5, "b second"); + } + + /// #637: `.ARM.attributes` blob structure — format version 'A', "aeabi" + /// vendor subsection, Tag_File subsubsection with the uleb tag pairs, and + /// zero-valued tags omitted (spec default). + #[test] + fn test_arm_attributes_section_bytes_637() { + // Cortex-M3: v7, profile M, no A32, Thumb-2. + let sec = arm_attributes_section(aeabi::CPU_ARCH_V7, aeabi::PROFILE_M, 0, 2); + assert_eq!(sec.name, ".ARM.attributes"); + assert_eq!(sec.section_type, SectionType::ArmAttributes); + let d = &sec.data; + assert_eq!(d[0], b'A', "format version"); + let vendor_len = u32::from_le_bytes(d[1..5].try_into().unwrap()) as usize; + assert_eq!(vendor_len, d.len() - 1, "vendor subsection length"); + assert_eq!(&d[5..11], b"aeabi\0"); + assert_eq!(d[11], 1, "Tag_File"); + let file_len = u32::from_le_bytes(d[12..16].try_into().unwrap()) as usize; + assert_eq!(file_len, d.len() - 11, "Tag_File length"); + // Attribute pairs (all values < 128 ⇒ one uleb byte each). + let attrs = &d[16..]; + assert_eq!( + attrs, + &[ + 6, 10, // Tag_CPU_arch = v7 + 7, b'M', // Tag_CPU_arch_profile = M + 9, 2, // Tag_THUMB_ISA_use = Thumb-2 (Tag_ARM_ISA_use=0 omitted) + ], + ); + + // Cortex-R5: v7, profile R, A32 permitted, Thumb-2 permitted. + let sec = arm_attributes_section(aeabi::CPU_ARCH_V7, aeabi::PROFILE_R, 1, 2); + assert_eq!(&sec.data[16..], &[6, 10, 7, b'R', 8, 1, 9, 2]); + } } diff --git a/crates/synth-backend/src/lib.rs b/crates/synth-backend/src/lib.rs index 9e9685b5..1806053e 100644 --- a/crates/synth-backend/src/lib.rs +++ b/crates/synth-backend/src/lib.rs @@ -14,7 +14,8 @@ pub mod w2c2_wrapper; pub use elf_builder::{ ArmRelocationType, EF_ARM_ABI_FLOAT_HARD, EF_ARM_ABI_FLOAT_SOFT, EF_ARM_EABI_VER5, ElfBuilder, ElfClass, ElfData, ElfMachine, ElfType, ProgramFlags, ProgramHeader, ProgramType, Relocation, - Section, SectionFlags, SectionType as ElfSectionType, Symbol, SymbolBinding, SymbolType, + Section, SectionFlags, SectionType as ElfSectionType, Symbol, SymbolBinding, SymbolType, aeabi, + arm_attributes_section, }; pub use linker_script::{LinkerScriptGenerator, MemoryRegion}; pub use memory_layout::{MemoryLayout, MemoryLayoutAnalyzer, MemorySection, SectionType}; diff --git a/crates/synth-cli/src/main.rs b/crates/synth-cli/src/main.rs index f5c04fd0..d47fb8f4 100644 --- a/crates/synth-cli/src/main.rs +++ b/crates/synth-cli/src/main.rs @@ -2794,9 +2794,9 @@ fn compile_all_exports( None }, input_dwarf.as_ref(), - // #598: only Thumb-encoded functions get the interworking bit on - // their STT_FUNC symbols; the A32 path (cortex-r5) keeps bit 0 clear. - !matches!(target_spec.isa, synth_core::target::IsaVariant::Arm32), + // #598/#637: Thumb-bit handling + `.ARM.attributes` derive from + // the selected target inside the builder. + target_spec, )? } else if cortex_m { // #649: the self-contained image materializes the R9 globals table — @@ -2889,6 +2889,36 @@ fn compile_all_exports( } /// Build a simple multi-function ELF +/// #637: `.ARM.attributes` for an ARM target — Tag_CPU_arch + profile + +/// Tag_ARM_ISA_use/Tag_THUMB_ISA_use for the selected `-t`. Emitted into every +/// ARM object/image so standard toolchains (objdump, gdb, `synth disasm`) +/// auto-select the Thumb vs A32 decoder without a manual `--triple`. +fn arm_build_attributes(target: &TargetSpec) -> Section { + use synth_backend::{aeabi, arm_attributes_section}; + use synth_core::target::IsaVariant; + match target.isa { + // Cortex-R5 (A32 encoder): v7, profile R, A32 permitted. Thumb-2 is + // architecturally permitted on v7-R, but synth emits pure A32 here and + // the STT_FUNC symbols carry no Thumb bit (#598) — advertise A32 only + // so ISA auto-detection picks the A32 decoder. + IsaVariant::Arm32 => arm_attributes_section(aeabi::CPU_ARCH_V7, aeabi::PROFILE_R, 1, 0), + // Cortex-M0 (Thumb-1 only): v6-M, 16-bit Thumb. + IsaVariant::Thumb => arm_attributes_section(aeabi::CPU_ARCH_V6M, aeabi::PROFILE_M, 0, 1), + // Cortex-M Thumb-2 family: arch from the triple (M3 = v7-M, + // M4/M4F/M7 = v7E-M, M55 = v8.1-M.mainline), profile M, Thumb-2. + _ => { + let cpu_arch = if target.triple.starts_with("thumbv8.1m") { + aeabi::CPU_ARCH_V8_1M_MAIN + } else if target.triple.starts_with("thumbv7em") { + aeabi::CPU_ARCH_V7EM + } else { + aeabi::CPU_ARCH_V7 + }; + arm_attributes_section(cpu_arch, aeabi::PROFILE_M, 0, 2) + } + } +} + fn build_multi_func_simple_elf(funcs: &[ElfFunction]) -> Result> { let base_addr: u32 = 0x8000; let mut elf_builder = ElfBuilder::new_arm32().with_entry(base_addr); @@ -2964,13 +2994,16 @@ fn build_relocatable_elf( // 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>, - // #598: true for Thumb targets (STT_FUNC symbols + e_entry get bit 0, the - // interworking bit), false for A32 (cortex-r5) — A32 code addresses must - // keep bit 0 clear. - thumb_funcs: bool, + // #598/#637: the selected ARM target. Thumb targets get bit 0 (the + // interworking bit) on STT_FUNC symbols + e_entry — A32 (cortex-r5) keeps + // it clear — and every object carries a target-derived `.ARM.attributes`. + target_spec: &TargetSpec, ) -> Result> { use std::collections::HashMap; + // #598: only Thumb-encoded functions get the interworking bit. + let thumb_funcs = !matches!(target_spec.isa, synth_core::target::IsaVariant::Arm32); + // #383 (VCR-MEM-001 layer-1): the integrator-declared shadow-stack budget // shrinks the [0, sp_init) reservation. The actual shrink is computed below // (after the reloc analysis, which decides whether the geometry is safe to @@ -3468,11 +3501,26 @@ fn build_relocatable_elf( // `func_{wasm_index}` name (the label the instruction selector emits for // internal `call`s). Internal `BL func_N` relocations resolve against the // latter; without it, internal calls were left as unpatched `bl #0` (#167). + // + // #656: only EXPORTED names are STB_GLOBAL. Non-exported functions (their + // only name is `func_{wasm_index}`) and every `func_N` alias are per-object + // labels by wasm function INDEX — two independently-dissolved objects both + // define `func_2`, so a GLOBAL binding made `ld -r a.o b.o` fail with + // multiple-definition collisions. STB_LOCAL resolves in-object relocations + // exactly the same (they reference the symbol by index, and `ElfBuilder` + // reindexes them across the locals-first sort) while staying invisible to + // cross-object resolution. for (i, func) in funcs.iter().enumerate() { + let internal_label = format!("func_{}", func.wasm_index); + let is_exported = func.name != internal_label; let export_sym = Symbol::new(&func.name) .with_value(func_offsets[i]) .with_size(func.code.len() as u32) - .with_binding(SymbolBinding::Global) + .with_binding(if is_exported { + SymbolBinding::Global + } else { + SymbolBinding::Local // #656: internal helper — file-local + }) .with_type(SymbolType::Func) .with_section(4); // .text is section 4 (null=0, shstrtab=1, strtab=2, symtab=3, .text=4) elf_builder.add_symbol(export_sym); @@ -3480,12 +3528,11 @@ fn build_relocatable_elf( sym_indices.insert(func.name.clone(), sym_count); // `func_{wasm_index}` alias (skip if the export name already is that). - let internal_label = format!("func_{}", func.wasm_index); - if internal_label != func.name { + if is_exported { let internal_sym = Symbol::new(&internal_label) .with_value(func_offsets[i]) .with_size(func.code.len() as u32) - .with_binding(SymbolBinding::Global) + .with_binding(SymbolBinding::Local) // #656: in-object call label .with_type(SymbolType::Func) .with_section(4); elf_builder.add_symbol(internal_sym); @@ -3796,6 +3843,10 @@ fn build_relocatable_elf( } } + // #637: target-derived `.ARM.attributes`, appended LAST so every earlier + // section keeps its index (`.text`=4, `.bss`/`.data`=5/6, DWARF tail). + elf_builder.add_section(arm_build_attributes(target_spec)); + let (external_count, reloc_count) = extern_sym_indices; info!( "Relocatable ELF: {} functions, {} external symbols, {} relocations", @@ -4174,18 +4225,183 @@ fn build_multi_func_cortex_m_elf( // TODO(#170, mapping-symbols): emit ARM `$t`/`$a`/`$d` mapping symbols so // tools (objdump, gdb, debuggers) know each .text region is Thumb code vs // data without relying on the Func-typed symbols above. This is the - // secondary half of #170 and is intentionally deferred: mapping symbols are - // STB_LOCAL and ELF requires all local symbols to precede globals in the - // symtab, with `.symtab`'s sh_info pointing at the first global. ElfBuilder - // currently appends symbols in call order and does not maintain - // local-before-global ordering or set sh_info, so adding locals here would - // produce a malformed symtab. Direct call resolution (the primary fix) - // works without these; objdump already disassembles correctly via the - // Func-typed function symbols. + // secondary half of #170. The former blocker is gone: since #656 ElfBuilder + // sorts STB_LOCAL symbols before globals and sets `.symtab` sh_info to the + // first-non-local index, so locals no longer produce a malformed symtab. + // Still deferred because direct call resolution (the primary fix) works + // without them and the `.ARM.attributes` section (#637) already tells + // tools the .text is Thumb. + + // #637: target-derived `.ARM.attributes` (appended last; `.text` stays 4). + elf_builder.add_section(arm_build_attributes(target)); elf_builder.build().context("ELF generation failed") } +/// #637: detect whether an EM_ARM ELF carries Thumb code, so `synth disasm` +/// selects the Thumb decoder instead of defaulting to A32 (which mis-decodes +/// synth's own Cortex-M output into garbage). Returns `None` when the file is +/// not a little-endian ELF32 EM_ARM object (RISC-V, AArch64, non-ELF, ...). +/// +/// Detection order: +/// 1. `.ARM.attributes` `Tag_THUMB_ISA_use` (synth emits it since #637; any +/// toolchain-produced ARM object carries it too); +/// 2. the STT_FUNC Thumb bit — every defined function symbol with `st_value` +/// bit 0 set is the standard ARM interworking marker; +/// 3. `e_entry` bit 0 (self-contained images); +/// 4. otherwise A32. +fn detect_arm_thumb(elf: &[u8]) -> Option { + // ELF32, little-endian, EM_ARM (40). + if elf.len() < 52 || elf[0..4] != [0x7f, b'E', b'L', b'F'] || elf[4] != 1 || elf[5] != 1 { + return None; + } + let u16le = |off: usize| u16::from_le_bytes(elf[off..off + 2].try_into().unwrap()); + let u32le = |off: usize| u32::from_le_bytes(elf[off..off + 4].try_into().unwrap()); + if u16le(18) != 40 { + return None; // not EM_ARM + } + + let e_shoff = u32le(32) as usize; + let e_shentsize = u16le(46) as usize; + let e_shnum = u16le(48) as usize; + let section = |i: usize| -> Option<(u32, usize, usize)> { + let base = e_shoff.checked_add(i.checked_mul(e_shentsize)?)?; + if base + 40 > elf.len() { + return None; + } + // (sh_type, sh_offset, sh_size) + Some(( + u32le(base + 4), + u32le(base + 16) as usize, + u32le(base + 20) as usize, + )) + }; + + // 1. `.ARM.attributes` (SHT_ARM_ATTRIBUTES = 0x70000003): Tag_THUMB_ISA_use. + for i in 0..e_shnum { + let Some((sh_type, off, size)) = section(i) else { + continue; + }; + if sh_type == 0x7000_0003 + && let Some(data) = elf.get(off..off + size) + && let Some(thumb_isa) = parse_aeabi_thumb_isa_use(data) + { + return Some(thumb_isa > 0); + } + } + + // 2. STT_FUNC symbols: the odd-address Thumb interworking convention. + for i in 0..e_shnum { + let Some((sh_type, off, size)) = section(i) else { + continue; + }; + if sh_type != 2 { + continue; // not SHT_SYMTAB + } + let mut saw_func = false; + for s in 0..size / 16 { + let base = off + s * 16; + if base + 16 > elf.len() { + break; + } + let st_value = u32le(base + 4); + let st_info = elf[base + 12]; + let st_shndx = u16le(base + 14); + // Defined STT_FUNC only (undefined symbols carry no Thumb bit). + if st_info & 0xf == 2 && st_shndx != 0 { + if st_value & 1 == 1 { + return Some(true); + } + saw_func = true; + } + } + if saw_func { + return Some(false); // FUNC symbols present, all even ⇒ A32 + } + } + + // 3. e_entry Thumb bit (ET_EXEC images), else A32. + Some(u32le(24) & 1 == 1) +} + +/// Parse an `.ARM.attributes` blob for the file-scope `Tag_THUMB_ISA_use` (9) +/// value in the `"aeabi"` vendor subsection. Tolerant: any malformed structure +/// yields `None` (the caller falls back to the symbol-table heuristic). +fn parse_aeabi_thumb_isa_use(data: &[u8]) -> Option { + fn uleb(data: &[u8], pos: &mut usize) -> Option { + let mut v: u32 = 0; + let mut shift = 0u32; + loop { + let byte = *data.get(*pos)?; + *pos += 1; + v |= u32::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Some(v); + } + shift += 7; + if shift > 28 { + return None; + } + } + } + + if *data.first()? != b'A' { + return None; + } + let mut pos = 1usize; + while pos + 4 <= data.len() { + // Vendor subsection: u32 length (self-inclusive) + NTBS name + data. + let sub_start = pos; + let sub_len = u32::from_le_bytes(data.get(pos..pos + 4)?.try_into().ok()?) as usize; + let sub_end = sub_start.checked_add(sub_len)?; + if sub_len < 4 || sub_end > data.len() { + return None; + } + pos += 4; + let name_end = data[pos..sub_end].iter().position(|&b| b == 0)? + pos; + let vendor = &data[pos..name_end]; + pos = name_end + 1; + if vendor != b"aeabi" { + pos = sub_end; + continue; + } + // Subsubsections: uleb tag + u32 length (self-inclusive) + contents. + while pos < sub_end { + let ss_start = pos; + let tag = uleb(data, &mut pos)?; + let ss_len = u32::from_le_bytes(data.get(pos..pos + 4)?.try_into().ok()?) as usize; + pos += 4; + let ss_end = ss_start.checked_add(ss_len)?; + if ss_end > sub_end || ss_end < pos { + return None; + } + if tag != 1 { + pos = ss_end; // only Tag_File carries file-scope attributes + continue; + } + // Attribute pairs. String-valued tags are NTBS; the rest uleb. + while pos < ss_end { + let attr_tag = uleb(data, &mut pos)?; + // Per the addenda: tag 4 (CPU_raw_name), 5 (CPU_name), 32 + // (compatibility), 65 (also_compatible_with), 67 (conformance) + // carry strings; everything else we care about is uleb. + if matches!(attr_tag, 4 | 5 | 32 | 65 | 67) { + let nul = data[pos..ss_end].iter().position(|&b| b == 0)?; + pos += nul + 1; + } else { + let value = uleb(data, &mut pos)?; + if attr_tag == 9 { + return Some(value); // Tag_THUMB_ISA_use + } + } + } + pos = ss_end; + } + pos = sub_end; + } + None +} + fn disasm_command(input: PathBuf) -> Result<()> { use std::process::Command; @@ -4195,9 +4411,18 @@ fn disasm_command(input: PathBuf) -> Result<()> { info!("Disassembling: {}", input.display()); - // Try objdump with ARM triple (works on macOS with Apple LLVM) + // #637: auto-detect Thumb vs A32 for EM_ARM inputs — synth's primary + // Cortex-M output is Thumb-2, and decoding it as A32 produced garbage. + let bytes = std::fs::read(&input).context("Failed to read input file")?; + let triple = match detect_arm_thumb(&bytes) { + Some(true) => "thumbv7m-none-eabi", + Some(false) | None => "arm-none-eabi", + }; + info!("Detected triple: {}", triple); + + // Try objdump with the detected triple (works on macOS with Apple LLVM) let output = Command::new("objdump") - .args(["-d", "--triple=arm-none-eabi"]) + .args(["-d", &format!("--triple={triple}")]) .arg(&input) .output() .context("Failed to run objdump. Is it installed?")?; @@ -4736,6 +4961,10 @@ fn build_cortex_m_elf( .with_section(4); elf_builder.add_symbol(func_sym); + // #637: target-derived `.ARM.attributes` (appended last; `.text` stays 4, + // and the LOAD-segment vaddr auto-correct still matches `.text` first). + elf_builder.add_section(arm_build_attributes(target)); + elf_builder.build().context("ELF generation failed") } @@ -5372,7 +5601,7 @@ mod tests { linear_memory_bytes, Some(native), None, - true, + &TargetSpec::cortex_m3(), ) .expect("#345: native-pointer zero-linmem object builds"); @@ -5470,8 +5699,16 @@ mod tests { sp_init: 65_536, shadow_stack_size: None, }; - let elf = build_relocatable_elf(&[func], &[], &[], 131_072, Some(native), None, true) - .expect("#345: native-pointer literal-pool object builds"); + let elf = build_relocatable_elf( + &[func], + &[], + &[], + 131_072, + Some(native), + None, + &TargetSpec::cortex_m3(), + ) + .expect("#345: native-pointer literal-pool object builds"); let header = object::elf::FileHeader32::::parse(&*elf).expect("valid ELF32"); let endian = header.endian().expect("endian"); @@ -5562,7 +5799,7 @@ mod tests { 131_072, Some(native), None, - true, + &TargetSpec::cortex_m3(), ) .expect("#354: mixed-case object builds"); diff --git a/crates/synth-cli/tests/elf_tooling_637_656.rs b/crates/synth-cli/tests/elf_tooling_637_656.rs new file mode 100644 index 00000000..3c1b3be4 --- /dev/null +++ b/crates/synth-cli/tests/elf_tooling_637_656.rs @@ -0,0 +1,343 @@ +//! #637 + #656 — ELF tooling/linkability oracles. +//! +//! #637: synth's ARM objects must be SELF-DESCRIBING: every EM_ARM output +//! carries a target-derived `.ARM.attributes` (Tag_CPU_arch / profile / +//! Tag_THUMB_ISA_use), and `synth disasm` auto-detects Thumb (attributes → +//! STT_FUNC thumb bit → e_entry) instead of defaulting to A32 — which +//! mis-decoded synth's own Cortex-M output into garbage mnemonics. +//! +//! #656: internal (non-exported) functions and the `func_{wasm_index}` call +//! aliases are per-object labels by wasm function INDEX. Emitted STB_GLOBAL, +//! two independently-dissolved objects both define `func_2` and +//! `ld -r a.o b.o` fails with multiple-definition collisions. They must be +//! STB_LOCAL (exports stay STB_GLOBAL), which drags in the ELF ordering rule: +//! all local symbols precede globals and `.symtab` `sh_info` = index of the +//! first non-local (was hardcoded 1 — the #430 blocker). Relocations reference +//! symbols by index, so the #167/#173 R_ARM_THM_CALL machinery must be +//! reindexed consistently across the sort — asserted here by resolving each +//! `.rel.text` entry back to its symbol and checking name + address. +//! +//! The frozen-codegen gate (`frozen_codegen_bytes.rs`) proves `.text` is +//! untouched — this PR changes symtab bindings/order and appends a section. + +use std::path::PathBuf; +use std::process::Command; + +use object::elf; +use object::read::elf::{FileHeader, SectionHeader, Sym}; +use object::{Endianness, Object, ObjectSection}; + +fn synth() -> &'static str { + env!("CARGO_BIN_EXE_synth") +} + +/// Compile `wat` for cortex-m3 `--all-exports --relocatable` into `out`. +fn compile(dir: &std::path::Path, name: &str, wat: &str) -> PathBuf { + let src = dir.join(format!("{name}.wat")); + std::fs::write(&src, wat).unwrap(); + let out = dir.join(format!("{name}.o")); + let status = Command::new(synth()) + .args([ + "compile", + src.to_str().unwrap(), + "-t", + "cortex-m3", + "--all-exports", + "--relocatable", + "-o", + out.to_str().unwrap(), + ]) + .output() + .expect("run synth"); + assert!( + status.status.success(), + "synth compile failed: {}", + String::from_utf8_lossy(&status.stderr) + ); + out +} + +/// A module with one EXPORTED function calling one INTERNAL (non-exported) +/// helper — the #656 shape. `body` differentiates the two objects' bodies. +fn module_with_internal_helper(export: &str, mul: u32) -> String { + format!( + r#"(module + (func $helper (param i32) (result i32) (i32.mul (local.get 0) (i32.const {mul}))) + (func (export "{export}") (param i32) (result i32) (call $helper (local.get 0))))"# + ) +} + +/// Parsed symtab entry: (name, st_value, binding, type, defined). +type SymEntry = (String, u32, u8, u8, bool); + +fn read_symbols(bytes: &[u8]) -> (Vec, u32) { + let header = elf::FileHeader32::::parse(bytes).expect("valid ELF32"); + let endian = header.endian().unwrap(); + let sections = header.sections(endian, bytes).unwrap(); + let (symtab_index, _symtab) = sections + .iter() + .enumerate() + .find(|(_, s)| s.sh_type(endian) == elf::SHT_SYMTAB) + .expect("symtab present"); + let sh_info = sections.iter().nth(symtab_index).unwrap().sh_info(endian); + let symbols = sections + .symbol_table_by_index(endian, bytes, object::SectionIndex(symtab_index)) + .expect("parse symtab"); + let strings = symbols.strings(); + let syms = symbols + .iter() + .map(|sym| { + ( + String::from_utf8_lossy(sym.name(endian, strings).unwrap_or(b"")).into_owned(), + sym.st_value(endian), + sym.st_bind(), + sym.st_type(), + sym.st_shndx(endian) != elf::SHN_UNDEF, + ) + }) + .collect(); + (syms, sh_info) +} + +/// #656: internal helpers + `func_N` aliases are STB_LOCAL, exports stay +/// STB_GLOBAL, locals precede globals, and `sh_info` = first-non-local index. +#[test] +fn internal_funcs_local_exports_global_sh_info_656() { + let dir = std::env::temp_dir().join("synth_656_bindings"); + std::fs::create_dir_all(&dir).unwrap(); + let obj = compile( + &dir, + "gpio", + &module_with_internal_helper("gpio_configure", 3), + ); + let bytes = std::fs::read(&obj).unwrap(); + let (syms, sh_info) = read_symbols(&bytes); + + let find = |name: &str| { + syms.iter() + .find(|s| s.0 == name) + .unwrap_or_else(|| panic!("symbol {name} missing")) + }; + // Internal helper (wasm index 0, never exported) — LOCAL. + let helper = find("func_0"); + assert_eq!( + helper.2, + elf::STB_LOCAL, + "#656: internal func_0 must be STB_LOCAL" + ); + // The exported function's `func_1` call alias — LOCAL. + let alias = find("func_1"); + assert_eq!( + alias.2, + elf::STB_LOCAL, + "#656: func_N call alias must be STB_LOCAL" + ); + // The export — GLOBAL, same address as its alias. + let export = find("gpio_configure"); + assert_eq!(export.2, elf::STB_GLOBAL, "export stays STB_GLOBAL"); + assert_eq!(export.1, alias.1, "alias and export share the address"); + + // Ordering rule: all locals precede all non-locals; sh_info = first global. + let first_global = syms + .iter() + .position(|s| s.2 != elf::STB_LOCAL) + .expect("has a global symbol"); + assert!( + syms[first_global..].iter().all(|s| s.2 != elf::STB_LOCAL), + "no LOCAL symbol may follow a global" + ); + assert_eq!( + sh_info, first_global as u32, + "`.symtab` sh_info must be the first-non-local index (was hardcoded 1, #430)" + ); + assert!(sh_info > 1, "this object has real locals"); +} + +/// #656: the R_ARM_THM_CALL machinery (#167/#173) must survive the +/// locals-first reindex — resolve each `.rel.text` entry to its symbol and +/// check it lands on the LOCAL `func_0` helper at the right address. +#[test] +fn relocations_reindexed_consistently_656() { + let dir = std::env::temp_dir().join("synth_656_relocs"); + std::fs::create_dir_all(&dir).unwrap(); + let obj = compile( + &dir, + "gpio", + &module_with_internal_helper("gpio_configure", 3), + ); + let bytes = std::fs::read(&obj).unwrap(); + let (syms, _) = read_symbols(&bytes); + + let header = elf::FileHeader32::::parse(&*bytes).expect("valid ELF32"); + let endian = header.endian().unwrap(); + let sections = header.sections(endian, &*bytes).unwrap(); + let rel_section = sections + .iter() + .find(|s| s.sh_type(endian) == elf::SHT_REL) + .expect(".rel.text present"); + let (rels, _link) = rel_section + .rel(endian, &*bytes) + .expect("parse rel") + .expect("REL entries"); + assert_eq!(rels.len(), 1, "one internal BL call site"); + let rel = &rels[0]; + assert_eq!( + rel.r_type(endian), + 10, // R_ARM_THM_CALL (object's elf module names it R_ARM_THM_PC22) + "Thumb BL uses R_ARM_THM_CALL (#167)" + ); + let sym = &syms[rel.r_sym(endian) as usize]; + assert_eq!(sym.0, "func_0", "BL resolves against the internal helper"); + assert_eq!(sym.2, elf::STB_LOCAL, "which is LOCAL after #656"); + assert!(sym.4, "and defined in this object"); + assert_eq!(sym.1 & !1, 0, "helper is the first function in .text"); +} + +/// #656 kill-criterion: co-link two independently-dissolved objects, each +/// with its own internal `func_0` + `func_1` alias. Pre-#656 this failed with +/// `multiple definition of 'func_N'`. Uses `arm-none-eabi-ld -r` or `ld.lld -r` +/// when available; the binding/sh_info asserts above are the CI-stable check. +#[test] +fn co_link_two_dissolved_objects_656() { + let dir = std::env::temp_dir().join("synth_656_colink"); + std::fs::create_dir_all(&dir).unwrap(); + let a = compile( + &dir, + "gpio", + &module_with_internal_helper("gpio_configure", 3), + ); + let b = compile(&dir, "spi", &module_with_internal_helper("spi_begin", 5)); + + let linker = ["arm-none-eabi-ld", "ld.lld"].iter().find(|ld| { + Command::new(ld) + .arg("--version") + .output() + .is_ok_and(|o| o.status.success()) + }); + let Some(linker) = linker else { + eprintln!("skip: no arm-none-eabi-ld / ld.lld on PATH — bindings asserted elsewhere"); + return; + }; + let merged = dir.join("merged.o"); + let out = Command::new(linker) + .args(["-r", "-o", merged.to_str().unwrap()]) + .arg(&a) + .arg(&b) + .output() + .expect("run linker"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + out.status.success() && !stderr.contains("multiple definition"), + "#656: co-linking two dissolved objects must not collide: {stderr}" + ); + + // Exports from BOTH objects still resolve cross-object. + let bytes = std::fs::read(&merged).unwrap(); + let (syms, _) = read_symbols(&bytes); + for export in ["gpio_configure", "spi_begin"] { + let s = syms + .iter() + .find(|s| s.0 == export) + .unwrap_or_else(|| panic!("{export} missing after merge")); + assert_eq!(s.2, elf::STB_GLOBAL); + assert!(s.4, "{export} defined in merged object"); + } + // Both objects' internal helpers survive as (duplicate-named) LOCALs. + let local_helpers = syms + .iter() + .filter(|s| s.0 == "func_0" && s.2 == elf::STB_LOCAL) + .count(); + assert_eq!(local_helpers, 2, "each object keeps its own local func_0"); +} + +/// #637: every fresh cortex-m3 object carries `.ARM.attributes` +/// (SHT_ARM_ATTRIBUTES) with Tag_CPU_arch=v7, profile=M, Tag_THUMB_ISA_use=2. +#[test] +fn arm_attributes_emitted_for_thumb_object_637() { + let dir = std::env::temp_dir().join("synth_637_attrs"); + std::fs::create_dir_all(&dir).unwrap(); + let obj = compile( + &dir, + "gpio", + &module_with_internal_helper("gpio_configure", 3), + ); + let bytes = std::fs::read(&obj).unwrap(); + + let file = object::File::parse(&*bytes).expect("parse ELF"); + let attrs = file + .section_by_name(".ARM.attributes") + .expect("#637: .ARM.attributes section must be emitted"); + let data = attrs.data().unwrap(); + assert_eq!(data[0], b'A', "attributes format version"); + // 'A' + u32 len + "aeabi\0" + Tag_File(1) + u32 len + pairs. + assert_eq!(&data[5..11], b"aeabi\0"); + let pairs = &data[16..]; + // (6, v7=10), (7, 'M'), (9, Thumb-2=2) — Tag_ARM_ISA_use omitted (0). + assert_eq!(pairs, &[6, 10, 7, b'M', 9, 2]); + + // Section type is SHT_ARM_ATTRIBUTES so readelf/objdump find it. + let header = elf::FileHeader32::::parse(&*bytes).unwrap(); + let endian = header.endian().unwrap(); + let sections = header.sections(endian, &*bytes).unwrap(); + assert!( + sections + .iter() + .any(|s| s.sh_type(endian) == elf::SHT_ARM_ATTRIBUTES), + "sh_type must be SHT_ARM_ATTRIBUTES (0x70000003)" + ); +} + +/// #637: `synth disasm` on a fresh cortex-m3 object prints Thumb-2 mnemonics +/// (auto-detected), not the A32 mis-decode. The fixture is the #682 masked +/// shift — `i32.shl` of `(i32.and (local.get 1) (i32.const 31))` — whose +/// lowering folds the mask and emits a register `lsl`; the prologue/epilogue +/// `push {..., lr}` / `pop {..., pc}` pair is the unmistakable Thumb signature +/// (the historical A32 mis-decode printed `andhs`/`andeq` garbage). Skips when +/// no `--triple`-capable objdump is on PATH (bare CI runner, #489 lesson). +#[test] +fn disasm_prints_thumb_mnemonics_637() { + let has_objdump = Command::new("objdump") + .args(["--version"]) + .output() + .is_ok_and(|o| o.status.success()); + if !has_objdump { + eprintln!("skip: no objdump on PATH"); + return; + } + + let dir = std::env::temp_dir().join("synth_637_disasm"); + std::fs::create_dir_all(&dir).unwrap(); + let obj = compile( + &dir, + "maskshift", + r#"(module (func (export "f") (param i32 i32) (result i32) + (i32.shl (local.get 0) (i32.and (local.get 1) (i32.const 31)))))"#, + ); + let out = Command::new(synth()) + .arg("disasm") + .arg(&obj) + .output() + .expect("run synth disasm"); + if !out.status.success() { + // objdump exists but choked (e.g. GNU objdump without --triple AND + // without ARM support) — the detection itself is unit-covered. + eprintln!( + "skip: synth disasm failed on this host: {}", + String::from_utf8_lossy(&out.stderr) + ); + return; + } + let text = String::from_utf8_lossy(&out.stdout).to_lowercase(); + assert!( + text.contains("push") && text.contains("pop"), + "#637: expected Thumb prologue/epilogue mnemonics, got:\n{text}" + ); + assert!( + text.contains("lsl"), + "#637/#682: masked-shift fixture must show the register shift:\n{text}" + ); + assert!( + !text.contains("andhs") && !text.contains("andeq"), + "#637: the A32 mis-decode signature must be gone:\n{text}" + ); +}