E4 Processor Simulation and Verification
You have already learned how to use Verilog through the online learning platform HDLBits. Similarly, to design your own processor, you will need to use Linux as the development environment.
RTL Simulation - Functional Verification
It is not hard to develop RTL code in Linux, as long as we have a text editor. However, we also need a simulator to check if our circuit from the RTL code functions as expected after we finish our RTL code development.
STFW + RTFM to Build The Verilator Simulation Environment
Verilator is an open-source Verilog simulator that you will use for RTL functional simulation.
The framework code provides an npc directory by default, where npc stands for New Processor Core. You will design your own processor in this directory in the future. The processors designed by everyone will be collectively referred to as NPC, but you can certainly give your processor a more personalized name. However, to set an environment variable NPC_HOME, you need to run the following command:
cd ysyx-workbench
bash init.sh npc
This environment variable will be used in the future. There are some simple files in the npc directory:
ysyx-workbench/npc
├── csrc
│ └── main.cpp
├── Makefile
└── vsrc
└── example.v
Currently, these three files are almost empty. We will guide you through setting up the Verilator simulation environment and writing two simple digital circuit modules for simulation.
There isn't even a simulation framework? Lame!
The reason why we have included this part of the experiment is to let everyone understand that all details in the project are relevant to you. In previous course experiments, more or less, everyone would feel that the framework should naturally be provided by the teaching assistants. Doing the experiment just meant writing the corresponding code in the designated places, and all other codes/files were irrelevant and didn't require attention. In fact, such an experimental approach is very dangerous. It not only fails to train you into a truly professional person but also makes it impossible for you to survive in real projects:
- When encountering systematic bugs, you will definitely not be able to fix them. Because even the modules that call your code are considered irrelevant to you, let alone having a clear understanding of the entire project's architecture and every detail within it.
- Without lecture notes, you can't do anything. Because you are always waiting for others to clearly tell you what to do next and how to do it, just like these lecture notes do, instead of practically analyzing what should be done from the project's perspective.
A very realistic scenario is that when you join a company or a research group in the future, there will no longer be lecture notes or framework codes to assist you. If your boss says "Come and try Verilator", you have to get Verilator up and running by yourself, write a usage report, and present your work to the boss at the group meeting next week. Therefore, we hope to provide you with more realistic training: set a goal, and let you learn to break down the goal and achieve it step by step with your own skills. Building a Verilator simulation framework is actually a goal that can be easily achieved, so it is also very suitable as a small training to test your abilities.
If you want to use Chisel
Chisel can generate functionally equivalent Verilog code, which can then be simulated using Verilator. For now, we will focus on the usage of Verilator. If you wish to use Chisel, we also recommend that you first set up the Verilog workflow as described in the lecture notes, and then switch.
Let's begin.
Familiarize with Verilator
This is probably the first time you hear about Verilator, and that's quite normal. Then, it is also normal that you would want to learn more about various aspects of Verilator. However, it is inappropriate if your first reaction is to ask someone. In fact, the Verilator tool is so well-known in the simulation field that you can easily find relevant information about it on the Internet. You need to find its official website through STFW and then read the relevant introduction.
After finding and reading the relevant information, it is time to try running it. But before that we need to install it first.
Installing Verilator
Find the steps to install Verilator on the official website and follow the corresponding steps for installation via git. The reason we don't use apt-get for installation is that the version it provides is relatively old. In addition, to unify the version, you need to install version stable via git. For this purpose, you also need to perform some simple git operations. If you are not familiar with this, you may need to look for some git tutorials to learn. Moreover, it's better for you to carry out this operation in a directory outside ysyx-workbench/. Otherwise, git will track the source code of Verilator, thereby occupying unnecessary disk space.
After successful installation, run the following command to check if the installation is successful and if the version is correct.
verilator --version
Verilator compiles code into C++ files, which are then compiled into executable files. Simulation is carried out by running these executable files.
Running An Example
The Verilator manual contains a C++ example. You need to find this example in the manual and follow the steps of the example to operate. You have already learned C language. To use Verilator, you don't need to understand complex C++ syntax. You just need to know some basic usage methods of classes. From this point of view, many materials on the Internet can meet your needs.
Example: Two-way Switch (Combinational Logic Circuit)
The example in the manual is very simple and doesn't even qualify as a real circuit module. Next, we'll write a real circuit module, a two-way switch, for testing. Write the following Verilog code:
module top(
input a,
input b,
output f
);
assign f = a ^ b;
endmodule
One application of a two-way switch is to jointly control the on/off state (f) of the same lamp through two switches (a and b). Unlike the example in the manual, this module has input and output ports. To drive the input ports and obtain results from the output ports, we need to modify the while loop in the C++ file:
// The following is pseudocode
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
while (???) {
int a = rand() & 1;
int b = rand() & 1;
top->a = a;
top->b = b;
top->eval();
printf("a = %d, b = %d, f = %d\n", a, b, top->f);
assert(top->f == (a ^ b));
}
In one loop iteration, the code will randomly generate two 1-bit signals to drive the two input ports. Then, it will update the circuit state using the eval() function, allowing us to read and print the values from the output port. To automatically verify the correctness of the results, we will check the output using assert() statements.
Simulate the two-way switch module
Try to simulate the two-way switch module in Verilator. Since the top-level module name is different from the example in the manual, you need to make some corresponding modifications to the C++ file. In addition, this project has no statement to indicate the end of the simulation. To exit the simulation, you need to type Ctrl+C.
What does the above code mean?
If you don't know how to modify it, it means you are not very familiar with writing C programs. You should go back to the previous section to review C language.
Printing and Viewing Waveforms
Viewing waveform files is one of the common methods for RTL debugging. Verilator supports waveform generation, and you can view waveforms using the open-source waveform viewer GTKWave.
Generate and View Waveforms
The Verilator manual has already introduced the method for generating waveforms. You need to read the manual to find the relevant content, then follow the steps in the manual to generate the waveform file, and install GTKWave using
apt-get install gtkwave
to view the waveforms.
With so much content in the manual, how to find it?
Try pressing Ctrl+F.
Do not generate waveforms for a long time
Waveform files generally occupy a lot of disk space. Generating waveforms for a long time may lead to disk space exhaustion, which can cause the system to crash.
Generate FST format waveforms
The size of FST format waveform files is roughly 1/50 of that of VCD format, but it is only supported by GTKWave. Nevertheless, we still recommend you to use it. Specifically, you can refer to the Verilator manual to learn how to generate FST format waveforms.
Writing Makefile
One-click Simulation
Repeatedly typing compile and run commands is inconvenient. Try to write a sim rule for npc/Makefile to implement one-click simulation, such that typing make sim will execute the above simulation.
Note to Preserve Git Tracking Commands
The framework code has already provided a default sim rule in npc/Makefile, which includes the command for git tracking: $(call git_commit, "sim RTL"). When writing the Makefile, be careful not to modify this command, as it will affect the development tracking function, which is an important basis for recording the originality of the "One Student One Chip" results. Therefore, after writing the Makefile and running it, you also need to confirm whether git has correctly tracked the simulation records.
Integrating NVBoard
NVBoard (NJU Virtual Board) is a virtual FPGA board project developed by Nanjing University for teaching purposes. It can provide a virtual board interface in an RTL simulation environment, supporting functions such as DIP switches, LED lights, VGA displays, etc. In scenarios where speed requirements are not high, it can completely replace a real FPGA board (after all, not everyone has an FPGA at hand). Obtain the NVBoard code using the following command:
cd ysyx-workbench
bash init.sh nvboard
Running The NVBoard Example
Read the README.md of NVBoard, and try to run the provided example.
Not sure how NVBoard works?
Try starting with the make command to see how everything happens. With the knowledge you've gained from previous studies, you already have a sufficient background to understand how NVBoard operates: This includes the use of Makefiles, and the basic usage of classes in C and C++. Now, try reading the code (Makefiles are also code) to see how the Verilog top-level ports, constraint files, and NVBoard are connected.
Implement the two-way switch on NVBoard
Read the instructions of the NVBoard project, then try to mimic the C++ files and Makefile in the example to modify your C++ file, assign pins to the input and output of the two-way switch, and modify npc/Makefile to connect it to the switches and LED lights on NVBoard.
The story of NVBoard
Although NVBoard is a teaching project of Nanjing University, it has a special connection with all those participating in "One Student One Chip":
Among the list of students who fabricated chips in the third phase of "One Student One Chip", there were two special ones. They were only freshmen when they signed up. And one of them, sjr, is the first author of NVBoard.
In fact, it was the ability to solve problems independently and the confidence that sjr developed while participating in "One Student One Chip" that helped him successfully develop the NVBoard project. Now, the NVBoard project in turn helps "One Student One Chip" improve the learning effect. Beyond its function as a virtual FPGA board, NVBoard also carries the concept of independently solving problems that "One Student One Chip" upholds. All of this is not far from you. When you are willing to learn independently instead of waiting for others to give you answers, your future will also be full of infinite possibilities.
Example: Running Lights (Sequential Logic Circuit)
A running light is a set of lights that turn on and off in sequence. Below is a reference implementation of a running light:
module light(
input clk,
input rst,
output reg [15:0] led
);
reg [31:0] count;
always @(posedge clk) begin
if (rst) begin led <= 1; count <= 0; end
else begin
if (count == 0) led <= {led[14:0], led[15]};
count <= (count >= 5000000 ? 32'b0 : count + 1);
end
end
endmodule
Each bit of its output signal led corresponds to an LED light on the virtual board. Since the code contains sequential logic components that require reset, we need to modify the simulation code of Verilator:
// Below is the pseudocode
void single_cycle() {
top->clk = 0; top->eval();
top->clk = 1; top->eval();
}
void reset(int n) {
top->rst = 1;
while (n -- > 0) single_cycle();
top->rst = 0;
}
...
reset(10); // reset for 10 cycles
while(???) {
...
single_cycle();
...
}
Connect the running lights to NVBoard
Write the running lights module, then connect it to NVBoard and assign pins. If your implementation is correct, you will see the lights light up and turn off sequentially from the right end to the left end.
Static Code Checking
Verilator can also function as a lint tool for static code checking. By passing the --lint-only parameter to verilator in the command line, Verilator will only perform code checking, and point out potentially problematic code in the form of warning messages without generating C++ files. In particular, you can also add the -Wall option to enable all types of checks in Verilator, allowing it to help you find more potential issues.
The Errors and Warnings chapter in the Verilator manual lists explanations for all warnings. By reading them, you will understand how these warnings are generated and thus know how to fix them. For warnings related to code logic, you should remove them by modifying the code; but for some warnings related to code style, if you are sure they do not affect the code logic, you can turn off the xxx warning with an additional -Wno-xxx option, such as -Wno-DECLFILENAME.
Perform Static Code Checking with Verilator
Try using Verilator to check your code and fix all warnings as much as possible. We recommend that you always enable Verilator's static code checking function in the future. On one hand, this helps you develop good coding habits, thereby writing higher-quality code. On the other hand, finding potential problems in the code as early as possible is also beneficial for saving unnecessary debugging work: As the code scale increases, you may well spend several days debugging due to a bit-width error of a certain signal in the future, but Verilator's warnings can make you notice this problem immediately, thus easily eliminating the corresponding error.
Advanced Study in Verilator
Several Coding Styles and Standards
In previous phases of OSOC, we found that certain non-standard coding styles can introduce additional problems during the SoC integration phase. To avoid impacting the progress of future SoC integrations, we recommend that everyone adhere to the following coding standards.
1. If you have not deeply understood the event model of Verilog, do not use behavioral modeling.
In fact, the Digital Circuit Lab notes from Nanjing University also mention that “behavioral modeling is detrimental to beginners in establishing circuit thinking.” We quote the relevant description here:
it is strongly recommended that beginners do NOT design circuits using behavioral modeling.
Verilog was not originally intended for designing synthesizable circuits; its essence is a circuit modeling language based on an event queue model. Therefore, behavioral modeling can easily lead beginners away from the original intent of circuit description: Developers need to look at the circuit diagram, mentally visualize the circuit behavior, and then convert that into the event queue model, ultimately using behavioral modeling to describe the circuit’s behavior, from which the synthesizer derives the corresponding circuit. From this process, it is not only unnecessary but also very easy to introduce errors:
- If the developer already has the circuit diagram in mind, describing it directly is the most convenient.
- If the developer already has the circuit diagram in mind, but their understanding of behavioral modeling is flawed, they may adopt an incorrect description method, resulting in an unexpected circuit design.
- If the developer does not have the circuit diagram, but expects the synthesizer to generate a circuit of a certain behavior through behavioral modeling, this has already deviated from the essence of “describing circuits.” Many students easily make this mistake, treating behavioral modeling as procedural C code and attempting to map any complex behavior to a circuit, ultimately leading the synthesizer to generate low-quality circuits with high delay, area, and power consumption, or even result in a circuit that behaves unexpectedly due to data races in the code.
Therefore, until everyone masters the “description of circuits” thinking without being misled by behavioral modeling, we strongly recommend that beginners stay away from behavioral modeling and directly describe circuits using data flow modeling and structured modeling. The following questions can help test whether you have grasped the essence of Verilog:
- In hardware description languages, what is the precise meaning of “execution”?
- Who executes Verilog statements? Is it the circuit, the synthesizer, or something else?
- If the condition of an if statement is met, the statements following else are not executed; what does “not executed” mean here? What is its relation to describing circuits?
- There are “concurrent executions”, “sequential executions”, and “executions triggered by any variable change”, as well as “executions under any circumstances”; how are they reflected in the designed circuit?
If you cannot answer these questions clearly, we strongly recommend that you refrain from using behavioral modeling. If you truly want to understand them, you need to read the Verilog Standard Manual.
the true description of a circuit = instantiation + wiring.
Forgetting behavioral modeling allows for a straightforward return to the simple essence of circuit description. Imagine you have a circuit diagram; how would you describe its contents to others? You would likely say something like, “There is an A component/module, and its x pin is connected to the y pin of another B component/module,” as this is the most natural way to describe a circuit. Designing circuits with HDL is about using HDL to describe the circuit diagram—what’s on the diagram is directly what you describe. Thus, using HDL to describe a circuit essentially involves two tasks:
- Instantiation: Placing a component/module on the circuit board, which can be a gate circuit or a module composed of gate circuits.
- Wiring: Correctly connecting the pins of components/modules with wires.
You can appreciate how data flow modeling and structured modeling embody these two tasks, while behavioral modeling complicates these straightforward tasks.
Thus, we do not recommend beginners write any "always" statements in Verilog code. To facilitate the use of flip-flops and multiplexers, we provide the following Verilog templates for you to utilize:
// Flip-Flop Template
module Reg #(WIDTH = 1, RESET_VAL = 0) (
input clk,
input rst,
input [WIDTH-1:0] din,
output reg [WIDTH-1:0] dout,
input wen
);
always @(posedge clk) begin
if (rst) dout <= RESET_VAL;
else if (wen) dout <= din;
end
endmodule
// Example of using the Flip-Flop Template
module example(
input clk,
input rst,
input [3:0] in,
output [3:0] out
);
// width of 1 bit, reset value of 1’b1, write enable always active
Reg #(1, 1'b1) i0 (clk, rst, in[0], out[0], 1'b1);
// width of 3 bits, reset value of 3’b0, write enable is out[0]
Reg #(3, 3'b0) i1 (clk, rst, in[3:1], out[3:1], out[0]);
endmodule
// Internal Implementation of the Multiplexer Template
module MuxKeyInternal #(NR_KEY = 2, KEY_LEN = 1, DATA_LEN = 1, HAS_DEFAULT = 0) (
output reg [DATA_LEN-1:0] out,
input [KEY_LEN-1:0] key,
input [DATA_LEN-1:0] default_out,
input [NR_KEY*(KEY_LEN + DATA_LEN)-1:0] lut
);
localparam PAIR_LEN = KEY_LEN + DATA_LEN;
wire [PAIR_LEN-1:0] pair_list [NR_KEY-1:0];
wire [KEY_LEN-1:0] key_list [NR_KEY-1:0];
wire [DATA_LEN-1:0] data_list [NR_KEY-1:0];
genvar n;
generate
for (n = 0; n < NR_KEY; n = n + 1) begin
assign pair_list[n] = lut[PAIR_LEN*(n+1)-1 : PAIR_LEN*n];
assign data_list[n] = pair_list[n][DATA_LEN-1:0];
assign key_list[n] = pair_list[n][PAIR_LEN-1:DATA_LEN];
end
endgenerate
reg [DATA_LEN-1 : 0] lut_out;
reg hit;
integer i;
always @(*) begin
lut_out = 0;
hit = 0;
for (i = 0; i < NR_KEY; i = i + 1) begin
lut_out = lut_out | ({DATA_LEN{key == key_list[i]}} & data_list[i]);
hit = hit | (key == key_list[i]);
end
if (!HAS_DEFAULT) out = lut_out;
else out = (hit ? lut_out : default_out);
end
endmodule
// Multiplexer Template without Default Value
module MuxKey #(NR_KEY = 2, KEY_LEN = 1, DATA_LEN = 1) (
output [DATA_LEN-1:0] out,
input [KEY_LEN-1:0] key,
input [NR_KEY*(KEY_LEN + DATA_LEN)-1:0] lut
);
MuxKeyInternal #(NR_KEY, KEY_LEN, DATA_LEN, 0) i0 (out, key, {DATA_LEN{1'b0}}, lut);
endmodule
// Multiplexer Template with Default Value
module MuxKeyWithDefault #(NR_KEY = 2, KEY_LEN = 1, DATA_LEN = 1) (
output [DATA_LEN-1:0] out,
input [KEY_LEN-1:0] key,
input [DATA_LEN-1:0] default_out,
input [NR_KEY*(KEY_LEN + DATA_LEN)-1:0] lut
);
MuxKeyInternal #(NR_KEY, KEY_LEN, DATA_LEN, 1) i0 (out, key, default_out, lut);
endmodule
In which, the MuxKey module implements the “key-value selection” function, which sets out to the matching data based on the provided key key from a list of (key, data) pairs lut. If there is no data with the key value key in the list, out will be 0. Specifically, the MuxKeyWithDefault module can provide a default value default_out, and when no key-value pair matches key, out will be default_out.
When instantiating these two modules, please note the following:
- Users need to provide the number of key-value pairs
NR_KEY, the bit width of the keyKEY_LEN, and the data widthDATA_LEN, ensuring that the port signal widths match the parameters provided, or else incorrect results will be produced. - If there are multiple data entries with the same key value in the list, the value of
outis undefined, and it is the user’s responsibility to ensure that the key values in the list are unique.
The implementation of the MuxKeyInternal module utilizes various advanced features such as generate and for loops, and behavioral modeling has been used for convenience. Here, we do not elaborate on this; through the abstraction of structured modeling, users can ignore these details.
The following code uses the multiplexer templates to implement both a 2-to-1 multiplexer and a 4-to-1 multiplexer:
module mux21(a,b,s,y);
input a,b,s;
output y;
// Implement the following always code through MuxKey
// always @(*) begin
// case (s)
// 1'b0: y = a;
// 1'b1: y = b;
// endcase
// end
MuxKey #(2, 1, 1) i0 (y, s, {
1'b0, a,
1'b1, b
});
endmodule
module mux41(a,s,y);
input [3:0] a;
input [1:0] s;
output y;
// Implement the following always code through MuxKeyWithDefault
// always @(*) begin
// case (s)
// 2'b00: y = a[0];
// 2'b01: y = a[1];
// 2'b10: y = a[2];
// 2'b11: y = a[3];
// default: y = 1'b0;
// endcase
// end
MuxKeyWithDefault #(4, 2, 1) i0 (y, s, 1'b0, {
2'b00, a[0],
2'b01, a[1],
2'b10, a[2],
2'b11, a[3]
});
endmodule
if you use Chisel, it is also advised that you do not use "when" and "switch".
In Chisel, the semantics of when and switch are very similar to Verilog’s behavioral modeling, so it is also not recommended for beginners to use them. Instead, you can use library functions like Mux1H to implement multiplexer functionality. For specifics, you can refer to related materials on Chisel.
2. if you insist on using Verilog’s behavioral modeling, do not use negedge.
Mixing posedge and negedge can make timing convergence more difficult and increase the difficulty of backend physical implementation. If you are unclear on how to maintain good timing while mixing both, we recommend you only use posedge. Otherwise, if your processor severely affects the overall timing of the SoC, the OSOC project team will remove your processor from the batch of tape-out listings under tight tape-out deadlines.
If you use the Verilog templates we provided above or use Chisel, you do not need to worry about this issue.
if you insist on using Verilog’s behavioral modeling, do not use latches.
The changes in latches are not driven by the clock, making them difficult for timing analysis tools to analyze. If you are unsure how to avoid latches, we recommend you not use behavioral modeling.
If you use the Verilog templates we provided above or use Chisel, you do not need to worry about this issue.
4. you need to add a student ID prefix before the module name.
For example, module IFU needs to be modified to module ysyx_22040000_IFU. This is because when everyone integrates their processor into the SoC, modules with the same name will lead to duplicate definition errors reported by the tools.
If you use Chisel, you do not need to add a student ID prefix to your module names while writing code for now.
5. if you use Verilog, you need to add a student ID prefix before the macro definition identifiers.
For example, define SIZE 5` needs to be modified to define ysyx_22040000_SIZE 5`. This is because when everyone integrates their processor into the SoC, macros with the same name will lead to duplicate definition errors reported by the tools.
If you use Chisel, you do not need to worry about this issue.
Complete Digital Circuit Experiments
You have previously completed several digital circuit designs through the online learning platform HDLBits. After setting up the simulation environment, we can now support a relatively complete digital circuit design process:
New Requirements -> Architecture Design -> Logic Design -> Functional Verification -> Circuit Evaluation
Here, Architecture Design refers to “thinking about how to implement new requirements through circuit functionality,” Logic Design refers to “implementing the design plan using RTL code,” Functional Verification is currently achieved through Verilator simulation to check whether the functionality implemented by the RTL code meets expectations, and Circuit Evaluation uses open-source EDA tools to assess circuit performance, area, power consumption, and other metrics.
Next, you will attempt to complete some digital circuit experiments according to the above process, thereby gaining a deeper understanding of it.
Learning the Agile Development Language Chisel
For some complex digital circuits, using Chisel can make the design process more convenient. You will gradually realize this during your study in Stage B. However, One Student One Chip does not restrict which language you use to design your processor.
If you plan to learn Chisel, we still recommend that you first master the basics of Verilog, then, follow this suggested learning sequence:
- Chisel Users Guide It's a good introduction to chisel, as it organizes the features of chisel in a more systematic way.
- Chisel cheatsheet A concise list of common uses cases of the chisel language.
- Digital Design with Chisel is a reference book that integrates digital logic design concepts with Chisel.
- Chisel API All APIs of the chisel library are listed in detail for reference.
If you would like to join the Chisel discussion group, you can scan the QR code below using WeChat and contact a teaching assistant to request access:

if you want to use Chisel
Please run the following command:
cd ysyx-workbench
bash init.sh npc-chisel
This command will replace the files in the npc directory with a Chisel development environment; specific details can be found in the README.md within it.
For Verilog code generated by Chisel, warnings from Verilator’s static code analysis may be difficult to fix, but you can ignore them as long as you are sure these warnings do not affect code correctness. However, we still recommend that you always enable Verilator’s static code check feature, as you may discover some code logic-related issues while reviewing these warnings.
Complete digital circuit experiments with NVBoard
We first recommend Nanjing University's Digital Circuit and Computer Composition Experiment.
Nanjing University has implemented a teaching reform that integrates “Digital Circuits” and “Computer Organization Principles” into a single course, with experiment content spanning from the basics of digital circuits to simple processor design. With NVBoard, you can treat it as an FPGA to implement experiments that require FPGA support.
You need to complete the following mandatory content:
- Experiment 2: Decoders and Encoders
- Experiment 3: Adders and ALUs
- Experiment 6: Shift Registers and Barrel Shifters
- Experiment 7: State Machines and Keyboard Input
- Experiment 8: VGA Interface Controller Implementation
If you plan to use Chisel to complete the above digital circuit experiments, you just need to connect the compiled Verilog code to Verilator and NVBoard.
Implementing a Simple Processor with RTL
The moment has finally come to design your first processor using RTL! You have implemented sCPU using Logisim, and now to implement sCPU in RTL, you will describe the circuit structure of each module using RTL code based on the circuit schematic in Logisim. With your experience in completing digital circuits, this should not be difficult for you.
Implement sCPU with RTL
Try to make sCPU the design target of NPC. Based on the sCPU you designed with Logisim, redesign it using RTL for calculating 1+2+...+10. To see the output. You can output the computation result to the NVBoard's LEDs via the io instruction.
The Efficient Method for Processor Verification——DiffTest
Currently, sISA only has 4 instructions, and the summation program running on sCPU is also very simple — even if the RTL implementation is wrong, debugging with waveforms is not difficult. However, as the processor and programs become more complex, finding errors from a massive amount of waveform data will become very challenging. Imagine that in the future, the NPC you design has 1,000 signals. If you discover after 100,000 cycles that a complex program's result is incorrect, the waveform file will contain 1000 × 100000 = 100 million signal values. How do you quickly find a few key signal values among them to help diagnose the RTL problem? Clearly, in such a complex scenario, relying solely on waveforms for debugging is extremely inefficient.
To find an efficient debugging method, we need to revisit how a processor works. Running a program on a processor is simply the process of executing instructions one by one. This process can essentially be described by an ISA model machine — the processor is merely a digital circuit that implements this ISA model machine. So, can we implement this ISA model machine in a simpler way and compare whether the instruction execution processes of both are correct?
Indeed we can!* This is actually a very effective testing methodology, known in the software testing field as [differential testing] difftest (subsequently referred to as DiffTest). Typically, DiffTest is performed by providing a REF (Reference) that has the same functionality as the DUT (Design Under Test), but with a different implementation, and then letting them accept the same defined inputs to see if they behave the same way. *When the behavior of the two differs, it usually indicates that the DUT has a bug under the corresponding input.
Clearly, we should treat the processor implemented with digital circuits as the DUT. To verify the DUT using DiffTest, we also need a REF.
ISA Simulator — A Program That Can Execute Programs
In fact, we attempt to implement the process of program execution using the C language. Such a program used to execute other programs is called a "simulator". In the industrial design process of processors, simulators play a very important role. As you may have guessed, this simulator can serve as the REF for DiffTest! but for now, let’s focus on how to implement a simple simulator.
You have already designed a CPU before, and the process involves using a digital circuit state machine to implement the ISA state machine. To implement a simulator in C, what we really need to consider is how to use the C program's state machine to implement the ISA state machine. Thus, let's first review C programs and ISA from the perspective of state machines:
You have already tried to design a CPU, whether designed using Logisim or RTL, where the process involves using a digital circuit state machine to implement the ISA state machine. To implement a simulator in C, we need to consider how to use the state machine of the C program to realize the ISA state machine. Therefore, we will first review the C program and ISA from the perspective of state machines:
| C Program | ISA | |
|---|---|---|
| State | {PC, V} | {PC,R,M} |
| Event | State Transition Rule | Execute Instruction |
| State Transition Rule | Statement Semantics | Instruction Semantics |
To use the C program’s state machine to implement the ISA state machine, we need to develop a C program that includes the following functionalities:
- Use the C program’s state to represent the ISA’s state, which means using the C program’s variables to represent the ISA’s PC, GPR, and memory.
- Use the C program’s state transition rules to implement the ISA’s state transition rules, which means using C language statements to implement the semantics of instructions.
A simulator that only implements instruction behavior from the ISA perspective is called an instruction set simulator. Similarly, there are architecture simulators and circuit simulators, but we will not touch on those for now.
Let’s illustrate how to implement an instruction set simulator using sISA as an example, referring to the corresponding instruction set simulator as sEMU (simple EMUlator). For details about sISA, please refer to previous lectures.
sEMU needs to use C program variables to represent the ISA’s PC, GPR, and memory, which is not difficult:
// sEMU.c
#include <stdint.h>
uint8_t PC = 0;
uint8_t R[4];
uint8_t M[256];
Among these, PC has an 8-bit width, meaning that sISA programs can contain at most 256 instructions. Therefore, the size of the array M used to implement memory only needs to be set to 256.
sEMU needs to use C language statements to implement the semantics of instructions, which is also not difficult. From the perspective of the instruction cycle, we need to write a function inst_cycle() to achieve the following functionalities:
- Fetch - Directly index memory
Mbased onPCto fetch an instruction. - Decode - Use C language bitwise operations to extract the
opcodefield of the instruction and check which instruction it belongs to; then extract the operand fields based on the instruction format and obtain the corresponding operands. - Execute - If the executed instruction is not
bner0, write the result back to the destination register; otherwise, decide whether to jump based on the condition. - Update PC - If not jumping, increment
PCby 1.
After implementing inst_cycle(), we just need to keep calling it in sEMU:
while (1) { inst_cycle(); }
The above has considered all the details of sISA, but to run a program in sEMU, we also need to consider how to put the program into M. However, since sEMU only intends to run the summation program, we can directly initialize M:
uint8_t M[16] = { ... };
Additionally, since sEMU currently has no device abstraction, it cannot correctly run programs containing the io instruction. We can choose not to implement the specific behavior of the io instruction — if an io instruction needs to be executed, simply updating PC is sufficient. However, since we only intend to run the summation program for now, we don't need to worry about running other programs.
Implement sEMU
Based on the above ideas, implement sEMU in C code and run the previous summation program. Since the summation program itself will not end, you can modify the loop condition in the while statement to exit after a certain number of iterations, and then check if the summation result meets expectations.
Compare sEMU and sCPU
For sISA, you have implemented both sEMU and sCPU. Try comparing them and see what differences there are.
You may have noticed that, even when implementing the same sISA instruction set, developing a simulator in C is much simpler than designing a processor in RTL. We only need to consider how to implement the behavior of each instruction in C, without worrying about its circuit implementation or wiring details. In fact, implementing a real processor requires considering many additional factors, including timing, area, and power consumption. You will gain a deeper understanding of these aspects in later stages. However, from a learning perspective, precisely because implementing a simulator does not require us to consider these factors, it provides an excellent way to understand instruction sets and program behavior. In industrial processor design flows, simulators also play an important role in functional verification, program analysis, and debugging. More detailed architectural simulators are also an important component of processor design-space exploration. As you continue learning, you will gradually develop a deeper understanding of the value and significance of simulators.
Let sCPU and sEMU run DiffTest
With sEMU as the REF, we can consider running DiffTest between sCPU and sEMU. Specifically, during simulation, after sCPU executes one instruction, immediately let sEMU also execute one instruction. Then, obtain the GPR values of both sCPU and sEMU and check whether they are the same. If the GPR values differ, output an error message and stop the simulation.
// The following is pseudocode
while (???) {
dut_single_cycle();
ref_inst_cycle();
uint8_t *dut_regs = &top->rootp->NPC__DOT__gpr_ext__DOT__Memory;
uint8_t *ref_regs = ref_get_regs();
int is_diff = check_regs(dut_regs, ref_regs);
if (is_diff) {
printf("GPR different\n");
printf("Simulation stop\n");
break;
}
}
To implement the functionality described above, you need to:
- Remove the main() function from sEMU and change the source file extension from .c to .cpp. This is to facilitate linking sEMU into sCPU's simulation environment.
- Add
sEMU.cppto theverilatorcommand's file list. - After understanding the pseudocode above, rewrite the
whileloop of the simulation process and implement the missing functions.
- Note that,
&top->rootp->NPC__DOT__gpr_ext__DOT__Memoryin the pseudocode is used to access GPR through the C++ files compiled by Verilator. The specific C++ variable names are related to the module names and variable names in Verilog, which you can find by reading the compiled C++ header files. However, C++ variable names may change when modifying RTL code or switching Verilator versions, so they need to be manually synchronized.
Running DiffTest Between sCPU and sEMU
Follow the above steps to add the DiffTest mechanism to sCPU's simulation environment. After adding it, try injecting some errors into sCPU and observe whether DiffTest reports errors as expected.
Improving Error Messages
The error messages provided above are very vague and not conducive to problem diagnosis. Try modifying the code to output the PC value at the time of the error, as well as the GPR whose stored value differs.
DiffTest the PC Value
Since the above code only checks whether GPR values are the same, but the bner0 instruction only modifies PC, when the branch target of the bner0 instruction is incorrect, the above code may fail to detect the error in time.
Try modifying the code to include PC in the comparison. Then try injecting some errors into the implementation of the bner0 instruction, and see if the DiffTest can detect them promptly.
Congratulations, you have essentially completed a simple CPU’s full design process!
New Requirements -> Architecture Design -> Logic Design -> Functional Verification -> Circuit Evaluation
- The requirement for the sCPU design is to implement an sISA processor with RTL.
- The lecture content from phase F has already helped everyone to understand how to implement sCPU through digital circuit functionality, which essentially completes the architecture design, the output of which is the circuit schematic in Logisim.
- The process of designing sCPU with RTL is the logic design process.
- Verifying whether your RTL code can successfully run
1+2+...+10using Verilator is the functional verification; while running DiffTest between sCPU and sEMU performs functional verification at a finer-grained, instruction-by-instruction level. - As for the circuit evaluation step, it has not been covered yet. We will introduce it at the end of Stage E.
Of course, the functionality of sCPU is still far from what we want to achieve in a CPU, so we do not currently require everyone to focus on the quality of the sCPU implementation. From the project process perspective, we need to first design a relatively complete CPU before considering how to optimize it, which also adheres to the principle of “complete first, perfect later.” Subsequent lecture content will follow this process, guiding everyone on how to design more powerful processors to run more complex programs.
zhr
