The Register File

The register file is the 32-entry, 32-bit-wide scratchpad that every RV32I instruction reads from and writes to. Architecturally it is defined in one sentence of the RISC-V Unprivileged ISA specification: “For RV32I, the 32 x registers are each 32 bits wide, i.e., XLEN=32. Register x0 is hardwired with all bits equal to 0” (Unprivileged ISA, version 20240411, §RV32I Programmers’ Model). Structurally it is the single most port-constrained block in the datapath: a single-issue RV32 core needs to read two operands and write one result every cycle, so the file needs two read ports and one write port — no more, and no fewer. Two details make it more interesting than “an array of 32 words”: x0 is implemented as a read multiplexer, not as storage, and a surprising amount of the ISA is built on that fact; and the question of what happens when the same register is read and written in the same cycle has no single right answer, only a choice you must make deliberately and then honour everywhere else in the pipeline.

This note builds the module, simulates it, and shows the output. The context is definitely-not-esp32 MOC Stage 2 — “a single-cycle RV32I core that executes addi x1, x0, 42 and halts” — where the register file is the known answer: you read x1 out of the simulator and check it by hand. Everything here is verified against the ratified specification and against a testbench that ran on the machine this note was written on (Icarus Verilog 13.0, Verilator 5.046).

Mental Model

Think of the register file as a switchboard with three cables and thirty-one lockers. Two of the cables are read cables: you plug each into any locker number and the contents appear immediately at the other end, in the same cycle, with no clock edge involved. One cable is a write cable: you point it at a locker, put a value on it, and the value lands on the next rising clock edge. Locker zero does not exist — the read cables have a switch on them that says “if the number is zero, return all zeros and do not bother looking”, and the write cable has a switch that says “if the number is zero, throw the value away”.

That is the entire module. It is perhaps forty lines of Verilog. What makes it worth a note is that each of those three cables and both of those switches encodes a real architectural decision, and that getting the timing of the write cable wrong relative to the read cables is the single most common way a first core produces plausible-looking but wrong results.

flowchart LR
    subgraph DEC["from the decoder"]
        A1["rs1_addr<br/>instr[19:15]"]
        A2["rs2_addr<br/>instr[24:20]"]
        AD["rd_addr<br/>instr[11:7]"]
    end
    subgraph RF["register file"]
        ARR[("xr[1..31]<br/>31 x 32-bit<br/>flip-flops or RAM")]
        M1{{"x0 mux<br/>addr==0 ?"}}
        M2{{"x0 mux<br/>addr==0 ?"}}
        WG{{"write gate<br/>we &amp; addr!=0"}}
    end
    A1 -->|"read port 1"| ARR
    A2 -->|"read port 2"| ARR
    ARR --> M1 --> O1["rs1_data<br/>-> ALU operand a"]
    ARR --> M2 --> O2["rs2_data<br/>-> ALU operand b / store data"]
    ZERO(["constant 32'h0"]) --> M1
    ZERO --> M2
    AD --> WG
    WEN["we<br/>from control"] --> WG
    WD["rd_data<br/>from ALU / load / pc+4"] --> WG
    WG -->|"on posedge clk"| ARR
    CLK["clk"] -.-> ARR

The register file’s ports and the x0 multiplexer. What it shows: three address buses arrive from the instruction word at fixed bit positions, two of them driving combinational reads and one driving a clocked write. The two diamond-shaped muxes on the read paths and the one gate on the write path are the whole of x0’s implementation. The insight to take: the reads are asynchronous — address in, data out, no clock edge — while the write is synchronous. That asymmetry is not stylistic; a single-cycle core fetches, decodes, reads, computes, and writes back inside one clock period, so the read must complete combinationally within that period. It is also the reason this structure does not map cleanly onto FPGA block RAM, which has no asynchronous read port at all (see What It Becomes on an FPGA).

Two Reads and One Write — Why That Exact Port Count

The port count is not a design choice you get to make. It falls directly out of the instruction encoding. Look at where the register specifiers live in an RV32 instruction word:

packet-beta
0-6: "opcode (7)"
7-11: "rd (5)"
12-14: "funct3 (3)"
15-19: "rs1 (5)"
20-24: "rs2 (5)"
25-31: "funct7 (7)"

The R-type instruction word, the format that uses every register port at once. What it shows: three five-bit register specifiers at fixed positions — rd at bits 11:7, rs1 at 19:15, rs2 at 24:20 — laid out exactly as the ratified spec’s base instruction format table gives them (Unprivileged ISA 20240411, §Base Instruction Formats). The insight to take: five bits is exactly log2(32), and there are exactly three of them. The register file’s port count is therefore fixed by the encoding, and the addresses can be wired straight from the instruction word to the register file before decode has finished — you do not need to know what the instruction is to start reading its operands. This is why RISC-V decode is cheap: the register read begins speculatively in parallel with opcode decode, and if the instruction turns out not to use rs2, you throw the value away.

RISC-V deliberately puts rs1, rs2 and rd at the same bit positions in every format that uses them. The spec is explicit that this is the point: the immediate bits get scrambled precisely so the register fields do not have to move (Unprivileged ISA 20240411, §Immediate Encoding Variants; see RISC-V Instruction Formats for the full six-format story). The consequence for this module is that the two read-address inputs are literally instr[19:15] and instr[24:20], with no logic in between.

Now count what a single-issue core actually needs in one cycle:

Instruction classReadsWritesWorst case
add rd, rs1, rs2 (R-type)rs1, rs2rd2 reads, 1 write
addi rd, rs1, imm (I-type)rs1rd1 read, 1 write
sw rs2, imm(rs1) (S-type)rs1 (address), rs2 (data)2 reads, 0 writes
beq rs1, rs2, off (B-type)rs1, rs22 reads, 0 writes
lw rd, imm(rs1) (I-type)rs1rd1 read, 1 write
lui rd, imm (U-type)rd0 reads, 1 write
jal rd, off (J-type)rd (the link, pc+4)0 reads, 1 write

Register-port demand by instruction format. What it shows: no RV32I instruction ever needs more than two source registers or more than one destination. The insight to take: two-read-one-write (“2R1W”) is not a compromise — it is the exact envelope of the ISA. Any more ports would be dead silicon on a scalar core; any fewer would force a stall on the most common instruction class. This is what “single-issue” means in hardware terms.

The moment you want to issue two instructions per cycle, this arithmetic doubles: a dual-issue core needs 4R2W, which in a real chip is a substantially harder circuit, because register-file area grows roughly as the product of read ports and write ports (each write port must be routed to every cell, and each read port needs its own bit-line and sense path per cell). This is one of the concrete reasons superscalar cores are not “twice the core” — the register file alone is more than twice the structure. For this project, and for The Single-Cycle Processor specifically, 2R1W is the whole story.

One further subtlety: PicoRV32, a widely used small RV32 soft core, makes the second read port optional (ENABLE_REGS_DUALPORT, default 1), noting that “a dual ported register file improves performance a bit, but can also increase the size of the core” (PicoRV32 README). With one read port, an R-type instruction must read rs1 in one cycle and rs2 in the next, which costs a cycle on every register-register operation — the README’s CPI table lists a separate “CPI (SP)” column for exactly this. That trade is available to you, but only because PicoRV32 is a multi-cycle core to begin with. A single-cycle core has no spare cycle to spend, so 2R1W is mandatory.

x0 Is a Mux, Not a Register

The spec says x0 is “hardwired with all bits equal to 0”. Read that as an instruction to the implementer: do not build a register for x0. Build a mux on each read port that returns zero when the address is zero, and a gate on the write port that discards writes to address zero. The array is 31 entries deep, not 32 — though it is conventional to declare it 32 deep and simply never touch slot 0, because that keeps the address a plain array index instead of an offset subtraction.

The distinction is testable. Here is the relevant fragment from the run below, in which the testbench reaches into the design under test and forcibly deposits 0xDEADBEEF into the storage slot the write port refuses to touch:

== 2. x0 is not storage: writing it changes nothing ==
  ok   x0 read port 1 = 00000000
  ok   x0 read port 2 = 00000000
  u_old.xr[0] storage forced to deadbeef
  ok   x0 read port STILL zero = 00000000

The storage holds garbage and the read port still returns zero, because the read port never consults the storage for address 0. That is what “hardwired” means, and it is why writing x0 costs nothing: there is no cell to disturb.

How much of the ISA leans on this

Rather more than you would guess. x0 is not a convenience for zeroing variables; it is a structural element of the instruction set, used to synthesise a whole family of operations that would otherwise need their own opcodes. Every one of the following is a real encoding produced by riscv64-linux-gnu-gcc 16.1.1 on this machine and disassembled with riscv64-linux-gnu-objdump:

   0:	02a00093          	li	ra,42          # addi x1, x0, 42
   4:	00058513          	mv	a0,a1          # addi x10, x11, 0
   8:	00000013          	nop                    # addi x0, x0, 0
   c:	ff5ff06f          	j	0 <_start>     # jal  x0, -12
  38:	00133293          	seqz	t0,t1          # sltiu x5, x6, 1
  3c:	006032b3          	snez	t0,t1          # sltu  x5, x0, x6
  40:	fff34293          	not	t0,t1          # xori  x5, x6, -1
  44:	406002b3          	neg	t0,t1          # sub   x5, x0, x6
  48:	00008067          	ret                    # jalr  x0, 0(x1)

Decode 0x02a00093 by hand, because this is precisely the Stage 2 exercise and doing it once is worth reading it three times:

BitsFieldValueMeaning
[6:0]opcode0010011OP-IMM
[11:7]rd00001x1
[14:12]funct3000ADDI
[19:15]rs100000x0
[31:20]imm[11:0]00000010101042

Hand-decode of addi x1, x0, 42 = 0x02a00093. What it shows: the “load immediate 42” that Stage 2 is built around is really “add 42 to x0”. The insight to take: RV32I has no load-immediate instruction and does not need one, because x0 supplies a guaranteed-zero addend. The register file’s x0 mux is therefore load-bearing for the very first program the core will ever run — if the mux is missing and slot 0 powers up as x (undefined) in simulation, x1 comes out as xxxxxxxx and you will spend an hour blaming the ALU.

Walking the rest of the table:

  • mv rd, rs is addi rd, rs, 0. Not a register-to-register copy instruction — an add of zero. 0x00058513 has rs1 = x11, imm = 0, rd = x10.
  • nop is addi x0, x0, 0. The spec designates this exact encoding as canonical and gives the reason: ADDI “is most likely to take fewest resources to execute across a range of systems… the instruction only reads one register” (Unprivileged ISA 20240411, §NOP Instruction). It works because the write to x0 is discarded. Note that the canonical NOP is not the all-zero word: it is 0x00000013. The all-zero encoding is deliberately reserved as illegal, and the spec says why — “We reserve all-zero instructions to be illegal instructions to help trap attempts to execute zero-ed or non-existent portions of the memory space” (Unprivileged ISA 20240411, §“C” Standard Extension). So a block of zeroed memory traps rather than sliding, while a block of 0x00000013 is a genuine NOP slide.
  • j label is jal x0, offset. JAL always writes the return address to rd; an unconditional jump simply discards it into x0. The spec states this directly: “Plain unconditional jumps (assembler pseudoinstruction J) are encoded as a JAL with rd=x0.” The same trick makes ret = jalr x0, 0(x1) — jump to the address in ra and throw away the new link.
  • neg rd, rs is sub rd, x0, rs. Zero minus the value. 0x406002b3 has rs1 = x0, rs2 = x6.
  • seqz rd, rs is sltiu rd, rs, 1 — “set if unsigned-less-than 1”, which for unsigned values means “set if zero”. The spec calls this out by name. snez rd, rs is sltu rd, x0, rs — “set if 0 is unsigned-less-than rs”, i.e. “set if nonzero”. Again, the spec names it.
  • not rd, rs is xori rd, rs, -1 — this one does not use x0, but it belongs in the same family of “the ISA has fewer instructions than it appears to” observations.
mindmap
  root(("x0 = zero<br/>read mux + write gate"))
    ("as a SOURCE<br/>(guaranteed 0 in)")
      ("li rd, imm<br/>= addi rd, x0, imm")
      ("neg rd, rs<br/>= sub rd, x0, rs")
      ("snez rd, rs<br/>= sltu rd, x0, rs")
      ("mv is the sibling<br/>addi rd, rs, 0<br/>zero immediate, not x0")
    ("as a DESTINATION<br/>(result discarded)")
      ("nop<br/>= addi x0, x0, 0")
      ("j label<br/>= jal x0, offset")
      ("ret<br/>= jalr x0, 0(x1)")
      ("csrrw x0, csr, rs<br/>write CSR, no read")
    ("what it buys")
      ("no load-immediate opcode")
      ("no dedicated jump opcode")
      ("no negate opcode")
      ("no compare-to-zero opcode")

The x0 idiom family, grouped by whether zero is being consumed or a result thrown away. What it shows: two distinct mechanisms — the read mux supplies a guaranteed zero operand, the write gate silently drops a result — and the pseudoinstructions each one enables. The insight to take: the right-hand branch is the payoff. Four opcodes RV32I does not need to encode, because two pieces of trivial logic in the register file cover them. This is the register file paying for its own complexity, and it is why x0 is not an afterthought you can bolt on later.

There is one more use that matters for the core you are about to build: x0 as a destination is how you write an instruction with a side effect but no result. A CSR read-write to x0 (csrrw x0, mtvec, t0) writes the CSR without the read side effect. This becomes relevant at Control and Status Registers in Stage 6, and it works only because your write gate correctly discards address-zero writes.

The failure mode to expect

If you forget the write gate, everything appears to work until an instruction writes x0 — and the first one that does is nop. The symptom is that a NOP slide clobbers a register that nothing seems to touch. If you forget the read mux instead, addi x1, x0, 42 returns 42 anyway (because the slot happens to be zero after reset), and the bug only surfaces after the first nop has written something into slot 0. Both bugs hide behind reset state; both are caught in one line of testbench.

The Write-During-Read Hazard

Here is the question the ISA does not answer for you. In one clock cycle, the write port is committing a value to x6 and a read port is being asked for x6. Does the read return the old value or the new one?

The specification is silent, and correctly so — architecturally an instruction completes before the next one begins, so the situation cannot arise. It arises entirely because of implementation: you built a machine in which one instruction’s write-back overlaps another instruction’s operand read. The behaviour is therefore yours to define, and the two options have names:

  • Read-old (also read-first, read-before-write): the read port returns the value the register held at the start of the cycle. The write lands and is visible from the next cycle onward.
  • Write-first (also read-new, write-before-read, internal bypass): the read port returns the value being written, in the same cycle.
sequenceDiagram
    autonumber
    participant W as write port (rd=x6, data=0xFF)
    participant A as array xr[6]
    participant R as read port (rs1=x6)
    Note over W,R: cycle N — both ports active on x6
    W->>A: drive addr=6, data=0xFF, we=1
    par read-old configuration
        A->>R: returns 0x07 (the value from cycle N-1)
    and write-first configuration
        W->>R: bypass mux forwards 0xFF directly
    end
    Note over A: rising edge of clk N — write commits
    Note over W,R: cycle N+1
    A->>R: both configurations now return 0xFF

Write-during-read on the same register, drawn as the two configurations racing in one cycle. What it shows: in cycle N the read-old port answers from the array (stale) while the write-first port answers from a bypass mux fed straight off rd_data (fresh); from cycle N+1 they agree. The insight to take: write-first is not “the correct one” — it is a forwarding path built into the register file, with all the cost and delay that implies. The bypass mux sits in series with the read output, so it lengthens the read path, and the read path is already on the critical path of a single-cycle core.

The measured behaviour, from the same testbench run:

== 5. WRITE-DURING-READ: read x6 in the cycle x6 is written ==
  during the write cycle:
  ok   read-old    port sees = 00000007
  ok   write-first port sees = 000000ff
  the cycle after:
  ok   read-old    port sees = 000000ff
  ok   write-first port sees = 000000ff

Why this is the whole reason WB happens in the first half-cycle

In a single-cycle core the question is nearly academic: there is one instruction in flight, so nothing reads a register in the same cycle another instruction writes it. The reason to care now is that Stage 3 of the build ladder — Classic Five-Stage Pipeline — makes it acute. In a five-stage pipeline the register file is read in ID and written in WB, three stages apart. Consider three instructions:

  cycle:      1     2     3     4     5     6     7
  i1  add x6,x1,x2  IF    ID    EX   MEM   [WB]              <- WRITES x6 in cycle 5
  i2  (any)               IF    ID    EX   MEM    WB
  i3  (any)                     IF    ID    EX   MEM    WB
  i4  add x7,x6,x3                    IF   [ID]   EX   MEM   <- READS  x6 in cycle 5
                                            ^^^^
                              same cycle, same register, opposite ports

Instruction i4 reads x6 in ID during cycle 5 — the same cycle i1 writes it in WB. That is exactly three instructions of separation, and it is the last distance at which a data hazard can occur. If the register file is read-old, i4 gets the stale x6 and the pipeline is wrong; you must either stall i4 for one more cycle or forward from WB to ID. If the register file is write-first, i4 gets the correct value and the three-instruction case resolves for free.

The classic textbook resolution — and the reason the phrase “write in the first half of the cycle, read in the second” exists — is to clock the register file’s write port on the falling edge while the rest of the pipeline uses the rising edge. The write settles by mid-cycle, and the combinational read later in the same cycle sees it. That is a legitimate design, and it is what the original MIPS-based teaching pipelines do.

Do not use the falling-edge trick on an FPGA

Half-cycle timing means your effective clock period for the write path is half the nominal period, and it introduces a second clock edge that the synthesis tool must time separately. On an FPGA this is a reliable way to lose 40–50% of your Fmax and to produce a design that simulates correctly and fails on hardware. The FPGA-appropriate version of the same idea is a combinational bypass mux inside the register file — the WRITE_FIRST parameter in the code below — which achieves identical behaviour on a single clock edge at the cost of one mux delay on the read path. Use that. See Timing Closure and Fmax.

Either way, the WB→ID case is only one of the hazard distances. Instructions one and two apart still need EX→EX and MEM→EX forwarding paths, which live outside the register file entirely; that is the subject of Operand Forwarding and Pipeline Hazards. The point for this note is narrower and important: write-first behaviour in the register file removes exactly one forwarding path, the longest one. If you choose read-old, you owe the hazard unit a WB→ID forward. If you choose write-first, you do not. Choose consciously and write it down, because the hazard unit you build in Stage 3 has to know which one you picked.

Writing It in Verilog

Forty lines, and every line earns its place. This is rtl/regfile.v, linted clean under verilator --lint-only -Wall (Verilator 5.046) and simulated under Icarus Verilog 13.0.

// regfile.v -- RV32I architectural register file: 32 x 32-bit, 2 read ports,
// 1 write port. x0 reads as zero by construction, not by storage.
//
// WRITE_FIRST=0 : read-old  (read port returns the pre-write value)
// WRITE_FIRST=1 : read-new  (internal bypass: read port returns the value
//                            being written this same cycle)
module regfile #(
    parameter WRITE_FIRST = 0
) (
    input  wire        clk,
    input  wire [4:0]  rs1_addr,
    input  wire [4:0]  rs2_addr,
    output wire [31:0] rs1_data,
    output wire [31:0] rs2_data,
    input  wire        we,
    input  wire [4:0]  rd_addr,
    input  wire [31:0] rd_data
);
    // 31 real registers. Index 0 exists in the array but is never written and
    // never read -- keeping the array 32 deep keeps the address a plain index.
    reg [31:0] xr [0:31];
 
    integer i;
    initial for (i = 0; i < 32; i = i + 1) xr[i] = 32'h0;
 
    // The one and only write. Guarded on rd_addr != 0: writes to x0 are
    // architecturally discarded.
    always @(posedge clk)
        if (we && rd_addr != 5'd0)
            xr[rd_addr] <= rd_data;
 
    // Same-cycle write/read collision detect, per port.
    wire hit1 = WRITE_FIRST && we && (rd_addr != 5'd0) && (rd_addr == rs1_addr);
    wire hit2 = WRITE_FIRST && we && (rd_addr != 5'd0) && (rd_addr == rs2_addr);
 
    // The x0 mux: address 0 forces the read result to zero regardless of
    // storage. Two of these, one per read port.
    assign rs1_data = (rs1_addr == 5'd0) ? 32'h0 : (hit1 ? rd_data : xr[rs1_addr]);
    assign rs2_data = (rs2_addr == 5'd0) ? 32'h0 : (hit2 ? rd_data : xr[rs2_addr]);
endmodule

Line by line, the parts that matter:

  • reg [31:0] xr [0:31]; — declared 32 deep even though slot 0 is dead, so rs1_addr can index it directly. Costing one wasted word of storage to avoid an address decrement is the right trade every time; on an FPGA the “waste” is usually free anyway because RAM primitives come in power-of-two depths.
  • initial for (i = 0; i < 32; i = i + 1) xr[i] = 32'h0; — this is simulation scaffolding, not a reset. Real flip-flops power up in an unknown state. Without it, every register reads xxxxxxxx until first written, and your first add x7, x5, x6 produces x and propagates it everywhere. Including it makes early debugging tolerable. Note that on most FPGAs an initial block on a memory does synthesise (it becomes the bitstream’s RAM initialisation) — but on an ASIC it does not, so do not build correctness on it. RISC-V does not define reset values for x1x31; software must not read a register before writing it.
  • if (we && rd_addr != 5'd0) — the write gate. This one condition is the entire implementation of “writes to x0 are discarded”, and it is what makes nop and j work.
  • xr[rd_addr] <= rd_data;nonblocking assignment, and this is not a style preference. With <=, the array update is scheduled for the end of the time step, so the continuous assign statements reading xr[...] in the same time step observe the old value. That is what gives the WRITE_FIRST=0 instance its read-old semantics naturally, without any extra logic. Writing xr[rd_addr] = rd_data; with a blocking assignment creates a genuine simulation race: whether the read sees old or new depends on the simulator’s internal event ordering, and Verilator and Icarus can legitimately disagree. This is the classic Verilog trap — clocked logic uses <=, combinational logic uses =.
  • wire hit1 = WRITE_FIRST && we && (rd_addr != 5'd0) && (rd_addr == rs1_addr); — the bypass detector, one per read port. Note rd_addr != 0 is repeated here: without it, a write to x0 would bypass rd_data onto a read of x0 and break the zero guarantee. The testbench checks exactly this case.
  • assign rs1_data = (rs1_addr == 5'd0) ? 32'h0 : (hit1 ? rd_data : xr[rs1_addr]); — the read path. The x0 test is outermost, so nothing can override it: not the bypass, not the storage. Ordering the ternaries the other way round is a real bug that a casual test will not catch.
  • parameter WRITE_FIRST — making the choice a parameter rather than baking it in means you can instantiate both and compare, which is what the testbench does. Once you have committed to a pipeline design, fix it and delete the other path; a parameter that is only ever set one way is a maintenance cost.

The one thing this module deliberately does not have is a reset input. Resetting 31 × 32 = 992 flip-flops costs a global reset net fanning out to a thousand loads, which is a routing burden and a timing burden for no architectural benefit — the ISA does not define register reset values. Leave them uninitialised. Reset your program counter, your CSRs, and your bus state machines; do not reset your register file.

Simulating It

The testbench instantiates both configurations side by side, driven by identical stimulus, so the write-during-read difference is visible as a divergence rather than requiring two runs. The full source is short; the interesting part is the task that drives a write cleanly:

    // Drive one write, aligned so signals are stable before the posedge.
    task wr(input [4:0] a, input [31:0] d);
        begin
            @(negedge clk); we = 1; rd_addr = a; rd_data = d;
            @(negedge clk); we = 0;
        end
    endtask

Changing stimulus on the negative edge when the design clocks on the positive edge is the standard testbench discipline: it guarantees setup and hold are met with a half-period of margin and removes any race between the testbench and the DUT. A testbench that drives on the same edge the design samples will appear to work in one simulator and fail in another.

Build and run:

$ iverilog -g2012 -o tb_regfile.vvp rtl/regfile.v rtl/tb_regfile.v
$ ./tb_regfile.vvp
VCD info: dumpfile regfile.vcd opened for output.
== 1. addi x1, x0, 42 : the Stage 2 known answer ==
  ok   read x0 (ALU operand a) = 00000000
  ok   x1 after addi = 0000002a
== 2. x0 is not storage: writing it changes nothing ==
  ok   x0 read port 1 = 00000000
  ok   x0 read port 2 = 00000000
  u_old.xr[0] storage forced to deadbeef
  ok   x0 read port STILL zero = 00000000
== 3. two read ports really are independent ==
  ok   rs1 = x6 = 00000007
  ok   rs2 = x7 = 00000005
  ok   rs1 = x7 = 00000005
  ok   rs2 = x6 = 00000007
== 4. same register on both read ports ==
  ok   rs1 = x6 = 00000007
  ok   rs2 = x6 (same cycle) = 00000007
== 5. WRITE-DURING-READ: read x6 in the cycle x6 is written ==
  during the write cycle:
  ok   read-old    port sees = 00000007
  ok   write-first port sees = 000000ff
  the cycle after:
  ok   read-old    port sees = 000000ff
  ok   write-first port sees = 000000ff
== 6. write-during-read on x0 is still zero on both ==
  ok   read-old    x0 = 00000000
  ok   write-first x0 = 00000000
== 7. all 31 writable registers hold distinct values ==
  ok   x1..x31 all read back correctly
  ok   x0 still zero after the fill = 00000000
 
PASS -- 0 failures
rtl/tb_regfile.v:118: $finish called at 1130000 (1ps)

Reading the output as evidence rather than as decoration:

  • Test 1 is the Stage 2 known answer. addi x1, x0, 42 decomposes into “read x0 (must be zero), add 42, write x1”. The two checks confirm both halves. When your single-cycle core runs and x1 reads back 0000002a, that is the same fact, checked at a higher level.
  • Test 2 is the x0-is-a-mux proof described above.
  • Tests 3 and 4 are the port-independence checks. Test 3 swaps the two addresses within a single cycle and confirms both ports track independently; test 4 points both ports at the same register, which is the add x7, x6, x6 case and a surprisingly common source of bugs in hand-written read multiplexers.
  • Test 5 is the write-during-read divergence, and it is the only test here whose expected values are a design decision rather than a spec requirement.
  • Test 6 confirms the x0 guarantee survives the bypass path. This is the test that catches the ternary-ordering bug.
  • Test 7 is a full sweep: write a distinct value into each of x1x31, read all 31 back, and confirm x0 is still zero afterwards. It catches address-decode bugs (an off-by-one in the array bounds, an accidental 4-bit address) that the small directed tests miss.

The run also emits regfile.vcd, which opens in gtkwave (3.3.127 on this machine). For a module this small the waveform is not necessary — but the habit is, because from Stage 3 onward the waveform is the primary debugging surface. See Waveform Debugging.

Verilator’s linter, run separately, is the second half of the check:

$ verilator --lint-only -Wall rtl/regfile.v --top-module regfile
- V e r i l a t i o n   R e p o r t: Verilator 5.046 2026-02-28 rev fedora-5.046
- Verilator: Built from 0.029 MB sources in 2 modules, into 0.017 MB in 3 C++ files

No warnings. -Wall on Verilator is genuinely strict — it flags width mismatches, unused bits, latch inference, and combinational loops, all of which are the categories of bug that produce a design that simulates and does not synthesise. Run it on every module before you run the testbench; it costs milliseconds and it catches a class of error that a directed test never will. See Verilator and Testbenches and RTL Verification.

What It Becomes on an FPGA

Write reg [31:0] xr [0:31]; and the synthesis tool has to decide what physical resource to build it from. On an FPGA there are exactly two candidates, and the choice is not yours to make directly — you make it implicitly, through the shape of the ports you asked for. Getting this wrong on a small part is how a register file quietly consumes a fifth of your block RAM budget.

The decisive property is whether the read port is asynchronous. A single-cycle core needs address-in, data-out within the same clock period, with no intervening register. That is an asynchronous (combinational) read. Here is what the two resource families actually offer, taken from Yosys’s memory-mapping libraries — which are the tool’s machine-readable model of the real primitives, and are therefore a better primary source for “what ports exist” than any marketing datasheet (Yosys v0.58 techlibs/):

ResourceYosys cellPorts declaredRead timingDepth × width per primitive
Gowin LUT RAM (SSRAM)$__GOWIN_LUTRAM_port sw "W" + port ar "R"ar = asynchronous16 × 4
Gowin BSRAM, single-port$__GOWIN_SP_port srsw "A"srsw = synchronous16 K bits, widths 1–36
Gowin BSRAM, true dual-port$__GOWIN_DP_port srsw "A" "B"synchronouswidths 1–18 per port
Gowin BSRAM, simple dual-port$__GOWIN_SDP_port sr "R" + port sw "W"synchronouswidths 1–36
Lattice ECP5 LUT RAM$__TRELLIS_DPR16X4_port sw "W" + port ar "R"asynchronous16 × 4
Xilinx 7-series LUT RAM, quad-port$__XILINX_LUTRAM_QP_port arsw "RW" + port ar "R0" "R1" "R2"asynchronous32 × 2
Xilinx block RAM, true dual-port$__XILINX_BLOCKRAM_TDP_port srsw "A" "B"synchronous18 Kb / 36 Kb

FPGA memory primitives by port structure, read from the Yosys 0.58 mapping libraries techlibs/gowin/{lutrams,brams}.txt, techlibs/ecp5/lutrams.txt, techlibs/xilinx/lutrams_xc5v.txt, techlibs/xilinx/brams_xc4v.txt. What it shows: every block RAM port in every family is sr/srsw — synchronous. Only LUT RAM offers ar, an asynchronous read. The insight to take: the reason a register file goes into LUT RAM is not that it is small; it is that block RAM physically cannot do a combinational read. If you infer block RAM for your register file, the tool will insert an output register, your read result arrives a cycle late, and your single-cycle core silently executes every instruction with stale operands.

The “two block RAMs” surprise, explained precisely

The folklore is that a 2R1W register file costs two block RAMs. It is true, and there are two independent reasons depending on which BRAM mode the tool picks — which is why the result surprises people who expect one 18 Kbit macro to swallow a 1 Kbit register file whole.

Reason one — ports. In simple dual-port mode ($__GOWIN_SDP_, and the equivalent on every other family) a block RAM has exactly one read port and one write port. A 2R1W file has two read ports. The only way to build 2R1W out of 1R1W primitives is replication: instantiate two copies, write the same data to both on every write, and drive one read address into each. Two copies, two block RAMs, and every write consumes write bandwidth on both. The storage is duplicated; the architectural state is not.

Reason two — width. In true dual-port mode you do get two ports, so replication is unnecessary — but on Gowin, $__GOWIN_DP_ declares widths 1 2 4 9 18 per_port, capping each port at 18 bits. Thirty-two bits does not fit. You need two macros side by side to make the word wide enough, so you land on two block RAMs again, this time for width rather than ports. (The single-port and simple-dual-port modes go to 36 bits wide; only true dual-port is capped at 18. This is a common pattern across vendors — the second port costs you width.)

flowchart TB
    Q{"does the read port<br/>need to be<br/>ASYNCHRONOUS?"}
    Q -->|"yes — single-cycle core,<br/>or 5-stage ID read"| LUT["LUT RAM<br/>(Gowin SSRAM / Xilinx SLICEM /<br/>Lattice DPR16X4)"]
    Q -->|"no — you have a<br/>pipeline stage to absorb<br/>a registered read"| BR{"which BRAM mode<br/>does the tool pick?"}
    LUT --> L1["1 write + 1 async read<br/>per primitive (Gowin, ECP5)"]
    LUT --> L2["1 write + 3 async reads<br/>per primitive (Xilinx RAM32M)"]
    L1 -->|"replicate per read port"| L1C["Gowin: 16 prims x 2 copies<br/>= 32 LUT RAM primitives"]
    L2 -->|"ports to spare"| L2C["Xilinx: 16 x RAM32M,<br/>one read port unused"]
    BR -->|"simple dual-port<br/>1R + 1W"| B1["replicate for the<br/>2nd read port"]
    BR -->|"true dual-port<br/>2 x R/W, but 18 bits wide"| B2["widen for the<br/>32-bit word"]
    B1 --> TWO["**2 block RAMs**"]
    B2 --> TWO
    TWO --> BAD["...and the read is<br/>a cycle late anyway"]
    style LUT fill:#d5f5d5,stroke:#2a7
    style BAD fill:#ffd9d9,stroke:#c33

How a 2R1W 32×32 array lands on FPGA resources, and the two independent routes to “two block RAMs”. What it shows: the asynchronous-read question decides everything, and both BRAM paths converge on two macros — one because ports are short, one because the word is too wide for true-dual-port mode. The insight to take: the red box is the real point. Even if you accept the two-macro cost, the design is still wrong for a single-cycle core, because no block RAM in any of the three families offers a combinational read. The BRAM branch is not an expensive alternative; it is a different circuit.

Either path costs two of the 46 BSRAM macros on the Tang Nano 20K’s Gowin GW2AR-18 — about 4% of the part’s block RAM, spent on 1 Kbit of actual state, in exchange for read timing that does not work. Do not do it.

What you want instead, on the Gowin part: 31 × 32 bits from $__GOWIN_LUTRAM_ primitives at 16 × 4 each. Depth 32 needs 2 primitives stacked; width 32 needs 8 side by side; that is 16 LUT RAM primitives per read port, and because the Gowin LUT RAM has only one read port, 32 in total for a 2R1W file. Against 20,736 LUT4s that is negligible — but note that Gowin’s shadow SRAM (SSRAM) budget on this part is 41,472 bits total, and the register file wants 2 × 1,024 = 2,048 bits of it. Still comfortable.

On Xilinx 7-series the arithmetic is friendlier because the quad-port LUT RAM (RAM32M) already provides three asynchronous read ports and one write port at 32 × 2 bits. A 32-bit-wide 2R1W file needs 16 of them — one per pair of bits — with a read port left over. That spare port is not academic: it is exactly what a third operand would need, which is why RAM32M exists in the first place.

The measured reality check: PicoRV32’s own Vivado area report gives, for area-optimised 7-series synthesis, 48 “LUTs as Memory” in both the small and regular configurations, rising to 88 in the large one (PicoRV32 README, Utilization on Xilinx 7-Series FPGAs). “LUTs as Memory” is precisely the SLICEM-distributed-RAM count — the register file — and there is no block RAM line in that table at all. The README also notes the counter-intuitive corollary directly: “In architectures that implement the register file in dedicated memory resources, such as many FPGAs, disabling the 16 upper registers and/or disabling the dual-port register file may not further reduce the core size.” Dropping to RV32E’s 16 registers saves you nothing, because you were paying for a whole primitive either way.

Uncertain

Verify: the exact primitive count Gowin’s own synthesis tool (Gowin EDA) produces for this module on a GW2AR-18, and whether it prefers SSRAM or registers for a 32×32 2R1W array by default. Reason: neither yosys nor nextpnr-himbaechel nor Gowin EDA is installed on this machine, so the mapping counts above are derived from the Yosys library declarations by arithmetic, not measured from a synthesis report. The Gowin GW2AR data sheet DS226 was also unreachable — alcom.be returned HTTP 403 to curl on 2026-09-04. To resolve: install yosys + apicula and run synth_gowin -family gw2a on regfile.v, then read the Printing statistics section; or open the project in Gowin EDA and read the Resource Usage Summary. Note that the port structure claims — asynchronous LUT RAM read, synchronous BRAM read, 18-bit true-dual-port width cap — are read directly from the Yosys library files and are not in doubt; only the final primitive counts are derived.

The ABI Names and the Calling Convention

The hardware knows only x0x31. Everything else is convention, and the convention lives in a different document: the RISC-V ELF psABI, not the ISA manual. The ISA manual is explicit about the separation — “There is no dedicated stack pointer or subroutine return address link register in the Base Integer ISA; the instruction encoding allows any x register to be used for these purposes. However, the standard software calling convention uses register x1 to hold the return address for a call… and register x2 as the stack pointer” (Unprivileged ISA 20240411, §Programmers’ Model).

Here is the mapping, taken verbatim from the psABI’s integer register convention table (riscv-elf-psabi-doc, draft-20260813, riscv-cc.adoc):

RegisterABI nameMeaningPreserved across calls?
x0zeroZero— (immutable)
x1raReturn addressNo
x2spStack pointerYes
x3gpGlobal pointer— (unallocatable)
x4tpThread pointer— (unallocatable)
x5x7t0t2TemporariesNo
x8x9s0s1Callee-savedYes
x10x17a0a7Argument / return valuesNo
x18x27s2s11Callee-savedYes
x28x31t3t6TemporariesNo

The RV32 integer calling convention. What it shows: thirteen registers are callee-saved (sp, s0s11), fifteen are caller-saved (ra, t0t6, a0a7), and four are special in one way or another. The insight to take: the “preserved” column is what your Stage 8 context switch has to respect — and the answer is that a trap must save all of them, because a trap is not a call. A function call saves only the caller-saved set because the compiler emitted code on both sides that agreed on the contract; an interrupt arrives between two arbitrary instructions with no such agreement. This is why Context Switching in a Microkernel saves 31 registers plus mepc and not 15.

Three of these deserve a note each:

ra (x1) and the alternate link register x5. JAL and JALR write the return address to whichever rd you name, but the convention picks x1. The spec adds that “Hardware might choose to accelerate function calls and returns that use x1 or x5” — this is the hook for a return-address stack predictor, which is worth knowing exists even though it belongs to Stage 9 rather than Stage 2. x5 is the alternate link register, reserved for “millicode routines (e.g., those to save and restore registers in compressed code) while preserving the regular return address register”, and the spec notes it was chosen because “it maps to a temporary in the standard calling convention, and has an encoding that is only one bit different than the regular link register.”

sp (x2) and the compressed extension. The spec states that “The optional compressed 16-bit instruction format is designed around the assumption that x1 is the return address register and x2 is the stack pointer. Software using other conventions will operate correctly but may have greater code size.” The C extension has stack-pointer-relative load and store encodings (C.LWSP, C.SWSP) that hardwire x2. So the calling convention, nominally software policy, is baked into the instruction encoding once you enable C — which RV32IMC does.

a0a7 (x10x17). Eight argument registers, and the same set carries return values (a0, and a1 for the second word of a 64-bit return). These are also the registers a RISC-V system call uses: the SBI and Linux conventions both pass arguments in a0a5 with the call number in a7. That matters at ecall Instruction in Stage 6 — your trap handler reads the syscall arguments straight out of the saved a0a7 slots of the trap frame.

Two registers are marked unallocatable: gp (x3) and tp (x4). The compiler will never assign a variable to them. gp supports “global pointer relaxation”, a linker optimisation that turns a two-instruction lui/addi absolute address into a single gp-relative addi for globals within ±2 KiB of the global pointer; tp holds the thread-local storage base. The psABI is explicit that “procedures should not modify the integer registers tp and gp, because signal handlers may rely upon their values.” For a bare-metal kernel neither matters much at first, but your linker script must still define __global_pointer$ if you want relaxation to work — see Linker Scripts and Memory Layout.

None of this changes a single gate in the register file. That is the point worth carrying: the register file is 31 identical, interchangeable words. The ABI is a fiction agreed between a compiler and a linker, and your hardware neither knows nor cares. The only asymmetry in the hardware is x0.

Failure Modes and Gotchas

These are ordered roughly by how likely you are to hit them this evening.

1. Blocking assignment in the clocked block. Writing xr[rd_addr] = rd_data; instead of <= creates a simulator-dependent race between the write and the combinational reads. Symptom: the design passes under Icarus and fails under Verilator, or vice versa, with a one-cycle-off read. Diagnosis: grep your always @(posedge blocks for = that should be <=. Verilator’s -Wall does not always catch this, because the code is legal — it just does not mean what you think.

2. The x0 mux placed inside the bypass instead of outside it. (hit1 ? rd_data : (rs1_addr == 0 ? 0 : xr[rs1_addr])) looks equivalent and is not: a write to x0 with rd_addr == rs1_addr == 0 now bypasses rd_data onto the read port. Symptom: x0 reads nonzero for exactly one cycle, immediately after a nop, and only in the WRITE_FIRST build. Test 6 in the testbench above exists solely to catch this.

3. Missing the write gate. If we alone enables the write, nop (addi x0, x0, 0) writes 0 to slot 0 — harmless — but j label (jal x0, offset) writes pc+4 to slot 0, and if your read mux is also missing, x0 now reads as a code address. Symptom: arithmetic involving zero starts producing addresses. This is the pair of bugs that mask each other: either one alone is caught quickly, both together are nearly invisible until a j executes.

4. Inferring block RAM by accident. If you register the read output — always @(posedge clk) rs1_data <= xr[rs1_addr]; — the tool will happily map the array into a BSRAM, and it will work in simulation because your testbench is checking one cycle later anyway. It will then break the single-cycle datapath, because the ALU now sees the previous instruction’s operands. Symptom: every instruction computes with the operands of the instruction two back. Diagnosis: read the synthesis resource report and check the BSRAM count; a register file should contribute zero.

5. Assuming registers come up zero. They do in simulation if you wrote the initial block, and they usually do on an FPGA because the bitstream initialises RAM contents. They do not on an ASIC, and they do not if you switch the array from LUT RAM to flip-flops on a family whose flops have no initialisation. Symptom: works on the FPGA, fails in gate-level simulation or on silicon. The ISA does not promise reset values; do not rely on them. Your boot code should not read a register before writing it.

6. Choosing write-first and then also building a WB→ID forwarding path. Doubling up is not harmful for correctness — the two paths agree — but it costs a mux and a chunk of your timing budget for nothing. Symptom: Fmax lower than expected, critical path reported through the ID-stage operand mux. Pick one.

7. Choosing read-old and forgetting the WB→ID forward. The complementary error, and the dangerous one. Symptom: a dependent instruction exactly three slots after its producer reads a stale value. This is hard to hit by accident in hand-written tests, because you have to place the dependency at precisely distance three — closer and your EX/MEM forwarding covers it, further and the write has landed. It is, however, all over riscv-tests. This is one of the specific reasons Stage 4 of the ladder exists: your own tests encode your own assumptions.

8. A five-bit address on a 32-deep array is right; a four-bit address is a silent halving. If you parameterise the file for RV32E (16 registers) and forget to widen the address back for RV32I, x16x31 alias onto x0x15. Symptom: callee-saved registers s2s11 corrupt argument registers. Test 7’s full 31-register sweep catches this immediately.

9. Reading rs2 on an instruction that has no rs2. Harmless in hardware — you read a register and discard the value — but if you gate the read on the instruction type to “save power”, you introduce a decode dependency into the read path and lengthen the critical path. Do not. Read both ports unconditionally; the decoder decides what to use, not what to read.

Alternatives and When to Choose Them

The register file has fewer genuine design alternatives than most blocks, because the ISA pins its size and the encoding pins its port count. What varies is the storage substrate and the port strategy.

ApproachWhat it isCostWhen to choose it
Flip-flop array + read muxes992 flops and two 32:1 muxesLarge: 31 2:1 muxes per bit per port before vendor wide-mux resourcesASIC flows, or any target with no LUT RAM. Fully general, works with any port count.
LUT RAM (distributed RAM)Vendor RAM primitives with asynchronous readSmall; 16–32 primitivesThe default on any FPGA. Asynchronous read is exactly what a single-cycle or ID-stage read needs.
Block RAM, replicated 2×Two 1R1W macros, written in lockstep2 BRAM macros + a registered readOnly when LUT RAM is exhausted and you have a pipeline stage to absorb the registered read.
Single read portRead rs1 and rs2 in successive cyclesHalves the read structure; costs a cycle per R-typeMulti-cycle cores only. PicoRV32’s ENABLE_REGS_DUALPORT=0.
Bit-serial (1 bit wide)The whole file is 32 × 32 bits accessed one bit at a timeTiny; SERV fits an entire core in 125–239 LUTsExtreme area constraints, where 32+ cycles per instruction is acceptable.
RV32E (16 registers)Halve the fileOften saves nothing on FPGA (whole primitives)Genuinely useful on ASIC; largely cosmetic on FPGA.

Register-file implementation strategies. What it shows: the substrate choice, not the architecture, is what varies. The insight to take: the row that matters for this project is LUT RAM, and the row that is most instructive is the bit-serial one — SERV, the smallest RISC-V core, achieves 125 LUTs on an AMD Artix-7 (SERV README) precisely by refusing the 2R1W structure and processing one bit per cycle. That is the extreme end of the same trade every row here makes: ports and width bought with cycles.

Two of these deserve elaboration.

Flip-flops versus LUT RAM is a real fork even on an FPGA. A flip-flop array gives you unlimited read ports for free in the RTL sense (a mux per port), asynchronous reads, and a genuine reset if you want one. It also costs roughly 992 flip-flops plus a 32:1 multiplexer per bit per read port. Do that arithmetic honestly: a 32:1 mux built purely from 2:1 muxes is a binary tree of 31 of them, and on a LUT4 architecture a 2:1 mux is one LUT4 — so 31 LUT4s per bit, times 32 bits, times 2 read ports is on the order of 2,000 LUT4s, roughly 10% of the Tang Nano 20K’s 20,736, for a block that LUT RAM does in about 32 primitives. Real tools will beat that: every FPGA family provides dedicated wide-multiplexer resources (Xilinx MUXF5MUXF9, Gowin MUX2_LUT5MUX2_LUT8, both present in the Yosys 0.58 cell libraries for those families — techlibs/xilinx/cells_sim.v, techlibs/gowin/) that combine LUT outputs without consuming another LUT, so the true figure is lower — but the order of magnitude is the point, and the order of magnitude is bad. Start with the inferred array and let the tool pick LUT RAM; drop to explicit flip-flops only if the synthesis report shows it did something you did not want.

RV32E halves the architectural register count to 16 and is a ratified base ISA in its own right (RV32E version 2.0, ratified, per the 20240411 colophon). It is a genuine ASIC-area win. On an FPGA it usually is not, for the reason PicoRV32’s README states outright: the file already fits in whole primitives, and half of a primitive is still a primitive. Do not adopt RV32E to save FPGA area; adopt it only if you actually want the ABI.

Compared against the other Stage 2 blocks: The Arithmetic Logic Unit has far more design freedom (adder topology, shifter structure, how much to share), and Instruction Decode has more still. The register file is the block where the right answer is nearly forced, which is exactly why it makes a good first module — you can be confident that a passing test means a correct design.

Production Notes

What real small cores do. PicoRV32 — the most widely deployed small RV32 soft core — implements its register file in FPGA distributed RAM and reports 48 “LUTs as Memory” in area-optimised 7-series synthesis, with no block RAM used for the core at all, while achieving 416–454 MHz on Kintex-7 and Virtex-7 parts across speed grades (PicoRV32 README, as of the main branch fetched 2026-09-04). Its headline area is 750–2,000 LUTs depending on configuration. Those numbers are a useful sanity bound for this project: if your whole core lands in that range you are in normal territory; if your register file alone is consuming block RAM, something is wrong.

The register file is rarely the critical path — but it is on it. In a single-cycle core the path is PC → instruction memory → decode → register read → ALU → writeback mux → register write. The register read is one hop in that chain, and on an FPGA a LUT-RAM read is fast. What does lengthen it is the write-first bypass mux, which sits after the read and adds one LUT delay per bit. PicoRV32 offers TWO_CYCLE_COMPARE explicitly to “relax the longest data path a bit” — a reminder that on a small core the timing budget is tight enough that a single extra mux level is a design decision. This is Timing Closure and Fmax territory, and the honest advice is: build it, synthesise it, and read the critical path report rather than guessing.

Power and the “few hot registers” observation. The spec’s own design rationale notes that “Dynamic register usage tends to be dominated by a few frequently accessed registers, and regfile implementations can be optimized to reduce access energy for the frequently accessed registers” (citing Tseng and Asanović’s work on banked register files) (Unprivileged ISA 20240411, §Programmers’ Model). For an FPGA project this is not actionable — you take what the primitive gives you — but it explains a real design pattern in shipping low-power cores, where the register file is split into a small hot bank and a larger cold one.

The debugging discipline that pays. The register file is the only architectural state you can read out and check by hand at Stage 2, which is why the MOC names it as the known answer. Concretely: run addi x1, x0, 42, halt, and print u_core.u_rf.xr[1] from the Verilator testbench. If it is 0000002a, your fetch, decode, immediate generation, ALU, register read of x0, and register write of x1 are all correct simultaneously. That single check covers more of the datapath than any other one-line assertion you can write, and it is why this instruction and not some other one is the ladder’s first rung.

Add the x0 assertion permanently. Once the core exists, add a continuous assertion to the testbench:

    always @(posedge clk)
        if (u_rf.rs1_addr == 5'd0 && u_rf.rs1_data !== 32'h0)
            $fatal(1, "x0 read as %h at time %0t", u_rf.rs1_data, $time);

It costs nothing, it never fires in a correct design, and when it does fire it names the bug precisely instead of letting a nonzero zero propagate silently through fifty instructions before something visible breaks. The same pattern — a cheap invariant checked every cycle — is the single most effective RTL debugging technique available in simulation, and it is unavailable on hardware, which is why the MOC insists that design decisions be made in the simulator and only confirmed on the FPGA.

See Also

The rest of Stage 2 — build these alongside this module:

Where the write-during-read choice comes due:

Tooling and target:

Where the registers go later:

Maps of Content: