先说一个我最近遇到的真实问题。某模块运行在 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_DENIED,Stop 流程就会卡住。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 Attr=0x%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打底,是成本最低的防御姿势。
先说一个我最近遇到的真实问题。某模块运行在 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_DENIED,Stop 流程就会卡住。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 Attr=0x%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打底,是成本最低的防御姿势。