Embedded C
Registers & volatile
A hardware register looks like an ordinary variable in C — an address
you read and write — but it isn't one, and the compiler needs to be
told so explicitly. This page covers what a register actually is,
exactly why volatile exists, and the bit-level
manipulation that's the real, daily mechanics underneath every
protocol page elsewhere in this notebook.
- What it is
- A fixed memory address that controls or reports hardware state, accessed exactly like RAM but behaving nothing like it.
- Mental model
- A normal variable is a note only your code writes on. A register is a note the hardware itself can also change, at any moment, with no announcement —
volatileis what tells the compiler not to assume it knows the note's contents. - Key numbers
- A single register commonly packs 8, 16, or 32 independent bit-fields — reading/writing "the register" as one number without masking is how you clobber a neighbor's setting.
- Reach for it when
- Any variable touched by hardware or an interrupt handler — never optional, never a performance-only concern.
What a register actually is
A peripheral register is a specific memory address, documented in the chip's reference manual, where reading or writing doesn't just store a value — it controls or reports the state of actual hardware (a GPIO pin's level, a timer's current count, whether a UART has a byte ready). This is memory-mapped I/O: the CPU accesses hardware using the exact same load/store instructions it uses for ordinary RAM, at an address that happens to be wired to a peripheral instead of a memory chip.
Why volatile exists
Without volatile, the compiler is allowed to assume a
variable's value only ever changes through code it can see — a
completely valid assumption for ordinary variables, and the basis for
real optimizations like caching a value in a CPU register instead of
re-reading memory every time. That assumption is simply wrong for a
hardware register or a variable an interrupt handler writes
asynchronously — the value can change from somewhere the compiler has
no visibility into at all, which is precisely the shared-state
situation already covered in Interrupts.
/* WITHOUT volatile — a real, classic bug */
while (STATUS_REG == 0) { }
/* the optimizer may read STATUS_REG exactly ONCE, decide the loop
condition can't change (nothing in the visible code writes it), and
generate an infinite loop that never looks at the register again */
/* WITH volatile — correct */
volatile uint32_t *status = (volatile uint32_t *)STATUS_REG_ADDR;
while (*status == 0) { } // re-read from hardware on every iteration, guaranteed
Register access patterns
Two equivalent ways to reach a register show up constantly. Direct address-cast access:
#define GPIO_BASE 0x40020000UL
#define GPIO_ODR_OFFSET 0x14UL
*(volatile uint32_t *)(GPIO_BASE + GPIO_ODR_OFFSET) = 0x1;
And the struct-overlay pattern most vendor SDKs actually generate — a struct whose member layout exactly matches the peripheral's register map, then one pointer cast to the peripheral's base address gives named access to every register in it:
typedef struct {
volatile uint32_t MODER; // offset 0x00
volatile uint32_t OTYPER; // offset 0x04
volatile uint32_t ODR; // offset 0x14
} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *)0x40020000UL)
GPIOA->ODR = 0x1; // same effect as the direct-cast version, more readable
Bit manipulation
A register is rarely one logical value — it's a packed set of independent bit-fields, and writing the whole register wholesale would clobber every unrelated bit sharing it. Read-modify-write with bitwise operators touches only the bit(s) intended:
#define BIT(n) (1UL << (n))
reg |= BIT(3); // set bit 3, leave every other bit untouched
reg &= ~BIT(3); // clear bit 3
reg ^= BIT(3); // toggle bit 3
if (reg & BIT(3)) { /* bit 3 is set */ }
Real-world applications
- Every protocol page in this notebook — configuring a UART's baud rate register, checking an I2C status register's ACK bit (see I2C), setting an SPI mode's CPOL/CPHA bits (see SPI) are all instances of exactly this pattern.
- GPIO configuration — setting a pin's mode (input/output/alternate function) is a multi-bit field write into a single mode register shared by every pin on that port — see GPIO.
- Interrupt flag clearing — most interrupt sources require explicitly clearing a status-register flag inside the handler (often by writing 1 to it, not 0 — a real per-peripheral quirk) or the interrupt re-fires immediately.
Gotchas
- Forgetting
volatileon any register pointer or ISR-shared variable is a "works in testing, fails intermittently in the field" bug, since its symptoms depend entirely on compiler optimization level. - A non-atomic read-modify-write (
reg |= BIT(3);) can lose an ISR's concurrent change to the same register if the interrupt fires between the read and the write — the same shared-state hazard as any other variable touched by both contexts, needing the same discipline covered in RTOS. - Some peripherals need a few clock cycles before a written value is reflected in a subsequent read-back — a documented hardware timing quirk, not a bug in the C code reading it too soon.