Skip to content

NEMU 代码导读

本节手册与计算机系统中的“基础软件”(gdb)一层有关。

----------
用户程序
----------
基础软件     gdb  ←  nemu
----------
操作系统
----------
硬件指令集
----------

摘要

本篇文档将简要介绍 nemu 框架代码中的一些对新手而言较为生疏的点,从而便于实验的进行。

本篇文档对应“RTFSC”和“基础设施”两个部分,建议在阅读完原实验文档的“RTFSC”一章后再来阅读本篇文档。


当你刚打开 nemu 代码时(假设你是使用 VSCode ),你估计会看到一对的头文件报红,和宏的报红。当你正确地配置好 includePath 之后,大部分的报红都会消失。

可见, nemu 的代码中大量使用了宏定义的方式来修改代码的逻辑,理解这些复杂宏的行为将会更有利于我们进行后续的实验,消除慌张感(不要紧张~)。

一些宏定义的解析

我们先来看看和当前实验进度(sdb)不太相关的一些宏。

concat

我们先来到 include/macro.h 文件中,找到 concat 宏的定义。

// macro concatenation
#define concat_temp(x, y) x ## y
#define concat(x, y) concat_temp(x, y)
#define concat3(x, y, z) concat(concat(x, y), z)
#define concat4(x, y, z, w) concat3(concat(x, y), z, w)
#define concat5(x, y, z, v, w) concat4(concat(x, y), z, v, w)
我们先看第一行, concat_temp 通过 ## 运算符将两个参数连接起来,合成一个完整的单词。

然后,就开始嵌套定义了,我们就拥有了连接三个、四个、五个参数的 concat 宏。

这里,我们实际上就可以把宏视作一个函数,只是宏操作的“变量”是源文件中的字符串,而不是程序变量。

ifdef/ifndef 与头文件保护

我们现在来到 include/debug.h 文件中,找到 ifndef ,从文件头直接跳到文件尾。

#ifndef __DEBUG_H__
#define __DEBUG_H__
//......
#endif

这是在表达什么?

#ifndef#endif 是成对的关系,意思是,如果没有定义过 __DEBUG_H__ 这个宏,就把这对语句中间夹着的内容放到文件里,否则把中间的内容全部忽略掉。

但是,为什么要在 #ifndef 后紧跟对这个宏本身的定义呢?

我们想象一下一个情况,在一个 struct.h 文件中存在一个结构体的类型名称定义,这个头文件被两个不同的 .c 文件所 include ,然后我们生成可执行文件时,需要将这两个 .c 文件合到一起去。众所周知, #include 宏就是直接把一个文件中的内容原封不动地搬到 #include 所在的地方,这个宏在预处理时就原原本本地把两个 .c 文件中都展开了同一份 struct 的定义,然后你就出现了对一个类型重复定义的错误,文件无法编译通过。

但如果你加上这个 #ifndef __STRUCT_H__ 系列宏,在预处理时,在第一个 .c 文件中对 #include <struct.h> 展开时, __STRUCT_H__ 暂未被定义,所以中间的内容会正常展开,实现对 __STRUCT_H__struct 的定义。而在第二个 .c 文件中继续展开时,因为 __STRUCT_H__ 已经被定义,头文件里面的所有内容都被删去,我们就成功编译通过了。

所以,这里面 #ifndef 宏的作用就是防止头文件的重复展开导致编译错误。

另一种头文件保护方式

目前还有一种防止头文件重复定义的方式是在头文件里加上一行 #pragma once ,可以尝试搜索和思考两种方式的优缺点。

Log

继续在 include/debug.h 文件中,看到 Log 宏,里面可以看到 __FILE__ __LINE__ __func__ 这些宏,它们代表什么应该很清晰。

#define Log(format, ...) \
    _Log(ANSI_FMT("[%s:%d %s] " format, ANSI_FG_BLUE) "\n", \
        __FILE__, __LINE__, __func__, ## __VA_ARGS__)

比较不清晰的可能就是宏定义上的 ... 和定义末尾的 ## __VA_ARGS__ ,前面其他的宏已经说了那么多了,这个宏就请你去问 AI 吧~


前面讲解了几个简单的宏热热身,接下来我们要开始讲解和实验架构关系较为紧密的一些宏了。

CPU_state

我们来到 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);

这里面又嵌套了一个 MUXDEF 宏,可能不太好理解,我们换到另一个文件中对比着看。我们来到 src/isa/x86/include/isa-def.h (未正确实现版本)。

// 暂未正确实现的 x86 寄存器结构体
typedef struct {
  struct {
    uint32_t _32;
    uint16_t _16;
    uint8_t _8[2];
  } gpr[8];

  /* Do NOT change the order of the GPRs' definitions. */
  uint32_t eax, ecx, edx, ebx, esp, ebp, esi, edi;

  vaddr_t pc;
} x86_CPU_state;

经过这么一比较,我们就可以合理类比推测, riscv 里面的 MUXDEF 宏,最后会展开成类似 riscv32_CPU_state 的形式。

eip 寄存器去了哪里

我们知道在 x86 架构中,存在一个 eip 寄存器,就是起到 PC 的作用,但是为什么 x86_CPU_state 这个结构体中不去声明 eip ,而是在结构体中声明一个 pc 呢?

我们来到 include/isa.h 中,看到:

// The macro `__GUEST_ISA__` is defined in $(CFLAGS).
// It will be expanded as "x86" or "mips32" ...
typedef concat(__GUEST_ISA__, _CPU_state) CPU_state;

这里用到了我们前面讲过的 concat 宏,将 __GUEST_ISA___CPU_state 连接起来,得到 x86_CPU_state 或者 riscv32_CPU_state 这样的结构体名称,并给到它一个别名 CPU_state

所以,__GUEST_ISA__ 宏的作用就是根据当前的配置,选择一个 ISA ,并将 CPU_state 结构体名称指向它。这个宏的来源就是 make 时的配置文件。

然后我们现在来到 src/cpu/cpu-exec.c 文件中,就可以看到 CPU_state 变量的声明了。

CPU_state cpu = {};

src/isa/riscv32/init.c 中,我们可以看到它对 CPU_state 结构体的初始化。

static void restart() {
  /* Set the initial program counter. */
  cpu.pc = RESET_VECTOR;

  /* The zero register is always 0. */
  cpu.gpr[0] = 0;
}

很不错,完美符合我们前面的结构体架构。

成功的开始

代码框架中还有其他很多的宏,限于篇幅我们不可能一一讲解,但相信对这几个最关键的宏的讲解已经让你建立了最初的信心,能够尽力去理解这些宏的作用。

你可能也感受到了,任何一个声明可能都会跨好几个文件被使用,这体现了好好配置项目结构,消除报红,实现声明跳转的必要性。

nemu 自身的逻辑

NEMUState

我们来到 include/utils.h 中,可以看到如下的声明:

// ----------- state -----------

enum { NEMU_RUNNING, NEMU_STOP, NEMU_END, NEMU_ABORT, NEMU_QUIT };

typedef struct {
  int state;
  vaddr_t halt_pc;
  uint32_t halt_ret;
} NEMUState;

extern NEMUState nemu_state;

这里可以看到对 NEMUState 结构体的声明,其中有三个成员变量。第一个是一个整数 state ,会用来存储上面的枚举类型中的一个,用来代表当前 nemu 的状态(比如 NEMU_RUNNING 表示正在执行用户程序, NEMU_STOP 表示程序暂停执行等),用来控制 nemu 的行为。第二个是虚拟地址类型的 halt_pc其中会存储 nemu 在停止执行用户程序时所在的 PC 值(这个变量目前还用不到,在 PA2 中会详细展开)。第三个是一个无符号整数 halt_ret ,这个表示 nemu 的用户程序的返回值(我们都知道, main() 函数返回非零值时表示异常退出)。

在这里干讲你肯定不好理解,我们来寻找一个实例。

我们来到 src/utils/state.c 中,可以找到对 nemu_state 的初始定义:

NEMUState nemu_state = { .state = NEMU_STOP };

int is_exit_status_bad() {
  int good = (nemu_state.state == NEMU_END && nemu_state.halt_ret == 0) ||
    (nemu_state.state == NEMU_QUIT);
  return !good;
}

这里可以看到, nemu_state 变量的初始值是 NEMU_STOP ,表示 nemu 刚刚启动,还没有执行用户程序。

后面还跟着一个 is_exit_status_bad() 函数,这个函数的作用从名字就可以知道,是判断退出状态是否异常。

但这个“异常”是指用户程序异常退出还是指 nemu 自身异常退出呢?(我们前面提到过, nemu 是装载着用户程序的一个模拟器,那么用户程序的异常退出和 nemu 的异常退出显然是两个不同的概念)

这又体现了良好的配置的重要性,如果你的编辑器拥有函数跳转功能,你立刻就可以跳转到其被调用的地方( src/nemu-main.c ),从而检查这一点。

int main(int argc, char *argv[]) {
  //......
  return is_exit_status_bad();
}

没想到我们一跳转,直接跳转到整个 nemu 的 main() 函数来了!那这个函数肯定是在指示 nemu 自身的退出状态,而不是用户程序的退出状态。

那我们现在可以回到 is_exit_status_bad() 了,从这个函数中我们可以获取如下的信息:

对于 nemu 来说,枚举类型中的各个状态里,只有 NEMU_ENDNEMU_QUIT 可以表示正常退出,其他状态都只能表示异常退出,且 NEMU_END 必须在用户程序返回值为 0 时才表示正常退出。

当退出状态不正常时,整个 nemu 会返回 1 作为退出码,这个异常退出码会被 Makefile 捕获,并打印一些错误信息。

Decode

好的,看完了 nemu 自身的状态,我们再来看 nemu 是怎么将用户程序的执行进行抽象的。

我们来到 include/cpu/decode.h 中,看到如下的声明:

typedef struct Decode {
  vaddr_t pc;
  vaddr_t snpc; // static next pc
  vaddr_t dnpc; // dynamic next pc
  ISADecodeInfo isa;
  IFDEF(CONFIG_ITRACE, char logbuf[128]);
} Decode;

光看这些可能还看不出什么,我们结合 src/cpu/cpu-exec.c 中的这一段代码来一起理解。

static void exec_once(Decode *s, vaddr_t pc) {
  s->pc = pc;
  s->snpc = pc;
  isa_exec_once(s);
  cpu.pc = s->dnpc;
  // ......
}

这里把一个 Decode 结构体指针 s 的各个参数初始化成地址 pc ,然后 s 带着装入的值,进入到具体的 ISA 指令集中去执行指令了。执行完后, cpu.pc 被更新。

所以, Decode 结构体实际上就是一个对程序执行的抽象,它包含了执行一条机器指令所需的最小程度的信息。它在 nemu 中被填写好,然后交给具体的 ISA 指令集去执行,从而实现了 nemu 和 ISA 之间的解耦。这使得 nemu 只需准备必须的信息,而无需知道它装载的具体是什么样的指令集,我们将会在 PA2 和这个结构体打很多交道。

gdb 式的框架

最后,我们再来看看 nemu 中 gdb 式的框架。

前面我们介绍过, gdb 可以视作一个状态机的管理器,我们可以将它简化为以下逻辑:

while(1){
  设置状态;
  执行一步用户程序的指令;
  检查状态;
}

所以说白了, gdb 就是一个用来执行程序的程序, nemu 里 sdb 的原理也是这样。

阅读完“RTFSC”一节之后你应该已经知道,在 nemu 中输入 c 就可以执行用户程序,我们来到 src/monitor/sdb/sdb.c 中,看看 c 命令的实现。

static int cmd_c(char *args) {
  cpu_exec(-1);
  return 0;
}

这个实现很简洁,就是调用 cpu_exec() 函数,然后返回,那显然, cpu_exec() 函数就是代表执行用户程序。

为什么要用 -1 作为参数?

cpu_exec() 里面的参数是一个数,那理论上这个函数的功能应该就是输入指令的个数,然后就根据这个数来对应执行多少条指令,但 cmd_c() 这里为什么要用 -1 作为参数呢?

看看函数定义

去源码里寻找一下, cpu_exec() 这个函数的原始定义是什么样的,结合你在《计算机系统基础》(《计算机组织结构》)课上学过的数据编码相关的知识,你应该就能理解了。

如果你能理解这个 -1 的含义,后面的任务对你来说也就只是时间问题了。


总结

前面说了这么多,或许你全部理解了,或许你很多都没懂,但你肯定已经对 nemu 的代码有了一个大概的了解,完成 RTFSC 一节的必做内容肯定是不成问题了,但在完成下一节“基础设施”的时候,多少还是会遇到一些困难,但那些困难都只是实现上的困难,而不是原理上的困难。只要你在阅读文档和源码的过程中好好思考了 nemu 的执行逻辑(甚至只是简单的模仿),实现就只是时间问题,甚至如果你像我们前面与 AI 交互中介绍的那样充分发挥 AI 的力量,实现所需的时间也可以极大缩短。(不过,出于锻炼你代码能力的考虑,还是建议至少核心逻辑应当由你自己实现,在一些繁杂耗时的细节上,使用 AI 是可以理解的)

开始 PA 的第一关吧

接下来请你完成 PA1.1 中的任务,完成之后,就可以继续后面的部分了。

相信自己,即使你可能读完手册一遍下来什么都不懂,你要做的也只是多读两遍手册和代码,慢慢理解,相信自己,不要慌张,一切都只是时间问题。

祝你好运!(爱来自 copilot 自动补全)