简介:一份基于Windows 10 x64平台的NDIS 6.0 Filter驱动示例,主要面向具有C/C++和Windows驱动基础的开发者,演示在KMDF框架下实现网络数据包处理功能:支持发送OID请求,能构造并发送ICMP自定义数据包,也可实时接收数据包,并新增了查询网卡MAC地址的代码,可作为NDIS过滤器驱动从入门到案例分析的完整参考。压缩包共16个文件,核心为C/C++源码文件(5个.c与5个.h),配套Visual Studio 2017工程文件与解决方案、INF驱动安装配置、资源文件以及一个C++演示程序,整体大小仅37KB,结构紧凑,便于快速阅读与实验。目前已有137人浏览学习。通过学习这份示例,可以掌握内核OID请求的发送与接收流程,理解NDIS驱动中缓冲区、内存资源的分配与回收机制,学会数据包收发的基本方法,并了解Filter驱动模块之间的组织方式,为后续开发网络监控、协议分析等实用工具打下基础。 写 Windows 网络驱动的人,多半都有过这种体验:驱动骨架已经能绑定网卡了,但产品只要多提一个需求——比如“把经过的数据包拿一份给我”或者“应用层要查网卡 MAC”——你就得重新扎进 NDIS 文档里翻半天。我手头这个基于 Win10 的 NDIS 6.0 Filter 驱动,就是在这种状态下新加了收发数据包和查询网卡 MAC 地址的代码。这篇文章把设计思路、关键代码片段、以及我在 Win10 测试机上踩过的实测坑整理出来,给同样在维护 NDIS Filter 驱动的朋友做个参考。
先说下背景:驱动本身不是从零开始写的,而是延续了一段老代码。原有部分能完成网卡绑定和基础透传,但只做到了“能加载、不断网”,离产品需求还差得远。这次新增的两个功能点,一个要求驱动能从发送和接收两条路径上记录数据包,一个要求用户态应用能通过 IOCTL 拿到当前绑定网卡的 MAC 地址。整个过程走完,我最大的感受是:NDIS Filter 驱动真正的门槛不在 API 本身,而在对 NBL 链表、OID 请求、IRQL 这些底层规矩的理解上。
1. 为什么“NDIS 6.0 Filter”和“MAC 地址查询”会写进同一个驱动
我做的是一个终端安全组件,用户态是一个管理服务,内核态就是这枚基于 NDIS 6.0 Filter 架构的驱动。它绑在指定网卡上,既要做流量统计,也要配合用户态做策略联动。Filter 驱动是 NDIS 6.0 开始主推的中间层模型,位置在协议驱动和网卡小端口驱动之间。相比更早的 NDIS 5 中间层驱动,Filter 不需要自己管理协议绑定,只需要处理“发给我的 NBL”,该转发就转发,想拦就停下,架构干净很多。
有人会问:“纯抓包,用 WinPcap/Npcap 或者 WFP 不就行了?”确实,单纯抓包用 WFP 的 callout 更省事。但我的需求里包含“应用层下发一个裸包,从指定网卡发出去”,还要在驱动内核态拿到本机 MAC 做设备指纹识别和网卡绑定。Filter 驱动在这条路径上有天然的操控权:发送方向先进它,接收方向也先进它,OID 请求同样可以从它这里往下发。所以最终代码里就出现了三个相互关联的功能:通过 IOCTL 记录收包、通过 IOCTL 下发数据包、以及查询 MAC 地址的 OID 请求。
这三个功能看着独立,实际在 NDIS 里共享同一套 NetBufferList 内存管理和同一套事件等待逻辑。理解了这个前提,后面看代码就不会觉得跳跃:所谓“新增收发数据包”,本质是在已有透传回调里插入处理和记录逻辑;所谓“查询 MAC 地址”,本质是让 Filter 驱动向下层小端口发起一个 OID 查询请求。
2. 动手前的三件套:签名、WDK 版本和虚拟机调试环境
先说 WDK。我用的是 VS2019 + WDK 10.0.19041.685,目标系统是 Win10 20H2/21H2 这类 x64 版本。工程里直接建的是“NDIS Filter Driver”模板,也就是很多网卡监控工具常用的那套骨架。这套模板自带的NDIS_FILTER_DRIVER_CHARACTERISTICS注册逻辑已经能跑通,省去了自己初始化 Filter 驱动对象的大量基础工作。
关于 NDIS 版本,我自己保留了“NDIS 6.0 Filter”的说法,主要是延续项目历史代码的习惯。Filter 框架的 API 从 6.0 到 6.x 兼容性整体不错,旧的NDIS_STRING等结构简单适配一下就能编译。但如果你是从零开始的新工程,面向 Win10 建议直接把 NDIS 版本设成 6.30 或 6.40,而不是继续盯在 6.0。因为新版 WDK 头文件对老版本 NDIS 的某些宏定义处理得并不完美,Windows 10 的 NDIS 版本支持表里,6.0 虽然是合法值,但很多新网卡驱动和新的电源管理、RSS 特性会走 6.x 的新路径,老版本过滤器在这些路径上可能拿不到完整功能。
调试环境我强烈建议用 VMware 装一台干净的 Win10 x64 虚拟机。别拿物理机直接测网络驱动,蓝屏概率太高,来回重启的成本不值得。虚拟机里接一个虚拟网卡给 Filter 绑定,宿主机通过 WinDbg 连接调试。我的配置大概是这样:
| 环境项 | 我的配置 | 说明 |
|---|---|---|
| 编译环境 | VS2019 + WDK 19041 | 模板自带 Filter 骨架 |
| 目标系统 | Win10 x64 虚拟机 | 版本建议 20H2+ |
| 调试器 | WinDbg Preview + 网络调试 | 网络调试比串口方便 |
| 签名 | 测试签名 | bcdedit /set testsigning on |
| 安装工具 | devcon / sc 命令 | 配合 INF 安装更新 |
这套环境准备好之后,后面遇到的问题基本都在代码逻辑本身,不会再被安装和签名打断。
3. 收发数据包的新增代码,避不开的几个 NDIS 回调
Filter 模板里最重要的回调有四个:SendNetBufferLists、SendNetBufferListsComplete、ReceiveNetBufferLists、ReturnNetBufferLists。模板默认行为很简单:发送进来就调用NdisFSendNetBufferLists继续往下扔,接收进来就调用NdisFIndicateReceiveNetBufferLists继续往上抛。所以“新增收发数据包代码”的本质,不是从零造轮子,而是在这四个回调里插入自己的处理逻辑,同时保持透传语义不变。
3.1 发送方向:记录 + 原样转发
发送方向我加了三块内容:统计计数、采集头部信息、原样转发。统计直接对 NBL 链做循环加计数器;采集头部信息是从NET_BUFFER的第一个 MDL 里把 Ethernet/IP 头复制到驱动的一块非分页缓冲区,用于后续上报;最后调用NdisFSendNetBufferLists把整条 NBL 链原封不动传下去,千万不能只传第一个包。
示意代码如下:
VOID FilterSendNetBufferLists( NDIS_HANDLE filterModuleContext, PNET_BUFFER_LIST netBufferLists, NDIS_PORT_NUMBER portNumber, ULONG sendFlags) { PDRIVER_CONTEXT ctx = (PDRIVER_CONTEXT)filterModuleContext; PNET_BUFFER_LIST nbl = netBufferLists; while (nbl != NULL) { InterlockedIncrement64(&ctx->sendPackets); RecordingPacketOnce(nbl); // 拷贝头部信息,不能长时间阻塞 nbl = NET_BUFFER_LIST_NEXT_NBL(nbl); } // 转发整条 NBL 链,嵌套 Filter 依赖这个行为 NdisFSendNetBufferLists(ctx->filterHandle, netBufferLists, portNumber, sendFlags); }3.2 接收方向:注意 NDIS_RECEIVE_FLAGS_RESOURCES
接收方向写法类似,但要特别注意一个 flag:当网卡处于资源紧张状态时,NDIS 会给ReceiveNetBufferLists传NDIS_RECEIVE_FLAGS_RESOURCES。这时候接收方不能做耗时操作,应当尽快原样转发。我第一次写接收回调时没管这个 flag,在驱动里做了一次完整的包内容拷贝,结果在把虚拟网卡网速拉到高位时出现丢包和延迟,后来发现就是这里拖了后腿。
VOID FilterReceiveNetBufferLists( NDIS_HANDLE filterModuleContext, PNET_BUFFER_LIST netBufferLists, NDIS_PORT_NUMBER portNumber, ULONG numberNetBufferLists, ULONG receiveFlags) { PDRIVER_CONTEXT ctx = (PDRIVER_CONTEXT)filterModuleContext; InterlockedIncrement64(&ctx->recvPackets); // 资源紧张时不分析,直接转发 if ((receiveFlags & NDIS_RECEIVE_FLAGS_RESOURCES) == 0) { RecordingPacketOnce(netBufferLists); } NdisFIndicateReceiveNetBufferLists(ctx->filterHandle, netBufferLists, portNumber, numberNetBufferLists, receiveFlags); }还有一件事,很多新手在这里漏掉:接收方向有两种结局。如果继续往上转发,就调NdisFIndicateReceiveNetBufferLists;如果驱动想自己留下这些包、不往上抛,就必须调NdisFReturnNetBufferLists把包还给底层。我调试时遇到一个“ping 不通但网卡状态正常”的怪问题,最后发现是某个分支里忘了 return,协议层再也收不到数据了。
3.3 应用层下发数据包:注意内存分配时机
“发送”的真正难点,是用户态程序通过DeviceIoControl传一段以太网帧数据,驱动把这些数据组装成 NBL 发到网卡。组装 NBL 有几个硬性要求:
- 数据缓冲区必须是非分页内存,不能直接指向用户传入的缓冲区;
- 每个 NBL 里的
NET_BUFFER要从NPagedPool分配; - 发送完成后,
SendNetBufferListsComplete回调会被 NDIS 调用,必须在这个回调里释放之前分配的内存,不能提前释放。
我的实现是:把用户态缓冲拷贝到NonPagedPool内存,构造好 NBL,挂到一个发送队列,然后调用NdisFSendNetBufferLists。发送完成回调里做清理,并设置事件通知应用层发送结果。这里最容易出问题的就是“提前释放”:如果在 IOCTL 线程里分配完内存后马上释放,NDIS 底层还没把包真正送出去,后续访问就是悬空指针,几乎必然蓝屏。我在头两个版本里就栽在这上面,后来养成了“谁拥有 NBL 内存,谁负责在 complete 路径里释放”的习惯,这个问题才算根治。
4. 查询网卡 MAC:从 IOCTL 到 OID 请求,中间隔着一个事件等待
应用层要查 MAC,其实GetAdaptersAddresses一行代码就搞定了,为什么还非要在驱动里查?关键原因是:驱动绑定的是“Filter 实例所在的那块网卡”,用户态枚举出来的 Adapter 顺序和 Filter 绑定的网卡不一定对应。与其在应用层通过网卡名字一个个对,不如直接在驱动内部对当前绑定的网卡发一个 NDIS OID 请求,把 MAC 拿回来,再通过 IOCTL 返回给应用层。这样能保证应用层拿到的 MAC 就是驱动真正绑定的那块网卡的 MAC,不会出现“查到的地址和实际走流量的网卡对不上”这种问题。
4.1 一次标准 OID 查询请求的写法
在 Filter 驱动里主动查询 MAC,思路是向底层小端口发OID_802_3_CURRENT_ADDRESS查询请求,用到的关键 API 是NdisFOidRequest。核心流程是:初始化NDIS_OID_REQUEST结构,填上 OID 号和缓冲区,调用NdisFOidRequest,如果返回NDIS_STATUS_PENDING,等待完成事件。简化代码大致是这样:
NDIS_STATUS QueryMacAddress( NDIS_HANDLE filterHandle, UCHAR macAddress[6]) { NDIS_OID_REQUEST oidRequest; NDIS_STATUS status; KEVENT completionEvent; LARGE_INTEGER timeout; RtlZeroMemory(&oidRequest, sizeof(oidRequest)); oidRequest.Header.Type = NDIS_OBJECT_TYPE_OID_REQUEST; oidRequest.Header.Revision = NDIS_OID_REQUEST_REVISION_1; oidRequest.Header.Size = NDIS_SIZEOF_OID_REQUEST_REVISION_1; oidRequest.RequestType = NdisRequestQueryInformation; oidRequest.DATA.QUERY_INFORMATION.Oid = OID_802_3_CURRENT_ADDRESS; oidRequest.DATA.QUERY_INFORMATION.InformationBuffer = macAddress; oidRequest.DATA.QUERY_INFORMATION.InformationBufferLength = 6; KeInitializeEvent(&completionEvent, NotificationEvent, FALSE); status = NdisFOidRequest(filterHandle, &oidRequest); if (status == NDIS_STATUS_PENDING) { // 给 2 秒超时,避免底层不响应导致线程卡死 timeout.QuadPart = -2LL * 10000000; status = KeWaitForSingleObject(&completionEvent, Executive, KernelMode, FALSE, &timeout); if (status != STATUS_SUCCESS) { return NDIS_STATUS_TIMEOUT; } status = oidRequest.DATA.QUERY_INFORMATION.BytesWritten >= 6 ? NDIS_STATUS_SUCCESS : NDIS_STATUS_FAILURE; } return status; }4.2 完成回调:没有它,演练不了真实工程
这段代码看起来短,但里面有一个非常容易忽略的细节:NdisFOidRequest返回NDIS_STATUS_PENDING时,请求还没有真正完成,必须等待一个“完成信号”。在 Filter 驱动里,这个完成信号不是光等一个空事件就能等来的——实际项目里要给NDIS_OID_REQUEST关联一个请求上下文,在FilterOidRequestComplete回调里对事件做置位操作。我第一版偷懒没做完整上下文,结果出现偶发性获取 MAC 失败,加上完成回调之后才稳定下来。
另一个更实用的经验:驱动不要每次应用层查询都走一次 OID 请求。网卡 MAC 在运行期基本不变,我在网卡绑定初始化时查询一次,存到驱动上下文里;应用层随时来问,直接内存拷贝返回。只有在收到媒体断开/连接事件,或者用户强制刷新时,才重新查一次。这样既避免了频繁构建 OID 请求的开销,也不会因为底层驱动处理 OID 慢导致应用层超时。
4.3 两个细节经验:缓冲区长度和多网卡处理
一是 OID 查询的InformationBufferLength,传 6 字节确实能查到 802.3 地址,但有些网卡驱动实现得不够规范,会要求缓冲区至少是 12 字节或者更长的对齐值。为了兼容性,我在工程里把缓冲区开成 32 字节,用前 6 字节作为 MAC。这是典型的花钱买不到的细节,照着文档写 6 字节也不会报错,但实际遇到不规范的网卡驱动就会得到失败返回。
二是多网卡情况下,每个 Filter 实例有各自的FilterModuleContext。查询 MAC 时一定要用当前实例的filterHandle,不要写成全局的默认句柄。否则绑定的网卡多了以后,应用层拿到的 MAC 可能永远是第一块网卡的地址,排查起来非常隐蔽。
5. 实测机器的反击:蓝屏、返回值和那些“表面正常”的坑
代码逻辑写完,真正花时间的地方在实测。我在这台 Win10 测试虚拟机上走了不少弯路,挑几个有代表性的说说。
5.1 签名与安装
Win10 x64 对内核驱动强制要求签名。第一件事是开测试签名模式:
bcdedit /set testsigning on重启后,每次编译出的驱动要用signtool打测试证书,或者把 INF 的 CatalogFile 处理好。否则devcon update会看到“数字签名无法验证”之类的安装错误。我平时安装驱动的命令是这样几条:
devcon update filter.inf "Root\MYFILTER" devcon restart "Root\MYFILTER" devcon remove "Root\MYFILTER"注意 Filter 驱动有绑定关系,直接卸载正在绑定的驱动会让网卡先掉线再恢复。测试时最好先把应用层服务一起停掉,避免一边发包一边卸载导致状态错乱。
5.2 典型故障记录
下面这张表是我这次实测过程中印象最深的几次故障,写的都是真实排查链路,不是理论猜测:
| 故障现象 | 可能原因 | 我最后的处理 |
|---|---|---|
| 加载驱动后 ping 不通,网卡状态正常 | 接收方向返回路径处理错误,没有把 NBL 正确 return | 重新梳理ReceiveNetBufferLists的两个分支,该 return 的补全 |
偶发蓝屏PAGE_FAULT_IN_NONPAGED_AREA | 在DISPATCH_LEVEL访问了分页内存 | 包分析缓冲区全部改成NonPagedPoolNx,热路径停用DbgPrint |
| 应用层下发包后驱动无响应 | 在 IOCTL 调用线程里做耗时发送导致阻塞 | 单独开内核发送线程,IOCTL 只负责把数据加入队列 |
| MAC 查询偶发全 0 | 等待事件不完整,超时或没置位 | 加入请求上下文 +完成回调置位,并为等待加超时 |
蓝屏排查我习惯用 WinDbg 打开 dump 后直接!analyze -v,先看故障模块是不是myFilter.sys。如果堆栈停在NdisFSendNetBufferLists或NdisFIndicateReceiveNetBufferLists,那八成是在 NDIS 层面违反了规则,比如错误 IRQL 下访问分页内存,或者传了空的 NBL 链。
5.3 用好 ndiskd 调试命令
WinDbg 里有一组很好用的 NDIS 调试命令。命令行输入!ndiskd.filter、!ndiskd.netbuffer,可以直接看到过滤器实例、NBL 链和队列状态。我遇到过“上层收不到包”的问题,用!ndiskd.filter查看每个绑定实例的ReceiveNetBufferLists回调是否正常,再对照!ndiskd.nbl看 NBL 的 ownership 归属,很快就能定位到是哪个分支没往下传。这个工具比盲加打印高效太多,建议每个做 NDIS 开发的人都提前熟悉。
5.4 热路径别打日志
最后提一个性能问题:内核驱动在 Send/Receive 热路径上每包打一个DbgPrint,哪怕在虚拟机里也会让吞吐量掉到不可接受的水平。我最后把所有热路径日志都改成 WPP 或分级控制,平时只保留计数器,只有显式打开调试标志时才输出包摘要。这个改进对确认稳定性和持续调试都很有帮助。
这轮改动上线之后,最直观的收获是:用户态管理服务能够通过同一个驱动的 IOCTL 接口完成抓包记录、下发数据包、查询网卡 MAC 三件事,不再需要额外跑一个 WinPcap,也不用在应用层拼拼凑凑查地址。对我个人来说,写 Filter 驱动最难啃的还是 NDIS 里那套 NBL 和 OID 的规矩——它不像普通应用开发那样有即时反馈,一个内存引用错误就得靠蓝屏来教会你。这次改动让我养成了一个习惯:动 NBL 之前,先问自己“这个链最终谁会释放、我有没有能力在完成回调里补齐收尾”。这个习惯,比任何模板代码都有用。
本文还有配套的精品资源,点击获取