ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RISC-V特权架构实战:CSR寄存器与M/S/U特权级切换详解

RISC-V特权架构实战:CSR寄存器与M/S/U特权级切换详解 1. 从一条报错说起为什么你必须搞懂 CSR 和特权级第一次在 RISC-V 上跑裸机程序十有八九会撞上一条让人摸不着头脑的异常mcause 0x2也就是 illegal instruction。你盯着反汇编看了半天指令明明合法为什么 CPU 说它非法答案往往藏在 CSR 里——你试图在 U 模式访问一个只有 M 模式才能碰的寄存器或者忘了在mstatus里打开某个位导致浮点指令被当成非法指令。这就是 RISC-V 特权架构的日常。和 ARM 那种动辄上千页的架构手册相比RISC-V 的特权规范其实相当克制但它的“克制”是有代价的很多东西没有默认帮你做好需要你亲手配置 CSR。CSRControl and Status Register控制状态寄存器是 RISC-V 里软件和硬件对话的唯一窗口而M/S/U 三个特权级则决定了谁能碰哪些 CSR、谁能执行哪些指令。这篇内容适合三类人正在写 RISC-V 裸机启动代码的嵌入式工程师、做操作系统移植或 RTOS 适配的开发者、以及准备自己搓一个 RISC-V 核或者写模拟器的爱好者。我会把 CSR 的编号规则、M/S/U 的切换机制、常见的 CSR 速查表、以及实际调试中踩过的坑一次性讲清楚。读完你应该能做到看到任意一个 CSR 名字能立刻判断它属于哪个特权级、大概管什么遇到特权级切换的 bug知道从哪几个寄存器下手排查。2. CSR 到底是什么编号规则与访问机制2.1 CSR 的 12 位地址是怎么分配的CSR 在指令里用 12 位地址编码也就是最多 4096 个。这个数字看着不多但 RISC-V 用了一套非常聪明的位域划分让地址本身就携带了“权限信息”。你只要记住这张表看到地址就能反推它的属性位域名称含义[11:10]读写权限00只读01读写10只读11读写[9:8]最低特权级00U01S10H11M[7:6]只读/读写11 表示只读其他表示读写[5:0]具体编号同一功能组内的序号这里有个容易混淆的点bit[11:10] 和 bit[7:6] 都涉及读写属性。实际规范里bit[11:10]11 表示该 CSR 是只读的这是判断只读 CSR 的主要依据。比如misa机器指令集架构寄存器地址是0x301拆开看bit[11:10]00bit[9:8]11M 级bit[7:6]00所以它是 M 级可读写……等等misa其实是只读的。这里我要纠正一下自己misa的地址0x301中 bit[11:10]00 表示读写但规范里misa被定义为 WARLWrite Any Values, Reads Legal Values写进去的值可能被硬件忽略或修正所以它“看起来可写实际由硬件说了算”。这种细节正是 CSR 让人头疼的地方。我的建议是别死记地址记住功能组的高位规律。比如0xC00到0xCFF这一段全是只读的性能计数器cycle、time、instret 及其高 32 位0xF11到0xF14是只读的机器信息寄存器mvendorid、marchid、mimpid、mhartid。这些在调试时特别有用后面会细说。2.2 CSR 指令六个助记符和它们的隐含语义访问 CSR 只有六条指令全部属于 Zicsr 扩展csrrw rd, csr, rs1 # 读旧值到 rd写 rs1 到 csr csrrs rd, csr, rs1 # 读旧值到 rd将 rs1 的置位写进 csrset bits csrrc rd, csr, rs1 # 读旧值到 rd将 rs1 的置位清零clear bits csrrwi rd, csr, uimm # 立即数版本写 5 位立即数 csrrsi rd, csr, uimm # 立即数版本置位 csrrci rd, csr, uimm # 立即数版本清零这里有个非常实用的技巧当 rd x0 时指令不读旧值只写当 rs1 x0 时指令不写只读。所以csrrs x0, mstatus, x0实际上是一条“空操作但会触发权限检查”的指令常被用来探测某个 CSR 是否可访问。而csrr x0, mcause这种写法伪指令csrr展开为csrrs rd, csr, x0就是纯粹的读取。我在写启动代码时最常用的是csrrs和csrrc的“读-改-写”模式。比如要打开mstatus里的 MIE 位全局中断使能标准写法是csrrs t0, mstatus, (1 3) # 读 mstatus 到 t0同时置位 bit3注意这里t0拿到的是修改前的旧值如果你后续还要用旧值做判断得先保存。这个“读旧值”的特性在保存上下文时特别有用但也很容易写出 bug——我曾经在一个中断处理里忘了csrrs会覆盖t0结果把上层保存的变量冲掉了查了一下午。2.3 哪些 CSR 是“必须实现”的RISC-V 特权规范把 CSR 分成“必须实现”和“可选实现”两类。对于 M 模式必须实现的有mstatus、misa、mie、mtvec、mscratch、mepc、mcause、mtval、mipmvendorid、marchid、mimpid、mhartid只读信息类mcycle、minstret及其高 32 位如果实现了性能计数器S 模式必须实现的有sstatus、sie、stvec、sscratch、sepc、scause、stval、sip、satp。U 模式本身没有专属 CSR它只能通过 S 模式配置的sstatus里的位来间接感知自己的状态。这里有个坑很多精简核只实现 M 模式不实现 S/U。如果你拿到一个核发现sstatus访问就报 illegal instruction别怀疑代码先查misa的 bit[9:8]S 位和 bit[7:6]U 位是否为 1。misa的地址是0x301读出来看低 26 位里有没有 S 和 U 的标志位这是判断核支持哪些特权级最快的方法。3. M/S/U 三个特权级谁管谁怎么切3.1 特权级的层级关系与典型职责RISC-V 定义了三个严格说是四个加上 H 扩展的 hypervisor 模式特权级编号越大权限越高特权级编码典型职责能访问的 CSR 范围U0用户程序仅非特权 CSR如 cycle、timeS1操作系统内核S 级 U 级 CSRM3固件、BootROM、安全监控全部 CSR注意 H 模式编码是 2夹在 S 和 M 之间但普通场景很少用到。M 模式是“最高权限”它不受任何限制可以访问所有 CSR、执行所有指令。S 模式是给操作系统用的它管不了 M 级的 CSR但可以配置页表、处理异常。U 模式最受限连mstatus都看不到。一个常见的误解是“M 模式就是内核态”。其实在典型的 Linux 系统里内核跑在 S 模式M 模式留给 OpenSBI 这样的固件。M 模式负责最底层的初始化、中断委托、以及处理那些 S 模式处理不了的异常比如 S 模式自己出的错。这种分层设计的好处是即使 S 模式内核崩了M 模式固件还能兜底做安全监控或重启。3.2 特权级切换的三种触发方式特权级不会无缘无故变化只有三种情况会触发切换第一种异常Exception。当当前特权级执行出错非法指令、缺页、断点等硬件会自动把特权级提升到medeleg/mideleg指定的处理级。默认情况下所有异常都进 M 模式如果 S 模式存在且medeleg里对应位被置 1异常会委托给 S 模式处理。第二种中断Interrupt。和异常类似但走的是mip/mie和sip/sie的路径。中断的委托由mideleg控制。这里有个细节M 模式的中断永远进 M 模式不能被委托因为 M 模式中断是给固件用的比如定时器中断用于任务调度。第三种显式跳转。通过mret、sret、uret指令从高特权级返回低特权级。注意没有“从低到高”的显式指令想进高特权级只能靠异常或中断。这是 RISC-V 安全模型的核心低特权级无法主动提权必须通过受控的入口。切换时硬件会自动做几件事把当前 PC 保存到mepc/sepc把原因写到mcause/scause把出错地址写到mtval/stval然后跳转到mtvec/stvec指定的入口。同时mstatus里的 MIE 位会被保存到 MPIEMIE 被清零关中断特权级更新。返回时mret会反向操作恢复 MIE跳回mepc。3.3 mstatus 里那些容易搞混的位mstatus是特权架构里最复杂的寄存器之一位域多且互相影响。我挑几个实际开发中绕不开的位来说MIEbit 3M 模式全局中断使能。注意它和mie寄存器是两回事mie是各中断源的使能MIE 是总开关。MPIEbit 7进入异常时 MIE 的备份。mret时会自动恢复。MPPbit 12:11进入异常前的特权级。mret会根据这个位决定返回到哪个级别。这个位是可写的所以你可以通过修改 MPP 来“伪造”返回目标这是实现特权级切换的关键技巧。MPRVbit 17修改特权级。置 1 后load/store 会使用 MPP 指定的特权级做权限检查而不是当前特权级。这在 M 模式代 S 模式访问用户内存时非常有用。SUMbit 18允许 S 模式访问 U 模式内存。默认是 0意味着 S 模式不能直接读用户页。很多内核在拷贝用户数据时会临时置 1。MXRbit 19允许执行读操作时把可执行页也当成可读。这个位在 JIT 场景下有用。S 模式的sstatus是mstatus的子集只有 SIE、SPIE、SPP、SUM、MXR 等位。对sstatus的写入会同步到mstatus的对应位反之亦然。这个联动关系经常让人困惑你在 S 模式改了sstatus.SIE切到 M 模式读mstatus会发现 SIE 也变了。这不是 bug是设计如此。4. 异常与中断的委托机制medeleg 和 mideleg4.1 委托的本质把处理权下放medelegMachine Exception Delegation和midelegMachine Interrupt Delegation是两个 M 级 CSR它们的每一位对应一种异常或中断。置 1 表示“这个异常/中断交给 S 模式处理”置 0 表示“M 模式自己处理”。举个例子如果想让 S 模式处理缺页异常cause 13和系统调用cause 8你需要// 假设 medeleg 已经可写 csrrs t0, medeleg, (1 13) | (1 8)这样当 U 模式或 S 模式触发缺页时硬件会直接跳到stvec而不是mtvec。S 模式内核就能自己处理页错误不用惊动 M 模式固件。但有几个异常是不能委托的规范里明确禁止Instruction address misalignedcause 0Instruction access faultcause 1Illegal instructioncause 2Breakpointcause 3Load/Store address misalignedcause 4/6Load/Store access faultcause 5/7Environment call from M-modecause 11这些异常如果发生在 M 模式永远进 M 模式如果发生在 S/U 模式且medeleg对应位为 1可以委托给 S。但 M 模式自己触发的这些异常委托位无效必须 M 模式处理。这个规则保证了 M 模式固件的控制权不会被绕过。4.2 中断委托的特殊性中断委托比异常委托更微妙。mideleg可以委托 S 模式的外部中断、定时器中断、软件中断但M 模式的定时器中断MTI和软件中断MSI不能被委托。原因很简单M 模式需要定时器来做抢占式调度如果被委托走了固件就失去了对时间的控制。实际配置时典型的 OpenSBI 会这样设置// 委托 S 模式外部中断、定时器中断、软件中断 csrrs t0, mideleg, (1 9) | (1 5) | (1 1)其中 bit 9 是 S 外部中断bit 5 是 S 定时器中断bit 1 是 S 软件中断。委托之后这些中断会走stvecS 模式内核用sip/sie来管理。这里有个我踩过的坑委托了中断但忘了在sie里使能或者在sstatus里没开 SIE结果中断来了却没人处理系统看起来像死机。排查时先读mip看中断是否 pending再读mie和mideleg确认路由最后读sip/sie/sstatus确认 S 模式是否真的能收到。这个顺序能帮你快速定位是“没产生”“没委托”还是“没使能”。4.3 委托后的返回路径当异常被委托给 S 模式处理完后用sret返回。sret会从sepc恢复 PC从sstatus.SPIE恢复 SIE并根据sstatus.SPP决定返回 U 还是 S 模式。注意sret不能返回到 M 模式如果SPP被错误地设成了 M 的编码3行为是未定义的。所以 S 模式内核在保存上下文时一定要确保SPP只可能是 0 或 1。M 模式的mret则灵活得多它可以根据MPP返回到 U、S 或 M。这也是为什么 M 模式固件能在系统启动时“降级”到 S 模式执行内核——它把MPP设成 1Smepc设成内核入口然后执行mret硬件就切到 S 模式了。5. CSR 速查表按功能分类的实用清单5.1 机器模式M核心 CSR地址名称功能读写0x300mstatus机器状态读写0x301misa指令集架构只读/WARL0x302medeleg异常委托读写0x303mideleg中断委托读写0x304mie中断使能读写0x305mtvec异常入口读写0x306mcounteren计数器使能读写0x310mstatushmstatus 高 32 位读写0x340mscratch临时寄存器读写0x341mepc异常 PC读写0x342mcause异常原因读写0x343mtval异常值读写0x344mip中断 pending读写0x3A0pmpcfg0PMP 配置 0读写0x3B0pmpaddr0PMP 地址 0读写0xB00mcycle周期计数读写0xB02minstret指令计数读写0xF11mvendorid厂商 ID只读0xF12marchid架构 ID只读0xF13mimpid实现 ID只读0xF14mhartid硬件线程 ID只读这张表里mhartid在多核启动时特别重要。每个核读自己的mhartid如果是 0 就当主核做初始化其他核先自旋等待。这是 RISC-V 多核启动的标准套路。5.2 监管者模式S核心 CSR地址名称功能读写0x100sstatus监管者状态读写0x102sedeleg异常委托H 扩展读写0x103sideleg中断委托H 扩展读写0x104sie中断使能读写0x105stvec异常入口读写0x106scounteren计数器使能读写0x140sscratch临时寄存器读写0x141sepc异常 PC读写0x142scause异常原因读写0x143stval异常值读写0x144sip中断 pending读写0x180satp地址转换与保护读写satp是 S 模式最关键的寄存器它控制页表基址和分页模式。写satp会触发 TLB 刷新所以切换页表时要注意顺序先写好页表内存再写satp最后用sfence.vma刷新 TLB。顺序错了会出现“页表更新了但 TLB 还是旧映射”的诡异问题。5.3 非特权 CSRU 模式也能读的计数器地址名称功能0xC00cycle周期计数低 32 位0xC01time定时器低 32 位0xC02instret指令计数低 32 位0xC80cycleh周期计数高 32 位0xC81timeh定时器高 32 位0xC82instreth指令计数高 32 位这些 CSR 的访问受mcounteren和scounteren控制。默认情况下U 模式读cycle会触发 illegal instruction除非 M 模式在mcounteren里把对应位置 1。这个设计是为了防止侧信道攻击——通过高精度计数器推测其他进程的行为。实际产品里很多安全核会直接禁用 U 模式对这些计数器的访问。6. 实操从 M 模式降级到 S 模式的完整流程6.1 启动阶段M 模式都做了什么一个典型的 RISC-V 启动流程M 模式固件比如 OpenSBI会按顺序做这些事设置栈指针。每个核的栈要分开通常用mhartid计算偏移。配置mtvec。把异常入口指向固件的 trap handler。初始化 PMP。设置物理内存保护至少要让 S 模式能访问所有内存。配置委托。写medeleg和mideleg把该给 S 模式的异常和中断委托出去。配置mcounteren。允许 S 模式读计数器。准备 S 模式的执行环境。设置mepc为内核入口mstatus.MPP为 1S 模式mstatus.MPIE为 1返回后开中断。执行mret。硬件切换到 S 模式PC 跳到内核入口。第 6 步里有个细节mstatus.MPP是两位写 1 表示 S 模式。但如果你直接写mstatus可能会不小心改到其他位。标准做法是读-改-写csrr t0, mstatus li t1, ~(3 11) # 清除 MPP and t0, t0, t1 li t1, (1 11) # 设置 MPP 1 (S) or t0, t0, t1 csrw mstatus, t0这段代码里~(3 11)生成一个除了 bit12:11 全 1 的掩码用来清除 MPP。然后置上 S 模式的编码。这种位操作在 CSR 配置里到处都是建议封装成宏不然很容易写错。6.2 S 模式内核的初始化要点S 模式拿到控制权后第一件事是设置自己的stvec然后配置sie和sstatus.SIE。但要注意在设置stvec之前如果发生异常会跳到 M 模式的mtvec因为 S 模式的 trap 还没准备好。所以顺序很重要// 1. 先设置 stvec csrw stvec, (uintptr_t)trap_entry; // 2. 使能需要的中断 csrw sie, SIE_SSIE | SIE_STIE | SIE_SEIE; // 3. 最后开全局中断 csrs sstatus, SSTATUS_SIE;如果顺序反了在stvec还是 0 的时候来了中断硬件会跳到地址 0大概率直接跑飞。这个坑我在第一次移植 RTOS 时踩过现象是系统启动后随机重启查了很久才发现是中断使能早了一步。6.3 特权级切换的调试技巧调试特权级问题时最有效的手段是在 trap handler 里打印关键 CSR。我通常会在mtvec和stvec的入口处加一段汇编把mcause、mepc、mtval、mstatus存到内存的固定位置然后用调试器读出来。这样即使系统跑飞也能看到最后一次异常的原因。另一个技巧是利用mscratch和sscratch做上下文交换。标准做法是在 trap 入口先把当前寄存器保存到mscratch指向的内存然后从mscratch加载内核栈指针。这个交换过程只有几条指令但能保证不破坏任何用户寄存器。sscratch在 S 模式里做同样的事。还有一个容易被忽略的点mepc的写入要保证 4 字节对齐如果没开 C 扩展或 2 字节对齐开了 C 扩展。如果mepc不对齐mret会触发 instruction address misaligned 异常然后你又回到 trap handler形成死循环。我在写模拟器时遇到过这个问题现象是mret之后立刻又进异常mcause一直是 0。7. 常见问题与排查速查表7.1 典型问题与解决思路现象可能原因排查方法illegal instruction (mcause2)访问了不存在的 CSR 或权限不足读misa确认支持的特权级检查 CSR 地址的特权位中断不触发MIE/SIE 未开或mie/sie未使能依次读mstatus、mie、mip、sstatus、sie、sipmret 后跑飞mepc地址错误或MPP设置错误检查mepc是否指向合法代码MPP是否为预期值S 模式访问 U 内存失败sstatus.SUM为 0临时置位 SUM 或修改页表权限页表切换后映射错乱TLB 未刷新写satp后执行sfence.vma多核启动只有一个核跑其他核未正确自旋或mhartid读取错误检查每个核的启动代码确认mhartid分支逻辑7.2 几个我踩过的坑和独家技巧坑一csrrs的读旧值特性导致变量被覆盖。前面提过csrrs rd, csr, rs1会把旧值写到rd。如果你用t0做临时寄存器而t0里正好存着重要数据就会被冲掉。我的习惯是CSR 操作统一用t0和t1并且在操作前确认这两个寄存器是空闲的。在 trap handler 里先把所有寄存器保存到栈上再动 CSR。坑二mstatus.MPP的写入需要读-改-写。直接csrw mstatus, (1 11)会把其他位全清零包括 MIE、MPIE 等。正确做法是先读出来用掩码改再写回去。这个错误在初学者代码里非常常见后果是中断莫名其妙被关掉。坑三委托了异常但没设置stvec。如果medeleg把某个异常委托给 S 模式但stvec还是 0异常发生时硬件会跳到地址 0。如果地址 0 是合法代码比如启动 ROM可能会执行出奇怪的结果如果是非法地址就触发新的异常可能形成嵌套。委托之前务必先设置好stvec。技巧一用misa探测核的能力。misa的 bit[25:0] 表示支持的扩展bit[9:8] 是 S 模式bit[7:6] 是 U 模式。读一次misa就能知道这个核支持哪些指令集、哪些特权级。这在移植代码到不同核时特别有用可以写条件编译。技巧二用mhartid做多核分支。多核启动时每个核都从同一个入口开始执行。用csrr a0, mhartid读自己的 ID然后判断ID 为 0 的核做全局初始化其他核跳到等待循环。等待循环里可以用 WFI 指令省电等主核发 IPI 再唤醒。技巧三sfence.vma的参数选择。sfence.vma rs1, rs2可以刷新特定地址或特定 ASID 的 TLB 项。如果 rs1x0刷新所有地址如果 rs2x0刷新所有 ASID。全刷新性能差但简单精确刷新性能好但容易漏。我的建议是调试阶段用全刷新性能优化时再精确刷新并且每次修改页表后都要刷新不要偷懒。8. 从 CSR 看 RISC-V 的设计哲学写到这里我想聊聊为什么 RISC-V 要把 CSR 和特权级设计成这样。和 x86 的 MSR、ARM 的系统寄存器相比RISC-V 的 CSR 有几个鲜明的特点。第一是正交性。CSR 的地址编码本身就携带了权限信息硬件只需要检查地址的高位就能决定是否允许访问不需要维护复杂的权限表。这种设计让硬件实现变得简单也让软件在编译期就能做部分权限检查。第二是显式委托。异常和中断默认都进 M 模式S 模式想处理必须显式委托。这和 ARM 的默认路由到 EL1/EL2 不同RISC-V 把控制权默认留给最高权限级安全性更高但启动代码也更啰嗦。这种“默认保守”的设计哲学贯穿整个 RISC-V 规范。第三是可裁剪。S 模式和 U 模式都是可选的一个只有 M 模式的核可以做得非常小。这让 RISC-V 能覆盖从 8 位单片机到服务器 CPU 的整个 spectrum。但代价是软件移植时必须先探测核的能力不能假设 S 模式一定存在。实际开发中我越来越觉得这套设计对调试是友好的。CSR 的编号有规律异常原因有标准编码trap handler 里读几个寄存器就能还原现场。比起某些架构里“异常原因藏在某个不透明的状态字里”RISC-V 的mcause/scause直接告诉你发生了什么mtval/stval还附带出错地址或指令排查效率高很多。最后分享一个我常用的调试宏放在 trap handler 开头把关键 CSR 一次性 dump 出来#define DUMP_TRAP() do { \ uint32_t cause, epc, tval, status; \ asm volatile(csrr %0, mcause : r(cause)); \ asm volatile(csrr %0, mepc : r(epc)); \ asm volatile(csrr %0, mtval : r(tval)); \ asm volatile(csrr %0, mstatus : r(status)); \ printf(TRAP: cause%08x epc%08x tval%08x status%08x\n, \ cause, epc, tval, status); \ } while(0)这段代码在 S 模式就把m换成s。有了它大部分特权级相关的 bug 都能在几分钟内定位。CSR 这东西刚接触时觉得琐碎用熟了之后会发现它是 RISC-V 里最“讲道理”的部分——每个位都有明确含义每个地址都有规律可循只要你愿意花时间把这张表刻在脑子里。
返回列表