Windows内核开发-3-内核编程基础
阅读原文时间:2021年07月22日阅读:1

Windows内核开发-3-内核编程基础

这里会深入讲解kernel内核的API、结构体、和一些定义。考察代码在内核驱动中运行的机制。最后把所有知识合在一起写一个有用的驱动。

本章学习要点:

1:通用内核编程指南

2:debug和release版本的区别

3:内核API

4:函数和错误代码

5:字符串

6:动态内存分配

7:内核驱动对象

8:设备对象

内核编程依赖于WDK(Windows Driver Kit)Windows驱动工具包,这个东西存放了大量头文件和第三方库。内核的API由C构成,本质上内核开发和用户态开发非常相似,但是还是有一些不同,比如:

 

User Mode

Kernel Mode

Unhandled Exception未处理异常

未处理异常会导致进程崩溃

未处理异常会导致系统崩溃

Termination 终止

当一个进程中止时,会自动释放内存和资源。

当一个驱动卸载时,如果没有释放掉它运行时所用的所有内容,都会导致泄露,只有重启后会自动解决。

return values 返回值

函数的返回值错误有时可以忽略

永远不要忽略任何错误

IRQL中断请求级别

在PASSIVE_LEVEL(0)级别,级别比较低

可能是在DISPATCH_LEVEL(2)或更高级别

Bad coding坏代码

通常用在进程里面处理

影响可以扩大到整个系统

Testing and Debugging测试和调试

通常都在主机测试和调试

必须在其他机器上进行调试

Libraries

可以使用绝大部分的C/C++库(例如stl这中)

绝大部分不能用

Exception Handleing异常的句柄

可以用C/C++里面的异常也可以使用SEH(Windows中的)

只能用SEH

C++ Usage

完全支持C++的用法

不支持C++

1.1 Unhandled Exceptions未处理的异常

在用户态下写的程序出现了异常就直接结束进程就完事了,但是如果在内核态这种问题会导致系统崩溃出现蓝屏。

其实蓝屏也是一种保护机制表示如果继续往下执行就会造成很严重的后果。

1.2 Termination终止

当一个User的进程被关闭时,不管怎么关闭的,都不会导致任何的泄露和系统问题。

但是如果是驱动程序就不一样了,如果驱动程序正常关闭但是unload函数里面没有释放前面保留的内容和数据就会导致泄露,只有在重启后才会解决该问题。

1.3 return value返回值:

在user下的开发中,忽略返回值是经常干的事情,比如有时候嫌麻烦就直接用void随便怎么返回。但是在内核下忽略返回值是一个非常危险的情况,应该避免这样的情况出现,所以内核编程中有一点千万记住,就是 始终检查内核API返回值

1.4 IRQL 中断请求级别

IRQL在内核开发中是一个非常重要的概念,在User的代码执行下它始终为0,在kernel下也经常为0,但是也可以不是0,也就是说kernel下这个级别可以提升。

高于0的IRQL后面再提。

1.5 C++ Usage用法

在User下,C++已经完美支持调用Windows API了。在内核中C++用得比较少,但是有一些使用资源的用法较弱( Resource Acquisition Is Initialization 资源获取即初始化)RALL用法很常用,可以防止资源泄露。

C++是完美支持内核的,但是由于内核中没有C++的运行示例,所以有一些C++的操作无法实行:

1 new和delete:

new和delete都是从user态的堆里面来获取资源,这显然对kernel没用。kernel的API更接近于C语言的malloc和free这样的操作,当然要实现像user态的各种C++特性后面也会提到如何实现。

2 不会调用没有默认构造函数的全局变量。解决办法:

A: 开辟构造函数,但是构造函数里面没有实际代码,只是调用init()函数,再在init函数里写好了。

B:只把指针作为全局变量,利用指针来动态创建

3:C++中的异常长处理不支持(try,catch,throw),因为Kernel只支持SEH

4:不支持C++标准库

驱动用纯C来写没有任何问题,但是也可以采用C/C++。

1.6 Testing and Debugging测试和调试

通常开发user下的程序,直接在本机搞就好。如果是调试通常是将进程附加到调试器上(如vs 2019)。

而在kernel下不行,为的是防止BSOD蓝屏出现在开发者的电脑里,通常是将另一台虚拟机弄来测试和调试,因为调试的断点打在系统上,直接会让系统停下来无法运行。

和在User下开发很类型,Debug版本更适合调试,而Release版本利用编译器来优化生成尽可能高效的代码。但是还是有一些区别的,有一些内核文档用Checked和Free版本来形容Debug和Release,如果看到了不要惊慌。

从编译器的角度来看,Debug版本下会有一些宏定义,会宏定义DBG来区别Debug和release如果设置为1表示是debug。这个其实导致的最重要的就是Kdprint可以使用了,在debug版本下Kdprint会调用dbgprint来输出信息,但是在release就会忽略掉kdprint这个函数。

写的内核驱动程序可以使用已经存在的一些内核组件中提供的API,这个函数被称为内核API。大多数的API由内核模块本身NtOskrnl.exe实现,但是有的也是来自别的模块(例如hal.dll)。

内核API的内部是一大堆C函数,大多数的函数的前缀表明了实现该函数的内核组件。

以下是常见的Kernel内核API:

Prefix前缀

Meaning意义

Example 示例函数

Ex

通用的执行函数

ExAllocatePool

Ke

通用的内核函数

KeAcquireSpinLock

Mm

内存管理函数

MmProbeAndLockPages

Rtl

通用的库函数

RtlInitUnicodeString

FsRtl

文件系统调用库

FsRtlGetFileSize

Flt

文件系统过滤库

FltCreateFile

Ob

对象管理的操作函数

ObReferenceObject

Io

I/O设备的管理

IoCompleteRequest

Se

安全函数

SeAccessCheck

Ps

有关进程结构的函数

PsLookupProcessByProcessId

Po

电池管理函数

PoSetSystemState

Wmi

Windows管理工具

WmiTraceMessage

Zw

本机API打包器

ZwCreateFile

Hal

硬件抽象层相关函数

HalExamineMBR

Cm

注册表相关函数

CmRegisterCallBackEx

大部分的内核代码都会有返回值来表示是否操作成功,返回值的类型被定义为NTSTATUS,是一个32位的有符号数,返回值STATUS_SUCCESS(0)表示成功,返回负数表示失败,具体的失败类型可以通过ntstatus.h里面查看宏定义来确定失败类型。

大多数代码并不关系错误的根本原因,只需要知道是否是负数就行,对于这种只需要关心最高有效位是否为负就好。

这个可以用NT_SUCCESS宏来确定是否为负。例如:

NTSTATUS Test(PRTL_OSVERSIONINFOW lp)
{
NTSTATUS status = AnyFuncion(lp);
if (NT_SUCCESS(status))
{
KdPrint(("Error occurred: 0x%08x\n", status));
return status;
}
return STATUS_SUCCESS;
}

大部分情况下内核采用unicode指针的形式来使用字符串(wchar_t* 或者WCHAR)但是很多函数期待用UNICODE_STRING。

Unicode可以大致看作为UTF-16,意味着每个字符有2个字节。这是内核的内部组成字符串的方式。

UNICODE_STRING类型标识一个字符串可以知道它的长度和最大长度。它的简单定义如下:

typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWCH Buffer;//wchar的指针
} UNICODE_STRING;
typedef UNICODE_STRING *PUNICODE_STRING;
typedef const UNICODE_STRING *PCUNICODE_STRING;

UNICODE_STRING是以字节而不是字符为单位,并且不包括UNICODE-NULL终结符,如果终结符存在,则MaximumLength是字符串可以增长到的最大字节数,而无需重新分配内存。操作UNICODE_STRING字符串通常是用一组专门处理该字符串的Rtl函数来完成。

以下是一部分操作UNICODE_STRING字符串函数:

Function函数

Description描述

RtlInitUnicodeString

基于C系列的字符串指针初始化UNICODE_STRING字符串,设置Buffer,计算Length长度,然后把MaximumLength设置为相同的值。它不分配内存,只是把现有的初始化

RtlCopyUnicodeString

把UNICODE_STRING字符串拷贝给另一个UNICODE_STRING字符串,拷贝的字符串必须在拷贝前就开辟好空间,设置好内部的MaximumLength字段

RtlCompareUnicodeString

比较UNICODE_STRING字符串(大于小于或等于),还可以指定是否区分大小写

RtlEqualUnicodeString

比较两个UNICODE_STRING是否相等,区分大小写。

RtlAppendUnicodeStringToString

将一个UNICODE_STRING附加到另一个UNICODE_STRING后面。

RtlAppendUnicodeToString

将一个UNICODE_STRING附加到C样式字符串上。

内核中还有一些函数可以处理C系列的字符串,为了方便C的运行库中也在内核里实现了一些常用的字符串如:wcscpy、wcscat、wcslen、wcscpy_s、wcschr、strcpy、strcpy_s 等。

内核的栈空间非常小,所以任何大的内存卡都应该动态分配。

内核提供了两个通用的内存池来给驱动使用:

A:Paged pool页面池:如果需要可以被分页的内存池

B: Non Paged Pool 非页面池:保留在RAM中永远不会被分页的内存池。

很明显地可以看出来Non Paged Pool非页面池更好,因为它不会导致页错误,但是使用该区域要谨慎使用,比较普通的情况还是使用Paged pool页面池比较好。

POOL_TYPE这个枚举变量表示内存池的类型,该枚举类保存了很多种内存池,但是只有三种可以用:PagedPool页面池,NonPagePool非页面池,NonPagePoolNx(非页面池且没有执行权限)。

处理内存池最有用的函数:

Function

Description

ExAllocatePool

这个函数过时了

ExAllocatePoolWithTag

从指定标签的内存池中分配内存

ExAllocatePoolWithQuotaTag

从指定标签的内存池分配内存,并分配当前进程的内存池配额。

ExFreePool

释放分配的内存,该函数自动释放不用管是什么类型的。

一些函数中的tag参数允许用4字节的值来标记分配的内存,通常这个值由4个ASCII字符组成,用来在逻辑上表示驱动程序或驱动程序的某些部分。这些标记常用来表示内存是否泄露(如果再卸载驱动后仍有任何标记该驱动程序的标记分配内存就表示有泄露)。

可以使用一些工具来查看这个标记的tag: Poolmon WDK tool, or PoolMonX tool (downloadable from http://www.github.com/zodiacon/AllTools).

以下代码是对分配内存给字符串,然后字符串复制注册表内容给DriverEntry,然后再在unload实例程序中释放该字符串:

#include

#define DRIVER_TAG 'dcba' //定义一个标签,由于小字节序,在PoolMan中看到的是abcd
UNICODE_STRING g_RegistryPath;//定义一个UNICODE_STRING字符串

void SampleUnload(_In_ PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);//防止这个参数没有被使用而报错。

ExFreePool(g_RegistryPath.Buffer);//释放申请的内存

KdPrint(("Sample driver Unload called\n"));
}

extern"C"
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath)
{
DriverObject->DriverUnload = SampleUnload;//定义Unload函数地址
g_RegistryPath.Buffer = (WCHAR*)ExAllocatePoolWithTag(PagedPool, RegistryPath->Length, DRIVER_TAG);
//分配一个字符串,内存池类型是PagedPool,页面内存池
//长度是注册表的长度,分配好的内存的标签栏的内容的DRIVER_TAG
if (g_RegistryPath.Length == 0)
{
KdPrint(("Failed to allocate memory\n"));
return STATUS_INSUFFICIENT_RESOURCES;
}
g_RegistryPath.MaximumLength = RegistryPath->Length;//将最大值赋值为它的长度防止泄露


RtlCopyUnicodeString(&g_RegistryPath, (PCUNICODE_STRING)RegistryPath);//把注册表的内容复制给g_Registrty

KdPrint(("Copied registry path: %wZ\n", &g_RegistryPath));//%wZ是给UNICODE_STRING输出的标准格式符。

return STATUS_SUCCESS;

}

这个自己拿去调试就好了。

内核中的许多内部结构都采用循环双向链表。

所有的List都用以下类型的结构构建:

typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY;

比如:

如果你要构建自己的双向链表你可以采取这种格式:

struct MyDataItem {
// some data members
LIST_ENTRY Link;//这个就是前面的链表指针结构体
// more data members
};

当真正执行代码来跑的时候,我们会有一个表头,存在某一个变量里面,因为这个表头的Windows自己定义的所以我们无法强行把它转换变成别的,但是Windows提供了一个宏定义帮助我们处理,我们在使用链表时只能把头指针继续执行Link里面的数据,那么我们要取整个结构体的数据怎么办呢?Windows提供了宏定义来帮助我们:

MyDataItem* GetItem(LIST_ENTRY* pEntry) {
return CONTAINING_RECORD(pEntry, MyDataItem, Link);
}

这个返回值就是一个我们自己定义的MyDataItem结构体的指针了,就可以用它来处理了。

以下是常用的循环双向链表函数:

Function

Description

InitializeListHead

初始化一个列表头来创建一个空链表,前面指针互相只向后前指针

InsertHeadList

在链表最前面插入

InsertTailList

在链表最后面插入

IsListEmpty

判断链表是否为空

RemoveHeadList

删除头部节点

RemoveTailList

删除尾部节点

RemoveEntryList

删除特定内容

ExInterlockedInsertHeadList

使用指定的自旋锁原子地在列表的头部插入一个项目。

ExInterlockedInsertTailList

使用指定的自旋锁原子地在列表的尾部插入一个项目。

ExInterlockedRemoveHeadList

使用指定的自旋锁原子地在列表的头部删除一个项目。

注:自旋锁原子(specified spinlock)后面讲

前面的DriverEntry函数的第一个参数其实就是一个驱动对象。驱动对象在WDK头文件中定义,被称为半文档化的结构体DRIVER_OBJECT。半文档化的意思是一部分内容可以查得到有文档记录而另一部分没有。该结构体由内核自己来分配并且部分初始化,然后提供给DriverEntry,由编写的驱动程序来进一步初始化该结构体,来指示驱动程序支持的操作。

前面写的各种demo里面由一个unload实例函数,该函数被称为驱动程序的一个操作。

驱动程序要执行的另外一个重要操作就是初始化操作,该操作被称为调度实例(Dispatch Routines)。

一些常见的函数代码和意义:

Major Function主要函数

Descript

IRP_MJ_CREATE(0)

创建操作,通常为 CreateFile 或 ZwCreateFile 调用。

IRP_MJ_CLOSE(0)

关闭操作,通常由CloseFile或ZwCloseFile调用。

IRP_MJ_READ(3)

读操作,通常被ReadFile、ZwReadFile和其类似的读取API调用

IRP_MJ_WRITE(4)

写操作,通常被WriteFile、ZwWriteFile和其类似的API调用

IRP_MJ_DEVICE_CONTROL(14)

对驱动程序的通用调用,由于 DeviceIoControl 或 ZwDeviceIoControlFile 调用而调用。

IRP_MJ_INTERNAL_DEVICE_CONTROL(15)

与前一个类似,但仅适用于内核模式调用者。

IRP_MJ_PNP(31)

即插即用回调由即插即用管理器调用。 通常对基于硬件的驱动程序或 过滤这些驱动程序。

IRP_MJ_POWER(22)

电源管理器调用的电源回调。 通常对基于硬件的驱动程序或此类驱动程序的过滤器很感兴趣。

在最开始的时候,MajorFunction函数数组由内核初始化,执行内核内部的实例IopInvalidDeviceRequest,这个实例函数会返回一个失败,表示所有的都没有调用。这就意味着我们的驱动程序只需要写自己需要的操作就好了,别的不用管都保留为默认值也就是没有。但是如果我们没有写任何的调度就表示我们的驱动程序无法通信也就是无法被使用起来。一个驱动程序要实用i起来必须至少支持IRP_MJ_CREATE和IRP_MJ_CLOSE操作,这将允许为驱动程序打开一个设备对象的句柄。

客户端和驱动程序对话的实际端点是设备对象,设备对象也是一个半文档化的DEVICE_OBJECT结构的实例对象。没有设备对象,驱动就没有办法连接。表示一个驱动程序至少应该创建一个设备对象来方便和client交互。

CreateFile和它的变化函数都有一个接受文件名的参数,但是实际上这里指向的其实是设备对象的名称,CreateFile这里的File其实指的是文件对象,打开一个文件或设备的会创建一个内核结构FILE_OBJECT的实例,也是一个半文档化的结构。准确的来说CreateFile接受了一个符号链接,符号链接可以理解为文件的快捷方式,该符号链接知道如何指向另一个内核对象的内核对象。所有的符号链接名字都不一样,都保存在Object Manager directory对象管理字典里。但是可以通过WinObj查看GLOBAL??目录下的内容查看。

一些常见的文件目录,像什么C://其实就是I/O系统为了调IoRegisterDeviceInterface API 基于硬件的驱动程序来生成的。

大多数符号链接在??目录(Winobj是GLOBAL??)下是指向Device目录下的内部设备名字,User调用者不能直接访问此目录中的名称,但是可以通过内核使用IoGetDeviceObjectPointer API 来访问。

驱动程序使用IoCreateDevice函数来创建设备对象,该函数初始化并分配一个设备对象结构并把指针给调用这,设备对象实例存储在DRIVER_OBJECT结构的DeviceObject成员中。如果创建多个对象就会形参一个单项链表:

一些内核编程的注意事项,以及比较重要的概念字符串,动态内存分配,链表,驱动对象和设备对象的理解,这些一时间也记不完背不完,只能说后面慢慢用慢慢记了。