Part 1 · The C Language39 min read
Memory: Where Everything Lives
On a microcontroller you have, say, 128 KB of RAM and no operating system watching it. There is no virtual memory, no page fault, no “out of memory” dialog. If the stack grows into your global variables, nothing stops it: your data just changes. This lesson shows exactly where the compiler and linker put every kind of variable, how the stack and heap behave on a bare-metal Cortex-M, why firmware engineers mostly refuse to use malloc, and the two data structures that replace it in almost every real product: the fixed-block memory pool and the ring buffer. You will write both.
The firmware memory map
Section titled “The firmware memory map”A compiled C program is split into sections, named blocks of bytes that the linker places at specific addresses. Six regions matter:
| Region | Contains | Lives in | Survives reset? |
|---|---|---|---|
.text | machine code, plus the vector table | flash | yes (it is flash) |
.rodata | const globals, string literals, jump tables | flash | yes |
.data | globals and statics with a non-zero initializer | RAM (initial values stored in flash) | no, re-copied at boot |
.bss | globals and statics that are zero or uninitialized | RAM | no, zeroed at boot |
| heap | memory handed out by malloc | RAM, above .bss | no |
| stack | locals, return addresses, saved registers | RAM, usually at the top | no |
On the STM32F407 the result looks like this:
Flash 0x08000000 SRAM 0x20000000+-------------------------+ 0x08000000 +-------------------------+ 0x20000000| .isr_vector | | .data (initialized) || .text (code) | +-------------------------+| .rodata (const, strings)| | .bss (zeroed) |+-------------------------+ +-------------------------+ <- end of .bss| .data initial values | --- copied at ---> | heap grows up ^ || (load image) | boot | |+-------------------------+ | free || unused | | || | | stack grows down v |+-------------------------+ 0x080FFFFF +-------------------------+ 0x2001FFFF initial SP = 0x20020000Two things in that picture surprise people coming from desktop programming.
First, .data exists twice. The variables live in RAM, because the program writes them, but RAM is blank at power-up. So the linker stores a copy of the initial values in flash, right after .rodata, and the startup code copies them into RAM before main runs. Every byte of initialized global data costs you a byte of flash and a byte of RAM.
Second, .bss costs no flash at all. The linker only records its start and end addresses, and the startup code fills that range with zeros. A 4 KB zero-initialized buffer adds 4 KB to RAM usage and 0 bytes to the flash image.
The copy and the zeroing look like this, simplified from a typical Reset_Handler:
#include <stdint.h>
extern uint32_t _sidata[]; /* start of .data initial values in flash */extern uint32_t _sdata[]; /* start of .data in RAM */extern uint32_t _edata[]; /* end of .data in RAM */extern uint32_t _sbss[]; /* start of .bss */extern uint32_t _ebss[]; /* end of .bss */
int main(void);
void Reset_Handler(void){ const uint32_t *src = _sidata; for (uint32_t *dst = _sdata; dst < _edata; dst++) { *dst = *src++; } for (uint32_t *dst = _sbss; dst < _ebss; dst++) { *dst = 0u; } (void)main(); for (;;) { }}The symbols are not variables you defined in C. The linker script creates them, and declaring them as arrays of unknown size lets you use their addresses without the compiler assuming they are single objects. Until these two loops run, every global in your program holds garbage. That is why nothing that touches globals may run before them. The full story, including the vector table and FPU enable, is in Startup code and the vector table.
Which declaration goes where
Section titled “Which declaration goes where”The rule follows from two questions: “is it a local without static?” and, if not, “is it const, zero, or non-zero?”. Here is a program with ten numbered declarations. Before reading the table below it, write down where you think each one lives.
#include <stdint.h>#include <stdlib.h>
uint32_t boot_count; /* 1 */uint32_t baud = 115200u; /* 2 */static uint8_t rx_buf[256]; /* 3 */const uint16_t crc_seed[4] = {0x0000u, 0x1021u, 0x2042u, 0x3063u}; /* 4 */const char *banner = "firmware v1.2"; /* 5 */char version[] = "1.2.0"; /* 6 */static uint32_t zero_init = 0u; /* 7 */
int main(void){ uint32_t i = 0u; /* 8 */ static uint32_t calls; /* 9 */ uint8_t *dyn = malloc(32u); /* 10 */
calls++; boot_count++; rx_buf[i] = (uint8_t)baud; zero_init += crc_seed[1]; if (dyn != NULL) { dyn[0] = (uint8_t)banner[0]; dyn[1] = (uint8_t)version[0]; free(dyn); } return (int)(calls + zero_init + rx_buf[0]);}| # | Declaration | Section | Why |
|---|---|---|---|
| 1 | uint32_t boot_count; | .bss | file scope, no initializer, so zero |
| 2 | uint32_t baud = 115200u; | .data | non-zero initializer, writable |
| 3 | static uint8_t rx_buf[256]; | .bss | static changes linkage, not placement |
| 4 | const uint16_t crc_seed[4] | .rodata | const object at file scope, stays in flash |
| 5 | const char *banner | pointer in .data, literal in .rodata | the pointer is writable, only the chars are const |
| 6 | char version[] = "1.2.0"; | .data (6 bytes) | a writable array initialized from a literal |
| 7 | static uint32_t zero_init = 0u; | .bss | an explicit zero is the same as no initializer |
| 8 | uint32_t i | register or stack | automatic storage, exists only during the call |
| 9 | static uint32_t calls; | .bss | block scope, but static lifetime |
| 10 | uint8_t *dyn | pointer in register/stack, block on the heap | malloc returns memory from the heap region |
Number 5 deserves a second look. const char *banner is a pointer to const characters, and the pointer itself is an ordinary writable variable, so it costs 4 bytes of RAM and 4 bytes of flash for its initial value. Write const char *const banner and the pointer moves to .rodata too. For a lookup table of strings, that one extra const can save hundreds of bytes of RAM.
You can confirm all of this without guessing. Compile for the target with -fdata-sections and ask nm for symbol types:
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -fdata-sections -c where.c -o where.oarm-none-eabi-nm -S where.o00000000 00000004 D banner00000000 00000004 D baud00000000 00000004 B boot_count00000000 00000004 b calls.000000000 00000008 R crc_seed U free00000000 0000005c T main U malloc00000000 00000100 b rx_buf00000000 00000006 D version00000000 00000004 b zero_initThe letter is the section: T text, R read-only data, D initialized data, B bss. Lowercase means the symbol has internal linkage (static). The local static calls gets a compiler-generated name with a numeric suffix that varies between GCC versions. main’s size depends on the compiler version. The string literal has no symbol at all: it is an anonymous label (.LC0) in .rodata. U means “undefined here, the linker must find it”.
One historical wrinkle: before GCC 10, an uninitialized file-scope variable like number 1 was a “common” symbol (C in nm), and two files that both defined uint32_t boot_count; would silently merge into one variable. GCC 10 made -fno-common the default, so that is now a “multiple definition” link error, which is what you want.
The stack
Section titled “The stack”The Cortex-M stack is full descending: the stack pointer holds the address of the last item pushed, and pushing first decrements sp. At reset the core loads sp from the first word of the vector table, normally 0x20020000 on the F407, the address one past the end of SRAM.
Each function call can claim a frame: space for saved registers, the return address in lr when the function itself calls others, and locals that did not fit in registers. Here is the stack partway through main → parse_line → checksum:
higher addresses 0x20020000 +----------------------------+ initial SP | main's frame | | saved r4, lr | | locals | +----------------------------+ | parse_line's frame | | saved r4-r6, lr | | char line[128] | +----------------------------+ | checksum's frame | | saved r4 | sp ---> +----------------------------+ | | | free (holds old garbage) | v v lower addressesWhen checksum returns, sp moves back up. The bytes it used are not cleared; they simply become “free”. The next call to any function reuses them for its own frame. That is how a local becomes garbage: the memory is still there, still holding the old value for a while, until a later call overwrites it. A pointer to a local that outlives its function points into this churn. It may “work” in a test and fail in the field, which is why GCC warns with -Wreturn-local-addr, and recent GCC versions at -O2 may even replace the returned address with a null pointer so that the bug crashes early. Dangling pointers in general are covered in Pointers II: function pointers, declarations, and bugs.
It also explains the rule from Variables, scope, lifetime, and storage classes that automatic variables start with garbage: the “initial value” is whatever the previous frame left there.
Interrupts use the stack too
Section titled “Interrupts use the stack too”On Cortex-M, when an interrupt fires, the hardware pushes 8 words (32 bytes: r0-r3, r12, lr, pc, xPSR) onto the current stack before running the handler. If the FPU is in use, the frame grows to 26 words (104 bytes). The handler’s own frame comes on top of that, and a higher-priority interrupt can pre-empt it and push another frame. So the worst-case stack depth is:
deepest call chain in main + deepest ISR at each priority level that can nest + one hardware exception frame per nesting levelStack overflow on a microcontroller
Section titled “Stack overflow on a microcontroller”With the layout above, the stack grows down toward the heap and .bss. When it overflows, it does not fault. It overwrites your globals. Typical symptoms:
- A global variable changes value with no code that writes it. A watchpoint shows the write coming from a function that has nothing to do with it.
- The device works until a particular command arrives (the one with the deep call chain or large local buffer), then misbehaves.
- A HardFault with a nonsense
pc, because a savedlrwas overwritten by data and the function “returned” into it. - Failures only in release builds, because inlining changed frame sizes, or only in debug builds, because
-O0frames are much bigger.
If the stack does run past the bottom of SRAM, below 0x20000000, the write hits an address with no RAM behind it and you get a bus fault that escalates to HardFault. That is the good outcome, because it is loud. Some projects deliberately place the stack at the bottom of RAM, below .data and .bss, so that any overflow immediately runs off the end of SRAM and faults. The MPU-based guard region in Memory protection, stack safety, and robustness is the more general fix.
Estimating stack usage
Section titled “Estimating stack usage”You have two tools, and you should use both.
Static analysis with -fstack-usage. GCC writes a .su file next to each object file listing every function’s frame size:
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -fstack-usage -c shell.ccat shell.sushell.c:41:13:parse_line 152 staticshell.c:78:6:shell_task 24 staticshell.c:90:6:shell_print 40 dynamic,boundedstatic means the frame size is fixed. dynamic,bounded means it varies but GCC knows a maximum. dynamic alone means GCC cannot bound it, usually because of a VLA or alloca, and in firmware that is a bug to fix. These numbers are per function. The worst case is the sum along the deepest call path, which you must work out from the call graph (or with a tool that combines .su files with the call graph). Add -Wstack-usage=256 to get a warning for any single function whose frame exceeds 256 bytes. Function pointers and recursion break static analysis, which is one more reason firmware avoids recursion.
Measurement with a high-water mark. Fill the stack with a known pattern at boot (“painting”), run the device through its worst workload, then scan from the bottom to find how much of the pattern survived:
#include <stddef.h>#include <stdint.h>
#define STACK_PAINT 0xA5A5A5A5u
extern uint32_t _sstack[]; /* lowest address of the stack region (linker script) */extern uint32_t _estack[]; /* one past the highest address */
/* Call early in main. Paints everything below the current frame. */void stack_paint(void){ uint32_t here = 0u; /* lives in this frame */ uintptr_t limit = (uintptr_t)&here - 64u; /* stay clear of our own frame */
for (uint32_t *p = _sstack; (uintptr_t)p < limit; p++) { *p = STACK_PAINT; } (void)here;}
/* Bytes of stack that have ever been used since stack_paint(). */size_t stack_high_water(void){ const uint32_t *p = _sstack; while ((uintptr_t)p < (uintptr_t)_estack && *p == STACK_PAINT) { p++; } return (size_t)((uintptr_t)_estack - (uintptr_t)p);}The linker script must define _sstack as the bottom of the region you reserve for the stack (for example _sstack = _estack - 4K;); Linker scripts shows how. Comparisons go through uintptr_t because comparing pointers into unrelated objects with < is undefined in ISO C, even though every embedded compiler does the obvious thing. Report stack_high_water() over the debug UART or read it in the debugger after a soak test, then keep at least 25% margin above the worst value you ever saw. A measurement only covers the paths you exercised; the static analysis covers the paths you forgot.
The heap
Section titled “The heap”malloc hands out memory from a region between the end of .bss and the stack. A typical allocator keeps a free list: a linked list of free chunks, each starting with a small header that records its size.
heap start heap end| hdr | used 24 | hdr | FREE 40 | hdr | used 16 | hdr | FREE 200 | free list ---^-----------------------------------^malloc(n) walks the list looking for a chunk of at least n bytes plus header (first-fit or best-fit), splits it if it is much larger, and returns a pointer just past the header. free(p) steps back to find the header, puts the chunk back on the list, and merges it with free neighbours (coalescing). When no chunk fits, the allocator asks for more space by moving the heap’s end upward through a system call named _sbrk. On bare metal you provide _sbrk, which is covered in The C standard library on a microcontroller.
That machinery has three properties that firmware cannot tolerate.
Fragmentation. Total free memory is not the same as the largest allocatable block. Consider a 1 KB heap:
allocate A(300) B(300) C(300) | A 300 | B 300 | C 300 | free 124 |free B | A 300 | free 300 | C 300 | free 124 |malloc(400) -> NULL, although 424 bytes are freeA device that allocates and frees messages of varying sizes for weeks will slowly fragment its heap until an allocation fails. On a desktop the process restarts and nobody notices. On a pacemaker or a motor controller, that is a field failure after 23 days that no test reproduces.
Nondeterministic time. Walking a free list takes as long as the list is long. Coalescing takes longer. You cannot put a bound on malloc’s execution time, so you cannot call it from code with a deadline, and it is not reentrant, so you cannot call it from an interrupt at all.
Failure handling. Every malloc can return NULL, and every caller must handle it. In practice, error paths that only run when memory is exhausted are the least tested code in the product.
For these reasons, safety standards like MISRA C forbid dynamic allocation outright, and most firmware teams adopt one of these policies:
| Policy | Rule | Used where |
|---|---|---|
| No heap | every object is static or on the stack; link with no heap region | most bare-metal firmware |
| Boot-time only | malloc allowed during init, never after; nothing is ever freed | systems whose configuration is only known at boot |
| Pools | fixed-size blocks from static arrays, O(1), no fragmentation | message buffers, network packets, RTOS objects |
| Full heap | general allocator with monitoring | large systems with an RTOS and lots of RAM, carefully |
Static allocation everywhere
Section titled “Static allocation everywhere”The default answer in firmware is: decide at compile time how many of each thing you need, and declare them.
#include <stdint.h>
#define MAX_SENSORS 4u
typedef struct { uint8_t i2c_addr; uint16_t last_raw; uint32_t error_count;} sensor_t;
static sensor_t sensors[MAX_SENSORS]; /* .bss, 32 bytes, known at link time */This feels wasteful until you notice what you get in return. The linker tells you, before the firmware ever runs, exactly how much RAM is used. If it does not fit, the build fails rather than the device. There is no allocation to fail at runtime, nothing to leak, and nothing to fragment. “What is the maximum number of simultaneous sensors?” becomes a requirement someone has to answer, which is a good thing.
When objects genuinely come and go at runtime, such as messages in flight or connection records, use a pool.
A fixed-block memory pool
Section titled “A fixed-block memory pool”A memory pool manages an array of equal-sized blocks. Because all blocks are the same size, any free block satisfies any request, so fragmentation is impossible, and both allocate and free are a handful of instructions.
The trick that makes it cheap: a free block is not being used for anything, so the free list’s “next” pointer is stored inside the free block itself. The pool needs no extra memory per block.
storage: | blk0 | blk1 | blk2 | blk3 | after init: free_list -> blk0 -> blk1 -> blk2 -> blk3 -> NULLafter alloc, alloc: free_list -> blk2 -> blk3 -> NULL (blk0, blk1 in use)after free(blk0): free_list -> blk0 -> blk2 -> blk3 -> NULL#ifndef POOL_H#define POOL_H
#include <stdbool.h>#include <stddef.h>#include <stdint.h>
typedef struct { uint8_t *base; /* first byte of the block storage */ void *free_list; /* first free block, or NULL when exhausted */ size_t block_size; /* bytes per block, at least sizeof(void *) */ size_t block_count; /* total blocks */ size_t free_count; /* blocks currently free */} pool_t;
/* storage must hold block_count blocks of block_size bytes, suitably aligned * for the objects you will store (declare it as an array of that type). */bool pool_init(pool_t *pool, void *storage, size_t block_size, size_t block_count);void *pool_alloc(pool_t *pool);bool pool_free(pool_t *pool, void *block);size_t pool_free_count(const pool_t *pool);
#endif#include "pool.h"
#include <string.h>
/* The "next" link of a free block lives in the block's first bytes. * memcpy avoids any alignment or aliasing assumption about the storage. */static void *link_get(const void *block){ void *next; memcpy(&next, block, sizeof next); return next;}
static void link_set(void *block, void *next){ memcpy(block, &next, sizeof next);}
bool pool_init(pool_t *pool, void *storage, size_t block_size, size_t block_count){ if (pool == NULL || storage == NULL || block_count == 0u) { return false; } if (block_size < sizeof(void *) || block_count > SIZE_MAX / block_size) { return false; }
pool->base = storage; pool->block_size = block_size; pool->block_count = block_count; pool->free_count = block_count; pool->free_list = NULL;
/* Build the list back to front so the first allocation returns block 0. */ for (size_t i = block_count; i > 0u; i--) { void *block = pool->base + (i - 1u) * block_size; link_set(block, pool->free_list); pool->free_list = block; } return true;}
void *pool_alloc(pool_t *pool){ void *block = pool->free_list; if (block != NULL) { pool->free_list = link_get(block); pool->free_count--; } return block;}
bool pool_free(pool_t *pool, void *block){ if (block == NULL) { return false; }
uintptr_t addr = (uintptr_t)block; uintptr_t start = (uintptr_t)pool->base; size_t total = pool->block_size * pool->block_count;
if (addr < start || addr - start >= total) { return false; /* not from this pool */ } if ((addr - start) % pool->block_size != 0u) { return false; /* points into the middle of a block */ } if (pool->free_count == pool->block_count) { return false; /* nothing is allocated: double free */ }
link_set(block, pool->free_list); pool->free_list = block; pool->free_count++; return true;}
size_t pool_free_count(const pool_t *pool){ return pool->free_count;}The important lines:
link_getandlink_setusememcpyinstead of*(void **)block = next. The cast version assumes the block is aligned for a pointer and accesses the storage through a type it was not declared with, which breaks the strict aliasing rule explained in Undefined behavior,volatile, and what the optimizer assumes. GCC compiles a 4-bytememcpywith a constant size into a singleldrorstr, so the safe version costs nothing.pool_initchecksblock_count > SIZE_MAX / block_sizebefore multiplying. Size arithmetic that overflows silently is a classic source of memory corruption.pool_freevalidates the pointer: it must lie inside the storage and at the start of a block. Freeing a pointer from the wrong pool, orptr + 1, is caught instead of corrupting the list. The last check catches only the most obvious double free; a full check needs a bitmap or a walk of the free list, which is an exercise below.pool_allocandpool_freerun in constant time: no loops, no search.
Using it, with the storage declared as an array of the element type so alignment is automatically correct:
#include <stdint.h>#include <stdio.h>
#include "pool.h"
typedef struct { uint8_t len; uint8_t payload[27]; uint32_t timestamp;} msg_t; /* 32 bytes, 4-byte aligned */
#define MSG_COUNT 8u
static msg_t msg_storage[MSG_COUNT]; /* .bss, 256 bytes */static pool_t msg_pool;
int main(void){ if (!pool_init(&msg_pool, msg_storage, sizeof(msg_t), MSG_COUNT)) { return 1; }
msg_t *a = pool_alloc(&msg_pool); msg_t *b = pool_alloc(&msg_pool); if (a == NULL || b == NULL) { return 1; } a->len = 3u; b->timestamp = 1000u; printf("free after 2 allocs: %zu\n", pool_free_count(&msg_pool));
(void)pool_free(&msg_pool, a); printf("free after 1 free: %zu\n", pool_free_count(&msg_pool)); printf("bad free accepted? %d\n", (int)pool_free(&msg_pool, (uint8_t *)b + 1)); return 0;}free after 2 allocs: 6free after 1 free: 7bad free accepted? 0If an interrupt handler and main both use the same pool, pool_alloc and pool_free become read-modify-write sequences on shared data and must run inside a critical section. Interrupts done right shows how. RTOSes ship exactly this structure as “memory pools” or “block allocators”.
Arenas
Section titled “Arenas”An arena (or bump allocator) is even simpler: a pointer that only moves forward. You allocate variable-sized objects by bumping the pointer, and you free everything at once by resetting it. It fits work that has a clear lifetime, like parsing one received frame or handling one request.
#include <stddef.h>#include <stdint.h>
typedef struct { uint8_t *base; size_t size; size_t used;} arena_t;
/* align must be a power of two */void *arena_alloc(arena_t *a, size_t n, size_t align){ uintptr_t start = (uintptr_t)a->base + a->used; uintptr_t aligned = (start + (align - 1u)) & ~(uintptr_t)(align - 1u); size_t pad = (size_t)(aligned - start);
if (pad > a->size - a->used || n > a->size - a->used - pad) { return NULL; } a->used += pad + n; return (void *)aligned;}
void arena_reset(arena_t *a){ a->used = 0u;}The bounds check is written as two subtractions rather than a->used + pad + n > a->size so that a huge n cannot wrap the sum around and pass the check. Allocation is O(1), there is no per-object header, and fragmentation cannot happen because nothing is freed individually.
A byte ring buffer
Section titled “A byte ring buffer”A ring buffer (circular buffer, FIFO) is the structure you will write most often in firmware. A UART interrupt pushes received bytes in, the main loop pulls them out. Producer and consumer run at different times and speeds, and the buffer absorbs the difference.
The design below uses two free-running indices. head counts every byte ever written, tail counts every byte ever read. They are never reset or wrapped by hand. Because the capacity is a power of two, the array index is just the low bits (head & mask), and the number of bytes stored is head - tail, which unsigned arithmetic gets right even after the counters wrap past SIZE_MAX.
Here is a buffer of capacity 8 (mask 7) after 13 bytes have been written and 10 read. The count is 13 - 10 = 3, the next read comes from slot 10 & 7 = 2 and the next write goes to slot 13 & 7 = 5:
capacity 8, mask 7, head = 13, tail = 10, count = 3
index: 0 1 2 3 4 5 6 7 +---+---+---+---+---+---+---+---+ | | | a | b | c | | | | +---+---+---+---+---+---+---+---+ ^ ^ tail&7 = 2 head&7 = 5 (next write)The classic alternative keeps both indices in the range 0 to N-1 and cannot distinguish “full” from “empty” when they are equal, so it sacrifices one slot. Free-running indices avoid that: empty is head == tail, full is head - tail == capacity, and every slot is usable.
#ifndef RINGBUF_H#define RINGBUF_H
#include <stdbool.h>#include <stddef.h>#include <stdint.h>
typedef struct { uint8_t *buf; /* caller-provided storage */ size_t mask; /* capacity - 1, capacity is a power of two */ size_t head; /* total bytes ever written (free-running) */ size_t tail; /* total bytes ever read (free-running) */} ringbuf_t;
bool rb_init(ringbuf_t *rb, uint8_t *storage, size_t capacity);bool rb_put(ringbuf_t *rb, uint8_t byte);bool rb_get(ringbuf_t *rb, uint8_t *out);bool rb_peek(const ringbuf_t *rb, uint8_t *out);size_t rb_count(const ringbuf_t *rb);size_t rb_capacity(const ringbuf_t *rb);bool rb_is_empty(const ringbuf_t *rb);bool rb_is_full(const ringbuf_t *rb);size_t rb_write(ringbuf_t *rb, const uint8_t *src, size_t len);size_t rb_read(ringbuf_t *rb, uint8_t *dst, size_t len);
#endif#include "ringbuf.h"
bool rb_init(ringbuf_t *rb, uint8_t *storage, size_t capacity){ if (rb == NULL || storage == NULL) { return false; } if (capacity == 0u || (capacity & (capacity - 1u)) != 0u) { return false; /* must be a power of two */ } rb->buf = storage; rb->mask = capacity - 1u; rb->head = 0u; rb->tail = 0u; return true;}
size_t rb_count(const ringbuf_t *rb){ return rb->head - rb->tail; /* correct across wraparound */}
size_t rb_capacity(const ringbuf_t *rb){ return rb->mask + 1u;}
bool rb_is_empty(const ringbuf_t *rb){ return rb->head == rb->tail;}
bool rb_is_full(const ringbuf_t *rb){ return rb_count(rb) == rb_capacity(rb);}
bool rb_put(ringbuf_t *rb, uint8_t byte){ if (rb_is_full(rb)) { return false; /* reject newest; caller counts drops */ } rb->buf[rb->head & rb->mask] = byte; rb->head++; return true;}
bool rb_get(ringbuf_t *rb, uint8_t *out){ if (rb_is_empty(rb)) { return false; } *out = rb->buf[rb->tail & rb->mask]; rb->tail++; return true;}
bool rb_peek(const ringbuf_t *rb, uint8_t *out){ if (rb_is_empty(rb)) { return false; } *out = rb->buf[rb->tail & rb->mask]; return true;}
size_t rb_write(ringbuf_t *rb, const uint8_t *src, size_t len){ size_t n = 0u; while (n < len && rb_put(rb, src[n])) { n++; } return n;}
size_t rb_read(ringbuf_t *rb, uint8_t *dst, size_t len){ size_t n = 0u; while (n < len && rb_get(rb, &dst[n])) { n++; } return n;}Points worth noticing:
rb_initrejects capacities that are not powers of two with the test(capacity & (capacity - 1u)) != 0u, from Bit manipulation. The mask trick silently breaks with any other size, so the check is not optional.- The masking happens only when indexing the array.
headandtailthemselves just increment. For the “count” arithmetic to stay correct after wraparound, the capacity must divideSIZE_MAX + 1, which every power of two up to that size does. - When full,
rb_putrejects the new byte. The other policy, overwriting the oldest byte, is right for “latest data wins” streams like a log you only inspect after a crash. Choose deliberately and document it. A real UART driver also counts dropped bytes so you can see overruns. - The storage is passed in, so one implementation serves a 64-byte RX buffer and a 1 KB TX buffer.
Usage:
#include <stdint.h>#include <stdio.h>
#include "ringbuf.h"
static uint8_t rx_storage[16];static ringbuf_t rx;
int main(void){ if (!rb_init(&rx, rx_storage, sizeof rx_storage)) { return 1; } const uint8_t msg[] = "hello"; size_t written = rb_write(&rx, msg, 5u);
uint8_t c; while (rb_get(&rx, &c)) { putchar(c); } printf("\nwritten=%zu count=%zu\n", written, rb_count(&rx)); return 0;}hellowritten=5 count=0Alignment and _Alignas
Section titled “Alignment and _Alignas”Every type has an alignment: its address must be a multiple of that number. On Cortex-M4, uint16_t has alignment 2, uint32_t, float and pointers have 4, and uint64_t and double have 8. The compiler handles alignment for anything you declare with its real type. You need to think about it in three cases:
- Raw byte buffers that hold other types. A
uint8_t buf[64]has alignment 1. If you later treat part of it as auint32_t, the access may be unaligned. Cortex-M4 tolerates unaligned singleldr/str(slowly), butldrd,ldm/stmand the FPU’svldrfault on unaligned addresses, and Cortex-M0 faults on all of them. Pointers I: addresses, arithmetic, andconstcovers the casting trap. - Hardware requirements. DMA controllers, USB peripherals and caches often require buffers aligned to 4, 32 or more bytes, beyond what the C type needs.
- Your own allocators. A pool or arena carving objects out of bytes must hand out suitably aligned addresses.
C11’s _Alignas (spelled alignas with stdalign.h) raises the alignment of a declaration, and _Alignof queries it:
#include <stdalign.h>#include <stddef.h>#include <stdint.h>
/* Byte buffer usable for any object type: alignment of max_align_t (8 on Arm). */static _Alignas(max_align_t) uint8_t arena_storage[1024];
/* Buffer aligned to a 32-byte cache line (needed on Cortex-M7 for DMA). */static alignas(32) uint8_t dma_rx[256];
_Static_assert(_Alignof(uint32_t) == 4, "unexpected uint32_t alignment");_Alignas can only increase alignment, never decrease it, and the value must be a power of two. The stack has an alignment rule of its own: the AAPCS requires sp to be 8-byte aligned at every public function call, which is why the initial stack pointer and _estack are always multiples of 8.
memcpy, memmove, memset, memcmp
Section titled “memcpy, memmove, memset, memcmp”These four functions from string.h are the primitives of memory manipulation, and GCC treats them as built-ins: a small constant-size memcpy becomes a couple of ldr/str instructions with no call at all.
| Function | Does | Overlap allowed? | Watch out for |
|---|---|---|---|
memcpy(dst, src, n) | copies n bytes | no, undefined if regions overlap | use memmove for shifting within one buffer |
memmove(dst, src, n) | copies n bytes as if through a temporary | yes | slightly slower: chooses copy direction |
memset(p, c, n) | sets n bytes to (unsigned char)c | n/a | sets bytes, not elements |
memcmp(a, b, n) | compares bytes, returns negative, zero or positive | n/a | compares padding bytes too |
The overlap rule is the one that bites. Removing the first byte of a buffer by shifting the rest down:
#include <stdint.h>#include <string.h>
void drop_first(uint8_t *buf, size_t len){ if (len == 0u) { return; /* len - 1 would wrap to SIZE_MAX */ } /* memcpy(buf, buf + 1, len - 1); undefined: regions overlap */ memmove(buf, buf + 1, len - 1u);}With memcpy this may work today with a byte-by-byte forward copy and break when the library switches to a word-at-a-time or backward copy. memmove is defined to work.
memset writes the same byte everywhere. memset(arr, 1, sizeof arr) on a uint32_t array gives every element 0x01010101, not 1. It is fine for zero and for byte arrays. Note also that all-bits-zero is a null pointer and 0.0f on every platform you will use, but ISO C does not promise it.
memcmp on structs compares padding bytes, which hold indeterminate values unless you zeroed the whole struct first; Structs, unions, enums, and memory layout shows the trap. And all four functions have undefined behavior when passed a null pointer, even with n == 0, which the optimizer exploits as described in the undefined behavior lesson.
Measuring RAM usage
Section titled “Measuring RAM usage”You should know your RAM budget at all times, and the toolchain tells you for free. Start with size:
arm-none-eabi-size firmware.elf text data bss dec hex filename 14212 124 5672 20008 4e28 firmware.elfRead it as:
- Flash used =
text+data= 14,336 bytes. (texthere includes.rodataand the vector table;datacounts because its initial values are stored in flash.) - Static RAM used =
data+bss= 5,796 bytes. If your linker script reserves the stack and heap as sections, they are included inbss; if not, add them yourself.
Ask the linker to print usage per memory region on every build with -Wl,--print-memory-usage:
Memory region Used Size Region Size %age Used RAM: 5796 B 128 KB 4.42% CCMRAM: 0 GB 64 KB 0.00% FLASH: 14336 B 1 MB 1.37%When the number jumps, find out who is responsible. nm sorted by size lists the biggest symbols:
arm-none-eabi-nm -S --size-sort -r firmware.elf | head200001c0 00001000 b log_buffer20001200 00000400 b uart_tx_storage08003a10 00000400 r crc32_table...Addresses starting with 0x2000 are RAM, 0x0800 are flash. The map file (-Wl,-Map=firmware.map) goes further and shows every input section from every object file and library with its address and size, so you can see that 3 KB of your .bss came from inside the C library. Linker scripts walks through reading it.
Common mistakes
Section titled “Common mistakes”- Initialized global where zero would do.
uint8_t buf[1024] = {1};puts 1 KB in.dataand 1 KB in flash. Initialize at runtime or leave it zero. consttable inside a function withoutstatic. It may be rebuilt on the stack every call. Fix:static const.const char *table[]of strings. The pointer array lands in RAM. Fix:const char *const table[].- Large local buffers.
char line[512]in a function called from an ISR blows the stack. Fix: make itstatic(accepting non-reentrancy) or move it to a module-level buffer. - Never measuring the stack. Fix: build with
-fstack-usage, paint the stack, read the high-water mark after a soak test. - Using
mallocafter init. Fix: static allocation, or a fixed-block pool sized at compile time. - Ring buffer with non-power-of-two capacity and mask indexing. Fix: check in
rb_init, or use modulo. memcpyon overlapping regions. Fix:memmove.- Assuming
.bssis zero before startup runs. Code called from the reset handler before the zeroing loop sees garbage. Fix: do nothing with globals before the C runtime init.
Exercises
Section titled “Exercises”- Compile
where.cfrom this lesson for the target with-O2 -fdata-sections -cand runarm-none-eabi-nm -Sandarm-none-eabi-objdump -hon the object file. Confirm each of the ten variables against the table. Then changebannertoconst char *constand show that it moves fromDtoR. - Build the ring buffer on your host and write a
mainthat pushes 20 bytes into a 16-byte buffer. Expectrb_writeto return 16, and expect reading back to yield the first 16 bytes in order. Then setheadandtailboth toSIZE_MAX - 2before the test and confirm that it still passes across the wraparound. - Add double-free detection to the pool: keep a bitmap of allocated blocks (one bit per block, sized for at most 32 blocks), set the bit in
pool_alloc, and inpool_freereject a block whose bit is already clear. Write a test that frees the same block twice and expectsfalsethe second time. - Add
stack_paint()andstack_high_water()to a target project (Renode or board). Call a function with a 400-byte local array frommain, print the high-water mark before and after the call, and explain the difference. Compare it to the number in that function’s.sufile. - Take any firmware ELF you have built and account for every byte of RAM: run
arm-none-eabi-size -A firmware.elf, then use the map file to attribute the largest.bssand.datacontributions to source files or libraries. Reduce RAM by at least 10% without changing behavior and explain each change.
Check yourself
Section titled “Check yourself”Question 1:A file contains uint32_t table[256] = {0}; at file scope. How much flash does table cost, and where does it live at runtime?
An all-zero initializer is the same as no initializer for a static-duration object, so it goes in .bss, which the startup code zeroes. Only .data needs initial values stored in flash.
Question 2:Which declaration puts both the pointer and the characters in flash?
The literal itself is in .rodata in every pointer case; what decides the pointer's placement is the const after the *. Without it the pointer is a writable variable in .data, and static char msg[] is a writable copy of the characters in .data.
Question 3:A free-running ring buffer has capacity 16, head = 3 and tail = SIZE_MAX - 4 after the counters wrapped. What does head - tail return?
Unsigned arithmetic wraps modulo SIZE_MAX + 1, so 3 - (SIZE_MAX - 4) equals 8: five values up to the wrap plus three after it. Unsigned overflow is well defined, which is why the indices are unsigned.
Question 4:A device runs for three weeks, then a malloc(256) returns NULL even though a heap dump shows 2 KB free. What is the most likely cause?
Free memory scattered across many small chunks cannot satisfy a larger request. Fixed-block pools avoid this because every block is the same size.
Question 5:Why does pool_alloc in this lesson read the next-free pointer with memcpy instead of *(void **)block?
Accessing storage through void ** assumes pointer alignment and violates strict aliasing when the storage was declared with another type. A fixed-size memcpy is a built-in that GCC turns into one ldr, so the safe version is free.
Key takeaways
Section titled “Key takeaways”.textand.rodatalive in flash;.datalives in RAM and has its initial values in flash;.bsslives in RAM and costs no flash. Startup copies.dataand zeroes.bssbeforemain.- The Cortex-M stack is full descending and is shared by interrupts, which push 32 bytes (104 with FPU state) per nesting level. Overflow silently corrupts whatever lies below it.
- Measure stack usage two ways:
-fstack-usagefor per-function frames along the worst call path, and stack painting for a measured high-water mark. - General-purpose
mallocfragments, has unbounded execution time and fails at runtime; firmware uses static allocation, boot-time allocation, fixed-block pools or arenas instead. - A power-of-two ring buffer with free-running unsigned indices uses every slot, needs no modulo, and computes its count as
head - tailcorrectly across wraparound. memcpywith overlapping regions is undefined; usememmove.memsetsets bytes, not elements.arm-none-eabi-size,--print-memory-usage,nm --size-sortand the map file tell you exactly where every byte went. Check them on every build.
Next: Organizing a real C codebase, where these modules get proper headers, private state and a directory layout.