Linux0.11 进程切换(十)

在这里插入图片描述

系列文章目录


Linux 0.11启动过程分析(一)
Linux 0.11 fork 函数(二)
Linux0.11 缺页处理(三)
Linux0.11 根文件系统挂载(四)
Linux0.11 文件打开open函数(五)
Linux0.11 execve函数(六)
Linux0.11 80X86知识(七)
Linux0.11 内核体系结构(八)
Linux0.11 系统调用进程创建与执行(九)
Linux0.11 进程切换(十)


文章目录

  • 系列文章目录
  • 一、简介
  • 二、代码
  • 三、进程切换
  • 1、switch_to(1) 执行前
    • 1.1 **task0** 用户态
      • 寄存器的信息
      • task_struct 信息
      • ldt
      • tss
    • 1.2 **task1**
      • task_struct
      • ldt
      • tss
  • 2、switch_to(1) 执行后
    • 2.1 task0 信息变化
      • tss
    • 2.2 task1 信息变化
      • 寄存器的信息(用户态)
      • 寄存器的信息(内核态)


一、简介

    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 指针作为各个正在等待任务的联系。

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

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

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

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

    还有一个函数 interruptible_sleep_on(),它的结构与 sleep_on()的基本类似,只是在进行调度之前是把当前任务置成了可中断等待状态,并在本任务被唤醒后还需要判断队列上是否有后来的等待任务,若有,则调度它们先运行。在内核 0.12 开始,这两个函数被合二为一,仅用任务的状态作为参数来区分这两种情况。
    在阅读本文件的代码时,最好同时参考包含文件 include/linux/sched.h 文件中的注释,以便更清晰地了解内核的调度机理。

二、代码

// 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;
}

三、进程切换

    调度程序头文件,定义了任务结构 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 的情况。

1、switch_to(1) 执行前

1.1 task0 用户态

寄存器的信息

Linux0.11 进程切换(十)_第3张图片

task_struct 信息

Linux0.11 进程切换(十)_第4张图片

ldt

Linux0.11 进程切换(十)_第5张图片

tss

Linux0.11 进程切换(十)_第6张图片

1.2 task1

task_struct

Linux0.11 进程切换(十)_第7张图片

ldt

Linux0.11 进程切换(十)_第8张图片

tss

Linux0.11 进程切换(十)_第9张图片

2、switch_to(1) 执行后

2.1 task0 信息变化

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

tss

Linux0.11 进程切换(十)_第10张图片

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

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

2.2 task1 信息变化

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

寄存器的信息(用户态)

Linux0.11 进程切换(十)_第11张图片

寄存器的信息(内核态)

Linux0.11 进程切换(十)_第12张图片

你可能感兴趣的:(Linux内核,linux,服务器,运维)