RAM is a fixed pie, and the linker cuts most of it
On a microcontroller there is one physical SRAM region and no virtual memory to hide behind. Suppose you have 64KB. The linker script divides it, and four things want a share.
Initialised globals land in .data, which occupies RAM but whose contents are copied out of flash by the start-up code before main runs. Zero-initialised and uninitialised globals land in .bss, which occupies RAM and is cleared by that same start-up code. Both sizes are known exactly at build time, and size on the ELF file will print them. Then the heap, if you have one, typically grows upwards from the end of .bss, and the stack grows downwards from the top of RAM. The gap between those two is your only slack.
That last sentence is the whole design problem. .data and .bss are decided by the compiler and are non-negotiable at run time. The stack and heap share whatever is left, they grow towards each other, and on a part without a memory protection unit nothing detects the moment they meet.
0x2000FFFF +----------------+ top of SRAM
| stack | grows down
+----------------+ <- no guard here by default
| |
| free slack |
| |
+----------------+
| heap | grows up, if you have one
+----------------+
| .bss | zeroed at start-up, size fixed at link time
+----------------+
| .data | copied from flash at start-up
0x20000000 +----------------+
Read that middle band as your entire safety margin. Every buffer you make static shrinks it from below and makes the remaining number honest; every recursive call and every nested interrupt eats it from above at a moment you did not choose.
The stack is measured, not estimated
The stack has to hold the deepest chain of live call frames, plus whatever the compiler spilled at each level, plus the register context that hardware pushes on interrupt entry, plus every nested interrupt frame on top of that. The last part is what candidates omit. If three interrupt priorities can preempt one another, the worst case is not the deepest function, it is the deepest function interrupted at its deepest point by the whole priority chain.
Two techniques make this tractable. Static analysis of the call graph gives you an upper bound provided you have no recursion and no function pointers whose targets you cannot enumerate, which is exactly why safety-oriented coding standards ban both. Then you measure: fill the stack region with a known pattern such as 0xA5 at start-up, run the worst load you can construct, and inspect how far the pattern has been overwritten. That high-water mark against your allocation is a number you can put in a review. Under FreeRTOS the same idea is available per task through uxTaskGetStackHighWaterMark, with configCHECK_FOR_STACK_OVERFLOW giving you a hook when the guard bytes are disturbed.
Why repeated malloc and free will not ship
The objection to dynamic allocation on a constrained device is not that it is slow. It is that its worst case is unbounded and unrepeatable.
Fragmentation is the mechanism. Allocate and free blocks of differing sizes for long enough and the free list becomes a scattering of gaps that sum to plenty of memory while no single gap is large enough for the next request. A device that runs for a few minutes on the bench and fails after eleven days in the field is the signature failure, and it is nearly impossible to reproduce because it depends on the exact order of allocations since power-on.
The second problem is that there is nowhere sensible to fail to. A server can return a 503; a sensor node has no such option, and the honest handling of a null return from malloc in a control loop is usually a reset. The third is timing: allocator walk time varies with the state of the free list, so a hard deadline cannot be met by code that allocates inside it.
What you do instead is allocate everything at start-up and never release it. Buffers become statically sized objects with file scope. Where you genuinely need variable lifetimes, you use a pool of fixed-size blocks carved out of a static array, which cannot fragment because every block is interchangeable.
#define POOL_BLOCKS 8
#define BLOCK_BYTES 128
static pool[POOL_BLOCKS][BLOCK_BYTES];
in_use[POOL_BLOCKS];
* {
( i = ; i < POOL_BLOCKS; i++) {
(!in_use[i]) { in_use[i] = ; pool[i]; }
}
;
}
The strong version of this answer adds that a NULL here is a bounded and testable condition: the pool has eight blocks, so you can exercise the ninth request in a unit test, which you cannot meaningfully do for a general heap.
A stack overflow that reads as a compiler bug
This is the specific failure worth naming, because it wastes weeks. With no memory protection unit configured, an overflowing stack does not fault. It simply writes past the end of its region into whatever sits below, which is your heap or the top of .bss. The symptom appears in an unrelated module: a state variable changes value on its own, a calibration constant becomes nonsense, a flag set on one line reads false on the next. Engineers respond by suspecting optimisation, sprinkling volatile, and lowering the optimisation level, which shifts the frame sizes and appears to fix it.
The countermeasure is to make the overflow loud instead of silent. On a Cortex-M part with an MPU, configure a small no-access region immediately below the stack so a touch faults rather than corrupts. On ARMv8-M there is a hardware stack limit register that traps the push itself. Failing both, keep the fill pattern in place in production builds and check the lowest words periodically, so a near-miss is reported before it becomes corruption.
Say the number and say how you got it: this build uses 41KB of .bss, the deepest measured stack use is 3.2KB against 6KB allocated, there is no heap, and here is the pattern-fill test that produced the figure.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).