RISC-V 上下文切换实战:从 Trap 入口到 Linux 进程调度

一、引言:上下文切换是操作系统的物理基础

进程并发、线程切换、中断嵌套,最终都落到同一个硬件动作:把当前执行流的运行现场保存下来,再恢复另一条执行流的运行现场。这个动作即上下文切换,其正确性直接决定系统稳定性,其开销则决定系统实时性。

与 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、出错地址写入 stvalsstatus.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-s11spra)跨函数调用不变,因此内核只需保存这些寄存器,即可保证任务被切走后再切回时,其局部变量与调用栈完全无损。caller-savedt0-t6a0-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-f31v0-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 以求无缝续跑,sretret 的差异即两类切换的边界;
  • 最小切换原则贯穿始终 :能少存绝不多存------FP/向量惰性保存、内核线程复用 active_mm,一切优化都围绕削减切换成本展开;
  • 完整切换由三部分拼成:通用寄存器、FP/向量状态、页表指针,任一缺失都会造成难以定位的偶发故障。
相关推荐
用户83562907805135 分钟前
使用 Python 在 Word 文档中创建自定义图表
后端·python
Profile排查笔记1 小时前
JavaScript 实现浏览器指纹生成:基础字段、Canvas 采样与 SHA-256 摘要
前端·人工智能·后端·自动化
GoGeekBaird1 小时前
我给 DeepSeek Harness 造了个铸造台
后端
YOU OU1 小时前
Spring Cloud概述
后端·spring·spring cloud
Freak嵌入式1 小时前
基于 MicroPython+DMA 链式触发的 Scatter-Gather 数据聚合实现
嵌入式
半个落月1 小时前
React JWT 登录鉴权实战:Mock、Zustand、Axios 与路由守卫全流程
后端·react.js
XingXingXxx1 小时前
Skill 能自己变强吗?从轨迹归纳到文本空间优化的进化之路
后端
jvmind_dev2 小时前
频繁 new JedisCluster 之后,对象池为什么回不去了?
java·后端
掘金者阿豪2 小时前
给你的AI龙虾打造一间像素办公室!Star Office UI部署+公网访问完整教程
后端