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
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.
PC selection priority
Five possible next-PC sources are arbitrated by priority. Highest priority wins; the rest are ignored on that cycle.
- Stall keeps the current PC. Triggered by a load-use hazard or any other stall condition.
- JALR takes the indirect jump target produced by the ALU in EX.
- Branch takes the conditional-branch target computed in EX, when the branch was resolved as taken.
- JAL takes the direct jump target computed early in ID (the target is just
PC + Imm). - 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.
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.
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.
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.
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;
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;
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).
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_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.
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 |
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.