今天来学习一下Windows驱动开发基础

由于之前操之过急,对驱动开发很多东西都没有了解便强行上手,导致后面困难重重,于是痛定思痛,开始推翻重来,相信之前的一些开发经验会让这一路好走一点。

环境搭建

Vmware + VirtualKD + windbg preview 做调试环境。

VS 2022 + WDK 做开发环境。

参考链接1

参考链接2

内核API的使用

对于导出的函数,只需要包含对应的头文件直接使用即可,内核 API 的返回类型几乎都是 NTSTATUS。

当你调用的内核函数,如果返回的结果不是STATUS_SUCCESS,就说明函数执行中遇到了问题,具体是什么问题,可以在ntstatus.h文件中查看。

驱动基本数据类型

WDK 对于一些标量有自己的书写习惯

WDK 习惯 SDK 习惯
ULONG unsigned long
PULONG unsigned long*
UCHAR unsigned char
PUCHAR unsigned char*
UINT unsigned int
PUNIT unsigned int*
VOID void
PVOID void*

常用的内核内存函数

即,如何使用内核的堆内存,在用户层我们知道可以使用 malloc 或者一些 windows API,就算没有库也可以使用系统调用去申请内存。但是在内核层,内核的开发环境同样支持了一系列的内存分配函数。

内存对应的操作有:分配,释放,拷贝,清空。

普通程序 内核中
malloc ExAllocatePoolWithTag
memset RtlFillMemory
memcpy RtlMoveMemory
free ExFreePool

这里有个概念需要补一下,什么是分页内存,什么是非分页内存

在使用 ExAllocatePoolWithTag 函数申请内存的时候会有POOL_TYPE PoolType这个参数。那么什么是POOL_TYPE,通过 WDK 我们可以看到定义

1
2
3
4
5
6
7
8
9
10
typedef enum _POOL_TYPE {
NonPagedPool,
PagedPool,
NonPagedPoolMustSucceed,
DontUseThisType,
NonPagedPoolCacheAligned,
PagedPoolCacheAligned,
NonPagedPoolCacheAlignedMustS
} POOL_TYPE;

用的最多的就是前两个,NonPagedPoolPagedPool,前者分配非分页内存,后者申请分页内存。什么是分页内存,前面介绍过,在 Windows 操作系统中,有 pagefile.sys 这个文件,这个文件会保存长期不使用的物理页,如果申请分页内存,那么这个页就有可能会被置换到这个文件中去。等到再次需要的时候,会通过一个 0xE 号中断将该页从 pagefile.sys 中又取出来。

非分页内存就是告诉操作系统,不要把我的申请的物理页撤走,这就是我独享的物理页。操作系统就不会把它给撤走转到文件中了。

至于有什么用,后面应该会看到。

内核字符串

内核有两种字符串类型。ANSI_STRING/UNICODE_STRING 分别表示 ASCII 字符和宽字符。

来看看它们的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _STRING
{
USHORT Length;
USHORT MaximumLength;
PCHAR Buffer;
}STRING;

typedef struct _UNICODE_STRING
{
USHORT Length;
USHORT MaxmumLength;
PWSTR Buffer;
} UNICODE_STRING;

几乎都是这样的定义:长度,最大长度,字符指针,原因就是内核需要非常安全,直接操作字符容易造成一系列不可控的后果,因此在原字符指针上再封装一层。

同样来看看字符串的基本操作的 API

创建、复制、比较以及转换等。它们的函数如下:

ANSI_STRING UNICODE_STRING
RtlInitAnsiString RtlInitUnicodeString
RtlCopyString RtlCopyUnicodeString
RtlCompareString RtlCompareUnicodeString
RtlAnsiStringToUnicodeString RtlUnicodeStringToAnsiString

驱动代码解析

还是拿最经典的 hello world 为例

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <ntddk.h>

NTSTATUS UnloadDriver(PDRIVER_OBJECT DriverObject)
{
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Bye!\n");
}

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Hello!\n");
DriverObject->DriverUnload = UnloadDriver;
return STATUS_SUCCESS;
}

DriverEntry

DriverEntry是驱动程序的入口,如果驱动加载成功后,就像Dll加载成功调用DllMain函数一样,调用该函数。

但是,编译成功后可以发现,DriverEntry 跟 main 一样,并不是程序加载最先调用的,都是间接被调用的,而真正的入口是 FxDriverEntry。

并且该函数是被导出的。

DriverEntry 的第一个参数需要来解析一下,它的类型是 PDRIVER_OBJECT,熟悉 Windows SDK 命名的应该知道,它是一个指向 DRIVER_OBJECT 的指针。

驱动文件加载之后,驱动的所有信息通过这个结构体来返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typedef struct _DRIVER_OBJECT {
CSHORT Type;
CSHORT Size;

PDEVICE_OBJECT DeviceObject;
ULONG Flags;

PVOID DriverStart;
ULONG DriverSize;
PVOID DriverSection;
PDRIVER_EXTENSION DriverExtension;

UNICODE_STRING DriverName;
PUNICODE_STRING HardwareDatabase;
PFAST_IO_DISPATCH FastIoDispatch;

PDRIVER_INITIALIZE DriverInit;
PDRIVER_STARTIO DriverStartIo;
PDRIVER_UNLOAD DriverUnload;
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];

} DRIVER_OBJECT;

可以在驱动中加个断点,来查看这个对象

这里逐步分析该结构体的各个字段意义

  • Type:类型
  • Size:结构体大小
  • DeviceObject:设备对象
  • Flags:标志位
  • DriverStart:驱动对象加载后的起始地址
  • DriverSize:驱动对象加载后的内存大小
  • DriverSection:它是一个存储目前所有已加载的驱动程序信息相关的LDR_DATA_TABLE_ENTRY结构体的双向循环链表。
  • DriverName:驱动名
  • DriverUnload:驱动对象的卸载地址,如果存在则会调用它

其余就不一一写出了。

IRQL

IRQL全称Interrupt Request Level,即中断请求等级。它是Windows自己定义的一套优先级方案,与CPU无关,数值越大权限越高。中断包括了硬中断和软中断,硬中断是由硬件产生,而软中断则是完全虚拟出来的。处理器在一个IRQL上执行线程代码,每个处理器的IRQL决定了它如何处理中断,以及允许接收哪些中断。在同一处理器上,线程只能被更高级别IRQL的线程能中断。每个处理器都有自己的中断IRQL

常见的IRQL级别有四个:PassiveAPCDispatchDIRQLPASSIVE_LEVEL是最低级别,没有被屏蔽的中断,线程执行用户模式,可以访问分页内存。

APC_LEVEL只有APC级别的中断被屏蔽,可以访问分页内存。当有APC发生时,处理器提升到APC级别,就屏蔽掉其它APC

DISPATCH_LEVEL可以屏蔽DPC(延迟过程) 和更低的中断,不能访问分页内存。

关于分页内存和非分页内存

上面提到,中断等级在 DISPATCH_LEVEL 及以上时无法访问分页内存。因为分页内存会被换到外存,如果想要加载到内存中会触发一个缺页中断,将该页重新加载进内存,该例程运行在 DISPATCH_LEVEL 的中断等级下。而这个所谓的中断是不允许同级打断的,因此在 DISPATCH_LEVEL 下访问分页内存会导致访问内存的线程一直尝试等待物理页被写入内存,而触发的中断又无法直接打断该例程,就有可能直接造成蓝屏。

而根据看雪某帖子下面的评论描述,访问分页内存的时候会同时判断 IRQL 和物理页的 valid 位,当 IRQL > APC_LEVEL 且物理页 valid=0 时,直接蓝屏。

参考文献