ARTICLE DETAIL

资讯详情

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

RISC-V CSR与M/S/U特权架构实战解析:从异常处理到系统调用

RISC-V CSR与M/S/U特权架构实战解析:从异常处理到系统调用 1. 从一条热搜说起CSR 和邻接表到底是不是一回事前阵子在一个技术群里看到有人问“risc-v 里的 CSR和数据结构里那个 CSR 压缩存储内存空间消耗是不是一个量级”底下还真有人认真讨论了半天最后才发现是两码事——一个是 RISC-V 的特权架构寄存器一个是稀疏矩阵的压缩格式只是缩写撞了名。这个误会其实挺典型因为 RISC-V 的 CSR 在入门阶段确实容易被一笔带过很多人写了不少裸机代码对mstatus、mepc、mcause这些寄存器的理解还停留在“抄例程”的层面。这篇就专门把 RISC-V 的 CSR 和 M/S/U 三级特权架构掰开揉碎讲一遍。我会从“为什么需要特权级”这个最根本的问题出发把 CSR 的地址编码规则、读写指令、M/S/U 三级的职责划分、异常与中断的硬件跳转流程以及实际写启动代码和 trap handler 时最容易踩的坑全部串起来。内容偏向实战适合已经能跑通 RISC-V 裸机点灯、想进一步理解特权架构和异常处理的开发者也适合正在看手册但被一堆 CSR 名字绕晕的朋友。看完你至少能做到拿到一份 RISC-V 手册知道该翻哪一章、该查哪几个寄存器、trap 进来之后该按什么顺序读状态。2. 特权架构到底解决了什么问题2.1 没有特权级的世界有多乱先想一个最朴素的问题如果一颗 CPU 只有一种运行模式所有代码都能执行所有指令、访问所有内存和所有硬件寄存器会发生什么答案很简单——一个写错指针的应用程序就能把操作系统内核的数据覆盖掉一个恶意程序可以直接关掉中断、改掉定时器、甚至把自己提权成“内核”。早期一些简单的单片机确实是这样程序跑飞了只能靠看门狗复位谈不上任何隔离。RISC-V 的设计者显然不满足于此。他们引入特权级Privilege Level的核心目的就两个隔离和受控入口。隔离是指低特权级代码不能随意访问高特权级的资源受控入口是指低特权级想获得高特权级的服务必须通过一条明确的、硬件规定的路径跳进去而不是随便跳。这条路径就是异常/中断机制而承载这条路径状态信息的就是 CSR。2.2 M/S/U 三级的分工RISC-V 定义了三个特权级从高到低是 MachineM、SupervisorS、UserU。名字听着抽象其实对应关系很清晰M 模式最高权限固件、BootROM、安全监控代码住在这里。M 模式可以访问所有 CSR可以配置物理内存保护PMP是芯片上电后第一个进入的模式。S 模式操作系统内核住在这里。Linux、RT-Thread 这类带 MMU 或 MPU 的系统内核态通常跑在 S 模式通过sstatus、stvec、sepc这些 S 级 CSR 管理异常。U 模式普通应用程序住在这里。权限最低很多 CSR 直接不可访问访问了会触发非法指令异常。这里有个关键点很多人一开始会搞混不是所有 RISC-V 芯片都实现了三级。一颗只有 M 模式的单片机比如很多低端 MCU是完全合法的它只需要实现 M 级 CSR而一颗能跑 Linux 的应用处理器通常 M/S/U 三级齐全。所以看手册第一件事是确认它实现了哪几级这决定了你能用哪些 CSR、异常会往哪跳。2.3 特权级切换的本质是“换一套寄存器视角”我习惯把特权级切换理解成“换一套寄存器视角”。同一份代码在 M 模式看到的mstatus和在 S 模式看到的sstatus字段布局高度相似但作用域不同同一段地址在 U 模式可能因为 PMP 或页表配置而根本访问不了。切换发生时硬件会自动做几件事保存当前 PC 到xepc、保存原因到xcause、更新xstatus里的状态位、然后跳到xtvec指定的入口。这套“自动保存 跳转”的机制就是整个特权架构的骨架。理解了这一点后面看 CSR 就不会觉得是一堆孤立的寄存器而是一套围绕“异常入口”组织起来的状态机。3. CSR 速查地址编码、读写指令与权限3.1 CSR 的 12 位地址不是随便编的CSR 全称 Control and Status Register控制状态寄存器。它和通用寄存器x0-x31最大的区别是通用寄存器用 5 位编号CSR 用12 位地址所以理论上最多 4096 个。这 12 位不是随便分配的高 4 位编码了“这个寄存器属于哪个特权级、是否只读”低 8 位才是具体编号。看下面这张表就明白了地址高位 [11:10]含义典型例子00非特权级U 模式可访问cycle、time、instret01S 级S/U 模式可访问受sstatus控制sstatus、stvec、sepc10保留—11M 级仅 M 模式可访问mstatus、mtvec、mepc而地址位 [9:8] 表示读写权限00是读写01是读写但 bit[11:10] 必须为 11即 M 级读写10是只读11是只读且 bit[11:10] 必须为 11。这个编码规则非常实用——你拿到一个 CSR 地址不用查表就能大致判断它属于哪一级、能不能写。比如0x300是mstatus0x100是sstatus0xC00是cycle只读一眼就能看出层级。注意地址编码只是“约定”具体某个 CSR 是否实现、字段怎么定义仍然要以具体芯片手册为准。有些 CSR 在标准里是“可选实现”手册里没写就是没有别硬写。3.2 六条 CSR 读写指令RISC-V 专门为 CSR 设计了六条指令分两类读写和置位/清位。它们都是原子操作这点在多核或中断场景下很重要。csrrw rd, csr, rs1 # 读旧值到 rd同时把 rs1 写入 csr csrrs rd, csr, rs1 # 读旧值到 rd同时把 rs1 的置位写进 csr csrrc rd, csr, rs1 # 读旧值到 rd同时把 rs1 的清位写进 csr csrrwi rd, csr, imm # 立即数版本imm 是 5 位无符号 csrrsi rd, csr, imm csrrci rd, csr, imm用 C 语言写的时候编译器提供了 intrinsic 或者内联汇编封装。以 GCC 为例标准头文件里通常有// 读 CSR #define read_csr(reg) ({ unsigned long __tmp; \ asm volatile (csrr %0, #reg : r(__tmp)); __tmp; }) // 写 CSR #define write_csr(reg, val) ({ \ asm volatile (csrw #reg , %0 :: rK(val)); }) // 置位 #define set_csr(reg, bit) ({ unsigned long __tmp; \ asm volatile (csrrs %0, #reg , %1 : r(__tmp) : rK(bit)); __tmp; }) // 清位 #define clear_csr(reg, bit) ({ unsigned long __tmp; \ asm volatile (csrrc %0, #reg , %1 : r(__tmp) : rK(bit)); __tmp; })这里有个细节值得说csrrs和csrrc的 rs1 如果是 x0就变成“只读不写”等价于csrr反过来 rd 是 x0 就变成“只写不读”。编译器经常利用这个特性生成更紧凑的代码。另外csrrwi这类立即数版本imm 只有 5 位所以只能操作低 5 位写高位字段还是得用寄存器版本。3.3 常用 CSR 速查表下面这张表是我自己整理的高频 CSR按 M/S/U 分组实际调试时对着查非常省事CSR地址级别作用mstatus0x300M全局状态中断使能、特权级、FS/VS 等misa0x301M指令集架构信息只读mie0x304M中断使能位mtvec0x305MM 模式 trap 入口地址mscratch0x340M临时寄存器trap 时保存上下文指针mepc0x341M异常返回地址mcause0x342M异常/中断原因mtval0x343M出错地址或指令mip0x344M中断挂起位sstatus0x100SS 级全局状态sie0x104SS 级中断使能stvec0x105SS 模式 trap 入口sscratch0x140SS 级临时寄存器sepc0x141SS 级异常返回地址scause0x142SS 级异常原因stval0x143SS 级出错信息sip0x144SS 级中断挂起satp0x180S页表基址控制地址翻译cycle0xC00U周期计数只读time0xC01U实时计数只读instret0xC02U已执行指令数只读提示mstatus和sstatus字段布局几乎一样但sstatus是mstatus的一个“视图”M 模式改mstatus会影响sstatus的可见部分。调试时如果发现 S 级状态不对先回头查 M 级是不是把它屏蔽了。4. M/S/U 三级特权架构的实战拆解4.1 M 模式上电后的第一站芯片上电复位后PC 指向复位向量此时一定处于 M 模式。M 模式代码通常做这几件事初始化时钟和内存、配置 PMP、设置mtvec、准备好 S 模式或 U 模式的运行环境最后用mret跳过去。这里的关键 CSR 是mstatus里的 MPP 字段bit[12:11]它决定mret之后进入哪个特权级。// 准备跳转到 S 模式 unsigned long mstatus read_csr(mstatus); mstatus ~(3UL 11); // 清 MPP mstatus | (1UL 11); // MPP 01表示 S 模式 write_csr(mstatus, mstatus); write_csr(mepc, s_mode_entry); // 设置返回地址 asm volatile (mret); // 跳转这段代码看着简单但有两个坑。第一mret之前必须确保mepc指向的地址是合法的、可执行的否则直接跑飞。第二如果目标模式是 S 或 U还要提前配置好 PMP否则跳过去第一条取指就可能触发访问异常。我见过有人忘了配 PMP结果mret之后直接进 trap查了半天以为是mepc写错了。4.2 S 模式操作系统内核的主场S 模式是给操作系统内核用的。它能看到sstatus、stvec、sepc这一套 S 级 CSR也能通过satp配置页表开启虚拟地址翻译。S 模式最核心的机制是S 级异常委托M 模式可以通过mideleg和medeleg两个 CSR把一部分中断和异常“委托”给 S 模式处理这样 S 模式内核就不用每次都陷回 M 模式性能更好。// M 模式代码把 S 级定时器中断和部分异常委托给 S 模式 set_csr(mideleg, (1UL 5)); // 委托 S 级定时器中断 set_csr(medeleg, (1UL 8)); // 委托 U 模式 ecall委托之后S 模式收到中断时硬件会直接跳到stvecscause里记录原因sepc记录返回地址。这套机制让 M 模式可以退居幕后只处理真正需要最高权限的事情比如 PMP 配置和平台级中断。4.3 U 模式应用代码的沙箱U 模式权限最低很多 CSR 直接不可访问。它想请求内核服务只能通过ecall指令主动触发异常陷入 S 模式。ecall从 U 模式发出时mcause或scause的值是 8如果委托给 S 就是scause8从 S 模式发出时是 9。这个区别在写系统调用入口时非常重要因为你要根据来源决定怎么处理。U 模式还有一个容易被忽略的点它能不能访问cycle、time这些非特权 CSR取决于mcounteren和scounteren的配置。默认情况下这些计数器在 U 模式是关闭的访问会触发非法指令异常。如果你在 U 模式跑性能测试代码发现读cycle就崩八成是忘了在 M 模式打开mcounteren。4.4 三级切换的完整路径把三级串起来看一次典型的系统调用路径是这样的U 模式应用执行ecall。硬件检查medeleg如果 bit 8 被置位异常委托给 S 模式。硬件保存 PC 到sepc原因 8 写入scause跳到stvec。S 模式内核保存上下文根据scause分发系统调用。处理完毕执行sret硬件从sepc恢复 PC回到 U 模式。如果异常没有被委托第 2 步就会走 M 模式跳到mtvec用mepc/mcause。理解这条路径调试 trap 问题时就能快速判断“到底该看哪一级的 CSR”。5. 异常与中断trap 机制的硬件细节5.1 trap 发生时硬件自动做了什么很多人写 trap handler 时习惯性地“先保存所有寄存器”但其实硬件已经帮你做了一部分。trap 发生时硬件自动完成把当前 PC 写入xepcx 是 m 或 s。把异常原因写入xcause最高位区分中断1和异常0。把出错地址或指令写入xtval部分异常才有意义。更新xstatus里的状态位比如把当前特权级存入 MPP/SPP。关闭中断使能mstatus.MIE或sstatus.SIE清零。跳到xtvec指定的入口。所以 handler 里第一件事不是保存 PC而是保存通用寄存器和必要的临时状态。xepc、xcause这些硬件已经存好了你直接读就行。5.2 mtvec/stvec 的两种模式mtvec和stvec的低两位决定入口模式Direct 模式低两位 00所有 trap 都跳到BASE地址。Vectored 模式低两位 01中断按编号跳到BASE 4 * cause异常仍然跳到BASE。Vectored 模式的好处是中断响应快不用在 handler 里再判断一次原因。但它要求BASE对齐到 4 字节边界而且中断编号不能太大否则会跳到非法地址。实际项目里 Direct 模式更常见因为灵活、好调试。// 设置 mtvec 为 Direct 模式 write_csr(mtvec, (unsigned long)trap_entry ~0x3UL);注意mtvec的 BASE 字段有对齐要求具体对齐位数看手册。有些实现要求 4 字节对齐有些要求更严格。写之前一定确认否则低两位会被硬件忽略或触发异常。5.3 mcause/scause 编码速查mcause和scause的编码规则一样最高位是 1 表示中断0 表示异常低位是编号。下面这张表是高频原因码编号类型含义0异常指令地址非对齐1异常指令访问错误2异常非法指令3异常断点4异常加载地址非对齐5异常加载访问错误6异常存储地址非对齐7异常存储访问错误8异常U 模式 ecall9异常S 模式 ecall11异常M 模式 ecall3中断M 软件中断7中断M 定时器中断11中断M 外部中断5中断S 定时器中断9中断S 外部中断写 handler 时先读xcause判断最高位再按编号分发。这里有个经验不要用 switch-case 硬编码所有编号因为不同芯片可能实现不同的中断源。更好的做法是只处理你关心的几个其余统一记录并返回避免漏掉未知中断导致系统卡死。5.4 上下文保存与恢复的实操trap handler 的上下文保存是性能敏感区。最朴素的做法是把 x1-x31 全部压栈但这样开销大。实际项目里通常分两级第一级只保存 caller-saved 寄存器x1、x5-x7、x10-x17、x28-x31因为被中断的函数本来就不指望这些寄存器跨调用保持如果 handler 里要调用 C 函数再保存 callee-saved 寄存器。trap_entry: addi sp, sp, -128 sd x1, 0(sp) sd x5, 8(sp) sd x6, 16(sp) sd x7, 24(sp) sd x10, 32(sp) # ... 保存其余 caller-saved csrr a0, mcause csrr a1, mepc call trap_handler # ... 恢复寄存器 addi sp, sp, 128 mret这里有个细节mepc在返回前可能需要 4因为ecall这类异常发生时mepc指向的是ecall指令本身如果不加 4 就会无限循环。但中断发生时mepc指向的是下一条要执行的指令不能加。所以 handler 里要根据mcause判断是否调整mepc。6. 常见问题与排查技巧实录6.1 trap 进去出不来先查这五个地方trap 死循环是新手最常见的坑。我整理了一个排查顺序基本能覆盖 90% 的情况排查项检查方法常见错误xtvec是否设置读回mtvec/stvec忘了设或地址没对齐xepc是否正确读mepc/sepc返回前没 4或指向非法地址栈指针是否有效看sp是否在合法 RAM 范围栈溢出或未初始化中断使能是否合理查mstatus.MIE、miehandler 里没关中断导致嵌套PMP 是否放行查pmpcfg/pmpaddr跳转目标被 PMP 拦住我自己的习惯是trap handler 第一行先把mcause、mepc、mtval打印出来如果串口可用这三个值基本能定位大部分问题。mcause告诉你发生了什么mepc告诉你从哪来mtval告诉你出错地址。6.2 CSR 写入不生效可能是权限或只读位有时候你明明写了write_csr(mstatus, val)读回来却不一样。原因通常有三个第一某些位是只读的WARL 或 WLRL 字段硬件会忽略你的写入第二当前特权级不够写 M 级 CSR 时如果不在 M 模式会触发非法指令异常第三字段之间有联动比如改 MPP 可能受其他位约束。遇到这种情况先查手册确认字段的读写属性再用csrrs/csrrc只改你关心的位避免误伤其他字段。6.3 中断嵌套与优先级处理RISC-V 的中断优先级由硬件和平台定义标准本身不规定。实际写嵌套中断时要注意两点第一进入 handler 后硬件已经关了中断使能如果要支持嵌套得手动重新打开但要先保存好上下文第二mip/sip里的挂起位是只读的你只能通过清除中断源来让它消失不能直接写。我见过有人试图写mip清中断结果毫无效果就是因为这个位是只读的。6.4 从 M 模式跳 S 模式后串口不输出这个问题很典型。M 模式串口能输出mret跳到 S 模式后就没反应了。原因通常是 S 模式没有配置自己的 trap 入口或者 PMP 没放行串口寄存器地址。排查方法先在 S 模式入口点个灯确认代码在跑再检查stvec是否设置、PMP 是否覆盖串口地址范围。如果 S 模式还要开页表那还得确认satp配置正确否则取指都可能出错。6.5 独家避坑清单mstatus.MPRV别乱用这个位能让 M 模式用 MPP 指定的特权级做 load/store调试时很有用但忘了清会污染后续访问。mscratch是救命稻草trap 进来时通用寄存器可能全是脏的mscratch是唯一能安全拿到上下文指针的地方启动时一定初始化。委托异常前先确认 S 级能处理medeleg一开异常就归 S 管了如果 S 级 handler 还没准备好系统直接崩。计数器使能要逐级打开U 模式读cycle需要mcounteren和scounteren同时放行少一个都不行。fence和 CSR 的顺序改完satp或 PMP 后记得加fence或sfence.vma否则流水线可能用旧配置。7. 写在最后的一点个人体会CSR 和特权架构这块我自己的学习路径是“先跑通再深挖”。一开始不用把每个字段都背下来先把mtvec、mepc、mcause这三个用熟能写一个打印异常原因的 handler就已经超过很多人了。等真正要做系统隔离、跑多任务或者移植操作系统时再回头啃mstatus的字段布局和委托机制会顺畅很多。另外提醒一句不同芯片对 CSR 的实现差异比想象中大。标准里写“可选”的实际可能没有标准里写“必须”的不同版本也可能有细微差别。所以手册永远比记忆可靠遇到不确定的字段翻手册确认一遍比在网上搜半天强。
返回列表