第2章 系统调用
如果说第 1 章给读者建立的是"Windows 内核长什么样"的全景图,那么本章要回答的就是**"用户态与内核态之间究竟如何对话"------这就是系统调用**。
系统调用(System Call)是现代操作系统的核心机制 :应用程序运行在受限的 ring 3 等级,绝大部分硬件资源由 ring 0 的内核独占;当应用需要"打开文件"、"申请内存"、"创建进程"等受保护的服务时,必须通过一条精心设计的、CPU 级别的"边界通道"向内核提出请求。这条通道的两端------用户态的入口(ntdll!Nt* stub)与内核态的入口(ntoskrnl!KiSystemService*)------是本章逐字节要剖析的对象。
本章将分六小节,沿着"内核态入口 → 调度跳转 → 出口"这条主轴,把 ReactOS 在 x86 平台上对系统调用的实现一层一层剥开。每一节都先给出一张 ASCII 框架图作为"先见森林"的导览,再深入到代码与数据结构层面讲解"树木"。
2.1 内核与系统调用
2.1.0 框架图(先见森林)
在展开细节前,先用一张图勾勒本章的"系统调用全貌"------读者可以把它当作本节的导航图。
┌────────────────────────────────────────────────────────────────────┐
│ CPU 在用户态执行应用代码(ring 3) │
│ │
│ ──── 三道穿越用户/内核边界的门 ────── │
│ │
│ INT n / sysenter / INT 2Eh │ 异常 (Trap) │ 中断 (IRQ) │
│ ─────────────────────── │ ──────────── │ ──────────── │
│ 系统调用(用户主动) │ CPU 主动触发 │ 硬件被动触发 │
│ │ │ │ │ │ │
│ ↓ │ ↓ │ ↓ │
│ _KiSystemService │ KiTrap*Handler │ KiInterrupt* │
│ _KiFastCallEntry │ │ │
│ │ │ │ │ │
│ ↓ │ ↓ │ │
│ ──── 共同的 C 层入口 ──── │
│ KiSystemServiceHandler (本节) │
│ Ki*ExceptionHandler / Ki*InterruptHandler (其他章节) │
│ │ │
│ ↓ │
│ Nt* 真正实现(ntoskrnl 内) │
│ ...或异常/中断处理函数 │
│ │ │
│ ↓ │
│ KiServiceExit / KiTrapReturn → 返回用户态/内核态 │
└────────────────────────────────────────────────────────────────────┘
本图核心要点 :系统调用只是"穿越边界"的三道门之一。一旦进入内核层,无论是系统调用、异常、还是中断,最终都汇入 C 层的统一入口,再向下调用真正的实现。理解这张图后,读者应当形成这样的心智模型:系统调用不是孤立的一条线,而是与中断/异常共用的内核入口框架。
2.1.1 边界的存在
现代 CPU 都提供"特权级"(privilege level)机制以隔离用户程序与操作系统内核。x86 架构提供了 4 个特权级(ring 0~ring 3),其中:
- ring 0:最高特权,内核态。CPU 在此特权级下可以执行任何指令、访问任何内存、读写 I/O 端口、配置硬件。
- ring 3 :最低特权,用户态。CPU 在此特权级下禁止执行特权指令(如
cli/sti、修改 CR0/CR3/CR4、读写 I/O 端口等),对内存的访问也受限于页表中的特权位。
Windows NT 实际只使用了 ring 0 与 ring 3 两个特权级(ring 1 与 ring 2 保留)。所有用户态的应用程序、可执行文件、DLL 都运行在 ring 3 ;所有内核态的代码------包括 ntoskrnl.exe、hal.dll、win32k.sys、各种设备驱动 .sys------都运行在 ring 0。
特权级划分的目的是安全与稳定:
- 进程隔离:用户态的进程 A 无法直接读取/破坏进程 B 的内存,更不能直接操纵硬件。
- 内核保护:用户态的错误(如除零、访问空指针、缓冲区溢出)只能让该进程崩溃,而不会破坏内核。
- 硬件独占:只有内核态能直接与硬件(磁盘、网卡、显卡等)通信,避免应用程序争夺资源。
代价是:用户态不能直接做"打开文件"等需要特权级的事,必须有一条受控的"通道"把请求传递给内核。这就是系统调用。
2.1.2 边界的三道门
CPU 提供三种"穿越用户态-内核态边界"的机制,分别是:
- 系统调用(System Call) :用户态主动发起,请求内核服务。x86 上的实现有
INT 2Eh、sysenter、syscall三种指令。 - 异常(Exception):CPU 在执行指令时检测到错误或陷阱条件时触发。典型异常有除零(#DE)、缺页(#PF)、断点(#BP)、通用保护(#GP)等。
- 中断(Interrupt):外部硬件(时钟、键盘、网卡、磁盘等)向 CPU 发送的电信号,CPU 暂停当前执行并跳转到预设的中断处理例程。
三者在内核态的入口不同,但在更深的层次(特别是 C 层的处理函数)有许多共用的代码路径:都需要保存寄存器现场(KTRAP_FRAME)、切换到内核栈、判断是哪个线程触发的、然后调用相应的处理函数。ReactOS 的 traphdlr.c 中可以看到 Ki*Handler 系列函数名(如 KiSystemServiceHandler、KiDebugHandler、KiNpxHandler),它们以相似的方式处理不同类型的事件。
2.1.3 系统调用的三要素
任何系统调用都涉及三个约定(contract),少一个都不能工作:
- 入口约定(Entry Convention) :用户态如何切到内核态。x86 上是
INT 2Eh(兼容、慢速)、sysenter(快速)、syscall(x64 专用)。每种指令对 CPU 寄存器、栈、CS/SS 都有特定约定。 - 参数约定(Parameter Convention) :如何把参数从用户栈传送到内核。Windows 的做法是:
- 用户态 stub 把"参数区地址"(一个指针)放在
edx寄存器中; - 把"系统调用号"放在
eax寄存器中; - 真正的参数本身保留在用户态栈上不动(不需要拷贝,由
KiSystemCallTrampoline后续在调用时复制)。
- 用户态 stub 把"参数区地址"(一个指针)放在
- 返回约定(Return Convention) :如何把内核的返回值回传给用户态。结果以
NTSTATUS(32 位有符号整数)的形式通过eax写回。带外数据则通过参数中传入的"输出缓冲区指针"由内核填充。
这三个约定在 x86/x64 各有不同,学习 ReactOS 源码时必须时刻注意当前讨论的是 x86 还是 x64。本章默认讨论 x86 实现。
2.1.4 ReactOS 的实现要点
在 ReactOS 中,x86 系统调用的实现要点可以归纳为以下几条:
(1) 三种入口指令
INT 2Eh:兼容路径,所有 x86 CPU 都支持。触发软中断,CPU 自动压栈SS/ESP/EFLAGS/CS/EIP。sysenter:快速路径,Pentium II 及以上支持。CPU 不自动压栈,CS/ESP/EIP 由 MSR(Model-Specific Register)预设。ReactOS 32 位默认走sysenter。syscall:x64 专用。AMD 引入,Intel 也支持。是 x64 上sysenter的对应物。
(2) 退化机制
如果当前 CPU 不支持 sysenter(如非常老的 Pentium、Pentium MMX),ReactOS 会通过 KiFastSystemCallDisable 全局变量关闭快速路径。判断代码在 ntoskrnl/ke/i386/cpu.c 第 1045 行附近:
c
/* ntoskrnl/ke/i386/cpu.c, 第 28 行(全局变量) */
ULONG KiFastSystemCallDisable = 0;
/* ntoskrnl/ke/i386/cpu.c, 第 1045 行附近(条件检查) */
if (KiFastSystemCallDisable)
{
/* Disable fast system call */
KeFeatureBits &= ~KF_FAST_SYSCALL;
KiFastCallExitHandler = KiSystemCallTrapReturn;
DPRINT1("Support for SYSENTER disabled.\n");
}
KiFastSystemCallDisable 默认 0 ------ReactOS 启动时默认启用 sysenter 快速路径;只有当启动过程中检测到硬件缺陷或用户主动禁用时,才回退到 INT 2Eh 慢速路径。
(3) IDT 中 DPL3 的特殊意义
在 ntoskrnl/ke/i386/trap.s 第 69 行,我们看到 IDT 中对系统调用的注册:
asm
/* ntoskrnl/ke/i386/trap.s, 第 69 行 */
idt _KiSystemService, INT_32_DPL3 /* INT 2E: System Call Service Handler */
其中 INT_32_DPL3 是一个描述符位掩码,定义在 ntoskrnl/include/internal/i386/asmmacro.S 第 12 行:
c
#define INT_32_DPL3 HEX(0EE00)
这个值的关键在于 DPL 字段(Descriptor Privilege Level)= 3。DPL3 意味着用户态(ring 3)可以主动调用这个中断 ------而一般的硬件中断(如时钟中断)使用 INT_32_DPL0(DPL 0),用户态无法触发。
因此,DPL3 是"系统调用"区别于普通中断的根本特征 :CPU 允许用户态应用通过 INT 2Eh 指令主动陷入内核,而通过 INT 8(时钟中断)则不行。
(4) 用户态的 KiFastSystemCall
ntdll.dll 中还有一个关键的用户态函数 KiFastSystemCall------它是一个汇编实现,内部执行:
asm
_KiFastSystemCall:
; 切换到内核栈
mov edx, esp ; 保存用户栈指针
; 注意:实际实现会用 sysenter 指令切换到 ring 0
; 此处给出简化示意
sysenter
ret
用户态 stub 通过 call [0x7FFE0300] 间接跳到这个函数。这里的 0x7FFE0300 是 KUSER_SHARED_DATA 系统区中的"系统调用入口指针"------所有用户态 stub 都用同一个入口,但根据每个 stub 传入的 eax(系统调用号)派发到不同的 Nt* 实现。
2.1.5 入口类型对照表
| 入口类型 | 汇编符号 | C 入口 | 触发指令 | 性能 |
|---|---|---|---|---|
慢速 INT 2Eh |
_KiSystemService |
KiSystemServiceHandler |
INT 2Eh |
较慢,CPU 自动压栈 |
快速 sysenter |
_KiFastCallEntry |
KiSystemServiceHandler |
sysenter |
较快,CPU 不压栈 |
| 单步跟踪 | _KiFastCallEntryWithSingleStep |
KiSystemServiceHandler |
sysenter(+TF) |
用于调试 |
| GUI 切换 | KiConvertToGuiThread |
PsConvertToGuiThread |
内部调用 | 临时扩展内核栈 |
两种入口(_KiSystemService 与 _KiFastCallEntry)虽然汇编不同,但都通过 KiCallHandler 宏(见 asmmacro.S:253)调用同一个 C 函数 KiSystemServiceHandler------这是 ReactOS 系统调用框架的核心抽象:入口的差异止于汇编层;进入 C 层后就是统一的处理流程。
2.1.6 小结
- 系统调用是用户态穿越到内核态的三道门之一,与中断/异常在更深的层次上共享代码。
- 三个约定:入口约定、参数约定、返回约定,缺一不可。
- x86 上有
INT 2Eh(兼容)与sysenter(快速)两种实现,ReactOS 默认走快速路径。 _KiSystemService与_KiFastCallEntry是汇编层的两个入口;它们会汇合到同一个 C 函数KiSystemServiceHandler。
2.1.7 为什么要这样做
读到这里,读者自然会问:为什么 x86 明明有 4 个特权级,Windows 却只用 ring 0 与 ring 3 两个?为什么系统调用要走 INT 2Eh 而不是 INT 0x80?为什么三道门(系统调用/异常/中断)最终要汇入同一个 C 入口? 这些问题不是"实现细节",而是 NT 内核架构设计时做出的关键取舍。理解它们,才能读懂 ReactOS 源码。
(1) 为什么只用 ring 0 / ring 3 两个特权级?
x86 架构提供 4 个特权级(ring 0~ring 3),理论上可以实现"内核→驱动→子系统→应用"的四层隔离。但 NT 团队在设计 Windows NT 时主动放弃了 ring 1 和 ring 2,原因有三个:
- 性能代价:每次特权级切换都会触发 CPU 的栈切换(SS/ESP 自动加载)与段限长检查。多一层特权级意味着多一次栈切换------这对高频系统调用是不可接受的开销。
- 硬件差异 :并非所有非 x86 的硬件平台都支持 4 个特权级(如 MIPS 只有 2 个,Alpha 也只有 kernel/user 两种模式)。NT 是一个跨平台内核,必须在所有平台上保持一致的抽象。
- 复杂性:四层特权级要求驱动程序、子系统、应用之间层层隔离,这会让内核接口变得无比复杂。实践证明,"内核态 vs 用户态"的二元划分已经足够。
所以在 ReactOS 源码中,你只会看到两种模式:KernelMode 与 UserMode(定义在 ndk/mmtypes.h(file:///d:/reactos/sdk/include/ndk/mmtypes.h) 中,值为 0 和 1)。这是一个简洁而经典的设计取舍。
(2) 为什么是 INT 2Eh,而不是其他中断号?
这是历史约定 。Windows NT 3.x 时代选择了 0x2E(46 号中断)作为系统调用的软中断号。这个数值本身没有特殊含义,关键在于它必须同时满足两个条件:
- 不能与硬件中断冲突:x86 的前 32 个中断(0x00~0x1F)被 CPU 自身用于异常报告;从 0x20 起的中断可以分配给外部硬件或系统调用。
- IDT 描述符必须设置 DPL=3(用户态可主动触发),而普通硬件中断的 IDT 描述符设置 DPL=0(只有内核态能访问)。
你可以在 trap.s:69(file:///d:/reactos/ntoskrnl/ke/i386/trap.s#L69) 看到:
asm
idt _KiSystemService, INT_32_DPL3 /* INT 2E: System Call Service Handler */
这一行的关键是 INT_32_DPL3------它告诉 CPU:允许 ring 3 的代码通过 INT 0x2E 指令主动切到 ring 0 。如果少了 DPL3,用户态程序执行 INT 0x2E 会触发"通用保护异常"(#GP),直接崩溃。这是系统调用与普通中断最本质的安全区别:普通中断由硬件触发,用户态不能主动调用;系统调用是受控的"用户主动穿越"。
Linux 用的是 INT 0x80(后来也切到 sysenter/syscall),FreeBSD 用的是 INT 0x80------不同操作系统选不同的号,本质都是同一件事:为用户态开放一条受控的内核入口。
(3) 为什么三道门要汇入同一个 C 入口?
系统调用、异常、中断三道"门"在汇编层有不同的入口(_KiSystemService、_KiTrap00 等),但在 C 层都汇入统一的"构建 TrapFrame → 调用处理函数 → 恢复现场"流程。这是因为:无论穿越边界的原因是什么,内核要做的事情在本质上是一样的------保存现场、判断来源、调度处理、恢复现场。
把公共逻辑抽象到 C 层的好处非常清晰:
| 好处 | 具体解释 |
|---|---|
| 代码复用 | 不必为系统调用、异常、中断写三套几乎相同的寄存器保存/恢复代码 |
| 一致性 | PreviousMode 的设置、SEH 链的保存、调试寄存器处理、APC 投递都走同一条路径,不会出现"某条路径漏掉某个检查"的 bug |
| 可维护性 | C 语言比汇编易读,后续开发者修改公共逻辑只需动一份代码 |
| 可扩展 | 未来引入新的穿越方式(如虚拟化扩展)时,只需新增一个汇编入口,C 层无需改动 |
ReactOS 对这一点贯彻得非常彻底------你在 traphdlr.c(file:///d:/reactos/ntoskrnl/ke/i386/traphdlr.c) 中可以看到,KiSystemServiceHandler、KiDebugHandler、KiNpxHandler 等函数的结构非常相似。这是 NT 内核"汇编层只做最少量的差异化工作,C 层统一处理"设计哲学的直接体现。
2.2 系统调用的内核入口 KiSystemService()
2.2.0 框架图(先见森林)
┌─────────────────────────────────────────────────────────────────────┐
│ 用户态(ring 3) │
│ │
│ NtCreateFile (ntdll!Stub) │
│ eax = SysCallId, edx = &Arg[0] │
│ call [KUSER_SHARED_SYSCALL] │
│ │ │
│ sysenter / INT 2Eh │
│ ↓ │
├─────────────── 内核态(ring 0) ─────────────────────────────────── │
│ _KiSystemService / _KiFastCallEntry │
│ │ │
│ KiEnterTrap 宏(在 asmmacro.S 中) │
│ → 构建 KTRAP_FRAME(伪/真错误码 + 段寄存器 + 通用寄存器) │
│ │ │
│ KiSystemServiceHandler(TrapFrame, Arguments) ← traphdlr.c:1720│
│ ① 取当前线程,保存 PreviousPreviousMode │
│ ② Thread->PreviousMode = UserMode │
│ ③ 解码 SysCallId(Offset + Id) │
│ ④ 取 SSDT 描述符并做边界检查 │
│ ⑤ ProbeForRead(Arguments 不可越过用户边界) │
│ ⑥ Handler = SSDT[Id] │
│ ⑦ KiSystemCallTrampoline(Handler, Args, StackBytes) │
│ ⑧ KiServiceExit → 回到用户态 │
└─────────────────────────────────────────────────────────────────────┘
本图核心要点 :从汇编到 C 入口只经历"压栈 + 一层 C 调度"两步;所有的"解释用户参数 → 找到 Nt* 实现 → 调用"逻辑都集中在 KiSystemServiceHandler 一个函数内。本节要做的事,就是把这条流水线一节一节拆开看。
2.2.1 现场保护------KTRAP_FRAME
穿越边界的第一件事是保存现场 。当用户态代码触发系统调用时,CPU 自动做以下事情(以 INT 2Eh 为例):
- 将当前的
SS(栈段寄存器)、ESP(栈指针)、EFLAGS(标志寄存器)、CS(代码段寄存器)、EIP(指令指针)压入内核栈。 - 跳转到 IDT 中
INT 2Eh项指定的中断处理函数(即_KiSystemService)。 - 在
_KiSystemService中,KiEnterTrap宏继续保存其他通用寄存器(EAX/EBX/ECX/EDX/EBP/ESI/EDI)和段寄存器(DS/ES/FS/GS),形成一个完整的KTRAP_FRAME。
KTRAP_FRAME 是 x86 上系统调用、异常、中断三道门共用的"现场保存"数据结构。它在内核栈上自高地址向低地址生长,最终形成的内存布局如下(注意 32 位下栈向低地址生长,所以"低地址"对应"栈顶"):
┌────────────────────────────────────────┐ ← 栈顶(低地址)
│ EBP │ 非易失寄存器(被调用者保存)│
│ EBX │ │
│ EDI │ │
│ ESI │ │
│ EAX │ ← NtCreateFile 的 SysCallId│
│ EDX │ ← 用户参数指针 │
│ ECX │ │
│ Eip │ │
│ SegCs │ │
│ EFlags │ │
│ HardwareEsp │
│ HardwareSegSs │
│ SegDs │ │
│ SegEs │ │
│ SegFs │ │
│ SegGs │ │
│ ErrCode │ ← 系统调用时这里是 0 │
│ ... │
│ PreviousPreviousMode │ ← 在 KiSystemServiceHandler 中填充
│ ExceptionList │
│ Dr0..Dr7 │ 调试寄存器 │
└────────────────────────────────────────┘ ← 栈底(高地址)
每个字段的含义(按 x86 上的 i386/ke.h 与 ks386.h):
Eip、EFlags、SegCs、SegEs、SegDs:进入内核前的指令指针、标志、代码段与数据段。HardwareEsp/HardwareSegSs:用户态的栈指针与栈段寄存器。EAX:系统调用号(在用户态 stub 中由 stub 写入)。EDX:用户态参数区地址。ECX:sysenter 路径下保存的是用户态返回 EIP;INT 2Eh 路径下保留由 stub 设置的值。EBP/EBX/ESI/EDI:被调用者保存的通用寄存器,内核态使用前需要保存。SegFs/SegGs:段寄存器。ErrCode:错误码。CPU 异常中只有少数会压入错误码;系统调用时这里由KiSystemServiceHandler显式设为 0。PreviousPreviousMode:进入系统调用前的 PreviousMode,退出时恢复。ExceptionList:SEH 链。Dr0..Dr7:调试寄存器。
2.2.2 汇编入口的差异
ReactOS 在 x86 上有两个汇编入口:_KiSystemService(INT 2Eh 路径)和 _KiFastCallEntry(sysenter 路径)。它们在 ntoskrnl/ke/i386/trap.s 第 142-162 行:
asm
/* ntoskrnl/ke/i386/trap.s, 第 141-162 行 */
EXTERN @KiSystemServiceHandler@8:PROC
PUBLIC _KiSystemService
.PROC _KiSystemService
FPO 0, 0, 0, 0, 1, FRAME_TRAP
KiEnterTrap (KI_PUSH_FAKE_ERROR_CODE OR KI_NONVOLATILES_ONLY OR KI_DONT_SAVE_SEGS)
KiCallHandler @KiSystemServiceHandler@8
.ENDP
PUBLIC _KiFastCallEntry
.PROC _KiFastCallEntry
FPO 0, 0, 0, 0, 1, FRAME_TRAP
KiEnterTrap (KI_FAST_SYSTEM_CALL OR KI_NONVOLATILES_ONLY OR KI_DONT_SAVE_SEGS)
KiCallHandler @KiSystemServiceHandler@8
.ENDP
PUBLIC _KiFastCallEntryWithSingleStep
.PROC _KiFastCallEntryWithSingleStep
FPO 0, 0, 0, 0, 1, FRAME_TRAP
KiEnterTrap (KI_FAST_SYSTEM_CALL OR KI_NONVOLATILES_ONLY OR KI_DONT_SAVE_SEGS)
or dword ptr [ecx + KTRAP_FRAME_EFLAGS], EFLAGS_TF
KiCallHandler @KiSystemServiceHandler@8
.ENDP
可以看到,两者的核心区别仅在调用 KiEnterTrap 宏时传入的标志位:
_KiSystemService:KI_PUSH_FAKE_ERROR_CODE | KI_NONVOLATILES_ONLY | KI_DONT_SAVE_SEGS_KiFastCallEntry:KI_FAST_SYSTEM_CALL | KI_NONVOLATILES_ONLY | KI_DONT_SAVE_SEGS
KiEnterTrap 宏定义在 ntoskrnl/include/internal/i386/asmmacro.S 第 77-245 行。这里摘录关键分支(KI_FAST_SYSTEM_CALL 与 KI_PUSH_FAKE_ERROR_CODE 两个分支的开头):
asm
/* ntoskrnl/include/internal/i386/asmmacro.S, 第 77-103 行 */
MACRO(KiEnterTrap, Flags)
LOCAL not_v86_trap
LOCAL set_sane_segs
/* Check what kind of trap frame this trap requires */
if (Flags AND KI_FAST_SYSTEM_CALL)
/* SYSENTER requires us to build a complete ring transition trap frame */
FrameSize = KTRAP_FRAME_EIP
/* Fixup fs. cx is free to clobber */
mov cx, KGDT_R0_PCR
mov fs, cx
/* Get pointer to the TSS */
mov ecx, fs:[KPCR_TSS]
/* Get a stack pointer */
mov esp, [ecx + KTSS_ESP0]
/* Set up a fake hardware trap frame */
push KGDT_R3_DATA or RPL_MASK
push edx
pushfd
push KGDT_R3_CODE or RPL_MASK
push dword ptr ds:[KUSER_SHARED_SYSCALL_RET]
elseif (Flags AND KI_SOFTWARE_TRAP)
...
elseif (Flags AND KI_PUSH_FAKE_ERROR_CODE)
FrameSize = KTRAP_FRAME_EIP
else
FrameSize = KTRAP_FRAME_ERROR_CODE
endif
两种入口的关键差异:
(1) INT 2Eh 路径(_KiSystemService)
- CPU 已经自动压栈
SS/ESP/EFLAGS/CS/EIP。 KiEnterTrap看到KI_PUSH_FAKE_ERROR_CODE标志位,只分配到KTRAP_FRAME_EIP大小的栈空间(即跳过错误码槽位,预留一个伪错误码位置)。- 用户参数指针在
edx中(由 stub 设置),但 TrapFrame 已经在栈上完整保存了用户态的 ESP。
(2) sysenter 路径(_KiFastCallEntry)
- CPU 不自动压栈------sysenter 指令只设置 CS/SS/ESP/EIP 到 MSR 预设值。
KiEnterTrap看到KI_FAST_SYSTEM_CALL标志位,手动 从 TSS 取 ESP,按"伪 TrapFrame"的顺序压栈:SS=KGDT_R3_DATA|RPL→EDX(用户参数指针)→EFLAGS→CS=KGDT_R3_CODE|RPL→EIP=KUSER_SHARED_SYSCALL_RET。- 注意:
push edx是把"用户参数指针"放到HardwareEsp的位置------这是个巧妙的安排,让KiSystemServiceHandler后续读TrapFrame->HardwareEsp时拿到的就是edx的值。
(3) sysenter 路径的"伪 EIP"
push dword ptr ds:[KUSER_SHARED_SYSCALL_RET] 压入的是用户态的"返回地址"。这个返回地址来自 KUSER_SHARED_DATA(位于 0x7FFE0000,由内核与用户态共享)。KUSER_SHARED_SYSCALL_RET 是 KUSER_SHARED_DATA 中的一个字段,指向用户态 stub 调用 sysenter 之后的下一条指令地址(即 ntdll!KiFastSystemCallReturn 之后的位置)。这样构造的"伪 EIP"使得 sysexit 出口能正确地跳回用户态的 stub 之后。
2.2.3 C 入口 KiSystemServiceHandler
汇编层完成"压栈 + 设置参数"后,通过 KiCallHandler 宏(asmmacro.S:253)调用真正的 C 函数 KiSystemServiceHandler:
c
/* ntoskrnl/ke/i386/traphdlr.c, 第 1719-1854 行 */
DECLSPEC_NORETURN
VOID
FASTCALL
KiSystemServiceHandler(IN PKTRAP_FRAME TrapFrame,
IN PVOID Arguments)
{
PKTHREAD Thread;
PKSERVICE_TABLE_DESCRIPTOR DescriptorTable;
ULONG Id, Offset, StackBytes;
NTSTATUS Status;
PVOID Handler;
ULONG SystemCallNumber = TrapFrame->Eax;
/* Get the current thread */
Thread = KeGetCurrentThread();
/* Set debug header */
KiFillTrapFrameDebug(TrapFrame);
/* Chain trap frames */
TrapFrame->Edx = (ULONG_PTR)Thread->TrapFrame;
/* No error code */
TrapFrame->ErrCode = 0;
/* Save previous mode */
TrapFrame->PreviousPreviousMode = Thread->PreviousMode;
/* Save the SEH chain and terminate it for now */
TrapFrame->ExceptionList = KeGetPcr()->NtTib.ExceptionList;
KeGetPcr()->NtTib.ExceptionList = EXCEPTION_CHAIN_END;
/* Default to debugging disabled */
TrapFrame->Dr7 = 0;
/* Check if the frame was from user mode */
if (KiUserTrap(TrapFrame))
{
/* Check for active debugging */
if (KeGetCurrentThread()->Header.DebugActive & 0xFF)
{
/* Handle debug registers */
KiHandleDebugRegistersOnTrapEntry(TrapFrame);
}
}
/* Set thread fields */
Thread->TrapFrame = TrapFrame;
Thread->PreviousMode = KiUserTrap(TrapFrame);
/* Enable interrupts */
_enable();
/* Decode the system call number */
Offset = (SystemCallNumber >> SERVICE_TABLE_SHIFT) & SERVICE_TABLE_MASK;
Id = SystemCallNumber & SERVICE_NUMBER_MASK;
/* Get descriptor table */
DescriptorTable = (PVOID)((ULONG_PTR)Thread->ServiceTable + Offset);
/* Validate the system call number */
if (__builtin_expect(Id >= DescriptorTable->Limit, 0))
{
/* Check if this is a GUI call */
if (!(Offset & SERVICE_TABLE_TEST))
{
/* Fail the call */
Status = STATUS_INVALID_SYSTEM_SERVICE;
goto ExitCall;
}
/* Convert us to a GUI thread -- must wrap in ASM to get new EBP */
Status = KiConvertToGuiThread();
/* Reload trap frame and descriptor table pointer from new stack */
TrapFrame = *(volatile PVOID*)&Thread->TrapFrame;
DescriptorTable = (PVOID)(*(volatile ULONG_PTR*)&Thread->ServiceTable + Offset);
if (!NT_SUCCESS(Status))
{
/* Set the last error and fail */
goto ExitCall;
}
/* Validate the system call number again */
if (Id >= DescriptorTable->Limit)
{
/* Fail the call */
Status = STATUS_INVALID_SYSTEM_SERVICE;
goto ExitCall;
}
}
/* Check if this is a GUI call */
if (__builtin_expect(Offset & SERVICE_TABLE_TEST, 0))
{
/* Get the batch count and flush if necessary */
if (NtCurrentTeb()->GdiBatchCount) KeGdiFlushUserBatch();
}
/* Increase system call count */
KeGetCurrentPrcb()->KeSystemCalls++;
/* Get stack bytes */
StackBytes = DescriptorTable->Number[Id];
/* Probe caller stack */
if (__builtin_expect((Arguments < (PVOID)MmUserProbeAddress) && !(KiUserTrap(TrapFrame)), 0))
{
/* Access violation */
UNIMPLEMENTED_FATAL();
}
/* Call pre-service debug hook */
KiDbgPreServiceHook(SystemCallNumber, Arguments);
/* Get the handler and make the system call */
Handler = (PVOID)DescriptorTable->Base[Id];
Status = KiSystemCallTrampoline(Handler, Arguments, StackBytes);
/* Call post-service debug hook */
Status = KiDbgPostServiceHook(SystemCallNumber, Status);
/* Make sure we're exiting correctly */
KiExitSystemCallDebugChecks(Id, TrapFrame);
/* Restore the old trap frame */
ExitCall:
Thread->TrapFrame = (PKTRAP_FRAME)TrapFrame->Edx;
/* Exit from system call */
KiServiceExit(TrapFrame, Status);
}
按代码顺序,KiSystemServiceHandler 完成的工作可以分为 12 步:
- 取当前线程 :
Thread = KeGetCurrentThread();。从 PCR(处理器控制区域)中读出当前 ETHREAD。 - 保存 TrapFrame 链 :
TrapFrame->Edx = Thread->TrapFrame。Edx 字段被复用为"上一个 TrapFrame 指针",退出系统调用时通过Thread->TrapFrame = (PKTRAP_FRAME)TrapFrame->Edx恢复。 - 设置伪错误码 :
TrapFrame->ErrCode = 0;。系统调用没有真正的错误码。 - 保存 PreviousPreviousMode :
TrapFrame->PreviousPreviousMode = Thread->PreviousMode;。在退出时用Thread->PreviousMode = TrapFrame->PreviousPreviousMode恢复。 - 保存 SEH 链:把当前 SEH 链保存到 TrapFrame,临时清空 PCR 的 ExceptionList------这是为了在系统调用过程中如果发生异常,SEH 不会跨越系统调用边界。
- 处理调试寄存器 :如果
KiUserTrap(TrapFrame)为真(即来自用户态),并且当前线程处于调试激活状态,调用KiHandleDebugRegistersOnTrapEntry处理 DR7 等调试寄存器。 - 设置 PreviousMode 为 UserMode :
Thread->PreviousMode = KiUserTrap(TrapFrame);。从此,当前线程进入"处理用户请求"的语义------后续的 Nt* 实现据此判断是否需要 probe 用户态参数。 - 开中断 :
_enable();。允许中断嵌套。 - 解码系统调用号 :
Offset = (SysCallNum >> SERVICE_TABLE_SHIFT) & SERVICE_TABLE_MASK; Id = SysCallNum & SERVICE_NUMBER_MASK;。这一步将 32 位的SysCallNum拆为高位(表选择)与低位(表内偏移)两部分。详见 2.3 节。 - 边界检查与 GUI 转换 :如果
Id >= DescriptorTable->Limit(即"系统调用号超界"),则检查Offset是否含SERVICE_TABLE_TEST位------若含,说明该调用本意是 GUI 调用(Win32k 表),需要先把当前线程提升为 GUI 线程。GUI 线程使用更大的内核栈与不同的描述符表。 - Probe 用户栈 :
if (Arguments < MmUserProbeAddress) ...。MmUserProbeAddress是用户态地址的上界(默认 0x7FFF0000)。如果Arguments(用户参数指针)在这个范围内但当前是内核态调用------理论上不应发生------则触发 fatal 错误。这是一种防御性检查。 - 调用 Nt* 实现并退出 :
Handler = DescriptorTable->Base[Id]; Status = KiSystemCallTrampoline(Handler, Arguments, StackBytes); ... KiServiceExit(TrapFrame, Status);。
2.2.4 KiSystemCallTrampoline------参数复制与函数调用
KiSystemCallTrampoline 是一个汇编实现的 trampoline 函数 (trap.s:230-256),它的作用是把 Arguments(用户栈上的参数)复制到当前内核栈中,然后调用真正的 Nt* 实现。为什么要做一次"参数复制"?因为 Nt* 实现是普通的 C 函数,它会期望参数从栈顶往下读取。直接传递用户栈指针会面临两个问题:
- 指针合法性:用户栈可能在分页池中,被换出到磁盘。
- 结构体拷贝:某些参数是结构体,需要从用户栈完整拷贝到内核栈。
KiSystemCallTrampoline 的实现(trap.s:230-256):
asm
/* ntoskrnl/ke/i386/trap.s, 第 230-256 行 */
PUBLIC _KiSystemCallTrampoline@12
_KiSystemCallTrampoline@12:
push ebp
mov ebp, esp
push esi
push edi
/* Get handler */
mov eax, [ebp + 8] ; Handler (Nt* 函数指针)
/* Get arguments */
mov esi, [ebp + 12] ; Arguments (用户栈参数指针)
/* Get stack bytes */
mov ecx, [ebp + 16] ; StackBytes (参数总字节数)
/* Copy args to the stack */
sub esp, ecx ; 在当前栈上预留 StackBytes 空间
mov edi, esp
shr ecx, 2 ; 转换为 DWORD 数
rep movsd ; 复制 (DWORD * ecx) 字节
call eax ; 调用 Nt* 实现
pop edi
pop esi
leave
ret 12
END
执行流程:
- 设置标准函数栈帧,保存 esi/edi。
- 从参数中取
Handler、Arguments、StackBytes。 sub esp, ecx:在内核栈顶预留StackBytes字节。rep movsd:从用户栈复制参数到内核栈。call eax:调用真正的 Nt* 实现。这时 Nt* 函数看到的参数就像"从栈顶往下"读取的正常 C 函数参数。- 函数返回后清理栈帧,
ret 12清理调用方传入的 3 个参数。
注意 rep movsd 是用户栈 → 内核栈 的显式内存拷贝 ------这相当于一次"用户参数捕获"(capture)。如果用户栈的某些页面不在物理内存中(如被换出),rep movsd 会触发缺页异常;这是被允许的,因为 KiSystemServiceHandler 已经把 PreviousMode 设为 UserMode,缺页异常会被正常处理。
2.2.5 完整流程图:从用户态到 Nt* 调用
用户态 内核态
┌──────────────────┐ ┌──────────────────────────────────┐
│ application.exe │ │ _KiSystemService / _KiFastCall │
│ call NtCreateFile│ │ (trap.s:142/149) │
│ │ │ │ │ │
│ ↓ │ │ ↓ │
│ NtCreateFile stub│ │ KiEnterTrap 宏 │
│ (ntdll.dll) │ │ (asmmacro.S:77) │
│ mov eax, Id │ │ → 构建 KTRAP_FRAME │
│ mov edx, &Args │ │ │ │
│ call [7FFE0300] │ ─── sysenter/INT 2Eh ────────→ │ ↓ │
│ ret 2Ch │ │ KiCallHandler 宏 │
│ │ │ │ call @KiSystemServiceHandler@8 │
│ ↓ │ │ │ │
│ (用户态参数栈) │ │ ↓ │
│ [esp]: PHANDLE │ │ KiSystemServiceHandler │
│ [esp+4]: ACCESS_ │ │ (traphdlr.c:1720) │
│ ... │ │ ① 12 步处理(前述) │
│ 11 个参数, 44字节│ │ │ │
│ │ │ ↓ │
│ │ │ KiSystemCallTrampoline │
│ │ │ (trap.s:230) │
│ │ │ rep movsd (用户栈→内核栈) │
│ │ │ │ │
│ │ │ ↓ │
│ │ │ call NtCreateFile (ntoskrnl 内)│
│ │ │ ... 内部走 IoCreateFile ... │
│ │ │ │ │
│ │ │ ↓ │
│ │ │ Nt* 返回 → Status in EAX │
│ │ │ │ │
│ │ │ ↓ │
│ │ │ KiServiceExit │
│ │ ←─── sysexit / iretd ────────── │ (traphdlr.c:150) │
│ (EAX = Status) │ │ │
│ (用户态继续执行) │ │ │
└──────────────────┘ └──────────────────────────────────┘
2.2.6 小结
_KiSystemService与_KiFastCallEntry是两个汇编入口,区别仅在KiEnterTrap宏的标志位与对 TrapFrame 的构造方式。KiEnterTrap宏接收Flags参数,根据KI_FAST_SYSTEM_CALL/KI_PUSH_FAKE_ERROR_CODE等位决定压栈内容。KiSystemServiceHandler是 C 层统一入口,包含 12 步关键处理:取线程、PreviousMode 处理、SSDT 解码、边界检查、Probe、调用 trampoline。KiSystemCallTrampoline是一次性"参数复制 + 函数调用"的 trampoline,使用rep movsd把用户栈参数复制到内核栈。
2.2.7 为什么要这样做
(1) 为什么需要 KTRAP_FRAME------为什么不直接保存少数寄存器?
当用户态触发系统调用时,CPU 会自动 切换到内核栈并保存 SS/ESP/EFLAGS/CS/EIP。但这远远不够 。Nt* 实现是一个普通的 C 函数------它会"随意使用" eax/ebx/ecx/edx/esi/edi/ebp 等寄存器。如果内核不保存这些寄存器的值,从 Nt* 返回用户态时寄存器的值就会被污染,用户程序会崩溃。
这背后有一个更根本的原则:内核作为"被调用者",不能破坏"调用者"(用户态)的寄存器上下文 。无论是系统调用、异常还是中断,内核必须保证"返回时用户态的寄存器与进入内核时完全一致"(除了明确约定要修改的寄存器,如 eax 用于返回值)。
所以 ReactOS 的做法是:
- CPU 自动做的部分 :
SS/ESP/EFLAGS/CS/EIP(特权级切换时)。 - KiEnterTrap 手动做的部分:其余通用寄存器与段寄存器。
- 合起来就是一个完整的 KTRAP_FRAME。
你在 asmmacro.S:77(file:///d:/reactos/ntoskrnl/include/internal/i386/asmmacro.S#L77) 中看到的 KiEnterTrap 宏,就是对这个原则的精确实现。它不是"过度设计"------少保存一个寄存器,就意味着内核会"偷走"用户态的那个寄存器值。
(2) 为什么要分"汇编层"和"C 层"------而不是全汇编或全 C?
理论上你可以用纯汇编实现整个系统调用流程,也可以尝试用 C 实现一切。但 ReactOS 的做法------汇编层做最少量的差异化工作,C 层做统一处理------是工业级内核的标准做法。原因:
为什么汇编层必不可少:
- 栈切换与寄存器保存只能用汇编:C 语言没有"压栈 SS"、"从 TSS 加载 ESP"这样的原语。
- CPU 指令层面的行为必须精确控制 :
sysenter指令的后续几条指令必须在寄存器被污染之前完成 TrapFrame 构造,C 编译器无法保证这种时序。 KiCallHandler这个宏(asmmacro.S:253(file:///d:/reactos/ntoskrnl/include/internal/i386/asmmacro.S#L253))负责把TrapFrame的地址作为第一个参数、edx(参数指针)作为第二个参数压栈,然后调用 C 函数------这是 C 编译器无法自动完成的"参数手动编排"。
为什么 C 层必不可少:
- 12 步处理逻辑(解码系统调用号、边界检查、GUI 线程转换、Probe、调用 trampoline 等)用汇编写的话会有几百行、极易出错。
- C 函数可以直接调用其他 C 函数 :
KiSystemServiceHandler内部调用KiConvertToGuiThread、KiDbgPreServiceHook等 C 函数是自然的;如果这些都用汇编,就需要手动维护调用约定。 - 可移植性 :未来 x64 版本只需替换汇编层(
_KiSystemService→KiSystemCall64),C 层逻辑可以基本复用。
一句话:汇编层处理"CPU 如何切换",C 层处理"内核做什么"。这是所有现代操作系统内核(Linux、FreeBSD、NT)共同的分层方式。
(3) 为什么 PreviousMode 要先保存到 TrapFrame 再恢复------而不是直接用一个全局变量?
Thread->PreviousMode 是"当前线程代表谁在执行"的标志。在系统调用入口处:
- 先把旧值保存到
TrapFrame->PreviousPreviousMode。 - 再设置新值为
UserMode(或KernelMode,取决于来源)。 - 在
KiServiceExit中恢复旧值。
为什么不直接用一个"栈上变量"保存旧值? 因为 PreviousMode 会被嵌套调用的其他内核代码读取------它不能只存在于某个函数的局部栈上。例如:
- Nt* 实现内部会检查
KeGetPreviousMode()来决定是否 probe 参数。 - 如果 Nt* 内部又调用了
Zw*(触发嵌套系统调用),嵌套的系统调用会再次读写PreviousMode。 - 如果 PreviousMode 的旧值没有"链"到 TrapFrame 上,最内层的返回就会恢复错误的值。
ReactOS 的方案是把 PreviousMode 的旧值链到 TrapFrame 上 ------每个嵌套调用都压一个新链。这让 PreviousMode 的恢复与 TrapFrame 的"入栈→出栈"天然同步。你在 traphdlr.c:1737(file:///d:/reactos/ntoskrnl/ke/i386/traphdlr.c#L1737) 中可以看到:
c
TrapFrame->PreviousPreviousMode = Thread->PreviousMode;
Thread->PreviousMode = KiUserTrap(TrapFrame);
然后在出口处(traphdlr.c:1849(file:///d:/reactos/ntoskrnl/ke/i386/traphdlr.c#L1849)):
c
Thread->TrapFrame = (PKTRAP_FRAME)TrapFrame->Edx; // 恢复链
KiServiceExit(TrapFrame, Status); // 在里面恢复 PreviousMode
这是一个精巧的"调用链"设计 ------不仅 PreviousMode,SEH 链(TrapFrame->ExceptionList)、调试寄存器(TrapFrame->Dr7)也都用同样的"保存到 TrapFrame"方式处理。核心思想是:TrapFrame 不仅是"寄存器快照",更是"内核嵌套调用的状态链"。
(4) 为什么 KiSystemCallTrampoline 要做参数复制------而不是让 Nt* 直接读用户栈?
KiSystemCallTrampoline 做的事很简单:用 rep movsd 把用户态的参数复制到内核栈上,然后调用真正的 Nt* 实现。为什么要多这一步拷贝? 有三个原因:
- C 调用约定要求参数在栈上 :Nt* 是普通的 C 函数,它期望
[esp+4]是第一个参数、[esp+8]是第二个参数......如果不把参数复制到内核栈,Nt* 就无法按 C 约定读取。 - 用户栈不可信:用户态可能在系统调用过程中修改参数(TOCTOU 攻击------Time Of Check, Time Of Use)。把参数"捕获"到内核栈后,Nt* 实现读到的是调用时的快照,不受用户态后续修改影响。
- 缺页容错 :
rep movsd期间如果用户栈被换出,会触发缺页异常。这是被允许的------内核会把页面换回并继续执行。但如果 Nt* 实现直接读用户栈(在 PreviousMode=UserMode 时也可以),就需要用ProbeForRead先"试读"一次来检查有效性。Trampoline 的拷贝相当于"一次性完成 probe + 捕获"。
注意:对内核态调用方(Zw* 路径) ,参数本来就在内核栈上(由驱动 push)。此时 Arguments 指向的是内核栈,rep movsd 仍然执行,但"用户栈 → 内核栈"变成了"内核栈 → 内核栈"------这没问题,只是一次冗余拷贝,逻辑上与用户态路径完全一致。这种"统一路径,不区分来源"的设计是 NT 内核的一贯风格。
2.3 系统调用的函数跳转
2.3.0 框架图(先见森林)
┌──────────────────────────────────────────────────────────────────────┐
│ 系统调用号 (SysCallId) 32-bit │
│ │
│ ┌──────────┬─────────────────┐ │
│ │ Offset │ Id │ │
│ │ (高 4 位)│ (低 12 位) │ │
│ └────┬─────┴────────┬────────┘ │
│ │ │ │
│ ↓ ↓ │
│ KeServiceDescriptorTable[Offset] ←── 描述符选择 │
│ ┌─────────────────────────────┐ │
│ │ Limit (SSDT 项数) │ │
│ │ Base[] (函数指针数组) │ │
│ │ Number[] (栈字节数数组) │ │
│ │ Count[] (调用计数,DBG 用) │ │
│ └─────────────────────────────┘ │
│ │ │
│ ↓ Base[Id] │
│ Nt* 真正实现(ntoskrnl 内) │
│ 例:NtCreateFile → IoCreateFile → I/O 管理器派发 IRP │
└──────────────────────────────────────────────────────────────────────┘
配套细化图(SSDT 在内存中的物理布局):
KeServiceDescriptorTable (数组 4 项)
┌────────────────┐ ┌────────────────┐
│[0] Kernel/Ntdll│ │[1] GUI/Win32k │
│ Base ──────┐ │ │ Base ──────┐ │
│ Limit │ │ │ Limit │ │
│ Number │ │ │ Number │ │
│ Count │ │ │ Count │ │
└────────────┼───┘ └────────────┼───┘
↓ ↓
Base[0] NtAcceptConnectPort Base[0] NtUserCreateWindow
Base[1] NtAccessCheck Base[1] NtUserGetMessage
Base[2] NtAccessCheckAndAud.. Base[2] ...
...
Base[N] NtQueryObject Base[M] ...
本图核心要点 :系统调用号被拆为"表选择"与"表内偏移"两部分。每个表项是一个 KSERVICE_TABLE_DESCRIPTOR,通过数组下标就能直接定位到 Nt* 实现。
2.3.1 系统调用号
ReactOS 把系统调用号保存在一个文本文件 ntoskrnl/sysfuncs.lst 中:
text
NtAcceptConnectPort 6
NtAccessCheck 8
NtAccessCheckAndAuditAlarm 11
...
NtClose 1
...
NtCreateFile 11
...
该文件由构建系统使用 ,在编译时生成对应的汇编 stub。sysfuncs.lst 中的每一行是 <函数名> <参数字节数>:
NtAcceptConnectPort 6→NtAcceptConnectPort有 6 个参数(24 字节栈)。NtClose 1→NtClose有 1 个参数(4 字节)。NtCreateFile 11→NtCreateFile有 11 个参数(44 字节)。
sysfuncs.lst 的列表顺序就是系统调用号------从 0 开始计数。ReactOS 默认 0-based。例如:
NtAcceptConnectPort→ 0x000NtAccessCheck→ 0x001- ...
NtCreateFile→ 0x01E(30)- ...
系统调用号的 32 位 编码如下:
31 12 11 0
┌─────────────┬─────────┐
│ Offset │ Id │
│ (高 20 位) │ (低 12)│
└─────────────┴─────────┘
- 低 12 位 Id:表内偏移,对应 0~4095 号调用。
- 高 20 位 Offset :经过
SERVICE_TABLE_SHIFT与SERVICE_TABLE_MASK处理后,决定查哪个表(Ke/User/GDI/Win32k)。常用位SERVICE_TABLE_TEST(0x1000)用于"切换到第二张表(GUI)"。
例如 0x1000 是从主表切换到 GUI 表的标志位。
2.3.2 SSDT(系统服务调度表)的结构
ReactOS 在 ntoskrnl/include/internal/ntoskrnl.h 中定义:
c
typedef struct _KSERVICE_TABLE_DESCRIPTOR
{
PULONG Base; /* 函数指针数组(按调用号顺序) */
PULONG Count; /* 每个函数的累计调用计数(DBG 才有) */
ULONG Limit; /* 函数指针数组的最大项数 */
PUCHAR Number; /* 每个函数需要的栈字节数 */
} KSERVICE_TABLE_DESCRIPTOR, *PKSERVICE_TABLE_DESCRIPTOR;
KeServiceDescriptorTable 是一个长度为 4 的 KSERVICE_TABLE_DESCRIPTOR 数组(在 ntoskrnl/ex/init.c 中定义):
c
/* ntoskrnl/ex/init.c 中 */
KSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTable[KSERVICE_TABLES_MAX];
KSERVICE_TABLE_DESCRIPTOR KeServiceDescriptorTableShadow[KSERVICE_TABLES_MAX];
KSERVICE_TABLES_MAX = 4(实际上 ReactOS 中也定义为 4)。4 张表的用途:
KeServiceDescriptorTable[0]:主表,包含绝大多数Nt*实现。KeServiceDescriptorTable[1]:第二表,GUI/Win32k 表,包含NtUserCreateWindow、NtUserGetMessage等 GUI 相关调用。KeServiceDescriptorTable[2]与[3]:保留,目前不使用。
KeServiceDescriptorTableShadow 是"影子表"------当线程切换为 GUI 线程时,会把 Thread->ServiceTable 指向 shadow 表,让 KiSystemServiceHandler 通过 ServiceTable + Offset 派发到 GUI 表。
初始化 :在 ntoskrnl/ex/init.c:1294-1310 行附近:
c
/* ntoskrnl/ex/init.c, 第 1294-1310 行(DBG 模式下) */
KeServiceDescriptorTable[0].Count =
ExAllocatePoolWithTag(NonPagedPool,
KiServiceLimit * sizeof(ULONG),
'llaC'); /* 'llaC' = 'Call'(小端存储)*/
/* Use it for the shadow table too */
KeServiceDescriptorTableShadow[0].Count = KeServiceDescriptorTable[0].Count;
if (KeServiceDescriptorTable[0].Count)
{
/* Zero the call counts to 0 */
RtlZeroMemory(KeServiceDescriptorTable[0].Count,
KiServiceLimit * sizeof(ULONG));
}
只有 DBG 模式才分配 Count 数组------用于统计每个系统调用被调用了多少次,是性能调优和 bug 调查的有力工具。
2.3.3 服务表查找流程
KiSystemServiceHandler 中的查找代码(traphdlr.c:1772-1795):
c
/* Decode the system call number */
Offset = (SystemCallNumber >> SERVICE_TABLE_SHIFT) & SERVICE_TABLE_MASK;
Id = SystemCallNumber & SERVICE_NUMBER_MASK;
/* Get descriptor table */
DescriptorTable = (PVOID)((ULONG_PTR)Thread->ServiceTable + Offset);
/* Validate the system call number */
if (__builtin_expect(Id >= DescriptorTable->Limit, 0))
{
/* Check if this is a GUI call */
if (!(Offset & SERVICE_TABLE_TEST))
{
/* Fail the call */
Status = STATUS_INVALID_SYSTEM_SERVICE;
goto ExitCall;
}
...
}
关键解读:
Thread->ServiceTable是当前线程的服务表基址。(ULONG_PTR)Thread->ServiceTable + Offset:将基址加上 Offset(实际是Offset * sizeof(KSERVICE_TABLE_DESCRIPTOR))得到当前使用的描述符。注意Offset经过SERVICE_TABLE_SHIFT与SERVICE_TABLE_MASK提取出来,本身已经包含了"表索引 × 描述符大小"的信息。Id是低 12 位,作为表内编号。- 调用的函数 =
DescriptorTable->Base[Id]。 - 如果
Id >= Limit,即"调用号超过本表大小"------说明这是非法调用或 GUI 调用。 - 如果
Offset含SERVICE_TABLE_TEST位(如 0x1000),则进入"转换为 GUI 线程"路径。
2.3.4 Nt* 与 Zw* stub 的生成
ReactOS 的 stub 是自动生成 的:同一份 ntoskrnl/include/sysfuncs.h(由 sysfuncs.lst 编译时生成)经过 sdk/include/asm/syscalls.inc 中的宏,既生成用户态 stub(STUB_U)也生成内核态 stub(STUB_K)。
syscalls.inc:55-75:
asm
/* sdk/include/asm/syscalls.inc, 第 55-75 行 */
#ifdef _M_IX86
#define KUSER_SHARED_SYSCALL HEX(7ffe0300)
#define KGDT_R0_CODE 8
MACRO(STUBCODE_U, Name, SyscallId, ArgCount)
StackBytes = 4 * ArgCount
FPO 0, 0, 0, 0, 0, FRAME_FPO
mov eax, SyscallId
mov edx, KUSER_SHARED_SYSCALL
call dword ptr [edx] ; call ntdll!KiFastSystemCall
ret StackBytes
ENDM
MACRO(STUBCODE_K, Name, SyscallId, ArgCount)
StackBytes = 4 * &ArgCount
FPO 0, 0, 0, 0, 0, FRAME_FPO
mov eax, SyscallId
lea edx, [esp + 4] ; 取内核栈上方的参数区地址
pushfd
push KGDT_R0_CODE ; 模拟用户态框架(CS=R0)
call _KiSystemService ; 走 INT 2Eh 路径
ret StackBytes
ENDM
用户态 stub(STUBCODE_U):
asm
NtCreateFile@44: ; @44 表示 11 个参数(44 字节)
mov eax, 0x01E ; SysCallId(NtCreateFile 是第 30 号)
mov edx, 0x7FFE0300 ; KUSER_SHARED_SYSCALL
call dword ptr [edx] ; call ntdll!KiFastSystemCall(用 sysenter 切到内核)
ret 44 ; 清理 11 个参数(44 字节)
用户态 stub 的特点:
edx指向KUSER_SHARED_DATA中的SystemCall字段(地址 0x7FFE0300)。[0x7FFE0300]处是ntdll!KiFastSystemCall的入口地址------这是一个函数指针,由 ntdll 在加载时填入。- 真正的 sysenter 指令就在
KiFastSystemCall内(不是 stub 本身发出)。 - stub 本身只做"准备参数 + 调用",不直接发 sysenter。
内核态 stub(STUBCODE_K):
asm
ZwCreateFile@44: ; @44 表示 11 个参数(44 字节)
mov eax, 0x01E ; SysCallId
lea edx, [esp + 4] ; 当前内核栈上方的参数区
pushfd ; 模拟"中断"框架
push KGDT_R0_CODE ; 压入一个伪 CS(这是关键!)
call _KiSystemService ; 走 INT 2Eh 路径
ret 44
内核态 stub 的关键设计点:
- lea edx, esp + 4 :与用户态 stub 的"edx 指向用户参数区"不同 ,内核态 stub 的
edx指向当前内核栈 的"参数区"(在 call ZwCreateFile 之后,[esp+4]之后就是第一个参数)。这是因为内核态调用者已经在内核栈上准备好了参数。 - pushfd + push KGDT_R0_CODE :这是最巧妙的设计 ------内核态 stub 手动模拟"用户态"框架 ,让
INT 2Eh路径认为这次系统调用来自"用户态"但 CS 是 R0。 - call _KiSystemService :直接走
INT 2Eh路径(而不是 sysenter,因为 sysenter 路径要求 CPU 硬件支持,且入口约定更复杂)。
为什么内核态 stub 要"再走一次 INT 2Eh"?因为 KiSystemServiceHandler 中的 Thread->PreviousMode = KiUserTrap(TrapFrame) 依赖 TrapFrame->SegCs 判断来源是用户态还是内核态。通过 push KGDT_R0_CODE 把 CS 设为 R0,KiSystemServiceHandler 会把 PreviousMode 设为 KernelMode,从而让 Nt* 实现跳过用户态 probe。这就是 2.6 节"从内核中发起系统调用"的核心机制。
2.3.5 完整函数跳转链
把用户态与内核态的调用链放在一起看:
用户态调用链:
CreateFileW (kernel32.dll)
→ CreateFileInternal (kernel32 内)
→ NtCreateFile (ntdll!Stub, 用户态)
mov eax, Id; mov edx, &Args; call [7FFE0300]
→ ntdll!KiFastSystemCall
sysenter
→ _KiFastCallEntry (ntoskrnl)
KiEnterTrap (KI_FAST_SYSTEM_CALL)
→ KiSystemServiceHandler (C 入口)
解码 Id → 查 SSDT[0] → 取 Handler
→ NtCreateFile (ntoskrnl 真正实现)
→ IopCreateFile (内部)
→ ObCreateObject (创建文件对象)
→ I/O 管理器 → IopParseDevice → 调用具体文件系统
→ 返回 NTSTATUS
→ KiServiceExit → sysexit
→ 回到 ntdll!KiFastSystemCall 之后
→ 回到 ntdll!NtCreateFile stub 之后
→ 回到 kernel32!CreateFileInternal
→ 回到 CreateFileW 调用方
内核态调用链:
fastfat!FatCommonWrite (驱动)
→ ZwCreateKey (内核态 stub, ex/zw.S)
mov eax, Id; lea edx, [esp+4]; pushfd; push KGDT_R0_CODE
call _KiSystemService
→ _KiSystemService (ntoskrnl)
KiEnterTrap (KI_PUSH_FAKE_ERROR_CODE | KI_NONVOLATILES_ONLY | KI_DONT_SAVE_SEGS)
→ KiSystemServiceHandler (C 入口)
解码 Id → 查 SSDT[0] → 取 Handler
CS=R0 → KiUserTrap=false → PreviousMode=KernelMode → 跳过 probe
→ NtCreateKey (ntoskrnl 真正实现)
→ CmCreateKey (内部)
→ 注册表 hive 写入
→ 返回 NTSTATUS
→ KiServiceExit
→ 回到 fastfat!FatCommonWrite 继续执行
可以看到两条调用链的中间段(KiSystemServiceHandler → Nt* 实现)完全相同;区别仅在入口是 sysenter 还是 INT 2Eh,以及 PreviousMode 的设置。
2.3.6 小结
- 系统调用号 来自
sysfuncs.lst,是一个 0-based 编号。 - SSDT 是
KSERVICE_TABLE_DESCRIPTOR数组,每项含Base/Count/Limit/Number。 Thread->ServiceTable指向当前线程的描述符;通过(ServiceTable + Offset)选表,Base[Id]选函数。- 用户态 stub 与 内核态 stub 由同一份宏定义生成,但内核态 stub 多做了一次"伪用户态框架"压栈,目的是让
KiSystemServiceHandler能识别出"这是内核态调用方",从而把 PreviousMode 设为 KernelMode。
2.3.7 为什么要这样做
(1) 为什么需要 SSDT------为什么不是一个巨大的 switch-case?
SSDT(系统服务调度表)的本质是一个函数指针数组 。系统调用号作为数组下标,查表就能拿到 Nt* 实现的地址。为什么不直接写一个 switch (SysCallNumber) { case 0: NtAcceptConnectPort(...); case 1: NtAccessCheck(...); ... } 呢?
原因有三条------都与工业级代码的维护和性能有关:
-
性能 :函数指针数组是 O(1) 查表,编译为一条
mov eax, [ebx + ecx*4]指令。如果是switch-case,编译器在case数量较大时会生成跳转表(也是数组),性能差不多------但关键在于不依赖编译器优化。SSDT 是一个显式的、手工控制的数据结构,性能完全可预测。 -
可扩展性------第二张表 :
KeServiceDescriptorTableShadow是 Win32k.sys 的服务表。GUI 线程需要访问 Win32k 提供的 API(窗口管理、GDI 绘图等)。如果只有一张主表,Win32k 就不得不与 NT 执行体"共享编号空间",导致编号冲突。通过Thread->ServiceTable + Offset的方式,第二张表可以独立于主表 ,不需要修改KiSystemServiceHandler的任何代码------只要在启动时填好描述符即可。这是"开放/封闭原则"的经典应用:对扩展开放,对修改封闭。 -
可维护性 :
sysfuncs.lst是一个文本文件,每一行定义一个系统调用的名字和参数数量。构建系统自动从它生成:(a) 系统调用号常量、(b) 用户态 stub、© 内核态 stub、(d) SSDT 的Base数组。开发者只需要在sysfuncs.lst中加一行,就能完成新系统调用的"接线"。如果用 switch-case,每个新调用都要改三个地方(switch 块、用户态 stub 生成、内核态 stub 生成),极易出错。
你可以在 sysfuncs.lst(file:///d:/reactos/ntoskrnl/sysfuncs.lst) 中看到这份"系统调用清单"的真实内容。它看起来像一个普通的配置文件,但它实际上定义了整个用户态/内核态的接口契约。
(2) 为什么系统调用号要拆成 Offset + Id------而不是一张扁平的线性表?
系统调用号 32-bit,被拆为高若干位(Offset)和低 12 位(Id)。这个设计直接服务于上一条("第二张表")。思路是:
- 低 12 位 Id:表内偏移,最多 4096 个函数。一个表 4096 个函数------对单一执行体(NT 执行体或 Win32k)来说足够了。
- 高位 Offset :表选择,值为
0 * sizeof(KSERVICE_TABLE_DESCRIPTOR)、1 * sizeof(...)、2 * sizeof(...)......直接加到Thread->ServiceTable指针上就得到当前表的描述符地址。
这样做的好处是:切换服务表只需要改 Offset 位 ,不需要做任何分支判断。KiSystemServiceHandler 中(traphdlr.c:1783(file:///d:/reactos/ntoskrnl/ke/i386/traphdlr.c#L1783)):
c
Offset = (SystemCallNumber >> SERVICE_TABLE_SHIFT) & SERVICE_TABLE_MASK;
Id = SystemCallNumber & SERVICE_NUMBER_MASK;
DescriptorTable = (PVOID)((ULONG_PTR)Thread->ServiceTable + Offset);
三条语句------一次移位、一次掩码、一次指针加法------就完成了"解码 → 查表 → 取描述符"。非常干净。
对比一下"扁平线性编号"方案的代价:如果所有服务统一编号为 0~N,GUI 线程访问 Win32k 服务时就得判断 if (Id >= NT_SERVICE_COUNT) Handler = Win32kBase[Id - NT_SERVICE_COUNT],多一次比较和分支。在现代 CPU 上这可能不是大事,但 NT 内核的设计哲学是:能在数据结构层面解决的问题,绝不引入额外的控制流。
(3) 为什么 Limit、Number 与 Base 都要保存在一起?
KSERVICE_TABLE_DESCRIPTOR 的结构(ntoskrnl.h(file:///d:/reactos/ntoskrnl/include/internal/ntoskrnl.h))是:
c
typedef struct _KSERVICE_TABLE_DESCRIPTOR {
PULONG Base; // 函数指针数组
PULONG Count; // 每个函数被调用的次数(仅 DBG 版)
ULONG Limit; // 数组大小
PUCHAR Number; // 每个函数的参数字节数
} KSERVICE_TABLE_DESCRIPTOR;
四个字段放在一起不是偶然:
| 字段 | 作用 | 为什么需要 |
|---|---|---|
Base |
函数指针数组 | 真正"跳转"的目标地址 |
Limit |
数组大小 | 边界检查------非法系统调用号会触发 STATUS_INVALID_SYSTEM_SERVICE |
Number |
每个函数的参数字节数 | KiSystemCallTrampoline 需要知道 rep movsd 要复制多少字节;每个系统调用的参数数量不同 |
Count |
调用计数 | DBG 版用于性能分析;Release 版通常为 NULL |
Number 数组尤其容易被忽略------它其实是系统调用契约的元数据 。用户态 stub 在调用时会 ret StackBytes(清理自己栈上的参数),内核态 trampoline 也会用同样的 StackBytes 复制参数。StackBytes 就来自 Number[Id]。如果少了 Number 数组,trampoline 就不知道每个系统调用到底有多少参数------要么全按最大参数复制(浪费),要么每个系统调用都硬编码参数数量(维护噩梦)。
(4) 为什么用户态 stub 和内核态 stub 要由同一个宏生成?
看 syscalls.inc(file:///d:/reactos/sdk/include/asm/syscalls.inc) 中的宏定义:
asm
MACRO(STUBCODE_U, Name, SyscallId, ArgCount)
mov eax, SyscallId
mov edx, KUSER_SHARED_SYSCALL
call dword ptr [edx]
ret 4 * ArgCount
ENDM
MACRO(STUBCODE_K, Name, SyscallId, ArgCount)
mov eax, SyscallId
lea edx, [esp + 4]
pushfd
push KGDT_R0_CODE
call _KiSystemService
ret 4 * ArgCount
ENDM
两个宏几乎一模一样 ------除了用户态走 call [KUSER_SHARED_SYSCALL](sysenter 路径),内核态走 call _KiSystemService(INT 2Eh 路径)。这种"宏驱动的 stub 生成"带来两个关键好处:
-
保证两条路径的系统调用号一致 :如果
NtCreateFile在用户态的 stub 中mov eax, 30,在内核态的ZwCreateFilestub 中也必然是mov eax, 30。不可能出现"两个 stub 使用了不同的编号"的情况。 -
保证参数清理一致 :两条路径的
ret 4 * ArgCount都清理同样多的参数,这意味着无论调用方是用户态程序还是内核态驱动,函数签名(参数数量和布局)永远一致。
这是一种"以宏定义契约"的模式:把容易出错的重复操作交给预处理器,而不是相信开发者会手工维护两套 stub 的一致性。在 NT 内核这种"有几百个系统调用"的系统中,手工维护一致性是不可接受的。
2.4 系统调用的返回
2.4.0 框架图(先见森林)
┌────────────────────────────────────────────────────────────────────┐
│ Nt* 实现返回 NTSTATUS Status │
│ ↓ │
│ KiServiceExit(TrapFrame, Status) │
│ ① TrapFrame->Eax = Status │
│ ② KiCommonExit(TrapFrame, FALSE) │
│ └─ APC 投递 / Dr7 恢复 / Eip 重定向检查 │
│ ③ Thread->PreviousMode = TrapFrame->PreviousPreviousMode │
│ ↓ │
│ ④ KiUserTrap(TrapFrame) ? │
│ │ │
│ ┌──┴──┐ │
│ 是 否 │
│ │ │ │
│ EFLAGS_TF? │ │
│ ┌──┴──┐ │ │
│ 是 否 │ │
│ │ │ │ │
│ iret sysexit│ │
│ │ │ │ │
│ └──┬──┘ │ │
│ 用户态 │ │
│ 返回 │ │
│ ↓ │
│ KiSystemCallReturn │
│ (KiTrapReturnNoSegmentsRet8) │
│ → iret 回内核态 │
│ ↓ │
│ Nt* 调用方继续执行 │
└────────────────────────────────────────────────────────────────────┘
配套细化图(出口的"三选一"决策表):
出口类型 (KiTrapExitStub):
┌──────────────────┬──────────────────┬────────────────────┐
│ KiSystemCall │ KiSystemCall │ KiSystemCall │
│ SysExitReturn │ TrapReturn │ Return │
│ (sysexit) │ (iretd) │ (jmp iret stub) │
│ 用户态/无单步 │ 用户态/单步 │ 内核态/通用 │
└──────────────────┴──────────────────┴────────────────────┘
本图核心要点 :系统调用返回有"三选一"的决策树------根据 PreviousMode 和 EFLAGS_TF 标志选择不同的出口宏。最常见的快速出口是 sysexit 路径。
2.4.1 KiServiceExit 总流程
KiServiceExit 是 C 层出口,位于 traphdlr.c:147-184:
c
/* ntoskrnl/ke/i386/traphdlr.c, 第 147-184 行 */
DECLSPEC_NORETURN
VOID
FASTCALL
KiServiceExit(IN PKTRAP_FRAME TrapFrame,
IN NTSTATUS Status)
{
ASSERT((TrapFrame->EFlags & EFLAGS_V86_MASK) == 0);
ASSERT(!KiIsFrameEdited(TrapFrame));
/* Copy the status into EAX */
TrapFrame->Eax = Status;
/* Common trap exit code */
KiCommonExit(TrapFrame, FALSE);
/* Restore previous mode */
KeGetCurrentThread()->PreviousMode = (CCHAR)TrapFrame->PreviousPreviousMode;
/* Check for user mode exit */
if (KiUserTrap(TrapFrame))
{
/* Check if we were single stepping */
if (TrapFrame->EFlags & EFLAGS_TF)
{
/* Must use the IRET handler */
KiSystemCallTrapReturn(TrapFrame);
}
else
{
/* We can use the sysexit handler */
KiFastCallExitHandler(TrapFrame);
UNREACHABLE;
}
}
/* Exit to kernel mode */
KiSystemCallReturn(TrapFrame);
}
KiServiceExit 完成 6 件事:
- 写返回码到 EAX :
TrapFrame->Eax = Status;。这是系统调用"返回 NTSTATUS"的最后一步------TrapFrame 即将被 iret/sysexit 恢复,其中 EAX 会被恢复到用户态的 eax 寄存器。 - 公共出口 :
KiCommonExit(TrapFrame, FALSE);。这个函数是所有 trap 出口共享的"清理"步骤(详见 2.4.3)。 - 恢复 PreviousMode :
KeGetCurrentThread()->PreviousMode = (CCHAR)TrapFrame->PreviousPreviousMode;。把线程的 PreviousMode 恢复到进入系统调用前的状态。如果本次系统调用是用户态发起的,进入前 PreviousMode=UserMode(用户态主流程),恢复后还是 UserMode。 - 判断用户态/内核态出口 :
KiUserTrap(TrapFrame)返回 true 表示 TrapFrame 来自用户态(CS 是 R3)。 - 用户态出口 :
- 如果 EFLAGS_TF(单步标志)打开,调用
KiSystemCallTrapReturn(TrapFrame),使用iretd返回(必须通过 iret 处理单步)。 - 否则调用
KiFastCallExitHandler(TrapFrame),使用sysexit快速返回。
- 如果 EFLAGS_TF(单步标志)打开,调用
- 内核态出口 :调用
KiSystemCallReturn(TrapFrame),使用标准的 trap return。
2.4.2 sysexit 与 iretd 路径
trap.s:172-178 中用 KiTrapExitStub 宏生成 5 个出口 stub:
asm
/* ntoskrnl/ke/i386/trap.s, 第 172-178 行 */
KiTrapExitStub KiSystemCallReturn, (KI_RESTORE_EAX OR KI_RESTORE_EFLAGS OR KI_EXIT_JMP)
KiTrapExitStub KiSystemCallSysExitReturn, (KI_RESTORE_EAX OR KI_RESTORE_FS OR KI_RESTORE_EFLAGS OR KI_EXIT_SYSCALL)
KiTrapExitStub KiSystemCallTrapReturn, (KI_RESTORE_EAX OR KI_RESTORE_FS OR KI_EXIT_IRET)
KiTrapExitStub KiEditedTrapReturn, (KI_RESTORE_VOLATILES OR KI_RESTORE_EFLAGS OR KI_EDITED_FRAME OR KI_EXIT_RET)
KiTrapExitStub KiTrapReturn, (KI_RESTORE_VOLATILES OR KI_RESTORE_SEGMENTS OR KI_EXIT_IRET)
KiTrapExitStub KiTrapReturnNoSegments, (KI_RESTORE_VOLATILES OR KI_EXIT_IRET)
KiTrapExitStub KiTrapReturnNoSegmentsRet8,(KI_RESTORE_VOLATILES OR KI_RESTORE_EFLAGS OR KI_EXIT_RET8)
每个 stub 由 KiTrapExitStub 宏生成,对应不同的"出口行为":
| Stub 名称 | 行为 | 何时使用 |
|---|---|---|
KiSystemCallReturn |
iret 通用 trap return |
内核态出口 / 任何 |
KiSystemCallSysExitReturn |
sysexit 快速返回 |
用户态出口,无单步 |
KiSystemCallTrapReturn |
iretd 返回(带段寄存器恢复) |
用户态出口,有单步 |
KiEditedTrapReturn |
编辑过的 TrapFrame 的退出 | SEH 修改了 EIP 时 |
KiTrapReturn |
完整 iret 恢复(带 volatile 寄存器) |
异常处理 |
KiTrapReturnNoSegments |
iret 不恢复段寄存器 |
异常处理 |
KiTrapReturnNoSegmentsRet8 |
iret + 清理 8 字节 |
内核态出口 |
最常用的是 KiSystemCallSysExitReturn ------它使用 sysexit 指令返回用户态,是 sysenter 的对称指令。
sysexit 的寄存器约定:
ECX:用户态 EIP(CPU 自动设置到 CS:EIP 中的 EIP)EDX:用户态 ESP(CPU 自动设置到 SS:ESP 中的 ESP)- 隐含设置 CS=KGDT_R3_CODE、SS=KGDT_R3_DATA。
因此,sysexit 出口代码会做:
asm
; ecx ← TrapFrame->Eip (用户态返回地址)
; edx ← TrapFrame->HardwareEsp (用户态栈指针)
sysexit
注意 TrapFrame->Eip 在 sysenter 入口处被设置为 [KUSER_SHARED_SYSCALL_RET],即 ntdll!KiFastSystemCallReturn 之后的地址;TrapFrame->HardwareEsp 是用户态的栈顶。这就是为什么 sysenter/sysexit 能精确跳回用户态 stub 之后继续执行。
2.4.3 KiCommonExit 详解
KiCommonExit 是 trap 出口的"公共代码",处理以下几件事:
- APC 投递 :如果
Thread->ApcState.UserApcPending且 PreviousMode=UserMode,把用户态 APC 投递出去(即修改 TrapFrame 的 Eip 为ntdll!KiUserApcDispatcher,让返回用户态时进入 APC 派发)。这是 Windows"异步过程调用"机制的入口。 - 调试寄存器恢复 :
TrapFrame->Dr7 = 0;。清空 DR7 调试控制寄存器。 - EFlags 调整:恢复用户态的 EFlags(如 IOPL 标志等)。
- 回调返回检查 :如果当前是从
KeUserModeCallback返回(Thread->CallbackStack非空),修改 Eip 为KeUserCallbackDispatcher(ntdll 中),让用户态继续处理回调。
KiCommonExit 实际上是一个内联的汇编宏(在 asmmacro.S 中),与 C 语言的 KiServiceExit 紧密配合。
2.4.4 nt!KiSystemServiceHandler 的退出片段
回到 KiSystemServiceHandler 自身的退出(traphdlr.c:1842-1853):
c
/* Call post-service debug hook */
Status = KiDbgPostServiceHook(SystemCallNumber, Status);
/* Make sure we're exiting correctly */
KiExitSystemCallDebugChecks(Id, TrapFrame);
/* Restore the old trap frame */
ExitCall:
Thread->TrapFrame = (PKTRAP_FRAME)TrapFrame->Edx;
/* Exit from system call */
KiServiceExit(TrapFrame, Status);
注意两个细节:
(1) Thread->TrapFrame = (PKTRAP_FRAME)TrapFrame->Edx;
这一行在 KiSystemServiceHandler 入口处被 TrapFrame->Edx = Thread->TrapFrame; 设置为"旧的 TrapFrame"。在出口处恢复。这是一个轻量级的"TrapFrame 链"------保留调用方(可能是另一个 trap frame)的 TrapFrame 指针。
(2) KiDbgPostServiceHook
这是一个调试钩子------DBG 模式才存在。允许调试器在系统调用完成后做特殊处理(如统计调用、检测异常参数等)。生产模式(Release)下是 no-op。
2.4.5 NtCallbackReturn 与 KiCallbackReturn 简述
NtCallbackReturn 是系统调用中"反向"的一个:当内核通过 KeUserModeCallback 调用用户态函数时,用户态函数最终通过 NtCallbackReturn 返回。
NtCallbackReturn 的实现路径不走 KiSystemServiceHandler,而是走 INT 0x2B 中断(在 IDT 中注册为 KiCallbackReturn):
c
/* ntoskrnl/ke/i386/traphdlr.c, 第 1639-1663 行 */
VOID
FASTCALL
KiCallbackReturnHandler(IN PKTRAP_FRAME TrapFrame)
{
PKTHREAD Thread;
NTSTATUS Status;
/* Save the SEH chain, NtCallbackReturn will restore this */
TrapFrame->ExceptionList = KeGetPcr()->NtTib.ExceptionList;
/* Set thread fields */
Thread = KeGetCurrentThread();
Thread->TrapFrame = TrapFrame;
Thread->PreviousMode = KiUserTrap(TrapFrame);
ASSERT(Thread->PreviousMode != KernelMode);
/* Pass the register parameters to NtCallbackReturn.
Result pointer is in ecx, result length in edx, status in eax */
Status = NtCallbackReturn((PVOID)TrapFrame->Ecx,
TrapFrame->Edx,
TrapFrame->Eax);
/* If we got here, something went wrong. Return an error to the caller */
KiServiceExit(TrapFrame, Status);
}
工作机制:
- 用户态通过
INT 0x2B通知内核"回调结束"。 - 寄存器约定:
EAX= NTSTATUS,ECX= 结果指针,EDX= 结果长度。 KiCallbackReturnHandler把这些参数透传给NtCallbackReturn(这是一个正常实现的 Nt* 函数,存在于 ntoskrnl 中)。NtCallbackReturn恢复被KeUserModeCallback备份的栈、设置返回值,并最终通过KiServiceExit返回。
完整的"用户态回调"机制将在 2.6 节中进一步展开。
2.4.6 小结
KiServiceExit是 C 层出口,把 NTSTATUS 写到 EAX、恢复 PreviousMode、调用KiCommonExit、选择出口。- 三种出口:sysexit(快)、iretd(带单步)、通用 trap return(内核态)。
KiCommonExit处理 APC 投递、调试寄存器恢复、EFlags 调整、回调返回等"公共清理"。KiCallbackReturnHandler是 INT 0x2B 的处理函数,是"反向"系统调用的入口。
2.4.7 为什么要这样做
(1) 为什么返回路径要"三选一"------而不是一个统一出口?
系统调用返回时有三条可能的路径:
| 路径 | 条件 | 指令 | 速度 |
|---|---|---|---|
sysexit |
用户态出口 + 无单步 | sysexit |
最快 |
iretd |
用户态出口 + 有单步(EFLAGS_TF=1) |
iretd |
中等 |
| 通用 trap return | 内核态出口 | 恢复寄存器 + iret |
最慢 |
为什么不统一用 iretd? 因为 iretd 会完整地从栈上加载 EIP/CS/EFLAGS/ESP/SS 五个字段,同时检查 CPL、IOPL、VM 等标志位------对高频调用来说开销太大。sysexit 则只做两件事:
- 从
ECX加载用户态 EIP,从EDX加载用户态 ESP。 - 把 CS/SS 设置为固定的用户态段选择子。
为什么不统一用 sysexit? 因为在以下情况下 sysexit 不适用:
- 内核态返回内核态 :
sysexit专门设计为"ring 0 → ring 3"的返回,返回 ring 0 时必须走正常 trap return。 - 有单步调试 :
sysexit不处理EFLAGS_TF(陷阱标志)。如果调试器设置了单步,必须走iretd------它会正确恢复 EFLAGS 中的 TF 位,从而在返回用户态后的第一条指令触发调试异常 #DB。 - 异常/中断路径 :异常和中断在进入内核时可能已经压入了错误码(ErrorCode),
iretd与 trap return 宏可以正确跳过错误码恢复现场,而sysexit没有这个概念。
所以 ReactOS 在 trap.s(file:///d:/reactos/ntoskrnl/ke/i386/trap.s) 中用 KiTrapExitStub 宏一次性生成了多个出口 stub------每条出口都是一个独立的汇编标签,KiServiceExit 根据条件跳到正确的标签。这是一种"把控制流选择集中到 C 层,把底层指令交给汇编"的经典分层设计。
(2) 为什么 PreviousMode 要在出口处恢复------而不是在入口处自动"撤销"?
PreviousMode 的设置/恢复模式是:
入口:保存旧值 → 设置新值(UserMode 或 KernelMode)
出口:恢复旧值
为什么不设计成"函数返回时自动撤销"? 因为 PreviousMode 的生命周期不只是一个函数调用。Nt* 实现内部可能:
- 调用
Zw*(触发嵌套系统调用,进一步修改 PreviousMode)。 - 触发 APC(异步过程调用),APC 例程会运行在任意上下文。
- 触发缺页、调试异常等,这些异常处理例程也会读写 PreviousMode。
如果 PreviousMode 的恢复逻辑"内嵌"在 Nt* 函数的末尾(比如用 GCC 的 __attribute__((cleanup)) 之类的机制),嵌套调用就会破坏恢复顺序------内层的 Nt* 会把 PreviousMode 设回外层本该看到的值,而外层的 Nt* 实际上需要的是"用户态"语义。
ReactOS 选择的方案是:PreviousMode 的生命周期与 TrapFrame 的生命周期一致。每次穿越边界都压一个新的 TrapFrame,TrapFrame 中保存"进入边界前的 PreviousMode";返回时从 TrapFrame 中取出这个旧值恢复。这样:
- 嵌套调用时,内层 TrapFrame 保存的是"进入内层前的 PreviousMode",外层 TrapFrame 保存的是"进入外层前的 PreviousMode"------两者互不干扰。
- 返回时先恢复内层,再恢复外层,顺序自动正确。
这种"状态栈"模式在操作系统内核中非常常见:处理器的中断允许位、段寄存器、虚拟地址空间等"全局可修改状态"都应该以"进入时保存,返回时恢复"的方式管理。TrapFrame 就是这些状态的统一容器。
(3) 为什么 KiCommonExit 要处理 APC------为什么不在调度器里做?
KiCommonExit(在 asmmacro.S(file:///d:/reactos/ntoskrnl/include/internal/i386/asmmacro.S) 中作为内联汇编宏实现)负责在退出 trap 之前检查 APC(Async Procedure Call,异步过程调用)队列。如果有待处理的用户态 APC,就把用户态 EIP 重定向到 ntdll!KiUserApcDispatcher,让用户态继续执行 APC 例程。
为什么要在 trap exit 做这件事,而不是在 Nt* 实现末尾主动投递?
- 统一处理点 :系统调用、异常、中断三种路径最终都会走到
KiCommonExit。如果把 APC 投递放在每条路径的末尾,就需要维护三份代码。 - 语义正确 :APC 的语义是"在线程返回用户态之前,先执行一段用户态代码"。这意味着它必须在即将返回用户态的那个时刻触发------如果在 Nt* 内部就触发 APC,那么 APC 例程中的系统调用会导致嵌套 trap,PreviousMode 链会变得混乱。
- 与中断兼容 :时钟中断是周期性触发的。如果中断处理器检测到需要 APC,它可以把 APC 挂到线程上,然后由
KiCommonExit统一处理------中断处理例程本身不需要知道用户态回调的细节。
(4) 为什么要设计"反向"回调------为什么不直接在内核里做?
KeUserModeCallback 是"反向系统调用":内核调用用户态函数,用户态函数通过 NtCallbackReturn 触发 INT 0x2B 回到内核。
为什么要设计这种"来回穿越"的机制? 直接在内核里完成不好吗?
关键原因是兼容性与隔离:
- Win32 子系统需要调用用户态 DLL :某些 API(如窗口过程
WindowProc、钩子过程HookProc)本质上是用户态代码------应用程序注册了这些回调函数,Win32k.sys 需要在合适的时刻"回过来调用它们"。如果内核直接在内核栈上调用用户态函数,用户态代码就拥有了 ring 0 权限------这是安全灾难。 - 隔离保护 :
KeUserModeCallback做的事情是:切回用户态 → 让用户态执行回调 → 用户态再调用NtCallbackReturn回到内核。整个过程中,用户态代码始终运行在 ring 3,没有特权。内核"借给"用户态一个临时的执行窗口,但控制权最终回到内核。 - 与正常系统调用对称 :
NtCallbackReturn用INT 0x2B触发,由KiCallbackReturnHandler(traphdlr.c:1639(file:///d:/reactos/ntoskrnl/ke/i386/traphdlr.c#L1639))处理。它与INT 0x2E的正常系统调用在处理流程上高度相似------只减少了不必要的检查(如 Probe),因为回调方在内核层已经验证过了。
这种"内核调用用户态,用户态再回内核"的模式是 Windows GUI 架构的核心机制之一。理解了它,你才能理解为什么"消息循环"在内核与用户态之间反复切换。
2.5 快速系统调用
2.5.0 框架图(先见森林)
┌──────────────────────────────────────────────────────────────────────┐
│ sysenter/sysexit "快速通道" 寄存器协议 │
│ │
│ 用户态 → 内核态(sysenter 入口) │
│ ┌──────────────────────────────────────────────┐ │
│ │ EAX ← SysCallId(用户态 stub 准备) │ │
│ │ EDX ← &Args[0](用户态 stub 准备) │ │
│ │ ECX ← 用户态返回 EIP(sysenter 隐含保存) │ │
│ │ ESP ← TSS.Esp0(sysenter 隐含设置) │ │
│ │ CS:EIP ← MSR 设置的入口:_KiFastCallEntry │ │
│ │ SS:ESP ← MSR 设置的内核栈(TSS.Esp0) │ │
│ └──────────────────────────────────────────────┘ │
│ ↓ │
│ KiEnterTrap(KI_FAST_SYSTEM_CALL) │
│ → 手动压栈构造伪 TrapFrame: │
│ SS=KGDT_R3_DATA | RPL_MASK │
│ ESP=edx (用户参数指针) │
│ EFLAGS │
│ CS=KGDT_R3_CODE | RPL_MASK │
│ EIP=[KUSER_SHARED_SYSCALL_RET] │
│ ↓ │
│ KiSystemServiceHandler(TrapFrame, Arguments) │
│ ...(与 INT 2Eh 路径完全一致) │
│ ↓ │
│ Nt* 实现执行 │
│ ↓ │
│ KiServiceExit → sysexit 出口 │
│ ECX = 用户态 EIP(恢复自 TrapFrame.Eip) │
│ EDX = 用户态 ESP(恢复自 TrapFrame.HardwareEsp) │
└──────────────────────────────────────────────────────────────────────┘
配套对比图(INT 2Eh vs sysenter 路径差异):
┌────────────────────────┬────────────────────────┐
│ INT 2Eh 路径 │ sysenter 路径 │
├────────────────────────┼────────────────────────┤
│ CPU 自动压栈 │ CPU 不压栈 │
│ SS/ESP/EFLAGS/CS/EIP │ 必须手动构建 TrapFrame │
│ 入口 _KiSystemService │ 入口 _KiFastCallEntry │
│ KiEnterTrap(FAKE_EC|...) │ KiEnterTrap(FAST_SC|...) │
│ 公共 C 入口 │ 公共 C 入口 │
│ KiSystemServiceHandler │ KiSystemServiceHandler │
│ 公共 Nt* 实现 │ 公共 Nt* 实现 │
│ 出口: iretd/sysexit │ 出口: sysexit 优先 │
│ 速度: 较慢 │ 速度: 较快 │
└────────────────────────┴────────────────────────┘
本图核心要点 :sysenter 与 INT 2Eh 在 CPU 行为上有本质区别(一个不压栈、一个压栈),但汇合到同一个 C 入口------这是 ReactOS 设计的精妙之处。
2.5.1 为什么需要"快速"系统调用
x86 在引入 Pentium II 之前,系统调用只能通过 INT n 软件中断实现。每次 INT 2Eh 触发,CPU 会自动压栈 SS/ESP/EFLAGS/CS/EIP 五个寄存器(特权级切换时)。这看似不多,但对高频小调用(如 GetLastError、GetCurrentThreadId)来说开销过大------每秒钟调用数千次时,光压栈就要数万个 CPU 时钟周期。
为解决这个问题,Intel 在 Pentium II 处理器中引入了 sysenter 与 sysexit 指令。sysenter 不做自动压栈------CPU 只设置 CS:EIP 和 SS:ESP 到 MSR 中的预设值。这意味着内核必须手动构造 TrapFrame,但换来的是:每次系统调用节省约 20-30 个 CPU 时钟周期。
类似的快速系统调用指令还有:
- AMD 的
syscall/sysret:x64 主流方案。 - Intel 后续的
syscall/sysret:在 Itanium 之后被 Intel 采纳。 - x64 下的
syscall:当前 x64 系统调用的标准。
ReactOS 的 x86 实现默认走 sysenter(如果 CPU 支持);x64 实现走 syscall。
2.5.2 sysenter 寄存器约定
sysenter 指令的 CPU 行为:
| 寄存器/状态 | sysenter 设置后 |
|---|---|
| CS | IA32_SYSENTER_CS MSR 预设值(通常为 KGDT_R0_CODE) |
| EIP | IA32_SYSENTER_EIP MSR 预设值(设为 _KiFastCallEntry) |
| SS | IA32_SYSENTER_CS MSR + 8(通常是 KGDT_R0_DATA) |
| ESP | IA32_SYSENTER_ESP MSR 预设值(设为当前线程的 KernelStack) |
| EAX, EBX, ECX, EDX, ESI, EDI, EBP | 不变(需要内核手动保存) |
| EFLAGS | 不变(不保存用户态 EFLAGS) |
重要约束 :sysenter 不保留 EAX/EBX/ECX/EDX/ESI/EDI/EBP/EFLAGS------这些寄存器的值在进入内核后与进入前完全相同。但因为内核不能信任这些寄存器的值(用户态可能设了任意值),它们必须全部保存到 TrapFrame 中。
用户态 stub 责任:
EAX:放 SysCallIdEDX:放&Args[0](用户参数区地址)ECX:用户态返回 EIP(其实 sysenter 不自动保存 ECX,但 sysexit 出口需要它)
为什么 ECX 是用户态 EIP?
因为 sysexit 的寄存器约定要求 ECX = 用户态 EIP、EDX = 用户态 ESP。ReactOS 的 KiEnterTrap 宏在 KI_FAST_SYSTEM_CALL 分支中手动 把 ECX 保存到 TrapFrame->Eip 位置------这正是 sysenter 与 INT 2Eh 的关键差异:INT 2Eh 让 CPU 帮忙保存 EIP;sysenter 必须由我们手动"骗出"一个伪 EIP。
KiEnterTrap 的"伪 EIP 构造"代码(asmmacro.S:82-103):
asm
/* ntoskrnl/include/internal/i386/asmmacro.S, 第 82-103 行 */
if (Flags AND KI_FAST_SYSTEM_CALL)
FrameSize = KTRAP_FRAME_EIP
mov cx, KGDT_R0_PCR
mov fs, cx
mov ecx, fs:[KPCR_TSS]
mov esp, [ecx + KTSS_ESP0]
/* Set up a fake hardware trap frame */
push KGDT_R3_DATA or RPL_MASK ; SS
push edx ; ESP (用户参数指针!)
pushfd ; EFLAGS
push KGDT_R3_CODE or RPL_MASK ; CS
push dword ptr ds:[KUSER_SHARED_SYSCALL_RET] ; EIP ← ntdll!KiFastSystemCallReturn 后
注意 push edx 这一行------它把 edx(用户参数指针)压到栈上 HardwareEsp 的位置。这是因为 KiSystemServiceHandler 中会通过 TrapFrame->HardwareEsp 取出"用户参数指针"------而 HardwareEsp 字段在 TrapFrame 中就是 [TrapFrame + offset_of(HardwareEsp)] 这个位置。
2.5.3 KUSER_SHARED_DATA 与 KUSER_SHARED_SYSCALL
KUSER_SHARED_DATA 是 Windows 在 32 位下的一块"用户态/内核态共享"数据区,位于地址 0x7FFE0000(在用户态与内核态都映射到同一物理页)。它包含一些用户态可以读、内核可以写的信息(如系统时间、版本号、Tick Count 等),同时也包含"系统调用入口指针"。
关键的几个字段:
| 字段 | 地址 | 含义 |
|---|---|---|
KUSER_SHARED_SYSCALL |
0x7FFE0300 |
指向 ntdll!KiFastSystemCall(一个 32 位函数指针) |
KUSER_SHARED_SYSCALL_RET |
0x7FFE0304 |
指向 ntdll!KiFastSystemCallReturn 之后(即返回用户态后继续执行的地址) |
KUSER_SHARED_SYSCALL_PAD |
0x7FFE0308 |
对齐填充 |
为什么需要这个共享区?
因为 ntdll 加载到内存中的位置是不固定的(ASLR 之类),但用户态 stub 必须能调用 KiFastSystemCall。让所有 stub 通过 [0x7FFE0300] 这个固定地址间接调用,而不直接调用某个固定地址------这就是 Windows 的设计。
0x7FFE0300 的内容由 ntdll 在加载时填入:
c
/* ntdll.dll 加载时 */
*(PVOID *)0x7FFE0300 = &KiFastSystemCall; /* 指向 ntdll!KiFastSystemCall */
*(PVOID *)0x7FFE0304 = &KiFastSystemCallReturn; /* 指向 KiFastSystemCallReturn 之后 */
2.5.4 sysexit 出口
sysexit 是 sysenter 的对称指令。sysexit 的 CPU 行为:
| 寄存器/状态 | sysexit 之前需设置 | sysexit 之后 |
|---|---|---|
| CS | (隐含 KGDT_R3_CODE) | = KGDT_R3_CODE + RPL_MASK |
| EIP | ECX(用户态 EIP) | = ECX |
| SS | (隐含 KGDT_R3_DATA + 8) | = KGDT_R3_DATA + RPL_MASK |
| ESP | EDX(用户态 ESP) | = EDX |
KiSystemCallSysExitReturn 这个 stub 由 KiTrapExitStub 宏生成,核心逻辑是:
asm
; 恢复所有通用寄存器(从 TrapFrame)
mov ecx, [esp + KTRAP_FRAME_EIP] ; 用户态 EIP
mov edx, [esp + KTRAP_FRAME_HARDWARE_ESP] ; 用户态 ESP
; ... 恢复其他寄存器 ...
sysexit
注意 ECX 与 EDX 必须包含用户态 EIP 与 ESP------这就是为什么 KiEnterTrap 在 sysenter 入口处要把 edx(用户参数指针)"借"到 HardwareEsp 槽位,让它在出口时能作为"用户态 ESP"被恢复到 edx 寄存器中。
2.5.5 INT 2Eh 与 sysenter 路径的对比表
| 维度 | INT 2Eh 路径 | sysenter 路径 |
|---|---|---|
| CPU 上下文保存 | 自动压栈 SS/ESP/EFLAGS/CS/EIP | 不自动保存;KiEnterTrap 手动压栈 |
| TrapFrame 构造 | KI_PUSH_FAKE_ERROR_CODE:CPU 已压栈大部分,只需分配"伪错误码"槽 |
KI_FAST_SYSTEM_CALL:CPU 不压栈,需要手动构建整个伪帧 |
| 入口汇编 | _KiSystemService (trap.s:142) |
_KiFastCallEntry (trap.s:149) |
| 用户态 EIP 来源 | TrapFrame->Eip(CPU 自动压) | TrapFrame->Eip ← [KUSER_SHARED_SYSCALL_RET] |
| C 入口 | 同一个 KiSystemServiceHandler |
同一个 KiSystemServiceHandler |
| 出口指令 | iret / sysexit(两选一) | sysexit 优先(TrapReturn 仅在 TF=1 时用) |
| KiCommonExit 行为 | 相同 | 相同 |
| 性能 | 较慢 | 较快(每条系统调用少 20-30 个 CPU 周期) |
| CPU 要求 | 任何 x86 | Pentium II 及以上 |
| CPU 兼容性回退 | 默认就是 | KiFastSystemCallDisable = 1 时回退 |
2.5.6 退化与兼容性
某些老 CPU(早期 Pentium、Pentium MMX)不支持 sysenter 指令。ReactOS 在启动过程中检测 CPU 能力,必要时关闭快速路径。
cpu.c:1045 附近有判断代码:
c
/* ntoskrnl/ke/i386/cpu.c, 第 1045-1050 行 */
if (KiFastSystemCallDisable)
{
/* Disable fast system call */
KeFeatureBits &= ~KF_FAST_SYSCALL;
KiFastCallExitHandler = KiSystemCallTrapReturn;
DPRINT1("Support for SYSENTER disabled.\n");
}
KiFastSystemCallDisable 默认 0,但 KiInitializeSystem 中可能根据 CPU 类型设为 1。一旦设为 1:
KeFeatureBits清除KF_FAST_SYSCALL位(标识"无快速系统调用能力")。KiFastCallExitHandler指向KiSystemCallTrapReturn(而不是KiSystemCallSysExitReturn),强制所有用户态出口走 iret 路径。
这样即使 stub 内部用了 sysenter,出口也会用 iretd------但实际上更稳妥的做法是让 stub 直接走 INT 2Eh 路径。
2.5.7 x64 的 syscall 指令
简单对比 x64 的 syscall 指令(syscalls.inc:76-90):
asm
/* sdk/include/asm/syscalls.inc, 第 76-90 行 */
#elif defined(_M_AMD64)
MACRO(STUBCODE_U, Name, SyscallId, ArgCount)
.ENDPROLOG
mov eax, SyscallId
mov r10, rcx ; x64 调用约定:rcx 是第一个参数
syscall
ret
ENDM
MACRO(STUBCODE_K, Name, SyscallId, ArgCount)
.ENDPROLOG
EXTERN Nt&Name:PROC
lea rax, Nt&Name[rip]
mov r10, ArgCount * 8
jmp KiZwSystemService
ENDM
x64 的关键变化:
- 寄存器数量多 :参数通过
rcx/rdx/r8/r9寄存器传递(调用约定)。 syscall自动设置 RCX = RIP(返回地址),R11 = RFLAGS。- 内核态 stub 不调用
_KiSystemService,而是直接jmp KiZwSystemService------这是一个不经过 INT 2Eh 路径的内核态 stub 实现,直接跳到内核态处理。 mov r10, rcx:因为syscall会破坏 rcx 为返回 RIP,但 Nt* 实现期望第一个参数在 rcx,所以先把 rcx 保存到 r10(x64 调用约定允许这样做)。
x64 实现的细节比 x86 更复杂,但基本思路一致 ------通过固定的入口(syscall 指令)切到内核态的 C 入口,由 C 入口统一处理。
2.5.8 小结
- 快速系统调用 解决了
INT n指令在高频调用时开销过大的问题。 sysenter是 Intel 在 Pentium II 引入的快速入口;它不自动保存寄存器,CPU 必须手动构建 TrapFrame。KUSER_SHARED_DATA(地址 0x7FFE0000)是用户态与内核态共享的数据区,包含系统调用入口指针(0x7FFE0300)与返回地址指针(0x7FFE0304)。sysexit是sysenter的对称出口------ECX = 用户态 EIP、EDX = 用户态 ESP。- 退化机制 :
KiFastSystemCallDisable = 1时回退到INT 2Eh慢速路径。 - x64 使用
syscall指令,约定与 x86 不同但思路类似。
2.5.9 为什么要这样做
(1) 为什么 sysenter 不自动保存寄存器------Intel 的设计哲学是什么?
INT n 指令会自动压栈 EFLAGS/CS/EIP,特权级切换时还会压 SS/ESP。sysenter 则什么都不压,只设置 CS/EIP/SS/ESP 到 MSR 中的预设值。这看起来"更原始",但 Intel 的设计选择其实非常清晰:
sysenter 是一个"最小切换"指令。它只做进入 ring 0 必需的事------设置段寄存器和指令指针,其余所有事情(保存寄存器、构造 TrapFrame)都交给操作系统。这样做有两个好处:
- 灵活性 :每个操作系统对 TrapFrame 的定义可能不同。Linux 的
struct pt_regs与 ReactOS 的KTRAP_FRAME字段顺序完全不同。如果sysenter硬编码压栈顺序,操作系统就被迫用 Intel 指定的 TrapFrame 格式,内核代码的设计自由会被限制。 - 速度 :少压栈几个寄存器意味着更少的内存写入。
sysenter本身只需要修改少量寄存器------寄存器操作是 O(1) 且无内存访问,相比INT n的 3-5 次内存写入要快得多。
代价 是内核必须在 sysenter 之后手动压栈构造 TrapFrame。你在 asmmacro.S:82(file:///d:/reactos/ntoskrnl/include/internal/i386/asmmacro.S#L82) 中看到的手动 push 序列就是这个代价------但这个代价只在入口处付一次,而且是"精准构造"(只压需要的字段),整体仍然比 INT n 的"自动压栈 + 额外补充"更快。
(2) 为什么需要 KUSER_SHARED_DATA------为什么不把入口直接编进 stub?
KUSER_SHARED_DATA(固定地址 0x7FFE0000)是 Windows 引入的一个巧妙机制:把一页内存同时映射到内核态和用户态,地址相同,内容共享。它的用处非常多:
- 存放高频读取的系统信息(系统时间、Tick Count、版本号)------用户态 DLL 不需要系统调用就能读到。
- 存放"当前系统调用入口"------这是本节重点。
为什么 stub 不直接 call _KiSystemService? 因为 _KiSystemService 是内核态符号,用户态的 ntdll.dll 根本看不到它的地址。用户态 stub 必须通过某种"受控通道"进入内核------这个通道就是:
ntdll stub → call [0x7FFE0300] → ntdll!KiFastSystemCall → sysenter
为什么要通过 0x7FFE0300 间接着?
-
入口可替换 :内核在启动时检测 CPU 是否支持
sysenter。如果支持,就把0x7FFE0300处的函数指针设为KiFastSystemCall(sysenter 路径);如果不支持,设为KiIntSystemCall(INT 2Eh 路径)。用户态的 stub 不需要关心用哪条路径 ------它只做call [0x7FFE0300]。 -
版本兼容 :如果 Windows 未来引入了更快速的系统调用指令(如 Intel VT-x 提供的 VMCALL),只需修改
0x7FFE0300处的指针,所有已经发布的应用程序(stub 已烧录在 ntdll.dll 中)自动受益------不需要重新编译。 -
与 x64 架构统一 :x64 上
syscall是标准指令,不需要 KUSER_SHARED_DATA 作为"中间人"------stub 直接执行syscall。但 x86 上这套"间接调用"机制仍然保留,用于在sysenter和INT 2Eh之间切换。
你可以把 0x7FFE0300 理解成"系统调用的函数指针表"------内核在启动时填好指针,用户态的 stub 通过它间接调用。这是一种"内核掌握主动权,用户态完全被动"的设计。
(3) 为什么保留 INT 2Eh 退化路径------为什么不直接删除?
KiFastSystemCallDisable(cpu.c:1045(file:///d:/reactos/ntoskrnl/ke/i386/cpu.c#L1045))是一个全局开关。当它被设置为 1 时,系统回退到 INT 2Eh 慢速路径。
保留退化路径不是出于"怀旧",而是出于工程稳健性:
-
老 CPU 兼容性 :早期 Pentium(无 MMX、P5 架构)不支持
sysenter/sysexit。虽然这类机器已经非常稀少,但作为通用操作系统,ReactOS 必须支持它们。 -
虚拟化环境中的问题 :某些 hypervisor(如早期的 VirtualBox、VMware)对
sysenter的虚拟化支持不完善。如果内核在虚拟环境中检测到sysenter行为异常,可以通过设置KiFastSystemCallDisable = 1回退到INT 2Eh。这是"降级模式"------虽然慢,但至少能运行。 -
调试与测试 :内核开发者有时需要强制走
INT 2Eh路径,以便在调试器中设置断点或观察特定行为。KiFastSystemCallDisable提供了一个简单的开关。
这种"快速路径 + 慢速退化路径"的模式在系统编程中非常常见:网络栈有"TSO 硬件加速 + 软件回退",存储栈有"NVMe 快速路径 + AHCI 慢速路径"。核心思想都是:先尝试最好的,失败时退到安全但较慢的备选方案。
(4) 为什么 x64 完全抛弃了 sysenter------改用 syscall?
x64 架构上没有 sysenter/sysexit,取而代之的是 AMD 设计的 syscall/sysret 指令。它们的区别:
| 特征 | sysenter(x86) |
syscall(x64) |
|---|---|---|
| 寄存器约定 | ECX=用户EIP, EDX=用户ESP | RCX=用户RIP, R11=用户RFLAGS |
| MSR 设置 | 需要设置 SYSENTER_CS_MSR 等 3 个 MSR | 需要设置 STAR/LSTAR/MSR_STAR 等 |
| 用户态段 | 通过 CS=SYSENTER_CS+16 推断 | 固定为 RPL=3 的段 |
| 参数传递 | 栈上(x86 调用约定) | 寄存器(rcx/rdx/r8/r9 + 栈) |
为什么抛弃 sysenter? 因为 sysenter 的设计对 x64 来说不够优雅:它依赖"段寄存器 + 16"来推断用户态 CS/SS,而 x64 已经淡化了段的概念(段描述符几乎是平的)。syscall 在设计时就明确了"最小上下文切换"的目标,与 x64 的"寄存器足够多、栈对齐严格"的调用约定天然契合。
ReactOS 的做法 :x86 代码全部走 sysenter/sysexit 路径;x64 代码全部走 syscall/sysret 路径;两者在 C 层的 KiSystemServiceHandler 完全相同。这就是"汇编层差异化,C 层统一"策略的直接体现。
2.6 从内核中发起系统调用
2.6.0 框架图(先见森林)
┌──────────────────────────────────────────────────────────────────────┐
│ 内核驱动调用 Zw* 的"反直觉"完整路径 │
│ │
│ 假设:fastfat!FatCommonWrite 调用 ZwCreateKey(...) │
│ │
│ ┌─────────────────────┐ │
│ │ fastfat.sys (R0) │ │
│ │ call ZwCreateKey │ │
│ │ cs:eip = zw.S!Zw.. │ │
│ │ [esp+4] = 参数区 │ ← 驱动当前的内核栈 │
│ └──────────┬──────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────┐ │
│ │ Zw* stub (ex/zw.S) │ │
│ │ mov eax, SysCallId │ │
│ │ lea edx, [esp+4] │ ← 关键:lea esp+4(驱动栈) │
│ │ pushfd │ │
│ │ push KGDT_R0_CODE │ ← 关键:模拟"用户态" CS=R0 │
│ │ call _KiSystemService│ │
│ └──────────┬──────────┘ │
│ │ │
│ ↓ INT 2Eh 路径 │
│ ┌─────────────────────┐ │
│ │ _KiSystemService │ │
│ │ KiEnterTrap(...) │ │
│ │ KiCallHandler( │ │
│ │ KiSystemServiceHandler) │
│ └──────────┬──────────┘ │
│ ↓ │
│ ┌─────────────────────┐ │
│ │ KiSystemServiceHandler │
│ │ TrapFrame->CS == R0 │ │
│ │ → KiUserTrap 返回 false │
│ │ → PreviousMode = KernelMode ← 关键:安全语义! │
│ │ → 跳过 ProbeForRead │
│ │ → 调用 NtCreateKey 实现 │
│ └──────────┬──────────┘ │
│ ↓ │
│ ┌─────────────────────┐ │
│ │ NtCreateKey │ │
│ │ (内核态真正实现) │ │
│ │ → 信任参数 │ │
│ │ → 走内核 Cm 模块 │ │
│ │ → KiServiceExit │ │
│ │ → 回到 fastfat!FatCommonWrite 继续执行 │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
配套概念图(Nt* vs Zw* 的"PreviousMode 决定"):
┌────────────────────────────────────────────────┐
│ Nt*: 真正实现,参数语义取决于 PreviousMode │
│ ├─ UserMode: Probe + 严格检查 │
│ └─ KernelMode: 信任参数,不 Probe │
│ │
│ Zw*: 永远创建 KernelMode 上下文 │
│ └─ 内部走 INT 2Eh 路径,把 PreviousMode 改为 │
│ KernelMode 后再调 Nt* │
│ │
│ → 内核代码"主动"调用系统服务时用 Zw* │
│ → 内核代码"处理用户请求"时用 Nt*(实现入口) │
└────────────────────────────────────────────────┘
本图核心要点 :Zw* 的"再走一次系统调用"看起来浪费,但它是"强制声明 PreviousMode"的标准做法。
2.6.1 为什么内核态要"再走一次系统调用"
考虑这样一个场景:文件系统驱动 fastfat.sys 在内核态处理一个写文件请求时,需要把"写完成"事件写入注册表(一种系统调用)。此时驱动已经在内核态,正在内核栈上工作。
如果直接调用 NtCreateKey(ntdll 中的真正实现),会发生什么?
NtCreateKey 的实现位于 ntoskrnl 中。它会根据 Thread->PreviousMode 决定是否对参数做 probe:
- 如果
PreviousMode = UserMode,Nt* 会用ProbeForRead/ProbeForWrite等函数检查传入的指针(用户态传入的),确保它们没有越界。 - 如果
PreviousMode = KernelMode,Nt* 会信任传入的指针(因为内核代码被认为是可信的),不 probe。
那么问题来了:驱动在内核态调用 NtCreateKey 时,Thread->PreviousMode 应该是什么?
直觉上,因为驱动本身就是内核态,PreviousMode 应该是 KernelMode------驱动可以信任自己传入的指针。但如果驱动在处理"用户态写文件"请求时被调用,PreviousMode 应该保留为 UserMode------因为驱动必须代表用户态做参数检查。
这就是 Zw* 与 Nt* 的本质区别:
Nt*:真正的实现。它根据 调用链的"用户态源头" 决定 PreviousMode。Zw*:内核态 stub。它强制把 PreviousMode 改为 KernelMode(无论调用上下文是什么)。
驱动中的约定:
| 场景 | 用什么 |
|---|---|
| 驱动"主动"调用某个系统服务(如写日志、打开句柄) | 用 Zw*(强制 KernelMode) |
| 驱动"处理用户请求"并把控制转交给 Nt* 实现 | 用 Nt*(保留用户态 PreviousMode) |
2.6.2 Zw* 模板的内部细节
我们已经在 2.3.4 节中看过 STUBCODE_K 宏,但这里再深入分析它为什么"巧妙"。
STUBCODE_K 的汇编模板(syscalls.inc:66-75):
asm
MACRO(STUBCODE_K, Name, SyscallId, ArgCount)
StackBytes = 4 * &ArgCount
FPO 0, 0, 0, 0, 0, FRAME_FPO
mov eax, SyscallId
lea edx, [esp + 4] ; ← edx 指向当前内核栈上的参数
pushfd ; ← 模拟"用户态"框架
push KGDT_R0_CODE ; ← CS = R0(用户态 + R0)
call _KiSystemService ; ← 走 INT 2Eh 路径
ret StackBytes
ENDM
关键点 1:lea edx, [esp + 4]
[esp + 4] 在 call _KiSystemService 之后是参数区的第一个参数(call 压入了返回地址)。这个 lea 把参数区地址放到 edx,与用户态 stub 的 "edx 指向用户参数区" 形成对应------这样 KiSystemCallTrampoline 中 rep movsd 就能从内核栈正确复制参数。
关键点 2:pushfd 与 push KGDT_R0_CODE
这两条指令模拟 了"用户态进入内核时 CPU 自动压栈的"两个值------EFLAGS 与 CS。在 INT 2Eh 路径下,CPU 会自动压栈 SS/ESP/EFLAGS/CS/EIP 五个值;但内核态 stub 的 call _KiSystemService 不会自动压栈。所以我们手动压栈 EFLAGS 与 CS,伪装成"用户态"框架。
KGDT_R0_CODE 是 ring 0 的代码段选择子。把它压入栈的 CS 位置后,KiEnterTrap 后续会用 KiUserTrap(TrapFrame) 判断这是不是用户态:
c
/* KiUserTrap 通常定义为 */
#define KiUserTrap(tf) (((tf)->SegCs & RPL_MASK) == USER_DPL) /* USER_DPL = 3 */
SegCs & RPL_MASK 在我们的栈帧中是 KGDT_R0_CODE & RPL_MASK。如果 RPL_MASK = 3,那么 KGDT_R0_CODE & 3 通常为 0(因为 KGDT_R0_CODE = 8 = 0b1000,CPL=0)。所以 KiUserTrap 返回 false------Thread->PreviousMode = KernelMode。
这是整个 Zw* 设计的精髓:
通过
push KGDT_R0_CODE,KiSystemServiceHandler自然地知道"这是内核态调用方",把 PreviousMode 设为 KernelMode,从而让 Nt* 实现跳过 probe。
2.6.3 PreviousMode 的"安全语义"
PreviousMode 在 KTHREAD(线程对象)中是一个字节,定义在 ntoskrnl/include/internal/ps.h 中。它的取值有两个:
c
#define KernelMode 0
#define UserMode 1
PreviousMode 的语义:
| PreviousMode | 含义 | Nt* 实现行为 |
|---|---|---|
UserMode |
当前线程代表用户态 | 指针/句柄要 probe;不能信任参数 |
KernelMode |
当前线程代表内核态 | 信任参数,不 probe |
probe 的含义:
ProbeForRead(Address, Length, Alignment) 是一个内核态 API,它检查 [Address, Address+Length) 这段内存是否在用户态地址范围内且对齐。如果用户态传入了一个非法指针,probe 会触发异常。
c
/* sdk/include/ndk/rtlfuncs.h 简化版 */
NTSTATUS ProbeForRead(IN PVOID Address, IN SIZE_T Length, IN ULONG Alignment);
ProbeForRead 的核心 :它用 try/except 读取一次 [Address] 处的值------如果 Address 越界,访问违例触发异常,probe 失败。
驱动中的 PreviousMode 流转:
用户态应用
│ call NtCreateFile
↓
ntdll!NtCreateFile (用户态 stub)
│ sysenter
↓
KiSystemServiceHandler
│ Thread->PreviousMode = UserMode ← 来自用户态
↓
NtCreateFile 实现
│ ProbeForRead(...)
↓
(参数检查后)执行实际工作
↓
KiServiceExit
│ Thread->PreviousMode = PreviousPreviousMode
↓
返回用户态
驱动中"主动调用 Zw*"的 PreviousMode 流转:
内核线程(PreviousMode=KernelMode)
│ call ZwCreateKey
↓
zw.S!ZwCreateKey (内核态 stub)
│ push KGDT_R0_CODE ← 关键
│ call _KiSystemService
↓
KiSystemServiceHandler
│ TrapFrame->CS = KGDT_R0_CODE → KiUserTrap=false
│ Thread->PreviousMode = KernelMode ← 已经是 KernelMode
↓
NtCreateKey 实现
│ 不做 probe(信任驱动)
↓
(执行后)KiServiceExit
↓
回到驱动
注意:此时 Thread->PreviousMode 在 KiSystemServiceHandler 入口处被覆盖 为 KiUserTrap(TrapFrame) 的结果。在 push KGDT_R0_CODE 的情况下,KiUserTrap 返回 false,所以 PreviousMode = KernelMode。这与驱动调用前的状态一致------但关键区别是 Nt* 看到的 PreviousMode 一定是 KernelMode,不会受调用上下文影响。
2.6.4 完整例子:fastfat 写日志到注册表
我们用一个完整的例子来说明这一切。假设 fastfat.sys 正在处理一个用户态发起的写文件请求,需要把"写完成"事件写入注册表:
c
/* fastfat.sys 内核态代码(伪代码) */
NTSTATUS FatCommonWrite(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
NTSTATUS Status;
HANDLE LogKey;
/* 1. 处理用户态的写请求 */
Status = FatDoUserWrite(Irp);
/* 2. 写完成后想记录到注册表(这是驱动主动发起的系统调用) */
InitializeObjectAttributes(&oa, &logName, OBJ_CASE_INSENSITIVE, NULL, NULL);
/* 注意:这里用 Zw*,不是 Nt* */
Status = ZwCreateKey(&LogKey,
KEY_WRITE,
&oa,
0, NULL, REG_OPTION_VOLATILE,
&disposition);
if (NT_SUCCESS(Status))
{
/* ... 写入注册表 ... */
ZwClose(LogKey);
}
/* 3. 完成 IRP,返回用户态 */
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return Status;
}
调用 ZwCreateKey 时,发生以下事情:
- fastfat 处于内核态 :当前
Thread->PreviousMode可能是 UserMode(如果是处理用户态写请求)或 KernelMode(如果是系统线程上下文)。 ZwCreateKeystub 取出SysCallId到 eax、lea edx, [esp+4]把参数区地址放到 edx、pushfd、push KGDT_R0_CODE、call _KiSystemService。_KiSystemService入口的KiEnterTrap看到KI_PUSH_FAKE_ERROR_CODE标志,按"伪错误码"模式构造 TrapFrame。TrapFrame 的 SegCs 字段被设为KGDT_R0_CODE(R0)。KiSystemServiceHandler看到 TrapFrame->CS 是 R0,KiUserTrap(TrapFrame)返回 false。Thread->PreviousMode = KernelMode。NtCreateKey实现 看到PreviousMode = KernelMode,跳过 probe ,直接用传入的指针(oa的地址)。NtCreateKey内部走 Cm 模块(配置管理器)创建注册表键。- 完成 :
KiServiceExit写 NTSTATUS 到 EAX,恢复PreviousMode = PreviousPreviousMode(即用户态写请求的 UserMode),sysexit 返回驱动(其实是返回到 fastfat 继续执行)。
2.6.5 GUI 线程与"双重服务表"
SERVICE_TABLE_TEST(值为 0x1000)是系统调用号的一个特殊位。当某个系统调用号含此位时,表示该调用来自第二张表(GUI/Win32k 表)。
c
/* traphdlr.c 第 1783-1810 行附近 */
if (__builtin_expect(Id >= DescriptorTable->Limit, 0))
{
if (!(Offset & SERVICE_TABLE_TEST))
{
/* Fail the call */
Status = STATUS_INVALID_SYSTEM_SERVICE;
goto ExitCall;
}
/* Convert us to a GUI thread -- must wrap in ASM to get new EBP */
Status = KiConvertToGuiThread();
/* Reload trap frame and descriptor table pointer from new stack */
TrapFrame = *(volatile PVOID*)&Thread->TrapFrame;
DescriptorTable = (PVOID)(*(volatile ULONG_PTR*)&Thread->ServiceTable + Offset);
...
}
为什么需要 GUI 线程?
Win32k.sys 提供的 API(NtUserCreateWindowEx、NtUserGetMessage 等)需要大内核栈------因为 GDI/User 调用经常使用较大的局部变量与栈帧。普通线程的内核栈默认 12 KB,GUI 线程通常是 64 KB。
KiConvertToGuiThread(在 trap.s:184 附近的 PsConvertToGuiThread 中)会:
- 分配一个更大的内核栈。
- 切换
Thread->KernelStack到新栈。 - 复制 TrapFrame 到新栈(这是为什么需要"在汇编中重新获取 EBP"------因为栈变了)。
- 设置
Thread->ServiceTable = KeServiceDescriptorTableShadow,让后续的 GUI 系统调用走 Win32k 表。
GUI 线程转换是一个一次性升级------一旦升级为 GUI 线程,线程就永久使用 GUI 表,直到线程结束。
2.6.6 NtCallbackReturn 与用户态回调
2.4.5 节提到 NtCallbackReturn 是"反向系统调用"。完整理解它需要先理解 KeUserModeCallback:
KeUserModeCallback (usercall.c:131):
当内核需要"调用"一个用户态函数时(如子系统向应用进程投递消息),使用 KeUserModeCallback:
c
/* ntoskrnl/ke/i386/usercall.c, 第 131 行附近 */
NTSTATUS
NTAPI
KeUserModeCallback(IN ULONG RoutineIndex,
IN PVOID Argument,
IN ULONG ArgumentLength,
OUT PVOID *Result,
OUT PULONG ResultLength)
{
...
/* 1. 保存当前内核栈帧 */
/* 2. 切到用户态调用 ntdll!KiUserCallbackDispatcher */
/* 3. ntdll 找到应用进程的回调函数(如窗口过程)并调用 */
/* 4. 用户态函数返回时通过 NtCallbackReturn */
...
/* 5. NtCallbackReturn 通过 INT 0x2B 返回内核 */
/* 6. 内核恢复保存的栈帧,继续执行 */
...
}
机制非常巧妙------内核临时假装自己是用户态来执行一段用户态代码。这与系统调用的"用户→内核"是相反方向。
KeUserModeCallback 涉及的汇编代码在 usercall_asm.S:49-76(KiCallUserMode@8):
asm
/* ntoskrnl/ke/i386/usercall_asm.S, 第 49-76 行 */
PUBLIC _KiCallUserMode@8
_KiCallUserMode@8:
push ebp
push ebx
push esi
push edi
lea ecx, [esp - 12] ; ecx 指向 callout frame
sub esp, 12 + NPX_FRAME_LENGTH + KTRAP_FRAME_LENGTH + 16
call @KiUserModeCallout@4
add esp, 12 + NPX_FRAME_LENGTH + KTRAP_FRAME_LENGTH + 16
pop edi
pop esi
pop ebx
pop ebp
ret 8
KiUserModeCallout(在 usercall.c 中)完成实际工作:建立 TrapFrame、设置返回地址为 ntdll!KiUserCallbackDispatcher、然后调用 KiServiceExit------它把 TrapFrame 当作"系统调用返回",用 sysexit 切到用户态。
NtCallbackReturn 是反向的入口:用户态通过 INT 0x2B 触发,由 KiCallbackReturnHandler(traphdlr.c:1639-1663)处理,最终恢复 KeUserModeCallback 保存的栈帧。
2.6.7 内核态 system service 调用的其他途径
除了 Zw* 与 GUI 线程,ReactOS 还提供其他内核态系统调用机制:
-
Ex/Ke等前缀的内部 API :如ExAllocatePoolWithTag、KeWaitForSingleObject。这些不经过系统调用表,而是直接调用内部函数。它们不切换栈、不改变 PreviousMode------是真正的"内部函数调用"。 -
Ob/Mm等子系统 API :如ObCreateObject、MmAllocatePagesForMdl。同样不经过系统调用表,但可能在内部进一步调用Zw*触发嵌套系统调用。 -
HAL 函数 :如
HalGetInterruptVector、HalAllocateCommonBuffer。最底层的硬件抽象层调用。
这三类 API 之间的区别:
Zw*:经过系统调用表,会强制 PreviousMode = KernelMode。Ke*/Ex*等:直接内部调用,不改变 PreviousMode。- 底层 HAL:直接读写硬件。
驱动开发者应仅在需要系统服务级别能力时 使用 Zw*------大多数情况下直接使用 Ke*/Ex* 等内部 API 更高效。
2.6.8 小结
- 内核态 stub (
Zw*)通过push KGDT_R0_CODE模拟"用户态 + R0"的 CS 字段,让KiSystemServiceHandler把 PreviousMode 设为 KernelMode。 Nt*vsZw*:Nt*是真正实现(语义随 PreviousMode 变化),Zw*是内核态 stub(永远创建 KernelMode 上下文)。- 驱动中的约定 :主动发起系统服务时用
Zw*;处理用户请求时用Nt*(实现入口)。 - GUI 线程 通过
SERVICE_TABLE_TEST触发,调用KiConvertToGuiThread升级为大栈线程。 - KeUserModeCallback / NtCallbackReturn 是"反向"系统调用机制,用于内核调用用户态函数。
2.6.9 为什么要这样做
(1) 为什么 Zw* 要强制 PreviousMode=KernelMode------为什么不直接调用 Nt* 实现?
这是整个"内核态调用系统服务"机制中最核心的设计决策。考虑两种方案:
方案 A(当前 ReactOS 的做法) :驱动调用 ZwCreateKey → Zw* stub(含 push KGDT_R0_CODE)→ _KiSystemService → KiSystemServiceHandler 检测到 CS=R0 → 设 PreviousMode=KernelMode → 调用 NtCreateKey 实现。
方案 B(直觉做法) :驱动直接调用 NtCreateKey(内核态符号,对驱动可见)→ NtCreateKey 内部读取 KeGetPreviousMode() → 如果当前线程 PreviousMode=KernelMode,则跳过 probe。
为什么 ReactOS 选择方案 A?
答案是安全隔离与语义一致性:
-
避免"PreviousMode 泄露" :假设驱动正在处理用户态的写请求(PreviousMode=UserMode)。如果驱动内部想"以驱动自身身份"打开一个注册表键,它不能直接调用
NtCreateKey------因为NtCreateKey会看到 PreviousMode=UserMode,对传入的指针/句柄做严格检查。这是对的,但不是驱动想要的语义。驱动想说"我(内核代码)请求这项操作",而不是"我代表用户请求这项操作"。 -
语义一致性 :
Zw*的命名就隐含了"内核态发起者"的语义。所有Zw*函数都有一个不变承诺------它们会把 PreviousMode 改为 KernelMode,再进入真正实现。这样 Nt* 实现中只需要判断"当前 PreviousMode 是什么",不需要区分"这个调用是来自用户态 stub 还是驱动"。 -
避免 TOCTOU 攻击 :如果驱动直接调用
NtCreateKey而 PreviousMode 恰好是 UserMode(因为当前线程刚才在处理用户态请求),NtCreateKey会 probe 参数。probe 完后参数在用户态可能被另一个线程修改------这就是"检查时间 vs 使用时间"(Time-Of-Check, Time-Of-Use)漏洞。通过Zw*强制 PreviousMode=KernelMode,驱动确保了"一旦进入 Nt*,参数就被当作内核态信任"。
但方案 A 的代价是:一次完整的系统调用穿越 ------从内核态走 INT 2Eh 路径进入内核态的 _KiSystemService,再进入 C 层,再回到 KiServiceExit。这看起来像是"绕了一圈回到原地",但实际上这是保证语义一致的最小代价。
一句话总结:Zw* 不是"性能优化",而是"语义明确化"的工具------它告诉内核"这次调用来自内核代码自身,请按内核态语义处理参数"。
(2) 为什么不把 PreviousMode 暴露给驱动------让驱动自己 KeSetPreviousMode(KernelMode)?
一个更直接的方案是:给驱动提供 KeSetPreviousMode 函数,让驱动按需设置。但这个方案有两个致命问题:
-
"忘记恢复"的风险 :驱动调用
KeSetPreviousMode(KernelMode)后必须在返回前恢复旧值。如果驱动在某个错误路径上忘记恢复,接下来用户态的系统调用会看到 PreviousMode=KernelMode,这意味着 Probe 被跳过------攻击者可以构造内核态地址作为参数传入 Nt* 实现,实现任意读写。 -
难以审计 :每个使用
KeSetPreviousMode的地方都需要配对恢复------在有多个 return 路径、有异常处理、有 try/finally 语义的代码中,这几乎不可能正确实现。
ReactOS(和 Windows NT)的方案解决了这两个问题:
- PreviousMode 的保存与恢复由
KiSystemServiceHandler/KiServiceExit自动完成,驱动开发者不需要手动管理。 Zw*函数的命名和行为在编码规范层面就明确了语义 ------开发者看到Zw前缀就知道"这个函数会以内核态身份调用系统服务"。
这又是"集中处理 vs 分散处理"的权衡。ReactOS 选择了"集中在 C 入口处理",让驱动代码保持简洁和安全。
(3) 为什么 GUI 线程需要更大的内核栈------不能一开始就分配大栈吗?
当系统调用号包含 SERVICE_TABLE_TEST 标志位时,内核会判断"这是一个 GUI 子系统调用",调用 KiConvertToGuiThread 把当前线程升级为 GUI 线程。
什么是 GUI 线程的"升级"?
- 更大的内核栈:普通线程内核栈默认 12 KB(一个页),GUI 线程 64 KB(多个页)。
- 切换到 Win32k 的 SSDT 副本 :
KeServiceDescriptorTableShadow中包含 Win32k 提供的 GUI 相关服务。 - 设置
Win32Thread指针:在 ETHREAD 中设置 Win32k 的线程上下文。
为什么不一开始就给每个线程分配大栈?
-
空间开销 :Windows 上一个进程可能有上百个线程。如果每个线程的内核栈都是 64 KB,仅内核栈就可能消耗数 MB 内存。对于非 GUI 线程(绝大多数 worker 线程、IO 线程、后台线程),这么大的栈是浪费。
-
启动性能 :创建线程时分配大栈会增加系统调用(
NtCreateThread)的执行时间。
按需分配 的策略就很自然了:线程初始时只有 12 KB 的小栈,当它第一次触发 GUI 调用时,KiConvertToGuiThread 分配大栈并把 TrapFrame 拷贝过去。这个过程是"第一次调用时一次性付费",对绝大多数线程来说永远不需要付费。
你可以把它理解为"惰性初始化"(lazy initialization)模式的一种------只有在真正需要时才分配资源,这是所有资源受限系统的标准实践。
(4) 为什么需要"反向"系统调用 KeUserModeCallback------为什么不直接在用户态做?
KeUserModeCallback 的语义是:内核态临时"借用"当前线程的用户态上下文,去执行一段用户态代码,然后再回到内核。典型场景:
- 窗口过程 :应用注册了
WindowProc,当 Win32k.sys 收到消息时需要调用这个用户态过程。 - 钩子过程 :应用注册了
SetWindowsHookEx的钩子回调,Win32k 在内核态分发消息时需要调用用户态的钩子函数。 - 打印后台处理:打印机驱动需要与用户态组件配合工作。
为什么不把这些逻辑全放到内核态? 因为:
-
应用代码的不可信任:窗口过程是由应用编写的,可能有 bug 或恶意代码。在内核态直接调用应用代码等同于"把 ring 0 权限交给应用"------这是安全灾难。
-
历史原因 :Windows 16 位时代的窗口系统完全在用户态(USER.EXE / GDI.EXE)。Win32 子系统保留了这个模型,但把核心绘图与窗口管理移到了 Win32k.sys------用户态窗口过程仍然是必要的。
-
可扩展性:许多应用自定义窗口行为(自绘控件、自定义消息处理)。如果把这些逻辑全放到内核态,应用就失去了定制能力。
因此 Win32k.sys 采用的是一个"半内核半用户态"的架构:消息路由、绘图优化、窗口管理在内核态;窗口过程、钩子过程、自定义绘图在用户态 ,通过 KeUserModeCallback / NtCallbackReturn 的"反向穿越"与内核交互。
这也解释了为什么"Windows 图形系统很复杂"------内核态与用户态之间需要频繁地"来回穿越",每一次穿越都要做完整的寄存器保存、参数拷贝、状态恢复。理解了这一点,你才能理解 ReactOS 中 Win32k!xxxSendMessage、Win32k!xxxCallHookProc 等函数的实现逻辑。
2.7 本章小结
本章沿着"内核态入口 → 调度跳转 → 出口"这条主轴,把 ReactOS 在 x86 平台上对系统调用的实现一层一层剥开。读者读完后应当能形成以下 5 个核心结论:
-
系统调用是用户态/内核态边界的桥梁。CPU 提供三种"穿越边界"的机制:系统调用(用户主动)、异常(CPU 主动)、中断(硬件被动)。三种机制在 C 层汇入统一的入口架构。
-
x86 系统调用有两条路径 :
INT 2Eh(兼容、慢速)与sysenter(快速、Pentium II+)。两条路径在汇编层有差异(一个 CPU 自动压栈、一个手动构造 TrapFrame),但在 C 层汇入同一个KiSystemServiceHandler。这是 ReactOS 设计的精妙之处。 -
系统调用号到 Nt* 实现的查找 由 SSDT(系统服务调度表)完成。32 位系统调用号被拆为"表选择"(高 4 位)与"表内偏移"(低 12 位)两部分,通过
KeServiceDescriptorTable[Offset].Base[Id]即可定位到真正的 Nt* 实现。 -
Nt* 与 Zw* 的本质区别在于 PreviousMode:Nt* 是真正实现(按 PreviousMode 决定是否 probe 参数),Zw* 是内核态 stub(强制 PreviousMode=KernelMode)。驱动主动调用系统服务时用 Zw*。
-
快速系统调用 通过
sysenter/sysexit指令实现,比INT 2Eh少 20-30 个 CPU 周期。KUSER_SHARED_DATA(0x7FFE0000)是用户态与内核态共享的关键数据区,包含KiFastSystemCall入口与返回地址。
2.7.1 设计哲学总结------为什么 ReactOS 的系统调用是这样设计的?
在本章的"为什么要这样做"各小节中,我们看到了一系列设计取舍。把它们归纳到一起,可以提炼出 ReactOS/Windows NT 在系统调用设计上遵循的三条核心原则:
原则一:汇编层做最小差异化,C 层做统一处理
系统调用有两条路径(INT 2Eh / sysenter)、三条返回路径(iretd / sysexit / trap return)、还有异常和中断。ReactOS 的策略是:在汇编层只做 CPU 指令层面必需的差异化工作 (如 KiEnterTrap 根据标志位决定压栈顺序),所有逻辑处理都下沉到 C 层 (KiSystemServiceHandler、KiServiceExit、KiCommonExit)。这样做的好处是:
- C 代码比汇编易写、易读、易维护。
- 未来引入新的穿越方式时,只需新增汇编入口,C 层不需要改动。
- 统一的 C 入口确保了 PreviousMode、SEH 链、调试寄存器等"状态管理"的一致性------不会出现"路径 A 保存了调试寄存器但路径 B 忘了"这样的 bug。
原则二:语义由最外层声明,内部实现按语义工作
Nt* 与 Zw* 的区别不在于实现------它们调用的是同一个 C 函数 。区别在于入口处声明的 PreviousMode 不同 :用户态 stub 让 PreviousMode=UserMode,内核态 stub 让 PreviousMode=KernelMode。真正的 Nt* 实现只需要读取 KeGetPreviousMode() 并据此决定是否 probe 参数。
这种"语义声明在上层,实现不区分来源"的模式有两个强大的好处:
- 安全边界清晰:内核代码永远不需要"猜测"调用者的意图------PreviousMode 已经明确声明了。
- 可组合 :驱动可以嵌套调用
Zw*(从用户态系统调用中进入,再触发另一个内核态系统调用),PreviousMode 的"栈式恢复"机制会自动还原上下文,不需要任何手动管理。
原则三:先尝试最优路径,保留安全退化
快速系统调用(sysenter)是最优路径,慢速路径(INT 2Eh)是退化路径。KUSER_SHARED_DATA 中的函数指针允许内核在运行时动态选择使用哪条路径。这种"快速路径 + 退化路径"的模式在 NT 内核中随处可见:
- 网络:TSO 硬件加速 + 软件分片退化
- 存储:NVMe 快速路径 + AHCI/IDE 慢速路径
- 图形:GPU 加速 + GDI 软件退化
- 系统调用:sysenter + INT 2Eh
每一条"快速路径"都带来性能提升,但代价是依赖特定硬件或特性 。保留退化路径意味着系统在任何环境中都能运行------即使在最老的 CPU 上也不会崩溃。这是工业级系统与学术系统最本质的区别之一:工业级系统必须在最坏情况下也能工作。
理解了这三条原则,你就能读懂 ReactOS 中几乎所有看似"多此一举"的代码------它们不是多余的,而是对"安全、稳健、可维护"这三个目标的长期投资。
下一章将进入"对象管理器"(Object Manager)的情景分析。对象管理器 是 NT 内核中一切资源的"管理中枢"------理解了它就理解了 Windows 内核的"对象"思想。它依赖本章学到的"系统调用"作为底层通道:用户态通过 NtCreateFile 等 Nt* 调用,最终都汇入对象管理器的 ObCreateObject、ObInsertObject 等内部 API。