Pipeline stages

Each pipeline stage is its own SystemVerilog module under src/. Top-level wiring lives in pipelined_cpu.sv. This page walks through what each stage does, why the logic looks the way it does, and shows the RTL that implements it. The Hazards & Forwarding page covers the cross-stage logic (forwarding paths, stalls, flushes) that keeps the pipeline correct.

The complete datapath

Complete riscv-5i datapath with pipeline registers, forwarding paths, and hazard detection logic Figure 1: The full datapath, including pipeline registers, the forwarding multiplexers in EX, and the hazard-detection wires that drive stalls and flushes. Based on Patterson & Hennessy Figure 4.51 and instantiated in src/pipelined_cpu.sv.

Instruction Fetch (IF)

Implemented in src/if_stage.sv. The IF stage holds the program counter, picks the address of the next instruction, and emits the current PC plus PC+4 to the next stage.

IF stage block diagram: PC register, +4 adder, and the 5-way next-PC multiplexer Figure 2: IF stage. The next-PC mux selects between the five inputs in priority order; the chosen value latches into the PC register on the next clock edge.

PC selection priority

Five possible next-PC sources are arbitrated by priority. Highest priority wins; the rest are ignored on that cycle.

  1. Stall keeps the current PC. Triggered by a load-use hazard or any other stall condition.
  2. JALR takes the indirect jump target produced by the ALU in EX.
  3. Branch takes the conditional-branch target computed in EX, when the branch was resolved as taken.
  4. JAL takes the direct jump target computed early in ID (the target is just PC + Imm).
  5. Sequential, the default, takes PC + 4.

Resolving JAL in ID rather than waiting for EX costs one cycle of fetch penalty instead of two; see Hazards Case 5. JALR and conditional branches still cost two because their targets depend on register-file or ALU outputs that aren’t ready until EX.

Why JAL is faster than JALR JAL's target is PC + Imm with both operands available immediately after decode. JALR's target is rs1 + Imm, which has to wait on the register file (or a forwarded value) and the ALU. Same instruction family, different dependency chain, different penalty.
// --- Next PC Logic ---
logic [XLEN-1:0] if_pc_reg;
logic [XLEN-1:0] if_next_pc;
logic [XLEN-1:0] if_pc_plus_4_calc;

assign if_pc_plus_4_calc = if_pc_reg + 4;

// -- Select Next PC based on control signals ---
always_comb begin: SelectNextPC
    if (stall) begin : Stalled
        if_next_pc = if_pc_reg;
    end else if (jalr_taken) begin : JALRTaken
        if_next_pc = jalr_target;
    end else if (branch_taken) begin : BranchTaken
        if_next_pc = branch_target;
    end else if (jal_taken) begin : JALTaken
        if_next_pc = jal_target;
    end else begin : IncrementPC
        if_next_pc = if_pc_plus_4_calc;
    end
end

// -- Update or Hold PC ---
always_ff @(posedge clk) begin : PC_Register
    if (rst) begin : ResetPC
        if_pc_reg <= {XLEN{1'b0}};
    end else begin : UpdatePC
        if_pc_reg <= if_next_pc;
    end
end

// --- Outputs ---
assign pc_out          = if_pc_reg;
assign pc_plus_4       = if_pc_plus_4_calc;
assign instruction_out = instruction_in;

Instruction Decode (ID)

Implemented in src/id_stage.sv. The ID stage chops the 32-bit instruction word into its fields, generates the control signals for downstream stages, reads rs1 and rs2 from the register file, and produces the sign-extended immediate via src/imm_gen.sv.

ID stage block diagram: instruction field decomposition, control unit, register file, and immediate generator Figure 3: ID stage. The control unit, register file, and immediate generator all run in parallel on the same instruction word.

Instruction field extraction

assign opcode = opcode_t(instruction[6:0]);
assign rd     = instruction[11:7];
assign funct3 = instruction[14:12];
assign rs1    = instruction[19:15];
assign rs2    = instruction[24:20];
assign funct7 = instruction[31:25];

The opcode and funct3/funct7 fields drive src/control_unit.sv, which emits the control bundle (reg_write, mem_write, alu_control, op_a_sel, op_b_sel, wb_mux_sel, branch and jump flags) that follows the instruction through the pipeline registers.

ImmGen handles the five immediate encodings RV32I uses: I-type, S-type, B-type, U-type, and J-type. Each format extracts a different set of bits and sign-extends differently; the module’s job is to make every immediate look like a 32-bit signed value to downstream logic.

ISA reference Instruction formats and field positions: RISC-V Unprivileged ISA Specification v20191213, Section 2. The encodings the immediate generator implements are summarized in Section 2.3 ("Immediate Encoding Variants").

Execute (EX)

Implemented in src/ex_stage.sv. EX is where every RV32I arithmetic, logical, comparison, and shift operation happens, and where branch direction gets resolved. It is also the consumer of the forwarding paths from MEM and WB.

Figure 4 (pending): EX stage showing the forwarding multiplexers, ALU source multiplexers, and the branch-resolution block.

Forwarding multiplexers

forwarding_unit.sv produces a 2-bit forward_a/forward_b per source operand. The mux in EX uses those selectors to pull the freshest copy of each operand from the register file, the EX/MEM register, or the MEM/WB register.

always_comb begin : ForwardA_MUX
    case (forward_a)
        2'b00:   ex_alu_in_a_fwd = rs1_data;           // No hazard (Register)
        2'b01:   ex_alu_in_a_fwd = wb_write_data;      // Forward from WB
        2'b10:   ex_alu_in_a_fwd = ex_mem_alu_result;  // Forward from MEM
        default: ex_alu_in_a_fwd = rs1_data;
    endcase
end

ALU source multiplexers

op_a_sel covers the special cases where the A input isn’t a register: AUIPC routes the PC into A, and LUI routes a constant zero into A.

always_comb begin : ALUInputA_MUX
    case (op_a_sel)
        2'b00:   ex_alu_in_a = ex_alu_in_a_fwd;  // Regular register op
        2'b01:   ex_alu_in_a = pc;               // AUIPC: PC
        2'b10:   ex_alu_in_a = {XLEN{1'b0}};     // LUI: Zero
        default: ex_alu_in_a = ex_alu_in_a_fwd;
    endcase
end

assign ex_alu_in_b = op_b_sel ? imm : rs2_data_forwarded;
LUI without a dedicated unit LUI defines rd = imm << 12. By driving the ALU with A = 0 and B = (imm << 12) (the immediate generator already produces the shifted value for U-type), the ordinary ADD operation produces the correct result. No extra ALU op-code or shifter is needed for LUI specifically.

Branch resolution

always_comb begin
    if (branch_en) begin
        case (funct3)
            F3_BEQ:  branch_taken = alu_zero;        // A == B
            F3_BNE:  branch_taken = ~alu_zero;       // A != B
            F3_BLT:  branch_taken = alu_result[0];   // A < B (signed)
            F3_BGE:  branch_taken = ~alu_result[0];  // A >= B (signed)
            F3_BLTU: branch_taken = alu_result[0];   // A < B (unsigned)
            F3_BGEU: branch_taken = ~alu_result[0];  // A >= B (unsigned)
            default: branch_taken = 1'b0;
        endcase
    end else begin
        branch_taken = 1'b0;
    end
end

assign branch_target = pc + imm;
How the ALU drives the comparison The ALU computes both signed and unsigned SLT-style results; bit 0 of the result holds the comparison outcome. BLT/BGE read the signed bit, BLTU/BGEU read the unsigned one, and BEQ/BNE read the ALU's zero flag. The branch resolution block just routes the appropriate signal into branch_taken.

Memory Access (MEM)

Implemented in src/mem_stage.sv. MEM presents the ALU result as an address to the data memory and either drives a word in (for stores) or latches a word out (for loads).

MEM stage block diagram: data memory interface, byte-enable generation, and sub-word sign/zero-extension Figure 5: MEM stage. The byte-enable function turns funct3 and the address LSBs into the mask the data memory uses to drive (stores) or select (loads) the right bytes.

Byte enables for sub-word access

RV32I has byte and half-word loads and stores in addition to the natural word-aligned ones. The data memory is word-addressed under the hood, so the MEM stage produces a 4-bit byte-enable mask that picks which bytes of the addressed word participate in the transaction. The mask depends on funct3 (which sub-word size) and the low two bits of the address (which byte or half-word within the word).

function automatic logic [3:0] get_byte_enable(logic [2:0] funct3, logic [1:0] addr_lsb);
    case (funct3)
        F3_BYTE: begin : ByteEnable
            case (addr_lsb)
                2'b00: return 4'b0001;
                2'b01: return 4'b0010;
                2'b10: return 4'b0100;
                2'b11: return 4'b1000;
            endcase
        end
        F3_HALF: begin : HalfwordEnable
            case (addr_lsb[1])
                1'b0: return 4'b0011;   // Lower halfword
                1'b1: return 4'b1100;   // Upper halfword
            endcase
        end
        default: return 4'b1111;        // Word access
    endcase
endfunction

assign dmem_be = get_byte_enable(ex_mem_funct3, ex_mem_alu_result[1:0]);

The data memory (src/data_memory.sv) consumes dmem_be as its byte-enable per cell. Sign-extension (for LB/LH) vs. zero-extension (for LBU/LHU) is handled on the read-data path by selecting between sign-extended and zero-extended views of the addressed bytes before the value propagates to the MEM/WB register.

Writeback (WB)

Implemented in src/wb_stage.sv. WB picks which value lands in the destination register using wb_mux_sel.

WB stage block diagram: three-way result multiplexer selecting between ALU result, loaded data, and PC+4 Figure 6: WB stage. The three-way mux on the right selects which value goes into the register file.

wb_mux_sel has three encodings:

Encoding Source Used by
2'b00 ALU result R-type, I-type ALU, LUI, AUIPC
2'b01 Data loaded from memory LB, LH, LW, LBU, LHU
2'b10 PC + 4 JAL, JALR (return address)

The first two are obvious. The third is what makes function calls possible: JAL ra, target puts PC + 4 into ra (x1), so ret (which is JALR x0, 0(ra)) jumps back to the instruction after the call.

Why PC+4 instead of PC RISC-V Unprivileged ISA Specification, Section 2.5 ("Control Transfer Instructions"). JAL and JALR save the address of the instruction after the call so the callee can return without any further offset arithmetic. Saving PC instead of PC+4 would force every return to add 4, which the ISA designers (rightly) decided was the caller's job once, not the callee's job every time.

What each pipeline register carries

Each pipeline register hands a bundle of architectural state and control signals to the next stage. The fields fall out from what that stage’s logic still needs to consume.

Register Data fields Control signals Why
IF/ID pc, instruction, pc+4 n/a; generated in ID Hand the fetched word and its address to the decoder
ID/EX pc, pc+4, rs1_data, rs2_data, imm, rs1, rs2, rd, funct3 reg_write, mem_write, alu_control, op_a_sel, op_b_sel, wb_mux_sel, branch, jump, jalr Operands and full control bundle for execute
EX/MEM alu_result, rs2_data, rd, pc+4, funct3, rs2 reg_write, mem_write, wb_mux_sel Address and store-data for memory; preserve writeback target
MEM/WB mem_read_data, alu_result, rd, pc+4 reg_write, wb_mux_sel Three writeback sources plus the destination register
Why rs2 rides in EX/MEM Carrying the source register index rs2 through to EX/MEM enables store-data forwarding: if a store's data operand is being produced by an instruction one cycle ahead in the pipeline, the forwarding unit can route the fresher value into the store path. See Hazards Case 3. Textbook diagrams often omit this; it costs a few extra bits in the register but avoids a real correctness bug.