
1. 从三条执行路径说起为什么CSR和特权级是RISC-V的骨架很多人第一次接触RISC-V注意力都放在指令集有多精简、寄存器有多少个、流水线怎么搭。但真正让RISC-V从能跑的玩具核变成能跑操作系统的处理器的是两样东西CSRControl and Status Register控制状态寄存器和特权架构Privilege Architecture。这两个概念合在一起决定了处理器在什么状态下能做什么事、遇到异常该跳到哪里、操作系统怎么接管硬件。我刚开始看RISC-V特权手册的时候最大的困惑不是某个CSR的位域怎么定义而是为什么要有M/S/U三个模式两个不够吗。后来自己动手写了一个带异常处理的迷你核才真正理解这套设计的意图。简单说M模式Machine是最高权限负责最底层的硬件管理S模式Supervisor是给操作系统用的U模式User是给应用程序用的。三个模式层层下放权限每一层只能碰自己该碰的东西。这篇文章不是手册的翻译而是把我自己在做RISC-V相关开发时积累的CSR速查经验和特权级切换的实操理解整理出来。适合已经了解RISC-V基本指令、想深入理解特权架构和CSR用法的读者。如果你正在写裸机程序、移植操作系统、或者设计自己的RISC-V核这些内容应该能帮你少走一些弯路。先给一个全局视角CSR是处理器内部的配置面板特权级是门禁系统。配置面板里的每个旋钮CSR都有权限标签门禁系统决定你现在能拧哪些旋钮。两者是绑定的脱离特权级谈CSR没有意义。2. CSR编号规律与读写指令别把地址当随机数背2.1 CSR地址的编码逻辑CSR在RISC-V里是一个12位的地址空间总共4096个。乍一看很多但实际常用的也就几十个。关键在于这个地址不是随便分配的它的高位编码隐含了读写权限和特权级归属。CSR地址的12位中高4位bit[11:8]编码了这个寄存器在哪个特权级可访问以及是只读还是可读写。具体规则是这样的地址位[11:10]含义00用户级CSRU模式可访问01监督级CSRS模式可访问10保留11机器级CSRM模式可访问而bit[9:8]编码读写属性地址位[9:8]含义00只读01保留10保留11可读写这个规律非常实用。比如mstatus的地址是0x300拆开看11表示机器级00表示只读不对0x300的二进制是0011 0000 0000bit[11:10]11机器级bit[9:8]00。但mstatus明明是读写寄存器。这里要注意bit[9:8]00表示只读是针对非特权级访问而言的M模式自己访问自己的CSR不受这个限制。实际上地址编码中的读写位更多是给硬件做权限检查用的参考。再比如sstatus地址是0x100二进制0001 0000 0000bit[11:10]01监督级bit[9:8]00。ustatus地址是0x000bit[11:10]00用户级。记住这个规律之后你看到一个CSR地址就能大致判断它属于哪个特权级不用每次都翻手册。2.2 六条CSR访问指令RISC-V专门为CSR访问定义了六条指令都是原子操作读和写在一个指令内完成不会被中断打断。这是很多人容易忽略的细节——CSR访问不是简单的load/store而是有专门的指令编码。csrrw rd, csr, rs1 # 读旧值到rd将rs1写入csr csrrs rd, csr, rs1 # 读旧值到rd将rs1与csr按位或后写入 csrrc rd, csr, rs1 # 读旧值到rd将rs1取反后与csr按位与后写入 csrrwi rd, csr, imm # 立即数版本imm为5位零扩展 csrrsi rd, csr, imm # 立即数版本按位或 csrrci rd, csr, imm # 立即数版本按位与取反这里有个非常实用的技巧当你不需要读旧值时把rd设为x0。比如csrrw x0, mstatus, t0就是纯写入不产生读副作用。反过来当你不需要写时把rs1设为x0比如csrrs rd, mstatus, x0就是纯读取。这个用法在保存/恢复上下文时特别常见。还有一个坑csrrs和csrrc的或和与操作是读-改-写语义。如果你用csrrs去设置某个位它会先把整个CSR读出来或上你的值再写回去。这意味着如果这个CSR中有WARLWrite Any value, Read Legal value字段读出来的值可能和你上次写入的不一样。WARL字段是RISC-V特权架构里一个很重要的概念后面会专门讲。2.3 立即数版本的适用场景csrrwi、csrrsi、csrrci这三条指令的立即数是5位无符号数0-31。它们的好处是不占用通用寄存器在上下文切换时特别有用。比如你要清除mstatus的MIE位bit 3可以写csrrci x0, mstatus, 8 # 清除bit 3比先加载一个寄存器再操作要省一条指令。但注意立即数只能表示0-31如果要设置的位在更高的位置就只能用寄存器版本。3. M/S/U三层特权级权限边界到底划在哪里3.1 三个模式各自的职责RISC-V的特权架构定义了三个模式从高到低是M、S、U。不是所有实现都必须支持三个模式最简单的嵌入式核可以只有M模式跑Linux的核通常支持MSU。M模式是最高权限也是复位后处理器进入的默认模式。它负责系统启动初始化、物理内存保护PMP配置、中断和异常的底层委托、时钟和电源管理。M模式可以访问所有CSR和所有物理内存在没有PMP限制的情况下。S模式是给操作系统内核用的。它有自己的页表通过satp寄存器控制、自己的异常向量stvec、自己的中断使能sie/sip。S模式不能直接访问M模式的CSR但可以通过mstatus中的MPRV位和mret指令从M模式降级到S模式。U模式是应用程序运行的模式。它能访问的CSR非常有限主要是cycle、time、instret这些只读的性能计数器如果mcounteren允许的话。U模式不能执行特权指令不能访问S模式和M模式的CSR内存访问受页表限制。三层模式的切换不是随便跳的必须通过特定的陷阱trap和返回指令mret/sret来完成。这是硬件强制的安全边界软件绕不过去。3.2 特权级切换的完整路径从U模式到S模式的切换典型路径是这样的U模式执行ecall指令触发环境调用异常。硬件自动完成以下动作把当前PC保存到sepcS模式的异常PC寄存器把异常原因写入scause把出错地址如果是内存访问异常写入stval把当前特权级和中断使能状态保存到sstatus的SPP和SIE位把特权级切换到S模式关闭S模式中断SIE清零跳转到stvec指向的异常处理入口从S模式返回U模式执行sret指令。硬件会从sstatus.SPP恢复特权级从sstatus.SIE恢复中断使能跳转到sepc指向的地址M模式的mret类似但操作的是mstatus的MPP和MIE位以及mepc。这里有一个容易搞混的点sstatus是mstatus的一个视图。sstatus中的很多位其实是mstatus中对应位的影子。当你修改sstatus的某位时实际上改的是mstatus中的对应位。这个设计是为了让S模式在不访问M模式CSR的前提下也能管理自己的状态。3.3 委托机制把异常处理下放RISC-V特权架构里有一个非常优雅的设计异常委托delegation。M模式可以通过medeleg和mideleg两个CSR把某些异常和中断直接委托给S模式处理不需要先陷入M模式再转发。medeleg是一个64位寄存器RV64每一位对应一种异常类型。比如bit 8对应U模式的环境调用ecall from U-mode如果这一位置1那么U模式执行ecall时硬件直接跳到S模式的stvec而不是M模式的mtvec。mideleg类似控制中断的委托。比如把监督级定时器中断STI委托给S模式S模式就可以自己处理时钟中断不用M模式介入。这个机制的好处是减少模式切换开销。如果没有委托每次U模式的系统调用都要先陷入M模式M模式再转发给S模式多了一次切换。有了委托U模式直接陷入S模式路径更短。但委托也有代价M模式失去了对这些异常的可见性。如果M模式需要监控S模式的行为比如做安全审计就不能全部委托。实际系统中通常会保留一些关键异常在M模式处理比如非法指令异常、物理内存访问异常等。4. 那些必须刻在脑子里的核心CSR4.1 mstatus机器模式的状态中枢mstatus是M模式最重要的CSR地址0x300。它的位域很多但常用的就那么几个位名称含义0UIEU模式中断使能RV64中实际是SIE的影子1SIES模式中断使能3MIEM模式中断使能4SPIES模式先前中断使能7MPIEM模式先前中断使能8SPPS模式先前特权级11:12MPPM模式先前特权级17MPRV修改特权级用于M模式访问用户内存18SUM允许S模式访问U模式内存19MXR允许读可执行页这里重点说两个容易踩坑的位MPRV和SUM。MPRV位是给M模式用的。当MPRV1时load和store指令的内存访问权限检查使用MPP字段指定的特权级而不是当前特权级。这个机制让M模式可以模拟成S模式或U模式去访问内存常用于操作系统在M模式中读写用户态数据。但注意MPRV只影响load/store不影响取指。SUM位是给S模式用的。正常情况下S模式不能访问U模式的内存页防止内核意外读写用户数据。当SUM1时S模式可以访问U模式的内存页。Linux内核在需要拷贝用户数据时会临时设置SUM位用完立即清除。这个位如果忘记清除会留下安全隐患。4.2 mtvec/stvec异常入口的两种模式mtvec和stvec分别指定M模式和S模式的异常处理入口。它们的最低两位编码了模式00直接模式Direct。所有异常都跳到同一个地址。01向量模式Vectored。异常入口地址 BASE 4 × cause。向量模式的好处是每种异常有独立的入口不用在软件里判断cause。但中断和异常的cause编号空间是分开的向量模式下需要小心处理。实际使用中很多系统用直接模式然后在软件里根据mcause/scause分发。因为异常处理入口通常需要保存上下文统一的入口反而更方便。4.3 satpS模式的页表控制satpSupervisor Address Translation and Protection是S模式控制虚拟内存的核心CSR。它的位域在RV32和RV64中不同RV64的satpbit[63:60]MODE0表示Bare无地址转换8表示Sv399表示Sv4810表示Sv57bit[59:44]ASID地址空间标识符bit[43:0]PPN根页表的物理页号写satp会触发TLB刷新除非实现支持ASID且新旧ASID不同。这是一个昂贵的操作操作系统在切换页表时要尽量减少satp的写入次数。还有一个细节satp的写入在sfence.vma指令执行前不保证生效。RISC-V的内存模型是弱序的修改页表后必须执行sfence.vma来确保地址转换的一致性。这个坑我在调试页表切换时踩过——改了页表但没执行sfence.vma结果CPU还在用旧的TLB条目导致莫名其妙的页错误。4.4 异常相关CSR速查表CSR地址所属模式用途mcause0x342M异常/中断原因mepc0x341M异常PCmtval0x343M异常附加信息mtvec0x305M异常向量基址medeleg0x302M异常委托mideleg0x303M中断委托scause0x142S异常/中断原因sepc0x141S异常PCstval0x143S异常附加信息stvec0x105S异常向量基址sie0x104S中断使能sip0x144S中断挂起satp0x180S页表基址这张表建议直接记住。调试异常的时候第一件事就是看mcause/scause确定异常类型看mepc/sepc确定出错位置看mtval/stval确定出错地址或指令。5. 中断与异常的硬件处理流程从触发到返回5.1 中断的使能与优先级RISC-V的中断分为三类软件中断SI、定时器中断TI、外部中断EI。每一类在M模式和S模式各有一个使能位和挂起位。M模式的中断使能由mstatus.MIE总控具体某类中断是否使能由mie寄存器控制。S模式类似sstatus.SIE总控sie控制具体类型。中断的优先级是固定的外部中断 软件中断 定时器中断。同一优先级内M模式中断优先于S模式中断。这个优先级是硬件规定的软件改不了。一个容易忽略的点当处理器在M模式时S模式的中断不会触发。即使sstatus.SIE1且sie中对应位使能只要当前在M模式S模式中断就被屏蔽。这是特权级隔离的一部分。5.2 异常处理的完整硬件流程当异常发生时硬件按以下顺序动作以M模式为例确定异常原因写入mcause。最高位表示是中断1还是异常0低位是原因编号。保存返回地址写入mepc。对于中断保存的是被中断指令的地址对于异常保存的是出错指令的地址。保存附加信息写入mtval。对于非法指令异常保存指令编码对于地址不对齐异常保存出错地址对于其他异常可能为0。保存特权级和中断使能mstatus.MPP保存当前特权级mstatus.MPIE保存当前MIE然后MIE清零。切换特权级切换到M模式。跳转PC设置为mtvec的值直接模式或mtvec.BASE 4×cause向量模式。整个过程是原子的不会被其他异常打断。但有一个例外如果异常处理过程中又发生了更高优先级的异常硬件会覆盖mcause和mepc导致原始异常信息丢失。所以异常处理入口的第一件事通常是保存这些CSR的值。5.3 mret/sret的返回细节mret指令的执行流程特权级设置为mstatus.MPPmstatus.MIE设置为mstatus.MPIEmstatus.MPIE设置为1如果mstatus.MPP不是M模式mstatus.MPRV清零PC设置为mepc注意第4步从M模式返回到低特权级时MPRV自动清零。这是为了防止M模式在低特权级下意外使用MPRV访问内存。sret类似但操作的是sstatus的SPP、SIE、SPIE位以及sepc。sret不会修改MPRV。还有一个细节mepc的最低两位在写入时会被硬件清零因为指令地址至少4字节对齐。如果你在异常处理中修改了mepc要注意这个对齐要求。6. 实操中容易踩的坑与调试技巧6.1 WARL字段读出来的值可能不是你写进去的WARLWrite Any value, Read Legal value是RISC-V特权架构中一个非常重要的概念。很多CSR字段是WARL的意味着你可以写入任意值但硬件会把它合法化后再存起来。读出来的值可能和你写入的不同。比如mstatus.MPP是WARL字段。如果你的实现只支持M和U两个模式那么写入10S模式时硬件可能把它变成00U模式或11M模式。具体变成什么取决于实现。这个特性对软件的影响是不要假设你写入的值就是读出的值。在保存/恢复上下文时如果直接保存读出的值可能丢失信息。正确的做法是保存你写入的原始值或者在接受WARL行为的前提下使用。调试时如果发现某个CSR的值不对劲先查手册确认这个字段是不是WARL的。6.2 中断使能的嵌套与优先级反转在异常处理中重新使能中断是一个需要谨慎对待的操作。RISC-V的mret/sret会自动恢复中断使能但在处理过程中如果你手动设置了mstatus.MIE可能会遇到嵌套中断。嵌套中断本身不是问题问题是栈的管理。如果异常处理程序没有正确保存上下文嵌套中断会覆盖寄存器导致返回后程序崩溃。我的经验是在异常处理入口先保存所有 caller-saved 寄存器或者全部保存然后再考虑是否使能中断。对于简单的裸机程序直接关中断处理完再开是最省事的。另一个坑是优先级反转。如果低优先级中断的处理程序长时间关中断高优先级中断会被延迟。RISC-V没有硬件优先级仲裁除了固定的中断类型优先级软件需要自己管理。6.3 调试CSR访问异常的排查链路当你遇到非法指令异常或非法CSR访问异常时排查思路是这样的第一步确认当前特权级。读mstatus.MPP或sstatus.SPP看异常发生前在哪个模式。如果是U模式访问S模式CSR那异常是预期的。第二步确认CSR地址。读mtval如果是非法CSR访问有些实现会把CSR地址放在mtval里。对照CSR地址编码规则确认这个CSR属于哪个特权级。第三步确认委托设置。如果异常应该被委托给S模式但实际陷入了M模式检查medeleg和mideleg的对应位是否设置。第四步确认mcounteren/scounteren。如果U模式访问cycle、time、instret这些计数器时出错检查mcounteren中对应的使能位是否打开。默认情况下这些位是0U模式访问会触发非法指令异常。这个排查链路我实际用过很多次基本上能覆盖90%的CSR访问异常。6.4 上下文切换时的CSR保存清单做任务切换时需要保存和恢复的CSR取决于任务运行的特权级。如果所有任务都在U模式需要保存的CSR包括通用寄存器x1-x31sepc如果任务可能被中断sstatusSPP、SIE、SPIEscause、stval如果任务需要知道上次异常原因浮点寄存器如果使用了FPU如果任务在S模式还需要保存satp页表基址和stvec。M模式的CSR通常不需要在任务间切换因为M模式代码是全局的。一个常见的错误是忘记保存sstatus。如果任务A在中断使能状态下被切换出去任务B在中断关闭状态下被切换进来恢复任务A时如果不恢复sstatus.SIE任务A的中断就再也开不起来了。7. 从CSR视角理解RISC-V的设计哲学7.1 最小化硬件最大化软件灵活性RISC-V的CSR设计有一个明显的倾向硬件只提供必要的机制策略留给软件。比如异常委托硬件只提供medeleg/mideleg两个寄存器具体委托哪些异常、怎么处理完全由软件决定。再比如中断优先级硬件只规定了中断类型之间的固定优先级同一类型内的多个中断源怎么仲裁交给中断控制器PLIC去处理。这种设计的好处是硬件简单、可验证性强。代价是软件需要做更多工作。但对于一个要覆盖从微控制器到超级计算机的指令集来说这种取舍是合理的。7.2 特权级的正交性RISC-V的特权级设计和CSR访问控制是正交的。特权级决定你能进哪个门CSR地址编码决定这个抽屉属于哪个房间。两者结合形成完整的访问控制矩阵。这种正交性让RISC-V可以灵活裁剪。一个只有M模式的嵌入式核不需要实现S模式和U模式的CSR也不需要实现委托机制。一个完整的应用处理器则实现全部三个模式和完整的CSR集合。软件可以通过读取misa寄存器或尝试访问某些CSR来判断当前实现支持哪些特性。7.3 与邻接表CSR压缩存储的区分搜索热词里有一个容易混淆的点邻接表 和csr压缩存储 内存空间消耗是同一个量级吗。这里的CSR指的是Compressed Sparse Row是一种稀疏矩阵存储格式和RISC-V的Control and Status Register完全是两回事。邻接表存储图时每个顶点维护一个链表空间开销是O(VE)但指针开销大。CSR压缩存储用两个数组列索引和行偏移表示稀疏矩阵空间开销也是O(VE)但没有指针开销缓存友好性更好。两者在渐近复杂度上是同一个量级但常数因子不同。CSR通常比邻接表更省内存因为不需要存储指针。这个混淆在搜索时经常出现写RISC-V相关内容时要注意区分避免读者误解。8. 几个实际场景中的CSR配置示例8.1 裸机启动时的最小CSR配置一个典型的RISC-V裸机启动流程中CSR配置顺序是这样的# 1. 设置异常入口 la t0, trap_entry csrw mtvec, t0 # 2. 配置PMP如果需要 # 允许M模式访问所有地址 li t0, 0x1f csrw pmpcfg0, t0 li t0, -1 csrw pmpaddr0, t0 # 3. 使能定时器中断 li t0, 0x80 # MTIE bit 7 csrw mie, t0 # 4. 使能全局中断 li t0, 0x8 # MIE bit 3 csrs mstatus, t0 # 5. 设置定时器 li t0, 100000 csrw mtimecmp, t0这个顺序很重要先设置入口再配置中断源最后开全局中断。如果顺序反了中断可能在入口还没设置好时就触发导致跳转到非法地址。8.2 从M模式切换到S模式的完整流程# 假设要跳转到S模式的 entry_s 地址 la t0, entry_s csrw mepc, t0 # 设置MPP为S模式01 li t0, 0x800 # MPP bit 11 csrc mstatus, t0 # 先清除 li t0, 0x400 # MPP 01 csrs mstatus, t0 # 设置MPIE为1返回后中断使能 li t0, 0x80 # MPIE bit 7 csrs mstatus, t0 # 执行mret mret注意MPP是两位字段bit 11-12设置时要先清除再设置避免残留旧值。这个坑我在第一次写模式切换时踩过——只设置了bit 11但bit 12残留了之前的值导致MPP变成了11M模式mret后还在M模式白折腾了半天。8.3 页表切换时的sfence.vma使用# 切换页表 la t0, new_page_table srli t0, t0, 12 # 取物理页号 li t1, 8 # Sv39模式 slli t1, t1, 60 or t0, t0, t1 csrw satp, t0 # 刷新TLB sfence.vma x0, x0 # 刷新所有TLB条目sfence.vma的两个参数是vaddr和asid。sfence.vma x0, x0刷新所有地址空间的所有条目。如果只想刷新特定地址可以把vaddr设为该地址。如果只想刷新特定ASID把asid设为对应值。在多核系统中一个核修改了页表其他核的TLB也需要刷新。这需要通过核间中断IPI来通知其他核执行sfence.vma。这个机制在Linux内核中叫TLB shootdown是性能敏感操作。9. 我个人的CSR调试工具箱做了几个RISC-V项目之后我养成了一个习惯在异常处理入口打印关键CSR的值。这个习惯帮我定位了无数问题。具体来说我会在trap_entry中保存以下信息mcause/scause异常原因mepc/sepc出错地址mtval/stval附加信息mstatus/sstatus异常发生时的状态所有通用寄存器把这些信息打印出来基本上能还原异常发生时的现场。如果mepc指向的地址是合法的代码段那可能是数据访问异常如果指向非法地址可能是跳转错误。另一个实用技巧是用CSR做性能计数。mcycle和minstret是硬件维护的周期计数和指令退休计数。在关键代码段前后读取这两个CSR可以算出IPC每周期指令数。如果IPC突然下降说明流水线有停顿可能是缓存未命中或分支预测失败。还有一个不太常用但很有用的CSRmhartid。在多核系统中每个核的mhartid不同。启动时可以用它来判断自己是哪个核从而决定执行哪段初始化代码。这个CSR是只读的地址0xF14。最后说一个关于CSR访问性能的观察CSR访问通常比普通load/store慢因为CSR不在数据缓存中而且可能涉及特权级检查。在性能敏感的代码中尽量减少CSR访问次数。比如中断处理中不要每次都用csrrs去读mip判断中断源可以在入口一次性读取并保存。这些经验都是实际调试中积累的手册上不会写。希望对你理解RISC-V的CSR和特权架构有所帮助。