Reactos 第2章 系统调用

第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

特权级划分的目的是安全与稳定

  1. 进程隔离:用户态的进程 A 无法直接读取/破坏进程 B 的内存,更不能直接操纵硬件。
  2. 内核保护:用户态的错误(如除零、访问空指针、缓冲区溢出)只能让该进程崩溃,而不会破坏内核。
  3. 硬件独占:只有内核态能直接与硬件(磁盘、网卡、显卡等)通信,避免应用程序争夺资源。

代价是:用户态不能直接做"打开文件"等需要特权级的事,必须有一条受控的"通道"把请求传递给内核。这就是系统调用。

2.1.2 边界的三道门

CPU 提供三种"穿越用户态-内核态边界"的机制,分别是:

  1. 系统调用(System Call) :用户态主动发起,请求内核服务。x86 上的实现有 INT 2Ehsysentersyscall 三种指令。
  2. 异常(Exception):CPU 在执行指令时检测到错误或陷阱条件时触发。典型异常有除零(#DE)、缺页(#PF)、断点(#BP)、通用保护(#GP)等。
  3. 中断(Interrupt):外部硬件(时钟、键盘、网卡、磁盘等)向 CPU 发送的电信号,CPU 暂停当前执行并跳转到预设的中断处理例程。

三者在内核态的入口不同,但在更深的层次(特别是 C 层的处理函数)有许多共用的代码路径:都需要保存寄存器现场(KTRAP_FRAME)、切换到内核栈、判断是哪个线程触发的、然后调用相应的处理函数。ReactOS 的 traphdlr.c 中可以看到 Ki*Handler 系列函数名(如 KiSystemServiceHandlerKiDebugHandlerKiNpxHandler),它们以相似的方式处理不同类型的事件。

2.1.3 系统调用的三要素

任何系统调用都涉及三个约定(contract),少一个都不能工作:

  1. 入口约定(Entry Convention) :用户态如何切到内核态。x86 上是 INT 2Eh(兼容、慢速)、sysenter(快速)、syscall(x64 专用)。每种指令对 CPU 寄存器、栈、CS/SS 都有特定约定。
  2. 参数约定(Parameter Convention) :如何把参数从用户栈传送到内核。Windows 的做法是:
    • 用户态 stub 把"参数区地址"(一个指针)放在 edx 寄存器中;
    • 把"系统调用号"放在 eax 寄存器中;
    • 真正的参数本身保留在用户态栈上不动(不需要拷贝,由 KiSystemCallTrampoline 后续在调用时复制)。
  3. 返回约定(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] 间接跳到这个函数。这里的 0x7FFE0300KUSER_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,原因有三个:

  1. 性能代价:每次特权级切换都会触发 CPU 的栈切换(SS/ESP 自动加载)与段限长检查。多一层特权级意味着多一次栈切换------这对高频系统调用是不可接受的开销。
  2. 硬件差异 :并非所有非 x86 的硬件平台都支持 4 个特权级(如 MIPS 只有 2 个,Alpha 也只有 kernel/user 两种模式)。NT 是一个跨平台内核,必须在所有平台上保持一致的抽象。
  3. 复杂性:四层特权级要求驱动程序、子系统、应用之间层层隔离,这会让内核接口变得无比复杂。实践证明,"内核态 vs 用户态"的二元划分已经足够。

所以在 ReactOS 源码中,你只会看到两种模式:KernelModeUserMode(定义在 ndk/mmtypes.h(file:///d:/reactos/sdk/include/ndk/mmtypes.h) 中,值为 0 和 1)。这是一个简洁而经典的设计取舍。

(2) 为什么是 INT 2Eh,而不是其他中断号?

这是历史约定 。Windows NT 3.x 时代选择了 0x2E(46 号中断)作为系统调用的软中断号。这个数值本身没有特殊含义,关键在于它必须同时满足两个条件

  1. 不能与硬件中断冲突:x86 的前 32 个中断(0x00~0x1F)被 CPU 自身用于异常报告;从 0x20 起的中断可以分配给外部硬件或系统调用。
  2. 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) 中可以看到,KiSystemServiceHandlerKiDebugHandlerKiNpxHandler 等函数的结构非常相似。这是 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 为例):

  1. 将当前的 SS(栈段寄存器)、ESP(栈指针)、EFLAGS(标志寄存器)、CS(代码段寄存器)、EIP(指令指针)压入内核栈。
  2. 跳转到 IDT 中 INT 2Eh 项指定的中断处理函数(即 _KiSystemService)。
  3. _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.hks386.h):

  • EipEFlagsSegCsSegEsSegDs:进入内核前的指令指针、标志、代码段与数据段。
  • 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 宏时传入的标志位

  • _KiSystemServiceKI_PUSH_FAKE_ERROR_CODE | KI_NONVOLATILES_ONLY | KI_DONT_SAVE_SEGS
  • _KiFastCallEntryKI_FAST_SYSTEM_CALL | KI_NONVOLATILES_ONLY | KI_DONT_SAVE_SEGS

KiEnterTrap 宏定义在 ntoskrnl/include/internal/i386/asmmacro.S 第 77-245 行。这里摘录关键分支(KI_FAST_SYSTEM_CALLKI_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|RPLEDX(用户参数指针)→ EFLAGSCS=KGDT_R3_CODE|RPLEIP=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_RETKUSER_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 步

  1. 取当前线程Thread = KeGetCurrentThread();。从 PCR(处理器控制区域)中读出当前 ETHREAD。
  2. 保存 TrapFrame 链TrapFrame->Edx = Thread->TrapFrame。Edx 字段被复用为"上一个 TrapFrame 指针",退出系统调用时通过 Thread->TrapFrame = (PKTRAP_FRAME)TrapFrame->Edx 恢复。
  3. 设置伪错误码TrapFrame->ErrCode = 0;。系统调用没有真正的错误码。
  4. 保存 PreviousPreviousModeTrapFrame->PreviousPreviousMode = Thread->PreviousMode;。在退出时用 Thread->PreviousMode = TrapFrame->PreviousPreviousMode 恢复。
  5. 保存 SEH 链:把当前 SEH 链保存到 TrapFrame,临时清空 PCR 的 ExceptionList------这是为了在系统调用过程中如果发生异常,SEH 不会跨越系统调用边界。
  6. 处理调试寄存器 :如果 KiUserTrap(TrapFrame) 为真(即来自用户态),并且当前线程处于调试激活状态,调用 KiHandleDebugRegistersOnTrapEntry 处理 DR7 等调试寄存器。
  7. 设置 PreviousMode 为 UserModeThread->PreviousMode = KiUserTrap(TrapFrame);。从此,当前线程进入"处理用户请求"的语义------后续的 Nt* 实现据此判断是否需要 probe 用户态参数。
  8. 开中断_enable();。允许中断嵌套。
  9. 解码系统调用号Offset = (SysCallNum >> SERVICE_TABLE_SHIFT) & SERVICE_TABLE_MASK; Id = SysCallNum & SERVICE_NUMBER_MASK;。这一步将 32 位的 SysCallNum 拆为高位(表选择)与低位(表内偏移)两部分。详见 2.3 节。
  10. 边界检查与 GUI 转换 :如果 Id >= DescriptorTable->Limit(即"系统调用号超界"),则检查 Offset 是否含 SERVICE_TABLE_TEST 位------若含,说明该调用本意是 GUI 调用(Win32k 表),需要先把当前线程提升为 GUI 线程。GUI 线程使用更大的内核栈与不同的描述符表。
  11. Probe 用户栈if (Arguments < MmUserProbeAddress) ...MmUserProbeAddress 是用户态地址的上界(默认 0x7FFF0000)。如果 Arguments(用户参数指针)在这个范围内但当前是内核态调用------理论上不应发生------则触发 fatal 错误。这是一种防御性检查。
  12. 调用 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 函数,它会期望参数从栈顶往下读取。直接传递用户栈指针会面临两个问题:

  1. 指针合法性:用户栈可能在分页池中,被换出到磁盘。
  2. 结构体拷贝:某些参数是结构体,需要从用户栈完整拷贝到内核栈。

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

执行流程:

  1. 设置标准函数栈帧,保存 esi/edi。
  2. 从参数中取 HandlerArgumentsStackBytes
  3. sub esp, ecx:在内核栈顶预留 StackBytes 字节。
  4. rep movsd:从用户栈复制参数到内核栈。
  5. call eax:调用真正的 Nt* 实现。这时 Nt* 函数看到的参数就像"从栈顶往下"读取的正常 C 函数参数。
  6. 函数返回后清理栈帧,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 层做统一处理------是工业级内核的标准做法。原因:

为什么汇编层必不可少

  1. 栈切换与寄存器保存只能用汇编:C 语言没有"压栈 SS"、"从 TSS 加载 ESP"这样的原语。
  2. CPU 指令层面的行为必须精确控制sysenter 指令的后续几条指令必须在寄存器被污染之前完成 TrapFrame 构造,C 编译器无法保证这种时序。
  3. KiCallHandler 这个宏(asmmacro.S:253(file:///d:/reactos/ntoskrnl/include/internal/i386/asmmacro.S#L253))负责把 TrapFrame 的地址作为第一个参数、edx(参数指针)作为第二个参数压栈,然后调用 C 函数------这是 C 编译器无法自动完成的"参数手动编排"。

为什么 C 层必不可少

  1. 12 步处理逻辑(解码系统调用号、边界检查、GUI 线程转换、Probe、调用 trampoline 等)用汇编写的话会有几百行、极易出错。
  2. C 函数可以直接调用其他 C 函数KiSystemServiceHandler 内部调用 KiConvertToGuiThreadKiDbgPreServiceHook 等 C 函数是自然的;如果这些都用汇编,就需要手动维护调用约定。
  3. 可移植性 :未来 x64 版本只需替换汇编层(_KiSystemServiceKiSystemCall64),C 层逻辑可以基本复用。

一句话:汇编层处理"CPU 如何切换",C 层处理"内核做什么"。这是所有现代操作系统内核(Linux、FreeBSD、NT)共同的分层方式。

(3) 为什么 PreviousMode 要先保存到 TrapFrame 再恢复------而不是直接用一个全局变量?

Thread->PreviousMode 是"当前线程代表谁在执行"的标志。在系统调用入口处:

  1. 先把旧值保存到 TrapFrame->PreviousPreviousMode
  2. 再设置新值为 UserMode(或 KernelMode,取决于来源)。
  3. 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* 实现。为什么要多这一步拷贝? 有三个原因:

  1. C 调用约定要求参数在栈上 :Nt* 是普通的 C 函数,它期望 [esp+4] 是第一个参数、[esp+8] 是第二个参数......如果不把参数复制到内核栈,Nt* 就无法按 C 约定读取。
  2. 用户栈不可信:用户态可能在系统调用过程中修改参数(TOCTOU 攻击------Time Of Check, Time Of Use)。把参数"捕获"到内核栈后,Nt* 实现读到的是调用时的快照,不受用户态后续修改影响。
  3. 缺页容错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 6NtAcceptConnectPort 有 6 个参数(24 字节栈)。
  • NtClose 1NtClose 有 1 个参数(4 字节)。
  • NtCreateFile 11NtCreateFile 有 11 个参数(44 字节)。

sysfuncs.lst 的列表顺序就是系统调用号------从 0 开始计数。ReactOS 默认 0-based。例如:

  • NtAcceptConnectPort → 0x000
  • NtAccessCheck → 0x001
  • ...
  • NtCreateFile → 0x01E(30)
  • ...

系统调用号的 32 位 编码如下:

复制代码
   31         12 11       0
   ┌─────────────┬─────────┐
   │  Offset     │   Id    │
   │  (高 20 位) │ (低 12)│
   └─────────────┴─────────┘
  • 低 12 位 Id:表内偏移,对应 0~4095 号调用。
  • 高 20 位 Offset :经过 SERVICE_TABLE_SHIFTSERVICE_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 表,包含 NtUserCreateWindowNtUserGetMessage 等 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_SHIFTSERVICE_TABLE_MASK 提取出来,本身已经包含了"表索引 × 描述符大小"的信息。
  • Id 是低 12 位,作为表内编号。
  • 调用的函数 = DescriptorTable->Base[Id]
  • 如果 Id >= Limit,即"调用号超过本表大小"------说明这是非法调用或 GUI 调用。
  • 如果 OffsetSERVICE_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 的关键设计点:

  1. lea edx, esp + 4与用户态 stub 的"edx 指向用户参数区"不同 ,内核态 stub 的 edx 指向当前内核栈 的"参数区"(在 call ZwCreateFile 之后,[esp+4] 之后就是第一个参数)。这是因为内核态调用者已经在内核栈上准备好了参数。
  2. pushfd + push KGDT_R0_CODE :这是最巧妙的设计 ------内核态 stub 手动模拟"用户态"框架 ,让 INT 2Eh 路径认为这次系统调用来自"用户态"但 CS 是 R0。
  3. 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 继续执行

可以看到两条调用链的中间段(KiSystemServiceHandlerNt* 实现)完全相同;区别仅在入口是 sysenter 还是 INT 2Eh,以及 PreviousMode 的设置。

2.3.6 小结

  • 系统调用号 来自 sysfuncs.lst,是一个 0-based 编号。
  • SSDTKSERVICE_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(...); ... } 呢?

原因有三条------都与工业级代码的维护和性能有关:

  1. 性能 :函数指针数组是 O(1) 查表,编译为一条 mov eax, [ebx + ecx*4] 指令。如果是 switch-case,编译器在 case 数量较大时会生成跳转表(也是数组),性能差不多------但关键在于不依赖编译器优化。SSDT 是一个显式的、手工控制的数据结构,性能完全可预测。

  2. 可扩展性------第二张表KeServiceDescriptorTableShadow 是 Win32k.sys 的服务表。GUI 线程需要访问 Win32k 提供的 API(窗口管理、GDI 绘图等)。如果只有一张主表,Win32k 就不得不与 NT 执行体"共享编号空间",导致编号冲突。通过 Thread->ServiceTable + Offset 的方式,第二张表可以独立于主表 ,不需要修改 KiSystemServiceHandler 的任何代码------只要在启动时填好描述符即可。这是"开放/封闭原则"的经典应用:对扩展开放,对修改封闭。

  3. 可维护性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 生成"带来两个关键好处:

  1. 保证两条路径的系统调用号一致 :如果 NtCreateFile 在用户态的 stub 中 mov eax, 30,在内核态的 ZwCreateFile stub 中也必然是 mov eax, 30。不可能出现"两个 stub 使用了不同的编号"的情况。

  2. 保证参数清理一致 :两条路径的 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 件事:

  1. 写返回码到 EAXTrapFrame->Eax = Status;。这是系统调用"返回 NTSTATUS"的最后一步------TrapFrame 即将被 iret/sysexit 恢复,其中 EAX 会被恢复到用户态的 eax 寄存器。
  2. 公共出口KiCommonExit(TrapFrame, FALSE);。这个函数是所有 trap 出口共享的"清理"步骤(详见 2.4.3)。
  3. 恢复 PreviousModeKeGetCurrentThread()->PreviousMode = (CCHAR)TrapFrame->PreviousPreviousMode;。把线程的 PreviousMode 恢复到进入系统调用前的状态。如果本次系统调用是用户态发起的,进入前 PreviousMode=UserMode(用户态主流程),恢复后还是 UserMode。
  4. 判断用户态/内核态出口KiUserTrap(TrapFrame) 返回 true 表示 TrapFrame 来自用户态(CS 是 R3)。
  5. 用户态出口
    • 如果 EFLAGS_TF(单步标志)打开,调用 KiSystemCallTrapReturn(TrapFrame),使用 iretd 返回(必须通过 iret 处理单步)。
    • 否则调用 KiFastCallExitHandler(TrapFrame),使用 sysexit 快速返回。
  6. 内核态出口 :调用 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 出口的"公共代码",处理以下几件事:

  1. APC 投递 :如果 Thread->ApcState.UserApcPending 且 PreviousMode=UserMode,把用户态 APC 投递出去(即修改 TrapFrame 的 Eip 为 ntdll!KiUserApcDispatcher,让返回用户态时进入 APC 派发)。这是 Windows"异步过程调用"机制的入口。
  2. 调试寄存器恢复TrapFrame->Dr7 = 0;。清空 DR7 调试控制寄存器。
  3. EFlags 调整:恢复用户态的 EFlags(如 IOPL 标志等)。
  4. 回调返回检查 :如果当前是从 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 则只做两件事:

  1. ECX 加载用户态 EIP,从 EDX 加载用户态 ESP。
  2. 把 CS/SS 设置为固定的用户态段选择子。

为什么不统一用 sysexit 因为在以下情况下 sysexit 不适用:

  1. 内核态返回内核态sysexit 专门设计为"ring 0 → ring 3"的返回,返回 ring 0 时必须走正常 trap return。
  2. 有单步调试sysexit 不处理 EFLAGS_TF(陷阱标志)。如果调试器设置了单步,必须走 iretd------它会正确恢复 EFLAGS 中的 TF 位,从而在返回用户态后的第一条指令触发调试异常 #DB。
  3. 异常/中断路径 :异常和中断在进入内核时可能已经压入了错误码(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* 实现末尾主动投递?

  1. 统一处理点 :系统调用、异常、中断三种路径最终都会走到 KiCommonExit。如果把 APC 投递放在每条路径的末尾,就需要维护三份代码。
  2. 语义正确 :APC 的语义是"在线程返回用户态之前,先执行一段用户态代码"。这意味着它必须在即将返回用户态的那个时刻触发------如果在 Nt* 内部就触发 APC,那么 APC 例程中的系统调用会导致嵌套 trap,PreviousMode 链会变得混乱。
  3. 与中断兼容 :时钟中断是周期性触发的。如果中断处理器检测到需要 APC,它可以把 APC 挂到线程上,然后由 KiCommonExit 统一处理------中断处理例程本身不需要知道用户态回调的细节。
(4) 为什么要设计"反向"回调------为什么不直接在内核里做?

KeUserModeCallback 是"反向系统调用":内核调用用户态函数,用户态函数通过 NtCallbackReturn 触发 INT 0x2B 回到内核。

为什么要设计这种"来回穿越"的机制? 直接在内核里完成不好吗?

关键原因是兼容性与隔离

  1. Win32 子系统需要调用用户态 DLL :某些 API(如窗口过程 WindowProc、钩子过程 HookProc)本质上是用户态代码------应用程序注册了这些回调函数,Win32k.sys 需要在合适的时刻"回过来调用它们"。如果内核直接在内核栈上调用用户态函数,用户态代码就拥有了 ring 0 权限------这是安全灾难。
  2. 隔离保护KeUserModeCallback 做的事情是:切回用户态 → 让用户态执行回调 → 用户态再调用 NtCallbackReturn 回到内核。整个过程中,用户态代码始终运行在 ring 3,没有特权。内核"借给"用户态一个临时的执行窗口,但控制权最终回到内核。
  3. 与正常系统调用对称NtCallbackReturnINT 0x2B 触发,由 KiCallbackReturnHandlertraphdlr.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 处理器中引入了 sysentersysexit 指令。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:放 SysCallId
  • EDX:放 &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 出口

sysexitsysenter 的对称指令。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

注意 ECXEDX 必须包含用户态 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)。
  • sysexitsysenter 的对称出口------ECX = 用户态 EIP、EDX = 用户态 ESP。
  • 退化机制KiFastSystemCallDisable = 1 时回退到 INT 2Eh 慢速路径。
  • x64 使用 syscall 指令,约定与 x86 不同但思路类似。

2.5.9 为什么要这样做

(1) 为什么 sysenter 不自动保存寄存器------Intel 的设计哲学是什么?

INT n 指令会自动压栈 EFLAGS/CS/EIP,特权级切换时还会压 SS/ESPsysenter 则什么都不压,只设置 CS/EIP/SS/ESP 到 MSR 中的预设值。这看起来"更原始",但 Intel 的设计选择其实非常清晰:

sysenter 是一个"最小切换"指令。它只做进入 ring 0 必需的事------设置段寄存器和指令指针,其余所有事情(保存寄存器、构造 TrapFrame)都交给操作系统。这样做有两个好处:

  1. 灵活性 :每个操作系统对 TrapFrame 的定义可能不同。Linux 的 struct pt_regs 与 ReactOS 的 KTRAP_FRAME 字段顺序完全不同。如果 sysenter 硬编码压栈顺序,操作系统就被迫用 Intel 指定的 TrapFrame 格式,内核代码的设计自由会被限制。
  2. 速度 :少压栈几个寄存器意味着更少的内存写入。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 间接着?

  1. 入口可替换 :内核在启动时检测 CPU 是否支持 sysenter。如果支持,就把 0x7FFE0300 处的函数指针设为 KiFastSystemCall(sysenter 路径);如果不支持,设为 KiIntSystemCall(INT 2Eh 路径)。用户态的 stub 不需要关心用哪条路径 ------它只做 call [0x7FFE0300]

  2. 版本兼容 :如果 Windows 未来引入了更快速的系统调用指令(如 Intel VT-x 提供的 VMCALL),只需修改 0x7FFE0300 处的指针,所有已经发布的应用程序(stub 已烧录在 ntdll.dll 中)自动受益------不需要重新编译。

  3. 与 x64 架构统一 :x64 上 syscall 是标准指令,不需要 KUSER_SHARED_DATA 作为"中间人"------stub 直接执行 syscall。但 x86 上这套"间接调用"机制仍然保留,用于在 sysenterINT 2Eh 之间切换。

你可以把 0x7FFE0300 理解成"系统调用的函数指针表"------内核在启动时填好指针,用户态的 stub 通过它间接调用。这是一种"内核掌握主动权,用户态完全被动"的设计。

(3) 为什么保留 INT 2Eh 退化路径------为什么不直接删除?

KiFastSystemCallDisablecpu.c:1045(file:///d:/reactos/ntoskrnl/ke/i386/cpu.c#L1045))是一个全局开关。当它被设置为 1 时,系统回退到 INT 2Eh 慢速路径。

保留退化路径不是出于"怀旧",而是出于工程稳健性

  1. 老 CPU 兼容性 :早期 Pentium(无 MMX、P5 架构)不支持 sysenter/sysexit。虽然这类机器已经非常稀少,但作为通用操作系统,ReactOS 必须支持它们。

  2. 虚拟化环境中的问题 :某些 hypervisor(如早期的 VirtualBox、VMware)对 sysenter 的虚拟化支持不完善。如果内核在虚拟环境中检测到 sysenter 行为异常,可以通过设置 KiFastSystemCallDisable = 1 回退到 INT 2Eh。这是"降级模式"------虽然慢,但至少能运行。

  3. 调试与测试 :内核开发者有时需要强制走 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 指向用户参数区" 形成对应------这样 KiSystemCallTrampolinerep movsd 就能从内核栈正确复制参数。

关键点 2:pushfdpush 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_CODEKiSystemServiceHandler 自然地知道"这是内核态调用方",把 PreviousMode 设为 KernelMode,从而让 Nt* 实现跳过 probe

2.6.3 PreviousMode 的"安全语义"

PreviousModeKTHREAD(线程对象)中是一个字节,定义在 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->PreviousModeKiSystemServiceHandler 入口处被覆盖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 时,发生以下事情:

  1. fastfat 处于内核态 :当前 Thread->PreviousMode 可能是 UserMode(如果是处理用户态写请求)或 KernelMode(如果是系统线程上下文)。
  2. ZwCreateKey stub 取出 SysCallId 到 eax、lea edx, [esp+4] 把参数区地址放到 edx、pushfdpush KGDT_R0_CODEcall _KiSystemService
  3. _KiSystemService 入口的 KiEnterTrap 看到 KI_PUSH_FAKE_ERROR_CODE 标志,按"伪错误码"模式构造 TrapFrame。TrapFrame 的 SegCs 字段被设为 KGDT_R0_CODE(R0)。
  4. KiSystemServiceHandler 看到 TrapFrame->CS 是 R0,KiUserTrap(TrapFrame) 返回 false。Thread->PreviousMode = KernelMode
  5. NtCreateKey 实现 看到 PreviousMode = KernelMode跳过 probe ,直接用传入的指针(oa 的地址)。
  6. NtCreateKey 内部走 Cm 模块(配置管理器)创建注册表键。
  7. 完成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(NtUserCreateWindowExNtUserGetMessage 等)需要大内核栈------因为 GDI/User 调用经常使用较大的局部变量与栈帧。普通线程的内核栈默认 12 KB,GUI 线程通常是 64 KB。

KiConvertToGuiThread(在 trap.s:184 附近的 PsConvertToGuiThread 中)会:

  1. 分配一个更大的内核栈。
  2. 切换 Thread->KernelStack 到新栈。
  3. 复制 TrapFrame 到新栈(这是为什么需要"在汇编中重新获取 EBP"------因为栈变了)。
  4. 设置 Thread->ServiceTable = KeServiceDescriptorTableShadow,让后续的 GUI 系统调用走 Win32k 表。

GUI 线程转换是一个一次性升级------一旦升级为 GUI 线程,线程就永久使用 GUI 表,直到线程结束。

2.6.6 NtCallbackReturn 与用户态回调

2.4.5 节提到 NtCallbackReturn 是"反向系统调用"。完整理解它需要先理解 KeUserModeCallback

KeUserModeCallbackusercall.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-76KiCallUserMode@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 触发,由 KiCallbackReturnHandlertraphdlr.c:1639-1663)处理,最终恢复 KeUserModeCallback 保存的栈帧。

2.6.7 内核态 system service 调用的其他途径

除了 Zw* 与 GUI 线程,ReactOS 还提供其他内核态系统调用机制:

  1. Ex/Ke 等前缀的内部 API :如 ExAllocatePoolWithTagKeWaitForSingleObject。这些不经过系统调用表,而是直接调用内部函数。它们不切换栈、不改变 PreviousMode------是真正的"内部函数调用"。

  2. Ob/Mm 等子系统 API :如 ObCreateObjectMmAllocatePagesForMdl。同样不经过系统调用表,但可能在内部进一步调用 Zw* 触发嵌套系统调用。

  3. HAL 函数 :如 HalGetInterruptVectorHalAllocateCommonBuffer。最底层的硬件抽象层调用。

这三类 API 之间的区别:

  • Zw*:经过系统调用表,会强制 PreviousMode = KernelMode。
  • Ke*/Ex*:直接内部调用,不改变 PreviousMode。
  • 底层 HAL:直接读写硬件。

驱动开发者应仅在需要系统服务级别能力时 使用 Zw*------大多数情况下直接使用 Ke*/Ex* 等内部 API 更高效。

2.6.8 小结

  • 内核态 stubZw*)通过 push KGDT_R0_CODE 模拟"用户态 + R0"的 CS 字段,让 KiSystemServiceHandler 把 PreviousMode 设为 KernelMode。
  • Nt* vs Zw*Nt* 是真正实现(语义随 PreviousMode 变化),Zw* 是内核态 stub(永远创建 KernelMode 上下文)。
  • 驱动中的约定 :主动发起系统服务时用 Zw*;处理用户请求时用 Nt*(实现入口)。
  • GUI 线程 通过 SERVICE_TABLE_TEST 触发,调用 KiConvertToGuiThread 升级为大栈线程。
  • KeUserModeCallback / NtCallbackReturn 是"反向"系统调用机制,用于内核调用用户态函数。

2.6.9 为什么要这样做

(1) 为什么 Zw* 要强制 PreviousMode=KernelMode------为什么不直接调用 Nt* 实现?

这是整个"内核态调用系统服务"机制中最核心的设计决策。考虑两种方案:

方案 A(当前 ReactOS 的做法) :驱动调用 ZwCreateKeyZw* stub(含 push KGDT_R0_CODE)→ _KiSystemServiceKiSystemServiceHandler 检测到 CS=R0 → 设 PreviousMode=KernelMode → 调用 NtCreateKey 实现。

方案 B(直觉做法) :驱动直接调用 NtCreateKey(内核态符号,对驱动可见)→ NtCreateKey 内部读取 KeGetPreviousMode() → 如果当前线程 PreviousMode=KernelMode,则跳过 probe。

为什么 ReactOS 选择方案 A?

答案是安全隔离与语义一致性

  1. 避免"PreviousMode 泄露" :假设驱动正在处理用户态的写请求(PreviousMode=UserMode)。如果驱动内部想"以驱动自身身份"打开一个注册表键,它不能直接调用 NtCreateKey------因为 NtCreateKey 会看到 PreviousMode=UserMode,对传入的指针/句柄做严格检查。这是对的,但不是驱动想要的语义。驱动想说"我(内核代码)请求这项操作",而不是"我代表用户请求这项操作"。

  2. 语义一致性Zw* 的命名就隐含了"内核态发起者"的语义。所有 Zw* 函数都有一个不变承诺------它们会把 PreviousMode 改为 KernelMode,再进入真正实现。这样 Nt* 实现中只需要判断"当前 PreviousMode 是什么",不需要区分"这个调用是来自用户态 stub 还是驱动"。

  3. 避免 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 函数,让驱动按需设置。但这个方案有两个致命问题:

  1. "忘记恢复"的风险 :驱动调用 KeSetPreviousMode(KernelMode) 后必须在返回前恢复旧值。如果驱动在某个错误路径上忘记恢复,接下来用户态的系统调用会看到 PreviousMode=KernelMode,这意味着 Probe 被跳过------攻击者可以构造内核态地址作为参数传入 Nt* 实现,实现任意读写。

  2. 难以审计 :每个使用 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 的线程上下文。

为什么不一开始就给每个线程分配大栈?

  1. 空间开销 :Windows 上一个进程可能有上百个线程。如果每个线程的内核栈都是 64 KB,仅内核栈就可能消耗数 MB 内存。对于非 GUI 线程(绝大多数 worker 线程、IO 线程、后台线程),这么大的栈是浪费。

  2. 启动性能 :创建线程时分配大栈会增加系统调用(NtCreateThread)的执行时间。

按需分配 的策略就很自然了:线程初始时只有 12 KB 的小栈,当它第一次触发 GUI 调用时,KiConvertToGuiThread 分配大栈并把 TrapFrame 拷贝过去。这个过程是"第一次调用时一次性付费",对绝大多数线程来说永远不需要付费。

你可以把它理解为"惰性初始化"(lazy initialization)模式的一种------只有在真正需要时才分配资源,这是所有资源受限系统的标准实践。

(4) 为什么需要"反向"系统调用 KeUserModeCallback------为什么不直接在用户态做?

KeUserModeCallback 的语义是:内核态临时"借用"当前线程的用户态上下文,去执行一段用户态代码,然后再回到内核。典型场景:

  • 窗口过程 :应用注册了 WindowProc,当 Win32k.sys 收到消息时需要调用这个用户态过程。
  • 钩子过程 :应用注册了 SetWindowsHookEx 的钩子回调,Win32k 在内核态分发消息时需要调用用户态的钩子函数。
  • 打印后台处理:打印机驱动需要与用户态组件配合工作。

为什么不把这些逻辑全放到内核态? 因为:

  1. 应用代码的不可信任:窗口过程是由应用编写的,可能有 bug 或恶意代码。在内核态直接调用应用代码等同于"把 ring 0 权限交给应用"------这是安全灾难。

  2. 历史原因 :Windows 16 位时代的窗口系统完全在用户态(USER.EXE / GDI.EXE)。Win32 子系统保留了这个模型,但把核心绘图与窗口管理移到了 Win32k.sys------用户态窗口过程仍然是必要的

  3. 可扩展性:许多应用自定义窗口行为(自绘控件、自定义消息处理)。如果把这些逻辑全放到内核态,应用就失去了定制能力。

因此 Win32k.sys 采用的是一个"半内核半用户态"的架构:消息路由、绘图优化、窗口管理在内核态;窗口过程、钩子过程、自定义绘图在用户态 ,通过 KeUserModeCallback / NtCallbackReturn 的"反向穿越"与内核交互。

这也解释了为什么"Windows 图形系统很复杂"------内核态与用户态之间需要频繁地"来回穿越",每一次穿越都要做完整的寄存器保存、参数拷贝、状态恢复。理解了这一点,你才能理解 ReactOS 中 Win32k!xxxSendMessageWin32k!xxxCallHookProc 等函数的实现逻辑。


2.7 本章小结

本章沿着"内核态入口 → 调度跳转 → 出口"这条主轴,把 ReactOS 在 x86 平台上对系统调用的实现一层一层剥开。读者读完后应当能形成以下 5 个核心结论:

  1. 系统调用是用户态/内核态边界的桥梁。CPU 提供三种"穿越边界"的机制:系统调用(用户主动)、异常(CPU 主动)、中断(硬件被动)。三种机制在 C 层汇入统一的入口架构。

  2. x86 系统调用有两条路径INT 2Eh(兼容、慢速)与 sysenter(快速、Pentium II+)。两条路径在汇编层有差异(一个 CPU 自动压栈、一个手动构造 TrapFrame),但在 C 层汇入同一个 KiSystemServiceHandler。这是 ReactOS 设计的精妙之处。

  3. 系统调用号到 Nt* 实现的查找 由 SSDT(系统服务调度表)完成。32 位系统调用号被拆为"表选择"(高 4 位)与"表内偏移"(低 12 位)两部分,通过 KeServiceDescriptorTable[Offset].Base[Id] 即可定位到真正的 Nt* 实现。

  4. Nt* 与 Zw* 的本质区别在于 PreviousMode:Nt* 是真正实现(按 PreviousMode 决定是否 probe 参数),Zw* 是内核态 stub(强制 PreviousMode=KernelMode)。驱动主动调用系统服务时用 Zw*。

  5. 快速系统调用 通过 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 层KiSystemServiceHandlerKiServiceExitKiCommonExit)。这样做的好处是:

  • C 代码比汇编易写、易读、易维护。
  • 未来引入新的穿越方式时,只需新增汇编入口,C 层不需要改动。
  • 统一的 C 入口确保了 PreviousMode、SEH 链、调试寄存器等"状态管理"的一致性------不会出现"路径 A 保存了调试寄存器但路径 B 忘了"这样的 bug。

原则二:语义由最外层声明,内部实现按语义工作

Nt*Zw* 的区别不在于实现------它们调用的是同一个 C 函数 。区别在于入口处声明的 PreviousMode 不同 :用户态 stub 让 PreviousMode=UserMode,内核态 stub 让 PreviousMode=KernelMode。真正的 Nt* 实现只需要读取 KeGetPreviousMode() 并据此决定是否 probe 参数。

这种"语义声明在上层,实现不区分来源"的模式有两个强大的好处:

  1. 安全边界清晰:内核代码永远不需要"猜测"调用者的意图------PreviousMode 已经明确声明了。
  2. 可组合 :驱动可以嵌套调用 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* 调用,最终都汇入对象管理器的 ObCreateObjectObInsertObject 等内部 API。

相关推荐
阿坤带你走近大数据1 小时前
flink的架构介绍
大数据·架构·flink
love530love1 小时前
Hermes-Agent 本地化部署与详细交互式配置实战指南 [LM Studio + QQ ]
人工智能·windows·python·aigc·agent·hermes·hermes-agent
小短腿的代码世界2 小时前
高性能订单路由与智能拆单算法:Qt在量化交易系统中的核心架构——毫秒级延迟下如何隐藏你的交易意图?
开发语言·qt·架构
阿正的梦工坊2 小时前
【Rust】20-Rust 编译器架构与 MIR/LLVM 优化管线
开发语言·架构·rust
小鹿软件办公2 小时前
微软推出 Windows 就绪打印功能,彻底解决打印机驱动难题
windows·microsoft
虾壳云官方2 小时前
【一步到位】OpenClaw 2.7.9 Windows 部署 + 激活 + 使用 (含安装包)
人工智能·windows·自动化·openclaw·小龙虾·openclaw安装·openclaw一键安装
霸道流氓气质2 小时前
Spring Boot 微服务中“调用第三方接口 → 数据加工 → 分接口返回“的完整架构实践
spring boot·微服务·架构
Linlingu2 小时前
OpenClaw接入钉钉企业内部机器人完整实操教程(Stream模式无公网部署)
人工智能·windows·机器人·钉钉·办公自动化·小龙虾
jike88ai2 小时前
Claude Code完整安装+API配置教程(Windows系统)
windows·gpt·node.js·claude·api中转·claude code·88api