Skip to content

Latest commit

 

History

History
891 lines (458 loc) · 107 KB

Thought.md

File metadata and controls

891 lines (458 loc) · 107 KB

食用声明

PA实验的版本为ics2019,具体参考实验手册南大2019PA实验。后续的标题与该实验手册的目录是相对应的。

此处关闭了原PA的开发跟踪,采用原生的git方式一步步提交相应的改动,关闭方式参考

这里记录了PA实验过程中的思考和git log跟踪目录,具体可以通过git reset的方式,或查看commit记录回到当时提交的时间节点,查看相应的改动。查看改动的方式可以使用git diff

理解有时难免会出错,可以在issues中指出,非常感谢。

谢谢食用。

(一刷走的是x86的路线)

目录

1. 基本环境

2. 通过PA能收获什么

3. PA0

4. PA1

-- 4.1 PA1-3

-- 4.2 PA1-4

-- 4.3 PA1-5

-- 4.4 PA1-6

-- 4.5 PA1-7

5. PA2

-- 5.1 PA2-2

-- 5.2 PA2-3

-- 5.3 PA2-4

-- 5.4 PA2-5

6. PA3

-- 6.1 PA3-2

-- 6.2 PA3-3

-- 6.3 PA3-4

-- 6.4 PA3-5

7. 参考资料

基本环境

系统环境:虚拟机vbox + debian,其中vbox是6.0.14版本(不太建议,因为要安一些麻烦的拓展工具),debian是10.3.0版本。docker用起来比较麻烦,所以比较推荐使用vbox。主机环境为win10,cpu和内存影响不是很大。在一切就绪后debian默认应该是800*600分辨率,偏小,建议调成1280*800以上(在桌面右键Application/Settings/Display中设置)。

操作环境:tmux + vim。其中tmux在PA中有链接教学,相比于多终端我更喜欢使用tmux切换和分屏查看代码和执行程序。vim按照PA修改配置即可,够用就行。多依赖键盘而不是鼠标可以在一定程度上提高效率,但不强求,毕竟需要有一定vim的功底和命令行的基础。

通过PA能收获什么

其实依我个人的看法,凡是学习或涉及到指令程序(包括计算机、单片机、FPGA,或者指硬件层面)的专业,都很有必要做一遍PA,因为它能够帮你迅速且全面地掌握系统、架构、编译相关的知识,通过实操使你的印象更加深刻,也充分锻炼了debug即解决问题的能力。除此之外,RTFSC、RTFM、RTFW等获取信息的能力也会得到极大的提升。这是从硬件与软件的中间层,即系统层这种极为基础的层次了解一个系统与环境的基本构成与实现,这对于相关专业都是极为有意义的。

PA0

基础知识,跟着实验走即可。

PA1

PA1主要是熟悉PA的基础——硬件相关的nemu。主要是搭建一个运行在nemu之上的简易调试器,能够完成一些简单的调试功能(或者说针对性的调试),而这在后面的PA也是用的上的很重要的一部分。此外通过PA1也能对nemu有一个大致的了解,知道功能模块的分布,并学会阅读i386手册,为PA2打下基础。

PA1-3

Reorganize the structure "CPU_state" (nemu/src/isa/x86/include/isa/reg.h).

这里需要查阅i386指令手册中的寄存器编码方案,具体在INTEL 80386中的31页(2.3.2节),也可以参考实验手册给出的x86寄存器结构。

寄存器分为3类,32位、16位、8位,且物理上不是独立的,即其是共享一块内存的。从命名方式也可以看出,如EAX是32位,其低16位是AX,AX中分为高8位(AH,H->high)和低8位(AL,L->low)。

根据共享内存这一条件可以想到使用union,通过将3类寄存器用union而非struct的方式定义,可以实现上述的寄存器设计。关于union可STFW获取更多信息。

在修改代码前先查看相应的测试代码(nemu/src/isa/x86/reg.c中的reg_test()),查看assert的相关条件。可以看出其先随机给32位的寄存器赋值,然后通过assert判断32位寄存器中低16位和16位中的高低8位的值是否与对应的寄存器中存放的值相等。这与x86寄存器的设计以及上述思考是一致的。因此上述实现可以通过第一部分的assert测试。

此外在测试代码中还可以发现,其第二部分的assert测试是直接调用cpu.eax的方式访问相应的寄存器,即32位寄存器在cpu中有着类似cache的方式,有直接的变量对应,可以直接访问。因此回到reg.h的头文件中,需要再用一个union将相应的变量定义(eax等)与上述union定义的3类寄存器包含,使其也共享同一块内存空间。

如果此时直接运行测试,会发现assert会在第二部分的第二条语句触发。因为cpu.eax等的定义是uint32,相当于在union中连续声明8个uint32,了解过union共享内存的方式可以得知eax,ecx等寄存器在此时对应的都是同一个内存块(即reg[0]),因此eax可以pass第一个assert,但ecx由于对应的是eax的值,因此无法pass第二个assert。这里的解决方式很简单,只需要用一个struct将uint32的连续声明括住,定义一个大的结构体来表明其是一个整体而非互不相关的变量即可,此时的struct将与另一个union共享32*8的内存空间,而在struct内部则是按顺序连续分配32字节的内存块,可以实现一一对应。

到这里就可以执行make run的指令并通过reg的所有测试,启动nemu了。

git log: 0a61a1821ee0a44b0171e5b154d8ecbccce748db

小结:1、熟悉了x86指令集的cpu寄存器结构。 2、熟悉了union的原理和使用方式。 3、根据测试代码进行分析与修改。

调用cpu_exec()时传入参数-1.

注意到其形参是uint64_t,传入-1意味着其可执行无符号64位数所支持的最大数的指令数(内置有一个循环,循环次数为可执行的指令数)。在C99中已有对64位整型数的支持,当给uint64赋值为-1时,其为uint64_MAX - 1,当然前提是平台支持64位。

PA1-4

实现单步执行,打印寄存器,扫描内存的debug功能。(nemu/src/monitor/debug/ui.c

单步执行只需要调用cpu_exec,传入相应的参数即可。

打印寄存器使用strtok分离出子参数,根据子参数进入打印寄存器的条件块,调用ISA的API即可。这里选择的是x86,因此需要在nemu/src/isa/x86/reg.c中完善相应api的代码。打印方式可以参考相应的测试代码,以及使用reg.h头文件中定义的函数和声明的变量数组。

扫描内存同样用strtok分别分离出两个子参数,这里先将EXPR暂时规定为十六进制而非表达式。访问内存数据的方式参考nemu/include/memory/memory.h中定义的宏和函数,直接调用即可(相关代码文件包括nemu/src/isa/x86/mmu.c以及nemu/src/memory/memory.c)。其中形参len指代的是字节的长度(详见memory.c)。输出格式参考命令si的输出。

git log: 6247203359e269e0e2a466579f03aaf93d78be9d

小结:1、熟悉使用strtok处理命令的参数。 2、熟悉monitor中debugger的框架。

PA1-5

实现算术表达式的词法分析(nemu/src/monitor/debug/expr.c)

代码实现可分为两部分,第一部分是定义,第二部分是功能实现。

在定义中,包括TOKEN类型值的定义,以及TOKEN规则的定义。根据词法分析一开始给出的表达式例子,在这里实现加(ADD)、减(SUB)、乘(MUL)、除(DIV)、十六进制(HEX)、指针解引用(DEREF)、小括号(LP、RP)、数字(NUM)、变量(VAR)、寄存器(REG)等基本定义以及根据其定义的equal符号(==),补充不等(NEQ)、非(NOT)、与(AND)、或(OR)等逻辑符号,最后用正则表达式定义相应的规则即可。

(不过此处为了简单起见,负数的处理在后续内容中暂不涉及,正如KISS法则,先实现简单的功能,让其能够run起来。在此基础上,遇到有更多功能的需求时也可以很方便的参考写出相应的代码。一开始就面面俱到只会让自己头大,并写出满目疮痍BUG频出的代码。)

注意1:由于循环中是按照规则定义的顺序来匹配字符串的,因此有冲突的正则定义需要有正确的顺序(比如HEX与NUM,NEQ和NOT等)。

注意2:在enum中定义TOKEN类型值的时候是顺序递增的,此处为了方便对常用的加(ADD)等符号直接用相应符号的ASCII值,定义完常用符号后需要恢复原来的排序值。

注意3:c的regex不支持\d正则匹配,可用[0-9]的方式来替代。

在功能实现部分,需要对表达式进行词法分析,简单来说就是将表达式中的所有符号和数字一一分离并保存。在框架代码中已经通过循环的方式用规则匹配并识别出了相应的token,后续只需要将这些token存至数组中即可。对于几种特殊的类型,即HEXNUMVARREG,需要记录其字符串,以便在后续对其值进行分析,而一般的符号只需要记录其符号类型即可判断其作用。

注意4:使用assert的方式拦截缓冲区溢出的情况。

后续可通过打印token数组的方式对实现进行调试。

git log: e4a738a8ba160ecd490592268bf2706861217b65

实现算术表达式的递归求值

在实验手册中已有BNF的解释说明与实现框架,只需要根据BNF的定义说明填充框架内的细节代码并补充部分必要的函数功能即可。这里通过传入success指针的方式来做相应的错误处理并提前结束求值过程,包括括号匹配的问题以及其他的一些非法表达式。

在做算术表达式求值前,通过check_deref的方式提前将乘(MUL)与解引用(DEREF)分开,具体的判断方式是解引用符号是第一个token或前面一个token是操作符而非数值。

负数求值选做留至2周目。(负数符号与解引用符号是类似的,识别方式相同,处理方式类似)

注意1:要考虑到只有一个解引用作为主运算符的情况,此时左式val1不存在,右式仅有一个数值或变量val2作为指针地址。(非运算符同理)

git log: fb0f64b47a5854131d9e2dbd887dea4ffec34d61

随机测试,实现表达式生成器

本身的框架和代码并不复杂,是很简单的功能,较复杂的结果计算与保存已经有相应的代码实现。而需要注意的要求有:

注意1:长表达式buf的溢出判断。

注意2:随机插入空格的方式。这里仅实现插入0或1个空格。

注意3:除0行为。由于结果是uint32_t即无符号的32位整型数,因此除法很容易带来除0的情况。为了减少这种无效的表达式,可以将生成除号的概率降低。

注意4:保证表达式进行无符号运算。

为什么要使用无符号类型?

一个是溢出的问题,第二个是在表达式求值没有需要用到负数结果的情况。进行有符号运算可能会出现非法或溢出的问题。

举例来说,如果是8 bitunsigned,在数据超过2^8时会取余(实际上是不断轮回到0直到其在表达范围内),包括出现负数时也会用2^8减去相应的值,可以将其看成是一个由数值范围组成的圆。而对于8 bitsigned,超出范围即溢出时不会做求余运算,在C中的定义是Undefined Overflow,由于最高位是符号位,因此会在截断多余的高位后进行符号判断,决定是否采用补码。

除0的确切行为。

当出现除0行为的表达式,如123 / (456 / (789)),表达式生成器依旧会将其嵌入至求值代码中。此时求值结果会报warning: division by zero,而输出至input中的计算结果是上一次计算的结果,显然结果是错误的。因为除0行为的算式没有求值结果,表达式生成器便会用result变量原本的值输出。因此需要过滤除0行为的表达式,否则会出错。

注意5:除了需要过滤除0行为的表达式,溢出的表达式也应过滤,即求值“计算器”不处理溢出的表达式。

过滤除0行为的表达式。

其中一个解决方案是在表达式求值的调用代码中操作。第二个解决方案是直接在表达式求值的代码中操作。归根结底都是对除0的报错做相应的处理。但过滤溢出行为只能采用后者的方法,通过返回特殊的值进行处理。

这里做过滤除0的工作,溢出过滤比较麻烦,这里暂不实现。

有两种方法可以过滤除0,一个是采用system的方式先执行一遍“计算器”程序,如果不是正常返回说明是无效的除0表达式,过滤后重新生成表达式即可。但由于system会输出合法表达式的结果,因此需要在后续将结果打印至文件中,即通过FILE的形式使用fputs,而非使用printf后在shell中调用./gen-expr [amount] > input命令将程序打印的输出保存至input文件中。

为了保留原本程序的特性,即使用> input的方式保存相应的结果和表达式,这里采用第二种方法,即对popen打开管道中的内容进行判断。通过代码调试和分析可以发现,不能直接调用feof来判断是否出现了除0等非法行为导致没有输出,因为对于合法表达式和除0表达式,feof均输出0,即没有到文件结尾。原因是即使程序没有输出,在popen的管道中仍会存在字符串的结尾\0。故这里通过getc的方式读取第一个字符,判断其是否是EOF来进而判断其是否正确执行了计算(从而过滤除0)。当然也可以先执行getc将第一个字符读出后再调用feof判断是否到了文件结尾,对于无输出的情况,其会将\0先读出,此时到了文件结尾feof会输出非0值;对于有输出的情况,即使只有一个个位数,其也会因为残余\0而未到文件结尾,feof输出0值。

需要注意的是由于使用了getc读取了第一个字符,因此需要重新回到文件开头读取正确的结果。

注意6rewind不适合在popen中使用,在stack overflow中有相应的问题,因为rewind是特殊的fseek,而fseek仅适用于文件而不适用于流,故popen的管道(也是流)是无法通过rewind的方式回到开头的。如果检查rewind的errno也可以发现这一点。事实上,由于管道是一小块内存缓冲,只存放临时产生的数据,因此无法使用seek。若要回到文件开头,这里采用的方式是关闭原来的管道并重新创建新管道。

完善测试与实现和完善debugger中的表达式功能

input文件中生成了表达式与结果后,在nemu/src/main.c中编写相应的测试函数,并在main入口主函数中调用该测试函数即可。根据测试的结果对原来的实现进行分析,修复隐藏的bug。

通过这种方式确实找出了一个没报错的隐藏bug,这个bug在正常情况下也不会触发,但在表达式出现多个括号的情况下就可能出现。bug是在找主运算符中对括号的处理上出现了问题,具体可以通过git log以及git diff查看相应的改动。

到此时PA1-5求值表达式的功能实现与完整测试已经完成,只需要在之前留空的PA1-4-debugger中补充相应表达式功能即可。包括:

  1. 实现cmd_p的表达式求值功能。
  2. cmd_x扫描内存中暂用的16进制数替换成EXPR即表达式求值,用所求的值作为扫描的内存地址。

git log: ea31260b2bba64e609e0c39350926248f64e9bd1

PA1-6

扩展表达式求值的功能

这部分工作在PA1-5中已经基本完成。值得注意的是在PA1-5中寄存器的求值处理在这里需要做一点改变,将其抽象至ISA中,直接调用ISA的API来获取相应的值。具体的实现工作放在ISA的API中去做。

git log: c0abf3e3d6cc8aa3352d9121f2e8013810daf7c6

实现监视点池的管理

主要是链表的插入与删除操作(为了方便删除操作的参数使用监视点序号n)。(nemu/src/monitor/debug/watchpoint.c)

static的含义,为什么使用。

在定义不需要与其他文件共享的全局变量时,加上static关键字能够有效地降低程序模块之间的耦合,避免不同文件同名变量的冲突,且不会误使用。

实现监视点

首先需要确定监视点所监视的对象,即在监视点结构中需要加入什么监视变量。这里需要记录的对象有表达式以及表达式的值(或称为未发生改变的旧值)。(nemu/include/monitor/watchpoint.h)

随后需要新增监视点对应的功能函数,包括两方面:(nemu/src/monitor/debug/watchpoint.c)

  1. 每执行一条指令,需要调用函数1来对监视的表达式求值,比较其是否发生变化。若发生变化,则暂停程序。
  2. 在调用命令info w时需要调用函数2,以打印监视点中的详细信息。

确定好需求和目标后实现起来并不困难,只需要按照要求完成即可。

最后在PA1-4的debugger中更新与监视点相关的功能。(nemu/src/monitor/debug/ui.c

需要注意的是,因为要在执行指令的过程中检查监视点的值是否发生变化,因此需要在cpu_exec中植入与监视点相关的检查代码。(nemu/src/monitor/cpu-exec.c

git log: bbba03087a58c8fa921859c2fc38580789fe6c2f

调试工具与原理

一些很有用的调试方式与建议,GDB也很强大。

在GDB中运行触发段错误的程序,会提供什么信息?

关于段错误可以参考百科,GDB会提供以下信息:

  1. 程序触发的错误类型(段错误),接收到什么信号而终止的程序(可以通过man 7 signal查询对应信号)。
  2. 触发错误的具体位置,即源文件的行数、所在的函数、函数的地址。
  3. 触发错误对应行的详细代码。

可以看出GDB的强大作用,在assertprintf不管用的情况下,GDB是很好的选择。

断点

该篇提到了用监视点来模拟断点的功能,以方便查看程序在某一时刻的状态。值得注意的是,断点地址仅能设置在每条完整指令的开头,设置在中间时会跳过,实际上用si单步执行指令时也可以发现这一点。当然这是合理的,且不论实际的编译调试就是如此,更深入地看,不同的指令有不同的字节长度,而每次执行完指令寄存器PC都会跳到下一条指令的地址,而不是下一个字节。

提高断点的效率

设置断点会明显降低NEMU执行程序的效率,这是显然的。因为每执行一次指令都需要检查所有的监视点,而检查的过程包括对表达式的一次求值,这个过程相比于执行单条指令是慢很多的,因此会大大降低执行效率(甚至可能是10%的时间在执行指令,90%的时间在检查断点)。可以很容易的发现断点的效率之所以非常低下,归根结底是因为断点是用监视点来模拟的,为了解决这个问题,则需要从根源着手。监视点本身相比于断点是低效的,因此需要将断点与监视点的功能分离开来。类似于debugger中x86的int 3断点中断指令,在设置断点的时候,需要将断点目的地址的数据保存起来,然后将指令的第一个字节码替换成对应的中断指令(0xCC),由CPU自己产生中断而不需要像监视点一样每条指令都进行扫描。

具体方法不难,只需要设置对应的断点地址,用框架内置已实现的vaddr_read将地址内的指令数据读出,再用(data & 0xFFFFFF00) | 0xCC(因为这里一次读了4个字节)后vaddr_write写回即可,遇到断点暂停后再将原数据写回(这一步则需要在cpu-exec.c中完成,与监视点的暂停实现类似)。

一点也不能长以及随心所欲的断点

int 3指令长度是1个字节,这是必须的。指令的长度变成了2个字节后文章中所说的断点机制就不再能正常工作了,并非指的是程序无法在断点处停下,而是如果需要在单字节指令处设置断点(如汇编中的DEC/INC),意味着下一条指令的首字节会被覆盖而改变原意,导致无效的指令,具体案例可以参考[这里]的More on int 3小节。

如果把断点设置在指令的非首字节,即中间或者末尾,那么在GDB中程序并不会在断点处停下,这与我们之前实现的监视点形式的断点是类似的。原因在前面已经提到,CPU在执行指令的过程中会先读PC寄存器指向地址的第一个字节,即操作码,在未遇到int 3这类中断暂停的单字节指令时程序会继续执行,而植入在中间或末尾的断点只会改变操作码后面的字节数据,造成错误的结果。

举个例子,当指令为mov ebx, 1时,机器的字节码为bb 01 00 00 00 | mov $0x1, %ebx,这是一个5字节长度的指令,如果将首字节的0xbb替换为0xcc,将引入一个int 3断点中断。当断点设置在非首字节,如将0x01替换为0xcc,则指令的原意将发生改变,且不会触发中断暂停程序。

PA1-7

使用的ISA为x86,按照假设需要在调试上花费时间75小时。简易调试器花费25小时,节省50小时,事实上使用GDB直接调试客户程序需要的时间可能更长,相比于简易调试器而言更难且效率更低。

通过查阅x86手册可以得到以下问题的答案:

  1. EFLAGS寄存器中的CF位是“Carry Flag”,是进位或借位标志位,在计算前后会对其进行处理。具体解释在3.2节,EFLAGS寄存器结构图在2.3.4节的图2-8。
  2. ModR/M字节是用于指示操作数是在寄存器中还是在内存中。具体参考2.5.3节。
  3. mov指令的具体格式是MOV [DEST], [SRC]。具体参考17.2.2.11节。

shell命令可以统计代码行数,包括除去空行时的行数。命令为:find . -name "*.[ch]" | xargs cat | wc -l,除去空行则在wc -l前加上grep -v ^$和管道即可。至于在PA1编写了多少代码,只需要通过git log以及git reset回到相应的历史版本即可,或者通过git checkout master回到最初的pa master分支,因为此时还没合并分支,即分支pa1是超前的,因此可以通过比较分支pa1的代码行数以及分支master的代码行数来统计在PA1中编写了多少代码。可将上述统计代码行数的命令加入Makefile中。

git log: 6ecf9bc47235bb49b40768a017fed9d5146bb816

在GCC的编译选项中,-Wall-Werror分别是打开GCC的所有警告,以及将所有警告当成错误进行处理。使用这两者可以对编译过程中可能存在的问题进行检查,通过error的方式找出这些问题,而不会被忽略成为隐患。

到这里PA1的内容已经结束。基本的调试与模拟工具NEMU已基本实现,为之后的PA打好了基础。

PA2

PA2开始深入nemu并熟练掌握其原理和运行方式,对硬件层面的nemu完成基本的实现(大概有80%),主要需要在i386手册与指令的译码执行上折腾。完成后能作为软件与硬件的中间层完成CPU的绝大部分工作。随后还会对抽象出来的、独立于架构之上的软件层am做一定的了解并实现常用的一些库函数。测试程序通过一些基本的操作或者调用这些库函数来完成一个从代码到软件(库函数),从软件到硬件执行(机器码的译码与执行),即从am到nemu的一个运行过程,到这里便实现了一个图灵机。最后在am中添加了对设备的支持,使得与键盘、时钟和显示互动成为可能。

PA2-2

RTFM,找手册指令集内容的位置 (x86)

每一条指令具体行为的描述: P255标题为17.2.2.11 Instruction Set Detail。之后很长一段都是描述各个指令的具体行为。

指令opcode的编码表格: P412标题为Appendix A Opcode Map。附录开头给出简写定义,P414-P416给出编码表格。

RTFSC2, 整理一条指令在NEMU中的执行过程

  1. 取指令,在/include/cpu/exec.h中调用函数instr_fetch,通过访问相应长度(len)的内存地址(pc)将指令(的值,即机器码)取出并返回。

  2. 译码与执行,根据取出指令的机器码,通过翻译其操作码opcode来决定指令对应的具体操作OpcodeEntry元素。在include/cpu/exec.h中的idex函数抽象出了译码与执行的两个过程,从译码查找表中取得的元素(或称之为辅助函数,包含译码辅助函数和执行辅助函数)会被作为参数进行调用。

  3. 译码的具体细节,找出操作码后还需要找出操作对象,这就涉及到了译码辅助函数,在include/cpu/decode.h中统一通过宏make_DHelper来抽象定义,不同的译码辅助函数负责单独一种的操作数译码。为了进一步解耦出操作数译码和指令译码,还额外使用了宏makeDopHelper来专门抽象定义操作数译码的辅助函数,该宏和相应的函数在src/isa/x86/decode/decode.c中定义(因为这是ISA相关的内容,具体原因请查看讲义的蓝框背景说明)。

  4. 执行的具体过程,在include/cpu/exec.h中也定义了相应的执行辅助函数的宏make_EHelper,而执行辅助函数内具体通过RTL(寄存器传输语言)指令来真正执行对应的指令。关于RTL的行为定义,在include/rtl中包含了rtl.h以及c_op.h,对应的RTL寄存器则在include/cpu/decode.h以及ISA相关的src/isa/x86/include/isa/reg.h中定义,还有供中间操作的临时寄存器(src/cpu/cpu.c)。

  5. 更新PC,根据执行指令的长度,更新pc至下一条指令的地址。

运行第一个C程序

报错信息的来源

在nemu的文件目录下查找源码中含有的关键字即可,find ./ -type f | xargs grep -n "invalid opcode"可以发现是在src/isa/x86/exec/special.c的第17行输出的报错信息。更具体的,后续调用的display_inv_msg的invalid函数则是在src/cpu/inv.c中定义的。

指令的实现

首先根据上述RTFSC2的问题可以知道执行指令的入口在idex函数处,这部分会调用两个辅助函数来完成译码与执行两部分工作。而在这之前我们需要考虑的是操作码的识别与索引opcode_table找出相应的OpcodeEntry元素这两个过程。通过查看src/isa/x86/exec/exec.cisa_exec函数我们可以发现,操作码的识别(即取出当前pc指向地址的第一个字节作为操作码),以及操作码的译码表索引(源码中为opcode_table[opcode])已经在nemu的框架内实现。跟着源码前进我们可以发现我们需要补充实现的部分为opcode_table的填充,为其补上相应的OpcodeEntry元素,更准确的说,通过查看OpcodeEntry元素的结构(在include/cpu/exec.h中),我们需要给出该操作码对应的两个辅助函数以及相应操作数的宽度。

KISS法则,这里仅实现dummy中必要的指令。注意到include/cpu/exec.h中定义的宏IDEXWIDEX可以很方便地初始化生成OpcodeEntry元素,仅需要填入对应操作的函数名字与操作数宽度。此外还有EXW, EXEMPTY

接下来查看反汇编结果,发现其涉及的操作码有(这里省去0x)bd, bc, e8, 90, 31, c3, 55, 89, 83, 68, d6, eb,其中框架代码未实现的为e8, 90, 31, c3, 55, 68, eb。不过90是nop即无操作,eb对应的jmp是在(bad)后面,即函数无法正确返回时执行的操作。因此如果仅仅是让dummy程序跑起来,只需要实现e8, 31, c3, 55, 68即call, xor, ret, push。其中push有两种形式,寄存器和立即数。此外,83的sub虽然表中已经实现,但需要额外在src/isa/x86/include/isa/reg.h寄存器结构体中添加EFLAGS寄存器。

译码辅助函数的实现在src/isa/x86/decode/decode.c中。实现的过程中需要参考之前提到的i386的opcode编码表格和指令的具体行为描述,且仔细思考后会发现慢慢能看懂P412之后的几个表格。这里给出各个指令除填表外涉及到的函数。(在src/isa/x86/include/isa/decode.h头文件中列出了所有decode函数)

  1. 实现call,i386(后略)的P275可以看到e8对应的rel32的指令,找到Operation部分发现需要先将当前pc压入栈中保存(事实也是如此),因此需要先实现压栈的rtl。

  2. 实现xor。P414可知操作码31涉及make_DopHelper(G2E),已实现,填表时直接调用即可。注意到xor得出的结果需要覆盖(replace)第一个操作数,在这里指的是id_dest的内存地址/寄存器的值,而非操作数的临时内容val。部分EFLAGS也需要更新。

  3. 实现ret。直接调用ret的执行辅助函数即可,无需额外译码辅助。

  4. 实现push。涉及make_DopHelper(I)make_DopHelper(r),均已实现,可以直接调用,填表即可。值得注意的是,由于rtl支持的栈操作pushpop均使用4字节,因此在调用exec_push时需要先做符号扩展rtl_sext,将操作数扩展至32位。

  5. 实现sub。涉及make_DopHelper(SI), rtl_sext。注意到在make_DopHelper(SI)已经默认参数load_valtrue了,直接执行了rtl_li操作,且op->simmint32_t类型。

执行辅助函数的实现在src/isa/x86/exec整个文件夹中,包括special.c给出了nop, inv, nemu_trap等特殊操作;control.c给出了jmp, jcc, jmp_rm, call, call_rm, ret, ret_imm等控制操作;system.c给出lidt, mov_r2cr, mov_cr2r, int, iret, in, out等系统操作;剩余arith.c为数学操作,logic.c为逻辑操作,data-mov.c为数据移动相关操作。很多都留空需要实现相关的代码才能正常运作。

在译码表填充完毕后,剩下的事就是找到相应的RTL指令函数(include/rtl/rtl.h以及src/isa/x86/include/isa/rtl.h),实现执行辅助函数调用的相关部分,然后运行。

额外需要注意的是,sub或之后要实现的add都会涉及到CF进/借位位和OF溢出位的判断。其中CF主要针对的是无符号数,而OF主要针对的是有符号数。无符号数不会发生溢出问题,只会出现进位,即只管CF;有符号数“不会”出现进位(因为最高位已经是符号位了),只会发生溢出问题,即只管OF。事实上,有符号数也会出现进位(发生在两个负数相加且未溢出时),此时进位得到的是新的符号位,增加了负数表达的位数,但实际上该进位位没有任何意义。

CF进位发生在结果比操作数小,借位发生在结果比操作数大的情况下。OF溢出发生在两个源操作数符号位一致,但结果与源操作数符号位不一致的情况下。

此外,参考i386手册会发现部分情况下src操作数需要做符号扩展sext(在destsrc位数不同的时候),但在实际执行操作(执行辅助函数)中并不需要额外再做这一步,因为这一步已经在操作数译码辅助函数中完成了(Make_DopHelper(SI)),凡是涉及到符号扩展的都会调用SI的方式读取立即数。(如果细心点,可以发现在exec.c的译码表中,只有6b和83两个操作码涉及到了SI操作,而83对应的是gp1的指令表,gp1中包含了所有涉及到符号扩展的数学/逻辑指令,他们对应的操作码实际上也就是83)

git log: 68b6b5a0a1f2f0ca8b72006ec4fa2b60a38b4e3a

PA2-3

运行更多的程序 (完善nemu中的代码,实现更多的指令)

没什么好说的,之前已经走过一遍流程了,无非是对未实现的译码、执行辅助函数做填空题。值得一提的是,为了边做边测找问题,而不是全部实现了才进行测试,这里有按一定的顺序编译程序查看其反汇编的机器码,然后对应实现相应的指令(顺序不固定,但最好是从简单到复杂,从少到多):

  1. add.c。需要实现的指令数量不大,且实现完后可以将涉及到指令的源码都基本过一遍,有一定的认识和理解,后续再实现其他指令时可以更快更高效的完成。主要有setccincadd等,对应的git log为0e05a0ce2875d239d6ae6138c6472f17b6b0d503。 (后续修正(感谢xt指出):在src/isa/x86/include/isa/rtl.h中,rtl的add_overflow代码有bug,参考sub_overflow的实现,第二个rtl_xor的操作数是src1和res而非src1和dest。)

  2. 基本没啥问题了,直接先撸掉操作码的译码表,包括group部分,中间遇到什么未实现的执行辅助函数再跳到exec文件夹中去实现。从头到尾一个个去实现,实现完了再去做各个程序的总测试,看报错消息来修正bug。

值得注意一下的是,先前我们已经分析了一条指令的执行过程,其中取指部分只需要完成译码表的部分,操作数译码部分在框架中和之前的dummy后已经全部实现,执行部分则需要到isa/exec文件夹中去逐个将.c文件中的函数全部实现,rtl部分在add.c程序执行完成后已经基本完成了。此外,由于nemu中不涉及segment,因此诸如CS、DS、ES、FS、GS等带S的segments所涉及的指令我们也无需去实现。

(小陷阱:group中的不全是EX执行辅助函数,group3在译码阶段会先调用E读取modR/Mid_dest中,这对于group3中的绝大多数指令是没问题的,都是单操作数,但其中第一个指令test如果翻阅i386手册的指令解释会发现其没有单操作数的例子,即其仍需再读入一个操作数。在decode.c中也可以找到test_I的译码辅助函数,注释解释其是专门用在group3中的test的。因此group3中的test指令仍需调用一次IDEX而非只用EX。这个小陷阱是测试出现“未实现指令”报错时,查看日志log发现的,你也可以去试试看这个debug找错的过程:group3中的test指令使用EX,测试的程序对象为leap-yearrun之后会出现“1000be处未实现指令(d8)”,而实际上d8是没有指令的,因此可以初步判断是之前的指令读取操作数的时候发生了错位(读多或读少),找到build/nemu-log.txt对照build/leap-year.txt的反汇编文本就可以发现问题出在哪个位置的哪个操作码上。)

git log: f106a6a2230b41fdfa3555ee7b78a269ac521aa6

实现常用的库函数

实际上只有两个文件需要实现其中的部分函数,也不需要再考虑nemu等底层硬件的调用,而是在C语言的层面上实现一些简单常用的库函数,没什么太大的难度。唯一有一点难度的是需要man stdarg查看相关宏如何调用,以及man printf查看相关函数的调用参数以及返回值是什么,但实际上翻到最底下看returnexample部分就能快速入手了。

(注意:void指针加减1均移动一个字节,因此memcmp这里有两种写法,一个是先转换成char *int8_t *再做加减,另一种就是先对void *做加减再做类型转换,效果是一样的。截取computer programming的Pointer章节原话则是会默认将其当成char *处理)

这一小节的目的是为了说明这些库函数实际上是与硬件无关的,只是将一些循环和基本操作封装在一个函数中,这些函数再封装成库,方便其他程序的调用。而无论那些程序是运行在x86还是mips上,都不会影响这些库函数的表现,因此我们仅需要实现这一份代码就可以用在所有的架构上了。

免责声明部分

我们可以看到很多函数虽然没有实现,但都写好了返回值保证这些库函数可以执行。但目前我们用不上所有的函数,只需要实现部分即可。而免责声明提出定义了API不一定来得及把它们全部实现,但将来用到没有实现的库函数debug的时候会非常痛苦,需要花费大量时间去调试,我们需要的是当我们用到了某个没有实现的函数时能够及时提醒。因此如果查看git log的改动可以发现,我们可以在暂时不想实现的函数中加上assert(0)来引发报错,将来如果用到了这个函数,我们可以在报错信息中找到具体的文件和行数。(类似的,也可以自己定义一个输出宏,输出相关的未实现信息)

git log: 67e71695120d00ca2e4825276efd235ae88c0429

(后注:这里popa的执行辅助函数实际上只是把pusha的倒置复制过去了,并没有正确实现,因为这一段要到PA3才用得上,所以这里只是占个位置,等做到PA3相关部分的时候还是要回来修改和实现的)

PA2-4

DiffTest

首先是需要检查寄存器排列的顺序是否满足API的约束,对比文件为:nemu/tools/qemu-diff/src/isa/x86/include/isa.hnemu/src/isa/x86/include/isa/reg.h。其次还可以查看nemu/src/isa/x86/include/isa/diff-test.h中定义的DIFFTEST_REG_SIZE为9,即这里只检查8个通用寄存器和PC的值是否相同,eflags是不参与检查的(事实上也没办法检查,因为我们在nemu中实现的标志位有限,正常对比结果一般都是不同的)。

接着实现DiffTest中的ISA相关的checkregs函数,并在nemu/include/common.h中将宏DIFF_TEST打开,重新在nemu文件夹下make即可。

如果之前实现的都是正确的指令,那么每次执行完程序都会输出“HIT GOOD TRAP”,并自动退出nemu以及qemu。此外也可以看到输出的字段中有“Differential testing: ON”字样。

(另外,如果想看一下DiffTest的效果,可以将译码表中的31对应执行辅助函数的xor改成and,然后在nexus-am的cputest中测试add程序。这时候nemu会及时在寄存器值不一致的时候打断程序,并输出所有通用寄存器和对应下一个pc的值,以及发生异常中断的pc位置,此时可以去build中找到程序对应的txt反汇编码,找到相应pc位置,查看其指令,从而定位错误的大致位置。)

git log: 1a50d393c007af5fd501d2bfe3db6a2cdfca98e8

PA2-5

native如何实现main函数的参数传递

提示:先看./src/main.c,再看./include/amtest.h

串口 - 运行Hello World

值得注意的是由于在pio_read以及pio_write中(nemu/src/device/io/port-io.c)会调用nemu/include/device/map.h中的find_mapid_by_addr函数来获取map的相应index,而在这个函数中已经调用了difftest_skip_ref函数(在未定义宏DIFF_TEST时是空函数,详见DiffTest部分)来跳过与QEMU的检查,因此我们不需要在实现inout指令时额外调用difftest_skip_ref函数,这样代码会显得整洁很多,也将测试与指令部分完全分离了。

git log: 9be299dd4f294b1ffa768a88f1d2af97d15ed645

时钟 - 实现IOE

跑分进入相应文件夹make run即可。这里记录一下第一次跑分结果:(i5-10210U @ 1.60GHz,虚拟机仅分配单核处理器)

  1. dhrystone: 133

  2. coremark: 405

  3. microbench: 601

当然,这里为了能让microbench的MD5测试通过,需要回到nemu中去实现d3-group2中第一个的rol指令。这个函数没有TODO的提示,需要我们自己添加,由于做的是位循环的操作,因此我们将执行辅助函数添加至logic.c中,别忘了在all-instr.h中加入该函数的声明。此外,参考shl,标志位CFOF在nemu中不需要更新。

git log: 082278eac8baf6b80361d3a885468ad10670613e

键盘 - IOE2

MAP宏实际上就是多个宏之间互相作参数调用,最后实现一个enum的键值声明。对于检测多个按键,扫描矩阵可以检测出哪些键处于被按下的状态,而按键码有先后顺序,会以后来的按键码为准一直停留(事实上你不管在什么机器上,同时按下两个键后后一个键会一直延续按下的状态),如果是要判断组合方式,则会把之前按下但还没收到松开信号的按键记录下来,与当前按下的按键码一起组合成相应的功能。在nemu中(src/device/keyboard.c)有维护一个循环队列来保存相应的按键码信息。IOE2的实现蛮简单的,只需要把两个代码调换一下位置即可,做一定的操作即可。

git log: bec65b199d222b14e81182bb23430422822459dd

VGA - IOE3 + IOE4

涉及到的文件主要有:

  1. nexus-am/am/include/nemu.h,看端口的宏定义。

  2. nexus-am/libs/klib/src/io.c,有4个实现VGA要用到的函数。

  3. nemu/src/device/vga.c,VGA的硬件实现。其中回调函数handler未实现。

  4. nexus-am/am/src/nemu-common/nemu-video.c,VGA抽象寄存器信息的读取与写入,未正确实现,VGA的初始化也未实现。

具体改动看git log。

(由于使用了memcpy函数,需要在之前PA2-3提到的string.c中实现,实现后顺便对原有的strcpy简化了)

git log: 391b9a11d19f05444abd845f82dbf97fb68d53c2

可展示的计算机系统

运行程序到相应的文件夹下make run即可。其中LiteNES(先makemake run)运行后加载的是pacman吃豆人游戏,但很可惜目前只有6FPS,非常卡。按照FPS/跑分指标,仅有6/601 = 0.01,显然在LiteNES上单位计算能力贡献的FPS非常低,本身的性能很差,有极大的优化空间。

LiteNES如何工作

读了LiteNES的cpu.c的代码后我们不难发现,其大体结构与nemu是很相似的。同样是取指与译码,然后最后执行。主要的工作流程可以只看cpu_run函数以达到窥一斑而知全豹的效果。首先会通过memory_readb函数读取pc所指向地址的第一个字节,作为操作码op_code,然后用该操作码到cpu_op_address_mode里面检索并调用相应的译码函数(在cpu-addressing.c中),并在译码之后到cpu_op_handler中调用相应的执行函数,便完成了一个指令。这里唯一不同的是,其cpu_run的参数是周期而非指令数目,在nemu中是执行指令的数目作为循环,而在这里是用指令周期数作为循环,而每个指令消耗的时间周期是不同的。

必答题

  1. RTFSC:参考PA2-2

  2. 编译与链接:首先我们需要掌握staticinline的作用。由于语言是C这里没有类的概念,因此static修饰的是静态函数,这意味着该静态函数不能被其他文件所使用,而只能在该文件中调用(虽然在我们看来是被执行辅助函数调用了,至于原因则是与inline的修饰有关),并且在其他文件中可以定义相同名字的函数而不会发生冲突。再来看inline,在C99中inline是向编译器建议将被inline修饰的函数以内联的方式嵌入至调用这个函数的地方,或者换言之,相当于在调用部分将函数的代码一行行重写一遍而不是跳转到函数中去执行。

那么staticinline在这里一起用的作用是什么?或者说有什么好处?

我们先明确一个前提就是这些static inline函数都是在rtl.h头文件中定义的,而不是源码文件.c,这是因为我们想在其他地方用到这些函数而不是单单只在这个文件中用到,因此我们在这里不用考虑函数作用域的问题。

好处主要是体现在inline上。对于一般函数,首先跳转需要访问内存地址,而且还要将一些参数压栈保存现场,指令数量也会增多,显然速度相比于嵌入的内联函数会慢很多。不过这也是有代价的,inline的嵌入会使代码量增加,换言之,会占用更多的内存,这是明显的以空间换取时间的做法。因此正如前面黑体标记的建议,编译器会根据内联函数的代码量、参数量以及调用次数来决定是否内联嵌入。总结来说,inline只能向编译器提议,而最后是否内联还是得看编译器的优化策略。如果决定不内联嵌入,那么inline修饰的函数与普通的函数无异,一样是通过内存地址跳转调用函数的。

那这时候就会有一个问题:如果没有用static来限定inline的函数,那么这个函数在编译时就会被认为是全局的。但刚刚也提到我们在编译完成后如果不看反汇编的代码,是无法确定一个函数是否是内联函数的。而由于内联函数一定是要放在头文件当中实现(而不是声明,后面会解释为什么)来给其他源码文件使用的,因此如果这个函数没有被编译成内联函数,又被多个源文件include库所包含,那么就会引发函数重定义的问题(不同编译器或不同的优化级别很有可能会报错)。因此这里static的作用就是维护代码的健壮性。

用更通俗易懂的例子解释就是:我们知道源码文件的前几行一般都是include各种库,这些库里面被用到的函数实现(而非声明,具体看后面)就会在这些函数的开头被隐式的定义并实现一遍。这就好比有三间屋子要用电,他们按照一个标准给自己的屋子都造了一个电机,那么这里就有3台重复的、一摸一样的电机出现,但实际上我们只需要在外面有一个总的电机(或称为电站),同时给这三个屋子供电就可以了。static就是这个作用,将函数限制在头文件当中,而不会出现在多个源码文件中重复定义的问题。因此确定会被多次用到的、在头文件中实现的函数最好前面加上static进行修饰。但static会为这个函数预先分配静态空间,因此如果函数没被用到则会浪费这片空间,也会使程序变得笨重,这并非是include的库本意。而inline函数就是这其中最显著的例子。如果编译器将所有用到的inline函数都内联嵌入,那么static不会有任何影响;但一旦有某个inline函数没有被内联嵌入,又没有用static修饰限制,那么就会引发之前提到重定义问题。这就好比如果某间屋子要用独立的电机,我们可以搬一台给他,但如果他们为此而自己造电机,那么就会浪费很多资源,且可能会带来某些冲突的问题。

于是我们可以得出结论,在头文件中用inline的时候务必要再加上static,事实上在linux的内核中也是这么做的。而static inline应用的场景一般都是其函数语句较少,且不是递归函数时,为了加速程序,避免频繁调用函数的跳转与压栈带来的时间开销,会用一定的内存去换取更高的效率。尤其是在一些非常基础的操作中(在nemu中则是rtl操作)。

现在还剩的一个问题就是:为什么要在头文件中实现(inline)函数呢?这就涉及到C的编译器原则了,编译器会以c文件为单位逐个编译obj,每个c文件的编译是独立的,该c文件用到的外部函数都在编译时预留一个符号,只有等到所有obj生成后链接时才会给这些符号相应的函数地址(链接脚本决定地址),所以其他c文件编译时只会看到这个函数的声明而无法知道它的实体(实现)。这意味着如果不在头文件中实现inline函数,它就会像普通函数一样编译,然后等链接的时候再给其他c文件填入该函数地址,这就做不到内联展开。所以要内联的话则必须在每个用到它的c文件中都能看到其实现,换言之,其必须在头文件中有相应的实现(而不是在.c文件中)。

总结:这类希望全局使用又希望增加效率的函数会在头文件中通过static inline的方式实现。

最后回到题目问题。去掉static的问题之前已经分析了,但编译器并不会报错(这是因为我去掉的是rtl_li,函数只有一句,应该是编译器在所有调用该函数的地方都采用了内联嵌入的方式,所以没有冲突)。去掉inline则会降低该操作的执行速度,但不会触发错误(因为仅仅是静态函数)。两者都去掉则函数相当于普通的函数,而这个函数又是在头文件中实现的,这时候编译器是一定会报错的,报错的结果也正如我们前面分析所说,"yyy.o: in function xxx: multiple defination of xxx. first defined here (rtl.h)...",所有用到该函数的.o(obj)文件都会定义并实现一遍(因为include这个头文件),显然这是冲突的。

想要证明static则需要查看反汇编的代码和分析.o文件(是链接了静态函数的地址而非定义实现一遍)。想要证明inline则看反汇编代码是否实现了嵌入,而不带static是否会出问题分为两个方面,一个方面是如果函数中引用了另外一个static函数,则该函数也需要用static进行静态修饰,否则报错;另一个方面是如果用在了复杂的函数中,可能不会作为内联函数嵌入,那么就会出现前面提到的未用static限制的重定义问题。两者都不用直接会报错,可以证明我们的想法。

  1. 编译与链接2:这题其实是上个问题的一个小拓展,除了加深对头文件以及static的理解外,还加深了对volatile关键字的理解。具体关于volatile的解释可以STFW获得,概括起来就是防止优化,保证变量不会被缓存,而是强制访问内存。

我们现在想要得知重新编译后的nemu有多少个dummy变量的实体,最简单的方法就是用grep在所有的.o(obj)文件中查找。上一题中也有提到,所有的c文件都会逐个编译obj,生成对应的实体对象。因此这里我们可以在/nemu目录下使用命令find build | grep '\.o' | xargs grep 'dummy' | wc -l来查看nemu中含有多少个dummy变量实体。

在编译前用该命令是0个。

common.h添加volatile static int dummy后,有38个实体。

继续在debug.h添加volatile static int dummy后,有76个实体。不过我们这里需要对命令有一个小小的修改:find build | grep '\.o' | xargs grep -a 'dummy' | wc -l,需要在最后一个grep中加上-a参数,可以通过grep --helpman grep找到这个参数的含义——把二进制文件当成文本文件来处理。因为之前我们不需要知道二进制的文本内容,有实体就是1,没实体就是0,那时候的grep也仅仅是输出哪些二进制文件匹配(包含)。但这里不同,由于二进制文件数量不会变,但内容里可能会多出一个dummy实体,所以我们需要把二进制转换成文本后再进行匹配(否则还是38个,因为就这38个文件里包含)。修改后的指令指出有76个实体。

我们与之前的实体数量进行比较可以得知,这两个头文件中定义的volatile的变量是独立的,即给他们分配的内存空间地址是不同的,访问时自然也是对应的不同的内存(注意一定是内存而不是寄存器,这是volatile的特性)。因此实际上在这38个include了这两个头文件的源文件编译后,生成的二进制文件(.o)中,每个文件都有两个dummy实体,这两个dummy实体指向的内存地址是不同的,所以也不会出现重复定义的问题。

对两处变量都进行初始化后,则会引发报错,报错的原因是重定义。但对其中任何一个取消初始化(即一个初始化,另一个不初始化)则不会出现这个问题。这里我们可以确定,一旦对两者都进行初始化时,编译器会检查到两者是相同的名字,此时便会引发重定义的错误。之所以这里有这个问题而之前不会出现(以及只定义一个的时候不会出现),我个人的理解是未初始化时,编译器会为变量分配一个独立的内存地址(因为此时没有具体的指定其内存地址或指向),访问这个变量时也一定是访问其对应的内存(volatile作用)。但当我们对其初始化时(这里是赋值为0),意味着什么?意味着其被指定了内存地址,之后访问这个变量时也是到这个内存地址中去。什么内存地址呢?装载这个数值0的内存地址。换言之,这个数值的内存地址与dummy变量进行了“绑定”。如果我们存在两个相同名字的dummy变量,且都初始化了,那么就会引发重定义的问题,因为我们在第一次定义的时候就已经为这个变量指定了确定的地址,在第二次定义的时候就会发现之前已经定义过并为其指定了地址了,这里就变成了重复定义(或者说发生了同一变量不同地址的冲突)。而我们不初始化的时候,是没有为其指定地址的,此时这两个dummy是独立的(因为在两个不同的头文件中),或者说我们可以理解为其已经是不同的变量了,因此其最后分配不同的地址时不会发生冲突。

具体可以自己简单的做一些小测试然后去查看变量的地址(写一个简单的main.c测试)。这里给个简单的实例,我定义了一个mytest.h的头文件,里面的代码是这样的:

#ifndef __MYTEST_H__
#define __MYTEST_H__

#include<stdio.h>

volatile static int dummy;

void output() {
    printf("%p\n", &dummy);
}

#endif

然后在main.c中引用了该头文件,并且在主程序中:

volatile static int dummy;
printf("%p\n", &dummy);
output();

这时候程序的输出是000000000040c038000000000040c034,可以看到这两个dummy变量对应的内存地址是不同的。如果我们将mytest.h中的dummy初始化为1,结果变成000000000040c0340000000000408010

那么这几个问题想告诉我们什么呢?我觉得是在定义volatile变量时,一定要对其初始化,防止出现重复变量名的情况,防止其变成一个“野”易变变量(类似“野”指针),出现未定义行为UB

  1. 了解Makefile:略。Makefile需要对其语法和结构有一定的了解,这里只能大概看出其主要工作方式是find出所有的.c.h文件后,将文件名或目录名保存在相应的变量里,读取与调用相应的参数联合编译。

PA2到此结束,在PA2中我们实现了一个简单的计算机,能处理指令,有基本的运算能力(图灵机)和输入输出的能力,包括时钟、键盘和图像的基本功能也有了。

PA3

到了PA3我们就接触到了Nanos-Lite,一个简化版的操作系统。到这里就可以列出整个实验的大致结构了:nemu是最底层的,与硬件(内存)直接打交道的程序;AM是中层的,负责与nemu对接,为nemu提供运行程序(image)的抽象计算机,并为上层的系统提供API接口;最后Nanos是上层的操作系统(本质上也是程序),运行在AM之上,通过调用AM提供的接口做程序与系统的管理。

PA3-2

触发自陷操作

需要先在nemu/src/isa/x86/include/isa/reg.h中添加几个寄存器,包括为qemu设置的cs、x86使用的idtr寄存器。关于idtr寄存器,RTFM可以参考i386手册的p155-156、p174(9.4节与10.1节)。

关于lidt指令的具体过程,可以参考nexus-am/am/src/x86/nemu/cte.c以及nexus-am/am/include/x86.hset_idt函数(提示:lidt会传入一个数组的地址,数组大小为3,0是size-对应limit,1和2是idt地址的低16位和高16位,对应base)。会将idt表的大小和地址作为操作数传入,因此我们需要在nemu/src/isa/x86/exec/system.c中的lidt执行辅助函数中对操作数做一定的处理,将其加载到idtr寄存器中。当然了,exec.c中的指令表也需要填入相应的指令来完善这个功能。

在完成lidt指令后,我们则需要来实现int的指令。除了填表和在执行辅助函数里调用函数raise_intr外,我们还需要到nemu/src/isa/x86/intr.c中实现这个函数。RTFM以及参照讲义,由于之前已经实现了lidt,即idtr的数据填入,现在实现这个函数已经没有多少困难了。(可以多看讲义的那个IDT跳转图和门描述符的结构)

至于restart函数,则在nemu/src/isa/x86/init.c中。

(不过这里出现了一个问题,就是编译运行后在打印完Project-N的图案,程序会中断报错。错误原因是地址越界——pc跑到了0x3a3a3a3a。这与上述实现的内容无关,即如果注释掉宏HAS_CTE,跳过_yield自陷,仍然会出现这个问题。在检查完前面的相关实现发现没有任何问题后,我注释掉了一开始printf图案的语句就没有这个问题了。猜测是图案的大小涉及的字符过多,由于调用的是AM中实现的printf,因此我们需要去am中的stdio.h中扩展字符缓冲区的大小(5096)。)

最后就是如何判断是否触发了自陷,找出异常入口地址在哪比较麻烦,因为程序会在最后的panic之前输出system panic的未处理事件的panic报错,我们无法得知其是否进入了正确的异常入口地址,这个地址又是如何来的。首先我们需要到cte.c中找到函数_cte_init,查看异常触发对应的函数地址。在set_idt中我们知道会调用int 81,因此第81个门描述符对应的自陷函数是vectrap,门描述符中存放的是该函数的内存地址(但我们仍不知道其值,只知道触发时会进入这个函数)。这时候我们需要把之前跑分关闭的debug宏打开,方便查看nemu的log文件。我们通过对比输出的log文件以及反汇编码的txt文件(都在nanos的build文件夹内),可以查找vectrap关键字,发现其会在函数内部再跳转到asm_trap中(其他几个异常也是会跳到该处),因此这就是真正的异常入口地址了,即1004a9,而中断发生的pc为100490,我们也可以在nemu-log.txt中检索到,即触发了81中断后跳转到了1004a9这个地址,后续的处理过程都是与irq_handle函数相关的了。(地址根据实现的不同会有区别,但方法是一样的)

(其实在nexus-am/am/src/x86/nemu/trap.S中也可以发现会跳转到ams_trap当中)

git log: e9f91354ce6b2dd9e9c24a932406217af8c398b0

保存上下文

对比异常处理与函数调用

函数调用实际上还是在主程序的运行当中,是主程序内的行为,因此只需要保存需要涉及的寄存器信息以及返回的地址即可。但异常处理已经可以视为是另一个独立的行为了,是主程序外的行为,需要保存发生异常(或中断)的原因,异常返回的地址,以及一系列的寄存器,只要是一个程序可能会用到的寄存器都需要保存,在这种层面上来看,我们确实可以将异常或中断视为是另一个程序了。

Context结构体

需要实现的新指令其实之前已经顺带着实现了(虽然没用上),就是pusha,对照着i386手册实现起来很简单。主要是用来保存所有通用寄存器的当前状态的,用处我们也知道了,就是用在现在这个情况中——异常/中断。

这里重新组织结构体成员的时候必须对这部分有一定的认知,具体可以参考下面的必答题回答。值得注意的是,由于成员的值是用压栈的方式构造的上下文结构,因此这里的顺序要用出栈的方式进行规划。最后一个压栈的也是最先出栈的,而传入的参数指针指向的是最后压栈的地址,显然,第一个成员就是最后压栈的元素,以此类推。

至于最后的验证,在printf打印完上下文c的内容后,用nemu自带的简易调试器来info r打印系统寄存器的内容进行比对,不过我们原本只打印8个通用寄存器和pc的值,这里再补充一个eflags的值(反正根据需要在nemu/src/isa/x86/reg.c中添加即可)。具体的方法不难,如果我们看过nemu目录下的Makefile就知道,在运行nemu后可以加上镜像文件的名字来加载,否则就是加载自带的测试程序。这里我们在nemu目录下运行命令./build/x86-nemu ../nanos-lite/build/nanos-lite-x86-nemu.bin加载nanos操作系统的image,并进入debug模式(记住需要把debug的宏打开)。我们需要设置断点,断点的位置则需要去反汇编码里找(nanos-lite/build/nanos-lite-x86-nemu.txt),关键词检索到asm_trap后,我们可以找到pusha指令的pc码,然后将断点设在这。如果忘了怎么设置断点,可以去温习一下PA1-6的内容,很简单,w $pc==0x1004ed,当然了你得根据你自己找到的pc值进行相应的修改。然后c让程序执行,在断点处停下,这时候我们再info r就可以打印出此时寄存器的信息了,然后c继续运行至程序终止,最后跟nanos内部printf打印的_Context成员的值进行比较吧!

不过由于我们之前在stdio.h里实现的vsprintf仅支持%d%s两种字符串模式,所以我们这里可以暂且先打印十进制的值,自己手动转换进制验证一下结果是否正确。在初步验证后为了之后更多的需求,我们还是要回到stdio.h中去实现一下%x的字符串模式的。这也印证了讲义上的话,不是没有TODO的地方就不需要再写代码了,根据自己的需求,时时刻刻都会需要添加新的功能进去完善,我们在用不着的时候可以暂且放下不管,但之后用到了你还是得回来实现的。

git log: e54830f6fb32350c313419336612c0196c1b274d

必答题

模拟__am_asm_trap的过程:

其实这里我们考虑一个异常/中断的完整流程会更清晰一些。之前我们在触发自陷操作时已经分析了前半段,即异常的触发、寻址找到异常的入口地址,最后在int 81指令后程序进入了异常的第一扇门——__am_vectrap,紧接着会在__am_vectrap内进入第二扇门(跳转)——__am_ams_trap,这也是其他几个异常都会进入的真正的异常入口地址。进入前我们会将异常(中断)号压栈,才执行跳转。

紧接着我们再看trap.S的内容,先将8个通用寄存器的内容压栈(指令pusha),然后压0值(讲义提到的地址空间占位),最后又会再将esp寄存器的值单独压一次栈。执行完这些压栈操作后才会跳转到异常的处理函数中去。

到这里我们整理一下栈里面保存的内容,中断之前的内容先忽略,那就是:中断raise_intr函数中会压三下,eflagscseipvectrap压一下,中断号0x81__am_ams_trap会压3下,8个寄存器pusha,地址空间0,寄存器esp,最后栈顶指向esp

屡清楚内容后就能看懂后面的几个addl操作了,先将当前的esp指向8个寄存器的地址,然后popa,紧接着跳过中断号,使esp指向eip,就可以执行iret指令了。

这里又引出了新的问题,虽然我们了解了trap.S汇编代码表示的过程,但并不知道他究竟是在做什么,这些操作的意义是什么。这时不妨看一下必答题一开始给出的问题:__am_irq_handle函数的参数——指向_Context的指针是怎么传进去的?它指向的上下文结构究竟在哪里?这个上下文结构又是怎么来的?而每一个成员又是在哪里赋值的?

如果我们用grep关键字_Context对整个目录进行检索,会发现没有什么地方是传入了_Context参数或者为其赋值。那我们就有理由确信答案是在trap.S中了,因为只有这里是用汇编代码直接调用了函数__am_irq_handle。回到讲义上之前有一个蓝框提到的提问,对这个问题做答案分析,那么以上问题的答案自然也就明晰了。

诡异的x86代码

其实没什么诡异的。就是讲义前面提到的保存上下文的一个过程,或者说这就是在为_Context上下文结构的每一个成员赋值的过程。前后的代码分别是保存eflags、cs、eip(这三部分在intr.c即异常响应时已经保存在堆栈上了,为nemu的硬件现场保护)、异常号、通用寄存器的值和地址空间占位,以及跳转到异常的处理函数__am_irq_handle中,在跳转前需要将上下文保存完整,并传入指向这个上下文结构的指针。这也是为什么有一行pushl %esp,因为此时的esp寄存器的值指向的就是栈顶——上下文结构的最后一个参数压栈的地址,或者说这个地址也是上下文结构的地址。这也回答了上面的问题:指向_Context的指针是怎么传进__am_irq_handle函数的。我们知道函数的调用本质上就是参数的压栈以及pc(或eip)地址的跳转,这里也没什么不同的,最后压进去的esp就是函数的参数。

这也是我们为什么需要对照trap.S中构造的上下文,让x86-nemu.h中定义的_Context结构体成员的定义顺序与其保持一致。因为这里没有显式地调用参数传递的语句,而是通过汇编码压栈的方式将上下文结构中的成员一一赋值并传入相应的指向指针,同样上下文结构也不是显式地进行表示,而是隐含在了这些汇编码当中。

至于上下文结构是怎么来的,这就要提到我们之前要完善的Context结构体的定义了。根据定义将这些压入栈中的数据一一弹出至相应的成员位置,共同构成了这一个上下文结构。

至于四部分内容的联系,答案已经在之前的分析中有所体现了。

事件分发

就两行代码的事。

git log: 8720d4ce78108dbf02b33c5f2924ee137d93427f

恢复上下文

加4操作看CISC和RISC

硬件决定是否+4的好处是程序员不需要考虑对异常返回pc的处理,不需要参照手册去一一实现,避免了人为错误的情况。但软件来决定则可以更好的进行拓展和修改,当引入了新的异常或修改了异常的处理方式后,可以根据相应的情况决定如何对返回的pc进行处理。

新指令就是popairet,但我们之前已经参照i386手册实现了,很简单。并且我们在事件分发完成后已经可以看到末尾panic输出的"should not reach here"字样了。因此这一节没有git提交。

必答题

为了理解透彻,其实这一部分的问题已经在整个PA3-2中写的很详细了,包括自陷部分和上一个必答题。因此这里也不再重复阐述了。

PA3-3

加载第一个用户程序

堆和栈在哪里

nemu以内存地址作为堆和栈的头指针地址,am将堆和栈抽象成c语言描述的方式。可执行文件通过nanos操作系统调用am的堆与栈的接口,最后实际上是对nemu进行操作。

FileSiz和MemSiz,以及物理区间清零

首先占用的内存空间大小肯定是不会小于文件大小的,否则文件会加载不完全而发生错误。有时候会多留一些内存空间的原因是可读写的数据段除了data之外还有bss,而bss里全是未初始化的全局变量,因为没有初始化所以并不算在FileSiz中(或者说在文件中不占据空间),但这部分变量的的确确是需要一个内存空间来承载它们,在存储器映像中是占据空间的。而且我们都知道,未初始化的变量默认初值都是0,因此我们需要将这段对应的物理区间值清零,否则可能出现未定义行为UB。

(实际我们可以readelf -l build/ramdisk.img看这两个字段,并参考下面的映射情况,可以发现只有含有.bsssection的segment才会出现MemSiz大于FileSiz的情况)

实现loader

一定要man 5 elf,因为在loader.c#include <elf.h>且宏定义了Elf_EhdrElf_Phdr,如果不RTFM,我们是无法知道这些结构里包含了什么数据的。事实上,实现loader的难点也就在于怎么获得ELF中segment的属性并根据这些属性加载ramdisk中的ELF文件,将程序的代码和数据从中抽取出来。这些都是讲义上没有提到的,实际上elf.h也可以去navy-apps/libs/libc/include/elf.h中RTFSC找到相关的定义内容,到这里定义的那两个宏也可以明白究竟是什么东西了(事实上这些定义都不是没意义的,一定要思考为什么这里定义了这个,STFW也可以知道其作用是什么,毕竟有个前缀ELF呢)。

注意要求是“把用户程序加载到正确的内存位置”,因此这里我们只需要管两点,一个是用户程序(可执行文件),一个是正确的内存位置。前者在ramdisk中,后者在讲义中也有约定,将程序链接到内存位置0x3000000附近(参考navy-apps/Makefile.compileLDFLAGS变量给出的值),不过这部分会在Elf_Phdr.p_vaddr体现。具体可以readelf -l build/ramdisk.img查看几个Segment的VirtAddr

还有一个readelf -h build/ramdisk.img也很重要,看了就知道啦,应该会有不少启发的。

(其实我们在这里也看到了,naive_uload函数中用定义的宏Log打印了跳转的入口地址,这里用了%x,因此我们前面在stdio.h中实现的%x也是必要的)

最后我们可以在x86架构make ARCH=x86-nemu run(之前我们已经在nexus-am/Makefile.check里改成默认x86了,因此也可以说是make run),以及native上(make ARCH=native run)测试我们的实现是否正确。如果是正确的那么应该都能走到system panic这一段而不是其他的错误(如内存越界),其中x86会触发Unhandled event ID = 1,而native会触发Unhandled event ID = 6

git log: 3ed5aaaafe3ef04fb42abad6cb36b0629d23a02a

系统调用

解析一下源码就没什么难度了。

对于/navy-apps/libs/libos/src/nanos.c,里面定义了很多宏所以代码虽然很简洁但第一眼是看不懂的。对宏之间的关系屡清楚后,就可以知道有一个参数数组ARGS_ARRAY,这个数组是ISA相关的。然后_args(n, ARGS_ARRAY)是用来将这个参数数组中的第n个参数取出来,赋值给GPR1-x,以及SYSCALL。这里我们选择的是x86的架构,因此在函数_syscall_中我们执行的asm汇编语句就是int $0x80,然后GPR1-GPRx分别对应的是eax、ebx、ecx、edx、eax,其中GPRx重复的eax代表的是返回值。所以这里可以分析出中断号是0x80,上下文中保存的必要信息(即系统调用的4个参数)存在eax~edx中,最后执行完系统调用后的返回值会放回eax中。

识别系统调用 + 实现系统调用

搞清楚调用的过程后,就有着手点了。首先nexus-am/am/am.h中定义的事件的号码,这里要用到的是_EVENT_SYSCALL,可以看到其是6号,是不是很眼熟?没错,之前我们在native中触发的ID就是6。接着我们要回到cte.c中在irq_handle函数中增加新的事件分发,并在nanos-lite/src/irq.cdo_event中对事件进行处理。由于do_event实际上是各个事件处理的一个入口,因此我们需要调用syscall.c中的do_syscall做进一步的处理(系统调用处理)。

进到syscall.c中,我们可以看到需要调用到上下文的GPR1-GPRx。所以我们得到nexus-am/am/include/arch/x86-nemu.h中将这些宏一一对应到正确的寄存器上。

如果我们这时候直接在nanos-lite目录下make run,会触发Unhandled syscall ID = 1的panic,这说明我们的系统调用已经进到了do_syscall函数中。接下来我们只需要在这最后一环的do_syscall中做相应的完善即可。最后在实现完所有相关必要的事件后,会触发SYS_exit的系统调用,并正确退出程序HIT GOOD TRAP

(在最后调用sys_exit中传递的参数code为什么是a[1]即GPR2?想要搞明白这个问题,就得到libos/src/nanos.c中去,先弄清楚_syscall_的过程,再看_exit函数所传递的参数。)

git log: e158a412f130dc71a9983b602d108afa77332868

标准输出

过程与之前描述的没有什么太大的区别。如果在系统调用小节充分理解了,那么这里也没有什么困难。唯一需要注意的是要man返回值以及注意相关参数的类型转换。

简单记录一下:GPR1 - SYS_write, GPR2 - fd, GPR3 - buf, GPR4 - count。

测试时需要先在nanos-lite/Makefile中将SINGLE_APP的末尾从dummy改成hello,然后make run。成功后会先输出一句Hello World!然后再不停的打印printf语句的内容(具体可以看源码navy-apps/tests/hello/hello.c)。

git log: 931a9fe5730ba7adcc266be9b2f1cc70be859b59

堆区管理

其实这里有一点比较困惑的是我们如何获取_end的值?讲义里提到了“链接的时候ld会默认添加一个名为_end的符号, 来指示程序的数据段结束的位置”,但我们还是不知道这个_end符号在哪,我们应该怎么引用。这时候可以回到主文件夹ics2019,用grep -r "\<_end\>"精确检索包含_end的文件,可以发现只有nexus-am/am/src/x86/nemu/boot/loader.ld中有,这就与讲义对上了。我们可以来简单的分析一下loader.ld文件的内容,有.text(代码)、.data(数据)、.bss(未初始化变量),在这些之后,内容的最后一部分,定义了一个_end符号,紧接着还有堆区的开始与结束符号。到这里我们就知道了,_end符号的地址就是程序数据段结束的位置,也是我们堆区的起始位置(或者说是program break的初始值)。要用到_end符号的时候,我们用extern char _end并通过&获取其地址就可以了。

此外man 3 end后看EXAMPLE小节也可以查阅如何使用_end符号,结合上述所说我们就了解清楚了_end的由来和用法了!

剩下的讲义已经描述的非常清楚了,也没什么难点。

实现堆区管理

这里需要注意一点的是,讲义给出的_sbrk函数的5点工作方式,都是在描述用户层的库函数_sbrk的处理过程与调用系统调用_syscall_的过程,而不涉及到sys_brk的工作过程,实现也仅仅是在_sbrk内进行。按照讲义所说,目前PA3还是单任务的操作系统,空闲的内存都可以让用户程序自由使用, 因此我们只需要让SYS_brk系统调用总是返回0即可,表示堆区大小的调整总是成功(或者说sys_brk什么都不做,直接返回成功分配的参数0)。如果我们man sbrk后会发现这其实是不正确的,因为还需要涉及到对data segmentend做相应的调整,但这些工作都是在PA4中进行的,目前只是fake实现,还需要对这个系统调用做一定过得修改才是真正的内存分配。

其实到这里我们也能看清楚sbrkbrk的本质区别了,sbrk是用户层的库函数,而brk是系统调用。在sbrk中会维护一个program_break记录用户程序的program break,这里的记录强调的是sbrk仅仅记录而不会真正的改变,真正改变(或者说设置)program break还是在系统调用brk中进行的(或者说是在操作系统的层面上进行的),包括更新也只是在用户层面上更新这一个变量而已。所以实际上sbrk需要调用brk,即brk才是更里层的、基于操作系统且真正修改program break的系统调用函数。

在正确实现前,我们可以在sys_write中使用宏Log打印调试信息,会发现每一个字符都会调用一次_write。正确实现后,则是一整个字符串格式完后调用一次_write来输出,差别还是蛮大的。

git log: 973d6b9b45768f27d08915cc286b5ad3a26d0e90

必答题

之前已经有了这么多的分析,能到这里已经有一定深入的理解了,因此这里就不再详细叙述每一个细节了。

C源文件在编译后会被链接成一个ELF文件,C程序一开始就在这个ELF文件的开头0字节处。随后loader通过解析ELF文件的程序头ELF_Phdr可以将程序中的代码部分和数据部分正确地装载至相应的内存位置,而ELF_Phdr的位置则是通过解析文件开头0字节处的ELF的头ELF_Ehdr得到的,此外在ELF_Ehdr中还可以得到程序指令的入口地址,loader也会将这个入口地址返回给操作系统。操作系统则在程序切换(引发中断或自陷操作,虽然我们这里还没涉及到程序切换这一步)的时候通过这个入口地址进入程序,执行其第一条指令。

在hello程序中,其不断地打印字符串。字符串首先会在printf中经历格式化的过程,然后通过系统调用sys_write来输出至相应的位置(标准输出或者文件)。但在每次调用_write之前都会向操作系统申请一定的内存空间(堆区)作为缓冲区来承载整个字符串,申请则是通过sbrk的方式,改变program break来增加/减少用户程序可使用的内存区域。在申请到缓冲区后则可以将字符串中的每一个字符都存放在缓冲区,通过_write一次性全部输出至终端上,而不用一个字符一个字符(申请失败后没有多余的空间存放整个字符串)地输出,这也大大地降低了系统调用的开销,减少了系统调用的次数。

PA3-4

简单文件系统

其实主要的工作都是在fs.c中,至于syscall.c以及nanos.c中的系统调用,前面已经做了不少类似的工作了,实现的过程差不多。

让loader使用文件

先实现三个文件操作的函数,然后用loader去测试一下是否能加载相应的程序。这里暂时还不需要实现系统调用,因此只需要在fs.c中添加3个函数,实现后到loader.c中修改原来的loader函数,最后在proc.c中为naive_uload函数传入文件名参数即可。

不过这里有个比较坑的地方,在loader里。原则上loader的工作原理没变,整个代码流程和框架是不怎么需要变动的。但是因为新的img是将navy-apps下多个img压在一块的,正如讲义中的示意图,是挨个存放的,所以这里偏移量会有些坑,他指的是在这个文件中的偏移量,或者说,我们去到原始的img中看(在navy-apps/fsimg/bin下,如hello),可以发现如果我们在loader中用Log调试打印出相关的信息,是跟这个原始img的readelf -h / -l结果一样的。比如说我们打印Program Header的偏移量,在Log中是52,readelf -l navy-apps/fsimg/bin/hello也是52,但后者是单独的一个img,Program Header确实是在偏移量为52的位置,而前者是一个img集合,偏移量52也仅仅是针对img中hello程序部分的偏移量,而不是那个总img的偏移量为52的位置。如果只是在loader中改变了eh的获取方式(用fs_read),但没有改变ph的获取方式,那么根据这个偏移量,读入的ph信息肯定是有问题的,最后加载过程会出错,整个程序后续也不知道跑哪去了。

你问能不能按照原来的方式,通过0字节处读取eh?且不说如果你看过files.h中第一个程序的偏移量和大小连52都不到(52是ELF header的大小),就能知道读取肯定是会出错的,也可以通过readelf -h build/ramdisk.img来验证之前所说的。因为是拼凑起来的img,这里用readelf会报错说不是elf文件的格式。

因此对ph的读取也要做一些改变。由于ph的offset也是相对偏移量(而不是绝对偏移量),因此我们也需要对加载到虚拟内存vaddr的过程做相应的改变,最简单的办法还是统一使用fs_read,因为其本身就会调用ramdisk_read并根据程序的偏移量计算其在ramdisk的位置。所以为了load不同的segment,这里还需要额外增加一个文件操作的函数fs_lseek来切换文件读取的位置。

因此最后还是除了fs_write暂时不需要实现,其他4个文件操作的函数都要用到。

(不过我们这里的实现和man里的标准实现有些区别,主要是为了适应nanos-lite,根据讲义的定义实现的,比如没有ssize_toff_t类型,这些类型都是在unistd.h中定义的,但其实并不影响)

正确实现后,测试hellodummy程序结果跟之前一样。

git log: e569e3a2edce7b84aff25ca23debc48b74cbf63b

实现完整的文件系统

把剩下的fs_write实现后(与fs_read基本相同),到syscall.c中去实现所有相关的系统调用,到nanos.c中完成相关库函数的系统调用过程,就可以将proc.c中的测试程序改为/bin/text进行测试了!

git log: 725ccfcf3fc52cf5389068928cef7e24ca5f53b2

一切皆文件

工作重心转移到device.c中,正如讲义所说,我们现在的工作是把IOE设备通过VFS抽象成文件,让上层调用不用关心设备的情况。

把串口抽象成文件

将原来在syscall.c中的sys_writefd = 1 / 2的判断部分原封不动的转移到serial_write就可以了,然后sys_write只需要调用fs_write即可,这就符合了抽象的概念,将所有(包括串口输出)都当做文件进行读写。再把原来在fs.cstdoutstderr的写函数调用从invalid改成刚刚改好的serial_write,将proc.c中的程序换成hello后运行测试。不过这里需要注意一下的是,我们还需要对fs_write做一点修改,因为当fd对应的是stdoutstderr时,不需要考虑offset和越界的问题而对长度len进行裁减了,这个标准输出“文件”理论上是无穷长的字节序列。

git log: 10cb95fa0ef28273be76cb6be7a8955d66a890f6

把设备输入抽象成文件

这里最关键的一点是我们需要回到am借助IOE的API来获得相关设备的输入,此时的offset是无意义的参数,len也只是限制最长写入的字节数,只需要把从设备获取的信息放入buf即可。

至于获取输入的接口在哪?有点久确实容易忘记,在遥远的PA2中我们曾使用过nexus-am/libs/klib/src/io.c中的draw_sync以及screen_width等函数来初始化VGA的窗口。但除了VGA的相关函数外,上面还提供了时钟和键盘的相关读取接口——uptimeread_key,这两个函数在内部调用了_io_read,最后会返回读取的相关结果。

这里我们用snprintf(显然我们得回到nexus-am/libs/klib/src/stdio.h中去实现相关的函数)将获得的信息用讲义约定好的事件表达方式输入至buf中(说是讲义,实际上你也可以进入navy-apps/tests/events/events.c中读取测试的源码)。

最后别忘了在fs.c中添加对/dev/events的支持。

git log: cefb0ba587e63fce17baad304413d175700a7e42

你也许还会好奇是怎么调用了fs_read进而调用了events_read的。找到测试的源码navy-apps/tests/events/events.c,发现只有fgetc是读取了文件的内容的。如果我们在其中加入printf语句进行调试,再在events_read内输出一次len的大小,会发现程序会先打印一次len,再输出7次printf,期间我们没有按键,因此这是时间事件(恰好也是7个字符)。结合之前我们提到的sbrk申请内存堆区(或称为申请缓冲区),我们可以猜测这里虽然调用了7次fgetc,但实际上我们只调用了1次fs_read,或者说只调用了一次系统调用sys_read,是不是很耳熟?对,之前的实现的输出printf也只调用了1次sys_write

在第一次调用fgetc时就会向系统申请一个1024字节的缓冲区(1024来自于打印的len),然后通过一次系统调用sys_read将内容读取到缓冲区中(这说明之前event_read中接收到的参数buf就是对应的缓冲区的首地址),最后每次fgetc都是直接从缓冲区中获取内容,而不必多次调用sys_read。另一点可以佐证的是,如果你在运行完测试后去查看/navy-apps/fsimg/dev目录,会发现并没有所谓的/dev/events文件,因为/dev/events只是一个“文件代号”,虽然我们将设备输入抽象成了文件,但其并不是真实存在系统中的一个真实文件。通过这个代号我们可以对设备输入做相应的处理,这才是这个“文件”的目的。实际上我们在events_read中也没对文件进行读写,只是将获取到的设备输入信息导入到了参数buf中,即刚刚提到的申请的1024字节的缓冲区,换言之,这个1024字节的缓冲区就是这个“文件”!

把VGA显存抽象成文件

init_fs中4个字节一个像素。

fb_write除了需要计算坐标外,一次只往显存写入一行(注意其本身也是行优先的方式存储的像素),绘制图案需要调用多次,每次直接往坐标那行写入len个字节。(如果要找具体细节的话,需要到程序源码以及navy-apps/libs/libndl/src/ndl.c中找到NDL_Render并读懂)

fbsync_write直接调用draw_sync

init_device把讲义中约定的/proc/dispinfo内容格式提前打印至字符串dispinfo中。

dispinfo_read需要用到strncpy,当然了得回到nexus-am/libs/klib/src/string.c补充实现。

VFS在fs.cfile_table中添加相应的表项。

因为PA3的大部分实现已经完成,这里回到原来的一些函数中去稍微修改完善一下,以更整洁的方式处理所有情况。

git log: 05480d3b97b2b6dd7138b3357c37c08a6b3c875f

如果在VGA实现完后运行会跳出我们熟悉的i386的错误图案,这是因为之前还有一个a4操作码的指令没有实现。好吧,又得回到nemu中去,按照i386手册的指示实现a4指令了,当然了,为了减少以后工作的麻烦,把与a4很像的a5指令也一起实现了吧。是的,这又得在exec/data-mov.c中添加新的函数了,这个工作之前已经做过一次,忘了就回去翻翻吧。

下面是遇到一个很麻烦的bug,花了很多精力调试(基本所有代码流程都走了一遍,包括navy-apps目录)才解决的,特此记录一下:

巨坑,如果没有一定的理解甚至不知道bug出在哪,更别提其是如何引发的

先把bug标识出来再分析(是不是很难想象就这一句有什么区别):

bug版本:

rtl_sm(&cpu.edi, &cpu.esi, 1);
cpu.edi++;
cpu.esi++;

正确版本:

rtl_lm(&s0, &cpu.esi, 1);
rtl_sm(&cpu.edi, &s0, 1);
cpu.edi++;
cpu.esi++;

在遇到a4操作码没实现的报错后,很快就将其对应的指令movsb实现了,但发现还是无法显示logo,中途就报错了。经过一段时间的debug定位发现是在fread这个libc库函数中。但想了很久都没想明白问题在哪,然后打印fread后数据的值发现是错误的。说明fread虽然通过a4能运行了,但结果是错的。通过打开宏DIFF_TEST后发现qemu在fread完成之后也因为通用寄存器的值不一致而退出了,而退出的pc处的指令也千奇百怪,有与eax相关的,有与ecx相关的,但那处指令的操作码和执行都没有问题(通过比对反汇编码以及审查nemu中exec目录下相关的执行函数)。似乎到这里就陷入了绝境,找不到真正的问题出在哪,因为执行a4操作码的时候qemu也没有报错。但恰恰就是这个地方才是真正的大坑,因为问题就是出在了a4操作码,也就是movsb指令的执行辅助函数中!

这里引出了两个关键的问题,第一个是关于qemu的,为什么qemu没有在a4操作码的时候引发不一致的错误?第二个核心问题,为什么一定要多一句rtl_lm的中间操作?

第一个问题的分析可能比较抽象:首先得明确无论是rtl_lm还是rtl_sm都是对内存的操作,与src参数本身无关,而与src的值指向的内存地址有关。在这里src就是esiedi寄存器,movsb指令的目的也是将esi寄存器内存储的值所指向的内存地址复制到edi寄存器内存储的值所指向的内存地址,然后esiedi的值递增至下一个要复制的内存地址。通俗来讲就是一个字符串复制到另一个字符串,指针一步步后移。在i386手册中该指令的操作也是[destination-index] <- [source-index],这里面的index表示的就是序号,而[]就是以这个值作为内存地址。最后总结来讲,其只会改变对应内存地址的值,而寄存器本身的值是不会改变的,只有自增那块才会改变寄存器内存储的值。分析到这里就明确这个问题的答案了,上面给出的两个版本的代码实际上对esiedi的值改变是一样的,都是自增1,因此a4movsb指令不会引发寄存器值不同而导致qemu的中断报错。

第二个问题就得看的比较深入一些:从rtl_lm所调用的函数一步步深入,找到最关键的paddr_read。当然,还有map_read,以及rtl_sm深入进入的那些write后缀的函数,这里只拿paddr_read进行分析。很早之前(PA2)对nemu内存的规划与分布没有想的太深,现在吃到了苦头,在nemu中map里管理的是各种设备的入口地址,当然了,这些入口地址是对应的某一个特定的内存地址,然后有一个范围,在这个范围内归这个设备用,然后map之外还有pmem,对应的是物理内存地址。而这些如果注意看结构的话,都是有一个偏移量offset的,即对内存地址操作的时候,可能真实的内存地址并不是值所对应的那个地址,而是还需要加上一个偏移量offset,这与nemu的特性有关。这也是为什么之前我们在做PA2实现指令的时候,nemu/include/rtl/rtl.h中有两个load memory了,一个是rtl_host_lm,一个是rtl_lm,前者是直接根据所给的内存地址读取与赋值的(传入的参数是addr即内存地址,读取的值就是该地址的值);而后者需要间接调用vaddr_read(最后会到带有偏移量offsetpaddr_read)来读取与该值真正对应的内存地址(传入的参数是addr的内存地址,将addr的值作为新的地址参数传入paddr_read,读取这个值对应的地址,这里有三层)。

分析到这里这个巨坑的bug已经拨云见雾,一睹真面目了。首先读取和写入内存按照i386的指令操作用的是rtl_lmrtl_sm。如果我们没有用rtl_lm先读取esi寄存器值对应的内存这一个中间操作,而是直接使用rtl_sm,那么结果就是将esi的值送到edi值对应的内存,这显然是不对的。而且短期来看这可能不会触发什么错误,至少qemu是不会触发不一致的中断,但在经过一段时间的运行后,涉及到这片内存的时候就会引发错误,不管是什么指令,这就是为什么之前说的qemu会触发各种类型指令的不一致中断。最后fread得出的结果自然也是错误的了。

debug后的总结

  1. 出bug先看报错信息。未实现操作码a4就去翻阅i386手册看其作用以及是否能实现,能实现就去补上,并根据这个指令的意义简单推测是否是现在需要用上的(fread确实需要做字符串的拷贝复制)。

  2. 指令实现后还是出错,先做简单的排除法,缩小范围。首先定位错误的语句位置,通过主程序注释可以定位到fread函数。然后再分析,am的测试已经足够多,而且基本没有对am做什么修改或实现,优先级不在这上面。虽然nanos也有可能,但之前各种测试包括text也做过,因此更高的优先级还是在nemu。当然,最直接的方法还是用DIFF_TEST即qemu来检测,如果pass了就不是nemu的问题,没pass就可能是nemu的问题。

  3. qemu没pass,虽然看反汇编码很难找到错误,但如果思考一下就会想到在vga中做的更改就那些,而涉及到nemu的也只有a4操作码,审查相关的执行函数,根据i386手册的描述以及操作后的结果(影响)来判断是否有问题,从而定位bug的位置,并修复bug。

  4. 最关键的其实还是将bug定位到fread,然后与该函数真正相关的只有nanos中实现的fs_read以及nemu中实现的操作码a4(因为在text测试程序中的fscanf是没有用到a4码的,但fread用到了),然后在这两方面仔细审查就能更高效的debug了。

PA3-5

展示批处理系统

不要忘了到navy-apps/libs/libos/src/nanos.c中把exec的系统调用实现。另外关掉nemu中的debug宏运行会更快一些(毕竟不用写反汇编码的log)。

git log: 27d303f5dee39c5067b70adb81abbb18d393efa5

运行pal

pal数据文件名大小写替换:perl -e 'for (@ARGV) { rename $_, lc }' *,要求是全小写(我下的数据包是大写的)。

运行后发现太卡了,慢的不行。老样子排除法,首先nemu是硬件相关,我对自己实现的指令性能还是有点自信的,而且之前跑分的结果还不错。关键的大头一般也不是在指令这块。而nanos更多的是操作系统以及对上层接口的支持,系统调用也就那些比较固定的实现,没有什么新的花样,实现的差异也不大,对性能的影响也不占据主要地位。唯一有着最大可能性的就是am,因为am提供的是软件部分,提供了很多软件对硬件的接口。一般来说,软件实现的差异会更大,且不同的实现对性能的影响也很大。另外,最关键的是,am中还提供了设备的支持,包括输入输出以及视频流。因此为了顺畅运行pal,优化性能,审查的重点应该在am中。之前am的需求只是为了让功能实现,现在需求发生了改变,就得回去做一定的优化了。

审查设备相关的代码(nexus-am/am/src/nemu-common/)发现能优化的地方很少,因为基本都是调用汇编语句或访存得到相关的数据,然后通过am中实现的库函数完成一些读写内存的操作。那值得优化的部分极有可能在之前实现的库函数中(nexus-am/libs/klib/src/),因为库函数是调用最频繁的部分,重复率极高,一旦有一部分效率不高,而这部分调用的频率又较高,则会大大影响整个程序的运行效率。举例来说,如果有一个函数在优化前运行需要1000条指令,而这个函数在整个系统占的比重又达到了50%,那么如果能将函数优化成500条指令,系统整体的性能就能提升25%。而事实上,最常用一些读写操作都是在这个库中实现的,那么其在系统中占用的比例可能比想象的还要高。

将优化的重点放在最频繁的事件中,在系统结构中有专业的术语描述:经常性事件。对一个系统的优化工作首先要放在占比最高的经常性事件上,占比只有10%的事件优化了90%,和占比90%的事件优化了10%效果是一样的,但哪个优化难度更大相信不难判断。

这里重点审查两个库的实现:stdio.c以及string.c。后者更多的是对内存的操作,主要涉及的都是循环与比较或赋值,优化空间有限,但还是可以做一定的优化的,因为这些都属于调用频繁的函数,小的优化对整体的改善也是不容忽视的。更多需要优化的还是在stdio.c中的函数,提高循环内处理的效率是核心目标。

此外一些必要的格式化也要加入vsprintf中,否则会卡界面。虽然之前的apps对格式化的要求不多,仍能够正常运行,但如果我们去init代码中看,是有一些长度的格式化要求的。此外如果把vsprintf中的defalut加入_putc进行调试,会发现还需要实现%u的格式。在加入%u的格式化后就不会卡pal启动的动画界面了。

最后要记得在nexus-am目录下先运行make clean将原有的编译包删除,之后再回到nanos-lite目录下运行即可。发现确实能顺畅运行pal了,看来问题的确出在经常性事件上。

git log: 77d1bda5ebd48876fa154b0e412b66cd7344ed72

(这里建议把vsnprintf注释掉,且snprintf改使用fake版本,vsnprintf没仔细审查与实现,可能会有未知的bug)

脚本引擎

不需要看懂很多,把结构和流程看懂就行。主要是刚进函数的时候会对传入的两个参数——指令脚本入口,以及操作事件(如行走、对话、装备等)的ID做一定的处理,然后通过switch根据操作码找到对应的执行段,对一些数据做读写或调用相关的执行函数。是的,开发者是通过一个游戏的指令译码函数,将游戏的各个功能集成在一起。“游戏引擎”与我们之前在nemu中实现的cpu功能很像,或者说“游戏引擎”就是这个游戏的cpu,负责指令的译码、数据的读取与写入,根据不同的指令码完成不同的操作,达成不同的功能!一款完整的游戏看起来也像是一个完整的计算机,他的“游戏引擎”负责cpu的工作,而再往上层走则是各个抽象出来的功能函数和处理模块。

不再神秘的秘技

因为我们之前一直有在操作与int和unsigned相关的%d以及%u,而这里描述的情况又与溢出的情况表现很类似,所以我们可以猜测这些秘技都是因为计算的时候类型导致的溢出问题。如果定义钱的变量是unsigned类型,而判断语句是if (money - xx > 0)来决定钱是否足够买,那么语句的前半段是unsigned类型,小于0的时候会溢出到最大值,显然是恒满足的(当然,可能只在特定的场景,即特定的代码段才有这种bug卡),减了之后钱也会变回最大值。这可能就是1和2秘技的背后故事。关于3所述情况中的经验值,很可能也是因为在使用金蚕王的那段代码有溢出的bug,导致所需经验值溢出回到初期极少经验值的状态。

基础设施3

自由开关的DiffTest

detachattach的函数在diff-test.c中,框架已经做好了绝大部分工作了。不过对于attach,还需要进到isa相关的isa/x86/diff-test.c中去实现。最后当然了,得在monitor的ui.c中增加detachattach的命令,不过很简单,只需要直接调用相应的函数就行了。

此外还需要扩展一下DIFF_TEST覆盖的寄存器范围,原来是9(8个通用寄存器+pc),现在还需要加入eflags。跳过eflags的检查倒不需要管,因为我们之前在isa_difftest_checkregs中的实现就是只针对前9个寄存器的。

需要关注的点包括各个diff-test的头文件和源码文件(qemu中的部分源码和头文件也需要关注),里面包含的信息非常丰富,但也同时要注意用对函数。如果不对这些代码有一定的理解,甚至会不知道如何使用函数。举例来说,对pmem的操作讲义已经说得很清楚了,内存区间也给了出来,但如果不知道函数ref_difftest_memcpy_from_dut函数,就无从下手,即使知道了,如果对nemu和qemu两者的内存地址偏移没有理解透彻,也无法知道传入的参数是什么或会引发qemu的错误。对qemu,会通过通信传入地址参数,在qemu内部会做相应的偏移,因此这里直接给出内存的guest地址即可(第一个参数),而在nemu中需要自己处理内存地址偏移这部分,将DUT中的内存地址通过guest_to_host转换为pmem的真实地址(第二个参数,具体参考memory.h以及ref.c的操作)。

准备lidt 0x7e00的指令可能比较复杂,因为要写机器码,这里给一下我的大致思路。首先前两个字节很简单,查i386手册就有了0f 01。主要是之后要读取modrm,这个得读一下decode.cgp7_E的函数,了解到其是将modrm的rm部分传给id_dest,再到modrm.c中看相关的函数知道其是根据第一个modrm字节读取相应的内存地址,因此得根据modrm的规则先将modrm字节写出,再给出后续的地址。具体可以STFW和RTFSC,这里直接给出最后的机器码:0x0f 0x01 0x18,其中modrm字节的二进制为00 011 000(注意高位在前低位在后,参照i386手册的group7也可以得知),需要预先把地址0x7e00放到寄存器eax当中(这里还有个小建议就是可以把机器码写到nemu/src/isa/x86/init.c中,然后编译运行nemu后查看反汇编码确认自己写的机器码是否正确,或者去nanos的反汇编码中找到lidt这一条指令仿照着写)。另外还需要注意一下的就是在idtr中字段读取的顺序。

最后测试的方式也给一下。把实现分为3块,分别是reg部分,pmem部分以及idtr部分。注意要把include/common.h中的DEBUG以及DIFF_TEST宏都打开。

对于reg部分,很简单,只需要在nemu中make ISA=x86 run进到简易调试器中(此时没有加载image则用内置的一个dummy小程序),先detach退出DiffTest,然后si执行2次,再attach,最后c继续运行,如果能HIT GOOD TRAP基本证明这块没问题了(主要是pc寄存器的加载)。

接下来的pmem部分就需要一些涉及到内存的操作了,可以马上想到am中的string涉及的内存操作可不少,那就用那个例子吧。讲义里给出的去掉-b参数的方式是2018年延续过来的,过时了,经过grep操作找到了正确的位置——nexus-am/am/arch/platform/nemu.mk。去掉后在am的cputest目录下make ALL=string run会发现不是直接运行完毕HIT GOOD TRAP了而是会停留在运行前的一个状态,这时候我们就可以detach了。当然,为了设置断点我们还得到build目录下找到对应的反汇编码,检索strcpy将其pc值记录下来(比如说我的是0x1001cc),然后在这个位置设置断点并在ret前也设置一个断点,以方便来跳过这一段的DiffTest。具体操作为进入简易调试器时detach,然后设置两个断点(可以info w查看断点的设置),c运行至第二个断点处,attach恢复DiffTest,然后c使程序运行至结束。如果pmem部分实现正确,最后会出现HIT GOOD TRAP,如果注释掉这部分的实现,则会引发qemu的不一致中断。

最后是idtr部分,涉及中断的一想就知道是要到nanos中去测试的。不过要对nanos做一点更改,将proc.c中的启动改为dummy即可(因为即使只设置一个断点,也会因为debug宏打开的缘故,不断写log以及检查断点,会导致运行非常非常慢)。然后在反汇编码中检索yield,在中断int处打上断点。在断点处停下时先d 0删掉断点(不然后面运行可能会比较慢),随后再attachcsi恢复DiffTest并运行。如果没有正确实现idtr部分的话,会出现qemu无法追上nemu的pc的报错,正确实现后则可以继续顺利运行(当然可以在后面再打个断点,比如__am_asm_trapiret返回前打一个,这样能够暂停程序,然后detach,不然开着DiffTest运行太卡了...不过如果运行一段时间没错误也可以认为实现正确了,因为这个错误触发很快,不需要等很久,关不关掉DiffTest还是看个人喜好吧,我反正懒得关直接用的si来触发报错或判断是否能够通过...)。

git log: 4fb6cf9a94ea48bb69447f14b08883f413a7205b

快照

其实在实现可开关的DiffTest的时候我们已经对nemu的状态有了较深的了解了,因此这里实现起来不难,主要是用freadfwrite来实现快照文件的读写。在ui.c中解析参数后直接用该文件名参数调用相关的函数实现即可,函数实现还是放在之前的isa/x86/diff-test.c中(因为都涉及到nemu状态的操作),操作其实跟之前实现的attach差不多,而且不用分块内存和对idtr做很多额外的操作(毕竟快照是nemu而不是qemu)。无论是save还是load两次分别对cpu寄存器以及内存的状态进行读写就完成了快照的功能。

测试蛮简单的,直接用nemu中默认加载的dummy程序就能完成。这里先关掉qemu的DiffTest宏(节约操作,当然不关也没问题),然后进入调试器后,单步执行几次后save当前快照(可以用save /tmp/nemus1),然后退出后再进来,进来的时候info r或者打印pc会发现是在程序开头,这时候load快照后再info r或者si单步执行(如果还开着DiffTest记得attach否则qemu状态是不一致的),会发现nemu的状态确实回到了当时save时的状态,扫描内存或者看寄存器包括pc的值都可以佐证这一点。

文件名的小坑:有两个。第一个用户目录是默认~开头(在shell中也可以看出),但我们在简易调试器中执行save [path]load[path]的时候是要键入绝对路径的,因此诸如...还有~这种最好不要用,一个是am或nanos执行目录和nemu是不同的,其次是函数fopen也无法识别~这种文件路径,最终会导致nemu的segment fault。第二个则是要注意文件名中的空格,在shell中习惯用tab来补全命令或者文件名,在nemu中同样也可以,在save之后文件就存在了,之后load的时候是可以用tab自动补全save生成的快照文件的,但这里就有个问题,shell在补全后会加空格方便键入下一个参数或指令,但我们用strtok读入参数的时候空格也是会读入的,如果没有注意到删掉那个空格,也同样会读取不到文件而导致segment fault。因此文件名一定要注意用绝对路径且不要引入多余的空格。

git log: 143aaa95a36a44781e67f715852a919a7413a062

必答题-动画如何运行

PAL_SplashScreen函数中调用了很多SDL(当然移植过来就是NDL了)的库函数,进一步的,在NDL的库函数中(如NDL_DrawRect)会调用我们之前在libos中nanos.c_write,进而引发syscall的事件,在nanos操作系统中对事件做相应的操作(如sys_write),而这些都是靠系统中断int实现的,这一阶段为上层的函数做了很多系统调用的工作。AM提供了基本的库函数,将不同的架构抽象出来,提供给软件统一的接口,使运行在其上的应用程序不用考虑底层的架构是什么。如io.c,软件和系统只需要调用诸如screen_widthdraw_rect就能完成相关的操作,而不用关心其中的实现是否兼容。仙鹤动画主要涉及到VGA那块内存的读写,会通过video_write实现,将像素的信息写入到相应的物理内存中。当然这一切的操作最后都会转译成指令,指令的执行与硬件相关,最终是通过nemu进行译码、执行的操作。与内存读写相关的操作就是mov的相关指令了,其中也用到了rtl_sm等指令,最后会用paadr_writemap_write等函数将数据写入对应的内存中。

PA3到此结束,这一阶段麻烦还是蛮多的,但所幸通过分析都解决了,同时也学到了更多的东西,对整个计算机的运行过程有了更深刻的了解。有些东西没有bug也是不会注意到的,遇到bug的那一瞬间会很难受,但解决bug的时候会很爽,同时也会留下一些思考和感悟。

PA4

参考资料

ICS2019-PA

源码分析