在前文结尾处我们提到内核映像的加载是由专用的bootloader比如LILO或是GRUB来实现的,而在x86架构下Linux内核通常使用其中之一的GRUB,它通过执行initrd文件来识别内核映像所在的文件系统进而执行加载,然而有一个需要注意的问题是,并非所有的物理地址空间对内核而言都是可用的,比如其中的某个物理地址范围可能被映射为I/O设备的共享内存,也可能其中的一个物理页框存放着BIOS数据,综合上述原因,GRUB必须建立一个物理地址映射来将内核加载至可用的物理内存中,而建立映射的这一过程则是根据协议完成的,不仅如此,实模式下的内核代码能够占用的内存空间,以及初始化过程中用来建立堆栈的物理内存大小都需要遵守相关的启动协议,有关LINUX/x86启动协议的详细信息可参考Documentation\x86目录下的boot.txt文件,该文件还详细解释了header.S中所定义的全局变量hdr的各个字段的含义,而其重要性单从源文件中各个字段后的注释规模便可见一斑。
物理内存布局
在深入剖析源代码之前,我们有必要首先明确启动阶段物理内存的分布情况,因为只有明白了执行过程背后所依据的一些基本事实才能对源代码有更为深刻的认识,而其中的一些事实源于软硬件的高速发展,另外一些则是为了满足兼容性的要求。
总的来说,内核映像由三部分构成,它们分别是:
- 一个512字节的启动扇区——Kernel boot sector,也即在前文中所剖析的Linux内核自带的bootloader,但由于现在使用的bootloader为GRUB,为了避免产生歧义,所以冠以另外一个名称。该boot sector占用操作系统映像所在逻辑分区的第一个扇区,注意从bootsect的偏移0x1f1处开始存放hdr变量。
- 实模式下的内核安装部分——Kernel setup,连续占用若干个扇区大小的内存空间,主要用于检测硬件环境并执行一系列的初始化,为保护模式下内核代码的运行完成一些前期的准备工作。
- 保护模式下的内核代码,GRUB将其加载至从地址0x10 0000开始的物理内存中。
这里要注意的是实模式下的Kernel boot sector和Kernel setup虽然从逻辑上被分成两部分,但它们的分布是连续的,并且这两部分都处于第一个1MB的物理内存空间中,而其上所提到的保护模式下的内核代码的初始地址为0x10 0000,表示这部分代码从物理内存的第二个1MB开始安装,因此内核映像的实模式及保护模式这两个部分的分界线即物理地址0x10 0000。这里要特别注意的一点是,GRUB不可能单独在实模式下完成上述加载任务,因为在实模式下CPU只能寻址第一个1MB范围内的物理内存空间(原因请参见处理器体系结构及寻址模式一文)。有意思的是,在GRUB的次引导过程执行时将会临时性地切换到保护模式,完成内核映像的加载后再次回到实模式。因此虽然引导加载过程有两种模式的来回切换操作,但对于内核映像来说却是透明的,当控制权转交给内核后,它仍然首先从实模式下开始运行,但此时并不意味着超出第一个1MB范围的物理内存中没有任何内容,只是这些内容占用的内存空间无法被内核当前执行的指令所寻址,这使得保护模式下的内核代码不会被随意修改,也算是对保护模式中“保护”一词的另类诠释。
以下是GRUB将内核映像加载至内存空间后的分布图:
图1
上图同样位于Documentation\x86目录下的boot.txt中。在该文件中还展示了由zImage内核映像所使用的传统内存映射模型,但我们并不过多关注这类历史遗留问题,在后续的代码剖析过程中均以现阶段所使用的一些模型作为基准。在前文结尾处曾提到过BIOS例程将内核放入低地址0x0001 0000(小内核映像zImage)或者从高地址0x0010 0000(大内核映像bzImage)开始的RAM中,而现阶段所使用的Linux内核均编译为bzImage,因此保护模式下的内核代码被安装在从高地址0x0010 0000开始的RAM中。正如上图所示,我们也可以看到在完成POST以及一系列的初始化工作后,BIOS将bootloader加载至物理地址0000 7c00处。而令人困惑的是,内核的启动扇区bootsect的起始地址并未被严格限制,这其实是由于Linux内核允许使用多种bootloader所导致的,比如前文所提到的LILO以及GRUB,在现实情况中存在更多不同类型的bootloader,不同的bootloader可能将实模式的起始地址加载至不同的位置,然而在x86架构下的GRUB设置的起始地址正是0x9 0000。
另外我们也可以看到实模式下的内核代码以及该代码所创建的堆栈的大小至多为8KB,之所以需要创建堆栈是为了提供C语言的运行环境,因为代码的执行过程需要使用栈来保存局部变量,函数调用则需要借助栈来传递参数以及保存返回地址,其中还有可能涉及到动态内存的分配,以及将内存分配给内核命令行。这样一来,实模式下的内核代码需要使用的内存空间将会达到8KB*2=16KB,而该段的起始地址为0x9 0000,因此这段内存空间的结尾处的地址至多将会达到0xA 0000。而这是不允许,因为现代机器中的许多BIOS例程需要使用从起始地址0x9 A000开始的额外内存空间,该内存空间即扩展的BIOS数据区(Extended BIOS Data Area,EBDA)。若内核被GRUB安装至较高的内存空间,那么执行代码将被BIOS修改。事实上实模式下的Linux内核正是基于这一原则所设计的,我们将在后文的源代码剖析过程中看到其具体的实现细节。
我们在前文提到过,代码背后所依据的某些事实是为了满足历史遗留问题所提出的兼容性要求,这一点可从上图中物理地址范围0xA 0000~0x10 0000的内存占用情况得知。追溯至上世纪80年代,当时IBM所推出的第一台PC机可供寻址的物理内存总共为1MB。而这1MB中的低640KB供DOS以及应用程序使用,而高端的384KB则被留作它用,其中低端的640KB被称为常规内存,高端的384KB则被称为保留内存,这两种不同的内存类型通过物理地址0xA 0000得以分隔,此后这个分界线便被确定下来并沿用至今。
简单总结一下,在x86体系结构中,RAM的第一个1MB内存空间包含如下两个“独特”的地方:
- 物理地址0x0000~0x1000以及0x9 A000~0xA 0000所占内存由BIOS使用,存放加电自检(Power-On Self-Test,POST)期间检查到的系统硬件配置。有些类型的BIOS甚至在系统初始化之后依然将数据写入该内存。
- 从0xA 0000~0x10 0000范围内的物理内存通常保存BIOS例程,并且映射ISA图形卡上的内部内存。这个区域就是IBM兼容PC上从640KB到1MB之间的著名的洞——图1中的I/O memory hole:物理地址存在但被保留,不能由操作系统使用。
内核将上述地址范围的物理内存所占页框标记为保留,它们连同内核代码以及已初始化或未初始化的内核数据一起,在整个机器的执行周期中常驻内存,而绝不能被动态分配或由内核调度程序交换到磁盘上。
图2
上图形象地显示了常驻物理内存中各个区域的使用情况,需要注意的一点是保护模式下的内核代码所占页框的总数依赖于对应的配置方案,因此上图中与之对应的区域只是用符号简单的加以表示。而实模式下的内核代码仅在启动期间执行硬件检测及一系列初始化操作之后便不再被使用,因此当实模式下的内核代码跳转到保护模式之后,这部分内存空间即可由内核代码用于其他用途。
实模式下内核的初始化变量——安装头(setup header)
该安装头为从Kernel boot sector中偏移0x1f1处开始的hdr变量,主要存放初始化期间将会使用到的一些数据。我将把该变量中各个字段的含义集中罗列在这里,在后文中讲到内核执行初始化过程中使用到这些数据时不再单独详细描述。另外要注意的是有些字段的存在同样属于历史遗留性问题,对于这些内容我们直接一带而过。下表是这些字段的概要说明:
偏移量/大小 | 字段名 | 含义 |
0x1F1/1 | setup_sects | 表示Kernel setup中的代码所占用的物理存储空间 |
0x1F2/2 | root_flags |
如果被设置,则根以可读形式挂载 |
0x1F4/4 | syssize |
32位代码的大小,该值以16字节的段为单位 |
0x1F8/2 | ram_size |
不再使用——仅由bootsect.S文件使用(已被废弃) |
0x1FA/2 | vid_mode |
视频模式控制 |
0x1FC/2 | root_dev |
默认根设备号 |
0x1FE/2 | boot_flag |
魔数,其值为常量"0xAA55",标识有效引导记录结尾的标签 |
0x200/2 | jump |
跳转指令 |
0x202/4 | header |
魔数标签,其值为"HdrS" |
0x206/2 | version |
启动协议版本支持 |
0x208/4 | realmode_swtch |
引导加载程序钩子 |
0x20C/2 | start_sys_seg |
加载低地址段(已被废弃) |
0x20E/2 | kernel_version |
指向内核版本字符串的指针 |
0x210/1 | type_of_loader |
引导加载程序标识符 |
0x211/1 | loadflags |
引导协议选项标志 |
0x212/2 | setup_move_size |
移动至高位内存所需的大小(已被废弃) |
0x214/4 | code32_start |
引导加载程序钩子 |
0x218/4 | ramdisk_image |
initrd加载地址 |
0x21C/4 | ramdisk_size |
initrd的尺寸 |
0x220/4 | bootsect_kludge |
不再使用——仅由bootsect.S使用(已被废弃) |
0x224/2 | heap_end_ptr |
在setup尾部之后的空闲内存 |
0x226/1 | ext_loader_ver |
扩展的引导加载程序版本 |
0x227/1 | ext_loader_type |
扩展的引导加载的ID |
0x228/4 | cmd_line_ptr |
指向内核命令行的32位指针 |
0x22C/4 | ramdisk_max |
initrd可用的最高地址 |
0x230/4 | kernel_alignment |
对内核要求的物理地址对齐 |
0x234/1 | relocatable_kernel |
内核是否可重定位 |
0x235/1 | min_alignment |
最小对齐,其值要求为2的平方 |
0x236/2 | pad3 |
不再使用(已被废弃) |
0x238/4 | cmdline_size |
内核命令行的最大尺寸 |
0x23C/4 | hardware_subarch |
硬件子架构 |
0x240/8 | hardware_subarch_data |
特定子架构的数据 |
0x248/4 | payload_offset |
内核负载偏移 |
0x24C/4 | payload_length |
内核负载的长度 |
0x250/8 | setup_data |
指向存放setup_data结构体的链表的64位物理指针 |
0x258/8 | pref_address |
首选的加载地址 |
0x260/4 | init_size |
初始化期间的线性地址要求 |
下面单独列出其中某些重要字段更详细的解释,注意所有字段都以小端法存放,并且其中一些字段存放由引导加载程序从内核中读出的信息(即类型为可读),另外一些字段则由引导加载程序填充(类型为可写),其他的字段则由引导加载程序做适当的修改(类型为可修改)。
- setup_sects——占用1个字节,类型为可读,表示Kernel setup所占用的物理内存大小,且以一个512字节的扇区为单位。为了保持后向兼容,如果该字段被赋值为0,那么实际的值是4。实模式代码由boot sector(总是占用一个512字节的扇区大小)加上Kernel setup代码组成。
- syssize——占用4个字节,类型为可读,该字段表示保护模式代码的尺寸,大小以16字节的小段为单元。但对于现在的Linux内核来说,在进行引导配置时仅使用其中的两个字节——两个高位字节不再可用,因此如果LOAD_HIGH标志被置位那么该字段不能被认为是内核的大小。
- jump——占用2个字节,类型为可读。该字段包含x86架构下的跳转指令,0xEB(跳转指令的字节码)后跟一个相对于地址0x202的有符号偏移,这个字段能被用来决定安装头的大小。
- header——占用4个字节,类型为可读,包含魔数"Hdrs"(0x53726448)。如果该魔数没有在偏移0x202处设置,那么启动协议的版本被认为是旧的,因此装载一个老的内核。但我们在源文件header.S中清晰地看到了该标签,因此总是加载bzImage内核映像。并且header字段之后的version字段包含协议的版本,例如若version字段被设置为0x0204则代表使用2.04版本的协议,在源文件中该字段被设置为0x020a,因此表示使用最新的2.10版本的协议。
- kernel_version——占用2个字节,类型为可读,如果该字段被设置为非零值,则表示一个指向以NULL结尾的含有内核版本号的字符串的指针。这能够被用来向用户展示内核版本。字段值应小于0x200*setup_sects。
- type_of_loader——占用1个字节,类型为可写,该字段与ext_loader_type以及ext_loader_ver字段联合起来表示所使用的bootloader的类型及其版本号,由于x86架构下始终使用的是GRUB,因此这里我们不再继续深究,具体细节可参考Documentation\x86目录下的boot.txt文件。
- loadflags——占用1个字节,类型为可修改,这个字段是一个位掩码(bitmask)。第0位(只读):LOADED_HIGH,如果该位置0,那么保护模式下的代码被加载至0x1 0000处,若复位则加载至0x10 0000。第5位(可写):QUIET_FLAG,如果置0则打印早期信息,如果复位则禁止早期信息。第6位(可写):KEEP_SEGMENTS,如果该位置0那么在32位入口点处重新加载段寄存器,如果复位那么不会重新加载。第7位(可写):CAN_USE_HEAP,将该位置1指示字段heap_end_ptr中的值有效,如果这个位被清除,那么一些Kernel setup代码将无法执行。其中最重要的是第0位与第7位。
- code32_start——占用4个字节,类型为可修改。表示在保护模式中的跳转地址,其值默认为内核的加载地址,同时能够用来被bootloader决定合适的加载地址。修改这个字段是出于以下两个目的:①作为引导加载程序的钩子(a boot loader hook),②如果没有安装钩子的bootloader将一个可重定位的内核加载至非标准的地址,那么bootloader将会修改这个字段以指向加载地址。
- ramdisk_image/ramdisk_size——均占用4个字节且类型为可写。这两个字段主要指示initrd的32位线性地址及其尺寸,若不使用initrd则这两位均为0。此外还有一个名为ramdisk_max的字段,同样占用4个字节但类型为可读,表示initrd可用物理内存的最大地址。我们在前文中提到过,initrd主要是由GRUB的次引导程序载入内存,实现一些模块的加载及文件系统的安装。
- heap_end_ptr——占用2个字节,类型为可写。将这个字段设置为Kernel setup中堆栈结尾处距离实模式代码起始部分的偏移减去0x200后的值。
- cmd_line_ptr——占用4个字节,类型为可写。将这个字段设置为内核命令行的线性地址。内核命令行能够被定位至Kernel setup中堆的结尾处至物理地址0xA 0000之间的任何位置,正如实模式代码自身一样,内核命令行同样不一定需要被放置在同一个64KB段中。即使引导加载程序不支持命令行,也需要填充这个字段,在这种情形下可以将其指向一个空字符串。但如果这个字段被置为0,那么内核将假设引导加载程序不支持2.02以上版本的协议(当前所使用的是2.10版本的协议)。
- kernel_alignment——占用4个字节,类型为可读可修改。若relocatable_kernel字段被设置为真,那么这个字段是由内核要求的对齐单元。一个可重定位内核当被加载至对齐方式与当前字段不兼容的地址处,那么在内核的初始化过程中将会被重新对齐。在允许更小对齐的情况下,这个字段可以由引导加载程序修改。
- relocatable_kernel——占用1个字节,类型为可读。如果这个字段非零,内核的保护模式部分能够被加载至满足kernel_alignment字段的任意地址处。完成加载之后bootloader将会设置code32_start字段以指向被加载的代码,或是bootloader钩子。
- min_alignment——占用1个字节,类型为可读。这个字段如果非零那么作为2的幂指示最小对齐要求。如果引导加载程序使用了这个字段,它也应该更新kernel_alignment字段,更新方式为:kernel_alignment=1<<min_alignment。
- cmdline_size——占用4个字节,类型为可读。这个字段表示不考虑结束符0在内的命令行的最大尺寸。这移位这命令行最多能够包含cmdline_size个字符。
- hardware_subarch/hardware_subarch_data——分别占用4个及8个字节,且类型均为可写,这个字段允许bootloader通知内核现在所处的硬件环境。
- payload_offset——占用4个字节,类型为可写。如果非零那么这个字段包含从保护模式代码的起始地址到负载(payload)的偏移量。负载应该被压缩,压缩和非压缩的数据都应该使用标准的魔数来决定。当前所支持的压缩格式分别是:gzip(魔数为1F 88或1F 9E),bzip2(魔数为42 5A),LZMA(魔数为5D 00)以及XZ(魔数为FD 37)。非压缩负载的格式至今总是ELF(魔数为7F 45 4C 46)。下一个字段payload_length指示负载的长度。
- setup_data——占用8个字节,类型为可写。这个字段是一个指向节点为setup_data结构体且以NULL结尾的链表的64位物理指针。它被用来定义可扩展的启动参数传递机制。
- pref_address——占用8个字节,类型为可读。这个字段如果非零,则其值为内核首选的加载地址。一个可重定位的bootloader应该尽可能试图将内核加载至此处。一个不可重定位的内核则无条件移动其自身并从该地址处开始运行。
- init_size——占用4个字节,类型为可读。这个字段指示了在内核能够检测内存映射之前它所需要的线性连续内存的总量,这段连续内存起始于内核运行时的开始地址。它能够被用来帮助可重定位的引导加载程序为内核选择一个安全的加载地址。
建立堆栈——准备C语言的运行环境
在前文中提到过,GRUB在完成一系列工作之后通过执行一个长跳转指令进入内核的入口点,该入口点位于从实模式内核起始的偏移量0x200处。这意味着如果实模式内核代码在地址0x9 0000处,内核的入口点则为0000:9020。在起始处,ds/es/ss寄存器应该指向实模式内核代码的开始处,即如果代码被加载至0x9 0000处时这些寄存器的值被置为0x9000(注意这一点很重要!),栈指针寄存器sp一般指向堆的顶部,并且中断被禁用。此外为了防范错误,在有些引导加载程序中将把fs/gs/ds/es/ss寄存器均设为相同的值。通常引导加载程序的典型设置方式如下所示:
/*段基址由特定的引导加载程序而定,在x86架构下始终使用GRUB*/
/*因此seg被设置为0x9000*/
seg = base_ptr >> 4;
/*禁用中断*/
cli();
/*设置实模式内核栈 */
_SS = seg;
_SP = heap_end;
/*将DS/ES/FS/GS寄存器设为段基址值*/
_DS = _ES = _FS = _GS = seg;
/*执行长跳转将控制权转交内核*/
/*从header.S的_start全局标号处开始执行*/
jmp_far(seg+0x20, 0);
以下是紧跟全局标号_start的头两个字节的内容:
.globl _start
_start:
# Explicitly enter this as bytes, or the assembler
# tries to generate a 3-byte jump here, which causes
# everything else to push off to the wrong offset.
/*跳转指令,对应于安装头变量中的jump字段*/
.byte 0xeb # short (2-byte) jump
.byte start_of_setup-1f
1: /*标号1*/
# Part 2 of the header, from the old setup.S
这里.byte 0xeb与.byte start_of_setup-1f是汇编指令jmp start_of_setup-1f的硬编码形式,其中的跳转为短转移,因此start_of_setup-1f所在的字节表示偏移量。因为汇编指令经过汇编器的“翻译”之后所形成的均为如上形式的字节码,而CPU本质上并不对数据和指令严格区分,因此可以通过对数据进行精心构造,使其表面上看起来是被处理的数据,但本质上却是可以被用来执行的指令。说到这里还想扯句题外话,我们经常说一个可执行文件被感染了,通常就是指该文件的内部构造被修改了,因为对任何文件都可以进行写操作,所以该可执行文件自然可以由某个不怀好意的进程增加一些新的数据,而这些数据正是被精心构造好的指令,在该文件此后的执行过程中发生的原本不可能存在的一系列操作都是这些数据的“功劳”,而这种方式正是通常所说的“区段注入”,这些被感染的文件自然也就成了所谓的病毒或是木马,另外在某些形式的缓冲区溢出攻击中也利用了这一点。其实这一特点还会催生很多有意思的话题,比如可执行文件对自身进行修改,在执行过程中进行自我进化——有些《黑客帝国》的味道 :-)。
继续回到内核的剖析上来,我们在之前说过上面这两个字节的硬编码执行的是jmp跳转指令的功能,而如果直接写jmp start_of_setup-1f形式的汇编指令,汇编器最终也能生成执行相同功能的字节码,那为什么不直接写汇编指令而偏要费那么大劲,去精心构造这两个字节的数据呢?其实上面的注释已经给出了详细的解答,因为汇编器生成的字节码最终将会占用3个字节,这使得后续的字段都被“推移”到错误的偏移处,所以我们只能通过硬编码的形式来实现跳转。另外在GAS汇编中还有一个重要的知识点,那就是在start_of_setup-1f中的1f并不表示十六进制的数据0x1f,其中的1表示一个标号,紧跟着的f表示向前的(forward),而如果要向后面的标号1跳转,则应该写成1b(b—backward)。.byte start_of_setup-1f这个字节的值表示两个标号——即start_of_setup与1之间的偏移量,由汇编器在汇编过程中自动填充。因为在汇编中的分支及循环语句只能通过jmp及其各种不同的变体来实现,因而跳转指令所跳转到的位置必须赋予一个标号,如果每个标号都取一个具有特定意义的名称将会very painful,因而GNU assembler中的这一特性对于汇编爱好者来说无疑very absorbing。我们在源文件中可以看到其中的大部分标号都仅仅只是一些没有含义的数字,足以说明维护Linux内核代码的这些hackers是十足的懒人 :-)。
接着跳转到start_of_setup标号处:
.section ".entrytext", "ax"
start_of_setup:
#ifdef SAFE_RESET_DISK_CONTROLLER
# Reset the disk controller.
movw $0x0000, %ax # Reset disk controller
movb $0x80, %dl # All disks
int $0x13
#endif
# Force %es = %ds /*强制将ds寄存器的内容赋值给es寄存器*/
movw %ds, %ax
movw %ax, %es /*注意此时ax寄存器的内容与ds寄存器相同*/
cld /*清除方向标志,使用在串传送指令中,表示在完成传送后将di寄存器自动增加*/
在start_of_setup标号之后紧跟着的又是一个历史遗留的产物。在上述代码中我们看到如果预定义了宏SAFE_RESET_DISK_CONTROLLER,那么将调用BIOS中断例程0x13重置磁盘控制器。而这仅仅只是针对老式硬盘的代码,目前的硬盘并不需要执行这些指令,留着它仅仅是为了兼容老式硬盘,因此内核文件中并未预定义这个宏。
# Apparently some ancient versions of LILO invoked the kernel with %ss != %ds,
# which happened to work by accident for the old code. Recalculate the stack
# pointer if %ss is invalid. Otherwise leave it alone, LOADLIN sets up the
# stack behind its own code, so we can't blindly put it directly past the heap.
/*注意之前已将ds寄存器中的值赋予ax寄存器*/
movw %ss, %dx /*将ss寄存器赋予dx寄存器*/
cmpw %ax, %dx # %ds == %ss? /*比较ds寄存器与ss寄存器是否相等*/
movw %sp, %dx /*将栈指针寄存器sp的值赋给dx寄存器*/
/*若ds寄存器的值与ss相等则跳转至标号2处,即说明sp寄存器已被合理设置*/
je 2f # -> assume %sp is reasonably set
/*反之则说明ss寄存器无效,建立一个新的栈*/
# Invalid %ss, make up a new stack
movw $_end, %dx /*将Kernel setup的结束地址装入dx寄存器*/
testb $CAN_USE_HEAP, loadflags /*位测试操作的结果为真,不发生跳转*/
jz 1f
movw heap_end_ptr, %dx /*heap_end_ptr = _end+STACK_SIZE-512*/
1: addw $STACK_SIZE, %dx /*将heap_end_ptr的值加上STACK_SIZE,即栈的大小*/
jnc 2f
xorw %dx, %dx # Prevent wraparound
首先解释一下注释——有些旧版本的引导加载程序LILO在装载完内核并将控制权转交给内核后,寄存器ss与ds并不相等。并且此时ss寄存器无效,因而需要重新计算栈指针,建立新栈的过程是由上述代码中跳转指令je 2f与标号2之间所执行的指令完成的。建立新栈时首先执行movw $_end, %dx指令将_end的值赋给dx寄存器,这里_end是在汇编时由汇编器自动填充的,它的值正是Kernel setup与实模式内核代码起始地址的偏移量,由图1中我们可以看出其值最大为0x8000,在arch\x86\boot目录中的链接脚本setup.ld也验证了这一点:
. = ASSERT(_end <= 0x8000, "Setup too big!"); /*第59行*/
上述语句断言当_end的值大于0x8000时,执行链接时将会报错,提示“Kernel setup太大”。接着执行testb $CAN_USE_HEAP, loadflags指令,测试在loadflags字段中是否已经置位CAN_USE_HEAP所指示的位,这两个操作数在源文件中的定义如下:
loadflags:
LOADED_HIGH = 1 # If set, the kernel is loaded high
CAN_USE_HEAP = 0x80 # If set, the loader also has set
# heap_end_ptr to tell how much
# space behind setup.S can be used for
# heap purposes.
# Only the loader knows what is free
.byte LOADED_HIGH /*被设置为LOADED_HIGH*/
我们发现loadflags字段的值被设置为LOADED_HIGH,因此由注释看出保护模式下的内核将被加载至起始地址0x10 0000处。然而根据1=0000 0001b可知这个字段的第7位并未被置1,但是其注释同样指出只有在第7位置1时才表示内核会使用堆,那么loadflags字段中的这一位究竟是否会被置1?答案是肯定的,因为Linux内核从启动协议版本号2.01开始,至今一直都支持实模式下的堆,所以根据注释可以猜测正是bootloader在加载内核时将这一位设置成1。于是testb $CAN_USE_HEAP, loadflags指令最后的运算结果为1,从而不发生跳转,随后紧接着执行movw heap_end_ptr, %dx指令,这条指令将heap_end_ptr的值填充dx寄存器,其中heap_end_ptr的值为:
heap_end_ptr: .word _end+STACK_SIZE-512
# (Header version 0x0201 or later)
# space from here (exclusive) down to
# end of setup code can be used by setup
# for local heap purposes.
heap_end_ptr被设置为_end+STACK_SIZE+512,其中STACK_SIZE的值在arch\x86\boot\Boot.h文件中被定义如下:
#define STACK_SIZE 512 /* Minimum number of bytes for stack */
可以发现heap_end_ptr的值其实就等价于_end,而_end的值为Kernel setup的结束地址。接着执行标号1后的指令addw $STACK_SIZE, %dx,其中STACK_SIZE表示整个栈的大小,在实模式下,512字节的内存被分配给堆和栈同时使用完全足够。接着执行jnc 2f指令,由于标志位没有发生进位,因此直接跳转至标号2处,代码如下:
2: # Now %dx should point to the end of our stack space
andw $~3, %dx # dword align (might as well...)
jnz 3f /*测试条件为真,执行跳转*/
movw $0xfffc, %dx # Make sure we're not zero
3: movw %ax, %ss /* 实际执行 movw %ds, %ss */
movzwl %dx, %esp # Clear upper half of %esp
/*此时允许中断,该指令与GRUB中的cli对应*/
sti # Now we should have a working stack
首先执行andw $~3, %dx指令将dx寄存器中的最低两位清零,即将栈底地址执行双字对齐操作,使得加载数据的效率更高。之后执行jnz 3f指令,由于上一条指令执行后的结果非零,因此跳转。在标号3后首先执行movw %ax, %ss指令,将ax寄存器赋值给ss,这里注意我们在一开始跳转至start_of_setup标号处执行时,曾将ax寄存器用来暂存ds寄存器的值,而此后的执行过程中该寄存器的值一直都未发生改变,因此这条指令实际是将ds寄存器的值赋值给了ss寄存器。紧接着执行movzwl %dx, %esp将栈底地址赋值给esp寄存器从而完成堆栈的建立,将栈指针寄存器esp赋值为栈底地址表明初始时刻栈为空。其后的sti指令则打开中断,执行该指令是因为在将控制权转交给实模式下的内核代码之前,GRUB执行了cli()禁用中断的操作,因此完成堆栈的建立之后需要再次打开中断。上图所执行的一系列指令最终可由下图形象的显示:
图3
完成堆栈的建立操作后,还需对cs:eip执行相关的修正操作,具体指令如下所示:
# We will have entered with %cs = %ds+0x20, normalize %cs so
# it is on par with the other segments.
pushw %ds
pushw $6f
lretw
6:
前两条pushw指令分别将ds寄存器的值以及标号6处的偏移量进行压栈,因为我们已经正确建立了堆栈,因此上述两条指令可以正常工作。此后执行lretw长跳转指令,它将先前压入堆栈的操作数——即ds寄存器以及标号6对应的偏移量分别弹出至cs寄存器和eip寄存器,此后从标号6处继续开始执行。之所以要执行这三条指令,是因为GRUB将内核装载至从地址0x9 0000开始的物理内存后,执行一条长跳转指令jmp_far(seg+0x20, 0)跳过了512字节大小的bootsect,该指令将cs寄存器的值设置为0x9020,而其他的一系列段寄存器ds/es/ss/fs/gs的值均指向起始地址0x9 0000处,因此在执行上述三条指令后将所有的段寄存器的值均设置为0x9000——即执行了前文所说的修正操作。
接着执行标号6之后的指令:
# Check signature at end of setup
cmpl $0x5a5aaa55, setup_sig
jne setup_bad /*测试条件为假,不执行跳转*/
# Zero the bss
movw $__bss_start, %di /*将bss段的起始地址加载至di寄存器中*/
movw $_end+3, %cx /*将地址_end+3加载至cx寄存器中*/
xorl %eax, %eax /*将eax寄存器清零*/
subw %di, %cx /*将cx寄存器的值减去di寄存器的值,并将结果放入cx寄存器*/
shrw $2, %cx /*将cx寄存器中的值右移两位,即将cx的值除以4,并将结果存入cx寄存器*/
rep; stosl /*执行串指令操作stosl,将eax中的值保存到es:edi指向的内存中,并且将edi自增4*/
# Jump to C code (should not return)
calll main /*跳转到main函数中*/
首先比较setup_sig的值是否与0x5a5aaa55相等,若不等则跳转至setup_bad标号处,同样setup_sig的值是在链接的时候由链接器填充的,该值同样定义在arch\x86\boot\setup.ld链接脚本文件中:
.signature : {
setup_sig = .;
LONG(0x5a5aaa55)
}
可以发现该值确实被定义为0x5a5aaa55,因此jne setup_bad指令将不会发生跳转。接下来就是清空实模式下内核代码的bss段——该段是Kernel setup的最后一段内存空间,需要注意bss段与数据段的区别:bss段存放的是未初始化的全局变量和静态变量,而数据段存放的则是已初始化的全局变量和静态变量。首先将bss段的起始地址装载至di寄存器中并将eax清零,随后设置带前缀的串指令rep; stosl的循环次数,该循环次数存放在cx寄存器中,这里需要注意的是由于执行一次串指令将清除4个字节的内存空间,因此cx寄存器存放的应该是整个bss段占用的内存空间大小除以4之后的值,由于bss段的大小可能并非4的倍数,而除4之后会将余数舍去,因此为了保证将整个bss段所占内存全部清零,需要将Kernel setup的结束地址_end加3之后再存入寄存器cx中,即执行movw $_end+3, %cx指令才能正确清空整个bss段,而非movw $end, %cx。在将整个bss段全部清零之后,即执行call main指令跳转至C语言的main函数中,该函数主要执行一系列硬件检测及初始化操作,至于详细内容放在后文剖析。