E7 Simple Bus and SoC

You've designed processor NPC, and you understand how devices work. However, previously we let the simulation environment provide the behavioral models of devices. In real hardware, device controllers are RTL modules, and the processor should communicate with them through a purely hardware-based mechanism. This mechanism is called a bus.

Basic Concepts of Bus

The essence of a bus is a communication protocol. It can exist in the communication between various modules. For example, when a processor accesses memory, there is also a communication protocol between them.

+-----+         +-----+
| CPU | <-----> | MEM |
+-----+         +-----+

Generally, the module that actively initiates communication is called the master, while the module that responds to the communication is called the slave. For example, in the above case, the processor is the master, and the memory is the slave.

In fact, bus design has two levels of meaning:

  • At the protocol level, the master and slave need to reach an agreement on "how they communicate with each other".
  • At the implementation level, when designing the interfaces between the master and slave, we need to use circuits to implement the details defined by the protocol.

System Bus

The bus that connects the processor with memory and devices is usually called the system bus. In fact, we previously used the DPI-C to simulate the functionality of a system bus. NPC accessed memory through the DPI-C and interfaces such as pmem_read() provided by the simulation environment. In this process, there was no read latency: the read data could be returned in the same cycle when the read request was received. However, this is only a convenient method for implementing communication in NPC. In reality, such a memory device does not exist.

Compared with the DPI-C, the biggest difference in memory access inside a real chip is that the access process has latency. In other words, after the processor issues a read request, it needs to wait for several cycles before receiving the data returned by the memory. This requires the processor to implement additional mechanisms:

  1. The processor needs to determine when the data returned by the memory becomes available.
  2. Before the memory response arrives, the processor must wait. The processor can only continue executing the current instruction after receiving the returned data. This means that the processor can no longer maintain the property of executing one instruction per cycle.
  3. Furthermore, instruction fetching does not need to happen every cycle. For example, while waiting for a memory response, the processor should not perform instruction fetching. Therefore, the processor needs to notify the memory when an actual instruction fetch operation should occur.

However, real chips usually use industrial bus protocols similar to AXI, whose details are very complicated. For example, the AXI bus protocol contains more than 30 signals in total. This means that, in principle, if we want a real chip to communicate with devices, we need to implement these bus protocols inside the processor. To reduce the implementation burden, we will first introduce a simple custom bus protocol called SimpleBus.

Before implementing the bus, we will first briefly discuss processor performance evaluation.

Measure the IPC of the processor

IPC (Instruction Per Cycle) represents the average number of instructions executed by a processor in each cycle. It reflects the processor's instruction execution capability and is an important metric for evaluating processor performance.

To measure IPC, you first need to measure:

  • the number of clock cycles taken when the processor runs a program;
  • the number of instructions executed during the program execution.

After obtaining these two values, simply divide the number of executed instructions by the number of cycles. For simplicity, you can use the hello program for the measurement.

Currently, the IPC should be 1. A processor that executes one instruction every cycle is called a single-cycle processor. You will measure the IPC again after implementing the bus.

Accessing Read-Only Memory

Since reading data from memory is the most basic requirement, we will first consider how the processor performs read operations through the system bus.

Assume that the processor has a fixed specification of N*32, meaning that the memory contains N addressable 32-bit locations. Also assume that the read latency of this memory is fixed at 1 cycle. This is essentially a synchronous memory, where the delay between receiving a read request and returning the data is fixed. This behavior is consistent with the access characteristics of SRAM.

If we do not consider write operations, we only need a ROM (Read-Only Memory). To read data from the ROM, the corresponding bus only needs two signals: address and data. Their widths are log2(N) and 32, respectively.

+-----+ raddr[log2(N)-1:0] ---> +-----+
| CPU | <---        rdata[31:0] | MEM |
+-----+                         +-----+

The communication protocol is:

  • The master (CPU) sends the read address raddr to the slave (MEM).
  • In the next cycle, the slave returns the data rdata to the master.
  • The above behavior occurs every cycle.

Since instructions are stored in memory, IFU also needs to access memory during instruction fetch. At the same time, instruction fetching does not write data into memory, so a read-only memory is sufficient for the IFU to operate. To simulate the delay introduced during memory access, after receiving an instruction fetch request, the memory cannot immediately return the fetched instruction. Instead, it must wait for one cycle before returning the instruction data. According to the discussion above, for IFU, the SimpleBus involves the following signals:

output [31:0] ifu_raddr,
input  [31:0] ifu_rdata,

The timing of these signals is shown in the following diagram:

           --\ /----------\ /----------\ /----------\ /----------\ /--
 ifu_raddr    X 0x80000000 X     (1)    X 0x80000004 X            X
           --/ \----------/ \----------/ \----------/ \----------/ \--
           --\ /----------\ /----------\ /----------\ /----------\ /--
 ifu_rdata    X            X 0x00000413 X     (2)    X 0x80051137 X
           --/ \----------/ \----------/ \----------/ \----------/ \--

To allow NPC to "wait for instructions returned from memory", we first need to let the IFU know which stage of the instruction fetch process it is currently in, and take different actions in different stages. This kind of behavior, where different actions are performed at different times, can be implemented using a finite state machine (FSM) in digital circuits. Specifically, we can implement two states for the IFU:

  • idle state: Set ifu_raddr to pc.Then transition to the wait state.
  • wait state: Use ifu_rdata as the valid instruction and continue execution.Then transition back to the idle state.

According to the bus protocol described above, since communication occurs every cycle, the memory returns the data corresponding to the previous cycle's request while receiving the address request in the current cycle. For example, in the figure above, when NPC sends a fetch request for address 0x80000004, the memory simultaneously returns the data marked as (2). This data corresponds to the address request marked as (1) in the previous cycle. If NPC treats (2) as a valid instruction and executes it immediately, it will either execute the instruction at address 0x80000000 again or execute an instruction from another address. In either case, the behavior of NPC will no longer conform to the ISA specification. Therefore, in the idle state, NPC should not treat ifu_rdata as a valid instruction. Instead, it should not execute any instruction.

Previously, we assumed that NPC could execute one instruction every cycle. However, after introducing SimpleBus, NPC does not have a valid instruction to execute while waiting for the instruction response. Therefore, we need to consider the question:"How can we make NPC stop executing instructions?" Recall the state machine model of computer systems: the process of instruction execution is essentially the process of changing the processor state. In other words, if we can keep the processor state unchanged, we can achieve the effect of "not executing an instruction". You should be able to think of the solution: the state of a processor is stored in sequential logic elements. As long as their write enable signals are disabled, the values of these sequential logic elements will not change. Therefore, you also need to correctly configure the write enable signals of various sequential logic elements in NPC under the appropriate states.

Support SimpleBus in IFU

According to the discussion above, make the IFU support the SimpleBus protocol. For the instruction fetch part of the memory, you can refer to the following code:

always @(posedge clock) begin
  ifu_rdata <= pmem_read(ifu_raddr);
end

For the data access part of the LSU, no modification is required for now. We will make it support SimpleBus later.

After implementation, try running some test programs. At the same time, inspect the waveform to confirm that the communication process between NPC and memory matches the expected behavior. In principle, the bus protocol should be transparent to upper-level programs. Therefore, programs that could run successfully before should continue to run successfully after implementing SimpleBus.

However, since the memory now requires one cycle to return the instruction data, NPC is no longer a strictly single-cycle processor. Instead, it becomes a simple multi-cycle processor:

  1. In the first cycle, the IFU issues an instruction fetch request.
  2. In the second cycle, the IFU receives the instruction and passes it to the following modules for decoding and execution.

Adapt DiffTest for a Multi-Cycle Processor

After converting NPC into a multi-cycle processor, NPC no longer executes one instruction every cycle. To make the DiffTest mechanism work correctly, you need to slightly adjust the timing of the checking process: DiffTest should only be performed when an instruction has finished execution. To achieve this, you may need to read some status signals from the RTL through DPI-C to help determine when DiffTest should be triggered.

Measure Processor Performance

After making the IFU support SimpleBus, complete the following tasks:

  1. Re-measure the IPC of the hello program.
  2. Evaluate the processor's performance using archbench.

Accessing Readable and Writable Memory

Since the LSU needs to execute store instructions, we need to add new signals to the bus to support write operations:

  • First, we naturally need a write address waddr and write data wdata.

  • Since write operations do not occur every cycle, we also need to add a write enable signal wen.

    • Although read operations also do not occur every cycle (for example, in the multi-cycle NPC described above, no instruction fetch request is sent in the wait state), read enable is theoretically not required because read operations do not change the state of the circuit.
    • However, in practice, a read enable signal ren is usually still included. If there is no read request, the memory does not need to perform a read operation, which can reduce power consumption.
  • A write operation may only modify some bytes within a word (for example, the sb instruction only writes 1 byte). Therefore, we also need to add a write mask signal wmask, which specifies which bytes in the write data should be written.

+-----+ addr[log2(N)-1:0]  ---> +-----+
|     | wen                ---> |     |
| CPU | wdata[31:0]        ---> | MEM |
|     | wmask[3:0]         ---> |     |
|     | <---        rdata[31:0] |     |
+-----+                         +-----+

Since the LSU will not execute load and store instructions at the same time, raddr and waddr will never be used simultaneously. Therefore, we can merge raddr and waddr into a single signal addr.

At the same time, the communication protocol needs to define the behavior of write operations. We use pseudocode to describe it:

if (wen) {
  // wmask_full is the bit-expanded version of wmask
  M[waddr] = (wdata & wmask_full) | M[waddr] & ~wmask_full;
}

Similar to the IFU, we will also make the LSU access memory according to the SimpleBus protocol. For the LSU, since store instructions need to write data into memory, the SimpleBus involves the following signals:

output [31:0] lsu_addr,
output        lsu_wen,
output [31:0] lsu_wdata,
output [ 3:0] lsu_wmask,
input  [31:0] lsu_rdata,

For read operations, wdata and wmask can take arbitrary values. The timing of the related signals is shown in the following figure:

           --\ /----------\ /------------------------
 lsu_addr     X 0x80400000 X
           --/ \----------/ \------------------------
           ---+            +-------------------------
 lsu_wen      |            |
              +------------+
           ------------------------------------------
 lsu_wdata
           ------------------------------------------
           ------------------------------------------
 lsu_wmask
           ------------------------------------------
           ---------------\ /----------\ /-----------
 lsu_rdata                 X 0x12345678 X
           ---------------/ \----------/ \-----------

For write operations, rdata is a don't-care signal. The timing of the related signals is shown in the following figure:

           --\ /----------\ /------------------------
 lsu_addr     X 0x80400000 X
           --/ \----------/ \------------------------
              +------------+
 lsu_wen      |            |
           ---+            +-------------------------
           --\ /----------\ /------------------------
 lsu_wdata    X 0x12345678 X
           --/ \----------/ \------------------------
           --\ /----------\ /------------------------
 lsu_wmask    X     0xf    X
           --/ \----------/ \------------------------
           ------------------------------------------
 lsu_rdata
           ------------------------------------------

Support SimpleBus in LSU

According to the discussion above, make the LSU support the SimpleBus protocol.

For the data access part of the memory, you can refer to the following code:

always @(posedge clock) begin
  lsu_rdata <= (!lsu_wen) ? pmem_read(lsu_addr) : 32'b0;
  if (lsu_wen) begin
    pmem_write(lsu_addr, lsu_wdata, lsu_wmask);
  end
end

At this point, you can keep the device access functionality inside pmem_read()/pmem_write(). We will introduce how to access peripherals through the bus in later lectures.

After making the LSU support SimpleBus, a load instruction requires 3 cycles for NPC to complete:

  1. In the first cycle, the IFU issues an instruction fetch request.
  2. In the second cycle, the IFU receives the instruction and passes it to the following modules for decoding. After discovering that it is a load instruction, it issues a memory access request through the LSU.
  3. In the third cycle, the LSU receives the data and passes it to the WBU to write back to the register.

Therefore, you also need to modify the IFU so that it fetches the next instruction only after the load instruction has finished execution. After implementation, try running some test programs. At the same time, inspect the waveform to confirm that the communication process between NPC and memory matches the expected behavior. Similarly, programs that could run successfully before should continue to run successfully after implementing SimpleBus.

Measure Processor Performance(2)

After making the LSU support SimpleBus, complete the following tasks:

  1. Re-measure the IPC of the hello program.
  2. Evaluate the processor's performance using archbench.

More General Memory

In fact, the property of a fixed one-cycle read latency can usually only be satisfied by SRAM. This is because SRAM can be manufactured using the same process technology as the processor. However, SRAM is very expensive. To build lower-cost memory, other technologies with higher storage density are usually used, such as DRAM. Due to their electrical characteristics, the read latency of these memories is usually longer than the processor's one-cycle latency.

In this situation, the processor cannot continuously send read requests. Otherwise, because the request rate of the processor is higher than the service rate of the memory, the memory will be constantly occupied by unnecessary requests, significantly reducing the efficiency of the entire system. To solve this problem, the processor needs to tell the memory when a request is valid. Therefore, we can add a new signal reqValid to the signals sent from the processor to the memory. The memory can use this signal to determine when a real request exists. When the processor needs to access memory, it sets addr and other related signals, and sets reqValid to valid. When the processor does not need to access memory, it sets reqValid to invalid. For the memory: When reqValid is valid, it needs to access the data corresponding to addr. When reqValid is invalid, the memory does not perform any access operation.

On the other hand, when the memory can return data cannot be determined in advance. For example, DRAM periodically refreshes the capacitors in its storage cells. If a read request arrives during the refresh process, the actual data read operation will only occur after the refresh is completed. Therefore, the memory also needs to tell the processor when valid data can be returned. Similarly, to identify when the memory response arrives, we can add a new signal respValid to the signals received by the processor from the memory. The processor can use this signal to determine when the memory response is valid.

Taking a read operation as an example: When the memory has read out the data, it sets rdata and asserts respValid. When the memory has not finished reading the data, it deasserts respValid.

For the processor: When respValid is valid, it considers rdata to contain valid data. When respValid is invalid, the processor considers that the data has not returned yet and continues waiting.

+-----+ reqValid           ---> +-----+
|     | addr[log2(N)-1:0]  ---> |     |
|     | wen                ---> |     |
| CPU | wdata[31:0]        ---> | MEM |
|     | wmask[3:0]         ---> |     |
|     | <---          respValid |     |
|     | <---        rdata[31:0] |     |
+-----+                         +-----+

Taking the LSU as an example, the extended SimpleBus involves the following signals:

output        lsu_reqValid,
output [31:0] lsu_addr,
output        lsu_wen,
output [31:0] lsu_wdata,
output [ 3:0] lsu_wmask,
input         lsu_respValid,
input  [31:0] lsu_rdata,

For read operations, wdata and wmask can take arbitrary values. The timing of the related signals is shown in the following figure:

                  +------------+
 lsu_reqValid     |            |
               ---+            +-------------------------------------------------
               --\ /----------\ /------------------------------------------------
 lsu_addr         X 0x80400000 X
               --/ \----------/ \------------------------------------------------
               --\              /------------------------------------------------
 lsu_wen          X            X
               --/ ------------ \------------------------------------------------
               ------------------------------------------------------------------
 lsu_wdata
               ------------------------------------------------------------------
               ------------------------------------------------------------------
 lsu_wmask
               ------------------------------------------------------------------
                                                       +------------+
 lsu_respValid                                         |            |
               ----------------------------------------+            +------------
               ---------------------------------------\ /----------\ /-----------
 lsu_rdata                                             X 0x12345678 X
               ---------------------------------------/ \----------/ \-----------

For write operations, although rdata is a don't-care signal, SimpleBus still uses respValid to indicate that the write operation has completed. The timing of the related signals is shown in the following figure:

                  +------------+
 lsu_reqValid     |            |
               ---+            +-------------------------------------------------
               --\ /----------\ /------------------------------------------------
 lsu_addr         X 0x80400000 X
               --/ \----------/ \------------------------------------------------
               --\ ------------ /------------------------------------------------
 lsu_wen          X            X
               --/              \------------------------------------------------
               --\ /----------\ /------------------------------------------------
 lsu_wdata        X 0x12345678 X
               --/ \----------/ \------------------------------------------------
               --\ /----------\ /------------------------------------------------
 lsu_wmask        X     0xf    X
               --/ \----------/ \------------------------------------------------
                                                       +------------+
 lsu_respValid                                         |            |
               ----------------------------------------+            +------------
               ------------------------------------------------------------------
 lsu_rdata
               ------------------------------------------------------------------

After adding valid signals to SimpleBus, we also need to extend the state machine:

  • In the idle state: If a memory access is currently required, set reqValid, addr, and other related signals, then transition to the wait state. If no memory access is required, remain in the idle state.

  • In the wait state: If respValid is valid, use rdata as the returned data and continue execution, then transition back to the idle state. If respValid is invalid, remain in the wait state to wait for the response.

  • The above process describes a read operation. The process for a write operation is similar.

Support the SimpleBus protocol with valid signals

According to the discussion above, make the IFU and LSU access memory according to the SimpleBus protocol with valid signals. For the data access part of the memory, you can refer to the following code:

always @(posedge clock) begin
  lsu_rdata <= (lsu_reqValid && !lsu_wen) ? pmem_read(lsu_addr) : 32'b0;
  if (lsu_reqValid && lsu_wen) begin
    pmem_write(lsu_addr, lsu_wdata, lsu_wmask);
  end
  lsu_respValid <= lsu_reqValid;
end

The instruction fetch part of the memory can be modified in a similar way.

After implementation, try running some test programs. At the same time, inspect the waveform to confirm that the communication process between NPC and memory matches the expected behavior. Similarly, programs that could run successfully before should continue to run successfully after extending SimpleBus.

Measure Processor Performance(3)

After implementing the SimpleBus protocol with valid signals, complete the following tasks:

  1. Re-measure the IPC of the hello program.
  2. Evaluate the processor's performance using archbench.

Test the SimpleBus implementation

Add random delay functionality to the memory to test whether the bus implementation can work correctly under arbitrary latency.

You can add memory access delays gradually from simple to complex:

  1. Change the memory access latency to fixed values such as 5, 10, and 20 cycles.
  2. Add an LFSR in the memory module and use the generated pseudo-random numbers to determine the delay of the current request.
  3. Add LFSRs in the IFU and LSU as well, and use them to determine the delay of the corresponding valid signals.

If NPC can still execute programs correctly under random delays generated by multiple LFSRs, it will greatly increase your confidence in the correctness of your implementation.

Integrating with SoC

You have already implemented the SimpleBus bus protocol in NPC, but the functionality of devices is still provided by the behavioral models in the simulation environment.

With SimpleBus, we can connect NPC to an SoC and communicate with real device controllers.

Obtain the ysyxSoC source code

You need to clone the ysyxSoCopen in new window project:

cd ysyx-workbench
bash init.sh ysyxSoC

Note that ysyxSoC is still somewhat different from the final SoC used for tape-out. Therefore, passing the tests in ysyxSoC does not mean that the design will also pass the tests in the final tape-out SoC simulation environment. However, even so, the ysyxSoC project can help expose some potential issues in advance.

The ysyxSoC project contains many details. You will learn more about SoC-related topics in later studies. For now, we have prepared a simplified version of the SoC code. The architecture diagram of this SoC is shown below (some devices are omitted):

                                                        +------+
+-------+                                           +-> | UART |
| +---+ |     +--------+                            |   +------+
| |IFU| | <-> |        |     +-----+     +------+   |
| +---+ |     | Memory |     | AXI |     | APB  | <-+   +-----+     +-------+
|  CPU  |     |        | <-> | to  | <-> |      | <---> | SPI | <-> | Flash |
| +---+ |     | Bridge | AXI | APB | APB | XBar | <-+   +-----+     +-------+
| |LSU| | <-> |        |     +-----+     +------+   |
| +---+ |     +--------+                            |   +-------+
+-------+                                           +-> | PSRAM |
       SimpleBus                                        +-------+

This simplified SoC code also contains a bridge module that converts SimpleBus into AXI.

With this bridge, NPC can be connected to an SoC with an AXI interface and communicate with various devices.

At this stage, you do not need to understand the details of AXI, APB, or the various devices.

The current SoC contains the following key devices (some devices are omitted):

DeviceAddress Space
UART165500x1000_0000 ~ 0x1000_0fff
SPI master0x1000_1000 ~ 0x1000_1fff
Flash0x3000_0000 ~ 0x3fff_ffff
PSRAM0x8000_0000 ~ 0x807f_ffff
ReservedOthers

Integrate with ysyxSoC

Follow the steps below to integrate NPC into ysyxSoC:

  1. Adjust the top-level interface of NPC so that it exactly matches the interface naming conventions specified in ysyxSoC/ready-to-run/minirv/cpu-interface.md, including signal directions, names, and data widths.

    • The io_lsu_size signal is used for communication with peripherals. It should be set by the LSU according to the data width of the memory access instruction:

      • Set it to 2'b00 for 1-byte accesses.
      • Set it to 2'b01 for 2-byte accesses.
      • Set it to 2'b10 for 4-byte accesses.
  2. Modify the reset value of the NPC PC register to 0x3000_0000, so that instruction fetching starts from Flash after reset.

  3. Add all .v and .sv files under the ysyxSoC/perip directory and its subdirectories to the Verilator Verilog file list.

  4. Add the following two directories to Verilator's include search path:

    • ysyxSoC/perip/uart16550/rtl

    • ysyxSoC/perip/spi/rtl

    • For the specific method, please RTFM (man verilator or the official Verilator manual).

      • If you have never checked the available Verilator options before, we recommend that you carefully read the argument summary section in the manual this time. You may discover some useful options that you have not encountered before.
  5. Add the following options to the Verilator compilation options:

    • --timescale "1ns/1ns"
    • --no-timing
    • -D__VERILOG__
    • -DPDK_BEHAV
    • -D__UART_TO_CONSOLE__
  6. Add ysyxSoC/ready-to-run/minirv/ElaborateTop.v to the Verilator Verilog file list.

  7. The SimTop module contains several ports, which need to be handled as follows in the simulation environment:

    • During reset, the reset signal must remain asserted for at least 100 cycles.

    • For the clock and cpuClock clock signals, the former is used to drive the SoC clock, while the latter is used to drive the NPC clock. For now, you should drive both with the same input, for example:

      void single_cycle() {
        top->clock = 0; top->cpuClock = 0; top->eval();
        top->clock = 1; top->cpuClock = 1; top->eval();
      }
      
    • Other port signals can be ignored for now.

  8. Set the SimTop module (defined in ysyxSoC/ready-to-run/minirv/ElaborateTop.v) as the top module for Verilator simulation.

  9. Modify the module name ysyx_00000000 in ysyxSoC/ready-to-run/minirv/ElaborateTop.v to the module name of your processor.

  10. Add the following code to the simulation C++ file to solve the linking error caused by the missing flash_read function:

extern "C" void flash_read(int32_t addr, int32_t *data) { assert(0); }
  1. Compile the simulation executable using Verilator.

    • If you encounter a combinational loop error, modify your RTL code accordingly.
  2. Start the simulation. You should observe that the code triggers the assert(0) error inside flash_read(). We will solve this problem next.

    • If this error is not triggered, please check your bus implementation.

Running the First Program on SoC

For ysyxSoC, we provide a hello program located at: ysyxSoC/ready-to-run/minirv/hello-minirv-ysyxsoc.bin

Run the hello program on ysyxSoC

To run this hello program, you need to let the simulation environment load the program into Flash memory. Specifically, you need to define a 16MB array in the simulation C++ file to model the Flash memory, and then implement the flash_read() function. According to the address addr, this function should return the corresponding data from the Flash memory. Then, load the .bin file that needs to be executed into the Flash memory array, so that NPC can fetch the first instruction of the program from Flash.

Use the above method to make NPC run the hello program. This program takes about 30 seconds to finish. If your implementation is correct, you will see the program output Hello and then enter an infinite loop.

The .bin file of the above hello program is provided directly by us. Next, you need to compile your own programs for ysyxSoC and run them.

In real computers, common memory devices are usually volatile memory, such as SRAM and DRAM. They do not contain valid data after power-on. If the CPU directly reads instructions from memory after power-on, the data returned by the memory is undefined. Therefore, the behavior of the entire system is also undefined, and the CPU cannot execute the expected program. Therefore, a type of non-volatile memory is needed to store the initial program. Its contents can be preserved after power loss, allowing the CPU to immediately fetch instructions from it after power-on. In ysyxSoC, the non-volatile memory is implemented using Flash, while the volatile memory is implemented using PSRAM. PSRAM is a special type of DRAM. After integrating with ysyxSoC, NPC fetches its first instruction from Flash at address 0x30000000 after reset. However, from the perspective of the processor, Flash cannot be directly written through memory access instructions. Therefore, programs usually need to be loaded from Flash into writable memory (such as PSRAM) before execution.

The hello program above already includes this process. In fact, the provided .bin file also contains a loader program. It is located at address 0x30000000. When NPC executes this .bin file, it first executes the loader. The loader's job is to load the actual hello program from Flash into PSRAM at address 0x80000000. Therefore, you will see output from the program through the serial port similar to:loading to memory region [0x80000000, 0x8004896c) After loading is complete, NPC jumps to the entry point of the hello program, which is 0x80000000, and then fetches and executes instructions from the hello program.

Since the loader runs in Flash, and Flash cannot be written through memory access instructions by NPC, additional processing is required when linking the loader. To reduce the implementation burden, we currently do not require you to implement the loader. Instead, we reuse the loader contained in the provided .bin file. We provide a script located at:ysyxSoC/ready-to-run/minirv/gen.sh, which can combine other programs with this loader to generate a new .bin file. In this way, you can load and run other programs on ysyxSoC.

To use this script, you need to create a file named minirv-ysyxsoc.mk in the abstract-machine/scripts/ directory with the following contents:

include $(AM_HOME)/scripts/minirv-npc.mk
image: image-dep
	cd $(AM_HOME)/../ysyxSoC/ready-to-run/minirv && bash gen.sh $(IMAGE).elf $(IMAGE).bin

Then, use the following command to make a copy of klib for minirv-ysyxsoc:

cd abstract-machine/klib/build
cp klib-minirv-npc.a klib-minirv-ysyxsoc.a

After that, you can compile the program using ARCH=minirv-ysyxsoc and run it on ysyxSoC.

Run a self-compiled program on SoC

According to the description above, try running the dummy program on minirv-ysyxsoc.

Since the dummy program does not produce any output, after the loader jumps to the dummy program, it will output the message: HIT GOOD TRAP

Run self-compiled programs on SoC (2)

Try running cpu-tests and riscv-tests on minirv-ysyxsoc.

Accessing a Real UART Controller

Since we have integrated ysyxSoC, which contains a real UART controller, UART16550, some details of this UART controller are different from the simple UART behavioral model we implemented before. As a result, the previously written putch() function cannot correctly access the real UART16550. This means that if we want to compile and run our own programs that can output characters, we need to further understand the details of UART16550 and modify putch() to support it.

In real chips, before a program outputs characters through a serial port, it needs to initialize the serial port first. Specifically, the initialization process needs to configure the transmission parameters of the serial port, including: baud rate; character length; whether a parity bit is used; stop bit width. Among these parameters, the baud rate refers to the number of characters transmitted per second. The transmitter and receiver must use exactly the same parameter configuration in order to correctly send and receive characters. A set of serial port parameters is usually described in a format such as 115200 8N1. This means: the baud rate is 115200; the character length is 8 bits; no parity bit is used; the stop bit width is 1 bit.

The data transmission process of 8N1 is shown below:

                                      stop ---+
                                              V
--------+   +---+---+---+---+---+---+---+---+---+--------
  idle  |   | D0| D1| D2| D3| D4| D5| D6| D7|      idle
        +---+---+---+---+---+---+---+---+---+    
          ^
 start ---+

During idle periods, the serial line remains at a high level. When a character needs to be transmitted, the UART first sends a start bit of 1-bit low level, then sends each bit of the character in order (the D0~D7 bits shown in the figure), and finally sends a stop bit of 1-bit high level. The serial port can also be configured as 9600 7E2, which means: the baud rate is 9600; the character length is 7 bits; one even parity bit is used; two stop bits are used. If a parity bit is enabled, it is placed between the character bits and the stop bits.

Due to electrical characteristics, in environments with long transmission distances and strong noise interference, the probability of successful character transmission decreases. To improve the robustness of transmission against interference, UART protocols usually use oversampling techniques. For example, when using 16× oversampling to detect the start bit, the signal needs to be sampled 16 consecutive times. Only when 8 or more consecutive samples are detected as low level will the UART consider the start bit to have been successfully detected.

The baud rate also affects the probability of successful character transmission. A higher baud rate means more characters can be transmitted per unit time. The transmission time of each character becomes shorter, and the sampling window for each character also becomes shorter, resulting in a higher bit error rate. Conversely, a lower baud rate reduces the bit error rate, but software needs to wait longer when transmitting each character.

Since the UART controller cannot predict its future operating frequency, the baud rate is usually configured by software according to the actual operating frequency. There is a ratio between the operating frequency and the baud rate, which indicates how many clock cycles are required to transmit one character. Assume that the UART controller operates at 50 MHz: If the baud rate is set to 115200, one character occupies:50*1000000/115200 = 434 cycles. If the baud rate is set to 9600, one character occupies:50*1000000/9600 = 5208 cycles.

Considering that UART uses oversampling, the UART controller needs to determine the sampling clock frequency. This sampling clock is usually generated by dividing the UART controller's clock. Therefore, software needs to configure the clock division factor (or divisor) to indirectly set the baud rate. For example, when the UART controller operates at 50 MHz and uses 16× oversampling, setting the baud rate to 115200 requires configuring the divisor register as:50*1000000/115200/16 = 27.13. This means that sampling occurs approximately every 27.13 clock cycles. You can RTFM the UART documentation for more details about the relationship between the divisor and baud rate. The UART manual is located at:ysyxSoC/perip/uart16550/doc/UART_spec.pdf

However, in hardware implementations, the divisor register usually only supports integer values. For example, in the above case, the value written into the divisor register is usually 27. The actual sampling interval then becomes 27/50MHz = 540ns. When the transmitter and receiver operate at different frequencies, this may introduce some transmission errors. In such cases, changing the baud rate may help eliminate these errors.

Calculate the UART divisor

Assume that the transmitter UART uses the configuration described above. The receiver UART controller operates at 25 MHz and uses 16× oversampling. What value should be configured in the receiver's divisor register? How often does the receiver actually sample the signal? Do the two sides have transmission errors under this configuration? What happens if the baud rate is set to 57600 instead?

Correctly implement UART initialization and transmission

Assume that the UART controller operates at 25 MHz, with a target baud rate of 115200. You need to modify the code in abstract-machine/am/src/riscv/npc/trm.c according to this configuration to implement the following functionality:

  1. Before calling main() in trm_init(), configure the divisor register of the UART.
  2. Modify the implementation of putch(). Before outputting a character, first check the status of the UART transmit FIFO. Only when the transmit FIFO is not full should the character be written into the transmit FIFO.

For details on how to configure the divisor, how to check the transmit FIFO status, and how to write characters into the transmit FIFO, you can refer to the UART documentation (RTFM). You can also refer to the RTL implementation of the UART16550 registers (RTFSC) to better understand the related functionality.

After implementation, try running your self-compiled hello program on minirv-ysyxsoc. You should find that the hello program can correctly output all characters.

Integrating with NVBoard

The asicTop module in ysyxSoC defines the top-level module of the chip. In a real chip, the chip's pins need to be connected to other devices through a circuit board. Although you do not have a real chip yet, we can use the previously introduced NVBoard to help you understand this process.

UART

We have already tested serial output with the help of the UART16550 controller. However, previously the transmitter side only used the $write system task inside the UART16550 controller implementation to output characters. It did not involve the process of encoding characters and transmitting them serially through a cable to a receiver. NVBoard integrates a serial terminal. With NVBoard, we can experience this complete process.

The UART terminal in NVBoard is very simple. It only supports the 8N1 serial transmission configuration. As for the baud rate, since NVBoard does not have the concept of a clock frequency, it uses a divisor-based description instead. That is, the divisor specifies how many cycles are used between sampling each bit. Since NVBoard is a software simulation environment, it does not involve bit errors caused by electrical characteristics. Therefore, unlike a real UART, it does not use oversampling techniques. The divisor in NVBoard cannot be configured at runtime, but it can be adjusted by modifying the source code. You can modify it in the UART constructor in nvboard/src/uart.cpp. There are two possible ways:

  1. Modify the initial value of the divisor member.
  2. Call the set_divisor() function to configure it.

Connect the UART TX pin to NVBoard

You only need to modify the NVBoard constraint file to bind the UART TX pin to the UART terminal in NVBoard. For how to perform the binding, you can refer to the examples provided by NVBoard. Since the UART controller has already been integrated into ysyxSoC, you do not need to modify the RTL code again.

You have already configured the UART divisor register in trm_init() according to the 25 MHz operating frequency and 115200 baud rate. Therefore, you also need to configure the UART divisor in NVBoard accordingly. Note that the divisor in NVBoard is not exactly the same as the divisor configured in the UART16550 divisor register. There is a certain relationship between them, which you need to figure out through RTFSC or RTFM. This is also a test of whether you understand how UART works.

Then run the hello program again. You will see that the serial output not only appears in the command-line terminal, but also appears in the UART terminal at the top-right corner of NVBoard.

Hint: If you find that the loading to memory region message only appears in the command-line terminal but not in the serial terminal in the upper-right corner of NVBoard, this is because the loader has already configured the UART divisor according to the 25 MHz operating frequency and 115200 baud rate, while the UART divisor configured in NVBoard does not match this setting. As a result, NVBoard cannot correctly decode the characters output by the loader.

To fix this issue, recheck the divisor register in trm_init() and the UART divisor configured in NVBoard, and make sure their values match the 25 MHz operating frequency and 115200 baud rate configuration.

GPIO

NVBoard also provides devices such as LEDs and DIP switches, which we encountered in the F stage. Now we can make use of them as well. Since the state of these devices can be transmitted through a single signal line, their functionality is relatively simple. Generally, they can be connected through general-purpose pins on the chip. These pins are called GPIOs. Through GPIOs, a chip can directly output internal signals to the outside of the chip, for example, to drive simple devices such as LEDs on a development board. The chip can also use GPIOs to obtain simple external states, such as the status of DIP switches and buttons on the board.

ysyxSoC integrates a GPIO controller with an APB bus interface and maps it to the CPU address space 0x2000_1000 ~ 0x2000_100f. We allocate only 16 bytes of address space for the GPIO controller, which supports up to 128 pins. This is usually sufficient for typical applications. Considering the peripherals provided by NVBoard, the devices suitable for GPIO include 16 LEDs, 16 DIP switches, and 8 seven-segment displays. Therefore, we allocate the register space of the GPIO controller as follows:

AddressFunction
0x016-bit data, each bit drives one of the 16 LEDs
0x416-bit data, each bit represents the state of one of the 16 DIP switches
0x832-bit data, each 4 bits drive one seven-segment display
0xcReserved

ysyxSoC does not provide the internal implementation of the GPIO controller. We leave the implementation of this controller as an assignment. However, to implement the GPIO controller, you first need to understand the APBopen in new window bus protocol. The APB bus is very similar to the SimpleBus protocol introduced above, but the communication details are different. You can learn these details by reading the relevant documentation.

Implement a running LED pattern through a program

You need to complete the following tasks:

  1. Implement the register used to drive LEDs in the GPIO controller. Specifically: If you choose Verilog, implement the corresponding code in ysyxSoC/perip/gpio/mygpio_top_apb.v. If you choose Chisel, you can paste the Verilog code generated by Chisel into mygpio_top_apb.v.

  2. Connect NVBoard and bind the GPIO output pins in the top-level module SimTop to the LEDs.

  3. Write a test program that periodically writes data to the register mentioned above to create a running LED effect.

    • Note that although the LED-driving register is only 16 bits wide, since the lh instruction in minirv will be expanded into multiple lb instructions, we still recommend using a 32-bit data pointer to read this register.

Implement a password lock

Similar to the LED example, make the program read the state of the DIP switches. You can define a 16-bit password in the program. When the program starts, it continuously checks the state of the DIP switches. The program should continue execution only when the DIP switch state matches the predefined password.

Display the registration number on seven-segment displays through a program

Convert your registration number into 8 hexadecimal digits, and use them to drive the 8 seven-segment displays. For example, if your registration number is 123456789, converting it into hexadecimal text 0x75bcd15. You can configure the seven-segment displays to show this hexadecimal number.

You may also choose to display other numbers, such as your birthday.

Adding Simple Control and Status Registers

In RISC-V, there is a class of system registers used to describe the processor state, called Control and Status Registers (CSR). Through CSRs, we can add custom information to the hardware and allow programs to read this information during execution.

Unlike general-purpose registers, ordinary computational instructions cannot directly access CSRs. Therefore, RISC-V provides CSR instructions for exchanging data between CSRs and general-purpose registers. Unlike ordinary instructions, CSR instructions atomically read and write the same CSR.

CSR instructions use the I-type instruction format, so the CSR address space is 12 bits wide, containing 4096 possible addresses. You can refer to the RISC-V Privileged Architecture Manualopen in new window to learn more about CSR details. However, we do not need to implement all CSRs. We only need to implement the CSRs that are required and perform read/write operations according to their CSR addresses. Therefore, you do not need to understand every CSR described in the manual. You only need to refer to the relevant parts when needed.

Student ID CSR

To identify different students' NPC implementations, we can use identifier registers in CSR. Specifically, RISC-V defines two CSRs, mvendorid and marchid, which can be used to store identification information. Later, programs can use CSR instructions to read these identification values into general-purpose registers and print them.

Add student ID CSRs and output them

Specifically, you need to implement the csrrs instruction in NPC and add the following two CSRs:

  • mvendorid - Reading this CSR should return the ASCII code of ysyx, which is 0x79737978.

  • marchid - Reading this CSR should return the decimal representation of the numeric part of the student ID.

    For example, assuming your student ID is ysyx_22068888, interpreting the returned value as an integer should give 22068888, which is 0x150be98. However, the "One Student One Chip" student ID can only be obtained after the admission defense. At this stage, you can use your application number or another custom ID, such as your birthday. After obtaining your official "One Student One Chip" ID, you can modify it later.

You need to read the RISC-V Privileged Architecture Manual and find the CSR numbers and other information for these two CSRs.

After implementation, use inline assembly in a program to read the values of these two CSRs, and then print them using printf(). In particular, you can also display the student ID on the seven-segment displays of NVBoard.

Hint: You can use the following inline assembly:

asm volatile ("csrr %0, mvendorid" : "=r"(val));

to read the value of mvendorid into the C variable val. Here, csrr is a pseudo-instruction. When generating the instruction sequence, the compiler expands it into the csrrs instruction that you implemented.

Cycle Count CSR

Previously, we implemented clock functionality using a behavioral model. However, such a behavioral model does not exist in real chips. RISC-V defines the mcycle CSR, which is a counter that increases by 1 every cycle. With this CSR, we can convert the cycle count into elapsed time according to the processor frequency, allowing clock-related functions to be implemented on real chips. According to the RISC-V specification, mcycle is a 64-bit counter.

In RV32, since the width of general-purpose registers is 32 bits, this counter is divided into two 32-bit CSRs mcycle and mcycleh.

Add mcycle

Specifically, you need to add mcycle and mcycleh to NPC. To do this, you need to find the CSR numbers and other information for mcycle and mcycleh from the manual.

After implementation, try reading the mcycle register multiple times through inline assembly and check whether its value automatically increments.

Finally, you also need to modify the clock implementation in AM. Specifically, modify the __am_timer_uptime() function in abstract-machine/am/src/riscv/npc/timer.c. So that it reads mcycle and converts the counter value into time using an appropriate coefficient. When running programs on a real processor chip in the future, this coefficient will depend on the processor operating frequency. However, in the simulation environment there is no concept of an actual clock frequency, and the rate at which time passes in simulation is not the same as real time. Therefore, you can choose an appropriate coefficient so that the clock speed observed by the program is close to real time. Since this conversion is performed in software, when running on a real processor chip in the future, you can adjust the coefficient according to the processor frequency and recompile the program.

Run the clock test

Modify the implementation of __am_timer_uptime(). Then run the real-time clock test in am-tests, and make the interval between test program output messages close to 1 second.

Evaluating Performance on the SoC

After completing the development tasks based on ysyxSoC above, let's conduct some performance evaluation.

Measuring IPC and Performance

Measure the IPC of the Processor Integrated into ysyxSoC

Run the hello program on minirv-ysyxsoc and measure its IPC. Try using the waveform to observe approximately how many cycles it takes to execute a single instruction, in order to verify the measured IPC.

From the IPC of the hello program, you can see that after integrating the processor into the SoC, although the memory access behavior becomes more realistic, its efficiency also decreases. It can be expected that the performance of archbench will also decrease, and the simulation will take significantly longer to complete.

To obtain performance evaluation results more quickly, you can first evaluate the test programs in archbench that require less simulation time. Specifically, you can start with the 100.blockchain program. Before doing so, however, you need to modify the implementation of the __am_timer_uptime() function and adjust the time conversion factor according to the NPC's synthesized frequency. For now, you only need to synthesize the NPC itself; there is no need to synthesize the entire ysyxSoC.

Fixing the Issue with Passing `mainargs` to Programs Running on ysyxSoC

We updated the usage of the gen.sh script in the lab manual at 19:30:00 on August 16, 2026, fixing an issue that prevented mainargs from being passed to programs running on ysyxSoC.

If you have not yet created minirv-ysyxsoc.mk in AM, please reread the section Running Your First Program on the SoC above, particularly the part about using the gen.sh script.

Measure the Performance of the Processor Integrated into ysyxSoC

After modifying the implementation of __am_timer_uptime(), run the 100.blockchain program on minirv-ysyxsoc. This should take approximately 5 minutes.

After it finishes, record the program's execution time (min time) and score, and compare them with your previous results.

Measure the Performance of the Processor Integrated into ysyxSoC (2)

If you are patient, you can run all of the test programs in archbench on minirv-ysyxsoc.

This may require waiting nearly 10 hours, but you can expect the resulting benchmark score to be less than satisfactory.

Calibrating the Frequency Ratio Between the NPC and the Devices

For simulation, the closer the simulation environment behaves to a real chip, the smaller the error in the evaluation results and the more useful the measured performance data will be. Starting with the zero-latency memory model implemented using DPI-C, then moving to a one-cycle-latency memory accessed through SimpleBus, and finally to the Flash and PSRAM in the SoC, the benchmark scores have gradually decreased. However, these lower scores are increasingly representative of the performance of a real chip.

In a real chip, peripheral devices typically operate at relatively low frequencies due to their electrical characteristics. For example, for the IS66WVS4M8ALL PSRAMopen in new window, the maximum operating frequency for read operations in normal mode is 33 MHz. At higher frequencies, timing violations may occur, preventing the PSRAM from reading data correctly. On the other hand, processors fabricated using advanced processes can typically operate at much higher frequencies.

To make full use of the processor's performance, an SoC typically includes a PLL (Phase-Locked Loop) to multiply the low-frequency clock supplied from outside the chip. The high-frequency clock generated by the PLL is used to drive the processor, while the lower-frequency clock before frequency multiplication is used to drive the SoC and its peripherals. Suppose the SoC and peripherals operate at 25 MHz and the PLL outputs a 500 MHz clock. Assuming that this high-frequency clock is within the NPC's maximum operating frequency range, the NPC should go through 20 cycles during one cycle of the PSRAM controller.

Update ysyxSoC to Provide an Additional `cpuClock`

We updated the ysyxSoC code at 17:30:00 on August 15, 2026, adding clock-domain crossing support and providing cpuClock as a port of the simulation top module.

If you obtained the ysyxSoC code before this time, back up any necessary modifications, delete the ysyxSoC directory, and obtain the code again using:

bash init.sh ysyxSoC

However, the current simulation assumes that the processor and all peripherals operate at the same frequency. Specifically, the ysyxSoC simulation top provides two clocks, clock and cpuClock. Currently, we drive them at the same frequency. In other words, one Verilator simulation cycle corresponds to one processor cycle as well as one peripheral cycle. Compared with a real chip, the performance evaluation results obtained under this assumption are overly optimistic. Therefore, the score obtained from running 100.blockchain on minirv-ysyxsoc earlier is still too high. If we run 100.blockchain on the actual chip after tape-out, the resulting score will be lower.

To obtain more accurate performance evaluation results in simulation, we need to calibrate the frequency ratio between the NPC and the SoC: Use a fast clock to drive the processor and a slower clock to drive the SoC and its peripherals. Specifically, in the simulation environment, each call to single_cycle() will drive cpuClock for one cycle, making cpuClock the fast clock. Only after calling single_cycle() several times will clock complete one cycle, making clock the slow clock.

For example, the diagram below shows a case where the frequency of cpuClock is three times that of clock:

             +---+   +---+   +---+   +---+   +---+   +---+   +---+
cpuClock     |   |   |   |   |   |   |   |   |   |   |   |   |   |
         +---+   +---+   +---+   +---+   +---+   +---+   +---+   +---+

                     +-----------+           +-----------+
  clock              |           |           |           |
         +-----------+           +-----------+           +-----------+

Implement Clocks with Different Frequencies

Modify the single_cycle() function in the simulation environment so that the frequency of cpuClock is an integer multiple of the frequency of clock. To achieve this, you can use a counter variable to control the frequency at which clock toggles. clock should only toggle when the counter reaches a certain value.

After implementing this, use the waveform to check whether the frequency relationship between the two clocks is as expected. Then try running the hello program and check whether it can still output correctly.

Measure the Performance of the Processor Integrated into ysyxSoC (3)

Assume that the SoC and its peripherals operate at 25 MHz, and choose a frequency multiplication factor k such that the frequency of cpuClock is k times that of clock. As the baseline for performance comparison, first choose k = 1, which means setting the NPC's operating frequency to 25 MHz.

With k = 1, run the 100.blockchain program. After it finishes, record the program's execution time (min time) and compare it with the previous result.

Measure the Performance of the Processor Integrated into ysyxSoC (4)

Choose the largest multiplication factor k such that k * 25 MHz does not exceed the NPC's synthesized frequency. In other words, set the NPC's operating frequency to k * 25 MHz, rather than using the NPC's synthesized maximum frequency directly. This simulates the process of manually selecting a PLL multiplication factor on the actual chip in the future.

With the maximum value of k, run the 100.blockchain program. This should take approximately 30 minutes to 1 hour. After it finishes, record the program's execution time (min time) and compare it with the previous results. Note that performance measurements, including both IPC and execution time, should be based on the number of cpuClock cycles and the k * 25 MHz frequency.

Is Frequency Optimization Worth It?

You may find that the performance of the 100.blockchain program is almost unchanged between k = 1 and the maximum value of k. This means that increasing the NPC's frequency provides almost no positive benefit in terms of program performance.

Think about why this happens. In the current SoC, is it worth optimizing the processor frequency? If it is worthwhile, where exactly does the benefit of optimizing the processor frequency come from? If it is not worthwhile, what factors offset the benefits of optimizing the processor frequency?

Other Considerations

Estimating the Tape-Out Cost

For chip design, the tape-out cost is proportional to the chip area. Suppose the ICsprout55 foundry charges 30,000 RMB per for tape-out. In general, the area reported by synthesis is not the final chip area, because additional area needs to be reserved for the back-end design process. Based on empirical estimates, you can assume that the area reported by synthesis accounts for 60% of the final tape-out area.

Estimate Performance and Area Cost

Based on the current NPC synthesis report, estimate the tape-out cost of the NPC.

Note that the current synthesis report does not include the area of the SoC, and the overall cost of chip design includes more than just the tape-out cost.

todo: Estimate Performance and Area Cost (2)

Try using an area-oriented synthesis strategy to reevaluate the NPC's performance and tape-out cost.

From an overall perspective, which synthesis strategy is more appropriate for the current design?

Simulation Efficiency

The minirv-ysyxsoc environment with a calibrated frequency ratio is well suited for performance evaluation, but you will also notice that its simulation efficiency is significantly lower than that of minirv-npc. In terms of program execution time, minirv-npc can be tens or even hundreds of times faster to simulate than minirv-ysyxsoc. This reflects a fundamental trade-off: to obtain more accurate performance data, you need to simulate more details, such as the bus, PSRAM controller, and frequency differences. This means that simulating each cycle requires more real-world computation time, ultimately reducing simulation efficiency. Correspondingly, although minirv-npc has higher simulation efficiency, the performance data it produces is less accurate.

Does this mean that minirv-npc is useless? In fact, we can use minirv-npc as a functional testing environment. If a functional bug exists in minirv-npc, there is a high probability that the same bug also exists in minirv-ysyxsoc. However, debugging the bug in minirv-npc, where simulation is much faster, is clearly a more appropriate approach. In this way, we can make full use of the strengths of both simulation environments and compensate for their respective weaknesses, thereby improving the overall efficiency of development and testing.

Improve Functional Testing Efficiency

Try modifying the relevant simulation flow so that the simulation environment can switch between the standalone NPC and the NPC integrated into ysyxSoC. You can use macro definitions, Makefile variables, or any other approach to implement this flow. When simulating the NPC standalone, continue to use 0x8000_0000 as the reset PC value.

Although minirv-npc is more efficient than minirv-ysyxsoc, running a somewhat larger program on the NPC can still take a long time. In principle, minirv can implement the entire RV32I instruction set. However, to achieve this, instructions that are not included in minirv need to be translated into several or even dozens of minirv instructions with equivalent behavior. In other words, compared with compiling a program for RV32I, compiling it for minirv results in execution efficiency that is several or even dozens of times lower. Therefore, even though minirv has the potential to run games, the gaming experience would be tens of times slower than on RV32I.

In the D stage, we will extend the NPC from the 8 instructions supported by minirv to the dozens of instructions in RV32I. By adding more functionality to the hardware, we can significantly improve software execution efficiency.