2024 NJU PA1.1
1. 在native上运行超级玛丽
FCEUX是任天堂游戏的模拟器,最知名的任天堂游戏必然是超级玛丽(怀念逝去的青春)。fceux-am是fceux在nemu上的移植。首先下载游戏文件,并复制到fceux-am/nes/rom
中。
克隆am-kernels
后,在目录fceux-am
中执行以下命令,就能在主机(ARCH=native
)运行这个游戏:
make ARCH=native run mainargs=mario
会弹出超级玛丽的游戏界面(README.md文件内有操作说明):
此后,将逐步实现支撑此游戏运行的各个模块。
2. NEMU初探
首先,创建一个名为pa1的分支:
git checkout -b pa1
main
函数位于$NEMU_HOME/src/nemu-main.c
(目前CONFIG_TARGET_AM
未定义,此函数实际调用了init_monitor
):int main(int argc, char *argv[]) {
#ifdef CONFIG_TARGET_AM
am_init_monitor();
#else
init_monitor(); // 初始化
#endif
engine_start(); // 启动引擎
return is_exit_status_bad();
}
init_monitor
此函数用于初始化,具体实现位于文件$NEMU_HOME/src/monitor/monitor.c
:
void init_monitor(int argc, char* argv[]) {
parse_args(argc, argv); // 解析参数
init_rand(); // 初始化随机数种子
init_log(log_file); // 打开记录日志的文件
init_mem(); // 初始化内存
IFDEF(CONFIG_DEVICE, init_device()); // 初始化设备
init_isa(); // 执行特定ISA的初始化
long img_size = load_img(); // 加载镜像文件
...
init_sdb(); // 初始化sdb(simple debugger)
...
welcome();
}
随便看看即可,目前不必深究。
engine_start
engine_start
运行NEMU,此函数定义于$NEMU_HOME/engine/interpreter/init.c
:void engine_start() {
#ifdef CONFIG_TARGET_AM
cpu_exec(-1);
#else
sdb_mainloop();
#endif
}
sdb_mainloop
中,出现了“奇怪”的缩写sdb, 不免让人联想到gdb,确实如此,sdb是simple debugger的缩写。在PA1中,需要实现一个简单的调试器,虽然sdb这个名字还是一如既往地草率。sdb_mainloop
定义于$NEMU_HOME/src/monitor/sdb/sdb.c
, 不难看出,它的主要功能是与用户交互:读取用户输入→执行用户输入→..., 如此反复,这也是一个debugger的基本功能。当终端输出(nemu)
提示符后,用户可以输入各种指令,sdb_mainloop
负责解析这些指令,并执行相应功能。cmd_table
中查找,而cmd_table
就定义在此函数的上方:static struct {
...
} cmd_table [] = {
{ ... },
{ "c", "Continue ...", cmd_c },
{ "q", "Exit NEMU", cmd_q },
/* TODO: Add more commands */
}
int (char*)
型函数,此函数接收一个字符串参数(因为有的指令带有多个参数),很显然,指令功能的具体实现就是它了。注释中的TODO
暗示着接下来的工作量之大。3. NEMU如何模拟CPU
框架实现了几个简单的sdb功能,例如指令c, 其功能是让程序继续运行,其实现位于函数cmd_c
,可以看到,cmd_c
不过是简单地调用了cpu_exec(-1)
. 这样看来,CPU运行的奥秘就藏在函数cpu_exec
中。
从原理上来说,CPU的功能无非是一条接一条地执行指令。但是请注意,NEMU模拟了不同的体系架构,而不同架构的指令千差万别,因此需要将一条指令的具体执行通过某个函数抽象出来,这个函数正是isa_exec_once
.
cpu_exec
的执行流程如下:
cpu_exec
cpu_exec(n)
的主要功能是执行n条指令,这一功能交给函数execute(n)
完成。多了一层封装,还做了另外两件事情:- 在调用
execute(n)
前后检查NEMU的运行状态(即nemu_state.state
); - 计算调用
execute(n)
耗费的时间(保存在变量g_timer
中),以测量CPU的性能。
为何g_timer
以g_
开头?这是一种古老的命名方式,以g_
开头表示此变量为全局(global)变量。类似地,在C++中,也有人习惯将类的成员以m_
开头,以区分其它类型的变量。
execute(n)
后,对NEMU状态的检查。记住这里的"HIT GOOD TRAP"
以及"HIT BAD TRAP"
, 以后你会爱上这两句话的。execute
n
次,每次调用函数exec_once
,执行一条指令. 这里出现了变量cpu.pc
, 无论哪种体系架构,CPU必然有PC寄存器,因此这种抽象是架构无关的。exec_once
后还会检查NEMU的状态(if (nemu_state.state != NEMU_RUNNING)
)。这个很容易理解:可能程序没执行到n
条指令就结束了(例如,程序本身就没有n
条指令),在这种情况下,程序结束运行后应当跳出循环。exec_once
exec_once
用于执行单条指令,此函数的主要功能是调用isa_exec_once
. exec_once
与riscv32的exec_once
必然有着不同的实现。NEMU将此过程抽象为函数exec_once
,与指令集相关的代码写在函数isa_exec_once
中,之后根据编译的配置链接相应的代码。(例如,我们选择的是riscv32指令集,只要实现基于riscv32的isa_exec_once
,并在编译时链接此函数即可。)exec_once
的功能很简单:- 设置结构体
Decode
中成员pc的值; - 调用体系架构相关的函数
isa_exec_once(s)
.
头文件在哪里?文件nemu/src/cpu/cpu-exec.c
包含了头文件cpu/cpu.h
,cpu/decode.h
,cpu/difftest.h
和locale.h
,那么这些头文件的具体位置在哪儿呢?查看目录nemu/include
,这些头文件都在此目录中(locale.h
除外,暂时不去管它)。例如头文件cpu/cpu.h
,对应的就是文件nemu/include/cpu/cpu.h
.只要在编译时将nemu/include
指定为查找头文件的目录,当编译器遇到尖括号括起的文件时(例如这里的#include <cpu/cpu.h>
), 就会在这些指定的目录中寻找匹配的文件。
isa_exec_once
nemu/src/isa
中,查看此目录,里面包含mips32、riscv32等子目录。由于我们需要实现riscv32指令集,具体的实现应该在目录riscv32中。果然,在nemu/src/isa/riscv32/inst.c
中找到了函数isa_exec_once
.“歪门邪道”之grep大法
子目录riscv32包含一定数量的文件,如何得知函数isa_exec_once
一定在文件inst.c
中?其实,我使用了一个名为grep的查找工具。它可以查找一个字符串在哪些文件中,例如:grep -r "isa_exec_once"
选项-r
表示从当前目录开始,递归查找子目录。查找的结果如下:就这么几个文件中出现了isa_exec_once
, 哪个是我们需要的就一目了然啦。用grep搜索字符串"TODO"
,从而对未来的工作量有一个初步的认识:唉,伟大无需多言,一行TODO
,一行辛酸泪。
$NEMU_HOME/src/isa/riscv32/inst.c
,在文件底部找到riscv32中函数isa_exec_once
的具体实现:int isa_exec_once(Decode *s) {
s->isa.inst.val = inst_fetch(&s->snpc, 4);
return decode_exec(s);
}
inst_fetch
, inst应当是instruction(指令)一词的缩写,fetch有提取的意思,所以此函数的功能就是取指令。在头文件$NEMU_HOME/include/cpu/ifetch.h
中找到了此函数的具体实现,仅3行代码:static inline uint32_t inst_fetch(vaddr_t *pc, int len) {
uint32_t inst = vaddr_ifetch(*pc, len);
(*pc) += len;
return inst;
}
inst_fetch
读取指令后,接下来需要解码指令,此功能位于函数decode_exec
:它向传入的结构体Decode
写入一些值,暂时不管它。提示冗长的文字很容易让人摸不着头绪。但只要记住以下两点:
- CPU的功能是一条接一条地执行指令;
- NEMU对不同的体系结构加以抽象,因此包含架构无关、架构相关这两部分的代码。
或许对理解NEMU有所帮助。
4. PA 1.1
在$NEMU_HOME
下运行make menuconfig
,启用Build Options → Enable debug information:
选项前出现*
表示选中此项,选中后保存并退出,之后make clean
& make run
即可。
启用Enable debug information后有何不同?
打开
$NEMU_HOME/Kconfig
,在118行左右,当此项被启用后,CC_DEBUG
将被置为y. 最后导致Makefile中的CFLAGS_BUILD += $(if $(CONFIG_CC_DEBUG),-Og -ggdb3,)
被启用,即启用编译器的调试选项。
PA1主要实现调试工具,在PA1.1中,需要实现的sdb指令有以下3个:
指令名称 | 功能 | 示例 | 备注 |
si [N] |
执行N 条指令 |
si 5 |
未指定N 时,默认为1 |
info [cmd] |
|
||
x [N] [expr] |
计算expr 的值,将其作为起始地址,输出从此地址开始的4*N 个字节 |
x 10 $esp |
具体实现于文件$NEMU_HOME/src/monitor/sdb/sdb.c
中。
si: 单步执行
调用cpu_exec
即可,无非多了个处理参数的步骤。首先,在cmd_table
中添加一行:
cmd_table [] = {
...
/* TODO: Add more commands */
{ "si", "Step into n instructions", cmd_si},
};
之后实现函数cmd_si
:
static int cmd_si(char *args) {
int n_inst = 1; // 默认执行1条指令
if (args != NULL) { // 用户指定了参数
if (sscanf(args, "%d", &n_inst) < 1) {
printf("error: invalid input %s, expecting an integer\n", args);
return 0;
}
if (n_inst <= 0) {
printf("error: invalid input %s, expecting a positive integer\n", args);
return 0;
}
}
cpu_exec(n_inst); // 执行n_inst条指令
return 0;
}
以上代码仅供参考,它并没有严格检查输出,例如输入si 2.2
,最终会解释为si 2
. 使用atoi
之类的函数亦可。
重新编译NEMU,测试si
是否正确实现:
info r: 输出寄存器信息
和si
的实现一样,首先在cmd_table
中添加一行:
{ "info", "Print program status", cmd_info},
之后实现函数cmd_info
即可。目前我们只需要实现info r
(之后还需要实现info w
).
info r
用于查看寄存器,可问题是,不同架构的寄存器连名字都不一样。为了抽象不同架构,NEMU将打印寄存器这一功能封装为函数isa_reg_display
,只需要在cmd_info
中调用此函数即可:
static int cmd_info(char *args) {
if (strcmp(args, "r") == 0) {
// info r: 打印寄存器状态
isa_reg_display();
}
else {
printf("error: invalid command %s\n", args);
}
return 0;
}
但天底下哪有那么好的事情,我们需要自行实现riscv32版本的isa_reg_display
. 在此之前,需要简单了解riscv32的CPU定义。
riscv32的CPU结构体
不同架构的CPU差别极大,想要将它们抽象成一个通用的结构体基本上不可能。因此,在NEMU中,仅仅声明了
CPU_state
结构体,并没有给出定义,编译时将此声明链接到指定架构的结构体定义。例如,在riscv32中,此结构体将会链接到结构体riscv32_CPU_state
(定义于$NEMU_HOME/src/isa/riscv32/include/isa-def.h
):typedef struct { word_t gpr[MUXDEF(CONFIG_RVE, 16, 32)]; vaddr_t pc; } MUXDEF(CONFIG_RV64, riscv64_CPU_state, riscv32_CPU_state);
目前只定义了通用寄存器(
gpr
数组,general purpose register)和PC寄存器(pc
).
isa_reg_display
定义于文件$NEMU_HOME/src/isa/riscv32/reg.c
. 此文件中,定义了寄存器名称的字符串数组regs
, 以及通过寄存器名称获取寄存器值的辅助函数isa_reg_str2val
,首先实现此函数:
word_t isa_reg_str2val(const char *s, bool *success) {
for (int i = 0; i < sizeof(regs) / sizeof(const char*); i++) {
if (strcmp(s, regs[i]) == 0) {
*success = true;
/* 变量cpu定义于$NEMU_HOME/src/cpu/cpu-exec.c: CPU_state cpu = {};
声明见文件$NEMU_HOME/include/isa.h: extern CPU_state cpu; */
return cpu.gpr[i]; // 数组regs的声明顺序与riscv32的定义一致
}
}
*success = false;
return 0;
}
有了这个函数,很容易实现isa_reg_display
:
void isa_reg_display() {
bool success = false;
for (int i = 0; i < sizeof(regs) / sizeof(const char*); i++) {
word_t val = isa_reg_str2val(regs[i], &success);
printf("%-16s0x%-16x%d\n", regs[i], val, val); // 为了输出美观
}
}
x: 扫描内存(简化版)
扫描内存功能比较复杂,因为涉及到表达式求值。目前只需要实现它的简化版本:假设表达式为十六进制。
和之前一样,在cmd_table
中填入项,并实现函数cmd_x
:
word_t vaddr_read(vaddr_t, int); // 编译时出错,所以添加了对vaddr_read的声明
static int cmd_x(char *args) {
char* args_end = args + strlen(args);
// 读取第一个参数
char* arg_word = strtok(NULL, " ");
if (arg_word == NULL) {
printf("error: missing arguments\n");
return 0;
}
int n_word = 0;
if (sscanf(arg_word, "%d", &n_word) != 1) {
printf("error: first argument should be an integer, but got %s\n", arg_word);
return 0;
}
// 读取第二个参数
char* arg_expr = arg_word + strlen(arg_word) + 1;
if (arg_expr >= args_end) {
printf("error: missing arguments\n");
return 0;
}
word_t start_addr = 0; // 内存首地址
if (sscanf(arg_expr, "%x", &start_addr) != 1) {
printf("error: require a hex expression, but got %s\n", arg_expr);
return 0;
}
// 读取字节并输出
for (int i = 0; i < n_word; i++) {
word_t current_addr = start_addr + i * 4;
word_t val = vaddr_read(current_addr, 4);
printf("\033[34m0x%08x\033[0m: 0x%08x\n", current_addr, val);
}
return 0;
}
以上代码很大一部分用于错误检查,也可以写得简单些,不过之后输入指令时就要小心点。
提示
这里解析参数时,没有使用
sscanf
, 主要因为参数expr
中可能包含空格,例如%esp + 3
这种,用了sdb_mainloop
中使用过的strtok
. 此函数内部维护了一个static
变量,首次调用时需要指定字符串;在之后的调用中,将第一个参数指定为NULL
即可。
编译后,输出大致如下: