Skip to content

Symbol/size analysis: detect or prevent map-file 0x00000000 false positives (~95 KB phantom-bloat reported on FastLED#2473) #417

Description

@zackees

Summary

Map-file-based binary-size analysis is a trap: tree-shaken sections still
appear in the GNU ld map file with a real size field, but their
placement address is 0x00000000 — meaning the linker dropped them via
--gc-sections. Any analyzer that sums sizes without filtering on
address != 0x00000000 invents bloat that doesn't exist.

I just fell into exactly this trap on FastLED/FastLED#2473
(symbol audit comment)
and posted a meta-issue (FastLED/FastLED#2771) with ~95 KB of phantom
"wins" that the linker had already harvested
. Caught and corrected within
the hour, but only because a reviewer pushed back ("why aren't these
tree-shaken?"). A symbol-analysis tool sanctioned by fbuild — or even just
a documented gotcha — would have caught it on the first pass.

This issue asks: does fbuild's size/symbol analysis (today or planned)
correctly filter tree-shaken sections, or does it expose the same trap to
its consumers?

The trap, concretely

Stock GNU ld --gc-sections keeps the dropped section in the map for
debugging purposes, but assigns it placement address 0x00000000. Example
from a current FastLED master build of examples/Blink/Blink.ino for
esp32s3 (PIO 6.1.19, IDF 5.4, xtensa-esp-elf-gcc 14.2.0):

 .rodata.embedded
                0x00000000    0x110f8 .../libmbedtls.a(x509_crt_bundle.S.obj)

That looks like "68 KB Mozilla CA cert bundle in the binary." It is not —
xtensa-esp32s3-elf-nm firmware.elf | grep _binary_x509_crt_bundle_start
returns nothing. The linker dropped the whole 68 KB, and the map entry is
a tombstone.

Same on this build for:

Section Map says Actually placed? Reality
libmbedtls.a(x509_crt_bundle.S.obj) .rodata.embedded 69,880 B ❌ addr 0x00000000 tree-shaken
libmesh.a(mesh_parent.o) .rodata.str1.1 11,108 B ❌ addr 0x00000000 tree-shaken
libFastLED.a(third_party+.cpp.o) .rodata...huffTable 8,484 B ❌ addr 0x00000000 tree-shaken
libFastLED.a(third_party+.cpp.o) .rodata...PLM_AUDIO_SYNTHESIS_WINDOW 2,048 B ❌ addr 0x00000000 tree-shaken
libesp_common.a(esp_err_to_name.c.obj) .rodata.str1.1 6,006 B ✅ addr 0x3c0... live

A naive map-file parser claims ~95 KB of false-positive bloat if it
sums all four "dropped" rows. The actual rodata top-10 picture is
completely different once you filter on address != 0x00000000.

What fbuild has today

fbuild --help lists build / deploy / monitor / reset / purge / daemon / show / device / mcp / clang-tidy / iwyu / test-emu / clang-query / lnk / lib-select / compile-many / ci. No size / analyze / bloat /
symbols subcommand.

fbuild#297 added build_info.json emission so downstream FastLED tooling
(ci/symbol_analysis_runner.py, ci/inspect_elf.py,
ci/compiled_size.py) can find the ELF + toolchain paths. Those tools
operate on nm/readelf output of the linked ELF and so do not have
the map-file trap by construction — they only see what is actually in the
binary.

But there is no fbuild-sanctioned path for the "what's the per-archive
contribution to rodata?" question, which is what tempts people into
map-file parsing in the first place. (The largest rodata symbols in
modern ESP32-S3 builds are anonymous merged .str1.1 string pools that
nm doesn't surface; you need the map file to attribute them to source
archives — and the map file is the trap.)

Ask

Pick one of:

A) Add fbuild size --bloat / fbuild analyze subcommand

Sanctioned, fbuild-supplied analyzer that:

  1. Uses the linked ELF as ground truth for "is this in the binary".
  2. For per-archive rodata attribution, parses the map file but filters
    sections placed at 0x00000000
    out of the totals.
  3. Cross-checks every "live" section it reports against nm/readelf on
    the ELF — anonymous string pools (.str1.*) get attributed to the
    .o file that declared them, but the size comes from the ELF's
    merged-string section, not from the map's pre-link figure.
  4. Prints output that distinguishes "live in firmware.bin" (truth) from
    "section listed in .map" (intermediate state).

Optional extras:

  • --top=N symbol leaderboard from ELF nm.
  • Per-archive rodata/text/data split.
  • --diff PRIOR.json to track size deltas across commits.
  • JSON output for CI gating.

B) Document the map-file trap prominently in fbuild docs

If implementing A is out of scope, at minimum a short doc page (docs/size-analysis-gotchas.md?) with:

  • The 0x00000000 filter rule.
  • A vetted one-liner awk / python recipe.
  • A pointer at the build_info.json contract so people know they should
    drive analysis off the ELF, not the map.

C) Sanctioned fbuild_size_helpers.py library

Drop-in Python module that ships in the fbuild distribution with vetted
implementations of the map-file filter, the per-archive grouper, and the
ELF cross-check. Then FastLED's ci/ scripts (and other downstream
consumers) can import the helpers instead of each rolling their own
awk-soup.

Why this matters now

FastLED's binary-size investigation cycle right now is:

  1. User reports the binary grew.
  2. Someone runs xtensa-esp32s3-elf-size -A + nm + objdump + ad-hoc
    awk over the map.
  3. The audit either misses real wins or invents phantom ones (this case).
  4. A reviewer catches it, the headline numbers get walked back.
  5. Repeat in three months.

A 1-shot fbuild size --bloat that reliably distinguishes
"actually-linked" from "in-the-map-but-dropped" would short-circuit that
loop and stop the embarrassment of headline corrections.

Reproduction

# Build (PIO backend; fbuild backend currently hangs on cold ESP32-S3 — fbuild#117)
bash compile esp32s3 --examples Blink --platformio

MAP=.build/pio/esp32s3/.pio/build/esp32s3/firmware.map
ELF=.build/pio/esp32s3/.pio/build/esp32s3/firmware.elf

# NAIVE rodata audit — what a first-time analyzer would write:
awk '/^ \.rodata/ {section=$0; getline; size=strtonum($2); src=$3; \
     if (size>=500) print size "\t" section "\t" src }' "$MAP" \
  | sort -nr | head -10
# Output includes: x509_crt_bundle (69880), main.cpp.o (57902),
# mesh_parent (11108), ... <- WRONG, three of these are tree-shaken

# CORRECT rodata audit — filter out dropped sections:
awk '/^ \.rodata/ {section=$0; getline; addr=$1; size=strtonum($2); src=$3; \
     if (addr != "0x00000000" && size >= 500) print size "\t" section "\t" src }' "$MAP" \
  | sort -nr | head -10
# Output: main.cpp.o (57902), libesp_common (6006), ... <- mbedtls/mesh gone

# Cross-check against linked-ELF ground truth:
xtensa-esp32s3-elf-nm "$ELF" | grep _binary_x509_crt_bundle_start  # empty
xtensa-esp32s3-elf-nm "$ELF" | grep huffTable                       # empty
xtensa-esp32s3-elf-nm "$ELF" | grep PLM_AUDIO_SYNTHESIS              # empty

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions