cswork / libraryOpen practice workspace

Articles

C Volatile Interview Questions: MMIO, Optimization, and Thread-Safety Myths

Practice C volatile interview questions with MMIO access traces, write-one-to-clear mistakes, atomic counters, and release/acquire publication examples.

A polling loop works in a debug build and hangs after optimization. Someone adds volatile, sees the loop recover, and proposes the same fix for a shared counter. That is the moment a C interview becomes interesting: the first change may address a compiler access requirement, while the second leaves a data race.

C volatile controls access to qualified objects under the implementation’s rules. It does not make a compound operation atomic, publish ordinary memory between threads, or supply a complete device-access protocol. A useful answer identifies which guarantee the program needs before choosing a keyword, atomic operation, lock, or platform accessor.

This guide separates official language/compiler facts from original practice exercises and engineering inference. It makes no claim about how often employers ask these questions and does not present candidate reports as language specifications. The language baseline is C11; the linked N1570 document is a historical committee draft, not the latest C standard.

What does volatile actually tell the compiler?

Official fact: C11 draft section 6.7.3 describes volatile-qualified objects as potentially changing in ways unknown to the implementation or having other unknown side effects. What constitutes an access is implementation-defined. GCC’s documentation supplies its compiler-specific rules and explicitly says volatile accesses do not order unrelated non-volatile memory. C11 committee draft, GCC volatile documentation.

Avoid the shortcut “it disables optimization.” The compiler can still optimize surrounding arithmetic, inline functions, and transform ordinary memory operations. The relevant restriction concerns accesses to the qualified object. It is also misleading to say “every access goes directly to RAM”: a language qualifier does not choose the processor’s memory attributes, establish an MMIO mapping, or bypass caches by itself.

Consider these two functions:

unsigned ordinary_pair(const unsigned *p) {
    unsigned a = *p;
    unsigned b = *p;
    return a + b;
}

unsigned volatile_pair(const volatile unsigned *p) {
    unsigned a = *p;
    unsigned b = *p;
    return a + b;
}

Local observation: compiling this original exercise with Apple Clang 21.0.0, -std=c11 -O2, for arm64 produced one load followed by a shift in ordinary_pair, but two loads followed by addition in volatile_pair. No hardware register was accessed. This demonstrates one compiler’s output for these expressions, not a universal promise that each volatile expression corresponds to one bus transaction.

An interviewer may then ask whether the two volatile reads can return different values. For a device-backed location that changes between reads, that is precisely the possibility the code must accommodate. If the desired computation needs one consistent sample, read once into an ordinary local value and calculate from that sample—provided the device’s documented read semantics allow it.

Which part of a pointer is volatile?

Read the declaration before diagnosing the loop. The following types encode different requirements:

DeclarationWhat is qualifiedInterview implication
volatile uint32_t *regThe pointed-to valueReads and writes through reg access a volatile-qualified value.
uint32_t * volatile regThe pointer objectReloading the pointer does not make the register value volatile.
const volatile uint32_t *statusA read-only, volatile pointeeThis code cannot assign through the pointer; external hardware can still change the value.

For a polling exercise, a volatile pointer to an ordinary value is an especially plausible mistake. It preserves accesses to the address variable while leaving the pointed-to value under ordinary rules. The symptom may resemble the correct declaration in an unoptimized build.

Ask where the pointer originates, whether its width and alignment match the platform, and whether every access uses the appropriate type. Casting away the qualifier to pass the address into a helper undermines the intended access contract. A helper accepting const uint32_t * is not interchangeable with one accepting const volatile uint32_t * merely because both parameters contain an address.

Engineering inference: keep device addresses behind an interface that expresses permitted operations. A function named read_status() can document read side effects and access width more clearly than scattering casts around application code. Its implementation still needs the compiler, operating-system, and device rules; its name provides no ordering guarantee.

Why can a correct volatile access still damage an MMIO register?

The compiler preserving a read and write does not mean the device wants both operations. Here is an original synthetic register exercise, not a specification for any particular chip.

A four-bit status register begins at 1101. Each set bit represents a pending event. The device implements write-one-to-clear: writing a one clears that bit; writing a zero leaves it alone. You need to acknowledge only bit zero.

Proposed operationValue writtenStatus afterwardReason
Write the acknowledgment mask directly00011100Only bit zero is cleared.
Read, OR with 0001, then write11010000Every previously set flag is acknowledged.
Read, AND with the inverse mask, then write11000001The unrelated flags are cleared; the intended flag remains.

The familiar expression *status |= 1u is therefore wrong for this hypothetical register. It performs a read-modify-write when the protocol requires a mask write. Replacing it with *status = 1u fits the stated protocol. Neither expression becomes correct merely because status has a volatile pointee.

In the same local Clang assembly check, the direct assignment emitted a store without a preceding load. The OR assignment emitted a load, OR, and store. Those observations support the source-level distinction, while the register’s response remains an assumption of this exercise.

A second variation uses a read-to-clear register. Reading once for a condition and again for logging can consume two observations or erase evidence. Capture a permitted sample once, then log the local copy. Official compiler boundary: GCC warns that volatile bit-fields can involve implicit reads and partial accesses, making them an unwise default for hardware register access. GCC volatile documentation.

For a real device, obtain its register documentation before proposing the fix. Check access width, reserved-bit rules, read side effects, write behavior, and any required barriers or readbacks. A debugger’s register view can itself be an access; “inspect the value” is not automatically a harmless diagnostic step.

Why does volatile fail as a shared-counter fix?

Suppose two threads execute counter++ on a volatile unsigned counter. The operation conceptually reads a value, computes its successor, and writes it back. Volatile does not turn those steps into an indivisible increment.

The familiar illustration has both threads read seven and both write eight, although two increments were intended. Treat that as a way to explain the missing atomic operation, not as a prediction of a valid C program. Official fact: C11’s thread model makes a conflicting access involving a non-atomic object, without the required happens-before relationship, a data race with undefined behavior. Qualifying that object volatile does not remove the race. C11 committee draft, section 5.1.2.4.

For a counter whose only purpose is counting, one possible implementation is:

#include <stdatomic.h>

atomic_uint counter;
/* Initialize before starting the worker threads. */
atomic_init(&counter, 0u);

/* Each worker performs an atomic increment. */
atomic_fetch_add_explicit(&counter, 1u, memory_order_relaxed);

Relaxed ordering can be appropriate when the counter is not being used to publish other data. It does not establish a protocol for reading a related payload. GCC’s atomic documentation distinguishes relaxed ordering from release/acquire and also documents cases where atomic operations require runtime support instead of lock-free instructions. GCC atomic built-ins.

Local observation: the accompanying C11 pthread exercise ran two workers performing 10,000 atomic increments each and asserted a final count of 20,000 after both joins. That verifies the exercise’s result on the stated compiler and machine. It does not benchmark contention or establish lock-free behavior.

If the invariant spans several fields—such as an inventory count and a linked list of reservations—an atomic increment alone may not protect it. Explain the invariant and consider a mutex around the complete state transition.

How do you publish ordinary data safely?

Now consider a producer writing payload = 42 and then setting a volatile ready flag. A consumer spins until ready is true and reads payload. The missing piece is a language-level synchronization relationship, not another volatile annotation.

An original one-shot C11 publication exercise uses an atomic flag:

#include <stdatomic.h>

static atomic_bool ready;
static int payload;

/* Producer, after initialization and thread creation. */
payload = 42;
atomic_store_explicit(&ready, 1, memory_order_release);

/* Consumer. */
while (!atomic_load_explicit(&ready, memory_order_acquire)) {
    /* Teaching example: no waiting or backoff strategy. */
}
/* The acquire load observed the producer's release. */
use(payload);

Initialize ready to false before creating the threads. In this exercise, the producer is the sole writer and never changes payload again after publication. Official ordering principle: release/acquire synchronization depends on the acquire observing the relevant release; unrelated atomic operations do not automatically publish arbitrary memory. GCC atomic ordering overview.

The local runnable version asserted that the consumer saw 42. Its combined output was payload=42 count=20000. The abbreviated snippet above omits thread setup and uses use() as a placeholder; the saved fixture contains the complete executable program.

A follow-up may change the flag into a reusable mailbox. The one-shot reasoning no longer settles ownership: can the producer overwrite the payload while the consumer reads it? Is there an acknowledgment, generation number, queue, or mutex? Draw the second publication before choosing memory orders. A busy loop also needs a separate production decision about waiting, cancellation, and shutdown.

What evidence should you collect before proposing a fix?

Separate four questions in your diagnosis:

  1. Compiler access: does the generated code perform the intended qualified accesses under the chosen optimization and target settings?
  2. Thread synchronization: what atomic operation or lock establishes ownership and visibility for ordinary memory?
  3. Platform ordering: what does the architecture or operating-system accessor require around this device mapping?
  4. Device completion: does the device require a readback, acknowledgment, or other documented completion check?

A compiler barrier constraining code motion is not automatically a CPU barrier or a portable thread synchronization protocol. Likewise, an atomic operation suitable for ordinary shared memory is not automatically a permitted transaction against a side-effecting device register. Use the platform’s documented MMIO interface rather than treating stronger atomics as a universal hardware fix.

In an interview, state what you can prove now and what evidence is still missing. Assembly can demonstrate emitted loads and stores. It cannot independently prove the device’s bus ordering or that a peripheral finished processing a command. Reproduce compiler behavior with ordinary allocated objects; do not run fabricated register addresses or an undefined data-racing program to manufacture a demonstration.

Sources and further reading