深入分析Linux内核源码-第五章进程调度
Sailor_forever 整理
【摘要】本章介绍了Linux系统的时间机制,包括系统时钟和硬件时钟之间的关系、各种时间标准的转换、时钟中断tick的维护;接着介绍了进程调度的几种常见算法、何时进行内核调度及选择相应该调度的进程,并分析了调度程序的实现;最后介绍了进程切换的硬件支持和软件实现。
【关键字】Tick,系统时间,硬件CMOS时钟,Jiffies,调度算法,RR,Goodness,Schedule,进程切换,Switch_mm
计算机最基本的时间单元是时钟周期,例如取指令、执行指令、存取内存等。时间系统是计算机系统非常重要的组成部分,特别是对于Unix类分时系统尤为重要。时间系统主要任务是维持系统时间并且防止某个进程独占CPU及其他资源,也就是驱动进程的调度。
5.1.1时钟硬件
大部分PC机中有两个时钟源,他们分别叫做RTC和OS(操作系统)时钟。RTC(Real Time
Clock,实时时钟)也叫做CMOS时钟,它是PC主机板上的一块芯片,它靠电池供电,即使系统断电,也可以维持日期和时间。由于它独立于操作系统,所以也被称为硬件时钟,它为整个计算机提供一个计时标准,是最原始最底层的时钟数据。
Linux只用RTC来获得时间和日期;然而,通过作用于/dev/rtc设备文件,也允许进程对RTC编程。通过执行/sbin/clock系统程序,系统管理员可以配置时钟。
OS时钟产生于PC主板上的定时/计数芯片,由操作系统控制这个芯片的工作,OS时钟的基本单位就是该芯片的计数周期。在开机时操作系统取得RTC中的时间数据来初始化OS时钟,然后通过计数芯片的向下计数形成了OS时钟,它更应该被称为一个计数器。OS时钟只在开机时才有效,而且完全由操作系统控制,所以也被称为软时钟或系统时钟。下面我们重点描述OS时钟的产生。
OS时钟输出脉冲信号,接到中断控制器上,产生中断信号,触发后面要讲的时钟中断,由时钟中断服务程序维持OS时钟的正常工作。
5.1.2时钟运作机制
RTC和OS时钟之间的关系通常也被称作操作系统的时钟运作机制。一般来说,RTC是OS时钟的时间基准,操作系统通过读取RTC来初始化OS时钟,此后二者保持同步运行,共同维持着系统时间。保持同步运行是什么意思呢?就是指操作系统运行过程中,每隔一个固定时间会刷新或校正RTC中的信息。
图5.2时钟运作机制
我们可以看到,RTC处于最底层,提供最原始的时钟数据。OS时钟建立在RTC之上,初始化完成后将完全由操作系统控制,和RTC脱离关系。操作系统通过OS时钟提供给应用程序所有和时间有关的服务。
5.1.3 Linux时间基准
以上我们了解了RTC(实时时钟、硬件时钟)和OS时钟(系统时钟、软时钟)。下面我们具体描述OS时钟。OS时钟是由可编程定时/计数器产生的输出脉冲触发中断而产生的。输出脉冲的周期叫做一个“时钟滴答”。计算机中的时间是以时钟滴答为单位的,每一次时钟滴答,系统时间就会加1。操作系统根据当前时钟滴答的数目就可以得到以秒或毫秒等为单位的其他时间格式。
定义“时间基准”的目的是为了简化计算,这样计算机中的时间只要表示为从这个时间基准开始的时钟滴答数就可以了。“时间基准是由操作系统的设计者规定的。例如DOS的时间基准是1980年1月1日,Unix的时间基准是1970年1月1日上午12点,Linux的时间基准是1970年1月1日凌晨0点。
5.1.4 Linux的时间系统
OS时钟记录的时间也就是通常所说的系统时间。系统时间是以“时钟滴答”为单位的,而时钟中断的频率决定了一个时钟滴答的长短,例如每秒有100次时钟中断,那么一个时钟滴答的就是10毫秒(记为10ms),相应地,系统时间就会每10ms增1。
Linux中用全局变量jiffies表示系统自启动以来的时钟滴答数目。在/kernel/time.c中定义如下:
unsigned long volatile
jiffies
在jiffies基础上,Linux提供了如下适合人们习惯的时间格式,在/include/linux/time.h中定义如下:
struct timespec {/*这是精度很高的表示*/
long tv_sec;/*
秒 (second)
*/
long tv_nsec;
if(xtime.tv_sec > last_rtc_update +
660)
update_RTC();
}
其中,xtime是前面所提到的timeval类型,这是一个全局变量。
(3) 时钟函数do_timer() (在/kernel/sched.c中)
void
do_timer(struct pt_regs * regs)
{
(*(unsigned long
*)&jiffies)++;
update_process_times();
++lost_ticks;
if( !
user_mode ( regs ) )
++lost_ticks_system;
mark_bh(TIMER_BH);
if (tq_timer)
mark_bh(TQUEUE_BH);
}
其中,update_process_times()函数与进程调度有关,从函数的名子可以看出,它处理的是与当前进程与时间有关的变量,例如,要更新当前进程的时间片计数器counter,如果counter<=0,则要调用调度程序。
与时间有关的事情很多,不能全都让这个函数去完成,这是因为这个函数是在关中断的情况下执行,必须处理完最重要的时间信息后退出,以处理其他事情。那么,与时间相关的其他信息谁去处理,何时处理?这就是由第三章讨论的后半部分去去处理。上面timer_interrupt()所做的事情就是上半部分。
(4)中断安装程序
从上面的介绍可以看出,时钟中断与进程调度密不可分,因此,一旦开始有时钟中断就可能要进行调度,在系统进行初始化时,所做的大量工作之一就是对时钟进行初始化,其函数time_init
()的代码在/arch/i386/kernel/time.c中,对其简写如下:
void
__init time_init(void)
{
xtime.tv_sec=get_cmos_time();
xtime.tv_usec=0;
setup_irq(0,&irq0);
}
其中的get_cmos_time()函数就是把当时的实际时间从CMOS时钟芯片读入变量xtime中,时间精度为秒。而setup_irq(0,&irq0)就是时钟中断安装函数,那么irq0指的是什么呢,它是一个结构类型irqaction,其定义及初值如下:
static struct irqaction irq0 = {
timer_interrupt,
SA_INTERRUPT,
0,
"timer",
NULL,
NULL};
setup_irq(0,
&irq0)的代码在/arch/i386/kernel/irq.c中,其主要功能就是将中断程序连入相应的中断请求队列,以等待中断到来时相应的中断程序被执行。
我们将有关函数改写如下,体现时钟中断的大意:
do_timer_interrupt( )/*这是一个伪函数
*/
{
SAVE_ALL/*保存处理机现场
*/
intr_count += 1;/*
这段操作不允许被中断 */
timer_interrupt()/*
调用时钟中断程序 */
intr_count -= 1;
jmp ret_from_intr
case
SCHED_FIFO:
case
SCHED_RR:
weight
= 1000 + p->rt_priority;
case
SCHED_OTHER:
{
weight = p->counter;
if(!weight)
goto
out
if
(p->mm=this_mm||!p->mm)
weight
= weight+1;
weight+=20-p->nice;
}
}
out:
return weight;
}
其中,在sched.h中对调度策略定义如下:
#define
SCHED_OTHER 0
#define
SCHED_FIFO 1
#define
SCHED_RR 2
#define
SCHED_YIELD 0x10
这个函数比较很简单。首先,根据policy区分实时进程和普通进程。实时进程的权值取决于其实时优先级,其至少是1000,与conter和nice无关。普通进程的权值需特别说明两点:
为什么进行细微的调整?如果p->mm为空,则意味着该进程无用户空间(例如内核线程),则无需切换到用户空间。如果p->mm=this_mm,则说明该进程的用户空间就是当前进程的用户空间,该进程完全有可能再次得到运行。对于以上两种情况,都给其权值加1,算是对它们小小的奖励。
进程的优先级nice是从早期Unix沿用下来的负向优先级,其数值标志“谦让”的程度,其值越大,就表示其越“谦让”,也就是优先级越低,其取值范围为-20~+19,因此,(20-p->nice)的取值范围就是0~40。可以看出,普通进程的权值不仅考虑了其剩余的时间片,还考虑了其优先级,优先级越高,其权值越大。
根据进程调度的依据,调度程序就可以控制系统中的所有处于可运行状态的进程并在它们之间进行选择。
5.3.5进程调度的实现
调度程序在内核中就是一个函数,为了讨论方便,我们同样对其进行了简化,略其对SMP的实现部分。
asmlinkage
void schedule(void)
{
struct task_struct
*prev,
*next,
*p; /* prev表示调度之前的进程,
next表示调度之后的进程
*/
struct
list_head *tmp;
int this_cpu,
c;
if (!current->active_mm)
BUG();
if (prev->policy == SCHED_RR)
/*将一个时间片用完的SCHED_RR实时
goto move_rr_last;进程放到队列的末尾
*/
move_rr_back:
switch (prev->state) {/*根据prev的状态做相应的处理*/
case TASK_INTERRUPTIBLE:
if
(signal_pending(prev)) {
prev->state =
TASK_RUNNING;
break;
}
default:/*如果为不可中断的等待状态或僵死状态*/
del_from_runqueue(prev);
/*从运行队列中删除*/
case TASK_RUNNING:;/*如果为可运行状态,继续处理*/
}
prev->need_resched =
0;
/*下面是调度程序的正文
*/
repeat_schedule:/*真正开始选择值得运行的进程*/
next =
idle_task(this_cpu);
c =
-1000;
if
(prev->state == TASK_RUNNING)
goto
still_running;
still_running_back:
list_for_each(tmp,
&runqueue_head) { /*遍历运行队列*/
p = list_entry(tmp,
struct task_struct,
run_list);
if (can_schedule(p,
this_cpu)) { /*单CPU中,该函数总返回1*/
int weight = goodness(p,
this_cpu,
prev->active_mm);
if
(weight > c)
c = weight,
next = p;
}
}
/* 如果c为0,说明运行队列中所有进程的权值都为0,也就是分配给各个进程的
时间片都已用完,需重新计算各个进程的时间片 */
if
(!c) {
struct
task_struct *p;
spin_unlock_irq(&runqueue_lock);/*锁住运行队列*/
read_lock(&tasklist_lock);/*
锁住进程的双向链表*/
for_each_task(p)/*
对系统中的每个进程*/
p->counter = (p->counter >> 1) +
NICE_TO_TICKS(p->nice);
read_unlock(&tasklist_lock);
spin_lock_irq(&runqueue_lock);
goto
repeat_schedule;
}
spin_unlock_irq(&runqueue_lock);/*对运行队列解锁,并开中断*/
if (prev == next) {
prev->policy &=
~SCHED_YIELD;
goto
same_process;
}
/* 下面开始进行进程切换*/
kstat.context_swtch++; /*统计上下文切换的次数*/
{
struct
mm_struct *mm = next->mm;
struct
mm_struct *oldmm = prev->active_mm;
if (!mm) {/*如果是内核线程,则借用prev的地址空间*/
if
(next->active_mm) BUG();
next->active_mm =
oldmm;
} else { /*如果是一般进程,则切换到next的用户空间*/
if
(next->active_mm != mm) BUG();
switch_mm(oldmm,
mm,
next,
this_cpu);
}
if (!prev->mm) { /*如果切换出去的是内核线程*/
prev->active_mm =
NULL;/*归还它所借用的地址空间*/
mmdrop(oldmm);/*mm_struct中的共享计数减1*/
}
}
switch_to(prev,
next,
prev); /*进程的真正切换,即堆栈的切换*/
__schedule_tail(prev);/*置prev->policy的SCHED_YIELD为0
*/
same_process:
reacquire_kernel_lock(current);/*针对SMP*/
if (current->need_resched)/*如果调度标志被置位*/
goto need_resched_back;
/*重新开始调度*/
return;
}
以上就是调度程序的主要内容,为了对该程序形成一个清晰的思路,我们对其再给出进一步的解释:
如果当前进程既没有自己的地址空间,也没有向别的进程借用地址空间,那肯定出错。另外, 如果schedule()在中断服务程序内部执行,那也出错.
对当前进程做相关处理,为选择下一个进程做好准备。当前进程就是正在运行着的进程,可是,当进入schedule()时,其状态却不一定是TASK_RUNNIG,例如,在exit()系统调用中,当前进程的状态可能已被改为TASK_ZOMBE;又例如,在wait4()系统调用中,当前进程的状态可能被置为TASK_INTERRUPTIBLE。因此,如果当前进程处于这些状态中的一种,就要把它从运行队列中删除。
从运行队列中选择最值得运行的进程,也就是权值最大的进程。
如果已经选择的进程其权值为0,说明运行队列中所有进程的时间片都用完了(队列中肯定没有实时进程,因为其最小权值为1000),因此,重新计算所有进程的时间片,其中宏操作NICE_TO_TICKS就是把优先级nice转换为时钟滴答。
进程地址空间的切换。如果新进程有自己的用户空间,也就是说,如果next->mm与next->active_mm相同,那么,switch_mm( )函数就把该进程从内核空间切换到用户空间,也就是加载next的页目录。如果新进程无用户空间(next->mm为空),也就是说,如果它是一个内核线程,那它就要在内核空间运行,因此,需要借用前一个进程(prev)的地址空间,因为所有进程的内核空间都是共享的,因此,这种借用是有效的。
用宏switch_to()进行真正的进程切换,后面将详细描述。
5.4 进程切换
5.4.1硬件支持
Intel
i386 体系结构包括了一个特殊的段类型,叫任务状态段(TSS,Task
state segment)。每个任务包含有它自己最小长度为104字节的TSS段,在/include/
i386/processor.h 中定义为tss_struct结构:
struct
tss_struct {
unsigned short
back_link,__blh;
unsigned long
esp0;
unsigned short
ss0,__ss0h;/*0级堆栈指针,即Linux中的内核级
*/
unsigned long esp1;
unsigned short ss1,__ss1h;
/* 1级堆栈指针,未用*/
unsigned long esp2;
unsigned short ss2,__ss2h;
/* 2级堆栈指针,未用*/
unsigned long __cr3;
unsigned long
eip;
unsigned long
eflags;
unsigned long
eax,ecx,edx,ebx;
unsigned long
esp;
unsigned long
ebp;
unsigned long
esi;
unsigned long
edi;
unsigned short
es,
__esh;
unsigned short
cs,
__csh;
unsigned short
ss,
__ssh;
unsigned short
ds,
__dsh;
unsigned short
fs,
__fsh;
unsigned short gs,
__gsh;
unsigned short ldt,
__ldth;
unsigned short trace,
bitmap;
unsigned long
io_bitmap[IO_BITMAP_SIZE+1];
unsigned long
__cacheline_filler[5];
};
每个TSS有它自己
8字节的任务段描述符(Task State Segment
Descriptor ,简称TSSD)。这个描述符包括指向TSS起始地址的32位基地址域,20位界限域,界限域值不能小于十进制104(由TSS段的最小长度决定)。TSS描述符存放在GDT中,它是GDT中的一个表项。
后面将会看到,Linux在进程切换时,只用到TSS中少量的信息,因此Linux内核定义了另外一个数据结构,这就是thread_struct 结构:
struct thread_struct
{
unsigned long esp0;
unsigned long eip;
unsigned long esp;
unsigned long fs;
unsigned long gs;
unsigned long debugreg[8];
unsigned long cr2,
trap_no,
error_code;
union
i387_union i387;
struct
vm86_struct * vm86_info;
unsigned long
screen_bitmap;
unsigned long v86flags,
v86mask,
v86mode,
saved_esp0;
int
ioperm;
unsigned long
io_bitmap[IO_BITMAP_SIZE+1];
};
用这个数据结构来保存cr2寄存器、浮点寄存器、调试寄存器及指定给Intel
80x86处理器的其他各种各样的信息。
那么,进程到底是怎样进行切换的?从第三章我们知道,在中断描述符表(IDT)中,除中断门、陷阱门和调用门外,还有一种“任务们”。任务门中包含有TSS段的选择符。当CPU因中断而穿过一个任务门时,就会将任务门中的段选择符自动装入TR寄存器,使TR指向新的TSS,并完成任务切换。CPU还可以通过JMP或CALL指令实现任务切换,当跳转或调用的目标段(代码段)实际上指向GDT表中的一个TSS描述符项时,就会引起一次任务切换。
内核只是在初始化阶段设置TR,使之指向一个TSS,从此以后再不改变TR的内容了。也就是说,每个CPU(如果有多个CPU)在初始化以后的全部运行过程中永远使用那个初始的TSS。同时,内核也不完全依靠TSS保存每个进程切换时的寄存器副本,而是将这些寄存器副本保存在各个进程自己的内核栈中(参见上一章task_struct结构的存放)。那么,当进行任务切换时,怎样自动更换堆栈?我们知道,新任务的内核栈指针(SS0和ESP0)应当取自当前任务的TSS,可是,Linux中并不是每个任务就有一个TSS,而是每个CPU只有一个TSS。在Linux内核中只更换TSS中的SS0和ESP0,而不更换TSS本身,也就是根本不更换TR的内容。因此,在Linux内核中,TSS并不是属于某个进程的资源,而是全局性的公共资源。在多处理机的情况下,尽管内核中确实有多个TSS,但是每个CPU仍旧只有一个TSS。
5.4.2进程切换
前面所介绍的schedule()中调用了switch_to宏,这个宏实现了进程之间的真正切换,其代码存放于include/
i386/system.h:
1 #define
switch_to(prev,next,last) do {
/
2 asm
volatile("pushl %%esi/n/t" /
3 "pushl
%�i/n/t" /
4 "pushl
%�p/n/t" /
5 "movl %%esp,%0/n/t"
/
6 "movl %3,%%esp/n/t"
/
7 "movl $1f,%1/n/t"
/
8 "pushl
%4/n/t" /
9 "jmp
__switch_to/n" /
10 "1:/t"
/
11 "popl
%�p/n/t" /
12 "popl
%�i/n/t" /
13 "popl
%%esi/n/t" /
14 :"=m"
(prev->thread.esp),"=m"
(prev->thread.eip),
/
15 "=b"
(last) /
16 :"m"
(next->thread.esp),"m"
(next->thread.eip),
/
17 "a" (prev),
"d" (next),
/
18 "b"
(prev)); /
19 } while
(0)
switch_to宏是用嵌入式汇编写成,比较难理解,为描述方便起见,我们给代码编了行号,在此我们给出具体的解释:
thread的类型为前面介绍的thread_struct结构。
输出参数有三个,表示这段代码执行后有三项数据会有变化,它们与变量及寄存器的对应关系如下:
0%与prev->thread.esp对应,1%与prev->thread.eip对应,这两个参数都存放在内存,而2%与ebx寄存器对应,同时说明last参数存放在ebx寄存器中。
输入参数有五个,其对应关系如下:
3%与next->thread.esp对应,4%与next->thread.eip对应,这两个参数都存放在内存,而5%,6%和7%分别与eax,edx及ebx相对应,同时说明prev,next以及prev三个参数分别放在这三个寄存器中。表5.1列出了这几种对应关系:
表5.1
参数类型
参数名
内存变量
寄存器
函数参数
输出参数
0%
prev->thread.esp
1%
prev->thread.eip
2%
Ebx
Last
输入参数
3%
next->thread.esp
4%
next->thread.eip
5%
Eax
Prev
6%
Edx
Next
7%
Ebx
Prev
第2~4行就是在当前进程prev的内核栈中保存esi,edi及ebp寄存器的内容。
第5行将prev的内核堆栈指针ebp存入prev->thread.esp中。
第6行把将要运行进程next的内核栈指针next->thread.esp置入esp寄存器中。从现在开始,内核对next的内核栈进行操作,因此,这条指令执行从prev到next真正的上下文切换,因为进程描述符的地址与其内核栈的地址紧紧地联系在一起(参见第四章),因此,改变内核栈就意味着改变当前进程。如果此处引用current的话,那就已经指向next的task_struct结构了。从这个意义上说,进程的切换在这一行指令执行完以后就已经完成。但是,构成一个进程的另一个要素是程序的执行,这方面的切换尚未完成。
第7行将标号“1”所在的地址,也就是第一条popl指令(第11行)所在的地址保存在prev->thread.eip中,这个地址就是prev下一次被调度运行而切入时的“返回”地址。
第8行将next->thread.eip压入next的内核栈。那么,next->thread.eip究竟指向那个地址?实际上,它就是
next上一次被调离时通过第7行保存的地址,也就是第11行popl指令的地址。因为,每个进程被调离时都要执行这里的第7行,这就决定了每个进程(除了新创建的进程)在受到调度而恢复执行时都从这里的第11行开始。
第9行通过jump指令(而不是
call指令)转入一个函数__switch_to()。这个函数的具体实现将在下面介绍。当CPU执行到__switch_to()函数的ret指令时,最后进入堆栈的next->thread.eip就变成了返回地址,这就是标号“1”的地址。
第11~13行恢复next上次被调离时推进堆栈的内容。从现在开始,next进程就成为当前进程而真正开始执行。
下面我们来讨论__switch_to()函数。
通过寄存器eax和edx把prev和next
参数传递给__switch_to()函数。
void __switch_to(struct task_struct
*prev_p,
struct task_struct
*next_p)
{
struct thread_struct *prev =
&prev_p->thread,
*next
= &next_p->thread;
struct
tss_struct *tss = init_tss +
smp_processor_id();
tss->esp0 =
next->esp0;
/* 保存fs和gs,但无需保存es和ds,因为当处于内核时,内核段总是保持不变*/
/*恢复next进程的fs和gs
*/
………….
/* 如果next挂起时使用了调试寄存器,则装载0~7个寄存器中的6个寄存器,其中第4、5个寄存器没有使用
*/
/*把next进程的I/O操作权限位图拷贝到TSS中
*/
………..
}
Linux内核的设计者用软件实现了进程切换,而且,软件实现比硬件实现的效率更高,灵活性更大。