C Part II · Module 17 · volatile for Hardware Engineers
volatile, Interrupts and sig_atomic_t
A flag set by a signal handler and polled by a loop is the one non-register use of volatile that C genuinely requires — and this is the rare chapter in Part II where the experiment actually runs. It produces a result worth the whole module: the version without volatile does not hang, it prints its success message in eighteen milliseconds without ever reading the flag. The chapter also reports the first version of that experiment, which asked whether the program terminated and therefore got the answer exactly backwards, and then gives the limits of volatile sig_atomic_t immediately rather than letting you leave with a tool you would misuse.
Advanced22 min readCPart IIHardwareCorrectness
Part II · Module 17 · Chapter 17.4 · volatile, Interrupts and sig_atomic_t
1. The Other Reason volatile Exists
Everything so far has been about memory-mapped registers, where the device changes the object. There is a second case with the same shape and no hardware in it: an object written by a handler that runs outside your normal flow of control.
static sig_atomic_t stop = 0;
static void on_alarm(int sig) { (void)sig; stop = 1; }
int main(void) {
signal(SIGALRM, on_alarm);
alarm(1);
while (stop == 0) { } /* wait for the handler to run */
printf("the handler's write was observed\n");
}Chapter 17.1 §2's first assumption — nothing changes an object except the program — is false here for exactly the reason it is false for a register: another agent writes it. The handler is part of your program, but it does not appear in the control flow the compiler can see at the point of the loop.
This chapter is that case, and it is the one place in Part II where the experiment can actually be run.
2. The Experiment
Four builds of the same source: stop declared volatile sig_atomic_t or plain sig_atomic_t, at -O0 and -O2. The alarm fires one second in.
3. The First Measurement Was Invalid
My first version asked the obvious question — does the program terminate? — and ran each build with a three-second timeout.
-O0 volatile sig_atomic_t : terminated
-O0 plain sig_atomic_t : terminated
-O2 volatile sig_atomic_t : terminated
-O2 plain sig_atomic_t : terminatedAll four. Read naively, that says volatile makes no difference, and the chapter writes itself in the wrong direction.
It is wrong, and the reason is that termination is the wrong discriminator. A program whose wait loop has been deleted does not hang — it finishes immediately, which my test scored as success. The broken build was faster, not slower, and faster looked like working.
Chapter 13.9 §4's instruction is to observe the actual state rather than the state you expect, and Chapter 13.10 §5's is that an implausible number is a signal. Four identical results from a test designed to show a difference is that signal.
The valid discriminator is elapsed time, because a correct program must wait for the alarm:
volatile -O2 elapsed 1.023 s waited for the alarm — correct
plain -O2 elapsed 0.018 s never checked the flag
volatile -O0 elapsed 1.025 s correct
plain -O0 elapsed 1.026 s correct by accidentEighteen milliseconds. The plain -O2 build printed "the handler's write was observed" having never once read stop.
And the real evidence is the generated code, which needs no timer:
plain -O2: loop labels in main: 0
sequence is: signal, alarm, puts, fflush, puts, ret
volatile -O2: LBB0_1:
ldr w9, [x8, _stop@PAGEOFF]
cbz w9, LBB0_1The plain build has no loop at all. Chapter 17.1 §4 explained why: C17 §6.8.5 ¶6 permits assuming a side-effect-free loop terminates, and the loop body touches no volatile object, so the loop can be removed and execution falls through as though the condition had been satisfied.
4. sig_atomic_t, and Exactly What It Guarantees
volatile makes the access happen. It says nothing about whether the access is a single indivisible operation, and for an object written asynchronously that is a second question.
C's answer is one type. C17 §7.14 defines sig_atomic_t as an integer type of an object that can be accessed as an atomic entity even in the presence of asynchronous interrupts, and §7.14.1.1 ¶5 draws the boundary precisely: if a signal occurs other than as the result of abort or raise, the behaviour is undefined if the handler refers to any object with static or thread storage duration that is not a lock-free atomic object, other than by assigning a value to an object declared as volatile sig_atomic_t.
Read that exemption clause carefully, because it is unusually narrow:
- the object must be declared
volatile sig_atomic_t— both qualifier and type - the handler may assign a value to it — a plain store
- that is the entire permitted interaction
Not incrementing it. Not reading it and writing it back. Not touching a second object. Not calling a function that does. Everything else in a handler, as far as the C standard is concerned, is undefined behaviour.
Two further facts that are routinely got wrong:
sig_atomic_t is not int. It is an implementation-defined integer type whose range is given by SIG_ATOMIC_MIN and SIG_ATOMIC_MAX in <stdint.h>. On many platforms it is int; relying on that is Chapter 16.8's observed, not guaranteed.
"Atomic" here means one access is not torn. A single read or a single write completes as a unit — you will not observe half of a stored value. It does not mean stop++ is atomic, and Chapter 17.5 measures that directly.
5. The Correct Pattern, and Only That
#include <signal.h>
static volatile sig_atomic_t stop = 0; /* BOTH qualifier and type */
static void on_signal(int sig) {
(void)sig;
stop = 1; /* a plain assignment — the only permitted act */
}
int main(void) {
signal(SIGALRM, on_signal);
alarm(1);
while (stop == 0) { } /* the main flow polls it */
/* ... */
}That is the whole pattern. One flag, written by the handler, read by the main flow, with no other shared state. Measured working at -O2 in §3, taking 1.023 seconds as it should.
Note what it does not do, in the program itself:
- the handler does not increment anything
- the handler does not touch a second variable
- the main flow does not write
stop - nothing calls a non-async-signal-safe function from the handler
Every one of those is a separate rule, and the last is a whole subject of its own: printf in a signal handler is undefined behaviour, which is unfortunate, because printing from a handler is the first thing everybody tries.
6. The Limits, Immediately
It would be easy to leave this chapter believing you now have a mechanism for sharing data with a handler. You do not, and the limits are worth stating while the pattern is still fresh.
One flag is the whole of it. Two flags that must agree — "data ready" and "length" — cannot be managed this way, because nothing orders the handler's two writes with respect to the main flow's two reads. You can observe the second without the first.
A counter does not work. stop++ in a handler is a read-modify-write, which is three operations, which is Chapter 16.5's sequence with a genuinely asynchronous writer. Chapter 17.5 measures it.
There is no mutual exclusion. volatile sig_atomic_t gives you an untorn access and nothing else. If the main flow must read the flag, act, and clear it, the handler can run between the read and the clear.
And — the one that matters most for this curriculum — an embedded interrupt handler is not a C signal handler. The standard's guarantee in §7.14 is about signals. A hardware interrupt service routine on a microcontroller is outside the C standard entirely: whether it can share a volatile sig_atomic_t safely with the main loop is settled by your toolchain's and your target's documentation, which is Chapter 16.8's implementation-defined category.
In practice the pattern does work on ordinary single-core microcontrollers, for the same architectural reason it works for signals — an aligned word-sized store is a single instruction that an interrupt cannot interrupt. But that is a fact about the target, not a guarantee from C, and Chapter 16.8 §4's test applies: did I learn this from the document or from the device?
7. The Misconception: volatile Makes Sharing Safe
"The variable is
volatile, so it is safe to share between the interrupt handler and the main loop."
volatile makes the access happen. That is necessary for sharing and nowhere near sufficient, and the gap is the whole of Chapter 17.5.
What it gives you: the main loop re-reads the flag instead of caching it, and the handler's store is not optimised away. Without it, §3 measured the loop vanishing entirely.
What it does not give you: atomicity for anything other than a single access to a sig_atomic_t, any ordering between that object and any other, and any form of exclusion.
The honest summary of the whole chapter:
volatile sig_atomic_tsupports exactly one pattern: a single flag, written by the handler, read by the main flow. Anything more needs a real mechanism.
What a real mechanism looks like — _Atomic types, disabling interrupts around a critical section, a lock-free queue with a single producer and consumer — is outside this module, and the reason it is outside is that each of them is a subject in its own right. What this module owes you is the precise boundary, and §6 is it.
8. What You Should Be Able to Say Now
- The second use of
volatileis an object written outside the current flow of control — the same broken assumption as a register, with no hardware involved. - My first experiment was invalid: it asked whether the program terminated, and all four builds terminated. A deleted loop makes the program finish faster, so termination scored the broken build as success.
- Elapsed time is the valid discriminator. Measured:
volatile -O21.023 s, plain-O20.018 s, both-O0builds ≈1.02 s — the plain one correct by accident. - The plain
-O2build printed its success message without ever reading the flag.maincontains zero loop labels. - Falling through is worse than hanging, because it reports success and the next failure happens elsewhere.
- C17 §7.14.1.1 ¶5's exemption is extremely narrow: a handler may assign a value to an object declared
volatile sig_atomic_t. Not increment, not read-and-write, not touch a second object. sig_atomic_tis notint— an implementation-defined integer type withSIG_ATOMIC_MIN/MAX.- "Atomic" means one access is not torn, not that
++is safe. - One flag, one writer, one reader is the entire supported pattern. Two related flags, a counter, or read-act-clear all need something else.
- An embedded ISR is not a C signal handler. The §7.14 guarantee is about signals; for interrupts you are relying on the target's documentation (16.8).
9. Exercises
- Build §1's program four ways and time each. Confirm the ≈1.02 s against 0.018 s split, and say which build is correct by accident.
- Compile the plain
-O2version to assembly and count the loop labels inmain. List the calls it makes, in order. - Explain, in two sentences, why asking "does it terminate?" gave the wrong answer, and name the chapter that warned about tests like this.
- Find C17 §7.14.1.1 ¶5 (or a draft). Write out its exemption clause, then list four things a handler may not do that people commonly do.
- Print
SIG_ATOMIC_MINandSIG_ATOMIC_MAXandsizeof(sig_atomic_t)on your machine. Is itint? Say what you may conclude from the answer. - Change
stop = 1in the handler tostop++and describe what guarantee you have lost. Do not rely on the program's behaviour to answer. - Write a case needing two related values from a handler — a flag and a length. Explain why
volatile sig_atomic_tcannot carry it, and what you would need instead.
10. Summary
- A second broken assumption: an object written outside the visible flow of control.
- My first test was invalid — a deleted loop terminates faster, so "does it terminate?" scored the bug as a pass.
- Measured by elapsed time:
volatile -O21.023 s · plain-O20.018 s ·-O0both ≈1.02 s (the plain one correct by accident). - The plain
-O2build reported success without reading the flag — zero loop labels inmain. Worse than hanging. - C17 §7.14.1.1 ¶5: a handler may assign a value to a
volatile sig_atomic_t. That is the whole exemption. sig_atomic_tis notint; "atomic" means untorn single access, not a safe++.- One flag, one writer, one reader. Two flags, a counter, or read-act-clear all need a real mechanism.
- An embedded ISR is not a C signal handler — that case is the target's documentation, not the standard's (16.8).
Three times now this module has said volatile does not give you that and moved on: not atomicity in §4, not exclusion in §6, not ordering against ordinary accesses back in Chapter 17.2 §4. Those three have been accumulating since Module 16, and collecting them is the next chapter — with the measurement for each, because each of the three is believed by people who would tell you they know what volatile does.
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.
