虽然做过操作系统实验,但是那个实验充满了遗憾,比如文件系统和锁的概念没有得到实践。另外一点就是,那个实验是基于框架的,很多东西都为我们准备好了,尤其是页表管理模块。虽然方便而且有助于专注,但是也让我对一些细节缺少掌握。

目前准备从零做操作系统实验,虽然会参考现有的各种教学框架和实现,但是整个代码按道理应该是一个字符一个字符敲出来的。就算是照抄,只要不是无脑拷贝,就会出现一般开发中遇到的各种失误,而有选择的参考则会暴露自己设计时的思路缺陷,对于调试和思维的锻炼还是有的。

boot loader

虽说要从零代码写起,不过 boot loader 我还是直接搬运了 JOS 的代码,主要是想快点进入到内核代码的编写,毕竟 boot loader 和内核的代码不是一个位面的,我并不想一直盯着 BIOS 打转。不过我一开始的设想是 boot loader 做的很简单,只是单纯地拷贝内核代码。但是这要求内核代码有足够简单的结构,而准备这样的易于拷贝和硬编码的结构让我苦思许久。而 JOS 的做法则是复杂化 boot loader 的逻辑。除了基本的环境设置外,还要直接解析 ELF 文件进行内核代码的拷贝。这样内核的编译可以相对简化,硬编码文件位置也比编码代码位置要简单许多(所以其实脏活是链接器做了)。但是 boot loader 的大小就变得比较紧张了。

boot loader复杂化后,链接时需要增加些注意。 0x7C00 是首条指令的地址,对于可重定位的,有符号引用的模块间的编译链接,虽然我们能指定入口函数 entry point 和规定 .text 节的起始位置,但是稍加测试便可知这两者不能保证相等。一般情况下有 loader 准备环境,解析 EFL 文件找到 entry point,所以没什么问题。但是 boot loader 没有人来解析文件格式,必须保证 0x7C00 即是代码的起始也是逻辑的起始。这要保证入口符号是链接器见到的第一个重定位文件的第一个(有定义的)符号。所以在写 boot loader 的 makefile 时,一定要在某个层面上写死输入文件的顺序。

由于我在 64 位机器上编译,而内核目标是 32 位,所以一不小心忘记显式指明架构就导致了非常混乱的情况。总的来说是 boot loader 是 32 位的,并且解析的是 32 位的 ELF 文件,而独立编译的简单的“内核”代码则用了默认选项,导致生成了 64 位的 ELF 文件。这也就造成了 ELF 的魔数检查通过,但是 entry point 都能读错的情况。

链接

为了省去书写格式化字符串的功夫,我暂时拷贝了 JOS 的 lib 目录下的代码,除去暂时不用的 console.c。但是编译链接时出现了一些小问题。

由于格式化函数里有对long long类型的数进行处理,在-m32的编译选项下,会生成__udiv3di3等一系列软件模拟 64 位数运算的函数。令我感到奇怪的是,明明已经使用-fno-builtin回避内置函数了,为什么还会夹带编译器的私货?然后我发现我使用的标准是-std=gnu11,如果改成-std=c11,那么可能不会生成__udiv3di3这样的函数,不过内联汇编的关键字就需要加下划线了。

我参考了 JOS 的 Makefile 的解决办法,发现他们在链接时还是额外加上了 gcc 的库。gcc 的选项-print-libgcc-file-name可以打印标准库的归档文件的路径,加上对应的编译选项能输出对应的归档文件(主要是-m32的影响)。

串口输出

在 QEMU 下,通过串口将内核的调试信息输出到标准输出上是极好的一件事。串口输出作为一个基本的工具,一般在实验框架中是提供好的,但是还是要知道怎么从已有的资料中明确串口的使用方法并编码实现,OSDev 的 Serial Ports 条目的内容已经足够了。

一个关键的问题是,用-serial stdio作为 qemu 的启动选项后,内核代码访问哪些个端口去进行串口设备所规定的操作。根据上面那个 OSDev 的链接,一共有四个串口:COM1、COM2、COM3 和 COM4,其提供的 4 个起始端口号在 qemu 下是正确可用的:

COM Port IO Port
COM1 3F8h
COM2 2F8h
COM3 3E8h
COM4 2E8h

更加保险的做法是扫面BIOS Data Area,从地址 0x4000 开始的 4 个连续的 16 位数记录了从 COM1 到 COM4 这四个串口的起始端口号(OSDev 上的参考内容)。

要想使用全部 4 个串口,只需要设置多个-serial dev作为 qemu 的启动选项(详见 qemu 的文档,搜索-serial dev快速定位),比如-serial stdio -serial file:foo -serial file:bar就会将标准输入输出作为 COM1,文件 foo 和 bar 分别作为 COM2 和 COM3。

虚拟 8086 模式

为了使用 qemu 的 vbe 2.0 扩展,需要在保护模式下使用 bios 中断(话说应该也可以直接对显示卡进行端口IO,不过没找到相关资料)。

根据一些资料,我目前采取的进入 v86m 的方式是在一个中断处理程序里克隆一份保存现场,修改 eflags 使得 vm 位有效,然后查询 bios 的中断向量表(IVT,注意与中断描述符表区分),得到段基址和段内偏移,用来修改 cs 和 eip。最后 iret 直接进入到 bios 的中断向量里。由于之前克隆了现场,所以 iret 执行完弹栈操作后,esp 刚好指向正版的中断现场,我期望 bios 最后执行 iret 就能直接恢复。

在这个大方向上,遇到了些问题,记录如下:

实模式下段转换机制

我混淆了实模式和保护模式的段转换机制。实模式下是直接将段寄存器的值左移 4 位加到 ip 上的。我一开始甚至为了能进入到自己写的 16 位代码中而专门准备了段描述符。由于地址限制在 20 位以内,我暂时不打算自己写 16 位代码,而是直接进入 BIOS 的中断服务程序。

IVT 与 IDT 是不同的

http://wiki.osdev.org/Interrupt_Vector_Table

http://wiki.osdev.org/Interrupt_Descriptor_Table

在 BIOS 的第一条指令上重启(Triple Fault)

第一条指令是 cli,由于 GDB 估计只支持扁平模式,所以显式的是问好或者全0,要手动计算线性地址。iret 发现现场镜像(image)的 VM是 1,就自动把 CPL 改成了 3。这个特权级一般无法执行关中断这种危险的指令,不过可以修改 eflags 的 iopl 位,它表示 io 相关指令所允许的最大特权级(数值意味上)。

在一条 IO 指令上触发 GP

最外层的表现是导致 Triple Fault,通过 OSDev 的这个帖子发现了让 qemu 报告异常的方法:启动选项 -d int,cpu_reset

查阅 80386 手册 out 指令说明,发现其在 v86m 模式下,会考察 TSS 中 IO map。由于默认的 TR 为 0,所以取出的描述符是第一个全空的,指向的 TSS 从 0 地址开始,那里是 IVT,内容丰富,会出现 IO map 置 1 (不允许)的情况。

为了设置 TSS,不得不提前进行 GDT 的设置。设置完 GDT,执行 int 指令时,抛出了 GP(8) 异常,8 代表的是选择符,即我设置的 CS 段寄存器的值,原因是特权级不够,我想当然地把 GDT 里的描述符 DPL 设置成了 3,而 IDT 那里还是 0。

不过设置了 TSS 后还是 Triple Fault……首先我没有想到 TR 所用的选择子格式与段寄存器的格式是一样的,而且我用错误的选择子 ltr 指令都没有问题,而使用正确的选择子,在 ltr 指令上就会抛出异常。原因是选择子对应的描述符不是合法的门描述符,主要是 type 字段附近与段描述符不一致。

按照 80386 手册的说法,我只要将 TSS 的 IO Permission Base 的值设得比 TSS 描述符的 limit 大就能禁用,这样就不会因为 IO Permission 导致 out 指令抛出异常。但是试了一下不成功,而 OSDev 提供的数值 104 (参见 http://wiki.osdev.org/TSS),就能解决问题,成功禁用……

IRET 在进入 virtual mode 时的行为

我参考的 80386 手册是这么描述弹栈行为的:

EFLAGS <- SS:[eSP + 8]; (* Sets VM in interrupted routine *)
EIP <- Pop();
CS <- Pop(); (* CS behaves as in 8086, due to VM = 1 *)
throwaway <- Pop(); (* pop away EFLAGS already read *)
ES <- Pop(); (* pop 2 words; throw away high-order word *)
DS <- Pop(); (* pop 2 words; throw away high-order word *)
FS <- Pop(); (* pop 2 words; throw away high-order word *)
GS <- Pop(); (* pop 2 words; throw away high-order word *)
IF CS.RPL > CPL
THEN
  TempESP <- Pop();
  TempSS <- Pop();
  SS:ESP <- TempSS:TempESP;
FI;

而我观察栈上内容和实际各寄存器的值发现,应该是先弹esp和ss再处理其它段寄存器。但是令我疑惑的是,CS.RPL是哪来的?现在的 CS 可是作为值来使用的。此外 CPL 为 3,已经是最高了,为什么还是会有弹 esp 和 ss 的行为?

从 virtual mode 退出

在解决进入 virtual mode 时的现场镜像的布局问题后,我能保证进入 bios 后 esp 刚好指向正版的现场镜像,就好像直接通过 int 指令进来的一样。但是执行过程中还是重启了。观察 qemu 的异常记录,我发现 cs:ip 的内容变得很奇怪,主要是 cs 变成了 0x10 这么一个非常不适合做段基址的值。我使用 gdb 的 watch 来观察 $cs 什么时候改变,结果发现定位十分不精准(原因是 iret 改变了 cs 后,整个执行流就改变了,我却想用物理的扫描办法找到那个改了 cs 的指令)。最后只能通过手动 si 把整个执行流打印出来。结果如我所料,是在 iret 上发生了问题。不过第一次定位到这里,esp 的值比较奇怪,没有指到现场镜像,但是后面几次重现都指到了这里。关键问题是进入 virtual mode 后,CPL 变成了 3,此时无法改变 VM flag,整个行为如同实模式下,所以与预想的情况不同。

画图

最后虽然理论上能进出虚拟 8086 模式了,但是我发现 bios 中断比如用int $0x10改变显式模式完全没有作用,可能是端口 I/O 行为有变?心灰意冷,回避这个问题了。

我从维基百科上[VESA BIOS Extension](https://en.wikipedia.org/wiki/VESA_BIOS_Extensions)条目看到,虽然标准没有强行规定模式号,但是还有有些通用的编号的,并且在 QEMU 上也测试成功了。另外,通过在实模式下手动获取控制器信息,我发现 QEMU 支持的 VBE 标准实际上已经到了 3 了(至少字符串是这么写的)!现在就直接在 boot 阶段设置好模式。参考 OSDev 的Drawing_In_Protected_ModeGetting_VBE_Mode_Info,我还要获取 mode info,才能正确地定位像素点在显存中的位置。纵坐标 y 要乘的不是宽度,而是 pitch 字段,它表明了一行一共多少各字节。一个字节只代表一个像素的三色素之一,这一点很容易疏忽。同样的道理,横坐标 x 也要乘以 3。

我利用 imagemagick 将图片转换成了和显存格式相同的数组,但是忘记了乘上 3,结果一直没有输出对。

imagemagick 相关的参考资料:

  • 缩放
  • RGB转换,我估计扩展名不针对图片的话都会转换成原始RGB格式
  • 返回尺寸,竟然还有格式化输出……