1. 从三条指令说起:为什么CSR是RISC-V特权架构的"总控台"
很多人第一次接触RISC-V特权架构,是从三条指令开始的:csrr、csrw、csrrw。看起来不过是读写几个寄存器,能有多复杂?但真正上手写裸机代码或者移植操作系统时,你会发现几乎所有"卡住"的问题最后都指向同一个地方——CSR配置不对。
CSR,全称Control and Status Register,控制状态寄存器。它不像通用寄存器那样用来算数,而是CPU内部的"控制面板":中断开不开、异常入口在哪、当前运行在哪个特权级、性能计数器怎么配,全都靠它。你可以把它理解成一颗芯片的"BIOS设置界面",只不过这个界面是通过指令直接访问的。
RISC-V特权架构定义了三个特权级别:M模式(Machine)、S模式(Supervisor)、U模式(User)。这三个级别不是随便分的,它们对应着不同的权限边界。M模式是最高权限,能访问所有CSR和物理内存;S模式通常给操作系统内核用,权限受限但仍能管理虚拟内存和中断;U模式给应用程序用,权限最低,很多CSR在U模式下访问会直接触发非法指令异常。
这篇文章要解决的问题很具体:给你一份能查、能用、能避坑的CSR速查手册,同时把M/S/U三个特权级之间的关系和切换逻辑讲透。不管你是正在写RISC-V裸机启动代码,还是在移植RTOS或Linux,又或者只是想在QEMU上跑一段特权级切换的测试,这里的内容都能直接拿来参考。
我自己的经验是,CSR这块最容易出问题的地方不是"不知道有这个寄存器",而是"知道名字但不知道什么时候该配、配错了会怎样"。所以下面不会只列一张表,而是把每个关键CSR放到实际场景里讲。
2. CSR的编号规则与访问机制:读懂了编码,查手册效率翻倍
2.1 CSR地址的12位编码逻辑
CSR的地址是12位的,范围0x000到0xFFF。这个编码不是随便排的,它按照功能分成了几个大区。你拿到一个CSR地址,光看高位就能猜出它是干什么的:
| 地址范围 | 用途 | 典型代表 |
|---|---|---|
| 0x000-0x0FF | 非特权级CSR(U模式可访问) | cycle、time、instret |
| 0x100-0x1FF | 非特权级CSR(读写) | 自定义用途 |
| 0x200-0x2FF | 非特权级CSR(只读) | vl、vtype(向量扩展) |
| 0x300-0x3FF | 非特权级CSR(读写) | 浮点相关 |
| 0x400-0x4FF | 非特权级CSR(读写) | 自定义 |
| 0x500-0x5FF | 非特权级CSR(读写) | 自定义 |
| 0x600-0x6FF | 非特权级CSR(读写) | 自定义 |
| 0x700-0x7FF | 非特权级CSR(读写) | 自定义 |
| 0x800-0x8FF | 非特权级CSR(只读) | 自定义 |
| 0x900-0x9FF | 非特权级CSR(读写) | 自定义 |
| 0xA00-0xAFF | 非特权级CSR(读写) | 自定义 |
| 0xB00-0xBFF | 非特权级CSR(读写) | 自定义 |
| 0xC00-0xCFF | 非特权级CSR(只读) | cycle、time、instret |
| 0xD00-0xDFF | 非特权级CSR(读写) | 自定义 |
| 0xE00-0xEFF | 非特权级CSR(读写) | 自定义 |
| 0xF00-0xFFF | 非特权级CSR(读写) | 自定义 |
上面这张表是"非特权级"的部分,真正和特权架构相关的是0xC00以上的区域。但更关键的是地址的高两位(csr[11:10])决定了这个CSR在哪些特权级下可访问:
00:U模式可读写01:S模式可读写,U模式只读10:S模式可读写,U模式不可访问11:M模式可读写,S/U模式不可访问
这个规则非常重要。比如mstatus的地址是0x300,高两位是11,所以只有M模式能访问。sstatus的地址是0x100,高两位是01,S模式可读写,U模式只能读。cycle的地址是0xC00,高两位是11,但它是只读的,而且U模式下能不能读取决于mcounteren和scounteren的配置。
注意:CSR地址的高两位只是"权限提示",具体能不能访问还要看当前特权级和
mstatus里的相关位。有些CSR在低特权级访问会触发非法指令异常,有些则返回0。
2.2 六条CSR指令的使用场景
RISC-V定义了六条CSR访问指令,它们的行为差异直接影响你的代码怎么写:
csrrw rd, csr, rs1 # 原子交换:读旧值到rd,写rs1到csr csrrs rd, csr, rs1 # 原子置位:读旧值到rd,将rs1的1位置位到csr csrrc rd, csr, rs1 # 原子清位:读旧值到rd,将rs1的1位清零到csr csrrwi rd, csr, imm # 立即数版本,imm是5位无符号数 csrrsi rd, csr, imm # 立即数版本 csrrci rd, csr, imm # 立即数版本这里有个细节很多人会踩坑:当rd为x0时,csrrw不会读CSR,也就不会触发读操作的副作用。而csrrs和csrrc即使rd为x0,仍然会读CSR。这个区别在访问某些"读操作会清标志"的CSR时非常关键。
另一个坑是:csrrs和csrrc的rs1如果为x0,则不会写CSR。这意味着你可以用csrrs x0, csr, x0来"只读不写",但更常见的做法是用csrr伪指令,它等价于csrrs rd, csr, x0。
实际写代码时,我习惯用伪指令让代码更清晰:
csrr t0, mstatus # 读mstatus csrw mstatus, t0 # 写mstatus csrs mstatus, t1 # 置位 csrc mstatus, t1 # 清位这些伪指令在汇编器层面会展开成对应的真实指令,可读性更好。
2.3 读写CSR时的异常行为
在低特权级访问高特权级CSR,或者访问不存在的CSR,都会触发非法指令异常(Illegal Instruction)。这个异常的cause值是2。但有一个例外:如果CSR地址的高两位是11,且当前是S模式,访问会触发非法指令异常;但如果地址高两位是00或01,S模式访问通常没问题。
更细的规则是:当mstatus.MPRV=1时,M模式下的load/store会使用mstatus.MPP指定的特权级来检查权限。这个机制在操作系统切换页表时非常有用,但也很容易配错。
我遇到过的一个真实问题是:在M模式下写了一段代码去访问sstatus,结果触发了非法指令异常。原因是mstatus的MPRV位被意外置位,导致访问权限检查用了U模式的规则。排查了半天才发现是之前某段代码清mstatus时把MPRV也清掉了,但后续代码又依赖它。
3. M/S/U三级特权模型:权限边界与切换的完整链路
3.1 三个特权级各自能碰什么
M模式是"上帝模式",所有CSR都能访问,所有物理内存都能读写,中断和异常的控制权都在它手里。S模式是"管理员模式",能访问大部分S级CSR,能管理虚拟内存(通过satp),能处理S级中断和异常,但不能碰M级CSR。U模式是"用户模式",只能访问非特权CSR,内存访问受页表限制,很多指令(如wfi、sfence.vma)在U模式下会触发异常。
这三个级别的关系可以用一个简单的规则概括:高特权级可以访问低特权级的所有资源,反之则不行。但有一个例外:M模式可以通过mstatus.MPRV和mstatus.MPP来"模拟"低特权级的内存访问,这在操作系统里用来安全地读写用户空间数据。
| 特权级 | 可访问CSR范围 | 典型用途 | 异常入口CSR |
|---|---|---|---|
| M | 全部 | 固件、BootROM、安全监控 | mtvec |
| S | S级和U级 | 操作系统内核 | stvec |
| U | 非特权级 | 应用程序 | 无(通过S或M处理) |
3.2 特权级切换的触发条件
特权级不会无缘无故切换,只有以下几种情况会发生:
从低到高:只能通过异常或中断。比如U模式执行了ecall,会触发环境调用异常,CPU自动跳到S模式或M模式(取决于medeleg的配置)。又比如U模式访问了非法地址,触发缺页异常,也会跳到S模式。
从高到低:只能通过mret或sret指令。这两个指令会从mstatus.MPP或sstatus.SPP中恢复之前的特权级,同时恢复中断使能状态。
这里的关键CSR是mstatus和sstatus里的几个字段:
mstatus.MPP[12:11]:记录异常发生前的特权级,mret时恢复mstatus.SPP:记录S模式异常发生前的特权级,sret时恢复mstatus.MPIE:记录异常发生前的中断使能,mret时恢复到MIEmstatus.SPIE:记录S模式异常发生前的中断使能,sret时恢复到SIE
我见过很多人在写mret之前忘记设置MPP,结果mret之后CPU跑到了错误的特权级,代码直接跑飞。正确的做法是:在异常处理程序里,先读mstatus,修改MPP为目标特权级,再执行mret。
3.3 异常委托:让S模式处理该处理的事
medeleg和mideleg是两个非常重要的CSR。它们的作用是把某些异常和中断委托给S模式处理,这样M模式就不用管所有事情,S模式的操作系统可以自己处理缺页、系统调用等。
medeleg的每一位对应一个异常cause。比如第8位对应ecall from U,第12位对应instruction page fault。如果某位为1,该异常在U模式或S模式触发时,会直接跳到S模式的stvec,而不是M模式的mtvec。
mideleg类似,但对应的是中断。比如第1位对应supervisor software interrupt,第5位对应supervisor timer interrupt,第9位对应supervisor external interrupt。
配置这两个CSR的典型代码:
// 把U模式的ecall和缺页异常委托给S模式 csr_set(medeleg, (1 << 8) | (1 << 12) | (1 << 13) | (1 << 15)); // 把S模式的中断委托给S模式 csr_set(mideleg, (1 << 1) | (1 << 5) | (1 << 9));注意:委托之后,M模式就不再收到这些异常和中断了。如果S模式没有正确处理,系统可能会挂死。所以在委托之前,一定要确保S模式的异常处理程序已经就绪。
3.4 中断使能的多层控制
RISC-V的中断使能是分层的:全局使能 + 单个中断使能 + 委托状态。
M模式有mstatus.MIE作为全局中断使能,mie寄存器控制各个中断源的使能。S模式有sstatus.SIE和sie。U模式没有自己的中断使能,它依赖S模式或M模式的配置。
一个中断要真正被响应,需要满足:
- 当前特权级的中断全局使能打开(
MIE或SIE) - 对应中断在
mie或sie中使能 - 该中断没有被委托到更低特权级(或者已经委托但更低特权级也使能了)
- 当前特权级低于中断的目标特权级
这个逻辑听起来绕,但实际配置时只要记住:M模式的中断永远由M模式处理,S模式的中断可以委托给S模式,U模式没有中断。
4. 高频CSR速查:从mstatus到satp的实战配置
4.1 mstatus:最复杂的那个寄存器
mstatus是M模式的状态寄存器,也是整个特权架构里最复杂的CSR之一。它的字段很多,但常用的就那么几个:
| 字段 | 位 | 含义 | 典型值 |
|---|---|---|---|
| MIE | 3 | M模式全局中断使能 | 1=开,0=关 |
| MPIE | 7 | 异常前的中断使能 | mret时恢复到MIE |
| MPP | 12:11 | 异常前的特权级 | 0=U, 1=S, 3=M |
| MPRV | 17 | 内存访问特权级覆盖 | 1=用MPP的特权级访问 |
| SUM | 18 | S模式访问U模式内存 | 1=允许 |
| MXR | 19 | 执行权限影响读 | 1=可读可执行页 |
| TVM | 20 | 虚拟内存控制 | 1=禁止S模式写satp |
| TW | 21 | 等待指令控制 | 1=U模式wfi触发异常 |
| TSR | 22 | sret控制 | 1=U模式sret触发异常 |
初始化mstatus的典型代码:
// 关闭中断,设置MPP为M模式,清其他位 csr_write(mstatus, 0x1800); // MPP=3, MIE=0这里0x1800是MPP字段为11的值。很多人会直接写csr_write(mstatus, 0),这样MPP变成0,下次mret会跳到U模式,如果你本来想留在M模式就会出问题。
4.2 mtvec/stvec:异常入口的两种模式
mtvec和stvec的格式是一样的:低两位是模式,高位是基地址。
- 模式0:直接模式,所有异常都跳到
BASE - 模式1:向量模式,中断跳到
BASE + 4 * cause,异常仍然跳到BASE
// 直接模式 csr_write(mtvec, (uintptr_t)handler & ~0x3); // 向量模式 csr_write(mtvec, ((uintptr_t)handler & ~0x3) | 1);向量模式在中断处理时很有用,因为不同中断可以有不同的入口,省去了在软件里判断cause的步骤。但异常还是走同一个入口,所以异常处理程序里仍然需要读mcause来判断类型。
注意:
mtvec的基地址必须4字节对齐,向量模式下还需要考虑每个入口4字节的空间是否够放跳转指令。如果处理程序很长,通常放一条跳转指令到真正的处理函数。
4.3 mepc/sepc:异常返回地址的保存与恢复
mepc保存异常发生时的PC,mret会跳回这个地址。但有一个细节:对于某些异常,mepc保存的是触发异常的指令地址,对于另一些,保存的是下一条指令的地址。
比如ecall指令,mepc保存的是ecall本身的地址。如果你在异常处理里不修改mepc,mret之后会再次执行ecall,陷入死循环。正确的做法是:对于ecall,把mepc加4(假设指令长度是4字节);对于缺页异常,通常需要重新执行那条指令,所以不加。
void handle_exception(void) { uintptr_t epc = csr_read(mepc); uintptr_t cause = csr_read(mcause); if ((cause & 0xFF) == 8) { // ecall from U epc += 4; // 跳过ecall } // 其他异常根据情况处理 csr_write(mepc, epc); }4.4 satp:开启分页的钥匙
satp是S模式地址转换和保护的寄存器,它控制页表的基地址和分页模式。在RV64上,satp的格式是:
- 位63:60:模式,0=裸机,8=Sv39,9=Sv48,10=Sv57
- 位59:44:ASID(地址空间标识符)
- 位43:0:根页表的物理页号
// 开启Sv39分页 uint64_t satp = (8UL << 60) | (root_page_table >> 12); csr_write(satp, satp); // 刷新TLB asm volatile("sfence.vma" ::: "memory");这里有个坑:写satp之后必须执行sfence.vma刷新TLB,否则旧的地址映射可能还在TLB里,导致访问错误。另外,satp的写入在M模式下受mstatus.TVM控制,如果TVM=1,S模式写satp会触发异常。
4.5 性能计数器:cycle/time/instret
这三个CSR在U模式下可读,但前提是mcounteren和scounteren的对应位被置1。
// 允许U模式读cycle、time、instret csr_write(mcounteren, 0x7); csr_write(scounteren, 0x7);cycle是时钟周期计数,time是实时时间(通常由外部定时器提供),instret是退休指令数。这三个在性能分析时非常有用,但要注意它们可能不是精确的,取决于具体实现。
5. 特权级切换的代码实战:从M模式跳到U模式再回来
5.1 准备U模式的运行环境
要让CPU从M模式跳到U模式,需要做几件事:
- 设置
mstatus.MPP为0(U模式) - 设置
mepc为U模式代码的入口地址 - 配置
medeleg和mideleg,把该委托的异常和中断委托给S模式(如果有S模式) - 设置
mtvec,确保异常能回到M模式处理 - 执行
mret
void enter_user_mode(void (*user_entry)(void)) { // 设置MPP为U模式 uint64_t mstatus = csr_read(mstatus); mstatus &= ~(3UL << 11); // 清MPP csr_write(mstatus, mstatus); // 设置U模式入口 csr_write(mepc, (uintptr_t)user_entry); // 设置异常入口 csr_write(mtvec, (uintptr_t)m_handler); // 跳转到U模式 asm volatile("mret"); }5.2 U模式触发ecall后的处理流程
U模式代码执行ecall后,CPU会:
- 把当前PC保存到
mepc - 把cause(8,ecall from U)保存到
mcause - 把当前特权级(U)保存到
mstatus.MPP - 把
mstatus.MIE保存到MPIE,然后清MIE - 跳转到
mtvec指定的地址
在M模式的异常处理程序里,你可以读取mcause判断是哪种异常,处理完之后修改mepc(跳过ecall),然后执行mret返回U模式。
void m_handler(void) { uint64_t cause = csr_read(mcause); uint64_t epc = csr_read(mepc); if ((cause & 0xFF) == 8) { // ecall from U,跳过它 csr_write(mepc, epc + 4); } // 返回U模式 asm volatile("mret"); }5.3 从M模式到S模式再到U模式的完整链路
如果系统有S模式,典型的启动流程是:
- M模式初始化,配置
medeleg和mideleg,把大部分异常委托给S模式 - M模式设置
mstatus.MPP为S模式,mepc为S模式入口,执行mret - S模式初始化页表、中断,设置
sstatus.SPP为U模式,sepc为U模式入口,执行sret - U模式运行应用程序,通过
ecall触发异常,S模式处理
这个链路里,M模式通常只负责最底层的初始化和安全监控,S模式负责操作系统的大部分工作。
注意:
mret和sret都会恢复中断使能状态。如果MPIE或SPIE是0,返回后中断仍然是关的。所以在这之前要确保中断使能状态正确。
6. 踩坑实录:CSR配置中最容易翻车的五个地方
6.1 mstatus.MPP忘记设置导致mret跑飞
这是最经典的问题。很多人写mret之前只设置了mepc,忘了MPP。结果mret之后CPU跑到了U模式(因为MPP默认是0),而代码是按M模式写的,一访问M级CSR就触发异常。
排查方法:在mret之前打印mstatus的值,确认MPP字段是期望的特权级。如果是在QEMU里调试,可以用-d int参数看异常日志。
6.2 medeleg配置错误导致异常丢失
medeleg的位对应异常cause,但cause的编码不是连续的。比如cause 11是ecall from M,cause 8是ecall from U。如果误把cause 11也委托给S模式,M模式的ecall就会跑到S模式,而S模式可能没有对应的处理程序。
正确做法:只委托那些确实需要S模式处理的异常,比如缺页、U模式ecall。M模式的ecall通常保留在M模式处理。
6.3 satp写入后忘记sfence.vma
写satp只是改了页表基地址,但TLB里可能还缓存着旧的映射。如果不执行sfence.vma,后续的内存访问可能用到错误的映射,导致数据损坏或异常。
正确做法:每次修改satp或页表内容后,都执行一次sfence.vma。如果只修改了部分页表,可以用带参数的sfence.vma只刷新特定地址。
6.4 中断使能顺序错误导致中断丢失
中断使能是分层的,如果先开全局中断再配置mie,可能在配置过程中就来了中断,而mie还没配好,导致中断处理程序读到错误的cause。
正确做法:先配置mie和mideleg,再开全局中断mstatus.MIE。关中断时反过来,先关全局中断,再改配置。
6.5 性能计数器在U模式读不到
cycle、time、instret在U模式下默认不可读,需要mcounteren和scounteren的对应位为1。如果忘了配,U模式读这些CSR会触发非法指令异常。
正确做法:在M模式初始化时就把mcounteren和scounteren配好,通常设为0x7(允许cycle、time、instret)。
7. 调试技巧:怎么快速定位CSR相关的问题
7.1 用QEMU的trace功能看CSR访问
QEMU支持-d int参数打印异常和中断的详细信息,包括CSR的读写。如果你在QEMU上调试,这个功能非常有用。
qemu-system-riscv64 -d int -M virt -kernel your_kernel.elf输出里会显示每次异常的原因、mepc、mcause等,能快速定位是哪个CSR配错了。
7.2 在异常处理里打印关键CSR
如果是在真实硬件上调试,没有QEMU的trace,可以在异常处理程序里打印mcause、mepc、mstatus、mtval。mtval通常会保存触发异常的地址或指令,对定位问题很有帮助。
void dump_csrs(void) { printf("mcause: 0x%lx\n", csr_read(mcause)); printf("mepc: 0x%lx\n", csr_read(mepc)); printf("mtval: 0x%lx\n", csr_read(mtval)); printf("mstatus:0x%lx\n", csr_read(mstatus)); }7.3 用csrr伪指令做最小化测试
如果怀疑某个CSR配置有问题,可以写一段最小的汇编代码,只做CSR读写,排除其他干扰。
# 测试mstatus读写 csrr t0, mstatus li t1, 0x1800 csrw mstatus, t1 csrr t2, mstatus # 此时t2应该等于0x1800这种最小化测试能快速确认CSR是否按预期工作。
8. 从CSR看RISC-V特权架构的设计哲学
RISC-V的特权架构设计有一个很明显的思路:把复杂性留给软件,把灵活性留给实现。CSR的数量和功能是标准化的,但具体实现可以选择支持哪些扩展。比如嵌入式场景可能只需要M模式,不需要S模式;而应用处理器则需要完整的M/S/U三级。
另一个特点是异常委托机制。通过medeleg和mideleg,M模式可以把大部分异常和中断交给S模式处理,自己只保留最核心的功能。这种设计让M模式的固件可以做得非常小,而操作系统在S模式里有足够的控制权。
还有一个细节是CSR的原子操作。csrrs和csrrc的置位/清位是原子的,这在多核环境下很重要。比如多个核同时修改mstatus的不同位,用csrrs和csrrc可以避免读-改-写的竞争。
我个人在实际项目里的体会是:CSR配置看起来简单,但每一个位都有它的用途,配错一个位可能就会导致系统行为完全不符合预期。最好的办法是每次修改CSR之前先读出来,确认当前值,再修改,再读出来验证。这个习惯能帮你省下大量调试时间。
最后分享一个小技巧:如果你在写RISC-V的启动代码,建议把CSR的初始化分成几个阶段:最基础的mstatus和mtvec先配,然后是异常委托,最后是中断和性能计数器。每个阶段配完之后打印一下关键CSR的值,确认无误再进入下一阶段。这样即使出问题,也能快速定位到是哪个阶段配错了。