2021年8月14日,我花了一整天把 EDK II 的编译环境彻底跑通,从拉源码、搞子模块、编 BaseTools,到最后用 QEMU 把 OVMF 启动起来,整个过程比预想中曲折,但也比想象中过瘾。这篇文章就把那次 EDK II 编译的完整过程原原本本写下来,包括环境怎么搭、参数怎么选、报错怎么排。如果你正打算入门 UEFI 固件开发,或者之前跟着教程编 EDK II 一直卡在各种莫名其妙的问题上,这篇内容应该能帮你少走不少弯路。
1. 动手之前:EDK II 编译环境的版本选型与依赖准备
很多第一次接触 EDK II 的朋友,上来就是git clone+build,结果卡在环境问题上。我那次也是这么过来的,所以先说说版本和依赖,这两件事没做好,后面全是徒劳。
1.1 为什么要选 stable tag,而不是直接用 master
EDK II 的代码仓库是托管在 GitHub 上的 tianocore/edk2。2021年8月中旬那阵子,master 分支上的提交非常频繁,几乎每天都有改动,包括工具链脚本、模块接口、PCD 定义都在不停调整。如果直接拿 master 来编译,今天能过,明天换个 commit 可能就编不过了,这类问题跟你的代码没关系,纯粹是基线不稳定。
所以我那次直接切到了当时的稳定 tag。edk2-stable202105 是 2021 年 5 月发布的版本,edk2-stable202108 要到 8 月底才正式发布,8月14日那天稳定可用的就是 edk2-stable202105。切 tag 的好处很明显:社区里别人遇到的问题、解决方案,都是基于这个版本记录的,你照着做能复现,出了问题也好搜。
再给第一次接触的朋友解释一句,EDK II 到底是什么。简单说,它是 UEFI 固件开发的标准框架,由 Intel 发起、TianoCore 社区维护,目前绝大多数 x86 平台的开源 UEFI 固件、驱动、引导程序都是基于它开发的。OVMF 是 EDK II 面向 QEMU/KVM 虚拟机的一个平台实现,编译难度适中,产物可以直接用虚拟机跑起来,特别适合作为学习 EDK II 编译的入门目标。
git clone https://github.com/tianocore/edk2.git cd edk2 git checkout edk2-stable202105这里有一个细节:clone 的时候我没有加--recursive,而是在 checkout 到稳定 tag 之后再统一更新子模块。原因后面会讲,先记住这个顺序比较稳妥。
1.2 编译 EDK II 需要准备的“底层零件”
EDK II 的编译跟平时编译一个 Linux 内核或者 CMake 项目不太一样,它除了需要常规的 C 编译器之外,还依赖几个专门的小工具。我那次用的是 Ubuntu 20.04,下面这套命令基本能把依赖装齐:
sudo apt-get install -y build-essential uuid-dev nasm iasl python3 python3-distutils逐个说下它们的作用,这样你以后在别的发行版上也知道该装什么。
build-essential 提供 gcc、make 这些基础编译工具,这个不用多解释。uuid-dev 提供 libuuid 头文件和库,EDK II 在 Linux 下编译某些工具模块时会用到,缺了它会在链接阶段报找不到libuuid.a。
NASM 是 x86 汇编器。EDK II 里有一些很底层的模块,比如 ResetVector,是用汇编写的,必须靠 NASM 才能编译。缺 NASM 的话,build 很可能会直接报Could not find the NASM assembler。
IASL 是 ACPI 编译器,用于处理 DSDT/SSDT 这类 ACPI 表。如果只是编 OVMF,有些平台配置不一定会触发 ACPI 源码编译,但为了保险,建议装上。
Python 3 是构建脚本的运行环境。这里要强调一下,2021 年的时候 EDK II 已经基本完成了从 Python 2 到 Python 3 的切换。如果你在某个老旧教程里看到要求安装 Python 2,那已经过时了。我一开始用的系统自带的 Python 3.8,跑 edksetup.sh 和 build.py 都很正常。
所有这些依赖,就像做菜之前要备好的调料,缺一样,后面做出来的味道就不对。而且 EDK II 的报错信息有时候并不直观,比如 NASM 缺失,它不会告诉你“请安装 nasm”,而是会在工具链检测阶段报一个比较隐晦的错误。与其到时候猜,不如先装全。
2. 源码拉取与 BaseTools 编译:把工具链先盘活
环境依赖装好之后,下一步是拉源码和编译 BaseTools。很多新手在这一步容易操作顺序颠倒,我得把逻辑讲清楚。
2.1 子模块更新:少了 openssl 后面全是坑
第一次 clone EDK II 的人,如果不看文档直接编,大概率会挂在 CryptoPkg 上,报错内容各式各样,比如找不到某个 OpenSSL 头文件,或者在链接阶段报一堆undefined reference。
问题根源其实是子模块没有拉全。EDK II 仓库里嵌套了好几个独立的 Git 仓库,最重要的就是CryptoPkg/Library/OpensslLib/openssl,它指向 OpenSSL 源码的某个固定 commit。EDK II 的 HTTPS、HASH、签名验证等一堆功能都依赖 OpenSSL,而 EDK II 官方没有把 OpenSSL 源码直接放进主仓库,而是用 git submodule 的方式引用。
我那次的操作顺序是这样:
cd edk2 git checkout edk2-stable202105 git submodule update --init --recursive之所以在 checkout 之后再更新子模块,是因为不同 tag 对应的子模块 commit 不一样。如果你 clone 的时候就--recursive,默认会把 master 对应的子模块拉下来,再切到别的 tag 时子模块可能不会自动跟着变,容易造成版本不一致。先切 tag 再 submodule update,才能保证子模块和主仓库的版本匹配。
--recursive参数是因为子模块里还可能嵌套子模块,EDK II 不止 OpenSSL 一处,ArmPkg 里也有子模块依赖。加了这个参数可以一次性全部拉取,省得后面报错了再补。
2.2 BaseTools 和 edksetup.sh 到底在干什么
源码就绪之后,第一件要编译的东西不是固件本身,而是 BaseTools。很多初学者不理解,怎么 EDK II 编个固件还得先编一套工具出来?其实这套工具就是 EDK II 构建系统自身的基础设施,包括build.py、GenFw、GenFv、GenSec、GenCrc32等等。
打个比方,EDK II 的构建过程很像是“用一套工具来加工另一套工具,再用加工出来的工具去生产固件”。这些工具负责把编译出来的 ELF 或者 PE/COFF 文件做各种转换、封装成 Firmware Volume、再合成最终的.fd固件镜像。如果 BaseTools 编译有问题,后面所有步骤都跑不起来。
编译 BaseTools 的命令很简单:
make -C BaseTools -j$(nproc)这里-C BaseTools是让 make 进入 BaseTools 目录执行构建,-j$(nproc)是按 CPU 核数并行编译。整个过程一般一两分钟就结束,如果你看到make输出一串 gcc 命令并且在最后顺利退出,说明 BaseTools 没问题。
接下来是source edksetup.sh。这个脚本是 EDK II 的环境初始化入口,它会做几件事:设置WORKSPACE、EDK_TOOLS_PATH等环境变量,把 BaseTools 的二进制目录加入PATH,并且在 edk2 目录下创建Conf文件夹,生成target.txt、tools_def.txt、build_rule.txt这三个初始配置文件。
这里有两点要特别注意。第一,每次新开一个终端窗口,都要重新在 edk2 目录下执行source edksetup.sh,否则环境变量丢了,build命令会提示找不到。第二,Conf/target.txt是默认编译配置,里面定义了编译哪个平台、哪个架构、用哪套工具链,是 DEBUG 还是 RELEASE。你可以修改它,也可以不管它,后面直接用命令行参数覆盖。我那次刚开始没搞懂这两者的关系,走了不少弯路,后面专门写一节讲这个。
3. 核心编译实战:以 OvmfPkg 为例跑通全流程
环境没问题之后,真正的编译试炼就开始了。我选择的第一块试验田是 OvmfPkg,也就是 OVMF。
3.1 target.txt 还是 build 命令行参数?
在Conf/target.txt里,常改的配置有这么几项:
ACTIVE_PLATFORM = OvmfPkg/OvmfPkgX64.dsc TARGET = DEBUG TARGET_ARCH = X64 TOOL_CHAIN_TAG = GCC5ACTIVE_PLATFORM指定编译哪个平台的描述文件(.dsc),TARGET是 DEBUG 还是 RELEASE,TARGET_ARCH是目标架构,TOOL_CHAIN_TAG是工具链标签。
我个人的建议是,第一次编译直接改 target.txt 也可以,但更推荐用命令行参数,因为直观,而且不用反复改文件:
build -a X64 -p OvmfPkg/OvmfPkgX64.dsc -t GCC5 -b DEBUG-a指定架构,-p指定平台 DSC 文件,-t指定工具链,-b指定构建类型。这条命令和改 target.txt 的效果是一样的。
TOOL_CHAIN_TAG这个地方有个比较迷惑人的点。GCC5 这个标签名字里带着 5,但并不是说只有 GCC 5 能用。它实际上代表的是“GCC 5 及以上版本通用的工具链配置”,我那次系统里装的是 GCC 9,用 GCC5 标签完全没问题。EDK II 工具链命名的历史遗留问题不少,CLANGPDB、VS2019、GCC5 这些标签,名字只是代号,别被名字误导。
3.2 从 .dsc 到 .fd:一个 EDK II 模块的编译流程
第一次完整跑 EDK II 编译的时候,我对它的内部流程其实是一头雾水的。等看多了 BUILD_LOG 才慢慢摸清楚,它跟普通 C 项目编译有着本质不同。普通应用编译,无非是源码、库、链接,最后生成可执行文件。EDK II 要多好几道工序。
先说入口。build命令首先读取.dsc文件,这个文件描述一个平台包含哪些模块、哪些 PCD 配置、哪些固件卷(FV)。然后构建系统会根据.dsc和每个模块的.inf文件,自动生成一批代码,包括AutoGen.c和AutoGen.h。
这里就涉及到一个很关键的机制:PCD。PCD 是 EDK II 的配置项机制,类似编译期可以配置的全局变量。你可以在.dsc里给某个模块设置 PCD 的值,构建系统生成 AutoGen 代码时,会把这些值固化到生成的 C 代码里。所以有些 PCD 的值,你改了之后必须重新编译才生效,不是运行期能动态修改的。
接下来才是我们熟悉的编译动作。每个模块.inf文件里列出的.c文件被 gcc 编译成目标文件,再链接成可执行文件。但这里生成的还不是最终的.efi文件,而是一个中间格式。以 GCC 工具链为例,链接出来的其实是一个 ELF 文件,需要经过GenFw这个工具转换成 UEFI 标准的 PE/COFF 格式,这才是真正的.efi。
再往下就是 EDK II 比较独特的地方。单个.efi文件不能直接启动,它需要被打包进 Firmware Volume(固件卷)。这个过程由GenFv完成,它会把多个模块的.efi以及一些元数据、GUID、校验信息封装成一个卷,类似把所有零件放进一个货架,并且贴上位置标签。最后,一个平台可能有多个 FV,还需要把 FV 和变量区域等拼接成最终的.fd文件,也就是整个固件的镜像。
为了验证这个流程,我特意翻过编译日志。在Build/OvmfX64/DEBUG_GCC5/目录下,你能看到FV目录里放着OVMF.fd,而X64目录下则是各个模块的.efi文件。我第一次看到列表里密密麻麻的.efi时,才真正意识到一个 UEFI 固件里到底集成了多少个模块。
3.3 编译完成后的产物验证
编译结束后,最重要的产物体现在:
Build/OvmfX64/DEBUG_GCC5/FV/OVMF.fd这个文件的大小一般是 2MB 左右,也就是 0x200000 字节。看到它生成,编译就算成功了,但还不够,我建议一定要实际跑一下,只有跑起来才能确认固件内容是真的可用的。
我用 QEMU 验证的命令是:
qemu-system-x86_64 -bios Build/OvmfX64/DEBUG_GCC5/FV/OVMF.fd -m 2048-bios指定固件镜像,-m 2048给虚拟机分配 2GB 内存。运行后,如果屏幕上出现 TianoCore 的 logo,或者进入 UEFI Shell 界面,说明整个编译链彻底通了。那一刻我心里才踏实,之前所有折腾都值了。
另外提一句,OVMF 编译产物通常不只是OVMF.fd一个文件,还会有拆分出来的OVMF_CODE.fd和OVMF_VARS.fd。前者是代码区域,后者是 NVRAM 变量区域。在真实场景里,把这两部分分开刷写或者挂载到虚拟机里使用,会更灵活。入门阶段直接用合一的OVMF.fd就行。
4. 编译进度的直观感受与提速技巧
这一节不聊原理,聊聊实践经验。很多人在编译 EDK II 时问得最多的问题就是:怎么这么慢?怎么才能快一点?
4.1 一次完整编译大概要多久
我当时的机器是 8 核 16GB 内存,首次编译 OVMFPkg,差不多花了 8 分钟左右。如果机器是 4 核,可能要 20 分钟以上。这里说的首次编译,是指从零开始编译整个平台的所有模块,涉及的模块数量非常多,每个模块都要经历预处理、编译、链接、转换、打包,工作量远远大于编一个普通应用。
所以如果你第一次编 OVMF,看到终端里不断刷屏,持续好几分钟甚至十几分钟,这是正常现象,别以为卡死了。判断有没有在正常工作,可以看编译日志的滚动,它会输出正在编译的模块路径。如果长时间停在同一行不动,那才要考虑是不是真的有问题。
但要注意,首次编译完成后,再次编译会快很多,因为 EDK II 构建系统支持增量编译。你只改了一个模块的代码,重新运行 build,它会跳过其他没变化的模块,只编译受影响的部分。我后来一天之内反复改了十几次代码做实验,每次编译都只要十几秒,就是这个原因。
4.2 让 EDK II 编译变快的几个实用招数
第一,并行编译参数要拉满。build 命令支持-j参数,类似 make 的并行度。我常用的写法是:
build -a X64 -p OvmfPkg/OvmfPkgX64.dsc -t GCC5 -b DEBUG -j $(nproc)$(nproc)会自动获取 CPU 核数,这样构建系统会同时启动对应数量的任务。不过这里有个小提醒:内存不够大的机器,并行度过高可能导致内存爆掉,编译进程被系统杀掉。我试过在 4GB 内存的机器上拉满并行度,直接 OOM。建议内存不大的话把-j设置为核数的一半比较稳。
第二,用-m参数指定单一模块编译。如果你只是在调试某一个驱动,不需要每次编整个固件。build 命令支持-m参数,后面接模块的.inf文件路径。比如只想编译某个驱动,可以这么写:
build -a X64 -p OvmfPkg/OvmfPkgX64.dsc -t GCC5 -b DEBUG -m MdeModulePkg/Universal/DriverSampleDxe/DriverSampleDxe.inf这样会把该模块及其依赖编译出来,速度非常快。但要注意,它不会生成最终的.fd固件镜像,只编译模块本身,适合日常开发验证语法和链接错误。
第三,有条件的话把 Build 目录放到内存盘。这是一个比较高级但也非常实用的技巧。EDK II 编译过程中会产生大量小文件,频繁读写磁盘,如果把输出目录放到/dev/shm(内存文件系统),速度提升非常明显。我做过实验,首次全量编译能从 8 分钟降到 4 分钟左右。做法是把Build目录做软链接指向内存盘:
mkdir -p /dev/shm/edk2build ln -s /dev/shm/edk2build Build不过要记住,重启之后内存盘里的内容会清空,所以这只适合临时用来加速,不适合保存正式产物。
5. 常见问题与排查技巧实录
EDK II 编译报错的信息量很大,但很多报错其实都指向几个常见根因。把我踩过的坑和扒过的日志整理一下,按问题类别列个速查。
5.1 环境类问题速查
| 报错现象 | 根本原因 | 解决办法 |
|---|---|---|
build: command not found | 没有执行source edksetup.sh,或换终端后环境丢了 | 在 edk2 目录下重新执行source edksetup.sh |
Could not find the NASM assembler | NASM 未安装或版本过旧 | sudo apt-get install nasm,并确认nasm -v能正常输出 |
| 找不到 iasl / ACPI compiler | IASL 未安装 | 安装iasl包 |
出现 Python 2 的语法报错,如print语句报 SyntaxError | 有旧的 Python 2 脚本混入构建流程 | 确认默认 python 是 Python 3,卸载或屏蔽系统残留的 Python 2 |
Tool chain [GCC5] was not found | 环境变量不全,或 GCC 版本与工具链标签不匹配 | 重新 source edksetup.sh,检查gcc --version,确认 GCC 5 及以上 |
链接时报错找不到libuuid | uuid-dev 未安装 | sudo apt-get install uuid-dev |
这里想多说一句,build: command not found是我见过最多的初级问题。很多人下载了源码,也 make 了 BaseTools,但开新终端后直接敲 build,系统当然找不到。EDK II 的环境是会话级的,不像系统全局变量,所以养成习惯:每次进终端先source edksetup.sh,再执行 build。
5.2 EDK II 特有的编译错误实例
第一类是我自己踩过的一个坑。第一次编译 OVMF 时,编到 CryptoPkg 直接失败,报错是找不到某个 OpenSSL 头文件。我一开始以为是系统缺包,装了一堆 OpenSSL 相关的库,没用。后来才发现是子模块没拉全。重新执行git submodule update --init --recursive之后,这个错误就消失了。
这里补一句原因:EDK II 不是用系统自带的 OpenSSL,而是用固定版本的 OpenSSL 子模块源码直接参与编译。所以就算你系统里装了 OpenSSL,子模块缺失照样编不过,两者没有关系。
第二类是针对.dsc配置的错误。有时候你会看到类似PCD ... not found或者PCD ... used in ... is not declared。这种错误通俗地说,就是某个模块在.inf里声明了要用某个 PCD,但它在.dsc或.dec里没有定义或者没有赋初值。解决思路就是去对应.dec文件确认 PCD 声明的位置,再到.dsc里补上赋值。
第三类是GenFw相关的错误,比如:
GenFw: ERROR 3000: Invalid这类错误通常是链接生成的 ELF 文件有问题,或者模块用了当前工具链不支持的编译选项。我第一次遇到时完全没有头绪,后来通过查看Build/OvmfX64/DEBUG_GCC5/BUILD_LOG.txt的日志才定位到具体是哪个模块的哪次链接出了问题。EDK II 的报错有个特点:真正的错误往往是最早出现的那一个,后面的报错基本都是连锁反应。所以排查时,不要盯着最后一行看,要看第一个 error 出现在哪里。
排查这类问题,我的通用方法论是三步走。第一步,先检查环境类问题,比如依赖缺失、子模块缺失,这类问题占了六七成。第二步,打开BUILD_LOG.txt,用grep -n "error"过滤出所有错误行,找到第一个错误点。第三步,围绕第一个错误定位到具体模块,再搜索这个模块的.inf和.dsc,看是不是配置问题。大部分编译问题都能靠这三步解决。
还有一个容易被忽略的问题:如果build命令执行到一半被 Ctrl+C 打断了,或者编译进程因为 OOM 被杀,工作区里可能会留下一些不完整的中间文件。重新编译时偶尔会出现莫名其妙的错误。遇到这种情况,可以先清理再重编:
rm -rf BuildEDK II 没有提供标准的 clean 命令,直接删Build目录是最干净的清理方式。当然,这会丢掉所有增量编译的缓存,下次全量编译会慢一些,但至少能排除脏文件的干扰。