进程线程篇——线程切换(下)

写在前面

  此系列是本人一个字一个字码出来的,包括示例和实验截图。由于系统内核的复杂性,故可能有错误或者不全面的地方,如有错误,欢迎批评指正,本教程将会长期更新。 如有好的建议,欢迎反馈。码字不易,如果本篇文章有帮助你的,如有闲钱,可以打赏支持我的创作。如想转载,请把我的转载信息附在文章后面,并声明我的个人信息和本人博客地址即可,但必须事先通知我

你如果是从中间插过来看的,请仔细阅读 羽夏看Win系统内核——简述 ,方便学习本教程。

  看此教程之前,问几个问题,基础知识储备好了吗?保护模式篇学会了吗?系统调用篇学会了吗?练习做完了吗?没有的话就不要继续了。


🔒 华丽的分割线 🔒


线程切换途径

  上一篇我们介绍了线程切换的基本概念并在3环模拟了线程切换,在Windows中是如何切换线程的呢?模拟线程切换有一个函数SwitchContext,调用它就能实现模拟的线程切换,而Windows中有一个函数SwapContext用来实现线程切换。要想确切地了解,我们先知道导致Windows中线程切换的途径。

主动切换

  我们在应用层学习线程切换的时候都是说,当执行完某一个线程的时候,线程都是被切换成另一个线程。为什么我说这一句,是因为这句话的意思就是线程是被切换的,是被动的。而事实上,线程切换绝大多数是主动切换的,当然也有被动切换,下面部分将会介绍到。
  之前所有的学习,为了降低学习难度并防止出现个人难以理解的莫名其妙的情况,我们把虚拟机调整成单核的。既然线程是运行的,而CPU正是运行代码的。多个CPU想想似乎还有线程被切换的可能性,但事实上我们单个也能够运行的好好的,这就说明线程是不可能“被”切换的,因为CPU拿不出另一个手来干活了。
  Windows通过SwapContext用来实现线程切换,我们用IDA来初步认识一下,先查找一下它的引用:

  然后我们看一下引用SwapContext的函数KiSwapContext的引用:

  然后再看看里面的唯一引用KiSwapThread的引用:

  然后再看看里面的一个函数KeWaitForSingleObject的引用:

  可以发现,有大量的函数都会调用我们的SwapContext,这个仅仅是一个小小的缩影。由于篇幅限制就不再展示,自己有兴趣可以看看这个函数的交叉引用的数量到底多么大。Windows中绝大部分API都调用了SwapContext函数也就是说,当线程只要调用了API,就是导致线程切换。
  代码是在内存中的,如果线程不属于一个进程,如果Cr3不切换的话,明显是不行的。线程切换时会比较是否属于同一个进程,如果不是,切换Cr3Cr3换了,进程也就切换了。
  如果不调用API,就可以一直占用CPU吗?

时钟中断

  绝大部分系统内核函数都会调用SwapContext函数,来实现线程的切换,那么这种切换是线程主动调用的。那如果当前的线程不去调用系统API,操作系统如何实现线程切换呢?那就靠时钟中断了,这个是被动切换。
  我们可以通过中断异常来实现中断一个正在执行的程序。其中,时钟中断也是一种中断,中断号0x30Windows系列操作系统为10-20毫秒。如要获取当前的时钟间隔值,可使用GetSystemTimeAdjustment这个API进行获取。如下示意图就是对时钟中断执行时的流程示意图以供了解:

graph TD KiStartUnexpectedRange --> KiEndUnexpectedRange --> KiUnexpectedInterruptTail --> HalBeginSystemInterrupt --> HalEndSystemInterrupt --> KiDispatchInterrupt --> SwapContext

  如果一个线程不调用API,在代码中屏蔽中断(CLI指令),并且不会出现异常,那么当前线程将永久占有CPU

时间片管理

  时钟中断会导致线程进行切换,但并不是说只要有时钟中断就一定会切换线程,时钟中断时,如下两种情况会导致线程切换:
  1、当前的线程CPU时间片到期
  2、有备用线程:KPCR.PrcbData.NextThread

时间片

  当一个新的线程开始执行时,初始化程序会在_KTHREAD.Quantum赋初始值,该值的大小由_KPROCESS.ThreadQuantum决定。每次时钟中断会调用KeUpdateRunTime函数,该函数每次将当前线程Quantum减少3个单位,如果减到0,则将KPCR.PrcbData.QuantumEnd的值设置为非0KiDispatchInterrupt判断时间片到期,调用KiQuantumEnd重新设置时间片、找到要运行的线程。

存在备用线程

  这个值被设置时,即使当前线程的CPU时间片没有到期,仍然会被切换。

  综上所述,本部分先做一个小总结来看看线程切换的三种情况:

  1. 当前线程主动调用API

    graph LR API函数 --> KiSwapThread --> KiSwapContext --> SwapContext
  2. 当前线程时间片到期:

    graph LR KiDispatchInterrupt --> KiQuantumEnd --> SwapContext
  3. 有备用线程KPCR.PrcbData.NextThread:

    graph LR KiDispatchInterrupt --> SwapContext

TSS

  SwapContext这个函数是Windows线程切换的核心,无论是主动切换还是系统时钟导致的线程切换,最终都会调用这个函数。在这个函数中除了切换堆栈意外,还做了很多其他的事情,了解这些细节对我们学习操作系统至关重要,接下来我们看看线程切换与TSS的关系。
  上一篇我们进行了线程的模拟切换,实现是差不多的,结合之前讲解的结构体,我们就能明白线程切换堆栈,我们回顾一下:

  上面这个图是用来表示内核堆栈示意图的,在 系统调用篇——0环层面调用过程(下) 中提到。

kd> dt _KTrap_Frame
nt!_KTRAP_FRAME
   +0x000 DbgEbp           : Uint4B
   +0x004 DbgEip           : Uint4B
   +0x008 DbgArgMark       : Uint4B
   +0x00c DbgArgPointer    : Uint4B
   +0x010 TempSegCs        : Uint4B
   +0x014 TempEsp          : Uint4B
   +0x018 Dr0              : Uint4B
   +0x01c Dr1              : Uint4B
   +0x020 Dr2              : Uint4B
   +0x024 Dr3              : Uint4B
   +0x028 Dr6              : Uint4B
   +0x02c Dr7              : Uint4B
   +0x030 SegGs            : Uint4B
   +0x034 SegEs            : Uint4B
   +0x038 SegDs            : Uint4B
   +0x03c Edx              : Uint4B
   +0x040 Ecx              : Uint4B
   +0x044 Eax              : Uint4B
   +0x048 PreviousMode : Uint4B
   +0x04c ExceptionList    : Ptr32 _EXCEPTION_REGISTRATION_RECORD
   +0x050 SegFs            : Uint4B
   +0x054 Edi              : Uint4B
   +0x058 Esi              : Uint4B
   +0x05c Ebx              : Uint4B
   +0x060 Ebp              : Uint4B
   +0x064 ErrCode          : Uint4B
   +0x068 Eip              : Uint4B
   +0x06c SegCs            : Uint4B
   +0x070 EFlags           : Uint4B
   +0x074 HardwareEsp      : Uint4B
   +0x078 HardwareSegSs    : Uint4B
   +0x07c V86Es            : Uint4B
   +0x080 V86Ds            : Uint4B
   +0x084 V86Fs            : Uint4B
   +0x088 V86Gs            : Uint4B

  这个结构体是不是很熟悉,对线程切换也遵守这个约定的。
  之前我们学习过API调用流程,如果忘记的话请到 系统调用篇 进行复习。普通调用,也就是使用中断门进入的,通过TSS.ESP0得到0环堆栈,而快速调用是从MSR得到一个临时0环栈,代码执行后仍然通过TSS.ESP0得到当前线程0环堆栈。
  Intel设计TSS的目的是为了任务切换,在操作系统层面也就是线程切换,但WindowsLinux并没有使用,而是采用堆栈来保存线程的各种寄存器。但是一个CPU只有一个TSS,但是线程很多,如何用一个TSS来保存所有线程的ESP0呢?本问题将会作为思考题,下一篇进行详细讲述。

FS

  FS:[0]寄存器在3环时指向TEB,进入0环后FS:[0]指向KPCR。系统中同时存在很多个线程,这就意味着FS:[0]在3环时指向的TEB要有多个,即每个线程一份。但在实际的使用中我们发现,当我们在3环查看不同线程的FS寄存器时,FS的段选择子都是相同的,那是如何实现通过一个FS寄存器指向多个TEB呢?这一切的一切都在SwapContext这个函数里面,逆向此函数作为本篇思考题,下一篇继续讲解。

线程优先级

  之前在上一篇,我们简单介绍了线程的等待链表和调度链表。这部分我们谈谈线程优先级的事情。
  之前讲过有三种情况会导致线程切换,在KiSwapThreadKiQuantumEnd函数中都是通过KiFindReadyThread来找下一个要切换的线程,KiFindReadyThread是根据什么条件来选择下一个要执行的线程呢?
  调度链表有32个,每次都从头开始查找效率太低,所以Windows都过一个DWORD类型变量的变量来记录,正好是32位,一个位代表一个链表,当向调度链表.中挂入或者摘除某个线程时,会判断当前级别的链表是否为空,为空将.变量对应位置0,否则置1,这个变量就是_kiReadySummary。多CPU会随机寻找KiDispatcherReadyListHead指向的数组中的线程,线程可以绑定某个CPU,可以使用APISetThreadAffinityMask进行设置。
  如果没有就绪线程怎么办?CPU是不可能闲下来的,它会执行一个空闲线程,即为IdleThread,它在_KPRCB结构体中,通过它就能找到执行的线程,如下所示:

kd> dt _KPRCB
ntdll!_KPRCB
   +0x000 MinorVersion     : Uint2B
   +0x002 MajorVersion     : Uint2B
   +0x004 CurrentThread    : Ptr32 _KTHREAD
   +0x008 NextThread       : Ptr32 _KTHREAD
   +0x00c IdleThread       : Ptr32 _KTHREAD

本节练习

本节的答案将会在下一节进行讲解,务必把本节练习做完后看下一个讲解内容。不要偷懒,实验是学习本教程的捷径。

  俗话说得好,光说不练假把式,如下是本节相关的练习。如果练习没做好,就不要看下一节教程了,越到后面,不做练习的话容易夹生了,开始还明白,后来就真的一点都不明白了。本节练习较多,请保质保量的完成。本篇答案将会在文章正文讲解。

0️⃣ 进程线程篇——线程切换(上) 模拟的线程切换模拟了什么切换,它们是什么?试分析。并实现模拟线程的挂起和恢复函数。
1️⃣ SwapContext有几个参数,分别是什么?你是如何判断出来参数的?在哪里实现了线程切换?
2️⃣ 线程切换的时候,会切换Cr3吗?切换Cr3的条件是什么?
3️⃣ 中断门提权时,CPU会从TSS得到ESP0SS0TSS中存储的一定是当前线程的ESP0SS0吗?如何做到的?
4️⃣ FS:[0]在3环时指向TEB但是线程有很多,FS:[0]指向的是哪个线程的TEB如何做到的?
5️⃣ 0环的ExceptionList在哪里备份的?
6️⃣ IdleThread是什么?什么时候执行?如何找到这个函数?
7️⃣ 分析KiFindReadyThread,查看是怎样查找就绪线程的。
8️⃣ 模拟线程切换与Windows的线程切换有哪些区别?
9️⃣ 走一遍时钟中断流程,分析KeUpdateRunTine函数。

下一篇

  进程线程篇——总结与提升

posted @ 2022-01-11 12:09  寂静的羽夏  阅读(1627)  评论(4编辑  收藏  举报