
先说一个我最近遇到的真实问题。某模块运行在 UEFI DXE 阶段需要消费另一个驱动暴露的协议接口代码逻辑看着完全没问题GUID 定义一样安装函数的返回值也是 Success但 LocateHandleBuffer 就是返回 NotFound。后来在 EDK2 源码里翻了大半天才发现安装协议的那一侧把 Protocol 挂到了驱动镜像自己的 Handle 上而消费方一直在按设备句柄去找。这个错不复杂但如果没有把 Protocol Handle 机制的底层关系想清楚碰上这类问题很容易绕圈子。对于刚接触 UEFI 开发、或者在写驱动和 Shell 工具时频繁遇到“协议定位不到”“接口明明存在却访问失败”这类问题的人来说Handle 和 Protocol 这两样东西几乎是必须迈过去的坎。这套机制是 UEFI 固件世界的公共服务骨架理解它比背一堆 API 签名有用得多。下面我把句柄数据库、协议接口布局、安装与发现流程、以及驱动模型里的实际用法完整梳理一遍。1. 为什么UEFI开发绕不开Handle和Protocol从一次“协议找不到”开始1.1 复现一下“明明有协议却定位不到”那个问题最开始的表现是驱动 A 加载成功代码里也确实调用了 InstallProtocolInterface返回 EFI_SUCCESS。驱动 B 随后通过 LocateHandleBuffer( ByProtocol, gMyProtocolGuid, ... ) 去找这个协议返回 EFI_NOT_FOUND。当时第一反应是怀疑 GUID 定义不一致但把两份头文件拉出来对比字节完全一样。又怀疑是编译顺序问题把两个模块重新编了一遍现象依旧。后来在调试器里看 InstallProtocolInterface 的入参发现安装时的 Handle 是gImageHandle也就是驱动 A 自己的镜像句柄。而驱动 B 查找时潜意识里认为“这个协议一定挂在被管理的设备句柄上”于是拿着设备控制器句柄去 ByProtocol 搜索。问题根源就在这里LocateHandleBuffer 的 ByProtocol 搜索是在整个句柄数据库里遍历所有 Handle看它们挂载的协议列表里有没有对应 GUID而不是只搜某个固定句柄。驱动 A 把协议装到了镜像句柄上驱动 B 却把它当成设备协议来用自然搜不到。实际调试中这类“协议存在但找不到”的根因其实非常集中我整理了一份排查方向可疑点常见表象优先排查方向GUID 不一致两端定义看似相同但字节序或某一段写错用构建产物里的 GUID 定义做二进制比对安装目标 Handle 错误协议装在镜像句柄上却按设备句柄去找打印安装时传入的 Handle确认它的用途搜索类型用错想找全部协议实例却只拿第一个确认 ByProtocol 和 AllHandles 语义安装时序不对消费方先跑生产方后跑检查 DXE 依赖或使用协议通知事件协议被 Reinstall/卸载中间有驱动做了 Uninstall检查 Uninstall 路径和调用顺序1.2 通俗模型Handle是门牌号Protocol是挂在门上的服务清单UEFI 里抽象东西的方式很直接所有可以被系统管理起来的“对象”比如一块磁盘、一个网卡、一个驱动镜像、一个控制台输出设备都会对应一个 EFI_HANDLE。Handle 本身不携带业务数据它的作用类似门牌号你不需要知道门背后是什么样的房间只需要知道“这个门牌号代表这个对象存在”。Protocol 则是挂在这个门牌号上的服务清单。一个 Handle 可以同时挂多个 Protocol比如一个磁盘控制器 Handle 上可能同时有 DevicePathProtocol、BlockIoProtocol、DiskIoProtocol。反过来说同一个 Protocol 也可以被安装到多个 Handle 上比如一个 USB 主控驱动可能会给每个 USB 端口都安装同一个 LeafProtocol。所以“找到某个设备”在 UEFI 里通常不是直接搜设备名称而是“找到挂着对应协议集合的 Handle”。理解了这一层再去看 LocateHandle、OpenProtocol、HandleProtocol 这些函数就会清楚很多它们做的事情本质是在门牌号和门上的服务清单之间做索引查询。2. 句柄数据库的内部结构EFI_HANDLE到底指向什么2.1 规范定义与EDK2实现里的“黑盒”UEFI 规范里EFI_HANDLE 被定义成VOID*并且明确告诉你这是不透明句柄不要对它的值做任何假设更不要试图解引用它。设计成这样是有原因的不同固件实现可以用完全不同的数据结构来管理句柄只要对外行为一致就行。EDK2 作为开源参考实现内部是用一个句柄数据库来管理的。DXE Core 里有一个全局链表每个节点对应一个句柄也就是内部结构通常叫 IHANDLE。IHANDLE 里除了签名、链表节点之外最重要的是一张协议接口链表每个节点对应一个已经安装到该句柄上的 Protocol。每个协议节点里存了 GUID、接口指针、打开记录列表等信息。内部字段作用大白话Signature校验结构有效性防止野生指针被当成句柄Protocols该句柄挂载的协议接口链表门牌号下的服务清单NotifyList监听该句柄相关事件的回调有人敲门时通知住户OpenList记录谁打开了这个协议防止多个驱动重复认领同一个控制器当你看到LocateHandleBuffer返回的 HandleBuffer 时里面每一个元素其实都是句柄数据库中某个节点的“门牌号”。规范不让你碰内部结构所以你能做的只是把这个 Handle 传给 Boot Service 函数让 Core 去查它。2.2 句柄的创建、维护与销毁句柄并不是显式“CreateHandle”创建的。大多数情况下第一次调用InstallProtocolInterface时如果传入的 Handle 是 NULL系统会创建一个新句柄并把协议挂上去如果传入一个已经存在的句柄则是在老句柄上追加协议。反过来当一个句柄上的最后一个 Protocol 被卸载时这个句柄通常也就从数据库中销毁了。所以不要试图自己保存一份 EFI_HANDLE 长期使用除非你明确知道对应的协议生命周期比你长。很多诡异问题的根源就是有人缓存了一个句柄等协议被卸载、句柄被删除之后再去访问轻则返回 NotFound重则触发异常。另外句柄数据库的遍历顺序不保证稳定。今天 LocateHandleBuffer 返回的第一个 Handle 是设备 A明天换一个固件版本可能是设备 B。所以一切按 GUID 和协议语义来判断不要依赖返回顺序。2.3 镜像句柄、控制器句柄和设备句柄的区分刚开始写 UEFI 代码的人最容易混淆三类 Handle。镜像句柄ImageHandle对应一个加载到内存里的 PE/COFF 镜像。你的驱动入口被调用时第一个参数就是 ImageHandle。它通常挂有 LoadedImageProtocol。控制器句柄ControllerHandle对应一个需要被驱动管理的硬件控制器比如 PCI 设备。这类 Handle 上通常有 DevicePathProtocol 以及某个总线协议。子句柄ChildHandle由父控制器驱动创建的子设备抽象比如磁盘控制器下的一个分区。驱动安装协议时想清楚“这个协议到底应该挂在哪个句柄上”是最基本的设计决策。上面那个调试案例本质上就是把镜像句柄和控制器句柄搞混了。3. Protocol接口的底层布局GUID、结构体和安装动作3.1 GUID定义确保“同一个协议”的唯一凭据Protocol 的身份是 16 字节的 GUID。只要 GUID 相同任何模块都能找到同一个协议GUID 不同就算接口结构一模一样系统也认为它们是两种东西。所以自定义协议时GUID 要放进一个公共头文件或 DEC 文件里统一管理不要在各模块里手动复制一份#define。我见过太多因为复制时漏了一位数字、或者大小写转换工具处理不当导致两个模块各拿各的 GUID 去 install/locate然后互相找不到的例子。定义方式类似#define MY_PROTOCOL_GUID \ { 0x9e2b4c00, 0x6d1f, 0x4a8b, \ { 0x9f, 0x3e, 0x2c, 0x1d, 0x4e, 0x5a, 0x6f, 0x7b } } EFI_GUID gMyProtocolGuid MY_PROTOCOL_GUID;检查两个模块是否用同一个 GUID最靠谱的办法不是肉眼对比而是看最终的编译产物或者直接打印双方的 GUID 前 8 字节。3.2 自定义协议结构体函数指针表的摆放Protocol 接口本质是一张函数指针表外加必要的状态字段。消费方拿到接口指针后调用结构体里的函数指针来使用服务。下面是一个最经典的示例typedef struct _MY_PROTOCOL MY_PROTOCOL; typedef EFI_STATUS (EFIAPI *MY_PROTOCOL_GET_VERSION)( IN MY_PROTOCOL *This, OUT UINT32 *Version ); struct _MY_PROTOCOL { UINT32 Revision; MY_PROTOCOL_GET_VERSION GetVersion; };注意几个点第一个字段建议放 Revision方便消费方做版本判断。函数指针的第一个参数通常是 This类似面向对象里的 this 指针。调用约定必须是 EFIAPI。别小看这个修饰符在 x64 和 ARM 平台上调用约定差异会导致传参错位甚至崩溃。结构体一旦发布给其他模块使用就不要随意增删字段否则老消费者和新生产者会互相踩。真要升级换个新 GUID。3.3 安装协议的正确姿势InstallMultipleProtocolInterfaces安装协议有两个常用入口InstallProtocolInterface和InstallMultipleProtocolInterfaces。前者一次装一个后者可以一次装多个且必须用 NULL 结尾。STATIC MY_PROTOCOL mMyProtocol { MY_PROTOCOL_REVISION, MyGetVersion }; EFI_STATUS Status; Status gBS-InstallMultipleProtocolInterfaces ( ControllerHandle, gMyProtocolGuid, mMyProtocol, NULL ); if (EFI_ERROR (Status)) { return Status; }安装函数内部会做几件事往句柄数据库里加入协议节点如果 Handle 是 NULL 就创建新句柄触发协议通知事件把这些都做完才会返回 Success。卸载时用UninstallMultipleProtocolInterfaces参数顺序要和安装时一致Status gBS-UninstallMultipleProtocolInterfaces ( ControllerHandle, gMyProtocolGuid, mMyProtocol, NULL ); if (EFI_ERROR (Status)) { return Status; }这里有个容易忽略的点Uninstall 的第三个参数必须和 Install 时传的接口指针完全一致系统会比较协议节点里存的那个指针。如果你用另一个分配出来的新指针去 Uninstall会返回 EFI_INVALID_PARAMETER。协议实例如果是从 Pool 里动态分配的卸载之后才允许 FreePool顺序不能反。4. 从“找句柄”到“拿接口”LocateHandle与OpenProtocol的完整路径4.1 用LocateHandleBuffer把符合条件的句柄全部拿回来消费方最常见的需求是给我所有挂着某个 GUID 协议的句柄。LocateHandleBuffer就是干这个的EFI_HANDLE *HandleBuffer NULL; UINTN HandleCount 0; UINTN Index; EFI_STATUS Status; Status gBS-LocateHandleBuffer ( ByProtocol, gMyProtocolGuid, NULL, HandleCount, HandleBuffer ); if (EFI_ERROR (Status)) { return Status; } for (Index 0; Index HandleCount; Index) { // 到这里只是拿到了句柄还没拿到协议接口指针 } gBS-FreePool (HandleBuffer);这段代码里有三个容易被忽略的地方HandleBuffer 需要 FreePool否则每次查询都漏一块 BootServicesData 内存。在 Shell 工具里反复调用尤其明显。ByProtocol 的第三个参数是 SearchKey不是协议 GUID 本身。对于 ByProtocol 类型这个参数通常是 NULL协议 GUID 已经通过第二个参数指定了。还有一种更简单的LocateProtocol它只返回找到的第一个协议接口。如果你确定全系统只有一个实例用它很方便但如果有多个实例它可能返回不是你想要的那一个。所以做设备遍历时我基本都用 LocateHandleBuffer 自己过滤。4.2 OpenProtocol与HandleProtocol从句柄到接口的那一步拿到句柄之后下一步是调 OpenProtocol 把协议接口指针拿出来MY_PROTOCOL *MyProto NULL; Status gBS-OpenProtocol ( HandleBuffer[Index], gMyProtocolGuid, (VOID **)MyProto, gImageHandle, NULL, EFI_OPEN_PROTOCOL_BY_HANDLE_PROTOCOL ); if (EFI_ERROR (Status)) { continue; } // 调用协议接口 UINT32 Version; MyProto-GetVersion (MyProto, Version);OpenProtocol 的签名里有几个参数值得说明AgentHandle请求者句柄一般填 gImageHandle表示“是我这个模块在打开协议”。ControllerHandle如果是以驱动身份认领控制器这里填被认领的控制器句柄如果只是读取接口可以填 NULL。Attributes决定了这次打开的语义详见下一节。HandleProtocol是很多旧代码里常见的函数它和 OpenProtocol 的 BY_HANDLE_PROTOCOL 模式很接近但不会维护打开记录。新版代码我建议统一走 OpenProtocol语义更清晰排查问题时也更方便。4.3 打开模式的语义差异何时需要CloseProtocol很多新手会问OpenProtocol 之后要不要 CloseProtocol答案取决于 Attributes。我按使用场景列了一张表打开模式语义是否产生打开记录典型场景BY_HANDLE_PROTOCOL只读取接口指针不认领控制器否消费方临时查询接口TEST_PROTOCOL只测试是否存在不返回接口否Supported 阶段探测能力BY_DRIVER驱动认领控制器占用该协议是DriverBinding Start 里使用BY_CHILD_CONTROLLER子控制器通过父控制器访问协议是子设备创建场景BY_HANDLE_PROTOCOL 这种只读打开不产生打开记录理论上不需要 Close。但如果你随后又用 BY_DRIVER 打开同一个协议那就必须在 Stop 时成对调用 CloseProtocol把打开记录消掉。否则 UninstallProtocolInterface 会因为“还有别人打开着这个协议”返回 EFI_ACCESS_DENIEDStop 流程就会卡住。Status gBS-CloseProtocol ( ControllerHandle, gMyProtocolGuid, gImageHandle, NULL );理解打开记录还有一个好处你能解释为什么同一个驱动 Start 两次会返回 EFI_ALREADY_STARTED。协议打开记录是驱动模型做冲突检测的基础。5. 生产者与消费者驱动模型下的协议安装时机和常见翻车点5.1 DriverBinding的Supported/Start/Stop如何决定协议的生与死UEFI Driver Model 把驱动分成三件事Supported 判断“这个控制器我认不认识”Start 负责“认识就接管它并安装协议”Stop 负责“不再接管并卸载协议”。以某个硬件控制器驱动为例Start 里典型流程是在 ControllerHandle 上 OpenProtocol 一个总线协议Attributes 用 BY_DRIVER标记自己认领了这个控制器。分配私有上下文结构体。调用 InstallMultipleProtocolInterfaces把自定义协议装到 ControllerHandle 上。如果还要生成子句柄就创建子控制器并把子协议装到子句柄上。Stop 则反过来先 Uninstall 协议再 CloseProtocol最后释放私有上下文。这里有个常见问题把 Install 放在 Supported 里而不是 Start 里。Supported 的语义只是“询问”不应该产生副作用。如果在这里安装协议后续 Start 被调用时协议可能已经被安装过导致重复安装又或者系统只是做一轮探测你的协议就意外挂在控制器上了。我在代码评审里看到过好几次这种写法属于对驱动模型理解不到位。5.2 协议通知与事件安装后如何通知等待方如果消费方模块被加载时生产方还没跑最简单的做法是轮询 LocateHandleBuffer但这在固件里既浪费 CPU也不优雅。UEFI 提供了协议通知机制你先注册一个事件系统在协议安装和卸载时会给这个事件发信号。EFI_EVENT MyEvent NULL; VOID *Registration NULL; Status gBS-RegisterProtocolNotify ( gMyProtocolGuid, OnProtocolInstalled, Registration );回调函数里再做 LocateHandleBuffer 或 OpenProtocol。因为回调执行的 TPL 可能不是 TPL_APPLICATION所以不要在里面做太重的工作也不要调用可能等待长时间的操作。很多生产者消费者问题用通知事件比用固定延时循环可靠得多。实际项目里如果两个模块都是 DXE 驱动也可以通过 DXE 依赖关系Depex或者 APRIORI 来保证先后顺序但通知事件更通用。5.3 “找不到协议”的根因排查清单我在这一节把开发过程中真正踩过的“协议找不到”根因集中列出来根因说明怎么定位GUID 不一致生产方和消费方用了不同 GUID 值打印双方 GUID或比对 DEC 文件协议装错句柄装到了镜像句柄却按控制器句柄搜索检查 Install 的 Handle 入参搜索方式用错用 LocateProtocol 只取到第一个实例换成 LocateHandleBuffer 遍历安装还没发生Depex 顺序不满足消费方先于生产方用 RegisterProtocolNotify 等待协议被 Reinstall其他驱动替换了协议实例打印 Uninstall/Reinstall 路径接口指针失效生产者提前 FreePool 了接口内存检查 Stop 里 FreePool 与 Uninstall 的顺序这套清单基本能覆盖绝大多数“明明有协议却找不到”的问题。每次调试我都建议先把“协议到底装在哪、什么时候装的、谁动过它”这三个问题用日志回答清楚再动手改代码。6. 调试经验与个人避坑清单6.1 UEFI Shell里快速查看句柄数据库UEFI Shell 里最实用的命令是dh它会把整个句柄数据库里的句柄和挂载协议列出来。你可以直接在 Shell 里跑dh也能看到每个句柄的编号、类型、以及协议 GUID 列表。配合这个输出很容易确认协议到底挂在哪个句柄上。我通常的做法是在源码里把目标 GUID 记下来然后到 Shell 输出里搜这个 GUID看它挂的 Handle 编号再反推这个编号是不是设备控制器句柄。如果它挂在镜像句柄上那基本可以断定 Install 的目标 Handle 选错了。drivers和devices命令也很有用前者展示驱动句柄后者展示设备句柄。遇到连接控制器的场景先看devices里有没有对应设备再看驱动状态能省不少事。6.2 串口日志里该打哪些关键信息固件调试离不开串口日志但日志不是越多越好。对于 Protocol Handle 相关的问题我建议至少打全四个关键信息安装侧打印目标 Handle、GUID、协议接口指针、安装返回值。查找侧打印 SearchType、GUID、HandleCount、Status。打开侧打印 Handle、Attributes、返回的 Interface。卸载侧打印 Handle、GUID、返回值。EDK2 里打印 GUID 和 Status 有现成格式符DEBUG ((DEBUG_ERROR, Install MyProtocol: H%p G%g St%r\n, ControllerHandle, gMyProtocolGuid, Status)); DEBUG ((DEBUG_ERROR, Open MyProtocol: H%p Attr0x%x St%r\n, Handle, Attributes, Status));%g会把 GUID 格式化成一串可读字符%r会把 EFI_STATUS 转成字符串。这两个格式符在定位协议问题上比打%d好用得多。6.3 几个容易被忽视的坑内存所有权、对齐与卸载顺序最后分享几个实操中反复踩到的坑。第一个是内存所有权。协议接口指针可以指向全局变量也可以指向动态分配的 Pool。如果是动态分配的卸载协议之前不要 FreePool。因为系统在 Uninstall 时还要比较接口指针并清理打开记录你提前释放了内存轻则校验失败重则直接访问已释放内存。第二个是结构体对齐。Protocol 接口结构里既有字段又有函数指针不要随手加#pragma pack(1)。消费方和生产方如果一边加一边不加结构体大小可能不一致函数指针的偏移也会错位。自定义协议结构体时保持默认对齐字段顺序从 Revision 开始函数的读取逻辑才会稳定。第三个是Stop 里的顺序。正确顺序是先 Uninstall 协议再 CloseProtocol最后 FreePool 私有数据。如果先 Close 再 Uninstall某些实现会因为在 Uninstall 时已经没有打开记录而放行但你把私有数据放到 Install 的接口里之后再 FreePool 会有悬空期。如果先 FreePool 再 Uninstall就直接踩内存了。这个顺序我记了很多年每次写驱动都默念一遍。还有一个额外的小技巧安装协议里如果你的实例不是全局变量而是动态分配尽量在分配后把结构体清零再赋初值。很多只设置部分字段的代码会让消费方读到随机垃圾尤其在函数指针没有被显式赋值的时候。分配后用ZeroMem打底是成本最低的防御姿势。