Skip to content

Commit ea5ee43

Browse files
avrabeclaude
andauthored
fix(elf): #354 — per-region .bss/.data split for high-offset init segments (#356)
The #345 zero-init split was binary (split_linmem_bss = native_layout && data_segments.is_empty()): ANY initialized (data) segment fell to the one-PROGBITS arm, so a small .rodata const at a HIGH linmem offset (gale's stack_push: a 12-byte -ENOMEM const at 65536, above the 64 KiB shadow stack) dragged the whole zero gap into a 65552-byte PROGBITS .data — MCU-unshippable. Add a third "mixed-split" case in build_relocatable_elf using per-region SYMBOLS (link-survivable, unlike a naive per-section split — a single __synth_wasm_data base + selector-baked addends can't span independently-placed sections): - zero reservation -> NOBITS .bss (__synth_wasm_data = 0) - each init segment packed into a small PROGBITS .data under its own __synth_wasm_seg_K symbol - every __synth_wasm_data + C static reloc whose addend C lands in segment K is retargeted to __synth_wasm_seg_K + (C - seg_off_K): both the symbol AND the in-place REL addend word in .text (R_ARM_ABS32 is S+A, A in the word). SAFE-BY-CONSTRUCTION GATE: fire only when every init segment sits at offset >= wasm_data_base (the SP-global init — the same boundary the selector uses for static-vs-frame) AND every __synth_wasm_data reloc is the retargetable Abs32 form. The shadow stack is reached only via the SP register value (dynamic, never a static reloc with an addend in that range), so per-region symbols can't mis-address anything the selector doesn't already assume separable. If the gate fails, fall back to the existing one-PROGBITS arm (fat but always correct). This is the mixed-case deferred in the #345 code comment coming due — NOT a v0.11.44 regression; #350 just made stack_push compile far enough to expose it. Verification: - scripts/repro/high_offset_init_segment_354.wat: .data 65552 -> 16 B; .bss 65548 NOBITS. Reloc retargets to __synth_wasm_seg_0; in-place addend rewritten 65544 -> 8 (readelf -r/-s/-x confirmed). New unit test mixed_high_offset_segment_splits_per_region_354. - native_pointer_shadow_stack (a 2nd bug instance): 4104 B PROGBITS .data -> .bss=4100 NOBITS + .data=8; differential ORACLE PASS (const 42 loads via the retargeted symbol). - Byte-IDENTICAL to main: control_step (non-native) + mutex_pressure (#345 all-zero split). Four frozen differentials PASS (control_step 0x00210A55, flight_seam 0x07FDF307, div_const 338/338, mutex_pressure). - synth-cli suite 27+32 green; fmt + clippy -D warnings clean. On-target gate (gale): stack/msgq .data bounded + stack_pop shim+silicon, to run when v0.11.45 tags. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 501a6e9 commit ea5ee43

1 file changed

Lines changed: 302 additions & 14 deletions

File tree

crates/synth-cli/src/main.rs

Lines changed: 302 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2309,13 +2309,14 @@ fn build_relocatable_elf(
23092309
all_code.extend_from_slice(&func.code);
23102310
}
23112311

2312-
let text_section = Section::new(".text", ElfSectionType::ProgBits)
2313-
.with_flags(SectionFlags::ALLOC | SectionFlags::EXEC)
2314-
.with_addr(0) // Relocatable: addresses start at 0
2315-
.with_align(4)
2316-
.with_data(all_code);
2317-
2318-
elf_builder.add_section(text_section);
2312+
// #354: the `.text` section is built + added LATER (just before the
2313+
// `.bss`/`.data` sections, so it stays section index 4). The per-region
2314+
// split (below) may need to rewrite in-place `R_ARM_ABS32` literal-pool
2315+
// addend words inside `all_code` first — those are REL (the linker computes
2316+
// `S + A` with `A` stored in the data word), so retargeting a static-data
2317+
// reloc from `__synth_wasm_data + C` to `__synth_wasm_seg_K + (C - seg_off)`
2318+
// must patch the word before `.text` is frozen. `all_code` is kept mutable
2319+
// until then.
23192320

23202321
// #237: if any function addresses a wasm static via `__synth_wasm_data`
23212322
// (the --native-pointer-abi path), emit the whole wasm linear memory as one
@@ -2426,8 +2427,141 @@ fn build_relocatable_elf(
24262427
// emission, the two symbol defs, and the `.meld_import_table` index off one
24272428
// `split_linmem_bss` flag so they cannot diverge.
24282429
let split_linmem_bss = native_layout.is_some() && data_segments.is_empty();
2430+
2431+
// #354: per-region split for the MIXED case (native-pointer ABI WITH an
2432+
// initialized `(data)` segment). The #345 split only handled the all-zero
2433+
// case; any init segment fell to the one-PROGBITS arm, so a small `.rodata`
2434+
// const at a HIGH linmem offset (gale's stack_push: a 12-byte -ENOMEM const
2435+
// at 65536, above the 64 KiB shadow stack) dragged the whole zero gap into a
2436+
// 65552-byte PROGBITS `.data` (MCU-unshippable). Split per region instead:
2437+
// the zero reservation is `.bss` (NOBITS, `__synth_wasm_data`); each init
2438+
// segment is packed into a small PROGBITS `.data` under its own
2439+
// `__synth_wasm_seg_K` symbol; and every `__synth_wasm_data + C` static-data
2440+
// reloc whose addend C lands in segment K is retargeted to
2441+
// `__synth_wasm_seg_K + (C - seg_off_K)` (symbol + in-place REL addend word).
2442+
//
2443+
// SAFE-BY-CONSTRUCTION GATE: fire only when every init segment sits in the
2444+
// static-data region (offset >= wasm_data_base = the SP-global init, the
2445+
// same boundary the selector uses to classify addresses >= base as static
2446+
// relocs vs `<` as frame offsets) AND every `__synth_wasm_data` reloc is the
2447+
// retargetable literal-pool `Abs32` form (the live form post-#345-step-2).
2448+
// The shadow stack is reached only via the SP register value (dynamic, never
2449+
// a static reloc with an addend in that range), so per-region symbols cannot
2450+
// mis-address anything the selector doesn't already assume separable. If the
2451+
// gate fails, fall back to the one-PROGBITS arm (fat but always correct).
2452+
let wasm_data_base: u32 = native_layout
2453+
.as_ref()
2454+
.map(|ng| ng.sp_init.max(0) as u32)
2455+
.unwrap_or(0);
2456+
let all_static_data_abs32 = funcs.iter().flat_map(|f| &f.relocations).all(|r| {
2457+
r.symbol != "__synth_wasm_data" || matches!(r.kind, synth_core::backend::RelocKind::Abs32)
2458+
});
2459+
let mixed_separable = native_layout.is_some()
2460+
&& !data_segments.is_empty()
2461+
&& all_static_data_abs32
2462+
&& data_segments.iter().all(|(off, _)| *off >= wasm_data_base);
2463+
// Packed layout of the init segments inside the mixed-case `.data`: each
2464+
// segment 4-aligned in declaration order (i32 loads stay aligned — seg_off
2465+
// is 4-aligned in wasm and the packed start is 4-aligned), then the globals
2466+
// slots. (packed_off per segment, globals packed offset, total .data size).
2467+
let mixed_layout: Option<(Vec<u32>, u32, u32)> = if mixed_separable {
2468+
let mut packed = Vec::with_capacity(data_segments.len());
2469+
let mut cur = 0u32;
2470+
for (_off, d) in data_segments {
2471+
cur = cur.next_multiple_of(4);
2472+
packed.push(cur);
2473+
cur += d.len() as u32;
2474+
}
2475+
let globals_off = cur.next_multiple_of(4);
2476+
Some((packed, globals_off, globals_off + globals_bytes))
2477+
} else {
2478+
None
2479+
};
2480+
let do_mixed_split = mixed_layout.is_some();
2481+
if native_layout.is_some() && !data_segments.is_empty() && !do_mixed_split {
2482+
info!(
2483+
"Native-pointer linmem: init (data) segment not separable (below base \
2484+
{wasm_data_base} or non-Abs32 static reloc); keeping one PROGBITS \
2485+
.data (correct, not per-region split) — #354 fallback"
2486+
);
2487+
}
2488+
2489+
// #354: retarget map + in-place REL addend patch for the mixed case. Keyed
2490+
// by (func index, reloc offset) -> (new symbol, new addend). The addend C is
2491+
// read from the in-place `.text` literal word (Abs32 is REL — `S + A`).
2492+
let mut retarget: HashMap<(usize, u32), (String, i32)> = HashMap::new();
2493+
if do_mixed_split {
2494+
for (i, func) in funcs.iter().enumerate() {
2495+
for reloc in &func.relocations {
2496+
if reloc.symbol != "__synth_wasm_data"
2497+
|| !matches!(reloc.kind, synth_core::backend::RelocKind::Abs32)
2498+
{
2499+
continue;
2500+
}
2501+
let pos = (func_offsets[i] + reloc.offset) as usize;
2502+
if pos + 4 > all_code.len() {
2503+
continue;
2504+
}
2505+
let c = u32::from_le_bytes([
2506+
all_code[pos],
2507+
all_code[pos + 1],
2508+
all_code[pos + 2],
2509+
all_code[pos + 3],
2510+
]);
2511+
if let Some(k) = data_segments
2512+
.iter()
2513+
.position(|(off, d)| c >= *off && c < *off + d.len() as u32)
2514+
{
2515+
let new_addend = (c - data_segments[k].0) as i32;
2516+
retarget.insert(
2517+
(i, reloc.offset),
2518+
(format!("__synth_wasm_seg_{k}"), new_addend),
2519+
);
2520+
all_code[pos..pos + 4].copy_from_slice(&new_addend.to_le_bytes());
2521+
}
2522+
}
2523+
}
2524+
}
2525+
2526+
// #354/#345: build + add `.text` (section index 4) now — after any in-place
2527+
// addend patch and before the `.bss`/`.data` sections, so `.text` keeps its
2528+
// index and the patched literal words are what ship.
2529+
let text_section = Section::new(".text", ElfSectionType::ProgBits)
2530+
.with_flags(SectionFlags::ALLOC | SectionFlags::EXEC)
2531+
.with_addr(0)
2532+
.with_align(4)
2533+
.with_data(all_code);
2534+
elf_builder.add_section(text_section);
2535+
24292536
if emit_wasm_data {
2430-
if split_linmem_bss {
2537+
if do_mixed_split {
2538+
// #354: zero reservation -> NOBITS `.bss` (section 5),
2539+
// `__synth_wasm_data` = 0; init segments packed into a small PROGBITS
2540+
// `.data` (section 6), each under its own `__synth_wasm_seg_K`.
2541+
let (packed, globals_off, data_size) = mixed_layout.as_ref().unwrap();
2542+
let bss = Section::new(".bss", ElfSectionType::NoBits)
2543+
.with_flags(SectionFlags::ALLOC | SectionFlags::WRITE)
2544+
.with_addr(0)
2545+
.with_align(4)
2546+
.with_size(used_extent);
2547+
elf_builder.add_section(bss);
2548+
let mut blob = vec![0u8; *data_size as usize];
2549+
for ((_off, d), &poff) in data_segments.iter().zip(packed.iter()) {
2550+
blob[poff as usize..poff as usize + d.len()].copy_from_slice(d);
2551+
}
2552+
if let Some(ng) = &native_layout {
2553+
for (idx, init) in &ng.globals {
2554+
let at = (*globals_off + idx * 4) as usize;
2555+
blob[at..at + 4].copy_from_slice(&init.to_le_bytes());
2556+
}
2557+
}
2558+
let data = Section::new(".data", ElfSectionType::ProgBits)
2559+
.with_flags(SectionFlags::ALLOC | SectionFlags::WRITE)
2560+
.with_addr(0)
2561+
.with_align(4)
2562+
.with_data(blob);
2563+
elf_builder.add_section(data);
2564+
} else if split_linmem_bss {
24312565
// #345: zero-init linmem reservation → NOBITS `.bss` (section 5).
24322566
let bss = Section::new(".bss", ElfSectionType::NoBits)
24332567
.with_flags(SectionFlags::ALLOC | SectionFlags::WRITE)
@@ -2550,12 +2684,31 @@ fn build_relocatable_elf(
25502684
elf_builder.add_symbol(data_sym);
25512685
sym_count += 1;
25522686
sym_indices.insert("__synth_wasm_data".to_string(), sym_count);
2553-
// #237/#345: the globals slot region. In the non-split (PROGBITS) case
2554-
// it sits right after the used extent inside section 5. In the #345
2555-
// split case the slots live in their own PROGBITS `.data` (section 6),
2556-
// at value 0.
2687+
// #354: per-region segment symbols. In the mixed split each init
2688+
// segment is packed into the `.data` section (index 6) at its packed
2689+
// offset; `__synth_wasm_seg_K` is its base, and the retargeted static
2690+
// relocs resolve against it (+ the rewritten in-place addend).
2691+
if let Some((packed, _goff, _sz)) = &mixed_layout {
2692+
for (k, &poff) in packed.iter().enumerate() {
2693+
let seg_sym = Symbol::new(&format!("__synth_wasm_seg_{k}"))
2694+
.with_value(poff)
2695+
.with_binding(SymbolBinding::Global)
2696+
.with_type(SymbolType::Object)
2697+
.with_section(6);
2698+
elf_builder.add_symbol(seg_sym);
2699+
sym_count += 1;
2700+
sym_indices.insert(format!("__synth_wasm_seg_{k}"), sym_count);
2701+
}
2702+
}
2703+
// #237/#345/#354: the globals slot region. In the non-split (PROGBITS)
2704+
// case it sits right after the used extent inside section 5. In the #345
2705+
// all-zero split and the #354 mixed split the slots live in the PROGBITS
2706+
// `.data` (section 6) — at value 0 (#345) or after the packed segments
2707+
// (#354).
25572708
if native_layout.is_some() {
2558-
let (gv, gsec) = if split_linmem_bss {
2709+
let (gv, gsec) = if let Some((_packed, goff, _sz)) = &mixed_layout {
2710+
(*goff, 6)
2711+
} else if split_linmem_bss {
25592712
(0, 6)
25602713
} else {
25612714
(used_extent, 5)
@@ -2613,7 +2766,14 @@ fn build_relocatable_elf(
26132766
for (i, func) in funcs.iter().enumerate() {
26142767
let func_base = func_offsets[i];
26152768
for reloc in &func.relocations {
2616-
let sym_idx = sym_indices[&reloc.symbol];
2769+
// #354: a static-data reloc retargeted to a per-region segment
2770+
// symbol resolves against that symbol; all others against their
2771+
// original symbol.
2772+
let sym_name = retarget
2773+
.get(&(i, reloc.offset))
2774+
.map(|(s, _)| s.as_str())
2775+
.unwrap_or(reloc.symbol.as_str());
2776+
let sym_idx = sym_indices[sym_name];
26172777
// #237: map the relocation kind. BL calls → R_ARM_THM_CALL; the
26182778
// symbol-relative static-data MOVW/MOVT → R_ARM_MOVW_ABS_NC/MOVT_ABS.
26192779
let reloc_type = match reloc.kind {
@@ -4004,6 +4164,134 @@ mod tests {
40044164
);
40054165
}
40064166

4167+
/// #354: a native-pointer module with an initialized `(data)` segment at a
4168+
/// HIGH linmem offset (above the shadow stack) must NOT ship the whole
4169+
/// `[0, used_extent)` image as one PROGBITS `.data` (65552 bytes for gale's
4170+
/// stack_push). It splits per region: the zero reservation is NOBITS `.bss`
4171+
/// (`__synth_wasm_data`), the init segment is packed into a tiny PROGBITS
4172+
/// `.data` under `__synth_wasm_seg_0`, and the static-data reloc is
4173+
/// retargeted (`__synth_wasm_data + C` -> `__synth_wasm_seg_0 + (C-seg_off)`,
4174+
/// symbol AND in-place REL addend word).
4175+
#[test]
4176+
fn mixed_high_offset_segment_splits_per_region_354() {
4177+
use object::Endianness;
4178+
use object::read::elf::{FileHeader, SectionHeader};
4179+
4180+
// The const is accessed by a literal-pool Abs32 load whose in-place word
4181+
// is the absolute linmem offset C = 65544 (8 bytes into a segment based
4182+
// at 65536 — gale's `\f4\ff\ff\ff` = -12/-ENOMEM at offset+8).
4183+
const C: u32 = 65_544;
4184+
const SEG_OFF: u32 = 65_536;
4185+
let mut code = vec![0u8; 4];
4186+
code[0..4].copy_from_slice(&C.to_le_bytes());
4187+
let func = ElfFunction {
4188+
name: "stack_push_decide".to_string(),
4189+
wasm_index: 0,
4190+
code,
4191+
relocations: vec![synth_core::backend::CodeRelocation {
4192+
offset: 0,
4193+
symbol: "__synth_wasm_data".to_string(),
4194+
kind: synth_core::backend::RelocKind::Abs32,
4195+
}],
4196+
};
4197+
// 12-byte init segment at the high offset, above the shadow stack.
4198+
let seg: Vec<u8> = vec![0, 0, 0, 0, 0, 0, 0, 0, 0xf4, 0xff, 0xff, 0xff];
4199+
let data_segments = vec![(SEG_OFF, seg)];
4200+
let native = NativeGlobalsLayout {
4201+
globals: vec![(0, 65_536)],
4202+
sp_init: 65_536,
4203+
};
4204+
4205+
let elf = build_relocatable_elf(&[func], &[], &data_segments, 131_072, Some(native))
4206+
.expect("#354: mixed-case object builds");
4207+
4208+
let header = object::elf::FileHeader32::<Endianness>::parse(&*elf).expect("valid ELF32");
4209+
let endian = header.endian().expect("endian");
4210+
let sections = header.sections(endian, &*elf).expect("sections");
4211+
4212+
let mut bss_size: Option<u64> = None;
4213+
let mut bss_is_nobits = false;
4214+
let mut data_size: Option<u64> = None;
4215+
let mut text_data: Vec<u8> = Vec::new();
4216+
for section in sections.iter() {
4217+
let name = sections
4218+
.section_name(endian, section)
4219+
.map(|n| String::from_utf8_lossy(n).into_owned())
4220+
.unwrap_or_default();
4221+
match name.as_str() {
4222+
".bss" => {
4223+
bss_is_nobits = section.sh_type(endian) == object::elf::SHT_NOBITS;
4224+
bss_size = Some(section.sh_size(endian).into());
4225+
}
4226+
".data" => data_size = Some(section.sh_size(endian).into()),
4227+
".text" => {
4228+
text_data = section.data(endian, &*elf).unwrap_or_default().to_vec();
4229+
}
4230+
_ => {}
4231+
}
4232+
}
4233+
4234+
// The zero reservation is a NOBITS .bss spanning the used extent.
4235+
let bss = bss_size.expect("#354: a .bss reservation must be present");
4236+
assert!(
4237+
bss_is_nobits,
4238+
"#354: the zero reservation must be SHT_NOBITS"
4239+
);
4240+
assert!(
4241+
bss >= 65_536,
4242+
"#354: .bss spans the zero gap (got {bss} bytes)"
4243+
);
4244+
// The PROGBITS .data is bounded to the packed segment + globals, NOT the
4245+
// 64 KiB image.
4246+
let data = data_size.expect("#354: a small PROGBITS .data must be present");
4247+
assert!(
4248+
data < 256,
4249+
"#354: .data is bounded to the init bytes, not the 64 KiB image (got {data} bytes)"
4250+
);
4251+
4252+
// The static-data reloc is retargeted to __synth_wasm_seg_0 — verified
4253+
// via the stable high-level API (avoids the low-level Sym trait, which is
4254+
// ambiguous with two `object` versions in the tree).
4255+
{
4256+
use object::{Object, ObjectSection, ObjectSymbol};
4257+
let file = object::File::parse(&*elf).expect("#354: parse ELF");
4258+
assert!(
4259+
file.symbols().any(|s| s.name() == Ok("__synth_wasm_seg_0")),
4260+
"#354: __synth_wasm_seg_0 symbol must be defined"
4261+
);
4262+
let mut retargeted = false;
4263+
for section in file.sections() {
4264+
for (_off, rel) in section.relocations() {
4265+
if let object::RelocationTarget::Symbol(idx) = rel.target()
4266+
&& file
4267+
.symbol_by_index(idx)
4268+
.ok()
4269+
.and_then(|s| s.name().ok().map(|n| n == "__synth_wasm_seg_0"))
4270+
.unwrap_or(false)
4271+
{
4272+
retargeted = true;
4273+
}
4274+
}
4275+
}
4276+
assert!(
4277+
retargeted,
4278+
"#354: the static-data reloc must retarget to __synth_wasm_seg_0"
4279+
);
4280+
}
4281+
4282+
// The in-place REL addend word in .text was rewritten C -> C - seg_off.
4283+
assert!(
4284+
text_data.len() >= 4,
4285+
"#354: .text must hold the pooled word"
4286+
);
4287+
let patched = u32::from_le_bytes([text_data[0], text_data[1], text_data[2], text_data[3]]);
4288+
assert_eq!(
4289+
patched,
4290+
C - SEG_OFF,
4291+
"#354: in-place addend rewritten to C-seg_off (8); link computes seg0_base+8 = the const"
4292+
);
4293+
}
4294+
40074295
/// Without a `call_indirect`, table entries are NOT force-included — a module
40084296
/// that never dispatches indirectly keeps the tight direct-call closure.
40094297
#[test]

0 commit comments

Comments
 (0)