热身:单周期指令模拟器
难度:⭐⭐
阅读时间:2h
预计代码时间:2h
在正式开始 Tomasulo 实验之前,为了让大家熟悉实验框架,我们首先来实现一个简单的“单周期”的 CPU 模拟器。
注意
本小节热身实验不计入分数。其目的仅在帮助大家实现一个对拍工具,供后续实验使用。同时让大家尽可能快地熟悉框架。
如果不需要以上帮助,可以直接跳到下一小节,开始正式的实验。
熟悉实验框架
指令译码和执行
在框架的 common/instructions.cpp
中,我们已经提供了 RV32IM 和 Zicsr 扩展的全部指令的译码,输出以及执行的逻辑。
将对应的指令的二进制表示传入到 Instruction 类的构造函数中,即可获得该指令的对应结构体,用于执行。
指令重载的 std::ostream
的 <<
运算符,可以直接使用 std::cout
等直接输出。
最后,我们还实现了 Instruction::execute
函数,用于执行对应指令。为了在后面的 tomasulo 实验中确保指令在正确的单元中执行,execute
函数额外添加了第一个参数,用于确认指令所在的执行单元。实验中涉及到的执行单元有以下几种:
- ALU,用于执行整数指令
- BRU,用于执行分支和跳转指令
- MUL,用于执行乘法指令
- DIV,用于执行除法和取余指令
- LSU,用于计算 Load/Store 指令的地址
- 正式实验中的 LSU 还会进行预访存
- 预访存是为了获得数据用于移位对齐和唤醒,只进行存储器读取,不会写入
- 如果有 cache 的话,情况可能会不一样
如果指令在错误的执行单元中执行,则会直接报错。
elf 加载
在框架的 common/utils.cpp
中,我们提供了 readElf
函数,该函数的的定义如下:
unsigned readElf(const std::string &elfFileName,
std::vector<unsigned> &instruction,
std::vector<unsigned> &data)
其中 elfFileName
是要读取的 elf 文件的名称。instruction
和 data
参数是两个 输出 的 std::vector
,分别对应程序的指令段和数据段。这个函数还会返回一个 unsigned
类型的变量,表示 elf 执行的入口地址。
由于实验框架最后要求实现的 CPU 的指令内存和数据内存是分离的,基地址分别为 0x80000000
和 0x80400000
,实验使用的 elf 需要由特殊的链接脚本生成。实验框架的 test
文件夹下提供了测试程序,链接脚本和生成 elf 使用的 CMakeLists.txt
。同学们在实验时,也可以自行编写程序进行实验和 debug。
在框架的 program/elf_test.cpp
中已经提供了一个解析 elf 的样板逻辑。同学们在实现单周期指令模拟器时,可以进行参考。
热身实验内容
请使用上述提到的框架代码内容,实现一个能够通过测试的单周期 CPU 模拟器。我们希望大家可以通过这个过程熟悉框架,同时可以将实现的简单模拟器用于后续实验的对拍。
详细实验步骤
-
在
program
文件夹中,创立新的single_cycle.cpp
,用于编写 单周期指令模拟器 的执行逻辑。 -
参考
program/elf_test.cpp
编写读入 elf 的逻辑,代码如下:
#include "defines.h"
#include <iostream>
#include <string>
int main() {
std::string name;
std::cout << "Elf file name: ";
std::cin >> name;
std::vector<unsigned> inst, data;
unsigned entry = readElf(name, inst, data);
return 0;
}
这样我们就可以获取到一个 elf 内的指令和数据信息了。
- 根据实验框架的要求,实现的 CPU 的数据内存和指令内存分离,指令内存的位置在
0x80000000
到0x80400000
之间;数据内存为0x80400000
到0x80800000
。因此,我们定义 PC,让其指向程序的入口地址,也就是 readElf 函数返回的 entry 位置,开始程序的执行。同时定义 RISC-V 规定的 32 个通用寄存器。注意,要根据文档对 sp 和 gp 寄存器进行特殊的初始化:
...
unsigned pc = entry;
unsigned regs[32] = {0};
regs[2] = 0x80800000; // initialize sp
regs[3] = 0x80400000; // initialize gp
...
- 现在我们就可以开始读取指令并且执行对应功能了。首先通过提供的
Instruction
类,读取当前 PC 对应的指令信息,并完成译码:
#include "instructions.h"
...
while(true) {
unsigned addr = (pc - 0x80000000) >> 2;
unsigned instData = ???? // read the data from the address
Instruction inst(instData);
inst.pc = pc;
inst.predictBundle.predictJump = false;
// 你可以使用我们事先编写好的输出函数,把指令打出来看一看:
std::cout << inst << std::endl;
}
-
在执行这条指令之前,我们要进行以下的步骤:
-
根据指令的寄存器字段,获取寄存器对应的函数值:
unsigned a = regs[inst.getRs1()], b = regs[inst.getRs2()];
-
确认指令对应的执行单元:
auto fuType = getFUType(inst);
-
根据不同的执行单元,分类进行处理:
bool endFlag = false; ExecuteResultBundle result; switch (getFUType(inst)) { case FUType::NONE: // EXIT 指令,跳出循环 endFlag = true; break; case FUType::ALU: result = inst.execute("ALU", a, b); break; case FUType::BRU: result = inst.execute("BRU", a, b); break; case FUType::LSU: result = inst.execute("LSU", a, b); break; case FUType::MUL: result = inst.execute("MUL", a, b); break; case FUType::DIV: result = inst.execute("DIV", a, b); break; default: throw std::runtime_error("Unknown FUType"); } if(endFlag) break;
-
参考
backend/execute_pipeline.cpp
完成代码逻辑和访存逻辑的编写,需要时修改寄存器。 -
结合 result 内的信息,完成可能的跳转或者
pc + 4
pc = result.actualTaken ? result.jumpTarget : pc + 4;
-
为了完成对拍,我们需要输出指令的执行 trace。trace 应该包含以下内容:
- 写寄存器的编号和数据
- 写内存的地址和数据
- 跳转目标地址
如果你额外实现了其他内容,向 CPU 添加了额外的状态信息,则最好也将其信息标准化,然后输出,便于进行比对。
-
正式实验中,你的 CPU 会接入一个延时可配置的简易内存模拟器上。如果你希望在该环境上进行测试,则请参考后续的实验文档进行接入。
-
除此之外,你还可以参考
program
文件夹下的其他程序,参考cxxopts
的使用,给你编写的对拍器添加命令行参数。 -
在
./CMakeLists.txt
中,参考其他程序添加 CMake 指令,将你的程序加入编译项目中,重新 cmake 进行编译即可。
以上,我们完成了一个用于对拍的单周期 RISC-V 模拟器的编写。相信大家通过以上的实验步骤,已经对实验框架有了一个初步的了解。接下来,我们就要正式开始 tomasulo 实验了。