这一篇在干嘛?

按下电源开关的那一刻,内存里是随机的垃圾数据,什么操作系统都没有——那 Linux 是怎么”无中生有”跑起来的?本章按时间顺序讲完整个引导(bootstrap)链路:BIOS 自检、引导加载程序载入内核镜像、setup() 初始化硬件、startup_32() 解压内核并搭好分页,最后 start_kernel() 把内核每个子系统唤醒。搞懂它,你就能看懂内核启动日志的每一行,也能从容应对 grub、bzImage 这些日常词汇。

引导为什么难 | 史前时代:BIOS | 上古时代:Boot Loader | 中世纪:setup() 函数 | 文艺复兴:startup_32() 函数 | 现代:start_kernel() 函数

引导为什么难

“bootstrap”这个词的本义是”提着自己的靴带把自己拉起来”——一个近乎悖论的形象。在操作系统领域,它指的是:把操作系统的至少一部分装入主内存,让处理器去执行它;同时初始化内核数据结构、创建若干用户进程,并把控制权移交给其中一个。

这件事之所以”冗长而乏味”,根源在于起点太糟糕了:计算机上电之初,几乎每个硬件设备——包括 RAM——都处于随机、不可预测的状态。没有任何代码可以被”信任”,甚至没有代码可以被”找到”。此外,引导过程与计算机体系结构高度相关,本章沿本书惯例只讨论 80x86 架构。

整个引导过程可以按”文明进化”的阶段来记忆,这也是原书章节标题的妙处:

电源 → BIOS(史前) → Boot Loader(上古) → setup()(中世纪)
     → startup_32()(文艺复兴) → start_kernel()(现代) → 登录提示符

每一阶段都只做”最少必要的事”,然后把接力棒交给下一阶段。

史前时代:BIOS

计算机通电后的一瞬间,它几乎毫无用处:RAM 芯片里是随机数据,没有操作系统在运行。要开始引导,需要一个”第一推动力”。硬件上由一个特殊电路把 CPU 的 RESET 引脚电平拉高;RESET 之后,处理器的某些寄存器(包括 cs 和 eip)被设为固定值,CPU 开始执行物理地址 0xfffffff0 处的代码。

这个地址被硬件映射到一块只读的持久化内存芯片,即通常所说的 ROM。存放在 ROM 里的那组程序,在 80x86 架构上传统称为 BIOS(Basic Input/Output System,基本输入/输出系统),因为它包含若干由中断驱动的底层过程,所有操作系统在引导阶段都用它们来处理硬件设备。有些操作系统(如 MS-DOS)甚至大量依赖 BIOS 来实现大部分系统调用。

有两个关键事实值得记住:

  1. 进入保护模式后,Linux 就不再使用 BIOS 了,而是为计算机上的每种硬件设备提供自己的设备驱动。原因是 BIOS 过程必须在实模式下执行,哪怕共享函数有好处也无法做到——它和内核活在两个世界。
  2. BIOS 使用实模式地址,因为这是计算机开机时唯一可用的寻址方式。实模式地址由段 seg 和偏移 off 组成,物理地址 = seg*16+off。因此 CPU 的地址翻译电路不需要 GDT、LDT 或页表。反过来说,初始化 GDT、LDT 和页表的那段代码,自己必须运行在实模式下——这就是引导初期种种”别扭”操作的根源。

Linux 被迫在引导阶段使用 BIOS,因为它必须借助 BIOS 从磁盘或其他外部设备取回内核镜像。BIOS 的引导过程本质上做四件事:

  1. 对硬件做一系列测试,确定哪些设备存在、是否工作正常——这一阶段常称为 POST(Power-On Self-Test,上电自检),期间会显示若干消息,比如 BIOS 版本横幅。近来的 80x86、AMD64 和 Itanium 计算机遵循 ACPI(高级配置与电源接口)标准,兼容 ACPI 的 BIOS 引导代码会构建几张描述系统中硬件设备的表,格式与厂商无关,操作系统内核可以读取这些表来学习如何操纵设备。
  2. 初始化硬件设备。这个阶段在现代基于 PCI 的架构上至关重要:它保证所有硬件设备在 IRQ 线和 I/O 端口上互不冲突地工作。此阶段结束时,会显示出一张已安装 PCI 设备的表。
  3. 搜索要引导的操作系统:依据 BIOS 设置,按预定义、可定制的次序尝试访问系统中每个软盘、硬盘和 CD-ROM 的第一个扇区(引导扇区,boot sector)。
  4. 一旦找到有效设备,就把其第一个扇区的内容复制到 RAM 中物理地址 0x00007c00 开始处,然后跳转到该地址执行刚载入的代码。

从这里开始,控制权就离开了固化在芯片里的 BIOS,交到了磁盘上的代码手里。

上古时代:Boot Loader

**引导加载程序(boot loader)**是 BIOS 调用来把操作系统内核镜像装入 RAM 的程序。它在 IBM PC 架构上的工作方式大致是:

  • 从软盘引导:第一个扇区里的指令被装入 RAM 并执行,这些指令再把包含内核镜像的其余扇区全部复制进 RAM。
  • 从硬盘引导则不同:硬盘的第一个扇区称为 MBR(Master Boot Record,主引导记录),里面包含分区表和一个小程序;这个小程序去装载”要启动的那个操作系统所在分区”的第一个扇区。有些操作系统(如 Windows 98)依靠分区表中的活动标志(active flag)识别该分区——按这个思路,只有内核镜像存放在活动分区里的操作系统才能被引导。

Linux 更灵活:它用一个复杂的程序(boot loader)替换掉 MBR 里那个简陋的小程序,让用户可以选择引导哪个操作系统。

顺带一提内核镜像自身的演变:2.4 及更早的 Linux 内核镜像的头 512 字节里自带一个微型”boot loader”,因此把镜像从第一扇区开始复制就能让软盘可引导;Linux 2.6 的内核镜像不再包含这个引导程序,想从软盘引导就必须把合适的 boot loader 放进磁盘第一扇区。如今软盘引导已经和硬盘、CD-ROM 引导没有本质区别了。

两阶段引导与 LILO/GRUB

从磁盘引导 Linux 内核需要两阶段引导加载程序。80x86 系统上著名的 Linux 引导加载程序叫 LILO(LInux LOader);还有 GRUB(GRand Unified Bootloader)也被广泛使用。GRUB 比 LILO 更先进,因为它认识好几种基于磁盘的文件系统,从而能从文件中读取引导程序的一部分——这也是今天绝大多数发行版选择 GRUB 的原因(本章背景补充:GRUB 的配置写在 /boot/grub/grub.conf 或 menu.lst 中,可以在启动菜单里直接编辑内核参数;而 LILO 每次改配置后都必须重跑 lilo 命令把映射写回磁盘,否则改动不生效)。

LILO 可以安装在 MBR 上(替换掉装载活动分区引导扇区的小程序),也可以装在每个磁盘分区的引导扇区上。两种安装方式的最终效果一样:引导时执行加载程序,用户可以选择装载哪个操作系统。

LILO 实际上太大,一个扇区(512 字节)装不下,所以它被拆成两部分

  1. 第一部分很小,放在 MBR 或分区引导扇区里,由 BIOS 装入 RAM 中 0x00007c00 开始处。这个小程序把自己搬到地址 0x00096a00,建立实模式栈(范围 0x00098000 到 0x000969ff),再把 LILO 第二部分装入 RAM 中 0x00096c00 开始处,然后跳过去执行。
  2. 第二部分从磁盘读出可引导操作系统的映射表,给用户一个提示符以便从中选择。用户选定要装载的内核(或者超时后由 LILO 选择默认项)之后,引导加载程序要么把相应分区的引导扇区复制进 RAM 执行,要么直接把内核镜像复制进 RAM。

假设要引导的是 Linux 内核镜像,LILO 借助 BIOS 例程本质上有序地执行下列操作:

  1. 调用 BIOS 过程显示 “Loading” 消息;
  2. 调用 BIOS 过程从磁盘装入内核镜像的初始部分:内核镜像的前 512 字节放进 RAM 的 0x00090000 处,setup() 函数的代码(见下节)放进 RAM 的 0x00090200 开始处;
  3. 调用 BIOS 过程装入内核镜像的其余部分,放到低地址 0x00010000(对应 make zImage 编译的小内核镜像)或高地址 0x00100000(对应 make bzImage 编译的大内核镜像)。下文分别称为内核镜像”装载到低端”或”装载到高端”。大内核镜像的支持使用的引导方案与小镜像基本相同,只是把数据放到不同的物理内存地址,以避免第 2 章”物理内存布局”提到的 ISA 孔(0xa0000~0xfffff 那段被老式 ISA 设备占用的地址区间)带来的问题;
  4. 跳转到 setup() 代码。

常见坑:zImage 与 bzImage 不是"压缩与否"的区别

很多人望文生义以为 b 代表”不压缩”——其实两者都是压缩镜像,b 指 big:bzImage 只是允许内核被装到 1 MB 以上的高端内存(0x00100000),从而突破 zImage 的大小限制。现代发行版的内核全是 bzImage。

中世纪:setup() 函数

setup() 是一段汇编语言函数,链接器把它放在内核镜像文件的偏移 0x200 处——这正是上一节引导加载程序能轻易找到它、并把它复制到 RAM 物理地址 0x00090200 开始处的原因。

setup() 的任务:初始化计算机中的硬件设备,并为执行内核程序建立环境。虽然 BIOS 已经初始化过大部分硬件,但 Linux 不信任 BIOS 的结果,而是用自己的方式重新初始化设备,以增强可移植性和健壮性。setup() 本质上执行下列操作:

  1. 在遵循 ACPI 的系统上,调用 BIOS 例程在 RAM 里构建一张描述系统物理内存布局的表(在引导内核消息里找 “BIOS-e820” 标签就能看到它;e820 这个名字来自调用 BIOS 中断时 AX 寄存器填的值 0xe820)。较老的系统上则调用一个只返回系统可用 RAM 数量的 BIOS 例程;
  2. 设置键盘的重复延迟和速率(用户按住某键超过一定时间后,键盘设备会一遍遍把相应的 keycode 发给 CPU);
  3. 初始化视频适配卡;
  4. 重新初始化磁盘控制器并确定硬盘参数;
  5. 检测 IBM Micro Channel 总线(MCA);
  6. 检测 PS/2 指点设备(总线鼠标);
  7. 检测高级电源管理(APM)BIOS 支持;
  8. 如果 BIOS 支持增强磁盘驱动服务(EDD),调用相应的 BIOS 过程在 RAM 中建一张描述系统可用硬盘的表(读取 sysfs 特殊文件系统 firmware/edd 目录下的文件可以看到表中信息);
  9. 如果内核镜像被装载到低端(物理地址 0x00010000),就把它搬到物理地址 0x00001000;装载到高端则不动。这一步是必要的:为了能把内核镜像存进软盘并缩短引导时间,磁盘上的内核镜像是压缩的,而解压缩例程需要一块空闲空间——紧随镜像之后的临时缓冲区;
  10. 设置 8042 键盘控制器上的 A20 引脚。A20 引脚是 80286 时代引入的一个”补丁”,目的是让物理地址与古老的 8088 微处理器兼容。不幸的是,切换到保护模式之前必须正确设置 A20 引脚,否则 CPU 会把每个物理地址的第 21 位永远当作 0。设置 A20 引脚是个相当凌乱的操作;
  11. 建立临时的中断描述符表(IDT)和临时的全局描述符表(GDT)
  12. 复位浮点单元(FPU),如果有的话;
  13. 重新编程可编程中断控制器(PIC),屏蔽除 IRQ2(两个 PIC 之间的级联中断)之外的所有中断;
  14. 通过设置 cr0 状态寄存器里的 PE 位,把 CPU 从实模式切换到保护模式。注意 cr0 里的 PG 位被清零,所以分页仍然关闭
  15. 跳转到 startup_32() 汇编语言函数。

文艺复兴:startup_32() 函数

有意思的是,有两个不同的 startup_32() 函数。本节先讲第一个,代码在 arch/i386/boot/compressed/head.S 文件里。setup() 结束时,这个函数已经被移到物理地址 0x00100000 或 0x00001000 处——取决于内核镜像装载到高端还是低端。

第一个 startup_32() 执行以下操作:

  1. 初始化段寄存器和一个临时栈
  2. 清除 eflags 寄存器的所有位;
  3. 把内核未初始化数据区(由 _edata 和 _end 符号标识,见第 2 章”物理内存布局”)填充为 0;
  4. 调用 decompress_kernel() 函数解压内核镜像。屏幕上先显示 “Uncompressing Linux…” 消息,解压完成后显示 “OK, booting the kernel.”。如果内核镜像装载到低端,解压后的内核放在物理地址 0x00100000;如果装载到高端,解压后的内核先放在压缩镜像之后的一个临时缓冲区里,随后再被搬到最终位置——物理地址 0x00100000 开始处;
  5. 跳转到物理地址 0x00100000。

解压后的内核镜像开头是另一个 startup_32() 函数,这次包含在 arch/i386/kernel/head.S 文件里。两个函数重名不会出问题(除了让读者犯迷糊)——因为两者都是靠跳转到各自的初始物理地址来执行的,根本不是通过符号名调用。

第二个 startup_32() 的任务是为第一个 Linux 进程(进程 0)建立执行环境。它执行以下操作:

  1. 用最终值初始化段寄存器;
  2. 把内核的 bss 段(见第 20 章”程序段与进程内存区域”)填充为 0;
  3. 初始化 swapper_pg_dir 和 pg0 中的临时内核页表,把线性地址恒等映射到相同的物理地址(原理见第 2 章”内核页表”);
  4. 把页全局目录的地址存入 cr3 寄存器,并设置 cr0 中的 PG 位开启分页
  5. 为进程 0 建立内核态栈(见第 3 章”内核线程”);
  6. 再次清除 eflags 寄存器的所有位;
  7. 调用 setup_idt() 用空中断处理函数填充 IDT(见第 4 章”IDT 的初步初始化”);
  8. 把从 BIOS 获得的系统参数和传给操作系统的参数放进第一个页框(见第 2 章”物理内存布局”);
  9. 识别处理器的型号;
  10. 用 GDT 和 IDT 表的地址装载 gdtr 和 idtr 寄存器;
  11. 跳转到 start_kernel() 函数。

到这里,CPU 已经运行在解压后的真内核、开启了分页、有了页表和描述符表——虽然还只是”毛坯房”,但地基已经打好。

现代:start_kernel() 函数

start_kernel() 完成 Linux 内核的初始化。几乎每个内核组件都在这个函数里初始化,摘几个重要的:

  • 调度器:调用 sched_init() 初始化(见第 7 章);
  • 内存管理区:调用 build_all_zonelists() 初始化(见第 8 章”内存管理区”);
  • 伙伴系统分配器:调用 page_alloc_init() 和 mem_init() 初始化(见第 8 章”伙伴系统算法”);
  • IDT 的最终初始化:调用 trap_init()(异常处理,见第 4 章)和 init_IRQ()(IRQ 数据结构,见第 4 章);
  • TASKLET_SOFTIRQ 和 HI_SOFTIRQ 软中断:调用 softirq_init() 初始化(见第 4 章”软中断”);
  • 系统日期和时间:由 time_init() 初始化(见第 6 章”Linux 计时体系结构”);
  • slab 分配器:由 kmem_cache_init() 初始化(见第 8 章”普通与专用缓存”);
  • CPU 时钟速度:调用 calibrate_delay() 测定(见第 6 章”延迟函数”;就是启动日志里那句著名的 “Calibrating delay loop…”);
  • 进程 1 的内核线程:调用 kernel_thread() 创建。这个内核线程再创建其他内核线程,并执行 /sbin/init 程序(详见第 3 章”内核线程”)。

在 start_kernel() 开始后不久显示 “Linux version 2.6.11…” 消息,最后一阶段里内核线程和 init 程序还会显示许多其他消息。最终,熟悉的登录提示符出现在控制台上(如果启动时开启了 X 窗口系统,则出现在图形屏幕上)——它告诉用户:Linux 内核已经起来并正常运转了。

通关标准

合上书能默写出五级引导链各自的核心使命与代表性操作:BIOS(POST、初始化 PCI、找引导扇区、把第一扇区载入 0x7c00)→ boot loader(两阶段 LILO/GRUB,zImage 装低端 0x10000 / bzImage 装高端 0x100000,跳 setup())→ setup()(BIOS-e820 内存表、移动镜像、开 A20、建临时 GDT/IDT、置 PE 进保护模式)→ 第一个 startup_32()(decompress_kernel 解压到 0x100000)→ 第二个 startup_32()(临时页表、开 PG 分页、进程 0 内核栈、填 IDT)→ start_kernel()(初始化一切子系统,创建进程 1 执行 /sbin/init)。