资讯动态

一个UEFI引导程序的实现:从BIOS到64位内核的实战解析

发布时间:2026/10/25 12:25:10 来源:尧图企业网站定制
简介《一个UEFI引导程序的实现》是一本面向操作系统开发者和爱好者的技术PDF系统讲解如何从零构建UEFI引导程序。书中从UEFI与BIOS的结构、启动流程对比入手说明UEFI在性能、扩展性与安全上的优势并逐步展开TianoCore EDK2环境搭建、GPT分区布局、ESP文件系统、UEFI镜像与系统表、调用约定等重点内容。随后以实例指导读者实现显示模式配置、物理内存获取、存储介质文件读取、64位平台处理和系统内核引导启动形成完整的UEFI引导程序编写路径。压缩包内为单个PDF文件大小2.36MB适合具备一定操作系统基础、希望深入理解固件启动机制或为学习Linux内核源码做好铺垫的读者。目前已有2379人学习下载是配套《一个64位操作系统的设计与实现》进行实践升级的实用参考资料。1. 一个 UEFI 引导程序从 BIOS 遗产到自研内核的第一道门UEFI 引导程序是操作系统接管硬件前的最后一段固件逻辑。很多从零写内核的开发者卡住的地方不是内核代码本身而是拿不到内存图、读不出分区文件、跳进 64 位入口就白屏。《一个UEFI引导程序的实现》把这条链路从头串到尾。它先讲清 BIOS 与 UEFI 在结构和启动流程上的差异再带你在 EDK2 里编译并运行 UEFI Shell随后逐步配置显示模式、获取物理内存、从存储介质读取内核文件最终把控制权交给 64 位内核。做操作系统实验或想给固件、引导器加 UEFI 支持的人都可以拿它当主线来跟。2. UEFI 与 BIOS结构、启动流程和取代原因2.1 结构组成中断服务与数据表的两条路线BIOS 是一组固化在主板 ROM 里的程序代码。上电后它运行在实模式数据位宽 16 位物理寻址范围只有 0~1MB其中 0x0C0000~0x0FFFFF 保留给 BIOS 自身使用。它的能力通过中断服务暴露出来应用程序要读磁盘、操作串口直接发 INT 指令就行硬件差异都由 BIOS 在中断里抹平。BIOS 的主体由四块组成加电自检 POST、系统初始化与中断向量创建、即插即用设备适配、CMOS 设置存取。UEFI 的接口形态完全不同。它本质上是平台固件提供给系统的一组数据表包含平台相关信息、引导服务和运行时服务。设备访问不再靠中断号而是用「句柄 Handle 协议 Protocol」抽象一个设备对应一个 HandleHandle 上挂着若干 Protocol应用程序拿到协议接口再调函数。对比项BIOSUEFI运行模式16 位实模式32 位保护模式或 IA-32e 长模式设备服务方式INT 中断调用引导服务 / 运行时服务磁盘分区MBR2TB 上限GPT支持大容量盘驱动方式静态链接、固定 ROM 地址模块化驱动可动态加载可执行格式实模式代码PE/COFF 格式镜像安全机制无Secure Boot 证书校验这套设计决定了后续写引导程序的姿势。你不需要像 BIOS 时代那样拼实模式汇编也不需要维护中断向量表而是用 C 语言直接调协议函数。2.2 启动流程BIOS 的 8 步操作与 UEFI 的 7 个阶段BIOS 的启动流程几乎固定是 8 步。CPU 上电后跳到物理地址 0xFFFF0那里通常是一条跳转指令进入真正的 BIOS 入口接下来做 POST检测关键设备和 CMOS 配置然后初始化显示设备并打印显卡信息再检测 CPU、内存接着检测硬盘、光驱、串并口等标准设备随后检测即插即用设备并为它们分配中断号、I/O 端口和 DMA 通道硬件配置有变化就写回 CMOS最后按启动顺序读取设备引导程序把控制权交给 MBR。UEFI 的启动过程被拆成 7 个阶段职责划分非常清晰SEC验证此时内存尚未初始化拿处理器缓存或一部分物理内存做临时存储同时把 CPU 切换到保护模式。PEIEFI 预初始化初始化 CPU、内存、芯片组确定引导路径代码要尽量精简。DXE驱动运行环境加载大部分驱动和固件模块UEFI 的大部分功能在这里就绪。BDS启动设备选择初始化控制台按环境变量里的启动项和启动设备找操作系统加载器。TSL临时系统运行UEFI Shell 和操作系统加载器都运行在这个阶段系统还没交出控制权。RT运行时操作系统调用 ExitBootServices 后进入引导服务销毁只剩运行时服务和系统表。AL后世调用 ResetSystem 或 ACPI 睡眠状态时进入服务器和工作站里常见。这张阶段图对引导程序的意义在于你自己写的 UEFI 应用基本都跑在 TSL 阶段。此时引导服务还在可以随便用内存分配、协议查询这些接口一旦调用了 ExitBootServices自己就是唯一的主角不能再依赖固件帮忙。2.3 为什么 UEFI 取代 BIOS性能、扩展性、安全性与 2TB 上限BIOS 被取代并不是因为它老而是它已经扛不住现代硬件的几个硬性要求。BIOS 代码用汇编写跟具体芯片耦合太深加一个设备就要改 ROM 代码、重新编译开发效率极低所有外设操作走中断开销大且没有异步机制大量时间浪费在等待上。安全方面更是空白引导阶段没有任何可信校验。还有个工程上的硬限制BIOS 磁盘寻址用 32 位 LBA最大逻辑块地址是 2 的 32 次方乘上 512 字节扇区就是 2TB超过这个容量的磁盘根本无法从 BIOS 引导。UEFI 对应地给出了解决方案。绝大多数固件代码改成 C 语言维护上层应用甚至可以用 C驱动按模块独立加载随用随调不依赖固定地址外设操作改成「事件 异步」只保留时钟中断启动时只遍历必须的启动设备Secure Boot 打开后固件会校验驱动和应用镜像的数字签名签名可信才给执行。UEFI 还引入了 ESP 系统分区和 GPT 布局为引导程序提供了标准化的文件存放位置。实际写引导程序时这些优点会直接转化为开发体验。驱动按模块加载意味着你可以根据需要 OpenProtocol 拿串口、拿 GOP、拿文件系统而不必在代码里硬编码设备地址表和协议都有版本号接口升级有据可查编译期和运行期都能校验版本是否匹配。在现在这个时间点还从 MBR 引导新写的 64 位内核等于主动放弃大容量磁盘、放弃安全启动、放弃现代化驱动管理。3. 搭建 EDK2 环境编译 UEFI Shell 并让 U 盘启动它3.1 依赖、分支与 target.txt 三个关键配置TianoCore EDK2 是 UEFI 开发的常用环境自带一套编译系统支持 GCC、MinGW、MSVC 等编译器既能编 UEFI 应用也能编 UEFI 固件。以 CentOS 系 Linux 发行版为例先把依赖装齐再从 git 仓库拉指定分支。# CentOS 系列 yum install gcc make git uuid-devel iasl nasm libuuid-devel # Ubuntu/Debian 系列 apt-get install build-essential git uuid-dev iasl nasm uuid-dev git clone https://github.com/tianocore/edk2.git cd edk2/ git checkout UDK2018 make -C BaseTools/ source edksetup.sh第一行装的是编译器和工具链依赖iasl 是 ACPI 表编译器nasm 是汇编器后面编译 OVMF 固件时会用到。clone 之后显式切到 UDK2018 分支因为这个分支的模块结构比较稳定配套实验大多基于它。make 出来的是 EDK2 自己的编译工具集source edksetup.sh 则是把编译环境变量导入当前 Shell。真正决定编译哪个模块、用哪个工具链的是 Conf/target.txt 里的三个变量。配置项示例值作用ACTIVE_PLATFORMOvmfPkg/OvmfPkgX64.dsc指定要编译的模块组 .dsc 文件TOOL_CHAIN_TAGGCC48指定工具链Linux 默认 GCC 4.8TARGET_ARCHX64目标处理器架构写 X64 才会生成 64 位镜像TOOL_CHAIN_TAG 要和本机编译器版本对应UDK2018 年代常用 GCC48如果你在新发行版上装了更高版本 GCC需要去 tools_def.txt 里确认对应工具链名称。改完 target.txt 后第一次编译 OVMF 这类含子模块的工程还要先拉子模块git submodule update --init --recursive build编译产物会出现在 Build/OvmfX64/DEBUG_GCC48/FV/ 目录下OVMF.fd 就是可以直接当固件用的文件后面做虚拟机验证时它很关键。3.2 编译 ShellPkg 并准备 GPT/ESP 引导 U 盘UEFI Shell 本身就是一个 UEFI 应用运行在 TSL 阶段。固件找不到系统加载器时通常会进 Shell方便排查分区、看启动项。编译它只需要改 target.txt 里的 ACTIVE_PLATFORMACTIVE_PLATFORM ShellPkg/ShellPkg.dsc TOOL_CHAIN_TAG GCC48 TARGET_ARCH X64然后执行 build产物路径一般是 Build/Shell/DEBUG_GCC48/X64/Shell.efi。拿到 Shell.efi 之后怎么让固件从 U 盘执行它这一步的坑比编译多。先清空 U 盘扇区里的数据再用磁盘分区工具把分区表类型转换成 GUID也就是 GPT。转换完成会提示原分区全部失效所以操作前想清楚 U 盘里有没有重要数据。接下来在 GPT 磁盘上创建两个分区第一个勾选 ESP 分区给 20MB按 4096 扇区对齐第二个建一个 10MB 的 FAT32 分区。UEFI 规范要求可引导块设备上必须有 ESP 分区固件会从中按\EFI\BOOT\BOOTx64.EFI这个固定路径找启动镜像。在 Windows 下资源管理器看不到 ESP 分区这是系统故意屏蔽的。切到 Linux 下挂载就方便了ls /dev/sdb* mount /dev/sdb1 /mnt/ mkdir -p /mnt/EFI/BOOT cp /root/Desktop/edk2/Build/Shell/DEBUG_GCC48/X64/Shell.efi /mnt/EFI/BOOT/BOOTx64.EFI sync umount /mnt/注意fdisk 显示出来的分区表项可能是 GPT 保护分区并不代表实际分区节点。想看真实分区直接ls /dev/sdb*比看 fdisk 输出更可靠。确定 ESP 分区设备号后再挂载把 Shell.efi 改名成 BOOTx64.EFI 放到EFI/BOOT/下固件启动时找不到系统加载器就会尝试这个路径。3.3 第一个 UEFI 应用Hello World 的工程结构前面用 Shell 验证了环境链路自己写 UEFI 应用时还需要一个最小工程模板。一个 Hello World 只需要两个文件一个 C 源文件和一个 .inf 描述文件。#include Uefi.h #include Library/UefiApplicationEntryPoint.h EFI_STATUS EFIAPI UefiMain(IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable) { SystemTable-ConOut-OutputString(SystemTable-ConOut, LHello World!\r\n); return EFI_SUCCESS; }[Defines] INF_VERSION 0x00010006 BASE_NAME HelloWorld FILE_GUID 1E2F3A4B-5C6D-7E8F-9A0B-1C2D3E4F5A6B MODULE_TYPE UEFI_APPLICATION ENTRY_POINT UefiMain [Sources] HelloWorld.c [Packages] MdePkg/MdePkg.dec [LibraryClasses] UefiApplicationEntryPointUefiMain 是 UEFI 应用的入口参数里的EFI_SYSTEM_TABLE *SystemTable是所有系统级能力的入口。这里先理解一件事SystemTable-ConOut 是字符输出协议OutputString 输出的是 UTF-16 字符所以字符串字面量要加L前缀。.inf 里的 FILE_GUID 是镜像的唯一标识固件靠它区分不同应用每写一个新模块都要换一个 GUID。编译后生成的是 PE/COFF 格式的 .efi 文件这也意味着只要工具链支持 PE/COFF 输出理论上 UEFI 应用可以用 C、C、Rust 来写只不过 EDK2 原生支持最好的仍然是 C。4. UEFI 镜像、系统表与协议引导程序的接口基础4.1 镜像格式与调用约定EFIAPI 不是摆设UEFI 应用的镜像格式是 PE/COFF和 Windows 可执行文件同源但子系统标记为 EFI Application。固件加载这种镜像后不是跳到一个固定地址而是直接按 PE 头里的入口点调用。所以早期做 UEFI Shell 相关调试时用 Windows 的开发经验去猜布局基本能对上。真正要重视的是调用约定。x64 平台上 UEFI 遵循微软 x64 调用约定前 4 个参数依次放 RCX、RDX、R8、R9剩余参数压栈栈 16 字节对齐。EDK2 里用EFIAPI宏标注凡是传给固件或从固件回调的函数声明时都要带上。漏掉 EFIAPI 的经典症状是函数能编译、运行时传参错乱拿到的指针全是垃圾值。4.2 EFI 系统表Boot Services 与 Runtime Services 的分工每个 UEFI 应用的入口都会收到一份EFI_SYSTEM_TABLE。这张表里包含固件厂商信息、控制台句柄、Boot Services 表指针、Runtime Services 表指针以及若干配置表。EDK2 里全局变量gST可以直接访问它gBS和gRT分别指向引导服务和运行时服务。Boot Services 只在引导阶段可用功能包括内存分配、协议查询、事件与定时器、图像加载。最关键的接口是ExitBootServices——操作系统接管后所有引导服务函数立即失效。Runtime Services 则是在系统运行期间继续提供的能力比如读写 NVRAM 变量、获取当前时间、重启系统。代码里常见的一对组合是EFI_STATUS Status; EFI_GRAPHICS_OUTPUT_PROTOCOL *GOP NULL; Status gBS-LocateProtocol(gEfiGraphicsOutputProtocolGuid, NULL, (VOID**)GOP); if (EFI_ERROR(Status) || GOP NULL) { // 图形协议不可用退回文本模式 return Status; }LocateProtocol 是 Boot Services 里的查询函数按 GUID 找全局唯一的协议实例。注意这里拿到的 GOP 指针只在 ExitBootServices 之前有效这也是引导程序必须在交出控制权前把所有要用的资源、句柄、缓冲区都准备好的原因。4.3 基于 Handle/Protocol 的设备访问方式UEFI 不提供「打开设备文件」的通用接口而是用 Handle 和 Protocol 组合描述外设。一个物理设备可以有多个 Handle每个 Handle 上挂着若干协议实例你要操作什么功能就按对应的协议 GUID 去查。比如串口调试找EFI_SERIAL_IO_PROTOCOL_GUID读文件系统找EFI_SIMPLE_FILE_SYSTEM_PROTOCOL_GUID要显示画面找EFI_GRAPHICS_OUTPUT_PROTOCOL_GUID。EFI_HANDLE *HandleBuffer NULL; UINTN HandleCount 0; Status gBS-LocateHandleBuffer( ByProtocol, gEfiSimpleFileSystemProtocolGuid, NULL, HandleCount, HandleBuffer );ByProtocol 表示按协议类型过滤所有 Handle返回的是符合条件的句柄数组。拿到文件系统 Handle 后再 OpenProtocol 取出具体协议指针用完记得 CloseProtocol 释放。这套机制的好处是驱动和应用的耦合被拆开固件新增一块硬件只要提供一个实现同款协议的新驱动引导程序不需要感知设备型号。5. 实现引导程序的常见问题显示、内存、读文件、切 64 位从 Hello World 到真正引导一个内核中间隔着一堆细节问题。下面四条按实际开发顺序排基本每一条都让初学者翻过车。5.1 GOP 显示模式QueryMode 与 SetMode 之间的坑配置图形设备的第一步是拿到 GOP 协议然后查询可用模式。UINTN SizeOfInfo 0; EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *Info NULL; Status GOP-QueryMode(GOP, 0, SizeOfInfo, Info); Status GOP-SetMode(GOP, ModeNumber);现象SetMode 传入一个模式号直接返回不支持屏幕没反应。原因模式号不是随便填的不同显卡固件支持的模式列表长度不一样模式 0 的分辨率也千差万别。解决先用 QueryMode 把所有模式号遍历一遍记录宽、高、像素格式再挑一个内核能接受的分辨率调用 SetMode。有些环境里 GOP 协议还没加载LocateProtocol 本身就失败这时要先保证 BDS 阶段把显示驱动加载完再查。5.2 GetMemoryMap 与 ExitBootServices内存表要抓准时机物理内存信息是引导程序最重要的产出之一。流程是先 GetMemoryMap 拿到描述符数组整理成内核能用的内存图最后 ExitBootServices。UINTN MapSize 0, MapKey 0, DescSize 0; UINT32 DescVersion 0; Status gBS-GetMemoryMap(MapSize, NULL, MapKey, DescSize, DescVersion); /* 分配足够缓冲区后再次调用 */现象第一次调用 GetMemoryMap 永远返回缓冲区太小分配好缓冲区后第二次调用又失败。原因GetMemoryMap 本身会触发固件分配内存MapSize 在你调用期间会变化必须在循环里反复重试直到成功。解决把分配和重试写成一个循环分配时给 MapSize 加一点余量一般加一页就够每次失败都根据新的 DescriptorSize 调整。另一个高频翻车点是 ExitBootServices 的 MapKey。现象退出后内核访问内存崩溃。原因在 GetMemoryMap 和 ExitBootServices 之间不能调用任何会改变内存分配的函数包括 AllocatePool甚至某些协议查询操作否则 MapKey 过期固件拒绝退出或退出后状态不一致。解决把 GetMemoryMap 放到最后一步拿到 MapKey 后立刻调 ExitBootServices中间不给固件改变内存布局的机会。5.3 用 SimpleFileSystem 读内核文件路径回车的烦恼从 ESP 分区读内核文件标准做法是先 OpenVolume 拿到根目录再按路径 Open 文件。EFI_SIMPLE_FILE_SYSTEM_PROTOCOL *SFS NULL; EFI_FILE_PROTOCOL *Root NULL, *KernelFile NULL; UINTN FileSize 0; gBS-LocateProtocol(gEfiSimpleFileSystemProtocolGuid, NULL, (VOID**)SFS); SFS-OpenVolume(SFS, Root); Status Root-Open(Root, KernelFile, L\\EFI\\BOOT\\kernel.elf, EFI_FILE_MODE_READ, 0);现象文件明明在 ESP 分区里Open 却返回找不到文件。原因UEFI 的路径分隔符是反斜杠在 C 字符串里必须写成\\并且要使用绝对路径从根目录写起。解决路径写成L\\EFI\\BOOT\\kernel.elf不要带EFI:或BOOT:这种卷名。读文件内容时还有第二个坑。现象第一次 Read 返回缓冲区太小或只读到了半截。原因File-Read 把传入的 BufferSize 理解为当前缓冲区容量不是读满为止大文件固件会分批返回。解决先打开文件拿到文件大小分配完整缓冲区后循环调用 Read直到读完再关闭文件然后才校验内核格式。5.4 从 UEFI 环境切到内核 64 位入口GDT 与页表的准备UEFI 在 x64 上本身运行于长模式但不代表内核可以直接接管。你要在跳转前给内核准备一套干净的运行环境。现象内核入口执行第一条指令就触发异常虚拟机黑屏或重启。原因最常见的是没有建立临时页表就把控制权跳给了内核或者 GDT 里代码段描述符的 L 位没有置位导致 CPU 把 64 位代码当成 32 位解析。解决跳转前至少做三件事——建一张覆盖物理内存的临时页表直接把物理地址按 1:1 映射到内核虚拟地址空间加载一个带 64 位代码段描述符的 GDT用远跳转把 CS 刷成 64 位代码段再把入口函数地址转成物理地址传进去。这个阶段之后再想回头调 UEFI 引导服务已经不可能所以我会把内核入口地址、内存图、GOP 信息放进一个约定好的内存结构里通过寄存器或共享内存传给内核入口而不是让内核回头读 UEFI 服务。6. 用 OVMF 快速验证整条引导链并留下一个复盘习惯6.1 把 OVMF.fd 当固件问题定位省一半时间在真实主板上调试 UEFI 引导程序非常费劲清 CMOS、刷固件、接串口转接线一次折腾半小时。我自己的做法是全程用 OVMF 固件在虚拟机里测。把 EDK2 编译出的 OVMF.fd 指定为虚拟机固件启动模式就变成 UEFI把你做好的 ESP 分区做成磁盘镜像挂给虚拟机。启动后如果固件能进入 UEFI Shell说明 GPT 分区、ESP 路径、BOOTx64.EFI 这条链路是通的。之后每加一个功能重新编译 .efi替换镜像里的文件再启动看打印。整个过程只需要关虚拟机、换镜像、再开机不碰任何物理硬件。操作系统层面的验证还有个技巧在引导程序里按阶段输出标记比如GOP OK、MEM OK、FS OK、KERNEL ENTRY。每次启动看日志停在哪一步就能定位是显示模式失败、内存表问题还是文件读取没走到。6.2 一个让我少熬夜的复盘习惯以前有段时间我经常遇到「昨天能引导、今天改了三个地方就起不来」的情况。后来强制自己每次改动都留两样东西一个是改动前后都能跑的 boot 镜像备份另一个是启动时打印的完整内存图与显示模式信息。出问题时先不猜直接对照两张图找差异。从那以后我每次实现引导程序都强制走一遍「先编译 → 进 Shell → 读内存图 → 读文件 → 跳内核」的检查顺序任何一步失败就先修这一步不急着往下写。UEFI 引导程序不是玄学只是链路长、接口多把每一步的验证门槛都设清楚问题基本都能在十分钟内定位。希望这份《一个UEFI引导程序的实现》能帮到你也祝你的内核早点在自研引导器下跑起来。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑