No history yet

Advanced C Memory

C Memory and the Bare Metal

When you write a C program for a desktop computer, the operating system handles the messy details of memory. But in embedded systems, you're often working directly on the hardware. Understanding how your program's memory is laid out isn't just academic; it's essential for writing code that works correctly and reliably.

A compiled C program organizes memory into several distinct segments. Each has a specific purpose and ends up in a particular type of physical memory on a microcontroller.

SegmentContentsStored In
TextYour compiled program code (machine instructions).Flash/ROM (read-only, non-volatile)
DataGlobal and static variables that have an initial value.RAM (copied from Flash at startup)
BSSGlobal and static variables that are uninitialized (or initialized to zero).RAM (zeroed out at startup)
HeapDynamically allocated memory (using malloc).RAM
StackLocal variables, function parameters, and return addresses.RAM

When your microcontroller powers on, the bootloader copies the Data segment from Flash into RAM and clears the BSS segment. The Text segment stays in Flash, and the processor fetches instructions directly from there. The Stack and Heap are areas of RAM available for your program to use as it runs.

Mastering Pointers for Hardware

Pointers in C are not just for complex data structures. In embedded programming, they are your primary tool for interacting with the hardware. Since hardware peripherals often have their controls and data registers at fixed memory addresses, you can use a pointer to read from or write to a peripheral as if it were a simple variable.

Pointer arithmetic becomes especially powerful. If you know a peripheral has a block of 10 consecutive registers starting at a specific address, you can point to the first one and simply increment the pointer to access the others. This is far more efficient than defining ten separate variables.

// A generic function to write data to any peripheral
// 'reg' is the address of the hardware register
// 'data' is the value to write
void write_register(void* reg, uint32_t data) {
    // Cast the void pointer to a specific pointer type
    // and then dereference it to write the data.
    *( (volatile uint32_t*)reg ) = data;
}

// Usage:
#define TIMER1_CTRL_REG 0x40010000

write_register((void*)TIMER1_CTRL_REG, 0x01); // Enable Timer 1

The void* pointer, or generic pointer, is a key tool for writing reusable drivers. A driver for a communication protocol like I2C shouldn't care if it's sending temperature data (float) or a control command (uint8_t). By accepting a void* to the data, the function can work with any data type, casting it to the correct type only when needed.

A step beyond pointing to data is pointing to code. store the memory address of a function. This is the foundation of event-driven programming in embedded systems. Instead of constantly checking if a button has been pressed (polling), you can tell the hardware, "When this button is pressed, execute the function at this address." This is called an interrupt callback.

// Define a type for a function pointer that takes no arguments and returns nothing.
typedef void (*button_callback_t)(void);

// Our function that will be called on a button press.
void handle_button_press() {
    // Toggle an LED, for example
}

// In our setup code, we register our callback.
void setup_interrupts() {
    // Fictional function to set the interrupt handler
    register_button_isr(handle_button_press);
}

// The ISR (Interrupt Service Routine) a fictional implementation:
void register_button_isr(button_callback_t callback) {
    // Store the function pointer to be called later.
    g_button_handler = callback;
}

// When the hardware interrupt occurs, the system calls:
if (g_button_handler != NULL) {
    g_button_handler();
}

The Deceptive Compiler

Modern compilers are incredibly smart. They analyze your code to find optimizations, like eliminating redundant operations or storing frequently used variables in fast CPU registers instead of slower RAM. Usually, this is great. But it can cause bizarre, hard-to-find bugs when working with hardware.

This happens with (MMIO), where hardware control registers are accessed at specific memory addresses. A status register might change value at any moment due to an external event, not because your code changed it. But the compiler doesn't know that.

Imagine code that waits for a bit to be set in a status register: while (STATUS_REGISTER == 0) { /* do nothing */ } The compiler might see that your code never changes STATUS_REGISTER inside the loop. It could 'optimize' the code by reading the register just once, outside the loop. If the value is 0, it creates an infinite loop, while (true) {}, because from its perspective, the condition will never change. Your program gets stuck, even if the hardware sets the bit a microsecond later.

To prevent this, you use the volatile keyword. This is a directive to the compiler that says, "Hey, the value at this memory location can change at any time, for reasons you can't see. Do not optimize away any reads or writes to this address."

Any pointer to a hardware register should always be declared volatile.

// Correct way to declare a pointer to a hardware register
volatile uint32_t* const UART_STATUS_REG = (uint32_t*)0x40011004;

// Now the compiler will re-read the memory location in every loop iteration
while ((*UART_STATUS_REG & (1 << 5)) == 0) {
    // Wait for the 'Transmit Empty' bit to be set
}

// Send data

The Perils of `malloc`

In desktop applications, malloc() is your go-to for allocating memory on the heap. Need space for a string or an object? Just ask for it. But in the constrained world of embedded systems, especially safety-critical ones, malloc() is often forbidden.

The two main reasons are non-determinism and fragmentation.

Non-determinism: The time it takes for malloc() to find a suitable block of memory can vary. In a real-time system that must respond within microseconds, this unpredictability is unacceptable.

Memory fragmentation: This is a more insidious problem. Imagine the heap is a long parking lot. Cars of different sizes (your memory allocations) arrive and leave at different times. Over time, the lot can become a patchwork of small, unusable empty spaces between parked cars. Even if you have enough total empty space (say, 50 bytes free in total), you might not be able to park a large vehicle (request a 30-byte block) because no single empty spot is large enough.

Lesson image

When malloc() fails in an embedded system, the result can be catastrophic. The device might crash, reboot, or enter an undefined state. Because of this, the preferred approach is static allocation. You determine all the memory your program will ever need at compile time and allocate it as global or static variables. This approach is predictable and reliable.

If you truly need dynamic behavior, a common strategy is to use a memory pool. You statically allocate a large block of memory once at startup, then write a simple, custom allocator that hands out fixed-size chunks from this pool. This avoids the overhead and unpredictability of malloc and prevents fragmentation.

By understanding how C's memory model maps to hardware and the trade-offs of different allocation strategies, you can write firmware that is not only functional but also robust, efficient, and reliable.