ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

RISC-V CSR速查指南:从地址编码到特权模式与异常处理

RISC-V CSR速查指南:从地址编码到特权模式与异常处理

1. 先从一次"非法指令"异常说起:CSR速查到底解决了什么问题

去年调试一个自研的RISC-V小核时,我在M模式下写了一段再普通不过的代码,想读一下sstatus看看当前中断状态,结果一跑到那条指令就跳进了非法指令异常处理。当时第一反应是代码写错了,排查了半天才发现问题出在sstatus是S模式的CSR,M模式虽然权限更高,但按特权规范,M模式访问S模式的CSR并不会被自动"降级"放行——该报的异常一个都不会少。从那以后,我就养成了一个习惯:凡是碰RISC-V特权架构,先把CSR速查表和权限位段放在手边。

这篇文章就是给刚接触RISC-V、正在写裸机程序或者在设计CPU的朋友准备的。我尽量把CSR地址分配规律、M/S/U三种特权模式的关系、常见CSR的功能、以及CPU实现时需要留意的细节一次讲清楚。你不需要把数百个CSR全部背下来,但你要掌握"查表"的方法和"看地址就知道特权级"的密码,这样无论是写固件、调系统还是写RTL,都能少踩几个坑。

一个容易混淆的点先说明:RISC-V里的CSR是Control and Status Register(控制状态寄存器),跟图算法里那个CSR(Compressed Sparse Row,压缩稀疏行)完全是两回事。我在查资料的时候看到不少人把这两个名词搞混,本文只聊RISC-V的CSR,图存储的事这里不展开。

1.1 为什么CSR对RISC-V这么重要

RISC-V的指令集本身走的是精简路线,普通运算指令里你几乎看不到复杂的状态位。但CPU要运行操作系统、要响应中断、要管理内存权限,这些都得有地方记录状态和控制行为。CSR就是承担这个职责的寄存器集合。它不像通用寄存器那样可以随意读写,每一个CSR都有明确的地址、权限等级和位域含义。

举个例子,机器模式下处理一个外部中断,硬件会自动修改mtvec、mepc、mcause、mstatus等CSR,中断处理程序则要主动读写这些CSR才能正确保存现场、判断原因、完成返回。这些操作绕不开CSR指令,也绕不开对CSR地址的精确理解。可以说,搞懂CSR的速查方法,就拿到了RISC-V特权架构的钥匙。

1.2 这篇文章适合谁

如果你正准备写一个跑在RISC-V上的最小RTOS,你需要知道M模式和S模式各有哪些CSR、中断委托怎么配。如果你在做乱序流水线或者多核缓存一致性,你至少要知道CSR指令的原子性要求,以及特权检查会在哪个阶段产生异常。即便你只是用现成开发板跑Linux,了解satp和sstatus也能帮你在系统崩溃时更快定位问题。这篇文章默认你已经有RISC-V基础指令集的概念,但我会把CSR相关的每一点都解释清楚,不会让你卡在术语上。

2. 特权架构 M/S/U 的三层权力游戏

RISC-V的特权架构把CPU的运行模式分成三个主要等级:Machine(机器)、Supervisor(监管)、User(用户),也就是标题里的M/S/U。这三个字母几乎出现在所有RISC-V处理器手册里,但它们到底怎么分工、怎么切换,很多人只是囫囵吞枣。我这里用一个日常类比来拆开讲。

2.1 三种模式各自管什么

M模式是权力最大的模式,也是处理器复位后默认进入的模式。它可以直接访问所有CSR、所有内存地址,也可以配置物理内存保护(PMP)。在简单的嵌入式项目里,整个程序都跑在M模式,不需要分S和U,这也是最省事的方式。

S模式是给操作系统内核用的。Linux内核、FreeRTOS的SMP版本如果要跑在RISC-V上,通常就在S模式。S模式比M模式低一级,它不能随便动M模式的CSR,但可以通过ecall或者中断请求让M模式的固件帮忙处理。U模式权限最低,用户的应用程序跑在这里。普通应用想要操作硬件、访问受保护的内存,只能通过系统调用进入S或M模式完成。

这三个层级不是拍脑袋定的,而是为了满足"隔离"这个核心需求。比如一个在U模式跑的程序因为漏洞崩溃了,它不应该能把整个操作系统拖垮。S模式做进程切换、虚拟内存管理,M模式则负责真正的固件安全和硬件初始化。层级越往下,权限越大,但代码量也越少,这正好符合安全设计里"最可信的代码放最底层"的原则。

2.2 模式切换:ecall、mret、sret与Trap的委托

模式不是你想切就切,硬件会通过一套机制保证切换受控。最典型的是ecall指令,U模式程序执行ecall会主动发起一个异常,根据异常委托配置,这个异常可能直接进S模式的内核处理程序,也可能进M模式的固件。mret是M模式专用返回指令,执行后硬件从mepc恢复PC,从mstatus的MPP字段恢复之前的特权模式,同时恢复中断使能状态。sret则是S模式的返回指令,逻辑类似,只不过用的是sepc和sstatus。

这里有个容易误解的地方:M模式虽然权限最高,但并不是所有异常都必须进M模式。RISC-V提供了一张"委托表",也就是medeleg和mideleg这两个CSR,它们的每一位对应某一种异常或中断。如果对应位写1,表示这个异常在产生时会被直接送到S模式处理,M模式的软件就可以做到"平时不管、只需要兜底硬件错误"。这种设计让简单系统可以只用M模式跑裸机,复杂系统可以把中断和系统调用大量委托给S模式的Linux内核,M模式只当最底层的安全固件。

2.3 为什么不是只保留M模式

你可能会问:既然M模式什么都能干,那直接所有代码全放M模式不就行了?对功能简单的单片机来说确实可以,但一旦跑多任务、要保护内存地址、要做进程隔离,就必须要低特权级的模式来限制应用程序的权限。没有U模式,用户程序一旦出错就可能把内核数据踩烂;没有S模式,每一次系统调用都要穿透到M模式,性能上浪费太大。M/S/U三层正好在安全、性能、灵活之间取得了平衡。很多入门级RISC-V核为了省面积,只实现了M模式,也没有问题,但如果你想在上面跑Linux,S模式和U模式就变成刚需,甚至还需要额外的虚拟化扩展V模式,那是后话。

3. CSR地址编码规律的"查表密码"

CSR数量多、名字杂,背下来不现实。好在RISC-V规范给出了非常工整的地址编码规则,你只要看懂12位CSR地址里哪几位代表什么意思,就可以在没见过某个CSR的情况下快速推断出它的基本属性。

3.1 12位地址的字段拆解

每个CSR地址是12位,也就是0x000到0xFFF,最多4096个地址。规范按照以下方式划分这12位:

  • 第11、10位([11:10]):表示可读写属性。值为2’b11时,该CSR是只读的;值为2’b00、2’b01或2’b10时,是可读写的。
  • 第9、8位([9:8]):表示能访问该CSR的最低特权级。2’b00代表U模式,2’b01代表S模式,2’b10是保留值,2’b11代表M模式。
  • 第7到0位([7:0]):CSR在对应特权级内的编号。

举个例子,mstatus地址是0x300,二进制就是0011_0000_0000,可以看到[11:10]是00,所以可读写;[9:8]是11,所以最低访问特权是M模式。cycle地址是0xC00,二进制1100_0000_0000,[11:10]是11,所以用户只读;[9:8]是00,所以U模式就能访问。这个规律几乎覆盖了所有基础CSR。需要注意,某些保留地址或者组合CSR(比如PMP的pmpcfg0~3)在低8位的编号是连续的,用的时候按表查一眼最保险。

3.2 按特权级整理的常用CSR速查表

我自己调试时最常用的是下面这几张表。建议你保存下来,或者打印出来贴在显示器旁边。

地址名称作用
0x300mstatus机器状态,包含全局中断使能、特权级切换信息、浮点/扩展状态
0x301misa机器指令集和扩展配置,按位表示支持的扩展
0x302medeleg异常委托寄存器,决定哪些同步异常交给S模式
0x303mideleg中断委托寄存器,决定哪些中断交给S模式
0x304mie机器中断使能,控制外部、软件、定时器中断开关
0x305mtvec机器陷阱向量,异常入口地址和模式
0x306mcounteren机器计数器使能,控制U模式能否访问性能计数器
0x340mscratch机器模式临时寄存器,通常用于保存上下文指针
0x341mepc机器异常PC,异常发生时保存出错的指令地址
0x342mcause机器异常原因,编码异常类型和中断标志
0x343mtval机器异常附加信息,比如访存地址、非法指令编码
0x344mip机器中断挂起,反映当前哪些中断正在等待处理
0x3A0pmpcfg0物理内存保护配置0
0x3A1pmpaddr0物理内存保护地址0
0xB00mcycle机器模式周期计数器,可读写
0xB02minstret机器模式已退休指令计数,可读写
0xF14mhartid硬件线程ID,只读,用于多核启动

S模式常用CSR:

地址名称作用
0x100sstatus监管状态,是mstatus中S模式可见位的一个子集
0x104sie监管中断使能
0x105stvec监管陷阱向量
0x106scounteren监管计数器使能
0x140sscratch监管临时寄存器
0x141sepc监管异常PC
0x142scause监管异常原因
0x143stval监管异常附加信息
0x144sip监管中断挂起
0x180satp监管地址翻译和保护,存页表根地址和模式

U模式常用CSR:

地址名称作用
0x000ustatus用户状态,部分实现可选
0x001fflags浮点异常标志
0x002frm浮点舍入模式
0x003fcsr浮点控制状态,合并fflags和frm
0xC00cycle用户只读周期计数
0xC01time用户只读实时时钟
0xC02instret用户只读指令计数
0xC80cycleh高32位周期计数(RV32)
0xC81timeh高32位实时时钟(RV32)
0xC82instreth高32位指令计数(RV32)

看到规律没有?mcycle和cycle在物理上是同一个计数器,但M模式的版本可写,用户可以读的版本是只读的。这种"同一个计数器,两套地址"的做法在RISC-V里很常见,目的是让用户程序能安全读取而不破坏系统计数器。

3.3 怎样不看表也能快速判断一个CSR能不能碰

如果编译器的汇编代码里出现一个奇怪的CSR地址,你可以用下面这个简单的逻辑判断:

address = 0x341 read_only = ((address >> 10) & 0x3) == 0x3 min_privilege = (address >> 8) & 0x3 # 0x0=U, 0x1=S, 0x3=M def privilege_enough(current_priv): # current_priv: 0=U, 1=S, 3=M return current_priv >= min_privilege if min_privilege != 2 else False

这个判断只是入门级"密码本",实际规范里还有一些组合规则和扩展规则,但对绝大多数M/S/U中央CSR都适用。我在写RTL检查逻辑时,就是把这个判断直接组合到CSR访问单元里,省掉了硬编码几百个CSR地址的体力活。至于保留地址2’b10,并不是说完全不能用,而是标准里留出来给未来的扩展,普通实现里碰到这个值就不会分配CSR。

4. 五条CSR指令:读写、置位、清位的原子操作

有了地址,还得有访问CSR的指令。RISC-V提供了一整套CSR操作指令,它们都属于SYSTEM大类,opcode是1110011。很多第一次接触的人会觉得CSR指令跟普通访存指令差不多,其实它们更像"寄存器+状态位"的组合操作,而且每条指令都带原子性要求。

4.1 指令格式和每条指令的真实用途

CSR指令使用I型格式:

  • opcode:1110011(SYSTEM)
  • funct3: 区分不同的CSR操作
  • rd: 目标寄存器,通常回写旧值
  • rs1: 源寄存器,或立即数版本中作为零扩展的5位立即数
  • csr[11:0]: 12位CSR地址

最常用的五条(实际是六条)如下:

指令funct3行为典型用途
CSRRW001先读CSR旧值到rd,再把rs1的值写入CSR无副作用地修改CSR整体
CSRRS010读CSR旧值到rd,将rs1中为1的位在CSR中置1打开某个中断使能位
CSRRC011读CSR旧值到rd,将rs1中为1的位在CSR中清0关闭某个中断使能位
CSRRWI101类似CSRRW,但写入的是5位立即数零扩展后的值写立即数到CSR
CSRRSI110类似CSRRS,但使用立即数快速打开某一个开关
CSRRCI111类似CSRRC,但使用立即数快速关闭某一个开关

读CSR需要小心副作用。比如读取mcycle这样的计数器不会有什么问题,但有些CSR会在读取时锁存状态或者引发副作用。所以规范里特别做了两件事:第一,CSRRW如果rd=x0,指令不会去读CSR,只会写入;第二,CSRRS和CSRRC如果rs1=x0,指令不会去写CSR,只会读取。这样一来,csrrw zero, csr, t0就成了纯写CSR,csrrs t0, csr, zero就成了纯读CSR。

4.2 "读-改-写"原子性为什么重要

CSRRS和CSRRC是典型的读-改-写操作:硬件要把CSR旧值读出来,跟源操作数做按位或/按位与,再写回去。如果这个操作被中断打断,中间状态就会丢失。比如你正在用一条csrrc关闭mie里的定时器中断位,结果刚读出旧值还没来得及写回,定时器中断就触发了,处理程序一看mie发现没关掉,机器就乱套了。所以规范要求CSR指令的执行必须是原子的,也就是说,从软件视角看,这条指令不能被其他异常或中断"劈开"。对单发射、单周期处理器来说,这很容易实现,只要在同一个周期内完成读和写就行;对乱序处理器,就要保证CSR指令不会被重排到其他会改CSR的指令中间。

4.3 CPU设计中的译码要点

如果你在做CPU设计,CSR指令的译码并不复杂,只需要在ID阶段判断opcode == 7'b1110011且funct3落在上述六个值之一。CSR寄存器堆可以单独做一个模块,地址线来自指令的csr[11:0]。执行阶段需要产生三个关键信号:

  • csr_wen:是否写CSR。对CSRRW/CSRRWI由rd是否x0决定?实际上CSRRW写是由rs1存在决定?准确说CSRRW只要执行就写CSR,rd=x0只是不读。对CSRRS/CSRRC如果rs1=x0则不写。CSRRSI/CSRRCI如果立即数为0则不写。
  • csr_read_en:是否读CSR。对CSRRW且rd=x0时不读;对CSRRS/CSRRC且rs1!=x0时读;对纯读场景rs1=x0时读。
  • csr_op:选择写值来源。CSRRW用rs1原值,CSRRS用旧值|rs1,CSRRC用旧值&~rs1。

权限检查和只读检查要放在写通道上:如果当前特权级低于CSR地址[9:8]要求的最低特权级,或者对只读CSR执行写操作,就要触发非法指令异常。很多初学者以为权限不够时直接忽略写操作就行,这是错的。规范明确要求必须产生异常,否则软件无法感知自己的错误操作。

5. 常见Trap路径上的CSR联动:从异常到返回

CSR不是一堆孤立的寄存器,它们在异常处理这条链路上是紧密联动的。搞懂这些联动关系,写中断处理程序才能不出错。我挑最常见的M模式陷阱路径来拆解。

5.1 异常发生时硬件自动做的几件事

当M模式下发生一个同步异常(比如访存错误、非法指令)或者中断,硬件会按照下面的顺序更新CSR:

  1. 把当前指令的PC保存到mepc(如果中断可能保存下一条待执行PC,具体按中断类型有所不同,但大体上是相关PC)。
  2. 把异常或中断类型编码写入mcause。最高位表示这是中断还是异常,低若干位是具体原因号。
  3. 如果有附加信息,比如非法地址、非法指令编码,保存到mtval。
  4. 把mstatus中的MPIE设置为当前的MIE,然后把MIE清零,这样进入异常处理程序后不会立刻被同一级中断打断。
  5. 把之前运行的特权级保存到mstatus.MPP。
  6. 跳转到mtvec指向的异常入口地址。

每一步都不能少。mtvec在设置时要注意对齐要求:支持压缩指令C扩展时,入口地址需要2字节对齐;不支持C扩展时,要求4字节对齐。写固件的人经常忽略mtvec的低位模式配置,导致跳转后PC不对齐,白出一次异常。

5.2 mret恢复现场的原理

异常处理完,用mret返回。硬件会:

  • 用mepc的值覆盖PC;
  • 把mstatus.MPIE恢复回MIE;
  • 把当前特权级恢复到mstatus.MPP保存的值;
  • 把MPP置为U模式(如果U模式实现)或M模式(如果只有M模式)。

从这里就能看出mstatus里MPP和MPIE这两个位域有多关键。如果异常处理程序里不小心改写了mstatus的这两个位,返回时CPU会跑到一个未知特权级,整个系统直接崩溃。我建议软件在保存和恢复mstatus时,把低位的MIE/MPIE/MPP连同其他状态位一起整体保存,不要只保存一部分。

5.3 S模式的trap流程和M模式的区别

S模式和M模式的路径几乎一一对应,只是寄存器换成了sepc、scause、stval、stvec、sstatus。差别在于,当异常要进入S模式时,sstatus里没有MPP,而是SPP表示先前的特权级是S还是U;同时sie、sip、sstatus.SIE这些位域比M模式小。很多人从M模式裸机编程转到S模式时,习惯性地读写mstatus的位,结果在S模式下访问M模式CSR直接触发非法指令异常,或者读了mstatus被权限检查拦住,一头雾水。

委托也会影响trap路径。如果U模式的ecall被委托给了S模式,硬件不会跳进M模式的mtvec,而是直接进入S模式的stvec。这时mcause、mepc这些M模式CSR根本不会被自动更新。你如果还在中断处理程序里读取mepc来定位错误指令,拿到的一定是上一次M模式异常留下的残留值,非常容易误判。正确做法是:在入口先用mcause区分这次异常是M模式还是S模式处理,再读对应模式的*epc和*cause。

6. CPU设计中CSR单元的实现要点与避坑清单

如果你不只是写软件,而是要动手把CSR单元写进RTL,这部分请仔细看。我在设计一个小型RISC-V核时,CSR单元是改动最频繁的模块之一,原因就是它跟异常、中断、特权级、调试功能全都交织在一起。

6.1 寄存器堆还是散布逻辑

CSR的物理实现有两种常见选择。第一种是做一个独立的CSR寄存器堆,用地址译码访问,优点是读写路径统一、便于添加调试接口,缺点是很多CSR位域有不同的硬件行为,比如mtvec的BASE自然对齐、misa的WARL字段,你得在寄存器堆旁边加一堆组合逻辑。第二种是把CSR分散到各个功能模块里,比如mtvec放在取指单元附近,mepc放在异常处理单元里,优点是位域行为就近实现,缺点是CSR指令访问时要跨模块传递地址和数据,总线连接复杂。

对大多数入门级CPU,我推荐先做统一的CSR寄存器堆。你可以把每个CSR定义成一个内部结构体,里面区分只读位和读写位,然后在写端口用assign映射到实际功能逻辑。这样至少CSR指令的时序和权限检查都集中在一个地方,出了问题好查。等性能要求上来、要支持多发射或多核时,再考虑把热点CSR分散到流水线各级并加入旁路。

6.2 特权检查与非法指令异常的产生

CSR访问的权限检查不是可选的。每个访问CSR的指令,在执行阶段都要做以下几个判断:

  • 当前特权级是否不小于CSR地址[9:8]表示的最低特权级;
  • 如果是写操作,目标CSR是否只读;
  • 目标CSR是否真的被实现了。

三条里任意一条失败,都要抛出非法指令异常。实际芯片上,如果读一个未实现的CSR,很多规范描述会说"行为可能未定义"?不对,标准规定访问未实现的CSR会引发非法指令异常。所以CSR寄存器堆的译码逻辑必须能识别出"这个地址对应哪个CSR"以及"这个CSR是否存在"。我见过一个工程团队为了省事,把未实现地址的读返回固定为0、写直接忽略,结果跑Linux时内核一访问预留CSR没收到异常,还以为硬件支持某个扩展,后面行为完全错乱。宁可拍异常,也别静默吞掉。

6.3 与流水线的耦合:结果旁路和中断优先级

CSR指令的结果要写回通用寄存器rd,所以它和普通ALU指令一样要经过写回段。流水线里尤其要处理CSR读数据的前递:如果前一条CSRRS的结果还没写回,后一条算术指令就要读rd,旁路逻辑不能漏掉CSR的写回端口。很多人在做旁路网络时只关注ALU的写回,结果CSR指令一跟其他指令挨着就出数据冒险,查起来非常隐蔽。

另一个容易忽视的判断是"CSR写"和"异常入口更新"同时发生时的优先级。规范对某些情况有明确定义,但简单实现里最常见的选择是:先让异常改写mepc、mcause等CSR,再让流水线里已经通过执行的CSR指令完成写回?这可能会覆盖异常硬件写入的值。更稳妥的做法是在ID阶段发现当前指令本身要触发异常时,就阻断它的CSR写,让异常逻辑在一个专门的周期写入trap CSR。我自己喜欢把CSR写使能和trap更新分开两个写端口,trap更新的优先级更高,这样软件看到trap CSR一定是最新的。

6.4 一些实战层面的碎碎念

最后补充几条我自己踩出来的经验,普通文档里很少写。

第一,misa是WARL字段,不是你想写什么就能读什么。规范允许实现只支持一部分指令扩展,其他位硬连线为0。软件探测扩展时要逐位试探,别直接读misa就以为所有扩展都可用。

第二,PMP(物理内存保护)的CSR数量取决于硬件实现。pmpcfg0之后可能是pmpcfg1、pmpcfg2甚至更多,读取顺序要按PMPCNT配置来,不要想当然认为只有16个PMP项。

第三,调试模式CSR不占用普通CSR空间?实际上调试CSR如dcsr使用了0x7B0等地址,但它们是Debug模式专属的,M/S/U模式下访问会触发非法指令。如果你的核不带调试模块,也要记得把这些地址保留成未实现,别误分配。

7. 一张我带在身边的速查习惯

很多初学者会问我,CSR速查到底怎么速查?我的习惯是三层递进:先看地址高低位判断权限和读写性,再按特权级找到对应的表格,最后再确认寄存器里的位域。位域部分需要查规范正文,因为不同扩展会往同一个CSR里塞不同的位。比如mstatus里有FS、VS、XS这些扩展状态位,它们可能占多个位,写0和写1的含义都不同。

我会建议你写一个简单的CSR访问头文件,把常用的CSR地址和位域宏定义好,而不是寄希望于手写二进制。还有一个小技巧:仿真时在CSR写端口加一个monitor,一旦发生对只读CSR的写操作就打印警告。这在早期调试CPU时能帮你瞬间定位到是哪条指令在乱来。

RISC-V的CSR体系初看很庞杂,但它的编码规律和M/S/U层级设计非常工整。掌握了本章这套速查方法和trap联动关系,你就能在写裸机、调内核、设计CPU之间从容切换。我自己从那次sstatus的非法指令异常开始,花了小半个月把CSR相关代码重新整理了一遍,之后再没被这类问题卡过。希望这篇专栏也能帮你把零散的CSR知识点串成一张可以随手查的网。

RELATED READING

延伸阅读

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