1. 为什么每个RISC-V开发者都绕不开CSR和特权架构
搞RISC-V开发的人,迟早会撞上CSR和特权架构这堵墙。你写裸机程序要配中断,得碰CSR;你移植RTOS,得理解M/S/U三级权限;你调启动代码,得搞清楚复位后CPU到底在哪个模式跑。这些东西不像GPIO、UART那样直观,手册里散落在不同章节,看一遍记不住,用的时候又得翻半天。我自己刚开始搞RISC-V那阵子,最头疼的就是记不住哪个CSR是干嘛的、哪个位控制什么功能、M态和S态之间到底怎么切换。后来项目做多了,慢慢整理出一套自己的速查方法,这篇文章就是把这套东西系统化地分享出来。
这篇文章面向的读者很明确:正在做RISC-V裸机开发、RTOS移植、或者Bootloader编写的工程师。如果你刚开始接触RISC-V,还在点灯阶段,这篇文章可以帮你提前建立特权架构的认知框架;如果你已经有一定经验,但CSR寄存器总是记不全、M/S/U切换逻辑总是理不清,那这篇文章就是给你准备的速查手册和实操指南。我会从CSR的基本概念讲起,把M/S/U三级特权架构的设计逻辑拆开,然后逐个讲清楚最常用的CSR寄存器,最后给出实际的配置代码和踩坑经验。整篇内容基于RISC-V特权架构规范,结合我在实际项目中的使用经验,尽量做到看完就能用。
2. CSR与特权架构的核心设计逻辑
2.1 CSR到底是什么,为什么不能用普通内存访问
CSR全称Control and Status Register,控制状态寄存器。你可以把它理解成CPU内部的“配置面板”——CPU运行时的各种行为,比如中断使能、异常入口地址、当前特权级别、性能计数器,全都靠CSR来控制。它跟通用寄存器(x0-x31)不一样,通用寄存器是拿来算数的,CSR是拿来控制CPU行为的。
那为什么不把CSR映射到内存地址空间,像操作普通外设寄存器那样访问呢?原因有几个。第一是速度,CSR的读写需要和流水线紧密配合,比如修改mstatus后,后续指令的执行行为可能立刻改变,如果走总线访问内存映射寄存器,延迟太大,流水线没法处理。第二是权限,CSR的访问必须和当前特权级别绑定,M态能访问的CSR,U态绝对不能碰,这种检查必须在译码阶段完成,不能等到访存阶段。第三是原子性,CSR的读-改-写操作需要保证原子性,CSRRS、CSRRC这类指令就是为此设计的,如果走内存映射,还得额外加锁。
所以RISC-V定义了专门的CSR访问指令:CSRRW(读-写)、CSRRS(读-置位)、CSRRC(读-清位)、CSRRWI/CSRRSI/CSRRCI(立即数版本)。这些指令的编码里直接包含了CSR地址(12位,最多4096个CSR)和源/目标寄存器。硬件在执行这些指令时,会根据当前特权级别和CSR地址做权限检查,不通过就触发非法指令异常。
2.2 M/S/U三级特权架构的设计哲学
RISC-V的特权架构分三个级别:Machine(M)、Supervisor(S)、User(U)。M态是最高权限,什么都能干;S态通常给操作系统内核用;U态给应用程序用。这个设计跟ARM的EL0-EL3有点像,但RISC-V更简洁,级别更少,规范也更开放。
为什么是三级而不是两级或四级?这背后有实际的工程考量。两级(M+U)太简单,操作系统没法用——OS内核需要访问页表、处理中断,这些操作不能放在M态(太危险),也不能放在U态(权限不够),所以必须有S态。四级(M+S+U+某种更低的态)又太复杂,增加硬件成本,而且实际应用中很少需要。三级是一个平衡点:M态跑固件和Bootloader,S态跑OS内核,U态跑应用,职责清晰。
M态是复位后的默认状态,所有CSR在M态都可访问(除非硬件实现不支持)。M态的核心职责包括:系统启动初始化、物理内存保护(PMP)、中断和异常的顶层处理、以及模式切换。S态是给操作系统用的,它有自己的CSR集合,比如sstatus、stvec、satp(页表基址)。U态权限最低,只能访问非特权CSR(比如cycle、time、instret这些只读计数器),其他CSR一律触发异常。
模式切换通过mstatus.MPP字段和mret指令完成。比如从M态切换到S态:先把mstatus.MPP设为S态编码(01),把目标地址写入mepc,然后执行mret,硬件就会切换到S态并从mepc指向的地址开始执行。反过来,S态要回到M态,只能通过异常或中断(比如ecall),不能主动切回去。这个单向性很重要,保证了M态对系统的绝对控制。
2.3 CSR地址空间的分配规则
CSR地址是12位,范围0x000到0xFFF。规范把这4096个地址按权限和功能做了划分。0x000-0x0FF是U态非特权CSR,比如cycle、time、instret,这些在U态也能读。0x100-0x1FF是S态CSR,比如sstatus、stvec、sscratch。0x300-0x3FF是M态CSR,比如mstatus、mtvec、mepc。0xC00-0xCFF是只读的计数器和ID寄存器,比如cycle、time、instret、mvendorid、marchid。
地址的高两位(csr[11:10])决定了这个CSR的读写权限:00表示只读,01表示读写,10表示只读(保留),11表示读写(保留)。比如cycle的地址是0xC00,高两位是11,但它是只读的,这个要看具体规范定义。实际编程中,你不需要记这些编码规则,编译器会帮你处理,但理解这个分配逻辑有助于你快速定位一个CSR属于哪个特权级别。
还有一点要注意:CSR的读写不一定要硬件全部实现。比如你读一个不存在的CSR,可能会触发非法指令异常,也可能返回0,这取决于具体实现。所以写可移植代码时,最好先通过设备树或硬件手册确认CSR是否可用。
3. 最常用的CSR寄存器速查与实操解析
3.1 M态核心CSR:mstatus、mtvec、mepc、mcause
M态是RISC-V的“根权限”,这几个CSR是M态编程的基础,必须烂熟于心。
mstatus(地址0x300)是M态的状态寄存器,字段很多,但常用的就几个。MIE(bit 3)是M态全局中断使能,复位后是0,意味着中断默认关闭,你必须手动置1。MPIE(bit 7)保存的是进入异常前的MIE值,mret时会恢复到MIE。MPP(bit 12:11)保存的是进入异常前的特权级别,00是U态,01是S态,11是M态。MPRV(bit 17)控制load/store指令使用哪个特权级别的页表,这个在M态访问U态内存时很有用。FS(bit 14:13)控制浮点单元的状态,00是Off,01是Initial,10是Clean,11是Dirty,如果你用浮点指令,必须先把FS设为Initial或更高,否则触发非法指令异常。
mtvec(地址0x305)是M态异常入口地址。低两位是模式:00表示Direct模式,所有异常都跳到BASE地址;01表示Vectored模式,中断跳到BASE+4*cause,异常还是跳到BASE。实际项目中,我一般用Direct模式,然后在入口处根据mcause做分发,这样更灵活。注意mtvec的BASE地址必须4字节对齐,因为低两位要用来存模式。
mepc(地址0x341)保存的是异常发生时的PC值。mret会跳回这个地址。这里有个坑:如果是中断导致的异常,mepc保存的是被中断指令的地址,mret后会重新执行那条指令;如果是ecall导致的异常,mepc保存的是ecall的下一条指令地址,mret后会继续执行。这个区别在处理系统调用时很关键。
mcause(地址0x342)记录异常原因。最高位(bit 31或63)表示是中断还是异常:1是中断,0是异常。低几位是异常码,比如0是指令地址非对齐,2是非法指令,8是ecall from U,9是ecall from S,11是ecall from M。处理异常时,先读mcause判断类型,再读mepc定位出错位置,然后决定是修复、跳过还是报错。
3.2 S态核心CSR:sstatus、stvec、sepc、scause
S态的CSR和M态一一对应,但功能范围窄一些。sstatus是mstatus的子集,只包含S态关心的字段,比如SIE(S态全局中断使能)、SPIE、SPP(S态异常前的特权级别,只能是U或S)。stvec、sepc、scause的格式和M态版本一样,只是作用范围限于S态异常。
这里有个容易混淆的点:M态异常和S态异常是分开处理的。当CPU在S态或U态运行时发生异常,如果medeleg寄存器把对应的异常委托给了S态,那就走S态的stvec,用scause记录原因;如果没有委托,那就陷入M态,走mtvec。medeleg(地址0x302)和mideleg(地址0x303)就是干这个的。比如你把medeleg的bit 8(ecall from U)置1,那U态执行ecall时就会直接跳到S态的异常处理程序,M态完全不参与。这个机制让操作系统可以自己处理系统调用,不用每次都陷入M态,性能更好。
3.3 U态能碰的CSR:cycle、time、instret
U态权限最低,能访问的CSR非常有限。最常用的就是cycle、time、instret这三个只读计数器。cycle记录CPU执行的时钟周期数,time记录实时时间(通常由外部定时器驱动),instret记录退休的指令数。这三个CSR在性能分析、基准测试、延时校准中非常有用。
但要注意,U态访问这些CSR需要mcounteren寄存器(地址0x306)的允许。mcounteren的bit 0对应cycle,bit 1对应time,bit 2对应instret。如果对应的位是0,U态访问就触发非法指令异常。所以如果你想让U态程序读cycle做性能统计,必须先在M态把mcounteren的bit 0置1。这个设计是为了防止低权限程序通过计数器做侧信道攻击,实际项目中按需开启就行。
3.4 中断与异常委托:medeleg、mideleg、mie、mip
中断和异常的委托机制是RISC-V特权架构的精髓之一。medeleg(地址0x302)控制哪些异常委托给S态,mideleg(地址0x303)控制哪些中断委托给S态。比如你把mideleg的bit 5(S态定时器中断)置1,那S态定时器中断就会直接跳到S态的stvec,M态不参与处理。
mie(地址0x304)是M态中断使能寄存器,mip(地址0x344)是M态中断挂起寄存器。常用的位包括:bit 3(MSIE,M态软件中断)、bit 7(MTIE,M态定时器中断)、bit 11(MEIE,M态外部中断)、bit 1(SSIE,S态软件中断)、bit 5(STIE,S态定时器中断)、bit 9(SEIE,S态外部中断)。使能一个中断需要两步:先把mie的对应位置1,再把mstatus.MIE置1。如果中断委托给了S态,还需要把sie寄存器的对应位置1,以及sstatus.SIE置1。
这里有个实操中的坑:mip是只读的,你不能直接写mip来清除中断挂起位。清除中断挂起通常要通过外设寄存器(比如CLINT的msip寄存器)或者等待中断条件消失。比如清除M态软件中断,要写CLINT的msip寄存器,而不是写mip。
4. M/S/U模式切换的完整实操流程
4.1 从M态切换到S态的代码实现
从M态切换到S态是Bootloader的典型操作。假设你已经完成了M态的初始化,现在要跳转到S态执行操作系统内核。步骤如下:
第一步,配置mstatus.MPP为01(S态)。注意mstatus是M态CSR,只能用csrrw/csrrs/csrrc指令操作。代码大概长这样:
# 读取mstatus csrr t0, mstatus # 清除MPP字段(bit 12:11) li t1, ~(3 << 11) and t0, t0, t1 # 设置MPP为01(S态) li t1, (1 << 11) or t0, t0, t1 # 写回mstatus csrw mstatus, t0第二步,设置mepc为目标地址。mepc是M态CSR,直接写就行:
la t0, s_mode_entry csrw mepc, t0第三步,配置S态的中断和异常入口。这一步可选,但通常要做。比如设置stvec指向S态的异常处理程序:
la t0, s_trap_handler csrw stvec, t0第四步,执行mret。mret会从mepc取地址,从mstatus.MPP取特权级别,然后跳转:
mret执行完mret后,CPU就运行在S态了,PC指向s_mode_entry。注意mret还会把mstatus.MPIE恢复到MIE,把MPP重置为U态(00)。这个细节在嵌套异常处理时很重要。
4.2 从S态触发M态服务的ecall机制
S态不能主动切换到M态,只能通过ecall触发异常,让M态的异常处理程序接管。这通常用于S态请求M态的服务,比如访问M态独有的CSR、配置PMP、或者关机。
S态执行ecall的代码很简单:
ecall执行后,CPU会陷入M态,mcause的值为9(ecall from S),mepc保存ecall的下一条指令地址。M态的异常处理程序根据mcause判断是S态ecall,然后读取S态放在寄存器里的参数(通常用a0-a7传递),执行相应服务,最后用mret返回S态。
这里有个关键点:M态处理程序返回时,mret会跳回mepc指向的地址,也就是ecall的下一条指令。所以S态的ecall就像一次函数调用,返回后继续执行。但要注意,M态处理程序必须小心保存和恢复S态的上下文,否则会破坏S态的执行状态。
4.3 U态程序的加载与运行
U态程序的加载通常由S态的OS内核完成。基本流程是:S态准备好U态程序的代码和数据,设置好页表(如果启用MMU),然后通过sret切换到U态。
sret的用法和mret类似:设置sstatus.SPP为00(U态),设置sepc为U态入口地址,然后执行sret。sret会从sepc取地址,从sstatus.SPP取特权级别,跳转到U态。
U态程序运行时,如果发生异常(比如系统调用ecall),会陷入S态(如果medeleg委托了的话),S态处理完后再用sret返回U态。这个来回切换的过程就是系统调用的本质。
实际项目中,U态程序的调试比较麻烦,因为很多调试工具在U态下功能受限。我的经验是,先在M态或S态把逻辑调通,再移植到U态,同时用cycle和instret做性能对比,确保U态版本没有引入额外开销。
5. 常见问题与排查技巧实录
5.1 CSR访问触发非法指令异常的排查
CSR访问触发非法指令异常是最常见的问题之一。原因通常有三个:权限不够、CSR不存在、或者CSR的某些位配置不对。
权限不够的典型场景是U态访问了S态或M态的CSR。比如U态程序读sstatus,就会触发非法指令异常。排查方法是看mcause的值,如果是2(非法指令),再看出错指令的编码,确认它访问的是哪个CSR。如果确实是权限问题,要么修改程序逻辑,要么在M态把对应的CSR访问权限开放(比如通过mcounteren开放计数器的U态访问)。
CSR不存在的场景比较隐蔽。有些硬件实现不支持某些CSR,读的时候可能返回0,也可能触发异常。比如你读一个未实现的mhpmcounter,可能得到0,也可能陷入异常。排查方法是查硬件手册,确认CSR是否实现。如果手册没写,就写个测试程序,读一下看是否异常。
CSR位配置不对的典型场景是浮点单元。如果你用浮点指令,但mstatus.FS是00(Off),就会触发非法指令异常。解决方法是在使用浮点指令前,把mstatus.FS设为01(Initial)或11(Dirty)。这个坑我在第一次用RISC-V浮点单元时踩过,调了半天才发现是FS没开。
5.2 模式切换后程序跑飞的调试方法
模式切换后程序跑飞,通常是因为mepc或sepc设置不对,或者mstatus.MPP/SPP配置错误。排查步骤如下:
首先,确认mepc/sepc的值是否正确。可以在切换前把mepc/sepc读出来打印,看看是不是预期的地址。如果地址不对,检查你的加载地址和链接脚本是否匹配。
其次,确认mstatus.MPP/SPP的值。如果MPP设成了00(U态),但你的目标代码需要S态权限,那切换后执行第一条特权指令就会触发异常。排查方法是在切换前读mstatus,确认MPP字段的值。
第三,确认目标地址的代码是否已经加载。如果目标地址是空的或者包含非法指令,切换后就会跑飞。可以用调试器在目标地址设断点,看看是否能命中。
第四,确认中断是否关闭。如果切换前没有关闭中断,切换后可能立刻触发中断,导致程序跑飞。我的习惯是在模式切换前先关全局中断(清除mstatus.MIE),切换完成后再按需开启。
5.3 中断委托配置错误的典型表现
中断委托配置错误的表现比较多样。如果medeleg/mideleg配置不对,中断可能跑到错误的特权级别,导致处理程序找不到或者权限不够。
典型表现一:S态定时器中断跑到了M态。这是因为mideleg的bit 5没有置1,中断没有被委托给S态。解决方法是在M态初始化时,把mideleg的bit 5置1。
典型表现二:U态ecall跑到了M态而不是S态。这是因为medeleg的bit 8没有置1。解决方法类似,把medeleg的bit 8置1。
典型表现三:中断使能了但一直不触发。这可能是因为mie的对应位没有置1,或者mstatus.MIE没有置1,或者中断控制器的配置不对。排查方法是先读mie和mstatus,确认使能位;再读mip,确认中断是否挂起;最后检查中断控制器的配置。
这里分享一个我常用的排查表格,把常见问题和可能原因列出来,方便快速定位:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| CSR访问触发非法指令 | 权限不够 | 检查当前特权级别和CSR地址 |
| CSR访问触发非法指令 | CSR未实现 | 查硬件手册或写测试程序 |
| CSR访问触发非法指令 | FS字段为Off | 读mstatus确认FS值 |
| 模式切换后跑飞 | mepc/sepc错误 | 切换前打印mepc/sepc |
| 模式切换后跑飞 | MPP/SPP错误 | 切换前读mstatus确认 |
| 中断不触发 | mie/mstatus未使能 | 读mie和mstatus确认 |
| 中断不触发 | 中断未挂起 | 读mip确认 |
| 中断跑到错误级别 | medeleg/mideleg未配置 | 读medeleg/mideleg确认 |
5.4 实操心得与避坑建议
做了这么多RISC-V项目,我总结了几条实操心得,都是踩坑换来的。
第一条:复位后第一件事是读mstatus和misa,确认CPU的初始状态。不同厂商的RISC-V核复位后的mstatus值可能不一样,有的MIE默认是0,有的默认是1。misa寄存器告诉你这个核支持哪些扩展(比如是否支持浮点、是否支持压缩指令),这对后续编程很重要。
第二条:CSR的读写尽量用csrrw/csrrs/csrrc指令,不要用内存映射的方式。有些RISC-V核把CSR也映射到了内存空间,但访问行为可能和CSR指令不一致,比如原子性没有保证。用CSR指令最稳妥。
第三条:模式切换前一定要关中断。我见过太多因为切换过程中中断触发导致跑飞的案例。关中断很简单,清除mstatus.MIE就行,切换完成后再恢复。
第四条:调试CSR问题时,善用GDB的info registers命令。GDB可以显示所有CSR的值,包括mstatus、mtvec、mepc、mcause等。在异常处理程序入口设断点,然后打印这些CSR,基本能定位大部分问题。
第五条:写可移植代码时,不要假设所有CSR都存在。比如mhpmcounter在某些低端核上可能没有实现。用之前先查手册,或者用条件编译做兼容。
第六条:U态程序的性能分析要用cycle和instret,但记得先在M态开启mcounteren。我一般会在Bootloader里把mcounteren的bit 0和bit 2置1,这样U态程序就能读cycle和instret做性能统计了。
第七条:中断委托的配置要成对考虑。比如你把S态定时器中断委托给了S态,那S态的sie和sstatus也要相应配置,否则中断还是不会触发。M态和S态的中断使能是独立的,不能只配一边。
这些经验在手册里通常不会写,但实际项目中非常有用。RISC-V的特权架构设计得很优雅,但细节很多,只有真正写过代码、调过bug,才能把这些细节内化成自己的知识。希望这篇文章能帮你少走一些弯路,更快地上手RISC-V的特权架构编程。