实模式初始化程序即为上文所提到的“booter.bin”,二进制文件,下文简称为booter。该文件使用C和汇编混合编制,负责检测硬件、加载解析PE格式的内核、初始化保护模式和初始化分页机制,还负责建立最初的内存分页布局。
4.1 编译问题
由于使用DOS版的实模式C编译器TCC,要想使得编译得到的目标代码可以在无DOS环境下正常运行,得解决几点技术问题。
首先是IO库函数的使用。在无DOS环境下,所有的文件操作库函数,包括文本输出函数都无法使用,因为它们的实现依赖于DOS中断服务21h。所以Sinos必须自行处理屏幕的输出和文件的操作。
对于屏幕的输出Sinos重写了一套输出函数,函数中通过调用BIOS中断10h来实现文本输出。文件操作方面,同引导扇区程序一样,实模式初始化程序也实现了一套FAT16文件系统的读取函数,实现方法也较为类似,所不同的是,该模块中使用C语言实现文件系统读取算法,容错性大大提高。
其次是编译器相关的内部函数问题。为了在DOS的16位环境下实现32位long型数据的四则运算,编译器使用了一系列内部函数[1],但这些内部函数的实现却位于C语言运行库中,需要链接时同时链接进去。这就带来了另一个问题,C运行库依据编译时内存模式[2]的不同,有五个[3]不同的版本。
经过考虑,Sinos对实模式C程序统一使用Tiny模式编译,链接时与该模式相应的CS.LIB运行链接。虽说使用Tiny模式把实模式程序的大小限制在了64KB以内,但这样避免了可能的错误隐患,因为对于编译器生成的代码中有关远指针的使用方式,笔者并不是太清楚,这样可能会产生误操作。
虽然使用Tiny模式,但其只是意味着C程序中指针默认为近指针,并不影响显式的远指针定义。Sinos booter中所使用的数据指针,均定义成huge *形式,即“巨指针”,用以保证程序的正确性。Huge指针通过特定的例程保证,每次操作完成后其偏移量均小于10h,即只有最低4位有数值,其余数值都被进位到段地址上去了,这样就可以避免Far指针在64K边界时出乎意料的回绕的行为。当然,一次操作必须小于64K。
最后就是C语言与汇编的函数接口问题。其实这是个很常规的问题,不只是在操作系统开发中才有,一般只要注意了参数压栈顺序和栈的平衡就行了。另外,对于C调用约定的函数,nasm提供了一套宏,可以很方便地处理C程序传递给汇编程序的参数。
4.2 链接程序
紧接着编译问题的,是程序的链接。常规的链接器链接出来的结果一般都是DOS的的COM文件或EXE文件。EXE文件有很多段重定位的信息,要让程序正确运行,需要不少的代码去处理这些信息。然而,booter是在引导扇区中加载的,这就希望其用于加载的代码越少越好,EXE文件显然是不合适的。COM文件虽然是无格式的二进制文件,但其在链接时的默认起始偏移为100h,给代码控制带来了一定的不方便。
一个更好的链接选择是使用jloc。jloc是面向那些需要自由控制各模块的链接地址的应用(如操作系统引导程序、嵌入式系统等)而开发的,可以通过一个配置文件指定链接时各模块所在位置。而且其生成的目标文件没有格式,是平直的二进制代码和数据,所以它非常符合booter的要求。
4.3 Booter程序
booter的入口点位于stub.asm中,它负责从引导程序中接过控制权,然后调用C语言编写的初始化程序。stub.asm通过jloc的链接,位于目标代码的起始位置,也就将被引导记录加载到地址从0:7e00h开始的内存中。
在C语言初始化程序(见下文)返回后,stub得到作为返回值的保护模式内核代码入口。然后就开始加载GDT和开启保护模式、分页机制,在保护模式下加载各段寄存器的初值后,就跳转到保护模式的内核入口函数中去了。当然,最后一步是通过call来完成的,因为stub还把实模式下收集到的硬件信息作为参数传递给了入口函数。
至于用C语言编写的初始化程序,负责内核载入、初始化等任务,主要由如下几个步骤组成:
查找内核文件。目前内核文件名为Kernel.dll,位于启动盘根目录。这部分的代码与引导记录中的查找根目录表的代码类似,只不过是用C语言写的。出乎意料地是,这些C代码与相应的汇编代码相比,看上去更为复杂,只因C语言中需要大量类型转换的代码。
找到内核文件对应的根目录表项后,可以获得文件的长度以及起始簇等信息,留待下面的载入部分使用。
建立初始GDT。Sinos由于不使用x86的段机制,所以GDT是固定的,其内容也很简单。在内核初始化之初,只需要两个描述符(Descriptor):系统代码段和系统数据段描述符。两个描述符指定的段基址均为00000000h,Limit字段为fffffh,G字段为1。意即段范围为整个4GB内存,也就是跳过段机制。
两个描述符DPL为0,用于核心态的代码,其实这无所谓,因为Sinos同样不使用段的保护机制,区分用户/核心态只使用页的特权保护机制。
GDT固定位于2500h:0000h处,以便于各启动模块间通讯,关于初始内存的分配问题,详见下一节。
建立初始页表。在启动分页机制和转到保护模式下的内核代码之前,最少要建立三张表:一张页目录表和两张页表。
首先,x86系统分页机制使用二级页表,所以一张页目录表是必需的。Sinos中,页目录表是属于各进程的,各进程拥有各自的页目录,因此该页目录表可以算是系统进程的。关于系统进程,详见下文。
其次,为了让CPU能够顺利地启动分页机制,需要有一段内存在开启分页机制前后地址是一致的,即物理地址和虚地址一致[4] 。Sinos的解决方法是将虚地址的最低1MB映射到物理内存的最低1MB,1MB内存地址正好占用1页页表。
最后,内核需要分页地址。Sinos对虚地址的规划是使用3GB以上的地址作为核心态地址,所以在实模式下加载的内核程序需要放入3GB开始的内存。所以,Sinos的解决方案就是把最低1MB内存同样映身到3GB开始的1MB地址空间中去。这样,在实模式下做出的任何更改都可以直接反映到3GB开始的内存中去。
检测硬件。由于没有类似Linux中的lrmi [5]程序支持[6] ,更没有如Windows般强大的硬件数据库,所以只能在“真正的”实模式下调用BIOS来进行硬件的检测工作。因此,硬件检测代码便出现在实模式初始化程序中。
由于Sinos只是处于开发框架阶段,只编写了PC标准硬件的设备驱动,如以需要检测的硬件很有限,其中最重要的就是内存大小的检测。
获取机器内存容量的方法一般来讲有三种方法,这三种方法都是基于BIOS int 15h中断,它们的名称依次为88h,E801h,E820h。由于88h中断子功能报告内存容量有64MB限制[7] ,而E801h子功能最初是为EISA总线设计的,支持面不是很广,所以Sinos使用E820h子功能来获取内存容量。
通过int 15h中断E820h子功能获取的并不是一个简单的内存容量的数值,而是通过一系列的枚举式的调用,返回当前内存的布局信息。所谓内存布局,是指32位物理内存地址中各不连续的、性质不同的地址块的分布情况。
由于历史原因,1MB以下的内存被分为传统内存(640KB以下)和ROM映射区,以及总线技术造成的内存空洞、APIC内存映射、显卡LFB内存映射和系统BIOS重映射区等等,目前PC的物理内存是支离破碎的。
在这些离破碎的内存块中捡起所有可用内存是一项复杂的任务,所以Sinos采用了一项极大的简化措施:只取最大的内存块作为主存,其余的内存块放弃使用。由于640KB以下的常规内存被用于实模式与保护模式通讯用途,所以这么做的话Sinos最多浪费数百KB的可用内存,这对于现代PC机来说,是微不足道的,但这却可大大简化内存的处理工作。
目前另一个检测重点是显卡的检测。如上文所述,Sinos使用VESA制定的VBE2.0标准来控制显卡,使用简单高效的线性帧缓冲技术来完成图形显示。所以Sinos必须在实模式下检测诸如显存大小、缓冲区映射地址等参数,这些参数都是通过VBE调用来完成的。具体有关VBE的各项参数信息,详见下文图形系统部分。
载入和解析内核文件。内核文件文件名为Kernel.dll。正如该文件名所显示的,它是一个动态链接库(Dynamic Link Library, DLL),是符合Windows PE格式的DLL文件。
PE格式的详情在此不再赘述。Sinos使用PE格式作为内核格式的原因,一方面是因为PE格式简洁明了,特别是它的重定位部分,与LE、NE格式相比要简洁很多;另一方面是由于,Windows下有着大量PE格式的开发工具,包括非常优秀的Microsoft®的Visual Studio开发环境以及大量的PE实用工具,可以用于PE格式的调试和测试。
PE格式的可执行文件其本质是一个内存的映像(Image),反映该程序运行时内存中程序和数据的分布情况。由于映像一般是分块存放的,所以加载PE文件时需要有一个解析、重定位的过程[8] 。在实模式初始化程中没有实现文件接口,所以不能像C库函数一样逐块地读取内核文件进行解析,所以目前采用一次性将文件读入内存缓冲区、然后对缓冲区进行拷贝操作的办法。这个办法操作比较简单,但需耗费近一倍的内存,也就是说,由于常规内存为640KB左右,这个方法使得内核映像的大小最大限制为300KB左右。当然,目前内核的大小为61KB,没有问题,将来内核超过300KB时,可以到时改变加载方法。
除了解析文件块,PE格式的内核在可以运行之前还需要重定位(Relocation)操作。目前Sinos内核编译时使用cl.exe编译器的默认参数,以40000000h为基址进行链接,也就是说内核代码中所有使用的绝对地址都是假定程序加载至虚地址40000000h时的地址,而事实上,Sinos内核将被加载到C0000000h处,即3GB起始处。的确,通过链接参数可以指定链接时的基址,但由于cl只是用来开发用户态程序的,就算开启大地址支持选项[9] 也无法处理3GB以上的地址,所以只能求助于重定位技术。
关于重定位操作的详请,在此不再详述,只是写一下PE格式映像重定位操作的特点。其特点就是简洁明了,PE文件中重定位信息位于PE文件头后的数据字典中,所有的重定位地址直接列在重定位表中,没有其它格式重定位所需要的复杂偏移量计算,使得代码非常简单。
设置显示模式。由于实模式中断调用的原因,显示模式的设置需要提前到进入保护模式之前,详细情况请参阅下文图形系统部分。
在C语言编写的启动过程执行完成后,执行流重新回到汇编编写的Stub存根程序。C启动函数把内核的入口虚地址作为参数返回给Stub程序,用于最后一步的跳转。
之后,Stub开始执行一系列系统指令,用于载入GDT、进入保护模式和开启分页机制。
整个启动过程的最后步骤是转到内核入口。为了避免使用固定的硬件信息块地址,初始化程序将硬件信息作为参数传递给内核。这样,内核入口是一个带有一个指针参数的函数。为了进行正确的函数调用,初始化程序最后的跳转指令使用了call而不是jmp指令。正如下文线程调度部分所要提到的,内核入口函数不会返回,初始化线程将转化成IDLE线程。
至此,Sinos完成了从实地址的实模式到虚地址的保护模式的转换,把控制权交给了内核入口函数。从下一节开始,将分成几部分介绍内核的组成模块及其工作方式,至于进入内核后的入口函数的操作,留待第十一节再作介绍。
[1]比如16位环境下进行32位乘法运算,代码必须使用AX和DX两个寄存器来存入32位数据,其涉及大量寄存器间的传送、运算操作,需要固定的数十条指令。所以编译器一般把这段固定代码独立成工具函数。
[2]分别是Tiny、Small、Medium、Compact、Large和Huge,共六个。
[3]Tiny和Small模式由于都使用近指针,所以运行库无差别。
[4]在启动CPU的分页机制,即执行设置CR1的MOV指令之后,CPU已经开始使用分页机制寻址,包括在代码段中取指操作。而这时EIP值并没有变,还是指向MOV指令之后的那句指令。如果此时EIP值为一个无效地址(页表中不存在),立即会发后缺页异常。因此,需要有一段地址一致的内存,以便在启用分页机制前后EIP值所代表的地址始终有效。
[5]lrmi – Linux Real Mode Interface,Josh Vanderhoof开发的保护模式下实模式接口,可以实现保护模式下调用BIOS中断。
[6]lrmi是通过模拟实模式CPU来实现实模式接口的,Sinos当然也可以仿效,但这需要大量时间。
[7]88h子功能出现于Intel 80286时代,使用16位寄存器来报告内存容量,其报告的单位为1KB,所以其最大容量为1KB*64K=64MB。
[8]分块存放并不意味着映像不连续,这取决于具体的分块地址。理论上PE文件中的程序和数据可以位于不连续的虚地址,但实际上Windows中的可执行文件都是采用了连续地址;至于文件中与内存中的相对地址是否一致,还取决于内存和文件中对齐(Alignment)属性的设置,通常它们是一致的。也就是说,实际中的PE文件都可以看作连续的内存映像,可以不需要解析过程,但出于严谨考虑,Sinos还是加入了解析代码。
[9]Large Address Aware选项,用于链接DLL时指定2GB以上、3GB以下的基址。在Windows 2000的Advanced Server及以上版本中,可以通过启动选项指定核心态内存使用顶部的1GB而不是通常的2GB来增加用户程序的地址空间。