Computer Architecture, Bits, and Number Representation
Part 1 of 15
This is Lesson 1, Part 1 of a planned ten-part Computer Architecture From First Principles series. It's the foundations chapter — the outline version of this material existed as a single short post; this is the fully worked-out version, with every idea backed by several numeric examples instead of one, and explicit call-outs for the places engineers most commonly trip up. Coming from firmware/C++/compiler work, my goal throughout the series is to be able to descend mentally from a line of C++ all the way to transistors and explain every layer in between. This part builds the vocabulary: what architecture even studies, the one equation that describes a clock cycle, and how numbers actually live in bits.
What Is Computer Architecture, Really?
A serviceable engineering definition:
Computer architecture is the study of how a digital system represents, stores, moves, and transforms information, and how each layer of that system presents an interface to the layer above it.
That last clause — "interface to the layer above it" — is the part people skip, and it's the part that matters. A computer isn't one monolithic thing you can point at; it's a stack of layers, each of which hides the layer below it behind a contract. Roughly:
The critical property of this stack is that each layer is a specification, not an implementation. The ISA layer in particular is worth staring at, because it's the layer most people accidentally conflate with "the chip."
The ISA is a contract, not a chip
An Instruction Set Architecture defines things like: how many general-purpose registers exist and how wide they are, what instructions exist and what each one is guaranteed to do to architectural state, how memory addressing works, and what happens on exceptions. It says nothing about pipeline depth, cache size, clock frequency, or how many instructions execute per cycle. Those are microarchitecture decisions, and a single ISA can be implemented by wildly different microarchitectures.
Common misconception: "ARM is faster than x86." This sentence category-errors two different layers. ARM and x86-64 (and RISC-V) are ISAs — specifications. Speed is a property of a specific microarchitectural implementation of that ISA, built at a specific process node, with a specific pipeline and cache hierarchy. There exist slow ARM cores (a Cortex-M0 in a light switch) and extremely fast ARM cores (Apple's firestorm/icestorm cores), and the same spread exists for x86-64. The ISA tells you almost nothing about performance on its own — it tells you about the software contract the hardware must honor.
To make this concrete, here's how the same architectural concept — "a general-purpose integer register" — looks across three real ISAs:
| ISA | GPR width | Register count | Register naming |
|---|---|---|---|
| x86-64 | 64-bit, with 32/16/8-bit sub-views | 16 (RAX–R15) | RAX → EAX → AX → AL/AH (same physical storage, different width windows) |
| ARM64 (AArch64) | 64-bit | 31 (X0–X30) + SP | X0–X30 are 64-bit views; W0–W30 are the low 32 bits of the same registers |
| RISC-V (RV64) | 64-bit | 32 (x0–x31) | x0 is hardwired to the constant 0 — writes to it are silently discarded |
Notice that all three of these are 64-bit ISAs today, but they disagree on register count, on whether sub-widths are separate names for slices of the same physical register (x86-64 and ARM64 both do this) or not, and on quirky details like RISC-V's x0 being a permanently-zero register (useful for encoding "compare to zero" and "unconditional move" without dedicated opcodes). None of this is visible from C++; the compiler's register allocator deals with it. But it's the layer directly below the assembly you'd see in a debugger.
State and Transformation: The One Equation That Explains a CPU
Strip away everything else and a synchronous digital machine — which is what essentially every CPU built since the 1970s is — obeys one equation:
S_(t+1) = F(S_t, I_t)where S_t is the entire architectural state at time t, I_t is the instruction (or input) being applied, and F is the transformation the hardware performs at each clock edge. This isn't a metaphor; it is a literal, complete description of what a CPU does, cycle after cycle, for its entire operational lifetime. Everything else — pipelining, caching, branch prediction, out-of-order execution, superscalar issue — is machinery built to compute F faster, more cheaply, or with less energy, while producing exactly the same sequence of S_t values an unpipelined, in-order implementation would have produced. That last clause is the whole discipline of microarchitecture in one sentence, and we'll come back to it constantly in later parts of the series.
Let's make this concrete with three separate examples, because "state" means more than "the registers you're currently touching."
Example 1 — an ALU instruction changes a register and the PC
Before:
R1 = 10
R2 = 20
R3 = 0
PC = 0x1000
Instruction: ADD R3, R1, R2Applying F:
R3_new = R1 + R2 = 10 + 20 = 30
PC_new = PC + 4 = 0x1000 + 4 = 0x1004
After:
R1 = 10
R2 = 20
R3 = 30
PC = 0x1004Two pieces of state changed, not one: the destination register, and the program counter. Every non-branch instruction implicitly advances the PC — it's part of S_t, and it's part of what F updates, even though it's easy to forget it's "state" at all because it's not written explicitly in the assembly mnemonic.
Example 2 — a branch changes only the PC, not any register
Before:
R1 = 5
R2 = 5
PC = 0x2000
Instruction: BEQ R1, R2, 0x2100Here F compares R1 and R2, finds them equal, and redirects control flow:
After:
R1 = 5 (unchanged)
R2 = 5 (unchanged)
PC = 0x2100 (not PC + 4)This matters because it shows F is not "always += 4 to the PC, plus maybe touch a register." The next-PC computation is itself part of the transformation, conditioned on the current state. Branch prediction — a huge topic later in this series — exists entirely because computing the correct next PC for a conditional branch can take multiple cycles, and the pipeline doesn't want to sit idle waiting to find out.
Example 3 — a store instruction changes memory, which is also state
Before:
R1 = 0x3000 (address)
R2 = 42 (value)
Mem[0x3000] = 0
Instruction: STORE [R1], R2After:
R1 = 0x3000
R2 = 42
Mem[0x3000] = 42This is the example people most often forget when they first meet S_(t+1) = F(S_t, I_t): main memory is part of the architectural state, not something separate from it. A CPU with a thousand registers and zero memory would be a curiosity, not a computer. In a real CPU, S_t is the union of the program counter, the register file, the flags/condition codes, all of addressable memory, and (once you get to a real OS-capable machine) privileged control registers, page tables, and so on. F is enormous and mostly local — a given instruction only ever touches a tiny slice of S_t — but the model doesn't change.
Common misconception: thinking of "state" as only the registers you can see in a debugger's register pane. In reality, state includes memory contents, condition flags, the PC, and (as later parts of this series will cover) a lot of microarchitectural state that isn't architecturally visible at all — cache line contents, branch predictor tables, reorder buffer entries. The architectural state is the state the ISA promises to preserve semantics for; microarchitectural state is everything the implementation uses to compute that faster, which software isn't supposed to be able to observe directly (though side-channel attacks like Spectre exist precisely because it leaks a little anyway — a topic for a much later part).
Tie-back to C++: what "state" means in your own programs
When you write:
int factorial(int n) {
int result = 1;
for (int i = 2; i <= n; ++i)
result *= i;
return result;
}the local variables result and i are, at the machine level, either registers or stack-frame memory locations — pieces of S_t. Every loop iteration is one more application of F: read the current state (result, i, n), compute a new state (result * i, i + 1), compare, and either branch back or fall through. The for-loop you write in C++ is, underneath the compiler, nothing but a repeated invocation of the state-transformation equation with a branch instruction closing the loop. Later parts of this series will trace exactly which assembly instructions a loop like this compiles to and how the pipeline processes them.
What "Information" Means to a Computer
At the physical layer, a computer manipulates voltages on wires — is a node's voltage above or below some threshold. We abstract that continuous physical quantity into exactly two logical values:
0
1One such binary digit is a bit. Group eight of them and you get a byte:
8 bits = 1 byteAn 8-bit byte has 2^8 = 256 distinct patterns, from 00000000 through 11111111. That number, 256, recurs constantly in architecture — it's the size of a uint8_t's range, the number of distinct opcodes a single byte can encode, the number of entries in a lot of small lookup tables.
Here's the idea that everything else in this post depends on:
A bit pattern has no inherent meaning. The hardware/software contract assigns it meaning, purely from context.
Take the byte 01000001. Every one of the following is a valid interpretation, and the bits themselves do not tell you which one is correct:
- Unsigned integer:
65 - ASCII character:
'A' - One byte of a larger multi-byte float, integer, or pointer
- One byte of a machine instruction's opcode or operand
- Arbitrary application data (part of a compressed file, a network packet, anything)
The type system in a language like C++ exists largely to pin down, at compile time, which interpretation a given region of memory is supposed to get — but that's a software-layer fiction imposed on top of bits that are, physically, entirely untyped. This is why reinterpret_cast and type punning are possible (and dangerous) in C++: you can always look at the same 8 bytes as a double or as 8 unsigned chars, because at the hardware level there was never a "double-ness" attached to those bits in the first place — only your program's declared intent to treat them that way.
A concrete illustration: one buffer, several interpretations
Imagine a 10-byte buffer, dumped the way a tool like xxd would show it — hex on the left, ASCII rendering on the right:
offset hex bytes ascii
00000000 48 69 00 00 00 00 fb ff ff ff Hi........Read as raw bytes, this is just ten numbers between 0x00 and 0xff. But depending on what the program expects to find there:
- Bytes 0–1 (
48 69) as ASCII: the charactersHandi. - Bytes 2–5 (
00 00 00 00) as a little-endian 32-bit unsigned integer:0. - Bytes 6–9 (
fb ff ff ff) as a little-endian 32-bit signed integer:-5(we'll derive exactly why in the two's complement section below). - The entire 10 bytes, read blindly as one big binary blob: just a number with roughly
2^80possible values, meaningless until you know the schema.
Nothing in the bytes themselves says "the first two are a string and the last four are a signed int." That knowledge lives in the code that produced the buffer and the code that will later parse it — a struct layout, a file format spec, a network protocol. Get that agreement wrong (read four bytes as unsigned where the writer meant signed, or misalign the offsets) and you get a real, extremely common class of bug, usually with no compiler warning to save you, because from the machine's point of view every one of those interpretations is equally "valid" — it's just moving bits.
Common misconception: "the computer knows this memory holds an integer / string / float." It doesn't, structurally. High-level languages track types so you don't have to reason at the bit level constantly, and the compiler uses that type information to pick the right instructions (an integer add vs. a floating-point add are completely different circuits). But once compiled, the binary is just bytes in memory and instructions operating on them; the "type" existed at compile time, not at runtime, for most primitive types in C++. This is precisely why
memcpy-ing raw bytes between astructand acharbuffer works at all.
Representing Numbers in Binary
With the "bits are just bits" idea established, we can define the most basic interpretation: an unsigned integer. For an n-bit unsigned quantity:
x = Σ (i = 0 to n-1) b_i · 2^i, where each b_i ∈ {0, 1}In words: number the bits from position 0 (least significant, rightmost) to position n-1 (most significant, leftmost), and sum 2^i for every position where the bit is 1.
Worked example 1 — a 4-bit value
1011Positions, right to left, are 3 2 1 0:
1·2^3 + 0·2^2 + 1·2^1 + 1·2^0
= 8 + 0 + 2 + 1
= 11Worked example 2 — an 8-bit value, and where hex comes from
101101011·128 + 0·64 + 1·32 + 1·16 + 0·8 + 1·4 + 0·2 + 1·1
= 128 + 32 + 16 + 4 + 1
= 181Split into two 4-bit nibbles, 1011 and 0101, those are B and 5 in hex — so 181 = 0xB5. Hold onto that; the hex section below builds directly on this.
Worked example 3 — a 32-bit address-sized value
0000 0000 0000 0000 0001 0000 0000 0000Only bit 12 is set: 2^12 = 4096. In hex this is 0x00001000, which is exactly the program-counter value used in the state-transformation examples above — addresses are just unsigned integers, and 0x1000 is a completely ordinary one.
Range table
The range of representable values for an unsigned n-bit field is 0 through 2^n - 1. This shows up constantly, so it's worth having memorized for the common widths:
Width n | Max unsigned value (2^n - 1) | Common C++ type |
|---|---|---|
| 8 | 255 | uint8_t, unsigned char |
| 16 | 65,535 | uint16_t, unsigned short |
| 32 | 4,294,967,295 | uint32_t, unsigned int (typically) |
| 64 | 18,446,744,073,709,551,615 | uint64_t, size_t (typically, on 64-bit targets) |
Common misconception: "unsigned means it can't misbehave." People reach for
unsignedtypes thinking they've eliminated a class of bugs by ruling out negative values. What they've actually done is change the failure mode from "negative surprise" to "wraparound surprise."size_t a = 3, b = 5; size_t diff = a - b;does not produce-2— there is no negative value in an unsigned type — it produces18446744073709551614on a 64-bit system (that's2^64 - 2, the wraparound result). This exact pattern, subtracting a possibly-larger unsigned value from a smaller one inside a loop bound or buffer-size calculation, is a genuinely common source of out-of-bounds reads in real C++ codebases. Unsigned types don't remove the footgun; they relocate it.
Hexadecimal: Compression for Human Eyes, Not for the Machine
The machine never uses hexadecimal. Every physical wire and every stored bit is binary; hex is purely a notational convenience for the humans reading dumps, disassembly, and addresses. It earns its keep because of one clean mathematical fact: one hex digit represents exactly four bits, because 2^4 = 16, which is exactly the size of the hex digit alphabet (0–9, A–F).
Binary Hex Binary Hex
0000 0 1000 8
0001 1 1001 9
0010 2 1010 A
0011 3 1011 B
0100 4 1100 C
0101 5 1101 D
0110 6 1110 E
0111 7 1111 FBecause the grouping is exact (unlike, say, octal against a byte, which splits unevenly at 3-bit boundaries), converting between binary and hex is purely mechanical: chunk the bits into groups of four from the right, and look each nibble up independently — no arithmetic required, unlike converting to decimal.
Worked example — a 16-bit value
1010 1101 0011 0110
A D 3 6so 1010110100110110 (binary) = 0xAD36 (hex). Compare that to converting to decimal, which requires actually summing Σ b_i · 2^i across all 16 positions — hex conversion is just a lookup table applied four times.
Worked example — decomposing a 32-bit address
0x7FFFFFFF is a value that shows up constantly in architecture discussions (it's INT32_MAX, as we'll see in the two's complement section). Breaking it into nibbles:
0x7FFFFFFF
= 0111 1111 1111 1111 1111 1111 1111 1111
7 F F F F F F FNotice the leading nibble is 0111 (=7), not 1111 (=F) — that single bit is the difference between the largest positive signed 32-bit integer and -1 as an unsigned pattern, which is exactly the kind of off-by-one-bit distinction the next section is about.
This is why disassemblers, debuggers, and linkers universally print addresses, opcodes, and immediates in hex — 0x00007FFF, 0x80000000, 0xFF00 — instead of 32-character binary strings or hard-to-eyeball decimal numbers. A trained engineer can glance at 0x80000000 and immediately recognize "only the top bit is set" in a way that 2147483648 doesn't communicate at all.
Common misconception: thinking hexadecimal is somehow "closer to the machine" or that the CPU "converts to hex internally" at some stage. It doesn't — there is no hex circuitry anywhere in a CPU. Every ALU, register, and bus is pure binary from power-on to shutdown. Hex exists exclusively in text: source code literals (
0x1000), disassembly output, log messages, and debugger UIs. The moment a hex literal like0x1000is compiled, it becomes the same binary patternunsigned x = 4096;would have produced — the two are byte-for-byte identical once assembled; hex is a source-level and display-level convention only.
Tie-back to C++
printf("%x", value) and std::hex in iostreams don't change what value is in memory — they only change how the digits get rendered to text. This is also why pointer values print in hex by default in most debuggers and in std::cout << ptr: addresses are unsigned integers, and hex is simply the least error-prone way for a human to read and compare them, especially when checking alignment (a hex address ending in 0 is 16-byte aligned; that pattern is invisible in decimal).
Signed Integers: Two's Complement
Unsigned representation covers exactly half of "represent a number" — it says nothing about negative values, and computers need those constantly (loop counters going backwards, differences, temperatures, financial deltas, signed pixel offsets). The representation that essentially every modern CPU uses is two's complement.
Why not the "obvious" approach first
The naive idea is sign-magnitude: reserve the top bit as a pure sign flag (0 = positive, 1 = negative) and use the remaining bits for the absolute value. It seems reasonable, and it's what several very early computers actually did. It has two serious problems.
Problem 1 — two representations of zero. In 8-bit sign-magnitude, +0 is 00000000 and -0 is 10000000. Two different bit patterns claim to be the same number. That wastes an encoding and complicates every comparison circuit (== now has to special-case it).
Problem 2 — addition needs a completely separate subtraction circuit. Try to add 5 + (-3) in sign-magnitude by naively treating the bit patterns as plain binary numbers and adding them:
5 = 00000101
-3 (sign-mag)= 10000011
00000101
+ 10000011
-----------
10001000Interpreted as sign-magnitude, 10001000 means: sign bit 1 (negative), magnitude 0001000 = 8, so the "result" reads as -8. The correct answer is 2. Naive binary addition on sign-magnitude patterns simply gives the wrong answer whenever the operands have different signs — you'd need dedicated logic to detect differing signs, subtract magnitudes instead of adding them, and figure out the result's sign separately. That's expensive silicon for something as fundamental as addition.
The two's complement recipe
Two's complement sidesteps both problems. For an n-bit signed integer, the representable range is:
-2^(n-1) ≤ x ≤ 2^(n-1) - 1For 8 bits specifically: -128 ≤ x ≤ 127 — note the asymmetry, one more negative value than positive. We'll come back to exactly why in the overflow section.
To find the representation of -x given the representation of +x:
1. Invert every bit (0 → 1, 1 → 0)
2. Add 1 to the resultWorked example — represent −5 in 8 bits:
+5: 00000101
invert: 11111010
add 1: 11111011So -5 is 11111011, which is 0xFB in hex — exactly the value used in the buffer-dump illustration earlier in this post.
Worked example — represent −1 in 8 bits (a useful pattern to memorize):
+1: 00000001
invert: 11111110
add 1: 11111111-1 is always all ones, at any bit width — 0xFF for 8 bits, 0xFFFFFFFF for 32 bits, 0xFFFFFFFFFFFFFFFF for 64. This is worth memorizing because it comes up constantly in debugging (a return value of "all Fs" is very often -1, e.g. a failed syscall) and in bitwise tricks (~0 is a fast way to write "all bits set" in C++).
Worked example — the edge case, −128 in 8 bits:
+128 doesn't fit in 8-bit signed range at all (max is 127),
but we can still ask: what's the two's complement pattern for -128?
10000000Check it directly: invert 10000000 → 01111111, add 1 → 10000000. It maps to itself. -128 is representable (it's the single most-negative value), but +128 is not — there is no positive counterpart in the 8-bit signed range. This single fact is the entire reason abs(INT_MIN) is undefined behavior in C/C++: computing abs(-128) in an 8-bit-analogous world would need to produce 128, which doesn't exist in the type's range, so the "correct" answer is not representable in the same type at all.
Why this actually works: one adder does both jobs
The entire point of two's complement — the property that justifies all the bit-twiddling above — is that ordinary unsigned binary addition, applied directly to the bit patterns, produces the correct signed result, with no special-casing for sign at all. No extra subtractor circuit, no sign-comparison logic. Just one adder.
Worked example — mixed signs, 9 + (-4):
9 = 00001001
-4 (2's c) = 11111100
00001001
+ 11111100
-----------
1 00000101Discard the 9th-bit carry out (we'll return to exactly when it's safe to discard it, in the overflow section): 00000101 = 5. And 9 + (-4) = 5 — correct, using the exact same binary adder that computed 10 + 20 = 30 back in the state-transformation section.
Worked example — two negatives, -5 + (-3):
-5 (2's c) = 11111011
-3 (2's c) = 11111101
11111011
+ 11111101
-----------
1 11111000Discard the carry: 11111000. Convert back to check: invert → 00000111, add 1 → 00001000 = 8, so 11111000 = -8. And indeed -5 + (-3) = -8. Same adder, still correct, no sign logic anywhere in the circuit.
This is the payoff of the whole representation. Two's complement is deliberately designed so that a single, simple hardware adder can perform both unsigned and signed addition correctly. The "meaning" of the bit pattern (signed vs. unsigned) only matters when you're interpreting the result — the addition circuitry itself doesn't need to know or care which interpretation you intend.
Common misconception: that two's complement is an arbitrary historical convention that "just happens to be standard." It isn't arbitrary — it's the specific encoding that makes addition sign-agnostic, which is precisely why it beat out sign-magnitude and one's complement (a similar-but-flawed earlier scheme that also has a redundant
±0) decades ago. It's such a load-bearing property of modern hardware that as of C++20, the standard was changed to mandate two's complement as the only permitted representation for signed integer types — before C++20 it was technically implementation-defined (sign-magnitude and one's complement were nominally allowed), even though virtually every real compiler had used two's complement for decades already. What C++20 did not change is that signed integer overflow remains undefined behavior — the representation is now guaranteed, but overflowing it is still something the standard allows the compiler to assume never happens, which enables optimizations (and, notoriously, occasional very surprising miscompilations when that assumption is violated).
Overflow: When the Representation Runs Out of Room
Every representation we've covered has a finite range, and arithmetic can push a result outside that range. That's overflow, and — as the C++ note above hints — how a CPU reports and how software handles it are two separate concerns that are frequently confused.
Positive signed overflow
Take 8-bit signed integers. The maximum representable value is 127. Add 1:
127 = 01111111
+ 1 = 00000001
--------------
10000000The 8-bit result 10000000 is the two's complement pattern for -128, not 128 — 128 isn't representable at all in this width. Mathematically-correct addition produced a result that overflowed the type; the hardware silently produced a wrapped, wrong-signed value.
Negative signed overflow
Symmetrically, take the two most-negative-ish values and push further negative. -128 + (-1):
-128 = 10000000
-1 = 11111111
--------------
1 01111111Discarding the 9th-bit carry gives 01111111 = 127. Two negative numbers just summed to a positive one — obviously wrong, and a second flavor of overflow.
The hardware-level detection rule
Both examples above share a detectable pattern, and it's worth knowing the exact circuit-level rule, not just the "operands same sign, result different sign" shortcut (which is correct but hides how it's actually implemented). Track the carry bit flowing into the most significant bit position versus the carry flowing out of it:
Overflow ⟺ carry_in(MSB) ≠ carry_out(MSB)Checking the 127 + 1 example bit by bit, the carry chain into bit 7 is 1 (everything below was 1+0 with an incoming carry rippling all the way up), and the carry out of bit 7 is 0 (the final 0+0+carry-in produces no new carry). 1 ≠ 0 → overflow, matching the wrong result we saw. This XOR-of-two-carries is literally the circuit an ALU uses to set the overflow flag — it's one extra XOR gate tapped off the adder's internal carry chain, not a separate comparison unit.
Signed overflow vs. unsigned wraparound are different events
This is the single most common flag-related mixup, so it's worth its own worked contrast. Take the same 8-bit addition, 255 + 1, interpreted two ways:
As unsigned: 255 is the maximum unsigned 8-bit value. 255 + 1 = 256, which needs 9 bits; only 8 are kept, so the stored result wraps to 0. The hardware carry-out bit is 1 — that's the unsigned overflow indicator.
As signed: the bit pattern 11111111 used as an input actually represents -1 in two's complement, not 255. So the "same" bit-level addition, read as signed, is -1 + 1 = 0 — completely correct, no overflow at all.
11111111
+ 00000001
-----------
1 00000000The bit-level computation is identical in both cases (it's the same adder!) — only the interpretation of the inputs and the flag that matters differ. Unsigned code should watch the carry flag; signed code should watch the overflow flag. They are set independently by the same addition and frequently disagree with each other.
Common misconception: treating "carry flag" and "overflow flag" as synonyms, or assuming one implies the other. They answer two different questions about the exact same bit operation — "did this overflow assuming the operands were unsigned?" versus "did this overflow assuming the operands were signed?" — and a given addition can set either, both, or neither, independently. Using the wrong flag after a comparison or subtraction is a real bug class in hand-written assembly and in compilers' code generation, which is exactly why ISAs expose both flags separately rather than collapsing them into one "something went wrong" bit.
How different ISAs expose this
| ISA | Flag mechanism | Notes |
|---|---|---|
| x86-64 | EFLAGS/RFLAGS register: CF (carry), OF (overflow), ZF (zero), SF (sign), plus PF, AF | Flags are an implicit side effect of most ALU instructions |
| ARM64 | NZCV condition flags in PSTATE: N, Z, C, V | Only set by instructions with the S suffix (e.g. ADDS) or comparison instructions — ordinary ADD leaves flags untouched |
| RISC-V | No flags register at all | By design. Comparisons and overflow checks use explicit instructions (SLT/SLTU for "set less than", explicit branch instructions like BLT/BLTU) instead of implicit condition codes |
RISC-V's choice is a deliberate architectural bet: implicit flag registers create a hidden dependency between instructions (every flag-setting instruction is a producer, every flag-reading branch is a consumer), which complicates out-of-order scheduling and pipeline hazard detection. RISC-V's designers decided that cost wasn't worth it and pushed the same information into ordinary, explicit register results instead — a good example of an ISA-level decision made for microarchitectural reasons, tying the top and bottom of the stack diagram from the very first section back together.
Tie-back to C++: UB vs. defined wraparound
This is where the representation-level facts above turn directly into everyday C++ behavior:
int a = INT_MAX; // 0x7FFFFFFF
int b = a + 1; // signed overflow: UNDEFINED BEHAVIOR
unsigned u = UINT_MAX; // 0xFFFFFFFF
unsigned v = u + 1; // defined: wraps to 0Signed overflow is undefined behavior in C++ — not "wraps to INT_MIN," even though that's what the two's-complement hardware will typically produce if the check is compiled naively. Because the standard says overflow can't happen, optimizing compilers are permitted to assume a + 1 > a always holds for signed a, which has, in real shipped code, eliminated overflow checks that programmers wrote specifically to catch overflow (if (a + 1 < a) // overflow! can get optimized away entirely, since the compiler "knows" the condition is always false). Unsigned overflow, by contrast, is fully defined as arithmetic modulo 2^n — the wraparound behavior demonstrated above is guaranteed, portable, and safe to rely on. This asymmetry is a genuine, sharp-edged gotcha in real C++ code, and it's a direct, load-bearing consequence of everything covered in this post: the representation (two's complement), the hardware behavior (silent wraparound with a flag set), and the language-level contract (UB for signed, defined modulo arithmetic for unsigned) are three separate layers that happen to interact in a way that surprises people constantly.
Putting It Together: A Byte-Level Walkthrough
As a last concrete example tying every idea in this post into one artifact, consider the x86-64 instruction:
mov eax, -5An assembler encodes this as five bytes: the opcode B8 (which means "move a 32-bit immediate into EAX"), followed by the immediate itself, little-endian:
B8 FB FF FF FFWhere does FB FF FF FF come from? Exactly the two's complement recipe from earlier, just at 32 bits instead of 8: take +5 (0x00000005), invert every bit (0xFFFFFFFA), add 1 (0xFFFFFFFB). Store that 32-bit value little-endian — least significant byte first — and you get FB FF FF FF. This is precisely the same value that appeared in the illustrative buffer dump near the start of this post; that was never a coincidence, it was foreshadowing this exact derivation.
Stare at the full instruction bytes as an outside observer with no context, the way a disassembler effectively has to:
B8 FB FF FF FFThere is nothing in these five bytes that inherently says "instruction" as opposed to "five arbitrary data bytes that happen to also look like -5 in hex." A disassembler only knows to read it as mov eax, -5 because it's walking the instruction stream starting from a known-good boundary (the function entry point) and applying the x86-64 opcode table, which says "byte B8 starts a 5-byte instruction, and the next 4 bytes are a little-endian signed 32-bit immediate." Every idea from this post is present in that one instruction: bits with context-dependent meaning, hexadecimal as a compact human-readable encoding of those bits, and two's complement making the negative immediate representable and directly usable by the same hardware that would just as happily load a positive one.
Further Reading
- MIT OCW 6.004, Computation Structures — Section 1: Basics of Information
- MIT OCW 6.004 — Worked Example: Two's Complement Addition
- GeeksforGeeks — Two's Complement
- GeeksforGeeks — Overflow in Arithmetic Addition in Binary Number System
- Raktim Bora, "Understanding Two's Complement," Medium
- David A. Patterson and John L. Hennessy, Computer Organization and Design — the standard reference for number representation, ALU design, and the RISC-V examples used throughout that text
- Sarah L. Harris and David Money Harris, Digital Design and Computer Architecture — particularly strong on the gate-level construction of adders and overflow detection, which the next parts of this series build on directly
With bits, bytes, hex, and two's complement in place, the next post — Lesson 1, Part 2, "From Logic Gates to a 32-bit ALU" — builds the actual adder circuit that performs the arithmetic shown here, starting from individual logic gates and working up through a full ripple-carry adder to the point where we can wire up an ALU capable of everything this post's worked examples assumed hardware could just... do.