Bits, Overflow, and Signed Numbers

CS 301 Lecture, Dr. Lawlor

Arithmetic In Binary

We can do arithmetic in binary or hex by hand just like we do in decimal.  To add, line up the digits, add corresponding digits, and if the per-digit sum is greater than the base, carry to the next digit.  Easy, right?  To subtract, do the exact same thing, but "borrow" from the next digit if the per-digit difference is less than 0.

For example, in binary 01+01=10 because the first digit overflows, and the extra "1" is carried to the next digit.  Similarly, 1111+1=10000 because all the digits overflow in sequence.  In general, adding a "1" will cause carries to ripple up through ones, flipping them to zeros, until it finally reaches a zero, which it will flip to a one.

Addition in hex is exactly the same--it's a bit tricker to add the larger digits involved, but carries work exactly the same.  So  0x8+0x8=0x10, and 0xffff+0x1=0x10000.

Subtraction and Negative Numbers in Binary

Subtraction in binary seems really bizarre until you remember than 10-1=1 in binary--that is, "1" is the one-less-than-zero digit in binary; just like "9" is the corresponding one-below-zero digit in decimal.  So when you have to borrow, you just flip the zeros to ones until you hit a one. 

Subtraction actually shows you how to represent negative numbers in binary.  Consider -1==0-1: you just keep on flipping zeros to ones forever, "borrowing" against the future that never comes (just like the US banking system!).  So -1 is represented (in principle) by an infinite number of ones, just like +1 is represented (in principle) by an infinite number of zeros followed by a one.  In practice, we only store a finite number (usually 32 or 64) of bits, so on a 32-bit machine we define 32 ones as -1.  The highest bit, then, can be thought of as a "sign bit"--negative numbers have that bit set, positive numbers have that bit clear.  Suprisingly, it make sense to think of the sign bit in digit n as having value -2n, instead of value 2n for unsigned numbers.  What's weirder is that addition and subtraction are exactly the same for signed or unsigned numbers--try it!  (All other operations are different, though: comparison, multiplication, division, etc.)

Signed versus unsigned numbers are clearer if you draw them out for a 3-bit machine:
Bit Pattern
Unsigned Value
Signed Value
000
0
0
001
1
1
010
2
2
011
3
3
100
4
-4
101
5
-3
110
6
-2
111
7
-1

Note that languages other than Java support "signed" as well as "unsigned" integer types explicitly.  In C/C++/C#, typecasting to an unsigned value does nothing to the bits, and is hence a zero-clock operation.  One way to speed up a "zero to n" bounds test on a signed value is to turn it into an unsigned value and just compare against n--that is, "if ((x>=0)&&(x<n)) ..." is the same as "if (((unsigned int)x)<n) ..." do exactly the same thing, because negative signed x's will turn into really huge unsigned x's!

In hex, -1 (in binary, all ones) is written in hex as 0xFFffFFff (on a 32-bit machine).

Another way to think about -1 is that it's like "99999" on a car's odometer--if you drive one more mile, you'll be at "00000", so you're really at mile -1!

The "obvious" way to represent negative numbers, just using the sign bit to store the +/- sign, is called one's complement, and has the weird behavior with zero plus the sign bit set--you get negative zero!  For this reason, the approach above with a big negative sign bit, called two's complement, is the standard today.

Accessing the Bits of an Integer

If you'd like to stare at, or modify the sign bit inside your integer, you can use the bitwise operators to access them.  There's a common trick which is to use bitwise AND to pull out bits:
void poke_at_int(int v) {
std::cout<<"For the int "<<v<<": ";

if (v&(1<<31)) std::cout<<"The sign bit is set (1).";
else std::cout<<"The sign bit is clear (0).";

std::cout<<"\n";
}

int foo() {
poke_at_int(0);
poke_at_int(+1);
poke_at_int(-1);
return 0;
}

(Try this in NetRun now!)

This prints:

For the int 0: The sign bit is clear (0).
For the int 1: The sign bit is clear (0).
For the int -1: The sign bit is set (1).
Program complete.  Return 0 (0x0)

You can also use bitwise OR to stick on new bits:
void poke_at_int(int v) {
std::cout<<"For the int "<<v<<": ";

v=v|(1<<2);
std::cout<<"Setting bit 2 makes the value "<<v;

std::cout<<"\n";
}

int foo() {
poke_at_int(0);
poke_at_int(+1);
poke_at_int(+2);
poke_at_int(+4);
poke_at_int(-1);
return 0;
}

(Try this in NetRun now!)

This prints:

For the int 0: Setting bit 2 makes the value 4
For the int 1: Setting bit 2 makes the value 5
For the int 2: Setting bit 2 makes the value 6
For the int 4: Setting bit 2 makes the value 4
For the int -1: Setting bit 2 makes the value -1
Program complete. Return 0 (0x0)
Note that 1<<2 is 4.  Setting this bit on the value 4 (or 7, or 12, or...) does nothing, since that bit is already set.  Setting this bit on -1 does nothing, since -1 has all its bits set already.

Crazy Speed via Bitwise Operators

There are some incredible, and incredibly bizarre, tricks using bitwise operators.  Sean Anderson has an impressive collection of these tricks, but don't follow the link until you've read and digested everything we've said here so far!

A good simple beginner entry:
	int x, y;               // input values to compare signs
bool f = ((x ^ y) < 0); // true iff x and y have opposite signs
This works using XOR (exclusive OR) on the sign bits of x and y.  If these bits have different values, then XOR returns 1 (look at the truth table for XOR), so x^y is negative.

They get a lot more complicated from there!

Examples from Lecture