NEMU运行逻辑
这里简单梳理一下NEMU
的生命周期内都干了些什么.
从main函数看, 基本可以分成三个阶段:
// 简化版本
int main(int argc, char *argv[]) {
init_monitor(argc, argv); // 初始化模拟器
engine_start(); // 开始运行
return is_exit_status_bad(); // 结束运行, 判断结果
}
初始化
实际上进入init_monitor
函数就能很清晰的看到整个初始化过程都做了些什么事.
先由parse_args
处理输入参数, 并保存到对应变量, 后续有个任务也需要看懂这个函数才能完成对应操作.
init_rand
主要是为初始化内存时提供随机值的功能, 内存会被初始化为类似-0x12121212
,0xa8a8a8a8
的格式.init_log
会将输出保存到文件.init_mem
初始化内存, 本质上就是把一个uint8的数组全部填充为随机值, 随- 机值的功能可以在menuconfig
中关闭.init_device
比较有意思, 在PA2
后续内容中会接触到, 现在只需要知道是- 初始化虚拟设备的就行了.init_isa
与load_img
是初始化isa, 将cpu的各个寄存器都进行赋值, 并将- 程序搬运到内存的指定位置.- 后面的几个都是与模拟器调试相关的, difftest用来做对比测试, 不建议提前开- 启, 多练练自行debug的能力.
- sdb在PA1时应该接触很多了, 略.
init_disasm
是反汇编, 也就是在使用si命令时打印信息里看到的内容, 比如auipc t0, 0
, 在调试程序时很好用.
void init_monitor(int argc, char *argv[]) {
/* Perform some global initialization. */
/* Parse arguments. */
parse_args(argc, argv);
/* Set random seed. */
init_rand();
/* Open the log file. */
init_log(log_file);
/* Initialize memory. */
init_mem();
/* Initialize devices. */
IFDEF(CONFIG_DEVICE, init_device());
/* Perform ISA dependent initialization. */
init_isa();
/* Load the image to memory. This will overwrite the built-in image. */
long img_size = load_img();
/* Initialize differential testing. */
init_difftest(diff_so_file, img_size, difftest_port);
/* Initialize the simple debugger. */
init_sdb();
IFDEF(CONFIG_ITRACE, init_disasm());
/* Display welcome message. */
welcome();
}
运行
直接看engine_start
会发现就一个sdb_mainloop
函数, 等待用户输入后执行我们在PA1中完成的功能.
在我们实现的所有的功能里, 有一个功能很神秘特殊, 那就是cpu_exec
, 可以说这个函数就是整个nemu
模拟硬件运行的核心. 通过它就能让程序在程序上运行起来.
直接拆开来看, 结构也很好懂, 简化一下:
void cpu_exec(uint64_t n) {
// 判断状态
switch() {
...
}
// 计时
uint64_t timer_start = get_time();
// 执行
execute(n);
// 计时
uint64_t timer_end = get_time();
// 判断状态
switch() {
...
}
}
再次剥开execute
会发现依然差不多, 无非是判断状态换为了difftest
和更新设备.
内部的exec_once
才是运行的部分, 再次进入, 看到一大堆用于反编译的代码与isa_exec_once
这个函数.
恭喜你, 终于快要看到整个nemu的精髓了.
int isa_exec_once(Decode *s) {
s->isa.inst.val = inst_fetch(&s->snpc, 4);
return decode_exec(s);
}
isa_exec_once
内总共就干了两件事:
inst_fetch
: 获取下一条指令decode_exec
: 执行对应计算
inst_fetch
比较简单, 就是根据输入的pc, 返回对应内存地址存储的值返回.
decode_exec
就比较头疼了, 因为一上来就会看到一大段宏, 直接到头文件中看必定懵逼.
不过不用慌, 先看明白其他部分, 然后一步一步看下去, 理清思路就好.
#define R(i) gpr(i) // 用于获取cpu寄存器值
#define Mr vaddr_read // 读取内存
#define Mw vaddr_write // 写入内存
// 指令类型枚举
enum {
TYPE_I, TYPE_U, TYPE_S,
TYPE_N, // none
};
#define src1R() do { *src1 = R(rs1); } while (0) // 获取rs1的值
#define src2R() do { *src2 = R(rs2); } while (0) // 获取rs2的值
// 获取各类型指令中的立即数的值(缺少J, B类型)
#define immI() do { *imm = SEXT(BITS(i, 31, 20), 12); } while(0)
#define immU() do { *imm = SEXT(BITS(i, 31, 12), 20) << 12; } while(0)
#define immS() do { *imm = (SEXT(BITS(i, 31, 25), 7) << 5) | BITS(i, 11, 7); } while(0)
/*
BITS可以将某个值的某一段取出来转为数值, SEXT是对一个数进行符号扩展
*/
static void decode_operand(Decode *s, int *rd, word_t *src1, word_t *src2, word_t *imm, int type) {
uint32_t i = s->isa.inst.val; // 获取指令
int rs1 = BITS(i, 19, 15); // 得到rs1的编号
int rs2 = BITS(i, 24, 20); // 得到rs2的编号
*rd = BITS(i, 11, 7); // 得到rd的编号
switch (type) { // 根据指令类型获取编号对应寄存器的值与立即数的值(缺少J, B类型)
case TYPE_I: src1R(); immI(); break;
case TYPE_U: immU(); break;
case TYPE_S: src1R(); src2R(); immS(); break;
}
}
看明白其他部分后, 哪怕你现在还不能理解那些宏是怎么工作的, 你应该也差不多能明白decode_exec
在干什么了吧.
前段部分先定义变量, 初始化s->dnpc
(可简单理解为下一条指令)的值为s->snpc
(在上一轮执行结束时被设置为s->dnpc + 4).
static int decode_exec(Decode *s) {
int rd = 0;
word_t src1 = 0, src2 = 0, imm = 0;
s->dnpc = s->snpc;
#define INSTPAT_INST(s) ((s)->isa.inst.val)
#define INSTPAT_MATCH(s, name, type, ... /* execute body */ ) { \
decode_operand(s, &rd, &src1, &src2, &imm, concat(TYPE_, type)); \
__VA_ARGS__ ; \
}
INSTPAT_START();
INSTPAT("??????? ????? ????? ??? ????? 00101 11", auipc , U, R(rd) = s->pc + imm);
INSTPAT("??????? ????? ????? 100 ????? 00000 11", lbu , I, R(rd) = Mr(src1 + imm, 1));
INSTPAT("??????? ????? ????? 000 ????? 01000 11", sb , S, Mw(src1 + imm, 1, src2));
INSTPAT("0000000 00001 00000 000 00000 11100 11", ebreak , N, NEMUTRAP(s->pc, R(10))); // R(10) is $a0
INSTPAT("??????? ????? ????? ??? ????? ????? ??", inv , N, INV(s->pc));
INSTPAT_END();
R(0) = 0; // reset $zero to 0
return 0;
}
这一段直接看是没法明白的, 但是我们通过修改scripts/build.mk
, 在@$(CC) $(CFLAGS) -c -o $@ $<
下方添加一行@$(CC) $(CFLAGS) -E -o $(patsubst %.o, %.i, $@) $<
后, 我们就能在build
文件夹中找到预编译文件, 也就是宏展开后的结果, 然后就很清晰明了了.
通过pattern_decode
获取三个用于判断的标志, 若指令满足条件, 则将指令进行解码获取执行指令所需数据, 并执行我们写好的操作.
最后会跳转到末尾, 也就是INSTPAT_END宏的位置.
// INSTPAT_START宏展开
{ const void ** __instpat_end = &&__instpat_end_;;
// auipc宏展开
do {
uint64_t key, mask, shift;
pattern_decode(
"??????? ????? ????? ??? ????? 00101 11",
(sizeof("??????? ????? ????? ??? ????? 00101 11") - 1),
&key,
&mask,
&shift
);
if ((((uint64_t)((s)->isa.inst.val) >> shift) & mask) == key) { {
decode_operand(s, &rd, &src1, &src2, &imm, TYPE_U);
(cpu.gpr[check_reg_idx(rd)]) = s->pc + imm;
};
goto *(__instpat_end);
}
} while (0);
// INSTPAT_END宏展开
__instpat_end_: ; };
结束
结束的代码比较简单, 就是判断一下nemu的状态然后返回对应的值, 让外部的脚本或程序得到此次模拟是否正常结束.
任务
PA2就开始进入正片了(一些简单的题会被略过), 编程占比开始变大, 需要查看学习的手册也会变多.
运行第一个C程序
跟随手册运行程序, 一定会看到报错.
此时只需要查看cpu-test/build
中反汇编文件找到具体是哪条指令, 添加支持后再次运行即可, 反复运行完善就能将除了string
和hello-str
的程序全部运行成功.
这一段对有些人来说可能有些枯燥, 反复测试、运行、补缺、查手册, 但是能极大的提升你对所选isa的理解程度.
如果卡在一个地方比较久, 不妨完善一下sdb的功能, 提高的工具能力.
或者去重新阅读一下手册, 检查检查是否有什么细节漏下了.
如果你不想看官方的英文原版, 甚至不想看官方文档, 可以试试包老师团队翻译的《RISC-V开放架构设计之道》入门.
提示
来自2024年的补充: 突然发现PA的手册本身变友好了, 很多代码都有了很详细的解析, 要是还是不知道如何添加指令支持, 建议多读读上文与PA的手册
程序, 运行时环境与AM
这一段比较难理解, 但是任务都挺简单的.
这里还是对整个过程梳理一遍.
我们都知道, 一个普通的c语言程序完成之后, 需要编译器编译后才能运行.
在不同的机器, 不同的操作系统上, 普通的二进制程序是不互通的, 你可以试试将linux上的helloworld复制到windows上运行, 你会得到报错(某些情况例如WSL是可以兼容windows的程序, 一些工具也能跨平台运行, 这些不在考虑范围内). 如果我们想要让程序运行在nemu上, 会需要对应的交叉编译器, 也就是可以在x86的linux上编译出riscv32/64程序的编译器.
当我们敲下make ARCH=$ISA-nemu ALL=dummy run
时, 整个cpu-test
项目做了非常多的事情:
- 从
ARCH
中获取目标平台信息, 假设结果为在nemu
上运行的riscv32e
程序 - 获得需要编译的代码文件, 此处为
dummy.c
- 将信息传给
${AM_HOME}/Makefile
, 即abstract-machine/Makefile
接下来就进入了一个陌生的领域: abstract-machine
.
如果你实现inst.c
时考虑了riscv32
, riscv64
, riscv32e
三种情况, 得到了一个可以运行三种程序的nemu.
那么你会发现ARCH无论设置为哪一个, 最终都能在nemu上运行.
只要你有时间, 甚至能通过改变ARCH与nemu配置, 运行x86
版本.
同样的代码, 仅选项不同, 就能编译成不同架构上的程序, 这就是abstract-machine
的基本功能.
在后续实现中你会发现不止这些, abstract-machine
还为程序提供了一套运行的环境, 将很多功能进行了抽象.
想要支持新架构, 只需要在abstract-machine
添加很少的驱动代码, 完成nemu的指令支持, 就能直接运行已有的程序.
这里还是拿make ARCH=$ISA-nemu ALL=dummy run
举例:
- 得到
cpu-test
传过来的参数 - 找到属于
riscv32e
的脚本 - 使用
klib
、riscv32e
与nemu
的库和dummy.c
编译生成结果
在am/src
中, 存有平台库(platform)与架构库(riscv、x86、mips), klib
则是通用库. 一个dummy.c
与不同的库就能生成不同的程序, 但功能一样, 很有意思吧.
阅读Makefile, 通过批处理模式运行NEMU
实际上前面已经将基本过程都提到了, 只是没有对照Makefile
进行解析.
我继续用make ARCH=$ISA-nemu ALL=dummy run
举例, 直接看Makefile
比较麻烦, 咱可以喂给ai帮助分析, 不过还有更简单的方法.
使用-nB
选项可以只打印出make指令执行的步骤, 但是不实际执行.
/bin/echo -e "NAME = dummy\nSRCS = tests/dummy.c\ninclude ${AM_HOME}/Makefile" > Makefile.dummy
if make -s -f Makefile.dummy ARCH=riscv32e-nemu run; then \
printf "[%14s] \033[1;32mPASS\033[0m\n" dummy >> .result; \
else \
printf "[%14s] \033[1;31m***FAIL***\033[0m\n" dummy >> .result; \
fi
rm -f Makefile.dummy
echo "test list [1 item(s)]:" dummy
cat .result
rm .result
这样看稍微有些杂乱, 但是已经比较好理解了.
你也可以直接执行一下看看这些命令都会发生什么.
就比如第一行, 会向Makefile.dummy
文件写入:
NAME = dummy
SRCS = tests/dummy.c
include abstract-machine/Makefile
第二行是一段shell脚本, 根据条件打印结果, 仔细看就只有判断条件本身比较重要.
在有Makefile.dummy
的前提下, 运行make -s -f Makefile.dummy ARCH=riscv32e-nemu run
, 你会发现正确运行了dummy
程序, 只是少了PASS的提示信息, Makefile.dummy
也残留下来没被删除.
正好对应了后面的命令.
到这里你也应该知道接下来该干什么了吧.
继续使用-nB
选项, 我们得到了超多输出, 但是不要慌, 我们可以用管道将输出信息转为文件: make -s -f Makefile.dummy ARCH=riscv32e-nemu run -nB > log
.
打开文件还是很难理解查看, 使用工具(sed)整理一下, 比如删掉对我们理解没有帮助的mkdir信息, 将空格转为回车加缩进的形式, 重复项只保留一个等:
// 简单整理
# Building dummy-run [riscv32e-nemu]
make -s -C abstract-machine/am archive
# Building am-archive [riscv32e-nemu]
riscv64-linux-gnu-gcc -std=gnu11 -O2 -MMD -Wall -Werror -Iabstract-machine/am/src -Iabstract-machine/am/include -Iabstract-machine/am/include/ -Iabstract-machine/klib/include/ -D__ISA__=\"riscv32e\" -D__ISA_RISCV32E__ -D__ARCH__=riscv32e-nemu -D__ARCH_RISCV32E_NEMU -D__PLATFORM__=nemu -D__PLATFORM_NEMU -DARCH_H=\"arch/riscv.h\" -fno-asynchronous-unwind-tables -fno-builtin -fno-stack-protector -Wno-main -U_FORTIFY_SOURCE -fvisibility=hidden -fno-pic -march=rv64g -mcmodel=medany -mstrict-align -march=rv32em_zicsr -mabi=ilp32e -static -fdata-sections -ffunction-sections -Iabstract-machine/am/src/platform/nemu/include -DMAINARGS_MAX_LEN=64 -DMAINARGS_PLACEHOLDER=\""The insert-arg rule in Makefile will insert mainargs here."\" -DISA_H=\"riscv/riscv.h\" -c -o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/trm.o abstract-machine/am/src/platform/nemu/trm.c
echo + AR "->" build/am-riscv32e-nemu.a
riscv64-linux-gnu-ar rcs abstract-machine/am/build/am-riscv32e-nemu.a abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/trm.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/ioe.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/timer.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/input.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/gpu.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/audio.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/ioe/disk.o abstract-machine/am/build/riscv32e-nemu/src/platform/nemu/mpe.o abstract-machine/am/build/riscv32e-nemu/src/riscv/nemu/start.o abstract-machine/am/build/riscv32e-nemu/src/riscv/nemu/cte.o abstract-machine/am/build/riscv32e-nemu/src/riscv/nemu/trap.o abstract-machine/am/build/riscv32e-nemu/src/riscv/nemu/vme.o
make -s -C abstract-machine/klib archive
# Building klib-archive [riscv32e-nemu]
riscv64-linux-gnu-gcc -std=gnu11 -O2 -MMD -Wall -Werror -Iabstract-machine/klib/include -Iabstract-machine/am/include/ -Iabstract-machine/klib/include/ -D__ISA__=\"riscv32e\" -D__ISA_RISCV32E__ -D__ARCH__=riscv32e-nemu -D__ARCH_RISCV32E_NEMU -D__PLATFORM__=nemu -D__PLATFORM_NEMU -DARCH_H=\"arch/riscv.h\" -fno-asynchronous-unwind-tables -fno-builtin -fno-stack-protector -Wno-main -U_FORTIFY_SOURCE -fvisibility=hidden -fno-pic -march=rv64g -mcmodel=medany -mstrict-align -march=rv32em_zicsr -mabi=ilp32e -static -fdata-sections -ffunction-sections -Iabstract-machine/am/src/platform/nemu/include -DMAINARGS_MAX_LEN=64 -DMAINARGS_PLACEHOLDER=\""The insert-arg rule in Makefile will insert mainargs here."\" -DISA_H=\"riscv/riscv.h\" -c -o abstract-machine/klib/build/riscv32e-nemu/src/string.o abstract-machine/klib/src/string.c
...
整个文件中, 可以看到几行比较重要的命令
- 链接命令
- 程序: dummy.o
- am库和nemu库: am-riscv32e-nemu.a
- klib库: klib-riscv32e-nemu.a
riscv64-linux-gnu-ld
-z noexecstack
-Tabstract-machine/scripts/linker.ld
-melf64lriscv
--defsym=_pmem_start=0x80000000
--defsym=_entry_offset=0x0
--gc-sections
-e _start
-melf32lriscv
-o am-kernels/tests/cpu-tests/build/dummy-riscv32e-nemu.elf
--start-group
am-kernels/tests/cpu-tests/build/riscv32e-nemu/tests/dummy.o
abstract-machine/am/build/am-riscv32e-nemu.a
abstract-machine/klib/build/klib-riscv32e-nemu.a
--end-group
- 运行nemu
make
-C
nemu
ISA=riscv32e
run
ARGS="-l am-kernels/tests/cpu-tests/build/nemu-log.txt -e am-kernels/tests/cpu-tests/build/ dummy-riscv32e-nemu.elf -b"
IMG=am-kernels/tests/cpu-tests/build/dummy-riscv32e-nemu.bin
nemu/build/riscv32-nemu-interpreter
-l am-kernels/tests/cpu-tests/build/nemu-log.txt
-e am-kernels/tests/cpu-tests/build/dummy-riscv32e-nemu.elf
-b // 这一项需要自行实现 默认没有
am-kernels/tests/cpu-tests/build/dummy-riscv32e-nemu.bin
这样看就非常清晰了, 将各个部分编译链接后, 传给nemu, nemu得到程序文件后存入数组, 就完成了整个流程.
所以实际上只需要在传输的信息中加上“批处理”的选项.
找到scripts/platform/nemu.mk
中关于nemu选项的部分.
NEMUFLAGS += -l $(shell dirname $(IMAGE).elf)/nemu-log.txt
NEMUFLAGS += -e $(IMAGE).elf
NEMUFLAGS += 批处理选项
这里填什么实际上在nemu的src/monitor/monitor.c
中有很明显的提示, 大部分都帮你实现好了.
// 已添加好的批处理选项
const struct option table[] = {
{"batch" , no_argument , NULL, 'b'}, // batch -> 批处理
{"log" , required_argument, NULL, 'l'},
{"diff" , required_argument, NULL, 'd'},
{"port" , required_argument, NULL, 'p'},
{"help" , no_argument , NULL, 'h'},
{"elf" , required_argument, NULL, 'e'},
{0 , 0 , NULL, 0 },
};
// 已设置好的批处理模式的函数
sdb_set_batch_mode();
接下来应该做什么我就不继续了, 很简单的.
实际上是我懒
实现字符串处理函数
比较简单, 现在的ai都能帮你完美完成, 略.
实现printf
这个推荐看看jyy老师的这节课C 标准库设计与实现. 不用看完, 只用看C标准库中实现printf
的思路, 在视频的48:08
.