Part 1 explained the linker script. Part 2 explained the startup and the vector table. Part 3 built the ELF.
Now we look at the artifact that quietly ties all of those together: the linker map file.
If you ever wonder “did my vector table really end up at the flash base?” or “why is my .data empty?” or “what did –gc-sections remove?” — the map file is where the answers live. 1
What is a “map file”?
A map file is a human-readable report produced by the linker that shows:
- the memory regions (
MEMORY) the linker knows about, - which object files and libraries were linked,
- which output sections exist and where they were placed (addresses + sizes),
- which input sections contributed to them,
- and (optionally) a cross-reference of symbols.
GNU ld supports map generation via -Map=<file> and a cross-reference table via --cref (both enabled in Part 3). 2 3
1) Memory Configuration
Map file contains a Memory Configuration block:
Name Origin Length Attributes
FLASH 0x08000000 0x00100000 xr
RAM 0x20000000 0x00050000 xrw
This is exactly the MEMORY block defined back in Part 1 (Flash at 0x08000000, RAM at 0x20000000), now reflected as the linker’s internal memory regions.
Why it matters: if this section is wrong, everything that follows is suspect (addresses, placement, even the validity of the ELF). ld’s linker scripts exist to map input sections into output sections and control memory layout, which is precisely what we’re validating here.
2) Discarded input sections
At the very top of the file:
Discarded input sections
.text 0x00000000 0x0 ... main.cpp.obj
.data 0x00000000 0x0 ... main.cpp.obj
.bss 0x00000000 0x0 ... main.cpp.obj
.text.SystemInit 0x00000000 0xc ... main.cpp.obj
...
This confirms:
Section-based garbage collection is active. When project is compiled with
-ffunction-sections/-fdata-sectionsand linked with--gc-sections, the linker discards whole sections that are not reachable from a root set. This is the standard mechanism behind “strip unused code/data” in embedded builds. 4SystemInit()function is present in the object file (as.text.SystemInit) but is unused in this minimal project, so it gets dropped. The map explicitly lists.text.SystemInitas discarded (size0xc).
This discard happens because Reset_Handler calls only main() (and not SystemInit()), the linker sees no references to SystemInit() and garbage-collects it. Thus when some function is expected to be present, but it appears under this header, it means that none of the other functions referenced it.
3) Linker script and memory map
This block lists everything ld loaded:
LOAD ... main.cpp.obj
LOAD ... startup.c.obj
LOAD ... libstdc++.a
LOAD ... libm.a
START GROUP
LOAD ... libgcc.a
LOAD ... libg.a
LOAD ... libc.a
END GROUP
Even though this is a bare-metal project, toolchain still pulled in standard libraries (libstdc++, libc, libgcc, etc.) to the final ELF. They can be disabled with -nostdlib, which is not used here. 5
The important thing is that main and startup objects are there and are linked before the library group — which is exactly what is needed for custom startup and vector table to dominate.
Also note: the linker is controlled by a script, and scripts define how input sections are mapped into output sections. That’s the core purpose of ld linker scripts.
4) The .text output section
Here’s the most satisfying part of the map:
.text 0x08000000 0x36
.isr_vector 0x08000000 0x10 ... startup.c.obj
0x08000000 vector_table
.text.main 0x08000010 0x12 ... main.cpp.obj
0x08000010 main
.text.Reset_Handler 0x08000022 0xc ... startup.c.obj
0x08000022 Reset_Handler
.text.Default_Handler 0x0800002e 0x8 ... startup.c.obj
0x0800002e Default_Handler
This is the direct, mechanical proof that Parts 1 and 2 are wired correctly:
- The vector table is placed at
0x08000000and is exactly0x10bytes (4 entries × 4 bytes), matching minimal startup table. main()follows immediately at0x08000010.Reset_HandlerandDefault_Handlerare placed next, also in flash.
This reflects the linker script rule “place .isr_vector first in .text and put the whole .text output section into FLASH”. ld scripts define the mapping of input sections to output sections and their addresses (VMA), which is exactly what we’re observing.
Why the addresses matter
On Cortex‑M, the CPU expects a valid vector table at the configured vector-table base; in this minimal project, that’s the flash base, so seeing .isr_vector at 0x08000000 is the “boots at all” requirement.
5) .data and .bss
The map shows:
.data
.igot.plt 0x20000000 0x0 load address 0x08000036
.bss
No actual .data or .bss payload is present. That’s expected for this minimal project because:
- no initialized globals/statics present, so
.datacontributes nothing, - no zero-initialized globals/statics present, so
.bsscontributes nothing.
The map still shows the RAM base 0x20000000 reserved as the start of .data, which matches the linker script’s > RAM placement.
This ties back to Part 2: since .data/.bss are empty, minimal Reset_Handler that just calls main() happens to be “good enough” for now. As soon as there are globals that require initialization, .data copy and .bss zeroing must be implemented, otherwise the language runtime expectations are violated.
6) Debug sections
Near the end there is a bunch of .debug_* sections:
.debug_info ...
.debug_line ...
.debug_str ...
They’re shown with address 0x00000000 because they are not loadable runtime sections — they are DWARF debug information stored in the ELF for tools like debugger. Linker scripts and ld treat debug sections differently from loadable code/data sections.
This is also why ELF can be tiny in flash (especially when converted to binary format, something that will happen later) but still contain a lot of debug metadata for source-level debugging.
7) Cross Reference Table
Because of --cref flag, the map file ends with a symbol→object mapping:
Default_Handler ... startup.c.obj
Reset_Handler ... startup.c.obj
SystemInit ... main.cpp.obj
main ... main.cpp.obj
vector_table ... startup.c.obj
This is insanely useful for two cases:
- “Why do I have two symbols with the same name?” (duplicate definitions show up immediately)
- “Where did this symbol actually come from?” (especially important once linking to third party libraries, such has HAL drivers, starts)
Practical checks I do every time
When I build a any firmware like this, I sanity-check the map file in this order:
- Memory Configuration matches the linker script regions (Flash/RAM origin + lengths)
.isr_vectoris at the flash base and the size matches my vector table entries.textstarts where expected and containsmain,Reset_Handler, etc.- Unexpected code in “Discarded input sections” (usually a missing reference or symbol is not explicitly specified to be kept by a linker script or some other option) 4
.data/.bsssizes (once the program grows, this is where RAM pressure shows up first)
Next
In Part 5 I’ll actually use this ELF.
References
GNU ld: Options –print-map: https://sourceware.org/binutils/docs/ld/Options.html#index-link-map ↩︎
GNU ld: Options -Map: https://sourceware.org/binutils/docs/ld/Options.html#index-_002dMap_003dmapfile ↩︎
GNU ld: Options –cref: https://sourceware.org/binutils/docs/ld/Options.html#index-cross-reference-table ↩︎
GNU ld: Options –gc-sections: https://sourceware.org/binutils/docs/ld/Options.html#index-_002d_002dgc_002dsections ↩︎ ↩︎
GNU ld: Options -nostdlib: https://sourceware.org/binutils/docs/ld/Options.html#index-_002dnostdlib ↩︎