How do you size the stack and the heap on a device with a fixed RAM budget?
Stack and heap sizing on fixed RAM starts from the link map, worst-case call depth and interrupt nesting. Measure stack high-water marks with fill patterns, avoid unbounded heap allocation, and prefer fixed pools allocated at startup for predictable firmware behaviour.
What the interviewer is scoring
- Whether you can say where each region of RAM comes from and who decides its size
- Does the candidate treat stack depth as something to be measured rather than guessed
- That they name interrupt nesting as part of the stack budget, not just the call tree
- Whether the objection to malloc is fragmentation and non-determinism rather than speed
- Whether you describe what a stack overflow does on a part with no memory protection
Answer
Short answer
Budget globals from the link map, size stack from worst-case calls plus nested interrupts, prove it with high-water testing, and replace general heap use with fixed pools.
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.
/* One pool, one block size. Interchangeable blocks cannot fragment. */
#define POOL_BLOCKS 8
#define BLOCK_BYTES 128
static uint8_t pool[POOL_BLOCKS][BLOCK_BYTES];
static uint8_t in_use[POOL_BLOCKS]; /* lives in .bss, so zeroed at start-up */
void *pool_take(void) {
for (unsigned i = 0; i < POOL_BLOCKS; i++) {
if (!in_use[i]) { in_use[i] = 1; return pool[i]; }
}
return NULL; /* exhaustion is a designed-for state, not a surprise */
}
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.
Likely follow-ups
- Where would you place a buffer that must survive a warm reset but not a power cycle?
- How would you prove to a safety reviewer that the deepest call path fits in the allocated stack?
- If a third-party TLS library insists on malloc, how do you contain it?
- What changes about your RAM budget once you introduce an RTOS with one stack per task?
Related questions
- Why do low-latency systems preallocate arenas instead of calling the general-purpose allocator on the hot path?hardAlso on memory-management7 min
- How do you use a watchdog timer to recover a wedged device without hiding the bug that wedged it?hardAlso on firmware5 min
- How do you design a custom memory allocator to eliminate fragmentation and guarantee deterministic execution time in a safety-critical embedded system?hardAlso on memory-management3 min
- How do retain cycles happen in Swift closures and delegates, and how would you find one in an app that is slowly leaking memory?mediumAlso on memory-management4 min