原文地址:The Linux Boot Process: From Power Button to Kernel
作者:0xkato
本文为中文翻译版本,旨在分享这篇优秀的Linux启动过程技术文章。
引言
你按下电源按钮。一秒钟后,一墙文本滚过,或是一个logo淡入,最终Linux出现了。其间发生的事情并非魔法。这是tiny程序和一个非常 literal 的CPU之间的精心握手。这部分将追踪这个握手过程,直到Linux内核内部的第一行C代码运行。
第一部分 — 从电源按钮到内核的第一次呼吸
最早的指令
当电源稳定时,CPU将自己重置为一个称为实模式的小型、老式的模式。实模式可以追溯到最初的8086芯片。规则设计得很简单。内存地址由CPU保存在称为寄存器的特殊快速存储中的两个值构建。你这样组合段和偏移量:
1 | physical_address = (segment << 4) + offset |
如果你看到像0xFFFFFFF0这样的数字,那是十六进制。Hex是16进制。我们写0x在前面来明确这一点。0x10是日常计数中的16。0x100000是1兆字节。十六进制与硬件如何存储位很好地对齐,这就是为什么你在低级代码中到处看到它的原因。
复位后,CPU跳转到称为复位向量的特殊地址0xFFFFFFF0。把它想象成一个永久书签,说"从这里开始"。在那个地址几乎没有空间,所以制造商在那里放了一个远(长)跳转,将控制权传递给主板上的固件。
小解释:寄存器
寄存器是CPU内部的一个小槽。它保存CPU正在使用的数字。像CS和IP这样的名称是寄存器名称。CS意思是"代码段",标记指令的当前邻域。IP意思是"指令指针",标记接下来要执行哪条指令。
BIOS和UEFI
固件是烘焙在你主板上的一个小启动程序。
BIOS代表基本输入输出系统。它是较旧的样式。BIOS执行一个称为POST的快速健康检查,查看启动顺序,并尝试每个设备。如果它找到其第一个512字节扇区以标记字节0x55和0xAA结尾的磁盘,它将该设备视为可启动。BIOS将该扇区复制到内存中0x7C00并跳转到那里。那个扇区很小,所以它通常只知道如何加载下一个更大的片段。
UEFI是现代替代品。它仍然启动机器,但直接理解文件系统,并可以加载更大的引导程序,而不需要旧的"第一扇区"舞蹈。UEFI还向操作系统传递更丰富的信息。不同的路径,相同的目标:将控制权交给能够加载Linux的引导程序。
认识引导加载程序
引导加载程序是让操作系统就位的引导员。GRUB是PC上的流行选择。它读取其配置,如果你安装了一个会显示菜单,并将Linux内核加载到内存中。Linux内核文件实际上包含两样东西:
• 一个仍然在实模式下运行的小设置程序
• 稍后将被解压缩的较大的压缩内核
GRUB还填充了一个称为设置头的小结构,包含有用的事实:
它将内核放在哪里,命令行在哪里,如果有initrd的话它在哪里。然后它跳转到设置程序。
设置程序创建一个安全的空间
在任何Linux可以做任何有趣的事情之前,设置代码创建一个可预测的工作空间。
它对齐段寄存器,使得内存复制每次都以相同的方式进行。你在这里会看到的名称是CS表示代码,DS表示数据,SS表示栈。它还清除一个称为"方向标志"的单个CPU位,以便复制指令向前通过内存移动。
它创建一个栈。栈是一个后进先出的工作台,函数在其中存储临时值。SS表示栈使用哪个段。SP是栈当前顶部的指针。
它清除一个称为BSS的区域。BSS是必须以零开始的全局变量所在的地方。C代码假设BSS为零。设置程序将零写入整个跨度以保持这个承诺。
如果你在内核命令行上传入了earlyprintk,设置代码还会对串行端口进行编程,这样它就可以打印非常早期的消息。这在图形尚未准备就绪时很有用。
最后,设置程序询问固件"我们实际上有多少可用RAM以及空洞在哪里。"在旧BIOS上,这是一个人们经常昵称为e820的调用,它返回一个简单的可用和保留范围列表。内核将使用该列表避免踩到固界的脚趾。
完成后,设置代码调用其第一个C函数 literally 名为main。我们仍然在小的旧实模式中。下一步工作是离开它。
小解释:中断
中断是硬件或软件的"打扰一下",暂停CPU正在做的事情并为紧急事情运行一个小处理程序。定时器tick是一个中断。键击是一个中断。这里有两种风格。可屏蔽中断遵循你的规则,可以被临时阻止,以便它们在关键时刻不触发。非屏蔽中断,通常称为NMI,总是切入,因为它们通常报告严重的硬件问题。我们将在切换模式时控制两者,这样就不会有东西在半路上让我们惊讶。
第二部分 — 离开实模式,穿越32位土地,到达64位
现代Linux在PC上以长模式运行,即x86_64的64位模式。你不能从实模式直接跳转到那里。路径是实模式到保护模式,然后保护模式到长模式。这部分涵盖那条路径并解释沿途的词汇。
保护模式,没有术语迷雾
保护模式是1980年代引入的32位世界,用于超越当时的限制。它添加了两个核心思想。
全局描述符表或GDT是一个短描述符列表。一个描述说"这个段从这里开始,覆盖这么多,并被允许做这些事情。"Linux保持这个简单。它使用平面模型,这意味着基址为零,大小覆盖整个32位空间。当一切都平坦时,地址看起来像普通数字一样。
中断描述符表或IDT是"紧急呼叫电话号码"的目录。如果中断到达,CPU在IDT中查找条目并跳转到列出的处理程序。在切换期间,我们加载一个小的占位符IDT,因为我们即将阻止中断。全功能IDT稍后会到达,一旦真正的内核负责。
小心的切换
设置代码首先关闭嘈杂的部分。它用一条指令禁用可屏蔽中断。它使旧的PIC芯片安静下来,这样硬件中断在瞬间被完全阻塞。它打开A20行。这是一个历史遗留问题。早期PC使得地址在1兆字节处环绕。打开A20移除那个环绕,所以更高的地址按你期望的方式工作。它重置数学协处理器,使浮点状态干净。
然后它加载一个只有我们现在需要的小GDT和一个小IDT。最后它在称为CR0的控制寄存器中设置一个称为PE的单个位并执行远跳转。该跳转从GDT重新加载代码段并锁定保护模式。它重新加载数据段和栈段,并修复栈指针以匹配新的平面世界。
我们现在处于32位保护模式。
小解释:控制寄存器
CPU有一些特殊的开/关开关寄存器。CR0打开保护模式。CR3保存页表顶部的地址,我们马上就会需要。CR4启用更大的页表条目等扩展功能集。
为什么我们仍然没有完成
Linux想要64位。那是长模式。需要两样东西。
分页必须打开。分页是虚拟地址和物理地址之间的转换器。程序使用虚拟地址。硬件读取和写入物理内存。页表以称为页的固定大小块将一个映射到另一个。在PC上,正常页面是4KB。还有更大的页面。启动早期,内核使用2兆字节页面快速描述低内存。
在称为EFER的特殊寄存器中必须设置一个称为LME的单个位以允许长模式。EFER是型号特定寄存器,这是一种说"用于某些CPU功能的寄存器"的优雅方式。
构建足够的分页
32位序言构建一个小的页表集,说"对于这个区域,虚拟等于物理。"这称为身份映射。足以安全地打开分页。
为了使这工作,代码在CR4中启用PAE以使用更大的条目。它构建覆盖低内存2兆字节块的最小表集。它将顶层表的地址写入CR3。分页现在已准备就绪。
最后它在EFER中设置LME并执行远返回到一个写为64位代码的标签。长模式现在处于活动状态。段仍然是"平坦的",但地址和寄存器是64位宽。
为什么所有额外的小心
在运行系统切换模式就像在滚动时更换轮胎一样。代码阻止中断,准备所需的最小表,翻转位,然后才邀请中断返回。慢而稳阻止了奇怪的半切换状态。
第三部分 — 解压真实内核,修复地址,以及为什么Linux有时会移动自己
我们有一个64位CPU,分页打开,内存中有一个压缩内核。现在小的64位存根做实际工作:如果需要就离开,解压内核,如果内核不在其默认位置就修复地址,然后跳转。
清理路径并设置安全网
存根首先弄清楚它实际在哪里运行。早期代码被链接为好像它住在地址零,然后在运行时计算其真实基址。如果解压缩内核的计划目标位置会与存根重叠,它将自己复制到安全地方。
它清除自己的BSS,以便全局状态从干净开始。
它用两个处理程序加载一个最小IDT。一个用于页面错误,一个用于NMI。当CPU无法找到它刚刚尝试使用的虚拟地址的映射时,会发生页面错误。在我们的早期身份映射世界中,小页面错误处理程序可以动态添加缺失的映射并继续。NMI处理程序在那里,这样当我们在启动时,NMI不会使机器崩溃。
它还为其下一步将触及的区域构建身份映射。这包括内核的未来家园、引导加载程序填充的小引导参数页面和命令行缓冲区。
解压Linux…
通常名为extract_kernel的C函数接管。它为临时缓冲区设置一个小堆,打印经典行,并使用内核构建时使用的任何算法解压缩内核。gzip、xz、zstd、lzo和其他都插入到同一个包装器中。
当字节出来后,解压器读取内核的ELF头。ELF是Executable and Linkable Format的缩写,既是文件格式也是地图。它说哪些块是代码,哪些是数据,以及每个块确切地想要住在哪里。解压器将每个块复制到它所属的地方。
如果内核被加载到与它构建的地址不同的地址,解压器应用重定位。重定位是一个小的修复,调整包含地址的指针或指令。解压器遍历这些的列表并修补每个地方,以便它指向我们在地址空间中实际使用的正确位置。
当一切都到位时,解压器返回真正内核的入口点并跳转,将指向引导参数的指针传递过去。从那一刻起你就处于完整内核中了。你遇到的第一个函数是start_kernel,大的初始化开始了。
为什么内核有时会故意移动自己
你可能在内核日志中看到kASLR提到。那是内核地址空间布局随机化。想法很简单。如果攻击者不知道内核在内存中住在哪里,某些攻击就变得困难得多。
在启动早期,如果kASLR启用,解压器随机选择两个"基址":
• 物理基址,即字节在RAM中住的地方
• 虚拟基址,即一旦完整分页设置好内核将使用的起始虚拟地址
它如何在不破坏任何东西的情况下选择
它构建一个不要触摸列表。这包括解压器本身、压缩图像、initial ramdisk、引导参数页面和命令行缓冲区。它还可以包括你用命令行上的memmap=选项保留的范围。
它从固件扫描内存映射以找到大空闲区域。对于每个空闲区域,它计算有多少对齐的"槽"适合正确的大小。它使用它拥有的最佳早期熵源绘制随机数。在现代CPU上可能是硬件随机指令。它将数字减少到槽的总数并选择匹配的槽。那成为物理基址。虚拟基址以相同方式选择,但在内核的虚拟地址窗口内。
如果不存在合适的东西,代码回退到默认地址并打印小警告。如果你在命令行上传入nokaslr,设计会跳过随机化步骤。
快速词汇表
十六进制:用0x书写的16进制数字。0x10是16。0x100000是1兆字节。十六进制与位映射清洁,这就是低级代码使用它的原因。
寄存器:CPU内部保存当前数字的小槽。例子:CS、DS、SS、IP、SP。
段和偏移:用于构建实模式地址的两部分。物理地址等于段乘以16加偏移。
BIOS:启动机器、检查硬件并将第一个引导扇区加载到内存的较旧固件。
UEFI:理解文件系统并直接加载更大引导程序的现代固件。
引导加载程序:将内核放入内存并将系统事实传递给它的引导员。GRUB是常见的一个。
栈:函数的后进先出工作台。SS选择其段。SP指向当前顶部。
BSS:必须以零开始的全局变量所在的内核区域。设置代码在C运行之前清除它。
中断:来自硬件或软件的快速"打扰一下"。CPU暂停,运行小处理程序,然后恢复。可屏蔽中断可以被阻止片刻。NMI不能。
GDT:全局描述符表。段描述符的短列表。Linux将其设置为简单平面模型。
IDT:中断描述符表。处理程序的中断目录。早期引导使用最小的一个。真正的内核稍后安装真正的。
A20行:必须打开以在旧PC上正确寻址1兆字节以上地址的历史开关。
保护模式:引入GDT和IDT并允许分页的32位模式。
长模式:x86_64上的64位模式。需要分页和在EFER寄存器中设置名为LME的位。
分页:从虚拟地址到物理内存的转换器。用页表实现。
页表:将虚拟页面映射到物理页面的数据结构。早期引导使用身份映射。正常页面是4KB。早期引导经常使用2MB页面快速覆盖区域。
CR0、CR3、CR4:控制寄存器。CR0打开保护模式。CR3指向页表顶部。CR4启用PAE等扩展功能。
EFER:保存长模式启用以及其他位的型号特定寄存器。
ELF:内核的磁盘格式,带有内置的什么属于哪里的地图。
重定位:当代码加载到与其构建的基址不同的基址时调整地址的修复。
kASLR:在启动时随机化内核基址地址以使开发更加困难。
相关阅读
本文为技术翻译文章,原始文章作者为0xkato,发表在0xkato.xyz上。翻译旨在学习和分享,如需查看原文请访问上述链接。