KiSwitchToBootStack 函数分析
1. 概述
KiSwitchToBootStack 是一个内联函数,负责将当前 CPU 的栈从引导加载程序 (FreeLDR) 使用的栈切换到内核自己的初始化栈,并在新栈上预留陷阱帧和 NPX 帧空间,然后跳转到 KiSystemStartupBootStack 执行最终的内核引导初始化。这个函数从不返回。
文件位置: ntoskrnl/include/internal/i386/ke.h#L909-L948(file:///d:/reactos/ntoskrnl/include/internal/i386/ke.h#L909)
c
CODE_SEG("INIT")
FORCEINLINE
DECLSPEC_NORETURN
VOID
KiSwitchToBootStack(IN ULONG_PTR InitialStack)
函数属性
| 属性 | 含义 |
|---|---|
CODE_SEG("INIT") |
代码位于 INIT 段,初始化完成后可被丢弃/覆盖 |
FORCEINLINE |
强制内联(无论编译器的优化级别如何),因为此函数直接操作栈指针 |
DECLSPEC_NORETURN |
告知编译器此函数不会返回(跳转到 KiSystemStartupBootStack) |
IN ULONG_PTR InitialStack |
新栈的栈顶指针(传入时已 & ~3 对齐清除低 2 位) |
2. 调用上下文
2.1 调用位置
在 kiinit.c#L1005(file:///d:/reactos/ntoskrnl/ke/i386/kiinit.c#L1005) 由 KiSystemStartup 调用:
c
/* 提升 IRQL 到 HIGH_LEVEL,确保后续栈切换操作不会被中断 */
KeRaiseIrql(HIGH_LEVEL, &DummyIrql);
KiSwitchToBootStack(InitialStack & ~3);
2.2 调用链
KiSystemStartup(LoaderBlock)
→ KiSwitchToBootStack(InitialStack & ~3) [ke.h:916] ←★ 本文分析
→ jmp KiSystemStartupBootStack [kiinit.c:685]
→ KiInitializeKernel() [kiinit.c:433]
→ ExpInitializeExecutive() [kiinit.c:593]
→ KeSetPriorityThread(..., 0) → 触发调度器切换
→ KiIdleLoop() [永不返回]
2.3 参数 InitialStack 的来源
InitialStack 从 LoaderBlock->KernelStack 获取。在 BSP (Boot Strap Processor) 上,KiSystemStartup 的第 763 行将其设为 P0BootStack:
c
LoaderBlock->KernelStack = (ULONG_PTR)P0BootStack;
而 P0BootStack 定义在 kiinit.c#L19-L22(file:///d:/reactos/ntoskrnl/ke/i386/kiinit.c#L19):
c
UCHAR DECLSPEC_ALIGN(PAGE_SIZE) P0BootStackData[KERNEL_STACK_SIZE] = {0};
ULONG_PTR P0BootStack = (ULONG_PTR)&P0BootStackData[KERNEL_STACK_SIZE];
// P0BootStack 指向数组末尾 → 栈向下增长
3. 关键常量
| 常量 | 值 | 说明 |
|---|---|---|
NPX_FRAME_LENGTH |
0x210 (528) |
NPX (Numerical Processor eXtension) 帧长度,等于 sizeof(FX_SAVE_AREA),用于保存 FPU/MMX/SSE 寄存器状态 |
KTRAP_FRAME_ALIGN |
0x04 (4) |
陷阱帧对齐填充 |
KTRAP_FRAME_LENGTH |
0x8C (140) |
陷阱帧 (KTRAP_FRAME) 结构体大小 |
| 总和 | 0x2A0 (672) |
栈上预留的总空间 |
CR0_EM |
0x04 |
CR0.Emulation --- x87 FPU 仿真标志 |
CR0_TS |
0x08 |
CR0.Task Switched --- 任务切换标志 |
CR0_MP |
0x02 |
CR0.Monitor Coprocessor --- 监控协处理器标志 |
| OR 值 | 0x0E (14) |
入栈的 CR0 标志组合值 |
4. 执行流程详解
4.1 函数原型和前置声明
c
CODE_SEG("INIT") DECLSPEC_NORETURN VOID NTAPI KiSystemStartupBootStack(VOID);
函数体内首先声明了 KiSystemStartupBootStack 的原型,带有 CODE_SEG("INIT") 属性。此声明的作用是让内联汇编中的 jmp 指令可以正确引用该符号。
4.2 GCC 内联汇编版本
c
__asm__
(
"movl %0, %%esp\n\t" // 步骤 1
"subl %1, %%esp\n\t" // 步骤 2
"pushl %2\n\t" // 步骤 3
"jmp _KiSystemStartupBootStack@0" // 步骤 4
:
: "c"(InitialStack), // %0 = InitialStack (ECX 寄存器)
"i"(NPX_FRAME_LENGTH + KTRAP_FRAME_ALIGN + KTRAP_FRAME_LENGTH), // %1 = 立即数 0x2A0
"i"(CR0_EM | CR0_TS | CR0_MP), // %2 = 立即数 0x0E
"p"(KiSystemStartupBootStack) // %3 = 函数地址 (用于跳转)
: "%esp" // 告知编译器 ESP 被修改
);
步骤 1: movl %0, %%esp --- 切换栈指针
ESP = InitialStack
将栈指针切换到内核的启动栈 P0BootStack。这是不可逆操作 --- 从此之后,局部变量、返回地址等都在新栈上。
为什么不在栈上留返回地址? 因为下一步不是 call 而是 jmp(没有 call 指令的隐式 push return_address),所以栈顶从 InitialStack 开始就是全新的空栈。
步骤 2: subl %1, %%esp --- 预留帧空间
ESP = InitialStack - 0x2A0
ESP = InitialStack - (NPX_FRAME_LENGTH + KTRAP_FRAME_ALIGN + KTRAP_FRAME_LENGTH)
ESP = InitialStack - (528 + 4 + 140)
ESP = InitialStack - 672
在新栈上向下移动栈指针,预留出 672 字节的空间。这个空间将用于存放:
新栈布局 (从高地址到低地址):
┌─────────────────────────────┐ ← InitialStack (原 ESP)
│ │
│ NPX_FRAME (528 bytes) │ ← NPX/FPU 保存区
│ FX_SAVE_AREA │ (FNSAVE/FXSAVE 指令用)
│ │
├─────────────────────────────┤ ← InitialStack - 0x210
│ KTRAP_FRAME_ALIGN (4B) │ ← 对齐填充
├─────────────────────────────┤ ← InitialStack - 0x214
│ │
│ KTRAP_FRAME (140 bytes) │ ← 陷阱帧 (异常/中断上下文)
│ _KTRAP_FRAME │
│ │
├─────────────────────────────┤ ← InitialStack - 0x2A0 = ESP (预留后)
│ [已压入 CR0 标志] (4B) │ ← pushl 之后
├─────────────────────────────┤
│ │
│ 空闲栈空间 (向下增长) │
└─────────────────────────────┘
NPX_FRAME 用于在初始线程的上下文切换时保存 FPU 状态。KiInitializeKernel 初始化空闲线程时,会在栈上构造陷阱帧,使该线程看起来就像刚刚执行完一条 iret 指令并获得 CPU 控制权。
KTRAP_FRAME 是异常/中断发生时 CPU 自动或内核代码手动保存的寄存器上下文。在引导初始化阶段,内核先在栈上构造一个陷阱帧,然后通过 iret / sysexit 等方式切换到初始线程。
步骤 3: pushl %2 --- 保存 CR0 标志
[ESP] = 0x0E (CR0_EM | CR0_TS | CR0_MP)
ESP = ESP - 4
将 CR0_EM | CR0_TS | CR0_MP (= 0x0E) 压入栈顶。这个值在后续 KiInitializeKernel → KeInitSystem 中被读取和使用。
这三个标志位的含义:
| 标志 | 位 | 含义 | 用途 |
|---|---|---|---|
CR0_EM |
位 1 (0x02) | Monitor Coprocessor | 控制 WAIT/FWAIT 指令在任务切换时的行为 |
CR0_TS |
位 3 (0x08) | Task Switched | 每次任务切换时由硬件置位,表示 FPU 上下文可能已变更 |
CR0_MP |
位 4 (0x10) | Emulation | 置位时表示没有 x87 FPU,所有 FPU 指令触发 #NM 异常 |
为什么入栈这三个标志位?
KiInitializeKernel 内部的 KeInitSystem() 会读取 CR0 的这些标志位来判断当前的 FPU 状态。但如果直接读 CR0 寄存器,得到的是已经设置好的最终值(FreeLDR 和 KiVerifyCpuFeatures 已经设置过 CR0)。压入栈的这个值实际上是一个初始标记值,表示"尚未初始化 FPU 状态",这样初始化代码就可以正确地检测到需要初始化 FPU 上下文。
具体来说,当初始线程第一次切换到用户模式时,内核的 KiInitSystem() 看到 CR0_TS 和 CR0_EM 被置位,就知道需要设置 FPU 状态并清除这些位,然后再继续执行。
步骤 4: jmp _KiSystemStartupBootStack@0 --- 跳转到引导栈
EIP = KiSystemStartupBootStack
这是一个尾调用优化 --- 用 jmp 代替 call:
- 不会压入返回地址
- 不会改变栈的内容
KiSystemStartupBootStack会直接在当前准备好的栈上执行- 永不返回
4.3 MSVC 内联汇编版本
c
__asm
{
mov esp, InitialStack // 步骤 1: 切换栈
sub esp, (NPX_FRAME_LENGTH + // 步骤 2: 预留帧空间
KTRAP_FRAME_ALIGN +
KTRAP_FRAME_LENGTH)
push (CR0_EM | CR0_TS | CR0_MP) // 步骤 3: 保存 CR0 标志
jmp KiSystemStartupBootStack // 步骤 4: 跳转
}
MSVC 版本的功能与 GCC 版本完全相同,只是语法不同:
- MSVC 使用
__asm { ... }块而非__asm__("...") - MSVC 可以直接引用局部变量
InitialStack - 不需要像 GCC 一样使用寄存器约束 (
"c"约束)
4.4 UNREACHABLE
c
UNREACHABLE;
在两种汇编版本之后放置 UNREACHABLE 宏:
- 告知编译器控制流永远不会到达这里(不会生成
ret指令的返回代码) - 如果因为某些原因(如目标函数损坏)执行到达此处,会触发 CPU 异常或编译器特定的 UB 陷阱
5. 栈布局详细图解
切换前 (在 KiSystemStartup 的栈上)
KiSystemStartup 的栈帧:
┌─────────────────────────────┐
│ KiSystemStartup 局部变量 │
│ (DummyIrql, Pcr, Tss, ...) │
├─────────────────────────────┤ ← ESP (FreeLDR 的栈)
│ 返回地址到 FreeLDR │
├─────────────────────────────┤
│ FreeLDR 的栈帧 │
└─────────────────────────────┘
切换后 (在 P0BootStack 上)
P0BootStack 内存布局 (高 → 低):
┌─────────────────────────────┐ ← P0BootStack = InitialStack
│ │ (P0BootStackData 数组末尾)
│ │
│ ← 空闲空间 (可被使用) │
│ │
├─────────────────────────────┤ ← InitialStack - 0x210
│ NPX_FRAME │
│ (FX_SAVE_AREA, 528 字节) │
│ --- 用于保存 FPU/MMX/SSE │
│ 寄存器状态的区域 │
├─────────────────────────────┤ ← InitialStack - 0x214
│ KTRAP_FRAME_ALIGN (4 字节)│
│ --- 对齐填充到 8 字节边界 │
├─────────────────────────────┤ ← InitialStack - 0x218
│ KTRAP_FRAME │
│ (140 字节) │
│ --- 陷阱帧 │
│ --- 包含: EBP, EBX, ESI, │
│ EDI, EAX, ECX, EDX, │
│ DS, ES, FS, GS, SegCs, │
│ SegSs, EFlags, ESP, │
│ ErrCode, TrapFrame │
├─────────────────────────────┤ ← InitialStack - 0x2A0
│ CR0 Flags (0x0E) │ ← ESP 在此 (pushl 之后)
│ --- CR0_MP | CR0_EM | │
│ CR0_TS 的组合位 │
├─────────────────────────────┤ ← 新 ESP (jmp 之后)
│ ← 空闲栈空间 │
│ (向下增长) │
└─────────────────────────────┘
6. 与 KiSystemStartupBootStack 的衔接
当 jmp 到 KiSystemStartupBootStack 时,栈的状态为:
ESP → [CR0_Flags = 0x0E] (4 字节)
[NPX_Frame 空间] (528 字节)
[对齐填充] (4 字节)
[KTRAP_Frame 空间] (140 字节)
← InitialStack 顶部
然后 kiinit.c#L685-L710(file:///d:/reactos/ntoskrnl/ke/i386/kiinit.c#L685) 的 KiSystemStartupBootStack 执行:
c
KiSystemStartupBootStack(VOID)
{
// 调用 KiInitializeKernel,它使用当前栈帧
KiInitializeKernel(&KiInitialProcess.Pcb,
(PKTHREAD)KeLoaderBlock->Thread,
(PVOID)(KeLoaderBlock->KernelStack & ~3),
(PKPRCB)__readfsdword(KPCR_PRCB),
KeNumberProcessors - 1,
KeLoaderBlock);
// ...
// 进入空闲循环
KiIdleLoop();
}
KiInitializeKernel 内部 (特别是 KeInitSystem) 会使用栈上的 NPX 帧和陷阱帧来初始化空闲线程的初始上下文。当设置空闲线程优先级为 0 时,调度器执行上下文切换,从栈上的陷阱帧恢复寄存器并用 iret 切换到初始线程。
7. 设计要点总结
7.1 为什么用 jmp 而不是 call
用 jmp 而不是 call 是因为这是一个栈切换操作 。如果用 call,CPU 会将返回地址压入当前栈 (也就是旧的 FreeLDR 栈),然后代码才切换 ESP 到新栈,这样返回地址就留在了旧栈上。而 KiSystemStartupBootStack 是永远不会返回的,所以不需要保存返回地址。jmp 直接跳转,不在栈上产生任何额外数据。
7.2 FORCEINLINE 的必要性
此函数被标记为 FORCEINLINE,意味着它在 KiSystemStartup 中被内联展开而不是调用。这是因为:
- 该函数直接修改
ESP寄存器,如果作为被调函数执行,编译器会为函数入口生成push ebp; mov ebp, esp,函数返回时会leave; ret,这些操作都在旧栈上进行,干扰栈切换 - 强制内联后,汇编代码直接嵌入到
KiSystemStartup的函数体中,栈切换发生时直接跳转到新的执行路径
7.3 完整的栈过渡流程
引导加载程序 (FreeLDR) 的栈
↓ KiSystemStartup 被调用
KiSystemStartup 使用 FreeLDR 的栈执行初始化
↓ InitialStack = P0BootStack
KiSwitchToBootStack 切换 ESP 到 P0BootStack
↓ jmp KiSystemStartupBootStack
在新栈上继续执行
↓ KiInitializeKernel
↓ KeSetPriorityThread(..., 0) → 调度器切换
P0BootStack 被保存为内核栈
↓ 初始线程获得 CPU 控制权
系统继续初始化...
7.4 栈上的 CR0 标志位的作用
栈上保存的 CR0_EM | CR0_TS | CR0_MP 组合标志位是 x86 传统上用于延迟 FPU 上下文切换的机制。CR0_TS 在每次任务切换时被 CPU 自动置位,当下一次 FPU 指令被执行时触发 #NM (Device Not Available, 中断 7) 异常,内核在异常处理程序中保存前一个任务的 FPU 状态、恢复当前任务的 FPU 状态,然后清除 CR0_TS。通过将初始标志压入栈,内核初始化代码可以正确地参与到这个机制中。