加入最后的拼图
设备代码介绍
框架代码中已经提供了设备的代码, 位于 nemu/src/device
目录下. 代码中提供了两种I/O寻址方式, i8259中断控制器和五种设备的模拟. 为了简化实现, 中断控制器和所有设备都是不可编程的, 只实现了在NEMU中用到的功能. 我们对代码稍作解释.
nemu/src/device/io/port-io.c
是对端口I/O的模拟. 其中PIO_t
结构用于记录一个端口I/O映射的关系, 设备会初始化时会调用add_pio_map()
函数来注册一个端口I/O映射关系, 返回该映射关系的I/O空间首地址.pio_read()
和pio_write()
是面向CPU的端口I/O读写接口. 由于NEMU是单线程程序, 因此只能串行模拟整个计算机系统的工作, 每次进行I/O读写的时候, 才会调用设备提供的回调函数(callback), 更新设备的状态. 内存映射I/O的模拟和端口I/O的模拟比较相似, 只是内存映射I/O的读写并不是面向CPU的, 这一点会在下文进行说明.nemu/src/device/i8259.c
是对Intel 8259中断控制器的功能模拟. 代码模拟了两块i8259芯片级联的情况, 从片的INT引脚连接到主片的IRQ2引脚.i8259_raise_intr()
是面向设备的接口, 当设备需要发出硬件中断时, 就会调用i8259_raise_intr()
, 代码会根据当前的中断请求状态选择一个优先级最高的中断, 并生成相应的中断号, 把中断号记录在intr_NO
变量中, 然后把CPU的INTR引脚置为高电平, 通知CPU有硬件中断到来. CPU如果发现有硬件中断到来, 可以通过i8259_query_intr()
查询当前优先级最高的中断号, 并调用i8259_ack_intr()
向中断控制器确认收到中断信息, 中断控制器收到CPU的确认后会更新中断请求的状态, 清除刚才发送给CPU的中断请求. 为了简化, 中断控制器中没有实现中断屏蔽位的功能.nemu/src/device/timer.c
模拟了i8253计时器的功能. 计时器的大部分功能都被简化, 只保留了"发起时钟中断"的功能.nemu/src/device/keyboard.c
模拟了i8042通用设备接口芯片的功能. 其大部分功能也被简化, 只保留了键盘接口. i8042初始化时会注册0x60
处的端口作为数据寄存器, 每当用户敲下/释放按键时, 将会把键盘扫描码放入数据寄存器, 然后发起键盘中断, CPU收到中断后, 可以通过端口I/O访问数据寄存器, 获得键盘扫描码.nemu/src/device/serial.c
模拟了串口的功能. 其大部分功能也被简化, 只保留了数据寄存器和状态寄存器. 串口初始化时会注册0x3F8
处长度为8个字节的端口作为其寄存器, 但代码中只模拟了其中的两个寄存器的功能, 由于NEMU串行模拟计算机系统的工作, 串口的状态寄存器可以一直处于空闲状态; 每当CPU往数据寄存器中写入数据时, 串口会将数据传送到主机的标准输出.nemu/src/device/ide.c
模拟了磁盘的功能. 磁盘初始化时会注册0x1F0
处长度为8个字节的端口作为其寄存器, 并把NEMU运行时传入的测试文件当做虚拟磁盘来使用. 磁盘读写以扇区为单位, 进行读写之前, 磁盘驱动程序需要把读写的扇区号写入磁盘的控制寄存器, 然后往磁盘的命令寄存器中写入读/写命令字. 进行读操作时, 驱动程序可以从磁盘的数据寄存器依次读出512个字节; 进行写操作时, 驱动程序需要向磁盘的数据寄存器依次写入512个字节.nemu/src/device/vga.c
模拟了VGA的功能. VGA初始化时会注册了两个用于更新调色板的端口, 并注册了从0xa0000
开始的一段用于映射到video memory的物理内存. 在NEMU中, video memory是唯一使用内存映射I/O方式访问的I/O空间. 代码只模拟了320x200x8
的图形模式, 一个像素占8个bit的存储空间, 因此在一幅图中最多能够同时使用256种颜色.nemu/src/device/vga-palette.c
定义了VGA的默认调色板. 现代的显示器一般都支持24位的颜色(R, G, B各占8个bit, 共有2^8*2^8*2^8
约1600万种颜色), 为了让屏幕显示不同的颜色成为可能, 在8位颜色深度时会使用调色板的概念. 调色板是一个颜色信息的数组, 每一个元素占4个字节, 分别代表R(red), G(green), B(blue), A(alpha)的值, 其中VGA不使用alpha的信息. 引入了调色板的概念之后, 一个像素存储的就不再是颜色的信息, 而是一个调色板的索引: 具体来说, 要得到一个像素的颜色信息, 就要把它的值当作下标, 在调色板这个数组中做下标运算, 取出相应的颜色信息. 因此, 只要使用不同的调色板, 就可以在不同的时刻使用不同的256种颜色了. 如果你对VGA编程感兴趣, 这里有一个名为FreeVGA的项目, 里面提供了很多VGA的相关资料.nemu/src/device/sdl.c
中是和SDL库相关的代码, NEMU使用SDL库来模拟计算机的标准输入输出. 在init_sdl()
函数中会进行一些和SDL相关的初始化工作, 包括创建窗口, 设置默认调色板等. 最后还会注册一个100Hz的定时器, 每隔0.01秒就会调用一次device_update()
函数.device_update()
函数主要进行一些设备的操作, 包括发送100Hz的时钟中断, 以25Hz的频率刷新屏幕, 以及检测是否有按键按下/释放, 若有, 则发送键盘中断. 需要说明的是, 代码中注册的定时器是虚拟定时器, 它只会在NEMU处于用户态的时候进行计时, 如果NEMU在ui_mainloop()
中等待用户输入, 定时器将不会计时; 如果NEMU进行大量的输出, 定时器的计时将会变得缓慢, 因此除非你在进行调试, 否则尽量避免大量输出的情况, 从而影响定时器的工作.
你应该从数字逻辑电路实验中认识到和扫描码相关的内容了: 当按下一个键的时候, 键盘控制器将会发送该键的通码(make code); 当释放一个键的时候, 键盘控制器将会发送该键的断码(break code), 其中断码的值为通码的值+0x80. 需要注意的是, 断码仅在键被释放的时候才发送, 因此你应该用"收到断码"来作为键被释放的检测条件, 而不是用"没收到通码"作为检测条件.
在游戏中, 很多时候需要判断玩家是否同时按下了多个键, 例如RPG游戏中的八方向行走, 格斗游戏中的组合招式等等. 根据键盘扫描码的特性, 你知道这些功能是如何实现的吗?
阅读 kernel/driver/ide/disk.c
中的代码, 理解kernel读写磁盘的方式. 以读操作为例, 你会发现磁盘中的每一个数据都先通过 in
指令读入到寄存器中, 然后再通过 mov
指令把读到的数据放回内存; 写操作也是类似的情况. 思考一下, 有什么办法能够提高磁盘读写的效率?
在一些90年代的游戏中, 很多渐出渐入效果都是通过调色板实现的, 聪明的你知道其中的玄机吗?
我们提供的代码是模块化的, 为了让新加入的代码在NEMU中工作, 你只需要在原来的代码上作少量改动:
- 在
nemu/include/common.h
中定义宏HAS_DEVICE
. - 在cpu结构体中添加一个
bool
成员INTR
, 这个成员用于表示是否有外部中断到来, 它会在nemu/src/device/i8259.c
中用到. 在
init_monitor()
函数中加入初始化设备和SDL的代码:init_device(); init_sdl();代码在模拟某些设备的功能时用到了SDL库, 为了编译新加入的代码, 你需要先安装SDL库:
apt-get install libsdl1.2-dev
安装成功后, 修改
nemu/Makefile.part
, 把SDL库加入链接对象:--- nemu/Makefile.part +++ nemu/Makefile.part @@ -4,1 +4,1 @@ -nemu_LDFLAGS := -lreadline +nemu_LDFLAGS := -lreadline -lSDL之后重新编译.
配置X环境
如果你使用PA0中的Docker镜像, 你会发现编译后运行NEMU会提示以下错误:
(*) DirectFB/Core: Single Application Core. (2012-05-20 13:17)
(!) Direct/Util: opening '/dev/fb0' and '/dev/fb/0' failed
--> No such file or directory
(!) DirectFB/FBDev: Error opening framebuffer device!
(!) DirectFB/FBDev: Use 'fbdev' option or set FRAMEBUFFER environment variable.
(!) DirectFB/Core: Could not initialize 'system_core' core!
--> Initialization error!
nemu: src/device/sdl.c:57: init_sdl: Assertion `ret == 0' failed.
(!) [ 2095: 0.000] --> Caught signal 6 (unknown origin) <--
为了运行加入SDL库后的NEMU, 你还需要进行一些额外的配置.
下载X Server
根据主机操作系统的类型, 你需要下载不同的X Server:
- Windows用户. 点击这里下载, 安装并打开Xming.
- Mac用户. 点击这里进入XQuartz工程网站, 下载, 安装并打开XQuartz.
- GNU/Linux用户. 系统中已经自带XServer, 你不需要额外下载.
为SSH打开X11转发功能
根据主机操作系统的类型, 你需要进行不同的操作:
- Mac用户和GNU/Linux用户. 在运行
ssh
时加入-X
选项即可,-X
选项会为SSH连接打开X11转发功能:ssh -X [email protected]
- Windows用户. 在使用
PuTTY
登陆时, 在PuTTY Configuration
窗口左侧的目录中选择Connection -> SSH -> X11
, 在右侧勾选Enable X11 forwarding
, 然后登陆即可.
通过带有X11转发功能的SSH登陆后, 你就可以顺利运行加入SDL库后的NEMU了, 运行时你会看到一个新窗口弹出.
使用设备代码
上述代码只是提供了I/O寻址方式的接口, 你还需要在NEMU中编写相应的代码来调用这些接口. 具体的, 你需要:
- 实现
in
,out
指令, 在它们的helper函数中分别调用pio_read()
和pio_write()
函数. - 在
hwaddr_read()
和hwaddr_write()
中加入对内存映射I/O的判断. 通过is_mmio()
函数判断一个物理地址是否被映射到I/O空间, 如果是,is_mmio()
会返回映射号, 否则返回-1
. 内存映射I/O的访问需要调用mmio_read()
或mmio_write()
, 调用时需要提供映射号. 如果不是内存映射I/O的访问, 就访问DRAM.
你还需要在NEMU中添加和硬件中断相关的代码:
在
cpu_exec
()中for循环的末尾添加轮询INTR引脚的代码, 每次执行完一条指令就查看是否有硬件中断到来:if(cpu.INTR & cpu.eflags.IF) { uint32_t intr_no = i8259_query_intr(); i8259_ack_intr(); raise_intr(intr_no); }添加
hlt
指令. 这条指令十分特殊, 执行这条指令后, CPU直到硬件中断到来之前都不会执行下一条指令. 实现的时候, 只需要在相应的helper函数中通过一个循环不断查看INTR引脚, 直到满足响应硬件中断的条件才退出循环. 需要注意的是, 如果在关中断状态下执行hlt
指令, 响应硬件中断的条件将永远得不到满足, CPU将一直处于停止工作的状态, 永远无法执行下一条指令. (温馨提示: 如果你发现在实现hlt
指令的时候遇到了困难, 请参考本实验中的某一道蓝框题.)
最后你需要在kernel中加入相关的代码, 你只需要在 kernel/include/common.h
中定义宏 HAS_DEVICE
, 然后重新编译kernel就可以了. 重新编译后, kernel会在 init_cond()
函数中多进行一些和设备相关的工作:
- 初始化i8259. 由于NEMU中的i8259模拟实现是不可编程的, 因此它没有注册端口I/O的回调函数, 故kernel中对i8259的初始化并没有实际效果.
- 初始化串口. 如果你的
out
指令实现正确, 初始化串口后, 你就可以在kernel中使用Log()
进行输出了. 同时SYS_write
系统调用也不需要通过"陷入"NEMU来输出了, 修改kernel中sys_write()
的代码, 通过serial_printc()
把SYS_write
系统调用中buf
的内容输出到串口. - 初始化IDE驱动程序.
kernel/src/driver/ide
中实现了IDE驱动程序. 初始化工作包括:- 初始化IDE驱动程序的高速缓存.
- 把
ide_writeback()
函数加入到时钟中断的中断处理函数. 每次时钟中断到达的时候,ide_writeback()
将会被调用, 它负责每经过1秒将高速缓存中的脏块写回磁盘, 进行写数据的同步. - 把
ide_intr()
函数加入到磁盘中断的中断处理函数. 每次磁盘中断到达的时候,ide_intr()
将会被调用, 它负责设置has_ide_intr
标志, 记录磁盘中断的到来.
- 此时内核的底层初始化操作已经全部完成, 可以打开中断.
- IDE驱动程序封装了磁盘读写的功能, 并向上层提供了
ide_read()
和ide_write()
两个方便使用的接口来读写磁盘. 端口I/O的功能实现正确后, 我们已经可以使用"真正"的磁盘, 而不需要使用ramdisk了. 定义宏HAS_DEVICE
后,kernel/src/elf/elf.c
中的一处代码会把磁盘开始的4096字节读入一个缓冲区中, 这4096字节已经包含了ELF头部和program header table了. 你需要修改加载loader模块的代码, 从磁盘读入每一个segment的内容. 修改后, 把nemu/include/common.h
中定义的宏USE_RAMDISK
注释掉, ramdisk就可以退休了. - 为用户进程创建video memory的虚拟地址空间. 在
loader()
函数中有一处代码会调用create_video_mapping()
函数(在kernel/src/memory/vmem.c
中定义), 为用户进程创建video memory的恒等映射, 即把从0xa0000
开始, 长度为320 * 200
字节的虚拟内存区间映射到从0xa0000
开始, 长度为320 * 200
字节的物理内存区间. 这是PA3中的一个选做任务, 如果你之前没有实现的话, 现在你需要面对它了. 具体的, 你需要定义一些页表(注意页表需要按页对齐, 你可以参考kernel/src/memory/kvm.c
中的相关内容), 然后填写相应的页目录项和页表项即可. 注意你不能使用mm_malloc()
來实现video memory映射的创建, 因为mm_malloc()
分配的物理页面都在16MB以上, 而video memory位于16MB以内, 故使用mm_malloc()
不能达到我们的目的. 如果创建地址空间和内存映射I/O的实现都正确, 你会看到屏幕上输出了一些测试时写入的颜色信息, 同时video_mapping_read_test()
将会通过检查.
到此为止, 随着设备这最后一块拼图的加入, NEMU的基本功能都已经实现好了, 最后我们通过往NEMU中移植两个游戏来测试实现的正确性.