Numbers: How the Machine Counts
Bits, binary, hex, and how negatives exist with no minus sign
Before anything else, one question worth a year: how can a machine made of matter do arithmetic? A rock doesn't count. The answer is one idea, and if it lands here, the whole machine unfolds from it. We won't rush to 'convert binary' — we'll earn the idea, because it's the bedrock the CPU, assembly, C, and every exploit you'll ever write are built on. Read it start to finish; by the end you'll build a program that lets you SEE a number as the switches it really is.
- › Explain why meaning in a computer is imposed, never intrinsic
- › Convert between binary, decimal, and hex by hand — by deriving, not memorizing
- › Say how negative numbers exist with no minus sign (two's complement), and why it's built that way
- › Write a C program that reveals any number's bits, its hex, and its signed vs unsigned reading
1 · Meaning is imposed
Deep inside the machine is a switch — a tiny thing that's either on or off. That's all it physically is. It has no idea what 'five' is. The move that created computing: we *decide*, by pure convention, to call on 1 and off 0. Nothing in physics says a switch 'is' a one — we impose that meaning. Do it for billions of switches, agree on rules for what patterns mean, and you have a computer.
Why two states, not ten (so one switch could store a decimal digit)? Not math — reliability. Two clearly-separated states (on / off, with a big gap between) are cheap to build and easy to read a billion times a second without error. Ten precise voltage levels would be a nightmare. The machine is shaped by what physics makes *trustworthy*, not what's elegant — a theme you'll see everywhere.
2 · Counting with two symbols
You already know how to read numbers — in base ten. Write 205 and each column is a power of ten: 2×100 + 0×10 + 5×1. **Binary is the exact same idea with one change: two symbols instead of ten, so each column is a power of *two*.** That's the whole difference.
bit: 1 1 0 0 1 1 0 1
power: 2^7 2^6 2^5 2^4 2^3 2^2 2^1 2^0
value: 128 64 32 16 8 4 2 1
on?: 128 + 64 + 0 + 0 + 8 + 4 + 0 + 1 = 205To go decimal → binary you recall nothing — you subtract the largest power of two that fits,
repeatedly: 205 − 128 = 77 (so bit 7 is 1), 77 − 64 = 13 (bit 6 is 1), 32 too big (bit 5 is 0)… giving
11001101. To go the other way, add the columns that hold a 1. Redoable on any number, on paper. That
gap — between deriving and recalling — is the whole difference between knowing and memorizing.
In code, you read one bit with a shift and a mask: (v >> i) & 1. Shifting v right by i
slides bit i down to the bottom; & 1 keeps only that bottom bit. You'll use this exact idiom in
the project to print a number's bits:
1void print_byte(unsigned char b) {2 for (int i = 7; i >= 0; i--) // high bit first3 putchar('0' + ((b >> i) & 1)); // read bit i: shift it down, mask it4 putchar('\n');5}6// print_byte(205) prints 110011013 · The byte, and hex
One bit answers one yes/no question — not much. So we group them: 8 bits = one byte, giving 2⁸ = 256 patterns (0 to 255 read as an unsigned number). The byte is the atom of memory — the machine addresses bytes, never single bits.
Long binary is painful to read (11001101 takes effort). Hexadecimal fixes this: it has 16
symbols (0–9 then A–F for 10–15), and because 2⁴ = 16, one hex digit is exactly four bits. So a
byte is always two hex digits — no arithmetic, just read binary in chunks of four:
1100 1101
C D -> 0xCD (which is 205 — it all lines up)
one hex digit = one nibble (4 bits); a byte = two hex digits.0xFF instantly reads as 'all 8 bits set'; 0xFF00 as 'high byte
full, low byte empty'. Decimal hides the byte boundaries; hex shows them.4 · Negatives with no minus sign
A byte is 8 switches — there's no switch for a minus sign. So how does -1 exist? You might steal
the top bit to mean 'minus' (called sign-magnitude), but then addition needs special cases and you get
*two* zeroes. The scheme actually used, two's complement, is chosen so ordinary binary addition
just works with no special case.
The rule to negate a number: flip every bit, then add 1. Find -1 in a byte, starting from +1:
+1: 0 0 0 0 0 0 0 1
flip bits -> 1 1 1 1 1 1 1 0
add 1 -> 1 1 1 1 1 1 1 1 = 0xFF <- this bit pattern IS -111111111 is 255 read as unsigned, and −1 read as signed — the bits are
identical, only the interpretation differs (section 1, made concrete). The top bit is the sign: 1 means
negative. And it's not arbitrary — because (-1) + 1 in this scheme is 11111111 + 1 = 100000000,
the 9th bit falls off the byte and you're left with 00000000 = 0. Addition 'just works' for signed
and unsigned with the *same* hardware. That's the whole reason it's built this way.The Project
Build a 'byte inspector' in C: given any number, it prints that number's bits, its value in hex, and how those same bits read as an unsigned versus a signed value — turning the 'meaning is imposed' idea into something you can see on screen.
- 01
A
print_byte(unsigned char)that prints the 8 bits high-bit-first using(b >> i) & 1(e.g. 205 →11001101). - 02
A
print_bits(unsigned int)that prints all 32 bits, grouped in bytes of 8 with a space between groups. - 03
For a set of values, print each one's: bits, hex (
%X), value read asunsigned, and value read assigned char/int. - 04
Run it on 0, 1, 128, 205, 255, and −1, and confirm each line matches what you derived by hand first.
- •
Only
<stdio.h>. Read bits with shifts and masks, not string tricks. - •
Derive every expected output BY HAND before running, then check the program against your derivation.
- •
Compiles clean with
-Wall -Wextra.
inspect.cEverything here was taught above: a byte is 8 bits (sections 1–2), the `(v >> i) & 1` bit-reading idiom is shown in section 2, hex is four bits per digit (section 3), and the same bits read as signed or unsigned via two's complement (section 4). If a part stumps you, reread the section it comes from.