进程切换的工作机制
进程切换:为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复执行以前挂起的某个进程。即从正在运行的进程中收回处理器,然后再使待运行进程来占用处理器。
进程上下文
-
用户地址空间:程序代码、数据、用户堆栈等
-
控制信息:进程描述符,内核堆栈等
-
进程的CPU上下文,相关寄存器的值
进程执行环境切换步骤
-
从就绪队列中选择一个进程
-
完成进程上下文切换
进程切换核心代码分析
schedule()
函数选择一个新的进程来运行,并调用context_switch
进行上下文的切换。context_switch
首先调用switch_mm
切换CR3
,- 然后调用宏
switch_to
来进行CPU上下文切换。
进程执行环境的切换
进程执行环境的切换大致分为两大步,一是从就绪队列中选择一个进程(pick_next_task
),也就是由进程调度算法决定选择哪一个进程作为下一个进程(next);二是完成进程上下文切换context_switch,进程上下文包含了进程执行需要的所有信息。
static void __sched notrace __schedule(bool preempt)
{
struct task_struct *prev, *next;
...
next = pick_next_task(rq, prev, &rf);
...
rq = context_switch(rq, prev, next, &rf);
...
}
context_switch
首先调用 prepare_task_switch()
来准备进程切换。然后调用 arch_start_context_switch()
函数来开始进程上下文的切换。
关于内存管理的操作,会根据进程的类型(内核空间进程或用户空间进程)进行不同的处理。如果切换到内核空间进程,则会进入“懒 TLB”,并且直接使用前一个进程的地址空间。如果切换到用户空间进程,则需要切换地址空间,并调用 membarrier_switch_mm()
函数和 switch_mm_irqs_off()
函数进行一些额外的操作。
之后,函数调用 prepare_lock_switch()
函数准备锁的切换。
最后,函数调用 switch_to()
函数来进行寄存器状态和栈的切换,并返回 finish_task_switch() 函数来完成进程切换。
/*
* context_switch - switch to the new MM and the new thread's register state.
*/
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);
...
/*
* kernel -> kernel lazy + transfer active
* user -> kernel lazy + mmgrab() active
*
* kernel -> user switch + mmdrop() active
* user -> user switch
*/
if (!next->mm) { // to kernel
...
} else { // to user
...
}
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
barrier();
return finish_task_switch(prev);
}
最核心的是几个关键寄存器的保存与变换。
- 进程页目录表(页表),即地址空间、数据。
- 内核堆栈栈顶寄存器sp代表进程内核堆栈(保存函数调用历史),进程描述符(最后的成员thread是关键)和内核堆栈存储于连续存取区域中,进程描述符存在内核堆栈的低地址,栈从高地址向低地址增长,因此通过栈顶指针寄存器还可以获取进程描述符的起始地址。
指令指针寄存器代表进程的CPU上下文,即要执行的下条指令地址。
- 这些寄存器从一个进程的状态切换到另一个进程的状态,进程切换的关键上下文就算完成了。
schedule()
函数选择一个新的进程来运行,并调用context_switch
进行上下文的切换。context_switch
首先调用switch_mm切换CR3
,然后调用宏switch_to
来进行CPU上下文切换。
部分关键代码为:
static inline void
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next)
{
...
if (unlikely(!mm)) { /* 如果被切换进来的进程的mm为空切换,内核线程mm为空 */
next->active_mm = oldmm; /* 将共享切换出去进程的active_mm */
atomic_inc(&oldmm->mm_count); /* 有一个进程共享,所有引用计数加一 */
/* 将per cpu变量cpu_tlbstate状态设为LAZY */
enter_lazy_tlb(oldmm, next);
} else /* 普通mm不为空,则调用switch_mm切换地址空间 */
switch_mm(oldmm, mm, next);
...
/* 这里切换寄存器状态和栈 */
switch_to(prev, next, prev);
...
}
ARM64架构中的switch_to函数分析
我们重点关注switch_to
函数的工作流程,switch_to
函数完成了线程的硬件上下文切换。当完成了硬件上下文切换后。通过还原栈顶的寄存器后,保存在栈中的局部变量,因为栈的切换变为当前的运行线程的的栈。当线程栈改变后对应的局部变量地址也改变为当前线程原来的局部变量。所以当最后栈顶变为需切换的线程的栈顶时。代码运行的路径也变为新的线程代码路径和数据。switch_to
函数在arm64
架构下的定义如下:
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
#ifdef CONFIG_STACKPROTECTOR
movq TASK_stack_canary(%rsi), %rbx
movq %rbx, PER_CPU_VAR(fixed_percpu_data) + stack_canary_offset
#endif
#ifdef CONFIG_RETPOLINE
/*
* When switching from a shallower to a deeper call stack
* the RSB may either underflow or use entries populated
* with userspace addresses. On CPUs where those concerns
* exist, overwrite the RSB with entries which capture
* speculative execution to prevent attack.
*/
FILL_RETURN_BUFFER %r12, RSB_CLEAR_LOOPS, X86_FEATURE_RSB_CTXSW
#endif
/* restore callee-saved registers */
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
jmp __switch_to
END(__switch_to_asm)
注意 __switch_to_asm
是在C代码中调用的,也就是使用call指令,而这段汇编的结尾是jmp __switch_to
,__switch_to
函数是C代码最后有个 return
,也就是 ret
指令。
将 __switch_to_asm
和 __switch_to
结合起来,正好是call
指令和ret
指令的配对出现。
中断上下文和进程上下文的一个关键区别是堆栈切换的方法。中断是由CPU实现的,所以中断上下文切换过程中最关键的栈顶寄存器sp和指令指针寄存器ip是由CPU协助完成的;进程切换是由内核实现的,所以进程上下文切换过程中最关键的栈顶寄存器sp切换是通过进程描述符的thread.sp
实现的,指令指针寄存器ip的切换是在内核堆栈切换的基础上巧妙利用call/ret指令实现的。
ARM64
体系结构下__switch_to
的实现见 arch/arm64/kernel/process.c
,其中 cpu_switch_to
是我们特别关心的进程的CPU上下文切换的关键代码。情况和x86类似,就不多做分析了。
实验总结
通过上述分析,我们已经得到了在arm64架构下进程切换过程中发生的主要操作:
__schedule // kernel/sched/core.c
->context_switch
->switch_mm_irqs_off //进程地址空间切换
->switch_to //处理器状态切换
通过这一过程,Linux内核便完成了进程的切换。实际上在不同的体系结构下,只有switch_to函数是有较大差异的,因为不同的体系结构下的寄存器和堆栈有所不同,涉及具体的操作自然也是不同的,其他的函数基本上是通用的。