Repository navigation
Description
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=offBuild-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 v1When 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-separatedPrecedence (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 referencesvfprintfas 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-Lis first, and the rest of nano (smallerstrtol, 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:
- Compile the vendored picolibc TUs with
-fno-lto. LTO can't inline across the native/bitcode boundary; thevfprintfreference survives to the linker where--wraprewrites it. - Mark the stub functions
__attribute__((noinline, externally_visible)). Theexternally_visibleattribute 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
crates/fbuild-build/src/shrink/mod.rs—ShrinkMode,ShrinkPlan { applied: Vec<Applier>, skipped: Vec<(Applier, Reason)> }, auto-resolver. Fingerprint augmentation: includeShrinkMode+ resolved applier set inbuild_fingerprint/mod.rsso toggling--shrinkinvalidates the link cache (per review comment).crates/fbuild-build/src/shrink/printf_thin.rs— vendored picolibc tinystdio (~8 files fromnewlib/libc/tinystdio/:vfprintf.c,vfscanf.c,dtoa_engine.c,dtoa.h,stdio_private.h, formatter-spec headers; BSD-3-clause, shipNOTICE+COPYING.picolibc+COPYING.NEWLIB). Compiled with-fno-ltoalways.crates/fbuild-build/src/shrink/newlib_stdio_manifest.rs— per-newlib-version map of which symbolsvfprintf.o/svfprintf.oco-export, used to size the complete-shadow archive. CI test diffsxtensa-esp-elf-nmoutput against the manifest and fails on drift.crates/fbuild-build/src/shrink/printf_thin_archive.rs— combines the picolibc sources + a thinvfprintf/_vfprintf_r/printf/_printf_r/__sbprintf/_svfprintf_r/vsnprintfetc. shim intolibprintf_thin.a. Each entry point sets up a stack-allocated cookieFILEwhoselockis 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 #endifso the archive refuses to build under picolibc-as-libc.crates/fbuild-build/src/shrink/spec_emitter.rs— generatesprintf-thin.specsfor the resolved toolchain; cached per-toolchain under~/.fbuild/cache/printf-thin/<toolchain-hash>/.crates/fbuild-build/src/shrink/wrap_fallback.rs—-Wl,--wrap=flag set +noinline,externally_visiblestub TU, used when the auto-resolver picksprintfmode (single-knob debugging) or when spec-file emission fails.crates/fbuild-build/src/shrink/applier.rs— translates the chosen mode into compile flags, link flags (--specs=primary,--wrapfallback), extra TUs / archives, and sdkconfig overrides (aggressive only). Auto-disables when--platformiois set (byte-identical PIO output, per review comment). Auto-disables whenCONFIG_LIBC_NEWLIB_NANO_FORMAT=yis detected in resolved sdkconfig (avoids net-negative outcome, per review comment).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 tooff, log warning (per review comment).crates/fbuild-build/src/build_info.rs— recordshrink: { mode, resolved, applied: [...], strategy: "spec-file"|"wrap" }inbuild_info.jsonsofbuild bloatreports can label results.crates/fbuild-build/src/compile_many.rs:121— extendseed_stage2_core_from_stage1to includeprintf_thin.aand the spec file so multi-sketch CI builds amortize the printf-thin work in stage 1 (per review comment).- Tests: an
esp32s3build under each mode (off,safe,aggressive,printf,auto), assert the symbol-table delta (_vfprintf_rabsent undersafe+ etc.) — small integration test that runs in CI. Additional test: a String-heavy sketch that exercisesdtostrfto confirm_dtoa_ris still present and float formatting works (per review comment,dtostrfis a separate_dtoa_rcaller 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 (picksoffwhenever 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. ABREAKING_CHANGES.mdline should call this out.
Open design questions
- Composability for
aggressive— do we want--shrink=aggressive --shrink-skip=coredumpfrom the start? Proposal: no for v1. Five modes +platformio.ini'sfbuild_shrink_skipcovers the long tail. Composition is non-breaking to add later. aggressivefailure mode when sdkconfig cannot be regenerated — loud failure with hint to use--cleanor downgrade to--shrink=safe, vs silent degrade. Proposal: loud failure — silent feature loss is worse than an explicit error.- Telemetry in
build_info.json— recordshrink: { mode, resolved, applied }sofbuild bloatand CI bloat-budget gates can label reports. Proposal: yes — costs nothing, makes the per-symbol report self-describing. - Single
--shrinkon the global Cli vs per-subcommand — fbuild already duplicates--clean,--environment,--verbosebetween the global Cli and per-subcommandBuild/Deploy. Match that pattern.
Risks and caveats
- Newlib symbol-list drift (spec-file primary path):
libprintf_thin.ashadows newlib'svfprintf.o/svfprintf.oco-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 diffsnmagainst the manifest. Newlib's stdio layout has been stable for ~15 years; the manifest changes once every few years. - LTO and
--wrapfallback: when--shrink=printfselects 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_rback viastrtod: ~3.5 KB of the savings only holds while nothing in the sketch callsstrtod/atof/Arduino::String::toFloat. Once a sketch parses floats from strings, the dtoa block resurrects. Fix is to also bring picolibc'sstrtodinto the shadow archive (~2 KB cost) — track separately if it becomes a real regression. The bundled scanf wrap (v1 per review comment) brings picolibc'sstrtodalong automatically. Arduino::String += floatkeeps_dtoa_ralive: Arduino-ESP32'sdtostrfcalls_dtoa_rdirectly (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._REENTsemantics under FreeRTOS: picolibc tinystdio callswrite(fd, ...)directly, which in IDF resolves to_write_r(_REENT, ...)via the VFS layer. Errno is set correctly because_write_rwrites through_REENT. Stub must not read/write errno between the picolibc call and return; CI test confirmssnprintf(NULL, 0, ...)does not clobber errno. The__lock___libc_recursive_mutexpre-binding fix (from ESPHome's pre-drop work on the picolibc cookie path) handlesflockfile'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_ron 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 fromsnprintf->strtod, this is not safe. Document under--shrink=printf/--shrink=safeand addBREAKING_CHANGES.mdline. - Banker's vs half-up rounding: picolibc dtoa-engine uses round-half-up; newlib
_dtoa_ruses banker's rounding (round-half-to-even). Sketches with golden-output tests will diverge. Recommend--no-shrinkfor 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_printforesp_log_early_printf, notvprintf. CI test: triggeresp_panic, confirm shadow not reached. - Forward compat is real: on ESP-IDF 6.0+ the compiler defines
__PICOLIBC__, the shadow archive's#errorguard fires, the spec file becomes inert (no newlibvfprintfsymbol 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 preservefirmware.mapautomatically. 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
--wrapprecedent.
Acceptance
fbuild build --shrinkandfbuild build --no-shrinkboth work,--shrink=auto|off|safe|aggressive|printfparses.- Auto-resolver picks
safefor 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.ashadow archive);--shrink=printfuses the--wrapfallback for single-knob debugging. - A clean build with
--shrink=safeproduces a firmware wherextensa-esp-elf-nmshows no_vfprintf_ror_svfprintf_r, and_dtoa_ris absent if the sketch does not callstrtod/dtostrf. - Flash size on
tests/platform/esp32s3 -e esp32s3drops by >= 18 KB (conservative target; measured ceiling is ~24 KB). build_info.jsonrecords the resolved mode, applied appliers, and link strategy ("spec-file"or"wrap").--no-shrinkproduces a byte-for-byte identical (or modulo timestamp) ELF to today's build.- CI test diffs newlib's
nmoutput againstnewlib_stdio_manifest.rs; fails on drift so the shadow archive stays complete across toolchain bumps.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTriage
Activity
zackees commentedon Jun 8, 2026
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
offin v1 should besafe, 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:
%f/%lld; if absent, swapprintf→printf_minvia-Wl,-u,vfprintf -lprintf_min. ~5% on 328p, makes-or-breaks on attiny85pico_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 fullyprintNumber/printFloatdirectly —Serial.print(x)never touches_vfprintf_r. OnlySerial.printfbenefits, and typical Teensy sketches (Audio, OctoWS2811) don't use it. Provide manual--shrink=printffor users who measure benefitNet: 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--wraplink group):scanffamily wrap —__svfscanf_r,__ssrefill_r,__sccl(4–6 KB when anysscanf/atof/String::toIntis linked). Picolibc tinystdio has unified__d_vfscanf. Shares_dtoa_r/strtodtax with printf — bundling is free.__sf[3],__sinit_lock,_global_impure_ptr(~312 B RAM + ~1 KB flash). Once printf-thin wraps the formatter family, the only remaining__sfusers arefopen/fread/fwrite, which embedded sketches almost never call. Probe forfopenreferences; drop via--gc-sections+ stub__sinit.v1 under
--shrink=aggressive:-Oz(GCC 12+) — 1–3% extra over-Os; gate on toolchain GCC ≥ 12 check.CONFIG_BT_CLASSIC_ENABLED=n,CONFIG_BT_BLE_50_FEATURES_SUPPORTED=n,CONFIG_LWIP_PPP_SUPPORT=netc. (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:
time/mktime/strftime/ locale tables — pulled byString(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):
-fno-rtti— universal across all C++ configs (teensy*.json,esp8266.json,generic_arm/arm_compiler.rs:156)-fno-exceptions— universal-ffunction-sections -fdata-sections -Wl,--gc-sections— every platform JSON + reference configs-fno-unwind-tables -fno-asynchronous-unwind-tables—crates/fbuild-build/src/eh_frame_policy.rsalready auto-applies on all GCC platforms (per Propagate eh_frame strip policy to ARM Cortex-M / Teensy / RISC-V GCC targets (follow-up to #243) #244)The
auto shrinking:green one-liner should list these baseline items separately or via a--shrink-verbosetoggle, so users don't think they're missing free wins.Separate flag, not
--shrink:-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:
__adddf3,__divdf3) — codegen-level, can't--wrap. Lint-only.__divdi3,__moddi3) — libgcc, small, replacement requires custom libgcc.-Wl,-ssymbol strip — hurtsfbuild bloat/fbuild symbols/addr2line, no flash win (.binstrip is bootloader-level).--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-wrapperhonors--wraponly when the wrapped symbol is referenced through a non-LTO TU. If a user passes-fltoviabuild_flags, picolibc TUs get inlined and thevfprintfreference vanishes — linker has no symbol to wrap. Fix: compile vendored picolibc + stub TU with-fno-ltoeven when sketch sets-flto. Document inprintf_thin.rs.3b. Build-fingerprint invalidation (B-11) —
crates/fbuild-build/src/build_fingerprint/mod.rsdoes not includeShrinkModeor the resolved applier set in the fingerprint key. Toggling--shrinkbetween builds would reuse the cachedfirmware.elffrom the prior mode. Fix: addshrink_mode+shrink_appliersto 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#endifand inspect preprocessor exit. On any probe failure (toolchain not found, header missing, ambiguous) → default tooff, log warning. Never guess.3d. Newlib-nano detection (D-19) — if user has
CONFIG_LIBC_NEWLIB_NANO_FORMAT=y(or legacyCONFIG_NEWLIB_NANO_FORMAT=y) insdkconfig.defaults, vfprintf is already ~2 KB. Our wrap is ~6 KB picolibc — net-negative. Fix: parse resolvedsdkconfigbefore resolving auto; if nano-format is set, downgrade tooffand print "shrink: disabled (newlib-nano already in use)".4. Other gotchas worth handling explicitly
--platformiocompat mode (B-7) — auto-disable--shrinkwhenever--platformiois set; users expect byte-identical PIO output. Log debug-level "shrink: disabled under --platformio".compile-manystage placement (B-8) — vendored picolibc + stub TU + scanf wrap must live in stage 1 (shared across sketches). Addprintf_thin.atoseed_stage2_core_from_stage1incompile_many.rs:121.__wrap_vfprintfcollision (B-10) — detect by scanningbuild_flagsfor-Wl,--wrap=vfprintffamily before final link; error out with clear "use--no-shrink" hint instead of cryptic duplicate-symbol linker error.CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=yin user sdkconfig; on detection, error with "explicit coredump enable detected; use--shrink=safeor remove the config". Silent strip of a debug feature is the wrong default.__assert_funccallsfiprintf(stderr, ...)(A-2) — currently NOT in the wrap list. Addfiprintf/vfiprintfto the wrap targets (same picolibc backend, ~no cost). Otherwise asserts emit through newlib's integer-only path that we tried to delete.tools/esp32-arduino-libs/{mcu}/include/esp_common/include/esp_idf_version.hforESP_IDF_VERSION_MAJOR; cache inEsp32Framework. Fallback: parse Arduino-ESP32'spackage.jsonversion field (3.x encodes IDF version).dtostrfkeeps_dtoa_ralive (C-15) — Arduino-ESP32'sdtostrfcalls_dtoa_rdirectly. After--shrink=safe,_vfprintf_ris gone but_dtoa_rremains (referenced bydtostrf). No regression (correct behavior), no extra savings beyond the printf core. Add a CI test on a String-heavy sketch (FastLED Apa102 example usesString += float) confirming_dtoa_ris still present and float formatting is intact._REENTerrno bridge (A-5) — stub must not read/writeerrnobetween the picolibc call and return; rely on underlyingwrite()syscall to set newlib's errno via_write_r. Add a test:snprintf(NULL, 0, ...)does not clobber errno.5. Documented (non-action) caveats
_dtoa_ruses banker's rounding. AddBREAKING_CHANGES.mdline; recommend--no-shrinkfor byte-exact reproduction. Invisible for typical logging.components/esp_systemusesesp_rom_printfnotvprintfon panic paths. Integration test: triggeresp_panic, confirm wrap is not reached.std::cout,printf_P,esp_err_to_name— out of scope for printf-thin; document in--shrink=aggressivehelp text where applicable.Highest-priority deltas for the issue body
offtosafe.-fno-ltoon vendored TUs, fingerprint augmentation, fail-closed probe, newlib-nano downgrade) to the implementation sketch.fiprintf/vfiprintfto the wrap target list (for__assert_func).-ffunction-sections/--gc-sections/-fno-rtti/-fno-exceptions/-fno-unwind-tablesare already in play.scanf-thinandstdio-FILE-stripwithprintf-thinas the v1safepackage.--platformiointeraction (auto-disable shrink) andcompile-manystage-1 placement explicitly.Reference files in the repo:
crates/fbuild-build/src/eh_frame_policy.rs— pattern for auto-resolver + env-override precedencecrates/fbuild-build/src/compile_many.rs:121—seed_stage2_core_from_stage1extension pointcrates/fbuild-build/src/build_fingerprint/mod.rs— fingerprint augmentation pointcrates/fbuild-build/src/build_info.rs:642—extract_prefixedextension forshrink.appliedrecordcrates/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 pointcrates/fbuild-cli/src/cli/args.rs— CLI surface +--platformiointeractionzackees commentedon Jun 8, 2026
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 (--wrapvs strong-symbol override vs archive surgery vs spec-file), the per-platform opportunity audit that moved 6 platforms fromofftosafe, and the LTO-interaction discussion that motivated picking the spec-file path as primary.Implementation work tracked in #493.