Registers, the Register File, and the Program Counter
Part 4 of 15
Lesson 1, Part 3 established that a single flip-flop captures one bit of state on the clock edge — D flows to Q, and it stays there until the next edge. That's a nice fact about one bit. It says nothing yet about how a CPU holds a 64-bit number, names it R1, and reads two of them in the same cycle that a third one gets written. That's the jump we make in this post.
1. From One Flip-Flop to a Register
A flip-flop stores one bit. A register stores a word — and a word is just several bits that we've decided to treat as a single named quantity. There is no new primitive here. A register is nothing more than N flip-flops, standing side by side, that all share the same clock and the same write-enable signal.
D[0] ──►[FF]──► Q[0]
D[1] ──►[FF]──► Q[1]
D[2] ──►[FF]──► Q[2]
... ...
D[N-1]►[FF]──► Q[N-1]
▲
│
shared CLK, shared WE"Shared clock" is doing more work here than it looks like. If bit 5 of a register updated on a slightly different clock edge than bit 4, you would momentarily have a corrupted word — half old value, half new value — visible to anything reading the register. Every flip-flop in a register is wired to the same clock net, so the entire word transitions atomically, in the same instant, as far as the rest of the circuit can tell. That atomicity is what lets us stop thinking about "64 independent bits" and start thinking about "one 64-bit number."
The write-enable (WE) line matters just as much. Without it, a register would capture new data on every single clock edge, which is nearly never what you want — most cycles, most registers should hold still. WE is ANDed into (or otherwise gates) the clock or the D-input path of each flip-flop, so the register only actually updates on cycles where the controlling logic asserts WE = 1. On every other cycle, Q simply continues outputting whatever it last held, even though the clock keeps ticking underneath it. This is the register-level version of a fact we'll use constantly in the datapath: presenting new data on the input wires does nothing by itself — only a clock edge with write-enable asserted commits it.
So the complete picture:
1.1 Why Width Is a Real Design Decision
N isn't an arbitrary implementation detail — it's an architectural commitment that ripples through the entire ISA. It sets:
- The largest integer a single register can hold without splitting across multiple registers.
- The size of a virtual address, which caps how much memory a program can address directly.
- The width of every ALU, every register-file read/write port, and every bus in the datapath — all of it has to match
N, because a 64-bit adder wired to a 32-bit register is just a 32-bit adder with expensive extra wires.
Real ISAs have moved through this exact width progression, and each generation kept the old width alive as a subset rather than throwing it away:
8-bit → early microcontrollers, small counters, byte-oriented data
16-bit → early x86 (8086), address space measured in tens of KB
32-bit → dominant from the 1990s–2000s (x86 IA-32, ARMv7, MIPS32)
64-bit → current desktop/server/mobile standard (x86-64, AArch64, RISC-V RV64)The reason 8/16/32-bit didn't just vanish when 64-bit arrived is backward compatibility: an enormous amount of existing software assumes 32-bit registers exist. So instead of discarding the smaller widths, mainstream ISAs did something more interesting — they made the smaller-width registers aliases into the low bits of the full-width register.
1.2 x86-64: RAX / EAX / AX / AL as One Physical Register
Take x86-64's accumulator register. There is exactly one 64-bit physical register here, and four different names give you access to different slices of it:
RAX— the full 64-bit register.EAX— the low 32 bits ofRAX.AX— the low 16 bits ofRAX(i.e., the low 16 bits ofEAX).AH/AL— the high and low bytes ofAXrespectively.
This is genuinely useful, not just historical baggage — a byte-sized loop counter can live in AL while the rest of the code freely uses RAX for 64-bit pointer arithmetic, and no register renaming or copying is needed to move between the two views. But the write behavior of these aliases is not symmetric, and it's a well-known gotcha:
- Writing
ALorAHmodifies only that byte; the rest ofEAX/RAXis left untouched. - Writing
AXmodifies only the low 16 bits; the upper 48 bits ofRAXare untouched. - Writing
EAX, however, zero-extends into the full 64-bitRAX— the upper 32 bits are explicitly cleared to zero.
That last rule is not an accident of the diagram, it's an explicit architectural decision baked into AMD64: a 32-bit result should never leave stale garbage sitting in the top half of a 64-bit register, because that garbage would silently corrupt any later 64-bit operation on the same register. Sub-32-bit writes don't get this cleanup, purely for 8086/80386-era compatibility — as one write-up of the naming scheme puts it, "the layering is not design, it's archeology" (Tushar Sadhwani, x86-64 Registers Explained).
1.3 ARM AArch64: Xn / Wn
ARM's 64-bit architecture (AArch64) uses the same aliasing idea with a cleaner, more regular naming scheme. Instead of historically-accreted names like EAX/AX/AH/AL, AArch64 just has 31 general-purpose registers, each with two names:
X0, X1, X2, ... X30 ← 64-bit view
W0, W1, W2, ... W30 ← 32-bit view (low half of the same physical register)W0 is simply the low 32 bits of X0; there is no further byte-level aliasing the way x86 has AH/AL. And ARM made the same zero-extension choice AMD made for EAX: writing to a Wn register zero-extends the result into the full 64-bit Xn, clearing the upper 32 bits, while reads through Wn simply ignore (and leave unchanged) whatever is sitting in the upper half. This is a deliberate consistency guarantee — a 32-bit computation can never leave undefined high bits behind for a later 64-bit use of the same register to trip over.
The practical lesson for a systems engineer: register width and register naming are an ISA-level abstraction over one piece of physical flip-flop hardware. There's still just one array of flip-flops per register; the "different sized registers" you see in assembly are different address/wiring taps into the same physical bits, with architecturally-defined rules about what happens to the untouched bits on a write.
Key takeaway: A register is N flip-flops sharing one clock and one write-enable. Register width is an ISA decision with real consequences (max integer size, address space, datapath width), and both x86-64 and AArch64 expose sub-widths as aliases into the same physical bits — with explicit, documented rules about zero-extension on write.
2. The Register File
A single register holds one number. A CPU needs many — general-purpose registers, R0 through R31 in a RISC-style ISA, or RAX through R15 in x86-64. You could imagine wiring each one up independently, but every instruction that touches a register needs its own private address decoder, its own bank of muxes to select it for reading, and its own enable logic for writing. That's a mess of near-identical control logic repeated 16 or 32 times. Instead, we do what we always do when we have N of the same primitive component: build a uniform, addressable array. That structure is the register file.
2.1 What a Register File Exposes
A classic RISC register file exposes exactly five kinds of signal:
ReadAddr1 — which register to read onto ReadData1 (combinational)
ReadAddr2 — which register to read onto ReadData2 (combinational)
ReadData1 — value read from ReadAddr1
ReadData2 — value read from ReadAddr2
WriteAddr — which register to write
WriteData — value to write
WriteEnable — whether the write actually happens this cycleInternally it's exactly what it looks like: an array of the N-bit registers from Section 1, plus two independent read-select muxes and one write-decoder. Nothing conceptually new — it's the register-file interface that matters, because that interface is precisely shaped by how instructions actually use registers.
2.2 Why Two Read Ports? Derive It From the Instruction
Don't take "two read ports" as a given — derive it. Consider:
ADD R3, R1, R2This instruction needs the values currently sitting in R1 and R2, and it needs both of them at once, in the same cycle, so they can both be fed into the ALU together. If the register file only exposed one read port, you would have to read R1, latch it somewhere, then read R2 on a second cycle before you could even start the addition — turning a single-cycle ALU operation into a two-cycle one, purely because of a resource conflict on the register file. MIT's 6.004 course materials on the Beta processor's datapath describe exactly this shape: after instruction fetch supplies the two register-select fields, "the RA and RB register values appear on the read data ports of the register file" and are routed directly into the ALU as its two operands in the same cycle (MIT OCW 6.004, Computation Structures).
So the two read ports aren't a nicety — they exist because the most common instruction shape in any RISC ISA is binary (two source operands, one destination), and one-cycle-per-instruction execution is only possible if both sources can be fetched simultaneously. A single write port, by contrast, is normally sufficient: a plain ALU instruction produces exactly one result, so one write port serves the common case. (Superscalar and out-of-order machines add more read and write ports for exactly this reason — but that's a machine-width problem for a much later post, not a Lesson 1 concern.)
2.3 Asynchronous Read, Synchronous Write
Here's a detail that trips people up the first time they see a register-file schematic: reads are typically combinational (asynchronous), while writes are clocked (synchronous). That asymmetry is deliberate, and it follows from Section 1 almost immediately.
- Reads are combinational.
ReadAddr1/ReadAddr2feed into pure muxes that select among theQoutputs of the underlying flip-flops. A mux has no clock — it just routes whatever is already sitting on its inputs to its output, continuously. So the momentReadAddr1changes,ReadData1changes too, with no clock edge required. This matters for timing: the ALU needs its operands available early in the cycle so it has the rest of the cycle to compute a result before the next edge. - Writes are clocked.
WriteDataonly actually lands in the target register's flip-flops on the active clock edge, and only ifWriteEnableis asserted. This is exactly the write-enable behavior from Section 1 — the register file's write port is just the sharedWE/CLKwiring pattern, gated additionally by an address decoder so only the selected register'sWEfires.
This "asynchronous read / synchronous write" pattern shows up by name across register-file implementations and RISC-V teaching cores — reads happen combinationally so the value is available in the same cycle as the address, while writes commit only on the clock edge (see the RISC-V-from-scratch walkthrough of a Verilog register file, and 6.004's treatment of the Beta datapath, both cited below). It's also exactly the model used in Harris & Harris's Digital Design and Computer Architecture, where the single-cycle RISC-V datapath reads rs1/rs2 combinationally in the same cycle it computes and writes back rd.
2.4 Timing Diagram: One Instruction, One Cycle
Let's make this concrete with ADD R3, R1, R2, where at the start of the cycle R1 = 10 and R2 = 20.
CLK __/‾‾\__/‾‾\__/‾‾\__
^cycle 1^
ReadAddr1 ───< R1 >──────────
ReadAddr2 ───< R2 >──────────
ReadData1 ───< 10 >────────── (combinational — settles almost immediately)
ReadData2 ───< 20 >──────────
ALU inputs ───< 10,20 >───────
ALU output ───< 30 >────────── (settles partway through the cycle)
WriteAddr ───< R3 >──────────
WriteData ───< 30 >──────────
WriteEnable──────1─────────────
▲
│ rising edge: R3 ← 30 is committed HEREReadData1/ReadData2 are valid almost as soon as the addresses are, because there's no clock in the read path. WriteData (the ALU's sum) also becomes valid mid-cycle, once the ALU has had time to compute — but it does not actually land in R3 until the next rising clock edge, when WriteEnable = 1 commits it. This is the same D-flip-flop discipline from Part 3, just applied at the register-file's write port instead of a lone flip-flop.
2.5 Worked Example: Three Instructions Across Three Cycles
Now trace state across several cycles to see reads and writes interact, including the read-after-write case that register files are specifically built to handle correctly.
Initial: R1 = 5, R2 = 3, R3 = 0, R4 = 0
Cycle 1: ADD R3, R1, R2 ; R3 <- R1 + R2 = 5 + 3 = 8
ReadAddr1=R1(5), ReadAddr2=R2(3) → ALU computes 8
At rising edge: WriteAddr=R3, WriteData=8, WE=1 → R3 becomes 8
Cycle 2: ADD R4, R3, R1 ; R4 <- R3 + R1 = 8 + 5 = 13
ReadAddr1=R3 → reads 8 (the value written LAST cycle, now stable)
ReadAddr2=R1 → reads 5
At rising edge: R4 becomes 13
Cycle 3: ADD R3, R3, R3 ; R3 <- R3 + R3 = 8 + 8 = 16
ReadAddr1=R3 and ReadAddr2=R3 both read the SAME port value: 8
ALU computes 16
At rising edge: R3 becomes 16Two things worth pulling out of this trace. First, cycle 2 depends on cycle 1's write — and it works correctly precisely because the write from cycle 1 committed on that cycle's clock edge, so by the time cycle 2's combinational read happens, R3 is already sitting at its new, stable value of 8. If writes were asynchronous too, you'd have to worry about races between "is the new value settled yet" and "is the read happening now" — synchronous writes remove that ambiguity entirely. Second, cycle 3 reads the same register R3 on both read ports simultaneously (ADD R3, R3, R3) — which is exactly why the two read ports must be independent, not sharing circuitry: both ReadAddr1 and ReadAddr2 can legally be equal, and the register file must serve both without conflict.
Key takeaway: The register file is an addressable array of the N-bit registers from Section 1. It has two independent, combinational read ports (because a binary ALU instruction needs two operands in one cycle) and one clocked write port (because a write should commit atomically, exactly once, on an edge — never "sort of, continuously"). This asynchronous-read/synchronous-write split is what makes single-cycle RISC execution possible.
3. The Program Counter
Registers give us storage; the ALU (Part 2) gives us computation. But nothing so far tells the machine which instruction to fetch next. That's the job of one more special-purpose register: the Program Counter (PC).
The PC holds the memory address of the current (or next — this is an architectural convention that differs by ISA and by which pipeline stage you're looking at) instruction. Structurally it is nothing exotic — it's built from the exact same N-bit register primitive as Section 1, just dedicated to one specific job and wired into the instruction-fetch path instead of the general register file.
3.1 The Straight-Line Case: PC+4 vs. PC+2
For a fixed-width ISA where every instruction is 4 bytes (32 bits) — true of classic MIPS, ARMv7 in ARM mode, and base RV32I RISC-V — sequential execution just walks the PC forward by 4 each cycle:
PC = 0x1000 → fetch instruction at 0x1000
PC = 0x1004 → fetch instruction at 0x1004
PC = 0x1008 → fetch instruction at 0x1008
PC = 0x100C → ...But "instructions are always 4 bytes" is an assumption, not a law, and several real ISAs deliberately break it to save code size. ARM's Thumb mode uses mostly-16-bit encodings, and RISC-V's "C" (compressed) extension does the same: roughly half of a typical RISC-V program's instructions can be re-encoded as 16-bit compressed forms, yielding on the order of a 25–30% reduction in static code size (Waterman, Improving Energy Efficiency and Reducing Code Size with RISC-V Compressed, UC Berkeley). In these modes, a single instruction might be only 2 bytes wide, so PC advancement isn't a constant +4 anymore — it's +2 or +4 depending on which instruction was just fetched. RISC-V's encoding makes this decodable up front: every 32-bit instruction has its low two bits set to 11, while the patterns 00, 01, and 10 mark a 16-bit compressed instruction — so the fetch unit can tell, from the first 16 bits alone, exactly how far to advance the PC before it has even finished decoding the instruction (RISC-V Compressed ISA manual / riscv-isa-manual).
This is the same "N is an architectural decision" lesson from Section 1, applied to instruction width instead of register width: once you allow variable-length instructions, PC-advancement logic can no longer be a bare constant-adder — it has to be, at minimum, a small adder whose input (2 or 4) is itself selected by a decode signal.
3.2 Branches: the PC Needs a MUX Too
Sequential PC + 4 covers straight-line code. But:
BEQ R1, R2, targetmeans the next PC might not be PC + 4 at all — it might be target, if R1 == R2. This is precisely the same problem structure we saw with the ALU and control signals in Part 2: whenever there are two candidate values and a condition decides between them, the answer is a mux.
The PC register itself still just does one thing — load whatever is on its D input at the next clock edge, exactly like any other register. All the interesting decision-making (is this a branch? was it taken? where's the target?) happens in combinational logic feeding the PC's input mux; the PC is simply the last stop before that decision becomes the new fetch address. This is worth sitting with, because it generalizes: every "special" control-flow behavior in a CPU — branches, jumps, calls, returns, interrupts, exceptions — ultimately reduces to selecting a different value at the PC's input mux. The complexity lives in how many candidate next-PCs there are and what generates the select signal, never in the PC register itself.
3.3 A Real Gotcha: ARM's "PC Is Ahead of Itself"
Here's a wrinkle that has bitten real assembly programmers, and it's worth understanding precisely because it shows that "the PC" is not always as simple as "the address of the instruction currently executing."
On the classic ARM7-family 3-stage pipeline (fetch → decode → execute), when you read the PC as a general-purpose register from within an executing instruction, you don't get the address of that instruction — you get an address further ahead, because of pipelining. While one instruction is in its execute stage, the next instruction is already in decode, and the one after that is already being fetched — so the fetch stage's PC value has already advanced two instructions past the one currently executing. For ARM's 4-byte instructions, that offset works out to PC + 8 when read during execution; in Thumb mode with 2-byte instructions it's PC + 4 instead (GeeksforGeeks, Pipelining in ARM; RF Wireless World, ARM Register Set, Processor Models, and Pipeline Concept).
Concretely: if an instruction sits at address 0x8000 and reads the PC as an operand while it executes, it doesn't see 0x8000 — on classic ARM state it sees 0x8008, because the fetch unit is already three instructions (0x8000, 0x8004, 0x8008) into the pipeline by the time that read happens. Compilers and assemblers targeting these cores have to account for the +8/+4 offset explicitly whenever code does PC-relative addressing (a very common trick for position-independent code and literal pools).
The deeper point: the PC is architecturally defined as "the address of the current instruction," but physically, in a pipelined implementation, there may be no single flip-flop that always holds exactly that value — different pipeline stages can have their own PC copies at different points in the pipeline, and what you observe when you "read the PC" depends on which stage's copy you're actually looking at. This is a preview of a much bigger theme this series will return to once we build pipelining: the clean single-cycle model of "one PC register, one value" starts to fragment the moment instructions overlap in time, and ISA designers have to make an explicit, documented choice about which pipeline stage's PC value is the architecturally-visible one.
Key takeaway: The PC is a register like any other — same flip-flops, same clocked-load discipline — but its input comes from a mux selecting between
PC + 4(or+2, for compressed/Thumb instructions) and a branch/jump target. Variable-length ISAs make even the "sequential" path non-trivial, and pipelined implementations can make "reading the PC" return a value several instructions ahead of the one actually executing.
Further Reading
- MIT OpenCourseWare, 6.004 Computation Structures — Register File / Beta datapath lecture materials: ocw.mit.edu/courses/6-004-computation-structures-spring-2017/pages/c13/c13s1/
- David A. Patterson and John L. Hennessy, Computer Organization and Design (RISC-V/ARM/MIPS editions) — the canonical treatment of the single-cycle datapath, register file ports, and PC-update logic.
- Sarah L. Harris and David Money Harris, Digital Design and Computer Architecture — worked single-cycle RISC-V datapath with the same asynchronous-read/synchronous-write register file design used in Section 2.
- GeeksforGeeks, Pipelining in ARM: geeksforgeeks.org/digital-logic/pipelining-in-arm — source for the classic ARM
PC+8/PC+4pipeline offset discussed in Section 3.3. - Andrew Waterman, Improving Energy Efficiency and Reducing Code Size with RISC-V Compressed, UC Berkeley technical report — background on the RISC-V "C" extension and its effect on PC advancement: people.eecs.berkeley.edu/~krste/papers/waterman-ms.pdf
- Tushar Sadhwani, x86-64 Registers Explained, Medium — RAX/EAX/AX/AL aliasing and zero-extension-on-write behavior: medium.com/@tushrsa/x86-64-registers-naming-explained-1cd958628090
With storage (registers, the register file) and sequencing (the PC) in place, the next question is: where do instructions themselves live, and how does a raw fetched bit pattern turn into ALU operations, register addresses, and control signals? That's Lesson 1, Part 5: Instruction Memory, Decode, and the Control Unit.