现在我们来看主引导程序c03_mbr.asm。
第6行的%include是一个预处理器指令。所谓预处理器指令,是在进行实际的编译工作前需要预先处理的指令,但这些指令并不对应机器指令,而是用来指导编译器工作的伪指令。换句话说,是提前做一些处理,为后面的编译做准备。
所有预处理指令都以百分号开始,预处理器指令%include是文件包含指令,用来将另一个文件的内容包含进来,它类似于C语言的文件包含指令#include。
在这里,被包含的文件是global_defs.wid,它的位置在上一级目录下面的common子目录。
与本书配套的文件需要从我的个人网站www.lizhongc.com下载,下载的是一个经过压缩的文件,包含了本书的所有源文件和工具。下载并解压缩之后,在x64asm目录下是一些工具软件。其中,Nasmide64.exe用来编辑和编译汇编语言源程序;fixvhdw64.exe用来将编译后的二进制文件写入虚拟硬盘,这两个程序都只能在64位的Windows上运行。
目录x64asm\c03对应于本章,保存着本章使用的源文件,一共有三个,分别是前面已经介绍过的c03_mbr.asm、c03_ldr.asm和c03_core.asm。
目录x64asm\common存放的是公用的文件,也可以称之为公用的程序模块,包括我们刚才所提到的global_defs.wid。尽管这个文件的扩展名不是.asm,但它本质上还是一个.asm文件,包含了汇编语言代码,可以用任何文本编辑软件打开。wid的意思是小组件,小插件。其实扩展名并不重要,你甚至可以将扩展名也设置成.asm,这都无所谓。
global_defs.wid,顾名思义,这是一个全局定义文件。这个文件的内容是有关系统全局的常量定义,比如内核5级头表的物理地址、内核4级头表的物理地址、中断描述符表的物理地址、全局描述符表的物理地址,等等。本书每章的源文件都会用到这些常量,为避免在每个文件中都重复定义这些常量,可以将它们定义在一个独立的文件中,而其他程序文件只需要用文件包含指令%include引入这个文件即可。
使用预处理指令%include包含另一个文件的内容可以减少重复劳动,使程序文件的组织结构化、条理化。但是,若不注意,文件可能会被多次重复包含。举例来说,如图3-2所示,文件a.asm、b.asm和c.asm都用%include指令包含了文件global_defs.wid,这是很自然很正常的。
图3-2 文件的重复包含
除此之外,文件c.asm还需要包含文件a.asm和b.asm,因为它需要使用这两个文件中定义的数据和例程。如此一来,在文件c.asm中,同一个文件global_defs.wid被重复包含了三次。这就是说,文件global_defs.wid会在文件c.asm中重复插入三次。在程序中出现重复的内容是危险的,这将导致编译时出现错误。
和C语言一样,如果要求一个文件中只能包含另一个文件一次,那就需要在被包含的文件中添加条件判断指令。以我们的全局定义文件global_defs.wid为例,用文本编辑工具打开它,你会发现有这两行:
在这个文件的末尾,还有一个配对的
上述第一行的%ifndef是预处理指令,即if not define,意思是“如果没有定义”。如果没有定义什么呢?如果没有定义后面的符号_GLOBAL_DEFS_。它是一个宏,或者也可称之为符号常量。符号常量的名字可以随意,这都无所谓。显然,这一行是判断指定的符号常量是否已经定义。
在某个文件内直接或者间接地包含全局定义文件global_defs.wid时,如果是第一次包含,这个符号常量肯定是没有定义的。所以,预处理指令
判断的结果是确实没有定义。此时,从本行开始,一直到末尾的%endif,中间这部分内容是有效的,会参与编译。若其中有其他预处理指令和常量定义,也将执行这些常量定义和预处理指令。因此,预处理指令
会被执行,从而定义了符号常量_GLOBAL_DEFS_。
接下来,如果再次直接或者间接地包含全局定义文件global_defs.wid,将再次执行上述过程。这一次预处理指令%ifndef判断的结果是符号常量_GLOBAL_DEFS_已经定义,所以,从本行开始,一直到预处理指令%endif,中间这部分内容被编译器忽略。
在主引导程序c03_mbr.asm内的第6行,这条预处理指令会首先被处理,编译器用指定的那个文件的内容替换当前这条预处理指令,即,将文件global_defs.wid的内容插入当前文件。
为方便引用主引导程序内的数据,我们定义了段mbr,并用vstart子句指定段的虚拟起始地址为0x7c00。
主引导程序是由基本输入输出系统BIOS加载到内存的,位置是物理地址0x7c00,我们通常认为这个位置的逻辑段地址为0,偏移地址是0x7c00。
第9~13行初始化各个段寄存器,以及栈指针寄存器SP。显然,栈是从0x7c00开始向下推进的,也就是往主引导程序相反的方向推进。它们以0x7c00为界,但互不影响。
接下来的任务是从硬盘上读取内核加载器,然后将控制权交给内核加载器。我们规定内核加载器起始于硬盘的逻辑1扇区。这就意味着,内核加载器程序编译后,必须从硬盘的逻辑1扇区开始连续写入。
按照标准,主引导扇区包括两个部分:前面是主引导程序,后面是一个分区表,最后还有2字节的主引导扇区有效标志0x55和0xaa。一个硬盘可以有4个主分区,而硬盘分区表则记录了这4个分区的详细信息,包括每个分区的起始位置、结束位置和相关属性,比如分区的类型,以及是否为活动分区。主引导程序必须从活动分区加载操作系统的自举代码,并转移到自举代码执行,自举代码用来完成操作系统的加载和初始化工作。因为主引导扇区太小,不可能用来完成操作系统的加载和初始化工作。
我们从来没有用过分区表,也没有创建过分区表,这是因为我们不需要。毕竟我们不是在编写一个真正的操作系统。但是,我们最好是给分区表留出空间,这样符合规范。而且这样还有一个好处,如果你以后想写一个操作系统的话,也能预留出足够的空间。
所以,我们必须精简程序,程序在主引导扇区里占用的空间越小越好。要想节省空间,最好是利用现成的基础设施。在计算机启动并执行到主引导程序时,基本输入输出系统BIOS已经可以使用,而且还提供了现成的硬盘读写功能,我们最好利用这些功能。
我们目前的任务是从硬盘上读取内核加载器,但我们也说了,主引导扇区空间有限,最好是充分利用BIOS提供的服务,即,使用BIOS扩展硬盘读。所谓扩展,是因为以前的硬盘读写功能使用CHS方式,也就是需要指定磁头、柱面和扇区。这种方式很麻烦,所以人们又发明了LBA方式,也就是逻辑块地址。使用逻辑块地址,只需要指定逻辑扇区号就行了。为此,基本输入输出系统BIOS也扩展了原来的功能,增加了一些新的硬盘读写接口,这就是扩展硬盘读写功能。
如图3-3的左侧所示,BIOS提供的扩展硬盘读功能需要通过软中断int 0x13进入,而且必须用寄存器传入需要的参数。其中,必须用AH传入0x42,表明我们是要读硬盘;必须用DL传入我们要读写的那个磁盘驱动器的编号。0x80是第一硬盘的编号。另外,还必须传入一个数据结构,这个数据结构里包含了一些地址信息,所以我们称之为地址结构。这个地址结构一定是位于数据段中的,所以用段寄存器DS传入段地址,用SI传入它的段内偏移。
如图3-3的右侧所示,地址结构的长度是16字节,在这个地址结构内,偏移为0的地方是当前这个地址结构自己的长度,以字节计,固定为16;偏移为1的地方是保留字节,固定为0;偏移为2的地方是本次传输或者说读写的扇区数。
读写硬盘时需要指定一个缓冲区,用来存放读出或者写入的数据。为此,在地址结构内偏移为4的地方是数据缓冲区所在的段内偏移量;偏移为6的地方是数据缓冲区所在的逻辑段地址。最后,偏移为8的地方用来指定本次读写的起始逻辑扇区号,长度为8字节。
回到主引导程序中,我们用栈来构造一个地址结构,这样比较方便,省得额外寻找地方。因为栈是向下推进的,从高地址往低地址推进,与正常的内存访问方向相反,所以要先压入起始的逻辑扇区号,最后压入的是当前地址结构的尺寸。
图3-3 BIOS扩展硬盘读和相关的地址结构
内核加载器在硬盘上的起始逻辑扇区号是LDR_START_SECTOR,这是一个常量,在全局定义文件global_defs.wid里被定义为数值1,即,内核加载器起始于硬盘的逻辑1扇区。定义常量有一个好处,那就是,如果将来改变了常量的数值,其他程序文件不需要修改就能使用修改后的数值。
再回到主引导程序,地址结构中的起始逻辑扇区号是64位的,8字节,但是实地址模式下没有指令可以操作64位数据,只能按两个32位进行操作,所以是压入两个双字(第16~17行)。x86处理器是低端字节序的,所以先压入高双字。内核加载器在硬盘上的位置比较靠前,可以将它的高32位看成零,所以我们先压入一个双字长度的0,再压入双字长度的LDR_START_SECTOR。两条push指令执行后,它们合起来组成一个64位的数值。
缓冲区的逻辑段地址是用表达式LDR_PHY_ADDR >> 4指定的,LDR_PHY_ADDR是在文件global_defs.wid中定义的常量,等于0x0000F000,是用于安装内核加载器的起始物理地址。如果你对这个地址感到茫然,可以看一下图3-4,这是本书所使用的内存布局。显然,这个物理地址位于物理内存的低端1MB范围内。从这个地址开始一直到0x10000的64KB先用于安装内核加载器的代码,内核加载器的代码用于加载内核,等内核加载完成后,这段代码就没用了,后续用于安装多处理器初始化代码。这个物理地址是按16字节对齐的,因为只有这样的物理地址才能转换为逻辑段地址来用。两个大于号“>>”是比特右移运算符,它将LDR_PHY_ADDR右移4次,也就是向右移动4比特。你可以想象一下,假定我们是把常量LDR_PHY_ADDR装入一个32位的寄存器,然后右移4次,右边挤出来的比特被丢弃,左边空出来的比特用0来填充。因为常量LDR_PHY_ADDR是一个物理地址,所以,右移4次,其结果是得到了一个实地址模式下的逻辑段地址。
需要特别强调的是,这个表达式是在程序编译时计算的,而不是在指令执行时计算的,这一点需要注意。换句话说,第18行实际上压入一个16位的立即数,这个立即数是在编译期间用这个表达式计算出来的。
图3-4 本书的整体内存布局
实际上,像“>>”这样的运算符NASM还有很多,分别用于编译时的数值运算。具体都还有哪些运算符,请参考NASM的手册,它是一个PDF文件,在NASM的安装目录下。
在主引导程序中,第20行,压入数字1,也就是读取一个扇区。为什么是一个扇区呢?是我们规定内核加载器只占用一个扇区吗?
不是的,内核加载器程序的长度原则上不作限制,我们的用意是先读取一个扇区,然后从这个扇区中获得内核加载器程序的实际长度。
来看一下内核加载器程序c03_ldr.asm,这个程序一开始就定义了很多数据。在它的起始处,也就是程序内偏移量为0的地方,是一个双字,填充的是4个字符“lizh”。这是一个标志,表明内核加载器是有效的。就像主引导扇区的有效标志0x55和0xAA一样,我们只知道内核加载器程序开始于硬盘的逻辑1扇区,但是,它有没有真的被写入硬盘,有没有真的被写入这个地方,需要判断一下,免得我们傻傻地以为读出的内容一定就是内核加载器程序。为此,我们设置了这个标志,用来确保读出的内容确实是内核加载器程序。
在偏移为4的地方,是内核加载器程序的长度,以字节计,它取自内核加载器程序的最后一个标号ldr_end,是这个标号的汇编地址。因为它是本程序最后一个标号,所以它的汇编地址就是本程序的长度。
再回到主引导程序,在这里,我们的用意是先读取第一个扇区,从中取出内核加载器程序的总长度,就知道到底要读几个扇区才能全部读完。
地址结构是在栈中构造的,段寄存器SS专门用来访问栈段,但BIOS例程要求我们用数据段寄存器DS和通用寄存器SI指向这个地址结构。这个问题其实不难。不管是栈段还是普通的数据段,本质上都一样,都是内存里的一段空间,只是访问的方式不同。
栈是从高地址方向往低地址方向推进的,也就是从上往下推进。当我们用压栈指令创建地址结构后,栈指针寄存器SP指向地址结构的起始处。对于栈段来说,SP指向栈顶,而对于数据段来说,SP指向地址结构的起始处。访问普通的数据段不能用SP,所以,第22行,我们将SP的值传送给SI,以后就用SI充当段内偏移量来访问这个地址结构。
第25行,用int指令发出0x13号软中断,进入BIOS例程,执行磁盘扩展读。BIOS例程使用我们指定的参数读硬盘,读取一个扇区,然后传送到指定的位置。读磁盘可能会失败,比如读一个不存在的磁盘,或者读的是一个老式的软盘驱动器,但驱动器里没有插入盘片。
无论如何,当中断返回后,如果读写成功,则标志寄存器的进位标志CF清零,而且通用寄存器AH的值为0;否则,进位标志CF被置位,而且在AH里包含了错误代码。错误代码是一个数字,用来表示出现错误的原因。对于我们当前的目标来说,如果读硬盘失败,那就没有选择,只能显示错误信息,然后停机。同样是为了节省主引导扇区空间,我们采用BIOS提供的字符串显示功能来显示错误信息。
BIOS服务并不是本章和本书的重点,它很古老,而且只能在实地址模式下调用,不值得我们过多地了解。我们只需要知道,这个字符串显示功能通过软中断0x10进入,功能号为0x13,通过AH传入。如果寄存器AL是1,意味着带光标跟随的写,颜色属性通过寄存器BL来指定;寄存器BH用于指定页号,在文本模式下直接设置为0即可;寄存器BL用来指定颜色属性,它决定了文本的前景色和背景色;寄存器CX用来指定字符串的长度;DH和DL用来指定起始的坐标,DH用来指定行号,DL用来指定列号;字符串的位置用段寄存器ES和通用寄存器BP来指定,分别是字符串所在的逻辑段地址和段内偏移量。
回到主引导程序中,错误信息是在第93行定义的,是字符串“Disk error.”,以及回车和换行。显然,用标号msg1减去msg0,就得到了这个字符串的长度,也就是字节数或者字符数。
第26行,我们将标号msg0代表的汇编地址传送到寄存器BP,这是字符串所在的段内偏移量。主引导程序所在的段地址和偏移量分别是0x0000和0x7C00,而这个字符串在这个段内的偏移量是多少呢?当然是0x7C00加上该字符串相对于主引导程序起始处的偏移量,在数值上等于msg0的汇编地址。但是注意了,主引导程序被定义为一个段,而且在段定义中有vstart=0x7c00子句。这意味着,段内所有标号的汇编地址都是相对于段的起始处,从0x7C00开始计算,而不是从0开始计算的。所以,标号msg0所代表的汇编地址是从0x7C00开始累计的,本身就代表它实际的段内偏移量。
第27行,我们用标号msg1减去msg0,得到字符串的长度,并传送到寄存器DI。按照要求,应该是传送到寄存器CX,但是CX在后面会被占用,所以临时用DI保存。
紧接着,第28行jc指令判断标志寄存器的进位标志CF,如果进位,则表明磁盘读写失败,转到标号go_err处执行,显示错误信息并停机,因为这种错误需要重置磁盘驱动器并重新开机。为了显示错误信息,需要先用另一个BIOS中断服务来获取光标位置。
要返回光标位置,可以通过0x10号软中断调用BIOS服务,功能号是3,通过寄存器AH传入。同时,还需要用BH指定页号,文本模式下,将页号设置为0就可以了。在标准的VGA文本模式下,每屏可以显示25行文本,每行可以显示80个字符,BIOS例程用DH和DL返回光标在文本方式下的行列位置,用CH和CL返回光标所在扫描线的开始和结束位置。
回到程序中,第79~81行用于返回光标当前所在的位置。我们只使用DH和DL中返回的文本行和文本列,不使用CH和CL返回的数值。
紧接着,第83行,我们将DI中的字符串长度传送到CX。从这里就可以看出为什么要用DI来传递字符串长度,而不是使用CX,因为CX会被刚才的BIOS服务例程破坏。
最后,我们设置BH和BL,然后调用BIOS服务例程显示字符串。为字符指定的颜色数值是0x07,也就是黑底白字。显示错误信息之后,用cli指令关闭外部硬件中断,然后用hlt指令停机。
按照预先确定的程序执行流程,如果读磁盘成功则继续往下执行,开始判断读出的内容里有没有加载器有效标志,从这个标志可以知道加载器程序是否真的存在于硬盘,是否真的已经写入硬盘。
现在,我们要访问内核加载器所在的段,它的物理地址是LDR_PHY_ADDR,这个地址是16字节对齐的,将它右移4位,就得到了逻辑段地址,内核加载器的有效标志就位于这个段内偏移为0的地方。
为此,第30~33行,我们先将段寄存器DS压栈保存,然后将LDR_PHY_ADDR右移4次,得到逻辑段地址,再传送到DS,这就将DS切换到内核加载器所在的段。
在内核加载器里,偏移为0的地方是有效标志“lizh”,4字节,可以用cmp指令执行一个双字长度的比较操作(第35行)。在这条指令中,源操作数是一个字符常量。这应该是我们第一次在指令中使用字符常量,在程序编译时,这4个字符的编码被转换为一个双字参与比较。
如果比较的结果是不相等,则用jnz指令转到标号go_err处执行。在此之前的第36和37行已经把错误信息的偏移地址传送到BP,把字符串的长度传送到DI。错误信息来自标号msg1,它位于主引导程序的后面。和前面的msg0一样,标号msg1代表的偏移地址是相对于主引导程序的起始处,并且是从0x7C00开始累计的。
如果成功检测到内核加载器的有效标志,则继续往下执行,第41~44行判断整个内核加载器有多大,有多少字节。我们目前只是读取了一个扇区,所以需要知道接下来还得读取几个扇区才能把内核加载器程序完全装入内存。
来看一下内核加载器程序c03_ldr.asm,在这个文件内偏移为4的位置记录了内核加载器的总大小,以字节计,它取自当前文件的最后一个标号ldr_end,我们前面讲过,这里不再多说。
回到主引导程序c03_mbr.asm,第41行访问数据段,从段内偏移为4的地方取出内核加载器程序的总字节数并传送到EAX。接下来,第43行将它除以512,得到内核加载器占用的扇区数,512是每个扇区的字节数。我们采用64位除法,按照处理器的要求,被除数在EDX和EAX中,EDX是被除数的高32位,EAX是被除数的低32位。内核加载器程序的总长度在EAX,我们只需要将EDX清零即可(第42行)。
准备好了被除数,第43~44行将512传送到ECX中作为除数,然后做除法。这将在EAX中得到商,也就是扇区数。但是,这个扇区数可能比实际的扇区数少一,因为内核加载器程序的长度不见得一定是512的整数倍,这个除法可能会有余数,余数在EDX中。为此,第46行用or指令检查EDX是否为零。如果EDX不为零,则我们求出来的扇区数实际上少了一个。但是我们在前面已经读了一个,正好抵消,EAX的值就是我们还要再继续读取的扇区数,所以转到标号@1处执行。
如果EDX的值为零,则意味着内核加载器的总大小正好是512的倍数。但是前面已经读过一个扇区,所以还必须用dec指令将EAX减一才能得到还需要读取的扇区数,然后也转到标号@1处执行。
无论如何,一旦程序的执行到达标号@1处,EAX的值就是我们还要继续读取的扇区数。这条or指令判断EAX的值是否为零。如果为零,说明内核加载器程序很小,只占用一个扇区,而且我们已经读过了,可直接转到标号go_ldr处执行。在那里,我们将处理器的控制权交给内核加载器,也就是进入内核加载器执行。
相反,如果EAX的值不为零,则我们继续读取内核加载器程序的剩余部分。读扇区还是要调用BIOS扩展磁盘读,还需要使用前面定义的地址结构。这个地址结构是在栈中构造的,但可以使用数据段来访问。为此,第54行的pop指令弹出我们原先的数据段地址到DS中,地址结构就位于DS指向的段中,段内偏移量在寄存器SI中,这是我们在前面设置的,一直没有改变过。
由于寄存器SI指向这个地址结构,那么,在si+2的地方是本次传输的扇区数,第56行将它修改为内核加载器剩余的扇区数。剩余的逻辑扇区数在EAX中,但实际上它的数值很小,所以只使用AX就行。
在si+4的地方是数据缓冲区的段内偏移。因为已经读了一个扇区,所以第57行将这个地址修改为512。实际上,这条mov指令也可以改成加法指令add,直接将512加到原先的段内偏移量上。
在si+8的地方是起始的逻辑扇区号,因已经读了一个扇区,所以第58行将起始的逻辑扇区号加一(采用inc指令),这是我们下一次读硬盘时所用的起始逻辑扇区号。
最后,第59~61行,在AH里设置功能号,在DL里设置硬盘编号,发出0x13号软中断,进入BIOS例程读磁盘并传送数据到指定位置。
第63~65行,和前面一样,如果读磁盘失败,则转到标号go_err这里执行,显示错误信息并停机;如果读磁盘成功,则继续往下执行,到达标号go_ldr这里。
在标号go_ldr这里,我们调整栈指针到最开始的位置0x7c00,手工恢复栈平衡。内核加载器已经位于内存中,它的物理地址是LDR_PHY_ADDR。第70~72行,我们将它右移4位生成逻辑段地址,并传送到DS和ES。
再来看一下内核加载器程序c03_ldr.asm,在它内部偏移为8的地方保存着入口点的位置信息,它取自标号start,是标号start代表的汇编地址。该标号位于段loader内,而且在这个段的定义中没有vstart子句,所以,标号start代表的汇编地址是它相对于程序起始处的距离。
回到主引导程序c03_mbr.asm。
将内核加载器读入内存之后,我们将离开主引导程序,进入内核加载器执行。为此可以使用跳转指令,但我们用的是一个非常规的方法,那就是使用retf指令以远过程返回的方式进入内核加载器。在实地址模式下,远过程返回是处理器自动从栈中弹出偏移地址到指令指针寄存器IP,从栈中弹出逻辑段地址到代码段寄存器CS来完成转移。
为此,第74行,我们先压入段寄存器DS的内容,这就是内核加载器所在的段;然后,第75行访问这个段,从偏移为8的地方取出内核加载器的入口点并压入栈中。
第76行的retf指令执行时,处理器自动从栈中弹出刚才压入的入口点地址到指令指针寄存器IP;接着又弹出我们刚才压入的段地址到代码段寄存器CS,从而转移到目标位置执行,也就是进入内核加载器执行。