嵌入式操作系统
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

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,实现线程上下文的保存和切换工作,在本文的后面部分进行详细描述。