Back to Blog

Computer Architecture, Bits, and Number Representation

August 18, 202630 min read
Computer Architecture Digital Logic Systems Learning

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:

SOFTWARE C / C++ / Python / OS / Compiler compiles/assembles to ISA ARM64 / x86-64 / RISC-V registers, instructions, encoding, memory model — a *contract*, not a chip implemented by MICROARCHITECTURE pipeline depth, caches, branch predictor, out-of-order engine, issue width built from DIGITAL LOGIC gates, adders, multiplexers, flip-flops fabricated as TRANSISTORS physical devices switching current

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:

ISAGPR widthRegister countRegister naming
x86-6464-bit, with 32/16/8-bit sub-views16 (RAX–R15)RAXEAXAXAL/AH (same physical storage, different width windows)
ARM64 (AArch64)64-bit31 (X0–X30) + SPX0X30 are 64-bit views; W0W30 are the low 32 bits of the same registers
RISC-V (RV64)64-bit32 (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, R2

Applying F:

R3_new = R1 + R2 = 10 + 20 = 30
PC_new = PC + 4 = 0x1000 + 4 = 0x1004
 
After:
R1 = 10
R2 = 20
R3 = 30
PC = 0x1004

Two 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, 0x2100

Here 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], R2
After:
R1 = 0x3000
R2 = 42
Mem[0x3000] = 42

This 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
1

One such binary digit is a bit. Group eight of them and you get a byte:

8 bits = 1 byte

An 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 characters H and i.
  • 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^80 possible 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 a struct and a char buffer 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

1011

Positions, right to left, are 3 2 1 0:

1·2^3 + 0·2^2 + 1·2^1 + 1·2^0
= 8 + 0 + 2 + 1
= 11

Worked example 2 — an 8-bit value, and where hex comes from

10110101
1·128 + 0·64 + 1·32 + 1·16 + 0·8 + 1·4 + 0·2 + 1·1
= 128 + 32 + 16 + 4 + 1
= 181

Split 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 0000

Only 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 nMax unsigned value (2^n - 1)Common C++ type
8255uint8_t, unsigned char
1665,535uint16_t, unsigned short
324,294,967,295uint32_t, unsigned int (typically)
6418,446,744,073,709,551,615uint64_t, size_t (typically, on 64-bit targets)

Common misconception: "unsigned means it can't misbehave." People reach for unsigned types 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 produces 18446744073709551614 on a 64-bit system (that's 2^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 (09, AF).

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       F

Because 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    6

so 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    F

Notice 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 like 0x1000 is compiled, it becomes the same binary pattern unsigned 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
-----------
  10001000

Interpreted 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) - 1

For 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 result

Worked example — represent −5 in 8 bits:

+5:      00000101
invert:  11111010
add 1:   11111011

So -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?
 
10000000

Check it directly: invert 1000000001111111, 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 00000101

Discard 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 11111000

Discard 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
--------------
       10000000

The 8-bit result 10000000 is the two's complement pattern for -128, not 128128 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 01111111

Discarding 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 00000000

The 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

ISAFlag mechanismNotes
x86-64EFLAGS/RFLAGS register: CF (carry), OF (overflow), ZF (zero), SF (sign), plus PF, AFFlags are an implicit side effect of most ALU instructions
ARM64NZCV condition flags in PSTATE: N, Z, C, VOnly set by instructions with the S suffix (e.g. ADDS) or comparison instructions — ordinary ADD leaves flags untouched
RISC-VNo flags register at allBy 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 0

Signed 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, -5

An 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 FF

Where 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 FF

There 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

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.