Why Modifying a Variable Twice Causes Undefined Behavior in C
Modifying a scalar variable more than once between consecutive
sequence points—or, in modern C terminology, without an intervening
sequenced-before relationship—leads to undefined behavior because the C
language standard intentionally leaves the evaluation order of most
subexpressions unspecified. This design choice grants compilers the
freedom to reorder instructions, interleave memory accesses, and
optimize register allocations for maximum hardware performance.
Consequently, expressions like i = i++ or
(i++) + (i++) produce no guaranteed result, potentially
causing unpredictable runtime behavior, register corruption, or
inconsistent memory states across different platforms and optimization
levels.
The Role of Sequence Points and Sequencing
In C99 and earlier standards, the language relied on the concept of sequence points—specific boundaries in program execution (such as the end of a full expression marked by a semicolon, or before a function call executes) where all side effects of previous evaluations are guaranteed to be complete, and no side effects of subsequent evaluations have begun. Starting with C11, the standard refined this terminology using sequenced-before, sequenced-after, and unsequenced relations to define order.
According to the ISO C standard:
- Between the previous and next sequence point (or if two evaluations are unsequenced), an object's stored value can be modified at most once by the evaluation of an expression.
- Furthermore, the prior value shall be accessed only to determine the value to be stored.
Violating this rule immediately invokes undefined behavior (UB).
Lack of Specified Operator Evaluation Order
Most binary operators in C, including addition (+),
multiplication (*), and assignment (=), do not
introduce sequence points between their operands. While operator
precedence dictates how expressions are grouped syntactically, it does
not dictate the chronological order of execution.
For example, in the expression:
int result = (x++) + (x++);The compiler is entirely free to evaluate the left operand first, the right operand first, or interleave the machine instructions generated by both operands.
Compiler Optimization and Hardware Realities
At the machine level, modifying a variable typically requires three discrete operations:
- Loading the variable from memory into a CPU register.
- Performing the arithmetic or alteration within the register.
- Storing the updated register value back into memory.
When two modifications occur without an intervening sequence point,
the compiler’s optimizer assumes it can pipeline and reorder these load
and store steps arbitrarily. It may load x into two
distinct registers, perform two independent operations, and write them
back in an arbitrary order, overwriting one modification with another.
Alternatively, dead-code elimination and common subexpression
elimination passes can transform the code in ways that assume such
conflicting writes are impossible.
Why the Standard Specifies Undefined Rather than Unspecified Behavior
If the C standard specified this as unspecified behavior, implementations would be forced to document or bound the possible outcomes, forcing compilers to generate defensive synchronization code to ensure one write strictly completes before the next begins. By classifying it as undefined behavior, the standard removes all constraints from the compiler. This ensures that compilers can emit the leanest, fastest possible assembly instructions without needing to track and resolve conflicting side effects occurring within the same basic step of computation.