armhub.dev
Programming · 55 pages
Programmingtutorial

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.

5 min read · 997 wordsupdated · May 13, 2026
Lesson 4 — at a glance
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.

Cortex-M 4 GB address space
0x0000_0000
– 0x1FFF_FFFF
Code
0x2000_0000
– 0x3FFF_FFFF
SRAM
0x4000_0000
– 0x5FFF_FFFF
Peripheral
0x6000_0000
– 0x9FFF_FFFF
External RAM
0xA000_0000
– 0xDFFF_FFFF
External device
0xE000_0000
– 0xE00F_FFFF
System
0xE010_0000
– 0xFFFF_FFFF
Vendor

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 at 0x0000_0004 actually comes from flash at 0x0800_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 rangeContents
0x0000_0000-0x0007_FFFFAliased to flash, system memory, or SRAM (BOOT0/BOOT1 selects)
0x0800_0000-0x0807_FFFFInternal flash, 512 KB
0x1FFF_0000-0x1FFF_77FFSystem memory (vendor bootloader, factory-programmed)
0x1FFF_7800-0x1FFF_7A0FOTP area
0x1FFF_C000-0x1FFF_C007Option bytes
0x2000_0000-0x2001_FFFFSRAM, 128 KB
0x4000_0000-0x4002_63FFAPB1/APB2/AHB1 peripherals
0xE000_0000-0xE00F_FFFFCortex-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:

SymbolMeaningTypical value
_etextEnd of .text and .rodata in flash0x0800_00B4
_sidataLoad address of .data (in flash, = _etext)0x0800_00B4
_sdataRun-time start of .data (in SRAM)0x2000_0000
_edataRun-time end of .data0x2000_0014
_sbssStart of .bss in SRAM (= _edata, usually)0x2000_0014
_ebssEnd of .bss0x2000_0020
_estackTop of SRAM, initial MSP0x2002_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 layout

Expected (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 -30

Expected:

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 _estack

The 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_0000 and up: code and read-only data in flash.
  • 0x2000_0000-0x2000_0014: .data in SRAM.
  • 0x2000_0014-0x2000_0020: .bss in 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, _estack at section boundaries.
  • .data is loaded into flash but its run-time addresses are in SRAM; Reset_Handler copies it.
  • .bss is reserved in SRAM only; Reset_Handler zeroes it.
  • nm --numeric-sort shows the post-link memory map; nm --size-sort shows what is bloating your binary.

In lesson 5 we annotate the linker script line-by-line so you understand every directive.

Next

PreviousNext
Lesson 3 — Reading hardware registers in CLesson 5 — A minimum linker script you understand