linux0.11内核源码修仙传第十三章——进程调度大战前夕

🚀 前言

    所谓的进程调度,其实就是让 CPU 一会去程序 1 的位置处运行一段时间,一会去程序 2 的位置处运行一段时间。本文是在真正去进行进程调度之前所需要的一些准备。本文对应的章节是书中第23~24回。希望各位给个三连,拜托啦,这对我真的很重要!!!

🏆进程调度大体流程

📃 如何让CPU一会去这里一会去那里

    有种方法是在程序1里面隔几行就放弃自己的执行权跳转程序2,程序2也是如此。但是这种方法不靠谱且不公平。那就想个办法,找一个第三方来协调,这个第三方就是调度程序。

    这时候就得提到定时器了,之前初始化的时候提到过的,不记得的话可以查看这篇博客:linux0.11内核源码修仙传第十章——进程调度始化简单来说就是定时器根据设定好的时间定时向CPU发送一个计时器中断。之后的进程调度函数的基础就是这个时钟。

📃上下文环境

    有计算机基础的同学可能知道,虚拟内存的一大优点就是让每个程序都以为自己能用的整个内存,但是其实从物理层面上,只要这个程序执行程序,那就肯定会涉及寄存器,内存和外设端口。那总不能每个程序都配一组吧,自然只能做出避让了。

    这些需要避让的寄存器就算是上下文环境,具体的寄存器如下所示:

在这里插入图片描述
    具体会出现什么样的干扰呢?比如程序1往eax寄存器写入一个值准备用,这是被切换到了进程2,又往 eax 写入了一个值。那么回到进程1的时候不就出错了吗。所以聪明的你一定想到了,每个进程都把自己的环境保存好,等到CPU控制权回到自己手上的时候,重新将这些寄存器赋成之前的值就好了,恭喜你,你已经学会了保存上下文!

    那么怎么保存呢?采用下面的结构体,下面的结构体就包含了所需要的所有寄存器:

struct task_struct {
	···
	struct tss_struct tss;
};

struct tss_struct {
	long	back_link;	/* 16 high bits zero */
	long	esp0;
	long	ss0;		/* 16 high bits zero */
	long	esp1;
	long	ss1;		/* 16 high bits zero */
	long	esp2;
	long	ss2;		/* 16 high bits zero */
	long	cr3;
	long	eip;
	long	eflags;
	long	eax,ecx,edx,ebx;
	long	esp;
	long	ebp;
	long	esi;
	long	edi;
	long	es;		/* 16 high bits zero */
	long	cs;		/* 16 high bits zero */
	long	ss;		/* 16 high bits zero */
	long	ds;		/* 16 high bits zero */
	long	fs;		/* 16 high bits zero */
	long	gs;		/* 16 high bits zero */
	long	ldt;		/* 16 high bits zero */
	long	trace_bitmap;	/* bits: trace 0, bitmap 16-31 */
	struct i387_struct i387;
};

    这里面注意,还有个 cr3 寄存器,还记得这个寄存器都是干嘛的嘛,可以看之前的博客:linux0.11内核源码修仙传第三章——head.s📃开启方式 节。这个寄存器是指向页目录表首地址的。

在这里插入图片描述
    那就很有意思了,如果每个进程都有一个cr3寄存器,那不就意味着每个进程都可以有自己的页表了吗?事实就是如此,这样的话,线性地址到物理地址的映射关系就有能力做到不同。也就是说,在我们刚刚假设的理想情况下,不同程序用不同的内存地址可以做到内存互不干扰。但是很遗憾, Linux 0.11 并不是通过替换 cr3 寄存器来实现内存互不干扰的,这是后话了。

📃 进程调度时机

    在上面我们已经解决了进程转换时的准备,现在来看看进程转换时机。

    上面提到了定时器,那总不能每次时钟中断都切换一次吧?一来这样不灵活,二来这完全依赖时钟中断的频率,有点危险。所以一个好的办法就是,给进程一个属性,叫剩余时间片,每次时钟中断来了之后都 -1,如果减到 0 了,就触发切换进程的操作。在代码里面用counter来代表。

struct task_struct {
	···
	long counter;
	···
	struct tss_struct tss;
};

    他的用法也非常简单,就是每次中断都判断一下是否到 0 了。

void do_timer(long cpl)
{
	···
	// 当前还有剩余时间直接返回
	if ((--current->counter)>0) return;
	current->counter=0;
	if (!cpl) return;
	// 没有时间片,调度
	schedule();
}

    如果还没到 0,就直接返回,相当于这次时钟中断什么也没做,仅仅是给当前进程的时间片属性做了 -1 操作。如果已经到 0 了,就触发进程调度,选择下一个进程并使 CPU 跳转到那里运行。至于进程调度的逻辑 schedule 函数里面怎么实现,怎么调,我们先不管。

📃优先级

    那么已经解决了调度时机的问题,现在还有个问题,这个counter每个进程设置多少合适呢?同时这个问题也关乎着集成调度之后counter重新设置多少。

    往宏观想一下,这个值越大,那么 counter 就越大,那么每次轮到这个进程时,它在 CPU 中运行的时间就越长,也就是这个进程比其他进程得到了更多 CPU 运行的时间。那么这个变量的名字可以有一个大家都很熟悉的名字,叫优先级。可以在结构体里面再添加一个priority变量:

struct task_struct {
	···
	long counter;
	long priority;
	···
	struct tss_struct tss;
};

    每次一个进程初始化时,都把 counter 赋值为这个 priority,而且当 counter 减为 0 时,下一次分配时间片,也赋值为这个。

📃进程状态

    现在我们知道了进程调度时机,进程切换保存上下文,现在还需要保存一下进程状态,这是为什么呢?很简单的一个场景,一个进程中有一个读取硬盘的操作,发起读请求后,要等好久才能得到硬盘的中断信号。那这个时间其实该进程再占用着 CPU 也没用,此时就可以选择主动放弃 CPU 执行权,然后再把自己的状态标记为等待中。意思是告诉进程调度的代码,先别调度我,因为我还在等硬盘的中断,现在轮到我了也没用,把机会给别人吧。那这个状态可以记录一个属性了,叫 state,记录了此时进程的状态。

struct task_struct {
	long state;
	long counter;
	long priority;
	···
	struct tss_struct tss;
};

    来看看linux进程一共有多少种:

#define TASK_RUNNING		0
#define TASK_INTERRUPTIBLE	1
#define TASK_UNINTERRUPTIBLE	2
#define TASK_ZOMBIE		3
#define TASK_STOPPED		4

    现在我们有了进程状态,进程调度时机,进程优先级,进程上下文保存,已经可以完成调度任务了。

🏆从定时器的角度看进程调度

📃定时器中断

    接下来我们重点看看定时器的事,我们都知道定时器每隔一段时间会向CPU发起一个中断信号,这个间隔时间被设置为10ms,也就是100Hz:

#define HZ 100

    发起中断的函数是timer_interrupt,详细可以看这篇博客:linux0.11内核源码修仙传第十章——进程调度始化

void sched_init(void)
{
	···
	set_system_gate(0x80,&system_call);
}

_timer_interrupt:
	···
	# 增加系统滴答数
	incl _jiffies
	···
	# 调用定时器中断函数 do_timer
	call _do_timer
	···

    上面的函数做了简化,一件事是将系统滴答数这个变量 jiffies 加一,一个是调用了另一个函数 do_timer,详细的_timer_interrupt函数见下一节。接下来看看do_timer函数:

void do_timer(long cpl)
{
	···
	if ((--current->counter)>0) return;
	···
	schedule();
}

    do_timer 函数最重要的就是上面这一段,内容就是将当先进程的时间片 -1,然后判断时间片,如果时间片仍然大于零,则什么都不做直接返回;如果时间片已经为零,则调用 schedule(),这个函数就是进程调度的主干。看到这里其实已经可以了,如果想了解这个函数全貌可以看下面一节的内容。

📃定时器中断函数剩余部分(选看)

    先来看定时器中断函数,首先来看最开始的压栈:

_timer_interrupt:
	push %ds		# save ds,es and put kernel data space
	push %es		# into them. %fs is used by _system_call
	push %fs
	pushl %edx		# we save %eax,%ecx,%edx as gcc doesn't
	pushl %ecx		# save those across function calls. %ebx
	pushl %ebx		# is saved as we use that in ret_sys_call
	pushl %eax
	···

    要注意,CPU在发生中断后会自行压栈一些寄存器的值:SS,ESP,EFLAGS,CS,EIP 。然后这里又压入了一些值,现在整个栈的分布情况如下所示:

在这里插入图片描述
    至此,包括CPU自动压栈的寄存器,基本所有重要的寄存器都有了,也就是进程上下文进行了保存。翻看中断函数可以发现这是所有中断处理函数的统一操作。

    接下来来看下一段:

_timer_interrupt:
	···
	movl $0x10,%eax
	mov %ax,%ds
	mov %ax,%es
	movl $0x17,%eax
	mov %ax,%fs
	incl _jiffies
	···

    这一段改变了DS和ES寄存器,值是0x10,这个正好对应了内核数据段选择子,也就是GDT中下标为2的位置。之后FS是用户数据段选择子,分析也是同理。最后incl _jiffies 递增系统时钟滴答计数器。jiffies 变量记录自系统启动以来的时钟中断次数。

    中断里面只是对计数变量进行递增,接下来就是通知中断控制芯片,当前中断处理完毕,允许接收新中断:

_timer_interrupt:
	···
	movb $0x20,%al		# EOI to interrupt controller #1
	outb %al,$0x20
	···

    0x20EOI命令码,0x20是主PIC的端口地址。

    之后判断中断发生时CPU处于内核态(CPL=0)还是用户态(CPL=3)作为参数最后传给后续的do_timer寄存器:

_timer_interrupt:
	···
    movl CS(%esp), %eax ; 从栈中加载原始CS值
    andl $3, %eax       ; 提取CPL(CS的最低两位)
    pushl %eax          ; 将CPL作为参数压栈
	···

    最后调用定时器处理函数并返回到公共出口,最后jmp的公共出口会通过统一出口恢复上下文:

_timer_interrupt:
	···
    call _do_timer      ; 调用C函数do_timer(CPL)
    addl $4, %esp       ; 清理参数(弹出CPL)
    jmp ret_from_sys_call ; 跳转到系统调用/中断返回路径

    总结一下这个函数前后做了什么:

上下文保存于恢复:保存寄存器值,确保C代码能安全使用这些寄存器,最后进行中断返回。
特权级判断:获取CS寄存器低两位CPL,统计用户态和内核态时间。
进程调度函数:计算了jiffies 变量并调用do_timer函数
中断结束:给PIC发送EOI信号,通知PIC接收新中断,避免中断丢失。

    接下来看调用的do_timer 函数,首先如果计数的变量为零(初始值为之前设置时钟频率的1/8),则停止计数。之后依据上面_timer_interrupt获取的CPL,更新用户态或者内核态的CPU时间:

static void sysbeep(void)
{
	···
	beepcount = HZ/8;	
}

void sysbeepstop(void)
{
	// 0x61端口的Bit0是定时器(1开始计数,0停止计数),Bit1是扬声器(1是发声,0是静音)
	outb(inb_p(0x61)&0xFC, 0x61);
}

// cpl:优先级
void do_timer(long cpl)
{
	extern int beepcount;
	extern void sysbeepstop(void);

	if (beepcount)
		if (!--beepcount)
			sysbeepstop();

	if (cpl)
		current->utime++;
	else
		current->stime++;
	···
}

    下面是实现内核定时任务:

void do_timer(long cpl)
{
	···
	if (next_timer) {
	    next_timer->jiffies--;
	    while (next_timer && next_timer->jiffies <= 0) {
	        void (*fn)(void);
	        fn = next_timer->fn;
	        next_timer->fn = NULL;
	        next_timer = next_timer->next;
	        (fn)();
	    }
	}
	···
}

    将人话就是这个循环里面首先递减第一个定时器的 jiffies;若 jiffies <= 0,执行其回调函数 fn(),并移除该节点;循环处理所有到期的定时器(支持多个定时器同时到期)。

    最后一段了,最后是时间片管理,并调用调度函数:

void do_timer(long cpl)
{
	···
	if (current_DOR & 0xf0)
		do_floppy_timer();
	if ((--current->counter)>0) return;
	current->counter=0;
	if (!cpl) return;
	schedule();
}

    软盘已经用的不多了,因此第一个if不重要,重要的是后面时间片管理和调度。每次中断后剩余进程片都减一,如果时间片还没用完,直接返回,如果时间片用完,则重置为0。其次,仅当中断发生在用户态时,会调用调度函数 schedule()。如果是在内核态,则不触发调度,确保内核代码的原子性。

📃进程调度函数

    上面铺垫终于结束了,终于来到了进程调度函数schedule(),先来看整体代码一饱眼福:

void schedule(void)
{
	int i,next,c;
	struct task_struct ** p;

/* check alarm, wake up any interruptible tasks that have got a signal */

	for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
		if (*p) {
			if ((*p)->alarm && (*p)->alarm < jiffies) {
					(*p)->signal |= (1<<(SIGALRM-1));
					(*p)->alarm = 0;
				}
			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];
		while (--i) {
			if (!*--p)
				continue;
			if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
				c = (*p)->counter, next = i;
		}
		if (c) break;
		for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
			if (*p)
				(*p)->counter = ((*p)->counter >> 1) +
						(*p)->priority;
	}
	switch_to(next);
}

    下面进行拆解,直接抽取出主干:

void schedule(void) {
    int next = get_max_counter_and_runnable_thread();
    refresh_all_thread_counter();
    switch_to(next);
}

    所以调度函数只干了三件事:

拿到剩余时间片最大且在runnable 状态的进程号 next。
如果所有 runnable 进程时间片都为 0,则将所有进程(不仅仅是 runnable 的进程)的 counter 重新赋值,然后再次执行步骤 1
最后拿到了一个进程号 next,调用了 switch_to(next) 这个方法,就切换到了这个进程去执行了。

    上面第二步中,要将所有进程的counter重新复制,那么有个问题,只是runnable的进程全是0了,但是其他进程还不一定为0,那么赋值多少合适呢?这里linus给出的答案是:(counter = counter/2 + priority) 现在可以来看具体的代码了:

void schedule(void)
{
	···
	while (1) {
		c = -1;
		next = 0;
		i = NR_TASKS;
		p = &task[NR_TASKS];
		while (--i) {
			if (!*--p)
				continue;
			if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
				c = (*p)->counter, next = i;
		}
		if (c) break;
		for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
			if (*p)
				(*p)->counter = ((*p)->counter >> 1) + (*p)->priority;
	}
	switch_to(next);
}

    schedule函数里面就是一直遍历所有非空任务,选择状态为TASK_RUNNINGcounter 值最大的任务。counter 表示剩余时间片,越大优先级越高。若找到有效任务(c > 0),跳出循环!这不是和上面第一步对上了吗!最后一个for循环就是所有就绪任务的counter为0,重新计算所有任务的counter,计算公式就是上面介绍的那个。

    现在来看最后的switch函数:

#define switch_to(n) {\
struct {long a,b;} __tmp; \
__asm__("cmpl %%ecx,_current\n\t" \
	"je 1f\n\t" \
	"movw %%dx,%1\n\t" \
	"xchgl %%ecx,_current\n\t" \
	"ljmp %0\n\t" \
	"cmpl %%ecx,_last_task_used_math\n\t" \
	"jne 1f\n\t" \
	"clts\n" \
	"1:" \
	::"m" (*&__tmp.a),"m" (*&__tmp.b), \
	"d" (_TSS(n)),"c" ((long) task[n])); \
}

    这里就是进程切换的底层代码了。看不懂没关系,其实主要就干了一件事,就是 ljmp 到新进程的 tss 段处。就是 CPU 规定,如果 ljmp 指令后面跟的是一个 tss 段,那么,会由硬件将当前各个寄存器的值保存在当前进程的 tss 中,并将新进程的 tss 信息加载到各个寄存器。说人话就是保存当前进程上下文,恢复下一个进程的上下文,跳过去!

在这里插入图片描述

📃流程小节

    来总结一下流程:

罪魁祸首的,就是那个每 10ms 触发一次的定时器滴答
这个滴答将会给 CPU 产生一个时钟中断信号
这个中断信号会使 CPU 查找中断向量表,找到操作系统写好的一个时钟中断处理函数 do_timer
do_timer 会首先将当前进程的 counter 变量 -1,如果 counter 此时仍然大于 0,则就此结束
如果 counter = 0 了,就开始进行进程的调度
进程调度就是找到所有处于 RUNNABLE 状态的进程,并找到一个 counter 值最大的进程,把它丢进 switch_to 函数的入参里
switch_to 这个终极函数,会保存当前进程上下文,恢复要跳转到的这个进程的上下文,同时使得 CPU 跳转到这个进程的偏移地址处
接着就是等待下一次时钟中断的来临

🎯总结

    这一章讲了很多东西,来做个回顾:首先我们自己设计了一下进程调度的规则,直到怎么保存上下文,设置进程运行优先级,规定了进程的几种状态。接下来从定时器的角度看进程调度,看了定时器中断函数,与进程调度函数。


📖参考资料

[1] linux源码趣读
[2] 一个64位操作系统的设计与实现

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值