Embedded Systems · Bare-Metal Programming

Read-Modify-Write Hazards in Peripheral Registers

Why assigning through a bit-set idiom corrupts registers the hardware also writes, how write-one-to-clear flags interact with that idiom, and what surrogate registers buy.

A memory-mapped register that only software ever writes can be updated with an ordinary read-modify-write. A register that hardware also writes cannot, and most peripheral registers are in the second category: interrupt enable bits set by one piece of code and cleared by another, status bits the device raises asynchronously, configuration fields that a hardware state machine mutates. The failure mode is a bit that is occasionally missing, in a system where the same code path works most of the time.

What the compiler emits for a bit-set assignment

The idiom reads naturally and compiles into three accesses:

#define INT_ENABLE (*(volatile uint32_t *)0x40002000u)
INT_ENABLE |= (1u << 3);

The generated sequence loads the word, sets one bit in a register, and stores the word back. The volatile qualifier guarantees all three accesses occur in that order and that no part of the sequence is optimised away, and it guarantees nothing else. Between the load and the store, any write to the same address by another piece of code is overwritten. An interrupt handler that runs in that window and writes a different bit has its update discarded, and because the window is a handful of cycles wide, the resulting failure rate is low and load-dependent.

The same code executed while the hardware itself changes the register has the same outcome. If the device sets a bit that software does not own, the reload of the stale value overwrites it. Whether that matters depends on the register: a set-only enable bit written back as a zero removes an enable, while a flag bit written back as a zero may do nothing at all.

Read-modify-write on a register shared with an interrupt handler is not repairable by making the pointer volatile or by adding memory barriers, because the interference is temporal, not visual. The mechanisms that address it operate at interrupt level and are described in critical sections without an operating system.

Registers where the stored value is a command

A second class of register treats the written data as an instruction rather than as a new value for the register. The most common is write-one-to-clear, used for interrupt pending flags and latched error conditions: for each bit position, a written one clears the corresponding flag, and a written zero leaves the flag alone. Writing the whole word therefore clears exactly the flags named in the word.

Under that rule, the bit-set idiom is not merely racy; it is destructive. The load reads back the flag register, and every flag currently raised appears as a one. The store then writes those ones to the register, which clears every flag that was set at the moment of the load, including flags the software has never examined. A handler that intended to clear one interrupt can silently clear four others, and the evidence disappears with them.

The correct write is a plain store of the intended mask, with no read at all:

#define TIMER_PENDING (*(volatile uint32_t *)0x40002008u)

/* Correct for a write-one-to-clear register: the value written names the
   flags to clear and nothing else. No read-modify-write is involved. */
TIMER_PENDING = (1u << 3);

The mirror case is write-one-to-set, where the stored ones set bits and stored zeros have no effect. Clearing a bit in such a register requires writing a one to that position, so the operator that looks right in C, &= ~mask, does the opposite of what it reads as: it writes zeros everywhere the mask is set, which in a write-one-to-set register leaves those bits untouched. Reading such a register to learn its state is meaningful; the value read is a snapshot and cannot be used as the source of the next write.

Some registers compound the problem by defining their behaviour differently in read and in write. A field’s read value may be a status while its written value is a command, and reading a reserved bit may return one while writing a one to it is ignored. The safe assumption is that a multiplexed register has no general update operator at all, only statements that describe each write site individually.

Registers where reading is itself an action

A third class of register treats the read as the operation. A data register of a receive queue pops an entry when it is read; a status register in some designs clears the flags it reports as it reports them; a command-complete register clears on read. The read is destructive, and it is destructive for anything that reads it, including code that has no intention of consuming the value.

Two consequences follow. The first is that a diagnostic tool which displays register contents consumes data and loses flags, so a fault that reproduces under inspection and not otherwise is not necessarily intermittent. The second is that any loop structure which evaluates a volatile read twice for one decision has performed the operation twice; a condition that reads a status register, and a body that reads it again, has taken two entries from the queue. The safe form reads once into a local variable and decides from that.

The mirrored case is a register whose write requires a completion step: some devices need the written value to be read back before the update takes effect, or need a specific field written twice. Those requirements are per-register documentation rather than consequences of the access model, and they make a driver written against the general idiom incorrect even though every individual access is performed exactly as written.

Surrogate address ranges

The standard hardware answer is to give the same register several addresses with different write semantics. A write-only alias sets exactly the bits that are one in the written word, a second clears exactly those bits, and a third toggles them. Each is a single store that needs no read, so it cannot lose an update from another writer and cannot clear flags by accident.

#define GPIO_SET (*(volatile uint32_t *)0x40010010u)
#define GPIO_CLR (*(volatile uint32_t *)0x40010014u)

GPIO_SET = (1u << 5);   /* one store: bit 5 set, other bits untouched */
GPIO_CLR = (1u << 5);   /* one store: bit 5 goes low, other bits unaffected */

The price is address space: three addresses instead of one per register, which is why this style appears on ports and interrupt controllers with high write rates and is absent elsewhere. A variant packs two semantics into one word — writes to the low half of a 32-bit store set bits, writes to the high half clear them — which keeps the address count down at the cost of a rule that is invisible at the call site. A single 32-bit store that names both a set and a clear in different halves is also the only way to flip two bits in one bus transaction when the order between them matters.

Keeping a shadow copy

Where no alias exists and a register mixes software-owned and hardware-owned bits, a RAM shadow of the software-owned bits is the usual workaround. The shadow holds the software’s intent; the code modifies the shadow and then writes the whole register from it, and writes are arranged so the hardware-owned bits are written as neutral values. The approach requires knowing which bits are neutral on a write for that particular register, and it fails as soon as the device changes a bit the shadow believes it owns.

An alternative is to serialise access: disable interrupts across the read-modify-write so that no handler can interleave, accept the added interrupt latency for the duration of the sequence, and leave the hardware-versus-software race unresolved for registers the device itself updates. This works for enable registers that only software touches from an interrupt context and does nothing for flag registers, for the reason described above.

Exclusive accesses and device memory

The ARM architecture’s exclusive access instructions are the lock-free way to perform a read-modify-write on memory, and they do not extend to device regions. The exclusive monitor is defined for normal memory with specific attributes; an exclusive access to device memory, or to a region whose attributes permit buffering or merging, has no defined effect on the monitor. A load-exclusive followed by a store-exclusive on a peripheral register is therefore not a portable substitute for the alias register, even when the pair happens to work on a given implementation. Registers whose mapped type is device or strongly ordered are equally unavailable to the atomic types of the C memory model, which assume a coherent memory model rather than a bus with per-access side effects.

From the mechanism to the code

The practical consequence is that the choice of update style follows from the register, not from the source file’s conventions. Enable and configuration registers with no hardware writers accept read-modify-write when interrupted code is the only other writer and the sequence is protected. Registers with command semantics accept nothing but a plain store. Registers the peripheral updates asynchronously accept only an alias or a shadow. Recognising which of the three a given register is belongs with the register’s documentation rather than with the driver’s structure, and the difference between the first two cases is the one that produces a bug which reproduces only when a second interrupt happens to arrive during a three-instruction window. The mechanisms that protect the window when protection is the right answer are shared with the atomicity problem in volatile and memory-mapped register access.

References

  1. ARMv7-M Architecture Reference Manual (ARM DDI 0403) — Arm
  2. The Definitive Guide to Arm Cortex-M3 and Cortex-M4 Processors — Joseph Yiu, Newnes
  3. ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC