Linux 操作系统分析实验:深入理解进程切换

本文以 Linux 内核中的上下文切换函数 content_switch 为中心,分析 Linux 5.4.34 版本内核中进程切换的基本操作与基本代码框架。

由于需要阅读一些与体系结构相关的函数源码,为了快速定位有关函数的位置,这里推荐一个网站:https://elixir.bootlin.com,这个网站提供了所有 Linux 内核版本源码的在线阅读功能。我们可以直接在页面右上方的搜索框中键入函数名,来快速定位该函数在内核源码的哪些地方定义和使用过。

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);
}

content_switch 函数有三个参数:rq、prev、next,其中 rq 指向本次进程切换发生的 running queue;prev 和 next 分别指向切换前后进程的进程描述符。下面我们来分析一下具体的执行过程。

每个 CPU核有一个 running queue,大致可以理解为该 CPU上的进程就绪队列,每次从该队列中取出一个进程上 CPU运行。

1、prepare_task_switch()

该函数在进程切换之前调用,内核会执行与体系结构相关的一些调测指令。上下文切换完成后,必须调用 finish_task_switch,即这两个函数一定是要成对出现的。

2、arch_start_context_switch()

该函数给各个体系结构专有的开始上下文切换的工作提供了入口,不同体系结构的实现不同。

3、地址空间切换

if (!next->mm) {                             // 1
    enter_lazy_tlb(prev->active_mm, next);   // 2

    next->active_mm = prev->active_mm;       // 3
    if (prev->mm)                            // 4
        mmgrab(prev->active_mm);
    else                                     // 5
        prev->active_mm = NULL;
} else {                                     // 6
    membarrier_switch_mm(rq, prev->active_mm, next->mm);

    switch_mm_irqs_off(prev->active_mm, next->mm, next);  // 7

    if (!prev->mm) {                         // 8
        rq->prev_mm = prev->active_mm;
        prev->active_mm = NULL;
    }
}

该段代码实现了进程地址空间的切换。prev 是进程切换前执行的进程(后面简称 A 进程),next 是进程切换后要执行的进程(后面简称 B 进程)。next->mm 指向 B 进程的地址空间描述符,prev->mm 指向 A 进程的当前正在使用的地址空间描述符(active_mm)。对于用户进程来说,其进程描述符(task_struct)的 mm 和 active_mm 相同,都是指向其进程地址空间。对于内核线程而言,其 task_struct 的 mm 成员为 NULL(内核线程没有进程地址空间),然而内核线程执行的时候,总是需要一个进程地址空间,而 active_mm 就是指向它借用的那个进程地址空间。

所以,我们可以通过判断一个 task_struct 的 mm 成员是否为空来判断它是一个用户进程还是内核级线程。如果 B 进程为内核级线程(对应上方代码块的注释 1),那么我们需要调用体系结构相关的函数 enter_lazy_tlb,标识该 CPU 核进入 lazy tlb mode(对应上方代码块的注释 2)。

enter_lazy_tlb函数在 x86等架构中有实现,其默认实现为空。在 x86中, Lazy TLB机制主要用于减少切换上下文时不必要的 TLB(快表)更新。由于内核级线程不访问用户空间,所以 TLB是否更新都不会影响它的运行。在这种情况下为了优化性能,CPU进入 lazy tlb mode,不对 TLB进行更新。

下一行 next->active_mm = prev->active_mm 把 A 进程的 mm 直接拿到了 B 进程中,即内核级线程借用用户进程地址空间运行(对应上方代码块的注释 3)。

之后对上一个进程的类别加以区分:如果 A 进程是用户进程(prev->mm 不为空),则需要将这个被借用的 mm_struct 的引用计数增加一(对应上方代码块的注释 4),如果该 mm_struct 对应的用户进程已经退出,则系统需要等到其引用计数为 0(即不再有内核线程借用它)才能将其销毁;如果 A 进程是内核级线程,则把 A 进程的 active_mm 成员清空(对应上方代码块的注释 5),结束其对于该 mm_struct 的借用(这只是把借用它的内核线程从一个转换到了另一个,故引用计数无需增加)。

接下来我们来看 B 进程为用户进程的情况(对应上方代码块的注释 6)。首先 membarrier_switch_mm(rq, prev->active_mm, next->mm) 使用了一个内存屏障,来保证上一个进程访问其内存空间与下一个进程访问其内存空间之间的先后顺序(某种程度上是一种进程同步机制),避免在访存进行过程中发生 mm_struct 的切换导致的访存错误(值得补充的是,前面切换到内核级线程的时候因为没有切换 mm_struct,所以不需要这样的内存屏障)。

终于到了重头戏 switch_mm_irqs_off(prev->active_mm, next->mm, next),即真正切换 mm_struct(对应上方代码块的注释 7)。x86 中定义的 switch_mm_irqs_off 函数在 /arch/x86/mm/tlb.c 下。

最后,如果 A 进程是内核级线程,则需要设置 rq->prev_mm 用于后续清除引用计数,并执行 prev->active_mm = NULL 解除对 active_mm 的借用(对应上方代码块的注释 8)。

4、switch_to()

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 语句就是新旧进程的分界线,随着内核栈顶的切换,内核栈空间也就切换到了新进程,之后只需要弹出栈中保存的各个寄存器的值即可恢复寄存器状态。但上面似乎并没有显式地保存、修改指针寄存器 rip 的值(不修改就无法把程序的执行流换到新进程去),那这又是怎么回事呢?

这里需要对函数调用堆栈框架有一定的理解才能明白其中的 trick。注意到 __switch_to_asm 是在 C 语言中调用的,对应汇编就是 call 指令,而这段汇编的结尾是 jmp  __switch_to,并不是 ret;__switch_to 函数对应了一个 C 函数,最后必然有 return,也就是 ret 指令。将 __switch_to_asm 和 __switch_to 结合起来,正好是 call 指令和 ret指令的配对出现。call 指令压栈时,内核堆栈还是 prev 进程的,所以 rip 寄存器的值被存入 prev 进程内核堆栈;而 ret 指令出栈时,内核堆栈已经切换到 next 进程,所以 ret 恢复的就是 next 进程内核堆栈中的 rip 值,这样就巧妙实现了 rip 值的保存与修改。

最后我们再简要分析一下 arm64 下切换寄存器和栈的汇编代码,其过程就是保存和恢复如下 cpu_context 结构体。

struct cpu_context {
  unsigned long x19;
  unsigned long x20;
  unsigned long x21;
  unsigned long x22;
  unsigned long x23;
  unsigned long x24;
  unsigned long x25;
  unsigned long x26;
  unsigned long x27;
  unsigned long x28;
  unsigned long fp; // x29
  unsigned long sp;
  unsigned long pc;
};

arm64 下的 cpu_switch_to 有两个参数,分别存放在寄存器 x0 与 x1 中,x0 存放上一个进程的进程描述符的地址,x1 存放下一个进程的进程描述符的地址。

ENTRY(cpu_switch_to)
  mov  x10, #THREAD_CPU_CONTEXT  // 寄存器x10存放thread.cpu_context偏移,与进程task_struct地址相加后即可获得该进程的cpu_context
  add  x8, x0, x10               // x0与偏移量相加后存入x8,获取旧进程cpu_context的地址
  mov  x9, sp                    // 将栈顶sp存入x9,以备后续保存

  // 保存x19~x28寄存器的值,每条指令执行完毕后x8的值会自动+16,以便保存后续寄存器值
  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        // 保存x29(栈基址)与x9(栈顶sp)
  str  lr, [x8]                  // 保存寄存器LR,该寄存器存放了cpu_switch_to函数的返回地址

  add  x8, x1, x10               // x1与偏移量相加后存入x8,获取新进程cpu_context的地址

  // 恢复x19~x28寄存器的值
  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        // 恢复x29(栈基址)与x9(栈顶sp)
  ldr  lr, [x8]                  // 恢复寄存器LR,这样函数cpu_switch_to返回后就会从新进程上次被中断的位置处继续执行
  mov  sp, x9                    // 从x9处恢复sp的值
  msr  sp_el0, x1                // 将新进程进程task_struct地址放入sp_el0
  ret
ENDPROC(cpu_switch_to)
NOKPROBE(cpu_switch_to)

arm64 也没有显式保存和恢复程序计数器 PC 的值,这和 x86_64 的处理方法是大同小异的,也是通过函数调用堆栈的特性来巧妙解决这一问题。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注