Repository navigation
Description
Summary
fbuild controls compile-flag policy centrally per platform (e.g. crates/fbuild-build/src/esp32/esp32_compiler.rs already injects -fexceptions, -fno-rtti, -fuse-cxa-atexit for every ESP32 TU). The same hook can detect when neither C++ exception handling nor ESP-IDF panic backtrace are in use, and inject -fno-asynchronous-unwind-tables -fno-unwind-tables to suppress .eh_frame emission. On a stock ESP32-S3 NEOPIXEL Blink (PlatformIO starter, no source changes) this strips ~225 KB of dead unwind metadata from the binary.
Background
Symbol-level analysis of examples/Blink/Blink.ino on FastLED master (PR FastLED/FastLED#2469 merged) shows the firmware is 657,072 B, of which 236,932 B (36%) is orphan .eh_frame — exception-unwinding tables for code that the panic handler never walks and that no _Unwind_* API consumes. Of that, 168,772 B comes from libFastLED.a alone.
Per-archive .eh_frame totals in the ESP32-S3 Blink binary:
libFastLED.a 168,772 bytes
libstdc++.a 56,148
libnvs_flash.a 4,068
libFrameworkArduino 3,240
libFS.a 1,628
libSD.a 1,092
libSPI.a 728
libgcc.a 688
main.cpp.o 424
libcxx.a 140
TOTAL 236,932 bytes (231 KB)
FastLED tried to fix this with PR FastLED/FastLED#2423 (FL_NO_UNWIND_BEGIN/_END pragmas on the ESP32 driver unity TU). That fix is a no-op on GCC 14.2.0 / xtensa-esp-elf for two reasons:
- The pragma form
_Pragma("GCC optimize(\"no-unwind-tables\")")only disables-funwind-tables, not-fasynchronous-unwind-tables(the ESP32 default-ON emitter that actually produces.eh_frame). - GCC's
__attribute__((optimize(...)))is documented as supporting-O*-style optimizations and not general codegen flags — even with the right string, behavior is not guaranteed.
Concrete evidence: platforms+.cpp.o — the file with the pragma wrap around every include — ships 3,007 FDE entries and 34,960 B of .eh_frame out of GCC, identical to unwrapped sibling TUs. Full byte-level audit: FastLED/FastLED#2473.
The robust mechanism is to pass -fno-asynchronous-unwind-tables -fno-unwind-tables on the cc1 command line. That's a build-system concern, not a library-header concern — which is why this belongs here.
Proposed mechanism
Add a per-platform "eh_frame policy" to the ESP32 orchestrator (and parallel hooks for other platforms that emit eh_frame by default — STM32 Cortex-M, RISC-V, etc.):
// crates/fbuild-build/src/esp32/esp32_compiler.rs
enum EhFramePolicy {
Preserve,
Strip,
}
fn eh_frame_policy(&self, project_cfg: &ProjectConfig) -> EhFramePolicy {
// Preserve eh_frame if ANY of:
// - user explicitly requested C++ exceptions (-fexceptions in build_flags)
// - sdkconfig has CONFIG_ESP_SYSTEM_PANIC_PRINT_BACKTRACE=y
// - sdkconfig has CONFIG_ESP_SYSTEM_PANIC_GDBSTUB=y
// - sdkconfig has CONFIG_ESP_DEBUG_OCDAWARE=y
// - sdkconfig has CONFIG_COMPILER_OPTIMIZATION_DEBUG=y
// - debug profile (-Og, -O0, !NDEBUG)
// - FBUILD_KEEP_EH_FRAME=1 env override
// - project_cfg.build_flags contains "-funwind-tables"
// or "-fasynchronous-unwind-tables"
if exceptions_enabled
|| panic_backtrace_enabled
|| debug_build
|| user_override
{
EhFramePolicy::Preserve
} else {
EhFramePolicy::Strip
}
}
fn cpp_flags(&self, project_cfg: &ProjectConfig) -> Vec<String> {
let mut flags = self.base_cpp_flags();
if matches!(self.eh_frame_policy(project_cfg), EhFramePolicy::Strip) {
flags.push("-fno-asynchronous-unwind-tables".into());
flags.push("-fno-unwind-tables".into());
}
flags
}Decision inputs
These are already available to fbuild's orchestrator:
| Input | Source |
|---|---|
-fexceptions vs -fno-exceptions |
platformio.ini build_flags / build_unflags, plus fbuild's own defaults (currently -fexceptions on ESP32-C6) |
| ESP-IDF panic mode | sdkconfig / sdkconfig.defaults parsed for CONFIG_ESP_SYSTEM_PANIC_* |
| Debug build | [env:*] build_type, presence of -Og/-O0/-g3, absence of NDEBUG |
| User override | new FBUILD_KEEP_EH_FRAME=1 env var |
PlatformIO-starter default
fastled/platformio-starter platforms/platformio.esp32s3.ini:
[env:esp32s3]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.20/platform-espressif32.zip
framework = arduino
board = um_tinys3
upload_protocol = esptool
monitor_filters =
default
esp32_exception_decoder
lib_deps =
FastLEDframework = arduino→ ESP-IDF panic prints backtrace by default →EhFramePolicy::Preserve- No
build_flagsoverride → fbuild's own defaults apply monitor_filters = esp32_exception_decoderis runtime, signals user wants readable backtraces
So the starter's default lands in Preserve. The strip path triggers when a user explicitly turns off panic backtrace via sdkconfig, sets FBUILD_STRIP_EH_FRAME=1, or uses a "size-optimized" build profile that fbuild offers.
Estimated impact
Measured on FastLED master (PR FastLED/FastLED#2469) + ESP32-S3 + minimal NEOPIXEL Blink:
| Build | firmware.bin |
|---|---|
| Today (eh_frame kept) | 657,072 B |
With -fno-async-unwind on FastLED TUs only |
~488,000 B (−169 KB) |
| Same + propagated to libstdc++ link | ~432,000 B (−225 KB) |
| 3.10.3 baseline | ~344,000 B |
The same flag is a win for any FastLED user on any ESP32 variant whose project doesn't rely on backtraces — which is the dominant case for LED-controller firmware. STM32 / RISC-V / ARM Cortex-M users see a similar (smaller) effect; fbuild can apply the same policy uniformly.
Why this belongs in fbuild, not in FastLED
- Library headers can't reliably emit codegen flags. GCC pragmas for unwind-table suppression are unreliable, as proven empirically in ESP32-S3 binary size: 657 KB on master vs 344 KB on 3.10.3 — remaining +313 KB delta breakdown FastLED#2473. The only stable mechanism is
cc1command-line flags, which is the build system's job. - One policy, every C++ library. A FastLED-local fix wouldn't help
libstdc++.a's 56 KB of eh_frame, the user's own translation units, or any other library in the project. fbuild can centralize it. - Already has the right policy hooks.
esp32_compiler.rsalready does conditional flag injection by board variant. The exception/panic detection is a one-method addition. - Aligned with existing
-fno-rttiand-fno-exceptionspolicy that fbuild already enforces centrally.
Acceptance criteria
-
EhFramePolicyenum +eh_frame_policy()method on ESP32 compiler config - sdkconfig parser recognizes
CONFIG_ESP_SYSTEM_PANIC_*family - Build profile + env var override (
FBUILD_KEEP_EH_FRAME/FBUILD_STRIP_EH_FRAME) for explicit user control - Auto-detect debug profile (
build_type = debug,-Og/-O0,!NDEBUG) - Unit tests on
EhFramePolicy::decide(...)matrix - Integration test: build
fastled/platformio-starterESP32-S3 withFBUILD_STRIP_EH_FRAME=1, assert binary size drops by ≥150 KB - Document in fbuild user docs (
docs/) under "Size optimization" - Parallel implementation hooks for ARM Cortex-M, RISC-V (where applicable)
Reproduction
git clone https://github.com/fastled/platformio-starter
cd platformio-starter
cp platforms/platformio.esp32s3.ini platformio.ini
# build via fbuild
fbuild build -e esp32s3
# inspect eh_frame in output
xtensa-esp32s3-elf-readelf --debug-dump=frames .fbuild/.../firmware.elf | grep -c "FDE cie"
# today: ~6500 FDEs survivingRelated
- ESP32-S3 binary size: 657 KB on master vs 344 KB on 3.10.3 — remaining +313 KB delta breakdown FastLED#2473 — full symbol-level evidence with byte counts
- [Tracking] ESP32-S3 timing regression + binary bloat from Channel API (from #2420) FastLED#2421 — original bloat investigation
- feat(fl/stl): add FL_NO_UNWIND macros, apply to ESP32 unity headers FastLED#2423 — the attempted FL_NO_UNWIND fix that turned out to be a no-op
- [Tracking] Break unwanted cross-TU references that drag optional subsystems into all builds (follow-up to #2421) FastLED#2424 — eh_frame cross-TU leakage cleanup
- feat(channels): compile-time Bus binding via templated ChannelConfig (eliminates Channel API binary bloat) FastLED#2428 — driver registry default-off (the other big win, already merged)
Activity
zackees commentedon May 12, 2026
Refinement: the strip is essentially risk-free on Xtensa (S2 / S3 / original ESP32)
While re-reading my own issue I realized the conservative gate I proposed (
CONFIG_ESP_SYSTEM_PANIC_PRINT_BACKTRACE=y→ preserve) is more pessimistic than the architectures actually require. Two different families need two different policies.What ESP-IDF actually uses to produce backtraces
.eh_frame?CALLX8/CALL8triggers a window rotation.esp_backtrace_get_next_frame()reads A0/A1 from the spill area.-fno-omit-frame-pointeris default for ESP-IDF RISC-V builds). Newer ESP-IDF versions can opt-in to DWARF unwinding for higher fidelity.This is straight from ESP-IDF source —
components/esp_system/port/arch/xtensa/esp_system_chip.cand the equivalentriscv/variant. The Xtensa backtrace function only reads the register-window spill area; it never touches.eh_frame.Implication for the policy
The full decision tree should be:
Why this matters
The dominant FastLED-on-ESP32 case is ESP32-S3 + Arduino framework +
framework = arduino— Xtensa, panic-backtrace ON by default. With the original conservative gate, that user gets no strip and stays at 657 KB. With the Xtensa-aware policy they get the full 225 KB strip without losing any diagnostic capability, because:.eh_frameto begin with.-fno-exceptions.build_type = debug) hit the Tier 1 preserve gate.RISC-V can be similarly aggressive
The
CONFIG_ESP_SYSTEM_USE_EH_FRAMEknob in ESP-IDF is opt-in, not default-on. So even on RISC-V the default config gets the strip. Only users who've explicitly opted into DWARF-based backtraces preserve their.eh_frame.Net impact for FastLED Blink on ESP32-S3
firmware.binSame number I cited originally — just achieved with a more permissive default that doesn't need users to flip an opt-in flag.
Updated acceptance criterion
Replace the original criterion 4 ("Auto-detect debug profile") with:
CONFIG_ESP_SYSTEM_USE_EH_FRAME=yis in sdkconfig; ARM/AVR preserve (out of scope, decided separately).The existing Tier 1 guards (exceptions enabled, user override, debug build) remain.
feat(build): #243 #244 strip .eh_frame on release builds across all G…