内核与驱动_00_内核编程基础知识
程序员文章站
2022-03-22 20:22:41
...
内核编程环境
概述
- 在Windows下,32为系统中每个进程都有其自身享有的4GB内存空间,每个进程的内存空间都是相互隔离的。其中低2GB是用户空间,高2GB是内核空间。原则上,每个进程的高2GB内核空间内的数据是共享的,即绝大部分都是一样的。
- 而内核空间是受到硬件保护的,比如在x86架构下R0层(Ring0)的代码才可以访问内核空间,普通的应用程序编译出来后都运行在R3层,R3层程序要调用R0层功能时,只能通过系统提供的一个入口(该入口中调用sysenter指令)来实现。
无处不在的内核模块
- 内核是由接口的,微软提供规定的格式,用于让硬件驱动的编程人员能够按照规定的格式编写“驱动程序”。这些驱动程序能加载到内核中,称为内核的一部分,这样内核就有了可扩展性,只要简单的安装驱动程序,就可以适应各种不同的硬件了。
- 把内核模块叫做驱动程序Dirver,也可以按照Linux程序员们的叫法,称之为内核模块(Kernel module)。
- 内核模块位于内核空间,作为R0级代码执行,可以不受任何限制,任意修改内核。
- 内核模块位于内核空间,而内核空间又被所有的进程所共享,因此内核模块实际上位于任何一个进程空间,但是任意一段代码的一次执行,一定是位于某个具体的进程空间中的,至于这个进程是哪个,这取决于当时的运行状况。
- 一个函数
PsGetCurrentprocessId
,能够得到当前进程的进程号,函数返回进程HANDLE句柄,实际上就是一个进程的PID。 - 一个误区:认为所有内核代码都运行在系统进程内:windows中的系统进程是一个名为“System”的进程,是Windows自身生成的一个特殊进程,在XP中这个进程的PID始终为4 。DirverEntry函数被调用时,一般都位于系统进程中,这是因为Windows一般都使用系统进程来加载内核模块,并不是说内核代码始终运行在System进程中。
- 使用微软提供的驱动开发包WDK进行开发。
数据类型
基本数据类型
-
在进行内核编程时,应当遵守WDK的编码习惯,这样可以使得代码在不同的目标平台上编译不会产生不一致的问题。
-
WDK中已经将很多数据类型向SDK那样重新定义过,这样做的好处是,万一有了什么问题,再重新定义一下即可,不至于使得代码产生不可控的问题。
-
以下是一些需要习惯使用的数据类型:
- unsigned long ---->ULONG
- unsigned char ---->UCHAR
- unsigned int ---->UINT
- void ---->VOID
- unsigned long* ---->PULONG
- unsigned char * ---->PUCHAR
- unsigned int * ---->PUINT
- void* ---->PVOID
-
一般我们使用x86和x64平台进行编译,它们的区别除了指针从四个字节变为了8个字节之外,其余几种类型字节的宽度都没有什么变化。
返回状态
- 绝大部分的内核API的返回值都是一个返回状态,就是一个错误码。类型为
NTSTATUS
。如下示例:
NTSTATUS MyFun()
{
NTSTATUS status;
//打开一个文件...
status=ZwCreateFile(..);
//宏NT_SUCESS()用来判断一个返回值是否成功
if(!NT_SUCESS(status))
{
//出错就返回错误码
return status;
}
}
- 返回的错误码完全由编写者说了算,不过错误码有一些约定成俗的含义,如下一些示例:
字符串
- 驱动字符串一般用一个结构来容纳,定义如下:
typedef struct _UNICODE_STRING{
USHORT Length; //字符串的长度,单位是字节数
USHORT MaximumLength; //最大字节数
PWSTR Buffer;
}UNICODE_STRING,*PUNICODE_STRING;
//字符串的字符是宽字符,双字节的
- UNICODE_STRING是可以直接打印的,可以使用如下的写法:
//定义和输出字符串
UNICODE_STRING buff = RTL_CONSTANT_STRING(L"使用字符串结构体的字符串");
DbgPrint("%wZ\n", &buff); //输出使用%wZ
-
UNICODE_STRING结构体的指针(注意是指针,结构体本身不能打印)可以用%wZ来打印。
-
UNICODE_STRING除了可以用于初始化和打印之外,其它普通字符串的操作也可以同样做到。
重要的数据结构
- Windows内核编程中有三个重要的概念:
- 驱动对象
- 设备对象
- IRP
驱动对象
- Windows内核采用了面对对象的编程方式,但使用的却是C语言。所以Windows内核中所谓的内核对象并不是一个C++类对象,而是一种使用C语言对面对对象编程方式的一种模拟。
- 在Windows中很多东西都是“对象”,比如一个驱动、一个设备等,“对象”在这里相当于一个基类。
- 驱动对象代表着当前编写的驱动程序,从面对对象的角度来看就如同Windows编程时的示例句柄INSTANCE代表一个应用程序一样。
- 当驱动一加载,对象管理器便会创建一个驱动对象,并调用DriverEntry将驱动对象传入,然后我们就可以在DriverEntry中为驱动对象结构体的各个字段赋值,为整体的驱动程序定下总的基调。
- 驱动对象的结构如下:
typedef staruct _DRIVER_OBJECT{
//结构的类型和大小
SHORT Type; //0x0
SHORT Size; //0x2
//设备链
struct _DEVICE_OBJECT* DeviceObject; //0x4
ULONG Flags; //0x8
VOID* DriverStart; //0xc
ULONG DriverSize; //0x10
VOID* DriverSection; //0x14
//驱动扩展,对于WDM程序比较重要
struct _DRIVER_EXTENSION* DriverExtension; //0x18
//驱动名称
struct _UNICODE_STRING DriverName; //0x1c
//设备的硬件数据库名称
struct _UNICODE_STRING* HardwareDatabase; //0x24
//文件驱动中的快速IO请求函数地址
struct _FAST_IO_DISPATCH* FastIoDispatch; //0x28
//初始化函数指针
PDRIVER_INITIALIZE DriverInit; //0x2c
//DriverStartIo派发函数的地址
PDRIVER_STARTIO DriverStartIo; //0x30
//卸载函数指针
PDRIVER_UNLOAD DriverUnload; //0x34
//30个分发函数
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION+1]; //0x38
}DRIVER_OBJECT,*PDRIVER_OBJECT;
- 要注意的是内核模块并不生成一个进程,只是填写一组回调函数让Windows来调用,而且这组回调函数必须符合windows内核的规定。包括上面所述的“普通分发函数”和“快速IO分发函数“,这些函数用来处理发送给这个内核模块的请求,一个内核模块的所有功能都由它们提供给Windows。
- 双向链表的LIST_ENTRY结构如下:
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink; //指向下一个链表
struct _LIST_ENTRY *Blink; //指向上一个链表
} LIST_ENTRY, *PLIST_ENTRY, *RESTRICTED_POINTER PRLIST_ENTRY;
-
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION+1]
-驱动对象的派遣函数指针数组中不同的下标保存着不同的函数,下标列出了每个元素的下标(宏)所保存的函数指针(这个数组内的函数指针都可以设置为NULL):
名称 | 调用时机 | 触发调用的API |
---|---|---|
IRP_MJ_CLEANUP |
当本驱动对象的设备对象句柄被关闭(且引用计数为0),但仍有I/O请求未完成,此函数会被调用,可以在此函数中清理未完成的I/O请求 | CloseHandle |
IRP_MJ_CLOSE |
当本驱动对象的设备对象句柄被关闭(且引用计数为0),并且I/O请求已经被完成或已经全部取消。此函数会被调用,此函数相当于设备对象的析构函数 | CloseHandle |
IRP_MJ_CREATE |
当本驱动对象的设备对象被打开(通过CreateFile/ZwCreateFile ),此函数会被调用,此函数相当于设备对象的构造函数 |
CreateFile |
IRP_MJ_DEVICE_CONTROL |
设备控制,用于读/写设备对象 | DeviceIoControl |
IRP_MJ_FILE_SYSTEM_CONTROL |
||
IRP_MJ_FLUSH_BUFFERS |
写输出缓冲区或者丢弃输入缓冲区 | FlushFileBuffers |
IRP_MJ_INTERNAL_DERVICE_CONTROL |
||
IRP_MJ_PNP |
||
IRP_MJ_POWER |
电源管理器发出的请求 | |
IRP_MJ_QUERY_INFORMATION |
获取设备对象的长度 | GetFileSize |
IRP_MJ_READ |
读取设备对象的内容 | ReadFile |
IRP_MJ_SET_INFORMATION |
设置设备对象的长度 | |
IRP_MJ_SHUTDOWN |
||
IRP_MJ_WRITE |
将数据写到设备对象 | WriteFile |
- 这些函数都是可选的,当用户从或者内核层通过设备的符号链接操作设备(打开、读、写、关闭时),这些函数就会被调用,也就是说之前用户层使用的API如:
CreateFile
.GetFileSize
…等API操作的是一个设备对象—文件设备对象(在内核中,文件属于一个设备对象) - 同一个函数,能够操作不同的对象,这就是内核中通过C语言实现的多态了。
使用示例-遍历所有驱动
-
驱动对象的DriverSection字段指向一个结构体,存储着驱动的很多信息:如驱动名字、加载基址、下一个驱动的驱动信息。形成了一个双向链表。
-
利用这个结构体中存储的驱动信息来遍历出所有的驱动:
#include <ntddk.h>
VOID OnDriverUnload(PDRIVER_OBJECT driver)
{
driver;
KdPrint(("驱动被卸载\n"));
}
//定义驱动信息结构体
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks; //双向链表
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
ULONG Flags;
USHORT LoadCount;
USHORT TlsIndex;
union {
LIST_ENTRY HashLinks;
struct {
PVOID SectionPointer;
ULONG CheckSum;
}s1;
}u1;
union {
struct {
ULONG TimeDateStamp;
}s2;
struct {
PVOID LoadedImports;
}s3;
}u2;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;
//遍历驱动信息
VOID EnumDriver(PDRIVER_OBJECT driver)
{
PLDR_DATA_TABLE_ENTRY pLdr = (PLDR_DATA_TABLE_ENTRY)driver->DriverSection;
PLIST_ENTRY pTemp =& pLdr->InLoadOrderLinks;
KdPrint(("加载基址 | 大 小 | 路径\n"));
do
{
PLDR_DATA_TABLE_ENTRY pDirverInfo = (PLDR_DATA_TABLE_ENTRY)pTemp;
KdPrint(("%08x |%06x |%wZ\n",pDirverInfo->DllBase,pDirverInfo->SizeOfImage, &pDirverInfo->FullDllName));
pTemp = pTemp->Flink;
} while (pTemp!= &pLdr->InLoadOrderLinks);
}
NTSTATUS DriverEntry(PDRIVER_OBJECT driver, PUNICODE_STRING reg_path)
{
reg_path;
NTSTATUS status = STATUS_SUCCESS;
EnumDriver(driver);
driver->DriverUnload = OnDriverUnload;
return status;
}
设备对象
- 设备对象是内核中的重要对象,其重要性不亚于WindowsGUI编程中的窗口(Windows)。也就是相当于窗口的概念。
- 在内核世界里,大部分“消息”都以请求(IRP)的方式传递,而设备对象(DEVICE_OBJECT)是唯一一个可以接收请求的实体,任何一个“请求”(IRP)都是发送给某个设备对象的。
- 结构DEVICE_OBJECT常被简称为DO。
- 因为我们总是在内核程序中生成一个DO,而一个内核程序是用一个驱动对象表示的,所以一个设备对象总是属于一个驱动对象。
- 一个DO结构的细节如下:
//0xb8 bytes (sizeof)
struct _DEVICE_OBJECT
{
//和驱动对象一样
SHORT Type; //0x0
USHORT Size; //0x2
//引用计数
LONG ReferenceCount; //0x4
//这个设备所属的驱动对象
struct _DRIVER_OBJECT* DriverObject; //0x8
//下一个设备对象,在一个驱动对象中可以有n个设备,这些设备使用这个指针连接起来作为一个单向链表
struct _DEVICE_OBJECT* NextDevice; //0xc
struct _DEVICE_OBJECT* AttachedDevice; //0x10
struct _IRP* CurrentIrp; //0x14
struct _IO_TIMER* Timer; //0x18
ULONG Flags; //0x1c
ULONG Characteristics; //0x20
struct _VPB* Vpb; //0x24
VOID* DeviceExtension; //0x28
//设备类型
ULONG DeviceType; //0x2c
//IRP栈大小
CHAR StackSize; //0x30
union
{
struct _LIST_ENTRY ListEntry; //0x34
struct _WAIT_CONTEXT_BLOCK Wcb; //0x34
} Queue; //0x34
ULONG AlignmentRequirement; //0x5c
struct _KDEVICE_QUEUE DeviceQueue; //0x60
struct _KDPC Dpc; //0x74
ULONG ActiveThreadCount; //0x94
VOID* SecurityDescriptor; //0x98
struct _KEVENT DeviceLock; //0x9c
USHORT SectorSize; //0xac
USHORT Spare1; //0xae
struct _DEVOBJ_EXTENSION* DeviceObjectExtension; //0xb0
VOID* Reserved; //0xb4
}DEVICE_OBJECT,*PDEVICE_OBJECT;
//重要字段:
//1. DriverObject:指出设备对象属于那个驱动对象
//2. NextDevie:下一个设备对象(一个驱动对象可以创建多个设备对象),这个设备对象是同一层的
//3. AttachedDevice:指向下一层驱动程序的设备对象(可以理解为被挂载的设备对象)
//4. CurrentIrp:使用IRP串行化时很重要,用于决定当前IRP是完成还是挂起等
//5. StackSize:设备栈的个数
//6. DeviceExtension:指向LDR链的指针
-
思考一个问题:Windows向设备对象发送的请求是如何处理呢?实际上这些请求是被驱动对象的分发函数所捕获的,当Windows内核向一个设备发送一个请求时,驱动对象的分发函数中的某一个会被调用。
-
一个典型的分发函数原型如下:
- 第一个参数device是请求的目标设备,第二个参数irp是请求的指针
NTSTATUS MyDispatch(PDEVICE_OBJECT device,PIRP irp);
设备对象使用
- 设备对象虽然在内核层,但是创建了
DOS
下的符号链接之后,在用户从中就可以通过CreateFile
来打开设备对象,并能够通过ReadFile
、WrietFile
、DeviceIoControl
、GetFileSize
等函数来间接地调用保存在驱动对象中的派遣函数,它们呈现一种如下图所示的关系:
设备对象的创建和销毁
-
IoCreateDevice
–创建设备对象 -
IoDeleteDevice
–销毁设备对象
符号链接
-
符号链接就是一个名字,
\DosDevices\D:\\
这是一个盘符,但其实也可以视为一个符号链接名,作用是能够让用户层的API发出IO请求,并能够在发出IO请求时指定一个设备来处理此IO请求:- 当用户层的应用程序发出一个IO请求时,对象管理器通过次符号链接名称来找到对应的设备,对象管理器能够解析符号链接的名称,已确定IO请求的目的地。
- 这个符号链接是给设备对象使用的,设备对象默认没有符号链接,没有符号链接的设备对象无法被用户从的代码所使用,只有为设备对象创建符号链接之后才可以使用。
-
在内核中符号链接有两种:
- DOS设备名:设备名一般格式为
“\DosDevices\自定义设备名”
,此格式的名字一般是用户传递给函数IoCreateSymbolLinkName
的参数,后面这个函数的功能是为一个NT设备名创建一个用户层能够使用的符号链接名。
- DOS设备名:设备名一般格式为
符号链接的创建和销毁
-
IoCreateSymbolicLink
–为一个NT设备名连接到一个DOS设备名,DOS设备名可供用户层程序使用。 -
IoDeleteSymbolicLink
–删除一个DOS设备名。
IRP
-
下面讲述内核中的请求的处理:
-
何为请求?
- 诸如读取一个文件从0开始的512字节就是一个请求,作为应用开发者,只要调用API函数ReadFile就可以读取文件数据了,但是这些操作在内核中汇编IO管理器翻译为请求(IRP或与之等效的其他形式,比如快速IO调用)发送往某个设备对象。
- 大部分请求一IRP形式发送。
-
IRP也是一个内核数据结构,这个结构更为复杂,因为它需要表示无数种实际的请求。
-
IRP结构体部分重要字段如下:
typedef struct _IRP
{
//类型和大小
CSHORT Type;
USHORT Size;
//内存描述符链表指针。实际上这里用来描述一个缓冲区
PMDL MdlAddress;
//...
//下面这个联合体中也有一个SystemBuffer的缓冲区表示方式,IRP使用Mdladdress还是SystemBuffer取决于当前的请求的IO方式
union{
struct _IRP * MAsterIrp;
__Volatile LONG IrpCount;
PVOID SystemBuffer;
}AssociatedIrp;
//IO状态,一般请求完成之后的返回情况放在这里
IO_STATUS_BLOCK IoSattus;
//IRP栈空间大小
CHAR StackCount;
//IRP当前栈空间
CHAR CurrentLocation;
//...
//用来取消一个未决请求的函数
__volatile PDRIVCE_CANCEL CancelRoutine;
//也是一个缓冲区,但特性和前面两个有所不同
PVOID UserBuffer;
union{
//...
//发出这个请求的线程
PETHREAD Thread;
//...
struct{
LIST_ENTRY ListEntry;
union{
//一个IEP栈空间元素
struct _IO_STACK_LOCATION* CurrentStackLocation;
};
};
}Overlay;
//...
}Tail;
}IRP,*PIRP;
- 所谓IRP栈空间是?
- 一个IRP往往需要传递n个设备才能得以完成,所以在每次传递时为了保存一些请求参数的变化,需要每次“中转”都保留一个“栈空间”用来存储中间参数。