Linux0.11 进程切换(十)

简介: Linux0.11 进程切换(十)

一、简介

   sched.c 是内核中有关任务(进程)调度管理的程序,其中包括有关调度的基本函数(sleep_on()、wakeup()、schedule()等)以及一些简单的系统调用函数(比如 getpid())。系统时钟中断处理过程中调用的定时函数 do_timer()也被放置在本程序中。另外,为了便于软盘驱动器定时处理的编程,Linus 也将有关软盘定时操作作的几个函数放到了这里。


  这几个基本函数的代码虽然不长,但有些抽象,比较难以理解。好在市面上有许多教科书对此解释得都很清楚,因此可以参考其他书籍对这些函数的讨论。这些也就是教科书上重点讲述的对象,否则理论书籍也就没有什么好讲的了。这里仅对调度函数 schedule()作一些详细说明。


  schedule()函数负责选择系统中下一个要运行的进程。它首先对所有任务(进程)进行检测,唤醒任何一个已经得到信号的任务。具体方法是针对任务数组中的每个任务,检查其报警定时值 alarm。如果任务的 alarm 时间已经过期(alarm < jiffies),则在它的信号位图中设置 SIGALRM 信号,然后清 alarm 值。jiffies 是系统从开机开始算起的滴答数(10ms/滴答)。在 sched.h 中定义。如果进程的信号位图中除去被阻塞的信号外还有其他信号,并且任务处于可中断睡眠状态(TASK_INTERRUPTIBLE),则置任务为就绪状态(TASK_RUNNING)。


  随后是调度函数的核心处理部分。这部分代码根据进程的时间片和优先权调度机制,来选择随后要执行的任务。它首先循环检查任务数组中的所有任务,根据每个就绪态任务剩余执行时间的值 counter,选取该值最大的一个任务,并利用 switch_to()函数切换到该任务。若所有就绪态任务的该值都等于零,表示此刻所有任务的时间片都已经运行完,于是就根据任务的优先权值 priority,重置每个任务的运行时间片值 counter,再重新执行循环检查所有任务的执行时间片值。


  另两个值得一提的函数是自动进入睡眠函数 sleep_on()和唤醒函数 wake_up(),这两个函数虽然很短,却要比 schedule()函数难理解。这里用图示的方法加以解释。简单地说,sleep_on()函数的主要功能是当一个进程(或任务)所请求的资源正忙或不在内存中时暂时切换出去,放在等待队列中等待一段时间。当切换回来后再继续运行。放入等待队列的方式是利用了函数中的 tmp 指针作为各个正在等待任务的联系。


  函数中共牵涉到对三个任务指针操作:*p、tmp 和 current,*p 是等待队列头指针,如文件系统内存 i 节点的 i_wait 指针、内存缓冲操作中的 buffer_wait 指针等;tmp 是在函数堆栈上建立的临时指针,存储在当前任务内核态堆栈上;current 是当前任务指针。对于这些指针在内存中的变化情况我们可以用图 8-6 的示意图说明。图中的长条表示内存字节序列。


   当刚进入该函数时,队列头指针 * p 指向已经在等待队列中等待的任务结构(进程描述符)。 当然,在系统刚开始执行时,等待队列上无等待任务。因此上图中原等待任务在刚开始时是不存在的,此时 *p 指向 NULL。通过指针操作,在调用调度程序之前,队列头指针指向了当前任务结构,而函数中的临时指针 tmp 指向了原等待任务。在执行调度程序并在本任务被唤醒重新返回执行之前,当前任务指针被指向新的当前任务,并且 CPU 切换到该新的任务中执行。这样本次 sleep_on()函数的执行使得 tmp 指针指向队列中队列头指针指向的原等待任务,而队列头指针则指向此次新加入的等待任务,即调用本函数的任务。从而通过堆栈上该临时指针 tmp 的链接作用,在几个进程为等待同一资源而多次调用该函数时,内核程序就隐式地构筑出一个等待队列。从图 8-7 中我们可以更容易地理解 sleep_on()函数的等待队列形成过程。图中示出了当向队列头部插入第三个任务时的情况。


   在插入等待队列后 sleep_on()函数就会调用 schedule()函数去执行别的进程。当进程被唤醒而重新执行时就会执行后续的语句,把比它早进入等待队列的一个进程唤醒。注意,这里所谓的唤醒并不是指进程处于执行状态,而是处于可以被调度执行的就绪状态。


   唤醒操作函数 wake_up(把正在等待可用资源的指定任务置为就绪状态。该函数是一个通用唤醒函数。在有些情况下,例如读取磁盘上的数据块,由于等待队列中的任何一个任务都可能被先唤醒,因此还需要把被唤醒任务结构的指针置空。这样,在其后进入睡眠的进程被唤醒而又重新执行 sleep_on()时,就无需唤醒该进程了。


   还有一个函数 interruptible_sleep_on(),它的结构与 sleep_on()的基本类似,只是在进行调度之前是把当前任务置成了可中断等待状态,并在本任务被唤醒后还需要判断队列上是否有后来的等待任务,若有,则调度它们先运行。在内核 0.12 开始,这两个函数被合二为一,仅用任务的状态作为参数来区分这两种情况。

   在阅读本文件的代码时,最好同时参考包含文件 include/linux/sched.h 文件中的注释,以便更清晰地了解内核的调度机理。

二、代码

1、sleep_on 函数

// kernel/sched.c

// 把当前任务置为不可中断的等待状态,并让睡眠队列头指针指向当前任务。
// 只有明确地唤醒时才会返回。该函数提供了进程与中断处理程序之间的同步机制。
// 函数参数 p 是等待任务队列头指针。指针是含有一个变量地址的变量。这里参数 p 使用了指针的
// 指针形式'**p',这是因为 C 函数参数只能传值,没有直接的方式让被调用函数改变调用该函数
// 程序中变量的值。但是指针' *p' 指向的目标(这里是任务结构)会改变,因此为了能修改调用该
// 函数程序中原来就是指针变量的值,就需要传递指针' *p' 的指针,即'**p'。参见图 8-6 中 p'指针。
// 的使用情况。
void sleep_on(struct task_struct **p)
{
  struct task_struct *tmp;
// 若指针无效,则退出。(指针所指的对象可以是 NULL,但指针本身不应该为 0)。另外,如果
// 当前任务是任务 0,则死机。因为任务 0 的运行不依赖自己的状态,所以内核代码把任务 0 置
// 为睡眠状态毫无意义。
  if (!p)
    return;
  if (current == &(init_task.task))
    panic("task[0] trying to sleep");
// 让 tmp 指向已经在等待队列上的任务(如果有的话),例如 inode->i_wait。并且将睡眠队列头
// 的等待指针指向当前任务。这样就把当前任务插入到了 *p 的等待队列中。然后将当前任务置。
// 为不可中断的等待状态,并执行重新调度。
  tmp = *p;
  *p = current;
  current->state = TASK_UNINTERRUPTIBLE;
  schedule();
// 只有当这个等待任务被唤醒时,调度程序才又返回到这里,表示本进程已被明确地唤醒(就
// 绪态)。既然大家都在等待同样的资源,那么在资源可用时,就有必要唤醒所有等待该资源
// 的进程。该函数嵌套调用,也会嵌套唤醒所有等待该资源的进程。这里嵌套调用是指当一个
// 进程调用了 sleep_on( 后就会在该函数中被切换掉,控制权被转移到其他进程中。此时若有
// 进程也需要使用同一资源,那么也会使用同一个等待队列头指针作为参数调用 sleep_on() 函
// 数,并且也会"陷入"该函数而不会返回。只有当内核某处代码以队列头指针作为参数 wake_up
// 了该队列,那么当系统切换去执行头指针所指的进程 A 时,该进程才会继续执行 163 行,把。
// 队列后一个进程 B 置位就绪状态(唤醒)。 而当轮到 B 进程执行时,它也才可能继续执行
// 163 行。若它后面还有等待的进程 C,那么它也会把 C 唤醒等。这里在 163 行前还应该添加。
// 一条语句:*p = tmp; 见 183 行上的解释。
  if (tmp)    // 若在其前还存在等待的任务,则也将其置为就绪状态(唤醒)。
    tmp->state=0;
}

2、schedule 函数

// 文件路径 kernel/sched.c
/*
 *  'schedule()' is the scheduler function. This is GOOD CODE! There
 * probably won't be any reason to change this, as it should work well
 * in all circumstances (ie gives IO-bound processes good response etc).
 * The one thing you might take a look at is the signal-handler code here.
 *
 *   NOTE!!  Task 0 is the 'idle' task, which gets called when no other
 * tasks can run. It can not be killed, and it cannot sleep. The 'state'
 * information in task[0] is never used.
 */
/*
* 'schedule()'是调度函数。这是个很好的代码!没有任何理由对它进行修改,因为
* 它可以在所有的环境下工作(比如能够对 IO-边界处理很好的响应等)。只有一件
* 事值得留意,那就是这里的信号处理代码。
* 
*  注意!! 任务 0 是个闲置(' idle')任务,只有当没有其他任务可以运行时才调片
* 它。它不能被杀死,也不能睡眠。任务 0 中的状态信息'state' 是从来不用的。
*/
void schedule(void)
{
  int i,next,c;
  struct task_struct ** p;    // 任务结构指针的指针。

/* check alarm, wake up any interruptible tasks that have got a signal */
/* 检测 alarm(进程的报警定时值), 映醒任何已得到信号的可中断任务 */

// 从任务数组中最后一个任务开始循环检测 alarm。在循环时跳过空指针项。
  for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
    if (*p) {
// 如果设置过任务的定时值 alarm, 并且已经过期(alarm<jiffies), 则在信号位图中置 SIGALRM
// 信号, 即向任务发送 SIGALARM 信号。然后清 alarm。该信号的默认操作是终止进程。jiffies
// 是系统从开机开始算起的滴答数(10ms/滴答)。定义在 sched.h 第 139 行。
      if ((*p)->alarm && (*p)->alarm < jiffies) {
          (*p)->signal |= (1<<(SIGALRM-1));
          (*p)->alarm = 0;
        }
// 如果信号位图中除被阻塞的信号外还有其他信号,并且任务处于可中断状态,则置任务为就绪
// 状态。其中'~(_BLOCKABLE & (*p)->blocked)'用于忽略被阻塞的信号,但 SIGKILL 和 SIGSTOP
// 不能被阻塞。
      if (((*p)->signal & ~(_BLOCKABLE & (*p)->blocked)) &&
      (*p)->state==TASK_INTERRUPTIBLE)
        (*p)->state=TASK_RUNNING;   //置为就绪(可执行)状态。
    }

/* this is the scheduler proper: */
/* 这里是调度程序的主要部分 */
  while (1) {
    c = -1;
    next = 0;
    i = NR_TASKS;
    p = &task[NR_TASKS];
// 这段代码也是从任务数组的最后一个任务开始循环处理,并跳过不含任务的数组槽。比较每个
// 就绪状态任务的 counter(任务运行时间的遗减滴答计数)值,哪一个值大,运行时间还不长,
// next 就指向哪个的任务号。
    while (--i) {
      if (!*--p)
        continue;
      if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
        c = (*p)->counter, next = i;
    }
// 如果比较得出有 counter 值不等于 0 的结果, 或者系统中没有一个可运行的任务存在(此时 c
// 仍然为-1,next=0),则退出 124 行开始的循环, 执行 141 行上的任务切换操作。否则就根据
// 每个任务的优先权值, 更新每一个任务的 counter 值, 然后回到 125 行重新比较。counter 值
// 的计算方式为 counter = counter /2 + priority。注意,这里计算过程不考虑进程的状态。
    if (c) break;
    for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
      if (*p)
        (*p)->counter = ((*p)->counter >> 1) +
            (*p)->priority;
  }
// 用下面宏(定义在 sched.h 中)把当前任务指针 current 指向任务号为 next 的任务,并切换
// 到该任务中运行。在 126 行 next 被初始化为 0。因此若系统中没有任何其他任务可运行时,
// 则 next 始终为 0。因此调度函数会在系统空闲时去执行任务 0。 此时任务 0 仅执行 pause()
// 系统调用,并又会调用本函数。
  switch_to(next);
}

可参考:调度程序

三、进程切换

1、switch_to 函数

   调度程序头文件,定义了任务结构 task_struct、初始任务 0 的数据,还有一些有关描述符参数设置和获取以及任务上下文切换 switch_to()的嵌入式汇编函数宏。下面详细描述一下任务切换宏的执行过程。

   任务切换宏 switch_to(n)(从 171 行开始)首先申明了一个结构 ‘struct {long a,b;} __tmp’,用于在任务内核态堆栈上保留出 8 字节的空间来存放将切换到新任务的任务状态段 TSS 的选择符。然后测试我们是否是在执行切换到当前任务的操作,如果是则什么也不需要做,直接退出。否则就把新任务 TSS 的选择符保存到临时结构 __tmp 中的偏移位置 4 处,此时 __tmp 中的数据设置为:

__tmp+0:未定义(long)

__tmp+4:新任务 TSS 的选择符(word)

__tmp+6:未定义(word)


  接下来把 %ecx 寄存器中的新任务指针与全局变量 current 中的当前任务指针相交换,让 current 含有我们将要切换到的新任务的指针值,而 ecx 中则保存着当前任务(本任务)的指针值。接着执行间接长跳转到 __tmp 的指令 Ijmp。长跳转到新任务 TSS 选择符的指令将忽略 __tmp 中未定义值的部分,CPU 将自动跳转到 TSS 段指定新任务中去执行,而本任务也就到此暂停执行。这也是我们无需设置结构变量 __tmp 中其他未定义部分的原因。参见第 5 章中图 2-22:任务切换操作示意图。

  当一段时间之后,某个任务的 ljmp 指令又会跳转到本任务 TSS 段选择符,从而造成 CPU 切换回本任务,并从 ljmp 的下一条指令开始执行。此时 ecx 中含有本任务即当前任务的指针,因此我们可以使用该指针来检查它是否是最后(最近)一个使用过数学协处理器的任务。若本任务没有使用过协处理器则立刻退出,否则执行 clts 指令以复位控制寄存器 CR0 中的任务已切换标志 TS。每当任务切换时 CPU 都会设置该标志位,并且在执行协处理器指令之前测试该标志位。Linux 系统中的这种处理 TS 标志的方法可以让内核避免对协处理状态不必要的保存、恢复操作过程,从而提高了协处理器的执行性能。

// include/linux/sched.h

/*
* 在 GDT 表中寻找第 1 个 TSS 的入口。0-没有用 nul, 1-代码段 cs, 2-数据段 ds, 3-系统调用 syscall
* 4-任务状态段 TSS0, 5-局部表 LTD0, 6-任务状态段 TSS1, 等。
*/
// 从该英文注释可以猜想到,Linus 当时曾想把系统调用的代码专门放在 GDT 表中第 4 个独立的段中。
// 但后来并没有那样做,于是就一直把 GDT 表中第 4 个描述符项(上面 syscall 项)闲置在一旁。
// 下面定义宏:全局表中第 1 个任务状态段(TSS)描述符的选择符索引号。
#define FIRST_TSS_ENTRY 4
// 全局表中第 1 个局部描述符表(LDT)描述符的选择符索引号。
#define FIRST_LDT_ENTRY (FIRST_TSS_ENTRY+1)
// 宏定义,计算在全局表中第 n 个任务的 TSS 段描述符的选择符值(偏移量)。
// 因每个描述符占 8 字节,因此 FIRST_TSS_ENTRY<<3 表示该描述符在 GDT 表中的起始偏移位置。
// 因为每个任务使用 1 个 TSS 和 1 个 LDT 描述符,共占用 16 字节,因此需要 n<<4 来表示对应
// TSS 起始位置。该宏得到的值正好也是该 TSS 的选择符值。
#define _TSS(n) ((((unsigned long) n)<<4)+(FIRST_TSS_ENTRY<<3))
/// 宏定义,计算在全局表中第 n 个任务的 LDT 段描述符的选择符值(偏移量)。
#define _LDT(n) ((((unsigned long) n)<<4)+(FIRST_LDT_ENTRY<<3))
// 宏定义,把第 n 个任务的 TSS 段选择符加载到任务寄存器 TR中。
#define ltr(n) __asm__("ltr %%ax"::"a" (_TSS(n)))
// 宏定义,把第 n 个任务的 LDT 段选择符加载到局部描述符表寄存器 LDTR 中。
#define lldt(n) __asm__("lldt %%ax"::"a" (_LDT(n)))
// 取当前运行任务的任务号(是任务数组中的索引值,与进程号 pid 不同)。
// 返回:n - 当前任务号。用于(kerne1/traps.c, 79)。
#define str(n) \
__asm__("str %%ax\n\t" \
  "subl %2,%%eax\n\t" \
  "shrl $4,%%eax" \
  :"=a" (n) \
  :"a" (0),"i" (FIRST_TSS_ENTRY<<3))
  
/*
* switch_to(n)将切换当前任务到任务 nr,即 n。首先检测任务 n 不是当前任务,
* 如果是则什么也不做退出。如果我们切换到的任务最近(上次运行)使用过数学。
* 协处理器的话,则还需复位控制寄存器 cr0 中的 TS 标志。
*/
// 跳转到一个任务的 TSS 段选择符组成的地址处会造成 CPU 进行任务切换操作。
// 输入:%0 - 指向 _tmp;     
//    %1 - 指向__tmp.b 处,用于存放新 TSS 的选择符;
//    dx - 新任务 n 的 TSS 段选择符;
//    ecx - 新任务 n 的任务结构指针 task[n]。
//
// 其中临时数据结构 _tmp 用于组建 177 行远跳转(far jump)指令的操作数。该操作数由 4 字节偏移
// 地址和 2 字节的段选择符组成。因此__tmp 中 a 的值是 32 位偏移值,而 b 的低 2 字节是新 TSS 段的
// 选择符(高 2 字节不用)。跳转到 TSS 段选择符会造成任务切换到该 TSS 对应的进程。对于造成任务。
// 切换的长跳转,a 值无用。177 行上的内存间接跳转指令使用 6 字节操作数作为跳转目的地的长指针,
// 其格式为:jmp 16 位段选择符:32 位偏移值。但在内存中操作数的表示顺序与这里正好相反。
// 任务切换回来之后,在判断原任务上次执行是否使用过协处理器时,是通过将原任务指针与保存在
// last_task_used_math 变量中的上次使用过协处理器任务指针进行比较而作出的,参见文件。
// kernel/sched.c 中有关 math_state_restore()函数的说明。
#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,current\n\t" \  // 任务 n 是当前任务吗?(current ==task[n]?)
  "je 1f\n\t" \         // 是,则什么都不做,退出。
  "movw %%dx,%1\n\t" \      // 将新任务 TSS 的 16 位选择符存入__tmp.b 中。
  "xchgl %%ecx,current\n\t" \   // current = task[n]; ecx = 被切换出的任务。
  "ljmp *%0\n\t" \        // 执行长跳转至*&___tmp,造成任务切换。
                  // 在任务切换回来后才会继续执行下面的语句。
  "cmpl %%ecx,last_task_used_math\n\t" \    // 原任务上次使用过协处理器吗?
  "jne 1f\n\t" \          // 没有则跳转,退出。
  "clts\n" \            // 原任务上次使用过协处理器,则清 cr0 中的任务切换
  "1:" \              // 标志 TS。
  ::"m" (*&__tmp.a),"m" (*&__tmp.b), \
  "d" (_TSS(n)),"c" ((long) task[n])); \  // edx 存放了新任务的 TSS, ecx 存放了 task[n]
}

   从上面代码可知:当前任务对 GDT 中的 TSS 描述符执行 LJMP 指令可导致任务切换;在任务切换期间,当前运行任务的执行环境(称为任务的状态或上下文)会被保存到它的 TSS 中并且暂停该任务的执行。此后新调度任务的上下文会被加载进处理器中,并且从加载的 EIP 指向的指令处开始执行新任务。具体可参考: 九、任务管理4、任务切换

 以下是在 task0 的用户态执行 switch_to(1) 切换到 task1 的情况。

2、switch_to(1) 执行前

2.1 task0 用户态

寄存器的信息

task_struct 信息

ldt

tss

2.2 task1

task_struct

ldt

tss

3、switch_to(1) 执行后

3.1 task0 信息变化

从前面我们知道,任务切换时,CPU 会自动保存原有的信息到 task0tss 段上。因此其 tss 段发生了变化。备注:ldt 保持不变。

tss

保存的字段可参考: 3.1 任务状态段4、任务切换

结合 task0 用户态中的寄存器的信息,可见任务切换时,这些信息确实保存在 tss 字段中。

3.2 task1 信息变化

任务切换时,CPU 会自动保存原有的信息到 tasktss 段上。同时会加载当前 tss 段信息到相应的寄存器中(恢复的是用户态的信息)。

寄存器的信息(用户态)



寄存器的信息(内核态)

目录
相关文章
|
16天前
|
网络协议 Linux
Linux查看端口监听情况,以及Linux查看某个端口对应的进程号和程序
Linux查看端口监听情况,以及Linux查看某个端口对应的进程号和程序
94 2
|
16天前
|
Linux Python
linux上根据运行程序的进程号,查看程序所在的绝对路径。linux查看进程启动的时间
linux上根据运行程序的进程号,查看程序所在的绝对路径。linux查看进程启动的时间
32 2
|
20天前
|
消息中间件 Linux 开发者
Linux进程间通信秘籍:管道、消息队列、信号量,一文让你彻底解锁!
【8月更文挑战第25天】本文概述了Linux系统中常用的五种进程间通信(IPC)模式:管道、消息队列、信号量、共享内存与套接字。通过示例代码展示了每种模式的应用场景。了解这些IPC机制及其特点有助于开发者根据具体需求选择合适的通信方式,促进多进程间的高效协作。
48 3
|
18天前
|
消息中间件 Linux
Linux进程间通信
Linux进程间通信
31 1
|
19天前
|
C语言
Linux0.11 系统调用进程创建与执行(九)(下)
Linux0.11 系统调用进程创建与执行(九)
19 1
|
19天前
|
存储 Linux 索引
Linux0.11 系统调用进程创建与执行(九)(上)
Linux0.11 系统调用进程创建与执行(九)
34 1
|
23天前
|
Web App开发 Linux
在Linux中,如何杀死一个进程?
在Linux中,如何杀死一个进程?
|
23天前
|
域名解析 监控 安全
在Linux中,什么是守护进程,它们是如何工作的?
在Linux中,什么是守护进程,它们是如何工作的?
|
22天前
|
消息中间件 Linux
在Linux中,进程间通信方式有哪些?
在Linux中,进程间通信方式有哪些?
|
13天前
|
Unix Linux
linux中在进程之间传递文件描述符的实现方式
linux中在进程之间传递文件描述符的实现方式