
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.
Every pointer-to-int declaration with these qualifiers falls into one of four patterns. The position of const relative to the * is what changes the meaning.
const int *ptr_a; /* pointer to const int */int *const ptr_b; /* const pointer to int */const int *const ptr_c; /* const pointer to const int */volatile int *ptr_d; /* pointer to volatile 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.
A reliable technique is to read the declaration starting from the identifier, going right first (if brackets exist), then left:
const int *const pp -> p is a name* -> ...a pointer to...const int -> ...a constant integerconst -> ...that itself is constant
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), 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, the data is volatile (hardware reads it), and firmware writes to it.
/* 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 is legal C but almost always wrong in embedded code. The compiler will now 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 --> both are fixed || volatile int *ptr --> data may change externally || int *volatile ptr --> pointer itself may change || (rare — e.g., jump tables) || |+-------------------------------------------------------+
A clean way to model a peripheral is with a struct of qualified pointers:
typedef struct {volatile const uint32_t * const status; /* read-only, hardware-updated */volatile uint32_t * const data; /* write-only, hardware-consumed */volatile uint32_t * const control; /* read-write, firmware-configured */} UartPeripheral;static const UartPeripheral UART0 = {.status = (volatile const uint32_t *)0x40003000,.data = (volatile uint32_t *)0x40003004,.control = (volatile uint32_t *)0x40003008,};
Each member is a constant pointer (the address never changes) with the appropriate volatility and constness for its direction. The struct itself is const and lives in flash — zero RAM cost.
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





