输入输出

我们已经成功运行了cpu-tests中的各个测试用例, 但这些测试用例都只能默默地进行纯粹的计算. 回想起我们在程序设计课上写的第一个程序hello, 至少也输出了一行信息. 事实上, 输入输出是计算机与外界交互的基本手段, 如果你还记得计算机刚启动时执行的BIOS程序的全称是Basic Input/Output System, 你就会理解输入输出对计算机来说是多么重要了. 在真实的计算机中, 输入输出都是通过访问I/O设备来完成的.

设备与CPU

设备的工作原理其实没什么神秘的. 你会在不久的将来在数字电路实验中看到键盘控制器模块和VGA控制器模块相关的verilog代码. 噢, 原来这些设备也一样是个数字电路! 事实上, 只要向设备发送一些有意义的数字信号, 设备就会按照这些信号的含义来工作. 让一些信号来指导设备如何工作, 这不就像"程序的指令指导CPU如何工作"一样吗? 恰恰就是这样! 设备也有自己的状态寄存器(相当于CPU的寄存器), 也有自己的功能部件(相当于CPU的运算器). 当然不同的设备有不同的功能部件, 例如键盘有一个把按键的模拟信号转换成扫描码的部件, 而VGA则有一个把像素颜色信息转换成显示器模拟信号的部件. 控制设备工作的信号称为"命令字", 可以理解成"设备的指令", 设备的工作就是负责接收命令字, 并进行译码和执行... 你已经知道CPU的工作方式, 这一切对你来说都太熟悉了.

既然设备是用来进行输入输出的, 所谓的访问设备, 说白了就是从设备获取数据(输入), 比如从键盘控制器获取按键扫描码, 或者是向设备发送数据(输出), 比如向显存写入图像的颜色信息. 但是, 如果万一用户没有敲键盘, 或者是用户想调整屏幕的分辨率, 怎么办呢? 这说明, 除了纯粹的数据读写之外, 我们还需要对设备进行控制: 比如需要获取键盘控制器的状态, 查看当前是否有按键被按下; 或者是需要有方式可以查询或设置VGA控制器的分辨率. 所以, 在程序看来, 访问设备 = 读出数据 + 写入数据 + 控制状态.

我们希望计算机能够控制设备, 让设备做我们想要做的事情, 这一重任毫无悬念地落到了CPU身上. CPU除了进行计算之外, 还需要访问设备, 与其协作来完成不同的任务. 那么在CPU看来, 这些行为究竟意味着什么呢? 具体要从哪里读数据? 把数据写入到哪里? 如何查询/设置设备的状态? 一个最本质的问题是, CPU和设备之间的接口, 究竟是什么?

答案也许比你想象中的简单很多: 既然设备也有寄存器, 一种最简单的方法就是把设备的寄存器作为接口, 让CPU来访问这些寄存器. 比如CPU可以从/往设备的数据寄存器中读出/写入数据, 进行数据的输入输出; 可以从设备的状态寄存器中读出设备的状态, 询问设备是否忙碌; 或者往设备的命令寄存器中写入命令字, 来修改设备的状态.

那么, CPU要如何访问设备寄存器呢? 我们先来回顾一下CPU是如何访问CPU自己的寄存器的: 首先给这些寄存器编个号, 比如eax0, ecx1... 然后在指令中引用这些编号, 电路上会有相应的选择器, 来选择相应的寄存器并进行读写. 对设备寄存器的访问也是类似的: 我们也可以给设备中允许CPU访问的寄存器逐一编号, 然后通过指令来引用这些编号. 设备中可能会有一些私有寄存器, 它们是由设备自己维护的, 它们没有这样的编号, CPU不能直接访问它们.

这就是所谓的I/O编址方式, 因此这些编号也称为设备的地址. 常用的编址方式有两种.

端口I/O

一种I/O编址方式是端口映射I/O(port-mapped I/O), CPU使用专门的I/O指令对设备进行访问, 并把设备的地址称作端口号. 有了端口号以后, 在I/O指令中给出端口号, 就知道要访问哪一个设备寄存器了. 市场上的计算机绝大多数都是IBM PC兼容机, IBM PC兼容机对常见设备端口号的分配有专门的规定.

x86提供了inout指令用于访问设备, 其中in指令用于将设备寄存器中的数据传输到CPU寄存器中, out指令用于将CPU寄存器中的数据传送到设备寄存器中. 一个例子是使用out指令给串口发送命令字:

movl $0x41, %al
movl $0x3f8, %edx
outb %al, (%dx)

上述代码把数据0x41传送到0x3f8号端口所对应的设备寄存器中. CPU执行上述代码后, 会将0x41这个数据传送到串口的一个寄存器中, 串口接收之后, 发现是要输出一个字符A; 但对CPU来说, 它并不关心设备会怎么处理0x41这个数据, 只会老老实实地把0x41传送到0x3f8号端口. 事实上, 设备的API及其行为都会在相应的文档里面有清晰的定义, 在PA中我们无需了解这些细节, 只需要知道, 驱动开发者可以通过RTFM, 来编写相应程序来访问设备即可.

有没有一种熟悉的感觉?

API, 行为, RTFM... 没错, 我们又再次看到了计算机系统设计的一个例子: 设备向CPU暴露设备寄存器的接口, 把设备内部的复杂行为(甚至一些模拟电路的特性)进行抽象, CPU只需要使用这一接口访问设备, 就可以实现期望的功能.

计算机系统处处蕴含抽象的思想, 只要理解其中的原理, 再加上RTFM的技能, 你就能掌握计算机系统的全部!

內存映射I/O

端口映射I/O把端口号作为I/O指令的一部分, 这种方法很简单, 但同时也是它最大的缺点. 指令集为了兼容已经开发的程序, 是只能添加但不能修改的. 这意味着, 端口映射I/O所能访问的I/O地址空间的大小, 在设计I/O指令的那一刻就已经决定下来了. 所谓I/O地址空间, 其实就是所有能访问的设备的地址的集合. 随着设备越来越多, 功能也越来越复杂, I/O地址空间有限的端口映射I/O已经逐渐不能满足需求了. 有的设备需要让CPU访问一段较大的连续存储空间, 如VGA的显存, 24色加上Alpha通道的1024x768分辨率的显存就需要3MB的编址范围. 于是内存映射I/O(memory-mapped I/O, MMIO)应运而生.

内存映射I/O这种编址方式非常巧妙, 它是通过不同的物理内存地址给设备编址的. 这种编址方式将一部分物理内存的访问"重定向"到I/O地址空间中, CPU尝试访问这部分物理内存的时候, 实际上最终是访问了相应的I/O设备, CPU却浑然不知. 这样以后, CPU就可以通过普通的访存指令来访问设备. 这也是内存映射I/O得天独厚的好处: 物理内存的地址空间和CPU的位宽都会不断增长, 内存映射I/O从来不需要担心I/O地址空间耗尽的问题. 从原理上来说, 内存映射I/O唯一的缺点就是, CPU无法通过正常渠道直接访问那些被映射到I/O地址空间的物理内存了. 但随着计算机的发展, 内存映射I/O的唯一缺点已经越来越不明显了: 现代计算机都已经是64位计算机, 物理地址线都有48根, 这意味着物理地址空间有256TB这么大, 从里面划出3MB的地址空间给显存, 根本就是不痛不痒. 正因为如此, 内存映射I/O成为了现代计算机主流的I/O编址方式: RISC架构只提供内存映射I/O的编址方式, 而PCI-e, 网卡, x86的APIC等主流设备, 都支持通过内存映射I/O来访问.

作为RISC架构, mips32和riscv32都是采用内存映射I/O的编址方式. 对x86来说, 内存映射I/O的一个例子是NEMU中的物理地址区间[0xa1000000, 0xa1800000). 这段物理地址区间被映射到VGA内部的显存, 读写这段物理地址区间就相当于对读写VGA显存的数据. 例如

memset((void *)0xa1000000, 0, SCR_SIZE);

会将显存中一个屏幕大小的数据清零, 即往整个屏幕写入黑色像素, 作用相当于清屏. 可以看到, 内存映射I/O的编程模型和普通的编程完全一样: 程序员可以直接把I/O设备当做内存来访问. 这一特性也是深受驱动开发者的喜爱.

理解volatile关键字

也许你从来都没听说过C语言中有volatile这个关键字, 但它从C语言诞生开始就一直存在. volatile关键字的作用十分特别, 它的作用是避免编译器对相应代码进行优化. 你应该动手体会一下volatile的作用, 在GNU/Linux下编写以下代码:

void fun() {
  extern unsigned char _end;  // _end是什么?
  volatile unsigned char *p = &_end;
  *p = 0;
  while(*p != 0xff);
  *p = 0x33;
  *p = 0x34;
  *p = 0x86;
}

然后使用-O2编译代码. 尝试去掉代码中的volatile关键字, 重新使用-O2编译, 并对比去掉volatile前后反汇编结果的不同.

你或许会感到疑惑, 代码优化不是一件好事情吗? 为什么会有volatile这种奇葩的存在? 思考一下, 如果代码中p指向的地址最终被映射到一个设备寄存器, 去掉volatile可能会带来什么问题?

状态机视角下的输入输出

我们在PA1中提到, 计算机和程序都可以看做一个状态机, 这个状态机的状态可以表示成S = <R, M>, 其中R是寄存器的状态, M是内存的状态. 计算机添加输入输出的功能之后, 我们应该如何理解输入输出的行为呢?

我们可以把设备分成两部分, 一部分是数字电路. 我们刚才粗略地介绍了一些设备控制器的功能, 例如我们CPU可以从键盘控制器中读出按键信息. 既然是数字电路, 我们就可以把其中的时序逻辑电路看成是设备数字电路部分的状态D. 但D比较特殊, 计算机只能通过端口I/O指令或者内存映射I/O的访存指令来访问和修改D.

有意思的是设备的另一部分: 模拟电路, 它也可以改变D. 例如键盘通过检查按键位置的电容变化来判断是否有按键被按下, 若有, 则会将按键信息写入到键盘控制器的寄存器中. 而按键位置的电容是否发生变化, 又是由物理世界中的用户是否按下按键决定的. 所以我们会说, 设备是连接计算机和物理世界的桥梁.

  状态机模型           |           状态机模型之外
  S = <R, M>         |        D
  计算机/程序  <----I/O指令----> 设备 <----模拟电路----> 物理世界
                     |
                     |

要对设备的状态和行为进行建模是一件很困难的事情, 除了设备本身的行为五花八门之外, 设备的状态还时时刻刻受到物理世界的影响. 于是, 我们在对状态机模型的行为进行扩展的时候, 并不考虑将D加入到S中, 而是仅仅对输入输出相关指令的行为进行建模:

  • 执行普通指令时, 状态机按照TRM的模型进行状态转移
  • 执行设备输出相关的指令(如x86的out指令或者RISC架构的MMIO写指令)时, 状态机除了更新PC之外, 其它状态均保持不变, 但设备的状态和物理世界则会发生相应的变化
  • 执行设备输入相关的指令(如x86的in指令或者RISC架构的MMIO读指令)时, 状态机的转移将会"分叉": 状态机不再像TRM那样有唯一的新状态了, 状态机具体会转移到哪一个新状态, 将取决于执行这条指令时设备的状态

ioe

例如, 上图中的程序将要执行指令in addr, r, 这条指令将会从设备地址addr中读入一个数据到CPU的寄存器r中. 不妨假设这个设备地址对应的是某键盘控制器, 执行这条指令之后, r中的值可能是0x01, 表示读到"按下扫描码为1的按键"的信息; 也可能是0x81, 表示读到"释放扫描码为1的按键"的信息; 也可能是0x0, 表示没有任何按键信息. 这一步不确定的状态转移会影响到后续程序的运行, 例如某个游戏会根据读入的按键信息决定如何响应, 但不难理解, 游戏如何响应都是通过TRM中普通的计算指令来实现的, 和输入输出没有关系.

这个扩展之后的状态机模型从微观的角度告诉我们, 设备的输入输出都是通过CPU的寄存器来进行数据交互的. 输入输出对程序的影响也仅仅体现在输入时会进行一次不能提前确定的状态转移, 这基本上就是程序眼中输入输出的全部.

通过内存进行数据交互的输入输出

我们知道S = <R, M>, 上文介绍的端口I/O和内存映射I/O都是通过寄存器R来进行数据交互的. 很自然地, 我们可以考虑, 有没有通过内存M来进行数据交互的输入输出方式呢?

其实是有的, 这种方式叫DMA. 为了提高性能, 一些复杂的设备一般都会带有DMA的功能. 不过在NEMU中的设备都比较简单, 关于DMA的细节我们就不展开介绍了.

NEMU中的输入输出

NEMU的框架代码已经在nemu/src/device/目录下提供了设备相关的代码,

映射和I/O方式

NEMU实现了端口映射I/O和内存映射I/O两种I/O编址方式. 但无论是端口映射I/O还是内存映射I/O, 它们的核心都是映射. 自然地, 我们可以通过对映射的管理来将这两者统一起来.

具体地, 框架代码为映射定义了一个结构体类型IOMap(在nemu/include/device/map.h中定义), 包括名字, 映射的起始地址和结束地址, 映射的目标空间, 以及一个回调函数. 然后在nemu/src/device/io/map.c实现了映射的管理, 包括I/O空间的分配及其映射, 还有映射的访问接口.

其中map_read()map_write()用于将地址addr映射到map所指示的目标空间, 并进行访问. 访问时, 可能会触发相应的回调函数, 对设备和目标空间的状态进行更新. 由于NEMU是单线程程序, 因此只能串行模拟整个计算机系统的工作, 每次进行I/O读写的时候, 才会调用设备提供的回调函数(callback). 基于这两个API, 我们就可以很容易实现端口映射I/O和内存映射I/O的模拟了.

nemu/src/device/io/port-io.c是对端口映射I/O的模拟. add_pio_map()函数用于为设备的初始化注册一个端口映射I/O的映射关系. pio_read()pio_write()是面向CPU的端口I/O读写接口, 它们最终会调用map_read()map_write(), 对通过add_pio_map()注册的I/O空间进行访问.

内存映射I/O的模拟是类似的, paddr_read()paddr_write()会判断地址addr落在物理内存空间还是设备空间, 若落在物理内存空间, 就会通过pmem_read()pmem_write()来访问真正的物理内存; 否则就通过map_read()map_write()来访问相应的设备. 从这个角度来看, 内存和外设在CPU来看并没有什么不同, 只不过都是一个字节编址的对象而已.

设备

NEMU实现了串口, 时钟, 键盘, VGA, 声卡, 磁盘, SD卡七种设备, 其中磁盘会在PA的最后再进行介绍, 而SD卡在PA中不会涉及. 为了简化实现, 这些设备都是不可编程的, 而且只实现了在NEMU中用到的功能. 为了开启设备模拟的功能, 你需要在menuconfig选中相关选项:

[*] Devices  --->

重新编译后, 你会看到运行NEMU时会弹出一个新窗口, 用于显示VGA的输出(见下文). 需要注意的是, 终端显示的提示符(nemu)仍然在等待用户输入, 此时窗口并未显示任何内容.

NEMU使用SDL库来实现设备的模拟, nemu/src/device/device.c含有和SDL库相关的代码. init_device()函数主要进行以下工作:

  • 调用init_map()进行初始化.
  • 对上述设备进行初始化, 其中在初始化VGA时还会进行一些和SDL相关的初始化工作, 包括创建窗口, 设置显示模式等;
  • 然后会进行定时器(alarm)相关的初始化工作. 定时器的功能在PA4最后才会用到, 目前可以忽略它.

另一方面, cpu_exec()在执行每条指令之后就会调用device_update()函数, 这个函数首先会检查距离上次设备更新是否已经超过一定时间, 若是, 则会尝试刷新屏幕, 并进一步检查是否有按键按下/释放, 以及是否点击了窗口的X按钮; 否则则直接返回, 避免检查过于频繁, 因为上述事件发生的频率是很低的.

将输入输出抽象成IOE

设备访问的具体实现是架构相关的, 比如NEMU的VGA显存位于物理地址区间[0xa1000000, 0xa1080000), 但对native的程序来说, 这是一个不可访问的非法区间, 因此native程序需要通过别的方式来实现类似的功能. 自然地, 设备访问这一架构相关的功能, 应该归入AM中. 与TRM不同, 设备访问是为计算机提供输入输出的功能, 因此我们把它们划入一类新的API, 名字叫IOE(I/O Extension).

要如何对不同架构的设备访问抽象成统一的API呢? 回想一下在程序看来, 访问设备其实想做什么: 访问设备 = 读出数据 + 写入数据 + 控制状态. 进一步的, 控制状态本质上也是读/写设备寄存器的操作, 所以访问设备 = 读/写操作.

对, 就是这么简单! 所以IOE提供三个API:

bool ioe_init();
void ioe_read(int reg, void *buf);
void ioe_write(int reg, void *buf);

第一个API用于进行IOE相关的初始化操作. 后两个API分别用于从编号为reg的寄存器中读出内容到缓冲区buf中, 以及往编号为reg寄存器中写入缓冲区buf中的内容. 需要注意的是, 这里的reg寄存器并不是上文讨论的设备寄存器, 因为设备寄存器的编号是架构相关的. 在IOE中, 我们希望采用一种架构无关的"抽象寄存器", 这个reg其实是一个功能编号, 我们约定在不同的架构中, 同一个功能编号的含义也是相同的, 这样就实现了设备寄存器的抽象.

abstract-machine/am/include/amdev.h中定义了常见设备的"抽象寄存器"编号和相应的结构. 这些定义是架构无关的, 每个架构在实现各自的IOE API时, 都需要遵循这些定义(约定). 为了方便地对这些抽象寄存器进行访问, klib中提供了io_read()io_write()这两个宏, 它们分别对ioe_read()ioe_write()这两个API进行了进一步的封装.

特别地, NEMU作为一个平台, 设备的行为是与ISA无关的, 因此我们只需要在abstract-machine/am/src/platform/nemu/ioe/目录下实现一份IOE, 来供NEMU平台的架构共享. 其中, abstract-machine/am/src/platform/nemu/ioe/ioe.c中实现了上述的三个IOE API, ioe_read()ioe_write()都是通过抽象寄存器的编号索引到一个处理函数, 然后调用它. 处理函数的具体功能和寄存器编号相关, 下面我们来逐一介绍NEMU中每个设备的功能.

串口

串口是最简单的输出设备. nemu/src/device/serial.c模拟了串口的功能. 其大部分功能也被简化, 只保留了数据寄存器. 串口初始化时会分别注册0x3F8处长度为8个字节的端口, 以及0xa00003F8处长度为8字节的MMIO空间, 它们都会映射到串口的数据寄存器. 由于NEMU串行模拟计算机系统的工作, 串口的状态寄存器可以一直处于空闲状态; 每当CPU往数据寄存器中写入数据时, 串口会将数据传送到主机的标准错误流进行输出.

事实上, 在$ISA-nemu中, 我们之前提到的putch()函数, 就是通过串口输出的. 然而AM却把putch()放在TRM, 而不是IOE中, 这让人觉得有点奇怪. 的确, 可计算理论中提出的最原始的TRM并不包含输出的能力, 但对于一个现实的计算机系统来说, 输出是一个最基本的功能, 没有输出, 用户甚至无法知道程序具体在做什么. 因此在AM中, putch()的加入让TRM具有输出字符的能力, 被扩充后的TRM更靠近一个实用的机器, 而不再是只会计算的数学模型.

abstract-machine/am/src/platform/nemu/trm.c中的putch()会将字符输出到串口. 如果你选择了x86, 为了让程序使用串口进行输出, 你还需要在NEMU中实现端口映射I/O.

运行Hello World

如果你选择了x86, 你需要实现in, out指令. 具体地, 你需要RTFSC, 然后在in指令和out指令的实现中正确调用pio_read()pio_write(). 如果你选择的是mips32和riscv32, 你不需要实现额外的代码, 因为NEMU的框架代码已经支持MMIO了.

实现后, 在am-kernels/kernels/hello/目录下键入

make ARCH=$ISA-nemu run

如果你的实现正确, 你将会看到程序往终端输出一些信息(请注意不要让输出淹没在调试信息中).

需要注意的是, 这个hello程序和我们在程序设计课上写的第一个hello程序所处的抽象层次是不一样的: 这个hello程序可以说是直接运行在裸机上, 可以在AM的抽象之上直接输出到设备(串口); 而我们在程序设计课上写的hello程序位于操作系统之上, 不能直接操作设备, 只能通过操作系统提供的服务进行输出, 输出的数据要经过很多层抽象才能到达设备层. 我们会在PA3中进一步体会操作系统的作用.

设备和DiffTest

在状态机视角下, 执行一条输入指令会让状态机的状态转移变得不唯一, 新状态取决于设备的状态. 由于NEMU中设备的行为是我们自定义的, 与REF中的标准设备的行为不完全一样 (例如NEMU中的串口总是就绪的, 但QEMU中的串口也许并不是这样), 这导致在NEMU中执行输入指令的结果会和REF有所不同. 为了使得DiffTest可以正常工作, 框架代码在访问设备的过程中调用了difftest_skip_ref()函数 (见nemu/include/device/map.h中定义的find_mapid_by_addr()函数)来跳过与REF的检查.

在AM中, main()函数允许带有一个字符串参数, 这一参数通过mainargs指定, 并由AM的运行时环境负责将它传给main()函数, 供AM程序使用. 具体的参数传递方式和架构相关. 例如你可以在运行hello的时候给出一个字符串参数:

make ARCH=$ISA-nemu run mainargs=I-love-PA

这个参数会被hello程序原样输出.

理解mainargs

请你通过RTFSC理解这个参数是如何从make命令中传递到hello程序中的, $ISA-nemunative采用了不同的传递方法, 都值得你去了解一下.

实现printf

有了putch(), 我们就可以在klib中实现printf()了.

你之前已经实现了sprintf()了, 它和printf()的功能非常相似, 这意味着它们之间会有不少重复的代码. 你已经见识到Copy-Paste编程习惯的坏处了, 思考一下, 如何简洁地实现它们呢?

实现了printf()之后, 你就可以在AM程序中使用输出调试法了.

时钟

有了时钟, 程序才可以提供时间相关的体验, 例如游戏的帧率, 程序的快慢等. nemu/src/device/timer.c模拟了i8253计时器的功能. 计时器的大部分功能都被简化, 只保留了"发起时钟中断"的功能(目前我们不会用到). 同时添加了一个自定义的时钟. i8253计时器初始化时会分别注册0x48处长度为8个字节的端口, 以及0xa0000048处长度为8字节的MMIO空间, 它们都会映射到两个32位的RTC寄存器. CPU可以访问这两个寄存器来获得用64位表示的当前时间.

为什么不设置一个64位的RTC寄存器?

我们希望设备的设计与CPU解耦, 无论设备接入到何种架构的CPU都能工作. 但32位的CPU无法一次访问64位的设备寄存器, 因此将一个64位的功能拆分成两个32位的设备寄存器, 可同时支持接入32位和64位CPU.

abstract-machine/am/include/amdev.h中为时钟的功能定义了两个抽象寄存器:

  • AM_TIMER_RTC, AM实时时钟(RTC, Real Time Clock), 可读出当前的年月日时分秒. PA中暂不使用.
  • AM_TIMER_UPTIME, AM系统启动时间, 可读出系统启动后的微秒数.
RTC - 实时时钟

RTC泛指流逝速率与真实时间一致的时钟, 用户可根据RTC进行一段时间的测量. 按照这个定义, 上述两个AM抽象寄存器都属于RTC, 不过它们的侧重点有所不同: AM_TIMER_RTC强调读出的时间与现实时间完全一致, AM_TIMER_UPTIME则侧重系统启动后经过的时间, 即从0开始计数.

虽然NEMU中的设备寄存器的名称也叫RTC, 但为了支持AM_TIMER_UPTIME的功能, 它不必实现成AM_TIMER_RTC.

实现IOE

abstract-machine/am/src/platform/nemu/ioe/timer.c中实现AM_TIMER_UPTIME的功能. 在abstract-machine/am/src/platform/nemu/include/nemu.habstract-machine/am/src/$ISA/$ISA.h中有一些输入输出相关的代码供你使用.

实现后, 在$ISA-nemu中运行am-kernel/tests/am-tests中的real-time clock test测试. 如果你的实现正确, 你将会看到程序每隔1秒往终端输出一行信息. 由于我们没有实现AM_TIMER_RTC, 测试总是输出1900年0月0日0时0分0秒, 这属于正常行为, 可以忽略.

不要在native链接到klib时运行IOE相关的测试

native的IOE是基于SDL库实现的, 它们假设常用库函数的行为会符合glibc标准, 但我们自己实现的klib通常不能满足这一要求. 因此__NATIVE_USE_KLIB__仅供测试klib实现的时候使用, 我们不要求在定义__NATIVE_USE_KLIB__的情况下正确运行所有程序.

RTFSC尽可能了解一切细节

经常有同学反馈自己阅读代码的经验很少, 没见过好的代码怎么写. 但另一方面, 这些同学在做实验的时候却只关心究竟在什么地方填写代码, 写完就认为自己完成了实验, 从而觉得其它代码和实验内容无关, 不愿意花时间去阅读.

怀着这种心态的同学其实是主动放弃了锻炼能力的机会. 如果你是真心想学习, 你就不应该觉得"这个文件好像不看也行, 关我P事", 而是应该主动想"这个程序跑出来的结果是这样, 我来读代码看看它究竟是怎么写的". 事实上, 框架代码里面有非常多的宝藏, 即使是测试代码, 阅读它们除了能让你明白这些测试的具体行为之外, 你还会了解程序应该如何使用AM的API, 甚至是编码风格也有值得你学习的地方.

因此, 要如何运行real-time clock test这个测试, 就交给你来RTFSC吧.

看看NEMU跑多快

有了时钟之后, 我们就可以测试一个程序跑多快, 从而测试计算机的性能. 尝试在NEMU中依次运行以下benchmark(已经按照程序的复杂度排序, 均在am-kernel/benchmarks/目录下; 另外跑分时请关闭NEMU的监视点, trace以及DiffTest, 同时取消menuconfig中的 Enable debug information并重新编译NEMU, 以获得较为真实的跑分):

  • dhrystone
  • coremark
  • microbench

成功运行后会输出跑分. 其中microbench跑分以i9-9900K @ 3.60GHz的处理器为参照, 100000分表示与参照机器性能相当, 100分表示性能为参照机器的千分之一. 除了和参照机器比较之外, 也可以和小伙伴进行比较. 如果把上述benchmark编译到native, 还可以比较native的性能.

另外, microbench提供了四个不同规模的测试集, 包括test, train, refhuge. 你可以先运行test规模, 它可以较快地运行结束, 来检查NEMU实现的正确性, 然后再运行ref规模来测量性能. 具体的运行方法请阅读README.

此外, huge规模一般用于真机的测试, 在NEMU中需要运行很长时间, 我们不要求你运行它.

跑分参考

在真机上通过RISC-V的NEMU(按照PA的常规流程开发, 未进行优化)运行microbench的ref规模输入, 跑分约300~500. 若在虚拟机中运行, 则分数更低一些. 若在虚拟机中跑分过低(例如只有几分), 可以尝试在menuconfig 中将NEMU所使用的定时器从gettimeofday()换成clock_gettime():

Miscellaneous
      Host timer (clock_gettime)

重新编译NEMU后若跑分有明显提升, 则gettimeofday()的低性能可能与时钟源(clocksource)相关, 可尝试参考这个帖子 进行调整, 或者重启虚拟机或真机(有同学反映重启可解决问题), 然后切换回gettimeofday()并对比性能.

如何调试复杂程序

随着程序变得越来越复杂, 调试会变得越来越困难, bug也可能变得越来越奇怪. 这几乎是做PA的每位同学都必须经历的痛苦: 你在程序设计课上锻炼出的能力根本不足以让你编写正确的程序. 但你毕业之后还需要面对代码量上万行甚至几十万行的大项目, 相比之下PA简直就是一个小玩具.

我们在PA中让你面对这些痛苦, 最终还是希望你明白真正的调试方法, 从而将来可以面对更大的项目:

  1. RTFSC. 这是为了让你明白一切细节是如何发生的, 这些细节在调试的时候就会成为有用的线索, 这是因为: 只有当你明白什么是"正确", 你才会知道什么是"错误"; 对"正确"的认识越清晰, 对"错误"的了解也会越深刻. 因此当你碰到bug后感到束手无策, 完全无法理解这个bug大概是如何出现, 几乎都是因为你之前不愿意RTFSC, 觉得不看也无所谓, 导致你手上完全没有可用的线索帮助你进行bug的推敲.
  2. 使用正确的工具. 你遇到的bug千奇百怪, 但总有方法可以帮你高效地解决. 我们在讲义中介绍了非常多的工具和方法, 它们总有各自适用的场合和局限性. 因此如果你对一个bug的原因有初步的猜测, 但完全不知道应该如何下手去调试它, 几乎都是因为你没有熟练地掌握这些工具和方法, 导致无法根据实际情况选择出合适的调试方案, 甚至是因为你完全不重视它们, 觉得不了解它们也无所谓. 比如我们已经在某道选做题里面提示了高效应对段错误的方法, 如果你不知道遇到段错误的时候应该怎么办, 而又忽略了上述提示, 相信你已经浪费了很多不必要的时间.

在PA中锻炼的技能是环环相扣的, 当你认为可以通过"不仔细看代码/讲义/手册"来节省时间时, 你将来必定会为此付出更多的代价: bug一来, 欠的债都是要还的.

RTFSC了解一切细节

关于AM_TIMER_UPTIME的实现, 我们在框架代码中埋了一些小坑, 如果你没有修复相关的问题, 你可能会在运行benchmark的时候出现跑分不正确的现象. 这是为了强迫大家认真RTFSC了解程序运行过程中的一切细节: benchmark读取时钟信息的时候, 整个计算机系统究竟发生了什么? 只有这样你才能把时钟相关的bug调试正确.

NEMU和语言解释器

microbench中有一个叫bf的测试项目, 它是Brainf**k语言的一个解释器. Brainf**k语言非常简单, 它只有8种指令, 连变量的概念都没有, 因此非常接近机器语言. 有兴趣的同学可以阅读bf.c的代码, 你会发现这么简单的一个语言解释器和看上去很复杂的NEMU从原理上来看还是有相似之处的.

先完成, 后完美 - 抑制住优化代码的冲动

计算机系统的设计过程可以概括成两件事:

  1. 设计一个功能正确的完整系统 (先完成)
  2. 在第1点的基础上, 让程序运行得更快 (后完美)

看到跑分之后, 你也许会忍不住去思考如何优化你的NEMU. 上述原则告诉你, 时机还没到. 一个原因是, 在整个系统完成之前, 你很难判断系统的性能瓶颈会出现在哪一个模块中. 你一开始辛辛苦苦追求的完美, 最后对整个系统的性能提升也许只是九牛一毛, 根本不值得你花费这么多时间. 比如你可能在PA1中花时间去优化表达式求值的算法, 你可以以此作为一个编程练习, 但如果你的初衷是为了优化性能, 你的付出绝对是没有效果的: 你得输入多长的表达式才能让你明显感觉到新算法的性能优势?

此外, PA作为一个教学实验, 只要性能不是差得无法接受, 性能都不是你需要考虑的首要目标, 实现方案点到为止即可. 相比之下, 通过设计一个完整的系统来体会程序如何运行, 对你来说才是最重要的.

事实上, 除了计算机, "先完成, 后完美"的原则也适用于很多领域. 比如企业方案策划, 大家可以在一个完整但哪怕很简单的方案上迭代; 但如果一开始就想着把每一个点都做到完美, 最后很可能连一份完整的方案也拿不出手. 论文写作也一样, 哪怕是只有完整的小标题, 大家都可以去检查文章的整体框架有无逻辑漏洞; 相反, 就算文章配有再漂亮的实验数据, 在有漏洞的逻辑面前也无法自圆其说.

随着你参与越来越大的项目, 你会发现让完整的系统正确地跑起来, 会变得越来越难. 这时候, 遵循"先完成, 后完美"的原则就显得更重要了: 很多问题也许会等到项目趋于完整的时候才会暴露出来, 舍弃全局的完整而换来局部的完美, 大多时候只会南辕北辙.

运行红白机模拟器

正确实现时钟后, 你就可以在NEMU上运行一个字符版本的FCEUX了. 修改fceux-am/src/config.h中的代码, 把HAS_GUI宏注释掉, FCEUX就会通过putch()来输出画面.

不过FCEUX需要调用klib中的malloc()free(), 目前你可以实现一个简单的版本:

  • malloc()中维护一个上次分配内存位置的变量addr, 每次调用malloc()时, 就返回[addr, addr + size)这段空间. addr的初值设为heap.start, 表示从堆区开始分配. 你也可以参考microbench中的相关代码. 注意malloc()对返回的地址有一定的要求, 具体情况请RTFM.
  • free()直接留空即可, 表示只分配不释放. 目前NEMU中的可用内存足够让FCEUX成功运行.

然后你可以参考PA1中运行FCEUX的方式, 来将超级玛丽运行在你的NEMU上. 为了获得比较好的显示效果, 你需要在一个不少于60行的终端中运行. 由于此时还没有实现键盘, 你将不能对游戏进行操作, 但你还是可以观看超级玛丽自带的演示(需要在开始界面中等待约10秒).

设备访问的踪迹 - dtrace

和mtrace类似, 我们也可以记录设备访问的踪迹(device trace), 从而观察程序是否以预期的方式访问设备.

实现dtrace

这个功能非常简单, 你可以自行定义dtrace输出的格式. 注意你可以通过map->name来获取一段设备地址空间的名字, 这样可以帮助你输出可读性较好的信息. 同样地, 你也可以为dtrace实现条件控制功能, 提升dtrace使用的灵活性.

键盘

键盘是最基本的输入设备. 一般键盘的工作方式如下: 当按下一个键的时候, 键盘将会发送该键的通码(make code); 当释放一个键的时候, 键盘将会发送该键的断码(break code). nemu/src/device/keyboard.c模拟了i8042通用设备接口芯片的功能. 其大部分功能也被简化, 只保留了键盘接口. i8042芯片初始化时会分别注册0x60处长度为4个字节的端口, 以及0xa0000060处长度为4字节的MMIO空间, 它们都会映射到i8042的数据寄存器. 每当用户敲下/释放按键时, 将会把相应的键盘码放入数据寄存器, CPU可以访问数据寄存器, 获得键盘码; 当无按键可获取时, 将会返回AM_KEY_NONE.

abstract-machine/am/include/amdev.h中为键盘的功能定义了一个抽象寄存器:

  • AM_INPUT_KEYBRD, AM键盘控制器, 可读出按键信息. keydowntrue时表示按下按键, 否则表示释放按键. keycode为按键的断码, 没有按键时, keycodeAM_KEY_NONE.
实现IOE(2)

abstract-machine/am/src/platform/nemu/ioe/input.c中实现AM_INPUT_KEYBRD的功能. 实现后, 在$ISA-nemu中运行am-tests中的readkey test测试. 如果你的实现正确, 在程序运行时弹出的新窗口中按下按键, 你将会看到程序输出相应的按键信息, 包括按键名, 键盘码, 以及按键状态.

如何检测多个键同时被按下?

在游戏中, 很多时候需要判断玩家是否同时按下了多个键, 例如RPG游戏中的八方向行走, 格斗游戏中的组合招式等等. 根据键盘码的特性, 你知道这些功能是如何实现的吗?

运行红白机模拟器(2)

正确实现键盘后, 你就可以在NEMU上运行字符版本的红白机模拟器, 并在里面玩超级玛丽了.

VGA

VGA可以用于显示颜色像素, 是最常用的输出设备. nemu/src/device/vga.c模拟了VGA的功能. VGA初始化时注册了从0xa1000000开始的一段用于映射到video memory(显存, 也叫frame buffer, 帧缓冲)的MMIO空间. 代码只模拟了400x300x32的图形模式, 一个像素占32个bit的存储空间, R(red), G(green), B(blue), A(alpha)各占8 bit, 其中VGA不使用alpha的信息. 如果你对VGA编程感兴趣, 这里有一个名为FreeVGA的项目, 里面提供了很多VGA的相关资料.

神奇的调色板

现代的显示器一般都支持24位的颜色(R, G, B各占8个bit, 共有2^8*2^8*2^8约1600万种颜色), 为了让屏幕显示不同的颜色成为可能, 在8位颜色深度时会使用调色板的概念. 调色板是一个颜色信息的数组, 每一个元素占4个字节, 分别代表R(red), G(green), B(blue), A(alpha)的值. 引入了调色板的概念之后, 一个像素存储的就不再是颜色的信息, 而是一个调色板的索引: 具体来说, 要得到一个像素的颜色信息, 就要把它的值当作下标, 在调色板这个数组中做下标运算, 取出相应的颜色信息. 因此, 只要使用不同的调色板, 就可以在不同的时刻使用不同的256种颜色了.

在一些90年代的游戏中(比如仙剑奇侠传), 很多渐出渐入效果都是通过调色板实现的, 聪明的你知道其中的玄机吗?

在AM中, 显示相关的设备叫GPU, GPU是一个专门用来进行图形渲染的设备. 在NEMU中, 我们并不支持一个完整GPU的功能, 而仅仅保留绘制像素的基本功能.

abstract-machine/am/include/amdev.h中为GPU定义了五个抽象寄存器, 在NEMU中只会用到其中的两个:

  • AM_GPU_CONFIG, AM显示控制器信息, 可读出屏幕大小信息widthheight. 另外AM假设系统在运行过程中, 屏幕大小不会发生变化.
  • AM_GPU_FBDRAW, AM帧缓冲控制器, 可写入绘图信息, 向屏幕(x, y)坐标处绘制w*h的矩形图像. 图像像素按行优先方式存储在pixels中, 每个像素用32位整数以00RRGGBB的方式描述颜色. 若synctrue, 则马上将帧缓冲中的内容同步到屏幕上.
实现IOE(3)

事实上, VGA设备还有两个寄存器: 屏幕大小寄存器和同步寄存器. 我们在讲义中并未介绍它们, 我们把它们作为相应的练习留给大家. 具体地, 屏幕大小寄存器的硬件(NEMU)功能已经实现, 但软件(AM)还没有去使用它; 而对于同步寄存器则相反, 软件(AM)已经实现了同步屏幕的功能, 但硬件(NEMU)尚未添加相应的支持.

好了, 提示已经足够啦, 至于要在什么地方添加什么样的代码, 就由你来RTFSC吧. 这也是明白软硬件如何协同工作的很好的练习. 实现后, 向__am_gpu_init()中添加如下测试代码:

--- abstract-machine/am/src/platform/nemu/ioe/gpu.c
+++ abstract-machine/am/src/platform/nemu/ioe/gpu.c
@@ -6,2 +6,8 @@
 void __am_gpu_init() {
+  int i;
+  int w = 0;  // TODO: get the correct width
+  int h = 0;  // TODO: get the correct height
+  uint32_t *fb = (uint32_t *)(uintptr_t)FB_ADDR;
+  for (i = 0; i < w * h; i ++) fb[i] = i;
+  outl(SYNC_ADDR, 1);
 }

其中上述代码中的wh并未设置正确的值, 你需要阅读am-tests中的display test测试, 理解它如何获取正确的屏幕大小, 然后修改上述代码的wh. 你可能还需要对gpu.c中的代码进行一些修改. 修改后, 在$ISA-nemu中运行am-tests中的display test测试, 如果你的实现正确, 你会看到新窗口中输出了全屏的颜色信息.

实现IOE(4)

事实上, 刚才输出的颜色信息并不是display test期望输出的画面, 这是因为AM_GPU_FBDRAW的功能并未正确实现. 你需要正确地实现AM_GPU_FBDRAW的功能. 实现后, 重新运行display test. 如果你的实现正确, 你将会看到新窗口中输出了相应的动画效果.

实现正确后, 你就可以去掉上文添加的测试代码了.

运行红白机模拟器(3)

正确实现VGA后, 重新定义fceux-am/src/config.h中的HAS_GUI, 你就可以在NEMU上运行图形版本的FCEUX了.

声卡

此部分为选做内容

声卡部分为选做内容, 不计入成绩, 但实现声卡之后, 你将来也可以在运行仙剑奇侠传的时候播放音频, 欢迎感兴趣的同学进行尝试.

一个真实的声卡设备非常复杂, 在NEMU中, 我们根据SDL库的API来设计一个简单的声卡设备. 使用SDL库来播放音频的过程非常简单:

  1. 通过SDL_OpenAudio()来初始化音频子系统, 需要提供频率, 格式等参数, 还需要注册一个用于将来填充音频数据的回调函数 更多的信息请阅读man SDL_OpenAudio(需要安装libsdl2-doc)或者这个页面.
  2. SDL库会定期调用初始化时注册的回调函数, 并提供一个缓冲区, 请求回调函数往缓冲区中写入音频数据
  3. 回调函数返回后, SDL库就会按照初始化时提供的参数来播放缓冲区中的音频数据

声卡不能独立播放音频, 它需要接受来自客户程序的设置和音频数据. 程序要和设备交互, 自然是要通过I/O方式了, 因此我们需要定义一些寄存器和 MMIO空间来让程序访问(见nemu/src/device/audio.c).

  • freq, channelssamples这三个寄存器可写入相应的初始化参数
  • init寄存器用于初始化, 写入后将根据设置好的freq, channelssamples来对SDL的音频子系统进行初始化
  • 流缓冲区STREAM_BUF是一段MMIO空间, 用于存放来自程序的音频数据, 这些音频数据会在将来写入到SDL库中
  • sbuf_size寄存器可读出流缓冲区的大小
  • count寄存器可以读出当前流缓冲区已经使用的大小

NEMU的简单声卡在初始化时会分别注册0x200处长度为24个字节的端口, 以及0xa0000200处长度为24字节的MMIO空间, 它们都会映射到上述寄存器; 此外还注册了从0xa1200000开始, 长度为64KB的MMIO空间作为流缓冲区.

在AM中, abstract-machine/am/include/amdev.h中为声卡定义了四个抽象寄存器:

  • AM_AUDIO_CONFIG, AM声卡控制器信息, 可读出存在标志present以及流缓冲区的大小bufsize. 另外AM假设系统在运行过程中, 流缓冲区的大小不会发生变化.
  • AM_AUDIO_CTRL, AM声卡控制寄存器, 可根据写入的freq, channelssamples对声卡进行初始化.
  • AM_AUDIO_STATUS, AM声卡状态寄存器, 可读出当前流缓冲区已经使用的大小count.
  • AM_AUDIO_PLAY, AM声卡播放寄存器, 可将[buf.start, buf.end)区间的内容作为音频数据写入流缓冲区. 若当前流缓冲区的空闲空间少于即将写入的音频数据, 此次写入将会一直等待, 直到有足够的空闲空间将音频数据完全写入流缓冲区才会返回.
实现声卡

作为一个选做任务, 声卡的硬件实现nemu/src/device/audio.c 和相应的IOE抽象abstract-machine/am/src/platform/nemu/ioe/audio.c都未提供, 不过am-tests里面的audio test给出了声卡IOE抽象的使用方式, 你需要先RTFSC理解它是如何播放音频的(你也可以在native上感受一下), 然后来实现NEMU和AM的相关代码. 如果你的实现正确, 运行audio test将会听到一段"小星星"的旋律.

一些提示

实现声卡主要需要做两件事:

  1. 初始化. 除了理清程序如何通过AM提供的API来访问硬件的寄存器之外, 你还需要编写一些代码来对SDL库的音频子系统进行初始化. 我们提供一些代码片段供你使用, 你还需要在......处补充必要的代码, 更多细节请阅读上文提到的相关资料:
    SDL_AudioSpec s = {};
    s.format = AUDIO_S16SYS;  // 假设系统中音频数据的格式总是使用16位有符号数来表示
    s.userdata = NULL;        // 不使用
    ......
    SDL_InitSubSystem(SDL_INIT_AUDIO);
    SDL_OpenAudio(&s, NULL);
    SDL_PauseAudio(0);
    
  2. 维护流缓冲区. 我们可以把流缓冲区可以看成是一个队列, 程序通过AM_AUDIO_PLAY的抽象往流缓冲区里面写入音频数据, 而SDL库的回调函数则从流缓冲区里面读出音频数据. 所以维护流缓冲区其实是一个数据结构的作业, 不过这个作业的特殊之处在于, 队列的读写双方分别位于两个不同的项目(硬件和软件), 它们之间只能通过I/O操作来进行交互. 此外, 如果回调函数需要的数据量大于当前流缓冲区中的数据量, 你还需要把SDL提供的缓冲区剩余的部分清零, 以避免把一些垃圾数据当做音频, 从而产生噪音.

这个选做任务综合考察计算机抽象层, 数据结构, RTFM等技能, 回报是让你设计的计算机系统变得与众不同, 非常值得一做.

注意音量, 保护耳朵

如果你的实现不正确, 程序有可能输出白噪声. 请你务必在低音量的情况下进行测试, 避免误伤耳朵.

音频播放的原理

实现了声卡之后, 就可以玩上一番了. 我们刚才提到了freq, channelssamples这三个参数, 除了STFW之外, 你也可以通过实际操作来感受一下它们的含义: 你可以修改audio test中的这些参数, 感受一下播放出来的音频有什么不同, 从而对这几个参数有更感性的认识.

播放自己的音乐

由于声卡是一个底层的硬件设备, 它只能直接播放音频的离散采样数据, 也即PCM格式. 框架代码提供的"小星星"片段就是PCM格式的. 但PCM格式的存储容量比较大, 假设有一段时长为3分钟, 双通道44100Hz的音频, 每个通道的采样用16位整数表示, 那么用PCM格式来存储这段音频将需要花费

44100 * 2 * 16 / 8 * 3 * 60 = 31752000B = 30.28MB

的存储空间. 因此我们会对PCM格式的音频进行编码压缩, 来节省存储空间, 于是有了MP3, OGG等音频编码格式. 但这些格式的音频, 声卡设备是无法直接识别的. 要播放这些格式的音频, 还需要对它们进行解码, 将PCM格式的采样数据还原出来, 然后才能交给声卡设备进行播放.

Linux中有一款非常强大的音频编解码工具叫ffmpeg, 例如我们可以通过以下命令来对一段MP3音频进行解码:

ffmpeg -i MyMusic.mp3 -acodec pcm_s16le -f s16le -ac 1 -ar 44100 44k.pcm

其中各个参数的含义就交给你RTFM吧. ffmpeg可以智能地识别输入文件的格式, 还支持视频的编解码, 包括音频和视频的裁剪和拼接等, 非常适合业余场合使用. 有了ffmepg, 你就可以在NEMU上播放你喜欢的音乐了.

运行红白机模拟器(4)

正确实现声卡后, 你就可以在NEMU上运行带音效的FCEUX了.

不过由于FCEUX需要额外进行音频解码的工作, 这会使得FCEUX在NEMU中运行的帧率有所下降. 为了避免大幅影响游戏体验, 我们在fceux-am/src/config.h中提供了一些配置选项, 其中音效有三种配置, 分别是高质量(SOUND_HQ), 低质量(SOUND_LQ)以及无音效(SOUND_NONE). NEMU平台默认选择低质量, 以节省FCEUX的音效解码时间. 你可以根据实际的运行效果对此处的配置进行调整, 例如增加跳帧的数量来节省FCEUX的渲染时间, 但代价是降低画面的连贯度.

冯诺依曼计算机系统

展示你的计算机系统

完整实现IOE后, 我们还可以运行一些酷炫的程序:

  • 幻灯片播放(在am-kernels/kernels/slider/目录下). 程序将每隔5秒切换images/目录下的图片.
  • 打字小游戏(在am-kernels/kernels/typing-game/目录下). typing
  • 简单的红白机模拟器LiteNES(在am-kernels/kernels/litenes/目录下). 不过LiteNES的性能比较低, 在NEMU上只能跑十几FPS, 而且只能运行超级玛丽.
  • 完整的红白机模拟器FCEUX. 没错, 我们在PA1中给大家介绍的红白机模拟器, 现在也已经可以在NEMU中运行起来了!

这些游戏虽然看上去差异很大, 但背后都蕴含了"程序如何使用IOE来实现游戏效果"的框架. 事实上, 游戏可以抽象成一个死循环:

while (1) {
  等待新的一帧();  // AM_TIMER_UPTIME
  处理用户按键();  // AM_INPUT_KEYBRD
  更新游戏逻辑();  // TRM
  绘制新的屏幕();  // AM_GPU_FBDRAW
}

我们的计算机添加IOE之后, 完全可以通过AM的抽象支撑起循环体中的功能, 所以要在NEMU中运行这些酷炫的游戏, 并不是不可能的. 甚至我们也可以把刚才运行的am-tests测试中的死循环看成是一些简化的游戏. 你将要在PA3中运行的复杂游戏仙剑奇侠传, 背后也是这样的一个死循环.

游戏是如何运行的

请你以打字小游戏为例, 结合"程序在计算机上运行"的两个视角, 来剖析打字小游戏究竟是如何在计算机上运行的. 具体地, 当你按下一个字母并命中的时候, 整个计算机系统(NEMU, ISA, AM, 运行时环境, 程序) 是如何协同工作, 从而让打字小游戏实现出"命中"的游戏效果?

打字小游戏只有不到200行的简单代码, 非常适合大家RTFSC. 如果你发现自己难以理解打字小游戏的具体行为, 你需要给自己敲响警钟了: 你在做PA的时候很有可能只关注怎么把必做内容的代码写对, 而不去思考这些代码和计算机系统的关系. 从ICS和PA的角度来说, 这种做法是不及格的, 而且很快你就会吃苦头了.

观察程序如何运行

RTFSC是从静态视角理解程序如何运行, 事实上我们还能够从动态视角去理解程序如何运行: 我们先观察程序运行时刻的行为, 看看它实际上做了什么. 这正是trace的作用! 我们之所以在NEMU中要求大家实现各种trace工具, 其实是在引导大家从各个层次观察程序运行时刻的行为, 这也符合PA"理解程序如何在计算机上运行"的终极目标.

尝试打开NEMU中的所有trace工具, 然后运行打字小游戏, 你会发现ftrace和dtrace对你回答上述必答题有非常大的帮助.

体会到AM的好处了吧?

听闻这学期的数字电路实验的大作业可以选择设计一个单周期CPU. 现在有了这么多丰富的AM应用, 关键是这些AM应用可以很方便地移植到任意架构, 在自己设计的CPU上运行超级玛丽和魂斗罗等经典游戏, 展示效果酷炫得要上天!

AM就是为教学实验而生. 既然这样, 你还等什么呢?

RTFSC指南

我们在这里列出目前大家应该读过并理解的代码, 供大家进行自我检讨:

  • NEMU中除了fixdep, kconfig, 以及没有选择的ISA之外的全部已有代码(包括Makefile)
  • abstract-machine/am/下与$ISA-nemu相关的, 除去CTE和VME之外的代码
  • abstract-machine/klib/中的所有代码
  • abstract-machine/Makefileabstract-machine/scripts/中的所有代码
  • am-kernels/tests/cpu-tests/中的所有代码
  • am-kernels/tests/am-tests/中运行过的测试代码
  • am-kernels/benchmarks/microbench/bench.c
  • am-kernels/kernels/中的hello, slidertyping-game的所有代码

如果你发现自己不能理解这些代码的行为, 就赶紧看看吧. 多看一个文件, bug少调几天, 到了PA3你就会领教到了.

LiteNES如何工作?

另一个值得RTFSC的项目是LiteNES, 除了内置rom之外, 代码总数大约1500行. 关键是这个小巧玲珑的项目里面已经包含了一个完整的计算机系统: CPU, 内存, MMIO, 以及手柄(psg), 卡带(mmc)和图形处理器(ppu)这三个外设. 除了ppu的内部实现细节之外, 其余的部分你都已经有能力去理解了.

有趣的是, LiteNES可以看成是NEMU和AM程序的融合. 尝试阅读LiteNES的代码, 理解LiteNES作为一个完整的计算机系统, 上述部件如何交互, 以及LiteNES作为一个AM程序, 如何通过AM提供的API来实现游戏效果. 我们提供6502处理器(NES的CPU)以及NES PPU(图形处理器)的资料供大家参考.

优化LiteNES

我们在LiteNES中默认开启了跳帧模式, 只渲染1/2的帧. 虽然在native上可以跑满60FPS, 但即使开启跳帧模式, 在NEMU中也只能跑十几FPS.

NEMU的性能确实不高, 但LiteNES也是半斤八两, 代码还有非常多可以优化的空间. 我们手上有一个经过暴力优化的LiteNES, 它在microbench跑分为236分的x86-nemu上也可以跑满60 FPS, 在microbench跑分为400分的riscv32-nemu甚至可以跑到100 FPS!

为了尽可能屏蔽真机和NEMU实现带来的性能差异, 我们可以在记录FPS的同时, 把microbench的跑分也一同记录下来, 然后计算FPS/跑分这个指标, 来衡量单位计算能力贡献的FPS, 通过它可以大致反映出LiteNES本身的性能. 比如我们上手的暴力优化版本, 在x86-nemu上计算这个指标, 结果是60/236 = 0.2542; 而在riscv32-nemu上计算则是100/400 = 0.2500, 结果还是比较一致的.

如果你已经完成了必做的内容而又闲来无事, 可以尝试来优化LiteNES的代码, 并和小伙伴PK优化后的性能吧! 不过, 你打算怎么来进行优化呢?

在NEMU上运行NEMU

这听上去有点疯狂, 但仔细想想也没什么不可以的, 毕竟NEMU本身也是一个程序. am-kernels/kernels/nemu/目录下已经准备了一个Makefile, 你可以在这一目录下运行如下命令:

make ARCH=$ISA-nemu mainargs=/home/user/ics2022/am-kernels/kernels/hello/build/hello-$ISA-nemu.bin

它会进行如下的工作:

  1. 保存NEMU当前的配置选项
  2. 加载一个新的配置文件, 将NEMU编译到AM上, 并把mainargs指示bin文件作为这个NEMU的镜像文件
  3. 恢复第1步中保存的配置选项
  4. 重新编译NEMU, 并把第2步中的NEMU作为镜像文件来运行

第2步把NEMU编译到AM时, 配置系统会定义宏CONFIG_TARGET_AM, 此时NEMU的行为和之前相比有所变化:

  • sdb, DiffTest等调试功能不再开启, 因为AM无法提供它所需要的库函数(如文件读写, 动态链接, 正则表达式等)
  • 通过AM IOE来实现NEMU的设备

思考一下, 如果在内层NEMU(即编译到AM的NEMU)上运行打字游戏:

make ARCH=$ISA-nemu mainargs=打字游戏的路径

此时的打字游戏又是经历了怎么样的过程才能读取按键/刷新屏幕的呢?

事实上, 我们已经实现了一个冯诺依曼计算机系统! 你已经在导论课上学习到, 冯诺依曼计算机系统由5个部件组成: 运算器, 控制器, 存储器, 输入设备和输出设备. 何况这些咋听之下让人云里雾里的名词, 现在都已经跃然"码"上: 你已经在NEMU中把它们都实现了! 再回过头来审视这一既简单又复杂的计算机系统: 说它简单, 它只不过在TRM的基础上添加了IOE, 本质上还是"取指->译码->执行"的工作方式, 甚至只要具备一些数字电路的知识就可以理解构建计算机的可能性; 说它复杂, 它却已经足够强大来支撑这么多酷炫的程序, 实在是让人激动不已啊! 那些看似简单但又可以折射出无限可能的事物, 其中承载的美妙规律容易使人们为之陶醉, 为之折服. 计算机, 就是其中之一.

必答题

你需要在实验报告中用自己的语言, 尽可能详细地回答下列问题.

  • 程序是个状态机 理解YEMU的执行过程, 具体请参考这里.
  • RTFSC 请整理一条指令在NEMU中的执行过程, 具体请参考这里.
  • 程序如何运行 理解打字小游戏如何运行, 具体请参考这里.
  • 编译与链接nemu/include/cpu/ifetch.h中, 你会看到由static inline开头定义的inst_fetch()函数. 分别尝试去掉static, 去掉inline或去掉两者, 然后重新进行编译, 你可能会看到发生错误. 请分别解释为什么这些错误会发生/不发生? 你有办法证明你的想法吗?
  • 编译与链接
    1. nemu/include/common.h中添加一行volatile static int dummy; 然后重新编译NEMU. 请问重新编译后的NEMU含有多少个dummy变量的实体? 你是如何得到这个结果的?
    2. 添加上题中的代码后, 再在nemu/include/debug.h中添加一行volatile static int dummy; 然后重新编译NEMU. 请问此时的NEMU含有多少个dummy变量的实体? 与上题中dummy变量实体数目进行比较, 并解释本题的结果.
    3. 修改添加的代码, 为两处dummy变量进行初始化:volatile static int dummy = 0; 然后重新编译NEMU. 你发现了什么问题? 为什么之前没有出现这样的问题? (回答完本题后可以删除添加的代码.)
  • 了解Makefile 请描述你在am-kernels/kernels/hello/目录下敲入make ARCH=$ISA-nemu 后, make程序如何组织.c和.h文件, 最终生成可执行文件am-kernels/kernels/hello/build/hello-$ISA-nemu.elf. (这个问题包括两个方面:Makefile的工作方式和编译链接的过程.) 关于Makefile工作方式的提示:
    • Makefile中使用了变量, 包含文件等特性
    • Makefile运用并重写了一些implicit rules
    • man make中搜索-n选项, 也许会对你有帮助
    • RTFM
温馨提示

PA2到此结束. 请你编写好实验报告(不要忘记在实验报告中回答必答题), 然后把命名为学号.pdf的实验报告文件放置在工程目录下, 执行make submit将工程提交到指定网站.

results matching ""

    No results matching ""