阅读 NEMU 的源码 (更新中)
Table of Contents
1. 用到的工具链
GDB
: 我使用GDB
来寻找结构体、函数在项目中的位置,它对于分析较大项目的源码非常有用。cflow
: 这是一个用来查看函数之间调用关系的工具。GNU Global
:Global
配合 Emacs 的ggtags
插件可以实现定义的查找和跳转,它可以与GDB
配合使用。GCC
:GCC
的-e
选项可以输出预处理过后的代码,可以更好理解宏展开的过程。
2. The Build System
之前提到过,了解程序的行为主要有两种方法:
RTFSC
: 了解程序的静态行为,更耗时,但更精确Trace
: 了解程序的动态踪迹,更轻松,但不全面
对于一个具有一定规模的项目来说,直接阅读源码可能会陷入过分繁琐的细节中,所以我们先通过分析程序的动态踪迹,获取对程序的一个大体认识。
但是,哪怕是看踪迹,也有不同的层次:
strace
为系统调用的踪迹,关注程序与操作系统的交互,更为底层make -d
用来查看make
的决策过程,我觉得适合 debugMakefile
的时候使用????
查看make
执行的命令,了解make
的具体操作
很明显第三点是符合我们目前需求的做法, But How?
RTFM
2.1. What does the manual say?
通过 man make
阅读 make
工具的手册页,发现了两个实用的选项:
-n
: 只打印将要执行的命令,但不实际执行它们-B
: 强制构建所有 targets (防止构建完成后make
跳过某些文件的编译)
所以,只要我们执行 make -nB
,就能知道 make
在干什么了吧。
对,也不完全对。
2.2. Read The Logs
执行 make -nB > log.txt
后,它确实显示出了 make
执行的所有命令,但很可惜我看不懂一点。
echo + CC src/nemu-main.c
mkdir -p .../nemu/build/obj-riscv32-nemu-interpreter/src/
clang -O2 -MMD -Wall -Werror -I .../nemu/include -I .../nemu/src/isa/riscv32/include -I .../nemu/src/engine/interpreter -I tools/capstone/repo/include -O2 -Og -ggdb3 -DITRACE_COND=true -D__GUEST_ISA__=riscv32
...........
...........
实在太乱了 ,各个文件的路径、编译选项和各种命令行工具混在一起,根本没法读。
有没有办法让它变得可读一些?
2.2.1. Emacs,启动!
由于我用 Emacs
,所以直接用了 Emacs
内嵌功能解决这个问题:
M-x keep-lines RET ^clang RET ;;只保留 clang 开头的行 M-x replace-regexp RET .../nemu/ RET $NEMU_HOME/ RET ;;使用正则表达式替换很长的路径名 M-x replace-regexp RET $NEMU_HOME/build/obj-riscv32-nemu-interpreter/ RET $OBJ_DIR/ ;;替换输出路径 M-x replace-regexp RET -O2.*=riscv32 RET ;;忽略目前不那么重要的编译选项
于是我们得到了一个更易读的日志文件:
clang $CFLAGS -c -o $NEMU_HOME/build/obj-riscv32-nemu-interpreter/src/nemu-main.o src/nemu-main.c clang $CFLAGS -c -o $NEMU_HOME/build/obj-riscv32-nemu-interpreter/src/cpu/difftest/dut.o src/cpu/difftest/dut.c clang $CFLAGS -c -o $OBJ_DIR/src/cpu/difftest/ref.o src/cpu/difftest/ref.c clang $CFLAGS -c -o $OBJ_DIR/src/cpu/cpu-exec.o src/cpu/cpu-exec.c clang $CFLAGS -c -o $OBJ_DIR/src/monitor/monitor.o src/monitor/monitor.c clang $CFLAGS -c -o $OBJ_DIR/src/monitor/sdb/expr.o src/monitor/sdb/expr.c clang $CFLAGS -c -o $OBJ_DIR/src/monitor/sdb/sdb.o src/monitor/sdb/sdb.c clang $CFLAGS -c -o $OBJ_DIR/src/monitor/sdb/watchpoint.o src/monitor/sdb/watchpoint.c clang $CFLAGS -c -o $OBJ_DIR/src/utils/log.o src/utils/log.c clang $CFLAGS -c -o $OBJ_DIR/src/utils/state.o src/utils/state.c clang $CFLAGS -c -o $OBJ_DIR/src/utils/timer.o src/utils/timer.c clang $CFLAGS -c -o $OBJ_DIR/src/utils/disasm.o src/utils/disasm.c clang $CFLAGS -c -o $OBJ_DIR/src/memory/paddr.o src/memory/paddr.c clang $CFLAGS -c -o $OBJ_DIR/src/memory/vaddr.o src/memory/vaddr.c clang $CFLAGS -c -o $OBJ_DIR/src/device/io/map.o src/device/io/map.c clang $CFLAGS -c -o $OBJ_DIR/src/device/io/mmio.o src/device/io/mmio.c clang $CFLAGS -c -o $OBJ_DIR/src/device/io/port-io.o src/device/io/port-io.c clang $CFLAGS -c -o $OBJ_DIR/src/isa/riscv32/init.o src/isa/riscv32/init.c clang $CFLAGS -c -o $OBJ_DIR/src/isa/riscv32/system/mmu.o src/isa/riscv32/system/mmu.c clang $CFLAGS -c -o $OBJ_DIR/src/isa/riscv32/system/intr.o src/isa/riscv32/system/intr.c clang $CFLAGS -c -o $OBJ_DIR/src/isa/riscv32/difftest/dut.o src/isa/riscv32/difftest/dut.c clang $CFLAGS -c -o $OBJ_DIR/src/isa/riscv32/reg.o src/isa/riscv32/reg.c clang $CFLAGS -c -o $OBJ_DIR/src/isa/riscv32/inst.o src/isa/riscv32/inst.c clang $CFLAGS -c -o $OBJ_DIR/src/isa/riscv32/logo.o src/isa/riscv32/logo.c clang $CFLAGS -c -o $OBJ_DIR/src/engine/interpreter/hostcall.o src/engine/interpreter/hostcall.c clang $CFLAGS -c -o $OBJ_DIR/src/engine/interpreter/init.o src/engine/interpreter/init.c clang++ -o $NEMU_HOME/build/riscv32-nemu-interpreter ....(一堆之前生成的 .o 文件)
这下明朗了,它操作哪些文件,路径在哪里一目了然。
更棒的是,我们读源码的时候只需要读这些和构建有关的文件就可以了,工作量小了很多。
2.3. Dig Deeper into the Make System
NEMU 使用了 Kconfig 来管理配置选项,你可以通过 make menuconfig
来进行配置。
我们同样可以使用 make menuconfig -nB > log2.txt
并通过上面的操作来简化这个日志文件。
那么它究竟干了什么呢?
简单来说,它试图在 $NEMU_HOME/tools/kconfig/build/
目录下构建两个程序, mconf
与 conf
然后运行命令 mconf nemu/Kconfig
,将配置选项以图形化的方式呈现出来,并将配置结果记录到 nemu/.config
中
最后再运行命令 conf --syncconfig nemu/Kconfig
,生成给 Makefile 调用的 auto.conf
与给 C 调用的 autoconf.h
2.4. Actually Reading The Makefile
直到这时,我发现 Makefile
中会引用一大堆现在还不知道哪来的头文件,而如果不把这些东西弄清楚,之后读源码会非常困难。
所以还是不能偷懒,该读的还得读。
2.4.1. The Filelist
首先引起我注意的,就是 FILELIST_MK
,相关代码如下:
# Include all filelist.mk to merge file lists FILELIST_MK = $(shell find -L ./src -name "filelist.mk") include $(FILELIST_MK) DIRS-BLACKLIST-y += $(DIRS-BLACKLIST) SRCS-BLACKLIST-y += $(SRCS-BLACKLIST) $(shell find -L $(DIRS-BLACKLIST-y) -name "*.c") SRCS-y += $(shell find -L $(DIRS-y) -name "*.c") SRCS = $(filter-out $(SRCS-BLACKLIST-y),$(SRCS-y))
它甚至调用了另一个 Makefile
…
filelist.mk
中有价值的部分如下:
SRCS-y += src/nemu-main.c DIRS-y += src/cpu src/monitor src/utils DIRS-$(CONFIG_MODE_SYSTEM) += src/memory DIRS-BLACKLIST-$(CONFIG_TARGET_AM) += src/monitor/sdb
根据注释(谢天谢地)可以知道:
SRCS-y
: 参与编译的源文件DIRS-y
: 参与编译的目录,目录中的文件会被加入SRCS-y
中SRCS-BLACKLIST-y
: 不参与编译的文件黑名单DIRS-BLACKLIST-y
: 不参与编译的目录,目录中的文件会被加入SRCS-BLACKLIST-y
中
但这个 CONFIG_TARGET_AM
是什么呢…
2.4.2. 死去的配置系统还在追我
之前提到了 Kconfig 会生成 auto.conf
,而 Makefile
中也确实调用了它:
# Include variables and rules generated by menuconfig -include $(NEMU_HOME)/include/config/auto.conf -include $(NEMU_HOME)/include/config/auto.conf.cmd
查看这个文件的内容,发现它都以 CONFIG_
格式开头:
# # Automatically generated file; DO NOT EDIT. # NEMU Configuration Menu # CONFIG_DIFFTEST_REF_NAME="none" CONFIG_ENGINE="interpreter" CONFIG_PC_RESET_OFFSET=0 CONFIG_TARGET_NATIVE_ELF=y CONFIG_MSIZE=0x8000000 CONFIG_CC_O2=y ......
但是我并没有找到 CONFIG_TARGET_AM
这个变量,应该是我 Make 的时候把它关了。
所以这行代码到底在干什么呢?
DIRS-BLACKLIST-$(CONFIG_TARGET_AM) += src/monitor/sdb
当 CONFIG_TARGET_AM
未定义时,它等价于 DIRS-BLACKLIST- += src/monitor/sdb
,不会影响 BLACKLIST 的值。
而当 CONFIG_TARGET_AM=y
时,上式则变为 DIRS-BLACKLIST-y += src/monitor/sdb
,编译时将 /sdb
目录排除在外。
我觉得这是很巧妙的设计。
2.4.3. 编译和链接
其实目前这个阶段下,我唯一关注的只有头文件的位置,所以只需要关注很小一部分的 Makefile
就好。
然后我发现具体的构建写在了 nemu/scripts/build.mk
下:
# Compilation patterns $(OBJ_DIR)/%.o: %.c @echo + CC $< @mkdir -p $(dir $@) @$(CC) $(CFLAGS) -c -o $@ $< $(call call_fixdep, $(@:.o=.d), $@)
只需要关注 $CFLAGS
即可,将 -nB
的结果和脚本进行对比,得到以下信息:
NEMU 构建时的头文件在以下这几个地方:
$NEMU_HOME/src/isa/riscv32/include
$NEMU_HOME/include
$NEMU_HOME/src/engine/interpreter
$NEMU_HOME/tools/capstone/repo/include
现在,我们终于可以开始着手阅读 C 源码了!
3. 从主函数开始
看代码,肯定要从主函数开始看,但是对于大项目,不知道主函数具体在哪个文件里。
我们有以下几种方式来获取 main()
函数所处的位置:
- 使用
GDB
$ make clean $ make gdb (gdb) starti (gdb) b main Breakpoint 1 at 0x555555556361: file src/nemu-main.c, line 29.
- 使用
grep
与正则表达式
$ grep -nr "\bmain\b" src/
src/filelist.mk:16:SRCS-y += src/nemu-main.c
src/nemu-main.c:24:int main(int argc, char *argv[]) {
于是我们知道了,NEMU 的主函数位于 src/nemu-main.c
中。
3.1. 主函数里有什么?
int main(int argc, char *argv[]) { /* Initialize the monitor. */ #ifdef CONFIG_TARGET_AM am_init_monitor(); #else init_monitor(argc, argv); #endif /* Start engine. */ engine_start(); /* See file $NEMU_HOME/src/utils/state.c */ return is_exit_status_bad(); }
主函数实际上调用了三个函数:
init_monitor()
engine_start()
is_exit_status_bad()
我们一点点来分析。
3.1.1. init_monitor()
通过 GNU Global
的 TAG 跳转,发现 init_monitor
位于 /src/monitor/monitor.c
中定义:
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(); }
其实就调用了一大堆函数做初始化,大部分没什么需要特别关注的,所以只讲一些自己注意了的东西:
parse_args
使用了getopt
来做参数解析,具体我在这里介绍过。welcome
中使用了一个宏Log
,在/include/utils.h
中,我觉得很实用。
#define Log(format, ...) \ _Log(ANSI_FMT("[%s:%d %s] " format, ANSI_FG_BLUE) "\n", \ __FILE__, __LINE__, __func__, ## __VA_ARGS__) #define _Log(...) \ do { \ printf(__VA_ARGS__); \ log_write(__VA_ARGS__); \ } while (0) #define log_write(...) IFDEF(CONFIG_TARGET_NATIVE_ELF, \ do { \ extern FILE* log_fp; \ extern bool log_enable(); \ if (log_enable() && log_fp != NULL) { \ fprintf(log_fp, __VA_ARGS__); \ fflush(log_fp); \ } \ } while (0) \) #define ANSI_FMT(str, fmt) fmt str ANSI_NONE #define ANSI_NONE "\33[0m" #define ANSI_FG_BLUE "\33[1;34m" // just for color display, so I omitted the rest.
MUXDEF
等定义在 /include/marco.h
中:
#define IFDEF(macro, ...) MUXDEF(macro, __KEEP, __IGNORE)(__VA_ARGS__) #define MUXNDEF(macro, X, Y) MUX_MACRO_PROPERTY(__P_DEF_, macro, Y, X) #define MUX_MACRO_PROPERTY(p, macro, a, b) MUX_WITH_COMMA(concat(p, macro), a, b) #define CHOOSE2nd(a, b, ...) b #define MUX_WITH_COMMA(contain_comma, a, b) CHOOSE2nd(contain_comma a, b)
虽然但是,还是完全看不懂…
所以不得不模拟它宏展开的过程了:
// PART I // CONFIG_TRACE is defined in autoconf.h, if you turn it up then it is 1 Log("Trace: %s", MUXDEF(CONFIG_TRACE, ANSI_FMT("ON", ANSI_FG_GREEN), ANSI_FMT("OFF", ANSI_FG_RED))); // equals to _Log(ANSI_FMT("[%s:%d %s] " "Trace: %s", ANSI_FG_BLUE) "\n", \ __FILE__, __LINE__, __func__,MUXDEF(CONFIG_TRACE, ANSI_FMT("ON", ANSI_FG_GREEN), ANSI_FMT("OFF", ANSI_FG_RED))); // equals to _Log(ANSI_FMT("[%s:%d %s] " "Trace: %s", ANSI_FG_BLUE) "\n", \ __FILE__, __LINE__, __func__,MUXDEF(CONFIG_TRACE, ANSI_FMT("ON", ANSI_FG_GREEN), ANSI_FMT("OFF", ANSI_FG_RED))); // equals to _Log(ANSI_FG_BLUE "[%s:%d %s] " "Trace: %s" ANSI_NONE "\n", \ __FILE__, __LINE__, __func__,MUXDEF(CONFIG_TRACE, ANSI_FMT("ON", ANSI_FG_GREEN), ANSI_FMT("OFF", ANSI_FG_RED))); // refer to PART II _Log(ANSI_FG_BLUE "[%s:%d %s] " "Trace: %s" ANSI_NONE "\n", \ __FILE__, __LINE__, __func__, ANSI_FG_GREEN "ON" ANSI_HOME); // equals to do { printf(ANSI_FG_BLUE "[%s:%d %s] " "Trace: %s" ANSI_NONE "\n", __FILE__, __LINE__, __func__, ANSI_FG_GREEN "ON" ANSI_HOME); log_write(ANSI_FG_BLUE "[%s:%d %s] " "Trace: %s" ANSI_NONE "\n", __FILE__, __LINE__, __func__, ANSI_FG_GREEN "ON" ANSI_HOME); } while (0) // PART II MUXDEF(1, ANSI_FMT("ON", ANSI_FG_GREEN), ANSI_FMT("OFF", ANSI_FG_RED)) // equals to MUX_MACRO_PROPERTY(__P_DEF_, 1, ANSI_FMT("ON", ANSI_FG_GREEN), ANSI_FMT("OFF", ANSI_FG_RED)) // equals to MUX_WITH_COMMA(concat(__P_DEF_, 1), ANSI_FMT("ON", ANSI_FG_GREEN), ANSI_FMT("OFF", ANSI_FG_RED)) // equals to CHOOSE2nd(__P_DEF_1, ANSI_FMT("ON", ANSI_FG_GREEN), ANSI_FMT("OFF", ANSI_FG_RED)) // equals to ANSI_FMT("ON", ANSI_FG_GREEN) // equals to ANSI_FG_GREEN "ON" ANSI_HOME // Goto Part I
经过我们的手动化简,发现这行语句实际上在终端里的输出为:
[src/monitor/monitor.c:28 welcome] Trace: ON
所以, ~Log(...)
干的事情实际上就是把信息打印到屏幕上,同时再写入 Logfile 中,只是使用了比较复杂的宏定义来自动记录如文件名,行号,路径等信息。
init_difftest()
这是给 DiffTest
这个协同仿真差分测试框架提供的初始化函数,主要是为了 debug 时使用。
DiffTest
可以在程序执行时验证每一条指令,核心思想:对于同一规范的两种实现,给定相同的定义中的输入,行为应当完全一致。
之后我们会深入研究它的源码,目前我们先将它放在一边。
init_sdb()
这个函数定义在 src/monitor/sdb/sdb.c
中,它调用了两个函数 init_regex()
和 init_wp_pool()
我们目前先只关注 init_wp_pool()
,这个函数定义在 src/monitor/sdb/watchpoint.c
中,它初始化了 NEMU 内置调试器的监视点链表。
有关监视点的具体定义如下:
typedef struct watchpoint { int NO; struct watchpoint *next; /* TODO: Add more members if necessary */ } WP; static WP wp_pool[NR_WP] = {}; static WP *head = NULL, *free_ = NULL;
NO
代表监视点的序号,而 next
是一个指向下一个监视点的指针。
head
和 free_
是两个链表,前者用来处理使用中的监视点,后者用于处理空闲的监视点结构。
NEMU 默认情况下提供了 32 个监视点结构,定义在 NR_WP
宏中。
[未完待续…]