Frontend Labs
⋯

49. Memory-mapped registers: volatile, /dev/mem, and memory barriers

49.1 What volatile actually promises — and doesn't

volatile tells the compiler: "this memory can change for reasons you can't see, and every access in the source must become a real load or store — never cache it in a register, never eliminate it as 'redundant,' never reorder it relative to other volatile accesses." That's the core compiler-side guarantee; it is not a complete hardware ordering model. It does not make an access atomic, does not insert a CPU/device memory barrier, and does not synchronize with another thread — for those you still need Section 38's atomics, OS or architecture barrier primitives, or Section 52's kernel locks. Confusing volatile with thread-safety is one of the most persistent myths in C; volatile is mainly for memory whose value can change outside the compiler's model of your program — a hardware register being the textbook example.

// Without volatile, the compiler is free to read `*status` ONCE, cache it
// in a register, and spin forever checking the cached copy — even though
// the hardware is actively changing the real memory underneath it.
while ((*status & READY_BIT) == 0) { }        // BUG: may never see the bit flip

while ((*(volatile uint32_t *)status & READY_BIT) == 0) { }   // correct: reloads every time

49.2 Mapping a hardware register block from user space

On Linux, /dev/mem gives a (root-only) process access to physical memory — including a peripheral's registers — through the same mmap you'd use for a file (Section 39's allocator used anonymous mmap; this is the same call, pointed at physical hardware addresses instead):

#include <stdio.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>

#define BCM2835_PERI_BASE 0x3F000000              // Raspberry Pi 3's peripheral base address —
#define GPIO_BASE         (BCM2835_PERI_BASE + 0x200000)  // straight out of the SoC datasheet
#define BLOCK_SIZE        4096

static volatile uint32_t *gpio_map(void) {
    int fd = open("/dev/mem", O_RDWR | O_SYNC);
    if (fd < 0) { perror("open /dev/mem"); return NULL; }

    void *map = mmap(NULL, BLOCK_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, GPIO_BASE);
    close(fd);   // safe once mapped — the mapping itself keeps the pages accessible

    if (map == MAP_FAILED) { perror("mmap"); return NULL; }
    return (volatile uint32_t *)map;
}

#define GPFSEL1 1   // word offset: function-select register for GPIOs 10-19
#define GPSET0  7   // word offset: writing a 1 here drives that GPIO high
#define GPCLR0  10  // word offset: writing a 1 here drives that GPIO low

void gpio_set_output(volatile uint32_t *gpio, int pin) {
    int reg = pin / 10, shift = (pin % 10) * 3;
    gpio[reg] &= ~(7u << shift);   // clear this pin's 3-bit function-select field
    gpio[reg] |= (1u << shift);     // 001 = output, per the BCM2835 datasheet's register table
}

void gpio_write(volatile uint32_t *gpio, int pin, int value) {
    if (value) gpio[GPSET0 + pin / 32] = 1u << (pin % 32);
    else       gpio[GPCLR0 + pin / 32] = 1u << (pin % 32);
}

int main(void) {
    volatile uint32_t *gpio = gpio_map();   // must run as root: sudo ./a.out
    if (!gpio) return 1;
    gpio_set_output(gpio, 17);
    gpio_write(gpio, 17, 1);   // turn an LED on
    return 0;
}

Every field here — 0x3F000000, the GPFSEL/GPSET/GPCLR offsets, the 3-bits-per-pin layout — comes directly from the BCM2835/BCM2711 peripheral datasheet, not from any general C knowledge. This is the actual job: reading a register map out of a datasheet and turning each row into a #define and a masked read-modify-write, exactly like Section 17's bit-manipulation primitives, aimed at real silicon instead of an in-memory flags variable. (In a real kernel driver you'd use ioremap() instead of /dev/mem + mmap — same idea, Section 51.)

49.3 Memory barriers: when volatile alone isn't enough

volatile stops the compiler eliding accesses and constrains ordering between volatile accesses in the abstract C program, but it is not a portable device-memory ordering primitive. A modern CPU, interconnect, or posted-write path can still delay when a write becomes visible to a device. When the order of two register writes matters (very common: "set the configuration register, then the enable register, in that exact order, or the device ignores the config"), use the barrier primitive provided by your OS, compiler, or architecture:

#include <stdatomic.h>   // useful for C atomics; real MMIO often needs OS/arch barriers

gpio[CONFIG_REG] = desired_config;
atomic_thread_fence(memory_order_release);   // teaching sketch; not a universal MMIO barrier
gpio[ENABLE_REG] = 1;

Inside the Linux kernel you'd use the kernel's own wmb()/rmb()/mb() or MMIO helpers such as writel()/readl() instead of <stdatomic.h>, because they are defined for the kernel's CPU and device-memory model. The intuition is similar to Section 38's acquire/release pairing, but the C standard alone does not define how device registers observe memory.

War story. A board bring-up engineer wrote register-configuration code that worked perfectly in a debug build and intermittently failed in the optimized release build. The missing piece was not "make everything volatile"; it was the device's required ordering between "set config" and "set enable." Adding the platform's write barrier/MMIO helper made that ordering explicit — the kind of small-looking change that can take days to discover on real hardware.


Generated from real-life-c-guide.md · 8 Oct 2026 16:36