探本溯源——深入领略Linux内核绝美风光之系统启动篇(一)

从拿到Linux3.1.1版内核源码并搭建好阅读环境开始,到现在大约已经徘徊了两个多月的时间,期间google了大大小小的文章,才刚刚理清了些许思路并找到了阅读的切入口。对于内核初学者来说一个好的指导比什么都重要,有关Linux内核学习的方法论可以参考fudan_abc写的Linux内核修炼之道,作者以其深厚的内核功底加上诙谐幽默的文字对读者娓娓道来,这样的感染力使得我几乎是一口气不断的看完了整个专栏,相信对于任何对内核有强烈兴趣的学习者一定有很多助益。另外,对初学者来说光有方法论是不够的,特别是对于Linux Kernel如此庞大的迷宫,所以一份好的地图在内核学习中同样举足轻重,Kconfig Makefile正扮演了如此重要的角色,特别是对于想要重点研究某一模块或是子系统的学习者来说更是如此,详细参见以上专栏的具体文章——Kernel地图:Kconfig与Makefile,而对于Makefile Kbuild体系介绍的更加具体的可以参见云松写的Makefile预备知识/Kbuild体系,当然最权威的信息自然来自内核文档了。根据我自己的实际情况,其实我认为并没有必要去详细分析每个makefile文件,因为最后的结果是显然的——一个内核映像由成百乃至上千个文件组成,这样的解剖工作量无疑是巨大的,特别是对于像我这样还没有构建大型系统,甚至写的最大的makefile文件都仅有寥寥数行的初学者来说,无疑是一个巨大的挑战,因此通过google搜索前人所写的关于某一模块所依赖的源文件的文章可以让我们把时间放在更加重要的源代码剖析上。

Kbuild/Makefile/Kconfig
根据个人的亲身体会,阅读Linux内核对于新手来说首先要过的第一道坎便是源文件中大大小小的CONFIG_XXXX标识,这对于广大的像我一样没有接触过驱动开发/文件系统/网络协议的学习者们无疑是对自信心的首个重大打击,不过幸运的是,Linux内核的发行版提供了丰富的文档,在内核学习的过程中碰到很多自己不熟悉的东西是很常见的现象,因此学会查找合适的文档对于学习将会事半功倍。

有关Kbuild/Makefile/Kconfig的文档可参见目录Documentation\kbuild,以下列出了该文档中有关这三者的简要概述:

  • Makefile
    Makefile总共包含五个部分,分别为:①顶层Makefile文件,②内核配置文件,③在各个体系结构下的makefile,在目录arch/$(ARCH)中,④一系列用于kbuild Makefile的通用规则,这些文件主要在scripts目录中,⑤kbuild Makefiles,这类文件大约有500个左右。
    顶层Makefile读取.config文件,该文件主要在内核的配置过程中生成。有关内核的具体配置可参见这里。顶层的Makefile主要用来构造两个最主要的文件:vmlinux——固定内核映像,以及任意的模块,这种构造过程是通过递归进入内核源代码树的子目录而完成的。需要访问的子目录列表依据内核的配置而定。顶层Makefile包含一个具体体系结构下的Makefile文件,该文件主要向顶层Makefile提供指定体系结构的信息。
    每一个子目录都包含kbuild Makefile,其用来执行由上级目录传递下来的一些命令。kbuild Makefile通过使用.config文件中包含的信息来构造各种各样的文件清单,最终kbuild根据这些文件清单构造任一内置的或模块化的目标。
    而scripts目录中包含的一些Makefile.*文件中则包含了一系列的定义或是规则,这些规则根据kbuild makefile构造内核。


  • Kbuild
    这是从Linux2.6版本内核开始采用的编译系统。简言之,就是根据makefile生成的文件列表构造内置的或模块化的目标。

  • Kconfig
    kconfig文件就是用来存放组织成树形结构的一系列配置选项的配置数据库。这些配置选项被组织成菜单条目的形式,一个简单的例子如下:
    config MODVERSIONS
        bool "Set version information on all module symbols"
        depends on MODULES
        help
          Usually, modules have to be recompiled whenever you switch to a new
          kernel.  ...
    这是一个简单的配置菜单条目,不过这也说明了所有的配置条目所应有的特征:"config"引出一个新的配置选项,接下来的行定义了一系列的属性,这些属性的类型可以是配置选项,输入提示,依赖关系,帮助文档或是默认值,需要注意的是同一个配置菜单条目可以被定义多次,但每一次定义都仅有一个输入提示,并且类型定义不能冲突。绝大多数的条目都定义了一个配置选项,而其他的条目为这些条目充当依赖关系。鉴于Kconfig文件重要性,这里列出一些其常用语法:

    ——类型定义 :"bool"/"tristate"/"string"/"hex"/"int",表示用户配置该选项时的输入类型。这里有两种最基本的类型:tristate和string类型,其他类型则基于这两者。类型定义允许给出输入提示,比如:
    bool "Networking support"
    等价于
    bool
    prompt "Networking support"
    两者都提示该配置菜单的输入类型为bool型,输入之前将有一条提示为“Networking support”。

    ——默认值: "default" ["if" ]
    一个配置选项可以有任意数量的默认值。如果多个默认值是可见的,那么当且仅当第一个定义的默认值处于活动状态,即如果用户不配置该选项那么该默认值将被选择。默认值将不受所在菜单项的限制,这意味着默认值可以被定义在任何其他地方或被先前的定义所覆盖。可以通过添加"if"条件语句增加指定条件下存在的默认值。

    ——类型定义+默认值:"def_bool"/"def_tristate" ["if" ]
    这是类型定义和默认值的简写方式。同样可以通过增加"if"语句添加指定条件下的类型及默认值。

    ——依赖:"depends on"
    这为本菜单项定义了一个依赖条件,如果有多个依赖,那么它们可以通过符号"&&"连接。依赖应用于本菜单项从其定义处开始的所有其他属性。例如:
    bool "foo" if BAR
    default y if BAR
    等价于
    depends on BAR
    bool "foo"
    default y

    ——帮助文档:"help"或者"---help---"
    该属性定义了一个帮助文档,帮助文档的末尾取决于其缩进层次,换言之,比帮助文档开始的第一行有更小缩进的一行指示整个帮助文档结束。"---help---"与"help"在作用上并无差别,只不过"---help---"帮助开发者将配置选项从整个菜单项中清晰的分离出来。

以上是对Kbuild/Makefile/Kconfig的简要介绍,其实理解这三者的关键在于:因为构建的软件是通过对源文件编译得到的,而Linux内核支持多种CPU架构,这使得整个内核包含成千上万个每个架构下各自所需的源文件,然而在形成内核映像的过程中只需要从源文件中抽取出一部分针对某一种具体体系结构的源文件,以及所有体系结构下都需要的源文件(例如内存管理/进程调度/网络等)进行编译即可,Kbuild Makefile中的一部分实现的正是这样的功能,然而事实是Makefile中构建每个目标所需的源文件之间的相互依赖关系错综复杂,并且上层的Makefile还需要调用下层的Makefile,这还有可能导致目标覆盖,期间还可能需要scripts目录中的脚本文件辅助编译,还有Makefile中一大堆预定义的对于初学者来说格式奇怪的环境变量/自动化变量以及内建函数等等,最后链接顺序对于某些模块来讲具有严格的递进关系,否则还有可能导致硬件的损坏,可以看到,仅仅内核映像所需源文件的剖析过程就将耗费大量的精力,所以这一部分工作建议通过搜索引擎完成。而与我们的源文件有关的Kconfig则应该适当了解,否则正如上文所讲,将对代码的具体剖析过程造成极大的困扰。以Page_32_types.h头文件中定义的宏__PAGE_OFFSET中使用到的变量CONFIG_PAGE_OFFSET为例,遇到这类配置类型的变量首先在对应体系结构下的目录中(arch\x86)找到Kconfig文件,其中有关CONFIG_PAGE_OFFSET的菜单项如下所示:

config PAGE_OFFSET
	hex
	default 0xB0000000 if VMSPLIT_3G_OPT
	default 0x80000000 if VMSPLIT_2G
	default 0x78000000 if VMSPLIT_2G_OPT
	default 0x40000000 if VMSPLIT_1G
	default 0xC0000000
	depends on X86_32
可以看到该菜单项是一个类型为hex,即以16进制格式显示的整型且不可视(non-visible)变量(因为没有任何输入提示,这表现为hex之后没有带双引号的字符串,也没有prompt输入提示符),如果预定义了表达式VMSPLIT_3G_OPT那么该值则为0xB000 0000,后接三个默认值均为在指定条件下PAGE_OFFSET可取的值,最后一个没有任何条件的默认值为0xC000 0000,即当我们使用默认配置时PAGE_OFFSET的取值即为0xC000 0000,最后看到depends on X86_32语句,说明该菜单项对X86_32配置选项具有依赖关系,但我们看到其后并未定义其他属性,因此直接忽略该依赖即可。源文件中的PAGE_OFFSET的含义是内核在单个进程的线性地址空间中的偏移量,这表明了在默认情况下,Linux内核将占用x86架构下0~4G虚拟内存中最高1G的线性地址空间,同时也表明了在任意进程的页目录项中从0x300(即768)开始的项均对应的是内核地址空间。

以上是有关Kbuild/Makefile/Kconfig的简要介绍,想要更加详细的了解这一部分的内容可参考之前所列的资料。可以看到与我们的源代码剖析联系最紧密的就是我们的Kconfig配置文件,因此能够大致理解Kconfig文件是阅读内核源码的最基本要求,另外在x86\configs目录下的i386_defconfig文件中也有对一部分CONFIG__XXXX标识的默认配置。以上介绍的内容仅仅只是我们内核之旅开始的前奏,接下来就将正式进入我们的内核世界中。

加电启动
首先需要知道,内存(DRAM)是一类易失性存储设备,从微观上表现为,泄漏电流的各种因素会导致DRAM单元在10~100毫秒时间内失去电荷,因此存储系统必须周期性地通过读出然后写回来刷新存储器的每个位,这与SRAM不同,只要有电,SRAM中存储的信息就不需要刷新,SRAM比之DRAM的优点还有存取速度快,对光和电的噪音干扰不敏感,所有这些优点的代价是SRAM单元比DRAM单元使用更多的晶体管,更贵且功耗更大;从宏观上来讲,其易失性则表现为一旦断电,那么内存中所存储的信息都将丢失。因此我们的内核必须存储在一类永久性的介质中,即使断电也照样可以保存信息,在PC上这样的介质就是我们的硬盘,它是通过将信息转换为电信号,再将电信号转换为磁场去磁化材料,这样就将信息永久地保存下来,当然还可以是其他非易失性存储设备如U盘。


然而我们的程序在执行之前都必须先被载入内存,因为在CPU上执行的指令都是在内存中进行寻址的,继而这就要求存在某种外力,在开机启动时能够将内核“放入”内存并执行相应的初始化工作,其后将控制权转移给内核,向上对用户提供所需的服务,向下则可以有效的管理硬件资源使得整个精彩纷呈的机器世界有效运转,而这种“外力”就是我们的基本输入/输出系统(Basic Input/Output System,BIOS),之所以称其为“基本”输入\输出系统是因为其包含几个中断驱动的低级过程。所有操作系统在启动时都需要借助这些过程对计算机硬件设备进行初始化。因为BIOS过程只能在实模式下运行,所以Linux一旦进入保护模式就不再使用BIOS,而是为计算机上的每个硬件设备提供各自的设备驱动程序。

在开始启动时,有一个特殊的硬件电路在CPU的一个引脚上产生一个RESET逻辑值,CPU在识别出RESET信号后将数据总线设为高阻抗状态,地址线强行设为1,并禁用中断。在这之后就将处理器的一些寄存器设成固定的值,其中最重要的两个寄存器——CS段寄存器被置为0xf000,EIP指令指针寄存器为0x0000 fff0,因为此时CR0寄存器中的PE位还未被置位,因此处于实模式状态下,对指令的地址计算是通过在16位的CS段寄存器内容最右边补齐一个0从而形成一个小段,再加上IP指令指针寄存器中的内容(EIP寄存器的低16位),用一个形象的公式即表示为:CS*16+IP,最终得到地址值为0xf fff0。注意这个地址寻址的第一条指令存放在BIOS中,而又因为BIOS被固化在ROM中,并且对ROM中的指令进行寻址则需要使用32位地址(原因请参考PC存储器一文),联系到之前所介绍的,CPU将地址线强行设为1,最终形成的物理地址即为0xffff fff0。形成该地址之后,CPU将其交给MCH(南桥芯片-相当于一个分配器,根据不同的地址映射分配给不同的设备去处理),MCH会决定这个地址要分配给ICH(北桥芯片-解码器),最终这个地址被解析到BIOS的ROM里面的地址。

而BIOS实际上大致执行以下4个操作:

  1. 对硬件执行一系列测试,用来检测现在有哪些设备以及这些设备是否正常运转,这个阶段被称为POST(Power-On-Self-Test,加电自检)。
  2. 初始化硬件设备,这个阶段保证所有的硬件设备操作不会引起IRQ(中断请求)线与I/O端口的冲突,最后显示系统中安装的所有PCI设备的一个列表。
  3. 搜索一个操作系统来启动。这个过程可以根据用户设置的顺序依次进行访问系统中的软盘、硬盘以及CD-ROM的第一个扇区(即引导扇区),通常是在开机时按del键进入BIOS的设置界面,但也可能是其他键,视各个具体的PC而定。
  4. 按上述访问次序找到一个有效设备后,即将第一个扇区的内容拷贝到RAM中物理地址0x0000 7c00处,随后跳转到该地址开始执行刚刚加载的代码。

有关BIOS启动过程更详细的介绍可以参考BIOS启动过程——硬件检测及初始化浅析一文。

引导装入过程(boot loader)
引导装入程序是由BIOS装载,且用来把操作系统的内核映像装载到RAM中所执行的一个程序,该可执行过程存放在硬盘的第0个磁道第0个扇区上,由于总共只需占用较少(512字节)的存储空间,故也可称之为引导记录,除此之外该引导记录还包括64字节的分区表以及2个字节标识有效引导记录结尾的标签(0x55 0xAA)。但从Linux2.6开始不再执行这样的引导装入程序,这一点可以通过从header.S文件剖析得知:

BOOTSEG        = 0x07C0        /* original address of boot-sector */
SYSSEG        = 0x1000        /* historical load address >> 4 */

    .code16  /*表示16位模式*/
    .section ".bstext", "ax"  /*将以下代码放在.bstext节区,"ax"表示该节区可分配并且可执行*/

    .global bootsect_start  /*定义全局标号bootsect_start*/
bootsect_start:

    # Normalize the start address
    ljmp    $BOOTSEG, $start2

start2:
    movw    %cs, %ax
    movw    %ax, %ds
    movw    %ax, %es
    movw    %ax, %ss
    xorw    %sp, %sp
    sti
在全局标号bootsect_start后执行一个长跳转指令ljmp $BOOTSEG,$start2,因为BOOTSEG的值为0x07C0,并且当前仍处于实模式下,因此该指令表示转移到段基址为0x7C00(seg*16),偏移为start2地址处,联系到开机启动时第一个扇区的内容被BIOS拷贝到物理地址0x0000 7C00处,所以该长跳转指令实际表示转移到本段start2标号处继续执行,可以看到在start2标号后的指令将cs段寄存器中的内容依次填充至ds/es/ss寄存器中,接着将sp堆栈指针寄存器内容清零并启用中断。
    cld  /*清除方向标志,使用在串传送指令中(在本例中为lodsb),表示在完成传送后将di寄存器自动增1*/

    movw    $bugger_off_msg, %si  /*将bugger_off_msg的首地址移入si寄存器*/

msg_loop:
    lodsb  /*将内存地址ds:si存放的内容读入al寄存器中*/
    andb    %al, %al  /*测试al寄存器中的内容是否为0*/
    jz    bs_die  /*若是则跳转到标号bs_die处继续执行*/
    movb    $0xe, %ah
    movw    $7, %bx
    int    $0x10  /*调用BIOS0x10号中断,该中断的作用是显示字符*/
    jmp    msg_loop
可以发现上述代码段的作用即是调用BIOS中断例程在屏幕上显示标识为bugger_off_msg的字符串内容,bugger_off_msg的定义如下:
bugger_off_msg:
    .ascii    "Direct booting from floppy is no longer supported.\r\n"
    .ascii    "Please use a boot loader program instead.\r\n"
    .ascii    "\n"
    .ascii    "Remove disk and press any key to reboot . . .\r\n"
    .byte    0
该字符串告知用户“已不再支持从软盘直接启动的方式,请代之以使用一个boot loader程序,移除磁盘并按任意键重启”。接着显示完该字符后跳转到bs_die标号处继续执行,该代码段如下所示:
bs_die:
    # Allow the user to press a key, then reboot
    xorw    %ax, %ax  /*将ax寄存器清零*/
    int    $0x16  /*从键盘读入字符,即等待用户按键*/
    int    $0x19  /*重新启动系统*/

    # int 0x19 should never return.  In case it does anyway,
    # invoke the BIOS reset code...
    ljmp    $0xf000,$0xfff0  /*长跳转到0xf fff0处,即重新执行BIOS中的一系列初始化过程*/

以上代码等待用户按键之后即刻重启。实际上虽然header.S文件中自带bootloader,但如果内核从硬盘启动该段代码无论如何都不会被执行,而内核的维护者也不再维护这段引导记录,关于这一点也可以从链接脚本文件的设置中看出。

OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)  /*指定入口标号*/

SECTIONS
{
    . = 0;
    .bstext        : { *(.bstext) }
    .bsdata        : { *(.bsdata) }

    . = 497;
    .header        : { *(.header) }
    .entrytext    : { *(.entrytext) }
    .inittext    : { *(.inittext) }
    .initdata    : { *(.initdata) }
    __end_init = .;
    ......
}

以上文件为arch\x86\boot目录下的setup.ld,可以发现链接脚本指定的入口为_start标号而非bootsect_start,这就好比在C/C++中指定main()函数为入口点。事实上当Linux内核从_start标号处开始执行后便再也不会跳入bootsect_start标号执行。在这里简单提一下链接脚本的语法:在SECTIONS{}内设置最终生成的文件中各个节区的属性,. = 0表示.bstext以及.bsdata节区中的代码及数据被加载至偏移0处。每个节区的描述格式都是“节区名 : { 组成 }”,例如.bstext : { *(.bstext) },左边表示最终生成的文件的.bstext段,右边表示所有目标文件的.bstext段,意思是最终生成的文件的.bstext节区由各目标文件的.bstext节区组合而成,有关链接脚本更详细的介绍参见GNU-ld链接脚本浅析。联系上述引导装入程序中开始处的.section ".bstext", "ax"指令,我们知道上述代码位于被载入内存的整个内核映像的起始处,注意虽然这些指令不会被执行,但其仍然将会被载入内存,并且在这个512字节的引导记录中存放的数据仍是有用的,这些参数将被用来初始化setup_header结构体(位于arch\x86\include\asm\Bootparam.h文件中),如下所示:

	.section ".header", "a"
	.globl	hdr
hdr:
setup_sects:	.byte 0			/* Filled in by build.c */
root_flags:	.word ROOT_RDONLY
syssize:	.long 0			/* Filled in by build.c */
ram_size:	.word 0			/* Obsolete */
vid_mode:	.word SVGA_MODE
root_dev:	.word 0			/* Filled in by build.c */
boot_flag:	.word 0xAA55
	# offset 512, entry point
这些参数位于.header节区中,由之前的链接脚本文件得知这个节区在整个引导记录的偏移量为497,而其中的变量将总共占用1+2+4+2*4=15个字节的空间,因而最终构成512字节的引导记录。另外从标识整个有效引导记录结尾的标签的汇编指令boot_flag: .word 0xAA55以及最后一行的注释也可看出。另外要注意,由于x86体系结构采用小端法存放数据,因此0xAA55最后存放的形式从低到高依次为0x55,0xAA,与我们之前所描述的并不冲突。而这些参数的填充从注释中可以看出是由build.c文件完成的,我们将放在后文介绍这种参数的具体设置。

前面提到过,bootloader由BIOS加载,并用于负责将内核映像装入内存,然而Linux2.6开始不再执行该代码,那么装载内核映像的重大责任由谁来承担?针对不同的体系结构,Linux所使用的或是众所周知的LInux LOader(LILO)或是广泛使用的GRand Unified Bootloader(GRUB)。而在x86架构下,则是由其中之一的GRUB来装载内核映像

事实上,由于硬盘容量的飞速发展,通常是将一个硬盘划分成若干个“分区”,从而可以把一个物理硬盘看成是若干个逻辑磁盘。这样在每个逻辑磁盘上都可以存放一个操作系统映像,并且每个逻辑磁盘的第一个扇区仍然作为引导扇区(boot sector),上文我们所剖析的Linux内核自带的但已不再执行的bootloader便存放在该引导扇区中。显然这些引导扇区在物理上已经不再是整个硬盘的第一个扇区了(即整块硬盘上的第0个磁道第0个扇区),在这种情况下,整个硬盘的第一个扇区被独立出来,不属于任何一个逻辑磁盘。但BIOS在执行POST操作以及初始化后还是从这个扇区引导,因而在该扇区中存放的引导程序又被称为主引导记录(Master Boot Record,MBR)。有关MBR更详细的信息可以参考操作系统装载过程及BootSector的汇编语言实现一文。

结合前述分析,由于在一个物理硬盘上可能存在多个操作系统映像,因此GRUB的执行过程又具体细分为如下两个部分:

  • 首先执行基本的引导装载过程,该程序通常位于主引导记录(MBR)中,大小为512字节,由BIOS将其装入RAM中物理地址0x0000 7c00处,而它的任务则是建立实模式栈并利用BIOS指令将第二引导加载过程装入内存。
  • 第二引导加载过程(又称次引导过程)随后从磁盘读取可用操作系统的映射表,并提供给用户一个提示符,当用户选择需要被载入的操作系统,或是经过一段时间的延迟自动选择一个默认值之后,次引导过程便将相应分区下的内核映像以及initrd装载到内存中,而前述的映射表是GRUB通过读取/boot/grub/grub.conf文件中所设置的内容生成的。另外次引导过程还包括对特定文件系统(如ext2,ext3等)的支持以及对内核启动代码的初始化等职责,这就决定了次引导过程将占用较大的存储空间——连续多个扇区,从而无法装进单个扇区中,因此GRUB通常将该过程放在特定的文件系统中(通常是boot所在的根分区)。

可以看到,整个GRUB是由MBR中的基本引导过程以及特定文件系统中的次引导过程两部分构成。这里要详细说明的是,次引导过程拷贝到内存的目标中包括一个名为initrd的文件,该文件的全称为boot loader initialized RAM disk,即bootloader初始化的内存盘。因为在Linux内核的启动过程中将会加载位于硬盘上的根文件系统,与硬盘相应的设备驱动程序又被存储在该文件系统中,这就导出一个矛盾:加载根文件系统需要使用设备驱动程序,而设备驱动程序在根文件系统加载之前又无法载入内存运行。当然解决该矛盾最简单的方法便是将设备驱动程序编译进内核,然而如今的Linux内核支持多种硬件架构,因此根文件系统可能被存储在IDE、SCSI、SATA、U盘等多种介质中,如果将所有的硬件驱动均编译进内核无疑将使得内核臃肿不堪,引入initrd正是为了解决如上问题,它主要用于实现一些模块的加载以及文件系统的安装等功能。在次引导过程完成相应文件的加载之后将会执行一个长跳转指令,该指令跳过实模式内核代码的前512个字节,也即跳到由前述链接脚本所指定的执行入口_start处开始执行,而所跳过的512字节正是我们之前剖析的Linux内核自带的bootloader引导程序,整个的衔接过程可谓天衣无缝。

最后简单总结GRUB的引导加载过程如下:

  • 调用一个BIOS例程显示”Loading“信息。
  • 调用BIOS例程装入内核映像的初始部分,将内核映像的第一个512字节(即为存放在boot sector中已不再运行的bootloader)从地址0x0009 0000处开始装入内存。
  • 同样通过调用BIOS例程装载其余的内核映像,并把内核映像放入从低地址0x0001 0000(小内核映像)或者从高地址0x0010 0000(大内核映像)开始的RAM中。

完成内核映像的装载之后,控制权即正式转交给内核。欲知后事如何,且听下回分解。

你可能感兴趣的:(内核)