The Register File in RTL

The contract is signed — time to cut the first metal. We start with the piece every other block will lean on: the register file, the CPU's 32-word scratchpad. It is the busiest memory on the chip: in the machine we are building it will be read twice and written once every single cycle, forever. You already know its software face from the RV32I plan — 32 registers of 32 bits, with x0 pinned to zero — and its place beside the ALU from computer systems. Today we pin down its hardware truth: exactly what ports it has, exactly when data moves, and exactly how x0 stays zero.

Two reads, one write: counting ports from the ISA

How many ports does the register file need? Don't guess — read the contract. add x3, x1, x2 must, in one cycle, read x1 and x2 and write x3. That is the worst case in our subset, so the register file is a 2R1W memory: two read ports (traditionally named for the fields that drive them, rs1 and rs2) and one write port (rd). Three instructions can touch it at once — two readers and a writer — and none may wait, because in a CPU "wait" means a stalled machine.

The two kinds of port work on different clocks of logic:

The module, in full

Here is the whole thing — genuinely all of it. After five modules of RTL, a 32×32 2R1W register file with a hardwired zero comes to about a dozen lines:

module regfile ( input logic clk, input logic we, // RegWrite, from the control unit input logic [4:0] rs1, rs2, rd, // two read addresses, one write address input logic [31:0] wdata, output logic [31:0] rdata1, rdata2 ); logic [31:0] regs [31:0]; // the 32 x 32-bit array // Two combinational read ports: each is just a 32-way mux. // x0 is forced to zero on the way out. assign rdata1 = (rs1 == 5'd0) ? 32'd0 : regs[rs1]; assign rdata2 = (rs2 == 5'd0) ? 32'd0 : regs[rs2]; // One clocked write port. Writes aimed at x0 are silently dropped. always_ff @(posedge clk) if (we && rd != 5'd0) regs[rd] <= wdata; endmodule

Read the x0 defence carefully — it is belt and braces. The read ports force zero out whenever the address is 0, and the write port refuses to store when rd is 0. Either alone would almost work; together they make x0 == 0 an invariant no instruction sequence can break. Synthesis enjoys the braces too: register 0 is never written and never read as storage, so the optimizer quietly deletes its 32 flip-flops.

Drop the rd != 5'd0 guard and everything looks fine — until a program runs jal x0, loop (a plain jump: "discard the return address"). Your register file dutifully stores the return address into register 0… and if the read path returns regs[0] rather than forced zero, x0 is suddenly, silently, not zero. Every later "load constant" (addi x5, x0, 7), every NOP, every "compare against zero" is now poisoned — the program doesn't crash at the store, it goes subtly insane hundreds of instructions later. This is a classic CPU bug class: the violation and the symptom are far apart. Tuck that away — in the final lesson we will write an assertion whose only job is to catch exactly this.

Same cycle, same register: who wins?

One timing subtlety decides whether next lesson's CPU is buggy or correct. Suppose in one cycle the write port is storing 42 into x7 while a read port is reading x7. What comes out? Trace the hardware: the read mux taps the flip-flops' current outputs, all cycle long; the write only lands at the edge that ends the cycle. So the read sees the old value, and the new one becomes visible the following cycle. For our single-cycle CPU this is exactly right: an instruction reads its sources before its own result exists — reads-see-old matches program order perfectly. (File the question away, though: when the pipeline arrives, reader and writer will be different instructions sharing a cycle, and this same question becomes a genuine hazard with a whole lesson of consequences.)

The model, running

Now the executable twin — the same 2R1W behaviour in the two-phase style we will grow into a full CPU next lesson: reads are plain functions (combinational), and the write produces the next state (the edge). Watch cycle 0 read the old value of the register it is writing, and cycles 1–2 bounce off x0:

// -- The register file: 32 words of 32 bits, two read ports, one write port. -- interface RF { regs: number[] } // Combinational reads: just a big mux. x0 always reads as zero. const read = (rf: RF, r: number): number => (r === 0 ? 0 : rf.regs[r]); // The clocked write: honoured only when we is set AND rd is not x0. function write(rf: RF, we: number, rd: number, wdata: number): RF { const regs = rf.regs.slice(); if (we && rd !== 0) regs[rd] = wdata | 0; return { regs }; } // -- Drive it single-cycle style: reads settle first, then the edge commits the write. -- interface Cycle { rs1: number; rs2: number; we: number; rd: number; wdata: number; note: string } const script: Cycle[] = [ { rs1: 0, rs2: 5, we: 1, rd: 5, wdata: 123, note: "write x5=123; the same-cycle read of x5 sees the OLD value" }, { rs1: 5, rs2: 0, we: 1, rd: 0, wdata: 999, note: "x5 now reads 123; meanwhile, try to write x0=999..." }, { rs1: 0, rs2: 5, we: 1, rd: 6, wdata: 7, note: "...and x0 still reads 0: the write was silently dropped" }, { rs1: 6, rs2: 5, we: 0, rd: 7, wdata: 42, note: "we=0: rd and wdata are ignored entirely" }, { rs1: 7, rs2: 6, we: 0, rd: 0, wdata: 0, note: "x7 was never written; x6 holds 7" }, ]; let rf: RF = { regs: new Array(32).fill(0) }; console.log("cycle | rdata1 rdata2 | write | note"); console.log("------+---------------+-----------+-----"); script.forEach((c, i) => { const r1 = read(rf, c.rs1), r2 = read(rf, c.rs2); // reads: combinational, pre-edge rf = write(rf, c.we, c.rd, c.wdata); // the rising edge const w = c.we ? `x${c.rd} := ${c.wdata}` : "(we=0)"; console.log(` ${i} | ${String(r1).padStart(4)} ${String(r2).padStart(4)} | ${w.padEnd(9)} | ${c.note}`); }); console.log(`\nafter: x0=${read(rf, 0)} x5=${read(rf, 5)} x6=${read(rf, 6)} x7=${read(rf, 7)}`);

Five cycles, every rule on display: combinational reads, the edge-committed write, reads-see-old on a same-cycle collision, we gating the port, and x0 shrugging off a write. Keep read and write in your head — next lesson they get sandwiched between a decoder and an ALU, and the machine starts to run.

Flip-flops today, SRAM tomorrow

Our register file is built from flip-flops — 31 real registers × 32 bits ≈ a thousand flops plus two 32-way muxes. At this size that is the right call: cheap, fast, and the synthesis tool handles it without ceremony. But the approach does not scale. Multiply the muxes and the fan-out and you can feel it: every extra read port replicates a 32-way, 32-bit mux; every extra entry deepens it. Big register files — a GPU's, say — abandon flip-flops for dense SRAM banks and start playing scheduling games to fake many ports out of few. If you want to see how monstrous the same idea can get, peek at the GPU register file: megabytes of registers per chip, served by banked SRAM and an operand collector. Same contract as our twelve lines — read sources, write results — utterly different machinery. The ISA-vs-implementation lesson, already paying rent.

If registers are this cheap, why stop at 32? Because the register file is cheap but the register fields are not: each register number costs 5 bits of every instruction, and an R-type already spends 15 of its 32 bits naming registers. Doubling to 64 registers would spend 18 bits — squeezing opcodes and immediates — for gains that fall off a cliff once the compiler has "enough" places to keep loop variables. RISC-V's answer is 32 (and even a 16-register embedded variant, RV32E, for tiny cores). The sweet spot is an ISA-level trade-off between encoding space, register-file area, and how many live values compilers actually juggle — decided in 1980s RISC research and barely moved since.