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

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

本章学习要点:

1:通用内核编程指南

2:debug和release版本的区别

3:内核API

4:函数和错误代码

5:字符串

6:动态内存分配

7:内核驱动对象

8:设备对象

1 内核编程注意事项

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

 

 User ModeKernel 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蓝屏出现在开发者的电脑里,通常是将另一台虚拟机弄来测试和调试,因为调试的断点打在系统上,直接会让系统停下来无法运行。

 

2 构建Debug和Release版本的区别

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

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

3 The Kernel API 内核API

写的内核驱动程序可以使用已经存在的一些内核组件中提供的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

4 Functions and Error Code 函数和错误代码

大部分的内核代码都会有返回值来表示是否操作成功,返回值的类型被定义为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;
}

 

5 strings 字符串

大部分情况下内核采用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 等。

6 Dynamic Memory Allocation动态内存分配

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

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

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

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

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

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

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

FunctionDescription
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<ntddk.h>#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;
​
}

 

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

7 Lists链表

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

所有的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结构体的指针了,就可以用它来处理了。

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

FunctionDescription
InitializeListHead 初始化一个列表头来创建一个空链表,前面指针互相只向后前指针
InsertHeadList 在链表最前面插入
InsertTailList 在链表最后面插入
IsListEmpty 判断链表是否为空
RemoveHeadList 删除头部节点
RemoveTailList 删除尾部节点
RemoveEntryList 删除特定内容
ExInterlockedInsertHeadList 使用指定的自旋锁原子地在列表的头部插入一个项目。
ExInterlockedInsertTailList 使用指定的自旋锁原子地在列表的尾部插入一个项目。
ExInterlockedRemoveHeadList 使用指定的自旋锁原子地在列表的头部删除一个项目。

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

8 The Driver Object驱动对象

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

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

要初始化的另一组重要操作被称为Dispatch Routines调度实例,这个是一个函数指针数组,位于DRIVER_OBJECT的MajorFunction成员中,这一组操作指定驱动程序支持哪些特定操作,例如:创建、读取、写入等。这些函数指针的前缀是由IRP_MJ_开始的。

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

MajorFunction数组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操作,这将允许为驱动程序打开一个设备对象的句柄。

9 Device Objects设备对象

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

typedef struct _DEVICE_OBJECT {
  CSHORT                   Type;
  USHORT                   Size;
  LONG                     ReferenceCount;
  struct _DRIVER_OBJECT    *DriverObject;
  struct _DEVICE_OBJECT    *NextDevice;
  struct _DEVICE_OBJECT    *AttachedDevice;
  struct _IRP              *CurrentIrp;
  PIO_TIMER                Timer;
  ULONG                    Flags;
  ULONG                    Characteristics;
  __volatile PVPB          Vpb;
  PVOID                    DeviceExtension;
  DEVICE_TYPE              DeviceType;
  CCHAR                    StackSize;
  union {
    LIST_ENTRY         ListEntry;
    WAIT_CONTEXT_BLOCK Wcb;
  } Queue;
  ULONG                    AlignmentRequirement;
  KDEVICE_QUEUE            DeviceQueue;
  KDPC                     Dpc;
  ULONG                    ActiveThreadCount;
  PSECURITY_DESCRIPTOR     SecurityDescriptor;
  KEVENT                   DeviceLock;
  USHORT                   SectorSize;
  USHORT                   Spare1;
  struct _DEVOBJ_EXTENSION *DeviceObjectExtension;
  PVOID                    Reserved;
} DEVICE_OBJECT, *PDEVICE_OBJECT;

 

创建设备对象需要用到API:

NTSTATUS IoCreateDevice(
  PDRIVER_OBJECT  DriverObject,//设备对象绑定的驱动对象
  ULONG           DeviceExtensionSize,//设备对象的大小,采用0用默认的就好
  PUNICODE_STRING DeviceName,//设备对象的名称
  DEVICE_TYPE     DeviceType,//设备对象的类型,没有指定什么设备的对象就用FILE_DEVICE_UNKNOWN就好
  ULONG           DeviceCharacteristics,//设备对象的特征,默认用0就行
  BOOLEAN         Exclusive,//True表示在内核模式下使用,一般都是True
  PDEVICE_OBJECT  *DeviceObject//接受设备对象的指针
);
//创建设备对象的例子
    PDEVICE_OBJECT DeviceObject = nullptr;
    UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\test");
    status = IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, TRUE, &DeviceObject);

 

那么如何利用设备对象来进行交互呢?其实很多时候你都用到了只是你不知道,在Windows下的和文件相关的内容都是和设备对象进行交互了,比如:CreateFile,ReadFile,WriteFile这些操作Windows文件的API。

比如说:CreateFile

HANDLE CreateFileA(
  LPCSTR                lpFileName,
  DWORD                 dwDesiredAccess,
  DWORD                 dwShareMode,
  LPSECURITY_ATTRIBUTES lpSecurityAttributes,
  DWORD                 dwCreationDisposition,
  DWORD                 dwFlagsAndAttributes,
  HANDLE                hTemplateFile
);

 

这里的第一个参数,lpFileName就是设备对象了,只是说一般我们在User模式下是用的符号链接符号链接可以想象成一种快捷方式,相当于设备对象的一个别名,专门用来给User下使用设备对象准备的。User模式下识别设备只能通过符号链接或者接口设备来使用,但是接口设备一般用的很少。设备对象的名称只能被Kernel模式的驱动识别,而符号链接可以被Kernel和User识别。比如说常用的C盘、D盘就是符号链接。所谓的C盘指的是名为"C:"的符号链接,而真实的设备对象是 \Device\HarddiskVolume1。注意设备对象的名称只能用\Device\开头。而符号链接对象在内核模式下是以 \??\或者是\DosDevices\开头的比如:\??\test,在User模式下符合链接就是以 \.\开头的比如说 ”\.\C: “。

一个创建设备对象和建立符号链接的例子:

    PDEVICE_OBJECT DeviceObject = nullptr;
    UNICODE_STRING symLink = RTL_CONSTANT_STRING(L"\\??\\test");
    UNICODE_STRING devName = RTL_CONSTANT_STRING(L"\\Device\\test");
    IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, TRUE, &DeviceObject);
    IoCreateSymbolicLink(&symLink, &devName);
​
  CreateFile("\\\\.\\test.txt",GENERIC_READ,0,NULL,OPEN_EXISTING,0,NULL);//因为user下有转义字符\的存在。

 

 

 

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

 

 

总结

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