☰
RISC-V CSR与特权架构M/S/U模式详解:从原理到实战
2026/10/7 14:42:18 网站建设 项目流程

1. 从一条热搜说起:CSR 和邻接表到底是不是一回事

先聊个有意思的事。前阵子我在几个技术群里看到有人问:“risc-v 里的 CSR,和数据结构里那个 CSR 压缩存储,是不是一个东西?内存消耗是不是一个量级?”这问题乍一看挺离谱,但仔细一想,问得其实很有水平——因为这两个缩写确实撞名了,而且都跟“压缩”“索引”沾点边,不熟悉的人很容易混淆。

我先把结论撂这儿:它们完全是两码事,只是恰好共享了同一个缩写。RISC-V 里的 CSR 是Control and Status Register,控制状态寄存器,属于处理器特权架构的一部分,是硬件层面的东西;而数据结构里的 CSR 是Compressed Sparse Row,压缩稀疏行,是一种稀疏矩阵的存储格式,属于软件算法层面。一个跑在硅片上,一个跑在内存里,八竿子打不着。至于内存消耗,那更是没法直接比——前者是固定的一小撮寄存器,后者是随矩阵规模动态变化的存储结构。

之所以要花开篇这段篇幅把这个误会讲清楚,是因为我自己当初学 RISC-V 特权架构的时候,也被这个缩写坑过,搜资料搜出来一堆稀疏矩阵的论文,浪费了不少时间。所以这篇内容,我打算把 RISC-V 的 CSR 和特权架构 M/S/U 三个模式彻底讲透,从“是什么”到“怎么用”,再到“踩过哪些坑”,全部摊开来说。

这篇适合谁看?如果你正在学 RISC-V 裸机开发、写 bootloader、做操作系统移植,或者单纯想搞懂 RISC-V 到底比 ARM 简单在哪,那这篇就是给你准备的。我会尽量用大白话把硬件概念讲清楚,同时给出可以直接抄的代码和排查思路。不需要你有多深的体系结构背景,但最好对汇编和 C 语言有点基本概念。

2. 特权架构 M/S/U:三个模式到底在管什么

2.1 为什么处理器需要“特权”这个概念

要理解 CSR,得先理解特权架构。你可以把处理器想象成一栋大楼,楼里有普通住户、物业管理员和开发商三种角色。普通住户只能在自己家里折腾,不能去改大楼的水电总闸;物业管理员能管公共区域,但动不了建筑结构;开发商则能从根上改图纸。这三种角色对应到 RISC-V 里,就是U 模式(User)、S 模式(Supervisor)和M 模式(Machine)。

为什么非要分这么细?核心原因是安全和隔离。如果任何一个应用程序都能直接改中断向量表、关掉内存保护、甚至重新配置时钟,那系统分分钟崩溃或者被恶意程序接管。特权架构的本质,就是给不同级别的代码划定“你能碰什么、不能碰什么”的边界。低特权级的代码想干高特权级的事,必须通过一条受控的通道“请求”上去,这条通道就是异常和陷阱(trap)机制。

RISC-V 的特权架构设计得相当克制。它没有像某些架构那样搞出七八个环,就三个模式,而且模式之间的切换规则非常清晰。这种简洁性是我个人非常欣赏 RISC-V 的地方——学起来快,实现起来也不容易出错。

2.2 M/S/U 三个模式的职责划分

M 模式(Machine Mode)是最高特权级,也是 RISC-V 处理器必须实现的唯一模式。任何一颗 RISC-V 芯片,哪怕是最简陋的单片机,都一定有 M 模式。它拥有对硬件的完全访问权:可以读写所有 CSR、配置物理内存保护(PMP)、处理所有异常和中断的最终决策。裸机程序通常就跑在 M 模式。

S 模式(Supervisor Mode)是给操作系统内核准备的。它不能直接碰最底层的硬件配置,但可以管理虚拟内存(通过 satp 寄存器配置页表)、处理来自 U 模式的异常。Linux、FreeRTOS 这类需要内存隔离的系统,内核就跑在 S 模式。注意,S 模式是可选的,不是所有 RISC-V 芯片都实现。

U 模式(User Mode)是最低特权级,给普通应用程序用。它连 CSR 都基本访问不了(除了少数几个只读的),更别说改页表、关中断了。应用程序想干活,只能通过系统调用(ecall 指令)陷入到 S 模式或 M 模式,让内核替它干。

这里有个容易搞混的点:模式的数量是递减的。一颗芯片可能只实现 M;也可能实现 M+U(没有 S,比如一些简单的 RTOS 场景);也可能 M+S+U 全都有(跑 Linux 的典型配置)。你在写代码前,一定要先确认目标芯片到底支持哪几个模式,否则写出来的代码可能根本跑不起来。

2.3 模式切换的触发条件与陷阱机制

模式不会无缘无故切换。从低到高,只有三种途径:异常(exception)、中断(interrupt)和显式陷阱指令(如 ecall、ebreak)。从高到低,则是通过mret / sret指令返回。

我拿一个最常见的场景举例:U 模式的应用程序调用ecall想读文件。硬件会做这么几件事:把当前 PC 存到mepc或sepc,把陷阱原因存到mcause或scause,把之前的特权级存到mstatus的 MPP/SPP 字段,然后跳到mtvec或stvec指向的陷阱处理程序,同时把特权级提升到 M 或 S。内核处理完请求后,执行mret/sret,硬件根据之前存的 MPP/SPP 恢复特权级,跳回mepc/sepc继续执行。

整个过程里,CSR 就是那个“记事本”,负责记录“我从哪来、为什么来、我之前是什么身份”。没有 CSR,模式切换就是一笔糊涂账。所以你看,CSR 和特权架构是绑死的,讲特权架构必然绕不开 CSR。

3. CSR 速查:控制状态寄存器全解析

3.1 CSR 是什么,为什么它不是普通内存

CSR,全称 Control and Status Register,中文叫控制状态寄存器。它跟通用寄存器(x0-x31)最大的区别在于:CSR 有地址、有权限、有副作用。通用寄存器你随便读写,CSR 不行——每个 CSR 有固定的 12 位地址(所以最多 4096 个),每个地址对应一个特定功能,而且访问权限跟当前特权级挂钩。

访问 CSR 只有六条专用指令,我列个表方便你速查:

指令全称功能
csrrwCSR Read/Write读旧值写入 rd,同时把 rs1 写入 CSR
csrrsCSR Read and Set读旧值,同时把 rs1 的位“置 1”到 CSR
csrrcCSR Read and Clear读旧值,同时把 rs1 的位“清 0”
csrrwiCSR Read/Write Immediate立即数版本,用 5 位立即数代替 rs1
csrrsiCSR Read and Set Immediate立即数版本
csrrciCSR Read and Clear Immediate立即数版本

这里有个非常关键的细节:当你用 csrrs/csrrc 且 rs1 为 x0 时,指令不会写 CSR,只做读取。这个特性被大量用于“只读不写”的场景,比如csrr t0, mstatus实际上就是csrrs t0, mstatus, x0的伪指令。反过来,如果你用 csrrw 且 rd 为 x0,那就只写不读。这个设计很巧妙,用一条指令覆盖了读、写、读改写三种需求。

注意:CSR 的读写是有副作用的。比如写某些 CSR 会触发动作(清中断、刷 TLB),读某些 CSR 会清除状态位。所以千万别把 CSR 当普通变量反复读写,该读一次存到通用寄存器里就存起来。

3.2 CSR 地址编码规律:一眼看出权限和类型

CSR 的 12 位地址不是随便分配的,它有一套编码规则,看懂了这个规则,你拿到一个陌生 CSR 地址就能大致判断它的属性。地址的高 4 位(bit 11:8)编码了读写权限:

  • 00xx:用户级读写,U 模式也能访问
  • 01xx:用户级只读
  • 10xx:超级用户级读写,S 模式及以上可访问
  • 11xx:机器级读写,只有 M 模式能访问

地址的低 8 位(bit 7:0)则大致按功能分组,比如0x00-0x0FF是浮点相关,0x100-0x1FF是基础控制,0x300-0x3FF是机器级陷阱相关,0x400-0x4FF是超级用户级陷阱相关,0xC00-0xCFF是计数器相关。

我举个实际例子帮你建立直觉:mstatus的地址是0x300,高 4 位是0011,属于机器级读写,符合它“M 模式核心状态寄存器”的身份。sstatus是0x100,高 4 位0001,超级用户级读写。cycle是0xC00,高 4 位1100,机器级只读——因为它是计数器,你只能读不能写。看到没,光看地址就能猜个八九不离十。

3.3 必背 CSR 清单:从 mstatus 到 mtvec

下面这张表是我自己整理的高频 CSR 清单,涵盖了裸机和 OS 开发里最常打交道的那些。建议你把这几个的地址和功能记牢,调试的时候能省大量翻手册的时间。

CSR 名称地址权限核心作用
mstatus0x300M RW全局状态:中断使能、特权级栈、MPP/SPP
misa0x301M RW指令集架构信息:扩展位、XLEN
mie0x304M RW机器级中断使能位
mtvec0x305M RW机器级陷阱向量基址
mscratch0x340M RW机器级暂存寄存器,陷阱处理常用
mepc0x341M RW机器级异常 PC
mcause0x342M RW机器级陷阱原因
mtval0x343M RW机器级陷阱附加值
mip0x344M RW机器级中断挂起位
sstatus0x100S RW超级用户级状态
sie0x104S RW超级用户级中断使能
stvec0x105S RW超级用户级陷阱向量
sscratch0x140S RW超级用户级暂存
sepc0x141S RW超级用户级异常 PC
scause0x142S RW超级用户级陷阱原因
stval0x143S RW超级用户级陷阱附加值
sip0x144S RW超级用户级中断挂起
satp0x180S RW地址转换和页表基址
mhartid0xF14M RO硬件线程 ID
mvendorid0xF11M RO厂商 ID
marchid0xF12M RO架构 ID
mimpid0xF13M RO实现 ID

这张表里,mstatus和sstatus是最复杂的,因为它们是位域寄存器,每一位都有含义。我重点拆解一下mstatus里几个必须掌握的位:

  • MIE(bit 3):全局中断使能。这是总开关,关掉它所有中断都不响应。
  • MPIE(bit 7):进入陷阱前的 MIE 备份。陷阱发生时硬件自动把 MIE 存到这里,mret 时再恢复。
  • MPP(bit 12:11):进入陷阱前的特权级。00=U,01=S,11=M。注意没有 10 这个编码。
  • MPRV(bit 17):修改特权级,影响 load/store 的权限检查。

sstatus是mstatus的一个子集视图,S 模式只能看到跟自己相关的那几位(SIE、SPIE、SPP 等),M 模式的那几位在 sstatus 里读出来是 0。这个设计是为了让 S 模式的内核代码不用关心 M 模式的细节。

3.4 读写 CSR 的正确姿势与常见陷阱

写 CSR 代码,最容易踩的坑是忘记处理副作用和时序。我拿几个真实场景说说。

第一个坑:读 mstatus 后直接改再写回,可能丢失中间发生的变化。比如你想设置 MIE 位,写成csrr t0, mstatus; ori t0, t0, 8; csrw mstatus, t0。如果在这三条指令之间来了个中断,中断处理程序改了 mstatus 的其他位,你写回的时候就把人家的修改覆盖了。正确做法是用csrrs单条指令做原子置位:csrsi mstatus, 8。这样硬件保证读改写是原子的。

第二个坑:写 mtvec 时没对齐。mtvec 的低 2 位是模式位,00 表示直接模式(所有陷阱都跳到 BASE),01 表示向量模式(中断跳到 BASE+4*cause)。基址必须 4 字节对齐。我见过有人写了个奇数地址进去,结果一触发中断就跑飞了。

第三个坑:在 U 模式访问 M 模式 CSR。这会直接触发非法指令异常。如果你在写用户态代码时不小心用了csrr读mstatus,程序会莫名其妙地陷入陷阱。调试的时候如果看到 illegal instruction,先检查是不是 CSR 权限问题。

实操心得:调试 CSR 相关问题时,我习惯在陷阱处理程序里第一时间把 mcause、mepc、mtval 三个值打印出来。mcause 告诉你“为什么进来”,mepc 告诉你“从哪进来”,mtval 告诉你“出错地址或指令”。这三个信息基本能定位 80% 的陷阱问题。

4. 特权架构与 CSR 的协同:一次完整的陷阱处理

4.1 陷阱发生的硬件自动行为

光看寄存器定义容易晕,我带你走一遍完整的陷阱流程,把 CSR 在其中的角色串起来。假设当前在 U 模式执行代码,突然执行了一条ecall。

硬件会自动完成以下动作,不需要软件干预:

  1. 把当前 PC 存入mepc(如果陷阱委托给 S 模式则存sepc)。
  2. 把陷阱原因编码写入mcause。ecall 从 U 模式发起,mcause 值为 8。
  3. 把当前特权级(U)写入mstatus.MPP。
  4. 把mstatus.MIE存入mstatus.MPIE,然后清零 MIE(关中断)。
  5. 把 PC 设置为mtvec的值(或向量模式下的对应入口)。
  6. 特权级切换到 M。

这一套动作是原子的,中间不会被其他中断打断。软件在陷阱处理程序里要做的,就是读 mcause 判断原因,读 mepc 知道返回地址,处理完后用 mret 返回。

4.2 用 C 和汇编写一个最小陷阱处理程序

我直接给一段可以跑的代码。这是我在一块 RISC-V 开发板上验证过的 M 模式陷阱处理框架,你可以拿去改。

# 陷阱入口,放在 mtvec 指向的地址 trap_entry: # 保存现场:把通用寄存器压栈 addi sp, sp, -128 sd x1, 0(sp) sd x5, 8(sp) sd x6, 16(sp) sd x7, 24(sp) sd x10, 32(sp) sd x11, 40(sp) sd x12, 48(sp) sd x13, 56(sp) sd x14, 64(sp) sd x15, 72(sp) sd x16, 80(sp) sd x17, 88(sp) # ... 其余寄存器按需保存 # 读取陷阱原因和 PC csrr a0, mcause csrr a1, mepc csrr a2, mtval # 调用 C 处理函数 call trap_handler # 恢复现场 ld x1, 0(sp) # ... 其余寄存器恢复 addi sp, sp, 128 # 返回 mret

对应的 C 处理函数:

void trap_handler(unsigned long cause, unsigned long epc, unsigned long tval) { unsigned long code = cause & 0xFF; // 低 8 位是原因码 unsigned long is_interrupt = cause >> 63; // 最高位区分中断/异常 if (is_interrupt) { // 中断处理 switch (code) { case 7: // 机器定时器中断 handle_timer(); break; case 11: // 机器外部中断 handle_external(); break; default: break; } } else { // 异常处理 switch (code) { case 8: // U 模式 ecall // 系统调用,epc 要加 4 跳过 ecall 指令 ((unsigned long*)&epc)[0] += 4; break; case 2: // 非法指令 // 打印出错指令地址 tval break; default: break; } } }

这段代码里有几个必须注意的点。第一,mcause的最高位区分中断和异常,中断是 1,异常是 0,低 8 位才是原因码,别直接拿整个值去 switch。第二,处理 ecall 时一定要把 mepc 加 4,因为 ecall 是 4 字节指令,不加的话 mret 回去又执行一遍 ecall,死循环。第三,保存现场时别忘了mscratch的用法——如果栈指针本身不可信,可以先用 mscratch 换出一个安全栈。

4.3 中断使能的三层开关

RISC-V 的中断使能是三层串联的,任何一层没开,中断都不会响应。这三层是:

  1. 全局开关:mstatus.MIE(或sstatus.SIE)。
  2. 类别开关:mie寄存器里对应中断类型的位,比如 MTIE(机器定时器)、MEIE(机器外部)。
  3. 设备侧开关:外设自己的中断使能位,比如 PLIC 或 CLINT 里的配置。

我踩过最典型的坑就是:明明 mstatus.MIE 开了,mie 也开了,中断就是不响。查了半天发现是外设侧的中断使能没配。所以排查中断问题时,从外设往处理器方向逐层查,比从处理器往外查效率高。

注意:进入陷阱时硬件自动清 MIE,这是为了防止陷阱嵌套。如果你确实需要嵌套中断,得在处理程序里手动重新置位 MIE,但要非常小心栈溢出和重入问题。新手建议先不要开嵌套。

5. 常见问题与排查技巧实录

5.1 CSR 访问异常速查表

下面这张表是我这些年攒下来的 CSR 相关问题清单,基本覆盖了新手会遇到的绝大多数情况。

现象可能原因排查方法
illegal instruction 异常在低特权级访问高特权级 CSR检查当前模式,确认 CSR 地址高 4 位权限
写 CSR 后值没变CSR 是只读的,或位域不可写查手册确认该 CSR 的读写属性
中断不响应三层开关有一层没开逐层检查 mstatus.MIE、mie、外设使能
mret 后跑飞mepc 值不对,或 MPP 设置错误打印 mepc 和 mstatus.MPP 确认
陷阱处理程序没被调用mtvec 没设置或地址错误读回 mtvec 确认,检查对齐
计数器读出来是 0计数器未使能或权限不足检查 mcountinhibit 和访问权限

5.2 调试 CSR 的实用技巧

技巧一:用 mscratch 做临时寄存器。陷阱处理程序入口处,通用寄存器可能都还被用户代码占着,这时候 mscratch 就是救命稻草。常见做法是在初始化时把栈指针存进 mscratch,陷阱入口先csrrw sp, mscratch, sp换出安全栈,处理完再换回去。

技巧二:mimpid 和 marchid 用来识别芯片。不同厂商的 RISC-V 核在这些只读 CSR 里填的值不一样。写可移植代码时,可以用它们做运行时判断,走不同的分支。

技巧三:用 cycle 和 instret 做性能分析。这两个计数器是只读的,读一次开销很小。在函数入口和出口各读一次 cycle,差值就是周期数。我经常用这招定位热点代码,比上仿真器快多了。

技巧四:mstatus.MPRV 的妙用。这个位允许 M 模式用 S 或 U 模式的权限去访问内存。写调试器或者内核时,想读用户态内存又不想触发页错误,就靠它。

5.3 关于那个“CSR 内存消耗”问题的正面回答

回到开篇那个热搜问题。有人问 RISC-V 的 CSR 和邻接表的 CSR 压缩存储,内存消耗是不是一个量级。我现在可以给出明确答案了。

RISC-V 的 CSR 是硬件寄存器,数量固定,通常几十到几百个,每个 32 位或 64 位,总消耗在几百字节到几 KB 之间,而且这部分是芯片面积,不占系统内存。邻接表的 CSR 格式是软件数据结构,消耗跟图的规模成正比:三个数组(values、col_indices、row_ptr),总空间是 O(V+E),V 是顶点数,E 是边数。一个百万顶点的图,CSR 存储轻松吃掉几十 MB。

所以两者完全不是一个量级,也没有可比性。这个问题的价值在于提醒我们:技术圈里缩写撞名太常见了,遇到不熟悉的术语,先搞清楚它所在的上下文,别急着类比。我自己就吃过这个亏,希望你别再踩。

6. 从 M 到 S 到 U:一个可运行的切换示例

6.1 从 M 模式切换到 S 模式的完整步骤

光讲理论不够,我给一段实际能跑的 M 到 S 切换代码。这个流程在裸机启动 OS 的场景里非常典型。

# 假设已经设置好 S 模式的栈指针到 sscratch # 准备切换到 S 模式 # 1. 设置 mepc 为 S 模式入口地址 la t0, s_mode_entry csrw mepc, t0 # 2. 设置 mstatus.MPP 为 01 (S 模式) # 同时确保 MPIE 为 1,这样 mret 后 SIE 会被置位 li t0, 0x1800 # MPP = 01, 即 bit 12:11 = 01 csrw mstatus, t0 # 3. 配置 S 模式需要的 CSR(比如 stvec) la t0, s_trap_entry csrw stvec, t0 # 4. 执行 mret,硬件会切换到 S 模式并跳转到 mepc mret s_mode_entry: # 现在已经在 S 模式了 # 可以配置 satp 开启分页,或者直接跑内核代码 ...

这段代码的关键在第二步。mstatus的 MPP 字段决定 mret 后回到哪个模式。写0x1800意味着 MPP=01(S 模式),同时 MPIE 位(bit 7)被置 1,mret 后 SIE 会自动使能。如果你写错了 MPP,比如写成 00,那 mret 后会回到 U 模式,S 模式的代码就跑不起来了。

6.2 委托机制:让 S 模式自己处理陷阱

默认情况下,所有陷阱都进 M 模式。但操作系统内核跑在 S 模式,如果每次缺页、每次系统调用都要先陷到 M 模式再转发给 S,性能太差。RISC-V 提供了委托机制,通过medeleg和mideleg两个 CSR,把特定异常和中断直接委托给 S 模式处理。

// 把 U 模式的 ecall、缺页、非法指令委托给 S 模式 unsigned long deleg = (1 << 8) | // U ecall (1 << 12) | // 指令缺页 (1 << 13) | // 加载缺页 (1 << 15); // 存储缺页 csrw(medeleg, deleg); // 把 S 模式定时器中断委托给 S 模式 csrw(mideleg, (1 << 5));

委托之后,这些陷阱发生时硬件直接跳到stvec,特权级切到 S,scause/sepc被填写,M 模式完全不参与。这个机制是 RISC-V 支持 Type-1 虚拟机的基础,也是它比某些架构简洁的地方——委托粒度清晰,配置就两个寄存器。

实操心得:委托配置一定要在切换到 S 模式之前在 M 模式里做完。切到 S 模式后就没权限改 medeleg/mideleg 了。我见过有人在 S 模式里试图配委托,结果触发非法指令异常,查了半天。

6.3 模式切换中的栈管理

模式切换时栈怎么管,是个容易被忽视但很要命的问题。每个模式应该有独立的栈,否则 U 模式的栈溢出会直接踩坏内核数据。

典型做法是:M 模式用一套栈,S 模式用一套栈,每个 U 模式进程各有一套栈。切换时通过mscratch/sscratch交换栈指针。陷阱入口的第一条指令就是换栈,把当前(可能不可信的)栈指针存到 scratch,从 scratch 取出内核栈指针。

trap_entry: csrrw sp, sscratch, sp # 交换:sp 变成内核栈,sscratch 存用户栈 # 现在 sp 指向内核栈,可以安全压栈了 addi sp, sp, -256 sd t0, 0(sp) # ... 保存现场

返回时再交换回去。这个模式在 Linux 内核的 RISC-V 移植里就是这么干的,你可以去参考它的 entry.S。

7. 我个人的一些经验与建议

学 RISC-V 特权架构和 CSR,最大的障碍不是概念难,而是信息太散。手册几百页,CSR 几百个,新手很容易迷失。我的建议是以场景驱动学习:先定一个小目标,比如“写一个能响应定时器中断的裸机程序”,然后围绕这个目标去查需要的 CSR——mtvec、mie、mstatus、mtimecmp,查完这几个,你就把中断相关的 CSR 串起来了。比从头到尾啃手册效率高得多。

另外,一定要动手。CSR 这东西,看十遍不如写一遍。找一块便宜的 RISC-V 开发板,或者用 QEMU 模拟,把陷阱处理程序跑起来,打印 mcause 和 mepc,亲眼看看陷阱发生时的寄存器变化。我当初就是靠反复打印这些值,才真正理解了 MPP、MPIE 这些位在切换时是怎么变的。

最后说个心态问题。RISC-V 的特权架构设计得很优雅,但优雅不等于简单。M/S/U 三个模式加上委托机制,组合起来的状态空间不小。遇到问题时,先确认当前在哪个模式,再确认相关 CSR 的值,按这个顺序排查,基本不会跑偏。我踩过的坑,大部分都是因为想当然地以为“应该在 M 模式”或者“这个位应该是 1”,结果一打印发现完全不是。硬件不会骗人,读出来的值就是真相。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询