by fanxiushu 2017-04-06 转载或引用请注明原始作者。
很早前的文章介绍过windows和linux平台的虚拟网卡技术,
详见
http://blog.csdn.net/fanxiushu/article/details/8526708
http://blog.csdn.net/fanxiushu/article/details/8526719
http://blog.csdn.net/fanxiushu/article/details/8525749
http://blog.csdn.net/fanxiushu/article/details/8507638
前两个是讲述如何组成一个虚拟局域网,后边的是如何在linux平台下开发一个虚拟网卡
(当时提供的代码比较老,需要修改才能在新版本linux下使用,
或者懒得自己开发,直接使用linux自带的tun驱动,linux底层这些驱动总比windows平台方便得多)。
这些文章介绍过如何利用虚拟网卡组建局域网的原理:获取应用层程序发给虚拟网卡的数据包,
然后通过真实的网络发给服务端, 服务端再转发给另外一台机器,这台机器再把从网络获取的数据包传递给虚拟网卡。
通过这样的方式,就把处于不同真实网络环境中的机器连接到同一个虚拟局域网中。
只是当时没介绍如何开发windows虚拟网卡驱动,这篇文章填补这个空白。
win7系统有最新的NDIS6.2框架,win8 的NDIS提高到6.3以上,win10 达到ndis6.4 。
最大变化是从NDIS5.x 到 NDIS6.x, 连最基本的包的定义等数据结构都发生了巨大变化。
但是windows有个最大优点,就是兼容,在win7,win8,win10,等平台可以运行ndis5.x框架的驱动,
(不过ndis5.x的中间驱动无法在win10上运行,这个估计是最大不方便了)
就跟TDI驱动能在各种windows平台通吃一样,NDIS5.x也能通吃各种windows平台。
这里采用 NDIS5.1框架,不是要抱着老的框架不放,而是许多用户抱着WinXP 不放,
同时要兼容 WinXP和WIN7,而且也不用开发两套代码的最好选择就是NDIS5.1 了。
如果你的程序只运行在WIN7系统以上,可以只使用NDIS6以上的版本的框架,
NDIS6虽然基本结构尤其是包结构改变了,但是我们开发的总体方式差不多。
首先在DriverEntry中声明 NDIS_MINIPORT_CHARACTERISTICS 变量,它是一个包含多个回调函数的数据结构,
在此结构中填写好我们需要的各种回调函数之后,调用 NdisMRegisterMiniport 函数注册。
NdisMRegisterMiniport虽然没开放源代码,但是基本工作流程应该能想到,因为虚拟网卡驱动也是即插即用驱动模型,
因此在DriverEntry 函数中一样需要实现 AddDevice,以及各种派遣函数,
只是 NdisMRegisterMiniport 使用它内部的某个函数 设置到AddDevice 回调中,同时设置各种IRP_MJ_XXX派遣函数,
并且做一些其他初始化操作,当有设备(也就是网卡)插上来,DriverObject->DriverExtension->AddDevice 函数被调用,
这时会进入到NdisMRegisterMiniport注册的 某个内部函数中,
在这个函数中会调用 NDIS_MINIPORT_CHARACTERISTICS 导出的 InitializeHandler 函数,
这样就进入到我们注册的网卡初始化函数。
在虚拟网卡驱动中,主要实现以下几个回调函数,基本上就能完成一个虚拟网卡的功能:
InitializeHandler , 初始化网卡。也就是当我们安装一块网卡实例驱动的时候,这个函数被调用,
在这个函数中,初始化各种资源,这个函数等同于普通的即插即用驱动的AddDevice函数,
只是被NDIS框架封装成 InitializeHandler 回调函数了。
HaltHandler , 卸载网卡,当我们卸载某个网卡驱动时候,这个函数被调用,
相当于普通即插即用驱动程序收到 IRP_MN_REMOVE_DEVICE等消息之后触发的回调。
QueryInformationHandler, 查询网卡 OID。其实就是查询网卡的各种信息,网卡包含的信息很多,基本上有几十个。
SetInformationHandler, 设置网卡OID。 设置我们感兴趣的OID信息。
ResetHandler, 是否重启网卡,虚拟网卡驱动中,基本用不上。
CheckForHangHandler, 检测网卡是否处于hang状态,是的话,调用ResetHandler, 虚拟网卡基本上也用不着。
SendPacketsHandler, 处理网络数据包的核心函数之一,这个回调函数表示网卡从应用层程序接收到以太网数据包,
比如在应用层调用套接字函数send 或sendto发送数据,数据进入到内核的传输层,
经过分析剥离,进入到NDIS协议驱动层,协议驱动层找到这个数据是朝哪个网卡发送的,
于是找到这个网卡注册的 SendPacketsHandler 回调函数地址,
最后调用这个回调函数实现数据包的真正发送。
在SendPacketsHandler 函数中处理的数据包是准备发给底层的物理链路的,
虚拟网卡没有物理链路,因此我们把这些数据包入队,
然后直接在驱动层通过WSK(TDI)方式(或者其他各种方式,如USB, 串口等)发给远端设备或电脑,
或者把数据包传递到应用层, 让我们的应用层程序做各种处理,为了开发的方便和简洁,
我们采用的是传递到应用层来处理。
ReturnPacketHandler, 这个函数与上边的刚好相反,当物理链路有数据包到达(或者通过其他方式有数据包,如USB等),
调用NDIS函数NdisMIndicateReceivePacket,通知上层有个数据包达到,
等上层(这个上层就是处理TCP/IP等各种协议的协议层)处理完这个数据包之后,
ReturnPacketHandler 就被调用。
接着这个数据被上传到传输层进一步分析处理,
再进入到应用层,这时候调用 recv或者recvfrom等套接字函数的程序就接收到了数据。
我们的虚拟网卡驱动在应用层程序通过某个IOCTL控制命令传递一个数据包到驱动,
在驱动中直接调用NdisMIndicateReceivePacket通知上层有数据包到达。
CancelSendPacketsHandler, 这个是NDIS5.1框架中,提供的取消某些数据包发送的回调函数,也就是上层调用SendPacketsHandler,
发送数据包,但是我们的驱动还没来得及处理,只是入队等待处理,这个时候上层决定取消某些数据包的发送,
于是调用 CancelSendPacketsHandler 让我们取消某些数据包的发送。
PnPEventNotifyHandler, NDIS5.1框架的PnP通知事件,其实就是对应普通的即插即用驱动中的IRP_MJ_PNP请求的封装。
AdapterShutdownHandler, NDIS5.1框架的网卡关闭事件。
因为我们的虚拟网卡驱动是把数据包传递到应用层来处理,也就是应用层相当于是“物理连线”,
必须创建一个控制设备才能跟应用层交换数据,NDIS5.1框架提供了NdisMRegisterDevice 函数来创建一个控制设备,
在 InitializeHandler 网卡实例初始化函数中可以调用这个函数创建控制设备,
在 HaltHandler 网卡卸载函数中可以调用NdisMDeregisterDevice删除这个设备。
创建这个控制设备时候,传递一些参数,包括派遣函数,我们感兴趣的主要是IRP_MJ_DEVICE_CONTROL,以及CREATE /CLOSE 。
可以定义两个IOCTL命令,一个用于数据包读取,一个用于向驱动写数据包,比如命名为 READ IOCTL 和WRITE IOCTL。
网卡处理的数据包是非常多的,
以100M以太网来计算,以太网数据包大小 1514,当全速传输时候, 100M*1024*1024/8/1514 = 大约 8千多个数据包,
全速传输,每秒传输8千多个数据包甚至更多, 这个量是很大的,千兆网达到8万多个包每秒,甚至更多。
为了尽量提高IO吞吐率,在应用层可以采用完成端口方式接收数据包,下边会讲到。
(开发虚拟网卡驱动相对而言不算太难,难得是如何调优,让他的IO性能提升到更好的效果)
如此之多的数据包要在应用层和驱动层进行交换,驱动里边该采用什么结构来处理,才能尽可能的提高IO效率呢?
这里顺带提一下我测试IO效率的方式,
一个服务端程序,负责多个虚拟网卡数据包转发,它让多个虚拟网卡组成一个虚拟局域网,
虚拟网卡的客户端程序采用TCP连接到服务端,转发的虚拟网卡数据包也是通过TCP方式转发。
服务端程序放到一台性能还可以的WIN7的机器上(四代 i7 的 8核CPU,16G内存),
它的千兆网卡接到千兆交换机上,完全保证它达到千兆网卡的速度。
一台装有XP系统的古老机器,以前的ATOM的CPU,1G内存,它的网卡接到百兆交换机上。
一台装WIN10系统的古老CPU是Core2的机器,2G内存,它的网卡接到百兆交换机上。
测试以TCP传输为主,UDP基本不考虑,
测试时候使用FTP,HTTP,windows文件夹共享三种方式上传或者下载一个超过1G大小的大文件。
在100M网络环境下,以上的测试,XP和WIN10之间通过虚拟网络传输文件,
基本达到5M-6MB的速度,也就是达到50-%60%的真实网卡利用率,
也许使用更好的机器效果会更高,在装服务端程序的机器上也装上虚拟网卡,让他与WIN10传输文件,
这个速度就比较高了,维持在7-11M的速度,基本上达到 70-95%的真实网卡利用率,峰值时候基本能达到饱和。
这种测试还跟客户端服务端程序,机器配置等多种因素有关,因此不能保证换个环境就一定准确。
整体来看,WIN7以上的系统性能的表现比WINXP系统好,这应该是WIN7上的内核,微软把整个网络层重构了一遍,跟WINXP完全不同。
我是基于以上测试来评定开发的虚拟网卡的IO性能。
为此想了许多办法,也做了多种尝试,往往写好了一种方式的代码发现效果不太理想,之后想到另外一种处理方式可能会更好,
因此废弃先前的代码,重新再实现新想到的处理方式,来来回回的折腾了多种处理方式,采用了如下的处理结构:
(不过任然是采用单帧接收和发送的方式)
数据包传递以IRP 请求为主,一个IRP传递一个数据包。也就是上边所说的定义两个IOCTL命令,read ioctl和write ioctl,
每次都发送和接收一个数据包,都会产生一个IOCTL调用。
以read ioctl为例,
应用层投递read ioctl请求到驱动,这个请求的 Irp 都被驱动挂载到某个 IRP队列 比如 tx_irps_head,
上层数据包发到虚拟网卡驱动 ,也就是 SendPacketsHandler 函数被调用时候,
把这些数据包挂载到 某个Packet队列,比如 tx_pkts_head 。
两者每次处理完后,都调用 KeInsertQueueDpc触发DPC调用 ,在DPC执行函数中,检查两个队列是否都不为空,
都不为空的话,分别取出一个IRP和一个Packet,把Packet数据copy到IRP,完成这个IRP和Packet,如此循环,直到某个队列为空。
可能有人会问,这里为何要多次一举使用DPC调用,而不是直接执行这种检查处理!?
一般是在硬件的中断函数中,为了尽快让出中断函数,让稍微耗时的处理交给次一级的函数处理,
但是也是要求尽快处理完成而不被其他软中断打断,DPC就处于这种地位。
对软件来说,是很高的运行级别,在DISPACH_LEVEL,运行的时候是不会被调度到其他CPU或者被软中断打断的运行级别。
对于网络数据包的处理就是需要在这种DPC环境中运行,才能让他达到更好的IO效率。
大致伪代码如下,
在IRP_MJ_DEVICE_CONTROL请求中,响应 IOCTL_NCARD_READ_DATA(也就是read IOCTL宏)
case IOCTL_NCARD_READ_DATA:
// PENDING
status = STATUS_PENDING;
IoMarkIrpPending(Irp);
InitializeListHead(&Irp->Tail.Overlay.ListEntry);
Irp->Tail.Overlay.DriverContext[0] = a;
IoSetCancelRoutine(Irp, ioctl_cacnel_routine); ///
if (Irp->Cancel) {
if (IoSetCancelRoutine(Irp, NULL) != NULL) { //取消例程还没被执行,自己取消
status = STATUS_CANCELLED;
complete_irp(Irp, status, 0);
}
////
}
else {
///加入到队列 , 等待 上层有数据包发送 SendPacketsHandler 被调用,再从 tx_irps_head队列取出IRP进行处理。
NdisInterlockedInsertTailList(&a->tx_irps_head, &Irp->Tail.Overlay.ListEntry, &a->tx_spinlock); ////
}
////// 触发DPC调用,这里采用DPC。
KeInsertQueueDpc(&(a)->tx_dpc, NULL, NULL); /////
.......
上边的DPC的初始化操作,
KeInitializeDpc(&a->tx_dpc, adapter_complete_send_packets_dpc, a);
在 adapter_complete_send_packets_dpc 这个DPC执行函数中完成类似如下的操作:
///--------------------------------------------------------------------------------------------------
adapter_t* a = (adapter_t*)context;
adapter_inc(a);
PLIST_ENTRY entry;
while (TRUE) {
tx_lock(a);
if (IsListEmpty(&a->tx_irps_head) || IsListEmpty(&a->tx_pkts_head)) {
tx_unlock(a);
break;
}
entry = RemoveHeadList(&a->tx_irps_head);
PIRP Irp = CONTAINING_RECORD(entry, IRP, Tail.Overlay.ListEntry );
if (IoSetCancelRoutine(Irp, NULL) == NULL) { /// 应该检查返回值,若为空,说明取消例程已经被调用了
NdisInitializeListHead(&Irp->Tail.Overlay.ListEntry); //初始化,防止在取消例程出问题
tx_unlock(a);
continue;
}
entry = RemoveHeadList(&a->tx_pkts_head);
PNDIS_PACKET packet = CONTAINING_RECORD(entry, NDIS_PACKET, MiniportReserved);
tx_unlock(a);
/////
NTSTATUS status = adapter_send_packet_to_irp(a, packet, Irp); //// 复制 Packet 数据到 Irp
///////
}
adapter_dec(a);
/////------------------------------------------------------------------------------------------------------------
在网卡 SendPacketsHandler 函数中做类似如下处理:
for (index = 0; index < NumberOfPackets; ++index) {
PNDIS_PACKET packet = PacketArray[index]; ///
////
status = NDIS_STATUS_PENDING;
NDIS_SET_PACKET_STATUS(packet, status);
tx_inc(a); //增加send包计数
NdisInterlockedInsertTailList(&a->tx_pkts_head, (PLIST_ENTRY)packet->MiniportReserved, &a->tx_spinlock); ///挂载到队列
///
}
.........
////触发 DPC调用
KeInsertQueueDpc(&(a)->tx_dpc, NULL, NULL);
-------.........................................................
如上就完成一个数据包的交换。
write ioctl也做类似的处理。
回到应用层来,在应用层,一般做法都是阻塞调用 DeviceIoControl ,这样没什么问题。
但是可以这样考虑,我们在调用DeviceIoControl返回后,处理数据包,然后再接着调用DeviceIoControl,
在再次调用DeviceIocontrol之前这中间有空隙,如果一次多投递读请求,这样数据包一来就被接收,中间就不存在空隙了。
最容易想到的就是多线程调用 DeviceIoControl ,但是这种密集型的IO,多线程反而会降低效率,
而且多线程还容易造成接收到的数据包乱序,这对TCP这种君子协议来说不是好事。
在一个线程里,同时投递多个请求,异步方式处理,这才是解决这个问题的办法。
完成端口就可以完成这件事,
大家所熟悉的完成端口,多用到网络编程上,其实网络套接字只是它的一个应用而已,
凡是具备异步读写的OVERLAPPED重叠请求的,都可以关联到完成端口。
这里也就不具体描述如何使用完成端口来异步投递read ioctl请求了,因为相信大家已经很熟悉。
以上开发的虚拟网卡测试的都是在100M网络环境下进行的,但是在千兆网络环境下的测试是很糟糕的。
我按照上边的环境测试,1000Mbps 的环境下,最快只能达到 20 MBytes 每秒的速度,也就是相当于 千兆网的 五分之一的速度。
这个测试数据非常让人气馁。
归根结底还是因为每个数据包长度不超过 1514,千兆网每秒需要处理 8多万甚至更多数据包,这么多数据包,
按照每个包的取,而且取出来之后,再把每个包在应用层封装一下再发到服务端,再服务端再转发出去,
达到对方虚拟网卡,再传递到网卡驱动层接收,再等待ACK回应,
这中间的延时比真正的物理硬件环境要高得多了,
解决这个问题也不是没办法,既然包太多了,那就减少包数量。如果在纯粹的虚拟网络中,可以设置虚拟网卡的MTU值,
让他更大,比如设置4M这么大的MTU值,这样每个包就可以达到 4M,传递的包个数大大减少,这种情况下疯狂传输文件的话,
立马就能把千兆网络跑满。
但是如果把虚拟网卡和真实的网卡混合桥接,1514的包大小的限制是无法改变的,任然要面对大量的包造成的效率问题。
在写这篇文章时候,想到另外一个把数据包传递到应用层的办法,不采用IRP传递。
就是在应用层开辟一块很大的内存,比如2M的内存,这块内存映射到驱动,这个内存块按照 1514 拆分成起码1000多个小块。
这些小块组成循环队列, 驱动不停的朝这个循环队列写数据包,应用层不停的从这个循环队列读数据包。
这样就减少IRP调用的开销,应该能提高效率,但在千兆网环境中能否得到质的提升,因为没实现,所以不能下定论。
不过即使提升了驱动和应用层的IO效率,但是应用层还得发数据包到网络去转发,这个无论如何也得不到实质的提升。
总体下来估计不会有质的提升。
范秀树 2017-04-06 晚
很早前的文章介绍过windows和linux平台的虚拟网卡技术,
详见
http://blog.csdn.net/fanxiushu/article/details/8526708
http://blog.csdn.net/fanxiushu/article/details/8526719
http://blog.csdn.net/fanxiushu/article/details/8525749
http://blog.csdn.net/fanxiushu/article/details/8507638
前两个是讲述如何组成一个虚拟局域网,后边的是如何在linux平台下开发一个虚拟网卡
(当时提供的代码比较老,需要修改才能在新版本linux下使用,
或者懒得自己开发,直接使用linux自带的tun驱动,linux底层这些驱动总比windows平台方便得多)。
这些文章介绍过如何利用虚拟网卡组建局域网的原理:获取应用层程序发给虚拟网卡的数据包,
然后通过真实的网络发给服务端, 服务端再转发给另外一台机器,这台机器再把从网络获取的数据包传递给虚拟网卡。
通过这样的方式,就把处于不同真实网络环境中的机器连接到同一个虚拟局域网中。
只是当时没介绍如何开发windows虚拟网卡驱动,这篇文章填补这个空白。
win7系统有最新的NDIS6.2框架,win8 的NDIS提高到6.3以上,win10 达到ndis6.4 。
最大变化是从NDIS5.x 到 NDIS6.x, 连最基本的包的定义等数据结构都发生了巨大变化。
但是windows有个最大优点,就是兼容,在win7,win8,win10,等平台可以运行ndis5.x框架的驱动,
(不过ndis5.x的中间驱动无法在win10上运行,这个估计是最大不方便了)
就跟TDI驱动能在各种windows平台通吃一样,NDIS5.x也能通吃各种windows平台。
这里采用 NDIS5.1框架,不是要抱着老的框架不放,而是许多用户抱着WinXP 不放,
同时要兼容 WinXP和WIN7,而且也不用开发两套代码的最好选择就是NDIS5.1 了。
如果你的程序只运行在WIN7系统以上,可以只使用NDIS6以上的版本的框架,
NDIS6虽然基本结构尤其是包结构改变了,但是我们开发的总体方式差不多。
首先在DriverEntry中声明 NDIS_MINIPORT_CHARACTERISTICS 变量,它是一个包含多个回调函数的数据结构,
在此结构中填写好我们需要的各种回调函数之后,调用 NdisMRegisterMiniport 函数注册。
NdisMRegisterMiniport虽然没开放源代码,但是基本工作流程应该能想到,因为虚拟网卡驱动也是即插即用驱动模型,
因此在DriverEntry 函数中一样需要实现 AddDevice,以及各种派遣函数,
只是 NdisMRegisterMiniport 使用它内部的某个函数 设置到AddDevice 回调中,同时设置各种IRP_MJ_XXX派遣函数,
并且做一些其他初始化操作,当有设备(也就是网卡)插上来,DriverObject->DriverExtension->AddDevice 函数被调用,
这时会进入到NdisMRegisterMiniport注册的 某个内部函数中,
在这个函数中会调用 NDIS_MINIPORT_CHARACTERISTICS 导出的 InitializeHandler 函数,
这样就进入到我们注册的网卡初始化函数。
在虚拟网卡驱动中,主要实现以下几个回调函数,基本上就能完成一个虚拟网卡的功能:
InitializeHandler , 初始化网卡。也就是当我们安装一块网卡实例驱动的时候,这个函数被调用,
在这个函数中,初始化各种资源,这个函数等同于普通的即插即用驱动的AddDevice函数,
只是被NDIS框架封装成 InitializeHandler 回调函数了。
HaltHandler , 卸载网卡,当我们卸载某个网卡驱动时候,这个函数被调用,
相当于普通即插即用驱动程序收到 IRP_MN_REMOVE_DEVICE等消息之后触发的回调。
QueryInformationHandler, 查询网卡 OID。其实就是查询网卡的各种信息,网卡包含的信息很多,基本上有几十个。
SetInformationHandler, 设置网卡OID。 设置我们感兴趣的OID信息。
ResetHandler, 是否重启网卡,虚拟网卡驱动中,基本用不上。
CheckForHangHandler, 检测网卡是否处于hang状态,是的话,调用ResetHandler, 虚拟网卡基本上也用不着。
SendPacketsHandler, 处理网络数据包的核心函数之一,这个回调函数表示网卡从应用层程序接收到以太网数据包,
比如在应用层调用套接字函数send 或sendto发送数据,数据进入到内核的传输层,
经过分析剥离,进入到NDIS协议驱动层,协议驱动层找到这个数据是朝哪个网卡发送的,
于是找到这个网卡注册的 SendPacketsHandler 回调函数地址,
最后调用这个回调函数实现数据包的真正发送。
在SendPacketsHandler 函数中处理的数据包是准备发给底层的物理链路的,
虚拟网卡没有物理链路,因此我们把这些数据包入队,
然后直接在驱动层通过WSK(TDI)方式(或者其他各种方式,如USB, 串口等)发给远端设备或电脑,
或者把数据包传递到应用层, 让我们的应用层程序做各种处理,为了开发的方便和简洁,
我们采用的是传递到应用层来处理。
ReturnPacketHandler, 这个函数与上边的刚好相反,当物理链路有数据包到达(或者通过其他方式有数据包,如USB等),
调用NDIS函数NdisMIndicateReceivePacket,通知上层有个数据包达到,
等上层(这个上层就是处理TCP/IP等各种协议的协议层)处理完这个数据包之后,
ReturnPacketHandler 就被调用。
接着这个数据被上传到传输层进一步分析处理,
再进入到应用层,这时候调用 recv或者recvfrom等套接字函数的程序就接收到了数据。
我们的虚拟网卡驱动在应用层程序通过某个IOCTL控制命令传递一个数据包到驱动,
在驱动中直接调用NdisMIndicateReceivePacket通知上层有数据包到达。
CancelSendPacketsHandler, 这个是NDIS5.1框架中,提供的取消某些数据包发送的回调函数,也就是上层调用SendPacketsHandler,
发送数据包,但是我们的驱动还没来得及处理,只是入队等待处理,这个时候上层决定取消某些数据包的发送,
于是调用 CancelSendPacketsHandler 让我们取消某些数据包的发送。
PnPEventNotifyHandler, NDIS5.1框架的PnP通知事件,其实就是对应普通的即插即用驱动中的IRP_MJ_PNP请求的封装。
AdapterShutdownHandler, NDIS5.1框架的网卡关闭事件。
因为我们的虚拟网卡驱动是把数据包传递到应用层来处理,也就是应用层相当于是“物理连线”,
必须创建一个控制设备才能跟应用层交换数据,NDIS5.1框架提供了NdisMRegisterDevice 函数来创建一个控制设备,
在 InitializeHandler 网卡实例初始化函数中可以调用这个函数创建控制设备,
在 HaltHandler 网卡卸载函数中可以调用NdisMDeregisterDevice删除这个设备。
创建这个控制设备时候,传递一些参数,包括派遣函数,我们感兴趣的主要是IRP_MJ_DEVICE_CONTROL,以及CREATE /CLOSE 。
可以定义两个IOCTL命令,一个用于数据包读取,一个用于向驱动写数据包,比如命名为 READ IOCTL 和WRITE IOCTL。
网卡处理的数据包是非常多的,
以100M以太网来计算,以太网数据包大小 1514,当全速传输时候, 100M*1024*1024/8/1514 = 大约 8千多个数据包,
全速传输,每秒传输8千多个数据包甚至更多, 这个量是很大的,千兆网达到8万多个包每秒,甚至更多。
为了尽量提高IO吞吐率,在应用层可以采用完成端口方式接收数据包,下边会讲到。
(开发虚拟网卡驱动相对而言不算太难,难得是如何调优,让他的IO性能提升到更好的效果)
如此之多的数据包要在应用层和驱动层进行交换,驱动里边该采用什么结构来处理,才能尽可能的提高IO效率呢?
这里顺带提一下我测试IO效率的方式,
一个服务端程序,负责多个虚拟网卡数据包转发,它让多个虚拟网卡组成一个虚拟局域网,
虚拟网卡的客户端程序采用TCP连接到服务端,转发的虚拟网卡数据包也是通过TCP方式转发。
服务端程序放到一台性能还可以的WIN7的机器上(四代 i7 的 8核CPU,16G内存),
它的千兆网卡接到千兆交换机上,完全保证它达到千兆网卡的速度。
一台装有XP系统的古老机器,以前的ATOM的CPU,1G内存,它的网卡接到百兆交换机上。
一台装WIN10系统的古老CPU是Core2的机器,2G内存,它的网卡接到百兆交换机上。
测试以TCP传输为主,UDP基本不考虑,
测试时候使用FTP,HTTP,windows文件夹共享三种方式上传或者下载一个超过1G大小的大文件。
在100M网络环境下,以上的测试,XP和WIN10之间通过虚拟网络传输文件,
基本达到5M-6MB的速度,也就是达到50-%60%的真实网卡利用率,
也许使用更好的机器效果会更高,在装服务端程序的机器上也装上虚拟网卡,让他与WIN10传输文件,
这个速度就比较高了,维持在7-11M的速度,基本上达到 70-95%的真实网卡利用率,峰值时候基本能达到饱和。
这种测试还跟客户端服务端程序,机器配置等多种因素有关,因此不能保证换个环境就一定准确。
整体来看,WIN7以上的系统性能的表现比WINXP系统好,这应该是WIN7上的内核,微软把整个网络层重构了一遍,跟WINXP完全不同。
我是基于以上测试来评定开发的虚拟网卡的IO性能。
为此想了许多办法,也做了多种尝试,往往写好了一种方式的代码发现效果不太理想,之后想到另外一种处理方式可能会更好,
因此废弃先前的代码,重新再实现新想到的处理方式,来来回回的折腾了多种处理方式,采用了如下的处理结构:
(不过任然是采用单帧接收和发送的方式)
数据包传递以IRP 请求为主,一个IRP传递一个数据包。也就是上边所说的定义两个IOCTL命令,read ioctl和write ioctl,
每次都发送和接收一个数据包,都会产生一个IOCTL调用。
以read ioctl为例,
应用层投递read ioctl请求到驱动,这个请求的 Irp 都被驱动挂载到某个 IRP队列 比如 tx_irps_head,
上层数据包发到虚拟网卡驱动 ,也就是 SendPacketsHandler 函数被调用时候,
把这些数据包挂载到 某个Packet队列,比如 tx_pkts_head 。
两者每次处理完后,都调用 KeInsertQueueDpc触发DPC调用 ,在DPC执行函数中,检查两个队列是否都不为空,
都不为空的话,分别取出一个IRP和一个Packet,把Packet数据copy到IRP,完成这个IRP和Packet,如此循环,直到某个队列为空。
可能有人会问,这里为何要多次一举使用DPC调用,而不是直接执行这种检查处理!?
一般是在硬件的中断函数中,为了尽快让出中断函数,让稍微耗时的处理交给次一级的函数处理,
但是也是要求尽快处理完成而不被其他软中断打断,DPC就处于这种地位。
对软件来说,是很高的运行级别,在DISPACH_LEVEL,运行的时候是不会被调度到其他CPU或者被软中断打断的运行级别。
对于网络数据包的处理就是需要在这种DPC环境中运行,才能让他达到更好的IO效率。
大致伪代码如下,
在IRP_MJ_DEVICE_CONTROL请求中,响应 IOCTL_NCARD_READ_DATA(也就是read IOCTL宏)
case IOCTL_NCARD_READ_DATA:
// PENDING
status = STATUS_PENDING;
IoMarkIrpPending(Irp);
InitializeListHead(&Irp->Tail.Overlay.ListEntry);
Irp->Tail.Overlay.DriverContext[0] = a;
IoSetCancelRoutine(Irp, ioctl_cacnel_routine); ///
if (Irp->Cancel) {
if (IoSetCancelRoutine(Irp, NULL) != NULL) { //取消例程还没被执行,自己取消
status = STATUS_CANCELLED;
complete_irp(Irp, status, 0);
}
////
}
else {
///加入到队列 , 等待 上层有数据包发送 SendPacketsHandler 被调用,再从 tx_irps_head队列取出IRP进行处理。
NdisInterlockedInsertTailList(&a->tx_irps_head, &Irp->Tail.Overlay.ListEntry, &a->tx_spinlock); ////
}
////// 触发DPC调用,这里采用DPC。
KeInsertQueueDpc(&(a)->tx_dpc, NULL, NULL); /////
.......
上边的DPC的初始化操作,
KeInitializeDpc(&a->tx_dpc, adapter_complete_send_packets_dpc, a);
在 adapter_complete_send_packets_dpc 这个DPC执行函数中完成类似如下的操作:
///--------------------------------------------------------------------------------------------------
adapter_t* a = (adapter_t*)context;
adapter_inc(a);
PLIST_ENTRY entry;
while (TRUE) {
tx_lock(a);
if (IsListEmpty(&a->tx_irps_head) || IsListEmpty(&a->tx_pkts_head)) {
tx_unlock(a);
break;
}
entry = RemoveHeadList(&a->tx_irps_head);
PIRP Irp = CONTAINING_RECORD(entry, IRP, Tail.Overlay.ListEntry );
if (IoSetCancelRoutine(Irp, NULL) == NULL) { /// 应该检查返回值,若为空,说明取消例程已经被调用了
NdisInitializeListHead(&Irp->Tail.Overlay.ListEntry); //初始化,防止在取消例程出问题
tx_unlock(a);
continue;
}
entry = RemoveHeadList(&a->tx_pkts_head);
PNDIS_PACKET packet = CONTAINING_RECORD(entry, NDIS_PACKET, MiniportReserved);
tx_unlock(a);
/////
NTSTATUS status = adapter_send_packet_to_irp(a, packet, Irp); //// 复制 Packet 数据到 Irp
///////
}
adapter_dec(a);
/////------------------------------------------------------------------------------------------------------------
在网卡 SendPacketsHandler 函数中做类似如下处理:
for (index = 0; index < NumberOfPackets; ++index) {
PNDIS_PACKET packet = PacketArray[index]; ///
////
status = NDIS_STATUS_PENDING;
NDIS_SET_PACKET_STATUS(packet, status);
tx_inc(a); //增加send包计数
NdisInterlockedInsertTailList(&a->tx_pkts_head, (PLIST_ENTRY)packet->MiniportReserved, &a->tx_spinlock); ///挂载到队列
///
}
.........
////触发 DPC调用
KeInsertQueueDpc(&(a)->tx_dpc, NULL, NULL);
-------.........................................................
如上就完成一个数据包的交换。
write ioctl也做类似的处理。
回到应用层来,在应用层,一般做法都是阻塞调用 DeviceIoControl ,这样没什么问题。
但是可以这样考虑,我们在调用DeviceIoControl返回后,处理数据包,然后再接着调用DeviceIoControl,
在再次调用DeviceIocontrol之前这中间有空隙,如果一次多投递读请求,这样数据包一来就被接收,中间就不存在空隙了。
最容易想到的就是多线程调用 DeviceIoControl ,但是这种密集型的IO,多线程反而会降低效率,
而且多线程还容易造成接收到的数据包乱序,这对TCP这种君子协议来说不是好事。
在一个线程里,同时投递多个请求,异步方式处理,这才是解决这个问题的办法。
完成端口就可以完成这件事,
大家所熟悉的完成端口,多用到网络编程上,其实网络套接字只是它的一个应用而已,
凡是具备异步读写的OVERLAPPED重叠请求的,都可以关联到完成端口。
这里也就不具体描述如何使用完成端口来异步投递read ioctl请求了,因为相信大家已经很熟悉。
以上开发的虚拟网卡测试的都是在100M网络环境下进行的,但是在千兆网络环境下的测试是很糟糕的。
我按照上边的环境测试,1000Mbps 的环境下,最快只能达到 20 MBytes 每秒的速度,也就是相当于 千兆网的 五分之一的速度。
这个测试数据非常让人气馁。
归根结底还是因为每个数据包长度不超过 1514,千兆网每秒需要处理 8多万甚至更多数据包,这么多数据包,
按照每个包的取,而且取出来之后,再把每个包在应用层封装一下再发到服务端,再服务端再转发出去,
达到对方虚拟网卡,再传递到网卡驱动层接收,再等待ACK回应,
这中间的延时比真正的物理硬件环境要高得多了,
解决这个问题也不是没办法,既然包太多了,那就减少包数量。如果在纯粹的虚拟网络中,可以设置虚拟网卡的MTU值,
让他更大,比如设置4M这么大的MTU值,这样每个包就可以达到 4M,传递的包个数大大减少,这种情况下疯狂传输文件的话,
立马就能把千兆网络跑满。
但是如果把虚拟网卡和真实的网卡混合桥接,1514的包大小的限制是无法改变的,任然要面对大量的包造成的效率问题。
在写这篇文章时候,想到另外一个把数据包传递到应用层的办法,不采用IRP传递。
就是在应用层开辟一块很大的内存,比如2M的内存,这块内存映射到驱动,这个内存块按照 1514 拆分成起码1000多个小块。
这些小块组成循环队列, 驱动不停的朝这个循环队列写数据包,应用层不停的从这个循环队列读数据包。
这样就减少IRP调用的开销,应该能提高效率,但在千兆网环境中能否得到质的提升,因为没实现,所以不能下定论。
不过即使提升了驱动和应用层的IO效率,但是应用层还得发数据包到网络去转发,这个无论如何也得不到实质的提升。
总体下来估计不会有质的提升。
范秀树 2017-04-06 晚