RISC-V Datapath

Table of Contents

1. Single-Cycle Datapath

We will be building a one-instruction-per-cycle RISC-V machine: on every tick of the clock, the computer executes one instruction. At the rising clock edge, all the state elements will be updated with the combinatorial logic outputs, and execution moves to the next clock cycle.

1.1. State Elements

State elements store the current data of the program. RISC-V is designed to work with state elements only: if two CPUs have the exact same values in all state elements, then both CPUs will perform the exact same sequence of operations. There are three main state elements in the CPU: registers, regfile, and main memory.

1.1.1. Registers

A register stores data:

Input:

  • 32-bit input bus (D)
  • enable bit (EN)

Output:

  • 32-bit output bus (Q)

Behavior:

  • if enable is 1 on rising clock edge, set D=Q
  • otherwise, nothing changes

1.1.2. Register File

A register file (or regfile) is a group of 32 registers and manages which registers get changed and which register outputs get read:

Input:

  • one 32-bit input bus (dataW)
  • three 5-bit selectors (rs1, rs2, rsW)
  • RegWEn control bit (write enable)

Output:

  • two 32-bit output buses (data1, data2)

Behavior:

  • registers are accessed by their 5-bit register numbers, with rs1 corresponding to data1, rs2 to data2, and rsW identifies which register should update next cycle
  • if RegWEn is 1 on rising clock edge, set rsW=dataW
  • reading the value of a register doesn’t require a clock tick

1.1.3. Memory

Main memory allows access to each byte saved in the program:

Input:

  • one 32-bit input bus, addr
  • one 32-bit input bus, dataW
  • MemRW control bit (write enable)

Output:

  • one 32-bit output bus, dataR (word stored at addr)

Memory holds both instructions and data in one contiguous 32-bit memory space. In our processor, we will use two “separate” memories:

  • IMEM: A read-only memory for fetching instructions.
  • DMEM: A memory for reading and writing data words.

Since IMEM is read-only, it never requires a clock trigger and dataW:

Additionally, DMEM can only access 32 bits at a time. Additionally, RISC-V mandates that loads/stores happen on aligned addresses (multiples of 4). To access a misaligned address is undefined behavior.

2. Datapath Implementation

In general, the implementation of a datapath follows the below steps:

  1. Instruction Fetch (IF): sends the PC into IMEM to retrieve the instruction to be run.
  2. Instruction Decode (ID): read the instruction, decode what the instruction needs to do, and compute immediates/register values with a regfile.
  3. Execute (EX): do any computations needed for the instruction.
  4. Memory (MEM): for DMEM operations, a separate stage for accessing memory.
  5. Writeback (WB): update inputs to any state elements that needs to be updated.

These stages all happen in a single clock cycle. The general philosophy is to do every single operation for every instruction, then add control logic to ensure only the operations we want end up affecting state elements.

2.1. R-Type Instructions

The add instruction does two things to state elements:

  • rd = rs1 + rs2=
  • PC = PC + 4

This is the full implementation of the add instruction:

Now, we want to add the sub instruction. The sub instruction is similar to the add instruction, except that it sets rd = rs1 - rs2. To add this to our datapath, we can use a mux to select between the two operations:

We abstract away the “control logic” needed to select between the operations here. This logic reads the opcodes and funct bits of the instruction to determine which path to take.

In general, for all the arithmetic R-Type instructions, we can create a subcircuit called the arithmetic logic unit (ALU) to handle all math:

2.1.1. Datapath with Immediates

However, some of our arithmetic instructions use an immediate instead of a register. To account for this, we can add a mux to choose between rs2 and an immediate value:

The “ImmGen” (immediate generator) abstracts away the processing needed to convert an immediate value from IMEM to the 32-bit value expected by the ALU.

2.1.2. Load Instructions

Now let’s add the lw instruction. lw first computes addr = rs1 + imm, then affects the following state elements:

  • rd = *addr
  • PC = PC + 4

We can already compute addr using the ALU. However, we need to add a DMEM after the ALU but before writing back to register in order to get the data stored at addr. We also add another mux to select which path to write back from:

2.2. Store Instructions

Now let’s implement the sw instruction. sw affects the following state elements:

  • DMEM[addr] = rs2
  • PC = PC + 4

We have two main changes here: we are now writing to DMEM (not regfile), and our immediate is split into two parts (as per the instruction format). To fix this, we change ImmGen and add lines to the dataW input of DMEM:

2.3. Jump Instructions

Now let’s implement the jal instruction. jal computes a new PC, where PC = PC + offset: our goal will be for the ALU to do this math. jal also affects two state elements:

  • rd = PC + 4
  • PC = PC + offset

We have a couple issues here. We need ImmGen to handle J-Type immediates (including adding the implicit 0). The writeback to regfile needs to include PC+4, and the writeback to PC needs to include PC+offset. Finally, we also need a way to send the PC into the ALU so it can compute PC+offset:

2.4. Branch Instructions

Let’s add the beq instruction. Critically, beq compares rs1 and rs2, so we need a new component to do comparisons. It also computes PC + offset to figure out the branch, and sets one state element:

  • PC = PC + 4 if branch not taken, otherwise PC = PC + offset

To do comparisons, we introduce a new component: the branch comparator:

2.4.1. Branch Comparator

The branch comparator will handle all our branch instructions.

Input:

  • Two data buses (corresponding to rs1 and rs2)
  • BrUn control bit (do unsigned comparison?)

Output:

  • BrEq is 1 if A==B
  • BrLt is 1 if A<B

We can also implement lui and auipc with this datapath by adjusting ImmGen to handle different types of immediates.

2.5. ImmGen

A lot of our immediate processing is being abstracted into the “immediate generator” (ImmGen) currently, so let’s actually implement that. The idea is we can look for patterns in the instruction format:

I 11 10 9 8 7 6 5 4 3 2 1 0
S 11 10 9 8 7 6 5 4 3 2 1 0
B 12 10 9 8 7 6 5 4 3 2 1 11
U 31 30 29 28 27 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12
J 20 10 9 8 7 6 5 4 3 2 1 11 19 18 17 16 15 14 13 12

We can gain some insight here:

  • Bit 0: bit 20 if I-type, bit 7 if S-type, always 0 if B/U/J-type
  • Bits 4-1: these bits are always found together — bits 24-21 if I/J-type, bits 11-18 if S/B-type, always 0 if U-type
  • Bits 10-5: always found in the same part — bits 30-25 if I/S/B/J-type, always 0 if U-type
  • MSB: always the MSB of the instruction — to sign extend, we can just copy the MSB of the instruction

2.6. Control Logic

When it comes to control logic, there are two main options: read-only-memory (ROM) and combinatorial logic. ROM can be easily reprogrammed to fix errors and add instructions, but combinatorial logic uses gates and is faster.

2.6.1. ROM-Based Control

Read-only memory (ROM) reads out a word at a given address. To get control signals, we pass in for the input address the relevant set of instruction bits that determine which instruction we want, and the output word gives us the bits of our control signals.

RV32I is a 9-bit ISA, which means we only need to pass nine bits into ROM to get our control bits.

2.6.2. Combinatorial Logic Control

Under the hood, the ROM controller can be implemented as combinatorial logic gates by using sum of products on the underlying logic table. However, using a combinatorial logic circuit that computes each individual control signal using the relevant bits uses significantly fewer gates than a ROM-based approach would.

3. Critical Paths

The total time delay of a critical path directly influences how fast our processor can run. In a single-cycle datapath, the clock speed of a processor must encompass the critical path delay of the longest instruction.

A critical path always starts at the output of and ends at the input to a state element, such as a flip flop. The state elements in our RISC-V processor are generally combinational read (doesn’t require a clock tick), but sequential write. For our datapath, the clock period must obey the following time delay:

\begin{align} \boxed{t_{\text{clk-to-q}} + t_{\text{logic}} + t_{\text{setup}} \leq t_{clk}} \end{align}

The clock-to-q and setup times are for the registers we start and end at, and the logic times are for all the combinational logic gates that we go through.

4. Stage Pipelining

Our single-cycle datapath is reasonably fast, but it’s not as efficient as it could be: most components do nothing most of the time (e.g. IMEM is idle while the ALU is running).

We want to improve the following performance measures for our processor:

  • Latency: program execution time (e.g. time to update display)
  • Throughput: total tasks per unit time (e.g. number of requests handled per hour)
  • Energy Efficiency: energy per task (e.g. how many movies you can watch per battery charge)

4.1. Iron Law of Processor Perforamnce

The iron law of processor performance states the following:

\begin{align} \boxed{\frac{\text{Time}}{\text{Program}} = \frac{\text{Instructions}}{\text{Program}} \times \frac{\text{Cycles}}{\text{Instruction}} \times \frac{\text{Time}}{\text{Cycle}}} \end{align}

We can reduce the number of instructions per program by optimizing our compiler and how we write our program. We can also optimize the instruction set we use.

We can reduce the number of cycles per instruction by optimizing our datapath. For our single-cycle datapath, the CPI is 1; but for a pipelined processor, the CPI is approximately one. We define CPI to be the inverse of throughput.

We can reduce the amount of time per cycle by optimizing our processor implementation as well as the physical technology. Supply voltage also plays a role: lower voltage reduces transistor speed but improves energy efficiency.

For example, the classical instruction set CISC optimized for instructions per program, so it had fewer instructions per program but longer cycles per instruction. However, since RISC has less instructions, RISC wins on time per cycle.

4.2. Pipelining

The idea behind pipelining is that once one stage of an instruction is done, that stage can be used by the next instruction. This way, components don’t have to wait for the current instruction to be done. Pipelining allows us to increase instruction throughput, not instruction latency (the same instruction still takes the same amount of time).

We do this by adding registers between the stages we want to pipeline. This allows us to speed up our clock (since the critical delay is now reduced between two registers), thereby increasing our throughput. However, a single instruction now takes more clock cycles to complete (increasing latency). In our RISC-V datapath, we can add these between each stage of the instruction:

4.3. Hazards

We may run into some issues when we run code using naive pipelining. These issues are called hazards.

4.3.1. Structural Hazard

A structural hazard occurs when multiple instructions compete for access to a single physical resource. There are two potential structural hazards in RISC-V: simultaneous regfile access, or simultaneous memory access. For example, a structural hazard can happen when a previous instruction’s writeback stage coincides with the decode stage for another instruction. To solve this hazard, we can simply add more hardware specifications: how it handles simultaneous read/write. For example, requiring separate write ports on the regfile and separate IMEM and DMEM hardware solves this hazard in RV32I.

4.3.2. Data Hazard

A data hazard occurs when instructions have data dependencies: one instruction needs to wait for a previous instruction to complete its data read/write. If the same register is read on the same cycle it is written, we always write first, then read. Additionally, we can insert nop stalls in order wait for a previous instruction to write before reading a register for the current instruction.

Another technique we can use to reduce the number of stalls is forwarding, also known as bypassing. This technique involves using the result of the previous instruction when it is computed, instead of waiting for it to be stored in the register. This will, however, result in additional datapath connections.

The instruction slot after a load is known as a load delay slot. This is because if we want to use the register we loaded in the next instruction, we must stall. However, a clever compiler can reorder instructions by identifying unrelated instructions to put in the load delay slot, effectively saving that stall without any performance loss.

4.3.3. Control Hazard

A control hazard occurs when the flow of execution depends on previous instructions. When we pipeline naively, we might load in instructions that we don’t want to run next, such as due to a jal instruction. For jump and branch instructions, we can naively insert three stalls to prevent this hazard.

An optimization that we can take is to run following instructions on branches even if they’re the wrong ones. If a branch ends up not being taken, don’t flush the instructions, so there’s no penalty. Computing the results of a branch takes 3 cycles anyways, so if the branch ends up not being taken, we just discard the result during the MEM stage. This is known as branch prediction. If we guess correctly, then our average performance is improved.

Last modified: 2026-07-22 14:13