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:

  1. Loading the variable from memory into a CPU register.
  2. Performing the arithmetic or alteration within the register.
  3. 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.