Linker Scripts and Firmware Memory Layout
On a hosted system the linker's default script is invisible and correct. On bare metal you write it, and it decides where every byte lands. This week opens the file that weeks 49 through 51 have been quietly relying on.
- Write a linker script defining memory regions and section placement.
- Explain load address versus virtual address, and why
.datahas both. - Define and use symbols that C code can read.
- Place the stack, and detect an overflow before it corrupts anything.
- Read a map file and account for every byte of an image.
1Sections
The compiler does not emit a flat stream of bytes. It sorts everything into named sections, and the linker gathers matching sections from every object file and places each group at an address.
| Section | Contains | Lives in | In the image? |
|---|---|---|---|
.text | Machine code | Flash | Yes |
.rodata | const data, string literals | Flash | Yes |
.data | Initialized globals | RAM | Yes — the initial values |
.bss | Zero-initialized globals | RAM | No — only its size |
| Stack | Locals, call frames | RAM | No |
| Heap | malloc, if used at all | RAM | No |
Week 17 met these on a hosted system, where the loader handles them. Here the same four sections exist and nothing handles them for you.
2Load address and virtual address
This is the idea the whole script turns on. Most sections live where they are stored. .data does not.
Week 49's startup loop, seen from the linker's side.
- VMA — virtual memory address — is where the code expects the section to be. For
.data, RAM. - LMA — load memory address — is where it is actually stored in the image. For
.data, flash.
For .text and .rodata the two are the same: code executes directly from flash. For .data they differ, and the gap is exactly what the startup copy closes.
3The script
ENTRY(reset_handler) /* the entry symbol, for debuggers */
MEMORY
{
FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 256K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
_stack_size = 4K;
_heap_size = 0; /* no heap: week 53 */
SECTIONS
{
.text : {
KEEP(*(.vectors)) /* must be first, and never discarded */
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASH
.data : {
. = ALIGN(4);
_data_start = .;
*(.data*)
. = ALIGN(4);
_data_end = .;
} > RAM AT> FLASH /* VMA in RAM, LMA in FLASH */
_data_load = LOADADDR(.data); /* where the startup code reads from */
.bss (NOLOAD) : {
. = ALIGN(4);
_bss_start = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
_bss_end = .;
} > RAM
.stack (NOLOAD) : {
. = ALIGN(8); /* ARM requires 8-byte stack alignment */
_stack_bottom = .;
. = . + _stack_size;
_stack_top = .;
} > RAM
/* Fail the build rather than the device. */
ASSERT(_stack_top <= ORIGIN(RAM) + LENGTH(RAM), "RAM overflow")
}| Construct | Means |
|---|---|
MEMORY | Names the physical regions and their sizes |
> FLASH | Place this section in that region |
AT> FLASH | …but store it in this one — sets the LMA |
. | The location counter, assignable |
KEEP(...) | Do not discard even if nothing references it |
NOLOAD | Reserve the space; store nothing in the image |
_symbol = .; | Define a symbol C can refer to |
ASSERT(...) | Fail the link if the condition is false |
KEEP on the vector table is not optional. With --gc-sections the linker discards anything unreferenced, and nothing in C refers to the vector table — the processor reads it directly. Without KEEP it disappears and the device does not boot.
The ASSERT is the difference between a link error and a device that runs until the stack meets .bss. Every embedded project should have one.
4Linker symbols in C
A symbol defined in the script has no storage — the linker records only an address. That makes the C declaration counter-intuitive:
extern uint32_t _data_start; /* declares a variable AT that address */
uint32_t *p = &_data_start; /* CORRECT: take its address */
uint32_t v = _data_start; /* WRONG: reads whatever is stored there */The value of a linker symbol is its address, so you always want &. Some projects avoid the trap by declaring them as arrays, where the name already decays to an address:
extern uint32_t _data_start[]; /* now _data_start is the address */
uint32_t *p = _data_start; /* no & needed, and no way to misuse it */5Stack placement and overflow
The stack grows downward from its top. Place it at the end of RAM and it grows toward .bss; there is no guard page and no fault — week 49's point about the absent MMU.
Three defences, in increasing cost:
A painted region. Fill the stack with a pattern at startup, then check later how far down it has been disturbed. This measures the high-water mark, which tells you how much margin you actually have.
#define STACK_PAINT 0xC0DEC0DEu
void paint_stack(void) /* called early, before deep calls */
{
uint32_t *p = _stack_bottom;
uint32_t marker;
while (p < &marker) *p++ = STACK_PAINT; /* up to the current frame */
}
size_t stack_used(void)
{
uint32_t *p = _stack_bottom;
while (p < _stack_top && *p == STACK_PAINT) p++;
return (size_t)((uint8_t *)_stack_top - (uint8_t *)p);
}A canary. Reserve a word at the bottom of the stack, set it to a known value, and check it periodically. Cheap, and it detects an overflow after the fact rather than before.
Hardware. An MPU region marked no-access below the stack turns an overflow into a fault at the moment it happens. Available on many Cortex-M parts and worth configuring on anything safety-relevant.
6Worked example: taking an image apart
/* layout.c — every section populated deliberately */
#include <stdint.h>
/* Linker symbols, declared as arrays so no & is needed. */
extern uint32_t _etext[], _data_start[], _data_end[], _data_load[];
extern uint32_t _bss_start[], _bss_end[];
extern uint32_t _stack_bottom[], _stack_top[];
/* .rodata — stays in flash, costs no RAM */
static const char banner[] = "firmware layout demo";
static const uint32_t table[16] = { 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16 };
/* .data — initial values in flash, variable in RAM */
static uint32_t marker = 0xA5A5A5A5u;
static uint16_t revision = 42;
/* .bss — no flash cost at all */
static uint32_t counter;
static uint8_t scratch[1024];
/* ---------- UART, from week 50 ---------- */
#define UART0_DR (*(volatile uint32_t *)0x4000C000u)
#define UART0_FR (*(volatile uint32_t *)0x4000C018u)
static void putc_raw(char c)
{
while (UART0_FR & (1u << 5)) { }
UART0_DR = (uint32_t)(unsigned char)c;
}
static void puts_raw(const char *s)
{
for (; *s; s++) { if (*s == '\n') putc_raw('\r'); putc_raw(*s); }
}
static void put_hex(uint32_t v)
{
static const char d[] = "0123456789ABCDEF";
puts_raw("0x");
for (int s = 28; s >= 0; s -= 4) putc_raw(d[(v >> s) & 0xFu]);
}
static void put_uint(uint32_t v)
{
char t[11]; int i = 0;
if (!v) { putc_raw('0'); return; }
while (v) { t[i++] = (char)('0' + v % 10); v /= 10; }
while (i) putc_raw(t[--i]);
}
/* ---------- stack painting ---------- */
#define PAINT 0xC0DEC0DEu
void paint_stack(void)
{
uint32_t here;
for (uint32_t *p = _stack_bottom; p < &here; p++) {
*p = PAINT;
}
}
static uint32_t stack_used_bytes(void)
{
uint32_t *p = _stack_bottom;
while (p < _stack_top && *p == PAINT) p++;
return (uint32_t)((uint8_t *)_stack_top - (uint8_t *)p);
}
/* Recursion to consume stack on demand. */
static uint32_t consume(uint32_t depth)
{
volatile uint8_t frame[64];
frame[0] = (uint8_t)depth;
if (depth == 0) return frame[0];
return consume(depth - 1) + frame[0];
}
int main(void)
{
puts_raw("\n=== firmware layout ===\n\n");
puts_raw("FLASH\n");
puts_raw(" .text + .rodata end : "); put_hex((uint32_t)(uintptr_t)_etext);
puts_raw("\n banner (rodata) : ");
put_hex((uint32_t)(uintptr_t)banner);
puts_raw("\n table (rodata) : ");
put_hex((uint32_t)(uintptr_t)table);
puts_raw("\n .data initial values: ");
put_hex((uint32_t)(uintptr_t)_data_load);
puts_raw(" <-- LMA, in flash\n");
puts_raw("\nRAM\n");
puts_raw(" .data "); put_hex((uint32_t)(uintptr_t)_data_start);
puts_raw(" .. "); put_hex((uint32_t)(uintptr_t)_data_end);
puts_raw(" <-- VMA, in RAM\n");
puts_raw(" .bss "); put_hex((uint32_t)(uintptr_t)_bss_start);
puts_raw(" .. "); put_hex((uint32_t)(uintptr_t)_bss_end);
puts_raw("\n stack "); put_hex((uint32_t)(uintptr_t)_stack_bottom);
puts_raw(" .. "); put_hex((uint32_t)(uintptr_t)_stack_top);
puts_raw("\n");
puts_raw("\nsizes\n");
puts_raw(" .data : ");
put_uint((uint32_t)((uint8_t *)_data_end - (uint8_t *)_data_start));
puts_raw(" bytes (also occupies that much FLASH)\n");
puts_raw(" .bss : ");
put_uint((uint32_t)((uint8_t *)_bss_end - (uint8_t *)_bss_start));
puts_raw(" bytes (zero FLASH)\n");
puts_raw(" stack : ");
put_uint((uint32_t)((uint8_t *)_stack_top - (uint8_t *)_stack_bottom));
puts_raw(" bytes reserved\n");
puts_raw("\nstartup did its job\n");
puts_raw(" marker = "); put_hex(marker);
puts_raw(marker == 0xA5A5A5A5u ? " .data copied\n" : " NOT COPIED\n");
puts_raw(" revision = "); put_uint(revision); puts_raw("\n");
puts_raw(" counter = "); put_uint(counter);
puts_raw(counter == 0 ? " .bss zeroed\n" : " NOT ZEROED\n");
puts_raw("\nstack high-water mark\n");
puts_raw(" at entry : "); put_uint(stack_used_bytes());
puts_raw(" bytes\n");
(void)consume(8);
puts_raw(" after 8 frames : "); put_uint(stack_used_bytes());
puts_raw(" bytes\n");
(void)consume(40);
puts_raw(" after 40 frames : "); put_uint(stack_used_bytes());
puts_raw(" bytes\n");
uint32_t reserved = (uint32_t)((uint8_t *)_stack_top -
(uint8_t *)_stack_bottom);
uint32_t used = stack_used_bytes();
puts_raw(" headroom : "); put_uint(reserved - used);
puts_raw(" of "); put_uint(reserved); puts_raw(" bytes\n");
puts_raw("\n this is a measurement, not an estimate — which is what\n");
puts_raw(" lets you size the stack with evidence\n");
for (;;) { }
}Call paint_stack() from the reset handler, immediately after zeroing .bss and before calling main.
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb \
-ffreestanding -nostdlib -O2 -Wall -Wextra -g \
-T firmware.ld -Wl,-Map=firmware.map \
-o firmware.elf startup.c layout.c
qemu-system-arm -M lm3s6965evb -nographic -kernel firmware.elfCompare the output with the tools
arm-none-eabi-size firmware.elf text data bss dec hex filename
1632 8 1028 2668 a6c firmware.elfEight bytes of .data — the uint32_t and the uint16_t with padding — and 1028 of .bss, which is the 1024-byte scratch plus counter. The .data figure appears in both flash and RAM; the .bss figure only in RAM. Add = {1} to scratch and watch text grow by a kilobyte as it moves into .data.
arm-none-eabi-objdump -h firmware.elfIdx Name Size VMA LMA File off Algn
0 .text 00000660 00000000 00000000 00010000 2**2
1 .data 00000008 20000000 00000660 00020000 2**2
2 .bss 00000404 20000008 20000008 00020008 2**2There it is, printed by the tool: .text has VMA equal to LMA, and .data does not — VMA 0x20000000 in RAM, LMA 0x00000660 in flash. That one row is the entire content of section 2.
Read the map file
grep -A20 'Memory Configuration' firmware.map
grep -E '^\s+\.(text|data|bss)' firmware.map | head -20
grep -B2 -A8 '_stack_top' firmware.mapThe map file accounts for every byte: which object file contributed which bytes to which section, at which address. When an image is unexpectedly large, this is where the answer is — usually one function, or a library pulled in by a single call to printf.
arm-none-eabi-nm --size-sort -S firmware.elf | tail -10 # the biggest symbolsMove a variable into flash
__attribute__((section(".rodata"))) static const uint32_t big_table[256] = { … };Rebuild and compare size: text grows by a kilobyte and RAM is untouched. On a part with 8 KB of RAM and 64 KB of flash, moving constant tables out of RAM is often the difference between fitting and not.
Make the linker catch a mistake
Set _stack_size = 128K; in the script and rebuild:
arm-none-eabi-ld: RAM overflow
collect2: error: ld returned 1 exit statusThe ASSERT turned a device that would have corrupted itself at run time into a build failure. Remove the assertion and the same configuration links cleanly, producing firmware whose stack overlaps .bss — and the symptom would be variables changing value for no reason.
Watch the stack grow into the danger zone
Raise the recursion depth until the high-water mark approaches the reserved size:
(void)consume(60); /* 60 frames of 64 bytes plus overhead */The reported headroom shrinks toward zero. Push past it and variables in .bss start changing — silently, because nothing is watching. That is the failure this week's painting and ASSERT exist to make visible, and it is why embedded projects size the stack from a measurement rather than a guess.
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
No KEEP on the vector table | Discarded by --gc-sections; device will not boot | KEEP(*(.vectors)). |
| Vector table not first | The processor reads the wrong initial SP | Place it at the start of .text. |
Omitting AT> FLASH on .data | Initial values are never stored in the image | Set the LMA explicitly. |
| Reading a linker symbol's value | Reads the memory at that address | Take its address, or declare it as an array. |
No RAM-overflow ASSERT | Stack silently overlaps .bss | Add the check to the script. |
| Guessing the stack size | Either wasted RAM or an overflow in the field | Paint it and measure. |
Forgetting ALIGN | Unaligned access faults — week 37 | Align sections to 4, the stack to 8. |
Large tables without const | They occupy RAM as well as flash | const keeps them in .rodata. |
8Check yourself
What is the difference between a section's LMA and VMA, and which section needs both?
The LMA is where the section is stored in the image; the VMA is the address the code expects it at. They are the same for .text and .rodata, which execute from flash. .data needs both: its initial values must be stored in flash because RAM is empty at power-on, but the variables are addressed in RAM — and the startup code copies one to the other.
Why does .bss occupy no space in the image?
Because every byte is zero, so there is nothing to store — only the size is recorded, and the startup code clears that much RAM. Give a .bss variable a non-zero initializer and it moves to .data, where the contents must actually be present in flash.
Why must the vector table be wrapped in KEEP?
Because nothing in the C code references it — the processor reads it directly from address 0 — so section garbage collection sees an unused section and discards it. Without it the image links successfully and the device fails to boot, which is a particularly unhelpful combination.
Why is uint32_t v = _data_start; wrong?
Because a linker symbol has no storage: the linker records only an address, and the declaration extern uint32_t _data_start describes a variable located at that address. Reading it reads whatever bytes are there. You want &_data_start — or declare it as an array, where the name is already the address.
How do you size a stack with evidence rather than a guess?
Fill the reserved region with a known pattern at startup, run the worst-case workload, then find the lowest address whose pattern is still intact. The distance from there to the top is the high-water mark. Combined with an ASSERT in the linker script that the regions fit, that converts a guess into a measurement and a build-time check.
9Where this leads
Week 53 takes the constraint seriously: no heap at all. Pool allocators, ring buffers, fixed-point arithmetic instead of floating point, and optimizing for code size — the techniques that let a program fit in the flash and RAM this week's script has been measuring.