Part 1 — Beginner: The Foundations Everyone Needs
Nine chapters, each following the same shape described above, needing nothing beyond arithmetic to start. This is the foundation everything later — linear algebra, calculus, cryptography, ML math — quietly assumes you already have, whether or not anyone ever said so out loud.
1. Number Systems & Bit-Level Math
Level: Beginner You'll use this for: many bugs involving memory, money, overflow, or "why is this number wrong."
The idea
Computers store everything as binary (base 2). Humans think in decimal (base 10). Engineers read/write hex (base 16) because it maps cleanly to binary (1 hex digit = 4 bits) and is far more compact than binary for humans.
Conversions
| Decimal | Binary | Hex |
|---|---|---|
| 10 | 1010 | 0xA |
| 255 | 11111111 | 0xFF |
| 256 | 100000000 | 0x100 |
| 4096 | 1000000000000 | 0x1000 |
>>> bin(255) # '0b11111111'
>>> hex(255) # '0xff'
>>> int('ff', 16) # 255
>>> int('11111111', 2) # 255
The table above shows the answers. Here's the actual mechanism behind each arrow — the by-hand process, so a conversion is never a black box.
Positional notation: the one idea every conversion rests on
Every number system here is positional — each digit's value depends on
where it sits, not just what digit it is. In decimal, 243 means
2×10² + 4×10¹ + 3×10⁰. The base (10) tells you what each position is a
power of. Binary and hex work identically, just with base 2 and base 16
instead of base 10:
binary 1010 = 1×2³ + 0×2² + 1×2¹ + 0×2⁰ = 8 + 0 + 2 + 0 = 10
hex FF = 15×16¹ + 15×16⁰ = 240 + 15 = 255
Every conversion method below is just a different way of applying (decimal → other base) or undoing (other base → decimal) this same place-value idea.
Decimal → Binary: repeated division by 2
Divide by 2 repeatedly, writing down the remainder (always 0 or 1) each time, until the quotient hits 0. Then read the remainders bottom to top — that's your binary number.
Converting 10 to binary:
10 ÷ 2 = 5 remainder 0 <- read bottom-up: 1 0 1 0
5 ÷ 2 = 2 remainder 1
2 ÷ 2 = 1 remainder 0
1 ÷ 2 = 0 remainder 1 <- stop, quotient is 0
Reading remainders bottom-to-top: 1010 ✓ matches the table
Why bottom-to-top? Each division peels off the least significant bit first (the 2⁰ place), the same way repeatedly dividing by 10 peels off a decimal number's last digit first. The last remainder you compute is the most significant bit, so it goes on the left.
def decimal_to_binary(n):
if n == 0:
return "0"
bits = []
while n > 0:
bits.append(str(n % 2)) # the remainder
n //= 2 # the quotient, fed into the next round
return "".join(reversed(bits)) # remainders came out least-significant-first
print(decimal_to_binary(10)) # '1010'
print(decimal_to_binary(255)) # '11111111'
Binary → Decimal: multiply each digit by its place value, then add
Reverse of the above: number the bit positions from the right starting at 0, raise 2 to each position, multiply by the bit (0 or 1), and sum.
Converting 1010 to decimal:
position: 3 2 1 0
bit: 1 0 1 0
value: 1×2³ + 0×2² + 1×2¹ + 0×2⁰
= 8 + 0 + 2 + 0
= 10 ✓
def binary_to_decimal(bits):
total = 0
for position, bit in enumerate(reversed(bits)):
total += int(bit) * (2 ** position)
return total
print(binary_to_decimal("1010")) # 10
print(binary_to_decimal("11111111")) # 255
Decimal → Hex: repeated division by 16
Identical process to decimal → binary, just dividing by 16 instead of 2.
The catch: a remainder can now be 0–15, and hex only has single-character
digits 0-9, so remainders 10–15 are written as letters A-F.
Converting 255 to hex:
255 ÷ 16 = 15 remainder 15 -> 'F' <- read bottom-up: F F
15 ÷ 16 = 0 remainder 15 -> 'F' <- stop, quotient is 0
Reading remainders bottom-to-top: FF ✓ matches the table
HEX_DIGITS = "0123456789ABCDEF"
def decimal_to_hex(n):
if n == 0:
return "0"
digits = []
while n > 0:
digits.append(HEX_DIGITS[n % 16]) # remainder, mapped to a hex character
n //= 16
return "".join(reversed(digits))
print(decimal_to_hex(255)) # 'FF'
print(decimal_to_hex(4096)) # '1000'
Hex → Decimal: multiply each digit by its place value, then add
Same idea as binary → decimal, but each position is a power of 16 instead of 2, and letter digits convert back to their 10–15 value first.
Converting FF to decimal:
position: 1 0
digit: F F -> 15 15
value: 15×16¹ + 15×16⁰
= 240 + 15
= 255 ✓
def hex_to_decimal(hex_str):
total = 0
for position, char in enumerate(reversed(hex_str.upper())):
total += HEX_DIGITS.index(char) * (16 ** position)
return total
print(hex_to_decimal("FF")) # 255
print(hex_to_decimal("1000")) # 4096
Binary ↔ Hex: group into nibbles (no arithmetic needed at all)
Why hex maps to binary so cleanly: 16 = 2⁴, so every hex digit is
exactly 4 bits (a "nibble"), and every possible 4-bit pattern (0000
through 1111) has exactly one hex digit (0 through F). That means
converting between binary and hex is pure regrouping — no division, no
multiplication, just splitting into chunks of 4 and looking each chunk up:
Binary -> Hex: split into groups of 4 bits, starting from the right,
then convert each group independently.
11111111 -> 1111 1111 -> F F -> FF
Hex -> Binary: expand each hex digit into its 4-bit pattern, and
concatenate.
FF -> F F -> 1111 1111 -> 11111111
nibble lookup table (memorize this once, use it forever):
0000=0 0100=4 1000=8 1100=C
0001=1 0101=5 1001=9 1101=D
0010=2 0110=6 1010=A 1110=E
0011=3 0111=7 1011=B 1111=F
def binary_to_hex_by_grouping(bits):
bits = bits.zfill((len(bits) + 3) // 4 * 4) # pad to a multiple of 4 on the left
nibbles = [bits[i:i+4] for i in range(0, len(bits), 4)]
return "".join(HEX_DIGITS[int(nibble, 2)] for nibble in nibbles)
print(binary_to_hex_by_grouping("11111111")) # 'FF'
This is why MAC addresses (00:1A:2B:3C:4D:5E), IPv6 addresses, and memory
dumps are always shown in hex — it's binary in disguise, without 32
characters of 1s and 0s to read, and you can convert either direction in
your head by regrouping 4 bits at a time instead of doing any real math.
Two's complement (how negative numbers actually work)
An 8-bit signed integer doesn't store a "minus sign" — it flips all bits of the positive value and adds 1.
5 = 00000101
-5 = 11111011 (flip bits: 11111010, add 1: 11111011)
Why it matters: signed integer overflow. An 8-bit signed int ranges from
-128 to 127. 127 + 1 wraps to -128. This is a real, recurring bug class:
- The 1996 Ariane 5 rocket exploded 37 seconds after launch because a 64-bit float was cast into a 16-bit signed integer and overflowed.
- Classic game bugs ("civilization gandhi nuke" isn't real, but integer wraparound score/health bugs are extremely common).
- Security angle: integer overflow is a documented vulnerability class
(CWE-190). An attacker who can push a size/length calculation past
INT_MAXcan cause a buffer to be allocated smaller than expected, leading to a buffer overflow.
# Simulate 8-bit signed overflow
def to_int8(n):
n = n & 0xFF # keep lowest 8 bits
return n - 256 if n >= 128 else n
print(to_int8(127 + 1)) # -128 <- wraps around
IEEE-754 floating point (why 0.1 + 0.2 != 0.3)
Floats store numbers as sign × mantissa × 2^exponent. Just like 1/3
cannot be written exactly in decimal, 0.1 cannot be written exactly in
binary — it's a repeating fraction. The stored value is the closest
representable approximation.
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
Real-life engineering ties:
- SWE: never store money as
float/double. Use integer cents, or aDecimaltype (from decimal import Decimal). Comparing floats for equality is a classic bug — useabs(a - b) < epsiloninstead. - AI: float16/bfloat16 training (mixed precision) trades mantissa precision for speed/memory — understanding why explains why some models see NaN losses (exponent overflow) at low precision.
- Network: checksums and packet length fields are fixed-width integers; overflow/wraparound is a real parsing bug source (e.g., TCP sequence number wraparound at 2³², handled by "serial number arithmetic," RFC 1982).
- Security: integer overflow (CWE-190) and floating-point rounding errors in financial systems are exploitable classes — "salami slicing" attacks historically exploited rounding remainders.
More real-life examples:
- The Boeing 787 Dreamliner's generator control units had a documented 32-bit counter overflow (FAA Airworthiness Directive 2015-19-07) that forced a shutdown after 248 days of continuous power — the same overflow class as the toy 8-bit example above, at safety-critical scale.
- The "Year 2038 problem": Unix time stored as a signed 32-bit integer overflows on January 19, 2038 — every system still storing timestamps this way inherits this chapter's wraparound bug on a fixed calendar date.
- In 2010, a Bitcoin integer-overflow bug let someone mint 184.4 billion BTC in a single transaction before the network was patched — a direct real-money consequence of fixed-width arithmetic.
- Payment APIs (e.g., Stripe) represent amounts as integer minor units
(cents), not decimals, specifically to avoid the floating-point rounding
drift shown in the
0.1 + 0.2example above.
Common mistake
Assuming int in your language is unbounded. Python's int is arbitrary
precision (auto-grows), but C, Java, Go's fixed-width ints are not — this
difference alone explains many "works in Python, crashes in C" ports.