我一直很好奇编写自定义操作系统引导加载程序究竟有多难。我说的不是那种打印"Hello, World!"的简单程序,而是一个功能齐全的引导加载程序,它能将控制权从计算机固件转移到操作系统内核。现代引导加载程序是复杂的程序,能够以多种方式加载多个操作系统,并考虑到各种软硬件的细微差别。阅读它们的源代码时,很容易陷入细节,而忽略其本质和实现方式。
我决定从最简单的方法入手,通过实验和学习逐步增加任务的复杂性。如果我成功引起了你的兴趣,欢迎来到下一章。
介绍
解释如何编写引导加载程序并非易事。这是因为它涵盖了许多相关和不相关的主题,并且至少需要对以下方面有基本的了解:
- 计算机体系结构;
- 处理器运行模式;
- C语言和汇编语言的语法;
- C 语言中的指针(在这里你离不开它们);
- UEFI应用程序开发基础知识;
- 使用 RAM 进行工作组织;
- 函数调用约定;
- 作曲家的作品;
- 操作系统内核文件格式(bzImage、PE32+/COFF)。
当然,本文不可能全面涵盖所有这些主题,但我已尽力以浅显易懂的方式呈现,使对这些领域知之甚少的读者也能理解引导加载程序编写的基础知识,并尝试自行实现。我出于教学目的编写的asbootsap引导加载程序中包含一些信息。我相信可以编写出更安全可靠的代码。然而,我的目标是编写一个能够启动 Linux 且代码量尽可能少的引导加载程序。因此,我为该引导加载程序设定了以下限制:
- 它必须是适用于 x86_64 架构的 UEFI 应用程序。
- 它应该能够以 PE32+ 和 bzImage 格式启动适用于此架构的现代 Linux 内核。
- 它必须加载位于与引导加载程序本身相同的 ESP 分区上的内核和 initramfs,
- 它不应该支持安全启动。
不要被这些限制吓到,因为这个引导加载程序可以启动大多数流行的Linux发行版。如果你一开始被一些术语吓到,但又渴望理解它们,别担心------读完本文后,你会发现它们不再是一堆难以理解的符号。
我希望你喜欢根据我的示例自己重写的引导加载程序,并且在获得新知识后,引导加载程序的运行不会再让你觉得神奇。
如果你想在实践中测试所有内容,你需要安装一些 Linux 发行版。
我不会进一步讨论Linux内核的启动过程;本文的目标之一是解释如何将控制权从UEFI转移到操作系统内核。我将首先介绍如何在不使用GRUB等常用引导加载程序的情况下启动Linux。
UEFI基础知识:编写引导加载程序的基础知识
▍ UEFI
统一可扩展固件接口 (UEFI) 是由统一 EFI 论坛制定的规范,用于描述操作系统和计算机固件之间的接口。该规范对已开发应用程序的架构施加了一些限制。尽管 UEFI 与编程语言无关,但规范中对结构和功能的描述是用 C 语言编写的。
通常,规范都会有一个参考实现,UEFI 也不例外,其参考实现名为TianoCore EDK II。它是一个用于创建 UEFI 系统驱动程序和应用程序的开发环境。在虚拟机中开发和调试 UEFI 应用程序更加便捷,因此针对 Qemu 和 KVM 虚拟机,有一个专门的 UEFI固件实现------OVMF。
TianoCore EDK II 可能难以理解和掌握。对于开发更简单的 UEFI 应用程序,您可以使用轻量级的gnu-efi环境,本文将使用该环境。
UEFI规范文档篇幅很长,但我会概括其要点,以便您能够编写自己的引导加载程序。
UEFI 可以被视为一种面向对象的架构。它的核心概念包括协议 、协议接口 、句柄 、系统表 、启动服务和运行时服务。起初,我很难理解 UEFI 中的协议、协议接口和服务这些概念,因为这些术语的使用语境与通常略有不同。
▍ 关于 x86-64 架构处理器的运行模式
历史上,处理器启动时处于所谓的实模式(Real Mode)。此模式使用 16 位分段寻址,并且只能访问 1 MB 内存。它没有内存保护,不支持多任务处理,并且所有地址都是物理地址。要运行 32 位内核的操作系统,需要切换到保护模式 (Protected Mode) ,而对于 64 位内核,则需要切换到长模式(Long Mode),长模式可以避免这些缺点。我们无需了解如何切换到这些模式,因为 UEFI 会在将控制权移交给引导加载程序时自动完成。这方面的信息在使用旧的 16 位 BIOS 将控制权移交给引导加载程序时是必要的。使用 64 位 UEFI 时,处理器将处于长模式。
▍ UEFI 图像
UEFI 中的可执行代码存储在 PE32+/COFF 文件中。UEFI 映像有三种类型:
- UEFI应用程序
- UEFI操作系统加载器
- UEFI驱动程序。
本质上,UEFI 操作系统加载程序是 UEFI 应用程序的一个子集,其职责是在调用 ExitBootServices() 后将控制权移交给操作系统。我们不讨论 UEFI 驱动程序;它与 UEFI 应用程序的主要区别在于,它在从入口点返回后仍然驻留在内存中。
▍ 下载 UEFI 镜像并上传文件
UEFI 可以加载 UEFI 镜像或文件,区分这两者非常重要。使用*LoadImage()*函数加载 UEFI 镜像时,系统会自动分配 RAM、解析 PE 头部,并将镜像内容放置到相应的内存区域。
加载文件操作虽然更简单,但需要开发者付出更多努力。在这种情况下,必须手动为文件内容分配内存,然后使用文件函数从设备读取数据并将其放置在分配的内存地址。在本例中,设备指的是 ESP 分区上的文件系统。
UEFI启动阶段
之前,我用简单的术语解释了启动过程。下图是一个经典的示意图,展示了UEFI环境下的启动阶段。

UEFI启动阶段
我们只关注瞬态系统负载(TSL)阶段。该阶段将控制权从固件转移到操作系统。
▍ 操作系统加载器图像
操作系统加载映像是UEFI应用程序的一个子集,其目的是将控制权从UEFI移交给操作系统内核。通常,它位于磁盘的ESP分区上,但也可以位于计算机网络上,或者更特殊地,嵌入到主板芯片中。
如何运行操作系统加载器镜像?最简单的方法是将其命名为 BOOTX64.EFI,并将其放置在 ESP 分区上的一个目录中。在 UEFI 设置中选择从包含此分区的驱动器启动时,UEFI 将运行操作系统加载器镜像。第二种方法是使用 EFIShell,这是一个 UEFI 应用程序,它提供了一个类似于 bash 或其他类似 shell 的 UEFI 命令行界面。您也可以在 UEFI 启动管理器中指定引导加载程序信息。
▍ 系统表
UEFI 映像有一个入口点。该入口点是一个带有两个参数的函数:一个是指向 UEFI 应用程序本身的句柄,另一个是指向系统表的指针。系统表是一个重要的结构,UEFI 应用程序可以通过它与 UEFI 环境进行交互,它包含指向 I/O 接口、启动服务和运行时服务的指针。系统表被认为是 UEFI 层次结构的根。为了更清楚地说明这一点,我将提供 efiapi.h 文件中定义的系统表源代码。
`typedef struct _EFI_SYSTEM_TABLE {
EFI_TABLE_HEADER Hdr;
CHAR16 *FirmwareVendor;
UINT32 FirmwareRevision;
EFI_HANDLE ConsoleInHandle;
SIMPLE_INPUT_INTERFACE *ConIn;
EFI_HANDLE ConsoleOutHandle;
SIMPLE_TEXT_OUTPUT_INTERFACE *ConOut;
EFI_HANDLE StandardErrorHandle;
SIMPLE_TEXT_OUTPUT_INTERFACE *StdErr;
EFI_RUNTIME_SERVICES *RuntimeServices;
EFI_BOOT_SERVICES *BootServices;
UINTN NumberOfTableEntries;
EFI_CONFIGURATION_TABLE *ConfigurationTable;
} EFI_SYSTEM_TABLE;
`
▍ 服务
UEFI 中的服务与我们通常在 Web 应用程序编程或 Windows 系统编程中使用的服务略有不同。UEFI 服务是执行系统任务的函数,例如通过受支持的协议标识符查找对象的句柄或分配/释放内存。
启动服务(在 TSL 阶段可用)和运行时服务(在 TSL 阶段及其完成后均可使用)是 UEFI 基础架构的关键组件,它们提供对固件功能的访问,并支持驱动程序和应用程序的运行。
我的引导加载程序中没有使用运行时服务,但读者可以根据需要尝试使用,例如,将当前时间显示添加到引导加载程序中。
启动服务和运行时服务仅包含基本的 UEFI 功能;其余功能位于应用程序和驱动程序中,可通过句柄/协议/协议接口机制访问。这正是UEFI 具有可扩展性的原因所在。
▍句柄、协议和协议接口
在 UEFI 中,我们可以与之交互的实体都具有一个称为句柄 (Handle) 的唯一标识符。句柄可以通过其协议 ID 在 UEFI 环境中找到,方法是使用启动服务函数*`LocateHandle`* 或*`LocateHandleBuffer`* 。要更改实体的状态,必须使用 ` HandleProtocol` 或*`OpenProtocol`*函数查询其接口。每个 UEFI 协议都有一个唯一的标识符 (GUID) 和一个协议接口结构定义。查询接口时,您将收到一个协议接口结构的实例,其中包含数据和函数指针。您可以通过修改这些数据并调用函数来操作 UEFI 对象的状态。
▍ Memory Map
操作系统的引导加载程序或内核必须知道 RAM 地址的分配方式,哪些内存可用,哪些内存不可访问。这些信息存储在一个名为*"内存映射表"* 的结构中。要获取内存映射表,对于 UEFI,必须调用 GetMemoryMap 系统函数;对于 BIOS,则必须使用 AX=0xE820(查询系统地址映射表)触发 INT 0x15(系统 BIOS 服务)软件中断。使用 BIOS 中断获取的内存映射表称为E820 内存映射表。我们需要 E820 内存映射表来启动 Linux,但我们会通过转换 EFI 内存映射表来获取它,而无需触发 BIOS 中断。
加载操作系统
关于操作系统启动过程,书籍、文章和网络上已经有很多相关的资料和视频,但我只想重点介绍创建引导加载程序所需的最重要要素。引导加载程序的创建过程会因计算机架构、主板固件和操作系统的不同而略有差异。因此,本文将以 x86-64 架构、UEFI 主板固件和 Linux 操作系统为例进行说明。
- UEFI 初始化并检查硬件(处理器、RAM 等)。
- UEFI 会在连接的设备(例如 SSD 或 HDD)上查找操作系统引导加载程序。这通常是 ESP 分区中的一个文件。默认情况下,64 位系统的引导加载程序路径是标准化的:64 位系统使用 .EFI 文件。您可以将其放置在其他路径,但这需要在 UEFI 中写入启动项。
- 找到引导加载程序后,UEFI 会将控制权移交给它。引导加载程序会准备系统以加载操作系统内核。
- 然后,控制权从引导加载程序转移到操作系统内核,内核继续加载。
引导加载程序是 UEFI 和操作系统之间的中间链接,由于其代码在操作系统加载之前执行,因此它不能使用操作系统的常规功能,其源代码的编译和调试方式也与操作系统不同。
虽然可以不安装它来启动操作系统,但这灵活性较差,并且存在一些特殊之处。
不使用引导加载程序启动 Linux
您或许会惊讶地发现,在 UEFI 系统上无需使用引导加载程序即可启动 Linux。没错,如果 Linux 内核编译时启用了 CONFIG_EFI_STUB 参数,这确实可行。好消息是:现代内核通常都启用了此参数。
对于当前的 Linux 内核,您可以使用以下命令查看此参数是否已启用:
`cat /boot/config-$(uname -r) | grep CONFIG_EFI_STUB
`
启动 Linux 需要以下组件:
- Linux内核文件。
- 包含初始根文件系统映像的文件。
- 根文件系统。
- 内核命令行参数。
我知道有四种无需引导加载程序即可启动 Linux 的方法:
- 使用 UEFI 交互式 Shell 命令行。
- 使用 startup.nsh 文件。
- 在 UEFI 交互式 Shell 中使用 bcfg 命令向 UEFI NVRAM 添加启动项。
- 在 Linux 中使用 efibootmgr 命令向 UEFI NVRAM 添加启动项。
▍ 使用 UEFI 交互式 Shell 命令行
`\vmlinuz initrd=/initrd.img root=UUID=7023590e-426d-4087-bb7d-4bc6fd1bbc7a quiet splash
`

▍ 使用 startup.nsh 文件
此文件可以包含第一种方法中使用的 Linux 启动命令。无需持续输入,每次计算机启动时都会执行此命令。该文件必须采用 ASCII 编码。
▍ 使用 UEFI 交互式 Shell bcfg 命令向 UEFI NVRAM 添加启动项
-
让我们进入 ESP 分区的根目录:
fs0:\ -
我们显示启动记录:
bcfg boot dump -
添加引导记录:
bcfg boot add 2 fs0:\vmlinuz "My Linux" -
用于传递 bcfg 的命令行参数必须位于一个文件中,因此我们创建 options.txt 文件:
edit options.txt -
我们输入文件内容,按 F2 保存,再按 F3 保存:
initrd=/initrd.img root=UUID=7023590e-426d-4087-bb7d-4bc6fd1bbc7a quiet splash -
将命令行内容添加到引导记录中:
bcfg boot -opt 2 fs0:\options.txt -
如果我们想要删除引导记录:
bcfg boot rm 2
▍ 使用 Linux efibootmgr 命令向 UEFI NVRAM 添加启动项
efibootmgr 允许您在 UEFI NVRAM 中添加和管理启动项,从而允许您在没有引导加载程序的情况下启动 Linux。
`sudo efibootmgr -c -d /dev/sda -p 1 -L "My Linux" -l '\vmlinuz' -u 'root=UUID=7023590e-426d-4087-bb7d-4bc6fd1bbc7a initrd=\initrd.img quiet splash'
`
efibootmgr 在磁盘 /dev/sda 的第一个分区上创建一个名为 My Linux 的启动项。在上面的示例中,UEFI 加载位于分区根文件系统中的 vmlinuz 文件。内核接收以下参数:initrd、root 和 quiet splash。initrd 参数指定初始根文件系统文件的位置。root 参数指定根文件系统的位置。根文件系统的位置可以通过多种方式指定:例如,指定其所在分区的块设备名称(/dev/sda1),或者指定分区或文件系统标识符(这是首选方法)。在本例中,使用文件系统标识符。可以使用以下命令查找位于分区上的文件系统标识符:
`blkid -s UUID -o value /dev/nvme0n1p1
`
可以使用以下命令查找现有章节的名称:
`lsblk -o name -lpn
`
可以省略 quiet 和 splash 参数,但如果要尽量减少加载内核时的消息输出,则需要这些参数。
▍ 不使用引导加载程序启动 Linux 的特性和局限性
注意:
- 许多 UEFI 固件版本可能缺少 UEFI 交互式 Shell。在这种情况下,您需要从网站下载 UEFI Shell ,将其重命名为 BOOTX64.EFI,并将其放置在 ESP 分区上的某个目录中。
- 写入 UEFI NVRAM 是一项潜在风险的操作。您必须清楚地了解自己在做什么以及为什么要这样做。驱动程序或命令实现错误也可能损坏主板上 SPI 芯片的内容。此外,频繁写入芯片会加速其损耗。
- 只有当 Linux 内核使用特定选项编译时,才能在没有引导加载程序的情况下启动系统。大多数现代内核都使用了这些选项进行编译,因此您不太可能遇到任何问题。
既然我们已经尝试过在没有引导加载程序的情况下启动 Linux,那么接下来让我们编写自己的引导加载程序。
Linux操作系统引导加载程序
Linux 操作系统引导加载程序的作用是什么?它会根据指定的设置,找到可引导的内核和相应的初始文件系统映像文件(initramfs/initrd),将它们加载到 RAM 中,然后启动内核,并将包括根文件系统信息和其他设置在内的命令行参数传递给内核。
如何启动引导加载程序?在 UEFI 系统中,引导加载程序是一个 UEFI 应用程序。您可以按以下步骤启动引导加载程序:
- 通过从UEFI启动交互式Shell,
- 通过将引导加载程序写入 NVRAM 中的引导记录来实现。
- 将其重命名为 BOOTX64 并将其放置在 ESP 分区上。
将控制权从引导加载程序转移到操作系统内核的规则称为引导协议。
▍ x86-64架构的Linux内核文件格式
Linux 内核是一个 ELF 文件,但 x86-64 架构上的引导加载程序无法处理这种格式(至少我还没遇到过)。x86-64 架构上的内核被打包成 bzImage 文件。bzImage 文件的内容在内核编译期间确定。通常情况下,这些内容包括:
- 内核头文件
- 实模式下的内核启动代码
- 内核解包代码
- 压缩核心。
大多数现代内核都有 EFIStub,这是一种特殊代码,允许 UEFI 将 Linux 内核视为 UEFI 应用程序。
对我们来说,以下几点至关重要:
- 内核头描述文件位于哪里?
- 内核运行的入口点在哪里?
内核头文件占用不超过两个扇区(1024 字节),其大小可能因编译期间指定的内核配置而异。例如,如果内核编译时使用了 CONFIG_EFI_STUB 选项,则内核头文件将同时包含 PE 头和 COFF 头。
bzImage 格式的 Linux 内核有多个入口点:
- 对于实模式,
- 对于保护模式,
- 对于长时间模式,
- 用于 EFI 交接,
- 适用于 UEFI 固件 efi_pe_entry。
我们只对最后三个感兴趣。
操作系统启动协议
长期以来,我一直认为 Linux 使用多重启动协议 (Multiboot Protocol)来启动,但事实并非如此。在 x86-64 架构上,通常使用的是 Linux 启动协议 (Linux Boot Protocol)。介绍 Linux 启动协议的文章是深入了解启动机制的起点。
文章中描述的一些内容对我来说仍然是个谜,仅凭文章中的信息编写引导加载程序几乎是不可能的。尽管如此,这篇文章包含了大量关于在传统 BIOS 系统上启动的信息,这对理解这个主题大有裨益。我将介绍64 位 Linux 启动协议 、EFI 交接 ,以及虽然文章中没有提及,但编程最简单的协议------链式加载 (Chainload )。
▍ 链式负载
该协议的核心在于,现代 Linux 内核是有效的 UEFI 应用程序。这使得引导加载程序能够使用 UEFI 提供的 API 加载并执行应用程序的 UEFI 映像。initramfs 并非由引导加载程序加载到 RAM 中,而是由 Linux 内核本身加载,要使用的 initramfs 则通过 Linux 命令行参数指定。只有支持 EFIStub 的内核才能使用此协议加载。
▍ EFI交接
EFI交接协议要求引导加载程序在解析bzImage 格式的Linux内核文件头后,将内核和initramfs的内容放入RAM中。为了将控制权移交给Linux,引导加载程序必须调用文件头中handover_offset字段指定的函数。对于x86-64架构,该函数的绝对地址计算如下:
`handover_function_address = kernel_loading_address + handover_offset + 512
`
在哪里:
- kernel_loading_address --- 引导加载程序加载 Linux 内核的地址,
- hadover_offset --- 此文件中通过 EFI 交接协议启动的入口点的偏移量,
- 512 - 如果使用 x86-64 架构,则需额外偏移 512 字节。
该函数需要三个参数:
- EFI应用程序描述符
- 指向 EFI 系统表的指针
- 指向已填充的boot_params结构的指针。
EFI交接协议虽然被认为已经过时,但我还是把它列入其中,因为我认为它值得考虑。
▍ 64 位 Linux 启动协议
在讨论的三种启动协议中,64 位 Linux 启动协议最为复杂。引导加载程序不仅要将内核和 initramfs 放入 RAM 并填充boot_params结构,还必须执行其他步骤:
- 配置帧缓冲区。
- 从UEFI内存映射 准备E820内存映射。
- 准备有关 UEFI 环境的信息。
- 调用*ExitBootServices()*退出 UEFI 预启动环境。
- 调用一个函数,该函数在 x86-64 架构上的地址计算如下:
`boot64_function_address = kernel_loading_address + 512
`
编写 Linux 引导加载程序
编写引导加载程序涉及开发一个在 UEFI 环境中运行的程序,该程序使用 UEFI 服务和协议,并实现操作系统所需的内核引导协议(不要与 UEFI 协议混淆)。
从零开始,仅根据规范开发一个引导加载程序或许可行,但参考现有开源引导加载程序的代码会更容易一些。代码能更好地解释规范,因为它消除了歧义。因此,我简要地研究了我所了解的引导加载程序的代码。
我的代码基于 EfiLinux(一个老旧的引导加载程序示例)。它支持通过 EFI 交接和 Linux 启动协议(适用于较旧的 Linux 内核)启动。我修改了它的代码,使其能够通过 Linux 启动协议启动较新的内核,但未能成功。这引起了我的兴趣,于是我编写了自己的引导加载程序,解决了这个问题。Limine 的源代码对我帮助很大(它让我确信使用 Linux 启动协议的 UEFI 引导加载程序是可行的),rEFInd 的源代码也给了我很大的帮助(我弄清楚了如何在通过 Chainload 启动时传递内核参数)。
我采用迭代的方式开发了引导加载程序,但本文仅展示了最终成果。
我最初编写的简易引导加载程序(不支持配置文件)的第一个版本,虽然能神奇地在我的笔记本电脑上启动 Debian 系统,但现在已不再提供,因为它不够友好,也不够美观。这稍微增加了代码的复杂性,但该引导加载程序现在不仅可以用于教学目的,还可以用于启动真正的 Linux 发行版,尽管存在一些限制。
我的引导加载程序支持的参数包括:
- 选定的Linux启动协议,
- Linux内核文件在ESP分区文件系统中的位置
- initramfs 文件在 ESP 分区文件系统中的位置
- Linux内核命令行参数。
我的 Linux 引导加载程序是如何工作的
引导加载程序的基本算法如下:
- 将控制权移交给引导加载程序后,它会查找并解析包含引导参数的配置文件。
- 如果未指定 Linux 启动协议,则会向用户显示一个菜单,用户可以在其中选择启动协议。
- 根据所选的启动协议,将执行以下算法之一。
▍ 各种启动协议的引导加载程序算法
▍ EFI 链式负载
- 确定加载引导加载程序映像的设备路径(在本例中,该设备将是 ESP 分区上的文件系统)。
- 包含 EFIStub 的 Linux 内核以 UEFI 应用程序映像的形式加载到内存中。
- Linux 命令行选项会被转换为 LoadOptions。(initramfs 的路径由 initrd 命令行选项指定。)
- Linux 内核以 UEFI 应用程序映像的形式启动。(Linux 内核命令行参数存储在 LoadOptions 中。)
▍ EFI交接
- 确定加载引导加载程序映像的设备路径。
- 读取核心文件中的头部信息。
- 分配内存并将操作系统内核写入其中(不包括引导扇区和实模式下的引导代码)。
- 内存被分配,initramfs 文件被写入其中。
- 命令行所需的内存已分配并填充完毕。
- 确定EFI交接的入口点。
- 通过调用入口点,控制权转移到内核。
▍ 64 位 Linux 启动协议
- 确定加载引导加载程序映像的设备路径。
- 读取核心文件中的头部信息。
- 分配内存并将操作系统内核写入其中(不包括引导扇区和实模式下的引导代码)。
- 命令行所需的内存已分配并填充完毕。
- 生成有关正在使用的帧缓冲区的信息。
- 从 UEFI 内存映射中,得到 E820 格式的内存映射。
- 正在生成有关 UEFI 环境的信息。
- 计算 64 位 Linux 启动协议的入口点。
- GDT 和段寄存器已配置(即使没有这一步也能启动,但不知何故大多数现有的引导加载程序都会这样做,所以我还是做了。如果有人能解释一下为什么要进行此配置,我将不胜感激)。
- 通过调用入口点,控制权转移到内核。
▍ 关于引导加载程序操作的说明
以上是引导加载程序运行的粗略概述,忽略了边界条件和错误处理。为了使代码更易于阅读(留给感兴趣的读者自行探索),我在代码中考虑了一些错误和边界条件,而忽略了其他一些。尽管算法很简单,但每一步都需要对 UEFI 和 C 或汇编语言编程有一定的了解。如果您对 C 语言如何处理内存和指针运算有基本的了解,那么代码会更容易理解。
使用 GNU-EFI 构建 EFI 应用程序
使用 gnu-efi 构建 EFI 应用程序并不难,但确实需要一些相关知识。
▍ 通话协议
我们已经讨论过UEFI应用程序和UEFI固件如何交互。但是,我们还没有考虑所谓的函数调用约定。
调用约定定义了如何向函数传递参数、如何返回结果、谁负责清理堆栈以及如何使用处理器寄存器。这些规则确保了调用者和被调用函数之间的兼容性,尤其是在它们使用不同的编程语言编写或使用不同的编译器编译的情况下。如果您尝试用汇编语言编写函数并从 C 代码中调用它,那么了解函数的调用约定可能非常重要,但这并非本文的重点。
历史上,不同架构上的不同操作系统使用不同的调用约定。这些调用约定是应用程序二进制接口 (ABI) 的一部分。UEFI 虽然不是操作系统,但也对调用约定有要求。UEFI 针对不同的指令集架构 (ISA) 使用不同的调用约定,例如,对于 x86-64,它使用微软的 64 位调用约定。
调用约定对于编译器正确生成调用函数的目标代码至关重要。有趣的是,UEFI 应用程序内部的函数可以采用任何调用约定。只有当 UEFI 应用程序函数被 UEFI 固件调用,或者 UEFI 应用程序调用 UEFI 固件函数时,遵循调用约定才显得重要。
了解函数调用约定对我们来说很重要,因为我们使用的是 x86-64 架构的 gcc 编译器,它默认使用的约定与 Microsoft 的 64 位调用约定不同。
▍ 应用开发阶段
- 预处理(*.h 头文件的预处理)。
- 编译(获取 *.o 目标文件)。
- 链接(生成静态/共享库或可执行文件)。
使用 gnu-efi 创建 EFI 应用程序时,需要额外添加一个步骤:将共享库转换为 .efi 文件。使用 gcc 编译器时确实如此;clang 允许跳过此步骤,但我出于教学目的使用了 gcc。
开发 EFI 应用程序与开发传统用户应用程序的不同之处在于,EFI 应用程序并非在操作系统内部运行,而是在 EFI 环境中运行。例如:
- 它不使用标准 C 库 (glibc),因为后者使用操作系统系统调用,而这些调用在 EFI 环境中是不可用的。
- 调试 EFI 应用程序需要额外的设置和工具。
- 我们可以访问计算机的所有物理内存,因此在使用时需要更加小心。
- 该应用程序以单任务模式运行,仅使用一个处理器核心。
▍ 布局和布局脚本
链接是将多个目标文件合并成一个库文件或可执行文件的过程。通常情况下,链接有标准的规则,但使用 gnu-efi 时,需要使用 gnu-efi 提供的自定义链接脚本。这里就不赘述细节了;你只需要知道链接时需要使用这个文件即可。
▍ gnu-efi 软件包的内容
gnu-efi 软件包包含各种文件,以下是我们需要了解的文件:
- 头文件(*.h,用于操作 UEFI API 和辅助函数)
- libefi.a --- UEFI API 实现,
- libgnuefi.a --- 辅助函数的实现,
- crt0-efi-x86_64.o --- EFI 应用程序启动代码,
- elf_x86_64_efi.lds --- 自定义链接器脚本。
▍ 库文件、链接脚本和 gnu-efi 头文件的位置
gnu-efi 软件包不包含 *.pc 文件,这意味着 pkg-config 工具不支持它。因此,需要显式地向编译器和链接器指定头文件和库文件的位置。在我的发行版中,它们分别位于 /usr/include/efi 和 /usr/lib 目录下。
▍ 使用 gcc 和 gnu-efi 获取 EFI 应用程序的算法
- 如果缺少所需的开发软件包,请安装它们。
- 准备应用程序的源代码。
- 将源代码编译成目标代码。
- 使用自定义链接脚本将目标文件、gnu-efi 库和 EFI 应用程序启动代码链接到共享库中。
- 使用 objcopy 命令将生成的库转换为 EFI 格式。
引导加载程序实现
好了,最后,我已经准备了足够的材料,你们可以查看我的引导加载程序的源代码了。
- main.c --- 包含 UEFI 应用程序的入口点。
- bootloader.c --- 将控制权移交给交互式选择或在配置文件中指定的启动协议。
- chainload.c - 包含使用 chainload 协议进行加载的特定函数。
- efihandover.c - 包含使用 EFI Handover 协议启动的特定功能。
- linuxboot64.c - 包含使用 64 位 Linux 启动协议启动的特定函数。
- common.c --- 包含 EFI 交接协议和 64 位 Linux 启动协议共有的函数。
- gdtutils.asm - 包含用于配置 GDT 的代码。
- memory.c - 包含简化内存操作的函数。
- configparser.c --- 包含用于解析配置文件的函数。
- filesystems.c - 包含简化文件操作的函数。
- debugutils.c - 包含简化调试的函数。
我觉得没有必要详细查看源代码文件,因为你可以自己查看和研究它们。
正在构建引导加载程序
- 选择编译器。在本例中,我们选择 gcc 和 nasm 汇编器。
- 选择一个链接器。在本例中,选择 ld。
- 选择一个实用程序将共享库转换为 .efi 文件。在本例中,我们使用 objcopy。
- 确定gcc和nasm的命令行参数。
- 确定 ld 的命令行参数。
- 确定 objcopy 的命令行参数。
- 按顺序调用编译器、链接器和 objcopy,并将一个程序的输出传递给另一个程序。(这大概就是为什么这些程序统称为工具链的原因。)
接下来,我们来看看需要传递给每个程序的参数。我已经附上了 Makefile 的内容。如果您不熟悉 Makefile 和 make 工具,
`BUILD_DIR := ./build
SRC_DIRS := ./src
LIBDIR := /usr/lib
ARCH := x86_64
TARGET_NAME := asbootsap
OBJCOPY := objcopy
CC := gcc
LD := ld
FORMAT := efi-app-x86-64
SECTIONS := .text .sdata .data .dynamic .dynsym .rel .rela .reloc
CRT0 := $(shell find $(LIBDIR) -name crt0-efi-$(ARCH).o 2>/dev/null | tail -n1)
LDSCRIPT := $(shell find $(LIBDIR) -name elf_$(ARCH)_efi.lds 2>/dev/null | tail -n1)
# Note the single quotes around the * expressions. The shell will incorrectly expand these otherwise,
# but we want to send the * directly to the find command.
SRCS := $(shell find $(SRC_DIRS) -name '*.c')
SRCS_ASM := $(shell find $(SRC_DIRS) -name '*.asm')
OBJS_ASM := $(patsubst ./src/%.asm,$(BUILD_DIR)/%.o,$(SRCS_ASM))
OBJS := $(patsubst ./src/%.c,$(BUILD_DIR)/%.o,$(SRCS))
.PRECIOUS: $(OBJS)
.PRECIOUS: $(OBJS_ASM)
.PRECIOUS: $(BUILD_DIR)/%.so
# String substitution (suffix version without %).
DEPS := $(OBJS:.o=.d)
INC_DIRS := $(shell find $(SRC_DIRS) -type d)
GNU_EFI_DIRS := /usr/include/efi /usr/include/efi/$(ARCH)
INC_DIRS := $(INC_DIRS) $(GNU_EFI_DIRS)
CPPFLAGS := $(addprefix -I,$(INC_DIRS)) \
-MMD \
-MP
CFLAGS := -fshort-wchar \
-DGNU_EFI_USE_MS_ABI \
-ffreestanding \
-mno-red-zone \
-Wall \
-Werror \
-fPIC \
-O2
LDFLAGS=-T $(LDSCRIPT) \
-Bsymbolic \
-shared \
-nostdlib \
-znocombreloc \
-L$(LIBDIR) \
$(CRT0)
all: $(BUILD_DIR)/$(TARGET_NAME).efi
mkdir -p ./build
deploy: all
mkdir -p ./esp/efi/boot
cp $(BUILD_DIR)/$(TARGET_NAME).efi ./esp/efi/boot/BOOTX64.EFI
start: all deploy
./start-qemu.sh
$(BUILD_DIR)/%.efi: $(BUILD_DIR)/%.so
$(OBJCOPY) $(foreach sec,$(SECTIONS),-j $(sec)) --target=$(FORMAT) -S $< $@
$(BUILD_DIR)/%.so: $(OBJS) $(OBJS_ASM)
$(LD) $(LDFLAGS) -o $@ $^ -lgnuefi -lefi
# Build step for C source
$(BUILD_DIR)/%.o: $(SRC_DIRS)/%.c
mkdir -p $(dir $@)
$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
$(BUILD_DIR)/%.o: $(SRC_DIRS)/%.asm
mkdir -p $(dir $@)
nasm -g -f elf64 -l $@.lst $< -o $@
.PHONY: clean
clean:
rm -r $(BUILD_DIR)
-include $(DEPS)
`
▍ 编译设置:
-
生成 C 语言和汇编语言的源文件列表:
SRCS := $(shell find $(SRC*DIRS) -name '*.c') SRCS_ASM := $(shell find $(SRC_DIRS) -name '*.asm') -
生成对象文件列表:
OBJS_ASM := $(patsubst ./src/%.asm,$(BUILD_DIR)/%.o,$(SRCS_ASM)) OBJS := $(patsubst ./src/%.c,$(BUILD_DIR)/%.o,$(SRCS)) -
生成包含头文件的目录列表:
INC_DIRS := $(shell find $(SRC_DIRS) -type d) GNU_EFI_DIRS := /usr/include/efi /usr/include/efi/$(ARCH) INC_DIRS := $(INC_DIRS) $(GNU_EFI_DIRS) -
为 C 预处理器生成参数列表:
CPPFLAGS := $(addprefix -I,$(INC_DIRS)) \ -MMD \ -MP -
为 C 编译器生成参数列表:
CFLAGS := -fshort-wchar \ -DGNU_EFI_USE_MS_ABI \ -ffreestanding \ -mno-red-zone \ -Wall \ -Werror \ -fPIC \ -O2 -
编译 C 和汇编文件的规则:
$(BUILD_DIR)/%.o: $(SRC_DIRS)/%.c $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@ $(BUILD_DIR)/%.o: $(SRC_DIRS)/%.asm nasm -g -f elf64 -l $@.lst $< -o $@
▍ 配置链接器:
-
库文件、UEFI应用程序引导代码和自定义构建脚本的位置:
LIBDIR := /usr/lib CRT0 := $(shell find $(LIBDIR) -name crt0-efi-$(ARCH).o 2>/dev/null | tail -n1) LDSCRIPT := $(shell find $(LIBDIR) -name elf_$(ARCH)\_efi.lds 2>/dev/null | tail -n1) -
生成链接器的参数列表:
LDFLAGS=-T $(LDSCRIPT) \ -Bsymbolic \ -shared \ -nostdlib \ -znocombreloc \ -L$(LIBDIR) \ $(CRT0) -
链接器规则:
$(BUILD_DIR)/%.so: $(OBJS) $(OBJS_ASM) $(LD) $(LDFLAGS) -o $@ $^ -lgnuefi -lefi
▍ 设置 objcopy
-
选择可执行文件格式。在本例中,应为 EFI 映像 (PE32+/COFF)。
FORMAT := efi-app-x86-64 -
生成需要复制到可执行文件的分区列表:
SECTIONS := .text .sdata .data .dynamic .dynsym .rel .rela .reloc -
objcopy 规则:
$(OBJCOPY) $(foreach sec,$(SECTIONS),-j $(sec)) --target=$(FORMAT) -S $< $@
结论
很遗憾,这篇文章无法完全展现看到你的代码启动 Linux 时的那种感觉,但我希望你已经按照步骤操作,并且成功运行了引导加载程序。或者至少编译了我的引导加载程序,并尝试进行了一些小的修改。引导加载程序存在一些缺点:
- 我简化了bzImage文件头的处理过程,
- 错误处理非常有限。
- 我主要使用软件栈而非堆来简化内存管理。此外,它也全面清晰地展示了引导加载程序的编写原理,能够加载现代 Linux 内核,并且可以为编写更复杂、更适合实际应用的引导加载程序提供灵感。
