Foundations/Integer Representation

Lesson 1.21,782 words

Integer Representation

A fixed-width byte string is just a pattern; what makes it a number is the rule we read it by. We define unsigned encoding and two's complement — where the top bit carries a negative weight — derive the ranges UMax, TMin, and TMax, and show how the same bits reinterpret between signed and unsigned, how widening sign-extends, and what truncation throws away.

╌╌╌╌

The previous lesson left a -bit string as a raw pattern with no meaning of its own. Two rules turn that pattern into an integer. The unsigned rule reads it as a plain base-2 magnitude; the two's-complement rule reads it as a signed integer by making the top bit count against the total. Both rules cover all patterns, neither wastes one, and the machine implements both with the same adder. This lesson fixes those two encodings, the ranges they span, and the three operations — reinterpretation, extension, and truncation — that move a value between widths and meanings.

Unsigned encoding

Let be a bit vector, the most significant bit. The unsigned value reads each bit at its positional weight and sums the ones, exactly as the byte figure from the last lesson did:

Every bit carries a non-negative weight, so ranges from (all bits clear) up to a maximum when every bit is set. That maximum is one of the three numbers worth memorizing for the rest of the course:

The map is a bijection: it pairs each of the bit patterns with a distinct value in and leaves nothing out. That property, full coverage with no collisions, is what lets us speak of the unsigned value of a pattern, and it will hold for two's complement too.

Two's complement and the sign bit's weight

To represent negatives we keep every bit's weight the same except the most significant, whose weight we negate. That single change defines two's complement:

The leading term is the only difference from : the top bit, the sign bit, now contributes when set instead of . Everything below it is an ordinary unsigned magnitude. So a negative value is the sign bit's plus an unsigned offset from the lower bits.

The same eight bits read two ways. Every bit but the top keeps its positive weight; the sign bit's weight flips from +128 to -128, turning the unsigned 233 into the two's-complement -23.

Like , the map is a bijection onto its range, but the range is shifted to straddle zero. Setting only the sign bit gives the most negative value; clearing the sign bit and setting everything below gives the most positive:

Two facts follow, and both matter in practice. First, the range is asymmetric: there is one more negative number than positive, because zero occupies a slot on the non-negative side. So , and is not representable. Second, , since flipping the sign bit's weight from to shifts every negative value up by .

For the landmarks are , , ; for , , , . These are the limits a C unsigned, int, UINT_MAX, and INT_MIN/INT_MAX actually take on a typical machine.1

A short table of 4-bit values shows the two readings side by side and exactly where they diverge. The first eight patterns (sign bit clear) read the same under both rules; the last eight (sign bit set) diverge by .

BitsUnsigned Two's comp.
000000
000111
011177 (= )
10008 (= )
10019
111014
111115 (= )

The pattern 1111 is worth a second look: it is read one way and read the other, and the reason is all ones in two's complement is that borrows through every column, filling the whole word. This is worth committing to memory — an all-ones word is signed and unsigned at every width — because it turns up constantly in masks and error returns.

The number wheel: same bits, two readings

Because both encodings are bijections over the same patterns, they differ only in where they cut the circle. Counting bit patterns and reading each as unsigned gives a monotone climb; reading the top half as two's complement wraps those patterns around to the negative side.

A 4-bit number wheel. The outer ring is the bit pattern; inside, the unsigned reading climbs 0..15 while the two's-complement reading sends the top half (1000..1111) to -8..-1. The sign bit splits the circle.

The dashed boundary marks the sign bit: every pattern in the lower half has , and exactly there the two readings diverge by . Converting between the two encodings never touches the bits, only the rule we apply to them.

The asymmetry of TMin

The signed range holds one more negative value than positive, and the odd one out is itself. Every other value has a negation partner across zero: pairs with , with . The partner of would be , one step past , and no -bit pattern encodes it.

Negation mirrors the 4-bit number line across zero, pairing each value with its opposite. TMin = -8 is the exception: its mirror +8 sits one step past TMax = 7, so negating -8 hands back -8.

Ask the machine for anyway and fixed-width arithmetic (the subject of the next lesson) wraps the missing around to : negating returns . For , negate by flipping the bits and adding one: , the same pattern. Three practical consequences follow.

  • abs(INT_MIN) cannot produce the right answer, since is out of range; on a two's-complement machine the call comes back negative, and the C standard leaves it undefined.
  • The innocuous normalization if (x < 0) x = -x; fails to make one input non-negative, a real bug in binary-search midpoints and absolute-difference code.
  • The literal -2147483648 is a pitfall in C source: it parses as negation applied to the constant 2147483648, which does not fit in an int, so limits.h spells the constant as (-INT_MAX - 1).2
tmin.cc
#include <limits.h>
#include <stdlib.h>

int a = abs(INT_MIN);      /* undefined; wraps back to INT_MIN on x86-64 */
int m = -INT_MAX - 1;      /* the portable way to write TMin */

Signed ↔ unsigned: same bits, different meaning

A C cast between int and unsigned of the same width is a reinterpretation: the bit pattern is left alone and only the reading rule changes. For a value in two's complement, the unsigned reading of the identical bits is

Negative values gain because the sign bit's weight flips from to , a swing of exactly . Going the other way, an unsigned value at or above reads back as negative:

This conversion is usually silent. In C, an expression mixing signed and unsigned operands converts the signed one to unsigned, which can flip comparisons.

signedness.cc
#include <stdio.h>

int main(void) {
  int      a = -1;          /* bits: 0xffffffff */
  unsigned b = 1u;
  /* -1 is converted to unsigned 4294967295, so the test is FALSE */
  printf("%d\n", a < b);    /* prints 0, not 1 */
  printf("%u\n", (unsigned) a);  /* prints 4294967295 */
  return 0;
}

The comparison a < b looks like and should be true, but C promotes a to unsigned, turning its bits 0xffffffff into , which is not less than . The same bits, read under the unsigned rule, give the opposite answer.3

Widening: sign extension and zero extension

Moving a value into a wider type must preserve its numeric value, and the bits needed to do so depend on the encoding. For an unsigned value, prepend zeros (zero extension), since leading zeros never change a base-2 magnitude. For a two's-complement value, replicate the sign bit (sign extension), copying into every new high position.

Widening 4 bits to 8. Zero extension pads an unsigned value with 0s; sign extension copies the sign bit, so the negative -3 (1101) stays -3 (1111 1101) rather than becoming a large positive number.

Sign extension keeps the value intact: the high copies of the sign bit re-create the telescoping that leaves unchanged. A C compiler picks the right one automatically from the source type — int to long sign-extends, unsigned to unsigned long zero-extends — which is why declaring a width's signedness correctly matters.4

Truncation: dropping the high bits

The reverse, casting to a narrower type, simply discards the high-order bits. This is truncation, and unlike widening it can change the value. For an unsigned value, dropping to bits computes the result modulo :

For a signed value, the kept bits are read by on the narrower width, so the result is — the same low bits, but their top surviving bit may now act as a sign bit. Casting int to a 16-bit short, for instance, keeps 0xCFC7 and reinterprets it as the negative .

Truncating the int 53191 = 0x0000cfc7 to a 16-bit short. The dropped high half is all zeros, so no magnitude is lost, but the kept half now leads with a 1 bit, which the narrower signed type reads as -12345.
truncate.cc
int   x = 53191;            /* 0x0000cfc7 */
short s = (short) x;        /* keeps 0xcfc7 */
/* low 16 bits 0xcfc7 read as signed short = -12345 */

Truncation is information loss: the high bits are gone, and whether the surviving value is the one you wanted depends entirely on whether it fit in the narrower range. Mixing it with the signedness reinterpretation above is a reliable source of bugs, which is why the next lesson treats overflow as a first-class topic.

Why two's complement won

CS:APP presents two's complement as the signed encoding, which is accurate for every machine you will program — but it was not the only candidate, and seeing the alternatives explains why the industry converged on it. Three schemes competed in early computers, all of which reserve the top bit as a sign.

Sign-magnitude stores the magnitude in the low bits and a sign in the top bit, exactly as humans write numbers: is 1011 (sign 1, magnitude 011). It is easy to read but has two flaws that sank it: there are two zeros (0000 and 1000, positive and negative zero), wasting a pattern and complicating equality; and addition needs to inspect the signs and choose between adding and subtracting, so it cannot reuse the plain adder. One's complement negates by flipping every bit, which also produces a negative zero (1111) and needs an awkward end-around carry to add correctly. Two's complement negates by flipping and adding one, and its single decisive advantage is the one the next lesson develops in full: the same adder circuit adds signed and unsigned values with no special cases, because the encoding is arithmetic modulo .5

Three signed encodings of -3, each negating +3 = 0011. Sign-magnitude flips only the sign bit; one's complement flips all bits; two's complement flips all bits and adds one. Only two's complement has a single zero and reuses the unsigned adder.

The design is old and its origins are usually credited to von Neumann's 1945 report on the EDVAC, which proposed complement arithmetic for exactly the hardware-economy reason above.6 A representation is a hardware decision as much as a mathematical one: two's complement won because it lets one adder, one comparison, and one multiply serve both signed and unsigned data, a theme that runs through the whole next lesson.

With both encodings in hand, the next lesson asks what happens when fixed-width arithmetic runs off the end of its range — the wrap-around behavior of integer arithmetic.

Footnotes

  1. Bryant & O'Hallaron, CS:APP, §2.2.1 — Integral Data Types: the C limits.h constants UINT_MAX, INT_MIN, INT_MAX and the typical 32- and 64-bit widths.
  2. Bryant & O'Hallaron, CS:APP, §2.2.3 — Two's-Complement Encoding: the asymmetric range , and the aside on why C headers write as (-INT_MAX - 1).
  3. Bryant & O'Hallaron, CS:APP, §2.2.5 — Signed vs. Unsigned in C: implicit conversion of operands to unsigned and the comparison pitfalls it causes.
  4. Bryant & O'Hallaron, CS:APP, §2.2.6 — Expanding the Bit Representation of a Number: sign extension preserves the two's-complement value; zero extension preserves the unsigned value.
  5. Bryant & O'Hallaron, CS:APP, §2.2.2–2.2.3 — the aside contrasting two's complement with sign-magnitude and one's complement, and the observation that two's complement is the near-universal choice because it has a single zero and a uniform adder.
  6. von Neumann, First Draft of a Report on the EDVAC (1945): the early proposal to use complement arithmetic so that subtraction could be performed by the addition hardware, the hardware-economy argument that still explains two's complement today.

╌╌ END ╌╌