RISC-V这两年成了处理器圈子的顶流但很多人入门时会被两个概念卡住一个是CSRControl and Status Register另一个是特权架构M/S/U。我在做自研RISC-V核的那段时间先在纯M模式的裸机环境里跑得很顺以为特权架构不过如此结果一接S模式、一碰Linux内核才发现前面理解的CSR就是配寄存器完全不够用。CSR不只是控制逻辑的开关集合它的地址编码、读写权限、陷阱处理链路、委托机制直接决定了一个核能不能支撑操作系统、能不能做安全隔离。这篇文章把M/S/U三级特权架构和常用CSR一次性捋清楚附一份可以直接当手册翻的速查表再加实际调核时踩过的一堆坑。顺便说一句热搜里经常有人问邻接表和CSR压缩存储的内存空间消耗是同一个量级吗那个CSR是Compressed Sparse Row图算法里的稀疏矩阵压缩格式跟RISC-V的CSR完全不是一回事别搞混了。这篇专栏只聊RISC-V的Control and Status Register和特权级设计对正在写RISC-V核的工程师、准备系统学习特权规范的本科生以及想把裸机程序升级成带权限管理系统的开发者应该都能直接受益。1. 特权架构M/S/U先搞清楚这三个档位1.1 三级特权的职责边界RISC-V定义了多个特权级主流接触到的就三个UUser、SSupervisor、MMachine。M是最高级也是CPU复位后默认所处的模式S是给操作系统内核用的中间层U是最低层跑普通应用程序。还有一个可选的H模式Hypervisor夹在S和M之间专门做虚拟化但绝大多数自研项目和课程设计都碰不到它把M/S/U吃透就足够应付绝大部分场景了。这三层本质是一条信任链。M模式下你拥有全部权限能读写所有CSR、能访问任意物理地址、能关掉所有中断系统调用和异常处理都得经过它。S模式则拥有内核级权限能配置satp页表寄存器、能处理来自U模式的陷阱但改不了M模式专属的硬件状态。U模式最受限它连自己所在的地址是不是虚拟地址都未必能感知只负责老老实实跑应用代码。我经常拿小区物业做类比U模式是住户钥匙只能开自己家门坏了只能报修S模式是物业办公室有楼栋总钥匙能处理住户上报的问题M模式是开发商房子的地基图纸、承重结构全在它手里物业搞不定的事情最后都得回到开发商这边。三层之间不能随便串门只能通过规定好的报修流程也就是陷阱机制逐级上报。1.2 为什么非要多搞几个特权级很多刚接触的人会问裸机程序在M模式里跑得挺欢加S和U是不是自找麻烦答案是一旦你要跑Linux或者要做带内存保护的任务调度隔离就是刚需而非选项。举个例子一个RTOS想把每个任务的栈和内存相互隔离避免某个任务野指针把别的任务踩了这靠M模式单纯关中断是做不到的必须通过S模式的satp打开地址翻译让每个任务看到的虚拟地址空间互不重叠。再举个例子一个应用卡死在死循环里系统总得要一个定时器中断能把控制权抢回来。如果所有代码都堆在M模式应用就可能直接摸到中断向量表系统连纠错的机会都没有。还有一层容易被忽略的动机特权级不只是限制CSR访问它同时限制内存访问权限。M模式直接读写物理地址S模式开了页表之后看到的是虚拟地址U模式只能碰自己有权访问的页。正是因为这个双重限制后面要讲的MPRV、SUM这些mstatus位才有存在意义——它们是让S模式在某些场合临时切换内存访问视角而不是真的降级到U模式去跑代码。1.3 模式切换只能走陷阱通道模式切换不是往CSR里写一个值就算完事RISC-V规定必须通过陷阱trap机制完成。trap分两类同步异常比如非法指令、缺页、地址不对齐和异步中断比如定时器中断、外部中断。进入trap时硬件会自动把当前PC存进mepc把当时的特权级存进mstatus的MPP字段然后跳转到mtvec指向的入口地址。退出陷阱用mret指令把mepc写回PC、把MPP恢复成当前特权级。这里有个非常关键的认知直接修改mstatus的MPP字段来伪造模式切换是无效的真正的切换必须完整走过trap路径。我在调试波形时见过不止一次软件往MPP里写了期望值但当前特权级纹丝不动查了半天才发现是跳过了指令流里的mret。这套存现场-跳转-恢复现场的环路就是特权架构的心脏后面所有的CSR细节都围绕它展开。2. CSR速查手册地址编码、关键寄存器与常用位2.1 12位地址里的编码密码CSR地址一共12位最多4096个但布局不是随便排的这12位本身就携带了权限信息。其中位[11:10]为11表示这是一个只读CSR对这个地址执行写操作会触发非法指令异常位[9:8]编码当前CSR的最低可访问特权级00对应U模式、01对应S模式、10对应H模式保留、11对应M模式。地址段因此天然分成几块0x000开头是U模式CSR0x100开头是S模式CSR0x300开头是M模式CSR0xC00开头的计数器CSR则是U模式可读的0xF00开头是厂商自定义的只读信息CSR。这个编码规则是特权安全的第一道闸门。硬件在译码阶段只需要比对CSR地址的位[9:8]和当前特权级就能立刻判定本次访问合不合法完全不需要额外的权限表。我经常在培训时强调看CSR不要死记地址看地址的高四位就能猜出它的身份。比如satp是0x180展开后位[9:8]01必然是S模式才能写的寄存器U模式写它直接就进异常处理。当然还要注意一个叫WARL的属性全称是Write Any values, Read Legal values意思是你写入什么值都行硬件只保证读回来的是合法值。比如mstatus的MPP字段是WARL如果你的核只实现M和U两个特权级MPP硬件上只需要能保存00和11这两档写入01这种非法值会被硬件清掉。这个属性给CPU设计留了巨大的简化空间后面第5节还会展开。2.2 M模式CSR机器模式的家底M模式CSR是整套特权架构的基石不管你的核做得多精简下面这几个几乎绕不开。地址名称读写作用0x300mstatusRW机器状态全局中断使能、上一次特权级、MPRV/SUM等0x301misaRWISA扩展位图标识支持的指令集扩展0x302medelegRW哪些同步异常委托给S模式处理0x303midelegRW哪些中断委托给S模式处理0x304mieRW机器模式中断使能定时器/软件/外部0x305mtvecRW陷阱入口基地址和向量模式0x340mscratchRW临时寄存器trap处理里救命的那个0x341mepcRW异常发生时保存的PC0x342mcauseRW异常类型和中断标志0x343mtvalRW附加信息一般是出错地址或指令编码0x344mipRW中断挂起状态0xF11mvendoridRO厂商ID0xF12marchidRO微架构ID0xF14mhartidRO硬件线程ID其中mstatus是最核心的值得单独拆开讲。bit3是MIE机器模式全局中断使能总开关bit7是MPIE保存进trap之前的MIE值bit12:11是MPP记录进入trap前的特权级bit17是MPRV把它置1后内存访问会改用MPP指定的特权级去做权限检查典型场景是S模式内核想直接访问物理地址时的临时操作bit18是SUM允许S模式访问U模式的页bit19是MXR允许load指令读可执行页。如果核里有浮点单元bits14:13的FS字段还承担浮点状态跟踪的职责这在上下文切换时特别有用。mepc、mcause、mtval三个寄存器是陷阱现场的三件套。mepc存放触发异常的指令地址对中断来说存放的通常是中断发生前正要执行的那条指令地址mcause的最高位标明这是中断还是同步异常低30位是具体原因码mtval则存放额外信息比如缺页异常的出错虚拟地址。理解这三件套的配合方式基本就掌握了RISC-V异常处理的原始模型。2.3 S模式CSR与中断委托S模式是操作系统的家它能看到的是mstatus的裁剪版本sstatus以及自己的一套陷阱寄存器。地址0x100的sstatus只是把mstatus里S模式允许看的位透传过来比如SIE、SPIE、SPP、SUM这些你在S模式下写sstatus的MIE位是无效的硬件会按WARL把这种越权写归零。这既保持了S模式程序的舒适感又没泄露M模式的秘密。地址名称读写作用0x100sstatusSS模式可见状态位0x104sieSS模式中断使能0x105stvecSS模式陷阱入口0x140sscratchSS模式临时寄存器0x141sepcSS模式异常PC0x142scauseSS模式异常原因0x143stvalSS模式异常附加信息0x144sipSS模式中断挂起0x180satpS地址翻译与保护配置真正让S模式具备接管能力的是委托机制。默认情况下所有异常和中断都先进入M模式这对跑裸机没问题但要是Linux也这么干M模式的中断处理程序就得当邮差把所有中断一封封转发给S模式性能直接崩。RISC-V给出的方案是medeleg和mideleg两个寄存器谁在对应位上写1对应的异常或中断就直接在S模式下处理硬件不再切换进M模式陷阱现场直接写在sepc/scause/stval里入口直接跳stvec。举个最常用的例子系统调用。U模式应用执行ecall时同步异常的原因码是8。Linux内核希望自己处理系统调用那引导阶段就要在M模式里执行medeleg的bit8写1这样U模式一调ecall硬件直接以S模式身份跳进stvec内核的syscall处理程序就近接管。定时器中断同理mideleg的bit5对应STIPS模式定时器中断写1之后定时器中断就不再经手M模式了。2.4 U模式CSR与计数器U模式能访问的CSR非常有限主要是ustatus、utvec之类的用户态陷阱支持以及在大多数核上开放的性能计数器cycle、time、instret地址分别是0xC00、0xC01、0xC02。这些计数器设计成U模式可读是为了让应用程序能用rdcycle、rdtime、rdinstret这些伪指令做性能测量同时又能防止用户态随便改计数器破坏系统审计。有个容易忽略的点M模式有mcounteren寄存器S模式有scounteren寄存器这两者是计数器向下开放的闸门。想让U模式读cycleM模式必须在mcounteren里允许S模式也必须在scounteren里允许两层都开着U模式才读得到。如果U模式读计数器触发非法指令异常多半不是核的问题而是某层闸门没打开。3. 四条CSR指令搞定所有访问3.1 CSRRW/CSRRS/CSRRC语义对比RISC-V访问CSR一共有六条指令但抓本质就三条CSRRW、CSRRS、CSRRC。CSRRW是原子读改写把操作数rs1写入CSR同时把CSR的旧值读回rdCSRRS是原子读置位把rs1里为1的位在CSR里置1CSRRC是原子读清除把rs1里为1的位在CSR里清零。这三个操作的共同点是读旧值和写新值是原子的不存在中间被别的指令插队的窗口这对并发关中断这类操作至关重要。它们各自的变体CSRRWI、CSRRSI、CSRRCI把rs1换成了5位立即数zimm适合设置单个固定位。注意这个立即数只占低5位所以只能覆盖0到31号位改高位的位还是要走寄存器版本。有个利用寄存器编号的隐藏语义值得记住当rd是x0时CSRRW不再读旧值纯粹只写当rs1是x0时CSRRS和CSRRC不再写CSR纯粹只读。汇编器把csrr rd, csr翻译成csrrs rd, csr, x0就是利用了这个规则。理解这些规则读代码时就能一眼看清人家是想读、想写还是想读改写。3.2 立即数变体和伪指令的使用场景实际写汇编时我们很少直接碰裸指令汇编器提供了大量伪指令比如csrw、csrs、csrc、csrr。csrw本质是csrrw x0, csr, rs1csrs是csrrs x0, csr, rs1csrc是csrrc x0, csr, rs1。这些伪指令把为了满足读旧值的副作用而额外分配目标寄存器的细节藏了起来代码可读性大幅提升。选择立即数版本还是寄存器版本的准则很简单如果你要设置的是固定的某个位比如开全局中断使能MIEbit3直接csrs mstatus, 8一行搞定如果这个位的值来自变量比如从函数参数里决定开哪个中断那就得用寄存器版本。我在写内核切换代码时习惯优先用csrs/csrc因为它在语义上表达了我只改这几个位其他位不要动比先读出来改完再写回去的经典三连更不容易出错。3.3 访问CSR的实战代码套路下面是一段在M模式trap handler开头保存现场的常见写法用mscratch做临时交换区trap_entry: csrrw t0, mscratch, t0 # 交换t0和mscratch腾出t0 csrr t1, mcause # 读异常原因 # 这里是真正的处理逻辑可以用t0存临时值 ... csrrw t0, mscratch, t0 # 换回t0恢复原值 mret这段代码的精髓在于csrrw的原子交换进入handler时t0可能正被主程序用着直接写t0会破坏现场先和mscratch交换就能安全地使用t0退出时再交换回来。这也是mscratch被称为救命的寄存器的原因——它就是为了解决trap入口处没有空闲寄存器的问题而存在的。另外再给一个读改写习惯的建议修改CSR多字段时尽量一次读入、集中修改、一次写回减少原子读改写之间的窗口期。比如改mstatus的全局开关和MPP字段如果你分两次csrs/csrc中间一旦来了中断硬件自动改MPP/MIE后的状态就可能和你的预期打架。别问怎么知道的都是血泪。4. 陷阱处理全链路从触发到返回4.1 trap入口的硬件自动动作陷阱发生时硬件做什么顺序其实非常固定。第一步把异常类型写进mcause如果是中断最高位置1低30位是中断号如果是同步异常只有低30位的原因码第二步把当前PC写进mepc第三步按实际情况把出错地址或非法指令编码写进mtval第四步处理mstatus——把MPIE置成当前MIE的值再清零MIE关掉全局中断把MPP置成当前特权级第五步PC跳转到mtvec指向的地址如果mtvec的MODE是向量模式跳转地址是base加上4倍原因码。这一整套动作是硬件在几个周期内完成的软件只管在入口处接住就行。这里特别提醒一句如果你的设计里trap入口再用软件去读mepc、mcause那么必须保证trap入口本身不会再次触发异常。最常见的翻车姿势是mtvec没对齐——特权规范要求mtvec必须按4字节对齐向量模式还要对齐到4×N写错对齐值进入trap的第一步就fetch异常指令又会触发异常形成死循环现象就是系统不断重启或者PC乱跳。4.2 返回路径mret/sret的细节mret的恢复动作和进入trap时的保存动作严格对称。执行mret时硬件把mepc写回PC把MPP写回特权级把MIE恢复成MPIE的值然后把MPP置为默认值、MPIE置1保证下次进trap时寄存器状态是干净的。sret同理只不过对象是sepc、SPP、SIE、SPIE这一组S模式字段。很多人写操作系统时容易犯一个错处理完异常后没有把mepc往正确的下一条指令上挪。对同步异常来说mepc指向的是触发异常的指令本身比如ecall那条指令。如果你希望异常返回后执行ecall的下一条指令就要在handler里先把mepc加4再mret否则会回到同一条ecall无限循环。对中断来说mepc指向的是被中断打断的那条指令通常不需要调整返回后继续执行即可。这个差异几乎每个写过trap handler的人都踩过。4.3 委托机制与中断性能再往深一层说委托机制的正确配置是系统能不能跑出性能的分水岭。一个没有配置委托的裸S模式系统每个定时器中断都要先切到M模式存一套M模式现场跑完M模式代码再手动转发给S模式S模式处理完再切回M模式恢复光上下文切换的开销就浪费了几百个周期。配置好mideleg把STIP委托给S模式之后定时器中断直接以S模式身份进stvec窗口小一个数量级。还有一点容易被忽略委托之后S模式能看到的mip/sip位才是有效的。也就是说S模式软件清中断挂起位时清的是sip里对应的位而这个位背后和M模式的mip是同一个物理来源。如果硬件不注意这个共享关系可能S模式清了又被M模式重新置起中断风暴就是这么来的。做软核的时候mip和sip在硬件上就应该是对同一组pending位在不同地址暴露的视图而不是两份独立寄存器。5. 自研CPU怎么落地特权架构5.1 最小CSR集合与可选项如果你正在设计一颗RISC-V核不需要一上来就把规范里的CSR全部实现。纯M模式裸机核最少只需要mstatus、mtvec、mepc、mcause这四个加上misa就够跑了要实现完整的中断控制再把mie、mip加上mscratch虽然规范里不是强制但我强烈建议做没有它trap入口写起来会非常别扭。要做S模式支持就得加上medeleg、mideleg、mcounteren以及sstatus、sie、stvec、sepc、scause、stval、satp这一整套。很多团队先把S模式的指令执行和数据路径做出来发现S模式跳进去就黑屏最后定位到是satp没实现导致S模式一开翻译就崩。如果短期内不打算接MMUsatp可以先做成只接受Bare模式的WARL实现写非零模式直接报非法指令至少不会留下开了页表却没人翻译的悬案。5.2 流水线里的CSR冒险与旁路CSR在流水线里最容易出问题的点有两个写后读冒险和trap改写陷阱寄存器。如果你的核是经典五级流水线CSR写通常在WB阶段才落定紧接着下一条指令就读这个CSR就会读到旧值。比较省事的解法是把CSR读操作放到ID阶段CSR写操作尽早提交并对相邻的CSR写后读在ID阶段做一次stall。更激进的设计可以给常用CSR加旁路但这会摊平不少复杂度小核不一定划算。trap改写mepc/mcause的时序更需要小心。当一条指令在EX阶段触发异常而它后面的指令已经预取或者正在WB硬件必须保证异常现场不会被这些在途指令覆盖。比较稳妥的做法是检测到异常后让流水线里所有更年轻的指令全部作废同时阻塞写回mepc/mcause直到WB阶段的最后一条合法指令结束。很多人第一次做异常处理时忽略了这条导致mepc记录的是错误PC排查起来极度痛苦。5.3 mstatus关键位的实现取舍mstatus位很多但很多位在不同配置下有完全不同的实现策略。只跑MU两个特权级的核MPP只需要保存00和11两个值硬件上可以用一个两位寄存器自然实现WARL会把01的写入自动忽略。MPRV、SUM、MXR这三个位只有在实现S模式和MMU之后才有意义没有内存翻译的核完全可以把它们做成只读0省掉一票逻辑。FS、XS、VS这三个扩展状态字段是给浮点、向量扩展做脏状态跟踪的做上下文切换的时候能帮大忙——如果应用没用浮点硬件通过FS字段就能避免保存浮点寄存器。如果核里没有浮点单元FS做成只读0省心。还有UXL和SXL这两个字段RV64的核要用它们记录当前U/S模式是32位还是64位ABIRV32的核就完全不需要直接不做。说得再直白一点RISC-V特权规范的WARL设计哲学本质上就是把哪些功能必须精确实现的选择权交给了CPU设计者。硬件只需要保证写入合法值的读回正确其余的都按最省逻辑的方式处理。理解这个哲学你设计核的时候就知道哪些位可以理直气壮地砍掉哪些位砍了会被软件骂。6. 常见问题与排查实录6.1 只读寄存器写保护的理解偏差新手最常见的误解是只读CSR就是写了被忽略。实际上对地址编码里位[11:10]为11的只读CSR执行写操作直接触发非法指令异常原因码是2而不是静默忽略。这个设计的意图很明确硬件宁可抛异常让软件意识到自己越权也不愿意默许错误继续传播。我调试时就见过有人往mvendorid里清了“警告位”结果系统反复陷入异常循环还以为是中断配置错了。反过来可读可写的CSR如果实现里有只读字段比如mstatus的某些位是WARL只读0写入这些位则会被硬件清理不会异常。这两个机制的区别必须记牢整寄存器只读会报非法指令字段级只读则只是静默归零。6.2 mtvec对齐造成的重启循环mtvec的对齐要求是隐藏杀手。直接模式要求按4字节对齐向量模式要求按4倍cause数对齐因为PC要跳到base4×cause去取指令。如果你的向量表有16个标准入口base就必须按64字节对齐。很多人在链接脚本里没给.vec段加.align编译时正常一上板就复位循环。排查这类问题有个实用的手法在仿真环境里直接在trap入口的第一条指令处拉一个断点看看PC到底跳没跳到mtvec值。如果PC跳过去了但立刻又异常说明入口代码或对齐属性有问题如果PC压根没跳过去说明mtvec写入或者取指路径有问题。我在好几个项目里用这招十分钟就能定位。6.3 中断使能的总开关-分开关陷阱RISC-V的中断使能是分层的mstatus.MIE是M模式总开关mie里的MSIE/MTIE/MEIE是三种中断的分开关mip里的对应位是挂起状态。三者必须都是1中断才真正被响应。很多人只开了mie的定时器位忘了置mstatus.MIE结果定时器计数器在跑中断就是不来。还有一个和委托相关的隐藏坑mideleg委托了STIP给S模式后S模式软件通常会直接操作sip和sie。此时如果你在M模式的handler里错误地清了mip中STIP对应的pending位而实际上这个中断已经被委托、根本不该由你经手就会把S模式正要处理的中断给偷走造成中断丢事件。写多特权级软件的时候一定要分清当前handler到底在执行哪个特权级的逻辑操作对应特权级能看到的pending位。6.4 区分异常类型与定位技巧调试异常处理第一步永远是读mcause。mcause的最高位区分中断和同步异常低30位才是原因码。直观的感觉是中断不是错误是被打断同步异常才是出事了。处理方式因此完全不同——中断处理完直接返回打断点即可同步异常则要考虑mepc是否需要加4、出错地址在mtval里能拿到什么信息。我个人的习惯是在trap入口第一件事就用汇编把mcause、mepc、mtval三个值挪到一块固定的内存区然后在主循环里用调试器盯着看。很多奇怪的崩溃比如U模式访问了S模式的页、S模式写了一个M模式专属CSR光看现象是定位不了的但mcausemtval一组合答案基本就浮出水面了。原因码12、13、15分别是指令缺页、load缺页、store缺页如果你的系统没开MMU却出现这些码说明satp被人非法改过了顺着这条线查就能找到是谁在搞事。再分享一个小技巧调试异常处理时在trap入口处临时把mepc的值打印或者通过gpio输出能快速判断异常是从哪条指令来的。尤其是指令地址不对齐原因码0和非法指令原因码2这两类几乎总能直接定位到出问题的代码行。真到了翻来覆去查不出原因的时候回到这个最基本的入口检查一遍现场往往比追几百行业务逻辑有效得多。