☰
RISC-V特权架构与CSR详解:从M/S/U模式到中断处理
2026/10/4 14:15:12 网站建设 项目流程

做RISC-V最低层开发,说到底就两件事:搞清楚当前跑在哪个特权级,搞清楚这个特权级能碰哪些CSR。我在专栏前两篇聊过指令集基础和通用寄存器布局,这次趁着整理项目笔记,把M/S/U三态特权架构和常用CSR一次性讲透。不管是做CPU设计、写RTOS还是纯想弄明白Linux底层在干什么,这篇文章都能当工具书用,建议收藏。

CSR(Control and Status Register,控制状态寄存器)是RISC-V里最特殊的一类寄存器,它不在通用寄存器组里,也不在内存地址空间里,而是走独立的专用指令访问。很多刚上手的朋友在这里栽跟头:以为是普通寄存器直接读写,结果编译出来的代码一跑就非法指令异常;或者中断响应后返回地址没保存好,整个程序直接跑飞。这些问题的共同根源,都是对特权架构和CSR规则理解不到位。下面我从特权级的本质开始,一路拆到每个常用寄存器的位定义和陷阱处理链路。

1. 三种特权级到底解决了什么问题

1.1 没有特权级的世界:一损俱损

想象一个没有任何权限隔离的单片机程序:应用代码可以直接改写定时器寄存器、可以随意跳转到任意地址、甚至把整个Flash擦掉。单任务裸机时代这勉强能忍,因为出问题大家一起死。可一旦跑多任务操作系统,场景就变了——你不想让用户程序通过一个野指针把内核的数据区全部踩烂,更不想让某个崩溃的任务把整个系统拖下水。

特权级就是给"谁有资格做什么"划了一条线。RISC-V定义的M/S/U三级模式,本质上是一套硬件强制执行的信任分级:M代表机器模式(Machine),权限最高,系统上电后最先运行;S代表监管模式(Supervisor),主要给操作系统内核用;U代表用户模式(User),跑普通应用程序。权限低的不能直接干高权限才能干的事,例如U模式程序想切换页表,硬件会直接拒绝并产生异常。

1.2 数值约定与权限边界

特权级的数值编码很简单:U=0,S=1,M=3。你没看错,中间没有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(监管模式陷阱向量基址)地址是0x105,S和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、satp
0x200-0x2FF保留(H模式历史遗留)无标准定义
0x300-0x3FFM模式mstatus、mtvec、mepc、mcause
0xC00-0xCFFU模式(只读)cycle、time、instret
0xF00-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:所有开关的总闸

mstatus(Machine Status Register,机器状态寄存器)绝对是你第一个要背熟的寄存器。它管着三件大事:全局中断开关、进入陷阱前的状态保存、以及浮点/扩展单元的状态跟踪。

RV64下mstatus的位布局大致如下:

位段名称作用
[63]SD扩展状态汇总位,只读,等于FS或XS为脏时的标志
[16:15]XS用户自定义扩展状态(Off/Initial/Clean/Dirty)
[14:13]FS浮点单元状态
[12:11]MPP进入陷阱前的特权级编码(M=3,S=1,U=0)
[10]VS向量扩展状态,和FS/XS类似
[8]SPP进入S模式陷阱前的特权级(0=U,1=S)
[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、mtval

mtvec(Machine Trap Vector,机器陷阱向量)决定异常/中断发生后CPU跳去哪里。低2位是模式:0表示Direct模式,所有陷阱都跳到一个固定入口;1表示Vectored模式,中断会按cause值偏移跳转(基址+4×cause)。高62位是基址,要求4字节对齐。

mepc(Machine Exception PC,机器异常PC)保存的是"触发陷阱的那条指令地址"。注意,它保存的不是下一条指令,而是当前指令!返回的时候mret会把这个值填回PC。Trap处理完,软件要根据异常类型决定是重新执行这条指令,还是用mepc加4跳过去。在支持压缩指令(RVC)的核上,mepc的地址可能只按2字节对齐,设计和验证时别写死4字节对齐。

mcause(Machine Cause,机器异常原因)的上一位区分中断和异常:最高位为1表示中断,为0表示异常。常见的异常码和中断码有这些:

编码类型含义
0异常指令地址未对齐
1异常指令访问异常
2异常非法指令
3异常断点(ebreak)
4异常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模式外部中断

mtval(Machine Trap Value,机器陷阱值)是可选的辅助寄存器,用来携带地址相关异常的出错地址,或者非法指令的指令编码。软件调试的时候很有用,但注意规范没有强制要求实现,做移植前先查手册确认。

3.3 中断使能的双层开关:mie、mip、mstatus.MIE

RISC-V的中断使能是两层结构:局部使能位(mie)和全局中断位(mstatus.MIE)。局部负责细粒度地选通"定时器中断、软件中断、外部中断"这些具体源,全局负责总闸门。CPU要响应某个中断,两个条件必须同时满足:

  • mie中对应的局部使能位为1,并且mip中对应的挂起位为1;
  • mstatus.MIE为1。

这种双层设计在RTOS里很实用:系统进入临界区时只清全局位就能屏蔽所有中断;想单独屏蔽定时器又放行外部中断,就操作局部位。

mie和mip的位布局几乎一一对应:

位中断源
bit 3机器软件中断(MSI)
bit 7机器定时器中断(MTI)
bit 11机器外部中断(MEI)
bit 1监管软件中断(SSI)
bit 5监管定时器中断(STI)
bit 9监管外部中断(SEI)

mip是只读的,里面反映的是中断条件是否满足;软件的"写"操作实际走的是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. 监管模式与内存虚拟化核心CSR

4.1 S模式存在的意义

S模式是跑Linux这类现代操作系统的地基。它的核心职责有两块:一是提供独立的陷阱处理流程(stvec/sepc/scause这套),让内核能安全地接管异常;二是通过satp寄存器控制虚拟地址到物理地址的翻译,也就是页表。

为什么页表这套必须放在S模式而不是M模式?因为M模式的特权太高,如果操作系统跑在M模式,一旦用户程序触发异常,所有处理都要经过M模式代码,虚拟机管理器或者安全固件就很难做隔离和审计。S模式夹在中间,既能做虚拟内存管理,又不至于把所有机器资源直接暴露给内核。

4.2 satp:页表翻译的入口

satp(Supervisor Address Translation and Protection,监管地址翻译与保护)是S模式最重要的寄存器。RV64下它的布局:

位段名称说明
[63:60]MODE翻译模式:0=Bare(不翻译),8=Sv39(三级页表),9=Sv48(四级页表)
[59:44]ASID地址空间标识符,用于TLB隔离,切换进程时用它区分缓存
[43:0]PPN根页表的物理页号

切换进程的时候,软件把新的根页表物理页号写进satp,然后必须执行SFENCE.VMA指令刷掉TLB里旧的翻译缓存。很多初学Linux移植的朋友忘了这一步,结果进程切出去再切回来,访问的还是旧地址映射,出现一堆诡异的内存错乱。

MODE字段的细节值得留意:Bare模式(0)意味着CPU不做任何翻译,物理地址直接使用。固件阶段通常跑Bare,等操作系统准备好页表再切Sv39。另外,mstatus里的TVM位如果置1,S模式代码访问satp会直接触发非法指令异常——这是安全固件用来"没收"S模式改页表权限的手段。

页表项本身也有权限位:V(有效)、R/W/X(读/写/执行)、U(用户可访问)、G(全局映射)、A(已访问)、D(已脏)。S模式想访问U类型页面,必须在mstatus里置上SUM位,否则一碰就页错误。这个设计隔离了内核和用户数据,但也经常让内核开发者困惑——为什么我明明给了权限还是page fault?多半是SUM忘了开。

4.3 陷阱委托:medeleg/mideleg

默认情况下,所有异常和中断都会进M模式处理。但Linux内核希望把用户程序的系统调用和普通中断直接在S模式处理,不要每次都在固件和内核之间来回切。这就需要委托机制。

medeleg(Machine Exception Delegation,机器异常委托)和mideleg(Machine Interrupt Delegation,机器中断委托)的每一位对应一种异常码或中断码,置1表示"这类陷阱交给S模式的stvec处理"。例如:

  • medeleg[8] = 1:ecall from U的异常直接进S模式,不用先惊动M模式。
  • mideleg[5] = 1:S模式的定时器中断在S模式处理。
  • 但是M模式自己的定时器中断(编码7)不能委托给S——硬件上直接规定这部分位只读为0。

委托不会改变陷阱发生的根本触发条件,只是改变了"跳谁":被委托的陷阱自动跳stvec,没被委托的跳mtvec。我在调试系统调用的时候发现一个常见误区:以为ecall从U发出就直接走stvec,其实要先看medeleg[8]有没有置1。很多固件默认全0,那就所有陷阱都先绕一圈M模式。

4.4 S模式CSR速查表

寄存器地址访问核心作用
sstatus0x100RWS模式全局中断控制,是mstatus的裁剪视图
sie0x104RWS模式中断局部使能(对应SSI/STI/SEI)
stvec0x105RWS模式陷阱向量,MODE和BASE定义同mtvec
sscratch0x140RWS模式陷阱临时寄存器
sepc0x141RWS模式异常PC
scause0x142RWS模式异常原因
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系统调用"举例,完整链路是:

  1. 用户程序把调用号放进a7,参数放进a0-a5,执行ecall。
  2. 硬件原子地:把当前PC保存到sepc(如果medeleg[8]=1,否则是mepc),把sstatus.SIE存到SPIE,清零SIE,把SPP设为U,跳转到stvec。
  3. trap handler开场:用sscratch交换出通用寄存器的原始值,保存现场,然后根据scause判断是ecall,从a7取调用号。
  4. 内核执行read逻辑。
  5. 返回前恢复现场,执行sret。硬件恢复PC=sepc、特权级恢复为U、SIE=SPIE。

整个过程中硬件只做了"存档、关中断、跳转"三件套,现场保存(寄存器压栈)完全是软件干的。这就是RISC-V的取舍:硬件尽量少干活,给软件留足自由,代价是trap handler的编写比ARM的硬件自动压栈更繁琐,但换来的是更低的硬件复杂度。

6. 陷阱处理的硬件行为与中断使能细节

6.1 硬件自动做的四件事

不管是异常还是中断,不管在哪个特权级,硬件进入陷阱时自动做的工作高度统一。以M模式为例,触发陷阱时硬件会:

  1. 把当前PC写入mepc。
  2. 把mstatus.MIE的值存入MPIE。
  3. 清零MIE,同时根据当前特权级更新MPP。
  4. 根据陷阱类型和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里第一条指令写在了mtvec+1的地方——mtvec的低两位被配置成了非零值,而我把整个基址当成了"直接写入地址"。

mtvec的BASE必须4字节对齐(启用RVC后是2字节),低两位是MODE。正确写法:

la t0, trap_entry andi t0, t0, ~0x3 # 对齐到4字节边界 csrw mtvec, t0 # 写入时已是mod=0直接模式

还有个配置顺序问题:先配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特权规范里自动抓取寄存器列表生成头文件,排查问题的时候先查表后查文档,效率高了一大截。现在这套表就是你在上面看到的那几张表格,希望能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询