使用 C++23 从零实现 RISC-V 模拟器(1):最简CPU

👉🏻 文章汇总「从零实现模拟器、操作系统、数据库、编译器…」:https://okaitserrj.feishu.cn/docx/R4tCdkEbsoFGnuxbho4cgW2Yntc

本节实现一个最简的 CPU ,最终能够解析 addaddi 两个指令。如果对计算机组成原理已经有所了解可以跳过下面的内容直接看代码实现。

完整代码在这个分支:lab1-cpu-add,本章节尾有运行的具体指令。

1. 冯诺依曼结构

冯·诺依曼结构是现代计算机体系结构的基础,由约翰·冯·诺依曼在 1945 年提出。这种结构也称为冯·诺依曼体系结构,其核心特点是将程序指令和数据存储在同一个读写存储器(内存)中,计算机的工作流程则是按顺序执行存储器中的指令。这一概念是区别于早期的计算机设计,如图灵机和哈佛架构,后者将数据存储和指令存储分开。

冯·诺依曼结构的五大组成部分:

  1. 中央处理单元(CPU):负责解释计算机程序中的指令和处理数据。
  2. 存储器:存储程序和数据。在冯·诺依曼结构中,程序指令和数据共享同一存储区。
  3. 输入设备:用于将数据和程序输入到计算机中。
  4. 输出设备:将处理结果展示给用户。
  5. 控制单元(CU):协调其他部件的操作。

工作流程:

  1. 指令和数据的存储:在开始执行程序之前,程序的指令和所需的数据被加载到存储器中。
  2. 指令的执行:CPU 从存储器中读取指令,解析指令,然后执行指令。这个过程包括从存储器读取数据、在算术逻辑单元(ALU)中进行计算,以及将结果写回存储器或通过输出设备展示出来。
+------------------+      +-------------+
|    输入设备       | ---> |             |
+------------------+      |             |
                          |             |
+------------------+      |             |      +-----------------+
|    存储器         | <--> |     CPU     | ---> |    输出设备      |
+------------------+      |             |      +-----------------+
                          |             |
+------------------+      |             |
|    控制单元       | <--> |             |
+------------------+      +-------------+

在这个结构中,控制单元指导操作的流程,确保指令正确执行。输入设备可以是键盘、鼠标等,它们把用户的输入转换成机器可以理解的数据。输出设备如显示器和打印机,用于向用户展示结果。存储器不仅保存了待处理的数据,还保存了计算机程序的所有指令。CPU 是核心部件,执行所有的计算和逻辑处理。

重要性:

冯·诺依曼结构的提出,极大推动了计算机科学的发展,使得计算机设计变得更加灵活,程序存储成为可能。在这种架构下,更改程序不再需要重新设计计算机硬件,仅需在存储器中替换或修改程序即可。这一概念至今仍是大多数计算机设计的基础。

2. 最简 CPU

用数组来模拟内存,其中存放待执行的指令。pc 是程序计数器(Program Counter)的简写,用来指向当前正在执行指令的下一条指令。此外 RISC-V 有 32 个寄存器,可以用数组来存放,寄存器用来存放临时产生数据。

// main.cpp
#include <vector>
#include <array>
#include <cstdint>

// 定义DRAM_SIZE为128MB
const uint64_t DRAM_SIZE = 1024 * 1024 * 128;

class Cpu {
    // RISC-V 有 32 个寄存器
    std::array<uint64_t, 32> regs;

    // PC 寄存器包含下一条指令的内存地址
    uint64_t pc;

    // 内存,一个字节数组。在真实的 CPU 中没有内存,这里仅作模拟。
    std::vector<uint8_t> dram;

public:
    // 构造函数
    Cpu(const std::vector<uint8_t>& code) : pc(0), dram(code) {
        regs.fill(0); // 初始化寄存器为0
        regs[2] = DRAM_SIZE - 1; // 设置堆栈指针寄存器的初始值
    }

    // 可能需要的其他成员函数声明
};

int main() {
    // 示例代码使用
    std::vector<uint8_t> code = { /* 初始化代码 */ };
    Cpu cpu(code);
    // 使用cpu对象进行操作
}

其中 pc 的值置为 0 表示表示程序从地址 0 处开始执行。

2. CPU 流水线

在现代计算机体系结构中,尤其是遵循冯·诺依曼架构的计算机系统,程序的执行可以分解为几个连续的阶段,这些阶段共同构成了 CPU 的指令周期。这些阶段包括取指(Instruction Fetch, IF)、解码(Instruction Decode, ID)、执行(Execute, EX)、访存(Memory Access, MEM)和写回(Write Back, WB)。这个过程是循环进行的,每个阶段完成特定的任务,确保计算机程序顺利执行。

+--------+   +--------+   +--------+   +--------+   +--------+
| 取指IF  |-->| 解码ID |-->| 执行EX  |-->| 访存MEM |-->| 写回WB |
+--------+   +--------+   +--------+   +--------+   +--------+

在这个示意图中,每行代表 CPU 中的一条指令随时间前进经过不同的处理阶段。每个方框代表流水线的一个阶段,箭头表示指令从一个阶段移动到下一个阶段的流程。这种设计使得在任何给定的时钟周期内,最多可以有五条指令处于不同的执行阶段,极大提高了 CPU 的效率和性能。

  • 取指 IF:从内存中读取指令。
    • CPU 使用程序计数器(PC)指向的地址从内存中读取指令。读取后,PC 会更新到下一条指令的地址,为下一个周期准备。
  • 解码 ID:解析指令并准备必要的操作数。
    • 指令解码器(ID)解析取出的指令,确定需要执行的操作和操作数。这可能涉及到从指令中提取立即数、计算地址、确定寄存器编号等。
  • 执行 EX:执行计算或其他操作。
    • 执行阶段根据解码阶段的结果,进行相应的算术逻辑运算(ALU 操作)、分支决策或其他操作。此阶段可能需要使用到 ALU(算术逻辑单元)。
  • 访存 MEM:进行内存访问,如数据加载或存储。
    • 如果指令需要读取内存(如加载操作)或向内存写入数据(如存储操作),这一阶段将完成该操作。这一步是可选的,因为不是所有指令都需要访问内存。
  • 写回 WB:将执行结果写回寄存器。
    • 将执行阶段的结果或访存阶段从内存读取的数据写回到指定的寄存器。这一步确保了指令的执行结果可以被后续指令使用。

这五个阶段共同构成了指令的完整执行周期,是现代 CPU 设计的基础。通过将指令执行分解为这些阶段,计算机能够以高效和有序的方式运行程序。每个阶段都由 CPU 的不同部件负责,使得计算机能够在任何给定时刻执行多条指令的不同阶段,这种设计是流水线处理的基础,极大提高了 CPU 的执行效率。

3. 取指

接下来实现Cpu类中的fetch函数。此函数的目的是从 CPU 内部的动态随机存取存储器(DRAM)中读取当前程序计数器(pc)指向的指令。

文本图形化解释:

假设我们有以下 DRAM 内容和一个pc值指向 DRAM 的起始位置:

DRAM 内容(示例):
+----+----+----+----+
| 01 | 02 | 03 | 04 | ...
+----+----+----+----+
  ↑
  pc
  • 步骤 1: pc指向 DRAM 中的第一个字节。

  • 步骤 2: fetch函数读取pc指向的四个字节(01, 02, 03, 04)。

  • 步骤 3: 将这四个字节组合成一个 32 位的指令。

组合过程:
    01                   02                   03                   04
00000001 (字节1) | 00000010 (字节2) << 8 | 00000011 (字节3) << 16 | 00000100 (字节4) << 24
= 04030201 (十六进制)
          DRAM
+----+----+----+----+----+----+----+----+
| 01 | 02 | 03 | 04 | xx | xx | xx | xx | ...
+----+----+----+----+----+----+----+----+
  ↑
  pc
  (假设pc=0)

fetch操作:
1. 读取 [01] → index=pc
2. 读取 [02] → index=pc+1
3. 读取 [03] → index=pc+2
4. 读取 [04] → index=pc+3

组合为32位指令:04030201

代码解释:

// main.cpp
class Cpu {
    // ...
public:
    // ...
    // Fetch函数用于读取当前pc指向的指令
    uint32_t fetch() {
        size_t index = static_cast<size_t>(pc); // 确保pc值在转换时不会丢失信息
        uint32_t inst = static_cast<uint32_t>(dram[index])
                      | (static_cast<uint32_t>(dram[index + 1]) << 8)
                      | (static_cast<uint32_t>(dram[index + 2]) << 16)
                      | (static_cast<uint32_t>(dram[index + 3]) << 24);
        return inst;
    }
};
  • size_t index = static_cast<size_t>(pc);pc的值转换为适合作为索引的类型。
  • static_cast<uint32_t>(dram[index]) 读取第一个字节,并保持其原位。
  • (static_cast<uint32_t>(dram[index + 1]) << 8) 读取第二个字节,并左移 8 位。
  • (static_cast<uint32_t>(dram[index + 2]) << 16) 读取第三个字节,并左移 16 位。
  • (static_cast<uint32_t>(dram[index + 3]) << 24) 读取第四个字节,并左移 24 位。
  • 这四个部分通过位或操作(|)组合成一个完整的 32 位指令。

这个过程展示了如何从连续的字节中构建一个完整的指令,是执行流水线中取指阶段的关键步骤。

同时上面的实现是小端,如果反过来高位数据位于低地址部分就是大端。

4. 解码

上一部分已经读取到指令了,接下来就是解析指令然后执行。接下来实现如何解析 add 和 addi 指令,在解析之前要先弄清楚这两个指令的具体作用及其使用场景。

add 指令和 addi 指令是在汇编语言和计算机架构中常用的两种基本指令,尤其在 MIPS 架构中广泛应用。它们用于进行加法运算,但在操作方式和使用场景上有所不同。

add 指令

add 指令用于将两个寄存器中的数值相加,并将结果存储在另一个寄存器中。这是一种寄存器到寄存器的操作。

格式: add 目标寄存器, 源寄存器1, 源寄存器2

例子:

add $t0, $t1, $t2

这条指令的意思是将 $t1$t2 中的值相加,然后将结果存储在 $t0 中。

addi 指令

addi 指令是 “add immediate” 的缩写,它将一个寄存器中的值与一个立即数(即直接提供的数值)相加,并将结果存储在另一个寄存器中。这是一种寄存器到立即数的操作。

格式: addi 目标寄存器, 源寄存器, 立即数

例子:

addi $t0, $t1, 5

这条指令的意思是将 $t1 中的值与立即数 5 相加,然后将结果存储在 $t0 中。

使用场景

  • add 指令 通常用于需要将两个变量的值相加的情况,这两个变量的值在执行指令之前已经被加载到寄存器中。
  • addi 指令 常用于需要将某个变量的值与一个已知的常数相加的场景,例如数组索引计算、根据偏移量计算地址等。

为了以文本图形化的方式更直观地展示这两个指令的作用,我们可以用简化的图表来表示它们的操作流程:

  1. add 操作流程
   [寄存器1]     [寄存器2]
      |             |
      |             |
      +----加法----+
            |
            ↓
        [目标寄存器]
  1. addi 操作流程
   [寄存器]     [立即数]
      |           |
      |           |
      +----加法----+
            |
            ↓
        [目标寄存器]

通过上面的解释和图形化表示,可以看出 addaddi 指令在汇编语言编程中如何用于处理不同的加法运算场景。

指令内部组成

上部分已经讲解了 add 和 addi 指令的具体功能和使用场景,接下来讲解这两个指令是如何存放在内存中的。

add 指令格式

add 指令在 RISC-V 中是一种 R 型(寄存器-寄存器)指令,用于将两个寄存器的数值相加,并将结果存储在第三个寄存器中。

  • 操作码(opcode):标识这是一种什么操作的字段,对于 add 指令,操作码指定了这是一种算术操作。
  • 目标寄存器(rd):存放操作结果的寄存器。
  • 功能码(funct3):提供操作的进一步细化,对于 add 来说,这个字段有特定的值。
  • 源寄存器 1(rs1):第一个操作数的寄存器。
  • 源寄存器 2(rs2):第二个操作数的寄存器。
  • 功能码(funct7):与 funct3 合作,确定是哪种具体的算术操作,add 有其特定的值。
┌───────┬─────┬──────┬─────┬─────┬────────┐
│opcode │  rd │funct3│ rs1 │ rs2 │ funct7 │
└───────┴─────┴──────┴─────┴─────┴────────┘
  7 bits  5 bits 3 bits 5 bits 5 bits  7 bits

接下来结合具体的汇编代码来讲解:

  1. 对于 add x7, x5, x6 指令:将寄存器 x5 和寄存器 x6 的值相加,并将结果存储到寄存器 x7 中。

下面是对应在内存中的表示:

┌────────┬──────┬──────┬──────┬──────┬────────┐
│opcode  │  rd  │funct3│ rs1  │ rs2  │ funct7 │
│ 0110011│ 00111│ 000  │ 00101│ 00110│ 0000000│
└────────┴──────┴──────┴──────┴──────┴────────┘
  7 bits  5 bits 3 bits 5 bits 5 bits  7 bits
  • 操作码(opcode)0110011,表示这是一个 R-type 指令。
  • 目标寄存器(rd)00111,即寄存器 x7
  • 功能码(funct3)000,与 funct7 一起确定这是一个加法操作。
  • 源寄存器 1(rs1)00101,即寄存器 x5
  • 源寄存器 2(rs2)00110,即寄存器 x6
  • 功能码(funct7)0000000,与 funct3 一起指定了这是一个 add 操作。
RISC-V addi 指令格式

addi 指令在 RISC-V 中是一种 I 型(立即数)指令,它将一个寄存器中的数值与一个立即数相加,并将结果存储在另一个寄存器中。

  • 操作码(opcode):标识操作类型的字段,对于 addi,这指定了是一种立即数加法操作。
  • 目标寄存器(rd):存放操作结果的寄存器。
  • 功能码(funct3):进一步指定了操作的类型,addi 有其特定的值。
  • 源寄存器(rs1):操作数的寄存器。
  • 立即数(imm):与源寄存器中的值相加的立即数。
┌───────┬─────┬─────┬─────┬─────────────────┐
│opcode │  rd │funct3│ rs1 │       imm       │
└───────┴─────┴─────┴─────┴─────────────────┘
  7 bits  5 bits 3 bits 5 bits      12 bits

接下来结合具体的汇编代码来讲解:

对于 addi x7, x5, 10 指令: 将寄存器 x5 的值与立即数 10 相加,并将结果存储到寄存器 x7 中。

下面是对应在内存中的表示:

┌────────┬──────┬──────┬──────┬─────────────────┐
│opcode  │  rd  │funct3│ rs1  │       imm       │
│ 0010011│ 00111│ 000  │ 00101│ 0000000000101010│
└────────┴──────┴──────┴──────┴─────────────────┘
  7 bits  5 bits 3 bits 5 bits      12 bits
  • 操作码(opcode)0010011,表示这是一个 I-type 指令。
  • 目标寄存器(rd)00111,即寄存器 x7
  • 功能码(funct3)000,确定这是一个立即数加法操作。
  • 源寄存器(rs1)00101,即寄存器 x5
  • 立即数(imm)0000000000101010,表示十进制数 10。这里立即数字段实际上是 12 位,为简化表示,应解释为补码形式,代表正数 10

通过上面的例子,我们可以清楚地看到 RISC-V 架构下 addaddi 指令的内部组成及其编码方式。这种表示不仅有助于理解指令的结构,也方便在设计汇编语言程序时进行指令选择和使用。

使用场景和解析

  • add 指令 用于两个寄存器值的加法运算,常用于各种数值计算和数据处理任务。
  • addi 指令 用于将寄存器值与立即数相加,常见于地址计算、数值调整等场景。

这些指令的设计反映了 RISC-V 指令集的目标,即提供简单、高效且足够灵活的指令集,以支持现代编译器技术和硬件实现的需求。

代码

// main.cpp
class Cpu {
public:
    // 其他成员和方法...

    // 执行指令的函数
    void execute(uint32_t inst) {
        // 解析指令中的操作码(opcode),占用最低的7位
        uint32_t opcode = inst & 0x7f;
        // 解析目标寄存器(rd),位于指令的第7到11位
        uint32_t rd = (inst >> 7) & 0x1f;
        // 解析第一个源寄存器(rs1),位于指令的第15到19位
        uint32_t rs1 = (inst >> 15) & 0x1f;
        // 解析第二个源寄存器(rs2),位于指令的第20到24位
        uint32_t rs2 = (inst >> 20) & 0x1f;
        // 解析功能码(funct3),位于指令的第12到14位
        uint32_t funct3 = (inst >> 12) & 0x7;
        // 解析功能码(funct7),位于指令的第25到31位
        uint32_t funct7 = (inst >> 25) & 0x7f;

        // 寄存器x0永远为0
        regs[0] = 0;

        // 执行阶段
        switch (opcode) {
            case 0x13: { // 处理addi指令
                // 解析立即数,将指令的最高20位视为符号扩展的立即数
                int64_t imm = static_cast<int32_t>(inst & 0xfff00000) >> 20;
                // 执行加法操作,将rs1寄存器的值与立即数相加,并将结果存入rd寄存器
                regs[rd] = regs[rs1] + imm;
                break;
            }
            case 0x33: { // 处理add指令
                // 执行加法操作,将rs1和rs2寄存器的值相加,并将结果存入rd寄存器
                regs[rd] = regs[rs1] + regs[rs2];
                break;
            }
            default:
                // 如果操作码不是预期中的值,则输出错误信息
                std::cerr << "Invalid opcode: " << std::hex << opcode << std::endl;
            break;
        }
    }
    // 其他成员变量和方法...
};

5. 测试

上面已经上一个最简 CPU 了,接下来需要增加一些辅助功能来使得 CPU 跑起来。

首先是需要能够查看寄存器中的数据,根据数据变化来验证指令执行正确。

class Cpu {
public:
    // 其他成员和方法...

    // RISC-V 寄存器名称
    const std::array<std::string, 32> RVABI = {
        "zero", "ra", "sp", "gp", "tp", "t0", "t1", "t2",
        "s0", "s1", "a0", "a1", "a2", "a3", "a4", "a5",
        "a6", "a7", "s2", "s3", "s4", "s5", "s6", "s7",
        "s8", "s9", "s10", "s11", "t3", "t4", "t5", "t6",
    };

    void dump_registers() {
        std::cout << std::setw(80) << std::setfill('-') << "" << std::endl; // 打印分隔线
        std::cout << std::setfill(' '); // 重置填充字符
        for (size_t i = 0; i < 32; i += 4) {
            std::cout << std::setw(4) << "x" << i << "(" << RVABI[i] << ") = " << std::hex << std::setw(16) << std::setfill('0') << regs[i] << " "
                      << std::setw(4) << "x" << i + 1 << "(" << RVABI[i + 1] << ") = " << std::setw(16) << regs[i + 1] << " "
                      << std::setw(4) << "x" << i + 2 << "(" << RVABI[i + 2] << ") = " << std::setw(16) << regs[i + 2] << " "
                      << std::setw(4) << "x" << i + 3 << "(" << RVABI[i + 3] << ") = " << std::setw(16) << regs[i + 3] << std::endl;
        }
    }
};

总的来说上面的代码就是为 32 个寄存器增加了对应的名称以及提供一个一个能够打印其中数值的方法。

创建 add-addi.s 并写入下面的内容

.global _start
_start:
    addi x29, x0, 5
    addi x30, x0, 37
    add x31, x30, x29

在汇编语言中,.global _start_start: 的语句定义了程序的入口点。

  • .global _start:这条指令告诉链接器(linker),_start 标签是一个全局符号,可以被程序的其他部分或其他链接的文件访问。更重要的是,它标示 _start 为程序的入口点,即程序执行的起始位置。这对于操作系统(OS)来说非常关键,因为在程序被加载到内存并执行时,操作系统需要知道从哪里开始执行程序。

  • _start::这是一个标签,紧跟在它后面的是程序的入口点。在这个位置上编写的指令将会是程序执行的第一批指令。在一个裸机(bare-metal)环境或操作系统内核开发中,_start 是执行流程的起点,没有标准库或运行时环境的初始化过程。

综上所述,.global _start_start: 一起定义了程序开始执行的地方,为操作系统提供了一个明确的起点来运行程序。如果不写的话会报错。

这段代码是使用 RISC-V 汇编语言编写的,它执行了一个非常简单的任务:计算两个数值的和,并将结果存储。具体来说,代码做了以下几件事情:

  1. 将数字 5 存储到寄存器 x29。
  2. 将数字 37 存储到寄存器 x30。
  3. 将寄存器 x29 和 x30 中的数值相加,将结果存储到寄存器 x31。

总结来说,这段代码简单地计算了 5 和 37 的和,然后将结果 42 存储到寄存器 x31 中。

将汇编转为二进制文件:

$ riscv64-unknown-elf-gcc -Wl,-Ttext=0x0 -nostdlib -o add-addi add-addi.s
$ riscv64-unknown-elf-objcopy -O binary add-addi add-addi.bin

运行并测试是否正确:

mkdir -p build && cd build && cmake .. && make && ./crvemu ../add-addi.bin
~/crvemu/build$ ./crvemu ../add-addi.bin
--------------------------------------------------------------------------------
x0(zero) = 0000000000000000 000x1(ra) = 0000000000000000 000x2(sp) = 0000000007ffffff 000x3(gp) = 0000000000000000
000x4(tp) = 0000000000000000 000x5(t0) = 0000000000000000 000x6(t1) = 0000000000000000 000x7(t2) = 0000000000000000
000x8(s0) = 0000000000000000 000x9(s1) = 0000000000000000 000xa(a0) = 0000000000000000 000xb(a1) = 0000000000000000
000xc(a2) = 0000000000000000 000xd(a3) = 0000000000000000 000xe(a4) = 0000000000000000 000xf(a5) = 0000000000000000
000x10(a6) = 0000000000000000 000x11(a7) = 0000000000000000 000x12(s2) = 0000000000000000 000x13(s3) = 0000000000000000
000x14(s4) = 0000000000000000 000x15(s5) = 0000000000000000 000x16(s6) = 0000000000000000 000x17(s7) = 0000000000000000
000x18(s8) = 0000000000000000 000x19(s9) = 0000000000000000 000x1a(s10) = 0000000000000000 000x1b(s11) = 0000000000000000
000x1c(t3) = 0000000000000000 000x1d(t4) = 0000000000000005 000x1e(t5) = 0000000000000025 000x1f(t6) = 000000000000002a

注意最后一行最后三个,因为是二进制,所以数据是正确的,例如 25 对应的十进制就是 37 。

至此本节内容已经完成,目前已经能够实现解析 add 和 addi 两个指令。

👉🏻 文章汇总「从零实现模拟器、操作系统、数据库、编译器…」:https://okaitserrj.feishu.cn/docx/R4tCdkEbsoFGnuxbho4cgW2Yntc

  • 19
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值