C Part II · Module 17 · volatile for Hardware Engineers
Using volatile for Register Access
The qualifier has one correct position in a declaration and several plausible wrong ones, and Chapter 16.3 measured the most popular mistake making the pointer volatile while leaving the access unprotected. This chapter covers the full set of forms — including const volatile, which lets the type system reject a write to a read-only register at compile time — and then the one remaining way to lose the guarantee silently: a cast that strips the qualifier mid-expression, which reverts the generated code to a single load and which the obvious warning flag catches in one spelling and misses entirely in another.
Advanced21 min readCPart IIHardwareRegisters
Part II · Module 17 · Chapter 17.3 · Using volatile for Register Access
1. The One Correct Position
Chapter 16.3 §4 measured the two plausible spellings and the result is worth restating as the rule:
volatile uint32_t *p; /* pointer to volatile uint32_t — the OBJECT is volatile */
uint32_t volatile *p; /* identical to the above; some prefer this spelling */
uint32_t *volatile p; /* volatile POINTER to ordinary uint32_t — almost never */The first two are the same declaration. The third is a different one, and it is the one people write by accident: it makes the pointer variable volatile — so the compiler spills it to the stack and reloads it on every use, which Chapter 16.3 §4 measured — while leaving *p an ordinary access the optimiser may delete.
Read the declaration aloud. "Pointer to volatile uint32_t" is right; "volatile pointer to uint32_t" is wrong. The uint32_t volatile *p spelling exists because it puts the qualifier immediately before the *, where it is harder to misplace, and some codebases standardise on it for that reason alone.
For the canonical access the qualifier appears inside the cast:
*(volatile uint32_t *)(base + offset) = value;which is read the same way: cast to pointer to volatile uint32_t, then dereference.
2. Read-Only Registers: const volatile
This combination looks contradictory and is not, and it is the most under-used tool in register programming.
const says this program will not write it. volatile says it may change anyway. Together they describe exactly a read-only status register: Chapter 16.6 measured that writing to STATUS is silently ignored by the hardware, and const volatile makes the compiler reject the write instead.
#define STATUS ((const volatile uint32_t *)(uintptr_t)0x40021008u)
uint32_t read_status(void) { return *STATUS; } /* fine */
void bad(void) { *STATUS = 1u; } /* compile error */cvw.c:3:18: error: read-only variable is not assignable
3 | void w(void){ *S = 1u; }
| ~~ ^An error, not a warning, at compile time, for a mistake that the hardware would have accepted and ignored. That is Chapter 16.6's access-type column being enforced by the type system instead of by a comment, and it costs nothing.
The full set worth having in a register header:
| Access type (16.6) | Declaration | What the type system enforces |
|---|---|---|
| RW | volatile uint32_t * | nothing beyond the access |
| RO | const volatile uint32_t * | writes are a compile error |
| WO | volatile uint32_t * | nothing — C has no write-only qualifier |
| W1C | volatile uint32_t * | nothing; the hazard is semantic (16.5) |
Only RO gets real help, and it is worth taking. For WO and W1C the type system cannot express the constraint and the comment on the declaration is what you have — which is Chapter 16.7 §4's rule about recording the access type next to the register.
3. Arrays and Structure Members
Both work the way you would hope, and both are worth checking rather than assuming.
A volatile array preserves every element access:
extern volatile uint32_t fifo[4];
uint32_t sum4(void) { return fifo[0] + fifo[1] + fifo[2] + fifo[3]; }sum4 loads: 4Four loads for four element reads — none merged, none reordered relative to each other. The qualifier applies to the element type, so every subscript is a volatile access.
A volatile structure member is how Chapter 16.7's register overlay works, and that chapter made the case for the typedef:
typedef volatile uint32_t vu32;
struct uart_regs {
vu32 id; /* 0x00 RO */
vu32 ctrl; /* 0x04 RW — RMW is safe */
vu32 irq; /* 0x0C W1C — write only the bit; NEVER |= */
};The qualifier must be on every member. Omitting it on one makes that register's accesses ordinary, with all four of Chapter 17.1's transformations available on exactly one register — which is as hard to find as a bug gets. Chapter 16.7 §2's callout argued that vu32 is worth it because a missing qualifier then becomes visually obvious.
And the pointer to the structure takes the qualifier the same way as any other pointer:
volatile struct uart_regs *p; /* correct: the members are volatile anyway */
struct uart_regs *volatile p; /* wrong, for exactly §1's reason */4. Losing the Qualifier by Casting
Here is the one way to lose the guarantee that Module 16 never covered, and it is silent.
extern volatile uint32_t v;
uint32_t keep_two(void) { return v + v; }
uint32_t drop_two(void) {
uint32_t *p = (uint32_t *)(uintptr_t)&v; /* volatile stripped */
return *p + *p;
}keep_two : 2 data load(s), combines with 'add'
drop_two : 1 data load(s), combines with 'lsl'drop_two is back to one load and a shift — which Chapter 17.1 §3 identified as the exact signature of the "both reads agree" assumption being in force. Casting the qualifier away did not merely weaken the guarantee; it removed it entirely for every access through that pointer.
And the diagnostic depends on how the cast is spelled.
uint32_t *p = (uint32_t *)&v; /* direct */
uint32_t *p = (uint32_t *)(uintptr_t)&v; /* laundered */direct: warning: cast from 'volatile unsigned int *' to 'unsigned int *'
drops volatile qualifier [-Wcast-qual]
laundered: (no diagnostic — 0 warnings with -Wall -Wextra -Wpedantic -Wcast-qual)-Wcast-qual is in Chapter 13.7's recommended set, where it was earned for catching const being cast away; it catches volatile too. The intermediate conversion to uintptr_t defeats it, because converting a pointer to an integer is always allowed and the integer carries no qualifier to complain about.
5. Where the Qualifier Belongs in a Driver
Putting §1 to §4 together, the practical form is the one Chapter 16.3 §6 introduced, now with its qualifiers justified:
#include <stdint.h>
static inline uint32_t reg_read32(uintptr_t base, uint32_t offset) {
return *(const volatile uint32_t *)(base + offset);
}
static inline void reg_write32(uintptr_t base, uint32_t offset, uint32_t value) {
*(volatile uint32_t *)(base + offset) = value;
}The const on the read accessor is new and worth a sentence: it documents that this function does not write, and it means a future edit that tries to assign through that pointer is a compile error rather than a review comment.
Three rules to apply alongside it:
Qualify at the source, not at the use. A register's declaration or accessor carries the qualifier; callers should never need to add it, and should never be in a position to remove it.
Never cast it away. If you find yourself wanting to, the question is why — usually it is to pass a register address to a function with an ordinary pointer parameter, and the fix is to change that parameter rather than to lie about the type.
Do not qualify ordinary objects. Chapter 17.1 §6 made this point: volatile on a hot loop's array suppresses every optimisation on it, correctly and permanently. The qualifier is precise and should be applied precisely.
6. The Misconception: The Declaration Protects Every Access
"The register is declared
volatile, so every access to it is protected."
Measured false. §4's drop_two accesses the same volatile object through a stripped pointer and gets one load where the honest version gets two. The declaration governs accesses through that type; an expression that arrives at the object by another route is governed by the type it used.
Three practical consequences:
A cast can remove it mid-expression, and when laundered through uintptr_t nothing diagnoses the removal.
A function parameter can remove it. void helper(uint32_t *p) called with a volatile uint32_t * requires a cast — which -Wcast-qual catches — but once written, every access inside helper is ordinary. The parameter should be volatile uint32_t *.
A memcpy removes it. The standard library's parameters are not volatile, so copying out of a register block with memcpy makes no volatile accesses at all. If you need to read a block of registers, write the loop.
The corrected formulation:
volatileis a property of the type used to perform the access, not a property of the object. The declaration sets the default; any expression that changes the type changes the guarantee.
7. What You Should Be Able to Say Now
volatile uint32_t *panduint32_t volatile *pare the same;uint32_t *volatile pis a different declaration that qualifies the pointer and leaves the access unprotected (16.3 §4).- Read the declaration aloud. "Pointer to volatile
uint32_t" is right. const volatileis the right type for a read-only register. Measured: writing through it is a compile error, where the hardware would silently ignore the write (16.6).- Only RO gets type-system help. WO and W1C cannot be expressed in C's type system; the comment on the declaration is what you have.
- A volatile array preserves every element access — measured, four loads for four subscripts.
- Every member of a register overlay needs the qualifier.
vu32makes an omission visible (16.7 §2). - Casting the qualifier away removes the guarantee entirely. Measured: two loads and an
addbecome one load and alsl. -Wcast-qualcatches the direct cast and misses theuintptr_t-laundered one — the same shape as 14.4's-Wcast-alignandvoid *.- A cast added to silence a warning is a bug report until proven otherwise.
volatileis a property of the type performing the access, not of the object. Casts, function parameters andmemcpyall change it.
8. Exercises
- Write all three declaration forms from §1 and compile a store through each at
-O2 -S. Identify which one spills the pointer, and say what it fails to protect. - Declare a status register
const volatileand attempt a write. Record the exact error. Then remove theconstand confirm the write compiles. - Build §3's
sum4and count the loads. Then removevolatilefrom the array and recount. Explain the difference using Chapter 17.1's assumptions. - Write a register overlay with
volatileon every member but one. Compile a function that reads all of them, and identify from the generated code which member lost its qualifier. - Reproduce §4's two functions and both cast spellings. Confirm the load counts, the
add/lsldifference, and which spelling-Wcast-qualcatches. - Write a function
void drain(uint32_t *dst, const uint32_t *src, size_t n)and call it with a register pointer. What does the compiler say, what would you have to write to silence it, and why should you not? - §6 claims
memcpymakes no volatile accesses. Confirm it frommemcpy's prototype, then write the loop that does it correctly.
9. Summary
- One correct position:
volatile uint32_t *p(oruint32_t volatile *p).uint32_t *volatile pqualifies the pointer and protects nothing. const volatilefor read-only registers — measured: a write is a compile error, where hardware would ignore it silently.- Only RO is expressible; WO and W1C rely on the comment (16.7 §4).
- Volatile arrays and struct members work — 4 subscripts, 4 loads. Qualify every member;
vu32makes a gap visible. - Casting it away removes it: 2 loads and an
addbecome 1 load and alsl. -Wcast-qualcatches the direct cast, not theuintptr_t-laundered one — the same pattern as 14.4's-Wcast-align/void *.- A cast that silences a warning is a bug report until proven otherwise.
- The qualifier belongs to the access type, not the object — casts, parameters and
memcpyall strip it.
Everything so far has been one program talking to one device, in a single flow of control. The qualifier's other classical use is for an object that changes while your code is between two statements — a flag set by an interrupt handler — and that case turns out to expose both what volatile is genuinely required for and, immediately, how little it is enough for on its own.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
