ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RISC-V CSR与特权级详解:从trap链路到CPU设计实践

RISC-V CSR与特权级详解:从trap链路到CPU设计实践

调试一枚自研RISC-V核的时候,我遇到过最诡异的场景:程序跑着跑着突然进了死循环,JTAG一挂上去,发现PC停在一个从未在源码里出现过的地址。查了半天,最后定位到是mtvec寄存器被某段固件写坏了,异常一跳就跳到未初始化的内存里去执行。那一次之后我彻底意识到——搞RISC-V,不管是写软件还是做CPU设计,CSR和特权架构这两块如果只停留在"知道有这回事"的层面,后面调试成本会高到你怀疑人生。

这篇文章是RISC-V专栏的第三篇,专门把CSR(Control and Status Register,控制与状态寄存器)和M/S/U三种特权级掰开揉碎讲清楚。内容包括:为什么硅片里要分出三档特权、CSR地址空间怎么排布、日常开发最常用的CSR速查表、CSR读写指令的原子操作陷阱、一次trap从发生到返回的完整链路,以及站在CPU设计者角度哪些CSR必须做扎实。不管你是在写裸机程序、移植RTOS还是做硬件实现,这篇都能当案头手册用。

1. 为什么RISC-V要把运行环境分成M/S/U三档

1.1 特权级的本质是"信任边界"

RISC-V指令集本身只定义了三种特权模式:Machine(机器模式,简称M)、Supervisor(监管模式,简称S)、User(用户模式,简称U)。此外还有一个可选的VS/VU虚拟化扩展模式,那是后话,先不展开。

这三档的直观理解是:M模式是上帝模式,S模式是内核模式,U模式是普通用户模式。

  • M模式:开机复位的默认模式,能访问所有CSR、所有物理内存、所有外设,异常和中断的最终兜底处理者。固件、bootloader、M态RTOS都跑在这里。
  • S模式:给操作系统内核用的。Linux内核跑在S模式,它需要操控页表(satp寄存器)、处理缺页异常、收发外部中断。S模式能访问S级CSR,但碰不到M级CSR。
  • U模式:跑应用代码。它的所有敏感操作都要通过ecall指令陷入内核,由内核代为完成。U模式连S级的CSR都读不到。

为什么要分档?因为现代操作系统要求"应用崩溃不能拖垮整个系统"。如果没有U模式,应用可以直接改写页表、关中断,一个野指针就能让整机瘫痪。有了特权级之后,应用被关进"隔离屋"里,所有危险操作都必须通过内核审核后才能执行。

1.2 为什么是三级而不是两级

有人问:M模式和S模式都有"高特权"的感觉,区别在哪?为什么不让内核直接跑M模式?

关键差异在虚拟内存管理和可信计算基两个点上。

虚拟内存分页功能(satp寄存器)S模式才有,M模式通常不启用分页。M模式的管理员权限用来保护系统最底层资源——中断控制器、物理内存属性(PMP)、调试接口等。S模式的内核虽然权限很高,但仍在M模式的监管之下,M模式可以通过PMP限制S模式能访问的物理内存区域,这样即使内核被攻破,最底层的安全边界还在。

一句话总结这套分工:M管物理世界,S管虚拟世界,U只管跑应用。

1.3 各模式能力差异速览

能力项M模式S模式U模式
访问M级CSR(mstatus等)可以禁止禁止
访问S级CSR(satp等)可以可以禁止
ecall陷入目标M模式S模式S模式(或M模式)
异常兜底是可配置委托无
PMP访问限制可配置绕过受M模式约束受M/S约束
典型运行内容固件、bootloaderLinux/RTOS内核应用代码

开发裸机程序时如果不计划跑操作系统,完全可以只用M模式跑天下,U/S模式形同虚设。但一旦你开始移植Linux或引入MMU,就必须把S/U模式的CSR通路都打通。

2. CSR的命名游戏:从地址编码看访问权限

2.1 CSR是独立于通用寄存器的"控制面板"

RISC-V的CSR不走通用寄存器文件(x0-x31),而是独立的12位地址空间,最多4096个位置。每个CSR像一个"硬件控制面板",上面排列着各种位域,每个位域对应一个开关、一个状态灯或者一个硬件配置项。

拿日常开发碰得最多的几个来举例:

  • 0x300是mstatus,机器模式的状态寄存器,里面存着全局中断使能、当前特权级、浮点单元状态等核心信息。
  • 0x305是mtvec,异常/中断入口地址,CPU收到trap信号后跳去哪里,全看它。
  • 0x341是mepc,trap发生时自动保存的PC值,mret返回靠它。
  • 0x342是mcause,记录这次trap是什么原因——异常还是中断、具体是哪种。

2.2 地址段划分:看开头的十六进制就知道归属

CSR地址虽然乱,但前缀非常有规律:

  • 0x000~0x0FF:用户级CSR,例如0x001(fflags)、0x002(frm)、0x003(fcsr)。U模式可以直接访问。
  • 0x100~0x1FF:监管级CSR,例如0x105(stvec)、0x180(satp)。S模式才能访问。
  • 0x200~0x2FF:Hypervisor扩展保留区,普通设计用不到。
  • 0x300~0x3FF:机器级CSR,例如0x300(mstatus)、0x305(mtvec)。只有M模式能访问。
  • 0xC00~0xCFF:只读的性能计数和时间类CSR(cycle、time、instret)。U模式只读可访问。

这套地址划分本质上就是特权级的另一层体现:硬件在CSR访问路径上做了等级判定,低特权级访问高等级CSR会直接抛Illegal Instruction异常。所以在写程序时,看到CSR地址范围心里就要有数——这里的寄存器能碰不能碰,不是"好像不能",而是硬件层面写死了。

2.3 机制层面的一个小设计:只读位的秘密

不少CSR里的位域是硬件自动更新的,软件不能改。比如mstatus的SD位(位63)是浮点单元不干净状态,软件写它没意义;mcause的31号位(Interrupt标志)是trap硬件自动写的。

向只读位写入,规范上的处理是"写入被忽略,不影响硬件状态",但具体实现各不相同。有的实现会直接把整个写操作吞掉,有的实现会抛出非法指令异常。我见过一个核,向misa的只读扩展位写1,直接被判定为非法指令,整段初始化代码卡死。所以写CSR之前,一定要先读datasheet确认哪些位是可写的。

3. 核心CSR速查表:先背下这些,再谈调优

3.1 机器级CSR清单

下面这张表是M模式下的核心CSR,做CPU设计和写M态固件的人都绕不开。

地址名称作用关键位/域
0x300mstatus机器状态:中断使能、特权级、浮点状态等MIE(3)、MPIE(7)、MPP(12:11)、FS(14:13)、MPRV(17)、SD(63)
0x301misa指令集扩展信息记录该核支持哪些ISA扩展
0x304mie机器级中断使能开关MSIE(3)、MTIE(7)、MEIE(11)
0x305mtvec机器级trap入口地址低2位为MODE,00直连模式、01向量模式
0x340mscratch机器级临时寄存器通常放trap处理需要的上下文指针
0x341mepctrap发生时保存PC,mret返回目标可读写,写后mret跳此处
0x342mcausetrap原因码bit31为中断标志,低31位为cause code
0x343mtval附加信息:出错地址/非法指令编码缺页时放虚地址,非法指令时放指令本体
0x344mip机器级中断挂起状态MSIP(3)、MTIP(7)、MEIP(11)
0xF11mhartid硬件线程ID多核时区分核编号
0xF12mhartstart?无,规范用的是0xF12 mconfigptr、0xF13 mimpid别记错

mstatus里的MPP是最重要的位域:trap发生时硬件把之前的特权级存进去,mret时恢复到这个特权级。举个例子,U模式触发ecall,硬件进M模式处理,MPP被写成00(U模式),mret返回时CPU就切回U模式继续跑,整个切换过程CPU是"自动记忆"的。

3.2 监管级CSR清单

S级CSR基本是M级的"精简镜像",操作逻辑一致,差别只在特权级。

地址名称作用
0x100sstatusmstatus的S模式可见子集,主要是SIE/SPIE/SPP等
0x104sieS模式中断使能
0x105stvecS模式trap入口地址
0x140sscratchS模式临时寄存器
0x141sepcS模式trap时保存PC
0x142scauseS模式trap原因
0x143stvalS模式附加信息
0x144sipS模式中断挂起
0x180satp页表基址寄存器,MMU开关所在

satp值得单独说:RV64上,63:60位是MODE字段,0表示关闭分页(bare)、8表示Sv39、9表示Sv48;59:44是ASID(地址空间标识符);43:0是物理页号PPN。往satp写值相当于"开MMU"或"切页表",这一步一旦做错,下一条指令取指就可能page fault,属于内核移植里最容易翻车的地方之一。

3.3 用户级CSR与性能计数器

U模式能访问的CSR不多,主要是:

  • 0xC00cycle:处理器周期计数器
  • 0xC01time:墙钟时间计数器(在部分实现中映射到外部mtime)
  • 0xC02instret:已退休指令数
  • 0x001/0x002/0x003:浮点状态寄存器(fflags/frm/fcsr)

这些计数器对性能剖析特别有用。注意cycle和time在某些SMP实现中可能不同步,做性能测试时最好绑核跑。

3.4 mcause异常码速查

mcause的低31位是异常编号,常见的有这些:

编码含义
0指令地址非对齐
1指令访问错误(取指权限不足)
2非法指令
3断点
4Load地址非对齐
5Load访问错误
6Store/AMO地址非对齐
7Store/AMO访问错误
8U模式ecall
9S模式ecall
11M模式ecall
12指令页错误
13Load页错误
15Store页错误

中断时的编码是另一套,31号位为1。M模式定时器中断对应码是7,外部中断是11,软件中断是3。S模式则分别是5、9、1。看到mcause就知道发生了什么,这是trap调试的第一步。

4. CSR指令的读改写陷阱:csrrw/csrrs/csrrc的正确姿势

4.1 三类核心指令的语义差异

CSR不能像普通寄存器那样直接用add、lw操作,必须走专属指令。基础就三条:

  • csrrw rd, csr, rs1:原子地把csr旧值读到rd,再把rs1写入csr。
  • csrrs rd, csr, rs1:原子地把csr旧值读到rd,然后把csr置位rs1中为1的位(读-置位)。
  • csrrc rd, csr, rs1:原子地把csr旧值读到rd,然后把csr清零rs1中为1的位(读-清零)。

简单记忆:W是覆盖写,S是set(置1),C是clear(清0)。

还有对应的立即数版本csrrwi、csrrsi、csrrci,把rs1换成5位立即数zimm,用于写常量。csrr rd, csr则是csrrs rd, csr, x0的伪指令,纯读取。

4.2 常见的三个坑

第一坑:更新CSR某个位域时用了csrrw,把整个CSR覆盖了。

典型例子:想给mtvec切换成向量模式,低2位改成01,但基址高位不能动。正确做法是先csrr读出完整值,改好低2位再csrrw写回。如果直接csrrw mtvec, x1,x1里又只准备了新地址没保留原MODE位,那mtvec的MODE字段就被抹成00,向量中断模式丢失,中断全部跑进直连入口,行为立刻怪起来。

第二坑:清位用了csrrs,置位用了csrrc,方向搞反。

csrrs是"置1",csrrc是"清0"。关中断mie里的某一位,要用csrrc;开中断才用csrrs。我在调试中断时反复遇到"中断使能后就像没开一样"的问题,最后发现是固件把csrrc写成了csrrs——使能位被反复置1,但从来没人清过。这种逻辑错误很难靠看波形发现,因为寄存器值一直是对的。

第三坑:向只写或只读CSR下手。

有的CSR是"写1清除"语义(比如中断挂起mip的某些位),有的CSR是"写什么都无效"。没摸清语义就对mip做读改写,轻则白写,重则触发非法指令。规范允许硬件对只读CSR的写入直接忽略或报Illegal Instruction,你不能假设它一定忽略。

4.3 推荐一个安全写CSR的模式

我自己的习惯是:需要改单个位时,先用csrr读出完整值,按位与/或准备好新值,再用csrrw写回。虽然多了一条指令,但能避免绝大多数"意外覆盖"的问题。如果你追求原子性,用csrrs/csrrc也要先确认目标CSR的所有位都是可控的,不存在硬件自动更新的只读位——比如mstatus里的SD位,如果你用csrrs置位它,行为在实现里可能是"忽略",也可能"报错",这种不确定性不该出现在关键代码里。

5. 一次trap的完整旅程:从异常发生到mret返回

5.1 硬件自动完成的五个动作

当M模式收到中断或异常(统称trap)时,CPU的硬件RTL会按固定顺序做这几件事:

  1. 把mepc保存为当前PC(中断时通常存下一条未执行的指令地址,异常时存触发异常的指令地址)。
  2. 把mcause设置为trap原因。
  3. 把mtval设置成辅助信息(如果有)。
  4. 把当前特权级和中断使能状态备份到mstatus的MPP和MPIE。
  5. 把mstatus.MIE清零(关掉中断),把PC改成mtvec指向的地址。

从这一刻起,PC就跳到你的trap处理程序里去了。处理程序先用csrrw把mepc、mcause、mtval读到通用寄存器,判断原因、保存现场、处理业务,最后一个mret收尾。

mret的返回动作也是硬件自动完成的:

  • mstatus.MIE恢复为MPIE的值;
  • 特权级恢复为MPP保存的值;
  • PC跳转到mepc。

整个过程不需要软件写"恢复特权级"的代码,硬件全包了。这也是RISC-V trap处理比某些架构简洁的原因——切换现场的顺序是标准化写进指令集规范的。

5.2 一张表看懂状态迁移

假设U模式跑应用时发生了一个外部中断,且有中断委托机制把该中断交给S模式处理,那么状态迁移是这样的:

阶段特权级PCmstatus关键位
中断前U应用代码SPP=00, SPIE=0, SIE=1
trap进入Sstvec向量入口SPP被硬件置为00, SPIE=1, SIE清0
内核处理S中断服务函数持续关闭S级中断
sret返回U应用代码(sepc指向)SIE恢复为1, SPP清除

S模式的stvec、sepc、scause和sscratch组合起来,就是一套完整的"内核态trap链路"。如果中断没有委托给S模式,而是一直往M模式走,那上述流程就会落在mtvec、mepc、mcause上,处理完再想办法手动切回原特权级——这也是很多人写RTOS时觉得别扭的地方,一招mret全自动,一切没委托就全手动。

5.3 委托机制:哪些trap该让M模式管

中断委托是S模式跑Linux的关键设计。默认情况下所有异常、中断都在M模式处理,但这样做OS内核就失去了对中断的自主控制权。通过mideleg(中断委托)和medeleg(异常委托)寄存器,M模式可以把一部分trap"下放"到S模式,让S模式自己处理,不再惊动M模式。

典型的委托配置是:把S模式的软件中断、定时器中断、外部中断全部委托给S模式;把ecall from U、指令页错误、Load页错误、Store页错误也委托过去。这样Linux内核才能自己管缺页、管调度。至于M模式的定时器中断,一般留着给固件做安全监控或时间基准。

委托陷阱在于:委托不是"转移",而是"允许S模式直接接管"。如果S模式没有准备好trap处理程序,委托反而会把系统丢进更深的坑。裸机程序没跑OS之前,保持全M模式处理是最稳妥的。

6. CPU设计者视角:哪些CSR必须做扎实,哪些可以偷懒

6.1 最低限度必备的CSR集合

如果你在做一颗自研RISC-V核,第一版RTL至少要保证下面这些CSR行为正确,否则跑不了任何现有工具链:

  • mstatus:中断使能位、MPP、MPIE、FS。这是所有软件的前提。
  • mtvec:trap入口。设计上要考虑直连/向量两种模式是否都支持,向量模式的对齐要求要文档写明。
  • mepc、mcause:trap的"病历本",没有它调试无从谈起。
  • misa:哪怕不全功能实现,也必须准确报告你支持哪些扩展——工具链靠它生成指令,操作系统靠它检测能力。
  • mhartid:多核启动必读。
  • mie/mip:中断使能和挂起标志,注意这两个寄存器某些位是只读镜像,写路线要跟通用寄存器分开。

mscratch和mtval属于"强烈建议"级别。mscratch在M态trap处理里几乎是保命存在——trap发生时通用寄存器全在忙,得先有个地方放原始sp,要么就用mscratch暂存指针。mtval在调试缺页、非法指令时能直接给出出错地址/指令编码,省掉大量猜谜时间。

6.2 哪些功能可以缓一缓

S模式是不是必须做?答案看产品定位。跑裸机控制器的MCU级别芯片,完全可以不做S模式,S级CSR全部返回0或按保留处理,软件只用M模式。这时候satp这类寄存器可以直接省略,省下来的面积和验证成本相当可观。

但如果目标是要跑Linux,S模式逃不掉,而且不止是CSR本身的RTL,你还要额外处理:

  • satp的MMU通路(Sv39至少);
  • 页表walk时产生的page fault异常路径;
  • sret返回时SPP/SPIE的状态恢复;
  • mstatus的SUM/MXR位对S模式访问权限的调节。

这些加起来的工作量,往往比实现M模式本身还要大两三倍。

6.3 硬件实现上的一些建议

我在实现CSR时踩过不少坑,说说经验和教训:

第一,CSR寄存器组最好独立编址、独立写,不要混进通用寄存器堆里做端口复用。通用寄存器堆在高频CPU里本来就是面积和时序大户,再把CSR读写的几百个端口挤进去,后端很容易爆时序。

第二,mtvec/mepc这类"跳转目标"CSR,写入后要有立即生效的组合逻辑路径。也就是说,你写mepc后马上执行mret,RTL必须能在一个周期内把新值送到PC选择器,中间不能有锁存延迟。这类路径是时序收敛的重灾区,设计阶段就要留意。

第三,做仿真验证时,CSR的可观测性一定要预留。我最终加了一个debug接口,能在仿真时把所有CSR的当前值拉出来,否则trap链路一断,你只能翻波形一页一页找mcause到底写了什么。真实项目的经验是:CSR的验证强度应该和其访问频率成正比,像mstatus这种每个trap进出一轮的寄存器,是RTL bug的高发地。

6.4 中断的响应延迟与CSR的关系

中断响应延迟是嵌入式系统设计的大问题。当trap发生时,硬件需要做的工作越多,延迟越大:保存mepc、写mcause、更新mstatus、查mtvec。如果这些操作放在一个周期里做完,频率上不去;如果分多个流水级做,每条中断路径的周期数就变长。

折中方案是把"保存PC"和"取向量地址"并行,mtvec直连模式下进入trap只需要知道一个固定入口,组合逻辑开销小;向量模式需要根据mcause查表算偏移,则要多一点硬件。从这个角度看,mtvec选直连还是向量,不仅是软件风格问题,也影响硬件面积和时序。很多MCU核只做直连模式,把多级中断向量表交给软件查表,就是基于这个考量。

7. 经验之谈:调试器里看不到的坑和"另一个CSR"

7.1 踩过的坑:中断不响应,问题在CSR而不在外设

我调试一块开发板时遇到定时器中断完全不触发的情况。外设寄存器检查了一圈都是对的,定时器在跑,计数在涨,中断挂起位也能置位,但CPU就是不进ISR。

后来用逻辑分析仪抓mtvec写操作,发现固件在某个驱动初始化时,不小心把mtvec的值覆盖成了0。CPU收到中断后,PC跳到了地址0去执行——那里正好放的是复位向量,整颗芯片重新复位。表面看是"中断没响应",实际是"中断被响应了,但跳到错的地方去了"。

这件事给我两个教训:

第一,关键时刻优先读CSR,再查外设。中断链路里,mtvec、mie、mstatus.MIE三个值看一遍,一半以上的中断问题都能暴露。

第二,CSR的写入防护很重要。软件层面可以在每次初始化CSR后做一次回读校验;硬件层面应该支持对关键CSR的写保护寄存器,防止非法写。

7.2 搜索引擎里"另一个CSR"的歧义

最后说一个搜索相关的小提醒。RISC-V领域的CSR指Control and Status Register,但计算机图形学和稀疏矩阵计算里的CSR是Compressed Sparse Row(压缩稀疏行)。你在搜"邻接表和CSR压缩存储的内存空间消耗是同一个量级吗"这个问题时,搜到的CSR其实是稀疏矩阵的存储格式,跟RISC-V的控制状态寄存器没有任何关系。两拨人用同一个缩写,检索时记得把上下文关键词补全,否则翻半天资料发现牛头不对马嘴。

我的个人习惯是搜RISC-V相关问题时,直接用"machine status register"或"CSR RISC-V spec"这样的组合词,能避开大部分歧义内容。

回到技术本身,CSR和M/S/U权限设计是RISC-V区别于老牌架构的鲜明特征之一:它把特权级的切换成本压到最低,把硬件自动状态保存做得极其干净。无论你是做嵌入式应用、写内核还是设计CPU,把这套机制吃透,等于拿到了RISC-V世界里最核心的那把钥匙。下一篇专栏我会继续往深处走,拆解中断控制器和定时器在M/S/U模式下协同工作的完整实现。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进