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 4 — Memory layout
Walk the Cortex-M memory map — flash, SRAM, peripherals, system region. Decode _etext, _sdata, _edata, _sbss, _ebss. Watch the startup-time .data copy.
- What you'll learn
- The Cortex-M unified memory map, what each region is for, how linker symbols mark section boundaries, and what the startup .data copy actually moves
- Code provided
- About 70 lines — a program that introspects its own memory layout
- Time
- 60 minutes
- Hardware
- STM32F411 Nucleo or any Cortex-M4 board
By now you have written a program that runs on bare silicon. The startup code copied .data and zeroed .bss, but those operations probably felt mechanical — you wrote the assembly without fully internalising what was moving where. This lesson fixes that. We will walk every region of the Cortex-M memory map, explain what lives there, and trace exactly what the startup code does to bridge "flash image at power-on" and "running C program with global variables".
The Cortex-M architectural memory map
Every Cortex-M part shares the same 4 GB address space layout, defined by the architecture. Vendors customise what lives at each address, but the regions and their permissions are fixed.
The regions matter because the architecture pre-defines memory attributes for each:
- Code (0x0000-0x1FFF) — normal memory, cacheable on parts with cache. Instruction fetches happen here. Most STM32s alias flash here on reset (
BOOT0=0), so the first instruction the CPU fetches at0x0000_0004actually comes from flash at0x0800_0004. - SRAM (0x2000-0x3FFF) — normal memory, cacheable, executable. The architecture lets you fetch instructions from SRAM, which is how things like ITCM-resident ISRs and bootloaders updating their own flash work.
- Peripheral (0x4000-0x5FFF) — device memory, never cacheable, never executable. Writes complete in order with respect to each other and have side effects.
- System (0xE000-0xE00F) — strongly-ordered device memory. NVIC, SCB, SysTick, FPU control all live here. Reads and writes are not buffered.
The MPU (if you configure it) can override these defaults per region, but the default attribute for each band matches what you almost always want.
STM32F411 specifics
The architectural map says "code region from 0 to 0x2000_0000", but it does not say what is actually there. The vendor decides. On STM32F411:
| Address range | Contents |
|---|---|
0x0000_0000-0x0007_FFFF | Aliased to flash, system memory, or SRAM (BOOT0/BOOT1 selects) |
0x0800_0000-0x0807_FFFF | Internal flash, 512 KB |
0x1FFF_0000-0x1FFF_77FF | System memory (vendor bootloader, factory-programmed) |
0x1FFF_7800-0x1FFF_7A0F | OTP area |
0x1FFF_C000-0x1FFF_C007 | Option bytes |
0x2000_0000-0x2001_FFFF | SRAM, 128 KB |
0x4000_0000-0x4002_63FF | APB1/APB2/AHB1 peripherals |
0xE000_0000-0xE00F_FFFF | Cortex-M4 system region |
The reference manual (RM0383 section 2.3) lists every peripheral's base address. We have already used three: RCC_BASE = 0x40023800, GPIOA_BASE = 0x40020000, and our flash origin 0x08000000.
Linker symbols — the bridge between linker script and C
The linker script generates symbols at every section boundary. These are not variables; they are addresses, treated by C as having type void (you reference them via &symbol or by declaring them as arrays).
For our lesson 2 linker script, the symbols generated are:
| Symbol | Meaning | Typical value |
|---|---|---|
_etext | End of .text and .rodata in flash | 0x0800_00B4 |
_sidata | Load address of .data (in flash, = _etext) | 0x0800_00B4 |
_sdata | Run-time start of .data (in SRAM) | 0x2000_0000 |
_edata | Run-time end of .data | 0x2000_0014 |
_sbss | Start of .bss in SRAM (= _edata, usually) | 0x2000_0014 |
_ebss | End of .bss | 0x2000_0020 |
_estack | Top of SRAM, initial MSP | 0x2002_0000 |
(Exact values depend on your binary's size.)
The startup code copies [_sidata, _sidata + (_edata - _sdata)) from flash to [_sdata, _edata) in SRAM, then zeroes [_sbss, _ebss). After that, every initialised global has its correct starting value, every uninitialised global is zero, and the program is in the state that the C standard requires before main runs.
A program that introspects its own layout
The clearest way to make this concrete is to print the addresses from a running program. We will use _write over SWO (or a polling UART) — for now, just stash them and inspect via the debugger.
#include <stdint.h>
#include <stddef.h>
/* Linker-provided symbols. The 'extern' makes the linker fill in the addresses. */
extern uint32_t _etext;
extern uint32_t _sidata;
extern uint32_t _sdata;
extern uint32_t _edata;
extern uint32_t _sbss;
extern uint32_t _ebss;
extern uint32_t _estack;
/* Some test data so .data, .bss, .rodata each have content */
static const uint32_t flash_table[4] = { 0xDEAD, 0xBEEF, 0xCAFE, 0xBABE }; /* .rodata */
static uint32_t init_counter = 0x42; /* .data */
static uint32_t zero_counter; /* .bss */
/* A volatile pointer the debugger can read */
volatile struct {
uint32_t etext;
uint32_t sidata;
uint32_t sdata;
uint32_t edata;
uint32_t sbss;
uint32_t ebss;
uint32_t estack;
uint32_t flash_table_addr;
uint32_t init_counter_addr;
uint32_t zero_counter_addr;
uint32_t init_counter_value;
uint32_t zero_counter_value;
} layout;
int main(void) {
layout.etext = (uint32_t)&_etext;
layout.sidata = (uint32_t)&_sidata;
layout.sdata = (uint32_t)&_sdata;
layout.edata = (uint32_t)&_edata;
layout.sbss = (uint32_t)&_sbss;
layout.ebss = (uint32_t)&_ebss;
layout.estack = (uint32_t)&_estack;
layout.flash_table_addr = (uint32_t)flash_table;
layout.init_counter_addr = (uint32_t)&init_counter;
layout.zero_counter_addr = (uint32_t)&zero_counter;
layout.init_counter_value = init_counter;
layout.zero_counter_value = zero_counter;
while (1) { __asm__ volatile("nop"); }
}Build it, load it under GDB, set a breakpoint on the while (1), and inspect:
arm-none-eabi-gdb build/blink.elf
(gdb) target extended-remote :3333 # OpenOCD must be running
(gdb) load
(gdb) b 49 # break at the while
(gdb) c
(gdb) print layoutExpected (approximate):
$1 = {
etext = 0x80000b4,
sidata = 0x80000b4,
sdata = 0x20000000,
edata = 0x20000014,
sbss = 0x20000014,
ebss = 0x20000020,
estack = 0x20020000,
flash_table_addr = 0x8000088,
init_counter_addr = 0x20000010,
zero_counter_addr = 0x2000001c,
init_counter_value = 0x42,
zero_counter_value = 0x0
}Look at the addresses. flash_table is a static const, so it lives in .rodata (which our linker put in .text) at 0x0800_0088 — in flash. init_counter (a static with an initialiser) is in .data at 0x2000_0010 — in SRAM. zero_counter is in .bss at 0x2000_001c.
The startup code copied init_counter's initial value 0x42 from flash to SRAM before main ran. It also zeroed zero_counter. Both are visible in their final state.
Inspecting symbols with arm-none-eabi-nm
arm-none-eabi-nm --numeric-sort build/blink.elf | head -30Expected:
08000000 T __isr_vector
08000000 t $t
080000b4 T _etext
080000b4 T _sidata
...
20000000 D _sdata
20000010 d init_counter
20000014 B _sbss
20000014 D _edata
2000001c B zero_counter
20000020 B _ebss
20020000 T _estackThe single-letter columns are symbol types: T for .text (code), D for .data, B for .bss, R for .rodata. The lowercase versions are the local (static) variants.
Notice the addresses cluster:
0x0800_0000and up: code and read-only data in flash.0x2000_0000-0x2000_0014:.datain SRAM.0x2000_0014-0x2000_0020:.bssin SRAM.0x2002_0000: top of SRAM (_estack).
_etext == _sidata — that's the LMA trick from the linker script: .data's load address is wherever the linker emits it in the flash image, which is right after .text ends.
The .data copy in slow motion
Flash: SRAM:
0x0800_00B0: 0xDEAD 0x2000_0000: 0x???? (random at reset)
0x0800_00B4: 0xBEEF 0x2000_0004: 0x????
0x0800_00B8: 0xCAFE 0x2000_0008: 0x????
0x0800_00BC: 0xBABE 0x2000_000C: 0x????
0x0800_00C0: 0x42 0x2000_0010: 0x???? (init_counter dst)
0x0800_00C4: ...
After Reset_Handler runs:
Flash: SRAM:
0x0800_00C0: 0x42 0x2000_0010: 0x42 (init_counter)
0x2000_0014: 0x00 (zero_counter, .bss start)
0x2000_0018: 0x00
0x2000_001C: 0x00 (zero_counter is here)The flash image always has both copies. Power-on always wipes SRAM to indeterminate values (some chips reset to all-zeros, most do not — never rely on it). The startup code is the only thing standing between "SRAM full of garbage" and "globals in their expected state".
Cortex-M7 ITCM/DTCM aliasing
On Cortex-M7 parts (STM32H7, NXP i.MX RT, MIMXRT10xx), the memory map gets richer. ITCM (Instruction Tightly-Coupled Memory) sits at 0x0000_0000 and DTCM at 0x2000_0000. Both are dual-ported single-cycle SRAM accessible only by the core, not by DMA or other masters.
For our M4 target this does not apply. But the same idea — placing hot code or buffers in a faster, smaller memory region — is one of the most common reasons to write a custom linker script. See the Cortex-M7 memory page for details.
Exercises
Exercise 4.1. Add arm-none-eabi-nm --size-sort to your build's post-link step. Sort by size in reverse. Identify the three largest symbols. Are they where you expected?
Exercise 4.2. Change the linker script to move SRAM origin to 0x2000_1000 and shrink LENGTH to 124 K, leaving 4 K at the start unused. Rebuild and check arm-none-eabi-nm to confirm. What might you put in the first 4 K of SRAM?
Exercise 4.3. Add an uninitialised array static uint32_t big_buf[1024]; to your program. Check nm — what section is it in, and what is the binary impact?
What you learned
- Cortex-M has a fixed 4 GB memory map: code, SRAM, peripheral, external, system regions.
- Vendor reference manuals fill in what lives at each address on a specific chip.
- The linker emits symbols
_etext,_sidata,_sdata,_edata,_sbss,_ebss,_estackat section boundaries. .datais loaded into flash but its run-time addresses are in SRAM; Reset_Handler copies it..bssis reserved in SRAM only; Reset_Handler zeroes it.nm --numeric-sortshows the post-link memory map;nm --size-sortshows what is bloating your binary.
In lesson 5 we annotate the linker script line-by-line so you understand every directive.
Next
| Previous | Next |
|---|---|
| Lesson 3 — Reading hardware registers in C | Lesson 5 — A minimum linker script you understand |