Binary: the universal language

How computers count

1,899 words10 min read

Why binary?

Every photo you've ever taken, every song you've streamed, every message you've sent - all of it, at its most fundamental level, is just a vast sequence of ones and zeros. But here's the thing: computers don't use binary because it's elegant or because some engineer thought it would be cool. They use it because it's the only practical choice given how transistors work.

A [[transistor]] is essentially a tiny switch. It can be ON or OFF. Conducting current or not. There's no "halfway" state that's reliable enough to use. Sure, we could theoretically build computers that work with three states, or ten states, or a hundred - but the error rates would be catastrophic. With just two states, we can reliably distinguish between them even when there's electrical noise, temperature variation, or manufacturing imperfections.

The physics makes this clear: a transistor either allows current to flow or it doesn't. We can set thresholds - anything below 0.8 volts is a 0, anything above 2.0 volts is a 1 - and have a comfortable margin for noise. Try to squeeze in more states, and those margins shrink to the point where a slight temperature change or a cosmic ray could flip your data. Binary is robust precisely because it's simple.

Binary Number System

42
Value Breakdown:
32+8+2=42
Hexadecimal:0x2A
Click the bits to toggle them and see how binary numbers translate to decimal

Counting in powers of two

In decimal, each position represents a power of 10. The rightmost digit is 10⁰ (ones), then 10¹ (tens), then 10² (hundreds), and so on. Binary works exactly the same way, just with powers of 2 instead. This isn't arbitrary - it's a natural consequence of having two symbols to work with.

So the binary number 11001010 breaks down like this: (1×128) + (1×64) + (0×32) + (0×16) + (1×8) + (0×4) + (1×2) + (0×1) = 128 + 64 + 8 + 2 = 202 in decimal. Each position either contributes its power of 2 (if the bit is 1) or contributes nothing (if the bit is 0).

Bit Position76543210
Power of 22⁷2⁶2⁵2⁴2⁰
Decimal Value1286432168421
Example Bits11001010
Contribution12864008020

This is why computer memory and storage sizes always seem to be weird numbers like 256, 512, 1024, or 65536. They're all powers of 2, which makes them natural sizes for binary systems. A single [[byte]] (8 bits) can represent 2⁸ = 256 different values (0 through 255). A 16-bit value can represent 65,536 values. A 32-bit value can represent over 4 billion different values.

The choice of 8 bits per byte is somewhat historical - early systems used various byte sizes (6, 7, 9 bits), but 8 bits became standard because it's convenient for representing characters (ASCII uses 7 bits, leaving one for parity checking) and because 8 is a power of 2, making address calculations simpler.

The problem with negative numbers

Here's a puzzle: how do you represent negative numbers when all you have are ones and zeros? There's no minus sign in binary. Early computer designers tried several approaches, each with its own trade-offs.

The naive approach is sign-magnitude: use the leftmost bit as a sign (0 for positive, 1 for negative) and the remaining bits for the magnitude. So +5 would be 00000101 and -5 would be 10000101. Simple, but problematic: you get two representations of zero (+0 and -0), and addition requires checking signs and potentially subtracting instead of adding. The hardware becomes complex.

The solution that virtually all modern computers use is called [[two's complement]], and it's brilliantly elegant once you understand it. The basic idea is to reserve the leftmost bit (the [[MSB]]) as a sign indicator. If it's 0, the number is positive. If it's 1, the number is negative. But here's the clever part: we don't just flip the sign bit to make a number negative. Instead, we invert all the bits and add 1.

Let's convert -6 using 8-bit two's complement:

Step 1: Start with +6 in binary
   00000110

Step 2: Invert all bits (0→1, 1→0)
   11111001

Step 3: Add 1
   11111010  ← This is -6 in two's complement

To verify: 11111010
The MSB is 1, so it's negative.
Value = -128 + 64 + 32 + 16 + 8 + 0 + 2 + 0
      = -128 + 122
      = -6 ✓

Range for 8-bit two's complement: -128 to +127

Why go through all this trouble? Because with two's complement, the same addition circuitry works for both positive and negative numbers. The CPU doesn't need separate logic for subtraction - it just adds the two's complement. 5 + (-3) = 5 + (two's complement of 3) = 2, using the exact same adder circuit. This was a huge simplification for early computer hardware, and we still use it today because it just works.

Bitwise operations: surgery on bits

One of the most powerful things about working in binary is that you can manipulate individual bits using [[bitwise operation]]s. These are fundamental operations that CPUs can perform in a single clock cycle, making them incredibly fast. Understanding them opens up a world of efficient programming techniques.

Bitwise Operations

1 if both bits are 1

A:
= 202
B:
= 179
&
=
1
0
0
0
0
0
1
0
= 130
Truth Table for AND:
A
B
Result
0
0
0
0
1
0
1
0
0
1
1
1
Toggle the input bits and see how different bitwise operations combine them

The basic operations are AND (both bits must be 1), OR (either bit can be 1), XOR (bits must be different), and NOT (flip all bits). Each has distinct use cases. AND is perfect for masking - extracting specific bits while zeroing others. OR is great for setting bits. XOR is useful for toggling and has the magical property that A XOR B XOR B = A, making it useful for encryption and error detection.

// Check if number is odd (last bit is 1)
const isOdd = (n & 1) === 1

// Multiply by 2 (shift left by 1)
const times2 = n << 1

// Divide by 2 (shift right by 1)
const half = n >> 1

// Multiply by 8 (shift left by 3, since 2³ = 8)
const times8 = n << 3

// Check if specific bit is set (bit 4)
const hasBit4 = (flags & 0b00010000) !== 0

// Toggle bit 4 (flip it)
const toggled = flags ^ 0b00010000

// Clear bit 4 (set to 0)
const cleared = flags & ~0b00010000

// Set bit 4 (set to 1)
const setBit = flags | 0b00010000

// Swap two variables without a temp variable!
a = a ^ b
b = a ^ b  // b is now original a
a = a ^ b  // a is now original b

Bit shifting deserves special attention. Left shift (<<) multiplies by powers of 2 - shifting left by 3 is the same as multiplying by 8. Right shift (>>) divides by powers of 2. These operations are much faster than actual multiplication and division. Compilers often optimize multiplications by constants into shifts and adds automatically.

Hexadecimal: binary's best friend

Writing out long strings of ones and zeros gets tedious fast. That's why programmers often use hexadecimal (base-16) as a more compact representation. Each hex digit represents exactly 4 bits, making conversion trivial - just group binary digits into fours and convert each group.

DecimalBinaryHexDecimalBinaryHex
000000810008
100011910019
200102101010A
300113111011B
401004121100C
501015131101D
601106141110E
701117151111F

This is why you see hex values everywhere in computing. Memory addresses like 0x7FFE0000, color codes like #FF5733 (which is RGB: 255, 87, 51), MAC addresses like 00:1A:2B:3C:4D:5E, error codes, and debug output - they're all hex because it's a compact way to represent binary data that's still easy for humans to read and type. Each byte is exactly two hex digits.

The 0x prefix is a convention meaning "this is hexadecimal." So 0xFF is 255 in decimal (15×16 + 15 = 255). Some systems use other conventions: $ in assembly language, # in CSS colors, or a trailing h (FFh). But they all mean the same thing: interpret these characters as base-16 digits.

Floating point: when integers aren't enough

Integers are great, but what about numbers like 3.14159 or 0.000001 or 6.022×10²³? Enter floating-point representation, specified by [[IEEE 754]]. The basic idea is to represent numbers in scientific notation: a sign, a mantissa (the significant digits), and an exponent that determines where the decimal point "floats" to.

IEEE 754 Single Precision (32-bit float):
┌─────┬──────────┬───────────────────────┐
│  1  │    8     │          23           │
│Sign │ Exponent │       Mantissa        │
└─────┴──────────┴───────────────────────┘

The value is: (-1)^sign × 1.mantissa × 2^(exponent-127)

Example: -13.625 in IEEE 754
Step 1: Convert to binary: 13.625 = 1101.101
Step 2: Normalize: 1.101101 × 2³
Step 3: Sign bit: 1 (negative)
Step 4: Exponent: 3 + 127 = 130 = 10000010
Step 5: Mantissa: 10110100000000000000000 
        (the leading 1 is implicit!)

Result: 1 10000010 10110100000000000000000

Double precision (64-bit) uses 11 exponent bits 
and 52 mantissa bits for more range and precision.

The exponent uses a "bias" of 127 (for 32-bit) rather than two's complement. This makes comparing floating-point numbers easier - you can almost compare them as if they were integers. The mantissa always has an implicit leading 1 (for normalized numbers), giving you an extra bit of precision for free.

IEEE 754 also defines special values: positive and negative infinity (for overflow), positive and negative zero (yes, they're different!), and NaN (Not a Number) for undefined results like 0/0 or √(-1). These special cases ensure that floating-point operations never crash - they always produce some result, even if that result is "this doesn't make sense."

Endianness: which end first?

When you have a multi-byte value like a 32-bit integer, in what order do you store the bytes in memory? There are two conventions: big-endian (most significant byte first) and little-endian (least significant byte first). This is [[endianness]], and it's caused more headaches than you might expect.

The 32-bit value 0x12345678 in memory:

Big-Endian (network byte order):
Address:  0x00  0x01  0x02  0x03
Value:    0x12  0x34  0x56  0x78
          ↑ MSB first

Little-Endian (Intel x86, ARM):
Address:  0x00  0x01  0x02  0x03
Value:    0x78  0x56  0x34  0x12
          ↑ LSB first

Both represent the same number!

Intel processors use little-endian; network protocols use big-endian; ARM can do both. When systems communicate across architectures or read files created on different systems, endianness conversion is essential. This is why network programming has functions like htonl() (host to network long) and ntohl() (network to host long).

From bits to everything

Here's the profound truth: every piece of digital information - text, images, audio, video, programs - is ultimately just patterns of bits. The meaning comes from how we interpret those bits. This abstraction is what makes general-purpose computers possible.

The byte sequence 01001000 01101001 could be the ASCII text "Hi", or it could be the 16-bit integer 18,537, or it could be two pixels in a grayscale image, or it could be a small snippet of audio. The bits themselves don't "know" what they represent - that meaning comes from the context and the software interpreting them. Type safety in programming languages exists precisely to prevent us from accidentally interpreting bits the wrong way.

There are only 10 types of people in the world: those who understand binary and those who don't.

Ancient programmer wisdom

This is both the simplicity and the magic of digital computing. By reducing everything to the simplest possible representation - just two symbols - we've built a universal system that can represent and manipulate any information that can be expressed symbolically. Music, art, mathematics, human language, the laws of physics - all encoded as ones and zeros. And it all starts with a transistor that's either on or off.

How Things Work - A Visual Guide to Technology