第 3 章 BootLoader引导启动程序

第 3 章 BootLoader引导启动程序

本章将采用一种简洁、高效的开发方式对BootLoader引导启动程序进行讲解,进而将BootLoader引导启动程序的整体概貌展现在读者面前,然后在高级篇里对BootLoader引导启动程序的更多技术细节再做进一步解释。

BootLoader引导启动程序原本由Boot引导程序和Loader引导加载程序两部分构成。Boot引导程序主要负责开机启动和加载Loader程序;Loader引导加载程序则用于完成配置硬件工作环境、引导加载内核等任务。

从这一章开始,将正式进入操作系统开发环节。话不多说,精彩即刻开始!

3.1 Boot引导程序

计算机上电启动后,首先会经过BIOS上电自检,这个过程BIOS会检测硬件设备是否存在问题。如果检测无误的话,将根据BIOS的启动项配置选择引导设备,目前BIOS支持的设备启动项有软盘启动、U盘启动、硬盘启动以及网络启动。通常情况下,BIOS会选择硬盘启动作为默认启动项,但从简单和易实现等角度来看,还是选择最为简单的软盘启动。关于U盘启动技术将在后续的高级篇中予以讲解和实现。

为了让读者阅读起来更有动力,此处先把运行效果展示出来,然后再逐步编码实现。图3-1便是Boot引导程序的最终运行效果图。

{%}

图3-1 Boot运行效果图

在图3-1的左上角处不难发现,除软盘A的图标显示有软盘存在外,软盘B、光驱、鼠标等设备均处于未连接状态,这正是我们在虚拟平台环境配置文件bochsrc里设置的软盘启动。屏幕中的Start Boot.....是程序打印在屏幕上的日志信息。至于为什么要显示这些内容,请读者在阅读完本节内容后自己找出答案。

3.1.1 BIOS引导原理

为什么所有操作系统都从Boot引导程序开始?

这个问题要追溯到BIOS自检设备开始。当BIOS自检结束后会根据启动选项设置(这里指软驱启动)去选择启动设备,即检测软盘的第0磁头第0磁道第1扇区,是否以数值0x550xaa两字节作为结尾。如果是,那么BIOS就认为这个扇区是一个Boot Sector(引导扇区),进而把此扇区的数据复制到物理内存地址0x7c00处,随后将处理器的执行权移交给这段程序(跳转至0x7c00地址处执行)。图3-2展示了软盘的磁盘结构。

图3-2 软盘的磁盘结构图

从图3-2可知,软盘的第0磁头第0磁道第1扇区实则是软盘的第一个扇区。对于一张3.5英寸的1.44 MB软盘而言,一个扇区的容量仅有512 B,而且BIOS只负责加载这一个扇区的数据到物理内存中,一个容量只有512 B的引导扇区是无法容纳操作系统的,甚至连获取硬件信息的检测程序都容纳不下。鉴于如此苛刻的容量限制,Boot引导程序仅能作为一级助推器,将功能更强大的引导加载程序Loader装载到内存中,这也可以看做是硬件设备向软件移交控制权。一旦Loader引导加载程序开始执行,那么一切都交由我们编写的软件来控制。

引导扇区里的程序自然应该叫作引导程序。在BIOS向引导程序移交执行权之前,BIOS会对处理器进行初始化,这其中就包括处理器的代码段寄存器CS和指令指针寄存器IP。当BIOS跳转至引导程序时,CS寄存器和IP寄存器的值分别为0x00000x7c00。此时的处理器正处于实模式下,物理地址必须经过CS寄存器和IP寄存器转换才能得到,转换公式为:物理地址= CS << 4 + IP,也就是物理地址0x7c00处。

因为引导程序只能装在一个扇区里,还要以0x550xaa作为结束标识数据,那么引导程序的有效数据长度为512 B - 2 B,即510 B。这510 B虽然足够写一个加载Loader的汇编程序,但以何种形式存储和加载Loader,却是一个需要认真考虑的问题。

如果用直接写入到固定扇区的方法加载Loader,那么以后的内核程序也需要指定固定的扇区来加载。

如果将Loader引导加载程序直接保存到固定扇区中,那么今后的内核程序也必须使用固定扇区来加载。这种方法的好处是,Boot引导程序的加载代码会比较容易实现,只明确Loader引导加载程序的起始磁头号、磁道号、扇区号和所占扇区块数,即可将其加载到内存中。即使待加载的扇区在物理上或者逻辑上是不连续的,也没有问题。该方法同样可以应用到Loader程序加载系统内核中。但是伴随着程序代码量的不断增加,Loader和内核对扇区的需求量也会日益增长,每次向存储介质(包括软盘、硬盘、U盘等)写入Loader与内核,都要重新计算它们的起始扇区和占用扇区数。而且,对于每次修改完程序急于看到执行结果的我们来说,此种做法会变得越来越不方便。如果还希望加载开机画面、系统服务等诸多文件和程序,该种方法势必会带来等多不便,同时也会大量修改Boot和Loader代码。

分析出上述弊端因素后,与其每次都调整Boot和Loader程序,倒不如采用一次性投资终生受益的方法,即为软盘创建文件系统。

我知道你们想说什么,一开始就讲文件系统会不会太难了些?

其实,一个简单的文件系统并没有想象中那么复杂,像FAT12/16这类软盘型文件系统还是非常简单易懂的,仅需一些简单的逻辑即可实现FAT12文件系统。也正因为这类文件系统逻辑简单、容易实现,所以将FAT12文件系统作为软盘文件系统以及后续的U盘文件系统再合适不过了。

接下来将结合代码实现,分片段讲解Boot引导程序各实现环节的关键技术点。

3.1.2 写一个Boot引导程序

下面就以一个简单的引导程序为例,拉开操作系统开发的序幕。这个引导程序采用Intel汇编语言格式编写,编译代码使用的编译器为NASM,它的功能并不复杂,只为在屏幕上显示一条日志信息。以此开头,一来可以让初学者对汇编程序有所熟悉,二来作为本书的第一个程序,先让读者热热身。代码清单3-1是引导程序的寄存器初始化部分。

代码清单3-1 第3章\程序\程序3-1\boot.asm

    org    0x7c00

BaseOfStack    equ    0x7c00

Label_Start:

    mov    ax,    cs
    mov    ds,    ax
    mov    es,    ax
    mov    ss,    ax
    mov    sp,    BaseOfStack

在这段程序中,org是Origin的英文缩写,意思为起始地址或源地址。这条伪指令用于指定程序的起始地址,若程序未使用org伪指令,那么编译器会把地址0x0000作为程序的起始地址。程序的起始地址将主要影响绝对地址寻址指令,不同的起始地址会编译生成不同的绝对地址。因此,代码org 0x7c00的意思是,将程序的起始地址设置在0x7c00处。至于为什么是0x7c00,想必只有当年的BIOS工程师们才会知道。既然BIOS会加载引导程序至内存地址0x7c00处,我们就必须将引导程序的起始地址设置在此处,否则当程序访问绝对地址时很可能会出错。

再看下一条汇编代码BaseOfStack equ 0x7c00,这是一条等价语句,它将标识符BaseOfStack等价为数值0x7c00。其中,equ伪指令的作用是,让其左边的标识符代表右边的表达式。equ等价语句不会给标识符分配存储空间,而且标识符不能与其他符号同名,也不能被重新定义。equ不光可以代表常量和表达式,也可以代表字符串以及一些助记符。代码中的标识符BaseOfStack用于为栈指针寄存器SP提供栈基址。其实BIOS并未要求栈基址必须设置在0x7c00地址处,而且Boot引导程序极少涉及栈操作,因此读者不必担心栈溢出问题。最后几条指令则是将CS寄存器的段基地址设置到DS、ES、SS等寄存器中,以及设置栈指针寄存器SP。

代码清单3-2是引导程序的主体代码,它的功能并不复杂,主要是通过BIOS中断服务程序INT 10h实现屏幕信息显示相关操作。INT 10h中断服务程序要求在调用时,必须向AH寄存器传入服务程序的主功能编号,再向其他寄存器传入参数。以下是完整程序实现。

代码清单3-2 第3章\程序\程序3-1\boot.asm

;=======   clear screen

    mov    ax,    0600h
    mov    bx,    0700h
    mov    cx,    0
    mov    dx,    0184fh
    int    10h
;=======    set focus

    mov    ax,    0200h
    mov    bx,    0000h
    mov    dx,    0000h
    int    10h
;=======   display on screen : Start Booting......

    mov    ax,    1301h
    mov    bx,    000fh
    mov    dx,    0000h
    mov    cx,    10
    push   ax
    mov    ax,    ds
    mov    es,    ax
    pop    ax
    mov    bp,    StartBootMessage
    int    10h

这段代码使用BIOS中断服务程序INT 10h的主功能编号有06h、02h和13h,它们的功能及参数说明如下。

  • 设置屏幕光标位置

    BIOS中断服务程序INT 10h的主功能号AH=02h可以实现屏幕光标位置的设置功能,具体寄存器参数说明如下。

    INT 10h,AH=02h功能:设定光标位置。

    • DH=游标的列数;
    • DL=游标的行数;
    • BH=页码。

    这条语句的目的是,将屏幕的光标位置设置在屏幕的左上角(0,0)处。不论是行号还是列号,它们皆从0开始计数,屏幕的列坐标0点和行坐标0点位于屏幕的左上角,纵、横坐标分别向下和向右两个方向延伸,或者说坐标原点位于屏幕左上角。

  • 上卷指定范围的窗口(包括清屏功能)

    BIOS中断服务程序INT 10h的主功能号AH=06h可以实现按指定范围滚动窗口的功能,同时也具备清屏功能,具体寄存器参数说明如下。

    INT 10h,AH=06h功能:按指定范围滚动窗口。

    • AL=滚动的列数,若为0则实现清空屏幕功能;
    • BH=滚动后空出位置放入的属性;
    • CH=滚动范围的左上角坐标列号;
    • CL=滚动范围的左上角坐标行号;
    • DH=滚动范围的右下角坐标列号;
    • DL=滚动范围的右下角坐标行号;
    • BH=颜色属性。
      • bit 0~2:字体颜色(0:黑,1:蓝,2:绿,3:青,4:红,5:紫,6:综,7:白)。
      • bit 3:字体亮度(0:字体正常,1:字体高亮度)。
      • bit 4~6:背景颜色(0:黑,1:蓝,2:绿,3:青,4:红,5:紫,6:综,7:白)。
      • bit 7:字体闪烁(0:不闪烁,1:字体闪烁)。

    这条命令主要用于按指定范围滚动窗口,但是如果AL=0的话,则执行清屏功能。在使用清屏功能时(AL寄存器为0),其他BX、CX、DX寄存器参数将不起作用,读者无需纠结它们的数值。

  • 显示字符串

    BIOS中断服务程序INT 10h的主功能号AH=13h可以实现字符串的显示功能,具体寄存器参数说明如下。

    INT 10h,AH=13h 功能:显示一行字符串。

    • AL=写入模式。
      • AL=00h:字符串的属性由BL寄存器提供,而CX寄存器提供字符串长度(以B为单位),显示后光标位置不变,即显示前的光标位置。
      • AL=01h:同AL=00h,但光标会移动至字符串尾端位置。
      • AL=02h:字符串属性由每个字符后面紧跟的字节提供,故CX寄存器提供的字符串长度改成以Word为单位,显示后光标位置不变。
      • AL=03h:同AL=02h,但光标会移动至字符串尾端位置。
    • CX=字符串的长度。
    • DH=游标的坐标行号。
    • DL=游标的坐标列号。
    • ES:BP=>要显示字符串的内存地址。
    • BH=页码。
    • BL=字符属性/颜色属性。
      • bit 0~2:字体颜色(0:黑,1:蓝,2:绿,3:青,4:红,5:紫,6:综,7:白)。
      • bit 3 :字体亮度(0:字体正常,1:字体高亮度)。
      • bit 4~6:背景颜色(0:黑,1:蓝,2:绿,3:青,4:红,5:紫,6:综,7:白)。
      • bit 7:字体闪烁(0:不闪烁,1:字体闪烁)。

    字符串的显示功能,算是BIOS中断服务程序中使用比较频繁的功能。该功能不仅可以显示字符串、设定字体的前景色和背景色,还可以设置待显示字符串的坐标位置。此功能非常适合显示不同的日志信息,因此在后续开发过程中将会多次使用到。

上述程序已经完成了引导程序的日志信息显示工作。接下来,再让我们看看BIOS中断服务程序是如何操作磁盘驱动器的,请见代码清单3-3。

代码清单3-3 第3章\程序\程序3-1\boot.asm

;=======   reset floppy

    xor    ah,    ah
    xor    dl,    dl
    int    13h

    jmp    $

这段汇编代码实现了软盘驱动器的复位功能,它相当于重新初始化了一次软盘驱动器,从而将软盘驱动器的磁头移动至默认位置。整个复位过程是通过BIOS中断服务程序INT 13h的主功能号AH=00h实现的,具体寄存器参数说明如下。

INT 13h,AH=00h 功能:重置磁盘驱动器,为下一次读写软盘做准备。

  • DL=驱动器号,00H~7FH:软盘;80H~0FFH:硬盘。
    • DL=00h代表第一个软盘驱动器(“drive A:”);
    • DL=01h代表第二个软盘驱动器(“drive B:”);
    • DL=80h代表第一个硬盘驱动器;
    • DL=81h代表第二个硬盘驱动器。

这段代码并无特别用意,只是为了让初学者多了解一些BIOS中断服务程序,以应对后续的开发需求。代码清单3-4是Boot引导程序的剩余代码,其中定义了一个字符串和引导程序的结束标识数据等内容,详情如下。

代码清单3-4 第3章\程序\程序3-1\boot.asm

StartBootMessage:    db    "Start Boot"

;=======    fill zero until whole sector

    times    510 - ($ - $$)    db    0
    dw    0xaa55

剩余代码比较好理解,它首先定义一个字符串"Start Boot",并取名为StartBootMessage,汇编代码StartBootMessage:db "Start Boot"可以理解为C语言中的一维字符数组,而标识符StartBootMessage可以看作数组名。

汇编代码times 510 - ($ - $$) db 0在2.2.2节中曾经介绍过,其中,表达式$ - $$的意思是,将当前行被编译后的地址(机器码地址)减去本节(Section)程序的起始地址。由于Boot引导程序只有一个以0x7c00为起始地址的节,那么表达式$ - $$的作用是计算出当前程序生成的机器码长度,进而可知引导扇区必须填充的数据长度(510 - ($ - $$))。又因为软盘是个块设备,访问块设备的特点是每次必须以扇区为单位(512 B),而times伪指令恰好可以实现多次重复操作,所以这行汇编代码的目的是,通过times伪指令填充引导扇区剩余空间,以保证生成的文件大小为512 B。

最后,再将一个字(0xaa55)填充到程序的末尾。此处请注意,在3.1.1节中已经提到引导扇区是以0x550xaa两个字节作为结尾,由于Intel处理器是以小端模式存储数据,那么用一个字表示0x550xaa就应该是0xaa55,这样它在扇区里的存储顺序才是0x550xaa

3.1.3 创建虚拟软盘镜像文件

现在就来看看引导程序的运行效果,我们还需要一个软盘。尽管软盘和软盘驱动器已经退出历史舞台,好在绝大多数虚拟机软件都提供虚拟软盘驱动器和创建虚拟软盘镜像的功能,Bochs虚拟机也不例外。那么现在就告诉大家如何使用Bochs虚拟机自带工具创建虚拟软盘镜像。

其实按照2.1.3节描述的Bochs虚拟机环境搭建步骤编译Bochs虚拟机源代码后,不仅会生成Bochs虚拟机软件,还会生成一些辅助工具,这其中就包括虚拟磁盘镜像创建工具bximage

bximage工具(命令)的使用方式非常简单,仅需向命令行终端键入命令bximage,再按照bximage命令的提示内容进行选择,便可顺利创建出虚拟软盘镜像文件。而且,还可以通过bximage命令创建虚拟硬盘镜像文件。以下是使用bximage命令创建虚拟软盘镜像的一个实例:

[root@localhost 1]# bximage
========================================================================
bximage
  Disk Image Creation / Conversion / Resize and Commit Tool for Bochs
         $Id: bximage.cc 12364 2014-06-07 07:32:06Z vruppert $
========================================================================

1. Create new floppy or hard disk image
2. Convert hard disk image to other format (mode)
3. Resize hard disk image
4. Commit 'undoable' redolog to base image
5. Disk image info

0. Quit

Please choose one [0] 1

向终端命令行键入bximage即可出现上述信息。键入数字1,将进入虚拟软盘镜像或虚拟硬盘镜像的创建流程。从提示内容的字面意思可知,其他选项与虚拟软盘镜像的创建过程无关,故此就不过多介绍了,感兴趣的读者可以自行研究。

当执行1选项后,将会提示用户选择创建磁盘镜像的种类。目前只有软盘和硬盘两种类型,以下是详细提示信息:

Create image

Do you want to create a floppy disk image or a hard disk image?
Please type hd or fd. [hd] fd

如果希望创建虚拟软盘镜像则键入fd;如果希望创建虚拟硬盘镜像就键入hd。当键入fd后,将会进入软盘容量选择步骤,1.44 MB是软盘的默认容量。这个容量对应的是最通用的3.5英寸软盘,也是本次开发选用的软盘类型:

Choose the size of floppy disk image to create, in megabytes.
Please type 160k, 180k, 320k, 360k, 720k, 1.2M, 1.44M, 1.68M, 1.72M, or 2.88M.
 [1.44M]

在确定软盘容量后,还要为虚拟软盘镜像文件命名,镜像文件的默认名为a.img,这里将本系统的虚拟软盘镜像文件命名为boot.img。当确认镜像文件名后,bximage工具会显示出虚拟软盘的总扇区数等信息:

What should be the name of the image?
[a.img] boot.img

Creating floppy image 'boot.img' with 2880 sectors

The following line should appear in your bochsrc:
floppya: image="boot.img", status=inserted

最后一行提示信息floppya: image="boot.img", status=inserted,告诉用户如何把镜像文件加入到虚拟平台环境配置信息里,其中status是虚拟软盘镜像的状态,inserted的意思是已将虚拟软盘插入到虚拟软盘驱动器中。

bximage工具不仅可以创建虚拟磁盘镜像文件,还可以查看虚拟磁盘镜像文件的硬件配置信息,如镜像类型、磁盘容量、磁头数、磁道数以及扇区数等,详见下面这个例子:

[root@localhost 1]# bximage
========================================================================
bximage
  Disk Image Creation / Conversion / Resize and Commit Tool for Bochs
         $Id: bximage.cc 12364 2014-06-07 07:32:06Z vruppert $
========================================================================

1. Create new floppy or hard disk image
2. Convert hard disk image to other format (mode)
3. Resize hard disk image
4. Commit 'undoable' redolog to base image
5. Disk image info

0. Quit

Please choose one [0] 5

键入数字5,进入磁盘信息查看功能,以下是进入该功能后的提示信息:

Disk image info

What is the name of the image?
[c.img] boot.img

此时,输入之前创建好的虚拟软盘镜像文件名boot.img,随后便会显示虚拟软盘的硬件配置信息:

disk image mode = 'flat'
hd_size: 1474560
geometry = 2/16/63 (1 MB)

以上硬盘配置信息显示了磁盘镜像文件类型:“软盘镜像”(flat),磁盘镜像文件大小1474560 B,并描述虚拟软盘拥有2个磁头、64个磁道、16个扇区。

特别注意,正常3.5英寸软盘的容量是1.44 MB=1440×1024 KB=1474560 B,软盘共包含2个磁头、80个磁道、18个扇区。此处的bximage工具只正确解析出虚拟磁盘容量是1474560 B,而对磁道数和扇区数的计算有误,按照每扇区容量512 B来计算,该虚拟软盘的总容量是2 × 16 × 64 × 512 = 1048576 B = 1 MB,少了425984 B = 832 × 512 B。因此推断bximage工具是按照1 MB软盘容量进行计算的,而非1.44 MB磁盘容量。可见bximage工具还存在许多bug有待更正。

3.1.4 在Bochs上运行我们的Boot程序

经过引导程序的编写和虚拟软盘镜像文件的创建工作后,现在是时候让引导程序在虚拟机中运行了。

首先来编译3.1.2节撰写的引导程序。此前已经说明过,引导程序会使用NASM编译器进行编译,nasm命令是该编译器提供的编译工具。由于nasm命令的参数甚多无法逐个介绍,本着现学现用的原则,此处仅对即将使用的-o参数予以介绍。nasm命令编译汇编文件的编译格式为:nasm 汇编语言源文件名 -o 目标程序名。其中的参数-o指定编译后的输出文件名,以下是编译引导程序使用的编译命令:

nasm boot.asm -o boot.bin

编译结束后,便可将生成的二进制程序文件写入到虚拟软盘镜像文件内。请注意,此处说的是写入到虚拟软盘镜像文件内,而不是复制到虚拟软盘镜像文件内。由于引导程序被强行写入到虚拟软盘的第一个扇区中,这个引导扇区写入过程并不属于文件系统管理范畴。如果使用“复制”一词将会牵涉文件系统的操作,感觉描述不恰当,而用“写入”一词相对更合理些。

说到这里,我们将会使用dd命令把引导程序强制写入到虚拟软盘的固定扇区中,这种强制写入固定扇区的方法能够跳过文件系统的管理与控制,转而直接操作磁盘扇区。话不多说,下面就来看看如何使用dd命令把引导程序强制写入到引导扇区中:

dd if=boot.bin of=../../bochs-2.6.8/boot.img bs=512 count=1 conv=notrunc

这行命令中的if = boot.bin指定输入源文件名,而of=../../bochs-2.6.8/boot.img则指定输出文件名,参数bs=512指定传输的块大小为512 B,参数count=1指定写入到目标文件的块数量,参数conv=notrunc规定在写入数据后不截断(改变)输出文件的尺寸大小,以下是此条命令的执行日志信息:

[root@localhost 1]# dd if=boot.bin of=../../bochs-2.6.8/boot.img bs=512 count=1 conv=notrunc
1+0 records in
1+0 records out
512 bytes (512 B) copied, 0.000155041 s, 3.3 MB/s

从本章开始至此,经过多个环节的实现与说明,终于到了收获的时刻。参照如下bochs命令启动虚拟机,其中的参数-f ./bochsrc指定虚拟机环境配置文件的路径名:

[root@localhost bochs-2.6.8]# bochs -f ./bochsrc

特别注意,在运行虚拟机之前,请确保虚拟软盘镜像文件boot.img、虚拟平台环境配置文件bochsrc以及其他文件的路径一定要正确,否则虚拟机将无法正常运行。

如果一切运行顺利,当Bochs虚拟机启动后,将会在终端命令行中显示一个文字选项界面,界面显示的文字信息如下所示:

You can also start bochs with the -q option to skip these menus.

1. Restore factory default configuration
2. Read options from...
3. Edit options
4. Save options to...
5. Restore the Bochs state from...
6. Begin simulation
7. Quit now

Please choose one: [6]

当这个文字界面出现后,默认执行选项是数字6(表示开始运行虚拟机),其他选项则与虚拟平台的环境配置有关。因为在执行bochs命令时,已经为虚拟平台明确指定了环境配置文件bochsrc,此处便无需再配置平台环境,直接运行虚拟机即可。图3-3是虚拟机启动后的图形界面效果。

{%}

图3-3 虚拟机启动界面

现在的虚拟机刚启动,它只完成了硬件平台的初始化,还未执行引导程序。此时的终端命令行会显示如下信息。只要在终端命令行中输入字符串c/cont/continue中的任意一种,即可使虚拟机运行。此刻,虚拟机开始执行Boot引导程序:

……
Next at t=0
(0) [0x0000fffffff0] f000:fff0 (unk. ctxt): jmpf 0xf000:e05b   ; ea5be000f0
<bochs:1> c

激动人心的时刻就要到了,是否准备好了?图3-4便是引导程序的运行效果。

{%}

图3-4 虚拟机启动界面

历经多节课程的学习,终于见到了运行效果。现在,这个操作系统已经成功地迈出了第一步。未来还有好多路要走,以后会逐渐加快前进的步伐。

3.1.5 加载Loader到内存

目前,操作系统已经实现了一个简单的引导程序,只需在此基础上加入文件加载功能,即可完成Boot引导程序的工作。

说到加载Loader程序,最理想的方法自然是从文件系统中把Loader程序加载到内存里。考虑到代码的易实现性,本操作系统将选用逻辑简单的FAT12文件系统来装载Loader程序和内核程序。

在将软盘格式化成FAT12文件系统的过程中,FAT类文件系统会对软盘里的扇区进行结构化处理,进而把软盘扇区划分成引导扇区、FAT表、根目录区和数据区4部分。

  • 引导扇区

    FAT12文件系统的引导扇区不仅包含有引导程序,还有FAT12文件系统的整个组成结构信息。这些信息描述了FAT12文件系统对磁盘扇区的管理情况,它相当于EXT类文件系统的superblock结构,但是与EXT类文件系统相比,FAT类文件系统的结构更简单、易于实现。表3-1便描述了FAT12文件系统的引导扇区结构。

    表3-1 FAT12文件系统引导扇区结构

    名称偏移长度内容本系统引导程序数据
    BS_jmpBoot03跳转指令jmp short Label_Start nop
    BS_OEMName38生产厂商名'MINEboot'
    BPB_BytesPerSec112每扇区字节数512
    BPB_SecPerClus131每簇扇区数1
    BPB_RsvdSecCnt142保留扇区数1
    BPB_NumFATs161FAT表的份数2
    BPB_RootEntCnt172根目录可容纳的目录项数224
    BPB_TotSec16192总扇区数2880
    BPB_Media211介质描述符0xF0
    BPB_FATSz16222每FAT扇区数9
    BPB_SecPerTrk242每磁道扇区数18
    BPB_NumHeads262磁头数2
    BPB_HiddSec284隐藏扇区数0
    BPB_TotSec32324如果BPB_TotSec16值为0,则由这个值记录扇区数0
    BS_DrvNum361int 13h的驱动器号0
    BS_Reserved1371未使用0
    BS_BootSig381扩展引导标记(29h)0x29
    BS_VolID394卷序列号0
    BS_VolLab4311卷标'boot loader'
    BS_FileSysType548文件系统类型'FAT12'
    引导代码62448引导代码、数据及其他信息
    结束标志5102结束标志0xAA550xAA55

    从表3-1中可以看出,在引导程序的起始处,首先定义的是BS_jmpBoot字段。从字面意思可知,它是一句跳转代码,这是由于BS_jmpBoot字段后面的数据不是可执行程序,而是FAT12文件系统的组成结构信息,故此必须跳过这部分内容。字段长度为3,说明汇编代码jmp short Label_Startnop经过编译后,一共生成三个字节的机器码,其中nop会生成一个字节的机器码,jmp short Label_Start会生成两个字节的机器码。

    • BS_OEMName。记录制造商的名字,亦可自行为文件系统命名。
    • BPB_SecPerClus。描述了每簇扇区数。由于每个扇区的容量只有512 B,过小的扇区容量可能会导致软盘读写次数过于频繁,从而引入簇(Cluster)这个概念。簇将2的整数次方个扇区作为一个“原子”数据存储单元,也就是说簇是FAT类文件系统的最小数据存储单位。
    • BPB_RsvdSecCnt。指定保留扇区的数量,此域值不能为0。保留扇区起始于FAT12文件系统的第一个扇区,对于FAT12而言此位必须为1,也就意味着引导扇区包含在保留扇区内,所以FAT表从软盘的第二个扇区开始。
    • BPB_NumFATs。指定FAT12文件系统中FAT表的份数,任何FAT类文件系统都建议此域设置为2。设置为2主要是为了给FAT表准备一个备份表,因此FAT表1与FAT表2内的数据是一样的,FAT表2是FAT表1的数据备份表。
    • BPB_RootEntCnt。指定根目录可容纳的目录项数。对于FAT12文件系统而言,这个数值乘以32必须是BPB_BytesPerSec的偶数倍。
    • BPB_TotSec16。记录着总扇区数。这里的总扇区数包括保留扇区(内含引导扇区)、FAT表、根目录区以及数据区占用的全部扇区数,如果此域值为0,那么BPB_TotSec32字段必须是非0值。
    • BPB_Media。描述存储介质类型。对于不可移动的存储介质而言,标准值是0xF8。对于可移动的存储介质,常用值为0xF0,此域的合法值是0xF00xF80xF90xFA0xFB0xFC0xFD0xFE0xFF。另外提醒一点,无论该字段写入了什么数值,同时也必须向FAT[0]的低字节写入相同值。
    • BPB_FATSz16。记录着FAT表占用的扇区数。FAT表1和FAT表2拥有相同的容量,它们的容量均由此值记录。
    • BS_VolLab。指定卷标。它就是Windows或Linux系统中显示的磁盘名。
    • BS_FileSysType。描述文件系统类型。此处的文件系统类型值为'FAT12 ',这个类型值只是一个字符串而已,操作系统并不使用该字段来鉴别FAT类文件系统的类型。

    依据表3-1提供的文件系统结构信息,可将软盘扇区描绘成如图3-5所示的结构。

    图3-5 软盘文件系统分配图

    以上就是FAT12文件系统引导扇区结构的介绍。FAT16、FAT32等FAT类文件系统都是在FAT12文件系统的基础上扩展而得,FAT32文件系统将会在未来的章节中予以使用。

  • FAT表

    FAT12文件系统以簇为单位来分配数据区的存储空间(扇区),每个簇的长度为BPB_BytesPerSec * BPB_SecPerClus字节,数据区的簇号与FAT表的表项是一一对应关系。因此,文件在FAT类文件系统的存储单位是簇,而非字节或扇区,即使文件的长度只有一个字节,FAT12文件系统也会为它分配一个簇的磁盘存储空间。此种设计方法可以将磁盘存储空间按固定存储片(页)有效管理起来,进而可以按照文件偏移,分片段访问文件内的数据,就不必一次将文件里的数据全部读取出来。

    FAT表中的表项位宽与FAT类型有关,例如,FAT12文件系统的表项位宽为12 bit、FAT16文件系统的表项位宽为16 bit、FAT32文件系统的表项位宽为32 bit。当一个文件的体积增大时,其所需的磁盘存储空间也会增加,随着时间的推移,文件系统将无法确保文件中的数据存储在连续的磁盘扇区内,文件往往被分成若干个片段。借助FAT表项,可将这些不连续的文件片段按簇号链接起来,这个链接原理与C语言的单向链表极为相似。表3-2以FAT12文件系统为例,来对FAT表项的取值加以说明。

    表3-2 FAT表项取值说明

    FAT项实例值描述
    0FF0h磁盘标示字,低字节与BPB_Media数值保持一致
    1FFFh第一个簇已经被占用
    2003h000h:可用簇
    3004h002h~FEFh:已用簇,标识下一个簇的簇号
    …………FF0h~FF6h:保留簇
    NFFFhFF7h:坏簇
    N+1000hFF8h~FFFh:文件的最后一个簇
    …………

    :FAT[0]和FAT[1]始终不作为数据区的索引值使用。

    其中,FAT[0](FAT表项0)的低8位在数值上与BPB_Media字段保持一致,剩余位全部设置为1。由于表3-1的BPB_Media字段数值是F0h,故此FAT[0]的值是FF0h。在文件系统初始化期间,已经明确地将FAT[1]赋值为FFFh,想必这是为了防止文件系统误分配该表项。

    现在,大部分操作系统的FAT类文件系统驱动程序都直接跳过这两个FAT表项的检索,使它们不再参与计算。因此,FAT[0]和FAT[1]的数值已经不再那么重要了,有时候这两个值为0也是没问题的。我们在编写程序时不必检测它们的数值,直接跳过即可。

  • 根目录区和数据区

    从本质上讲,根目录区和数据区都保存着与文件相关的数据,只不过根目录区只能保存目录项信息,而数据区不但可以保存目录项信息,还可以保存文件内的数据。

    此处提及的目录项是一个由32 B组成的结构体,它既可以表示成一个目录,又可以表示成一个文件,其中记录着名字、长度以及数据起始簇号等信息,表3-3是目录项的完整结构。对于树状的目录结构而言,树的层级结构自然是通过代表着目录的目录项结构建立起来,从根目录开始经过目录项的逐层嵌套渐渐地形成了树状结构,更多详细内容将会在第13章中予以讲解。

    表3-3 目录项结构

    名称偏移长度描述
    DIR_Name0x0011文件名8 B,扩展名3 B
    DIR_Attr0x0B1文件属性
    保留0x0C10保留位
    DIR_WrtTime0x162最后一次写入时间
    DIR_WrtDate0x182最后一次写入日期
    DIR_FstClus0x1A2起始簇号
    DIR_FileSize0x1C4文件大小

    对于表3-3中的DIR_FstClus字段必须特别注意,它描述了文件在磁盘中存放的具体位置。由于FAT[0]和FAT[1]是保留项,不能用于数据区的簇索引,因此数据区的第一个有效簇号是2,而不是0或者1。

经过对FAT12文件系统的综合学习后,我们现在已经掌握了足够的知识来实现Loader程序的加载功能。代码清单3-5是为虚拟软盘创建的FAT12文件系统引导扇区数据。

代码清单3-5 第3章\程序\程序3-2\boot.asm

    org 0x7c00

BaseOfStack       equ    0x7c00
BaseOfLoader      equ    0x1000
OffsetOfLoader    equ    0x00

RootDirSectors                  equ    14
SectorNumOfRootDirStart         equ    19
SectorNumOfFAT1Start            equ    1
SectorBalance                   equ    17

    jmp    short Label_Start
    nop
    BS_OEMName            db    'MINEboot'
    BPB_BytesPerSec       dw    512
    BPB_SecPerClus        db    1
    BPB_RsvdSecCnt        dw    1
    BPB_NumFATs           db    2
    BPB_RootEntCnt        dw    224
    BPB_TotSec16          dw    2880
    BPB_Media             db    0xf0
    BPB_FATSz16           dw    9
    BPB_SecPerTrk         dw    18
    BPB_NumHeads          dw    2
    BPB_hiddSec           dd    0
    BPB_TotSec32          dd    0
    BS_DrvNum             db    0
    BS_Reserved1          db    0
    BS_BootSig            db    29h
    BS_VolID              dd    0
    BS_VolLab             db    'boot loader'
    BS_FileSysType        db    'FAT12   '

这段程序中的代码BaseOfLoader equ 0x1000OffsetOfLoader equ 0x00组合成了Loader程序的起始物理地址,这个组合必须经过实模式的地址变换公式才能生成物理地址,即BaseOfLoader << 4 + OffsetOfLoader = 0x10000

代码RootDirSectors equ 14定义了根目录占用的扇区数,这个数值是根据FAT12文件系统提供的信息经过计算而得,即(BPB_RootEntCnt * 32 + BPB_BytesPerSec – 1) / BPB_Bytes PerSec = (224×32 + 512 – 1) / 512 = 14

等价语句SectorNumOfRootDirStart equ 19定义了根目录的起始扇区号,这个数值也是通过计算而得,即保留扇区数 + FAT表扇区数 * FAT表份数 = 1 + 9 * 2 = 19,因为扇区编号的计数值从0开始,故根目录的起始扇区号为19。

程序SectorNumOfFAT1Start equ 1代表了FAT1表的起始扇区号,在FAT1表前面只有一个保留扇区(引导扇区),而且它的扇区编号是0,那么FAT1表的起始扇区号理应为1。

汇编代码SectorBalance equ 17用于平衡文件(或者目录)的起始簇号与数据区起始簇号的差值。更通俗点说,因为数据区对应的有效簇号是2(FAT[2]),为了正确计算出FAT表项对应的数据区起始扇区号,则必须将FAT表项值减2,或者将数据区的起始簇号/扇区号减2(仅在每簇由一个扇区组成时可用)。本程序暂时采用一种更取巧的方法是,将根目录起始扇区号减219–2=17),进而间接把数据区的起始扇区号(数据区起始扇区号=根目录起始扇区号+根目录所占扇区数)减2

准备好FAT12文件系统引导扇区数据后,还需要为引导程序准备软盘读取功能。代码清单3-6是软盘读取功能的程序实现。

代码清单3-6 第3章\程序\程序3-2\boot.asm

;=======       read one sector from floppy
Func_ReadOneSector:

    push       bp
    mov        bp,    sp
    sub        esp,   2
    mov        byte   [bp - 2],    cl
    push       bx
    mov        bl,    [BPB_SecPerTrk]
    div        bl
    inc        ah
    mov        cl,    ah
    mov        dh,    al
    shr        al,    1
    mov        ch,    al
    and        dh,    1
    pop        bx
    mov        dl,    [BS_DrvNum]
Label_Go_On_Reading:
    mov        ah,    2
    mov        al,    byte    [bp - 2]
    int        13h
    jc Label_Go_On_Reading
    add esp,    2
    pop bp
    ret

代码中的Func_ReadOneSector模块负责实现软盘读取功能,它借助BIOS中断服务程序INT 13h的主功能号AH=02h实现软盘扇区的读取操作,该中断服务程序的各寄存器参数说明如下。

INT 13h,AH=02h 功能:读取磁盘扇区。

  • AL=读入的扇区数(必须非0);
  • CH=磁道号(柱面号)的低8位;
  • CL=扇区号1~63(bit 0~5),磁道号(柱面号)的高2位(bit 6~7, 只对硬盘有效);
  • DH=磁头号;
  • DL=驱动器号(如果操作的是硬盘驱动器,bit 7必须被置位);
  • ES:BX=>数据缓冲区。

从代码清单3-6可知,模块Func_ReadOneSector仅仅是对BIOS中断服务程序的再次封装,以简化读取磁盘扇区的操作过程,进而在调用Func_ReadOneSector模块时,只需传递下列参数到对应的寄存器中,即可实现磁盘扇区的读取操作。模块Func_ReadOneSector详细参数说明如下。

模块Func_ReadOneSector功能:读取磁盘扇区。

  • AX=待读取的磁盘起始扇区号;
  • CL=读入的扇区数量;
  • ES:BX=>目标缓冲区起始地址。

因为Func_ReadOneSector模块传入的磁盘扇区号是LBA(Logical Block Address,逻辑块寻址)格式的,而INT 13h,AH = 02h中断服务程序只能受理CHS(Cylinder/Head/Sector,柱面/磁头/扇区)格式的磁盘扇区号,那么必须将LBA格式转换为CHS格式,通过公式(3-1)可将LBA格式转换为CHS格式。

      (3-1)

模块Func_ReadOneSector在读取软盘之前,会先保存栈帧寄存器和栈寄存器的数值,从栈中开辟两个字节的存储空间(将栈指针向下移动两个字节),由于此时代码bp – 2与ESP寄存器均指向同一内存地址,所以CL寄存器的值就保存在刚开辟的栈空间里。而后,使用AX寄存器(待读取的磁盘起始扇区号)除以BL寄存器(每磁道扇区数),计算出目标磁道号(商:AL寄存器)和目标磁道内的起始扇区号(余数:AH寄存器),考虑到磁道内的起始扇区号从1开始计数,故此将余数值加1,即inc ah。紧接着,再按照公式(3-1)计算出磁道号(也叫柱面号)与磁头号,将计算结果保存在对应寄存器内。最后,执行INT 13h中断服务程序从软盘扇区读取数据到内存中,当数据读取成功(CF标志位被复位)后恢复调用现场。

有了软盘扇区读取功能,便可在其基础上实现文件系统访问功能。代码清单3-7则是在其基础上实现的目标文件搜索功能。

代码清单3-7 第3章\程序\程序3-2\boot.asm

;=======   search loader.bin
    mov    word    [SectorNo],    SectorNumOfRootDirStart

Lable_Search_In_Root_Dir_Begin:

    cmp    word    [RootDirSizeForLoop],    0
    jz     Label_No_LoaderBin
    dec    word    [RootDirSizeForLoop]
    mov    ax,     00h
    mov    es,     ax
    mov    bx,     8000h
    mov    ax,     [SectorNo]
    mov    cl,     1
    call   Func_ReadOneSector
    mov    si,     LoaderFileName
    mov    di,     8000h
    cld
    mov    dx,     10h

Label_Search_For_LoaderBin:

    cmp    dx,     0
    jz     Label_Goto_Next_Sector_In_Root_Dir
    dec    dx
    mov    cx,     11

Label_Cmp_FileName:

    cmp    cx,     0
    jz     Label_FileName_Found
    dec    cx
    lodsb
    cmp    al,     byte    [es:di]
    jz     Label_Go_On
    jmp    Label_Different

Label_Go_On:

    inc    di
    jmp    Label_Cmp_FileName

Label_Different:

    and    di,     0ffe0h
    add    di,     20h
    mov    si,     LoaderFileName
    jmp    Label_Search_For_LoaderBin

Label_Goto_Next_Sector_In_Root_Dir:

    add    word    [SectorNo],    1
    jmp    Lable_Search_In_Root_Dir_Begin

通过这段代码能够从根目录中搜索出引导加载程序(文件名为loader.bin)。在程序执行初期,程序会先保存根目录的起始扇区号,并依据根目录占用磁盘扇区数来确定需要搜索的扇区数,并从根目录中读入一个扇区的数据到缓冲区;接下来,遍历读入缓冲区中的每个目录项,寻找与目标文件名字符串("LOADER BIN",0)相匹配的目录项,其中DX寄存器记录着每个扇区可容纳的目录项个数(512 / 32 = 16 = 0x10),CX寄存器记录着目录项的文件名长度(文件名长度为11B,包括文件名和扩展名,但不包含分隔符“.”)。在比对每个目录项文件名的过程中,使用了汇编指令LODSB,该命令的加载方向与DF标志位有关,因此在使用此命令时需用CLD指令清DF标志位。

以下是Intel官方白皮书对LODSB/LODSW/LODSD/LODSQ指令的概括描述。

  • 该命令可从DS:(R|E)SI寄存器指定的内存地址中读取数据到AL/AX/EAX/RAX寄存器。
  • 当数据载入到AL/AX/EAX/RAX寄存器后,(R|E)SI寄存器将会依据R|EFLAGS标志寄存器的DF标志位自动增加或减少载入的数据长度(1/2/4/8字节)。当DF=0时,(R|E)SI寄存器将会自动增加;反之,(R|E)SI寄存器将会自动减少。

一旦发现完全匹配的字符串,则跳转到Label_FileName_Found处执行;如果没有找到,那么就执行其后的Label_No_LoaderBin模块,进而在屏幕上显示提示信息,通知用户引导加载程序不存在。

特别注意,因为FAT12文件系统的文件名是不区分大小写字母的,即使将小写字母命名的文件复制到FAT12文件系统内,文件系统也会为其创建大写字母的文件名和目录项。而小写字母文件名只作为其显示名,真正的数据内容皆保存在大写字母对应的目录项。所以这里应该搜索大写字母的文件名字符串。

代码清单3-8是Label_No_LoaderBin模块的处理程序,它的作用是在搜索不到loader.bin程序时,显示提示信息。

代码清单3-8 第3章\程序\程序3-2\boot.asm

;=======   display on screen : ERROR:No LOADER Found

Label_No_LoaderBin:

    mov    ax,    1301h
    mov    bx,    008ch
    mov    dx,    0100h
    mov    cx,    21
    push   ax
    mov    ax,    ds
    mov    es,    ax
    pop    ax
    mov    bp,    NoLoaderMessage
    int    10h
    jmp    $

这段代码借助BIOS中断处理程序INT 10h,将字符串ERROR:No LOADER Found显示到屏幕的第1行第0列上。图3-6是Label_No_LoaderBin模块的显示效果。

{%}

图3-6 Boot错误效果图

其实,模块Label_No_LoaderBin设置的字符属性(位于BL寄存器内)是字符闪烁、黑背景色、高亮、红色字体,但由于图3-6是屏幕截图,无法表现出闪烁效果。更多执行细节还请读者从虚拟机中观察。

当搜索到loader.bin程序后,便可根据FAT表项提供的簇号顺序依次加载扇区数据到内存中,这个加载过程会涉及FAT表项的解析工作。代码清单3-9是FAT表项的解析代码。

代码清单3-9 第3章\程序\程序3-2\boot.asm

;=======   get FAT Entry

Func_GetFATEntry:

    push   es
    push   bx
    push   ax
    mov    ax,    00
    mov    es,    ax
    pop    ax
    mov    byte   [Odd],    0
    mov    bx,    3
    mul    bx
    mov    bx,    2
    div    bx
    cmp    dx,    0
    jz     Label_Even
    mov    byte   [Odd],    1

Label_Even:

    xor    dx,    dx
    mov    bx,    [BPB_BytesPerSec]
    div    bx
    push   dx
    mov    bx,    8000h
    add    ax,    SectorNumOfFAT1Start
    mov    cl,    2
    call   Func_ReadOneSector

    pop    dx
    add    bx,    dx
    mov    ax,    [es:bx]
    cmp    byte   [Odd],    1
    jnz    Label_Even_2
    shr    ax,    4

Label_Even_2:
    and    ax,    0fffh
    pop    bx
    pop    es
    ret

此前已经提及FAT12文件系统的每个FAT表项占用12 bit,即每三个字节存储两个FAT表项,由此看来,FAT表项的存储位置是具有奇偶性的。使用Func_GetFATEntry模块可根据当前FAT表项索引出下一个FAT表项,该模块的寄存器参数说明如下。

模块Func_GetFATEntry 功能:根据当前FAT表项索引出下一个FAT表项。

  • AX=FAT表项号(输入参数/输出参数)。

这段程序首先会保存FAT表项号,并将奇偶标志变量(变量[odd])置0。因为每个FAT表项占1.5 B,所以将FAT表项乘以3除以2(扩大1.5倍),来判读余数的奇偶性并保存在[odd]中(奇数为1,偶数为0),再将计算结果除以每扇区字节数,商值为FAT表项的偏移扇区号,余数值为FAT表项在扇区中的偏移位置。接着,通过Func_ReadOneSector模块连续读入两个扇区的数据,此举的目的是为了解决FAT表项横跨两个扇区的问题。最后,根据奇偶标志变量进一步处理奇偶项错位问题,即奇数项向右移动4位。有能力的读者可自行将FAT12文件系统替换为FAT16文件系统,这样可以简化FAT表项的索引过程。

在完成Func_ReadOneSectorFunc_GetFATEntry模块后,就可借助这两个模块把loader.bin文件内的数据从软盘扇区读取到指定地址中。代码清单3-10实现了从FAT12文件系统中加载loader.bin文件到内存的过程。

代码清单3-10 第3章\程序\程序3-2\boot.asm

;=======   found loader.bin name in root director struct

Label_FileName_Found:

    mov    ax,    RootDirSectors
    and    di,    0ffe0h
    add    di,    01ah
    mov    cx,    word    [es:di]
    push   cx
    add    cx,    ax
    add    cx,    SectorBalance
    mov    ax,    BaseOfLoader
    mov    es,    ax
    mov    bx,    OffsetOfLoader
    mov    ax,    cx

Label_Go_On_Loading_File:
    push   ax
    push   bx
    mov    ah,    0eh
    mov    al,    '.'
    mov    bl,    0fh
    int    10h
    pop    bx
    pop    ax

    mov    cl,    1
    call   Func_ReadOneSector
    pop    ax
    call   Func_GetFATEntry
    cmp    ax,    0fffh
    jz     Label_File_Loaded
    push   ax
    mov    dx,    RootDirSectors
    add    ax,    dx
    add    ax,    SectorBalance
    add    bx,    [BPB_BytesPerSec]
    jmp    Label_Go_On_Loading_File

Label_File_Loaded:

    jmp    $

Label_FileName_Found模块中,程序会先取得目录项DIR_FstClus字段的数值,并通过配置ES寄存器和BX寄存器来指定loader.bin程序在内存中的起始地址,再根据loader.bin程序的起始簇号计算出其对应的扇区号。为了增强人机交互效果,此处还使用BIOS中断服务程序INT 10h在屏幕上显示一个字符'.'。接着,每读入一个扇区的数据就通过Func_GetFATEntry模块取得下一个FAT表项,并跳转至Label_Go_On_Loading_File处继续读入下一个簇的数据,如此往复,直至Func_GetFATEntry模块返回的FAT表项值是0fffh为止。当loader.bin文件的数据全部读取到内存后,跳转至Label_File_Loaded处准备执行loader.bin程序。

这段代码使用了BIOS中断服务程序INT 10h的主功能号AH=0Eh在屏幕上显示一个字符。详细寄存器参数说明如下。

INT 10h,AH=0Eh 功能:在屏幕上显示一个字符。

  • AL=待显示字符;
  • BL=前景色。

看到这里,想必读者已经知道图3-1在字符串Start Boot后面显示的5个字符'.'的意义了,即读入5个扇区的数据,或者loader.bin程序占用了将近5个扇区的磁盘空间。

代码清单3-11和代码清单3-12是boot.asm文件的剩余代码,这部分代码定义了引导程序在运行时使用的临时变量和日志信息字符串。

代码清单3-11 第3章\程序\程序3-2\boot.asm

;=======    tmp variable

RootDirSizeForLoop    dw    RootDirSectors
SectorNo              dw    0
Odd                   db    0

这三个变量用于保存程序运行时的临时数据,上文已经讲解了它们的使用过程,此处不再过多讲述。

代码清单3-12 第3章\程序\程序3-2\boot.asm

;=======    display messages

StartBootMessage:    db    "Start Boot"
NoLoaderMessage:     db    "ERROR:No LOADER Found"
LoaderFileName:      db    "LOADER  BIN",0

上述字符串均是屏幕上显示的日志信息。值得说明的是,NASM编译器中的单引号与双引号作用相同,并非如标准C语言中规定的:双引号会在字符串结尾处自动添加字符'\0',而在NASM编译器中必须自行添加。不过,本程序使用的BIOS中断服务程序必须明确提供显示的字符串的长度,不需要判读字符串结尾处的字符'\0'

目前,我们还未进入Loader引导加载程序的开发环节,所以在Label_File_Loaded处使用代码jmp $,让程序死循环在此处。

3.1.6 从Boot跳转到Loader程序

接下来,将实现Boot引导程序的最后一步,那就是跳转至Loader引导加载程序处,向其移交处理器的控制权。代码清单3-13完成了向Loader引导加载程序移交执行权的工作,即跳转至物理地址0x10000处执行loader.bin程序。

代码清单3-13 第3章\程序\程序3-3\boot.asm

Label_File_Loaded:

    jmp BaseOfLoader:OffsetOfLoader

这行jmp代码与早前的指令略有不同。此前的JMP汇编指令属于段内地址跳转,而此处的JMP指令属于段间地址跳转,它可以从一个段跳转至另一个段地址中。因此,这个长跳转指令JMP必须在操作地址中明确指定跳转的目标段和目标段内偏移地址。

这个跳转指令执行结束后,目标段会赋值到CS代码段寄存器中。以此段程序为例,当JMP指令执行后,CS寄存器的值就是BaseOfLoader,即0x1000。在实模式下,代码段寄存器的值必须左移4位后才转换成段基地址,即0x1000 << 4 = 0x10000

至此,Boot引导程序的代码已经全部实现。接下来,编写一个简单的Loader引导加载程序来检测这个跳转过程,请看代码清单3-14。

代码清单3-14 第3章\程序\程序3-3\loader.asm

org    10000h

    mov    ax,    cs
    mov    ds,    ax
    mov    es,    ax
    mov    ax,    0x00
    mov    ss,    ax
    mov    sp,    0x7c00

;=======   display on screen : Start Loader......

    mov    ax,    1301h
    mov    bx,    000fh
    mov    dx,    0200h        ;row 2
    mov    cx,    12
    push   ax
    mov    ax,    ds
    mov    es,    ax
    pop    ax
    mov    bp,    StartLoaderMessage
    int    10h

    jmp    $

;=======   display messages

StartLoaderMessage:    db    "Start Loader"

这段测试代码与程序3-1中的boot.asm程序在功能上极为相似,即显示一行日志信息,以证明Loader引导加载程序正在被处理器执行。

编译loader.asm程序使用的命令与编译boot.asm程序相似,区别在于源文件名和目标文件名不同而已。以下是loader.asm程序的完整编译命令:

nasm loader.asm -o loader.bin

当loader.asm程序编译结束后,必须将生成的二进制程序loader.bin复制到虚拟软盘镜像文件boot.img中。此处的复制过程与boot.bin程序的写入过程采用了完全不同方法,当boot.bin程序写入到boot.img虚拟软盘镜像文件后,boot.img虚拟软盘已经拥有了FAT12文件系统,那么应该借助挂载命令mount和复制命令cp,把引导加载程序loader.bin复制到文件系统中。整个复制过程需要执行以下命令:

mount ../bochs-2.6.8/boot.img /media/ -t vfat -o loop
cp loader.bin  /media/
sync
umount /media/

在这组命令中,挂载命令mount的参数../bochs-2.6.8/boot.img指定了待挂载文件的路径名,参数/media/指定挂载目录,参数-t vfat指定磁盘的文件系统类型,参数-o loop负责把一个文件描述成磁盘分区。读者可以根据个人的实际情况,适当调整虚拟软盘镜像文件路径和挂载目录路径,当虚拟软盘镜像文件挂载成功后便可对其进行访问了。复制命令cp和磁盘强制同步命令sync属于常用命令,这里就不再赘述。umount命令用于将已挂载的设备或文件卸载下来,它与挂载命令mount的作用相反,此处的参数/media/指明了挂载目录。

有些读者可能会问,为什么虚拟软盘不用格式化成FAT12文件系统,就可以直接挂载使用呢?因为使用dd命令将引导程序boot.bin强行写入到引导扇区的动作与磁盘的格式化作用相似,也就相当于将其格式化成FAT12文件系统。其实格式化文件系统,就是把文件系统的所有结构数据写入到磁盘扇区的过程。

解释了这么多内容,现在来欣赏一下引导加载程序的运行效果,参见图3-7。

{%}

图3-7 Boot跳转至Loader

Loader引导加载程序在屏幕的第三行显示一条字符串Start Loader,随后进入死循环状态。万事开头难,相信经过Boot引导程序的学习后,Loader引导加载程序将不再令人胆怯。

3.2 Loader引导加载程序

与Boot引导程序挥手作别后,此刻的处理器控制权已经移交给Loader引导加载程序。Loader引导加载程序任重而道远,它必须在内核程序执行前,为其准备好一切所需数据,比如硬件检测信息、处理器模式切换、向内核传递参数等。

由于Loader引导加载程序的工作纷繁而又复杂,为了使读者能够快速对操作系统有个整体认识,本节将主要讲解Loader引导加载程序的主线工作,其他分支工作将会在高级篇中予以详细讲解。有能力的读者也可以结合第6章和第7章的知识同步学习。

新的征程马上开始,Loader引导加载程序,Ready Go!

3.2.1 Loader原理

Loader引导加载程序负责检测硬件信息、处理器模式切换、向内核传递数据三部分工作,这些工作为内核的初始化提供信息及功能支持,以便内核在完成初始化工作后能够正常运行。下面将对这三部分内容逐一讲解。

  • 检测硬件信息

    Loader引导加载程序需要检测的硬件信息很多,主要是通过BIOS中断服务程序来获取和检测硬件信息。由于BIOS在上电自检出的大部分信息只能在实模式下获取,而且内核运行于非实模式下,那么就必须在进入内核程序前将这些信息检测出来,再作为参数提供给内核程序使用。

    在这些硬件信息中,最重要的莫过于物理地址空间信息,只有正确解析出物理地址空间信息,才能知道ROM、RAM、设备寄存器空间和内存空洞等资源的物理地址范围,进而将其交给内存管理单元模块加以维护。还有后续章节中会讲解的VBE功能,通过VBE功能可以检测出显示器支持的分辨率、显示模式、刷新率以及显存物理地址等信息,有了这些信息才能配置出合理的显示模式。

  • 处理器模式切换

    从起初BIOS运行的实模式(real mode),到32位操作系统使用的保护模式(protect mode),再到64位操作系统使用的IA-32e模式(long mode,长模式),Loader引导加载程序必须历经这三个模式,才能使处理器运行于64位的IA-32e模式。在各个模式的切换过程中,Loader引导加载程序必须手动创建各运行模式的临时数据,并按照标准流程执行模式间的跳转。其中有配置系统临时页表的工作,即既要根据各个阶段的页表特性设置临时页表项,还要保证页表覆盖的地址空间满足程序使用要求。临时段结构亦是如此。

  • 向内核传递数据

    Loader引导加载程序可向内核程序传递两类数据,一类是控制信息,另一类是硬件数据信息。这些数据一方面控制内核程序的执行流程,另一方面为内核程序的初始化提供数据信息支持。

    • 控制信息一般用于控制内核执行流程或限制内核的某些功能。这些数据(参数)是与内核程序早已商定的协议,属于纯软件控制逻辑,如启动模式(字符界面或图形界面)、启动方式(网络或本地)、终端重定向(串口或显示器等)等信息。
    • 硬件数据信息通常是指Loader引导加载程序检测出的硬件数据信息。Loader引导加载程序将这些数据信息多半都保存在固定的内存地址中,并将数据起始内存地址和数据长度作为参数传递给内核,以供内核程序在初始化时分析、配置和使用,典型的数据信息有内存信息、VBE信息等。

    考虑到Loader引导加载程序的任务比较多,因此本节将针对引导加载程序的主线任务予以讲解,即加载内核、各个模式间的切换。关于这部分的原理级知识,请读者参考第6章关于处理器体系结构的内容。关于Loader引导加载程序的支线任务(显示模式、内存空间结构等),将推迟到第7章中进行原理级讲解和功能扩充。

3.2.2 写一个Loader程序

由于程序3-4中的loader.asm文件过长,而且其中一部分代码与Boot引导程序重复,为了节省篇幅重复的内容将不再予以讲解。请读者参照loader.asm程序源码阅读本节内容,同时也请参照程序源码并行阅读本书内容。

代码清单3-15是Loader引导加载程序的一些基础数据定义和头文件引用,其中包含了内核程序起始地址、临时内存空间起始地址等标识符定义。

代码清单3-15 第3章\程序\程序3-4\loader.asm

org    10000h
    jmp    Label_Start

%include    "fat12.inc"

BaseOfKernelFile          equ    0x00
OffsetOfKernelFile        equ    0x100000

BaseTmpOfKernelAddr       equ    0x00
OffsetTmpOfKernelFile     equ    0x7E00

MemoryStructBufferAddr    equ    0x7E00

这部分代码包含了%include "fat12.inc",通过关键字include可将文件fat12.inc的内容包含进loader.asm文件。从字面意思大家也都猜得出来,它跟C语言引用程序头文件的功能相同。fat12.inc文件是从Boot引导程序中提取出的FAT12文件系统结构,其中的内容一目了然,此处不再赘述。

本系统的内核程序起始地址位于物理地址0x100000(1 MB)处,因为1 MB以下的物理地址并不全是可用内存地址空间,这段物理地址被划分成若干个子空间段,它们可以是内存空间、非内存空间以及地址空洞。随着内核体积的不断增长,未来的内核程序很可能会超过1 MB,因此让内核程序跳过这些纷繁复杂的内存空间,从平坦的1 MB地址开始,这是一个非常不错的选择。

内存地址0x7E00是内核程序的临时转存空间,由于内核程序的读取操作是通过BIOS中断服务程序INT 13h实现的,BIOS在实模式下只支持上限为1 MB的物理地址空间寻址,所以必须先将内核程序读入到临时转存空间,然后再通过特殊方式搬运到1 MB以上的内存空间中。当内核程序被转存到最终内存空间后,这个临时转存空间就可另作他用,此处将其改为内存结构数据的存储空间,供内核程序在初始化时使用。本节将主要围绕上述内容逐步展开代码实现。

代码清单3-16是Loader引导加载程序的入口模块,它的作用是在屏幕上显示一条日志信息,这条信息标志着处理器正在执行Loader引导加载程序。

代码清单3-16 第3章\程序\程序3-4\loader.asm

[SECTION .s16]
[BITS 16]

Label_Start:
    ……
    mov bp,    StartLoaderMessage
    int 10h

这段程序是代码清单3-14的Loader引导加载程序实现,此处追加定义了一个名为.s16的段,BITS伪指令可以通知NASM编译器生成的代码,将运行在16位宽的处理器上或者运行在32位宽的处理器上,语法是'BITS 16''BITS 32'

当NASM编译器处于16位宽('BITS 16')状态下,使用32位宽数据指令时需要在指令前加入前缀0x66,使用32位宽地址指令时需要在指令前加入前缀0x67。而在32位宽('BITS 32')状态下,使用16位宽指令也需要加入指令前缀。伪指令'BITS 位宽'拥有一种等效的书写格式,即[BITS位宽]

通常情况下,实模式只能寻址1 MB以内的地址空间。为了突破这一瓶颈,接下来的代码将开启1 MB以上物理地址寻址功能,同时还开启了实模式下的4 GB寻址功能。详细程序如代码清单3-17所示。

代码清单3-17 第3章\程序\程序3-4\ loader.asm

;=======   open address A20
    push   ax
    in     al,    92h
    or     al,    00000010b
    out    92h,   al
    pop    ax

    cli

    db     0x66
    lgdt   [GdtPtr]

    mov    eax,   cr0
    or     eax,   1
    mov    cr0,   eax

    mov    ax,    SelectorData32
    mov    fs,    ax
    mov    eax,   cr0
    and    al,    11111110b
    mov    cr0,   eax

    sti

这段代码的起始部分开启地址A20功能,此项功能属于历史遗留问题。最初的处理器只有20根地址线,这使得处理器只能寻址1MB以内的物理地址空间,如果超过1 MB范围的寻址操作,也只有低20位是有效地址。随着处理器寻址能力的不断增强,20根地址线已经无法满足今后的开发需求。为了保证硬件平台的向下兼容性,便出现了一个控制开启或禁止1 MB以上地址空间的开关。当时的8042键盘控制器上恰好有空闲的端口引脚(输出端口P2,引脚P21),从而使用此引脚作为功能控制开关,即A20功能。如果A20引脚为低电平(数值0),那么只有低20位地址有效,其他位均为0。

在机器上电时,默认情况下A20地址线是被禁用的,所以操作系统必须采用适当的方法开启它。由于硬件平台的兼容设备种类繁杂,进而出现多种开启A20功能的方法。

  • 开启A20功能的常用方法是操作键盘控制器,由于键盘控制器是低速设备,以至于功能开启速度相对较慢。
  • A20快速门(Fast Gate A20),它使用I/O端口0x92来处理A20信号线。对于不含键盘控制器的操作系统,就只能使用0x92端口来控制,但是该端口有可能被其他设备使用。
  • 使用BIOS中断服务程序INT 15h的主功能号AX=2401可开启A20地址线,功能号AX=2400可禁用A20地址线,功能号AX=2403可查询A20地址线的当前状态。
  • 还有一种方法是,通过读0xee端口来开启A20信号线,而写该端口则会禁止A20信号线。

本系统通过访问A20快速门来开启A20功能,即置位0x92端口的第1位。

当A20功能开启后,紧接着使用指令CLI关闭外部中断,再通过指令LGDT加载保护模式结构数据信息,并置位CR0寄存器的第0位来开启保护模式。当进入保护模式后,为FS段寄存器加载新的数据段值,一旦完成数据加载就从保护模式中退出,并开启外部中断。整个动作一气呵成,实现了保护模式的开启和关闭。看似多此一举的代码,其目的只是为了让FS段寄存器可以在实模式下寻址能力超过1 MB,也就是传说中的Big Real Mode模式,详细的原理级说明请参见第7章。通过此番操作后,借助FS段寄存器的特殊寻址能力,就可将内核程序移动到1 MB以上的内存地址空间中。

我们可以将代码jmp $插入到STI汇编指令后的某个地方让程序停留下来,在Bochs的终端中按下Ctrl + C键进入DBG调试命令行,输入命令sreg可查看当前段状态信息,大致内容如下:

00014040193i[BIOS  ] Booting from 0000:7c00
^C00196624559i[      ] Ctrl-C detected in signal handler.
Next at t=196624560
(0) [0x0000000100ce] 1000:00ce (unk. ctxt): jmp .-3 (0x000100ce)      ; e9fdff
<bochs:2> sreg
es:0x1000, dh=0x00009301, dl=0x0000ffff, valid=1
    Data segment, base=0x00010000, limit=0x0000ffff, Read/Write, Accessed
cs:0x1000, dh=0x00009301, dl=0x0000ffff, valid=1
    Data segment, base=0x00010000, limit=0x0000ffff, Read/Write, Accessed
ss:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7
    Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
ds:0x1000, dh=0x00009301, dl=0x0000ffff, valid=1
    Data segment, base=0x00010000, limit=0x0000ffff, Read/Write, Accessed
fs:0x0010, dh=0x00cf9300, dl=0x0000ffff, valid=1
    Data segment, base=0x00000000, limit=0xffffffff, Read/Write, Accessed
gs:0x0000, dh=0x00009300, dl=0x0000ffff, valid=1
    Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
ldtr:0x0000, dh=0x00008200, dl=0x0000ffff, valid=1
tr:0x0000, dh=0x00008b00, dl=0x0000ffff, valid=1
gdtr:base=0x0000000000010040, limit=0x17
idtr:base=0x0000000000000000, limit=0x3ff

从Bochs虚拟机的调试信息中可知,FS段寄存器的状态信息与其他段寄存器略有不同,特别是段基地址base=0x00000000和段限长limit=0xffffffff两值,它们的寻址能力已经从20位(1 MB)扩展到32位(4 GB)。

这里需要注意一点的是,在物理平台下,当段寄存器拥有这种特殊能力后,如果重新对其赋值的话,那么它就会失去特殊能力,转而变回原始的实模式段寄存器。但是Bochs虚拟机貌似放宽了对寄存器的检测条件,即使重新向FS段寄存器赋值,FS段寄存器依然拥有特殊能力。

完成上述准备工作后,接下来的任务是从FAT12文件系统中搜索出内核程序文件kernel.bin。代码清单3-18是搜索任务的代码实现片段。

代码清单3-18 第3章\程序\程序3-4\loader.asm

;=======   search kernel.bin
    mov    word    [SectorNo],    SectorNumOfRootDirStart

    ……

    jmp    Lable_Search_In_Root_Dir_Begin

;=======   display on screen : ERROR:No KERNEL Found

Label_No_LoaderBin:

    ……

    mov    bp,    NoLoaderMessage
    int    10h
    jmp    $

已经在Boot引导程序中看到过与代码清单3-18类似的内容,只不过此刻已将待搜索的文件名从loader.bin改为kernel.bin。

如果搜索到内核程序文件kernel.bin,则将kernel.bin文件内的数据读取至物理内存中,实现代码如代码清单3-19所示。

代码清单3-19 第3章\程序\程序3-4\loader.asm

;=======   found kernel.bin name in root director struct

Label_FileName_Found:
    mov    ax,    RootDirSectors
    and    di,    0FFE0h
    add    di,    01Ah
    mov    cx,    word    [es:di]
    push   cx
    add    cx,    ax
    add    cx,    SectorBalance
    mov    eax,   BaseTmpOfKernelAddr    ;BaseOfKernelFile
    mov    es,    eax
    mov    bx,    OffsetTmpOfKernelFile    ;OffsetOfKernelFile
    mov    ax,    cx

Label_Go_On_Loading_File:
    ……

    mov    cl,    1
    call   Func_ReadOneSector
    pop    ax

;;;;;;;;;;;;;;;;;;;;;;;
    push   cx
    push   eax
    push   fs
    push   edi
    push   ds
    push   esi

    mov    cx,    200h
    mov    ax,    BaseOfKernelFile
    mov    fs,    ax
    mov    edi,   dword    [OffsetOfKernelFileCount]

    mov    ax,    BaseTmpOfKernelAddr
    mov    ds,    ax
    mov    esi,   OffsetTmpOfKernelFile

Label_Mov_Kernel:    ;------------------

    mov    al,    byte    [ds:esi]
    mov    byte   [fs:edi],    al

    inc    esi
    inc    edi

    loop   Label_Mov_Kernel

    mov    eax,   0x1000
    mov    ds,    eax

    mov    dword  [OffsetOfKernelFileCount],    edi

    pop    esi
    pop    ds
    pop    edi
    pop    fs
    pop    eax
    pop    cx
;;;;;;;;;;;;;;;;;;;;;;;

    call    Func_GetFATEntry

    ……

    jmp    Label_Go_On_Loading_File

这部分程序负责将内核程序读取到临时转存空间中,随后再将其移动至1 MB以上的物理内存空间。为了避免转存环节发生错误,还是一个字节一个字节的复制为妙,借助汇编指令LOOP可完成此项工作。由于内核体积庞大必须逐个簇地读取和转存,那么每次转存内核程序片段时必须保存目标偏移值,该值(EDI寄存器)保存于临时变量OffsetOfKernelFileCount中。

相信你一定注意到了这段程序中关于FS段寄存器的操作。这段代码在Bochs虚拟机中是可以达到预期效果的,待其移植到物理平台上就会出现问题。这个问题将在第7章予以更正,请读者多加留意。

当内核程序被加载到1 MB以上物理内存地址后,使用代码清单3-20在屏幕的第0行第39列显示一个字符'G'。此举不仅可以隔离内核程序的加载过程,还引入了一种高效的字符显示方法。

代码清单3-20 第3章\程序\程序3-4\loader.asm

Label_File_Loaded:

    mov    ax, 0B800h
    mov    gs, ax
    mov    ah, 0Fh                ; 0000: 黑底    1111: 白字
    mov    al, 'G'
    mov    [gs:((80 * 0 + 39) * 2)], ax    ; 屏幕第 0 行, 第 39 列。

这段代码首先将GS段寄存器的基地址设置在0B800h地址处,并将AH寄存器赋值为0Fh,将AL寄存器赋值为字母'G',然后将AX寄存器的值填充到地址0B800h向后偏移(80×0 + 39) × 2处。该方法与BIOS的INT 10h中断服务程序相比,更符合操作显卡内存的习惯。从内存地址0B800h开始,是一段专门用于显示字符的内存空间,每个字符占用两个字节的内存空间,其中低字节保存显示的字符,高字节保存字符的颜色属性。此处的属性值0Fh表示字符使用白色字体、黑色背景。图3-8是此刻的引导加载程序运行效果。

{%}

图3-8 在屏幕上显示字符'G'

在最开始的1 MB物理地址空间内,不仅有显示字符的内存空间,还有显示像素的内存空间以及其他用途的内存空间。这段代码只为让读者了解操作显示内存的方法,毕竟不能长期依赖BIOS中断服务程序。在不久的将来,我们会操作更高级的像素内存,甚至可以通过像素内存在屏幕上作画或者播放视频,更多内容将会在第7章中讲述。

当Loader引导加载程序完成内核程序的加载工作后,软盘驱动器将不再使用,通过代码清单3-21提供的程序可关闭软驱马达。

代码清单3-21 第3章\程序\程序3-4\loader.asm

KillMotor:

    push       dx
    mov dx,    03F2h
    mov al,    0
    out dx,    al
    pop dx

关闭软驱马达是通过向I/O端口3F2h写入控制命令实现的,此端口控制着软盘驱动器的不少硬件功能。表3-4罗列出了I/O端口3F2h可控制的软盘驱动器功能。

表3-4 软盘驱动器控制功能表

名称

说明

7

MOT_EN3

控制软驱D马达,1:启动;0:关闭

6

MOT_EN2

控制软驱C马达,1:启动;0:关闭

5

MOT_EN1

控制软驱B马达,1:启动;0:关闭

4

MOT_EN0

控制软驱A马达,1:启动;0:关闭

3

DMA_INT

1:允许DMA和中断请求
0:禁止DMA和中断请求

2

RESET

1:允许软盘控制器发送控制信息
0:复位软盘驱动器

1

DRV_SEL1

00~11用于选择软盘驱动器A~D

0

DRV_SEL0

 

既然已将内核程序从软盘加载到内存,便可放心地向此I/O端口写入数值0关闭全部软盘驱动器。在使用OUT汇编指令操作I/O端口时,需要特别注意8位端口与16位端口的使用区别。

以下是Intel官方白皮书对OUT指令的概括描述:

OUT指令的源操作数根据端口位宽可以选用AL/AX/EAX寄存器;目的操作数可以是立即数或DX寄存器,其中立即数的取值范围只能是8位宽(0~FFh),而DX寄存器允许的取值范围是16位宽(0~FFFFh)。

当内核程序不再借助临时转存空间后,这块临时转存空间将用于保存物理地址空间信息,代码清单3-22是物理地址空间信息的获取过程。

代码清单3-22 第3章\程序\程序3-4\loader.asm

;=======   get memory address size type

    ……

    mov    bp,    StartGetMemStructMessage
    int    10h

    mov    ebx,   0
    mov    ax,    0x00
    mov    es,    ax
    mov    di,    MemoryStructBufferAddr

Label_Get_Mem_Struct:

    mov    eax,   0x0E820
    mov    ecx,   20
    mov    edx,   0x534D4150
    int    15h
    jc     Label_Get_Mem_Fail
    add    di,    20

    cmp    ebx,    0
    jne    Label_Get_Mem_Struct
    jmp    Label_Get_Mem_OK

Label_Get_Mem_Fail:

    ……

    mov    bp,    GetMemStructErrMessage
    int    10h
    jmp    $

Label_Get_Mem_OK:

    ……

    mov    bp,    GetMemStructOKMessage
    int    10h

物理地址空间信息由一个结构体数组构成,计算机平台的地址空间划分情况都能从这个结构体数组中反映出来,它记录的地址空间类型包括可用物理内存地址空间、设备寄存器地址空间、内存空洞等,详细内容将会在第7章中讲解。

这段程序借助BIOS中断服务程序INT 15h来获取物理地址空间信息,并将其保存在0x7E00地址处的临时转存空间里,操作系统会在初始化内存管理单元时解析该结构体数组。

代码清单3-23的作用与配置系统功能无关,只是为了显示一些查询出的结果信息,它与Label_File_Loaded模块使用相同的显示方法。

代码清单3-23 第3章\程序\程序3-4\loader.asm

[SECTION .s16lib]
[BITS 16]
;=======   display num in al

Label_DispAL:

    push   ecx
    push   edx
    push   edi

    mov    edi,   [DisplayPosition]
    mov    ah,    0Fh
    mov    dl,    al
    shr    al,    4
    mov    ecx,   2
.begin:

    and    al,    0Fh
    cmp    al,    9
    ja     .1
    add    al,    '0'
    jmp    .2
.1:

    sub    al,    0Ah
    add    al,    'A'
.2:

    mov    [gs:edi],    ax
    add    edi,   2

    mov    al,    dl
    loop   .begin

    mov    [DisplayPosition],    edi

    pop    edi
    pop    edx
    pop    ecx

    ret

通过这个程序模块可将十六进制数值显示在屏幕上,执行Label_DispAL模块需要提供的参数说明如下。

模块Label_DispAL功能:显示十六进制数字。

  • AL=要显示的十六进制数。

Label_DispAL模块首先会保存即将变更的寄存器值到栈中,然后把变量DisplayPosition保存的屏幕偏移值(字符游标索引值)载入到EDI寄存器中,并向AH寄存器存入字体的颜色属性值。为了先显示AL寄存器的高四位数据,暂且先把AL寄存器的低四位数据保存在DL寄存器。接着将AL寄存器的高四位数值与9比较,如果大于9,则减去0Ah并与字符'A'相加,否则,直接将其与字符'0'相加。然后将AX寄存器(AL与AH寄存器组合而成)的值,保存至以GS段寄存器为基址、DisplayPosition变量为偏移的显示字符内存空间中。最后再按上述执行步骤将AL寄存器的低四位数值显示出来。

目前,Label_DispAL模块的主要作用是显示视频图像芯片的查询信息,然后根据查询信息配置芯片的显示模式,具体代码如代码清单3-24所示。

代码清单3-24 第3章\程序\程序3-4\loader.asm

;=======    set the SVGA mode(VESA VBE)
    jmp    $
    mov    ax,    4F02h
    mov    bx,    4180h    ;========================mode : 0x180 or 0x143
    int    10h

    cmp    ax,    004Fh
    jnz    Label_SET_SVGA_Mode_VESA_VBE_FAIL

这段程序设置了SVGA芯片的显示模式,代码中的0x1800x143是显示模式号,表3-5是这两种显示模式号的属性信息。

表3-5 显示模式的属性信息

模式

物理地址

像素点位宽

0x180

1440

900

e0000000h

32 bit

0x143

800

600

e0000000h

32 bit

此部分内容是关于VBE(VESABIOSEXTENSION)的显示模式,通过设置不同的显示模式号,可配置出不同的屏幕分辨率、每个像素点的数据位宽、颜色格式等。这些信息皆是从Bochs虚拟平台的SVGA芯片中获得,读者目前只要了解表3-5描述的内容就可以了,更多知识将在第7章中讲解。

截至目前,尽管程序3-4中的loader.asm程序还未讲解完,但屏幕显示效果部分已经基本叙述完毕。图3-9是程序运行到这个阶段时的执行结果。

{%}

图3-9 Loader执行效果图

图3-9中记录的一串数字是,通过调用Label_DispAL模块打印出的SVGA芯片支持的显示模式号。此时的Start Boot字符串已被覆盖,说明Start Boot字符串也在显示字符的内存空间0B800h里。观察Start Loader字符串后面的 . 符号个数,读者想必能猜到kernel.bin文件的规模了。

经过本节内容的学习,相信读者的汇编语言水平已经有了很大程度的提升。本节所编写的汇编程序算是复习过去大学里学到的知识吧。更重要的汇编知识几乎只有在编写操作系统核心时才能用到,也只有掌握这些知识才算对汇编语言有了真正的了解。

3.2.3 从实模式进入保护模式再到IA-32e模式

3.2.2节已经对部分Loader引导加载程序进行了讲解,当使用这部分程序检测出硬件信息后,下面就该脱离实模式进入到保护模式。

在实模式下,程序可以操作任何地址空间,而且无法限制程序的执行权限。尽管这种模式给设置硬件功能带来许多方便,但却给程序执行的安全性和稳定性带来了灾难性的后果,一旦程序执行错误,很可能导致整个系统崩溃。况且实模式的寻址能力有限,故而才进化出保护模式。

在保护模式里,处理器按程序的执行级别分为0、1、2、3四个等级(由高到低排序)。最高等级0由系统内核使用,最低等级3由应用程序使用,Linux内核目前仅使用这两个等级(0级和3级)。而等级1和等级2介于内核程序与应用程序之间,它们通常作为系统服务程序来使用。虽然层级划分的决定权在系统开发者手里,但一些特殊汇编指令必须在0特权级下才能执行。保护模式不仅加入了程序的权限,还引入了分页功能。分页功能将庞大的地址空间划分成固定大小的内存页面,此举不仅便于管理,而且还缩减了应用程序的空间浪费现象。不过,在保护模式的段级保护措施中,从段结构组织的复杂性,到段间权限检测的繁琐性,再到执行时的效率上,都显得臃肿,而且还降低了程序的执行效率和编程的灵活性。当页管理单元出现后,段机制显得更加多余。随着硬件速度不断提升和对大容量内存的不断渴望,IA-32e模式便应运而生。

IA-32e模式不仅简化段级保护措施的复杂性,升级内存寻址能力,同时还扩展页管理单元的组织结构和页面大小,推出新的系统调用方式和高级可编程中断控制器。

关于本节程序的原理级内容,读者可以结合第6章关于处理器体系结构的知识同步阅读。

  1. 从实模式进入保护模式

    有许多读者可能会对处理器的模式切换没有编程思路,不过好在Intel官方白皮书已经为我们做了详尽的描述(请参考Intel官方白皮书Volume 3的9.8.1、9.8.2、9.8.3、9.8.4节)。下面让我们逐步完成从实模式向保护模式切换工作。在处理器执行模式切换代码前,应该先了解模式切换前的准备工作和切换过程必备的系统数据结构。

    为了进入保护模式,处理器必须在模式切换前,在内存中创建一段可在保护模式下执行的代码以及必要的系统数据结构,只有这样才能保证模式切换的顺利完成。相关系统数据结构包括IDT/GDT/LDT描述符表各一个(LDT表可选)、任务状态段TSS结构、至少一个页目录和页表(如果开启分页机制)和至少一个异常/中断处理模块。

    在处理器切换到保护模式前,还必须初始化GDTR寄存器、IDTR寄存器(亦可推迟到进入保护模式后,使能中断前)、控制寄存器CR1~4、MTTRs内存范围类型寄存器。

    • 系统数据结构。系统在进入保护模式前,必须创建一个拥有代码段描述符和数据段描述符的GDT(Globad Descriptor Table,全局描述符表)(第一项必须是NULL描述符),并且一定要使用LGDT汇编指令将其加载到GDTR寄存器。保护模式的栈寄存器SS,使用可读写的数据段即可,无需创建专用描述符。对于多段式操作系统,可采用LDT(Local Descriptor Table,局部描述符表)(必须保存在GDT表的描述符中)来管理应用程序,多个应用程序可独享或共享一个局部描述符表LDT。如果希望开启分页机制,则必须准备至少一个页目录项和页表项。(如果使用4 MB页表,那么准备一个页目录即可。)
    • 中断和异常。在保护模式下,中断/异常处理程序皆由IDT(Interrupt Descriptor Table,中断描述符表)来管理。IDT由若干个门描述符组成,如果采用中断门或陷阱门描述符,它们可以直接指向异常处理程序;如果采用任务门描述符,则必须为处理程序准备TSS段描述符、额外的代码和数据以及任务段描述符等结构。如果处理器允许接收外部中断请求,那么IDT还必须为每个中断处理程序建立门描述符。在使用IDT表前,必须使用LIDT汇编指令将其加载到IDTR寄存器,典型的加载时机是在处理器切换到保护模式前。
    • 分页机制。CR0控制寄存器的PG标志位用于控制分页机制的开启与关闭。在开启分页机制(置位PG标志位)前,必须在内存中创建一个页目录和页表(此时的页目录和页表不可使用同一物理页),并将页目录的物理地址加载到CR3控制寄存器(或称PDBR寄存器)。当上述工作准备就绪后,可同时置位控制寄存器CR0的PE标志位和PG标志位,来开启分页机制。(分页机制往往与模式切换同时进行,不能在进入保护模式前开启分页机制。)
    • 多任务机制。如果希望使用多任务机制或允许改变特权级,则必须在首次执行任务切换前,创建至少一个任务状态段TSS结构和附加的TSS段描述符。(当特权级切换至0、1、2时,栈段寄存器与栈指针寄存器皆从TSS段结构中取得。)在使用TSS段结构前,必须使用LTR汇编指令将其加载至TR寄存器,这个过程只能在进入保护模式后执行。此表也必须保存在全局描述符表GDT中,而且任务切换不会影响其他段描述符、LDT表、TSS段结构以及TSS段描述符的自由创建。只有处理器才能在任务切换时置位TSS段描述符的忙状态位,否则忙状态位始终保持复位状态。如果既不想开启多任务机制,也不允许改变特权级,则无需加载TR任务寄存器,也无需创建TSS段结构。

    相信读者已经知道哪些任务必须在进入保护模式之前完成,再结合第6章关于描述符结构的知识,方可轻松应对保护模式切换前的数据准备工作。代码清单3-25是为向保护模式切换而准备的系统数据结构。

    代码清单3-25 第3章\程序\程序3-4\loader.asm

    [SECTION gdt]
    
    LABEL_GDT:            dd    0,0
    LABEL_DESC_CODE32:    dd    0x0000FFFF,0x00CF9A00
    LABEL_DESC_DATA32:    dd    0x0000FFFF,0x00CF9200
    
    GdtLen    equ    $ - LABEL_GDT
    GdtPtr    dw     GdtLen - 1
              dd     LABEL_GDT
    
    SelectorCode32    equ    LABEL_DESC_CODE32 - LABEL_GDT
    SelectorData32    equ    LABEL_DESC_DATA32 - LABEL_GDT

    本段程序创建了一个临时GDT表。为了避免保护模式段结构的复杂性,此处将代码段和数据段的段基地址都设置在0x00000000地址处,段限长为0xffffffff,即段可以索引0~4 GB内存地址空间。更多信息可以参照第6章的相关内容,这里暂不做详细讲解。

    因为GDT表的基地址和长度必须借助LGDT汇编指令才能加载到GDTR寄存器,而GDTR寄存器是一个6 B的结构,结构中的低2 B保存GDT表的长度,高4 B保存GDT表的基地址,标识符GdtPtr是此结构的起始地址。这个GDT表曾经用于开启Big Real Mode模式,由于其数据段被设置成平坦地址空间(0~4 GB地址空间),故此FS段寄存器可以寻址整个4 GB内存地址空间。

    代码中的标识符SelectorCode32SelectorData32是两个段选择子(Selector),它们是段描述符在GDT表中的索引号。

    除了必须为GDT手动创建初始数据结构外,还需要为IDT开辟内存空间,代码清单3-26是详细的内存空间申请代码。

    代码清单3-26 第3章\程序\程序3-4\loader.asm

    ;=======      tmp IDT
    
    IDT:
        times     0x50    dq    0
    IDT_END:
    
    IDT_POINTER:
            dw    IDT_END - IDT - 1
            dd    IDT

    在处理器切换至保护模式前,引导加载程序已使用CLI指令禁止外部中断,所以在切换到保护模式的过程中不会产生中断和异常,进而不必完整地初始化IDT,只要有相应的结构体即可。如果能够保证处理器在模式切换的过程中不会产生异常,即使没有IDT也可以。

    当保护模式的系统数据结构准备就绪后,便可着手编写模式切换程序。处理器从实模式进入保护模式的契机是,执行MOV汇编指令置位CR0控制寄存器的PE标志位(可同时置位CR0寄存器的PG标志位以开启分页机制)。进入保护模式后,处理器将从0特权级(CPL=0)开始执行。为了保证代码在不同种Intel处理器中的前后兼容性,建议遵循以下步骤执行模式切换操作(请参考Intel官方白皮书Volume 3的9.9.1节)。

    (1) 执行CLI汇编指令禁止可屏蔽硬件中断,对于不可屏蔽中断NMI只能借助外部电路才能禁止。(模式切换程序必须保证在切换过程中不能产生异常和中断。)

    (2) 执行LGDT汇编指令将GDT的基地址和长度加载到GDTR寄存器。

    (3) 执行MOV CR0汇编指令位置CR0控制寄存器的PE标志位。(可同时置位CR0控制寄存器的PG标志位。)

    (4) 一旦MOV CR0汇编指令执行结束,紧随其后必须执行一条远跳转(far JMP)或远调用(far CALL)指令,以切换到保护模式的代码段去执行。(这是一个典型的保护模式切换方法。)

    (5) 通过执行JMPCALL指令,可改变处理器的执行流水线,进而使处理器加载执行保护模式的代码段。

    (6) 如果开启分页机制,那么MOV CR0指令和JMP/CALL(跳转/调用)指令必须位于同一性地址映射的页面内。(因为保护模式和分页机制使能后的物理地址,与执行JMP/CALL指令前的线性地址相同。)至于JMPCALL指令的目标地址,则无需进行同一性地址映射(线性地址与物理地址重合)。

    (7) 如需使用LDT,则必须借助LLDT汇编指令将GDT内的LDT段选择子加载到LDTR寄存器中。

    (8) 执行LTR汇编指令将一个TSS段描述符的段选择子加载到TR任务寄存器。处理器对TSS段结构无特殊要求,凡是可写的内存空间均可。

    (9) 进入保护模式后,数据段寄存器仍旧保留着实模式的段数据,必须重新加载数据段选择子或使用JMP/CALL指令执行新任务,便可将其更新为保护模式。(执行步骤(4)的JMPCALL指令已将代码段寄存器更新为保护模式。)对于不使用的数据段寄存器(DS和SS寄存器除外),可将NULL段选择子加载到其中。

    (10) 执行LIDT指令,将保护模式下的IDT表的基地址和长度加载到IDTR寄存器。

    (11) 执行STI指令使能可屏蔽硬件中断,并执行必要的硬件操作使能NMI不可屏蔽中断。

    掌握上述切换步骤后,编程思路就非常清晰了。在实际编码时,不必严格遵照标准步骤执行,可适当做些调整和变通。本系统编写的保护模式切换代码就在标准步骤的基础上做出了适当调整,代码清单3-27是模式切换步骤的详细程序实现。

    代码清单3-27 第3章\程序\程序3-4\loader.asm

    ;=======    init IDT GDT goto protect mode
    
        cli            ;======close interrupt
    
        db     0x66
        lgdt   [GdtPtr]
    
    ;   db     0x66
    ;   lidt   [IDT_POINTER]
    
        mov    eax,    cr0
        or     eax,    1
        mov    cr0,    eax
    
        jmp    dword SelectorCode32:GO_TO_TMP_Protect

    这段代码在执行加载描述符表指令之前均插入一个字节db 0x66,这个字节是LGDT和LIDT汇编指令的前缀,用于修饰当前指令的操作数是32位宽。而最后一条远跳转指令已明确指定跳转的目标代码段选择子和段内偏执地址。如果在GO_TO_TMP_Protect地址处设置断点,或者使用汇编代码jmp $将处理器暂停在此处,通过Bochs虚拟机终端命令行查看处理器当前的段寄存器信息,如下所示:

    <bochs:2> sreg
    es:0x1000, dh=0x00009301, dl=0x0000ffff, valid=1
        Data segment, base=0x00010000, limit=0x0000ffff, Read/Write, Accessed
    cs:0x0008, dh=0x00cf9b00, dl=0x0000ffff, valid=1
        Code segment, base=0x00000000, limit=0xffffffff, Execute/Read, Non-Conforming, Accessed, 32-bit
    ss:0x0000, dh=0x00009300, dl=0x0000ffff, valid=7
        Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
    ds:0x1000, dh=0x00009301, dl=0x0000ffff, valid=3
        Data segment, base=0x00010000, limit=0x0000ffff, Read/Write, Accessed
    fs:0x0010, dh=0x00cf9300, dl=0x0100ffff, valid=1
        Data segment, base=0x00000100, limit=0xffffffff, Read/Write, Accessed
    gs:0xb800, dh=0x0000930b, dl=0x8000ffff, valid=7
        Data segment, base=0x000b8000, limit=0x0000ffff, Read/Write, Accessed
    ldtr:0x0000, dh=0x00008200, dl=0x0000ffff, valid=1
    tr:0x0000, dh=0x00008b00, dl=0x0000ffff, valid=1
    gdtr:base=0x0000000000010040, limit=0x17
    idtr:base=0x0000000000000000, limit=0x3ff

    在上述段寄存器信息里,CS段寄存器中的段基地址base、段限长limit以及其他段属性,自汇编代码jmp dwordSelectorCode32:GO_TO_TMP_Protect执行后皆发生了改变。与此同时,GDTR寄存器中的数据已更新为GdtPtr结构记录的GDT表基地址和长度。

    再使用info flags命令查看EFLAGS标志寄存器各个标志位的状态。以下是具体查询信息,从中可见PF标志位已被置位(大写字母):

    <bochs:3> info flags
    id vip vif ac vm rf nt IOPL=0 of df if tf sf zf af PF cf

    如果向终端命令行输入命令q退出虚拟机,则会显示如下信息,来说明处理器当前的运行状态,其中的日志信息CPU is in protected mode (active)已表明处理器正运行在保护模式下:

    <bochs:7> q
    00250932000i[      ] dbg: Quit
    00250932000i[CPU0  ] CPU is in protected mode (active)
    00250932000i[CPU0  ] CS.mode = 32 bit
    00250932000i[CPU0  ] SS.mode = 16 bit
    00250932000i[CPU0  ] EFER   = 0x00000000
    00250932000i[CPU0  ] | EAX=60000011  EBX=00004180  ECX=0000001e  EDX=00000e00
    00250932000i[CPU0  ] | ESP=00007c00  EBP=000008ee  ESI=0000804c  EDI=00009700
    00250932000i[CPU0  ] | IOPL=0 id vip vif ac vm rf nt of df if tf sf zf af PF cf
    00250932000i[CPU0  ] | SEG sltr(index|ti|rpl)     base    limit G D
    00250932000i[CPU0  ] |  CS:0008( 0001| 0|  0) 00000000 ffffffff 1 1
    00250932000i[CPU0  ] |  DS:1000( 0005| 0|  0) 00010000 0000ffff 0 0
    00250932000i[CPU0  ] |  SS:0000( 0005| 0|  0) 00000000 0000ffff 0 0
    00250932000i[CPU0  ] |  ES:1000( 0005| 0|  0) 00010000 0000ffff 0 0
    00250932000i[CPU0  ] |  FS:0010( 0002| 0|  0) 00000100 ffffffff 1 1
    00250932000i[CPU0  ] |  GS:b800( 0005| 0|  0) 000b8000 0000ffff 0 0
    00250932000i[CPU0  ] | EIP=00010390 (00010390)
    00250932000i[CPU0  ] | CR0=0x60000011 CR2=0x00000000
    00250932000i[CPU0  ] | CR3=0x00000000 CR4=0x00000000
    (0).[250932000] [0x000000010390] 0008:0000000000010390 (unk. ctxt): jmp .-5 (0x00010390)      ; e9fbffffff
    00250932000i[CMOS  ] Last time is 1431928744 (Mon May 18 13:59:04 2015)
    00250932000i[XGUI  ] Exit
    00250932000i[SIM   ] quit_sim called with exit code 0
    [root@localhost bochs-2.6.8]#

    经过漫长的准备工作,处理器终于进入到保护模式。这仅仅是一个开始,后面还有许多挑战在等着我们。当处理器进入保护模式后,紧接着将会再转入IA-32e模式(64位模式)。

  2. 从保护模式进入IA-32e模式

    学习了保护模式的切换过程后,再来学习IA-32e模式的切换过程就容易得多。Intel官方白皮书依然对IA-32e模式的初始化过程做出了详细的描述(请参考Intel官方白皮书Volume 3的9.8.5节、9.8.5.1节和9.8.5.2节)。

    在进入IA-32e模式前,处理器依然要为IA-32e模式准备执行代码、必要的系统数据结构以及配置相关控制寄存器。与此同时,还要求处理器只能在开启分页机制的保护模式下切换到IA-32e模式。

    • 系统数据结构。当IA-32e模式激活后,系统各描述符表寄存器(GDTR、LDTR、IDTR、TR)依然沿用保护模式的描述符表。由于保护模式的描述符表基地址是32位,这使得它们均位于低4 GB线性地址空间内。既然已经开启IA-32e模式,那么系统各描述符表寄存器理应(必须)重新加载(借助LGDTLLDTLIDTLTR指令)为IA-32e模式的64位描述符表。
    • 中断和异常。当软件激活IA-32e模式后,中断描述符表寄存器IDTR仍然使用保护模式的中断描述符表,那么在将IDTR寄存器更新为64位中断描述符表IDT前不要触发中断和异常,否则处理器会把32位兼容模式的中断门解释为64位中断门,从而导致不可预料的结果。使用CLI指令能够禁止可屏蔽硬件中断,而NMI不可屏蔽中断,必须借助外部硬件电路才可禁止。

    IA32_EFER寄存器(位于MSR寄存器组内)的LME标志位用于控制IA-32e模式的开启与关闭,该寄存器会伴随着处理器的重启(重置)而清零。IA-32e模式的页管理机制将物理地址扩展为四层页表结构。在IA-32e模式激活前(CR0.PG=1,处理器运行在32位兼容模式),CR3控制寄存器仅有低32位可写入数据,从而限制页表只能寻址4 GB物理内存空间,也就是说在初始化IA-32e模式时,分页机制只能使用前4 GB物理地址空间。一旦激活IA-32e模式,软件便可重定位页表到物理内存空间的任何地方。以下是IA-32e模式的标准初始化步骤。

    (1) 在保护模式下,使用MOV CR0汇编指令复位CR0控制寄存器的PG标志位,以关闭分页机制。(此后的指令必须位于同一性地址映射的页面内。)

    (2) 置位CR4控制寄存器的PAE标志位,开启物理地址扩展功能(PAE)。在IA-32e模式的初始化过程中,如果PAE功能开启失败,将会产生通用保护性异常(#GP)。

    (3) 将页目录(顶层页表PML4)的物理基地址加载到CR3控制寄存器中。

    (4) 置位IA32_EFER寄存器的LME标志位,开启IA-32e模式。

    (5) 置位CR0控制寄存器的PG标志位开启分页机制,此时处理器会自动置位IA32_ERER寄存器的LMA标志位。当执行MOV CR0指令开启分页机制时,其后续指令必须位于同一性地址映射的页面内(直至处理器进入IA-32e模式后,才可以使用非同一性地址映射的页面)。

    如果试图改变IA32_EFER.LME、CR0.PG和CR4.PAE等影响IA-32e模式开启的标志位,处理器会进行64位模式的一致性检测,以确保处理器不会进入未定义模式或不可预测的运行状态。如果一致性检测失败,处理器将会产生通用保护性异常(#GP)。以下境遇会导致一致性检测失败。

    • 当开启分页机制后,再试图使能或禁止IA-32e模式。
    • 当开启IA-32e模式后,试图在开启物理地址扩展(PAE)功能前使能分页机制。
    • 在激活IA-32e模式后,试图禁止物理地址扩展(PAE)。
    • 当CS段寄存器的L位被置位时,再试图激活IA-32e模式。
    • 如果TR寄存器加载的是16位TSS段结构。

    在学习了关于IA-32e模式的切换知识后,再结合第6章关于IA-32e模式的相关内容,相信IA-32e模式切换的编程思路已经非常清晰。代码清单3-28是为了切换到IA-32e模式而准备的临时GDT表结构数据。

    代码清单3-28 第3章\程序\程序3-4\loader.asm

    [SECTION gdt64]
    
    LABEL_GDT64:          dq    0x0000000000000000
    LABEL_DESC_CODE64:    dq    0x0020980000000000
    LABEL_DESC_DATA64:    dq    0x0000920000000000
    
    GdtLen64      equ     $ - LABEL_GDT64
    GdtPtr64      dw      GdtLen64 - 1
                  dd      LABEL_GDT64
    
    SelectorCode64  equ  LABEL_DESC_CODE64 - LABEL_GDT64
    SelectorData64  equ  LABEL_DESC_DATA64 - LABEL_GDT64

    IA-32e模式的段结构与保护模式的段结构极其相似,不过此处的数据显得更为简单。因为IA-32e模式简化了保护模式的段结构,删减掉冗余的段基地址和段限长,使段直接覆盖整个线性地址空间,进而变成平坦地址空间。当准备好段结构的初始化信息后,方可从GO_TO_TMP_Protect地址处开始执行IA-32e模式的切换程序,请看代码清单3-29。

    代码清单3-29 第3章\程序\程序3-4\loader.asm

    [SECTION .s32]
    [BITS 32]
    
    GO_TO_TMP_Protect:
    
    ;=======   go to tmp long mode
    
        mov    ax,    0x10
        mov    ds,    ax
        mov    es,    ax
        mov    fs,    ax
        mov    ss,    ax
        mov    esp,   7E00h
    
        call   support_long_mode
        test   eax,   eax
    
        jz    no_support

    一旦进入保护模式,首要任务是初始化各个段寄存器以及栈指针,然后检测处理器是否支持IA-32e模式(或称长模式)。如果不支持IA-32e模式就进入待机状态,不做任何操作。如果支持IA-32e模式,则开始向IA-32e模式切换。

    代码清单3-30是IA-32e模式的检测模块,通过此模块可检测出处理器是否支持IA-32e模式。

    代码清单3-30 第3章\程序\程序3-4\loader.asm

    ;=======   test support long mode or not
    
    support_long_mode:
    
        mov    eax,    0x80000000
        cpuid
        cmp    eax,    0x80000001
        setnb          al
        jb     support_long_mode_done
        mov    eax,    0x80000001
        cpuid
        bt     edx,    29
        setc           al
    support_long_mode_done:
    
        movzx  eax,    al
        ret
    ;=======   no support
    
    no_support:
        jmp    $

    由于CPUID汇编指令的扩展功能项0x80000001的第29位,指示处理器是否支持IA-32e模式,故此本段程序首先检测当前处理器对CPUID汇编指令的支持情况,判断该指令的最大扩展功能号是否超过0x8000000。只有当CPUID指令的扩展功能号大于等于0x80000001时,才有可能支持64位的长模式,因此要先检测CPUID指令支持的扩展功能号,再读取相应的标志位。最后将读取的结果存入EAX寄存器供模块调用者判断。以下是对CPUID指令的概括描述。

    • EFLAGS标志寄存器的ID标志位(第21位)表明处理器是否支持CPUID指令。如果程序可以操作(置位和复位)此标志位,则说明处理器支持CPUID指令,CPUID指令在64位模式和32位模式的执行效果相同。
    • CPUID指令会根据EAX寄存器传入的基础功能号(有时还需要向ECX寄存器传入扩展功能号),查询处理器的鉴定信息和机能信息,其返回结果将保存在EAX、EBX、ECX和EDX寄存器中。

    如果处理器支持IA-32e模式,接下来将为IA-32e模式配置临时页目录项和页表项,代码清单3-31是页目录项和页表项的配置过程。

    代码清单3-31 第3章\程序\程序3-4\loader.asm

    ;=======       init template page table 0x90000
    
        mov dword  [0x90000],    0x91007
        mov dword  [0x90800],    0x91007
    
        mov dword  [0x91000],    0x92007
    
        mov dword  [0x92000],    0x000083
        mov dword  [0x92008],    0x200083
        mov dword  [0x92010],    0x400083
        mov dword  [0x92018],    0x600083
        mov dword  [0x92020],    0x800083
        mov dword  [0x92028],    0xa00083

    这段程序将IA-32e模式的页目录首地址设置在0x90000地址处,并相继配置各级页表项的值(该值由页表起始地址和页属性组成)。关于页表属性的描述请参见第6章分页机制的内容。

    现在,向IA-32e模式切换的系统数据结构均已准备好,随后借助代码清单3-32重新加载全局描述符表GDT,并初始化大部分段寄存器。

    代码清单3-32 第3章\程序\程序3-4\loader.asm

    ;=======       load GDTR
    
        db  0x66
        lgdt       [GdtPtr64]
        mov ax,    0x10
        mov ds,    ax
        mov es,    ax
        mov fs,    ax
        mov gs,    ax
        mov ss,    ax
    
        mov esp,   7E00h

    代码清单3-32使用LGDT汇编指令,加载IA-32e模式的临时GDT表到GDTR寄存器中,并将临时GDT表的数据段初始化到各个数据段寄存器(除CS段寄存器外)中。由于代码段寄存器CS不能采用直接赋值的方式来改变,所以必须借助跨段跳转指令(far JMP)或跨段调用指令(far CALL)才能实现改变。

    执行完这段代码后,可将虚拟机暂停在此处来查看处理器当前各段寄存器的状态,从而验证程序的执行效果。以下是段寄存器此刻的状态信息:

    <bochs:2> sreg
    es:0x0010, dh=0x00009300, dl=0x00000000, valid=1
        Data segment, base=0x00000000, limit=0x00000000, Read/Write, Accessed
    cs:0x0008, dh=0x00cf9b00, dl=0x0000ffff, valid=1
        Code segment, base=0x00000000, limit=0xffffffff, Execute/Read, Non-Conforming, Accessed, 32-bit
    ss:0x0010, dh=0x00009300, dl=0x00000000, valid=1
        Data segment, base=0x00000000, limit=0x00000000, Read/Write, Accessed
    ds:0x0010, dh=0x00009300, dl=0x00000000, valid=1
        Data segment, base=0x00000000, limit=0x00000000, Read/Write, Accessed
    fs:0x0010, dh=0x00009300, dl=0x00000000, valid=1
        Data segment, base=0x00000000, limit=0x00000000, Read/Write, Accessed
    gs:0x0010, dh=0x00009300, dl=0x00000000, valid=1
        Data segment, base=0x00000000, limit=0x00000000, Read/Write, Accessed
    ldtr:0x0000, dh=0x00008200, dl=0x0000ffff, valid=1
    tr:0x0000, dh=0x00008b00, dl=0x0000ffff, valid=1
    gdtr:base=0x0000000000010060, limit=0x17
    idtr:base=0x0000000000000000, limit=0x3ff

    当DS、ES、FS、GS、SS段寄存器加载了IA-32e模式的段描述符后,它们的段基地址和段限长皆已失效(全部清零),而代码段寄存器CS依然运行在保护模式下,其段基地址和段限长仍然有效。

    继续执行IA-32e模式的切换程序,代码清单3-33通过置位CR4控制寄存器的PAE标志位,开启物理地址扩展功能(PAE)。

    代码清单3-33 第3章\程序\程序3-4\loader.asm

    ;=======        open PAE
    
        mov eax,    cr4
        bts eax,    5
        mov cr4,    eax

    CR4控制寄存器的第5位是PAE功能的标志位,置位该标志位可开启PAE。当开启PAE功能后,下一步将临时页目录的首地址设置到CR3控制寄存器中。代码清单3-34完成了CR3控制寄存器的设置工作。

    代码清单3-34 第3章\程序\程序3-4\loader.asm

    ;=======        load    cr3
    
        mov eax,    0x90000
        mov cr3,    eax

    在向保护模式切换的过程中未开启分页机制,便是考虑到稍后的IA-32e模式切换过程必须关闭分页机制重新构造页表结构。

    按照官方提供的模式切换步骤,当页目录基地址已加载到CR3控制寄存器,接下来就可通过置位IA32_EFER寄存器的LME标志位激活IA-32e模式,完整程序实现如代码清单3-35所示。

    代码清单3-35 第3章\程序\程序3-4\loader.asm

    ;=======        enable long-mode
    
        mov ecx,    0C0000080h        ;IA32_EFER
        rdmsr
    
        bts eax,    8
        wrmsr

    IA32_EFER寄存器位于MSR寄存器组内,它的第8位是LME标志位。为了操作IA32_EFER寄存器,必须借助特殊汇编指令RDMSR/WRMSR。以下是对RDMSRWRMSR指令的概括描述。

    • 借助RDMSR/WRMSR指令可以访问64位的MSR寄存器。在访问MSR寄存器前,必须向ECX寄存器(在64位模式下,RCX寄存器的高32位被忽略)传入寄存器地址。而目标MSR寄存器则是由EDX:EAX组成的64位寄存器代表,其中的EDX寄存器保存MSR寄存器的高32位,EAX寄存器保存低32位。(在64位模式下,RAX和RDX寄存器的高32位均为零。)
    • RDMSRWRMSR指令必须在0特权级或实模式下执行。在使用这两条指令之前,应该使用CPUID指令(CPUID.01h:EDX[5] = 1)来检测处理器是否支持MSR寄存器组。

    最后再使能分页机制(置位CR0控制寄存器的PG标志位),就完成了IA-32e模式的切换工作。代码清单3-36是CR0寄存器的操作代码,保险起见,这里再次使能了保护模式(置位PE标志位)。

    代码清单3-36 第3章\程序\程序3-4\loader.asm

    ;=======        open PE and paging
    
        mov eax,    cr0
        bts eax,    0
        bts eax,    31
        mov cr0,    eax

    至此,处理器进入IA-32e模式。但是处理器目前正在执行保护模式的程序,这种状态叫作兼容模式(Compatibility Mode),即运行在IA-32e模式(64位模式)下的32位模式程序。若想真正运行在IA-32e模式,还需要一条跨段跳转/调用指令将CS段寄存器的值更新为IA-32e模式的代码段描述符。

3.2.4 从Loader跳转到内核程序

经过上面的精心准备后,此刻仅需一条远跳转/调用指令,便可切换到IA-32e模式。这条指令与实模式切换至保护模式的远跳转/调用指令用法相同,必须明确指定跳转目标的段选择子和段内偏移地址,代码清单3-37是详细跳转代码。

代码清单3-37 第3章\程序\程序3-4\loader.asm

jmp SelectorCode64:OffsetOfKernelFile

执行这条远跳转指令后,随即进入64位IA-32e模式。由于目前还未编写内核程序,暂且使用假程序文件kernel.bin代替。

开启Bochs虚拟机,使用b命令在物理地址0x100000处设置一个断点,然后再使用c命令运行虚拟机。当处理器进入IA-32e模式,进而跳转至地址0x100000处执行内核程序时,将会触发断点并进入到终端命令行,终端显示的信息如下:

00025008363i[BXVGA ] VBE enabling x 1440, y 900, bpp 32, 5184000 bytes visible
(0) Breakpoint 1, 0x0000000000100000 in ?? ()
Next at t=25009439
(0) [0x000000100000] 0008:0000000000100000 (unk. ctxt): mov ax, 0x0010            ; 66b81000
<bochs:3>

在触发断点进入终端命令行时,Bochs虚拟机的界面窗口尺寸也相继发生变化,这是因为此前使用BIOS中断服务程序INT 10h设置的显示模式已经生效。日志内容里的字符串VBE enabling x 1440, y 900, bpp 32, 5184000 bytes visible已经描述了虚拟机界面窗口的显示分辨率、颜色深度、可见内存容量等信息。读者可以参考第7章关于VBE规范的知识自行设置显示模式。

接下来,向终端命令行输入sreg命令查看各个段寄存器的状态,详细的段寄存器状态信息如下:

<bochs:3> sreg
es:0x0010, dh=0x00009300, dl=0x00000000, valid=1
    Data segment, base=0x00000000, limit=0x00000000, Read/Write, Accessed
cs:0x0008, dh=0x00209900, dl=0x00000000, valid=1
    Code segment, base=0x00000000, limit=0x00000000, Execute-Only, Non-Conforming, Accessed, 64-bit
ss:0x0010, dh=0x00009300, dl=0x00000000, valid=1
    Data segment, base=0x00000000, limit=0x00000000, Read/Write, Accessed
ds:0x0010, dh=0x00009300, dl=0x00000000, valid=1
    Data segment, base=0x00000000, limit=0x00000000, Read/Write, Accessed
fs:0x0010, dh=0x00009300, dl=0x00000000, valid=1
    Data segment, base=0x00000000, limit=0x00000000, Read/Write, Accessed
gs:0x0010, dh=0x00009300, dl=0x00000000, valid=1
    Data segment, base=0x00000000, limit=0x00000000, Read/Write, Accessed
ldtr:0x0000, dh=0x00008200, dl=0x0000ffff, valid=1
tr:0x0000, dh=0x00008b00, dl=0x0000ffff, valid=1
gdtr:base=0x0000000000010060, limit=0x17
idtr:base=0x0000000000000000, limit=0x3ff

从这段信息可以看出,处理器的所有段寄存器均被赋值为IA-32e模式的段描述符,特别是经过远跳转后的代码段寄存器CS,它也运行在IA-32e模式下。

再向终端命令行输入命令q退出虚拟机。下面是虚拟机在退出过程中显示的日志信息,其中的字符串CPU is in long mode (active)已清楚表明处理器当前正运行在长模式,并处于激活状态。而且,所有通用寄存器也从32位扩展为64位,读者还可以通过其他寄存器的标志位状态验证IA-32e模式的有效性:

<bochs:3> q
00020848011i[      ] dbg: Quit
00020848011i[CPU0  ] CPU is in long mode (active)
00020848011i[CPU0  ] CS.mode = 64 bit
00020848011i[CPU0  ] SS.mode = 64 bit
00020848011i[CPU0  ] EFER   = 0x00000500
00020848011i[CPU0  ] | RAX=00000000e0000011  RBX=0000000000000000
00020848011i[CPU0  ] | RCX=00000000c0000080  RDX=0000000000000000
00020848011i[CPU0  ] | RSP=0000000000007e00  RBP=00000000000008ea
00020848011i[CPU0  ] | RSI=000000000000804c  RDI=0000000000009700
00020848011i[CPU0  ] |  R8=0000000000000000   R9=0000000000000000
00020848011i[CPU0  ] | R10=0000000000000000  R11=0000000000000000
00020848011i[CPU0  ] | R12=0000000000000000  R13=0000000000000000
00020848011i[CPU0  ] | R14=0000000000000000  R15=0000000000000000
00020848011i[CPU0  ] | IOPL=0 id vip vif ac vm rf nt of df if tf sf zf af pf cf
00020848011i[CPU0  ] | SEG sltr(index|ti|rpl)     base    limit G D
00020848011i[CPU0  ] |  CS:0008( 0001| 0|  0) 00000000 00000000 0 0
00020848011i[CPU0  ] |  DS:0010( 0002| 0|  0) 00000000 00000000 0 0
00020848011i[CPU0  ] |  SS:0010( 0002| 0|  0) 00000000 00000000 0 0
00020848011i[CPU0  ] |  ES:0010( 0002| 0|  0) 00000000 00000000 0 0
00020848011i[CPU0  ] |  FS:0010( 0002| 0|  0) 00000000 00000000 0 0
00020848011i[CPU0  ] |  GS:0010( 0002| 0|  0) 00000000 00000000 0 0
00020848011i[CPU0  ] |  MSR_FS_BASE:0000000000000000
00020848011i[CPU0  ] |  MSR_GS_BASE:0000000000000000
00020848011i[CPU0  ] | RIP=0000000000100000 (0000000000100000)
00020848011i[CPU0  ] | CR0=0xe0000011 CR2=0x0000000000000000
00020848011i[CPU0  ] | CR3=0x00090000 CR4=0x00000020
(0).[20848011] [0x000000100000] 0008:0000000000100000 (unk. ctxt): jnle .+69 (0x0000000000100047) ; 7f45
00020848011i[CMOS  ] Last time is 1432024299 (Tue May 19 16:31:39 2015)
00020848011i[XGUI  ] Exit
00020848011i[SIM   ] quit_sim called with exit code 0
[root@localhost bochs-2.6.8]#

伴随着Loader引导加载程序最后一条指令(远跳转指令)的执行,处理器的控制权就移交到了内核程序手上。此刻,Loader引导加载程序已完成了它的使命,其占用的内存空间可以释放或另作他用。

目前系统虽已进入IA-32e模式,但这只是临时中转模式,接下来的内核程序将会为系统重新创建IA-32e模式的段结构和页表结构。

通过本章的学习,相信读者收获颇多。下面将进入内核程序的开发环节。一个全新的冒险即将开始!

目录