1.进程切换的大致过程
当进程在运行过程中遇到中断或触发系统调用,首先会进行中断上下文的切换,之后进行ISR中断服务,之后调用_schedule()函数进行进程的切换,schedule()将当前进程的标识符记为prev进程。然后再通过调度算法确定下一个要被换上的进程,记为next进程。之后,检查next如果和prev进程不一样,调用context_switch()函数进行上下文切换,next进程进入CPU运行。
在context_switch()中调用switch_to()进行寄存器和堆栈的切换,switch_to()会调用switch_to_asm()函数,在switch_to_asm()的中进行了从prev内核堆栈到next内核堆栈的切换,在最后不使用ret指令,而是通过jmp指令跳转到switch_to()函数,在switch_to()函数的结尾调用return返回,因为在switch_to_asm()中进行了堆栈的切换,因此_switch_to()返回后,回到的是next进程的内核堆栈,而不是prev进程的内核堆栈。
2.进程切换各函数分析
schedule()函数调用context_switch()函数进行上下文切换,在kernel/sched/core.c中查看函数的定义:
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
{
prepare_task_switch(rq, prev, next);
/*
* For paravirt, this is coupled with an exit in switch_to to
* combine the page table reload and the switch backend into
* one hypercall.
*/
arch_start_context_switch(prev);
/*
* kernel -> kernel lazy + transfer active
* user -> kernel lazy + mmgrab() active
*
* kernel -> user switch + mmdrop() active
* user -> user switch
*/
if (!next->mm) { // to kernel
enter_lazy_tlb(prev->active_mm, next);
next->active_mm = prev->active_mm;
if (prev->mm) // from user
mmgrab(prev->active_mm);
else
prev->active_mm = NULL;
} else { // to user
membarrier_switch_mm(rq, prev->active_mm, next->mm);
/*
* sys_membarrier() requires an smp_mb() between setting
* rq->curr / membarrier_switch_mm() and returning to userspace.
*
* The below provides this either through switch_mm(), or in
* case 'prev->active_mm == next->mm' through
* finish_task_switch()'s mmdrop().
*/
switch_mm_irqs_off(prev->active_mm, next->mm, next);
if (!prev->mm) { // from kernel
/* will mmdrop() in finish_task_switch(). */
rq->prev_mm = prev->active_mm;
prev->active_mm = NULL;
}
}
rq->clock_update_flags &= ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP);
prepare_lock_switch(rq, next, rf);
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
content_switch 函数有三个参数:rq、prev、next,其中 rq 指向本次进程切换发生的 running queue(进程就绪队列);prev 和 next 分别指向切换前后进程的进程描述符。其中prepare_task_switch()函数在进程切换之前调用,内核会执行与体系结构相关的一些调测指令。上下文切换完成后,必须调用 finish_task_switch,即这两个函数一定是要成对出现的。
arch_start_context_switch()函数给各个体系结构专有的开始上下文切换的工作提供了入口,不同体系结构的实现不同。
content_switch 函数的下一个重要函数就是 switch_to,即切换寄存器状态和栈。swtich_to 函数会进一步调用 __switch_to_asm,而 __switch_to_asm 的实现是和体系结构强相关的。下面我们以 x86_64 的实现为例进行分析:
ENTRY(__switch_to_asm)
UNWIND_HINT_FUNC
/*
* Save callee-saved registers
* This must match the order in inactive_task_frame
*/
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15
/* switch stack */
movq %rsp, TASK_threadsp(%rdi) // 保存旧进程的栈顶
movq TASK_threadsp(%rsi), %rsp // 恢复新进程的栈顶
/* restore callee-saved registers */
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
jmp __switch_to
END(__switch_to_asm)
两条 movq 语句就是新旧进程的分界线,随着内核栈顶的切换,内核栈空间也就切换到了新进程,之后只需要弹出栈中保存的各个寄存器的值即可恢复寄存器状态。将__switch_to_asm和switch_to结合起来,发现是call指令和ret指令的配对出现。call指令压栈RIP寄存器到进程切换前的prev进程内核堆栈,而ret指令出栈存入RIP寄存器的是进程切换之后的next进程的内核堆栈栈顶数据,所以 ret 恢复的就是 next 进程内核堆栈中的 rip 值,即实现了 rip 值的保存与修改。
再看一下arm64架构下__switch_to_asm()的实现 ,其过程就是保存和恢复cpu_context 结构体,在arm中其函数和宏调用过程:switch_to -> switch_to -> cpu_switch_to,具体的切换发生在cpu_switch_to中其代码如下:
ENTRY(cpu_switch_to)
mov x10, #THREAD_CPU_CONTEXT // 寄存器x10存放thread.cpu_context偏移
add x8, x0, x10 // x0与偏移量相加后存入x8,获取旧进程cpu_context的地址
mov x9, sp // 将栈顶指针sp保存在x9寄存器
stp x19, x20, [x8], #16 // 将寄存器x19~x29保存,保存现场
stp x21, x22, [x8], #16
stp x23, x24, [x8], #16
stp x25, x26, [x8], #16
stp x27, x28, [x8], #16
stp x29, x9, [x8], #16 //x29是frame pointer,x9是stack pointer,lr是pc值
str lr, [x8]
add x8, x1, x10 // 获取访问next进程的cpu_context的指针
ldp x19, x20, [x8], #16 // 恢复next进程的现场
ldp x21, x22, [x8], #16
ldp x23, x24, [x8], #16
ldp x25, x26, [x8], #16
ldp x27, x28, [x8], #16
ldp x29, x9, [x8], #16
ldr lr, [x8]
mov sp, x9
msr sp_el0, x1
ret // 此时ret将next进程的lr寄存器的值加载到PC,进程切换完毕
ENDPROC(cpu_switch_to)
NOKPROBE(cpu_switch_to)