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.