fbuild symbols produces a fine-grained, attributed report of every
live symbol in an ELF: name, size, region (flash / ram), source
archive, object file, and output section. It's the same analysis the
build orchestrator emits when you pass --symbol-analysis to
fbuild build, but as a standalone subcommand that works against any
ELF — fbuild-built, PlatformIO-built, or hand-compiled.
Point it at a project directory:
fbuild symbols .Or at an ELF directly:
fbuild symbols .fbuild/build/uno/firmware.elf
fbuild symbols .pio/build/esp32s3/firmware.elfNo --nm. No --cppfilt. No manual ELF / map paths. Toolchain paths
come from the build_info.json fbuild (or PlatformIO) wrote next to
the ELF.
The analyzer needs four binaries: nm (lists symbol sizes), c++filt
(demangles C++ names), and optionally readelf / objdump for
downstream tools. These are looked up in this order:
--nm <path>/--cppfilt <path>flags (highest precedence; the user's explicit override always wins).--build-info <path>— loadnm_path/cppfilt_pathfrom that file, accepting native fields or partial PlatformIOaliases.- Auto-discovery — walk up from the ELF's directory looking for
build_info.jsonorbuild_info_<env>.json. Both fbuild and PlatformIO write one next toplatformio.ini. - PATH lookup of bare
nm(and derivec++filtfrom its stem). - Hard error with a message pointing at all four sources.
fbuild build populates the nm_path, cppfilt_path, readelf_path,
and objdump_path fields (and a PIO-shape aliases block mirroring
them) in build_info_<env>.json so step 3 just works.
$ fbuild build .
# … link succeeds, writes .fbuild/build/esp32s3/firmware.elf +
# build_info.json with toolchain paths …
$ fbuild symbols .
# resolves ELF via discover_elf_in_project()
# resolves nm/c++filt via build_info.json
# emits text report to stdoutPass --output-dir <dir> to drop both report.json and report.md
into the directory side-by-side. Pass --json <path> for JSON only.
PlatformIO emits its own build_info_<env>.json (with an aliases
block) next to platformio.ini. fbuild symbols .pio/build/esp32s3/firmware.elf
walks up from .pio/build/esp32s3/ to the project root, finds it, and
reads the toolchain paths from there. No --nm needed.
Multi-environment metadata selects the environment whose prog_path matches
this ELF, or the ELF's build/<env>/ directory (including nested build profiles).
Automatic discovery prefers the matching build_info_<env>.json, then
build_info.json. Ambiguous
metadata files or environments and malformed metadata are explicit errors;
fbuild does not silently substitute host tools.
An explicit --nm also selects its sibling c++filt and objdump, rather
than mixing that toolchain with tools from build metadata. --cppfilt still
has highest precedence.
When no metadata exists, the analyzer falls back to bare nm on PATH,
which works only for host ELFs. For a copied cross-toolchain ELF, pass
--nm or --build-info explicitly.
build_info.json carries a top-level aliases block keyed by short
PIO tool names so PlatformIO consumers (FastLED
ci/util/symbol_analysis.py, ci/inspect_binary.py, etc.) can read
both PIO- and fbuild-built artifacts uniformly:
{
"esp32s3": {
"prog_path": "...",
"cc_path": "/.../xtensa-esp32s3-elf-gcc",
"cxx_path": "/.../xtensa-esp32s3-elf-g++",
"size_path": "/.../xtensa-esp32s3-elf-size",
"nm_path": "/.../xtensa-esp32s3-elf-nm",
"cppfilt_path": "/.../xtensa-esp32s3-elf-c++filt",
"readelf_path": "/.../xtensa-esp32s3-elf-readelf",
"objdump_path": "/.../xtensa-esp32s3-elf-objdump",
"aliases": {
"gcc": "/.../xtensa-esp32s3-elf-gcc",
"g++": "/.../xtensa-esp32s3-elf-g++",
"size": "/.../xtensa-esp32s3-elf-size",
"nm": "/.../xtensa-esp32s3-elf-nm",
"c++filt": "/.../xtensa-esp32s3-elf-c++filt",
"readelf": "/.../xtensa-esp32s3-elf-readelf",
"objdump": "/.../xtensa-esp32s3-elf-objdump",
"ar": "/.../xtensa-esp32s3-elf-ar",
"objcopy": "/.../xtensa-esp32s3-elf-objcopy"
}
}
}Alias keys are only present when the corresponding path is non-empty;
consumers can rely on "nm" in aliases meaning the path is real.
Every row in report.json carries a referenced_by array of
translation units that referenced the symbol, parsed from GNU ld's
Cross Reference Table block in the linker map:
{
"demangled": "_vfprintf_r",
"size": 11309,
"archive": "libc.a",
"object": "libc_a-vfprintf.o",
"source": "nm",
"referenced_by": [
{ "archive": "libc.a", "object": "libc_a-vprintf.o" },
{ "archive": "libc.a", "object": "libc_a-printf.o" },
{ "archive": "libc.a", "object": "libc_a-fprintf.o" }
]
}The defining TU is intentionally omitted — that information is already
on the row's own archive + object fields. The list contains only
referencers, so a bloat audit can answer "why was this symbol
linked?" without grepping the map file by hand.
report.md exposes the same data as a Referenced by column on the
top-N tables, rendered as archive(object) strings truncated to the
top three with a (… and N more) overflow. The cell is - when the
linker map has no Cross Reference Table block — possible with older
ld versions or when the build was linked with -Wl,--no-cref.
cref granularity is archive-member (.o), not symbol — that's a
property of ld --cref itself. fbuild does not synthesize per-symbol
back-references; ask nm/objdump/addr2line for that.
See #459 for the motivating bloat audit.
This is the trap that bit FastLED #2473's symbol audit: GNU ld
keeps dropped sections in the .map for debugging, but assigns them
placement address 0x00000000. A naive map-file parser that sums
sizes without filtering produces phantom bloat for sections that
--gc-sections already harvested.
fbuild bloat and fbuild symbols handle this for you — the
parse_linker_map step in fbuild_core::symbol_analysis drops every
input-section range whose addr == 0 before attribution runs, and a
parse_linker_map_drops_fastled_2473_tombstones regression test
pins the exact rows from the original audit
(x509_crt_bundle.S.obj, mesh_parent.o, huffTable,
PLM_AUDIO_SYNTHESIS_WINDOW). PT_LOAD filtering on the nm side
catches the sibling case (linker-script boundary symbols like
__StackTop whose nm-reported "size" is just the gap to the next
symbol). Together: anything fbuild bloat's report.json lists as
live is actually placed in the firmware image.
If you're rolling your own analyzer outside fbuild, always filter
address != 0x00000000 before summing per-archive contributions.
The exact awk invocation that catches the trap is captured in
fbuild#417; the
sanctioned answer is "use fbuild bloat . and skip the awk".
- Issue #417 — the tree-shaken-sections trap that motivated documenting this gotcha.
- Issue #428 — toolchain-path schema and CLI auto-discovery (this doc).
- Issue #434 — the
fbuild bloatmeta that subsumessymbols. - Issue #459 —
referenced_bycref back-references on every row. - PR [#424] / PR [#427] — the fine-grained analyzer and map-derived rodata attribution this CLI drives.
Reports include reference_analysis alongside the legacy name lists. Each
analysis pass (disassembly, static_data, object_references) records
analyzed, unavailable, or error, its tool when applicable, and a reason.
An analyzed pass means the supported extraction ran; it does not claim a
complete runtime call graph.
Edges identify each endpoint by (name, address, source) so a function and its
map-derived literal/string pools cannot be confused. Edge kinds are:
disassembly: an address-qualified symbol annotation in the final ELF's disassembly, which may be a code or data reference, not necessarily a call.static_data: an absolute function pointer in an allocated Itanium vtable;offsetis the pointer slot's byte offset from the owning vtable.fragment_owner: ownership recorded by the compiler's map input-section name. This is attribution evidence, not proof of a runtime access.
Allocated V/v weak objects are included and classified using their ELF
sections. AVR's two-byte word-addressed function pointers and ARM's Thumb bit
are handled explicitly. Relative vtables and function-descriptor ABIs are not
reconstructed. Arbitrary integers in data are not guessed to be pointers.
Static extraction reports an error for position-independent/shared ELFs until
their dynamic relocations are supported, rather than claiming an empty analysis.
roots records confirmed ELF entry points, including unsized entries omitted
from the byte tables. unresolved retains external/ROM/local targets with their
addresses; unexplained retains positive-size rows without a recorded incoming
edge, object reference, or confirmed root. Empty lists do not prove unused code:
indirect dispatch, generic callback tables, KEEP directives and additional
platform linker roots may remain unexplained. limitations states that scope
in the transport so consumers can show it alongside the graph.
Tool discovery accepts the diagnostic paths in native build metadata and the
partial per-environment aliases metadata used by external builders. It
selects the ELF's matching environment and rejects ambiguous or invalid
metadata instead of silently substituting host tools. Explicit --nm selects
its sibling tools before metadata from a different toolchain.
Graphviz graphs and per-row sidecars consume the same typed identities and
edge kinds. Static pointers and fragment ownership are labeled separately from
instruction references. Sidecars root at the exact selected row, so data pools
sharing a function's name remain distinct. For ambiguous local names, the CLI
accepts --symbol 'name@0xADDRESS'; it rejects an ambiguous name instead of
choosing an arbitrary function.