E5 A Fully Functional mini RISC-V Processor
We have already implemented the sCPU. Although implementing this processor has indeed given us a deeper understanding of how processors work, from a practical perspective, the sCPU cannot run more complex programs due to various limitations. In fact, these limitations ultimately stem from the simplicity of the sISA instruction set, such as:
- The PC register has a width of only 8 bits, which means that a program can contain at most 256 instructions.
- The GPRs have a width of only 8 bits, making it impossible to represent data larger than 255.
- The functionality of instructions is limited; for example, it cannot perform subtraction operations between two GPRs, let alone multiplication and division.
Next, you will implement a fully functional RISC-V processor that can run more programs and even has the potential to run NES games!
Mini RISC-V Instruction Set
RISC-V is an open instruction set architecture that has gained prominence over the past decade. It employs a modular design philosophy, partitioning instructions into distinct subsets. Beyond the base instruction set RV32I, various extensions exist—including multiply/divide (M), floating-point (F), and atomic operations (A). Developers can selectively implement zero or more extensions based on application requirements. This flexibility has made it highly popular among developers.
The RV32I base set comprises 42 instructions. Implementing RV32I alone suffices for most computational tasks. To further reduce development effort, we propose minirv—a 'miniature RISC-V' instruction subset. Minirv selects 8 core instructions from RV32I that can functionally substitute all other RV32I instructions via instruction combinations. Consequently, any task achievable with RV32I can be executed using minirv. This approach eliminates the need to implement the full 42-instruction RV32I set while enabling processors to execute relatively complex programs.
As an real Instruction Set Architecture,the details of the RISC-V specification are documented in corresponding official manuals. We encourage everyone to cultivate the good habit of reading official manuals. Therefore, you need to download the riscv manual. If this is your first exposure to ISA and processor design concepts, you may find it challenging to comprehend every detail in the manuals. However, we will guide you to locate key RV32I-related information within these documents.
Preliminarily understand the RISC-V instruction set through RTFM
Check the table of contents of the RISC-V manual. In which chapter is RV32I introduced? Try to look up the relevant content of RV32I in that chapter and answer the following questions:
- What is the bit width of the PC register?
- How many GPRs are there in total? What is the bit width of each GPR?
- What are the differences between
R[0]andR[0]in sISA? - What is the bit width of instruction encoding? How many basic formats do instructions have?
- In the basic format of instructions, how many bits are needed to represent a GPR? Why?
- What is the specific format of the
addinstruction? - There is another base instruction set called RV32E. What is the difference between it and RV32I?
After understanding some details of the RISC-V instruction set, we can present the specification of the ISA named minirv, as follows:
- The initial value of PC is
0. - The number of GPRs is the same as that defined in RV32E.
- It supports the following 8 instructions:
add,addi,lui,lw,lbu,sw,sb,jalr - Other ISA details are the same as those of RV32I.
Implement the Instruction Set simulator for minirv
A minirv processor with only two instructions
Whether we implement a processor using Logisim or RTL, we need to consider the details at the digital circuit level. However, since you may be encountering the RISC-V instruction set for the first time, implementing the MiniRV processor directly at the digital circuit level can be quite challenging.
Therefore, we will first implement the above instructions in an instruction set simulator. Implementing these instructions in a simulator is usually much simpler, as we only need to focus on how to describe their behavior using the features of the C language. This will help us develop a correct understanding of how these instructions work. We call this instruction set simulator minirvEMU.
minirv has 8 instructions, and we will first implement two of them: addi, jalr. First, let's consider the addi instruction.
RTFM(2)
Consult the RISC-V manual to find the encoding and corresponding functional description of the addi instruction. There are some instruction tables in Chapter 34, "RV32/64G Instruction Set Listings", which can help you look up the encoding of the addi instruction.
For the instruction fetch process, you need to consider modifying the width of M and the bit width of the PC register. However, the concept of memory exists at both the ISA level and the actual implementation level. Therefore, how to implement an ISA-level memory model using C code becomes an important question that needs to be addressed.
RTFM(3)
To understand several conventions of RISC-V regarding memory, you need to read the first paragraph of Section 1.4 in the RISC-V manual, so as to understand the specifications of memory from the ISA level, especially the definition of width.
For convenience, we denote the memory width defined in the RISC-V manual as . Obviously, at the ISA level, the PC register uses -bit addressing. However, in the actual C implementation, if the width of M, , is different from , the PC value cannot be directly used as an address to access M. Therefore, you need to consider how to solve this problem in the C implementation.
For the decoding process, first consider the decoding of the opcode. However, since the miniRV instruction set contains only a small number of instructions and the opcode encoding is relatively sparse, we can directly compare the opcode field of an instruction with the encoding of the addi instruction to perform the decoding process. For example, we can determine whether an instruction is an addi instruction using the following operation:
// The following is pseudo code
is_addi = (inst[6:0] == ?) && (inst[14:12] == ?)
Here, inst represents the fetched instruction, and ? needs to be determined based on the results of your manual consultation.
For operand decoding, one important point to note is the immediate value. Since the immediate field in an instruction has a relatively small bit width, but needs to be used in calculations with the wider GPRs, the immediate value must first be sign-extended before the computation.
For GPR, the design idea is similar to the previous one. In addition, in RISC-V, the function of R[0] is quite special, and you also need to consider how to correctly implement it.
For the execution process, we only need to implement the addition operation for now.
For updating the PC, since the instruction bit width of RISC-V is different from that of sISA, you also need to think about how to update the PC so that it can correctly point to the next instruction.
RTFM(4)
Consult the RISC-V manual to find the encoding and corresponding functional description of the jalr instruction.
Implement minirvEMU with two instructions
After understanding the functions of the addi and jalr instructions, try to design minirvEMU that supports these two RISC-V instructions based on your previous experience in designing sEMU processors.
To help you perform a simple test on the processor, we have prepared the following test program. In the assembly instructions below, GPRs use ABI mnemonics, that is, names that better reflect their functions are adopted. For example, zero is used to represent the GPR numbered 0. There are also a0 and ra in the assembly instructions, and you can know the corresponding GPR numbers by parsing the corresponding instruction encodings.
00000000 <_start>:
0: 01400513 addi a0,zero,20
4: 010000e7 jalr ra,16(zero) # 10 <fun>
8: 00c000e7 jalr ra,12(zero) # c <halt>
0000000c <halt>:
c: 00c00067 jalr zero,12(zero) # c <halt>
00000010 <fun>:
10: 00a50513 addi a0,a0,10
14: 00008067 jalr zero,0(ra)
Try to understand the function of this program through the state machine of the instruction set. After understanding, place the program in the M and try to run your processor, then check whether the running result of the processor meets the expectation.
Testing the addi Instruction
In the test program above, the immediate values used by the addi instructions are relatively small. To verify whether the sign extension implementation is correct, you need to make minirvEMU execute some addi instructions with negative immediate values. Try writing several addi instructions of this type, placing them into M, and check whether your implementation works correctly.
Implementing the Complete minirvEMU
Next, we consider how to implement the remaining 6 instructions of minirv. After RTFM, you will find that the function of the add instruction is very similar to the add instruction in sISA, so it is not difficult to implement. As for the lui instruction, it is quite similar to the li instruction in sISA, except that different types of immediate formats need to be taken into account.
Implement the complete minirvEMU
Implement the add and lui instructions. After implementation, try to write some simple instruction sequences and place them in the M to preliminarily check whether your implementation is correct.
The remaining 4 instructions are all memory access instructions, and they all need to access memory. Memory access operations are divided into two types: load (reading memory) and store (writing memory). In minirvEMU, memory is M. Before further considering how to implement these four memory access instructions, you need to understand the conventions for memory defined by RISC-V first, as well as the specific behaviors of the corresponding memory access instructions.
RTFM(5)
Consult the RISC-V manual to find the encodings and corresponding functional descriptions of the four instructions: lw, lbu, sw, and sb. The manual also introduces content related to EEI and unaligned memory access, which are not used for the time being, so you can ignore these contents.
The implementation of the lw instruction is relatively straightforward. After calculating the memory address, we can directly use it to read the corresponding data from M. Similar to the instruction fetch process discussed earlier, the actual memory specification represented by M may differ from the memory definition at the ISA level. Therefore, you need to consider how to correctly index M. The implementation of the sw instruction is similar to that of lw, but you also need to consider the different formats of immediate values used by the instruction.
Unaligned memory access does not need to be considered.
If the remainder of the memory address addr divided by the data width w of the memory access instruction is 0, the access is considered aligned. For lw and sw, we have w = 4. Therefore, if addr % 4 == 0 (% represents the modulo operation), the memory access is aligned; otherwise, it is unaligned.
To simplify the implementation, we can assume that the lowest 2 bits of the binary representation of the memory addresses calculated by lw and sw are always 0. The test programs we provide will guarantee this property, so there will be no case where the accessed data crosses two memory words. Therefore, the implementation does not need to handle unaligned memory accesses.
Students who are interested can try reading the related sections in the RISC-V manual for more details.
Implement the complete minirvEMU (2)
Implement the lw and sw instructions, then write some simple instruction sequences and place them into M. At the same time, place some test data in M to perform an initial verification of whether the memory access instructions behave correctly.
The lbu instruction only needs to read one byte. You need to select the corresponding byte from the read data based on the specific memory access address and write it back to the destination register.
Implement the complete minirvEMU (3)
Implement the lbu instruction and use several instruction sequences to perform an initial check of whether your implementation is correct.
Hint: You can first place a 4-byte data value 0x12345678 in M, and use the lw instruction to read it (assuming the data is stored at memory address a). Verify that the result is 0x12345678. Then, use several lbu instructions to read data from memory addresses a, a+1, a+2, and a+3. The expected results are that these lbu instructions should read 0x78 (from address a), 0x56, 0x34, and 0x12 (from address a+3), respectively.
The sb instruction is the opposite. It only needs to write a single byte to the target address.
Implement the complete minirvEMU (4)
Implement the sb instruction and use some instruction sequences to perform an initial check of whether your implementation is correct.
Hint: You can first place a 4-byte data value 0x12345678 in M, and use the lw instruction to read it (assuming the data is located at memory address a). Verify that the read result is 0x12345678. Then, use several sb instructions to write the following values to memory addresses a+3, a+2, a+1, and a+0 respectively: 0x90 (at address a+3), 0xab, 0xcd, and 0xef (at address a). Before performing the writes, you can use the addi instruction together with the zero register to load an immediate value into the destination register, achieving the same effect as the li instruction in the sISA. Finally, use the lw instruction to read the updated data again. The expected result is 0x90abcdef.
Let the Program Decide When the Simulator Should Stop
Previously, we let minirvEMU keep running until the program entered an expected infinite loop to indicate the end of execution. Another approach was to make minirvEMU stop after executing a fixed number of instructions. However, these approaches are not very general: you need to know in advance how many instructions a program will execute before it finishes, and then manually write this information into the minirvEMU code. Is there a way to automatically stop minirvEMU when the program finishes execution?
Since Logisim is a GUI-based program, it is not very convenient to add customized features. However, minirvEMU is a C program, so we can make it automatically determine whether the program has finished successfully. Specifically, we can add an ebreak instruction and define its behavior as program termination. To achieve automatic termination detection, we define that when minirvEMU executes an ebreak instruction, it should stop the entire program execution and output some corresponding information.
To make the program follow this convention, we can manually write the encoding of the ebreak instruction into the correct location in M. For the specific encoding of the ebreak instruction, please RTFM. As for the "correct location", it refers to the position near the halt() function in the program, or the end of an instruction sequence that you write yourself. For example, you can write the ebreak instruction using the following method:
M[? + 0] = ?;
M[? + 1] = ?;
// ......
You need to find the correct memory address and the encoding of the ebreak instruction, and then replace the ? above with the appropriate values.
Implement Automatic Program Termination Detection
According to the convention described above, add and implement the ebreak instruction in minirvEMU. Then modify the instruction sequence of the program so that it executes the ebreak instruction when it finishes. If your implementation is correct, you will see that the program automatically terminates and minirvEMU outputs the corresponding termination information.
Of course, this process still involves quite a few manual operations. However, since we currently only need to run a small number of programs, the overhead of these manual steps is still acceptable. As the number of programs we need to run increases, we will need to find a way to implement a fully automatic detection mechanism. We will soon continue discussing this problem.
Implement the miniRV NPC using RTL
After implementing minirvEMU, you should now have a clear understanding of the details of the MiniRV instruction set. Now it is time to "upgrade" the previous NPC from sCPU into a MiniRV processor.
Modular RTL Design
Unlike the sCPU, we will continue improving the NPC in the future by adding more features. Therefore, it is necessary to maintain the NPC project properly and prepare it for future extensions. One way to improve code maintainability is through modular design.
From the perspective of instruction types, the miniRV instruction set covers several functions, including addition, bit concatenation, memory access, and jumps. Based on these functions and the processor workflow, we can divide the NPC into the following modules:
- IFU (Instruction Fetch Unit): Responsible for fetching an instruction from memory according to the current PC.
- IDU (Instruction Decode Unit): Responsible for decoding the current instruction and preparing the data and control signals required for the execution stage.
- EXU (EXecution Unit): Responsible for controlling the ALU according to control signals and performing data calculations.
- LSU (Load-Store Unit): Responsible for controlling memory access according to control signals, including reading data from memory and writing data to memory.
- WBU (WriteBack Unit): Responsible for writing data back to registers and updating the PC.
You need to design and organize the interfaces between these modules by yourself. Of course, you can also decide which components should be placed in which module based on your own design choices.
One exception is the memory. To simplify testing, we do not plan to implement the memory using RTL. Instead, it will be implemented in C++. For now, we will consider the simplest implementation approach: exposing the memory access interface signals at the top level and using C++ code to access the memory.
while (???) {
...
top->inst = pmem_read(top->pc);
single_cycle();
...
}
You can easily implement a simple memory using C++ code.
A miniRV NPC with Only Two Instructions
When implementing minirvEMU, we used the features of the C language to describe the instruction execution process. However, if we want to implement minirv NPC using RTL, we need to consider how to implement the instruction execution process using circuit modules at the hardware level. Therefore, you should first have an architecture diagram of the miniRV NPC, whether it is drawn on paper or simply exists in your mind. With an architecture diagram, describing the circuit structure of each module using RTL code becomes much easier.
Let us first implement the simplest instruction: addi.
Implementing the addi Instruction in NPC
Specifically, you need to pay attention to the following points:
- Several binary encodings of
addiinstructions can be placed in memory. (You can use the properties of register x0 to write instructions with predictable behavior.) - Since jump instructions have not been implemented yet, the NPC can only execute instructions sequentially. You can stop the simulation after the NPC has executed a certain number of instructions.
- You can check whether the
addiinstruction is executed correctly by viewing the waveform or by printing the state of the general-purpose registers in the RTL code. - Regarding the general-purpose registers (GPRs), their circuit implementation is essentially a memory. To prevent students using Verilog from writing inappropriate behavioral modeling code, we provide the following incomplete code for you to complete. (You do not need to modify the contents of the always block.)
module RegisterFile #(ADDR_WIDTH = 1, DATA_WIDTH = 1) (
input clk,
input [DATA_WIDTH-1:0] wdata,
input [ADDR_WIDTH-1:0] waddr,
input wen
);
reg [DATA_WIDTH-1:0] rf [2**ADDR_WIDTH-1:0];
always @(posedge clk) begin
if (wen) rf[waddr] <= wdata;
end
endmodule
- You also need to consider how to implement the special behavior of zero register.
Don't know where to start?
You will likely encounter the following problems:
- How can you correctly access memory using the PC value?
- How can you place
addiinstructions into memory? - How can you stop the simulation after executing only a few instructions?
- ...
When setting up the Verilator framework, we already reminded everyone:
Every detail in the project is related to you.
Whenever you feel that you have no idea how to proceed, it is very likely a reminder that you may have missed something important in your previous learning.
Compared with simply asking your classmates, you should first review the previous experiments, try your best to understand every detail, and use that knowledge to find the answers to the problems above.
Implement the `jalr` instruction in NPC
After implementing the addi and jalr instructions, run the two-instruction test program that was previously executed on minirvEMU using NPC, and check whether the NPC execution result matches the expected behavior.
Let the Program Decide When Simulation Should End
Similar to minirvEMU, we can implement a similar mechanism in NPC: If the program executes the ebreak instruction, it will notify the simulation environment to terminate the simulation.
Implementing this feature is not difficult. First, you need to add support for the ebreak instruction in NPC. However, in order for NPC to notify the simulation environment when it executes the ebreak instruction, you also need to implement an interaction mechanism between RTL code and C++ code. We will use the DPI-C provided by SystemVerilog to achieve this interaction.
Try the DPI-C mechanism
Read the Verilator manual, find the relevant sections about the DPI-C mechanism, and try running the examples provided in the manual.
Implement `ebreak` through DPI-C
Use the DPI-C mechanism in the RTL code to notify the simulation environment to terminate the simulation when NPC executes the ebreak instruction. After implementation, place an ebreak instruction at the location of the halt() function in the program above for testing. If your implementation is correct, the simulation environment no longer needs to know when the program will finish execution. It only needs to keep running the simulation until the program executes the ebreak instruction.
If you are using Chisel, you can use Chisel's BlackBox mechanism to call Verilog code, and then allow the Verilog code to communicate with the simulation environment through DPI-C. For the usage of BlackBox, please refer to the relevant documentation.
Implementing the Complete MiniRV NPC
You need to implement the remaining six MiniRV instructions, including: add, lui,lw,lbu,sw,sb. Among them, the first two are integer computation instructions. They are very similar to the add and li instructions in sISA. You have already implemented these two instructions of sISA, so implementing them should not be difficult.
To implement the remaining four memory access instructions, we need to consider some additional issues. Memory access instructions need to access memory. Unlike instruction fetching, memory access instructions may also need to write data into memory. The simple implementation we used earlier, where the instruction fetch interface was directly exposed to the top level, cannot correctly support memory access instructions. This is because the signals of the memory access interface depend on the currently fetched instruction, while the simulation environment is unaware of this dependency and therefore cannot handle it correctly. To solve this problem, we can use the DPI-C to implement memory access:
import "DPI-C" function int pmem_read(input int raddr);
import "DPI-C" function void pmem_write(
input int waddr, input int wdata, input byte wmask);
reg [31:0] rdata;
always @(*) begin
if (valid) begin // 有读写请求时
rdata = pmem_read(raddr);
if (wen) begin // 有写请求时
pmem_write(waddr, wdata, wmask);
end
end
else begin
rdata = 0;
end
end
extern "C" int pmem_read(int raddr) {
// Always read 4 bytes from the address `raddr & ~0x3u` and return the result
}
extern "C" void pmem_write(int waddr, int wdata, char wmask) {
// Always write 4 bytes to the address `waddr & ~0x3u` using the write mask `wmask`
// Each bit in `wmask` represents the write enable mask for one byte of `wdata`.
// For example, `wmask = 0x3` means that only the lowest 2 bytes are written,
// while the other bytes in memory remain unchanged.
}
In these two memory read/write functions, we simulate the behavior of a 32-bit bus: they only support 4-byte aligned memory accesses. For read operations, the function always returns the data read from a 4-byte aligned address. The RTL code needs to select the required portion of the returned data according to the read address. This design is intended to minimize future modifications when implementing a real bus interface. You need to pass the correct parameters to these two functions when calling them in the Verilog code, and implement their functionality in the C++ code. For instruction fetching, you need to remove the previous implementation where the fetch signals were directly exposed to the top level, and instead call pmem_read() once more to implement instruction fetching.
Implement the complete miniRV NPC
Add the remaining six MiniRV instructions to NPC and run the tests that you previously wrote for minirvEMU. Determine whether the program can successfully finish execution.
lms
