# 线程切换概述
在 Windows 中,线程切换的核心实现位于 SwapContext 函数。以下将从上到下逐层分析。
# 分析 KiSwapContext
KiSwapContext 是线程切换的入口函数,负责保存 / 恢复寄存器并调用 SwapContext 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| .text:0040580E @KiSwapContext@4 proc near .text:0040580E .text:0040580E sub esp, 10h
; 保存当前线程的寄存器现场 .text:00405811 mov [esp+10h+var_4], ebx .text:00405815 mov [esp+10h+var_8], esi .text:00405819 mov [esp+10h+var_C], edi .text:0040581D mov [esp+10h+var_10], ebp
; FS:[0x1C] = KPCR.SelfPcr,获取当前 CPU 的 KPCR .text:00405820 mov ebx, large fs:1Ch
; ECX 中存储要切换到的目标线程的 _KTHREAD(由调用者传入) .text:00405827 mov esi, ecx
; 取出当前正在运行的线程的 _KTHREAD .text:00405829 mov edi, [ebx+_KPCR.PrcbData.CurrentThread]
; 将目标线程设置为 CurrentThread .text:0040582F mov [ebx+_KPCR.PrcbData.CurrentThread], esi .text:00405835 mov cl, [edi+58h]
; 调用 SwapContext 完成实际的上下文切换 .text:00405838 call SwapContext
; SwapContext 返回后,ESP 已经属于新线程 ; 恢复新线程的寄存器 .text:0040583D mov ebp, [esp+10h+var_10] .text:00405840 mov edi, [esp+10h+var_C] .text:00405844 mov esi, [esp+10h+var_8] .text:00405848 mov ebx, [esp+10h+var_4] .text:0040584C add esp, 10h .text:0040584F retn .text:0040584F @KiSwapContext@4 endp
|
其中 ECX (目标线程)的来源是 KiSwapThread:
1 2 3 4 5 6 7 8 9
| .text:0040AB8A @KiSwapThread@0 proc near ... ; 从调度链表中找到一个就绪线程,返回其 _KTHREAD .text:0040ABAD call @KiFindReadyThread@8 ... .text:0040ABBB mov ecx, eax ... ; 通过 KiSwapContext 切换到该线程 .text:0040ABBD call @KiSwapContext@4
|
# 分析 SwapContext
SwapContext 是线程切换的核心函数,完成堆栈切换和 CR3 切换。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| .text:00405932 SwapContext proc near ...
; ====== 第一步:保存当前线程的 ESP ====== .text:00405983 mov [edi+_ETHREAD.Tcb.KernelStack], esp
; ====== 第二步:获取目标线程的栈信息 ====== ; 目标线程栈底 .text:00405986 mov eax, [esi+_ETHREAD.Tcb.InitialStack] .text:00405989 mov ecx, [esi+_ETHREAD.Tcb.StackLimit]
; 减去浮点寄存器保存区(_FX_SAVE_AREA,0x210 字节),计算写入 TSS.ESP0 的值 .text:0040598C sub eax, 210h ...
; ====== 第三步:更新 TSS.ESP0 ====== ; 取出当前 CPU 的 TSS .text:004059BD mov ecx, [ebx+_KPCR.TSS]
; 将目标线程的内核栈顶写入 TSS.ESP0 ; 下次从 3 环进 0 环时使用此值 .text:004059C0 mov [ecx+4], eax
; ====== 第四步:切换 ESP ====== ; 此行之后,当前线程"死亡",目标线程"复活" .text:004059C3 mov esp, [esi+_ETHREAD.Tcb.KernelStack]
; ====== 第五步:更新 KPCR 中的 TEB 指针 ====== .text:004059C6 mov eax, [esi+_ETHREAD.Tcb.Teb] .text:004059C9 mov [ebx+_KPCR.NtTib.Self], eax
; ====== 第六步:判断是否需要切换 CR3 ====== .text:004059CC sti .text:004059CD mov eax, [edi+_ETHREAD.Tcb.ApcState.Process] .text:004059D0 cmp eax, [esi+_ETHREAD.Tcb.ApcState.Process] .text:004059D3 mov [edi+_ETHREAD.Tcb.IdleSwapBlock], 0
; 如果两个线程属于同一进程,跳过 CR3 切换 .text:004059D7 jz short loc_405A19
; 不同进程:取出目标线程的 _KPROCESS .text:004059D9 mov edi, [esi+_ETHREAD.Tcb.ApcState.Process] ...
; 取出目标进程的 CR3(页目录表基址) .text:00405A01 mov eax, [edi+_EPROCESS.Pcb.DirectoryTableBase] .text:00405A04 mov ebp, [ebx+_KPCR.TSS]
; 将 CR3 写入 TSS .text:00405A0A mov [ebp+1Ch], eax
; 切换 CR3——进程地址空间随之切换 .text:00405A0D mov cr3, eax ... .text:00405AA1 SwapContext endp
|
# 分析 KiSwapThread
查看 KiSwapThread 的交叉引用表,可以看到大量函数调用了它:
再查看其中 KeWaitForSingleObject 的交叉引用:
# 小结
- Windows 中绝大部分 API 最终都会调用 SwapContext,也就是说,调用 API 就可能导致线程切换
- 线程切换时会比较源线程和目标线程是否属于同一个进程,如果不是,则切换 CR3——CR3 一换,进程的地址空间也就切换了
思考:
- 如果当前线程不调用任何 API,能否一直占用 CPU?
- 如果当前线程不主动调用系统 API,操作系统如何实现线程切换?
答案:现代操作系统通过硬件中断(如时钟中断)周期性地触发调度机制,即使线程不主动调用 API,也会被强制切换。
# 时钟中断
中断一个正在执行的程序有两种方式:
- 异常:如缺页异常、除零异常等
- 中断:如时钟中断、I/O 完成中断等
# 系统时钟
- 在 Windows 中,每隔 10~20 毫秒便会触发一次时钟中断
- 可使用 Win32 API
GetSystemTimeAdjustment 获取当前的时钟间隔值
时钟中断的执行流程:
# 分析 INT 0x30
通过 IDA 的 ALT+T 功能定位 IDT 表中的 INT 0x30 处理函数:
_KiStartUnexpectedRange 调用了 _KiEndUnexpectedRange :
1 2 3
| .text:00405E80 _KiStartUnexpectedRange@0 proc near .text:00405E80 push 30h ; 中断号 0x30 .text:00405E85 jmp _KiEndUnexpectedRange@0
|
_KiEndUnexpectedRange 跳转到 _KiUnexpectedInterruptTail :
1 2 3
| .text:00406667 _KiEndUnexpectedRange@0 proc near .text:00406667 jmp cs:off_40666E .text:0040666E off_40666E dd offset _KiUnexpectedInterruptTail
|
KiUnexpectedInterruptTail 调用了 HAL.dll 中的两个函数:
1 2 3 4 5
| .text:00407149 _KiUnexpectedInterruptTail proc near ... .text:00407203 call ds:__imp__HalBeginSystemInterrupt@12 ... .text:00407213 call ds:__imp__HalEndSystemInterrupt@8
|
这两个函数来自 HAL.dll(硬件抽象层):
# 分析 HAL.dll
# HalEndSystemInterrupt
HalEndSystemInterrupt 最终调用了 KiDispatchInterrupt :
1 2 3 4 5
| .text:80012F0C HalEndSystemInterrupt proc near ... .text:80012F5D call ds:KiDispatchInterrupt ... .text:80012F66 HalEndSystemInterrupt endp
|
KiDispatchInterrupt 来自 ntoskrnl.exe:
最终, KiDispatchInterrupt 调用了 SwapContext ,完成线程切换:
1 2 3 4 5
| .text:00405852 _KiDispatchInterrupt@0 proc near ... .text:004058F4 call SwapContext ... .text:00405923 _KiDispatchInterrupt@0 endp
|
# 关于 "抢占式" 调度
- 如果一个线程不调用 API、在代码中屏蔽中断(在 0 环执行
CLI 指令;3 环无权执行 CLI ,需通过其他方式)、并且不会出现异常,那么该线程将永久占有 CPU
- 在单核 CPU 上表现为 CPU 占用率 100%,双核 CPU 上表现为 50%
- 严格来说,Windows 的 "抢占" 依赖于中断机制 —— 必须是当前线程允许被中断(未屏蔽中断),其他线程才能 "抢" 到 CPU
# 时间片管理
时钟中断最终可能导致线程切换,但并非每次时钟中断都会触发线程切换。
时钟中断发生时,以下两种情况会导致线程切换:
- 当前线程的 CPU 时间片到期
- 存在备用线程(
KPCR.PrcbData.NextThread 不为空)
# CPU 时间片
- 新线程开始执行时,初始化程序会在
_KTHREAD.Quantum 赋初始值,该值大小由 _KPROCESS.ThreadQuantum 决定
- 每次时钟中断调用
KeUpdateRunTime ,该函数每次将当前线程的 Quantum 减少 3 个单位,若减至 0 或以下,则将 KPCR.PrcbData.QuantumEnd 设置为非零值
KiDispatchInterrupt 检查 QuantumEnd ,若非零则调用 KiQuantumEnd 重新设置时间片并查找下一个线程
# 分析 KeUpdateRunTime
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| .text:0040B764 _KeUpdateRunTime@4 proc near ...
; 每次减 3 .text:0040B89A sub [ebx+_KTHREAD.Quantum], 3 .text:0040B89E jg short loc_40B8B9
; 若是空闲线程,不标记时间片到期 .text:0040B8A0 cmp ebx, [eax+_KPCR.PrcbData.IdleThread] .text:0040B8A6 jz short loc_40B8B9
; Quantum <= 0:标记时间片到期 ; 将 QuantumEnd 设置为非零值 .text:0040B8A8 mov [eax+_KPCR.PrcbData.QuantumEnd], esp .text:0040B8AE mov ecx, 2 .text:0040B8B3 call ds:__imp_@HalRequestSoftwareInterrupt@4 ... .text:0040B8BA _KeUpdateRunTime@4 endp
|
# 分析 KiDispatchInterrupt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| .text:00405852 _KiDispatchInterrupt@0 proc near ...
; 判断时间片是否到期 .text:00405882 cmp [ebx+_KPCR.PrcbData.QuantumEnd], 0
; 若到期,跳转到时间片处理逻辑 .text:00405889 jnz loc_405910 ...
; ====== 时间片到期处理 ====== .text:00405910 loc_405910: ; 清除 QuantumEnd 标志 .text:00405910 mov [ebx+_KPCR.PrcbData.QuantumEnd], 0
; 重新设置时间片,并查找下一个就绪线程 .text:0040591A call _KiQuantumEnd@0 .text:0040591F or eax, eax .text:00405921 jnz short loc_4058C1 ... .text:00405923 _KiDispatchInterrupt@0 endp
|
# 分析 KiQuantumEnd
KiQuantumEnd 负责重新设置时间片并寻找下一个要运行的线程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| .text:00413387 _KiQuantumEnd@0 proc near ...
; 重新设置时间片:从 KPROCESS.ThreadQuantum 取值 .text:004133C5 mov al, [eax+_KPROCESS.ThreadQuantum] .text:004133C8 mov [esi+_KTHREAD.Quantum], al ...
; 查找下一个就绪线程 .text:004133F7 call @KiFindReadyThread@8 .text:004133FC cmp eax, ebx .text:004133FE jnz loc_4134B5 ; 找到则跳转 ... .text:00413419 retn .text:00413423 _KiQuantumEnd@0 endp
|
KiQuantumEnd 返回后,若找到就绪线程, KiDispatchInterrupt 将执行线程切换:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| .text:004058C1 loc_4058C1: .text:004058C1 sub esp, 0Ch .text:004058C4 mov [esp+0Ch+var_4], esi .text:004058C8 mov [esp+0Ch+var_8], edi .text:004058CC mov [esp+0Ch+var_C], ebp
; 将就绪线程存入 ESI .text:004058CF mov esi, eax .text:004058D1 mov edi, [ebx+_KPCR.PrcbData.CurrentThread] .text:004058D7 mov [ebx+_KPCR.PrcbData.NextThread], 0
; 更新 CurrentThread 为新线程 .text:004058E1 mov [ebx+_KPCR.PrcbData.CurrentThread], esi .text:004058E7 mov ecx, edi .text:004058E9 mov byte ptr [edi+50h], 1
; 将原线程挂回调度链表 .text:004058ED call @KiReadyThread@4 .text:004058F2 mov cl, 1
; 执行上下文切换 .text:004058F4 call SwapContext
|
KiReadyThread 将原线程按优先级挂载到对应的调度链表中:
1 2 3 4 5 6 7 8
| .text:00405657 @KiReadyThread@4 proc near ... .text:00405706 mov byte ptr [esi+2Dh], 1 ; KTHREAD.State = 1(就绪) .text:0040570A add esi, 60h ; ESI 指向 _KTHREAD+0x60(WaitListEntry) .text:00405711 cmp [ebp+var_1], 0 .text:00405718 lea ecx, _KiDispatcherReadyListHead[eax*8] ... .text:00405739 @KiReadyThread@4 endp
|
# 备用线程(NextThread)
除了时间片到期, KiDispatchInterrupt 还会检查是否存在备用线程:
1 2 3 4 5 6 7 8 9 10 11 12
| .text:00405852 _KiDispatchInterrupt@0 proc near ...
; 判断备用线程是否存在 .text:0040588F cmp [ebx+_KPCR.PrcbData.NextThread], 0 ...
; 若存在,将原线程挂回调度链表,切换到备用线程 .text:004058ED call @KiReadyThread@4 .text:004058F4 call SwapContext ... .text:00405923 _KiDispatchInterrupt@0 endp
|
# 线程切换与 TSS
背景:Intel 设计 TSS 的初衷是用于硬件任务切换,但 Windows 和 Linux 都没有使用这种方式,而是通过堆栈切换来保存和恢复线程的寄存器。
问题:一个 CPU 只有一个 TSS,但线程却有很多个,如何用一个 TSS 保存所有线程的 ESP0 ?
答案:TSS 中只保存当前线程的 ESP0 。每次线程切换时, SwapContext 会将目标线程的内核栈顶写入 TSS.ESP0 。
# 内核堆栈
# 调用 API 进 0 环
- 普通调用(
INT 0x2E ):通过 TSS.ESP0 获取 0 环堆栈
- 快速调用(
sysenter ):先从 MSR 寄存器获取临时 0 环栈,代码执行后仍然通过 TSS.ESP0 获取当前线程的 0 环堆栈
1 2 3 4 5 6 7 8
| .text:0040688F _KiFastCallEntry proc near ... ; KPCR+0x40 = TSS .text:0040689C mov ecx, large fs:40h
; TSS+0x4 = ESP0(当前线程的 0 环栈顶) .text:004068A3 mov esp, [ecx+4] ...
|
# SwapContext 中的 TSS 操作
线程切换时, SwapContext 会更新 TSS 中的关键字段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| .text:00405932 SwapContext proc near ...
; 检查 EFLAGS 的 VM 位(Bit 17),判断是否为 V86 模式 ; 若是 V86 模式(VM=1),跳过减法(因为栈中已包含 V86 的 4 个额外段寄存器) ; 若不是 V86 模式,减去 0x10(跳过 _KTRAP_FRAME 中 V86 段寄存器的预留空间) .text:004059B1 test dword ptr [eax-1Ch], 20000h .text:004059B8 jnz short loc_4059BD .text:004059BA sub eax, 10h
; 取出当前 CPU 的 TSS .text:004059BD mov ecx, [ebx+_KPCR.TSS]
; 将目标线程的内核栈顶写入 TSS.ESP0 ; 这样下次从 3 环进入 0 环时,CPU 会使用目标线程的内核栈 .text:004059C0 mov [ecx+4], eax
; 切换 ESP 到目标线程 .text:004059C3 mov esp, [esi+_ETHREAD.Tcb.KernelStack]
; 更新 KPCR 中的 TEB 指针 .text:004059C6 mov eax, [esi+_ETHREAD.Tcb.Teb] .text:004059C9 mov [ebx+_KPCR.NtTib.Self], eax
|
跨进程线程切换时的 CR3 操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| ; 此前 xor eax, eax 将 EAX 置零 ; GS 段寄存器清零(这解释了为什么 3 环单步执行时 GS 会被清零) .text:004059FF mov gs, eax
; 取出目标进程的 CR3 .text:00405A01 mov eax, [edi+_EPROCESS.Pcb.DirectoryTableBase] .text:00405A04 mov ebp, [ebx+_KPCR.TSS]
; 将目标进程的 CR3 写入 TSS .text:00405A0A mov [ebp+1Ch], eax
; 切换 CR3,地址空间随之切换 .text:00405A0D mov cr3, eax
; 存储 IO 权限位图到 TSS(Windows 2000 后不再使用) .text:00405A10 mov [ebp+66h], cx
|
# 线程切换与 FS
背景:
FS:[0] 在 3 环指向 TEB,进入 0 环后指向 KPCR
- 系统中有多个线程,意味着
FS:[0] 在 3 环需要指向多个不同的 TEB(每个线程一份)
问题:不同线程在 3 环查看 FS 寄存器时,段选择子都是相同的( 0x3B ),那么如何通过同一个 FS 段选择子指向不同的 TEB?
答案:线程切换时, SwapContext 会修改 GDT 中 FS 段描述符的基地址。
# SwapContext 中的 FS 操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| .text:00405932 SwapContext proc near ...
; 取出目标线程的 TEB 地址 .text:00405A24 mov eax, [ebx+_KPCR.NtTib.Self]
; 获取 GDT 基址 .text:00405A27 mov ecx, [ebx+_KPCR.GDT]
; 修改 GDT 中 FS 段描述符的基地址 ; FS 段选择子为 0x3B,Index = 0x3B >> 3 = 7,描述符起始于 GDT+0x38 ; AX = TEB 地址的低 16 位,写入描述符偏移 +2 处(Base[15:0]) .text:00405A2A mov [ecx+3Ah], ax
; 右移 16 位,得到 TEB 地址的高 16 位 .text:00405A2E shr eax, 10h
; 写入描述符偏移 +4 处的 Byte 0(Base[23:16]) .text:00405A31 mov [ecx+3Ch], al
; 写入描述符偏移 +4 处的 Byte 3(Base[31:24]) .text:00405A34 mov [ecx+3Fh], ah
; 线程切换计数 +1 .text:00405A37 inc [esi+_ETHREAD.Tcb.ContextSwitches] .text:00405A3A inc [ebx+_KPCR.PrcbData.KeContextSwitches] ... .text:00405AA1 SwapContext endp
|
通过修改 GDT 中 FS 段描述符的基地址,使得不同线程在 3 环通过同一个 FS 段选择子访问到各自不同的 TEB。
# 总结
三种情况会导致线程切换:
| 触发方式 |
调用链 |
| 主动调用 API |
KiSwapThread → KiSwapContext → SwapContext |
| CPU 时间片到期 |
KiDispatchInterrupt → KiQuantumEnd → SwapContext |
| 存在备用线程 |
KiDispatchInterrupt → SwapContext |
线程切换时 SwapContext 完成的关键操作(按实际执行顺序):
- 保存当前线程的 ESP 到
_KTHREAD.KernelStack
- 更新
TSS.ESP0 为目标线程的内核栈顶(下次从 3 环进 0 环时使用)
- 切换 ESP 到目标线程的
_KTHREAD.KernelStack (此刻目标线程 "复活")
- 更新 KPCR 中的 TEB 指针
- 若跨进程切换,切换 CR3 为目标进程的页目录表基址
- 修改 GDT 中 FS 段描述符的基地址(指向目标线程的 TEB)