# 线程切换概述

在 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 的交叉引用:

# 小结

  1. Windows 中绝大部分 API 最终都会调用 SwapContext,也就是说,调用 API 就可能导致线程切换
  2. 线程切换时会比较源线程和目标线程是否属于同一个进程,如果不是,则切换 CR3——CR3 一换,进程的地址空间也就切换了

思考

  • 如果当前线程不调用任何 API,能否一直占用 CPU?
  • 如果当前线程不主动调用系统 API,操作系统如何实现线程切换?

答案:现代操作系统通过硬件中断(如时钟中断)周期性地触发调度机制,即使线程不主动调用 API,也会被强制切换。


# 时钟中断

中断一个正在执行的程序有两种方式:

  1. 异常:如缺页异常、除零异常等
  2. 中断:如时钟中断、I/O 完成中断等

# 系统时钟

  1. 在 Windows 中,每隔 10~20 毫秒便会触发一次时钟中断
  2. 可使用 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

# 关于 "抢占式" 调度

  1. 如果一个线程不调用 API、在代码中屏蔽中断(在 0 环执行 CLI 指令;3 环无权执行 CLI ,需通过其他方式)、并且不会出现异常,那么该线程将永久占有 CPU
  2. 在单核 CPU 上表现为 CPU 占用率 100%,双核 CPU 上表现为 50%
  3. 严格来说,Windows 的 "抢占" 依赖于中断机制 —— 必须是当前线程允许被中断(未屏蔽中断),其他线程才能 "抢" 到 CPU

# 时间片管理

时钟中断最终可能导致线程切换,但并非每次时钟中断都会触发线程切换

时钟中断发生时,以下两种情况会导致线程切换:

  1. 当前线程的 CPU 时间片到期
  2. 存在备用线程KPCR.PrcbData.NextThread 不为空)

# CPU 时间片

  1. 新线程开始执行时,初始化程序会在 _KTHREAD.Quantum 赋初始值,该值大小由 _KPROCESS.ThreadQuantum 决定
  2. 每次时钟中断调用 KeUpdateRunTime ,该函数每次将当前线程的 Quantum 减少 3 个单位,若减至 0 或以下,则将 KPCR.PrcbData.QuantumEnd 设置为非零值
  3. 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

背景

  1. FS:[0] 在 3 环指向 TEB,进入 0 环后指向 KPCR
  2. 系统中有多个线程,意味着 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 完成的关键操作(按实际执行顺序):

  1. 保存当前线程的 ESP 到 _KTHREAD.KernelStack
  2. 更新 TSS.ESP0 为目标线程的内核栈顶(下次从 3 环进 0 环时使用)
  3. 切换 ESP 到目标线程的 _KTHREAD.KernelStack (此刻目标线程 "复活")
  4. 更新 KPCR 中的 TEB 指针
  5. 若跨进程切换,切换 CR3 为目标进程的页目录表基址
  6. 修改 GDT 中 FS 段描述符的基地址(指向目标线程的 TEB)