Skip to content

fbuild build: add --shrink[=MODE] / --no-shrink for aggressive flash reduction #492

Closed as not planned

Description

@zackees

Motivation

A stock Arduino-ESP32 3.3.7 build of tests/platform/esp32s3 on this branch lands at 329,776 B flash, of which ~30 KB is newlib stdio that we could replace with picolibc's tinystdio while keeping %f/%lld/%e/%g support. Measured fan-out on the actual ELF:

Category Symbols Bytes
Printf core _vfprintf_r 11,413 + _svfprintf_r 11,222 + __sbprintf 149 + public wrappers (printf/vprintf/snprintf/vsnprintf/sprintf/fprintf 383) 23,167 flash
dtoa + mprec _dtoa_r 3,223 + __mprec_tens 200 + __mprec_bigtens 40 3,463 flash
Stdio plumbing __sfvwrite_r, __sflush_r, __ssprint_r, __srefill_r, _fseeko_r, _fopen_r, _fclose_r, _fflush_r, __sread/swrite/sseek/sclose/sinit 3,237 flash
vfprintf state-machine rodata __chclass 256 + __state_table 108 + __action_table 108 272 rodata
__sf (FILE[3]) 312 RAM
Total ~30.1 KB flash + 312 B RAM

Replacement cost (picolibc tinystdio __d_vfprintf, _NEED_IO_LONG_LONG=1, _NEED_IO_DOUBLE=1, io-float-exact=false, no C99/positional/wchar): ~5.5–7 KB flash on xtensa-esp-elf (extrapolated from picolibc upstream Cortex-M7 numbers in printf.md, +15% for xtensa density).

Net achievable on this sketch: ~22–24 KB flash + ~312 B RAM (~11% of attributed code).

This issue proposes a CLI knob — --shrink[=MODE] / --no-shrink — that exposes this and several adjacent wins behind one named flag, with auto-detection that picks the right default per framework + IDF version. Picolibc is the upstream direction (ESP-IDF 6.0 already defaults to it), so the wrap auto-noops on future toolchains via #ifdef __PICOLIBC__.

Prior art: ESPHome PR #16170 (the picolibc cookie-FILE wrap that informed this design) and ESPHome PR #16189 (the IDF-6-disables-it pattern).

Proposed flag surface

On the Build, Deploy, TestEmu, and Ci subcommands:

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

/// Aggressive flash-size reduction. MODE selects how hard fbuild tries:
///   auto        (default) decide from framework + IDF version
///   off         no shrinking; use stock libc, full symbol set
///   safe        no-behavior-change wins only (printf-thin wrap)
///   aggressive  safe + behavior-altering (strip esp_err_msg, disable coredump)
///   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.
/// Convenience for quick A/B comparisons; always wins over --shrink.
#[arg(long = "no-shrink", conflicts_with = "shrink")]
pub no_shrink: bool,
#[derive(clap::ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ShrinkMode { #[default] Auto, Off, Safe, Aggressive, Printf }

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

Mode taxonomy

Mode printf swap esp_err_msg strip coredump disable C++ exception personality sdkconfig rebuild?
off — — — — no
printf YES — — — no
safe YES — — drop if exceptions already off (cheap) no
aggressive YES YES YES YES yes (Arduino-ESP32 cache rebuild)
auto resolved per matrix below

Auto-resolution matrix

Framework libc --shrink=auto resolves to Why
arduino-esp32 (any) newlib (IDF ≤ 5.x) safe Picolibc wrap saves ~24 KB, zero behavior change
arduino-esp32 picolibc (IDF ≥ 6.x) off Picolibc already in use; wrap is net-negative
ESP-IDF native newlib safe Same logic
ESP-IDF native picolibc off Same logic
avr / megaavr avr-libc off avr-libc already minimal
stm32 / nrf52 / rp2040 / teensy newlib off (v1), upgrade to safe after per-platform measurement Conservative default

Libc detection runs after the toolchain probe — fbuild already inspects libc.a; we add a single check for whether __PICOLIBC__ is defined by the libc headers. The resolved decision is always printed (see reporting below) so behavior is never silently auto-enabled.

Six concrete invocations

# Default — let fbuild decide
fbuild build tests/platform/esp32s3 -e esp32s3
# → Shrink: auto → safe (libc=newlib, framework=arduino-esp32@3.3.7)

# Explicit on, default mode
fbuild build tests/platform/esp32s3 -e esp32s3 --shrink

# Maximum, including sdkconfig knobs
fbuild build tests/platform/esp32s3 -e esp32s3 --shrink=aggressive

# Just the printf swap (debugging the swap in isolation)
fbuild build tests/platform/esp32s3 -e esp32s3 --shrink=printf

# Hard off — reproduce stock newlib build for A/B baseline
fbuild build tests/platform/esp32s3 -e esp32s3 --no-shrink
# → Shrink: --no-shrink (user override; would otherwise default to safe)

# Equivalent explicit form
fbuild build tests/platform/esp32s3 -e esp32s3 --shrink=off

Build-output reporting

Default case (--shrink=auto, the no-flag default): when the platform's auto-resolver decides to apply shrinks, fbuild prints a single concise line in green listing the symbol families being wrapped. When the platform has no auto-shrink to apply (e.g. AVR, or ESP-IDF 6.x where picolibc is already the libc), the line is omitted entirely — silence is the default.

For ESP32-S3 / Arduino-ESP32 IDF 5.x (auto-resolver picks safe, which enables the printf-thin wrap):

0.00 ========================
0.00 =   BUILDING esp32s3   =
0.00 ========================
0.11 Toolchain: xtensa-esp-elf-gcc 14.2.0
0.12 auto shrinking: vfprintf, vsnprintf, snprintf, printf, vprintf, fprintf, vsprintf, sprintf   <- green
...
341.48 Flash:  306.05KB / 8.00MB (3.7%)

For AVR / megaavr (no auto-shrink available — avr-libc already minimal):

0.00 ========================
0.00 =   BUILDING uno   =
0.00 ========================
0.11 Toolchain: avr-gcc 7.3.0
   <- no shrink line; silently skipped
...

For ESP-IDF 6.x (libc is already picolibc — wrap would be net-negative, auto resolves to off):

0.12   <- no shrink line; auto picked off

Explicit modes (--shrink=safe|aggressive|printf|off or --no-shrink): print the verbose plan so the user can see exactly what fired and what was skipped — explicit intent earns explicit reporting:

0.12 Shrink: aggressive (explicit)
0.12   [x] printf-thin (picolibc tinystdio wrap, est. -24 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] C++ exception personality drop (est. -0.7 KB flash)
...
341.48 Flash:  302.10KB / 8.00MB (3.7%)  [stock: 329.78KB; saved 27.7 KB]
341.48 RAM:    665.50KB / 320.00KB (208.0%) [stock: 670.25KB; saved 4.8 KB]
341.48 Shrink result: flash -27.7 KB (-8.4 %), ram -4.8 KB (-0.7 %)
0.12 Shrink: --no-shrink (user override; auto would have applied: printf-thin)
0.12 Shrink: off (explicit)

The "stock" comparison line only prints if we have a cached baseline from a previous --no-shrink build of the same env. Otherwise it is suppressed.

Reporting rules summary

Invocation Output
No flag, platform has auto-shrinks Green one-liner: auto shrinking: <symbol1>, <symbol2>, ...
No flag, platform has no auto-shrinks (nothing)
--shrink=auto, behaves same as above Same as no flag
--shrink=safe|aggressive|printf Verbose multi-line plan with checkboxes
--shrink=off or --no-shrink Single line acknowledging user override

Color / styling

Use owo-colors = "4" (tiny, auto-disables on non-tty and when NO_COLOR is set). The auto shrinking: line is green(); the prefix label auto shrinking: is green().bold(); the comma-separated symbol list is plain green(). The verbose plan uses default color with a single bold first line.

Per-platform auto-shrink registry

The categories printed in the green line are sourced from a per-platform registry in crates/fbuild-build/src/shrink/registry.rs:

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

// Example registry data:
// arduino-esp32 (IDF 5.x newlib): [
//   AutoShrinkEntry { category: "printf-thin",
//     symbols: &["vfprintf", "vsnprintf", "snprintf", "printf",
//                "vprintf", "fprintf", "vsprintf", "sprintf"] },
// ],
// arduino-esp32 (IDF 6.x picolibc): [],   // empty -> no green line
// avr / megaavr:                    [],   // empty -> no green line
// stm32 / nrf52 / rp2040 / teensy:  [],   // empty in v1

When a new platform shrinker is added, it appends to its platform's registry entry and the symbols automatically show up in the green one-liner — no further reporting plumbing needed.

platformio.ini integration

[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.

A/B workflow this enables

fbuild build tests/platform/esp32s3 -e esp32s3 --no-shrink \
    --output-dir /tmp/baseline
fbuild build tests/platform/esp32s3 -e esp32s3 --shrink=aggressive \
    --output-dir /tmp/shrunk
fbuild bloat diff /tmp/baseline/firmware.elf /tmp/shrunk/firmware.elf
# (a future `bloat diff` subcommand, separately tracked)

Linking strategy: spec-file primary, --wrap fallback

Two architectural options exist for redirecting vfprintf calls from newlib to vendored picolibc; we adopt the spec-file path as primary.

Primary: GCC spec file (printf-thin.specs)

fbuild emits ~/.fbuild/cache/printf-thin/<toolchain>/printf-thin.specs at first build and adds --specs=<path>/printf-thin.specs to the link line. The spec rewrites the *libc: rule 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: it defines vfprintf, _vfprintf_r, _printf_r, printf, __sbprintf, _svfprintf_r, vsnprintf, _vsnprintf_r, etc. — every symbol newlib pulls those two objects in for. Linker resolves all of them to our archive in the --start-group scan, never pulls newlib's vfprintf.o / svfprintf.o. No archive surgery, no toolchain modification.

Why this is the right primary path:

  • Naturally LTO-safe. Our archive is precompiled native code (-fno-lto). Sketch bitcode references vfprintf as external; LTO has no bitcode definition to inline; the reference survives to the final link and resolves to our archive. None of the --wrap-under-LTO gymnastics needed.
  • Clean stack traces. Frames show vfprintf (the picolibc one), addr2line resolves to vendored picolibc source. No __wrap_* confusion.
  • Composes with --specs=nano.specs. Users who already pass nano.specs and the user-passes-our-specs get both — our printf-thin wins because our -L is first, and the rest of nano (smaller strtol, etc.) still applies. We get a free win that's hard to express with --wrap.
  • Mirrors how Espressif, ARM, and Newlib itself ship printf variants (nano.specs, picolibc.specs). Linker behavior is well-understood; users debugging link issues find familiar territory.

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

Fallback: -Wl,--wrap= per-symbol

When the spec-file path can't be used (toolchain without spec-file support, or the resolved auto path is printf mode for debugging the wrap in isolation), fall back to the ESPHome-style wrap:

-Wl,--wrap=vfprintf,--wrap=vprintf,--wrap=printf,--wrap=fprintf
-Wl,--wrap=vsnprintf,--wrap=snprintf,--wrap=vsprintf,--wrap=sprintf
-Wl,--wrap=fiprintf,--wrap=vfiprintf            ## for __assert_func, per review comment

LTO safety here requires two belt-and-suspenders steps:

  1. Compile the vendored picolibc TUs with -fno-lto. LTO can't inline across the native/bitcode boundary; the vfprintf reference survives to the linker where --wrap rewrites it.
  2. Mark the stub functions __attribute__((noinline, externally_visible)). The externally_visible attribute lives in the bitcode and tells lto1 "this symbol is referenced from outside the LTO universe; don't IPA-eliminate it."

Modern GCC's linker plugin (≥ 4.6) communicates --wrap resolutions to lto1, so wrapped symbols are treated as preempted in most cases. The two steps above make the behavior independent of plugin version.

Linker scripts cannot fix this because they run in step 5 of the LTO pipeline — after lto1 has already done IPA inlining in step 4. By the time KEEP() / PROVIDE() / EXCLUDE_FILE() apply, any inlined vfprintf references are gone. The spec-file approach sidesteps the issue entirely by keeping our printf out of the LTO unit.

Comparison table

Property Spec-file (primary) --wrap (fallback)
LTO interaction Naturally immune (native archive) Needs -fno-lto + attribute knobs
Stack-trace clarity vfprintf (clear) __wrap_vfprintf → vfprintf (chained)
Toolchain modification None None
Newlib symbol-list drift risk Yes — needs version manifest Lower — wrap only the 10 entry points
Composes with nano.specs Yes (cumulative) Conflicts (both wrap the same symbols)
Precedent in embedded nano.specs, picolibc.specs ESPHome PR #16170, our prior research

Implementation sketch

  1. crates/fbuild-build/src/shrink/mod.rs — ShrinkMode, ShrinkPlan { applied: Vec<Applier>, skipped: Vec<(Applier, Reason)> }, auto-resolver. Fingerprint augmentation: include ShrinkMode + resolved applier set in build_fingerprint/mod.rs so toggling --shrink invalidates the link cache (per review comment).
  2. crates/fbuild-build/src/shrink/printf_thin.rs — vendored picolibc tinystdio (~8 files from newlib/libc/tinystdio/: vfprintf.c, vfscanf.c, dtoa_engine.c, dtoa.h, stdio_private.h, formatter-spec headers; BSD-3-clause, ship NOTICE + COPYING.picolibc + COPYING.NEWLIB). Compiled with -fno-lto always.
  3. crates/fbuild-build/src/shrink/newlib_stdio_manifest.rs — per-newlib-version map of which symbols vfprintf.o / svfprintf.o co-export, used to size the complete-shadow archive. CI test diffs xtensa-esp-elf-nm output against the manifest and fails on drift.
  4. crates/fbuild-build/src/shrink/printf_thin_archive.rs — combines the picolibc sources + a thin vfprintf / _vfprintf_r / printf / _printf_r / __sbprintf / _svfprintf_r / vsnprintf etc. shim into libprintf_thin.a. Each entry point sets up a stack-allocated cookie FILE whose lock is pre-bound to __lock___libc_recursive_mutex (ESPHome fix for the lazy-mutex leak), then forwards to picolibc's __d_vfprintf. Guarded by #ifdef __PICOLIBC__ #error #endif so the archive refuses to build under picolibc-as-libc.
  5. crates/fbuild-build/src/shrink/spec_emitter.rs — generates printf-thin.specs for the resolved toolchain; cached per-toolchain under ~/.fbuild/cache/printf-thin/<toolchain-hash>/.
  6. crates/fbuild-build/src/shrink/wrap_fallback.rs — -Wl,--wrap= flag set + noinline,externally_visible stub TU, used when the auto-resolver picks printf mode (single-knob debugging) or when spec-file emission fails.
  7. crates/fbuild-build/src/shrink/applier.rs — translates the chosen mode into compile flags, link flags (--specs= primary, --wrap fallback), extra TUs / archives, and sdkconfig overrides (aggressive only). Auto-disables when --platformio is set (byte-identical PIO output, per review comment). Auto-disables when CONFIG_LIBC_NEWLIB_NANO_FORMAT=y is detected in resolved sdkconfig (avoids net-negative outcome, per review comment).
  8. crates/fbuild-build/src/shrink/probe.rs — fail-closed libc probe via a 5-line probe TU with #ifdef __PICOLIBC__\n#error PICOLIBC\n#endif, inspecting preprocessor exit code. On any probe failure → default to off, log warning (per review comment).
  9. crates/fbuild-build/src/build_info.rs — record shrink: { mode, resolved, applied: [...], strategy: "spec-file"|"wrap" } in build_info.json so fbuild bloat reports can label results.
  10. crates/fbuild-build/src/compile_many.rs:121 — extend seed_stage2_core_from_stage1 to include printf_thin.a and the spec file so multi-sketch CI builds amortize the printf-thin work in stage 1 (per review comment).
  11. Tests: an esp32s3 build under each mode (off, safe, aggressive, printf, auto), assert the symbol-table delta (_vfprintf_r absent under safe+ etc.) — small integration test that runs in CI. Additional test: a String-heavy sketch that exercises dtostrf to confirm _dtoa_r is still present and float formatting works (per review comment, dtostrf is a separate _dtoa_r caller and won't disappear from the shrink).

Decisions

  • Default is --shrink=auto (confirmed). Passing no flag is equivalent to --shrink=auto. The auto-resolver is conservative (picks off whenever the libc is already optimal), so on platforms with no auto-shrink the behavior is byte-for-byte identical to today. On Arduino-ESP32 IDF 5.x the printf-thin wrap fires and saves ~24 KB. A BREAKING_CHANGES.md line should call this out.

Open design questions

  1. Composability for aggressive — do we want --shrink=aggressive --shrink-skip=coredump from the start? Proposal: no for v1. Five modes + platformio.ini's fbuild_shrink_skip covers the long tail. Composition is non-breaking to add later.
  2. aggressive failure mode when sdkconfig cannot be regenerated — loud failure with hint to use --clean or downgrade to --shrink=safe, vs silent degrade. Proposal: loud failure — silent feature loss is worse than an explicit error.
  3. Telemetry in build_info.json — record shrink: { mode, resolved, applied } so fbuild bloat and CI bloat-budget gates can label reports. Proposal: yes — costs nothing, makes the per-symbol report self-describing.
  4. Single --shrink on the global Cli vs per-subcommand — fbuild already duplicates --clean, --environment, --verbose between the global Cli and per-subcommand Build/Deploy. Match that pattern.

Risks and caveats

  • Newlib symbol-list drift (spec-file primary path): libprintf_thin.a shadows newlib's vfprintf.o/svfprintf.o co-exports. If newlib reshuffles those objects, our shadow misses a symbol and the linker pulls newlib's full object back in. Mitigation: per-newlib-version symbol manifest + CI test that diffs nm against the manifest. Newlib's stdio layout has been stable for ~15 years; the manifest changes once every few years.
  • LTO and --wrap fallback: when --shrink=printf selects the wrap path (debugging mode), LTO can inline wrapped symbols across the wrap boundary if our TUs are in the same LTO unit as the sketch. Mitigation is two-step: vendored picolibc compiled with -fno-lto, and stub functions marked __attribute__((noinline, externally_visible)). Linker scripts cannot fix this because they run after LTO inlining.
  • Bringing _dtoa_r back via strtod: ~3.5 KB of the savings only holds while nothing in the sketch calls strtod/atof/Arduino::String::toFloat. Once a sketch parses floats from strings, the dtoa block resurrects. Fix is to also bring picolibc's strtod into the shadow archive (~2 KB cost) — track separately if it becomes a real regression. The bundled scanf wrap (v1 per review comment) brings picolibc's strtod along automatically.
  • Arduino::String += float keeps _dtoa_r alive: Arduino-ESP32's dtostrf calls _dtoa_r directly (not via vfprintf), so it stays linked after --shrink=safe. No regression in behavior, no extra savings beyond the printf core. CI test on a String-heavy sketch (FastLED Apa102 example) confirms this is correct.
  • _REENT semantics under FreeRTOS: picolibc tinystdio calls write(fd, ...) directly, which in IDF resolves to _write_r(_REENT, ...) via the VFS layer. Errno is set correctly because _write_r writes through _REENT. Stub must not read/write errno between the picolibc call and return; CI test confirms snprintf(NULL, 0, ...) does not clobber errno. The __lock___libc_recursive_mutex pre-binding fix (from ESPHome's pre-drop work on the picolibc cookie path) handles flockfile's lazy heap-allocated mutex.
  • Float quality: picolibc tinystdio with io-float-exact=false (dtoa-engine, not Ryu) is slightly less accurate than newlib's _dtoa_r on edge cases (off-by-1-ulp on a tiny fraction of values). For embedded logging this is fine; for code that depends on exact IEEE round-trip from snprintf -> strtod, this is not safe. Document under --shrink=printf/--shrink=safe and add BREAKING_CHANGES.md line.
  • Banker's vs half-up rounding: picolibc dtoa-engine uses round-half-up; newlib _dtoa_r uses banker's rounding (round-half-to-even). Sketches with golden-output tests will diverge. Recommend --no-shrink for byte-exact reproduction.
  • IRAM placement on ESP32: the spec-file shadow archive lives in flash (matches stock newlib placement). ISR / panic-handler paths in IDF already use esp_rom_printf or esp_log_early_printf, not vprintf. CI test: trigger esp_panic, confirm shadow not reached.
  • Forward compat is real: on ESP-IDF 6.0+ the compiler defines __PICOLIBC__, the shadow archive's #error guard fires, the spec file becomes inert (no newlib vfprintf symbol to shadow), and the firmware gets picolibc natively. Same forward-compat mechanism as ESPHome.

Related

  • fbuild bloat: emit and preserve firmware.map automatically #491 — fbuild bloat: emit and preserve firmware.map automatically. The build-output reporting above depends on having archive-attributed symbols, which depends on the map.
  • Investigation thread that produced these numbers (June 2026 session): printf-family bloat on esp32s3, picolibc tinystdio extraction cost, ESPHome --wrap precedent.

Acceptance

  • fbuild build --shrink and fbuild build --no-shrink both work, --shrink=auto|off|safe|aggressive|printf parses.
  • Auto-resolver picks safe for Arduino-ESP32 3.3.7 on ESP32-S3 and prints the decision via the green one-liner.
  • Default mode (--shrink=safe) uses the spec-file path (printf-thin.specs + libprintf_thin.a shadow archive); --shrink=printf uses the --wrap fallback for single-knob debugging.
  • A clean build with --shrink=safe produces a firmware where xtensa-esp-elf-nm shows no _vfprintf_r or _svfprintf_r, and _dtoa_r is absent if the sketch does not call strtod / dtostrf.
  • Flash size on tests/platform/esp32s3 -e esp32s3 drops by >= 18 KB (conservative target; measured ceiling is ~24 KB).
  • build_info.json records the resolved mode, applied appliers, and link strategy ("spec-file" or "wrap").
  • --no-shrink produces a byte-for-byte identical (or modulo timestamp) ELF to today's 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.

Activity

  1. zackees commented on Jun 8, 2026

    @zackees
    MemberAuthor

    Review: missed shrinkage opportunities and corner cases

    Three-agent audit across (1) per-platform opportunities, (2) cross-platform shrinkers beyond printf, (3) runtime/link/sketch corner cases. Headline: most platforms currently marked off in v1 should be safe, and there are four critical gotchas the design does not yet address.


    1. Expanded auto-resolver matrix

    The issue's v1 matrix is too conservative — 6 of 7 non-Xtensa platforms have a documented printf-thin path with measured savings:

    Platform Current (issue) Recommended Est. flash savings Rationale
    arduino-esp32 / IDF 5.x newlib (Xtensa) safe safe -24 KB (unchanged)
    arduino-esp32 / IDF 5.x newlib (RISC-V C3) off safe -6 KB picolibc-RISC-V is the reference target, most polished
    arduino-esp32 / IDF 5.x (C6/H2) off off n/a ROM has full printf; wrap is net-negative
    arduino-esp32 / IDF 6.x (any) off off n/a picolibc already default
    AVR (atmega328p, attiny85, atmega2560) off safe -1.0 to -1.7 KB Static-scan sketch for %f/%lld; if absent, swap printf → printf_min via -Wl,-u,vfprintf -lprintf_min. ~5% on 328p, makes-or-breaks on attiny85
    STM32 (f103/f411/f429/h747) off safe -8 KB Arduino_Core_STM32 defaults to full newlib (~15.5 KB vfprintf); picolibc ARM is the most mature picolibc backend; same cookie-FILE recipe applies
    NRF52 (840/833/Feather Sense) off safe -10 KB Adafruit nRF52 core uses full newlib; SoftDevice independent of libc; same ARM picolibc wrap
    RP2040 / RP2350 off safe -6 to -12 KB Two stacked wins: (a) pico_set_printf_implementation(compiler) drops pico-sdk's vendored mpaland (~5 KB), (b) picolibc wrap on newlib side (~7 KB). RP2350 is M33; picolibc supports it fully
    Teensy 2.0 (AVR) off safe -1.0 KB AVR rule
    Teensy 3+/4+ (Cortex-M) off off n/a Print.cpp implements printNumber/printFloat directly — Serial.print(x) never touches _vfprintf_r. Only Serial.printf benefits, and typical Teensy sketches (Audio, OctoWS2811) don't use it. Provide manual --shrink=printf for users who measure benefit
    ESP8266 off safe -10 to -12 KB picolibc-xtensa-lx106-elf Debian package exists; every byte counts on 80 KB flash

    Net: only Teensy 3+/4+, ESP32-C6/H2, and all IDF 6.x legitimately stay at off. Six other platforms are leaving 6–12 KB on the table per sketch.

    2. New shrinkers to add to v1 registry

    The cross-platform audit revealed several items already shipped (worth documenting in the green one-liner) plus a few new appliers:

    v1 additions (bundle with printf-thin, same --wrap link group):

    • scanf family wrap — __svfscanf_r, __ssrefill_r, __sccl (4–6 KB when any sscanf/atof/String::toInt is linked). Picolibc tinystdio has unified __d_vfscanf. Shares _dtoa_r/strtod tax with printf — bundling is free.
    • Stdio FILE init strip — __sf[3], __sinit_lock, _global_impure_ptr (~312 B RAM + ~1 KB flash). Once printf-thin wraps the formatter family, the only remaining __sf users are fopen/fread/fwrite, which embedded sketches almost never call. Probe for fopen references; drop via --gc-sections + stub __sinit.

    v1 under --shrink=aggressive:

    • -Oz (GCC 12+) — 1–3% extra over -Os; gate on toolchain GCC ≥ 12 check.
    • ESP32 sdkconfig stack knobs — CONFIG_BT_CLASSIC_ENABLED=n, CONFIG_BT_BLE_50_FEATURES_SUPPORTED=n, CONFIG_LWIP_PPP_SUPPORT=n etc. (20–80 KB on ESP32 BLE-only sketches). Already implied by issue's "aggressive needs sdkconfig rebuild" pathway; needs explicit per-platform registry under --shrink=aggressive.

    v1 warn-only detector:

    • newlib time / mktime / strftime / locale tables — pulled by String(millis()), NTP libs, asctime. 2–4 KB code + 1 KB locale rodata. Warn for v1; v2 add picolibc tinystdio time wrap.

    Already shipped — surface in reporting as "baseline" (audit found these in repo):

    The auto shrinking: green one-liner should list these baseline items separately or via a --shrink-verbose toggle, so users don't think they're missing free wins.

    Separate flag, not --shrink:

    • LTO (-flto) — different failure mode (link-time errors vs runtime), different debug story (line tables move), already opt-in per platform JSON for STM32. Make it --lto[=on|off|auto], not bundled into --shrink.

    Out of scope, document only:

    • libgcc soft-float helpers (__adddf3, __divdf3) — codegen-level, can't --wrap. Lint-only.
    • 64-bit integer helpers (__divdi3, __moddi3) — libgcc, small, replacement requires custom libgcc.
    • -Wl,-s symbol strip — hurts fbuild bloat/fbuild symbols/addr2line, no flash win (.bin strip is bootloader-level).
    • C++ iostreams — different bloat path; future --shrink=cxx-iostreams (separate issue).

    3. Critical gotchas missing from the design

    Four items that will silently break the wrap in production:

    3a. LTO interaction (B-9) — GCC's lto-wrapper honors --wrap only when the wrapped symbol is referenced through a non-LTO TU. If a user passes -flto via build_flags, picolibc TUs get inlined and the vfprintf reference vanishes — linker has no symbol to wrap. Fix: compile vendored picolibc + stub TU with -fno-lto even when sketch sets -flto. Document in printf_thin.rs.

    3b. Build-fingerprint invalidation (B-11) — crates/fbuild-build/src/build_fingerprint/mod.rs does not include ShrinkMode or the resolved applier set in the fingerprint key. Toggling --shrink between builds would reuse the cached firmware.elf from the prior mode. Fix: add shrink_mode + shrink_appliers to fingerprint struct; treat change as full relink trigger (object cache can stay; only link inputs change).

    3c. Libc probe must fail-closed (D-18) — nm libc.a | grep __PICOLIBC__ is fragile (picolibc may be header-only conditionalized). Fix: compile a 5-line probe TU with #ifdef __PICOLIBC__\n#error PICOLIBC\n#endif and inspect preprocessor exit. On any probe failure (toolchain not found, header missing, ambiguous) → default to off, log warning. Never guess.

    3d. Newlib-nano detection (D-19) — if user has CONFIG_LIBC_NEWLIB_NANO_FORMAT=y (or legacy CONFIG_NEWLIB_NANO_FORMAT=y) in sdkconfig.defaults, vfprintf is already ~2 KB. Our wrap is ~6 KB picolibc — net-negative. Fix: parse resolved sdkconfig before resolving auto; if nano-format is set, downgrade to off and print "shrink: disabled (newlib-nano already in use)".

    4. Other gotchas worth handling explicitly

    • --platformio compat mode (B-7) — auto-disable --shrink whenever --platformio is set; users expect byte-identical PIO output. Log debug-level "shrink: disabled under --platformio".
    • compile-many stage placement (B-8) — vendored picolibc + stub TU + scanf wrap must live in stage 1 (shared across sketches). Add printf_thin.a to seed_stage2_core_from_stage1 in compile_many.rs:121.
    • User-defined __wrap_vfprintf collision (B-10) — detect by scanning build_flags for -Wl,--wrap=vfprintf family before final link; error out with clear "use --no-shrink" hint instead of cryptic duplicate-symbol linker error.
    • Coredump strip in aggressive mode (C-16) — detect CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y in user sdkconfig; on detection, error with "explicit coredump enable detected; use --shrink=safe or remove the config". Silent strip of a debug feature is the wrong default.
    • __assert_func calls fiprintf(stderr, ...) (A-2) — currently NOT in the wrap list. Add fiprintf/vfiprintf to the wrap targets (same picolibc backend, ~no cost). Otherwise asserts emit through newlib's integer-only path that we tried to delete.
    • IDF version detection (D-20) — parse tools/esp32-arduino-libs/{mcu}/include/esp_common/include/esp_idf_version.h for ESP_IDF_VERSION_MAJOR; cache in Esp32Framework. Fallback: parse Arduino-ESP32's package.json version field (3.x encodes IDF version).
    • dtostrf keeps _dtoa_r alive (C-15) — Arduino-ESP32's dtostrf calls _dtoa_r directly. After --shrink=safe, _vfprintf_r is gone but _dtoa_r remains (referenced by dtostrf). No regression (correct behavior), no extra savings beyond the printf core. Add a CI test on a String-heavy sketch (FastLED Apa102 example uses String += float) confirming _dtoa_r is still present and float formatting is intact.
    • FreeRTOS _REENT errno bridge (A-5) — stub must not read/write errno between the picolibc call and return; rely on underlying write() syscall to set newlib's errno via _write_r. Add a test: snprintf(NULL, 0, ...) does not clobber errno.

    5. Documented (non-action) caveats

    • Banker's vs half-up rounding — picolibc dtoa-engine uses round-half-up; newlib _dtoa_r uses banker's rounding. Add BREAKING_CHANGES.md line; recommend --no-shrink for byte-exact reproduction. Invisible for typical logging.
    • IRAM placement (A-1) — wrap stays flash-resident, matching stock newlib placement. Risk is ESP_DRAM_LOGE from a panic handler; mitigation is verifying components/esp_system uses esp_rom_printf not vprintf on panic paths. Integration test: trigger esp_panic, confirm wrap is not reached.
    • std::cout, printf_P, esp_err_to_name — out of scope for printf-thin; document in --shrink=aggressive help text where applicable.

    Highest-priority deltas for the issue body

    1. Expand auto-resolver matrix from 5 rows to 13, moving AVR / STM32 / NRF52 / RP2040/RP2350 / ESP8266 / ESP32-C3 from off to safe.
    2. Add the four critical gotchas (LTO -fno-lto on vendored TUs, fingerprint augmentation, fail-closed probe, newlib-nano downgrade) to the implementation sketch.
    3. Add fiprintf/vfiprintf to the wrap target list (for __assert_func).
    4. Add a "baseline already shipped" section to the reporting design so users know -ffunction-sections/--gc-sections/-fno-rtti/-fno-exceptions/-fno-unwind-tables are already in play.
    5. Bundle scanf-thin and stdio-FILE-strip with printf-thin as the v1 safe package.
    6. Specify --platformio interaction (auto-disable shrink) and compile-many stage-1 placement explicitly.

    Reference files in the repo:

    • crates/fbuild-build/src/eh_frame_policy.rs — pattern for auto-resolver + env-override precedence
    • crates/fbuild-build/src/compile_many.rs:121 — seed_stage2_core_from_stage1 extension point
    • crates/fbuild-build/src/build_fingerprint/mod.rs — fingerprint augmentation point
    • crates/fbuild-build/src/build_info.rs:642 — extract_prefixed extension for shrink.applied record
    • crates/fbuild-packages/src/library/library_compiler.rs:16 — cxx-only flag filter (precedent for new flags)
    • crates/fbuild-packages/src/library/esp32_framework/sdk_paths.rs — IDF version detection point
    • crates/fbuild-cli/src/cli/args.rs — CLI surface + --platformio interaction
  2. zackees commented on Jun 8, 2026

    @zackees
    MemberAuthor

    Closing — superseded by #493, which captures the final design after the spec-file linking strategy was adopted and the cross-platform / corner-case review was incorporated.

    This issue (#492) preserves the design exploration: the initial --wrap-only approach, the four-option linker analysis (--wrap vs strong-symbol override vs archive surgery vs spec-file), the per-platform opportunity audit that moved 6 platforms from off to safe, and the LTO-interaction discussion that motivated picking the spec-file path as primary.

    Implementation work tracked in #493.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions