Skip to content
← Prism
Prism
Overview
Features
defer orelse zeroinit raw bounds-check auto-unreachable auto-static check
C in practice
goto skips init tag namespace struct padding strict aliasing char signedness signed overflow VLAs
Spec Draft Releases Blog
GitHub ↗

Signed overflow and shiftsthe same source, two different programs

Signed integer overflow is undefined behaviour, which means the compiler assumes it never happens and optimizes on that assumption. One loop below exits after three iterations at -O0 and never terminates at -O2. Neither build is wrong.

#The loop that never ends

int count = 0;
for (int i = INT_MAX - 2; i > 0; i++) {
    count++;
    if (count > 5) break;
}
printf("iterations before exit: %d\n", count);

Read it as a machine and the loop exits: i climbs to INT_MAX, wraps to INT_MIN, and i > 0 is false. Read it as C and i++ at INT_MAX has no defined behaviour, so the compiler may assume i only ever increases, so i > 0 is always true, so the exit test is dead code. GCC 11.4.0:

-O0
iterations before exit: 3
-O2
(does not terminate)

The break saves it here only because count is a separate variable the compiler cannot reason away. Take it out and the -O2 build has no exit at all. The smaller version of the same thing:

int no_overflow(int x) { return x + 1 > x; }
/* called with INT_MAX */
BuildINT_MAX + 1 > INT_MAX
-O0true
-O2true
-O2 -fwrapvfalse

GCC folds x + 1 > x to the constant 1 even at -O0, because the fold is a front-end simplification rather than an optimization pass. -fwrapv defines the overflow as wrapping, which makes the fold invalid, and the answer flips. Same expression, same compiler, one flag.

#Why the optimizer is allowed to

C23 6.5.1p5: if an exceptional condition occurs during the evaluation of an expression, that is, if the result is not mathematically defined or not in the range of representable values for its type, the behavior is undefined. Signed overflow is the headline case. Unsigned arithmetic is excluded, because 6.2.5 defines unsigned types to obey modular arithmetic, so UINT_MAX + 1u is 0 and always has been.

The reason the rule survives is that the assumption is worth real code quality. If i can wrap, the compiler cannot prove a loop with int i terminates, cannot promote i to a 64-bit register on a 64-bit target without inserting truncations, and cannot strength-reduce i * 4 in an address computation. Those are not micro-optimizations; they decide whether a loop vectorizes.

The cost is that the assumption is invisible. Nothing in the source says "this must not overflow", and no diagnostic fires when it does.

#Shifts

Shifts have three separate rules and people generally know one of them. GCC 11.4.0, int is 32 bits:

ExpressionPrintedStatus
1 << 31-2147483648undefined: not representable in int
1 << 321undefined: count ≥ width
-1 << 1-2undefined: left operand is negative
-1 >> 1-1implementation-defined, not undefined

1 << 32 printing 1 is the one worth staring at. The hardware masked the shift count to 5 bits, so it shifted by 0. This is not hypothetical variation: x86 truncates a 32-bit shift count to 5 bits and gives you a shift by 0, while PowerPC truncates to 6 bits and gives you 0. Two mainstream architectures, two different answers for the same expression, which is exactly why the standard declines to pick one.

C23 6.5.8p3 makes the count rule explicit: if the right operand is negative or greater than or equal to the width of the promoted left operand, the behavior is undefined. p4 covers the value: for a signed left operand, the result is defined only if the operand is nonnegative and the mathematical result is representable in the result type. Otherwise undefined. Right shift of a negative value is the odd one out, demoted to implementation-defined by p5, and GCC documents arithmetic shift.

#C23 mandated two’s complement and changed nothing

C23 removed sign-magnitude and ones' complement from the standard. 6.2.6.2 now says outright that the sign representation "is called two's complement", and previous editions permitted others. The reasonable expectation is that this makes 1 << 31 well defined, since the bit pattern is now fully determined.

It does not. C23 6.5.8p4 still requires the left operand to be nonnegative and the result to be representable, and Annex J.2 still lists as undefined "an expression having signed promoted type is left-shifted and either the value of the expression is negative or the result of shifting would not be representable in the promoted type". The representation was fixed; the operation was left alone.

So 1u << 31 is the portable spelling and 1 << 31 is not, in C23 exactly as in C99. If you are setting the top bit of a 32-bit mask, the u is not decoration.

#What each tool catches

FlagEffectCost
-fwrapvDefines signed overflow as two's complement wrapping. The loop above terminates.Blocks the loop and address-arithmetic assumptions described above.
-ftrapvTraps at runtime. The example aborts with SIGABRT, exit 134.Real. int add(int a, int b) { return a + b; } at -O2 compiles to a call to __addvsi3 in libgcc instead of one instruction. Less maintained than the sanitizer.
-fsanitize=signed-integer-overflowReports and continues: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'Test builds only. Needs a test that reaches the overflow.
-fsanitize=shiftCatches all three shift cases by name, including left shift of 1 by 31 places cannot be represented in type 'int'.Same.
-Wshift-count-overflowCompile-time, on by default with no flags at all. Catches 1 << 32.Only when the count is a constant. Put the 32 in a variable and it goes silent.

One measured caveat on the sanitizer. Running -fsanitize=undefined over the x + 1 > x example reports nothing, at -O0 or -O1, because GCC folded the comparison before the instrumentation pass ever saw an addition. The sanitizer catches the overflow when the operands come from outside the translation unit and cannot be folded. UBSan is a good tool and it is not a proof.

#Writing it so it cannot overflow

Check before, not after. if (a > INT_MAX - b) is defined; if (a + b < 0) is the overflow you are trying to detect.

Use the builtins. __builtin_add_overflow(a, b, &out) is supported by GCC and Clang, returns whether the operation overflowed, and writes the wrapped result regardless. Called with INT_MAX and 1 it returns 1 and stores -2147483648. It compiles to the add plus a flag test, cheaper than a manual range check, and it cannot be optimized away on the grounds that overflow is impossible.

C23 standardises the same idea as ckd_add, ckd_sub and ckd_mul in <stdckdint.h> (7.20). Check before reaching for it: GCC 11.4.0 does not ship the header even under -std=c2x, and the builtins are what you have until the toolchain catches up.

Use unsigned where you want wrapping. Hashes, checksums, ring buffer indices, PRNGs. Unsigned overflow is defined, so the wrap you are relying on is in the language rather than in your compiler's mood.

Put u on shift literals. 1u << 31, or UINT32_C(1) << 31 when the width matters.

Do not ship -fwrapv as a fix without knowing what it costs you. It is a legitimate choice, and it is a project-wide performance decision, not a bug fix.

#How Prism handles it

Arithmetic semantics are not something Prism changes. It transforms declarations and control flow, then hands the result to your compiler, so -fwrapv, -ftrapv and -fsanitize= pass through and behave as they would without it.

Where the two topics meet is bounds-check: an index computed by arithmetic that overflowed is a wrong index, and a bounds check catches the access even when nothing caught the arithmetic. That is a second line of defence rather than a fix for the overflow.

#Further reading

Measured on GCC 11.4.0, Ubuntu 22.04, aarch64, 32-bit int. The 1 << 32 result is a property of the shift instruction and differs by target, which is the point.