Chapter 6: Memory Management Without an OS
If you come from Python, JavaScript, or even desktop C++, you are used to creating objects, arrays, and strings without thinking about where they live. The runtime allocates memory, the garbage collector reclaims it, and you never see the details. Embedded systems remove this comfort. With kilobytes of RAM and no OS to manage it, memory becomes a design constraint you must consciously manage. This chapter explains why malloc is often banned and what to use instead.
The Embedded Memory Landscape
A typical ARM Cortex-M4 microcontroller (STM32F407) has:
- Flash: 1 MB — stores code, constants, and initial values for variables
- RAM: 192 KB — stores variables, stack, and heap (if used)
Compare this to a desktop system with 16 GB of RAM and virtual memory. The embedded system has approximately 0.001% of the RAM. Every byte matters.
Your RAM budget must cover:
┌─────────────────────────────────┐
│ .data (initialized) │ Global/static with initial values
├─────────────────────────────────┤
│ .bss (zeroed) │ Global/static zero-initialized
├─────────────────────────────────┤
│ Heap (if used) │ malloc/free allocations
├─────────────────────────────────┤
│ ↓ │ Grows downward
│ │
│ Free space │
│ │
│ ↑ │ Grows upward
│ Stack │ Local variables, return addresses
├─────────────────────────────────┤
│ Top of RAM (stack pointer) │
└─────────────────────────────────┘
The stack and heap share the same free space. If they collide, the system corrupts itself silently. This is why stack overflow and heap exhaustion are critical concerns in embedded systems.
The Case Against malloc
Many embedded coding standards (MISRA-C, JPL, NASA) ban dynamic allocation entirely. The reasons are practical:
1. Memory Fragmentation
malloc and free create holes of varying sizes. Over time, memory becomes fragmented: there is enough total free space, but no single contiguous block large enough for a new allocation.
// Hypothetical heap state after many alloc/free cycles
// [Allocated 64B][Free 16B][Allocated 128B][Free 8B][Allocated 32B][Free 64B]
// Total free: 88 bytes, but largest contiguous block: 64 bytes
// A request for 80 bytes fails, even though 88 are free
Embedded systems often run for months or years without reboot. Fragmentation accumulates over time, eventually causing allocation failures at unpredictable moments.
2. Non-Deterministic Timing
malloc searches for a suitable free block. The time this takes depends on heap state. In a real-time system, a 100-microsecond allocation might occasionally take 10 milliseconds because the allocator must coalesce fragments. This unpredictability is unacceptable in control systems.
3. Allocation Failure
On desktop, malloc returning NULL is rare (the OS overcommits or the process dies). In embedded, it is common. Every allocation must handle failure:
char *buffer = malloc(1024);
if (buffer == NULL) {
// What now? No OS to kill the process. No recovery mechanism.
// System continues running with a null pointer.
}
Most embedded code does not handle allocation failure gracefully. The alternative—static allocation—makes failure impossible: the memory exists or the code does not link.
4. Memory Overhead
malloc implementations store metadata (block size, next/prev pointers) for each allocation. A 4-byte allocation might consume 16 bytes of actual heap. On a system with 16 KB of RAM, this overhead is significant.
Misconception: "I need
mallocfor variable-length data. Static buffers are too rigid."Reality: You design for worst-case sizes at compile time. If a protocol message can be at most 256 bytes, you allocate a 256-byte buffer. The buffer is always there, always ready, and never fails. The rigidity is a feature, not a limitation.
Static Allocation Patterns
Fixed-Size Buffers
The most common pattern: declare buffers at file scope with known maximum sizes.
// uart.c
static uint8_t rx_buffer[256]; // Ring buffer for incoming data
static uint8_t tx_buffer[128]; // Ring buffer for outgoing data
// protocol.c
static uint8_t packet_buffer[512]; // One packet at a time
static uint16_t packet_length;
If the actual data is smaller, you waste some RAM. This is a trade-off: predictability over efficiency. For most embedded applications, the waste is acceptable.
Compile-Time Configuration
Buffer sizes are often configurable via macros:
// config.h
#define UART_RX_BUFFER_SIZE 256
#define UART_TX_BUFFER_SIZE 128
#define PACKET_BUFFER_SIZE 512
// uart.c
#include "config.h"
static uint8_t rx_buffer[UART_RX_BUFFER_SIZE];
Changing a buffer size is a one-line edit and rebuild. The linker ensures everything still fits.
Unions for Overlapping Buffers
When multiple buffers are never used simultaneously, a union saves RAM:
union shared_buffer {
uint8_t uart_rx[256];
uint8_t spi_tx[256];
uint8_t packet_data[512];
};
static union shared_buffer shared;
The union is as large as its largest member. The programmer must ensure the buffers are not used concurrently. This is powerful but dangerous if misused.
Memory Pools
When you need dynamic allocation with static guarantees, use memory pools. A pool is a fixed collection of fixed-size blocks. Allocation is O(1) and cannot fragment.
// pool.h
typedef struct {
void *blocks;
size_t block_size;
size_t block_count;
uint8_t *free_map; // Bitmap: 1 = free, 0 = allocated
} pool_t;
void pool_init(pool_t *pool, void *memory, size_t block_size, size_t block_count);
void *pool_alloc(pool_t *pool);
void pool_free(pool_t *pool, void *block);
// pool.c
void pool_init(pool_t *pool, void *memory, size_t block_size, size_t block_count) {
pool->blocks = memory;
pool->block_size = block_size;
pool->block_count = block_count;
pool->free_map = (uint8_t *)calloc((block_count + 7) / 8, 1);
// Mark all blocks free (1 = free)
memset(pool->free_map, 0xFF, (block_count + 7) / 8);
}
void *pool_alloc(pool_t *pool) {
for (size_t i = 0; i < pool->block_count; i++) {
if (pool->free_map[i / 8] & (1 << (i % 8))) {
pool->free_map[i / 8] &= ~(1 << (i % 8));
return (uint8_t *)pool->blocks + (i * pool->block_size);
}
}
return NULL; // Pool exhausted
}
void pool_free(pool_t *pool, void *block) {
size_t index = ((uint8_t *)block - (uint8_t *)pool->blocks) / pool->block_size;
pool->free_map[index / 8] |= (1 << (index % 8));
}
Usage:
// Allocate a pool for 10 packet buffers of 512 bytes each
static uint8_t packet_memory[10 * 512];
static uint8_t packet_free_map[(10 + 7) / 8];
static pool_t packet_pool;
void init_packet_pool(void) {
packet_pool.blocks = packet_memory;
packet_pool.block_size = 512;
packet_pool.block_count = 10;
packet_pool.free_map = packet_free_map;
memset(packet_free_map, 0xFF, sizeof(packet_free_map));
}
// Allocate a packet
uint8_t *packet = pool_alloc(&packet_pool);
if (packet) {
// Use packet...
pool_free(&packet_pool, packet);
}
Memory pools provide deterministic timing (O(1) or O(n) for small n) and no fragmentation. The cost is fixed block size: a 513-byte packet does not fit in a 512-byte pool.
Ring Buffers for Streaming Data
Ring buffers (also called circular buffers) are the standard pattern for ISR-to-main communication and streaming data. They are static, lock-free for single-producer/single-consumer scenarios, and provide predictable performance.
// ring_buffer.h
typedef struct {
uint8_t *buffer;
uint16_t size;
volatile uint16_t head; // ISR writes
volatile uint16_t tail; // Main loop reads
} ring_buffer_t;
void ring_buffer_init(ring_buffer_t *rb, uint8_t *buffer, uint16_t size);
bool ring_buffer_push(ring_buffer_t *rb, uint8_t data);
bool ring_buffer_pop(ring_buffer_t *rb, uint8_t *data);
uint16_t ring_buffer_available(ring_buffer_t *rb);
uint16_t ring_buffer_free(ring_buffer_t *rb);
// ring_buffer.c
void ring_buffer_init(ring_buffer_t *rb, uint8_t *buffer, uint16_t size) {
rb->buffer = buffer;
rb->size = size;
rb->head = 0;
rb->tail = 0;
}
bool ring_buffer_push(ring_buffer_t *rb, uint8_t data) {
uint16_t next_head = (rb->head + 1) % rb->size;
if (next_head == rb->tail) {
return false; // Buffer full
}
rb->buffer[rb->head] = data;
rb->head = next_head;
return true;
}
bool ring_buffer_pop(ring_buffer_t *rb, uint8_t *data) {
if (rb->head == rb->tail) {
return false; // Buffer empty
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % rb->size;
return true;
}
uint16_t ring_buffer_available(ring_buffer_t *rb) {
return (rb->head - rb->tail + rb->size) % rb->size;
}
The single-producer/single-consumer pattern works because:
- Only the ISR writes
head - Only the main loop writes
tail - Each reads the other's variable without modification
No locks, no critical sections, no races—as long as the pattern is respected.
The Stack: Your Other Memory
The stack holds local variables and return addresses. Its size is determined by the linker script and startup code.
void function_a(void) {
uint8_t buffer[1024]; // 1 KB on the stack
// ...
}
void function_b(void) {
uint8_t buffer[2048]; // 2 KB on the stack
function_a(); // Additional 1 KB
// Total: 3 KB + overhead
}
Deep call chains with large local buffers can exhaust the stack. The stack then overflows into .data or .bss, silently corrupting variables.
Measuring Stack Usage
Tools like arm-none-eabi-gcc -fstack-usage generate per-function stack usage reports. Linkers can also estimate maximum stack depth. For critical systems, developers fill the stack area with a known pattern (0xDEADBEEF) and check how much was overwritten at runtime:
// In startup code or main, before any deep calls
extern uint32_t _stack_start;
extern uint32_t _stack_end;
void fill_stack_pattern(void) {
uint32_t *p = &_stack_start;
while (p < &_stack_end) {
*p++ = 0xDEADBEEF;
}
}
uint32_t measure_stack_used(void) {
uint32_t *p = &_stack_start;
while (*p == 0xDEADBEEF && p < &_stack_end) {
p++;
}
return (uint32_t)(&_stack_end - p) * 4;
}
Avoiding Large Stack Allocations
Large buffers should be static, not stack-local:
// BAD: 2 KB on stack, possible overflow
void process_packet(void) {
uint8_t buffer[2048];
// ...
}
// GOOD: Static allocation, 2 KB in .bss
static uint8_t packet_buffer[2048];
void process_packet(void) {
// Use packet_buffer
// ...
}
Static buffers persist across function calls (saving initialization overhead), but they are not reentrant. If process_packet can be called from multiple contexts, use separate buffers or careful synchronization.
What to Actually Learn vs. Look Up
Look up (as needed):
- Specific heap allocator implementations (
mallocinternals) - Linker script syntax for stack/heap placement
- Vendor-specific memory protection features (MPU)
Deeply understand:
- The memory map: where
.data,.bss, stack, and heap live - Why fragmentation and non-determinism make
mallocdangerous - Static allocation patterns: fixed buffers, compile-time configuration
- Memory pools: deterministic allocation for fixed-size objects
- Ring buffers: lock-free streaming data between ISR and main loop
- Stack usage and how to detect overflow
Key Takeaways
- RAM is scarce. A typical embedded system has kilobytes, not gigabytes.
mallocis often banned. Fragmentation, non-deterministic timing, and allocation failure make it unsuitable.- Static allocation is the default. Fixed buffers sized for worst case at compile time.
- Memory pools provide dynamic allocation with static guarantees. O(1) allocation, no fragmentation, fixed block sizes.
- Ring buffers are the standard for streaming data. Lock-free single-producer/single-consumer communication.
- The stack is memory too. Large locals cause overflow; use static buffers for large allocations.
Chapter 7 will explore how C is used to emulate object-oriented patterns, creating modular and testable firmware without native OOP support.