LinuxBoot:从 Linux 启动 FreeBSD
作者:Warner Losh
我们是如何走到这一步的
有三个主要因素促使 LinuxBoot 获得日益增长的关注:最初的简单性、失控的增长和回归简单时代的渴望。这三者共同作用下,导致了在 x86 和嵌入式系统上形成了复杂的引导生态系统。尽管 LinuxBoot 试图简化这些生态系统,但涉及整个 Linux 内核通常并不会让人立即联想到“简单”。
在 IBM PC 之前,大多数系统要么需要手动输入引导程序,要么其自动化的引导 ROM 简单到足以加载单个引导扇区,再随之加载系统的其余部分。使用简单的加载程序加载逐步更复杂的加载程序的过程被称为“引导系统”,源自“pulling yourself up by your own bootstraps”(自举)这一古老的说法。随着时间的推移,这个过程被简化为“引导”系统。
1982 年,IBM 发布的 IBM PC 只略微进了之前的系统,提供了 ROM 中的引导代码和其他基本的输入输出功能。IBM 将这些服务称为 BIOS,源于 CP/M 系统中的术语。这就是我们今天使用的“BIOS”一词的来源,也解释了为什么人们在讨论时会对它是否指“用来引导系统的固件”,还是仅指 PC 平台上 UEFI 之前的引导方式产生混淆。支持两种观点的阵营都坚信自己的看法是正确的,但很少有人了解这一歧义的历史。所以,我将使用“CSM”和“CSM 引导”来指代这种引导方式。UEFI 标准使用“CSM”来描述传统引导,并且这一术语是确无歧义的。
随着时间的推移,CSM 引导逐渐加入了许多额外功能:磁盘的分区表、处理器配置的 MP 表、APM 用于电源管理、SMBIOS 提供系统元数据、PCI 的运行时服务、PXE 网络引导和用 ACPI 来统一许多之前的功能。这些服务的接口与 x86 特定的机制绑定在一起。整个系统演变成了一个十分复杂的生态系统,充斥着临时修补、特殊情况和微妙的不同解释
在 1990 年代末,当英特尔设计 IA-64 CPU 架构时,很快发现这一革命性架构无法使用 CSM 生态系统中的大部分技术。因此,整个引导生态系统不得不被替换。这标志着统一可扩展固件接口(UEFI)引导的诞生。最初,它仅适用于英特尔的 x86(重新命名为 IA-32)和 IA-64 架构。2000 年代初期,UEFI 固件逐渐取代了英特尔 x86 系统上的旧系统,一般能够支持旧的 CSM 引导和新的 UEFI 引导。到 2006 年,英特尔通过其 TianoCore 项目发布了 EDK2 开源开发工具包,用于创建 UEFI 固件,推动了更多 OEM 采用 UEFI。
与此同时,1990 年代和 2000 年代,另一种嵌入式系统的引导生态系统正在发展。最初,嵌入式领域有几十种不同的引导加载程序,它们的接口与后续引导阶段略有不同。Mangus Damm 和 Wolfgang Denk 于 1999 年创建了 Das U-Boot,最初用于 PowerPC,后来扩展到 ARM、MIPS 和其他架构。U-Boot 从一个小巧简单的引导加载程序开始,灵活性比竞争对手更强,而且由于它是开源的,相对易扩展。它迅速成为通用引导加载程序,凭借其简单性、广泛的支持和丰富的功能集。它设立了引导的标准,并推动了 Linux 的许多特性,包括支持扁平化设备树(FDT)。这个引导系统与 CSM 和 UEFI 完全不同。它如此易用,以至于所有竞争对手都逐渐消失在历史的长河中。比如,redboot、eCos、CFE、yaboot 和 YAMON,现在还在哪儿呢?最多只能在维基百科的脚注中找到它们。
2011 年,ARM 推出了 64 位版本的 ARM 平台 aarch64。U-Boot 和 EDK2 开始争夺在该平台上引导系统的主导地位,而 FDT 和 ACPI 也争夺系统设备枚举的主导权。低端嵌入式系统通常使用 U-Boot 和 FDT,而高端服务器类系统则使用 UEFI 和 ACPI。最终,UEFI 引导开始占据主导地位,尤其是当 U-Boot 开始提供最简化的 UEFI 实现,足以通过 UEFI 引导 Linux 时。ACPI 和 FDT 合并(现在可以使用 FDT 属性指定 ACPI 节点)。在这一过程中,EDK2/UEFI 变得越来越复杂,支持安全启动、iSCSI、更多网卡、RAM 磁盘支持、initramfs 支持等等其他无法一一列举的功能。
此外,还不能忽视那些使用简化版 Linux 内核的引导加载程序,比如 coreboot、slimboot、LinuxBIOS 等。将在下文中讨论上述中的一些。也不能忽略商业 BIOS 之间的差异。到 2017 年,谷歌决定采取措施,启动了 NERF 项目,以简化这一混乱,增强安全性。该项目名称代表非扩展型简化固件,与 UEFI 的统一可扩展固件接口相对立。在计算机游戏中它还是个俚语,表示对游戏元素的能力和影响力进行削弱,从而实现更好的平衡,提升游戏的乐趣。这些努力后来成为了 LinuxBoot 项目。
Linux 引导
使用 Linux 引导 Linux 历史悠久,但由于篇幅限制,这里仅做简要概述。在 1990 年代中期,Linux 添加了 kexec(2) 系列系统调用,用于增加服务器和嵌入式系统的正常运行时间和可靠性。在 1990 年代,Ron Minnich 和 Eric Biederman 于洛斯阿拉莫斯启动了 LinuxBIOS 项目,旨在使用 Linux 内核作为固件来引导系统。此后,它演变为 coreboot,广泛应用于 Chromebook 和几款开源平台笔记本电脑。在这一过程中,coreboot 变得模块化,能与开源组件一起使用二进制 blob,因为 CPU 制造商一直抵制公开早期的处理器初始化代码,只提供二进制 blob 给开源和闭源的固件开发者。EDK2、U-Boot 和闭源固件也发展出了模块化系统,能让这些二进制 blob 与其他组件共存。
NERF 项目演变为 LinuxBoot
由 Ron Minnich 领导的谷歌 NERF 项目最终演变为 LinuxBoot 项目:一系列帮助创建固件镜像、以 Linux 内核引导最终操作系统的脚本。这个项目背后有几个更远的目标。谷歌希望创建一款开源固件,其中每个组件都能自由获取。他们希望简化 UEFI 引导环境,认为它变得过于复杂且存在许多潜在的安全漏洞,通过用加固的 Linux 内核来替代它。他们希望利用广泛部署和经过审查的代码创建一个通用框架;最大限度减少不可避免的、只能通过二进制方式提供的非源代码部分;尽可能统一 ARM 和其他嵌入式系统的引导;消除冗余代码,加速引导过程;并提供比传统固件甚至 EDK2 更加模块化和可定制的引导体验。他们还希望能够创建可复现的构建环境,确保无论用户是下载还是自行构建,都能运行完全相同的二进制文件。
最终,LinuxBoot 成为了一款模块化的系统,支持多种引导加载程序。由特定于 CPU 和引导加载程序的代码处理初始化 CPU 的最早阶段。LinuxBoot 定义了哪些部分由这些代码初始化,哪些部分推迟到 Linux 内核进行初始化。这种设置能让 CPU 厂商继续发布二进制 blob,用于初始化现代 CPU 所需的底层时钟、内存控制器、辅助核心等。EDK2、coreboot、U-Boot 和 slimboot 都支持这些协议,因此 Linux 内核能够与它们一起引导,而无需为每种方案编写专门的代码。LinuxBoot 还提供了 u-root,一款用 Go 编写的 ramfs 构建工具,用于查找和加载最终的工具,以及一些其他用来操作固件镜像的工具。在第二部分中,我将讨论这些工具及其使用方法。
尽管 LinuxBoot 未完全成功地用 Linux 替代整个引导加载程序,但它最大程度地减少了残留的部分。例如,在 UEFI 中,只有 Pre-EFI 初始化(PEI)阶段负责初始化处理器、缓存和 RAM,而 UEFI 的运行时服务依然存在。
LinuxBoot 消除了所有薄弱测试的 UEFI DEX 驱动程序。Linux 内核接管了内存和基础硬件的初始化,但未初始化其他更传统固件可能会初始化的内容,例如 PCI 设备的资源。
除了更好的安全性和对固件的更多控制外,LinuxBoot 还使用了经过充分测试和广泛审查的 Linux 驱动程序,这些驱动程序是 Linux 在平台上运行所必需的。通过 LinuxBoot,SOC 厂商和系统集成商可以通过只为 Linux 编写驱动程序来优化上市时间,完全无需创建 UEFI DEX 驱动程序。拥有 Linux 驱动程序技能的程序员,比能编写 UEFI DEX 驱动程序的人更容易找到得多。与研究过 EDK2 UEFI 代码库的相对少数人相比,成千上万的研究者已经审计过 Linux 内核。尽管如此,这些优势要求其他支持 UEFI 的操作系统做出适应。它们的 UEFI 引导加载程序无法在剩余的 UEFI 小块上工作。这意味着,要在这些系统上启动,操作系统必须创建一款新的加载程序来支持 LinuxBoot。
一些更简单的操作系统通过 LinuxBoot 使用 Linux 的 kexec-tools 包提供的基本 ELF 加载功能进行引导。非常古老版本的 BSD 和 Plan9 就是通过这种方式引导的。运行在具有明确定义的 OpenFirmware 接口的更简单处理器上的 FreeBSD/powerpc 也采用这种方式加载。然而,Windows 无法通过这种方式引导,而 LinuxBoot 社区正在研究绕过这个限制的方法。FreeBSD/amd64 和 FreeBSD/aarch64 也无法通过这种方式引导。
FreeBSD 的 amd64 和 aarch64 内核需要仅引导加载程序可以访问的元数据。在 amd64 上,引导加载程序在捕获系统内存布局和其他数据之后将系统设置为长模式,这些信息只能在进入长模式之前访问。内核依赖于这些数据,缺少它就无法操作。在 amd64 和 aarch64 上,引导加载程序必须向内核通报 UEFI 系统表和其他系统数据的地址。引导加载程序通过设置“可调参数”来调整内核。加载程序预先加载动态内核模块,并将初始熵、UUID 等信息传递给内核。所有这些专有知识都不包含在仅能加载 ELF 二进制文件并跳转到起始地址的 kexec-tools 中。
FreeBSD 与 LinuxBoot
FreeBSD 与 Linux 引导的历史已有十多年。在 2010 年,FreeBSD 的 PS/3 移植版使用了 PS/3 的“另一款操作系统”参数进行引导。FreeBSD 开发者 Nathan Whitehorn 为 FreeBSD 引导加载程序添加了必要的代码,将内存设置为 FreeBSD 内核的使用。他创建了一个小型的 Linux 二进制文件,类似于 Ubuntu 在其 PS/3 支持包中的 kboot。这个 Linux 二进制文件是静态链接的,包含了读取 FreeBSD 内核所需的少数系统调用。它包括了一个小型的 libc(类似于 mucl 和 glibc)和命令行解析支持。然而,它的源代码结构假定仅支持 PowerPC 架构。
在 Netflix 工作期间,我于 2020 年开始实验,看看用 Linux 引导 FreeBSD 有多难。Netflix 在全球范围内运行着大量的服务器。经过多年的不断完善,Netflix 创建了一款非常稳健的系统,能够自动修复常见的问题。即便如此,启动问题仍然造成了大量的高成本返修。使用 UEFI 脚本来提高启动时间的可靠性只带来了边际性的改进。由于闪存驱动器包含脚本,只有少数几个简单的情况得到改善。闪存驱动器可能出现只读故障,导致 UEFI 固件和脚本做出错误的操作。只要脚本仍保留在驱动器上,进展就会停滞不前。
LinuxBoot 提供了一种吸引人的替代方案,因为它位于主板上的固件中,从而消除了最容易发生故障的组件。Netflix 希望我创建一个故障安全的环境,能够将机器的状态信息发送回主控系统,使用存活的 NVMe 驱动器重新配置机器,并提供了灵活的平台——支持远程调试、诊断镜像等。
我在用 Linux 引导 FreeBSD 时有几个目标:
必须能在 FreeBSD 构建系统内构建。
必须提供对主机资源的完全访问。
必须能够使用标准内核启动(如果可以)。
必须使用 UEFI 启动接口(不支持 i386 CSM 启动和 ARM U-Boot 二进制启动)。
必须作为 init/PID 1 运行。
必须在从 Shell 脚本调用时运行良好,以支持引导不同类型的镜像,且这些镜像不一定基于 FreeBSD。
在现代架构如 amd64 和 aarch64 上让 FreeBSD 从 Linux 引导,需要对相对简单的 PS/3 kboot 基础进行一些更改。引导程序需要四种类型的更改:根据 MI/MD 模式重构现有的 kboot,扩展对主机资源的访问支持,重构 UEFI 启动代码,使其能被 UEFI 加载程序(loader.efi)和 LinuxBoot 加载程序(loader.kboot)共用,并偿还引导加载程序中的技术债务。
修改 MI/MD
几个领域需要经典的 MI/MD 分离,其中通用的 MI 代码与实现共同 API 的每架构 MD 代码接口。Linux 在架构间的系统调用差异比 FreeBSD 大得多。程序启动在不同架构间需要稍微不同的汇编代码。需要不同的链接器脚本。引导程序的元数据虽然大致相似,但也存在架构差异。最后,从 Linux kexec 重启向内核的交接方式也不同。下面我将在“重构 UEFI 启动”部分进一步讨论这最后两点。
前三种更改是为了创建 Linux 二进制文件。为了创建静态二进制文件,我编写了 C 运行时支持,用于提供 Linux 内核交接和传统主例程之间的“粘合”。我写了一些架构相关的汇编代码,并与一个标准的启动例程结合,该例程调用 main
函数。为了实现这一点,我创建了一个标准的 C 接口来进行系统调用,这使得 FreeBSD 为 Linux 编写的迷你 libc 的 MD 部分非常简单。我还为系统调用添加了少量的架构相关汇编代码。我为 Linux 的每架构 ABI 差异创建了一个框架,其中最大的差异体现在 termios 接口上。这反映了 Linux 的二进制兼容性历史。架构相关的链接器脚本会生成一个 Linux ELF 二进制文件。这些元素结合起来,形成了 loader.kboot
和 Linux ELF 二进制文件。新的驱动程序 libsa (见下文)通过这个 libc 与之接口。
访问主机资源
原始的 loader.kboot
代码访问了一些主机资源,但并不完全。我希望能够从原始设备或通过存储在主机系统文件系统中的内核或引导加载程序进行引导。引导加载程序一直支持多种指定文件来源的方式,但在重构之前,增加新的方式非常困难。通过对现有代码进行的重构,我增加了用 Linux 名称访问所有块设备的功能。例如,“/dev/sda4:/boot/loader” 会读取位于 sda 磁盘第四分区上的 /boot/loader
文件。此外,“lsdev”现在会列出所有符合条件的 Linux 块设备。引导加载程序能够发现 zpool。例如,“zfs:zroot/kboot-example/boot/kernel”指定了要启动的内核。最后,将内核和/或引导加载程序直接放在 Linux initrd
中也很方便。引导加载程序本身使用此功能从 /sys
和 /proc
文件系统获取必要的数据。任何已挂载的文件系统都可以通过 “host:?path-to-file?” 访问。因此,你可以通过 “host:/freebsd/boot/kernel” 引导;还可使用 “more host:/proc/iomem” 查看 Linux 内存使用情况。引导加载程序还支持将前缀 “/sys/” 和 “/proc/” 映射到主机的文件系统 /sys
和 /proc
,无论活动设备如何。
loader.kboot
能替代 Linux initrd
中的 /sbin/init
。首个运行的程序是 init
,并且 init
必须做一些额外的步骤来准备系统。当 loader.kboot
作为 init
运行时,它会执行这些额外的步骤,包括:挂载所有初始文件系统(/dev
、/sys
、/proc
、/tmp
、/var
),创建一系列预期的符号链接,打开标准输入、标准输出和标准错误。引导加载程序可以在这种环境下运行,或作为从标准 Linux 启动脚本启动的进程运行。目前,loader.kboot
无法创建新进程来执行 Linux 命令。
重构 UEFI 启动
反映引导历史的 FreeBSD 启动过程与其内核一起共演化了 30 年(针对 amd64 架构),或约 20 年(针对 aarch64 架构)。当然,这两个架构并未始终存在,但 amd64 继承了许多 i386 的特性,而 aarch64 的引导虽然更为简洁,仍然是嵌入式 FreeBSD 系统 20 年的产物。为了在这个复杂的环境中成功引导,loader.kboot
需要重现这些特性。它遵循 UEFI 协议,创建与我们的 UEFI 引导加载程序 loader.efi
相同的元数据结构。
这些工作从 amd64 开始,因为它更容易进行实验。我选择模拟 UEFI + ACPI 启动环境。UEFI 是更新、更灵活的接口,似乎特殊情况更少。理论上,FreeBSD 内核可以从 UEFI 和 CSM 启动,而无需知道它是从哪个启动的,但实际情况有所不同。内核期望以某种方式获取 UEFI 派生的数据,而以略微不同的方式获取 BIOS 派生的数据。早先我就发现,尝试同时支持两者有碍进度,因为往往需要编写和调试两条不同的路径。既然 UEFI 会长期存在(即使在 LinuxBoot 中,只有 UEFI 的一小部分得以保留),而 CSM 可能会消失,我决定在 amd64 上仅支持 UEFI。
尽管这样简化了问题,进展依然缓慢,因为我不得不通过反复试验来找出 amd64 内核依赖于引导加载程序的各种问题。FreeBSD 开发者 Mark Johnston 建议我试试 aarch64,因为它的接口更简单。证明他是正确的。在我实现了从 UEFI 数据结构到 FreeBSD 引导加载程序元数据的基本转换后,aarch64 的引导进展就更顺利了。只出现了几个小问题,稍后我会讨论这些问题。我计划在 aarch64 工作后,再处理 amd64 的引导问题。
引导加载程序需要几百行代码来设置 UEFI 元数据。不出所料这些代码,假定在 UEFI 运行时环境中运行。它通过 UEFI API 分配内存,获取 UEFI 提供的内存信息,并以特定于 UEFI 的方式获取 ACPI 表。我需要重构这些代码,以便它能够从 Linux 的 /sys
和 /proc
文件系统中创建正确的元数据结构。此外,Linux 提供了 FDT 和 ACPI 数据,其中设备描述仅在 ACPI 中存在。这让 FreeBSD 错误地认为没有设备可用,因为当两者都存在时,FreeBSD 更倾向于使用 FDT 进行设备枚举。Linux 仅提供通过 FDT 执行另一次 kexec 所需的数据,而没有设备数据。
除了常规的 UEFI 数据结构和引导外,在 kexec 执行后,Linux 会将硬件置于一个略有不同的状态,而这个状态既非冷启动亦非热启动。系统并未完全恢复到重启状态。通常,这并不重要——我们可以从这个状态启动,来将硬件恢复到正确的状态。但是也遇到了一些麻烦。
我的第一个问题是 UEFI 引导服务问题。当 Linux 退出 UEFI 的“引导服务”时,它会创建内存映射,其中虚拟地址(VA)与物理地址(PA)不匹配。FreeBSD 的 loader.efi
总是创建 VA 与 PA 一一映射(即 PA = VA)。由于内存映射可能只能设置一次,FreeBSD 的内核必须使用 Linux 创建的映射。由于映射不是 VA = PA,内核会发生 panic。幸运的是,这些 panic 是由于 loader.efi
调试中遗留下来的限制性断言引起的。移除断言后,暴露了一个 bug,其中使用了 PA 而不是 VA,但在我修复这个 bug 后,内核顺利启动。
我遇到的第二个问题是 “gicv3” 问题,即“几乎重置”结合设备缺陷造成的麻烦。gicv3 中断路由器有设计缺陷:在启动后(Linux 在启动时完全启动了它),它无法在未完全系统重置和初始化的情况下停止(而 kexec 做不到这一点)。为了解决这个问题,FreeBSD 内核不得不重用这部分内存。Linux 通过 UEFI 系统表结构传递 gicv3 状态数据。此表包含了 gicv3 使用的物理地址列表。FreeBSD 解析此表,确保它与 gicv3 使用的地址匹配,并将其标记为保留,防止 FreeBSD 的内存分配代码分配给它们。在 QEMU 中一切都能正常工作,但当我们尝试在 aarch64 机器上运行时,我非常惊讶地发现了这个故障。幸运的是,Linux 社区早已发现了这个问题,还提供了一套修补程序,我可以用来以类似的方式修复 FreeBSD。
偿还技术债务
在这个项目中,我发现了一件并不令人意外的事情——引导加载程序中有大量复制粘贴的代码用于实现路径和设备名称解析。这些代码的副本在一些不明显的地方有所不同。有时这些变化是 bug 修复,但通过软件考古学发现,其他副本仍然保留着原有的 bug。有时,复制过程中还引入了新的 bug。可以理解,引导加载程序会有这样的命运。在移植到新平台时,通常会直接从工作良好的加载程序中复制代码,并对其进行一些小的调整适应新环境。很少有人考虑长期缺乏重构的影响。如果加载程序能够启动内核,为什么还要花更多时间在加载程序上呢?事实证明,这种策略和态度是有害的。例如,文件名解析代码曾在不同环境之间被复制,导致在我开始时大约有 10 个副本。所有这些副本都需要共同的常规程序——除了其中一个副本有合理的理由不同,因为它的设备规格与其他地方常用的“diskXpY:”格式不同。解析代码中的 bug 促使我将所有这些代码重构到一个地方(这样我仅需修复一次 bug)。这使得使用 “/dev/XXXX” 来访问原始设备时能够正常工作,也使得 “host” 前缀能够在无需单元号的情况下使用。现在,引导加载程序的文件名解析器比我之前知道的要灵活得多。
结论
将 FreeBSD 的引导加载程序和内核适配到这个新环境中,证明是相对直接的。对于 Linux 主机集成的大量小任务,再加上 FreeBSD 缺乏文档的引导加载程序到内核的交接,构成了项目阶段中的最大挑战。意外的硬件缺陷为这个过程增添了不少困难,并且导致需要对内核进行更大规模的更改。随着 FreeBSD/aarch64 在真实硬件上的成功引导,我们可以进入项目的下一阶段:创建我们自己的固件,寻找剩余的 FreeBSD/amd64 bug。哪怕有这些短板,我们已经使用 loader.kboot
下载了一个安装程序的 ramdisk 来配置系统,重新启动结果。它还被纳入了去年夏天的引导加载程序持续集成:谷歌编程之夏的项目中。
下一步
在下一篇文章中,我们将创建一个能够引导 FreeBSD 的 LinuxBoot 固件映像。我将解释如何打包固件映像,使用哪些工具来创建和操作它们,并且,如果你勇于尝试,我还将介绍如何重新刷写固件映像。我将帮助你从 Linux 提供的多种创建 initrd 工具中选择合适的工具,提供了示例脚本来找到并引导你的 FreeBSD 系统。如果运气好,到时候你将拥有更简单、快速和安全的固件。
Warner Losh 多年来一直在为 FreeBSD 项目做出贡献。他为引导加载程序贡献了许多特性和修复。他对 Unix 历史的兴趣扩展到了引导过程如何与 Unix 及其众多衍生系统共同发展。他和妻子 Lindy(热爱画猫狗)以及女儿(玩各种铜管乐器)住在科罗拉多州。他常常带着腊肠犬散步。
最后更新于