windows 内核态逆向开发

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通过段式内存管理和分页机制来转换和管理逻辑地址、虚拟地址和物理地址。
三种地址的转换具体如下图所示:
逻辑地址(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 | struct SegmentRegister { |
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, ax
,mov ax, es
等。
另外 push
和 pop
这种栈操作指令同样可以读写数据段寄存器,例如 push fs
,pop fs
等,在开发中我们通常利用这个方法来保存段寄存器环境。
注意
ss
寄存器也能通过 mov
指令修改,但是 ss
的 RPL 要保持不变。因为 ss
的 RPL 和 cs
的 RPL 同为 CPL,段权限管理要求 cs
和 ss
的 RPL 要始终保持相同,而仅通过 mov
指令修改 ss
的 RPL 显然不符合这一规定。
代码段寄存器
cs
寄存器不能通过 mov
和 pop
指令修改(不过可以使用 push cs
和 mov ax, cs
这种指令来读取 cs
寄存器的值),因此我们必须通过跨段跳转指令来修改 cs
寄存器。 例如 jmp fword
和 call fword
指令:
1 | jmp far :offset |
然而在 MSVC(尤其是 32 位 x86 平台)中的内联汇编在语法上不支持这种直接立即数跳转:
1 | jmp far 0x1234:0x5678 |
因此我们需要使用 call fword ptr [mem]
指令来代替:
1 | struct FarPointer { |
对于 call fword ptr
和 call far
指令,如果 CPL 不变则会在向栈中依次压入调用者的 cs 和返回地址(CPL 变化的情况比较复杂,具体见调用门部分)。
因此我们返回时需要通过 retf
指令返回。如果调用前在栈上压入了参数,那么我们可以通过 retf [参数的字节总数]
来平衡堆栈。
为了避免编译器在函数代码中生成堆栈帧(push ebp
/mov ebp, esp
…),我们需要借助裸函数(naked function)精确控制函数的入口和返回指令。
1 | __declspec(naked) void func() { |
除此之外中断指令 int
以及对应的返回指令 iret
同样会修改 cs
寄存器,具体同样见后面的分析。
段描述符表(Descriptor Table)
段描述符表种类
段描述符表是存储段描述符的结构,定义了各个段的属性。x86 架构中有两种主要的段描述符表:全局描述符表(GDT)和局部描述符表(LDT)。
全局描述符表(GDT, Global Descriptor Table)
- 作用 :全局描述符表用于定义系统范围内的段,包括代码段、数据段和系统段(如任务状态段TSS)。
- 存储位置 :GDT存储在内存中的一个固定位置,其基址和界限由 GDTR(GDT Register)寄存器保存。
- 访问方式 :通过段选择子中的 TI 位(Table Indicator)为 0 来选择 GDT 。
- 相关汇编指令 :
lgdt [mem]
→ 加载 GDTsgdt [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
→ 加载 LDTRsldt reg/mem
→ 存储 LDTR(Ring 3 可执行)str reg/mem
→ 存储任务寄存器(可用于 TSS 调试)
关于 LDT 有如下常用的调试命令:
命令 | 功能说明 |
---|---|
r ldtr /r ldtl |
查看 LDT 基地址和长度 |
段描述符(Segment Descriptor)
段描述符是描述段属性的结构体,存储在GDT或LDT中。每个段描述符占用8个字节,包含段的基址、界限和属性等信息。
一个段描述符的结构如下:
基址(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),Type :
S
描述符类型,Type
根据S
的不同含义不同。S
为 0 表示系统段,此时Type
描述系统段的类型(如调用门,中断门,陷阱门等),含义如下表所示。S
为 1 表示代码段或数据段,此时Type
描述段的具体类型(如代码段、数据段)和访问权限,含义如下表所示。
另外
Type
对应代码段和数据段时分别引入了一个新概念:- 一致代码段(Conforming Code Segment)
- 当
Type
的 11 位为 1 时段描述符表示的是代码段,此时如果C
位(10 位)为 1 则表示为一致代码段。 - 一致代码段是指当 CPU 执行跨特权级的代码段切换(比如调用更高权限的代码段)时,不需要特权级检查,允许直接进入。
- CPU 不会在段切换时更改 CPL(因为一致代码段特权级不敏感)。
- 当
- 向下拓展数据段(Expand-Down Data Segment)
- 当
Type
的 11 位为 0 时段描述符表示的是数据段,此时如果E
位(10 位)为 1 则表示为向下拓展。 - 正常情况下的我们见到的数据段都是向上拓展的,即有效偏移范围是从
0
到Limit
。 - 对于向下拓展的数据段,有效偏移范围是从
Limit + 1
到最大偏移值(例如,0xFFFF
或0xFFFFFFFF
,取决于段的大小)。由于Base + Limit
会溢出,因此实际的有效范围是[Base, Base + Limit)
之外的范围。
- 当
G位(Granularity) :决定界限单位。
- 0 表示段寄存器的
Limit
元素单位为字节,即最大范围为 0x000FFFFF(1MB)。 - 1 表示段寄存器的
Limit
元素单位为 4KB ,即最大范围为 0xFFFFFFFF(4GB)。
- 0 表示段寄存器的
D/B 位(Default Operand Size / Big) :决定了默认的操作数和地址的大小,以及堆栈指针的大小。
注意
这里需要与
Type
位配合才能生效。Type
位决定了段描述符是代码段还是数据段,另外还决定了一些特性如向下拓展是否生效。- 对于代码段:
- 0 :默认操作数和地址大小为 16 位。例如
push
指令只能压 2 字节。 - 1 :默认操作数和地址大小为 32 位。
- 0 :默认操作数和地址大小为 16 位。例如
- 对于堆栈段:
- 0 :使用 16 位堆栈指针。
- 1 :使用 32 位堆栈指针。
- 对于数据段:
- 0 :向下拓展。
- 1 :向上拓展。
- 对于代码段:
L 位(64-bit code segment) :仅适用于 IA-32e 模式(64 位模式)的代码段。
- 0 :表示这是一个 16 位或 32 位代码段,具体取决于
D/B
位。 - 1 :表示这是一个 64 位代码段,忽略
D/B
位。
- 0 :表示这是一个 16 位或 32 位代码段,具体取决于
AVL 位(Available for use by system software) :供操作系统或其他系统软件使用,用于特定用途,例如标记段的状态,未被硬件使用。
段权限检测
权限类型
Ring Model 是一种 CPU 权限级别(Privilege Level) 的分层架构,主要用于操作系统中处理权限隔离和保护。它通过硬件机制,限制不同权限级别的代码对系统资源的访问,从而提高系统的安全性和稳定性。
Ring Model 的分层通常分为 4 个等级,编号从 0 到 3:Ring 0
, Ring 1
, Ring 2
, Ring 3
。
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
的权限也要随着改变,cs
与ss
的等级必须一样。int
,call far
这一类的指令只能通过系统段提权或者不改变权限。jmp far
这一类指令不改变权限,除了 TSS(可以同时影响到cs
和ss
)。retf
,iretd
这一类指令只能同级跳转或者降权。
调用门
在 x86 保护模式下,调用门(Call Gate)是一种特殊的系统段描述符,存在于 GDT(全局描述符表)或 LDT(局部描述符表) 中。它的作用是:允许安全地从一个代码段转移到另一个代码段,通常伴随特权级切换。
门描述符
- 偏移地址(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
指令调用调用门:
根据指令中的段选择子找到调用门。(指令中的地址没有用到,最终跳转到的地址由调用门的门描述符中的
Offset
字段决定)进行权限检查:
- 指令中的 RPL 不参与整个过程。
- CPL 在数值上小于等于调用门 DPL。
- 调用门中代码段选择子的 RPL 在数值上小于等于对应的代码段的的 DPL(貌似不用满足,并且跳过去之后 CPL 还是设置为代码段的 DPL)。
- 如果是
jmp
则调用门的段描述符对应的代码段的 DPL 等于 CPL。(jmp
通过调用门提不了权) - 如果是
call
则调用门的段描述符对应的代码段的 DPL 在数值上小于等于 CPL。
判断调用门是否提权:
如果没有权限变化则等价于普通的跨段跳转,只不过这里跳转的地址不是直接从操作数获取的。
如果权限发生变化则需要进行栈切换:
从 TSS 中获取新的
ss
和esp
更新对应的寄存器,期间会检测ss
的权限以及 TSS 是否合法。将原有的
ss
,esp
,参数(从原本的栈中拷贝,拷贝长度参考调用门描述符中的参数计数(Param Count)字段),cs
,返回地址依次压入新的堆栈。注意
原有的堆栈中只有参数,没有压入返回地址之类的东西。
根据门描述符中表示的代码段还有调用门入口点更新
cs
,eip
。
至此我们完成的 cs
和栈的切换,跳转指调用门指定的代码开始执行。
注意
如果我们调试提权后的代码,则返回用户态之后会发生崩溃。
这是因为 int 3
对应的中断处理函数 _KiTrap03
会将 fs
寄存器修改为 0x30(这也是为什么我们下断点调试时发现 fs
的值发生变化),而由于我们通过调用门提权后处于 0 环权限,_KiTrap03
并不会恢复 fs
寄存器,因此我们在返回 3 环时需要手动还原 fs
寄存器。
返回过程
如果我们通过 call
长调用指令进行调用门跳转则需要通过 retf
指令返回。
注意
如果调用门规定了参数格式,则我们必须使用 retf [参数的字节总数]
来返回。
注意,retf
后面跟的是参数的字节数,而调用门的参数计数(Param Count)字段描述的是参数的个数。
具体过程为:
进行权限检查 :根据栈中存放的
cs
寄存器判断是否发生权限(CPL)变化:如果没有发生权限变化则为普通的跨段跳转,不会发生栈切换。
如果发生权限变化则:
- 只能是降权,即在数值上栈中保存的
cs
的 RPL 要大于 CPL。 - 对栈中保存的
ss
进行权限检查,要求返回后ss
和cs
的 DPL 要相等。
- 只能是降权,即在数值上栈中保存的
恢复寄存器 :
- 根据中保存的
eip
,cs
恢复eip
和cs
寄存器。 - 如果降权则要在普通的跨段跳转返回的基础上根据栈中保存的
esp
和ss
恢复堆栈。栈中保存的esp
和ss
的位置需要根据retf
后面跟的参数字节数定位。
- 根据中保存的
平衡堆栈 :
如果权限不变则根据
retf
后面跟的参数字节数平衡掉压入的参数,另外还要平衡掉跨段跳转时压入的返回地址和cs
。如果是降权则需要在原本的堆栈中根据
retf
后面跟的参数字节数平衡掉压入的参数。注意
内核堆栈每次都是通过 TSS 的
esp
赋值,因此不需要平衡内核堆栈。
中断
什么是中断
中断(Interrupt)是计算机系统中一种异步事件通知机制,用于在处理器正在执行任务时,打断当前执行流程,让处理器去响应更紧急或更重要的事件。
中断机制为计算机系统提供了一种对事件的实时响应方式,同时保证处理器资源的高效利用。
按照来源划分,中断可以分为硬件中断和软件中断 :
硬件中断 :来自外部设备(如键盘、鼠标、定时器、网卡)发出的中断信号。如:键盘按键、鼠标移动、时钟滴答。
软件中断 :由程序通过指令(如
int
)显式触发,通常用于系统调用。
按目的划分,中断可以分为可屏蔽中断(IRQ),不可屏蔽中断(NMI)和异常(Exception) :
可屏蔽中断(IRQ) :可以被禁止(如设置
IF=0
,EFLAGS
寄存器第 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 通过中断门自动跳转到对应的处理函数,并进行必要的权限切换、堆栈切换等。
门描述符
- 偏移地址(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
或指令触发中断:
根据中断向量在 IDT 中查找中断门描述符。(指令中的操作数
n
作为索引,找到对应的门描述符;跳转地址由中断门中的Offset
和Segment Selector
字段决定)进行权限检查:
- 对于硬件中断 :不进行权限检查,中断处理程序直接执行。
- 对于软件中断(如 INT n) :
- CPL(当前特权级)必须小于等于中断门的 DPL。
- 如果 CPL > DPL,则触发 #GP 异常。
判断是否发生权限变化(提权) :
如果中断门的目标代码段的 DPL < CPL,则发生提权,需要进行堆栈切换。
- 从 TSS 中查找对应的
SS
和ESP
,加载为新堆栈。 - 将当前栈的
SS
、ESP
、EFLAGS
、CS
和EIP
依次压入新堆栈中,形成完整的返回上下文。 - 若中断号是
INT n
且带有错误码,则错误码也一并压入堆栈(CPU 固定的几项,我们自己注册的没有)。
- 从 TSS 中查找对应的
如果没有权限变化(例如从 Ring 0 调用 Ring 0 中断门)则不会切换堆栈,仅将
EFLAGS
、CS
、EIP
压入当前堆栈。
修改
EFLAGS
寄存器 :中断门会清空EFLAGS
寄存器中的下面几个标志位:
位名 | 含义 | EFLAGS 位位置 | 修改行为 / 说明 |
---|---|---|---|
IF (Interrupt Flag) | 中断允许标志:控制是否允许 CPU 响应可屏蔽中断(IRQ) | 位 9 | 当通过中断门进入中断处理程序时,CPU 会自动将 IF = 0 ,即暂时屏蔽其他中断,防止当前中断被新的中断打断。等中断处理结束后通过 IRET 恢复原 EFLAGS ,IF 才可能再次为 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),以便执行保护模式下的中断处理程序。这个过程伴随着堆栈切换、权限检查和返回机制,支持从实模式模拟环境跳转到内核态中断处理逻辑。 |
跳转到中断处理程序:
- 设置新的
CS:EIP
为门描述符中指定的目标。 - 开始执行中断处理程序。
- 设置新的
返回过程
在中断处理程序结束时通过 iret
( 32 位 iretd
,64 位 iretq
)指令返回,具体过程为:
- 进行权限检查:
- 从栈中弹出
EIP
、CS
和EFLAGS
。 - 如果发生过权限变化(即从高权限跳转到低权限):
- 同时还需要从栈中恢复
SS
和ESP
。 - 返回后会将 CPL 设置为
CS
描述符中的 DPL。
- 同时还需要从栈中恢复
- 从栈中弹出
- 恢复上下文:
- 设置
CS:EIP
为返回地址。 - 恢复原
EFLAGS
。 - 如果发生过堆栈切换,则也恢复
SS
和ESP
,切回用户堆栈。
- 设置
- 完成权限恢复与返回:
- 中断处理完毕,程序继续从中断发生处继续执行。
注意
- 若中断期间修改了段寄存器(如
fs
),在返回到用户态前应手动还原。 iretd
不能被retf 4
代替,因为发送权限变化的时候retf 4
还会平衡掉用户态栈中压入的参数,而int
指令只在内核态栈中压入EFLAGS
,因此会导致堆栈不平衡。
陷阱门
陷阱门是一种门描述符,用于定义某个中断或异常发生时,处理器该跳转到哪段代码继续执行。它和中断门类似,都存在于 IDT(Interrupt Descriptor Table) 中,但行为略有不同,主要用于不会频繁嵌套、需要精细控制响应时机的异常或调试场景。
门描述符
陷阱门描述符与中断门描述符格式基本一致,唯一区别就是 Type
字段不同,由 1110
(0xE)变为了 1111
(0xF)。
过程分析
与中断门完全一致,唯一的不同点在于陷阱门不会清空 EFLAGS
的 IF
标志位。
任务门
任务门(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)描述符的定义如下:
注意
根据《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:00
、Base 23:16
和Base 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 的 基地址 和 限长。
TR
寄存器可以通过 LTR
和 STR
指令进行读写,不过这两个指令都是 特权指令,只能在特权级 0(CPL = 0)下执行。
STR r16/m16
:从TR
读取当前任务段选择子。LTR r/m16
:向TR
加载任务段选择子。
任务段(TSS,Task State Segment)
任务段(TSS,Task State Segment)是 x86 架构中为支持多任务操作系统设计的一个特殊机制,它定义了一段特殊的内存结构,用于保存任务(线程/进程)的运行上下文和特权级切换信息。
虽然现代操作系统(如 Windows 和 Linux)不再使用“硬件任务切换”(TSS 最初的主要目的),但 TSS 在 中断处理、安全控制、特权切换时仍然是必不可少的组件。
Previous Task Link :上一个任务字段。如果使用任务切换(如使用
jmp TSS selector
),这里保存前一个任务的 TSS 选择子。由于 Windows 基本不使用硬件任务切换(而是用软件方式),因此此字段一般无实际用途。栈切换相关字段 :
- ESP0 / SS0 : Windows 在处理系统调用(如
sysenter
或int 0x2e
)和异常(如页错误)时依赖这两个字段快速切换内核栈。当 CPU 从用户态(CPL=3)切换到内核态(CPL=0)时,会自动加载这两个字段的值作为内核栈的临时基地址(SS:ESP
)。 - ESP1 / SS1,ESP2 / SS2 :较少用,仅在三层栈(Ring1、Ring2)情况下切换,Windows 不使用,这些字段保留。
- ESP0 / SS0 : 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) : 限制间接跳转(
JMP
、CALL
到寄存器/内存)只能跳转到有效的目标地址。要跳转的目标必须以特殊指令(如ENDBR32
或ENDBR64
)开头,否则触发异常。
- Shadow Stack(影子栈) :CPU 维护一个只写入返回地址的“只读”影子栈。每次函数调用(
ntkrpamp!_KTSS
是 Windows 内核中用于描述任务状态段(TSS,Task State Segment)的结构体,主要用于 x86 架构下的特权级堆栈切换、I/O 端口访问控制等功能。
虽然现代 Windows 系统主要采用软件方式进行任务切换,但仍为每个处理器维护一个 TSS 结构,特别是在处理特定异常(如双重故障)时,硬件任务切换机制仍可能被触发。
1 | //0x20ac bytes (sizeof) |
注意
Windows 默认不使用 ESP1
/ SS1
和 ESP2
/ SS2
等特权级备用栈(Ring 1 和 Ring 2 的栈),所以在它所定义的 _KTSS
结构体中,这些字段会被统一省略或标为保留(Reserved1
,NotUsed1
)。
任务门描述符(Task Gate Descriptor)
段选择子(TSS Segment Selector) :表示该任务门对应的目标任务段(TSS)。该字段占据描述符的第 0–15 位,其值应为 GDT 中某个 TSS 段描述符的选择子。当任务门被触发时,处理器会使用该选择子来访问并加载目标 TSS,实现硬件任务切换。
类型(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_selector 或 JMP 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_selector
或 JMP TSS_selector
指令,其中选择子(Selector)指向 GDT 中的任务段描述符(TSS Descriptor)。
权限检查 :
max(CPL,RPL) ≤ DPL
,否则#GP(selector)
存在检查 :P=0 →
#NP(selector)
类型检查 :Busy TSS 调用 →
#GP(selector)
段限长检查 :limit < 0x67 →
#GP(selector)
加载新任务
将当前任务的上下文(寄存器、EIP、EFLAGS、段寄存器等)保存到当前 TSS;
设置 TR(Task Register)指向新的 TSS;
目标 TSS 类型字段自动由 0x9 → 0xB,表示忙状态。
载入新任务状态 :CR3、LDTR、EFLAGS、EIP、通用/段寄存器
对
CALL
:设置EFLAGS.NT=1
并把当前 TSS 段选择子写到新TSS.Backlink
;对JMP
不设置。因此如果想用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。
任务门调用过程
权限检查 对任务门(同样比较 CPL/RPL 与 DPL)
取出任务门中的 TSS Selector
后续步骤与直接调用任务段相同。
返回过程
当执行
iretd
指令的时候,CPU 发现EFLAGS.NT=1
,识别为任务返回。任务状态段(TSS)中的“前一个任务链接字段”(Previous Task Link,有时也称为“backlink”)以及 EFLAGS 寄存器中的 NT 标志,用于将控制流返回到前一个任务。
当
EFLAGS.NT = 1
时,表示当前正在执行的任务是嵌套在另一个任务之内。
注意
如果我们调试任务切换的代码,由于
int 3
中断会将EFLAGS.NT
为置 0,导致返回的时候是通过堆栈返回,造成蓝屏。读取当前 TSS 的 Backlink → 找到前一个 TSS 描述符。
对当前任务段执行与调用流程相同的有效性检查(
P=1
,limit≥0x67
,Type=Busy
)保存当前任务状态到当前 TSS,因为 IRET 任务返回也属于任务切换。
清除当前 TSS Busy 位。
加载前一任务 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.
写 TR = 前一 TSS selector,恢复其 Busy 状态。
清除
EFLAGS.NT
跳回前一任务继续执行
页式内存管理
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 | [boot loader] |
启动时你将看到两个选项:
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 位后不再区分 mp
和 up
,统一支持 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
bcdedit /copy {current} /d "Windows 7 - No PAE"
- 这会复制当前启动项,生成一个新 GUID(比如
{3e9a0...}
)。 - 新启动项会出现在启动菜单中,名字叫 “Windows 7 - No PAE”。
正常情况下系统返回类似下面这段输出:
1
The entry was successfully copied to {3e9a0123-xxxx-xxxx}.
- 这会复制当前启动项,生成一个新 GUID(比如
给这个新启动项设置关闭 PAE 参数
1
bcdedit /set {3e9a0123-xxxx-xxxx} pae ForceDisable
再次开机时会看到:
1 | Windows 7 |
- 第一个启用 PAE,可能支持 DEP、安全保护机制;
- 第二个禁用 PAE,用于兼容旧驱动或调试。
Windows 64 位
Windows 7 / 10 / 11 等 64 位系统使用长模式分页,ntoskrnl.exe
已合并所有功能模块,不再有 PAE / 非 PAE 区分,也不再需要多个版本。
文件名 | 支持 PAE | 支持多核 (SMP) | 说明 |
---|---|---|---|
ntoskrnl.exe |
✅ 是 | ✅ 是 | 唯一内核映像,默认启用 PAE + SMP + NX(DEP)等 |
控制寄存器
CR0
~CR4
是 x86 架构中的 控制寄存器(Control Registers),它们是 CPU 控制系统级特性和行为的关键寄存器。这些寄存器 只能在特权级 0(Ring 0)下访问,用于开启分页、保护模式、中断响应、缓存控制等功能。
名称 | 状态 | 主要功能 |
---|---|---|
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=1 、CPL=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 会将对 DR4 和 DR5 的访问重定向到 DR6 和 DR7 ,这是为了兼容旧软件(早期 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 分页模式的页表结构如下图所示。
该模式通过 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 分页模式的线性地址到物理地址的转换过程如下图所示:
线性地址由高到低划分为 10 位 PDE 索引、10 位 PTE 索引 和 12 位页内偏移,其中 10,10,12 恰好对应:
- 一个页目录表包含 个 PDE。
- 一个页表包含 个 PTE。
- 一个内存页大小为 字节。
例如假设线性地址为 0x12345678
,则具体的转换过程如下:
字段 | 值 | 含义 |
---|---|---|
PDE Index | 0x48 | CR3 指向页目录 → 第 0x48 项 PDE |
PTE Index | 0xD1 | PDE 指向的页表 → 第 0xD1 项 PTE |
Offset | 0x678 | 页内偏移,定位物理页中的位置 |
页目录自映射
另外,为了方便操作系统在内核态访问并维护自身的页表结构,Windows 会设置页目录表中 第 0x301
项(769 项) 的 页目录项 PDE 指向页目录表自身的物理地址,实现所谓的 页目录自映射(Page Directory Self-Mapping)机制。这样,整个页目录表和所有页表都可以通过一组固定的线性地址访问。
页目录表的线性地址为:
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 分页模式下,页表相关属性如下:
其中 PDE(页目录项)主要有两种类型,取决于 PDE 的第 7 位(PS 位) 的取值,这两种 PDE 的结构稍有不同。
页大小 | PS 位 | 解释方式 | PDE 指向的对象 | 后续结构 |
---|---|---|---|---|
4KB 页 | 0 | 二级页表结构 | 指向一个页表(PTE 数组) | 需要查找 PTE |
4MB 页 | 1 | 一级页表结构(大页) | 直接映射一个4MB 页框 | 不查 PTE,直接定位物理页 |
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 | kd> !process 0 0 |
其中 DirBase
的值 a9024000
就是页目录基地址的物理地址。
PDE
首先对于 4KB 页对应的 PDE,该页目录项的结构如下:
存在位(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
,也就是说指向的物理页大小为 字节,基址关于 对齐,因此只需要 32 - 22 = 10
比特来记录物理页的物理地址。
4MB PDE 的各字段分布如下:
相对于 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
前面两种 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 分页模式的页表结构如下图所示。
名称 | 层级 | 数量与结构 | 每项大小 / 总大小 | 作用说明 |
---|---|---|---|---|
页目录指针表(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 分页模式的线性地址到物理地址的转换过程如下图所示:
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 大小物理页的页内偏移。
页目录自映射
PAE 分页模式下的页目录自映射(Page Directory Self-Mapping)机制也发生变化。由于 PDPTE 代替了 CR3 的功能,因此需要由第 4 个页目录表实现页目录自映射机制。该页目录表的前 4 项分别指向 4 个页目录表,因此在线性地址空间中,4 个页目录表以及对应映射的页表分布如下:
根据页目录自映射的特性可知,对应一个线性地址 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 存储的值。
页表属性
CR3
在开启 PAE 之后,CR3 寄存器的字段发生了一些变化。
CR3 在 PAE 模式下,其 Bits[31:5] 存放 PDPTE 表的物理地址高位(因 32 字节对齐,所以低 5 位为 0)。
提示
在调试时我们可以通过
!process 0 0
输出的DirBase
是关于 0x20 还是 0x1000 对齐快速判断出当前是否是 PAE 分页模式。将 CR3 中的
PWT
和PCD
字段转移到了 PWPTE 中。
PDPTE
PDPTE 中主要承担了非 PAE 分页中的 CR3 的功能,唯一的区别就是 PDPTE 比 CR3 多了一个 P
标志位表示该 PDPTE 是否有效。
另外就是由于物理地址增大到 36 bits,因此页目录表地址字段增大到 36 - 12 = 24 bits。
PDE
对于 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 专门定义的一类控制寄存器,不像通用寄存器那样在汇编中直接访问。要通过RDMSR
和WRMSR
指令来读写。
对于 PS
位为 1 的 PDE,各字段分布基本和非 PAE 分页模式同样基本一致。不同点是由于大页的大小有 4MB 减半到 2MB,因此地址字段的起始位从 22 减小到 21。
PTE
PTE 除了内存页物理地址字段长度增加外基本无变化。
缓存
分页不仅仅是地址映射,它与 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 | 1. 固定类型区域(如 ROM/显存)? |
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 属性
- 非 PAE(
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
2mov 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 | |---------------------------| |
其中 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 | \ (根目录,DirectoryObject) |
其中每一类目录的具体作用如下:
目录名 | 作用 | 常见对象类型 |
---|---|---|
\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
10NTSTATUS 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 会按照文件系统路径解析的逻辑进行处理:
解析
C:
盘符;(由于设备路径缺少盘符,一般会在这一步报错❌)找到其对应物理卷;
然后交给文件系统驱动处理。
然而,像 COM1 串口、物理硬盘 (
PhysicalDrive0
)、USB 端口、命名管道、内核设备对象这些根本不属于文件系统。文件系统找不到这些对象,它们被挂在\Device\xxx
下(内核对象管理器里)。因此如果用户程序也希望用标准的CreateFile()
访问设备对象则 Win32 会试图当做文件路径来走,肯定会失败。于是微软在 Win32 设计了一个特殊标记机制:只要路径以\\.\
开头,Win32 不参与文件系统逻辑,而是把后面的内容原样放进\??
命名空间,留给内核对象管理器自己去解析。例如:1
2\\.\COM1 → \??\COM1 → \Device\Serial0
\\.\PhysicalDrive0 → \??\PhysicalDrive0 → \Device\Harddisk0\DR0Object Manager 路径 :所有内核对象的正式地址。样的路径仅对对象管理函数有效,如
ObReferenceObjectByName
、IoCreateDevice
、IoCreateSymbolicLink
等 API 可以直接使用;对文件 API 无意义。
典型解析链路
文件示例:
1 | CreateFile("C:\\Windows\\System32\\drivers\\Test.sys") |
设备示例:
1 | CreateFile("\\\\.\\COM1") |
驱动基本概念
驱动程序(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
→ 实际指向ControlSet001
或ControlSet002
,系统启动时动态决定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
5SC_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
15SC_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
15CreateService(
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
5SC_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
5BOOL StartService(
SC_HANDLE hService, // 目标服务的句柄
DWORD dwNumServiceArgs, // 参数个数(驱动设为 0)
LPCSTR *lpServiceArgVectors // 参数数组(驱动设为 NULL)
);hService
:来自CreateService
或OpenService
的服务句柄。dwNumServiceArgs
:参数个数,驱动无参数则设为0
。lpServiceArgVectors
:参数数组,驱动无参数则设为NULL
。
ControlService
:向运行中的服务发送控制命令。用于停止驱动(需驱动实现Unload
函数)。1
2
3
4
5BOOL ControlService(
SC_HANDLE hService, // 服务句柄
DWORD dwControl, // 控制命令(如 SERVICE_CONTROL_STOP)
LPSERVICE_STATUS lpServiceStatus // 输出当前服务状态
);hService
:目标服务句柄。dwControl
:控制命令,停止服务时设为SERVICE_CONTROL_STOP
(0x1)。lpServiceStatus
:接收服务状态的结构体指针。
DeleteService
:删除指定服务或驱动注册信息(从注册表清除),不会立即卸载已加载驱动。1
2
3BOOL DeleteService(
SC_HANDLE hService // 目标服务句柄
);hService
:目标服务句柄,需具有DELETE
权限。
CloseServiceHandle
:关闭服务或 SCM 句柄,释放资源。1
2
3BOOL CloseServiceHandle(
SC_HANDLE hSCObject // 可为服务句柄或 SCM 句柄
);hSCObject
:服务或控制管理器的句柄。
示例代码
1 |
|
相关命令
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 框架、测试加载器或绕过方式。
原理流程
首先用户态程序提前创建注册表项(路径一般为):
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\MyDriver
其中必须设置至少两个关键键值:
ImagePath
(REG_EXPAND_SZ
):驱动文件路径,如:\??\C:\Path\to\MyDriver.sys
Type
(DWORD
):必须为1
,表示该服务为内核驱动。
构造 NT 路径并调用
ZwLoadDriver
加载驱动,该函数原型如下:1
2
3NTSYSAPI 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
:已加载
提示
ZwLoadDriver
和NtLoadDriver
实际上是同一个函数的两个符号。在 Windows 内核设计中,
NtXxx
和ZwXxx
实际上代表的是同一个系统服务接口(Syscall)函数,但它们存在 调用上下文(user mode vs kernel mode)下的行为差异 和 API 访问路径差异,而 在用户态时,它们几乎是完全等价的入口符号。- 参数:注册表路径,格式为:
与驱动加载类似,卸载驱动的时候需要调用
ZwUnloadDriver
,并传入同样格式的注册表路径。注意
驱动必须自己实现
DriverUnload
回调,系统才会调用卸载。ZwUnloadDriver
函数原型如下:1
2
3NTSYSAPI NTSTATUS NTAPI ZwUnloadDriver(
IN PUNICODE_STRING DriverServiceName
);- 参数同上,指定已加载驱动的注册表路径;
- 若驱动未实现
DriverUnload
,调用将失败(一般是STATUS_INVALID_DEVICE_REQUEST
)。
清理注册表项。**
ZwUnloadDriver
不会自动删除注册表项**,即驱动从内核卸载后,注册表中的服务项仍然存在,必须你手动清理。
示例代码
1 |
|
驱动开发基础
基本代码
通常一个最基本的 WDM 驱动代码如下:
1 |
|
其中 DriverEntry
是 Windows 驱动程序的主入口函数(Entry Point),等同于用户程序中的 main()
函数。
1 | NTSTATUS DriverEntry( |
当驱动加载时,系统会调用此函数来完成驱动的初始化过程。
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 | NTSTATUS MyFunction() |
日志输出
内核调试输出需要使用专门的 api,并且输出内容走的是 DbgPrint Buffer
,通常只有连接 WinDbg / KD 等调试器时可实时显示;无内核调试连接时,有些版本仍可借助 DebugView(SysInternals 工具)捕获部分内核日志。
DbgPrint
:最基本的内核调试输出函数,用法与printf
类似,默认输出优先级较低,相当于DbgPrintEx(DPFLTR_DEFAULT_ID, DPFLTR_INFO_LEVEL, ...)
。1
2
3
4ULONG DbgPrint(
PCSTR Format, // 格式化字符串,类似 printf()
... // 可变参数
);DbgPrintEx
:DbgPrint
的增强版,允许指定组件类别和日志等级,便于在复杂项目中分类控制输出。1
2
3
4
5
6ULONG 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 | typedef struct _UNICODE_STRING { |
注意
Length
单位是字节,不是字符数;Buffer
不强制null
结尾;
UNICODE_STRING
可以通过 RtlInitUnicodeString
函数和 RTL_CONSTANT_STRING
宏两种方式进行初始化。
注意
这两种初始化方法都不会拷贝字符串内容,而只设置结构体,指针仍指向原始常量字符串。
RtlInitUnicodeString
函数原型如下:1
2
3
4VOID RtlInitUnicodeString(
PUNICODE_STRING DestinationString,
PCWSTR SourceString
);- 该函数会设置结构体的
Length
,MaximumLength
,Buffer
字段。 SourceString
必须是 null 结尾的常量或合法缓冲区
示例代码:
1
2UNICODE_STRING uStr;
RtlInitUnicodeString(&uStr, L"\\Device\\MyDriver");- 该函数会设置结构体的
RTL_CONSTANT_STRING
RTL_CONSTANT_STRING
用于编译期静态构造一个UNICODE_STRING
,该宏的定义如下:1
示例代码:
1
UNICODE_STRING path = RTL_CONSTANT_STRING(L"\\Device\\MyDriver");
注意
RTL_CONSTANT_STRING
不能用于变量字符串,只能用于编译期可见的字符串常量(即L"..."
形式的字面量)。如果你错误地用它去初始化一个运行时变量,会导致:结构体字段内容不正确(长度计算可能错误)
潜在的内存越界访问
编译器不报错,但运行时行为未定义
字符串转换
在实际开发中,经常会遇到将用户传入的 ANSI 字符串转换为内核 API 可用格式这种需求,这就需要我们将 char *
字符串转换为 UNICODE_STRING
类型,具体步骤如下:
首先我们需要利用
RtlInitAnsiString
函数将char *
字符串转换为ANSI_STRING
类型:1
2
3
4char* ansi = "MyDevice\\Test";
ANSI_STRING ansiStr;
RtlInitAnsiString(&ansiStr, ansi);提示
在有些教程中这一步会使用
RtlInitString
函数将将char *
字符串转换为STRING
类型,实际上这里的STRING
类型实际上就是ANSI_STRING
的旧别名,结构相同。使用
RtlAnsiStringToUnicodeString
函数将ANSI_STRING
字符串转换为UNICODE_STRING
字符串。这里RtlAnsiStringToUnicodeString
函数原型如下:1
2
3
4
5NTSTATUS RtlAnsiStringToUnicodeString(
PUNICODE_STRING DestinationString,
PCANSI_STRING SourceString,
BOOLEAN AllocateDestinationString
);DestinationString
:输出的 Unicode 结构体SourceString
:输入的 ANSI 结构体AllocateDestinationString
:如果为TRUE
,系统会分配DestinationString->Buffer
;否则你必须事先分配好ANSI_STRING.Buffer
并设置MaximumLength
,否则可能崩溃或数据丢失。
这里为了方便封装,我们采用
AllocateDestinationString
为TRUE
的写法。对于这样产生的UNICODE_STRING
字符串,使用完毕时候我们需要调用RtlFreeUnicodeString
函数将其释放,这里释放的是ANSI_STRING.Buffer
。
在实际开发中,我们一般习惯将上述步骤封装成一个函数:
1 | NTSTATUS ConvertAnsiToUnicode(_In_ const char* input, _Out_ PUNICODE_STRING uStr) { |
常用函数
RtlStringCbPrintfA/W
:内核安全字符串格式化函数1
2
3
4
5
6
7
8
9
10
11
12
13NTSTATUS 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
格式
RtlCompareUnicodeString
:UNICODE_STRING
安全比较1
2
3
4
5LONG RtlCompareUnicodeString(
_In_ const UNICODE_STRING *String1,
_In_ const UNICODE_STRING *String2,
_In_ BOOLEAN CaseInSensitive
);CaseInSensitive
:是否大小写无关(TRUE
表示忽略大小写)- 返回值:返回逻辑类似 C 标准库
strcmp
。0
:相等<0
:String1
小于String2
>0
:String1
大于String2
双向链表(LIST_ENTRY)
在 Windows 中有一个专门描述链表节点的结构 LIST_ENTRY
,该结构定义如下:
1 | typedef struct _LIST_ENTRY { |
在 Windows 的设计思想中,双向链表有两种成员组成:
ListHead
:即链表头,通常类型为LIST_ENTRY
结构体,有时会作为一个成员放到另一个结构体中,但是作为“链表头”本身仍是LIST_ENTRY
类型。链表头自己不存储任何数据,只是链表控制块,但会被串到双向链表中。1
2LIST_ENTRY MyList;
InitializeListHead(&MyList);注意
Windows 的
LIST_ENTRY
初始状态必须是:1
2ListHead->Flink = ListHead;
ListHead->Blink = ListHead;任何链表必须先初始化,否则后续操作容易蓝屏。Windows有一个专门用于初始化
ListEntry
的函数InitializeListHead
。1
VOID InitializeListHead(PLIST_ENTRY ListHead);
Entry
:用来将节点链入双向链表中的一个结构体成员,类型同样为LIST_ENTRY
。例如下面这个结构体中的List
就是一个Entry
,我们可以通过从链表头遍历双向链表找到所有链表中的MY_NODE
结构体。1
2
3
4typedef struct _MY_NODE {
ULONG ID;
LIST_ENTRY List;
} MY_NODE;注意
我们通过双向链表遍历找到的结构体地址实际上是
List
成员的地址,要想获取到结构体地址还需要借助CONTAINING_RECORD
宏。1
2例如:
1
2PLIST_ENTRY pEntry = RemoveHeadList(&MyList);
PMY_NODE pNode = CONTAINING_RECORD(pEntry, MY_NODE, List);
针对双向链表,Windows 提供了众多 API 用于操作双向链表中的成员:
InsertHeadList()
:将Entry
插入到链表头部(头节点后,ListHead->Flink
方向)1
2
3
4BOOLEAN InsertHeadList(
PLIST_ENTRY ListHead,
PLIST_ENTRY Entry
);InsertTailList()
:插入到链表尾部(头节点前,ListHead->Blink
方向)1
2
3
4BOOLEAN InsertTailList(
PLIST_ENTRY ListHead,
PLIST_ENTRY Entry
);RemoveEntryList()
:从链表中删除指定节点。1
2
3BOOLEAN RemoveEntryList(
PLIST_ENTRY Entry
);RemoveHeadList()
/RemoveTailList()
:移除链表头部第一个(最后一个)元素。1
2PLIST_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 | NTSTATUS RtlInitializeGenericTable( |
Table
:用于保存初始化后的通用表结构体。你需要先定义好RTL_GENERIC_TABLE
结构体,把地址传进来,内核将填充其中的指针和配置字段。CompareRoutine
:比较函数指针,内核在插入/查找/删除时调用此函数以判定 Key 大小顺序。你需要实现此函数来定义排序规则,返回值为GenericLessThan
/GenericGreaterThan
/GenericEqual
。回调函数声明如下:1
2
3
4
5typedef RTL_GENERIC_COMPARE_RESULTS (*PRTL_GENERIC_COMPARE_ROUTINE)(
PRTL_GENERIC_TABLE Table,
PVOID FirstStruct,
PVOID SecondStruct
);AllocateRoutine
:分配函数指针,在插入新节点时,内核通过此函数为节点分配内存。通常传入封装好的ExAllocatePoolWithTag()
。1
2
3
4typedef PVOID (*PRTL_GENERIC_ALLOCATE_ROUTINE)(
PRTL_GENERIC_TABLE Table,
CLONG ByteSize
);FreeRoutine
:释放函数指针,当删除节点时内核通过此函数释放节点内存。通常封装调用ExFreePool()
。1
2
3
4typedef 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 |
|
常用 API
内存管理
Windows 内核模式下有专门的内存申请/释放函数 ExAllocatePool
/ExAllocatePoolWithTag
和 ExFreePool
/ExFreePoolWithTag
。
1 | PVOID ExAllocatePoolWithTag( |
PoolType
:选择使用的内存池类型,常见的内存池有:PagedPool
:分页池,内存可被交换到磁盘。NonPagedPool
:非分页池,驻留物理内存,可执行。
NumberOfBytes
:要申请的字节数,任意长度。Tag
:内存标签,Windbg!poolused
、!pooltag
可用此标签进行泄漏、溢出排查。ExAllocatePool
和ExFreePool
函数缺少这个参数。
线程
在内核中通常使用 PsCreateSystemThread
函数创建内核线程,该函数原型如下:
1 | NTSTATUS PsCreateSystemThread( |
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 |
|
驱动隐藏
隐藏思路
在 Windows 内核中,驱动模块在系统内部存在两个最关键的暴露点:
PsLoadedModuleList
:系统全局模块双向链表(记录所有已加载驱动模块)DriverObject
结构体:系统所有已注册的驱动对象(包含驱动模块信息)
因此隐藏的本质是:
- 从
PsLoadedModuleList
断链 → 让系统模块枚举 API 查不到 - 抹除
DriverObject
内关键字段 → 让安全软件和调试器无法逆推出模块信息
隐藏流程
- 延迟隐藏逻辑 :如果在
DriverEntry()
阶段立即隐藏,可能会因为内核后续调用尚未完成而引发异常。这是因为驱动加载的后续流程可能会用到DriverObject
结构体中一些对象。因此我们可以启动一个内核线程,延迟约 100ms 后再执行隐藏逻辑。 - 断链模块表 :遍历
PsLoadedModuleList
,逐个对比BaseDllName
与目标模块名。找到后执行RemoveEntryList()
完成断链。 - 筛选合法伪造模块 :在遍历链表时顺便记录第一个合法存在的其它模块节点。该节点用作伪造用
DriverSection
。 - 定位 DriverObject :使用内核 API
ObReferenceObjectByName()
定位目标驱动的DriverObject
。 - 抹除与伪造 :将
DriverInit
、DriverSection
、Type
字段抹除或伪造。其中DriverSection
需要执行前面筛选的合法DriverSection
,防止安全软件在扫描DriverObject->DriverSection
时蓝屏。
完整代码
1 |
|
驱动通信
设备对象
绝大多数情况下,一个内核驱动如果没有创建任何设备对象 (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 | 用户 I/O 请求 |
不过大多数普通第三方内核驱动开发者实际上写的都是类似 FDO 或 Filter,PDO 只能由 Bus Driver 创建(通常系统自带)。
设备类型 | PDO 创建者 | FDO 创建者 | Filter 创建者 |
---|---|---|---|
USB 存储 | USB Hub 驱动 | UAS 驱动(如 usbstor.sys) | 杀毒软件过滤层 |
网卡 | PCI Bus 驱动 | NIC 功能驱动 | 防火墙、抓包驱动 |
虚拟设备 | Root Enumerator | 虚拟驱动 | 监控、调试工具 |
设备对象与驱动对象的关系:
- 一个驱动可以创建(
IoCreateDevice
)或附加(IoAttachDevice
)多个设备对象,用于负责处理和过滤多个设备的消息。 - 一个物理设备可以绑定多个设备对象,这些设备对象形成了一个设备对象堆栈,可以层层过滤用户程序向设备发送的消息。
设备对象定义
1 | struct _DEVICE_OBJECT |
DriverObject
:所属驱动的DriverObject
,用于找到设备对象所属的驱动对象。NextDevice
:将一个驱动所属的所有设备对象DriverObject
串联成一个单向链表,链表头为DriverObject->DeviceObject
。1
2
3DriverObject
|
+--> DeviceObject1 --> DeviceObject2 --> DeviceObject3 --> NULLAttachedDevice
:AttachedDevice
构成的是跨驱动的 I/O 层叠栈(Stacked Device Stack)。它让不同驱动可以挂接在同一设备上层,共同参与 I/O 请求的流转与处理。设备对象的AttachedDevice
指向下一层的设备对象。1
2
3
4
5
6
7
8
9
10
11
12
13IRP 入口
↓
DeviceObject_Filter3 <-- 过滤层3(最上层)
↓ AttachedDevice
DeviceObject_Filter2 <-- 过滤层2
↓ AttachedDevice
DeviceObject_Filter1 <-- 过滤层1
↓ AttachedDevice
DeviceObject_Functional (FDO) <-- 功能设备对象 (目标核心驱动)
↓ AttachedDevice
DeviceObject_PDO <-- 物理设备对象 (底层物理设备)
↓ AttachedDevice
NULLStackSize
:从当前设备对象所在的位置,往下直到整个设备栈的最底层(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 | NTSTATUS IoCreateDevice( |
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 | // 设备命名:注册到 Object Manager 命名空间下 |
那么用户程序就可以通过设备路径 \\.\MyDevice
来操作这个设备了。
最后我们需要对 DeviceObject->Flags
清除初始化标志。这一步实际上是为了兼容一些老的操作系统,这一类操作系统不会自动去除初始化标志,导致设备对象创建之后始终处于未初始化状态,导致一些对设备的操作失败。
1 | // 设备初始化完成,清除初始化标志 (兼容老系统) |
示例代码如下:
1 |
|
设备对象附加
在 Windows 内核 I/O 框架中,驱动可以将自己编写的设备对象附加到现有的设备对象之上,形成设备对象堆栈(Device Stack)。这种附加行为常用于开发过滤驱动、监控驱动、保护驱动、文件过滤驱动等场景。
内核提供 IoAttachDevice
或 IoAttachDeviceToDeviceStack
函数用于实现附加操作。
IoAttachDevice
函数原型如下:
1 | PDEVICE_OBJECT IoAttachDevice( |
SourceDevice
:本驱动中用IoCreateDevice()
创建好的设备对象,作为过滤层插入堆栈;TargetDevice
:目标设备对象的全路径(如\Device\Harddisk0\DR0
),指定要附加到哪个设备;AttachedTo
:附加成功后返回目标设备对象指针,即被附加的设备对象。也就是说附加后SourceDevice->AttachedDevice = AttachedTo
。
IoAttachDeviceToDeviceStack
则需要我们直接提供要被附加的设备对象,而不是设备路径,该函数定义如下:
1 | PDEVICE_OBJECT IoAttachDeviceToDeviceStack( |
SourceDevice
:指定新创建的过滤层设备对象(一般通过IoCreateDevice()
创建),将被插入到设备堆栈顶端,成为新的栈顶对象。附加成功后,该对象位于整个设备栈最顶层,优先接收 IRP 请求。TargetDevice
:要附加的目标设备对象。系统会根据其AttachedDevice
自动遍历整个设备栈,找到当前栈顶位置然后附加。
当完成附加后,当用户态通过 CreateFile()
打开设备时,虽然传入的设备路径仍然是被附加的设备对象。
但是由于我们在内核中通过 IoAttachDeviceToDeviceStack()
已经把自己的设备对象挂入了目标设备对象的 I/O 栈顶,因此用户请求命中目标设备对象时,内核始终从栈顶开始派发 IRP。而我们的过滤设备对象就在这条栈上,所有请求自然会经过我们的驱动。
设备对象附加的示例代码如下:
1 | NTSTATUS AttachToTargetDevice(PDEVICE_OBJECT MyDevice) |
在驱动卸载的时候,我们需要在删除自己的设备对象之前先从设备栈分离。
1 | IoDetachDevice(MyDeviceExtension->NextDevice); |
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 | // 0x70 bytes (sizeof) |
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 | IRP 栈状态: |
很多关于 IRP
结构体的 API 本质上就是在操作这三个字段:
API 函数 | 作用 | 本质操作的字段变化 |
---|---|---|
IoGetCurrentIrpStackLocation() |
获取当前派发栈帧指针 | 返回 CurrentStackLocation |
IoGetNextIrpStackLocation() |
获取下一个栈帧指针(仅指针偏移,不修改位置) | 返回 CurrentStackLocation - 1 |
IoSetNextIrpStackLocation() |
手动推进派发位置(很少用) | CurrentLocation-- ,CurrentStackLocation-- |
IoSkipCurrentIrpStackLocation() |
抵消 IoCallDriver 函数内部的“推进派发位置”的操作,使得下一层设备对象仍然处理当前栈帧 |
CurrentLocation++ ,CurrentStackLocation++ |
参数结构
IO_STACK_LOCATION
中存储了参数信息,由于是所有类型的 IRP 派发函数公用,因此内部有一个联合体记录了每种类型的 IRP 派发函数对应的参数结构。
1 | // 0x24 bytes (sizeof) |
通常我们会使用 IoGetCurrentIrpStackLocation
从 IRP
结构体中拿当前栈帧对应的 IO_STACK_LOCATION
参数。当然也可以通过 IoGetNextIrpStackLocation
获取下一层栈帧对应的 IO_STACK_LOCATION
参数或者手动解析 IRP
结构体拿任意一层的参数。
不过下一层的 IO_STACK_LOCATION
默认是空白的。如果我们作为过滤驱动,需要通过 IoCopyCurrentIrpStackLocationToNext
函数将当前栈帧中的参数拷贝到下一层才能让下一层的设备对象对应的驱动在当前栈帧中拿到参数。
而如果是底层设备栈的第一层(创建 IRP 时)则内核早已填好栈帧了,不会有这个问题。
1 | FORCEINLINE |
另外通过 IoSkipCurrentIrpStackLocation
跳过当前设备对象的话也是同样的效果。
返回结构
对于参数传递,IRP
针对每层的设备对象都有对应的 IO_STACK_LOCATION
,然而对于返回值,所有层最终共用同一个 IoStatus
返回区和缓冲区,只要有一层完成请求,设置好 IoStatus.Status
和 IoStatus.Information
即可。
IoStatus
是 IRP
结构体中的一个成员,该成员类型为 IO_STATUS_BLOCK
,定义如下:
1 | // 0x8 bytes (sizeof) |
其中 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 | typedef struct _DRIVER_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 | NTSTATUS DispatchFunction( |
我们需要在 DriverEntry()
中定义该类型的函数,并将其注册到 MajorFunction[]
数组中。
1 | DriverObject->MajorFunction[IRP_MJ_CREATE] = DispatchCreate; |
注意
最少也要注册 IRP_MJ_CREATE
/ IRP_MJ_CLOSE
/ IRP_MJ_DEVICE_CONTROL
,否则基本无法和用户层通信。
IRP_MJ_CREATE
和IRP_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_IO
与DO_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
3DeviceType
:设备类型代码,占用高 16 位(位 31-16)。如FILE_DEVICE_UNKNOWN
,FILE_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 |
|
系统调用
进程线程
句柄表
- 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.