RISC-V特权架构与CSR详解:从M/S/U模式到中断处理 做RISC-V最低层开发说到底就两件事搞清楚当前跑在哪个特权级搞清楚这个特权级能碰哪些CSR。我在专栏前两篇聊过指令集基础和通用寄存器布局这次趁着整理项目笔记把M/S/U三态特权架构和常用CSR一次性讲透。不管是做CPU设计、写RTOS还是纯想弄明白Linux底层在干什么这篇文章都能当工具书用建议收藏。CSRControl and Status Register控制状态寄存器是RISC-V里最特殊的一类寄存器它不在通用寄存器组里也不在内存地址空间里而是走独立的专用指令访问。很多刚上手的朋友在这里栽跟头以为是普通寄存器直接读写结果编译出来的代码一跑就非法指令异常或者中断响应后返回地址没保存好整个程序直接跑飞。这些问题的共同根源都是对特权架构和CSR规则理解不到位。下面我从特权级的本质开始一路拆到每个常用寄存器的位定义和陷阱处理链路。1. 三种特权级到底解决了什么问题1.1 没有特权级的世界一损俱损想象一个没有任何权限隔离的单片机程序应用代码可以直接改写定时器寄存器、可以随意跳转到任意地址、甚至把整个Flash擦掉。单任务裸机时代这勉强能忍因为出问题大家一起死。可一旦跑多任务操作系统场景就变了——你不想让用户程序通过一个野指针把内核的数据区全部踩烂更不想让某个崩溃的任务把整个系统拖下水。特权级就是给谁有资格做什么划了一条线。RISC-V定义的M/S/U三级模式本质上是一套硬件强制执行的信任分级M代表机器模式Machine权限最高系统上电后最先运行S代表监管模式Supervisor主要给操作系统内核用U代表用户模式User跑普通应用程序。权限低的不能直接干高权限才能干的事例如U模式程序想切换页表硬件会直接拒绝并产生异常。1.2 数值约定与权限边界特权级的数值编码很简单U0S1M3。你没看错中间没有2——数值2对应的Hypervisor扩展模式在RISC-V规范里是历史遗留保留位绝大多数量产核根本不实现直接把2当成非法值处理就行。权限边界的核心规则是三句话M模式可以访问M、S、U所有级别的资源也可以执行mret/ecall等特权指令。S模式可以访问S、U级别的资源但碰M模式专属CSR就会触发非法指令异常。U模式只能访问U级别资源想干换页表关中断这类敏感操作只能通过ecall把控制权交给上层。这套模型和ARM的EL0/EL1/EL3有相似之处但RISC-V的规则更简单直接硬件层面对特权级的检查点也少很多做CPU验证的时候会省不少心力。1.3 特权级如何限制CSR访问CSR是特权级约束最密集的地方。每个CSR的地址高两位和中间两位已经写死了谁能读、谁能写硬件在译码阶段就会检查当前特权级是否达标不达标直接产生非法指令异常不会出现读到了但结果是垃圾这种模棱两可的状态。举个例子mtvec机器模式陷阱向量基址地址是0x305只有M模式能访问stvec监管模式陷阱向量基址地址是0x105S和M模式都能访问cycle硬件周期计数器地址是0xC00是只读寄存器U模式都能读。这种地址即权限的设计是我个人比较欣赏的RISC-V特性之一——查权限不需要去翻额外的配置表看地址前缀就能猜个八九不离十。2. CSR 地址空间与通用访问规则2.1 地址区域是怎么划分的RISC-V的CSR地址是12位的理论上一共4096个位置。实际用起来没这么满但划分规则非常清晰按地址前缀就能判断访问权限地址范围最低访问特权级典型寄存器举例0x000-0x0FFU模式fflags、frm、fcsr浮点控制0x100-0x1FFS模式stvec、sepc、scause、satp0x200-0x2FF保留H模式历史遗留无标准定义0x300-0x3FFM模式mstatus、mtvec、mepc、mcause0xC00-0xCFFU模式只读cycle、time、instret0xF00-0xF7FM模式只读mvendorid、marchid、mhartid这里有个很容易和搜索引擎结果混淆的点你在网上搜RISC-V CSR经常会看到CSR压缩存储邻接表和CSR内存空间这些内容它们讲的是图论里的Compressed Sparse Row稀疏矩阵压缩行存储跟RISC-V的控制状态寄存器完全是两个东西。这俩碰巧都叫CSR做底层开发的同事聊起来也经常互相误会我一开始学RISC-V的时候就在这个坑里浪费了不少时间。2.2 六条CSR访问指令访问CSR靠的是专门的指令不是普通的load/store。基础指令一共六条csrrw rd, csr, rs1 # 原子读-写把旧值读到rd同时把rs1写入csr csrrs rd, csr, rs1 # 原子读-置位把csr里rs1为1的位写为1旧值读到rd csrrc rd, csr, rs1 # 原子读-清除把csr里rs1为1的位写为0旧值读到rd csrrwi rd, csr, imm # csrrw的立即数版本 csrrsi rd, csr, imm # csrrs的立即数版本 csrrci rd, csr, imm # csrrc的立即数版本csrrs和csrrc这类读-改-写指令一开始我觉得有点多余直到真正操作mstatus的时候才发现它的价值我想单独打开全局中断位MIE又不想动MPP进入陷阱前的特权级和MPIE进入陷阱前的中断使能状态这些相邻位用csrrs一条指令搞定不用先读出来再算掩码再写回去。原子性在全抢式的多核环境里尤其重要省了锁。2.3 处理器设计视角的写规则做CPU设计的朋友注意CSR除了权限检查之外还有几个写规则的细节写只读CSR或特权级不够统一抛非法指令异常Illegal Instruction异常码2。部分CSR的位是W1C语义Write 1 to Clear写1清0例如中断挂起寄存器mip软件清中断靠写1写0无效。这部分逻辑一定不能在硬件里做成写0清0否则中断会永远清不掉。很多CSR写的时候要检查对齐和保留位例如mtvec的基址要按4字节对齐低两位是模式位保留位硬件上应该实现成写入忽略、读出为0而不是报错这样软件可以放心地读写整个寄存器。3. 机器模式核心CSR速查3.1 mstatus所有开关的总闸mstatusMachine Status Register机器状态寄存器绝对是你第一个要背熟的寄存器。它管着三件大事全局中断开关、进入陷阱前的状态保存、以及浮点/扩展单元的状态跟踪。RV64下mstatus的位布局大致如下位段名称作用[63]SD扩展状态汇总位只读等于FS或XS为脏时的标志[16:15]XS用户自定义扩展状态Off/Initial/Clean/Dirty[14:13]FS浮点单元状态[12:11]MPP进入陷阱前的特权级编码M3S1U0[10]VS向量扩展状态和FS/XS类似[8]SPP进入S模式陷阱前的特权级0U1S[7]MPIE进入陷阱前MIE的值[5]SPIE进入陷阱前SIE的值[3]MIEM模式全局中断使能[1]SIES模式全局中断使能[20]TVM设为1时S模式访问satp会触发异常[21]TW设为1时S模式执行WFI会触发异常[22]TSR设为1时S模式执行sret会触发异常这里最关键的还是MIE、MPIE、MPP这个三件套。硬件在进入陷阱时自动把MIE的值搬进MPIE、把当前特权级存进MPP同时清零MIE禁止中断嵌套返回时再把这些值恢复回去。你不需要在异常处理函数里手动保存这三样东西硬件已经替你做了软件只管在退出时写好mret就行。SD位的计算值得一提它不是真正存储的位而是硬件根据FS和XS的状态实时算出来的只读汇总位。做CPU验证的时候要注意SD的准确行为需要单独测很多设计在这里栽跟头——把SD当成普通寄存器存储结果状态机对不上。3.2 陷阱入口四件套mtvec、mepc、mcause、mtvalmtvecMachine Trap Vector机器陷阱向量决定异常/中断发生后CPU跳去哪里。低2位是模式0表示Direct模式所有陷阱都跳到一个固定入口1表示Vectored模式中断会按cause值偏移跳转基址4×cause。高62位是基址要求4字节对齐。mepcMachine Exception PC机器异常PC保存的是触发陷阱的那条指令地址。注意它保存的不是下一条指令而是当前指令返回的时候mret会把这个值填回PC。Trap处理完软件要根据异常类型决定是重新执行这条指令还是用mepc加4跳过去。在支持压缩指令RVC的核上mepc的地址可能只按2字节对齐设计和验证时别写死4字节对齐。mcauseMachine Cause机器异常原因的上一位区分中断和异常最高位为1表示中断为0表示异常。常见的异常码和中断码有这些编码类型含义0异常指令地址未对齐1异常指令访问异常2异常非法指令3异常断点ebreak4异常Load地址未对齐5异常Load访问异常6异常Store/AMO地址未对齐7异常Store/AMO访问异常8异常ecall from U模式9异常ecall from S模式11异常ecall from M模式12/13/15异常指令/Load/Store页错误1中断S模式软件中断3中断M模式软件中断5中断S模式定时器中断7中断M模式定时器中断9中断S模式外部中断11中断M模式外部中断mtvalMachine Trap Value机器陷阱值是可选的辅助寄存器用来携带地址相关异常的出错地址或者非法指令的指令编码。软件调试的时候很有用但注意规范没有强制要求实现做移植前先查手册确认。3.3 中断使能的双层开关mie、mip、mstatus.MIERISC-V的中断使能是两层结构局部使能位mie和全局中断位mstatus.MIE。局部负责细粒度地选通定时器中断、软件中断、外部中断这些具体源全局负责总闸门。CPU要响应某个中断两个条件必须同时满足mie中对应的局部使能位为1并且mip中对应的挂起位为1mstatus.MIE为1。这种双层设计在RTOS里很实用系统进入临界区时只清全局位就能屏蔽所有中断想单独屏蔽定时器又放行外部中断就操作局部位。mie和mip的位布局几乎一一对应位中断源bit 3机器软件中断MSIbit 7机器定时器中断MTIbit 11机器外部中断MEIbit 1监管软件中断SSIbit 5监管定时器中断STIbit 9监管外部中断SEImip是只读的里面反映的是中断条件是否满足软件的写操作实际走的是W1C逻辑比如往mip的定时器位写1来清除挂起。写0没用这是很多人刚上手时的第一个坑。3.4 M模式CSR速查表寄存器地址访问核心作用mstatus0x300RW全局中断、陷阱前状态保存、扩展状态跟踪misa0x301RWCPU支持哪些指令集扩展位编码为字母medeleg0x302RW异常委托置1的位由S模式处理mideleg0x303RW中断委托同上mie0x304RW中断局部使能mtvec0x305RW陷阱入口基址和模式mscratch0x340RW陷阱处理临时寄存器通常存上下文的保存地址mepc0x341RW触发陷阱的指令地址mcause0x342RW异常/中断原因mtval0x343RW附加故障信息mip0x344RO中断挂起状态mhartid0xF14RO当前硬件线程ID多核启动时用mvendorid0xF11RO厂商ID做生态兼容时要实现mscratch这个寄存器单独说一句它不参与任何硬件自动行为纯粹是给软件铺路用的。惯例做法是进陷阱时用csrrw把mscratch和某个通用寄存器交换把通用寄存器救下来再通过它找到完整的上下文保存区。这个技巧写trap handler的时候几乎必用。4. 监管模式与内存虚拟化核心CSR4.1 S模式存在的意义S模式是跑Linux这类现代操作系统的地基。它的核心职责有两块一是提供独立的陷阱处理流程stvec/sepc/scause这套让内核能安全地接管异常二是通过satp寄存器控制虚拟地址到物理地址的翻译也就是页表。为什么页表这套必须放在S模式而不是M模式因为M模式的特权太高如果操作系统跑在M模式一旦用户程序触发异常所有处理都要经过M模式代码虚拟机管理器或者安全固件就很难做隔离和审计。S模式夹在中间既能做虚拟内存管理又不至于把所有机器资源直接暴露给内核。4.2 satp页表翻译的入口satpSupervisor Address Translation and Protection监管地址翻译与保护是S模式最重要的寄存器。RV64下它的布局位段名称说明[63:60]MODE翻译模式0Bare不翻译8Sv39三级页表9Sv48四级页表[59:44]ASID地址空间标识符用于TLB隔离切换进程时用它区分缓存[43:0]PPN根页表的物理页号切换进程的时候软件把新的根页表物理页号写进satp然后必须执行SFENCE.VMA指令刷掉TLB里旧的翻译缓存。很多初学Linux移植的朋友忘了这一步结果进程切出去再切回来访问的还是旧地址映射出现一堆诡异的内存错乱。MODE字段的细节值得留意Bare模式0意味着CPU不做任何翻译物理地址直接使用。固件阶段通常跑Bare等操作系统准备好页表再切Sv39。另外mstatus里的TVM位如果置1S模式代码访问satp会直接触发非法指令异常——这是安全固件用来没收S模式改页表权限的手段。页表项本身也有权限位V有效、R/W/X读/写/执行、U用户可访问、G全局映射、A已访问、D已脏。S模式想访问U类型页面必须在mstatus里置上SUM位否则一碰就页错误。这个设计隔离了内核和用户数据但也经常让内核开发者困惑——为什么我明明给了权限还是page fault多半是SUM忘了开。4.3 陷阱委托medeleg/mideleg默认情况下所有异常和中断都会进M模式处理。但Linux内核希望把用户程序的系统调用和普通中断直接在S模式处理不要每次都在固件和内核之间来回切。这就需要委托机制。medelegMachine Exception Delegation机器异常委托和midelegMachine Interrupt Delegation机器中断委托的每一位对应一种异常码或中断码置1表示这类陷阱交给S模式的stvec处理。例如medeleg[8] 1ecall from U的异常直接进S模式不用先惊动M模式。mideleg[5] 1S模式的定时器中断在S模式处理。但是M模式自己的定时器中断编码7不能委托给S——硬件上直接规定这部分位只读为0。委托不会改变陷阱发生的根本触发条件只是改变了跳谁被委托的陷阱自动跳stvec没被委托的跳mtvec。我在调试系统调用的时候发现一个常见误区以为ecall从U发出就直接走stvec其实要先看medeleg[8]有没有置1。很多固件默认全0那就所有陷阱都先绕一圈M模式。4.4 S模式CSR速查表寄存器地址访问核心作用sstatus0x100RWS模式全局中断控制是mstatus的裁剪视图sie0x104RWS模式中断局部使能对应SSI/STI/SEIstvec0x105RWS模式陷阱向量MODE和BASE定义同mtvecsscratch0x140RWS模式陷阱临时寄存器sepc0x141RWS模式异常PCscause0x142RWS模式异常原因stval0x143RWS模式附加故障信息sip0x144ROS模式中断挂起satp0x180RW页表根地址、ASID、翻译模式sstatus是mstatus的裁剪视图映射规则是S模式的SIE、SPIE、SPP和FS、XS等物理状态位直接映射MIE、MPP这些M专属位在sstatus里读出来是0、写被忽略。这种一个物理寄存器多种视图的设计在验证时尤其要注意不同特权级读同一个地址可能得到不同结果。5. 用户态、系统调用与完整切换链路5.1 U模式能碰什么U模式是整个体系里权限最薄的一层。它直接可访问的CSR非常有限浮点控制寄存器fflags/frm/fcsr、性能计数器cycle/time/instret以及一些无特权级的版本识别寄存器。除此之外几乎一切敏感操作都被硬件挡住。U模式程序想获得操作系统服务唯一的正规途径是ecall指令。这条指令会强制触发一个异常把控制权交给S或者M模式的陷阱处理代码。硬件不会自动检查你要调用的服务合不合法它只负责把异常抛出来、把上下文交给上层剩下的审计工作全是软件的事。5.2 系统调用的软件约定RISC-V规范本身没有规定系统调用号怎么传实际生态里形成了一套约定ecall之前把系统调用号放进a7寄存器参数依次放进a0-a5返回值放回a0。内核的trap handler再从a7里取出调用号查表分发。这套约定和ARM的SVC调用、x86的int 0x80思路一致但RISC-V更干净不依赖栈传参。这里有个值得注意的设计选择为什么ecall不像x86那样由指令本身携带一个立即数表示系统调用号因为RISC-V想让ecall保持最简单、最通用的语义——它就是一个触发陷阱的指令具体陷阱干什么由软件决定。这样既满足系统调用需求也满足协处理器缺页、用户态调试等更多场景还省了指令编码空间。5.3 一条系统调用的完整路径拿U模式程序发起read系统调用举例完整链路是用户程序把调用号放进a7参数放进a0-a5执行ecall。硬件原子地把当前PC保存到sepc如果medeleg[8]1否则是mepc把sstatus.SIE存到SPIE清零SIE把SPP设为U跳转到stvec。trap handler开场用sscratch交换出通用寄存器的原始值保存现场然后根据scause判断是ecall从a7取调用号。内核执行read逻辑。返回前恢复现场执行sret。硬件恢复PCsepc、特权级恢复为U、SIESPIE。整个过程中硬件只做了存档、关中断、跳转三件套现场保存寄存器压栈完全是软件干的。这就是RISC-V的取舍硬件尽量少干活给软件留足自由代价是trap handler的编写比ARM的硬件自动压栈更繁琐但换来的是更低的硬件复杂度。6. 陷阱处理的硬件行为与中断使能细节6.1 硬件自动做的四件事不管是异常还是中断不管在哪个特权级硬件进入陷阱时自动做的工作高度统一。以M模式为例触发陷阱时硬件会把当前PC写入mepc。把mstatus.MIE的值存入MPIE。清零MIE同时根据当前特权级更新MPP。根据陷阱类型和mtvec配置跳转到对应入口。后面三条经常被初学者忽略。清零MIE的意义是防止在没保存完上下文之前又被同级别中断打断——这是最简单的隐式关中断。MPP的更新则是让mret能找到回家的路mret返回时特权级直接恢复成MPP里存的值。S模式的陷阱假设没有被委托到M走同样的逻辑只不过操作的是sepc、SIE、SPIE、SPP入口换成stvec。6.2 mret与sret的返回逻辑mret做了四件事顺序上值得注意PC - mepc 特权级 - mstatus.MPP mstatus.MIE - mstatus.MPIE mstatus.MPIE - 1 mstatus.MPP - 最低支持特权级通常是U为什么返回后要把MPP重新命为U这是为了下次陷阱自动保存时MPP存的是当前真正的特权级而不是上次的历史值。如果硬件不清这个位连续两次嵌套陷阱后恢复会混乱。sret的语义和mret完全平行只是操作对象换成sepc、SIE、SPIE、SPP。注意sret只能在S模式或M模式执行如果U模式执行sret直接非法指令异常。同样mstatus.TSR置1时S模式执行sret也会被扣下——安全固件可以用这个位对操作系统实施代管。6.3 中断嵌套与显式重开陷阱入口硬件自动清零MIE意味着默认情况下同一个特权级的中断不能嵌套。这是为了避免上下文没存好就被新中断撕碎。但你可以在trap handler里手动置位MIE来允许嵌套。嵌套的代价是必须保存mepc、mcause、MPP这些硬件自动档因为它们会被新陷阱覆盖。我见过不少裸机工程为了贪图方便直接开嵌套结果两层中断同时到trap handler又没做完整保存整个栈被蹂躏得面目全非。我的建议是能不开嵌套就不要开RTOS的临界区完全可以用全局中断开关的粒度解决大多数问题嵌套只会让边界条件指数级增加。6.4 中断挂起与清除的边界情况中断响应后硬件不会自动清mip软件必须显式清除。典型的流程是读mip拿到中断源处理完往对应位写1清除再mret。这里最容易出的问题就是清早了。比如定时器中断你先清了挂起位再进处理函数处理过程还没结束下一次定时器中断条件又到了mip又变1可能导致mret一执行又立刻进中断——处理函数根本没机会返回。正确做法是先处理业务最后再清挂起或者在清除前把下一次触发时刻的设置先配好。7. 做CPU设计和写固件时踩过的坑7.1 mtvec的对齐与配置顺序有次我在FPGA上调一个简单的定时器中断现象是跑起来直接死机连启动日志都没有。查了半天发现trap handler里第一条指令写在了mtvec1的地方——mtvec的低两位被配置成了非零值而我把整个基址当成了直接写入地址。mtvec的BASE必须4字节对齐启用RVC后是2字节低两位是MODE。正确写法la t0, trap_entry andi t0, t0, ~0x3 # 对齐到4字节边界 csrw mtvec, t0 # 写入时已是mod0直接模式还有个配置顺序问题先配mtvec再开mie和mstatus.MIE。反过来的话中断可能在向量还没就绪时已经触发CPU会跳到一个空地址或者残缺的入口。起电顺序的bug最难查因为这属于时序问题单步调试时根本复现不了。7.2 中断返回前的重复触发另一个高频bug是中断处理完mret后立刻再次进入同一个中断形成伪死循环。根因往往不在返回逻辑而在挂起位清除的时机。我用一个具体场景说明定时器中断里我先mret返回主程序主程序延迟一会儿再重新配置定时器。但如果处理程序结束前没清mip[7]mret后只要定时器条件还满足CPU马上再次跳进trap入口。表面看起来是中断无法退出实际上是你没按协议清挂起。排查技巧很简单在trap handler入口打印mip看哪些位从一进来就是1。7.3 验证CSR时的约束随机思路做过CPU验证的朋友都知道特权架构相关的case是覆盖率黑洞。我自己摸索出一套还比较实用的策略特权级转换的每一个边界都建独立用例U-S、U-M、S-M、以及各级mret。每个CSR的访问矩阵必须穷举当前特权级×读写操作×目标CSR期望结果是pass或illegal instruction。保留位写入用约束随机颜色是写全1进去再读出来应该还是保留值不要让验证环境里出现写保留位导致未知状态。特别关注SD这类只读汇总位它是组合逻辑算出来的不是存储位不能像普通寄存器一样读改写。有个反直觉的经验验证陷阱链路时故意把mtvec设成一个未对齐地址再触发中断比在正常路径上跑一百遍更早暴露硬件bug。边界条件是这种系统的试金石。7.4 给入门者的一条路线建议如果让我给刚开始玩RISC-V特权架构的朋友一个建议不要在Linux内核上硬啃。先把M模式的完整闭环跑通——配mtvec写一个trap handler用ecall主动触发mret返回。这个最简闭环跑通后再引入S模式和页表最后才碰Linux。每上一层你需要理解的CSR也就多三四个而不是一开始就面对几百个寄存器。我自己刚开始做RISC-V处理器设计的时候最头疼的就是CSR太多太杂、文档又分散。后来我把常用寄存器整理成了速查表又用脚本从RISC-V特权规范里自动抓取寄存器列表生成头文件排查问题的时候先查表后查文档效率高了一大截。现在这套表就是你在上面看到的那几张表格希望能帮你少走点弯路。