资讯详情

资讯详情

NDIS小端口驱动:网卡能力清单与NBL数据路径解析

简介面向需要编写 NDIS 6.0 小端口驱动的 Windows 内核与网卡驱动开发者这份实例以 Realtek 8111/8168/8169/8110 系列 PCI 千兆以太网控制器为对象给出可参考的 miniport 驱动源码与配套说明弥补 DDK 中仅有 E100BEX 单一示例的不足。包内共 69 个文件以 gif、png、js 和 htm 等网页存档及页面脚本为主另有 zip 压缩的源码与功能扩展包、css 样式和 jpg 图片整体约 615KB体积轻便但保留了文章页面与工程线索。从扩展包与目录结构看内容围绕小端口初始化、发送与接收路径、中断与 DPC 处理、OID 查询设置、描述符与缓冲区管理以及 LSO、Jumbo Frame、电源管理等增强特性展开便于读者对照理解驱动框架、硬件寄存器访问和真实网卡适配方法。已有 1242 人学习适合具备一定 Windows 内核与网络驱动基础、希望借助实例移植代码或查漏补缺的开发者。1. 一块以太网卡被系统认出来靠的不是收发代码把一块以太网卡插进机器设备管理器里认出型号、链路状态变成已连接这套流程里最容易被低估的一环是驱动在加载时交出的那张能力清单。NDIS 小端口驱动miniport driver听起来像“给网卡写收发数据”这么直白的事但真正决定它能不能跑起来的往往不是MiniportSendNetBufferLists里那几十行搬运逻辑而是NDIS_MINIPORT_DRIVER_CHARACTERISTICS里填了哪些回调、NdisMSetMiniportAttributes上报了哪些能力。NDIS 只认这张清单不认你的硬件有多强。反直觉的结论是小端口驱动的绝大部分复杂度不在数据通路上而在边界约定上。它向上只说NET_BUFFER_LIST和 OID 两种语言向下必须把中断、DMA、多队列这些硬件脾气翻译成这套语言。翻译错了不会立刻报错而是几十秒后变成一次掉包、一次超时重置或者一块只有蓝屏才知道的内存被 DMA 写坏。搞清楚这层边界怎么划、所有权怎么转移、失败怎么回滚比背 API 有用得多——这也是几乎所有网卡驱动的“玄学问题”都收敛在同一个地方的原因。2. 分层边界为什么协议栈不能直接摸网卡的寄存器2.1 四个边缘模型miniport 是唯一被允许“接地”的那一侧NDIS 把整个网络数据通路切成了几个可以互相替换的“边缘”。最上面是协议边缘protocol edgeTCP/IP 协议驱动就挂在这里它只认识NET_BUFFER_LIST简称 NBL和一组 OID 请求中间可以插轻量筛选器LWF做过滤、抓包、QoS 标记最下面是小端口边缘miniport edge它是整个栈里唯一被允许碰寄存器和 DMA 描述符的角色。再往下才是硬件。上下两个边缘之间不共享任何数据结构只共享 NDIS 定义的接口语义——这正是它能跨硬件平台工作的原因。如果让协议栈直接操作网卡会怎样先不谈寄存器布局光传输介质的差异就够呛PCIe 设备用内存映射加 MSI-XUSB 网卡靠批量端点轮询虚拟网卡干脆就是另一台机器上的一段共享内存。协议栈要为每一种都写一遍 IP 头解析这显然是荒谬的。NDIS 的做法是把“怎么搬”和“搬什么”彻底分开搬的方式由 miniport 实现搬的内容用 NBL 统一描述控制信息速率、双工、offload 能力、电源状态走 OID 通道。这套模型还是一套向前兼容的机制。NDIS 6.x 的每个小版本都在加能力但接口本身几乎不变——新能力通过 characteristics 的 revision、OID 的查询/设置、以及 NBL 上的 OOB 信息位来协商。一块只实现了 NDIS 6.20 的老 miniport装在新系统上仍然能跑靠的不是系统迁就它而是系统先问“你支持什么”再决定自己走哪条路径。能力清单 OID 协商而不是固定函数签名这是理解整层设计的钥匙。2.2 NDIS 库替 miniport 兜住的四件事内存池、DMA、中断、锁写 miniport 时最容易产生的错觉是“我在直接操作硬件”。实际上你操作的是 NDIS 包好的一层抽象它替驱动兜住了四类脏活。第一类是缓冲区生命周期。NBL 不是随手 malloc 出来的它从池里来NdisAllocateNetBufferListPool创建一个池池参数里带DataSize、ContextSize、ContextBackFill。池的存在意义是复用 MDL 和底层物理页避免每个包都走一次映射。ContextSize尤其关键——每个 NBL 后面跟着一段驱动私有的上下文区你可以在里面塞描述符索引、时间戳、队列号收包和发包时靠它反查硬件资源不用再维护一张哈希表去对账。第二类是DMA。NdisMRegisterScatterGatherDma拿到 DMA 适配器NdisMAllocateSharedMemory拿到设备可见的共享内存NdisMAllocateNetBufferSGList把 NBL 里那条 MDL 链直接翻译成硬件能吃的物理地址片段。上层完全不用关心 IOMMU、页边界、描述符个数上限这些事——但你要关心因为跨页包会把一个逻辑包拆成多个片段描述符环开小了就会在满速跑流时突然开始丢包。第三类是中断。NdisMRegisterInterruptEx把中断对象、ISR、DPC、以及 MSI-X 的多消息支持一次性注册进去中断到 CPU 的亲和性也由它管理。第四类是锁但只给工具不给策略。NDIS 6 之后取消了早期的全局串行化发送回调和 OID 回调可能真的在不同 CPU 上同时进来。NdisAllocateSpinLock给你一把在 DISPATCH_LEVEL 也能用的自旋锁剩下的保护范围要自己划。这一点是新手驱动和成熟驱动最大的差距前者以为回调是串行的后者知道每个共享字段都得有明确的主人。边缘/角色谁来注册主要接口典型职责协议边缘协议驱动NdisSendNetBufferLists组包、解析上层协议筛选器LWF 驱动过滤回调抓包、防火墙、QoS小端口边缘miniport 驱动MiniportSendNetBufferLists、NdisMIndicateReceiveNetBufferListsDMA、中断、链路管理控制通道任意边缘MiniportOidRequest速率、offload、电源、统计3. 加载链路从 DriverEntry 到 MiniportInitializeEx 之间的资源谈判3.1 能力清单先于代码characteristics 决定了哪些回调会被调用驱动加载时做的第一件事不是初始化硬件而是交出能力清单。NdisMRegisterMiniportDriver收下NDIS_MINIPORT_DRIVER_CHARACTERISTICS这个结构体从此 NDIS 只按里面的函数指针调用你——你没填的回调对应的路径就永远走不到你这里。/* 加载阶段只做注册一个寄存器都不要碰 */ NDIS_HANDLE g_NdisMiniportHandle; PVOID g_DriverContext; NDIS_STATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { NDIS_MINIPORT_DRIVER_CHARACTERISTICS chars; NDIS_STATUS status; NdisZeroMemory(chars, sizeof(chars)); chars.Header.Type NDIS_OBJECT_TYPE_MINIPORT_DRIVER_CHARACTERISTICS; chars.Header.Size sizeof(chars); chars.Header.Revision NDIS_MINIPORT_DRIVER_CHARACTERISTICS_REVISION_3; /* 版本号写高了老系统会直接拒绝加载写低了则用不到新能力 */ chars.MajorNdisVersion 6; chars.MinorNdisVersion 85; chars.InitializeHandlerEx MiniportInitializeEx; chars.HaltHandlerEx MiniportHaltEx; chars.UnloadHandler MiniportDriverUnload; chars.SendNetBufferListsHandler MiniportSendNetBufferLists; chars.ReturnNetBufferListsHandler MiniportReturnNetBufferLists; chars.CancelSendHandler MiniportCancelSend; chars.OidRequestHandler MiniportOidRequest; chars.CheckForHangHandlerEx MiniportCheckForHangEx; chars.ResetHandlerEx MiniportResetEx; chars.DevicePnPEventNotifyHandler MiniportDevicePnPEventNotify; chars.ShutdownHandlerEx MiniportShutdownEx; status NdisMRegisterMiniportDriver(DriverObject, RegistryPath, g_DriverContext, chars, g_NdisMiniportHandle); return status; }Header里的三个字段是一组必须自洽Type指明这是哪种结构体Size必须是当前编译单元的sizeofRevision必须和这个大小匹配。三者对不上NDIS 会按旧版本布局去读结构体尾部结果是某些回调指针被读成垃圾值——现象是初始化阶段随机失败事件日志里只有一行毫无信息量的“设备未能启动”。MajorNdisVersion/MinorNdisVersion同理写高了加载直接被拒返回的是版本类错误码这个错在开发机上几乎见不到一上老系统就翻车。注意注册成功不等于初始化。NDIS 会在 PnP 管理器启动设备时才调用InitializeHandlerEx所以DriverEntry里任何“顺便把硬件摸一下”的写法都是错的——那时设备可能还没上电甚至可能根本不属于你。3.2 MiniportInitializeEx注册身份、上报能力、映射硬件顺序错了就起不来MiniportInitializeEx是整个驱动唯一被允许分配适配器上下文的入口。这个上下文的生命周期从本函数返回成功开始到MiniportHaltEx结束之后所有回调拿到的都是它。写法上有个固定套路先分配并清零上下文再原子地把“身份”注册进 NDIS最后才谈硬件能力。NDIS_STATUS MiniportInitializeEx(NDIS_HANDLE AdapterHandle, NDIS_HANDLE DriverContext, PNDIS_MINIPORT_INIT_PARAMETERS InitParams) { PMY_ADAPTER a NULL; NDIS_STATUS status; NDIS_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES reg; NDIS_MINIPORT_ADAPTER_GENERAL_ATTRIBUTES gen; NDIS_MINIPORT_ADAPTER_OFFLOAD_ATTRIBUTES off; /* 1) 适配器上下文所有回调的入口参数失败路径要能和成功路径共用清理函数 */ a NdisAllocateMemoryWithTagPriority(AdapterHandle, sizeof(MY_ADAPTER), MY_POOL_TAG, NormalPoolPriority); if (a NULL) return NDIS_STATUS_RESOURCES; NdisZeroMemory(a, sizeof(MY_ADAPTER)); NdisAllocateSpinLock(a-Lock); a-AdapterHandle AdapterHandle; /* 2) 先注册身份上下文 属性标志这一步必须最先做 */ NdisZeroMemory(reg, sizeof(reg)); reg.Header.Type NDIS_OBJECT_TYPE_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES; reg.Header.Size sizeof(reg); reg.Header.Revision NDIS_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES_REVISION_1; reg.MiniportAdapterContext a; reg.AttributeFlags NDIS_MINIPORT_ATTRIBUTES_HARDWARE_DEVICE | NDIS_MINIPORT_ATTRIBUTES_BUS_MASTER; status NdisMSetMiniportAttributes(AdapterHandle, (PNDIS_MINIPORT_ADAPTER_ATTRIBUTES)reg); if (status ! NDIS_STATUS_SUCCESS) goto fail; /* 3) 映射寄存器、读 MAC、做硬件自检任何一步失败都要能回到干净状态 */ status MyMapHardware(a, InitParams); if (status ! NDIS_STATUS_SUCCESS) goto fail; /* 4) 上报介质类型、MTU、组播表长度、offload 能力等 */ NdisZeroMemory(gen, sizeof(gen)); gen.Header.Type NDIS_OBJECT_TYPE_MINIPORT_ADAPTER_GENERAL_ATTRIBUTES; gen.Header.Size sizeof(gen); gen.MediaType NdisMedium802_3; /* 以太网卡的固定值 */ gen.PhysicalMediumType NdisPhysicalMedium802_3; gen.MaxMulticastListSize 32; gen.MacOptions NDIS_MAC_OPTION_TRANSFERS_NOT_PEND; status NdisMSetMiniportAttributes(AdapterHandle, (PNDIS_MINIPORT_ADAPTER_ATTRIBUTES)gen); if (status ! NDIS_STATUS_SUCCESS) goto fail; /* 5) 最后注册中断并启动硬件顺序反了会收到没人处理的中断风暴 */ status MyRegisterInterruptAndStart(a); if (status ! NDIS_STATUS_SUCCESS) goto fail; return NDIS_STATUS_SUCCESS; fail: MyReleaseHardware(a); /* 与 HaltEx 复用防止两套释放路径不一致 */ NdisFreeSpinLock(a-Lock); NdisFreeMemory(a, sizeof(MY_ADAPTER), 0); return status; }这里最容易忽略的是NdisMSetMiniportAttributes的调用顺序注册属性必须先于通用属性和 offload 属性因为后者要往一个已经存在的适配器记录上写。顺序反了返回的失败码往往只是笼统的通用错误你只能靠加日志慢慢二分。硬件访问也不是随手MmMapIoSpace。NDIS 为 WDM 型小端口提供了NdisMGetDeviceProperty拿到物理设备对象和下一层设备对象再向总线发查询接口的请求拿到配置空间访问方式然后读 BAR、读 MAC 地址、读链路能力。这一步失败率极高而且失败原因经常不在驱动里——设备没上电、复位没做完、固件还在自检都会让你读回一堆0xFF。我一般会在这里加一个带超时的轮询而不是读一次就判死刑。3.3 中断与 DPC为什么 ISR 里除了应答什么都不能干中断服务例程运行在 DIRQL 上这个级别下不能访问分页内存、不能等任何事件、不能用大部分内核 API。所以 ISR 的职责被压缩到极小读状态寄存器判断是不是自己的中断是就应答把状态存下来返回TRUE不是就老实返回FALSE——共享中断线上返回错会直接把别人的中断吞掉这是最典型的“一个驱动装完另一个设备不工作”的来源。BOOLEAN MiniportInterrupt(NDIS_HANDLE AdapterContext, NDIS_HANDLE MiniportInterruptContext, PULONG Reserved0, PVOID Reserved1) { PMY_ADAPTER a (PMY_ADAPTER)AdapterContext; ULONG isrStatus MyReadIsrStatus(a); if (isrStatus 0) return FALSE; /* 不是我的中断绝不冒领 */ MyAckInterrupt(a, isrStatus); /* 先应答否则电平一直拉着 */ a-IsrStatus | isrStatus; /* 只搬运状态不做业务 */ return TRUE; /* NDIS 会据此调度对应的 DPC */ } VOID MiniportInterruptDPC(NDIS_HANDLE AdapterContext, NDIS_HANDLE MiniportInterruptContext, PVOID DispatchContext, PVOID Reserved0, PVOID Reserved1) { PMY_ADAPTER a (PMY_ADAPTER)AdapterContext; if (a-IsrStatus ISR_RX) MyProcessRxRing(a); /* 取描述符 → 组 NBL → 上报 */ if (a-IsrStatus ISR_TX) MyCompleteTxRing(a); /* 回收描述符 → SendComplete */ a-IsrStatus 0; }DPC 跑在 DISPATCH_LEVEL可以调NdisMIndicateReceiveNetBufferLists、可以调NdisMSendNetBufferListsComplete但仍然不能阻塞。真正的坑在吞吐上10G 线速下一个 DPC 把整个接收环扫完单核就跑满了包开始堆在描述符环里直到溢出。NDIS 给的解法是 MSI-X 多消息加NdisMQueueDpcEx——每个接收队列绑定一个中断向量、一个 DPC 索引NdisMQueueDpcEx还能指定目标 CPU 掩码把 DPC 直接投到拥有该队列缓存的核上。这不是优化是让多队列真的有效的前提。4. 数据路径的所有权语义NBL 在谁手里谁说了算4.1 发送MiniportSendNetBufferLists 与“环满就退货”的流控发送回调签名很简短MiniportSendNetBufferLists(MiniportAdapterContext, NetBufferList, PortNumber, SendFlags)。传进来的是一个 NBL 链链上每个 NBL 又可能挂着多个NET_BUFFER每个NET_BUFFER后面是一串 MDL——这就是 scatter-gather一个逻辑包在物理内存里可能是散的。驱动要做的是把这条链翻译成描述符写门铃然后记住它们。所有权规则只有一条在你调用NdisMSendNetBufferListsComplete之前这些 NBL 归你NDIS 不会碰调用之后归 NDIS你一个字都不能再改。中间的窗口里你可以读、可以拿 MDL 建 DMA 映射但绝不能改 NBL 的结构字段那是 NDIS 的账本。VOID MiniportSendNetBufferLists(NDIS_HANDLE AdapterContext, PNET_BUFFER_LIST NetBufferList, NDIS_PORT_NUMBER PortNumber, ULONG SendFlags) { PMY_ADAPTER a (PMY_ADAPTER)AdapterContext; PNET_BUFFER_LIST nbl NetBufferList, next, rejected NULL; NdisAcquireSpinLock(a-TxLock); for (; nbl ! NULL; nbl next) { next NET_BUFFER_LIST_NEXT_NBL(nbl); NET_BUFFER_LIST_NEXT_NBL(nbl) NULL; /* 拆链逐个处理 */ if (!MyTxRingHasRoom(a)) { /* * 环满不要自己排队死等。用 RESOURCES 状态完成 * NDIS 会把这批 NBL 重新投下来这就是协议栈侧的背压。 */ NET_BUFFER_LIST_STATUS(nbl) NDIS_STATUS_RESOURCES; NET_BUFFER_LIST_NEXT_NBL(nbl) rejected; rejected nbl; continue; } MyProgramTxDescriptors(a, nbl); /* 遍历 NET_BUFFER / MDL 链 */ MyPushPendingList(a, nbl); /* 等硬件回写后再 Complete */ } MyKickTxDoorbell(a); NdisReleaseSpinLock(a-TxLock); if (rejected ! NULL) NdisMSendNetBufferListsComplete(a-AdapterHandle, rejected, 0); }这里有两个容易翻车的地方。一是RESOURCES的语义它不是“失败”而是“我现在吃不下你等会儿再给我”NDIS 会重投所以千万不能把它当成丢包处理也不能自己维护一个软件队列无限堆。二是部分完成是合法的你可以把一部分 NBL 挂上硬件、另一部分立刻完成NDIS 不要求整链一次性收尾。但每个 NBL 都必须有且只有一次完成少一次会挂住上层多一次会踩已释放内存。硬件回写发送完成后在 DPC 里做收尾NET_BUFFER_LIST_STATUS(nbl)设成成功或具体错误然后整链调一次NdisMSendNetBufferListsComplete。如果这里忘了填 Status上层看到的是残留值往往被解释成成功——现象就是 TCP 重传率莫名升高抓包看是本端明明发出去了对端就是没收到。4.2 接收RESOURCES 标志是 NBL 生命周期的分水岭接收方向反过来缓冲区由驱动自己准备。常见做法是给接收环的每个描述符预绑一个 NBL硬件 DMA 写本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →