一旦进入保护模式并显示了一条信息,接下来,我们要加载内核程序。我们在保护模式下使用平坦的内存访问模型,这对于加载内核来说比较方便。
先来看一下我们的内核程序c03_core.asm。这个内核程序比较简单,目前还缺少大部分必要的功能,比如内存分配、用户程序的加载和创建、任务和线程调度等。不过没有关系,我们当前的目标是说明如何加载内核并进入64位模式,所以只需要有一个简单的内核就行,不需要它很复杂。
在内核程序的开头也包含了全局定义文件global_defs.wid,因为内核程序也需要用到它定义的常量。这个文件里全都是常量定义和宏定义,所以不占用任何内存空间。
内核程序工作在IA-32e的64位模式下,而在64位模式下是不分段的,处理器强制采用平坦模型。但是,这个内核程序依然是分段的,定义了若干个段。这些段的定义只具有形式上的意义,只用来分隔程序中的指令和不同类型的数据,使程序的内容组织在视觉上显得更清楚,仅此而已。
一般来说,分段的意义在于决定标号的汇编地址如何计算。正如我们已经知道的,如果在段的定义中有vstart或者vfollows子句,则段内标号的汇编地址从指定的数值开始计算。如果在段的定义中没有任何子句,就像本章内核程序中的这些段定义一样,只有关键字section及段的名字,则在每个段内,标号的汇编地址都延续自上一个段,都是它们相对于整个内核程序起始处的距离。
在内核程序中,第一个段是core_header,从名字上表明它是内核头部段。在段的定义中没有任何子句,而且这个段是整个程序中的第一个段,在这个段内,标号length的汇编地址是0;在标号length这里,用伪指令dd定义了一个双字,所以,下一个标号init_entry的汇编地址是4;在标号init_entry这里,也用伪指令dd字义了一个双字,所以,下一个标号position的汇编地址是8。
再来看第二个段,第二个段是core_data,从名字上表明它是内核数据段。在这个段的定义中没有使用任何子句,所以,段内标号的汇编地址是延续自上一个段。
在上一个段中,最后一个标号position的汇编地址是8,从这里开始用dq定义了一个4字的数据,共8字节。因此,在第二个段内,标号welcome的汇编地址就是延续自第一个段,它的汇编地址是0x10,也就是16。
后面的段core_code也是如此,在这个段的定义中没有使用任何子句,所以,段内标号的汇编地址也是延续自上一个段。
通过以上分析可知,虽然内核程序分了段,但是,由于在每个段内,标号的汇编地址都延续自上一个段,或者说,在整个程序中,所有标号的汇编地址都是从程序的起始处开始计算的,所以,内核程序虽然是分段的,但是相当于没有分段,分段只是形式上的。
内核加载器的任务是将内核读入内存并进行重定位。那么,我们应该将内核加载到内存中的什么位置呢?
如图3-4所示,从物理地址0x20000到0xA0000的这一部分用来存放内核。这一段空间不大,只有将近600KB,对于Windows和Linux等流行的操作系统来说不值一提,根本就不够它们用的。在保护模式下可以访问4GB物理内存,这些流行的操作系统会将自己加载到1MB以上的内存空间,而且将占用更多的内存空间。不过,我们的内核非常小,这几百KB还用不完。所以,我们将内核加载到1MB以内的这一部分空间。当然了,如果你以后想写一个完善的操作系统,那又是另一回事了。
回到内核加载器程序c03_ldr.asm。
为了加载内核程序,我们编写了一个例程read_hard_disk_0。这个例程是从第507行开始的,用来读硬盘。读过《x86汇编语言:从实模式到保护模式》这本书的同学肯定非常熟悉,所以也就不准备详细介绍,它无非就是访问硬盘控制器,通过端口向硬盘控制器发送读写命令,再通过端口把硬盘上的数据读入内存。
进入例程时,用EAX指定逻辑扇区号,用EBX指定目标缓冲区的地址,也就是用来指定把读取的数据放在哪里。注意,这个例程工作在平坦模型下,不需要传入段地址。在平坦模型下,无论什么时候,包括在调用这个例程之前和之后,所有段寄存器都指向同一个4GB的段,段的基地址为0。所以,只需要用EBX指定4GB段内的偏移量即可。
另外需要注意的是,和从前一样,这个例程每次读一个逻辑扇区,而且在返回时要把EBX的内容在原来的基础上增加512。
前面已经说过,内核被加载的位置是物理地址0x20000。为了方便,我们在全局定义文件global_defs.wid中将它定义为常量CORE_PHY_ADDR。
在平坦模型下,段的基地址为0,内核被加载的物理地址就是4GB段内的线性地址或者说偏移量,我们将它传送到EDI(第253行)保存,然后在调用read_hard_disk_0之前从EDI传送给EBX(第256行)。同时,我们还要将内核程序在硬盘上的起始逻辑扇区号COR_START_SECTOR传送至EAX。符号COR_START_SECTOR是一个常量,也是在全局定义文件global_defs.wid中定义的。
准备好内核的起始逻辑扇区号,以及用于加载内核程序的起始线性地址后,我们调用例程read_hard_disk_0读取内核程序的第一个扇区。在这个扇区里包含了内核程序的长度信息,我们需要根据这个长度信息来决定还需要再读几个扇区才能把内核程序全部读完。
来看一下内核程序c03_core.asm,在程序的一开始包含了global_defs.wid文件,但这个文件里全都是常量定义和宏定义,所以不占用任何内存空间。
内核程序是分段的,但我们说过,这些段只具有形式上的意义,在本质上和不分段没有区别,程序内的所有标号,它们的汇编地址都是从整个程序的起始处开始计算的。正因如此,在程序的最后,标号core_end所代表的汇编地址就是它相对于整个程序开头的偏移量,而且等于整个内核程序的长度,以字节计。
在程序的一开始,偏移为0的地方,是标号length,在这里定义了一个双字并初始化为标号core_end的汇编地址。也就是说,这个双字记录了内核程序的总长度,以字节计。
回到内核加载器程序c03_ldr.asm。
接下来,我们需要取出内核程序的长度,然后换算成扇区数,就知道还需要再读几个扇区才能把内核程序全部读出来。
我们当前正在使用平坦模型,段的基地址为0,所以,用来加载内核的物理地址也是4GB段内的偏移量。内核加载的物理地址是CORE_PHY_ADDR,位于EDI中。内核的一开始就是内核的长度数据,第260行的指令访问4GB的数据段,从偏移为EDI的地方取出内核程序的长度数据。
接下来,第261~263行,我们用64位除法,用内核程序的长度除以每个扇区的字节数512,得到扇区数。被除数在EDX和EAX,除数在ECX中,商在EAX,余数在EDX。
和往常一样,第265~267行是根据余数是否为0来判断实际还需要再读几个扇区。如果余数为零,说明内核程序的长度正好是512的整数倍,jnz指令不发生转移,而是执行后面的dec指令,将还需要读的扇区数减一,这是因为前面已经读了一个扇区。如果余数不为零,说明扇区数少了一个。但是前面已经读了一个扇区,正好抵消,可直接用jnz指令转到后面执行。
无论如何,在标号@1这里,EAX中就是还需要再读取的扇区数。如果第269行的or指令检测到它为零,说明不需要再读了,整个内核程序就只有一个扇区,可转到标号pge执行。当然了,内核只有一个扇区的可能性几乎不存在,但在程序中必须做这样的检测。
如果内核程序剩余的扇区数不为零,第273~279行读取这些剩余的扇区。第273行将ECX设置为需要读取的扇区数,用来控制循环的次数;第274行设置起始的逻辑扇区号到EAX,但是这个扇区我们在前面已经读过了,那么第275行的inc指令将EAX加一。
扇区的读取是用loop指令组成的循环完成的,每次读硬盘之后都将EAX递增以指定下一个逻辑扇区号。
内核程序加载之后,还需要将内核程序在平坦模型下的起始线性地址回填到内核程序头部。在内核程序c03_core.asm内偏移为8的地方是标号position,此处用来记录内核加载的起始线性地址,长度为一个四字。这个地址信息非常有用,基本上解决了64位模式下的程序重定位问题,我们后面再详细解释。
我们知道,用于加载内核的物理地址已经被定义为常量CORE_PHY_ADDR,在平坦模型下,段的基地址为0,内核被加载的物理地址CORE_PHY_ADDR就是平坦模型下的线性地址,可直接用常量CORE_PHY_ADD代替标号position后面的“0”。这样做没有任何问题,但是,我们不是这么做的。
回到内核加载器程序c03_ldr.asm。
我们不怕麻烦,我们是用两条指令(第283~284行)来手工填写内核被加载的起始线性地址到内核程序的头部的。之所以用了两条指令,是因为在保护模式下,指令的操作数不能是64位的,只能分成两个双字进行。所以,我们只能将内核程序中的这个四字分成两个双字来访问,第一个双字的偏移为0x08,第二个双字的偏移为0x0C。
与此同时,由于内核的线性地址CORE_PHY_ADDR在数值上很小,可以看成64位的,高双字为0,低双字还是CORE_PHY_ADDR。
因为x86是低端字节序的,高双字数据在高地址,低双字数据在低地址,第283行将低双字保存在内核程序头部中偏移为8的第一个双字中。访问内存时,是访问基地址为零的段,段内偏移量为CORE_PHY_ADDR+8,写入的数值是CORE_PHY_ADDR。
紧接着,第284行将数值0填写在段内偏移为CORE_PHY_ADDR+0x0c的地方,这是高双字。