news 2026/4/3 9:16:34

RISCV简单的上下文切换以及多任务

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RISCV简单的上下文切换以及多任务

首先我们这次是做一个协作式多任务的切换,任务会自己放弃CPU从而提供给其他任务使用

一、前置知识:RISC-V 底层核心架构(上下文切换的基石)

要理解上下文切换,必须先吃透 RISC-V 的特权级寄存器体系指令集特性,这些是 OS 底层实现的 “地基”。

1. RISC-V 的特权级(Privilege Level)

RISC-V 定义了 4 个特权级(从高到低),本实验的内核运行在M-mode(机器模式),这是最高特权级,可访问所有资源:

特权级缩写用途关键 CSR(控制状态寄存器)
机器模式M-mode底层固件 / 内核(本实验)mhartid、mscratch、mstatus 等
监督模式S-mode普通操作系统内核sscratch、sstatus、stvec 等
客户模式U-mode用户程序无特权 CSR
超级 visor 模式H-mode虚拟化场景虚拟化相关 CSR

因为是极简 OS,跳过 S-mode 的复杂权限管控,直接用 M-mode 实现内核,降低入门难度;但真实 OS 会用 S-mode 隔离内核和用户态。

2. RISC-V 的寄存器体系(上下文的载体)

RISC-V 的寄存器分为通用寄存器CSR(控制状态寄存器),上下文切换的核心就是操作这两类寄存器。

(1)通用寄存器(32 个,x0~x31,32 位 / 64 位)

RISC-V 的通用寄存器有严格的功能约定(不是硬件强制,是软件调用约定),这直接决定了上下文切换时哪些寄存器需要保存:

寄存器别名编号功能保存要求(上下文切换)
zerox0硬连线为 0,不可修改无需保存(值固定)
rax1返回地址(函数调用的返回点)必须保存(任务暂停的指令指针)
spx2栈指针(指向栈顶,RISC-V 栈向下生长)必须保存(任务独立栈的标识)
gpx3全局指针(指向全局数据区)本实验未用,仅占位
tpx4线程指针(本实验存 hartid)无需保存(全局唯一,不随任务变)
t0~t2x5~x7临时寄存器(caller-saved)本实验选择保存(增强兼容性)
s0/fpx8保存寄存器 / 帧指针(callee-saved)必须保存(函数调用后需保留原值)
s1x9保存寄存器(callee-saved)必须保存
a0~a7x10~x17参数 / 返回值寄存器(caller-saved)本实验选择保存
s2~s11x18~x27保存寄存器(callee-saved)必须保存
t3~t6x28~x31临时寄存器(caller-saved)t6 特殊处理(下文详解)

核心区别:caller-saved vs callee-saved

  • caller-saved(调用者保存):如 t0~t2、a0~a7、t3~t6,函数 A 调用函数 B 时,A 需要自己保存这些寄存器的值(因为 B 可能会覆盖);
  • callee-saved(被调用者保存):如 s0~s11,函数 B 调用前必须保存这些寄存器的值,返回后恢复(保证 A 的这些寄存器值不变)。

上下文切换的保存逻辑:为了 “无缝恢复任务”,即使是 caller-saved 寄存器,本实验也全部保存(除 gp/tp/x0),避免数据丢失。

caller-saved 和 callee-saved 的保存逻辑

“为什么要保存两次”,其实是对 **“保存主体” 和 “保存范围”** 理解有偏差 ——根本不是对同一批寄存器保存两次,而是两类寄存器的 “保存责任主体” 不同,咱们分两层讲:

1. 先明确 “谁的寄存器”:A 和 B 的寄存器是同一组硬件寄存器

RISC-V 的通用寄存器是CPU 的硬件资源,不是 “属于 A” 或 “属于 B” 的。比如 A 函数执行时,s0 里存的是 A 的局部变量;当调用 B 函数后,B 如果要使用 s0,就会覆盖 A 的 s0 值,导致 A 返回后 s0 的值丢失。

2. caller-saved 和 callee-saved 的 “单次保存” 责任划分

这是 RISC-V软件调用约定(不是硬件强制),目的是保证函数调用后 “关键数据不丢失”,但两类寄存器的保存责任完全不同,不存在 “保存两次 A 的上下文”:

(1)caller-saved 寄存器(t0~t2、a0~a7、t3~t6):调用者 A 保存
  • 这类寄存器的特点是被调用者 B 可以随意覆盖(B 默认 A 已经保存了这些寄存器的值)。
  • 场景:A 要调用 B,且 A 后续还需要用 a0 的值,那么 A 必须在调用 B,把 a0 的值存到自己的栈里;B 执行时可以随便改 a0,A 返回后再从栈里恢复 a0 即可。
  • 这里的 “保存” 是A 为自己保存,和 B 无关。
(2)callee-saved 寄存器(s0~s11):被调用者 B 保存
  • 这类寄存器的特点是B 必须保证调用前后值不变(B 如果要使用 s0,必须先保存,返回前恢复)。
  • 场景:A 的 s0 里存了重要的局部变量,调用 B 后,B 如果要用到 s0,必须先把 s0 当前值(A 的 s0 值)存到 B 自己的栈里;B 用完 s0 后,再从栈里恢复 s0 的值,这样 A 返回后 s0 的值还是原来的。
  • 这里的 “保存” 是B 为 A 的 s 寄存器保存,不是 A 自己保存,且只保存一次。

3. 上下文切换的 “一次性保存”(和函数调用的区别)

印象里的 “只保存一次 A 的上下文” 是任务级的上下文切换,和 “函数调用的寄存器保存” 是两个场景:

  • 函数调用的保存:是临时、局部的(比如 B 只保存自己要用的 s 寄存器,不用的不保存),且执行完 B 就恢复;
  • 上下文切换的保存:是完整、持久的(把任务 A 的所有通用寄存器都存到 context 里),因为任务 A 会被暂停,下次恢复时需要完整的执行状态,和函数调用的 “局部保存” 本质不同。

总结:不存在 “保存两次 A 的上下文”,caller-saved 是 A 自己保自己的临时寄存器,callee-saved 是 B 帮 A 保保存寄存器,责任主体和范围都不同,是 “分工保存” 而非 “重复保存”。

(2)CSR(控制状态寄存器)

CSR 是 RISC-V 的 “特权寄存器”,用于控制 CPU 状态,本实验核心用到 3 个:

  1. mhartid:M-mode 的 CPU 核 ID 寄存器,多核系统中区分不同 hart,start.S 中用csrr t0, mhartid读取;
  2. mscratch:M-mode 的 “临时保存寄存器”,本实验用它存当前任务的 context 指针(关键!因为汇编指令不能直接访问内存,必须用通用寄存器当基址,而 mscratch 不参与任务执行,不会被覆盖);
  3. mstatus:M-mode 状态寄存器(本实验未直接用,但真实 OS 会用它控制中断使能、特权级切换)。
(3)RISC-V 的指令对齐要求

RISC-V 的指令地址必须 4 字节对齐(32 位指令),栈指针 sp 必须 16 字节对齐(调用约定强制要求),代码中__attribute__((aligned(16))) task_stack就是为了满足 sp 的对齐要求。

3. RISC-V 的 CSR 操作指令(上下文切换的关键指令)

操作 CSR 的指令是上下文切换的 “核心工具”,本实验用到csrr/csrw/csrrw

指令功能示例(本实验)
csrr rd, csr读 CSR 到通用寄存器 rdcsrr t6, mscratch(读 mscratch 到 t6)
csrw csr, rs写通用寄存器 rs 到 CSRcsrw mscratch, a0(写 a0 到 mscratch)
csrrw rd, csr, rs原子交换:先读 csr 到 rd,再写 rs 到 csrcsrrw t6, mscratch, t6(交换 t6 和 mscratch 的值)

原子性的意义csrrw是原子操作,不会被中断打断,保证上下文切换时 CSR 操作的完整性。

二、系统启动的底层细节(start.S + kernel.c,RISC-V M-mode 启动流程)

系统启动的每一步都和 RISC-V 的底层架构强绑定,我们逐行拆解:

1. start.S:M-mode 的硬件入口(0x80000000)

QEMU 将内核加载到0x80000000(platform.h 定义的内存布局),并从_start开始执行,这是 RISC-V 的硬件启动约定。

总的代码:

#include "platform.h" // 平台相关头文件,包含MAXNUM_CPU等硬件配置宏定义 # 每个硬件线程(hart)的栈大小为1024字节 # hart是RISC-V中硬件线程的核心单元,多核心/多线程系统中每个hart独立执行指令 .equ STACK_SIZE, 1024 // 定义栈大小常量,等价于C语言#define STACK_SIZE 1024 .global _start // 声明_start为全局符号,作为程序入口点(链接器识别的入口) .text // 代码段开始,RISC-V汇编中.text段存放可执行指令,只读且可执行 _start: # 暂停所有hart ID不等于0的硬件线程(仅保留hart 0执行核心逻辑) csrr t0, mhartid # 读取当前hart的ID到t0寄存器 # csrr是RISC-V特权指令,用于读取控制状态寄存器(CSR) # mhartid是机器模式下的hart ID寄存器,每个hart有唯一ID(从0开始) mv tp, t0 # 将hart ID保存到tp寄存器(线程指针),供后续使用 # tp寄存器在RISC-V ABI中约定为线程私有数据指针,常用于存储hart ID/线程上下文 bnez t0, park # 如果当前hart ID非0(t0≠0),跳转到park标签处挂起 # bnez是分支指令:Branch if Not Equal to Zero # 多核心启动时通常只让hart 0执行初始化,其余hart先挂起 # 将BSS段的所有字节清零 # BSS段用于存放未初始化的全局变量和静态变量,标准要求程序启动时必须清零 la a0, _bss_start # 加载BSS段起始地址到a0寄存器 # la是伪指令(Load Address),实际会翻译成auipc+addi组合指令 la a1, _bss_end # 加载BSS段结束地址到a1寄存器 bgeu a0, a1, 2f # 如果起始地址≥结束地址(无BSS段),跳转到2号标签(跳过清零) # bgeu是无符号大于等于分支,2f表示向前(forward)查找2号标签 1: # 1号标签:BSS清零循环体入口 sw zero, (a0) # 将0写入a0指向的内存地址(4字节) # sw是存储字指令(Store Word),zero寄存器恒为0 addi a0, a0, 4 # a0 += 4(指向下一个4字节地址) bltu a0, a1, 1b # 如果a0 < a1(未清零完),跳回1号标签继续循环 # bltu是无符号小于分支,1b表示向后(backward)查找1号标签 2: # 2号标签:BSS清零完成后的执行点 # 设置栈指针(栈从高地址向低地址生长,因此将栈指针指向栈空间的末尾) # RISC-V栈遵循满递减栈(Full Descending):sp指向最后一个已使用的栈地址,压栈时先sp-=size再存储 slli t0, t0, 10 # 将hart ID左移10位(等价于乘以1024) # slli是逻辑左移指令(Shift Left Logical Immediate),10位对应2^10=1024 la sp, stacks + STACK_SIZE # 加载栈空间起始地址+STACK_SIZE到sp(hart 0的栈顶) # stacks是栈空间的基地址,hart 0的栈范围:stacks ~ stacks+STACK_SIZE # sp初始指向栈空间末尾(栈顶),符合RISC-V栈生长方向 add sp, sp, t0 # 调整sp到当前hart对应的栈顶位置 # 多hart栈空间布局:hart N的栈 = stacks + N*STACK_SIZE ~ stacks + (N+1)*STACK_SIZE # 例如hart 1的栈顶 = stacks+STACK_SIZE + 1*STACK_SIZE j start_kernel # hart 0跳转到C语言实现的start_kernel函数执行 # j是无条件跳转指令(Jump),跳转到全局符号start_kernel park: # 非0号hart的挂起循环 wfi # 等待中断指令(Wait For Interrupt) # wfi会让hart进入低功耗状态,直到有中断/异常触发才唤醒 # 挂起的hart会在此处无限循环,直到被中断唤醒 j park # 跳回park标签,持续挂起 # RISC-V标准调用约定(ABI)要求:栈指针sp必须保持16字节对齐 # 对齐要求:函数调用时sp需是16的倍数,保证浮点寄存器/向量指令的内存访问对齐 .balign 16 # 对齐指令:后续数据按16字节边界对齐 stacks: # 栈空间的基地址标签 .skip STACK_SIZE * MAXNUM_CPU # 为所有hart分配栈空间(跳过指定字节数,即预留内存) # .skip是汇编伪指令,分配指定大小的未初始化内存 # MAXNUM_CPU是平台最大支持的hart数量(来自platform.h) .end # 汇编文件结束标记
这里有一个小细节,就是栈不是向下生长的吗,但是代码中提到了一个问题,就是

la sp, stacks + STACK_SIZE

这句话其实是直接给所有的hart分配了栈空间,然后在把sp寄存器往上移动,回到栈的起始地址,注意这里是分配空间,而不是使用空间,因为我们分配了空间,所以要回到我们分配的空间的起始位置,所以就是往高地址+++,

la sp, stacks + STACK_SIZE // stacks是所有hart栈的起始地址,stacks+STACK_SIZE是0号hart的栈顶初始地址 add sp, sp, t0 // t0是hartid×1024(即hartid×STACK_SIZE),为每个hart分配独立栈
  • 0 号 hart:t0=0 → sp=stacks+1024+0 → 对应 0 号 hart 的栈顶;
  • 1 号 hart:t0=1024 → sp=stacks+1024+1024 → 对应 1 号 hart 的栈顶;
  • 代码里stacks是所有 hart 栈的起始低地址stacks + STACK_SIZE是 0 号 hart 栈的最高地址,初始 sp 指向这里,完全符合栈向下生长的规则:
  • 假设stacks的地址是0x80001000STACK_SIZE=1024,则stacks+STACK_SIZE=0x80001400(高地址);
  • 0 号 hart 初始化时 sp=0x80001400(栈顶初始位置);
  • 当执行压栈操作(比如保存寄存器)时,先把数据存入sp-4=0x800013FC,再把 sp 更新为 0x800013FC(sp 递减,栈向下生长)。
(1)多核 hart 的初始化与休眠
csrr t0, mhartid // 读M-mode的hartid到t0(RISC-V架构指令,仅M-mode可访问mhartid) mv tp, t0 // tp寄存器永久存hartid(tp是线程指针,RISC-V约定tp不随任务切换) bnez t0, park // 非0号hart跳转到park标签,进入休眠
  • 为什么只让 0 号 hart 工作?本实验是单核 OS,多核需处理缓存一致性、锁等复杂问题,先简化为单核;
  • park 标签的休眠逻辑
park: wfi // RISC-V的“等待中断”指令,CPU进入低功耗,直到中断唤醒(本实验0号hart不会唤醒其他hart) j park // 无限循环,保持休眠
(2)BSS 段清零(RISC-V 数据段约定)

RISC-V 的 ELF 文件中,BSS 段是未初始化的全局变量(如task_stack),硬件不会自动清零,必须手动初始化:

la a0, _bss_start // 加载BSS段起始地址到a0(la是RISC-V的“加载地址”伪指令,实际是auipc+addi) la a1, _bss_end // 加载BSS段结束地址到a1 bgeu a0, a1, 2f // 如果a0 >= a1(BSS为空),跳转到2: 1: sw zero, (a0) // 把a0指向的内存写0(sw是RISC-V的“存储字”指令,32位) addi a0, a0, 4 // 地址+4(32位系统,按字对齐) bltu a0, a1, 1b // 未到结束地址则循环 2:
  • RISC-V 的地址指令la是伪指令,会被汇编器转为auipc(高位地址)+addi(低位偏移),因为 RISC-V 的立即数指令范围有限,无法直接加载 32 位地址。
(3)0 号 hart 的初始栈设置(RISC-V 栈对齐要求)
slli t0, t0, 10 // t0(hartid)左移10位,等价于*1024(STACK_SIZE=1024) la sp, stacks + STACK_SIZE // 加载栈区起始地址+栈大小到sp(sp指向栈顶,RISC-V栈向下生长) add sp, sp, t0 // 0号hart的sp = stacks+1024 + 0,满足16字节对齐 j start_kernel // 跳转到C语言的start_kernel(RISC-V的无条件跳转指令)
  • 栈的 16 字节对齐:RISC-V 调用约定强制要求 sp 必须 16 字节对齐,stacks标签用.balign 16保证对齐,否则函数调用会出错;
  • stacks 的内存布局
.balign 16 stacks: .skip STACK_SIZE * MAXNUM_CPU // 预分配8个hart的栈空间(每个1024字节)

2. kernel.c:C 语言的内核入口(M-mode 到 C 的过渡)

start_kernel是 C 代码的起点,每一步初始化都服务于后续的任务切换,总代码:

void start_kernel(void) { uart_init(); // 初始化UART(RISC-V的内存映射IO,地址0x10000000,见platform.h) uart_puts("Hello, RVOS!\n"); page_init(); // 初始化页式内存(本实验未用任务的动态内存,先忽略) sched_init(); // 关键!初始化mscratch=0(M-mode CSR,为第一次上下文切换做准备) os_main(); // 创建用户任务(task0和task1) schedule(); // 触发第一次上下文切换(从内核切换到task0) uart_puts("Would not go here!\n");// 一旦切换到任务,PC不会再回到这里(ra已被覆盖) while (1) {}; }
  • UART 的底层原理:UART 是内存映射 IO,RISC-V 通过读写0x10000000地址的寄存器(如 THR/RHR)实现串口通信,无需专门的 IO 指令(RISC-V 是 “内存 - IO 统一编址”,区别于 x86 的独立 IO 空间)。

三、任务管理的底层实现(sched.c,RISC-V 任务的 “诞生” 与调度)

任务的创建和调度,本质是为任务分配独立的 “寄存器上下文” 和 “栈空间”,我们逐行拆解:

1. 任务栈与上下文的初始化(task_create 函数)

每个任务需要独立的栈初始寄存器上下文,这是任务独立执行的基础:

#include "os.h" // 操作系统核心头文件,包含reg_t(寄存器类型)、context结构体等核心定义 /* * 声明外部函数switch_to:由汇编实现的上下文切换核心函数 * 拓展:switch_to是任务调度的关键,会修改CPU的寄存器(如sp/ra),实现从当前任务切到下一个任务 * 入参:next - 下一个待执行任务的上下文结构体指针 */ extern void switch_to(struct context *next); // 系统最大支持的任务数(FIFO调度队列的最大长度) #define MAX_TASKS 10 // 每个任务的栈大小:1024字节(符合RISC-V栈16字节对齐要求) #define STACK_SIZE 1024 /* * RISC-V标准调用约定(ABI)强制要求:栈指针sp必须始终保持16字节对齐 * __attribute__((aligned(16))):GCC属性,强制数组按16字节边界对齐 * 拓展:栈对齐保证浮点指令/向量指令的内存访问正确性,避免总线错误 */ uint8_t __attribute__((aligned(16))) task_stack[MAX_TASKS][STACK_SIZE]; /* * 任务上下文数组:每个元素保存一个任务的核心执行上下文 * 拓展:struct context是架构相关的结构体(通常在os.h中定义),至少包含: * - sp:任务的栈指针(栈顶地址) * - ra:任务的返回地址(任务入口/切出时的执行点) * 还可能包含通用寄存器(a0-a7/t0-t6)、状态寄存器(mstatus)等 */ struct context ctx_tasks[MAX_TASKS]; /* * 静态全局变量:调度器核心状态 * _top:标记ctx_tasks中已创建任务的最大下标(也表示当前总任务数) * _current:指向当前正在执行的任务在ctx_tasks中的下标(初始-1表示无任务) */ static int _top = 0; static int _current = -1; /* * 写RISC-V机器模式的mscratch寄存器 * 拓展:mscratch(Machine Scratch Register)是RISC-V机器模式专用寄存器: * 1. 用于保存临时数据(机器模式下中断/异常处理时,避免覆盖通用寄存器) * 2. 通常存放当前任务的上下文指针或中断栈地址 * 入参:x - 要写入mscratch的数值 */ static void w_mscratch(reg_t x) { // 内联汇编实现CSR写操作:csrw(Control and Status Register Write) // 格式:asm volatile("指令模板" : 输出约束 : 输入约束 : 破坏列表) // "r"(x):表示将变量x放入通用寄存器,作为输入传递给汇编指令 asm volatile("csrw mscratch, %0" : : "r" (x)); } /* * 调度器初始化函数 * 功能:初始化调度器核心状态,清空mscratch寄存器(初始无任务) * 调用时机:系统启动时(hart 0初始化完成后) */ void sched_init() { w_mscratch(0); // 将mscratch置0,表示当前无任务上下文关联 } /* * 实现简单的循环FIFO(先进先出)调度器 * 核心逻辑:按任务创建顺序循环执行,无优先级,属于非抢占式调度 * 拓展:FIFO调度是最基础的调度算法,优点是简单、无饥饿问题,缺点是响应性差 */ void schedule() { // 边界检查:如果无已创建的任务,触发panic(系统致命错误) if (_top <= 0) { panic("Num of task should be greater than zero!"); // panic是OS核心函数,终止系统并打印错误 return; } // 计算下一个要执行的任务下标:循环取模(实现FIFO循环) // 例如:_current=2, _top=3 → 下一个=3%3=0;_current=0, _top=3 → 下一个=1 _current = (_current + 1) % _top; // 获取下一个任务的上下文指针 struct context *next = &(ctx_tasks[_current]); // 跳转到汇编实现的上下文切换函数,切换到next任务执行 // 拓展:switch_to执行时会保存当前任务上下文,恢复next任务的寄存器(sp/ra等) switch_to(next); } /* * 【任务创建接口】 * 功能:创建一个新任务,初始化其上下文(栈指针、入口地址) * 入参:start_routin - 任务的入口函数(无参无返回值) * 返回值:0-成功;-1-失败(任务数达到上限) * 拓展:任务创建的核心是初始化上下文,让调度器能找到任务的执行起点和栈空间 */ int task_create(void (*start_routin)(void)) { // 检查是否还有空闲任务槽位(未达到最大任务数) if (_top < MAX_TASKS) { /* * 初始化新任务的栈指针:指向任务栈的末尾(栈顶) * RISC-V栈是满递减栈(Full Descending): * - 栈空间范围:task_stack[_top][0] ~ task_stack[_top][STACK_SIZE-1] * - sp初始指向栈末尾(最高地址),压栈时先sp-=size再存储数据 */ ctx_tasks[_top].sp = (reg_t) &task_stack[_top][STACK_SIZE]; /* * 初始化新任务的返回地址:指向任务入口函数 * 拓展:ra寄存器在RISC-V中保存函数返回地址,此处设置ra为任务入口, * 当switch_to恢复ra后,ret指令会跳转到start_routin执行 */ ctx_tasks[_top].ra = (reg_t) start_routin; // 任务数+1(_top既是下标也是任务总数) _top++; return 0; // 创建成功 } else { return -1; // 任务数达到上限,创建失败 } } /* * 【任务主动让出CPU接口】 * 功能:当前任务主动放弃CPU使用权,触发调度器切换到下一个任务 * 拓展:属于非抢占式调度的核心接口,任务需主动调用才能切换, * 抢占式调度则由定时器中断触发schedule() */ void task_yield() { // 调用调度器,切换到下一个任务 schedule(); } /* * 【简单的任务延时函数】 * 功能:通过忙等消耗CPU周期,实现粗略的延时(无定时器中断时的临时方案) * 入参:count - 延时计数(越大延时越长) * 拓展: * 1. volatile关键字:防止编译器优化掉空循环(编译器会认为count未被使用,直接删除循环) * 2. 忙等缺点:CPU利用率100%,无低功耗,实际OS中应使用定时器中断+休眠 * 3. count *= 50000:放大计数,让延时效果更明显(适配不同主频的CPU) */ void task_delay(volatile int count) { count *= 50000; while (count--); // 空循环消耗CPU }
// 预分配10个任务的栈,每个1024字节,16字节对齐(满足RISC-V调用约定) uint8_t __attribute__((aligned(16))) task_stack[MAX_TASKS][STACK_SIZE]; // 每个任务的上下文(寄存器快照) struct context ctx_tasks[MAX_TASKS]; static int _top = 0; // 已创建任务数 static int _current = -1;// 当前运行的任务ID int task_create(void (*start_routin)(void)) { if (_top < MAX_TASKS) { // 关键1:设置任务的sp(栈指针),指向栈的最末端(栈向下生长,初始栈顶在高地址) ctx_tasks[_top].sp = (reg_t) &task_stack[_top][STACK_SIZE]; // 关键2:设置任务的ra(返回地址),指向任务的入口函数(如user_task0) ctx_tasks[_top].ra = (reg_t) start_routin; _top++; return 0; } else { return -1; } }
  • RISC-V 栈的生长方向:栈从高地址向低地址生长,因此初始 sp 要指向task_stack[_top][STACK_SIZE](栈的最高地址),任务执行时 sp 会逐渐减小;
  • ra 的底层意义:ra 寄存器保存的是函数的返回地址,这里把 ra 设为任务入口函数的地址,当第一次切换到该任务时,ret指令会跳转到 ra 指向的地址(即任务开始执行)。

2. 调度器的底层逻辑(schedule 函数)

调度器的核心是选择下一个任务的上下文,并调用switch_to完成寄存器的切换:

void schedule() { if (_top <= 0) panic("任务数不能为0"); // 循环调度(FIFO):_current从-1→0→1→0→1...(取模实现循环) _current = (_current + 1) % _top; struct context *next = &ctx_tasks[_current]; switch_to(next); // 核心:切换到next任务的上下文(汇编实现) }
  • 为什么用 FIFO 调度?协作式多任务的调度策略很简单,因为任务会主动调用task_yield让出 CPU,无需复杂的优先级调度;
  • _current 的初始值 - 1:第一次调度时,_current+1=0,切换到 task0,符合任务创建顺序。

3. 协作式多任务的核心(task_yield 函数)

协作式多任务的本质是任务主动放弃 CPUtask_yield是任务 “主动让权” 的接口:

void task_yield() { schedule(); // 调用调度器,触发上下文切换 }
  • 协作式的底层局限性:如果任务不调用task_yield(比如死循环),会永久霸占 CPU,其他任务无法执行;真实 OS 会用抢占式多任务(通过定时器中断强制切换),这需要 RISC-V 的mtvec(中断向量表)和mstatus(中断使能)支持,后续可扩展学习。

四、上下文切换的底层核心(entry.S,RISC-V 汇编的逐行精解)

switch_to是整个实验的最核心、最底层的函数,用 RISC-V 汇编实现寄存器的 “保存” 与 “恢复”,我们逐行拆解,结合 RISC-V 架构原理分析:

1. 先理解两个核心宏(reg_save/reg_restore)

这两个宏是批量操作寄存器的 “工具”,本质是把通用寄存器按固定偏移存入 / 读出 context 结构体,先看struct context的内存布局(和宏的偏移严格对应):

struct context 成员偏移(字节)reg_save/restore 的操作对应 RISC-V 寄存器
ra0*4=0STORE ra, 0*SIZE_REG(base)x1
sp1*4=4STORE sp, 1*SIZE_REG(base)x2
gp2*4=8未操作(本实验不用)x3
tp3*4=12未操作(全局唯一)x4
t04*4=16STORE t0,4*SIZE_REG(base)x5
...(中间寄存器省略).........
t630*4=120单独保存(reg_save 未处理)x31

总的代码:

# 宏定义:统一内存访问指令和寄存器宽度 # LOAD:定义为lw(Load Word),4字节加载指令(适配32位RISC-V) # STORE:定义为sw(Store Word),4字节存储指令 # SIZE_REG:通用寄存器宽度(4字节,对应RV32架构;若为RV64则改为8,指令换为ld/sd) #define LOAD lw #define STORE sw #define SIZE_REG 4 # 宏:将除gp、tp外的所有通用寄存器(GP)保存到上下文结构体 # 功能等价于C代码: # struct context *base = &ctx_task; // base是上下文结构体指针(传入的参数) # base->ra = ra; // 依次保存各寄存器到上下文的对应偏移位置 # ...... # 拓展说明: # 1. 不保存gp(全局指针)和tp(线程指针)的原因: # - gp:用于访问全局数据区,系统中所有任务共享,无需任务间切换 # - tp:RVOS中固定存储hartid(硬件线程ID),全局唯一且不会变化 # 2. RISC-V寄存器分类(ABI约定): # - 调用者保存寄存器(t0-t6/a0-a7):函数调用时由调用者保存,上下文切换必须保存 # - 被调用者保存寄存器(s0-s11):函数调用时由被调用者保存,上下文切换也需保存 # - 特殊寄存器(ra/sp/gp/tp):ra/sp是任务执行核心,必须保存;gp/tp无需保存 .macro reg_save base # 按上下文结构体的偏移顺序保存寄存器,偏移=序号*4字节 STORE ra, 0*SIZE_REG(\base) # ra(x1):返回地址,偏移0 STORE sp, 1*SIZE_REG(\base) # sp(x2):栈指针,偏移4 # 跳过x3(gp)、x4(tp),无偏移2、3 STORE t0, 4*SIZE_REG(\base) # t0(x5):临时寄存器,偏移16 STORE t1, 5*SIZE_REG(\base) # t1(x6):临时寄存器,偏移20 STORE t2, 6*SIZE_REG(\base) # t2(x7):临时寄存器,偏移24 STORE s0, 7*SIZE_REG(\base) # s0(x8):保存寄存器,偏移28 STORE s1, 8*SIZE_REG(\base) # s1(x9):保存寄存器,偏移32 STORE a0, 9*SIZE_REG(\base) # a0(x10):参数/返回值寄存器,偏移36 STORE a1, 10*SIZE_REG(\base) # a1(x11):参数寄存器,偏移40 STORE a2, 11*SIZE_REG(\base) # a2(x12):参数寄存器,偏移44 STORE a3, 12*SIZE_REG(\base) # a3(x13):参数寄存器,偏移48 STORE a4, 13*SIZE_REG(\base) # a4(x14):参数寄存器,偏移52 STORE a5, 14*SIZE_REG(\base) # a5(x15):参数寄存器,偏移56 STORE a6, 15*SIZE_REG(\base) # a6(x16):参数寄存器,偏移60 STORE a7, 16*SIZE_REG(\base) # a7(x17):参数寄存器,偏移64 STORE s2, 17*SIZE_REG(\base) # s2(x18):保存寄存器,偏移68 STORE s3, 18*SIZE_REG(\base) # s3(x19):保存寄存器,偏移72 STORE s4, 19*SIZE_REG(\base) # s4(x20):保存寄存器,偏移76 STORE s5, 20*SIZE_REG(\base) # s5(x21):保存寄存器,偏移80 STORE s6, 21*SIZE_REG(\base) # s6(x22):保存寄存器,偏移84 STORE s7, 22*SIZE_REG(\base) # s7(x23):保存寄存器,偏移88 STORE s8, 23*SIZE_REG(\base) # s8(x24):保存寄存器,偏移92 STORE s9, 24*SIZE_REG(\base) # s9(x25):保存寄存器,偏移96 STORE s10, 25*SIZE_REG(\base) # s10(x26):保存寄存器,偏移100 STORE s11, 26*SIZE_REG(\base) # s11(x27):保存寄存器,偏移104 STORE t3, 27*SIZE_REG(\base) # t3(x28):临时寄存器,偏移108 STORE t4, 28*SIZE_REG(\base) # t4(x29):临时寄存器,偏移112 STORE t5, 29*SIZE_REG(\base) # t5(x30):临时寄存器,偏移116 # 注意:此处未保存t6(x31),因为t6被用作base(上下文指针) # t6的保存需要在reg_save宏外部单独处理,避免覆盖base导致地址错误 .endm # 宏:从上下文结构体恢复除gp、tp外的所有通用寄存器 # 功能与reg_save相反:将上下文数据加载回对应寄存器 # struct context *base = &ctx_task; # ra = base->ra; # ...... .macro reg_restore base # 按偏移顺序恢复寄存器,与reg_save一一对应 LOAD ra, 0*SIZE_REG(\base) # 恢复返回地址ra LOAD sp, 1*SIZE_REG(\base) # 恢复栈指针sp(核心:切换任务栈) LOAD t0, 4*SIZE_REG(\base) # 恢复临时寄存器t0 LOAD t1, 5*SIZE_REG(\base) # 恢复t1 LOAD t2, 6*SIZE_REG(\base) # 恢复t2 LOAD s0, 7*SIZE_REG(\base) # 恢复保存寄存器s0 LOAD s1, 8*SIZE_REG(\base) # 恢复s1 LOAD a0, 9*SIZE_REG(\base) # 恢复参数寄存器a0 LOAD a1, 10*SIZE_REG(\base) # 恢复a1 LOAD a2, 11*SIZE_REG(\base) # 恢复a2 LOAD a3, 12*SIZE_REG(\base) # 恢复a3 LOAD a4, 13*SIZE_REG(\base) # 恢复a4 LOAD a5, 14*SIZE_REG(\base) # 恢复a5 LOAD a6, 15*SIZE_REG(\base) # 恢复a6 LOAD a7, 16*SIZE_REG(\base) # 恢复a7 LOAD s2, 17*SIZE_REG(\base) # 恢复s2 LOAD s3, 18*SIZE_REG(\base) # 恢复s3 LOAD s4, 19*SIZE_REG(\base) # 恢复s4 LOAD s5, 20*SIZE_REG(\base) # 恢复s5 LOAD s6, 21*SIZE_REG(\base) # 恢复s6 LOAD s7, 22*SIZE_REG(\base) # 恢复s7 LOAD s8, 23*SIZE_REG(\base) # 恢复s8 LOAD s9, 24*SIZE_REG(\base) # 恢复s9 LOAD s10, 25*SIZE_REG(\base) # 恢复s10 LOAD s11, 26*SIZE_REG(\base) # 恢复s11 LOAD t3, 27*SIZE_REG(\base) # 恢复t3 LOAD t4, 28*SIZE_REG(\base) # 恢复t4 LOAD t5, 29*SIZE_REG(\base) # 恢复t5 LOAD t6, 30*SIZE_REG(\base) # 恢复t6(x31),偏移120(30*4) .endm # 保存/恢复寄存器的关键注意事项: # 1. 使用mscratch寄存器存储当前任务的上下文指针: # mscratch是RISC-V机器模式专用CSR,用于临时存储数据,不会被普通指令覆盖 # 2. 使用t6作为reg_save/reg_restore的base(上下文指针): # - t6是通用寄存器中编号最大的(x31),加载/存储过程中不会被其他指令覆盖 # - 注意:CSR寄存器(如mscratch)不能作为load/store指令的基址寄存器, # 因此必须先将mscratch的值换到通用寄存器(t6)中才能使用 .text # 代码段开始,存放可执行指令 # 全局函数:void switch_to(struct context *next); # 功能:上下文切换核心函数,将CPU从当前任务切换到next任务 # 入参约定(RISC-V ABI):a0寄存器存放next指针(下一个任务的上下文地址) .globl switch_to # 声明为全局符号,供C代码调用 .balign 4 # 指令按4字节对齐(RISC-V指令固定4字节) switch_to: # 核心步骤1:交换t6和mscratch的值(csrrw=CSR Read and Write) # 指令功能:先将mscratch的当前值读入t6,再将t6的原值写入mscratch # 作用:t6现在指向“当前任务”的上下文指针(mscratch中存储的旧值) csrrw t6, mscratch, t6 # 核心步骤2:处理第一次调用switch_to的特殊情况 # beqz t6, 1f:如果t6为0(第一次调用),跳转到1号标签 # 背景:sched_init()中初始化mscratch为0,第一次调用时csrrw会让t6=0, # 此时无“前一个任务”需要保存,直接跳转到恢复新任务的逻辑 beqz t6, 1f # 核心步骤3:保存前一个任务的上下文(非第一次调用时执行) # reg_save t6:将当前CPU的所有通用寄存器保存到t6指向的上下文(前一个任务) reg_save t6 # 核心步骤4:单独保存t6寄存器(因为reg_save中未保存) mv t5, t6 # t5暂存当前任务的上下文指针(避免被覆盖) csrr t6, mscratch # 从mscratch中读回t6的原始值(交换前的t6) STORE t6, 30*SIZE_REG(t5) # 将t6的值保存到当前任务上下文的t6偏移位置 1: # 1号标签:恢复下一个任务的上下文 # 核心步骤5:更新mscratch为下一个任务的上下文指针 # csrw:将a0(next指针)写入mscratch,后续切换时可通过mscratch获取新任务上下文 csrw mscratch, a0 # 核心步骤6:恢复下一个任务的所有寄存器 mv t6, a0 # t6指向新任务的上下文指针(a0=next) reg_restore t6 # 从t6指向的上下文恢复所有通用寄存器 # 关键:恢复sp(栈指针)后,CPU的栈已切换到新任务;恢复ra后,ret会跳转到新任务的执行点 # 核心步骤7:完成上下文切换(ret指令) # ret等价于jr ra:跳转到ra寄存器指向的地址(新任务的入口/切出时的执行点) # 此时ra已被reg_restore恢复为新任务的返回地址,执行ret即切换到新任务运行 ret .end # 汇编文件结束
当前任务A → switch_to(next=B) → 任务B执行 ├─ 保存A的寄存器到A的上下文(mscratch→t6→reg_save) ├─ 更新mscratch为B的上下文指针 ├─ 从B的上下文恢复所有寄存器(包括sp/ra) └─ ret跳转到B的ra地址,B开始执行
(1)reg_save 宏(保存寄存器到 context)
.macro reg_save base // base是context结构体的指针(通用寄存器) STORE ra, 0*SIZE_REG(\base) // ra存入base+0 STORE sp, 1*SIZE_REG(\base) // sp存入base+4 STORE t0, 4*SIZE_REG(\base) // t0存入base+16(跳过gp/tp的偏移2/3) STORE t1, 5*SIZE_REG(\base) // t1存入base+20 ...(中间寄存器省略) STORE t5, 29*SIZE_REG(\base) // t5存入base+116 # 不保存t6:因为t6被用作base,保存t6会覆盖base,需单独处理 .endm
  • 为什么跳过 gp/tp?gp 本实验未用,tp 存 hartid(全局不变),无需保存,因此宏中跳过偏移 2 和 3 的位置;
  • t6 的特殊处理:t6 被用作 base(context 指针),如果在宏中保存 t6,会把 base 的值覆盖,因此需要在宏外单独保存。
(2)reg_restore 宏(从 context 恢复寄存器)
.macro reg_restore base LOAD ra, 0*SIZE_REG(\base) // 从base+0恢复ra LOAD sp, 1*SIZE_REG(\base) // 从base+4恢复sp LOAD t0, 4*SIZE_REG(\base) // 从base+16恢复t0 ...(中间寄存器省略) LOAD t6, 30*SIZE_REG(\base) // 从base+120恢复t6(单独处理的t6) .endm
  • 恢复顺序的意义:恢复顺序和保存顺序一致,保证寄存器的值和暂停时完全相同;t6 在最后恢复,避免覆盖 base。

2. switch_to 函数的逐行拆解(结合 RISC-V 架构)

函数原型:void switch_to(struct context *next),RISC-V 调用约定中,函数参数通过a0寄存器传递,因此a0是下一个任务的 context 指针。

.globl switch_to // 声明为全局函数,供C语言调用 .balign 4 // 指令4字节对齐(RISC-V强制要求) switch_to: # 步骤1:原子交换t6和mscratch的值(核心!获取当前任务的context指针) csrrw t6, mscratch, t6 // 1. 读mscratch到t6;2. 写原t6到mscratch;原子操作 beqz t6, 1f // 如果t6=0(第一次切换,mscratch初始为0),跳转到1:标签
  • 第一次切换的特殊情况sched_init()把 mscratch 设为 0,因此第一次执行csrrw后 t6=0,直接跳过 “保存上一个任务上下文” 的步骤(因为还没有任务需要保存)。
# 步骤2:保存上一个任务的上下文(非第一次切换时执行) reg_save t6 // 调用宏,把上一个任务的寄存器保存到t6指向的context mv t5, t6 // t5暂存上一个任务的context指针(防止t6被覆盖) csrr t6, mscratch // 从mscratch读回原t6的值(步骤1交换的原t6) STORE t6, 30*SIZE_REG(t5) // 单独保存t6到context的30*4=120偏移处(reg_save未处理)
  • 为什么要单独保存 t6?步骤 1 中 t6 被替换为 mscratch 的值(context 指针),原 t6 的值被存到 mscratch,因此需要读回并单独保存,保证 t6 寄存器的快照完整。
1: # 步骤3:更新mscratch为下一个任务的context指针 csrw mscratch, a0 // mscratch现在指向next任务的context(后续切换时可通过mscratch获取) # 步骤4:恢复下一个任务的上下文 mv t6, a0 // t6指向next任务的context(作为reg_restore的base) reg_restore t6 // 调用宏,从context恢复所有寄存器(包括t6) # 步骤5:完成切换,跳转到next任务的ra地址 ret // RISC-V的ret指令:PC = ra(next任务的ra是入口函数/暂停点)
  • ret 指令的底层作用:ret 指令会把 ra 寄存器的值加载到 PC(程序计数器),从而让 CPU 执行 next 任务的指令:
    • 第一次切换时:next 任务的 ra 是user_task0的地址,ret 后 PC 跳转到 user_task0,task0 开始执行;
    • 非第一次切换时:next 任务的 ra 是上次暂停时的地址(如 task0 的 task_yield 之后),ret 后 task0 从暂停点继续执行。

3. 上下文切换的完整内存映射(以 task0→task1 为例)

假设 task0 的 context 在内存0x80010000,task1 的 context 在0x80010100,切换时的内存变化:

  1. 保存 task0 的 ra(user_task0中 task_yield 的下一条指令地址)到0x80010000
  2. 保存 task0 的 sp(task_stack[0][1024]的当前值)到0x80010004
  3. 其他寄存器依次存入0x80010008~0x80010078
  4. 恢复 task1 的 ra(user_task1的暂停点)到 ra 寄存器;
  5. 恢复 task1 的 sp 到 sp 寄存器;
  6. ret 指令让 PC=ra,task1 开始执行。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/3/30 7:14:55

BootstrapAdmin:10分钟构建企业级.NET权限管理系统,开发效率提升300%

还在为后台权限系统开发耗费数周时间而烦恼吗&#xff1f;从数据库设计到角色配置&#xff0c;从登录认证到菜单权限&#xff0c;一套完整的权限体系往往消耗开发团队30%以上的宝贵时间。现在&#xff0c;基于.NET 8的BootstrapAdmin权限管理框架彻底改变了这一现状——无需编写…

作者头像 李华
网站建设 2026/3/20 18:23:19

代码重构的艺术与实战指南

代码重构艺术的技术文章大纲引言重构的定义及其在软件开发中的重要性重构与代码优化的区别重构的目标&#xff1a;提升可读性、可维护性、扩展性重构的核心原则保持功能不变的前提下优化代码结构小步迭代&#xff0c;频繁验证自动化测试作为安全网常见的代码坏味道重复代码&…

作者头像 李华
网站建设 2026/4/2 0:11:46

从一个按钮实例入门CSS in JS之styled-components

前言最近接手一个react项目&#xff0c;项目中使用的 CSS in JS方案来实现CSS模块化&#xff0c;它使用的包是styled-components我以前的项目Vue居多的&#xff0c;Vue是很简单的通过scoped就可以实现模块化CSS&#xff0c;当然也在项目中用过CSS Modules方案&#xff0c;但CSS…

作者头像 李华
网站建设 2026/3/27 21:53:45

Python实现微信域名智能对抗性学习与动态防御系统

Python实现微信域名智能对抗性学习与动态防御系统功能概述本系统实现了一个基于深度强化学习、对抗性机器学习和复杂网络分析的微信域名智能对抗系统。通过多层防御架构、智能流量伪装、动态策略调整和分布式协同防御&#xff0c;构建了一个能够实时学习和适应微信风控变化的先…

作者头像 李华
网站建设 2026/3/26 4:12:08

C++电脑性能测试代码

源代码&#xff0c;拷贝到编译器上运行即可#include <iostream> #include <vector> #include <chrono> #include <cmath> #include <algorithm> #include <random> #include <fstream> #include <iomanip> #include <cstri…

作者头像 李华
网站建设 2026/3/24 11:32:42

【python大数据毕设实战】上海二手房数据可视化分析系统、Hadoop、计算机毕业设计、包括数据爬取、数据分析、数据可视化、机器学习、实战教学

&#x1f34a;作者&#xff1a;计算机毕设匠心工作室 &#x1f34a;简介&#xff1a;毕业后就一直专业从事计算机软件程序开发&#xff0c;至今也有8年工作经验。擅长Java、Python、微信小程序、安卓、大数据、PHP、.NET|C#、Golang等。 擅长&#xff1a;按照需求定制化开发项目…

作者头像 李华