Signed overflow¶
Level: 201 · for C and C++ programmers
One line: x + 1 > x is false at -O0 and true at -O2, from the same source — because signed overflow is undefined, so the optimizer is entitled to assume it cannot happen and delete the comparison.
The program¶
#include <limits.h>
#include <stdio.h>
/* Is there always a bigger int? */
static int has_room(int x) {
return x + 1 > x;
}
int main(void) {
printf("%d\n", has_room(INT_MAX));
return 0;
}
has_room is a bounds check. Somebody wrote it to find out whether adding one would overflow.
What it did¶
-O0 0 0 0
-O2 1 1 1
Both builds are correct. That is the point of the page.
At -O0 the addition wraps to INT_MIN, which is less than INT_MAX, so the comparison is false and you get 0 — the answer the author expected, from behaviour the standard never promised. At -O2 the optimizer reasons: signed overflow is undefined, therefore x + 1 never overflows, therefore x + 1 > x is true for all x, therefore the whole function is return 1. It does not compute anything at run time.
This is the sharpest form of a thing worth internalising: undefined behaviour is not "an unpredictable value". It is a premise the optimizer may reason from. The check did not return a wrong number; the check was deleted, because it was written in terms of an event the compiler is allowed to assume never occurs. The same reasoning removes null checks after a dereference, and removes loop-termination conditions that depend on a counter wrapping.
-fsanitize=signed-integer-overflow finds it, and its message is the clearest statement of the bug:
signed_overflow.c:6:14: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
The portable way to write the check without the undefined behaviour is to not perform the addition: x > INT_MAX - 1. Compilers also offer __builtin_add_overflow, and C23 adds <stdckdint.h> for the same job.
Why the standard allows it¶
Signed integer overflow is undefined behaviour ↗; unsigned arithmetic wraps and is fully defined. The asymmetry is historical — signed representations other than two's complement were once real, and by the time they were not, the undefinedness had become something optimizers depended on for loop analysis. -fwrapv turns it into defined wrapping and costs you some of that.
What Rust does instead¶
Overflow is defined, and you say which definition you want by choosing a method name:
let x = i32::MAX;
x.checked_add(1) // None — "tell me if it does not fit"
x.wrapping_add(1) // -2147483648 — "wrap, deliberately"
x.saturating_add(1) // 2147483647 — "clamp at the edge"
x.overflowing_add(1) // (-2147483648, true) — "wrap, and tell me you did"
Verified output of signed_overflow.rs — regenerated by tools/run_examples.py, never hand-typed.
checked_add(1) -> None
wrapping_add(1) -> -2147483648
saturating_add(1) -> 2147483647
overflowing_add(1) -> (-2147483648, true)
x + 1 -> panicked: attempt to add with overflow
Nothing is undefined and nothing is assumed. The decision that C leaves to the standard and the optimizer is a word in the method name, sitting in the diff where a reviewer can see it.
The trap, which is real¶
Plain x + 1 is the one to be careful with, and the benefits list's "integer overflow is defined" is doing some work to hide it. It is defined as two different things, chosen by the build profile: a debug build panics, a release build wraps. So an overflow bug can pass every test you run and wrap in production.
That is the last line of the output above — a caught panic, in a build with debug-assertions on. The same program compiled with -O prints -2147483648 and carries on. Neither is undefined behaviour, and neither is silent corruption; it is still a considerable improvement on the C row. But "defined" is not "the same everywhere", and if the distinction matters in a given line, name the behaviour you want instead of using +.
Where the compiler can see the overflow, it refuses outright:
error: this arithmetic operation will overflow
--> max_plus_one.rs:3:20
|
3 | println!("{}", x + 1);
| ^^^^^ attempt to compute `i32::MAX + 1_i32`, which would overflow
|
= note: `#[deny(arithmetic_overflow)]` on by default
Deny by default, so it is an error. It only fires when the operands are constant-foldable — which is why the runnable example above hides i32::MAX behind std::hint::black_box to get past it, exactly as the constant-index refusal works on the bounds-checking page.
If you are coming from another language¶
- C++ — same rule, same undefined behaviour, and the same
-fwrapvif you want out. What C++20 did settle is the representation: signed integers are now required to be two's complement, soINT_MINis-2147483648everywhere. Overflow stayed undefined regardless, which is the distinction worth holding onto — the bit pattern is specified and the operation still is not. - Python —
intis unbounded, so this bug does not exist and the surprise runs the other way: a Rusti32is a fixed 32 bits, and choosing the width is now part of writing the program. When you want Python's behaviour you reach for a big-integer crate and pay for it explicitly. What transfers is that//and%on negatives already taught you that arithmetic has conventions worth checking. - ABAP — arithmetic overflow raises
CX_SY_ARITHMETIC_OVERFLOW, catchable, at the moment it happens, which is the panic behaviour and not the wrapping one. Rust wants the same question answered earlier: rather than catching the exception, you decide before the run whether this addition can exceed the range and put your answer in the method name.TYPE iis a 4-byte signed integer, so the boundary is the one on this page;TYPE int8is the 8-byte one.
Practice¶
The comparison the optimizer deletes. Explain why x + 1 > x is false at -O0 and true at -O2 in C, and be precise about what undefined licenses the compiler to do.
Then give Rust's two defined behaviours and when each applies, and the four methods that let you say which one you meant. Finish with why if (x + 1 < x) is not an overflow test in C — and why unsigned types do not have this problem.
Solution
signed_overflow_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: the comparison the optimizer is allowed to delete.
//!
//! rustc --edition 2024 signed_overflow_kata.rs -o /tmp/sok && /tmp/sok
//! rustc --edition 2024 -O signed_overflow_kata.rs -o /tmp/sok && /tmp/sok
fn main() {
let x = i32::MAX;
println!("THE C SHAPE");
println!(" int x = INT_MAX; if (x + 1 > x) ...");
println!(" At -O0 the addition wraps and the comparison is false. At -O2");
println!(" the optimizer reasons: signed overflow is UNDEFINED, therefore");
println!(" x + 1 > x cannot be false, therefore the branch is dead -- and");
println!(" deletes it. Same source, two behaviours, and neither compiler");
println!(" is wrong.");
println!();
println!("RUST'S ANSWER IS TO DEFINE IT, TWICE");
println!(" debug builds: overflow PANICS -- 'attempt to add with overflow'");
println!(" release builds: overflow WRAPS, two's complement");
println!(" Both are defined behaviour. The optimizer may not assume the");
println!(" addition cannot overflow, so no branch anywhere is deleted on");
println!(" that reasoning, and the two builds differ in what they DO");
println!(" rather than in what is true.");
println!();
println!("AND WHEN YOU CARE, YOU SAY WHICH");
println!(" x = i32::MAX = {x}");
println!(" x.checked_add(1) {:?} <- None: ask, and handle it", x.checked_add(1));
println!(" x.wrapping_add(1) {} <- wrap, on purpose", x.wrapping_add(1));
println!(" x.saturating_add(1) {} <- clamp at the maximum", x.saturating_add(1));
println!(" x.overflowing_add(1) {:?} <- the value and a did-it-wrap flag",
x.overflowing_add(1));
println!();
println!(" Four named behaviours where C has one undefined one. The point");
println!(" is not that Rust picked a better default -- it is that the");
println!(" choice is written at the call site, so a reader can see which");
println!(" one this line meant.");
println!();
println!("THE PART THAT TRANSFERS BACK TO C");
println!(" 'Undefined' does not mean 'unpredictable result'. It means the");
println!(" compiler may assume it never happens and rewrite the code around");
println!(" that assumption -- so the damage lands somewhere else entirely,");
println!(" usually in a check you wrote to prevent it. That is why");
println!(" `if (x + 1 < x)` is not an overflow test in C, and why the");
println!(" correct test compares against INT_MAX before adding.");
println!();
println!("A DETAIL WORTH KNOWING");
println!(" Unsigned overflow in C is DEFINED to wrap, so the same trick is");
println!(" not available to the optimizer there. The undefined-ness is a");
println!(" property of the signed types alone -- which is why so much");
println!(" hardening advice is 'use unsigned', and why that advice brings");
println!(" its own family of wrap-around bugs.");
assert_eq!(x.checked_add(1), None);
assert_eq!(x.wrapping_add(1), i32::MIN);
assert_eq!(x.saturating_add(1), i32::MAX);
}
Verified output of signed_overflow_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
THE C SHAPE
int x = INT_MAX; if (x + 1 > x) ...
At -O0 the addition wraps and the comparison is false. At -O2
the optimizer reasons: signed overflow is UNDEFINED, therefore
x + 1 > x cannot be false, therefore the branch is dead -- and
deletes it. Same source, two behaviours, and neither compiler
is wrong.
RUST'S ANSWER IS TO DEFINE IT, TWICE
debug builds: overflow PANICS -- 'attempt to add with overflow'
release builds: overflow WRAPS, two's complement
Both are defined behaviour. The optimizer may not assume the
addition cannot overflow, so no branch anywhere is deleted on
that reasoning, and the two builds differ in what they DO
rather than in what is true.
AND WHEN YOU CARE, YOU SAY WHICH
x = i32::MAX = 2147483647
x.checked_add(1) None <- None: ask, and handle it
x.wrapping_add(1) -2147483648 <- wrap, on purpose
x.saturating_add(1) 2147483647 <- clamp at the maximum
x.overflowing_add(1) (-2147483648, true) <- the value and a did-it-wrap flag
Four named behaviours where C has one undefined one. The point
is not that Rust picked a better default -- it is that the
choice is written at the call site, so a reader can see which
one this line meant.
THE PART THAT TRANSFERS BACK TO C
'Undefined' does not mean 'unpredictable result'. It means the
compiler may assume it never happens and rewrite the code around
that assumption -- so the damage lands somewhere else entirely,
usually in a check you wrote to prevent it. That is why
`if (x + 1 < x)` is not an overflow test in C, and why the
correct test compares against INT_MAX before adding.
A DETAIL WORTH KNOWING
Unsigned overflow in C is DEFINED to wrap, so the same trick is
not available to the optimizer there. The undefined-ness is a
property of the signed types alone -- which is why so much
hardening advice is 'use unsigned', and why that advice brings
its own family of wrap-around bugs.
See also¶
- Meet the byte — how wide a number actually is, and what the suffix on the type means
- Buffer overruns — the other run-time check, and the one whose default does not move
- What the optimizer does — the machinery that deleted
has_room - The bugs Rust is a reply to — the other eight
Po polsku¶
x + 1 > x jest fałszem przy -O0 i prawdą przy -O2, z tego samego źródła. To nie jest błąd optymalizatora: przepełnienie liczby ze znakiem jest w C zachowaniem niezdefiniowanym, więc kompilator ma pełne prawo założyć, że nie zachodzi — a skoro nie zachodzi, porównanie jest zawsze prawdziwe i można je po prostu wyciąć.
Ten przykład wart jest zapamiętania, bo obala najczęstszy skrót myślowy o UB, powtarzany też po polsku: że „niezdefiniowane" znaczy „wyjdzie jakaś dziwna liczba". Nie znaczy. Kompilator wnioskuje wstecz z założenia, że sytuacja nie występuje, i usuwa kod, który przy tym założeniu jest zbędny — zniknąć może właśnie to sprawdzenie, które napisałeś, żeby przepełnieniu zapobiec.
Rust rozdziela dwie sprawy, których C nie rozdziela. Przepełnienie jest zdefiniowane: w trybie debug program panikuje, w release zawija się modulo. Obie odpowiedzi są opisane, żadna nie jest UB, więc żaden optymalizator niczego z tego nie wywnioskuje. Jest tu jednak prawdziwa pułapka i trzeba o niej mówić uczciwie: skoro --release zawija po cichu, to sam Rust nie chroni przed błędną liczbą — chroni przed wnioskowaniem z niemożliwego. Gdy wynik ma znaczenie, sięgasz po checked_add, saturating_add albo wrapping_add, a nazwa mówi, co ma się stać.
Szukaj po polsku: przepełnienie liczb ze znakiem · zachowanie niezdefiniowane · checked_add · rust integer overflow release · rust wrapping_add