简介:面向 Windows 网络驱动开发者的 NDIS 6.0 小端口(miniport)驱动实例,聚焦 Realtek 8111/8168/8169/8110 等 PCI 千兆以太网控制器,弥补公开 NDIS 6 miniport 参考仅见 DDK E100BEX 的不足,适合具备 C 语言与 WDM 基础、希望深入网卡驱动开发的中高级读者。压缩包共 69 个文件,约 615KB,主要由 htm 网页文档、js 脚本、gif/png/jpg 图片与 css 样式构成,并含数个 zip 子包,分别承载发布版本及 LSO/Jumbo frame、电源管理等功能增补,便于分层查阅。已有 1242 人浏览学习,说明其在同类驱动资料中具有一定参考热度。内容围绕 NDIS6 小端口驱动骨架展开,涉及适配器初始化、回调注册、中断与收发路径、DMA 描述符、OID 请求处理、复位卸载等关键环节;配合功能增补包,可对照理解千兆网卡在吞吐优化与低功耗场景下的实现差异,适合作为移植、调试和课程设计的参考底稿。
1. 自研以太网卡插上机器认不出来,缺的到底是什么
去年帮一个做硬件的朋友调一块自研的以太网卡。板子焊好、EEPROM 烧完、PCIe 插槽一插,设备管理器里只多出一个带黄色感叹号的未知设备,系统连它是什么都不知道。硬件本身没问题,缺的是中间那一层——NDIS 小端口驱动(miniport driver)。它的职责说起来很纯粹:向上,把一块具体的以太网卡包装成 NDIS 眼里的标准适配器,让 TCP/IP 协议栈觉得它和市面上任何一块网卡没区别;向下,把 NDIS 递过来的 NET_BUFFER_LIST 翻译成网卡能懂的 DMA 描述符和寄存器写操作。这篇文章面向两类人:手上有一块自定义网卡、需要写 Windows 驱动把它变成一块真正以太网卡的底层开发;以及想读懂 NDIS 小端口这套对象模型、拿它做虚拟网卡或流量注入的内核开发。我会把一个最小可用的以太网小端口从工程搭建、注册回调、收发链路一直拆到 OID 控制面和排错。
2. 架构拆解:小端口驱动在 NDIS 栈里的位置与回调约定
先把位置摆正,不然后面所有的回调都会填错。NDIS 不是一个协议,它是内核里的一层中间件,负责把「协议驱动」和「小端口驱动」这两类完全不相干的东西粘在一起。理解这一点,比记住十几个 MiniportXxx 函数名重要得多。
2.1 一个 ping 包从应用到网线上的四层接力
从上层往下数:应用调用 socket,落到 tcpip.sys 这个协议驱动;协议驱动不直接碰硬件,它把要发的数据封装成 NET_BUFFER_LIST,交给 NDIS 库;NDIS 库根据绑定关系找到对应的小端口驱动,调用它的发送回调;小端口驱动把数据搬进网卡的发送环形缓冲,写 doorbell 寄存器,硬件开始 DMA。收包方向完全反过来:网卡收到帧、DMA 写进接收环、触发中断,小端口驱动在 DPC 里把缓冲包装成 NET_BUFFER_LIST 往上报,最后由 NDIS 交给协议驱动。
这条链路里有三条并行的通道,很多人只盯着第一条:
| 通道 | 载体 | 方向 | 典型回调 |
|---|---|---|---|
| 数据面 | NET_BUFFER_LIST / NET_BUFFER | 双向 | SendNetBufferLists、ReturnNetBufferLists |
| 控制面 | OID 请求 | 协议驱动 → 小端口 | OidRequest |
| 状态面 | 状态指示 | 小端口 → 协议驱动 | NdisMIndicateStatusEx |
数据面负责吞吐,控制面负责「查询 MAC 地址、设置包过滤、读统计」这类一问一答,状态面是小端口主动汇报「网线插上了、速率协商成 1G 全双工」。三条通道缺一条,网卡在系统里的表现就是「能认出来但不工作」或者「能工作但图标打叉」。
NDIS 库还替你兜了一大堆脏活:中断和 DPC 的注册与调度、DMA 适配器的申请、NBL 内存池、电源管理状态机、适配器暂停与重启。小端口驱动要写的,本质上只是「怎么和我这块芯片说话」——读哪个寄存器、写哪个描述符、中断状态位怎么清。把这个边界想清楚,代码量能砍掉一大半。
2.2 回调表:NDIS_MINIPORT_DRIVER_CHARACTERISTICS 里哪些必须填
NDIS 6.x 的小端口驱动靠一张特性表注册自己,表里每个字段都是一个函数指针。填不满,DriverEntry 里 NdisMRegisterMiniportDriver 直接返回失败,或者驱动能装上但一跑就蓝。
| 回调字段 | 触发时机 | 是否必需 | 容易出问题的地方 |
|---|---|---|---|
| InitializeHandlerEx | 适配器被启用 | 必需 | 属性没设置完就返回成功 |
| HaltHandlerEx | 适配器被禁用/卸载 | 必需 | 释放顺序和初始化相反 |
| SendNetBufferListsHandler | 协议栈发包 | 必需 | 没归还 NBL 导致池耗尽 |
| ReturnNetBufferListsHandler | 协议栈归还接收缓冲 | 必需 | 只释放 NBL 不释放 MDL |
| OidRequestHandler | 查询/设置硬件参数 | 必需 | 返回 PENDING 却没 Complete |
| CancelSendNetBufferListsHandler | 上层取消发送 | 强烈建议 | 取消后 NBL 被重复完成 |
| CheckForHangHandlerEx | NDIS 定时检查 | 建议 | 卡死后无法自愈 |
| UnloadHandler | 驱动卸载 | 必需 | 忘了 NdisMDeregisterMiniportDriver |
中断不在表里,NDIS 6 之后中断走独立的 NdisMRegisterInterruptEx,在初始化回调里单独注册,支持 MSI/MSI-X。这个设计比早期版本清爽很多,中断对象和适配器对象可以一一对应。
2.3 适配器上下文与生命周期:InitializeEx 里的两次属性设置
整个驱动只有一个「状态容器」,就是你自己定义的适配器上下文结构体。NDIS 不关心它长什么样,只保存一个 NDIS_HANDLE;之后每一个回调的第一个参数都是这个 HANDLE,你把它强转回自己的结构体指针。所有硬件状态、锁、统计计数、环形队列下标全放里面,不要用全局变量——多网卡场景下全局变量是灾难。
初始化回调里有一处顺序不能错:必须先把 MiniportAdapterContext 通过 NdisMSetMiniportAttributes 交给 NDIS,之后才能做任何可能触发回调的事情。因为 NDIS 在后续调用中会把这个上下文原样回传,如果它此时还是空的,第一个 OID 查询进来就是空指针解引用。
NDIS_STATUS MpInitialize( NDIS_HANDLE MiniportAdapterHandle, NDIS_HANDLE MiniportDriverContext, PNDIS_MINIPORT_INIT_PARAMETERS InitParameters) { PMP_ADAPTER pAdapter = NULL; NDIS_STATUS status; NDIS_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES regAttr; NDIS_MINIPORT_ADAPTER_GENERAL_ATTRIBUTES genAttr; // 1) 先分配上下文,清零,打上 magic status = MpAllocAdapter(&pAdapter, MiniportAdapterHandle); if (status != NDIS_STATUS_SUCCESS) return status; // 2) 第一次设置属性:只填注册信息,把上下文地址交出去 NdisZeroMemory(®Attr, sizeof(regAttr)); regAttr.Header.Type = NDIS_OBJECT_TYPE_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES; regAttr.Header.Revision = NDIS_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES_REVISION_1; regAttr.Header.Size = NDIS_SIZEOF_MINIPORT_ADAPTER_REGISTRATION_ATTRIBUTES_REVISION_1; regAttr.MiniportAdapterContext = pAdapter; // 关键:后面所有回调靠它找回状态 regAttr.AttributeFlags = NDIS_MINIPORT_ATTRIBUTES_HARDWARE_DEVICE | NDIS_MINIPORT_ATTRIBUTES_NO_HALT_UNTIL_ALL_RECEIVES_RETURNED; regAttr.InterfaceType = NdisInterfacePci; regAttr.CheckForHangTimeInSeconds = 4; status = NdisMSetMiniportAttributes(MiniportAdapterHandle, (PNDIS_MINIPORT_ADAPTER_ATTRIBUTES)®Attr); if (status != NDIS_STATUS_SUCCESS) { MpFreeAdapter(pAdapter); return status; } // 3) 第二次设置属性:媒体类型、MAC 地址、能力位 NdisZeroMemory(&genAttr, sizeof(genAttr)); genAttr.Header.Type = NDIS_OBJECT_TYPE_MINIPORT_ADAPTER_GENERAL_ATTRIBUTES; genAttr.Header.Revision = NDIS_MINIPORT_ADAPTER_GENERAL_ATTRIBUTES_REVISION_2; genAttr.Header.Size = NDIS_SIZEOF_MINIPORT_ADAPTER_GENERAL_ATTRIBUTES_REVISION_2; genAttr.MediaType = NdisMedium802_3; genAttr.PhysicalMediumType = NdisPhysicalMedium802_3; genAttr.MaxMulticastListSize = 32; genAttr.MacOptions = NDIS_MAC_OPTION_TRANSFERS_NOT_PEND | NDIS_MAC_OPTION_RECEIVE_SERIALIZED; genAttr.SupportedPacketFilters = NDIS_PACKET_TYPE_DIRECTED | NDIS_PACKET_TYPE_MULTICAST | NDIS_PACKET_TYPE_BROADCAST | NDIS_PACKET_TYPE_ALL_MULTICAST | NDIS_PACKET_TYPE_PROMISCUOUS; genAttr.MaximumLookahead = 128; genAttr.LookaheadSize = 128; genAttr.MacAddressLength = ETH_LENGTH_OF_ADDRESS; NdisMoveMemory(genAttr.CurrentMacAddress, pAdapter->MacAddr, ETH_LENGTH_OF_ADDRESS); NdisMoveMemory(genAttr.PermanentMacAddress, pAdapter->MacAddr, ETH_LENGTH_OF_ADDRESS); status = NdisMSetMiniportAttributes(MiniportAdapterHandle, (PNDIS_MINIPORT_ADAPTER_ATTRIBUTES)&genAttr); if (status != NDIS_STATUS_SUCCESS) { MpFreeAdapter(pAdapter); return status; } // 4) 映射 BAR、注册中断、建 NBL 池,最后上报链路状态 status = MpMapHardware(pAdapter); if (status != NDIS_STATUS_SUCCESS) { MpFreeAdapter(pAdapter); return status; } MpRegisterInterrupt(pAdapter); MpCreatePools(pAdapter); MpIndicateLinkState(pAdapter); return NDIS_STATUS_SUCCESS; }逐段说明:第 1 步的 MpAllocAdapter 里要 NdisZeroMemory 整个结构体,并把 Magic 字段赋值——这个字段后面每次回调入口都会校验一次,蓝屏转储里能第一眼判断出是野指针还是结构体被踩。第 2 步的 AttributeFlags 里那个 NO_HALT_UNTIL_ALL_RECEIVES_RETURNED 是保命位:没有它,驱动卸载时如果还有接收包没还给 NDIS,Halt 已经跑完、内存已释放,NDIS 还来调 ReturnNetBufferLists,直接页错误。第 3 步的 MacOptions 里 RECEIVE_SERIALIZED 表示你保证同一时刻只有一个 CPU 在跑接收路径,能省掉一堆自旋锁。第 4 步任何一环失败,必须走统一的释放函数按初始化的反序退回,半初始化的适配器比没初始化更危险。
3. 工程落地:从 WDK 工程到设备管理器里认出一块网卡
代码写得再漂亮,装不进系统就什么都不是。这一章把工具链、源码骨架、INF 和加载验证串成一条能复现的路。
3.1 工具链与目录骨架
一台构建机装 Visual Studio 加对应版本的 WDK,装完在新建项目里能看到「Driver → Kernel Mode Driver (KMDF)」这类模板,但 NDIS 小端口建议直接拿 WDK 里的 ndis 示例工程改,或者从空驱动工程手写 vcxproj,把配置里的 Driver Type 设成 NDIS、Target OS Version 设成你实际要支持的系统版本。编译用 msbuild 就行,不需要额外的构建脚本。
我自己习惯的目录结构长这样,好处是每个文件职责单一,改哪块心里有数:
demoethmp/ ├── src/ │ ├── driver.c # DriverEntry,只负责填特性表并注册 │ ├── init.c # 初始化、属性设置、Halt、CheckForHang │ ├── send.c # 发送路径与发送环管理 │ ├── recv.c # 接收路径、NBL 池、回收 │ ├── oid.c # OID 查询与设置的分发 │ ├── hw.c # 寄存器读写、DMA 描述符、EEPROM/MAC │ └── mp.h # 适配器上下文、宏、环形队列定义 ├── inf/ │ └── demoethmp.inf └── demoethmp.vcxprojmp.h 里的适配器结构体是整个项目的核心,字段加错地方会牵连所有模块:
#define MP_ADAPTER_MAGIC 0x4D504144u /* 'MPAD',回调入口先校验 */ #define MP_TX_RING_SIZE 256 #define MP_RX_RING_SIZE 256 typedef struct _MP_ADAPTER { ULONG Magic; NDIS_HANDLE AdapterHandle; // NDIS 给的适配器句柄 NDIS_HANDLE NblPool; // 接收用 NBL 池,带 ContextSize NDIS_HANDLE InterruptHandle; // NdisMRegisterInterruptEx 返回 NDIS_SPIN_LOCK Lock; // 保护下面所有环形下标 PUCHAR RegBase; // MmMapIoSpace 映射后的 BAR0 ULONG RegLength; UCHAR MacAddr[6]; // 从 EEPROM 读出的永久地址 BOOLEAN LinkUp; ULONG LinkSpeedMbps; ULONG TxHead, TxTail; // 软件维护的发送环 ULONG RxHead, RxTail; ULONG64 XmitOk, RcvOk, XmitErr, RcvErr; } MP_ADAPTER, *PMP_ADAPTER;MAC 地址这里有个教训:不要把 CurrentMacAddress 直接指向结构体里的数组去设置,NdisMSetMiniportAttributes 之后这个地址可能被协议栈改写(比如用户手动改 MAC),你必须同时留一份永久地址用于 OID_802_3_PERMANENT_ADDRESS 查询,否则「恢复默认 MAC」这个功能就废了。
3.2 DriverEntry:只做填表和注册两件事
NDIS_MINIPORT_DRIVER_CHARACTERISTICS g_MpChars; NDIS_STATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { NDIS_STATUS status; NdisZeroMemory(&g_MpChars, sizeof(g_MpChars)); g_MpChars.Header.Type = NDIS_OBJECT_TYPE_MINIPORT_DRIVER_CHARACTERISTICS; g_MpChars.Header.Revision = NDIS_MINIPORT_DRIVER_CHARACTERISTICS_REVISION_3; g_MpChars.Header.Size = NDIS_SIZEOF_MINIPORT_DRIVER_CHARACTERISTICS_REVISION_3; g_MpChars.MajorNdisVersion = 6; // 必须是 6,NDIS 5 是老接口 g_MpChars.MinorNdisVersion = 85; // 对应目标系统支持的最小版本 g_MpChars.InitializeHandlerEx = MpInitialize; g_MpChars.HaltHandlerEx = MpHalt; g_MpChars.UnloadHandler = MpUnload; g_MpChars.SendNetBufferListsHandler = MpSendNetBufferLists; g_MpChars.ReturnNetBufferListsHandler = MpReturnNetBufferLists; g_MpChars.CancelSendNetBufferListsHandler= MpCancelSend; g_MpChars.OidRequestHandler = MpOidRequest; g_MpChars.CancelOidRequestHandler = MpCancelOid; g_MpChars.CheckForHangHandlerEx = MpCheckForHang; // 用一个静态变量当驱动上下文,只放全局只读数据(如日志句柄) status = NdisMRegisterMiniportDriver(DriverObject, RegistryPath, &g_DriverContext, &g_MpChars, &g_MpDriverHandle); return status; }MajorNdisVersion 必须是 6,填 5 会被当成老式驱动走完全不同的注册路径,行为诡异到怀疑人生。MinorNdisVersion 决定你能用哪些新特性,填高了在老系统上直接注册失败,填低了有些 OID 结构体尺寸对不上。我一般的做法是查目标系统支持的最低版本,宁低勿高。DriverEntry 里除了填表和注册,不要做任何硬件访问——此时适配器根本还不存在,也没有人保证你的驱动会一直被加载着。
3.3 INF:让系统知道这块 PCI 设备归你管
NDIS 小端口是网络类驱动,INF 必须写成 Net 类,并且要声明 Ndi 接口。少了 Ndi 那几行,驱动装上了但网络连接面板里不会出现适配器。
[Version] Signature = "$Windows NT$" Class = Net ClassGUID = {4d36e972-e325-11ce-bfc1-08002be10318} Provider = %ProviderName% CatalogFile = demoethmp.cat DriverVer = 06/01/2024,1.0.0.0 PnpLockdown = 1 [Manufacturer] %MfgName% = DemoEthMfg, NTamd64 [DemoEthMfg.NTamd64] %DeviceDesc% = DemoEthMp.ndi, PCI\VEN_1234&DEV_5678 [DemoEthMp.ndi] Characteristics = 0x1 BusType = 5 AddReg = DemoEthMp.Reg CopyFiles = DemoEthMp.Copy [DemoEthMp.Reg] HKR, Ndi, Service, 0, "DemoEthMp" HKR, Ndi\Interfaces, UpperRange, 0, "ndis5" HKR, Ndi\Interfaces, LowerRange, 0, "ethernet" HKR, Ndi\Params\*IfType, ParamDesc, 0, "Interface Type" HKR, Ndi\Params\*IfType, type, 0, "enum" HKR, Ndi\Params\*IfType, default, 0, "6" HKR, Ndi\Params\*MediaType, default, 0, "0" HKR, Ndi\Params\*PhysicalMediaType, default, 0, "14" [DemoEthMp.Copy] demoethmp.sys,,,2 [DemoEthMp.ndi.Services] AddService = DemoEthMp, 0x200, DemoEthMp.Service [DemoEthMp.Service] DisplayName = %ServiceDesc% ServiceType = 1 StartType = 3 ErrorControl = 1 ServiceBinary = %12%\demoethmp.sys几个参数值得单独讲:UpperRange 写 ndis5 表示向上暴露的是 NDIS 接口,协议栈据此决定要不要绑定;LowerRange 写 ethernet 表示物理介质是以太网,网络连接面板才会给你一个「以太网」图标;PhysicalMediaType 的 14 对应 NdisPhysicalMedium802_3,如果这里写成别的值,系统可能会把它当成拨号适配器或者干脆不显示。VEN/DEV 那两个值换成你板子实际的厂商号和设备号,SUBSYS 也要对,否则匹配不到。
3.4 编译、签名、加载验证
# 1) 编译,产出 demoethmp.sys msbuild demoethmp.vcxproj /p:Configuration=Release /p:Platform=x64 # 2) 生成目录文件,把 INF 和 SYS 关联起来 Inf2Cat /driver:inf /os:10_X64 # 3) 签名(测试环境用自签证书即可) signtool sign /fd sha256 /a /f testcert.pfx /p YourPassword inf\demoethmp.cat签名这步不能省。64 位系统上未签名的内核驱动加载会直接失败,错误码一般是 0xE0000247 之类。测试机上要先把测试签名模式打开(bcdedit /set testsigning on后重启),再在目标机上加载:
pnputil /add-driver inf\demoethmp.inf /install装完看两个地方:设备管理器里「网络适配器」下应该出现你 INF 里 %DeviceDesc% 那串名字,不带感叹号;ipconfig /all里能看到一个带 MAC 地址的新接口。两边都对上,说明注册路径和属性设置走通了,接下来才轮得到收发。
验证阶段我一般备一个调试通道:内核调试器挂上去,在 MpInitialize 返回前打个断点,确认 AdapterHandle、NblPool、InterruptHandle 三个句柄都不为 NULL。这一步花五分钟,能省掉后面几小时的盲猜。
4. 收发与控制两条链路:NET_BUFFER_LIST 数据面与 OID 请求处理
数据面的核心矛盾只有一个:内存的所有权什么时候从 NDIS 转给你,什么时候转回去。这条线理顺了,剩下全是寄存器和描述符的活。
4.1 发送路径:先拷贝再完成,还是等硬件回执
发送回调拿到的是一个 NBL 链表,一个 NBL 里可能串着多个 NET_BUFFER。每个 NB 对应一段待发数据,可能由多个 MDL 描述,也可能是非连续内存。最省心的做法是:自己预分配一块 DMA 一致性内存当发送缓冲,把 NB 里的数据 memcpy 进去,写完描述符、敲 doorbell,然后立刻把这批 NBL 归还给 NDIS。代价是一次内存拷贝,好处是所有权关系极其清晰,不会出现「硬件还在读、NDIS 已经复用」这种灾难。
VOID MpSendNetBufferLists( NDIS_HANDLE MiniportAdapterContext, PNET_BUFFER_LIST NetBufferList, NDIS_PORT_NUMBER PortNumber, ULONG SendFlags) { PMP_ADAPTER pAdapter = (PMP_ADAPTER)MiniportAdapterContext; PNET_BUFFER_LIST pCurr = NetBufferList; PNET_BUFFER_LIST pDone = NULL; // 一批同步完成的,最后一次性归还 BOOLEAN bQueueFull = FALSE; if (pAdapter->Magic != MP_ADAPTER_MAGIC) return; // 结构体被踩的保护 NdisAcquireSpinLock(&pAdapter->Lock); while (pCurr != NULL) { PNET_BUFFER_LIST pNext = NET_BUFFER_LIST_NEXT_NBL(pCurr); NET_BUFFER_LIST_NEXT_NBL(pCurr) = NULL; if (!bQueueFull && MpTxRingHasRoom(pAdapter)) { // 逐 NB 拷贝到预分配的发送缓冲,写完描述符 MpCopyAndBuildTxDesc(pAdapter, pCurr); } else { // 环满:挂到软件队列,等发送完成 DPC 里补发 NET_BUFFER_LIST_NEXT_NBL(pCurr) = pAdapter->TxPendingHead; pAdapter->TxPendingHead = pCurr; bQueueFull = TRUE; pCurr = pNext; continue; } // 数据已经进自己的缓冲,硬件读的是那块内存,可以安全归还 NBL NET_BUFFER_LIST_STATUS(pCurr) = NDIS_STATUS_SUCCESS; NET_BUFFER_LIST_NEXT_NBL(pCurr) = pDone; pDone = pCurr; pCurr = pNext; } MpKickTx(pAdapter); // 写 doorbell,告诉硬件有新描述符 NdisReleaseSpinLock(&pAdapter->Lock); if (pDone != NULL) { NdisMSendNetBufferListsComplete(pAdapter->AdapterHandle, pDone, 0); } }几个点必须说清。第一,pCurr 摘下后要立刻把 NEXT_NBL 置空,否则你归还的是整条链,NDIS 会以为后面的也完成了,触发重复完成蓝屏。第二,环满时不能直接丢弃,NDIS 不会自动重发;挂到软件队列里,在发送完成 DPC 里继续推进,这是必须实现的一环。第三,NdisMSendNetBufferListsComplete 一定要放在释放自旋锁之后调用,它在某些路径上会进协议驱动的回调,持锁调用容易死锁。
如果后面要走零拷贝,思路是把 NB 里的 MDL 链直接挂到 DMA 描述符上,硬件发完再在 DPC 里归还——这时候就必须实现 CancelSendNetBufferListsHandler,因为上层可能在你还没发完的时候把这批数据取消掉,你得保证取消和完成不会对同一个 NBL 各做一次。
4.2 接收路径:NBL 池的 ContextSize 是关键
接收方向反过来:数据已经在你的接收环里,你要把它包装成 NDIS 认识的 NBL 往上交。这里有个绕不开的问题——包交给协议栈之后,你并不知道它什么时候被还回来,所以你得在 NBL 里塞一个「回家地址」,记录这个包用的缓冲下标。
本文还有配套的精品资源,点击获取