Linux启动过程:从电源按钮到内核

71 阅读12分钟

从按下电源到内核的第一次呼吸

你按下电源键。片刻之后,一墙的文本滚过,或一个 logo 淡入,最终 Linux 启动。中间并非魔法,而是微型程序与一本正经的 CPU 之间的一次谨慎握手。本文沿着这次握手,一直走到 Linux 内核里第一行 C 代码开始运行的那一刻。

第一条指令

当电源稳定后,CPU 会复位到一种古老而简洁的“实模式”(real mode),它可追溯到 8086。规则被刻意简化:内存地址由两部分组成,CPU 在寄存器里分别保存“段”(segment)与“偏移”(offset),组合方式是:
physical_address = (segment << 4) + offset

看到 0xFFFFFFF0 这样的数字就是十六进制。十六进制以 16 为底,前缀 0x 用来标示:0x10 等于 16,0x100000 等于 1 MB。十六进制与硬件按位存储天然契合,所以底层代码里很常见。

复位后,CPU 会跳到名为“复位向量”(reset vector)的特殊地址 0xFFFFFFF0。可以把它当作一枚永久书签——“从这里开始”。这个地址空间几乎为零,因此厂商会在那放一个“远跳转”(far jump),把控制权交给主板上的固件。

小科普:寄存器 寄存器是 CPU 内部的一格超高速存储,用来保存此刻正在使用的数值。CS 表示“代码段”,标记当前指令所在的区域;IP 表示“指令指针”,指向下一条将执行的指令。

BIOS UEFI

固件是焊在主板上的小型“引导程序”。

BIOS(Basic Input Output System)是较旧的一种。它会做一次快速自检(POST),查看启动顺序并逐个尝试设备。如果发现某个磁盘的第一个 512 字节扇区以 0x55 和 0xAA 结尾,就认定该设备可启动。随后把这个扇区拷贝到 0x7C00 并跳转过去。这个扇区非常小,通常只负责加载下一个更大的部分。

UEFI 是现代替代者。它同样负责开机,但能直接理解文件系统,加载更大的引导程序,无需旧式“首扇区”流程;同时还会把更丰富的系统信息传给操作系统。路径不同,目的相同:把控制权交给能加载 Linux 的引导程序。

认识引导加载器

引导加载器是把操作系统“领进来”的司仪。PC 上常用的是 GRUB。它读取配置,若安装了菜单会显示菜单,然后把 Linux 内核加载进内存。内核文件实际包含两部分:

  • 仍在实模式下运行的小型 setup 程序

  • 稍后将解压的较大“压缩内核”

GRUB 还会填充一个名为 setup header 的小结构,记录关键信息:内核放在哪里、命令行和 initrd 在哪里等。然后它跳入 setup 程序。

setup 程序搭建“安全工作间”

在 Linux 做任何事之前,setup 代码会先把现场收拾得可预测且一致。

它对齐段寄存器,使内存拷贝每次都按同一规则工作。这里会看到 CS(代码段)、DS(数据段)、SS(栈段)。它还清除一个名为“方向标志”的 CPU 位,让拷贝指令向前推进内存。

它创建栈。栈是函数用来存放临时值的后进先出(LIFO)工作台。SS 指定栈使用的段,SP 指向当前栈顶。

它清零 BSS 区域。BSS 存放“必须从零开始”的全局变量。C 代码默认它为零,setup 程序会把这段内存写成全零以兑现承诺。

如果命令行传了 earlyprintk,setup 还会初始化串口,以便在图形未就绪时输出最早期日志。

最后,setup 向固件询问“到底有多少可用内存、哪里是保留区”。在旧式 BIOS 上,这个调用常被称作 e820,返回“可用/保留”范围列表。内核会用它避开固件占用的区域。

完成之后,setup 代码调用它的第一个 C 函数,名字就叫 main。我们仍处在那个小而古老的实模式里,下一步是离开它。

小科普:中断 中断是来自硬件或软件的“打断一下”:CPU 暂停当前工作,运行一个小处理程序后再恢复。可屏蔽中断可以暂时关闭以避开关键时刻;不可屏蔽中断(NMI)总会插入,因为它通常是严重硬件问题的信号。切换模式过程中,我们会控制两者,防止半途出状况。

离开实模式、走过 32 位、抵达 64 位长模式

现代 PC 上的 Linux 运行在“长模式”(long mode,即 x86_64 的 64 位模式)。无法从实模式直接跳过去,必须先到保护模式(protected mode),再从保护模式进入长模式。下面解释这条路径和相关术语。

去除术语迷雾的保护模式

保护模式是为突破 80 年代硬件限制而引入的 32 位世界,核心是两件事:

全局描述符表(GDT)是一份“段描述”的短列表。描述说明“段从哪里开始、大小有多大、允许做什么”。Linux 采用“扁平模型”:基址为 0,大小覆盖整个 32 位空间。这样地址又像普通数字。

中断描述符表(IDT)是紧急呼叫的“号码簿”。中断到来时,CPU 在 IDT 中查找条目并跳到对应的处理程序。切换期间我们会加载一个占位用的最小 IDT,因为马上就要屏蔽中断;完整 IDT 由后续真正的内核安装。

小心完成切换

setup 先“降噪”。它用一条指令关闭可屏蔽中断,让老式 PIC 芯片安静片刻,完全阻断硬件中断。它打开 A20 线——这是历史遗留:早期 PC 会在 1 MB 处让地址回绕;打开 A20 后,高地址才能如预期工作。它还重置数学协处理器,清理浮点状态。

接着加载只含必需内容的最小 GDT 与最小 IDT。最后,在控制寄存器 CR0 里设置名为 PE 的位,并执行一次远跳转。该跳转会用 GDT 重载代码段,锁定保护模式;随后重载数据段和栈段,并修正栈指针以匹配“扁平内存”。

至此,我们进入 32 位保护模式。

小科普:控制寄存器 CPU 里有几个专用于开关的寄存器。CR0 用来开启保护模式;CR3 保存页表顶层地址;CR4 启用扩展功能,例如更大的页表项。

为什么还没完

Linux 目标是 64 位的长模式,需要两件事:

必须开启分页。分页是“虚拟地址”和“物理内存”之间的翻译器。程序使用虚拟地址,硬件读写物理内存。页表把两者按固定大小的“页”映射起来。PC 上普通页是 4 KB,也有更大的页。启动早期内核常用 2 MB 大页快速覆盖低端内存。

必须在名为 EFER 的寄存器里置位一个叫 LME 的位,才能允许长模式。EFER 是“型号特定寄存器”(MSR),用来控制某些 CPU 特性。

构建“恰到好处”的分页

32 位序幕会先搭一套最小页表,表达“这片区域里虚拟地址等于物理地址”,称为同址映射(identity map),足以安全地打开分页。

为此,代码在 CR4 中启用 PAE,以使用更大的页表项;建立一套用 2 MB 大页覆盖低端内存的最小表;把顶层表地址写入 CR3。分页到位。

最后在 EFER 中置位 LME,并执行一次“远返回”(far return)到一个用 64 位语义写成的标签。长模式启用。段依然“扁平”,但地址与寄存器都已变为 64 位宽。

为什么要如此谨慎 在运行中的系统里切换模式,像在行进中换轮胎。先屏蔽打断,准备好最低限度的表结构,再翻动开关,最后才恢复中断。慢而稳,避免半切换的怪异状态。

解包真正的内核、修正地址、为何内核有时主动挪位

此时我们拥有开启分页的 64 位 CPU,以及一份压缩内核驻留内存。一个小型 64 位“存根”(stub)开始做务实工作:必要时先把自己挪开,解压内核,若内核不在默认位置则修正地址,最后跳转。

清出路径与设好安全网

存根先确定自己的实际运行基址。早期代码按地址 0 进行链接,运行时再计算真实基址。如果解压后的内核目标地址与存根重叠,它会先把自己拷到安全处。

它清理自己的 BSS,保证全局状态从零开始。

它加载一个最小 IDT,只有两个处理程序:页错误(page fault)与 NMI。页错误发生在 CPU 刚访问某个虚拟地址却找不到映射的时候;在同址映射的早期世界里,这个微型处理程序可以现场补齐映射继续运行。NMI 处理程序确保在尚未完全起来的阶段不会被不可屏蔽中断直接打崩。

它还为稍后要触碰的内存区域建立同址映射,包括内核的未来驻地、由引导加载器填充的引导参数页,以及命令行缓冲区。

解压 Linux…

一个通常叫 extract_kernel 的 C 函数接手。它划出一小块堆作临时缓冲,打印那句经典提示,然后用内核构建时所选算法进行解压。gzip、xz、zstd、lzo 等都可插拔到同一套封装里。

字节释放出来后,解压器读取内核的 ELF 头。ELF(Executable and Linkable Format)既是文件格式,也是地图:标注哪些块是代码、哪些是数据、每块期望放在哪里。解压器会把每块拷到它应在的位置。

若内核被加载到与构建时不同的地址,解压器会应用重定位(relocation)。这是一种对包含地址的指令或指针进行小型修补的过程。解压器遍历重定位列表,逐一打补丁,让它们指向我们当前地址空间里的正确位置。

当一切就位,解压器返回“真内核”的入口地址并跳转过去,同时传递引导参数指针。从此刻起你进入完整内核,遇到的第一个函数是 start_kernel,大型初始化随即展开。

为什么内核有时会故意挪动自己

你可能在内核日志里见到 kASLR(Kernel Address Space Layout Randomization)。思想很直白:如果攻击者不知道内核在内存中的确切位置,某些攻击就难以施展。

在启动早期,如果启用了 kASLR,解压器会随机选择两个“基址”:

  • 物理基址:字节在 RAM 中的实际落点

  • 虚拟基址:当完整分页建立后,内核将使用的虚拟地址起点

如何随机而不破坏现场?

它先列一份“勿动清单”,包括解压器自身、压缩镜像、初始 ramdisk、引导参数页、命令行缓冲区;还可以包含你通过 memmap= 命令行选项保留的范围。

随后扫描固件给出的内存映射,寻找足够大的空闲区域;对每个空闲区域,计算可容纳多少对齐且大小合适的“槽位”。用最好的早期熵源抽一个随机数——在现代 CPU 上可能是硬件随机指令——再把它约束到槽位总数范围,选中对应槽位作为物理基址。虚拟基址的选取方法类似,但限定在内核的虚拟地址窗口内。

如果没有合适位置,代码会退回默认地址并打印一则简短警告;若命令行传入 nokaslr,则按设计跳过随机化。

速查词汇表

十六进制。 以 0x 为前缀的 16 进制数;0x10 是 16,0x100000 是 1 MB。因与位表示高度契合,底层代码常用。

寄存器。 CPU 内的高速小存储,保存当前使用的值。示例:CS、DS、SS、IP、SP。

段与偏移。 实模式地址的两部分。物理地址等于段乘以 16 再加偏移。

BIOS 较旧的固件,负责开机自检、读启动顺序,并把首个可引导扇区载入内存。

UEFI. 现代固件,理解文件系统,能直接加载更大的引导程序,并传递更丰富的系统信息。

引导加载器。 把内核载入内存并传递系统信息的“司仪”。GRUB 是常见选择。

栈。 函数的后进先出工作台。SS 指定栈段,SP 指向栈顶。

BSS 存放需从零开始的全局变量。setup 在 C 运行前清零它。

中断。 来自硬件或软件的快速插入。可屏蔽中断可暂时关闭;NMI 不可屏蔽。

GDT 全局描述符表。段描述的短列表。Linux 采用扁平模型。

IDT 中断描述符表。中断处理程序的目录。早期用最小版本,完整内核后再安装正式版本。

A20 线。 历史开关。必须打开,地址才能正确越过 1 MB(老式 PC)。

保护模式。 引入 GDT/IDT 并允许分页的 32 位模式。

长模式。 x86_64 的 64 位模式。需开启分页并在 EFER 中置位 LME。

分页。 把虚拟地址翻译为物理内存的机制,靠页表实现。

页表。 映射虚拟页到物理页的数据结构。早期用同址映射。普通页 4 KB,常用 2 MB 大页快速覆盖低内存。

CR0 CR3 CR4 控制寄存器。CR0 开保护模式;CR3 指向页表顶层;CR4 开诸如 PAE 的扩展。

EFER 型号特定寄存器,包含 Long Mode Enable 等位。

ELF 内核的磁盘格式,内含“各部分应放何处”的地图。

重定位。 当加载基址不同于构建基址时,对地址做修补的过程。

kASLR 启动时随机化内核基址,使利用更困难。