概述
本章主要描述boot/目录中的三个汇编代码文件,见列表6-1所示。正如在前一章中提到的,这三个文件虽然都是汇编程序,但却使用了两种语法格式。bootsect.s和setup.s是实模式下运行的16位代码程序,采用近似于Intel的汇编语言语法并且需要使用Intel 8086汇编编译器和连接器as86和ld86,而head.s则使用GNU的汇编程序格式,并且运行在保护模式下,需要用GNU的as(gas)进行编译。这是一种AT&T语法的汇编语言程序。
Linus当时使用两种汇编编译器的主要原因在于对于Intel x86处理器系列来讲,Linus那时的GNU汇编编译器仅能支持i386及以后出的CPU代码指令,若不采用特殊方法就不能支持生成运行在实模式下的16位代码程序。直到1994年以后发布的GNU as汇编器才开始支持编译16位代码的.code16伪指令。参见GNU汇编器手册<<Using as -The GNU Assembler>>中"80386"相关特性”一节中"编写16位代码"小节。但直到内核版本2.4.X起,bootsects.s和setup.s程序才完全使用统一的as来编写。
总体功能
这里先总体说明一下Linux操作系统启动部分的主要执行流程。当PC的电源打开后,80x86结构的CPU将自动进入实模式,并从地址0xFFFF0开始自动执行程序代码,这个地址通常是ROM-BIOS中的地址。PC机的BIOS将执行某些系统的检测,并在物理地址0处开始初始化中断向量。此后,它将可启动设备的第一个扇区(磁盘引导扇区,512字节)读入内存绝对地址0x7C00处,并跳转到这个地方。启动设备通常是软驱或是硬盘。这里的叙述是非常简单的,但这已经足够理解内核初始化的工作过程了。
Linux的最最前面部分是用8086汇编语言写的(boot/bootsect.s),它将由BIOS读入到内存绝对地址0x7C00(31KB)处,当它被执行时就会把自己移动到内存绝对地址0x90000(576KB)处,并把启动设备中后2KB字节代码(boot/setup.s)读入到内存0x90200处,而内核的其他部分(system模块)则被读入到从内存地址(0x10000)(64KB)开始处,因此从机器加电开始顺序执行的程序见图6-1所示。
因为当时system模块的长度不会超过0x80000字节大小(即512KB),所以bootsect程序把system模块读入物理地址0x10000开始位置处时并不会覆盖在0x90000(576KB)处开始的bootsect和setup模块。后面setup程序将会把system模块移动到物理内存起始位置处,这样system模块中代码的地址也即等于实际的物理地址,便于对内核代码和数据进行操作。图6-2清晰地显示出Linux系统启动时这几个程序或模块在内存中的动态位置。其中,每一竖条框代表某一时刻内存中各程序的映像位置图。在系统加载期间将显示信息"Loading......"。然后控制权将传递给boot/setup.s中的代码,这是另一个实模式汇编语言程序。
启动部分识别主机的某些特性以及VGA卡的类型。如果需要,它会要求用户为控制台选择显示模式。然后将整个系统从地址0x10000移至0x0000处,进入保护模式并跳转至系统的余下部分(在0x0000处)。此时所有32位运行方式的设置启动被完成:IDT、GDT以及LDT被加载,处理器和协处理器也已确认,分页工作也设置好了;最终调用init/main.c中的main()程序。上述操作的源代码是在boot/head.s中的,这可能是整个内核中最有诀窍的代码了。注意如果在前述任何一步中出了错,计算机就会死锁。
bootsect的代码为什么不把系统模块直接加载到物理地址0x0000开始处而要在setup程序中再进行移动呢?这是因为在随后执行的setup代码开始部分还需要利用ROM BIOS中的中断调用来获取机器的一些参数(例如显示卡模式、硬盘参数表等)。当BIOS初始化时会在物理内存开始处放置一个大小为0x400字节(1KB)的中断向量表,因此需要在使用完BIOS的中断调用后才能将这个区域覆盖掉。
另外,仅在内存中加载了上述内核代码模块并不能让Linux系统运行起来。作为完整可运行的Linux系统还需要有一个基本的文件系统支持,即根文件系统。Linux0.11内核仅支持MINIX的1.0文件系统。根文件系统通常是在另一个软盘上或者在一个硬盘分区中。为了通知内核所需要的根文件系统在什么地方,bootsect.s程序的第43行上给出了根文件系统所在的默认块设备号。块设备号的含义请参见程序中的注释。在内核初始化时会使用编译内核时放在引导扇区第509、510(0x1fc--0x1fd)字节中的指定设备号。
bootsect.s程序
6.2.1功能描述
bootsect.s代码是磁盘引导块程序,驻留在磁盘的第一个扇区中(引导扇区,0磁道(柱面),0磁头,第1个扇区)。在PC机加电ROM BIOS自检后,ROM BIOS会把引导扇区代码bootsect加载到内存地址0x7C00开始处并执行之。在bootsect代码执行期间,它会将自己移动到内存绝对地址0x90000开始处并继续执行。该程序的主要作用是首先把磁盘第2个扇区开始的4个扇区的setup模块(由setup.s编译而成)加载到内存紧接着bootsect后面位置处(0x90200),然后利用BIOS中断0x13取磁盘参数表中当前启动引导盘的参数,接着在屏幕上显示"Loading system......"字符串。再者把磁盘上setup模块后面的system模块加载到内存0x10000开始的地方。随后确定根文件系统的设备号,若没有指定,则根据所保存的引导盘的每磁道扇区数判别出盘的类型和种类(是1.44M A盘吗?)并保存其设备号于root_dev(引导块的508地址处),最后长跳转到setup程序的开始处(0x90200)执行setUp程序。在磁盘上,引导块、setup模块和system模块的扇区位置和大小示意图见图6-3所示。
图中示出了Linux0.11内核在1.44MB磁盘上所占扇区的分布情况。1.44MB磁盘共有2880个扇区,其中引导程序代码占用第1个扇区,setup模块占用随后的4个扇区,而0.11内核system模块大约占随后的240个扇区。还剩下2630多个未被使用。这些剩余的未用空间可被利用来存放一个基本的根文件系统,从而可以创建出使用单张磁盘就能让系统运转起来的集成盘来。这将在块设备驱动程序一章中在详细说明。
6.2.3其他信息
6.2.3.1Linux0.11硬盘设备号
程序中涉及的硬盘设备命名方式如下:硬盘的主设备号是3.其他设备的主设备号分为:1-内存、2-磁盘、3-磁盘、4-ttyx、5-tty、6-并行口、7-非命名管道。由于1个硬盘中可以有1--4个分区,因此硬盘还依据分区的不同用次设备号进行指定分区。因此硬盘的逻辑设备号由以下方式构成:设备号=主设备号*256+次设备号。两个硬盘的所有逻辑设备号见表6-1所示。
6.2.3.2从硬盘启动系统
若需要从硬盘设备启动系统,那么通常需要使用其他多操作系统引导程序来引导系统加载。例如Shoelace、LILO或Grub等多操作系统引导程序。此时bootsects.s所完成的任务会由这些程序来完成。bootsect程序就不会被执行了。因为如果从硬盘启动系统,那么通常内核映像文件Image会存放在活动分区的根文件系统中。因此你就需要知道内核映像文件Image处于文件系统中的位置以及是什么关系系统。即你的引导扇区程序需要能够识别并访问文件系统,并从中读取内核映像文件。
从硬盘启动的基本流程是:系统上电后,可启动硬盘的第1个扇区(主引导记录MBR-MasterBootRecord)会被BIOS加载到内存0x7c00处并开始执行。该程序会首先把自己向下移动到内存0x600处,然后根据MBR中分区表信息所指明活动分区的第1个扇区(引导扇区)加载到内存0x7c00处,然后开始执行之。如果直接使用这种方式来引导系统就会碰到这样一个问题,即根文件系统不能与内核映射文件Image共存。
6.3setup.s程序
6.3.1功能描述
setup.s是一个操作系统加载程序,它的主要作用是利用ROM BIOS中断读取机器系统数据,并将这写数据保存到0x90000开始的位置(覆盖掉了bootsect程序所在的地方),所取得的参数和保留的内存位置见表6-2所示。这些参数将被内存中相关程序使用,例如字符设备驱动程序集中的console.c和tty_io.c程序等。
然后setup程序将system模块从0x10000-0x8ffff(当时认为内核系统模块system的长度不会超过此值:512KB)整块向下移动到内存绝对地址0x00000处。接下来加载中断描述符表寄存器(idtr)和全局描述符表寄存器(gdtr),开启A20地址线,重新设置两个中断控制芯片8259A,将硬件中断重新设置为0x20-0x2f。最后设置CPU的控制寄存器CR0(也称机器状态字),从而进入32位保护模式运行,并跳转到位于system模块最前面部分的head.s程序继续运行。
为了能让head.s在32位保护模式下运行,在本程序中临时设置了中断描述符表(IDT)和全局描述符表(GDT),并在GDT中设置了当前内核代码段的描述符和数据段的描述符。下面在head.s程序中会根据内核的需要重新设置这些描述符表。
下面首先简单介绍一下段描述符的格式、描述符表的结构和段选择符(有些书中称之为位选择子)的格式。Linux内核代码中用到的代码段、数据段描述符的格式见图6-4所示。其中各字段的含义请参见第4章中的说明。
段描述符存放在描述符表中。描述符表其实就是内存中描述符项的一个阵列。描述符表有两大类:全局描述符表(Global descriptor table-GDT)和局部描述表(Local descriptor table-LDT)。处理器是通过使用GDTR和LDTR寄存器来定位GDT表和当前的LDT表。这两个寄存器以线性地址的方式保存了描述符表的基地址和表的长度。指令lgdt和sgdt用于访问GDTR寄存器:指令lldt和sldt用于访问LDTR寄存器。lgdt使用内存中一个6字节操作数来加载GDTR寄存器。头两个字节代表描述符表的长度,后4个字节是描述符表的基地址。然而请注意,访问LDTR寄存器的指令lldt所使用的操作数却是一个2字节的操作数,表示全局描述符表GDT中一个描述符项的选择符。该选择符所对应的GDT表中的描述符项应该对应一个局部描述符表。
例如,setup.s程序设置的GDT描述符项(见程序第207--216行),代码段描述符的值是0x00C09A00000007FF,表示代码段的限长是8MB=(0x7FF+1)*4KB,这里加1是因为限长值是从0开始算起的,段在线性地址空间中的基址是0,段类型值0x9A表示该段存在于内存中,段的特权级别为0、段类型是可读可执行的代码段,段代码是32位的并且段的颗粒度是4KB.数据段描述符的值是0x00C09200000007FF,表示数据段的限长是8MB,段在线性地址空间中的基址是0,段类型值0x92表示该段存在于内存中、段的特权级别为0、段类型示可读可写的数据段,段代码是32为的并且段的颗粒度是4KB。
这里再对选择符进行一些说明。逻辑地址的选择符部分用于指定一描述符,它是通过指定一描述符表并且索引其中的一个描述符项完成的。图6-5示出了选择符的格式。
其中索引值(Index)用于选择指定描述符表中8192(2^13)个描述符中的一个。处理器将该索引值乘上8,并加上描述符表的基地址即可访问表中指定的段描述符。表指示器(Table Indicator-TI)用于指定选择符所引用的描述符表。值为0表示指定GDT表,值为1表示当前的LDT表。请求特权级别(Requestor's Privalege Level-RPL)用于保护机制。
由于GDT表的第一项(索引值为0)没有被使用,因此一个具有索引值0和表指示器值也为0 的选择符(也即指向GDT的第一项的选择符)可以用作为一个空(null)选择符。当一个段寄存器(不能是CS或SS)加载了一个空选择符时,处理器并不会产生一个异常。但是若使用这个段寄存器访问内存时就会产生一个异常。对于初始化还未使用的段寄存器以陷入意外的引用来说,这个特性是很有用的。
在进入保护模式之前,我们必须首先设置好将要用到的段描述符表,例如全局描述符表GDT.然后使用指令lgdt把描述符表的基地址告知CPU(GDT表的基地址存入gdtr寄存器)。再将机器状态子的保护模式标志置位即可进入32位保护运行模式。