深入理解进程切换
进程切换(process switch)是操作系统的核心任务之一,用于在不同进程之间进行 CPU 时间的共享和分配。当一个进程在运行时,它占用了 CPU,并占用了其他诸如内存等资源。当操作系统需要执行另一个进程时,就需要进行进程切换。进程切换涉及到保存当前进程的上下文信息,包括 CPU 寄存器、程序计数器、栈指针等,以及恢复调度执行下一个进程所需的上下文信息。
在 Linux 操作系统中,进程切换的实现源码可以分为两个部分:进程调度和上下文切换。进程调度负责决定当前应该将哪个进程分配给 CPU 执行;上下文切换则是在进程切换时,保存当前进程的上下文信息,并恢复调度执行下一个进程所需的上下文信息。
进程调度的代码主要位于 kernel/sched/
目录下,包括了进程调度算法以及实现。而进程切换则需要涉及到进程的 PCB(进程控制块)和线程的 TCB(线程控制块),以及 CPU 的寄存器状态和内核栈等上下文数据。在 x86 架构的处理器上,进程切换的具体实现涉及到 task_switch
函数、switch_to
宏以及 switch_to_asm
汇编函数等。在 AArch64 等不同架构的处理器上,对应的汇编代码可能有所不同,但目的是一致的。
基本上,当要切换到一个新进程时,CPU 必须保存当前进程(即上下文切换)。这包括将所有寄存器值保存到当前进程的 PCB 或 TCB 中,以及将内核栈保存到该进程的堆栈中。此外,还需要修改进程的状态标志、计数器和时间戳等信息。然后 CPU 将切换到新进程的 kernel 栈,并从其 PCB 或 TCB 中读取并加载该进程的 CPU 寄存器状态和堆栈指针等信息,以便开始执行新进程。
进程切换的 Linux 源码实现涉及到进程调度和上下文切换两个部分。以下是对进程切换源码的解读:
-
进程调度:在 Linux 内核源码中,进程调度的相关代码位于
kernel/sched
目录下。其中,调度策略相关的代码在kernel/sched/core.c
文件中实现,包括 Linux 中比较常用的 CFS(Completely Fair Scheduler)调度算法等。此外,进程状态相关的代码则位于include/linux/sched.h
文件中。 -
上下文切换:在 Linux 内核源码中,进程的上下文切换具体实现的代码主要位于
arch/x86/kernel/process.c
和arch/arm64/kernel/process.c
等架构相关的文件中。例如,在 x86 架构的处理器上,有一个名为task_switch()
的函数,它会保存当前进程上下文,并加载下一个进程的上下文。在这个函数中,会使用一些汇编代码来完成 CPB(process control block)的管理,如将当前进程的寄存器状态保存到 PCB 中,然后从新的 PCB 中加载下一个进程的寄存器状态等。
进程调度
进程调度的核心代码实现参考 kernel/sched/
目录文件,主要包含以下几个部分:
-
调度算法:Linux 中实现了多种不同的进程调度算法,如 CFS(Completely Fair Scheduler)、O(1) 调度算法、实时调度算法等,并且各个算法之间可以配置和切换,由用户指定默认调度器。
-
调度队列:调度算法的实现需要用到调度队列,它通过双向链表的数据结构来管理所有进程。Linux 中有就绪队列、休眠队列、实时队列等不同类型的队列,它们存储着不同状态的进程。
-
进程状态:Linux 中的进程状态有很多种,如 TASK_RUNNING(运行中)、TASK_INTERRUPTIBLE(可中断的)、TASK_UNINTERRUPTIBLE(不可中断的)、TASK_STOPPED(已停止的)等。进程在不同状态下会被放置到不同类型的调度队列中,以便进行合适的调度。
上下文切换
上下文切换的核心代码实现参考 arch/x86/kernel/process.c
或者 arch/arm64/kernel/process.c
等架构相关的文件,主要包含以下几个部分:
-
进程控制块(Process Control Block, PCB):PCB 是 Linux 内核用来存储和管理进程信息的重要数据结构。在进程切换时,需要将当前进程的 PCB 中保存的上下文信息保存到内存中。
-
内核栈:内核栈用来保存进程的运行状态,进程切换时需要把当前进程的内核栈保存到当前进程的 PCB 中,并从新进程的 PCB 中恢复对应的内核栈信息。
-
寄存器状态:CPU 中包含了多个寄存器,进程切换时需要将当前进程的寄存器状态保存到当前进程的 PCB 中,并从新进程的 PCB 中恢复寄存器状态。
-
进程上下文切换:在 Linux 中,进程上下文切换是通过
switch_to
宏实现的。该宏会将当前进程的上下文信息保存到 PCB 中,并从新进程的 PCB 中读取上下文信息,完成进程切换的操作。
需要注意的是,不同架构的处理器可能会有不同的实现,但其核心原理相同。以上仅为进程切换的核心代码实现的简单介绍。
Linux 进程切换的流程和关键代码
Linux 内核中进程上下文切换的核心代码主要在 kernel/sched/core.c
和 arch/*/kernel/process.c
等文件中实现。
1. 进程切换的触发
进程切换的触发通常有以下几种情况:
- 当前进程的时间片用完了;
- 当前进程主动让出 CPU 时间片,调用了
schedule()
或yield()
函数; - 当前进程因等待某个事件(如 I/O 操作)而进入睡眠状态;
- 新的进程创建或唤醒,需要切换到该进程并执行。
2. 进程上下文切换的步骤
Linux 内核中进程上下文切换的步骤主要包括以下几个部分:
- 保存当前进程的寄存器信息和堆栈信息;
- 保存当前进程的上下文信息(task_struct 结构体);
- 切换进程地址空间(如果需要);
- 切换内核堆栈;
- 恢复下一个进程的上下文信息;
- 恢复下一个进程的堆栈和寄存器信息;
- 跳转到下一个进程的执行点。
3. 进程切换的关键代码
在 kernel/sched/core.c
文件中,进程切换的关键函数包括 schedule()
、__schedule()
、switch_to()
等。其中,schedule()
函数会首先根据进程的优先级选择一个最合适的进程并调用 __schedule()
函数来执行具体的上下文切换操作。__schedule()
函数则会根据当前进程状态和下一个进程状态的不同,执行不同的操作,比如调度进程、睡眠进程、等待事件等。最后,switch_to()
函数则是用于执行实际的上下文切换操作,并切换到下一个进程的执行点。
在 arch/*/kernel/process.c
文件中,不同架构的 Linux 内核则会有不同的上下文切换代码实现。在 x86 架构的代码实现中,使用了 switch_to()
宏来完成具体的上下文切换操作,包括保存当前进程的寄存器信息,切换堆栈和地址空间,恢复下一个进程的寄存器信息并跳转到下一个进程的执行点等。其他架构的代码实现方式会有所不同,但都会涉及到类似的步骤和操作。
content_switch()
content_switch()
是 Linux 内核中用于进行进程上下文切换的函数,其源码位于 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);
}
这段代码是 Linux 内核中的 context_switch
函数的实现代码,用于在进程之间进行上下文切换。它接受三个参数:当前CPU运行的调度队列 rq
、当前正在运行任务 prev
和即将运行的任务 next
,以及一个用于标记上下文切换的一些标志 rq_flags *rf
。
函数首先会调用 prepare_task_switch
函数准备切换进程,其中会调用一些处理器实现相关的函数。然后通过 arch_start_context_switch(prev)
函数启动新的进程上下文,并且在必要的情况下会利用 hypervisor 等技术进行处理器状态的转移和页面表的重载等操作。
接下来需要根据即将运行的进程 next
是否具有自己的地址空间(mm)来采用不同的上下文切换策略。当 next
没有 mm 时,表示即将切换到一个内核线程,此时调用 enter_lazy_tlb()
函数来刷新 TLB 并在之后的某个时间点再真正地切换进程上下文。同时需要将 prev
进程使用的地址空间设置为 next
进程使用的地址空间,并对 prev
的地址空间进行引用计数增加的处理。当 next
有 mm 时,表示即将切换到一个用户进程,此时会调用 membarrier_switch_mm()
函数来确保内存屏障相关的操作已经完成,然后再调用 switch_mm_irqs_off()
函数切换进程,并对相应的地址空间进行引用计数的更新。
最后,函数调用 prepare_lock_switch()
来准备新进程的锁,然后调用 switch_to()
函数切换进程上下文并更新时钟。最终,函数会返回调用 finish_task_switch()
函数的结果。
需要注意的是,这段代码中还涉及了一些 Linux 内核中的体系结构相关的实现,例如 arch_start_context_switch()
、enter_lazy_tlb()
、switch_mm_irqs_off()
等函数,它们可能在不同的体系结构和不同的内核版本中具有不同的实现方式。
switch_to()
switch_to()
函数是 Linux 内核中进行进程上下文切换的关键函数之一,其实现在不同的体系结构和内核版本中会有所差别,但其基本功能都相似。其作用就是将当前正在运行的进程(prev
)的状态保存到其内核栈中,并将要运行的进程(next
)的状态从其内核栈中恢复。此外,还需要更新进程的调度信息和时钟等。
x86
在 x86 体系结构中,switch_to()
函数的实现分为两部分:汇编实现和 C 语言实现。汇编实现主要用于完成进程状态的保存和恢复,包括将处理器状态、段寄存器、控制寄存器、调试寄存器等保存到 prev
进程的内核栈中,并从 next
进程的内核栈中恢复这些寄存器的值等。而 C 语言实现则负责更新进程的调度信息和时钟等。
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)
这是x86 体系结构下 switch_to()
函数的汇编实现。该函数__switch_to_asm
,是 switch_to()
函数的一个帮助函数。需要注意修改任务结构体中的 TASK_threadsp 指向旧的进程栈的指针,以便在之后的上下文切换过程中使用旧的内核栈。
具体实现分为以下几个步骤:
-
首先保存调用该函数时,
rbp
、rbx
、r12
、r13
、r14
、r15
这些 callee-saved 寄存器的值。这些寄存器的值在接下来的操作中会被修改,因此需要在下一步恢复它们的值。 -
然后,通过
movq
指令将当前进程的栈顶地址保存到其任务结构体的TASK_threadsp
偏移处,表示该进程的内核栈已经切换到了新的栈,而且栈顶指针也已经更新为rdi
所指向的进程的内核栈顶。接着,通过movq
指令将待运行进程的栈顶地址从其任务结构体的TASK_threadsp
偏移处读出并移动到%rsp
寄存器中,使得下一步的操作将从新进程的内核栈中执行。 -
最后,通过
popq
指令恢复之前保存的 callee-saved 寄存器的值,以及jmp
指令跳转到 C 语言实现的__switch_to
函数中。
总之,这段汇编代码实现了 x86 体系结构下 switch_to()
函数的核心功能,即切换进程的内核栈,并将处理器状态从旧进程的栈中保存到 TASK_threadsp
中的指针所指向的地方,从新进程的栈中恢复处理器状态。
ARM
在 ARM 体系结构中,switch_to()
函数的实现也会涉及到进程状态的保存和恢复,以及进程调度信息和时钟的更新。在实现上需要考虑更多的体系结构特定的细节。例如,在 ARM64 中,需要对不同的 CPU 模式(EL0/EL1/EL2/EL3)进行处理,同时还需要考虑异常处理、虚拟化等方面的问题。
ENTRY(cpu_switch_to)
mov x10, #THREAD_CPU_CONTEXT
add x8, x0, x10
mov x9, sp
stp x19, x20, [x8], #16
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
str lr, [x8]
add x8, x1, x10
ldp x19, x20, [x8], #16
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
ENDPROC(cpu_switch_to)
NOKPROBE(cpu_switch_to)
这段汇编代码实现了 ARM64 (aarch64) 体系架构下 cpu_switch_to()
函数。
具体实现分为以下几个步骤:
-
首先,将偏移量
THREAD_CPU_CONTEXT
加到旧进程的任务结构体指针x0
中,获取旧进程的cpu_context
结构体的地址。将栈顶地址sp
存入x9
寄存器中,以备后续保存。 -
接着,使用
stp
指令依次将x19
-x28
寄存器的值保存到旧进程的cpu_context
中。这些寄存器中的值在接下来的操作中会被修改,因此需要在下一步恢复它们的值。 -
使用
stp
指令将x29
(栈基址) 和x9
(栈顶) 的值保存到旧进程的cpu_context
结构体中,并使用str
指令将lr
寄存器中的值(存放cpu_switch_to()
函数的返回地址)保存到旧进程的cpu_context
中。 -
将偏移量
THREAD_CPU_CONTEXT
加到新进程的任务结构体指针x1
中,获取新进程的cpu_context
结构体的地址。 -
使用
ldp
指令依次将x19
-x28
寄存器的值从新进程的cpu_context
中恢复。 -
使用
ldp
指令将x29
(栈基址) 和x9
(栈顶) 的值从新进程的cpu_context
中恢复,并使用ldr
指令将旧进程的lr
寄存器中的值 (函数返回地址) 从新进程的cpu_context
中恢复。 -
从
x9
恢复栈顶的值,使用msr
指令将新进程的任务结构体地址放入sp_el0
中,以实现从内核栈切换到新进程的用户模式栈。 -
最后,使用
ret
指令返回到cpu_switch_to()
函数的调用点。