windows 驱动实例分析系列: wintun驱动分析-api篇(三)
Wintun API 模块深度解析(文档三):会话管理与数据路径
一、概述
本文档聚焦session.c,它实现了 Wintun 数据会话的全部功能,包括会话的启动与结束、数据包的接收与发送。数据路径是整个 Wintun 性能的关键所在,其设计充分利用了 Windows 内核驱动与用户态通过共享环形缓冲区通信的机制,并实现了高效的无锁并发。
主要文件:session.c,以及公开头文件wintun.h中对应的函数声明。
二、会话结构(TUN_SESSION)
typedefstruct_TUN_SESSION{ULONG Capacity;struct{ULONG Tail;ULONG TailRelease;ULONG PacketsToRelease;CRITICAL_SECTION Lock;}Receive;struct{ULONG Head;ULONG HeadRelease;ULONG PacketsToRelease;CRITICAL_SECTION Lock;}Send;TUN_REGISTER_RINGS Descriptor;HANDLE Handle;}TUN_SESSION;Capacity:环形缓冲区容量(用户指定的值,必须是 2 的幂)。Receive与Send分别对应发送方向和接收方向(注意命名容易混淆:应用程序调用WintunAllocateSendPacket准备发送数据包时,实际上是向接收环(驱动读出的环)写入数据;而WintunReceivePacket是从发送环(驱动写入的环)读取。之所以这样命名,是因为从驱动的视角看,应用程序“接收”的包来自驱动“发送”的环,反之亦然。为清晰起见,下文将按功能描述:- 应用程序发送(
AllocateSendPacket/SendPacket)操作的是Receive方向(因为驱动会从这个环“接收”应用程序的数据)。 - 应用程序接收(
ReceivePacket/ReleaseReceivePacket)操作的是Send方向(驱动将数据“发送”到这个环供应用程序读取)。
- 应用程序发送(
- 每个方向都有自己的环形缓冲区指针(
Descriptor.Send.Ring/Descriptor.Receive.Ring)、尾指针(Tail)、释放指针(TailRelease或HeadRelease)、待释放包计数,以及一个临界区锁保护内部状态。 Descriptor包含了发送环和接收环的描述信息(基址、大小、尾移动事件句柄),用于传递给驱动。Handle是设备对象句柄,用于执行 IOCTL。
三、会话启动(WintunStartSession)
3.1 参数校验与内存分配
- 检查
Capacity是否在WINTUN_MIN_RING_CAPACITY和WINTUN_MAX_RING_CAPACITY之间,且为 2 的幂(由调用方保证,但未显式校验)。 - 计算每个环的大小:
RingSize = TUN_RING_SIZE(Capacity),即sizeof(TUN_RING) + Capacity + (TUN_MAX_PACKET_SIZE - TUN_ALIGNMENT)。这是为了容纳环形数据、数据包对齐以及最大包大小。 - 使用
VirtualAlloc分配两倍RingSize的内存(AllocatedRegion),原因是驱动要求发送环和接收环在物理上连续?实际上,代码将这段内存分为两个连续的环:发送环位于基址,接收环位于基址 +RingSize。分配双倍大小是为了确保两个环对齐,并且VirtualFree可一次性释放。
3.2 事件对象创建
- 为发送环和接收环各创建一个手动重置事件(
CreateEventW),用于通知对方尾指针已移动。这些事件句柄被填入Descriptor.Send.TailMoved和Descriptor.Receive.TailMoved,驱动会使用它们来唤醒等待的线程。
3.3 打开设备对象
- 调用
AdapterOpenDeviceObject获取设备句柄,该函数内部通过CreateFileW打开之前获得的设备接口文件。
3.4 IOCTL 注册环形缓冲区
- 执行
DeviceIoControl,控制码TUN_IOCTL_REGISTER_RINGS(自定义 CTL_CODE),传递TUN_REGISTER_RINGS结构,包含两个环的基址、大小和事件句柄。 - 驱动收到此 IOCTL 后,将内存映射到内核地址空间,并保存事件对象引用。若成功,则驱动开始接受数据包。
3.5 初始化内部状态
- 设置
Capacity。 - 初始化两个临界区(
Receive.Lock和Send.Lock),并指定自旋计数LOCK_SPIN_COUNT(0x10000),提高多核性能。 - 返回
TUN_SESSION指针。
四、数据包接收(应用程序读取)
4.1WintunReceivePacket
此函数从发送环(驱动写入数据的环)中取出一个包。
流程:
- 进入
Send.Lock临界区。 - 检查
Session->Send.Head是否已到达Capacity(若大于等于,则驱动可能已终止,返回ERROR_HANDLE_EOF)。 - 读取驱动更新的
Tail指针(ReadULongAcquire(&Session->Descriptor.Send.Ring->Tail))。该指针由驱动更新,指向环尾。 - 如果
Head == Tail,表示无新数据,返回ERROR_NO_MORE_ITEMS。 - 计算
BuffContent = TUN_RING_WRAP(Tail - Head, Capacity),即环中可用数据字节数。 - 从
Data[Head]处读取TUN_PACKET头部(包含Size字段),检查大小是否合法(≤ WINTUN_MAX_IP_PACKET_SIZE)。 - 计算对齐后的包大小
AlignedPacketSize = TUN_ALIGN(sizeof(TUN_PACKET) + BuffPacket->Size),确保不超过BuffContent。 - 将
PacketSize输出,返回BuffPacket->Data指针,并将Head向前移动AlignedPacketSize(按容量回绕)。 - 增加
PacketsToRelease计数,表示有一个包待释放。 - 离开临界区。
注意:函数返回的指针直接指向共享内存,因此应用程序在释放前必须尽快处理或拷贝数据,因为后续调用可能会覆盖该区域。
4.2WintunReleaseReceivePacket
当应用程序处理完包后,必须调用此函数释放缓冲区。
流程:
- 进入
Send.Lock。 - 通过指针反算
TUN_PACKET首地址:BuffPacket = (TUN_PACKET *)(Packet - offsetof(TUN_PACKET, Data))。 - 将该包的
Size与TUN_PACKET_RELEASE(0x80000000)进行位或操作,标记为“待释放”。这样可以延迟实际更新Head,直到有足够的待释放包可以成批更新。 - 然后进入一个循环,从当前
HeadRelease位置开始检查每个包的释放标志,若已释放,则累加AlignedPacketSize并减少PacketsToRelease,直到遇到未释放的包。 - 将更新后的
HeadRelease写入环形区的Head字段(WriteULongRelease),通知驱动这些位置已被释放。 - 离开临界区。
这种批量释放设计减少了频繁写共享内存的开销,提高了性能。
五、数据包发送(应用程序写入)
5.1WintunAllocateSendPacket
为要发送的数据包分配空间(从接收环中取空闲区域)。
流程:
- 进入
Receive.Lock。 - 检查
Session->Receive.Tail是否超限。 - 计算对齐后的包大小
AlignedPacketSize。 - 读取驱动更新的
Head指针(ReadULongAcquire(&Session->Descriptor.Receive.Ring->Head)),它指示驱动已处理到的位置。 - 计算可用空间:
BuffSpace = TUN_RING_WRAP(Head - Tail - TUN_ALIGNMENT, Capacity),减去一个对齐单位是为了避免Tail与Head完全相等时无法区分空和满。 - 若
AlignedPacketSize > BuffSpace,返回ERROR_BUFFER_OVERFLOW(环形缓冲区满)。 - 在
Tail位置写入TUN_PACKET头部,设置Size = PacketSize | TUN_PACKET_RELEASE(初始标记为待释放)。 - 返回
Data指针,并将Tail前移。 - 增加
PacketsToRelease计数。
5.2WintunSendPacket
将填充好的数据包提交给驱动。
流程:
- 进入
Receive.Lock。 - 通过
Packet反算TUN_PACKET,清除TUN_PACKET_RELEASE标志,表示该包已准备好被驱动读取。 - 进入循环,检查当前位置
TailRelease处的包是否已准备就绪(即没有释放标志),若是,则累加其大小并前移TailRelease,减少PacketsToRelease。 - 完成批量提交后,用
WriteULongRelease更新环形区的Tail字段。 - 内存屏障(
MemoryBarrier())确保之前的写入对驱动可见。 - 检查环形区的
Alertable字段,若为真,则触发SetEvent唤醒等待的驱动线程(驱动可能通过事件等待新数据)。 - 离开临界区。
六、环形缓冲区管理细节
6.1 对齐与填充
TUN_ALIGNMENT为sizeof(ULONG)(4 字节),保证所有指针自然对齐。- 每个包(包括头部)在环中的存储占用的实际大小是
TUN_ALIGN(sizeof(TUN_PACKET) + PacketSize),确保下一个包从对齐地址开始。 - 环容量
Capacity必须是 2 的幂,以便使用位与运算实现快速回绕。
6.2 原子操作与内存序
- 使用
ReadULongAcquire/WriteULongRelease等原语(在 Windows 中通常通过Interlocked或volatile加内存屏障实现),确保跨线程/跨进程的可见性。 - 共享内存的
Head、Tail字段被声明为volatile ULONG,但为了确保多核顺序,代码中使用了显式的MemoryBarrier()和ReadAcquire/WriteRelease语义(这些宏在 WDK 中定义,但在用户态需要自行实现,这里可能是通过_InterlockedCompareExchange等替代)。
6.3 事件通知
- 当驱动有数据可读时,它会设置
Send.Ring->Tail并触发Send.TailMoved事件,应用程序可以通过WintunGetReadWaitEvent获取该事件句柄,在ReceivePacket返回ERROR_NO_MORE_ITEMS后等待。 - 当应用程序提交新数据时,
SendPacket会检查Receive.Ring->Alertable并可能触发Receive.TailMoved事件,驱动同样可以等待该事件。
6.4 线程安全
- 每个方向使用单独的临界区,允许同时进行发送和接收(互不干扰)。
- 所有函数在必要时获取锁,确保对内部计数和指针的修改是原子的。
七、性能优化特性
- 批量处理:释放和提交时,将多个包的指针更新合并为一次写操作,减少与驱动共享内存的交互次数。
- 无锁头尾指针:共享内存的
Head/Tail由驱动和用户态分别以顺序一致性语义更新,避免锁争用。 - 固定容量:环形缓冲区大小在会话创建时确定,避免动态扩展带来的开销。
- 直接内存访问:数据包指针直接指向共享内存,避免内核态与用户态间的数据拷贝(仅需一次拷贝,从网卡到共享内存或反之)。
八、与驱动交互的 IOCTL
自定义控制码TUN_IOCTL_REGISTER_RINGS的定义为CTL_CODE(51820U, 0x970U, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA),其中51820是 Wintun 的专属设备类型码。驱动解析此 IOCTL 后,将用户空间的内存地址锁定并映射到内核,同时保存事件句柄以便后续信号通知。
九、总结
session.c实现了 Wintun 最核心的数据传输路径,其设计体现了极致的性能追求:
- 共享环形缓冲区消除了内核-用户态上下文切换和数据拷贝的开销。
- 批量处理与无锁原子操作充分利用多核 CPU。
- 事件机制允许高效等待,避免忙等。
正是这一层的高效实现,使得 Wintun 能够达到远超传统 TAP 驱动的吞吐量(如前文所述,实测可达 700+ Mbps)。下一篇文章将介绍支撑这一切的辅助基础设施:日志、命名空间、注册表、资源提取和 WOW64 代理。
