This page is the module’s teaching text. It covers where objects live on each tier, why firmware bans the general-purpose heap and what replaces it, how to predict and pin memory layout, how to size a stack, and how the same discipline is expressed in Rust — where the absence of a heap is a crate choice (core without alloc) and buffer ownership is checked by the compiler rather than by convention. As with every lessons page in this course, it is AI-drafted teaching text reviewed by me; see the syllabus’s note on AI use. Seacord’s chapter on dynamically allocated memory, the Rust Book’s smart-pointer chapter, and the heapless/static_cell crate docs remain available as optional deep-dives; nothing below requires them.
1 · Storage duration is the first question
Every object has a storage duration — the rule that decides when its storage comes into existence and when it goes away. C defines four; Rust has the same four shapes with different names.
Duration
C
Rust
Where it lives on the Cortex-M
Cost model
Static
File-scope objects; block-scope static
static, static mut (banned — §7), const (inlined, no storage)
.data (initialized; costs flash and RAM) or .bss (zero-initialized; RAM only)
Fixed at link time, visible in the map file
Automatic
Block-scope locals, parameters
Locals, parameters, temporaries
The stack frame
Bounded by the deepest call chain (§6)
Allocated
malloc family
Box, Vec, String, Rc, Arc — all in alloc
The heap, if one exists
Unbounded over uptime; nondeterministic
Thread
_Thread_local (meaningless bare-metal)
thread_local! (std only)
Linux tier only
One copy per thread
The point of this table is the third row. In a hosted program the heap is the default answer to “where does a variable-sized thing go”; in firmware it is the last answer, and this module is about the first three.
1.1 Why firmware bans the heap after init
Seacord’s own baseline for hosted C already says: objects whose size is known at compile time belong in automatic or static storage, and dynamic allocation is for sizes unknown until runtime. Firmware tightens that to a rule, for four reasons that are each sufficient on their own:
Fragmentation is unbounded over uptime. A general-purpose allocator (Knuth’s boundary-tag scheme and its descendants) coalesces free blocks, but a device that runs for months without a reboot will eventually see a large allocation fail while the total free memory is ample. There is no MMU to remap physical pages into a contiguous virtual range — the fragmentation is physical and permanent.
Allocation can fail at the worst moment, and every call site needs a plan for NULL. Most firmware has no plan except a reset.
malloc timing is nondeterministic. A real-time loop with a deadline cannot contain a call whose worst-case execution time depends on the history of every previous allocation.
Leaks and double-frees that a server shrugs off brick a device. With 96 KB of RAM, a leak of one 64-byte buffer per hour is a field failure.
The rule that follows: allocate everything at initialization, from static storage, sized by the worst case; after init, no malloc. “After init” is the concession that lets a driver stack allocate its tables once at boot; many projects ban the heap entirely and link with --wrap=malloc to make any call a link error.
TipFirmware rule
Every buffer has a compile-time size, a static address in the map file, and one owner. If a size is not known at compile time, the design is not finished.
1.2 The same rule on the other tiers
Under FreeRTOS the kernel itself needs storage for task control blocks and stacks; configSUPPORT_STATIC_ALLOCATION lets you supply them (xTaskCreateStatic, xQueueCreateStatic) so the kernel never touches a heap. The five FreeRTOS heap schemes (heap_1 allocate-only through heap_4/heap_5 coalescing) exist for projects that want one; choosing among them is a documented determinism decision, not a default.
On embedded Linux the heap exists and is fine for the parts of a program that are not real-time. The real-time loop still avoids it — for a different reason: the first touch of a fresh heap page is a page fault, serviced by the kernel at a latency that dwarfs the loop period, and free can return pages to the kernel (munmap/sbrk trimming) so the next malloc faults again. §8 gives the Linux recipe.
2 · The heap, when you do use it
The C allocation functions are worth knowing precisely even in a course that mostly avoids them, because the initialization phase uses them and because the Linux tier is hosted.
Function
Contract
Trap
malloc(size)
Returns storage suitably aligned for any object up to size; contents indeterminate
Reading before writing is undefined; NULL on failure must be checked
calloc(n, size)
n × size bytes, all-bits-zero
All-bits-zero is not guaranteed to be 0.0 or a null pointer on exotic hardware (it is on both of this course’s)
aligned_alloc(align, size)
C11; align a power of two supported by the implementation, size a multiple of align
Not implemented on every embedded libc; on newlib check before relying on it
realloc(p, size)
Grows or shrinks; may move; returns NULL and leaves p valid on failure
p = realloc(p, n) leaks the old block when it fails — use a temporary
free(p)
Returns the block; free(NULL) is a no-op
Double-free and use-after-free are undefined; set p = NULL after freeing
reallocarray(p, n, size) is a BSD/glibc extension that checks the multiplication for wraparound; it is not in newlib. Material in Seacord beyond C17/18 is skipped.
Seacord’s memory-state diagram is the compact way to hold the rules: a block is unallocated, allocated but uninitialized, or allocated and initialized; malloc moves it from the first to the second, a write moves bytes (not the whole block) to the third, free returns it to the first — and every bug in this area is an operation that is not an edge in that graph: reading an uninitialized byte, writing after free, freeing twice.
2.1 Flexible array members
C99’s one honest way to allocate a header plus a variable-length payload in one block:
typedefstruct{uint16_t len;uint8_t data[];// incomplete array type: must be last, struct must have ≥ 1 other member} frame_t;frame_t *frame_alloc(size_t n){ frame_t *f = malloc(sizeof*f + n);// sizeof ignores the flexible memberif(f != NULL) f->len =(uint16_t)n;return f;}
sizeof(frame_t) excludes data (padding may leave room for a few elements, but that is not a guarantee). The idiom rides on top of static pools too: a pool of fixed-size blocks each holding a frame_t plus a maximum payload is the firmware form.
2.2 alloca and VLAs: stack allocation, banned
Both allocate from the caller’s stack at runtime; neither can report failure, because there is no portable way to ask how much stack is left. alloca is not standard at all; VLAs (C99, optional since C11 — __STDC_NO_VLA__) are standard but every firmware coding standard bans them, and this course’s warning set adds -Wvla to make the ban mechanical. A VLA’s sizeof is evaluated at runtime, side effects included, which is a second reason to avoid them. The replacement is a fixed-size array sized by the documented maximum plus a bounds check — the maximum had to exist for the stack budget anyway.
3 · What replaces the heap
In order of preference.
3.1 Static allocation
Sized at compile time, placed by the linker, budgeted in the map file, and — because .bss is zero-filled by startup code for free — cheaper than the same buffer initialized to zero in source only if the compiler notices the zeros (it does).
// Lives in .bss: costs 4096 bytes of RAM, zero bytes of flash.staticuint8_t rx_buffer[4096];// Lives in .data: costs 256 bytes of RAM *and* 256 bytes of flash for the initial image.staticuint16_t sine_table[128]={0,804,1608,/* … */};// Lives in .rodata: flash only, never copied.staticconstuint16_t sine_table_ro[128]={0,804,1608,/* … */};
The const on the third declaration is what moves it to flash; a table that is never written but lacks const costs RAM on every board.
3.2 Fixed-block pools
A pool hands out blocks of one size from a static array, keeps the free ones on a list threaded through the blocks themselves, and reports exhaustion as a counted, testable condition rather than a fault in some later function:
#define POOL_BLOCKS 16typedefstruct{uint8_t bytes[64];} block_t;staticunion{ block_t b;void*next;} pool[POOL_BLOCKS];// union: a free block stores the linkstaticvoid*free_list;void pool_init(void){for(size_t i =0; i +1< POOL_BLOCKS;++i) pool[i].next =&pool[i +1]; pool[POOL_BLOCKS -1].next = NULL; free_list =&pool[0];}block_t *pool_alloc(void){// O(1), no fragmentation, no size argumentvoid*p = free_list;if(p != NULL) free_list =*(void**)p;return p;}
Allocation and release are O(1) and deterministic; the worst case is “all sixteen are out”, which is a design number, not a runtime surprise. The union is the standard trick: while a block is free, its first word holds the free-list link, so the pool has zero overhead per block — and _Static_assert(sizeof(block_t) >= sizeof(void *), "block too small for a link") pins the assumption. If the pool is shared with an interrupt, the two-pointer update is a critical section (Module 9).
3.3 Arenas
An arena is a static byte array with a bump pointer: allocation is “advance by size, rounded to alignment”, and release is “reset the pointer” — all at once, never one object at a time. It suits init-time allocation of mixed object types (a driver’s tables, a parser’s nodes) and phases with a clear end. The alignment rounding is the only subtlety: the arena itself is declared _Alignas(max_align_t) and each allocation rounds up to the requested alignment, which aligned_alloc’s contract would have handled for you.
3.4 Ring buffers and double buffers
A ring buffer is a static array plus two indices — the byte-stream shape of most firmware queues (UART receive, sample streams), with a power-of-two capacity so that idx & (N - 1) replaces a modulo. A double buffer is two static arrays and one “which is mine” bit — the shape of every DMA hand-off (Course 3 Lab 5.3’s circular ADC-DMA is a double buffer with half- and full-transfer interrupts marking the swap). Both are static allocation with a protocol on top; Module 9 supplies the protocol.
4 · Layout: predicting sizeof before the compiler tells you
Padding is mechanical: each member goes at the next multiple of its alignment; the struct’s total size rounds up to a multiple of its strictest member’s alignment. Two rules give every answer:
struct bad {uint8_t a;uint32_t b;uint16_t c;};// 1 + 3 pad + 4 + 2 + 2 pad = 12struct good {uint32_t b;uint16_t c;uint8_t a;};// 4 + 2 + 1 + 1 pad = 8
struct bad — 12 bytes struct good — 8 bytes
┌────┬────┬────┬────┐ ┌────┬────┬────┬────┐
│ a │ × │ × │ × │ 0–3 │ b │ b │ b │ b │ 0–3
├────┼────┼────┼────┤ ├────┼────┼────┼────┤
│ b │ b │ b │ b │ 4–7 │ c │ c │ a │ × │ 4–7
├────┼────┼────┼────┤ └────┴────┴────┴────┘
│ c │ c │ × │ × │ 8–11 × = padding
└────┴────┴────┴────┘
First tool, always: order members by descending alignment, and the padding disappears with no attributes. Second tool: pin the result.
Fixed-width members lay out identically on LP64 and ILP32. Pointers, long, size_t, and max_align_t do not — a struct containing a pointer changes size between the two, and a layout assertion that passes on the Mac is the first line of defense against shipping a host-verified assumption to the board:
max_align_t is 16 bytes on the Mac (long double / vector types) and 8 on the Cortex-M4 (long long, double); an arena aligned to it is over-aligned on the board only if the host’s number was hard-coded.
4.2 Packing
__attribute__((packed)) (GNU/Clang, not ISO) removes padding to match an external format at the price of unaligned members. On the Cortex-M4 ordinary LDR/LDRH tolerate unaligned addresses (slower, and only if the UNALIGN_TRP bit is clear), but LDM/LDRD/STRD and the exclusive accesses fault. Taking the address of a packed member yields a pointer whose type promises an alignment it does not have; the compiler warns (-Waddress-of-packed-member), and the fault arrives later, somewhere else. Pack only at an explicit boundary — a protocol header, a flash image, a hardware descriptor whose ABI requires it — and serialize to the wire byte-wise or with memcpy so byte order lives in the code, not in the ABI’s hands.
4.3 Over-alignment
_Alignas(32) static uint8_t dma_buf[512]; places a buffer on a 32-byte boundary — needed by cached cores for cache-line isolation (the STM32L476 has no data cache, so the need there is a peripheral’s, e.g. a burst engine), and by NEON loads on the A-profile boards. _Alignas cannot reduce alignment; alignof reads it back; the linker map confirms the address.
5 · Layout and allocation in Rust
The same physics, three differences in the language.
5.1 repr(Rust) reorders; repr(C) does not
By default Rust owes you nothing about field order — the compiler is free to reorder struct bad into struct good on its own, and does. That is why size_of::<Bad>() can be smaller than a C programmer predicts, and why any struct that crosses an ABI boundary — a register block, a wire format, a C function argument, a DMA descriptor — is declared #[repr(C)], which restores C’s rules exactly:
#[repr(C)]struct Good { b:u32, c:u16, a:u8}// 8 bytes, C layout, C alignment#[repr(C, align(32))]struct DmaDescriptor { addr:u32, len:u32}// over-aligned like _Alignas(32)#[repr(C, packed)]struct WireHeader { tag:u8, len:u16, crc:u32}// 7 bytes; field references are compile errorsconst _: () =assert!(core::mem::size_of::<Good>() ==8);// the _Static_assertconst _: () =assert!(core::mem::offset_of!(Good, a) ==6);const _: () =assert!(core::mem::align_of::<Good>() ==4);
packed is safer in Rust than in C for one reason: taking a reference to a packed field is a hard error (the reference type would promise alignment the field lacks), so the “address of packed member” bug cannot be written. You copy the field out (let len = hdr.len;, allowed because u16: Copy) or use core::ptr::addr_of! and read_unaligned.
5.2 Box, Rc, Arc are not the language — they are alloc
The Rust Book’s smart pointers (Box<T> for heap ownership, Rc<T> for shared ownership with a count, Arc<T> for the thread-safe count, Deref and Drop for the mechanics) live in the alloc crate. A #![no_std] crate does not have them until it opts in:
#![no_std]externcrate alloc;// opt in: now Box/Vec/String exist …usealloc::vec::Vec;useembedded_alloc::LlffHeap as Heap;// … but only with a global allocator#[global_allocator]static HEAP: Heap =Heap::empty();// at init, once: hand the allocator a static regionstaticmut HEAP_MEM: [core::mem::MaybeUninit<u8>;8192] = [core::mem::MaybeUninit::uninit();8192];// unsafe { HEAP.init(core::ptr::addr_of_mut!(HEAP_MEM) as usize, 8192) }
That is the whole heap story on bare metal: a static array, an allocator crate, one unsafe init, and then Vec works — with every one of §1.1’s four problems intact, plus a fifth: an allocation failure calls the alloc error handler, which aborts. This course never enables alloc in the mcu or qemu crates; the exercise that turns it on exists to measure what it costs.
Drop, on the other hand, is not allocation — it is scope-end code and works everywhere. A no_std driver that releases a peripheral in Drop is idiomatic; a no_std crate that needs Box has usually mistaken a design question for a library question.
5.3 heapless: the collections with the capacity in the type
useheapless::{Vec,String,spsc::Queue};usecore::fmt::Write;letmut samples:Vec<i16,256>=Vec::new();samples.push(42).map_err(|_|Error::Full)?;// push returns Err(value) when full — no panic, no reallocletmut line:String<64>=String::new();write!(line,"adc={}", samples[0]).ok();// core::fmt::Write, boundedstaticmut Q: Queue<u16,64>=Queue::new();// an SPSC ring: split() yields (Producer, Consumer)
Three facts to hold: capacity is a const generic and therefore part of the type, so a Vec<u8, 32> and a Vec<u8, 64> are different types and the sizes are visible to cargo size; every fallible operation returns Result or Option rather than growing; and the collections are plain values, so a heapless::Vec in a static costs exactly its capacity in .bss and nothing at runtime. heapless::spsc::Queue is the ring buffer of §3.4 with the single-producer/single-consumer protocol built in — Module 9 puts an interrupt on one end. heapless::pool is the fixed-block pool of §3.2 with ownership handles (box_pool!); it needs an atomic compare-and-swap, which the Cortex-M4 has.
5.4 static mut is banned; StaticCell and MaybeUninit replace it
A static mut is a global the compiler cannot reason about: every access is unsafe, two &mut to it are undefined behavior, and an interrupt is a second accessor you cannot see. The 2024 edition makes even taking a reference to one a lint error. The replacements, by situation:
Need
Tool
What you get
Storage initialized once at runtime, then owned by one place
static_cell::StaticCell<T>
CELL.init(value) returns &'static mut T exactly once; a second init panics
Storage that is only ever built in place, possibly uninitialized until then
core::mem::MaybeUninit<T> in a static
Explicit “not yet valid” state; assume_init is the unsafe line where the contract is stated
The &'static mut is the interesting type: it is a unique, lifetime-unbounded borrow of static storage — exactly the thing a DMA driver needs to hold across an asynchronous transfer, and exactly the thing C expresses as “this buffer is DMA-owned now, do not touch it” in a comment.
6 · The stack is a budget you did not write down
The stack is static allocation with the size decided by the linker script (_estack in a CubeMX project, _stack_start in cortex-m-rt’s link.x) and consumed by the deepest call chain plus interrupt nesting plus the FP context the hardware stacks on exception entry. Nothing checks it unless you do.
6.1 Predicting
GCC’s -fstack-usage (recent clang accepts it too) writes one .su file per translation unit listing every function’s frame size and whether it is static, dynamic, or bounded:
main.c:42:main 96 static
dsp.c:17:fir_block 288 static
parser.c:88:parse_line 4128 static ← a local char[4096]: the number to find
Any dynamic entry is a VLA or alloca and violates §2.2. Summing the frames along the worst call path — by hand for a small program, with a call-graph tool for a large one — gives the main-thread budget; add the largest ISR chain and, on the M4F, up to 104 bytes of automatic FP stacking per exception level. -Wstack-usage=N turns any frame above N into a warning. Rust has no -fstack-usage, but cargo call-stack computes the same worst-case path from LLVM’s stack-size metadata; check the tool’s current target support before relying on it.
6.2 Verifying
Two runtime techniques, both cheap: stack painting — fill the stack region with a pattern at reset (CubeMX projects do this by hand in Reset_Handler; cortex-m-rt offers an equivalent as an opt-in) and read back how much of the pattern survived after a stress run; and under FreeRTOS, uxTaskGetStackHighWaterMark() per task, plus configCHECK_FOR_STACK_OVERFLOW for the hook that fires when a task’s stack is breached. Under Rust’s frameworks the same numbers come from the framework’s own stack-usage reporting or from painting the region cortex-m-rt reserves.
ImportantThe stack fails silently
A stack overflow on the Cortex-M4 overwrites whatever the linker placed below the stack — usually .bss, usually a buffer, usually something that corrupts hours later. Neither language detects it: Rust’s guarantees are about references, not about the stack pointer. On the Cortex-M the MPU can be programmed to put a guard region under the stack so that overflow becomes a MemManage fault, which is the only reliable detector. Budget first, paint second, guard if the part allows.
7 · DMA and buffer ownership
DMA is a second bus master that changes memory without executing a single assignment — the compiler cannot see it, and volatile on a completion flag only forces the flag load. The robust design is an ownership protocol:
sequenceDiagram participant CPU as CPU (firmware) participant DMA as DMA engine CPU->>CPU: prepare buffer A CPU->>DMA: publish A (start transfer) Note over CPU,DMA: A is DMA-owned — CPU must not touch it CPU->>CPU: meanwhile: process buffer B DMA-->>CPU: transfer-complete IRQ CPU->>CPU: consume A, swap roles (double buffering)
sequenceDiagram
participant CPU as CPU (firmware)
participant DMA as DMA engine
CPU->>CPU: prepare buffer A
CPU->>DMA: publish A (start transfer)
Note over CPU,DMA: A is DMA-owned — CPU must not touch it
CPU->>CPU: meanwhile: process buffer B
DMA-->>CPU: transfer-complete IRQ
CPU->>CPU: consume A, swap roles (double buffering)
7.1 In C: ownership is a comment
staticuint16_t adc_buf[2][256];// static, .bss, address in the map filestaticvolatileuint8_t dma_owned =0;// which half the DMA is filling: the only thing volatile buys// Rules enforced by discipline: never touch adc_buf[dma_owned]; after the completion IRQ,// insert the ordering point (__DSB() or atomic_thread_fence) before reading the other half.
Rules that survive contact with hardware: never read or write a DMA-owned buffer; do not assume “transfer complete” means every peripheral-side condition is complete; place buffers with §4.3’s tools (alignment, and on parts with several RAM banks, the bank the DMA can reach); and put the ordering point — __DSB(), atomic_thread_fence, or the completion interrupt itself — between “DMA wrote it” and “CPU reads it”. On the STM32L476 there is no data cache to clean or invalidate; on M7-class parts that becomes mandatory and cache-line alignment stops being optional.
7.2 In Rust: ownership is a type
The &'static mut [u8; N] from §5.4 is what a HAL’s DMA API asks for, and the borrow checker enforces the protocol: while the transfer holds the mutable borrow, no other code can obtain one, so “CPU touched a DMA-owned buffer” is a compile error, not a Thursday.
static BUF: StaticCell<[u16;512]>=StaticCell::new();let buf:&'staticmut [u16;512] = BUF.init([0;512]);// embassy-stm32's ADC/DMA read borrows `buf` mutably for the duration of the transfer// (the future holds the borrow); using `buf` before the read completes does not compile.
Two caveats keep this honest. The borrow checker knows nothing about the hardware: if the transfer is started and the future is dropped or the function returns without waiting, the DMA engine keeps writing into memory the type system now considers free — which is why every well-designed async DMA API either completes the transfer in Drop or documents that cancellation is unsafe. And the ordering point is still hardware: the HAL inserts the barrier, but a hand-written unsafe DMA driver must insert it explicitly (cortex_m::asm::dsb()), exactly as in C.
8 · Embedded Linux: the allocator is fine, page faults are not
On the Jetson and the Pi the heap is glibc’s and works; what a real-time loop must avoid is the first touch of any page — heap, stack, or mapped file — after the loop starts, because a minor page fault costs a kernel round trip. The standard recipe, in C:
#include <sys/mman.h>#include <malloc.h>mlockall(MCL_CURRENT | MCL_FUTURE);// lock every present and future page; no swap, no lazy faultsmallopt(M_TRIM_THRESHOLD,-1);// never give heap pages back to the kernelmallopt(M_MMAP_MAX,0);// never satisfy malloc with a fresh mmap (which would fault again)// then: allocate and *touch* every buffer the loop will use, and pre-fault the stack// by calling a function with a large local array once, before the loop starts.
MCL_FUTURE makes later allocations lock-on-allocate, which is why the pre-touch matters: locking a page forces it to exist. Transparent huge pages can add latency spikes when the kernel compacts memory; prctl(PR_SET_THP_DISABLE, 1, 0, 0, 0) opts a process out. The same calls in Rust go through nix:
usenix::sys::mman::{mlockall, MlockAllFlags};mlockall(MlockAllFlags::MCL_CURRENT |MlockAllFlags::MCL_FUTURE)?;letmut frame =vec![0u8;1<<20];// Vec is fine here — allocated and touched *before* the loopframe.iter_mut().for_each(|b|*b =0);// touch every page; the optimizer may elide a plain zero-fill
std::hint::black_box on the buffer after the touch loop is the honest way to keep the compiler from removing it. The measurement is getrusage(RUSAGE_SELF).ru_minflt before and after the loop: a real-time loop that faults during its steady state has a bug, and Exercise 6.7 counts them.
9 · What transfers, what doesn’t
Concern
C
Rust
No heap
Project rule, --wrap=malloc, code review
Crate choice: core without alloc — a Vec is a compile error
Static buffers
static arrays in .bss/.data, map file
static + StaticCell; same sections, same map file
Layout
_Static_assert + offsetof; order by alignment
#[repr(C)] + const asserts + offset_of!; repr(Rust) reorders for you
Packed
packed attribute; address-of is a warning, fault later
repr(packed); address-of is a compile error
Pools
Hand-written free list, union trick
heapless::pool, or hand-written over [MaybeUninit<T>; N] with handles