简介
这篇教程将用于说明如何在Windows NT上编写一个简单的设备驱动程序。现在互联网上有很多关于编写设备驱动的资源和教程。但是,相对教你在Windows上编写一个“hello world”的GUI程序的要少得多。这使得搜索如何开始编写设备驱动的信息更加困难。你也许会想,如果已经有了相关的教程,为何你还需要别的?答案是,信息越多当然越好,特别是当你想弄清某个新概念的时候。通常从不同的角度获得信息更有利于理解。人们对于相同概念给出不同的解释,这是由于角度不同,或是他们认为就该那么解释。所以,我建议所有想编写设备驱动的人,不要停留在这里,或是别的地方。尽量多找些不同的例子或是代码段,研究它们的不同之处。有时会发现一些bugs,或是有些信息被忽略了,有时会发现没必要那样编写实现代码,甚至发现得到的信息是错误的,或者并不完整。
这篇教程解释如何编写一个简单的设备驱动,动态的载入和卸载,最后是在用户模式下与其通讯。
创建一个简单的设备驱动
什么是子系统?
在解释如何编写设备驱动之前,我需要先设定一个开始的地方。这个起点就是编译器。编译器和连接器用于生成操作系统可以运行的二进制代码。在Windows里,称作“PE”,Portable Executable格式。在这种格式里,有一个子系统的概念。子系统与其他定义在PE头部的参数,用于说明如何载入程序,以及二进制代码的入口。
很多人使用VC++ IDE来创建带有编译器(和连接器)预设命令行参数的项目。这就是为什么很多人在并不熟悉这些概念的情况下,只要他们编写过Windows应用程序,就已经使用了这些概念。你曾经编写过控制台程序吗?你曾经编写过Windows GUI程序吗?他们是Windows下不同的子系统。它们所生成的PE二进制代码中带有相应的子系统信息。这就是为什么控制台程序使用“main”而WINDOWS程序使用“WinMain”。如果你创建这些项目,VC++会为你带上/SUBSYSTEM:CONSOLE 或 /SUBSYSTEM:WINDOWS参数。如果你选错了项目,只需在连接器参数菜单做修改,而不用重新创建项目。
这些的关键是什么?驱动程序使用不同的子系统,叫做“NATIVE”。MSDN Subsystem 编译器参数
驱动程序的“main”
在设置好编译器的参数后,就可以考虑驱动程序的入口函数了。第一节提了一下子系统。“NATIVE”也可以用于用户模式程序,它的入口函数为“NtProcessStartup”。这是声明“NATIVE”的默认形式,如同连接器在创建程序的时候寻找“WinMain”和“main”。通过“-entry:<functionname>”连接器参数,你可以重新设置入口。如果创建的是驱动程序,只要入口函数的参数与返回值与驱动程序相符就可以了。系统会在我们安装时载入程序,并告诉系统这是驱动。
名字可以任意取。只要我们愿意,可以使用BufferFly()作为入口函数。通常编写驱动的程序员,微软,使用“DriverEntry”作为初始的入口函数。这意味着我们需要使用“-entry:DriverEntry”作为连接器的命令行参数。但是如果你使用DDK,只要你创建“DRIVER”类型的程序,就已经帮你设置好了。DDK环境中的make文件夹中包含了预设参数,这样你就可以方便的创建一个程序。驱动程序员可以修改这些预设参数,也可以直接使用。这就是“DriverEntry”如何成为驱动程序的“官方”入口的原因。
其实DLL是声明成“WINDOWS”子系统编译的,但它有一个额外的开关/DLL。同样也有用于驱动的开关:/DRIVER:WDM和/DRIVER:UP用来说明驱动不能加载到多核心处理器的系统上。
连接器创建最终的执行文件,根据PE头部的参数,还有执行文件所希望的加载方式(通过加载器运行的EXE,通过LoadLibrary加载,或是作为驱动加载),决定了载入系统的操作。加载系统会执行一些确认,例如,镜像文件是否支持指定的加载方式。甚至有时会把启动代码加到执行文件中,先于你的入口函数执行(例如,WinMainCRTStartup 调用 WinMain,用于初始化CRT)。你要做的就是根据希望的加载方式编写程序,并设置相应的连接器参数,这样连接器就可以正确的创建执行文件。如果你对PE格式感兴趣,可以找到很多这样的资源。
下面是连接器的参数:
/SUBSYSTEM:NATIVE /DRIVER:WDM –entry:DriverEntry
在创建“DriverEntry”之前
坐下来编写“DriverEntry”之前,有些东西需要先介绍一下。我知道很多人想着马上编写一个驱动程序,然后看它是否运作。编程在通常情况下是先获得代码,修改它,然后编译运行,再测试是否正常运作。如果你还记得第一次学习Windows编程的过程,很有可能就是这样。你的程序没有马上正常工作,可能崩溃了,或是消失了。这个过程很有趣,你也可以从中学到很多,但是对于驱动程序,就不太一样了。如果不知道什么情况下会造成系统蓝屏,并且你的驱动在系统启动时加载并运行,问题就来了。还好你可以运行安全模式,恢复之前的硬件配置。所以在编写驱动之前,我们先了解一些东西,告诉你正在做的是什么。
第一条原则,不要获取一个驱动程序后马上修改编译它。如果你不了解这个驱动如何运作,或是不了解如何在当前的环境下正常运作,很有可能会出问题。驱动程序可能会破坏系统的完整性,或是存在一些很难被发现的bug。普通的应用程序也会有类似行为的bug,但是不在底层。例如,有些时候你不能访问可分页的内存。如果你明白虚拟内存是如何工作的,就会知道操作系统会将内存中的某些页移除,调入需要使用的页,这就是为什么可以在有限的物理内存中运行更多的应用程序。有些时候无法将分页从硬盘中载入内存。这时驱动程序只能访问那些不能被换页的内存。
怎么才会遇到这样的问题呢?如果你让在这种限制下的驱动程序去访问可分页的内存,它可能不会崩溃,因为操作系统尽可能的让分页留在内存里。例如,你关闭了一个程序,但它仍然可能留在内存中。这就是为什么这种BUG可能被发现(除非你使用驱动验证器),但最终引发问题。一旦出现问题,而你又不知道这些基础概念,你也许就无法找到问题并且修复它。
本文将讲述很多背后的概念。单是IRQL,在MSDN上你就可以找到一份20的文档。还有一份同样大小的IRP的文档。我不打算复制这些资料,也不会介绍每一个细节。我尝试给出一个基本的概要,并告诉你哪里可以找到更多的信息。在编写驱动程序之前,知道这些概念的存在,并明白其中的道理是很重要的。
什么是IRQL?
IRQL被称为“Interrupt ReQuest Level(中断申请等级)”。处理器执行的线程代码处于某个特定的IRQL。处理器的IRQL用于确定运行中的线程是否能被中断。一个线程在相同的处理器上只能被拥有更高IRQL的代码中断。如果是相同或是更低的IRQL中断会被忽略,只有更高的IRQL才会被处理。在多处理器系统中,每个处理器独立上运行,拥有自己的IRQL。
通常你会处理4个IRQL等级,分别是“Passive”, “APC”, “Dispatch” 和 “DIRQL”。在MSDN上的核心API文档会注明你可以在哪个IRQL等级上使用。越高的IRQL就越少的API可以使用。MSDN文档定义了入口函数的IRQL。例如,“DriverEntry”是PASSIVE_LEVEL。
PASSIVE_LEVEL
这是最低的IRQL。没有中断会被忽略,并且这也是用户模式线程的运行等级。可以访问可分页内存。
APC_LEVEL
处理器运行在这个等级时,只有APC等级的中断会被忽略。这个等级用于异步过程调用(Asynchronous Procedure Call)。可分页内存仍然可以访问。当APC发生时,处理器将提升至APC等级(或是更高的等级)。这样也就相应的阻止了其他APC的发生。驱动程序可以手动的将等级提升至APC,这样可以与其他的APC同步,因为如果处理器处于APC,那么其他的APC就不会被执行。由于其他的APC被阻止了,所有有些API不能在APC等级上调用,例如,I/O完成的APC。
DISPATCH_LEVEL
处理器运行于这个等级时,DPC和低等级的中断被忽略。不能访问可分页内存,所有访问的内存都是不可分页的。当你运行于Dispatch等级时,可以调用的API大量的极少,因为只能访问不可分页内存。
DIRQL (Device IRQL)
通常处于高层次的驱动程序不会使用这个IRQL等级,在这个等级上所有的中断都会被忽略。这是IRQL的最高等级。通常使用这个来判断设备的优先级。
在这个驱动里,我们基本上工作在PASSIVE_LEVEL,所以我们不需要担心上面的内容。但是如果你打算继续研究设备驱动,你就必须知道IRQL。
想要了解更多IRQL和线程调度信息,请参考这个文档,另一个在这里。
什么是IRP?
IRP就是“I/O Request Packet”,它是在驱动栈中的驱动之间传递。这个数据结构用于驱动之间通讯,也用于请求完成某个任务。I/O管理器或是其他的驱动可以创建一个IRP,然后将其传递给你的驱动。IRP包含了被请求执行操作的信息。
IRP数据结构的说明在这里。
介绍和使用IRP容易从简单变复杂,所以我们只解释IRP对你有何意义。MSDN上有一篇专门介绍IRP和如何使用它的文章。可以在这里找到。
IRP包含一个“子请求”队列,也被称为“IRP栈地址”。关于IRP,每个驱动栈上的驱动通常会有相对自己的“子请求”。这个数据结构是“IO_STACK_LOCATION”,在MSDN上说明。
打一个比方说明IRP和IO_STACK_LOCATION的关系,假设你有3个人分别负责不同的工作,木工,水管和焊接。如果他们负责建造一座房子,他们会有一个设计图,和一些共同使用的常用工具。如电钻。所有这些常用工具以及设计图就是一个IRP。为了完成建造任务,他们个人也有自己的工作,如水管工,他需要计划如何铺设水管,并计算水管用量等。这种具体的水管工作就使用IO_STACK_LOCATION说明。木匠则负责搭建房子的框架,其中具体的工作也在IO_STACK_LOCATION中说明。总的来说,IRP就是建筑房子的请求,而每个在工匠栈中工匠的具体工作则在IO_STACK_LOCATION定义。当每个工匠完成了他们自己的工作后,IRP请求也就完成了。
这里将要编写的驱动程序并没有那么复杂,只是一个驱动栈中的一个简单驱动。