RTFSC

事实上, TRM的实现是如此的简单, 以至于框架代码已经实现它了. 接下来让我们看看, 构成TRM的那些数字电路, 在NEMU的C代码中都是何方神圣. 为了方便叙述, 我们将在NEMU中模拟的计算机称为"客户(guest)计算机", 在NEMU中运行的程序称为"客户程序".

框架代码初探

框架代码内容众多, 其中包含了很多在后续阶段中才使用的代码. 随着实验进度的推进, 我们会逐渐解释所有的代码. 因此在阅读代码的时候, 你只需要关心和当前进度相关的模块就可以了, 不要纠缠于和当前进度无关的代码, 否则将会给你的心灵带来不必要的恐惧.

ics2024
├── abstract-machine   # 抽象计算机
├── am-kernels         # 基于抽象计算机开发的应用程序
├── fceux-am           # 红白机模拟器
├── init.sh            # 初始化脚本
├── Makefile           # 用于工程打包提交
├── nemu               # NEMU
└── README.md

目前我们只需要关心NEMU子项目中的内容, 其它子项目会在将来进行介绍. NEMU主要由4个模块构成: monitor, CPU, memory, 设备. 我们已经在上一小节简单介绍了CPU和memory的功能, 设备会在PA2中介绍, 目前不必关心.

Monitor(监视器)模块是为了方便地监控客户计算机的运行状态而引入的. 它除了负责与GNU/Linux进行交互(例如读入客户程序)之外, 还带有调试器的功能, 为NEMU的调试提供了方便的途径. 从概念上来说, monitor并不属于一个计算机的必要组成部分, 但对NEMU来说, 它是必要的基础设施. 如果缺少monitor模块, 对NEMU的调试将会变得十分困难.

代码中nemu/目录下的源文件组织如下(并未列出所有文件):

nemu
├── configs                    # 预先提供的一些配置文件
├── include                    # 存放全局使用的头文件
│   ├── common.h               # 公用的头文件
│   ├── config                 # 配置系统生成的头文件, 用于维护配置选项更新的时间戳
│   ├── cpu
│   │   ├── cpu.h
│   │   ├── decode.h           # 译码相关
│   │   ├── difftest.h
│   │   └── ifetch.h           # 取指相关
│   ├── debug.h                # 一些方便调试用的宏
│   ├── device                 # 设备相关
│   ├── difftest-def.h
│   ├── generated
│   │   └── autoconf.h         # 配置系统生成的头文件, 用于根据配置信息定义相关的宏
│   ├── isa.h                  # ISA相关
│   ├── macro.h                # 一些方便的宏定义
│   ├── memory                 # 访问内存相关
│   └── utils.h
├── Kconfig                    # 配置信息管理的规则
├── Makefile                   # Makefile构建脚本
├── README.md
├── resource                   # 一些辅助资源
├── scripts                    # Makefile构建脚本
│   ├── build.mk
│   ├── config.mk
│   ├── git.mk                 # git版本控制相关
│   └── native.mk
├── src                        # 源文件
│   ├── cpu
│   │   └── cpu-exec.c         # 指令执行的主循环
│   ├── device                 # 设备相关
│   ├── engine
│   │   └── interpreter        # 解释器的实现
│   ├── filelist.mk
│   ├── isa                    # ISA相关的实现
│   │   ├── mips32
│   │   ├── riscv32
│   │   ├── riscv64
│   │   └── x86
│   ├── memory                 # 内存访问的实现
│   ├── monitor
│   │   ├── monitor.c
│   │   └── sdb                # 简易调试器
│   │       ├── expr.c         # 表达式求值的实现
│   │       ├── sdb.c          # 简易调试器的命令处理
│   │       └── watchpoint.c   # 监视点的实现
│   ├── nemu-main.c            # 你知道的...
│   └── utils                  # 一些公共的功能
│       ├── log.c              # 日志文件相关
│       ├── rand.c
│       ├── state.c
│       └── timer.c
└── tools                      # 一些工具
    ├── fixdep                 # 依赖修复, 配合配置系统进行使用
    ├── gen-expr
    ├── kconfig                # 配置系统
    ├── kvm-diff
    ├── qemu-diff
    └── spike-diff

为了支持不同的ISA, 框架代码把NEMU分成两部分: ISA无关的基本框架和ISA相关的具体实现. NEMU把ISA相关的代码专门放在nemu/src/isa/目录下, 并通过nemu/include/isa.h提供ISA相关API的声明. 这样以后, nemu/src/isa/之外的其它代码就展示了NEMU的基本框架. 这样做有两点好处:

  • 有助于我们认识不同ISA的共同点: 无论是哪种ISA的客户计算机, 它们都具有相同的基本框架
  • 体现抽象的思想: 框架代码将ISA之间的差异抽象成API, 基本框架会调用这些API, 从而无需关心ISA的具体细节. 如果你将来打算选择一个不同的ISA来进行二周目的攻略, 你就能明显体会到抽象的好处了: 基本框架的代码完全不用修改!

这个页面对上述API进行了整理, 供将来查阅使用, 目前你无需完全明白它们的作用. "抽象"是计算机系统中一个非常重要的概念, 如果你现在不明白抽象的意义, 不必担心, 在PA的后续内容中, 你会一次又一次地遇到它.

大致了解上述的目录树之后, 你就可以开始阅读代码了. 至于从哪里开始, 就不用多费口舌了吧.

需要多费口舌吗?

嗯... 如果你觉得提示还不够, 那就来一个劲爆的: 回忆程序设计课的内容, 一个程序从哪里开始执行呢?

如果你不屑于回答这个问题, 不妨先冷静下来. 其实这是一个值得探究的问题, 你会在将来重新审视它.

配置系统和项目构建

在真正开始阅读代码之前, 我们先来简单介绍一下NEMU项目中的配置系统和项目构建.

配置系统kconfig

在一个有一定规模的项目中, 可配置选项的数量可能会非常多, 而且配置选项之间可能会存在关联, 比如打开配置选项A之后, 配置选项B就必须是某个值. 直接让开发者去管理这些配置选项是很容易出错的, 比如修改选项A之后, 可能会忘记修改和选项A有关联的选项B. 配置系统的出现则是为了解决这个问题.

NEMU中的配置系统位于nemu/tools/kconfig, 它来源于GNU/Linux项目中的kconfig, 我们进行了少量简化. kconfig定义了一套简单的语言, 开发者可以使用这套语言来编写"配置描述文件". 在"配置描述文件"中, 开发者可以描述:

  • 配置选项的属性, 包括类型, 默认值等
  • 不同配置选项之间的关系
  • 配置选项的层次关系

在NEMU项目中, "配置描述文件"的文件名都为Kconfig, 如nemu/Kconfig. 当你键入make menuconfig的时候, 背后其实发生了如下事件:

  • 检查nemu/tools/kconfig/build/mconf程序是否存在, 若不存在, 则编译并生成mconf
  • 检查nemu/tools/kconfig/build/conf程序是否存在, 若不存在, 则编译并生成conf
  • 运行命令mconf nemu/Kconfig, 此时mconf将会解析nemu/Kconfig中的描述, 以菜单树的形式展示各种配置选项, 供开发者进行选择
  • 退出菜单时, mconf会把开发者选择的结果记录到nemu/.config文件中
  • 运行命令conf --syncconfig nemu/Kconfig, 此时conf将会解析nemu/Kconfig中的描述, 并读取选择结果nemu/.config, 结合两者来生成如下文件:
    • 可以被包含到C代码中的宏定义(nemu/include/generated/autoconf.h), 这些宏的名称都是形如CONFIG_xxx的形式
    • 可以被包含到Makefile中的变量定义(nemu/include/config/auto.conf)
    • 可以被包含到Makefile中的, 和"配置描述文件"相关的依赖规则(nemu/include/config/auto.conf.cmd), 为了阅读代码, 我们可以不必关心它
    • 通过时间戳来维护配置选项变化的目录树nemu/include/config/, 它会配合另一个工具nemu/tools/fixdep来使用, 用于在更新配置选项后节省不必要的文件编译, 为了阅读代码, 我们可以不必关心它

所以, 目前我们只需要关心配置系统生成的如下文件:

  • nemu/include/generated/autoconf.h, 阅读C代码时使用
  • nemu/include/config/auto.conf, 阅读Makefile时使用

项目构建和Makefile

NEMU的Makefile会稍微复杂一些, 它具备如下功能:

与配置系统进行关联

通过包含nemu/include/config/auto.conf, 与kconfig生成的变量进行关联. 因此在通过menuconfig更新配置选项后, Makefile的行为可能也会有所变化.

文件列表(filelist)

通过文件列表(filelist)决定最终参与编译的源文件. 在nemu/src及其子目录下存在一些名为filelist.mk的文件, 它们会根据menuconfig的配置对如下4个变量进行维护:

  • SRCS-y - 参与编译的源文件的候选集合
  • SRCS-BLACKLIST-y - 不参与编译的源文件的黑名单集合
  • DIRS-y - 参与编译的目录集合, 该目录下的所有文件都会被加入到SRCS-y
  • DIRS-BLACKLIST-y - 不参与编译的目录集合, 该目录下的所有文件都会被加入到SRCS-BLACKLIST-y

Makefile会包含项目中的所有filelist.mk文件, 对上述4个变量的追加定义进行汇总, 最终会过滤出在SRCS-y中但不在SRCS-BLACKLIST-y中的源文件, 来作为最终参与编译的源文件的集合.

上述4个变量还可以与menuconfig的配置结果中的布尔选项进行关联, 例如DIRS-BLACKLIST-$(CONFIG_TARGET_AM) += src/monitor/sdb, 当我们在menuconfig中选择了TARGET_AM相关的布尔选项时, kconfig最终会在nemu/include/config/auto.conf中生成形如CONFIG_TARGET_AM=y的代码, 对变量进行展开后将会得到DIRS-BLACKLIST-y += src/monitor/sdb; 当我们在menuconfig中未选择TARGET_AM相关的布尔选项时, kconfig将会生成形如CONFIG_TARGET_AM=n的代码, 或者未对CONFIG_TARGET_AM进行定义, 此时将会得到DIRS-BLACKLIST-n += src/monitor/sdb, 或者DIRS-BLACKLIST- += src/monitor/sdb, 这两种情况都不会影响DIRS-BLACKLIST-y的值, 从而实现了如下效果:

在menuconfig中选中TARGET_AM时, nemu/src/monitor/sdb目录下的所有文件都不会参与编译.
编译和链接

Makefile的编译规则在nemu/scripts/build.mk中定义:

$(OBJ_DIR)/%.o: %.c
  @echo + CC $<
  @mkdir -p $(dir $@)
  @$(CC) $(CFLAGS) -c -o $@ $<
  $(call call_fixdep, $(@:.o=.d), $@)

其中关于$@$<等符号的含义, 可以RTFM进行了解. call_fixdep的调用用于生成更合理的依赖关系, 目前我们主要关注编译的命令, 因此可以先忽略call_fixdep.

我们可以先查看make过程中都运行了哪些命令, 然后反过来理解$(CFLAGS)等变量的值. 为此, 我们可以键入make -nB, 它会让make程序以"只输出命令但不执行"的方式强制构建目标. 运行后, 你可以看到很多形如

gcc -O2 -MMD -Wall -Werror -I/home/user/ics2024/nemu/include
-I/home/user/ics2024/nemu/src/engine/interpreter -I/home/use
r/ics2024/nemu/src/isa/riscv32/include -O2    -D__GUEST_ISA__
=riscv32  -c -o /home/user/ics2024/nemu/build/obj-riscv32-nem
u-interpreter/src/utils/timer.o src/utils/timer.c

的输出, 这样你就很容易理解上述Makefile变量的值了:

$(CC) -> gcc
$@ -> /home/user/ics2024/nemu/build/obj-riscv32-nemu-interpreter/src/utils/timer.o
$< -> src/utils/timer.c
$(CFLAGS) -> 剩下的内容

于是你就可以根据上述输出结果和Makefile反推$(CFLAGS)的值是如何形成的. 因为编译每个文件的命令都很类似, 当你理解了一个源文件的编译之后, 你就能类推到其它源文件的编译过程了. 同理, 你也可以按照上述方法理解最后的链接命令.

准备第一个客户程序

我们已经知道, NEMU是一个用来执行客户程序的程序, 但客户程序一开始并不存在于客户计算机中. 我们需要将客户程序读入到客户计算机中, 这件事是monitor来负责的. 于是NEMU在开始运行的时候, 首先会调用init_monitor()函数(在nemu/src/monitor/monitor.c中定义) 来进行一些和monitor相关的初始化工作.

kconfig生成的宏与条件编译

我们已经在上文提到过, kconfig会根据配置选项的结果在 nemu/include/generated/autoconf.h中定义一些形如CONFIG_xxx的宏, 我们可以在C代码中通过条件编译的功能对这些宏进行测试, 来判断是否编译某些代码. 例如, 当CONFIG_DEVICE这个宏没有定义时, 设备相关的代码就无需进行编译.

为了编写更紧凑的代码, 我们在nemu/include/macro.h中定义了一些专门用来对宏进行测试的宏. 例如IFDEF(CONFIG_DEVICE, init_device());表示, 如果定义了CONFIG_DEVICE, 才会调用init_device()函数; 而MUXDEF(CONFIG_TRACE, "ON", "OFF")则表示, 如果定义了CONFIG_TRACE, 则预处理结果为"ON"("OFF"在预处理后会消失), 否则预处理结果为"OFF".

这些宏的功能非常神奇, 你知道这些宏是如何工作的吗?

为什么全部都是函数?

阅读init_monitor()函数的代码, 你会发现里面全部都是函数调用. 按道理, 把相应的函数体在init_monitor()中展开也不影响代码的正确性. 相比之下, 在这里使用函数有什么好处呢?

我们对这些初始化工作进行一些说明. parse_args(), init_rand(), init_log()init_mem()并没有什么深奥的内容, 直接RTFSC就行.

参数的处理过程

parse_args()中调用了一个你也许不太熟悉的函数getopt_long(), 框架代码通过它来对参数进行解析, 具体的行为可以查阅man 3 getopt_long.

参数的处理过程

另外的一个问题是, 这些参数是从哪里来的呢?

接下来monitor会调用init_isa()函数(在nemu/src/isa/$ISA/init.c中定义), 来进行一些ISA相关的初始化工作.

第一项工作就是将一个内置的客户程序读入到内存中. 为了理解这项工作, 我们还需要理清三个问题:

  1. 客户程序是什么? 我们知道, 程序是由指令构成的, 而不同ISA的指令也各不相同(想象一下用不同的语言来表达"你好"的意思), 因而程序本身肯定是ISA相关的. 因此, 我们把内置客户程序放在nemu/src/isa/$ISA/init.c中. 内置客户程序的行为非常简单, 它只包含少数几条指令, 甚至算不上在做一些有意义的事情.

  2. 内存是什么? 我们可以把内存看作一段连续的存储空间, 而内存又是字节编址的(即一个内存位置存放一个字节的数据), 在C语言中我们就很自然地使用一个uint8_t类型的数组来对内存进行模拟. NEMU默认为客户计算机提供128MB的物理内存(见nemu/src/memory/paddr.c中定义的pmem),

  3. 需要将客户程序读入到内存的什么位置? 为了让客户计算机的CPU可以执行客户程序, 因此我们需要一种方式让客户计算机的CPU知道客户程序的位置. 我们采取一种最简单的方式: 约定. 具体地, 我们让monitor直接把客户程序读入到一个固定的内存位置RESET_VECTOR. RESET_VECTOR的值在nemu/include/memory/paddr.h中定义.

BIOS和计算机启动

我们知道内存是一种RAM, 是一种易失性的存储介质, 这意味着计算机刚启动的时候, 内存中的数据都是无意义的; 而BIOS是固化在ROM/Flash中的, 它们都是非易失性的存储介质, BIOS中的内容不会因为断电而丢失.

因此在真实的计算机系统中, 计算机启动后首先会把控制权交给BIOS, BIOS经过一系列初始化工作之后, 再从磁盘中将有意义的程序读入内存中执行. 对这个过程的模拟需要了解很多超出本课程范围的细节, 我们在PA中做了简化: 采取约定的方式让CPU直接从约定的内存位置开始执行.

初探操作系统启动

你使用windows的时候, 开机过程一般都会播放相应的开机动画, 然后不知道怎么就进入登录画面了, 这显然不能满足CSer的求知欲. 事实上, 在GNU/Linux中, 你可以很容易得知操作系统在背后做了些什么. 键入sudo dmesg, 就可以输出操作系统的启动日志, 操作系统的行为一览无余.

不过, 目前你的知识可能还无法理解其中的奥秘. 但你无需为此感到沮丧, 在PA的中后期, 你将会在NEMU上运行一个小型操作系统Nanos-lite. 虽然和GNU/Linux相比, Nanos-lite可以说是沧海一粟, 但你将会完全明白操作系统启动过程中的一些关键步骤, 操作系统的大门也将会为你敞开.

init_isa()的第二项任务是初始化寄存器, 这是通过restart()函数来实现的. 在CPU中, 寄存器是一个结构化特征较强的存储部件, 在C语言中我们就很自然地使用相应的结构体来描述CPU的寄存器结构. 不同ISA的寄存器结构也各不相同, 为此我们把寄存器结构体CPU_state的定义放在nemu/src/isa/$ISA/include/isa-def.h中, 并在nemu/src/cpu/cpu-exec.c中定义一个全局变量cpu. 初始化寄存器的一个重要工作就是设置cpu.pc的初值, 我们需要将它设置成刚才加载客户程序的内存位置, 这样就可以让CPU从我们约定的内存位置开始执行客户程序了. 对于mips32和riscv32, 它们的0号寄存器总是存放0, 因此我们也需要对其进行初始化.

物理内存的起始地址

x86的物理内存是从0开始编址的, 但对于一些ISA来说却不是这样, 例如mips32和riscv32的物理地址均从0x80000000开始. 因此对于mips32和riscv32, 其CONFIG_MBASE将会被定义成0x80000000. 将来CPU访问内存时, 我们会将CPU将要访问的内存地址映射到pmem中的相应偏移位置, 这是通过nemu/src/memory/paddr.c中的guest_to_host()函数实现的. 例如如果mips32的CPU打算访问内存地址0x80000000, 我们会让它最终访问pmem[0], 从而可以正确访问客户程序的第一条指令. 这种机制有一个专门的名字, 叫地址映射, 在后续的PA中我们还会再遇到它.

对于x86, 我们把寄存器结构体的实现作为作业. 为了检查你的实现是否正确, 我们在init_isa()中还调用了reg_test()函数(在nemu/src/isa/x86/reg.c中定义). 具体会在下文的必做题中进行介绍.

Monitor读入客户程序并对寄存器进行初始化后, 这时内存的布局如下:

pmem:

CONFIG_MBASE      RESET_VECTOR
      |                 |
      v                 v
      -----------------------------------------------
      |                 |                  |
      |                 |    guest prog    |
      |                 |                  |
      -----------------------------------------------
                        ^
                        |
                       pc

NEMU返回到init_monitor()函数中, 继续调用load_img()函数 (在nemu/src/monitor/monitor.c中定义). 这个函数会将一个有意义的客户程序从镜像文件在新窗口中打开读入到内存, 覆盖刚才的内置客户程序. 这个镜像文件是运行NEMU的一个可选参数, 在运行NEMU的命令中指定. 如果运行NEMU的时候没有给出这个参数, NEMU将会运行内置客户程序.

monitor剩余的初始化工作我们会在后续实验内容中介绍, 目前你无需关心它们的细节, 最后monitor会调用welcome()函数输出欢迎信息. 现在你可以在nemu/目录下编译并运行NEMU了:

make run

实现x86的寄存器结构体

如果你选择了x86, 框架代码并没有正确地实现用于模拟x86寄存器的结构体x86_CPU_state, 现在你需要实现它了(结构体的定义在nemu/src/isa/x86/include/isa-def.h中). 在init_isa()中调用的reg_test()函数会生成一些随机的数据, 对寄存器结构体的实现进行测试. 若实现不正确, 将会触发assertion fail. 实现正确后, NEMU将不会再触发assertion fail, 而是输出上文提到的欢迎信息. 如果你选择的ISA不是x86, 你可以忽略这道题.

x86的寄存器结构如下:

 31                23                15                7               0
+-----------------+-----------------+-----------------+-----------------+
|                                  EAX       AH       AX      AL        |
|-----------------+-----------------+-----------------+-----------------|
|                                  EDX       DH       DX      DL        |
|-----------------+-----------------+-----------------+-----------------|
|                                  ECX       CH       CX      CL        |
|-----------------+-----------------+-----------------+-----------------|
|                                  EBX       BH       BX      BL        |
|-----------------+-----------------+-----------------+-----------------|
|                                  EBP                BP                |
|-----------------+-----------------+-----------------+-----------------|
|                                  ESI                SI                |
|-----------------+-----------------+-----------------+-----------------|
|                                  EDI                DI                |
|-----------------+-----------------+-----------------+-----------------|
|                                  ESP                SP                |
+-----------------+-----------------+-----------------+-----------------+

其中

  • EAX, EDX, ECX, EBX, EBP, ESI, EDI, ESP是32位寄存器;
  • AX, DX, CX, BX, BP, SI, DI, SP是16位寄存器;
  • AL, DL, CL, BL, AH, DH, CH, BH是8位寄存器.

但它们在物理上并不是相互独立的, 例如EAX的低16位是AX, 而AX又分成AHAL. 这样的结构有时候在处理不同长度的数据时能提供一些便利. 关于x86寄存器的更多细节, 请RTFM.

Hint: 使用匿名union.

什么是匿名union?

你有这个疑问是很正常的, 但你接下来应该意识到要去STFW了.

reg\_test()是如何测试你的实现的?

阅读reg_test()的代码, 思考代码中的assert()条件是根据什么写出来的.

运行NEMU之后你应该能看到相应的欢迎信息, 以及你选择的ISA. 请务必确认输出的ISA信息与你选择的ISA一致. 不过你会看到如下的错误信息:

[src/monitor/monitor.c:20 welcome] Exercise: Please remove me in the source code and compile NEMU again.
riscv32-nemu-interpreter: src/monitor/monitor.c:21: welcome: Assertion `0' failed.

事实上, 我们已经在PA0的最后介绍过这个错误了. 作为一个练习, 你需要根据错误信息回溯到报告错误的代码中, 然后删除相应代码. 删除后重新编译NEMU, 你将看到这个错误不再出现.

运行第一个客户程序

Monitor的初始化工作结束后, main()函数会继续调用engine_start()函数 (在nemu/src/engine/interpreter/init.c中定义). 代码会进入简易调试器(Simple Debugger)的主循环sdb_mainloop() (在nemu/src/monitor/sdb/sdb.c中定义), 并输出NEMU的命令提示符:

(nemu)

简易调试器是monitor的核心功能, 我们可以在命令提示符中输入命令, 对客户计算机的运行状态进行监控和调试. 框架代码已经实现了几个简单的命令, 它们的功能和GDB是很类似的.

在命令提示符后键入c后, NEMU开始进入指令执行的主循环cpu_exec() (在nemu/src/cpu/cpu-exec.c中定义). cpu_exec()又会调用execute(), 后者模拟了CPU的工作方式: 不断执行指令. 具体地, 代码将在一个for循环中不断调用exec_once()函数, 这个函数的功能就是我们在上一小节中介绍的内容: 让CPU执行当前PC指向的一条指令, 然后更新PC.

究竟要执行多久?

cmd_c()函数中, 调用cpu_exec()的时候传入了参数-1, 你知道这是什么意思吗?

潜在的威胁 (建议二周目思考)

"调用cpu_exec()的时候传入了参数-1", 这一做法属于未定义行为吗? 请查阅C99手册确认你的想法.

不同的ISA有着不同的指令格式和含义, 因此执行指令的代码自然是ISA相关的. 这部分代码位于nemu/src/isa/$ISA/inst.c. 关于指令执行的详细说明需要涉及很多细节, 目前你无需关心, 我们将会在PA2中进行说明.

由于刚才我们运行NEMU的时候并未给出客户程序的镜像文件, 此时NEMU将会运行上文提到的内置客户程序. NEMU将不断执行指令, 直到遇到以下情况之一, 才会退出指令执行的循环:

  • 达到要求的循环次数.
  • 客户程序执行了nemu_trap指令. 这是一条虚构的特殊指令, 它是为了在NEMU中让客户程序指示执行的结束而加入的, NEMU在ISA手册中选择了一些用于调试的指令, 并将nemu_trap的特殊含义赋予它们. 例如在riscv32的手册中, NEMU选择了ebreak指令来充当nemu_trap. 为了表示客户程序是否成功结束, nemu_trap指令还会接收一个表示结束状态的参数. 当客户程序执行了这条指令之后, NEMU将会根据这个结束状态参数来设置NEMU的结束状态, 并根据不同的状态输出不同的结束信息, 主要包括
    • HIT GOOD TRAP - 客户程序正确地结束执行
    • HIT BAD TRAP - 客户程序错误地结束执行
    • ABORT - 客户程序意外终止, 并未结束执行

当你看到NEMU输出类似以下的内容时(不同ISA的pc输出值会有所不同):

nemu: HIT GOOD TRAP at pc = 0x8000000c

说明客户程序已经成功地结束运行. NEMU会在cpu_exec()函数的最后打印执行的指令数目和花费的时间, 并计算出指令执行的频率. 但由于内置客户程序太小, 执行很快就结束了, 目前无法计算出有意义的频率, 将来运行一些复杂的程序时, 此处输出的频率可以用于粗略地衡量NEMU的性能.

退出cpu_exec()之后, NEMU将返回到sdb_mainloop(), 等待用户输入命令. 但为了再次运行程序, 你需要键入q退出NEMU, 然后重新运行.

谁来指示程序的结束?

在程序设计课上老师告诉你, 当程序执行到main()函数返回处的时候, 程序就退出了, 你对此深信不疑. 但你是否怀疑过, 凭什么程序执行到main()函数的返回处就结束了? 如果有人告诉你, 程序设计课上老师的说法是错的, 你有办法来证明/反驳吗? 如果你对此感兴趣, 请在互联网上搜索相关内容.

有始有终 (建议二周目思考)

对于GNU/Linux上的一个程序, 怎么样才算开始? 怎么样才算是结束? 对于在NEMU中运行的程序, 问题的答案又是什么呢?

与此相关的问题还有: NEMU中为什么要有nemu_trap? 为什么要有monitor?

最后我们聊聊代码中一些值得注意的地方.

  • 三个对调试有用的宏(在nemu/include/debug.h中定义)
    • Log()printf()的升级版, 专门用来输出调试信息, 同时还会输出使用Log()所在的源文件, 行号和函数. 当输出的调试信息过多的时候, 可以很方便地定位到代码中的相关位置
    • Assert()assert()的升级版, 当测试条件为假时, 在assertion fail之前可以输出一些信息
    • panic()用于输出信息并结束程序, 相当于无条件的assertion fail

代码中已经给出了使用这三个宏的例子, 如果你不知道如何使用它们, RTFSC.

  • 内存通过在nemu/src/memory/paddr.c中定义的大数组pmem来模拟. 在客户程序运行的过程中, 总是使用vaddr_read()vaddr_write() (在nemu/src/memory/vaddr.c中定义)来访问模拟的内存. vaddr, paddr分别代表虚拟地址和物理地址. 这些概念在将来才会用到, 目前不必深究, 但从现在开始保持接口的一致性可以在将来避免一些不必要的麻烦.

理解框架代码

你需要结合上述文字理解NEMU的框架代码.

如果你不知道"怎么才算是看懂了框架代码", 你可以先尝试进行后面的任务. 如果发现不知道如何下手, 再回来仔细阅读这一页面. 理解框架代码是一个螺旋上升的过程, 不同的阶段有不同的重点. 你不必因为看不懂某些细节而感到沮丧, 更不要试图一次把所有代码全部看明白.

RTFSC != 盯着代码看

你很可能是第一次接触到这么多源文件的项目, 看代码的时候可能会感到很迷茫: 不知道函数的定义在哪个文件, 不理解函数的功能是什么, 不清楚某段代码的行为具体如何... 同时你也很可能沿用以前看代码最原始的方式: 用眼睛看. 你坚持看了一段时间, 发现还是没什么收获, 于是你开始感到沮丧...

对于只有一两个源文件, 代码只有几百行的项目, 直接RTFSC还是有效果的. 但如果源文件更多, 代码量更大, 你很快就会发现这种做法效率很低, 这是因为人脑的短期记忆能力是很有限的, 即使是静态的代码也无法完全记得住, 更何况程序的动态行为是一个巨大的状态机, 你的大脑在一段时间内只能模拟出这个状态机很小的一部分.

有没有工具能够帮你模拟这个巨大的状态机呢? 这时我们在PA0里面提到的一个工具就派上用场了, 它就是GDB. 在GDB中, 我们可以通过单步执行的方式让程序一次执行一条指令, 相当于让状态机一次只前进一步, 这样我们就可以观察程序任意时刻的状态了! 而且状态机前进的轨迹就是程序执行的真实顺序, 于是你就可以一边运行程序一边理解程序的行为了. 这对于一些指针相关的代码有着不错的效果, 尤其是函数指针, 因为你从静态代码上很可能看不出来程序运行的时候这个指针会指向哪个函数.

GDB还自带一个叫TUI的简单界面. 在一个高度较高的窗口中运行GDB后, 输入layout split就可以切换到TUI, 这样你就可以同时从源代码和指令的角度来观察程序的行为了. 不过为了看到源代码, 你还需要在编译NEMU时添加GDB调试信息, 具体操作见下面的提示框. 如果你想了解TUI的更多内容, STFW.

为了帮助你更高效地RTFSC, 你最好通过RTFM和STFW多认识GDB的一些命令和操作, 比如:

  • 单步执行进入你感兴趣的函数
  • 单步执行跳过你不感兴趣的函数(例如库函数)
  • 运行到函数末尾
  • 打印变量或寄存器的值
  • 扫描内存
  • 查看调用栈
  • 设置断点
  • 设置监视点

如果你之前没有使用过GDB, 然后在PA0中又跳过了GDB相关的内容, 现在你就要吃偷懒的亏了.

为NEMU编译时添加GDB调试信息

menuconfig已经为大家准备好相应选项了, 你只需要打开它:

Build Options
  [*] Enable debug information

然后清除编译结果并重新编译即可. 尝试阅读相关代码, 理解开启上述menuconfig选项后会导致编译NEMU时的选项产生什么变化.

优美地退出

为了测试大家是否已经理解框架代码, 我们给大家设置一个练习: 如果在运行NEMU之后直接键入q退出, 你会发现终端输出了一些错误信息. 请分析这个错误信息是什么原因造成的, 然后尝试在NEMU中修复它.

就是这么简单

事实上, TRM的实现已经都蕴含在上述的介绍中了.

  • 存储器是个在nemu/src/memory/paddr.c中定义的大数组
  • PC和通用寄存器都在nemu/src/isa/$ISA/include/isa-def.h中的结构体中定义
  • 加法器在... 嗯, 这部分框架代码有点复杂, 不过它并不影响我们对TRM的理解, 我们还是在PA2里面再介绍它吧
  • TRM的工作方式通过cpu_exec()exec_once()体现

在NEMU中, 我们只需要一些很基础的C语言知识就可以理解最简单的计算机的工作方式, 真应该感谢先驱啊.