加入最后的拼图

设备代码介绍

框架代码中已经提供了设备的代码, 位于 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中移植两个游戏来测试实现的正确性.

results matching ""

    No results matching ""