Skip to content

ESP32: auto-strip .eh_frame when exception handling and panic backtrace are both off (~225 KB savings on FastLED Blink) #243

Closed

Description

@zackees

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:

  1. 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).
  2. 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 =
    FastLED
  • framework = arduino → ESP-IDF panic prints backtrace by default → EhFramePolicy::Preserve
  • No build_flags override → fbuild's own defaults apply
  • monitor_filters = esp32_exception_decoder is 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

  1. 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 cc1 command-line flags, which is the build system's job.
  2. 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.
  3. Already has the right policy hooks. esp32_compiler.rs already does conditional flag injection by board variant. The exception/panic detection is a one-method addition.
  4. Aligned with existing -fno-rtti and -fno-exceptions policy that fbuild already enforces centrally.

Acceptance criteria

  • EhFramePolicy enum + 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-starter ESP32-S3 with FBUILD_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 surviving

Related

Activity

  1. zackees commented on May 12, 2026

    @zackees
    MemberAuthor

    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

    Target family Backtrace mechanism Uses .eh_frame?
    Xtensa (ESP32 / S2 / S3) Walks register windows — each function's A0 (return PC) and A1 (stack pointer) are spilled by Xtensa's windowed-ABI hardware when the next CALLX8/CALL8 triggers a window rotation. esp_backtrace_get_next_frame() reads A0/A1 from the spill area. No. Zero dependency on DWARF unwind tables.
    RISC-V (C3 / C6 / H2 / C5 / P4) Walks frame pointers (-fno-omit-frame-pointer is default for ESP-IDF RISC-V builds). Newer ESP-IDF versions can opt-in to DWARF unwinding for higher fidelity. Frame-pointer path: no. DWARF path: yes (only if explicitly enabled).

    This is straight from ESP-IDF source — components/esp_system/port/arch/xtensa/esp_system_chip.c and the equivalent riscv/ 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:

    fn eh_frame_policy(target: Target, project_cfg: &ProjectConfig) -> EhFramePolicy {
        // Tier 1: hard requirements always preserve
        if project_cfg.exceptions_enabled()         // -fexceptions explicitly
           || project_cfg.user_override_keep()       // FBUILD_KEEP_EH_FRAME=1
           || project_cfg.debug_build()              // -Og / -O0 / !NDEBUG
        {
            return EhFramePolicy::Preserve;
        }
    
        // Tier 2: per-architecture default
        match target.arch() {
            Arch::Xtensa => {
                // ESP-IDF panic backtrace uses register-window walking on Xtensa.
                // .eh_frame contributes nothing functional on Xtensa with
                // -fno-exceptions. Safe to strip unconditionally.
                EhFramePolicy::Strip
            }
            Arch::RiscV => {
                // ESP-IDF panic backtrace defaults to frame-pointer walking on
                // RISC-V. However newer ESP-IDF versions can opt into DWARF-based
                // unwinding for higher-fidelity backtraces if the user enables it.
                if project_cfg.sdkconfig_has("CONFIG_ESP_SYSTEM_USE_EH_FRAME=y") {
                    EhFramePolicy::Preserve
                } else {
                    EhFramePolicy::Strip
                }
            }
            Arch::Arm | Arch::Avr => {
                // Out of scope for this issue, but the same shape applies.
                EhFramePolicy::Preserve  // (existing behavior, decide separately)
            }
        }
    }

    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:

    1. Their panic backtrace still works — it never read .eh_frame to begin with.
    2. C++ exceptions still don't work — but they weren't working anyway with -fno-exceptions.
    3. GDB JTAG debugging still works — debug builds (build_type = debug) hit the Tier 1 preserve gate.

    RISC-V can be similarly aggressive

    The CONFIG_ESP_SYSTEM_USE_EH_FRAME knob 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

    Build Today With Xtensa-default strip
    firmware.bin 657,072 B ~432,000 B (−225 KB, −34%)

    Same 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:

    • Per-architecture default: Xtensa strips by default (Tier 1 only); RISC-V strips by default unless CONFIG_ESP_SYSTEM_USE_EH_FRAME=y is in sdkconfig; ARM/AVR preserve (out of scope, decided separately).

    The existing Tier 1 guards (exceptions enabled, user override, debug build) remain.

  2. added a commit that references this issue on May 13, 2026
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

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions