4.2.9 线程的切换——中断上下文
在Hello China的当前实现中,采用的是可抢占式的线程调度方式,即每个时钟中断发生后,中断处理程序会打断当前执行的线程,检查线程的就绪队列(lpReadyQueue),选择一个优先级最高的线程投入运行。这样的调度机制,可确保优先级最高的线程能够马上得到调度。这样,就涉及一个问题,在中断上下文中,如何保存当前的线程上下文状态,并选择另外一个线程,恢复其上下文,并投入运行。在本节中,我们对这个问题进行详细描述。
首先,在进入正式讨论前,先介绍Intel IA32 CPU的一条指令iretd。这条指令的用途很广泛,最基础的用途是从中断中返回。
在IA32构架的CPU中,每次中断发生的时候,CPU会做如下动作(没有考虑不同层级之间的转换,只考虑在核心保护模式下的情况)。
(1)把当前执行的线程所在的代码段寄存器(CS)、EIP寄存器和标志寄存器(EFLAGS),以及一个可选的错误代码压入当前堆栈;
(2)根据中断向量号,查找中断描述表(IDT),并跳转到IDT指定的中断处理程序;
(3)中断处理程序执行完毕后,执行一条iretd指令,该指令恢复先前在堆栈中保存的CS、EFlags、EIP寄存器信息,并继续执行。
因此,当中断发生,CPU跳转到中断处理程序前后,当前线程堆栈的堆栈框架如图4-11所示。
图4-11 中断发生后的堆栈框架
当中断处理程序执行完毕,最后一条指令iretd恢复上述保存在堆栈中的寄存器,然后继续执行中断发生前的代码。可以看出,iretd指令的动作是一次性从堆栈中恢复EFlags、CS和EIP。
该指令除了用于从通常中断中返回之外,还用于任务的切换。假设在中断发生前,运行的线程是T1,这时候发生一次时钟中断,CPU按照上述方式,在T1的堆栈中保存T1的相关寄存器(EFlags、CS、EIP),然后跳转到中断处理程序。中断处理程序在执行具体的任务前,首先保存T1线程的其他相关寄存器(EAX/EBX等通用寄存器),然后才开始执行具体的中断处理任务(定时器处理、睡眠线程唤醒等)。在执行完毕后,中断处理程序会从就绪队列中选择一个优先级最高的线程,假设为T2,然后恢复其寄存器信息(包括EAX等通用寄存器,还包括线程T2的堆栈寄存器ESP),并建立上述堆栈框架(这时候的上述寄存器,就不是线程T1的,而是新选择的线程T2的),这时候的目标堆栈,也不是T1的,而是T2的,上述堆栈框架建立完毕后,执行iretd指令,这样恢复运行的就不再是线程T1,而是新选择的线程T2。
线程切换的机制清楚后,我们再来看Hello China是如何实现在中断上下文中切换线程的细节部分。在当前的Hello China的实现中,中断处理程序被分成两部分实现。
(1)中断处理程序入口,采用汇编语言实现,该部分保存当前线程的寄存器(通用寄存器)信息,并把中断向量号压入堆栈,然后调用采用C语言实现的中断处理程序;
(2)C语言实现的中断处理程序,根据压入的堆栈号,再调用特定的中断处理函数(详细的中断处理过程,请参考本书第8章)。
采用汇编语言实现的中断处理入口程序,对所有的中断和异常都是类似的,代码如下。
np_int20: push eax cmp dword [gl_general_int_handler],0x00000000 jz .ll_continue push ebx ;;The following code saves the general ;;registers. push ecx push edx push esi push edi push ebp mov eax,esp push eax mov eax,0x20 push eax call dword [gl_general_int_handler] pop eax ;;Restore the general registers. pop eax mov esp,eax pop ebp pop edi pop esi pop edx pop ecx pop ebx .ll_continue: mov al,0x20 ;;Indicate the interrupt chip we have fin- ;;ished handle the interrupt. ;;:-) out 0x20,al out 0xa0,al pop eax iret
入口程序首先保存EAX寄存器,然后判断gl_general_int_handler是否为0,该标号实际上就是采用C语言实现的中断处理程序。若该标号为0,则说明对应的C语言实现的中断处理程序不存在(可能Master没有加载),这样直接跳转到.ll_contiune编号处,恢复中断控制器后从中断中返回。
若gl_general_int_handler不为0,则说明存在对应的C语言处理函数,于是该中断入口程序首先保存当前线程的通用寄存器信息,然后把当前中断向量号压入堆栈,并调用gl_general_int_handler函数。在调用gl_general_int_handler函数前,当前线程各寄存器在堆栈中的框架如图4-12所示。
图4-12 当前线程的各寄存器在堆栈中的布局
因为ESP是一个动态变化的指针,每次向堆栈中压入一个变量,ESP就增加对应的字节,因此,在上述堆栈框架中,保存的ESP寄存器的值,是在压入EBP后ESP的值。之所以保存该值,是因为gl_general_int_handler函数可以通过该值来访问堆栈框架。
gl_general_int_handler函数的原型如下。
VOID GeneralIntHandler(DWORD dwVector,LPVOID lpEsp);
可以看出,该函数有两个参数,即对应的中断向量号和堆栈框架指针。其中,堆栈向量号就是上述代码中压入的向量号,而堆栈框架指针则就是上述堆栈框架中保存的ESP的值。需要注意的是,中断处理函数是在当前线程的堆栈中执行的。这样通过上述两个参数,GeneralIntHandler函数就可以访问中断向量号和堆栈框架。
GeneralIntHandler函数根据中断向量号,再调用对应的中断处理程序。比如,时钟中断的中断向量号是0x20,则GeneralIntHandler函数会根据该向量号,查找一个数组,在该数组中,保存了每个中断处理例程的地址,找到对应的例程后,GeneralIntHandle函数就会调用对应的例程。对于线程的调度,目前只在时钟中断中进行处理,因此,只有0x20号中断发生后,才会发生线程的重新调度。
在0x20号中断(时钟中断)的处理程序中,所有线程相关的调度工作,通过一个函数ScheduleFromInt来实现,时钟中断处理在处理完所有其他任务后,在程序最后调用该函数。下面是该函数的实现代码,为了阅读方便起见,我们分段进行解释。
static VOID ScheduleFromInt(__COMMON_OBJECT* lpThis,LPVOID lpESP) { __KERNEL_THREAD_OBJECT* lpNextThread =NULL; __KERNEL_THREAD_OBJECT* lpCurrentThread=NULL; __KERNEL_THREAD_MANAGER* lpMgr =NULL; __KERNEL_THREAD_CONTEXT* lpContext =NULL; if((NULL==lpThis) || (NULL==lpESP)) //Parameters check. return; lpMgr=(__KERNEL_THREAD_MANAGER*)lpThis; if(NULL==lpMgr->lpCurrentKernelThread) //The routine is called first time in the //initialization process. //In this case,the routine does not need //to save the current kernel's context, //it only fetch the first ready kernel thread //from Ready Queue,and switch to this kernel //thread. { lpNextThread= (__KERNEL_THREAD_OBJECT*)KernelThreadManager.lpReadyQueue-> GetHeaderElement( (__COMMON_OBJECT*)KernelThreadManager.lpReadyQueue, NULL); if(NULL==lpNextThread) //If this case is occurs, the system is crash. { PrintLine("In ScheduleFromInt,lpCurrentKernelThread== NULL."); PrintLine(lpszCriticalMsg); return; } KernelThreadManager.lpCurrentKernelThread=lpNextThread; //Update the current kernel thread pointer. lpNextThread->dwThreadStatus=KERNEL_THREAD_STATUS_RUNNING; //Update the status. lpContext=&lpNextThread->KernelThreadContext; SwitchTo(lpContext); //Switch to the next kernel thread. }
在操作系统刚刚启动,还没有发生线程切换(时钟中断被禁止)的时候,是在初始化上下文中执行的,这时候的代码属初始化代码(也可以认为是一个初始化线程)。但Hello China的实现不把这部分代码作为任何线程,因此这时候,lpCurrentKernelThread是空值。就绪队列中却不是空的,因为初始化代码创建了Shell、IDLE等线程,这些线程被放入就绪队列。
一旦初始化代码执行完毕,就会使能时钟中断,这时候,一旦发生时钟中断,该函数就会被调用。若lpCurrentKernelThread是空值,说明该函数是第一次被调用,这时候,该函数会从就绪队列中取出第一个线程对象(优先级最高的线程对象),并调用SwitchTo函数,切换到这个线程。SwitchTo函数是实现线程切换的汇编语言函数,在后面我们会详细描述,现在只要知道,一旦以目标线程的上下文信息(lpContext)调用了SwitchTo函数,就会切换到目标线程开始运行。
else { lpCurrentThread=KernelThreadManager.lpCurrentKernelThread; lpContext=&lpCurrentThread->KernelThreadContext; SaveContext(lpContext,(DWORD*)lpESP); //Save the current kernel thread's context.
若lpCurrentKernelThread不是空,则说明该函数不是第一次被调用(有且只有第一次被调用的时候,lpCurrentKernelThread为空),当中断发生的时候,已经有线程在运行了。这种情况下,该函数首先获得当前运行的线程(实际上是中断发生前运行的线程)的上下文结构(lpContext),然后调用SaveContext函数保存当前线程的上下文信息。SaveContex函数的具体实现,在后面我们再详细介绍。
保存线程上下文信息后,该函数会根据线程的状态,做进一步判断。
switch(lpCurrentThread->dwThreadStatus) { case KERNEL_THREAD_STATUS_BLOCKED: case KERNEL_THREAD_STATUS_TERMINAL: case KERNEL_THREAD_STATUS_SLEEPING: //ENTER_CRITICAL_SECTION(); lpCurrentThread->dwScheduleCounter-=1; if(0==lpCurrentThread->dwScheduleCounter) { lpCurrentThread->dwScheduleCounter= lpCurrentThread->dwThreadPriority; } lpCurrentThread->dwTotalRunTime+=SYSTEM_TIME_SLICE; lpContext=&lpCurrentThread->KernelThreadContext; SwitchTo(lpContext); break; //This instruction will never reach. default: break; }
一般情况下,线程的状态应该是RUNNING,因为被打断的时候,线程是处于运行状态的。但下列三种状态在线程被时钟中断打断的时候也可能出现。
(1)KERNEL_THREAD_STATUS_BLOCKED。这种状态下的线程,是正在执行一个等待共享资源的操作(WaitForThisObject),在等待共享资源的时候,线程的状态首先被设置为BLOCKED,然后被插入共享资源的本地等待队列。这个时候,若线程在被插入本地等待队列前发生中断,则其状态会为BLOCKED。这种情况下,中断调度程序不会再选择其他线程投入运行,而是继续恢复BLOCKED线程的上下文,让该线程继续执行。在线程被成功插入共享对象的本地等待队列后,会再次发生一次线程切换,这时候,当前优先级最高的就绪线程会被调度执行。
(2)KERNEL_THREAD_STATUS_SLEEPING。与上述类似,若当前线程执行Sleep调用,则线程的状态会首先被设置为KERNEL_THREAD_STATUS_SLEEPING,然后会被插入睡眠队列。在插入睡眠队列前,若有时钟中断发生,则线程的状态会为SLEEPING,这种情况下,调度程序也不会重新调度其他线程,而是直接恢复当前线程,因为当前线程马上就会停止运行(被插入睡眠队列后,会引发一次线程调度)。
(3)KERNEL_THREAD_STATUS_TERMINAL。在线程执行结束,但线程的“扫尾”工作还没有完成的时候,会发生这种情况。这时候,线程的状态已经被设置为TERMINAL,但还在处理一些线程的扫尾工作,比如唤醒等待该线程对象的其他线程等,这时候若发生时钟中断,则会出现线程状态为TERMINAL的线程。这个时候,时钟中断仍然继续恢复该线程的上下文,使得该线程继续执行。因为该线程马上就可以执行完毕,从而引发另一次线程的切换。
若当前线程的状态不是上述几种情况,则时钟中断会重新调度。这时候,时钟中断处理程序会把当前线程的状态设置为KERNEL_THREAD_STATUS_READY,并递减其调度计数,若调度计数达到0,则重新设置其调度计数(详细信息请参考4.2.5节),然后把当前线程对象插入就绪队列(lpReadyQueue)。由于就绪队列是一个优先队列,因此在插入的时候,使用当前线程的调度计数(dwScheduleCounter)为优先字段,插入就绪队列。这种情况下,当前线程的dwScheduleCounter决定了在就绪队列的位置。如果当前线程的dwScheduleCounter为所有线程中最大的,则该线程仍然会被排在队列头部,这样下一次被选择调度的线程,仍然是当前线程。相关代码如下。
lpCurrentThread->dwThreadStatus = KERNEL_THREAD_STATUS_READY; lpCurrentThread->dwScheduleCounter-=1; if(0==lpCurrentThread->dwScheduleCounter) { lpCurrentThread->dwScheduleCounter = lpCurrentThread-> dwThreadPriority; } lpCurrentThread->dwTotalRunTime +=SYSTEM_TIME_SLICE; KernelThreadManager.lpReadyQueue->InsertIntoQueue( //Insert into ready queue. (__COMMON_OBJECT*)KernelThreadManager.lpReadyQueue, (__COMMON_OBJECT*)lpCurrentThread, lpCurrentThread->dwScheduleCounter );
在把当前线程插入就绪队列后,调度程序会重新检查就绪队列,从就绪队列中选择第一个线程(优先级最高的线程)投入运行。按照Hello China的实现,就绪队列中至少有一个线程,即IDLE线程,因此,若从就绪队列中获取线程操作失败,则是一种严重异常的情况,这种情况下,会打印出一串告警信息,然后系统会进入死循环。代码如下。
lpNextThread=(__KERNEL_THREAD_OBJECT*) KernelThreadManager.lpReadyQueue->GetHeaderElement( //Get next ready one. (__COMMON_OBJECT*)KernelThreadManager.lpReadyQueue, NULL); if(NULL==lpNextThread) //If this case occurs,the system will crash. { PrintLine("In ScheduleFromInt,lpCurrentKernelThread !=NULL."); PrintLine(lpszCriticalMsg); Dead(); return; }
获得下一步该调度的线程后,首先设置其状态为KERNEL_THREAD_STATUS_RUNNING,然后把lpCurrentKernelThread设置为该线程,并调用SwitchTo函数,切换到该线程。代码如下。
lpNextThread->dwThreadStatus=KERNEL_THREAD_STATUS_RUNNING; KernelThreadManager.lpCurrentKernelThread=lpNextThread; //Update the current kernel thread. lpContext=&lpNextThread->KernelThreadContext; SwitchTo(lpContext); //Switch to the new kernel thread. } }
至此,对中断上下文中的线程调度就解释完了。在此总结一下。
(1)目前的实现,在所有的硬件中断中,Hello China只在时钟中断中实现了线程调度,实际上,大多数操作系统都是这么做的。
(2)当前线程的上下文信息,是在中断处理程序的入口处(采用汇编语言编写的代码)进行保存的。
(3)在中断处理程序中,调用ScheduleFromInt函数来实现线程的调度,需要注意的是,这个函数在中断处理程序的最后部分被调用,因为该函数不会返回,直接切换到目标线程开始运行。
(4)对线程的切换,在IA32 CPU上采用iretd指令实现。
(5)ScheduleFromInt函数调用SaveContext函数保存当前线程的上限文,调用SwitchTo函数来切换到目标线程。
(6)对于状态是BLOCKED、TERMINAL、SLEEPING的线程,不做调度,而是恢复其上下文,使得这些线程继续运行。因为处于这些状态的线程,都是临时状态,很快就会被切换出去。
两个底层的函数SaveContext和SwitchTo,实现线程上下文的保存和切换工作,在本文的后面部分进行详细描述。