windows 内核态逆向开发

sky123

https://www.vergiliusproject.com/

保护模式

参考 英特尔开发手册

CPU 的模式

x86 CPU 的三个主要模式分别是实模式(Real Mode)、保护模式(Protected Mode)和虚拟8086模式(Virtual 8086 Mode)。它们各自的功能和特点如下:

实模式(Real Mode)

  • 简介 :实模式是x86架构最早的工作模式,与8086 CPU的工作方式相同。
  • 内存管理 :在实模式下,CPU可以访问1MB的内存。内存地址是通过段寄存器和偏移地址组合来形成的,总地址空间为20位。
  • 特点
    • 没有内存保护机制,即程序可以访问所有内存区域,包括操作系统内核和其他程序的数据。
    • 没有多任务支持,所有程序运行在同一个地址空间中。
    • 简单的地址计算:物理地址 = 段寄存器 * 16 + 偏移地址。
  • 用途 :实模式主要用于早期的DOS操作系统和初始系统引导过程。

保护模式(Protected Mode)

  • 简介 :保护模式是为了提供更高级的内存管理和多任务处理而设计的,是现代操作系统(如Windows、Linux)运行的基础模式。
  • 内存管理 :保护模式下,CPU可以访问4GB的内存地址空间,使用32位地址。引入了分页(Paging)和分段(Segmentation)机制。
  • 特点
    • 支持内存保护,防止一个程序访问另一个程序的内存区域。
    • 支持硬件级别的多任务处理,通过任务状态段(TSS)进行任务切换。
    • 支持虚拟内存,通过分页机制,可以使用物理内存之外的存储空间。
    • 提供特权级别(Privilege Levels),通常有四个环(Ring 0到Ring 3),用于控制访问权限和隔离内核与用户程序。
  • 用途 :保护模式用于运行复杂的多任务操作系统,如Windows、Linux等。

虚拟8086模式(Virtual 8086 Mode)

  • 简介 :虚拟8086模式是保护模式的一部分,允许在保护模式下运行实模式应用程序。它引入了一种机制,使得保护模式操作系统可以运行多个8086虚拟机。
  • 内存管理 :每个虚拟8086模式下的任务都有自己独立的1MB地址空间,模拟实模式的内存管理。
  • 特点
    • 兼容性:允许在现代操作系统上运行旧的实模式程序。
    • 隔离:每个虚拟8086任务被隔离在自己的内存空间内,受保护模式的内存保护机制保护。
    • 性能:在保护模式下,通过硬件支持的虚拟化技术,可以高效地执行实模式代码。
  • 用途 :虚拟8086模式用于在保护模式操作系统(如Windows NT系列)中运行DOS程序。

保护模式下的地址

在保护模式下,x86 CPU通过段式内存管理分页机制来转换和管理逻辑地址虚拟地址物理地址
在这里插入图片描述

三种地址的转换具体如下图所示:

image-20250602014852488

逻辑地址(Logical Address)

  • 组成逻辑地址段选择子(Segment Selector)和段内偏移(Offset)组成。
  • 格式Logical Address = Segment Selector:Offset
  • 作用 :逻辑地址是程序员在代码中使用的地址形式。
    • 段选择子决定了使用哪个段描述符
    • 偏移量则指示在该段内的具体位置。

虚拟地址(Virtual Address)

  • 生成 :通过段选择子段描述符表(GDT或LDT)中找到对应的段描述符,利用段描述符中的基址(Base Address)加上偏移量(即逻辑地址中的段内偏移)得到虚拟地址。
  • 格式Virtual Address = Base Address (from Segment Descriptor) + Offset
  • 作用 :虚拟地址是CPU在段内的线性地址,它可以直接被分页单元使用以进一步转换为物理地址。

物理地址(Physical Address)

  • 生成虚拟地址通过分页机制(如果启用)被转换成物理地址。分页机制使用页目录(Page Directory)和页表(Page Table)来完成这个转换。
  • 作用 :物理地址是内存芯片上的实际地址,是最终用于存储器访问的地址。

段式内存管理

段式内存管理(Segmentation)是x86架构保护模式下的内存管理机制之一。它通过将内存划分为多个段,每个段都有独立的基址、大小和访问权限,以便对内存进行更加灵活和安全的管理。

不过在 32 位下由于访问范围为 4GB,因此通常设置基址为 0 。

段寄存器(Segment Registers)

段寄存器用于保存段选择子,指示当前正在使用的段。x86 CPU中有多个段寄存器,每个寄存器对应不同类型的内存段。

主要段寄存器

32 位 Windows(x86)段寄存器:

寄存器 Ring 3 (用户态) Ring 0 (内核态) 说明
CS 0x1B 0x08 代码段
DS 0x23 0x10 数据段
SS 0x23 0x10 栈段
ES 0x23 0x10 额外数据段(一般等于 DS)
FS 0x3B 0x30 用户态: TEB,内核态: KPCR
GS 0x00 0x00 通常未使用

64 位 Windows(x64)段寄存器:

寄存器 Ring 3 (用户态) Ring 0 (内核态) 说明
CS 0x33 0x10 代码段
DS 0x00 0x00 被忽略,平坦段
SS 0x00 0x00 被忽略,平坦段
ES 0x00 0x00 被忽略,平坦段
FS 0x00 (或用于 WOW64) 0x00 兼容层用,64 位下一般不用
GS 0x53 (或配置值) 0x10 用户态: TEB,内核态: KPCR

段寄存器结构

在x86保护模式下,每个段寄存器有一个16位的可见部分(段选择子)和一个80位的隐含部分(包含段基址、界限和属性)。所以,段寄存器的总长度是96位,其中只有16位是可见的。当然不可见的部分其实对我们来说是透明的,因为这一部分在段寄存器中的属性是从段描述符中加载出来,以提高内存访问速度。

为了更准确地描述段寄存器,可以使用如下的结构体:

1
2
3
4
5
6
7
8
struct SegmentRegister {
WORD Selector; // 段选择子 16位 可见
struct {
WORD Attributes; // 段属性 16位 不可见
DWORD Limit; // 段大小 20位(界限)+ 12位标志,不可见
DWORD Base; // 段基址 32位 不可见
} Hidden;
};

在这里插入图片描述

  • Selector(段选择子) :段寄存器中唯一可见的部分,它包含段选择子的索引、TI(表指示位)和RPL(请求特权级别)。Selector用于在GDT或LDT中查找段描述符。

    • 索引(Index) :段描述符在描述符表(GDT 或 LDT)中的索引。
    • TI(Table Indicator) :指示使用全局描述符表(GDT)还是局部描述符表(LDT)。
      • 0:使用 GDT。
      • 1:使用 LDT。
    • RPL(Requested Privilege Level) :请求的特权级别,用于权限检查。
  • Base(段基址) :段的起始地址,由段描述符中的 Base 字段组成。Base 字段在段描述符中分为三部分(低、中、高),合并后形成完整的 32 位基址。

  • Limit(段大小) :段的界限,由段描述符中的 Limit 字段组成。Limit 字段也分为低 16 位和高 4 位,高 4 位包含在段描述符的高字节中。Limit 通常表示段的大小。

  • Attributes(段属性) :段的属性和类型信息,包括段的类型(代码段、数据段)、DPL(描述符特权级别)、存在位(P)、扩展向下位(D/B)、可访问位(A)等。

段寄存器读写

数据段寄存器

数据段寄存器读写通常是通过寄存器完成的,例如 mov ds, axmov ax, es 等。

另外 pushpop 这种栈操作指令同样可以读写数据段寄存器,例如 push fspop fs 等,在开发中我们通常利用这个方法来保存段寄存器环境。

注意

ss 寄存器也能通过 mov 指令修改,但是 ss 的 RPL 要保持不变。因为 ss 的 RPL 和 cs 的 RPL 同为 CPL,段权限管理要求 csss 的 RPL 要始终保持相同,而仅通过 mov 指令修改 ss 的 RPL 显然不符合这一规定。

代码段寄存器

cs 寄存器不能通过 movpop 指令修改(不过可以使用 push csmov ax, cs 这种指令来读取 cs 寄存器的值),因此我们必须通过跨段跳转指令来修改 cs 寄存器。 例如 jmp fwordcall fword 指令:

1
2
jmp far segment:offset
call far segment:offset

然而在 MSVC(尤其是 32 位 x86 平台)中的内联汇编在语法上不支持这种直接立即数跳转:

1
2
jmp far 0x1234:0x5678
call far 0x1234:0x5678

因此我们需要使用 call fword ptr [mem] 指令来代替:

1
2
3
4
5
6
7
8
9
struct FarPointer {
unsigned int offset; // 32 位
unsigned short selector; // 16 位
} fp = {0x5678, 0x1234};

__asm {
jmp fword ptr ds:[fp]
call fword ptr ds:[fp]
}

对于 call fword ptrcall far 指令,如果 CPL 不变则会在向栈中依次压入调用者的 cs返回地址(CPL 变化的情况比较复杂,具体见调用门部分)。

image-20250603005133672

因此我们返回时需要通过 retf 指令返回。如果调用前在栈上压入了参数,那么我们可以通过 retf [参数的字节总数] 来平衡堆栈。

为了避免编译器在函数代码中生成堆栈帧(push ebp/mov ebp, esp…),我们需要借助裸函数(naked function)精确控制函数的入口和返回指令。

1
2
3
4
5
6
__declspec(naked) void func() {
__asm {
// 这里写汇编指令
retf
}
}

除此之外中断指令 int 以及对应的返回指令 iret 同样会修改 cs 寄存器,具体同样见后面的分析。

段描述符表(Descriptor Table)

段描述符表种类

段描述符表是存储段描述符的结构,定义了各个段的属性。x86 架构中有两种主要的段描述符表:全局描述符表(GDT)局部描述符表(LDT)

全局描述符表(GDT, Global Descriptor Table)
  • 作用 :全局描述符表用于定义系统范围内的段,包括代码段、数据段和系统段(如任务状态段TSS)。
  • 存储位置 :GDT存储在内存中的一个固定位置,其基址和界限由 GDTR(GDT Register)寄存器保存。
  • 访问方式 :通过段选择子中的 TI 位(Table Indicator)为 0 来选择 GDT 。
  • 相关汇编指令
    • lgdt [mem] → 加载 GDT
    • sgdt [mem] → 存储 GDT(SGDT 可以在 Ring 3 执行,常用于探测系统)

关于 GDT 有如下常用的调试命令:

命令 功能说明
r gdtr/r gdtl 查看 GDT 基地址和长度
!gdt 列出当前处理器的 GDT 表项
!gdt <n> 查看某个段选择子的详细描述符
dg <selector> [count] 解析段选择子 selector 开始的 count 个段描述符
~<n> 切换到第 n 个处理器上下文
局部描述符表(LDT, Local Descriptor Table)
  • 作用 :局部描述符表用于定义特定任务或进程的段,通常用于多任务环境中。

  • 存储位置 :LDT 也存储在内存中的一个固定位置,其基址和界限由 LDTR(LDT Register)寄存器保存。

  • 访问方式 :通过段选择子中的 TI 位为 1 来选择 LDT 。

  • 相关汇编指令

    • lldt reg/mem → 加载 LDTR
    • sldt reg/mem → 存储 LDTR(Ring 3 可执行)
    • str reg/mem → 存储任务寄存器(可用于 TSS 调试)

关于 LDT 有如下常用的调试命令:

命令 功能说明
r ldtr/r ldtl 查看 LDT 基地址和长度

段描述符(Segment Descriptor)

段描述符是描述段属性的结构体,存储在GDT或LDT中。每个段描述符占用8个字节,包含段的基址、界限和属性等信息。

一个段描述符的结构如下:

Figure 3-8. Segment Descriptor

  • 基址(Base Address) :段的起始地址,由 8 位高位基址(Base 31:24)、8 位中位基址(Base 23:16)和 16 位低位基址(Base Address 15:00)组成,共32位。

  • 界限(Limit) :段的大小,由 16 位低位界限(Limit 15:0)和 4 位高位界限(Limit 19:16)组成,共 20 位。

  • P(Present) :段存在位,1 表示段存在。段描述符加载时,首先看 P 位是否为 1 。

  • DPL(Descriptor Privilege Level) :描述符的特权级别,共 2 位。

  • S(Descriptor Type)TypeS 描述符类型,Type 根据 S 的不同含义不同。

    • S 为 0 表示系统段,此时 Type 描述系统段的类型(如调用门,中断门,陷阱门等),含义如下表所示。

      image-20250602003006973

    • S 为 1 表示代码段或数据段,此时 Type 描述段的具体类型(如代码段、数据段)和访问权限,含义如下表所示。

    image-20250601231301425

    另外 Type 对应代码段和数据段时分别引入了一个新概念:

    • 一致代码段(Conforming Code Segment)
      • Type 的 11 位为 1 时段描述符表示的是代码段,此时如果 C 位(10 位)为 1 则表示为一致代码段。
      • 一致代码段是指当 CPU 执行跨特权级的代码段切换(比如调用更高权限的代码段)时,不需要特权级检查,允许直接进入。
      • CPU 不会在段切换时更改 CPL(因为一致代码段特权级不敏感)。
    • 向下拓展数据段(Expand-Down Data Segment)
      • Type 的 11 位为 0 时段描述符表示的是数据段,此时如果 E 位(10 位)为 1 则表示为向下拓展。
      • 正常情况下的我们见到的数据段都是向上拓展的,即有效偏移范围是从 0Limit
      • 对于向下拓展的数据段,有效偏移范围是从 Limit + 1 到最大偏移值(例如,0xFFFF0xFFFFFFFF,取决于段的大小)。由于 Base + Limit 会溢出,因此实际的有效范围是 [Base, Base + Limit) 之外的范围。
  • G位(Granularity) :决定界限单位。

    • 0 表示段寄存器的 Limit 元素单位为字节,即最大范围为 0x000FFFFF(1MB)。
    • 1 表示段寄存器的 Limit 元素单位为 4KB ,即最大范围为 0xFFFFFFFF(4GB)。
  • D/B 位(Default Operand Size / Big) :决定了默认的操作数和地址的大小,以及堆栈指针的大小。

    注意

    这里需要与 Type 位配合才能生效。Type 位决定了段描述符是代码段还是数据段,另外还决定了一些特性如向下拓展是否生效。

    • 对于代码段:
      • 0 :默认操作数和地址大小为 16 位。例如 push 指令只能压 2 字节。
      • 1 :默认操作数和地址大小为 32 位。
    • 对于堆栈段:
      • 0 :使用 16 位堆栈指针。
      • 1 :使用 32 位堆栈指针。
    • 对于数据段:
      • 0 :向下拓展。
      • 1 :向上拓展。
  • L 位(64-bit code segment) :仅适用于 IA-32e 模式(64 位模式)的代码段。

    • 0 :表示这是一个 16 位或 32 位代码段,具体取决于 D/B 位。
    • 1 :表示这是一个 64 位代码段,忽略 D/B 位。
  • AVL 位(Available for use by system software) :供操作系统或其他系统软件使用,用于特定用途,例如标记段的状态,未被硬件使用。

段权限检测

权限类型

Ring Model 是一种 CPU 权限级别(Privilege Level) 的分层架构,主要用于操作系统中处理权限隔离和保护。它通过硬件机制,限制不同权限级别的代码对系统资源的访问,从而提高系统的安全性和稳定性。

Ring Model 的分层通常分为 4 个等级,编号从 0 到 3:Ring 0 Ring 1Ring 2Ring 3

image-20250602015322945

CPU 的权限等级主要有段机制来维护,具体来说段机制中有下面几个权限类型:

  • CPL(Current Privilege Level) :表示当前程序的运行权限级别,由 CS 和 SS 的 RPL(低两位)决定,并且 CS 和 SS 的低两位一定相等(因为权限变化要求 CS 和 SS 同时修改,并且 CS 和 SS 必须变成相同是值)。
  • RPL(Requested Privilege Level) :请求访问段时指定的权限级别。通常由访存时使用的段寄存器的段选择子决定。
  • DPL(Descriptor Privilege Level) :段描述符中定义的段权限级别。

权限规则

这里仅考虑常见的非一致代码段。

权限检测规则
  • 对于数据段:仅检测 DPL 与 CPL 之间的大小关系,当且仅当 DPL 在数值上大于等于 CPL(CPL ≤ DPL)时允许。例如当 CPL = DPL = 3 时,即使 RPL = 0 也允许访问。
  • 对于堆栈段和代码段:当且仅当 CPL = RPL = DPL 时允许访问。因为堆栈段在数据段的基础上本身 RPL 就是 CPL,因此我们只需要知道这里比数据段严格,必须要求 CPL = DPL。
  • 对于系统段:CPL = RPL ≤ 门描述符的 DPL
权限修改规则
  • cs 的权限一旦改变,ss 的权限也要随着改变,csss 的等级必须一样。

  • intcall far 这一类的指令只能通过系统段提权或者不改变权限。

  • jmp far 这一类指令不改变权限,除了 TSS(可以同时影响到 csss)。

  • retfiretd 这一类指令只能同级跳转或者降权。

调用门

在 x86 保护模式下,调用门(Call Gate)是一种特殊的系统段描述符,存在于 GDT(全局描述符表)LDT(局部描述符表) 中。它的作用是:允许安全地从一个代码段转移到另一个代码段,通常伴随特权级切换

门描述符

Figure 5-8. Call-Gate Descriptor

  • 偏移地址(Offset in Segment) :目标代码段中被调用过程的入口的逻辑地址,分为 16 位的低位偏移(Offset 15:00)和 16 位的高位偏移(Offset 31:16),需要与段选择子指向的段描述符结合才能得到入口的线性地址
  • 段选择子(Segment Selector) :调用门要跳转到的目标代码段的选择子,用于在 GDT 或 LDT 中查找目标代码段的描述符。
  • 参数计数(Param Count) :堆栈切换(权限改变)时需要从旧特权级堆栈复制到新特权级堆栈的参数个数,单位为字(16 位)或双字(32 位),具体取决于调用门的类型(位数)。
  • 类型(Type) :4 位,表示调用门的类型。对于 32 位调用门,值为 1100(0xC);对于 16 位调用门,值为 0100(0x4)。
  • 系统段标志(S) :1 位,固定为 0,表示调用门是系统段,而不是普通的代码段或数据段。
  • 描述符特权级(DPL) :2 位,表示访问调用门时所需的最低特权级,用于特权检查。
  • 段存在标志(P) :1 位,表示调用门描述符是否存在。若为 0,访问该调用门会触发 #NP(Not Present)异常。

过程分析

调用过程

当我们通过 call/jmp far 或者 call/jmp fword ptr 指令调用调用门:

  1. 根据指令中的段选择子找到调用门。(指令中的地址没有用到,最终跳转到的地址由调用门的门描述符中的 Offset 字段决定)

    image-20250602051346485

  2. 进行权限检查:

    • 指令中的 RPL 不参与整个过程。
    • CPL 在数值上小于等于调用门 DPL。
    • 调用门中代码段选择子的 RPL 在数值上小于等于对应的代码段的的 DPL(貌似不用满足,并且跳过去之后 CPL 还是设置为代码段的 DPL)。
    • 如果是 jmp 则调用门的段描述符对应的代码段的 DPL 等于 CPL。(jmp 通过调用门提不了权)
    • 如果是 call 则调用门的段描述符对应的代码段的 DPL 在数值上小于等于 CPL。
  3. 判断调用门是否提权:

    • 如果没有权限变化则等价于普通的跨段跳转,只不过这里跳转的地址不是直接从操作数获取的。

    • 如果权限发生变化则需要进行栈切换:

      • 从 TSS 中获取新的 ssesp 更新对应的寄存器,期间会检测 ss 的权限以及 TSS 是否合法。

      • 将原有的 ssesp,参数(从原本的栈中拷贝,拷贝长度参考调用门描述符中的参数计数(Param Count)字段),cs,返回地址依次压入的堆栈。

        注意

        原有的堆栈中只有参数,没有压入返回地址之类的东西。

        image-20250602053230511

      • 根据门描述符中表示的代码段还有调用门入口点更新 cseip

至此我们完成的 cs 和栈的切换,跳转指调用门指定的代码开始执行。

注意

如果我们调试提权后的代码,则返回用户态之后会发生崩溃。

这是因为 int 3 对应的中断处理函数 _KiTrap03 会将 fs 寄存器修改为 0x30(这也是为什么我们下断点调试时发现 fs 的值发生变化),而由于我们通过调用门提权后处于 0 环权限,_KiTrap03 并不会恢复 fs 寄存器,因此我们在返回 3 环时需要手动还原 fs 寄存器。

返回过程

如果我们通过 call 长调用指令进行调用门跳转则需要通过 retf 指令返回。

注意

如果调用门规定了参数格式,则我们必须使用 retf [参数的字节总数] 来返回。

注意,retf 后面跟的是参数的字节数,而调用门的参数计数(Param Count)字段描述的是参数的个数。

image-20250603020931539

具体过程为:

  1. 进行权限检查 :根据栈中存放的 cs 寄存器判断是否发生权限(CPL)变化:

    • 如果没有发生权限变化则为普通的跨段跳转,不会发生栈切换。

    • 如果发生权限变化则:

      • 只能是降权,即在数值上栈中保存的 cs 的 RPL 要大于 CPL。
      • 对栈中保存的 ss 进行权限检查,要求返回后 sscs 的 DPL 要相等。
  2. 恢复寄存器

    • 根据中保存的 eipcs 恢复 eipcs 寄存器。
    • 如果降权则要在普通的跨段跳转返回的基础上根据栈中保存的 espss 恢复堆栈。栈中保存的 espss 的位置需要根据 retf 后面跟的参数字节数定位。
  3. 平衡堆栈

    • 如果权限不变则根据 retf 后面跟的参数字节数平衡掉压入的参数,另外还要平衡掉跨段跳转时压入的返回地址和 cs

    • 如果是降权则需要在原本的堆栈中根据 retf 后面跟的参数字节数平衡掉压入的参数。

      注意

      内核堆栈每次都是通过 TSS 的 esp 赋值,因此不需要平衡内核堆栈。

中断

什么是中断

中断(Interrupt)是计算机系统中一种异步事件通知机制,用于在处理器正在执行任务时,打断当前执行流程,让处理器去响应更紧急或更重要的事件

中断机制为计算机系统提供了一种对事件的实时响应方式,同时保证处理器资源的高效利用。

按照来源划分,中断可以分为硬件中断软件中断

  • 硬件中断 :来自外部设备(如键盘、鼠标、定时器、网卡)发出的中断信号。如:键盘按键、鼠标移动、时钟滴答。

  • 软件中断 :由程序通过指令(如 int)显式触发,通常用于系统调用。

目的划分,中断可以分为可屏蔽中断(IRQ)不可屏蔽中断(NMI)异常(Exception)

  • 可屏蔽中断(IRQ) :可以被禁止(如设置 IF=0EFLAGS 寄存器第 10 位)。大多数硬件中断都属于这一类。

  • 不可屏蔽中断(NMI) :非常紧急,不能被屏蔽,比如内存校验出错。

  • 异常(Exception) :由 CPU 自身执行错误或特定条件触发,如除 0、段错误、页错误等。属于“内部中断”。

中断描述符表(IDT,Interrupt Descriptor Table)

与调用门不同,中断门的门描述符位于存储在 IDT(中断描述符表) 中。这是因为中断描述符要以“中断号”为索引,用于快速响应异步事件或异常情况,强调处理器控制流程的跳转,因此需要单开一个表存储。

中断向量表(IDT)最多有 256 项(编号 0–255),每一项都对应一个中断/异常,中断号就是中断向量表的下标。其中中断号 0–31(共 32 个)被 CPU 保留,用于异常处理,操作系统不得更改其语义

中断号 异常简称 异常名称 类型
0 #DE Divide Error(除 0 错误) Fault
1 #DB Debug(调试异常) Fault/Trap
2 Non-Maskable Interrupt(NMI) Interrupt
3 #BP Breakpoint(断点) Trap
4 #OF Overflow(溢出) Trap
5 #BR BOUND Range Exceeded(数组越界) Fault
6 #UD Invalid Opcode(非法/未定义指令) Fault
7 #NM Device Not Available(协处理器不可用) Fault
8 #DF Double Fault(双重故障) Abort
9 Coprocessor Segment Overrun(协处理器段溢出) Fault(已废弃)
10 #TS Invalid TSS(任务状态段无效) Fault
11 #NP Segment Not Present(段不存在) Fault
12 #SS Stack Segment Fault(栈段错误) Fault
13 #GP General Protection Fault(一般保护异常) Fault
14 #PF Page Fault(页错误) Fault
15 Reserved(保留)
16 #MF x87 Floating-Point Error(浮点错误) Fault
17 #AC Alignment Check(对齐检查) Fault
18 #MC Machine Check(机器检查) Abort
19 #XM/#XF SIMD Floating-Point Exception Fault
20–31 Reserved(保留)

中断号 32–255:可由操作系统自由使用,每一项都是一个“中断描述符”,可为以下几种类型:

描述符类型 用途
中断门(Interrupt Gate) 常用于处理硬件中断,自动清除 IF 位,防止嵌套
陷阱门(Trap Gate) 常用于异常和调试,不清除 IF
任务门(Task Gate) 切换到另一个任务(TSS),操作系统不使用该功能

关于 IDT 有如下常用的调试命令:

命令 功能说明
r idtr/r idtl 查看 IDT 基地址和长度
!idt 查看当前处理器 IDT 内容
!idt <n> 查看中断号 <n> 的处理函数
~<n> 切换到第 n 个处理器上下文

中断门

中断门(Interrupt Gate)是 x86 架构中用于处理中断(硬件/软件中断)和异常的一种特殊机制。它是一种描述符,存储在 IDT(中断描述符表) 中,用于指向中断/异常处理程序的入口地址和相关属性。

中断门的作用是:当发生中断或异常时,CPU 通过中断门自动跳转到对应的处理函数,并进行必要的权限切换、堆栈切换等。

门描述符

InterruptGateDescriptior

  • 偏移地址(Offset) :目标代码段中中断处理程序入口的逻辑地址,分为 16 位的低位偏移(Offset 15:00)和 16 位的高位偏移(Offset 31:16),需要与段选择子指向的段描述符结合才能得到入口的线性地址
  • 段选择子(Segment Selector) :中断门要跳转到的目标代码段的选择子,用于在 GDT 或 LDT 中查找中断处理程序所在的段描述符。
  • 类型(Type) :4 位,表示门描述符的类型。对于 32 位中断门,值为 1110(0xE);表示该门在触发时会自动清除 EFLAGS 中的 IF 位,从而禁止中断嵌套。
  • 系统段标志(S) :1 位,固定为 0,表示中断门是系统段,而不是普通的代码段或数据段。
  • 描述符特权级(DPL) :2 位,表示访问中断门所需的最低特权级。在中断由软件触发(如 INT 指令)时进行特权检查;若 CPL > DPL,则会触发 #GP 异常。
  • 段存在标志(P) :1 位,表示中断门描述符是否存在。若为 0,访问该中断门会触发 #NP(Not Present)异常。

过程分析

调用过程

当我们通过 int n 或指令触发中断:

  1. 根据中断向量在 IDT 中查找中断门描述符。(指令中的操作数 n 作为索引,找到对应的门描述符;跳转地址由中断门中的 OffsetSegment Selector 字段决定)

    image-20250603235057313

  2. 进行权限检查:

    • 对于硬件中断不进行权限检查,中断处理程序直接执行。
    • 对于软件中断(如 INT n)
      • CPL(当前特权级)必须小于等于中断门的 DPL。
      • 如果 CPL > DPL,则触发 #GP 异常。
  3. 判断是否发生权限变化(提权)

    • 如果中断门的目标代码段的 DPL < CPL,则发生提权,需要进行堆栈切换

      • 从 TSS 中查找对应的 SSESP,加载为新堆栈。
      • 将当前栈的 SSESPEFLAGSCSEIP 依次压入新堆栈中,形成完整的返回上下文。
      • 若中断号是 INT n 且带有错误码,则错误码也一并压入堆栈(CPU 固定的几项,我们自己注册的没有)。

      image-20250603235355733

    • 如果没有权限变化(例如从 Ring 0 调用 Ring 0 中断门)则不会切换堆栈,仅将 EFLAGSCSEIP 压入当前堆栈。

  4. 修改 EFLAGS 寄存器 :中断门会清空 EFLAGS 寄存器中的下面几个标志位:

    image-20250604005350818

位名 含义 EFLAGS 位位置 修改行为 / 说明
IF (Interrupt Flag) 中断允许标志:控制是否允许 CPU 响应可屏蔽中断(IRQ) 位 9 当通过中断门进入中断处理程序时,CPU 会自动将 IF = 0,即暂时屏蔽其他中断,防止当前中断被新的中断打断。等中断处理结束后通过 IRET 恢复原 EFLAGSIF 才可能再次为 1。这个行为只发生在中断门,陷阱门不会清除 IF,允许中断嵌套。
TF (Trap Flag) 单步调试标志:控制是否在每条指令后触发调试异常(#DB) 位 8 进入中断处理程序前,CPU 会自动将 TF 清 0,禁止单步调试在中断处理流程中生效,避免调试器每条指令都触发中断,造成混乱甚至不可预期行为。恢复时,IRET 会将 TF 恢复为中断前的状态。
NT (Nested Task) 嵌套任务标志:用于任务切换机制(与 TSS 和任务门相关) 位 14 NT = 1 表示当前是嵌套任务。中断门触发时,即便不是 Task Gate 形式,也会清除 NT 位为 0,防止任务嵌套链在中断中干扰任务切换控制流。(如果是嵌套任务则 iretd 指令会根据任务段返回)
VM (Virtual 8086 Mode) 虚拟 8086 模式标志:控制是否在 V8086 模拟环境中运行 16 位实模式代码 位 17 如果 CPU 当前处于 VM = 1(虚拟 8086 模式),当通过中断门执行 INT n 指令时,CPU 会自动清除 VM退出 V86 模式,进入保护模式(Ring 0),以便执行保护模式下的中断处理程序。这个过程伴随着堆栈切换、权限检查和返回机制,支持从实模式模拟环境跳转到内核态中断处理逻辑。
  1. 跳转到中断处理程序:

    • 设置新的 CS:EIP 为门描述符中指定的目标。
    • 开始执行中断处理程序。
返回过程

在中断处理程序结束时通过 iret( 32 位 iretd,64 位 iretq)指令返回,具体过程为:

  1. 进行权限检查:
    • 从栈中弹出 EIPCSEFLAGS
    • 如果发生过权限变化(即从高权限跳转到低权限):
      • 同时还需要从栈中恢复 SSESP
      • 返回后会将 CPL 设置为 CS 描述符中的 DPL。
  2. 恢复上下文:
    • 设置 CS:EIP 为返回地址。
    • 恢复原 EFLAGS
    • 如果发生过堆栈切换,则也恢复 SSESP,切回用户堆栈。
  3. 完成权限恢复与返回:
    • 中断处理完毕,程序继续从中断发生处继续执行。

注意

  • 若中断期间修改了段寄存器(如 fs),在返回到用户态前应手动还原。
  • iretd 不能被 retf 4 代替,因为发送权限变化的时候 retf 4 还会平衡掉用户态栈中压入的参数,而 int 指令只在内核态栈中压入 EFLAGS,因此会导致堆栈不平衡。

陷阱门

陷阱门是一种门描述符,用于定义某个中断或异常发生时,处理器该跳转到哪段代码继续执行。它和中断门类似,都存在于 IDT(Interrupt Descriptor Table) 中,但行为略有不同,主要用于不会频繁嵌套、需要精细控制响应时机的异常或调试场景

门描述符

TrapGateDescriptior

陷阱门描述符与中断门描述符格式基本一致,唯一区别就是 Type 字段不同,由 1110(0xE)变为了 1111(0xF)。

过程分析

与中断门完全一致,唯一的不同点在于陷阱门不会清空 EFLAGSIF 标志位。

任务门

任务门(Task Gate)是一种特殊的系统段描述符,用于在 x86 保护模式下实现任务切换(Task Switch),其本质作用是指向一个任务(TSS),以便通过它进行硬件级别的任务切换

然而现代操作系统几乎完全不用任务门(Task Gate)和硬件任务切换机制,无论是 Windows、Linux、macOS 还是虚拟化内核,都采用软件实现的任务调度和上下文切换机制。这是因为:

  • 控制粒度太粗、太死板。
    • 一旦使用任务门 → 切换的是整个 TSS + CR3 + 所有段寄存器 + GPR + EFLAGS + EIP。OS 不能只保存/恢复一部分,比如只切换线程状态,不切换地址空间。而软件调度可以精细控制切换内容(比如 lazy FPU context switching、延迟页表切换等)。
    • 性能开销大。使用任务门意味着 CPU 要自动执行一整套验证、保存、加载流程,而软件调度只需要保存需要的部分上下文,用 mov, push, pop 就行,效率更高。
    • 异常可恢复性差。硬件任务切换由 CPU 自动执行的一整套流程,如果在任务切换“中间过程”如果出错,则当前处于模糊状态,OS 没办法清楚知道到底处在哪个任务上下文。最终会导致:陷入不可恢复状态 → Triple Fault(第三次错误) → CPU 复位重启,分析调试困难。
  • 不利高并发场景。TSS 原生只支持一个任务状态。TSS 的 busy 位、防嵌套规则等,使得任务链和多线程管理变得复杂且危险,经常可能因为并发问题触发异常导致系统崩溃。Linux、Windows 等直接为每个 CPU 定义自己的调度器和线程控制块(TCB),避免使用硬件机制。
  • 兼容性差。硬件任务切换机制使得 OS 必须与 x86 的 GDT、IDT、TSS、任务门、段保护等高度绑定,导致 OS 无法跨架构通用,也难以模块化维护。

因此现代操作系统中任务门的主要作用为:

  • TSS 的 ESP0 用于内核栈切换 :现代操作系统都需要为每个 CPU 设置一个 TSS,仅为了提供 Ring 3 → Ring 0 的栈切换支持。在中断门或系统调用触发从用户态切换到内核态时,CPU 自动从当前 CPU 的 TSS 中加载 SS0 / ESP0 作为临时新栈顶。

  • 双重错误(#DF)异常处理 :Intel 推荐用 任务门 描述符来设置 IDT 中的第 8 号向量(#DF),这样就可以防止中断嵌套导致的堆栈损坏引发 Triple Fault。我们在调试这类异常的时候可以根据这一性质找到出现异常的堆栈。

    “Some exception handlers, such as the double-fault exception handler, are typically set up with a task gate in the IDT. This enables the processor to switch to a known-good TSS and clean stack.”
    — SDM Vol. 3, Section 6.15 “Exception and Interrupt Handling”

任务段描述符(TSS Descriptor)

任务段(TSS)描述符的定义如下:

TSSDescriptior

注意

根据《Intel SDM(System Developer’s Manual)》中的明确规定:

“TSS descriptors may only be placed in the GDT; they cannot be placed in an LDT or the IDT.”

任务段(TSS)描述符只能出现在 GDT(全局描述符表) 中,不能放在 LDT(局部描述符表)里。

  • 段基址(Base Address) :任务段(TSS)在内存中的线性地址,由三部分组成:Base 15:00Base 23:16Base 31:24,共同构成 32 位的段基址,用于定位 TSS 数据结构的起始地址。

  • 段限长(Segment Limit) :指定任务段的长度,通常为 0x67(即 104 字节),分为低 16 位(Limit 15:00)和高 4 位(Limit 19:16)。如果 G 位为 0,则按字节为单位解释该限长。

  • 类型(Type) :4 位,表示描述符的具体类型。对于 32 位可用任务段:

    • 类型值为 1001(0x9)说明该TSS段描述符未被加载到TR段寄存器中。
    • 类型值为 1011(0xB)说明该TSS段描述符已被加载到TR段寄存器中。
  • 系统段标志(S) :1 位,必须固定为 0,表示该描述符是一个系统段,而非普通的代码段或数据段。TSS、LDT、门描述符等都属于系统段。

  • 描述符特权级(DPL) :2 位,表示对该任务段的访问权限要求。在通过任务门或软件指令(如 CALL, JMP)触发任务切换时,当前特权级(CPL)必须 ≤ DPL,否则会触发 #GP(General Protection)异常。

  • 段存在标志(P) :1 位,表示该任务段是否有效。若为 0,则尝试访问此任务段会触发 #NP(Not Present)异常;为 1 时表示段存在且可以使用。

  • 可用位(AVL) :1 位,由操作系统自由使用,处理器不进行解释。常用于系统内部标记用途,例如多处理器状态跟踪或调试标志。

  • 默认操作数大小(D/B) :1 位,对于任务段必须设置为 0,否则行为未定义。该位在代码段中表示默认指令大小(16/32 位),但在 TSS 描述符中无实际意义。

  • 粒度(G) :1 位,控制段限长的单位。当为 0 时,段限长以字节为单位解释(TSS 推荐);若为 1,则以 4KB 页为单位(不建议用于 TSS)。

任务寄存器(TR,Task Register)

TR(Task Register,任务寄存器)是 x86 处理器中的一个系统段寄存器,用于引用当前正在使用的任务状态段(TSS)。

TR 寄存器会保存当前活动 TSS 的 段选择子(32 位下默认是 28),并缓存当前 TSS 的 基地址限长

image-20250604014705925

TR 寄存器可以通过 LTRSTR 指令进行读写,不过这两个指令都是 特权指令只能在特权级 0(CPL = 0)下执行

  • STR r16/m16:从 TR 读取当前任务段选择子。
  • LTR r/m16:向 TR 加载任务段选择子。

任务段(TSS,Task State Segment)

任务段(TSS,Task State Segment)是 x86 架构中为支持多任务操作系统设计的一个特殊机制,它定义了一段特殊的内存结构,用于保存任务(线程/进程)的运行上下文和特权级切换信息

虽然现代操作系统(如 Windows 和 Linux)不再使用“硬件任务切换”(TSS 最初的主要目的),但 TSS 在 中断处理、安全控制、特权切换时仍然是必不可少的组件

image-20250604014908047

  • Previous Task Link :上一个任务字段。如果使用任务切换(如使用jmp TSS selector),这里保存前一个任务的 TSS 选择子。由于 Windows 基本不使用硬件任务切换(而是用软件方式),因此此字段一般无实际用途。

  • 栈切换相关字段

    • ESP0 / SS0 : Windows 在处理系统调用(如sysenterint 0x2e)和异常(如页错误)时依赖这两个字段快速切换内核栈。当 CPU 从用户态(CPL=3)切换到内核态(CPL=0)时,会自动加载这两个字段的值作为内核栈的临时基地址(SS:ESP)。
    • ESP1 / SS1,ESP2 / SS2 :较少用,仅在三层栈(Ring1、Ring2)情况下切换,Windows 不使用,这些字段保留。
  • CR3(PDBR) :存储页目录基地址寄存器(CR3),用于指向进程的页表。如果用任务切换机制,CPU 会自动加载 CR3 切换页表。

  • 通用寄存器和段寄存器 :这些是当通过任务切换机制(CALL/JMP TSS)切换任务时自动保存的内容。

    • 通用寄存器 :EAX, EBX, ECX, EDX, ESI, EDI, EBP, ESP
    • 段寄存器 :ES, CS, SS, DS, FS, GS
    • 其他 :EIP、EFLAGS
  • LDT Segment Selector :指定当前任务使用的 LDT(Local Descriptor Table)。

  • T(Trap)位 :如果置 1,则在任务切换到此 TSS 时触发调试异常(#DB)

  • I/O Map Base Address :指定 TSS 后面的 I/O 权限位图偏移,用于控制任务是否可以访问某些 I/O 端口,某些驱动或 VM 技术中使用。

  • SSP(Shadow Stack Pointer) :当启用 Intel CET(Control-flow Enforcement Technology)时,记录 Shadow Stack 的指针。Windows 10+ 在启用 CET 保护(比如 MS Edge 浏览器)时会使用它。

    Intel CET(Control-flow Enforcement Technology,控制流强制技术)是 Intel 推出的一个 硬件级安全特性。CET 有两个核心组件:

    • Shadow Stack(影子栈) :CPU 维护一个只写入返回地址的“只读”影子栈。每次函数调用(CALL)时,返回地址会同时写入正常栈和影子栈。当执行 RET 时,CPU 会比对影子栈的返回地址,不一致则触发异常。
    • Indirect Branch Tracking(IBT) : 限制间接跳转(JMPCALL 到寄存器/内存)只能跳转到有效的目标地址。要跳转的目标必须以特殊指令(如 ENDBR32ENDBR64)开头,否则触发异常。

ntkrpamp!_KTSS 是 Windows 内核中用于描述任务状态段(TSS,Task State Segment)的结构体,主要用于 x86 架构下的特权级堆栈切换、I/O 端口访问控制等功能。

虽然现代 Windows 系统主要采用软件方式进行任务切换,但仍为每个处理器维护一个 TSS 结构,特别是在处理特定异常(如双重故障)时,硬件任务切换机制仍可能被触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
//0x20ac bytes (sizeof)
struct _KTSS
{
USHORT Backlink; //0x0
USHORT Reserved0; //0x2
ULONG Esp0; //0x4
USHORT Ss0; //0x8
USHORT Reserved1; //0xa
ULONG NotUsed1[4]; //0xc
ULONG CR3; //0x1c
ULONG Eip; //0x20
ULONG EFlags; //0x24
ULONG Eax; //0x28
ULONG Ecx; //0x2c
ULONG Edx; //0x30
ULONG Ebx; //0x34
ULONG Esp; //0x38
ULONG Ebp; //0x3c
ULONG Esi; //0x40
ULONG Edi; //0x44
USHORT Es; //0x48
USHORT Reserved2; //0x4a
USHORT Cs; //0x4c
USHORT Reserved3; //0x4e
USHORT Ss; //0x50
USHORT Reserved4; //0x52
USHORT Ds; //0x54
USHORT Reserved5; //0x56
USHORT Fs; //0x58
USHORT Reserved6; //0x5a
USHORT Gs; //0x5c
USHORT Reserved7; //0x5e
USHORT LDT; //0x60
USHORT Reserved8; //0x62
USHORT Flags; //0x64
USHORT IoMapBase; //0x66
struct _KiIoAccessMap IoMaps[1]; //0x68
UCHAR IntDirectionMap[32]; //0x208c
};

注意

Windows 默认不使用 ESP1 / SS1ESP2 / SS2特权级备用栈(Ring 1 和 Ring 2 的栈),所以在它所定义的 _KTSS 结构体中,这些字段会被统一省略或标为保留(Reserved1NotUsed1)。

任务门描述符(Task Gate Descriptor)

TaskGateDescriptior

  • 段选择子(TSS Segment Selector) :表示该任务门对应的目标任务段(TSS)。该字段占据描述符的第 0–15 位,其值应为 GDT 中某个 TSS 段描述符的选择子。当任务门被触发时,处理器会使用该选择子来访问并加载目标 TSS,实现硬件任务切换。

    image-20250604014810467

  • 类型(Type) :4 位,位于描述符的第 40–43 位,用于标识该描述符的具体类型。对于任务门而言,该字段的值为 0101(即 0x5),表示“32 位任务门(Task Gate)”。该类型值要求处理器在使用该描述符时执行硬件任务切换流程

  • 系统段标志(S) :1 位,位于第 44 位,必须固定为 0,表示该描述符是一个 系统段,而非普通的数据段或代码段。所有任务门、中断门、陷阱门、TSS 和 LDT 描述符都属于系统段。

  • 描述符特权级(DPL) :2 位,位于第 45–46 位,表示访问该任务门所需的最小权限级别。在尝试通过 CALL, JMP, 或 INT 指令使用该任务门时,如果当前特权级(CPL)高于该值,将会触发 #GP(General Protection)异常。该字段用于控制访问粒度,例如可限制用户态程序不可使用该门。

  • 段存在标志(P) :1 位,位于第 47 位,用于表示该任务门描述符是否有效。当该位为 0 时,尝试访问该任务门会导致处理器抛出 #NP(Segment Not Present)异常。为 1 时表示描述符存在且可使用。

调用过程(任务切换机制)

“任务门调用”是间接的任务切换方式,通过门找到 TSS;“任务段描述符调用”是直接调用 TSS 来切换任务。它们最终都会导致 TSS 被加载和任务切换,但路径和机制不同。

比较点 任务段描述符调用 任务门调用
调用方式 CALL TSS_selectorJMP TSS_selector(选择子直接指向 TSS 描述符) CALL, JMP, 或 INT 到一个任务门(描述符中包含 TSS 选择子)
位置 TSS 必须在 GDT 中 任务门可以在 GDT、LDT、IDT
是否间接 ❌ 否,直接引用 TSS ✅ 是,门中保存 TSS 的选择子
可用于中断 ❌ 否,不能嵌入到 IDT ✅ 是,任务门可以作为中断门放入 IDT(任务型中断)
权限检查点 直接检查 TSS 描述符的 DPL 先检查门的 DPL,再检查 TSS 描述符的 DPL
用途 通常用于显式调度(早期OS设计) 可用于中断/异常自动触发任务切换(支持隔离的 handler)
效率与灵活性 较低 稍好,但仍落后于软件上下文切换
现代操作系统使用情况 ❌ 弃用 ❌ 弃用(包括 Windows/Linux)
任务段描述符调用过程

使用 CALL TSS_selectorJMP TSS_selector 指令,其中选择子(Selector)指向 GDT 中的任务段描述符(TSS Descriptor)

  1. 权限检查max(CPL,RPL) ≤ DPL,否则 #GP(selector)

  2. 存在检查 :P=0 → #NP(selector)

  3. 类型检查 :Busy TSS 调用 → #GP(selector)

  4. 段限长检查 :limit < 0x67 → #GP(selector)

  5. 加载新任务

    • 将当前任务的上下文(寄存器、EIP、EFLAGS、段寄存器等)保存到当前 TSS;

    • 设置 TR(Task Register)指向新的 TSS;

    • 目标 TSS 类型字段自动由 0x9 → 0xB,表示忙状态。

    • 载入新任务状态 :CR3、LDTR、EFLAGS、EIP、通用/段寄存器

    • CALL:设置 EFLAGS.NT=1 并把当前 TSS 段选择子写到新 TSS.BacklinkJMP 不设置。因此如果想用 JMP 进行任务切换直接 iretd 返回会蓝屏。

      When a CALL instruction, an interrupt, or an exception causes a task switch:
      the processor copies the segment selector for the current TSS to the previous task link field (Backlink) of the TSS for the new task;
      it then sets EFLAGS.NT = 1.

      当一个 CALL 指令、中断或异常 触发任务切换时:
      处理器将当前 TSS 的段选择子写入新任务的 TSS 的 Backlink 字段,并设置 EFLAGS.NT = 1

      When a JMP instruction causes a task switch, the new task is not nested.
      The previous task link field is not used and EFLAGS.NT = 0.

      当一个 JMP 指令 引发任务切换时,新任务不被视为嵌套任务,不会使用 backlink,EFLAGS.NT = 0

任务门调用过程
  1. 权限检查 对任务门(同样比较 CPL/RPL 与 DPL)

  2. 取出任务门中的 TSS Selector

  3. 后续步骤与直接调用任务段相同。

返回过程
  1. 当执行 iretd 指令的时候,CPU 发现 EFLAGS.NT=1,识别为任务返回

    image-20250606011634326

    • 任务状态段(TSS)中的“前一个任务链接字段”(Previous Task Link,有时也称为“backlink”)以及 EFLAGS 寄存器中的 NT 标志,用于将控制流返回到前一个任务

    • EFLAGS.NT = 1 时,表示当前正在执行的任务是嵌套在另一个任务之内

    注意

    如果我们调试任务切换的代码,由于 int 3 中断会将 EFLAGS.NT 为置 0,导致返回的时候是通过堆栈返回,造成蓝屏。

  2. 读取当前 TSS 的 Backlink → 找到前一个 TSS 描述符。

  3. 对当前任务段执行与调用流程相同的有效性检查(P=1, limit≥0x67, Type=Busy

  4. 保存当前任务状态到当前 TSS,因为 IRET 任务返回也属于任务切换。

  5. 清除当前 TSS Busy 位。

  6. 加载前一任务 TSS 内容 → 寄存器、EIP、EFLAGS。

    注意

    如果 分页未启用(即 CR0.PG = 0),那么虽然 TSS 中的 CR3 字段仍会被读出来,但不会写入 CR3 寄存器。在 Intel 开发文档原文如下:

    If paging is not enabled, a PDBR value is read from the new task’s TSS, but it is not loaded into CR3.

  7. 写 TR = 前一 TSS selector,恢复其 Busy 状态。

  8. 清除 EFLAGS.NT

  9. 跳回前一任务继续执行

页式内存管理

Intel CPU 支持下面几种分页模式:

分页模式 地址宽度 页目录结构 页大小 是否支持 NX(DEP)
非 PAE 分页 32-bit 2 级(10+10+12)
CR3 → PDE → PTE
4 KB ❌ 否
PAE 分页 36-bit(最多64GB) 3 级(2+9+9+12)
CR3 → PDPT → PDE → PTE
4 KB, 2 MB ✅ 是
x64 长模式分页 48-bit(理论) 4 级(9+9+9+9+12)
CR3 → PML4 → PDPT → PDE → PTE
4 KB, 2 MB, 1 GB ✅ 是

页式内存管理由 CPU 和操作系统共同维护:

  • CPU 主要负责地址转换过程。CPU 在执行指令时,如果涉及内存访问(如 mov eax, [esp+0x10]),就会自动触发地址翻译流程(虚拟 → 线性 → 物理)。
  • 操作系统主要负责页表结构维护。操作系统负责在内存中创建、更新和删除页表各层的表项,以决定每个虚拟地址该映射到哪个物理地址。

有时候 CPU 还要与操作系统紧密配合,比如如果 CPU 发现访问的地址页表中标记为不存在(P=0),则触发 #PF 缺页异常,此时需要由操作系统进行缺页处理或报错终止程序。

环境配置

Windows XP 32 位

Windows XP 32 位按照是否支持 PAE 和是否支持多处理器分为 4 个内核。

文件名 支持 PAE 支持 SMP(多处理器) 用途与说明
ntoskrnl.exe ❌ 否 ❌ 否 标准 单处理器非 PAE 模式使用
ntkrnlpa.exe ✅ 是 ❌ 否 单处理器,启用 PAE 的内核
ntkrnlmp.exe ❌ 否 ✅ 是 多处理器 SMP,非 PAE 模式使用
ntkrpamp.exe ✅ 是 ✅ 是 多处理器 SMP,启用 PAE 的完整内核(PAE + MP 全支持)

我们通过 boot.ini 指定 /PAE 参数,并可通过 /KERNEL=xxx.exe 可以显式指定要加载的内核映像。

boot.ini 是 Windows NT 系列(如 Windows XP、2000、Server 2003)中用于控制启动引导参数的配置文件。它属于 NT Loader(ntldr)引导机制的一部分

1
2
3
4
5
6
[boot loader]
timeout=5
default=multi(0)disk(0)rdisk(0)partition(1)\WINDOWS
[operating systems]
multi(0)disk(0)rdisk(0)partition(1)\WINDOWS="Windows XP (PAE)" /fastdetect /PAE
multi(0)disk(0)rdisk(0)partition(1)\WINDOWS="Windows XP (No PAE)" /fastdetect /NOPAE

启动时你将看到两个选项:

  • Windows XP (PAE) 会启用 PAE 分页,可能加载 ntkrnlpa.exe / ntkrpamp.exe
  • Windows XP (No PAE) 会强制关闭 PAE,使用 ntoskrnl.exe / ntkrnlmp.exe

Windows Vista / 7 32 位

Windows Vista / 7 32 位后不再区分 mpup,统一支持 SMP。也不再使用 ntkrnlmp.exe 等文件。并且这个版本之后不再使用 boot.ini 而是使用 bcdedit 修改 PAE 状态。

文件名 支持 PAE 支持多核 (SMP) 说明
ntoskrnl.exe ❌ 否 ✅ 是 默认内核映像,未启用 PAE 时使用
ntkrnlpa.exe ✅ 是 ✅ 是 启用 PAE 时使用(通过 bcdedit /set pae ForceEnable

从 Windows Vista 开始(包括 Windows 7/8/10/11)就已经彻底废除了 boot.ini ,转而使用新的引导配置机制 BCD(Boot Configuration Data)。该机制通过 bcdedit 命令进行配置,具体步骤为:

  1. 复制当前默认启动配置

    1
    bcdedit /copy {current} /d "Windows 7 - No PAE"
    • 这会复制当前启动项,生成一个新 GUID(比如 {3e9a0...})。
    • 新启动项会出现在启动菜单中,名字叫 “Windows 7 - No PAE”

    正常情况下系统返回类似下面这段输出:

    1
    The entry was successfully copied to {3e9a0123-xxxx-xxxx}.
  2. 给这个新启动项设置关闭 PAE 参数

    1
    bcdedit /set {3e9a0123-xxxx-xxxx} pae ForceDisable

再次开机时会看到:

1
2
Windows 7
Windows 7 - No PAE
  • 第一个启用 PAE,可能支持 DEP、安全保护机制;
  • 第二个禁用 PAE,用于兼容旧驱动或调试。

Windows 64 位

Windows 7 / 10 / 11 等 64 位系统使用长模式分页,ntoskrnl.exe 已合并所有功能模块,不再有 PAE / 非 PAE 区分,也不再需要多个版本。

文件名 支持 PAE 支持多核 (SMP) 说明
ntoskrnl.exe ✅ 是 ✅ 是 唯一内核映像,默认启用 PAE + SMP + NX(DEP)等

控制寄存器

CR0CR4 是 x86 架构中的 控制寄存器(Control Registers),它们是 CPU 控制系统级特性和行为的关键寄存器。这些寄存器 只能在特权级 0(Ring 0)下访问,用于开启分页、保护模式、中断响应、缓存控制等功能。

image-20250609044743350

名称 状态 主要功能
CR0 使用中 启用保护模式、分页机制、缓存控制、对齐检查、写保护等
CR1 保留 未使用,Intel 架构中始终保留
CR2 使用中 保存最近一次页错误异常(#PF)的线性地址
CR3 使用中 页目录基地址(或 PAE 下页目录指针表地址),分页的起点
CR4 使用中 启用各种扩展功能,如 PAE、大页、PGE、SMEP、SMAP 等
CR8 64 位专用 控制 中断优先级(Task Priority Level),用于高级中断屏蔽

CR0

CR0 控制的是处理器的基本操作模式,特别是保护模式、分页机制、缓存和协处理器等。其关键位如下:

位号 名称 含义 & 功能
31 PG 📌 Paging Enable :启用分页机制。需要 PE=1(保护模式)才能生效。
30 CD 📌 Cache Disable :禁止 CPU 缓存,设为 1 时强制所有访问绕过缓存(需配合 NW=0)。
29 NW 📌 Not Write-through :当 CD=0 时决定写回/直写策略(Write-back/Write-through)。
18 AM Alignment Mask :启用对对齐错误的检测。需 EFLAGS.AC=1CPL=3 且当前处于保护模式或 V8086 模式。
16 WP 📌 Write Protect :设置为 1 时,内核模式代码无法写入只读页(即使 U/S=0)。常用于 Copy-On-Write(COW)。
5 NE Numeric Error :启用内部机制报告 x87 浮点错误;为 0 时使用 PC 式 FERR# 引脚。
4 ET Extension Type(已弃用):在现代 CPU 中固定为 1。
3 TS Task Switched :任务切换后由 CPU 设置,用于延迟保存 FPU/MMX/SSE 状态。与 EM/MP 配合控制 FPU 异常。
2 EM Emulation :启用浮点指令模拟(禁用 FPU、MMX、SSE 指令,抛出 #NM / #UD)。
1 MP Monitor Coprocessor :配合 TS 控制 WAIT/FWAIT 指令是否触发 #NM。
0 PE 📌 Protection Enable :启用保护模式。仅此位不足以开启分页,需同时 PG=1 才行。

CR2

内容 用途
Page Fault Linear Address 仅当发生页错误(#PF)时,由处理器自动写入导致错误的线性地址。调试和异常处理中使用频繁。

CR3

仅在分页启用(CR0.PG=1)时生效,用于指示页目录(或 PDPTE)的物理地址:

位号 名称 功能
31:12 Page Directory Base 页目录/页目录指针表的物理地址高位。必须 4KB 对齐,低 12 位为 0。
4 PCD Page-level Cache Disable:控制页表访问的缓存行为。为 1 则关闭缓存。
3 PWT Page-level Write-Through:控制页表访问的写策略。
2:0, 11:5, 63:32 - 保留/忽略位,在 PAE 或 IA-32e 模式下含义不同。

注意:在 PAE/64 位模式下,CR3 不直接索引 PDE/PTE,而是用于定位 PDPTE 表

CR4

提供了分页、虚拟化、异常处理、SIMD 等新机制的开关,位数最多,关键位如下:

位号 名称 功能简述
0 VME Virtual-8086 Mode Extensions(虚拟中断处理)
1 PVI Protected-mode Virtual Interrupts,支持虚拟 8086 中断
2 TSD 限制 RDTSC 指令只能在 CPL=0 执行
3 DE 禁用对 DR4/DR5 寄存器的访问(生成 #UD),否则 CPU 会将对 DR4DR5 的访问重定向到 DR6DR7,这是为了兼容旧软件(早期 IA-32 处理器中曾使用 DR4/DR5)
4 PSE 📌 启用 4MB 大页(非 PAE 下)
5 PAE 📌 启用物理地址扩展(使用 PDPTE 层、36 位物理地址)
6 MCE 启用机器检查异常(Machine Check Exception)
7 PGE 📌 启用全局页(带 G 标志的页不因 CR3 更新而清空 TLB)
8 PCE 用户态也能执行 RDPMC 指令
9 OSFXSR OS 支持 FXSAVE/FXRSTOR,支持 SSE/SSE2
10 OSXMMEXCPT OS 支持 SSE 异常处理(#XM)
11 UMIP 用户模式禁止访问 SGDT/SIDT/STR/SMSW/SLDT
12 LA57 启用 5 级分页,支持 57 位线性地址
13 VMXE 启用虚拟化扩展(VMX)
14 SMXE 启用安全模式扩展(SMX)
16 FSGSBASE 允许直接读写 FS/GS 基址
17 PCIDE 启用进程上下文标识(不刷新 TLB 切换上下文)
18 OSXSAVE OS 支持 XSAVE/XRSTOR/XGETBV/XSETBV
19 KL 启用 AES Key Locker
20 SMEP Supervisor Mode Execution Protection(防止内核执行用户页)
21 SMAP Supervisor Mode Access Protection(防止内核访问用户页)
22 PKE 启用用户页保护键(Protection Key)机制
23 CET 启用控制流强制(CET)技术
24 PKS 启用 supervisor 页面的保护键机制

CR8

位号 名称 功能
3:0 TPL Task Priority Level:中断优先级阈值;0 表示允许所有中断,15 表示屏蔽所有中断。仅在 IA-32e 模式(64 位)使用。

非 PAE 分页

在 32 位系统中,如果 CR0.PG = 1(启用分页)且 CR4.PAE = 0(未启用 PAE)则为非 PAE 分页模式,也就是我们常说的 10-10-12 分页模式。

页表结构

在不考虑大页的情况下,非 PAE 分页模式的页表结构如下图所示。

NO-PAE

该模式通过 2 级页表结构,将 32 位的线性地址映射到 最多 4GB 的物理地址空间

名称 层级 数量与结构 每项大小 / 总大小 作用说明
页目录表(Page Directory) 一级结构 每进程 1 个,含 1024 个页目录项(PDE) 每项 4 字节,共 4KB 每项控制 4MB 线性空间,可指向页表或直接映射一个 4MB 大页(需 CR4.PSE=1
页表(Page Table) 二级结构 最多 1024 个,每表含 1024 个页表项(PTE) 每项 4 字节,共 4KB 每项映射一个 4KB 物理页框,控制 4KB 线性空间。由 PDE 间接索引获得
地址转换过程

非 PAE 分页模式的线性地址到物理地址的转换过程如下图所示:

image-20250607022406579

线性地址由高到低划分为 10 位 PDE 索引10 位 PTE 索引12 位页内偏移,其中 10,10,12 恰好对应:

  • 一个页目录表包含 2102^{10} 个 PDE。
  • 一个页表包含 2102^{10} 个 PTE。
  • 一个内存页大小为 2122^{12} 字节。

例如假设线性地址为 0x12345678,则具体的转换过程如下:

字段 含义
PDE Index 0x48 CR3 指向页目录 → 第 0x48 项 PDE
PTE Index 0xD1 PDE 指向的页表 → 第 0xD1 项 PTE
Offset 0x678 页内偏移,定位物理页中的位置
页目录自映射

另外,为了方便操作系统在内核态访问并维护自身的页表结构,Windows 会设置页目录表中 0x301 项(769 项)页目录项 PDE 指向页目录表自身的物理地址,实现所谓的 页目录自映射(Page Directory Self-Mapping)机制。这样,整个页目录表和所有页表都可以通过一组固定的线性地址访问。

NO-PAE-flat

  • 页目录表线性地址为:0x300 << (12 + 10) | 0x300 << 12 = 0xC0300000

    • N 个 PDE 的线性地址为:0xC0300000 + (N - 1) * 4(后面加的是第 N 个 PDE 在页目录表中的偏移)

    • 第 0x301 个 PDE 存放着页目录表的物理地址,对应线性地址为:0xC0300000 + 0x300 * 4 = 0xC0300c00

      提示

      因此我们可以从 0xC0300c00 地址读取到页目录表的物理地址

    • 线性地址 p 对应的 PDE 的线性地址为:0xC0300000 | ((p >> (12 + 10)) * 4) = 0xC0300000 | p >> 20 & 0xFFC

  • 第一个 PDE 指向的页表(如果有)基址对应的线性地址为:0x300 << (12 + 10) = 0xC0000000

    • N 个 PDE 指向的页表(如果有)基址对应的线性地址为:0xC0000000 | (N - 1) << 12 = 0xC0000000 + (N - 1) * 0x1000

    • N 个 PDE 指向的页表的第 M 个 PTE的线性地址为:0xC0000000 | (N - 1) << 12 | (M - 1) * 4 = 0xC0000000 + (N - 1) * 0x1000 + (M - 1) * 4

    • 线性地址 p 对应的 PTE 的线性地址为:0xC0000000 | ((p >> 12) * 4) = 0xC0000000 | p >> 10 & 0x3FFFFC

      提示

      这个公式计算 PTE 的 PTE 时会找到指向 PTE 所在页表的 PDE 的线性地址;同理在计算 PDE 的 PTE 时会找到 PDE[0x300] 的线性地址。

页表属性

在非 PAE 分页模式下,页表相关属性如下:

image-20250607042913949

其中 PDE(页目录项)主要有两种类型,取决于 PDE 的第 7 位(PS 位) 的取值,这两种 PDE 的结构稍有不同。

页大小 PS 位 解释方式 PDE 指向的对象 后续结构
4KB 0 二级页表结构 指向一个页表(PTE 数组) 需要查找 PTE
4MB 1 一级页表结构(大页) 直接映射一个4MB 页框 不查 PTE,直接定位物理页
CR3

CR3

  • 页目录基地址(Address of page directory)CR3 的 Bits[31:12] 存储的是页目录的 物理地址的高 20 位(即第 12~31 位)。因为页目录必须 4KB 对齐,因此其低 12 位始终为 0,不会在 CR3 中存储。另外 Bits[63:32] 仅在 64 位模式下使用,在普通 32 位分页中忽略。

  • 页级写通标志(PWT, Page-level Write Through) :位于 CR3 的 第 3 位,指定访问页目录时所用内存类型是否为直写缓存策略(Write-Through)。仅在启用页表缓存机制时生效,属于 间接影响

    直写缓存策略(Write-Through)指的是每次 CPU 写数据到缓存时也会立即写入主内存,也就是说主内存始终是最新数据。

  • 页级缓存禁止(PCD, Page-level Cache Disable) :位于 CR3 的 第 4 位,指定是否对页目录表所在的内存区域禁用缓存。如果设为 1,则 CR3 所指向的整个页目录将不会被缓存,即CR3 所指向的页目录表所在内存区域,不会进入 CPU 的缓存系统(如 L1/L2/L3 cache),每次访问都直接访问内存(DRAM)

提示

在调试的时候,我们可以通过 !process 0 0 来获取页目录基地址

1
2
3
4
5
6
7
8
kd> !process 0 0
.
.
.
Failed to get VadRoot
PROCESS 8a01dda0 SessionId: 0 Cid: 0498 Peb: 7ffde000 ParentCid: 05f8
DirBase: a9024000 ObjectTable: e1bf5dc8 HandleCount: 52.
Image: notepad.exe

其中 DirBase 的值 a9024000 就是页目录基地址物理地址

PDE

首先对于 4KB 页对应的 PDE,该页目录项的结构如下:

PDE-4K

  • 存在位(P, Present) :表示该 PDE 是否有效,这里需要设置为 1 表示有效。

    • 1:该 PDE 有效,处理器可使用;
    • 0:该 PDE 无效效,访问相关内存地址会触发 #PF(Page Fault)
  • 读/写标志(R/W, Read/Write) :控制该页是否可写。

    • 0:只读;
    • 1:可读可写(实际受 CR0.WP 影响)。
  • 用户/特权标志(U/S, User/Supervisor) :控制访问权限级别。

    • 0:仅 CPL 0~2(内核)可访问;
    • 1:所有特权级(包括用户态)可访问。
  • 页级直写缓存策略(PWT, Page-level Write Through) :控制缓存策略。

    • 1:Write-Through(写同时写入主存和缓存);
    • 0:Write-Back(只写缓存,主存延迟刷新)。
  • 页级缓存禁止(PCD, Page-level Cache Disable) :控制是否禁用该页表所在区域的缓存。

    • 1:禁止缓存,每次访问都直接访问主内存;
    • 0:允许缓存。
  • 访问位(A, Accessed) :由处理器在访问该 PDE(即页表)时自动置 1。OS 可用来统计页面活跃度、用于换页算法。

  • 页大小标志(PS, Page Size) :指定 PDE 是映射页表还是映射 4MB 大页。

    • 0:PDE 指向一个 页表(常见);
    • 1:PDE 直接映射一个 4MB 大页(需 CR4.PSE=1)。
  • 页表物理基地址(Page Table Base Address) :用于指定页表的 物理地址的高 20 位,表示该 PDE 所对应页表的实际位置。因为页表是 4KB 对齐,低 12 位为 0,因此未在 PDE 中存储,实际页表地址为 PDE[31:12] << 12

而对于 4MB PDE,由于是直接记录物理页的地址,因此比 4KB PTE 多了一些 PTE 相关的属性。另外就是由于从 10-10-12 变成了 10-22,也就是说指向的物理页大小为 2222^{22} 字节,基址关于 2222^{22} 对齐,因此只需要 32 - 22 = 10 比特来记录物理页的物理地址。

image-20250607234821590

4MB PDE 的各字段分布如下:

PDE-4M

相对于 4KB PDE 有变化的属性如下:

  • 脏位(D, Dirty) :当该页被写入过时,自动由 CPU 置 1,用于操作系统判断是否需要回写磁盘。

  • 页面大小(PS, Page Size) :固定为 1,表示这是一个 4MB 大页项,而非指向页表(4KB 页面)。

  • 全局页(G, Global) :为 1 时表示该页为全局页(不因 CR3 切换被 TLB 清除),仅当 CR4.PGE=1 有效。

  • PAT 位(Page Attribute Table) :CPU 用来选择内存缓存类型的一个控制位,它和 CR0、CR3 中的 PCD/PWT 配合,最终决定某一页(或一块内存)以什么样的缓存策略访问

  • 页框基址(Page Frame Address) :存储 4MB 页起始的物理地址高 10 位。低 22 位默认为 0(因为对齐到 4MB)。另外如果 支持 PSE-36 机制 并且 处理器支持的最大物理地址宽度为 36,则页框基址可以存储 14 位,可以映射 64 GB 的物理地址。

PTE

PTE

前面两种 PDT 基本包含了 PTE 的所有属性,PTE 主要变化就是在 4MB PDT 的基础上把 PAT 字段移动到了 PS 字段上(PTE 不需要 PS 字段)。

PAE 分页

在 32 位处理器中,若设置 CR0.PG = 1(启用分页) CR4.PAE = 1(启用物理地址扩展),则启用 PAE 分页模式,也就是我们常说的 2-9-9-12 分页模式。此时每个进程的线性地址空间仍为 4GB,但可访问的物理地址上限扩展到 64GB(36 位地址)

页表结构

PAE 模式采用 3 级页表结构在不考虑大页的情况下,PAE 分页模式的页表结构如下图所示。

PAE

名称 层级 数量与结构 每项大小 / 总大小 作用说明
页目录指针表(PDPT) 顶层结构 每进程 1 个,含 4 项 每项 8 字节,共 32B 每项指向一个页目录,控制 1GB 线性空间
页目录表(Page Directory) 一级结构 最多 4 个,每表含 512 项 PDE 每项 8 字节,共 4KB 每项指向一个页表,或直接映射 2MB 大页(需 PS=1)
页表(Page Table) 二级结构 最多 512 个,每表含 512 项 PTE 每项 8 字节,共 4KB 每项映射 4KB 物理页,提供最小的线性地址映射单元
地址转换过程

PAE 分页模式的线性地址到物理地址的转换过程如下图所示:

image-20250608014033134

PAE 模式下将 32 位线性地址按如下方式拆分,即 2-9-9-12 分层结构。

  • Bits[31:30](2 位):选择 PDPT 中的页目录项(最多 4 项)
  • Bits[29:21](9 位):选择页目录中的 PDE(最多 512 项)
  • Bits[20:12](9 位):选择页表中的 PTE(最多 512 项)
  • Bits[11:0](12 位):页内偏移(4KB)

如果是大页模式,则线性地址低 21 比特作为 2MB 大小物理页的页内偏移。
image-20250608014252251

页目录自映射

PAE 分页模式下的页目录自映射(Page Directory Self-Mapping)机制也发生变化。由于 PDPTE 代替了 CR3 的功能,因此需要由第 4 个页目录表实现页目录自映射机制。该页目录表的前 4 项分别指向 4 个页目录表,因此在线性地址空间中,4 个页目录表以及对应映射的页表分布如下:

PAE-flat

根据页目录自映射的特性可知,对应一个线性地址 p

  • 对应的 PTE 的线性地址为:0xC0000000 | ((p >> 12) * 8) = 0xC0000000 | (p >> 9 & 0x7FFFF8)
    • (p >> 12) * 8 中右移 12 是在计算页框编号,也就是第几个 PTE;乘 8 是在计算页框号对应的 PTE 的偏移。这种计算方法是从页表分布上推导出来的。
    • (p >> 12) * 8 = p >> 9 & 0x7FFFF8,而右移 9 使得 32 位地址中最高 2 位放在 2-9-9-12 中第一个 “9” 的范围内,从另一个角度印证了只需要第 3 个页目录表的前 4 个 PDE 就可以实现页目录自映射机制
  • 对应的 PDE 的线性地址为:0xC0600000 | ((p >> (12 + 9)) * 8) = 0xC0600000 | p >> 18 & 0x3FF8

提示

  • PAE 分页模式物理地址寻址增大不是因为分页层数增多,而是因为每一个页表项从 4 字节增加到 8 字节,导致物理地址字段长度增大。
  • 新增页目录指针表(PDPT)的原因是每一个页表项从 4 字节增加到 8 字节,导致索引范围从 1024(10 bits)减小到 512(9 bits),空出了 2 bits。
  • 页目录指针表(PDPT)的作用是代替非 PAE 模式的 CR3,从而避免利用页目录自映射(Page Directory Self-Mapping)机制从固定线性地址读取到 CR3 存储的值。

页表属性

image-20250608023903628

CR3

在开启 PAE 之后,CR3 寄存器的字段发生了一些变化。

CR3-PAE

  • CR3 在 PAE 模式下,其 Bits[31:5] 存放 PDPTE 表的物理地址高位(因 32 字节对齐,所以低 5 位为 0)。

    提示

    在调试时我们可以通过 !process 0 0 输出的 DirBase 是关于 0x20 还是 0x1000 对齐快速判断出当前是否是 PAE 分页模式。

  • 将 CR3 中的 PWTPCD 字段转移到了 PWPTE 中。

PDPTE

PDPTE

PDPTE 中主要承担了非 PAE 分页中的 CR3 的功能,唯一的区别就是 PDPTE 比 CR3 多了一个 P 标志位表示该 PDPTE 是否有效。

另外就是由于物理地址增大到 36 bits,因此页目录表地址字段增大到 36 - 12 = 24 bits。

PDE

PDE-4K-PAE

对于 PS 位为 0 的 PDE,各字段分布基本和非 PAE 分页模式一致,只不过页表地址字段从原来的 20 bits 增加到 24 bits。

另外 PAE 模式的 PDE 新增了一个 XD 字段,即 Execute Disable(不可执行页)位。如果启用了 IA32_EFER.NXE(No eXecute Enable),则可以使用该位禁用页的代码执行,用于实现 DEP(数据执行保护) 等安全特性。如果 NXE 没启用,该位为 保留位(必须为 0)。

IA32_EFER.NXE 是 Intel x86 架构中一个控制位,用于 开启“不可执行页”功能(Execute Disable,XD),位于 IA32_EFER(扩展功能使能寄存器)第 11 位

IA32_EFER 是一个寄存器,更准确地说,它是一个 Model-Specific Register(MSR,模型特定寄存器)。因为这个寄存器是 Intel 专门定义的一类控制寄存器,不像通用寄存器那样在汇编中直接访问。要通过 RDMSRWRMSR 指令来读写。

PDR-2M-PAE

对于 PS 位为 1 的 PDE,各字段分布基本和非 PAE 分页模式同样基本一致。不同点是由于大页的大小有 4MB 减半到 2MB,因此地址字段的起始位从 22 减小到 21。

PTE

PTE 除了内存页物理地址字段长度增加外基本无变化。

PTE-PAE

缓存

分页不仅仅是地址映射,它与 CPU 的缓存体系(L1/L2/L3 Cache)与 TLB(Translation Lookaside Buffer) 紧密配合,高效完成地址转换与缓存一致性管理。

flowchart TD
    %% ───── 1. 发起访问 ─────
    subgraph LA["线性地址"]
        A0["发起内存访问
(CPU 指令)"] end A0 --> B1[TLB 查询] %% ───── 2. TLB 分支 ───── B1 -->|命中| C0[得到物理页基址
+ 权限/属性
Write/Exec 检查] B1 -->|未命中| PWalk["Page Walk
(CR3→PDPTE→PDE→PTE)"] %% ---- Page Walk 子流程 ---- subgraph Page_Table["页表遍历 (硬件完成)"] PWalk --> P1[PDPTE
① 定位 1 GB 区域] P1 --> P2[PDE
② 定位 2 MB 区域
或继续页表] P2 --> P3[PTE
③ 定位 4 KB 页框] P3 --> PT_Exit[得到物理页基址] end PT_Exit --> UpdateTLB["写入/更新 TLB
(缓存映射)"] UpdateTLB --> C0 %% ---- 3. 权限检查 ---- C0 -->|权限允许| L1[L1 Cache 查询] C0 -->|权限不允许| PageFault[#PF → Page-Fault Handler] %% ---- 写时复制(COW) ---- PageFault -->|写时复制触发| DoCOW[COW:分配新页+复制+更新页表] %% 触发 invlpg,刷本虚拟页的 TLB 项 DoCOW -.invLPG(TLB flush).-> L1 %% 非法访问 PageFault -->|非法访问等| Kill[进程终止 / 抛异常] %% ---- 4. Cache → DRAM ---- L1 -->|命中| Return[返回数据] L1 -->|未命中| L2[L2 Cache 查询] L2 -->|命中| Return L2 -->|未命中| L3["L3 (LLC) 查询"] L3 -->|命中| Return L3 -->|未命中| DRAM[主存 DRAM 访问] DRAM --> FillCache["加载到 L1
(并按一致性写入 L2/L3)"] FillCache --> Return %% ---- 样式:仅热点节点上色(不含 DRAM) ---- classDef hot fill:#d5f5d5,stroke:#2c6a21; class C0,L1,L2,L3,FillCache,Return hot;

CPU 缓存层级结构(L1 / L2 / L3)

CPU Cache 是 CPU 内部用于缓存主存(DRAM)数据的硬件单元,用于提升访问频繁数据的速度。主要分为多级:

缓存级别 延迟(大致) 每核独立? 存什么? 特点
L1 Cache 1~3 cycle ✅ 是 指令 / 数据 最快
L2 Cache 10~20 cycle ✅ 是 L1 的后备
L3 Cache 40~70 cycle ⛔ 否,共享 所有数据(共享页表) 更大更慢

这些缓存用于存储「最近访问的内存数据」,而数据来自「物理地址」,也就是说 CPU Cache 缓存的是“物理地址 → 数据”的映射。

缓存类型

内存的 缓存类型(Cache Type) 决定了 CPU 如何对某块物理内存进行缓存操作,包括:是否缓存、以何种策略缓存、如何写入内存等。缓存类型不仅影响性能,还关系到设备访问的一致性和可靠性(例如访问显存、MMIO 寄存器时必须禁止缓存)。

PAT 编码 MTRR 编码 名称(缩写) 中文释义 特征说明
00h 06h Write-Back (WB) 写回缓存 默认策略。读写均使用缓存(L1/L2/L3);写操作只更新缓存,延迟刷新主存(由缓存一致性机制控制)。性能最佳,支持预取、读写合并、乱序执行等,适用于普通主存数据访问
01h 04h Write-Through (WT) 直写缓存 写操作同时写入缓存和主存,保证主存数据始终最新;读操作仍可命中缓存。适用于对主存一致性要求高的区域,如共享内存。写性能低于 WB
02h 07h Uncached Minus (UC-) 弱无缓存 Intel 专有类型。行为类似 UC,但允许有限度的预取和请求合并。部分 CPU 可能仍使用缓存机制优化访问。适合性能敏感但对一致性要求略低的设备地址映射区域。
03h 00h Uncacheable (UC) 不可缓存 完全禁用缓存 :所有访问直接访问主存,不使用缓存、不支持预取、合并、乱序。适用于 MMIO、设备寄存器、显存帧缓冲对时序要求严格的场景。
04h 01h Write-Combining (WC) 写合并 不缓存读操作,但写操作可合并多个写入请求后批量写入主存。适用于图像缓冲区、视频帧缓冲、DMA 区域等大吞吐场景写性能提升显著,但不保证一致性。
05h 05h Write-Protected (WP) 只读缓存 只读可缓存,但所有写操作必须访问主存(写不命中缓存)。适用于代码段或受保护数据页防止意外修改或提升写安全性
07h 00h UC(冗余编码) 与 UC 等效 03h 完全等价,行为与 UC 一致,出于兼容性考虑保留此编码。部分 BIOS/固件使用该编码表示 UC。

我们常见的几种内存的缓存机制如下:

区域 推荐类型 原因
显卡显存(MMIO) UC / WC 避免缓存污染 / 提高写效率
页表 / CR3 WT / UC 保证一致性
普通内核代码页 WB 高效指令读取
DMA 缓冲区 UC / WC 避免设备与 CPU 缓存不一致
用户态栈 / 堆 WB 高频读写,性能关键
缓存类型的配置机制

缓存类型是 多个机制共同决定的结果,包括:

来源 描述
页表项的 PCD / PWT / PAT 位 控制页级别的缓存策略
IA32_PAT 寄存器 定义 PAT 编码与缓存类型的映射
MTRR(Memory Type Range Register) 控制物理地址范围的默认缓存类型
固定类型内存(如设备寄存器) 固定为 UC(不可缓存)或 WC(写合并)等类型

每个页表项(PDE/PTE)都有这三位控制缓存:

位名 含义 取值 1 的效果
PWT Page-level Write-Through 采用 Write-Through 策略
PCD Page-level Cache Disable 禁用缓存(Uncached)
PAT Page Attribute Table 用于选择 PAT 中的映射项(解释见下)

这三位组成一个 3 位索引(b2b1b0 = PAT,PWT,PCD),共可表示 8 种编码(0~7),再由 IA32_PAT 映射到缓存类型。

编号(索引) 对应组合(PAT:PWT:PCD) 默认缓存类型(如 IA32 手册)
0 0:0:0 Write-Back (WB)
1 0:0:1 Write-Through (WT)
2 0:1:0 Uncached Minus (UC-)
3 0:1:1 Uncached (UC)
4 1:0:0 Write-Combining (WC)
5 1:0:1 Write-Protected (WP)
6 1:1:0 保留
7 1:1:1 UC(冗余)

最终使用哪种类型由 CPU 按一定优先级解析这些机制的组合。

1
2
3
4
5
6
7
8
9
10
1. 固定类型区域(如 ROM/显存)?
是 → 使用平台指定类型(如 UC)

2. 页表项中 PCD/PWT/PAT 存在?
是 → 用它们组成 PAT 索引 → 查询 IA32_PAT → 得到类型

3. MTRR 中匹配该物理地址?
是 → 使用 MTRR 设置的缓存类型

4. 否则 → 使用默认缓存策略(通常是 WB)

TLB(Translation Lookaside Buffer)

TLB 是一种「地址映射的缓存」,专门缓存页表翻译(虚拟 → 物理)。

TLB 属性

通常 TLB 中的每一项包含如下属性:

  • LA:线性地址(Linear Address)
  • PA:物理地址(Physical Address)
  • ATTR:权限与属性字段(如 R/W, U/S, PWT, PCD, PAT 等)
    • 非 PAE(10-10-12)下:ATTR = PDE 属性 & PTE 属性
    • PAE(或 4级页表,如 9-9-9-12)下:ATTR = PDPTE 属性 & PDE 属性 & PTE 属性
  • LRU:最近最少使用(Least Recently Used)统计信息,TLB替换策略之一

这些都是标准硬件 TLB 中会缓存的内容,尤其现代 CPU 会把 PTE/PDE/PDPTE 的属性字段也缓存进去,加快访问判断速度,避免再次查页表。

当 TLB 未命中时 → 需要查完整页表 → 增加延迟 → 加载结果再写入 TLB。

TLB 类型

在典型的 x86 CPU(从 Intel 486 起)中,为了提高虚拟地址到物理地址转换的效率,CPU 内部通常设置多组 TLB 缓存,按用途和页大小分为:

  • Instruction TLB for 4KB pages :用于缓存指令取值所需的 4KB 页映射;
  • Data TLB for 4KB pages :用于缓存数据访问所需的 4KB 页映射;
  • Instruction TLB for large pages (2MB/4MB) :用于缓存代码段中的大页映射;
  • Data TLB for large pages (2MB/4MB) :用于缓存数据段中的大页映射;

在现代 CPU 架构中,上述 TLB 有可能是多级的(L1/L2),并支持更多页大小(如 1GB),具体组织方式视 CPU 实现而定。

TLB 控制位
标志位 寄存器 作用
CR4.PGE CR4[7] 开启全局页(Global Page)支持;全局页不因 CR3 切换被刷新
PDE/PTE.G 页表项 bit 8 与 CR4.PGE 配合,标记页项为全局页,避免频繁清除
CR4.PCIDE CR4[17] 启用 PCID 支持,配合 CR3 实现不清 TLB 的上下文切换
TLB 刷新方式

TLB(Translation Lookaside Buffer)是一个 缓存虚拟地址到物理地址映射 的结构(即页表的副本)。当页表发生变更后,TLB 中的内容可能过时,这时候就必须刷新 TLB,防止地址转换错误。

常见的 TLB 刷新方式如下:

  • 写入 CR3(页目录基地址)寄存器 是最常见的刷新方式,不论 CR3 的值是否真的改变,CR3 寄存器写入都会导致 TLB 被清空(传统 x86)。不过这种方式性能损耗较大,因为所有地址映射都要重新查页表。如果禁用 CR4.PGE,G 页也会被清除。

    1
    2
    mov eax, cr3
    mov cr3, eax ; 触发完整 TLB flush
  • 局部刷新(invlpg 指令)只刷新一个页(以页为单位),但是可以无视 G 标志位强制刷新

    1
    invlpg [0x8048000] ; 使虚拟地址 0x8048000 的 TLB 项失效

驱动

Windows 基础

内核对象

内核对象(Kernel Object),是 Windows 内核里对各种内核资源进行统一管理、统一命名、统一访问控制、统一生命周期控制的一种抽象机制。

设计思想

在没有内核对象之前,Windows 内核里存在着大量不同类型的资源(如设备,文件,进程线程等),这些资源每种结构体都不一样,但是每种都要支持用户态访问(有名字,有权限控制,有引用计数,有安全策略)。

因此 Windows 采用了一种类似 Linux 的 “万物皆文件” 的设计思想,即将每个需要统一管理的内核资源都被包装成一个 “内核对象”,交由 Object Manager(对象管理器) 组件管理。这样可以统一的解决下面几个问题:

典型问题 Object Manager 提供的统一解法
内核里有驱动、设备、进程、互斥量、事件、共享内存等几十种资源,各自要命名、要 ACL、要引用计数、要调试符号。 定义 OBJECT_HEADER + OBJECT_BODY 模型;创建/打开/引用/关闭/删除流程全部交给 Object Manager(Ob)。
资源的生命周期复杂:谁来保证用完才释放? PointerCount(内核指针引用) + HandleCount(用户/内核句柄数量)双计数模型,当二者皆为 0 时由 Ob 自动回收。
用户进程需要安全地访问部分内核资源 SECURITY_DESCRIPTOR 嵌进对象;用户通过系统调用走 SeAccessCheck
调试/监控工具需要统一查看 所有命名对象都挂进 对象目录树(Directory Object);Windbg / ETW / AV 可以枚举。

内核对象结构

每个内核对象在被 Object Manager 在分配时,自动套上了一个统一的“对象头部”+(可能存在的附加信息头)+ 对象体数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
|---------------------------|
| POOL_HEADER | 内存池头部
|---------------------------|
| OBJECT_HEADER_NAME_INFO | 名字信息 (可选)
|---------------------------|
| OBJECT_HEADER_HANDLE_INFO | 句柄信息 (可选)
|---------------------------|
| OBJECT_HEADER_QUOTA_INFO | 配额信息 (可选)
|---------------------------|
| OBJECT_HEADER | 内核对象统一头部 (必定存在)
|---------------------------|
| OBJECT_BODY | 对象体结构 (如 DRIVER_OBJECT)
|---------------------------|

其中 OBJECT_HEADER 在内核对象中一定存在,该结构体在不同版本的 Windows 中会发生变化,下面是一些常见字段:

字段 作用
PointerCount (LONG) 内核所有“裸指针”引用计数
HandleCount (LONG) 所有进程句柄数量
Type (POBJECT_TYPE) 指向 DRIVER_OBJECT_TYPE / DEVICE_OBJECT_TYPE
Flags OB_FLAG_PERMANENT / EXCLUSIVE / KERNEL_MODE
InfoMask 标记是否有 Name/Handle/Quota 这三种可选头

注意

Win11 以后 PatchGuard 会随机调整可选头偏移,驱动代码必须使用官方宏(OBJECT_HEADER_NAME_INFO_OFFSET 等)而非写死偏移。

Object Manager 命名空间

Object Manager 命名空间 是 Windows 内核中统一管理一切内核对象的“对象目录树”。

  • 它的本质是一个内核内存中的目录树结构
  • 每个可以被命名的内核对象都被挂载在这棵树上;
  • Object Manager 负责解析路径、查找对象、引用计数、权限控制等一切逻辑。

整个 Object Manager 命名空间以 \ 为根目录,形成一棵类似文件系统的目录树。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
\                                (根目录,DirectoryObject)
├── Driver 所有驱动对象 (DriverObject)
│ └── MyDriver

├── Device 所有设备对象 (DeviceObject)
│ └── Harddisk0
│ └── Serial0

├── ?? ★ 符号链接桥梁目录 (SymbolicLinkObject)
│ ├── C: → \Device\HarddiskVolume1
│ ├── COM1 → \Device\Serial0
│ └── MyLink → \Device\MyDevice

├── BaseNamedObjects ★ 用户会话 0 命名空间(全局同步对象)
│ └── MyEvent
│ └── MyMutex

├── Sessions ★ 多用户会话隔离目录
│ └── 1
│ └── BaseNamedObjects (Session 1 的私有命名空间)

├── ObjectTypes ★ 已注册的内核对象类型列表
├── Windows (部分版本存在)
└── 其它系统内部目录

其中每一类目录的具体作用如下:

目录名 作用 常见对象类型
\Driver 存放所有已注册的内核驱动对象 DriverObject
\Device 存放所有设备对象,供 I/O 管理器使用 DeviceObject
\?? 符号链接目录:Win32 路径与内核对象桥接 SymbolicLinkObject
\BaseNamedObjects 全局同步对象命名区(Session 0 共享) Event / Mutex / Semaphore
\Sessions\N\BaseNamedObjects 多用户会话隔离命名空间 各自的同步对象
\ObjectTypes 存放系统内置的对象类型定义表 ObjectTypeObject

其中 \?? 目录(全名为 DosDevices Directory)是 Object Manager 里专门用来桥接 Win32 路径系统 ↔ 内核命名空间 的目录。它里面挂载的都是 符号链接对象(SymbolicLinkObject),用于:

  • 盘符映射 (C:\Device\HarddiskVolumeX)
  • 传统设备名 (COM1\Device\Serial0)
  • 自定义设备别名(通过 IoCreateSymbolicLink() 创建)

提示

所以你看到的 \\.\COM1,Win32 实际内部转为 \??\COM1,由 Object Manager 查找对应符号链接完成跳转。

只要某路径存在于 \??\ 下,并且链接指向有效内核对象,用户态理论上就能访问。(不考虑权限问题)

如果路径不经过 \??\(例如裸的 \Device\xxx\Driver\xxx\BaseNamedObjects\xxx),则 3环无法直接访问。

常用 API

  • ObReferenceObjectByName 函数可以用路径字符串找到任何已存在内核对象,并返回其内核对象指针。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    NTSTATUS ObReferenceObjectByName(
    IN PUNICODE_STRING ObjectName, // [输入] 要查找的对象完整路径名 (例如: "\\Driver\\MyDriver")
    IN ULONG Attributes, // [输入] 属性标志,常用 OBJ_CASE_INSENSITIVE (忽略大小写匹配)
    IN PACCESS_STATE AccessState OPTIONAL, // [输入] 安全访问状态,普通内核使用时传 NULL
    IN ACCESS_MASK DesiredAccess, // [输入] 请求的访问权限,一般填 0 表示默认即可
    IN POBJECT_TYPE ObjectType, // [输入] 对象类型指针,例如 *IoDriverObjectType、*IoDeviceObjectType 等
    IN KPROCESSOR_MODE AccessMode, // [输入] 访问模式,一般传 KernelMode
    IN PVOID ParseContext OPTIONAL, // [输入] 解析上下文 (高阶场景使用),通常传 NULL
    OUT PVOID *Object // [输出] 成功时返回获取到的对象指针 (注意:需 ObDereferenceObject 释放引用计数)
    );

路径

四类常见路径

Windows 系统有四种路径:

  • Win32 路径(DOS 路径) :用户程序使用的路径,例如:

    1
    C:\Windows\System32\drivers\Test.sys
    • 仅存在于 Win32 API 层;内核本身不识别盘符。

    • 首次进入内核时经 RtlDosPathNameToNtPathName 转成 \??\C:\...

      提示

      \?? 是 Object Manager 命名空间下的一个目录,里面存放大量符号链接(充当快捷方式),这些符号链接用于将盘符、传统设备名等映射到内核对象的真实路径。当 Win32 子系统将用户空间的 DOS 路径传入内核时,会先将其转换为以 \?? 为前缀的 NT 路径,由内核在 \?? 目录中解析出对应的真实内核对象路径。

  • NT 文件路径 :I/O 管理器与文件系统驱动的直接输入,例如:

    1
    \Device\HarddiskVolume1\Windows\System32\drivers\Test.sys
    • 盘符被解析为真正卷设备对象。

    • ZwCreateFile/ZwOpenFile 等内核 API 需传此类路径

  • Win32 设备路径 \\.\

    用户态的路径会被Win32默认认为要访问的是”文件系统”里的文件。Win32 会按照文件系统路径解析的逻辑进行处理:

    1. 解析 C: 盘符;(由于设备路径缺少盘符,一般会在这一步报错❌)

    2. 找到其对应物理卷;

    3. 然后交给文件系统驱动处理。

    然而,像 COM1 串口、物理硬盘 (PhysicalDrive0)、USB 端口、命名管道、内核设备对象这些根本不属于文件系统。文件系统找不到这些对象,它们被挂在 \Device\xxx 下(内核对象管理器里)。因此如果用户程序也希望用标准的 CreateFile() 访问设备对象则 Win32 会试图当做文件路径来走,肯定会失败。于是微软在 Win32 设计了一个特殊标记机制:只要路径以 \\.\ 开头,Win32 不参与文件系统逻辑,而是把后面的内容原样放进 \?? 命名空间,留给内核对象管理器自己去解析。例如:

    1
    2
    \\.\COM1        →  \??\COM1          →  \Device\Serial0
    \\.\PhysicalDrive0 → \??\PhysicalDrive0 → \Device\Harddisk0\DR0
  • Object Manager 路径 :所有内核对象的正式地址。样的路径仅对对象管理函数有效,如 ObReferenceObjectByNameIoCreateDeviceIoCreateSymbolicLink 等 API 可以直接使用;对文件 API 无意义。

典型解析链路

文件示例:

1
2
3
4
5
6
7
8
9
10
11
CreateFile("C:\\Windows\\System32\\drivers\\Test.sys")

└─► Win32 → NT 转换
\??\C:\Windows\System32\drivers\Test.sys

└─► \??\C: (符号链接)

\Device\HarddiskVolume1

└─► 完整 NT 路径
\Device\HarddiskVolume1\Windows\System32\drivers\Test.sys

设备示例:

1
2
3
4
5
6
CreateFile("\\\\.\\COM1")

└─► \??\COM1 (Win32 设备路径)

└─► 符号链接解析
\Device\Serial0 ← 真实设备对象

驱动基本概念

驱动程序(Driver)是运行在操作系统内核或用户模式中的软件组件,负责在操作系统与硬件设备之间“翻译”命令与数据。

驱动框架

微软为简化驱动开发,提供了三种主要框架:

框架名 全称 运行模式 推荐用途
WDM Windows Driver Model 内核模式 底层控制、兼容性极强,但复杂
KMDF Kernel-Mode Driver Framework 内核模式 封装了 WDM 的常见任务(如 PnP、电源管理、同步、I/O 队列等),推荐用于大多数设备驱动开发
UMDF User-Mode Driver Framework 用户模式 WDF 框架的另一部分,适用于开发运行在用户模式的驱动,推荐用于外围、低风险设备驱动

考虑到兼容性,我们通常采用 WDM 框架开发驱动。

驱动服务名

驱动的服务名(Service Name)是系统用来识别和管理驱动程序的逻辑标识符,它是注册表 HKLM\SYSTEM\CurrentControlSet\Services 下的子项名称,也是驱动服务控制、注册、加载、配置等操作的核心索引键。

例如如果我们加载一个名称为 Services.sys 的驱动,则会在注册表中对应创建一个 HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services 子项。

  • CurrentControlSet → 实际指向 ControlSet001ControlSet002,系统启动时动态决定
  • Services → 包含所有服务与驱动程序的定义项

在该子项中通常有如下键值对:

键名 类型 示例值 说明
ImagePath REG_EXPAND_SZ \??\C:\Path\to\MyDriver.sys 驱动文件路径,通常位于 %SystemRoot%\System32\drivers\
Type REG_DWORD 1, 2 指定服务/驱动类型(详见下文)
Start REG_DWORD 0, 1, 3 启动类型(详见下文)
Group REG_SZ Base, Boot Bus Extender 指定驱动分组,影响加载顺序
ErrorControl REG_DWORD 1 启动失败时的处理方式
DisplayName REG_SZ My Sample Driver 控制面板中显示的服务名称(可选)
Description REG_SZ Test WDM Driver 人类可读描述信息(可选)
Tag REG_DWORD 分组内排序标识(较少使用)
Parameters REG_KEY 子键 自定义参数保存区,驱动可读取用于配置

\??\Windows 内核对象管理器(Object Manager)中的一个符号链接目录,代表当前会话的 DosDevices(用户态设备路径)目录

\??\ 通常映射到 \GLOBAL??,用于解析用户模式中的路径名,如:

  • \??\C:\Windows\System32 → 实际解析为 \Device\HarddiskVolumeX\Windows\System32
  • \??\COM1 → 实际是 \Device\Serial0

其中 Start 类型表示驱动何时启动,不同的值有如下含义:

含义 示例用途
0 BOOT_START :引导时加载(Boot Loader 加载) 如磁盘控制器驱动
1 SYSTEM_START :内核初始化阶段加载 大多数普通内核驱动
2 AUTO_START :Service Control Manager 启动时加载 系统服务,非 PnP 驱动
3 DEMAND_START :按需手动启动 测试驱动、虚拟设备
4 DISABLED :禁用服务 禁用驱动或服务启动

Type 类型表示服务/驱动的类别,不同的值有如下含义:

含义 示例
1 内核驱动(SERVICE_KERNEL_DRIVER .sys 驱动,运行在 Ring 0
2 文件系统驱动(SERVICE_FILE_SYSTEM_DRIVER NTFS、FAT 等
10 Win32 服务(用户模式,SERVICE_WIN32_OWN_PROCESS 普通服务程序

驱动加载

Windows 支持两种主要的内核驱动加载方式:

  • 高层推荐方式:通过 SCM(服务控制管理器)
  • 底层直接方式:通过 ZwLoadDriver(系统调用)

这两种方式都依赖于 驱动服务名对应的注册表项

  • SCM 方式加载不是由本进程完成的(实际由系统进程,如 services.exe),因此在 0 环不容易通过行为定位到进程。
  • ZwLoadDriver 方式加载过程可控,不容易被系统策略拦截。实际情况下 ZwLoadDriver 方式加载签名异常驱动的成功率高一些。

SCM 加载(服务控制管理器)

SCM 加载是 Windows 推荐的标准驱动加载方式。驱动作为一种特殊的“服务”被注册(类型为 SERVICE_KERNEL_DRIVER),然后由 服务控制管理器(SCM) 调用底层内核服务 NtLoadDriver 加载 .sys 驱动文件。

原理流程

Windows 把驱动程序视为一种特殊的服务,类型为 SERVICE_KERNEL_DRIVER。通过一套标准 API,开发者可以注册、启动、停止和卸载驱动。每个 API 都与注册表和内核交互紧密关联。

  • OpenSCManager :连接到本地或远程计算机上的 SCM(服务控制管理器),并获取一个 SCM 句柄,用于后续服务管理操作。

    1
    2
    3
    4
    5
    SC_HANDLE OpenSCManager(
    LPCSTR lpMachineName, // 计算机名,NULL 表示本地
    LPCSTR lpDatabaseName, // 数据库名,通常为 NULL 或 "ServicesActive"
    DWORD dwDesiredAccess // 访问权限(如 SC_MANAGER_ALL_ACCESS)
    );
    • lpMachineName :目标计算机名称。为 NULL 时表示本地计算机。
    • lpDatabaseName :服务数据库名称,通常为 NULL 或默认值 "ServicesActive"
    • dwDesiredAccess :请求的访问权限。建议使用 SC_MANAGER_ALL_ACCESS 以便执行创建、删除等所有操作。
  • CreateService :在 SCM 中注册一个新服务(或驱动),生成注册表项并配置驱动加载参数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    SC_HANDLE CreateService(
    SC_HANDLE hSCManager, // 打开的服务控制管理器句柄
    LPCSTR lpServiceName, // 服务逻辑名称(注册表键名)
    LPCSTR lpDisplayName, // 显示名称(服务管理器界面显示)
    DWORD dwDesiredAccess, // 返回句柄的访问权限
    DWORD dwServiceType, // 服务类型(如内核驱动)
    DWORD dwStartType, // 启动类型(如手动、系统、引导)
    DWORD dwErrorControl, // 启动失败时的系统响应方式
    LPCSTR lpBinaryPathName, // 驱动/服务可执行文件路径
    LPCSTR lpLoadOrderGroup, // 所属分组,决定加载顺序
    LPDWORD lpdwTagId, // 输出的标签值(排序用)
    LPCSTR lpDependencies, // 所依赖的服务列表(以双 \0 结尾)
    LPCSTR lpServiceStartName, // 服务启动账户(驱动设为 NULL)
    LPCSTR lpPassword // 启动账户的密码(驱动设为 NULL)
    );
    • hSCManager :由 OpenSCManager 返回的句柄。
    • lpServiceName :服务的逻辑名称,对应注册表子项名,必须唯一。
    • lpDisplayName :显示名称,出现在服务管理器界面中。
    • dwDesiredAccess :服务句柄的访问权限,推荐 SERVICE_ALL_ACCESS
    • dwServiceType :服务类型。驱动应设为 SERVICE_KERNEL_DRIVER(值 0x1)。
    • dwStartType :启动方式:
      • SERVICE_BOOT_START(0)→ 引导加载
      • SERVICE_SYSTEM_START(1)→ 内核加载
      • SERVICE_DEMAND_START(3)→ 手动加载
    • dwErrorControl :启动失败时系统行为:
      • SERVICE_ERROR_IGNORE(0)→ 忽略错误
      • SERVICE_ERROR_NORMAL(1)→ 记录日志
      • SERVICE_ERROR_SEVERE(2)→ 启动安全模式
    • lpBinaryPathName :驱动路径(如 "C:\\Drivers\\MyDriver.sys")。
    • lpLoadOrderGroup :加载分组(如 Base,影响加载顺序,可为 NULL)。
    • lpdwTagId :输出值,指定分组内的排序标识(可为 NULL)。
    • lpDependencies :依赖服务名称,多个用 \0 分隔,以 \0\0 结尾。
    • lpServiceStartName :服务启动账户,驱动设为 NULL 表示 LocalSystem。
    • lpPassword :账户密码,驱动设为 NULL

    对于我们测试的驱动,CreateService 示例传参如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    CreateService(
    hSCManager, // 服务控制管理器句柄(来自 OpenSCManager)
    "MyDriver", // 服务名称(注册表键名,必须唯一)
    "My Kernel Driver", // 服务显示名称(可在服务管理器中显示)
    SERVICE_ALL_ACCESS, // 访问权限(允许所有操作:启动、停止、删除等)
    SERVICE_KERNEL_DRIVER, // 服务类型:内核模式驱动(对应 .sys 文件)
    SERVICE_DEMAND_START, // 启动类型:按需启动(需手动调用 StartService)
    SERVICE_ERROR_NORMAL, // 错误控制:加载失败时记录日志,继续启动系统
    "C:\\Drivers\\MyDriver.sys", // 驱动程序路径(必须为绝对路径)
    NULL, // 加载顺序组(不指定)
    NULL, // Tag ID 输出参数(排序标识,不需要时设 NULL)
    NULL, // 依赖服务列表(无依赖)
    NULL, // 启动账户(驱动使用 LocalSystem,设为 NULL)
    NULL // 启动账户密码(同上,设为 NULL)
    );
  • OpenService :打开已存在的服务(或驱动),获取用于后续控制(启动、停止、删除)的句柄。

    1
    2
    3
    4
    5
    SC_HANDLE OpenService(
    SC_HANDLE hSCManager, // 来自 OpenSCManager 的 SCM 句柄
    LPCSTR lpServiceName, // 要打开的服务名
    DWORD dwDesiredAccess // 所需权限(如 SERVICE_START | STOP)
    );
    • hSCManager :由 OpenSCManager 获取的 SCM 句柄。
    • lpServiceName :服务名称,必须精确匹配已注册服务名。
    • dwDesiredAccess :访问权限(如 SERVICE_START | SERVICE_STOP | DELETE)。
  • StartService :启动指定服务或驱动。对于驱动,会由 SCM 调用 NtLoadDriver,将 .sys 文件加载到内核。

    1
    2
    3
    4
    5
    BOOL StartService(
    SC_HANDLE hService, // 目标服务的句柄
    DWORD dwNumServiceArgs, // 参数个数(驱动设为 0)
    LPCSTR *lpServiceArgVectors // 参数数组(驱动设为 NULL)
    );
    • hService :来自 CreateServiceOpenService 的服务句柄。
    • dwNumServiceArgs :参数个数,驱动无参数则设为 0
    • lpServiceArgVectors :参数数组,驱动无参数则设为 NULL
  • ControlService :向运行中的服务发送控制命令。用于停止驱动(需驱动实现 Unload 函数)。

    1
    2
    3
    4
    5
    BOOL ControlService(
    SC_HANDLE hService, // 服务句柄
    DWORD dwControl, // 控制命令(如 SERVICE_CONTROL_STOP)
    LPSERVICE_STATUS lpServiceStatus // 输出当前服务状态
    );
    • hService :目标服务句柄。
    • dwControl :控制命令,停止服务时设为 SERVICE_CONTROL_STOP(0x1)。
    • lpServiceStatus :接收服务状态的结构体指针。
  • DeleteService :删除指定服务或驱动注册信息(从注册表清除),不会立即卸载已加载驱动。

    1
    2
    3
    BOOL DeleteService(
    SC_HANDLE hService // 目标服务句柄
    );
    • hService :目标服务句柄,需具有 DELETE 权限。
  • CloseServiceHandle :关闭服务或 SCM 句柄,释放资源。

    1
    2
    3
    BOOL CloseServiceHandle(
    SC_HANDLE hSCObject // 可为服务句柄或 SCM 句柄
    );
    • hSCObject :服务或控制管理器的句柄。
示例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <windows.h>
#include <stdio.h>

int main() {
// 打开服务控制管理器
SC_HANDLE hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_ALL_ACCESS);
if (!hSCManager) {
printf("[-] OpenSCManager failed: %lu\n", GetLastError());
return 1;
}

// 创建一个内核驱动类型的服务
SC_HANDLE hService = CreateService(
hSCManager,
"MyDriver", // 驱动服务名(逻辑名)
"MyDriver", // 显示名称
SERVICE_ALL_ACCESS, // 权限
SERVICE_KERNEL_DRIVER, // 类型:内核驱动
SERVICE_DEMAND_START, // 启动方式:手动启动
SERVICE_ERROR_NORMAL,
"C:\\Drivers\\MyDriver.sys", // 驱动文件路径
NULL, NULL, NULL, NULL, NULL
);

if (!hService) {
if (GetLastError() == ERROR_SERVICE_EXISTS) {
printf("[*] 服务已存在,尝试打开...\n");
hService = OpenService(hSCManager, "MyDriver", SERVICE_ALL_ACCESS);
} else {
printf("[-] CreateService failed: %lu\n", GetLastError());
CloseServiceHandle(hSCManager);
return 1;
}
}

// 启动驱动
if (!StartService(hService, 0, NULL)) {
DWORD err = GetLastError();
if (err == ERROR_SERVICE_ALREADY_RUNNING) {
printf("[*] 驱动已在运行中。\n");
} else {
printf("[-] StartService failed: %lu\n", err);
}
} else {
printf("[+] 驱动已成功加载。\n");
}

// 可选:停止驱动并删除服务
SERVICE_STATUS status;
ControlService(hService, SERVICE_CONTROL_STOP, &status);
DeleteService(hService);

// 关闭句柄
CloseServiceHandle(hService);
CloseServiceHandle(hSCManager);
return 0;
}
相关命令

SCM(Service Control Manager)方式加载驱动,除了直接用 WinAPI 外,Windows 提供了标准命令行工具。

sc 是 Windows 提供的服务控制命令行工具,全名为 Service Control。它支持创建、启动、停止、删除内核驱动服务。

  • 创建服务(注册驱动)
1
sc create MyDriver type= kernel binPath= "C:\Path\To\MyDriver.sys"
  • MyDriver:驱动服务名(服务项名称)
  • type= kernel:表示是内核驱动(不可省略)
  • binPath= ...:驱动文件路径(推荐绝对路径)

注意

type=, binPath= 后必须留空格,语法严格。

  • 启动驱动服务(实际加载)

    会触发 Service Control Manager 调用 NtLoadDriver 加载 .sys 文件,驱动的 DriverEntry 将被执行。

    1
    sc start MyDriver
  • 停止驱动服务(触发卸载)

    要求驱动实现了 DriverUnload 函数,否则会失败。

    1
    sc stop MyDriver
  • 删除服务(清除注册表项)

    1
    sc delete MyDriver

ZwLoadDriver(系统调用 + 注册表)

这是更“底层”的方式,绕过 SCM,直接调用内核的 ZwLoadDriver 系统服务加载驱动。常用于调试工具、PoC 框架、测试加载器或绕过方式。

原理流程
  1. 首先用户态程序提前创建注册表项(路径一般为):HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\MyDriver

    其中必须设置至少两个关键键值:

    • ImagePathREG_EXPAND_SZ):驱动文件路径,如:\??\C:\Path\to\MyDriver.sys

    • TypeDWORD):必须为 1,表示该服务为内核驱动。

  2. 构造 NT 路径并调用 ZwLoadDriver 加载驱动,该函数原型如下:

    1
    2
    3
    NTSYSAPI NTSTATUS NTAPI ZwLoadDriver(
    IN PUNICODE_STRING DriverServiceName
    );
    • 参数:注册表路径,格式为:
      \Registry\Machine\System\CurrentControlSet\Services\MyDriver
    • 返回值:NTSTATUS 错误码,常见:
      • STATUS_SUCCESS:成功
      • STATUS_OBJECT_NAME_NOT_FOUND:注册表路径错误
      • STATUS_IMAGE_ALREADY_LOADED:已加载

    提示

    ZwLoadDriverNtLoadDriver 实际上是同一个函数的两个符号。

    Windows 内核设计中,NtXxxZwXxx 实际上代表的是同一个系统服务接口(Syscall)函数,但它们存在 调用上下文(user mode vs kernel mode)下的行为差异API 访问路径差异,而 在用户态时,它们几乎是完全等价的入口符号

  3. 与驱动加载类似,卸载驱动的时候需要调用 ZwUnloadDriver,并传入同样格式的注册表路径。

    注意

    驱动必须自己实现 DriverUnload 回调,系统才会调用卸载。

    ZwUnloadDriver 函数原型如下:

    1
    2
    3
    NTSYSAPI NTSTATUS NTAPI ZwUnloadDriver(
    IN PUNICODE_STRING DriverServiceName
    );
    • 参数同上,指定已加载驱动的注册表路径;
    • 若驱动未实现 DriverUnload,调用将失败(一般是 STATUS_INVALID_DEVICE_REQUEST)。
  4. 清理注册表项。**ZwUnloadDriver 不会自动删除注册表项**,即驱动从内核卸载后,注册表中的服务项仍然存在,必须你手动清理。

示例代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
#include <Windows.h>
#include <winternl.h>
#include <iostream>
#include <string>

#pragma comment(lib, "ntdll.lib")

extern "C" {
NTSYSAPI NTSTATUS NTAPI ZwLoadDriver(IN PUNICODE_STRING DriverServiceName);
NTSYSAPI NTSTATUS NTAPI ZwUnloadDriver(IN PUNICODE_STRING DriverServiceName);
NTSYSAPI VOID NTAPI RtlInitUnicodeString(PUNICODE_STRING DestinationString, PCWSTR SourceString);
}

// 自动提取驱动服务名(不含扩展名)
std::wstring ExtractDriverName(const std::wstring& fullPath) {
size_t slash = fullPath.find_last_of(L"\\/");
size_t dot = fullPath.find_last_of(L'.');

if (slash == std::wstring::npos) slash = -1;
if (dot == std::wstring::npos || dot <= slash) dot = fullPath.size();

return fullPath.substr(slash + 1, dot - slash - 1);
}

// 构造 \Registry\Machine\System\CurrentControlSet\Services\DriverName
std::wstring BuildRegPath(const std::wstring& driverName) {
return L"\\Registry\\Machine\\System\\CurrentControlSet\\Services\\" + driverName;
}

// 注册表项创建
bool CreateDriverServiceRegistry(const std::wstring& driverName, const std::wstring& driverPath) {
std::wstring keyPath = L"SYSTEM\\CurrentControlSet\\Services\\" + driverName;
HKEY hKey;
if (RegCreateKeyW(HKEY_LOCAL_MACHINE, keyPath.c_str(), &hKey) != ERROR_SUCCESS) {
std::wcerr << L"[!] Failed to create registry key.\n";
return false;
}

DWORD type = 1;
std::wstring imagePath = L"\\??\\" + driverPath;
if (RegSetValueExW(hKey, L"Type", 0, REG_DWORD, reinterpret_cast<const BYTE*>(&type), sizeof(type)) != ERROR_SUCCESS ||
RegSetValueExW(hKey, L"ImagePath", 0, REG_EXPAND_SZ,
reinterpret_cast<const BYTE*>(imagePath.c_str()),
static_cast<DWORD>((imagePath.size() + 1) * sizeof(wchar_t))) != ERROR_SUCCESS) {
std::wcerr << L"[!] Failed to set registry values.\n";
RegCloseKey(hKey);
return false;
}

RegCloseKey(hKey);
std::wcout << L"[+] Registry entry created at: " << keyPath << std::endl;
return true;
}

// 删除服务对应注册表项
bool DeleteDriverServiceRegistry(const std::wstring& driverName) {
std::wstring keyPath = L"SYSTEM\\CurrentControlSet\\Services\\" + driverName;
LONG result = RegDeleteKeyW(HKEY_LOCAL_MACHINE, keyPath.c_str());
if (result == ERROR_SUCCESS) {
std::wcout << L"[+] Registry key deleted: " << keyPath << std::endl;
return true;
} else {
std::wcerr << L"[!] Failed to delete registry key. Error: " << result << std::endl;
return false;
}
}

bool LoadDriver(const std::wstring& driverName) {
std::wstring regPath = BuildRegPath(driverName);
UNICODE_STRING ustr;
RtlInitUnicodeString(&ustr, regPath.c_str());

NTSTATUS status = ZwLoadDriver(&ustr);
std::wcout << L"[+] ZwLoadDriver status: 0x" << std::hex << status << std::endl;

return status == STATUS_SUCCESS || status == STATUS_IMAGE_ALREADY_LOADED;
}

bool UnloadDriver(const std::wstring& driverName) {
std::wstring regPath = BuildRegPath(driverName);
UNICODE_STRING ustr;
RtlInitUnicodeString(&ustr, regPath.c_str());

NTSTATUS status = ZwUnloadDriver(&ustr);
std::wcout << L"[+] ZwUnloadDriver status: 0x" << std::hex << status << std::endl;

return status == STATUS_SUCCESS;
}

int wmain(int argc, wchar_t* argv[]) {
if (argc != 2) {
std::wcerr << L"Usage: DriverLoader.exe <PathToDriver.sys>\n";
return 1;
}

std::wstring driverPath = argv[1];
std::wstring driverName = ExtractDriverName(driverPath);

std::wcout << L"[+] Driver path: " << driverPath << std::endl;
std::wcout << L"[+] Driver name: " << driverName << std::endl;

if (!CreateDriverServiceRegistry(driverName, driverPath)) {
return 1;
}

if (!LoadDriver(driverName)) {
std::wcerr << L"[!] Driver load failed.\n";
DeleteDriverServiceRegistry(driverName); // 清理失败也清注册表
return 1;
}

std::wcout << L"[+] Driver loaded successfully.\nPress Enter to unload...\n";
std::wcin.get();

if (!UnloadDriver(driverName)) {
std::wcerr << L"[!] Driver unload failed. Ensure DriverUnload is implemented.\n";
return 1;
}

DeleteDriverServiceRegistry(driverName); // 卸载成功后清理注册表

std::wcout << L"[+] Driver unloaded and registry cleaned up.\n";
return 0;
}

驱动开发基础

基本代码

通常一个最基本的 WDM 驱动代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <ntddk.h>  // 内核开发必要头文件

// 驱动卸载回调函数
VOID DriverUnload(IN PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);
DbgPrint("Driver unloaded.\n");
}

// 驱动入口函数(系统加载驱动时调用)
NTSTATUS DriverEntry(IN PDRIVER_OBJECT DriverObject, IN PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);
DbgPrint("Driver loaded.\n");

// 注册卸载回调函数
DriverObject->DriverUnload = DriverUnload;

return STATUS_SUCCESS;
}

其中 DriverEntry 是 Windows 驱动程序的主入口函数(Entry Point),等同于用户程序中的 main() 函数。

1
2
3
4
NTSTATUS DriverEntry(
IN PDRIVER_OBJECT DriverObject, // [输入] 驱动对象,由系统分配
IN PUNICODE_STRING RegistryPath // [输入] 驱动注册表路径
);

驱动加载时,系统会调用此函数来完成驱动的初始化过程。

  • PDRIVER_OBJECT DriverObject :内核为每个加载的驱动创建一个 DRIVER_OBJECT 结构,此参数就是它的指针。你需要通过它来注册 IRP 分发表、卸载函数、创建设备等。

    DRIVER_OBJECT 是 Windows 内核用来描述一个驱动程序核心信息的数据结构,驱动开发时我们通过它设置入口函数、分发表、卸载逻辑,是驱动生命周期管理的中心。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    //0xA8 bytes
    struct _DRIVER_OBJECT {
    CSHORT Type; // 内核对象类型 (固定 DRIVER_OBJECT(0x04))
    CSHORT Size; // 结构体大小 (0xA8 字节)
    struct _DEVICE_OBJECT* DeviceObject; // 📌设备对象链表头
    ULONG Flags; // 驱动状态标志
    PVOID DriverStart; // 📌驱动映像起始地址
    ULONG DriverSize; // 📌驱动映像总大小
    PVOID DriverSection; // 加载模块节点 (挂载到 PsLoadedModuleList)
    struct _DRIVER_EXTENSION* DriverExtension; // 扩展区域 (含 AddDevice)
    UNICODE_STRING DriverName; // 📌驱动名 (\Driver\XXX)
    UNICODE_STRING* HardwareDatabase; // 硬件数据库路径 (历史用途)
    PFAST_IO_DISPATCH FastIoDispatch; // 快速 I/O 分发表 (文件系统驱动用)
    PDRIVER_INITIALIZE DriverInit; // 📌初始化入口 (内部使用)
    PDRIVER_STARTIO DriverStartIo; // 串行化 I/O 支持 (极少用)
    PDRIVER_UNLOAD DriverUnload; // 📌驱动卸载函数指针
    PDRIVER_DISPATCH MajorFunction[28]; // 📌IRP 主功能分发表
    };
  • PUNICODE_STRING RegistryPath :指向一个 Unicode 字符串,表示驱动在注册表中的键路径,如 \Registry\Machine\System\CurrentControlSet\Services\MyDriver

DriverEntry 中,我们主要做一些初始化的操作,比如创建设备对象,初始化全局变量,注册 IRP 分发表等等。

注意

  • DriverEntry 中要的是设置驱动卸载函数 DriverObject->DriverUnload如果这一步没有做则驱动无法卸载

  • 只有全部成功后才返回 STATUS_SUCCESS,否则系统自动撤销加载。因此我们不需要担心在 DriverEntry 中由于出错提前返回没有设置设置驱动卸载函数而导致驱动无法卸载,因为驱动根本就没有加载成功。

功能技巧

返回值

几乎所有内核 API 和驱动入口函数都使用 NTSTATUS 类型作为返回值。

1
typedef LONG NTSTATUS;

同时 WDK 中提供了几个宏用根据返回值判断 api 调用结果。

宏函数 作用
NT_SUCCESS(Status) 判断是否成功(高位为 0)
NT_ERROR(Status) 判断是否是错误(高位为 1)
NT_WARNING(Status) 判断是否是警告

因此一个标准的 API 调用的返回值检测应该是下面这种写法:

1
2
3
4
5
6
7
8
9
10
NTSTATUS MyFunction()
{
NTSTATUS status = DoSomething();
if (!NT_SUCCESS(status)) {
return status; // 向上传播错误
}

// 继续处理...
return STATUS_SUCCESS;
}

日志输出

内核调试输出需要使用专门的 api,并且输出内容走的是 DbgPrint Buffer,通常只有连接 WinDbg / KD 等调试器时可实时显示;无内核调试连接时,有些版本仍可借助 DebugView(SysInternals 工具)捕获部分内核日志。

  • DbgPrint:最基本的内核调试输出函数,用法与 printf 类似,默认输出优先级较低,相当于 DbgPrintEx(DPFLTR_DEFAULT_ID, DPFLTR_INFO_LEVEL, ...)

    1
    2
    3
    4
    ULONG DbgPrint(
    PCSTR Format, // 格式化字符串,类似 printf()
    ... // 可变参数
    );
  • DbgPrintExDbgPrint 的增强版,允许指定组件类别和日志等级,便于在复杂项目中分类控制输出。

    1
    2
    3
    4
    5
    6
    ULONG DbgPrintEx(
    ULONG ComponentId, // 模块分类 (WDF 框架建议填写 DPFLTR_DRIVER_FRAMEWORK_ID)
    ULONG Level, // 日志级别 (DPFLTR_XXX_LEVEL)
    PCSTR Format, // 格式化字符串
    ... // 可变参数
    );

    这里常见 ComponentId 值有:

    • DPFLTR_DEFAULT_ID:默认组件
    • DPFLTR_IO_ID:I/O 子系统
    • DPFLTR_PNP_ID:PnP 子系统
    • DPFLTR_DRIVER_FRAMEWORK_ID:WDF 框架日志

    常见的 Level 值有:

    • DPFLTR_INFO_LEVEL:普通信息
    • DPFLTR_WARNING_LEVEL:警告
    • DPFLTR_ERROR_LEVEL:错误
    • DPFLTR_MASK :所有级别
  • KdPrintEx:实际上是对 DbgPrintEx 的宏封装,编写格式需使用两层括号,优势在于统一兼容内核版本控制,WDK 推荐使用,另外可以在 Release 版本自动去除日志输出。

    1
    KdPrintEx((DPFLTR_DEFAULT_ID, DPFLTR_INFO_LEVEL, "MyDriver running.\n"));

断点

DbgBreakPoint()Windows 内核提供的标准调试断点函数,专门用于驱动或内核模块中设置断点。它的作用是:当内核代码执行到 DbgBreakPoint() 处时,如果系统当前处于调试状态(例如 WinDbg 已附加),将触发调试器断点中断。

DbgBreakPoint() 是由内核导出的函数,声明如下:

1
VOID NTAPI DbgBreakPoint(VOID);

kdBreakPoint

数据结构

字符串

字符串类型

在 Windows 开发中有多种字符串类型,但是在内核驱动开发中为了安全起见,有额外引入了 UNICODE_STRING 这一新的字符串类型。

类型 说明 使用场景
UNICODE_STRING UTF-16 编码,结构体包装 内核中最常见的字符串类型,用于路径、设备名、对象名等
WCHAR[] C 风格宽字符串(null结尾) 常用于初始化 UNICODE_STRING
CHAR[] C 风格窄字符串(null结尾) 常用于初始化 ANSI_STRING
PWSTR / PCHAR 指向上述数组的指针 宽/窄字符数组地址,传参常用
UNICODE_STRING

UNICODE_STRING 字符串类型本质上就是将宽字符串利用一个结构体进行了一次封装。

1
2
3
4
5
typedef struct _UNICODE_STRING {
USHORT Length; // 单位:字节,不包括 NULL
USHORT MaximumLength; // 最大长度(字节)
PWSTR Buffer; // 指向宽字符串(WCHAR[])
} UNICODE_STRING;

注意

  • Length 单位是字节,不是字符数;

  • Buffer 不强制 null 结尾;

UNICODE_STRING 可以通过 RtlInitUnicodeString 函数和 RTL_CONSTANT_STRING 宏两种方式进行初始化。

注意

这两种初始化方法都不会拷贝字符串内容,而只设置结构体,指针仍指向原始常量字符串。

  • RtlInitUnicodeString 函数原型如下:

    1
    2
    3
    4
    VOID RtlInitUnicodeString(
    PUNICODE_STRING DestinationString,
    PCWSTR SourceString
    );
    • 该函数会设置结构体的 Length, MaximumLength, Buffer 字段。
    • SourceString 必须是 null 结尾的常量或合法缓冲区

    示例代码:

    1
    2
    UNICODE_STRING uStr;
    RtlInitUnicodeString(&uStr, L"\\Device\\MyDriver");
  • RTL_CONSTANT_STRING

    RTL_CONSTANT_STRING 用于编译期静态构造一个 UNICODE_STRING,该宏的定义如下:

    1
    #define RTL_CONSTANT_STRING(s) { sizeof(s) - sizeof((s)[0]), sizeof(s), s }

    示例代码:

    1
    UNICODE_STRING path = RTL_CONSTANT_STRING(L"\\Device\\MyDriver");

    注意

    RTL_CONSTANT_STRING 不能用于变量字符串,只能用于编译期可见的字符串常量(即 L"..." 形式的字面量)。如果你错误地用它去初始化一个运行时变量,会导致:

    • 结构体字段内容不正确(长度计算可能错误)

    • 潜在的内存越界访问

    • 编译器不报错,但运行时行为未定义

字符串转换

在实际开发中,经常会遇到将用户传入的 ANSI 字符串转换为内核 API 可用格式这种需求,这就需要我们将 char * 字符串转换为 UNICODE_STRING 类型,具体步骤如下:

  1. 首先我们需要利用 RtlInitAnsiString 函数将 char * 字符串转换为 ANSI_STRING 类型:

    1
    2
    3
    4
    char* ansi = "MyDevice\\Test";
    ANSI_STRING ansiStr;

    RtlInitAnsiString(&ansiStr, ansi);

    提示

    在有些教程中这一步会使用 RtlInitString 函数将将 char * 字符串转换为 STRING 类型,实际上这里的 STRING 类型实际上就是 ANSI_STRING 的旧别名,结构相同。

  2. 使用 RtlAnsiStringToUnicodeString 函数将 ANSI_STRING 字符串转换为 UNICODE_STRING 字符串。这里 RtlAnsiStringToUnicodeString 函数原型如下:

    1
    2
    3
    4
    5
    NTSTATUS RtlAnsiStringToUnicodeString(
    PUNICODE_STRING DestinationString,
    PCANSI_STRING SourceString,
    BOOLEAN AllocateDestinationString
    );
    • DestinationString:输出的 Unicode 结构体
    • SourceString:输入的 ANSI 结构体
    • AllocateDestinationString:如果为 TRUE,系统会分配 DestinationString->Buffer;否则你必须事先分配好 ANSI_STRING.Buffer 并设置 MaximumLength,否则可能崩溃或数据丢失。

    这里为了方便封装,我们采用 AllocateDestinationStringTRUE 的写法。对于这样产生的 UNICODE_STRING 字符串,使用完毕时候我们需要调用 RtlFreeUnicodeString 函数将其释放,这里释放的是 ANSI_STRING.Buffer

在实际开发中,我们一般习惯将上述步骤封装成一个函数:

1
2
3
4
5
NTSTATUS ConvertAnsiToUnicode(_In_ const char* input, _Out_ PUNICODE_STRING uStr) {
ANSI_STRING aStr;
RtlInitAnsiString(&aStr, input);
return RtlAnsiStringToUnicodeString(uStr, &aStr, TRUE);
}
常用函数
  • RtlStringCbPrintfA/W:内核安全字符串格式化函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    NTSTATUS RtlStringCbPrintfA(
    _Out_ CHAR *pszDest,
    _In_ size_t cbDest,
    _In_ const CHAR *pszFormat,
    ...
    );

    NTSTATUS RtlStringCbPrintfW(
    _Out_ WCHAR *pszDest,
    _In_ size_t cbDest,
    _In_ const WCHAR *pszFormat,
    ...
    );
    • pszDest:输出目标缓冲区
    • cbDest:缓冲区总字节数(注意单位:字节,不是字符数)
    • pszFormat:格式化字符串,类似 printf 格式
  • RtlCompareUnicodeStringUNICODE_STRING 安全比较

    1
    2
    3
    4
    5
    LONG RtlCompareUnicodeString(
    _In_ const UNICODE_STRING *String1,
    _In_ const UNICODE_STRING *String2,
    _In_ BOOLEAN CaseInSensitive
    );
    • CaseInSensitive:是否大小写无关(TRUE 表示忽略大小写)
    • 返回值:返回逻辑类似 C 标准库 strcmp
      • 0:相等
      • <0String1 小于 String2
      • >0String1 大于 String2

双向链表(LIST_ENTRY)

在 Windows 中有一个专门描述链表节点的结构 LIST_ENTRY,该结构定义如下:

1
2
3
4
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink; // Forward Link (前向指针)
struct _LIST_ENTRY *Blink; // Backward Link (后向指针)
} LIST_ENTRY, *PLIST_ENTRY;

在 Windows 的设计思想中,双向链表有两种成员组成:

  • ListHead:即链表头,通常类型为 LIST_ENTRY 结构体,有时会作为一个成员放到另一个结构体中,但是作为“链表头”本身仍是 LIST_ENTRY 类型。链表头自己不存储任何数据,只是链表控制块,但会被串到双向链表中

    1
    2
    LIST_ENTRY MyList;
    InitializeListHead(&MyList);

    注意

    Windows 的 LIST_ENTRY 初始状态必须是:

    1
    2
    ListHead->Flink = ListHead;
    ListHead->Blink = ListHead;

    任何链表必须先初始化,否则后续操作容易蓝屏。Windows有一个专门用于初始化 ListEntry 的函数 InitializeListHead

    1
    VOID InitializeListHead(PLIST_ENTRY ListHead);
  • Entry:用来将节点链入双向链表中的一个结构体成员,类型同样为 LIST_ENTRY。例如下面这个结构体中的 List 就是一个 Entry,我们可以通过从链表头遍历双向链表找到所有链表中的 MY_NODE 结构体。

    1
    2
    3
    4
    typedef struct _MY_NODE {
    ULONG ID;
    LIST_ENTRY List;
    } MY_NODE;

    注意

    我们通过双向链表遍历找到的结构体地址实际上是 List 成员的地址,要想获取到结构体地址还需要借助 CONTAINING_RECORD 宏。

    1
    2
    #define CONTAINING_RECORD(address, type, field) \
    ((type *)((PCHAR)(address) - (ULONG_PTR)(&((type *)0)->field)))

    例如:

    1
    2
    PLIST_ENTRY pEntry = RemoveHeadList(&MyList);
    PMY_NODE pNode = CONTAINING_RECORD(pEntry, MY_NODE, List);

针对双向链表,Windows 提供了众多 API 用于操作双向链表中的成员:

  • InsertHeadList():将 Entry 插入到链表头部(头节点后,ListHead->Flink 方向)

    1
    2
    3
    4
    BOOLEAN InsertHeadList(
    PLIST_ENTRY ListHead,
    PLIST_ENTRY Entry
    );
  • InsertTailList():插入到链表尾部(头节点前,ListHead->Blink 方向)

    1
    2
    3
    4
    BOOLEAN InsertTailList(
    PLIST_ENTRY ListHead,
    PLIST_ENTRY Entry
    );
  • RemoveEntryList():从链表中删除指定节点。

    1
    2
    3
    BOOLEAN RemoveEntryList(
    PLIST_ENTRY Entry
    );
  • RemoveHeadList()/RemoveTailList():移除链表头部第一个(最后一个)元素。

    1
    2
    PLIST_ENTRY RemoveHeadList(PLIST_ENTRY ListHead);
    PLIST_ENTRY RemoveTailList(PLIST_ENTRY ListHead);
  • IsListEmpty():检查链表是否为空。

    1
    BOOLEAN IsListEmpty(PLIST_ENTRY ListHead);

通用平衡树框架(RTL_GENERIC_TABLE)

RTL_GENERIC_TABLE 提供通用平衡树框架(Windows 早期是 Splay;Win7+ 全部切换为 AVL),支持 按键快速查找/插入/删除

RTL_GENERIC_TABLE 的初始化函数 RtlInitializeGenericTable 定义如下:

1
2
3
4
5
6
7
NTSTATUS RtlInitializeGenericTable(
PRTL_GENERIC_TABLE Table, // [OUT] 指向 RTL_GENERIC_TABLE 结构体,初始化后返回表控制块
PRTL_GENERIC_COMPARE_ROUTINE CompareRoutine, // [IN] 比较函数指针:用于比较 Key 大小(必填)
PRTL_GENERIC_ALLOCATE_ROUTINE AllocateRoutine, // [IN] 分配函数指针:用于为新节点分配内存(必填)
PRTL_GENERIC_FREE_ROUTINE FreeRoutine, // [IN] 释放函数指针:用于释放节点内存(必填)
PVOID TableContext // [IN] 上下文参数(可选):用户自定义传入,在回调函数中使用
);
  • Table:用于保存初始化后的通用表结构体。你需要先定义好 RTL_GENERIC_TABLE 结构体,把地址传进来,内核将填充其中的指针和配置字段。

  • CompareRoutine比较函数指针,内核在插入/查找/删除时调用此函数以判定 Key 大小顺序。你需要实现此函数来定义排序规则,返回值为 GenericLessThan / GenericGreaterThan / GenericEqual。回调函数声明如下:

    1
    2
    3
    4
    5
    typedef RTL_GENERIC_COMPARE_RESULTS (*PRTL_GENERIC_COMPARE_ROUTINE)(
    PRTL_GENERIC_TABLE Table,
    PVOID FirstStruct,
    PVOID SecondStruct
    );
  • AllocateRoutine分配函数指针,在插入新节点时,内核通过此函数为节点分配内存。通常传入封装好的 ExAllocatePoolWithTag()

    1
    2
    3
    4
    typedef PVOID (*PRTL_GENERIC_ALLOCATE_ROUTINE)(
    PRTL_GENERIC_TABLE Table,
    CLONG ByteSize
    );
  • FreeRoutine释放函数指针,当删除节点时内核通过此函数释放节点内存。通常封装调用 ExFreePool()

    1
    2
    3
    4
    typedef VOID (*PRTL_GENERIC_FREE_ROUTINE)(
    PRTL_GENERIC_TABLE Table,
    PVOID Buffer
    );
  • TableContext上下文指针,供你在比较函数 / 分配函数内部做额外业务逻辑用(可选,可以传 NULL)。例如存放配置信息、同步锁、日志上下文等。

另外 RTL_GENERIC_TABLE 提供了一系列的成员操作函数:

API 功能 复杂度
RtlInsertElementGenericTable 若无重复,则插入新节点并返回指针 O(log N)
RtlLookupElementGenericTable 按键查找 O(log N)
RtlDeleteElementGenericTable 删除节点 O(log N)
RtlEnumerateGenericTable 按字典序迭代 O(1) 步进
RtlEnumerateGenericTableWithoutSplaying 枚举但不再平衡 Win7+ AVL 下同样平衡;保留兼容
RtlGetElementGenericTable 按序号 (0..N-1) 获取 O(N)
RtlNumberGenericTableElements 统计元素个数 O(1)
RtlIsGenericTableEmpty 是否为空 O(1)

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
#include <ntifs.h>

// =======================================
// 定义数据节点结构体
// 注意:平衡树节点头必须在首字段 (必须包含 RTL_BALANCED_LINKS)
// =======================================
typedef struct _MY_DATA_ENTRY
{
RTL_BALANCED_LINKS Links; // AVL树内部链接信息
ULONG Id; // 主键字段,按此排序
ULONG X; // 业务字段1
ULONG Y; // 业务字段2
} MY_DATA_ENTRY, *PMY_DATA_ENTRY;

// 全局通用表对象
RTL_GENERIC_TABLE gTable;

// =======================================
// 比较函数:用于排序/查找/去重
// 返回值决定二叉树排序方向
// =======================================
RTL_GENERIC_COMPARE_RESULTS NTAPI MyCompare(
_In_ struct _RTL_GENERIC_TABLE *Table,
_In_ PVOID FirstStruct,
_In_ PVOID SecondStruct
)
{
PMY_DATA_ENTRY a = (PMY_DATA_ENTRY)FirstStruct;
PMY_DATA_ENTRY b = (PMY_DATA_ENTRY)SecondStruct;

if (a->Id < b->Id) return GenericLessThan;
if (a->Id > b->Id) return GenericGreaterThan;
return GenericEqual;
}

// =======================================
// 分配函数:在插入新节点时被调用
// 注意:必须使用池分配内存
// =======================================
PVOID NTAPI MyAllocate(
_In_ struct _RTL_GENERIC_TABLE *Table,
_In_ CLONG ByteSize
)
{
return ExAllocatePoolWithTag(NonPagedPoolNx, ByteSize, 'TgDT');
}

// =======================================
// 释放函数:在删除节点时被调用
// =======================================
VOID NTAPI MyFree(
_In_ struct _RTL_GENERIC_TABLE *Table,
_In_ __drv_freesMem(Mem) _Post_invalid_ PVOID Buffer
)
{
ExFreePool(Buffer);
}

// =======================================
// 驱动卸载函数:清理通用表资源
// =======================================
VOID DriverUnload(_In_ PDRIVER_OBJECT DriverObject)
{
PVOID RestartKey = NULL;
PMY_DATA_ENTRY pEntry;

// 枚举删除表内所有元素,避免内存泄漏
while ((pEntry = (PMY_DATA_ENTRY)RtlEnumerateGenericTable(&gTable, &RestartKey)) != NULL)
{
BOOLEAN deleted = RtlDeleteElementGenericTable(&gTable, pEntry);
UNREFERENCED_PARAMETER(deleted);
}

DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "Driver unloaded.\n");
}

// =======================================
// 驱动入口函数
// =======================================
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);

// 初始化通用表
RtlInitializeGenericTable(&gTable, MyCompare, MyAllocate, MyFree, NULL);

// 准备测试数据
MY_DATA_ENTRY data[] = {
{ 0, 1, 10, 20 },
{ 0, 2, 30, 40 },
{ 0, 3, 50, 60 },
{ 0, 4, 70, 80 },
};

// 插入测试数据
for (int i = 0; i < ARRAYSIZE(data); i++)
{
BOOLEAN newElement;
RtlInsertElementGenericTable(&gTable, &data[i], sizeof(MY_DATA_ENTRY), &newElement);
}

// 测试查找
MY_DATA_ENTRY search = { 0 };
search.Id = 3;

PMY_DATA_ENTRY found = (PMY_DATA_ENTRY)RtlLookupElementGenericTable(&gTable, &search);
if (found)
{
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "Found: Id=%lu X=%lu Y=%lu\n", found->Id, found->X, found->Y);
}

// 遍历所有元素
PVOID RestartKey = NULL;
PMY_DATA_ENTRY pEntry;

while ((pEntry = (PMY_DATA_ENTRY)RtlEnumerateGenericTable(&gTable, &RestartKey)) != NULL)
{
DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_INFO_LEVEL, "Enumerate: Id=%lu X=%lu Y=%lu\n", pEntry->Id, pEntry->X, pEntry->Y);
}

DriverObject->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}

常用 API

内存管理

Windows 内核模式下有专门的内存申请/释放函数 ExAllocatePool/ExAllocatePoolWithTagExFreePool/ExFreePoolWithTag

1
2
3
4
5
6
7
8
9
10
PVOID ExAllocatePoolWithTag(
POOL_TYPE PoolType,
SIZE_T NumberOfBytes,
ULONG Tag
);

VOID ExFreePoolWithTag(
PVOID P,
ULONG Tag
);
  • PoolType:选择使用的内存池类型,常见的内存池有:
    • PagedPool:分页池,内存可被交换到磁盘。
    • NonPagedPool:非分页池,驻留物理内存,可执行。
  • NumberOfBytes:要申请的字节数,任意长度。
  • Tag:内存标签,Windbg !poolused!pooltag 可用此标签进行泄漏、溢出排查。ExAllocatePoolExFreePool 函数缺少这个参数。

线程

在内核中通常使用 PsCreateSystemThread 函数创建内核线程,该函数原型如下:

1
2
3
4
5
6
7
8
9
NTSTATUS PsCreateSystemThread(
OUT PHANDLE ThreadHandle, // [输出] 线程句柄,成功返回后需 ZwClose 关闭
IN ACCESS_MASK DesiredAccess, // [输入] 访问权限,通常填写 THREAD_ALL_ACCESS
IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL, // [输入] 对象属性,内核线程一般填 NULL
IN HANDLE ProcessHandle OPTIONAL, // [输入] 进程句柄,内核线程填 NULL (表示系统进程)
OUT PCLIENT_ID ClientId OPTIONAL, // [输出] 线程 Client ID(包含 PID/TID),一般填 NULL
IN PKSTART_ROUTINE StartRoutine, // [输入] 线程入口函数 (函数指针)
IN PVOID StartContext // [输入] 入口函数参数 (传入自定义上下文)
);
  • ThreadHandle:函数成功返回时,输出新创建线程的句柄。

    • 驱动一般创建完线程立即关闭句柄,因为不需要持续持有。
    • 必须在成功创建后调用 ZwClose() 关闭句柄,否则可能会泄漏句柄表项。
    • 即使关闭句柄,线程本身仍在运行,句柄只是内核对象的一个引用。
  • DesiredAccess:指定希望线程句柄具有的访问权限。因内核线程通常不操作自身句柄,直接用 THREAD_ALL_ACCESS 或 0 都可。

  • ObjectAttributes:定义线程对象的名称、属性等。仅极少情况才会用,例如为线程创建命名对象供调试器附加。绝大部分内核驱动开发直接传 NULL

  • ProcessHandle:指定新线程在哪个进程空间中运行。

    • NULL 表示创建的是内核线程(属于系统进程 System,PID=4)。
    • 若传入用户进程句柄,则创建用户进程中的远程线程。
  • ClientId:可选输出参数,返回新创建线程的唯一标识(进程 ID + 线程 ID)。如果不需要则传 NULL 即可。

  • StartRoutine:线程的入口函数指针(回调函数),函数原型为:

    1
    VOID StartRoutine(PVOID StartContext);
    • 必须确保该函数永远不返回,最后用 PsTerminateSystemThread() 主动结束线程。
    • StartContext 参数由第七个参数传入,方便传递上下文数据。
  • StartContext:传入给入口函数的自定义参数,通常为结构体指针或简单数据,用于向新线程传递启动上下文信息(如配置、句柄、共享内存等)。

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
#include <ntddk.h>

// 线程控制结构体
typedef struct _MY_THREAD_CONTEXT
{
HANDLE ThreadHandle;
PETHREAD ThreadObject;
volatile BOOLEAN ShouldStop; // 控制退出标志
ULONG Parameter; // 模拟业务参数
} MY_THREAD_CONTEXT, *PMY_THREAD_CONTEXT;

// 线程上下文全局变量 (可替换为动态分配)
MY_THREAD_CONTEXT g_ThreadContext = { 0 };

// 线程入口函数
VOID MyKernelThread(IN PVOID Context)
{
PMY_THREAD_CONTEXT ThreadCtx = (PMY_THREAD_CONTEXT)Context;
LARGE_INTEGER Interval;
Interval.QuadPart = -10 * 1000 * 1000LL; // 1秒

DbgPrint("内核线程启动,参数: %lu\n", ThreadCtx->Parameter);

while (!ThreadCtx->ShouldStop)
{
DbgPrint("线程循环执行中...\n");
KeDelayExecutionThread(KernelMode, FALSE, &Interval);
}

DbgPrint("内核线程检测到退出请求\n");
PsTerminateSystemThread(STATUS_SUCCESS);
}

// 创建内核线程
NTSTATUS StartKernelThread()
{
NTSTATUS status;

g_ThreadContext.ShouldStop = FALSE;
g_ThreadContext.Parameter = 1234; // 模拟业务数据

status = PsCreateSystemThread(
&g_ThreadContext.ThreadHandle,
THREAD_ALL_ACCESS,
NULL,
NULL,
NULL,
MyKernelThread,
&g_ThreadContext
);

if (!NT_SUCCESS(status))
{
DbgPrint("创建内核线程失败: 0x%08X\n", status);
return status;
}

// 引用线程对象,方便后续等待退出
status = ObReferenceObjectByHandle(
g_ThreadContext.ThreadHandle,
THREAD_ALL_ACCESS,
*PsThreadType,
KernelMode,
(PVOID*)&g_ThreadContext.ThreadObject,
NULL
);

ZwClose(g_ThreadContext.ThreadHandle); // 关闭句柄本身

return status;
}

// 关闭线程
VOID StopKernelThread()
{
if (g_ThreadContext.ThreadObject)
{
g_ThreadContext.ShouldStop = TRUE;

// 等待线程退出
KeWaitForSingleObject(
g_ThreadContext.ThreadObject,
Executive,
KernelMode,
FALSE,
NULL
);

ObDereferenceObject(g_ThreadContext.ThreadObject);
g_ThreadContext.ThreadObject = NULL;

DbgPrint("内核线程已成功退出并释放资源\n");
}
}

// 驱动入口
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);
DbgPrint("驱动加载\n");

DriverObject->DriverUnload = DriverUnload;

if (!NT_SUCCESS(StartKernelThread()))
{
DbgPrint("内核线程创建失败,驱动加载终止\n");
return STATUS_UNSUCCESSFUL;
}

return STATUS_SUCCESS;
}

// 卸载例程
VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);
DbgPrint("驱动卸载,准备停止线程\n");

StopKernelThread();
}

驱动隐藏

隐藏思路

在 Windows 内核中,驱动模块在系统内部存在两个最关键的暴露点:

  • PsLoadedModuleList:系统全局模块双向链表(记录所有已加载驱动模块)
  • DriverObject 结构体:系统所有已注册的驱动对象(包含驱动模块信息)

因此隐藏的本质是:

  • PsLoadedModuleList 断链 → 让系统模块枚举 API 查不到
  • 抹除 DriverObject 内关键字段 → 让安全软件和调试器无法逆推出模块信息

隐藏流程

  1. 延迟隐藏逻辑 :如果在 DriverEntry() 阶段立即隐藏,可能会因为内核后续调用尚未完成而引发异常。这是因为驱动加载的后续流程可能会用到 DriverObject 结构体中一些对象。因此我们可以启动一个内核线程,延迟约 100ms 后再执行隐藏逻辑
  2. 断链模块表 :遍历 PsLoadedModuleList,逐个对比 BaseDllName 与目标模块名。找到后执行 RemoveEntryList() 完成断链。
  3. 筛选合法伪造模块 :在遍历链表时顺便记录第一个合法存在的其它模块节点。该节点用作伪造用 DriverSection
  4. 定位 DriverObject :使用内核 API ObReferenceObjectByName() 定位目标驱动的 DriverObject
  5. 抹除与伪造 :将 DriverInitDriverSectionType 字段抹除或伪造。其中 DriverSection 需要执行前面筛选的合法 DriverSection,防止安全软件在扫描 DriverObject->DriverSection 时蓝屏。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
#include <ntddk.h>

// =======================
// 精简版 KLDR_DATA_TABLE_ENTRY (省略部分字段)
//
// 注意:这里用简化版结构体,
// 实际项目中可用 Windbg dt命令导出完整结构体以提升兼容性
// =======================
typedef struct _KLDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
ULONG __Undefined1;
ULONG __Undefined2;
ULONG __Undefined3;
ULONG NonPagedDebugInfo;
ULONG DllBase;
ULONG EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
ULONG Flags;
USHORT LoadCount;
USHORT __Undefined5;
ULONG __Undefined6;
ULONG CheckSum;
ULONG TimeDateStamp;
// ...(后续字段省略)
} KLDR_DATA_TABLE_ENTRY, *PKLDR_DATA_TABLE_ENTRY;

// =======================
// 目标模块名称与DriverObject名称 (请根据目标驱动修改)
// =======================
#define HIDE_MODULE_NAME L"TestDriver.sys"
#define HIDE_DRIVER_OBJECT_NAME L"\\Driver\\TestDriver"

// 隐藏线程句柄
HANDLE g_HideThreadHandle = NULL;

// =======================
// 模块断链 + 伪造 DriverSection + DriverObject隐藏 统一逻辑
// =======================
VOID UnlinkAndHide(void)
{
// 获取 PsLoadedModuleList 链表头
PKLDR_DATA_TABLE_ENTRY pLdr = (PKLDR_DATA_TABLE_ENTRY)PsLoadedModuleList->Flink;
PLIST_ENTRY PsLoadedModuleListHead = pLdr->InLoadOrderLinks.Blink->Flink;

// 生成目标模块名字符串用于匹配
UNICODE_STRING targetName;
RtlInitUnicodeString(&targetName, HIDE_MODULE_NAME);

PKLDR_DATA_TABLE_ENTRY RemovedEntry = NULL; // 记录被断链的模块节点
PKLDR_DATA_TABLE_ENTRY FakeEntry = NULL; // 记录合法伪造用模块

// 遍历模块链表
PLIST_ENTRY pList = PsLoadedModuleListHead->Flink;
while (pList != PsLoadedModuleListHead)
{
PKLDR_DATA_TABLE_ENTRY pEntry = CONTAINING_RECORD(pList, KLDR_DATA_TABLE_ENTRY, InLoadOrderLinks);

// 只判断 BaseDllName.Length 非 0 简化合法性判定
if (pEntry->BaseDllName.Length == 0)
{
pList = pList->Flink;
continue;
}

// 是否为要隐藏的目标模块
if (RtlCompareUnicodeString(&pEntry->BaseDllName, &targetName, TRUE) == 0)
{
DbgPrint("找到目标模块: %wZ -> 执行断链隐藏\n", &pEntry->BaseDllName);
RemoveEntryList(&pEntry->InLoadOrderLinks);
RemovedEntry = pEntry;
}
else if (FakeEntry == NULL)
{
// 记录第一个合法可伪造模块作为 DriverSection 替代项
FakeEntry = pEntry;
}

pList = pList->Flink;
}

// ========================
// DriverObject抹除及 DriverSection伪造逻辑
// ========================
UNICODE_STRING drvName;
RtlInitUnicodeString(&drvName, HIDE_DRIVER_OBJECT_NAME);

PDRIVER_OBJECT pTargetDriver = NULL;
NTSTATUS status = ObReferenceObjectByName(
&drvName,
OBJ_CASE_INSENSITIVE,
NULL,
0,
*IoDriverObjectType,
KernelMode,
NULL,
(PVOID*)&pTargetDriver
);

if (NT_SUCCESS(status))
{
DbgPrint("成功定位 DriverObject,执行隐藏逻辑\n");

// 抹除关键字段 (防止逆向工具利用)
pTargetDriver->DriverInit = NULL;
pTargetDriver->Type = 0;

// 伪造合法 DriverSection 避免蓝屏
if (FakeEntry != NULL)
pTargetDriver->DriverSection = FakeEntry;

// 减少 ObReferenceObjectByName 增加的引用次数
ObDereferenceObject(pTargetDriver);
}
else
{
DbgPrint("ObReferenceObjectByName 获取 DriverObject 失败: 0x%08X\n", status);
}
}

// =======================
// 延迟隐藏线程逻辑 (核心隐藏动作在此执行)
// =======================
VOID HideThreadProc(PVOID StartContext)
{
UNREFERENCED_PARAMETER(StartContext);

// 延迟 100ms 保证系统加载流程稳定
LARGE_INTEGER interval;
interval.QuadPart = -10 * 1000 * 100LL; // 100毫秒延迟

DbgPrint("隐藏线程启动,延迟 100ms 后执行隐藏流程\n");
KeDelayExecutionThread(KernelMode, FALSE, &interval);

UnlinkAndHide();

DbgPrint("隐藏逻辑完成,退出隐藏线程\n");
PsTerminateSystemThread(STATUS_SUCCESS);
}

// =======================
// 驱动卸载逻辑
// =======================
VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);
DbgPrint("驱动卸载完成\n");
}

// =======================
// 驱动入口逻辑
// =======================
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);
DriverObject->DriverUnload = DriverUnload;

DbgPrint("驱动加载,准备启动隐藏线程\n");

NTSTATUS status = PsCreateSystemThread(
&g_HideThreadHandle,
THREAD_ALL_ACCESS,
NULL, NULL, NULL,
HideThreadProc,
NULL
);

if (!NT_SUCCESS(status))
{
DbgPrint("创建隐藏线程失败: 0x%08X\n", status);
return status;
}

ZwClose(g_HideThreadHandle);
return STATUS_SUCCESS;
}

驱动通信

设备对象

绝大多数情况下,一个内核驱动如果没有创建任何设备对象 (DeviceObject),那么用户态(Ring3)无法直接与该驱动通信。这是因为设备对象是内核通信入口,CreateFile / DeviceIoControl / ReadFile / WriteFile 等通信 API 只能打开设备对象。

设备对象主要有三类:

  • PDO(Physical Device Object) :总线驱动创建,表示物理设备本身的存在性。例如 USB、PCI、SATA 控制器、蓝牙模块等。PDO 只描述:“有这么个硬件挂上来了”,不控制它如何工作。
  • FDO(Functional Device Object) :功能驱动创建,负责控制硬件功能、提供核心业务逻辑。FDO 负责解释 IRP 请求、控制硬件寄存器、管理协议栈、提供用户空间接口,真正把硬件功能带给系统。
  • Filter Device Object :过滤驱动创建,可插在 FDO 上下两侧,负责监控、修改、拦截 I/O 请求,属于透明扩展层。它不控制硬件,而是做中间层逻辑处理。

这些设备对象彼此层叠形成的一条逻辑设备处理链。用户与设备交互的数据在 I/O 层叠栈中的设备对象中层层转发,每一层都可以可以拦截、监控、修改、阻断用户请求。这种结构被称为 I/O 层叠栈(Stacked Device Stack)

1
2
3
4
5
6
7
8
9
10
11
用户 I/O 请求

[Filter Device (Upper Filter)]

[Functional Device (FDO)]

[Filter Device (Lower Filter)]

[Physical Device (PDO)]

硬件

不过大多数普通第三方内核驱动开发者实际上写的都是类似 FDO 或 FilterPDO 只能由 Bus Driver 创建(通常系统自带)。

设备类型 PDO 创建者 FDO 创建者 Filter 创建者
USB 存储 USB Hub 驱动 UAS 驱动(如 usbstor.sys) 杀毒软件过滤层
网卡 PCI Bus 驱动 NIC 功能驱动 防火墙、抓包驱动
虚拟设备 Root Enumerator 虚拟驱动 监控、调试工具

设备对象驱动对象的关系:

  • 一个驱动可以创建IoCreateDevice)或附加IoAttachDevice)多个设备对象,用于负责处理和过滤多个设备的消息。
  • 一个物理设备可以绑定多个设备对象,这些设备对象形成了一个设备对象堆栈,可以层层过滤用户程序向设备发送的消息。

设备对象定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
struct _DEVICE_OBJECT
{
SHORT Type; // 内核对象类型标识,固定为0x03表示DEVICE_OBJECT类型
USHORT Size; // 结构体大小(字节),不同版本Windows大小略有不同,典型为0xB8
LONG ReferenceCount; // 内核内部引用计数,自动维护,表示被多少模块或线程引用

struct _DRIVER_OBJECT* DriverObject; // 📌当前设备对象所属的驱动对象指针
struct _DEVICE_OBJECT* NextDevice; // 📌同一个驱动下多个设备对象通过NextDevice组成链表
struct _DEVICE_OBJECT* AttachedDevice; // 📌指向附加在本设备对象上的过滤设备对象(设备堆叠时使用)

struct _IRP* CurrentIrp; // 当前处理的IRP,仅老式串行StartIo驱动模型下使用,绝大多数驱动不用管

struct _IO_TIMER* Timer; // 设备专用I/O定时器指针,通过IoInitializeTimer注册,支持周期性回调

ULONG Flags; // 📌设备标志控制设备的I/O模型和电源行为,常见有:DO_BUFFERED_IO(缓冲IO)、DO_DIRECT_IO(直接IO)、DO_POWER_PAGABLE(分页支持)
ULONG Characteristics; // 设备特性标志,控制设备特性行为,例如FILE_REMOVABLE_MEDIA(可移动介质)、FILE_READ_ONLY_DEVICE(只读设备),普通控制驱动一般填0

struct _VPB* Vpb; // 卷参数块,仅文件系统与存储类驱动使用,普通控制型驱动恒为NULL

VOID* DeviceExtension; // 📌设备扩展区,驱动自定义业务数据区,创建设备时指定大小并在此区域挂载私有结构体

ULONG DeviceType; // 📌设备类型,定义设备类别,如FILE_DEVICE_UNKNOWN(控制型驱动通用)、FILE_DEVICE_DISK、FILE_DEVICE_NETWORK等
CHAR StackSize; // 📌设备栈深度,表示此设备在过滤栈中的层数,每附加一层过滤器栈自动+1

union { struct _LIST_ENTRY ListEntry; struct _WAIT_CONTEXT_BLOCK Wcb; } Queue; // 内核内部用队列或DMA上下文,极少数底层硬件驱动使用

ULONG AlignmentRequirement; // 设备I/O缓冲区内存对齐要求,DMA设备特别关注对齐要求,普通驱动为默认对齐
struct _KDEVICE_QUEUE DeviceQueue; // 串行I/O请求队列,主要用于串口、磁带等硬件控制型串行设备

struct _KDPC Dpc; // 延迟过程调用对象,用于中断下半部处理逻辑,配合ISR分离快速中断和实际数据处理
ULONG ActiveThreadCount; // 正在处理本设备对象IRP的线程数量,内核自动管理,用于内部同步统计

VOID* SecurityDescriptor; // 设备对象安全描述符,定义DACL权限控制,控制型驱动通常填NULL表示默认安全性
struct _KEVENT DeviceLock; // 内部同步锁,配合串行I/O等同步场景控制并发访问

USHORT SectorSize; // 扇区大小,存储设备使用,非存储型控制驱动通常为0
USHORT Spare1; // 保留字段,未来扩展用

struct _DEVOBJ_EXTENSION* DeviceObjectExtension; // 内核扩展区,供PNP、电源管理等系统模块使用,驱动一般无需关心
VOID* Reserved; // 预留字段
};
  • DriverObject:所属驱动的 DriverObject,用于找到设备对象所属的驱动对象

  • NextDevice:将一个驱动所属的所有设备对象 DriverObject 串联成一个单向链表,链表头为 DriverObject->DeviceObject

    1
    2
    3
    DriverObject
    |
    +--> DeviceObject1 --> DeviceObject2 --> DeviceObject3 --> NULL
  • AttachedDeviceAttachedDevice 构成的是跨驱动I/O 层叠栈(Stacked Device Stack)。它让不同驱动可以挂接在同一设备上层,共同参与 I/O 请求的流转与处理。设备对象的 AttachedDevice 指向下一层的设备对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    IRP 入口

    DeviceObject_Filter3 <-- 过滤层3(最上层)
    ↓ AttachedDevice
    DeviceObject_Filter2 <-- 过滤层2
    ↓ AttachedDevice
    DeviceObject_Filter1 <-- 过滤层1
    ↓ AttachedDevice
    DeviceObject_Functional (FDO) <-- 功能设备对象 (目标核心驱动)
    ↓ AttachedDevice
    DeviceObject_PDO <-- 物理设备对象 (底层物理设备)
    ↓ AttachedDevice
    NULL
  • StackSize:从当前设备对象所在的位置,往下直到整个设备栈的最底层(PDO)为止,所需要的 IRP 栈帧数量总和。换句话说:当前设备对象在整个 IRP 传递链中,自己算在内,往下有多少设备对象要参与。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    用户层 I/O 请求

    [DeviceObject_Filter3] StackSize=5

    [DeviceObject_Filter2] StackSize=4

    [DeviceObject_Filter1] StackSize=3

    [DeviceObject_FDO] StackSize=2

    [DeviceObject_PDO] StackSize=1

设备对象创建

在内核中,设备对象通常通过 IoCreateDevice 函数进行创建,该函数由 I/O 管理器提供,用于在 Object Manager 中注册新的设备对象,并分配相应的内存与扩展数据区。以下是函数原型:

1
2
3
4
5
6
7
8
9
NTSTATUS IoCreateDevice(
IN PDRIVER_OBJECT DriverObject, // [输入] 驱动对象指针
IN ULONG DeviceExtensionSize, // [输入] 设备扩展区大小(字节数)
IN PUNICODE_STRING DeviceName OPTIONAL, // [输入] 设备名称(Object Manager 路径)
IN DEVICE_TYPE DeviceType, // [输入] 设备类型标识
IN ULONG DeviceCharacteristics, // [输入] 设备特性标志
IN BOOLEAN Exclusive, // [输入] 是否独占设备
OUT PDEVICE_OBJECT *DeviceObject // [输出] 返回新建的设备对象指针
);
  • DriverObject:指定所属驱动对象,通常传入 DriverEntry 函数中的 PDRIVER_OBJECT,用于将新创建的设备对象挂接到驱动对象下,由驱动统一管理。
  • DeviceExtensionSize:指定设备扩展区的大小(以字节为单位)。内核会在分配 DEVICE_OBJECT 结构时附加这一段额外空间,驱动可通过 DeviceObject->DeviceExtension 访问此区域,用于存储与设备实例相关的自定义上下文信息。如果不需要扩展区则传 0
  • DeviceName:指定设备对象的命名路径(完整的 Object Manager 路径),如 \Device\MyDevice。如果传入 NULL,则创建匿名设备对象,不注册命名空间,不可通过名称访问;一般控制型驱动需提供命名,供用户态程序通过符号链接访问。
  • DeviceType:指定设备对象类型,用于指明设备类别,内核使用该类型决定某些默认行为。例如:
    • FILE_DEVICE_UNKNOWN默认通用类型,绝大多数控制型驱动使用;
    • FILE_DEVICE_DISK:磁盘设备;
    • FILE_DEVICE_NETWORK:网络设备;
    • FILE_DEVICE_FILE_SYSTEM:文件系统设备;
    • 其他类型视具体功能选用。
  • DeviceCharacteristics:指定设备特性标志,用于控制设备的附加行为。常见取值包括:
    • FILE_DEVICE_SECURE_OPEN:启用安全性访问检查;
    • FILE_REMOVABLE_MEDIA:表示可移动介质;
    • 一般自定义控制型驱动可传 0,表示不声明任何特殊设备行为,内核使用默认通用行为对待该设备对象。
  • Exclusive:指定设备对象是否独占访问。当设为 TRUE 时,系统仅允许一个线程/进程打开该设备对象,后续打开请求会失败(返回 STATUS_DEVICE_BUSY)。通常设为 FALSE,允许并发访问。
  • DeviceObject:输出参数,返回成功创建的设备对象指针。驱动可通过此指针访问扩展区、设置属性,并在卸载时配合 IoDeleteDevice 正确释放内存资源。

然而 IoCreateDevice 只负责把设备对象的内存空间从内核池分配出来,做了最基本初始化,但没有对外暴露接口。因此我们还要注册符号链接建立 Win32 层访问路径,确保用户态能够通过 CreateFile() 调用访问。

例如我们将设备名为 \Device\MyDevice 的设备通过 IoCreateSymbolicLink 创建了一个到 \??\MyDevice 的软连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 设备命名:注册到 Object Manager 命名空间下
UNICODE_STRING deviceName = RTL_CONSTANT_STRING(L"\\Device\\MyDevice");

// 符号链接命名:供用户态 CreateFile 使用 (Win32 层访问路径)
UNICODE_STRING symLinkName = RTL_CONSTANT_STRING(L"\\??\\MyDevice");

// 创建符号链接 (供用户态访问)
status = IoCreateSymbolicLink(&symLinkName, &deviceName);
if (!NT_SUCCESS(status)) {
DbgPrint("IoCreateSymbolicLink 创建符号链接失败: 0x%08X\n", status);
IoDeleteDevice(DeviceObject); // 创建失败时需回滚释放设备对象
return status;
}

那么用户程序就可以通过设备路径 \\.\MyDevice 来操作这个设备了。

最后我们需要对 DeviceObject->Flags 清除初始化标志。这一步实际上是为了兼容一些老的操作系统,这一类操作系统不会自动去除初始化标志,导致设备对象创建之后始终处于未初始化状态,导致一些对设备的操作失败。

1
2
// 设备初始化完成,清除初始化标志 (兼容老系统)
DeviceObject->Flags &= ~DO_DEVICE_INITIALIZING;

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <ntddk.h>

// 设备扩展结构体示例(自定义业务数据)
typedef struct _MY_DEVICE_EXTENSION {
ULONG ExampleField;
} MY_DEVICE_EXTENSION, *PMY_DEVICE_EXTENSION;

// 卸载例程声明
VOID DriverUnload(PDRIVER_OBJECT DriverObject);

NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
UNREFERENCED_PARAMETER(RegistryPath);
NTSTATUS status;
PDEVICE_OBJECT DeviceObject = NULL;

// 设备命名:注册到 Object Manager 命名空间下
UNICODE_STRING deviceName = RTL_CONSTANT_STRING(L"\\Device\\MyDevice");

// 符号链接命名:供用户态 CreateFile 使用 (Win32 层访问路径)
UNICODE_STRING symLinkName = RTL_CONSTANT_STRING(L"\\??\\MyDevice");

// 创建设备对象
status = IoCreateDevice(
DriverObject, // 绑定到当前驱动
sizeof(MY_DEVICE_EXTENSION), // 设备扩展区大小
&deviceName, // 设备名 (具名注册)
FILE_DEVICE_UNKNOWN, // 设备类型
0, // 设备特性 (默认传 0)
FALSE, // 非独占 (允许并发访问)
&DeviceObject // 返回创建好的设备对象指针
);

if (!NT_SUCCESS(status)) {
DbgPrint("IoCreateDevice 创建设备失败: 0x%08X\n", status);
return status;
}

// 创建符号链接 (供用户态访问)
status = IoCreateSymbolicLink(&symLinkName, &deviceName);
if (!NT_SUCCESS(status)) {
DbgPrint("IoCreateSymbolicLink 创建符号链接失败: 0x%08X\n", status);
IoDeleteDevice(DeviceObject); // 创建失败时需回滚释放设备对象
return status;
}

// 设备初始化完成,清除初始化标志 (兼容老系统)
DeviceObject->Flags &= ~DO_DEVICE_INITIALIZING;

// 注册卸载例程
DriverObject->DriverUnload = DriverUnload;

DbgPrint("驱动加载成功,设备与符号链接已创建完成\n");

return STATUS_SUCCESS;
}

// 卸载例程 (驱动卸载时自动调用)
VOID DriverUnload(PDRIVER_OBJECT DriverObject)
{
UNICODE_STRING symLinkName = RTL_CONSTANT_STRING(L"\\??\\MyDevice");

// 删除符号链接 (先删符号链接,再删设备对象)
IoDeleteSymbolicLink(&symLinkName);

// 释放设备对象 (注意可能存在多个设备,需遍历释放)
PDEVICE_OBJECT DeviceObject = DriverObject->DeviceObject;
while (DeviceObject != NULL)
{
PDEVICE_OBJECT NextDevice = DeviceObject->NextDevice;
IoDeleteDevice(DeviceObject);
DeviceObject = NextDevice;
}

DbgPrint("驱动卸载完成,资源已释放\n");
}

设备对象附加

在 Windows 内核 I/O 框架中,驱动可以将自己编写的设备对象附加到现有的设备对象之上,形成设备对象堆栈(Device Stack)。这种附加行为常用于开发过滤驱动、监控驱动、保护驱动、文件过滤驱动等场景。

内核提供 IoAttachDeviceIoAttachDeviceToDeviceStack 函数用于实现附加操作。

IoAttachDevice 函数原型如下:

1
2
3
4
5
PDEVICE_OBJECT IoAttachDevice(
IN PDEVICE_OBJECT SourceDevice, // [输入] 自己新创建的设备对象
IN PUNICODE_STRING TargetDevice, // [输入] 要附加到的目标设备对象路径
OUT PDEVICE_OBJECT *AttachedTo // [输出] 实际附加成功后的目标设备对象指针
);
  • SourceDevice:本驱动中用 IoCreateDevice() 创建好的设备对象,作为过滤层插入堆栈;
  • TargetDevice:目标设备对象的全路径(如 \Device\Harddisk0\DR0),指定要附加到哪个设备;
  • AttachedTo:附加成功后返回目标设备对象指针,即被附加的设备对象。也就是说附加后 SourceDevice->AttachedDevice = AttachedTo

IoAttachDeviceToDeviceStack 则需要我们直接提供要被附加的设备对象,而不是设备路径,该函数定义如下:

1
2
3
4
PDEVICE_OBJECT IoAttachDeviceToDeviceStack(
IN PDEVICE_OBJECT SourceDevice, // [输入] 要附加的过滤设备对象 (本驱动创建)
IN PDEVICE_OBJECT TargetDevice // [输入] 目标设备对象 (被附加对象)
);
  • SourceDevice:指定新创建的过滤层设备对象(一般通过 IoCreateDevice() 创建),将被插入到设备堆栈顶端,成为新的栈顶对象。附加成功后,该对象位于整个设备栈最顶层,优先接收 IRP 请求。

  • TargetDevice:要附加的目标设备对象。系统会根据其 AttachedDevice 自动遍历整个设备栈,找到当前栈顶位置然后附加。

当完成附加后,当用户态通过 CreateFile() 打开设备时,虽然传入的设备路径仍然是被附加的设备对象。

但是由于我们在内核中通过 IoAttachDeviceToDeviceStack() 已经把自己的设备对象挂入了目标设备对象的 I/O 栈顶,因此用户请求命中目标设备对象时,内核始终从栈顶开始派发 IRP。而我们的过滤设备对象就在这条栈上,所有请求自然会经过我们的驱动。

设备对象附加的示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
NTSTATUS AttachToTargetDevice(PDEVICE_OBJECT MyDevice)
{
NTSTATUS status = STATUS_SUCCESS;
PDEVICE_OBJECT TargetDevice = NULL;
UNICODE_STRING TargetDeviceName = RTL_CONSTANT_STRING(L"\\Device\\Harddisk0\\DR0");

status = IoGetDeviceObjectPointer(
&TargetDeviceName,
FILE_READ_DATA,
NULL,
&TargetDevice
);
if (!NT_SUCCESS(status)) {
DbgPrint("获取目标设备失败: 0x%08X\n", status);
return status;
}

// 附加到目标设备栈
PDEVICE_OBJECT AttachedTo = IoAttachDeviceToDeviceStack(MyDevice, TargetDevice);
if (AttachedTo == NULL) {
DbgPrint("附加设备失败\n");
return STATUS_UNSUCCESSFUL;
}

DbgPrint("成功附加到目标设备\n");
return STATUS_SUCCESS;
}

在驱动卸载的时候,我们需要在删除自己的设备对象之前先从设备栈分离。

1
2
IoDetachDevice(MyDeviceExtension->NextDevice);
IoDeleteDevice(MyDeviceObject);

IRP(I/O Request Packet)

IRP(I/O Request Packet)是 Windows 内核 I/O 子系统内部使用的统一请求数据结构,负责在设备驱动之间传递 I/O 操作请求。所有内核驱动层(文件系统驱动、网络驱动、过滤驱动、控制驱动等等)之间的 I/O 交互,都是通过 IRP 结构体完成。

IRP 结构体

_IRP 是内核 I/O 子系统的核心数据结构,用于描述一次完整的 I/O 请求状态与控制信息。所有 IRP 派发、过滤、传递、完成逻辑,都是围绕该结构体展开的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// 0x70 bytes (sizeof)
struct _IRP
{
SHORT Type; // IRP对象标识,固定为0x06(IRP类型)
USHORT Size; // IRP结构体大小 (当前为0x70)

struct _MDL* MdlAddress; // 📌内存描述列表 (用于 Direct I/O 模式时映射缓冲区)

ULONG Flags; // IRP状态标志位,控制I/O管理器内部行为
// 常见标志如:IRP_BUFFERED_IO, IRP_INPUT_OPERATION

union {
struct _IRP* MasterIrp; // (分散聚集I/O使用)
LONG IrpCount; // (多IRP合并时的计数器)
VOID* SystemBuffer; // 📌(Buffered I/O 模式下的系统缓冲区指针)
} AssociatedIrp;

struct _LIST_ENTRY ThreadListEntry; // 挂接到线程 I/O 请求链的双向链表节点

struct _IO_STATUS_BLOCK IoStatus; // 📌I/O 操作状态与返回值

CHAR RequestorMode; // 发起请求方的CPU模式:UserMode / KernelMode
UCHAR PendingReturned; // 内核内部标志,表示IRP是否挂起返回
CHAR StackCount; // 📌IRP栈总深度 (栈帧数量)
CHAR CurrentLocation; // 📌当前 IRP 栈位置 (栈帧索引)

UCHAR Cancel; // 是否被请求取消 (1=正在取消)
UCHAR CancelIrql; // 取消时所处IRQL (中断优先级)
CHAR ApcEnvironment; // APC 环境信息
UCHAR AllocationFlags; // IRP分配标志 (一般由内核内部管理)

struct _IO_STATUS_BLOCK* UserIosb; // 用户空间的 IO_STATUS_BLOCK 指针(异步操作返回)
struct _KEVENT* UserEvent; // 用户空间的同步事件(供异步通知)

union {
struct {
union {
VOID (*UserApcRoutine)(VOID* Context, struct _IO_STATUS_BLOCK* IoStatus, ULONG Reserved);
// 用户APC回调函数指针 (异步完成通知用)
VOID* IssuingProcess; // 发起IRP请求的进程 (内核内部用)
};
VOID* UserApcContext; // APC回调上下文参数
} AsynchronousParameters;

union _LARGE_INTEGER AllocationSize; // (文件系统使用: 分配文件大小)
} Overlay;

VOID (*CancelRoutine)(struct _DEVICE_OBJECT* DeviceObject, struct _IRP* Irp);
// 取消时调用的回调函数

VOID* UserBuffer; // 📌用户空间缓冲区 (Direct I/O 模式下映射)

union {
struct {
union {
struct _KDEVICE_QUEUE_ENTRY DeviceQueueEntry; // 设备队列链表节点
VOID* DriverContext[4]; // 驱动扩展上下文数据 (驱动自由使用)
};

struct _ETHREAD* Thread; // 所属线程对象指针
CHAR* AuxiliaryBuffer; // 辅助缓冲区 (文件系统扩展使用)
struct _LIST_ENTRY ListEntry; // 通用链表节点 (供I/O管理器组织IRP列表)

union {
struct _IO_STACK_LOCATION* CurrentStackLocation; // 📌当前IRP栈帧 (IO_STACK_LOCATION)
ULONG PacketType; // 内部用标志
};

struct _FILE_OBJECT* OriginalFileObject; // 原始文件对象
} Overlay;

struct _KAPC Apc; // (特殊用法: 内核APC控制结构体)
VOID* CompletionKey; // (完成端口/队列扩展用)
} Tail;
};
I/O 层叠栈

由于 Windows 的设备对象组成了一个 I/O 层叠栈(Stacked Device Stack)的结构,因此 IRP 为了能够在按照 I/O 层叠栈(Stacked Device Stack)的结构回的调对应 IRP 派发函数传参,因此其内部也是一个类似堆栈的结构:

  • StackCount:总共有多少个设备对象参与 IRP 派发(即设备栈深度)。通常等于最上层 DeviceObject 的 StackSize
  • CurrentLocation:当前 IRP 正处于第几层派发阶段。每调用 IoSkipCurrentIrpStackLocation()IoCallDriver() 时自动递减。
  • Tail.Overlay.CurrentStackLocation:指向当前设备对象的 IO_STACK_LOCATION 结构,记录当前派发层级的参数、控制信息、IRP 参数(如 I/O 控制码、读写长度等)。
1
2
3
4
5
6
7
8
9
10
11
12
13
IRP 栈状态:
StackCount = 5 ← 栈帧总数
CurrentLocation = 3 ← 当前派发进度
CurrentStackLocation → IO_STACK_LOCATION 3 (Filter1)

设备对象堆叠对应关系:
──────────────────────────────────────
DeviceObject_Filter3 ←→ IO_STACK_LOCATION 5 (派发已完成)
DeviceObject_Filter2 ←→ IO_STACK_LOCATION 4 (派发已完成)
DeviceObject_Filter1 ←→ IO_STACK_LOCATION 3 ← 当前派发 (CurrentStackLocation 所在位置)
DeviceObject_Functional(FDO) ←→ IO_STACK_LOCATION 2 (等待后续派发)
DeviceObject_PDO ←→ IO_STACK_LOCATION 1 (等待后续派发)
──────────────────────────────────────

很多关于 IRP 结构体的 API 本质上就是在操作这三个字段:

API 函数 作用 本质操作的字段变化
IoGetCurrentIrpStackLocation() 获取当前派发栈帧指针 返回 CurrentStackLocation
IoGetNextIrpStackLocation() 获取下一个栈帧指针(仅指针偏移,不修改位置) 返回 CurrentStackLocation - 1
IoSetNextIrpStackLocation() 手动推进派发位置(很少用) CurrentLocation--CurrentStackLocation--
IoSkipCurrentIrpStackLocation() 抵消 IoCallDriver 函数内部的“推进派发位置”的操作,使得下一层设备对象仍然处理当前栈帧 CurrentLocation++CurrentStackLocation++
参数结构

IO_STACK_LOCATION 中存储了参数信息,由于是所有类型的 IRP 派发函数公用,因此内部有一个联合体记录了每种类型的 IRP 派发函数对应的参数结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// 0x24 bytes (sizeof)
struct _IO_STACK_LOCATION
{
UCHAR MajorFunction; // 0x0 📌IRP 主功能码 (IRP_MJ_*),驱动派发逻辑的核心依据
UCHAR MinorFunction; // 0x1 IRP 子功能码 (IRP_MN_*),配合 MajorFunction 做更精细的区分
UCHAR Flags; // 0x2 控制标志,部分操作行为控制(如 SL_OVERRIDE_VERIFY_VOLUME 等)
UCHAR Control; // 0x3 I/O 子系统内部控制标志

// 📌参数联合体:根据不同的 MajorFunction 类型,使用对应的子结构
union
{
struct // IRP_MJ_CREATE 使用
{
struct _IO_SECURITY_CONTEXT* SecurityContext; // 0x4 安全上下文
ULONG Options; // 0x8 创建选项标志 (如 FILE_DIRECTORY_FILE 等)
USHORT FileAttributes; // 0xC 文件属性 (如 FILE_ATTRIBUTE_NORMAL)
USHORT ShareAccess; // 0xE 共享模式 (如 FILE_SHARE_READ)
ULONG EaLength; // 0x10 EA长度 (扩展属性)
} Create;

struct // IRP_MJ_CREATE_NAMED_PIPE 使用
{
struct _IO_SECURITY_CONTEXT* SecurityContext;
ULONG Options;
USHORT Reserved;
USHORT ShareAccess;
struct _NAMED_PIPE_CREATE_PARAMETERS* Parameters; // 0x10 命名管道专用参数
} CreatePipe;

struct // IRP_MJ_CREATE_MAILSLOT 使用
{
struct _IO_SECURITY_CONTEXT* SecurityContext;
ULONG Options;
USHORT Reserved;
USHORT ShareAccess;
struct _MAILSLOT_CREATE_PARAMETERS* Parameters;
} CreateMailslot;

struct // IRP_MJ_READ 使用
{
ULONG Length; // 0x4 读取长度
ULONG Key; // 0x8 用于文件系统校验等用途
union _LARGE_INTEGER ByteOffset; // 0xC 读取偏移
} Read;

struct // IRP_MJ_WRITE 使用
{
ULONG Length;
ULONG Key;
union _LARGE_INTEGER ByteOffset;
} Write;

struct // IRP_MJ_DEVICE_CONTROL 使用
{
ULONG OutputBufferLength; // 0x4 输出缓冲区长度
ULONG InputBufferLength; // 0x8 输入缓冲区长度
ULONG IoControlCode; // 0xC IOCTL 控制码
VOID* Type3InputBuffer; // 0x10 输入缓冲区指针 (IOCTL第三类缓冲模式用)
} DeviceIoControl;

struct // IRP_MJ_FILE_SYSTEM_CONTROL (部分)
{
ULONG OutputBufferLength;
ULONG InputBufferLength;
ULONG FsControlCode;
VOID* Type3InputBuffer;
} FileSystemControl;

// 其它子结构太多,简化列出部分常用:
struct { ULONG Length; } SetEa;
struct { ULONG Length; enum _FILE_INFORMATION_CLASS FileInformationClass; } QueryFile;
struct { enum _DEVICE_RELATION_TYPE Type; } QueryDeviceRelations;
struct { struct _POWER_SEQUENCE* PowerSequence; } PowerSequence;
struct { struct _SCSI_REQUEST_BLOCK* Srb; } Scsi;
struct { VOID* Argument1; VOID* Argument2; VOID* Argument3; VOID* Argument4; } Others;
// 还有很多其它子结构,类似逻辑。
} Parameters; // 0x4 主参数区

struct _DEVICE_OBJECT* DeviceObject; // 0x14 当前派发到的设备对象 (本层目标设备)
struct _FILE_OBJECT* FileObject; // 0x18 当前关联的文件对象 (通常在文件操作中使用)

LONG (*CompletionRoutine)(struct _DEVICE_OBJECT* DeviceObject, struct _IRP* Irp, VOID* Context);
// 0x1C 完成例程回调函数指针 (用于注册完成回调逻辑)

VOID* Context; // 0x20 完成例程上下文参数 (传入 CompletionRoutine)
};

通常我们会使用 IoGetCurrentIrpStackLocationIRP 结构体中拿当前栈帧对应的 IO_STACK_LOCATION 参数。当然也可以通过 IoGetNextIrpStackLocation 获取下一层栈帧对应的 IO_STACK_LOCATION 参数或者手动解析 IRP 结构体拿任意一层的参数。

不过下一层的 IO_STACK_LOCATION 默认是空白的。如果我们作为过滤驱动,需要通过 IoCopyCurrentIrpStackLocationToNext 函数将当前栈帧中的参数拷贝到下一层才能让下一层的设备对象对应的驱动在当前栈帧中拿到参数。

而如果是底层设备栈的第一层(创建 IRP 时)则内核早已填好栈帧了,不会有这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
FORCEINLINE
VOID IoCopyCurrentIrpStackLocationToNext(
_Inout_ PIRP Irp // [输入输出] 目标 IRP 指针
)
{
//
// 获取当前栈帧 (当前派发层)
//
PIO_STACK_LOCATION irpSp = IoGetCurrentIrpStackLocation(Irp);

//
// 获取下一层栈帧 (即 IoCallDriver 下一次派发使用的栈帧)
//
PIO_STACK_LOCATION nextIrpSp = IoGetNextIrpStackLocation(Irp);

//
// 拷贝当前栈帧内容到下一栈帧 (注意只拷贝到 CompletionRoutine 之前的字段)
// 即:将本层参数 (如 MajorFunction、Parameters、FileObject 等) 直接传递给下一层
//
RtlCopyMemory(
nextIrpSp,
irpSp,
FIELD_OFFSET(IO_STACK_LOCATION, CompletionRoutine) // 仅拷贝到 CompletionRoutine 之前
);

//
// 清空 Control 字段,保证新派发时控制标志干净
//
nextIrpSp->Control = 0;
}

另外通过 IoSkipCurrentIrpStackLocation 跳过当前设备对象的话也是同样的效果。

返回结构

对于参数传递,IRP 针对每层的设备对象都有对应的 IO_STACK_LOCATION,然而对于返回值,所有层最终共用同一个 IoStatus 返回区和缓冲区,只要有一层完成请求,设置好 IoStatus.StatusIoStatus.Information 即可。

IoStatusIRP 结构体中的一个成员,该成员类型为 IO_STATUS_BLOCK,定义如下:

1
2
3
4
5
6
7
8
9
10
// 0x8 bytes (sizeof)
struct _IO_STATUS_BLOCK
{
union
{
LONG Status; // IRP 完成状态码 (NTSTATUS),表示 I/O 请求执行结果,供内核/用户态读取
VOID* Pointer; // 可选指针 (某些异步/特殊I/O场景用,极少用到)
};
ULONG Information; // 通常存放返回的字节数(如 Read/Write 实际传输数据长度),供用户态API返回
};

其中 Status 最终会变成 Win32 API 的返回值,而 Information 会变成 Win32 API 返回的输出字节数。至于输出的数据的存放位置,这个取决于 I/O 缓冲区管理方式

I/O 缓冲模式 数据缓冲区位置 数据写入哪
Buffered I/O (DO_BUFFERED_IO) Irp->AssociatedIrp.SystemBuffer 驱动填充 SystemBuffer,内核在 IoCompleteRequest() 时拷贝回用户缓冲区
Direct I/O (DO_DIRECT_IO) Irp->MdlAddress(MDL映射的缓冲区) 驱动使用 MmGetSystemAddressForMdlSafe() 获得内核虚拟地址,直接写入用户缓冲区映射
Neither I/O Irp->UserBuffer(直接原始用户地址) 驱动直接操作用户缓冲区(前提是地址合法性自己负责验证)

IRP 派发函数

IRP 派发函数是驱动程序中专门处理各类 IRP 请求的回调函数。当 I/O 管理器收到用户或内核发起的 I/O 请求时,系统会根据 IRP 的 MajorFunction 字段,自动把 IRP 分发到对应的派发函数。

IRP 派发函数类别

所有 IRP 派发函数的入口存放在 PDRIVER_OBJECT 结构体内的 MajorFunction[] 数组中:

1
2
3
4
5
typedef struct _DRIVER_OBJECT {
...
PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
...
} DRIVER_OBJECT, *PDRIVER_OBJECT;

其中 MajorFunction 是一个大小为 28 的函数指针数组,每个元素对应一个 IRP MajorFunction 编号,这些编号的宏定义如下:

IRP MajorFunction (值) 内核派发说明 Native API (Zw/Nt) Win32 API
IRP_MJ_CREATE (0x00) 创建 / 打开设备句柄 ZwCreateFile() / NtCreateFile() CreateFile()
IRP_MJ_CREATE_NAMED_PIPE (0x01) 命名管道专用 ZwCreateNamedPipeFile() CreateNamedPipe()
IRP_MJ_CLOSE (0x02) 关闭设备句柄 ZwClose() CloseHandle()
IRP_MJ_READ (0x03) 读取设备数据 ZwReadFile() ReadFile()
IRP_MJ_WRITE (0x04) 写入设备数据 ZwWriteFile() WriteFile()
IRP_MJ_QUERY_INFORMATION (0x05) 查询文件/设备信息 ZwQueryInformationFile() GetFileInformationByHandle()
IRP_MJ_SET_INFORMATION (0x06) 设置文件/设备信息 ZwSetInformationFile() SetFileInformationByHandle()
IRP_MJ_QUERY_EA (0x07) 查询扩展属性 (EA) ZwQueryEaFile() 无直接 API
IRP_MJ_SET_EA (0x08) 设置扩展属性 (EA) ZwSetEaFile() 无直接 API
IRP_MJ_FLUSH_BUFFERS (0x09) 刷新缓存区 ZwFlushBuffersFile() FlushFileBuffers()
IRP_MJ_QUERY_VOLUME_INFORMATION (0x0A) 查询卷信息 ZwQueryVolumeInformationFile() GetVolumeInformation()
IRP_MJ_SET_VOLUME_INFORMATION (0x0B) 设置卷信息 ZwSetVolumeInformationFile() 无直接 API
IRP_MJ_DIRECTORY_CONTROL (0x0C) 目录操作 ZwQueryDirectoryFile() FindFirstFile() / FindNextFile()
IRP_MJ_FILE_SYSTEM_CONTROL (0x0D) 文件系统控制 ZwFsControlFile() 无直接 API
IRP_MJ_DEVICE_CONTROL (0x0E) 设备控制(IOCTL) ZwDeviceIoControlFile() DeviceIoControl()
IRP_MJ_INTERNAL_DEVICE_CONTROL (0x0F) 内部设备控制 内核内部
IRP_MJ_SHUTDOWN (0x10) 关机通知 ZwShutdownSystem()
IRP_MJ_LOCK_CONTROL (0x11) 锁控制 ZwLockFile() / ZwUnlockFile() LockFile() / UnlockFile()
IRP_MJ_CLEANUP (0x12) 句柄清理 (Close 前触发) 自动派发 CloseHandle()(间接)
IRP_MJ_CREATE_MAILSLOT (0x13) 创建邮件槽 ZwCreateMailslotFile() CreateMailslot()
IRP_MJ_QUERY_SECURITY (0x14) 查询安全信息 ZwQuerySecurityObject() GetSecurityInfo()
IRP_MJ_SET_SECURITY (0x15) 设置安全信息 ZwSetSecurityObject() SetSecurityInfo()
IRP_MJ_POWER (0x16) 电源管理 内核电源管理
IRP_MJ_SYSTEM_CONTROL (0x17) WMI控制 WMI子系统派发 WMI系列API
IRP_MJ_DEVICE_CHANGE (0x18) 设备插拔通知 自动派发 RegisterDeviceNotification() (部分场景)
IRP_MJ_QUERY_QUOTA (0x19) 查询磁盘配额 ZwQueryQuotaInformationFile()
IRP_MJ_SET_QUOTA (0x1A) 设置磁盘配额 ZwSetQuotaInformationFile()
IRP_MJ_PNP (0x1B) 即插即用 PnP子系统派发 设备管理器控制
IRP_MJ_PNP_POWER (0x1B) 历史兼容(已废弃别名) —— ——
IRP_MJ_MAXIMUM_FUNCTION (0x1B) 内核保留 —— ——
IRP 派发函数类别注册

每个 IRP 派发函数都有统一的标准函数签名:

1
2
3
4
NTSTATUS DispatchFunction(
PDEVICE_OBJECT DeviceObject, // [输入] 当前被调用的设备对象
PIRP Irp // [输入] 当前要处理的 IRP 请求包
);

我们需要在 DriverEntry() 中定义该类型的函数,并将其注册到 MajorFunction[] 数组中。

1
2
3
4
5
DriverObject->MajorFunction[IRP_MJ_CREATE] = DispatchCreate;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = DispatchClose;
DriverObject->MajorFunction[IRP_MJ_READ] = DispatchRead;
DriverObject->MajorFunction[IRP_MJ_WRITE] = DispatchWrite;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchIoControl;

注意

最少也要注册 IRP_MJ_CREATE / IRP_MJ_CLOSE / IRP_MJ_DEVICE_CONTROL,否则基本无法和用户层通信。

  • IRP_MJ_CREATEIRP_MJ_DEVICE_CONTROL 保证驱动能和用户交互,
  • IRP_MJ_CLOSE 保证驱动能安全退出。

I/O 缓冲区管理方式

在驱动开发中,3 环用户程序与 0 环内核驱动需要频繁交换数据。交换数据的过程中需要内核I/O管理器在两者之间做好地址转换、访问隔离、安全控制,为此 Windows 提供了 3 种 I/O 缓冲区管理方式

  • Buffered I/O(系统缓冲 I/O)
  • Direct I/O(直接 I/O)
  • Neither I/O(无缓冲 I/O)
设置 I/O 缓冲区管理方式

在 Windows 内核中,用户态与内核态的数据传递的应用主要有两种场景:

  • 普通读写:IRP_MJ_READ / IRP_MJ_WRITE
  • 设备控制:IRP_MJ_DEVICE_CONTROL

这两种场景的 I/O 缓冲区管理方式的设置方法是不同的。

  • IRP_MJ_READ / IRP_MJ_WRITE 这种类型的 IRP 派发函数主要由设备对象 Flags 决定缓冲区模式。

    当用户调用 ReadFile() / WriteFile() 时,内核通过 IRP 派发到 IRP_MJ_READ / IRP_MJ_WRITE。此时内核用 DeviceObject->Flags 中的缓冲模式标志位 决定使用哪种缓冲机制:

    • DO_BUFFERED_IO:Buffered I/O(系统缓冲 I/O)
    • DO_DIRECT_IO:Direct I/O(直接 I/O)

    注意

    • DO_BUFFERED_IODO_DIRECT_IO 互斥,驱动在创建设备时只需二选一。
    • 如果两个标志都未设置,默认当做 Buffered I/O 处理。
    • IRP_MJ_READ / IRP_MJ_WRITE 不存在 Neither I/O(无缓冲 I/O)模式。
  • IRP_MJ_DEVICE_CONTROL 这种类型的 IRP 派发函数主要由 IOCTL 控制码决定缓冲区模式。

    WDK 提供的 CTL_CODE 宏用于在驱动开发中定义 IOCTL(Input/Output Control)和 FSCTL(File System Control)请求的控制码。控制码本质上是一个 32 位整数,四个参数共同编码出一个唯一的请求类型,供内核和驱动识别具体的控制命令。

    1
    2
    3
    #define CTL_CODE(DeviceType, Function, Method, Access) ( \
    ((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) \
    )
    • DeviceType:设备类型代码,占用高 16 位(位 31-16)。如 FILE_DEVICE_UNKNOWNFILE_DEVICE_DISK 等。由微软规范分配。
    • Access:访问权限,占用 2 位(位 15-14),控制调用时用户需具备的访问权限。常见取值:
      • FILE_ANY_ACCESS (0):不做权限限制
      • FILE_READ_ACCESS (1):需要读权限
      • FILE_WRITE_ACCESS (2):需要写权限
      • FILE_READ_ACCESS | FILE_WRITE_ACCESS (3):需同时具备读写权限
    • Function:功能号,占用 12 位(位 13-2),表示具体的功能编号。
      • 取值范围:0 ~ 4095
      • 其中 0 ~ 2047 为微软保留,2048 ~ 4095 供厂商自定义。
      • 通常你自己写驱动时使用 2048 以上的数字定义私有控制码,避免与系统冲突。
    • Method:缓冲区传递方式,占用 2 位(位 1-0),指定 I/O 缓冲机制。对应四种传输模式:
      • METHOD_BUFFERED (0):Buffered I/O(系统缓冲 I/O)
      • METHOD_IN_DIRECT (1)/METHOD_OUT_DIRECT (2):输入走 Buffered I/O(系统缓冲 I/O);输出走 Direct I/O(直接 I/O)。
      • METHOD_NEITHER (3):Neither I/O(无缓冲 I/O)
Buffered I/O(系统缓冲 I/O)
Direct I/O(直接 I/O)
Neither I/O(无缓冲 I/O)

IRP 派发过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#define IoCallDriver(a,b)   \
IofCallDriver(a,b)

NTSTATUS FASTCALL IofCallDriver(
IN PDEVICE_OBJECT DeviceObject,
IN OUT PIRP Irp
)
{
if (pIofCallDriver != NULL) {

//
// 如果开启了 I/O 验证器(Verifier),则这里会跳转到其 Hook 函数 (如 IovCallDriver / IoPerfCallDriver)。
// 这些 Hook 模块用于在开发调试中插入额外的验证逻辑,辅助检测驱动Bug。
//
return pIofCallDriver(DeviceObject, Irp, _ReturnAddress());
}

// 否则直接走默认 I/O 派发实现
return IopfCallDriver(DeviceObject, Irp);
}

NTSTATUS FORCEINLINE IopfCallDriver(
IN PDEVICE_OBJECT DeviceObject,
IN OUT PIRP Irp
)
{
PIO_STACK_LOCATION irpSp;
PDRIVER_OBJECT driverObject;
NTSTATUS status;

// 确认传入的确实是合法 IRP 对象
ASSERT( Irp->Type == IO_TYPE_IRP );

//
// 核心逻辑:派发 IRP 前先将当前栈位置往下移动一层 (推进派发深度)
//
Irp->CurrentLocation--;

// 栈溢出检查:若已经到底还继续派发,直接蓝屏(BugCheck)
if (Irp->CurrentLocation <= 0) {
KiBugCheck3(NO_MORE_IRP_STACK_LOCATIONS, (ULONG_PTR) Irp, 0, 0);
}

//
// 计算新的栈帧指针:CurrentStackLocation 始终指向当前要派发的栈帧
//
irpSp = IoGetNextIrpStackLocation(Irp);
Irp->Tail.Overlay.CurrentStackLocation = irpSp;

//
// 记录本层设备对象指针,供下层驱动获取自身 DeviceObject
// 通常派发 IRP 时下层驱动通过 irpSp->DeviceObject 知道自己是谁
//
irpSp->DeviceObject = DeviceObject;

//
// 获取目标驱动对象
//
driverObject = DeviceObject->DriverObject;

//
// 执行派发:调用目标驱动中对应的 MajorFunction 派发入口
// irpSp->MajorFunction 记录了当前 IRP 的操作类型 (如 IRP_MJ_READ / IRP_MJ_WRITE 等)
//
status = driverObject->MajorFunction[irpSp->MajorFunction](DeviceObject, Irp);

return status;
}

系统调用

进程线程

句柄表

  • Title: windows 内核态逆向开发
  • Author: sky123
  • Created at : 2022-09-28 11:45:14
  • Updated at : 2025-06-16 03:28:06
  • Link: https://skyi23.github.io/2022/09/28/windows 内核态逆向开发/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
windows 内核态逆向开发