深度探索Linux操作系统:系统构建和原理解析
前言
对于编译内核而言,一条make命令就足够了。构建内核最困难的地方不是编译,而是编译前的配置。配置内核时,通常我们都能找到一些参考。
比如,对于桌面系统,可以参考主流发行版的内核配置,比如,对于嵌入式系统,BSP(Board Support Package)中通常也提供内核,但他们通常也仅是个可以工作的内核而已,如果要一个占用空间更小、运行更快的内核,就需要开发人员手动配置内核,也确实存在着在某些情况下,我们找不到任何合适的参考,这时我们只能以手动方式从零开始配置。
构建内核
内核的构建系统kbuild基于GNUMake,是一套非常复杂的系统。我们本无意着太多笔墨来分析kbuild,因为作为开发者可能永远不需要去改动内核映像的构建过程,但是了解这一过程,无论是对学习内核,还是进行内核开发都有诸多帮助。所以在构建内核之前,本章首先讨论了内核的构建过程。
3.1 内核映像的组成
在讨论内核构建前,我们先来简单了解一下内核映像的组成,如图3-1所示。
如果将内核的映像比作航天器,则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 中收集显示信息的代码如下:
1inux-3.7.4/arch/x86/boot/video.c:
static void store_video_mode(void)
(
struct biosregs ireg, oreg;
initregs(&ireg);
ireg.ah=0x0f;
intcall(0x10,&ireg,&oreg);
boot_params.screen_info.orig_video_mode=oreg.al&0x7f;
boot_params.screen_info.orig_video_page=oreg.bh;
store_video_mode首先调用函数intcall获取显示方面的信息,并将其保存在boot_params的screen_info中。intcall是调用BIOS中断的封装,0x10是BIOS提供的显示服务(Video Service)的中断号,代码如下:
linux-3.7.4/arch/x86/boot/bioscall.s:
intcall:
/* Self-modify the INT instruction. Ugly, but works. */
cmpb
gal, 3f
je 1f
movb
gal, 3f
jmp 1f
/* Synchronize pipeline */
1:
...
.byte
Oxcd
/* INT opcode */
3: .byte
在代码中我们并没有看到熟悉的调用BIOS中断的身影,如“int$0x10”,但是我们看到了一个特殊的字符——Oxcd。正如其后面的注释所言,Oxcd就是x86汇编指令INT的机器码,如表3-1所示。
根据x86的INT指令说明,Oxcd后面跟着的1字节就是BIOS中断号,这就是上面代码中标号为3处分配1字节的目的。
0
函数intcall的开头,比较寄存器al中的值与标号3处占用的1字节,若直接向前跳转至标号1处,否则将寄存器al中的值复制到标号3处的1个字节空间。那么寄存器al中保存的是什么呢?
默认情况下,GCC使用树来传递参数。可以使用“_attribute_(regparm(n)”修饰函数,或者通过向GCC传递命令行参数“-mregparm=n”来指定GCC使用寄存器传递参数,其中n表示使用寄存器传递参数的个数。在编译setup.bin时,kbuild使用了后者,编译脚本如下所示:
linux-3.7.4/arch/x86/boot/Makefile:
KBUILD_CFLAGS
:=...-mregparm=3...
如此,函数的第一个参数通过寄存器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呢?
二级推进系统-内核非压缩部分
内核经过压缩,因此运行前需要解压缩,但是谁来负责内核映像的解压呢?解铃还须系铃人,既然内核在构建时自己压缩了自己,当然解压缩也要由内核映像自己完成。
除了解压以外,非压缩部分还负责内核重定位。内核可以配置为可重定位的(relocatable),所谓可重定位即内核可以被Bootloader加载到内存任何位置。但是在链接内核时,链接器需要假定一个加载地址,然后以这个假定地址为参考,为各个符号分配运行时地址。显然,如果加载地址和链接时假定的地址不同,那么需要对符号的地址进行重新修订,这就是内核重定位。
内核非压缩部分工作在保护模式下,其占用的内存在完成使命后将会被释放。
有效载荷-vmlinux
kbuild分别构建内核各个子目录中的目标文件,然后将它们链接为vmlinux。为了缩小内核体积,kbuild删除了vmlinux中一些不必要的信息,并将其命名为vmlinux.bin,最后将vmlinux.bin压缩为vmlinux.bin.gz。那么为什么内核要进行压缩呢?
1.最初,因为在某些体系架构上,特别是1386,系统启动时运行于实模式状态,可以寻址空间只能在1MB以下,内核尺寸过大,将无法正常加载,因此,对内核进行了压缩。
2.另外一个原因是,2.4及更早版本的内核,需要可以容纳在一张软盘上,所以内核也要进行压缩。
映像的格式
Linux作为操作系统的hosted environment环境下,二进制文件使用ELF格式,操作系统也提供ELF文件的加载器。但是,操作系统本身确是工作在freestanding environment 环境下。操作系统显然不能强制要求 Bootloader 也提供ELF加载器。
但是,从Linux 2.6.26版本开始,内核的压缩部分,即有效载荷部分,采用了ELF格式。至于为什么采用ELF格式,Patch的提交者给出了原因:
This allows other boot loaders such as the Xen domain builder the opportunity to extract the ELF file.
当内核映像不是裸二进制格式时,我们需要有一个ELF加载器来将ELF格式的内核映像转化为裸二进制格式。那么谁来充当这个ELF加载器呢?
正所谓“蜘蜂捕蝉,黄雀在后”。内核的非压缩部分调用函数decompress解压内核后,紧接着就调用了函数parse_elf来处理ELF格式的内核映像,代码如下:
11nux-3.7.4/arch/x86/boot/compressed/misc.c:agml inkage void decompress kernel (...)decompress (input_data, input_len, ...);
parse_elf(output);
static void parse_elf (void *output)
for (i - 0; i < ehdr.e_phnum; i++) {
phdr = &phdrs [i];
switch (phdr->p_type) {
case PT_LOAD:
#ifdef CONFIG RELOCATABLE
dest = output;
dest += (phdr->p_paddr - LOAD_PHYSICAL_ADDR);
#else
dest = (void *) (phdr->p_paddr);
#endif
memcpy (dest, output + phdr->p_offset, phdr->p_filesz);
break;
default:/*Ignore other PT_**/break;
HZ BOOKS
free(phdrs);
在ELF文件中,存放代码和数据的段的类型是PT_LOAD,因此,仅处理这个类型的段即可。在函数parse_elf中,对于类型是PT_LOAD的段,其按照Program Header Table中的信息,将它们移动到链接时指定的物理地址处,即p_paddr。当然,如果内核是可重定位的,还要考虑内核实际加载地址与编译时指定的加载地址的差值。
如果Bootloader不是所谓的“the Xen domain builder”,我们完全没有必要保留内核的压缩部分为ELF格式,并略去启动时进行的“parse_elf”。具体方法如下:
(1)将压缩部分链接为裸二进制格式
将传递给命令objcopy的参数追加“-Obinary”,如下面使用黑体标识的部分
:1inux-3.7.4/arch/x86/boot/compressed/Makefile:
OBJCOPYFLAGS_vmlinux.bin:=-R.comment-s-o binary
$(obj)/vmlinux.bin:vmlinux FORCE
$(call if_changed,objcopy)