专门做衬衣网站,颐高养生园网站建设,广州网站制作服务,专业的网站建设平台#x1f449;#x1f3fb; 文章汇总「从零实现模拟器、操作系统、数据库、编译器…」#xff1a;https://okaitserrj.feishu.cn/docx/R4tCdkEbsoFGnuxbho4cgW2Yntc
本节实现一个最简的 CPU #xff0c;最终能够解析 add 和 addi 两个指令。如果对计算机组成原理已经有所了… 文章汇总「从零实现模拟器、操作系统、数据库、编译器…」https://okaitserrj.feishu.cn/docx/R4tCdkEbsoFGnuxbho4cgW2Yntc
本节实现一个最简的 CPU 最终能够解析 add 和 addi 两个指令。如果对计算机组成原理已经有所了解可以跳过下面的内容直接看代码实现。
完整代码在这个分支lab1-cpu-add本章节尾有运行的具体指令。
1. 冯诺依曼结构
冯·诺依曼结构是现代计算机体系结构的基础由约翰·冯·诺依曼在 1945 年提出。这种结构也称为冯·诺依曼体系结构其核心特点是将程序指令和数据存储在同一个读写存储器内存中计算机的工作流程则是按顺序执行存储器中的指令。这一概念是区别于早期的计算机设计如图灵机和哈佛架构后者将数据存储和指令存储分开。
冯·诺依曼结构的五大组成部分
中央处理单元CPU负责解释计算机程序中的指令和处理数据。存储器存储程序和数据。在冯·诺依曼结构中程序指令和数据共享同一存储区。输入设备用于将数据和程序输入到计算机中。输出设备将处理结果展示给用户。控制单元CU协调其他部件的操作。
工作流程
指令和数据的存储在开始执行程序之前程序的指令和所需的数据被加载到存储器中。指令的执行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::arrayuint64_t, 32 regs;// PC 寄存器包含下一条指令的内存地址uint64_t pc;// 内存一个字节数组。在真实的 CPU 中没有内存这里仅作模拟。std::vectoruint8_t dram;public:// 构造函数Cpu(const std::vectoruint8_t code) : pc(0), dram(code) {regs.fill(0); // 初始化寄存器为0regs[2] DRAM_SIZE - 1; // 设置堆栈指针寄存器的初始值}// 可能需要的其他成员函数声明
};int main() {// 示例代码使用std::vectoruint8_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) 2404030201 (十六进制)DRAM
--------------------------------
| 01 | 02 | 03 | 04 | xx | xx | xx | xx | ...
--------------------------------↑pc(假设pc0)fetch操作
1. 读取 [01] → indexpc
2. 读取 [02] → indexpc1
3. 读取 [03] → indexpc2
4. 读取 [04] → indexpc3组合为32位指令04030201代码解释
// main.cpp
class Cpu {// ...
public:// ...// Fetch函数用于读取当前pc指向的指令uint32_t fetch() {size_t index static_castsize_t(pc); // 确保pc值在转换时不会丢失信息uint32_t inst static_castuint32_t(dram[index])| (static_castuint32_t(dram[index 1]) 8)| (static_castuint32_t(dram[index 2]) 16)| (static_castuint32_t(dram[index 3]) 24);return inst;}
};size_t index static_castsize_t(pc); 将pc的值转换为适合作为索引的类型。static_castuint32_t(dram[index]) 读取第一个字节并保持其原位。(static_castuint32_t(dram[index 1]) 8) 读取第二个字节并左移 8 位。(static_castuint32_t(dram[index 2]) 16) 读取第三个字节并左移 16 位。(static_castuint32_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 指令 常用于需要将某个变量的值与一个已知的常数相加的场景例如数组索引计算、根据偏移量计算地址等。
为了以文本图形化的方式更直观地展示这两个指令的作用我们可以用简化的图表来表示它们的操作流程
add 操作流程 [寄存器1] [寄存器2]| || |----加法----|↓[目标寄存器]addi 操作流程 [寄存器] [立即数]| || |----加法----|↓[目标寄存器]通过上面的解释和图形化表示可以看出 add 和 addi 指令在汇编语言编程中如何用于处理不同的加法运算场景。
指令内部组成
上部分已经讲解了 add 和 addi 指令的具体功能和使用场景接下来讲解这两个指令是如何存放在内存中的。
add 指令格式
add 指令在 RISC-V 中是一种 R 型寄存器-寄存器指令用于将两个寄存器的数值相加并将结果存储在第三个寄存器中。
操作码opcode标识这是一种什么操作的字段对于 add 指令操作码指定了这是一种算术操作。目标寄存器rd存放操作结果的寄存器。功能码funct3提供操作的进一步细化对于 add 来说这个字段有特定的值。源寄存器 1rs1第一个操作数的寄存器。源寄存器 2rs2第二个操作数的寄存器。功能码funct7与 funct3 合作确定是哪种具体的算术操作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操作码opcode 为 0110011表示这是一个 R-type 指令。目标寄存器rd 为 00111即寄存器 x7。功能码funct3 为 000与 funct7 一起确定这是一个加法操作。源寄存器 1rs1 为 00101即寄存器 x5。源寄存器 2rs2 为 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 架构下 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永远为0regs[0] 0;// 执行阶段switch (opcode) {case 0x13: { // 处理addi指令// 解析立即数将指令的最高20位视为符号扩展的立即数int64_t imm static_castint32_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::arraystd::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, 5addi x30, x0, 37add x31, x30, x29在汇编语言中.global _start 和 _start: 的语句定义了程序的入口点。 .global _start这条指令告诉链接器linker_start 标签是一个全局符号可以被程序的其他部分或其他链接的文件访问。更重要的是它标示 _start 为程序的入口点即程序执行的起始位置。这对于操作系统OS来说非常关键因为在程序被加载到内存并执行时操作系统需要知道从哪里开始执行程序。 _start:这是一个标签紧跟在它后面的是程序的入口点。在这个位置上编写的指令将会是程序执行的第一批指令。在一个裸机bare-metal环境或操作系统内核开发中_start 是执行流程的起点没有标准库或运行时环境的初始化过程。
综上所述.global _start 和 _start: 一起定义了程序开始执行的地方为操作系统提供了一个明确的起点来运行程序。如果不写的话会报错。
这段代码是使用 RISC-V 汇编语言编写的它执行了一个非常简单的任务计算两个数值的和并将结果存储。具体来说代码做了以下几件事情
将数字 5 存储到寄存器 x29。将数字 37 存储到寄存器 x30。将寄存器 x29 和 x30 中的数值相加将结果存储到寄存器 x31。
总结来说这段代码简单地计算了 5 和 37 的和然后将结果 42 存储到寄存器 x31 中。
将汇编转为二进制文件
$ riscv64-unknown-elf-gcc -Wl,-Ttext0x0 -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