这一篇在干嘛?
按下电源开关的那一刻,内存里是随机的垃圾数据,什么操作系统都没有——那 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 来实现大部分系统调用。
有两个关键事实值得记住:
- 进入保护模式后,Linux 就不再使用 BIOS 了,而是为计算机上的每种硬件设备提供自己的设备驱动。原因是 BIOS 过程必须在实模式下执行,哪怕共享函数有好处也无法做到——它和内核活在两个世界。
- BIOS 使用实模式地址,因为这是计算机开机时唯一可用的寻址方式。实模式地址由段 seg 和偏移 off 组成,物理地址 = seg*16+off。因此 CPU 的地址翻译电路不需要 GDT、LDT 或页表。反过来说,初始化 GDT、LDT 和页表的那段代码,自己必须运行在实模式下——这就是引导初期种种”别扭”操作的根源。
Linux 被迫在引导阶段使用 BIOS,因为它必须借助 BIOS 从磁盘或其他外部设备取回内核镜像。BIOS 的引导过程本质上做四件事:
- 对硬件做一系列测试,确定哪些设备存在、是否工作正常——这一阶段常称为 POST(Power-On Self-Test,上电自检),期间会显示若干消息,比如 BIOS 版本横幅。近来的 80x86、AMD64 和 Itanium 计算机遵循 ACPI(高级配置与电源接口)标准,兼容 ACPI 的 BIOS 引导代码会构建几张描述系统中硬件设备的表,格式与厂商无关,操作系统内核可以读取这些表来学习如何操纵设备。
- 初始化硬件设备。这个阶段在现代基于 PCI 的架构上至关重要:它保证所有硬件设备在 IRQ 线和 I/O 端口上互不冲突地工作。此阶段结束时,会显示出一张已安装 PCI 设备的表。
- 搜索要引导的操作系统:依据 BIOS 设置,按预定义、可定制的次序尝试访问系统中每个软盘、硬盘和 CD-ROM 的第一个扇区(引导扇区,boot sector)。
- 一旦找到有效设备,就把其第一个扇区的内容复制到 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 字节)装不下,所以它被拆成两部分:
- 第一部分很小,放在 MBR 或分区引导扇区里,由 BIOS 装入 RAM 中 0x00007c00 开始处。这个小程序把自己搬到地址 0x00096a00,建立实模式栈(范围 0x00098000 到 0x000969ff),再把 LILO 第二部分装入 RAM 中 0x00096c00 开始处,然后跳过去执行。
- 第二部分从磁盘读出可引导操作系统的映射表,给用户一个提示符以便从中选择。用户选定要装载的内核(或者超时后由 LILO 选择默认项)之后,引导加载程序要么把相应分区的引导扇区复制进 RAM 执行,要么直接把内核镜像复制进 RAM。
假设要引导的是 Linux 内核镜像,LILO 借助 BIOS 例程本质上有序地执行下列操作:
- 调用 BIOS 过程显示 “Loading” 消息;
- 调用 BIOS 过程从磁盘装入内核镜像的初始部分:内核镜像的前 512 字节放进 RAM 的 0x00090000 处,setup() 函数的代码(见下节)放进 RAM 的 0x00090200 开始处;
- 调用 BIOS 过程装入内核镜像的其余部分,放到低地址 0x00010000(对应
make zImage编译的小内核镜像)或高地址 0x00100000(对应make bzImage编译的大内核镜像)。下文分别称为内核镜像”装载到低端”或”装载到高端”。大内核镜像的支持使用的引导方案与小镜像基本相同,只是把数据放到不同的物理内存地址,以避免第 2 章”物理内存布局”提到的 ISA 孔(0xa0000~0xfffff 那段被老式 ISA 设备占用的地址区间)带来的问题; - 跳转到 setup() 代码。
常见坑:zImage 与 bzImage 不是"压缩与否"的区别
很多人望文生义以为 b 代表”不压缩”——其实两者都是压缩镜像,b 指 big:bzImage 只是允许内核被装到 1 MB 以上的高端内存(0x00100000),从而突破 zImage 的大小限制。现代发行版的内核全是 bzImage。
中世纪:setup() 函数
setup() 是一段汇编语言函数,链接器把它放在内核镜像文件的偏移 0x200 处——这正是上一节引导加载程序能轻易找到它、并把它复制到 RAM 物理地址 0x00090200 开始处的原因。
setup() 的任务:初始化计算机中的硬件设备,并为执行内核程序建立环境。虽然 BIOS 已经初始化过大部分硬件,但 Linux 不信任 BIOS 的结果,而是用自己的方式重新初始化设备,以增强可移植性和健壮性。setup() 本质上执行下列操作:
- 在遵循 ACPI 的系统上,调用 BIOS 例程在 RAM 里构建一张描述系统物理内存布局的表(在引导内核消息里找 “BIOS-e820” 标签就能看到它;e820 这个名字来自调用 BIOS 中断时 AX 寄存器填的值 0xe820)。较老的系统上则调用一个只返回系统可用 RAM 数量的 BIOS 例程;
- 设置键盘的重复延迟和速率(用户按住某键超过一定时间后,键盘设备会一遍遍把相应的 keycode 发给 CPU);
- 初始化视频适配卡;
- 重新初始化磁盘控制器并确定硬盘参数;
- 检测 IBM Micro Channel 总线(MCA);
- 检测 PS/2 指点设备(总线鼠标);
- 检测高级电源管理(APM)BIOS 支持;
- 如果 BIOS 支持增强磁盘驱动服务(EDD),调用相应的 BIOS 过程在 RAM 中建一张描述系统可用硬盘的表(读取 sysfs 特殊文件系统 firmware/edd 目录下的文件可以看到表中信息);
- 如果内核镜像被装载到低端(物理地址 0x00010000),就把它搬到物理地址 0x00001000;装载到高端则不动。这一步是必要的:为了能把内核镜像存进软盘并缩短引导时间,磁盘上的内核镜像是压缩的,而解压缩例程需要一块空闲空间——紧随镜像之后的临时缓冲区;
- 设置 8042 键盘控制器上的 A20 引脚。A20 引脚是 80286 时代引入的一个”补丁”,目的是让物理地址与古老的 8088 微处理器兼容。不幸的是,切换到保护模式之前必须正确设置 A20 引脚,否则 CPU 会把每个物理地址的第 21 位永远当作 0。设置 A20 引脚是个相当凌乱的操作;
- 建立临时的中断描述符表(IDT)和临时的全局描述符表(GDT);
- 复位浮点单元(FPU),如果有的话;
- 重新编程可编程中断控制器(PIC),屏蔽除 IRQ2(两个 PIC 之间的级联中断)之外的所有中断;
- 通过设置 cr0 状态寄存器里的 PE 位,把 CPU 从实模式切换到保护模式。注意 cr0 里的 PG 位被清零,所以分页仍然关闭;
- 跳转到 startup_32() 汇编语言函数。
文艺复兴:startup_32() 函数
有意思的是,有两个不同的 startup_32() 函数。本节先讲第一个,代码在 arch/i386/boot/compressed/head.S 文件里。setup() 结束时,这个函数已经被移到物理地址 0x00100000 或 0x00001000 处——取决于内核镜像装载到高端还是低端。
第一个 startup_32() 执行以下操作:
- 初始化段寄存器和一个临时栈;
- 清除 eflags 寄存器的所有位;
- 把内核未初始化数据区(由 _edata 和 _end 符号标识,见第 2 章”物理内存布局”)填充为 0;
- 调用 decompress_kernel() 函数解压内核镜像。屏幕上先显示 “Uncompressing Linux…” 消息,解压完成后显示 “OK, booting the kernel.”。如果内核镜像装载到低端,解压后的内核放在物理地址 0x00100000;如果装载到高端,解压后的内核先放在压缩镜像之后的一个临时缓冲区里,随后再被搬到最终位置——物理地址 0x00100000 开始处;
- 跳转到物理地址 0x00100000。
解压后的内核镜像开头是另一个 startup_32() 函数,这次包含在 arch/i386/kernel/head.S 文件里。两个函数重名不会出问题(除了让读者犯迷糊)——因为两者都是靠跳转到各自的初始物理地址来执行的,根本不是通过符号名调用。
第二个 startup_32() 的任务是为第一个 Linux 进程(进程 0)建立执行环境。它执行以下操作:
- 用最终值初始化段寄存器;
- 把内核的 bss 段(见第 20 章”程序段与进程内存区域”)填充为 0;
- 初始化 swapper_pg_dir 和 pg0 中的临时内核页表,把线性地址恒等映射到相同的物理地址(原理见第 2 章”内核页表”);
- 把页全局目录的地址存入 cr3 寄存器,并设置 cr0 中的 PG 位开启分页;
- 为进程 0 建立内核态栈(见第 3 章”内核线程”);
- 再次清除 eflags 寄存器的所有位;
- 调用 setup_idt() 用空中断处理函数填充 IDT(见第 4 章”IDT 的初步初始化”);
- 把从 BIOS 获得的系统参数和传给操作系统的参数放进第一个页框(见第 2 章”物理内存布局”);
- 识别处理器的型号;
- 用 GDT 和 IDT 表的地址装载 gdtr 和 idtr 寄存器;
- 跳转到 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)。
计算机刚通电时 CPU 执行的第一条指令位于哪里?为什么是那个地址?
位于物理地址 0xfffffff0。RESET 后 cs 和 eip 被设为固定值,CPU 从这个地址取指;该地址由硬件映射到 ROM 芯片,里面存的就是 BIOS 程序。选择放在 4 GB 末尾是为了让上电地址固定可预测,而实模式下 cs 基址加上偏移后实际会回落到 1 MB 以内的 ROM 区域执行。
Linux 进入保护模式后为什么不再使用 BIOS?引导阶段却又不得不用?
BIOS 过程必须在实模式下执行,而且它服务于”任何操作系统都能用”的通用性,没有保护态、分页这些概念;Linux 进入保护模式后为每种硬件提供自己的驱动,效率和控制力都更好。但引导初期必须从磁盘读内核镜像,而此时实模式是唯一可用的寻址方式、页表和 GDT 都还没建立,所以只能借用 BIOS 的磁盘例程来搬运内核。
setup() 为什么要移动内核镜像、设置 A20 引脚?分别不设置会怎样?
移动镜像(低端时装载在 0x10000 的搬到 0x1000)是为了给解压缩例程留出紧随其后的临时缓冲区——磁盘上的内核是压缩的,解压需要工作空间,不移动就没有连续空地可放解压结果。A20 引脚源于 80286 与 8088 的地址兼容补丁;不正确设置它,CPU 会把所有物理地址的第 21 位当 0,访问高于 1 MB 的内存就会缠绕回低地址,保护模式下的内存寻址将完全错乱。
有两个重名的 startup_32() 函数,它们分别做什么、互不冲突的原因是什么?
第一个在 arch/i386/boot/compressed/head.S,运行在未解压的镜像里,负责初始化段寄存器和临时栈、清 eflags、调 decompress_kernel() 解压内核到 0x100000,然后跳过去;第二个在 arch/i386/kernel/head.S,是解压后内核的开头,为进程 0 建立执行环境:最终段寄存器、清 bss、建临时页表并开启分页(PG 位)、设进程 0 的内核栈、填 IDT、把启动参数放进第一个页框,最后跳 start_kernel()。互不冲突是因为两者都是按初始物理地址跳转执行的,从不通过符号名调用。
start_kernel() 之后系统怎样走到登录提示符?
start_kernel() 依次初始化调度器、内存管理区与伙伴系统、slab 分配器、IDT(trap_init/init_IRQ)、软中断、系统时钟、CPU 速度(calibrate_delay),最后用 kernel_thread() 创建进程 1 的内核线程;进程 1 再创建其他内核线程并执行 /sbin/init 用户态程序,由它按运行级别拉起各项服务和 getty。init 及内核线程输出的众多消息刷屏之后,控制台出现登录提示符,说明引导完成。