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 object | Storage duration | Section | Memory |
|---|---|---|---|
code, string literals, const globals | static | .text, .rodata | flash |
globals and static variables with a non-zero initial value | static | .data | RAM, initial values copied from flash |
globals and static variables without an initialiser, or = 0 | static | .bss | RAM, zeroed at startup |
| local variables, function arguments beyond the registers | automatic | stack | RAM |
blocks from malloc | allocated | heap | RAM |
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.
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.
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:
where is function ’s frame size. GCC reports the 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:
- 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.
- Unbounded time. Searching for a fitting block takes a variable time, which matters in code with deadlines (unit 13).
- Failure at run time.
malloccan returnNULLweeks 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):
| Contribution | Bytes |
|---|---|
main frame | 8 |
parse_line: char line[1024] plus 16 of saved registers | 1040 |
| formatting routine, including its own callees | 600 |
| exception frame pushed by the core | 32 |
| interrupt handler’s frame | 64 |
| total | 1744 |
bytes of margin, 15 %. Tight but positive, provided the estimate covers every path. A second interrupt that can pre-empt the first adds another ; 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 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: bytes. , 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)
- 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
- 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]
- 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
- GCC manual, “GCC Developer Options”: -fstack-usage — writes a .su file with each function’s stack usage in bytes, qualified static, dynamic or bounded
- 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”