内核编程基础
3.1. 内核编程一般准则
用户编程和内核编程之间的差别
3.1.1 未处理的异常
在用户模式下如果程序出现未处理的异常,整个程序会直接中止;在内核模式下出现未处理的异常,会造成系统奔溃,出现BSOD(蓝屏)。所以内核代码得非常小心,编译时绝对不能跳过任何细节和错误检查。
3.1.2 终止
当用户进程终止时不管是否正常终止,系统内核都保证了不会造成资源泄漏;但如果是内核驱动程序在卸载之后它此前所申请的系统资源不会被自动释放,只有等操作系统重启该资源才会释放。所以在编写驱动程序的时候应该妥善做好资源清理工作,自己申请的资源应该要自己释放。
3.1.3 函数返回值
3.1.4 IRQL
在用户模式代码正在运行时,IRQL(中断请求级别 Interrupt Request Level)永远是0,在内核模式下大多数时间依旧是0,但并非永远是0,具体不为0造成的影响将在第六章讨论。
3.1.5 内核中C++的用法
在内核中没有C++ Runtime所以一些C++的特性就没法使用:
- 不支持new和delete操作符,使用它们会导致编译失败。
- 全局变量的构造函数不是默认的构造函数,则该构造函数不会被调用(有疑问日后填坑
- C++异常处理的关键字(try,catch,throw)无法通过编译,因为这些关键字的实现需要C++ Runtime,在内核中我们只能用内核的SEH(结构化异常处理)。
- C++标准库不能在内核中使用
3.2 内核API
内核API大多数都在内核本身模块(NtOskrnl.exe)实现,还有一些在如HAL(hal.dll)的其他内核模块中实现。
在Ring3层,Zw 和Nt 是同一个函数,都是stub函数不做实际功能只是系统调用的入口;而在Ring0层,俩个函数是不同的函数,Zw*函数很短,调用Zw*函数会将PreviousMode设置成KernelMode(0),使Nt*函数绕过一些安全性和缓冲区的检查。所以驱动程序最好是调用Zw* 函数。
3.3 函数和错误代码
内核中函数的返回状态为NTSTATUS,STATUS_SUCCESS(0)表示成功,负值表示某种错误。在某些情况下从系统函数返回的NTSTATUS值最终会返回到用户模式,在用户模式我们可以通过GetLastError函数获得这些错误信息。
3.4 字符串
在内核API中很多地方需要用到字符串,某些地方就是简单的Unicode指针,但大多数用到字符串的函数使用的是UNICODE_STRING结构。
其中Length和MaximumLength是按字节(BYTE)计算的字符串长度。
3.5 动态内存分配
内核的栈相当小,因此任何大块的内存都必须动态分配,内核为驱动程序提供了俩种通用的内存池。
除非必要驱动程序里要尽可能少的使用非分页池,POOL_TYPE这个枚举类型表示内存池的类型。
常见的内核内存池分配函数
3.6 链表
在内核中很多内部的数据结构都使用了环形双向链表,例如,系统中所有的进程使用EPROCESS结构进行管理,这些结构就用一个环形双向链表链接在一起,其中链表的头部保存在PsActiveProcessHead这个内核变量中。所有的链表都使用LIST_ENTRY结构相互链接。
LIST_ENTRY结构都是包含在一个更大的结构中,如果我们想要通过LIST_ENTRY反推它所在的父结构可以使用CONTAINING_RECORD宏(根据结构体中的某成员的地址来推算出该结构体整体的地址)
3.7 驱动程序对象
在驱动程序入口函数DriverEntry接收了两个参数,第一个DRIVER_OBJECT/_DRIVER_OBJECT)是该驱动程序的对象,这个结构由内核分配并进行了部分初始化。所以我们驱动编程人员需要进一步帮助它进行初始化,在第二章我们就设置了驱动程序Unload所需要的函数,此外一个驱动程序还需要在初始化时设置派遣函数(Dispatch Routines),它位于MajorFunction这个指针数组中,指明了驱动程序支持哪些操作。
派遣函数的设置方式
该部分可能不是很好理解,在下一章通过代码进一步学习。
3.8 设备对象
如果驱动想要和应用程序进行通信,首先必须要生成一个设备对象(DEVICE_OBJECT)。设备对象暴露给应用层,应用层可以像操作文件一样操作它。用于和应用程序通信的设备对象常是用来"控制"这个内核驱动,所以往往被称之为"控制设备对象"(Control Device Object, CDO)。
这个设备对象需要有一个名字,这样才会被暴露出来,供其他程序打开与之通信。但是,应用层是无法直接通过设备的名字来打开对象的,必须建立一个暴露给应用层的符号链接。符号链接是记录一个字符串对应到另一个字符串的简单结构,可以和文件系统的快捷方式类比。