1.2 编写第一个C文件
1.2.1 通过Visual Studio新建工程
内核驱动开发常用语言为C语言,编写内核C语言文件不一定要求使用Visual Studio,但是Visual Studio功能强大,强烈建议读者安装。下面将介绍如何通过Visual Studio来编写驱动。
请打开Visual Studio,在左上角的菜单中选中“文件(F)”→“新建(N)”→“项目(P)”,在弹出的对话框中的左侧,找到并选中“Windows Drivers”,在右侧的工程模板中选择“Empty WDM Driver”,工程名字输入:FirstDriver,如图1-7所示。
图1-7 选择工程模板
点击“确定”按钮后完成项目的新建,然后在菜单中找到“项目(P)”→“添加新项”,在弹出的对话框中选择“C++文件(.cpp)”,在下方的名称(N)中输入“First.c”,最后点击“添加”,如图1-8所示。
操作过程比较简单,现在已经可以看到工程内存在一个空白的First.c文件,开发者可以往这个空白文件中添加内核代码,但在添加代码前,需要包含驱动开发的头文件ntddk.h。
图1-8 添加C文件
1.2.2 内核入口函数
具有Windows应用层(用户态)开发经验的读者应该清楚,Windows应用层程序有统一的WinMain入口函数,类似于应用层,内核驱动也有一个统一的入口函数,名字叫DriverEntry,DriverEntry函数的原型如下:
首先为读者介绍DriverEntry的参数,一共有两个。
第一个参数为DriverObject,表示一个驱动对象的指针,刚接触内核编程的读者可能不太了解什么是驱动对象,没有关系,这个暂时不影响读者学习,读者可以先简单认为,一个驱动文件(sys)运行之后,操作系统在内存中为该驱动分配了一个类型为DRIVER_OBJECT的数据结构,用于记录该驱动的详细信息,DriverEntry的第二个参数,就表示当前驱动所对应的驱动对象指针;
第二个参数RegistryPath是一个类型为UNICODE_STRING的指针,表示当前驱动所对应的注册表位置。UNICODE_STRING是内核中表示字符串的结构体,对应定义如下:
其中Buffer为一个指针,指向一个UNICODE类型的字符串缓冲区;MaximumLength表示Buffer所指向缓冲区的总空间大小,一般等于Buffer被分配时的内存大小,单位为字节;Length表示Buffer所指向缓冲区中字符串的长度,单位也是字节。请注意,Buffer指向的字符串,并不要求以'\0'作为结束,在大多数情况下,Buffer指向的字符串没有以'\0'结尾。关于UNICODE_STRING的具体内容,请看下面一个具体的例子,笔者使用WINDBG调试工具,打印了一个UNICODE_STRING的值:
在这个例子中,0x00000000`0027f220是UNICODE_STRING的地址,读者可以忽略,在UNICODE_STRING这个结构体中,Buffer的值是0x00000000`003b0fa0,表示一个缓冲区的首地址,缓冲区内的字符串内容是"\??\VMCI",这个缓冲大小是MaximumLength,即0x21a字节,其中缓冲区内的字符串大小为0x10字节(不包含'\0'),读者可以自行验证一下。
回到原先的话题,RegistryPath为PUNICODE_STRING类型的参数,表示的是这个驱动所对应注册表的位置,这是什么意思呢?这是因为内核驱动是作为Windows系统服务(Service)存在的,Windows系统有众多服务,如果从服务运行的环境来分区,服务分为用户态服务(如Windows更新服务、DHCP服务),以及内核态服务,但无论何种服务,都统称为“服务(Service)”,不同服务通过服务的名字来识别,服务的名字简称“服务名”。在安装操作系统后,系统会内置一系列服务,这些服务称为系统服务,开发者可以开发属于自己的服务,称为第三方服务。一个驱动SYS文件需要运行(加载到内核中),首先需要把这个驱动文件注册(创建)成一个服务(第三方服务),注册成功后,系统会把该服务信息写入到注册表HKEY_LOCAL_MACHINE\ SYSTEM\CurrentControlSet\Services下,以服务的名字作为一个注册表的键名,如图1-9所示。
图1-9 服务对应注册表的位置
关于服务如何安装、启动、停止以及卸载,在后面章节会有详细的介绍,现阶段请读者务必理解清楚驱动文件、服务,以及服务对应注册表三者之间的关系。在本例中,假设驱动对应的服务名字为FirstDriver,那么RegistryPath的路径应该为“HKEY_LOCAL_MACHINE\ SYSTEM\ CurrentControlSet\Services\FirstDriver”,但是在实际情况下,RegistryPath的字符串值和上述字符串值会有些偏差,这是因为内核态与用户态对注册表路径的表示方式有差异。
上面已经为读者介绍了DriverEntry的参数含义,下面为读者介绍DriverEntry的返回值,DriverEntry的返回值类型为NTSTAUTS,而NTSTAUTS定义为:
由此可见,DriverEntry的返回值实际上是一个LONG类型,Windows操作系统规定DriverEntry返回STATUS_SUCCESS表示成功,返回其他值表示失败。STATUS_SUCCESS实际上是一个宏定义,具体定义为:
对刚接触内核驱动的读者来说,可能不太理解上述的成功与失败的深层含义,简单来说,内核驱动作为Windows服务运行,在执行具体代码前,驱动SYS文件首先会被映射到内核地址空间,作为内核的一个驱动模块(MODULE),接着系统对这个驱动模块执行导入表初始化、修正重定位表中对应的数据偏移等操作,最后系统会调用该驱动模块的DriverEntry入口函数,如果这个入口函数返回STATUS_SUCCESS,系统认为这个驱动初始化成功;如果这个入口函数返回除STATUS_SUCCESS以外的其他值,系统认为驱动初始化失败,系统执行一系列的清理工作,并把驱动模块从内核空间中移除,从用户态角度看,就是服务启动失败。
1.2.3 编写入口函数体
下面给出一个最简单的DriverEntry的编写例子:
在上面的代码中,第一行包含了内核开发所需的头文件ntddk.h,接下来是一个DriverUnload函数,最后是驱动的入口函数DirverEntry。
首先分析DriverEntry函数,该函数内部使用DbgPrint函数打印一条日志,DbgPrint函数是WDK提供的API(应用程序编程接口),类似应用层的OutputDebugString。DbgPrint与C语言的printf使用基本一样。
与DbgPrint函数功能类似的是KdPrint函数,但KdPrint函数只是针对DEBUG版本的驱动有效,关于驱动DEBUG版本的介绍,请参考下一节。
在第一个DbgPrint调用函数中,"[%ws]Hello Kernel World\n "为格式化字符串,表示输出的具体日志内容,该字符串包含了需要格式化的字段,其中%ws表示打印一个以'\0'结束的UNICODE的字符串(注意,不是UNICODE_STRING);__FUNCTIONW__是以'\0'结束的UNICODE字符串,表示当前函数的名字,对应格式化字符串中的%ws。
DriverEntry函数接下来判断两个参数值是否为NULL,不为NULL的情况下打印两个参数值,请读者注意,由于RegistryPath是UNICODE_STRING类型,打印该类型字符串需要用%wZ的方式来打印,特别指出,不能通过以下方式打印UNICODE_STRING:
原因上面已经提及,UNICODE_STRING结构体内Buffer指向的字符串,结尾不一定有'\0',而对于%ws类型来说,会一直寻找Buffer字符串的'\0',在这种情况下,行为是不可预料的。
DriverEntry函数除了打印一系列信息,还有一个重要的操作:
DriverUnload是DriverObject结构体中的一个成员,相信读者对DriverObject已经不陌生了,DriverObject表示当前的驱动对象,记录了当前驱动的详细信息,DriverUnload为驱动对象结构体内的一个函数指针。
前面提过,驱动是作为服务方式运行的,服务可以被启动,也可以被停止,停止的实质就是系统把该驱动模块对应在内核地址空间中的代码以及数据移除。当一个内核驱动被要求停止时,DriverObject→DriverUnload指向的函数就会被系统调用,开发者可以在这个函数中执行一些清理相关的工作。举一个例子,假设驱动A内部启动了一个线程B,当驱动A被要求停止时,如果开发者没有在DriverUnload函数中停止线程B,一旦驱动A被停止,线程B对应驱动A的代码已被系统删除,线程B在执行过程会触发缺页异常,最终导致系统异常。
DriverUnload函数非常重要,但重要并不等于必须,DriverUnload函数是可选的,开发者可以不提供DriverUnload函数,这样做的结果是该驱动不支持停止,也就是说,只要开发者不提供DriverUnload函数,这个驱动对应的服务一旦启动后,再也无法停止。该特性被很多安全软件利用,刻意不提供DriverUnload函数,避免驱动被恶意停止。
DriverEntry函数执行一系列操作后,最后返回STATUS_SUCCESS,表示驱动初始化成功。本章前面介绍过,DriverEntry函数返回除STATUS_SUCCESS以外的其他值时,表示驱动初始化失败,系统发现驱动初始化失败会移除内核地址空间的驱动代码与数据,这个操作看起来与驱动服务的停止非常类似,但是请读者注意:驱动初始化失败不会触发DriverUnload函数的调用,DriverUnload只有在驱动服务成功启动(初始化)后,被要求停止时才会触发,请读者谨记。