理解Linux内核:Linux内核启动

Hacker News Top 工具

摘要

本文以太空殖民地隐喻描述初始化阶段,解释了x86_64架构下Linux内核的启动过程,涵盖从引导程序交接至用户空间初始化的完整流程。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/05/14 21:26

# Linux 内核启动过程 来源:https://internals-for-interns.com/posts/linux-kernel-startup 你有没有想过,在你按下电源键到登录界面出现之间,到底发生了什么?那段间隔——通常只有几秒钟——隐藏着计算领域中最复杂的初始化序列之一。今天,我想带你走一遍这个过程。 这是系列文章的第一篇,我将和你一起尝试理解 Linux 内核的内部机制。我们会讨论 Linux 如何启动、如何管理进程和内存、如何处理硬件等等。如果你曾经好奇过引擎盖下发生了什么,那你来对地方了。 > **⚠️ 快速声明** 我不是内核专家——我是在公开学习。这里的目标不是深入、详尽的讲解,而是提供一张**有用的地图**:主要组件是什么,以及它们如何组合在一起。想要深入研究的话,源代码才是真正的老师。本文聚焦于 **x86_64**。大方向的概念是通用的,但在 ARM、RISC-V 等架构上具体细节有所不同。 现在,让我给你打个比方,因为这将是一段漫长的旅程,我们需要一根主线来抓住。 ## 想象我们在建立一个太空殖民地 想象一个贫瘠的星球。没有可以呼吸的空气,没有道路,没有建筑,没有电力,没有通讯。我们用一艘降落舱派遣一支小小的**先遣队**。他们的任务:把这块岩石变成一个可运作的殖民地,并且要在生命维持系统耗尽之前完成。 先遣队不能直接把所有人都卸下来,开始举行市政会议。他们必须按照一个非常特定的顺序来做。首先是基础:确认着陆器没有坠毁,设置好万一出问题时的应急程序。然后勘测地形,找到可用的资源,划出存储区域。接着启动施工设备,建造第一批居住舱、电网、通讯塔。然后开始正式的治理:任命一位殖民地总督,建立一个负责处理未来船员到达的调度办公室,以及一个接管那些无聊的“保持运转”职责的维护团队。最后,他们从冷冻睡眠中唤醒其他殖民者,并把这个地方的钥匙交给他们。 这基本上就是 Linux 内核在启动时做的事情。引导加载程序就是降落舱。你的计算机就是那个贫瘠星球。先遣队就是 Linux 内核启动代码的执行过程——我们整个文章都会跟随这个过程。而到这篇文章结束时,那个先遣队将真正地把自己转变为一个待命的维护团队,同时一个全新的平民政府接手工作。请忍耐一下——随着我们的进展,一切都会变得清晰。 以下是我们即将进行的大致行程: Linux 内核启动流程:引导加载程序 → 汇编入口 (startup_64) → 早期 C 代码 (x86_64_start_kernel) → 架构设置 (setup_arch) → 核心子系统 (start_kernel) → 线程化 (rest_init) → 最终化 (kernel_init) → 用户空间 (/sbin/init) 让我们从引导加载程序结束的地方开始。 ## 交接:引导加载程序交给了我们什么 所以 GRUB(或者你使用的任何引导加载程序)将控制权交给了内核。我们实际有什么可以用的呢? 老实说,不多。 **CPU** 已经在运行,但处于几种**模式**之一——大致上,指的是寄存器的宽度和内存的工作方式。在 x86 上是 16 位实模式、32 位保护模式或 64 位长模式。UEFI 会让我们直接进入长模式;传统的 BIOS 通常让我们停留在保护模式。我们处理 CPU 状态的其余部分(页表、中断)要等到进入第一阶段。 **内存很棘手。** 内核被加载到 RAM 的低地址(通常在 `0x1000000` 附近),但它是*编译*为在高虚拟地址(类似 `0xffffffff81000000`)运行的。这个不匹配很快就会困扰我们。 还有什么?来自固件的一张内存映射(在 x86 上是 **E820 映射**),告诉我们 RAM 在哪里、哪些是保留的、**ACPI**(高级配置与电源接口)表在哪里;一堆启动参数(命令行、initrd 位置等);就这些了。没有控制台,没有分配器,没有中断,没有日志。 让我们开始构建一些东西。 ## 第一阶段:汇编跳板 ### 首先解压内核 在一切之前,先说一个小曲折:引导加载程序交给我们的文件是一个 `bzImage`,其中大部分是**压缩的**。发布压缩的内核可以节省磁盘空间和启动时的内存,但 CPU 显然不能直接执行压缩后的字节码。所以,第一个运行的代码并不是内核本身——而是一个微小的**解压器**,位于 `arch/x86/boot/compressed/` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/boot/compressed/)。它的任务是将真正的内核镜像解压到内存中,然后跳转到那里。 解压器还会选择一个**随机基地址**来加载内核——这就是 **KASLR**(内核地址空间布局随机化),它让那些想猜测内核代码位置的攻击者更难下手。 一旦解压器完成工作,控制权就跳转到真正的内核中。 ### 进入真正的内核 我们落在哪里取决于引导加载程序。在传统的 32 位启动中,我们在解压器内部的 `startup_32` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/boot/compressed/head_64.S#L82) 开始,它必须自己将 CPU 提升到 64 位**长模式**——构建一个微小的页表,其中每个虚拟地址都指向相同的物理地址(一个*恒等映射*——最简单的设置),翻转“你现在是一块 64 位芯片”的位,开启分页,然后跳转到 `startup_64` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/head_64.S#L38)。现代的 UEFI 引导加载程序会跳过这个攀升过程,直接跳转到 `startup_64`。无论哪种方式,所有路径都汇聚在这里。而且我们处于最原始的状态:几乎是纯汇编,没有 C 运行时,没有库调用——只有指令和寄存器。 那么内核做的第一件事是什么?两个它无法运转的必备管道:它将栈指针指向一个预先分配的小缓冲区(没有可用的栈,你无法调用任何函数),并安装一个最小的 **GDT** 和 **IDT**——分别是 CPU 用于内存段和异常处理程序所需的两个查找表。有了这些,更有趣的工作就可以开始了。 ### 加密硬件的绕行 第一个有趣的步骤:内存加密。一些 AMD CPU 可以透明地**加密 RAM**,这样物理上访问内存芯片的人就无法直接读取你的数据。这个特性叫做 **SME**(安全内存加密),还有一个用于虚拟机的版本叫做 **SEV**(Intel 这边的类似物是 **TDX**);Intel 上更简单的“用一把钥匙加密所有 RAM”特性是 **TME**(全内存加密)。如果我们在这样的硬件上,内核必须*立刻*打开加密——你不能回头去加密已经以明文形式写下的数据。 处理完加密,我们可以问下一个问题了。 ### 着陆器幸存了吗? 在进一步深入之前,我们应该检查一下设备是否工作。先遣队不会在空气回收器都还没打开的时候就开始拆包装发电机。内核用 `verify_cpu` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/verify_cpu.S#L38) 做了它的等价操作: ``` verify_cpu: # 检查长模式支持 # 验证 SSE2(x86_64 ABI 所需) # 验证其他 CPU 特性 ``` 长模式?检查完毕。SSE2?检查完毕。如果缺少任何必要的东西,我们就停在这里。顺便说一下,这正是为什么你不能在 32 位 CPU 上运行 64 位内核——不是后面某个地方会出现微妙的错误,而是我们在第一个检查清单项上就失败了。 验证完设备,是时候处理清单中那个棘手的问题了。 ### 地址不匹配问题 还记得之前的尴尬吗?内核被加载到一个地址,但编译是针对另一个地址的,我们必须在进一步处理之前解决它。想象一下,先遣队带着一份详细的殖民地地图,但实际着陆点距离地图上标注的“你在这里”标志有两公里远。每个引用,比如“发电站在北边 500 米”,现在都是错的。 在内核术语中,这个修复叫做**页表修正**。**页表**是 CPU 用来将代码中的虚拟地址转换为字节实际所在的物理地址的查找表。内核附带了一小组由链接器预先填充的这些表,假设它会被加载到特定地址——而 KASLR 刚刚打破了这个假设。 所以 `startup_64` 调用了一个 C 辅助函数 `__startup_64()` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/boot/startup/map_kernel.c#L87)(通过一个称为 `__pi___startup_64` 的位置无关 thunk 调用,以防你去 grep 源代码),它计算“代码认为它在哪”和“我们实际落在哪”之间的差异,并按照那个偏移量修补页表条目。一旦返回,虚拟地址就能翻译到正确的物理字节,地图也与现实匹配了。 地址问题解决后,我们终于可以离开裸机汇编世界了。 ### 跳入 C 语言 页表修复后,内核将 CPU 切换到它们,并跳转到第一个真正的 C 函数 `x86_64_start_kernel`。从这时起,内核在链接器最初目标的高虚拟地址上运行。我们离开了裸汇编——但我们得到的 C 也只是勉强可用:没有分配器,没有控制台,没有库调用。只有带有原始指针和纪律的 C。 ## 第二阶段:早期 C 初始化 第一个 C 函数是 `arch/x86/kernel/head64.c` 中的 `x86_64_start_kernel` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/head64.c#L222)。我们仍处于先遣队模式,但我们现在有了稍微好一点的工具。 在更有趣的工作之前,`x86_64_start_kernel` 做一些我们不必深究的簿记工作:`cr4_init_shadow()` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/head64.c#L238) 缓存一份 CR4 控制寄存器的副本,以便后续代码避免从 CPU 重新读取;`reset_early_page_tables()` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/head64.c#L241) 丢弃了让我们走到这里的恒等映射页表——我们不再需要那个带辅助轮的映射了。第一个值得讨论的杂务是令人尴尬的平凡。 ### 擦净工作台 在一个普通的 C 程序中,运行时在 `main()` 运行之前负责将未初始化的全局变量清零。但是*我们*就是运行时。没有人会替我们做这件事: 这将 `.bss` 段清零——它保存未初始化的全局变量和静态变量。这相当于拆开装备,确保每个储物箱在有人放东西之前都是空的。 接下来,我们想让一些安全机制站起来——即使只是作为占位符。 ### KASAN,还没有办公室的安全检查员 在 KASAN 之前,一个快速的 `sme_early_init()` 完成了我们在第一阶段开始的加密设置,这样从现在起我们触碰的任何页表条目,在需要加密的硬件上都会以加密形式出现。 然后,如果内核是用 **KASAN**(内核地址消毒器)构建的——它就像一个能捕获 use-after-free 错误和缓冲区溢出的安全检查员——我们需要在这里把它启动起来。问题是:KASAN 需要一大片**影子内存**来跟踪内核分配的每一个字节,而我们还没有一个真正的内存分配器。 诀窍是将整个影子区域指向一个单一的**零页**。被 KASAN 检测的代码可以从任何影子地址读取,只会看到零,这防止了它在实际上没有任何东西被跟踪时崩溃。当真正的内存分配器稍后上线时,KASAN 会获得合适的影子内存,并真正开始工作。 说到安全网,我们还需要处理出问题的情况。 ### 在需要之前的应急程序 如果现在出了什么问题——一个错误的内存访问、除零错误,任何事——CPU 需要知道该叫谁。在 x86 上,CPU 查明这个的方法是查找一个叫做**中断描述符表 (IDT)** 的表:一个固定大小的数组,其中每个条目说“如果异常号 N 发生,就跳转到这个处理函数”。如果我们没有设置一个,CPU 就没有处理程序来运行原始问题,这本身就会变成第二个异常,而查找*那个*的处理程序也会失败。连续三次失败后,CPU 放弃并重置机器——一个**三重故障**,从外部看就像一个没有任何错误消息的静默重启。不理想。 所以我们要安装一个最小的 IDT: ``` idt_setup_early_handler(); ``` 它不花哨。它处理基础知识——**页错误**、**通用保护故障**——并且至少确保当我们最终得到一个控制台时,我们可以在死掉之前打印一些有用的信息。这就像殖民地的“拨打 911”贴纸贴在墙上,而实际的应急响应大楼还不存在。 ### 保存引导加载程序的笔记 引导加载程序给了我们重要的东西——命令行、内存映射、initrd 位置——但它们都位于我们即将覆盖的临时内存中。所以 `copy_bootdata()` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/head64.c#L281) 将它们复制到 `boot_params`,一个内核拥有的结构体中(在此过程中,一个叫做 `sanitize_boot_params()` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/head64.c#L206) 的内部辅助函数将引导加载程序不应填写的字段清零)。从现在开始,我们可以问“initrd 在哪?”而不用担心答案被静默覆盖。 ### 修补 CPU 本身 `x86_64_start_kernel` 做的最后一件事情确实令人惊讶:它修补**CPU 自己的微码**。一个现代 x86 芯片不直接执行其指令集——它使用称为**微码**的固件将每条指令翻译成更小的内部操作,该固件存在于芯片上。Intel 和 AMD 发布微码更新的方式与他们发布安全补丁的方式相同,内核通过 `load_ucode_bsp()` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/head64.c#L286) 在启动时应用它们。 为什么是现在?因为一些 CPU 错误(Spectre、MDS 及其同类)是通过微码更新本身修复的。先遣队在去任何地方之前,先完成了着陆器固件的更新。一旦补丁应用完毕,早期的 C 代码就完成了。是时候真正环顾四周了。 ## 第三阶段:硬件发现与内存设置 现在我们调用 `setup_arch()`,它位于 `arch/x86/kernel/setup.c` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/setup.c#L884)。这是将轨道勘测转化为地面实况——内核确切地弄清楚我们站在哪种星球上。 `setup_arch()` 想知道的第一件事是它要处理什么类型的 CPU。 ### 编目团队的技能 第一个问题:这个 CPU 实际能做什么?我们还不知道它是否有花哨的向量指令、硬件加密、快速上下文切换技巧,或者它容易受到哪些推测执行漏洞的影响。所以 `early_cpu_init()` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/setup.c#L937) 使用 **CPUID** 指令直接问芯片,并将答案转储到一个叫做 `boot_cpu_data` 的结构体中。 这个结构体是内核后来查找“我们是否有特性 X?”的依据——这就是内核决定诸如“使用 AVX-512 memcpy 还是回退到慢速版本”之类事情的方式。当我们开始自修补时,它会很重要。 编目完团队的技能后,下一个大问题是最基本的:我们可以把东西放在哪里? ### 读取勘测数据 还记得固件给我们的那个 E820 内存映射吗?我们终于真正读取它了: `e820__memory_setup()` (https://github.com/torvalds/linux/blob/v7.0/arch/x86/kernel/setup.c#L963) 拉入固件的内存映射并清理它。固件是出了名地不可靠——区域重叠、范围差一、特殊区域没有被标记——所以内核 sanit

相似文章

用 x86_64 汇编写成的 Linux 桌面

Lobsters Hottest

一位开发者借助 Claude Code,用纯 x86_64 汇编重建了完整的 Linux 桌面栈——从 shell、终端、窗口管理器到各种工具,实现微秒级启动,并延长数小时续航。

wsl9x:Windows 9x 的 Linux 子系统

Lobsters Hottest

wsl9x 是一款全新开源工具,它将现代 Linux 6.19 内核作为协作子系统嵌入 Windows 9x,无需重启即可让旧版与当代软件并肩运行。