2024/08/22
从今天开始,正式准备启动这个“项目”啦!(其实已经看过一段时间前面的基础介绍了
希望能够坚持做完!!!(ง •_•)ง
这里主要记录一下做PA过程中遇到的问题和思考(以及内心戏
不过后面一个月要去外地培训了。。。还不知道弄
1. PA1
1.3 RTFSC
getopt_long()函数的作用?
1 | SYNOPSIS |
got,用来解析命令行参数的。
1.3.1 准备第一个客户程序
为什么全部都是函数?
阅读
init_monitor()函数的代码, 你会发现里面全部都是函数调用. 按道理, 把相应的函数体在init_monitor()中展开也不影响代码的正确性. 相比之下, 在这里使用函数有什么好处呢?这样有利于抽象内部设计,使得设计更加层次分明
2024/08/23
NEMU是一个用来执行客户程序的程序(模拟计算机),但是NEMU一开始并没有客户程序(OS或其他客户程序),需要有程序将客户程序读入计算机中,NEMU项目src中的monitor就是用来干这个事的(也包含调试的功能sdb)。
monitor中调用init_isa()来进行ISA的一些初始化:
- 将一个内置客户程序读入内存中
- 初始化虚拟计算机系统(初始化寄存器,
restart()函数)
读入客户程序到内存中,读到什么位置?NEMU采用最简单的方式——约定一个位置,可由我们配置,定义在nemu/include/memory/paddr.h中,定义为RESET_VECTOR。
NEMU默认提供128MB的内存,模拟内存定义在src/memory/paddr.c中,定义为pmem
RISC-V32(以及MIPS32)的默认物理内存地址是从
0x80000000开始编址的, 将来CPU访问内存时, 会将CPU将要访问的内存地址映射到pmem中的相应偏移位置,这是通过nemu/src/memory/paddr.c中的guest_to_host()函数实现的。
可能这个RESET_VECTOR就相当于真实计算机启动的第一个地址?存放BIOS的位置?

成功运行!!!
2024.10.26记
果然。。。中途荒废了一个月再捡起来就需要勇气了hhh,
荒废了两个月后,我终于又准备回来了!
2024.12.15记
真的回来了(
2024/12/15
NEMU最开始默认的客户程序定义在init.c函数中,如下:

其中的img就是4条指令+一个数据。也可以在运行NEMU的时候添加一个参数,指定外部的镜像文件加载。
1.3.2 运行第一个客户程序
究竟要执行多久?
在
cmd_c()函数中, 调用cpu_exec()的时候传入了参数-1, 你知道这是什么意思吗?A:将-1转换成uint64_t的最大值,即执行0xFFFFFFFFFFFFFFFF这么多条指令(如不是指令自行退出的话)
潜在的威胁 (建议二周目思考)
“调用
cpu_exec()的时候传入了参数-1“, 这一做法属于未定义行为吗? 请查阅C99手册确认你的想法。A:好的,那就二周目再思考(
进入sdb_mainloop()函数后,会通过cmd_table结构体中的函数指针(handler)来调用不同的函数,输入c就可以运行内置的img。但是再次输入c的话会提示需要重新运行NEMU才能。。。
sdb_mainloop()函数中的strtok()函数是用来分割字符串的,类似python的split函数,可以指定分割标志。
有意思的是其中的nemu_trap指令,它是为了在NEMU中让客户程序指示执行的结束而加入的, NEMU在ISA手册中选择了一些用于调试的指令, 并将nemu_trap的特殊含义赋予它们。例如在riscv32的手册中, NEMU选择了ebreak指令来充当nemu_trap. 为了表示客户程序是否成功结束, nemu_trap指令还会接收一个表示结束状态的参数。
RTFM:
RISC-V的基础指令格式有如下4种:

RISC-V种,EBREAK指令可以用SYSTEM指令实现,如下:

ECALL和EBREAK的功能都是在SYSTEM这个opcode下实现的,ECALL的funct12是0,EBREAK的funct12是1。
其中,EBREAK是用于将控制权返回给调试环境。
NEMU中,EBREAK指令的实现是将nemu_state.state置为NEMU_END,将nemu_state.halt_pc置为当前PC,将nemu_state.halt_ret置为10。
Note:
什么是trap?
问了一下GPT:
Trap 是指在程序执行过程中,处理器遇到某些事件时,将控制权从当前程序转移到操作系统或异常处理程序的一种机制。
它可以由程序主动触发(如系统调用),也可以由硬件自动触发(如异常或中断)。so,基本上就是用来切换执行的代码流的(个人理解)。
谁来指示程序的结束?
在程序设计课上老师告诉你, 当程序执行到
main()函数返回处的时候, 程序就退出了, 你对此深信不疑. 但你是否怀疑过, 凭什么程序执行到main()函数的返回处就结束了? 如果有人告诉你, 程序设计课上老师的说法是错的, 你有办法来证明/反驳吗? 如果你对此感兴趣, 请在互联网上搜索相关内容。STFW:在程序终止前,可能会存在一些清理操作,例如关闭打开的文件、释放分配的内存等。这些清理操作可以在主函数的最后部分执行,调用
atexit()函数即可。有始有终 (建议二周目思考)
对于GNU/Linux上的一个程序, 怎么样才算开始? 怎么样才算是结束? 对于在NEMU中运行的程序, 问题的答案又是什么呢?
与此相关的问题还有: NEMU中为什么要有
nemu_trap? 为什么要有monitor?对于下面的问题,一周目的回答:为了暂停/终止当前程序的执行,将CPU的控制权交给其他程序(OS/monitor),monitor是用来调试的(吧。。
代码中有一些值得注意(学习),直接摘抄过来:
三个对调试有用的宏(在
nemu/include/debug.h中定义)
Log()是printf()的升级版, 专门用来输出调试信息, 同时还会输出使用Log()所在的源文件, 行号和函数. 当输出的调试信息过多的时候, 可以很方便地定位到代码中的相关位置Assert()是assert()的升级版, 当测试条件为假时, 在assertion fail之前可以输出一些信息panic()用于输出信息并结束程序, 相当于无条件的assertion fail内存通过在
nemu/src/memory/paddr.c中定义的大数组pmem来模拟. 在客户程序运行的过程中, 总是使用vaddr_read()和vaddr_write()(在nemu/src/memory/vaddr.c中定义)来访问模拟的内存. vaddr, paddr分别代表虚拟地址和物理地址.优美地退出
为了测试大家是否已经理解框架代码, 我们给大家设置一个练习: 如果在运行NEMU之后直接键入
q退出, 你会发现终端输出了一些错误信息. 请分析这个错误信息是什么原因造成的, 然后尝试在NEMU中修复它.
终于有一个练习了!
在NEMU中直接输入q和输入c之后再输入q的结果确实不一样!如下:

输入c之后再输入q能够优雅地退出,并没有报错,而直接输入则会报错。通过echo $?查看程序的返回值,发现直接退出的返回值是2,而正常的是0,所以问题出在返回值上。
PS:在menu里开启debug配置后,输入make gdb,可以调试NEMU
一个疑惑是,进入gdb调试后,发现直接退出返回的值是1,并不是2!这是为什么呢?值得思考,怀疑是make程序进行了再次返回,于是直接在命令行运行NEMU而不是通过make命令,得到以下结果:

猜想正确。(还可以深究一下为什么make会在返回值1的基础上返回2?
不过,程序会报错的原因找到了,就是因为返回值不为0,而控制返回值的函数在/src/utils/state.c中的函数is_exit_status_bad():

说明退出时不满足这个条件,返回去看sdb_mainloop()函数中的调用逻辑,运行c命令的话,会执行img程序,会使得nemu_state满足good表达式中的前一个条件,而直接输入q则直接返回-1,不会修改nemu_state的状态,于是不满足good的条件,所以为0,返回1,于是修改调用的cmd_q()函数,设置nemu_state的状态为NEMU_QUIT,即可实现“优雅地退出”。
事实上, TRM的实现已经都蕴含在上述的介绍中了.
- 存储器是个在
nemu/src/memory/paddr.c中定义的大数组- PC和通用寄存器都在
nemu/src/isa/$ISA/include/isa-def.h中的结构体中定义- 加法器在… 嗯, 这部分框架代码有点复杂, 不过它并不影响我们对TRM的理解, 我们还是在PA2里面再介绍它吧
- TRM的工作方式通过
cpu_exec()和exec_once()体现
1.4 基础设施
免责声明(不是
后续内容有很多是直接摘抄自NEMU的官方文档,每次都说明的话出来太麻烦且不啰嗦
所以,在这里声明,如有雷同,就是我“抄”过来的(保命
1.4.1. 简易调试器
目标是实现sdb的以下一些功能:
| 命令 | 格式 | 使用举例 | 说明 |
|---|---|---|---|
| 帮助(1) | help |
help |
打印命令的帮助信息 |
| 继续运行(1) | c |
c |
继续运行被暂停的程序 |
| 退出(1) | q |
q |
退出NEMU |
| 单步执行 | si [N] |
si 10 |
让程序单步执行N条指令后暂停执行, 当N没有给出时, 缺省为1 |
| 打印程序状态 | info SUBCMD |
info r info w |
打印寄存器状态 打印监视点信息 |
| 扫描内存(2) | x N EXPR |
x 10 $esp |
求出表达式EXPR的值, 将结果作为起始内存 地址, 以十六进制形式输出连续的N个4字节 |
| 表达式求值 | p EXPR |
p $eax + 1 |
求出表达式EXPR的值, EXPR支持的 运算请见调试中的表达式求值小节 |
| 设置监视点 | w EXPR |
w *0x2000 |
当表达式EXPR的值发生变化时, 暂停程序执行 |
| 删除监视点 | d N |
d 2 |
删除序号为N的监视点 |
CPU_state的定义在相应isa的include/isa-def.h中:
1 | typedef struct { |
其中gpr就是general purpose register的意思,pc是程序指针。
解析命令
这里有两个函数很好用:
- strtok():分割字符串
- sscanf():输入是字符串,从中获取格式化输入
具体用法可以STFW or RTFM
单步执行
略。
PS:并不是我懒,是我相信你可以的!
打印寄存器
同上
扫描内存
这里读取的是物理内存(pmem)。riscv32的物理地址是从0x80000000开始的,NEMU框架已经提供了一个paddr_read()函数来读取对应的地址数据。
1.4.2. 表达式求值
!!!前排提示!!!
大的要来了!这一小节应该会有难度。
在这里使用如下方法来解决表达式求值的问题:
- 首先识别出表达式中的单元
- 根据表达式的归纳定义进行递归求值
词法分析
词法分析就是识别出表达式中的单元,它们正式的称呼叫token。例子:
1 | "5 + 4 * 3 / 2 - 1" |
其中,数字5等,符号+,*等都是token(除了空格)。
秘密武器:正则表达式
先只考虑一种简单的情况:算术表达式,即待求值表达式中只允许出现以下的token类型:
- 十进制整数
+,-,*,/(,)- 空格串(一个或多个空格)
sdb/expr.c中,下面这个代码:
static Token tokens[32] __attribute__((used)) = {};中的
__attribute__((used))是告诉编译器:即使这个tokens数组在程序中好像没被用到,也不要优化掉它,这个是GCC / Clang 编译器的扩展语法。为什么要加这个?
因为NEMU中有很多变量都是用来被 monitor/debug/反射访问的。
什么是反射访问?
就是程序在运行时,根据名字动态查找、访问内部变量或者函数,而不是硬编码在源代码里。
C 语言本身没有内建反射机制,但可以通过 手动建表 + 符号查找来“模拟反射”。
一个反射访问的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 typedef struct {
const char* name;
void* addr;
} VarEntry;
VarEntry variables[] = {
{"x", &x},
{"y", &y},
{"z", &z},
};
void* lookup(const char* name) {
for (int i = 0; i < sizeof(variables)/sizeof(variables[0]); ++i) {
if (strcmp(variables[i].name, name) == 0)
return variables[i].addr;
}
return NULL;
}这样就可以通过变量名字”x”去查找变量
x的实际地址了。为什么要有这样的机制?
为了实现变量的动态访问,特别是NEMU中,例如,
sdb_mainloop()函数中处理用户输入,来选择调用不同的函数。或许后面会遇到的,根据用户输入的寄存器名来访问相应的寄存器值。所以加了
__attribute__((used))的静态变量,实际上是要被反射访问的。编译器表面上看不到调用,但是运行时需要靠查表、字符串匹配去访问!小技巧
又学到一个小技巧:
Log("... %.*s", substr_len, substr_start)。底层对应的printf函数。这里面的
%.*s其实对应的是最后的两个参数:substr_len, substr_start,分别表示要打印的字符数和字符串起始地址。这样就可以不用管\0字符在哪里了。
2. PA2
2.4. 基础设施2
消失的符号
我们在
am-kernels/tests/cpu-tests/tests/add.c中定义了宏NR_DATA, 同时也在add()函数中定义了局部变量c和形参a,b, 但你会发现在符号表中找不到和它们对应的表项, 为什么会这样? 思考一下, 什么才算是一个符号(symbol)?A:因为被编译器给优化了
2.4.1. 函数调用的踪迹
需要解析ELF文件,首先可以看一下ELF的格式,使用:man 5 elf。
我们要在初始化ftrace时从ELF文件中读出符号表和字符串表,供后续使用。
ELF文件中包括许多的section,其中我们需要用到的section有如下3个:
1 | .shstrtab |
.shstrtab保存的是section name;.strtab保存的是符号表中需要用到的字符串,也就是字符串表;.symtab保存的是符号相关的信息,就是我们说的符号表。
实现ftrace的思路如下:
- 提前通过符号表和字符串表解析出来各个函数对应的地址(和范围)
- 在执行
jal和jalr指令的时候,判断跳转的地址是否在第1步中解析出来的函数中。- 如果是,则打印信息
- 如果不是,则不打印
ELF文件的最开始是一个ELF Header结构体:
1 | typedef struct { |
其中,e_shoff表示的是section header table(sht)在文件中的字节偏移,可以通过它直接定位到sht。
然后,e_shstrndx是sht中字符串表的下标,可以通过它定位到字符串表。
最后,我们需要找到符号表,找到符号表才能找到相应的函数符号。
其中
e_shnum是sht数组的大小,即多少项。e_shentsize是每一项的大小。这两个是用来辅助解析sht的。
我们还需要知道的是,符号表每一项的结构体如下:
1 | typedef struct { |
其中st_info指示的是这个符号的类型,如果是它的值是STT_FUNC,那么就表示这个符号是一个函数名。
一个小坑需要注意,
st_info这个成员不只是符号的类型,还有binding信息,所以还需要通过一个宏ELF32_ST_TYPE(info)来将st_info转换成TYPE类型再和STT_FUNC对比判断。
RISC-V架构中,函数的调用和返回都是通过jal或jalr指令来实现的,前者是直接跳转指令,后者是间接跳转指令。
一个典型的ELF文件的大致内容如下:
1 | +---------------------------+ |
实现初版的ftrace后,运行得到的结果如下:
1 | 0x8000000c: call [_trm_init@0x80000258] |
不匹配的函数调用和返回
如果你仔细观察上文recursion的示例输出, 你会发现一些有趣的现象. 具体地, 注释(1)处的ret的函数是和对应的call匹配的, 也就是说, call调用了f2, 而与之对应的ret也是从f2返回; 但注释(2)所指示的一组call和ret的情况却有所不同, call调用了f1, 但却从f0返回; 注释(3)所指示的一组call和ret也出现了类似的现象, call调用了f1, 但却从f3返回。A:尾调用优化,在函数结尾简单地调用另一个函数的话,会触发编译器的尾调用优化。这里的简单是指仅仅调用另一个函数,而不会在调用后对返回值进行处理,例如f2中的“+9”。所以我们可以观察到,不匹配的call和ret是固定组合:
- call f1, ret f3
- call f1, ret f0
- call f0, ret f3 (这个是最外面一层call ret对)
还有一个“奇怪的”点,你可以观察到,最开始的第一个call f0函数后,call的是f2,而不是代码中的f3。实际上这也是尾调用优化的一个影响,实现尾调用的话,不需要保存返回地址到ra寄存器,而我们实现的ftrace是按照是否保存返回地址到ra判断是否是call指令的,因此,要实现真正的ftrace,还需要对jal/jalr指令进行判断,不能仅仅看是否保存地址到ra。
关于尾调用优化(TCO)的解释,推荐一个视频:
https://www.bilibili.com/video/BV1Pb421Y7uP
冗余的符号表
在Linux下编写一个Hello World程序, 然后使用
strip命令丢弃可执行文件中的符号表:
1
2 gcc -o hello hello.c
strip -s hello用
readelf查看hello的信息, 你会发现符号表被丢弃了, 此时的hello程序能成功运行吗?目标文件中也有符号表, 我们同样可以丢弃它:
1
2 gcc -c hello.c
strip -s hello.o用
readelf查看hello.o的信息, 你会发现符号表被丢弃了. 尝试对hello.o进行链接:
1 gcc -o hello hello.o你发现了什么问题? 尝试对比上述两种情况, 并分析其中的原因.
strip掉可执行文件中的符号表依然可以执行,但是strip掉目标文件中的符号表后再链接就会报错:
/usr/bin/ld: error in test.o(.eh_frame); no .eh_frame_hdr table will be created
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/9/../../../x86_64-linux-gnu/Scrt1.o: in function_start': (.text+0x24): undefined reference tomain’
collect2: error: ld returned 1 exit status
这是因为符号表就是用于在链接的时候定位地址
2.4.2. 输入输出
设备初始化的流程:
NEMU(init_monitor)—>NEMU device(init_device)—> NEMU device(各种device init)—>NEMU device IO(add_mmio_map)
如果是make ARCH=riscv32-nemu,那么暂时还没有用到IOE层,AM(ioe_init)—>AM层外设init
设备访问的流程:
对于RISC-V来说,就是直接写地址,然后在paddr.c中会进行地址的判断,判断是否是往MMIO的地址写入,如果是,则调用
vaddr_write—>paddr_write—>mmio_write—>map_write—>host_write(往mmio map的space中写入数据)—>调用设备的callback函数,以serial为例,那就是调用serial_io_handler函数—>serial_putc
1 | hello.c |
跑分的bug
实现完RTC的IOE后,运行PA中的3个benchmark,
出现了如下的bug:
1 | Dhrystone Benchmark, Version C, Version 2.2 |
跑分为-1!
原因是要先读取高位rtc才会刷新!
在utils.h中添加dtrace的函数声明如下:
1 | // ----------- dtrace ------------ |
其中,前向声明struct IOMap是必须的,因为这个结构体在map.h中,但是如果直接include “device/map.h”的话,会造成循环引用,从而导致以下错误:
1 | + CC src/device/device.c |
同时,需要将map中的结构体定义改为:
1 | typedef struct IOMap{ |
即,在struct后面也加一个IOMap名称。这样才能使用前向声明,否则也会报错。

