1. 从一条报错说起:为什么CSR和特权级绕不开
第一次在RISC-V上跑裸机程序,十有八九会遇到一个让人摸不着头脑的异常:代码明明写对了,一执行mret或者访问某个控制寄存器,机器就跳到一个莫名其妙的地址,然后卡死。翻手册翻半天,最后发现是特权级没配对,或者CSR地址写错了。这类问题在ARM上可能被硬件悄悄兜住,但在RISC-V里,特权架构是明明白白摊在你面前的,错一位就是错一位。
这篇内容就是围绕RISC-V的CSR(Control and Status Register,控制状态寄存器)和特权架构M/S/U展开的。CSR是RISC-V里访问硬件控制信息的唯一入口,特权架构则决定了"谁有资格碰哪些寄存器和指令"。两者绑在一起,构成了RISC-V从裸机到操作系统的地基。mtvec、mstatus、mepc这些寄存器,只要你写过中断、做过异常处理、移植过RTOS或者跑过Linux,就一定跟它们打过交道。
适合谁看?如果你正在做RISC-V裸机开发、写Bootloader、移植操作系统,或者单纯想搞明白"为什么我的中断进不去""为什么mret之后跑飞了",这篇内容能帮你把CSR和M/S/U三级的对应关系理清楚。我会按实际调试的顺序来讲,先建立整体地图,再逐个拆关键寄存器,最后落到实操和排错上。不堆砌手册原文,讲的是我实际用下来觉得最容易踩坑、也最值得记住的那些点。
2. 特权架构M/S/U的整体设计与选型逻辑
2.1 三级特权级到底解决什么问题
RISC-V把执行环境分成三个特权级:Machine(M)、Supervisor(S)、User(U)。这个设计不是拍脑袋定的,它对应的是"固件—内核—应用"三层软件栈的隔离需求。
M模式是最高特权级,也是唯一一个必须实现的模式。任何一颗RISC-V核,复位后一定从M模式开始跑。它管的是最底层的硬件:时钟、电源、内存保护单元的初始配置、异常向量表的基址,以及把控制权交给下一级。你可以把M模式理解成"主板上的固件",它不参与日常业务,但所有底层开关都在它手里。
S模式是可选的,主要给操作系统内核用。Linux、RT-Thread这类需要虚拟内存和进程隔离的系统,内核就跑在S模式。S模式能访问自己的CSR(比如sstatus、stvec、satp),但碰不到M模式的寄存器。这样设计的好处是:即使内核有bug,也不会直接把硬件配置搞崩,M模式的固件还能兜底。
U模式是最低特权级,跑普通应用程序。它连CSR都基本访问不了(除了少数只读的性能计数器),所有敏感操作必须通过系统调用陷入S模式。这就是"用户态—内核态"隔离的硬件基础。
三级之间的关系可以用一句话概括:高特权级能访问低特权级的资源,反之绝对不行。M可以读写S和U的CSR,S可以管U,但U想往上够,只能触发异常。
2.2 为什么不是两级,也不是四级
有人会问,ARM有EL0到EL3四级,x86有Ring0到Ring3,RISC-V为什么定三级?这背后是够用就好的取舍。
两级(M+U)做不了现代操作系统,因为内核和用户程序没有隔离,一个野指针就能改页表。四级(比如再加一个Hypervisor级)对绝大多数嵌入式场景是浪费,硅片面积和验证成本都上去了。RISC-V把Hypervisor扩展做成可选的H扩展,需要虚拟化时才加,不需要就不加,保持了基础架构的简洁。
这个"基础必选+扩展可选"的思路贯穿整个RISC-V设计。M模式必选,S模式在应用处理器上标配、在微控制器上可以砍掉,U模式同理。所以你会在不同芯片上看到不同的组合:低端MCU可能只有M+U,应用处理器是M+S+U,带虚拟化的再加H。
2.3 CSR地址空间的编码规则
CSR不是随便编号的,它的12位地址(csr[11:0])有明确的编码含义,理解这个编码能帮你快速判断一个CSR属于哪个特权级、是否只读。
地址的高4位csr[11:8]表示读写权限:
00:用户级读写CSR01:用户级只读CSR10:超级用户级读写CSR11:超级用户级只读CSR
地址的低8位csr[7:0]表示具体寄存器,其中高2位csr[9:8]进一步区分特权级归属。实际记忆时更实用的方法是看地址范围:
| 地址范围 | 归属 | 典型寄存器 |
|---|---|---|
| 0x000-0x0FF | 用户级 | fflags、frm、cycle、time |
| 0x100-0x1FF | 超级用户级 | sstatus、stvec、satp |
| 0x300-0x3FF | 机器级 | mstatus、mtvec、mepc |
| 0xB00-0xBFF | 机器级只读 | mcycle、minstret |
| 0xF00-0xFFF | 机器级只读 | mvendorid、marchid |
这个编码规则的价值在于:当你在调试器里看到一个CSR地址,能立刻判断出它属于哪一级、能不能写。比如0x341是mepc(机器级),0x141是sepc(超级用户级),两者只差最高位,但权限完全不同。
注意:CSR地址是12位,但指令编码里
csrrw、csrrs这些指令的csr字段也是12位,所以能寻址4096个CSR。实际实现的远没这么多,访问未实现的CSR会触发非法指令异常。
3. CSR速查:那些你必须记住的寄存器
3.1 机器级核心CSR逐个拆
M模式是起点,先把机器级最常用的几个寄存器吃透。
mtvec(Machine Trap-Vector Base-Address Register,地址0x305)是异常和中断的入口地址寄存器。它的低2位是模式位:00表示Direct模式,所有异常都跳到同一个地址;01表示Vectored模式,中断会跳到BASE + 4 × cause,异常仍然跳到BASE。这个区别很关键——Direct模式下你需要在处理函数里自己判断cause,Vectored模式下硬件帮你算好了偏移。
实际配置时,mtvec的BASE必须4字节对齐(低2位是模式位,所以实际基址是mtvec & ~0x3)。我见过有人把基址设成非对齐地址,结果一触发异常就跳飞。写的时候用csrw mtvec, t0,t0里放(base | mode)。
mstatus(Machine Status Register,地址0x300)是M模式的状态总控。它里面位很多,但真正天天打交道的是这几个:
MIE(bit 3):M模式全局中断使能MPIE(bit 7):进入异常前的MIE备份MPP(bit 12:11):进入异常前的特权级,11是M,01是S,00是UMPRV(bit 17):修改访存特权级
MPP这个字段是mret行为的关键。mret执行时,硬件会把MPP的值恢复到当前特权级,然后把MPIE恢复到MIE。如果你从S模式陷入M模式处理异常,处理完想回到S模式,就必须保证MPP是01。我踩过的坑是:在M模式初始化时手动改了mstatus,把MPP清成了00,结果第一次从S模式陷入后mret直接掉到U模式,程序跑飞。
mepc(Machine Exception Program Counter,地址0x341)保存异常发生时的PC。mret会跳回mepc。这里有个细节:如果是中断,mepc指向的是下一条未执行的指令;如果是异常(比如缺页、非法指令),mepc指向的是触发异常的那条指令本身。这个区别决定了你在处理异常时要不要手动给mepc加4。比如处理非法指令异常,如果你想跳过这条指令继续执行,就得mepc += 4;但如果是缺页异常,修好页表后直接mret回到原指令重试,不能加4。
mcause(Machine Cause Register,地址0x342)告诉你为什么陷入。最高位是中断标志(1为中断,0为异常),低位是cause编码。常见值:0是取指缺页,2是非法指令,5是读访问缺页,7是写访问缺页,11是M模式外部中断,15是M模式定时器中断。调试时第一件事就是读mcause,它能直接告诉你方向。
mie/mip(地址0x304/0x344)是中断使能和挂起寄存器。mie控制哪些中断源被使能,mip反映当前哪些中断在挂起。两者位定义对应:MEIE(bit 11)外部中断,MTIE(bit 7)定时器中断,MSIE(bit 3)软件中断。注意mip的位是只读的(反映硬件状态),mie可写。
3.2 超级用户级CSR与M级的对应关系
S模式的CSR基本是M模式的一套"镜像",命名上把m换成s,地址上把0x3xx换成0x1xx。理解这个对应关系,记忆量直接减半。
| M模式 | S模式 | 功能 |
|---|---|---|
mstatus(0x300) | sstatus(0x100) | 状态寄存器 |
mtvec(0x305) | stvec(0x105) | 异常向量基址 |
mepc(0x341) | sepc(0x141) | 异常PC |
mcause(0x342) | scause(0x142) | 异常原因 |
mie(0x304) | sie(0x104) | 中断使能 |
mip(0x344) | sip(0x144) | 中断挂起 |
但S模式不是M模式的完整复制。sstatus里只有SIE、SPIE、SPP、SUM、MXR这些位,没有MPRV、MPP。sie/sip也只管S模式能处理的中断(外部、定时器、软件),M模式的中断它管不了。
这里有个容易混淆的点:S模式的中断最终还是要经过M模式。因为中断控制器(如PLIC)的使能和优先级配置在M模式,S模式只能通过sie使能"允许S模式处理",但中断信号本身要先被M模式的路由逻辑放行。所以一个完整的中断链路是:外设→PLIC→M模式mie→委派给S模式→S模式sie→S模式处理。中间任何一环没配好,中断都进不来。
satp(Supervisor Address Translation and Protection,地址0x180)是S模式独有的,M模式没有对应物。它控制页表基址和地址翻译模式。写satp会触发TLB刷新,这个操作有开销,所以内核里切换页表时要谨慎。satp的MODE字段:0是Bare(无翻译),8是Sv39,9是Sv48,10是Sv57。
3.3 用户级CSR:少但别忽略
U模式能访问的CSR极少,主要是浮点相关的fflags、frm、fcsr,以及只读的性能计数器cycle、time、instret。这些寄存器在U模式可读,但写fflags需要mstatus.FS或sstatus.FS不为0(表示浮点单元已启用)。
性能计数器这块有个实用技巧:cycle和instret默认在U模式可读,但如果你不想让用户程序读,可以在mcounteren里关掉对应位。mcounteren是M模式控制计数器在S/U模式可见性的寄存器,scounteren则是S模式控制U模式可见性。这个两级控制链保证了从M到U的逐级授权。
4. 特权级切换的实操过程与关键环节
4.1 从M模式启动到跳入S模式的完整流程
这是Bootloader里最核心的一段代码,也是CSR配置最密集的地方。我按实际执行顺序拆一遍。
第一步,配置M模式异常向量。在跳入S模式之前,M模式必须先把mtvec设好,因为后续任何异常都要靠它兜底。
la t0, m_trap_handler csrw mtvec, t0第二步,配置PMP(Physical Memory Protection)。PMP是M模式控制内存访问权限的机制,S模式能不能访问某段内存,全看PMP怎么配。至少要给S模式开放它要用的代码段、数据段和外设区域。PMP有16个配置寄存器pmpcfg0-15和16个地址寄存器pmpaddr0-15,每个条目可以配读/写/执行权限。
# 允许S模式访问全部地址空间(简化配置,实际要按需收紧) li t0, 0x0F csrw pmpcfg0, t0 li t0, 0xFFFFFFFF csrw pmpaddr0, t0第三步,设置mstatus.MPP为S模式。这是跳转前的关键一步,决定了mret之后落在哪个特权级。
# 清除MPP,设为01(S模式) li t0, MSTATUS_MPP csrc mstatus, t0 li t0, (1 << 11) csrs mstatus, t0第四步,设置mepc为S模式入口地址,然后执行mret。
la t0, s_entry csrw mepc, t0 mretmret执行时,硬件做三件事:把当前特权级设为MPP的值,把MIE恢复为MPIE,跳转到mepc。执行完这条指令,CPU就正式进入S模式了。
实操心得:调试这段代码时,如果
mret之后跑飞,先检查mepc是否指向了合法地址,再检查mstatus.MPP是否设对。我遇到过因为mepc指向的地址没有映射到物理内存,mret之后直接取指异常,又跳回M模式,形成死循环。
4.2 异常与中断的委派配置
M模式处理所有异常是默认行为,但现代操作系统希望S模式自己处理大部分异常(缺页、系统调用),只有少数必须由M模式处理(比如M模式定时器中断)。这就涉及异常委派。
委派通过medeleg(Machine Exception Delegation,地址0x302)和mideleg(Machine Interrupt Delegation,地址0x303)两个寄存器控制。某位置1,表示对应的异常/中断委派给S模式处理。
# 把系统调用(cause 8)、缺页(cause 12/13/15)委派给S模式 li t0, (1 << 8) | (1 << 12) | (1 << 13) | (1 << 15) csrw medeleg, t0 # 把S模式外部中断、定时器中断委派给S模式 li t0, (1 << 9) | (1 << 5) | (1 << 1) csrw mideleg, t0委派之后,当这些异常在S模式或U模式发生时,硬件直接跳到stvec,不再经过M模式。这减少了陷入层级,性能更好。
但要注意:委派只对低特权级有效。如果异常发生在M模式本身,无论medeleg怎么配,都还是M模式处理。因为M模式没有更高的层级可以委派。
4.3 中断使能的分层控制
中断使能是分层的,从外设到CPU核心要过好几道关。以S模式定时器中断为例:
第一层,外设级:定时器自己的中断使能位。 第二层,PLIC级:PLIC里对应中断源的使能和优先级。 第三层,M模式委派:mideleg对应位置1,允许委派给S模式。 第四层,S模式使能:sie.STIE置1,允许S模式定时器中断。 第五层,S模式全局使能:sstatus.SIE置1。 第六层,M模式全局使能:mstatus.MIE置1(如果中断要经过M模式路由)。
任何一层没开,中断都进不来。调试中断不响应时,按这个顺序从下往上查,比盲目改代码高效得多。
# S模式定时器中断使能示例 li t0, (1 << 5) # STIE csrs sie, t0 # S模式中断使能 csrs sstatus, (1 << 1) # SIE全局使能 csrs mstatus, (1 << 3) # MIE全局使能注意:
sstatus.SIE和mstatus.MIE是独立的。从M模式进入S模式后,sstatus.SIE的初始值来自mstatus的某个位(具体取决于实现),但通常需要显式设置。我见过有人只设了sie忘了设sstatus.SIE,结果中断一直不触发。
5. 常见问题与排查技巧实录
5.1 异常跳飞与mret跑飞的排查路径
这类问题的表现是:程序执行到某条指令后突然跳到未知地址,或者mret之后行为异常。排查按以下顺序走。
先读mcause,确认异常类型。如果是非法指令(cause 2),看mepc指向的指令是什么,大概率是访问了未实现的CSR或执行了当前特权级不允许的指令。如果是缺页(cause 12/13/15),看mtval(地址0x343)里存的出错地址,检查页表或PMP配置。
再读mstatus.MPP,确认陷入前的特权级。如果MPP是00(U模式)但你期望是01(S模式),说明陷入前特权级就不对,往上追。
最后检查mepc的对齐。RISC-V指令是2字节或4字节对齐,如果mepc是奇数,mret必然跑飞。特别是手动改过mepc的代码,加偏移量时容易算错。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| mret后跑飞 | mepc地址非法或未对齐 | 读mepc,检查对齐和映射 |
| 异常反复触发 | mepc未推进,异常指令重复执行 | 异常处理里手动mepc+=4 |
| 中断不响应 | 使能链某一层未开 | 按6层使能链逐层检查 |
| 特权级错误 | mstatus.MPP配置错误 | 读mstatus,确认MPP值 |
| CSR访问异常 | CSR地址错误或权限不足 | 对照CSR地址表,确认特权级 |
5.2 CSR读写指令的使用细节
RISC-V访问CSR用6条指令:csrrw、csrrs、csrrc、csrrwi、csrrsi、csrrci。命名规则:csr+ 操作(rw/s/c)+ 立即数(i)。
csrrw rd, csr, rs1:读旧值到rd,写rs1到csr。如果rd是x0,表示不读,只写。csrrs rd, csr, rs1:读旧值到rd,把rs1的置1位写到csr(读-改-写)。csrrc rd, csr, rs1:读旧值到rd,把rs1的置1位清零。
这里有个反直觉的点:csrrs和csrrc的rs1是掩码,不是直接写入的值。csrrs只把rs1里为1的位在csr里置1,为0的位保持不变。所以想设置某一位,用csrrs;想清除某一位,用csrrc。
# 设置mstatus.MIE(bit 3) li t0, (1 << 3) csrrs x0, mstatus, t0 # 只置位,不读 # 清除mstatus.MIE li t0, (1 << 3) csrrc x0, mstatus, t0 # 只清位,不读 # 读mstatus csrrs t0, mstatus, x0 # rs1=x0,不改变任何位,只读最后一条csrrs t0, mstatus, x0是读CSR的标准写法,因为rs1是x0,没有位被置1,所以CSR值不变,只把旧值读到t0。
实操心得:
csrrwi、csrrsi、csrrci的立即数字段是5位(0-31),只能操作低5位。如果要设置高位(比如mstatus.MPP在bit 12:11),必须用寄存器版本,不能直接用立即数版本。
5.3 特权级切换时的寄存器保存
从S模式陷入M模式时,硬件只自动保存mepc、mcause、mtval和mstatus的部分位。通用寄存器、浮点寄存器、向量寄存器都需要软件自己保存。
这里有个性能取舍:如果异常处理程序只用少数几个寄存器,可以只保存用到的;如果要支持完整的上下文切换(比如任务调度),就得保存全部。我一般建议在异常入口先保存ra、sp、gp、tp和所有t寄存器,因为这些是调用者保存寄存器,异常处理程序可能会破坏它们。
m_trap_handler: addi sp, sp, -128 sd ra, 0(sp) sd t0, 8(sp) sd t1, 16(sp) # ... 保存其他寄存器 # 处理异常 # 恢复寄存器 ld ra, 0(sp) ld t0, 8(sp) # ... addi sp, sp, 128 mret浮点寄存器只有在mstatus.FS不为0时才需要保存。如果异常处理程序里不用浮点,可以跳过,省时间。
5.4 调试CSR的实用手段
没有调试器的时候,可以用最原始的办法:把CSR值通过串口打出来。写一个dump_csr函数,把关键CSR读出来转成十六进制输出。
void dump_csr(void) { unsigned long val; asm volatile("csrr %0, mstatus" : "=r"(val)); uart_printf("mstatus = 0x%lx\n", val); asm volatile("csrr %0, mtvec" : "=r"(val)); uart_printf("mtvec = 0x%lx\n", val); asm volatile("csrr %0, mepc" : "=r"(val)); uart_printf("mepc = 0x%lx\n", val); asm volatile("csrr %0, mcause" : "=r"(val)); uart_printf("mcause = 0x%lx\n", val); }在异常处理入口调用一次,能快速定位大部分问题。如果连串口都没有,可以用GPIO翻转或者写一个固定的内存地址,用逻辑分析仪抓。
有调试器的话,直接读CSR更高效。OpenOCD支持riscv expose_csrs命令,可以把CSR暴露给GDB,然后像读普通寄存器一样读。
6. 从裸机到系统:CSR配置的演进思路
6.1 裸机阶段的极简配置
裸机程序通常只跑在M模式,不需要S和U。这时候CSR配置可以极简:设好mtvec,开mstatus.MIE,配好mie,就够了。不需要PMP(除非有安全需求),不需要委派,不需要satp。
这个阶段最容易犯的错是忘了设mtvec就开中断。一旦中断触发,mtvec是复位默认值(通常是0),直接跳到地址0,而地址0往往是启动代码,结果就是重启循环。所以顺序永远是:先设mtvec,再开中断。
6.2 RTOS阶段的S模式引入
跑RTOS时,如果芯片支持S模式,通常会把RTOS内核放到S模式,任务放到U模式。这样任务崩溃不会拖垮内核。这个阶段的CSR配置多了几件事:配medeleg把系统调用和缺页委派给S模式,配stvec,配satp(如果RTOS支持虚拟内存),配PMP给S模式开放必要区域。
RTOS的任务切换在S模式完成,通过ecall从U模式陷入S模式。ecall的cause是8(U模式)或9(S模式),处理程序根据mepc和寄存器状态决定是系统调用还是任务切换。
6.3 Linux阶段的完整配置
Linux把M模式压缩到最小,只留一个SBI(Supervisor Binary Interface)固件。所有硬件操作通过ecall从S模式请求M模式完成。这个阶段的CSR配置最复杂:PMP要精细划分内存区域,medeleg要委派几乎所有异常,mideleg要委派大部分中断,satp要配Sv39/Sv48页表。
M模式的SBI固件通常只处理定时器中断(用于调度)和IPI(核间中断),其他全部委派。这样Linux内核在S模式就能完成绝大多数工作,M模式几乎不参与日常运行。
从裸机到Linux,CSR配置的复杂度是递增的,但核心逻辑不变:每一级只开放下一级需要的权限,能委派的就委派,M模式只保留必须自己处理的部分。理解这个原则,比记住具体寄存器值更重要。
我个人在实际移植过程中的体会是,CSR配置出错时,不要急着改代码,先把当前所有关键CSR的值dump出来,对照手册逐个核对。十次里有八次是某个使能位没开,或者某个模式位设错。把mstatus、mtvec、medeleg、mideleg、mie、mip这六个寄存器做成一个检查清单,每次调试前过一遍,能省下大量时间。另外,mtvec的Vectored模式在中断多的时候能省去软件判断cause的开销,但异常处理仍然要回到Direct逻辑,这个混合用法值得在中断密集的场景里试试。