Integer Arithmetic
Fixed-width integer arithmetic is arithmetic modulo a power of two: add past the top and the result wraps. We work out unsigned and two's-complement addition and the rules that detect their overflow, why negation is a complement-plus-one, how multiplication truncates to the low-order bits and how compilers turn constant multiplies into shifts and adds, why C declares signed overflow undefined, and the bias fix that keeps shift-based signed division rounding toward zero.
╌╌╌╌
A -bit register has only states, so arithmetic that should produce a -bit answer has nowhere to put the extra bit. The hardware simply drops it, and the result is arithmetic modulo : numbers live on a circle of circumference , and adding past the top wraps around to the bottom. This single fact — wrap-around — explains unsigned and signed overflow, complement-and-add-one negation, the low-order half of a product, and why a right shift is a division. Building on integer representation, this lesson works through the arithmetic the ALU actually performs.
Modular addition and unsigned overflow
For two unsigned values in , the true sum may reach up to , needing bits. The machine keeps only the low , so the stored result is the true sum reduced modulo :
Overflow is the second case: the true sum reached or passed , the th bit was discarded, and the stored value is too small.
Because the discarded bit is worth exactly , a wrapped sum is always smaller than the true one, and that gives a detection rule requiring no extra width.
Both directions are one line. If there is no overflow, because . If there is, , and since the correction drags below . In C the test is safe to write after the addition, because unsigned arithmetic is defined to wrap:
/* unsigned overflow: the wrapped sum is smaller than either operand */
int uadd_overflows(unsigned u, unsigned v) {
return u + v < u; /* defined behavior: unsigned wraps mod 2^w */
}
Subtraction wraps in the mirrored direction. The difference is exact
when and otherwise wraps up by , so unsigned subtraction overflows
(borrows) exactly when . The classic casualty is len - 1 when len is
zero: the result is , and a loop bounded by it walks off the end of a
buffer.
The carry chain, one column at a time
The wrap falls out of how the
adder works. Binary addition proceeds column by column from the right, exactly
like decimal long addition: column adds , , and the incoming carry
, emits the low bit of that count as the sum bit , and passes the high
bit along as . The carry that emerges from the last column would be
worth , and a -bit register has no column for it, so it is dropped. That
dropped carry is the in the overflow case above.
Every column here carries: in the ones column, then , and so on up the chain. How fast that chain can run, and how hardware shortcuts it, is the subject of the ALU lesson; for now the point is that one adder circuit produces the modular sum, and the carry-out is a one-bit signal that a wrap happened.
Two's-complement addition and signed overflow
Two's complement lets the very same adder work for signed values. Add the bit patterns, drop the carry-out, and reinterpret the kept bits with . The result equals the true sum unless it leaves the signed range , in which case it wraps by :
Signed overflow has a cleaner signature than the unsigned case: it can happen only when both operands share a sign, and it always produces a result of the opposite sign. Two positives summing to a negative is positive overflow; two negatives summing to a non-negative is negative overflow. Adding numbers of opposite sign can never overflow, since the true sum lies between them.1
For example, consider three 4-bit additions. All three run through the same adder; only the diagnosis differs.
- : , which reads as . Two positives produced a negative: positive overflow, off by from the true .
- : ; dropping the carry leaves . Two negatives produced a positive: negative overflow, off by from .
- : ; dropping the carry leaves , which is correct. A carry-out occurred, yet no signed overflow: the carry-out flags unsigned wrap, and the two signals are independent.
That last case is worth dwelling on, because the hardware reports both. An x86
addition sets the carry flag CF (unsigned overflow) and the overflow flag OF
(signed overflow) on every add, and the branch instruction chosen afterward
decides which flag matters — the bits themselves carry no signedness.
The true sum of two -bit signed operands spans ,
less than one full turn of the circle in each direction, so a single
correction always lands the stored result back in range:
Unlike the unsigned case, C offers no safe after-the-fact test — by the time the sum exists, the behavior is already undefined, a point taken up below. The portable detection compares against the limits before adding: positive overflow occurs iff and , negative iff and , both computed entirely in range.
Negation: complement and add one
Because addition is modular, the negative of is whatever you add to reach . That value is , and there is a bitwise shortcut: flip every bit (which gives , since ) then add one.
At : , complement , plus one .
The one exception is . Negating it should give , which exceeds and is not representable, so wraps back to itself. This is the arithmetic shadow of the asymmetric range from the previous lesson: .
int x = 5; /* 0x00000005 */
int neg = ~x + 1; /* 0xfffffffb == -5, same as -x */
int tmin = INT_MIN; /* -2147483648 */
/* -tmin overflows back to INT_MIN: ~tmin + 1 == tmin */
Signed overflow is undefined in C, and compilers act on it
Everything above describes what the hardware does: x86-64 wraps signed sums
exactly as it wraps unsigned ones. The C language makes a sharper distinction.
Unsigned arithmetic is defined to wrap modulo , which is why u + v < u
was a legal test. Signed overflow is undefined behavior: the standard does
not promise a wrap, an exception, or anything else, and a compiler is entitled
to assume it never happens.2
That assumption has real consequences. Because x + 1 can never overflow in a program the
compiler considers correct, the comparison x + 1 > x is always true for
signed x, and optimizers fold it to the constant 1 — deleting exactly the
guard a defensive programmer meant to write. Loop analyses lean on the same
license: in for (int i = 0; i <= n; i++) the compiler may assume i never
wraps, which lets it prove the loop terminates and vectorize it.
#include <limits.h>
/* looks like a wrap test; an optimizer may fold it to 0 */
int bad_check(int x) { return x + 1 < x; }
/* test BEFORE adding, using only in-range arithmetic */
int add_overflows(int u, int v) {
if (v > 0) return u > INT_MAX - v;
return u < INT_MIN - v;
}
The rule of thumb: on the machine, signed and unsigned addition are the same
instruction; in C, they are different contracts. When wraparound is the intended
behavior — hashes, checksums, sequence numbers — compute in unsigned and cast,
keeping every operation on the defined side of the language.
Multiplication and the low-order truth
The full product of two -bit numbers needs up to bits, but a C multiplication that stores into a -bit type keeps only the low bits. Those low bits are identical whether the operands are read as signed or unsigned. The high bits differ, but the part you keep does not.
The reason is that signed and unsigned values of the same bits differ only by
multiples of (recall adds to negatives), and those multiples
vanish under . So a single multiply instruction serves both
interpretations, just as one adder served both additions.3 Because the kept
half is taken mod , a multiplication can silently overflow with no signal at
all — a frequent source of buffer-size bugs when count * sizeof(elem) quietly
wraps to a small allocation.
Multiplying by constants: shifts and adds
A hardware multiply has historically cost ten or more clock cycles against one
for a shift or an add, and the gap has never fully closed. Compilers therefore
rewrite x * K for a constant K as a short sequence of shifts, adds, and
subtracts. The starting point is that a left shift multiplies by a power of two:
x << k computes (for both encodings, since the low bits behave
identically). Any constant then decomposes along its binary digits. Take
:
Three operations replace one multiply, and a run of ones does even better.
Since , the same product is
: two operations. In general each run of
consecutive ones from bit up to bit contributes
, so a constant like costs two
instructions no matter how long its run of ones.4 On
x86-64 the compiler leans on lea, which computes , , or
in a single address calculation:
leaq (%rdi,%rdi), %rax # rax = 2x
salq $4, %rdi # rdi = 16x
subq %rax, %rdi # rdi = 16x - 2x = 14x
In this notation $ marks a constant and %rdi a register, syntax made precise
in the
machine-level module.
The reverse direction is harder: division by a constant is far slower in hardware (tens of cycles) and has no shift-and-add decomposition, though for powers of two a shift still works, as the next section shows.
Division by powers of two
Multiplication by is a left shift, x << k, appending zero bits;
division by is a right shift, but the correct shift depends on signedness,
and that is where the two right shifts diverge.
A logical right shift fills the vacated high bits with zeros, the correct
behavior for unsigned division: u >> k computes . An
arithmetic right shift copies the sign bit into the vacated positions,
preserving the sign of a two's-complement value. In C, right shift on an
unsigned is logical and on a signed int is arithmetic.5
The rounding bias for signed division
Arithmetic right shift alone does not divide signed numbers correctly, because
shifting rounds down (toward ) while C integer division must round
toward zero. For a non-negative dividend the two agree, but for a negative one
they differ whenever the division is inexact: should be , yet
(-7) >> 1 rounds down to .
The fix is to bias a negative dividend up by before shifting, which nudges any negative inexact result up to the toward-zero answer. For ,
so the full expression for either sign is (x < 0 ? x + (1 << k) - 1 : x) >> k.
Biasing a positive inexact dividend would over-round: , not
.
The bias is applied only when ; non-negative dividends already shift
correctly. This is precisely the code a compiler emits for x / 2 when x is
signed.
/* divide a signed int by 2^k, rounding toward zero like C's / */
int div_pow2(int x, int k) {
int bias = (x >> 31) & ((1 << k) - 1); /* bias = 2^k-1 if x<0, else 0 */
return (x + bias) >> k;
}
/* div_pow2(-7, 1) == -3, matching (-7)/2; (-7)>>1 alone gives -4 */
The x >> 31 extracts the sign bit (all ones if negative, all zeros otherwise),
so the bias is applied only to negative dividends — non-negative ones already shift
correctly. With that one correction, a shift reproduces division exactly, which is
why power-of-two divisors are the fastest case in any integer-heavy code.
Dividing by a non-power-of-two
CS:APP stops at powers of two, where a single shift suffices. But compilers turn
every constant divisor into shifts and multiplies, not just the powers of two,
and the technique is worth knowing because it explains why x / 10 compiles to no
div instruction at all. The idea is multiply by a reciprocal: to divide by
a constant , precompute an integer approximation of scaled up by a power
of two, multiply, then shift back down. Dividing by , for instance, a
compiler multiplies by the magic constant and shifts the 64-bit product right by — equivalently,
keeps the high 32 bits (a shift of ) then shifts more — which reproduces
exactly for every 32-bit int.6
The reason this is exact and not merely close is a small theorem: for a divisor
there exists a shift and a multiplier such that
for all in range, and the
error introduced by rounding up is absorbed by choosing large enough.
Granlund and Montgomery worked out the exact conditions and the algorithm every
compiler now uses (Granlund & Montgomery, Division by Invariant Integers using Multiplication,
PLDI 1994).6 The signed case adds the same
toward-zero bias correction this lesson developed for powers of two.
The payoff is a division replaced by a multiply and a shift — perhaps five cycles
instead of twenty or more — for any divisor the compiler can see at compile time.
The catch, and the reason x / n for a variable n stays slow, is that the
magic constant depends on the divisor, so it can only be precomputed when the
divisor is a literal. Performance-sensitive code that divides by the same runtime
value many times sometimes computes the magic constant once by hand for exactly
this reason.
These integer rules give exact answers within a fixed range. The next lesson trades that exactness for enormous range, encoding real numbers in floating point.
Footnotes
- Bryant & O'Hallaron, CS:APP, §2.3.2 — Two's-Complement Addition: the same adder serves signed and unsigned, and the same-sign-in / opposite-sign-out overflow criterion. ↩
- Bryant & O'Hallaron, CS:APP, §2.3.2 — Two's-Complement Addition: the C standard leaves signed overflow unspecified; two's-complement wrapping is what most hardware does, not what the language promises. ↩
- Bryant & O'Hallaron, CS:APP, §2.3.4–2.3.5 — Multiplication: the low bits of a product agree for signed and unsigned operands; a single instruction suffices. ↩
- Bryant & O'Hallaron, CS:APP, §2.3.6 — Multiplying by Constants: replacing constant multiplies with shifts, adds, and subtractions, including the run-of-ones form . ↩
- Bryant & O'Hallaron, CS:APP, §2.3.7 — Dividing by Powers of Two: logical vs. arithmetic right shift and the bias that makes signed division round toward zero. ↩
- Granlund & Montgomery,
Division by Invariant Integers using Multiplication,
PLDI 1994: the algorithm every mainstream compiler uses to replace division by a compile-time constant with a multiply-high and a shift, including the exact conditions on the magic constant and shift that make over the whole input range. ↩ ↩2
╌╌ END ╌╌