diff --git a/articles/20230119-tinyemu-introduction.md b/articles/20230119-tinyemu-introduction.md new file mode 100644 index 0000000000000000000000000000000000000000..48ed2103f6e762fa7ae2473d1aaae1da5ea24a4b --- /dev/null +++ b/articles/20230119-tinyemu-introduction.md @@ -0,0 +1,234 @@ +> Corrector: [TinyCorrect](https://gitee.com/tinylab/tinycorrect) v0.1 - [spaces urls]
+> Author: YJMSTR
+> Date: 2023/01/19
+> Revisor: Bin Meng, Falcon
+> Project: [RISC-V Linux 内核剖析](https://gitee.com/tinylab/riscv-linux)
+> Sponsor: PLCT Lab, ISCAS + +# 从零开始,徒手写一个 RISC-V 模拟器(1)——简介与基本框架 + +## 前言 + +之前 [翻译了一篇博客][1],博主受到 [RVEMU 项目][2] 的启发,尝试通过用 C 语言写一个 RISC-V emulator,来学习 RISC-V 与计算机体系结构。但该博主在实现了 RISC-V 的整数、乘法和 Zicsr 模块之后便没有后续了。 + +本项目旨在延续该博主的工作,开发一个简单的教学用途 RISC-V 模拟器 —— TinyEMU,与此同时学习 RISC-V、计算机系统架构相关的知识。 + +## 模拟器与仿真器 + +2002 年全国科学技术名词审定委员会公布出版的《计算机科学技术名词》(第二版)把 simulation 翻译为模拟,emulation 翻译为仿真,造成了极大的混淆。我在翻译前言中提到的博客时也将 emulator 译为了模拟器。 + +模拟器(emulator)是使一个计算机系统(称为 host)表现得像另一个计算机系统(称为 guest)的硬件或软件,常用于调试,其使得 host 能够运行或使用为 guest 设计的软件或外设。 + +而仿真器(simulator)是对真实情景进行模拟的工具,但许多仿真器被译成了模拟器,比如 Flight Simulator 被译为了飞行模拟器。 + +正如 TinyEMU 的名字(Tiny Emulator)所示,它是一个迷你的 RISC-V 模拟器。 + +## 指令执行方式 + +目前模拟器 [执行指令的方式][4] 主要有以 Spike 为主的解释型,和以 QEMU 为主的翻译型。 + +解释型直接用高级语言模拟指令的行为,比如 RISC-V ISA 中的 `add rd, rs1, rs2` 指令,它的含义是将 rs1 和 rs2 寄存器中的值相加后存入寄存器 rd 中,在解释型的模拟器里可以直接进行模拟: + +```c +decode_result = decode(inst); //译码 +switch (decode_result) { + case ADD: {R(rd) = R(rs1) + R(rs2); pc += 4; break;} //执行 + ...; +} +``` + +解释型的优点是方便分析,缺点是性能较低。 + +翻译型的模拟器会将指令翻译成本机可以直接执行的指令序列,每次会翻译若干条指令,并一次性执行。其优点是运行速度快,可以结合编译技术对翻译过程进行优化,缺点是分析较为困难。 + +TinyEMU 采用的指令执行方式是解释型。 + +## 基本框架 + +要想运行模拟器,至少要实现本小节中包含的内容(监视器、内存、CPU)。 + +TinyEMU 的根目录结构如下: + +- TinyEmu/ + - src/ + - includes/ + - main.c + - Makefile + +### 监视器 + +监视器为 TinyEMU 提供与用户进行交互的命令行。监视器有一个主循环不断监听键盘输入,并根据键盘的输入执行对应的操作,我们将在 main.c 中实现它。 + +监视器还提供调试器,用于调试在 TinyEMU 中运行的客户程序。 + +要想实现和常见的命令行程序相同的基本功能,比如命令补全,快速输入历史命令等功能,可以使用 GNU Readline 库。如果想简单些也可以直接使用 scanf 读入命令进行处理。 + +监视器还要负责读入 guest 程序的路径。Guest 程序以二进制文件(.bin)的形式传入,存放于模拟器的内存中。 + +目前我们先为监视器实现如下基本功能: + +- q:退出 +- c:执行程序 +- r:读入程序 +- h:输出帮助文本 + +main.c 中的主循环如下: + +```c +while (1) { + scanf("%s", &opt); + switch (opt[0]) { + case 'q': + return 0; + case 'c': + cmd_c(); + case 'h': + cmd_h(); + case 'r': + cmd_r(); + default: + puts("invalid command"); + puts("input \'h\' for help"); + break; + } +} +``` + +其中 cmd_r() 的代码如下,它负责读入模拟器要执行的程序: + +```c +void cmd_r() { + scanf("%s", img_path); + FILE *fp = fopen(img_path, "rb"); + assert(fp); + fseek(fp, 0, SEEK_END); + long size = ftell(fp); + fseek(fp, 0, SEEK_SET); + fread(guest_to_host(RESET_VECTOR), fp, 1, size); + fclose(fp); +} +``` + +### 内存 + +DRAM 是系统内存,其中存放有指令和数据。此外,还需要为内存映射 IO(MMIO)留出足够的地址空间,以方便后续添加外设。 + +这里以 QEMU RISC-V 'virt' 平台作为参考,DRAM 的起始地址为 0x80000000,更低的地址留给外设。RESET_VECTOR 的地址默认为 DRAM 起始地址: + +```c +#define DRAM_SIZE 1024*1024*128ull //128 MiB +#define DRAM_BASE 0x80000000ull +#ifndef RESET_VECTOR_OFFSET +#define RESET_VECTOR_OFFSET 0 +#endif +#define RESET_VECTOR DRAM_BASE + RESET_VECTOR_OFFSET +``` + +如果之后要运行 xv6 等特定程序,需要对内存大小进行对应修改。 + +在真实的计算机系统中,信息流是通过总线进行传输的。而在模拟器中 CPU 等设备要想取得内存中的数据,可以直接访问内存,不需要经由总线。 + +访存函数实现如下: + +```c +void dram_write(uint64_t addr, int length, uint64_t val) { + assert (length == 1 || length == 2 || length == 4 || length == 8); + switch (length) { + case 1: + dram[addr] = val & 0xff; + return; + case 2: + dram[addr] = val & 0xff; + dram[addr + 1] = (val & 0xff00) >> 8; + return; + case 4: + dram[addr] = val & 0xff; + dram[addr + 1] = (val & 0xff00) >> 8; + dram[addr + 2] = (val & 0xff0000) >> 16; + dram[addr + 3] = (val & 0xff000000) >> 24; + return; + case 8: + dram_write(addr, 4, val & 0xffff); + dram_write(addr + 4, 4, (val & 0xffff0000) >> 32); + return; + } +} + +uint64_t dram_read(uint64_t addr, int length) { + assert (length == 1 || length == 2 || length == 4 || length == 8); + switch (length) { + case 1: + return dram[addr]; + case 2: + return (dram[addr + 1] << 8) | dram[addr]; + case 4: + return (dram[addr + 3] << 24) | (dram[addr + 2] << 16) | (dram[addr + 1] << 8) | (dram[addr]); + case 8: + return (dram_read(addr + 4, 4) << 32) | dram_read(addr, 4); + } +} +``` + +这里使用 `mem_read` 等辅助函数将 guest 程序的内存地址映射到 TinyEMU 的内存数组的下标中: + +```c +uint64_t mem_read(uint64_t addr, int length) { + return dram_read(addr - DRAM_BASE, length); +} + +void mem_write(uint64_t addr, int length, uint64_t val) { + dram_write(addr - DRAM_BASE, length, val); +} +``` + +### CPU + +CPU 部分包括 RISC-V 通用寄存器组。CPU 需要实现取指、译码、执行这几个步骤: + +- 取指:根据 PC 寄存器中的值在指令内存中取得数据,存到指令寄存器 IR 中。RISC-V 使用小端序存储数据,低地址存放低字节。 +- 译码:根据机器码判断是哪种类型的哪一条指令,并根据指令类型提取指令中的寄存器编号、立即数等信息。译码结果存放在 `DECODER` 结构体中。 +- 执行:直接用 C 代码模拟指令的行为。 + +CPU 结构体如下: + +```c +typedef struct CPU { + uint64_t regs[32]; + uint64_t pc; +} CPU; +``` + +可以将指令的译码结果封装成结构体 `DECODER`,将提取出的立即数,源地址,目的地址等数据存在里面供模拟该指令行为的函数使用。 + +取指->译码->执行三级流水: + +```c +void exec_once(CPU *cpu) { // CPU 执行一条指令 + uint32_t inst = inst_fetch(cpu); //取指 + DECODER decoder = decode(inst); //译码 + inst_handle[decoder->inst_name](&decoder);// 执行 + cpu->pc = decoder->dnpc; //更新 PC +} +``` + +其中 `inst_handle` 是存放函数指针的数组,`inst_handle[decoder->inst_name](&decoder)` 是执行指令的函数,`DECODER decoder` 作为该函数的参数。执行函数会对 `decoder` 进行修改,最终处理器根据执行结果对 PC 进行更新。 + +## 总结 + +本文介绍了模拟器与仿真器的区别,模拟器的指令执行方式以及 TinyEMU 的基本框架,主要包扩监视器、内存、CPU 三大模块。其中监视器负责提供和用户进行交互的命令行,以及提供调试客户程序的功能;内存是一个大数组,存放有要执行的指令。CPU 具有经典的取指、译码、执行三级流水。 + +下一篇文章将进一步介绍 RISC-V 指令集与 CPU 模块、并在模拟器上运行程序。 + +## 参考资料 + +1. [用纯 C 语言写一个简单的 RISC-V 模拟器(支持基础整数指令集,乘法指令集与 CSR 指令)][1] +2. [RVEMU][2] +3. [RVEMU 开发教程][3] +4. [【余子濠】NEMU:一个效率接近 QEMU 的高性能解释器 - 第一届 RISC-V 中国峰会][4] +5. [NEMU][5] + +[1]: https://tinylab.org/writing-a-simple-riscv-emulator-in-plain-c/ +[2]: https://github.com/d0iasm/rvemu +[3]: https://book.rvemu.app/ +[4]: https://www.bilibili.com/video/BV1Zb4y1k7RJ +[5]: https://github.com/NJU-ProjectN/nemu \ No newline at end of file