宠文网

深度探索Linux操作系统

宠文网 > 科普学习 > 深度探索Linux操作系统

第3章 构建内核

书籍名:《深度探索Linux操作系统》    作者:王柏生
    《深度探索Linux操作系统》章节:第3章 构建内核,宠文网网友提供全文无弹窗免费在线阅读。!


内核的构建系统kbuild基于GNU  Make,是一套非常复杂的系统。我们本无意着太多笔墨来分析kbuild,因为作为开发者可能永远不需要去改动内核映像的构建过程,但是了解这一过程,无论是对学习内核,还是进行内核开发都有诸多帮助。所以在构建内核之前,本章首先讨论了内核的构建过程。

对于编译内核而言,一条make命令就足够了。因此,构建内核最困难的地方不是编译,而是编译前的配置。配置内核时,通常我们都能找到一些参考。比如,对于桌面系统,可以参考主流发行版的内核配置。但是,这些发行版为了能够在更多的机器上运行,几乎选择了全部的配置选项,编译了全部的驱动,不仅增加了内核的体积,还降低了内核的运行速度。再比如,对于嵌入式系统,BSP(Board  Support  Package)中通常也提供内核,但他们通常也仅是个可以工作的内核而已。显然,如果要一个占用空间更小、运行更快的内核,就需要开发人员手动配置内核。而且,也确实存在着在某些情况下,我们找不到任何合适的参考,这时我们只能以手动方式从零开始配置。

但是,面对内核中成千上万的配置选项,开发人员通常不知从何下手。但正所谓万事开头难,一旦迈过了这个坎,读者就不会在内核前望而却步。因此,在本章中,我们摸着石头过河,带领读者以手动的方式配置内核。

在内核启动的最后,内核要从根文件系统加载用户空间的程序从而转入用户空间。因此,在本章的最后,我们准备了一个基本的根文件系统来配合内核的启动。我们也采用手动的方式构建这个根文件系统,通过手动的方式,读者将会更透彻地了解到动辄几个GB的根文件系统是如何组织和安排的。



3.1 内核映像的组成

在讨论内核构建前,我们先来简单了解一下内核映像的组成,如图3-1所示。

图 3-1 内核映像bzImage的组成

如果将内核的映像比作航天器,则setup.bin部分就类似于火箭的一级推进子系统。最初,这部分负责将内核加载进内存,并为后面内核保护模式的运行建立基本的环境。但后来加载内核的功能被分离到Bootloader中,setup.bin则退化为辅助Bootloader将内核加载到内存。

紧接着,包围在32位保护模式部分外的是非解压缩部分。这部分可以看作是火箭的二级推进子系统,负责将压缩的内核解压到合适的位置,并进行内核重定位,在完成这个环节后,其从内核映像脱离。

最后是内核的32位保护模式部分vmlinux。这部分相当于航天器的有效载荷,即类似于最后运行的卫星或者宇宙飞船,只有这部分最后留在轨道内(内存中)运行。内核构建时,将对有效载荷vmlinux进行压缩,然后与二级推进系统装配为vmlinux.bin。

下面我们就来看看内核映像的各个组成部分。



3.1.1 一级推进系统——setup.bin


在进行内核初始化时,需要一些信息,如显示信息、内存信息等。曾经,这些信息由工作在实模式下的setup.bin通过BIOS获取,保存在内核中的变量boot_params中,变量boot_params是结构体boot_params的一个实例。如setup.bin中收集显示信息的代码如下:



store_video_mode首先调用函数intcall获取显示方面的信息,并将其保存在boot_params的screen_info中。intcall是调用BIOS中断的封装,0x10是BIOS提供的显示服务(Video  Service)的中断号,代码如下:



在代码中我们并没有看到熟悉的调用BIOS中断的身影,如"int$0x10",但是我们看到了一个特殊的字符——0xcd。正如其后面的注释所言,0xcd就是x86汇编指令INT的机器码,如表3-1所示。

根据x86的INT指令说明,0xcd后面跟着的1字节就是BIOS中断号,这就是上面代码中标号为3处分配1字节的目的。

在函数intcall的开头,首先比较寄存器al中的值与标号3处占用的1字节,若相等则直接向前跳转至标号1处,否则将寄存器al中的值复制到标号3处的1个字节空间。那么寄存器al中保存的是什么呢?

在默认情况下,GCC使用栈来传递参数。但是我们可以使用关键字"__attribute__(regparm(n))"修饰函数,或者通过向GCC传递命令行参数"-mregparm=n"来指定GCC使用寄存器传递参数,其中n表示使用寄存器传递参数的个数。在编译setup.bin时,kbuild使用了后者,编译脚本如下所示:



如此,函数的第一个参数通过寄存器eax/ax传递,第二个参数通过ebx/bx传递,等等,而不是通过栈传递了。因此,上面的寄存器al中保存的是函数intcall的第一个参数,即BIOS中断号。

在完成信息收集后,setup.bin将CPU切换到保护模式,并跳转到内核的保护模式部分执行。如我们前面讨论的,setup.bin作为一级推进系统,即将结束历史使命,所以内核将setup.bin收集的保存在setup.bin的数据段的变量boot_params复制到vmlinux的数据段中。

但是随着新的BIOS标准的出现,尤其是EFI的出现,为了支持这些新标准,开发者们制定了32位启动协议(32-bit  boot  protocol)。在32位启动协议下,由Bootloader实现收集这些信息的功能,内核启动时不再需要首先运行实模式部分(即setup.bin),而是直接跳转到内核的保护模式部分。因此,在32位启动协议下,不再需要setup.bin收集内核初始化时需要的相关信息。但是这是否意味着可以彻底放弃setup.bin呢?

事实上,除了收集信息功能外,setup.bin被忽略的另一个重要功能就是负责在内核和Bootloader之间传递信息。例如,在加载内核时,Bootloader需要从setup.bin中获取内核是否是可重定位的、内核的对齐要求、内核建议的加载地址等。32位启动协议约定在setup.bin中分配一块空间用来承载这些信息,在构建映像时,内核构建系统需要将这些信息写到setup.bin的这块空间中。所以,虽然setup.bin已经失去了其以往的作用,但还不能完全放弃,其还要作为内核与Bootloader之间传递数据的桥梁,而且还要照顾到某些不能使用32位启动协议的场合。





3.1.2 二级推进系统——内核非压缩部分

内核的保护模式部分是经过压缩的,因此运行前需要解压缩,但是谁来负责内核映像的解压呢?解铃还须系铃人,既然内核在构建时自己压缩了自己,当然解压缩也要由内核映像自己完成。

内核在压缩的映像外包围了一部分非压缩的代码,Bootloader在加载内核映像后跳转至外围的这段非压缩部分。这些没有经过解压缩的指令可以直接送给CPU执行,由这段CPU可执行的指令负责解压内核的压缩部分。

除了解压以外,非压缩部分还负责内核重定位。内核可以配置为可重定位的(relocatable),所谓可重定位即内核可以被Bootloader加载到内存任何位置。但是在链接内核时,链接器需要假定一个加载地址,然后以这个假定地址为参考,为各个符号分配运行时地址。显然,如果加载地址和链接时假定的地址不同,那么需要对符号的地址进行重新修订,这就是内核重定位。

内核非压缩部分工作在保护模式下,其占用的内存在完成使命后将会被释放。



3.1.3 有效载荷——vmlinux

在编译时,kbuild分别构建内核各个子目录中的目标文件,然后将它们链接为vmlinux。为了缩小内核体积,kbuild删除了vmlinux中一些不必要的信息,并将其命名为vmlinux.bin,最后将vmlinux.bin压缩为vmlinux.bin.gz。在默认情况下,内核使用gzip压缩,当然也可以在配置时指定使用lzma等压缩格式。gzip的压缩比相对较小,但是压缩速度相对较快。

那么为什么内核要进行压缩呢?

1)最初,因为在某些体系架构上,特别是i386,系统启动时运行于实模式状态,可以寻址空间只能在1MB以下,如果内核尺寸过大,将无法正常加载,因此,对内核进行了压缩。在内核加载完毕后,CPU切换到保护模式,可以寻址更大的地址空间,于是就可以将压缩过的内核展开了。

2)另外一个原因是,2.4及更早版本的内核,需要可以容纳在一张软盘上,所以内核也要进行压缩。

以上都是历史原因了,如今有些Bootloader,如GRUB,在加载内核期间就已经将CPU切换到保护模式了,寻址空间的限制早已不是问题。而且,如今软盘基本已经被其他介质替代,容量已不是问题。

但是内核的压缩还是保留了下来,毕竟还要考虑到某些尺寸受限的情况。而且,现代CPU解压的速度要远大于IO的速度,在启动时虽然解压要耗费一点时间,但是更小的内核也减少了加载时间。



3.1.4 映像的格式

不知读者留意到没有,无论是setup.bin、vmlinux.bin,还是vmlinux.bin.gz,命名中都包含"bin"的字样,这是开发者有意为之,还是机缘巧合?显然,这个bin不是开发人员随意杜撰的,而是binary的缩写,表示文件格式是裸二进制(raw  binary)的。

读者可能有个困惑,在Linux操作系统中二进制文件的格式不是使用ABI(Application  Binary  Interface)规定的ELF吗?

没错,在Linux作为操作系统的hosted  environment环境下,二进制文件使用ELF格式,操作系统也提供ELF文件的加载器。但是,操作系统本身确是工作在freestanding  environment环境下。操作系统显然不能强制要求Bootloader也提供ELF加载器。而且,操作系统映像也没有必要使用ELF格式来组织,将代码和数据顺次存放即可,即所谓的裸二进制格式。所以,内核映像都采用裸二进制格式进行组织。

但是,从Linux  2.6.26版本开始,内核的压缩部分,即有效载荷部分,采用了ELF格式。至于为什么采用ELF格式,Patch的提交者给出了原因:



我们知道,在解压内核映像后,将会跳转到解压映像的开头执行。但是,ELF文件的开头并不是代码段的开始,而是ELF文件头,也就是说,并不是CPU可执行的机器指令。显然,当内核映像不是裸二进制格式时,我们需要有一个ELF加载器来将ELF格式的内核映像转化为裸二进制格式。那么谁来充当这个ELF加载器呢?

正所谓“螳螂捕蝉,黄雀在后”。内核的非压缩部分调用函数decompress解压内核后,紧接着就调用了函数parse_elf来处理ELF格式的内核映像,代码如下:



在ELF文件中,存放代码和数据的段的类型是PT_LOAD,因此,仅处理这个类型的段即可。在函数parse_elf中,对于类型是PT_LOAD的段,其按照Program  Header  Table中的信息,将它们移动到链接时指定的物理地址处,即p_paddr。当然,如果内核是可重定位的,还要考虑内核实际加载地址与编译时指定的加载地址的差值。

事实上,如果Bootloader不是所谓的"the  Xen  domain  builder",我们完全没有必要保留内核的压缩部分为ELF格式,并略去启动时进行的"parse_elf"。具体方法如下:

(1)将压缩部分链接为裸二进制格式

将传递给命令objcopy的参数追加"-O  binary",如下面使用黑体标识的部分:



(2)注释掉parse_elf

既然内核压缩部分已经是裸二进制格式的了,解压后自然不再需要调用函数parse_elf了。