Designing a 5-Stage Pipelined RISC-V Processor from Scratch
A practical walkthrough of building an RV32I pipelined processor in SystemVerilog : from architecture to simulation, with forwarding, hazard detection, and a clear path to acceleration.
RISC-V is an open instruction set architecture. That single fact changes what is possible for engineers who want to learn, experiment, and build custom processors. Unlike closed ISAs, RISC-V gives developers the freedom to study the full instruction format, implement a compatible core, and eventually add custom extensions, all without licensing friction.
That openness is especially relevant for edge AI systems, where future work may involve accelerating neural network kernels, custom arithmetic, memory movement, or tightly coupled hardware-software features. Before any of that can happen, the base processor needs to be understood deeply. This post walks through the design, implementation, and simulation of a 5-stage pipelined RV32I processor in SystemVerilog
Part I: Understanding the Processor Before Coding

Fig: 5-stage Risc-V Implementation
A good implementation starts with architecture, not syntax. Before writing or modifying RTL, it is important to understand the major processor stages and what each one does.
The classic 5-stage pipeline is:
Fetch → Decode → Execute → Memory → Writeback
Each stage has a clear responsibility. The fetch stage reads an instruction from instruction memory using the program counter. Decode extracts fields such as opcode, source registers, destination register, function bits, and immediate values. Execute performs ALU operations and branch decisions. Memory handles loads and stores. Writeback selects the final result and writes it into the register file.
This structure makes the processor straightforward to reason about. If a register has the wrong value, you can trace where the instruction went wrong: was it fetched incorrectly, decoded with the wrong fields, executed with stale operands, loaded from the wrong address, or written back with the wrong mux selection?
Part II: Code Structure
A processor quickly becomes impossible to debug if all logic is placed in one large module. This design separates the core into focused, single-responsibility modules:
riscv_5stage_pipeline/├── rtl/core/│ ├── risc_pkg.sv # enums, structs, opcodes│ ├── top.sv # architectural map│ ├── fetch.sv│ ├── instruction_memory.sv│ ├── decode.sv│ ├── control_unit.sv│ ├── alu_control.sv│ ├── alu.sv│ ├── execute.sv│ ├── register_file.sv│ ├── data_memory.sv│ ├── writeback.sv│ ├── pipeline_registers.sv│ ├── forwarding_unit.sv│ └── hazard_unit.sv├── rtl/board/│ └── basys3_top.sv # Basys 3 FPGA wrapper├── tb/unit/ # per-module testbenches├── tb/integration/ # full-processor tests└── mem/ # .mem instruction images
The top.sv file connects everything together — it is the architectural map of the processor. The supporting modules each implement one piece of behaviour. This separation matters because future changes become localised. Adding a new ALU operation should mostly affect alu.sv, alu_control.sv, and possibly control_unit.sv. Adding custom instructions later should not require rewriting the whole processor.
One important design decision: the entire pipeline state is defined through SystemVerilog packed structs in risc_pkg.sv. This keeps pipeline registers, control signals, and forwarding paths type-safe and easy to extend.
Part III: Typed Pipeline State
Rather than passing dozens of loose wires between pipeline stages, the design defines packed structs for every pipeline register boundary. Each struct carries exactly the data and control signals that the next stage needs.
// risc_pkg.sv — pipeline register typestypedef struct packed { logic [31:0] pc; logic [31:0] pc4; logic [31:0] instruction; logic valid;} if_id_t;typedef struct packed { logic [31:0] pc; logic [31:0] rs1_data; logic [31:0] rs2_data; logic [31:0] immediate; logic [4:0] rd_addr; execute_ctrl_t ex_ctrl; memory_ctrl_t mem_ctrl; writeback_ctrl_t wb_ctrl; logic valid; // ... additional fields} id_ex_t;
The control signals themselves are also structured. execute_ctrl_t groups the ALU operation, operand mux selects, and branch type. memory_ctrl_t groups memory request, write enable, and data size. writeback_ctrl_t groups the register file write enable and writeback mux select. This means the control unit produces a single decode_ctrl_t struct, and the pipeline carries its parts forward stage by stage without any ambiguity about what belongs where.
The enumerated types reinforce this clarity:
typedef enum logic [3:0] { ALU_ADD, ALU_SUB, ALU_SLL, ALU_SLT, ALU_SLTU, ALU_XOR, ALU_SRL, ALU_SRA, ALU_OR, ALU_AND, ALU_COPY_B, ALU_NOP} alu_op_t;typedef enum logic [1:0] { WB_ALU, WB_MEM, WB_PC4, WB_IMM} wb_sel_t;typedef enum logic [1:0] { FWD_NONE, FWD_EX_MEM, FWD_MEM_WB} fwd_sel_t;
Using ALU_ADD instead of 4'b0000 and WB_MEM instead of 2'b01 makes the control logic readable and makes it much harder to introduce silent bugs through typos in multi-bit constants.
Part IV: Instruction Loading and the Little-Endian Trap
The processor loads instructions from a memory initialisation file using $readmemh. The instruction memory stores individual bytes, then reconstructs a 32-bit instruction when the PC requests an address.
// instruction_memory.sv — byte-addressed, little-endian reconstructionalways_comb begin imem_data = 32'h0000_0013; // NOP default if (imem_req && ((imem_addr + 32'd3) < DEPTH)) begin imem_data = {mem[imem_addr + 32'd3], mem[imem_addr + 32'd2], mem[imem_addr + 32'd1], mem[imem_addr]}; endend
A memory file might contain:
93005000
These bytes are reconstructed as 32'h00500093, which corresponds to addi x1, x0, 5. This little-endian reconstruction is a common source of confusion — a wrong byte order can make the processor appear broken even when the RTL is correct. The NOP default (addi x0, x0, 0) ensures that out-of-bounds reads produce a safe no-operation rather than undefined behaviour.
Part V: Decode, Control, and Execute
The Decoder
The decode module is pure combinational logic. It extracts the fixed RISC-V instruction fields and generates the sign-extended immediate value based on the instruction format type.
// decode.sv — immediate generation for each formatunique case (opcode) OPCODE_OP_IMM, OPCODE_LOAD, OPCODE_JALR: begin immediate = {{20{instruction[31]}}, instruction[31:20]}; end OPCODE_STORE: begin immediate = {{20{instruction[31]}}, instruction[31:25], instruction[11:7]}; end OPCODE_BRANCH: begin immediate = {{19{instruction[31]}}, instruction[31], instruction[7], instruction[30:25], instruction[11:8], 1'b0}; end // ... LUI, AUIPC, JALendcase
Each RISC-V format type scatters its immediate bits differently across the instruction word. Getting these bit extractions right — especially for branches and JAL, where the bits are not contiguous — is one of the more error-prone parts of the decoder.
The Control Unit
The control unit takes the opcode and funct3 fields and produces a complete decode_ctrl_t struct that configures the entire downstream pipeline. A key design choice: the control unit also emits uses_rs1 and uses_rs2 flags, which the hazard unit needs to determine whether a data dependency actually matters.
// control_unit.sv — load instruction control (excerpt)OPCODE_LOAD: begin ctrl.ex.alu_op = ALU_ADD; ctrl.ex.op_a_sel = OP_A_RS1; ctrl.ex.op_b_sel = OP_B_IMM; ctrl.mem.dmem_req = 1'b1; ctrl.mem.dmem_wr_en = 1'b0; ctrl.wb.rf_wr_en = 1'b1; ctrl.wb.wb_sel = WB_MEM; ctrl.uses_rs1 = 1'b1; // data_size set by funct3 sub-decodeend
The ALU and Execute Stage
The execute stage has two jobs: compute the ALU result, and decide whether a branch or jump should be taken. The ALU itself is a clean combinational block supporting all RV32I operations:
// alu.svunique case (alu_op) ALU_ADD: result = lhs + rhs; ALU_SUB: result = lhs - rhs; ALU_SLL: result = lhs << rhs[4:0]; ALU_SLT: result = ($signed(lhs) < $signed(rhs)) ? 32'd1 : 32'd0; ALU_SLTU: result = (lhs < rhs) ? 32'd1 : 32'd0; ALU_XOR: result = lhs ^ rhs; ALU_SRL: result = lhs >> rhs[4:0]; ALU_SRA: result = $signed(lhs) >>> rhs[4:0]; ALU_OR: result = lhs | rhs; ALU_AND: result = lhs & rhs; ALU_COPY_B: result = rhs; default: result = 32'b0;endcase
The execute wrapper module handles operand muxing (choosing between RS1/PC/zero and RS2/immediate/four based on the control signals) and branch condition evaluation. Branch target computation handles both JAL (PC + immediate) and JALR (RS1 + immediate, with bit 0 cleared per spec).
Part VI: Pipeline Registers
In a pipelined processor, multiple instructions are active simultaneously. One instruction may be decoding while another is executing and a third is writing back. Pipeline registers hold the state between stages, ensuring each instruction’s data and control signals travel together as the instruction moves forward.
// pipeline_registers.sv — IF/ID with stall and flushmodule if_id_reg ( input logic clk, input logic reset_n, input logic stall, input logic flush, input if_id_t d, output if_id_t q); always_ff @(posedge clk or negedge reset_n) begin if (!reset_n) q <= '0; else if (flush) q <= '0; else if (!stall) q <= d; endendmodule
Each pipeline register module supports three control modes: normal advance, stall (hold current value), and flush (insert a bubble). The IF/ID register supports both stall and flush. The ID/EX register flushes on load-use stalls or taken branches. The EX/MEM and MEM/WB registers advance unconditionally during normal operation. This priority — flush beats stall beats advance — is critical for correct pipeline behaviour.
A useful beginner habit is to trace one instruction through these registers. For addi x1, x0, 5, ask: when is it fetched? When does decode see rd = x1? When does execute compute 5? When does writeback write x1? This style of tracing builds real architectural understanding.
Part VII: Forwarding and Hazard Detection
Data Forwarding
Pipelining improves throughput, but it introduces data hazards. Consider this sequence:
addi x1, x0, 5add x2, x1, x1
The second instruction needs x1 before the first instruction may have written it back to the register file. The forwarding unit solves this by detecting when a source register in the execute stage matches a destination register in a later pipeline stage, and selecting the newer value directly:
// forwarding_unit.svif (ex_valid && (ex_rs1_addr != 5'd0)) begin if (mem_valid && mem_rf_wr_en && (mem_rd_addr == ex_rs1_addr)) fwd_a_sel = FWD_EX_MEM; else if (wb_valid && wb_rf_wr_en && (wb_rd_addr == ex_rs1_addr)) fwd_a_sel = FWD_MEM_WB;end
The priority order is important: the EX/MEM stage (one stage ahead) takes precedence over MEM/WB (two stages ahead), because EX/MEM has the most recent value. The same logic applies symmetrically to both source operands.
Load-Use Hazards and Stalls
Forwarding handles most data hazards, but loads are different. A load result is only available after the memory stage, so if the very next instruction depends on the loaded value, it cannot be forwarded in time. The hazard unit detects this specific case — a load-use hazard — and inserts a one-cycle stall:
// hazard_unit.svrs1_hazard = id_uses_rs1 && (id_rs1_addr != 5'd0) && (id_rs1_addr == ex_rd_addr);rs2_hazard = id_uses_rs2 && (id_rs2_addr != 5'd0) && (id_rs2_addr == ex_rd_addr);load_use_stall = id_valid && ex_valid && ex_mem_read && ex_rf_wr_en && (ex_rd_addr != 5'd0) && (rs1_hazard || rs2_hazard);
When a load-use stall is detected, the PC and IF/ID register hold their current values, and the ID/EX register is flushed to insert a pipeline bubble. After the one-cycle delay, the forwarding unit can supply the loaded value normally from the MEM/WB stage.
Notice the uses_rs1 and uses_rs2 signals from the control unit — without them, the hazard unit would falsely stall on instructions like LUI or JAL that do not actually read register sources, even though the instruction word has non-zero bits in the rs1/rs2 fields.
Part VIII: Branches, Flushes, and Data Memory
Branch Resolution
Branches and jumps introduce control hazards. The processor fetches instructions sequentially, but a taken branch means the fetched instructions after the branch are wrong. This design resolves branches in the execute stage: when execute_branch_taken is asserted, the IF/ID and ID/EX pipeline registers are flushed, and the PC is redirected to the branch target.
// top.sv — PC update logicpc_stall = load_use_stall && !execute_branch_taken;pc_d = execute_branch_taken ? execute_branch_target : (fetch_started_q ? pc4 : pc_q);
The branch penalty is two cycles — the two instructions that entered the pipeline after the branch but before the result was known. In a more advanced design, this penalty could be reduced by moving branch resolution to the decode stage or by adding branch prediction.
Data Memory
The data memory supports byte, halfword, and word accesses with both sign-extension and zero-extension for loads. Internally, it is organised as a word-addressed array, with byte-offset logic handling sub-word access. This matches the RISC-V specification for LB, LH, LW, LBU, LHU, SB, SH, and SW.
// data_memory.sv — sub-word store (excerpt)unique case (dmem_data_size) MEM_SIZE_BYTE: begin unique case (byte_offset) 2'd0: mem[word_addr][7:0] <= dmem_wr_data[7:0]; 2'd1: mem[word_addr][15:8] <= dmem_wr_data[7:0]; 2'd2: mem[word_addr][23:16] <= dmem_wr_data[7:0]; 2'd3: mem[word_addr][31:24] <= dmem_wr_data[7:0]; endcase end // halfword, word cases ...endcase
Part IX: Verification
Small test programs are one of the best ways to verify and understand the processor. The integration testbench (processing_program_tb.sv) loads a program that exercises arithmetic, register writes, forwarding, loads, stores, branches, and jumps, then checks the final register file and memory state:
| Check | Expected | What it proves |
|---|---|---|
| x1 | 5 | Basic ADDI |
| x2 | 7 | Second ADDI |
| x3 | 12 | ADD with forwarding from x1, x2 |
| mem[0] | 12 | SW: store to memory |
| x4 | 12 | LW: load from memory |
| x5 | 24 | Load-use stall + forwarding |
| x6 | 0 | Branch flush (skipped write) |
| x7 | 1 | Post-branch target execution |
| x8 | 40 | JAL link address |
| x9 | 0 | JAL flush (skipped write) |
| x10 | 25 | Final computed result |
These checks are simple but powerful. They prove that instructions are not only being fetched, but also decoded, executed, forwarded, stalled, flushed, and committed correctly.
A second integration test runs a more complex program, a find-maximum loop (user_program_tb.sv) over an array of six signed integers stored in data memory. The program writes the values to memory, then iterates through them comparing each against a running maximum, and writes the final result to mem[6]. The testbench verifies the complete memory contents and that the loop counter in x12 reached the expected terminal value of 6:
| Check | Expected | What it proves |
|---|---|---|
| mem[0] | 8 | Array element stored correctly |
| mem[1] | -21 | Negative value handling |
| mem[2] | 15 | Mid-array positive value |
| mem[3] | -3 | Second negative value |
| mem[4] | 42 | Maximum value in array |
| mem[5] | 17 | Final array element |
| mem[6] | 42 | Maximum correctly identified |
| x11 | 42 | Register holds maximum |
| x12 | 6 | Loop index completed all iterations |
This test exercises conditional branches, load-use hazards across loop iterations, signed comparisons, and multi-iteration control flow — a significant step beyond the basic pipeline smoke test.
Part X: FPGA Target
The board-independent core is wrapped by a thin basys3_top.sv module for the Digilent Basys 3 (Artix-7 FPGA). The wrapper maps the centre button to active-low reset and routes the writeback data or PC to the 16 LEDs, providing immediate visual feedback during bring-up:
// basys3_top.svassign reset_n = ~btnC;assign led = dbg_wb_valid ? dbg_wb_data[15:0] : dbg_pc[15:0];
Keeping the processor core independent from board I/O is a deliberate choice. It means the same RTL can be simulated with Vivado’s xsim, synthesised for a different FPGA, or eventually integrated into a larger SoC without any modifications to the core logic.
What Comes Next
This post covers the architectural foundation. Future posts in this series will extend the processor towards custom ISA extensions for edge AI workloads.
The complete SystemVerilog source, testbenches, and memory files are available on GitHub/risc-v.
Final Thoughts
This journey started with a blank SystemVerilog file and ended with a verified, pipelined processor running real programs on a Basys 3 FPGA. We moved through instruction formats, pipeline stages, typed control structs, immediate extraction, ALU operations, pipeline registers with stall and flush control, data forwarding, load-use hazard detection, branch resolution, sub-word memory access, and integration testbenches.
The core lesson: a RISC-V processor is not a monolithic block of logic. It is a collection of small, focused modules each with one job connected by typed pipeline registers that carry instructions and their metadata forward in lockstep. A fetch unit reads memory. A decoder extracts fields. A control unit maps opcodes to signals. An ALU computes. A forwarding unit resolves data hazards. A hazard unit detects stalls. A writeback mux selects results. Each piece is simple. The power comes from how they compose.
That is the real foundation: not just RTL that runs, but architecture that you understand well enough to grow.
Published on neuralonedge.com