
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非特权级CSRU模式可访问cycle、time、instret0x100-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、instret0xD00-0xDFF非特权级CSR读写自定义0xE00-0xEFF非特权级CSR读写自定义0xF00-0xFFF非特权级CSR读写自定义上面这张表是非特权级的部分真正和特权架构相关的是0xC00以上的区域。但更关键的是地址的高两位csr[11:10]决定了这个CSR在哪些特权级下可访问00U模式可读写01S模式可读写U模式只读10S模式可读写U模式不可访问11M模式可读写S/U模式不可访问这个规则非常重要。比如mstatus的地址是0x300高两位是11所以只有M模式能访问。sstatus的地址是0x100高两位是01S模式可读写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或01S模式访问通常没问题。更细的规则是当mstatus.MPRV1时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范围典型用途异常入口CSRM全部固件、BootROM、安全监控mtvecSS级和U级操作系统内核stvecU非特权级应用程序无通过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之一。它的字段很多但常用的就那么几个字段位含义典型值MIE3M模式全局中断使能1开0关MPIE7异常前的中断使能mret时恢复到MIEMPP12:11异常前的特权级0U, 1S, 3MMPRV17内存访问特权级覆盖1用MPP的特权级访问SUM18S模式访问U模式内存1允许MXR19执行权限影响读1可读可执行页TVM20虚拟内存控制1禁止S模式写satpTW21等待指令控制1U模式wfi触发异常TSR22sret控制1U模式sret触发异常初始化mstatus的典型代码// 关闭中断设置MPP为M模式清其他位 csr_write(mstatus, 0x1800); // MPP3, MIE0这里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保存异常发生时的PCmret会跳回这个地址。但有一个细节对于某些异常mepc保存的是触发异常的指令地址对于另一些保存的是下一条指令的地址。比如ecall指令mepc保存的是ecall本身的地址。如果你在异常处理里不修改mepcmret之后会再次执行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裸机8Sv399Sv4810Sv57位59:44ASID地址空间标识符位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控制如果TVM1S模式写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为0U模式设置mepc为U模式代码的入口地址配置medeleg和mideleg把该委托的异常和中断委托给S模式如果有S模式设置mtvec确保异常能回到M模式处理执行mretvoid 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把cause8ecall 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模式入口执行mretS模式初始化页表、中断设置sstatus.SPP为U模式sepc为U模式入口执行sretU模式运行应用程序通过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 Mcause 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和midelegM模式可以把大部分异常和中断交给S模式处理自己只保留最核心的功能。这种设计让M模式的固件可以做得非常小而操作系统在S模式里有足够的控制权。还有一个细节是CSR的原子操作。csrrs和csrrc的置位/清位是原子的这在多核环境下很重要。比如多个核同时修改mstatus的不同位用csrrs和csrrc可以避免读-改-写的竞争。我个人在实际项目里的体会是CSR配置看起来简单但每一个位都有它的用途配错一个位可能就会导致系统行为完全不符合预期。最好的办法是每次修改CSR之前先读出来确认当前值再修改再读出来验证。这个习惯能帮你省下大量调试时间。最后分享一个小技巧如果你在写RISC-V的启动代码建议把CSR的初始化分成几个阶段最基础的mstatus和mtvec先配然后是异常委托最后是中断和性能计数器。每个阶段配完之后打印一下关键CSR的值确认无误再进入下一阶段。这样即使出问题也能快速定位到是哪个阶段配错了。