roadmap
week 1 · day 2

Hex & Two's Complement

How negatives exist with no minus sign

Easy 30 min 180 xp
After this you can
  • 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.

BinaryHexDecimalBinaryHexDecimal
000000100088
000111100199
0010221010A10
0011331011B11
0100441100C12
0101551101D13
0110661110E14
0111771111F15

So 11001101 splits into 1100 and 1101C and D0xCD. 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.

Key idea
Why hex is everywhere in low-level work. Memory addresses, register values, instruction encodings, colors (#FF8800), and error codes are all shown in hex because it maps cleanly onto bytes. 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.
hex.c, the compiler treats these as the same numberc
1#include <stdio.h>
2
3int main(void) {
4 int a = 205; // decimal literal
5 int b = 0xCD; // hex literal , same value
6 int c = 0b11001101; // binary literal (GCC/Clang extension)
7
8 printf("%d %d %d\n", a, b, c); // 205 205 205
9 printf("%d in hex is 0x%X\n", a, a); // 205 in hex is 0xCD
10 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:

Deriving -1 in 8 bits
 +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 -1

So 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 = 0, for free
 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 ✓
Note
Reading the sign fast. In two's complement the top bit is the sign bit: 1 means negative, 0 means non-negative. To read a negative value's magnitude, flip-and-add-1 again (the operation is its own inverse) and read the result as positive. 0xFF → flip → 0x00 → +1 → 0x01 = 1, so 0xFF is −1. 0x80 (10000000) → flip → 01111111 → +1 → 10000000 = 128, so signed 0x80 is −128.
Byte (hex)BitsAs unsignedAs signed (two's complement)
0x000000000000
0x01000000011+1
0x7F01111111127+127 (largest positive)
0x8010000000128−128 (most negative)
0xFF11111111255−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*.

overflow.c, watch the wrap happenc
1#include <stdio.h>
2#include <limits.h>
3
4int main(void) {
5 signed char s = 127; // 0x7F, the largest positive signed byte
6 printf("%d\n", s); // 127
7 s = s + 1; // bits become 0x80
8 printf("%d\n", s); // -128 ← signed overflow, wrapped
9
10 unsigned char u = 255; // 0xFF
11 u = u + 1; // bits become 0x00 (carry falls off)
12 printf("%d\n", u); // 0 ← unsigned wrap
13 return 0;
14}
finished reading?
Your task, you write the code

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.

deliverable: twoscomp.c
build & run
$ gcc -Wall -Wextra -o twoscomp twoscomp.c
$ ./twoscomp
self-review before running
  • -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)
stretchAdd a print of `(signed char)(127 + 1)` and explain in a comment, using the bit patterns, exactly why the result is −128 and not 128.

Self-check

01What is 0xB7 in binary?
02The bit pattern 10000000 (0x80) in a signed byte represents:
03To negate a two's-complement number you:
04An `unsigned char` holding 255 has 1 added to it. The result is:
0/4 correct · 0/4 checked