本节实现一个最简的 CPU ,最终能够解析 add
和 addi
两个指令。如果对计算机组成原理已经有所了解可以跳过下面的内容直接看代码实现。
完整代码在这个分支:lab1-cpu-add,本章节尾有运行的具体指令。
冯·诺依曼结构是现代计算机体系结构的基础,由约翰·冯·诺依曼在 1945 年提出。这种结构也称为冯·诺依曼体系结构,其核心特点是将程序指令和数据存储在同一个读写存储器(内存)中,计算机的工作流程则是按顺序执行存储器中的指令。这一概念是区别于早期的计算机设计,如图灵机和哈佛架构,后者将数据存储和指令存储分开。
+------------------+ +-------------+
| 输入设备 | ---> | |
+------------------+ | |
| |
+------------------+ | | +-----------------+
| 存储器 | <--> | CPU | ---> | 输出设备 |
+------------------+ | | +-----------------+
| |
+------------------+ | |
| 控制单元 | <--> | |
+------------------+ +-------------+
在这个结构中,控制单元指导操作的流程,确保指令正确执行。输入设备可以是键盘、鼠标等,它们把用户的输入转换成机器可以理解的数据。输出设备如显示器和打印机,用于向用户展示结果。存储器不仅保存了待处理的数据,还保存了计算机程序的所有指令。CPU 是核心部件,执行所有的计算和逻辑处理。
冯·诺依曼结构的提出,极大推动了计算机科学的发展,使得计算机设计变得更加灵活,程序存储成为可能。在这种架构下,更改程序不再需要重新设计计算机硬件,仅需在存储器中替换或修改程序即可。这一概念至今仍是大多数计算机设计的基础。
用数组来模拟内存,其中存放待执行的指令。pc
是程序计数器(Program Counter)的简写,用来指向当前正在执行指令的下一条指令。此外 RISC-V 有 32 个寄存器,可以用数组来存放,寄存器用来存放临时产生数据。
// main.cpp
#include
#include
#include
// 定义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 处开始执行。
在现代计算机体系结构中,尤其是遵循冯·诺依曼架构的计算机系统,程序的执行可以分解为几个连续的阶段,这些阶段共同构成了 CPU 的指令周期。这些阶段包括取指(Instruction Fetch, IF)、解码(Instruction Decode, ID)、执行(Execute, EX)、访存(Memory Access, MEM)和写回(Write Back, WB)。这个过程是循环进行的,每个阶段完成特定的任务,确保计算机程序顺利执行。
+--------+ +--------+ +--------+ +--------+ +--------+
| 取指IF |-->| 解码ID |-->| 执行EX |-->| 访存MEM |-->| 写回WB |
+--------+ +--------+ +--------+ +--------+ +--------+
在这个示意图中,每行代表 CPU 中的一条指令随时间前进经过不同的处理阶段。每个方框代表流水线的一个阶段,箭头表示指令从一个阶段移动到下一个阶段的流程。这种设计使得在任何给定的时钟周期内,最多可以有五条指令处于不同的执行阶段,极大提高了 CPU 的效率和性能。
这五个阶段共同构成了指令的完整执行周期,是现代 CPU 设计的基础。通过将指令执行分解为这些阶段,计算机能够以高效和有序的方式运行程序。每个阶段都由 CPU 的不同部件负责,使得计算机能够在任何给定时刻执行多条指令的不同阶段,这种设计是流水线处理的基础,极大提高了 CPU 的执行效率。
接下来实现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(pc);
将pc
的值转换为适合作为索引的类型。static_cast(dram[index])
读取第一个字节,并保持其原位。(static_cast(dram[index + 1]) << 8)
读取第二个字节,并左移 8 位。(static_cast(dram[index + 2]) << 16)
读取第三个字节,并左移 16 位。(static_cast(dram[index + 3]) << 24)
读取第四个字节,并左移 24 位。|
)组合成一个完整的 32 位指令。这个过程展示了如何从连续的字节中构建一个完整的指令,是执行流水线中取指阶段的关键步骤。
同时上面的实现是小端,如果反过来高位数据位于低地址部分就是大端。
上一部分已经读取到指令了,接下来就是解析指令然后执行。接下来实现如何解析 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
指令 常用于需要将某个变量的值与一个已知的常数相加的场景,例如数组索引计算、根据偏移量计算地址等。为了以文本图形化的方式更直观地展示这两个指令的作用,我们可以用简化的图表来表示它们的操作流程:
add
操作流程 [寄存器1] [寄存器2]
| |
| |
+----加法----+
|
↓
[目标寄存器]
addi
操作流程 [寄存器] [立即数]
| |
| |
+----加法----+
|
↓
[目标寄存器]
通过上面的解释和图形化表示,可以看出 add
和 addi
指令在汇编语言编程中如何用于处理不同的加法运算场景。
上部分已经讲解了 add 和 addi 指令的具体功能和使用场景,接下来讲解这两个指令是如何存放在内存中的。
add
指令格式add
指令在 RISC-V 中是一种 R 型(寄存器-寄存器)指令,用于将两个寄存器的数值相加,并将结果存储在第三个寄存器中。
add
指令,操作码指定了这是一种算术操作。add
来说,这个字段有特定的值。add
有其特定的值。┌───────┬─────┬──────┬─────┬─────┬────────┐
│opcode │ rd │funct3│ rs1 │ rs2 │ funct7 │
└───────┴─────┴──────┴─────┴─────┴────────┘
7 bits 5 bits 3 bits 5 bits 5 bits 7 bits
接下来结合具体的汇编代码来讲解:
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
0110011
,表示这是一个 R-type 指令。00111
,即寄存器 x7
。000
,与 funct7
一起确定这是一个加法操作。00101
,即寄存器 x5
。00110
,即寄存器 x6
。0000000
,与 funct3
一起指定了这是一个 add
操作。addi
指令格式addi
指令在 RISC-V 中是一种 I 型(立即数)指令,它将一个寄存器中的数值与一个立即数相加,并将结果存储在另一个寄存器中。
addi
,这指定了是一种立即数加法操作。addi
有其特定的值。┌───────┬─────┬─────┬─────┬─────────────────┐
│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
0010011
,表示这是一个 I-type 指令。00111
,即寄存器 x7
。000
,确定这是一个立即数加法操作。00101
,即寄存器 x5
。0000000000101010
,表示十进制数 10
。这里立即数字段实际上是 12 位,为简化表示,应解释为补码形式,代表正数 10
。通过上面的例子,我们可以清楚地看到 RISC-V 架构下 add
和 addi
指令的内部组成及其编码方式。这种表示不仅有助于理解指令的结构,也方便在设计汇编语言程序时进行指令选择和使用。
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;
}
}
// 其他成员变量和方法...
};
上面已经上一个最简 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 汇编语言编写的,它执行了一个非常简单的任务:计算两个数值的和,并将结果存储。具体来说,代码做了以下几件事情:
总结来说,这段代码简单地计算了 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