RTFSC
事实上, TRM的实现是如此的简单, 以至于框架代码已经实现它了. 接下来让我们看看, 构成TRM的那些数字电路, 在NEMU的C代码中都是何方神圣. 为了方便叙述, 我们将在NEMU中模拟的计算机称为"客户(guest)计算机", 在NEMU中运行的程序称为"客户程序".
框架代码初探
框架代码内容众多, 其中包含了很多在后续阶段中才使用的代码. 随着实验进度的推进, 我们会逐渐解释所有的代码. 因此在阅读代码的时候, 你只需要关心和当前进度相关的模块就可以了, 不要纠缠于和当前进度无关的代码, 否则将会给你的心灵带来不必要的恐惧.
ics2019
├── init.sh # 初始化脚本
├── Makefile # 用于工程打包提交
├── nanos-lite # 微型操作系统内核
├── navy-apps # 应用程序集
├── nemu # NEMU
├── nexus-am # 抽象计算机
└── README.md
PA的框架代码由4个子项目构成: NEMU, Nexus-AM, Nanos-lite和Navy-apps, 它们共同组成了一个完整的计算机系统. 目前我们只需要关心NEMU子项目中的内容, 其它子项目会在将来进行介绍. NEMU主要由4个模块构成: monitor, CPU, memory, 设备. 我们已经在上一小节简单介绍了CPU和memory的功能, 设备会在PA2中介绍, 目前不必关心.
Monitor模块是为了方便地监控客户计算机的运行状态而引入的. 它除了负责与GNU/Linux进行交互(例如读入客户程序)之外, 还带有调试器的功能, 为NEMU的调试提供了方便的途径. 从概念上来说, monitor并不属于一个计算机的必要组成部分, 但对NEMU来说, 它是必要的基础设施. 如果缺少monitor模块, 对NEMU的调试将会变得十分困难.
代码中nemu/
目录下的源文件组织如下(并未列出所有文件):
nemu
├── include # 存放全局使用的头文件
│ ├── common.h # 公用的头文件
│ ├── cpu
│ │ ├── decode.h # 译码相关
│ │ └── exec.h # 执行相关
│ ├── debug.h # 一些方便调试用的宏
│ ├── device # 设备相关
│ ├── macro.h # 一些方便的宏定义
│ ├── memory # 访问内存相关
│ ├── monitor
│ │ ├── expr.h # 表达式求值相关
│ │ ├── log.h # 日志文件相关
│ │ ├── monitor.h
│ │ └── watchpoint.h # 监视点相关
│ ├── nemu.h
│ └── rtl
│ ├── rtl.h # RTL基本指令
│ └── rtl-wrapper.h
├── Makefile # 指示NEMU的编译和链接
├── Makefile.git # git版本控制相关
├── runall.sh # 一键测试脚本
└── src # 源文件
├── cpu
│ └── cpu.c # 执行一条指令
├── device # 设备相关
├── isa # ISA相关的实现
│ ├── mips32
│ ├── riscv32
│ └── x86
├── main.c # 你知道的...
├── memory
│ └── memory.c # 访问内存的接口函数
└── monitor
├── cpu-exec.c # 指令执行的主循环
├── debug # 简易调试器相关
│ ├── expr.c # 表达式求值的实现
│ ├── log.c # 日志文件相关
│ ├── ui.c # 用户界面相关
│ └── watchpoint.c # 监视点的实现
├── diff-test
└── monitor.c
为了支持不同的ISA, NEMU把ISA相关的代码专门放在nemu/src/isa/
目录下.
这样以后, nemu/src/isa/
之外的其它代码就展示了NEMU的基本框架.
这样做有两点好处:
- 有助于我们认识不同ISA的共同点: 无论是哪种ISA的客户计算机, 它们都具有相同的基本框架
体现抽象的思想: 框架代码将ISA之间的差异抽象成API, 基本框架会调用这些API, 从而无需关心ISA的具体细节. 如果你将来打算选择一个不同的ISA来进行二周目的攻略, 你就能明显体会到抽象的好处了: 基本框架的代码完全不用修改!
"抽象"是计算机系统中一个非常重要的概念, 在PA的后续内容中, 你会一次又一次地遇到它. 为了区别这些API, 我们会以
isa_xxx
的形式来对相应的变量或者函数进行命令. 如果你现在不明白抽象的意义, 不必担心, 接下来我们就会遇到不少例子了.
大致了解上述的目录树之后, 你就可以开始阅读代码了. 至于从哪里开始, 就不用多费口舌了吧.
需要多费口舌吗?
嗯... 如果你觉得提示还不够, 那就来一个劲爆的: 回忆程序设计课的内容, 一个程序从哪里开始执行呢?
如果你不屑于回答这个问题, 不妨先冷静下来. 其实这是一个值得探究的问题, 你会在将来重新审视它.
对vim的使用感到困难?
在PA0的强迫之下, 你不得不开始学习使用vim. 如果现在你已经不再认为vim是个到处是bug的编辑器, 就像简明vim练级攻略里面说的, 你已经通过了存活阶段. 接下来就是漫长的修行阶段了, 每天学习一两个vim中的功能, 累积经验值, 很快你就会发现自己已经连升几级. 不过最重要的还是坚持, 只要你在PA1中坚持使用vim, PA1结束之后, 你就会发现vim的熟练度已经大幅提升! 你还可以搜一搜vim的键盘图, 像英雄联盟中满满的快捷键, 说不定能激发起你学习vim的兴趣.
其实最主要的还是, 你需要有尝试新事物的精神.
准备第一个客户程序
我们已经知道, NEMU是一个用来执行客户程序的程序, 但客户程序一开始并不存在于客户计算机中.
我们需要将客户程序读入到客户计算机中, 这件事是monitor来负责的.
于是NEMU在开始运行的时候, 首先会调用init_monitor()
函数(在nemu/src/monitor/monitor.c
中定义)
来进行一些和monitor相关的初始化工作.
为什么全部都是函数?
阅读init_monitor()
函数的代码, 你会发现里面全部都是函数调用.
按道理, 把相应的函数体在init_monitor()
中展开也不影响代码的正确性.
相比之下, 在这里使用函数有什么好处呢?
我们对这些初始化工作进行一些说明.
parse_args()
和init_log()
并没有什么深奥的内容, 直接RTFSC就行.
参数的处理过程
parse_args()
中调用了一个你也许不太熟悉的函数getopt()
,
框架代码通过它来对参数进行解析, 具体的行为可以查阅man 3 getopt
.
参数的处理过程
另外的一个问题是, 这些参数是从哪里来的呢?
然后, monitor通过调用load_img()
函数(在nemu/src/monitor/monitor.c
中定义)
将客户程序从镜像文件读入到客户计算机的内存.
而读入客户程序, 是为了将来让客户计算机的CPU来执行它,
因此我们需要一种方式让客户计算机的CPU知道客户程序的位置.
我们采取一种最简单的方式: 约定.
具体地, 我们让monitor直接把一个有意义的客户程序读入到一个固定的内存位置IMAGE_START
(也就是0x100000
).
这个客户程序是运行NEMU的一个参数, 在运行NEMU的命令中指定,
BIOS和计算机启动
我们知道内存是一种RAM, 是一种易失性的存储介质, 这意味着计算机刚启动的时候, 内存中的数据都是无意义的; 而BIOS是固化在ROM/Flash中的, 它们都是非易失性的存储介质, BIOS中的内容不会因为断电而丢失.
因此在真实的计算机系统中, 计算机启动后首先会把控制权交给BIOS, BIOS经过一系列初始化工作之后, 再从磁盘中将有意义的程序读入内存中执行. 对这个过程的模拟需要了解很多超出本课程范围的细节, 我们在PA中做了简化: 采取约定的方式让CPU直接从约定的内存位置开始执行.
初探操作系统启动
你使用windows的时候, 开机过程一般都会播放相应的开机动画,
然后不知道怎么就进入登录画面了, 这显然不能满足CSer的求知欲.
事实上, 在GNU/Linux中, 你可以很容易得知操作系统在背后做了些什么.
键入sudo dmesg
, 就可以输出操作系统的启动日志, 操作系统的行为一览无余.
不过, 目前你的知识可能还无法理解其中的奥秘. 但你无需为此感到沮丧, 在PA的中后期, 你将会在NEMU上运行一个小型操作系统Nanos-lite. 虽然和GNU/Linux相比, Nanos-lite可以说是沧海一粟, 但你将会完全明白操作系统启动过程中的一些关键步骤, 操作系统的大门也将会为你敞开.
如果运行NEMU时未给出客户程序, 那么monitor将会读入一个内置的客户程序.
我们知道, 程序是由指令构成的, 而不同ISA的指令也各不相同
(想象一下用不同的语言来表达"你好"的意思), 因而程序本身肯定是ISA相关的.
因此, 我们把内置客户程序放在nemu/src/isa/$ISA/init.c
中,
同时抽象出uint8_t isa_default_img[]
和long isa_default_img_size
这两个变量作为API来给monitor使用.
通过这两个API, monitor就可以不关心内置客户程序的具体内容, 只要将它们读入到IMAGE_START
的内存位置即可.
接下来monitor会调用init_isa()
函数(在nemu/src/isa/$ISA/init.c
中定义),
来进行一些ISA相关的初始化工作. 这些工作主要包括:
- 记录物理内存的起始地址.
我们可以把内存看作一段连续的存储空间, 而内存又是字节编址的(即一个内存位置存放一个字节的数据),
在C语言中我们就很自然地使用一个
uint8_t
类型的数组来对内存进行模拟. NEMU默认为客户计算机提供128MB的物理内存(见nemu/src/memory/memory.c
中定义的pmem
), 但对于一些ISA来说, 物理内存并不是从0开始编址的, 例如mips32和riscv32的物理地址均从0x80000000
开始. 因此我们需要记录其物理内存的起始地址(通过register_pmem()
函数来完成), 将来CPU访问内存时, 我们会将CPU将要访问的内存地址映射到pmem
中的相应偏移位置. 例如如果mips32的CPU打算访问内存地址0x80001234
, 我们最终会让它访问pmem[0x1234]
. 这种机制有一个专门的名字, 叫地址映射, 在后续的PA中我们还会再遇到它. - 初始化寄存器.
这是
restart()
函数目前的主要功能. 在CPU中, 寄存器是一个结构化特征较强的存储阵列, 在C语言中我们就很自然地使用相应的结构体来描述CPU的寄存器结构. 不同ISA的寄存器结构也各不相同, 为此我们把寄存器结构体CPU_state
的定义放在nemu/src/isa/$ISA/include/isa/reg.h
中, 并在nemu/src/cpu/cpu.c
中定义一个全局变量cpu
. 初始化寄存器的一个重要工作是将cpu.pc
的初值设置为PC_START
,PC_START
经过地址映射之后会得到刚才我们约定的内存位置0x100000
, 这样就可以让CPU从我们约定的内存位置开始执行客户程序了. 对于mips32和riscv32, 它们的0号寄存器总是存放0
, 因此我们也需要对其进行初始化.
对于x86, 我们把寄存器结构体的实现作为作业. 为了检查你的实现是否正确,
我们在init_isa()
中还调用了reg_test()
函数(在nemu/src/isa/x86/reg.c
中定义),
它会生成一些随机的数据, 对寄存器结构体的实现进行测试. 若实现不正确, 将会触发assertion fail.
读入客户程序并对寄存器进行初始化后, 这时内存的布局如下:
pmem:
0 0x100000
-----------------------------------------------
| | |
| | guest prog |
| | |
-----------------------------------------------
^
|
pc
monitor剩余的初始化工作我们会在后续实验内容中介绍, 目前你无需关心它们的细节,
最后monitor会调用welcome()
函数输出欢迎信息.
现在你可以在nemu/
目录下编译并运行NEMU了:
make ISA=$ISA run
如果你选择了x86之外的ISA, 运行NEMU之后你应该能看到相应的欢迎信息, 以及你选择的ISA. 请务必确认输出的ISA信息与你选择的ISA一致.
实现x86的寄存器结构体
如果你选择了x86, 运行NEMU会出现assertion fail的错误信息,
这是因为框架代码并没有正确地实现用于模拟x86寄存器的结构体CPU_state
,
现在你需要实现它了(结构体的定义在nemu/src/isa/x86/include/isa/reg.h
中).
实现正确后, NEMU将不会再触发assertion fail, 而是输出上文提到的欢迎信息.
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
又分成AH
和AL
.
这样的结构有时候在处理不同长度的数据时能提供一些便利.
关于x86寄存器的更多细节, 请RTFM.
Hint: 使用匿名union.
什么是匿名union?
你有这个疑问是很正常的, 但你接下来应该意识到要去STFW了.
reg_test()是如何测试你的实现的?
阅读reg_test()
的代码, 思考代码中的assert()
条件是根据什么写出来的.
运行第一个客户程序
当你看到NEMU的命令提示符, 表示monitor的初始化工作已经结束,
NEMU进入了用户界面主循环ui_mainloop()
(在nemu/src/monitor/debug/ui.c
中定义),
并输出NEMU的命令提示符:
(nemu)
用户界面主循环是monitor的核心功能, 我们可以在命令提示符中输入命令, 对客户计算机的运行状态进行监控和调试. 框架代码已经实现了几个简单的命令, 它们的功能和GDB是很类似的.
在命令提示符后键入c
后, NEMU开始进入指令执行的主循环cpu_exec()
(在nemu/src/monitor/cpu-exec.c
中定义).
cpu_exec()
的循环中有较多条件编译的内容, 忽略它们之后,
我们可以看到cpu_exec()
模拟了CPU的工作方式: 不断执行指令.
exec_once()
函数(在nemu/src/cpu/cpu.c
中定义)让CPU执行当前PC指向的一条指令, 然后更新PC.
已经执行的指令会输出到日志文件nemu/build/nemu-log.txt
中, 你可以打开日志文件来查看它们.
究竟要执行多久?
在cmd_c()
函数中, 调用cpu_exec()
的时候传入了参数-1
, 你知道这是什么意思吗?
潜在的威胁 (建议二周目思考)
"调用cpu_exec()
的时候传入了参数-1
", 这一做法属于未定义行为吗?
请查阅C99手册确认你的想法.
不同的ISA有着不同的指令格式和含义, 因此执行指令的代码自然是ISA相关的.
这部分代码位于nemu/src/isa/$ISA/exec/
目录下.
其中一个重要的部分是定义在nemu/src/isa/$ISA/exec/exec.c
文件中的opcode_table
数组,
在这个数组中, 你可以看到框架代码中都已经实现了哪些指令.
其中EMPTY
代表对应的指令还没有实现(也可能是不存在该指令).
在以后的PA中, 随着你实现越来越多的指令, 这个数组会逐渐被它们代替.
关于指令执行的详细说明需要涉及很多细节, 目前你无需关心, 我们将会在PA2中进行说明.
温故而知新
opcode_table
到底是个什么类型的数组? 如果你感到困惑, 你需要马上复习程序设计的知识了.
这里有一份十分优秀的C语言教程.
事实上, 我们已经在PA0中提到过这份教程了, 如果你觉得你的程序设计知识比较生疏,
而又没有在PA0中阅读这份教程, 请你务必阅读它.
由于刚才我们运行NEMU的时候并未给出客户程序的镜像文件,
此时NEMU将会运行上文提到的内置客户程序.
内置客户程序的行为非常简单, 它只包含少数几条指令,
甚至算不上在做一些有意义的事情.
你可以在nemu/src/isa/$ISA/init.c
中查看内置客户程序的具体行为.
NEMU将不断执行指令, 直到遇到以下情况之一, 才会退出指令执行的循环:
- 达到要求的循环次数.
- 客户程序执行了
nemu_trap
指令. 这是一条特殊的指令, 在ISA手册中并不存在, 它是为了在NEMU中让客户程序指示执行的结束而加入的. 为了表示客户程序是否成功结束,nemu_trap
指令还会接收一个表示结束状态的参数. 当客户程序执行了这条指令之后, NEMU将会根据这个结束状态参数来设置NEMU的结束状态, 并根据不同的状态输出不同的结束信息, 包括HIT GOOD TRAP
- 客户程序正确地结束执行HIT BAD TRAP
- 客户程序错误地结束执行ABORT
- 客户程序意外终止, 并未结束执行
当你看到NEMU输出类似以下的内容时(不同ISA的pc输出值会有所不同):
nemu: HIT GOOD TRAP at pc = 0x00100026
说明客户程序已经成功地结束运行.
退出cpu_exec()
之后, NEMU将返回到ui_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/memory.c
中定义的大数组pmem
来模拟. 在客户程序运行的过程中, 总是使用vaddr_read()
和vaddr_write()
访问模拟的内存. vaddr, paddr分别代表虚拟地址和物理地址. 这些概念在将来才会用到, 目前不必深究, 但从现在开始保持接口的一致性可以在将来避免一些不必要的麻烦.
理解框架代码
你需要结合上述文字理解NEMU的框架代码. 需要注意的是, 阅读代码也是有技巧的, 如果你分开阅读框架代码和上述文字, 你可能会觉得阅读之后没有任何效果. 因此, 你需要一边阅读上述文字, 一边阅读相应的框架代码.
如果你不知道"怎么才算是看懂了框架代码", 你可以先尝试进行后面的任务. 如果发现不知道如何下手, 再回来仔细阅读这一页面. 理解框架代码是一个螺旋上升的过程, 不同的阶段有不同的重点. 你不必因为看不懂某些细节而感到沮丧, 更不要试图一次把所有代码全部看明白.
阅读Makefile
阅读nemu/Makefile
, 尝试理解NEMU的编译过程.
特别地, NEMU是如何支持多种客户ISA的?
你现在可以忽略这个问题, 但你会在PA2中重新面对它.
就是这么简单
事实上, TRM的实现已经都蕴含在上述的介绍中了.
- 存储器是个在
nemu/src/memory/memory.c
中定义的大数组 - PC和通用寄存器都在
nemu/src/isa/$ISA/include/isa/reg.h
中的结构体中定义 - 加法器在... 嗯, 这部分框架代码有点复杂, 不过它并不影响我们对TRM的理解, 我们还是在PA2里面再介绍它吧
- TRM的工作方式通过
cpu_exec()
和exec_once()
体现
在NEMU中, 我们只需要一些很简单的C语言知识就可以理解最简单的计算机的工作方式, 真应该感谢先驱啊.