一、简介
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 会自动保存原有的信息到 task0 的 tss 段上。因此其 tss 段发生了变化。备注:ldt 保持不变。
tss
结合 task0 用户态中的寄存器的信息,可见任务切换时,这些信息确实保存在 tss 字段中。
3.2 task1 信息变化
任务切换时,CPU 会自动保存原有的信息到 task 的 tss 段上。同时会加载当前 tss 段信息到相应的寄存器中(恢复的是用户态的信息)。
寄存器的信息(用户态)
寄存器的信息(内核态)