JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Memory-Mapped Registers and volatile Done Right

On a bare-metal MCU there is no driver layer between you and the silicon — you blink an LED by writing a number to an address. We learn how to reach a peripheral register honestly, why a missing volatile makes a correct-looking loop hang forever, and exactly what volatile does and does not promise.

There is no API down here — only addresses

On a hosted machine, turning on a light means open(), write(), a kernel, a driver. On a bare-metal microcontroller there is none of that scaffolding: your code is the whole program, and the only way to affect the outside world is to read and write numbers at particular addresses. You met the memory map in this rung's first guide — that long street where some house numbers are RAM, some are flash, and some are not memory at all but the control panels of peripherals: the GPIO block, a timer, a serial port. To switch on an LED, you store a bit pattern to the address that the chip's reference manual assigns to a GPIO output register. That store never lands in RAM; it travels to the silicon and a pin goes high.

Each of these control-panel slots is a peripheral register. A peripheral register is just a fixed address — usually 32 bits wide on these chips — where every bit has a documented meaning the hardware acts on. Bit 5 of one register might enable the timer; the low byte of another might hold the value you are about to send out the serial line; reading a third tells you whether a byte has finished transmitting. The manual gives you, for each register, its address (often as a base plus an offset, like 0x40020000 + 0x14) and a table of what each bit does. Your entire job, much of the time, is translating that table into the right masks.

How do you reach an address from C? You make a pointer hold that exact number and dereference it. Write the register's address as an integer constant, cast it to a pointer of the right width, and the load or store on `*p` becomes a load or store to the silicon. The cast looks alarming the first time — you are conjuring a pointer out of a literal — but it is precisely correct here: that integer really is the address of a real thing. This is the same trick the kernel driver in the previous rung used through MMIO helpers; bare-metal just does it in the open, with nothing hidden underneath.

// GPIOA on a typical Cortex-M part (addresses from the reference manual)
#define GPIOA_BASE   0x40020000u
#define GPIOA_ODR    (*(volatile uint32_t *)(GPIOA_BASE + 0x14))  // output data
#define GPIOA_IDR    (*(volatile uint32_t *)(GPIOA_BASE + 0x10))  // input data

GPIOA_ODR |=  (1u << 5);   // drive pin 5 high  -> LED on
GPIOA_ODR &= ~(1u << 5);   // drive pin 5 low   -> LED off
uint32_t pins = GPIOA_IDR;  // read all 16 input pins at once
A register is an address you cast a pointer to; volatile (next section) is why this is correct, not just compilable.

The loop that hangs: why volatile is not optional

Here is the bug that ambushes nearly everyone once. You want to wait until the serial peripheral reports it is ready, so you read its status register in a loop until a flag bit appears. Written without volatile, the C is `while ((*status & READY) == 0) { }`. To the compiler `*status` is an ordinary memory read, and it reasons, correctly under its own rules: nothing in this loop writes to `*status`, so its value cannot change between iterations — read it once, keep the result in a register, and loop on the cached copy. The flag in real silicon flips a microsecond later, but the compiler is no longer looking. The loop spins forever on a stale value, and your firmware hangs. Worse, it may work at -O0, where the compiler re-reads every time, and hang only at -O2 — the most demoralising kind of bug.

The fix is one keyword. The volatile qualifier tells the compiler: this location can change in ways you cannot see, so never assume, never cache, never skip. With `volatile uint32_t *status`, every read of `*status` in the source becomes a real load from the address, every write a real store — none elided, none invented, and the accesses to a single volatile object kept in program order relative to each other. volatile is not a performance hint or a hint at all; it is you telling the truth to the compiler about a location whose value is governed by something outside your program. For a peripheral register, that something is the hardware, and the truth is: assume nothing.

Read, modify, write — without clobbering the neighbours

A register usually packs many unrelated controls into its 32 bits. The output register for a GPIO port holds all sixteen pins; the configuration register for a timer holds the enable bit, the direction, the clock divisor, all side by side. So to change one pin you must not overwrite the whole register — you must change your bit and leave the others exactly as they were. That is the read-modify-write pattern, and the bitwise operators are how you express it: read the current value, OR in the bits you want set, AND with the complement of the bits you want cleared, then write the result back.

  1. Read the register once into a local: uint32_t v = REG; (this is a real volatile load — the hardware's current state).
  2. Clear the bits you are about to set, so old values do not linger: v &= ~(mask);
  3. Set the new bits: v |= (new_value << shift); — using a shift to land the field at the right position.
  4. Write the whole word back in one store: REG = v; (a real volatile store — the neighbours you read in step 1 are preserved).

Two honest cautions about this pattern, because it is more delicate than it looks. First, some registers do not behave like memory at all. A few are write-1-to-clear: to acknowledge an interrupt flag you write a 1 to that bit, and writing 0 does nothing — so the read-modify-write dance is wrong for them, and you write a plain mask with only that bit set. Others are read-only, or clear themselves the instant you read them. The bit layout in the manual is the law; there is no general rule that fits every register, and assuming "it's just memory" is how subtle bugs are born. Second, this read-modify-write is three operations, not one — if an interrupt service routine touches the same register between your read and your write, your write can stomp the ISR's change. We meet that hazard squarely next.

What volatile does NOT promise

This is the part that makes someone a careful embedded programmer rather than a lucky one. volatile buys you exactly three things and not one more: each access in the source happens for real, the accesses are not optimised away, and accesses to one volatile object stay ordered relative to each other. It does NOT make an access atomic, it does NOT order a volatile access against an ordinary non-volatile one, and it does NOT erect a hardware memory barrier. People reach for volatile expecting it to make their shared-flag code safe, and it does not.

Take read-modify-write again. `REG |= bit;` on a volatile register is not atomic — it compiles to a load, an OR, and a store. If an ISR fires in that gap and modifies REG, your store writes back a value computed before the ISR ran, silently erasing its work. volatile faithfully made all three accesses real; it never claimed to fuse them. The cure is not more volatile but mutual exclusion appropriate to a single core: briefly disable the relevant interrupt around the sequence, or use a hardware bit-set/bit-clear register if the chip provides one (many Cortex-M parts expose atomic set/reset registers exactly so you can flip one pin without a read-modify-write at all).

Putting it together: a register access you can trust

Let us assemble everything into the small ritual a seasoned embedded programmer performs almost without thinking. Open the reference manual to the peripheral. Find the register's base and offset, its width, and the meaning of each bit — including whether any bit is write-1-to-clear or read-only. Define a volatile-qualified accessor macro so the qualifier can never be forgotten. To change a field, read-modify-write with masks and shifts; to change a single bit on a chip with atomic set/reset registers, prefer those and skip the read entirely. Where an ISR shares a register, protect the read-modify-write by briefly masking that interrupt. And where ordering across peripherals matters, drop in an explicit barrier — volatile will not do that job for you.

Stand back and notice how cleanly this rung's ideas stack. The memory map from guide one gave us the addresses; the peripheral register is one slot on that map; volatile makes our C accesses to that slot honest; bitwise masking lets us touch one field without disturbing its neighbours; and the ISR and NVIC from guide three are the asynchronous other party whose existence forces us to think about atomicity. You can now blink an LED, send a byte, and poll a status flag correctly — not by luck, but because you know what each access really compiles to. The rung's final guide lifts above this register-poking and asks how to run several timed activities at once without a tangle of hand-written state machines: a real-time operating system, scheduling, and determinism.