不少搞固件和底层开发的朋友应该都有这种体会:第一次接触UEFI Shell的时候,感觉像进了一个藏在固件里的迷你操作系统,能跑命令、看内存、测设备,但想真正写一个属于自己的小程序进去跑,翻遍手册也没理清“源码该放哪、环境怎么配、命令怎么敲”。我自己当年也是被这个编译和加载过程折腾了两三个晚上,才把第一个.efi文件跑起来。今天这篇就专门聊UEFI Shell下标准应用程序从编译到加载的完整链路,包括环境搭建、编译器选型、INF配置、Shell文件映射和常见的运行期错误。内容全部基于我实际操作过的路径,尽量把细节和坑都点出来,让想上手UEFI应用开发的同学少走弯路。
1. UEFI Shell与标准应用程序初识
1.1 标准应用程序到底是什么
UEFI Shell是一个运行在UEFI固件层上的命令行解释器,通常用来执行硬件诊断、固件升级、文件管理和启动项管理。和普通操作系统里的Shell不太一样,它没有完整的内核和驱动模型,只是一个面向固件服务的交互入口。标准应用程序就是符合UEFI规范、以.efi为扩展名、可以被UEFI固件或Shell直接加载运行的PE/COFF格式可执行文件。它和Windows下的exe在结构上同源,但又没有依赖操作系统的动态库,所有功能都通过UEFI的协议接口来完成。
打个比方,UEFI Shell就像一台还没装系统的电脑BIOS里自带的命令行,而标准应用程序就是能在这种裸机环境下运行的“小程序”。它不需要驱动,不需要文件系统支持,因为UEFI固件本身已经提供了最基础的控制台输出、文件读写、内存分配这些服务。你在Shell里敲一个命令,Shell会去指定路径找一个.efi文件,把它加载进内存,然后调用它的入口函数。
理解这一点很重要,因为这直接决定了你写代码时的思维模式。你不能调用printf、fopen这类标准C库函数,必须用UEFI提供的Service函数,比如gST->ConOut->OutputString来打印字符串,或者gBS->AllocatePages来分配内存。很多第一次接触的人在这里犯迷糊,觉得代码写起来怎么这么别扭。其实说白了,这就是一个裸机环境下的可执行程序,一切资源都靠UEFI固件来分配和暴露。
1.2 为什么要关心编译与加载过程
很多人觉得UEFI应用程序开发比较小众,平时接触不到。但只要你做固件测试、主板调测、安全启动验证或者嵌入式板级开发,就一定会遇到需要快速验证一个想法的情况。与其每次都在操作系统里搭环境,不如直接在UEFI Shell下面跑一个自定义的.efi,效率高得多,也更能贴近硬件底层。
编译过程解决的是“源码变成可执行文件”的问题,加载过程解决的是“可执行文件怎么被固件识别并运行”的问题。这两个环节是UEFI应用开发的骨架。编译做不对,跑起来全是内存错误;加载搞不明白,文件明明放在U盘里却怎么都找不到。而且UEFI的开发工具链和传统应用开发差别很大,不是装个编译器就能搞定,还要配置包依赖、指定平台描述文件、处理编译规则。所以把这两块彻底吃透,后续做再复杂的UEFI工具都会顺手很多。
另外,UEFI Shell下的应用程序本身有一个非常典型的应用场景:作为一种硬件或者固件诊断程序,它可以在操作系统挂掉的时候依然独立运行。比如你想确认某块主板上的PCIe设备枚举是否正常,或者想测试某段内存的读写稳定性,直接在Shell下跑一个自己写的检测工具,比先启动系统再加载驱动的路径要干净得多。
2. 编译环境的搭建与工具链选择
2.1 EDK2开发环境的完整搭建步骤
UEFI应用的编译并不是用普通的GCC或者MSVC直接编译就行,它需要依赖UEFI的开发框架EDK2,因为应用代码里要调用大量UEFI协议接口的封装函数,这些封装函数都在EDK2的库和头文件里。第一步就是搭建EDK2的编译环境。
我习惯在Ubuntu系统上做EDK2开发,因为命令行的工具链比较干净。首先需要从源码仓库拉取EDK2的代码到一个工作目录,比如我的习惯是放在~/workspace/edk2下。拉下来之后进到目录,我们需要先处理子模块,因为EDK2依赖了一些外部组件,比如OpenSSL,在跑编译工具之前得先初始化这些子模块。
git clone https://github.com/tianocore/edk2.git cd edk2 git submodule update --init --recursive接下来需要安装必需的编译工具。如果你在Linux上用GCC,那么make、gcc、nasm这些都要预先装好。NASM是用来编译汇编模块的,EDK2里有一部分代码是汇编写的,没有它编译到一半就会报错。在Ubuntu上执行:
sudo apt-get install build-essential nasm uuid-dev然后是真正关键的步骤:编译BaseTools。EDK2的大量编译逻辑并不直接由GCC完成,而是通过一套独立的工具链来生成makefile和控制编译过程。这套BaseTools本身也是C语言写的,需要先编译一次。进入edk2目录后运行:
make -C BaseTools设置环境变量。我一般会写一个脚本,把EDK_TOOLS_PATH、PATH这些变量都配好。通常是在edk2目录下source一个环境配置文件,比如edksetup.sh:
source edksetup.sh看到类似“Loading previous configuration from ...”或者没有报错,就说明BaseTools环境已经激活了。
Windows下同样可以做,但要求可能更多一些。要么用微软的Visual Studio工具链,要么用MinGW,中间还会踩到路径分隔符、环境变量混淆、Python版本不一致之类的坑。所以我给新手的建议是先在Linux下跑通一遍,搞清楚整个流程,再考虑去Windows上适配。
2.2 编译器选型与对比
EDK2支持多种编译器,常见的包括GCC和MSVC。在Linux上默认是GCC,在Windows上常用MSVC或者GCC的MinGW变体。选编译器不只是看着来的,它会直接影响你生成的EFI文件在目标固件上能不能正常加载。
GCC工具链的优势是开源、跨平台、配合EDK2的文档和示例最多,遇到问题Google一下基本都有答案。MSVC的优势是如果你在Windows下做开发,Visual Studio的调试体验会好一些,可以集成EDK2的IDE插件,但编译参数和EDK2的默认规则有一些差异,往往需要额外配置。
我个人的选择是:如果在Linux下开发,直接用系统自带的GCC,配合NASM和BaseTools,很少会遇到奇怪的兼容性问题。如果必须在Windows下开发,我会优先用Visual Studio 2019或2022自带的MSVC,因为EDK2在Windows下对MSVC的支持其实更完整,MinGW那条路在某些模块上经常遇到崩溃问题。
下面是两种编译器在EDK2里常见的配置要求:
| 编译器 | 适用平台 | 稳定性 | 痛点 |
|---|---|---|---|
| GCC | Linux/macOS | 高 | 跨平台交叉编译时要手动指定ARCH |
| MSVC | Windows | 中 | 环境变量配置繁琐,需要注意命令行参数格式 |
另外多说一句,如果你的目标平台是32位UEFI固件,要记得在编译时明确指定架构参数.IA32还是X64。很多人只关注编译器选型,忽略了架构匹配,结果编译出来一个X64的EFI文件,放到32位Shell里加载,立刻报兼容性错误。
3. 标准应用程序的编译过程详解
3.1 从零手写一个HelloWorld应用
搭建好环境之后,我们开始写第一个标准应用程序。传统做法是先创建一个工作区,在里面放一个包目录,比如叫MyAppPkg,然后在包里面定义Source目录、Include目录和模块描述文件。为了让内容更聚焦,我这里直接把一个最简化的实例拆开讲。
创建一个EDK2包之后,首先需要建一个模块目录,比如MyApp。在里面建一个C源文件,比如MyApp.c。UEFI应用的标准入口函数不叫main,而是叫UefiMain,代码如下:
#include <Uefi.h> #include <Library/UefiApplicationEntryPoint.h> #include <Library/UefiLib.h> EFI_STATUS EFIAPI UefiMain ( IN EFI_HANDLE ImageHandle, IN EFI_SYSTEM_TABLE *SystemTable ) { SystemTable->ConOut->OutputString (SystemTable->ConOut, L"Hello UEFI Shell!\r\n"); return EFI_SUCCESS; }这段代码很简洁,Snip 做两件事:第一,通过SystemTable参数拿到负责控制台输出的ConOut实例,然后调用它的OutputString函数打印一行字符串。第二,返回EFI_SUCCESS告诉固件这个程序正常结束了。注意字符串前缀要加L,表示宽字符,这是UEFI规范里的要求,因为Shell控制台默认按UTF-16处理文本。
你可能好奇,为什么不能直接用printf?因为UEFI环境下没有完整的C运行库,stdout、stderr、printf这些标准输入输出机制都不存在,所有输出都必须通过协议接口。之所以后面还要链接UefiLib,是因为这个库提供了一些封装函数,比如Print函数,可以帮你替代printf的一部分功能。但为了说明原理,我故意用了最底层的OutputString。
3.2 INF文件与编译参数解析
写完C代码还不够,EDK2要识别这个模块,还必须提供一个INF格式的模块描述文件。INF文件是EDK2编译系统的“身份证”,里面明确写出了模块类型、源文件、依赖库、入口函数和架构信息。
下面是我使用的MyApp.inf内容:
[Defines] INF_VERSION = 1.27 BASE_NAME = MyApp FILE_GUID = 0D3BC4B7-8A6E-4B6C-8A87-3F6E2B2C1A45 MODULE_TYPE = UEFI_APPLICATION ENTRY_POINT = UefiMain [Sources] MyApp.c [Packages] MdePkg/MdePkg.dec MdeModulePkg/MdeModulePkg.dec [LibraryClasses] UefiApplicationEntryPoint UefiLib各个字段的作用我简单梳理一下:
- INF_VERSION指定INF文件的语法版本,固定值即可。BASE_NAME是模块显示名,一般和源文件名或者.efi文件名保持一致。
- FILE_GUID是一个全局唯一标识符,EDK2用它来在固件里定位这个模块。如果不改,每次编译都是同一个GUID,调试的时候容易混淆多个实例。
- MODULE_TYPE必须填UEFI_APPLICATION,因为我们要生成的是标准应用程序,不是驱动或者运行时驱动。ENTRY_POINT对应C文件里的UefiMain函数名。
- Sources段列出所有需要编译的源文件。
- Packages段列出这个模块依赖的.dec文件,开头我们引用了MdePkg里的基础定义,这是几乎所有UEFI应用的必需依赖。
- LibraryClasses段列出本模块用到的库,这里包括入口函数库和UefiLib辅助库。
写完INF文件之后,编译命令其实非常简单。在EDK2根目录下,通过build工具指定平台、架构和模块名:
build -p MyAppPkg/MyAppPkg.dsc -m MyAppPkg/MyApp/MyApp.inf -a X64 -t GCC5这里-p指向平台描述文件,-m指向模块描述文件,-a指定架构,-t指定工具链。如果一切顺利,会在Build/MyAppPkg/DEBUG_GCC5/X64/目录下生成MyApp.efi。这个.efi文件就是我们最终可以拿到UEFI Shell里去加载的标准应用程序。
编译成功之后不要急着去运行,建议先检查一下文件属性,确认它对的是X64架构,大小也没有异常。有时候你改完源码重新编译,但build系统没有检测到修改,生成了一个旧的.efi,这种情况虽然很少见,但也需要留意,特别是当代码明明改了很多却没任何效果的时候。
4. 加载过程与UEFI Shell命令
4.1 把EFI文件装进U盘并启动Shell
编译生成的.efi文件要运行,最直接的方法就是放在U盘上,然后从UEFI Shell引导加载。这里有一个很关键的概念,就是UEFI Shell的文件系统和传统操作系统不太一样。你插入U盘之后,Shell并不会自动把U盘当成C盘或者D盘,而是要通过map命令查看当前可用的文件系统映射,类似UEFI下的盘符表。
先把编译好的MyApp.efi复制到一个FAT32格式的U盘根目录。然后开机进入固件启动菜单,选择UEFI Shell启动项。如果你的平台没有内置Shell,可以把Shell.efi也放到U盘上,再通过固件引导加载它。启动进入Shell之后,首先敲入map命令查看设备映射:
Shell> map你会看到类似fs0、fs1、blk0这样的映射列表。fs开头的是可识别的文件系统分区,blk是块设备。U盘通常对应其中一个fs节点,可能是fs0也可能是fs1,取决于你的硬盘上有没有EFI系统分区。用fs0:切换文件系统:
Shell> fs0: Shell> ls看到你在U盘根目录里放入的MyApp.efi文件,就说明文件系统路径已经对了。
这里有一个非常容易踩的坑:很多主板固件内置的UEFI Shell版本比较旧,对NTFS、exFAT这类U盘格式不支持,只认FAT32。你辛辛苦苦把文件放进去,结果在Shell里ls什么也看不到。所以我每次都提醒同事,U盘最好格式化成FAT32,并且用MBR分区表,兼容性最稳。
4.2 用Shell命令加载并运行EFI文件
在UEFI Shell下加载一个标准应用程序其实非常简单,只需要在文件系统路径下输入文件名。假设当前所在的文件系统已经是fs0:,并且ls能看到MyApp.efi,那么直接敲:
Shell> MyApp.efiShell会把MyApp.efi当作一个UEFI应用程序加载到内存里,定位入口函数,然后跳转执行。如果程序返回EFI_SUCCESS,Shell会回到命令行提示符,等待下一条指令。如果程序返回错误码,Shell可能会显示一行错误信息,但通常不会崩溃。
很多程序还支持命令行参数。UEFI Shell的标准应用程序可以通过Shell参数结构接收参数,比如写一个工具支持SHELL> MyApp.efi -h或者-t之类的选项,这在实际应用中非常常见。处理参数的方式也不复杂,在UefiMain入口点里,通过gEfiShellProtocol可以拿到命令行参数列表。我在做硬件测试工具的时候,就是靠着这个特性,让同一个.efi能跑不同测试模式。
另外,还有一个很实用的启动方式,就是直接把.efi文件放到固件启动项里。在UEFI固件设置里新建一个启动项,指向U盘上的MyApp.efi,开机就会自动运行,不需要经过Shell。这种方式特别适合全自动测试场景,插上U盘开机就跑测试,完成后自动关机或者重启。我当时做批量主板验收时就靠这个方案节省了大量时间。
加载过程中,固件实际上会做几件事:分配内存、把EFI文件的内容按段拷贝过去、解析PE/COFF头、调用构造器、然后进入入口函数。这一套流程如果某个环节不匹配,比如段对齐属性不对或者入口函数序号错了,就会导致无法加载的报错。这也是为什么编译的时候必须严格遵守EDK2的规则,不能随意改链接参数的原因。
5. 常见问题与排查技巧实录
5.1 编译阶段高频错误汇总
编译过程中最常见的错误其实都集中在一块:环境变量没有配对、某个工具链没有安装、或者模块依赖没有写全。我在这里整理一个速查表,方便你直接对照排查:
| 错误现象 | 常见原因 | 解决办法 |
|---|---|---|
| build: command not found | 未source edksetup.sh | 重新执行source edksetup.sh |
| nasm: command not found | 未安装NASM | sudo apt-get install nasm |
| gcc:无法找到库文件 | 缺少编译依赖 | 安装build-essential等依赖 |
| Module entry point not found | INF的ENTRY_POINT和代码函数名不一致 | 检查C源码里的函数名,改成UefiMain |
| FILE_GUID重复定义 | 模块GUID冲突 | 用uuidgen生成新的GUID替换 |
| Build failed: module incompatible | 架构参数错误或工具链不匹配 | 指定-a X64或-a IA32,更换-t参数 |
碰到编译错误不要慌,这几乎是必经之路。我见过最离奇的问题是在Windows下用GCC编译时,路径里带了空格,导致链接脚本找不到中间文件。所以如果你在Windows开发,务必把EDK2仓库放在一个不含空格的路径下,比如C:\edk2而不是C:\Users\My Name\edk2。
编译成功之后,还有一个容易忽略的问题:.efi文件虽然生成了,但可能是旧的。特别是当你改了.INF文件里的源文件列表,EDK2有时候不会自动检测到变动,需要你手动删掉Build目录下的中间文件再重新build。我的做法是遇到“改动没生效”的第一时间就执行:
rm -rf Build/MyAppPkg build -p MyAppPkg/MyAppPkg.dsc -m MyAppPkg/MyApp/MyApp.inf -a X64 -t GCC5清干净重编,效率反而更高,比在makefile日志里翻找原因来得直接。
5.2 加载运行阶段的问题定位
如果你的.efi在Shell下已经能正常加载,但运行起来表现怪异,比如没有输出就退出了,或者输出乱码,那就进入最需要耐心的调试阶段。
没有输出就退出,最常见的两个原因:程序入口函数里报错返回了非EFI_SUCCESS,或者程序本身因为访问了无效内存被固件捕获。UEFI Shell环境下不像操作系统里有完整的调试器,很多时候要靠着流输出和日志文件来定位问题。我在写的工具里都会加一个Log开关,把关键步骤的返回值都打印出来,比如内存分配是否成功、协议是否找到、文件写入是否完成。别嫌麻烦,这个习惯能帮你节省大量时间。
输出乱码的问题一般出在字符串编码上。UEFI Shell控制台要求的是UTF-16格式字符串,也就是宽字符,所以代码里必须用L"..."字面量。如果你错误地用了普通字符串,控制台会把它当作UTF-16来渲染,结果就是一堆奇怪的字符。还有一个相关的问题是Unicode机制,在源码文件保存为UTF-8编码时,编译后会有些微妙问题,所以我建议源码文件统一保存为UTF-8无BOM格式,字符串字面量用宽字符。
电源管理相关的问题也常碰到。某些平台在Shell下把处理器频率调度简化了,导致程序里如果使用了忙等待延时,实际等待时间会比预期长很多。遇到这类时序问题,不要怀疑自己的代码,先确认平台是否开启了SpeedStep或者类似技术,再考虑改用定时器协议统一处理时延。
最后再分享一个我私藏的小技巧:Shell下运行EFI程序时,如果意外卡死,别急着断电。可以先按Ctrl+Break组合键中断运行,Shell会尝试返回命令行,很多时候能救回来。这个功能在早期的Shell版本里并不完善,但在较新的固件Shell里已经很稳定了,有效避免了你因为频繁断电导致U盘分区表损坏的悲剧。我自己用这个技巧保住了好几个U盘。