进程调度是linux系统中再常见不过的事情,对于进程的调度,这里不管什么调度策略什么的,直接来看看进程的切换做了些什么事情。
这里从内核的上下文切换函数context_switch开始分析。
static inline task_t * context_switch(runqueue_t * rq, task_t *prev, task_t *next) { struct mm_struct *mm = next->mm; struct mm_struct *oldmm = prev->active_mm;
if (unlikely(!mm)) { /*切换进来的进程是内核线程*/ next->active_mm = oldmm; /*借用前一个进程的进程空间*/ atomic_inc(&oldmm->mm_count); enter_lazy_tlb(oldmm, next); /*惰性TLB*/ } else switch_mm(oldmm, mm, next); /*切换运行空间*/
if (unlikely(!prev->mm)) { /*前一个进程是内核线程*/ prev->active_mm = NULL; /*断开与借用的地址空间的联系*/ WARN_ON(rq ->prev_mm); rq ->prev_mm = oldmm; }
/* Here we just switch the register state and the stack. */ switch_to(prev, next, prev); /*切换执行环境*/
return prev; } |
进程切换最主要的事情是进程执行环境的切换和运行空间的切换。
先来看看task_struct结构体的两个重要字段:mm和active_mm。mm是表示进程所拥有的内存的描述符,active_mm是表示进程运行时所使用的内存的描述符。内核线程和用户进程都是task_struct的实例,区别在于内核线程是没有进程地址空间的。
mm |
active_mm |
|
内核线程 |
NULL |
指向上一个被调用进程的active_mm值 |
用户进程 |
两者相等 |
context_switch函数首先判断切换进来的进程next是内核线程还是用户进程。如果next是内核线程,那么它借用上一个进程的地址空间;由于内核线程不访问用户态地址空间,没有必要使一个用户态线性地址对应的TLB表项无效,因此调用enter_lazy_tlb通知底层体系结构不需要切换虚拟地址空间的用户空间部分。
如果next是用户进程,那么就调用switch_mm函数切换运行空间。下面分析下次函数代码实现。
static inline void switch_mm(struct mm_struct *prev, struct mm_struct *next, struct task_struct *tsk) { if (likely(prev != next)) { cpu_clear(cpu, prev->cpu_vm_mask); /*清除cpu_vm_mask,表示prev已经弃用了当前cpu*/ #ifdef CONFIG_SMP per_cpu(cpu_tlbstate, cpu).state = TLBSTATE_OK; per_cpu(cpu_tlbstate, cpu).active_mm = next; #endif cpu_set(cpu, next->cpu_vm_mask); /*设置next的cpu_vm_mask标志,表示next占用了当前cpu*/ /*将进程next的页目录表首地址next->pgd转换成物理地址,并将其写入寄存器cr3中*/ load_cr3(next->pgd);
if (unlikely(prev->context.ldt != next->context.ldt)) load_LDT_nolock(&next->context, cpu); } #ifdef CONFIG_SMP else { per_cpu(cpu_tlbstate, cpu).state = TLBSTATE_OK; BUG_ON(per_cpu(cpu_tlbstate, cpu).active_mm != next); /*判断next进程在切换出去之前是否运行在当前cpu上*/ if (!cpu_test_and_set(cpu, next->cpu_vm_mask)) { load_cr3(next->pgd); load_LDT_nolock(&next->context, cpu); } } #endif } |
从上面代码可以看到switch_mm最主要的工作就是切换pgd,我们知道pgd是包括内核空间和用户空间的地址映射的,但是因为所有进程的内核空间的地址映射是相同的,所以只需要进行用户空间的切换。
这里切换了页表,即地址空间,不会立马引起控制流程的改变。现在处于内核空间,而所有进程的内核空间的地址映射是一样的。但是当从内核空间返回用户空间时,这里的改变就会引起本质的改变,会跳到next的用户代码中执行。
在SMP系统中,即使prev和next是同一个进程,但是如果切换前后运行在不同的CPU上,这里还是需要重新加载CR3的。
接下来分析switch_to宏,这个宏比较难理解。下面是x86架构下面的代码分析。
#define switch_to(prev,next,last) do { \ unsigned long esi,edi; \ asm volatile("pushfl\n\t" \ /*在prev内核栈上保存prev进程的efalgs*/ "pushl %%ebp\n\t" \ /*在prev内核栈上保存prev进程的ebp*/ "movl %%esp,%0\n\t" /* save ESP */ \ /*将esp寄存器的值装入prev进程的thread.esp*/ "movl %5,%%esp\n\t" /* restore ESP */ \ /*将next进程的thread.esp装入esp寄存器*/ "movl $1f,%1\n\t" /* save EIP */ \ /*将prev进程恢复执行的指令设置为标号1位置处*/ "pushl %6\n\t" /* restore EIP */ \ /*将next进程恢复执行的指令位置压入next的内核栈*/ "jmp __switch_to\n" \ "1:\t" \ "popl %%ebp\n\t" \ "popfl" \ :"=m" (prev->thread.esp),"=m" (prev->thread.eip), \ "=a" (last),"=S" (esi),"=D" (edi) \ :"m" (next->thread.esp),"m" (next->thread.eip), \ "2" (prev), "d" (next)); \ } while (0) |
上面是x86架构下的switch_to宏的代码,是用内嵌汇编实现的。switch_to函数有5个输出参数,4个输入参数,第一个输出参数开始,参数以0开始编号,依次增加。
我们从注释就可以看到switch_to主要干的事情就是切换内核栈和寄存器。此宏开始4条命令就是切换内核堆栈。它首先保存prev进程的eflags、ebp寄存器压入prev进程的内核栈,然后将esp的值装入prev进程的thread.esp字段,最后就是将next进程的thread.esp字段装入esp寄存器中,这样就开始使用next进程的内核栈,即达到了切换内核栈的目的了。
调用switch_to宏之前
切换内核栈之前
切换内核栈之后
接下来的3条指令就是切换内核的控制流程。首先将prev进程恢复执行的指令位置设置为标号1位置处,然后就是将next进程恢复执行的指令位置压入next的内核栈。接下来就是通过jmp跳转到__switch_to函数执行,此函数执行完之后不会返回到下面的标号1处,而是到next进程的thread.ip处开始执行。
__switch_to返回之后
call和jmp指令的差别: call指令在执行前将其后紧跟着的一条指令的地址压入堆栈,然后jmp到call调用的地址去执行,遇到ret时弹出堆栈中的指令地址,继续执行。 |
试想一下,如果next进程不是新创建的,那么它上一次也是通过switch_to宏调度出去的,它的恢复执行的位置必定也是在标号1处,此时next进程的内核栈上还保存有eflags和ebp寄存器。紧跟的两条pop命令就会弹出寄存器。
switch_to宏完成
上面就是switch_to宏的做的事情,下面分析下switch_to为什么有三个参数,prev和next两个参数不行吗?
switch_to宏的第三个参数last是输出参数,而且是使用寄存器eax的值。__switch_to函数是有两个参数和一个返回值的,x86架构下默认的是堆栈传参的,但我们在前面的内嵌汇编代码中,没有看到在调用__switch_to之前有对参数入栈的操作?那有可能是通过寄存器传参的,查找arch/i386/Makefile文件,有下面的代码:
-mregparm=3表示使用3个寄存器传参,这证明了__switch_to是通过寄存器传参的,返回值是通过eax寄存器传递的。因此__switch_to函数的返回值就传递给了last,即作为switch_to宏的输出参数传递给力context_swicth函数。
分析__switch_to函数就知道返回值last为prey进程。此prev就需要好好分析下了。
上图为进程A的一个执行流示意图。调用switch_to宏切换到进程B,在switch函数返回之后,即进程A又切换回来执行的情况下,可能已经发生了多次进程的切换,就像图中所示的A切换到B,B切换到C,C切换到D,D最终切换到A。这个时候,switch_to(prev,next,last)中的第三个参数last,就是指向的进程D。
这样我们就能够对进程A重新回来执行之前被切换出去的进程D进行清理的工作。