版权声明
本书是免费电子书。 作者保留一切权利。但在保证本书完整性(包括版权声明、前言、正文内容、后记、以及作者的信息),并不增删、改变其中任何文字内容的前提下,欢迎任何读者 以任何形式(包括各种格式的文档)复制和转载本书。同时不限制利用此书赢利的行为(如收费注册下载,或者出售光盘或打印版本)。不满足此前提的任何转载、 复制、赢利行为则是侵犯版权的行为。
发现本书的错漏之处,请联系作者。请不要修改本文中任何内容,不经过作者的同意发布修改后的版本。
作者信息
作者网名楚狂人。真名谭文。在上海从事Windows驱动开发相关的工作。对本书任何内容有任何疑问的读者,可以用下列方式和作者取得联系:
QQ:16191935
MSN:walled_river@hotmail.com
前言
本书非常适合熟悉Windows应用编程的读者转向驱动开发。所有的内容都从最基础的编程方法入手。介绍相关的内核API,然后举出示范的例子。这本书只
有不到70页,是一本非常精简的小册子。所以它并不直接指导读者开发某种特定类型的驱动程序。而是起到一个入门指导的作用。
即使都是使用C/C++语言的代码,在不同的应用环境中,常常看起来还是大相径庭。比如用TurboC++编写的DOS程序代码和用VC++编写的MFC应用程序的代码,看起来就几乎不像是同一种语言。这是由于它们所依赖的开发包不相同的缘故。
在任何情况下都以写出避免依赖的代码为最佳。这样可以避免重复劳动。但是我们在学习一种开发包的使用时,必须习惯这个环境的编码方式,以便获得充分利用这个开发包的能力。
本书的代码几乎都依赖于WDK(Windows Driver Kit)。但是不限WDK的版本。WDK还在不断的升级中。这个开发包是由微软公司免费提供的。读者可以在微软的网站上下载。
当然读者必须把WDK安装的计算机上并配置好开发环境。具体的安装和配置方法本书没有提供。因为网上已经有非常多的中文文档介绍它们。
读完这本书之后,读者一定可以更轻松的阅读其他专门的驱动程序开发的文档和相关书籍。而不至于看到大量无法理解的代码而中途放弃。如果有任何关于本书的内
容的问题,读者可以随时发邮件到mfc_tan_wen@163.com或者walled_river@hotmail.com。能够回答的问题我一般都
会答复。
写本书的时候,我和wowocock合作的一本名为《天书夜读》(在网上有一个大约20%内容的缩减电子版本)正在电子工业出版社编辑。预计还有不到一个
月左右就会出版。这也是我自己所见的唯一一本中文原创的从汇编和反汇编角度来学习Windows内核编程和信息安全软件开发的书。希望读者多多支持。有想
购买的读者请发邮件给我。我会在本书出版的第一时间,回复邮件告知购买的方法。
此外我正在写另一本关于Windows安全软件的驱动编程的书。但是题目还没有拟好。实际上,读者现在见到的免费版本的《Windows驱动编程基础教程》是从这本书的第一部分中节选出来的。这本书篇幅比较大,大约有600-800页。主要内容如下:
第一章驱动编程基础
第二章磁盘设备驱动
第三章磁盘还原与加密
第四章传统文件系统过滤
第五章小端口文件系统过滤
第六章文件系统保护与加密
第七章协议网络驱动
第八章物理网络驱动
第九章网络防火墙与安全连接
第十章打印机驱动与虚拟打印
第十一章视频驱动与过滤
附录A WDK的安装与驱动开发的环境配置
附录B 用WinDbg调试Windows驱动程序
这本书还没有完成。但是肯定要付出巨大的精力,所以请读者不要来邮件索取完整的免费的电子版本。希望读者支持本书的纸版出版。因为没有完成,所以还没有联系出版商。有愿意合作出版本书的读者请发邮件与我联系。
凡是发送邮件给我的读者,我将会发送邮件提供本人作品最新的出版信息,以及最新发布的驱动开发相关的免费电子书。如果不需要这些信息的,请在邮件里注明,或者回复邮件给我来取消订阅。
谭文
2008年6月9日
目录
第一章 字符串
1.1 使用字符串结构
常常使用传统C语言的程序员比较喜欢用如下的方法定义和使用字符串:
char *str = { “my first string” }; // ansi字符串
wchar_t *wstr = { L”my first string” }; // unicode字符串
size_tlen = strlen(str); //ansi字符串求长度
size_twlen = wcslen(wstr); //unicode字符串求长度
printf(“%s%ws %d %d”,str,wstr,len,wlen); // 打印两种字符串
但是实际上这种字符串相当的不安全。很容易导致缓冲溢出漏洞。这是因为没有任何地方确切的表明一个字符串的长度。仅仅用一个’ ’字符来标明这个字符串
的结束。一旦碰到根本就没有空结束的字符串(可能是攻击者恶意的输入、或者是编程错误导致的意外),程序就可能陷入崩溃。
使用高级C++特性的编码者则容易忽略这个问题。因为常常使用std::string和CString这样高级的类。不用去担忧字符串的安全性了。
在驱动开发中,一般不再用空来表示一个字符串的结束。而是定义了如下的一个结构:
typedefstruct _UNICODE_STRING {
USHORTLength; // 字符串的长度(字节数)
USHORTMaximumLength; // 字符串缓冲区的长度(字节数)
PWSTR Buffer; //字符串缓冲区
}UNICODE_STRING, *PUNICODE_STRING;
以上是Unicode字符串,一个字符为双字节。与之对应的还有一个Ansi字符串。Ansi字符串就是C语言中常用的单字节表示一个字符的窄字符串。
typedefstruct _STRING {
USHORTLength;
USHORTMaximumLength;
PSTRBuffer;
}ANSI_STRING, *PANSI_STRING;
在驱动开发中四处可见的是Unicode字符串。因此可以说:Windows的内核是使用Uincode编码的。ANSI_STRING仅仅在某些碰到窄字符的场合使用。而且这种场合非常罕见。
UNICODE_STRING并不保证Buffer中的字符串是以空结束的。因此,类似下面的做法都是错误的,可能会会导致内核崩溃:
UNICODE_STRINGstr;
…
len =wcslen(str.Buffer); // 试图求长度。
DbgPrint(“%ws”,str.Buffer); // 试图打印str.Buffer。
如果要用以上的方法,必须在编码中保证Buffer始终是以空结束。但这又是一个麻烦的问题。所以,使用微软提供的Rtl系列函数来操作字符串,才是正确的方法。下文逐步的讲述这个系列的函数的使用。
1.2 字符串的初始化
请回顾之前的UNICODE_STRING结构。读者应该可以注意到,这个结构中并不含有字符串缓冲的空间。这是一个初学者常见的出问题的来源。以下的代码是完全错误的,内核会立刻崩溃:
UNICODE_STRINGstr;
wcscpy(str.Buffer,L”myfirst string!”);
str.Length= str.MaximumLength = wcslen(L”my first string!”) * sizeof(WCHAR);
以上的代码定义了一个字符串并试图初始化它的值。但是非常遗憾这样做是不对的。因为str.Buffer只是一个未初始化的指针。它并没有指向有意义的空间。相反以下的方法是正确的:
// 先定义后,再定义空间
UNICODE_STRING str;
str.Buffer= L”my first string!”;
str.Length= str.MaximumLength = wcslen(L”my first string!”) * sizeof(WCHAR);
… …
上面代码的第二行手写的常数字符串在代码中形成了“常数”内存空间。这个空间位于代码段。将被分配于可执行页面上。一般的情况下不可写。为此,要注意的是这个字符串空间一旦初始化就不要再更改。否则可能引发系统的保护异常。实际上更好的写法如下:
//请分析一下为何这样写是对的:
UNICODE_STRING str = {
sizeof(L”myfirst string!”) – sizeof((L”my first string!”)[0]),
sizeof(L”myfirst string!”),
L”myfirst_string!” };
但是这样定义一个字符串实在太繁琐了。但是在头文件ntdef.h中有一个宏方便这种定义。使用这个宏之后,我们就可以简单的定义一个常数字符串如下:
#include<ntdef.h>
UNICODE_STRING str = RTL_CONSTANT_STRING(L“myfirst string!”);
这只能在定义这个字符串的时候使用。为了随时初始化一个字符串,可以使用RtlInitUnicodeString。示例如下:
UNICODE_STRINGstr;
RtlInitUnicodeString(&str,L”myfirst string!”);
用本小节的方法初始化的字符串,不用担心内存释放方面的问题。因为我们并没有分配任何内存。
1.3 字符串的拷贝
因为字符串不再是空结束的,所以使用wcscpy来拷贝字符串是不行的。UNICODE_STRING可以用RtlCopyUnicodeString来
进行拷贝。在进行这种拷贝的时候,最需要注意的一点是:拷贝目的字符串的Buffer必须有足够的空间。如果Buffer的空间不足,字符串会拷贝不完
全。这是一个比较隐蔽的错误。
下面举一个例子。
UNICODE_STRINGdst; // 目标字符串
WCHARdst_buf[256]; // 我们现在还不会分配内存,所以先定义缓冲区
UNICODE_STRINGsrc = RTL_CONST_STRING(L”My source string!”);
// 把目标字符串初始化为拥有缓冲区长度为256的UNICODE_STRING空串。
RtlInitEmptyString(dst,dst_buf,256*sizeof(WCHAR));
RtlCopyUnicodeString(&dst,&src); // 字符串拷贝!
以上这个拷贝之所以可以成功,是因为256比L” My source string!”的长度要大。如果小,则拷贝也不会出现任何明示的错误。但是拷贝结束之后,与使用者的目标不符,字符串实际上被截短了。
我曾经犯过的一个错误是没有调用RtlInitEmptyString。结果dst字符串被初始化认为缓冲区长度为0。虽然程序没有崩溃,却实际上没有拷贝任何内容。
在拷贝之前,最谨慎的方法是根据源字符串的长度动态分配空间。在1.2节“内存与链表”中,读者会看到动态分配内存处理字符串的方法。
1.4 字符串的连接
UNICODE_STRING不再是简单的字符串。操作这个数据结构往往需要更多的耐心。读者会常常碰到这样的需求:要把两个字符串连接到一起。简单的追加一个字符串并不困难。重要的依然是保证目标字符串的空间大小。下面是范例:
NTSTATUSstatus;
UNICODE_STRINGdst; // 目标字符串
WCHARdst_buf[256]; // 我们现在还不会分配内存,所以先定义缓冲区
UNICODE_STRINGsrc = RTL_CONST_STRING(L”My source string!”);
// 把目标字符串初始化为拥有缓冲区长度为256的UNICODE_STRING空串
RtlInitEmptyString(dst,dst_buf,256*sizeof(WCHAR));
RtlCopyUnicodeString(&dst,&src); // 字符串拷贝!
status= RtlAppendUnicodeToString(
&dst,L”mysecond string!”);
if(status!= STATUS_SUCCESS)
{
……
}
NTSTATUS是常见的返回值类型。如果函数成功,返回STATUS_SUCCESS。否则的话,是一个错误码。
RtlAppendUnicodeToString在目标字符串空间不足的时候依然可以连接字符串,但是会返回一个警告性的错误
STATUS_BUFFER_TOO_SMALL。
另外一种情况是希望连接两个UNICODE_STRING,这种情况请调用RtlAppendUnicodeStringToString。这个函数的第二个参数也是一个UNICODE_STRING的指针。
1.5 字符串的打印
字符串的连接另一种常见的情况是字符串和数字的组合。有时数字需要被转换为字符串。有时需要把若干个数字和字符串混合组合起来。这往往用于打印日志的时候。日志中可能含有文件名、时间、和行号,以及其他的信息。
熟悉C语言的读者会使用sprintf。这个函数的宽字符版本为swprintf。该函数在驱动开发中依然可以使用,但是不安全。微软建议使用
RtlStringCbPrintfW来代替它。RtlStringCbPrintfW需要包含头文件ntstrsafe.h。在连接的时候,还需要连接
库ntsafestr.lib。
下面的代码生成一个字符串,字符串中包含文件的路径,和这个文件的大小。
#include<ntstrsafe.h>
// 任何时候,假设文件路径的长度为有限的都是不对的。应该动态的分配
// 内存。但是动态分配内存的方法还没有讲述,所以这里再次把内存空间
// 定义在局部变量中,也就是所谓的“在栈中”
WCHARbuf[512] = { 0 };
UNICODE_STRINGdst;
NTSTATUSstatus;
……
// 字符串初始化为空串。缓冲区长度为512*sizeof(WCHAR)
RtlInitEmptyString(dst,dst_buf,512*sizeof(WCHAR));
// 调用RtlStringCbPrintfW来进行打印
status= RtlStringCbPrintfW(
dst->Buffer,L”filepath = %wZ file size = %d
”,
&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函数完全相同。可以尽情使用。
另外就是常见的输出打印。printf函数只有在有控制台输出的情况下才有意义。在驱动中没有控制台。但是Windows内核中拥有调试信息输出机制。可以使用特殊的工具查看打印的调试信息(请参阅附录1“WDK的安装与驱动开发的环境配置”)。
驱动中可以调用DbgPrint()函数来打印调试信息。这个函数的使用和printf基本相同。但是格式字符串要使用宽字符。DbgPrint()的一
个缺点在于,发行版本的驱动程序往往不希望附带任何输出信息,只有调试版本才需要调试信息。但是DbgPrint()无论是发行版本还是调试版本编译都会
有效。为此可以自己定义一个宏:
#if DBG
KdPrint(a) DbgPrint##a
#else
KdPrint(a)
#endif
不过这样的后果是,由于KdPrint (a)只支持1个参数,因此必须把DbgPrint的所有参数都括起来当作一个参数传入。导致KdPrint看起来很奇特的用了双重括弧:
// 调用KdPrint来进行输出调试信息
status= KdPrint ((
L”filepath = %wZ file size = %d
”,
&file_path,file_size));
这个宏没有必要自己定义,WDK包中已有。所以可以直接使用KdPrint来代替DbgPrint取得更方便的效果。
第二章 内存与链表
2.1内存的分配与释放
内存泄漏是C语言中一个臭名昭著的问题。但是作为内核开发者,读者将有必要自己来面对它。在传统的C语言中,分配内存常常使用的函数是malloc。这个
函数的使用非常简单,传入长度参数就得到内存空间。在驱动中使用内存分配,这个函数不再有效。驱动中分配内存,最常用的是调用
ExAllocatePoolWithTag。其他的方法在本章范围内全部忽略。回忆前一小节关于字符串的处理的情况。一个字符串被复制到另一个字符串的
时候,最好根据源字符串的空间长度来分配目标字符串的长度。下面的举例,是把一个字符串src拷贝到字符串dst。
// 定义一个内存分配标记
#defineMEM_TAG ‘MyTt’
// 目标字符串,接下来它需要分配空间。
UNICODE_STRINGdst = { 0 };
// 分配空间给目标字符串。根据源字符串的长度。
dst.Buffer=
(PWCHAR)ExAllocatePoolWithTag(NonpagedPool,src->Length,MEM_TAG);
if(dst.Buffer== NULL)
{
// 错误处理
status= STATUS_INSUFFICIENT_RESOUCRES;
……
}
dst.Length= dst.MaximumLength = src->Length;
status= RtlCopyUnicodeString(&dst,&src);
ASSERT(status== STATUS_SUCCESS);
ExAllocatePoolWithTag的第一个参数NonpagedPool表明分配的内存是锁定内存。这些内存永远真实存在于物理内存上。不会被分页交换到硬盘上去。第二个参数是长度。第三个参数是一个所谓的“内存分配标记”。
内存分配标记用于检测内存泄漏。想象一下,我们根据占用越来越多的内存的分配标记,就能大概知道泄漏的来源。一般每个驱动程序定义一个自己的内存标记。也可以在每个模块中定义单独的内存标记。内存标记是随意的32位数字。即使冲突也不会有什么问题。
此外也可以分配可分页内存,使用PagedPool即可。
ExAllocatePoolWithTag分配的内存可以使用ExFreePool来释放。如果不释放,则永远泄漏。并不像用户进程关闭后自动释放所有分配的空间。即使驱动程序动态卸载,也不能释放空间。唯一的办法是重启计算机。
ExFreePool只需要提供需要释放的指针即可。举例如下:
ExFreePool(dst.Buffer);
dst.Buffer= NULL;
dst.Length= dst.MaximumLength = 0;
ExFreePool不能用来释放一个栈空间的指针。否则系统立刻崩溃。像以下的代码:
UNICODE_STRINGsrc = RTL_CONST_STRING(L”My source string!”);
ExFreePool(src.Buffer);
会招来立刻蓝屏。所以请务必保持ExAllocatePoolWithTag和ExFreePool的成对关系。
2.2 使用LIST_ENTRY
Windows的内核开发者们自己开发了部分数据结构,比如说LIST_ENTRY。
LIST_ENTRY是一个双向链表结构。它总是在使用的时候,被插入到已有的数据结构中。下面举一个例子。我构筑一个链表,这个链表的每个节点,是一个
文件名和一个文件大小两个数据成员组成的结构。此外有一个FILE_OBJECT的指针对象。在驱动中,这代表一个文件对象。本书后面的章节会详细解释。
这个链表的作用是:保存了文件的文件名和长度。只要传入FILE_OBJECT的指针,使用者就可以遍历链表找到文件名和文件长度。
typedefstruct {
PFILE_OBJECTfile_object;
UNICODE_STRINGfile_name;
LARGE_INTEGERfile_length;
}MY_FILE_INFOR, *PMY_FILE_INFOR;
一些读者会马上注意到文件的长度用LARGE_INTEGER表示。这是一个代表长长整型的数据结构。这个结构我们在下一小小节“使用长长整型数据”中介绍。
为了让上面的结构成为链表节点,我必须在里面插入一个LIST_ENTRY结构。至于插入的位置并无所谓。可以放在最前,也可以放中间,或者最后面。但是实际上读者很快会发现把LIST_ENTRY放在开头是最简单的做法:
typedefstruct {
LIST_ENTRYlist_entry;
PFILE_OBJECTfile_object;
UNICODE_STRINGfile_name;
LARGE_INTEGERfile_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
list_entry如果是作为链表的头,在使用之前,必须调用InitializeListHead来初始化。下面是示例的代码:
// 我们的链表头
LIST_ENTRY my_list_head;
// 链表头初始化。一般的说在应该在程序入口处调用一下
voidMyFileInforInilt()
{
InitializeListHead(&my_list_head);
}
// 我们的链表节点。里面保存一个文件名和一个文件长度信息。
typedefstruct {
LIST_ENTRYlist_entry;
PFILE_OBJECTfile_object;
PUNICODE_STRINGfile_name;
LARGE_INTEGERfile_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
// 追加一条信息。也就是增加一个链表节点。请注意file_name是外面分配的。
// 内存由使用者管理。本链表并不管理它。
NTSTATUS MyFileInforAppendNode(
PFILE_OBJECTfile_object,
PUNICODE_STRINGfile_name,
PLARGE_INTEGERfile_length)
{
PMY_FILE_INFORmy_file_infor =
(PMY_FILE_INFOR)ExAllocatePoolWithTag(
PagedPool,sizeof(MY_FILE_INFOR),MEM_TAG);
if(my_file_infor== NULL)
returnSTATUS_INSUFFICIENT_RESOURES;
//填写数据成员。
my_file_infor->file_object= file_object;
my_file_infor->file_name= file_name;
my_file_infor->file_length= file_length;
//插入到链表末尾。请注意这里没有使用任何锁。所以,这个函数不是多
//多线程安全的。在下面自旋锁的使用中讲解如何保证多线程安全性。
InsertHeadList(&my_list_head,(PLIST_ENTRY)& my_file_infor);
returnSTATUS_SUCCESS;
}
以上的代码实现了插入。可以看到LIST_ENTRY插入到MY_FILE_INFOR结构的头部的好处。这样一来一个MY_FILE_INFOR看起来
就像一个LIST_ENTRY。不过糟糕的是并非所有的情况都可以这样。比如MS的许多结构喜欢一开头是结构的长度。因此在通过LIST_ENTRY结构
的地址获取所在的节点的地址的时候,有个地址偏移计算的过程。可以通过下面的一个典型的遍历链表的示例中看到:
for(p =my_list_head.Flink; p != &my_list_head.Flink; p = p->Flink)
{
PMY_FILE_INFORelem =
CONTAINING_RECORD(p,MY_FILE_INFOR,list_entry);
// 在这里做需要做的事…
}
}
其中的CONTAINING_RECORD是一个WDK中已经定义的宏,作用是通过一个LIST_ENTRY结构的指针,找到这个结构所在的节点的指针。定义如下:
#defineCONTAINING_RECORD(address, type, field) ((type *)(
(PCHAR)(address) -
(ULONG_PTR)(&((type *)0)->field)))
从上面的代码中可以总结如下的信息:
LIST_ENTRY中的数据成员Flink指向下一个LIST_ENTRY。
整个链表中的最后一个LIST_ENTRY的Flink不是空。而是指向头节点。
得到LIST_ENTRY之后,要用CONTAINING_RECORD来得到链表节点中的数据。
2.3 使用长长整型数据
这里解释前面碰到的LARGE_INTEGER结构。与可能的误解不同,64位数据并非要在64位操作系统下才能使用。在VC中,64位数据的类型为__int64。定义写法如下:
__int64file_offset;
上面之所以定义的变量名为file_offset,是因为文件中的偏移量是一种常见的要使用64位数据的情况。同时,文件的大小也是如此(回忆上一小节中
定义的文件大小)。32位数据无符号整型只能表示到4GB。而众所周知,现在超过4GB的文件绝对不罕见了。但是实际上__int64这个类型在驱动开发
中很少被使用。基本上被使用到的是一个共用体:LARGE_INTEGER。这个共用体定义如下:
typedef__int64 LONGLONG;
typedefunion _LARGE_INTEGER {
struct{
ULONGLowPart;
LONGHighPart;
};
struct{
ULONGLowPart;
LONGHighPart;
} u;
LONGLONG QuadPart;
}LARGE_INTEGER;
这个共用体的方便之处在于,既可以很方便的得到高32位,低32位,也可以方便的得到整个64位。进行运算和比较的时候,使用QuadPart即可。
LARGE_INTEGERa,b;
a.QuadPart= 100;
a.QuadPart*= 100;
b.QuadPart= a.QuadPart;
if(b.QuadPart> 1000)
{
KdPrint(“b.QuadPart< 1000, LowPart = %x HighPart = %x”, b.LowPart,b.HighPart);
}
上面这段代码演示了这种结构的一般用法。在实际编程中,会碰到大量的参数是LARGE_INTEGER类型的。
2.4使用自旋锁
链表之类的结构总是涉及到恼人的多线程同步问题,这时候就必须使用锁。这里只介绍最简单的自选锁。
有些读者可能疑惑锁存在的意义。这和多线程操作有关。在驱动开发的代码中,大多是存在于多线程执行环境的。就是说可能有几个线程在同时调用当前函数。
这样一来,前文1.2.2中提及的追加链表节点函数就根本无法使用了。因为MyFileInforAppendNode这个函数只是简单的操作链表。如果
两个线程同时调用这个函数来操作链表的话:注意这个函数操作的是一个全局变量链表。换句话说,无论有多少个线程同时执行,他们操作的都是同一个链表。这就
可能发生,一个线程插入一个节点的同时,另一个线程也同时插入。他们都插入同一个链表节点的后边。这时链表就会发生问题。到底最后插入的是哪一个呢?要么
一个丢失了。要么链表被损坏了。
如下的代码初始化获取一个自选锁:
KSPIN_LOCKmy_spin_lock;
KeInitializeSpinLock(&my_spin_lock);
KeInitializeSpinLock这个函数没有返回值。下面的代码展示了如何使用这个SpinLock。在KeAcquireSpinLock和
KeReleaseSpinLock之间的代码是只有单线程执行的。其他的线程会停留在KeAcquireSpinLock等候。直到
KeReleaseSpinLock被调用。KIRQL是一个中断级。KeAcquireSpinLock会提高当前的中断级。但是目前忽略这个问题。中
断级在后面讲述。
KIRQLirql;
KeAcquireSpinLock(&my_spin_lock,&irql);
// Todo something …
KeReleaseSpinLock(&my_spin_lock,irql);
初学者要注意的是,像下面写的这样的“加锁”代码是没有意义的,等于没加锁:
voidMySafeFunction()
{
KSPIN_LOCKmy_spin_lock;
KIRQLirql;
KeInitializeSpinLock(&my_spin_lock);
KeAcquireSpinLock(&my_spin_lock,&irql);
// 在这里做要做的事情…
KeReleaseSpinLock(&my_spin_lock,irql);
}
原因是my_spin_lock在堆栈中。每个线程来执行的时候都会重新初始化一个锁。只有所有的线程共用一个锁,锁才有意义。所以,锁一般不会定义成局
部变量。可以使用静态变量、全局变量,或者分配在堆中(见前面的1.2.1内存的分配与释放一节)。请读者自己写出正确的方法。
LIST_ENTRY有一系列的操作。这些操作并不需要使用者自己调用获取与释放锁。只需要为每个链表定义并初始化一个锁即可:
LIST_ENTRY my_list_head; // 链表头
KSPIN_LOCK my_list_lock; //链表的锁
// 链表初始化函数
voidMyFileInforInilt()
{
InitializeListHead(&my_list_head);
KeInitializeSpinLock(&my_list_lock);
}
链表一旦完成了初始化,之后的可以采用一系列加锁的操作来代替普通的操作。比如插入一个节点,普通的操作代码如下:
InsertHeadList(&my_list_head,(PLIST_ENTRY)& my_file_infor);
换成加锁的操作方式如下:
ExInterlockedInsertHeadList(
&my_list_head,
(PLIST_ENTRY)&my_file_infor,
&my_list_lock);
注意不同之处在于,增加了一个KSPIN_LOCK的指针作为参数。在ExInterlockedInsertHeadList中,会自动的使用这个KSPIN_LOCK进行加锁。类似的还有一个加锁的Remove函数,用来移除一个节点,调用如下:
my_file_infor= ExInterlockedRemoveHeadList (
&my_list_head,
&my_list_lock);
这个函数从链表中移除第一个节点。并返回到my_file_infor中。
第三章 文件操作
在内核中不能调用用户层的Win32 API函数来操作文件。在这里必须改用一系列与之对应的内核函数。
3.1 使用OBJECT_ATTRIBUTES
一般的想法是,打开文件应该传入这个文件的路径。但是实际上这个函数并不直接接受一个字符串。使用者必须首先填写一个OBJECT_ATTRIBUTES
结构。在文档中并没有公开这个OBJECT_ATTRIBUTES结构。这个结构总是被InitializeObjectAttributes初始化。
下面专门说明InitializeObjectAttributes。
VOIDInitializeObjectAttributes(
OUTPOBJECT_ATTRIBUTES InitializedAttributes,
INPUNICODE_STRING ObjectName,
INULONG Attributes,
INHANDLE RootDirectory,
INPSECURITY_DESCRIPTOR SecurityDescriptor);
读者需要注意的以下的几点:InitializedAttributes是要初始化的OBJECT_ATTRIBUTES结构的指针。ObjectName则是对象名字字符串。也就是前文所描述的文件的路径(如果要打开的对象是一个文件的话)。
Attributes则只需要填写OBJ_CASE_INSENSITIVE|
OBJ_KERNEL_HANDLE即可(如果读者是想要方便的简洁的打开一个文件的话)。OBJ_CASE_INSENSITIVE意味着名字字符串是
不区分大小写的。由于Windows的文件系统本来就不区分字母大小写,所以笔者并没有尝试过如果不设置这个标记会有什么后果。
OBJ_KERNEL_HANDLE表明打开的文件句柄一个“内核句柄”。内核文件句柄比应用层句柄使用更方便,可以不受线程和进程的限制。在任何线程中
都可以读写。同时打开内核文件句柄不需要顾及当前进程是否有权限访问该文件的问题(如果是有安全权限限制的文件系统)。如果不使用内核句柄,则有时不得不
填写后面的的SecurityDescriptor参数。
RootDirectory用于相对打开的情况。目前省略。请读者传入NULL即可。
SecurityDescriptor用于设置安全描述符。由于笔者总是打开内核句柄,所以很少设置这个参数。
3.2 打开和关闭文件
下面的函数用于打开一个文件:
NTSTATUSZwCreateFile(
OUTPHANDLE FileHandle,
INACCESS_MASK DesiredAccess,
INPOBJECT_ATTRIBUTES ObjectAttribute,
OUTPIO_STATUS_BLOCK IoStatusBlock,
INPLARGE_INTEGER AllocationSize OPTIONAL,
INULONG FileAttributes,
INULONG ShareAccess,
INULONG CreateDisposition,
INULONG createOptions,
INPVOID EaBuffer OPTIONAL,
INULONG EaLength);
这个函数的参数异常复杂。下面逐个的说明如下:
FileHandle:是一个句柄的指针。如果这个函数调用返回成成功(STATUS_SUCCESS),那就么打开的文件句柄就返回在这个地址内。
DesiredAccess:
申请的权限。如果打开写文件内容,请使用FILE_WRITE_DATA。如果需要读文件内容,请使用FILE_READ_DATA。如果需要删除文件或
者把文件改名,请使用DELETE。如果想设置文件属性,请使用FILE_WRITE_ATTRIBUTES。反之,读文件属性则使用
FILE_READ_ATTRIBUTES。这些条件可以用|(位或)来组合。有两个宏分别组合了常用的读权限和常用的写权限。分别为
GENERIC_READ和GENERIC_WRITE。此外还有一个宏代表全部权限,是GENERIC_ALL。此外,如果想同步的打开文件,请加上
SYNCHRONIZE。同步打开文件详见后面对CreateOptions的说明。
ObjectAttribute:对象描述。见前一小节。
IoStatusBlock也是一个结构。这个结构在内核开发中经常使用。它往往用于表示一个操作的结果。这个结构在文档中是公开的,如下:
typedefstruct _IO_STATUS_BLOCK {
union {
NTSTATUS Status;
PVOID Pointer;
};
ULONG_PTR Information;
}IO_STATUS_BLOCK, *PIO_STATUS_BLOCK;
实际编程中很少用到Pointer。一般的说,返回的结果在Status中。成功则为STATUS_SUCCESS。否则则是一个错误码。进一步的信息在
Information中。不同的情况下返回的Information的信息意义不同。针对ZwCreateFile调用的情况,Information
的返回值有以下几种可能:
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_CREATED:文件被成功的新建了。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OPENED: 文件被打开了。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OVERWRITTEN:文件被覆盖了。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_SUPERSEDED: 文件被替代了。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_EXISTS:文件已存在。(因而打开失败了)。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_DOES_NOT_EXIST:文件不存在。(因而打开失败了)。
这些返回值和打开文件的意图有关(有时希望打开已存在的文件,有时则希望建立新的文件等等。这些意图在本小节稍后的内容中详细说明。
ZwCreateFile的下一个参数是AllocationSize。这个参数很少使用,请设置为NULL。
再接下来的一个参数为FileAttributes。这个参数控制新建立的文件的属性。一般的说,设置为FILE_ATTRIBUTE_NORMAL即
可。在实际编程中,笔者没有尝试过其他的值。
ShareAccess是一个非常容易被人误解的参数。实际上,这是在本代码打开这个文件的时候,允许别的代码同时打开这个文件所持有的权限。所以称为共
享访问。一共有三种共享标记可以设置:FILE_SHARE_READ、FILE_SHARE_WRITE、FILE_SHARE_DELETE。这三个
标记可以用|(位或)来组合。举例如下:如果本次打开只使用了FILE_SHARE_READ,那么这个文件在本次打开之后,关闭之前,别次打开试图以读
权限打开,则被允许,可以成功打开。如果别次打开试图以写权限打开,则一定失败。返回共享冲突。
同时,如果本次打开只只用了FILE_SHARE_READ,而之前这个文件已经被另一次打开用写权限打开着。那么本次打开一定失败,返回共享冲突。其中的逻辑关系貌似比较复杂,读者应耐心理解。
CreateDisposition参数说明了这次打开的意图。可能的选择如下(请注意这些选择不能组合):
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_CREATE:新建文件。如果文件已经存在,则这个请求失败。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OPEN:打开文件。如果文件不存在,则请求失败。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OPEN_IF:打开或新建。如果文件存在,则打开。如果不存在,则失败。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OVERWRITE:覆盖。如果文件存在,则打开并覆盖其内容。如果文件不存在,这个请求返回失败。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OVERWRITE_IF:新建或覆盖。如果要打开的文件已存在,则打开它,并覆盖其内存。如果不存在,则简单的新建新文件。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_SUPERSEDE:新建或取代。如果要打开的文件已存在。则生成一个新文件替代之。如果不存在,则简单的生成新文件。
请联系上面的IoStatusBlock参数中的Information的说明。
最后一个重要的参数是CreateOptions。在惯常的编程中,笔者使用
FILE_NON_DIRECTORY_FILE|FILE_SYNCHRONOUS_IO_NONALERT。此时文件被同步的打开。而且打开的是文件
(而不是目录。创建目录请用FILE_
DIRECTORY_FILE)。所谓同步的打开的意义在于,以后每次操作文件的时候,比如写入文件,调用ZwWriteFile,在
ZwWriteFile返回时,文件写操作已经得到了完成。而不会有返回STATUS_PENDING(未决)的情况。在非同步文件的情况下,返回未决是
常见的。此时文件请求没有完成,使用者需要等待事件来等待请求的完成。当然,好处是使用者可以先去做别的事情。
要同步打开,前面的DesiredAccess必须含有SYNCHRONIZE。
此外还有一些其他的情况。比如不通过缓冲操作文件。希望每次读写文件都是直接往磁盘上操作的。此时CreateOptions中应该带标记
FILE_NO_INTERMEDIATE_BUFFERING。带了这个标记后,请注意操作文件每次读写都必须以磁盘扇区大小(最常见的是512字节)
对齐。否则会返回错误。
这个函数是如此的繁琐,以至于再多的文档也不如一个可以利用的例子。早期笔者调用这个函数往往因为参数设置不对而导致打开失败。非常渴望找到一个实际可以使用的参数的范例。下面举例如下:
// 要返回的文件句柄
HANDLEfile_handle = NULL;
// 返回值
NTSTATUSstatus;
// 首先初始化含有文件路径的OBJECT_ATTRIBUTES
OBJECT_ATTRIBUTESobject_attributes;
UNICODE_STRINGufile_name = RTL_CONST_STRING(L”\??\C:\a.dat”);
InitializeObjectAttributes(
&object_attributes,
&ufile_name,
OBJ_CASE_INSENSITIVE|OBJ_KERNEL_HANDLE,
NULL,
NULL);
// 以OPEN_IF方式打开文件。
status= ZwCreateFile(
&file_handle,
GENERIC_READ | GENERIC_WRITE,
&object_attributes,
&io_status,
NULL,
FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_READ,
FILE_OPEN_IF,
FILE_NON_DIRECTORY_FILE |
FILE_RANDOM_ACCESS |
FILE_SYNCHRONOUS_IO_NONALERT,
NULL,
0);
值得注意的是路径的写法。并不是像应用层一样直接写“C:\a.dat”。而是写成了“\??\C:\a.dat”。这是因为ZwCreateFile使用的是对象路径。“C:”是一个符号链接对象。符号链接对象一般都在“\??\”路径下。
这种文件句柄的关闭非常简单。调用ZwClose即可。内核句柄的关闭不需要和打开在同一进程中。示例如下:
ZwClose(file_handle);
3.3 文件的读写操作
打开文件之后,最重要的操作是对文件的读写。读与写的方法是对称的。只是参数输入与输出的方向不同。读取文件内容一般用ZwReadFile,写文件一般使用ZwWriteFile。
NTSTATUS
ZwReadFile(
IN HANDLE FileHandle,
IN HANDLE Event OPTIONAL,
IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
IN PVOID ApcContext OPTIONAL,
OUT PIO_STATUS_BLOCK IoStatusBlock,
OUT PVOID Buffer,
IN ULONG Length,
IN PLARGE_INTEGER ByteOffset OPTIONAL,
INPULONG Key OPTIONAL);
FileHandle:是前面ZwCreateFile成功后所得到的FileHandle。如果是内核句柄,ZwReadFile和ZwCreateFile并不需要在同一个进程中。句柄是各进程通用的。
Event :一个事件。用于异步完成读时。下面的举例始终用同步读,所以忽略这个参数。请始终填写NULL。
ApcRoutine Apc:回调例程。用于异步完成读时。下面的举例始终用同步读,所以忽略这个参数。请始终填写NULL。
IoStatusBlock:返回结果状态。同ZwCreateFile中的同名参数。
Buffer:缓冲区。如果读文件的内容成功,则内容被被读到这个缓冲里。
Length:描述缓冲区的长度。这个长度也就是试图读取文件的长度。
ByteOffset:要读取的文件的偏移量。也就是要读取的内容在文件中的位置。一般的说,不要设置为NULL。文件句柄不一定支持直接读取当前偏移。
Key:读取文件时用的一种附加信息,一般不使用。设置NULL。
返
回值:成功的返回值是STATUS_SUCCESS。只要读取到任意多个字节(不管是否符合输入的Length的要求),返回值都是
STATUS_SUCCESS。即使试图读取的长度范围超出了文件本来的大小。但是,如果仅读取文件长度之外的部分,则返回
STATUS_END_OF_FILE。
ZwWriteFile
的参数与ZwReadFile完全相同。当然,除了读写文件外,有的读者可能会问是否提供一个ZwCopyFile用来拷贝一个文件。这个要求未能被满
足。如果有这个需求,这个函数必须自己来编写。下面是一个例子,用来拷贝一个文件。利用到了ZwCreateFile,ZwReadFile和
ZwWrite这三个函数。不过作为本节的例子,只举出ZwReadFile和ZwWriteFile的部分:
NTSTATUS MyCopyFile(
PUNICODE_STRING target_path,
PUNICODE_STRING source_path)
{
// 源和目标的文件句柄
HANDLE target = NULL,source = NULL;
// 用来拷贝的缓冲区
PVOID buffer = NULL;
LARGE_INTEGER offset = { 0 };
IO_STATUS_BLOCK io_status = { 0 };
do {
// 这里请用前一小节说到的例子打开target_path和source_path所对应的
// 句柄target和source,并为buffer分配一个页面也就是4k的内存。
… …
// 然后用一个循环来读取文件。每次从源文件中读取4k内容,然后往
// 目标文件中写入4k,直到拷贝结束为止。
while(1) {
length = 4*1024; //每次读取4k。
// 读取旧文件。注意status。
status = ZwReadFile (
source,NULL,NULL,NULL,
&my_io_status,buffer, length,&offset,
NULL);
if(!NT_SUCCESS(status))
{
// 如果状态为STATUS_END_OF_FILE,则说明文件
// 的拷贝已经成功的结束了。
if(status == STATUS_END_OF_FILE)
status = STATUS_SUCCESS;
break;
}
// 获得实际读取到的长度。
length = IoStatus.Information;
// 现在读取了内容。读出的长度为length.那么我写入
// 的长度也应该是length。写入必须成功。如果失败,
// 则返回错误。
status = ZwWriteFile(
target,NULL,NULL,NULL,
&my_io_status,
buffer,length,&offset,
NULL);
if(!NT_SUCCESS(status))
break;
// offset移动,然后继续。直到出现STATUS_END_OF_FILE
// 的时候才结束。
offset.QuadPart += length;
}
} while(0);
// 在退出之前,释放资源,关闭所有的句柄。
if(target != NULL)
ZwClose(target);
if(source != NULL)
ZwClose(source);
if(buffer != NULL)
ExFreePool(buffer);
return STATUS_SUCCESS;
}
除了读写之外,文件还有很多的操作。比如删除、重新命名、枚举。这些操作将在后面实例中用到时,再详细讲解。
第四章 操作注册表
4.1 注册键的打开操作
和在应用程序中编程的方式类似,注册表是一个巨大的树形结构。操作一般都是打开某个子键。子键下有若干个值可以获得。每一个值有一个名字。值有不同的类型。一般需要查询才能获得其类型。
子键一般用一个路径来表示。和应用程序编程的一点重大不同是这个路径的写法不一样。一般应用编程中需要提供一个根子键的句柄。而驱动中则全部用路径表示。相应的有一张表表示如下:
应用编程中对应的子键
HKEY_LOCAL_MACHINE
HKEY_USERS
HKEY_CLASSES_ROOT
HKEY_CURRENT_USER
实际上应用程序和驱动程序很大的一个不同在于应用程序总是由某个“当前用户”启动的。因此可以直接读取HKEY_CLASSES_ROOT和
HKEY_CURRENT_USER。而驱动程序和用户无关,所以直接去打开HKEY_CURRENT_USER也就不符合逻辑了。
打开注册表键使用函数ZwOpenKey。新建或者打开则使用ZwCreateKey。一般在驱动编程中,使用ZwOpenKey的情况比较多见。下面以此为例讲解。ZwOpenKey的原型如下:
NTSTATUS
ZwOpenKey(
OUT PHANDLE KeyHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes
);
这个函数和ZwCreateFile是类似的。它并不接受直接传入一个字符串来表示一个子键。而是要求输入一个OBJECT_ATTRIBUTES的指针。如何初始化一个OBJECT_ATTRIBUTES请参考前面的讲解ZwCreateFile的章节。
DesiredAccess支持一系列的组合权限。可以是下表中所有权限的任何组合:
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KEY_QUERY_VALUE:读取键下的值。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KEY_SET_VALUE:设置键下的值。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KEY_CREATE_SUB_KEY:生成子键。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KEY_ENUMERATE_SUB_KEYS:枚举子键。
不过实际上可以用KEY_READ来做为通用的读权限组合。这是一个组合宏。此外对应的有KEY_WRITE。如果需要获得全部的权限,可以使用KEY_ALL_ACCESS。
下面是一个例子,这个例子非常的有实用价值。它读取注册表中保存的Windows系统目录(指Windows目录)的位置。不过这里只涉及打开子键。并不读取值。读取具体的值在后面的小节中再完成。
Windows目录的位置被称为SystemRoot,这一值保存在注册表中,路径是“HKEY_LOCAL_MACHINESOFTWARE
MicrosoftWindowsNTCurrentVersion”。当然,请注意注意在驱动编程中的写法有所不同。下面的代码初始化一个
OBJECT_ATTRIBUTES。
HANDLEmy_key = NULL;
NTSTATUSstatus;
// 定义要获取的路径
UNICODE_STRINGmy_key_path =
RTL_CONSTANT_STRING(
L”\ Registry\Machine\SOFTWARE\Microsoft\Windows NT\CurrentVersion”);
OBJECT_ATTRIBUTEmy_obj_attr = { 0 };
// 初始化OBJECT_ATTRIBUTE
InitializeObjectAttributes(
&my_obj_attr,
&my_key_path,
OBJ_CASE_INSENSITIVE,
NULL,
NULL);
// 接下来是打开Key
status= ZwOpenKey(&my_key,KEY_READ,&my_obj_attr);
if(!NT_SUCCESS(status))
{
// 失败处理
……
}
上面的代码得到了my_key。子键已经打开。然后的步骤是读取下面的SystemRoot值。这在后面一个小节中讲述。
4.2 注册值的读
一般使用ZwQueryValueKey来读取注册表中键的值。要注意的是注册表中的值可能有多种数据类型。而且长度也是没有定数的。为此,在读取过程中,就可能要面对很多种可能的情况。ZwQueryValueKey这个函数的原型如下:
NTSTATUSZwQueryValueKey(
INHANDLE KeyHandle,
INPUNICODE_STRING ValueName,
INKEY_VALUE_INFORMATION_CLASS KeyValueInformationClass,
OUTPVOID KeyValueInformation,
INULONG Length,
OUTPULONG ResultLength
);
KeyHandle:这是用ZwCreateKey或者ZwOpenKey所打开的一个注册表键句柄。
ValueName:要读取的值的名字。
KeyValueInformationClass:本次查询所需要查询的信息类型。这有如下的三种可能。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KeyValueBasicInformation:获得基础信息,包含值名和类型。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KeyValueFullInformation:获得完整信息。包含值名、类型和值的数据。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KeyValuePartialInformation:获得局部信息。包含类型和值数据。
很容易看出实际上名字是已知的,获得基础信息是多此一举。同样获得完整信息也是浪费内存空间。因为调用ZwQueryValueKey的目的是为了得到类
型和值数据。因此使用KeyValuePartialInformation最常见。当采用KeyValuePartialInformation的时
候,一个类型为KEY_VALUE_PARTIAL_INFORMATION的结构将被返回到参数KeyValueInformation所指向的内存
中。
KeyValueInformation:当KeyValueInformationClass被设置为
KeyValuePartialInformation时,KEY_VALUE_PARTIAL_INFORMATION结构将被返回到这个指针所指内存
中。下面是结构KEY_VALUE_PARTIAL_INFORMATION的原型。
typedefstruct _KEY_VALUE_PARTIAL_INFORMATION {
ULONG TitleIndex; //请忽略这个成员
ULONG Type; //数据类型
ULONG DataLength; //数据长度
UCHAR Data[1]; // 可变长度的数据
}KEY_VALUE_PARTIAL_INFORMATION,*PKEY_VALUE_PARTIAL_INFORMATIO;
上面的数据类型Type有很多种可能,但是最常见的几种如下:
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif REG_BINARY:十六进制数据。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif REG_DWORD:四字节整数。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif REG_SZ:以空结束的Unicode字符串。
Length:用户传入的输出空间KeyValueInformation的长度。
ResultLength:返回实际需要的长度。
返回值:如果说实际需要的长度比Length要大,那么返回STATUS_BUFFER_OVERFLOW或者是STATUS_BUFFER_TOO_SMALL。如果成功读出了全部数据,那么返回STATUS_SUCCESS。其他的情况,返回一个错误码。
下面请读者考虑如何把上一小节的函数写完整。这其中比较常见的一个问题是在读取注册表键下的值之前,往往不知道这个值有多长。所以有些比较偷懒的程序员总
是定义一个足够的大小的空间(比如512字节)。这样的坏处是浪费内存(一般都是在堆栈中定义,而内核编程中堆栈空间被耗尽又是另一个常见的蓝屏问题)。
此外也无法避免值实际上大于该长度的情况。为此应该耐心的首先获取长度,然后不足时再动态分配内存进行读取。下面是示例代码:
// 要读取的值的名字
UNICODE_STRINGmy_key_name =
RTL_CONSTANT_STRING(L”SystemRoot”);
// 用来试探大小的key_infor
KEY_VALUE_PARTIAL_INFORMATIONkey_infor;
// 最后实际用到的key_infor指针。内存分配在堆中
PKEY_VALUE_PARTIAL_INFORMATIONac_key_infor;
ULONGac_length;
……
// 前面已经打开了句柄my_key,下面如此来读取值:
status= ZwQueryValueKey(
my_key,
&my_key_name,
KeyValuePartialInformation,
&key_infor,
sizeof(KEY_VALUE_PARTIAL_INFORMATION),
&ac_length);
if(!NT_SUCCESS(status)&&
status!= STATUS_BUFFER_OVERFLOW &&
status!= STATUS_BUFFER_TOO_SMALL)
{
// 错误处理
…
}
// 如果没失败,那么分配足够的空间,再次读取
ac_key_infor= (PKEY_VALUE_PARTIAL_INFORMATION)
ExAllocatePoolWithTag(NonpagedPool,ac_length,MEM_TAG);
if(ac_key_infor== NULL)
{
stauts= STATUS_INSUFFICIENT_RESOURCES;
// 错误处理
…
}
status= ZwQueryValueKey(
my_key,
&my_key_name,
KeyValuePartialInformation,
ac_key_infor,
ac_length,
&ac_length);
// 到此为止,如果status为STATUS_SUCCESS,则要读取的数据已经
// 在ac_key_infor->Data中。请利用前面学到的知识,转换为
//UNICODE_STRING
……
4.3 注册值的写
实际上注册表的写入比读取要简单。因为这省略了一个尝试数据的大小的过程。直接将数据写入即可。写入值一般使用函数ZwSetValueKey 。这个函数的原型如下:
NTSTATUSZwSetValueKey(
IN HANDLE KeyHandle,
IN PUNICODE_STRING ValueName,
IN ULONG TitleIndex OPTIONAL,
IN ULONG Type,
IN PVOID Data,
IN ULONG DataSize
);
其中的TileIndex参数请始终填入0。
KeyHandle、ValueName、Type这三个参数和ZwQueryValueKey中对应的参数相同。不同的是Data和DataSize。
Data是要写入的数据的开始地址,而DataSize是要写入的数据的长度。由于Data是一个空指针,因此,Data可以指向任何数据。也就是说,不
管Type是什么,都可以在Data中填写相应的数据写入。
ZwSetValueKey的时候,并不需要该Value已经存在。如果该Value已经存在,那么其值会被这次写入覆盖。如果不存在,则会新建一个。下
面的例子写入一个名字为“Test”,而且值为“My Test Value”的字符串值。假设my_key是一个已经打开的子键的句柄。
UNICODE_STRINGname = RTL_CONSTANT_STRING(L”Test”);
PWCHARvalue = { L”My Test Value” };
…
// 写入数据。数据长度之所以要将字符串长度加上1,是为了把最后一个空结束符
// 写入。我不确定如果不写入空结束符会不会有错,有兴趣的读者请自己测试一下。
status= ZwSetValueKey(my_key,
&name,0,REG_SZ,value,(wcslen(value)+1)*sizeof(WCHAR));
if(!NT_SUCCESS(status))
{
// 错误处理
……
}
关于注册表的操作就介绍到这里了。如果有进一步的需求,建议读者阅读WDK相关的文档。
第五章 时间与定时器
5.1 获得当前滴答数
在编程中,获得当前的系统日期和时间,或者是获得一个从启动开始的毫秒数,是很常见的需求。获得系统日期和时间往往是为了写日志。获得启动毫秒数很适合用来做一个随机数的种子。有时也使用时间相关的函数来寻找程序的性能瓶颈。
熟悉Win32应用程序开发的读者会知道有一个函数GetTickCount(),这个函数返回系统自启动之后经历的毫秒数。在驱动开发中有一个对应的函数KeQueryTickCount(),这个函数的原型如下:
VOID
KeQueryTickCount(
OUTPLARGE_INTEGER TickCount
);
遗憾的是,被返回到TickCount中的并不是一个简单的毫秒数。这是一个“滴答”数。但是一个“滴答”到底为多长的时间,在不同的硬件环境下可能有所不同。为此,必须结合另一个函数使用。下面这个函数获得一个“滴答”的具体的100纳秒数。
ULONG
KeQueryTimeIncrement(
);
得知以上的关系之后,下面的代码可以求得实际的毫秒数:
void MyGetTickCount(PULONG msec)
{
LARGE_INTEGER tick_count;
ULONG myinc = KeQueryTimeIncrement();
KeQueryTickCount(&tick_count);
tick_count.QuadPart *= myinc;
tick_count.QuadPart /= 10000;
*msec = tick_count.LowPart;
}
这不是一个简单的过程。不过所幸的是,现在有代码可以拷贝了。
5.2 获得当前系统时间
接下来的一个需求是得到当前的可以供人类理解的时间。包括年、月、日、时、分、秒这些要素。在驱动中不能使用诸如CTime之类的MFC类。不过与之对应的有TIME_FIELDS,这个结构中含有对应的时间要素。
KeQuerySystemTime()得到当前时间。但是得到的并不是当地时间,而是一个格林威治时间。之后请使用ExSystemTimeToLocalTime()转换可以当地时间。这两个函数的原型如下:
VOID
KeQuerySystemTime(
OUT PLARGE_INTEGER CurrentTime
);
VOID
ExSystemTimeToLocalTime(
IN PLARGE_INTEGER SystemTime,
OUT PLARGE_INTEGER LocalTime
);
这两个函数使用的“时间”都是长长整型数据结构。这不是人类可以阅读的。必须通过函数RtlTimeToTimeFields转换为TIME_FIELDS。这个函数原型如下:
VOID
RtlTimeToTimeFields(
IN PLARGE_INTEGER Time,
IN PTIME_FIELDS TimeFields
);
读者需要实际应用一下来加深印象。下面写出一个函数:这个函数返回一个字符串。这个字符串写出当前的年、月、日、时、分、秒,这些数字之间用“-”号隔开。这是一个很有用的函数。而且同时用到上面三个函数,此外,请读者回忆前面关于字符串的打印的相关章节。
{
LARGE_INTEGER snow,now;
TIME_FIELDS now_fields;
static WCHAR time_str[32] = { 0 };
// 获得标准时间
KeQuerySystemTime(&snow);
// 转换为当地时间
ExSystemTimeToLocalTime(&snow,&now);
// 转换为人类可以理解的时间要素
RtlTimeToTimeFields(&now,&now_fields);
// 打印到字符串中
RtlStringCchPrintfW(
time_str,
32*2,
L"%4d-%2d-%2d %2d-%2d-%2d",
now_fields.Year,now_fields.Month,now_fields.Day,
now_fields.Hour,now_fields.Minute,now_fields.Second);
return time_str;
}
请注意time_str是静态变量。这使得这个函数不具备多线程安全性。请读者考虑一下,如何保证多个线程同时调用这个函数的时候,不出现冲突?
5.3 使用定时器
使用过Windows应用程序编程的读者的读者一定对SetTimer()映像尤深。当需要定时执行任务的时候,SetTimer()变得非常重要。这个
功能在驱动开发中可以通过一些不同的替代方法来实现。比较经典的对应是KeSetTimer(),这个函数的原型如下:
BOOLEAN
KeSetTimer(
IN PKTIMER Timer, // 定时器
IN LARGE_INTEGER DueTime, // 延后执行的时间
IN PKDPC Dpc OPTIONAL // 要执行的回调函数结构
);
其中的定时器Timer和要执行的回调函数结构Dpc都必须先初始化。其中Timer的初始化比较简单。下面的代码可以初始化一个Timer:
KTIMERmy_timer;
KeInitializeTimer(&my_timer);
Dpc的初始化比较麻烦。这是因为需要提供一个回调函数。初始化Dpc的函数原型如下:
VOID
KeInitializeDpc(
IN PRKDPC Dpc,
IN PKDEFERRED_ROUTINE DeferredRoutine,
IN PVOID DeferredContext
);
PKDEFERRED_ROUTINE这个函数指针类型所对应的函数的类型实际上是这样的:
VOID
CustomDpc(
IN struct _KDPC *Dpc,
IN PVOID DeferredContext,
IN PVOID SystemArgument1,
IN PVOID SystemArgument2
);
读者需要关心的只是DeferredContext。这个参数是KeInitializeDpc调用时传入的参数。用来提供给CustomDpc被调用的时候,让用户传入一些参数。
至于后面的SystemArgument1和SystemArgument2则请不要理会。Dpc是回调这个函数的KDPC结构。
请注意这是一个“延时执行”的过程。而不是一个定时执行的过程。因此每次执行了之后,下次就不会再被调用了。如果想要定时反复执行,就必须在每次CustomDpc函数被调用的时候,再次调用KeSetTimer,来保证下次还可以执行。
值得注意的是,CustomDpc将运行在APC中断级。因此并不是所有的事情都可以做(在调用任何内核系统函数的时候,请注意WDK说明文档中标明的中断级要求。)
这些事情非常的烦恼,因此要完全实现定时器的功能,需要自己封装一些东西。下面的结构封装了全部需要的信息:
// 内部时钟结构
typedef struct MY_TIMER_
{
KDPC dpc;
KTIMER timer;
PKDEFERRED_ROUTINE func;
PVOID private_context;
}MY_TIMER,*PMY_TIMER;
// 初始化这个结构:
voidMyTimerInit(PMY_TIMER timer, PKDEFERRED_ROUTINE func)
{
//请注意,我把回调函数的上下文参数设置为timer,为什么要
//这样做呢?
KeInitializeDpc(&timer->dpc,sf_my_dpc_routine,timer);
timer->func = func;
KeInitializeTimer(&timer->timer);
return (wd_timer_h)timer;
}
// 让这个结构中的回调函数在n毫秒之后开始运行:
BOOLEAN MyTimerSet(PMY_TIMER timer,ULONG msec,PVOID context)
{
LARGE_INTEGER due;
//注意时间单位的转换。这里msec是毫秒。
due.QuadPart = -10000*msec;
//用户私有上下文。
timer->private_context = context;
return KeSetTimer(&timer->timer,due,&mytimer->dpc);
};
// 停止执行
VOIDMyTimerDestroy(PMY_TIMER timer)
{
KeCancelTimer(&mytimer->timer);
};
使用结构PMY_TIMER已经比结合使用KDPC和KTIMER简便许多。但是还是有一些要注意的地方。真正的OnTimer回调函数中,要获得上下
文,必须要从timer->private_context中获得。此外,OnTimer中还有必要再次调用MyTimerSet(),来保证下次
依然得到执行。
VOID
MyOnTimer (
IN struct _KDPC *Dpc,
IN PVOID DeferredContext,
IN PVOID SystemArgument1,
IN PVOID SystemArgument2
)
{
//这里传入的上下文是timer结构,用来下次再启动延时调用
PMY_TIMER timer = (PMY_TIMER)DeferredContext;
//获得用户上下文
PVOID my_context = timer->private_context;
//在这里做OnTimer中要做的事情
……
//再次调用。这里假设每1秒执行一次
MyTimerSet(timer,1000,my_context);
};
关于定时器就介绍到这里了。
第六章 内核线程
6.1 使用线程
有时候需要使用线程来完成一个或者一组任务。这些任务可能耗时过长,而开发者又不想让当前系统停止下来等待。在驱动中停止等待很容易使整个系统陷入“停
顿”,最后可能只能重启电脑。但一个单独的线程长期等待,还不至于对系统造成致命的影响。另一些任务是希望长期、不断的执行,比如不断写入日志。为此启动
一个特殊的线程来执行它们是最好的方法。
在驱动中生成的线程一般是系统线程。系统线程所在的进程名为“System”。用到的内核API函数原型如下:
NTSTATUS
PsCreateSystemThread(
OUT PHANDLE ThreadHandle,
IN ULONG DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
IN HANDLE ProcessHandle OPTIONAL,
OUT PCLIENT_ID ClientId OPTIONAL,
IN PKSTART_ROUTINE StartRoutine,
IN PVOID StartContext);
这个函数的参数也很多。不过作者本人的使用经验如下:ThreadHandle用来返回句柄。放入一个句柄指针即可。DesiredAccess总是填写
0。后面三个参数都填写NULL。最后的两个参数一个用于改线程启动的时候执行的函数。一个用于传入该函数的参数。
下面要关心的就是那个启动函数的原型。这个原型比起定时器回调函数倒是异常的简单,没有任何多余的东西:
VOID CustomThreadProc(INPVOID context)
可以传入一个参数,就是那个context。context就是PsCreateSystemThread中的StartContext。值得注意的是,
线程的结束应该在线程中自己调用PsTerminateSystemThread来完成。此外得到的句柄也必须要用ZwClose来关闭。但是请注意:关
闭句柄并不结束线程。
下面举一个例子。这个例子传递一个字符串指针到一个线程中打印一下。然后结束该线程。当然打印字符串这种事情没有必要单独开一个线程来做。这里只是一个简单的示例。请注意,这个代码中有一个隐藏的错误,请读者指出这个错误是什么:
// 我的线程函数。传入一个参数,这个参数是一个字符串。
VOIDMyThreadProc(PVOID context)
{
PUNICODE_STRINGstr = (PUNICODE_STRING)context;
// 打印字符串
KdPrint((“PrintInMyThread:%wZ
”,str));
// 结束自己。
PsTerminateSystemThread(STATUS_SUCCESS);
}
VOIDMyFunction()
{
UNICODE_STRINGstr = RTL_CONSTANT_STRING(L“Hello!”);
HANDLEthread = NULL;
NTSTATUSstatus;
status= PsCreateSystemThread(
&thread,0L,NULL,NULL,NULL,MyThreadProc,(PVOID)&str);
if(!NT_SUCCESS(status))
{
//错误处理。
…
}
// 如果成功了,可以继续做自己的事。之后得到的句柄要关闭
ZwClose(thread);
}
以上错误之处在于:MyThreadProc执行的时候,MyFunction可能已经执行完毕了。执行完毕之后,堆栈中的str已经无效。此时再执行KdPrint去打印str一定会蓝屏。这也是一个非常隐蔽,但是非常容易犯下的错误。
合理的方法是是在堆中分配str的空间。或者str必须在全局空间中。请读者自己写出正确的方法。
但是读者会发现,以上的写法在正确的代码中也是常见的。原因是这样做的时候,在PsCreateSystemThread结束之后,开发者会在后面加上一个等待线程结束的语句。
这样就没有任何问题了,因为在这个线程结束之前,这个函数都不会执行完毕,所以栈内存空间不会失效。
这样做的目的一般不是为了让任务并发。而是为了利用线程上下文环境而做的特殊处理。比如防止重入等等。在后面的章节读者会学到这方面的技巧。
如何等待线程结束在后面1.6.3“使用事件通知”中进一步的讲述。
6.2 在线程中睡眠
许多读者一定使用过Sleep函数。这能使程序停下一段时间。许多需要连续、长期执行,但是又不希望占太多CPU使用率的任务,可以在中间加入睡眠。这样能使CPU使用率大大降低。即使睡眠的时间非常短(几十个毫秒)。
在驱动中也可以睡眠。使用到的内核函数的原型如下:
NTSTATUS
KeDelayExecutionThread(
IN KPROCESSOR_MODE WaitMode,
IN BOOLEAN Alertable,
IN PLARGE_INTEGER Interval);
这个函数的参数简单明了。WaitMode请总是填写KernelMode,因为现在是在内核编程中使用。Alertable表示是否允许线程报警(用于
重新唤醒)。但是目前没有必要用到这么高级的功能,请总是填写FALSE。剩下的就是Interval了,表明要睡眠多久。
但是这个看似简单的参数说明起来却异常的复杂。为此作者建议读者使用下面简单的睡眠函数,这个函数可以指定睡眠多少毫秒,而没有必要自己去换算时间(这个函数中有睡眠时间的转换):
#defineDELAY_ONE_MICROSECOND (-10)
#defineDELAY_ONE_MILLISECOND (DELAY_ONE_MICROSECOND*1000)
VOIDMySleep(LONG msec)
{
LARGE_INTEGERmy_interval;
my_interval.QuadPart= DELAY_ONE_MILLISECOND;
my_interval.QuadPart*= msec;
KeDelayExecutionThread(KernelMode,0,&my_interval);
}
当然要睡眠几秒也是可以的,1毫秒为千分之一秒。所以乘以1000就可以表示秒数。
在一个线程中用循环进行睡眠,也可以实现一个自己的定时器。考虑前面说的定时器的缺点:中断级较高,有一些事情不能做。在线程中用循环睡眠,每次睡眠结束
之后调用自己的回调函数,也可以起到类似的效果。而且系统线程执行中是Passive中断级。睡眠之后依然是这个中断级,所以不像前面提到的定时器那样有
限制。
请读者自己写出用线程+睡眠来实现定时器的例子。
6.3 使用事件通知
一些读者可能熟悉“事件驱动”编程技术。但是这里的“事件”与之不同。内核中的事件是一个数据结构。这个结构的指针可以当作一个参数传入一个等待函数中。
如果这个事件不被“设置”,则这个等待函数不会返回,这个线程被阻塞。如果这个事件被“设置”,则等待结束,可以继续下去。
这常常用于多个线程之间的同步。如果一个线程需要等待另一个线程完成某事后才能做某事,则可以使用事件等待。另一个线程完成后设置事件即可。
这个数据结构是KEVENT。读者没有必要去了解其内部结构。这个结构总是用KeInitlizeEvent初始化。这个函数原型如下:
VOID
KeInitializeEvent(
IN PRKEVENT Event,
IN EVENT_TYPE Type,
IN BOOLEAN State
);
第一个参数是要初始化的事件。第二个参数是事件类型,这个详见于后面的解释。第三个参数是初始化状态。一般的说设置为FALSE。也就是未设状态。这样等待者需要等待设置之后才能通过。
事件不需要销毁。
设置事件使用函数KeSetEvent。这个函数原型如下:
LONG
KeSetEvent(
IN PRKEVENT Event,
IN KPRIORITY Increment,
IN BOOLEAN Wait
);
Event是要设置的事件。Increment用于提升优先权。目前设置为0即可。Wait表示是否后面马上紧接着一个KeWaitSingleObject来等待这个事件。一般设置为TRUE。(事件初始化之后,一般就要开始等待了。)
使用事件的简单代码如下:
// 定义一个事件
KEVENTevent;
// 事件初始化
KeInitializeEvent(&event,SynchronizationEvent,TRUE);
……
// 事件初始化之后就可以使用了。在一个函数中,你可以等待某
// 个事件。如果这个事件没有被人设置,那就会阻塞在这里继续
// 等待。
KeWaitForSingleObject(&event,Executive,KernelMode,0,0);
……
// 这是另一个地方,有人设置这个事件。只要一设置这个事件,
// 前面等待的地方,将继续执行。
KeSetEvent(&event);
由于在KeInitializeEvent中使用了SynchronizationEvent,导致这个事件成为所谓的“自动重设”事件。一个事件如果被
设置,那么所有KeWaitForSingleObject等待这个事件的地方都会通过。如果要能继续重复使用这个时间,必须重设(Reset)这个事
件。当KeInitializeEvent中第二个参数被设置为NotificationEvent的时候,这个事件必须要手动重设才能使用。手动重设使
用函数KeResetEvent。
LONG
KeResetEvent(
IN PRKEVENT Event
);
如果这个事件初始化的时候是SynchronizationEvent事件,那么只有一个线程的KeWaitForSingleObject可以通过。通
过之后被自动重设。那么其他的线程就只能继续等待了。这可以起到一个同步作用。所以叫做同步事件。不能起到同步作用的是通知事件
(NotificationEvent)。请注意不能用手工设置通知事件的方法来取代同步事件。请读者思考一下这是为什么。
回忆前面的1.6.1
“使用线程”的最后的例子。在那里曾经有一个需求:就是等待线程中的函数KdPrint结束之后,外面生成线程的函数再返回。 这可以通过一个事件来实
现:线程中打印结束之后,设置事件。外面的函数再返回。为了编码简单我使用了一个静态变量做事件。这种方法在线程同步中用得极多,请务必熟练掌握:
staticKEVENT s_event;
// 我的线程函数。传入一个参数,这个参数是一个字符串。
VOIDMyThreadProc(PVOID context)
{
PUNICODE_STRINGstr = (PUNICODE_STRING)context;
KdPrint((“PrintInMyThread:%wZ
”,str));
KeSetEvent(&s_event); // 在这里设置事件。
PsTerminateSystemThread(STATUS_SUCCESS);
}
// 生成线程的函数:
VOIDMyFunction()
{
UNICODE_STRINGstr = RTL_CONSTANT_STRING(L“Hello!”);
HANDLEthread = NULL;
NTSTATUSstatus;
KeInitializeEvent(&event,SynchronizationEvent,TRUE); // 初始化事件
status= PsCreateSystemThread(
&thread,0L,NULL,NULL,NULL,MyThreadProc,(PVOID)&str);
if(!NT_SUCCESS(status))
{
//错误处理。
…
}
ZwClose(thread);
// 等待事件结束再返回:
KeWaitForSingleObject(&s_event,Executive,KernelMode,0,0);
}
实际上等待线程结束并不一定要用事件。线程本身也可以当作一个事件来等待。但是这里为了演示事件的用法而使用了事件。以上的方法调用线程则不必担心str
的内存空间会无效了。因为这个函数在线程执行完KdPrint之后才返回。缺点是这个函数不能起到并发执行的作用。
第七章 驱动与设备
7.1 驱动入口与驱动对象
驱动开发程序员所编写的驱动程序对应有一个结构。这个结构名为DRIVER_OBJECT。对应一个“驱动程序”。下面的代码展示的是一个最简单的驱动程序。
#include<ntddk.h>
NTSTATUS
DriverEntry(
IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath
)
{
NTSTATUSstatus = STATUS_UNSUCCESSFUL;
return status;
}
函数DriverEntry是每个驱动程序中必须的。如同Win32应用程序里的WinMain。DriverEntry的第一个参数就是一个
DRIVER_OBJECT的指针。这个DRIVER_OBJECT结构就对应当前编写的驱动程序。其内存是Windows系统已经分配的。
第二个参数RegistryPath是一个字符串。代表一个注册表子键。这个子键是专门分配给这个驱动程序使用的。用于保存驱动配置信息到注册表中。至于读写注册表的方法,请参照前面章节中的内容。
DriverEntry的返回值决定这个驱动的加载是否成功。如果返回为STATUS_SUCCESS,则驱动将成功加载。否则,驱动加载失败。
7.2 分发函数与卸载函数
DRIVER_OBJECT中含有分发函数指针。这些函数用来处理发到这个驱动的各种请求。Windows总是自己调用DRIVER_OBJECT下的分发函数来处理这些请求。所以编写一个驱动程序,本质就是自己编写这些处理请求的分发函数。
DRIVER_OBJECT下的分发函数指针的个数为IRP_MJ_MAXIMUM_FUNCTION。保存在一个数组中。下面的代码设置所有分发函数的地址为同一个函数:
NTSTATUS
DriverEntry(
IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath
)
{
ULONGi;
for(i=0;i<IRP_MJ_MAXIMUM_FUNCTION;++i)
{
DriverObject->MajorFunctions= MyDispatchFunction;
}
…
}
这个设置固然不难。难的工作都在编写MyDispatchFunction这个函数上。因为所有的分发函数指针都指向这一个函数,那么这个函数当然要完成本驱动所有的功能。下面是这个函数的原型。这个原型是Windows驱动编程的规范,不能更改:
NTSTATUSMyDispatchFunction(PDEVICE_OBJECT device,PIRP irp)
{
……
}
这里出现了DEVICE_OBJECT和IRP这两大结构。前一个表示一个由本驱动生成的设备对象。后一个表示一个系统请求。也就是说,现在要处理的是:发给设备device的请求irp。请完成这个处理吧。这两个结构在后面再进一步描述。
还有一个不放在分发函数数组中的函数,称为卸载函数也非常重要。如果存在这个函数,则该驱动程序可以动态卸载。在卸载时,该函数会被执行。该函数原型如下:
VOIDMyDriverUnload(PDRIVER_OBJECT driver)
{
……
}
这个函数的地址设置到DriverObject->DriverUnload即可。
由于没有返回值,所以实际上在DriverUnload中,已经无法决定这个驱动能否卸载。只能做善后处理。
7.3 设备与符号链接
驱动程序和系统其他组件之间的交互是通过给设备发送或者接受发给设备的请求来交互的。换句话说,一个没有任何设备的驱动是不能按规范方式和系统交互的。当然也不会收到任何IRP,分发函数也失去了意义。
但并不意味着这样的驱动程序不存在。如果一个驱动程序只是想写写日志文件、Hook某些内核函数或者是做一些其他的小动作,也可以不生成任何设备,也不需要关心分发函数的设置。
如果驱动程序要和应用程序之间通信,则应该生成设备。此外还必须为设备生成应用程序可以访问的符号链接。下面的驱动程序生成了一个设备,并设置了分发函数:
#include<ntifs.h> // 之所以用ntifs.h而不是ntddk.h是因为我习惯开发文件
//系统驱动,实际上目前对读者来说这两个头文件没区别。
NTSTATUSDriverEntry(
PDRIVER_OBJECTdriver,
PUNICODE_STRINGreg_path)
{
NTSTATUS status;
PDEVICE_OBJECT device;
// 设备名
UNICODE_STRINGdevice_name =
RTL_CONSTANT_STRING("\Device\MyCDO");
// 符号链接名
UNICODE_STRINGsymb_link =
RTL_CONSTANT_STRING("\DosDevices\MyCDOSL");
// 生成设备对象
status= IoCreateDevice(
driver,
0,
device_name,
FILE_DEVICE_UNKNOWN,
0,
FALSE,
&device);
// 如果不成功,就返回。
if(!NT_SUCCESS(status))
returnstatus;
// 生成符号链接
status= IoCreateSymbolicLink(
&symb_link,
&device_name);
if(!NT_SUCCESS(status))
{
IoDeleteDevice(device);
returnstatus;
}
// 设备生成之后,打开初始化完成标记
device->Flags&= ~DO_DEVICE_INITIALIZING;
returnstatus;
}
这个驱动成功加载之后,生成一个名叫“DeviceMyCDO”的设备。然后在给这个设备生成了一个符号链接名字叫做“DosDevices
MyCDOSL”。应用层可以通过打开这个符号链接来打开设备。应用层可以调用CreateFile就像打开文件一样打开。只是路径应该是“"\.
MyCDOSL”。前面的“\.”意味后面是一个符号链接名,而不是一个普通的文件。请注意,由于C语言中斜杠要双写,所以正确的写法应该是“
\\.\”。与应用层交互的例子在下一节“IRP和IO_STACK_LOCATION”中。
7.4 设备的生成安全性限制
上一节的例子只是简单的例子。很多情况下那些代码会不起作用。为了避免读者在实际编程中遇到哪些特殊情况的困绕,下面详细说明生成设备和符号链接需要注意的地方。生成设备的函数原型如下:
NTSTATUS
IoCreateDevice(
IN PDRIVER_OBJECT DriverObject,
IN ULONG DeviceExtensionSize,
IN PUNICODE_STRING DeviceName OPTIONAL,
IN DEVICE_TYPE DeviceType,
IN ULONG DeviceCharacteristics,
IN BOOLEAN Exclusive,
OUT PDEVICE_OBJECT *DeviceObject
);
这个函数的参数也非常复杂。但是实际上需要注意的并不多。
第一个参数是生成这个设备的驱动对象。
第二个参数DeviceExtensionSize非常重要。由于分发函数中得到的总是设备的指针。当用户需要在每个设备上记录一些额外的信息(比如用于
判断这个设备是哪个设备的信息、以及不同的实际设备所需要记录的实际信息,比如网卡上数据包的流量、过滤器所绑定真实设备指针等等),需要指定的设备扩展
区内存的大小。如果DeviceExtensionSize设置为非0,IoCreateDevice会分配这个大小的内存在
DeviceObject->DeviceExtension中。以后用户就可以从根据DeviceObject->
DeviceExtension来获得这些预先保存的信息。
DeviceName如前例,是设备的名字。目前生成设备,请总是生成在Device目录下。所以前面写的名字是“DeviceMyCDO”。其他路径也是可以的,但是这在本书描述范围之外。
DeviceType表示设备类型。目前的范例无所谓设备类型,所以填写FILE_DEVICE_UNKNOWN即可。
DeviceCharacteristics目前请简单的填写0即可。
Exclusive这个参数必须设置FALSE。文档没有做任何解释。
最后生成的设备对象指针返回到DeviceObject中。
这种设备生成之后,必须有系统权限的用户才能打开(比如管理员)。所以如果编程者写了一个普通的用户态的应用程序去打开这个设备进行交互,那么很多情况下可以(用管理员登录的时候)。但是偶尔又不行(用普通用户登录的时候)。结果困绕很久。其实是权限问题。
为了保证交互的成功与安全性,应该用服务程序与之交互。
但是依然有时候必须用普通用户打开设备。为了这个目的,设备必须是对所有的用户开放的。此时不能用IoCreateDevice。必须用IoCreateDeviceSecure。这个函数的原型如下:
NTSTATUS
IoCreateDeviceSecure(
IN PDRIVER_OBJECT DriverObject,
IN ULONG DeviceExtensionSize,
IN PUNICODE_STRING DeviceName OPTIONAL,
IN DEVICE_TYPE DeviceType,
IN ULONG DeviceCharacteristics,
IN BOOLEAN Exclusive,
IN PCUNICODE_STRING DefaultSDDLString,
IN LPCGUID DeviceClassGuid,
OUT PDEVICE_OBJECT *DeviceObject
)
这个函数增加了两个参数(其他的没变)。一个是DefaultSDDLString。这个一个用于描述权限的字符串。描述这个字符串的格式需要大量的篇幅。但是没有这个必要。字符串“D:P(A;;GA;;;WD)”将满足“人人皆可以打开”的需求。
另一个参数是一个设备的GUID。请随机手写一个GUID。不要和其他设备的GUID冲突(不要复制粘贴即可)。
下面是例子:
// 随机手写一个GUID
const GUIDDECLSPEC_SELECTANY MYGUID_CLASS_MYCDO =
{0x26e0d1e0L, 0x8189, 0x12e0, {0x99,0x14, 0x08,0x00, 0x22, 0x30, 0x19, 0x03}};
// 全用户可读写权限
UNICODE_STRING sddl =
RLT_CONSTANT_STRING(L"D:P(A;;GA;;;WD)");
// 生成设备
status =IoCreateDeviceSecure( DriverObject,
0,
&device_name,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE,
&sddl,
(LPCGUID)&SFGUID_CLASS_MYCDO,
&device);
使用这个函数的时候,必须连接库wdmsec.lib。
7.5 符号链接的用户相关性
从前面的例子看,符号链接的命名貌似很简单。简单的符号链接(之所以称为简单,是因为还有一种使用GUID的符号链接,这在本书讨论范围之外)总是命名在DosDevices之下。但是实际上这会有一些问题。
比较高级的Windows系统(哪个版本的操作系统很难讲,可能必须判定补丁号),符号链接也带有用户相关性。换句话说,如果一个普通用户创建了符号链接“DosDevicesMyCDOSL”,那么,其实其他的用户是看不见这个符号链接的。
但是读者又会发现,如果在DriverEntry中生成符号链接,则所有用户都可以看见。原因是DriverEntry总是在进程“System”中执行。系统用户生成的符号链接是大家都可以看见的。
当前用户总是取决于当前启动当前进程的用户。实际编程中并不一定要在DriverEntry中生成符号链接。一旦在一个不明用户环境下生成符号链接,就可
能出现注销然后换用户登录之后,符号链接“不见了”的严重错误。这也是常常让初学者抓狂几周都不知道如何解决的一个问题。
其实解决的方案很简单,任何用户都可以生成全局符号链接,让所有其他用户都能看见。路径“DosDevicesMyCDOSL”改为“DosDevicesGlobalMyCDOSL”即可。
但是在不支持符号链接用户相关性的系统上,生成“DosDevicesGlobalMyCDOSL”这样的符号链接是一种错误。为此必须先判断一下。幸运的是,这个判断并不难。下面是一个例子,这个例子生成的符号链接总是随时可以使用,不用担心用户注销:
UNICODE_STRINGdevice_name;
UNICODE_STRINGsymbl_name;
if(IoIsWdmVersionAvailable(1, 0x10))
{
// 如果是支持符号链接用户相关性的版本的系统,用DosDevicesGlobal.
RtlInitUnicodeString(&symbl_name,L"\DosDevices\Global\SymbolicLinkName");
}
else
{
// 如果是不支持的,则用DosDevices
RtlInitUnicodeString(&symbl,L"\DosDevices\SymbolicLinkName");
}
// 生成符号链接
IoCreateSymbolicLink(&symbl_name,&device_name);
第八章 处理请求
8.1 IRP与IO_STACK_LOCATION
开发一个驱动要有可能要处理各种IRP。但是本书范围内,只处理为了应用程序和驱动交互而产生的IRP。IRP的结构非常复杂,但是目前的需求下没有必要
去深究它。应用程序为了和驱动通信,首先必须打开设备。然后发送或者接收信息。最后关闭它。这至少需要三个IRP:第一个是打开请求。第二个发送或者接收
信息。第三个是关闭请求。
IRP的种类取决于主功能号。主功能号就是前面的说的DRIVER_OBJECT中的分发函数指针数组中的索引。打开请求的主功能号是IRP_MJ_CREATE,而关闭请求的主功能号是IRP_MJ_CLOSE。
如果写有独立的处理IRP_MJ_CREATE和IRP_MJ_CLOSE的分发函数,就没有必要自然判断IRP的主功能号。如果像前面的例子一样,使用一个函数处理所有的IRP,那么首先就要得到IRP的主功能号。IRP的主功能号在IRP的当前栈空间中。
IRP总是发送给一个设备栈。到每个设备上的时候拥有一个“当前栈空间”来保存在这个设备上的请求信息。读者请暂时忽略这些细节。下面的代码在MyDispatch中获得主功能号,同时展示了几个常见的主功能号:
NTSTATUSMyDispatchFunction(PDEVICE_OBJECT device,PIRP irp)
{
// 获得当前irp调用栈空间
PIO_STACK_LOCATIONirpsp = IoGetCurrentIrpStackLocation(irp);
NTSTATUSstatus = STATUS_UNSUCCESSFUL;
swtich(irpsp->MajorFunction)
{
// 处理打开请求
caseIRP_MJ_CREATE:
……
break;
// 处理关闭请求
caseIRP_MJ_CLOSE:
……
break;
// 处理设备控制信息
caseIRP_MJ_DEVICE_CONTROL:
……
break;
// 处理读请求
caseIRP_MJ_READ:
……
break;
// 处理写请求
caseIRP_MJ_WRITE:
……
break;
default:
…
break;
}
returnstatus;
}
用于与应用程序通信时,上面这些请求都由应用层API引发。对应的关系大致如下:
应用层调用的API
CreateFile
CloseHandle
DeviceIoControl
ReadFile
WriteFile
了解以上信息的情况下,完成相关IRP的处理,就可以实现应用层和驱动层的通信了。具体的编程在紧接后面的两小节里完成。
8.2 打开与关闭的处理
如果打开不能成功,则通信无法实现。要打开成功,只需要简单的返回成功就可以了。在一些有同步限制的驱动中(比如每次只允许一个进程打开设备)编程要更加
复杂一点。但是现在忽略这些问题。暂时认为我们生成的设备任何进程都可以随时打开,不需要担心和其他进程冲突的问题。
简单的返回一个IRP成功(或者直接失败)是三部曲,如下:
1. 设置irp->IoStatus.Information为0。关于Information的描述,请联系前面关于IO_STATUS_BLOCK结构的解释。
2. 设置irp->IoStatus.Status的状态。如果成功则设置STATUS_SUCCESS,否则设置错误码。
3. 调用IoCompleteRequest (irp,IO_NO_INCREMENT)。这个函数完成IRP。
以上三步完成后,直接返回irp->IoStatus.Status即可。示例代码如下。这个函数能完成打开和关闭请求。
NTSTATUS
MyCreateClose(
IN PDEVICE_OBJECT device,
IN PIRP irp)
{
irp->IoStatus.Information = 0;
irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest (irp,IO_NO_INCREMENT);
return irp->IoStatus.Status;
}
当然,在前面设置分发函数的时候,应该加上:
DriverObject->MajorFunctions[IRP_MJ_CREATE]= MyCreateClose;
DriverObject->MajorFunctions[IRP_MJ_CLOSE]= MyCreateClose;
在应用层,打开和关闭这个设备的代码如下:
HANDLE device=CreateFile("\\.\MyCDOSL",
GENERIC_READ|GENERIC_WRITE,0,0,
OPEN_EXISTING,
FILE_ATTRIBUTE_SYSTEM,0);
if (device ==INVALID_HANDLE_VALUE)
{
// …. 打开失败,说明驱动没加载,报错即可
}
// 关闭
CloseHandle(device);
8.3 应用层信息传入
应用层传入信息的时候,可以使用WriteFile,也可以使用DeviceIoControl。DeviceIoControl是双向的,在读取设备的
信息也可以使用。因此本书以DeviceIoControl为例子进行说明。DeviceIoControl称为设备控制接口。其特点是可以发送一个带有
特定控制码的IRP。同时提供输入和输出缓冲区。应用程序可以定义一个控制码,然后把相应的参数填写在输入缓冲区中。同时可以从输出缓冲区得到返回的更多
信息。
当驱动得到一个DeviceIoControl产生的IRP的时候,需要了解的有当前的控制码、输入缓冲区的位置和长度,以及输出缓冲区的位置和长度。其中控制码必须预先用一个宏定义。定义的示例如下:
#defineMY_DVC_IN_CODE
(ULONG)CTL_CODE(FILE_DEVICE_UNKNOWN,
0xa01,
METHOD_BUFFERED,
FILE_READ_DATA|FILE_WRITE_DATA)
其中0xa01这个数字是用户可以自定义的。其他的参数请照抄。
下面是获得这三个要素的例子:
NTSTATUSMyDeviceIoControl(
PDEVICE_OBJECT dev,
PIRP irp)
{
// 得到irpsp的目的是为了得到功能号、输入输出缓冲
// 长度等信息。
PIO_STACK_LOCATIONirpsp =
IoGetCurrentIrpStackLocation(irp);
// 首先要得到功能号
ULONGcode = irpsp->Parameters.DeviceIoControl.IoControlCode;
// 得到输入输出缓冲长度
ULONGin_len =
irpsp->Parameters.DeviceIoControl.InputBufferLength;
ULONGout_len =
irpsp->Parameters.DeviceIoControl.OutputBufferLength;
// 请注意输入输出缓冲是公用内存空间的
PVOIDbuffer = irp->AssociatedIrp.SystemBuffer;
// 如果是符合定义的控制码,处理完后返回成功
if(code== MY_DVC_IN_CODE)
{
…在这里进行需要的处理动作
//因为不返回信息给应用,所以直接返回成功即可。
//没有用到输出缓冲
irp->IoStatus.Information= 0;
irp->IoStatus.Status= STATUS_SUCCESS;
}
else
{
// 其他的请求不接受。直接返回错误。请注意这里返
// 回错误和前面返回成功的区别。
irp->IoStatus.Information = 0;
irp->IoStatus.Status= STATUS_INVALID_PARAMETER;
}
IoCompleteRequest (irp,IO_NO_INCREMENT);
returnirp->IoStatus.Status;
}
在前面设置分发函数的时候,要加上:
DriverObject->MajorFunctions[IRP_MJ_DEVICE_CONTROL]= MyCreateClose;
应用程序方面,进行DeviceIoControl的代码如下:
HANDLE device=CreateFile("\\.\MyCDOSL",
GENERIC_READ|GENERIC_WRITE,0,0,
OPEN_EXISTING,
FILE_ATTRIBUTE_SYSTEM,0);
BOOLret;
DWORDlength = 0; // 返回的长度
if (device ==INVALID_HANDLE_VALUE)
{
// … 打开失败,说明驱动没加载,报错即可
}
BOOLret = DeviceIoControl(device,
MY_DVC_IN_CODE, // 功能号
in_buffer, // 输入缓冲,要传递的信息,预先填好
in_buffer_len, // 输入缓冲长度
NULL, // 没有输出缓冲
0, // 输出缓冲的长度为0
&length, // 返回的长度
NULL);
if(!ret)
{
// … DeviceIoControl失败。报错。
}
// 关闭
CloseHandle(device);
8.4 驱动层信息传出
驱动主动通知应用和应用通知驱动的通道是同一个。只是方向反过来。应用程序需要开启一个线程调用DeviceIoControl,(调用ReadFile亦可)。而驱动在没有消息的时候,则阻塞这个IRP的处理。等待有信息的时候返回。
有的读者可能听说过在应用层生成一个事件,然后把事件传递给驱动。驱动有消息要通知应用的时候,则设置这个事件。但是实际上这种方法和上述方法本质相同:
应用都必须开启一个线程去等待(等待事件)。而且这样使应用和驱动之间交互变得复杂(需要传递事件句柄)。这毫无必要。
让应用程序简单的调用DeviceIoControl就可以了。当没有消息的时候,这个调用不返回。应用程序自动等待(相当于等待事件)。有消息的时候这个函数返回。并从缓冲区中读到消息。
实际上,驱动内部要实现这个功能,还是要用事件的。只是不用在应用和驱动之间传递事件了。
驱动内部需要制作一个链表。当有消息要通知应用的时候,则把消息放入链表中(请参考前面的“使用LIST_ENTRY”),并设置事件(请参考前面的“使
用事件”)。在DeviceIoControl的处理中等待事件。下面是一个例子:这个例子展示的是驱动中处理DeviceIoControl的控制码为
MY_DVC_OUT_CODE的部分。实际上驱动如果有消息要通知应用,必须把消息放入队列尾并设置事件g_my_notify_event。
MyGetPendingHead获得第一条消息。请读者用以前的知识自己完成其他的部分。
NTSTATUSMyDeviceIoCtrlOut(PIRP irp,ULONG out_len)
{
MY_NODE*node;
ULONGpack_len;
// 获得输出缓冲区。
PVOIDbuffer = irp->AssociatedIrp.SystemBuffer;
// 从队列中取得第一个。如果为空,则等待直到不为空。
while((node= MyGetPendingHead()) == NULL)
{
KeWaitForSingleObject(
&g_my_notify_event,//一个用来通知有请求的事件
Executive,KernelMode,FALSE,0);
}
// 有请求了。此时请求是node。获得PACK要多长。
pack_len= MyGetPackLen(node);
if(out_len< pack_len)
{
irp->IoStatus.Information= pack_len; // 这里写需要的长度
irp->IoStatus.Status =STATUS_INVALID_BUFFER_SIZE;
IoCompleteRequest (irp,IO_NO_INCREMENT);
return irp->IoStatus.Status;
}
// 长度足够,填写输出缓冲区。
MyWritePackContent(node,buffer);
// 头节点被发送出去了,可以删除了
MyPendingHeadRemove();
// 返回成功
irp->IoStatus.Information= pack_len; // 这里写填写的长度
irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest (irp,IO_NO_INCREMENT);
return irp->IoStatus.Status;
}
这个函数的处理要追加到MyDeviceIoControl中。如下:
NTSTATUSMyDeviceIoControl(
PDEVICE_OBJECT dev,
PIRP irp)
{
…
if(code== MY_DVC_OUT_CODE)
returnMyDeviceIoCtrlOut(dev,irp);
…
}
在这种情况下,应用可以循环调用DeviceIoControl,来取得驱动驱动通知它的信息。
后记:我的闲言碎语
写这本小册子的时候,我正在NED-LS办离职手续。
想当初在东京的时候,NED的田上每夜好酒好肉的招待。北京几个同事跳槽,搞得项目特别尴尬。田上特意说道:“拜托你们不要转职......”。
半年不到,我就抛出一纸辞职信,真是负心人啊......
一眨眼间,就在NED-LS混了三年了。不是我非要走人,一方面汇率节节攀升,外包越来越困难。欧美企业不退反进,纷纷把更高档的玩意搬来国内来发。许多日本公司却还把外包当主业。另一方面我日语暴烂,又不肯学。继续摆烂显然不是办法。
相对于Intel,我其实一直是看好AMD的。上次帮小D选笔记本,还特意选了AMD的CPU。都说AMD的东西便宜量又足,最适合国内的劳苦大众,这可
不是吹的。只可惜貌似每次都被Intel揍得鼻青脸肿。所以我便去面试了。跟我去后面那天去的还有那个写“碟中谍虚拟光驱”的说话有点像唐僧的万春。
上海的AMD在浦东的荒郊。先把2号线坐到终点站,然后打的到一处无人知道的野外。只看见长长的公路和茂盛的野草。两边有无数片工地,横七竖八的堆着许多建筑材料,却没有一个人。好处是内急的时候不用找公厕(也不可能找得到)。直接在路边就可以解决。
工地的旁边有一部分成品。AMD的绿色标记就坐落其中。有两栋楼,都不大,袖珍型的。他们的面试没有笔试(说本来是有,但我去的时候卷子还没准备好,就免了)。是四个人轮番上阵,前三个是工程师,最后一个是经理。
我面的是存储芯片驱
动开发。问到驱动开发相关或者纯C语言的问题,我自然是对答如流,这许多年的苦工不是白干的。偏偏他们对效率很有兴趣,总是不时抛出几个位运算的妙用之类
的几个优化题。我只好明说了平时并不怎么关心效率。所以这个不擅长。待加盟了你们项目组之后,一定好好学习天天向上云云。
最终当然是没过了。他们Pending了好长一段时间。最终结论是我不适合做硬件驱动开发。因为我以前的经验比较上层。做虚拟SCSI设备的万春按理比我
好点,但是他死得更惨:结论是只在小公司干过,组织性、纪律性会比较差(其实这也没说错==!)。杂牌军被BS了。
万春没多久就去广州了。真是“浮云游子意,落日故人情”啊。不过话撂这儿了,莫怪劳资以后不支持AMD...
我的零八年的春夏真是惬意。做的几个程序都还没出大问题。有空还写写书,《天书夜读》扔给出版社了。只可惜一审再编的没完没了。到现在也还没有头。我又开始写新书。但不知道怎么说,因为题目还没有拟好。然后又见到小D,一个不小心就掉到蜜罐子里了。
后来又面试了几个。Mavell的面试官实在太强了,无所不知,成功的鄙视了我。MS的面试官是老外。虽然我不懂他说什么,但是我说的他也未见得明白。
不过MS的职位真的是很棒啊。做Windows Kernel,而且还在上海。一通过马上先送去美利坚培训。很美啊。
后来又去面Intel(屡败屡面==!)。Intel的环境真是不错。我到的时候正是早上。天气又好,一个人也没有,对面是一大片几乎望不到边的浅水,长
着人深的草。几只长脚的苍鹭站在水里。还有一些棕色的像小鸭子的玩意在水里抢食吃。一些小鸟在空中掠过,发出铜铃一样的声音。Intel就在那边沼泽地的
对面。比AMD大。几栋大楼。有上千人在那里工作。吃饭都在食堂。方圆2公里内没有饭店。
Intel的面试和AMD很像。没有笔试。四人轮番上阵。不过他们四个人稍微有些分工。每个人问的方向都不大一样。另一个情况是他们喜欢给你水笔,然后请直接在白板上写代码。还不能写简意,非要一行一行写出来才行。写白板手抖得厉害,没点心理素质还真不行。
第一个人问内核编程。问到我的得意之处了。不过这帮人还真的有两把刷子——他们能看Windows的代码,我还得自己反汇编。世界真不公平啊。
然后来一个人问了很多设计模式和代码管理之类的问题。这方面我当然是更滔滔不绝了。最有挑战性的是第三人,发型很像爱因斯坦那个,进来坐定之后,也不说什
么,就给出一张白纸,让我写一个矩形相交判断以及一个形状覆盖的算法。时间又大概只有二十分钟,大脑一片空白,汗就出来了。空白了大概十分钟,还一个字没
写。面试官都急了,就说:“你如果有什么中间结果,就先拿出来。”意思就是你多少写点,别交白卷啊。
不过好歹我以前是做过3D引擎的(虽然做得很烂的说)。慢慢冷静下来,和他说了计算的步骤。不算优秀也不算高效(十几分钟哪里有空考虑那么多啊)。但是面
试官说也算是逻辑完整。ok,又出了一道图论算法题。这时候我已经缓过劲来,五分钟内轻松搞定。爱因斯坦满意的走了。
最后一个是部门经理。相与言欢。然后他请客在他们食堂吃饭。但是真的很难吃的说。下午回NEC-AS继续上班,一面构思辞职信的措辞。
告别lu0,告别wowocock。
本书献给小D。她是我今夏遇到的,生命里最美的一缕阳光。
谭文 于 2008年端午
(全书完)
版权声明
本书是免费电子书。
作者保留一切权利。但在保证本书完整性(包括版权声明、前言、正文内容、后记、以及作者的信息),并不增删、改变其中任何文字内容的前提下,欢迎任何读者
以任何形式(包括各种格式的文档)复制和转载本书。同时不限制利用此书赢利的行为(如收费注册下载,或者出售光盘或打印版本)。不满足此前提的任何转载、
复制、赢利行为则是侵犯版权的行为。
发现本书的错漏之处,请联系作者。请不要修改本文中任何内容,不经过作者的同意发布修改后的版本。
作者信息
作者网名楚狂人。真名谭文。在上海从事Windows驱动开发相关的工作。对本书任何内容有任何疑问的读者,可以用下列方式和作者取得联系:
QQ:16191935
MSN:walled_river@hotmail.com
前言
本书非常适合熟悉Windows应用编程的读者转向驱动开发。所有的内容都从最基础的编程方法入手。介绍相关的内核API,然后举出示范的例子。这本书只
有不到70页,是一本非常精简的小册子。所以它并不直接指导读者开发某种特定类型的驱动程序。而是起到一个入门指导的作用。
即使都是使用C/C++语言的代码,在不同的应用环境中,常常看起来还是大相径庭。比如用TurboC++编写的DOS程序代码和用VC++编写的MFC应用程序的代码,看起来就几乎不像是同一种语言。这是由于它们所依赖的开发包不相同的缘故。
在任何情况下都以写出避免依赖的代码为最佳。这样可以避免重复劳动。但是我们在学习一种开发包的使用时,必须习惯这个环境的编码方式,以便获得充分利用这个开发包的能力。
本书的代码几乎都依赖于WDK(Windows Driver Kit)。但是不限WDK的版本。WDK还在不断的升级中。这个开发包是由微软公司免费提供的。读者可以在微软的网站上下载。
当然读者必须把WDK安装的计算机上并配置好开发环境。具体的安装和配置方法本书没有提供。因为网上已经有非常多的中文文档介绍它们。
读完这本书之后,读者一定可以更轻松的阅读其他专门的驱动程序开发的文档和相关书籍。而不至于看到大量无法理解的代码而中途放弃。如果有任何关于本书的内
容的问题,读者可以随时发邮件到mfc_tan_wen@163.com或者walled_river@hotmail.com。能够回答的问题我一般都
会答复。
写本书的时候,我和wowocock合作的一本名为《天书夜读》(在网上有一个大约20%内容的缩减电子版本)正在电子工业出版社编辑。预计还有不到一个
月左右就会出版。这也是我自己所见的唯一一本中文原创的从汇编和反汇编角度来学习Windows内核编程和信息安全软件开发的书。希望读者多多支持。有想
购买的读者请发邮件给我。我会在本书出版的第一时间,回复邮件告知购买的方法。
此外我正在写另一本关于Windows安全软件的驱动编程的书。但是题目还没有拟好。实际上,读者现在见到的免费版本的《Windows驱动编程基础教程》是从这本书的第一部分中节选出来的。这本书篇幅比较大,大约有600-800页。主要内容如下:
第一章驱动编程基础
第二章磁盘设备驱动
第三章磁盘还原与加密
第四章传统文件系统过滤
第五章小端口文件系统过滤
第六章文件系统保护与加密
第七章协议网络驱动
第八章物理网络驱动
第九章网络防火墙与安全连接
第十章打印机驱动与虚拟打印
第十一章视频驱动与过滤
附录A WDK的安装与驱动开发的环境配置
附录B 用WinDbg调试Windows驱动程序
这本书还没有完成。但是肯定要付出巨大的精力,所以请读者不要来邮件索取完整的免费的电子版本。希望读者支持本书的纸版出版。因为没有完成,所以还没有联系出版商。有愿意合作出版本书的读者请发邮件与我联系。
凡是发送邮件给我的读者,我将会发送邮件提供本人作品最新的出版信息,以及最新发布的驱动开发相关的免费电子书。如果不需要这些信息的,请在邮件里注明,或者回复邮件给我来取消订阅。
谭文
2008年6月9日
目录
第一章 字符串
1.1 使用字符串结构
常常使用传统C语言的程序员比较喜欢用如下的方法定义和使用字符串:
char *str = { “my first string” }; // ansi字符串
wchar_t *wstr = { L”my first string” }; // unicode字符串
size_tlen = strlen(str); //ansi字符串求长度
size_twlen = wcslen(wstr); //unicode字符串求长度
printf(“%s%ws %d %d”,str,wstr,len,wlen); // 打印两种字符串
但是实际上这种字符串相当的不安全。很容易导致缓冲溢出漏洞。这是因为没有任何地方确切的表明一个字符串的长度。仅仅用一个’ ’字符来标明这个字符串
的结束。一旦碰到根本就没有空结束的字符串(可能是攻击者恶意的输入、或者是编程错误导致的意外),程序就可能陷入崩溃。
使用高级C++特性的编码者则容易忽略这个问题。因为常常使用std::string和CString这样高级的类。不用去担忧字符串的安全性了。
在驱动开发中,一般不再用空来表示一个字符串的结束。而是定义了如下的一个结构:
typedefstruct _UNICODE_STRING {
USHORTLength; // 字符串的长度(字节数)
USHORTMaximumLength; // 字符串缓冲区的长度(字节数)
PWSTR Buffer; //字符串缓冲区
}UNICODE_STRING, *PUNICODE_STRING;
以上是Unicode字符串,一个字符为双字节。与之对应的还有一个Ansi字符串。Ansi字符串就是C语言中常用的单字节表示一个字符的窄字符串。
typedefstruct _STRING {
USHORTLength;
USHORTMaximumLength;
PSTRBuffer;
}ANSI_STRING, *PANSI_STRING;
在驱动开发中四处可见的是Unicode字符串。因此可以说:Windows的内核是使用Uincode编码的。ANSI_STRING仅仅在某些碰到窄字符的场合使用。而且这种场合非常罕见。
UNICODE_STRING并不保证Buffer中的字符串是以空结束的。因此,类似下面的做法都是错误的,可能会会导致内核崩溃:
UNICODE_STRINGstr;
…
len =wcslen(str.Buffer); // 试图求长度。
DbgPrint(“%ws”,str.Buffer); // 试图打印str.Buffer。
如果要用以上的方法,必须在编码中保证Buffer始终是以空结束。但这又是一个麻烦的问题。所以,使用微软提供的Rtl系列函数来操作字符串,才是正确的方法。下文逐步的讲述这个系列的函数的使用。
1.2 字符串的初始化
请回顾之前的UNICODE_STRING结构。读者应该可以注意到,这个结构中并不含有字符串缓冲的空间。这是一个初学者常见的出问题的来源。以下的代码是完全错误的,内核会立刻崩溃:
UNICODE_STRINGstr;
wcscpy(str.Buffer,L”myfirst string!”);
str.Length= str.MaximumLength = wcslen(L”my first string!”) * sizeof(WCHAR);
以上的代码定义了一个字符串并试图初始化它的值。但是非常遗憾这样做是不对的。因为str.Buffer只是一个未初始化的指针。它并没有指向有意义的空间。相反以下的方法是正确的:
// 先定义后,再定义空间
UNICODE_STRING str;
str.Buffer= L”my first string!”;
str.Length= str.MaximumLength = wcslen(L”my first string!”) * sizeof(WCHAR);
… …
上面代码的第二行手写的常数字符串在代码中形成了“常数”内存空间。这个空间位于代码段。将被分配于可执行页面上。一般的情况下不可写。为此,要注意的是这个字符串空间一旦初始化就不要再更改。否则可能引发系统的保护异常。实际上更好的写法如下:
//请分析一下为何这样写是对的:
UNICODE_STRING str = {
sizeof(L”myfirst string!”) – sizeof((L”my first string!”)[0]),
sizeof(L”myfirst string!”),
L”myfirst_string!” };
但是这样定义一个字符串实在太繁琐了。但是在头文件ntdef.h中有一个宏方便这种定义。使用这个宏之后,我们就可以简单的定义一个常数字符串如下:
#include<ntdef.h>
UNICODE_STRING str = RTL_CONSTANT_STRING(L“myfirst string!”);
这只能在定义这个字符串的时候使用。为了随时初始化一个字符串,可以使用RtlInitUnicodeString。示例如下:
UNICODE_STRINGstr;
RtlInitUnicodeString(&str,L”myfirst string!”);
用本小节的方法初始化的字符串,不用担心内存释放方面的问题。因为我们并没有分配任何内存。
1.3 字符串的拷贝
因为字符串不再是空结束的,所以使用wcscpy来拷贝字符串是不行的。UNICODE_STRING可以用RtlCopyUnicodeString来
进行拷贝。在进行这种拷贝的时候,最需要注意的一点是:拷贝目的字符串的Buffer必须有足够的空间。如果Buffer的空间不足,字符串会拷贝不完
全。这是一个比较隐蔽的错误。
下面举一个例子。
UNICODE_STRINGdst; // 目标字符串
WCHARdst_buf[256]; // 我们现在还不会分配内存,所以先定义缓冲区
UNICODE_STRINGsrc = RTL_CONST_STRING(L”My source string!”);
// 把目标字符串初始化为拥有缓冲区长度为256的UNICODE_STRING空串。
RtlInitEmptyString(dst,dst_buf,256*sizeof(WCHAR));
RtlCopyUnicodeString(&dst,&src); // 字符串拷贝!
以上这个拷贝之所以可以成功,是因为256比L” My source string!”的长度要大。如果小,则拷贝也不会出现任何明示的错误。但是拷贝结束之后,与使用者的目标不符,字符串实际上被截短了。
我曾经犯过的一个错误是没有调用RtlInitEmptyString。结果dst字符串被初始化认为缓冲区长度为0。虽然程序没有崩溃,却实际上没有拷贝任何内容。
在拷贝之前,最谨慎的方法是根据源字符串的长度动态分配空间。在1.2节“内存与链表”中,读者会看到动态分配内存处理字符串的方法。
1.4 字符串的连接
UNICODE_STRING不再是简单的字符串。操作这个数据结构往往需要更多的耐心。读者会常常碰到这样的需求:要把两个字符串连接到一起。简单的追加一个字符串并不困难。重要的依然是保证目标字符串的空间大小。下面是范例:
NTSTATUSstatus;
UNICODE_STRINGdst; // 目标字符串
WCHARdst_buf[256]; // 我们现在还不会分配内存,所以先定义缓冲区
UNICODE_STRINGsrc = RTL_CONST_STRING(L”My source string!”);
// 把目标字符串初始化为拥有缓冲区长度为256的UNICODE_STRING空串
RtlInitEmptyString(dst,dst_buf,256*sizeof(WCHAR));
RtlCopyUnicodeString(&dst,&src); // 字符串拷贝!
status= RtlAppendUnicodeToString(
&dst,L”mysecond string!”);
if(status!= STATUS_SUCCESS)
{
……
}
NTSTATUS是常见的返回值类型。如果函数成功,返回STATUS_SUCCESS。否则的话,是一个错误码。
RtlAppendUnicodeToString在目标字符串空间不足的时候依然可以连接字符串,但是会返回一个警告性的错误
STATUS_BUFFER_TOO_SMALL。
另外一种情况是希望连接两个UNICODE_STRING,这种情况请调用RtlAppendUnicodeStringToString。这个函数的第二个参数也是一个UNICODE_STRING的指针。
1.5 字符串的打印
字符串的连接另一种常见的情况是字符串和数字的组合。有时数字需要被转换为字符串。有时需要把若干个数字和字符串混合组合起来。这往往用于打印日志的时候。日志中可能含有文件名、时间、和行号,以及其他的信息。
熟悉C语言的读者会使用sprintf。这个函数的宽字符版本为swprintf。该函数在驱动开发中依然可以使用,但是不安全。微软建议使用
RtlStringCbPrintfW来代替它。RtlStringCbPrintfW需要包含头文件ntstrsafe.h。在连接的时候,还需要连接
库ntsafestr.lib。
下面的代码生成一个字符串,字符串中包含文件的路径,和这个文件的大小。
#include<ntstrsafe.h>
// 任何时候,假设文件路径的长度为有限的都是不对的。应该动态的分配
// 内存。但是动态分配内存的方法还没有讲述,所以这里再次把内存空间
// 定义在局部变量中,也就是所谓的“在栈中”
WCHARbuf[512] = { 0 };
UNICODE_STRINGdst;
NTSTATUSstatus;
……
// 字符串初始化为空串。缓冲区长度为512*sizeof(WCHAR)
RtlInitEmptyString(dst,dst_buf,512*sizeof(WCHAR));
// 调用RtlStringCbPrintfW来进行打印
status= RtlStringCbPrintfW(
dst->Buffer,L”filepath = %wZ file size = %d
”,
&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函数完全相同。可以尽情使用。
另外就是常见的输出打印。printf函数只有在有控制台输出的情况下才有意义。在驱动中没有控制台。但是Windows内核中拥有调试信息输出机制。可以使用特殊的工具查看打印的调试信息(请参阅附录1“WDK的安装与驱动开发的环境配置”)。
驱动中可以调用DbgPrint()函数来打印调试信息。这个函数的使用和printf基本相同。但是格式字符串要使用宽字符。DbgPrint()的一
个缺点在于,发行版本的驱动程序往往不希望附带任何输出信息,只有调试版本才需要调试信息。但是DbgPrint()无论是发行版本还是调试版本编译都会
有效。为此可以自己定义一个宏:
#if DBG
KdPrint(a) DbgPrint##a
#else
KdPrint(a)
#endif
不过这样的后果是,由于KdPrint (a)只支持1个参数,因此必须把DbgPrint的所有参数都括起来当作一个参数传入。导致KdPrint看起来很奇特的用了双重括弧:
// 调用KdPrint来进行输出调试信息
status= KdPrint ((
L”filepath = %wZ file size = %d
”,
&file_path,file_size));
这个宏没有必要自己定义,WDK包中已有。所以可以直接使用KdPrint来代替DbgPrint取得更方便的效果。
第二章 内存与链表
2.1内存的分配与释放
内存泄漏是C语言中一个臭名昭著的问题。但是作为内核开发者,读者将有必要自己来面对它。在传统的C语言中,分配内存常常使用的函数是malloc。这个
函数的使用非常简单,传入长度参数就得到内存空间。在驱动中使用内存分配,这个函数不再有效。驱动中分配内存,最常用的是调用
ExAllocatePoolWithTag。其他的方法在本章范围内全部忽略。回忆前一小节关于字符串的处理的情况。一个字符串被复制到另一个字符串的
时候,最好根据源字符串的空间长度来分配目标字符串的长度。下面的举例,是把一个字符串src拷贝到字符串dst。
// 定义一个内存分配标记
#defineMEM_TAG ‘MyTt’
// 目标字符串,接下来它需要分配空间。
UNICODE_STRINGdst = { 0 };
// 分配空间给目标字符串。根据源字符串的长度。
dst.Buffer=
(PWCHAR)ExAllocatePoolWithTag(NonpagedPool,src->Length,MEM_TAG);
if(dst.Buffer== NULL)
{
// 错误处理
status= STATUS_INSUFFICIENT_RESOUCRES;
……
}
dst.Length= dst.MaximumLength = src->Length;
status= RtlCopyUnicodeString(&dst,&src);
ASSERT(status== STATUS_SUCCESS);
ExAllocatePoolWithTag的第一个参数NonpagedPool表明分配的内存是锁定内存。这些内存永远真实存在于物理内存上。不会被分页交换到硬盘上去。第二个参数是长度。第三个参数是一个所谓的“内存分配标记”。
内存分配标记用于检测内存泄漏。想象一下,我们根据占用越来越多的内存的分配标记,就能大概知道泄漏的来源。一般每个驱动程序定义一个自己的内存标记。也可以在每个模块中定义单独的内存标记。内存标记是随意的32位数字。即使冲突也不会有什么问题。
此外也可以分配可分页内存,使用PagedPool即可。
ExAllocatePoolWithTag分配的内存可以使用ExFreePool来释放。如果不释放,则永远泄漏。并不像用户进程关闭后自动释放所有分配的空间。即使驱动程序动态卸载,也不能释放空间。唯一的办法是重启计算机。
ExFreePool只需要提供需要释放的指针即可。举例如下:
ExFreePool(dst.Buffer);
dst.Buffer= NULL;
dst.Length= dst.MaximumLength = 0;
ExFreePool不能用来释放一个栈空间的指针。否则系统立刻崩溃。像以下的代码:
UNICODE_STRINGsrc = RTL_CONST_STRING(L”My source string!”);
ExFreePool(src.Buffer);
会招来立刻蓝屏。所以请务必保持ExAllocatePoolWithTag和ExFreePool的成对关系。
2.2 使用LIST_ENTRY
Windows的内核开发者们自己开发了部分数据结构,比如说LIST_ENTRY。
LIST_ENTRY是一个双向链表结构。它总是在使用的时候,被插入到已有的数据结构中。下面举一个例子。我构筑一个链表,这个链表的每个节点,是一个
文件名和一个文件大小两个数据成员组成的结构。此外有一个FILE_OBJECT的指针对象。在驱动中,这代表一个文件对象。本书后面的章节会详细解释。
这个链表的作用是:保存了文件的文件名和长度。只要传入FILE_OBJECT的指针,使用者就可以遍历链表找到文件名和文件长度。
typedefstruct {
PFILE_OBJECTfile_object;
UNICODE_STRINGfile_name;
LARGE_INTEGERfile_length;
}MY_FILE_INFOR, *PMY_FILE_INFOR;
一些读者会马上注意到文件的长度用LARGE_INTEGER表示。这是一个代表长长整型的数据结构。这个结构我们在下一小小节“使用长长整型数据”中介绍。
为了让上面的结构成为链表节点,我必须在里面插入一个LIST_ENTRY结构。至于插入的位置并无所谓。可以放在最前,也可以放中间,或者最后面。但是实际上读者很快会发现把LIST_ENTRY放在开头是最简单的做法:
typedefstruct {
LIST_ENTRYlist_entry;
PFILE_OBJECTfile_object;
UNICODE_STRINGfile_name;
LARGE_INTEGERfile_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
list_entry如果是作为链表的头,在使用之前,必须调用InitializeListHead来初始化。下面是示例的代码:
// 我们的链表头
LIST_ENTRY my_list_head;
// 链表头初始化。一般的说在应该在程序入口处调用一下
voidMyFileInforInilt()
{
InitializeListHead(&my_list_head);
}
// 我们的链表节点。里面保存一个文件名和一个文件长度信息。
typedefstruct {
LIST_ENTRYlist_entry;
PFILE_OBJECTfile_object;
PUNICODE_STRINGfile_name;
LARGE_INTEGERfile_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
// 追加一条信息。也就是增加一个链表节点。请注意file_name是外面分配的。
// 内存由使用者管理。本链表并不管理它。
NTSTATUS MyFileInforAppendNode(
PFILE_OBJECTfile_object,
PUNICODE_STRINGfile_name,
PLARGE_INTEGERfile_length)
{
PMY_FILE_INFORmy_file_infor =
(PMY_FILE_INFOR)ExAllocatePoolWithTag(
PagedPool,sizeof(MY_FILE_INFOR),MEM_TAG);
if(my_file_infor== NULL)
returnSTATUS_INSUFFICIENT_RESOURES;
//填写数据成员。
my_file_infor->file_object= file_object;
my_file_infor->file_name= file_name;
my_file_infor->file_length= file_length;
//插入到链表末尾。请注意这里没有使用任何锁。所以,这个函数不是多
//多线程安全的。在下面自旋锁的使用中讲解如何保证多线程安全性。
InsertHeadList(&my_list_head,(PLIST_ENTRY)& my_file_infor);
returnSTATUS_SUCCESS;
}
以上的代码实现了插入。可以看到LIST_ENTRY插入到MY_FILE_INFOR结构的头部的好处。这样一来一个MY_FILE_INFOR看起来
就像一个LIST_ENTRY。不过糟糕的是并非所有的情况都可以这样。比如MS的许多结构喜欢一开头是结构的长度。因此在通过LIST_ENTRY结构
的地址获取所在的节点的地址的时候,有个地址偏移计算的过程。可以通过下面的一个典型的遍历链表的示例中看到:
for(p =my_list_head.Flink; p != &my_list_head.Flink; p = p->Flink)
{
PMY_FILE_INFORelem =
CONTAINING_RECORD(p,MY_FILE_INFOR,list_entry);
// 在这里做需要做的事…
}
}
其中的CONTAINING_RECORD是一个WDK中已经定义的宏,作用是通过一个LIST_ENTRY结构的指针,找到这个结构所在的节点的指针。定义如下:
#defineCONTAINING_RECORD(address, type, field) ((type *)(
(PCHAR)(address) -
(ULONG_PTR)(&((type *)0)->field)))
从上面的代码中可以总结如下的信息:
LIST_ENTRY中的数据成员Flink指向下一个LIST_ENTRY。
整个链表中的最后一个LIST_ENTRY的Flink不是空。而是指向头节点。
得到LIST_ENTRY之后,要用CONTAINING_RECORD来得到链表节点中的数据。
2.3 使用长长整型数据
这里解释前面碰到的LARGE_INTEGER结构。与可能的误解不同,64位数据并非要在64位操作系统下才能使用。在VC中,64位数据的类型为__int64。定义写法如下:
__int64file_offset;
上面之所以定义的变量名为file_offset,是因为文件中的偏移量是一种常见的要使用64位数据的情况。同时,文件的大小也是如此(回忆上一小节中
定义的文件大小)。32位数据无符号整型只能表示到4GB。而众所周知,现在超过4GB的文件绝对不罕见了。但是实际上__int64这个类型在驱动开发
中很少被使用。基本上被使用到的是一个共用体:LARGE_INTEGER。这个共用体定义如下:
typedef__int64 LONGLONG;
typedefunion _LARGE_INTEGER {
struct{
ULONGLowPart;
LONGHighPart;
};
struct{
ULONGLowPart;
LONGHighPart;
} u;
LONGLONG QuadPart;
}LARGE_INTEGER;
这个共用体的方便之处在于,既可以很方便的得到高32位,低32位,也可以方便的得到整个64位。进行运算和比较的时候,使用QuadPart即可。
LARGE_INTEGERa,b;
a.QuadPart= 100;
a.QuadPart*= 100;
b.QuadPart= a.QuadPart;
if(b.QuadPart> 1000)
{
KdPrint(“b.QuadPart< 1000, LowPart = %x HighPart = %x”, b.LowPart,b.HighPart);
}
上面这段代码演示了这种结构的一般用法。在实际编程中,会碰到大量的参数是LARGE_INTEGER类型的。
2.4使用自旋锁
链表之类的结构总是涉及到恼人的多线程同步问题,这时候就必须使用锁。这里只介绍最简单的自选锁。
有些读者可能疑惑锁存在的意义。这和多线程操作有关。在驱动开发的代码中,大多是存在于多线程执行环境的。就是说可能有几个线程在同时调用当前函数。
这样一来,前文1.2.2中提及的追加链表节点函数就根本无法使用了。因为MyFileInforAppendNode这个函数只是简单的操作链表。如果
两个线程同时调用这个函数来操作链表的话:注意这个函数操作的是一个全局变量链表。换句话说,无论有多少个线程同时执行,他们操作的都是同一个链表。这就
可能发生,一个线程插入一个节点的同时,另一个线程也同时插入。他们都插入同一个链表节点的后边。这时链表就会发生问题。到底最后插入的是哪一个呢?要么
一个丢失了。要么链表被损坏了。
如下的代码初始化获取一个自选锁:
KSPIN_LOCKmy_spin_lock;
KeInitializeSpinLock(&my_spin_lock);
KeInitializeSpinLock这个函数没有返回值。下面的代码展示了如何使用这个SpinLock。在KeAcquireSpinLock和
KeReleaseSpinLock之间的代码是只有单线程执行的。其他的线程会停留在KeAcquireSpinLock等候。直到
KeReleaseSpinLock被调用。KIRQL是一个中断级。KeAcquireSpinLock会提高当前的中断级。但是目前忽略这个问题。中
断级在后面讲述。
KIRQLirql;
KeAcquireSpinLock(&my_spin_lock,&irql);
// Todo something …
KeReleaseSpinLock(&my_spin_lock,irql);
初学者要注意的是,像下面写的这样的“加锁”代码是没有意义的,等于没加锁:
voidMySafeFunction()
{
KSPIN_LOCKmy_spin_lock;
KIRQLirql;
KeInitializeSpinLock(&my_spin_lock);
KeAcquireSpinLock(&my_spin_lock,&irql);
// 在这里做要做的事情…
KeReleaseSpinLock(&my_spin_lock,irql);
}
原因是my_spin_lock在堆栈中。每个线程来执行的时候都会重新初始化一个锁。只有所有的线程共用一个锁,锁才有意义。所以,锁一般不会定义成局
部变量。可以使用静态变量、全局变量,或者分配在堆中(见前面的1.2.1内存的分配与释放一节)。请读者自己写出正确的方法。
LIST_ENTRY有一系列的操作。这些操作并不需要使用者自己调用获取与释放锁。只需要为每个链表定义并初始化一个锁即可:
LIST_ENTRY my_list_head; // 链表头
KSPIN_LOCK my_list_lock; //链表的锁
// 链表初始化函数
voidMyFileInforInilt()
{
InitializeListHead(&my_list_head);
KeInitializeSpinLock(&my_list_lock);
}
链表一旦完成了初始化,之后的可以采用一系列加锁的操作来代替普通的操作。比如插入一个节点,普通的操作代码如下:
InsertHeadList(&my_list_head,(PLIST_ENTRY)& my_file_infor);
换成加锁的操作方式如下:
ExInterlockedInsertHeadList(
&my_list_head,
(PLIST_ENTRY)&my_file_infor,
&my_list_lock);
注意不同之处在于,增加了一个KSPIN_LOCK的指针作为参数。在ExInterlockedInsertHeadList中,会自动的使用这个KSPIN_LOCK进行加锁。类似的还有一个加锁的Remove函数,用来移除一个节点,调用如下:
my_file_infor= ExInterlockedRemoveHeadList (
&my_list_head,
&my_list_lock);
这个函数从链表中移除第一个节点。并返回到my_file_infor中。
第三章 文件操作
在内核中不能调用用户层的Win32 API函数来操作文件。在这里必须改用一系列与之对应的内核函数。
3.1 使用OBJECT_ATTRIBUTES
一般的想法是,打开文件应该传入这个文件的路径。但是实际上这个函数并不直接接受一个字符串。使用者必须首先填写一个OBJECT_ATTRIBUTES
结构。在文档中并没有公开这个OBJECT_ATTRIBUTES结构。这个结构总是被InitializeObjectAttributes初始化。
下面专门说明InitializeObjectAttributes。
VOIDInitializeObjectAttributes(
OUTPOBJECT_ATTRIBUTES InitializedAttributes,
INPUNICODE_STRING ObjectName,
INULONG Attributes,
INHANDLE RootDirectory,
INPSECURITY_DESCRIPTOR SecurityDescriptor);
读者需要注意的以下的几点:InitializedAttributes是要初始化的OBJECT_ATTRIBUTES结构的指针。ObjectName则是对象名字字符串。也就是前文所描述的文件的路径(如果要打开的对象是一个文件的话)。
Attributes则只需要填写OBJ_CASE_INSENSITIVE|
OBJ_KERNEL_HANDLE即可(如果读者是想要方便的简洁的打开一个文件的话)。OBJ_CASE_INSENSITIVE意味着名字字符串是
不区分大小写的。由于Windows的文件系统本来就不区分字母大小写,所以笔者并没有尝试过如果不设置这个标记会有什么后果。
OBJ_KERNEL_HANDLE表明打开的文件句柄一个“内核句柄”。内核文件句柄比应用层句柄使用更方便,可以不受线程和进程的限制。在任何线程中
都可以读写。同时打开内核文件句柄不需要顾及当前进程是否有权限访问该文件的问题(如果是有安全权限限制的文件系统)。如果不使用内核句柄,则有时不得不
填写后面的的SecurityDescriptor参数。
RootDirectory用于相对打开的情况。目前省略。请读者传入NULL即可。
SecurityDescriptor用于设置安全描述符。由于笔者总是打开内核句柄,所以很少设置这个参数。
3.2 打开和关闭文件
下面的函数用于打开一个文件:
NTSTATUSZwCreateFile(
OUTPHANDLE FileHandle,
INACCESS_MASK DesiredAccess,
INPOBJECT_ATTRIBUTES ObjectAttribute,
OUTPIO_STATUS_BLOCK IoStatusBlock,
INPLARGE_INTEGER AllocationSize OPTIONAL,
INULONG FileAttributes,
INULONG ShareAccess,
INULONG CreateDisposition,
INULONG createOptions,
INPVOID EaBuffer OPTIONAL,
INULONG EaLength);
这个函数的参数异常复杂。下面逐个的说明如下:
FileHandle:是一个句柄的指针。如果这个函数调用返回成成功(STATUS_SUCCESS),那就么打开的文件句柄就返回在这个地址内。
DesiredAccess:
申请的权限。如果打开写文件内容,请使用FILE_WRITE_DATA。如果需要读文件内容,请使用FILE_READ_DATA。如果需要删除文件或
者把文件改名,请使用DELETE。如果想设置文件属性,请使用FILE_WRITE_ATTRIBUTES。反之,读文件属性则使用
FILE_READ_ATTRIBUTES。这些条件可以用|(位或)来组合。有两个宏分别组合了常用的读权限和常用的写权限。分别为
GENERIC_READ和GENERIC_WRITE。此外还有一个宏代表全部权限,是GENERIC_ALL。此外,如果想同步的打开文件,请加上
SYNCHRONIZE。同步打开文件详见后面对CreateOptions的说明。
ObjectAttribute:对象描述。见前一小节。
IoStatusBlock也是一个结构。这个结构在内核开发中经常使用。它往往用于表示一个操作的结果。这个结构在文档中是公开的,如下:
typedefstruct _IO_STATUS_BLOCK {
union {
NTSTATUS Status;
PVOID Pointer;
};
ULONG_PTR Information;
}IO_STATUS_BLOCK, *PIO_STATUS_BLOCK;
实际编程中很少用到Pointer。一般的说,返回的结果在Status中。成功则为STATUS_SUCCESS。否则则是一个错误码。进一步的信息在
Information中。不同的情况下返回的Information的信息意义不同。针对ZwCreateFile调用的情况,Information
的返回值有以下几种可能:
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_CREATED:文件被成功的新建了。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OPENED: 文件被打开了。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OVERWRITTEN:文件被覆盖了。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_SUPERSEDED: 文件被替代了。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_EXISTS:文件已存在。(因而打开失败了)。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_DOES_NOT_EXIST:文件不存在。(因而打开失败了)。
这些返回值和打开文件的意图有关(有时希望打开已存在的文件,有时则希望建立新的文件等等。这些意图在本小节稍后的内容中详细说明。
ZwCreateFile的下一个参数是AllocationSize。这个参数很少使用,请设置为NULL。
再接下来的一个参数为FileAttributes。这个参数控制新建立的文件的属性。一般的说,设置为FILE_ATTRIBUTE_NORMAL即
可。在实际编程中,笔者没有尝试过其他的值。
ShareAccess是一个非常容易被人误解的参数。实际上,这是在本代码打开这个文件的时候,允许别的代码同时打开这个文件所持有的权限。所以称为共
享访问。一共有三种共享标记可以设置:FILE_SHARE_READ、FILE_SHARE_WRITE、FILE_SHARE_DELETE。这三个
标记可以用|(位或)来组合。举例如下:如果本次打开只使用了FILE_SHARE_READ,那么这个文件在本次打开之后,关闭之前,别次打开试图以读
权限打开,则被允许,可以成功打开。如果别次打开试图以写权限打开,则一定失败。返回共享冲突。
同时,如果本次打开只只用了FILE_SHARE_READ,而之前这个文件已经被另一次打开用写权限打开着。那么本次打开一定失败,返回共享冲突。其中的逻辑关系貌似比较复杂,读者应耐心理解。
CreateDisposition参数说明了这次打开的意图。可能的选择如下(请注意这些选择不能组合):
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_CREATE:新建文件。如果文件已经存在,则这个请求失败。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OPEN:打开文件。如果文件不存在,则请求失败。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OPEN_IF:打开或新建。如果文件存在,则打开。如果不存在,则失败。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OVERWRITE:覆盖。如果文件存在,则打开并覆盖其内容。如果文件不存在,这个请求返回失败。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_OVERWRITE_IF:新建或覆盖。如果要打开的文件已存在,则打开它,并覆盖其内存。如果不存在,则简单的新建新文件。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif FILE_SUPERSEDE:新建或取代。如果要打开的文件已存在。则生成一个新文件替代之。如果不存在,则简单的生成新文件。
请联系上面的IoStatusBlock参数中的Information的说明。
最后一个重要的参数是CreateOptions。在惯常的编程中,笔者使用
FILE_NON_DIRECTORY_FILE|FILE_SYNCHRONOUS_IO_NONALERT。此时文件被同步的打开。而且打开的是文件
(而不是目录。创建目录请用FILE_
DIRECTORY_FILE)。所谓同步的打开的意义在于,以后每次操作文件的时候,比如写入文件,调用ZwWriteFile,在
ZwWriteFile返回时,文件写操作已经得到了完成。而不会有返回STATUS_PENDING(未决)的情况。在非同步文件的情况下,返回未决是
常见的。此时文件请求没有完成,使用者需要等待事件来等待请求的完成。当然,好处是使用者可以先去做别的事情。
要同步打开,前面的DesiredAccess必须含有SYNCHRONIZE。
此外还有一些其他的情况。比如不通过缓冲操作文件。希望每次读写文件都是直接往磁盘上操作的。此时CreateOptions中应该带标记
FILE_NO_INTERMEDIATE_BUFFERING。带了这个标记后,请注意操作文件每次读写都必须以磁盘扇区大小(最常见的是512字节)
对齐。否则会返回错误。
这个函数是如此的繁琐,以至于再多的文档也不如一个可以利用的例子。早期笔者调用这个函数往往因为参数设置不对而导致打开失败。非常渴望找到一个实际可以使用的参数的范例。下面举例如下:
// 要返回的文件句柄
HANDLEfile_handle = NULL;
// 返回值
NTSTATUSstatus;
// 首先初始化含有文件路径的OBJECT_ATTRIBUTES
OBJECT_ATTRIBUTESobject_attributes;
UNICODE_STRINGufile_name = RTL_CONST_STRING(L”\??\C:\a.dat”);
InitializeObjectAttributes(
&object_attributes,
&ufile_name,
OBJ_CASE_INSENSITIVE|OBJ_KERNEL_HANDLE,
NULL,
NULL);
// 以OPEN_IF方式打开文件。
status= ZwCreateFile(
&file_handle,
GENERIC_READ | GENERIC_WRITE,
&object_attributes,
&io_status,
NULL,
FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_READ,
FILE_OPEN_IF,
FILE_NON_DIRECTORY_FILE |
FILE_RANDOM_ACCESS |
FILE_SYNCHRONOUS_IO_NONALERT,
NULL,
0);
值得注意的是路径的写法。并不是像应用层一样直接写“C:\a.dat”。而是写成了“\??\C:\a.dat”。这是因为ZwCreateFile使用的是对象路径。“C:”是一个符号链接对象。符号链接对象一般都在“\??\”路径下。
这种文件句柄的关闭非常简单。调用ZwClose即可。内核句柄的关闭不需要和打开在同一进程中。示例如下:
ZwClose(file_handle);
3.3 文件的读写操作
打开文件之后,最重要的操作是对文件的读写。读与写的方法是对称的。只是参数输入与输出的方向不同。读取文件内容一般用ZwReadFile,写文件一般使用ZwWriteFile。
NTSTATUS
ZwReadFile(
IN HANDLE FileHandle,
IN HANDLE Event OPTIONAL,
IN PIO_APC_ROUTINE ApcRoutine OPTIONAL,
IN PVOID ApcContext OPTIONAL,
OUT PIO_STATUS_BLOCK IoStatusBlock,
OUT PVOID Buffer,
IN ULONG Length,
IN PLARGE_INTEGER ByteOffset OPTIONAL,
INPULONG Key OPTIONAL);
FileHandle:是前面ZwCreateFile成功后所得到的FileHandle。如果是内核句柄,ZwReadFile和ZwCreateFile并不需要在同一个进程中。句柄是各进程通用的。
Event :一个事件。用于异步完成读时。下面的举例始终用同步读,所以忽略这个参数。请始终填写NULL。
ApcRoutine Apc:回调例程。用于异步完成读时。下面的举例始终用同步读,所以忽略这个参数。请始终填写NULL。
IoStatusBlock:返回结果状态。同ZwCreateFile中的同名参数。
Buffer:缓冲区。如果读文件的内容成功,则内容被被读到这个缓冲里。
Length:描述缓冲区的长度。这个长度也就是试图读取文件的长度。
ByteOffset:要读取的文件的偏移量。也就是要读取的内容在文件中的位置。一般的说,不要设置为NULL。文件句柄不一定支持直接读取当前偏移。
Key:读取文件时用的一种附加信息,一般不使用。设置NULL。
返
回值:成功的返回值是STATUS_SUCCESS。只要读取到任意多个字节(不管是否符合输入的Length的要求),返回值都是
STATUS_SUCCESS。即使试图读取的长度范围超出了文件本来的大小。但是,如果仅读取文件长度之外的部分,则返回
STATUS_END_OF_FILE。
ZwWriteFile
的参数与ZwReadFile完全相同。当然,除了读写文件外,有的读者可能会问是否提供一个ZwCopyFile用来拷贝一个文件。这个要求未能被满
足。如果有这个需求,这个函数必须自己来编写。下面是一个例子,用来拷贝一个文件。利用到了ZwCreateFile,ZwReadFile和
ZwWrite这三个函数。不过作为本节的例子,只举出ZwReadFile和ZwWriteFile的部分:
NTSTATUS MyCopyFile(
PUNICODE_STRING target_path,
PUNICODE_STRING source_path)
{
// 源和目标的文件句柄
HANDLE target = NULL,source = NULL;
// 用来拷贝的缓冲区
PVOID buffer = NULL;
LARGE_INTEGER offset = { 0 };
IO_STATUS_BLOCK io_status = { 0 };
do {
// 这里请用前一小节说到的例子打开target_path和source_path所对应的
// 句柄target和source,并为buffer分配一个页面也就是4k的内存。
… …
// 然后用一个循环来读取文件。每次从源文件中读取4k内容,然后往
// 目标文件中写入4k,直到拷贝结束为止。
while(1) {
length = 4*1024; //每次读取4k。
// 读取旧文件。注意status。
status = ZwReadFile (
source,NULL,NULL,NULL,
&my_io_status,buffer, length,&offset,
NULL);
if(!NT_SUCCESS(status))
{
// 如果状态为STATUS_END_OF_FILE,则说明文件
// 的拷贝已经成功的结束了。
if(status == STATUS_END_OF_FILE)
status = STATUS_SUCCESS;
break;
}
// 获得实际读取到的长度。
length = IoStatus.Information;
// 现在读取了内容。读出的长度为length.那么我写入
// 的长度也应该是length。写入必须成功。如果失败,
// 则返回错误。
status = ZwWriteFile(
target,NULL,NULL,NULL,
&my_io_status,
buffer,length,&offset,
NULL);
if(!NT_SUCCESS(status))
break;
// offset移动,然后继续。直到出现STATUS_END_OF_FILE
// 的时候才结束。
offset.QuadPart += length;
}
} while(0);
// 在退出之前,释放资源,关闭所有的句柄。
if(target != NULL)
ZwClose(target);
if(source != NULL)
ZwClose(source);
if(buffer != NULL)
ExFreePool(buffer);
return STATUS_SUCCESS;
}
除了读写之外,文件还有很多的操作。比如删除、重新命名、枚举。这些操作将在后面实例中用到时,再详细讲解。
第四章 操作注册表
4.1 注册键的打开操作
和在应用程序中编程的方式类似,注册表是一个巨大的树形结构。操作一般都是打开某个子键。子键下有若干个值可以获得。每一个值有一个名字。值有不同的类型。一般需要查询才能获得其类型。
子键一般用一个路径来表示。和应用程序编程的一点重大不同是这个路径的写法不一样。一般应用编程中需要提供一个根子键的句柄。而驱动中则全部用路径表示。相应的有一张表表示如下:
应用编程中对应的子键
HKEY_LOCAL_MACHINE
HKEY_USERS
HKEY_CLASSES_ROOT
HKEY_CURRENT_USER
实际上应用程序和驱动程序很大的一个不同在于应用程序总是由某个“当前用户”启动的。因此可以直接读取HKEY_CLASSES_ROOT和
HKEY_CURRENT_USER。而驱动程序和用户无关,所以直接去打开HKEY_CURRENT_USER也就不符合逻辑了。
打开注册表键使用函数ZwOpenKey。新建或者打开则使用ZwCreateKey。一般在驱动编程中,使用ZwOpenKey的情况比较多见。下面以此为例讲解。ZwOpenKey的原型如下:
NTSTATUS
ZwOpenKey(
OUT PHANDLE KeyHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes
);
这个函数和ZwCreateFile是类似的。它并不接受直接传入一个字符串来表示一个子键。而是要求输入一个OBJECT_ATTRIBUTES的指针。如何初始化一个OBJECT_ATTRIBUTES请参考前面的讲解ZwCreateFile的章节。
DesiredAccess支持一系列的组合权限。可以是下表中所有权限的任何组合:
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KEY_QUERY_VALUE:读取键下的值。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KEY_SET_VALUE:设置键下的值。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KEY_CREATE_SUB_KEY:生成子键。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KEY_ENUMERATE_SUB_KEYS:枚举子键。
不过实际上可以用KEY_READ来做为通用的读权限组合。这是一个组合宏。此外对应的有KEY_WRITE。如果需要获得全部的权限,可以使用KEY_ALL_ACCESS。
下面是一个例子,这个例子非常的有实用价值。它读取注册表中保存的Windows系统目录(指Windows目录)的位置。不过这里只涉及打开子键。并不读取值。读取具体的值在后面的小节中再完成。
Windows目录的位置被称为SystemRoot,这一值保存在注册表中,路径是“HKEY_LOCAL_MACHINESOFTWARE
MicrosoftWindowsNTCurrentVersion”。当然,请注意注意在驱动编程中的写法有所不同。下面的代码初始化一个
OBJECT_ATTRIBUTES。
HANDLEmy_key = NULL;
NTSTATUSstatus;
// 定义要获取的路径
UNICODE_STRINGmy_key_path =
RTL_CONSTANT_STRING(
L”\ Registry\Machine\SOFTWARE\Microsoft\Windows NT\CurrentVersion”);
OBJECT_ATTRIBUTEmy_obj_attr = { 0 };
// 初始化OBJECT_ATTRIBUTE
InitializeObjectAttributes(
&my_obj_attr,
&my_key_path,
OBJ_CASE_INSENSITIVE,
NULL,
NULL);
// 接下来是打开Key
status= ZwOpenKey(&my_key,KEY_READ,&my_obj_attr);
if(!NT_SUCCESS(status))
{
// 失败处理
……
}
上面的代码得到了my_key。子键已经打开。然后的步骤是读取下面的SystemRoot值。这在后面一个小节中讲述。
4.2 注册值的读
一般使用ZwQueryValueKey来读取注册表中键的值。要注意的是注册表中的值可能有多种数据类型。而且长度也是没有定数的。为此,在读取过程中,就可能要面对很多种可能的情况。ZwQueryValueKey这个函数的原型如下:
NTSTATUSZwQueryValueKey(
INHANDLE KeyHandle,
INPUNICODE_STRING ValueName,
INKEY_VALUE_INFORMATION_CLASS KeyValueInformationClass,
OUTPVOID KeyValueInformation,
INULONG Length,
OUTPULONG ResultLength
);
KeyHandle:这是用ZwCreateKey或者ZwOpenKey所打开的一个注册表键句柄。
ValueName:要读取的值的名字。
KeyValueInformationClass:本次查询所需要查询的信息类型。这有如下的三种可能。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KeyValueBasicInformation:获得基础信息,包含值名和类型。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KeyValueFullInformation:获得完整信息。包含值名、类型和值的数据。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif KeyValuePartialInformation:获得局部信息。包含类型和值数据。
很容易看出实际上名字是已知的,获得基础信息是多此一举。同样获得完整信息也是浪费内存空间。因为调用ZwQueryValueKey的目的是为了得到类
型和值数据。因此使用KeyValuePartialInformation最常见。当采用KeyValuePartialInformation的时
候,一个类型为KEY_VALUE_PARTIAL_INFORMATION的结构将被返回到参数KeyValueInformation所指向的内存
中。
KeyValueInformation:当KeyValueInformationClass被设置为
KeyValuePartialInformation时,KEY_VALUE_PARTIAL_INFORMATION结构将被返回到这个指针所指内存
中。下面是结构KEY_VALUE_PARTIAL_INFORMATION的原型。
typedefstruct _KEY_VALUE_PARTIAL_INFORMATION {
ULONG TitleIndex; //请忽略这个成员
ULONG Type; //数据类型
ULONG DataLength; //数据长度
UCHAR Data[1]; // 可变长度的数据
}KEY_VALUE_PARTIAL_INFORMATION,*PKEY_VALUE_PARTIAL_INFORMATIO;
上面的数据类型Type有很多种可能,但是最常见的几种如下:
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif REG_BINARY:十六进制数据。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif REG_DWORD:四字节整数。
file:///C:/Users/SHEN/AppData/Local/Temp/msohtml1/03/clip_image002.gif REG_SZ:以空结束的Unicode字符串。
Length:用户传入的输出空间KeyValueInformation的长度。
ResultLength:返回实际需要的长度。
返回值:如果说实际需要的长度比Length要大,那么返回STATUS_BUFFER_OVERFLOW或者是STATUS_BUFFER_TOO_SMALL。如果成功读出了全部数据,那么返回STATUS_SUCCESS。其他的情况,返回一个错误码。
下面请读者考虑如何把上一小节的函数写完整。这其中比较常见的一个问题是在读取注册表键下的值之前,往往不知道这个值有多长。所以有些比较偷懒的程序员总
是定义一个足够的大小的空间(比如512字节)。这样的坏处是浪费内存(一般都是在堆栈中定义,而内核编程中堆栈空间被耗尽又是另一个常见的蓝屏问题)。
此外也无法避免值实际上大于该长度的情况。为此应该耐心的首先获取长度,然后不足时再动态分配内存进行读取。下面是示例代码:
// 要读取的值的名字
UNICODE_STRINGmy_key_name =
RTL_CONSTANT_STRING(L”SystemRoot”);
// 用来试探大小的key_infor
KEY_VALUE_PARTIAL_INFORMATIONkey_infor;
// 最后实际用到的key_infor指针。内存分配在堆中
PKEY_VALUE_PARTIAL_INFORMATIONac_key_infor;
ULONGac_length;
……
// 前面已经打开了句柄my_key,下面如此来读取值:
status= ZwQueryValueKey(
my_key,
&my_key_name,
KeyValuePartialInformation,
&key_infor,
sizeof(KEY_VALUE_PARTIAL_INFORMATION),
&ac_length);
if(!NT_SUCCESS(status)&&
status!= STATUS_BUFFER_OVERFLOW &&
status!= STATUS_BUFFER_TOO_SMALL)
{
// 错误处理
…
}
// 如果没失败,那么分配足够的空间,再次读取
ac_key_infor= (PKEY_VALUE_PARTIAL_INFORMATION)
ExAllocatePoolWithTag(NonpagedPool,ac_length,MEM_TAG);
if(ac_key_infor== NULL)
{
stauts= STATUS_INSUFFICIENT_RESOURCES;
// 错误处理
…
}
status= ZwQueryValueKey(
my_key,
&my_key_name,
KeyValuePartialInformation,
ac_key_infor,
ac_length,
&ac_length);
// 到此为止,如果status为STATUS_SUCCESS,则要读取的数据已经
// 在ac_key_infor->Data中。请利用前面学到的知识,转换为
//UNICODE_STRING
……
4.3 注册值的写
实际上注册表的写入比读取要简单。因为这省略了一个尝试数据的大小的过程。直接将数据写入即可。写入值一般使用函数ZwSetValueKey 。这个函数的原型如下:
NTSTATUSZwSetValueKey(
IN HANDLE KeyHandle,
IN PUNICODE_STRING ValueName,
IN ULONG TitleIndex OPTIONAL,
IN ULONG Type,
IN PVOID Data,
IN ULONG DataSize
);
其中的TileIndex参数请始终填入0。
KeyHandle、ValueName、Type这三个参数和ZwQueryValueKey中对应的参数相同。不同的是Data和DataSize。
Data是要写入的数据的开始地址,而DataSize是要写入的数据的长度。由于Data是一个空指针,因此,Data可以指向任何数据。也就是说,不
管Type是什么,都可以在Data中填写相应的数据写入。
ZwSetValueKey的时候,并不需要该Value已经存在。如果该Value已经存在,那么其值会被这次写入覆盖。如果不存在,则会新建一个。下
面的例子写入一个名字为“Test”,而且值为“My Test Value”的字符串值。假设my_key是一个已经打开的子键的句柄。
UNICODE_STRINGname = RTL_CONSTANT_STRING(L”Test”);
PWCHARvalue = { L”My Test Value” };
…
// 写入数据。数据长度之所以要将字符串长度加上1,是为了把最后一个空结束符
// 写入。我不确定如果不写入空结束符会不会有错,有兴趣的读者请自己测试一下。
status= ZwSetValueKey(my_key,
&name,0,REG_SZ,value,(wcslen(value)+1)*sizeof(WCHAR));
if(!NT_SUCCESS(status))
{
// 错误处理
……
}
关于注册表的操作就介绍到这里了。如果有进一步的需求,建议读者阅读WDK相关的文档。
第五章 时间与定时器
5.1 获得当前滴答数
在编程中,获得当前的系统日期和时间,或者是获得一个从启动开始的毫秒数,是很常见的需求。获得系统日期和时间往往是为了写日志。获得启动毫秒数很适合用来做一个随机数的种子。有时也使用时间相关的函数来寻找程序的性能瓶颈。
熟悉Win32应用程序开发的读者会知道有一个函数GetTickCount(),这个函数返回系统自启动之后经历的毫秒数。在驱动开发中有一个对应的函数KeQueryTickCount(),这个函数的原型如下:
VOID
KeQueryTickCount(
OUTPLARGE_INTEGER TickCount
);
遗憾的是,被返回到TickCount中的并不是一个简单的毫秒数。这是一个“滴答”数。但是一个“滴答”到底为多长的时间,在不同的硬件环境下可能有所不同。为此,必须结合另一个函数使用。下面这个函数获得一个“滴答”的具体的100纳秒数。
ULONG
KeQueryTimeIncrement(
);
得知以上的关系之后,下面的代码可以求得实际的毫秒数:
void MyGetTickCount(PULONG msec)
{
LARGE_INTEGER tick_count;
ULONG myinc = KeQueryTimeIncrement();
KeQueryTickCount(&tick_count);
tick_count.QuadPart *= myinc;
tick_count.QuadPart /= 10000;
*msec = tick_count.LowPart;
}
这不是一个简单的过程。不过所幸的是,现在有代码可以拷贝了。
5.2 获得当前系统时间
接下来的一个需求是得到当前的可以供人类理解的时间。包括年、月、日、时、分、秒这些要素。在驱动中不能使用诸如CTime之类的MFC类。不过与之对应的有TIME_FIELDS,这个结构中含有对应的时间要素。
KeQuerySystemTime()得到当前时间。但是得到的并不是当地时间,而是一个格林威治时间。之后请使用ExSystemTimeToLocalTime()转换可以当地时间。这两个函数的原型如下:
VOID
KeQuerySystemTime(
OUT PLARGE_INTEGER CurrentTime
);
VOID
ExSystemTimeToLocalTime(
IN PLARGE_INTEGER SystemTime,
OUT PLARGE_INTEGER LocalTime
);
这两个函数使用的“时间”都是长长整型数据结构。这不是人类可以阅读的。必须通过函数RtlTimeToTimeFields转换为TIME_FIELDS。这个函数原型如下:
VOID
RtlTimeToTimeFields(
IN PLARGE_INTEGER Time,
IN PTIME_FIELDS TimeFields
);
读者需要实际应用一下来加深印象。下面写出一个函数:这个函数返回一个字符串。这个字符串写出当前的年、月、日、时、分、秒,这些数字之间用“-”号隔开。这是一个很有用的函数。而且同时用到上面三个函数,此外,请读者回忆前面关于字符串的打印的相关章节。
{
LARGE_INTEGER snow,now;
TIME_FIELDS now_fields;
static WCHAR time_str[32] = { 0 };
// 获得标准时间
KeQuerySystemTime(&snow);
// 转换为当地时间
ExSystemTimeToLocalTime(&snow,&now);
// 转换为人类可以理解的时间要素
RtlTimeToTimeFields(&now,&now_fields);
// 打印到字符串中
RtlStringCchPrintfW(
time_str,
32*2,
L"%4d-%2d-%2d %2d-%2d-%2d",
now_fields.Year,now_fields.Month,now_fields.Day,
now_fields.Hour,now_fields.Minute,now_fields.Second);
return time_str;
}
请注意time_str是静态变量。这使得这个函数不具备多线程安全性。请读者考虑一下,如何保证多个线程同时调用这个函数的时候,不出现冲突?
5.3 使用定时器
使用过Windows应用程序编程的读者的读者一定对SetTimer()映像尤深。当需要定时执行任务的时候,SetTimer()变得非常重要。这个
功能在驱动开发中可以通过一些不同的替代方法来实现。比较经典的对应是KeSetTimer(),这个函数的原型如下:
BOOLEAN
KeSetTimer(
IN PKTIMER Timer, // 定时器
IN LARGE_INTEGER DueTime, // 延后执行的时间
IN PKDPC Dpc OPTIONAL // 要执行的回调函数结构
);
其中的定时器Timer和要执行的回调函数结构Dpc都必须先初始化。其中Timer的初始化比较简单。下面的代码可以初始化一个Timer:
KTIMERmy_timer;
KeInitializeTimer(&my_timer);
Dpc的初始化比较麻烦。这是因为需要提供一个回调函数。初始化Dpc的函数原型如下:
VOID
KeInitializeDpc(
IN PRKDPC Dpc,
IN PKDEFERRED_ROUTINE DeferredRoutine,
IN PVOID DeferredContext
);
PKDEFERRED_ROUTINE这个函数指针类型所对应的函数的类型实际上是这样的:
VOID
CustomDpc(
IN struct _KDPC *Dpc,
IN PVOID DeferredContext,
IN PVOID SystemArgument1,
IN PVOID SystemArgument2
);
读者需要关心的只是DeferredContext。这个参数是KeInitializeDpc调用时传入的参数。用来提供给CustomDpc被调用的时候,让用户传入一些参数。
至于后面的SystemArgument1和SystemArgument2则请不要理会。Dpc是回调这个函数的KDPC结构。
请注意这是一个“延时执行”的过程。而不是一个定时执行的过程。因此每次执行了之后,下次就不会再被调用了。如果想要定时反复执行,就必须在每次CustomDpc函数被调用的时候,再次调用KeSetTimer,来保证下次还可以执行。
值得注意的是,CustomDpc将运行在APC中断级。因此并不是所有的事情都可以做(在调用任何内核系统函数的时候,请注意WDK说明文档中标明的中断级要求。)
这些事情非常的烦恼,因此要完全实现定时器的功能,需要自己封装一些东西。下面的结构封装了全部需要的信息:
// 内部时钟结构
typedef struct MY_TIMER_
{
KDPC dpc;
KTIMER timer;
PKDEFERRED_ROUTINE func;
PVOID private_context;
}MY_TIMER,*PMY_TIMER;
// 初始化这个结构:
voidMyTimerInit(PMY_TIMER timer, PKDEFERRED_ROUTINE func)
{
//请注意,我把回调函数的上下文参数设置为timer,为什么要
//这样做呢?
KeInitializeDpc(&timer->dpc,sf_my_dpc_routine,timer);
timer->func = func;
KeInitializeTimer(&timer->timer);
return (wd_timer_h)timer;
}
// 让这个结构中的回调函数在n毫秒之后开始运行:
BOOLEAN MyTimerSet(PMY_TIMER timer,ULONG msec,PVOID context)
{
LARGE_INTEGER due;
//注意时间单位的转换。这里msec是毫秒。
due.QuadPart = -10000*msec;
//用户私有上下文。
timer->private_context = context;
return KeSetTimer(&timer->timer,due,&mytimer->dpc);
};
// 停止执行
VOIDMyTimerDestroy(PMY_TIMER timer)
{
KeCancelTimer(&mytimer->timer);
};
使用结构PMY_TIMER已经比结合使用KDPC和KTIMER简便许多。但是还是有一些要注意的地方。真正的OnTimer回调函数中,要获得上下
文,必须要从timer->private_context中获得。此外,OnTimer中还有必要再次调用MyTimerSet(),来保证下次
依然得到执行。
VOID
MyOnTimer (
IN struct _KDPC *Dpc,
IN PVOID DeferredContext,
IN PVOID SystemArgument1,
IN PVOID SystemArgument2
)
{
//这里传入的上下文是timer结构,用来下次再启动延时调用
PMY_TIMER timer = (PMY_TIMER)DeferredContext;
//获得用户上下文
PVOID my_context = timer->private_context;
//在这里做OnTimer中要做的事情
……
//再次调用。这里假设每1秒执行一次
MyTimerSet(timer,1000,my_context);
};
关于定时器就介绍到这里了。
第六章 内核线程
6.1 使用线程
有时候需要使用线程来完成一个或者一组任务。这些任务可能耗时过长,而开发者又不想让当前系统停止下来等待。在驱动中停止等待很容易使整个系统陷入“停
顿”,最后可能只能重启电脑。但一个单独的线程长期等待,还不至于对系统造成致命的影响。另一些任务是希望长期、不断的执行,比如不断写入日志。为此启动
一个特殊的线程来执行它们是最好的方法。
在驱动中生成的线程一般是系统线程。系统线程所在的进程名为“System”。用到的内核API函数原型如下:
NTSTATUS
PsCreateSystemThread(
OUT PHANDLE ThreadHandle,
IN ULONG DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL,
IN HANDLE ProcessHandle OPTIONAL,
OUT PCLIENT_ID ClientId OPTIONAL,
IN PKSTART_ROUTINE StartRoutine,
IN PVOID StartContext);
这个函数的参数也很多。不过作者本人的使用经验如下:ThreadHandle用来返回句柄。放入一个句柄指针即可。DesiredAccess总是填写
0。后面三个参数都填写NULL。最后的两个参数一个用于改线程启动的时候执行的函数。一个用于传入该函数的参数。
下面要关心的就是那个启动函数的原型。这个原型比起定时器回调函数倒是异常的简单,没有任何多余的东西:
VOID CustomThreadProc(INPVOID context)
可以传入一个参数,就是那个context。context就是PsCreateSystemThread中的StartContext。值得注意的是,
线程的结束应该在线程中自己调用PsTerminateSystemThread来完成。此外得到的句柄也必须要用ZwClose来关闭。但是请注意:关
闭句柄并不结束线程。
下面举一个例子。这个例子传递一个字符串指针到一个线程中打印一下。然后结束该线程。当然打印字符串这种事情没有必要单独开一个线程来做。这里只是一个简单的示例。请注意,这个代码中有一个隐藏的错误,请读者指出这个错误是什么:
// 我的线程函数。传入一个参数,这个参数是一个字符串。
VOIDMyThreadProc(PVOID context)
{
PUNICODE_STRINGstr = (PUNICODE_STRING)context;
// 打印字符串
KdPrint((“PrintInMyThread:%wZ
”,str));
// 结束自己。
PsTerminateSystemThread(STATUS_SUCCESS);
}
VOIDMyFunction()
{
UNICODE_STRINGstr = RTL_CONSTANT_STRING(L“Hello!”);
HANDLEthread = NULL;
NTSTATUSstatus;
status= PsCreateSystemThread(
&thread,0L,NULL,NULL,NULL,MyThreadProc,(PVOID)&str);
if(!NT_SUCCESS(status))
{
//错误处理。
…
}
// 如果成功了,可以继续做自己的事。之后得到的句柄要关闭
ZwClose(thread);
}
以上错误之处在于:MyThreadProc执行的时候,MyFunction可能已经执行完毕了。执行完毕之后,堆栈中的str已经无效。此时再执行KdPrint去打印str一定会蓝屏。这也是一个非常隐蔽,但是非常容易犯下的错误。
合理的方法是是在堆中分配str的空间。或者str必须在全局空间中。请读者自己写出正确的方法。
但是读者会发现,以上的写法在正确的代码中也是常见的。原因是这样做的时候,在PsCreateSystemThread结束之后,开发者会在后面加上一个等待线程结束的语句。
这样就没有任何问题了,因为在这个线程结束之前,这个函数都不会执行完毕,所以栈内存空间不会失效。
这样做的目的一般不是为了让任务并发。而是为了利用线程上下文环境而做的特殊处理。比如防止重入等等。在后面的章节读者会学到这方面的技巧。
如何等待线程结束在后面1.6.3“使用事件通知”中进一步的讲述。
6.2 在线程中睡眠
许多读者一定使用过Sleep函数。这能使程序停下一段时间。许多需要连续、长期执行,但是又不希望占太多CPU使用率的任务,可以在中间加入睡眠。这样能使CPU使用率大大降低。即使睡眠的时间非常短(几十个毫秒)。
在驱动中也可以睡眠。使用到的内核函数的原型如下:
NTSTATUS
KeDelayExecutionThread(
IN KPROCESSOR_MODE WaitMode,
IN BOOLEAN Alertable,
IN PLARGE_INTEGER Interval);
这个函数的参数简单明了。WaitMode请总是填写KernelMode,因为现在是在内核编程中使用。Alertable表示是否允许线程报警(用于
重新唤醒)。但是目前没有必要用到这么高级的功能,请总是填写FALSE。剩下的就是Interval了,表明要睡眠多久。
但是这个看似简单的参数说明起来却异常的复杂。为此作者建议读者使用下面简单的睡眠函数,这个函数可以指定睡眠多少毫秒,而没有必要自己去换算时间(这个函数中有睡眠时间的转换):
#defineDELAY_ONE_MICROSECOND (-10)
#defineDELAY_ONE_MILLISECOND (DELAY_ONE_MICROSECOND*1000)
VOIDMySleep(LONG msec)
{
LARGE_INTEGERmy_interval;
my_interval.QuadPart= DELAY_ONE_MILLISECOND;
my_interval.QuadPart*= msec;
KeDelayExecutionThread(KernelMode,0,&my_interval);
}
当然要睡眠几秒也是可以的,1毫秒为千分之一秒。所以乘以1000就可以表示秒数。
在一个线程中用循环进行睡眠,也可以实现一个自己的定时器。考虑前面说的定时器的缺点:中断级较高,有一些事情不能做。在线程中用循环睡眠,每次睡眠结束
之后调用自己的回调函数,也可以起到类似的效果。而且系统线程执行中是Passive中断级。睡眠之后依然是这个中断级,所以不像前面提到的定时器那样有
限制。
请读者自己写出用线程+睡眠来实现定时器的例子。
6.3 使用事件通知
一些读者可能熟悉“事件驱动”编程技术。但是这里的“事件”与之不同。内核中的事件是一个数据结构。这个结构的指针可以当作一个参数传入一个等待函数中。
如果这个事件不被“设置”,则这个等待函数不会返回,这个线程被阻塞。如果这个事件被“设置”,则等待结束,可以继续下去。
这常常用于多个线程之间的同步。如果一个线程需要等待另一个线程完成某事后才能做某事,则可以使用事件等待。另一个线程完成后设置事件即可。
这个数据结构是KEVENT。读者没有必要去了解其内部结构。这个结构总是用KeInitlizeEvent初始化。这个函数原型如下:
VOID
KeInitializeEvent(
IN PRKEVENT Event,
IN EVENT_TYPE Type,
IN BOOLEAN State
);
第一个参数是要初始化的事件。第二个参数是事件类型,这个详见于后面的解释。第三个参数是初始化状态。一般的说设置为FALSE。也就是未设状态。这样等待者需要等待设置之后才能通过。
事件不需要销毁。
设置事件使用函数KeSetEvent。这个函数原型如下:
LONG
KeSetEvent(
IN PRKEVENT Event,
IN KPRIORITY Increment,
IN BOOLEAN Wait
);
Event是要设置的事件。Increment用于提升优先权。目前设置为0即可。Wait表示是否后面马上紧接着一个KeWaitSingleObject来等待这个事件。一般设置为TRUE。(事件初始化之后,一般就要开始等待了。)
使用事件的简单代码如下:
// 定义一个事件
KEVENTevent;
// 事件初始化
KeInitializeEvent(&event,SynchronizationEvent,TRUE);
……
// 事件初始化之后就可以使用了。在一个函数中,你可以等待某
// 个事件。如果这个事件没有被人设置,那就会阻塞在这里继续
// 等待。
KeWaitForSingleObject(&event,Executive,KernelMode,0,0);
……
// 这是另一个地方,有人设置这个事件。只要一设置这个事件,
// 前面等待的地方,将继续执行。
KeSetEvent(&event);
由于在KeInitializeEvent中使用了SynchronizationEvent,导致这个事件成为所谓的“自动重设”事件。一个事件如果被
设置,那么所有KeWaitForSingleObject等待这个事件的地方都会通过。如果要能继续重复使用这个时间,必须重设(Reset)这个事
件。当KeInitializeEvent中第二个参数被设置为NotificationEvent的时候,这个事件必须要手动重设才能使用。手动重设使
用函数KeResetEvent。
LONG
KeResetEvent(
IN PRKEVENT Event
);
如果这个事件初始化的时候是SynchronizationEvent事件,那么只有一个线程的KeWaitForSingleObject可以通过。通
过之后被自动重设。那么其他的线程就只能继续等待了。这可以起到一个同步作用。所以叫做同步事件。不能起到同步作用的是通知事件
(NotificationEvent)。请注意不能用手工设置通知事件的方法来取代同步事件。请读者思考一下这是为什么。
回忆前面的1.6.1
“使用线程”的最后的例子。在那里曾经有一个需求:就是等待线程中的函数KdPrint结束之后,外面生成线程的函数再返回。 这可以通过一个事件来实
现:线程中打印结束之后,设置事件。外面的函数再返回。为了编码简单我使用了一个静态变量做事件。这种方法在线程同步中用得极多,请务必熟练掌握:
staticKEVENT s_event;
// 我的线程函数。传入一个参数,这个参数是一个字符串。
VOIDMyThreadProc(PVOID context)
{
PUNICODE_STRINGstr = (PUNICODE_STRING)context;
KdPrint((“PrintInMyThread:%wZ
”,str));
KeSetEvent(&s_event); // 在这里设置事件。
PsTerminateSystemThread(STATUS_SUCCESS);
}
// 生成线程的函数:
VOIDMyFunction()
{
UNICODE_STRINGstr = RTL_CONSTANT_STRING(L“Hello!”);
HANDLEthread = NULL;
NTSTATUSstatus;
KeInitializeEvent(&event,SynchronizationEvent,TRUE); // 初始化事件
status= PsCreateSystemThread(
&thread,0L,NULL,NULL,NULL,MyThreadProc,(PVOID)&str);
if(!NT_SUCCESS(status))
{
//错误处理。
…
}
ZwClose(thread);
// 等待事件结束再返回:
KeWaitForSingleObject(&s_event,Executive,KernelMode,0,0);
}
实际上等待线程结束并不一定要用事件。线程本身也可以当作一个事件来等待。但是这里为了演示事件的用法而使用了事件。以上的方法调用线程则不必担心str
的内存空间会无效了。因为这个函数在线程执行完KdPrint之后才返回。缺点是这个函数不能起到并发执行的作用。
第七章 驱动与设备
7.1 驱动入口与驱动对象
驱动开发程序员所编写的驱动程序对应有一个结构。这个结构名为DRIVER_OBJECT。对应一个“驱动程序”。下面的代码展示的是一个最简单的驱动程序。
#include<ntddk.h>
NTSTATUS
DriverEntry(
IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath
)
{
NTSTATUSstatus = STATUS_UNSUCCESSFUL;
return status;
}
函数DriverEntry是每个驱动程序中必须的。如同Win32应用程序里的WinMain。DriverEntry的第一个参数就是一个
DRIVER_OBJECT的指针。这个DRIVER_OBJECT结构就对应当前编写的驱动程序。其内存是Windows系统已经分配的。
第二个参数RegistryPath是一个字符串。代表一个注册表子键。这个子键是专门分配给这个驱动程序使用的。用于保存驱动配置信息到注册表中。至于读写注册表的方法,请参照前面章节中的内容。
DriverEntry的返回值决定这个驱动的加载是否成功。如果返回为STATUS_SUCCESS,则驱动将成功加载。否则,驱动加载失败。
7.2 分发函数与卸载函数
DRIVER_OBJECT中含有分发函数指针。这些函数用来处理发到这个驱动的各种请求。Windows总是自己调用DRIVER_OBJECT下的分发函数来处理这些请求。所以编写一个驱动程序,本质就是自己编写这些处理请求的分发函数。
DRIVER_OBJECT下的分发函数指针的个数为IRP_MJ_MAXIMUM_FUNCTION。保存在一个数组中。下面的代码设置所有分发函数的地址为同一个函数:
NTSTATUS
DriverEntry(
IN PDRIVER_OBJECT DriverObject,
IN PUNICODE_STRING RegistryPath
)
{
ULONGi;
for(i=0;i<IRP_MJ_MAXIMUM_FUNCTION;++i)
{
DriverObject->MajorFunctions= MyDispatchFunction;
}
…
}
这个设置固然不难。难的工作都在编写MyDispatchFunction这个函数上。因为所有的分发函数指针都指向这一个函数,那么这个函数当然要完成本驱动所有的功能。下面是这个函数的原型。这个原型是Windows驱动编程的规范,不能更改:
NTSTATUSMyDispatchFunction(PDEVICE_OBJECT device,PIRP irp)
{
……
}
这里出现了DEVICE_OBJECT和IRP这两大结构。前一个表示一个由本驱动生成的设备对象。后一个表示一个系统请求。也就是说,现在要处理的是:发给设备device的请求irp。请完成这个处理吧。这两个结构在后面再进一步描述。
还有一个不放在分发函数数组中的函数,称为卸载函数也非常重要。如果存在这个函数,则该驱动程序可以动态卸载。在卸载时,该函数会被执行。该函数原型如下:
VOIDMyDriverUnload(PDRIVER_OBJECT driver)
{
……
}
这个函数的地址设置到DriverObject->DriverUnload即可。
由于没有返回值,所以实际上在DriverUnload中,已经无法决定这个驱动能否卸载。只能做善后处理。
7.3 设备与符号链接
驱动程序和系统其他组件之间的交互是通过给设备发送或者接受发给设备的请求来交互的。换句话说,一个没有任何设备的驱动是不能按规范方式和系统交互的。当然也不会收到任何IRP,分发函数也失去了意义。
但并不意味着这样的驱动程序不存在。如果一个驱动程序只是想写写日志文件、Hook某些内核函数或者是做一些其他的小动作,也可以不生成任何设备,也不需要关心分发函数的设置。
如果驱动程序要和应用程序之间通信,则应该生成设备。此外还必须为设备生成应用程序可以访问的符号链接。下面的驱动程序生成了一个设备,并设置了分发函数:
#include<ntifs.h> // 之所以用ntifs.h而不是ntddk.h是因为我习惯开发文件
//系统驱动,实际上目前对读者来说这两个头文件没区别。
NTSTATUSDriverEntry(
PDRIVER_OBJECTdriver,
PUNICODE_STRINGreg_path)
{
NTSTATUS status;
PDEVICE_OBJECT device;
// 设备名
UNICODE_STRINGdevice_name =
RTL_CONSTANT_STRING("\Device\MyCDO");
// 符号链接名
UNICODE_STRINGsymb_link =
RTL_CONSTANT_STRING("\DosDevices\MyCDOSL");
// 生成设备对象
status= IoCreateDevice(
driver,
0,
device_name,
FILE_DEVICE_UNKNOWN,
0,
FALSE,
&device);
// 如果不成功,就返回。
if(!NT_SUCCESS(status))
returnstatus;
// 生成符号链接
status= IoCreateSymbolicLink(
&symb_link,
&device_name);
if(!NT_SUCCESS(status))
{
IoDeleteDevice(device);
returnstatus;
}
// 设备生成之后,打开初始化完成标记
device->Flags&= ~DO_DEVICE_INITIALIZING;
returnstatus;
}
这个驱动成功加载之后,生成一个名叫“DeviceMyCDO”的设备。然后在给这个设备生成了一个符号链接名字叫做“DosDevices
MyCDOSL”。应用层可以通过打开这个符号链接来打开设备。应用层可以调用CreateFile就像打开文件一样打开。只是路径应该是“"\.
MyCDOSL”。前面的“\.”意味后面是一个符号链接名,而不是一个普通的文件。请注意,由于C语言中斜杠要双写,所以正确的写法应该是“
\\.\”。与应用层交互的例子在下一节“IRP和IO_STACK_LOCATION”中。
7.4 设备的生成安全性限制
上一节的例子只是简单的例子。很多情况下那些代码会不起作用。为了避免读者在实际编程中遇到哪些特殊情况的困绕,下面详细说明生成设备和符号链接需要注意的地方。生成设备的函数原型如下:
NTSTATUS
IoCreateDevice(
IN PDRIVER_OBJECT DriverObject,
IN ULONG DeviceExtensionSize,
IN PUNICODE_STRING DeviceName OPTIONAL,
IN DEVICE_TYPE DeviceType,
IN ULONG DeviceCharacteristics,
IN BOOLEAN Exclusive,
OUT PDEVICE_OBJECT *DeviceObject
);
这个函数的参数也非常复杂。但是实际上需要注意的并不多。
第一个参数是生成这个设备的驱动对象。
第二个参数DeviceExtensionSize非常重要。由于分发函数中得到的总是设备的指针。当用户需要在每个设备上记录一些额外的信息(比如用于
判断这个设备是哪个设备的信息、以及不同的实际设备所需要记录的实际信息,比如网卡上数据包的流量、过滤器所绑定真实设备指针等等),需要指定的设备扩展
区内存的大小。如果DeviceExtensionSize设置为非0,IoCreateDevice会分配这个大小的内存在
DeviceObject->DeviceExtension中。以后用户就可以从根据DeviceObject->
DeviceExtension来获得这些预先保存的信息。
DeviceName如前例,是设备的名字。目前生成设备,请总是生成在Device目录下。所以前面写的名字是“DeviceMyCDO”。其他路径也是可以的,但是这在本书描述范围之外。
DeviceType表示设备类型。目前的范例无所谓设备类型,所以填写FILE_DEVICE_UNKNOWN即可。
DeviceCharacteristics目前请简单的填写0即可。
Exclusive这个参数必须设置FALSE。文档没有做任何解释。
最后生成的设备对象指针返回到DeviceObject中。
这种设备生成之后,必须有系统权限的用户才能打开(比如管理员)。所以如果编程者写了一个普通的用户态的应用程序去打开这个设备进行交互,那么很多情况下可以(用管理员登录的时候)。但是偶尔又不行(用普通用户登录的时候)。结果困绕很久。其实是权限问题。
为了保证交互的成功与安全性,应该用服务程序与之交互。
但是依然有时候必须用普通用户打开设备。为了这个目的,设备必须是对所有的用户开放的。此时不能用IoCreateDevice。必须用IoCreateDeviceSecure。这个函数的原型如下:
NTSTATUS
IoCreateDeviceSecure(
IN PDRIVER_OBJECT DriverObject,
IN ULONG DeviceExtensionSize,
IN PUNICODE_STRING DeviceName OPTIONAL,
IN DEVICE_TYPE DeviceType,
IN ULONG DeviceCharacteristics,
IN BOOLEAN Exclusive,
IN PCUNICODE_STRING DefaultSDDLString,
IN LPCGUID DeviceClassGuid,
OUT PDEVICE_OBJECT *DeviceObject
)
这个函数增加了两个参数(其他的没变)。一个是DefaultSDDLString。这个一个用于描述权限的字符串。描述这个字符串的格式需要大量的篇幅。但是没有这个必要。字符串“D:P(A;;GA;;;WD)”将满足“人人皆可以打开”的需求。
另一个参数是一个设备的GUID。请随机手写一个GUID。不要和其他设备的GUID冲突(不要复制粘贴即可)。
下面是例子:
// 随机手写一个GUID
const GUIDDECLSPEC_SELECTANY MYGUID_CLASS_MYCDO =
{0x26e0d1e0L, 0x8189, 0x12e0, {0x99,0x14, 0x08,0x00, 0x22, 0x30, 0x19, 0x03}};
// 全用户可读写权限
UNICODE_STRING sddl =
RLT_CONSTANT_STRING(L"D:P(A;;GA;;;WD)");
// 生成设备
status =IoCreateDeviceSecure( DriverObject,
0,
&device_name,
FILE_DEVICE_UNKNOWN,
FILE_DEVICE_SECURE_OPEN,
FALSE,
&sddl,
(LPCGUID)&SFGUID_CLASS_MYCDO,
&device);
使用这个函数的时候,必须连接库wdmsec.lib。
7.5 符号链接的用户相关性
从前面的例子看,符号链接的命名貌似很简单。简单的符号链接(之所以称为简单,是因为还有一种使用GUID的符号链接,这在本书讨论范围之外)总是命名在DosDevices之下。但是实际上这会有一些问题。
比较高级的Windows系统(哪个版本的操作系统很难讲,可能必须判定补丁号),符号链接也带有用户相关性。换句话说,如果一个普通用户创建了符号链接“DosDevicesMyCDOSL”,那么,其实其他的用户是看不见这个符号链接的。
但是读者又会发现,如果在DriverEntry中生成符号链接,则所有用户都可以看见。原因是DriverEntry总是在进程“System”中执行。系统用户生成的符号链接是大家都可以看见的。
当前用户总是取决于当前启动当前进程的用户。实际编程中并不一定要在DriverEntry中生成符号链接。一旦在一个不明用户环境下生成符号链接,就可
能出现注销然后换用户登录之后,符号链接“不见了”的严重错误。这也是常常让初学者抓狂几周都不知道如何解决的一个问题。
其实解决的方案很简单,任何用户都可以生成全局符号链接,让所有其他用户都能看见。路径“DosDevicesMyCDOSL”改为“DosDevicesGlobalMyCDOSL”即可。
但是在不支持符号链接用户相关性的系统上,生成“DosDevicesGlobalMyCDOSL”这样的符号链接是一种错误。为此必须先判断一下。幸运的是,这个判断并不难。下面是一个例子,这个例子生成的符号链接总是随时可以使用,不用担心用户注销:
UNICODE_STRINGdevice_name;
UNICODE_STRINGsymbl_name;
if(IoIsWdmVersionAvailable(1, 0x10))
{
// 如果是支持符号链接用户相关性的版本的系统,用DosDevicesGlobal.
RtlInitUnicodeString(&symbl_name,L"\DosDevices\Global\SymbolicLinkName");
}
else
{
// 如果是不支持的,则用DosDevices
RtlInitUnicodeString(&symbl,L"\DosDevices\SymbolicLinkName");
}
// 生成符号链接
IoCreateSymbolicLink(&symbl_name,&device_name);
第八章 处理请求
8.1 IRP与IO_STACK_LOCATION
开发一个驱动要有可能要处理各种IRP。但是本书范围内,只处理为了应用程序和驱动交互而产生的IRP。IRP的结构非常复杂,但是目前的需求下没有必要
去深究它。应用程序为了和驱动通信,首先必须打开设备。然后发送或者接收信息。最后关闭它。这至少需要三个IRP:第一个是打开请求。第二个发送或者接收
信息。第三个是关闭请求。
IRP的种类取决于主功能号。主功能号就是前面的说的DRIVER_OBJECT中的分发函数指针数组中的索引。打开请求的主功能号是IRP_MJ_CREATE,而关闭请求的主功能号是IRP_MJ_CLOSE。
如果写有独立的处理IRP_MJ_CREATE和IRP_MJ_CLOSE的分发函数,就没有必要自然判断IRP的主功能号。如果像前面的例子一样,使用一个函数处理所有的IRP,那么首先就要得到IRP的主功能号。IRP的主功能号在IRP的当前栈空间中。
IRP总是发送给一个设备栈。到每个设备上的时候拥有一个“当前栈空间”来保存在这个设备上的请求信息。读者请暂时忽略这些细节。下面的代码在MyDispatch中获得主功能号,同时展示了几个常见的主功能号:
NTSTATUSMyDispatchFunction(PDEVICE_OBJECT device,PIRP irp)
{
// 获得当前irp调用栈空间
PIO_STACK_LOCATIONirpsp = IoGetCurrentIrpStackLocation(irp);
NTSTATUSstatus = STATUS_UNSUCCESSFUL;
swtich(irpsp->MajorFunction)
{
// 处理打开请求
caseIRP_MJ_CREATE:
……
break;
// 处理关闭请求
caseIRP_MJ_CLOSE:
……
break;
// 处理设备控制信息
caseIRP_MJ_DEVICE_CONTROL:
……
break;
// 处理读请求
caseIRP_MJ_READ:
……
break;
// 处理写请求
caseIRP_MJ_WRITE:
……
break;
default:
…
break;
}
returnstatus;
}
用于与应用程序通信时,上面这些请求都由应用层API引发。对应的关系大致如下:
应用层调用的API
CreateFile
CloseHandle
DeviceIoControl
ReadFile
WriteFile
了解以上信息的情况下,完成相关IRP的处理,就可以实现应用层和驱动层的通信了。具体的编程在紧接后面的两小节里完成。
8.2 打开与关闭的处理
如果打开不能成功,则通信无法实现。要打开成功,只需要简单的返回成功就可以了。在一些有同步限制的驱动中(比如每次只允许一个进程打开设备)编程要更加
复杂一点。但是现在忽略这些问题。暂时认为我们生成的设备任何进程都可以随时打开,不需要担心和其他进程冲突的问题。
简单的返回一个IRP成功(或者直接失败)是三部曲,如下:
1. 设置irp->IoStatus.Information为0。关于Information的描述,请联系前面关于IO_STATUS_BLOCK结构的解释。
2. 设置irp->IoStatus.Status的状态。如果成功则设置STATUS_SUCCESS,否则设置错误码。
3. 调用IoCompleteRequest (irp,IO_NO_INCREMENT)。这个函数完成IRP。
以上三步完成后,直接返回irp->IoStatus.Status即可。示例代码如下。这个函数能完成打开和关闭请求。
NTSTATUS
MyCreateClose(
IN PDEVICE_OBJECT device,
IN PIRP irp)
{
irp->IoStatus.Information = 0;
irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest (irp,IO_NO_INCREMENT);
return irp->IoStatus.Status;
}
当然,在前面设置分发函数的时候,应该加上:
DriverObject->MajorFunctions[IRP_MJ_CREATE]= MyCreateClose;
DriverObject->MajorFunctions[IRP_MJ_CLOSE]= MyCreateClose;
在应用层,打开和关闭这个设备的代码如下:
HANDLE device=CreateFile("\\.\MyCDOSL",
GENERIC_READ|GENERIC_WRITE,0,0,
OPEN_EXISTING,
FILE_ATTRIBUTE_SYSTEM,0);
if (device ==INVALID_HANDLE_VALUE)
{
// …. 打开失败,说明驱动没加载,报错即可
}
// 关闭
CloseHandle(device);
8.3 应用层信息传入
应用层传入信息的时候,可以使用WriteFile,也可以使用DeviceIoControl。DeviceIoControl是双向的,在读取设备的
信息也可以使用。因此本书以DeviceIoControl为例子进行说明。DeviceIoControl称为设备控制接口。其特点是可以发送一个带有
特定控制码的IRP。同时提供输入和输出缓冲区。应用程序可以定义一个控制码,然后把相应的参数填写在输入缓冲区中。同时可以从输出缓冲区得到返回的更多
信息。
当驱动得到一个DeviceIoControl产生的IRP的时候,需要了解的有当前的控制码、输入缓冲区的位置和长度,以及输出缓冲区的位置和长度。其中控制码必须预先用一个宏定义。定义的示例如下:
#defineMY_DVC_IN_CODE
(ULONG)CTL_CODE(FILE_DEVICE_UNKNOWN,
0xa01,
METHOD_BUFFERED,
FILE_READ_DATA|FILE_WRITE_DATA)
其中0xa01这个数字是用户可以自定义的。其他的参数请照抄。
下面是获得这三个要素的例子:
NTSTATUSMyDeviceIoControl(
PDEVICE_OBJECT dev,
PIRP irp)
{
// 得到irpsp的目的是为了得到功能号、输入输出缓冲
// 长度等信息。
PIO_STACK_LOCATIONirpsp =
IoGetCurrentIrpStackLocation(irp);
// 首先要得到功能号
ULONGcode = irpsp->Parameters.DeviceIoControl.IoControlCode;
// 得到输入输出缓冲长度
ULONGin_len =
irpsp->Parameters.DeviceIoControl.InputBufferLength;
ULONGout_len =
irpsp->Parameters.DeviceIoControl.OutputBufferLength;
// 请注意输入输出缓冲是公用内存空间的
PVOIDbuffer = irp->AssociatedIrp.SystemBuffer;
// 如果是符合定义的控制码,处理完后返回成功
if(code== MY_DVC_IN_CODE)
{
…在这里进行需要的处理动作
//因为不返回信息给应用,所以直接返回成功即可。
//没有用到输出缓冲
irp->IoStatus.Information= 0;
irp->IoStatus.Status= STATUS_SUCCESS;
}
else
{
// 其他的请求不接受。直接返回错误。请注意这里返
// 回错误和前面返回成功的区别。
irp->IoStatus.Information = 0;
irp->IoStatus.Status= STATUS_INVALID_PARAMETER;
}
IoCompleteRequest (irp,IO_NO_INCREMENT);
returnirp->IoStatus.Status;
}
在前面设置分发函数的时候,要加上:
DriverObject->MajorFunctions[IRP_MJ_DEVICE_CONTROL]= MyCreateClose;
应用程序方面,进行DeviceIoControl的代码如下:
HANDLE device=CreateFile("\\.\MyCDOSL",
GENERIC_READ|GENERIC_WRITE,0,0,
OPEN_EXISTING,
FILE_ATTRIBUTE_SYSTEM,0);
BOOLret;
DWORDlength = 0; // 返回的长度
if (device ==INVALID_HANDLE_VALUE)
{
// … 打开失败,说明驱动没加载,报错即可
}
BOOLret = DeviceIoControl(device,
MY_DVC_IN_CODE, // 功能号
in_buffer, // 输入缓冲,要传递的信息,预先填好
in_buffer_len, // 输入缓冲长度
NULL, // 没有输出缓冲
0, // 输出缓冲的长度为0
&length, // 返回的长度
NULL);
if(!ret)
{
// … DeviceIoControl失败。报错。
}
// 关闭
CloseHandle(device);
8.4 驱动层信息传出
驱动主动通知应用和应用通知驱动的通道是同一个。只是方向反过来。应用程序需要开启一个线程调用DeviceIoControl,(调用ReadFile亦可)。而驱动在没有消息的时候,则阻塞这个IRP的处理。等待有信息的时候返回。
有的读者可能听说过在应用层生成一个事件,然后把事件传递给驱动。驱动有消息要通知应用的时候,则设置这个事件。但是实际上这种方法和上述方法本质相同:
应用都必须开启一个线程去等待(等待事件)。而且这样使应用和驱动之间交互变得复杂(需要传递事件句柄)。这毫无必要。
让应用程序简单的调用DeviceIoControl就可以了。当没有消息的时候,这个调用不返回。应用程序自动等待(相当于等待事件)。有消息的时候这个函数返回。并从缓冲区中读到消息。
实际上,驱动内部要实现这个功能,还是要用事件的。只是不用在应用和驱动之间传递事件了。
驱动内部需要制作一个链表。当有消息要通知应用的时候,则把消息放入链表中(请参考前面的“使用LIST_ENTRY”),并设置事件(请参考前面的“使
用事件”)。在DeviceIoControl的处理中等待事件。下面是一个例子:这个例子展示的是驱动中处理DeviceIoControl的控制码为
MY_DVC_OUT_CODE的部分。实际上驱动如果有消息要通知应用,必须把消息放入队列尾并设置事件g_my_notify_event。
MyGetPendingHead获得第一条消息。请读者用以前的知识自己完成其他的部分。
NTSTATUSMyDeviceIoCtrlOut(PIRP irp,ULONG out_len)
{
MY_NODE*node;
ULONGpack_len;
// 获得输出缓冲区。
PVOIDbuffer = irp->AssociatedIrp.SystemBuffer;
// 从队列中取得第一个。如果为空,则等待直到不为空。
while((node= MyGetPendingHead()) == NULL)
{
KeWaitForSingleObject(
&g_my_notify_event,//一个用来通知有请求的事件
Executive,KernelMode,FALSE,0);
}
// 有请求了。此时请求是node。获得PACK要多长。
pack_len= MyGetPackLen(node);
if(out_len< pack_len)
{
irp->IoStatus.Information= pack_len; // 这里写需要的长度
irp->IoStatus.Status =STATUS_INVALID_BUFFER_SIZE;
IoCompleteRequest (irp,IO_NO_INCREMENT);
return irp->IoStatus.Status;
}
// 长度足够,填写输出缓冲区。
MyWritePackContent(node,buffer);
// 头节点被发送出去了,可以删除了
MyPendingHeadRemove();
// 返回成功
irp->IoStatus.Information= pack_len; // 这里写填写的长度
irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest (irp,IO_NO_INCREMENT);
return irp->IoStatus.Status;
}
这个函数的处理要追加到MyDeviceIoControl中。如下:
NTSTATUSMyDeviceIoControl(
PDEVICE_OBJECT dev,
PIRP irp)
{
…
if(code== MY_DVC_OUT_CODE)
returnMyDeviceIoCtrlOut(dev,irp);
…
}
在这种情况下,应用可以循环调用DeviceIoControl,来取得驱动驱动通知它的信息。
后记:我的闲言碎语
写这本小册子的时候,我正在NED-LS办离职手续。
想当初在东京的时候,NED的田上每夜好酒好肉的招待。北京几个同事跳槽,搞得项目特别尴尬。田上特意说道:“拜托你们不要转职......”。
半年不到,我就抛出一纸辞职信,真是负心人啊......
一眨眼间,就在NED-LS混了三年了。不是我非要走人,一方面汇率节节攀升,外包越来越困难。欧美企业不退反进,纷纷把更高档的玩意搬来国内来发。许多日本公司却还把外包当主业。另一方面我日语暴烂,又不肯学。继续摆烂显然不是办法。
相对于Intel,我其实一直是看好AMD的。上次帮小D选笔记本,还特意选了AMD的CPU。都说AMD的东西便宜量又足,最适合国内的劳苦大众,这可
不是吹的。只可惜貌似每次都被Intel揍得鼻青脸肿。所以我便去面试了。跟我去后面那天去的还有那个写“碟中谍虚拟光驱”的说话有点像唐僧的万春。
上海的AMD在浦东的荒郊。先把2号线坐到终点站,然后打的到一处无人知道的野外。只看见长长的公路和茂盛的野草。两边有无数片工地,横七竖八的堆着许多建筑材料,却没有一个人。好处是内急的时候不用找公厕(也不可能找得到)。直接在路边就可以解决。
工地的旁边有一部分成品。AMD的绿色标记就坐落其中。有两栋楼,都不大,袖珍型的。他们的面试没有笔试(说本来是有,但我去的时候卷子还没准备好,就免了)。是四个人轮番上阵,前三个是工程师,最后一个是经理。
我面的是存储芯片驱
动开发。问到驱动开发相关或者纯C语言的问题,我自然是对答如流,这许多年的苦工不是白干的。偏偏他们对效率很有兴趣,总是不时抛出几个位运算的妙用之类
的几个优化题。我只好明说了平时并不怎么关心效率。所以这个不擅长。待加盟了你们项目组之后,一定好好学习天天向上云云。
最终当然是没过了。他们Pending了好长一段时间。最终结论是我不适合做硬件驱动开发。因为我以前的经验比较上层。做虚拟SCSI设备的万春按理比我
好点,但是他死得更惨:结论是只在小公司干过,组织性、纪律性会比较差(其实这也没说错==!)。杂牌军被BS了。
万春没多久就去广州了。真是“浮云游子意,落日故人情”啊。不过话撂这儿了,莫怪劳资以后不支持AMD...
我的零八年的春夏真是惬意。做的几个程序都还没出大问题。有空还写写书,《天书夜读》扔给出版社了。只可惜一审再编的没完没了。到现在也还没有头。我又开始写新书。但不知道怎么说,因为题目还没有拟好。然后又见到小D,一个不小心就掉到蜜罐子里了。
后来又面试了几个。Mavell的面试官实在太强了,无所不知,成功的鄙视了我。MS的面试官是老外。虽然我不懂他说什么,但是我说的他也未见得明白。
不过MS的职位真的是很棒啊。做Windows Kernel,而且还在上海。一通过马上先送去美利坚培训。很美啊。
后来又去面Intel(屡败屡面==!)。Intel的环境真是不错。我到的时候正是早上。天气又好,一个人也没有,对面是一大片几乎望不到边的浅水,长
着人深的草。几只长脚的苍鹭站在水里。还有一些棕色的像小鸭子的玩意在水里抢食吃。一些小鸟在空中掠过,发出铜铃一样的声音。Intel就在那边沼泽地的
对面。比AMD大。几栋大楼。有上千人在那里工作。吃饭都在食堂。方圆2公里内没有饭店。
Intel的面试和AMD很像。没有笔试。四人轮番上阵。不过他们四个人稍微有些分工。每个人问的方向都不大一样。另一个情况是他们喜欢给你水笔,然后请直接在白板上写代码。还不能写简意,非要一行一行写出来才行。写白板手抖得厉害,没点心理素质还真不行。
第一个人问内核编程。问到我的得意之处了。不过这帮人还真的有两把刷子——他们能看Windows的代码,我还得自己反汇编。世界真不公平啊。
然后来一个人问了很多设计模式和代码管理之类的问题。这方面我当然是更滔滔不绝了。最有挑战性的是第三人,发型很像爱因斯坦那个,进来坐定之后,也不说什
么,就给出一张白纸,让我写一个矩形相交判断以及一个形状覆盖的算法。时间又大概只有二十分钟,大脑一片空白,汗就出来了。空白了大概十分钟,还一个字没
写。面试官都急了,就说:“你如果有什么中间结果,就先拿出来。”意思就是你多少写点,别交白卷啊。
不过好歹我以前是做过3D引擎的(虽然做得很烂的说)。慢慢冷静下来,和他说了计算的步骤。不算优秀也不算高效(十几分钟哪里有空考虑那么多啊)。但是面
试官说也算是逻辑完整。ok,又出了一道图论算法题。这时候我已经缓过劲来,五分钟内轻松搞定。爱因斯坦满意的走了。
最后一个是部门经理。相与言欢。然后他请客在他们食堂吃饭。但是真的很难吃的说。下午回NEC-AS继续上班,一面构思辞职信的措辞。
告别lu0,告别wowocock。
本书献给小D。她是我今夏遇到的,生命里最美的一缕阳光。
谭文 于 2008年端午
(全书完)