ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RISC-V CSR与特权架构实战速查:M/S/U三级权限与核心寄存器详解

2026/10/8 2:55:12 拓冰建站 浏览量
RISC-V CSR与特权架构实战速查:M/S/U三级权限与核心寄存器详解 1. 为什么每个RISC-V开发者都绕不开CSR和特权架构搞RISC-V开发的人迟早会撞上CSR和特权架构这堵墙。你写裸机程序要配中断得碰mtvec做操作系统移植要管内存映射得动satp调性能计数器得读mcycle。这些统统属于CSR——控制状态寄存器。而M/S/U三个特权级则是理解RISC-V怎么从最底层固件一路支撑到用户应用的关键框架。我刚开始接触RISC-V的时候觉得指令集挺简洁的RV32I就几十条指令一两天就能看完。但真正上手做项目才发现指令集只是冰山一角真正让RISC-V跑起来的是CSR和特权架构这套“隐藏系统”。你可以把CSR理解成CPU的控制面板——不是用来做计算的而是用来配置CPU行为、查询运行状态、管理异常和中断的。特权架构则是这个控制面板的权限分级系统决定了谁能在什么条件下操作哪些寄存器。这篇内容适合谁看如果你是刚接触RISC-V的嵌入式工程师正在从ARM Cortex-M往RISC-V迁移那CSR和特权架构是你必须跨过的门槛。如果你在做操作系统内核移植或者Hypervisor开发那M/S/U三级特权模型就是你每天都要打交道的核心机制。即使你只是用RISC-V做应用开发理解CSR也能帮你更好地读懂启动代码和运行时库。我写这篇的出发点很简单网上关于CSR的资料要么太碎片化要么直接甩一份规范文档让你自己啃。我当初就是对着规范一页一页翻踩了不少坑才把整个体系串起来。所以这里我按照实际开发中用到的顺序把CSR速查和M/S/U特权架构的核心知识点重新梳理一遍配上我在实际项目中积累的经验和避坑技巧。2. CSR核心概念与编号体系拆解2.1 CSR到底是什么从“控制面板”说起CSR全称Control and Status Register直译就是控制与状态寄存器。它和通用寄存器x0-x31最大的区别在于通用寄存器存的是数据CSR存的是CPU的配置和状态。打个比方通用寄存器像是你办公桌上的文件CSR像是办公室墙上的开关面板——灯亮不亮、空调开几度、门锁没锁都靠这个面板来控制。RISC-V的CSR是12位地址空间总共4096个寄存器位置。这个空间被划分成几个区域按照读写权限和用途分类。每个CSR有一个12位的地址通常用csrrw、csrrs、csrrc等指令来访问。这里有个关键点CSR的读写不是简单的load/store而是有专门的原子操作语义。比如csrrs是“读-置位-写回”csrrc是“读-清位-写回”csrrw是“读-写”。这些原子操作在多核或者中断场景下特别重要因为你可以用一条指令完成“读当前值、修改某几位、写回去”的完整操作不用担心中间被打断。我刚开始用的时候犯过一个错误用csrr读了一个只写寄存器结果读回来全是0还以为是硬件坏了。后来查规范才知道有些CSR是只写的读操作会返回0或者触发异常。这个坑在后面讲具体CSR的时候会再强调。2.2 CSR地址编码规则从高位到低位读懂“身份证”CSR的12位地址不是随便分配的它有一套编码规则。理解这套规则你看到一个CSR地址就能大致判断它的用途和权限。地址的[11:10]两位表示这个CSR的读写权限00或01读写Read/Write10只读Read-Only11只写Write-Only地址的[9:8]两位表示这个CSR允许访问的最低特权级00用户级U-mode可访问01管理员级S-mode可访问10机器级M-mode可访问11保留地址的[7:4]四位表示这个CSR属于哪个功能大类比如0x0是用户态相关0x1是管理员态相关0x2是机器态相关0x3是调试相关等等。地址的[3:0]四位是具体寄存器的编号。举个例子mstatus的地址是0x300。拆开看[11:10]00表示读写[9:8]10表示M-mode可访问[7:4]0x0表示用户态相关大类但实际是机器态核心寄存器[3:0]0x0是编号。再比如mcycle地址是0xB00[11:10]10表示只读[9:8]10表示M-mode[7:4]0xB表示性能计数器大类。这套编码规则的好处是硬件解码CSR地址时非常高效同时软件也能通过地址快速判断权限。你在写汇编的时候如果访问了一个当前特权级不允许的CSR会直接触发非法指令异常。这个机制在调试的时候特别有用——如果你看到程序跑飞了检查一下是不是在U-mode下误访问了M-mode的CSR。2.3 CSR指令族csrrw、csrrs、csrrc怎么选RISC-V提供了六条CSR访问指令分成两组寄存器操作数和立即数操作数。寄存器版本csrrw rd, csr, rs1读CSR到rd同时把rs1的值写入CSRcsrrs rd, csr, rs1读CSR到rd同时把rs1中为1的位在CSR中置1csrrc rd, csr, rs1读CSR到rd同时把rs1中为1的位在CSR中清0立即数版本csrrwi rd, csr, zimm读CSR到rd同时把5位立即数写入CSRcsrrsi rd, csr, zimm读CSR到rd同时把立即数中为1的位在CSR中置1csrrci rd, csr, zimm读CSR到rd同时把立即数中为1的位在CSR中清0选择哪条指令取决于你要做什么操作。如果你只是想读一个CSR用csrrs rd, csr, x0——因为rs1x0时没有任何位会被置1CSR的值不变只有读操作生效。同理只想写一个CSR而不关心旧值可以用csrrw x0, csr, rs1把目标寄存器设为x0读出来的值直接丢弃。这里有个实操技巧在修改CSR的某些位时尽量用csrrs和csrrc而不是csrrw。因为csrrw是整体覆盖如果你不知道CSR当前的值直接写可能会把其他位意外清零。而csrrs和csrrc是“读-改-写”的原子操作只影响你指定的位其他位保持不变。我在配置mstatus的时候就习惯用csrrs来置位用csrrc来清位避免误伤其他配置。注意csrrw在rdx0且rs1x0时不会产生读副作用也不会写CSR。这个特性在某些场景下有用比如探测某个CSR是否存在。3. M/S/U三级特权架构权限分层的设计哲学3.1 三个特权级各自负责什么RISC-V定义了三个特权级机器模式M-mode、管理员模式S-mode、用户模式U-mode。这三个级别从高到低排列高特权级可以访问低特权级的资源反过来则不行。M-mode是最高特权级也是RISC-V唯一必须实现的特权级。任何RISC-V处理器上电后都从M-mode开始执行。M-mode的代码通常是最底层的固件比如BootROM、安全监控代码、或者简单的裸机程序。M-mode可以访问所有CSR包括那些控制物理内存保护PMP、机器态中断、时钟和性能计数器的寄存器。S-mode是可选的通常用于运行操作系统内核。S-mode有自己的CSR集合比如satp用于配置页表基地址sstatus用于管理S-mode的状态stvec用于S-mode的异常入口。S-mode不能直接访问M-mode的CSR但可以通过环境调用ECALL向M-mode请求服务。U-mode是最低特权级用于运行用户应用程序。U-mode能访问的CSR非常有限主要是那些标记为U-mode可访问的寄存器和一些只读的性能计数器。U-mode下如果尝试访问S-mode或M-mode的CSR会触发非法指令异常。这三个级别的划分本质上是一种“最小权限”原则的体现。用户程序不需要也不应该直接操作硬件操作系统内核需要管理硬件但不需要直接控制最底层的物理资源而最底层的资源控制交给固件来做。这种分层让系统更安全、更稳定。3.2 特权级切换的触发条件与流程特权级不会无缘无故切换每次切换都有明确的触发条件。从低到高切换只有一种方式异常或中断。从高到低切换也只有一种方式执行特定的返回指令。从U-mode到S-mode当U-mode程序执行ECALL指令时会触发环境调用异常CPU自动切换到S-mode跳转到stvec指向的异常处理入口。操作系统内核在S-mode下处理这个请求比如系统调用。从S-mode到M-mode当S-mode执行ECALL时会触发异常切换到M-mode。这通常用于S-mode向M-mode请求服务比如在缺少S-mode某些功能时。从M-mode到S-mode或U-modeM-mode执行mret指令根据mstatus中的MPP字段决定返回到哪个特权级。从S-mode到U-modeS-mode执行sret指令根据sstatus中的SPP字段决定返回到U-mode。中断也可以触发特权级提升。比如M-mode的定时器中断会打断U-mode或S-mode的执行CPU自动切换到M-mode处理中断。处理完后通过mret返回。这里有个容易混淆的点异常和中断在特权级切换上的行为是一样的都是提升到更高的特权级。区别在于异常是同步的由指令执行引起中断是异步的由外部事件引起。但在CSR的保存和恢复机制上两者共用同一套硬件逻辑。3.3 特权级与CSR访问权限的对应关系每个CSR都有一个最低可访问特权级。M-mode可以访问所有CSRS-mode可以访问标记为S-mode和U-mode可访问的CSRU-mode只能访问标记为U-mode可访问的CSR。这个权限检查是硬件自动完成的。如果你在S-mode下执行csrrs访问一个M-mode的CSR硬件会直接触发非法指令异常而不是返回错误值。这个设计很干脆——要么让你访问要么直接报异常不会给你一个模棱两可的结果。在实际开发中这个机制帮你快速定位问题。比如你在写S-mode内核代码时不小心用了mhartid这个M-mode的CSR编译能过但运行时会直接触发异常。这时候你查一下CSR地址的[9:8]位发现是10就知道这个CSR是M-mode专用的S-mode下不能用。有个例外情况某些CSR在低特权级下访问时硬件会返回一个“影子”值或者全0而不是触发异常。这种情况通常出现在那些标记为“只读”且允许低特权级访问的CSR上比如cycle和time。这些CSR在U-mode下可以读但读到的值可能被M-mode的mcounteren寄存器控制是否可见。4. 机器模式核心CSR速查与实操配置4.1 mstatus机器态状态寄存器的关键位解析mstatus是M-mode最核心的状态寄存器地址0x300。它记录了当前的特权级状态、中断使能状态、以及一些杂项配置。这个寄存器的位域比较多我挑几个实际开发中最常用的位来说。MIEbit 3全局中断使能。这是M-mode中断的总开关。如果MIE0所有M-mode的中断都被屏蔽。但注意这个位不影响异常。异常该触发还是触发。MPIEbit 7前一个MIE的值。当发生异常或中断进入M-mode时硬件自动把MIE的值保存到MPIE然后清MIE。执行mret时硬件把MPIE恢复到MIE。这个机制保证了中断处理过程中不会被同级中断打断。MPPbits 12:11前一个特权级。记录进入M-mode之前CPU在哪个特权级。mret根据这个字段决定返回到M/S/U哪个级别。取值0U-mode1S-mode3M-mode。MPRVbit 17修改特权级。这个位控制load/store指令使用哪个特权级的权限来访问内存。当MPRV1时load/store使用MPP字段指定的特权级权限而不是当前特权级。这个机制在M-mode下模拟用户态内存访问时特别有用。FSbits 14:13浮点单元状态。00Off01Initial10Clean11Dirty。这个字段影响浮点指令的执行和上下文切换。如果FS00执行浮点指令会触发非法指令异常。我在配置mstatus的时候通常的流程是先读当前值然后用csrrc清掉要修改的位再用csrrs置上需要的位。比如要开启M-mode全局中断同时设置MPP为S-mode代码大概是这样# 读取mstatus清除MIE位设置MIE位 csrrs t0, mstatus, x0 # 读mstatus到t0 li t1, 0x8 # MIE位掩码 csrrc x0, mstatus, t1 # 清MIE csrrs x0, mstatus, t1 # 置MIE # 设置MPP为S-mode (01) li t1, 0x1800 # MPP掩码 (bits 12:11) csrrc x0, mstatus, t1 # 清MPP li t1, 0x800 # S-mode值 csrrs x0, mstatus, t1 # 置MPP注意修改mstatus的某些位有副作用。比如修改FS字段可能会影响浮点寄存器的状态。在上下文切换代码中修改mstatus要特别小心确保保存和恢复的顺序正确。4.2 mtvec机器态异常入口地址配置mtvec寄存器地址0x305存放M-mode异常和中断的处理入口地址。它的低两位MODE字段决定入口模式MODE0Direct模式。所有异常和中断都跳转到BASE地址。MODE1Vectored模式。异常跳转到BASE中断跳转到BASE4×cause。选择哪种模式取决于你的异常处理设计。Direct模式简单所有异常走同一个入口由软件根据mcause判断具体原因。Vectored模式省去了软件判断中断原因的开销但要求每个中断处理程序的入口地址按4字节对齐排列。我在实际项目中通常用Direct模式因为异常处理逻辑本身就需要读mcause来判断原因Vectored模式省的那点判断开销在大多数场景下可以忽略。而且Direct模式更灵活中断向量表的位置不受限制。配置mtvec的代码很简单la t0, trap_handler # 加载异常处理函数地址 csrw mtvec, t0 # 写入mtvecMODE0如果要启用Vectored模式la t0, trap_handler ori t0, t0, 1 # 设置MODE1 csrw mtvec, t0注意mtvec的BASE地址必须4字节对齐。如果你用Vectored模式每个中断入口之间的间隔是4字节也就是一条指令的空间。通常在那里放一条跳转指令跳到真正的处理函数。4.3 mepc与mcause异常现场保存与原因分析mepc地址0x341保存发生异常时的程序计数器值。当异常处理完成后执行mret会跳转到mepc指向的地址继续执行。对于中断mepc保存的是被中断指令的地址对于异常mepc保存的是触发异常的指令地址。mcause地址0x342记录异常或中断的原因。最高位bit 31或bit 63取决于XLEN表示是中断还是异常1中断0异常。低几位是具体的原因编码。比如0指令地址不对齐2非法指令3断点4load地址不对齐5load访问错误7store访问错误11环境调用从U-mode9环境调用从S-mode11环境调用从M-mode中断原因编码3M-mode软件中断7M-mode定时器中断11M-mode外部中断在异常处理函数中第一件事通常是读mcause判断原因然后读mepc获取出错地址。如果是非法指令异常可能还需要读mtval地址0x343获取出错的指令编码。trap_handler: csrr t0, mcause # 读异常原因 csrr t1, mepc # 读异常地址 # 根据t0的值判断异常类型 # ... csrw mepc, t1 # 恢复mepc如果需要 mret # 返回注意mepc的值在异常处理过程中可能被修改。如果你在异常处理中执行了可能导致嵌套异常的指令硬件会更新mepc。所以通常要在异常处理入口第一时间保存mepc和mcause到内存或栈上。4.4 mie与mip中断使能与挂起状态管理mie地址0x304是M-mode中断使能寄存器mip地址0x344是中断挂起寄存器。这两个寄存器的位布局是对应的bit 3MSIE/MIP - M-mode软件中断bit 7MTIE/MIP - M-mode定时器中断bit 11MEIE/MIP - M-mode外部中断mie中的位控制对应中断是否使能mip中的位表示对应中断是否挂起。当mip中某位为1且mie中对应位为1且mstatus.MIE为1时CPU会响应这个中断。配置中断的典型流程# 使能M-mode定时器中断 li t0, 0x80 # MTIE位掩码 (bit 7) csrrs x0, mie, t0 # 置位mie.MTIE # 使能M-mode全局中断 li t0, 0x8 # MIE位掩码 (bit 3) csrrs x0, mstatus, t0 # 置位mstatus.MIE注意mip中的某些位是只读的由硬件自动置位和清除。比如定时器中断挂起位在定时器到期时由硬件置1在软件写mtimecmp后由硬件清0。软件不能直接写mip来清除中断挂起状态必须通过操作对应的硬件模块来清除。4.5 机器态计数器mcycle、minstret与mcountinhibitmcycle地址0xB00记录CPU执行的时钟周期数minstret地址0xB02记录退休的指令数。这两个是64位计数器在RV32上需要分两次读取低32位和高32位。mcountinhibit地址0x320控制哪些计数器停止计数。bit 0控制mcyclebit 2控制minstret。在某些低功耗场景下可以停止计数器来省电。读取mcycle的代码# RV32下读取64位mcycle csrr t0, mcycle # 读低32位 csrr t1, mcycleh # 读高32位 # 注意两次读取之间可能发生溢出需要检查注意在RV32上读64位计数器有溢出风险。如果低32位读完后发生进位高32位会变化导致读到的值不准确。正确的做法是先读高32位再读低32位再读高32位如果两次高32位相同则读取有效。或者用csrrw的原子读特性来避免这个问题。5. 管理员模式CSR与地址翻译机制5.1 sstatus与sretS-mode的状态管理sstatus地址0x100是S-mode的状态寄存器它是mstatus的一个子集。sstatus中的位是mstatus中对应位的“影子”读写sstatus实际上是在读写mstatus的对应位。这种设计让S-mode可以在不访问M-mode CSR的情况下管理自己的状态。sstatus中常用的位SIEbit 1S-mode全局中断使能。对应mstatus.SIE。 SPIEbit 5前一个SIE值。对应mstatus.SPIE。 SPPbit 8前一个特权级。对应mstatus.SPP。0U-mode1S-mode。 SUMbit 18允许S-mode访问U-mode内存。对应mstatus.SUM。 MXRbit 19允许读可执行页。对应mstatus.MXR。sret指令用于从S-mode异常处理返回。它根据sstatus.SPP决定返回到U-mode还是S-mode根据sstatus.SPIE恢复sstatus.SIE。5.2 satp页表基地址与地址翻译模式satp地址0x180是S-mode的地址翻译和配置寄存器。它控制是否启用页表翻译以及页表的根地址。satp的位域RV32bit 31MODE。0Bare模式不翻译1Sv32模式。bits 30:22ASID。地址空间标识符用于TLB区分不同进程。bits 21:0PPN。页表根目录的物理页号。RV64的satp布局不同bits 63:60MODE。0Bare8Sv399Sv4810Sv57。bits 59:44ASID。bits 43:0PPN。启用页表翻译的代码# 假设页表根目录物理地址在t0中 srli t0, t0, 12 # 取PPN li t1, 0x80000000 # MODE1 (Sv32) or t0, t0, t1 # 组合MODE和PPN csrw satp, t0 # 写入satp sfence.vma # 刷新TLB注意修改satp后必须执行sfence.vma刷新TLB否则旧的地址翻译缓存可能导致不可预期的行为。sfence.vma可以带参数指定刷新特定地址或特定ASID的TLB条目。5.3 stvec、sepc、scauseS-mode异常处理三件套stvec地址0x105是S-mode的异常入口地址格式和mtvec一样。sepc地址0x141保存S-mode异常时的程序计数器。scause地址0x142记录S-mode异常的原因。S-mode异常处理的基本流程和M-mode类似但有几个关键区别第一S-mode的异常可能来自U-mode比如系统调用也可能来自S-mode自身比如页错误。scause中的原因编码会区分这两种情况。第二S-mode处理异常时如果异常来自U-modesstatus.SPP会被硬件设为0如果来自S-modesstatus.SPP设为1。sret根据这个字段决定返回目标。第三S-mode的异常处理可能需要访问U-mode的内存比如读取系统调用的参数。这需要设置sstatus.SUM1否则S-mode访问U-mode内存会触发页错误。5.4 S-mode中断与异常委托机制RISC-V允许把某些异常和中断委托给S-mode处理这样M-mode固件就不需要介入每一次系统调用或页错误。委托通过medeleg地址0x302和mideleg地址0x303两个寄存器控制。medeleg的每一位对应一个异常原因编码。如果某位为1对应的异常会被委托给S-mode处理。比如bit 8对应ECALL from U-mode如果medeleg[8]1U-mode的ECALL会直接触发S-mode的异常而不是先到M-mode。mideleg类似控制中断的委托。比如bit 5对应S-mode定时器中断如果mideleg[5]1这个中断会直接送到S-mode。委托机制的典型配置# 委托所有标准异常给S-mode li t0, 0xffff csrw medeleg, t0 # 委托S-mode软件中断、定时器中断、外部中断 li t0, 0x222 csrw mideleg, t0注意委托后M-mode的mcause和mepc不会更新对应的异常信息会记录在scause和sepc中。M-mode固件在初始化时配置好委托之后就可以“放手”让S-mode自己处理大部分异常。6. 用户模式CSR与性能计数访问6.1 U-mode能访问哪些CSRU-mode能访问的CSR非常有限。主要包括cycle地址0xC00时钟周期计数器time地址0xC01实时时钟instret地址0xC02退休指令计数器cycleh、timeh、instreth高32位版本这些CSR在U-mode下是只读的。而且它们是否可读还受mcounteren地址0x306和scounteren地址0x106控制。mcounteren控制S-mode能否访问这些计数器scounteren控制U-mode能否访问。比如要让U-mode能读cycle需要M-mode设置mcounteren.CY1S-mode设置scounteren.CY1如果任一条件不满足U-mode读cycle会触发非法指令异常。6.2 性能计数器在U-mode下的访问控制性能计数器的访问控制是一个链式授权机制M-mode授权给S-modeS-mode授权给U-mode。这个设计让M-mode可以精细控制哪些计数器对下层可见。mcounteren的位定义bit 0CYcyclebit 1TMtimebit 2IRinstretbit 3-31HPM硬件性能监控计数器scounteren的位定义相同但控制的是U-mode的访问权限。配置示例# M-mode: 允许S-mode访问cycle和instret li t0, 0x5 # CY和IR位 csrw mcounteren, t0 # S-mode: 允许U-mode访问cycle和instret csrw scounteren, t0注意time计数器通常由M-mode的mtime寄存器驱动但timeCSR本身是只读的反映mtime的值。U-mode读time时硬件会返回mtime的当前值但前提是mcounteren.TM1且scounteren.TM1。6.3 U-mode异常处理与系统调用约定U-mode下发生异常时CPU会自动切换到S-mode如果异常被委托给S-mode或M-mode。U-mode本身没有异常处理能力所有异常都必须由更高特权级处理。系统调用的标准约定U-mode程序把系统调用号放在a7寄存器参数放在a0-a6然后执行ecall。CPU切换到S-modescause显示“ECALL from U-mode”sepc指向ecall指令的下一条指令。S-mode处理完后把返回值放在a0执行sret返回U-mode。这个约定不是硬件强制的但它是RISC-V软件生态的事实标准。Linux、FreeBSD、Zephyr等系统都遵循这个约定。7. 实操中常见的坑与排查技巧7.1 CSR访问异常排查速查表现象可能原因排查方法非法指令异常访问了当前特权级不允许的CSR检查CSR地址的[9:8]位确认最低可访问特权级读回全0访问了只写CSR检查CSR地址的[11:10]位10只读11只写写入无效访问了只读CSR同上只读CSR写入会被忽略或触发异常中断不触发mstatus.MIE0或mie对应位0检查mstatus.MIE和mie寄存器中断不触发mip挂起位未置1检查中断源是否真的产生了中断sret返回错误sstatus.SPP设置错误检查sstatus.SPP0U-mode1S-modemret返回错误mstatus.MPP设置错误检查mstatus.MPP0U1S3M页表不生效satp.MODE0或未执行sfence.vma检查satp配置执行sfence.vma7.2 特权级切换时的寄存器保存陷阱特权级切换时硬件只自动保存和恢复少数几个CSRmepc、mcause、mstatus的部分位。通用寄存器的保存和恢复必须由软件完成。我踩过的一个坑在异常处理函数中使用了t0-t6寄存器但没有在入口保存它们。结果异常返回后被中断的代码发现t0-t6的值变了导致逻辑错误。正确的做法是在异常处理入口把所有可能用到的通用寄存器压栈返回前恢复。另一个坑是浮点寄存器的保存。如果异常处理中使用了浮点指令必须保存和恢复浮点寄存器。而且要注意mstatus.FS字段的状态——如果FS00执行浮点指令会触发异常。在上下文切换代码中通常要把FS设为01Initial或11Dirty来启用浮点单元。7.3 中断嵌套与优先级处理经验RISC-V的M-mode中断默认不支持嵌套。当CPU进入M-mode处理中断时mstatus.MIE被硬件清0同级中断被屏蔽。如果要在中断处理中允许更高优先级的中断嵌套需要手动设置mstatus.MIE1但这会带来复杂性。我的经验是在大多数嵌入式场景下不需要中断嵌套。中断处理应该尽可能短把耗时操作放到主循环或任务中处理。如果确实需要嵌套建议只允许更高优先级的中断嵌套并且在嵌套前保存好mepc和mcause。对于S-mode中断情况类似。sstatus.SIE在进入S-mode异常时被清0。如果S-mode要支持中断嵌套需要手动管理sstatus.SIE和sstatus.SPIE。7.4 调试CSR问题的实用手段调试CSR问题最直接的手段是读寄存器值。但有些CSR是只读或只写的不能直接读。这时候可以用以下方法第一用csrrw的原子读特性。即使CSR是只写的csrrw的读操作也会返回一个值通常是0但写操作会生效。你可以通过观察写操作的效果来间接判断CSR的状态。第二用模拟器。QEMU、Spike等RISC-V模拟器支持完整的CSR行为可以在模拟器中单步执行观察每条指令对CSR的影响。这比在真实硬件上调试方便得多。第三用调试器。OpenOCD配合GDB可以读写CSR查看异常发生时的寄存器状态。在trap_handler入口设置断点可以捕获异常现场。第四加打印。在异常处理中把mcause、mepc、mtval的值打印出来可以快速定位异常原因。但要注意打印本身可能触发新的异常比如串口中断需要小心处理。8. 从裸机到操作系统特权架构的实际应用8.1 裸机程序中的M-mode配置流程一个典型的RISC-V裸机程序启动流程大致如下第一步设置栈指针。上电后sp的值是未定义的必须尽快设置。第二步配置mtvec。设置异常和中断的入口地址。第三步配置mstatus。设置MPP、MIE等字段。第四步配置mie和mip。使能需要的中断。第五步配置PMP。设置物理内存保护区域控制M-mode和S/U-mode的内存访问权限。第六步初始化外设。配置串口、定时器、GPIO等。第七步如果要用S-mode配置medeleg和mideleg然后通过mret切换到S-mode。这个流程中PMP的配置经常被忽略。PMPPhysical Memory Protection是M-mode控制内存访问权限的机制。如果没有配置PMPS-mode和U-mode可能无法访问任何内存或者可以访问所有内存取决于硬件实现。在RISC-V规范中如果没有PMP条目匹配S/U-mode的访问默认是被拒绝的。所以裸机程序如果要支持S/U-mode必须配置PMP。8.2 操作系统内核的S-mode初始化要点操作系统内核在S-mode下的初始化核心是配置stvec、satp和sstatus。stvec设置S-mode的异常入口。通常指向一个汇编写的trap入口保存上下文后调用C语言的异常处理函数。satp设置页表。内核需要建立页表映射把虚拟地址映射到物理地址。然后写satp启用翻译执行sfence.vma刷新TLB。sstatus设置S-mode的状态。比如设置SUM1允许访问U-mode内存设置MXR1允许读可执行页。内核还需要处理来自U-mode的ECALL。当U-mode执行ECALL时scause显示“ECALL from U-mode”sepc指向ECALL的下一条指令。内核根据a7中的系统调用号分发处理。8.3 用户态程序如何通过ECALL请求服务用户态程序通过ECALL请求操作系统服务。标准流程把系统调用号放入a7把参数放入a0-a6执行ecallCPU切换到S-mode内核处理请求内核把返回值放入a0执行sret返回U-mode用户程序从a0读取返回值这个流程中用户程序不需要知道内核如何实现系统调用只需要遵循寄存器约定。内核也不需要知道用户程序的具体逻辑只需要根据系统调用号分发。ECALL指令本身不携带任何参数。所有信息都通过寄存器传递。这是RISC-V系统调用设计的一个特点简洁、灵活但需要软件约定来保证一致性。8.4 多特权级下的上下文切换实现上下文切换是多任务操作系统的核心。在RISC-V上上下文切换涉及通用寄存器、浮点寄存器、CSR的保存和恢复。通用寄存器的保存把ra、sp、s0-s11、t0-t6、a0-a7等寄存器压入任务栈。浮点寄存器的保存如果任务使用了浮点单元需要保存f0-f31和fcsr。注意mstatus.FS字段的状态。CSR的保存mepc、mcause、mstatus等CSR在异常入口由硬件自动保存但上下文切换时需要把它们保存到任务控制块中。切换流程保存当前任务的上下文到其任务控制块从下一个任务的控制块恢复上下文执行mret或sret返回到下一个任务这个过程中satp的切换是关键。不同任务有不同的页表切换任务时需要切换satp并刷新TLB。sfence.vma指令可以带ASID参数只刷新特定地址空间的TLB条目减少刷新开销。9. 几个容易混淆的概念澄清9.1 M-mode与S-mode的CSR影子关系mstatus和sstatus不是两个独立的寄存器而是同一个物理寄存器的不同视图。sstatus中的位是mstatus中对应位的别名。写sstatus实际上是在写mstatus的对应位但只有S-mode允许访问的那些位可以被修改。这种设计的好处是S-mode不需要访问M-mode的CSR就能管理自己的状态。M-mode可以通过mstatus看到S-mode的状态但S-mode看不到M-mode的完整mstatus。类似的影子关系还有mie和sie、mip和sip、mideleg和sideleg等。理解这种影子关系可以避免在调试时被“为什么读sstatus和读mstatus得到不同值”这类问题困扰。9.2 异常与中断在CSR层面的区别异常和中断在CSR层面的处理非常相似但有几个关键区别第一mcause的最高位区分异常和中断。1中断0异常。第二异常的mepc指向触发异常的指令中断的mepc指向被中断的指令。对于异常返回后重新执行mepc指向的指令可能会再次触发异常如果异常原因未消除。对于中断返回后继续执行被中断的指令。第三异常的mtval包含额外的信息比如出错的地址或指令编码。中断的mtval通常为0。第四中断可以被屏蔽通过mstatus.MIE和mie异常不能被屏蔽。9.3 委托机制下异常处理的优先级当异常被委托给S-mode时M-mode的medeleg和mideleg决定哪些异常和中断走S-mode哪些走M-mode。如果同一个异常同时满足委托给S-mode和M-mode的条件优先级如何规则是委托优先。如果medeleg中对应位为1异常走S-mode否则走M-mode。中断类似mideleg中对应位为1则走S-mode。但有一个例外如果异常发生在M-mode即使medeleg中对应位为1异常也不会被委托给S-mode。因为M-mode的异常必须在M-mode处理不能降级到S-mode。这个规则保证了M-mode的异常处理不会被委托机制干扰。M-mode固件可以放心地配置委托不用担心自己的异常被“劫持”到S-mode。10. 速查表与常用代码片段10.1 常用CSR地址速查CSR名称地址特权级功能mstatus0x300M机器态状态misa0x301M指令集架构medeleg0x302M异常委托mideleg0x303M中断委托mie0x304M中断使能mtvec0x305M异常入口mcounteren0x306M计数器使能mscratch0x340M机器态暂存mepc0x341M异常PCmcause0x342M异常原因mtval0x343M异常值mip0x344M中断挂起mcycle0xB00M周期计数minstret0xB02M指令计数sstatus0x100S管理员态状态sie0x104S中断使能stvec0x105S异常入口scounteren0x106S计数器使能sscratch0x140S管理员态暂存sepc0x141S异常PCscause0x142S异常原因stval0x143S异常值sip0x144S中断挂起satp0x180S地址翻译cycle0xC00U周期计数time0xC01U实时时钟instret0xC02U指令计数10.2 常用CSR操作代码片段读取CSRcsrr rd, csr # 读CSR到rd写入CSRcsrw csr, rs # 写rs到CSR置位CSRcsrrs rd, csr, rs # 读CSR到rd置位rs中为1的位清位CSRcsrrc rd, csr, rs # 读CSR到rd清位rs中为1的位立即数版本csrrwi rd, csr, imm # 读CSR到rd写入5位立即数 csrrsi rd, csr, imm # 读CSR到rd置位立即数中为1的位 csrrci rd, csr, imm # 读CSR到rd清位立即数中为1的位10.3 异常处理入口模板.section .text .globl trap_entry trap_entry: # 保存上下文 addi sp, sp, -128 sw ra, 0(sp) sw t0, 4(sp) sw t1, 8(sp) sw t2, 12(sp) sw a0, 16(sp) sw a1, 20(sp) sw a2, 24(sp) sw a3, 28(sp) sw a4, 32(sp) sw a5, 36(sp) sw a6, 40(sp) sw a7, 44(sp) sw t3, 48(sp) sw t4, 52(sp) sw t5, 56(sp) sw t6, 60(sp) # 读异常原因 csrr a0, mcause csrr a1, mepc csrr a2, mtval # 调用C处理函数 call trap_handler # 恢复上下文 lw ra, 0(sp) lw t0, 4(sp) lw t1, 8(sp) lw t2, 12(sp) lw a0, 16(sp) lw a1, 20(sp) lw a2, 24(sp) lw a3, 28(sp) lw a4, 32(sp) lw a5, 36(sp) lw a6, 40(sp) lw a7, 44(sp) lw t3, 48(sp) lw t4, 52(sp) lw t5, 56(sp) lw t6, 60(sp) addi sp, sp, 128 mret这个模板保存了所有通用寄存器调用C函数处理异常然后恢复寄存器并返回。实际项目中可以根据需要精简只保存用到的寄存器。10.4 从M-mode切换到S-mode的代码# 设置mepc为S-mode入口地址 la t0, s_mode_entry csrw mepc, t0 # 设置mstatus.MPP为S-mode (01) li t0, 0x1800 csrrc x0, mstatus, t0 # 清MPP li t0, 0x800 csrrs x0, mstatus, t0 # 置MPP01 # 设置mstatus.MPIE1确保mret后MIE1 li t0, 0x80 csrrs x0, mstatus, t0 # 执行mret切换到S-mode mret这段代码的关键是正确设置mstatus.MPP和mstatus.MPIE。mret执行时硬件把MPP的值恢复到当前特权级把MPIE的值恢复到MIE。所以设置MPP01让CPU返回到S-mode设置MPIE1让S-mode下中断使能。我在实际项目中第一次做M-mode到S-mode切换时忘了设置MPIE结果S-mode下中断一直不触发排查了半天才发现是mstatus.MIE在mret后被清0了。这个坑希望大家不要再踩。