Skip to content

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.

A compiled C program is split into sections, named blocks of bytes that the linker places at specific addresses. Six regions matter:

RegionContainsLives inSurvives reset?
.textmachine code, plus the vector tableflashyes (it is flash)
.rodataconst globals, string literals, jump tablesflashyes
.dataglobals and statics with a non-zero initializerRAM (initial values stored in flash)no, re-copied at boot
.bssglobals and statics that are zero or uninitializedRAMno, zeroed at boot
heapmemory handed out by mallocRAM, above .bssno
stacklocals, return addresses, saved registersRAM, usually at the topno

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 = 0x20020000

Two 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.

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.

where.c
#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]);
}
#DeclarationSectionWhy
1uint32_t boot_count;.bssfile scope, no initializer, so zero
2uint32_t baud = 115200u;.datanon-zero initializer, writable
3static uint8_t rx_buf[256];.bssstatic changes linkage, not placement
4const uint16_t crc_seed[4].rodataconst object at file scope, stays in flash
5const char *bannerpointer in .data, literal in .rodatathe pointer is writable, only the chars are const
6char version[] = "1.2.0";.data (6 bytes)a writable array initialized from a literal
7static uint32_t zero_init = 0u;.bssan explicit zero is the same as no initializer
8uint32_t iregister or stackautomatic storage, exists only during the call
9static uint32_t calls;.bssblock scope, but static lifetime
10uint8_t *dynpointer in register/stack, block on the heapmalloc 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:

Terminal window
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -fdata-sections -c where.c -o where.o
arm-none-eabi-nm -S where.o
00000000 00000004 D banner
00000000 00000004 D baud
00000000 00000004 B boot_count
00000000 00000004 b calls.0
00000000 00000008 R crc_seed
U free
00000000 0000005c T main
U malloc
00000000 00000100 b rx_buf
00000000 00000006 D version
00000000 00000004 b zero_init

The 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 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 addresses

When 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.

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 level

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 saved lr was 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 -O0 frames 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.

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:

Terminal window
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -O2 -fstack-usage -c shell.c
cat shell.su
shell.c:41:13:parse_line 152 static
shell.c:78:6:shell_task 24 static
shell.c:90:6:shell_print 40 dynamic,bounded

static 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:

stack_watermark.c
#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.

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 free

A 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:

PolicyRuleUsed where
No heapevery object is static or on the stack; link with no heap regionmost bare-metal firmware
Boot-time onlymalloc allowed during init, never after; nothing is ever freedsystems whose configuration is only known at boot
Poolsfixed-size blocks from static arrays, O(1), no fragmentationmessage buffers, network packets, RTOS objects
Full heapgeneral allocator with monitoringlarge systems with an RTOS and lots of RAM, carefully

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 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 -> NULL
after alloc, alloc: free_list -> blk2 -> blk3 -> NULL (blk0, blk1 in use)
after free(blk0): free_list -> blk0 -> blk2 -> blk3 -> NULL
pool.h
#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
pool.c
#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_get and link_set use memcpy instead 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-byte memcpy with a constant size into a single ldr or str, so the safe version costs nothing.
  • pool_init checks block_count > SIZE_MAX / block_size before multiplying. Size arithmetic that overflows silently is a classic source of memory corruption.
  • pool_free validates the pointer: it must lie inside the storage and at the start of a block. Freeing a pointer from the wrong pool, or ptr + 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_alloc and pool_free run 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:

main.c
#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: 6
free after 1 free: 7
bad free accepted? 0

If 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”.

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 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.

ringbuf.h
#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
ringbuf.c
#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_init rejects 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. head and tail themselves just increment. For the “count” arithmetic to stay correct after wraparound, the capacity must divide SIZE_MAX + 1, which every power of two up to that size does.
  • When full, rb_put rejects 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;
}
hello
written=5 count=0

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:

  1. Raw byte buffers that hold other types. A uint8_t buf[64] has alignment 1. If you later treat part of it as a uint32_t, the access may be unaligned. Cortex-M4 tolerates unaligned single ldr/str (slowly), but ldrd, ldm/stm and the FPU’s vldr fault on unaligned addresses, and Cortex-M0 faults on all of them. Pointers I: addresses, arithmetic, and const covers the casting trap.
  2. Hardware requirements. DMA controllers, USB peripherals and caches often require buffers aligned to 4, 32 or more bytes, beyond what the C type needs.
  3. 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.

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.

FunctionDoesOverlap allowed?Watch out for
memcpy(dst, src, n)copies n bytesno, undefined if regions overlapuse memmove for shifting within one buffer
memmove(dst, src, n)copies n bytes as if through a temporaryyesslightly slower: chooses copy direction
memset(p, c, n)sets n bytes to (unsigned char)cn/asets bytes, not elements
memcmp(a, b, n)compares bytes, returns negative, zero or positiven/acompares 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.

You should know your RAM budget at all times, and the toolchain tells you for free. Start with size:

Terminal window
arm-none-eabi-size firmware.elf
text data bss dec hex filename
14212 124 5672 20008 4e28 firmware.elf

Read it as:

  • Flash used = text + data = 14,336 bytes. (text here includes .rodata and the vector table; data counts 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 in bss; 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:

Terminal window
arm-none-eabi-nm -S --size-sort -r firmware.elf | head
200001c0 00001000 b log_buffer
20001200 00000400 b uart_tx_storage
08003a10 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.

  • Initialized global where zero would do. uint8_t buf[1024] = {1}; puts 1 KB in .data and 1 KB in flash. Initialize at runtime or leave it zero.
  • const table inside a function without static. 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 it static (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 malloc after 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.
  • memcpy on overlapping regions. Fix: memmove.
  • Assuming .bss is 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.
  1. Compile where.c from this lesson for the target with -O2 -fdata-sections -c and run arm-none-eabi-nm -S and arm-none-eabi-objdump -h on the object file. Confirm each of the ten variables against the table. Then change banner to const char *const and show that it moves from D to R.
  2. Build the ring buffer on your host and write a main that pushes 20 bytes into a 16-byte buffer. Expect rb_write to return 16, and expect reading back to yield the first 16 bytes in order. Then set head and tail both to SIZE_MAX - 2 before the test and confirm that it still passes across the wraparound.
  3. 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 in pool_free reject a block whose bit is already clear. Write a test that frees the same block twice and expects false the second time.
  4. Add stack_paint() and stack_high_water() to a target project (Renode or board). Call a function with a 400-byte local array from main, print the high-water mark before and after the call, and explain the difference. Compare it to the number in that function’s .su file.
  5. 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 .bss and .data contributions to source files or libraries. Reduce RAM by at least 10% without changing behavior and explain each change.

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?

Question 2:Which declaration puts both the pointer and the characters in flash?

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?

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?

Question 5:Why does pool_alloc in this lesson read the next-free pointer with memcpy instead of *(void **)block?

  • .text and .rodata live in flash; .data lives in RAM and has its initial values in flash; .bss lives in RAM and costs no flash. Startup copies .data and zeroes .bss before main.
  • 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-usage for per-function frames along the worst call path, and stack painting for a measured high-water mark.
  • General-purpose malloc fragments, 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 - tail correctly across wraparound.
  • memcpy with overlapping regions is undefined; use memmove. memset sets bytes, not elements.
  • arm-none-eabi-size, --print-memory-usage, nm --size-sort and 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.