You changed three lines and the firmware grew by 18 KiB. Or a library appeared in the image even though nobody remembers calling it. A linker map is where that mystery usually becomes a trail you can follow.
The map is a report produced while linking. It connects input objects and archive members to output sections, addresses, symbols, and—depending on the linker and options—discarded sections or cross-references. It does not replace the ELF file, and it is not a byte-for-byte memory profiler.
Let the compiler driver carry the message
#include <stdio.h>
static int unused(void) { return 99; }
int helper(int x) { return x * 2; }
int main(void) {
printf("%d\n", helper(21));
return 0;
}A deliberately small program with one reachable function and one candidate for dead-code removal.
Why this tiny program is revealing
helperis reachable frommain, so its code should survive a normal link.unusedhas internal linkage and no caller. Separate function sections let--gc-sectionsdiscard it.printfalso demonstrates that a seemingly small program pulls in startup and dynamic-linking machinery on a typical hosted Linux build.The source was compiled locally with GCC 13.3.0; the same experiment was not run with Clang because Clang is not installed in the verification environment.
gcc -ffunction-sections -fdata-sections main.c -Wl,--gc-sections,-Map=app.map,--cref -o appOne comma can change which program sees an option
GCC is the compiler driver here.
-Wl,optionforwards each comma-separated item to the linker.-Map=app.mapis a GNU ld linker option;-Wl,-Map=app.mapis its driver spelling.-Wl,-Map,app.mapis another common forwarding form.--gc-sectionsasks the linker to remove unused input sections; the compiler’s-ffunction-sections -fdata-sectionsmakes functions and data easier to collect independently.--crefadds a cross-reference table. It can make a map much larger, so enable it when symbol provenance matters.This exact command completed locally using GCC 13.3.0 and GNU ld 2.42. It produced a 2,859-line map and an executable that printed
42.
Clang uses the same courier pattern, not one universal report format
clang main.c -Wl,-Map=app.map -o app
clang -fuse-ld=lld main.c -Wl,-Map=app-lld.map -o app-lldThe driver is not the linker
Clang accepts
-Wl,<arg>and-Xlinker <arg>to pass arguments through. The linker actually selected depends on the target, installation, build system, and-fuse-ld=choice.-fuse-ld=lldrequests LLVM lld; it is not proof that every machine has lld installed.GNU ld, gold, and lld maps differ in headings, ordering, detail, and supported extensions. Build analysis around the linker you pin, not a parser that assumes every map is GNU ld text.
These Clang commands are documentation-validated templates, not local execution claims: neither Clang nor lld was installed on the test host.
Calling ld directly changes the contract
ld -Map=firmware.map -T memory.ld -o firmware.elf startup.o main.o drivers.aDirect control also means direct responsibility
There is no
-Wl,prefix when invokingld; the options already belong to the linker.-T memory.ldselects a custom linker script that defines the placement rules and, commonly, target memory regions.A compiler driver normally supplies startup files, runtime libraries, search paths, emulation details, and target defaults. Calling
lddirectly means you must provide the pieces appropriate to the ABI.The command is an unexecuted firmware template because this repository does not provide matching startup objects, linker script, runtime, or target hardware.
For normal C or C++ applications, prefer the compiler driver so its runtime and target choices remain consistent.
Read the map from causes toward consequences
A GNU ld map is easiest to approach in passes. First identify why archive members were loaded. Then inspect discarded input sections. Read memory regions and the final output-section layout. Only after that chase individual symbols.
Confirm the command, linker identity, output artifact, and linker script for this build.
Look for archive-member inclusion explanations. An undefined symbol can cause one member to be extracted; that member can introduce another undefined symbol and pull a chain behind it.
Inspect “Discarded input sections” when garbage collection is enabled. Presence there means an input section did not enter the final image; it does not mean the source was never compiled.
Read “Memory Configuration” as linker-script regions and their attributes. A default region spanning nearly every address is not a physical RAM measurement.
Under “Linker script and memory map,” follow output sections such as
.text,.rodata,.data, and.bss, then their contributing input sections and symbols.Use a cross-reference table to find which input files define and reference a symbol, while remembering that optimization and link-time optimization can transform what remains visible.
Corroborate the report with the ELF
readelf -S --wide app
size -A -d app
nm -S --size-sort app[16] .text PROGBITS ... 00012a ... AX ...
[25] .data PROGBITS ... 000008 ... WA ...
[26] .bss NOBITS ... 000008 ... WA ...
0000000000001149 0000000000000012 T helperThree tools answer three narrower questions
readelf -Sreads ELF section headers and distinguishes types such as PROGBITS and NOBITS.size -A -dprovides a section-oriented size summary in decimal; interpretation still depends on the target’s load and runtime model.nm -S --size-sortlists symbols with recorded sizes where available. Stripped symbols, aliases, generated symbols, and optimizer transformations limit what it can explain.The abbreviated output above was observed on the locally produced x86-64 ELF with GNU Binutils 2.42. Ellipses replace columns that do not help this example.
The map showed
.text.unusedamong discarded input sections andhelperat address0x1149, matching the ELF symbol evidence.
Make a map useful in an embedded memory budget
Define ROM, RAM, external flash, tightly coupled memory, or other real regions in the linker script’s
MEMORYblock; map region limits only mean something when the script models the board.Distinguish virtual address, load memory address, and file offset. Initialized
.datacommonly has bytes stored in nonvolatile memory and copied into RAM during startup.Count
.bssand no-load buffers against runtime memory even when they do not enlarge a raw payload in the same way.Account for heap, stack, DMA pools, reserved memory, bootloader gaps, alignment, and guard regions that may not appear as ordinary allocated symbols.
Use linker assertions for hard boundaries where possible. A CI parser and a map report are helpful; a link that fails on overflow is harder to ignore.
Validate the final image with the flashing/packaging tool and on the target. ELF sections can be transformed into segments, raw binaries, signed containers, or compressed images.
Diff builds without letting noise tell the story
diff -u baseline.map candidate.map
readelf -n app | sed -n "/Build ID/p"A textual diff is a lead, not a regression verdict
Addresses and symbol order can cascade after one early size change, producing a large diff for a small underlying cause.
Temporary object paths, archive order, linker version, timestamps, build IDs, debug settings, profile data, random seeds, and environment differences can add noise.
Compare like with like: same source revision, target, optimization, feature flags, linker script, toolchain, libraries, and reproducibility settings.
Prefer normalized section and symbol-size data for automated budgets, but keep the raw map for investigation. Never silently discard duplicate symbol names or archive-member identity.
diffreturns zero for identical files, one for differences, and a value greater than one for an error. CI must distinguish a real change from a failed comparison.The commands are safe inspection templates; this specific baseline/candidate pair was not created during local verification.
Why the object you expected never appears
No map file exists: verify the option reached the final link step rather than a compile-only command. Build systems often hide the actual link invocation; request verbose output.
The map is empty or stale: make the build fail if the expected output was not freshly produced, and clean only the targeted build directory—not unrelated artifacts.
An archive member is absent: static archives are normally extracted to satisfy unresolved symbols. Check link order, groups,
--whole-archive, and whether the symbol was already resolved.A function is discarded: inspect reachability, section garbage collection, linker-script
KEEP(), and references the optimizer can see. Do not globally disable collection just to hide the symptom.A symbol vanished under LTO: link-time optimization can inline, merge, internalize, or eliminate code; inspect the actual optimized link and its plugin-compatible tools.
Map totals disagree with `ls -l`: file size includes headers and non-allocated data, while runtime memory follows allocated sections or load segments. Decide which budget you are measuring.
The parser broke after a toolchain update: map text is linker-specific. Pin/test the toolchain or consume a more structured linker output when the selected linker offers one.
Treat maps as engineering artifacts, not public attachments
A map may expose function names, source and object paths, third-party library versions, memory addresses, feature names, or proprietary layout. That makes it useful to engineers—and useful to someone profiling the product from outside.
Restrict CI artifact access and retention to the team’s actual diagnostic needs.
Avoid embedding developer home paths by using supported reproducible-build path remapping and controlled build roots.
Do not publish a production map merely because the shipped binary is already stripped.
Keep maps out of deployable packages unless the product has a deliberate reason to include them.
For crash symbolication, use a controlled symbol and build-ID pipeline; a linker map alone may be insufficient.
The moment the map earns its keep
Do not begin with “Why is the map so large?” Begin with the change you care about: flash increased, RAM overflowed, or an unexpected library arrived. Then use the map to connect that consequence back to a section, an input file, and the symbol or reference that invited it in.
That shift—from admiring a wall of addresses to asking one precise question—is when the linker map stops feeling like linker folklore and starts becoming part of your debugging practice.
Comments and corrections