漫谈兼容内核之二十二:Windows线程的调度和运行

来源:互联网 发布:matlab 三维数组 画图 编辑:程序博客网 时间:2024/06/08 01:26

漫谈兼容内核之二十二:
Windows线程的调度和运行
毛德操

 大笑吐舌头偷笑奋斗得意

了解Windows线程的系统空间堆栈以后,还有必要对Windows线程的调度、切换、和运行也有所了解。当然,就兼容内核的开发而言,内核的线程调度/切换/运行机制只能有一套,而且必定是基本上沿用Linux的这套机制,而不可能在一个内核中有两套调度/运行机制。但是对于Windows这套机制的了解对于兼容内核的开发也很重要,并且还是必须的。举例来说,大家都知道在Windows系统中段寄存器FS在用户空间指向TEB、而在系统空间则指向KPCR,而且Windows的DDK中也公开了KPCR数据结构的定义。这样,设备驱动模块的开发者就有可能在程序中通过段寄存器FS获取KPCR的地址,并按KPCR数据结构的定义访问其中的某些字段。然而如果内核中并不真的有个KPCR,或者FS并不指向KPCR,那就要乱套了。所以,为了在兼容内核中支持设备驱动界面,就得把KPCR等等揉合进去,而那些东西其实就是Windows的线程切换/运行机制的一部分。

为此,我们先要了解一下Windows内核中有关这一方面的格局,这要从x86的系统结构谈起。

所谓“Intel架构”、即x86的系统结构,其最初的设计是在二十多年以前。当初一来是还没有“简约指令集”即RISC的概念,二来是把操作系统的设计与实现考虑得太复杂、太繁琐,因而把CPU系统结构的设计与实现也考虑得太复杂、太繁琐了。就我们所关心的问题而言,这主要表现在两个方面。

首先,Intel在x86的系统结构中把CPU的执行权限分得很细,分成了从0环至3环共4个“环”,并让CPU运行于0环时具有最高的权限,而运行于3环时则权限最低。但是从后来的发展看,无论是Linux还是Windows,实际上都只分系统(即内核)和用户两种状态、或称两个空间就够了,因而只使用了4个环中的两个,即0环(内核)和3环(用户)。

另一方面,Intel在“任务”(当初的“任务”相当于进程,现在则相当于线程)切换上也动了很多脑筋,其设计意图是让每个任务、即进程或线程、都有一个独立的“任务状态段”TSS,里面包含了几乎所有寄存器的映像,而通过TSS的切换来实现任务的切换,而且只要一条指令就能完成这样的切换。这条指令把几乎所有寄存器(除一些“系统寄存器”如GDTR等以外)的当前内容都一下子保存到当前任务的TSS中;然后通过一个实质上相当于段寄存器的“任务寄存器”TR切换到目标任务的TSS,就是使TR改而指向目标任务的TSS;再从这TSS中恢复目标任务的寄存器映像,切换就完成了。这整个过程都集成在一条指令中,从程序上看是一步到位。而这功能如此强大的指令,则实际上既可以是call指令、也可以是jmp指令,还可以是ret指令或是中断的发生(由此可见本来应该很简单的call指令、jmp指令,还有ret指令的实现变得多么复杂)。

TSS中还有关于一个任务的其它重要信息,包括从0环到2环共三个环的堆栈段寄存器和堆栈指针的映像。其设计意图是,当CPU从外环(例如3环)进入内环(例如0环)时,就从当前任务的TSS中把内环的堆栈指针(以及段寄存器SS的映像)装入ESP(以及SS)。此外,TSS中还有一个“I/O权限位图”,位图中的每一位都代表着I/O地址空间(共64KB)的一个字节,如果为0就表示即使在3环中也可以对此字节执行in、out等I/O指令。

Intel的这些设计意图都实现了。可是论者却认为这样做真是得不偿失,因为这使call、jmp、ret等指令的设计与实现都大大复杂化了,还使指令流水线的设计与实现也大大复杂化了,并且CPU芯片上的许多资源都被用来实现这些并非必须的功能。再说,虽然是单指令“一步到位”,但是这指令的执行时间却大大延长了,实际上也没有带来明显的好处。事实上,jmp指令在实现任务切换时需要200多个时钟周期。有200多个周期,在流水线操作的条件下,通过程序和堆栈实现任务切换也差不多了。因此,他们认为Intel这是把本来可以简单的事情不必要而且不值得地复杂化了。不过这只是RISC拥护者的看法。可是,更有甚者,对于Intel如此良苦的用心,本该从中获益的操作系统设计人员竟也不领情。无论是Linux还是Windows,线程的切换都没有使用Intel提供的这种单指令手段,而都是通过程序与堆栈实现的,与TSS几乎没有多少关系。

但是,尽管没有采用基于TSS的线程切换手段,线程切换却离不了TSS。这主要是因为CPU在从用户空间进入系统空间时自动到TSS中去获取系统空间的堆栈指针。此外,如果在用户空间执行in、out等I/O指令,CPU也要到TSS中去核对I/O权限位图。所以,在切换线程的时候,TSS不一定要切换,但是里面的ESP0等字段却还是必须要改变,因为不同线程的系统空间堆栈的位置各不相同。至于I/O权限位图,也有可能需要改变。可是,在Intel的设计中,这毕竟只是TSS的作用和功能的很小一部分,所以颇有些“买椟还珠”的意味。

 

在Intel架构中,段寄存器起着重要的作用。在16位的“实模式”中段寄存器起着扩大寻址范围和保护的作用,段寄存器的内容为“段地址”、即一个段的起点,每个段的最大长度为64KB,而整个地址则由段地址和段内位移整合而成。但是,在32位保护模式中段寄存器的作用已经改变,变成以保护为主了。此时段寄存器的内容已不再直接与地址有关,而变成了“段选择项”,其主体是用于“段描述表”的下标。下标不同,就选择了描述表中不同的表项,每个表项就是一个“段描述项”,至于段的长度则往往可以覆盖整个4GB空间。就线程切换而言,与其密切相关的“段描述表”有两个。一个是“全局描述表(Global Descriptor Table)”GDT,一个是“局部描述表(Local Descriptor Table)”。段描述项可以是针对GDT的,也可以是针对LDT的。其中LDT的设计意图是局部于个别的进程(任务),并且其本身也是作为一个段而存在的。所以作为“根”的段描述表就是GDT,而“GDT”中的字符G也可以理解为“General”,所以GDT就是“总描述表”。CPU中有个寄存器GDTR,其内容就是GDT起点的32位地址。GDT的最大长度是64K字节,最多可以容纳8192个描述项(每个描述项8个字节),其中的第一个描述项必须是0,表示“非法描述项”(所以段选择项不能为0)。

LDT和TSS都是作为段而存在的(但GDT不是),LDT可用可不用,但TSS是非有不可的;所以GDT中必须有TSS的描述项,可能还有LDT的描述项。既然LDT和TSS都是作为段而存在,就应该有相应的段寄存器,这就是LDTR和TR,只不过因为作用特殊而不明确地称为段寄存器。

这样,CPU中的段寄存器一共是8个,即CS、DS、SS、ES、FS、GS、LDTR、和TR,其中LDTR和TR为系统段寄存器。相比之下,GDT中最多可以有8191个有效的段描述项,所以只要改变段寄存器中的选择项即下标就可以使其灵活地指向不同的地址段。例如,段寄存器FS在用户空间时的内容是TEB_SELECTOR,而进入内核时就改成PCR_SELECTOR。这就在GDT中选择了不同的表项,从而分别指向当前线程用户空间的TEB和内核中的KPCR数据结构;而FS实际上起着指针的作用,同时也有对于越界访问的保护作用。当然,GDT中的描述项都是事先设置好了的。

对于具体的进程,还可以为其建立一个LDT,在LDT中又可以有多达8191个有效段描述项。在切换线程时,如果要保持LDTR的内容不变,则只要改变GDT中的相应表项,就可以使其切换到目标进程的LDT;或者也可以通过改变LDTR的内容选择GDT中的不同表项,同样达到切换LDT的目标。这个思路无疑是很好的,但是实际上却很少使用LDT,可能是因为一般的软件并没有复杂到这样的程度。

 

注意GDTR与“段寄存器”的区别在于:在32位保护模式下,CS、SS、DS、ES、FS、GS这些寄存器的内容并不包含目标段的起点和长度,而只是描述表、即全局描述表GDT或局部描述表LDT中某个表项的下标;但是GDTR的内容却不是下标,48位的GDTR中就含有GDT的32位线性基地址和16位的长度。用于中断向量段的IDTR也是一样。

相比之下,TR和LDTR就与一般的CS、SS、DS、ES、FS、GS这些段寄存器很相似了。TR和LDTR表面上都是16位的,实际上却都伴随着隐藏的64位段描述项。当然,LDTR和TSS都必须在GDT中有相应的表项,每当写入TR或LDTR的时候,CPU就自动根据写入的下标从GDT中将相应的表项装载到TR或LDTR的隐藏部分,作为高速缓存。

但是即使是TR和LDTR也不同于一般的段寄存器,因为对TR、LDTR、GDTR、以及IDTR的操作都有专用的指令,例如STR/LTR、SLDT/LLDT、SGDT/LGDT就分别是对于TR、LDTR、GDTR的读/写指令,这里的S表示“Store”、L表示“Load”。而一般的段寄存器,则都可以通过mov指令进行读/写。

 

如前所述,“任务状态段”TSS的设计意图是保存各个任务的执行环境和状态,而当前任务的TSS选择项就存储在TR中。当一个任务暂时放弃或被剥夺运行时,其当前状态、即所有通用寄存器的映像、就保存在TSS中。读者也许要问,这当前状态岂不就是运行现场?那不是保存在系统空间堆栈中吗?是的,但是当初的设计意图并非如此。另一方面,即使现在都是用系统空间堆栈保存运行现场,但这还不是任务状态的全部。这里有个问题:当前进程的系统空间堆栈又在那里?大家知道,任务切换只发生于系统空间,所以一个任务(线程)只有(主动或被动)进入了系统空间才能暂时放弃或者被剥夺运行。既然进入系统空间,就一定要用到系统空间堆栈,而且是在切换的瞬间就得用到,根本就不可能通过一段系统空间的程序去获取当前进程的系统空间堆栈指针,所以必须由CPU自动获取这个指针。那么从哪里去获取呢?可见至少得要有个固定的地方,这就是在本任务的TSS里面。实际上,除系统空间堆栈指针外,还有些别的信息也保存在TSS中。所以,某种形式的TSS的存在是确有必要的,只不过是否把运行现场都保存在TSS里面则值得商榷。

TSS中有三个堆栈指针,即ESP0、ESP1、ESP2、,分别用于0环、1环、和2环。当CPU从外环通过调用、陷阱、中断、异常进入某个内环时,CPU就从TSS取得该内环的堆栈指针。由于3环处于最外围,所以CPU不可能从某个更外围的环进入3环,所以TSS中没有用于3环的堆栈指针(见手册第三卷4.8.5节)。而所谓CPU在系统调用、中断、异常时切换到系统空间堆栈,实际上就是从TSS中把0环的SS0和ESP0装入SS和ESP,再把原来3环的SS和ESP压入0环堆栈。所以,CPU中物理的堆栈指针ESP只有一个,而逻辑的堆栈指针却最多可以有4个。不过,在实际的使用中,无论是Linux还是Windows,都只使用了0环和3环,即其中的两个。

不同线程的系统空间堆栈处于不同的位置,当然就得有不同的ESP0。如前所述,当初的设计意图是让每个任务都有自己的TSS,切换任务时就改变寄存器TR的指,使其指向不同任务的TSS。但是,现在实际上都把运行现场保存在堆栈上,光是为了一个ESP0就切换整个TSS未免不划算,还不如只用一个固定的TSS,保持TR不变,但是在切换任务时改变一下TSS中的ESP0,使其指向目标线程的系统空间堆栈。

现在可以转入正题,即Windows如何调度和切换线程了。

 

在Windows操作系统中,当调度一个线程运行、并实际切换到这个线程时,都需要做些什么、改变些什么呢?结合上面介绍的背景材料,我们先归纳一下,然后再看有关的代码。注意下面有时候说线程、有时候说进程,这是因为有些特性是属于进程、可能为多个线程所共有的:

l         由于所有线程(不管属于哪一个进程)都共用同一个TSS数据结构,在切换线程时就需要改变TSS中的一些字段、即某些寄存器的映像。特别地:

1.         各个线程的系统空间堆栈位置各不相同,所以字段ESP0的字段是必须改变的。注意需要恢复的只是系统空间的堆栈指针,用户空间的堆栈指针已经保存在系统空间堆栈的陷阱框架中,返回用户空间时自然就会恢复。

2.         各个进程可能有自己的I/O权限位图,这个位图的起点位移保存在进程的EPROCESS结构中,切换线程时要把它复制到TSS中。

l         各个进程的内存映射各不相同,各有自己的页面目录。各进程页面目录的起始地址保存在KPROCESS数据结构中,需要把它设置到控制寄存器CR3中。

l         每个线程在用户空间都有个“线程环境块”TEB,这就是进入用户空间时段寄存器FS所指的存储段。所以,GDT中的相应段描述项也需要加以改变。

l         如果用到LDT的话,GDT中的LDT段描述项的映像也需要改变。

l         FPU状态,如果使用浮点处理器的话,在切换线程时也要切换浮点运算的上下文,就是保存和恢复浮点处理器的状态(FX_SAVE_AREA)。

 

可见,在切换线程的时候需要设置不少的寄存器,而这牵涉到不少的指针和数据结构。把这些信息保存在许多离散的变量中,而把对所有这些变量的引用“硬编码”在程序中当然也是可以的,但是最好还是把它们集中保存在一个数据结构中。这样,程序中只需要访问一个变量,就取得了指向这个数据结构的指针,然后就可以“顺藤摸瓜”获取其它的信息了。特别地,当系统中有不止一个CPU时,这样做就更有必要了,因为在这样的系统中每个CPU都有一套这样的数据,还有许多从属于具体CPU的信息,如果分散保存就更不好办了。所以Windows内核中为此定义了一套以“处理器控制区(Processor Control Region)”KPCR为枢纽的数据结构,使每个CPU都有个KPCR结构,用来保存与线程切换有关的全局信息。KPCR数据结构的定义如下:

 

typedef struct _KPCR {

  KPCR_TIB  Tib;                  /* 00 */

  struct _KPCR  *Self;              /* 1C */

  struct _KPRCB  *Prcb;            /* 20 */

  KIRQL  Irql;                     /* 24 */

  ULONG  IRR;                    /* 28 */

  ULONG  IrrActive;                /* 2C */

  ULONG  IDR;                    /* 30 */

  PVOID  KdVersionBlock;          /* 34 */

  PUSHORT  IDT;                 /* 38 */

  PUSHORT  GDT;                 /* 3C */

  struct _KTSS  *TSS;             /* 40 */

  USHORT  MajorVersion;           /* 44 */

  USHORT  MinorVersion;           /* 46 */

  KAFFINITY  SetMember;          /* 48 */

  ULONG  StallScaleFactor;         /* 4C */

  UCHAR  DebugActive;             /* 50 */

  UCHAR  ProcessorNumber;        /* 51 */

  UCHAR  Reserved;                /* 52 */

  UCHAR  L2CacheAssociativity;      /* 53 */

  ULONG  VdmAlert;               /* 54 */

  ULONG  KernelReserved[14];       /* 58 */

  ULONG  L2CacheSize;             /* 90 */

  ULONG  HalReserved[16];          /* 94 */

  ULONG  InterruptMode;           /* D4 */

  UCHAR  KernelReserved2[0x48];    /* D8 */

  KPRCB  PrcbData;               /* 120 */

} KPCR, *PKPCR;

 

数据结构的定义取自ReactOS的代码,不过微软在DDK中也公开了这种数据结构的定义,只是比这里的小了一些,从位移0x54以后的字段都没有了。

KPCR结构中的第一个成分TIB也是个数据结构,即KPCR_TIB数据结构:

 

typedef struct _KPCR_TIB {

  PVOID  ExceptionList;            /* 00 */

  PVOID  StackBase;                /* 04 */

  PVOID  StackLimit;               /* 08 */

  PVOID  SubSystemTib;            /* 0C */

  _ANONYMOUS_UNION union {

      PVOID  FiberData;           /* 10 */

      DWORD  Version;           /* 10 */

  } DUMMYUNIONNAME;

  PVOID  ArbitraryUserPointer;       /* 14 */

  struct _NT_TIB *Self;              /* 18 */

} KPCR_TIB, *PKPCR_TIB;          /* 1C */

 

这是KPCR结构中至关重要的成分。首先其中StackBase和StackLimit的重要性显而可见的,而ExceptionList则是实现“结构化异常处理(SEH)”所必不可少的,这我以后还要专门加以介绍。

KPCR结构中的第二个成分self是个指针,指向其所在KPCR结构的起点,之所以这样安排的原因后面会讲到。

KPCR结构中的第三个成分Prcb又是个指针,指向一个“处理器控制块”、即KPRCB数据结构。这个结构中的信息可就多了,下面所列的只是一部分:

 

/* ProcessoR Control Block */

typedef struct _KPRCB {

       USHORT MinorVersion;

       USHORT MajorVersion;

       struct _KTHREAD *CurrentThread;

       struct _KTHREAD *NextThread;

       struct _KTHREAD *IdleThread;

       . . . . . .

       UCHAR CpuType;

       UCHAR CpuID;

       USHORT CpuStep;

       KPROCESSOR_STATE ProcessorState;

       . . . . . .

       PVOID LockQueue[33];    // Used for Queued Spinlocks

       struct _KTHREAD *NpxThread;

       ULONG InterruptCount;

       ULONG KernelTime;

       ULONG UserTime;

       . . . . . .

       struct _KEVENT *DpcEvent;

       UCHAR ThreadDpcEnable;

       BOOLEAN QuantumEnd;

       . . . . . .

       LONG MmPageReadCount;

       LONG MmPageReadIoCount;

       LONG MmCacheReadCount;

       LONG MmCacheIoCount;

       LONG MmDirtyPagesWriteCount;

       . . . . . .

       FX_SAVE_AREA NpxSaveArea;

       PROCESSOR_POWER_STATE PowerState;

} KPRCB, *PKPRCB;

 

 

里面的指针CurrentThread指向当前线程的KTHREAD数据结构,而NextThread指向已经预先调度运行的下一个线程,还有IdleThread则指向系统中的空转线程。

KPCR中还有个指针TSS,指向一个KTSS数据结构,这就是任务状态段TSS,每个CPU都有一个自己的TSS。

显然,从KPCR开始的这一套数据结构并不是专为线程调度和切换而设的,里面还包含着许多统计信息;还有如LockQueue[33]则是用于Spinlock的数组,它把可能需要用到的Spinlock集中到了一起。但是,既然有了KPCR,Windows的线程调度和切换也就离不开这些数据结构了。

 

在单CPU的系统中只有一个KPCR数据结构,其位置固定在地址为KPCR_BASE、即0xFF000000的地方。而在多CPU系统中、即在SMP结构的系统中则是个数组,数组的起点地址也是KPCR_BASE,用CPU的逻辑编号作为下标就可以取得该CPU的KPCR数据结构。

 

#define KPCR_BASE                   0xFF000000

 

当一个线程被调度在某个CPU上运行、并且运行于系统空间时,段寄存器FS的内容、即段选择项、就总是设置成PCR_SELECTOR,而GDT中的相应描述项则总是指向这个CPU的KPCR数据结构。以前读者看到CPU因系统调用或中断、异常进入系统空间时总是要把FS的内容换成PCR_SELECTOR,就是这个道理。这样,在系统空间,只要以FS加位移的方式寻址,就可以方便地访问其所在CPU的KPCR结构中的各个字段,以及实际上还有紧随在KPCR结构后面的KPRCB结构。例如,%fs:0就总是指向本线程的KPCR。但是,在汇编指令中“%fs:0”是KPCR的第一个32位长字的内容,却无法取它的地址,因为这不是一个变量。当然,根据段寄存器FS的内容,在GDT中可以找到相应的表项,从而找到其起始地址,但是那毕竟太麻烦了。这就是为什么要在KPCR中放上一个指针Self的原因。这个指针在KPCR中的位移是0x1c,所以“%fs:0x1c”就是KPCR的起点,下面这一小段代码就说明了这一点:

 

static __inline struct _KPCR * KeGetCurrentKPCR(VOID)

{

  ULONG Value;

  __asm__ __volatile__ ("movl %%fs:0x1C, %0\n\t"

         : "=r" (Value)

    : /* no inputs */);

  return (struct _KPCR *) Value;

}

 

 

现在我们可以看线程切换的代码了。在ReactOS中,线程切换是由Ki386ContextSwitch()实现的。这个函数有两个调用参数,就是新、老两个线程的KTHREAD结构指针。

 

 [Ki386ContextSwitch()]

 

_Ki386ContextSwitch:

  pushl   %ebp

  movl    %esp, %ebp

  /* Save callee save registers. */

  pushl   %ebx

  pushl   %esi

  pushl   %edi

  /* This is a critical section for this processor. */

  cli

#ifdef CONFIG_SMP

  . . . . . .

#endif /* CONFIG_SMP */

 

  /* Get the pointer to the new thread. */

  movl   8(%ebp), %ebx

 

  /* Set the base of the TEB selector to the base of the TEB for this thread. */

  pushl   %ebx

  pushl   KTHREAD_TEB(%ebx)

  pushl   $TEB_SELECTOR

  call      _KeSetBaseGdtSelector

  addl     $8, %esp

  popl     %ebx

 

首先设置框架指针EBP。设置了框架指针以后,第一个参数8(%ebp)就是目标线程、即新线程的KTHREAD结构指针;而第二个参数12(%ebp)则是当前线程、即老线程的KTHREAD结构指针。

切换线程的过程当然不能受中断干扰,所以要把这个过程置于关闭中断的条件下进行。

这里跳过了#ifdef CONFIG_SMP下面用于多处理器SMP系统结构的代码,先把单处理器结构下的线程切换搞清楚。

我们知道,当CPU回到用户空间时,段寄存器FS的内容应该指向当前线程的TEB。但是同一个进程中每个线程的TEB的位置都是不同的,显然需要在切换线程时加以改变。然而段寄存器FS是个16位寄存器,在保护模式下并不直接含有地址,而只是一个“段选择符”,由一个(13位)下标、一个描述表选择位、和两位的优先级构成。具体到TEB_SELECTOR,其定义为(0x38 + 0x3),说明下标为7,选择的是GDT,作用于用户空间。至于具体的地址,则编码在GDT的相应表项中。所以这里就通过KeSetBaseGdtSelector()设置这个表项。调用参数TEB_SELECTOR指明了需要改变的表项,而KTHREAD_TEB(%ebx)则取自目标进程KTHREAD结构中的TEB字段。注意KeSetBaseGdtSelector()改变的是GDT数据结构中的表项,数据结构的地址取自当前CPU的KPCR结构,而CPU中的GDTR也指向这个GDT数据结构。但是这并不意味着每次通过段寄存器访问内存时都要先访问GDT中的描述项,那样当然效率太低了。程序员所见到的16位FS寄存器实际上只是这个段寄存器的可见部分,CPU中的FS还有一个隐藏部分。每当将一个“段选择符”装入FS时,CPU就会根据段选择符中的信息和GDTR的内容找到相应的段描述项,并将这个描述项装入FS的隐藏部分。这样,通过段寄存器访问内存时就无须访问GDT数据结构了。FS是这样,其它段寄存器也是一样。那么什么时候把选择符TEB_SELECTOR装入FS呢?这个选择符是在用户空间才使用的,在CPU进入系统空间,形成不管是因为系统调用、中断、还是异常的陷阱框架的时候,都有这么几条指令:

 

    . . . . . .

    pushl  %fs

   

    /* Load PCR Selector into fs */

    movw  $PCR_SELECTOR, %bx

    movw  %bx, %fs

    . . . . . .

 

就是说,在进入系统空间时保存原来指向TEB的段选择符,并且把指向KPCR的段选择符装入FS,这就蕴含着把指向KPCR的段描述项装入了FS的隐藏部分。而在返回用户空间时则通过“popl %fs”指令恢复原来的段选择符,这时候就又把指向TEB的段描述符装入了FS的隐藏部分。至于在创建线程的时候,则在Ke386InitThreadWithContext()中把TEB_SELECTOR预先设置在虚构的陷阱框架中。

我们继续往下看代码:

 

[Ki386ContextSwitch()]

 

  /* Load the PCR selector. */

  movl    $PCR_SELECTOR, %eax

  movl    %eax, %fs

 

  /* Set the current thread information in the PCR. */

  movl   %ebx, %fs:KPCR_CURRENT_THREAD

 

  /* Set the current LDT */

  xorl     %eax, %eax

  movl   KTHREAD_APCSTATE_PROCESS(%ebx), %edi   //

  testw   $0xFFFF, KPROCESS_LDT_DESCRIPTOR0(%edi)

  jz 0f

 

  pushl   KPROCESS_LDT_DESCRIPTOR1(%edi)

  pushl   KPROCESS_LDT_DESCRIPTOR0(%edi)

  pushl   $LDT_SELECTOR

  call      _KeSetGdtSelector

  addl     $12, %esp

  movl    $LDT_SELECTOR, %eax

0:

  lldtw   %ax

 

在内核中,段寄存器的内容总是PCR_SELECTOR,所选择的是GDT中以此为下标的段描述项,实际上总是指向本CPU的KPCR数据结构,而%fs:KPCR_CURRENT_THREAD则为离KPCR数据结构起点的位移为KPCR_CURRENT_THREAD、即0x124处的字段。但是KPCR数据结构的大小一共才0x120,所以这实际上是KPRCB数据结构中的结构指针CurrentThread。这样,就使这个指针指向了目标线程的KTHREAD结构。注意这里有个前提,就是KPRCB数据结构必须是紧跟在KPCR数据结构的后面(这样的程序设计实在不敢恭维);然而KPCR数据结构中却又有个指针,指向与其配对的KPRCB数据结构,似乎意在让KPRCB数据结构可以独立存在。

由于段寄存器FS在系统空间总是指向当前进程的KPCR,为了提高效率,常常用汇编指令通过FS访问KPCR结构中的字段,例如:

 

static inline PKPRCB KeGetCurrentPrcb(VOID)

{

  ULONG value;

 

#if defined(__GNUC__)

  __asm__ __volatile__ ("movl %%fs:0x20, %0\n\t"

         : "=r" (value)

    : /* no inputs */

    );

#elif defined(_MSC_VER)

  . . . . . .

#endif

  return((PKPRCB)value);

}

 

这里fs:0x20就是KPCR数据结构中的指针Prcb,指向相应的KPRCB数据结构。这样的代码,如果在维护中需要作一些改变,那可真是牵一发动全身。

不管怎么说,现在KeGetCurrentPrcb()->CurrentThread已经指向目标线程了。可见,KPRCB数据结构中的这个指针总是指向目标线程程的ETHREAD数据结构。事实上,底层函数KeGetCurrentThread()的实现在单CPU结构的系统中就是返回这个指针:

 

PKTHREAD STDCALL KeGetCurrentThread(VOID)

{

#ifdef CONFIG_SMP

   ULONG Flags;

   PKTHREAD Thread;

   Ke386SaveFlags(Flags);

   Ke386DisableInterrupts();

   Thread = KeGetCurrentPrcb()->CurrentThread;

   Ke386RestoreFlags(Flags);

   return Thread;

#else

   return(KeGetCurrentPrcb()->CurrentThread);

#endif

}

 

回到前面的汇编代码中。

下面的指令“movl KTHREAD_APCSTATE_PROCESS(%ebx), %edi”使寄存器EDI指向了目标线程所属进程的KPROCESS数据结构。这个数据结构中有个ULONG数组LdtDescriptor[2],如果其第一个元素的低16位非0就是一个有效的LDT段描述项,那就说明目标线程使用了LDT,因此就要把这个指向其LDT段的描述项设置到GDT中,其下标为LDT_SELECTOR。同时还要通过指令lldt把这个下标作为段选择项装入到寄存器LDTR中。如前所述,LDTR实质上是个段寄存器,其结构与普通的段寄存器相同。这就是说,它在CPU中有个16位的可见部分,还有个64位的隐藏部分。而lldt指令,则一方面把16位的段选择项置入LDTR的可见部分,同时也从GDT中把相应的表项装入了LDTR的隐藏部分。这样:

l         当用户程序把FS、GS等段寄存器设置成使用LDT时(选择项中的表选择位为0表示使用GDT,为1表示使用LDT),根据LDTR就可以找到LDT,而不用再去访问GDT中的描述项。

l         根据置入FS、GS等段寄存器的选择项和LDT的内容,把LDT中的相应表项装入FS、GS等段寄存器的隐藏部分

l         于是,当用户程序通过FS、GS等段寄存器访问内存时,就无需再去访问LDT中的描述项。

注意在不使用LDT时装入LDTR的段选择项是0,而GDT中下标为0处是个非法段描述项,所以要是以后企图访问LDT(例如把段寄存器FS设置成使用LDT)就会导致异常。

我们再往下看。

 

[Ki386ContextSwitch()]

 

  /* Get the pointer to the old thread. */

  movl    12(%ebp), %ebx

 

  /* FIXME: Save debugging state. */

 

  /* Load up the iomap offset for this thread in preparation for setting it below. */

  movl    KPROCESS_IOPM_OFFSET(%edi), %eax

 

  /* Save the stack pointer in this processors TSS */

  movl    %fs:KPCR_TSS, %esi

  pushl  KTSS_ESP0(%esi)

 

  /* Switch stacks */

  movl   %esp, KTHREAD_KERNEL_STACK(%ebx)

  movl    8(%ebp), %ebx

  movl   KTHREAD_KERNEL_STACK(%ebx), %esp

  movl    KTHREAD_STACK_LIMIT(%ebx), %edi

  movl    %fs:KPCR_TSS, %esi

 

  /* Set current IOPM offset in the TSS */

  movw  %ax, KTSS_IOMAPBASE(%esi)

 

这里使EBX指向了老线程,其实要使用这个指针的地方还在后面。此时的EDI仍指向新线程(目标线程)所属进程的KPROCESS数据结构,而KPROCESS_IOPM_OFFSET(%edi)就是KPROCESS结构中字段IopmOffset的内容。这个字段说明该进程的IO权限位图在TSS中的位置,这里先把它装入了EAX,后面会把它写入TSS中的IoMapBase字段。

随后的指令“movl %fs:KPCR_TSS, %esi”把KPCR中的指针TSS装入ESI,使其指向了KTSS数据结构,接着就把这个结构中字段Esp0的当前值压入堆栈。如前所述,TSS中的这个字段总是指向当前线程系统空间堆栈的原点,当一个线程不再成为当前线程时就得把它保存起来。保存在哪里呢?办法当然不止一种,例如保存在KTHREAD结构中也未尝不可,而这里选择的是保存在堆栈中,那当然也可以。

至此,已经为堆栈的切换作好了准备,下面就是切换堆栈了,注意此时EBX指向老线程的KTHREAD结构。首先把此刻的堆栈指针记录在老线程KTHREAD结构中的字段KernelStack中。这就是老线程在切换点上的系统空间堆栈指针。然后又使EBX指向新线程的KTHREAD结构,从中恢复其保存着的堆栈指针,读者不妨回顾一下上一篇漫谈中所讲的这个字段的作用。注意这里的保存堆栈指针和恢复堆栈指针是分别针对两个不同线程、两个不同KTHREAD数据结构的操作。

从现在起,程序就开始使用另一个线程的系统空间堆栈了。读者也许心中疑虑,就这么把堆栈换了,会不会给程序的运行带来断裂?不会的。对于老线程,已经在堆栈上的内容或者是要到返回的时候才会用到,或者是在程序中需要这些数据时才会用到,但是那都发生在下一次当这个线程又被调度运行的时候。对于新线程,则下面要用到的堆栈内容都是在上一次当这个线程被调度停止运行时压入堆栈的。

下一条mov指令把目标进程KTHREAD结构中字段StackLimit的值置入EDI,但是这似乎是多余的。注意此前EDI指向新线程的KPROCESS数据结构,现在则变成了新线程的StackLimit,可是后面没有看到此项数据的使用。

再下一条mov指令把KPCR结构中的指针TSS置入ESI,使其指向本CPU的KTSS数据结构。但是这似乎又是多余的,因为在切换堆栈的那几条指令的前后FS的内容并未改变,GDT中的相应表项也未改变,又是在同一个CPU上,所以前后两条以ESI为目标的mov指令应该有着相同的效果。话虽如此,读者要是看出这里面有甚么奥妙就请发个Email给作者。

下面是把当前进程的IopmOffset位移写入KTSS数据结构中的IoMapBase字段。这里寄存器EAX的内容在切换堆栈之前来自目标进程的KPROCESS结构,现在则把它写入KTSS结构的IoMapBase字段中。这个字段的值是个16位的位移量,说明IO权限位图在TSS中的位置。这样,KTSS数据结构中的IoMapBase就总是指向当前进程的IO权限位图,或者说明当前进程没有IO权限位图(如果IoMapBase为0xffff)。需要说明的是,大部分Windows进程都没有IO权限位图,因而只有在内核中才能进行I/O操作;有IO权限位图的只是特殊的进程,一般是运行于V86模式的进程。

系统空间堆栈的切换意味着CPU的执行已由老线程转到新进程,已经恢复了新线程在系统空间的运行。但是,除非是内核线程,一般而言新线程最后还得回到用户空间,而此刻的用户空间映射还是老线程的,所以还得切换用户空间的映射。继续往下看:

 

[Ki386ContextSwitch()]

 

  /* Change the address space */

  movl    KTHREAD_APCSTATE_PROCESS(%ebx), %eax

  movl    KPROCESS_DIRECTORY_TABLE_BASE(%eax), %eax

  movl   %eax, %cr3

 

  /* Restore the stack pointer in this processors TSS*/

  popl    KTSS_ESP0(%esi)

 

  /* Set TS in cr0 to catch FPU code and load the FPU state when needed

   * For uni-processor we do this only if NewThread != KPCR->NpxThread */

#ifndef CONFIG_SMP

  cmpl    %ebx, %fs:KPCR_NPX_THREAD

  je 4f

#endif /* !CONFIG_SMP */

  movl    %cr0, %eax

  orl       $X86_CR0_TS, %eax

  movl    %eax, %cr0

4:

  /* FIXME: Restore debugging state */

  /* Exit the critical section */

  sti

 

  call    @KeReleaseDispatcherDatabaseLockFromDpcLevel@0

 

  cmpl    $0, _PiNrThreadsAwaitingReaping

  je 5f

  call      _PiWakeupReaperThread@0

5:

 

  /* Restore the saved register and exit */

  popl     %edi

  popl     %esi

  popl     %ebx

 

  popl     %ebp

  ret

 

除非内核线程,每个线程都在其所属进程的空间中运行,因而使用某个特定的页面目录。不同页面目录中系统空间页面的映射都是相同的,所不同的是用户空间页面的映射。至于内核线程则只有系统空间页面的映射,而没有用户空间页面的映射。所以,切换线程的时候也要切换页面目录。而页面目录属于进程,这里KTHREAD_APCSTATE_PROCESS(%ebx)实际上是KTHREAD数据结构内部KAPC_STATE结构中的字段Process的值。这是个指针,指向其所属进程的KPROCESS数据结构。把这个指针赋给EAX以后,KPROCESS_DIRECTORY_TABLE_BASE(%eax)就是KPROCESS数据结构中字段DirectoryTableBase的值,这又是个指针(物理地址),指向该进程的页面目录。把这个值写入控制寄存器CR3,就引起了地址映射的切换,不过此刻的程序是在系统空间执行,而所有进程的系统空间都是相同的,所以这种切换并不影响程序的继续执行,其作用要倒CPU回到用户空间时才表现出来。

下面从堆栈恢复TSS中的ESP0,这条pop指令与切换堆栈之前的push指令相对应,但却是针对不同的堆栈。前面的push指令是针对老线程的系统空间堆栈,而后面的pop指令则是针对新线程的系统空间堆栈。而所谓“新线程”,很可能是从前的某一次线程切换中的“老线程”。也就是说,当一个线程不被执行时,其ESP0存放在它的系统空间堆栈上,到被调度运行并切换时再把ESP0写入TSS。这样,CPU在需要从用户空间进入系统空间时才能知道当前线程的系统空间堆栈在哪里。注意在修改了KTSS的某些内容后并不需要重新装入段寄存器TR,TR还是指向原来的地方,因为TSS还是原来的TSS,而所改变的内容都是在实际用到时才由CPU到TSS中获取,例如Esp0就是要到CPU从用户空间进入系统空间时才会用到的。

此后的几条指令与浮点运算有关。控制寄存器CR0的一些标志位控制着CPU许多方面的运行状态,例如是否开启页面映射、是否启用高速缓存等等都是由CR0控制的。但是这里所关心的是其中与浮点运算有关的标志位X86_CR0_TS:

 

#define X86_CR0_TS  0x00000008  /* enable exception on FPU instruction for task switch */

 

这是一个控制/标志位,TS表示“Task Switched”,其作用是使浮点处理器FPU的上下文不必立即加以保存,因为新的目标线程在运行中未必会用到FPU,每次切换时都加以保存/恢复就造成浪费。这里的程序中把这一位设成1以后,如果新的线程真的用到FPU,就会在首次使用FPU时导致一次异常,在相应的异常处理程序中再来处理FPU的切换就可以了(详见Intel的软件开发手册第三卷)。

至此,线程切换的关键操作都已完成,可以打开中断了。后面的KeReleaseDispatcherDatabaseLockFromDpcLevel()显然是解锁,与其相对应的加锁操作在上一层的程序中,所以在这里看不到。至于PiWakeupReaperThread(),则是唤醒一个内核线程,让它来“收割”那些已经退出运行的线程,实际上就是释放它们的数据结构。

 

我们不妨考察一下在这整个过程中的堆栈操作。首先所有的push操作和pop操作显然是平衡的、即数量相等,这在任何函数中都是一样。进一步,除少数例外,绝大多数的push操作和pop操作也是配对的,例如前面有“pushl KTSS_ESP0(%esi)”,后面就有“popl KTSS_ESP0(%esi)”。最后,至关重要的是,在这个函数内部,这些push操作和pop操作实际上作用于两个不同的堆栈,即两个不同线程的系统空间堆栈。所以,与前面的push操作配对的确实就是后面的那些pop操作,但是这些pop指令的执行却是“老线程”在下一次被调度运行、并因此而而执行Ki386ContextSwitch()、变成了“新线程”时的时候。

新创建的线程是个特例。新建线程系统空间堆栈上的这些数据当然不可能是在切换线程时保存进去的,而是预先安排好的。我们再回顾一下Ke386InitThreadWithContext()中的这几行代码:

 

  KernelStack[0] = (ULONG)Thread->InitialStack - sizeof(FX_SAVE_AREA); /* TSS->Esp0 */

  KernelStack[1] = 0;      /* EDI */

  KernelStack[2] = 0;      /* ESI */

  KernelStack[3] = 0;      /* EBX */

  KernelStack[4] = 0;      /* EBP */

  KernelStack[5] = (ULONG)&PsBeginThreadWithContextInternal;   /* EIP */

 

比较一下前面的那些pop语句,就可以知道当新建线程被调度运行时被恢复到KTSS_ESP0(%esi)、即TSS中ESP0字段的是系统空间堆栈的原点,即堆栈区间顶部减去一个FX_SAVE_AREA数据结构以后的边界上。而寄存器EDI、ESI、EBX、和EBP的初值则为0。

新建线程开始运行时TSS中的ESP0字段被设置成系统空间堆栈原点。这样,在CPU回到用户空间之后,如果发生中断、异常、或者系统调用,CPU就会把TSS中的ESP0装入堆栈指针寄存器ESP,使其指向系统空间堆栈的原点。系统空间堆栈的SS也取自TSS,但是那实际上一经初始化以后便不再改变,所有线程在系统空间都使用同一个堆栈段。从此以后,这个线程的系统空间堆栈原点就会永远保持下去,作为当前线程运行时这个数值在TSS的ESP0中,不运行时则保存在其自己的系统空间堆栈中。

值得注意的还有KernelStack[5]、即返回地址,这是PsBeginThreadWithContextInternal。本来,调用Ki386ContextSwitch()的地方是PsDispatchThreadNoLock(),从Ki386ContextSwitch()返回时应该返回到那里去,但是那样就得在新建线程的堆栈上构建出包含多个函数调用框架的整个上下文,因为PsDispatchThreadNoLock()又是受别的函数调用的。对于新建的线程,那样做既麻烦又无必要,所以这里让它抄近路“返回”到PsBeginThreadWithContextInternal。在那里,读者在前一篇漫谈中已经看到,稍作处理就直接跳转到了_KiServiceExit。

读者也许要问,这里保存在堆栈上的寄存器才那么几个,这就够了吗?是的。须知线程切换一定是在Ki386ContextSwitch()进行的,首先这是在系统空间,所有寄存器在用户空间的内容都已经在进入系统空间时保存在陷阱框架中。而这些寄存器在线程切换前夕的内容,如果需要的话,也已经保存在系统空间堆栈上,或者本来就在系统空间堆栈上(作为局部变量的值),或者保存在有关的数据结构中。所以到进入Ki386ContextSwitch()的时候实际上已经没有什么需要保存的了,这里之所以要保存EDI、ESI、EBX、和EBP的值,只是因为在切换的过程中需要用到这几个寄存器。除此以外,真正需要保存/恢复的数据其实只有一项,那就是KTSS_ESP0(%esi),因为每个线程的系统空间堆栈的位置是不同的。

 

明白了线程切换的过程,剩下来的问题是什么时侯切换。这不用说当然是调度的时侯切换,于是问题变成了什么时候调度。事实上,很多情况都会引起线程调度:

l         当前线程通过NtYieldExecution()系统调用自愿礼让。

l         当前线程在别的系统调用中因操作受阻而半自愿地交出运行权。

l         当前线程通过NtSetInformationThread()等系统调用改变了自身或其它线程/进程的优先级,使得自己可能不再具有最高的运行优先级。

l         当前线程通过NtSuspendThread()挂起其自身的运行。

l         当前线程通过NtResumeThread()恢复了其它线程的运行,使得自己可能不再具有最高的运行优先级。

l         当前线程通过进程间通信/线程间通信唤醒了别的进程,使得自己可能不再具有最高的运行优先级。

l         对时钟中断的处理发现当前线程已经用完时间配额,因而调度其它线程运行。

l         其它中断的发生导致某个/某些线程被唤醒,从而使得当前线程可能不再具有最高的运行优先级。

明白了在什么时侯调度,剩下的就是怎样调度、特别是根据什么准则调度的问题了。

下面我们通过一个实际的情景来解答这个问题。为简单起见,我们从系统调用NtYieldExecution()着手来看这整个过程。这个系统调用的作用是使当前线程暂时放弃运行,但又不进入睡眠,实际上就是为别的线程让一下路,相当与Linux中的yield()。

 

NTSTATUS STDCALL

NtYieldExecution(VOID)

{

  PsDispatchThread(THREAD_STATE_READY);

  return(STATUS_SUCCESS);

}

 

由于只是暂时退让,当前线程并不被阻塞,其运行状态仍为THREAD_STATE_READY、仍处于就绪状态,而只是通过PsDispatchThread()启动一次线程调度,但是当前线程自己并不参与竞争。

 

[NtYieldExecution() > PsDispatchThread()]

 

VOID STDCALL PsDispatchThread(ULONG NewThreadStatus)

{

   KIRQL oldIrql;

 

   if (!DoneInitYet || KeGetCurrentPrcb()->IdleThread == NULL)

   {

      return;

   }

   oldIrql = KeAcquireDispatcherDatabaseLock();

   PsDispatchThreadNoLock(NewThreadStatus);

   KeLowerIrql(oldIrql);

}

 

实际的调度是由PsDispatchThreadNoLock()完成的。调度的过程需要排它地进行,不能在中途又因为别的原因而再次进入调度的过程,所以要对这整个进程加上锁。特别地,调度的过程中涉及许多队列操作,而队列操作是必须排它进行的。至于所谓Database,实际上就是指这些队列。要不然,如果不加锁的话,例如要是中途发生了一次时钟中断,而时钟中断服务程序发现当前进程已经用完了时间配额,就又会启动线程调度,这就乱了套。而PsDispatchThreadNoLock(),正如其函数名所示,是不管加锁这事的,所以这里要先加上锁。

但是读者也许会问,既然在调用PsDispatchThreadNoLock()之前先上了锁,那么理应在从这个函数返回以后开锁,怎么这里看不到呢?这是因为当前线程对PsDispatchThreadNoLock()的调用并不是在完成了调度以后就立即返回,而要到它下一次又被调度运行时才会返回,中间还夹着其它线程的运行。所以,到从PsDispatchThreadNoLock()返回的时侯才开锁,那就错了。

 

[NtYieldExecution() > PsDispatchThread() > PsDispatchThreadNoLock()]

 

VOID PsDispatchThreadNoLock (ULONG NewThreadStatus)

{

   KPRIORITY CurrentPriority;

   PETHREAD Candidate;

   ULONG Affinity;

   PKTHREAD KCurrentThread = KeGetCurrentThread();

   PETHREAD CurrentThread =

                        CONTAINING_RECORD(KCurrentThread, ETHREAD, Tcb);

 

   . . . . . .

 

   CurrentThread->Tcb.State = (UCHAR)NewThreadStatus;

   switch(NewThreadStatus)

   {

     case THREAD_STATE_READY:

        PsInsertIntoThreadList(CurrentThread->Tcb.Priority, CurrentThread);

        break;

     case THREAD_STATE_TERMINATED_1:

        PsQueueThreadReap(CurrentThread);

        break;

   }

 

   Affinity = 1 << KeGetCurrentProcessorNumber();

   for (CurrentPriority = HIGH_PRIORITY;

       CurrentPriority >= LOW_PRIORITY; CurrentPriority--)

   {

     Candidate = PsScanThreadList(CurrentPriority, Affinity);

     if (Candidate == CurrentThread)

     {

        Candidate->Tcb.State = THREAD_STATE_RUNNING;

        KeReleaseDispatcherDatabaseLockFromDpcLevel();  

        return;

     }

     if (Candidate != NULL)

     {

        PETHREAD OldThread;

        PKTHREAD IdleThread;

 

        DPRINT("Scheduling %x(%d)\n",Candidate, CurrentPriority);

 

        Candidate->Tcb.State = THREAD_STATE_RUNNING;

 

        OldThread = CurrentThread;

        CurrentThread = Candidate;

        IdleThread = KeGetCurrentPrcb()->IdleThread;

 

        if (&OldThread->Tcb == IdleThread)

        {

           IdleProcessorMask &= ~Affinity;

        }

        else if (&CurrentThread->Tcb == IdleThread)

        {

           IdleProcessorMask |= Affinity;

        }

 

        MmUpdatePageDir(PsGetCurrentProcess(),

                (PVOID)CurrentThread->ThreadsProcess, sizeof(EPROCESS));

 

        KiArchContextSwitch(&CurrentThread->Tcb, &OldThread->Tcb);

        return;

      }

   }

   CPRINT("CRITICAL: No threads are ready (CPU%d)\n", KeGetCurrentProcessorNumber());

   PsDumpThreads(TRUE);

   KEBUGCHECK(0);

}

 

一进入这个函数,就使局部量KcurrentThread指向当前线程的KTHREAD数据结构,并使CurrentThread指向相应的ETHREAD数据结构。宏定义CONTAINING_RECORD根据指向数据结构内部成分的指针和外层结构的类型推算出指向外层结构的指针。实际上,KTHREAD数据结构是ETHREAD结构内部的第一个成分,所以这两个指针其实是一样的,不过这样做更为安全(万一有谁修改了ETHREAD的定义)。

作为参数传下来的是当前线程的新的状态。如果当前线程被阻塞,那就是THREAD_STATE_BLOCKED,但是在我们这个情景中是THREAD_STATE_READY。

状态为THREAD_STATE_READY的线程应该挂入系统的就绪队列。所谓就绪队列,实际上是一组队列,每一种线程优先级都有一个队列。为此,内核中有个LIST_ENTRY结构数组PriorityListHead[MAXIMUM_PRIORITY],数组的大小MAXIMUM_PRIORITY定义为32,对应着32种不同的线程优先级。

 

[NtYieldExecution() > PsDispatchThread() > PsDispatchThreadNoLock()

> PsInsertIntoThreadList ()]

 

static VOID

PsInsertIntoThreadList(KPRIORITY Priority, PETHREAD Thread)

{

   . . . . . .

   InsertTailList(&PriorityListHead[Priority], &Thread->Tcb.QueueListEntry);

   PriorityListMask |= (1 << Priority);

}

 

显然,这是通过KTHREAD结构中的QueueListEntry将其挂入给定优先级的就绪队列。

与32个就绪队列相对应,内核中还有个位图PriorityListMask,只要某个优先级的就绪队列非空,这个位图中相应的标志位就设置成1。这样,只要看一下位图,就知道有没有某个优先级的线程在等待被调度运行了。

那么,不在就绪状态的线程怎么办呢?不在就绪状态的线程当然不在就绪队列中,但是仍通过ETHREAD结构中的ThreadListEntry链接在其所属进程的线程队列中,这是在创建之初通过PsInitializeThread()中挂入这个队列的。所以,一个线程,不管是否就绪,其ETHREAD数据结构总是挂在其所属进程的线程队列中。不过,到一个线程结束了运行、状态变成THREAD_STATE_TERMINATED_1的时候则又是特例,对于这样的线程要通过PsQueueThreadReap()“收割”这个线程的数据结构。

还有个问题,挂入就绪队列的线程什么时候从队列中脱离出来呢?下面就会看到,当调度一个线程运行时,就把它的ETHREAD结构从就绪队列中摘除下来。正因为这样,前面才把状态为就绪的线程又挂回就绪队列。

回到PsDispatchThreadNoLock()的代码,下面就是调度了。在多处理器SMP结构的系统中,有些线程是指定只能在某个或某几个CPU上运行的,这种关系叫做Affinity,即“亲和”,或者也可以说“绑定”。有关的信息以位图的形式记录在具体线程的KTHREAD数据结构中,所以在调度时得要明确这是为哪一个CPU在做调度。

具体的调度就是按从高到低的次序扫描各个优先级的就绪队列,从中找出第一个愿意在目标CPU上运行的线程。这就是程序中那个for循环的作用。对于具体优先级的就绪队列,则通过PsScanThreadList()加以扫描。

 

[NtYieldExecution() > PsDispatchThread() > PsDispatchThreadNoLock() > PsScanThreadList()]

 

static PETHREAD PsScanThreadList(KPRIORITY Priority, ULONG Affinity)

{

   PLIST_ENTRY current_entry;

   PETHREAD current;

   ULONG Mask;

 

   Mask = (1 << Priority);

   if (PriorityListMask & Mask)

   {

      current_entry = PriorityListHead[Priority].Flink;

      while (current_entry != &PriorityListHead[Priority])

      {

         current = CONTAINING_RECORD(current_entry, ETHREAD,

                                                     Tcb.QueueListEntry);

         if (current->Tcb.State != THREAD_STATE_READY)

         {

            DPRINT1("%d/%d\n", current->Cid.UniqueThread, current->Tcb.State);

         }

         . . . . . .

         if (current->Tcb.Affinity & Affinity)

         {

           PsRemoveFromThreadList(current);

           return(current);

         }

         current_entry = current_entry->Flink;

      }

   }

   return(NULL);

}

 

就这样,按优先级从高到底的次序对每个就绪队列执行PsScanThreadList();一找到合适的线程,循环就结束了。

回到前面PsDispatchThreadNoLock()的代码,调度的结果无非是这么几种:

l         目标线程Candidate就是当前线程本身,这就不需要切换了。将其状态改回THREAD_STATE_RUNNING,就可以返回了。注意返回前的解锁操作。

l         目标线程Candidate就是空转线程IdleThread,而当前线程不是空转线程。此时将位图IdleProcessorMask中对应于当前CPU的标志位设成1,表示空转线程在本CPU上运行。

l         目标线程Candidate不是空转线程,而当前线程是空转线程。此时此时将位图IdleProcessorMask中对应于当前CPU的标志位清0,表示空转线程已不在本CPU上运行。

l         目标线程Candidate和当前线程都不是空转线程,这是常规的线程调度。

l         没有找到任何可以在此CPU上执行的线程,这一定是程序中发生了什么严重的错误,因为空转线程永远都是就绪的,而且每个CPU都有一个(每个CPU的KRCB数据结构中的指针IdleThread指向其空转线程)。系统在这种情况下不能继续运行了,所以执行KEBUGCHECK()。

当然,在正常的情况下总是有线程可以运行的,所以下面就是切换的事了。这里有两步操作,第一步是对MmUpdatePageDir()的调用;第二步是宏操作KiArchContextSwitch(),对于x386处理器这就是前面的Ki386ContextSwitch()。

先看对MmUpdatePageDir()的调用。其第一个参数是PsGetCurrentProcess(),注意这是指切换前的当前线程所属的进程。第二个参数是CurrentThread->ThreadsProcess,这却是目标线程所属进程的EPROCESS结构指针。第三个参数则是sizeof(EPROCESS)。调用这个函数的目的是要确保目标线程所属进程的ETHREAD数据结构在当前进程的页面映射表中有映射。每个进程都有个页面映射表,而其所在的地址则在该进程的EPROCESS结构中。每个进程的EPROCESS结构都在物理页面中。但是,在ReactOS内核中(估计在Windows内核中也是一样),一个进程的EPROCESS结构所占的物理页面却未必映射到别的进程的系统(虚存)空间。换言之,从一个进程的虚存空间可能访问不到别的进程的EPROCESS结构。这在平时没有什么问题,但是在一些需要跨进程访问的特殊场合下就有问题了。需要跨进程访问的场合主要就在线程切换的过程中以及需要进程挂靠的时候。

其实,在线程切换的过程中需要访问的并非“新”线程所属进程的整个EPROCESS结构,而是作为其一部分的KPROCESS结构。回顾一下前面Ki386ContextSwitch()的代码,就可以看到:

l         为设置LDTR,需要访问KPROCESS结构中的映像LdtDescriptor[2]。

l         KPROCESS结构中的IopmOffset。

l         为设置CR3,需要访问KPROCESS结构中的字段DirectoryTableBase。

目标进程的EPROCESS结构所在页面在当前进程的页面映射表中未必有映射,如果不补上这些页面的映射,就会在访问这些数据的时候发生页面异常,所以需要先通过MmUpdatePageDir()补上这些映射。

注意这里说的是EPROCESS结构,而不是ETHREAD结构,后者无论在哪一个进程的页面目录中都是有映射的,要不然线程调度就没法做了。

现在已是万事俱备了,下面就是Ki386ContextSwitch(),这前面已经看过了。

 

从代码中可以看出,把线程挂入就绪队列的过程和从就绪队列中选择线程的过程都是很简单而直截了当的,实际上就是根据优先级、即KTHREAD结构中Priority字段的值。有多个相同优先级的就绪线程时,则轮流(Roubd Robin)执行,因为把就绪线程挂入队列时总是挂在尾部。

线程的运行优先级可以通过系统调用加以改变,也可能因为别的原因而受到改变,但是那又属于另一个话题了。

阅读全文
0 0
原创粉丝点击