Embedded C and C++ Programming Questions
Writing C and C++ for resource-constrained microcontrollers and bare-metal or RTOS firmware, where every language construct maps to hardware behavior. Covers the C and C++ language craft firmware code relies on (header guards and the one-definition rule, macros versus const, inline and constexpr, enum class, callback and dispatch tables, static locals, const-correctness, bounded string handling, compile-time lookup tables, and overridable default handlers through weak symbols), the const and volatile qualifiers, memory-mapped register access (including bitfield versus mask-and-shift modelling) and read-modify-write patterns, compiler pragmas and optimization flags, packing structs to match hardware register and wire layouts, linker scripts, memory maps, placement across flash, SRAM, tightly coupled and backup memory, and startup code, static versus dynamic allocation, memory pools, bump, first-fit, buddy and slab embedded allocators, heap fragmentation, out-of-memory strategy and finding leaks or heap corruption without desktop tools, DMA-safe buffer allocation, stack sizing and overflow detection, memory protection unit regions for isolating tasks, fixed-point arithmetic, code-size reduction, the cost of C++ features such as exceptions, virtual dispatch and templates in firmware, and whether a given access is atomic on the target hardware (word size, read-modify-write on registers). Boundary: general-purpose C++ application craft (STL containers and algorithms, template metaprogramming), language-defined struct padding rules, inline assembly, interrupt and real-time scheduling theory, general concurrency primitives, peripheral buses and drivers, assembly-level debugging, and firmware architecture and ring-buffer streaming are covered elsewhere.
Implement a first-fit allocator in C that manages a single statically reserved buffer using a free list, exposing init, alloc and free. How do you handle alignment and block splitting, what happens to fragmentation over time, and when would you accept this allocator on a small device instead of a pool?
Sample Answer
Design in one paragraph. A first-fit allocator keeps a linked list of free blocks and, on each request, takes the first block big enough. Here the list lives inside the free blocks themselves (a free block's header holds the offset of the next free block), the list is kept in address order (each free block's offset is lower than the next one's, so a block's neighbours in memory are its neighbours in the list) so free can merge neighbours cheaply, and every block starts with an 8-byte header (size counts header plus payload; next is the next free block, or the marker USED while the block is allocated). Offsets are uint32_t instead of pointers, so the header is 8 bytes on a 32-bit Cortex-M and on the 64-bit machine used to test it.
Rounding up to a multiple of 8. The expression ((n + ALIGN - 1) & ~(ALIGN - 1)) + HDR is the size of the block for a request of n bytes. Adding ALIGN - 1 (7) pushes n past the next multiple of 8 unless it is already on one, and & ~7 clears the low three bits, which rounds down to a multiple of 8. So 1 becomes 8, 3 becomes 8, 100 becomes 104, 104 stays 104 and 105 becomes 112; the header adds 8, giving blocks of 16, 16, 112, 112 and 120 bytes. It works for any power-of-two alignment.
Alignment. The arena is declared aligned(8), every block size is rounded up to a multiple of 8, and the header is 8 bytes, so every payload address is a multiple of 8. Eight is the alignment arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb gives double and long long (a _Static_assert, a check the compiler makes at build time, comparing _Alignof(double) and _Alignof(long long), the alignment each type requires, with 8 compiled cleanly there), so an 8-aligned payload is correctly aligned for any basic C type on that target. If a caller needs more (a 32-byte DMA or cache-line buffer), either raise ALIGN for the whole arena or add an alloc_aligned that over-allocates and returns an aligned address inside the block while recording the shift in a hidden header, because otherwise free could not find the real block. For example, to return a 32-byte-aligned address from a block whose payload starts at 0x...08, step forward to the next multiple of 32 (up to 24 bytes, plus room for the hidden header), store the step size in the word just before the returned address, and have free subtract it to recover the original pointer.
Splitting. If the chosen free block is larger than needed, the tail becomes a new free block, but only when the leftover can hold a header plus the smallest payload (MIN_BLOCK, 16 bytes). A smaller leftover stays attached to the allocated block as internal waste (bytes inside an allocated block that the caller cannot use: for example a free block of 120 bytes serving a request that needs 112 leaves 8, less than MIN_BLOCK, so the caller gets a 120-byte block; rounding 100 up to 104 wastes 4 more), because a free block too small to ever satisfy a request is just list-walking overhead.
Coalescing. free walks the address-ordered list to find the insertion point, then merges with the next block when they touch, and with the previous block when they touch. Without coalescing, every freed block stays at its old size forever and the heap shatters quickly. Traced on the run below, when p[2] (offset 224, 112 bytes) is freed: the walk starts at the free list 112 -> 336 -> 560 -> 784 -> 1008, stops at the first block whose offset is not below 224 (336) with prev = 112, and links the freed block in front of 336. Block 224 ends at 224 + 112 = 336, exactly where the next free block starts, so the two merge into one 224-byte block that points to 560. Block 112 (the previous free one) ends at 112 + 112 = 224, so it merges too, giving one 336-byte block. The list becomes 112 -> 560 -> 784 -> 1008 with sizes 336, 112, 112, 16, which is the free_blocks=4 largest=336 line printed.
Code, in full:
#include <stdint.h>
#include <stdio.h>
#include <stddef.h>
#define ARENA_SIZE 1024u
#define ALIGN 8u
#define NIL 0xFFFFFFFFu
#define USED 0xA110C8EDu
typedef struct { uint32_t size; uint32_t next; } Hdr; /* 8 bytes; size counts header + payload */
#define HDR ((uint32_t)sizeof(Hdr))
#define MIN_BLOCK (HDR + ALIGN)
static uint8_t arena[ARENA_SIZE] __attribute__((aligned(8)));
static uint32_t free_head; /* offset of first free block, address ordered */
static Hdr *at(uint32_t off) { return (Hdr *)(void *)(arena + off); }
void heap_init(void) {
at(0)->size = ARENA_SIZE;
at(0)->next = NIL;
free_head = 0;
}
void *heap_alloc(size_t n) {
if (n == 0 || n > ARENA_SIZE) return NULL;
uint32_t need = (((uint32_t)n + ALIGN - 1u) & ~(ALIGN - 1u)) + HDR;
uint32_t prev = NIL, cur = free_head;
while (cur != NIL) {
Hdr *b = at(cur);
if (b->size >= need) { /* first fit */
uint32_t after = b->next;
if (b->size - need >= MIN_BLOCK) { /* split off the tail */
uint32_t rest = cur + need;
at(rest)->size = b->size - need;
at(rest)->next = after;
b->size = need;
after = rest;
}
if (prev == NIL) free_head = after; else at(prev)->next = after;
b->next = USED;
return (uint8_t *)b + HDR;
}
prev = cur;
cur = b->next;
}
return NULL;
}
int heap_free(void *p) {
if (p == NULL) return 0;
uint8_t *u = p;
if (u < arena + HDR || u >= arena + ARENA_SIZE || ((uintptr_t)u & (ALIGN - 1u))) return -1;
uint32_t off = (uint32_t)(u - arena) - HDR;
if (at(off)->next != USED) return -1; /* double free or wild pointer */
uint32_t prev = NIL, cur = free_head;
while (cur != NIL && cur < off) { prev = cur; cur = at(cur)->next; }
at(off)->next = cur;
if (cur != NIL && off + at(off)->size == cur) { /* merge with following block */
at(off)->size += at(cur)->size;
at(off)->next = at(cur)->next;
}
if (prev == NIL) free_head = off;
else if (prev + at(prev)->size == off) { /* merge with preceding block */
at(prev)->size += at(off)->size;
at(prev)->next = at(off)->next;
} else at(prev)->next = off;
return 0;
}
void heap_stats(uint32_t *total_free, uint32_t *largest, uint32_t *blocks) {
*total_free = *largest = *blocks = 0;
for (uint32_t c = free_head; c != NIL; c = at(c)->next) {
uint32_t s = at(c)->size;
*total_free += s; (*blocks)++;
if (s > *largest) *largest = s;
}
}
static void show(const char *label) {
uint32_t t, l, n;
heap_stats(&t, &l, &n);
printf("%-28s free=%4u largest=%4u free_blocks=%u\n", label, t, l, n);
}
int main(void) {
void *p[9];
heap_init();
show("after init");
void *a = heap_alloc(1), *b = heap_alloc(3);
printf("alloc(1) offset %td, alloc(3) offset %td, both 8-aligned: %d\n",
(uint8_t *)a - arena, (uint8_t *)b - arena,
!((uintptr_t)a & 7) && !((uintptr_t)b & 7));
heap_free(a); heap_free(b);
show("after freeing both");
for (int i = 0; i < 9; i++) p[i] = heap_alloc(100); /* 9 blocks of 112 bytes */
show("after 9 x alloc(100)");
for (int i = 1; i < 9; i += 2) heap_free(p[i]); /* free every other block */
show("after freeing 4 of them");
printf("alloc(200) -> %s\n", heap_alloc(200) ? "ok" : "NULL");
heap_free(p[2]); /* neighbours p[1], p[3] already free */
show("after freeing p[2]");
void *q = heap_alloc(200);
printf("alloc(200) -> %s\n", q ? "ok" : "NULL");
for (int i = 0; i < 9; i += 2) if (i != 2) heap_free(p[i]);
heap_free(q);
show("after freeing everything");
printf("second free of the same pointer -> %d\n", heap_free(q));
return 0;
}
Run in a gcc:14 container (GCC 14.4, aarch64) as gcc -O2 -Wall -Wextra -fsanitize=address,undefined with no warnings, the driver prints:
after init free=1024 largest=1024 free_blocks=1
alloc(1) offset 8, alloc(3) offset 24, both 8-aligned: 1
after freeing both free=1024 largest=1024 free_blocks=1
after 9 x alloc(100) free= 16 largest= 16 free_blocks=1
after freeing 4 of them free= 464 largest= 112 free_blocks=5
alloc(200) -> NULL
after freeing p[2] free= 576 largest= 336 free_blocks=4
alloc(200) -> ok
after freeing everything free=1024 largest=1024 free_blocks=1
second free of the same pointer -> -1
Reading the output: nine 100-byte requests each take 112 bytes (100 rounded to 104, plus 8 header), leaving 16 bytes. After the four odd-numbered blocks are freed, 464 bytes are free, yet the biggest hole is 112 bytes, so alloc(200) fails although more than twice that is free in total. Freeing p[2] merges it with both free neighbours into one 336-byte hole, and the same request then succeeds and splits that hole. A second free of the same pointer is refused. That check is best effort only: it relies on the header no longer reading USED, so a stale header inside a block that was later reused can fool it.
Fragmentation over time. This is external fragmentation: plenty of free bytes, no single hole big enough. First fit has no worst-case bound. Best fit instead scans the whole list for the smallest hole that is big enough: with free holes of 200 and 40 bytes and a request needing 32, first fit takes the 200-byte hole if it comes first and leaves a 168-byte remainder, while best fit takes the 40-byte hole, preserving the large one but leaving a tiny remainder and always walking the entire list. A long run of mixed-size, mixed-lifetime allocations leaves small holes near the start of the list, and each request must walk past them, so allocation time grows with the number of free blocks and is not constant. Address ordering and coalescing reduce the problem; they do not remove it, and no testing can prove a particular device never hits it. Allocation can only be proven safe if the pattern is constrained.
When to accept it on a small device instead of a pool. A fixed-size pool (blocks all one size, constant-time allocate and free, no external fragmentation) is the default choice. Accept this allocator when all of these hold:
- request sizes truly vary, so a pool per size would waste more RAM than the free list's 8-byte headers and holes;
- most allocations happen at startup or in a mode change and live until the next reset, or are freed in roughly the reverse order they were made;
- no caller is an interrupt handler or a deadline-driven task, since the walk length is unbounded;
- a worst-case run on real traffic, with the free-bytes and largest-hole numbers from
heap_statslogged, leaves headroom.
If any one fails, use pools, or a pool per size class. The function as shown is not safe to call from two contexts at once: wrap heap_alloc and heap_free in a critical section (a short stretch of code that cannot be interrupted or interleaved with another caller, usually by briefly disabling interrupts) or one mutex if tasks share it.
Describe a time when you found and fixed a memory-related bug in an embedded product (behavioral). Use the STAR format: Situation, Task, Action, Result. Focus on how you diagnosed the issue, tools/methods you used, trade-offs considered, and lessons learned you applied to prevent similar issues.
Sample Answer
A model answer. The gateway, the fleet figures and the results below are an invented example that shows the shape of a strong answer; they are not measurements. In your own answer, use a bug you really fixed and your real numbers.
Situation. I was the firmware owner for a battery-powered sensor gateway on a Cortex-M4 running FreeRTOS, with 128 KB of RAM and about 300 units in the field. Roughly one unit in thirty rebooted every few days, with no pattern by site. The reset-cause log is a record the chip keeps of why it last restarted (power-on, watchdog, brown-out and so on). The watchdog (a hardware timer that resets the chip if firmware stops feeding it) was the reset cause in every log, which told us something had hung or crashed, not what.
Task. Find the root cause, fix it without a hardware revision, and make sure the whole class of bug could not return. I was accountable for the fix and for the release.
Action.
- Narrowed it down before touching code. Reset-cause logs showed the watchdog resets clustered after a cellular reconnect. That pointed at the uplink task, the only one that formatted large diagnostic messages. The corrupted state, not the reset, was the real symptom.
- Measured instead of guessing. I enabled FreeRTOS stack overflow checking (
configCHECK_FOR_STACK_OVERFLOW) and read each task's stack high-water mark (uxTaskGetStackHighWaterMark, the fewest free words ever seen). The kernel fills a new stack with a known byte pattern and the high-water mark is how much of that pattern is still untouched at the far end, so a reading near zero means the task came close to overflowing, while several hundred bytes or more (as in the final result below) is a comfortable margin. The uplink task had a 2 KB stack and normally used about 1.4 KB, but the reconnect error path drove it to within roughly 60 bytes of the end. A deeper call chain in that path would have overrun it, and the neighbouring memory it would have overwritten belonged to the next task's control block (the kernel's per-task record of its stack pointer, state and priority), which explained the hang and the reboot rather than a clean fault. - Found why the path was deep. The map file (the linker's report listing every symbol's address and size) and
-fstack-usageoutput (a compiler option that writes each function's stack frame size to a.sufile) showed a 512-byte local buffer in the error formatter, called from inside a function that already held a 400-byte frame, with a floatsnprintfbelow it. - Weighed the options. Just raising the stack to 4 KB would have hidden the problem and cost 2 KB of RAM for one task, and the gateway had only about 9 KB free. Moving the buffer to a statically allocated one would be safe only if one error could be formatted at a time, which a mutex (a lock that lets only one task at a time into a section of code) already guaranteed. Replacing the float format with fixed-point integer printing removed the deepest part of the call chain and some code size. I chose the static buffer under the existing mutex, integer formatting, and a modest rise to 2.5 KB so the worst path left more than a quarter of the stack free.
- Closed the class of bug. I added a build rule that fails when any function's stack use exceeds a set limit (the GCC warning
-Wstack-usage=N, which flags any function whose stack frame exceeds N bytes, combined with warnings treated as errors), a start-up painting of every stack (filling each stack area with a known pattern when the system boots, so the first untouched pattern byte from the far end shows how deep the stack ever went) and a periodic high-water check that logged and reported when any task's free stack fell below 25%, and a bench test that forced 50 reconnects while asserting the watermark.
Result. On the bench the worst-case free stack in the uplink task went from about 60 bytes to more than 1,000 bytes (60, plus the 512-byte buffer that left the stack, plus the 512 bytes the larger 2.5 KB stack added, is 1,084 bytes, before counting the saving from integer formatting), which is over 40% of the stack. After the update went out in stages over two weeks, the unexplained watchdog resets stopped: none in the following six weeks across the fleet, against about ten units (one in thirty of 300) showing watchdog resets before the fix. The periodic check also found a second task sitting at 90% use, which I fixed before it failed.
What I learned and would do differently. Stack sizes had been guessed once at project start and never measured, so a guard and a measurement should have been in the first version of the firmware, not added after an incident. I would also have enabled reset-cause and watermark logging in the field release earlier: weeks of ambiguity cost more than the few bytes of logging. And I would check error paths specifically, because normal-path testing never exercised the deepest stack use.
If you have a smaller story. The same structure works for a modest bug. For example: Situation, a sensor task on a small MCU that occasionally returned garbage; Task, find the cause in a week without new hardware; Action, read the map file to see that two buffers were adjacent, found by printing the high-water mark that one overran into the other, compared a bigger buffer with a length check and chose the check because RAM was short; Result, the garbage readings stopped and a unit test now feeds an oversized input. What makes it strong is the same in either size: the measurement that exposed the cause, the options you rejected with their cost, one number that changed, and one process change that stops the whole class of bug.
Design a linker script and startup strategy for a device that must boot a small ROM bootloader (flash 0x00000000-0x00003FFF) and then jump to an application at 0x00004000. Include handling of the vector table (copying or remapping), how to place read-only data in flash and .data in RAM, and how the bootloader should transfer control to the application safely (including stack pointer and reset handler).
Sample Answer
Assumptions. A Cortex-M3-class core whose reset vectors are fetched from address 0, 256 KiB of flash starting at 0x00000000 (erase unit 4 KiB), 64 KiB of RAM at 0x20000000. The bootloader owns 0x00000000 to 0x00003FFF (16 KiB) and the application starts at 0x00004000.
Approach. The boot-and-jump path is a bootloader at 0x0 with its own vector table, an application at 0x4000 with its own, and a safe hand-over (validate, quiet the hardware, repoint the vector table, load the stack pointer, branch). On cores that have a vector-table-offset register the standard way to handle the vector table is to remap it, as shown below. The memory map that follows lays out slots for field upgrades around that path.
Terms used throughout: the NVIC (Nested Vectored Interrupt Controller) is the core's interrupt controller; SCB (System Control Block) is a group of core control registers, one of which is VTOR (Vector Table Offset Register, the address where the core looks up its exception handlers); MSP is the main stack pointer; PRIMASK is a one-bit core register that, when set, blocks every exception except NMI and HardFault; a HardFault is the catch-all exception the core raises when something cannot be handled otherwise; SVC is the supervisor-call instruction that deliberately raises an exception, and IPSR is the register holding the number of the exception currently running (3 means HardFault); Thumb state is the instruction set Cortex-M cores execute, flagged by bit 0 of a branch target being 1; an erase sector is the smallest block flash can be erased in; QEMU is an emulator that can run the built images.
Memory map, including two upgrade slots and a swap area. This is one workable partition of the assumed 256 KiB; the slot sizes are a design choice, and the sizing rules after the table say how to check a real image against them:
| Range | Size | Use |
|---|---|---|
| 0x00000000 to 0x00003FFF | 16 KiB | bootloader (own vectors, code, .rodata, .data image) |
| 0x00004000 to 0x0001FFFF | 112 KiB | slot A: the application that runs (executes in place) |
| 0x00020000 to 0x0003BFFF | 112 KiB | slot B: the downloaded upgrade |
| 0x0003C000 to 0x0003CFFF | 4 KiB | scratch: one erase sector used to swap the slots a sector at a time |
| 0x0003D000 to 0x0003FFFF | 12 KiB | configuration and update status |
16 + 112 + 112 + 4 + 12 = 256 KiB. Sizing rules: a slot must hold .isr_vector + .text + .rodata + the initial values of .data (the load image) plus an image header or trailer; .bss takes no flash. Take that figure from arm-none-eabi-size (text + data) and add margin for growth; both slots are the same size so either can hold either image. RAM is .data + .bss + heap + stack and must fit the 64 KiB; the bootloader and the application share the same RAM because the application reinitialises everything, so almost nothing needs to be reserved. The application script below ends its RAM 4 KiB early (LENGTH = 60K), so the bootloader's stack (which starts at the end of RAM) is distinguishable from the application's, and the top 4 KiB are free for, optionally, a few bytes of "no-init" memory for a message from the application to the bootloader. Flash regions must start on erase-sector boundaries.
Linker scripts. The bootloader's script puts its table at 0, because the core reads its initial stack pointer and reset vector from there:
/* Bootloader: first 16 KiB of flash. The CPU fetches its reset vectors from 0x0. */
MEMORY
{
BOOT (rx) : ORIGIN = 0x00000000, LENGTH = 16K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
ENTRY(Reset_Handler)
_estack = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS
{
.isr_vector : { KEEP(*(.isr_vector)) } > BOOT
.text : { *(.text*) *(.rodata*) . = ALIGN(4); } > BOOT
_sidata = LOADADDR(.data); /* initial values of .data live in flash */
.data : { _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT > BOOT
.bss : { _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM
}
The application's script places the vector table first in slot A and keeps it (KEEP) so --gc-sections cannot drop it. AT > SLOT_A puts the load address of .data in flash while its run address is in RAM, and _sidata is that flash address:
/* Application: slot A, executes in place at 0x4000. */
MEMORY
{
SLOT_A (rx) : ORIGIN = 0x00004000, LENGTH = 112K
RAM (rwx): ORIGIN = 0x20000000, LENGTH = 60K /* top 4 KiB left to the bootloader's stack */
}
ENTRY(Reset_Handler)
_estack = ORIGIN(RAM) + LENGTH(RAM); /* 0x2000F000: the application's stack top */
SECTIONS
{
.isr_vector : { KEEP(*(.isr_vector)) } > SLOT_A /* first word: initial SP; second: reset */
.text : { *(.text*) *(.rodata*) . = ALIGN(4); } > SLOT_A
_sidata = LOADADDR(.data);
.data : { _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT > SLOT_A
.bss : { _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM
}
Read-only data goes in flash with .text (no AT needed because its run address is its load address); .data has two addresses (load in flash, run in RAM) and startup copies it; .bss has no load image and is zeroed. The linker rejects overflow: with LENGTH = 112K in app.ld changed to LENGTH = 256, the application link of the sources below stops with region `SLOT_A' overflowed by 340 bytes.
Startup and hand-over. The complete sources that were built follow: the board helpers (uart.h, included by both programs), the bootloader (boot.c), then the application (app.c). app_is_valid, jump_to_app, the application's Reset_Handler and the vector table are the code the rest of this answer discusses.
/* uart.h: helpers for the QEMU lm3s6965evb board: UART0 output and a semihosting exit. */
#include <stdint.h>
#define UART0_DR (*(volatile uint32_t *)0x4000C000u)
#define UART0_FR (*(volatile uint32_t *)0x4000C018u)
static void put_char(char c) { while (UART0_FR & (1u << 5)) { } UART0_DR = (uint32_t)c; } /* wait while TX FIFO full */
static void put_str(const char *s) { while (*s) put_char(*s++); }
static void put_hex(uint32_t v)
{
for (int i = 28; i >= 0; i -= 4) put_char("0123456789ABCDEF"[(v >> i) & 15]);
}
static void qemu_exit(void) /* semihosting SYS_EXIT; needs -semihosting-config enable=on */
{
static const uint32_t args[2] = { 0x20026u, 0 };
register uint32_t op __asm("r0") = 0x18;
register const void *arg __asm("r1") = args;
__asm volatile ("bkpt 0xAB" :: "r"(op), "r"(arg) : "memory");
}
/* boot.c: the bootloader, linked with boot.ld at 0x00000000 */
#include "uart.h"
#define RAM_BASE 0x20000000u
#define RAM_END 0x20010000u
#define APP_BASE 0x00004000u
#define APP_END 0x00020000u
#define SCB_VTOR (*(volatile uint32_t *)0xE000ED08u)
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss, _estack;
int counter = 41; /* initialised data: proves the .data copy worked */
void Reset_Handler(void);
void Default_Handler(void) { put_str("boot: unexpected exception\n"); for (;;) { } }
static int app_is_valid(uint32_t base)
{
const uint32_t *vt = (const uint32_t *)base;
uint32_t sp = vt[0], reset = vt[1];
if (sp < RAM_BASE || sp > RAM_END || (sp & 3u)) return 0; /* SP inside RAM, aligned */
if (!(reset & 1u)) return 0; /* Thumb bit set */
if ((reset & ~1u) < base || (reset & ~1u) >= APP_END) return 0; /* reset handler inside the slot */
return 1; /* a CRC check of the image goes here too */
}
__attribute__((noreturn)) static void jump_to_app(uint32_t base)
{
const uint32_t *vt = (const uint32_t *)base;
uint32_t sp = vt[0], reset = vt[1];
__asm volatile ("cpsid i" ::: "memory"); /* nothing may fire during the hand-over */
*(volatile uint32_t *)0xE000E010u = 0; /* SysTick off */
for (int i = 0; i < 8; i++) {
((volatile uint32_t *)0xE000E180u)[i] = 0xFFFFFFFFu; /* NVIC ICER: disable all IRQs */
((volatile uint32_t *)0xE000E280u)[i] = 0xFFFFFFFFu; /* NVIC ICPR: clear pending */
}
SCB_VTOR = base; /* app's table from now on */
__asm volatile ("dsb\n\tisb" ::: "memory");
__asm volatile ("msr msp, %0\n\tbx %1" :: "r"(sp), "r"(reset) : "memory");
for (;;) { }
}
void Reset_Handler(void)
{
for (uint32_t *s = &_sidata, *d = &_sdata; d < &_edata; ) *d++ = *s++;
for (uint32_t *d = &_sbss; d < &_ebss; ) *d++ = 0;
put_str("boot: counter="); put_hex((uint32_t)counter); put_str("\n");
if (!app_is_valid(APP_BASE)) { put_str("boot: no valid app\n"); qemu_exit(); }
put_str("boot: jumping\n");
jump_to_app(APP_BASE);
}
__attribute__((section(".isr_vector"), used))
static void (*const vectors[16])(void) = {
(void (*)(void))&_estack, Reset_Handler,
Default_Handler, Default_Handler, Default_Handler, Default_Handler, Default_Handler,
0, 0, 0, 0, Default_Handler, Default_Handler, 0, Default_Handler, Default_Handler };
/* app.c: the application, linked with app.ld at 0x00004000 */
#include "uart.h"
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss, _estack;
int value = 99; /* initialised data */
int zeroed; /* .bss */
void Reset_Handler(void);
void Default_Handler(void) /* any exception other than SVC lands here */
{
uint32_t ipsr;
__asm volatile ("mrs %0, ipsr" : "=r"(ipsr));
put_str("app: fault handler, IPSR="); put_hex(ipsr); put_str("\n");
qemu_exit();
for (;;) { }
}
void SVC_Handler(void) { put_str("app: SVC handler, found through the app's own vector table\n"); }
int main(void)
{
uint32_t sp;
__asm volatile ("mov %0, sp" : "=r"(sp));
put_str("app: running, VTOR="); put_hex(*(volatile uint32_t *)0xE000ED08u);
put_str(" data="); put_hex((uint32_t)value);
put_str(" bss="); put_hex((uint32_t)zeroed);
put_str(" sp_near_top_of_ram: ");
put_str((sp <= (uint32_t)&_estack && sp > (uint32_t)&_estack - 256u) ? "yes" : "no");
put_str("\n");
__asm volatile ("svc 0"); /* needs PRIMASK clear, see Reset_Handler */
put_str("app: done\n");
qemu_exit();
return 0;
}
void Reset_Handler(void)
{
__asm volatile ("cpsie i"); /* the bootloader left PRIMASK set: unmask before using SVC/IRQs */
for (uint32_t *s = &_sidata, *d = &_sdata; d < &_edata; ) *d++ = *s++;
for (uint32_t *d = &_sbss; d < &_ebss; ) *d++ = 0;
main();
}
__attribute__((section(".isr_vector"), used))
static void (*const vectors[16])(void) = {
(void (*)(void))&_estack, Reset_Handler,
Default_Handler, Default_Handler, Default_Handler, Default_Handler, Default_Handler,
0, 0, 0, 0, SVC_Handler, Default_Handler, 0, Default_Handler, Default_Handler };
Reading the code: app_is_valid rejects an image whose first two words cannot be a stack pointer and a reset address. jump_to_app does the hand-over: the constants are core registers (0xE000E010 is the SysTick control register, 0xE000E180 to 0xE000E19C are the eight NVIC Clear-Enable registers ICER, 0xE000E280 onward are the eight Clear-Pending registers ICPR; 8 registers x 32 bits covers 256 interrupts, and writing 1s to them disables and un-pends every one); in the inline assembly, msr msp, %0 copies the C variable sp into the main stack pointer and bx %1 jumps to reset. A branch rather than a call is used because a call would store a return address that nothing will ever use, and the code after it must never run. Reset_Handler is the application's startup, and the vector table array lists, by position: 0 initial stack pointer, 1 reset, 2 NMI, 3 HardFault, 4 MemManage, 5 BusFault, 6 UsageFault, 7 to 10 reserved (the four 0 entries), 11 SVCall, 12 DebugMonitor, 13 reserved (the next 0), 14 PendSV, 15 SysTick. The addresses 0xE000E010, 0xE000E180 and 0xE000E280 and the VTOR offset 0xED08 match CMSIS_6's core_cm3.h (SysTick at 0xE000E010, NVIC at 0xE000E100 with ICER at +0x80 and ICPR at +0x180, SCB at 0xE000ED00 with VTOR at +0x8).
How the transfer works, in order:
- Validate before trusting. Read the stack pointer (word 0) and reset handler (word 1) from the slot. The stack pointer must be word-aligned and inside RAM; the reset address must have bit 0 set (Thumb state) and lie inside the slot. A real bootloader also checks a CRC or signature over the whole image, and a version number if rollback protection is required.
- Quiet the machine. Mask interrupts (
cpsid i), stop SysTick, disable and clear all NVIC interrupts, and shut down any peripheral or DMA channel the bootloader started, so no stale interrupt arrives into the application's table. - Point the core at the application's table. Write the slot address to
SCB->VTOR(the Vector Table Offset Register, 0xE000ED08 in CMSIS), thendsbandisbso the write takes effect before anything else runs. The table address must be aligned to the table size rounded up to a power of two (and to at least 128 bytes on Cortex-M3, because the low bits of VTOR are fixed at zero); 0x4000 is 16 KiB aligned, which satisfies that for tables up to 16 KiB. - Load the stack pointer and branch.
msr mspfrom word 0, thenbxto word 1. It is a branch, not a call, and never returns. If the bootloader ran on the process stack, also clearCONTROLso the application starts on the main stack. - The application starts as if from reset. Its
Reset_Handlercopies.data, zeroes.bssand callsmain. It must also unmask interrupts: the bootloader left PRIMASK set, and with thecpsie iline deleted fromapp.cthe same build runs the application's SVC as a HardFault (its fault handler printsIPSR=00000003) instead of reachingSVC_Handler. The reason: with PRIMASK set, the SVC exception cannot be taken, because the core is already running at a priority the SVC does not exceed, so the core escalates the SVC to a HardFault; once PRIMASK is cleared, the SVC is taken normally.
Vector table: remap, copy or forward.
- Core has VTOR (Cortex-M3, M4, M7; M0+ only if the vendor included it): remap, as above. This is the standard path and needs no copy. The two options below are for special cases.
- Copy to RAM: choose it when handlers must be installed at run time or when flash is slow. Copy the table (for example 16 exceptions plus the device's IRQs, 4 bytes each) to a suitably aligned RAM block and point VTOR at it; the cost is that RAM and the copy time.
- Core without VTOR (Cortex-M0, whose CMSIS header has no VTOR register): either use the vendor's memory-remap register to alias RAM or the application's table at address 0, or keep the bootloader's table at 0 with small stubs that load each handler address from the application's table and jump to it.
Evidence. The three sources above and the two linker scripts were built with arm-none-eabi-gcc 14.2.1 and executed in QEMU 10.0 (qemu-system-arm -M lm3s6965evb, a Cortex-M3 board with flash at 0 and RAM at 0x20000000):
F="-mcpu=cortex-m3 -mthumb -Os -Wall -Wextra -ffreestanding -nostartfiles -nostdlib"
arm-none-eabi-gcc $F -T boot.ld boot.c -o boot.elf -Wl,--gc-sections
arm-none-eabi-gcc $F -T app.ld app.c -o app.elf -Wl,--gc-sections
arm-none-eabi-objcopy -O binary app.elf app.bin
qemu-system-arm -M lm3s6965evb -nographic -semihosting-config enable=on,target=native \
-kernel boot.elf -device loader,file=app.bin,addr=0x4000
Both links are warning-free; arm-none-eabi-size reports text 480, data 4, bss 0 for the bootloader and text 592, data 4, bss 4 for the application. Output:
boot: counter=00000029
boot: jumping
app: running, VTOR=00004000 data=00000063 bss=00000000 sp_near_top_of_ram: yes
app: SVC handler, found through the app's own vector table
app: done
Decoding the lines: boot: lines come from the bootloader and app: lines from the application. counter=00000029 is the bootloader's initialised .data variable (0x29 = 41) and data=00000063 is the application's (0x63 = 99). bss=00000000 shows its zero-initialised variable really is zero. sp_near_top_of_ram: yes is the application checking that its own stack pointer sits just below its own stack top, _estack = 0x2000F000 (the application's script ends RAM at 60 KiB, while the bootloader's stack starts at 0x20010000), which is where msr msp placed it from word 0 of the table. The bootloader's stack lies above that address, so with the msr msp, %0 removed from jump_to_app the same line prints no: the check can fail. So the values are 41 and 99 (printed in hex): the .data copies were made correctly in both programs, .bss is zero, VTOR reads 0x4000, and the SVC exception was delivered through the application's table. Starting QEMU without the -device loader option leaves flash at 0x4000 blank, and the bootloader prints boot: no valid app instead of jumping, so the validity check can fail. The built code covers the validity checks on the stack pointer and reset address, the hand-over and both startups; the CRC check, the swap and the slot logic are design only. The UART and exit calls are specific to that QEMU board.
What this trades. The bootloader costs 16 KiB of flash and a fixed application address. Two slots halve the flash available for the application (112 KiB of 256 here) in exchange for a power-fail-safe upgrade: the old image stays valid until the new one is verified. A smaller layout (one slot, overwrite in place) fits a larger application but a power loss during the write leaves a device with no working image, which is the case that would flip the choice.
Implement a simple fixed-size bump allocator in C for a contiguous memory pool. Provide the API: void pool_init(void* mem, size_t size); void* pool_alloc(size_t n); void pool_reset(void); The allocator does not need to free individual allocations. Ensure proper alignment for typical embedded types.
Sample Answer
What a bump allocator is. A bump (arena) allocator hands out memory by moving one pointer forward through a fixed buffer. Allocation is a compare and an add, so it takes the same few instructions every time (O(1), no searching) and cannot fragment. The cost is that individual blocks cannot be freed; the only way to give memory back is to reset the whole pool at once. That fits firmware that allocates its objects once during initialisation, or a short-lived scratch area that is thrown away at the end of each work cycle.
The implementation. Three details carry the interview weight: align the actual address (not just the offset), check the size against the space remaining before doing any arithmetic on it (so a huge n cannot wrap around), and decide what pool_alloc(0) means. The rounding expression (x + 7) & ~7 rounds x up to the next multiple of 8: adding 7 pushes any value that is not a multiple of 8 past the next multiple, and & ~7 then clears the low three bits (the part that is not a multiple of 8). For example 13 + 7 = 20 and 20 & ~7 = 16, while 16 + 7 = 23 and 23 & ~7 = 16, so a value that is already a multiple of 8 stays where it is. uintptr_t is an unsigned integer type wide enough to hold a pointer, which lets the code do this bit arithmetic on an address.
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#define POOL_ALIGN 8u /* covers uint64_t and double on Cortex-M and on the host */
static uint8_t *pool_cur;
static uint8_t *pool_end;
static uint8_t *pool_base;
void pool_init(void *mem, size_t size)
{
uintptr_t start = (uintptr_t)mem;
uintptr_t end = start + size;
start = (start + (POOL_ALIGN - 1u)) & ~(uintptr_t)(POOL_ALIGN - 1u);
if (mem == NULL || start > end) { /* buffer too small to hold even one aligned byte */
pool_base = pool_cur = pool_end = NULL;
return;
}
pool_base = pool_cur = (uint8_t *)start;
pool_end = (uint8_t *)end;
}
void *pool_alloc(size_t n)
{
size_t remaining = (size_t)(pool_end - pool_cur);
size_t rounded;
if (n == 0u || n > remaining) {
return NULL;
}
rounded = (n + (POOL_ALIGN - 1u)) & ~(size_t)(POOL_ALIGN - 1u); /* cannot overflow: n <= remaining */
if (rounded > remaining) {
return NULL;
}
void *p = pool_cur;
pool_cur += rounded;
return p;
}
void pool_reset(void)
{
pool_cur = pool_base;
}
/* ---- test driver ---- */
int main(void)
{
static _Alignas(16) uint8_t raw[70];
pool_init(raw + 1, sizeof raw - 1); /* deliberately misaligned start */
void *a = pool_alloc(1);
void *b = pool_alloc(10);
void *c = pool_alloc(8);
printf("a%%8=%u b%%8=%u c%%8=%u\n", (unsigned)((uintptr_t)a % 8), (unsigned)((uintptr_t)b % 8), (unsigned)((uintptr_t)c % 8));
printf("b-a=%td c-b=%td\n", (uint8_t *)b - (uint8_t *)a, (uint8_t *)c - (uint8_t *)b);
printf("alloc(SIZE_MAX) -> %p\n", pool_alloc((size_t)-1));
printf("alloc(0) -> %p\n", pool_alloc(0));
int filled = 0;
while (pool_alloc(8) != NULL) filled++;
printf("further 8-byte allocs before exhaustion: %d\n", filled);
pool_reset();
printf("after reset, first alloc equals a: %s\n", pool_alloc(1) == a ? "yes" : "no");
return 0;
}
Compiled with gcc -O2 -Wall -Wextra -fsanitize=address,undefined bump.c in a gcc:14 container (GCC 14.4.0, aarch64), the program prints:
a%8=0 b%8=0 c%8=0
b-a=8 c-b=16
alloc(SIZE_MAX) -> (nil)
alloc(0) -> (nil)
further 8-byte allocs before exhaustion: 3
after reset, first alloc equals a: yes
Tracing the output. raw is 70 bytes, and the demo hands over raw + 1, so the pool is 69 bytes starting one byte past a 16-byte boundary. Call that boundary address X: the start is X + 1, and (X + 1 + 7) & ~7 rounds it up to X + 8 (7 bytes lost to alignment), while the end is X + 70, so 70 - 8 = 62 bytes are usable. pool_alloc(1) rounds 1 up to 8 and returns X + 8 (a), leaving 54. pool_alloc(10) rounds 10 up to 16 and returns X + 16 (b), so b - a = 8, leaving 38. pool_alloc(8) returns X + 32 (c), so c - b = 16, leaving 30. Each further 8-byte request takes 8: 30 gives three allocations (24 bytes), leaving 6, and the fourth fails because 8 > 6. That is the 3 in the output. (%td prints a pointer difference, the type ptrdiff_t, in bytes here because the pointers are cast to uint8_t *.)
Why each choice.
- Alignment of the address.
pool_initrounds the start address up to a multiple of 8, because the caller may pass any buffer (the demo deliberately passesraw + 1). Rounding only the running offset would leave every pointer misaligned whenever the buffer itself is. 8 bytes coversuint64_tanddouble; a type with stricter alignment needs a largerPOOL_ALIGN. A misaligned access is a real hazard on microcontrollers: some cores and some instructions raise a fault for it, and even where the hardware tolerates it the access can cost extra cycles. The output showsa,bandcall at multiples of 8, and the 1-byte request consumed 8 bytes (b - a = 8), so rounding costs up to 7 bytes of internal waste per allocation (bytes reserved inside an allocation that the caller never uses). - Overflow-safe size check.
n > remainingis tested first. Only after that isn + 7computed, and it cannot wrap (overflow past the largest valuesize_tcan hold and restart near zero) becausen <= remaining. The naiveif (cur + n > end)test can wrap for a hugenand wrongly succeed: on a 32-bit part withcurat 0x20000100 andnof 0xFFFFFFFF, the sum is 0x1200000FF, which truncates to 0x200000FF, belowend, so the check passes and the caller is given a block that does not exist; the demo'spool_alloc((size_t)-1)returns NULL. pool_alloc(0)returns NULL in this version. The alternative (return a unique valid pointer) is also legitimate; what matters is documenting the choice.uintptr_tfor the alignment arithmetic. Bit masks are applied to integers, not pointers, so the rounding is well defined.pool_resetonly rewinds the cursor. Every pointer handed out earlier now points into memory that will be reused, so the caller must have dropped them all. The demo's last line shows the first allocation after a reset lands on the same address as the first one before it.
Limits to state in the interview. The allocator is not thread safe (it is not correct when several tasks or interrupts call it at the same time): two tasks, or a task and an ISR, can read the same pool_cur and receive the same block. Protect pool_alloc with a critical section (for example masking interrupts briefly) or give each context its own pool. There is also no per-block free, so use it only where lifetimes are grouped. If some objects must be freed individually, a fixed-block pool is the better tool; if every object shares one lifetime, the bump allocator is smaller, faster and impossible to fragment.
How would you detect stack overflow on a task stack in an RTOS and how do you decide how big each stack should be? Cover where a guard value goes, when it is checked, what you do on detection in a shipped device and how you estimate worst-case depth.
Sample Answer
Short answer. Detect overflow with two layers: a hardware guard that faults the instant a stack crosses its limit (a memory-protection region, which is an address range the memory protection unit (MPU) marks as no-access so touching it raises a fault, or the stack-limit registers on cores that have them: on Armv8-M the registers called PSPLIM and MSPLIM in the CMSIS headers hold the lowest address each stack pointer may reach), and a software guard value plus a high-water mark for cores or tasks without that. Size each stack from a worst-case path that is computed, then confirmed by measurement, then padded. In a shipped device, a detected overflow is a reason to log and reset, not to carry on.
Where the guard value goes. On Cortex-M the stack grows downwards, so the guard sits at the lowest addresses of the task's stack region, the end the stack runs into. Fill the whole region with a known pattern when the task is created (stack painting) and treat the lowest few words as the guard. FreeRTOS does exactly this. configCHECK_FOR_STACK_OVERFLOW is its setting that turns the software checks on, and the painted-pattern check (value 2) is the idea described here; value 1 is the cheaper variant. With it set to 2 it also checks that the last 16 bytes (four words) still hold the fill pattern 0xa5a5a5a5, and with 1 it only checks that the saved stack pointer is still inside the stack. Its own source states that the second test does not guarantee that an overflowed stack will always be recognised.
When it is checked.
- At every context switch (FreeRTOS checks the task being switched out and calls
vApplicationStackOverflowHookwith the task handle and name). This is late: the overflow already happened, and the damage was done between two switches. - Periodically from a low-priority task or the idle task, scanning the painted pattern to refresh the high-water mark (
uxTaskGetStackHighWaterMarkin FreeRTOS). - Immediately, in hardware, when a protection region or a stack limit register covers the guard. FreeRTOS's own source says that with its MPU ports a task-stack overflow triggers an MPU fault for unprivileged tasks, and that architectures with hardware stack-overflow checking, such as Armv8-M, fault when a task's stack overflows. Where the part has an MPU, a small no-access region at the stack bottom turns silent corruption into a fault at the offending instruction.
The guard value has a blind spot, shown by running it. The program below paints a 64-word stack that sits inside a larger array next to a neighbour's data. Case A grows the stack past its limit one frame at a time. Case B makes one large frame (think of a big local array) that jumps over the guard and writes only into the neighbour. The driver writes mem[2] by hand to stand in for that array's store, so the demo contains no stack code that overflows by itself; it only shows what the checks can and cannot see.
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#define FILL 0xA5A5A5A5u
#define GUARD_WORDS 4u /* 16 bytes at the low end of the stack */
typedef struct { uint32_t *base; size_t words; } task_stack_t; /* stack grows DOWN from base+words */
void stack_paint(task_stack_t *s)
{
for (size_t i = 0; i < s->words; i++) s->base[i] = FILL;
}
/* Words ever used: everything from the first overwritten word (scanning up from the bottom) to the top. */
size_t stack_used_words(const task_stack_t *s)
{
size_t untouched = 0;
while (untouched < s->words && s->base[untouched] == FILL) untouched++;
return s->words - untouched;
}
int stack_guard_ok(const task_stack_t *s)
{
for (size_t i = 0; i < GUARD_WORDS; i++)
if (s->base[i] != FILL) return 0;
return 1;
}
/* ---- test driver: the "stack" lives inside a bigger array so a deliberate overflow stays in bounds ---- */
#define VICTIM_WORDS 8u
#define STACK_WORDS 64u
static uint32_t mem[VICTIM_WORDS + STACK_WORDS]; /* mem[0..7] = a neighbour's data, mem[8..71] = the stack */
static void use_stack(const task_stack_t *s, size_t depth_words)
{
/* simulate a call chain that writes depth_words words, starting at the top of the stack */
for (size_t i = 0; i < depth_words; i++) s->base[s->words - 1 - i] = 0xDEAD0000u + (uint32_t)i;
}
int main(void)
{
task_stack_t s = { mem + VICTIM_WORDS, STACK_WORDS };
for (size_t i = 0; i < VICTIM_WORDS; i++) mem[i] = 0x11111111u; /* neighbour's data */
stack_paint(&s);
use_stack(&s, 40);
printf("normal use : used=%zu of %u words, guard %s\n", stack_used_words(&s), STACK_WORDS,
stack_guard_ok(&s) ? "intact" : "CORRUPT");
/* Case A: the stack grows 2 words past its limit, one frame at a time */
stack_paint(&s);
use_stack(&s, STACK_WORDS);
mem[VICTIM_WORDS - 1] = 0xBAD0BAD0u; mem[VICTIM_WORDS - 2] = 0xBAD0BAD1u;
printf("case A : used=%zu of %u words, guard %s, neighbour word mem[7]=0x%08X\n",
stack_used_words(&s), STACK_WORDS, stack_guard_ok(&s) ? "intact" : "CORRUPT", (unsigned)mem[VICTIM_WORDS - 1]);
/* Case B: one big frame (a large local array) skips over the guard and writes only into the neighbour */
stack_paint(&s);
mem[VICTIM_WORDS - 1] = 0x11111111u; mem[VICTIM_WORDS - 2] = 0x11111111u;
use_stack(&s, STACK_WORDS - GUARD_WORDS); /* fills down to the guard, not into it */
mem[2] = 0xBAD0BAD2u; /* big array writes lower, past the guard */
printf("case B : used=%zu of %u words, guard %s, neighbour word mem[2]=0x%08X\n",
stack_used_words(&s), STACK_WORDS, stack_guard_ok(&s) ? "intact" : "CORRUPT", (unsigned)mem[2]);
return 0;
}
Compiled with gcc -O2 -Wall -Wextra -fsanitize=address,undefined stackpaint.c in a gcc:14 container (GCC 14.4.0, aarch64), it prints:
normal use : used=40 of 64 words, guard intact
case A : used=64 of 64 words, guard CORRUPT, neighbour word mem[7]=0xBAD0BAD0
case B : used=60 of 64 words, guard intact, neighbour word mem[2]=0xBAD0BAD2
Case A is caught. In case B the guard is intact and the high-water mark reads 60 of 64 words, yet the neighbour's data is corrupted. A guard word and a watermark both prove only that nothing wrote on those particular words. That is why a large local array should be treated as a defect, why -Wstack-usage= and -Wframe-larger-than= belong in the build, and why a hardware region is stronger than a pattern.
What to do on detection in a shipped device. Assume the neighbouring memory (another task's stack, a control block, variables) may already be corrupt, so do not return to normal operation.
- Put outputs into a safe state (stop motors and heaters, release buses).
- Record a reason code and the task name in memory that survives a reset (a retained register or backup SRAM, meaning a register or RAM area that keeps its contents through a reset, or a flash log).
- Reset through the watchdog or a system reset.
- On the next boot, report the code, and count consecutive overflow resets so a boot loop falls back to a safe mode or an error indicator instead of resetting forever.
During development, the same hook should halt in the debugger with the task name.
How to size each stack. Worst-case depth is a sum, and each term comes from a tool.
- Deepest call chain.
-fstack-usagewrites each function's frame size. Take this sample firmware (itssinkfunction is a one-line function in a second file,int sink(const unsigned char *p, int n) { return p[0] + n; }):
#include <stdint.h>
#include <string.h>
uint32_t counters[64]; /* zero-initialised: .bss, 256 bytes */
static uint8_t rx_buf[2048]; /* .bss, 2048 bytes */
uint32_t calibration[16] = { 1, 2, 3 };/* initialised: .data (RAM) plus a load image in flash, 64 bytes */
const uint16_t lut[256] = { [0] = 1, [255] = 2 }; /* const: .rodata in flash only, 512 bytes */
extern int sink(const uint8_t *p, int n);
static int checksum(const uint8_t *p, int n)
{
uint8_t scratch[128]; /* 128 bytes of stack */
memcpy(scratch, p, (size_t)n);
int s = 0;
for (int i = 0; i < n; i++) s += scratch[i] ^ lut[i & 255];
return sink(scratch, s);
}
int process(int n)
{
uint8_t frame[256]; /* 256 bytes of stack */
memset(frame, n, sizeof frame);
counters[n & 63]++;
return checksum(frame, (int)sizeof frame) + checksum(rx_buf, n & 255) + (int)calibration[n & 15];
}
void Reset_Handler(void) { for (;;) { (void)process(3); } }
extern uint32_t _estack;
__attribute__((section(".isr_vector"), used))
const void *const vectors[2] = { &_estack, (void *)Reset_Handler };
Built with arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -Os -fstack-usage -c (arm-none-eabi-gcc 14.2.1), the .su files contained:
fw.c:11:12:checksum 144 static
fw.c:20:5:process 272 static
fw.c:28:6:Reset_Handler 8 static
sink.c:1:5:sink 0 static
The chain Reset_Handler, process, checksum, sink therefore needs at least 8 + 272 + 144 + 0 = 424 bytes before anything else is added. Each figure is a frame that is alive at the deepest moment: process is 272 because of its 256-byte frame array plus 16 bytes of saved registers and return address, checksum is 144 because of its 128-byte scratch array plus 16, and sink is a leaf (it calls nothing) with no frame. process calls checksum twice, but one call finishes before the next starts, so only one checksum frame is ever alive at a time. In the same build a function containing a variable-length array was reported with the qualifier dynamic instead of static, which means it has no compile-time bound.
The compiler does not see through function pointers, and library functions such as memcpy or printf have frames that are not in your .su files. Add their cost from the library's own disassembly or from a measurement.
2. Interrupt contribution. A Cortex-M core has two stack pointers: the main stack pointer (MSP), used after reset and by exception handlers, and the process stack pointer (PSP), which an RTOS usually points at the running task's stack. When an interrupt fires, the hardware pushes the stacked frame, the registers it saves automatically (including the return address), onto whichever stack was in use. It is an 8-word (32-byte) frame on exception entry. The FreeRTOS Cortex-M4F port saves r4 to r11 and the exception-return value itself (plus s16 to s31 when the task has used the floating-point unit), with a source comment that the remaining registers are "automatically saved on exception entry", and it runs tasks on the process stack pointer while interrupt handlers run on the main stack pointer. So on that kind of port each task stack needs room for the stacked frame (more when floating-point state is saved) but not for the interrupt handler's own locals, which use the main stack. That main stack must hold the deepest nest of interrupts. On a part with only one stack (no separate process stack), every interrupt's frame and locals land on whichever task was running, so each task stack must include the deepest possible interrupt nest.
3. Context-switch frame. Add what the RTOS saves on the task stack at a switch (the 8-word hardware frame plus r4 to r11 and the exception-return value, a special code the hardware places in the link register on exception entry that tells it how to return, on the Cortex-M4F port above, and more when floating-point registers are used). As an illustrative sum for the 424-byte chain on a port with a separate process stack and no floating-point state: 424 + 32 (hardware frame) + 36 (r4 to r11 are 8 words, 32 bytes, plus the 4-byte exception-return value) = 492 bytes. A 25 percent margin gives 492 x 1.25 = 615, which rounds up to 640 bytes (160 words) for that task's stack.
4. Recursion and third-party code. Avoid recursion, or bound the depth by design and test at that depth. For third-party code whose stack use is unknown, drive it with worst-case inputs in a test build with a painted stack and read the watermark. Treat the result as measured, not proven, and give that task a hardware guard.
5. Measure and pad. Run the worst realistic load (all interrupts firing, the longest logging path, the largest message) with a painted stack, read the high-water mark, and set the stack to measured maximum plus a margin. A margin of at least 25 percent is a reasonable starting rule for application code; reduce it only with a computed worst case.
Symptoms that point at overflow. Random HardFaults (the catch-all fault exception) or MemManage faults (the exception for memory-protection violations) with a program counter in a region with no code, a corrupted return address and a nonsense backtrace, a neighbouring task's variables changing for no reason, watchdog resets because a crashed task stopped feeding the watchdog, and faults that move when you change a stack size. In the fault handler, read the stacked frame (the registers the hardware saved when the fault was taken) from the stack pointer indicated by the exception-return value (bit 3 of the exception-return value selects thread mode and bit 2 selects the process stack pointer, the EXC_RETURN_MODE and EXC_RETURN_SPSEL masks in the CMSIS Armv8-M headers, so 0xFFFFFFFD returns to thread mode on the process stack pointer with a basic, non-floating-point frame); if that pointer lies outside RAM, the frame cannot be trusted, which in itself indicates overflow. Check the high-water marks of all tasks first, because the task that crashed is often not the one that overflowed.
Unlock Full Question Bank
Get access to all Embedded C and C++ Programming interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.