UNIT 03 · LESSON 5 OF 6

The Stack, the Heap, and Static Storage

Where exactly do local variables, globals and malloc blocks live on a microcontroller, and how do you know the stack is big enough?

INTERACTIVERecursion and the stack pointer
A descending stack with one frame per recursive call and the stack pointer markeduint32_t sum(uint32_t n) {    if (n == 0) return 0;    return n + sum(n - 1);}top 0x2004_2000main + sum() frames: 8 + 5 × 16 = 88 BSP = 0x2004_1FA8limit 0x2004_180088 B used, 1960 B of the 2 KiB stack left.
A descending stack with one frame per recursive call and the stack pointer markeduint32_t sum(uint32_t n) {    if (n == 0) return 0;    return n + sum(n - 1);}top 0x2004_2000main + sum() frames: 8 + 5 ×16 = 88 BSP = 0x2004_1FA8limit 0x2004_180088 B used, 1960 B of the 2 KiB stack left.

Try this

Frame size of sum()
Stack size
Depth 5: 8 + 5 × 16 = 88 bytes; SP = 0x2004_1FA8. 1960 bytes left.

Each call pushes a frame (saved registers, the return address, locals) and moves SP down; each return pops it. The stack top 0x2004_2000 and the 2 KiB default size are the RP2040 SDK’s defaults for core 0. An interrupt arriving at the deepest point pushes a further 32-byte exception frame; the handler’s own frame, which would go below it, is not drawn (every frame here is a multiple of 8 bytes, so no alignment padding is needed). Frame sizes depend on the compiler and optimisation level; -fstack-usage reports them.

What you will be able to do
  • Map C’s storage durations (static, automatic, allocated) to the memory sections where each object lives: .rodata, .data, .bss, the stack and the heap.
  • Explain what startup code does to .data and .bss before main(), and why an uninitialised local variable holds garbage while an uninitialised global holds zero.
  • Describe a full-descending stack, what a stack frame contains, and how the stack pointer moves on calls, returns and interrupts.
  • Estimate worst-case stack use from per-function frame sizes and the deepest call path, including interrupts, and name ways to measure and guard it.
  • State the risks of dynamic allocation in firmware (fragmentation, unbounded time, failure at run time) and the common alternatives.
Before you start
  • Pointers, arrays and sizeof (unit 2, lesson 3).
  • The SP and LR registers (lesson 2) and SRAM versus flash (lesson 4).
Steps in this lesson
  1. Three lifetimes, five places
  2. The stack
  3. How big must the stack be?
  4. The heap
  5. Worked example: is 2 KiB of stack enough?
  6. Common misconceptions

The puzzle

A board runs for three weeks and then starts sending corrupted packets. No crash, no fault, just a buffer whose last few bytes are occasionally wrong. The cause turns out to be a function added months ago with a 1 KiB local array, which, on the rare occasion that an interrupt arrives while it is running, pushes the stack a few dozen bytes past its end and into the buffer below. No part of the chip noticed. Where exactly do local variables, globals and malloc blocks live on a microcontroller, and how do you know the stack is big enough?

STEP 1

Three lifetimes, five places

C gives every object a storage duration (C11 §6.2.4), and on a microcontroller each duration maps to a specific region of memory set up by the linker (unit 4) and the startup code (unit 5):

C objectStorage durationSectionMemory
code, string literals, const globalsstatic.text, .rodataflash
globals and static variables with a non-zero initial valuestatic.dataRAM, initial values copied from flash
globals and static variables without an initialiser, or = 0static.bssRAM, zeroed at startup
local variables, function arguments beyond the registersautomaticstackRAM
blocks from mallocallocatedheapRAM

In code:

const uint16_t gain[4] = {1, 2, 4, 8};  /* .rodata: flash */
uint32_t boot_count = 1;                /* .data: RAM, 1 copied in at startup */
static uint8_t rx_buf[256];             /* .bss: RAM, zeroed at startup */

void handle(void) {
    uint32_t tmp;                       /* stack (or a register); indeterminate */
    static uint32_t calls;              /* .bss: static duration, block scope */
    uint8_t *p = malloc(64);            /* p on the stack; 64 bytes on the heap */
    …
}

The .data section is the odd one: its variables live in RAM, but their initial values must survive power-off, so the linker stores a copy in flash and the startup code copies it into RAM before main(). The RP2040 SDK’s linker script says exactly that, > RAM AT> FLASH, and its crt0.S copies .data, clears .bss, calls the runtime initialisation and only then calls main. That is why an uninitialised global reads 0 (the standard requires it, §6.7.9 ¶10, and the startup code implements it) while an uninitialised local reads whatever the stack held last.

INTERACTIVEWhere static data, the heap and the stack sit in RAM
RAM drawn as a column with data, bss and heap at the bottom and the stack at the topstack ↓ 2 KiBfree 10.5 KiBheap ↑ 1 KiB.bss 2 KiB.data 512 B0x2000_40000x2000_0000initial SP10.5 KiB between the heap and the stack.
RAM drawn as a column with data, bss and heap at the bottom and the stack at the topstack ↓ 2 KiBfree 10.5 KiBheap ↑ 1 KiB.bss 2 KiB.data 512 B0x2000_40000x2000_0000initial SP10.5 KiB between the heap and the stack.
.data 512 B + .bss 2 KiB + heap 1 KiB + stack 2 KiB = 5.5 KiB of 16 KiB. 10.5 KiB between the heap and the stack.

A typical single-stack layout: initialised data (.data) and zero-initialised data (.bss) at the bottom of RAM, the heap growing upwards after them, and the stack growing downwards from the top. The gap between heap and stack is all the headroom there is; on a core without memory protection nothing stops them meeting. The RAM here is an illustrative 16 KiB at 0x2000_0000.

The figure shows the common single-stack arrangement: static data at the bottom of RAM, the heap growing upwards after it, the stack growing downwards from the top. The free gap between the heap and the stack is the only safety margin, and on a core without memory protection, nothing stops them from meeting.

STEP 2

The stack

The stack holds the automatic storage of every function that has been called and has not yet returned. On Arm it is full-descending (AAPCS32 §6.2.1): SP holds the address of the last item pushed, and a push first decrements SP and then stores. Each call adds a frame, and each return removes it. A typical frame holds:

  • the return address (the caller’s LR, pushed if this function calls others),
  • callee-saved registers the function uses (r4–r11 on Arm),
  • local variables and arrays that do not fit in registers,
  • arguments for functions it calls, beyond the four passed in r0–r3.

The calling standard adds two rules: SP is always a multiple of 4, and a multiple of 8 at every public function boundary, so frames are padded to 8-byte multiples.

Interrupts use the stack too. When an exception is taken, a Cortex-M core itself pushes eight registers (r0–r3, r12, LR, the return address and xPSR): a 32-byte frame, plus 4 bytes of padding if SP was not a multiple of 8, and more if floating-point state must also be saved. The handler’s own frame goes below that. An interrupt can arrive at any instant, including the deepest point of the deepest call.

↑ This step uses the figure at the top of the page.

STEP 3

How big must the stack be?

The worst case is the deepest chain of calls the program can make, plus the deepest chain of interrupts that can nest on top of it:

Smax⁡=max⁡call paths∑f∈pathsf  +  ∑nesting levels(sexception frame+shandler)S_{\max} = \max_{\text{call paths}} \sum_{f \in \text{path}} s_f \;+\; \sum_{\text{nesting levels}} \left( s_{\text{exception frame}} + s_{\text{handler}} \right)

where sfs_f is function ff’s frame size. GCC reports the sfs_f with -fstack-usage, which writes a .su file listing each function’s frame in bytes, marked static if the size is fixed. The hard part is the call graph: function pointers, recursion and library calls make the deepest path hard to find by hand, and recursion with a data-dependent depth has no bound at all, which is why many embedded coding standards forbid it.

Two measurements complement the estimate. Stack painting: fill the stack region with a known pattern at startup, run the worst-case scenario, then find how much of the pattern was overwritten. Guards: place a no-access memory-protection region just below the stack, or use a stack-limit register on cores that have one (Armv8-M’s MSPLIM and PSPLIM), so an overflow faults immediately instead of silently corrupting data.

STEP 4

The heap

The heap serves malloc and free. It is convenient, and in firmware it brings three problems:

  1. Fragmentation. After many allocations and frees of different sizes, free memory is scattered in small pieces; a request can fail even though the total free space is large.
  2. Unbounded time. Searching for a fitting block takes a variable time, which matters in code with deadlines (unit 13).
  3. Failure at run time. malloc can return NULL weeks into deployment, in a situation no test reproduced.

Common responses: allocate everything once at startup and never free (FreeRTOS’s heap_1 allocator “does NOT allow allocated memory to be freed again”, by design), use fixed-size block pools that cannot fragment, or use an allocator that merges adjacent free blocks to limit fragmentation, as FreeRTOS’s heap_4 does. Many safety-oriented projects simply forbid dynamic allocation after initialisation.

STEP 5

Worked example: is 2 KiB of stack enough?

The RP2040 SDK’s defaults give core 0 a 2048-byte stack (PICO_STACK_SIZE = 0x800) whose top is 0x2004_2000, the end of the 4 KiB SCRATCH_Y bank. That size is only a reservation: nothing stops the stack growing past it into the rest of SCRATCH_Y and then SCRATCH_X, which the SDK uses for core 1’s stack. A program’s deepest path is main → parse_line → a formatting routine, and one interrupt can arrive at any time. From the build’s -fstack-usage output (the numbers here are illustrative):

ContributionBytes
main frame8
parse_line: char line[1024] plus 16 of saved registers1040
formatting routine, including its own callees600
exception frame pushed by the core32
interrupt handler’s frame64
total1744

2048−1744=3042048 - 1744 = 304 bytes of margin, 15 %. Tight but positive, provided the estimate covers every path. A second interrupt that can pre-empt the first adds another 32+shandler32 + s_{\text{handler}}; a future change that calls one more library function from inside parse_line might erase the margin without anyone noticing.

Moving line out of the stack, static char line[1024];, puts it in .bss: the RAM is still used, but now it is counted by the linker, which fails the build if .data, .bss, the heap reservation and the stack reservation do not fit their memory regions. The linker can check reservations; it cannot check how deep the stack actually goes at run time. Stack need drops to 1744−1024=7201744 - 1024 = 720 bytes. That is the general trade: large buffers belong in static storage, where the toolchain can see them.

MYTHS AND FACTS

Common misconceptions

A stack overflow crashes the program

On a core without a guard it silently overwrites whatever lies below the stack, and the program runs on with corrupted data.

Local variables start at zero

Automatic variables without an initialiser are indeterminate. Only static storage is zeroed, by the startup code.

static inside a function puts the variable on the stack

It gives it static storage duration: one copy in .data or .bss, keeping its value between calls.

const data is copied into RAM

On Cortex-M toolchains it stays in flash (.rodata). On Harvard parts such as AVR it may be copied to RAM unless marked for program memory (lesson 3).

free gives memory back for any later request

It returns the block to the heap, but fragmentation can leave no single piece large enough.

The stack and heap are separate hardware

Both are ordinary RAM ranges chosen by the linker script.

Check yourself

Answer in your head, then open the card.

Where does each live: static const char msg[] = "ok";, int counter; at file scope, int x = 5; inside a function?

msg: .rodata in flash. counter: .bss in RAM, zeroed at startup. x: the stack (or a register), set to 5 each time the function runs.

SP is 0x2004_1F00 and a function pushes four registers. What is SP afterwards, and at which address is the first register stored?

Full-descending: four 4-byte words move SP down by 16 to 0x2004_1EF0. The registers occupy 0x2004_1EF0 to 0x2004_1EFF; the lowest-numbered register is stored at the lowest address, 0x2004_1EF0.

A recursive function has a 24-byte frame and main’s frame is 8 bytes. With a 1 KiB stack and a 32-byte interrupt frame plus a 40-byte handler, what is the deepest safe recursion?

Available: 1024−8−32−40=9441024 - 8 - 32 - 40 = 944 bytes. 944/24=39.3944 / 24 = 39.3, so 39 levels.

Why does moving a large local array to static storage make an overflow easier to catch?

The array becomes part of .bss, whose size the linker knows. If RAM is over-committed the build fails. On the stack its size only matters at run time, and only on the path that calls the function.

Sources (5)
  1. ISO/IEC 9899:2011 (C11), committee draft N1570, §6.2.4 “Storage durations of objects” and §6.7.9 ¶10 (initialization) — four storage durations: static, thread, automatic and allocated; an automatic object that is not initialised has an indeterminate value, while an object with static storage duration is initialised to zero (arithmetic types) or a null pointer
  2. Arm, Procedure Call Standard for the Arm Architecture (AAPCS32), §6.2.1 “The Stack” — the stack is full-descending with its extent held in SP; at all times SP mod 4 = 0, and at a public interface SP mod 8 = 0; data may only be stored in [SP, stack base − 1]
  3. Raspberry Pi Ltd, pico-sdk 1.5.1, src/rp2_common/pico_standard_link/memmap_default.ld, crt0.S and src/rp2_common/pico_platform/include/pico/platform.h — a real layout: .data “> RAM AT> FLASH”, .bss and .heap in RAM, core 0’s stack at the top of SCRATCH_Y (__StackTop = 0x20042000); crt0.S copies .data, clears .bss, calls runtime_init and then main; PICO_STACK_SIZE and PICO_HEAP_SIZE default to 0x800 bytes
  4. GCC manual, “GCC Developer Options”: -fstack-usage — writes a .su file with each function’s stack usage in bytes, qualified static, dynamic or bounded
  5. FreeRTOS-Kernel, portable/MemMang/heap_1.c and heap_4.c — two allocator designs for embedded use: heap_1 “does NOT allow allocated memory to be freed again”; heap_4 coalesces adjacent free blocks “and in so doing limits memory fragmentation”