简介:面向需要编写 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 edge),TCP/IP 协议驱动就挂在这里,它只认识NET_BUFFER_LIST(简称 NBL)和一组 OID 请求;中间可以插轻量筛选器(LWF),做过滤、抓包、QoS 标记;最下面是小端口边缘(miniport edge),它是整个栈里唯一被允许碰寄存器和 DMA 描述符的角色。再往下才是硬件。上下两个边缘之间不共享任何数据结构,只共享 NDIS 定义的接口语义——这正是它能跨硬件平台工作的原因。
如果让协议栈直接操作网卡会怎样?先不谈寄存器布局,光传输介质的差异就够呛:PCIe 设备用内存映射加 MSI-X,USB 网卡靠批量端点轮询,虚拟网卡干脆就是另一台机器上的一段共享内存。协议栈要为每一种都写一遍 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、NdisMIndicateReceiveNetBufferLists | DMA、中断、链路管理 |
| 控制通道 | 任意边缘 | 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必须是当前编译单元的sizeof,Revision必须和这个大小匹配。三者对不上,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(®, 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)®); 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 写
本文还有配套的精品资源,点击获取