2026/10/29 23:48:10

从零实现Windows NDIS以太网小端口驱动:回调、收发与OID控制

从零实现Windows NDIS以太网小端口驱动:回调、收发与OID控制 简介面向 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 不释放 MDLOidRequestHandler查询/设置硬件参数必需返回 PENDING 却没 CompleteCancelSendNetBufferListsHandler上层取消发送强烈建议取消后 NBL 被重复完成CheckForHangHandlerExNDIS 定时检查建议卡死后无法自愈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(regAttr, 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)regAttr); 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 是保命位没有它驱动卸载时如果还有接收包没还给 NDISHalt 已经跑完、内存已释放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; // 必须是 6NDIS 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_1234DEV_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:ConfigurationRelease /p:Platformx64 # 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 里塞一个「回家地址」记录这个包用的缓冲下标。本文还有配套的精品资源点击获取