ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RISC-V特权架构与CSR速查:M/S/U模式切换与中断委托详解

RISC-V特权架构与CSR速查:M/S/U模式切换与中断委托详解 RISC-V 专栏写到第三篇总算敢碰 CSR 和特权架构这块硬骨头了。它在整个体系里属于“地基中的地基”——写裸机固件要碰跑 Linux 内核要碰做调试器、模拟器、RTOS 移植也绕不开。但资料往往碎得离谱尤其是 M/S/U 三种特权模式相互切换的细节不把状态寄存器捋清楚光是一个中断返回就能让你怀疑人生。这篇就当一份能直接抄作业的 CSR 速查手册把 RISC-V 的特权架构和模式切换机制讲透顺带分享我实测过的代码和踩坑记录。适合正在写底层软件、搞模拟器或者第一次接触 RISC-V 操作系统的开发人员。1. 特权架构为什么 RISC-V 要搞出 M/S/U 三个世界1.1 三个模式各管什么事很多初学者第一次看到 Machine / Supervisor / User 三个模式第一反应都是“这也太麻烦了吧”。但你先想一个问题如果所有代码都运行在同一个特权级一个普通应用随手改掉操作系统的内存或者直接篡改中断向量表整个系统瞬间就崩了。RISC-V 设计三个模式的目的就是做一套“最小可用”的隔离机制不搞复杂的硬件虚拟化墙而是先规定清楚——你现在是哪一级身份你能动哪些寄存器你能执行哪些指令你能踩哪些内存。这里我用一个生活化的类比一栋楼的门禁。U 模式是普通住户只能在自己家里、楼道里活动S 模式是物业管理员能打开设备间、看监控、协调公共设施M 模式是安保总控中心钥匙最多权限最高所有人都得听它的。对应到实际系统里U 模式跑用户应用程序S 模式跑操作系统内核Linux、RTOS 都行M 模式跑固件、Bootloader、OpenSBI 这类最底层的代码。芯片上电复位后硬件会自动进入 M 模式因为此时还没有任何“更底层”的代码能帮系统完成初始化。1.2 不同模式到底差在哪抛开抽象概念特权级的差异主要体现在三个维度上。第一是 CSR 访问权限。M 模式能访问绝大多数 CSRS 模式只能访问 S 模式以及更低级别的 CSRU 模式能访问的 CSR 少得可怜基本就是几个计数器。低特权模式访问高特权 CSR硬件会直接抛出一个非法指令异常Illegal Instruction。这条规则是硬件强制执行的不是靠软件约定这也是 RISC-V 特权安全能立得住的根基。第二是特权指令的执行权利。比如 SFENCE.VMA 指令用于刷新地址转换缓存它只能在 S 模式或 M 模式下执行WFI 这类指令则在不同模式下有不同的等待行为。像 mret、sret 这样的异常返回指令更是直接与特权模式强绑定。第三是物理地址访问控制。S 模式开启分页后通过 satp 寄存器指向页表M 模式通常忽略地址翻译直接以物理地址访问。U 模式下如果没开虚实地址转换用户程序可以随便访问物理内存那隔离就无从谈起——所以实际系统中只要跑 LinuxS 模式一定会把 satp 配置好。1.3 模式切换的三种入口模式与模式之间不存在“想切就切”的快捷方式。RISC-V 定义了 trap异常与中断的统称、ecall 指令、mret/sret 指令这几条通道。异常Exception比如非法指令、访问非对齐地址、页故障。trap 发生时芯片会跳转到更高特权模式的异常入口。中断Interrupt比如定时器中断、外部中断。中断发生后同样由当前硬件上下文决定陷入哪个特权模式。ecall 环境调用U 模式执行ecall会陷入 S 模式如果委托配置正确或 M 模式S 模式执行ecall会陷入 M 模式。这相当于应用程序主动“敲门”请操作系统办事。mret 与 sret分别从 M 模式和 S 模式的 trap 处理程序中返回到原来的特权模式。理解了入口还不够你最终还是要落到“谁记录了我从哪来、要去哪”这个状态上。这就是 CSR 里一堆状态位存在的意义。2. CSR 速查寄存器一大堆先抓住这张核心表2.1 先搞懂 CSR 地址空间CSRControl and Status Register是 CPU 内部的“控制与状态寄存器集合”它们不在普通内存地址空间里而是拥有独立编码。硬件通过专用的 CSR 指令来访问而不是 load/store 指令。每个 CSR 有一个 12 位的地址所以最多可以定义 4096 个。更关键的是这 12 位地址的分布不是随机的它和特权模式强相关地址范围对应的特权层典型用途0xC00 - 0xC1FU 模式用户态可读的计数器和定时器0x000 - 0x0FFU 模式特定实现用户自定义或扩展功能0x100 - 0x1FFS 模式操作系统内核相关 CSR0x200 - 0x2FFH 模式虚拟化扩展Hypervisor 相关 CSR0x300 - 0x3FFM 模式机器模式核心 CSR0xB00 - 0xBFFM 模式调试/触发器硬件调试相关0xF00 - 0xFFFM 模式性能监控计数器与事件选择用一个简单的判断方法你看到一个 CSR 地址看最高两位十六进制数就能猜出它属于哪层。比如0x300开头一定是 M 模式的0x100开头一定是 S 模式的。这样设计的好处是硬件在做权限检查时非常快——低特权模式下访问高地址段的 CSR直接判非法。2.2 机器模式M 模式常用寄存器这是 RISC-V 世界里最核心的一组寄存器裸机开发和 Bootloader 天天都要碰。地址名称一句话作用0x300mstatus机器模式状态全局中断开关、进入 trap 前的模式记录、内存特权等0x301misa描述 CPU 支持的 ISA 扩展按位表示是否支持 M/A/F/D/C/V 等0x302medeleg把一部分同步异常委托给 S 模式处理0x303mideleg把一部分中断委托给 S 模式处理0x304mie机器模式中断使能MSIE、MTIE、MEIE 分别对应软件/定时器/外部中断0x305mtvec机器模式下 trap 入口地址低 2 位表示直接模式还是向量模式0x306mcounteren控制 S/U 模式能否读取 cycle/time/instret 等计数器0x340mscratch机器模式暂存器典型用法是保存 trap 处理时用的上下文指针0x341mepc记录 trap 发生时被打断的指令地址0x342mcause记录 trap 原因最高位 1 为中断低 12 位为具体编号0x343mtvaltrap 附加信息比如非法指令的编码、出错的内存地址0x344mip机器模式挂起中断状态0x320mcountinhibit暂停某个计数器计数要注意一个特别容易混淆的点定时器比较寄存器mtime和mtimecmp并不是 CSR它们通常被映射在内存空间一般通过 load/store 访问。很多新手以为写 mtvec 就能控制定时器结果定时器中断永远不来就是卡在这里。2.3 监管者模式S 模式常用寄存器S 模式的 CSR 可以理解为 M 模式 CSR 的“精简视图”。它把和操作系统内核最相关的功能单独拎了出来。地址名称一句话作用0x100sstatusS 模式状态mstatus 的一个子集视图0x104sieS 模式中断使能0x105stvecS 模式下 trap 入口地址0x106scounterenS 模式控制 U 模式访问计数器0x140sscratchS 模式暂存器0x141sepcS 模式下 trap 返回地址0x142scauseS 模式下 trap 原因0x143stvalS 模式下 trap 附加信息0x144sipS 模式挂起中断状态0x180satp地址转换与保护页表根地址、ASID、地址翻译模式其中satp是 Linux 内核切换进程地址空间时一定会写的寄存器。你修改了页表之后还需要执行SFENCE.VMA指令让硬件刷新 TLB否则新映射不生效。还有几个 U 模式可见的性能计数器值得记住cycle0xC00、time0xC01、instret0xC02。在 RV32 上还有对应的cycleh、timeh、instreth高 32 位寄存器。程序在 U 模式下默认不一定能读它们必须由高特权模式在mcounteren/scounteren中把对应位置 1 放行。2.4 CSR 读写指令速览RISC-V 提供了六种基础 CSR 操作指令理解它们的关键在于“原子性”硬件保证读和写之间不会被中断打断。指令语义csrrw rd, csr, rs1原子地读出旧值到 rd再把 rs1 写入 CSRcsrrs rd, csr, rs1读出旧值到 rd再把 rs1 与 CSR 原值做按位或后写回csrrc rd, csr, rs1读出旧值到 rd再把 rs1 与 CSR 原值做按位取反后与写回csrrwi rd, csr, immcsrrw 的立即数版本csrrsi rd, csr, immcsrrs 的立即数版本csrrci rd, csr, immcsrrc 的立即数版本在汇编中常见的csrr t0, mstatus其实是csrrs t0, mstatus, x0的伪指令因为用 x0 做源寄存器时不会修改 CSR。类似地csrw mtvec, t0是csrrw x0, mtvec, t0。这里有个实操技巧你想单独打开 mstatus 里的 MIE 位不要用“读-改-写”三步。直接csrsi mstatus, 0x8或者csrs mstatus, 0x8硬件一条指令搞定既省指令又避免中间窗口被中断打断。3. 模式切换的本质一场 trap 的完整旅程与中断委托3.1 从异常发生到处理函数执行我们用一个最常见的例子M 模式下发生了一次定时器中断。硬件按以下顺序自动完成状态记录把当前特权模式和中断使能状态保存进 mstatus。具体来说当前特权模式这里是 M会被记录到 mstatus.MPP 字段当前 MIE 值会被记录到 MPIE随后 MIE 被清零——防止 trap 处理过程中再次被中断打扰。把被打断的指令地址写入 mepc。把中断/异常原因写入 mcause附加信息写入 mtval。把 PC 跳转到 mtvec 指向的地址开始执行 trap handler。handler 通过 mepc 知道该回哪通过 mcause 知道发生了什么通过 mstatus.MPP 知道自己应该恢复成哪个模式。执行 mret 指令时硬件把 mepc 恢复到 PC把 mstatus.MPP 恢复到当前特权模式把 MPIE 恢复到 MIE然后清掉 MPP通常设为 U完成返回。这套流程在手册里写得干巴巴的但你只要走一遍实际代码就会明白所谓“切换到更高特权模式”本质不是软件随便跳转而是硬件把旧的上下文锁进 CSR再跳到一个由高特权模式预设好的入口。3.2 mstatus 里那几位到底怎么用很多人卡在 mstatus 上就是因为不知道 MPP/MPIE/MPRV 是在什么时候被改的。我做一个速查位段含义MIEbit 3M 模式全局中断使能。为 0 时所有 M 模式中断都不响应MPIEbit 7记录进入 trap 前 MIE 的值MPPbit 12:11记录进入 trap 前的特权模式00 表示 U01 表示 S11 表示 MMPRVbit 17内存特权修改位。置 1 时M 模式下 load/store 的内存权限按 MPP 指定模式解释MPRV 是很多人忽略的细节。它的典型用途是M 模式代码想访问一个“按 S 模式权限规则”应该被页表拦下的地址可以通过 MPRV 临时模拟 S 模式访存而不需要真正切到 S 模式。OpenSBI 在做一些地址翻译模拟时会用到它。3.3 中断委托为什么不让所有 trap 都进 M 模式如果系统里既有 M 模式固件又有 S 模式操作系统那么每次系统调用都先陷入 M 模式再转发到 S 模式效率非常低。RISC-V 的答案是在硬件层面增加“中断委托”机制——通过medeleg和mideleg两个寄存器把一部分 trap 直接路由到 S 模式。委托的原理很简单medeleg的每一位对应一个异常编号mideleg的每一位对应一类中断。你在 M 模式把对应位置 1之后 S 模式下发生的这类 trap 就直接进入 S 模式的stvecM 模式完全看不到。典型做法是把“来自 U 模式的 ecall”和“指令页故障/数据页故障”委托给 S 模式这样 Linux 内核处理系统调用和缺页异常时不用每次去打扰 M 模式固件。中断委托还有一个漂亮的联动效果被委托的中断可以在 S 模式下用sie/sip单独开关和查询M 模式的mie/mip完全不管。这等于硬件给每个特权层都配了一套独立的中断视图。3.4 mret 与 sret 背后的状态机搞懂了 trap 流程再看 mret/sret 就简单了。mret会从 M 模式回到 MPP 记录的模式sret会从 S 模式回到 SPPsstatus 中的对应字段记录的模式。如果你在 M 模式下强行执行两次 mret第二次会因为 MPP 已被清零而回到 U 模式——这就是很多裸机程序莫名其妙从 S 模式“掉”到 U 模式的元凶。更隐蔽的一个坑是如果你想从 M 模式直接切到 S 模式又不经过任何 trap正确的做法是先手动设置 mstatus.MPP 01然后执行 mret。因为 mret 是按 MPP 恢复特权级的它不只是“从 handler 返回”还是一种合法的显式模式切换手段。很多嵌入式引导代码就是用这一招从 M 模式跳到 S 模式再启动 Linux。4. 实操演示在 QEMU 上把 M/S/U 三个模式玩明白4.1 环境准备与最小启动代码我这里的实验环境是 QEMU virt 平台RV64 配置交叉编译器用riscv64-unknown-elf-gcc。先用一个最小启动文件跑起来.section .text.init .globl _start _start: la sp, _stack_top call main 1: wfi j 1b .section .text .globl trap_entry trap_entry: csrr t0, mcause csrr t1, mepc # 简单处理死循环打印现场 j trap_entry链接脚本里把入口地址放在0x80000000栈顶放在入口后面 2MB 的位置。这一步不用写太复杂关键是先把 trap 入口地址设置到 mtvec#include stdint.h #define CSR_MSTATUS 0x300 #define CSR_MTVEC 0x305 static inline uint64_t read_csr(uint64_t csr) { uint64_t val; asm volatile(csrr %0, %1 : r(val) : i(csr)); return val; } static inline void write_csr(uint64_t csr, uint64_t val) { asm volatile(csrw %1, %0 : : r(val), i(csr)); } extern void trap_entry(void); void main(void) { write_csr(CSR_MTVEC, (uint64_t)trap_entry); // 故意触发一个异常 asm volatile(unimp); while (1); }这段代码跑起来后QEMU 会因为unimp指令触发非法指令异常跳到trap_entry。你用调试器看mcause的值会发现是 2Illegal Instructionmtval里就是那条非法指令的编码。这就完成了一个最原始的 trap 链路验证。4.2 通过 mret 实现 M 模式到 S 模式的切换接下来我们做正经的模式切换。在 main 里把 mstatus.MPP 改为 S 模式再执行 mretvoid switch_to_s_mode(void) { uint64_t mstatus read_csr(CSR_MSTATUS); // 清零 MPP 两位然后设为 01S 模式 mstatus ~(3UL 11); mstatus | (1UL 11); // 关掉 M 模式全局中断防止切换过程中被干扰 mstatus ~(1UL 3); write_csr(CSR_MSTATUS, mstatus); // 设置 S 模式的 trap 入口 write_csr(0x105, (uint64_t)trap_entry_s); // 执行 mret跳转到下一个地址但特权级变为 S asm volatile(mret); }执行完 mret 后PC 会继续执行 mret 后面的那条指令但当前模式已经从 M 变成了 S。怎么验证你在 S 模式下读一下satp如果能正常读说明确实是 S 模式——因为 U 模式读 satp 会触发异常。读出来的值应该为 0因为我们还没开分页。4.3 在 S 模式下触发异常并委托给 S 模式处理先用代码验证“U 模式读 satp 会非法”。我们从 S 模式切到 U 模式的方式是修改 sstatus.SPP 后执行 sretvoid switch_to_u_mode(void) { uint64_t sstatus read_csr(0x100); sstatus ~(1UL 8); // SPP 0表示返回 U 模式 write_csr(0x100, sstatus); asm volatile(sret); }进入 U 模式后尝试读 satpvoid user_code(void) { uint64_t val; asm volatile(csrr %0, 0x180 : r(val)); (void)val; }这条指令在 U 模式下必然触发非法指令异常。如果中断委托没有配置异常会直接进 M 模式的trap_entry你在 M 模式 handler 里读 mcause 就能看到异常编码 2mtval 是 0x180。这说明权限检查是硬件层做的软件没有半点商量余地。如果想验证中断委托在 M 模式 main 函数里设置medeleg把“来自 U 模式的 ecall”委托给 S 模式// mcause 8 对应 U 模式 ecall把第 8 位置 1 委托给 S 模式 write_csr(0x302, read_csr(0x302) | (1UL 8));之后 U 模式执行ecall异常会直接跳到 S 模式的trap_entry_sM 模式完全不知情。这种委托机制在跑 Linux 的系统上就是系统调用的核心通道——应用程序执行 ecall内核在 S 模式拿到控制权。4.4 开启定时器中断所需的完整配置链定时器中断是很多裸机开发者的第一道坎我把完整流程贴出来。这里的 mtime 和 mtimecmp 是 MMIO 映射寄存器以 QEMU virt 平台为例mtime 地址是0x200BFF8mtimecmp 是0x2004000。#define MTIME (*(volatile uint64_t *)0x200BFF8) #define MTIMECMP (*(volatile uint64_t *)0x2004000) void enable_timer_interrupt(void) { // 设置 mtimecmp 为当前时间 100000 MTIMECMP MTIME 100000; // 打开 M 模式定时器中断使能位MTIE 对应 bit 7 write_csr(0x304, read_csr(0x304) | (1UL 7)); // 打开 M 模式全局中断使能 uint64_t mstatus read_csr(0x300); mstatus | (1UL 3); write_csr(0x300, mstatus); }中断触发后进入 trap_entrymcause 的最高位为 1表示中断低 12 位为 7Machine Timer Interrupt。处理完业务后要通过写 mtimecmp 清除中断条件否则退出 handler 后会再次触发。正确的清中断方式是让 mtimecmp 大于当前 mtimevoid timer_handler(void) { MTIMECMP MTIME 100000; }这段链路看似简单但每个环节都可能出问题。接下来我把实际调试中遇到的坑集中列出来。5. 实操中的坑与排查技巧实录5.1 mtvec 没对齐导致 trap 入口飞了RISC-V 规范要求 mtvec 四字节对齐向量模式下还要十六字节对齐。很多人用 C 函数地址直接赋给 mtvec结果函数入口没对齐一旦触发中断PC 跳到一个非指令边界立刻又触发异常形成死循环。解决方案是在汇编里显式对齐.section .text .align 4 .globl trap_entry trap_entry: csrr t0, mcause ...写 mtvec 之前先用csrr t0, mtvec检查写进去的值是否和预期一致。如果芯片实现了向量模式mtvec 低 2 位为 1则每个中断会跳到BASE 4 * code你必须保证每个 4 字节槽都放了一条跳转指令或 handler 地址否则同样会跑飞。5.2 mret 后死机MPP 设错是最大元凶我见过很多初学者在 M 模式下执行 mret 想“返回”到 U 模式结果死机或者掉进非法指令异常。原因很简单mret 的去向由 mstatus.MPP 决定如果之前没经过任何 trapMPP 默认可能是 0U 模式或者随机值你把 MIE 和 MPP 没配对自然出问题。正确做法是在 mret 之前手动组装好完整的 mstatus而不是只改其中一位。特别是从 M 切 S 时MPP01、MPIE0、MIE0 是推荐组合因为切换过程中你不想被打断进入 S 模式后S 模式自己的 sstatus.SIE 再单独打开。5.3 U 模式访问 satp 触发异常反而说明系统是安全的很多人在用户态程序里不小心访问了特权 CSR然后发现程序被杀掉第一反应是“CPU 是不是坏了”。实际上这正是特权架构工作的表现。调试时你可以故意触发一次然后在 M 模式 handler 里读 mcause 和 mtval确认异常原因和 CSR 地址都对得上。这其实也是一条有用的检测手段如果你在 S 模式下能正常读 satp但切换到 U 模式后一读就非法说明权限边界是生效的。如果你想实现“用户态能不能访问某个资源”的自检完全可以在 U 模式故意访问再让高特权模式把这个事件作为测试结果记录下来。5.4 定时器中断不触发按这个顺序排查定时器中断不触发是高频问题我整理了一套排查顺序建议按下面八步走排查点检查方法mtime 是否在递增连续读两次比较值看有没有变化mtimecmp 是否设为未来时间必须大于当前 mtime否则中断条件立刻成立mie.MTIE 是否置 1csrr t0, mie看 bit 7mstatus.MIE 是否置 1csrr t0, mstatus看 bit 3mstatus.MPP 是否正确trap 返回后可能会改变确认没有被意外改掉mtvec 是否指向有效 handler在 handler 第一行打断点看是否进入mip.MTIP 是否置 1qemu 下用-d int可以看到中断挂起状态handler 是否清除了 mtimecmp如果不清楚返回后立刻再次触发表现为看似死循环其中-d int是 QEMU 调试中断的利器能在日志里看到每次中断进入和返回的详细过程。硬件开发板上则用逻辑分析仪抓中断引脚或者直接在 handler 里翻转一个 GPIO 观察电平。5.5 链接脚本与中断向量表的地址错位裸机程序最常见的问题是中断向量表定义在一个段里但链接脚本没有把它放到实际运行的地址上。比如 QEMU virt 平台固件运行在0x80000000你却把向量表放在0x0然后往 mtvec 里写了一个低地址。PC 一跳过去就是空指针。正确做法是先用la指令取运行时地址la t0, trap_entry csrw mtvec, t0不要直接用汇编 label 或者 C 函数名当作绝对地址除非你确定所有段都按运行地址链接。5.6 委托之后M 模式的 mie 变得“不灵了”把定时器中断委托给 S 模式后如果还按 M 模式思维去开mie.MTIE会发现中断还是不触发。这是因为委托之后对应中断的使能已经由 S 模式的sie.STIE控制M 模式的mie对应位虽然存在但不再参与门控。正确配置是在 M 模式完成mideleg委托然后切到 S 模式打开sstatus.SIE和sie.STIE。如果遇到中断不触发优先检查这两个 S 模式位而不是回到 M 模式折腾。5.7 完整调试序列我的个人习惯最后分享一个我自己的调试顺序算是多年踩坑换来的习惯。遇到特权架构相关的问题我从不直接猜而是按“现场还原”的思路走第一步确认当前特权模式。读 mstatus.MPP或者干脆读一个只有某模式能访问的 CSR看到底是权限问题还是模式判断错。第二步确认 CSR 写入是否生效。csrw 指令写只读字段会被静默忽略不报错。写完后马上读回来对比值是否等于预期。如果不等检查字段是否 WPRIWrite Preserve, Read Ignore或者是否被硬件自动改写。第三步确认 trap 是否真的进入了 handler。在 handler 第一行放一条死循环或者打印代码如果没进来用调试器查 mtvec、mie、mstatus如果进来了但不是预期 handler回头看 mcause 和 mtval确认异常编号。第四步确认返回是否正常。单步执行 mret观察 PC 是否跳到 mepc当前特权模式是否等于 MPP。这一步能抓出绝大多数“跑飞”问题。这套流程我用了很多年在 QEMU 上和真实开发板上同样有效。特权架构的东西看着复杂但只要你把 CSR 当成“现场记录本”而不是“黑盒配置项”问题就都会变成查字典式的体力活。RISC-V 最可爱的点也在这里它没有把机制藏起来不让看而是把所有状态都摆在你面前让你有机会真正搞懂每一步。
返回列表