ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RISC-V CSR与特权模式实战:从底层原理到中断调试

2026/10/7 14:44:56 拓冰建站 浏览量
RISC-V CSR与特权模式实战:从底层原理到中断调试 做RISC-V底层开发绕不开CSR和这套M/S/U特权架构。CSR全称是Control and Status Register控制状态寄存器它不像通用寄存器那样纯粹存数据而是管着处理器的工作模式、中断状态、异常原因、地址翻译、性能计数器这些东西。可以说CPU的灵魂都挂在CSR上。而M/S/U三个特权模式决定了软件到底能摸到哪些CSR、哪些指令以及怎么彼此隔离。搞懂这两块你就具备了看RISC-V系统软件、移植RTOS、调中断异常的最底层从这份实操笔记开始。这期专栏面向的是已经写过几行RISC-V汇编、知道怎么用qemu跑一个最小程序但还没系统整理过CSR和特权模式的读者。我会把地址布局、指令约束、模式切换、中断联动串起来讲再附上可以直接跑起来的代码和我在实际调试中踩过的坑。1. RISC-V特权架构总览M/S/U三种模式到底在管什么1.1 三种特权模式的分工与设计初衷RISC-V不像x86那样有Ring0到Ring3一堆等级它把特权等级精简成了三种Machine机器模式、Supervisor监督模式、User用户模式。数字上M是最高权限S次之U最低。理解这套东西可以拿公司门禁来类比M是物业管理员手里有整栋楼的钥匙能进任何房间、能改安保系统S是大楼里某层的主管能管自己这层的门禁但进不了电井房U是普通员工只能在开放工位活动想进机房必须申请临时授权。设计初衷非常直白安全隔离和功能分层。嵌入式裸机程序可以只跑M模式所有CSR随便访问省事但风险也大——一个野指针写错CSRCPU可能直接死给你看。跑Linux这类系统就需要S模式和U模式内核跑在S模式下应用跑在U模式里应用想干内核的事得通过ecall触发环境调用让S模式的内核来代理。这套机制保证了应用崩了不会把整个系统带崩也保证了用户程序无法直接篡改页表或中断配置。M模式是RISC-V世界里必须存在的模式硬件复位后默认进M模式。S模式是可选实现但只要有Linux或类Unix系统需求一般都会实现。U模式通常配合S模式出现用于跑普通程序。还有一种H模式用于虚拟化这里不做展开先把M/S/U吃透。仔细看指令集每个特权模式能使用的指令和CSR是逐层向下包含的。M能执行所有指令S能执行的指令少一些U最少。这个少不仅体现在CSR访问上还体现在一些特权指令上比如S模式下可以用SFENCE.VMA维护页表缓存U模式则完全用不了。每次执行时处理器会检查当前模式非法操作就触发异常异常原因会记录在mstatus和mcause里。1.2 模式切换的时机与代价模式切换不是你想切就切它必须通过特定机制进行。最常见的三个时机中断、异常、主动调用。中断和异常发生时硬件会根据配置自动把当前模式切换到能处理该事件的特权模式——物理中断一般由M模式或S模式的PLIC控制器分发指令异常非法指令、访问违例则陷入当前模式或更低权限无法处理时逐级上报。主动调用则是通过ecall指令把控制权交给更高特权级。这里要特别强调一个概念提升特权级和降低特权级的代价不对称。从U模式陷入S模式或M模式硬件会自动生效因为这是操作系统或固件在掌握全局但从S模式返回到U模式必须用SRET或MRET指令而且返回时硬件会从某个CSR恢复之前保存的特权级。这个返回不是简单跳转它还会同步恢复中断使能状态、修改特权级等。所以切换的本质是保存现场-修改状态-恢复现场的一个闭环。我自己在调一个双模式程序时一开始图省事想在用户代码里直接写CSR改变特权级结果毫无反应。后来看了特权规范才明白除非你处在M模式否则没法直接修改代表当前特权级的MPP位。正常路径就是通过ecall进入S或M在更高特权级的处理函数里改状态后再返回。路径不对C代码写得再花哨也没用硬件直接忽略你的企图。2. CSR速查地址映射、读写规则与分类体系2.1 CSR地址空间的布局逻辑RISC-V为CSR划了独立的12位地址空间也就是最多4096个CSR寄存器。这个12位地址本身包含了两个关键信息特权级和读写属性。最高两位[11:10]编码了该CSR所属的最低特权级00表示U模式可访问01表示S模式10表示保留给H模式11表示M模式。也就是说你只要看到地址的bit11:10是11就知道这个CSR只有M模式能碰。剩下还有第[9:8]位用于标记是否只读比如0b01表示只读其他值表示可读写。把这个布局讲透有个好处以后看到陌生CSR地址可以立刻判断自己是否有权限。比如地址0x300的mstatus11:10位是11只有M模式能访问地址0x100的sstatus11:10是01S模式就能访问。但是注意M模式当然也可以访问sstatus因为它权限更高。权限是向下兼容的M能访问所有S和U的CSRS能访问U的CSR反之不行。还有一点容易忽略CSR地址里bit[9:8]为只读位如mvendorid是0xF11低两位是01所以只读。访问只读CSR时写入会被忽略或触发非法指令异常具体取决于机器实现。我建议写代码时别去尝试写只读CSR因为不同模拟器和真片行为不一致靠这个验证行为容易产生误解。CSR地址空间中系统保留了一些区域比如0x7C0到0x7FF是自定义区设计者可以放自己的私有寄存器。这也是为什么很多厂商的CSR看起来乱因为标准没约束这部分。理解了布局逻辑读手册时就能快速过滤这个地址的11:10位是什么我的代码跑在什么模式能不能碰。2.2 CSR读写指令与权限约束CSR操作指令一共就那么几条CSRRW、CSRRS、CSRRC以及它们带立即数的变体CSRRWI、CSRRSI、CSRRCI。名字拆开看CSR Read Write / Read Set / Read Clear。比如CSRRW是读旧值并写入新值CSRRS是读旧值并设置指定位为1CSRRC是读旧值并清除指定位为1。后面加个I的是第二操作数用5位立即数而非寄存器。实际编码时每条指令里都有12位csr地址字段软件写死硬件根据这个地址做权限检查。为什么协议里要有Read Set、Read Clear这种操作因为很多CSR的字段需要在一个原子操作里被修改而不希望被人为拆成读-改-写。典型例子是mstatus中的MIE全局中断使能位你想把它置1且不破坏其他位用CSRRS把目标位写1即可。如果先读mstatus到寄存器再OR一个常数再写回中间一旦来了个中断读改写不是原子的就可能丢失中断状态。CSRRS/CSRRC本质上就是给这些读-修改-写场景提供硬件原子支持。特权约束方面CPU会检查当前特权级是否小于CSR地址要求的特权级如果小于访问就触发非法指令异常。比如U模式读mstatus指令会把cause设为Illegal Instruction然后陷入到能处理异常的更高特权级。要注意的是即使地址对应的CSR在硬件里不存在也会触发非法指令异常。所以判断一个CSR是否存在可以故意在目标特权级访问一下看是否异常但这种做法不够优雅更推荐查手册。2.3 常用CSR速查表CSR数量很大光M模式标准定义就有几十个但日常开发最先要记住的其实是一张小的速查表。我按功能列一下方便大家放在手边。机器模式CSRmvendorid0xF11只读厂商ID。JEDEC标准分配QEMU里通常返回0真片上能看到厂商编号。marchid0xF12只读微架构ID。mimpid0xF13只读实现ID。mhartid0xF14只读硬件线程ID。多核时每个核读到不同值。mstatus0x300状态寄存器里面对全局中断使能、特权级切换信息、浮点状态等进行统一管理。misa0x301RISC-V指令集扩展信息如果支持改动可以动态开关扩展。mie0x304机器模式中断使能控制哪些中断源能触发M模式中断。mtvec0x305M模式中断/异常入口地址必须4字节对齐。mscratch0x340M模式专用临时寄存器通常用来保存上下文指针。mcause0x342记录最近一次中断或异常的原因。mtval0x343记录出错的相关地址或指令编码便于诊断。mepc0x341M模式异常返回地址。mideleg0x303和medeleg0x302中断/异常委托寄存器决定哪些中断和异常交给S模式处理。监督模式CSRsstatus0x100M模式mstatus的一个子集S模式能访问的状态位。sie0x104S模式中断使能。stvec0x105S模式中断/异常入口地址。sscratch0x140S模式临时寄存器。sepc0x141S模式异常返回地址。scause0x142S模式异常原因。stval0x143S模式故障地址。satp0x180S模式的地址翻译控制。开启分页时就写这个寄存器里面包含页表基地址和ASID。sscratch在调用约定里常被用来在入口处保存上下文指针因为当异常发生时通用寄存器还保留着以前的值先用专用寄存器转存一下再找地方保存其他寄存器。用户模式CSR不多主要是U态可访问的性能计数和时间相关cycle0xC00只读周期计数。time0xC01只读实时时钟。instret0xC02只读退休指令数。ustatus0x000U模式状态寄存器主要是浮点状态。uie、utvec这些在用户态一般不会用到除非开启用户态中断扩展。这张表不需要死记你要做的是建立每个CSR大概管什么的印象遇到具体项目再翻手册查字段。关键是M/S两套CSR之间的对应关系mstatus对应sstatusmie对应siemtvec对应stvecmcause对应scause……名字上只是把m换成s但S模式版本会屏蔽掉只有M模式才有的字段。理解了这个对应关系看Linux内核代码里的入口处理就能快速对上号。3. 从0到1实现一个简单的CSR读写实操3.1 环境准备QEMU GCC工具链做实验前先把环境搭好。我习惯用RISC-V的QEMU模拟器加官方的交叉编译工具链这套组合干净、无硬件风险、还能随处跑。在Ubuntu上装工具链一个命令就能完成sudo apt install gcc-riscv64-unknown-elf qemu-system-misc如果你需要跑Linux内核那还得装qemu-user和更完整的工具链但这里我们只做裸机汇编实验gcc-riscv64-unknown-elf完全够用。QEMU我们选择qemu-system-riscv64配合-machine virt参数就可以模拟一块带串口和中断控制器的开发板。如果不想装真实环境也可以直接用在线模拟器但我还是建议本机装一套因为后面调试中断、观察CSR变化时GDB配合QEMU是最常见的手段。注意QEMU的virt机器默认跑在M模式上电从0x80000000开始执行我们要把程序链接到该地址。3.2 编写汇编读取mstatus和mvendorid直接写一个最小的裸机汇编程序读几个关键CSR然后循环输出结果。QEMU里没串口终端我先通过写UART寄存器的方式把数字发出去这样能看到效果。.section .text .globl _start _start: li a0, 0x10000000 # QEMU virt UART 基址 csrr a1, mstatus csrr a2, mvendorid csrr a3, mhartid # 简单把a1的低位逐位移出到UART这里省略具体移位代码 1: j 1b写CSR是另一套动作比如设置mtvec指向我们的异常处理入口la t0, trap_entry csrw mtvec, t0注意CSR指令只能在特定的模式访问。实验里我们全程位于M模式所以访问mstatus、mvendorid毫无障碍。如果你让程序切换到S或U模式再去读mstatus就会触发非法指令异常。我一开始想偷懒直接在M模式下初始化好CSR后就用mret切到U模式然后在U模式继续读mstatus结果程序直接进异常。这个实验恰好说明了权限检查是硬件强制不是软件约定。为了实际观察这些寄存器推荐在QEMU里用GDB。启动QEMU时加-s -S参数GDB连接后可以用info reg查看通用寄存器但CSR需要用monitor info registers这类命令统看。我第一次看到mstatus那几十个位的bitset才真正理解了字段域的含义。3.3 C语言中内嵌汇编访问CSR裸机开发大部分时间还是用C但偶尔需要直接操作CSR于是要在C代码里嵌汇编。RISC-V GCC提供了csr头文件里面是一堆内建函数。比如#include csr.h unsigned long read_mstatus(void) { return csr_read(mstatus); } void write_mtvec(void *entry) { csr_write(mtvec, (unsigned long)entry); } unsigned long read_mcause(void) { return csr_read(mcause); }这套内建函数最终会编译成csrr/csrw指令非常方便。有些老版本工具链没有这个头文件就得自己写宏#define csrr(reg) ({ unsigned long __tmp; \ asm volatile(csrr %0, #reg : r(__tmp)); __tmp; }) #define csrw(reg, val) \ asm volatile(csrw #reg , %0 :: r(val))内嵌汇编的坑主要在clobber和指令类型。比如CSRRW是读旧值、写新值但编译器看到只读或只写时可能优化掉不需要的寄存器结果。这时候要用volatile保证不优化。另外写S模式相关的CSR时如果当前代码跑在M模式编译器不会拦你但运行时硬件会接管。所以内嵌汇编前一定要确认当前特权模式符合CSR权限。另一个常见坑是编译选项。如果编译时没加-march的特定扩展某些CSR访问可能被编译器拒绝或生成非法指令。裸机程序一般-marchrv64gc这个组合里包含了基础的I/A/M/F/D/C扩展CSR指令属于I扩展肯定没问题。但如果你加了-marchrv32imc也要注意工具链内核级代码的隐含假设。3.4 实操中遇到的坑权限、非法指令与工具链支持我在调这段代码时踩过几个值得记录的坑。第一个坑在U模式读mstatus触发的是非法指令异常异常原因码是2Illegal Instruction。拿到mcause2一开始我还以为是自己代码写飞了后来排查才发现是U模式没权限。如果你也遇到mcause2第一反应应该是去查指令本身是否允许而不是查指令地址。第二个坑QEMU的-machine virt在启动时misa寄存器可能没有完全模拟真实的CPU扩展比如某些版本默认没有打开浮点扩展而你用了浮点指令也会触发非法指令异常。所以调试时先monitor info registers看一下misa的值确认扩展位已经打开。第三个坑GCC内联汇编里使用csrw时有的同学写成了csrw %0, mstatus操作数顺序反了。CSR指令语法是csrw csr, rs源操作数在后。写反了汇编器会报错但如果你用的老工具链汇编器对某些格式容忍度很高生成的结果就可能不是你想要的——我在某次用GAS版本2.40时遇到过类似问题建议换成官方推荐的工具链并更新到较新版本。4. 特权模式切换与中断异常中的CSR联动4.1 mret/sret/uret的返回机制模式切换还有一条关键指令组MRET、SRET、URET。它们分别用于从M、S、U模式的中断/异常服务程序中返回。这里先提一个底层设计中断异常响应时硬件会记录进入处理程序之前的特权级到一个状态字段中在M模式里这个字段叫MPP在S模式里叫SPP。执行MRET时硬件根据MPP的值把特权级恢复为进入中断前的那一级同时把mstatus中的MIE恢复为之前的MIE值。你没看错mstatus中还有一个MPIE字段专门保存进入中断前的中断使能位。说白了mret不仅是改变PC它还会修改特权状态和中断使能状态。这在汇编实现任务切换时特别重要。很多初学者模仿函数调用用jalr直接跳回用户程序结果特权级仍然停留在M模式用户程序却还以为自己在U模式后续很多操作都会出诡异问题。正确的返回必须使用MRET或SRET。一个常见的需求是系统启动时从M模式初始化完CSR后想降到S模式运行一个内核然后再降到U模式运行应用。这个降级不能靠直接改mstatus完成而是要构造好mstatus里的MPP字段后用MRET触发返回。伪码思路把mstatus的MPP设为U模式对应值0b00然后写mepc为用户程序入口执行MRET。MRET执行后CPU就认为这是从异常返回自然切到U模式。li t0, 0x00000000 # MPP0 (U模式) csrs mstatus, t0 # 这里需要用csrc清掉MPP或用csrw设置整字段 la t0, user_entry csrw mepc, t0 mret很多同学误以为需要把MPP设为2代表S注意编码MPP字段在mstatus的[12:11]两位00表示U01保留10表示S11表示M。想要降到S模式就设置成10降到U就设置成00。这个细节可以在QEMU里观察mstatus的值直接验证。4.2 中断异常处理时CSR的现场保护中断异常发生时硬件自动做四件事保存当前PC到对应模式的epc保存当前特权级到对应模式的状态位MPP/SPP保存原中断使能到MPIE/SPIE然后关掉全局中断使能MIE/SIE清零跳到mtvec/stvec指向的入口。剩下所有的寄存器保存软件自己负责。这意味着中断处理函数开头必须用汇编把a0-a7、ra、t0-t6这些可能被污染的寄存器都压栈或者保存到mscratch指向的上下文结构里。这里有一个很容易被忽略的点硬件自动保存的PC到底是哪条指令的PC中断发生时mepc是被中断的那条指令的地址通常你返回后应该继续执行这条指令。但如果是异常比如ecall或非法指令mepc可能指向触发异常的那条指令本身。所以返回时要区分处理方法对中断MRET不用调整mepc对ecall可能需要把mepc加4跳过ecall指令否则一返回又执行一遍ecall。我见过不少同学在这里加了4之后没考虑非对齐导致跳到一个奇怪地址。现场保护的时机和顺序也很讲究。当mtvec执行时我们已经处于M模式硬件中断被关闭所以处理器不会在突发中断打断保存过程。但如果你很快开启了中断比如把MIE置1又没保存完现场那就会重入。很多RTOS的临界区实现本质就是靠关闭全局中断保护CSR改写过程。因为CSR数量多现场保护一般不会把所有CSR都保存只保存和任务相关的子集比如mstatus、mepc、mcause以及一些自定义控制寄存器。QEMU的virt机器里我们可以选择保存mstatus、mepc。一个最小保存的结构体如下struct context { unsigned long ra; unsigned long sp; unsigned long s0; unsigned long s1; unsigned long s2; unsigned long s3; unsigned long s4; unsigned long s5; unsigned long s6; unsigned long s7; unsigned long s8; unsigned long s9; unsigned long s10; unsigned long s11; unsigned long mstatus; unsigned long mepc; };当然这只是一个示意。真正的操作系统或RTOS会有更严谨的保存和恢复过程。但核心思想是通用寄存器如果跑超过一层函数调用就必须在栈上保存除了那些被调用者保存的寄存器比如s0-s11它们本来就要由被调用者保存因此中断入口也要遵循这个ABI否则C编译器编出的函数会破坏现场。4.3 典型场景S模式下的时钟中断配置Linux或RTOS下的时钟中断通常配置在S模式。裸机程序在M模式设置好定时器后要通过mideleg把机器定时器中断授权给S模式使S模式能直接收到中断信号。RISC-V的PLIC中断控制器里每个中断源都有对应的使能和优先级寄存器写起来比较繁琐。配置一个S模式时钟中断的流程大致是在M模式下设置mtimecmp寄存器这个寄存器存在于CLINT模块地址在QEMU virt中通常是0x02004000。通过csrs mie, 0x80置位MTIE使机器定时器中断可以产生。通过csrw mideleg, 0x100把机器定时器中断中断号7委托给S模式。mideleg的位号对应中断编号bit7对应定时器中断。这样当定时器到期时CPU会进入S模式中断入口而不会停驻在M模式。设置S模式的stvec和sie使S模式能处理该中断。这里面最容易犯的错误是明明设置了M模式的中断使能但没把中断委托给S模式于是中断始终在M模式处理S模式下永远等不到。另外mideleg和medeleg只允许某些固定的中断/异常被委托不是所有都能委托。比如machine timer事件被委托后S模式看到的中断编号依然是7只是入口变了。由于我们是裸机不跑操作系统可以用S模式中断模拟一个简单的调度器定时器到期后在S模式中断里打印tick然后MRET回到U模式用户循环。这算是最简单的用户态和内核态协作模型了。写起来代码量不大但对理解中断委托和模式切换帮助极大。5. 常见问题与排查技巧实录5.1 CSR访问异常常见原因把常见问题和排查思路整理成一张表方便定位。现象可能原因排查方式读取某个CSR触发Illegal Instruction当前特权级低于CSR要求查看当前特权级、CSR地址bit[11:10]写CSR后值没变该CSR是只读属性查看bit[9:8]只读则不能写触发断点/单步时CSR变化异常不存在的CSR地址在目标机器上确认CSR存在性mret恢复PC不对mepc被中断现场破坏检查中断入口是否保存了mepc中断一直不进S模式mideleg没委托或者SIE没开检查mie、mideleg、sie、stvecU模式执行ecall后异常不进入预期处理函数异常原因码不是Environment Call确认ecall编号与对应cause一致排查CSR异常第一件事就是通过读mcause看原因码。如果是2Illegal instruction下一步检查当前模式。QEMU里可以用info registers查看CSR但QEMU的info registers默认只显示通用寄存器你需要输入info regall或者info csr来查看CSR。真片上一般就要靠调试器或者串口打印。5.2 如何快速定位特权模式问题特权模式出错问题往往表现为某条指令在某些场景下正常、在另一些场景下异常。比如你在S模式写satp正常但切到U模式后写satp立马陷阱。最快的定位办法是分别在不同模式的代码入口处打印当前模式。RISC-V没有直接的读当前特权模式指令但可以从mstatus的MPP字段读出来如果你在S模式跑也可以通过csrr sstatus后看SPP位来确认。另一个有效手段是故意触发一次外部中断如果中断进入到错误的特权级说明某些委托配置不对。比如你希望S模式处理外部中断但中断却跳到了M模式入口就检查mideleg对应位以及PLIC的路由配置。我排查时习惯在异常处理函数入口处用mtval的值来辅助判断。比如非法指令异常mtval会保存触发异常的指令编码可以把它打印出来反汇编看是哪条指令。如果指令是CSR开头的基本就是权限或地址错误。如果指令是普通访存那就是地址翻译或特权级访问控制问题。5.3 工具链选项与模拟器调试技巧最后分享几个提高调试效率的小技巧。QEMU调试启动参数可以这么写qemu-system-riscv64 -machine virt -m 128M -nographic -s -S其中-s是打开GDB远程调试监听1234端口-S是启动即暂停等GDB连上后再继续。GDB里读CSR用(gdb) info reg misa (gdb) info reg mstatus不同GDB版本语法有差异有的要用info registers all列出所有CSR再用p/x搜索。如果GDB不识别RISC-V CSR注意检查GDB版本是否支持RISC-V推荐GDB 12以上。编译汇编时记得加-g这样单步时能看到行号。链接脚本里要把起始地址设为0x80000000否则QEMU从该地址取指令时可能读到全0表现就是PC一直在0毫无反应。我做过一个最小链接脚本大家可以直接抄OUTPUT_ARCH(riscv) ENTRY(_start) SECTIONS { . 0x80000000; .text : { *(.text) } .data : { *(.data) } .bss : { *(.bss) } }还有个小技巧在QEMU的裸机程序里可以先构造一个简单的print函数把CSR值按十六进制打印到UART。这样调试串口输出就能实时看到CSR变化。QEMU virt的UART地址是0x10000000串口寄存器是8位宽写字符就是往该地址写一个字节。有了这层基础后面调中断、调页表、调时钟都不会两眼一抹黑。我个人的体会是学习CSR和特权架构千万不要在真片上硬扛先用QEMU把行为摸清楚再回到真实平台你会发现寄存器字段含义、异常行为、模式切换都一目了然。遇到不理解的特权行为最好的办法不是搜博客而是打开RISC-V特权架构手册的对应章节按字段逐个验证。这期内容到这里后续专栏我会继续拆解地址翻译、页表缓存和PLIC中断控制器先把CSR和特权模式的地基打牢后面盖楼就快了。