打印

对一个虚拟网卡驱动程序的剖析

[复制链接]
7925|0
手机看帖
扫描二维码
随时随地手机跟帖
跳转到指定楼层
楼主
high|  楼主 | 2009-10-15 16:29 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
来源:http://blog.chinaunix.net/u3/93290/showart_2052876.html

(一):写在前面的话

很抱歉,回家已经一个月了,今天才有机会正式开始写我前面承诺的东西。暑假要处理很多工作和生活方面的事情。虽然这个程序其实已经写地差不多了。
   首选说一下这个虚拟网卡的驱动的情况:在电脑上安装这个虚拟网卡后,它利用真实的网卡发送数据,并且和在真实的网络上某处的虚拟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来通知系统有新的数据包到达了,接下来就是各个协议栈的事情,就和我们无关了。

相关帖子

发新帖 我要提问
您需要登录后才可以回帖 登录 | 注册

本版积分规则

99

主题

1078

帖子

0

粉丝