admin 管理员组文章数量: 1184232
2024年1月12日发(作者:什么是前端框架)
Windows下设备驱动程序的开发方法
目录
一、
驱动开发环境的搭建..............................................................................1
1.1 关于1
1.2 关于驱动程序的编译.............................................................................1
1.3关于驱动程序的运行..............................................................................2
二、
驱动程序的结构......................................................................................3
2.1 驱动程序的头文件.................................................................................3
2.2 驱动程序的入口点.................................................................................3
2.3 创建设备例程.........................................................................................4
2.4 卸载驱动例程.........................................................................................5
2.5 派遣例程.................................................................................................6
三、
编写驱动程序的基础知识......................................................................6
3.1 内核模式下的字符串操作.....................................................................6
3.2 内核模式下各种开头函数的区别.........................................................8
3.3 一个示例程序.......................................................................................10
3.4 补充说明...............................................................................................10
四、
在驱动中使用链表................................................................................10
4.1 内存的分配与释放...............................................................................10
4.2 使用.12
4.3 使用自旋锁...........................................................................................12
五、
在驱动中读写文件................................................................................15
5.1 使用15
5.2 创建、打开文件...................................................................................16
5.3 读写文件操作.......................................................................................16
5.4 文件的其它相关操作...........................................................................18
六、
在驱动中操作注册表............................................................................18
6.1 创建、打开注册表...............................................................................19
6.2 读写注册表...........................................................................................20
6.3 枚举注册表...........................................................................................21
七、
在驱动中获取系统时间........................................................................21
7.1 获取启动毫秒数...................................................................................21
7.2 获取系统时间.......................................................................................22
八、
在驱动中创建内核线程........................................................................23
8.1 创建内核线程.......................................................................................23
8.2 关于线程同步.......................................................................................24
九、
初探25
9.1 IRP的概念.............................................................................................25
9.2 IRP的处理.............................................................................................26
9.3 IRP派遣例程示例.................................................................................27
十、
驱动程序与应用层的通信....................................................................29
10.1 使用WriteFile通信.............................................................................29
10.2 使用DeviceIoControl进行通信..........................................................32
十二、驱动程序开发实例.................................................................................33
12.1 NT驱动程序........................................................................................33
12.2 WDM驱动程序...................................................................................35
十三、参考资料.................................................................................................41
II
一、 驱动开发环境的搭建 1.1 关于DDK
开发驱动程序必备的一个东西就是DDK(Device Development Kit,设备驱动开发包),它跟我们在ring3常听到的SDK差不多,只不过它们分别支持开发不同的程序而已。DDK 和微软其他的产品一样,具有良好的向后兼容性,比如你用DDK2000开发的驱动在DDKXP 里面同样可以编译,但反之却不能保证
DDK常见的版本有DDK2000、DDKXP等,不过现在微软推出的驱动开发包已经不叫DDK了,而是WDK(Windows Driver Kit,Windows驱动开发包)。同时您还可能听说过Driver Studio之类的驱动开发工具,其实那只是对DDK的简单封装,跟SDK与MFC的关系差不多,不过Driver Studio不仅仅是对DDK的封装,而是个完整的开发工具包,它提供了很多有用的工具用于驱动程序的开发和调试,不过这些工 具我们可以单独提取出来使用。 DDK可以在微软的官方网站下载,当然也可以在Google搜索到很多链接,推荐至少使用DDKXP或更高的版本,下载到本地后直接双击安装就可以了。
1.2 关于驱动程序的编译
前面我们说了,DDK相当于在开发普通的ring3应用程序所使用的SDK,那么我们是否有与开发ring3应用程序对应的IDE呢,比如VC6.0、2003、Delphi等。很遗憾,除了Driver Studio,关于驱动开发的IDE我知道的不多,多数情况下我们都是使用DDK提供的在命令行下直接编译连接生成“.sys”文件,同时还需要自己编写makefile和sources文件。
对于使用VC开始学习编程的人们来说,makefile概念有点可能有点陌生,而且很多习惯了使用IDE的人们往往很讨厌麻烦的命令行编译。实际上我们要知道,VC本身只是个框 架,“编译”这个工作还是由一个名为“”的命令行工具执行的,VC不过是通过一个良好的界面帮我们完成了一些参数设置的工
1
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
作。
知道了这一点,我们就应该明白,使用VC来编译驱动程序是可行的,实际上已经有很多文章详细介绍如何使用VC环境开发驱动程序,版本从经典的VC6.0到最新的VS2008都没有被遗漏。
这里介绍一个简单的方法,即使用EasySys这个小工具。这是一个很实用的小工具,它可以通过简单的设置生成一个完整的“.dsw”工程,我们使用VC6.0打开该工程文件就可以方便地编写代码了,然后直接按“F7”完成编译连接这个过程。
EasySys是开源的程序,所有人都可以通过修改代码来定制自己的EasySys,但在我们对驱动开发比较熟悉之前,不建议这样做,如果我们生成的代码不合理很容易造成蓝屏。网上有很多大牛发布有自己修改过的版本,我们直接下载使用就可以了。它通过一个批处理文件来设置编译参数,但我们在开始学习时可以不理会它是如何工作的,只要能使用就是好东西。
1.3关于驱动程序的运行
通常我们生成的驱动程序格式都是“.sys”,虽然它也是PE格式,但不能直接运行,必须被加载到系统中。
因此我们在编写好一个驱动程序后,如果想运行查看结果,则必须使用一些工具来进行加载,当然也可以自己编写,我们将会在第十章介绍如何编程加载驱动。现在我们使用一个名为“KmdManager”的小工具来加载驱动。其运行界面如图1-3所示:
2
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
图1-3 KmdManager界面
二、 驱动程序的结构 2.1 驱动程序的头文件
在第一章我们曾经提到过,NT式驱动需要导入的头文件是“ntddk.h”,而WDM式驱动需要导入的是“wdm.h”,即:#include"ntddk.h";。在驱动中用到的变量或函数都需要指定分配在分页或非分页内存中,分页内存在物理内存不够的情况下可能会被交换出去,对于一些需要高IRQL的例程绝对不能被交换出页面,因此它们必须被定义为非分页内存。
通常来说在驱动程序的自定义头文件中都是定义了一些宏或函数声明,没有什么特别需要注意的地方。
2.2 驱动程序的入口点
在驱动对象DriverObject中,有个函数指针数组MajorFunction,它里面的每
3
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
一个元素都记录着一个函数的地址对应着相应的IRP,我们可以通过简单地设置这个数组将IRP与相应的派遣函数关联起来。诸如IRP_MJ_CREATE其实是使用#define定义的一个宏,比如IRP_MJ_CREATE实际上就是0x00,而IRP_MJ_CLOSE则是0x02等。
由于在进入DriverEntry之前,I/O管理器会将_IopInvalidDeviceRequest的地址填满整个MajorFunction数组,因此除了我们自行设置过的IRP之外,其他的IRP都与系统默认的_IopInvalidDeviceRequest函数关联。
2.3 创建设备例程
这里我们又遇到了一个新的概念“例程”,其实也不新,驱动中所说的例程实际上就是函数的另外一种说法,我们毋需过于关心这种细节(实际上例程与函数还是有所区别的,但我们不作关心)。
创建设备本来是在DriverEntry中完成的,不过这里为了讲解方便,我专门将其抽了出来,下面我们来看看相关代码:
// 创建设备对象
RtlInitUnicodeString(&ntDeviceName,TEST_DEVICE_NAME_W);
Status=IoCreateDevice(
DriverObject,
sizeof(DEVICE_EXTENSION), //DeviceExtensionSize
&ntDeviceName, //DeviceName
FILE_DEVICE_TEST, //DeviceType
0, //DeviceCharacteristics
TRUE, //Exclusive
&deviceObject //[OUT]
);
if(!NT_SUCCESS(Status))
{
KdPrint(("[Test]IoCreateDeviceErrorCode=0x%Xn",Status));
returnStatus;
}
deviceExtension=(PDEVICE_EXTENSION)deviceObject->DeviceExtension;
// 创建符号链接
4
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
RtlInitUnicodeString(&dosDeviceName,TEST_DOS_DEVICE_NAME_W);
Status=IoCreateSymbolicLink(&dosDeviceName,&ntDeviceName);
if(!NT_SUCCESS(Status))
{
KdPrint(("[Test]IoCreateSymbolicLinkErrorCode=0x%Xn",Status));
IoDeleteDevice(deviceObject);
returnStatus;
}
在上述代码中,我们先来看创建设备对象的代码,首先我们使用RtlInitUnicodeString函数来初始化UNICODE字符串,关于字符串的用法请参考其他书籍;然后我们调用函数IoCreateDevice来完成创建设备对象的功能,该函数返回一个NTSTATUS值,有一个宏NT_SUCCESS可以很方便地判断这个NTSTATUS是否成功。
紧接着我们调用IoCreateSymbolicLink创建一个符号链接,前面我们创建的设备对象虽然有个参数指定了设备名称,但是这个设备名称只能在内核态可见,也就说ring3的应用层程序是看不见它的,因此驱动程序需要向ring3公布一个符号链接,这个链接指向真正的设备名称,而ring3的应用程序可以通过该符号链接找到驱动程序进行通信。实际上我们经常所说的C盘、D盘就是一个符号链接,它们在内核中的真正设备对象是“DeviceHarddiskVolume1”和“DeviceHarddiskVolume2”。
在内核模式下,符号链接是以“??”(或“DosDevices”)开头的,如C盘就是“??C:”,而在用户模式下,则是以“.”开头的,如C盘就是“.C:”。
2.4 卸载驱动例程
卸载驱动例程是我们在DriverEntry中自己定义的,当驱动被卸载时I/O管理器负责调用该例程,它主要做一些扫尾处理的工作。相关代码如下所示:
UNICODE_STRINGdosDeviceName;
// 释放其他资源
// 删除符号链接
RtlInitUnicodeString(&dosDeviceName,TEST_DOS_DEVICE_NAME_W);
IoDeleteSymbolicLink(&dosDeviceName);
5
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
// 删除设备对象
IoDeleteDevice(DriverObject->DeviceObject);
KdPrint(("[Test]Unloaded"));
上述代码没有难以理解的地方,我们就简单介绍一下代码最后一句的KdPrint吧。由于驱动程序工作于内核态,不像我们控制台的程序一样可以使用printf输出一些信息,也不像Win32程序可以通过MessageBox来弹出一个对话框,它要想输出一些信息,就需要调用DbgPrint函数,不过这个函数输出的信息我们无法直接看到,需要使用一些专门的工具,比如DbgView等。
有些内容我们只想在调试版输出,在发行版忽略,因此DDK中定义了一个宏KdPrint,它在发行版不被编译,只在调试版才会运行。KdPrint的用法很奇怪,由于它是这样定义的:#defineKdPrint(_x_)DbgPrint_x_,这就导致了它的用法很奇怪,在使用时最外层要有两个 连续的括号。
2.5 派遣例程
派遣例程是处理IRP的,为了快速入门,现在我们暂时不处理和IRP有关的地方,因此这里就使用EasySys生成的框架,不做修改,第九章我们会详细介绍IRP的内容。
三、 编写驱动程序的基础知识
3.1 内核模式下的字符串操作
内核模式与用户模式一样都是有ANSI和UNICODE两种字符串,但可以这么说,Windows内核是使用Unicode编码的,ANSI只在很少的特殊场合才会使用,而这种场合往往是非常罕见的(摘自楚狂人的驱动教程),因此我们就不考虑ANSI字符串了,这里只介绍Unicode字符串的用法。
Unicode字符串有一个结构体定义如下:
typedefstruct_UNICODE_STRING{
USHORTLength; // 字符串的长度(字节数)
6
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
USHORTMaximumLength; // 字符串缓冲区的长度(字节数)
PWSTR Buffer; // 字符串缓冲区
}UNICODE_STRING,*PUNICODE_STRING;
需要注意的是,当我们定义了一个UNICODE_STRING变量之后,它的Buffer域还没有分配空间,因此我们不能直接赋值,好的做法是使用微软提供的Rtl系列函数。
UNICODE_STRINGstr;
RtlInitUnicodeString(&str,L"myfirststring!");
或者如下所示:
#include
UNICODE_STRINGstr=RTL_CONSTANT_STRING(L"myfirststring!");
看了上面的代码之后我们回顾一下第二章讲解创建设备对象和符号链接的代码,是不是就用RtlInitUnicode函数来初始化的。
还有一个需要注意的地方是,与ring3不同,我们的UNICODE字符串并不是以“0”来表示字符串结束的,而是依靠UNICODE_STRING的Length域来确定。
字符串的很多操作都有相应的函数,例如字符串的复制可以使用RtlCopyUnicodeString函数,字符串的比较可以使用RtlCompareUnicodeString函数,字符串转换成大写可以使用RtlUpcaseUnicodeString函数(没有转换成小写的),字符串与整数数字互相转换分别可以使用RtlUnicodeStringToInteger和RtlIntegerToUnicodeString函数。
下面我们来着重说明一下字符串的打印方法。比如在输出日志记录的时候,我们往往同时涉及数字、字符等信息,在C语言中我们可以使用sprintf和swprintf函数来完成任务,这两个函数在驱动中仍然可以使用,但很不安全,因为有许多C语言的运行时函数都是基于Win32API的,在驱动中绝对不能使用,如果我们不清楚哪些可以使用哪些不能使用,就都不要使用,而使用微软推荐的Rtl系列函数。对应sprintf的功能函数是RtlStringCbPrintfW,它需要包含头文件“ntstrsafe.h”和静态连接库“”。相关代码如下所示:
#include
// 任何时候,假设文件路径的长度为有限的都是不对的。应该动态的分配内存。
7
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
但动态分配内存的
// 方法还没有讲述,所以这里再次把内存空间定义在局部变量中,也就是所谓的“在栈中”
WCHARbuf[512]={0};
UNICODE_STRINGdst;
NTSTATUSstatus;
……
// 字符串初始化为空串。缓冲区长度为512*sizeof(WCHAR)
RtlInitEmptyString(dst,dst_buf,512*sizeof(WCHAR));
// 调用RtlStringCbPrintfW来进行打印
status=RtlStringCbPrintfW(
dst->Buffer,L”filepath=%wZfilesize =%drn”,
&file_path,file_size);
// 这里调用wcslen没问题,这是因为RtlStringCbPrintfW打印的字符串是以空结束的。
dst->Length=wcslen(dst->Buffer)*sizeof(WCHAR);
RtlStringCbPrintfW在目标缓冲区内存不足的时候依然可以打印,但是多余的部分被截去了。返回的status值为STATUS_BUFFER_OVERFLOW。调用这个函数之前很难知道究竟需要多长的缓冲区。一般都采取倍增尝试。每次都传入一个为前次尝试长度为2倍长度的新缓冲区,直到这个函数返回STATUS_SUCCESS为止。
值得注意的是UNICODE_STRING类型的指针,通常用%wZ可以打印出字符串。在不能保证字符串为空结束的时候,必须避免使用%ws或者%s。其他的打印格式字符串与传统C语言中的printf函数完全相同。可以尽情使用。
3.2 内核模式下各种开头函数的区别
在驱动开发的过程中,我们可能遇到很多不同开头的函数,如前面我们遇到过的Rtl和Io系列,此外还有比如Ex、Ps、Nt等等。
常见的函数开头及其含义如下表所示。
函数开头 含义
Cc
Cachemanager
8
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
Cm
Ex
FsRtl
Hal
Io
Ke
Lpc
Lsa
Mm
Nt
Configurationmanager
Executive support rountines
File system driver run-time library
Hardware abstraction layer
I/O manager
Kernel
Local Procedure Call
Local security authentication
Memory manager
Windows2000 system services(most of which are exported as win32
functions),例如NtCreateFile往往导出为CreateFile
Ob
Po
Pp
Ps
Rtl
Se
Wmi
Zw
Object manager
Power manger
PnP manager
Process support
Run-time library
Security
Windows Management Instrumentation
Mirror entry point for system services(beginning with Nt)that sets
previous access mode to kernel, which eliminates parameter validation,
since Ntsystem services validate parameters only if previous access
mode is user see Inside Microsoft Windows2000
上表中所提及到的并不完整,还有一些Dbg、Fs、Csr、Etw等开头的都没有提及到,这些可以等需要用到的时候再进行说明。
9
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
3.3 一个示例程序
3.4 补充说明
大部分的Win32API都是通过NativeAPI实现的,NativeAPI函数一般都是Win32API函数前面加上Nt两个字符,例如CreateFile函数对应着NtCreateFile函数,这些Nt函数都是在“”实现的,而多数Win32API都是在“”导出的,也有少部分GDI或窗口相关的函数是在“”和“”导出的。
NativeAPI从用户模式穿越进入到内核模式调用系统服务,这个穿越过程是通过软中断的方式进入的。这个软中断的实现方法在不同版本的Windows实现方式略有不同,在Win2K下是通过“int2eh”实现的,在WinXP是通过“sysenter”指令完成的。
软中断会将NativeAPI的参数和系统服务号的参数一起传进内核模式,不同的Native API会对应不同的系统服务号,这个过程是由SSDT辅助完成的。
系统服务函数一般和NativeAPI具有相同的名字,例如都是NtCreateFile,但它们的实现不同,系统服务调用是在“”导出的。
四、 在驱动中使用链表 4.1 内存的分配与释放
传统的C语言中,分配内存常常使用的函数是malloc,但在驱动开发过程中这个函数不再有效。驱动中分配内存,最常用的是调用ExAllocatePoolWithTag或ExAllocatePool。
// 定义一个内存分配标记
#defineMEM_TAG ‘MyTt’
// 目标字符串,接下来它需要分配空间。
UNICODE_STRINGdst={0};
// 分配空间给目标字符串。根据源字符串的长度。
10
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
=(PWCHAR)ExAllocatePoolWithTag(NonpagedPool,src->Length,MEM_TAG);
if(==NULL)
{
// 错误处理
status=STATUS_INSUFFICIENT_RESOUCRES;
……
}
=mLength=src->Length;
ExAllocatePoolWithTag的第一个参数NonpagedPool表明分配的内存是非分页内存,这样它们可以永远存在于物理内存,而不会被分页交换到硬盘上去;第二个参数是长度;第三个参数是一个所谓的“内存分配标记”。
内存分配标记用于检测内存泄漏。想象一下,我们根据占用越来越多的内存的分配标记,就能大概知道泄漏的来源。一般每个驱动程序定义一个自己的内存标记。也可以在每个模块中定义单独的内存标记。内存标记是随意的32位数字。即使冲突也不会有什么问题。
此外也可以分配可分页内存,使用PagedPool标识第一个参数即可。
ExAllocatePoolWithTag分配的内存可以使用ExFreePool来释放,否则的话这块内存就会产生泄漏。虽然用户进程关闭后自动释放进程内分配的空间,但驱动不太一样,即使它已经被卸载,空间也不会自动释放,除非重启计算机。
ExFreePool只需要提供需要释放的指针即可。举例如下:
ExFreePool();
=NULL;
=mLength=0;
注意,ExFreePool不能用来释放一个栈空间的指针,否则系统立刻崩溃。诸如下面的代码将会招致立刻蓝屏的灾难:
UNICODE_STRINGsrc=RTL_CONST_STRING(L”Mysourcestring!”);
ExFreePool();
请务必保持ExAllocatePoolWithTag或ExAllocatePool和ExFreePool的成对关系。
11
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
4.2 使用LIST_ENTRY
Windows内核提供了一个双向链表结构LIST_ENTRY,此外还有一些其他的结构,比如SINGLE_LIST_ENTRY(单向链表),我们这里不作介绍。
LIST_ENTRY是一个双向链表结构,但直接使用它将毫无任何意义,通常的做法是我们自定义一个结构体,将LIST_ENTRY作为该结构体的一个子域,这样给予了我们最大限度的灵活性,因为我们的数据需求千差万别,可能是整数、字符串等等,但只要简单修改一下便可利用LIST_ENTRY轻松实现一个链表。如下所示:
typedefstruct_MYDATASTRUCT{
ULONGnumber;
LIST_ENTRYListEntry;
}MYDATASTRUCT,*PMYDATASTRUCT;
把它放在后面,比如微软提供的很多结构就不是将其放在第一个子域。
这时候,如果我们想获取节点的地址,需要有一个计算偏移的过程,DDK里面提供了一个宏CONTAINING_RECORD可以在指定结构中找到节点地址的指针。
4.3 使用自旋锁
链表之类的结构总是涉及到恼人的多线程同步问题,这时候就必须使用锁。本章只介绍最简单的自选锁。
有些开发人员可能疑惑锁存在的意义,其实这和多线程操作有关。在驱动开发的代码中,大多是存在于多线程执行环境的,就是说可能同时有多个线程操作一个变量,这时候就可能会引起不可预料的后果。
虽然,多线程并不是真正的并发,但操作链表的过程翻译成汇编指令后往往是由多条指令组成的,简单如intc=a+b;之类的代码也是由多条指令组成的,这就是说这些操作不具有原子性,通过反汇编查看一下就很容易明白。
如下的代码演示了如何使用自选锁:
KSPIN_LOCKmy_spin_lock;
12
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
KIRQLirql;
// 初始化
KeInitializeSpinLock(&my_spin_lock);
KeAcquireSpinLock(&my_spin_lock,&irql);
//dosomething …
KeReleaseSpinLock(&my_spin_lock,irql);
在使用的时候,我们把需要同步的代码加在KeAcquireSpinLock和KeReleaseSpinLock这两个函数之间,这样在一个线程操作完成调用KeReleaseSpinLock之前,其他线程只能在KeAcquireSpinLock前面等候。KIRQL是一个中断级,KeAcquireSpinLock函数会提高当前的中断级,目前我们忽略这个问题。
在下面的一段程序中,具体演示了链表和自旋锁的操作方法。
VOID
LinkListTest()
{
LIST_ENTRYlinkListHead; // 链表
PMYDATASTRUCTpData; // 节点数据
ULONGi=0; // 计数
KSPIN_LOCKspin_lock; // 自旋锁
KIRQL irql; // 中断级别
// 初始化
InitializeListHead(&linkListHead);
KeInitializeSpinLock(&spin_lock);
//向链表中插入10个元素
KdPrint(("[Test]Begininserttolinklist"));
// 锁定,注意这里的irql是个指针
KeAcquireSpinLock(&spin_lock,&irql);
for(i=0;i<10;i++)
{
pData=(PMYDATASTRUCT)ExAllocatePool(PagedPool,sizeof(MYDATASTRUCT));
pData->number=i;
InsertHeadList(&linkListHead,&pData->ListEntry);
}
// 解锁,注意这里的irql不是指针
KeReleaseSpinLock(&spin_lock,irql);
//从链表中取出所有数据并显示
KdPrint(("[Test]Beginremovefromlinklistn"));
13
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
// 锁定
KeAcquireSpinLock(&spin_lock,&irql);
while(!IsListEmpty(&linkListHead))
{
PLIST_ENTRYpEntry=RemoveTailList(&linkListHead);
// 获取节点地址
pData=CONTAINING_RECORD(pEntry,MYDATASTRUCT,ListEntry);
// 读取节点数据
KdPrint(("[Test]%dn",pData->number));
ExFreePool(pData);
}
// 解锁
KeReleaseSpinLock(&spin_lock,irql);
}
现在我们需要查看程序的运行效果,首先打开DbgView,设置过滤条件为“*[Test]*”,然后使用KmdManager加载此驱动并运行(最好在虚拟机中测试),注意查看DbgView的输出,如图4-1所示:
图4-1 链表和自旋锁的操作
需要注意的是,像上述代码中在函数中定义一个锁的做法是没有实际意义
14
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
的,因为它是一个局部变量,被定义在栈中,每当有线程调用该函数时,都会重新初始化一个锁,因此它就失去了本来的作用。
在实际的编程中,我们应该把锁定义为一个全局变量、静态(static)变量或者将其定义在堆中。不过在驱动中应该尽量避免使用全局变量,良好的做法是将需要全局访问的变量定义为设备扩展结构体DEVICE_EXTENSION的一个子域。
另外,我们还可以为每个链表都定义并初始化一个锁,在需要向该链表插入或移除节点时不使用前面介绍的普通函数,而是使用如下方法:
ExInterlockedInsertHeadList(&linkListHead,&pData->ListEntry,&spin_lock);
pData=(PMYDATASTRUCT)ExInterlockedRemoveHeadList(&linkListHead,&spin_lock);
此时在向链表中插入或移除节点时会自动调用关联的锁进行加锁操作,可以有效地保证多线程安全性。
五、 在驱动中读写文件 对文件的读写操作一直是程序开发人员需要熟练掌握的内容,在ring3我们可以使用CreateFile、ReadFile、WriteFile等API,在ring0同样很相似,不过函数变成了ZwCreateFile、ZwReadFile、ZwWriteFile等内核函数。
5.1 使用OBJECT_ATTRIBUTES
ZwCreateFile与ring3的CreateFile函数有所不同,它不能直接将需要打开或创建的文件路径传递过去,我们必须首先填写一个OBJECT_ATTRIBUTES结构,这个结构在内核中被广泛使用,例如后面我们将要介绍的操作注册表函数也会用到它。
这个结构很容易使用,初始化后就可以使用了,初始化过程如下所示:
UNICODE_STRINGstr;
OBJECT_ATTRIBUTESobj_attrib;
RtlInitUnicodeString(&str,L"??C:");
InitializeObjectAttributes(&obj_attrib,
&str, // 需要操作的对象、比如文件或注册表路径等
15
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
OBJ_CASE_INSENSITIVE| OBJ_KERNEL_HANDLE,
NULL,
NULL);
第三个参数OBJ_CASE_INSENSITIVE表示不区分大小写,OBJ_KERNEL_HANDLE表示将要打开的句柄为内核句柄。
内核句柄比起应用层句柄有很多的好处,例如它可以不受进程或线程的限制,而且在需要打开一个内核句柄时不需要考虑当前是否有权限访问该文件的问题。
5.2 创建、打开文件
创建和打开文件都可使用ZwCreateFile函数,它的第一个参数将返回一个文件句柄,所有后续操作都可以通过这个句柄完成,在操作结束后,需要调用ZwClose关闭句柄。
ZwCreateFile函数的第三个参数就是使用我们此前填写的OBJECT_ATTRIBUTES结构;它返回的信息通过第四个IO_STATUS_BLOCK返回;第八、九个参数联合指明了如何打开或创建文件,详细用法请参考DDK帮助文档或Google查询。
其中IO_STATUS_BLOCK的定义如下所示:
typedefstruct_IO_STATUS_BLOCK{
NTSTATUS Status;
ULONG Information;
}IO_STATUS_BLOCK,*PIO_STATUS_BLOCK;
其中Status指明了函数的执行结果,如果执行成功它的值将是STATUS_SUCCESS,否则它将会是一个形如STATUS_XXX的错误提示。
此外,DDK还提供了一个函数ZwOpenFile用来简化打开文件的操作,它所需要的参数比ZwCreateFile更加简洁,使用更加简单。
5.3 读写文件操作
在内核中读写文件与用户模式下十分相似,它们分别使用ZwReadFile和
16
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
ZwWriteFile函数完成。
这两个函数的参数大抵相同,因此我们这里只对ZwReadFile的参数作简单介绍,该函数的原型如下所示:
NTSTATUS
ZwReadFile(
INHANDLE FileHandle,
INHANDLE Event OPTIONAL,
INPIO_APC_ROUTINE ApcRoutine OPTIONAL,
INPVOID ApcContext OPTIONAL,
OUTPIO_STATUS_BLOCK IoStatusBlock,
OUTPVOID Buffer,
INULONG Length,
INPLARGE_INTEGER ByteOffset OPTIONAL,
INPULONG Key OPTIONAL);
各参数的简要介绍如下所示:
FileHandle:函数ZwCreateFile返回的句柄。如果它是一个内核句柄,则ZwReadFile和ZwCreateFile并不需要在同一个进程中,因为内核句柄是各进程通用的。
Event :一个事件,用于异步完成读时;我们忽略这个参数。
ApcRoutineApc:回调例程,用于异步完成读时;我们忽略这个参数。
IoStatusBlock:返回结果状态,与ZwCreateFile中的同名参数相同。
Buffer:缓冲区,如果读取文件的内容成功,则内容将被读取到这里。
Length:描述缓冲区的长度,即试图读取文件的长度。
ByteOffset:要读取的文件的偏移量,也就是要读取的内容在文件中的位置。一般来说,不要将其设置为NULL,文件句柄不一定支持直接读取当前偏移。
Key:读取文件时用的一种附加信息,一般不使用。
当函数执行成功时返回STATUS_SUCCESS,实际上只要能够读取到任意字节的数据(不管它是否符合参数Length的要求),都返回成功;但是,如果仅读取文件长度之外的部分,则返回STATUS_END_OF_FILE。
ZwWriteFile的参数与ZwReadFile基本相同,不再进行介绍。
17
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
5.4 文件的其它相关操作
除了上述介绍过的一些函数,DDK还提供了一些函数用以文件操作。
ZwQueryInformationFile、ZwSetInformationFile可以分别用来获取和设置文件属性,包括文件大小、文件指针位置、文件属性(如只读、隐藏)、文件创建/修改日期等。
这两个函数的参数基本完全相同,只是功能不完全相同而已,因此这里我们仅以ZwSetInformationFile函数为例进行介绍,这个函数的原型声明如下所示:
NTSTATUS
ZwSetInformationFile(
INHANDLE FileHandle,
OUTPIO_STATUS_BLOCK IoStatusBlock,
INPVOID FileInformation,
INULONG Length,
INFILE_INFORMATION_CLASS FileInformationClass
);
第一、二、四个参数就不介绍了,相信大家都应该能猜到,下面我们仅重点介绍说明FileInformationClass这个参数,FileInformationClass指定修改或查询的类别。
在内核模式下操作文件的函数不像用户模式下那样丰富,想复制文件就调用CopyFile、想删除文件就调用DeleteFile等,在内核模式下除了读写文件的其他所有操作都是通过这两 个ZwQueryInformation和ZwSetInformationFile函数完成的,而如何使这两个函数精确完成我们需要的功能,就需要通过FileInformationClass参数来指定。
六、 在驱动中操作注册表 注册表是Windows的核心,日常的许多操作其实最终都是转化成了对注册表的操作,我们经常需要利用注册表达到一些特殊的效果,例如实现自启动等。
18
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
6.1 创建、打开注册表
和文件操作类似,在操作注册表之前需要首先打开注册表,获得一个句柄,这可以通过函数ZwCreateKey完成。
与ZwCreateFile函数类似,它通过一个OBJECT_ATTRIBUTES获得需要创建或打开的路径信息,但在内核中这个路径与用户模式下不相同,如下表所示:
表6-1 注册表中路径的写法
应用程序中对应的子键
HEKY_LOCAL_MACHINE
HEKY_USER
HEKY_CLASSES_ROOT
HEKY_CURRENT_USER
驱动编程中的路径写法
RegistryMachine
RegistryUser
没有对应的路径
没有简单的对应路径,但是可以求得
实际上,因为用户模式下的应用程序总是由某个“当前用户”打开的,因此在用户模式下可以直接访问HKEY_CLASSES_ROOT和HKEY_CURRENT_USER,但工作在内核模式下的驱动程序不属于任何一个用户,因此不能直接访问这两个根键。
如果ZwCreateKey指定的项不存在,则会直接创建该项,同时由函数的Disposition参数返回REG_CREATED_NEW_KEY;如果指定项已经存在了,则Disposition返回值REG_OPENED_EXISTING_KEY。
DDK同样提供了一个ZwOpenKey函数用以简化打开注册表的操作。同时DDK还提供一系列以Rtl开头的运行时函数,它们可以是对Zw系列函数的封装,可以有效地简化对注册表的操作过程。如下面两张表所示:
表6-2 注册表相关Zw系列函数
函数
ZwCreateKey
ZwOpenKey
ZwSetValueKey
功能
创建打开指定的注册表项
打开注册表项(ZwCreateKey的简化)
添加或修改指定的键值
19
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
ZeQueryKey
ZwQueryValueKey
ZwEnumerateValueKey
ZwEnumerateValueKey
ZwDeletekey
函数
RtlCreaterRegistryKey
RtlCheckRegistrykey
RtlWriteRegistryValue
RtlQueryRegistryValues
RtlDeleteRegistryValue
查询指定的项
查询指定的键值
枚举子项
枚举子键
删除指定的项
图6-3 注册表相关Rtl系列函数
功能
创建注册表项
检查指定的注册表项是否存在
写注册表
读注册表
删除指定的键值
6.2 读写注册表
注册表是以二元形式存储的,即“键名”和“键值”,通过键名来设置键值,其中键值分为多种情况,如表6-4所示:
图6-4 键值的分类
分类
REG_BINARY
REG_SZ
REG_EXPAND_SZ
REG_MULTI_SZ
REG_DWORD
REG_QWORD
描述
键值用二进制存储
键值用宽字符串存储,字符串以0隔开
同上,该字符串可扩展
键存储多个字符串,每个字符串以0隔开键使用4字节存储
键使用8字节存储
我们可以通过ZwSetValueKey函数添加或修改注册表键值,通过ZwQueryValueKey函数查询相关键值。这两个函数的使用与ring3没有多大差异,通过DDK的帮助文档可以很容易知道它的用法,因此这里不再赘述。
20
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
6.3 枚举注册表
枚举注册表是一个非常实用的功能,它通常分两种情况:枚举一个注册表项的所有子项和枚举一个注册表项的所有子键。
枚举子项使用ZwQueryKey(注意不是ZwQueryValueKey)和ZwEnumerateKey配合完成,枚举子键使用ZwQueryKey和ZwEnumerateValueKey配合完成。
我们以枚举子项来说明思路,首先利用ZwQueryKey获得某项究竟有多少个子项,然后利用ZwEnumerateKey来获取指定子项的详细信息,这个过程是通过一个子项索引(index)来完成的。
在使用ZwQueryKey时,可以将参数KeyInformationClass指定为KeyFullInformation,它对应KEY_FULL_INFORMATION结构中的SubKeys指明了该项中有多少子项。
七、 在驱动中获取系统时间 在编程中,经常需要获得系统时间,或是需要获得一个从启动开始的毫秒数。前者往往是为了日志记录,后者很适合用来获得一个随机数种子。
7.1 获取启动毫秒数
在ring3我们可以通过一个GetTickCount函数来获得自系统启动开始的毫秒数,在ring0也有一个与之对应的KeQueryTickCount函数。
不幸的是,这个函数并不能直接返回毫秒数,它返回的是“滴答”数,而一个时钟“滴答”到底是多久,这在不同的系统中可能是不同的,因此我们还需要另外一个函数的辅助,即KeQueryTimeIncrement函数。
KeQueryTimeIncrement函数可以返回一个“滴答”表示多少个100纳秒,注意这里的单位是100纳秒。
21
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
7.2 获取系统时间
在ring3获取系统时间是非常简单的,我们直接使用GetLocalTime就可以通过一个系统时间结构体SYSTEMTIME来返回当前时间。
到了ring0我们可以使用KeQuerySystemTime来获得当前时间,但它其实是一个格林威治时间,与ring3得到的LocalTime不同,因此我们还需要使用ExSystemTimeToLocalTime函数将这个格林威治时间转换成当地时间。
事情到这里还没有结束,现在我们获得的当地时间不是一个容易阅读的格式,因此我们还要使用RltTimeToTimeFieldh函数将其转换成容易阅读的格式。
下面两个程序分别演示了获得启动毫秒数和当前时间的方法,它们被封装在两个函数里面,我们可以很方便地拿来使用。
VOID
MyGetTickCount()
{
LARGE_INTEGER tick_count;
ULONG inc;
inc=KeQueryTimeIncrement();
KeQueryTickCount(&tick_count);
// 因为1毫秒等于1000000纳秒,而inc的单位是100纳秒
// 所以除以10000即得到当前毫秒数
tick_rt*=inc;
tick_rt/=10000;
KdPrint(("[Test]TickCount:%d",tick_rt));
}
VOID
MyGetCurrentTime()
{
LARGE_INTEGER CurrentTime;
LARGE_INTEGER LocalTime;
TIME_FIELDS TimeFiled;
staticWCHAR Time_String[32]={0};
// 这里得到的其实是格林威治时间
KeQuerySystemTime(&CurrentTime);
// 转换成本地时间
ExSystemTimeToLocalTime(&CurrentTime,&LocalTime);
// 把时间转换为容易理解的形式
22
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
RtlTimeToTimeFields(&LocalTime,&TimeFiled);
KdPrint(("[Test]NowTime:%4d-%2d-%2d%2d:%2d:%2d",
,,,
,,));
}
上面两段演示程序的测试效果如图7-1所示:
图7-1 获得系统时间
八、 在驱动中创建内核线程 线程是一个非常有用的东西,操作系统的最小执行单元就是线程,在内核中线程的概念尤其容易显现出来。
8.1 创建内核线程
在ring3我们可以使用CreateThread这个Win32API创建线程,在ring0也有与之对应的内核函数PsCreateSystemThread。
这个函数与CreateThread的使用很相似,它可以通过第一个参数返回线程的句柄,最后两个参数分别指定线程函数的地址和参数,在ring3我们就是这么做的。
23
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
我们使用CreateThread创建的线程只属于当前进程(不过CreateRemoteThread函数可以在指定进程中创建线程),而PsCreateSystemThread函数默认情况下创建的却是一个系统进程,它属于进程名为“system”,PID=4的这个进程。不过PsCreateSystemThread也是可以创 建用户线程的,这取决于它的第四个参数ProcessHandle,如果它为空,则创建的即系统线程;如果它是一个进程句柄,则创建的就是属于该指定进程的用户线程。
线程函数是一个非常重要的部分,它决定了该线程具有什么样的功能。线程函数必须按照如下规范声明:
VOIDThreadProc(INPVOIDcontext);
这个VOID指针参数通过强制转换可以达到很多特殊效果,给予了我们很大的自由度。我们还需要注意的一点,在内核里创建的线程必须自己调用PsTerminateSystemThread来结束自身,它不能像ring3的线程那样可以在执行完毕后自动结束。
8.2 关于线程同步
提到线程就不能不提到同步的问题,虽然多线程并不是真正的并发运行,但由于CPU分配的时间片很短,看起来它们就像是并发运行的一样。
此前我们曾经介绍过自旋锁,它就是一种典型的同步方案,不过在线程同步的时候通常不使用它,而是使用事件通知,此外还有类似ring3的临界区、信号灯等方法。
下面我们介绍使用KEVENT事件对象进行同步的方法。
在使用KEVENT事件对象前,需要首先调用内核函数KeInitializeEvent对其初始化,这个函数的原型如下所示:
VOID
KeInitializeEvent(
INPRKEVENT Event,
INEVENT_TYPE Type,
INBOOLEAN State);
第一个参数Event是初始化事件对象的指针;第二个参数Type表明事件的
24
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
类型。事件分两种类型:一类是“通知事件”,对应参数为NotificationEvent,另一类是“同步事件”,对应参数为SynchronizationEvent;第三个参数State如果为TRUE,则事件对象的初始化状态为激发状态,否则为未激发状态。
如果创建的事件对象是“通知事件”,当事件对象变为激发态时,需要我们手动将其改回未激发态。如果创建的事件对象是“同步事件”,当事件对象为激发态时,如果遇到相应的KeWaitForXXXX等内核函数,事件对象会自动变回到未激发态。
设置事件的函数是KeSetEvent,可通过该函数修改事件对象的状态。
九、 初探IRP 对IRP的处理是驱动开发中很重要的一个部分,本章我们将简单介绍有关IRP的概念以及常规操作。
9.1 IRP的概念
此前我们可能曾经多次听说过IRP这个名词,那么它究竟是什么呢?
IRP的全名是I/O Request Package,即输入输出请求包,它是Windows内核中的一种非常重要的数据结构。上层应用程序与底层驱动程序通信时,应用程序会发出I/O请求,操作系统将相应的I/O请求转换成相应的IRP,不同的IRP会根据类型被分派到不同的派遣例程中进行处理。
IRP有两个基本的属性,即MajorFunction和MinorFunction,分别记录IRP的主类型和子类型。操作系统根据MajorFunction决定将IRP分发到哪个派遣例程,然后派遣例程根据MinorFunction进行细分处理。
IRP的概念类似于Windows应用程序中“消息”的概念。在Win32编程中,程序由“消息”驱动,不同的消息被分发到不同的处理函数中,否则由系统默认处理。
文件I/O的相关函数例如CreateFile、ReadFile、WriteFile、CloseHandle等分
25
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
别会引发操作系统产生IRP_MJ_CREATE、IRP_MJ_READ、IRP_MJ_WRITE、IRP_MJ_CLOSE等不同的IRP,这些IRP会被传送到驱动程序的相应派遣例程中。
表9-1列出了常见的IRP类型并给出了简单说明:
表9-1 常见IRP类型
IRP类型
IRP_MJ_CREATE
IRP_MJ_CLOSE
IRP_MJ_CLEANUP
IRP_MJ_PNP
来源说明
创建设备,CreateFile会产生此IRP
关闭设备,CloseHandle会产生此IRP
清理,CloseHandle会产生此IRP
即插即用消息,NT式驱动不支持此IRP,只有WDM式驱动才支持
IRP_MJ_POWER
操作系统处理电源消息是产生此IRP
IRP_MJ_QUERY_INFORMATION获取文件长度,GetFileSize会产生此IRPIRP_MJ_READ
IRP_MJ_SET_INFORMATION
IRP_MJ_SHUTDOWN
IRP_MJ_SYSTEM_CONTROL
读取设备内容,ReadFile会产生此IRP
设置文件长度,GetFileSize会产生此IRP关闭系统前会产生此IRP
系统控制信息,类似于内核调用DeviceControl
IRP_MJ_WRITE
向设备写入数据,WriteFile会产生此IRP9.2 IRP的处理
在第二章我们就介绍过如何在DriverEntry中为不同的IRP设置相应的派遣例程。在派遣例程中处理IRP最简单做法就是将IRP的状态设置为成功,然后结束IRP请求并返回成功,同时还要记得设置这个IRP请求操作了多少字节。
我们在派遣函数中设置IRP的完成状态为STATUS_SUCCESS,发起I/O请求的Win32 API才能返回TRUE,否则Win32API将返回FALSE,在这个时候可以通过GetLastError获得错误代码,这个错误代码会和此时IRP被设置的状态一
26
Windows下设备驱动程序的开发方法 2120080411 计算机应用 赖锡盛
致。
下面的代码给出了简单的处理IRP例子:
NTSTATUS
TestDispatchRoutin(
INPDEVICE_OBJECT DeviceObject,
INPIRP Irp
)
{
NTSTATUSstatus=STATUS_SUCCESS;
// 设置IRP完成状态
Irp->=status;
// 设置IRP操作字节
Irp->ation=0;
// 结束IRP
IoCompleteRequest(Irp,IO_NO_INCREMENT);
returnstatus;
}
9.3 IRP派遣例程示例
在这个程序中,我们首先按照IRP_MJ_CLOSE的格式增加一个IRP_MJ_CLEANUP的派遣例程,具体请参考附文代码。
然后我们再编写一个应用层的控制台程序,代码如下所示:
#include"windows.h"
#include"stdio.h"
intmain()
{
// 打开设备句柄,它会触发IRP_MJ_CREATE
HANDLEhDevice=::CreateFile(".Test", // 符号链接
GENERIC_READ| GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
if(hDevice==INVALID_HANDLE_VALUE)
{
printf("TrytoOpenDevice%sError:%d!n",".Test",::GetLastError());
27
return-1;
}
// 关闭设备句柄,它会触发IRP_MJ_CLEANUP和IRP_MJ_CLOSE
CloseHandle(hDevice);
return0;
}
下面我们来介绍应用层程序打开的符号链接。我们查看驱动程序的DriverEntry,可以看到它调用IoCreateSymbolicLink创建了一个符号链接,如下所示:
//Test.h
#defineTEST_DOS_DEVICE_NAME_W L"DosDevicesTest"
//Test.c
RtlInitUnicodeString(&dosDeviceName,TEST_DOS_DEVICE_NAME_W);
Status=IoCreateSymbolicLink(&dosDeviceName,&ntDeviceName);
从上面可以看出该驱动的符号链接名为“DosDevicesTest”,也可以写成“??Test”,但在编程的时候需要稍微改动一下,写成“.Test”。
现在我们使用KmdManager加载驱动并运行,同时运行我们前面编写的应用层驱动程序,根据应用层程序的代码我们可知它应该会触发IRP_MJ_CREATE、IRP_MJ_CLEANUP、IRP_MJ_CLOSE这三个IRP,而我们的验证结果如图9-2所示:
28
图9-1 程序演示结果
从图9-1中可以看出在应用层程序中调用CreateFile等函数确实会产生相应的IRP,同时我们也理解了如何在应用层打开驱动设备。
十、 驱动程序与应用层的通信 此前的章节中我们都基本没有考虑过驱动程序与应用层程序之间的通信问题,但这是一个非常重要的内容,否则我们的驱动安装之后就无法被应用程序控制了。
10.1 使用WriteFile通信
我们可以在应用层调用ReadFile和WriteFile分别从驱动中读取和写入数据,他们通过两个不同的IRP来传递信息。
前面我们曾经说过,在用户模式下调用WriteFile函数会激发
29
IRP_MJ_WRITE。下面我们就来编写一个通过WriteFile向驱动层写入部分数据的演示程序。首先是我们的应用层程序代码:
#include"windows.h"
#include"stdio.h"
intmain()
{
char szInBuffer[20]={0};
DWORD nLen=0;
// 打开设备句柄
HANDLEhDevice=::CreateFile(".Test", // 符号链接
GENERIC_READ| GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
if(hDevice==INVALID_HANDLE_VALUE)
{ return-1;
}
// 向驱动设备写入连续10个字节的A
memset(szInBuffer,'A',10);
BOOLret=WriteFile(hDevice,
szInBuffer,
10,
&nLen,
NULL);
// 关闭设备句柄
CloseHandle(hDevice);
return0;
}
下面开始写驱动层的代码,首先添加一个IRP_MJ_WRITE的派遣例程,如下所示:
NTSTATUS
TestDispatchWrite(
INPDEVICE_OBJECT DeviceObject,
INPIRP Irp
)
{
NTSTATUS Status=STATUS_SUCCESS;
30
PIO_STACK_LOCATION irpStack;
// 得到当前栈
irpStack=IoGetCurrentIrpStackLocation(Irp);
// 输出缓冲区字节数和内容
DbgPrint("[Test]%d",irpStack->);
DbgPrint("[Test]%s",Irp->Buffer);
// 完成IRP
Irp->ation=irpStack->;
Irp->=Status;
IoCompleteRequest(Irp,IO_NO_INCREMENT);
returnStatus;
}
至于函数声明,添加派遣例程等等我就不多说了,在完整源码中我有中文注释,现在我们使用KmdManager加载该驱动,然后运行前面编写的应用层控制台程序,发现DbgView输出的结果很奇怪,首先输出“[Test]10”,说明接受到了10个字节的数据,看来我们的写入数据测试成功,但输出数据内容时却有误,即“[Test]null”。
仔细查看MSDN中关于IRP_MJ_WRITE的说明我们可以发现,在使用不同的I/O方式时得到的数据是在不同地方的,我加入了一行调试语句输出当前的flag,发现它既不是缓冲区I/O也不是直接I/O。最后我们在DriverEntry中添加一行“deviceObject->Flags|=DO_BUFFERED_IO;”
设置一下,现在再次运行程序,终于成功地输出结果,如图11-1所示:
10-1 使用WriteFile演示结果
31
10.2 使用DeviceIoControl进行通信
使用前面的方法,我们就不得不分别调用ReadFile和WriteFile来读写数据,实际上我们还有更好更通用的做法,就是使用DeviceIoControl函数。这个函数还可以用来做一些除读写之外的操作。
DeviceIoControl函数会使操作系统产生一个IRP_MJ_DEVICE_CONTROL类型的IRP,然后这个IRP会被分发到相应的派遣例程中。
我们先来看一下DeviceIoControl函数的原型声明:
BOOLDeviceIoControl(
HANDLE hDevice, //handletodevice
DWORD dwIoControlCode, //operation
LPVOID lpInBuffer, //inputdatabuffer
DWORD nInBufferSize, //sizeofinputdatabuffer
LPVOID lpOutBuffer, //outputdatabuffer
DWORD nOutBufferSize, //sizeofoutputdatabuffer
LPDWORD lpBytesReturned, //bytecount
LPOVERLAPPED lpOverlapped //overlappedinformation
);
在上面的参数中,我们需要重点掌握的是第二个参数dwIoControlCode,它是I/O控制码,即IOCTL值,是一个32位的无符号整型数值。
实际上DeviceIoControl与ReadFile和WriteFile相差不大,不过它可以同时提供输入/ 输出缓冲区,而且还可以通过控制码传递一些特殊信息。
IOCTL值的定义必须遵循DDK的规定,我们可以使用宏CTL_CODE来声明,如下:
#defineMY_DVC_IN_CODE
(ULONG)CTL_CODE(FILE_DEVICE_UNKNOWN,
0x900, // 自定义IOCTL码
METHOD_BUFFERED, // 缓冲区I/O
FILE_ALL_ACCESS)
32
十二、驱动程序开发实例 12.1 NT驱动程序
12.1.1 HOOK SSDT
HOOKSSDT是早年前很常用的一种rootkit技术,最重要的是它的实现相对容易,运行稳定,所以被很多人青睐。
SSDT的全称是SystemServicesDescriptorTable,即系统服务描述符表。这个表的作用是把ring3的Win32API与ring0的内核API联系起来。当然SSDT并不仅仅只包含一个庞大的地址索引表,它还包含着一些其它有用的信息,如地址索引的基地址、服务函数个数等。通过修改此表的函数地址可以对常用的WindowsAPI进行HOOK,从而实现对一些比较关心的系统动作进行过滤、监控的目的。一些HIPS、防毒软件、系统监控、注册表监控软件往往会采用此接口来实现自己的监控模块。
12.1.2 HOOK SSDT的编写
要挂钩SSDT,就必须先要由内核到处一个KeServiceDescriptorTable,那么我们还要先定义一个KeServiceDescriptorTable类型的的结构体:
typedef struct ServiceDescriptorEntry
{
unsigned int *ServiceTableBase;
unsigned int *ServiceCounterTableBase; //Used only in checked build
unsigned int NumberOfServices;
unsigned char *ParamTableBase;
} SSDTEntry;
定义了KSDT的结构,以ZwTerminateProcess为例,首先定义一个ZwTerminateProcess函数结构,函数原型:
ZwTerminateProcess(
IN HANDLE ProcessHandle OPTIONAL,
IN NTSTATUS ExitStatus
33
);
我们要HOOK ZwTerminateProcess,那么我们是不是要先找出它在KSDT中的位置呢,没错,那么我们来定义一个通过SSDT服务号得到函数地址的宏以达到我们的目的:
#defineGetSystemFunc(FuncName)eTableBase[*(PULONG)((PUCHAR)FuncName+1)];
想要达到改写SSDT的目的,那么首先要解决的是内存保护机制的问题,众所周知,Windows的某些版本对内存区域启用了写保护的功能,在XP和2003中更为常见,SSDT是只读的。可以使用Memory Descriptor List,简称 MDL。从字面意思看,不难理解,内存描述符列表。MDL包含了内存区域的起始、拥有者proc、字节数、标记等。OK,我们需要先定义一个MDL的指针。
定义了MDL的指针以后,我们要通过MAPPED系列的参数来使内存拥有可写性,然后锁定内存中的MDL,那么我们就要定义一个PVOID的指针,来供MmMap操作。获取没被HOOK之前的ZwTerminateProcess在KSDT中的索引,保存。最后就是替换为我们的函数。可以修改SSDT中函数地址指向的位置。
12.1.3 HOOK SSDT的运行
使用KmdManager加载运行该驱动的sys文件后,在任务管理器中结束任何一个进程都会出现如下情形,实现了函数劫持。
34
图12-1 HOOK SSDT运行效果
12.2 WDM驱动程序
WDM程序一般分为两个部分,一部分是类驱动程序(Class Driver),一部分是小驱动程序(Mini Driver)。类驱动程序是由微软提供,它为一类设备提供设计了标准接口,如摄像头驱动,各种摄像头驱动虽然在硬件设计上不尽相同,但为了实现标准化,微软提供的每种类别的Class Driver就会封装这些接口。小驱动程序是由程序员自己编写,它是配合Class Driver针对不同的硬件设备的,不同硬件会有不用的Mini Driver。之所以这样设计,就是为了将接口标准化,一
35
些通用的代码被封装到Class Driver中。只在一些特殊的地方需要被写入Mini
Driver中,从而使程序员的任务大大减轻了。在WDM驱动中,Mini Driver首先将自己注册给Class Driver,Class Driver拥有设备对象,而Mini Driver不用创建设备对象,利用Class Driver中的设备对象进行系统调用。下面以虚拟摄像头的驱动开发进行WDM驱动开发的介绍。
12.2.1 摄像头驱动的编写
摄像头采用的数据都是纯粹的数据文件,然后源源不断地传给PC,因此得名Stream(流设备)。编写摄像头驱动就是编写流设备的Mini Driver,但需要首先了解流设备的Class Driver与Mini Driver之间是如何配合的。
流设备的类驱动主要是控制请求,这需要通过调用小驱动的适配器来访问具体的硬件。在流设备的类驱动和小驱动都初始化了以后,小驱动需要被动地等待类驱动来调用,类驱动用stream request block(SRB)向小驱动发送标准的请求,小驱动通过解析SRB后回答Class Driver的请求。
SRB可以传送命令和数据,SRB用数据结构HW_STREAM_REQUEST_BLOCK代表。下面分析类驱动程序和小驱动程序是如何初始化的。
(1)当设备接入插口时,即插即用管理器能够侦测到新的设备,进而人调用Min Driver的DriverEntry进程。
(2)Mini Driver在自己的DriverEntry中填充HW_INITIALIZATION_DATA,然后返回StreamClassRegisterMiniDriver。
(3)在类驱动中初始化一个SRB_INITIALZATION_DATA,然后返回StreamClassRegisterMiniDriver。对于虚拟摄像头来说,这里没有太多的可用信息,这个SRB会被传递给小驱动程序,小驱动程序通过此或者一些硬件信息结束并且返回,并且通知类驱动,告知小驱动已经初始化完毕。
(4)类驱动程序会再发一个SRB_STREAM_INFO类型的SRB,向小驱动程序询问HW_STREAM_HEADER数据信息和HW_STREAM_INFORMATION等信息。这些信息包含了摄像头驱动提供的视频图像大小、图像格式等信息。
36
(5)类驱动会再向小驱动发一个HW_STREAM_INFORMATION的SRB,这次请求后,小驱动应该做完所有初始化的操作,并且返回给程序。
12.2.2 摄像头驱动小驱动的编写
类驱动程序的母的是与操作系统进行交互,其中包括处理同步、提供标准接口。而小驱动程序由类驱动调用,主要负责具体硬件相关的操作。
程序员编写的小驱动程序会向类驱动注册一些回调函数,类驱动作为主程序,会在适当的时机调用小驱动程序提供的这些回调函数。每个流的小驱动程序都会支持一种或多种数据个数。例如DVD播放器就支持一种声音流数据以及视频流数据。每种数据流都是从一个叫做PIN的接口输出。
每种小驱动程序必须支持一下几种回调函数:
(1)StrMiniCancelPacket:对HW_STREAM_REQUEST_BLOCK数据包进行取消的回调函数。
(2)StrMiniReceiveDevicePacket:获取HW_STREAM_REQUEST_BLOCK数据包的回调函数。
(3)StrMiniRequestTimeout:当HW_STREAM_REQUEST_BLOCK数据包超时的回调函数。
(4)StrMiniEvent:使小驱动程序支持某一事件。
(5)StrMiniInterrupt:当驱动获得中断时进入的回调函数。
另外,对于小驱动程序中的不同数据流还应该支持一下几种回调函数:
(1)StrMiniReceiveStreamDataPacket:对于获取数据流的回调函数。
(2)StrMiniReceiveStreamControlPacket:对于控制数据流的回调函数。
(3)StrMiniEvent:是数据流支持一种事件。
(4)StrMiniClock:对于数据流时钟控制的回调函数。
37
12.2.3 小驱动的流控制
小驱动中的典型流程是初始化、运行和反初始化。一般需要遵循以下几个步骤:
(1)小驱动所支持的硬件插入设备能够被即插即用管理器所检测到,然后为这种设备创造一个PDO,该PDO负责一些即插即用的IRP。
(2)I/O子系统加载小驱动并调用小驱动的DriverEntry入口函数。一般在DriverEntry中初始化HW_INITIALIZATION_DATA数据结构。
(3)在小驱动的DriverEntry中将初始化好的HW_INITIALIZATION_DATA数据结构作为参数,用StreamClassRegisterMiniDriver函数传递给类驱动程序。在HW_INITIALIZATION _DATA中包含一些控制的SRB函数地址,一百年在类驱动中回调这些函数。
(4)在类驱动程序中,类驱动将构造一个SRB,SRB的Command设置为SRB_INITIALIZE_DEVICE。并将此SRB作为参数,调用HW_INITIALIZATION_DATA数据结构中已经初始化的HwReceivePacket。因此,小驱动提供的HwReceivePacket有必要处理SRB_INITIALIZE_DEVICE。
(5)类驱动会构造SRB_GET_STREAM_INFO类型的SRB,并且调用HW_INITIALIZATION_DATA数据结构中的HwReceivePacket。因此,小驱动提供的HwReceivePacket有必要处理SRB_GET_STREAM_INFO。
(6)类驱动会继续调用HwReceivePacket函数,并将SRB_OPEN_STREAM类型的SRB传递给该函数。SRB_OPEN_STREAM的SRB会指定一个HW_STREAM_OBJECT,该数据结构描述一个流的实例。因此,小驱动提供的HwReceivePacket有必要处理SRB_OPEN_STREAM。
(7)类驱动程序会通过小驱动提供的HwReceivePacket用SRB_READ_DATA或SRB_WRITE_DATA发送或接收数据。因此,小驱动程序有必要处理SRB_READ_DATA或SRB_WRITE_DATA。
(8)类驱动会通过小驱动提供的某项属性或者流的某项属性,会向小驱动
38
提供的HwReceivePacket函数发送HW_STREAM_OBJECT请求,并伴随着HW_STREAM_REQUEST_BLOCK数据结构。
(9)当驱动想关闭数据流时,会通过小驱动提供的HwReceive函数发送SRB_CLOSE_STREAM请求,因此,小驱动程序应该在HwReceivePacket中处理SRB_CLOSE_STREAM请求。
12.2.4 虚拟摄像头的入口函数
这里的入口函数是针对小驱动程序而言的,类驱动已经由微软提供,不需要程序员编写,在DriverEntry中所做的事情就是向类驱动程序提供一个HW_INIITIALIZATION_DATA的数据结构,这个数据结构提供了小驱动提供的若个个回调函数,DriverEntry是由类驱动程序所调用。
在DriverEntry中设置的若干个回调函数中,最重要的就是除了STREAM_REQUEST_BLOCK的回调函数,这个回调函数在DriverEntry中设置HW_INITIALIZATION_DATA数据结构的HwReceivePacket的子域,子啊本程序中就是AdapterReceivepPacket函数。该函数只要是针对不同的STREAM_REQUEST_BLOCK进行处理。
12.2.5 打开视频流
在小驱动中一个重要的步骤就是打开设备流,这个请求是由类驱动向小驱动发起的。小驱动程序需要根据它的请求,判断本驱动是否支持请求的格式,视频的格式用固定的128位GUID号码确定。本程序用AdapterVerifyFormat判断是否为本驱动支持的视频格式。
12.2.6 对视频流的读取
本程序在VideoReceiveCtrlPacket函数中进行的代码主要是负责回应视频流的请求。该代码同样是HW_STREAM_BLOCK数据结构的指针,在处理中,会
39
间接调用ImageSynth函数,负责生成一副图像,以便给虚拟摄像头定时提供视频。
12.2.7 虚拟摄像头的运行
在windows的“控制面板”中选择“添加硬件”,选择“是,我已连接了此硬件”,选择“添加新的硬件设备”,单击“安装我手动从列表选择的硬件(高级)”,选择“声音、视频和游戏控制器”,选择“从磁盘安装”,接着按照提示点击下一步即可。
图12-2设备管理器Testcap
图12-3 虚拟摄像头qq中应用
40
十三、参考资料
《Windows驱动开发技术详解》张帆 史彩成 编著,电子工业出版社
《Windows 2000/XP WDM设备驱动程序开发(第2版)》 武安河 编著,电子工业出版社
41
版权声明:本文标题:Windows下设备驱动程序的开发方法 内容由网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://www.roclinux.cn/b/1705069010a471971.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论