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

第一部分 —— 从按下电源键到内核第一次“呼吸”

你按下电源键。一秒钟后,屏幕上闪过一行行文字,或者出现一个 logo,最终 Linux 系统启动。中间发生的一切并不是魔法,而是一些小程序与 CPU 之间精心设计的“握手”过程。这一部分将带你追踪这个过程,直到 Linux 内核的第一行 C 代码开始运行。

第一条指令

当电源稳定后,CPU 会将自己重置为一种古老的工作模式,称为实模式(real mode)。实模式可以追溯到最初的 8086 芯片,规则非常简单。内存地址由 CPU 内部称为寄存器(register)的两个值组合而成:段(segment)和偏移(offset)。地址计算公式如下:

物理地址 = (段 << 4) + 偏移

你可能会看到像 0xFFFFFFF0 这样的数字,这是十六进制(hexadecimal)表示法,即 base 16。我们在数字前加 0x 表示十六进制。0x10 就是十进制的 16,0x100000 是 1MB。十六进制与硬件存储位的方式非常契合,因此在底层代码中随处可见。

重置后,CPU 会跳转到称为“重置向量”(reset vector)的特殊地址 0xFFFFFFF0。你可以把它想象成一个永久书签,上面写着“从这里开始”。这个地址空间非常小,因此主板制造商会在这里放置一个“远跳转”(far jump)指令,将控制权交给主板上的固件(firmware)。

小知识:寄存器(register)
寄存器是 CPU 内部的一个微小存储槽,用于存放当前正在使用的数字。例如,CS 和 IP 是寄存器名称。CS 表示“代码段”(code segment),标记当前指令所在的“区域”;IP 表示“指令指针”(instruction pointer),标记下一条要执行的指令位置。

BIOS 与 UEFI

固件是主板内置的小型启动程序。

  • BIOS(Basic Input Output System)是较老的固件类型。它会进行简单的硬件自检(POST),检查启动顺序,并依次尝试每个设备。如果某个设备的第一个 512 字节扇区以 0x550xAA 结尾,BIOS 就认为该设备可启动。BIOS 将这个扇区复制到内存地址 0x7C00 并跳转到那里执行。这个扇区非常小,通常只能加载下一个更大的程序。

  • UEFI 是现代固件,能直接理解文件系统,无需“第一个扇区”这种老办法即可加载更大的启动程序。UEFI 还能向操作系统传递更丰富的信息。虽然路径不同,但目标一致:将控制权交给能加载 Linux 的启动程序。

认识引导加载器(bootloader)

引导加载器是“引座员”,负责将操作系统加载到内存中。GRUB 是 PC 上常用的引导加载器。它会读取配置文件,显示启动菜单(如果安装了),并将 Linux 内核加载到内存中。Linux 内核文件实际上包含两部分:

  • 一个仍在实模式下运行的小型安装程序(setup)
  • 一个较大的压缩内核,稍后会解压

GRUB 还会填充一个称为“安装头”(setup header)的小结构体,包含内核加载位置、命令行参数位置、initrd 位置等有用信息。然后,GRUB 跳转到安装程序。

安装程序创建安全空间

在 Linux 能做有趣的事情之前,安装代码会创建一个可预测的工作环境:

  • 对齐段寄存器(CS、DS、SS),使内存复制行为一致。
  • 清除一个称为“方向标志”(direction flag)的 CPU 位,使复制指令按正确方向移动。
  • 创建栈(stack),用于函数临时存储数据。SS 指定栈段,SP 指向栈顶。
  • 清除 BSS 区域,这是全局变量初始值为零的地方。C 代码假设 BSS 为零,安装程序会将其清零。
  • 如果内核命令行中指定了 earlyprintk,安装程序还会配置串口,以便在图形界面未准备好时打印早期调试信息。
  • 最后,安装程序向固件询问“实际可用内存有多少,哪些地方不能用”。在老 BIOS 上,这通常称为 e820 调用,返回可用和保留内存区域的列表。内核将使用此列表避免踩到固件的“脚趾”。

完成后,安装程序调用第一个 C 函数,名字就叫 main。此时我们仍在实模式下。下一步是离开实模式。

小知识:中断(interrupt)
中断是硬件或软件的“打扰一下”信号,会暂停 CPU 当前工作并运行一个紧急处理程序。定时器滴答、按键都会产生中断。中断分为可屏蔽(maskable)和不可屏蔽(NMI)。可屏蔽中断可以暂时被阻止,NMI 则不能,通常用于报告严重硬件问题。我们在切换模式时会控制这两种中断,防止中途被打断。


第二部分 —— 离开实模式,经过 32 位世界,抵达 64 位

现代 PC 上的 Linux 运行在“长模式”(long mode),即 x86_64 的 64 位模式。你不能直接从实模式跳转到长模式。路径是:实模式 → 保护模式(protected mode)→ 长模式。本部分将介绍这条路径及相关术语。

保护模式,去掉术语迷雾

保护模式是 1980 年代引入的 32 位世界,有两个核心概念:

  • GDT(Global Descriptor Table):段描述符的短列表。描述符说明“这个段从这里开始,覆盖这么多,允许做这些事”。Linux 使用“平坦模型”(flat model),即段基址为 0,大小覆盖整个 32 位空间。这样地址看起来又是普通数字了。
  • IDT(Interrupt Descriptor Table):中断处理程序的“电话簿”。中断到来时,CPU 查找 IDT 并跳转到对应处理程序。切换模式时我们只加载一个临时的占位 IDT,真正的 IDT 稍后才安装。

小心的切换过程

安装代码先关闭“噪音”:

  • 禁用可屏蔽中断。
  • 关闭老 PIC 芯片,完全屏蔽硬件中断。
  • 打开 A20 线(早期 PC 的地址在 1MB 处会回绕,打开 A20 才能正确访问更高地址)。
  • 重置数学协处理器,清理浮点状态。

然后加载一个极简 GDT 和 IDT,设置控制寄存器 CR0 中的保护模式位(PE),并执行远跳转(far jump),从 GDT 重新加载代码段,锁定保护模式。再重新加载数据段、栈段,并修正栈指针以适应新的平坦世界。

现在我们处于 32 位保护模式。

小知识:控制寄存器(control registers)
CPU 有一些特殊寄存器用于开关功能。CR0 开启保护模式,CR3 存放页表顶部地址,CR4 启用扩展功能(如更大的页表项)。

为什么还没完?

Linux 想要 64 位,即长模式。需要两件事:

  • 开启分页(paging):虚拟地址到物理地址的翻译。
  • 设置 EFER 寄存器中的 LME 位(Long Mode Enable)。

构建足够的分页机制

32 位前奏构建了一组小的页表,使用“虚拟地址等于物理地址”的“身份映射”(identity map),足够安全地开启分页。

代码启用 CR4 中的 PAE(Physical Address Extension),构建覆盖低内存的 2MB 大页表,将顶级页表地址写入 CR3,分页就绪。

最后设置 EFER 中的 LME 位,并执行远返回(far return)进入 64 位代码。长模式激活,段仍为“平坦”,但地址和寄存器变为 64 位。

为什么如此谨慎?
在系统运行时切换模式就像边开车边换轮胎。代码会屏蔽中断,准备最小所需表格,切换模式,再重新允许中断。慢而稳,避免奇怪的“半切换”状态。


第三部分 —— 解压真正的内核,修复地址,以及 Linux 为何有时主动搬家

我们已有 64 位 CPU、分页已开启、内存中有压缩内核。现在小的 64 位 stub 做实际工作:如有需要让出位置、解压内核、修复地址、跳转。

清理道路,设置安全网

stub 先确定自己实际运行的位置。早期代码链接时假设地址为 0,运行时计算真实基址。如果解压后的内核会与 stub 重叠,它会将自己复制到安全位置。

  • 清除自己的 BSS,确保全局状态干净。
  • 加载极简 IDT,只包含两个处理程序:页错误(page fault)和 NMI。页错误处理程序可在早期身份映射世界中动态添加缺失映射。NMI 处理程序防止不可屏蔽中断在启动过程中崩溃系统。
  • 构建身份映射,覆盖即将使用的区域,包括内核未来位置、启动参数页、命令行缓冲区。

解压 Linux……

一个名为 extract_kernel 的 C 函数接管。它会:

  • 分配一小块堆内存用于临时缓冲区。
  • 打印经典信息“Decompressing Linux…”。
  • 使用内核构建时选择的算法(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,则跳过随机化。


快速术语表(建议收藏)

  • 十六进制(Hexadecimal):base 16 数字,前缀 0x0x10 是 16,0x100000 是 1MB。底层代码常用。
  • 寄存器(Register):CPU 内部微小存储槽,存放当前使用的数字。如 CS、DS、SS、IP、SP。
  • 段与偏移(Segment & Offset):实模式下构建地址的两个部分。物理地址 = 段 × 16 + 偏移。
  • BIOS:旧固件,启动机器、检查硬件、加载第一个启动扇区。
  • UEFI:现代固件,理解文件系统,可直接加载较大启动程序。
  • 引导加载器(Bootloader):将内核放入内存并传递系统信息的“引座员”,如 GRUB。
  • 栈(Stack):函数临时存储的“后进先出”工作台。SS 选择栈段,SP 指向栈顶。
  • BSS:全局变量初始为零的区域,启动代码会在 C 运行前清零。
  • 中断(Interrupt):硬件或软件的“打扰一下”。可屏蔽中断可暂时阻止,NMI 不可。
  • GDT:全局描述符表,段描述符列表,Linux 使用平坦模型。
  • IDT:中断描述符表,中断处理程序目录。早期使用极简版,真正内核稍后安装完整版。
  • A20 线:必须打开才能正确访问 1MB 以上地址的历史开关。
  • 保护模式(Protected Mode):32 位模式,引入 GDT、IDT,允许分页。
  • 长模式(Long Mode):x86_64 的 64 位模式,需分页并设置 EFER 寄存器的 LME 位。
  • 分页(Paging):虚拟地址到物理地址的翻译机制,使用页表。
  • 页表(Page Tables):映射虚拟页到物理页的数据结构。早期使用身份映射,普通页 4KB,早期常用 2MB 大页。
  • CR0、CR3、CR4:控制寄存器。CR0 开启保护模式,CR3 指向页表顶部,CR4 启用扩展功能。
  • EFER:模型特定寄存器,存放长模式启用位等。
  • ELF:内核磁盘格式,内置“地图”说明各块应放何处。
  • 重定位(Relocation):代码加载地址与构建地址不同时的地址修正。
  • kASLR:启动时随机化内核基址,增加攻击难度。