欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

内核与驱动_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;
	}
}
  • 返回的错误码完全由编写者说了算,不过错误码有一些约定成俗的含义,如下一些示例:
    内核与驱动_00_内核编程基础知识

字符串

  • 驱动字符串一般用一个结构来容纳,定义如下:
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来打开设备对象,并能够通过ReadFileWrietFileDeviceIoControlGetFileSize等函数来间接地调用保存在驱动对象中的派遣函数,它们呈现一种如下图所示的关系:
    内核与驱动_00_内核编程基础知识

设备对象的创建和销毁

  • IoCreateDevice–创建设备对象
  • IoDeleteDevice–销毁设备对象

符号链接

  • 符号链接就是一个名字,\DosDevices\D:\\这是一个盘符,但其实也可以视为一个符号链接名,作用是能够让用户层的API发出IO请求,并能够在发出IO请求时指定一个设备来处理此IO请求:

    • 当用户层的应用程序发出一个IO请求时,对象管理器通过次符号链接名称来找到对应的设备,对象管理器能够解析符号链接的名称,已确定IO请求的目的地。
    • 这个符号链接是给设备对象使用的,设备对象默认没有符号链接,没有符号链接的设备对象无法被用户从的代码所使用,只有为设备对象创建符号链接之后才可以使用。
  • 在内核中符号链接有两种:

    • DOS设备名:设备名一般格式为“\DosDevices\自定义设备名”,此格式的名字一般是用户传递给函数IoCreateSymbolLinkName的参数,后面这个函数的功能是为一个NT设备名创建一个用户层能够使用的符号链接名。

符号链接的创建和销毁

  • 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个设备才能得以完成,所以在每次传递时为了保存一些请求参数的变化,需要每次“中转”都保留一个“栈空间”用来存储中间参数。
相关标签: 内核 驱动程序