swtch.S 文件是 xv6-riscv操作系统中实现多任务切换(context switch)的魔法核心。

我们来一步步解析它。

1. 宏观视角:swtch 函数的作用

首先,看它的函数签名注释:

void swtch(struct context *old, struct context *new);

这个函数从 C 语言的角度看,它接受两个参数:

  • a0 (第一个参数寄存器): 指向 old 上下文结构的指针。
  • a1 (第二个参数寄存器): 指向 new 上下文结构的指针。

它的核心任务是:

  1. 保存当前执行环境:将当前的寄存器状态保存到 old 指针指向的内存中。
  2. 加载新的执行环境:从 new 指针指向的内存中,加载一套全新的寄存器状态。
  3. 返回到新的执行环境中:函数返回时,它不会返回到调用 swtch 的地方,而是返回到 new 上下文所记录的返回点。

这就是上下文切换的精髓:在一个函数调用中,我们“潜入”了另一个完全不同的执行流。


2. struct context 和涉及的寄存器

swtch.S 中保存和加载的寄存器,就是 struct context 结构体定义的内容。在 RISC-V 架构中,寄存器有不同的用途,并遵循一套“调用约定”(Calling Convention)。swtch 只保存和恢复那些被调用者保存(Callee-Saved)的寄存器。

为什么?因为调用者(Caller)负责保存它自己需要的临时寄存器(Caller-Saved Registers)。而被调用者(Callee,这里就是 swtch 函数)必须保证在它返回时,这些 Callee-Saved 寄存器的值和它被调用时一样。swtch 通过保存它们来遵守这个约定,但它巧妙地恢复了另一个上下文的值。

swtch.S 中涉及的寄存器如下:

  • ra (Return Address Register): 返回地址寄存器。它存放着函数调用结束后应该返回到的指令地址。这是最重要的寄存器之一,因为它决定了代码的执行流。保存 ra 就是保存“我们从哪里来,要回到哪里去”。
  • sp (Stack Pointer Register): 栈指针寄存器。它指向当前栈的栈顶。每个进程(或内核线程)都有自己的栈,保存 sp 就等于保存了整个函数调用链的状态。
  • s0 - s11 (Saved Registers): 被调用者(calleed)保存寄存器。这些是通用寄存器,按照约定,如果一个函数要使用它们,必须在函数返回前将它们恢复到原始值。因此,它们的值在函数调用之间是“稳定”的,必须作为进程上下文的一部分被保存。

struct contextkernel/proc.h 中大致是这样定义的:

struct context {
  uint64 ra;
  uint64 sp;

  // callee-saved
  uint64 s0;
  uint64 s1;
  uint64 s2;
  uint64 s3;
  uint64 s4;
  uint64 s5;
  uint64 s6;
  uint64 s7;
  uint64 s8;
  uint64 s9;
  uint64 s10;
  uint64 s11;
};

这个结构体的字段顺序和 swtch.S 中的保存/加载顺序是严格对应的。


3. swtch.S 逐行代码解析

我们把代码分成三个部分来看。

第一部分:保存旧上下文 (Save current registers in old)

.globl swtch
swtch:
    sd ra, 0(a0)     # sd = store doubleword. 将 ra 寄存器的值存到 a0 指向的地址 (old->ra)
    sd sp, 8(a0)     # 将 sp 存到 a0+8 的地址 (old->sp)
    sd s0, 16(a0)    # 将 s0 存到 a0+16 的地址 (old->s0)
    sd s1, 24(a0)
    sd s2, 32(a0)
    sd s3, 40(a0)
    sd s4, 48(a0)
    sd s5, 56(a0)
    sd s6, 64(a0)
    sd s7, 72(a0)
    sd s8, 80(a0)
    sd s9, 88(a0)
    sd s10, 96(a0)
    sd s11, 104(a0)  # 将 s11 存到 a0+104 的地址 (old->s11)
  • a0 寄存器里放着 old 上下文的地址。
  • sd 指令将一个 64 位(doubleword)的寄存器值存入内存。
  • offset(reg) 是寻址方式,表示 reg 寄存器中的地址值 + offset。
  • RISC-V 是 64 位架构,所以每个寄存器占 8 字节。因此偏移量以 8 递增。
  • 执行完这部分代码后,调用 swtch 的那个执行环境(比如一个放弃 CPU 的进程,或者调度器本身)的 ra, sp, s0-s11 寄存器已经被完整地保存在了 old 指向的 struct context 内存中。

第二部分:加载新上下文 (Load from new)

    ld ra, 0(a1)     # ld = load doubleword. 从 a1 指向的地址加载值到 ra (ra = new->ra)
    ld sp, 8(a1)     # sp = new->sp
    ld s0, 16(a1)    # s0 = new->s0
    ld s1, 24(a1)
    ld s2, 32(a1)
    ld s3, 40(a1)
    ld s4, 48(a1)
    ld s5, 56(a1)
    ld s6, 64(a1)
    ld s7, 72(a1)
    ld s8, 80(a1)
    ld s9, 88(a1)
    ld s10, 96(a1)
    ld s11, 104(a1)  # s11 = new->s11
  • a1 寄存器里放着 new 上下文的地址。
  • ld 指令从内存中加载一个 64 位的值到寄存器。
  • 这是最关键的一步。执行完这部分代码后,CPU 的核心寄存器已经被 new 上下文完全覆盖了。
    • sp 寄存器现在指向了新任务的内核栈。
    • ra 寄存器现在包含了新任务上次被切换出去时记录的返回地址。

第三部分:返回 (Return)

    ret
  • ret (return) 是一个伪指令,它等价于 jalr zero, 0(ra)
  • 它的作用是:跳转到 ra 寄存器中存储的地址。
  • 此时的 ra 是什么?是刚刚从 new 上下文中加载进来的那个 ra

所以,ret 指令执行后,CPU 的程序计数器(PC)就指向了新任务被中断的地方,从而让新任务得以继续执行。swtch 函数的调用彻底完成,但它返回到了一个全新的世界。


比喻解释

工作台比喻

  1. CPU 核心 (CPU Core): 想象成一个工匠的工作台。在任何时刻,一个工作台只能用于一个项目。如果你的电脑是 4 核 CPU,那就有 4 个独立的工作台。

  2. 寄存器 (ra, sp, s0-s11 等):这些是工作台上面的工具和正在加工的零件。
    • sp (栈指针) 就像是你的“待办事项清单/流程图”,告诉你当前步骤在整个项目流程中的位置。
    • ra (返回地址) 就像是你刚刚完成一个子任务后,写在便签上的“下一步该做什么”。
    • s0-s11 等通用寄存器就像是你手边最常用的螺丝刀、扳手,上面沾着当前项目的零件。
    • 关键点:这些工具和零件就在工作台上,是当前正在进行的工作的唯一体现。它们是“现场”,不是“档案”。
  3. 内存 (RAM):这是工作台旁边的巨大货架。上面放着很多项目盒子。
  4. 进程 (Process): 货架上的每一个项目盒子都代表一个进程。比如“项目A:造一辆玩具车”、“项目B:修一个收音机”。

  5. struct context: 这就是项目盒子里的“状态托盘”。当你需要暂停一个项目时,你把工作台上的所有工具(寄存器)原样不动地摆放到这个托盘里,然后把托盘放回项目盒子,再把盒子放回货架。

总结

swtch.S 的整个流程就像一个精密的“偷天换日”:

  1. 一个进程(或调度器)调用 swtch。
  2. swtch 把这个调用者的核心寄存器打包存好(存入 old)。
  3. 然后,swtch 打开另一个之前打包好的包裹(new),把里面的寄存器状态装载到 CPU 上。
  4. 最后,swtch 执行 ret,但这个返回操作会把 CPU 带到新包裹所记录的目的地。