Skip to content

fbuild build: --shrink[=MODE] (default auto) for per-platform flash reduction via spec-file printf swap #493

@zackees

Description

@zackees

Summary

Add fbuild build --shrink[=MODE] (default auto) and --no-shrink to reduce flash size on a per-platform basis. Auto-resolution is conservative — it picks off on platforms with no measured win and safe on platforms with a documented shrinker. Default behavior on supported platforms (Arduino-ESP32 IDF 5.x newlib, STM32, NRF52, RP2040/RP2350, ESP8266, AVR, ESP32-C3) saves 6–24 KB of flash per sketch with zero behavior change.

Linking is via a GCC spec file (printf-thin.specs) that inserts a precompiled shadow archive (libprintf_thin.a) before -lc. This is naturally LTO-safe and composes with nano.specs. --wrap is the fallback used only for --shrink=printf single-knob debugging.

Supersedes #492.


CLI surface

On Build, Deploy, TestEmu, Ci subcommands:

// crates/fbuild-cli/src/cli/args.rs

#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ShrinkMode { #[default] Auto, Off, Safe, Aggressive, Printf }

/// Aggressive flash-size reduction. MODE selects how hard fbuild tries:
///   auto        (default) decide from framework + IDF version + libc probe
///   off         no shrinking; use stock libc, full symbol set
///   safe        no-behavior-change wins (printf-thin + scanf-thin + stdio FILE strip)
///   aggressive  safe + behavior-altering (esp_err_msg strip, coredump disable, -Oz)
///   printf      single-knob: only the printf-family wrap (debuggable A/B)
#[arg(long, value_enum, num_args = 0..=1, default_missing_value = "auto")]
pub shrink: Option<ShrinkMode>,

/// Disable all shrink optimizations. Equivalent to --shrink=off.
#[arg(long = "no-shrink", conflicts_with = "shrink")]
pub no_shrink: bool,

--shrink with no value resolves to auto. --no-shrink overrides any auto-enable from project config or framework detection.


Auto-resolver matrix

Platform libc auto resolves to Est. flash saved
arduino-esp32 / IDF 5.x newlib (Xtensa: S2/S3/D0WD) newlib safe -24 KB
arduino-esp32 / IDF 5.x newlib (RISC-V: C3) newlib safe -6 KB
arduino-esp32 / IDF 5.x (C6/H2) newlib off n/a (ROM full-printf)
arduino-esp32 / IDF 6.x (any) picolibc off n/a (picolibc default)
AVR (atmega328p/32u4/2560, attiny series) avr-libc safe -1.0 to -1.7 KB (printf_min downgrade after static scan)
STM32 (f103/f411/f429/h747) ARM newlib safe -8 KB
NRF52 (nrf52840/833, Adafruit cores) ARM newlib safe -10 KB
RP2040 / RP2350 newlib + pico-sdk safe -6 to -12 KB (pico-sdk printf(compiler) + picolibc)
Teensy 2.0 (AVR) avr-libc safe per AVR rule
Teensy 3+/4+ (Cortex-M) ARM newlib off n/a (Print.cpp bypasses vfprintf)
ESP8266 xtensa-lx106 newlib safe -10 to -12 KB
Unknown / probe fails n/a off fail-closed

Linking strategy

Primary: spec-file + shadow archive

fbuild emits ~/.fbuild/cache/printf-thin/<toolchain-hash>/printf-thin.specs at first build and adds --specs=<path>/printf-thin.specs to the link line. The spec rewrites *libc: to insert -lprintf_thin before -lc:

*libc:
%{!shared-libgcc:--start-group -L${printf_thin_dir} -lprintf_thin -lc -lnosys --end-group}

libprintf_thin.a is a complete shadow of newlib's vfprintf.o + svfprintf.o co-exports — every symbol newlib pulls those objects in for: vfprintf, _vfprintf_r, _printf_r, printf, __sbprintf, _svfprintf_r, vsnprintf, _vsnprintf_r, svfprintf, sprintf, etc. Linker resolves all to our archive during the --start-group scan; newlib's vfprintf.o/svfprintf.o never get pulled.

The archive contains vendored picolibc tinystdio (~8 files from newlib/libc/tinystdio/: vfprintf.c, vfscanf.c, dtoa_engine.c, dtoa.h, stdio_private.h, formatter-spec headers) compiled with -fno-lto, _NEED_IO_LONG_LONG=1, _NEED_IO_DOUBLE=1, io-float-exact=false, no C99/positional/wchar. Plus thin shim functions that wrap each entry point with a stack-allocated cookie FILE whose lock is pre-bound to __lock___libc_recursive_mutex (ESPHome's lazy-mutex-leak fix).

Why this is the right primary path:

  • Naturally LTO-safe. Native archive; sketch bitcode references vfprintf as external; LTO has no bitcode to inline.
  • Clean stack traces. Frames show vfprintf; addr2line resolves to vendored picolibc source.
  • Composes with --specs=nano.specs cumulatively.
  • Mirrors how Espressif (nano.specs), ARM (picolibc.specs), and Newlib itself ship printf variants.

Cost: a per-newlib-version symbol manifest (~50 lines listing each printf-related symbol newlib co-exports). Newlib's stdio layout has been stable for ~15 years; the manifest changes once every few years and is covered by a CI nm-diff test.

Fallback: -Wl,--wrap= (used only by --shrink=printf)

For single-knob debugging via --shrink=printf, use:

-Wl,--wrap=vfprintf,--wrap=vprintf,--wrap=printf,--wrap=fprintf
-Wl,--wrap=vsnprintf,--wrap=snprintf,--wrap=vsprintf,--wrap=sprintf
-Wl,--wrap=fiprintf,--wrap=vfiprintf

The fiprintf/vfiprintf entries cover __assert_func (newlib's integer-only assert path). LTO safety requires (1) vendored picolibc compiled -fno-lto, (2) stub functions marked __attribute__((noinline, externally_visible)).

Linker scripts cannot fix LTO+--wrap interactions because they run after LTO inlining. The spec-file approach sidesteps this entirely.


Bundled appliers per mode

Mode Applier Mechanism
printf printf-thin (vfprintf/vprintf/printf/fprintf/vsnprintf/snprintf/vsprintf/sprintf/fiprintf/vfiprintf) --wrap fallback
safe printf-thin + scanf-thin + stdio FILE strip + drop unused C++ exception personality spec-file (shadow archive includes vfscanf)
aggressive safe + esp_err_msg_table strip + coredump disable + -Oz (GCC ≥ 12) safe + sdkconfig overrides + compile flag
off / --no-shrink none none
auto resolves to one of the above per the matrix per the matrix

Scanf-thin and stdio FILE strip are bundled into the shadow archive at no additional implementation cost — same vendored sources, same --specs integration, additional ~5 KB savings when sketches use sscanf/atof/String::toInt.

ESP32 sdkconfig stack knobs under --shrink=aggressive (CONFIG_BT_CLASSIC_ENABLED=n, CONFIG_BT_BLE_50_FEATURES_SUPPORTED=n, CONFIG_LWIP_PPP_SUPPORT=n) live in a per-platform aggressive registry; trigger a sdkconfig regeneration and ESP-IDF cache rebuild. On detection of explicit CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y in user sdkconfig, aggressive mode errors out with "explicit coredump enable detected; use --shrink=safe or remove the config".

Baseline flags already applied across all fbuild platform configs (universal, not new appliers — but surface in reporting): -fno-rtti, -fno-exceptions, -ffunction-sections, -fdata-sections, -Wl,--gc-sections, -fno-unwind-tables, -fno-asynchronous-unwind-tables (via eh_frame_policy.rs).


Reporting

--shrink=auto (default, no flag)

Single green one-liner listing the symbol families being shadowed, sourced from a per-platform registry. Omitted entirely when the platform has no auto-shrink to apply:

0.12 auto shrinking: vfprintf, vsnprintf, snprintf, printf, vprintf, fprintf, vsprintf, sprintf   <- green

When auto resolves to off: no line.

When --no-shrink: Shrink: --no-shrink (user override; auto would have applied: printf-thin).

--shrink=safe|aggressive|printf (explicit)

Verbose multi-line plan with checkboxes — explicit intent earns explicit reporting:

0.12 Shrink: aggressive (explicit)
0.12   [x] printf-thin (spec-file shadow archive, est. -24 KB flash)
0.12   [x] scanf-thin (bundled with printf-thin)
0.12   [x] stdio FILE strip (est. -312 B RAM, -1 KB flash)
0.12   [x] esp_err_msg_table strip (est. -1.8 KB RAM)
0.12   [x] coredump strip (est. -3.0 KB flash, -1.9 KB RAM)
0.12   [x] -Oz (GCC 14.2 ≥ 12)
...
341.48 Flash:  302.10KB / 8.00MB (3.7%)  [stock: 329.78KB; saved 27.7 KB]
341.48 Shrink result: flash -27.7 KB (-8.4 %), ram -4.8 KB (-0.7 %)

Use owo-colors = "4" (auto-disables on non-tty + respects NO_COLOR). The auto shrinking: prefix is green().bold(); the symbol list is plain green(). Verbose plan uses default color with a single bold first line.

Per-platform shrinker registry

pub struct AutoShrinkEntry {
    pub category: &'static str,           // "printf-thin", "esp_err_msg", etc.
    pub symbols: &'static [&'static str], // printed in the green one-liner
}

Empty registry entry → no green line, silent. Adding a new shrinker on a platform appends to its entry and the symbols automatically appear in the green one-liner.


Configuration

platformio.ini

[env:esp32s3]
platform = espressif32
board = esp32-s3-devkitc-1
framework = arduino
fbuild_shrink = aggressive       ; off | safe | aggressive | printf | auto
fbuild_shrink_skip = coredump    ; optional escape hatch, comma-separated

Precedence

lowest → highest: platform default → platformio.ini → CLI flag. --no-shrink always wins.

Auto-disables

  • --platformio compat mode: shrink auto-disabled (byte-identical PIO output expected). Logged at debug level.
  • CONFIG_LIBC_NEWLIB_NANO_FORMAT=y detected in resolved sdkconfig: shrink auto-downgrades to off. Picolibc shadow (~6 KB) is net-negative against nano vfprintf (~2 KB). Logged: "shrink: disabled (newlib-nano already in use)".
  • Libc probe fails: defaults to off and logs warning. Probe is a 5-line TU with #ifdef __PICOLIBC__\n#error PICOLIBC\n#endif inspected via preprocessor exit code. Never guesses.

Implementation layout

crates/fbuild-build/src/shrink/
  mod.rs                       ShrinkMode, ShrinkPlan, auto-resolver
  probe.rs                     fail-closed libc probe (__PICOLIBC__ test TU)
  registry.rs                  per-platform AutoShrinkEntry table
  printf_thin/                 vendored picolibc tinystdio sources (-fno-lto always)
    vfprintf.c                 from picolibc/newlib/libc/tinystdio/
    vfscanf.c
    dtoa_engine.c
    ...
    COPYING.picolibc
    COPYING.NEWLIB
    NOTICE
  newlib_stdio_manifest.rs     per-newlib-version map of vfprintf.o / svfprintf.o co-exports
  shadow_archive.rs            builds libprintf_thin.a (complete shadow + shims with __lock___libc_recursive_mutex pre-binding)
  spec_emitter.rs              emits printf-thin.specs per toolchain hash
  wrap_fallback.rs             --wrap flag set + noinline,externally_visible stub TU (for --shrink=printf)
  applier.rs                   translates ShrinkMode → compile/link flags, sdkconfig overrides

Touch points:

  • crates/fbuild-cli/src/cli/args.rsShrinkMode enum, --shrink / --no-shrink flags on Build/Deploy/TestEmu/Ci. Per-subcommand (mirrors --clean/--environment/--verbose precedent).
  • crates/fbuild-build/src/build_fingerprint/mod.rs — include ShrinkMode + resolved applier set in fingerprint so toggling shrink invalidates the link cache.
  • crates/fbuild-build/src/build_info.rs — record shrink: { mode, resolved, applied: [...], strategy: "spec-file" | "wrap" }.
  • crates/fbuild-build/src/compile_many.rs:121 — extend seed_stage2_core_from_stage1 to include libprintf_thin.a + spec file so multi-sketch CI builds amortize printf-thin in stage 1.
  • crates/fbuild-packages/src/library/esp32_framework/sdk_paths.rs — IDF version detection via tools/esp32-arduino-libs/{mcu}/include/esp_common/include/esp_idf_version.h for ESP_IDF_VERSION_MAJOR; cache in Esp32Framework.
  • Cargo.toml — add owo-colors = "4" for the green reporting.

Acceptance

  • fbuild build --shrink and fbuild build --no-shrink both work; --shrink=auto|off|safe|aggressive|printf parses.
  • Default mode (no flag) on tests/platform/esp32s3 -e esp32s3 resolves to safe, prints the green one-liner, and drops flash by ≥ 18 KB (target; measured ceiling ~24 KB).
  • Default mode on tests/platform/uno -e uno resolves to safe (printf_min downgrade) when the sketch has no %f/%lld; resolves to off (the user keeps full printf) when it does.
  • Default mode on Arduino-ESP32 IDF 6.x resolves to off (no green line printed).
  • A clean build with --shrink=safe produces a firmware where xtensa-esp-elf-nm shows no _vfprintf_r / _svfprintf_r; _dtoa_r absent unless the sketch calls strtod / dtostrf.
  • build_info.json records the resolved mode, applied appliers, and link strategy.
  • --no-shrink produces a byte-for-byte identical (modulo timestamp) ELF to the pre---shrink build.
  • CI test diffs newlib's nm output against newlib_stdio_manifest.rs; fails on drift so the shadow archive stays complete across toolchain bumps.
  • CI test on a String-heavy sketch (FastLED Apa102 example uses String += float) confirms _dtoa_r is still present and float formatting is intact after --shrink=safe.
  • CI test confirms snprintf(NULL, 0, "...") does not clobber errno after the shadow archive is in place.
  • ESP32 panic-path test (trigger esp_panic) confirms the shadow is not reached from IRAM/cache-disabled context.
  • --platformio flag auto-disables shrink, produces byte-identical PlatformIO-compat output.
  • Spec-file path is naturally LTO-safe — a sketch with -flto in build_flags builds correctly with --shrink=safe and the shadow is honored.
  • Toggling --shrink between builds correctly invalidates the link cache; second build re-links.

Documented caveats (for BREAKING_CHANGES.md)

  • Banker's vs half-up rounding: picolibc dtoa-engine uses round-half-up; newlib _dtoa_r uses banker's rounding. Sketches with golden-output tests will diverge. Recommend --no-shrink for byte-exact reproduction.
  • Float quality: picolibc tinystdio with io-float-exact=false is slightly less accurate than newlib's _dtoa_r on edge cases (off-by-1-ulp on a tiny fraction of values). Fine for embedded logging; not safe for code that depends on exact IEEE round-trip from snprintfstrtod.
  • Newlib symbol-list drift: if newlib reshuffles vfprintf.o/svfprintf.o co-exports, our shadow misses a symbol and the linker pulls newlib's full object back in. Mitigation is the CI nm-diff test; the manifest is per-newlib-version.

Related


Implementation phases

Eight phases, each independently mergeable, ordered by dependency. Phases 1–5 ship the ESP32-S3 path end-to-end behind explicit opt-in; phase 6 turns on auto-default and rolls out platforms; phases 7–8 add aggressive mode and polish. Sub-phases inside a phase can land as separate PRs.

Phase 0 — Prerequisites

Goal: clear the runway. No user-facing behavior.

Scope:

  • Land fbuild bloat: emit and preserve firmware.map automatically #491 (firmware.map auto-emit) so shrink reports can show archive-attributed deltas. Hard dependency for phase 4 acceptance criteria.
  • Add owo-colors = "4" to crates/fbuild-cli/Cargo.toml (auto-disables on non-tty + NO_COLOR).
  • Create empty crates/fbuild-build/src/shrink/ directory with a mod.rs stub and a README.md. Wire into lib.rs.

Pass: cargo build --workspace --all-targets clean; cargo clippy --workspace --all-targets -- -D warnings clean.

Size: 1 PR, <50 LOC of fbuild code (most of the PR is #491).


Phase 1 — CLI + auto-resolver + reporting (no-op linking)

Goal: user-facing surface lands. Every invocation is a no-op at the link level; the shrink machinery decides and reports but doesn't change link flags yet. Safe to default-on because the resolver returns off for every platform in this phase.

Scope:

  • crates/fbuild-cli/src/cli/args.rs: ShrinkMode enum + --shrink[=MODE] / --no-shrink on Build, Deploy, TestEmu, Ci. default_missing_value = "auto", conflicts_with = "shrink" on --no-shrink.
  • crates/fbuild-build/src/shrink/probe.rs: fail-closed libc probe — compile a 5-line TU with #ifdef __PICOLIBC__\n#error PICOLIBC\n#endif, inspect preprocessor exit. Cache result per toolchain hash. On any failure → return Probe::Unknown.
  • crates/fbuild-build/src/shrink/registry.rs: AutoShrinkEntry { category, symbols } table. All entries empty for v1.
  • crates/fbuild-build/src/shrink/mod.rs: ShrinkPlan { applied: Vec<Applier>, skipped: Vec<(Applier, Reason)>, strategy: Strategy }. Auto-resolver function that returns ShrinkMode::Off for every platform (real resolution lands in phase 4+).
  • crates/fbuild-build/src/shrink/reporting.rs: green one-liner (empty registry → no line); verbose plan for explicit modes; --no-shrink / --platformio overrides logged.
  • crates/fbuild-build/src/build_info.rs: record shrink: { mode, resolved, applied: [], strategy: null } in build_info.json. Empty in phase 1.
  • Auto-disable conditions wired but not yet exercising appliers: --platformio → force off; CONFIG_LIBC_NEWLIB_NANO_FORMAT=y in resolved sdkconfig → force off with log.

Out of scope: any actual flag / file emission; LTO interaction; shadow archive; wrap fallback.

Tests:

  • Unit: CLI parse for all five modes + --no-shrink.
  • Unit: probe TU compiles and returns expected Probe::Newlib / Probe::Picolibc / Probe::Unknown.
  • Integration: fbuild build tests/platform/esp32s3 -e esp32s3 --shrink=safe prints "Shrink: safe (explicit)" + empty plan, builds identically to today.

Pass: every existing test still passes byte-for-byte. --no-shrink and --shrink=off print exactly one line each; --shrink=auto (default) prints nothing because all registries are empty.

Size: ~600 LOC, 1 PR.


Phase 2 — Shadow archive build (no link integration)

Goal: libprintf_thin.a produced as a build artifact; can be inspected with nm but isn't yet linked into any firmware.

Scope:

  • crates/fbuild-build/src/shrink/printf_thin/ — vendor picolibc tinystdio sources from picolibc/newlib/libc/tinystdio/: vfprintf.c, vfscanf.c, dtoa_engine.c, dtoa.h, stdio_private.h, formatter-spec headers. Include COPYING.picolibc, COPYING.NEWLIB, NOTICE.
  • crates/fbuild-build/src/shrink/shadow_archive.rs — shim TUs: each entry point (vfprintf, _vfprintf_r, _printf_r, printf, __sbprintf, _svfprintf_r, vsnprintf, _vsnprintf_r, svfprintf, sprintf, vsprintf, _sprintf_r, _vsprintf_r, fprintf, vprintf, fiprintf, vfiprintf) sets up a stack-allocated cookie FILE whose lock is pre-bound to __lock___libc_recursive_mutex (ESPHome's lazy-mutex-leak fix), then forwards to picolibc's __d_vfprintf. Guarded by #ifdef __PICOLIBC__ #error #endif.
  • build.rs invocation via cc::Build that produces libprintf_thin.a per target triple. Compiled with -fno-lto -Os -ffunction-sections -fdata-sections -D_NEED_IO_LONG_LONG=1 -D_NEED_IO_DOUBLE=1 -DPICOLIBC_FLOAT_PRINTF_SCANF=double -D_NEED_IO_C99_FORMATS=0 -D_NEED_IO_POS_ARGS=0 -D_NEED_IO_WCHAR=0 -D_NEED_IO_LONG_DOUBLE=0 -D_PICOLIBC_PRINTF=__d_vfprintf.

Out of scope: spec-file emission; integration into orchestrator; turning shrink on.

Tests:

  • Build: archive compiles for xtensa-esp-elf toolchain.
  • Symbol: xtensa-esp-elf-nm libprintf_thin.a lists every symbol in the manifest (phase 3 defines the manifest; in phase 2 we hand-check a known list).
  • Size: archive is ≤ 8 KB on Xtensa.

Pass: archive build is reproducible across runs (same byte hash from same sources); nm confirms expected entry points present and no undefined symbols outside libc.

Size: ~1500 LOC vendored + ~200 LOC shim, 1 PR.


Phase 3 — Newlib stdio symbol manifest + CI drift detection

Goal: catch drift between our shadow archive and newlib's actual vfprintf.o / svfprintf.o co-exports before it bites us in production.

Scope:

  • crates/fbuild-build/src/shrink/newlib_stdio_manifest.rs — per-toolchain map: { toolchain_id: { vfprintf_o: [symbols], svfprintf_o: [symbols] } }. Initial entries: xtensa-esp-elf-14.2.0_* (Arduino-ESP32 IDF 5.x newlib), arm-none-eabi-13.x (STM32/NRF52/RP2040), xtensa-lx106-elf-3.x (ESP8266).
  • CI test (crates/fbuild-build/tests/newlib_manifest_drift.rs) that walks each installed toolchain's libc.a, runs nm, and diffs against the manifest. Fails on any drift (missing symbol or new symbol). Provides a clear "update the manifest at X" hint.
  • Documentation: crates/fbuild-build/src/shrink/MANIFEST.md — how to regenerate a manifest entry when a toolchain bumps.

Out of scope: actually using the manifest to build the archive (still hand-coded in phase 2; phase 4 wires the manifest into archive generation).

Tests: CI runs against all toolchains in the test matrix; passes when manifest is in sync.

Pass: manifest is the authoritative source of truth; if a developer adds a new toolchain support, the CI test forces them to add a manifest entry.

Size: ~300 LOC manifest data + ~150 LOC test, 1 PR.


Phase 4 — ESP32-S3 end-to-end (first platform)

Goal: first measurable shrink. fbuild build tests/platform/esp32s3 -e esp32s3 (no flag) saves ≥ 18 KB by default.

Scope:

  • crates/fbuild-build/src/shrink/spec_emitter.rs — emit ~/.fbuild/cache/printf-thin/<toolchain-hash>/printf-thin.specs on first build per toolchain. Cache key includes toolchain hash + manifest version + picolibc source hash.
  • Wire --specs=<path>/printf-thin.specs -L<path> into the ESP32 link orchestrator (crates/fbuild-build/src/esp32/orchestrator/build.rs link step).
  • Turn on registry entry for arduino-esp32 IDF 5.x newlib Xtensa (AutoShrinkEntry listing the 8 wrap targets + scanf symbols).
  • Auto-resolver returns safe for arduino-esp32 IDF 5.x newlib Xtensa; still off everywhere else.
  • crates/fbuild-build/src/build_fingerprint/mod.rs — include ShrinkMode + resolved applier set in fingerprint. Toggling shrink invalidates the link cache (object cache stays valid).
  • crates/fbuild-build/src/compile_many.rs:121 — extend seed_stage2_core_from_stage1 to include libprintf_thin.a + spec file.
  • IDF version detection via tools/esp32-arduino-libs/{mcu}/include/esp_common/include/esp_idf_version.h for ESP_IDF_VERSION_MAJOR; cache in Esp32Framework.
  • build_info.json records strategy: "spec-file" + the applier list.

Out of scope: STM32 / NRF52 / RP2040 / ESP8266 / AVR / C3 (phase 6); aggressive mode (phase 7); --wrap fallback (phase 5).

Tests:

  • Acceptance: fbuild build tests/platform/esp32s3 -e esp32s3 drops flash by ≥ 18 KB (target ~24 KB).
  • Symbol: nm shows no _vfprintf_r / _svfprintf_r; _dtoa_r absent (sketch doesn't call strtod).
  • A/B: --no-shrink build is byte-for-byte identical (modulo timestamp) to pre-shrink baseline.
  • Cache: toggling --shrink between two builds correctly re-links (verified by mtime + symbol diff).
  • Errno: snprintf(NULL, 0, "...") does not clobber errno (small unit-test sketch).
  • Panic path: trigger esp_panic; shadow not reached (verified by addr2line on the panic frame).
  • String sketch: FastLED Apa102 example with String += float shows _dtoa_r still linked, float formatting intact.

Pass: green one-liner appears on default build; flash savings ≥ 18 KB; all symbol / errno / panic / String tests pass.

Size: ~800 LOC + acceptance tests, 1 PR.


Phase 5 — --wrap fallback for --shrink=printf

Goal: single-knob A/B debugging path lands. Implementation is a parallel mechanism; spec-file remains primary.

Scope:

  • crates/fbuild-build/src/shrink/wrap_fallback.rs-Wl,--wrap= flag set (vfprintf, vprintf, printf, fprintf, vsnprintf, snprintf, vsprintf, sprintf, fiprintf, vfiprintf). Stub TU with __attribute__((noinline, externally_visible)) on each __wrap_*. Picolibc TUs already -fno-lto from phase 2.
  • Wire into orchestrator: --shrink=printf → use wrap path instead of spec-file path. build_info.json records strategy: "wrap".
  • Auto-resolver does NOT pick wrap path for any platform — wrap is debug-only.

Tests: --shrink=printf on ESP32-S3 produces equivalent ELF (same symbol delta, within 200 bytes flash) to --shrink=safe. Differences attributable to wrap overhead are documented.

Pass: wrap path is exercised in CI but not the default for any platform.

Size: ~200 LOC + test, 1 PR.


Phase 6 — Per-platform rollout

Goal: turn on auto = safe for the remaining ~7 platforms with measured wins, one sub-PR per platform.

Scope (one PR each):

Sub-PR Platform Mechanism Target savings
6a STM32 ARM picolibc shadow + spec-file (same path as ESP32-S3) -8 KB
6b NRF52 ARM picolibc shadow + spec-file -10 KB
6c RP2040/RP2350 (a) pico_set_printf_implementation(compiler), (b) ARM picolibc shadow + spec-file -6 to -12 KB
6d ESP8266 xtensa-lx106 picolibc shadow + spec-file -10 KB
6e ESP32-C3 (IDF 5.x RISC-V) RISC-V picolibc shadow + spec-file -6 KB
6f AVR (special case) Static format-string scan + swap -lprintf_min for -lprintf when no %f/%lld -1.0 to -1.7 KB

Per sub-PR:

  • Add manifest entry (uses phase 3's CI test to catch drift)
  • Add platform-specific archive build target
  • Add registry entry with the symbols printed in the green one-liner
  • Acceptance test on a representative sketch (tests/platform/<platform>)
  • Update auto-resolver to return safe for this platform

AVR sub-PR is structurally different — no shadow archive, just a linker flag swap based on a static scan of all printf-family format-string literals in the sketch TUs (reuses fbuild-header-scan).

Out of scope: Teensy 3+/4+, ESP32-C6/H2, IDF 6.x — these stay at off (documented in matrix).

Tests: per platform, acceptance test runs in CI matrix.

Pass: each platform's default build drops flash by its target; green one-liner appears; no behavior regressions.

Size: ~200–400 LOC per sub-PR; 6 PRs total in this phase.


Phase 7 — Aggressive mode appliers

Goal: --shrink=aggressive enables behavior-altering wins behind explicit opt-in. Not part of auto.

Scope (each its own sub-PR):

  • 7a — -Oz (GCC ≥ 12): gate on toolchain GCC version probe; replace -Os with -Oz in compile flags. Toolchain probe at fbuild orchestrator startup.
  • 7b — ESP32 sdkconfig stack knobs: per-MCU registry of CONFIG_BT_CLASSIC_ENABLED=n, CONFIG_BT_BLE_50_FEATURES_SUPPORTED=n, CONFIG_LWIP_PPP_SUPPORT=n, etc. Triggers ESP-IDF cache rebuild. Detect explicit user enables and skip them (don't override).
  • 7c — esp_err_msg_table strip: linker-script EXCLUDE_FILE or --wrap=esp_err_to_name to return "ESP_ERR(0x%x)" formatted from the code. Document the human-readable-name loss.
  • 7d — Coredump strip: sdkconfig override CONFIG_ESP_COREDUMP_ENABLE=n. Detect explicit CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y in user sdkconfig.defaults; on detection, error with "explicit coredump enable detected; use --shrink=safe or remove the config". Silent strip of debug feature is wrong default.
  • 7e — Drop unused C++ exception personality: __gxx_personality_v0 already mostly dead via -fno-exceptions; aggressive mode adds -Wl,--gc-sections enforcement on the eh-frame and personality symbols.

Each sub-PR: registry entry + applier + acceptance test that measures the delta.

Tests: per applier, integration test on tests/platform/esp32s3 -e esp32s3 --shrink=aggressive measures expected delta; aggregate test confirms total aggressive delta is sum of individual deltas (no double-counting).

Pass: --shrink=aggressive saves ≥ 27 KB total on tests/platform/esp32s3; explicit-coredump test errors cleanly; loud failure when sdkconfig regeneration fails.

Size: ~150–300 LOC per sub-PR; 5 PRs in this phase.


Phase 8 — Documentation, breaking-change notice, release prep

Goal: ship-ready polish.

Scope:

  • BREAKING_CHANGES.md entry: default-on --shrink=auto changes Arduino-ESP32 IDF 5.x firmware behavior (smaller, picolibc float formatting). Direct users to --no-shrink for byte-exact reproduction.
  • docs/SHRINK.md — user-facing doc: what the modes do, when to use each, troubleshooting (toolchain probe failures, manifest drift, wrap-vs-spec).
  • Update README.md and CLAUDE.md (project root) to mention --shrink in the commands section.
  • Release notes draft for the version that ships phase 4 (initial) and the version that ships phase 6 (per-platform rollout).
  • crates/fbuild-build/src/shrink/README.md — developer doc on the architecture, where to add a new shrinker, manifest update workflow.

Out of scope: blog post, external announcements.

Tests: docs review only.

Pass: docs accurately reflect shipped behavior; release notes ready.

Size: docs only, 1 PR.


Dependency graph

Phase 0  -->  Phase 1  -->  Phase 2  -->  Phase 3  -->  Phase 4  -->  Phase 5
                                                            |             |
                                                            +--> Phase 6 -+
                                                                          |
                                                                          +-->  Phase 7  -->  Phase 8

Phase 5 (wrap fallback) and Phase 6 (per-platform) can run in parallel after phase 4. Phase 6 sub-PRs (6a–6f) can run in parallel with each other. Phase 7 sub-PRs (7a–7e) can run in parallel after phase 6 has at least the ESP32 platforms landed.

Total scope estimate

  • LOC: ~5,000 (40% vendored picolibc, 20% manifests/data, 40% Rust glue + tests)
  • PRs: ~16 (one per phase + sub-phases)
  • Calendar time: phases 1–4 are critical path (~2 weeks); phases 5–7 fan out (~3 weeks parallel)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions