资讯动态

UEFI Shell 应用从编译到加载的完整实践指南

发布时间:2026/10/14 2:36:31 来源:尧图企业网站定制
UEFI Shell 这个方向坦白说属于那种“平时没人提起、真用的时候大家都要临时翻手册”的知识点。前阵子我需要在一台没装系统的机器上确认内存控制器参数还得改一下启动顺序Windows 和 Linux 都还远没到出场阶段唯一能干活的环境就是 UEFI Shell。我在里面敲了几条命令顺便把自己写的一个 .efi 小工具跑起来整个过程让我想把“编译和加载”这条链路彻底捋清楚。这篇文章就是基于那次实操整理出来的适合两类人一类是刚接触固件开发、想弄明白 UEFI Shell 应用是怎么从源码变成可执行文件的初学者另一类是已经在写 UEFI 驱动或诊断工具、但一直被加载阶段各种怪问题困扰的开发老手。我会把环境搭建、代码骨架、编译链路、加载过程和常见坑串成一条完整路线顺便解释每一步为什么要这么做。1. 先搞明白“标准应用程序”这四个字代表什么1.1 Shell 在固件启动流程中的位置一台机器从按下电源键到进入操作系统其实要经过一个很短但非常重要的过渡阶段。在 UEFI 的框架里整个启动过程大致是 SEC安全验证、PEI前期初始化、DXE驱动执行环境、BDS启动设备选择然后才把控制权交给操作系统加载器。UEFI Shell 通常在 BDS 阶段被加载但它不负责引导系统它只是一个运行在固件环境里的交互式解释器。你可以把它想象成一个“没有内核支持的 Linux 终端”。Linux Shell 背后有内核在管理进程、内存、文件系统而 UEFI Shell 背后只有固件本身提供的服务。Shell 里的命令比如map、ls、bcfg本质上都是一个个独立的 UEFI 应用程序也就是常说的 .efi 文件。我们自己写的标准应用程序编译出来同样是 .efi放到 Shell 里输入文件名就能执行。这个定位非常重要。它决定了你在写这类程序时不能用平时那套“printf 操作系统 API”的思路。因为没有操作系统为你管理内存、打开文件、分配资源所有功能都必须通过 UEFI 提供的协议Protocol和服务表System Table来调用。1.2 .efi 文件与普通可执行文件的本质差异从文件格式看.efi 使用的是 PE32 格式和 Windows 下的 EXE/DLL 同源。但它不是给 Windows 用的也没有依赖特定的操作系统加载器。固件里的加载器会把 .efi 当成一个普通镜像读进来校验格式、分配内存、跳转到入口点执行。这里有个容易混淆的点UEFI 应用程序和 UEFI 驱动虽然都是 .efi但行为模式不同。驱动程序通常注册到某个协议或者设备路径上由固件的驱动管理器来调度而应用程序是“主动运行”的它有自己的入口点运行完返回状态码和普通程序很像。标准应用程序一般属于后者Shell 下的工具基本都是这种。理解了这个区别你才能理解为什么加载过程会有那么多讲究。加载器要处理的不是“系统调用”而是 PE 头解析、机器类型匹配、内存映像加载、入口点跳转这一整套流程。2. 编译环境交叉工具链、源码和架构选择2.1 为什么必须用“交叉编译”注意一个现实你的 .efi 可能运行在 x86_64 的机器上但写代码和编译很可能是在另一台机器上进行的。更重要的是UEFI 应用程序并不是在某个操作系统内核之上运行的它面对的是固件环境。所以你不能用宿主机的系统库也不能用普通的动态链接需要专门为 UEFI 环境准备一套工具链和基础库。交叉编译这个词听起来高大上其实道理很简单用一套编译器生成另一种环境下运行的二进制。你的电脑是 Linux但你要生成的是给 UEFI Firmware 用的镜像这就叫交叉编译。EDK2 这类固件开发套件本质上就是帮你把编译器、标准库、构建脚本全部按 UEFI 的要求组合好。2.2 搭建一个最基础的编译环境我在 Linux 上做示范因为最省事。假设你已经准备好一个干净的目录接下来按顺序操作sudo apt-get install build-essential nasm uuid-dev python3build-essential提供 gcc 编译器和 make 工具nasm是汇编器UEFI 某些底层库和启动代码需要它uuid-dev用来生成 GUIDpython3是 EDK2 构建脚本的运行时依赖。接着把 EDK2 源码拉下来这是目前最主流的 UEFI 开发环境包含 MdePkg、MdeModulePkg、ShellPkg 等基础包git clone https://github.com/tianocore/edk2.git cd edk2 git submodule update --init make -C BaseTools source edksetup.shedksetup.sh会设置环境变量让build工具和源码里的包可以被识别。我第一次做的时候忽略了git submodule update --init结果编译到一半报错缺少某些加密库只能回头补拉子模块。所以这四个步骤一个都不能省。2.3 架构匹配X64、IA32 和 AARCH64UEFI 应用程序的加载有一个硬规则镜像的机器类型必须和固件运行时所在的架构一致。你在 x86_64 平台上编译一个 IA32 的 .efi固件加载器会直接拒绝加载。目标架构说明常见使用场景X64大部分现代桌面和服务器的固件架构日常诊断工具、Shell 命令扩展IA32老平台的 32 位固件兼容老机器、部分嵌入式设备AARCH64ARM 64 位固件服务器、开发板如树莓派等在build命令里用-a X64指定。如果搞错架构最常见的报错是Unsupported image type或者直接没有任何反应。这个坑我后面会专门讲。3. 最小可编译程序的代码骨架与协议约定3.1 UefiMain 入口点的要求程序入口点不是main而是UefiMain。它接收两个参数ImageHandle和SystemTable。ImageHandle是当前镜像的句柄很多协议操作都要用到它SystemTable则是固件暴露出来的核心服务集合里面包含启动服务、运行时服务、配置表等。#include Uefi.h #include Library/UefiLib.h #include Library/UefiBootServicesTableLib.h #include Protocol/ShellParameters.h EFI_STATUS EFIAPI UefiMain ( IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable ) { Print (LHello, UEFI Shell!\r\n); return EFI_SUCCESS; }注意EFIAPI这个修饰符。它代表 UEFI 规定的调用约定和普通 C 函数的默认约定不同。如果你漏掉它函数入口参数可能传不进来程序跑起来会很诡异。其次字符串要用宽字符格式也就是前面加L因为 UEFI 内部使用 UCS-2 编码。Print是 UefiLib 提供的输出函数。3.2 参数从哪里来ShellParametersProtocol如果只是打印一句话这个程序和普通命令行工具没太大区别。但标准应用程序往往需要接收用户传入的命令行参数。在 Shell 里执行UefiHello.efi -a 1 2这些参数是怎么传给你的程序的答案是 Shell 在启动你的应用之前会往ImageHandle上挂一个EFI_SHELL_PARAMETERS_PROTOCOL协议。你的程序通过打开这个协议就能拿到参数数组。#include Uefi.h #include Library/UefiLib.h #include Library/UefiBootServicesTableLib.h #include Protocol/ShellParameters.h EFI_STATUS EFIAPI UefiMain ( IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable ) { EFI_SHELL_PARAMETERS_PROTOCOL *ShellParams NULL; EFI_STATUS Status; UINTN Index; Status gBS-OpenProtocol ( ImageHandle, gEfiShellParametersProtocolGuid, (VOID **) ShellParams, ImageHandle, NULL, EFI_OPEN_PROTOCOL_GET_PROTOCOL ); if (EFI_ERROR (Status)) { Print (L打开参数协议失败: %r\r\n, Status); return Status; } Print (L参数个数: %d\r\n, ShellParams-Argc); for (Index 0; Index ShellParams-Argc; Index) { Print (L参数 %d: %s\r\n, Index, ShellParams-Argv[Index]); } return EFI_SUCCESS; }gBS是启动服务表的全局变量UefiBootServicesTableLib把它定义出来。OpenProtocol是获取协议的通用方式。EFI_ERROR用来判断返回值是否表示错误这个习惯一定要养成。UEFI 编程里几乎所有函数都返回EFI_STATUS不加判断的后果就是程序在某个奇怪的时机关闭。4. 编译链路完整走一遍从 .c 到 .efi4.1 INF 文件是模块的“身份证”EDK2 构建系统不认识 .c 文件它认识的是 .inf 文件。INF 里声明了源文件、依赖包、库、入口点以及最关键的FILE_GUID。这个 GUID 会被写进生成的 .efi 头里固件加载器靠它区分不同镜像。[Defines] INF_VERSION 0x00010006 BASE_NAME UefiHello FILE_GUID 7c2e5a1b-4d3f-4e8a-9b2a-1f6a3d9c5e77 MODULE_TYPE UEFI_APPLICATION VERSION_STRING 1.0 ENTRY_POINT UefiMain [Sources] UefiHello.c [Packages] MdePkg/MdePkg.dec ShellPkg/ShellPkg.dec [LibraryClasses] UefiBootServicesTableLib UefiLibMODULE_TYPE必须写成UEFI_APPLICATION这决定了构建系统采用的应用入口和链接方式。[Packages]里列出需要用到的包描述文件。比如我们要用gEfiShellParametersProtocolGuid就得引入ShellPkg.dec。[LibraryClasses]则列出需要链接进来的库。4.2 从源码到 .efi 的四个阶段执行build时构建系统会经历四个阶段预处理、编译、链接、GenFw。编译阶段gcc把 .c 变成 .obj但此时的链接并不是平时那样的系统链接而是链接成一个没有标准入口的 PE32 镜像。接着GenFw工具接管对链接结果做 PE 头修正、重定位处理和调试符号剥离最终生成可用于固件加载的 .efi 文件。阶段工具产物作用预处理gcc.i 或 .ii展开头文件、处理宏编译gcc.obj生成机器码目标文件链接lld / GNU ld.debug 的 PE 镜像合并目标文件、解析库引用转换GenFw.efi修正 PE 头、生成最终固件镜像我一开始以为 .efi 是 gcc 直接编译出来的后来把构建日志打开看了一遍才发现链接产物还在Build/xxx/DEBUG_X64/X64/目录下那个中间文件叫UefiHello.debug真正能拿到 Shell 里执行的是经过GenFw处理后的UefiHello.efi。这两个文件大小差不少因为.debug文件带着符号表。4.3 如何验证编译产物是不是有效如果手上没有 UEFI 机器用file命令先看一眼file UefiHello.efi正常输出会显示PE32 executable (EFI application) x86-64。如果不小心编成了 IA32会出现x86-32。这个命令是我每次编译完的第一道检查能省掉不少盲目上机的折腾。5. 加载的实际操作三种路径与背后的启动机制5.1 准备工作把程序放到 FAT 分区上UEFI 固件能识别的文件系统比较有限最常见的就是 FAT12/FAT16/FAT32。PE 镜像加载器不直接认识 ext4 或 NTFS这是很多人第一次跑不通的原因。最简单的方式是做一个 FAT 格式的磁盘镜像把UefiHello.efi和 Shell 程序放进去。如果只是测试可以直接在内存盘里操作。我平时用 QEMU 加 OVMF 模拟固件来做验证这样不用反复重启真实机器。启动命令如下mkdir -p disk cp UefiHello.efi disk/ cp Shell.efi disk/ qemu-system-x86_64 \ -bios /usr/share/ovmf/OVMF.fd \ -drive filefat:rw:disk,formatraw,mediadisk \ -net nonefat:rw:disk表示让 QEMU 把宿主机上的disk目录模拟成一个 FAT 磁盘。启动后进入固件界面选Boot Manager或者直接切换到 UEFI Shell就能看到映射出来的FS0:分区。5.2 路径一进入 Shell 后手动执行这是最常用的开发验证路径。进入 Shell 后先敲map -r让文件系统重新扫描确保目录被识别成FS0:或者FS1:。然后切换目录FS0:\ dir FS0:\ UefiHello.efi -a 1 2程序会打印出参数个数: 3分别是UefiHello.efi、-a、1、2。Shell 把命令行拆分成参数数组然后通过 ShellParametersProtocol 传给应用。这种方式的优点是不需要修改任何启动配置适合开发期反复测试。缺点是你必须停留在 Shell 环境中每次测试都要手动输入命令。5.3 路径二通过bcfg注册成启动项如果你希望程序像操作系统加载器一样开机引导时自动运行可以通过bcfg命令把它写进 UEFI 启动变量。这个命令本质上是修改 NVRAM 里的 BootOrder 和 Boot#### 变量。FS0:\ bcfg boot add 0 FS0:\UefiHello.efi My Hello App FS0:\ bcfg boot dumpadd 0表示把这个启动项放到第一位后面跟的是设备路径和显示名称。执行后boot dump会列出当前所有启动项。这个操作会动 NVRAM所以建议只在虚拟机上验证真实机器上最好先备份原有启动项。修改之后重启固件会在 BDS 阶段直接执行这个 .efi相当于你的应用变成了一个“无操作系统的启动入口”。5.4 路径三手动选择启动文件如果不想改 NVRAM也不想进入 Shell很多固件允许你在 Boot Manager 里手动选择文件路径。比如选择FS0:下的UefiHello.efi固件会直接调用加载器跑这个程序。这三条路径看起来不同但底层机制是一致的固件通过 FAT 文件系统协议读取文件通过 PE/COFF 加载器把内容映射到内存然后调用StartImage启动镜像。区别只是“谁来发起加载”——是 Shell 的程序启动逻辑还是 BDS 阶段的启动项选择逻辑。5.5 加载器内部到底做了什么当固件或 Shell 决定加载你的 .efi 时背后是一串固定的流程。先检查文件头开头要是MZ标志偏移一定位置要有 PE 签名机器类型要和当前架构匹配。然后加载器解析 PE 头里的节表把.text、.data、.reloc等节分别加载到内存按加载地址做重定位。UEFI 应用通常没有动态链接依赖所有固件服务都通过 SystemTable 和协议指针访问所以这一步比操作系统加载器简单很多。接下来加载器会创建一个Loaded Image Protocol记录镜像的基地址、文件路径、父句柄等信息然后调用入口点。入口点执行期间代码使用的是固件映射的虚拟地址可以访问启动服务但操作系统还没有接管硬件。程序返回EFI_SUCCESS时加载器会把返回值传递给启动方如果返回错误码Shell 会显示对应的含义。这里要特别提一句ExitBootServices。一旦操作系统加载器调用这个服务固件的启动服务就失效了你的应用再想去访问gBS-AllocatePages这类接口就会崩溃。作为 Shell 应用你必须假设自己运行在启动服务阶段别去碰运行时服务里那些跟硬件强相关的东西。6. 实测中最容易踩的四个坑及排查过程6.1 编译架构和固件不匹配我在一次测试中把应用编成了 IA32但 OVMF 是 X64 固件。执行UefiHello.efi的瞬间Shell 直接提示镜像类型不被支持连程序入口都没进。排查思路很简单先执行file UefiHello.efi确认架构再确认构建命令里的-a参数。还要注意-t GCC5指定的是工具链名称不是架构。有人会把-a IA32和-t GCC5搞混结果编出来的还是 X64。报错信息可能原因处理方式Image type not supported架构不匹配重新用-a X64编译Not foundShell 没映射到文件系统执行map -r后重试Security violation安全启动拒绝未签名镜像临时关闭 Secure Boot 或签名Loaded image protocol 打开失败镜像句柄不对检查 ImageHandle 传参6.2 安全启动把程序拦在门外启动现代机器时如果固件开启了 Secure Boot而无签名的 .efi 不在信任列表里加载器会在校验阶段直接拒绝执行。这不是你的程序问题是固件安全策略的问题。我在某开发板上遇到过同一个 .efi 在关闭 Secure Boot 时跑得好好的开启后连 Shell 都进不了更别说应用。这种情况的排查重点是确认固件设置。开发阶段最快捷的方式是进入固件设置菜单找到 Secure Boot 选项临时关闭等签名流程完整后再开启。6.3 字符串乱码和参数为空的诡异情况Shell 程序的字符串是 UCS-2 编码如果你用 UTF-8 的编译器选项去处理宽字符串打印出来的内容可能是乱码。UefiLib 的Print对宽字符串有约定源文件最好保存为 UTF-8 带 BOM 或者直接避开非 ASCII 字符。参数为空的问题也很常见。如果代码里没有正确打开 ShellParametersProtocol或者 Shell 在启动应用时因为某种原因没挂上这个协议Argc会是 0。我在调试时反复确认过命令行明明传了两个参数程序里就是收不到。后来发现是我在入口点里先调用了ShellLib的某个初始化接口它把协议状态干扰了。规范做法是入口点第一件事就去OpenProtocol不要在中间穿插别的操作。6.4 程序运行后直接重启或无输出这个问题最让人头疼。没有输出、没有报错、连条日志都没有多半是程序在入口点之前就崩了。比如调用了未初始化的库函数、Print输入的字符串指针为空、某个库依赖没有被链接进来。排查手段有三个层次。第一层在入口点第一行只写一句Print跑通了再加代码。第二层利用 UEFI Shell 的输出重定向执行程序时把输出导到文件里这样即使显示缓冲有问题也能从文件里看到内容。第三层用调试器。OVMF 支持 GDB 连接 QEMU在入口点打断点单步执行看变量值。这个方法对复杂问题最有效但调试环境搭建成本稍高。7. 最后再补充一些能少走弯路的体会这套流程我前前后后跑了很多遍有几点体会值得分享。编译时尽量用 DEBUG 版本也就是-b DEBUG。它生成的镜像包含更多检查逻辑和调试信息虽然体积大一点但问题定位容易得多。等最终交付再切到 RELEASE 版本。加载路径的选择上开发期就用 Shell 手动执行不要一上来就写启动项。改 NVRAM 的启动变量虽然方便但在某些主板上出问题后恢复很麻烦。虚拟机上随便折腾没问题真实机器务必慎重。最后一个建议把你的构建脚本固定下来不要每次手动敲长命令。我习惯把这样一段脚本存起来source edksetup.sh build -p ShellPkg/ShellPkg.dsc -a X64 -b DEBUG -t GCC5这里需要说明-p ShellPkg/ShellPkg.dsc是构建整个 Shell 包里面包含 Shell 环境和示例应用。如果你有自定义应用把它们加到对应包的[Components]里即可。脚本化的好处是架构、工具链、目标这些参数不会因为时间久了而忘记。UEFI Shell 应用程序的编译和加载本质上就是“选择合适的工具链生成 PE 镜像再通过固件加载器把它启动起来”。这句话听起来简单但中间的每一个环节都受规范和环境的约束。希望这篇整理能帮你把这条路走顺。如果后面有空我还会把调试器连 QEMU 的方法也写一篇那是排查复杂固件问题最可靠的手段比靠打印语句猜位置高效得多。

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

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

免费获取报价 →
↑