Foundations/Integer Arithmetic

Lesson 1.32,384 words

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.

Unsigned addition on the mod-2^w circle (w=4, circumference 16). Adding 11 + 7 = 18 walks past the top and lands on 2; the result is smaller than both operands, the signature of unsigned overflow.

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:

uadd_check.cc
/* 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.

Ripple addition of 1011 + 0111 (11 + 7, w = 4). Each column's carry feeds the next; the final carry-out has no column and is dropped, leaving 0010 = 2, the same wrap the circle showed.

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:

True sums of two 4-bit signed operands span [-16, 14]. The middle window [-8, 7] is stored exactly; sums past TMax = 7 wrap down by 16 and sums below TMin = -8 wrap up by 16, landing with the wrong sign.

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: .

negate.cc
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.

signed_ub.cc
#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 :

Computing 14 x 5 with no multiplier. Each left shift of x = 5 doubles it; the three shifted copies 40 + 20 + 10 sum to 70. In each row the set bits of 0000 0101 march left together.

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:

mul14.sassembly
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.

Right-shifting 1011 0100 by 2. Logical shift feeds in zeros (treats the value as unsigned); arithmetic shift copies the sign bit, preserving a negative two's-complement value as it divides.

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 .

Dividing -7 by 4 (k = 2) in 8 bits. A bare arithmetic shift rounds down to -2, but C requires -1. Adding the bias 2^k - 1 = 3 before the shift carries the low bits across, and the shift lands on -1.

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.

sdiv_pow2.cc
/* 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.

Dividing x by 10 with no divide instruction. Multiply x by the magic constant 0x66666667 (about 2^35 / 10), take the high half of the product, and shift right; the result is floor(x / 10) exactly.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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 .
  5. 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.
  6. 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 ╌╌