虚拟网卡驱动程序的剖析
很抱歉,回家已经一个月了,今天才有机会正式开始写我前面承诺的东西。暑假要处理很多工作和生活方面的事情。虽然这个程序其实已经写地差不多了。
首选说一下这个虚拟网卡的驱动的情况:在电脑上安装这个虚拟网卡后,它利用真实的网卡发送数据,并且和在真实的网络上某处的虚拟switcher--其实是一个服务器程序--进行通讯,那个switcher同时也和其它的很多安装了这种虚拟网卡的机器通讯,但是对于系统来说,这个虚拟网卡好像也是机器上的另外一块网卡,它也有自己的IP地址,最重要的是,它和其它使用同样的switcher进行通讯的机器的关系从普通的网络上的两台机器的关系变成局域网中的两台机器的关系,这样很多只有局域网内可以进行的应用可以在互连网上也能应用了。
这个构想是在2003年非典时期我和我的室友想出来,他写了switcher的代码,这个是运行在linux上的一个服务器程序,windows的网卡驱动程序则是我们共同完成的。但是当时程序效率比较低,使用轮询的方式从虚拟网卡读取数据包,结果导致虚拟网卡的性能不过理想。后来我试图写出win98版的驱动,然而却发现很多意想不到的问题。Micro$oft声称网络驱动程序在98和2000上面是二进制兼容的,然而实际使用起来却远远不是那么理想,至少在2000上我们的要求达到了(只是性能比较低),在98上面同样的程序却不能达到我们的要求,如果有哪位大虾能告诉原因,感激不尽。
后来我们都以为这件事情到此结束了,然而没有。大概是去年年低的时候,我们听说了一个日本的大学生设计了一个叫做SoftEther的软件,我们看了,天啊,简直和我们的想法一摸一样。但是他设计的这个软件的性能比较理想,据说能达到真实网卡的80%的性能。而且,我们经过反思,认为其实从技术上来说,我们也是可以做到这一点的,但是,我们都很清楚,我们真正的差距在于对于一件事情能否做到坚持到底。
于是这个暑假,我们打算对我们的程序进行改进,最后我们将会把整个驱动程序和其它配套程序的源代码公开出来,希望对大家都有帮助。
今天罗嗦了这么多废话,明天开始逐步剖析这个驱动程序的各个部分。
驱动程序的入口DriverEntry
驱动程序是运行于内核态的系统服务程序,它通常的作用是直接执行操作硬件的指令,而用户态的应用程序只能以调用系统服务的形式来请求这种服务。编写驱动程序的时候要特别小心,因为驱动程序中的指令基本上可以不被限制地执行,如果编写地不好的话,兰屏死机那就是家常便饭了。另外驱动程序中很多地方必须考虑同步问题,不然会出现意想不到的结果。
开发Windows内核驱动程序我推荐的标准工具是WinDDK+VC6.0,我并不推荐DS这一类的工具,原因是使用这些工具会使得开发驱动的人员并没有对驱动真正的理解。而且使用DDK也并不意味着你什么都要从头开始,最典型的做法(其实是我常用的做法)是从DDK的示例性的源代码中找到一个和你要开发的设备类型最接近的工程代码,然后开始按照你的要求进行修改,这样就能省很多事情,而且很多框架性的代码就可以不用写了。
VC6.0基本上只把它做编辑器用,当然,实际上DDK是需要使用到VC6.0的编译器的。DDK安装好后编译驱动程序比较简单,只需要进入各种需要的build环境下,进入驱动程序工程的目录,执行build指令即可。build环境除了有操作系统的版本以外,还有一个checked和free的属性,这两种属性的区别就是是否在编译的时候包含调试信息,相当于用VC编写用户态程序时候的debug和released。当使用checked的环境下编译驱动程序的时候,驱动程序中的DbgPrint语句打印出来的信息可以被特定的工具所捕获,这样可以方便调试。DebugView就是这样的一个工具,它可以在这里找到:
http://www.sysinternals.com/ntw2k/freeware/debugview.shtml
所有的驱动程序都有一个DriverEntry函数,这个函数是驱动程序的入口点,它进行一些关于驱动程序的全局性的工作,虽然你可以修改编译器的相关设置来修改驱动程序的入口,不过除了增加了麻烦以外,通常这样做并没有实际的意义。
我们的虚拟网卡的DriverEntry函数中所做的就是这样的一种工作,和普通的网卡中的类似:
1.调用NdisMInitializeWrapper函数初始化一个NDIS_HANDLE。
2.填写一个NDIS_MINIPORT_CHARACTERISTICS结构中的内容,这个结构中的内容是和网卡相关的各种调用服务函数的入口。例如InitializeHandler表示对一块网卡进行初始化的函数的地址。
3.调用NdisMRegisterMiniport向系统注册改网卡驱动程序。
驱动程序中所有的其它函数都是网卡的各种服务的实现函数,它们要么直接出现在第二步填写的结构中,要么被这个结构中的函数直接或者间接地调用。
网卡的初始化函数MiniportInitialize
前面的驱动程序入口函数DriverEntry的作用是向系统说明这个驱动程序的结构。初始化函数则是为了使我们的网卡能够正常工作而进行各种准备工作。
系统在调用网卡的初始化函数的时候,会传进来一个输入参数MediumArray,这是一个包含一系列介质类型的数组,初始化函数要在这个数组中间选一种类型返回给系统,告诉系统该驱动支持的类型。做法就是将系统传进来的一个输出参数SelectedMediumIndex指向的地方赋上选择的类型在数组中的索引值。我们的虚拟网卡选择的是NdisMedium802_3,也就是告诉系统,我们的网卡是一块标准的以太网网卡。
接下来要做的也是最重要的准备工作就是想办法使一些公共信息能够在驱动程序的各个部分进行传递。通常使用的方法是定义一个结构,把驱动程序各个部分要用的信息都包含进去,驱动程序的各个部分根据这个结构的指针获取自己需要的信息。我们的这个虚拟网卡的驱动也定义了一个这样一个结构,D100_ADAPTER。
NdisAllocateMemoryWithTag(&Adapter,sizeof(D100_ADAPTER),'0000');
上面的系统的Ndis库函数,它负责分配内存,注意要检查它返回的状态,如果不成功的话,整个初始化函数也应该向系统返回对应的失败状态。然后将MiniportAdapterHandle,这是另外一个输入参数保存起来。日后调用很多Ndis库函数的时候都要提供这个Handle,以便让系统了解是哪个网卡要调用这些库函数。
接下来可以调用NdisOpenConfiguration函数打开注册表读取一些配置。系统允许每块网卡在注册表中的指定位置保留一些配置信息,每次初始化的时候可以去读取,也可以在需要的时候进行改变。当然,目前我们的虚拟网卡并没有什么信息需要保存在注册表。
下面应该对这个结构中的其它成员进行初始化,例如,如果用到同步互斥锁NDIS_SPIN_LOCK的话,要调用NdisAllocateSpinLock进行初始化,用到同步事件KEVENT也要调用KeInitializeEvent对其进行初始化。在我们的网卡中,为了使接收和发送的数据包的申请更加方便,预先申请了一个发送数据包的PacketPool和BufferPool,以及接收数据包的相应类型缓冲池。这样以后要申请一个数据包描述对象(NDIS_PACKET)或者缓存区描述对象(NDIS_BUFFER)的时候就可以在上面这个缓存池中申请了。
最后,我们使用了NdisMRegisterDevice向系统注册了一个支持各种Dispatch函数的设备对象,这样是为了使虚拟网卡能和用户态的代理(Agent)程序进行直接地通讯。例如,用户态的程序可以将我们注册的符号链接名放到CreateFile函数中去直接打开设备句柄,可以用ReadFile或者WriteFile这样的接口对设备进行读写,可以用DeviceIoControl对设备进行一些操纵。这些都是要以驱动程序中实现相应的Dispatch函数为基础的。
如果一切顺利的话,下面这条语句一定是出现在这个函数的最后的:
return NDIS_STATUS_SUCCESS;
支持OID的查询和设置
系统为了知道网卡的性能,从而决定分配多少任务给网卡,需要通过一些途径与驱动程序发生信息交互。这种途径就是驱动程序实现的MiniportSetInformation和MiniportQueryInformation函数,其中前者允许系统向驱动程序设置一些信息,后者则允许系统向驱动程序查询信息。
设置信息的函数系统提供了这些输入参数:OID,信息缓冲区地址,信息的长度。
OID表明的是系统准备告诉驱动程序的信息是什么,后面的两个参数则允许驱动程序能够访问到这些参数,驱动程序可以选择把这些信息保留起来,例如保存在那个公用结构(D100_ADAPTER)那里,当然驱动程序也可以对系统提供的信息置之不理,如果它并不重要的话。至于到底要做何选择这完全依赖于驱动的具体情况。
现在可以勾勒出MiniportSetInformation的通常情况下的正常结构:
从输入参数中获取出公用结构的地址MiniportAdapterContext,这个参数就是前面初始化函数中调用NdisMSetAttributesEx设置好的。可以将这个参数直接类型转换成公用结构的地址。
对着OID来一个switch语句,然后每个case自己看着办吧。这里举个例子,如OID_802_3_MULTICAST_LIST表示系统打算向驱动程序提供一个多播列表,如果驱动程序打算因此而对多播操作进行一些优化的话,它可能会选择把这些信息自己保存起来。
switch结束后根据执行的情况返回一个类型为NDIS_STATUS的表示状态的数值即可。
MiniportQueryInformation和MiniportSetInformation的结构类似,根据系统提供的OID,驱动程序应该返回一些信息。有些OID是比较重要的,如OID_GEN_MAXIMUM_FRAME_SIZE,它的意思是系统询问一帧最大能发送多少字节的数据,这个应该如实填写,以便系统按照情况对要发送的数据进行分割。另外当系统提供的缓冲区不足以容纳驱动程序提供的信息的话,应该向系统返回NDIS_STATUS_BUFFER_TOO_SHORT状态值。
网络数据的发送
驱动程序的MiniportSend或者MiniportSendPackets负责将网络数据发送出去。它们的区别在于后者在被系统调用的时候,得到的参数是一个数据包裹的数组,这样驱动程序可以一次将它们全部发送出去。驱动程序只需要实现这两个函数中的一个就可以了,如果驱动程序将两个函数全部实现了,系统只会调用MiniportSendPackets。
这是我们的函数头:
D100MultipleSend(NDIS_HANDLE MiniportAdapterContext,
PPNDIS_PACKET PacketArray,
UINT NumberOfPackets)
系统传给这个函数的就三个参数,前面定义的公共结构的地址,要发送的数据包数组,以及这个数组的长度。
通常的网卡驱动程序在实现这个函数的时候,使用的是一个for语句,一个一个地处理这些数据包。如果是真实的网卡,这个时候进行的工作应该是利用设备上的RAM,IO等等操作设备,前面的初始化函数中,如果是真实的网卡,这些内存映射等准备工作应该都已经就绪了。这样真实网卡的驱动程序的发送函数结束后,数据应该在物理上已经发送出去。但是我们的虚拟网卡并没有控制任何真正的物理设备来发送数据。那我们应该怎么完成数据发送的任务呢?
这里我们的虚拟网卡需要和一个用户态的程序进行交互,把要发送的数据传输给它,然后由用户态的应用程序直接以winsock的界面发送这些数据到虚拟switcher。
所以,在我们的虚拟网卡驱动程序中,这个发送函数实际执行的是下列动作:
在for循环的每一个数据包裹中:
获取数据包裹中的数据长度。(NdisQueryPacket)
从共用结构的数据包缓冲池和缓冲区缓冲池中申请数据包和缓冲区。(NdisAllocatePacket,NdisAllocateBuffer)
将数据从系统给的数据包中复制到新申请的缓冲区中,这中间需要遍历源数据包中的所有缓冲区。(NdisQueryBuffer,NdisMoveMemory,NdisGetNextBuffer)
将新的缓冲区和新的数据包挂接起来。(NdisChainBufferAtBack)
使用一个队列将这些数据包连接起来。(队列操作,指针操作,需要同步)
触发一个事件,通知用户态的程序。(KeSetEvent)
调用NdisMSendComplete通知系统该数据包已经发送完毕。(系统可以回收相应资源)
以上所有操作中,如果有失败的也应该调用NdisMSendComplete,只是最后一个表示状态的参数为相应的出错参数,告诉系统某某数据包发送失败了。然后应该使用continue这个C语言的关键字来进入for循环的下一个数据包的发送。而且如果已经分配了部分资源应该释放掉,避免内存泄漏。
驱动程序中需要实现发送的函数,但是没有接收的函数,因为网络数据的发送是可以确定的,但是接收是无法确定的,因此接收数据包在驱动程序的其它地方实现。例如真实网卡的中断服务函数将实现数据包的接收。我们的虚拟网卡是没有什么中断的,那么它在哪里接收数据呢?明天我们再说吧,呵呵:)
提醒系统接收数据
正如前面所讲述的那样,发送数据是可以预料到的,可以提供一个函数供系统随时调用,但是接收数据是不可预料的,因此没有专门的接收数据的函数,更确切一点的说,数据的接收是由驱动程序主动通知系统的。
普通的网卡是通过实现中断服务函数来处理接收到的数据的,它在初始化的时候往往会注册类似于中断,IO口,DMA等资源,并且将一些内存地址映射到硬件上的RAM地址。但是我们的虚拟网卡实际上并不占用任何硬件资源,自然也不需要实现什么中断服务函数了(实现了也不会有人来调用的)。
而实际上我们的网卡得到的数据是从真实网卡上获取的,而且这些数据是传递给一个用户态的程序(用通常的WinSock界面),然后用户态的程序以DeviceIoControl的方法把数据传送给驱动程序的,驱动程序所要做的是把这么一段数据封装成一个NDIS_PACKET,然后交给系统。
假设数据已经进入某块内存区域,例如,知道了数据的第一个字节的地址ptr,还知道了数据的长度len。我们首先要申请一块类型为NDIS_BUFFER的数据结构,使用NdisAllocateBuffer完成这项工作。NDIS_BUFFER的类型其实MDL类型,驱动程序通常用这种类型来描述一块内存区域,这种做法的好处是可以把多块内存区域连接起来而不进行内存拷贝。这种作用对于网络驱动程序来说,尤其有用。例如应用程序要向网络发送数据,在经过网络协议栈的时候,如果每一层都要把上面来的数据连同新增加协议头部分的字节拷贝到一个新的缓冲区中,那么这样将会严重影响系统的效率。因此使用MDL类型的内存描述表就可以避免很多内存拷贝,只需要为协议头部申请合适的内存,然后把这块内存的内存描述表直接连接到上面的内存描述表的头部即可。只有运行到了网卡的驱动程序这里,再最后根据这个内存描述表,把每个内存片断中的内容拷贝出来,就还原出了一个完整的数据包。这样就减少了很多次的内存拷贝。
然后调用NdisAllocatePacket申请一个不包含任何数据的NDIS_PACKET。然后将上面的内存描述符链接到这个数据包中:NdisChainBufferAtBack。这样这就不再是一个空的数据包了。再调用两个宏:NDIS_SET_PACKET_HEADER_SIZE和NDIS_SET_PACKET_STATUS设置该数据包的头部大小(14,以太网的头部大小)和状态(NDIS_STATUS_SUCCESS)。
当一个合格的数据包准备好后,就可以调用NdisMIndicateReceivePacket来通知系统有新的数据包到达了,接下来就是各个协议栈的事情,就和我们无关了。
驱动程序中的其它MiniportXXX函数
驱动程序中还有其它的一些函数,虽然看上去不那么显眼,但是缺少它们驱动程序也不完整。
MiniportHalt函数,负责网卡的卸载。它需要做事情就是把MiniportInitialize函数申请的系统资源释放掉。根据我们的网卡的实际情况,它做的事情有:
NdisMDeregisterDevice取消前面的DeviceObject的注册。
将所有发送和接收队列中的数据包全部释放掉。
使用NdisFreeBufferPool和NdisFreePacketPool把数据包缓冲池和缓冲区缓冲池释放掉。
最后,用NdisFreeMemory把公用结构所占据的内存释放掉。
MiniportReturnPacket函数,负责回收系统返还的NDIS_PACKET。这个函数的存在的必要性是由于驱动程序申请的数据包也应该由它来释放。前面使用了NdisMIndicateReceivePacket函数来向系统通知有新的数据包到达,这个新的数据包是由驱动程序申请的,因此系统只能使用里面的数据,而不能回收这个数据包占用的资源,系统在使用完数据包后,就会把它返还给这个函数来处理。
这个函数所做的工作就是比较简单的,只需要用NdisUnchainBufferAtFront把数据包中的缓冲区从数据包中剥离,然后用NdisGetNextBuffer遍历这个缓冲区,把遍历到的缓冲区全部释放掉,然后用NdisFreePacket把数据包也释放掉就可以了。释放数据包前必须把它的缓冲区剥离下来并且释放掉,否则会造成内存泄漏。
当然,为了提高系统效率,也可以不把这些数据包释放掉,而用一些程序上的机制使它们直接投入到下次使用。
用户态协作程序的基本框架
我们的虚拟网卡由于没有实际操作硬件,因此它的数据来往实际上还是要在真实的网络上进行。那么最简单的编程方法就是使用一个用户态的协作程序,在Windows系统看来,这个程序是一个很普通的网络应用程序,它使用标准的WinSocket网络接口和真实的网络通讯。
程序采样VC的对话框的模式编写,使用了两个线程。除了主线程外,发送数据单独有一个线程在运作。而接收数据方面,使用WinSocket的异步事件处理即可,而使用WSAAsyncSelect更可以把一个socket上的网络事件转化成windows事件,这样,当网络上有新的数据到达时,相应的窗体事件处理函数就会被调用。
为了和驱动程序通讯,应用程序必须首先获取设备句柄。NdisMRegisterDevice在被驱动程序调用后,该虚拟网卡已经向系统注册了一个符号链接名。这时用户态的程序使用CreateFile就可以得到设备句柄。这里设备的名称有一些要注意的地方,NdisMRegisterDevice函数中输入的两个参数中DeviceName的值是"\\Device\\xxx"这样的形式,而DeviceLinkUnicodeString的值则是"\\DosDevices\\xxx"这样的形式。在用户态程序调用CreateFile获取设备句柄的时候,作为文件名参数传递的符合链接名的值又变成"\\\\?\\xxx"这样的形式。三种情况中,xxx的值是相同的,这样,用户态就获取了设备的句柄。以后,用户态在调用DeviceIoControl来操作设备的时候,就需要把这个句柄作为参数传递进去。
程序的界面比较简单,就是一个地址栏加上两个按钮,连接和退出。当地址栏里面填好服务器的IP地址后,点击连接,如果成功,则会调用DeviceIoControl来通知驱动程序连接成功,而驱动程序则会通知系统该虚拟网卡可以正常工作,此时,系统托盘中的网络断开的图标将消失。这样,用户态协作程序和驱动程序之间就可以正常交互了。
提高程序运行效率的方法
本例中,用户态协作程序和驱动程序交互时提高系统效率的方法主要有两点:事件触发和内存映射。
首先说事件触发。在用户态程序里,用CreateEvent就可以创建一个事件对象,并获取它的句柄。然后就可以用WaitForSingleObject的方法来等待这个事件(在这个事件上阻塞),而当条件符合时,另一个线程就可以用SetEvent来触发这个事件,使前面的线程继续执行。而在驱动程序里,也有KEVENT对象,可以用类似的方法运行驱动程序的多个函数之间协调运行。但是现在来看看我们的程序中的需求:我们要求的是驱动程序的MiniportSendPackets把数据包一准备好,就要通知用户态的协作程序开始动手发送数据了,也就是说我们有一个用户态的线程,它需要等待一个核心态的线程触发的事件。而实际上,这个是可以实现的,两种事件它们本质上是一样的。具体实现的时候要求事件由用户态的程序来创建,然后在某次DeviceIoControl的时候,将这个事件的句柄传递到驱动程序处,驱动程序使用ObReferenceObjectByHandle就可以把句柄转化成KEVENT对象了,以后驱动程序使用KeSetEvent来触发这个事件的时候,用户态的阻塞在这个事件的线程就会继续执行了。
下面说说内存映射。由于我们的程序需要传输大量的数据,如前所述,能尽量减少内存拷贝对于系统的性能都会有很大的提高。所以当有数据包要在用户态程序和驱动程序之间交互的时候,尽可能地避免直接拷贝大量数据。而应该使用内存映射的方法使用户态程序和驱动程序共用一段内存,这样就可以避免不少的内存拷贝。要进行内存映射,首先要申请一块不分页(NonPaged)的内存,这里由驱动程序来完成。然后为这块内存建立一个内存描述符。其实我们只要查看Ndis.h文件就可以知道,NDIS_BUFFER(缓冲区)和MDL(内存描述符)是同一个数据结构。当开始映射的时候,我们要调用MmBuildMdlForNonPagedPool对这个内存描述符进行处理,接下来,就可以调用函数MmMapLockedPagesSpecifyCache来实现内存映射了。传递给它的第二个参数是UserMode,表明需要映射到用户态的内存空间中。这个函数返回的是一个地址,这个地址在用户态程序中可以使用,把它传递给用户态的程序就可以了。内存映射时有一点要注意的就是进程上下文的问题,我在编写这段程序时就曾经犯过这个错误,在MiniportSendPackets处理完包裹后,顺便就进行了内存映射,并把这个用户态的地址保存起来,结果用户态的程序拿到这个地址后死活不能用,搞了好几天,才被提醒到进程上下文的问题。后来就在IOCTL的代码中实现这个,即当收到相应的IOCTL代码后,现场执行内存映射,然后把这个地址交给用户态程序,这才可以正常使用。当内存映射使用完毕时,在释放这段内存前,要调用MmUnmapLockedPages来解除内存映射。
总算是全部写完了,等这个程序再完善一点,我们会把代码公布出来,可以请大家指教一下。