
In embedded C, pointer declarations with const and volatile qualifiers are among the most misunderstood constructs. Misreading a single qualifier can lead to code that silently corrupts hardware state, elides critical reads, or triggers undefined behavior. This article breaks down the four canonical combinations and shows exactly when each one belongs in firmware.
Understanding the effect of const starts with seeing how its position relative to * changes the meaning. Here are the four fundamental patterns:
int *ptr_a; /* pointer to int */const int *ptr_b; /* pointer to const int */int *const ptr_c; /* const pointer to int */const int *const ptr_d; /* const pointer to const int */
The rule is simple: const applies to whatever is immediately to its left. If there is nothing to its left (it appears first), it applies to the base type. The * is the boundary — qualifiers left of it apply to the pointee, qualifiers right of it apply to the pointer itself.
For pointer declarations involving const and volatile, a reliable technique is to read starting from the identifier, going strictly right-to-left:
const int *const pp -> p is aconst -> constant* -> pointer to aconst int -> constant integer
So p is a constant pointer to a constant integer. Neither the address nor the value can change after initialization.
Hardware peripherals expose memory-mapped registers at fixed addresses. A status register that updates on every ADC conversion must be read fresh each time — the compiler must not cache its value. A transmit-data register must be written with the exact value the firmware intends — the compiler must not optimize away a “redundant” write.
/* Status register: hardware updates it, firmware reads it */volatile const uint32_t * const STATUS = (volatile const uint32_t *)0x40001000;/* Data register: firmware writes it, hardware consumes it */volatile uint32_t * const DATA = (volatile uint32_t *)0x40001004;
Here STATUS is a constant pointer to a volatile constant uint32_t. The address is fixed (it is a hardware register), the data is volatile (hardware changes it asynchronously), and it is also const from the firmware’s perspective (we only read it). DATA is a constant pointer to a volatile uint32_t — the address is fixed, and the data is volatile to ensure the compiler emits every write (otherwise, the compiler could optimize away a “redundant” store to the same address).
/* WRONG: compiler may read STATUS once and reuse the value */const uint32_t * const STATUS = (const uint32_t *)0x40001000;/* RIGHT: every read hits the actual register */volatile const uint32_t * const STATUS = (volatile const uint32_t *)0x40001000;
Without volatile, the compiler sees no side effects from reading STATUS and may hoist the read out of a loop or eliminate it entirely.
const int *ptr = &sensor_value;*ptr = 42; /* COMPILE ERROR: cannot modify const data */ptr = &other; /* OK: pointer itself is not const */
Many firmware bugs arise from assuming const int *ptr makes the pointer immutable. It does not — it makes the data immutable through that pointer.
volatile uint32_t *hw_reg = (volatile uint32_t *)0x40002000;uint32_t *alias = (uint32_t *)hw_reg; /* DANGEROUS: volatile discarded */
Casting away volatile may compile without errors, but it is dangerous. In fact, if the underlying memory is truly volatile (like a hardware register), accessing it through a non-volatile pointer invokes undefined behavior according to the C standard. The compiler will optimize reads from alias as if it were normal RAM, defeating the entire purpose of memory-mapped access.
+------------------------------------------------------------+| Pointer Qualifier Map |+------------------------------------------------------------+| || const int *ptr --> data is read-only || int *const ptr --> pointer address is fixed || const int *const ptr --> both are fixed || volatile int *ptr --> data may change externally || int *volatile ptr --> pointer itself may change || (rare — e.g., ISR callbacks) || |+------------------------------------------------------------+
A clean and standard way to model a peripheral in embedded C is by mapping a struct of volatile members to a fixed memory address using a macro:
typedef struct {volatile const uint32_t status; /* read-only, hardware-updated */volatile uint32_t data; /* write-only, hardware-consumed */volatile uint32_t control; /* read-write, firmware-configured */} UartPeripheral;/* UART0 acts as a constant pointer to a peripheral struct */#define UART0 ((UartPeripheral *)0x40003000)
Here, the struct members appropriately use volatile (and const for read-only registers) to enforce correct memory access semantics.
Expert Tip: You might wonder why we don’t use #define UART0 ((UartPeripheral const)0x40003000) to explicitly make the macro a constant pointer. In the C standard, the result of a cast is an rvalue, which is inherently unassignable. Adding const to a cast result type is therefore redundant, and compilers like GCC will flag it with a -Wignored-qualifiers warning. The simple pointer cast shown above is the strictly correct, standard approach.*
const left of * → data is read-only through this pointer.const right of * → pointer address is fixed after init.volatile → compiler must access memory every time; never optimize away.volatile. Read-only registers add const on the data side. Fixed addresses add const on the pointer side.volatile — it silently reintroduces the optimization bugs you were trying to prevent.const, volatile, and restrict semantics.Quick Links
Legal Stuff




