输入输出

我们已经成功运行了cputest中的各个测试用例, 但这些测试用例都只能默默地进行纯粹的计算. 回想起我们在程序设计课上写的第一个程序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)应运而生.

内存映射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中的物理地址区间[0xa0000000, 0xa1000000). 这段物理地址区间被映射到VGA内部的显存, 读写这段物理地址区间就相当于对读写VGA显存的数据. 例如

memset((void *)0xa0000000, 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可能会带来什么问题?

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_[l|w|b]()pio_write_[l|w|b]()是面向CPU的端口I/O读写接口, 它们最终会调用map_read()map_write(), 对通过add_pio_map()注册的I/O空间进行访问.

内存映射I/O的模拟是类似的, 只是内存映射I/O的读写并不是面向CPU的, 这一点会在下文进行说明.

NEMU甚至把内存本身也看成一种映射:

  • nemu/src/memory/memory.c中定义的pmem_map结构体, 把模拟内存的大数组pmem作为目标空间
  • paddr_read()paddr_write()最终也是通过map_read()map_write()来实现的

于是这样就自动实现了MMIO: 如果物理地址属于某个设备, map_read()map_write()将会访问相应的设备, 否则将会访问内存. 从这个角度来看, 内存和外设在CPU来看并没有什么不同, 只不过都是一个字节编址的对象而已.

设备

NEMU实现了串口, 时钟, 键盘, VGA四种设备. 为了简化实现, 这些设备都是不可编程的, 而且只实现了在NEMU中用到的功能.

NEMU使用SDL库来实现设备的模拟, nemu/src/device/device.c含有和SDL库相关的代码. init_device()函数首先对以上四个设备进行初始化, 其中在初始化VGA时还会进行一些和SDL相关的初始化工作, 包括创建窗口, 设置显示模式等. 最后还会注册一个100Hz的定时器, 每隔0.01秒就会调用一次device_update()函数. device_update()函数主要检测是否有按键按下/释放, 以及是否点击了窗口的X按钮. 需要说明的是, 代码中注册的定时器是虚拟定时器, 它只会在NEMU处于用户态的时候进行计时: 如果NEMU在ui_mainloop()中等待用户输入, 定时器将不会计时; 如果NEMU进行大量的输出, 定时器的计时将会变得缓慢. 因此除非你在进行调试, 否则尽量避免大量输出的情况, 从而影响定时器的工作.

我们提供的代码是模块化的, 要在NEMU中加入设备的功能, 你只需要在nemu/include/common.h中定义宏HAS_IOE. 定义后, init_device()函数会对设备进行初始化. 重新编译后, 你会看到运行NEMU时会弹出一个新窗口, 用于显示VGA的输出(见下文). 需要注意的是, 终端显示的提示符(nemu)仍然在等待用户输入, 此时窗口并未显示任何内容.

将输入输出抽象成IOE

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

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

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

size_t _io_read(uint32_t dev, uintptr_t reg, void *buf, size_t size);
size_t _io_write(uint32_t dev, uintptr_t reg, void *buf, size_t size);
int _ioe_init();

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

nexus-am/am/amdev.h中定义了常见设备的ID:

#define _DEV_INPUT   0x0000ac02 // AM Virtual Input Device
#define _DEV_TIMER   0x0000ac03 // AM Virtual Timer
#define _DEV_VIDEO   0x0000ac04 // AM Virtual Video Controller

头文件中还定义了其它设备的ID, 但在PA中暂不使用.

头文件中还定义了这些常见设备的抽象寄存器编号. 这些定义是架构无关的, 每个架构在实现各自的IOE API时, 都需要遵循这些定义(约定). 这样, 我们就可以基于这套IOE的API, 来实现一些常用的输入输出功能了:

// 返回系统启动后经过的毫秒数
uint32_t uptime();
// 在`rtc`结构中返回当前时间, PA中不会用到
void get_timeofday(void *rtc);
// 返回按键的键盘码, 若无按键, 则返回`_KEY_NONE`
int read_key();
// 将`pixels`指定的矩形像素绘制到屏幕中以`(x, y)`和`(x+w, y+h)`两点连线为对角线的矩形区域
void draw_rect(uint32_t *pixels, int x, int y, int w, int h);
// 将之前的绘制内容同步到屏幕上
void draw_sync();
// 返回屏幕的宽度
int screen_width();
// 返回屏幕的高度
int screen_height();

经过了IOE的抽象, 上述功能的实现都可以是架构无关的, 因此可以将它们归入klib中(在nexum-am/libs/klib/src/io.c中定义), 供其它程序使用.

特别地, NEMU作为一个平台, 设备的行为是与ISA无关的, 因此我们只需要在nexus-am/am/src/nemu-common/目录下实现一份IOE, 来供NEMU平台的架构共享.

AM测试和运行时环境

在介绍设备功能之前, 我们先介绍一些和测试程序框架相关的内容. 在AM中, main()函数允许带有一个字符串参数, 这一参数通过mainargs指定, 并由AM的运行时环境负责将它传给main()函数, 供AM程序使用.

具体的参数传递方式和架构相关, 例如在NEMU中, 这一字符串通过命令行参数传入, 经由parse_args()的解析, 最后放在地址为00x80000000的内存位置. 在nexus-am/am/src/nemu-common/trm.c中, _trm_init()会把上述地址作为参数来调用main(), 这样AM程序就可以对其中的字符串进行参数的解析了.

Bug修复

旧的Args Rom设备存在与DiffTest不兼容的缺陷, 我们在NEMU中去掉了Args Rom, 通过上文的方式来支持mainargs机制. 如果你在2019/10/02 10:00:00之前获取框架代码, 请根据这个页面这个页面更新相关文件.

我们在AM项目中准备了一组小型测试程序amtest, 专门用于测试AM相关的功能. 它的main()函数接受从AM的运行时环境传递过来的一个参数, 用于进行测试程序的选择. 我们可以先在native上感受一下在AM中如何给main()函数传递参数: 在nexus-am/tests/amtest/目录下键入

make ARCH=native mainargs=H run

可以输出amtest中可选的测试程序. 更换不同的mainargs可以运行不同的测试.

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

阅读native的代码, 尝试理解在make命令中指定的mainargs 是如何传递给main函数的.

下面我们来逐一介绍NEMU中每个设备的功能.

串口

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

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

nexus-am/am/src/nemu-common/trm.c中已经提供了串口的功能. 如果你选择了x86, 为了让程序使用串口进行输出, 你还需要在NEMU中实现端口映射I/O.

运行Hello World

如果你选择了x86, 你需要实现in, out指令, 在它们的执行辅助函数中分别调用 pio_read_[l|w|b]()pio_write_[l|w|b]()函数. 如果你选择的是mips32和riscv32, 你不需要实现额外的代码, 因为NEMU的映射机制已经支持MMIO了.

实现后, 在nexus-am/tests/amtest/目录下键入

make mainargs=h run

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

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

设备和DiffTest

由于NEMU中设备的行为是我们自定义的, 与QEMU中的标准设备的行为不完全一样 (例如NEMU中的串口总是就绪的, 但QEMU中的串口也许并不是这样), 这导致在NEMU中访问设备的结果与QEMU可能会存在不可调整的偏差. 为了使得DiffTest可以正常工作, 框架代码在访问设备的过程中调用了difftest_skip_ref()函数 (见nemu/include/device/map.h中定义的find_mapid_by_addr()函数)来跳过与QEMU的检查.

实现printf

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

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

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

时钟

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

nexus-am/am/amdev.h中为时钟定义了两个抽象寄存器:

  • _DEVREG_TIMER_UPTIME, AM系统启动时间. 从中读出_DEV_TIMER_UPTIME_t结构体, (hi << 32LL) | lo是系统启动的毫秒数.
  • _DEVREG_TIMER_DATE, AM实时时钟(RTC). 从中读出_DEV_TIMER_DATE_t结构体, 包含年月日时分秒. PA中暂不使用.
实现IOE

nexus-am/am/src/nemu-common/nemu-timer.c中实现_DEVREG_TIMER_UPTIME的功能. 在nexus-am/am/include/nemu.hnexus-am/am/include/$ISA.h 中有一些输入输出相关的代码供你使用.

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

看看NEMU跑多快

有了时钟之后, 我们就可以测试一个程序跑多快, 从而测试计算机的性能. 尝试在NEMU中依次运行以下benchmark(已经按照程序的复杂度排序, 均在nexus-am/apps/目录下; 另外跑分时请注释掉nemu/include/common.h中的DEBUGDIFF_TEST宏, 以获得较为真实的跑分):

  • dhrystone
  • coremark
  • microbench

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

另外, microbench提供了三个不同规模的测试集, 包括test, trainref. 其中ref测试集规模较大, 计时计分, 用于跑分测试, 默认会运行ref测试集; test测试集规模较小, 不计时不计分, 用于正确性测试; train测试集规模中等, 计时不计分, 专门用于处理器设计的仿真测试; 若要运行test测试集或train测试集, 需要通过mainargs指定:

make run mainargs=test
先完成, 后完美 - 抑制住优化代码的冲动

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

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

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

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

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

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

键盘

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

神秘的MAP宏

框架代码在nemu/include/macro.h中定义了一个MAP宏, 并在nemu/src/device/kerboard.c中使用了它. 你能明白它是如何工作的吗?

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

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

nexus-am/am/amdev.h中为键盘定义了一个抽象寄存器:

  • _DEVREG_INPUT_KBD, AM键盘控制器. 从中读出_DEV_INPUT_KBD_t结构体, keydown = 1为按下按键, keydown = 0为释放按键. keycode为按键的断码, 没有按键时, keycode_KEY_NONE.
实现IOE(2)

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

VGA

VGA可以用于显示颜色像素, 是最常用的输出设备. nemu/src/device/vga.c模拟了VGA的功能. VGA初始化时注册了从0xa0000000开始的一段用于映射到video memory的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年代的游戏中, 很多渐出渐入效果都是通过调色板实现的, 聪明的你知道其中的玄机吗?

nexus-am/am/amdev.h中为VGA定义了两个抽象寄存器:

  • _DEVREG_VIDEO_INFO, AM显示控制器信息. 从中读出_DEV_VIDEO_INFO_t结构体, 其中width为屏幕宽度, height为屏幕高度. 另外假设AM运行过程中, 屏幕大小不会发生变化.
  • _DEVREG_VIDEO_FBCTL, AM帧缓冲控制器. 向其写入_DEV_VIDEO_FBCTL_t结构体, 向屏幕(x, y)坐标处绘制w*h的矩形图像. 图像像素按行优先方式存储在pixels中, 每个像素用32位整数以00RRGGBB的方式描述颜色.
实现IOE(3)

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

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

--- nexus-am/am/src/nemu-common/nemu-video.c
+++ nexus-am/am/src/nemu-common/nemu-video.c
@@ -31,2 +31,7 @@
 void __am_vga_init() {
+  int i;
+  int size = screen_width() * screen_height();
+  uint32_t *fb = (uint32_t *)(uintptr_t)FB_ADDR;
+  for (i = 0; i < size; i ++) fb[i] = i;
+  draw_sync();
 }

然后在$ISA-nemu中运行amtest中的display test测试. 如果你的实现正确, 你会看到新窗口中输出了全屏的颜色信息.

实现IOE(4)

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

可展示的计算机系统

展示你的计算机系统

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

  • 幻灯片播放(在nexus-am/apps/slider/目录下). 程序将每隔5秒切换images/目录下的图片.
  • 打字小游戏(在nexus-am/apps/typing/目录下). 打字小游戏来源于2013年NJUCS oslab0的框架代码. 为了配合移植, 代码的结构做了少量调整, 同时对屏幕更新进行了优化, 并去掉了浮点数.

typing

有兴趣折腾的同学可以尝试在NEMU中运行LiteNES(在nexus-am/apps/litenes/目录下). 没错, 我们在PA1给大家介绍的红白机模拟器, 现在也已经可以在NEMU中运行起来了!

体会到AM的好处了吧?

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

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

LiteNES如何工作?

阅读nexus-am/apps/litenes/src/cpu.c的代码, 你有什么新的发现?

优化LiteNES

我们在LiteNES中设置了跳帧模式, 在编译到native之外的平台时, 会默认开启跳帧模式, 只渲染1/3的帧. 虽然在native上可以跑满60FPS, 但即使开启跳帧模式, 在NEMU中也只能跑几FPS.

NEMU的性能确实不高, 但LiteNES也是半斤八两, 代码还有非常多可以优化的空间. 如果你将来打算在自己设计的CPU上运行LiteNES, 那么来对LiteNES进行优化也不会吃亏.

我们手上有一个经过暴力优化的LiteNES, 它在microbench跑分为236分的x86-nemu上也可以跑满60 FPS, 在microbench跑分为400分的riscv32-nemu甚至可以跑到100 FPS! 有了这个暴力优化版本, 在FPGA上就可以流畅地运行超级玛丽了.

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

我们提供6502处理器(NES的CPU)以及NES PPU(图形处理器)的资料供参考. 如果你已经完成了必做的内容而又闲来无事, 可以尝试来优化LiteNES的代码, 并和小伙伴PK优化后的性能吧! 不过, 你打算怎么来进行优化呢?

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

万变之宗

如果你看到了这里, 相信你也知道计算机是怎么运行的了. 如果你去阅读上文提供的NES CPU资料, 你就会发现很多概念是多么地熟悉; 如果你去RTFSC, 你就会发现有很多代码都和NEMU有着千丝万缕的联系.

这是因为, LiteNES和NEMU一样, 都是一个完整的计算机系统.

必答题

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

  • RTFSC 请整理一条指令在NEMU中的执行过程. (我们其实已经在PA2.1阶段提到过这道题了)
  • 编译与链接nemu/include/rtl/rtl.h中, 你会看到由static inline开头定义的各种RTL指令函数. 选择其中一个函数, 分别尝试去掉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 请描述你在nemu/目录下敲入make 后, make程序如何组织.c和.h文件, 最终生成可执行文件nemu/build/$ISA-nemu. (这个问题包括两个方面:Makefile的工作方式和编译链接的过程.) 关于Makefile工作方式的提示:
    • Makefile中使用了变量, 包含文件等特性
    • Makefile运用并重写了一些implicit rules
    • man make中搜索-n选项, 也许会对你有帮助
    • RTFM
温馨提示

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

results matching ""

    No results matching ""