本书非常适合熟悉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_t len = strlen(str); // ansi字符串求长度
size_t wlen = wcslen(wstr); // unicode字符串求长度
printf(“%s %ws %d %d”,str,wstr,len,wlen); // 打印两种字符串
但是实际上这种字符串相当的不安全。很容易导致缓冲溢出漏洞。这是因为没有任何地方确切的表明一个字符串的长度。仅仅用一个’\0’字符来标明这个字符串的结束。一旦碰到根本就没有空结束的字符串(可能是攻击者恶意的输入、或者是编程错误导致的意外),程序就可能陷入崩溃。
使用高级C++特性的编码者则容易忽略这个问题。因为常常使用std::string和CString这样高级的类。不用去担忧字符串的安全性了。
在驱动开发中,一般不再用空来表示一个字符串的结束。而是定义了如下的一个结构:
typedef struct _UNICODE_STRING {
USHORT Length; // 字符串的长度(字节数)
USHORT MaximumLength; // 字符串缓冲区的长度(字节数)
PWSTR Buffer; // 字符串缓冲区
} UNICODE_STRING, *PUNICODE_STRING;
以上是Unicode字符串,一个字符为双字节。与之对应的还有一个Ansi字符串。Ansi字符串就是C语言中常用的单字节表示一个字符的窄字符串。
typedef struct _STRING {
USHORT Length;
USHORT MaximumLength;
PSTR Buffer;
} ANSI_STRING, *PANSI_STRING;
在驱动开发中四处可见的是Unicode字符串。因此可以说:Windows的内核是使用Uincode编码的。ANSI_STRING仅仅在某些碰到窄字符的场合使用。而且这种场合非常罕见。
UNICODE_STRING并不保证Buffer中的字符串是以空结束的。因此,类似下面的做法都是错误的,可能会会导致内核崩溃:
UNICODE_STRING str;
…
len = wcslen(str.Buffer); // 试图求长度。
DbgPrint(“%ws”,str.Buffer); // 试图打印str.Buffer。
如果要用以上的方法,必须在编码中保证Buffer始终是以空结束。但这又是一个麻烦的问题。所以,使用微软提供的Rtl系列函数来操作字符串,才是正确的方法。下文逐步的讲述这个系列的函数的使用。
1.2 字符串的初始化
请回顾之前的UNICODE_STRING结构。读者应该可以注意到,这个结构中并不含有字符串缓冲的空间。这是一个初学者常见的出问题的来源。以下的代码是完全错误的,内核会立刻崩溃:
UNICODE_STRING str;
wcscpy(str.Buffer,L”my first 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”my first string!”) – sizeof((L”my first string!”)[0]),
sizeof(L”my first string!”),
L”my first_string!” };
但是这样定义一个字符串实在太繁琐了。但是在头文件ntdef.h中有一个宏方便这种定义。使用这个宏之后,我们就可以简单的定义一个常数字符串如下:
#include <ntdef.h>
UNICODE_STRING str = RTL_CONSTANT_STRING(L“my first string!”);
这只能在定义这个字符串的时候使用。为了随时初始化一个字符串,可以使用RtlInitUnicodeString。示例如下:
UNICODE_STRING str;
RtlInitUnicodeString(&str,L”my first string!”);
用本小节的方法初始化的字符串,不用担心内存释放方面的问题。因为我们并没有分配任何内存。
1.3 字符串的拷贝
因为字符串不再是空结束的,所以使用wcscpy来拷贝字符串是不行的。UNICODE_STRING可以用RtlCopyUnicodeString来进行拷贝。在进行这种拷贝的时候,最需要注意的一点是:拷贝目的字符串的Buffer必须有足够的空间。如果Buffer的空间不足,字符串会拷贝不完全。这是一个比较隐蔽的错误。
下面举一个例子。
UNICODE_STRING dst; // 目标字符串
WCHAR dst_buf[256]; // 我们现在还不会分配内存,所以先定义缓冲区
UNICODE_STRING src = 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不再是简单的字符串。操作这个数据结构往往需要更多的耐心。读者会常常碰到这样的需求:要把两个字符串连接到一起。简单的追加一个字符串并不困难。重要的依然是保证目标字符串的空间大小。下面是范例:
NTSTATUS status;
UNICODE_STRING dst; // 目标字符串
WCHAR dst_buf[256]; // 我们现在还不会分配内存,所以先定义缓冲区
UNICODE_STRING src = RTL_CONST_STRING(L”My source string!”);
// 把目标字符串初始化为拥有缓冲区长度为256的UNICODE_STRING空串
RtlInitEmptyString(dst,dst_buf,256*sizeof(WCHAR));
RtlCopyUnicodeString(&dst,&src); // 字符串拷贝!
status = RtlAppendUnicodeToString(
&dst,L”my second 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>
// 任何时候,假设文件路径的长度为有限的都是不对的。应该动态的分配
// 内存。但是动态分配内存的方法还没有讲述,所以这里再次把内存空间
// 定义在局部变量中,也就是所谓的“在栈中”
WCHAR buf[512] = { 0 };
UNICODE_STRING dst;
NTSTATUS status;
……
// 字符串初始化为空串。缓冲区长度为512*sizeof(WCHAR)
RtlInitEmptyString(dst,dst_buf,512*sizeof(WCHAR));
// 调用RtlStringCbPrintfW来进行打印
status = RtlStringCbPrintfW(
dst->Buffer,L”file path = %wZ file size = %d \r\n”,
&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”file path = %wZ file size = %d \r\n”,
&file_path,file_size));
这个宏没有必要自己定义,WDK包中已有。所以可以直接使用KdPrint来代替DbgPrint取得更方便的效果。
第二章 内存与链表
2.1内存的分配与释放
内存泄漏是C语言中一个臭名昭著的问题。但是作为内核开发者,读者将有必要自己来面对它。在传统的C语言中,分配内存常常使用的函数是malloc。这个函数的使用非常简单,传入长度参数就得到内存空间。在驱动中使用内存分配,这个函数不再有效。驱动中分配内存,最常用的是调用ExAllocatePoolWithTag。其他的方法在本章范围内全部忽略。回忆前一小节关于字符串的处理的情况。一个字符串被复制到另一个字符串的时候,最好根据源字符串的空间长度来分配目标字符串的长度。下面的举例,是把一个字符串src拷贝到字符串dst。
// 定义一个内存分配标记
#define MEM_TAG ‘MyTt’
// 目标字符串,接下来它需要分配空间。
UNICODE_STRING dst = { 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_STRING src = 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的指针,使用者就可以遍历链表找到文件名和文件长度。
typedef struct {
PFILE_OBJECT file_object;
UNICODE_STRING file_name;
LARGE_INTEGER file_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
一些读者会马上注意到文件的长度用LARGE_INTEGER表示。这是一个代表长长整型的数据结构。这个结构我们在下一小小节“使用长长整型数据”中介绍。
为了让上面的结构成为链表节点,我必须在里面插入一个LIST_ENTRY结构。至于插入的位置并无所谓。可以放在最前,也可以放中间,或者最后面。但是实际上读者很快会发现把LIST_ENTRY放在开头是最简单的做法:
typedef struct {
LIST_ENTRY list_entry;
PFILE_OBJECT file_object;
UNICODE_STRING file_name;
LARGE_INTEGER file_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
list_entry如果是作为链表的头,在使用之前,必须调用InitializeListHead来初始化。下面是示例的代码:
// 我们的链表头
LIST_ENTRY my_list_head;
// 链表头初始化。一般的说在应该在程序入口处调用一下
void MyFileInforInilt()
{
InitializeListHead(&my_list_head);
}
// 我们的链表节点。里面保存一个文件名和一个文件长度信息。
typedef struct {
LIST_ENTRY list_entry;
PFILE_OBJECT file_object;
PUNICODE_STRING file_name;
LARGE_INTEGER file_length;
} MY_FILE_INFOR, *PMY_FILE_INFOR;
// 追加一条信息。也就是增加一个链表节点。请注意file_name是外面分配的。
// 内存由使用者管理。本链表并不管理它。
NTSTATUS MyFileInforAppendNode(
PFILE_OBJECT file_object,
PUNICODE_STRING file_name,
PLARGE_INTEGER file_length)
{
PMY_FILE_INFOR my_file_infor =
(PMY_FILE_INFOR)ExAllocatePoolWithTag(
PagedPool,sizeof(MY_FILE_INFOR),MEM_TAG);
if(my_file_infor == NULL)
return STATUS_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);
return STATUS_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_INFOR elem =
CONTAINING_RECORD(p,MY_FILE_INFOR, list_entry);
// 在这里做需要做的事…
}
}
其中的CONTAINING_RECORD是一个WDK中已经定义的宏,作用是通过一个LIST_ENTRY结构的指针,找到这个结构所在的节点的指针。定义如下:
#define CONTAINING_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。定义写法如下:
__int64 file_offset;
上面之所以定义的变量名为file_offset,是因为文件中的偏移量是一种常见的要使用64位数据的情况。同时,文件的大小也是如此(回忆上一小节中定义的文件大小)。32位数据无符号整型只能表示到4GB。而众所周知,现在超过4GB的文件绝对不罕见了。但是实际上__int64这个类型在驱动开发中很少被使用。基本上被使用到的是一个共用体:LARGE_INTEGER。这个共用体定义如下:
typedef __int64 LONGLONG;
typedef union _LARGE_INTEGER {
struct {
ULONG LowPart;
LONG HighPart;
};
struct {
ULONG LowPart;
LONG HighPart;
} u;
LONGLONG QuadPart;
} LARGE_INTEGER;
这个共用体的方便之处在于,既可以很方便的得到高32位,低32位,也可以方便的得到整个64位。进行运算和比较的时候,使用QuadPart即可。
LARGE_INTEGER a,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_LOCK my_spin_lock;
KeInitializeSpinLock(&my_spin_lock);
KeInitializeSpinLock这个函数没有返回值。下面的代码展示了如何使用这个SpinLock。在KeAcquireSpinLock和KeReleaseSpinLock之间的代码是只有单线程执行的。其他的线程会停留在KeAcquireSpinLock等候。直到KeReleaseSpinLock被调用。KIRQL是一个中断级。KeAcquireSpinLock会提高当前的中断级。但是目前忽略这个问题。中断级在后面讲述。
KIRQL irql;
KeAcquireSpinLock(&my_spin_lock,&irql);
// To do something …
KeReleaseSpinLock(&my_spin_lock,irql);
初学者要注意的是,像下面写的这样的“加锁”代码是没有意义的,等于没加锁:
void MySafeFunction()
{
KSPIN_LOCK my_spin_lock;
KIRQL irql;
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; // 链表的锁
// 链表初始化函数
void MyFileInforInilt()
{
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。
VOID InitializeObjectAttributes(
OUT POBJECT_ATTRIBUTES InitializedAttributes,
IN PUNICODE_STRING ObjectName,
IN ULONG Attributes,
IN HANDLE RootDirectory,
IN PSECURITY_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 打开和关闭文件
下面的函数用于打开一个文件:
NTSTATUS ZwCreateFile(
OUT PHANDLE FileHandle,
IN ACCESS_MASK DesiredAccess,
IN POBJECT_ATTRIBUTES ObjectAttribute,
OUT PIO_STATUS_BLOCK IoStatusBlock,
IN PLARGE_INTEGER AllocationSize OPTIONAL,
IN ULONG FileAttributes,
IN ULONG ShareAccess,
IN ULONG CreateDisposition,
IN ULONG createOptions,
IN PVOID EaBuffer OPTIONAL,
IN ULONG 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也是一个结构。这个结构在内核开发中经常使用。它往往用于表示一个操作的结果。这个结构在文档中是公开的,如下:
typedef struct _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_CREATED:文件被成功的新建了。
FILE_OPENED: 文件被打开了。
FILE_OVERWRITTEN:文件被覆盖了。
FILE_SUPERSEDED: 文件被替代了。
FILE_EXISTS:文件已存在。(因而打开失败了)。
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_CREATE:新建文件。如果文件已经存在,则这个请求失败。
FILE_OPEN:打开文件。如果文件不存在,则请求失败。
FILE_OPEN_IF:打开或新建。如果文件存在,则打开。如果不存在,则失败。
FILE_OVERWRITE:覆盖。如果文件存在,则打开并覆盖其内容。如果文件不存在,这个请求返回失败。
FILE_OVERWRITE_IF:新建或覆盖。如果要打开的文件已存在,则打开它,并覆盖其内存。如果不存在,则简单的新建新文件。
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字节)对齐。否则会返回错误。
这个函数是如此的繁琐,以至于再多的文档也不如一个可以利用的例子。早期笔者调用这个函数往往因为参数设置不对而导致打开失败。非常渴望找到一个实际可以使用的参数的范例。下面举例如下:
// 要返回的文件句柄
HANDLE file_handle = NULL;
// 返回值
NTSTATUS status;
// 首先初始化含有文件路径的OBJECT_ATTRIBUTES
OBJECT_ATTRIBUTES object_attributes;
UNICODE_STRING ufile_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,
IN PULONG 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 |
\Registry\Machine |
HKEY_USERS |
\Registry\User |
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支持一系列的组合权限。可以是下表中所有权限的任何组合:
KEY_QUERY_VALUE:读取键下的值。
KEY_SET_VALUE:设置键下的值。
KEY_CREATE_SUB_KEY:生成子键。
KEY_ENUMERATE_SUB_KEYS:枚举子键。
不过实际上可以用KEY_READ来做为通用的读权限组合。这是一个组合宏。此外对应的有KEY_WRITE。如果需要获得全部的权限,可以使用KEY_ALL_ACCESS。
下面是一个例子,这个例子非常的有实用价值。它读取注册表中保存的Windows系统目录(指Windows目录)的位置。不过这里只涉及打开子键。并不读取值。读取具体的值在后面的小节中再完成。
Windows目录的位置被称为SystemRoot,这一值保存在注册表中,路径是“HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion”。当然,请注意注意在驱动编程中的写法有所不同。下面的代码初始化一个OBJECT_ATTRIBUTES。
HANDLE my_key = NULL;
NTSTATUS status;
// 定义要获取的路径
UNICODE_STRING my_key_path =
RTL_CONSTANT_STRING(
L” \\ Registry\\Machine\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion”);
OBJECT_ATTRIBUTE my_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这个函数的原型如下:
NTSTATUS ZwQueryValueKey(
IN HANDLE KeyHandle,
IN PUNICODE_STRING ValueName,
IN KEY_VALUE_INFORMATION_CLASS KeyValueInformationClass,
OUT PVOID KeyValueInformation,
IN ULONG Length,
OUT PULONG ResultLength
);
KeyHandle:这是用ZwCreateKey或者ZwOpenKey所打开的一个注册表键句柄。
ValueName:要读取的值的名字。
KeyValueInformationClass:本次查询所需要查询的信息类型。这有如下的三种可能。
KeyValueBasicInformation:获得基础信息,包含值名和类型。
KeyValueFullInformation:获得完整信息。包含值名、类型和值的数据。
KeyValuePartialInformation:获得局部信息。包含类型和值数据。
很容易看出实际上名字是已知的,获得基础信息是多此一举。同样获得完整信息也是浪费内存空间。因为调用ZwQueryValueKey的目的是为了得到类型和值数据。因此使用KeyValuePartialInformation最常见。当采用KeyValuePartialInformation的时候,一个类型为KEY_VALUE_PARTIAL_INFORMATION的结构将被返回到参数KeyValueInformation所指向的内存中。
KeyValueInformation:当KeyValueInformationClass被设置为KeyValuePartialInformation时,KEY_VALUE_PARTIAL_INFORMATION结构将被返回到这个指针所指内存中。下面是结构KEY_VALUE_PARTIAL_INFORMATION的原型。
typedef struct _KEY_VALUE_PARTIAL_INFORMATION {
ULONG TitleIndex; // 请忽略这个成员
ULONG Type; // 数据类型
ULONG DataLength; // 数据长度
UCHAR Data[1]; // 可变长度的数据
}KEY_VALUE_PARTIAL_INFORMATION,*PKEY_VALUE_PARTIAL_INFORMATIO;
上面的数据类型Type有很多种可能,但是最常见的几种如下:
REG_BINARY:十六进制数据。
REG_DWORD:四字节整数。
REG_SZ:以空结束的Unicode字符串。
Length:用户传入的输出空间KeyValueInformation的长度。
ResultLength:返回实际需要的长度。
返回值:如果说实际需要的长度比Length要大,那么返回STATUS_BUFFER_OVERFLOW或者是STATUS_BUFFER_TOO_SMALL。如果成功读出了全部数据,那么返回STATUS_SUCCESS。其他的情况,返回一个错误码。
下面请读者考虑如何把上一小节的函数写完整。这其中比较常见的一个问题是在读取注册表键下的值之前,往往不知道这个值有多长。所以有些比较偷懒的程序员总是定义一个足够的大小的空间(比如512字节)。这样的坏处是浪费内存(一般都是在堆栈中定义,而内核编程中堆栈空间被耗尽又是另一个常见的蓝屏问题)。此外也无法避免值实际上大于该长度的情况。为此应该耐心的首先获取长度,然后不足时再动态分配内存进行读取。下面是示例代码:
// 要读取的值的名字
UNICODE_STRING my_key_name =
RTL_CONSTANT_STRING(L”SystemRoot”);
// 用来试探大小的key_infor
KEY_VALUE_PARTIAL_INFORMATION key_infor;
// 最后实际用到的key_infor指针。内存分配在堆中
PKEY_VALUE_PARTIAL_INFORMATION ac_key_infor;
ULONG ac_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 。这个函数的原型如下:
NTSTATUS ZwSetValueKey(
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_STRING name = RTL_CONSTANT_STRING(L”Test”);
PWCHAR value = { 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(
OUT PLARGE_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
);
读者需要实际应用一下来加深印象。下面写出一个函数:这个函数返回一个字符串。这个字符串写出当前的年、月、日、时、分、秒,这些数字之间用“-”号隔开。这是一个很有用的函数。而且同时用到上面三个函数,此外,请读者回忆前面关于字符串的打印的相关章节。
PWCHAR MyCurTimeStr()
{
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:
KTIMER my_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说明文档中标明的中断级要求。)
这些事情非常的烦恼,因此要完全实现定时器的功能,需要自己封装一些东西。下面的结构封装了全部需要的信息: