简介:这是面向瑞昱(Realtek)8111、8168、8169、8110等常见PCI千兆以太网控制器编写的NDIS 6.0小端口驱动示例源码,适合需要开发Windows网络驱动却缺少完整参考实例的工程师学习。压缩包采用RAR格式,共69个文件,大小约为615KB,除核心驱动源码外,还包含HTML格式说明文档、GIF与PNG截图、JS与CSS页面素材;其中三个ZIP文件分别是完整工程、加入巨型帧与LSO卸载支持、加入电源管理支持的版本,方便逐项对照理解功能扩展方式。源码覆盖驱动入口、初始化、收发处理、中断处理、电源管理等关键环节,完整展示了NDIS小端口驱动从设备枚举到数据通路搭建再到硬件功能适配的常见实现路径,参考价值较高。目前已有1238人学习下载,适合具备NDIS与驱动开发基础、希望借助真实网卡控制器样例快速上手的开发者参考。 做网络驱动开发的,尤其是刚接触Windows平台网卡驱动的朋友,应该都绕不开 NDIS 小端口驱动这个名字。我最初接触这块的时候,看着文档里一堆 MiniportXxx 回调函数和 NET_BUFFER_LIST,说实话有点发怵。但真正把整个收发路径、初始化流程理顺之后才发现,这套框架设计得相当规整,只要理解了核心机制,写出来的驱动稳定性不会差。这篇文章我就结合自己做过的一个以太网卡小端口驱动项目,把从架构认知到编码实现,再到调试排错的过程完整梳理一遍,给正准备入坑或者已经踩坑的朋友一些实在的参考。
1. 先搞清楚 NDIS 小端口驱动在网络栈里的位置
1.1 从数据包旅程看驱动职责
很多初学者一上来就翻 WDK 文档里 MiniportInitializeEx、MiniportSendNetBufferLists 这些函数,看半天也不知道自己写的代码到底在整个系统里扮演什么角色。我习惯用一个简单的方式理解:把网卡驱动想象成快递公司在某个片区的配送站,上层协议栈是发货方,物理网线是公路,而 NDIS 库就是那个负责调度和规则制定的物流中心。
你写的 NDIS 小端口驱动,核心任务就是两件事:把上层传下来的数据包原封不动(或者说按硬件要求封装好)发到线上,把线上收到的数据帧拆解好交给上层。听起来简单,但实际要考虑的事情远比这个多:硬件怎么初始化、中断怎么处理、多核 CPU 上怎么保证并发安全、休眠唤醒怎么办、硬件出错怎么恢复。这些杂活,NDIS 框架已经替你搭好了台子,你只需要在对应的回调函数里填上针对你自己硬件的那部分逻辑。
1.2 NDIS、小端口和网卡硬件的三角关系
NDIS(Network Driver Interface Specification)从 1989 年由微软和 3Com 提出到现在,已经是所有 Windows 网络驱动的地基。它定义了一套统一的接口规范,让协议驱动(比如 TCP/IP 协议栈)和硬件驱动(小端口驱动)能独立开发、自由组合。
具体到小端口驱动这个角色,它在体系里夹在 NDIS 库和网卡硬件之间。上层协议完全不关心你的网卡到底是 Realtek 还是 Intel,它只调用 NDIS 提供的接口把数据往下送;NDIS 库拿着你注册的回调函数表,把上层的数据转交给你;你要做的就是在这些回调里操作硬件寄存器、DMA 描述符,完成真正的数据收发。打个比方,NDIS 提供了插座和电线,协议栈是插座里的电,你的小端口驱动就是那个把电转换成设备能用的适配器。
2. 小端口驱动的两种形态与应用场景选型
2.1 无总线小端口与 WDM 小端口的区别
开发之前必须先想清楚你要做哪一种。这里有个很多人忽略的概念:NDIS 小端口驱动按与总线的绑定方式,分成了无总线小端口(Non-Bus-Specific)和 WDM 小端口(也叫 KMDF 小端口)。
无总线小端口,名字听着玄乎,其实意思是驱动自己管理所有硬件资源,不依赖 PCI 总线驱动帮你枚举和分配资源。这类驱动常见于虚拟网卡、软件实现的网卡,或者某些特殊总线接口的设备。你直接在 MiniportInitializeEx 里通过 NdisMGetBusData 之类的方式去读配置空间、自己做 IO 端口映射。
WDM 小端口则是挂接在标准总线(最常见的就是 PCIe)上,借助 KMDF 框架帮你处理 PnP、电源管理。Windows 8 之后微软主推 KMDF 小端口模型,因为框架帮你解决了大量即插即用和电源管理的边缘情况。我做 PCIe 以太网卡驱动用的就是这种。选型上,一句话总结:有真实硬件且走标准总线的,优先 KMDF;纯软件虚拟设备、想轻量快速实现的,考虑无总线式。当然,你要是敢折腾,KMDF 也能做虚拟网卡,只是有点杀鸡用牛刀。
2.2 为什么选择 KMDF 小端口而非传统 NDIS 5.x
早年间写网卡驱动,大家都是基于 NDIS 5.x,自己处理 PnP 事件、自己管理电源 IRP,那叫一个痛苦。NDIS 5.x 时代一个简单的网卡驱动初始化,光处理 IRP 的代码就要占掉三成以上,而且不同 Windows 版本行为还有差异。
换成 NDIS 6.x 加 KMDF 之后,幸福感直线上升。KMDF 自动处理了设备启动、停止、休眠唤醒的状态机,你只需要实现 EvtDevicePrepareHardware、EvtDeviceReleaseHardware 这些框架回调。NDIS 层面也简化了数据路径,NET_BUFFER_LIST 链表的操作更加清晰,还加入了 RSS(接收端缩放)、RSC(接收段合并)这样的硬件卸载支持。实测下来,同样功能的驱动,KMDF 版本的初始化代码量能少一半左右,稳定性却高一个量级,特别是休眠唤醒这种边缘场景,框架帮你兜底太多了。
3. 核心机制逐层拆解:从注册到收发
3.1 驱动入口与 NDIS 注册流程
每个小端口驱动都有一个 DriverEntry 入口,这是老生常谈了。但里面的具体注册流程我建议你画个时序图记清楚。第一步是初始化 KMDF 驱动对象:WdfDriverCreate,这个会建立起驱动框架的根基。第二步就是重点了,NdisMRegisterMiniportDriver,这一步把你的 NDIS 版本、调用入口、关键特性全部登记到 NDIS 库里。
NdisMRegisterMiniportDriver 传进去的那个 NDIS_MINIPORT_DRIVER_CHARACTERISTICS 结构体,是整个驱动最核心的一张表。请注意,这里面的每一个函数指针都讲究得很:
- 必须实现的:MiniportInitializeEx、MiniportHaltEx、MiniportShutdownEx
- 数据路径的:MiniportSendNetBufferLists、MiniportReturnNetBufferLists
- 查询设置的:MiniportQueryInformation、MiniportSetInformation
- 可选的:MiniportDevicePnPEventNotify、MiniportResetEx、MiniportInterruptDPC 等
最容易犯的错是漏掉某个可选回调。比如不确定设备是否支持 WoL(局域网唤醒)就不填 MiniportDevicePnPEventNotify,结果休眠恢复后网卡直接失联,排查到崩溃。不要偷懒,该实现的别省。
3.2 初始化硬件与中断处理
MiniportInitializeEx 里,你拿到的是系统分配好的适配器句柄,接下来要做的事情大概分成这么几类:
- 读取 PCIe 配置空间,确认 BAR 资源,映射寄存器地址
- 分配 DMA 描述符和收发缓冲区(这一步在 KMDF 下用 WdfDmaEnabler 特别方便)
- 初始化硬件寄存器:MAC 地址、流控、VLAN、多播列表
- 配置中断:MSI-X 中断向量分配、中断使能
- 注册 NDIS 接收处理,调用 NdisMIndicateReceiveNetBufferLists
中断这块我要多唱几句。现代网卡几乎全走 MSI-X,每个队列一个中断向量,然后配合 RSS 把不同连接哈希到不同 CPU,这是大吞吐量的关键。分配中断的时候,要考虑 NUMA 节点,让网卡中断所在 CPU 和内存所在节点一致,否则跨节点访问内存延迟能差好多。我最初没注意这块,64 字节小包吞吐死活上不去,后来发现是中断绑在了远离 DMA 内存的 CPU 上,挪近之后性能直接翻倍。
3.3 收发路径的完整生命周期
发送路径的关键函数是。Mac 收到上层传来的 NET_BUFFER_LIST,里面挂着一串 NetBuffer。你要做的是:把 NetBuffer 里的数据复制到 DMA buffer(或用 DMA 直接映射),写入发送描述符,然后 Kick 一下硬件。
这里最考验功底的是对 NET_BUFFER_LIST 生命周期和引用计数的把握。记住一句话:NDIS 把 NBL 交给你,你就是所有者,但最终你必须调用 NdisMSendNetBufferListsComplete 把它还给 NDIS。中间无论硬件成功失败超时,这个回调一定要保证调用,否则上层协议栈会挂起然后卸载不了。
接收路径相对简单,硬件 DMA 写接收缓冲区后触发中断,你在 DPC 里检查接收描述符,把数据封装进 NET_BUFFER_LIST,调用 NdisMIndicateReceiveNetBufferLists 通知上层。注意用 NDIS_RECEIVE_FLAGS_DISPATCH_LEVEL 标志表示在 DPC 中调用,还有资源不够时可以返回 NDIS_STATUS_RESOURCES,上层会退避重试。接收路径性能好坏,很大程度上取决于你缓冲区回收的效率:网卡用完的 buffer 要能快速归还到接收 ring 里,我一般会预分配 512 个初始 buffer,然后在运行中动态补充,避免中断里做内存分配这种耗时的操作。
3.4 NDIS 版本选择与重卸载注意点
现在如果还按 NDIS 6.0 写新驱动,我是不太建议的。新平台、新功能基本都往 6.80 以上走。版本上的高低,不仅决定了你能用多少新特性(比如 NetAdapterCx 那种面向未来的框架),还决定了你能否通过 Windows 硬件认证。
还有个老生常谈但特别重要的问题:驱动的重入与卸载。小端口驱动支持热插拔就算了,网卡还经常出现在 NDIS 重配置(比如改 IP 导致协议绑定变化)的场景。MiniportHaltEx 里要把之前注册的定时器、DPC、DMA 全部清理干净,任何异步操作都要在返回前完成或取消。我踩过最深的坑是,定时器 callback 和 HaltEx 并发跑,Halt 已经把设备上下文释放了,定时器回调还在操作那个野指针,结果系统随机蓝屏。后来用 WdfSpinLock 把资源保护起来,并在 Halt 里先停止定时器再获取锁,问题彻底解决。
4. 实操:环境搭建与关键代码落地
4.1 开发与调试环境准备
开发 NDIS 小端口驱动,环境配置其实相当固定,但有几个细节值得写下来,免得重新踩坑:
- 系统:Windows 10/11 x64 专业版或企业版,开启测试签名模式(bcdedit /set testsigning on)
- 工具:Visual Studio 2022 + WDK(Windows 驱动工具包),版本要匹配,我用的 VS2022 配 WDK 10.0.22621
- 目标机:最好准备一台独立的测试机,不要在自己开发机上测试驱动,蓝屏重启打断节奏不说,代码都可能没保存
- 调试器:WinDbg,建议用 WinDbg Preview,连接方式用网络调试(内核调试),别再用串口了,速度慢得让人崩溃
还有个容易被忽略的点:WDK 装完默认在 C:\Program Files (x86)\Windows Kits\10,但 VS 的驱动项目模板有时候选不到,原因是 VS 的 Workload 里没勾选“使用 C++ 的桌面开发”以及“Windows 10 SDK”。这个坑很隐蔽,配环境卡了我整整半天。
4.2 DriverEntry 与 Characteristic 注册实战
直接甩一段我当时代码的骨架,加上注释,看注释就够了:
DriverEntry(DRIVER_OBJECT *DriverObject, UNICODE_STRING *RegistryPath) { WDF_DRIVER_CONFIG config; WDFDRIVER hDriver; NDIS_MINIPORT_DRIVER_CHARACTERISTICS ndisChars; NDIS_STATUS status; WDF_DRIVER_CONFIG_INIT(&config, EvtDeviceAdd); status = WdfDriverCreate(DriverObject, RegistryPath, WDF_NO_OBJECT_ATTRIBUTES, &config, &hDriver); if (!NT_SUCCESS(status)) { return status; } NdisZeroMemory(&ndisChars, sizeof(ndisChars)); ndisChars.Header.Type = NDIS_OBJECT_TYPE_MINIPORT_DRIVER_CHARACTERISTICS; ndisChars.Header.Size = sizeof(ndisChars); ndisChars.Header.Revision = NDIS_MINIPORT_DRIVER_CHARACTERISTICS_REVISION_2; ndisChars.MajorNdisVersion = 6; ndisChars.MinorNdisVersion = 80; ndisChars.InitializeHandler = MiniportInitializeEx; ndisChars.HaltHandler = MiniportHaltEx; ndisChars.ShutdownHandler = MiniportShutdownEx; ndisChars.SendNetBufferListsHandler = MiniportSendNetBufferLists; ndisChars.ReturnNetBufferListsHandler = MiniportReturnNetBufferLists; ndisChars.QueryInformationHandler = MiniportQueryInformation; ndisChars.SetInformationHandler = MiniportSetInformation; ndisChars.DevicePnPEventNotifyHandler = MiniportDevicePnPEventNotify; ndisChars.ResetHandler = MiniportResetEx; status = NdisMRegisterMiniportDriver(DriverObject, RegistryPath, &ndisChars, &s_ndisHandle); return status; }注意 NdisMRegisterMiniportDriver 的最后一个参数是返回的 NDIS 句柄,之后所有 NdisMxxx 调用都要用到它,包括后面 NdisMRegisterDevice 的时候。
还有一个很多人会忽略的:你需要在某个时机获取一个“适配器句柄” NdisMRegisterMiniportDriver 只是注册了驱动本身,真正为某个网卡实例创建适配器,是在你的 KMDF EvtDeviceAdd 回调里调用 NdisMRegisterMiniport 完成的。KMDF 每枚举到一个 PCIe 设备就会触发一次 EvtDeviceAdd,你就在那里创建 NDIS 适配器,并设置好上下文。
4.3 中断与 DPC 的配合代码示例
中断回调里不适合做重活,接收数据搬移都放到 DPC。KMDF 下用 WdfInterruptCreate 来创建框架管理的中断对象,注册 InterruptIsr 和 InterruptDpc 两个回调,框架会自动帮你在正确的 IRQL 下调用它们,还能自动处理中断共享的问题。
下面是一个最小实现骨架:
BOOLEAN InterruptIsr(WDFINTERRUPT Interrupt, ULONG MessageID) { PDEVICE_CONTEXT ctx = GetContextFromInterrupt(Interrupt); // 1. 读取中断状态寄存器,判断是不是自己的中断 ULONG status = READ_REGISTER_ULONG(ctx->RegBase + INT_STATUS_REG); // 2. 检查是否使能且 pending,如果都不是返回 FALSE(共享中断场景必须) if (!(status & ctx->IntMask)) { return FALSE; } // 3. 写寄存器清除中断 pending 位 WRITE_REGISTER_ULONG(ctx->RegBase + INT_STATUS_REG, status); // 4. 保存状态,调度 DPC ctx->PendingStatus = status; WdfInterruptQueueDpc(Interrupt); return TRUE; }InterruptDpc 里我会先处理基于状态位分发:如果 pending 里有 RX 标志,去搬接收描述符;有 TX 标志,回收发送完成包;有链路状态变化,更新 NDIS 的媒体状态。
链路状态这个细节说一下:网线拔了之后硬件会置位一个状态位,你在 DPC 里要调用 NdisMIndicateStatus 通知 NDIS 媒体断开,然后再调 NdisMIndicateStatusComplete 结束状态指示。很多驱动忘了指示链路断开,导致网络连接图标显示未连接但实际收发异常,用户体感很怪。
5. 那些让人怀疑人生的调试与排查实录
5.1 数据包发不出去:DMA 描述符 cache 一致性的坑
第一次调通发送的时候,我遇到一个诡异的问题:小包(64 字节)能发出去,但大包(大于 MTU 的 jumbo frame)就会偶发丢包。后来用逻辑分析仪抓 PCIe 总线,发现硬件读到的描述符内容偶尔是“旧的”。原因很简单:我改完描述符之后,没有做内存屏障,也没有做 cache flush。PCIe 设备的 DMA 读的是内存,但 CPU 的写可能还停留在 cache 里没落回主存。
解决办法分两步:一是描述符本身所在内存必须是非 cacheable 的,可以在分配时用 MmAllocateNonCachedMemory,或者使用 WdfCommonBufferCreate 分配,它天然免 cache 一致性问题;二是在写描述符和 Kick 硬件之间,加一个内存屏障,用 KeMemoryBarrier(或者编译屏障+硬件屏障的组合),确保写操作对所有总线主控可见。
5.2 接收性能上不去:RSS 和中断亲和的调优组合拳
吞吐量测试是最能暴露问题的。我提到过一次 NUMA 的问题,但除了中断亲和性,接收路径上还有一个常见的瓶颈:RSS 没有开启或者哈希类型不对。如果你的网卡支持 4 队列,但 RSS 没配置,所有流都挤在一个队列,一个 CPU 跑到 100%,其他核心在围观,那性能自然难看。
调优步骤总结如下:
- 在 MiniportInitializeEx 里通过注册表读取 RSS 配置,调用 NdisMStartAdapterRss
- 配置哈希类型(IPv4/IPv6 的 TCP/UDP 四元组)
- 中断向量与 RSS 队列一一对应,并用 KeSetTargetProcessorDpc 把每个队列的 DPC 绑定到不同 CPU
- 接收 ring 大小适度加大,我一般设为 1024 个描述符,避免中断风暴
这几步做完,配合多队列收包,64B 小包的 PPS 能从最初的单队列 30 万左右提升到 120 万以上,效果立竿见影。
5.3 蓝屏排查三板斧
网卡驱动事故频发,我这里把排蓝屏的基本套路总结成三个绝招:
- 第一招,抓现场:用 WinDbg 连接内核调试,蓝屏时执行 !analyze -v,重点看 bugcheck code 和 faulting module。如果是 NDIS.sys 里面的函数崩溃,十有八九是你的小端口回调传了非法参数,要么是拿错句柄,要么是 NBL 状态不对
- 第二招,查 NDIS 状态:!ndiskd 系列命令非常好用。!ndiskd.miniport 列出所有小端口实例,!ndiskd.netbuffer 查看特定 NBL 的引用计数和状态,一抓一个准
- 第三招,启用驱动验证器:verifier /standard /driver mydriver.sys,重启后开着验证器跑测试,它能监测到内存越界、DMA 错误、锁序问题,虽然有时候会误报,但大部分时候能帮你把潜在 bug 提前炸出来。注意开验证器之后系统会变慢,生产环境切记别开
有一回我遇到一个内存越界问题,正常测试完全没事,但一开网卡多队列高负载跑十分钟必蓝屏。!analyze 定位到是 NDIS.sys 里某处访问了野指针,最后查出来是我在资源不足时,错误地释放了一个还在硬件 DMA ring 里的 buffer。这类问题只有验证器加高负载压力测试才能暴露出来,写驱动的一定要养成定期开验证器跑压测的习惯。
6. 从能用走向好用:一些实战细节补充
写到这里,很多人以为驱动能通、吞吐量也达标就算大功告成了。实际上,作为一个经历过产品级驱动交付的人,我想提醒几个“能用”和“好用”之间差距很大的细节。
第一个是电源管理。笔记本合盖再打开,网卡驱动如果休眠唤醒处理得不好,要么直接消失(设备管理器里感叹号),要么链路状态不对。关键点在于:设备进入 D3 之前,保存好 MAC 地址、流控配置、卸载引擎参数这些硬件寄存器;唤醒后重放这些配置。KMDF 下这对应 EvtDeviceArmWakeFromS0 和 EvtDeviceDisarmWakeFromS0,配合 NDIS 的 MiniportDevicePnPEventNotify 收到 NetEventSetPower 事件时做完整重初始化。
第二个是虚拟化环境下的问题。现在很多测试跑在 VMware/Hyper-V 上,虚拟网卡的硬件行为有时候和真实网卡不完全一致,比如 MSI-X 中断数量、描述符深度上限。不要因为虚拟环境测试通过就掉以轻心,一定要在真实多型号硬件上跑一轮完整的兼容性测试。
第三个是想清楚驱动签名和部署升级的问题。Windows 10 之后,内核驱动的签名策略越来越严。企业内部分发测试签名驱动是一回事,面向公众发布就必须走 WHQL 签名。签名不是开发的最后一步,而是开发流程的一部分,产品规划早期就要把认证预算和时间排进去,不然驱动写完了也只能自己在公司里用。
最后,回到代码层面,我建议你在驱动里多做遥测。比如用 WPP 软件跟踪(WDK 自带),在初始化、复位、链路切换、异常收发的关键路径上打点。问题是,驱动不像应用层那样能随便打日志,WPP 的 ETW 机制在性能损耗上非常低,而且在客户现场出了问题,只需要抓一份系统日志就能定位到具体回调有没有执行、耗时多少,这在排查线上问题时价值巨大。别等到用户报障了才去抓瞎。
NDIS 小端口驱动开发是个投入门槛较高、但一旦摸清套路就很有成就感的事情。从 DriverEntry 注册,到数据通路的收发,再到中断与电源的细节,每一环都考验对 Windows 内核和网络协议栈双向的理解。真心建议新手不要直接跳过理解 NDIS 架构这一步,不然写出来的代码,上线之后迟早要还。等把这些都吃透了,你再看 NDIS 上层那些协议驱动、过滤驱动,会发现很多思想是相通的。
本文还有配套的精品资源,点击获取