4、UEFI工程结构:EDK2目录结构、.dsc/.fdf/.dec文件解析、Package与Module概念

好,咱们今天聊点实在的。EDK2 的工程结构,说白了就是 UEFI 固件开发的「工地现场」。你拿到一个 BIOS 项目,第一件事不是写代码,而是搞清楚这个工地怎么布局的。我刚开始接触 EDK2 的时候,打开目录一看,好家伙,几百个文件夹,头皮发麻。后来摸清楚了,其实就那么几块东西。

4.1 EDK2 顶层目录结构

EDK2 的根目录,你 clone 下来之后,会看到一堆文件夹。我挑几个最核心的说说:

  • MdePkg:这是基础包,定义了 UEFI 规范里的各种数据类型、协议、PPI 和 GUID。说白了就是「公共头文件库」。你写任何模块,几乎都要依赖它。
  • MdeModulePkg:在 MdePkg 之上,提供了一些通用的模块实现,比如磁盘驱动、控制台输出、Boot Manager 等。属于「半成品」,拿来就能用。
  • IntelFrameworkPkg:这是 Intel 的框架包,里面封装了 Framework 规范(PI 规范的前身)的一些接口。现在新项目用得少了,但老项目里还常见。
  • UefiCpuPkg:CPU 相关的驱动和库,比如 MP 初始化、SMM、CpuIo 等。做 x86 平台绕不开它。
  • ArmPkg / ArmPlatformPkg:ARM 架构的包。做服务器或者嵌入式 UEFI 会用到。
  • OvmfPkg:QEMU 虚拟机的 UEFI 固件。我经常拿它做实验,不用硬件就能调试,非常方便。
  • NetworkPkg:网络协议栈,包括 HTTP Boot、iSCSI、PXE 等。
  • ShellPkg:UEFI Shell 的实现。调试的时候,进 Shell 敲命令,比看日志快多了。
我的习惯: 拿到一个新项目,先看 Conf/target.txt 和 Conf/tools_def.txt。这两个文件决定了编译器和平台配置。很多新手编译不过,就是这里没配对。

4.2 Package 与 Module 的概念

EDK2 里有两个基本概念:Package 和 Module。

Package 是一个逻辑上的「包」,它把一组相关的模块、库、头文件、PCD 定义、甚至 GUID 声明打包在一起。每个 Package 都有一个 .dec 文件(Declaration File),用来声明这个包对外暴露了什么。

Module 是具体的可执行单元。它可以是:

  • DXE 驱动(.efi 文件)
  • PEIM(PEI 阶段的模块)
  • 应用程序(比如 UEFI Shell 下的工具)
  • 库实例(.inf 文件描述)

每个 Module 都有一个 .inf 文件(Module Information File),描述这个模块的源文件、依赖的库、PCD 设置、编译选项等。

一句话总结: Package 是「文件夹+接口声明」,Module 是「具体的 .efi 或 .lib」。一个 Package 里可以包含几十个 Module。

4.3 .dec 文件解析

.dec 文件是 Package 的「身份证」。它告诉编译器和其它 Package:我这个包里有什么东西可以给你用。

一个典型的 .dec 文件结构如下:

#
# MdePkg/MdePkg.dec
#

[Defines]
  DEC_SPECIFICATION              = 0x00010005
  PACKAGE_NAME                   = MdePkg
  PACKAGE_GUID                   = 1E73767F-8A52-4b2c-9E79-6B6B3B2B4C3E
  PACKAGE_VERSION                = 1.08

[Includes]
  Include                        # 公共头文件路径

[LibraryClasses]
  BaseLib|Include/Library/BaseLib.h
  UefiLib|Include/Library/UefiLib.h
  DebugLib|Include/Library/DebugLib.h

[Guids]
  gEfiGlobalVariableGuid         = { 0x8BE4DF61, 0x93CA, 0x11D2, { 0xAA, 0x0D, 0x00, 0xE0, 0x98, 0x03, 0x2B, 0x8C } }

[Protocols]
  gEfiSimpleTextOutProtocolGuid  = { 0x387477C1, 0x69C7, 0x11D2, { 0x8E, 0x39, 0x00, 0xA0, 0xC9, 0x69, 0x72, 0x3B } }

[PcdsFixedAtBuild]
  gEfiMdePkgTokenSpaceGuid.PcdDebugPropertyMask|0x1F|UINT8|0x00000001

关键段解释:

段名 作用
[Defines] 包的基本信息,包括 GUID、版本号。GUID 必须唯一,不能跟别的包冲突。
[Includes] 声明这个包对外暴露的头文件路径。别的模块 #include 时,编译器会来这里找。
[LibraryClasses] 声明这个包里有哪些库类(Library Class)。注意是「类」不是「实例」,具体实现由 .inf 决定。
[Guids] 定义这个包里使用的 GUID。GUID 是 UEFI 世界的「身份证号」,协议、PPI、变量、事件都靠它区分。
[Protocols] 定义这个包里提供的协议 GUID。DXE 阶段驱动之间通信,就是通过协议。
[PcdsFixedAtBuild] 编译时确定的 PCD(Platform Configuration Database)。编译完就不能改了。
我曾经踩过的坑: 在 .dec 里加了一个新的 GUID,但忘了在 [Guids] 段里声明。结果编译时链接器报「未定义符号」,查了半天才发现是 .dec 没更新。记住:所有对外暴露的 GUID、Protocol、PCD,都必须先在 .dec 里注册。

4.4 .dsc 文件解析

.dsc 文件(Platform Description File)是「项目配置文件」。它定义了整个固件要包含哪些模块、库、PCD 值、编译选项等。你可以把它理解成固件的「购物清单」。

一个典型的 .dsc 文件结构:

#
# OvmfPkg/OvmfPkgX64.dsc
#

[Defines]
  PLATFORM_NAME                  = Ovmf
  PLATFORM_GUID                  = 5a9e7754-8a4b-4b3c-9e79-6b6b3b2b4c3e
  PLATFORM_VERSION               = 0.1
  DSC_SPECIFICATION              = 0x00010005
  OUTPUT_DIRECTORY               = Build/OvmfX64
  SUPPORTED_ARCHITECTURES        = X64
  BUILD_TARGETS                  = DEBUG|RELEASE|NOOPT
  SKUID_IDENTIFIER               = DEFAULT

[LibraryClasses]
  BaseLib|MdePkg/Library/BaseLib/BaseLib.inf
  UefiLib|MdePkg/Library/UefiLib/UefiLib.inf
  DebugLib|MdePkg/Library/BaseDebugLibSerialPort/BaseDebugLibSerialPort.inf

[LibraryClasses.common.DXE_DRIVER]
  UefiBootServicesTableLib|MdePkg/Library/UefiBootServicesTableLib/UefiBootServicesTableLib.inf

[Components]
  OvmfPkg/PlatformPei/PlatformPei.inf
  OvmfPkg/BdsDxe/BdsDxe.inf
  MdeModulePkg/Universal/Disk/DiskIoDxe/DiskIoDxe.inf

关键段解释:

段名 作用
[Defines] 平台基本信息,包括输出目录、支持的架构、编译目标(DEBUG/RELEASE)。
[LibraryClasses] 全局的库实例映射。把「库类」映射到具体的「库实现 .inf」。比如 BaseLib 这个类,用 BaseLib.inf 这个实现。
[LibraryClasses.common.xxx] 按模块类型覆盖库映射。比如 DXE_DRIVER 类型的模块,使用特定的库实例。
[Components] 列出所有要编译进固件的模块。每个模块对应一个 .inf 文件。
[PcdsFixedAtBuild] 覆盖 PCD 的默认值。这里设置的值会覆盖 .dec 里的默认值。
我的经验: 调试阶段,我习惯在 [Components] 里只加必要的模块,把不相关的注释掉。这样编译速度快很多。等基本功能调通了,再全部打开做集成测试。

4.5 .fdf 文件解析

.fdf 文件(Flash Description File)是「固件布局文件」。它决定了最终的 BIOS 镜像(.fd 文件)里,各个模块放在 Flash 的什么位置。说白了,就是「固件在 Flash 里的地图」。

一个简化的 .fdf 文件结构:

#
# OvmfPkg/OvmfPkgX64.fdf
#

[FD.BFV]
BaseAddress   = 0xFFF00000
Size          = 0x00100000
ErasePolarity = 1

[FV.SECFV]
FvBaseAddress   = 0xFFF00000
FvSize          = 0x00020000
BlockSize       = 0x1000
NumBlocks       = 32

FILE FV_IMAGE = 8C8CE578-8A3D-4F1C-9935-896185C32DD3 {
  SECTION PE32 = OvmfPkg/Sec/SecMain.inf
  SECTION UI = "SecMain"
}

[FV.DXEFV]
FvBaseAddress   = 0xFFF20000
FvSize          = 0x000E0000
BlockSize       = 0x1000
NumBlocks       = 224

FILE DRIVER = 5B3B3B2B-4C3E-4B3C-9E79-6B6B3B2B4C3E {
  SECTION PE32 = MdeModulePkg/Universal/Disk/DiskIoDxe/DiskIoDxe.inf
  SECTION UI = "DiskIoDxe"
}

关键概念:

  • FD (Flash Device):代表整个 Flash 芯片。定义基地址、大小、擦除极性。
  • FV (Firmware Volume):Flash 里的一个逻辑分区。每个 FV 有自己的基地址、大小、块大小。
  • FILE:FV 里的一个文件。每个文件对应一个模块(.efi 或 .bin)。文件类型可以是 DRIVER、FV_IMAGE、RAW 等。
  • SECTION:文件内部的段。比如 PE32 段包含可执行代码,UI 段包含用户可读的名称。
注意: .fdf 里的 BaseAddress 必须跟硬件实际映射的 Flash 地址一致。我见过有人把基地址写错了,结果固件刷进去,CPU 根本找不到入口,直接变砖。所以,.fdf 的地址一定要跟硬件原理图对一遍。

4.6 三者的关系

这三兄弟的关系,我用一张图来展示:

EDK2 工程结构:.dec / .dsc / .fdf 关系图 .dec 文件 声明接口、GUID、PCD .dsc 文件 选择模块、库、PCD 值 .fdf 文件 定义 Flash 布局 引用 引用 Build 工具(BaseTools) 解析 .dsc / .fdf / .dec,生成 Makefile 并编译 输出:.fd 固件镜像 包含所有模块,按 .fdf 布局排列 说明:.dec 提供「零件规格」,.dsc 提供「零件清单」,.fdf 提供「装配图纸」 Build 工具根据这三者,最终生成可烧录的固件镜像 .fd

你看,.dec 定义了「有什么零件」,.dsc 决定了「要装哪些零件以及怎么配置」,.fdf 决定了「零件在板子上的位置」。Build 工具就是那个装配工,把这三份图纸合起来,生成最终的固件镜像。

4.7 实战建议

最后,给刚入门的同学几个建议:

  • 先从 OvmfPkg 入手:它是个完整的、可运行的平台,而且不需要硬件。你可以在 QEMU 上跑起来,然后试着改 .dsc 和 .fdf,看看效果。
  • 学会用 build -m 单独编译模块:不用每次都全量编译。比如 build -m MdeModulePkg/Universal/Disk/DiskIoDxe/DiskIoDxe.inf,只编译这一个模块,快很多。
  • 遇到链接错误,先查 .dsc 里的库映射:很多链接错误是因为某个库类没有映射到具体的实现。去 [LibraryClasses] 段里补上就行。
  • 调试时,在 .fdf 里加 RAW 类型的文件:比如放一个 DEBUG_IMAGE.bin,可以用来验证 Flash 布局是否正确。

核心记忆点: .dec 是「接口声明」,.dsc 是「项目配置」,.fdf 是「Flash 布局」。三者缺一不可,共同决定了固件长什么样。