多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

RISC-V特权架构与CSR实战:从裸机到Linux的配置与排错指南

RISC-V特权架构与CSR实战:从裸机到Linux的配置与排错指南 1. 从一条报错说起为什么CSR和特权级绕不开第一次在RISC-V上跑裸机程序十有八九会遇到一个让人摸不着头脑的异常代码明明写对了一执行mret或者访问某个控制寄存器机器就跳到一个莫名其妙的地址然后卡死。翻手册翻半天最后发现是特权级没配对或者CSR地址写错了。这类问题在ARM上可能被硬件悄悄兜住但在RISC-V里特权架构是明明白白摊在你面前的错一位就是错一位。这篇内容就是围绕RISC-V的CSRControl and Status Register控制状态寄存器和特权架构M/S/U展开的。CSR是RISC-V里访问硬件控制信息的唯一入口特权架构则决定了谁有资格碰哪些寄存器和指令。两者绑在一起构成了RISC-V从裸机到操作系统的地基。mtvec、mstatus、mepc这些寄存器只要你写过中断、做过异常处理、移植过RTOS或者跑过Linux就一定跟它们打过交道。适合谁看如果你正在做RISC-V裸机开发、写Bootloader、移植操作系统或者单纯想搞明白为什么我的中断进不去为什么mret之后跑飞了这篇内容能帮你把CSR和M/S/U三级的对应关系理清楚。我会按实际调试的顺序来讲先建立整体地图再逐个拆关键寄存器最后落到实操和排错上。不堆砌手册原文讲的是我实际用下来觉得最容易踩坑、也最值得记住的那些点。2. 特权架构M/S/U的整体设计与选型逻辑2.1 三级特权级到底解决什么问题RISC-V把执行环境分成三个特权级MachineM、SupervisorS、UserU。这个设计不是拍脑袋定的它对应的是固件—内核—应用三层软件栈的隔离需求。M模式是最高特权级也是唯一一个必须实现的模式。任何一颗RISC-V核复位后一定从M模式开始跑。它管的是最底层的硬件时钟、电源、内存保护单元的初始配置、异常向量表的基址以及把控制权交给下一级。你可以把M模式理解成主板上的固件它不参与日常业务但所有底层开关都在它手里。S模式是可选的主要给操作系统内核用。Linux、RT-Thread这类需要虚拟内存和进程隔离的系统内核就跑在S模式。S模式能访问自己的CSR比如sstatus、stvec、satp但碰不到M模式的寄存器。这样设计的好处是即使内核有bug也不会直接把硬件配置搞崩M模式的固件还能兜底。U模式是最低特权级跑普通应用程序。它连CSR都基本访问不了除了少数只读的性能计数器所有敏感操作必须通过系统调用陷入S模式。这就是用户态—内核态隔离的硬件基础。三级之间的关系可以用一句话概括高特权级能访问低特权级的资源反之绝对不行。M可以读写S和U的CSRS可以管U但U想往上够只能触发异常。2.2 为什么不是两级也不是四级有人会问ARM有EL0到EL3四级x86有Ring0到Ring3RISC-V为什么定三级这背后是够用就好的取舍。两级MU做不了现代操作系统因为内核和用户程序没有隔离一个野指针就能改页表。四级比如再加一个Hypervisor级对绝大多数嵌入式场景是浪费硅片面积和验证成本都上去了。RISC-V把Hypervisor扩展做成可选的H扩展需要虚拟化时才加不需要就不加保持了基础架构的简洁。这个基础必选扩展可选的思路贯穿整个RISC-V设计。M模式必选S模式在应用处理器上标配、在微控制器上可以砍掉U模式同理。所以你会在不同芯片上看到不同的组合低端MCU可能只有MU应用处理器是MSU带虚拟化的再加H。2.3 CSR地址空间的编码规则CSR不是随便编号的它的12位地址csr[11:0]有明确的编码含义理解这个编码能帮你快速判断一个CSR属于哪个特权级、是否只读。地址的高4位csr[11:8]表示读写权限00用户级读写CSR01用户级只读CSR10超级用户级读写CSR11超级用户级只读CSR地址的低8位csr[7:0]表示具体寄存器其中高2位csr[9:8]进一步区分特权级归属。实际记忆时更实用的方法是看地址范围地址范围归属典型寄存器0x000-0x0FF用户级fflags、frm、cycle、time0x100-0x1FF超级用户级sstatus、stvec、satp0x300-0x3FF机器级mstatus、mtvec、mepc0xB00-0xBFF机器级只读mcycle、minstret0xF00-0xFFF机器级只读mvendorid、marchid这个编码规则的价值在于当你在调试器里看到一个CSR地址能立刻判断出它属于哪一级、能不能写。比如0x341是mepc机器级0x141是sepc超级用户级两者只差最高位但权限完全不同。注意CSR地址是12位但指令编码里csrrw、csrrs这些指令的csr字段也是12位所以能寻址4096个CSR。实际实现的远没这么多访问未实现的CSR会触发非法指令异常。3. CSR速查那些你必须记住的寄存器3.1 机器级核心CSR逐个拆M模式是起点先把机器级最常用的几个寄存器吃透。mtvecMachine Trap-Vector Base-Address Register地址0x305是异常和中断的入口地址寄存器。它的低2位是模式位00表示Direct模式所有异常都跳到同一个地址01表示Vectored模式中断会跳到BASE 4 × cause异常仍然跳到BASE。这个区别很关键——Direct模式下你需要在处理函数里自己判断causeVectored模式下硬件帮你算好了偏移。实际配置时mtvec的BASE必须4字节对齐低2位是模式位所以实际基址是mtvec ~0x3。我见过有人把基址设成非对齐地址结果一触发异常就跳飞。写的时候用csrw mtvec, t0t0里放(base | mode)。mstatusMachine Status Register地址0x300是M模式的状态总控。它里面位很多但真正天天打交道的是这几个MIEbit 3M模式全局中断使能MPIEbit 7进入异常前的MIE备份MPPbit 12:11进入异常前的特权级11是M01是S00是UMPRVbit 17修改访存特权级MPP这个字段是mret行为的关键。mret执行时硬件会把MPP的值恢复到当前特权级然后把MPIE恢复到MIE。如果你从S模式陷入M模式处理异常处理完想回到S模式就必须保证MPP是01。我踩过的坑是在M模式初始化时手动改了mstatus把MPP清成了00结果第一次从S模式陷入后mret直接掉到U模式程序跑飞。mepcMachine Exception Program Counter地址0x341保存异常发生时的PC。mret会跳回mepc。这里有个细节如果是中断mepc指向的是下一条未执行的指令如果是异常比如缺页、非法指令mepc指向的是触发异常的那条指令本身。这个区别决定了你在处理异常时要不要手动给mepc加4。比如处理非法指令异常如果你想跳过这条指令继续执行就得mepc 4但如果是缺页异常修好页表后直接mret回到原指令重试不能加4。mcauseMachine Cause Register地址0x342告诉你为什么陷入。最高位是中断标志1为中断0为异常低位是cause编码。常见值0是取指缺页2是非法指令5是读访问缺页7是写访问缺页11是M模式外部中断15是M模式定时器中断。调试时第一件事就是读mcause它能直接告诉你方向。mie/mip地址0x304/0x344是中断使能和挂起寄存器。mie控制哪些中断源被使能mip反映当前哪些中断在挂起。两者位定义对应MEIEbit 11外部中断MTIEbit 7定时器中断MSIEbit 3软件中断。注意mip的位是只读的反映硬件状态mie可写。3.2 超级用户级CSR与M级的对应关系S模式的CSR基本是M模式的一套镜像命名上把m换成s地址上把0x3xx换成0x1xx。理解这个对应关系记忆量直接减半。M模式S模式功能mstatus(0x300)sstatus(0x100)状态寄存器mtvec(0x305)stvec(0x105)异常向量基址mepc(0x341)sepc(0x141)异常PCmcause(0x342)scause(0x142)异常原因mie(0x304)sie(0x104)中断使能mip(0x344)sip(0x144)中断挂起但S模式不是M模式的完整复制。sstatus里只有SIE、SPIE、SPP、SUM、MXR这些位没有MPRV、MPP。sie/sip也只管S模式能处理的中断外部、定时器、软件M模式的中断它管不了。这里有个容易混淆的点S模式的中断最终还是要经过M模式。因为中断控制器如PLIC的使能和优先级配置在M模式S模式只能通过sie使能允许S模式处理但中断信号本身要先被M模式的路由逻辑放行。所以一个完整的中断链路是外设→PLIC→M模式mie→委派给S模式→S模式sie→S模式处理。中间任何一环没配好中断都进不来。satpSupervisor Address Translation and Protection地址0x180是S模式独有的M模式没有对应物。它控制页表基址和地址翻译模式。写satp会触发TLB刷新这个操作有开销所以内核里切换页表时要谨慎。satp的MODE字段0是Bare无翻译8是Sv399是Sv4810是Sv57。3.3 用户级CSR少但别忽略U模式能访问的CSR极少主要是浮点相关的fflags、frm、fcsr以及只读的性能计数器cycle、time、instret。这些寄存器在U模式可读但写fflags需要mstatus.FS或sstatus.FS不为0表示浮点单元已启用。性能计数器这块有个实用技巧cycle和instret默认在U模式可读但如果你不想让用户程序读可以在mcounteren里关掉对应位。mcounteren是M模式控制计数器在S/U模式可见性的寄存器scounteren则是S模式控制U模式可见性。这个两级控制链保证了从M到U的逐级授权。4. 特权级切换的实操过程与关键环节4.1 从M模式启动到跳入S模式的完整流程这是Bootloader里最核心的一段代码也是CSR配置最密集的地方。我按实际执行顺序拆一遍。第一步配置M模式异常向量。在跳入S模式之前M模式必须先把mtvec设好因为后续任何异常都要靠它兜底。la t0, m_trap_handler csrw mtvec, t0第二步配置PMPPhysical Memory Protection。PMP是M模式控制内存访问权限的机制S模式能不能访问某段内存全看PMP怎么配。至少要给S模式开放它要用的代码段、数据段和外设区域。PMP有16个配置寄存器pmpcfg0-15和16个地址寄存器pmpaddr0-15每个条目可以配读/写/执行权限。# 允许S模式访问全部地址空间简化配置实际要按需收紧 li t0, 0x0F csrw pmpcfg0, t0 li t0, 0xFFFFFFFF csrw pmpaddr0, t0第三步设置mstatus.MPP为S模式。这是跳转前的关键一步决定了mret之后落在哪个特权级。# 清除MPP设为01S模式 li t0, MSTATUS_MPP csrc mstatus, t0 li t0, (1 11) csrs mstatus, t0第四步设置mepc为S模式入口地址然后执行mret。la t0, s_entry csrw mepc, t0 mretmret执行时硬件做三件事把当前特权级设为MPP的值把MIE恢复为MPIE跳转到mepc。执行完这条指令CPU就正式进入S模式了。实操心得调试这段代码时如果mret之后跑飞先检查mepc是否指向了合法地址再检查mstatus.MPP是否设对。我遇到过因为mepc指向的地址没有映射到物理内存mret之后直接取指异常又跳回M模式形成死循环。4.2 异常与中断的委派配置M模式处理所有异常是默认行为但现代操作系统希望S模式自己处理大部分异常缺页、系统调用只有少数必须由M模式处理比如M模式定时器中断。这就涉及异常委派。委派通过medelegMachine Exception Delegation地址0x302和midelegMachine Interrupt Delegation地址0x303两个寄存器控制。某位置1表示对应的异常/中断委派给S模式处理。# 把系统调用cause 8、缺页cause 12/13/15委派给S模式 li t0, (1 8) | (1 12) | (1 13) | (1 15) csrw medeleg, t0 # 把S模式外部中断、定时器中断委派给S模式 li t0, (1 9) | (1 5) | (1 1) csrw mideleg, t0委派之后当这些异常在S模式或U模式发生时硬件直接跳到stvec不再经过M模式。这减少了陷入层级性能更好。但要注意委派只对低特权级有效。如果异常发生在M模式本身无论medeleg怎么配都还是M模式处理。因为M模式没有更高的层级可以委派。4.3 中断使能的分层控制中断使能是分层的从外设到CPU核心要过好几道关。以S模式定时器中断为例第一层外设级定时器自己的中断使能位。 第二层PLIC级PLIC里对应中断源的使能和优先级。 第三层M模式委派mideleg对应位置1允许委派给S模式。 第四层S模式使能sie.STIE置1允许S模式定时器中断。 第五层S模式全局使能sstatus.SIE置1。 第六层M模式全局使能mstatus.MIE置1如果中断要经过M模式路由。任何一层没开中断都进不来。调试中断不响应时按这个顺序从下往上查比盲目改代码高效得多。# S模式定时器中断使能示例 li t0, (1 5) # STIE csrs sie, t0 # S模式中断使能 csrs sstatus, (1 1) # SIE全局使能 csrs mstatus, (1 3) # MIE全局使能注意sstatus.SIE和mstatus.MIE是独立的。从M模式进入S模式后sstatus.SIE的初始值来自mstatus的某个位具体取决于实现但通常需要显式设置。我见过有人只设了sie忘了设sstatus.SIE结果中断一直不触发。5. 常见问题与排查技巧实录5.1 异常跳飞与mret跑飞的排查路径这类问题的表现是程序执行到某条指令后突然跳到未知地址或者mret之后行为异常。排查按以下顺序走。先读mcause确认异常类型。如果是非法指令cause 2看mepc指向的指令是什么大概率是访问了未实现的CSR或执行了当前特权级不允许的指令。如果是缺页cause 12/13/15看mtval地址0x343里存的出错地址检查页表或PMP配置。再读mstatus.MPP确认陷入前的特权级。如果MPP是00U模式但你期望是01S模式说明陷入前特权级就不对往上追。最后检查mepc的对齐。RISC-V指令是2字节或4字节对齐如果mepc是奇数mret必然跑飞。特别是手动改过mepc的代码加偏移量时容易算错。现象可能原因排查方法mret后跑飞mepc地址非法或未对齐读mepc检查对齐和映射异常反复触发mepc未推进异常指令重复执行异常处理里手动mepc4中断不响应使能链某一层未开按6层使能链逐层检查特权级错误mstatus.MPP配置错误读mstatus确认MPP值CSR访问异常CSR地址错误或权限不足对照CSR地址表确认特权级5.2 CSR读写指令的使用细节RISC-V访问CSR用6条指令csrrw、csrrs、csrrc、csrrwi、csrrsi、csrrci。命名规则csr 操作rw/s/c 立即数i。csrrw rd, csr, rs1读旧值到rd写rs1到csr。如果rd是x0表示不读只写。csrrs rd, csr, rs1读旧值到rd把rs1的置1位写到csr读-改-写。csrrc rd, csr, rs1读旧值到rd把rs1的置1位清零。这里有个反直觉的点csrrs和csrrc的rs1是掩码不是直接写入的值。csrrs只把rs1里为1的位在csr里置1为0的位保持不变。所以想设置某一位用csrrs想清除某一位用csrrc。# 设置mstatus.MIEbit 3 li t0, (1 3) csrrs x0, mstatus, t0 # 只置位不读 # 清除mstatus.MIE li t0, (1 3) csrrc x0, mstatus, t0 # 只清位不读 # 读mstatus csrrs t0, mstatus, x0 # rs1x0不改变任何位只读最后一条csrrs t0, mstatus, x0是读CSR的标准写法因为rs1是x0没有位被置1所以CSR值不变只把旧值读到t0。实操心得csrrwi、csrrsi、csrrci的立即数字段是5位0-31只能操作低5位。如果要设置高位比如mstatus.MPP在bit 12:11必须用寄存器版本不能直接用立即数版本。5.3 特权级切换时的寄存器保存从S模式陷入M模式时硬件只自动保存mepc、mcause、mtval和mstatus的部分位。通用寄存器、浮点寄存器、向量寄存器都需要软件自己保存。这里有个性能取舍如果异常处理程序只用少数几个寄存器可以只保存用到的如果要支持完整的上下文切换比如任务调度就得保存全部。我一般建议在异常入口先保存ra、sp、gp、tp和所有t寄存器因为这些是调用者保存寄存器异常处理程序可能会破坏它们。m_trap_handler: addi sp, sp, -128 sd ra, 0(sp) sd t0, 8(sp) sd t1, 16(sp) # ... 保存其他寄存器 # 处理异常 # 恢复寄存器 ld ra, 0(sp) ld t0, 8(sp) # ... addi sp, sp, 128 mret浮点寄存器只有在mstatus.FS不为0时才需要保存。如果异常处理程序里不用浮点可以跳过省时间。5.4 调试CSR的实用手段没有调试器的时候可以用最原始的办法把CSR值通过串口打出来。写一个dump_csr函数把关键CSR读出来转成十六进制输出。void dump_csr(void) { unsigned long val; asm volatile(csrr %0, mstatus : r(val)); uart_printf(mstatus 0x%lx\n, val); asm volatile(csrr %0, mtvec : r(val)); uart_printf(mtvec 0x%lx\n, val); asm volatile(csrr %0, mepc : r(val)); uart_printf(mepc 0x%lx\n, val); asm volatile(csrr %0, mcause : r(val)); uart_printf(mcause 0x%lx\n, val); }在异常处理入口调用一次能快速定位大部分问题。如果连串口都没有可以用GPIO翻转或者写一个固定的内存地址用逻辑分析仪抓。有调试器的话直接读CSR更高效。OpenOCD支持riscv expose_csrs命令可以把CSR暴露给GDB然后像读普通寄存器一样读。6. 从裸机到系统CSR配置的演进思路6.1 裸机阶段的极简配置裸机程序通常只跑在M模式不需要S和U。这时候CSR配置可以极简设好mtvec开mstatus.MIE配好mie就够了。不需要PMP除非有安全需求不需要委派不需要satp。这个阶段最容易犯的错是忘了设mtvec就开中断。一旦中断触发mtvec是复位默认值通常是0直接跳到地址0而地址0往往是启动代码结果就是重启循环。所以顺序永远是先设mtvec再开中断。6.2 RTOS阶段的S模式引入跑RTOS时如果芯片支持S模式通常会把RTOS内核放到S模式任务放到U模式。这样任务崩溃不会拖垮内核。这个阶段的CSR配置多了几件事配medeleg把系统调用和缺页委派给S模式配stvec配satp如果RTOS支持虚拟内存配PMP给S模式开放必要区域。RTOS的任务切换在S模式完成通过ecall从U模式陷入S模式。ecall的cause是8U模式或9S模式处理程序根据mepc和寄存器状态决定是系统调用还是任务切换。6.3 Linux阶段的完整配置Linux把M模式压缩到最小只留一个SBISupervisor Binary Interface固件。所有硬件操作通过ecall从S模式请求M模式完成。这个阶段的CSR配置最复杂PMP要精细划分内存区域medeleg要委派几乎所有异常mideleg要委派大部分中断satp要配Sv39/Sv48页表。M模式的SBI固件通常只处理定时器中断用于调度和IPI核间中断其他全部委派。这样Linux内核在S模式就能完成绝大多数工作M模式几乎不参与日常运行。从裸机到LinuxCSR配置的复杂度是递增的但核心逻辑不变每一级只开放下一级需要的权限能委派的就委派M模式只保留必须自己处理的部分。理解这个原则比记住具体寄存器值更重要。我个人在实际移植过程中的体会是CSR配置出错时不要急着改代码先把当前所有关键CSR的值dump出来对照手册逐个核对。十次里有八次是某个使能位没开或者某个模式位设错。把mstatus、mtvec、medeleg、mideleg、mie、mip这六个寄存器做成一个检查清单每次调试前过一遍能省下大量时间。另外mtvec的Vectored模式在中断多的时候能省去软件判断cause的开销但异常处理仍然要回到Direct逻辑这个混合用法值得在中断密集的场景里试试。
返回列表