Hex & Two's Complement
How negatives exist with no minus sign
- › Convert between binary and hexadecimal instantly
- › Explain why programmers use hex everywhere
- › Derive a negative number's bit pattern using two's complement
- › Predict what happens on signed vs unsigned overflow
First, a human problem, and its elegant fix
Yesterday's idea stands: a value is a pattern of switches, and meaning is imposed. But there is a
purely *human* problem with those patterns, 11001101 is exhausting to read, write, and say
aloud, and a single flipped digit is easy to miss. We want a notation that keeps the byte-structure
visible but is compact enough for a human eye.
Here is the insight that gives us one. We chose base 2 for the *machine* (two reliable states). For our *eyes*, we can regroup those same bits into a base whose one digit covers a clean chunk of them. Four bits have 2⁴ = 16 possible patterns, so a base with 16 symbols maps *one digit to exactly four bits*, with no overlap and no arithmetic. That base is hexadecimal, and this is the whole reason it exists: it is not a different number system the machine uses, it is a *human-readable regrouping of binary*. Its 16 symbols are 0-9 then A B C D E F for 10-15, and because a byte is two nibbles, a byte is always exactly two hex digits.
| Binary | Hex | Decimal | Binary | Hex | Decimal |
|---|---|---|---|---|---|
| 0000 | 0 | 0 | 1000 | 8 | 8 |
| 0001 | 1 | 1 | 1001 | 9 | 9 |
| 0010 | 2 | 2 | 1010 | A | 10 |
| 0011 | 3 | 3 | 1011 | B | 11 |
| 0100 | 4 | 4 | 1100 | C | 12 |
| 0101 | 5 | 5 | 1101 | D | 13 |
| 0110 | 6 | 6 | 1110 | E | 14 |
| 0111 | 7 | 7 | 1111 | F | 15 |
So 11001101 splits into 1100 and 1101 → C and D → 0xCD. The 0x prefix is how C (and assembly) says "the following is hex". Reverse is just as fast: 0xCD → C is 1100, D is 1101 → 11001101. Check against yesterday: 0xCD = 205. It all lines up.
0xFF instantly reads as "all 8 bits set" (one full byte); 0xFF00 reads as "high byte full, low byte empty". Decimal hides the byte boundaries; hex shows them.1#include <stdio.h>2 3int main(void) {4 int a = 205; // decimal literal5 int b = 0xCD; // hex literal , same value6 int c = 0b11001101; // binary literal (GCC/Clang extension)7 8 printf("%d %d %d\n", a, b, c); // 205 205 2059 printf("%d in hex is 0x%X\n", a, a); // 205 in hex is 0xCD10 return 0;11}Line by line
`int a = 205; int b = 0xCD; int c = 0b11001101;`, three different *notations*, one identical bit pattern in memory. The compiler converts all literals to binary; the notation is purely for your eyes. This proves the "meaning comes from interpretation" idea from Day 1: the bits don't change, only how we wrote them down.
`printf("...%X...", a)`, the %X format specifier tells printf to render the integer's bits as uppercase hex. Same bits, printed in a different base. %d renders them as signed decimal. You are literally asking for two views of one value.
Now the big one: negative numbers
Here is a real puzzle, and I want you to feel it before I hand you the answer. A byte is 8 switches.
There is no switch for a minus sign, no separate place to store "this one is negative." Yet -1
plainly exists in every program. Where does the negativeness *live*?
You might reach for the obvious fix: steal the top bit to mean "minus." That is called
*sign-magnitude*, and it seems clean, until you try to add. (-1) + 1 should give 0, but under
sign-magnitude the hardware would need a special case to notice the signs, subtract instead of add,
and sort out the result. Two different circuits, endless edge cases, and *two different zeroes*
(+0 and −0). Nature hates it; so do CPU designers.
So the real question is sharper than "how do we mark negatives?" It is: can we choose the bit
patterns for negatives so that ordinary binary addition just works, with no special case at all?
The answer is yes, and the scheme is called two's complement. The rule to *negate* a number is
flip every bit, then add 1 — and in a moment you will see that this rule is not arbitrary at
all; it is precisely what makes addition free. Let's find -1 in a single byte, starting from
+1:
+1 0 0 0 0 0 0 0 1
flip bits → 1 1 1 1 1 1 1 0 (this step is "one's complement")
add 1 → 1 1 1 1 1 1 1 1 = 0xFF ← this bit pattern IS -1So in signed 8-bit, 0xFF means −1, not 255. Same eight switches, the *interpretation* (signed vs unsigned) decides. This is why Day 1's mantra matters: unsigned char reads 11111111 as 255; signed char reads the identical byte as −1.
Why flip-and-add-1 is not arbitrary
Two's complement is chosen so that addition just works with no special cases. The hardware adds signed and unsigned numbers with the *same* circuit. Watch (-1) + 1 in one byte:
1 1 1 1 1 1 1 1 (-1, i.e. 0xFF)
+ 0 0 0 0 0 0 0 1 (+1)
-----------------
1 0 0 0 0 0 0 0 0
^
this 9th "carry" bit falls off the end of an 8-bit byte
what remains: 0 0 0 0 0 0 0 0 = 0 ✓0xFF → flip → 0x00 → +1 → 0x01 = 1, so 0xFF is −1. 0x80 (10000000) → flip → 01111111 → +1 → 10000000 = 128, so signed 0x80 is −128.| Byte (hex) | Bits | As unsigned | As signed (two's complement) |
|---|---|---|---|
| 0x00 | 00000000 | 0 | 0 |
| 0x01 | 00000001 | 1 | +1 |
| 0x7F | 01111111 | 127 | +127 (largest positive) |
| 0x80 | 10000000 | 128 | −128 (most negative) |
| 0xFF | 11111111 | 255 | −1 |
Overflow: where it bites
An 8-bit signed value runs −128 to +127. Add 1 to +127 (0x7F) and the bits become 0x80, which signed means −128. The number wrapped from most-positive to most-negative. That's signed overflow, and it is the source of countless real bugs. Unsigned has its own wrap: 255 + 1 = 0. The CPU doesn't error, it just keeps the low 8 bits and moves on. Knowing exactly where these edges are is what separates someone who *guesses* from someone who *knows*.
1#include <stdio.h>2#include <limits.h>3 4int main(void) {5 signed char s = 127; // 0x7F, the largest positive signed byte6 printf("%d\n", s); // 1277 s = s + 1; // bits become 0x808 printf("%d\n", s); // -128 ← signed overflow, wrapped9 10 unsigned char u = 255; // 0xFF11 u = u + 1; // bits become 0x00 (carry falls off)12 printf("%d\n", u); // 0 ← unsigned wrap13 return 0;14}Prove two's complement to yourself
Write twoscomp.c. Reuse your print_byte() from Day 1 (or retype it). In main(), for the values 1, -1, 127, -128, and 255 stored in a `signed char`, print each value's decimal form AND its 8-bit pattern. Then, on paper first and then confirmed by the program, show that flipping the bits of -1 and adding 1 gives +1.
$ gcc -Wall -Wextra -o twoscomp twoscomp.c$ ./twoscomp- › -1 prints the pattern 11111111
- › -128 prints 10000000 and +127 prints 01111111
- › You did the flip-and-add-1 by hand for at least one value before running
- › You can state the signed range of a byte without looking (−128 to +127)