阅读 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 的决策过程,我觉得适合 debug Makefile 的时候使用
  • ???? 查看 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/ 目录下构建两个程序, mconfconf

然后运行命令 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 是一个指向下一个监视点的指针。

headfree_ 是两个链表,前者用来处理使用中的监视点,后者用于处理空闲的监视点结构。

NEMU 默认情况下提供了 32 个监视点结构,定义在 NR_WP 宏中。

[未完待续…]

Created: 2024-12-22 Sun 21:23

This webpage is generated by GNU Emacs