Skip to content

Commit b7560aa

Browse files
zackeesclaude
andauthored
fix(avr): archive framework objects with gcc-ar (fixes #304) (#306)
On AVR, an empty FastLED Blink sketch built with fbuild was ~10% larger than the same sketch built with PlatformIO. Symbol diff on attiny85 attributed the entire 342-byte delta to three symbols from `Tone.cpp` in the ATtinyCore framework (`__vector_3`, `__vector_1`, `noTone`) that PlatformIO drops as unreferenced archive members but that fbuild was pulling in because it linked framework `.o` files directly. The mechanism: `.o` files passed positionally to `avr-gcc` are always kept by the linker. Section-GC doesn't help because the ISRs override AVR libc's weak `__vector_default` and are reachable from the AVR interrupt vector table. `noTone` and its static state come along for the ride. Fix: partition link inputs into existing `.a` archives (pass-through) and raw `.o` objects, archive the raw objects into `libframework.a` with `avr-gcc-ar`, and feed only archives to the linker. Archive- member-selection drops members whose strong symbols are unreferenced before the vector table sees them — matches PlatformIO's behaviour. `gcc-ar` (not plain `ar`) is required because the default avr.json profile enables `-flto -fuse-linker-plugin`; `gcc-ar` writes the LTO bytecode plugin index so the linker can still resolve LTO objects. Patch shape: - `AvrLinker` gains a `gcc_ar_path: PathBuf` field; constructor takes it; orchestrator passes `toolchain.get_gcc_ar_path()` (already exposed alongside `get_ar_path` and already used by `pick_archiver`). - `partition_link_inputs` factored to module scope so the partition logic is unit-tested without invoking real `avr-gcc-ar`. - `link()` does the archive-then-link dance; `build_link_args` stays a pure argv builder. Tests: - `link_partitions_archives_and_objects` — mixed input partitioning - `partition_handles_all_archives`, `_all_objects`, `_empty`, `_treats_unknown_extensions_as_objects` - `test_avr_linker_creation` updated for new `gcc_ar_path` field Out of scope: same shape likely affects Teensy / RP2040 / STM32, but the symbol-diff evidence is AVR-only and broader changes need platform-by-platform fixture validation. Tracked separately if reproductions surface. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8f7910a commit b7560aa

2 files changed

Lines changed: 120 additions & 5 deletions

File tree

crates/fbuild-build/src/avr/avr_linker.rs

Lines changed: 116 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use crate::linker::{LinkExtraArgs, Linker};
1515
pub struct AvrLinker {
1616
gcc_path: PathBuf,
1717
ar_path: PathBuf,
18+
gcc_ar_path: PathBuf,
1819
objcopy_path: PathBuf,
1920
size_path: PathBuf,
2021
mcu: String,
@@ -30,6 +31,7 @@ impl AvrLinker {
3031
pub fn new(
3132
gcc_path: PathBuf,
3233
ar_path: PathBuf,
34+
gcc_ar_path: PathBuf,
3335
objcopy_path: PathBuf,
3436
size_path: PathBuf,
3537
mcu: &str,
@@ -42,6 +44,7 @@ impl AvrLinker {
4244
Self {
4345
gcc_path,
4446
ar_path,
47+
gcc_ar_path,
4548
objcopy_path,
4649
size_path,
4750
mcu: mcu.to_string(),
@@ -54,11 +57,32 @@ impl AvrLinker {
5457
}
5558
}
5659

60+
/// Partition a mixed list of link inputs into (existing archives, raw objects).
61+
///
62+
/// Inputs ending in `.a` (case-sensitive — matches the toolchain convention) are
63+
/// real static archives and should be passed straight to the linker. Everything
64+
/// else (typically `.o` files coming from the framework compile step) is treated
65+
/// as a raw object that needs to be wrapped in an archive so the linker pulls in
66+
/// only members whose strong symbols are referenced.
67+
///
68+
/// Lives at module scope so unit tests can exercise the partition logic without
69+
/// invoking `avr-gcc-ar`.
70+
pub(crate) fn partition_link_inputs(inputs: &[PathBuf]) -> (Vec<PathBuf>, Vec<PathBuf>) {
71+
let (existing_archives, raw_objects): (Vec<PathBuf>, Vec<PathBuf>) = inputs
72+
.iter()
73+
.cloned()
74+
.partition(|p| p.extension().and_then(|e| e.to_str()) == Some("a"));
75+
(existing_archives, raw_objects)
76+
}
77+
5778
impl AvrLinker {
5879
/// Build the argv that will be passed to `avr-gcc` for the link step.
5980
///
6081
/// Factored out so it can be unit-tested without invoking the toolchain
61-
/// (see #305 — assert that `-Wl,-Map=` is present).
82+
/// (see #305 — assert that `-Wl,-Map=` is present). `archives` is the
83+
/// already-partitioned archive list (real `.a` files + any framework
84+
/// archive synthesised by `link()` for #304); this function does not
85+
/// touch the filesystem.
6286
fn build_link_args(
6387
&self,
6488
objects: &[PathBuf],
@@ -92,8 +116,11 @@ impl AvrLinker {
92116
args.push(obj.to_string_lossy().to_string());
93117
}
94118

95-
// Core objects passed directly (not archived) for LTO compatibility
96-
// Python fbuild does the same: archive breaks LTO symbol visibility
119+
// Archives (already partitioned by `link()`): the framework archive
120+
// synthesised from raw `.o` framework objects + any real `.a` files
121+
// passed in. Archive-member selection drops unreferenced members so
122+
// unused-but-not-eliminable ISRs (e.g. Tone.cpp __vector_1/3 on AVR)
123+
// no longer get pulled in. See FastLED/fbuild#304.
97124
for archive in archives {
98125
args.push(archive.to_string_lossy().to_string());
99126
}
@@ -123,7 +150,33 @@ impl Linker for AvrLinker {
123150
std::fs::create_dir_all(output_dir)?;
124151
let elf_path = output_dir.join("firmware.elf");
125152

126-
let args = self.build_link_args(objects, archives, output_dir, &elf_path, extra);
153+
// Partition: real `.a` archives are pass-through; raw `.o` files get
154+
// archived into `libframework.a` so the linker only pulls in members
155+
// whose strong symbols are referenced. Without this, unused-but-not-
156+
// eliminable ISRs (e.g. Tone.cpp __vector_1/__vector_3 in the AVR
157+
// framework) are dragged in via weak-symbol override of
158+
// __vector_default, inflating the binary by ~10% on attiny85.
159+
// See FastLED/fbuild#304.
160+
//
161+
// Use gcc-ar (not plain ar) so the LTO bytecode plugin index is
162+
// written — preserves -fuse-linker-plugin compatibility, which the
163+
// default avr.json profile enables via `-flto -fuse-linker-plugin`.
164+
let (existing_archives, raw_objects) = partition_link_inputs(archives);
165+
let mut linker_archives: Vec<PathBuf> =
166+
Vec::with_capacity(existing_archives.len() + usize::from(!raw_objects.is_empty()));
167+
if !raw_objects.is_empty() {
168+
let framework_archive = output_dir.join("libframework.a");
169+
crate::linker::LinkerBase::archive(
170+
&self.gcc_ar_path,
171+
&raw_objects,
172+
&framework_archive,
173+
"avr-gcc-ar",
174+
)?;
175+
linker_archives.push(framework_archive);
176+
}
177+
linker_archives.extend(existing_archives);
178+
179+
let args = self.build_link_args(objects, &linker_archives, output_dir, &elf_path, extra);
127180

128181
if self.verbose {
129182
eprintln!("link: {}", args.join(" "));
@@ -191,6 +244,7 @@ mod tests {
191244
let linker = AvrLinker::new(
192245
PathBuf::from("/bin/avr-gcc"),
193246
PathBuf::from("/bin/avr-ar"),
247+
PathBuf::from("/bin/avr-gcc-ar"),
194248
PathBuf::from("/bin/avr-objcopy"),
195249
PathBuf::from("/bin/avr-size"),
196250
"atmega328p",
@@ -203,6 +257,63 @@ mod tests {
203257
assert_eq!(linker.mcu, "atmega328p");
204258
assert_eq!(linker.max_flash, Some(32256));
205259
assert_eq!(linker.max_ram, Some(2048));
260+
assert_eq!(linker.gcc_ar_path, PathBuf::from("/bin/avr-gcc-ar"));
261+
}
262+
263+
#[test]
264+
fn link_partitions_archives_and_objects() {
265+
// Regression test for FastLED/fbuild#304: link() must split mixed
266+
// .a/.o inputs so .o files are archived (and thus subject to
267+
// unreferenced-member elimination by the linker) while real .a
268+
// files pass through unchanged.
269+
let inputs = vec![
270+
PathBuf::from("/tmp/Tone.cpp.o"),
271+
PathBuf::from("/tmp/libfoo.a"),
272+
PathBuf::from("/tmp/main.cpp.o"),
273+
];
274+
let (archives, objects) = partition_link_inputs(&inputs);
275+
assert_eq!(archives, vec![PathBuf::from("/tmp/libfoo.a")]);
276+
assert_eq!(
277+
objects,
278+
vec![
279+
PathBuf::from("/tmp/Tone.cpp.o"),
280+
PathBuf::from("/tmp/main.cpp.o"),
281+
]
282+
);
283+
}
284+
285+
#[test]
286+
fn partition_handles_all_archives() {
287+
let inputs = vec![PathBuf::from("/tmp/a.a"), PathBuf::from("/tmp/b.a")];
288+
let (archives, objects) = partition_link_inputs(&inputs);
289+
assert_eq!(archives.len(), 2);
290+
assert!(objects.is_empty());
291+
}
292+
293+
#[test]
294+
fn partition_handles_all_objects() {
295+
let inputs = vec![PathBuf::from("/tmp/x.o"), PathBuf::from("/tmp/y.o")];
296+
let (archives, objects) = partition_link_inputs(&inputs);
297+
assert!(archives.is_empty());
298+
assert_eq!(objects.len(), 2);
299+
}
300+
301+
#[test]
302+
fn partition_handles_empty() {
303+
let (archives, objects) = partition_link_inputs(&[]);
304+
assert!(archives.is_empty());
305+
assert!(objects.is_empty());
306+
}
307+
308+
#[test]
309+
fn partition_treats_unknown_extensions_as_objects() {
310+
// Defensive: anything that isn't .a (e.g. .lo, .obj, no extension)
311+
// is treated as an object and archived. This is safer than letting
312+
// an unknown extension fall through to a raw avr-gcc positional arg.
313+
let inputs = vec![PathBuf::from("/tmp/weird.lo"), PathBuf::from("/tmp/no_ext")];
314+
let (archives, objects) = partition_link_inputs(&inputs);
315+
assert!(archives.is_empty());
316+
assert_eq!(objects.len(), 2);
206317
}
207318

208319
/// #305: every per-platform linker must emit a `firmware.map` next to
@@ -212,6 +323,7 @@ mod tests {
212323
let linker = AvrLinker::new(
213324
PathBuf::from("/bin/avr-gcc"),
214325
PathBuf::from("/bin/avr-ar"),
326+
PathBuf::from("/bin/avr-gcc-ar"),
215327
PathBuf::from("/bin/avr-objcopy"),
216328
PathBuf::from("/bin/avr-size"),
217329
"atmega328p",

crates/fbuild-build/src/avr/orchestrator.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,10 +237,13 @@ impl BuildOrchestrator for AvrOrchestrator {
237237
.with_build_unflags(ctx.build_unflags.clone())
238238
.with_eh_frame_policy(eh_frame_policy);
239239

240-
// 7. Create linker
240+
// 7. Create linker — pass gcc-ar so framework .o inputs can be
241+
// archived into libframework.a with the LTO bytecode plugin index
242+
// intact (preserves `-fuse-linker-plugin`). See FastLED/fbuild#304.
241243
let linker = AvrLinker::new(
242244
toolchain.get_gcc_path(),
243245
toolchain.get_ar_path(),
246+
toolchain.get_gcc_ar_path(),
244247
toolchain.get_objcopy_path(),
245248
toolchain.get_size_path(),
246249
&ctx.board.mcu,

0 commit comments

Comments
 (0)