Programming · 55 pages
- C language — 15 lessonstutorial
- Lesson 1 — Hello, C
- Lesson 2 — Types & literals
- Lesson 3 — Expressions & operators
- Lesson 4 — Control flow
- Lesson 5 — Functions
- Lesson 6 — Arrays
- Lesson 7 — Pointers
- Lesson 8 — Strings
- Lesson 9 — Structs & unions
- Lesson 10 — Dynamic memory
- Lesson 11 — Preprocessor
- Lesson 12 — Multi-file projects
- Lesson 13 — Standard library
- Lesson 14 — Undefined behavior
- Lesson 15 — Modern C (C11/17/23)
- C++ programming — 15 lessonstutorial
- Lesson 1 — Hello, C++
- Lesson 2 — References & namespaces
- Lesson 3 — Classes & RAII
- Lesson 4 — Inheritance & polymorphism
- Lesson 5 — Templates
- Lesson 6 — STL containers
- Lesson 7 — Iterators & algorithms
- Lesson 8 — Smart pointers
- Lesson 9 — Move semantics
- Lesson 10 — Lambdas
- Lesson 11 — Error handling
- Lesson 12 — Concurrency
- Lesson 13 — Modules (C++20)
- Lesson 14 — Modern C++23
- Lesson 15 — C++ on Cortex-M
- Bare-metal C tutorial — 10 lessonsembedded
- Lesson 1 — Toolchain setup
- Lesson 2 — Bare-metal blink
- Lesson 3 — Registers in C
- Lesson 4 — Memory map
- Lesson 5 — Linker script
- Lesson 6 — Interrupts & ISRs
- Lesson 7 — Bit patterns
- Lesson 8 — Fixed-point math
- Lesson 9 — State machines
- Lesson 10 — Ring buffers + DMA
- Embedded C patterns
- volatile & memory-mapped IO
- CMSIS-Core
- CMSIS-DSP
- Linker scripts
- Startup & vector table
- C++ on Cortex-M
- Rust on Cortex-Mmodern
- Inline assembly
- Bare-metal libc & newlib-nano
- Bootloaders & DFU
- OTA update flow
Lesson 5 — A minimum linker script you understand
Annotate the linker.ld from lesson 2 line-by-line. MEMORY, SECTIONS, AT-load, PROVIDE, ENTRY. Add a bootloader region. Reserve heap and stack.
- What you'll learn
- Line-by-line linker script anatomy, AT-load, PROVIDE, ENTRY, adding regions, bumping RAM, reserving a bootloader area
- Code provided
- About 90 lines of linker script, three progressively-richer versions
- Time
- 60-75 minutes
- Hardware
- STM32F411 Nucleo or any Cortex-M4 board
The linker script in lesson 2 was 25 lines and looked like magic. This lesson dispels the magic. We annotate every directive, then progressively extend the script to handle real-world cases: chips with more RAM, a reserved bootloader area at the top of flash, named regions for ITCM/DTCM, and proper stack/heap reservation.
By the end you will read any vendor linker script the same way you read C — line by line, knowing what each construct does and why it is there.
The lesson-2 script, annotated
ENTRY(Reset_Handler)ENTRY sets the program's entry symbol. The resulting ELF file's header records this address. It is not what the chip jumps to on reset — the chip always jumps to whatever address is in word 1 of the vector table. ENTRY matters when you load via a debugger or when another tool inspects "where does this program start". GDB's run command uses it. arm-none-eabi-objdump --start-address=ENTRY uses it. Setting it to Reset_Handler matches what the hardware will actually do; setting it wrong does not crash anything but confuses your debugger.
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx): ORIGIN = 0x20000000, LENGTH = 128K
}MEMORY declares the physical regions the linker is allowed to place things in. Each entry has three parts:
- Name (
FLASH,SRAM) — referenced below with> NAME. - Attribute string in parens. The letters are
r(readable),w(writable),x(executable). They do not affect placement; the linker just warns if a section's attributes do not match (e.g., placing executable code in a region markedrw). ORIGINandLENGTH— the physical address range.
512 K of flash and 128 K of SRAM matches the STM32F411RE. For a different chip you change the LENGTH (and sometimes ORIGIN — STM32L4 puts SRAM at 0x1000_0000 for the first 32 K). The reference manual tells you the actual numbers; the linker has no idea what chip you have.
_estack = ORIGIN(SRAM) + LENGTH(SRAM);This computes the top-of-SRAM address (0x2000_0000 + 0x00020000 = 0x2002_0000) and assigns it to a linker symbol. The vector table at 0x0800_0000 references _estack; the hardware loads MSP with this value at reset.
The stack grows down from this address. Anything in SRAM below the stack top can be overwritten as the stack grows.
SECTIONS
{The SECTIONS block tells the linker how to assemble output sections from input sections and where to place each output section. Each block inside is one output section.
.isr_vector : {
KEEP(*(.isr_vector))
} > FLASH.isr_vector is the first output section, placed first in FLASH (because the regions inside SECTIONS are emitted in order). The input is "every .isr_vector input section from every input object file" — that is what the wildcard *(.isr_vector) means.
KEEP(...) is critical. Without it, GCC's --gc-sections would discard the vector table because nothing in your C code calls into it from a tracked path. The Cortex-M boot ROM "calls" it by hardware, which the linker cannot see. KEEP is the override: "do not garbage-collect this even if it looks unreferenced".
.text : {
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASHThe .text output section collects every input .text section (your code) and every .rodata section (read-only data — const arrays, string literals). The * wildcards match any input section name starting with .text — which catches .text.main, .text.delay, etc., when you compile with -ffunction-sections (each function gets its own subsection so --gc-sections can discard unused ones).
. = ALIGN(4); is a fence: advance the location counter to a 4-byte boundary. Necessary so that _etext is aligned and the following .data AT-load starts on a word boundary.
_etext = .; defines a symbol at the current location counter. . is special — it is "the current address as the linker assembles output". So _etext ends up at the address right after the last byte of .rodata.
.data : {
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > SRAM AT > FLASH
_sidata = LOADADDR(.data);This is the AT-load section. > SRAM sets the virtual memory address (VMA): the addresses your code uses to reference these symbols. AT > FLASH sets the load memory address (LMA): where the bytes actually live in the binary image.
So init_counter (a global initialised to 0x42) has its address 0x2000_0010 in your code (VMA), but the byte 0x42 is stored at 0x0800_00B4 in flash (LMA). The startup code copies from LMA to VMA.
LOADADDR(.data) returns the LMA of .data — exactly where the flash bytes start. _sidata = LOADADDR(.data) gives Reset_Handler the source pointer for the copy loop.
.bss : {
_sbss = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
} > SRAM.bss is reserved space in SRAM, not loaded from flash. The linker tracks its size but emits no bytes in the binary for it. *(COMMON) catches the legacy "common block" symbols (uninitialised globals declared without extern in multiple translation units — older C trick, mostly obsolete with modern -fno-common).
_sbss and _ebss mark the range Reset_Handler will zero.
}End of SECTIONS. After this, the linker has assigned every input section to an output section, placed each output section in flash or SRAM, and resolved all the symbol values.
That is the entire 25-line script. Every line has a purpose.
Symbol vs section vs region
These three concepts trip up beginners. Be precise:
- Symbol — a name with an address.
Reset_Handler,_estack,main,init_counter. Defined either in code or by the linker script. - Section — a named blob of bytes (or reserved space).
.text,.data,.bss,.isr_vector. Each input object has many; the linker combines them into output sections. - Region — a physical memory range.
FLASH,SRAM. Declared in MEMORY, referenced in SECTIONS via> REGION.
Sections live in regions. Symbols live at addresses. Code references symbols by name; the linker resolves names to addresses at link time.
Bumping RAM for a bigger chip
Moving from STM32F411 (128 K SRAM) to STM32F407 (192 K SRAM, including 64 K of CCM at 0x1000_0000):
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K /* F407 has 1 MB flash */
CCM (rw) : ORIGIN = 0x10000000, LENGTH = 64K /* tightly-coupled, no DMA */
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K /* main SRAM */
}CCM (Core-Coupled Memory) is a separate SRAM bank that the CPU can access at single-cycle latency but DMA cannot reach. Useful for hot loop variables and stacks; useless for DMA buffers. To place a variable there:
__attribute__((section(".ccmram")))
static uint32_t hot_var;And add a section in the script:
.ccmram (NOLOAD) : {
*(.ccmram*)
. = ALIGN(4);
} > CCM(NOLOAD) says "this section reserves space but is not loaded from anywhere" — appropriate for uninitialised data. If you want CCM data to have initial values, mark it > CCM AT > FLASH and add another startup-time copy loop.
Reserving the top of flash for a bootloader
A/B firmware update designs reserve a chunk of flash for the bootloader (which never changes) and use the rest for the application. The bootloader at flash origin, application after:
MEMORY
{
BOOT (rx) : ORIGIN = 0x08000000, LENGTH = 16K
APP (rx) : ORIGIN = 0x08004000, LENGTH = 512K - 16K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}Then the bootloader's linker script uses > BOOT for its .isr_vector and .text; the application's linker script uses > APP. The bootloader jumps to the application's reset vector at 0x0800_4004 after validating the image.
The application's vector table is no longer at 0x0800_0000. You must set SCB->VTOR = 0x08004000 early in the application's Reset_Handler so the NVIC dispatches interrupts to the application's handlers, not the bootloader's.
Proper stack and heap reservation
The script so far implicitly puts the stack at the top of SRAM and assumes it never collides with .bss. For safety, reserve explicit blocks:
_min_stack_size = 0x1000; /* 4 K */
_min_heap_size = 0x0800; /* 2 K, set to 0 if not using malloc */
SECTIONS {
/* ... .isr_vector, .text, .data, .bss as before ... */
._user_heap_stack (NOLOAD) : {
. = ALIGN(8);
PROVIDE(end = .); /* newlib expects 'end' for _sbrk */
PROVIDE(_end = .);
. = . + _min_heap_size;
. = . + _min_stack_size;
. = ALIGN(8);
} > SRAM
/* If we run past the SRAM end, link fails */
. = ASSERT((_estack >= .), "Stack/heap overflows SRAM")
}PROVIDE(name = ...) defines a symbol only if it is not already defined elsewhere. Newlib's _sbrk reads end (or _end) as the start of the heap. Providing it from the linker script lets newlib's malloc work without you writing any extra code (and gives the user a chance to override it).
ASSERT fails the link with a custom message if the condition is false. The ASSERT above triggers if .bss plus the heap+stack reservation would overflow SRAM — you find this at link time, not at run time.
A more complete linker script
Putting it all together:
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_estack = ORIGIN(SRAM) + LENGTH(SRAM);
_min_stack_size = 0x1000;
_min_heap_size = 0x0800;
SECTIONS
{
.isr_vector : {
. = ALIGN(4);
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
.text : {
. = ALIGN(4);
*(.text*)
*(.rodata*)
*(.glue_7) /* ARM/Thumb interworking glue, harmless if empty */
*(.glue_7t)
. = ALIGN(4);
_etext = .;
} > FLASH
/* C++ constructors and destructors */
.preinit_array : {
PROVIDE_HIDDEN(__preinit_array_start = .);
KEEP(*(.preinit_array*))
PROVIDE_HIDDEN(__preinit_array_end = .);
} > FLASH
.init_array : {
PROVIDE_HIDDEN(__init_array_start = .);
KEEP(*(SORT(.init_array.*)))
KEEP(*(.init_array*))
PROVIDE_HIDDEN(__init_array_end = .);
} > FLASH
.fini_array : {
PROVIDE_HIDDEN(__fini_array_start = .);
KEEP(*(SORT(.fini_array.*)))
KEEP(*(.fini_array*))
PROVIDE_HIDDEN(__fini_array_end = .);
} > FLASH
_sidata = LOADADDR(.data);
.data : {
. = ALIGN(4);
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > SRAM AT > FLASH
.bss : {
. = ALIGN(4);
_sbss = .;
__bss_start__ = _sbss;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_ebss = .;
__bss_end__ = _ebss;
} > SRAM
._user_heap_stack (NOLOAD) : {
. = ALIGN(8);
PROVIDE(end = .);
PROVIDE(_end = .);
. = . + _min_heap_size;
. = . + _min_stack_size;
. = ALIGN(8);
} > SRAM
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) } /* ditch exception unwinding */
}About 60 lines. This handles:
- C++ static constructors via
.init_array(called by__libc_init_array). - Explicit heap and stack reservation with link-time overflow check.
PROVIDE_HIDDENsymbols for newlib internals.- Discarding ARM exception unwinding tables (saves a few hundred bytes; you do not need them on bare metal).
It is still small enough to read in one sitting and big enough for real production firmware.
Common mistakes
Forgetting KEEP on the vector table — --gc-sections discards it because nothing in C code references it. Result: chip resets and immediately HardFaults because the vector table reads as zeros.
Wrong alignment — _estack not on an 8-byte boundary. The Cortex-M architecture requires double-word stack alignment when entering exceptions; missing it causes UsageFault or silent corruption when the FPU lazy-stacks. ALIGN(8) everywhere is cheap insurance.
Mismatched AT-load — .data placed in SRAM without AT > FLASH. The flash image has no copy, so after Reset_Handler the SRAM is whatever it was at power-on. Symptoms: initialised globals come up with random values.
Stack/heap collision in .bss — without an explicit ASSERT, a big global array can push .bss past where the stack will grow into it. Hard to debug because the symptom is "random crashes when the call stack gets deep". Use ASSERT(_estack - _ebss > 0x800, "less than 2K free SRAM");.
Exercises
Exercise 5.1. Modify your blink linker script to reserve 32 K at the top of flash for a future bootloader. Application origin moves to 0x08000000 and length shrinks. Then set SCB->VTOR = 0x08000000 in Reset_Handler (it is the default; this is just to practice the pattern). Build and confirm via arm-none-eabi-objdump -h that section addresses changed.
Exercise 5.2. Add a region for "battery-backed RAM" — a 4 K block at the top of SRAM that the application's Reset_Handler should not zero. Declare a (NOLOAD) section for it and place a struct there.
Exercise 5.3. Run arm-none-eabi-ld --verbose (no input files) to dump the default linker script. Find the .text block. What sections is it composed of by default?
What you learned
ENTRY,MEMORY,SECTIONS,KEEP,ALIGN,LOADADDR,PROVIDE,ASSERT— every directive has a job.- AT-load:
.datahas VMA in SRAM and LMA in flash; Reset_Handler bridges them. - The location counter
.is your editing cursor;_etext = .defines a symbol at the current address. - Adding regions for ITCM, DTCM, CCM, BKP_RAM, or a bootloader area is a matter of MEMORY plus a
(NOLOAD)section. - Explicit
ASSERTs catch stack/heap overflow at link time. - The default GCC linker script exists but you almost never want it for embedded.
In lesson 6 we wire up SysTick by hand, write a real ISR, and learn how the vector table actually dispatches.
Next
| Previous | Next |
|---|---|
| Lesson 4 — Memory layout | Lesson 6 — Interrupts and ISRs |