简介:面向 Windows 平台网络驱动开发者的 NDIS 6.0 小端口(miniport)驱动实例,围绕 Realtek 8111/8168/8169/8110 系列 PCI 千兆以太网控制器展开,用以弥补 DDK 中仅有 E100BEX 一个参考样例、可借鉴代码过少的缺口。整包共 69 个文件、约 615KB,主体为 gif、png、jpg 网页配图以及 js 脚本和 htm 说明页,另含三个 zip 分包,分别对应 LSO 与巨型帧支持、release 发布版本、PM 电源管理等增量实现,便于按功能模块逐个对照阅读。已有 1242 人学习下载。读者可从中梳理 miniport 的初始化流程、中断处理与收发描述符管理、OID 请求响应等完整实现脉络,并借助电源管理与卸载加速补丁理解驱动分层与状态机设计。内容更适合具备 C 语言与 WDM 基础、希望深入 NDIS 6.0 框架的中高级开发者研读。
1. 网卡驱动真正难的地方,是怎么正确安排缓冲区
很多人对以太网卡驱动的第一印象是「把数据发到线上」,可真写过 NDIS 小端口驱动(miniport driver)之后会发现,难的地方在于你并不拥有那些数据。在 NDIS 模型里,miniport 只是一组被调用的回调,缓冲区归谁、什么时候可以碰、在哪个 IRQL 上碰,全由中间层说了算。搞不清这套安排缓冲区的约定,代码在单包 ping 时一切正常,一上多队列、真实流量就随机蓝屏。这层机制值得深入分析,它是理解内核网络栈稳定性与吞吐上限的入口。
2. 驱动的装配过程:NDIS 凭什么敢调用你的函数
NDIS 小端口驱动没有自己的入口循环,也没有main。它提交给系统的是一张静态函数表,剩下的全交给中间层调度。这张表填错一个字段,后面所有调试都是白费——我这方面有过实际教训:花了两个小时在MiniportInitializeEx里断点,最后发现是Header.Size少填了几个字节,NDIS 根本没读到那个回调。
2.1 函数表:NDIS_MINIPORT_DRIVER_CHARACTERISTICS 里的三个字段
DriverEntry里做的事非常少:填表、注册、走人。真正的信息量在Header的三个字段上,它们构成了 NDIS 的版本协商机制。
static NDIS_MINIPORT_DRIVER_CHARACTERISTICS g_Chars; NDIS_STATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { NDIS_HANDLE driverHandle = NULL; NDIS_STATUS status; // Header 的三个字段必须先填,NDIS 靠它们判断"这张表有多大、按哪版结构解析" NdisZeroMemory(&g_Chars, sizeof(g_Chars)); g_Chars.Header.Type = NDIS_OBJECT_TYPE_MINIPORT_DRIVER_CHARACTERISTICS; g_Chars.Header.Revision = NDIS_MINIPORT_DRIVER_CHARACTERISTICS_REVISION_1; g_Chars.Header.Size = NDIS_SIZEOF_MINIPORT_DRIVER_CHARACTERISTICS_REVISION_1; // 生命周期回调:注册/注销、启停 g_Chars.MiniportInitializeEx = MiniportInitializeEx; g_Chars.MiniportHaltEx = MiniportHaltEx; g_Chars.MiniportDriverUnload = MiniportDriverUnload; // 控制面:所有 OID 请求(查询 MAC、设 RSS 参数、改卸载开关)都从这里进 g_Chars.MiniportOidRequest = MiniportOidRequest; g_Chars.MiniportCancelOidRequest = MiniportCancelOidRequest; // 数据面:发和还,两个必须成对出现 g_Chars.MiniportSendNetBufferLists = MiniportSendNetBufferLists; g_Chars.MiniportReturnNetBufferLists = MiniportReturnNetBufferLists; g_Chars.MiniportCancelSend = MiniportCancelSend; // 有硬件中断的物理网卡基本都要 g_Chars.MiniportInterruptEx = MiniportInterruptEx; g_Chars.MiniportInterruptDPC = MiniportInterruptDPC; status = NdisMRegisterMiniportDriver(DriverObject, RegistryPath, NULL, &g_Chars, &driverHandle); if (status != NDIS_STATUS_SUCCESS) { return status; // 注册失败直接退出,不要继续做任何初始化 } return NDIS_STATUS_SUCCESS; }Size字段的语义是「调用方认为这个结构体有多大」。NDIS 拿到这个值后,只按这个长度去读你传进来的内存,多出来的部分它不看,少了的它也不会替你补。这套设计让同一个二进制可以在不同版本的栈上加载:老版本只解析到它认识的位置,新版本按Revision决定后面那些字段是否存在。代价是驱动作者一旦手写Size,就会遇到「回调明明赋值了却不被调用」的困扰。
另一个容易忽略的点:NdisMRegisterMiniportDriver是注册「驱动级」能力,一个驱动对象只注册一次;而每个网卡实例在MiniportInitializeEx里各自独立初始化。这两级生命周期分开,正是后面所有状态管理问题的根源。
2.2 MiniportInitializeEx 的调用顺序:先交上下文,再动硬件
MiniportInitializeEx会被调用不止一次。资源重新平衡、驱动被禁用后重新启用、系统从休眠恢复,都会再走一遍。这意味着任何「全局单例 + 一次性初始化」的写法都会在第二次调用时出错。
里面最关键的顺序约束是:必须先把 adapter context 交回去,之后拿到的所有回调才有地方存放状态。
NDIS_STATUS MiniportInitializeEx(NDIS_HANDLE NdisMiniportHandle, NDIS_HANDLE MiniportDriverContext, PNDIS_MINIPORT_INIT_PARAMETERS InitParameters) { PMP_ADAPTER a = NULL; NDIS_STATUS status; NDIS_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES reg; // 每个网卡实例一份,绝不共用;NonPaged 因为后面会在 DISPATCH_LEVEL 访问 a = ExAllocatePoolZero(NonPagedPoolNx, sizeof(MP_ADAPTER), MP_TAG); if (a == NULL) return NDIS_STATUS_RESOURCES; a->NdisHandle = NdisMiniportHandle; // NDIS 发号,驱动只借不换 // 第一次 NdisMSetMiniportAttributes 只能填"注册属性", // 作用是把 a 交回去,之后 MiniportSendNetBufferLists 里才拿得到它 NdisZeroMemory(®, sizeof(reg)); reg.Header.Type = NDIS_OBJECT_TYPE_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES; reg.Header.Revision = NDIS_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES_REVISION_1; reg.Header.Size = sizeof(NDIS_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES); reg.MiniportAdapterContext = a; reg.AttributeFlags = NDIS_MINIPORT_ATTRIBUTES_HARDWARE_DEVICE; reg.SupportedMediaType = NdisMedium802_3; // 声明这是以太网 status = NdisMSetMiniportAttributes(NdisMiniportHandle, (PNDIS_MINIPORT_ADAPTER_ATTRIBUTES)®); if (status != NDIS_STATUS_SUCCESS) { ExFreePoolWithTag(a, MP_TAG); return status; } // 到这里之后才有资格做:映射 BAR、建 DMA 通道、注册中断、初始化收发环 status = MpMapHardware(a); if (status != NDIS_STATUS_SUCCESS) goto fail; status = MpInitDma(a); // NdisMRegisterScatterGatherDma 等 if (status != NDIS_STATUS_SUCCESS) goto fail; status = MpRegisterInterrupt(a); // NdisMRegisterInterruptEx if (status != NDIS_STATUS_SUCCESS) goto fail; status = MpInitRings(a); // 收发描述符环 + NBL 池 if (status != NDIS_STATUS_SUCCESS) goto fail; // 第二次 SetMiniportAttributes 填"通用属性":MAC 选项、链路速度、RSS 能力等 return MpSetGeneralAttributes(a); fail: MpTeardown(a); // 失败路径必须能回滚到干净状态 return status; }顺序不是随便排的。中断一定要在 DMA 和环初始化之后才注册,否则硬件中断可能在描述符环还没建立时就打进来,DPC 里读到的是未初始化的内存。反过来,失败回滚必须严格逆序,中间漏一步就会留下悬挂的 DMA 通道或未注销的中断,下次MiniportInitializeEx直接失败。
AttributeFlags也值得注意。HARDWARE_DEVICE表示这是真实硬件而不是虚拟网卡;如果驱动要参与电源管理,还得配合MiniportPnPEventNotify处理NdisDevicePnPEventSurpriseRemoved——热插拔事件不会给你打招呼,收到这个通知之后还去访问硬件寄存器,踩中 0x9F 只是时间问题。
2.3 KMDF + NDIS 双栈:为什么网卡驱动要挂两个框架
现代以太网 miniport 基本不是一个纯粹的 NDIS 驱动,而是一个 KMDF 驱动里嵌着 NDIS 小端口。原因是职责天然分裂:NDIS 管网络语义(MAC、OID、NBL),KMDF 管设备和电源语义(PnP、电源状态机、WMI、资源分配)。如果全用 NDIS 的老接口自己做 PnP,代码量和出错概率都会高一个量级。
衔接点是NdisMGetDeviceProperty,miniport 用它从 NDIS 手里换回设备对象:
// 在 MiniportInitializeEx 里调用,把 NDIS 适配器和 WDF 设备对象绑起来 NdisMGetDeviceProperty(a->NdisHandle, &a->PhysicalDeviceObject, // PDO &a->FunctionalDeviceObject, // FDO,也就是 WdfDevice &a->NextDeviceObject, // 下层设备,DMA 要用 NULL, NULL);拿到资源列表的时机在 KMDF 的EvtDevicePrepareHardware,那里给的是已翻译的物理地址;而 NDIS 侧的MiniportInitializeEx通常在这个调用之后发生。于是常见的实现思路是:KMDF 侧先把资源存进设备上下文,NDIS 侧再从上下文里取。两个框架的回调交错执行,谁先谁后取决于设备栈的启动顺序——这也是为什么「在 DriverEntry 里就把硬件摸一遍」这种写法必然失败。
3. 数据通路的所有权:NET_BUFFER_LIST 为什么不是「一个包」
把 NBL 当成「一个数据包」是新手最容易踩的认知偏差,也是很多诡异 bug 的源头。它是三层结构,而且一层都不能省。
3.1 NBL / NET_BUFFER / MDL 的三级结构
NET_BUFFER_LIST是所有权单位,也是驱动之间传递的句柄;它挂着一串NET_BUFFER,每个NET_BUFFER才对应一份真正的数据;每个NET_BUFFER再挂一条 MDL 链,描述数据在物理内存中的位置。这三级分离带来的直接结果是一条 NBL 里可能有多个包,也可能一个包被拆在多个不连续的页上。
| 层级 | 结构 | 职责 | 驱动的典型操作 |
|---|---|---|---|
| 第一级 | NET_BUFFER_LIST | 所有权与状态 | 整条归还、设置状态码 |
| 第二级 | NET_BUFFER | 单个数据单元 | 遍历、映射到描述符 |
| 第三级 | MDL链 | 物理内存描述 | 生成 SG 表、交给 DMA |
为什么不让一条 NBL 只放一个包?因为要支持分片与合并。协议栈可能把同一个连接上的多个小包合并进一条 NBL 以减少调用次数,硬件也可能把一个大包拆到多个描述符。驱动如果假设「一条 NBL 就是一个以太网帧」,遇到合并后的 NBL 就会只发第一个包,剩下的静默丢弃——而且不会有任何报错,只是吞吐上不去。
3.2 发送:归还时机有三种,漏一种就会队列「假死」
MiniportSendNetBufferLists的返回值语义很容易被误解:返回NDIS_STATUS_SUCCESS并不表示「已经发出去了」,而是表示「这些 NBL 我接手了,之后我会负责还」。返回失败则意味着整条链退回给 NDIS,不调用 complete。
VOID MiniportSendNetBufferLists(NDIS_HANDLE MiniportAdapterContext, PNET_BUFFER_LIST NetBufferList, NDIS_PORT_NUMBER PortNumber, ULONG SendFlags) { PMP_ADAPTER a = (PMP_ADAPTER)MiniportAdapterContext; PNET_BUFFER_LIST cur, next; PNET_BUFFER_LIST doneHead = NULL, doneTail = NULL; // 立即完成的链 // SendFlags 带 DISPATCH_LEVEL 说明当前已在 DISPATCH_LEVEL, // 不用再调 KeGetCurrentIrql(),同时也意味着这里不能等待任何事件 cur = NetBufferList; while (cur != NULL) { next = NET_BUFFER_LIST_NEXT_NBL(cur); NET_BUFFER_LIST_NEXT_NBL(cur) = NULL; // 先拆链,逐条处理 if (MpMapNblToTxRing(a, cur)) { // 成功挂到硬件环上:所有权归驱动,等发送完成后统一 complete MpKickTxRing(a); } else { // 环满:写明失败原因,串到"立即完成"链上,稍后一次性还给 NDIS NDIS_SET_NET_BUFFER_LIST_STATUS(cur, NDIS_STATUS_RESOURCES); if (doneHead == NULL) { doneHead = doneTail = cur; } else { NET_BUFFER_LIST_NEXT_NBL(doneTail) = cur; doneTail = cur; } } cur = next; } // 关键:没能挂上硬件的必须马上还,不能闷着,否则上层以为还在飞 if (doneHead != NULL) { NdisMSendNetBufferListsComplete(a->NdisHandle, doneHead, NDIS_SEND_COMPLETE_FLAGS_DISPATCH_LEVEL); } return NDIS_STATUS_SUCCESS; // 剩下的我接手了 }这里有三条容易翻车的分支。第一,环满时不能直接return NDIS_STATUS_RESOURCES了事——那样会把已经成功挂到硬件上的那部分 NBL 也一起退回,硬件稍后 DMA 读的是已经被回收的内存,结果是随机校验错误。必须先拆链再分别处理。第二,NdisMSendNetBufferListsComplete对同一条 NBL 只能调用一次,重复调用会让引用计数偏移,最终表现为句柄泄漏或者干脆池子耗尽。第三,NDIS_SET_NET_BUFFER_LIST_STATUS必须在 complete 之前设好,上层就是靠这个状态字段判断是拥塞还是硬件故障的。
MpMapNblToTxRing里遍历NET_BUFFER的方式也要注意:NET_BUFFER_LIST_FIRST_NB拿到第一个,NET_BUFFER_NEXT_NB往后走,每个 NB 的NET_BUFFER_DATA_LENGTH才是长度。用NET_BUFFER_LIST_GET_DATA_LENGTH之类的高层宏在小包场景下没问题,遇到合并的 NBL 就只算了第一条。
3.3 接收:NDIS_RECEIVE_FLAGS_RESOURCES 到底改变了什么
接收方向的NdisMIndicateReceiveNetBufferLists有一个决定性的标志位:NDIS_RECEIVE_FLAGS_RESOURCES。加上它,NDIS 会在函数返回之前就把 NBL 通过MiniportReturnNetBufferLists还给你,也就是所谓「同步归还」;不加,所有权就交出去了,什么时候还由协议栈的节奏决定。
VOID MiniportInterruptDPC(NDIS_HANDLE MiniportInterruptContext, PVOID MiniportDpcContext, PVOID NdisReserved1, PVOID NdisReserved2) { PMP_ADAPTER a = (PMP_ADAPTER)MiniportDpcContext; PNET_BUFFER_LIST head = NULL, nbl; ULONG count = 0; // 从驱动自己预分配的 NBL 池取,绝不在这里临时申请内存 while ((nbl = MpPopRxNbl(a)) != NULL) { if (!MpFillFromRxRing(a, nbl)) { // 环里没新包了 MpPushRxNbl(a, nbl); // 放回池子,别泄漏 break; } NET_BUFFER_LIST_NEXT_NBL(nbl) = head; head = nbl; count++; } if (count == 0) return; // 带 RESOURCES:函数返回前 NDIS 就把 NBL 还回来,池子不会被掏空, // 适合 DPC 里缓冲有限的场景;代价是可能多一次同步往返 NdisMIndicateReceiveNetBufferLists(a->NdisHandle, head, NDIS_DEFAULT_PORT_NUMBER, count, NDIS_RECEIVE_FLAGS_DISPATCH_LEVEL | NDIS_RECEIVE_FLAGS_RESOURCES); }选哪种模式是个真实的取舍。不带RESOURCES时,驱动把 NBL 交出去就可以立刻回去收下一批,吞吐更高,但必须保证这些 NBL 在MiniportReturnNetBufferLists回来之前不被任何人碰——包括不能释放它们背后的 DMA 缓冲,因为协议栈可能还在读。带RESOURCES时,NBL 在调用返回后就回到你手上,可以立刻复用,但 NDIS 可能会走「拷贝到自己的缓冲区」这条慢路径。高吞吐场景的常见做法是:池子足够大时不用RESOURCES,靠MiniportReturnNetBufferLists回收;只有在 DPC 里资源紧张、不想让 NBL 悬着时才加上。
这里还有一个常被忽略的细节:NDIS_RECEIVE_FLAGS_DISPATCH_LEVEL是告诉 NDIS「我现在在 DISPATCH_LEVEL」,让它跳过内部的 IRQL 抬升。如果你在 PASSIVE_LEVEL 的上下文里错误地带上这个标志,NDIS 会按高 IRQL 处理,某些路径上会触发断言。标志位不是「优化建议」,而是对当前上下文的如实申报。
3.4 零拷贝的真实含义:不是不拷贝,是绕开 CPU
「零拷贝」这个词经常被理解成「数据一次都不搬」,其实它指的是让数据不经过 CPU 的通用搬移路径。网卡收发包时,数据必须从设备 FIFO 到主机内存,这一步 DMA 在做;真正被省掉的是 CPU 在中间缓冲区之间复制的那几趟。
NDIS 提供的做法是让驱动把NET_BUFFER的 MDL 链翻译成硬件能直接用的散列/聚集描述符表,这样硬件按表 DMA,CPU 完全不参与。
// 把一条 NET_BUFFER 的 MDL 链翻译成 SG 描述符数组,供网卡 DMA 使用 // 注意:传给它的单位是 NET_BUFFER,不是整条 NBL——粒度错了 SG 表会漏包 status = NdisMAllocateNetBufferSGList( a->DmaHandle, // NdisMRegisterScatterGatherDma 得到的句柄 netBuffer, // 单个 NET_BUFFER a->SgListBuffer, // 驱动预分配的 SG 描述符缓冲 a->SgListBufferSize, 0, // Flags:收包路径通常要求同步返回 &a->SgList); if (status <p> <a href="https://download.csdn.net/download/lzy0001sl/1807616" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>