一、引言
在 EDK II 工程中,INF、DSC、DEC 和 FDF 是有关模块,平台和包的重要配置文件。关于他们之间的关系,可能会容易混淆
- INF 中声明的库依赖,最终是如何找到具体实现的?
- DSC 为什么既能决定编译哪些模块,又能配置 PCD 和库实例?
- DEC 定义的 GUID、Protocol 和 PCD,如何被模块使用?
- 一个普通的 C 文件,最终是如何变成
.efi文件的? .efi文件又是如何被打包进固件卷(FV),最终生成可烧录的.fd镜像的?
要理解这些问题,不能只关注某一个配置文件,而应该从 EDK II 的整体设计出发,理解模块组织、依赖解析、代码生成、编译链接和固件打包之间的关系。
本文以一个简单的 UEFI 驱动 HelloDriver 为例,从代码结构出发,逐步分析 EDK II 的完整构建流程。
二、EDK II 的代码组织结构
EDK II 的代码组织主要涉及三个概念:Module、Package 和 Platform。
2.1 Module:最小的构建单元
Module 是 EDK II 中的基本构建单元,可以是:
- UEFI Application
- UEFI Driver
- DXE Driver
- PEIM
- Library Instance
- ...
一个模块通常由源文件和对应的 INF 文件组成。
例如:
MyBoardPkg/
└── Drivers/
└── HelloDriver/
├── HelloDriver.inf
└── HelloDriver.c
其中,HelloDriver.c 存放实际的 C 代码,HelloDriver.inf 则描述模块的元数据,包括:
- 模块名称、GUID 和类型
- 源文件列表
- 用到的 Pkg 的 dec
- 依赖的 Library Class
- 使用的 PCD、Protocol 和 GUID
- 模块入口点和依赖表达式等
INF 可以理解为模块的说明书。构建系统通过它了解模块是什么、需要什么,以及应该如何参与构建。
2.2 Package:相关模块与公共接口的集合
Package 用于组织功能相关的模块,通常包含一个 DEC 文件。
例如:
MyBoardPkg/
├── MyBoardPkg.dec
├── Include/
└── Drivers/
└── HelloDriver/
├── HelloDriver.inf
└── HelloDriver.c
DEC 主要描述 Package 对外提供的公共定义,例如:
- 公共头文件目录
- Library Class 声明
- GUID
- Protocol
- PCD 声明及其默认值和类型信息
例如,某个 Package 可以通过 DEC 声明一个 PCD,供其他模块引用。
需要注意,DEC 主要承担声明和元数据描述的职责,并不负责为模块选择最终的库实现,也不负责决定整个固件镜像的布局。
2.3 Platform:最终的构建与集成目标
Platform 描述一次具体的平台构建,通常由 DSC 文件定义,并可以配合 FDF 文件生成完整固件镜像。
例如:
MyBoardPkg/
├── MyBoardPkg.dec
├── MyBoardPkg.dsc
├── MyBoardPkg.fdf
└── Drivers/
└── HelloDriver/
├── HelloDriver.inf
└── HelloDriver.c
DSC 负责定义平台构建所需的核心信息:
- 本次构建包含哪些模块
- 各个 Library Class 使用哪个库实例
- 平台级 PCD 取值
- 构建选项及相关平台配置
FDF 则进一步描述固件镜像的组织方式,例如 Firmware Volume(FV)的布局、FV 中包含哪些模块,以及这些内容如何放入 Firmware Device(FD)。
因此,可以用一句话概括:
INF 描述模块,DEC 描述包的公共定义,DSC 描述平台如何构建,FDF 描述固件镜像如何组织。
三、INF、DSC、DEC、FDF
理解这四类文件的职责,是理解整个构建流程的关键。
3.1 INF:模块需要什么?
INF 描述单个模块的构建元数据。
例如,HelloDriver.inf 可以写成:
[Defines]
INF_VERSION = 0x00010005
BASE_NAME = HelloDriver
FILE_GUID = 5f8c9a1e-2b3d-4e6f-8a9b-0c1d2e3f4a5b
MODULE_TYPE = DXE_DRIVER
VERSION_STRING = 1.0
ENTRY_POINT = HelloDriverEntryPoint
[Sources]
HelloDriver.c
[Packages]
MdePkg/MdePkg.dec
MyBoardPkg/MyBoardPkg.dec
[LibraryClasses]
UefiDriverEntryPoint
DebugLib
UefiLib
[Protocols]
gEfiDriverBindingProtocolGuid
[Pcd]
gMyBoardPkgTokenSpaceGuid.PcdHelloMessage
-
MODULE_TYPE = DXE_DRIVER表明该模块是 DXE 驱动类型。 -
ENTRY_POINT声明了模块的逻辑入口函数。 -
[LibraryClasses]中声明的是抽象的库类,而不是具体的库实现。
例如:
[LibraryClasses]
DebugLib
这并不意味着模块已经确定使用哪个 DebugLib 实现。真正的映射通常由 DSC 完成。
此外,[Packages] 声明模块依赖的 Package。模块使用的相关公共定义,需要通过适当的 DEC 声明提供。
3.2 DEC:公共接口在哪里定义?
DEC 用于声明 Package 对外提供的公共定义。
例如:
[Defines]
DEC_SPECIFICATION = 0x00010005
PACKAGE_NAME = MyBoardPkg
PACKAGE_GUID = 9e8d7c6b-5a49-3827-1605-f4e3d2c1b0a9
PACKAGE_VERSION = 0.1
[Includes]
Include
[Guids]
gMyBoardPkgHelloGuid = { ... }
[PcdsFixedAtBuild]
gMyBoardPkgTokenSpaceGuid.PcdHelloMessage|L"Default hello"|VOID*|0x00010001
这里的 GUID 和 PCD 仅用于说明配置关系,实际项目中必须使用符合规范的定义,并保证相关 GUID、Token Space 和 Token Number 的一致性。
例如,DEC 声明了 PcdHelloMessage,模块便可以在适当的 INF 中引用它,平台再通过 DSC 为其配置实际值。
需要注意:DEC 中的声明并不意味着某个 PCD 一定会被编译进最终固件。它还需要满足模块依赖关系和平台构建配置等要求。
3.3 DSC:平台最终选择什么?
DSC 是平台构建配置的核心。
例如:
[Defines]
DSC_SPECIFICATION = 0x00010005
PLATFORM_NAME = MyBoardPkg
PLATFORM_GUID = 1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d
PLATFORM_VERSION = 0.1
OUTPUT_DIRECTORY = Build/MyBoardPkg
SUPPORTED_ARCHITECTURES = X64
BUILD_TARGETS = DEBUG|RELEASE
SKUID_IDENTIFIER = DEFAULT
[LibraryClasses]
UefiLib|MdePkg/Library/UefiLib/UefiLib.inf
UefiDriverEntryPoint|MdePkg/Library/UefiDriverEntryPoint/UefiDriverEntryPoint.inf
DebugLib|MdePkg/Library/BaseDebugLibSerialPort/BaseDebugLibSerialPort.inf
[PcdsFixedAtBuild]
gMyBoardPkgTokenSpaceGuid.PcdHelloMessage|L"Hello from MyBoardPkg"
[Components]
MyBoardPkg/Drivers/HelloDriver/HelloDriver.inf
当 HelloDriver.inf 声明需要 DebugLib 时,构建系统便可以根据 DSC 中的映射找到对应的库实例。
可以把这个过程理解为:
HelloDriver.inf
|
| 声明需要 DebugLib
|
DSC [LibraryClasses]
|
| 将抽象库类映射到具体实例
|
BaseDebugLibSerialPort.inf
|
|
编译并链接对应库代码
这正是 EDK II 的库类机制所提供的灵活性:模块依赖抽象接口,平台决定实际实现。
需要注意,示例中的库实例必须与实际模块的架构、模块类型和依赖关系兼容。真实平台的库映射往往比这个简化示例更加复杂。
另外,[Components] 决定平台构建中的模块集合。被其他模块引用的库实例,通常会通过依赖解析纳入构建,不必一律作为独立组件列出。
3.4 FDF:最终固件镜像如何组织?
FDF 主要描述固件镜像的布局与打包规则。
典型的结构是:
FD(Firmware Device)
└── FV(Firmware Volume)
├── FFS File:HelloDriver
│ ├── PE32 Image
│ ├── UI Section
│ └── DEPEX Section
└── 其他固件模块
FD 表示固件设备映像,通常对应一段待写入 Flash 的镜像数据。
FV 是固件卷,用于组织一组固件文件。每个固件文件可以包含 PE32、DXE_DEPEX、UI 等 Section。
FDF 可以指定:
- FD 的大小、基地址和布局
- FV 的属性
- FV 中包含哪些模块
- 不同模块类型使用什么打包规则
例如:
[FV.FVMAIN]
FvNameGuid = 7b9a1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d
INF MyBoardPkg/Drivers/HelloDriver/HelloDriver.inf
这段配置表示将指定模块纳入 FVMAIN 的固件卷构建。
但是,FDF 并不负责代替编译器编译 C 代码,也不负责决定所有平台模块的依赖关系。它主要消费前面构建阶段产生的模块产物,并按照固件布局规则进行组织。
此外,示例中的 GUID、Flash 地址和布局参数只是演示用途,不能直接用于真实硬件。实际配置需要符合目标平台的 Flash 容量、FV 约束和启动固件布局。
四、EDK II 完整编译流程
- 从一个 C 文件到最终的固件映像,EDK II 的构建流程可以概括为:
- 通过 INF 描述模块的源文件、类型和依赖。
- 通过 DEC 提供公共接口和相关元数据。
- 通过 DSC 决定平台构建哪些模块、选择哪些库实例,并配置平台级参数。
- BaseTools 解析元数据、解析依赖并生成 AutoGen 文件和构建规则。
- 编译器将源文件和自动生成的代码编译为目标文件。
- 链接器生成相应的中间镜像,GenFw 对相关镜像进行处理,形成 EFI 模块产物。
- 当需要完整固件映像时,GenFds 根据 FDF 的规则组织 FFS、FV 和 FD,最终生成固件映像文件。
4.1 第一步:解析平台和模块配置
执行类似下面的命令:
build -p MyBoardPkg/MyBoardPkg.dsc -a X64 -b DEBUG -t GCC
构建系统首先读取 DSC,并根据指定的架构、构建目标和工具链确定本次构建的配置。
随后,系统读取 [Components] 中的模块 INF,并解析模块引用的 DEC、Library Class、PCD 等信息。
例如:
MyBoardPkg.dsc
|
+-- HelloDriver.inf
| |
| +-- MdePkg.dec
| +-- MyBoardPkg.dec
| +-- DebugLib
| +-- UefiLib
|
+-- 平台级 PCD 配置
|
+-- 工具链与构建选项
此时,构建系统需要确定模块的依赖关系、具体库实例和有效配置。
这里要区分两件事:
- DEC 提供公共定义及声明信息。
- DSC 提供平台级选择和配置。
DSC 中的库映射关系与 PCD 配置,会影响后续的代码生成和编译结果。
4.2 第二步:解析库依赖
AutoGen 是 EDK II 构建过程中非常重要的一环。
对于模块而言,INF 声明的是抽象的库类。例如:
[LibraryClasses]
UefiDriverEntryPoint
DebugLib
UefiLib
构建系统根据 DSC 的映射关系,找到具体的库实例 INF,再递归解析这些库实例所需的依赖。
随后,构建系统根据模块、库实例和平台配置生成相应的 AutoGen 文件。
典型产物包括:
AutoGen.c
AutoGen.h
AutoGen 文件不是开发者手动编写的业务代码,而是构建系统根据元数据自动生成的辅助代码。
它们可以包含或参与生成:
- 模块入口点相关的包装代码
- 库 Constructor 和 Destructor 调用列表
- PCD 访问所需的实例化数据
- 模块及库实例的相关配置
- 其他依赖模块类型和构建配置的自动生成内容
例如,一个模块依赖多个库类时,构建系统可能会把相关库实例的构造函数组织到生成的 ProcessLibraryConstructorList() 中。
需要强调的是,实际生成内容会根据模块类型、库实例和构建配置而变化,不能认为每个模块都会得到完全相同的 AutoGen 文件。
4.3 第三步:编译 C 文件与 AutoGen 文件
完成元数据解析和代码生成后,构建系统会调用实际的编译器。
例如:
HelloDriver.c
AutoGen.c
|
v
编译器
|
v
HelloDriver.obj
AutoGen.obj
库实例也会按照各自的构建配置编译成目标文件。
这里有一个重要的认识:
BaseTools 负责解析元数据、生成构建规则并协调工具链,而真正将 C 代码编译成机器指令的是所选用的本机编译器。
因此,EDK II 并不是重新实现了一套 C 编译器,而是在传统编译和链接工具之上,建立了一套适用于 UEFI 固件开发的元数据驱动构建系统。
4.4 第四步:链接生成 PE/COFF 镜像
编译器生成 .obj 后,构建系统会进一步执行链接。
对于常见的 X64 UEFI 模块,最终产物通常采用 PE32+ 格式,属于 PE/COFF 镜像体系。
可以将过程简化为:
HelloDriver.obj
AutoGen.obj
Library objects
|
v
Linker
|
v
PE/COFF image
不过,不同工具链的中间产物和命名并不完全相同。
例如,使用 MSVC 工具链时可能出现 .dll 形式的中间产物;使用 GCC 工具链时,可能使用 ELF 作为中间格式。具体流程由 EDK II 的工具链配置和对应的构建规则决定。
因此,不宜把"先生成 DLL,再由 GenFw 转换"理解为所有 EDK II 构建都必须经历的固定步骤。
4.5 第五步:GenFw 处理 EFI 模块
GenFw 是 EDK II BaseTools 中负责处理固件模块镜像的重要工具。
它可以对构建产生的相关镜像进行格式转换、检查和处理,生成后续固件构建所需的模块产物。
例如:
中间 PE/COFF 镜像
|
v
GenFw
|
v
HelloDriver.efi
需要注意,.efi 并不是一种与 PE/COFF 完全无关的独立可执行文件格式。UEFI 应用和驱动通常采用 PE/COFF 镜像格式,.efi 是常见的文件扩展名。
到这里,一个独立的 UEFI 模块已经具备了主要的可执行镜像产物。
但如果目标是生成完整的固件映像,还需要继续进行 FV 和 FD 的构建。
4.6 第六步:GenFds 生成 FV 和 FD
当平台配置了 FDF,并且构建目标需要生成固件映像时,构建系统会进一步执行固件打包阶段。
GenFds 根据 FDF 中的定义,组织前面生成的模块产物。
在这个过程中,需要区分几个不同的概念。
EFI 模块 是可执行镜像,例如 HelloDriver.efi。
FFS 文件是固件文件系统中的文件,通常包含模块的镜像和相关 Section。
FV 是固件卷,内部组织一个或多个 FFS 文件。
FD 是固件设备映像,描述最终固件数据在整个设备映像中的组织和布局。
因此,GenFds 并不是简单地将所有 .efi 文件拼接起来。它需要按照固件文件系统格式、FV 结构和 FD 布局规则构建相应数据。
另外,NVRAM 变量存储区是否被纳入某个 FD,以及它的实际内容如何初始化,取决于具体平台的 FDF 和其他固件配置。不能把 NVRAM 中运行时产生的所有变量都理解成构建时直接打包进去的数据。
五、AutoGen 与模块运行时入口
前面介绍了从源代码到 .efi 的构建过程。接下来,进一步分析一个模块被加载执行时发生了什么。
对于 UEFI 模块,INF 中声明的 ENTRY_POINT 是模块的逻辑入口。
例如:
[Defines]
MODULE_TYPE = DXE_DRIVER
ENTRY_POINT = HelloDriverEntryPoint
在典型的 EDK II 模块构建中,实际入口可能由相应的库入口包装层提供,再由包装层调用模块本身的入口函数。
例如,使用 UefiDriverEntryPoint 库的模块,其入口调用和初始化逻辑会涉及相应库实现与自动生成的代码。
对于包含相关库初始化机制的模块,可以看到类似以下的执行顺序:
// Call constructors for libraries.
ProcessLibraryConstructorList (ImageHandle, SystemTable);
// Call the module's entry point.
Status = ProcessModuleEntryPointList (ImageHandle, SystemTable);
// Process destructors for libraries.
ProcessLibraryDestructorList (ImageHandle, SystemTable);
这段代码表达了三个逻辑步骤:
- 执行相关库的 Constructor。
- 调用模块的 Entry Point。
- 执行相关库的 Destructor。
不过,这里必须明确一个边界:上述代码是用于说明入口包装机制的示意。具体调用方式、返回值处理、异常路径和析构时机,要以对应模块类型和入口库的实际实现为准。
尤其是 DXE 驱动,其入口函数返回后,驱动通常不会因为入口函数返回就立即结束整个生命周期。它可能已经注册了协议、安装了服务或完成了事件注册,后续行为由 UEFI 固件环境和驱动自身逻辑决定。
因此,不能把所有 UEFI 模块的生命周期都简单理解为"Constructor---业务函数---Destructor,然后程序结束"。
5.1 Constructor 和 Destructor 从哪里来?
库实例可以在其 INF 中声明 Constructor 和 Destructor 函数。
当模块依赖这些库实例时,构建系统会根据依赖关系和库的相关元数据,生成相应的调用列表。
例如:
HelloDriver
|
+-- UefiLib
|
+-- DebugLib
|
+-- OtherLibrary
假设这些库实例中的部分库声明了 Constructor,那么生成的 AutoGen 代码便可以组织相应的构造函数调用。
这也说明了为什么 AutoGen 不只是简单地生成几个变量:它还承担了把声明式依赖转化为模块初始化代码的工作。
至于 Destructor 是否被调用、何时被调用,以及模块卸载时如何清理资源,则取决于模块类型、所用入口库及其运行时管理机制。
六、把四类文件与整个流程对应起来
前面分别介绍了 INF、DSC、DEC 和 FDF。现在可以从构建阶段的角度重新梳理它们之间的关系。
| 文件 | 核心职责 | 主要影响的阶段 |
|---|---|---|
| INF | 描述模块类型、源文件和依赖 | 模块解析、依赖解析、编译 |
| DEC | 声明 Package 的公共接口和元数据 | 头文件、GUID、Protocol、PCD 等解析 |
| DSC | 定义平台组件、库实例映射和平台配置 | 依赖解析、AutoGen、编译配置 |
| FDF | 描述 FV、FD 和固件打包规则 | 固件文件组织、FV/FD 生成 |
这里还有一个值得注意的地方:FDF 与前三类文件的关注点并不相同。
INF、DEC 和 DSC 主要参与模块和平台的构建描述;FDF 则主要负责把已构建的模块组织成符合固件格式的映像。
同样,PCD 的声明、配置和最终存储方式也不是由单个文件独立完成的:
- DEC 提供 PCD 的声明信息。
- INF 表明模块对相关 PCD 的使用。
- DSC 提供平台配置,并参与构建时的 PCD 实例化。
- 对于需要在固件映像中组织特定数据的场景,还可能涉及其他构建规则与固件配置。
因此,只有把这些文件放到整个构建系统中观察,才能理解它们之间的分工。
七、EDK II 声明式构建的设计思想
EDK II 很精妙的地方在于,不仅是它如何生成 .efi 文件,更是它如何管理复杂的依赖关系。
假设一个驱动依赖多个库,而这些库又依赖其他库。如果全部采用手写 Makefile 的方式,开发者就需要自己维护依赖关系、编译顺序、构建选项以及不同平台的实现差异。
EDK II 则将这些信息拆分到不同层次:
- INF 描述模块的构建需求。
- DEC 描述公共定义。
- DSC 描述平台选择与配置。
- FDF 描述固件镜像的组织方式。
BaseTools 读取这些元数据,解析依赖关系,生成 AutoGen 文件和相应的构建规则,再调用外部工具链完成编译与链接。
这种设计使同一个模块可以在不同平台中复用,而不必直接绑定某个平台的所有库实现和配置。
例如,驱动只需要依赖抽象的 DebugLib,平台便可以根据需求选择串口输出、空实现或其他合适的 DebugLib 实现。
同样,平台也可以通过 PCD 改变模块的配置,而不必为每个配置变体修改模块源码。
这就是 EDK II 将模块复用、平台定制和固件集成解耦的核心思路。
Steady Pregress!