输入输出
我们已经成功运行了各个cputest
中的测试用例, 但这些测试用例都只能默默地进行纯粹的计算.
回想起我们在程序设计课上写的第一个程序hello
, 至少也输出了一句话.
事实上, 输入输出是计算机与外界交互的基本手段,
如果你还记得计算机刚启动时执行的BIOS程序的全称是Basic Input/Output System,
你就会理解输入输出对计算机来说是多么重要了.
在真实的计算机中, 输入输出都是通过访问I/O设备来完成的.
设备的工作原理其实没什么神秘的. 你会在不久的将来在数字电路实验中看到键盘控制器模块和VGA控制器模块相关的verilog代码. 噢, 原来这些设备也一样是个数字电路! 事实上, 只要向设备发送一些有意义的数字信号, 设备就会按照这些信号的含义来工作. 让一些信号来指导设备如何工作, 这不就像"程序的指令指导CPU如何工作"一样吗? 恰恰就是这样! 设备也有自己的状态寄存器(相当于CPU的寄存器), 也有自己的功能部件(相当于CPU的运算器). 当然不同的设备有不同的功能部件, 例如键盘有一个把按键的模拟信号转换成扫描码的部件, 而VGA则有一个把像素颜色信息转换成显示器模拟信号的部件. 这些控制设备工作的信号称为"命令字", 可以理解成"设备的指令", 设备的工作就是负责接收命令字, 并进行译码和执行... 你已经知道CPU的工作方式, 这一切对你来说都太熟悉了.
既然设备是用来进行输入输出的, 所谓的访问设备, 说白了就是从设备获取数据(输入), 比如从键盘控制器获取按键扫描码, 或者是向设备发送数据(输出), 比如向显存写入图像的颜色信息. 但是, 如果万一用户没有敲键盘, 或者是用户想调整屏幕的分辨率, 怎么办呢? 这说明, 除了纯粹的数据读写之外, 我们还需要对设备进行控制: 比如需要获取键盘控制器的状态, 查看当前是否有按键被按下; 或者是需要有方式可以查询或设置VGA控制器的分辨率. 所以, 在程序看来, 访问设备 = 读出数据 + 写入数据 + 控制状态.
我们希望计算机能够控制设备, 让设备做我们想要做的事情, 这一重任毫无悬念地落到了CPU身上. CPU除了进行计算之外, 还需要访问设备, 与其协作来完成不同的任务. 那么在CPU看来, 这些行为究竟意味着什么呢? 具体要从哪里读数据? 把数据写入到哪里? 如何查询/设置设备的状态? 一个最本质的问题是, CPU和设备之间的接口, 究竟是什么?
答案也许比你想象中的简单很多: 既然设备也有寄存器, 一种最简单的方法就是把设备的寄存器作为接口, 让CPU来访问这些寄存器. 比如CPU可以从/往设备的数据寄存器中读出/写入数据, 进行数据的输入输出; 可以从设备的状态寄存器中读出设备的状态, 询问设备是否忙碌; 或者往设备的命令寄存器中写入命令字, 来修改设备的状态.
那么, CPU要如何访问设备寄存器呢?
我们先来回顾一下CPU是如何访问CPU自己的寄存器的:
首先给这些寄存器编个号, 比如eax
是0
, ecx
是1
...
然后在指令中引用这些编号, 电路上会有相应的选择器来对相应的寄存器进行读写.
对设备寄存器的访问也是类似的:
我们也可以给设备中允许CPU访问的寄存器逐一编号, 然后通过指令来引用这些编号.
设备中可能会有一些私有寄存器, 它们是由设备自己维护的,
它们没有这样的编号, CPU不能直接访问它们.
这就是所谓的I/O编址方式, 因此这些编号也称为设备的地址. 常用的编址方式有两种.
端口I/O
一种I/O编址方式是端口映射I/O(port-mapped I/O), CPU使用专门的I/O指令对设备进行访问, 并把设备的地址称作端口号. 有了端口号以后, 在I/O指令中给出端口号, 就知道要访问哪一个设备寄存器了. 市场上的计算机绝大多数都是IBM PC兼容机, IBM PC兼容机对常见设备端口号的分配有专门的规定.
x86提供了in
和out
指令用于访问设备,
其中in
指令用于将设备寄存器中的数据传输到CPU寄存器中,
out
指令用于将CPU寄存器中的数据传送到设备寄存器中.
一个例子是nexus-am/am/arch/x86-nemu/src/trm.c
中_putc()
的代码,
代码使用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来访问.
内存映射I/O的一个例子是NEMU中的物理地址区间[0x40000, 0x80000)
.
这段物理地址区间被映射到VGA内部的显存, 读写这段物理地址区间就相当于对读写VGA显存的数据.
例如
memset((void *)0x40000, 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和内存映射I/O两种I/O编址方式
- 串口, 时钟, 键盘, VGA四种设备
为了简化实现, 所有设备都是不可编程的, 只实现了在NEMU中用到的功能. 我们对代码稍作解释.
nemu/src/device/io/port-io.c
是对端口I/O的模拟. 其中PIO_t
结构用于记录一个端口I/O映射的关系, 设备会初始化时会调用add_pio_map()
函数来注册一个端口I/O映射关系, 返回该映射关系的I/O空间首地址.pio_read_[l|w|b]()
和pio_write_[l|w|b]()
是面向CPU的端口I/O读写接口. 由于NEMU是单线程程序, 因此只能串行模拟整个计算机系统的工作, 每次进行I/O读写的时候, 才会调用设备提供的回调函数(callback), 更新设备的状态. 内存映射I/O的模拟和端口I/O的模拟比较相似, 只是内存映射I/O的读写并不是面向CPU的, 这一点会在下文进行说明.nemu/src/device/device.c
含有和SDL库相关的代码, NEMU使用SDL库来实现设备的模拟.init_device()
函数首先对以上四个设备进行初始化, 其中在初始化VGA时还会进行一些和SDL相关的初始化工作, 包括创建窗口, 设置显示模式等. 最后还会注册一个100Hz的定时器, 每隔0.01秒就会调用一次device_update()
函数.device_update()
函数主要进行一些设备的模拟操作, 包括以50Hz的频率刷新屏幕, 以及检测是否有按键按下/释放. 需要说明的是, 代码中注册的定时器是虚拟定时器, 它只会在NEMU处于用户态的时候进行计时: 如果NEMU在ui_mainloop()
中等待用户输入, 定时器将不会计时; 如果NEMU进行大量的输出, 定时器的计时将会变得缓慢. 因此除非你在进行调试, 否则尽量避免大量输出的情况, 从而影响定时器的工作.
我们提供的代码是模块化的, 要在NEMU中加入设备的功能,
你只需要在nemu/include/common.h
中定义宏HAS_IOE
.
定义后, init_device()
函数会对设备进行初始化.
重新编译后, 你会看到运行NEMU时会弹出一个新窗口, 用于显示VGA的输出(见下文).
需要注意的是, 终端显示的提示符(nemu)
仍然在等待用户输入, 此时窗口并未显示任何内容.
将设备访问抽象成IOE
设备访问的具体实现是机器相关的, 比如NEMU的VGA显存位于物理地址区间[0x40000, 0x80000)
,
但对native
的程序来说, 这是一个不可访问的非法区间,
因此native
程序需要通过别的方式来实现类似的功能.
自然地, 设备访问这一机器相关的功能, 应该归入AM中.
与TRM不同, 设备访问是为机器提供输入输出的功能,
因此我们把它们划入一类新的API, 名字叫IOE(I/O Extension).
要如何对不同机器的设备访问抽象成统一的API呢? 回想一下在程序看来, 访问设备其实想做什么: 访问设备 = 读出数据 + 写入数据 + 控制状态. 进一步的, 控制状态本质上也是读/写设备寄存器的操作, 所以访问设备 = 读/写操作.
对, 就是这么简单! 所以IOE为每个设备定义了如下的数据结构(见nexus-am/am/am.h
):
typedef struct _Device {
uint32_t id;
const char *name;
size_t (*read) (uintptr_t reg, void *buf, size_t size);
size_t (*write)(uintptr_t reg, void *buf, size_t size);
} _Device;
其中id
是设备的唯一ID, name
是设备的名字(非必须),
read()/write()
分别是设备读/写操作的实现,
用于从设备的reg
寄存器中读出size
字节的内容到缓冲区buf
中,
或者往设备的reg
寄存器中写入缓冲区buf
中的size
字节的内容.
需要注意的是, 这里的reg
寄存器并不是上文讨论的设备寄存器,
因为设备寄存器的编号是机器相关的.
在IOE中, 我们希望采用一种机器无关的"抽象寄存器",
这个reg
其实是一个功能编号, 我们约定在不同的机器中,
同一个功能编号的含义也是相同的, 这样就实现了设备寄存器的抽象.
另一个API是
_Device *_device(int n);
用于返回编号为n
的设备的数据结构.
机器中的可用设备从1
开始按顺序编号, 若不存在编号为n
的设备, 则返回NULL
.
有了这个API, 程序就可以枚举机器中的每一个设备了.
最后一个API是_ioe_init()
, 它用于进行IOE相关的初始化操作.
nexus-am/am/arch/x86-nemu/src/ioe.c
中已经实现了时钟, 键盘和VGA的设备结构体,
但它们的读写函数(在nexus-am/am/arch/x86-nemu/src/devices/
目录下定义)并未实现.
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中每个设备的功能.
串口
串口是最简单的输出设备.
nemu/src/device/serial.c
模拟了串口的功能.
其大部分功能也被简化, 只保留了数据寄存器和状态寄存器.
串口初始化时会分别注册0x3F8
和0x3FD
处长度为1个字节的端口, 分别作为数据寄存器和状态寄存器.
由于NEMU串行模拟计算机系统的工作, 串口的状态寄存器可以一直处于空闲状态;
每当CPU往数据寄存器中写入数据时, 串口会将数据传送到主机的标准输出.
事实上, 在x86-nemu
中, 我们之前提到的_putc()
函数, 就是通过串口输出的.
然而AM却把_putc()
放在TRM, 而不是IOE中, 这让人觉得有点奇怪.
的确, 可计算理论中提出的最原始的TRM并不包含输出的能力,
但对于一个现实的计算机系统来说, 输出是一个最基本的功能,
没有输出, 用户甚至无法知道程序具体在做什么.
因此在AM中, _putc()
的加入让TRM具有输出字符的能力,
被扩充后的TRM更靠近一个实用的机器, 而不再是只会计算的数学模型.
nexus-am/am/arch/x86-nemu/src/trm.c
中已经提供了串口的功能.
为了让程序使用串口进行输出, 你还需要在NEMU中实现端口映射I/O.
运行Hello World
实现in
, out
指令, 在它们的helper函数中分别调用
pio_read_[l|w|b]()
和pio_write_[l|w|b]()
函数.
实现后, 在nexus-am/apps/hello/
目录下键入
make run
在x86-nemu
中运行基于AM的hello程序. 如果你的实现正确,
你将会看到程序往终端输出了10行Hello World!
(请注意不要让输出埋没在调试信息中).
需要注意的是, 这个hello程序和我们在程序设计课上写的第一个hello程序所处的抽象层次是不一样的: 这个hello程序可以说是直接运行在裸机上, 可以在AM的抽象之上直接输出到设备(串口); 而我们在程序设计课上写的hello程序位于操作系统之上, 不能直接操作设备, 只能通过操作系统提供的服务进行输出, 输出的数据要经过很多层抽象才能到达设备层. 我们会在PA3中进一步体会操作系统的作用.
另外, 由于NEMU中有一些设备的行为是我们自定义的, 与QEMU中的标准设备的行为不完全一样
(例如NEMU中的串口总是就绪的, 但QEMU中的串口也许并不是这样),
这导致在NEMU中执行in
和out
指令的结果与QEMU可能会存在不可调整的偏差.
为了使得DiffTest可以正常工作, 我们在这两条指令的实现中调用了相应的函数
来设置is_skip_ref
标志, 来跳过与QEMU的检查.
实现printf
有了_putc()
, 我们就可以在klib中实现printf()
了.
你之前已经实现了sprintf()
了, 它和printf()
的功能非常相似,
这意味着它们之间会有不少重复的代码.
你已经见识到Copy-Paste编程习惯的坏处了, 思考一下, 如何简洁地实现它们呢?
时钟
有了时钟, 程序才可以提供时间相关的体验, 例如游戏的帧率, 程序的快慢等.
nemu/src/device/timer.c
模拟了i8253计时器的功能.
计时器的大部分功能都被简化, 只保留了"发起时钟中断"的功能(目前我们不会用到).
同时添加了一个自定义的RTC(Real Time Clock), 初始化时将会注册0x48
处的端口作为RTC寄存器,
CPU可以通过I/O指令访问这一寄存器, 获得当前时间(单位是ms).
nexus-am/am/amdev.h
中为时钟定义了两个抽象寄存器:
_DEVREG_TIMER_UPTIME
, AM系统启动时间. 从中读出_UptimeReg
结构体,(hi << 32LL) | lo
是系统启动的毫秒数._DEVREG_TIMER_DATE
, AM实时时钟(RTC). 从中读出_DateReg
结构体, 包含年月日时分秒. PA中暂不使用.
实现IOE
在nexus-am/am/arch/x86-nemu/src/devices/timer.c
中实现_DEVREG_TIMER_UPTIME
的功能.
实现后, 在x86-nemu
中运行timetest
程序(在nexus-am/tests/timetest/
目录下,
编译和运行方式请参考上文, 此后不再额外说明).
如果你的实现正确, 你将会看到程序每隔1秒往终端输出一句话.
由于没有实现_DEVREG_TIMER_DATE
, 测试总是输出2018年0月0日0时0分0秒,
这属于正常行为, 可以忽略.
看看NEMU跑多快
有了时钟之后, 我们就可以测试一个程序跑多快, 从而测试计算机的性能.
尝试在NEMU中依次运行以下benchmark(已经按照程序的复杂度排序, 均在nexus-am/apps/
目录下;
另外跑分时请注释掉nemu/include/common.h
中的DEBUG
和DIFF_TEST
宏, 以获得较为真实的跑分):
- dhrystone
- coremark
- microbench
成功运行后会输出跑分. 跑分以i7-6700 @ 3.40GHz
的处理器为参照,
100000
分表示与参照机器性能相当, 100
分表示性能为参照机器的千分之一.
除了和参照机器比较之外, 也可以和小伙伴进行比较.
如果把上述benchmark编译到native
, 还可以比较native
的性能.
另外, microbench提供了两个不同规模的测试集test
和ref
.
其中ref
测试集规模较大, 用于跑分测试, 默认会编译ref
测试集;
test
测试集规模较小, 用于正确性测试, 需要在运行make
时显式指定编译test
测试集:
make INPUT=TEST
先完成, 后完美 - 抑制住优化代码的冲动
计算机系统的设计过程可以概括成两件事:
- 设计一个功能正确的完整系统 (先完成)
- 在第1点的基础上, 让程序运行得更快 (后完美)
看到跑分之后, 你也许会忍不住去思考如何优化你的NEMU. 上述原则告诉你, 时机还没到. 一个原因是, 在整个系统完成之前, 你很难判断系统的性能瓶颈会出现在哪一个模块中. 你一开始辛辛苦苦追求的完美, 最后对整个系统的性能提升也许只是九牛一毛, 根本不值得你花费这么多时间. 比如你可能在PA1中花时间去优化表达式求值的算法, 你可以以此作为一个编程练习, 但如果你的初衷是为了优化性能, 你的付出绝对是没有效果的: 你得输入多长的表达式才能让你明显感觉到新算法的性能优势?
此外, PA作为一个教学实验, 只要性能不是差得无法接受, 性能都不是你需要考虑的首要目标, 实现方案点到为止即可. 相比之下, 通过设计一个完整的系统来体会程序如何运行, 对你来说才是最重要的.
事实上, 除了计算机, "先完成, 后完美"的原则也适用于很多领域. 比如企业方案策划, 大家可以在一个完整但哪怕很简单的方案上迭代; 但如果一开始就想着把每一个点都做到完美, 最后很可能连一份完整的方案也拿不出手. 论文写作也一样, 哪怕是只有完整的小标题, 大家都可以去检查文章的整体框架有无逻辑漏洞; 相反, 就算文章配有再漂亮的实验数据, 在有漏洞的逻辑面前也无法自圆其说.
随着你参与越来越大的项目, 你会发现让完整的系统正确地跑起来, 会变得越来越难. 这时候, 遵循"先完成, 后完美"的原则就显得更重要了: 很多问题也许会等到项目趋于完整的时候才会暴露出来, 舍弃全局的完整而换来局部的完美, 大多时候只会南辕北辙.
键盘
键盘是最基本的输入设备.
一般键盘的工作方式如下: 当按下一个键的时候, 键盘将会发送该键的通码(make code);
当释放一个键的时候, 键盘将会发送该键的断码(break code).
nemu/src/device/keyboard.c
模拟了i8042通用设备接口芯片的功能.
其大部分功能也被简化, 只保留了键盘接口.
i8042初始化时会注册0x60
处的端口作为数据寄存器.
每当用户敲下/释放按键时, 将会把相应的键盘码放入数据寄存器,
CPU可以通过端口I/O访问数据寄存器, 获得键盘码; 当无按键可获取时, 将会返回_KEY_NONE
.
在AM中, 我们约定通码的值为断码 | 0x8000
.
如何检测多个键同时被按下?
在游戏中, 很多时候需要判断玩家是否同时按下了多个键, 例如RPG游戏中的八方向行走, 格斗游戏中的组合招式等等. 根据键盘码的特性, 你知道这些功能是如何实现的吗?
nexus-am/am/amdev.h
中为键盘定义了一个抽象寄存器:
_DEVREG_INPUT_KBD
, AM键盘控制器. 从中读出_KbdReg
结构体,keydown = 1
为按下按键,keydown = 0
为释放按键.keycode
为按键的断码, 没有按键时,keycode
为_KEY_NONE
.
实现IOE(2)
在nexus-am/am/arch/x86-nemu/src/devices/input.c
中实现_DEVREG_INPUT_KBD
的功能.
实现后, 在x86-nemu
中运行keytest
程序(在nexus-am/tests/keytest/
目录下).
如果你的实现正确, 在程序运行时弹出的新窗口中按下按键,
你将会看到程序输出相应的按键信息, 包括按键名, 键盘码, 以及按键状态.
VGA
VGA可以用于显示颜色像素, 是最常用的输出设备.
nemu/src/device/vga.c
模拟了VGA的功能.
VGA初始化时注册了从0x40000
开始的一段用于映射到video memory的物理内存.
在NEMU中, video memory是唯一使用内存映射I/O方式访问的I/O空间.
代码只模拟了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显示控制器信息. 从中读出_VideoInfoReg
结构体, 其中width
为屏幕宽度,height
为屏幕高度. 另外假设AM运行过程中, 屏幕大小不会发生变化._DEVREG_VIDEO_FBCTL
, AM帧缓冲控制器. 向其写入_FBCtlReg
结构体, 向屏幕(x, y)
坐标处绘制w*h
的矩形图像. 图像像素按行优先方式存储在pixels
中, 每个像素用32位整数以00RRGGBB
的方式描述颜色.
实现IOE(3)
我们在讲义中并未介绍NEMU中的VGA设备如何将屏幕大小的信息暴露给CPU,
但框架代码中已经实现了相应的功能, 你需要RTFSC,
然后在nexus-am/am/arch/x86-nemu/src/devices/video.c
中实现_DEVREG_VIDEO_INFO
的功能.
这一任务可以与下文的"添加内存映射I/O"任务一同测试.
添加内存映射I/O
在NEMU中的paddr_read()
和paddr_write()
中加入对内存映射I/O的判断.
通过is_mmio()
函数判断一个物理地址是否被映射到I/O空间,
如果是, is_mmio()
会返回映射号, 否则返回-1
.
内存映射I/O的访问需要调用mmio_read()
或mmio_write()
, 调用时需要提供映射号.
如果不是内存映射I/O的访问, 就访问pmem
.
实现后, _DEVREG_VIDEO_FBCTL
中添加如下测试代码:
--- nexus-am/am/arch/x86-nemu/src/devices/video.c
+++ nexus-am/am/arch/x86-nemu/src/devices/video.c
@@ -20,6 +20,9 @@
size_t video_write(uintptr_t reg, void *buf, size_t size) {
switch (reg) {
case _DEVREG_VIDEO_FBCTL: {
_FBCtlReg *ctl = (_FBCtlReg *)buf;
+ int i;
+ int size = screen_width() * screen_height();
+ for (i = 0; i < size; i ++) fb[i] = i;
if (ctl->sync) {
然后在x86-nemu
中运行videotest
程序(在nexus-am/tests/videotest/
目录下).
如果_DEVREG_VIDEO_INFO
和内存映射I/O均实现正确, 你会看到新窗口中输出了全屏的颜色信息.
实现IOE(4)
事实上, 刚才输出的颜色信息并不是videotest
输出的画面,
这是因为上述测试代码并未正确实现_DEVREG_VIDEO_FBCTL
的功能.
你需要正确地实现_DEVREG_VIDEO_FBCTL
的功能.
实现后, 在x86-nemu
中重新运行videotest
.
如果你的实现正确, 你将会看到新窗口中输出了相应的动画效果.
可展示的计算机系统
展示你的计算机系统
完整实现IOE后, 我们就可以运行一些酷炫的程序了:
- 幻灯片播放(在
nexus-am/apps/slider/
目录下). 程序将每隔5秒切换images/
目录下的图片. - 打字小游戏(在
nexus-am/apps/typing/
目录下). 打字小游戏来源于2013年NJUCS oslab0的框架代码. 为了配合移植, 代码的结构做了少量调整, 同时去掉了和显存优化相关的部分, 并去掉了浮点数.
有兴趣折腾的同学可以尝试在NEMU中运行litenes(在nexus-am/apps/litenes/
目录下).
没错, 我们在PA1的开头给大家介绍的红白机模拟器, 现在也已经可以在NEMU中运行起来了!
事实上, 我们已经实现了一个冯诺依曼计算机系统! 你已经在导论课上学习到, 冯诺依曼计算机系统由5个部件组成: 运算器, 控制器, 存储器, 输入设备和输出设备. 何况这些咋听之下让人云里雾里的名词, 现在都已经跃然"码"上: 你已经在NEMU中把它们都实现了! 再回过头来审视这一既简单又复杂的计算机系统: 说它简单, 它只不过在TRM的基础上添加了IOE, 本质上还是"取指->译码->执行"的工作方式, 甚至只要具备一些数字电路的知识就可以理解构建计算机的可能性; 说它复杂, 它却已经足够强大来支撑这么多酷炫的程序, 实在是让人激动不已啊! 那些看似简单但又可以折射出无限可能的事物, 其中承载的美妙规律容易使人们为之陶醉, 为之折服. 计算机, 就是其中之一.
必答题
你需要在实验报告中用自己的语言, 尽可能详细地回答下列问题.
- 编译与链接
在
nemu/include/cpu/rtl.h
中, 你会看到由static inline
开头定义的各种RTL指令函数. 选择其中一个函数, 分别尝试去掉static
, 去掉inline
或去掉两者, 然后重新进行编译, 你可能会看到发生错误. 请分别解释为什么这些错误会发生/不发生? 你有办法证明你的想法吗? - 编译与链接
- 在
nemu/include/common.h
中添加一行volatile static int dummy;
然后重新编译NEMU. 请问重新编译后的NEMU含有多少个dummy
变量的实体? 你是如何得到这个结果的? - 添加上题中的代码后, 再在
nemu/include/debug.h
中添加一行volatile static int dummy;
然后重新编译NEMU. 请问此时的NEMU含有多少个dummy
变量的实体? 与上题中dummy
变量实体数目进行比较, 并解释本题的结果. - 修改添加的代码, 为两处
dummy
变量进行初始化:volatile static int dummy = 0;
然后重新编译NEMU. 你发现了什么问题? 为什么之前没有出现这样的问题? (回答完本题后可以删除添加的代码.)
- 在
- 了解Makefile
请描述你在
nemu/
目录下敲入make
后,make
程序如何组织.c和.h文件, 最终生成可执行文件nemu/build/nemu
. (这个问题包括两个方面:Makefile
的工作方式和编译链接的过程.) 关于Makefile
工作方式的提示:Makefile
中使用了变量, 包含文件等特性Makefile
运用并重写了一些implicit rules- 在
man make
中搜索-n
选项, 也许会对你有帮助 - RTFM
温馨提示
PA2到此结束. 请你编写好实验报告(不要忘记在实验报告中回答必答题),
然后把命名为学号.pdf
的实验报告文件放置在工程目录下,
执行make submit
对工程进行打包, 最后将压缩包提交到指定网站.