一、引言:上下文切换是操作系统的物理基础
进程并发、线程切换、中断嵌套,最终都落到同一个硬件动作:把当前执行流的运行现场保存下来,再恢复另一条执行流的运行现场。这个动作即上下文切换,其正确性直接决定系统稳定性,其开销则决定系统实时性。
与 x86、ARM 相比,RISC-V 的上下文切换机制高度规整:通用寄存器数量少、CSR 语义清晰、Trap 流程由 stvec/sscratch 等少量 CSR 标准化约定。理解 RISC-V 上下文切换,需要先分清两个常被混为一谈的概念------Trap 上下文与进程上下文。
二、两类上下文:进入内核与更换任务
Trap 上下文 发生在每一次中断或异常:处理器自动跳转 stvec 指向的入口,特权级提升到 S 模式,软件将被打断的现场完整压入内核栈,处理完毕通过 sret 原路返回。进程上下文则发生在调度器决定换人执行时:在内核态中直接保存旧任务的控制现场,恢复新任务的控制现场。
两者的差异如下表:
| 维度 | Trap 上下文 | 进程上下文 |
|---|---|---|
| 触发方式 | 中断 / 异常 / ecall | 调度器主动调用 |
| 保存范围 | 全部通用寄存器 + 关键 CSR | callee-saved(s0-s11、ra、sp) |
| 保存位置 | 当前任务内核栈的 Trap Frame | 各任务 thread_struct |
| 返回方式 | sret 精确回到打断点 |
ret 跳回上次让出点 |
| 发生频率 | 极高频(每次中断) | 毫秒级调度周期 |
关键认知在于:中断返回不等于进程切换 。一次定时器中断只是"进内核转一圈再回同一进程";只有当内核在此路径上调用 schedule() 并选中新任务时,才真正发生进程切换。前者保存再多现场也只为一个进程服务,后者则意味着换一套栈与执行流。
三、Trap 入口:现场如何被完整保存
RISC-V 在 Trap 发生时,硬件只完成最小动作:pc 写入 sepc、异常原因写入 scause、出错地址写入 stval、sstatus.SIE 被清零并提升特权级,随后跳转 stvec。其余约 32 个通用寄存器的保存,全部由软件在入口汇编中完成。
入口汇编面对的第一个问题是:寄存器都还装着被打断现场的值,用什么来定位内核栈? 约定做法是 sscratch------该 CSR 预先存放当前任务的内核栈指针,进入入口后先用它与某个通用寄存器交换,从而无侵入地取得栈地址:
asm
# 进入 trap:sscratch 原存内核栈指针
csrrw sp, sscratch, sp # sp <-> sscratch,此时 sp = 内核栈
bnez sp, .Lfrom_kernel # 原 sscratch 非 0 说明来自 S/U 模式
# 来自内核态:恢复原 sp 后继续(嵌套中断场景)
.Lfrom_kernel:
addi sp, sp, -256 # 分配 Trap Frame
sd ra, 0*8(sp)
sd t0, 2*8(sp)
sd t1, 3*8(sp)
# ... 依次保存 t2-t6、a0-a7、s0-s11 等全部寄存器
csrr t0, sepc
sd t0, 1*8(sp) # 保存 sepc 副本
恢复是保存的逆操作:按序 ld 回所有寄存器,写回 sepc,最后 sret。值得注意两点:其一,Trap Frame 必须位于当前任务自己的内核栈 ,若用共享栈或全局栈,多任务与嵌套中断会互相踩踏;其二,进入 C 语言处理函数前,a0 通常存放 pt_regs 指针,即把 Trap Frame 地址作为参数传给异常处理函数,这正是 Linux 中 handle_arch_irq 等入口的原始形态。
四、进程切换的本质:callee-saved 的最小保存
进程切换发生在内核态内部,本质是一次特殊的函数调用。RISC-V 的调用约定保证了 callee-saved 寄存器(s0-s11、sp、ra)跨函数调用不变,因此内核只需保存这些寄存器,即可保证任务被切走后再切回时,其局部变量与调用栈完全无损。caller-saved(t0-t6、a0-a7)由编译器在调用点前后自行保存,无需切换逻辑操心。
Linux 在 RISC-V 上以 __switch_to(prev, next) 实现该逻辑,核心汇编只有两步------存 prev、取 next:
asm
__switch_to: # a0 = prev, a1 = next
sd ra, 0*8(a0) # 保存 prev 的返回地址
sd sp, 1*8(a0) # 保存 prev 的栈指针
sd s0, 2*8(a0)
sd s1, 3*8(a0)
# ... s2-s11 依次保存到 a0 偏移处 ...
ld ra, 0*8(a1) # 恢复 next 的返回地址
ld sp, 1*8(a1) # 换栈:此后运行在 next 的内核栈
ld s0, 2*8(a1)
# ... s1-s11 依次恢复 ...
ret
ret 是这段代码的精髓:ra 此刻已换成 next 上次让出时留下的返回地址,ret 弹出的不是本函数的返回点,而是 next 被切走前调用 schedule() 的位置。于是 next 仿佛从未被切走过,从自己"上次停下的地方"继续执行------这就是协作式与抢占式调度共同的底层魔术。任务各自的初始上下文在创建时伪造:ra 指向任务入口函数,sp 指向任务栈顶,首次切换即从入口开始运行。
五、超出通用寄存器:FP/向量与地址空间
完整的进程状态不止 32 个整数寄存器。
浮点与向量状态。 f0-f31 与 v0-v31 体量庞大,全量保存代价高昂。Linux 采用惰性保存策略:进程首次使用 FPU/向量前不为其保存状态,切换时若新进程未使用过则直接跳过;sstatus 的 FS/VS 字段记录使用状态,一旦发生首次使用即触发 trap 由内核接管。内核线程因从不使用用户浮点上下文,天然零开销。
地址空间。 进程切换若伴随内存空间变更,必须写入新进程的 satp 并执行 sfence.vma,使 TLB 切换到新页表(此细节可回溯本系列虚拟内存一文)。内核线程没有用户地址空间,Linux 令其复用前一个进程的 active_mm,从而省去一次页表切换。因此一个完整的切换动作实际由三部分拼成:通用寄存器、FP/向量状态、页表指针。
六、实战:QEMU virt 上的协作式双任务调度
本节在 QEMU virt 平台实现一个最小协作式调度器:两个任务各自持有独立栈,主循环交替让出 CPU。任务控制块仅需保存切换所需的寄存器组:
c
struct task {
unsigned long ra, sp; /* 返回地址与栈指针 */
unsigned long s[12]; /* s0-s11 */
void (*entry)(void);
unsigned long stack[256]; /* 任务私有栈 */
} task_a, task_b;
/* 上下文切换:汇编实现 */
extern void switch_to(struct task *prev, struct task *next);
void task0_entry(void)
{
int i;
for (i = 0; i < 3; i++) {
uart_puts("[task0] running\n");
switch_to(&task_a, &task_b); /* 主动让出 */
}
}
void task1_entry(void)
{
int i;
for (i = 0; i < 3; i++) {
uart_puts("[task1] running\n");
switch_to(&task_b, &task_a);
}
}
任务初始化时伪造首次切换的现场:sp 指向各自栈顶、ra 指向入口函数,首次 switch_to 便会"跳入"任务:
c
void task_init(struct task *t, void (*entry)(void))
{
t->ra = (unsigned long)entry;
t->sp = (unsigned long)&t->stack[255];
}
编译运行命令与预期输出:
bash
riscv64-linux-gnu-gcc -march=rv64gc -mabi=lp64d -nostdlib \
-T sched.lds -o sched.elf start.S switch.S main.c
qemu-system-riscv64 -machine virt -nographic -bios sched.elf
log
[task0] running
[task1] running
[task0] running
[task1] running
[task0] running
[task1] running
两个任务严格交替执行,证明每次 switch_to 都完整保存并恢复了各自现场。将该实验扩展为定时器中断驱动的抢占式调度,即得到 RTOS 与 Linux 调度器的雏形。
七、常见坑位与调优建议
- 任务栈未对齐或过浅 :RISC-V ABI 要求
sp始终保持 16 字节对齐,栈溢出会静默踩踏相邻任务数据。为每个任务栈预留冗余并填充哨兵值监测。 - 漏存 callee-saved 寄存器 :s0-s11 任一漏存,被切走任务的局部变量都会静默损坏,表现为随机崩溃。寄存器序号与
thread_struct偏移必须严格一一对应。 - 切换窗口被中断打断 :保存与恢复之间若发生嵌套 Trap,新现场会覆盖未完成的切换现场。调度临界区需关中断保护,Linux 中以
local_irq_disable/enable包裹调度路径。 - FP/向量状态未处理 :未开启惰性保存或漏存向量寄存器时,新任务对
v寄存器的写入会污染旧任务,表现为偶发计算错误。检查sstatus.FS/VS与切换钩子。 - 换栈后忘记刷新 TLB :切换
satp后未执行sfence.vma,新任务会命中旧地址空间的陈旧映射。 - 误用共享栈保存现场 :Trap Frame 存入全局数组而非当前任务内核栈,多任务下互相覆盖。
sscratch必须指向当前任务的内核栈。
八、总结
本文沿"Trap 上下文---进程上下文---最小切换---完整切换"的主线,系统解析了 RISC-V 上下文切换的实现脉络,并通过 QEMU virt 平台的协作式调度器验证了切换机制的端到端正确性。值得反复咀嚼的三个结论:
- 两类切换分野清晰 :Trap 保存全部现场以求精确返回,进程切换只存 callee-saved 以求无缝续跑,
sret与ret的差异即两类切换的边界; - 最小切换原则贯穿始终 :能少存绝不多存------FP/向量惰性保存、内核线程复用
active_mm,一切优化都围绕削减切换成本展开; - 完整切换由三部分拼成:通用寄存器、FP/向量状态、页表指针,任一缺失都会造成难以定位的偶发故障。