Volatile Registers
Master volatile keyword usage for memory-mapped registers in embedded C. Interactive simulator shows compiler optimization effects on register reads.
MMIO and volatile: Talking to Hardware from C
A memory-mapped register is not a variable. Reading one can change it, writing one can trigger a physical action, and its value can change with no store anywhere in the program. C's model has exactly one tool for this: volatile, which tells the compiler that accesses to this object are part of the program's observable behaviour and may not be added, removed, reordered relative to other volatile accesses, or cached in a register. That is a narrow guarantee and it is frequently mistaken for a broader one. volatile does not make an access atomic, does not order a volatile access against an ordinary one, and does not emit any memory barrier the bus or the cache might need. Everything beyond 'the access happens, exactly as written, in this order relative to other volatile accesses' has to come from somewhere else.
How it is built
- volatile makes each access observable, so the compiler must emit exactly the loads and stores the source specifies, in the order it specifies them relative to other volatile accesses. Without it a polling loop reading a status register can be hoisted out entirely, because the abstract machine sees a value that never changes.
- The qualifier's position matters and is read right to left. `volatile uint32_t *p` is a pointer to a volatile object, which is what a register pointer should be. `uint32_t *volatile p` is a volatile pointer to an ordinary object, which is almost never what was meant.
- volatile provides no atomicity. A read-modify-write of a register is three separate bus operations, and an interrupt landing between them loses whatever the handler wrote. Hardware that offers separate set and clear registers exists precisely so the read-modify-write can be avoided; where it does not, the sequence needs a critical section.
- volatile provides no ordering against ordinary memory. A DMA descriptor written to normal RAM and then a start bit written to a volatile register may reach the hardware in either order, because only the second access is observable. A memory barrier is what orders them, and on some cores a cache clean is needed as well.
- Datasheet access semantics have no C syntax. Read-to-clear means the act of reading destroys the flags, so reading a status register twice for two different bits loses the first read's information. Write-one-to-clear means writing a zero does nothing, so a read-modify-write of such a register clears bits the code never touched. Write-only registers return undefined data, so the read half of a read-modify-write is meaningless.
- Register access width is fixed by the peripheral. A 32-bit register accessed as two 16-bit halves may latch on the first access or fault outright, and the compiler is free to choose an access width unless the type pins it down - which is one more reason the register type must be exact-width.
Design procedure
- Declare registers as pointers to volatile exact-width types, and generate the definitions from the vendor header rather than by hand.
- Read a read-to-clear status register exactly once into a local, then test every bit against that local.
- Use dedicated set and clear registers when the hardware provides them, and a critical section when it does not. Never read-modify-write a write-one-to-clear register.
- Place an explicit barrier between a buffer write in ordinary memory and the volatile register write that hands it to hardware, and a cache clean as well on a cached core.
- Match the access width to the datasheet, and assert the register struct's size and member offsets with _Static_assert.
- For the ownership question that follows - who may touch a buffer while hardware is using it - see /dsa/capstones, which models the DMA handover directly.
Key terms
- volatile
- Makes each access observable: emitted exactly as written, in order relative to other volatile accesses.
- Not atomic
- volatile says nothing about interruptibility. A read-modify-write is still three bus operations.
- Not ordered vs ordinary memory
- Only volatile accesses are ordered against each other. A barrier orders the rest.
- Read-to-clear
- Reading destroys the flags. Read once into a local and test the local.
- Write-one-to-clear
- Writing zero does nothing. Read-modify-write clears bits you never touched.
- Access width
- Fixed by the peripheral. Splitting a 32-bit register into halves can latch early or fault.
Worked example
#include <stdint.h>
/* A register pointer, with the qualifier in the right place: */
#define UART_SR (*(volatile uint32_t *)0x40004400u)
#define UART_DR (*(volatile uint32_t *)0x40004404u)
/* Read-to-clear: read ONCE, then test the local. */
uint32_t sr = UART_SR; /* the read clears the flags */
if (sr & SR_OVERRUN) { ... }
if (sr & SR_RXNE) { ... } /* still valid: we kept the value */
/* Wrong: the second read sees flags already cleared by the first. */
if (UART_SR & SR_OVERRUN) { ... }
if (UART_SR & SR_RXNE) { ... }
/* Write-one-to-clear: assign, never read-modify-write. */
ICR = ICR_OVERRUN; /* clears just this flag */
ICR |= ICR_OVERRUN; /* WRONG: clears every set flag */
# What volatile does and does not give you:
#
# emitted exactly as written YES
# ordered vs other volatile accesses YES
# not cached in a register YES
# atomic NO
# ordered vs ordinary memory NO
# emits a memory barrier NO
# flushes a data cache NO
# Handing a buffer to DMA needs all four steps:
# 1. fill the buffer (ordinary memory)
# 2. cache clean (cached cores)
# 3. memory barrier (ordering)
# 4. write the start register (volatile)Common pitfalls
Reading a Datasheet Register Definition
A peripheral register is specified by a table: a name, an offset, a reset value, and a list of fields with bit ranges and meanings. Turning that table into working C is mostly mechanical and has a small number of places where it goes wrong - and those places account for a large share of driver bugs. The bit numbering may be documented most-significant-first while the code assumes least-significant-first. The reset value may not be zero, so a register that has never been written is not blank. Reserved bits may be documented as 'must be preserved' or 'write zero', and the two require opposite handling. And the access type - read-only, write-only, read-to-clear, write-one-to-clear - changes what a read-modify-write means, which is where the C-level rules from the MMIO chapter meet the specific part in front of you.
How it is built
- A field is a bit range plus a meaning. Encoding it in code needs three constants: the shift (the least significant bit position), the mask (width ones at that position), and often an enumeration of the legal values. Deriving mask from width rather than writing both keeps them from drifting apart.
- Bit numbering conventions differ. Most ARM and vendor datasheets number bit zero as least significant, but some communications standards number the most significant bit as bit one. Getting this backwards produces a field that is correct in width and wrong in position, which reads as a plausible but incorrect configuration.
- The reset value matters because it is the state before your code runs. A register whose reset value has bits set means that clearing a field is a real operation rather than a no-op, and a driver that only ever ORs bits in will never reach the intended configuration.
- Reserved bits carry a rule and the rule differs. 'Must be preserved' means a read-modify-write is mandatory. 'Write zero' means a blind write is acceptable and preserving the read value is wrong. Ignoring the distinction produces a driver that works on one silicon revision.
- Access type is the property that interacts with the C rules. A read-only field cannot be part of a read-modify-write meaningfully; a write-only register returns undefined data on read, so a read-modify-write of it is nonsense; a write-one-to-clear register must be assigned, never ORed. The datasheet's access column decides which idiom is legal.
- Some registers require a specific sequence: an unlock key before a write, a wait for a ready flag after, or a write to a second register to commit. These sequences are part of the register's contract and belong in the accessor rather than in every call site.
Design procedure
- Generate register definitions from the vendor's machine-readable description where one exists, rather than transcribing tables by hand.
- Define each field as a shift plus a width, and derive the mask from the width so the two cannot disagree.
- Check the bit numbering convention explicitly on the first register you implement for a part, and write down which one it is.
- Read the reset value and the reserved-bit rule for every register you write, and choose read-modify-write or blind write accordingly.
- Encapsulate any required unlock or commit sequence inside the accessor so no call site can perform half of it.
- Verify one representative write on a bus trace or a logic analyser before trusting the whole peripheral.
Key terms
- Field
- A bit range with a meaning. Encoded as a shift, a width, and often a value enumeration.
- Reset value
- The register's state before any code runs. Often non-zero, which makes clearing a real operation.
- Reserved: preserve
- Read-modify-write is mandatory; a blind write corrupts bits the datasheet reserves.
- Reserved: write zero
- A blind write is correct; preserving the read value is wrong.
- Access type
- RO, WO, RW, RC, W1C. Decides whether read-modify-write is meaningful at all.
- Unlock sequence
- A key or ordering the register requires. Belongs inside the accessor.
Worked example
/* A datasheet table, and the code it becomes:
*
* CR1 offset 0x00 reset 0x0000_0004
* bits name access meaning
* 31:16 reserved -- must be preserved
* 15:12 PRESC rw prescaler, 0-15
* 11:8 MODE rw 0=idle 1=run 2=test
* 7:3 reserved -- write zero
* 2 READY ro set when configured
* 1 OVF rc overflow, cleared by reading
* 0 ENABLE rw enable
*/
#define CR1_PRESC_SHIFT 12u
#define CR1_PRESC_WIDTH 4u
#define CR1_PRESC_MASK (((1u << CR1_PRESC_WIDTH) - 1u) << CR1_PRESC_SHIFT)
#define CR1_PRESERVE_MASK 0xFFFF0000u /* 31:16 must survive */
#define CR1_WRITE_ZERO 0x000000F8u /* 7:3 must be zero */
void cr1_set_prescaler(uint8_t presc) {
uint32_t v = CR1; /* preserve 31:16 */
v &= ~(CR1_PRESC_MASK | CR1_WRITE_ZERO);
v |= ((uint32_t)presc << CR1_PRESC_SHIFT) & CR1_PRESC_MASK;
CR1 = v;
}
# The five questions to ask of every register before writing it:
# 1. is bit 0 the least significant bit here?
# 2. what is the reset value?
# 3. do reserved bits need preserving or zeroing?
# 4. what is each field's access type?
# 5. is there an unlock or commit sequence?Common pitfalls
The abstract machine, and why the compiler is allowed to delete your code
C is defined in terms of an abstract machine, and a compiler must only reproduce that machine's observable behaviour: its input and output, and its accesses to volatile objects. Everything else is negotiable. That is not a loophole, it is the entire licence under which optimisation happens - the compiler removes, merges and reorders accesses because it can prove no conforming program could tell. Hardware is not a conforming program, and volatile is how you say so.
How it is built
- The as-if rule: any transformation is legal provided the observable behaviour is unchanged. Ordinary loads and stores are not observable.
- A sequence point orders side effects, but it constrains only the abstract machine, not the bus traffic of non-volatile objects.
- Accesses to volatile objects ARE observable behaviour, by definition in the standard, which is what makes the qualifier meaningful rather than a hint.
- The compiler's model of memory is: nothing changes a location except this program. Every elision follows from that single assumption.
- A memory-mapped register violates the assumption in both directions - it changes on its own, and reading or writing it does something.
Design procedure
- Ask of any access: could a conforming C program observe whether this happened? If not, assume the compiler may remove it.
- Mark as volatile exactly those objects whose accesses have meaning outside the program: hardware registers, and variables an ISR changes.
- Do not mark ordinary variables volatile to make a bug go away; it hides the real problem and costs every optimisation on that object.
- Read the generated assembly when the behaviour surprises you. The compiler's output is the ground truth about what will happen.
- Remember that raising the optimisation level does not introduce these bugs; it reveals ones that were always there.
Key terms
- Abstract machine
- The idealised C machine the standard defines behaviour in terms of.
- As-if rule
- Any transformation preserving observable behaviour is legal, however different the code becomes.
- Observable behaviour
- I/O, and accesses to volatile objects. Nothing else is guaranteed to survive.
- Sequence point
- A point where all prior side effects are complete. Orders the abstract machine, not the bus.
- Elision
- The removal of an access the compiler proved unobservable.
Worked example
The same function, and what the licence permits:
int f(int *p) {
*p = 1;
*p = 2;
return *p;
}
A conforming program cannot observe the value 1 ever being in *p,
so the compiler emits:
MOV r1, #2
STR r1, [r0]
MOV r0, #2
BX lr
One store, not two, and the load is gone entirely. This is not a
bug and not an aggressive optimisation - it is the definition of
the language being applied.
int f(volatile int *p) { ... same body ... }
MOV r1, #1
STR r1, [r0] <- both stores survive
MOV r1, #2
STR r1, [r0]
LDR r0, [r0] <- and the load
Nothing about the source changed except one qualifier, and the number of bus
transactions went from one to three.Common pitfalls
What volatile guarantees, and the three things it does not
volatile makes exactly one promise: every access to the object happens, exactly once, in the order the source specifies, relative to other volatile accesses. That is precise and it is narrow. It does not make an access atomic, it does not order volatile accesses against ordinary ones, and it emits no memory barrier - so on a multi-core or a write-buffered system it does not guarantee another observer sees anything at a particular time.
How it is built
- Guaranteed: the access is not elided, not duplicated, and not reordered with respect to other volatile accesses.
- Not guaranteed: atomicity. A 64-bit volatile on a 32-bit core is two accesses, and an interrupt can land between them.
- Not guaranteed: ordering against non-volatile accesses. The compiler may move ordinary loads and stores across a volatile one.
- Not guaranteed: visibility to other cores or DMA. That needs a barrier, and on many parts a cache maintenance operation as well.
- The qualifier is a property of the access path, so it belongs on the pointer used to reach the object, not necessarily on the object.
Design procedure
- Use volatile for hardware registers and for variables shared with an ISR on a single core.
- Use _Atomic or an explicit critical section when the shared object is wider than one atomic access, or when read-modify-write must be indivisible.
- Add an explicit barrier - __DMB() on Cortex-M - where ordering against ordinary memory matters, such as before starting a DMA transfer.
- For DMA buffers on a part with a data cache, add cache clean and invalidate operations; volatile does not touch the cache at all.
- Write the qualifier in the right place: volatile uint32_t * is a pointer to volatile, and uint32_t * volatile is a volatile pointer.
Key terms
- Access
- A read or a write of an object. For volatile, each one must occur exactly as written.
- Atomicity
- Indivisibility. A separate property that volatile does not provide.
- Memory barrier
- An instruction ordering memory operations. DMB on Arm; volatile emits none.
- Tearing
- A wide access observed half-complete, because it was really several accesses.
- Cache coherency
- Whether another observer sees your writes. Needs cache maintenance, not a qualifier.
Worked example
The three gaps, each with what actually closes it:
1. NOT ATOMIC
volatile uint32_t counter;
counter++; LDR / ADD / STR - three instructions.
An ISR between them loses an increment.
fix: _Atomic uint32_t, or disable interrupts around it
2. NOT ORDERED against ordinary accesses
buffer[0] = 0x42; /* ordinary store */
DMA_START = 1; /* volatile store */
Nothing stops the compiler sinking the buffer store below the
DMA start, or the write buffer draining them out of order.
fix: __DMB() between them
3. NOT VISIBLE to another observer
On a Cortex-M7 with a data cache, the buffer write may sit in
cache while DMA reads stale main memory.
fix: SCB_CleanDCache_by_Addr() before starting the transfer
volatile was necessary in all three cases and sufficient in none of them.Common pitfalls
Declaring a register: address, width, and qualifier placement
A memory-mapped register is reached by treating a fixed integer as an address. Getting the declaration right means getting four things right at once: the address, the access width, the qualifier's position, and whether the object is const. Each of these has a distinct failure mode, and three of the four fail silently.
How it is built
- The canonical form is a cast of a literal address to a pointer to volatile of the correct width, then a dereference.
- Access width matters to the hardware: many peripherals require a 32-bit access and behave differently or fault on a byte access.
- Qualifier position follows the usual right-to-left rule: volatile uint32_t *p is a pointer to volatile, uint32_t * volatile p is a volatile pointer.
- A read-only register should be const volatile - const because writing it is a bug, volatile because reading it still has meaning.
- A struct overlay maps a whole peripheral in one declaration, which is what vendor headers provide, and keeps the offsets in one place.
Design procedure
- Prefer the vendor's CMSIS-style struct overlay to hand-written address casts; it gets width and offset right by construction.
- Declare read-only registers const volatile and write-only ones volatile, so the compiler catches misuse.
- Match the declared width to the datasheet's required access size, and check whether byte accesses are permitted at all.
- Add a static assertion on the overlay struct's size and on key member offsets, so a layout mistake fails the build.
- Never take a non-volatile pointer to a register, even briefly; the qualifier is a property of the access path and is lost at the cast.
Key terms
- Peripheral overlay
- A struct whose members line up with a peripheral's register block.
- const volatile
- Read-only hardware: writing is an error, and reading still must not be elided.
- Access width
- The size of the bus transaction. Frequently constrained by the peripheral.
- Register block
- A contiguous set of registers at a base address, described by one overlay.
- offsetof assertion
- A compile-time check that a member sits where the datasheet says it does.
Worked example
The four ways to write it, and what each means:
#define STATUS (*(volatile uint32_t *)0x40011000u)
read/write, 32-bit, accesses preserved. The usual form.
#define ID (*(const volatile uint32_t *)0x40011004u)
read-only. ID = 1; is now a compile error.
volatile uint32_t *p = ...; pointer TO volatile (what you want)
uint32_t * volatile p = ...; VOLATILE pointer (almost never)
The overlay, which is what vendor headers give you:
typedef struct {
volatile uint32_t SR; /* 0x00 */
volatile uint32_t DR; /* 0x04 */
volatile uint32_t BRR; /* 0x08 */
volatile uint32_t CR1; /* 0x0C */
} USART_TypeDef;
#define USART1 ((USART_TypeDef *)0x40011000u)
USART1->CR1 |= USART_CR1_UE;
_Static_assert(offsetof(USART_TypeDef, CR1) == 0x0C, "layout");
_Static_assert(sizeof(USART_TypeDef) == 0x10, "size");
The assertions are the only thing standing between a mistyped reserved field
and a driver that writes to the wrong register.Common pitfalls
Bit manipulation on registers, and the read-modify-write trap
Setting a bit in a register is written |= and reads as one operation, but it compiles to three: load, modify, store. On ordinary memory that distinction rarely matters. On a register it matters twice - once because the hardware may change other bits in the window between the load and the store, and once because writing back a bit you read may itself be an action, which is exactly what write-1-to-clear means.
How it is built
- REG |= BIT compiles to a load of the whole register, an OR, and a store of the whole register.
- Any bit the hardware sets between the load and the store is overwritten by the stale value, and the event it recorded is lost.
- A write-1-to-clear bit read back as 1 and written back as 1 is cleared, so a read-modify-write silently discards every pending flag.
- A write-only or write-1-to-set register cannot be read-modify-written at all, because the read does not return what you assume.
- Peripherals that expect frequent bit changes usually provide separate SET and CLEAR registers precisely so no read is needed.
Design procedure
- Check the datasheet's access column for every bit in the register before using |= or &= on it.
- Use dedicated set/clear registers where the peripheral provides them: BSRR on STM32 GPIO, ISER and ICER in the NVIC.
- Where you must build a value, construct the whole word and write it once, with write-1-to-clear bits written as zero.
- Preserve reserved bits by reading them and writing them back unchanged, unless the datasheet specifies a fixed value.
- Disable interrupts around a read-modify-write on a register an ISR also touches, or the same window opens between two software contexts.
Key terms
- Read-modify-write
- Load, change, store. What |= and &= compile to.
- Write-1-to-clear
- A bit cleared by writing 1 to it. Reading back and writing back clears it.
- Set/clear register
- Separate write-only registers that set or clear bits with no read, so no window exists.
- Reserved bit
- A bit with no documented function. Usually must be preserved, sometimes must be written as zero.
- Atomic bit-band
- An Arm aliasing region where a word write changes one bit, giving a genuinely single-access update.
Worked example
The window, and the three ways to close it:
UART->SR |= TXEIE;
LDR r1, [r0] ; SR = 0b00011010 (ORE and FE are set)
ORR r1, r1, #2 ; set TXEIE
STR r1, [r0] ; writes 1 to ORE and FE -> BOTH CLEARED
Two error flags silently discarded by a line that says nothing
about them. If an interrupt handler was going to report a framing
error, it now never will.
1. dedicated set/clear register - no read at all
GPIOA->BSRR = (1u << 5); /* set PA5 */
GPIOA->BSRR = (1u << (5 + 16)); /* clear PA5 */
2. build the whole value, w1c bits written as zero
UART->SR = (config & ~SR_W1C_MASK) | TXEIE;
3. where neither is possible, close the window explicitly
uint32_t s = __get_PRIMASK(); __disable_irq();
REG |= BIT;
__set_PRIMASK(s);
BSRR exists because the hardware designers knew read-modify-write on a GPIO
output register is a race. The peripheral is telling you something.Common pitfalls
Sharing data with an interrupt handler
An ISR runs between two arbitrary instructions of the main program. That makes every object they share a concurrency problem, on a single core, with no threads in sight. volatile is necessary because the compiler must not cache the value; it is not sufficient because it says nothing about whether an access is indivisible. The rules that follow are simple, and getting them wrong produces bugs that appear once a day under load.
How it is built
- The compiler assumes no other context modifies memory, so a shared variable must be volatile or it will be cached in a register.
- Any access wider than the core's atomic width is several accesses, and the ISR can land between them.
- Any read-modify-write is three accesses, so counters, flags cleared after test, and bitmask updates are all racy without protection.
- A single-writer, single-reader ring buffer with power-of-two indices needs no lock, provided each index is written by exactly one side.
- Disabling interrupts is the simplest protection and directly increases worst-case interrupt latency, so the critical section must be short.
Design procedure
- Mark every object shared with an ISR volatile, without exception.
- Keep shared state to a single word where possible; a one-word flag or index is atomic on Cortex-M and needs nothing further.
- For anything wider or any read-modify-write, use _Atomic, an LDREX/STREX loop, or a short interrupts-off critical section.
- Prefer a lock-free single-producer single-consumer ring buffer to shared mutable state; it is the standard structure for exactly this.
- Save and restore the interrupt mask rather than unconditionally re-enabling, so nested critical sections do not enable interrupts early.
Key terms
- Critical section
- A region with interrupts disabled, making a multi-access sequence indivisible.
- PRIMASK
- The Cortex-M interrupt mask. Saving and restoring it makes critical sections nestable.
- SPSC ring
- Single producer, single consumer queue. Lock-free when each index has one writer.
- LDREX / STREX
- Arm exclusive access pair, giving an atomic read-modify-write without disabling interrupts.
- Interrupt latency
- Time from the hardware event to the handler's first instruction. Lengthened by every critical section.
Worked example
What is safe, and what only looks safe:
volatile uint32_t ticks; /* ISR writes, main reads */
uint32_t now = ticks; /* SAFE: one 32-bit load */
volatile uint64_t us; /* ISR writes, main reads */
uint64_t now = us; /* NOT SAFE on a 32-bit core:
two loads, ISR between
them -> a time that never
existed */
volatile uint32_t events;
events++; /* NOT SAFE: load/add/store */
The correct forms:
uint64_t read_us(void) { /* retry until consistent */
uint32_t hi, lo;
do { hi = us_hi; lo = us_lo; } while (hi != us_hi);
return ((uint64_t)hi << 32) | lo;
}
uint32_t s = __get_PRIMASK(); /* save, do not assume on */
__disable_irq();
events++;
__set_PRIMASK(s); /* restore what it was */
The save-and-restore matters: an unconditional __enable_irq() at the end of a
nested critical section enables interrupts inside the outer one.Common pitfalls
Ordering, barriers, and the write buffer
volatile orders accesses in the compiler. It does nothing about the hardware between the core and the peripheral - a write buffer, a bus matrix, a cache - each of which may complete transactions out of order or later than the instruction that issued them. On a simple core this never shows; on anything with a write buffer or a cache it produces failures that depend on timing and disappear under a debugger.
How it is built
- A store instruction retiring does not mean the write reached the peripheral; it may be sitting in a write buffer.
- A data memory barrier orders memory accesses against each other. A data synchronisation barrier waits for them to complete.
- An instruction synchronisation barrier flushes the pipeline, needed after changing configuration that affects instruction fetch or the MPU.
- Reading back a register after writing it forces the write to complete, which is why some vendor code does exactly that.
- On a cached core, a buffer shared with DMA needs a cache clean before the transfer and an invalidate after, independent of everything above.
Design procedure
- Add a DSB after writing a register that must have taken effect before the next step, such as disabling an interrupt source.
- Add a DMB between an ordinary memory write and the volatile register write that hands that memory to hardware.
- Add an ISB after changing the MPU, the vector table base, or anything else affecting how instructions are fetched or permissions checked.
- Clean the cache before a DMA read of your buffer, and invalidate it after a DMA write into your buffer.
- Align DMA buffers to a cache line and size them to a multiple of one, or a maintenance operation will affect a neighbouring object.
Key terms
- Write buffer
- Hardware holding stores after the instruction retires, so writes complete later than they issue.
- DMB
- Data memory barrier: orders memory accesses relative to each other.
- DSB
- Data synchronisation barrier: waits until prior accesses have completed.
- ISB
- Instruction synchronisation barrier: flushes the pipeline so newly changed configuration takes effect.
- Cache maintenance
- Clean writes cache back to memory; invalidate discards it so the next read refetches.
Worked example
The classic disable-then-continue race:
NVIC->ICER[0] = (1u << irq); /* disable the interrupt */
shared_state = 0; /* now safe to touch... ? */
The store may still be in the write buffer. The interrupt can
fire after the C statement and before the peripheral sees the
disable.
NVIC->ICER[0] = (1u << irq);
__DSB(); /* wait for it to actually land */
__ISB(); /* and for the effect to be visible */
shared_state = 0;
And the DMA sequence on a cached core, where three separate mechanisms all
have to be right:
memcpy(tx_buf, data, n); /* ordinary stores, may be
sitting in the data cache */
SCB_CleanDCache_by_Addr(tx_buf, n); /* push them to real memory */
__DMB(); /* order against what follows */
DMA->CR |= DMA_EN; /* volatile: hand it over */
volatile got the last line onto the bus in the right order relative to other
volatile accesses. The cache clean and the barrier did everything else.Common pitfalls
Reading the assembly, which is the only ground truth
Every claim in this topic is checkable, and checking takes about a minute. The compiler will show you the instructions it emitted, and that output settles whether an access survived, whether a loop reloads, and whether a bit operation became a read-modify-write. Learning to read a short listing is the difference between reasoning about the optimiser and knowing what it did.
How it is built
- -S emits assembly instead of an object file; -fverbose-asm annotates it with the source expressions.
- objdump -d on the object file gives the same information after assembly, with addresses and encodings.
- A volatile access appears as an LDR or STR that cannot be moved or removed; an elided access simply is not there.
- A poll loop that reloads shows the LDR inside the loop body; a hoisted one shows it before the branch target.
- Comparing -O0 and -O2 output for the same function is the fastest way to see which transformations are in play.
Design procedure
- Build the single translation unit with -S -O2 and read the function in question; it is usually under thirty instructions.
- Look for the load inside the loop. If it is above the label the branch returns to, the value is never refetched.
- Count the stores against the writes in the source; a mismatch is dead-store elimination.
- Check whether a bit set became LDR/ORR/STR rather than a single store to a set register.
- Do this once for each of the patterns in this topic, so the behaviour is something you have seen rather than something you were told.
Key terms
- -S
- Compile to assembly rather than to an object file.
- -fverbose-asm
- Annotate the assembly with the source-level expressions that produced it.
- objdump -d
- Disassemble an object file, showing addresses and encodings alongside mnemonics.
- Loop label
- The branch target. A load above it happens once; a load below it happens every iteration.
- LDR / STR
- Arm load and store. One per surviving access, and none for an elided one.
Worked example
The poll loop, both ways, and how to tell in one glance:
while ((STATUS & READY) == 0) { }
plain uint32_t *: volatile uint32_t *:
LDR r1, [r0] .Lloop:
TST r1, #1 LDR r1, [r0] <- inside
BNE .Ldone TST r1, #1
.Lloop: BEQ .Lloop
B .Lloop <- nothing .Ldone:
.Ldone: but a
branch
Left: the load is ABOVE the loop label, so it happens once. The
loop body is an unconditional branch to itself - the compiler has
compiled an infinite loop, correctly, from code that says wait.
Right: the load is BELOW the label, so every iteration refetches.
That is the whole diagnostic: is the LDR above or below the label the branch
targets. One minute with -S settles it, and no amount of reasoning about the
optimiser is as reliable.Common pitfalls
A checklist for driver code
Everything above reduces to a short set of checks that can be applied to any driver in a few minutes. They are ordered by how often each one is actually the problem, which is not the order they are usually taught in, and each has a specific symptom you can match against what the board is doing.
How it is built
- Every register access goes through a volatile-qualified path, including any pointer passed into a helper function.
- No |= or &= on any register containing write-1-to-clear or write-only bits; dedicated set/clear registers used where provided.
- Every object shared with an ISR is volatile, and anything wider than a word or read-modify-write is additionally protected.
- Barriers present where a write must have landed before the next step, and cache maintenance present around every DMA buffer.
- The generated assembly has been read once for each polling loop and each configuration sequence in the driver.
Design procedure
- Match the symptom first: a hang with no fault points at a hoisted poll; a peripheral at reset settings points at dead stores.
- An interrupt that re-fires forever points at a read-to-clear that was elided, or a source that was never cleared.
- Randomly lost error flags point at a read-modify-write on a status register.
- A failure that only appears at -O2, or only without a debugger attached, points at a missing volatile or a missing barrier.
- Corruption in a variable unrelated to the transfer points at an unaligned DMA buffer sharing a cache line with it.
Key terms
- Symptom-first debugging
- Matching the observed failure to the small set of mechanisms that produce it.
- Heisenbug
- A failure that disappears under observation - here, usually timing changed by the debugger.
- Reset settings
- The peripheral's power-on configuration. Running at them means the configuration writes never landed.
- Spurious interrupt
- A handler entered with no pending condition, or re-entered because the source was never cleared.
- Silent failure
- A defect with no error report. Most of this topic's failures are of this kind.
Worked example
The symptom table, which is the useful form of everything above:
SYMPTOM FIRST THING TO CHECK
---------------------------------------------------------------
hangs in a wait loop, poll variable not volatile;
no fault, PC in two LDR hoisted above the loop label
instructions
peripheral runs at reset configuration writes coalesced;
settings despite config only the last store survived
interrupt fires forever, read-to-clear elided because the
main loop starves value was unused
error flags randomly lost |= on a status register with
write-1-to-clear bits
works at -O0, fails at -O2 missing volatile somewhere on
the access path
works under the debugger, missing barrier; the debugger
fails free-running changed the timing
DMA works, an unrelated buffer not cache-line aligned;
variable is corrupted invalidate hit a neighbour
Every row is a mechanism from this topic, and every one of them is silent -
no warning, no fault, no error code. That is why the checklist is worth
running before the debugger comes out.Common pitfalls
More in Embedded C
- Interrupts, Rings & ConcurrencyThe handler is a second thread you did not declare. What is and is not atomic on a single core, the lock-free ring buffer and the conditions it requires, DMA and cache coherency, low-power modes and their wake sources, priority inversion, and debugging a race you cannot reproduce.
- User-Defined TypesDeclare your own types in embedded C: struct, union, enum, typedef, bitfields, designated initialisers, flexible array members and opaque handles.
- Embedded C + DSA 0 → 100One self-sufficient course connecting beginner C, Embedded C, hardware-facing APIs, bounded data structures, Embedded DSA practice, compiled code, diagnostics and production capstones.
- Bits, Fields & Fixed PointRegister fields and the mask conventions that silently disagree, Gray code and where one-bit-at-a-time matters, fixed-point arithmetic and the intermediate width a multiply needs, the undefined-behaviour traps in ordinary bit idioms, wire-format packing, and what each checksum detects.
- Types / PromotionUnderstand integer promotion and type conversion in embedded C. Interactive lab demonstrates implicit and explicit casting with signed/unsigned types.
- Compiler Workbench & TestingCompile real C for an embedded target and inspect what the compiler produced, then the discipline around it: where to draw the host-testable boundary, reading the generated assembly, undefined behaviour and the sanitizers, the warnings worth enabling, and measuring size and stack.
- Functions & ContractsDesign production C functions and modules with explicit preconditions, postconditions, ownership, status codes, opaque types, reentrancy, HAL seams and failure-state behavior.
- Arrays, Strings & BuffersArrays decay and the length does not travel; strings are a convention, not a type; and every length in a received packet is data rather than fact. Spans, the three string copies, framing and resynchronisation, serialisation, and parsing untrusted input safely.
- Object Layout & StorageWhere an object lives and what it costs: storage duration and linkage, the sections a declaration lands in, struct layout and the padding that makes a struct larger than its members, allocation without a heap, and integrity checks over stored data.
- C Basics & the Translation UnitFrom source text to a linked image: declarations against definitions, the translation unit the compiler actually sees, the preprocessor and what a macro can and cannot do, and the four build stages with the error vocabulary each one produces.
- FSM / DispatchDesign finite state machines with dispatch tables in embedded C. Interactive lab demonstrates state transitions and event handling for firmware.
- Embedded CA complete source-to-silicon workbench: C semantics, arrays and APIs, compiler and startup, MMIO, interrupts, DMA and caches, bounded systems, testing, safety evidence and production defense.