☰
RISC-V内存架构深度解析:PMA、ePMP、Cache、CMO、MMU与RVWMO实战指南
2026/10/7 14:41:48 网站建设 项目流程

1. 从一颗芯片的“记忆迷宫”说起:为什么内存架构值得深挖

搞RISC-V的人迟早会撞上一堵墙:指令集本身看着挺简洁,RV32I/RV64I那点东西几天就能翻完,但一旦你开始碰真实的SoC——尤其是带操作系统、跑多核、接DMA外设的那种——你会发现真正让人头秃的根本不是指令编码,而是内存系统。PMA、ePMP、Cache、CMO、MMU、RVWMO,这几个词单拎出来每个都能写一篇长文,但它们凑在一起才构成一个完整的“内存世界观”。

我最初接触这些概念是在调试一个多核RISC-V平台的时候。当时遇到一个诡异现象:CPU写了一段数据,DMA读出来是旧的;加个fence有时候好使有时候不好使;开了MMU之后性能反而掉了三成。后来一路刨下去,才发现问题分散在PMA属性配置、ePMP权限、Cache一致性、CMO指令使用和RVWMO内存模型理解这几个层面上。这也是我写这篇东西的动机——把RISC-V内存架构这条线从头到尾捋一遍,不是照本宣科翻译手册,而是把每个模块“为什么存在、解决什么问题、实际怎么配、容易踩什么坑”讲清楚。

这篇文章适合谁看?如果你已经写过RISC-V的裸机代码,或者正在做RISC-V平台的BSP/驱动/固件开发,又或者你在学体系结构想找个真实的落地视角,那这篇内容应该对你有用。我会尽量用大白话解释原理,用实际配置举例,把那些手册里一笔带过但实操中要命的地方标出来。全文围绕PMA、ePMP、Cache、CMO、MMU和RVWMO这六个核心点展开,每个点都会给出可参考的配置思路和排查方法。

2. 内存架构整体设计与思路拆解

2.1 为什么RISC-V要把内存相关的东西拆成这么多模块

先看一个基本事实:RISC-V的特权规范把内存管理相关的内容拆成了好几个相对独立的机制,而不是像某些架构那样揉成一个大MMU。这个设计选择背后有很实际的考量。

PMA(Physical Memory Attributes)描述的是物理地址空间的属性——这段地址是内存还是设备?能不能缓存?能不能乱序访问?支不支持原子操作?这些属性是平台固定的,跟当前跑什么程序无关。ePMP(enhanced Physical Memory Protection)则是在PMA基础上做权限控制,规定某个hart在某个特权级下能不能访问某段物理地址,是读、写还是执行。Cache和CMO(Cache Management Operations)管的是性能和数据可见性。MMU负责虚拟地址到物理地址的翻译。RVWMO(RISC-V Weak Memory Ordering)定义的是多hart之间内存操作的可见顺序规则。

拆开的好处是灵活:一个简单的MCU可以只实现PMA,不要MMU;一个应用处理器可以全都要。坏处是开发者得理解每个模块的边界,知道什么问题该找谁。我见过不少人把Cache一致性问题当成MMU问题查,或者把PMA属性配错导致的异常当成ePMP权限问题,方向错了排查效率极低。

2.2 从地址出发:一次内存访问到底经过了什么

要理解这套架构,最好的办法是跟着一次load/store走一遍。假设CPU执行一条ld a0, 0(x1),地址是虚拟地址VA:

第一步,MMU(如果开启)把VA翻译成物理地址PA,同时检查页表项里的权限位。如果翻译失败或者权限不够,触发page fault。第二步,PA进入PMA检查,看这段物理地址的属性是否允许当前类型的访问——比如你想对一个标记为“I/O设备”的地址做缓存读,PMA就会拦下来。第三步,ePMP检查当前hart在当前特权级下是否有权访问这个PA范围。第四步,如果地址可缓存,访问落到Cache;如果不可缓存,直接打到总线上。第五步,如果是多hart场景,RVWMO规则决定这次访问和其他hart的访问之间需不需要额外的fence或CMO来保证顺序和可见性。

这个流程里任何一步出问题都会表现为“程序行为不对”,但根因完全不同。理解这个链路,是高效调试的前提。

2.3 方案选型的现实考量:不是每个平台都需要全套

实际做项目的时候,内存架构的复杂度要跟需求匹配。我整理了一个简单的对照,帮你在设计阶段做取舍:

平台类型PMAePMPCacheCMOMMURVWMO关注度
简单MCU必须可选通常无无无低
实时控制核必须建议有可选可选可选中
应用处理器必须必须必须必须必须高
多核异构SoC必须必须必须必须视核而定极高

这个表不是规范,是我自己在几个项目里总结的经验值。比如做电机控制的实时核,Cache可以不要,但PMA和ePMP一定要配清楚,否则一个野指针就能把外设寄存器写乱。而做Linux能跑的应用核,MMU和CMO是绕不开的,RVWMO的理解深度直接决定你能不能写出正确的无锁代码。

3. 核心细节解析与实操要点

3.1 PMA:物理地址属性的“户口本”

PMA本质上是一张表,描述物理地址空间的每一段“是什么、能干什么”。典型的属性包括:

  • 可缓存性(Cacheability):这段地址能不能进Cache。可缓存又分可缓存、不可缓存、可缓存但需软件维护一致性等。
  • 访问顺序(Ordering):对这段地址的访问是否允许乱序、合并。
  • 原子性支持(Atomicity):支不支持AMO(原子内存操作)。
  • 访问宽度:支持哪些位宽的访问,比如某些设备寄存器只支持32位访问,你非要用8位读就可能出问题。
  • 可执行性:这段地址能不能取指。

配置PMA通常不是在运行时做的,而是在平台设计阶段就定好,通过硬件描述或者固件里的静态表体现。比如在设备树里你会看到类似reg = <0x0 0x80000000 0x0 0x10000000>这样的内存节点,配套的属性决定了它的行为。

注意:PMA配错最典型的表现是“访问设备寄存器时数据不对”或者“对内存做非对齐访问触发异常”。排查时先确认目标地址落在哪个PMA区间,再看该区间的属性是否匹配你的访问方式。

实操中我建议把平台的PMA表整理成一张文档,标注每段的起止地址、类型、可缓存性、支持的访问宽度。这个文档在调试DMA、外设驱动、Cache维护代码时能省大量时间。我自己的习惯是用一个简单的表格维护,每次改硬件描述就同步更新。

3.2 ePMP:比PMP更细的物理内存权限控制

ePMP是PMP的增强版,核心作用是给物理内存访问加权限检查。它把物理地址空间划分成若干区域,每个区域可以配置读/写/执行权限,并且可以针对不同的特权级(M/S/U)设置不同规则。

ePMP相比传统PMP的增强点主要在于:支持更灵活的权限组合、对锁定(lock)语义的完善、以及和Smepmp扩展配合时能实现更严格的安全隔离。在多核场景下,每个hart有自己的ePMP配置,这意味着你可以让核A能访问某段共享内存而核B不能。

配置ePMP的典型流程:

  1. 确定需要保护的区域,比如内核代码段、只读数据段、外设寄存器区、共享内存区。
  2. 为每个区域选择匹配的pmpcfg和pmpaddr值。pmpaddr存的是地址右移2位后的值,因为PMP粒度是4字节。
  3. 设置权限位R/W/X,以及地址匹配模式(TOR、NA4、NAPOT)。
  4. 对需要锁定的区域设置L位,锁定后直到复位才能修改。
// 示例:配置一个可读可写不可执行的区域,覆盖0x80000000开始的1MB // pmpaddr = 0x80000000 >> 2 = 0x20000000 // 假设使用pmpcfg0的第0个条目 #define PMP_R 0x01 #define PMP_W 0x02 #define PMP_X 0x04 #define PMP_A_NAPOT 0x18 // NAPOT模式 #define PMP_L 0x80 // 1MB区域用NAPOT表示:地址低若干位全1 // 1MB = 2^20,NAPOT编码需要地址低19位为1(因为粒度4字节,2^20/4=2^18,再加1位) // 实际编码需根据规范计算,这里示意

提示:ePMP的地址匹配模式选择很关键。TOR模式适合连续区域,NA4适合单个4字节,NAPOT适合2的幂次大小的区域。选错模式会导致保护范围不对,要么保护过头要么形同虚设。

我踩过的一个坑:早期调试时把内核代码段配成可写,结果一个指针越界把代码改了,系统跑飞。后来把代码段设成R+X、数据段设成R+W、外设区设成R+W且不可缓存,问题就定位得很快。ePMP配好之后,很多内存越界问题会直接变成异常,而不是静默地破坏数据,这对调试是巨大的帮助。

3.3 Cache:性能的发动机,也是一致性的麻烦源头

RISC-V的Cache设计比较开放,规范没有强制规定Cache的结构,所以不同实现差异很大。常见的有VIPT(虚拟索引物理标签)、PIPT(物理索引物理标签)等。对软件开发者来说,需要关心的是:Cache的层级、行大小、替换策略、以及是否支持硬件一致性。

查看Cache信息在Linux下可以用lscpu或者读/sys/devices/system/cpu/cpu0/cache/下的文件。比如:

# 查看各级Cache的大小、行大小、关联度 cat /sys/devices/system/cpu/cpu0/cache/index0/size cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size cat /sys/devices/system/cpu/cpu0/cache/index0/ways_of_associativity

这些信息在做性能优化和Cache维护时很有用。比如你知道行大小是64字节,那在做CMO的时候就要按64字节对齐操作。

Cache带来的核心问题是一致性。在单核且没有DMA的场景下,Cache基本是透明的。但一旦有多核或者DMA,就必须考虑:核A写了数据,核B能不能看到?DMA读内存,会不会读到Cache里的旧数据?

解决一致性有两条路:硬件维护一致性(比如总线上的snoop机制)和软件维护一致性(用CMO指令)。RISC-V里两者都可能出现,取决于具体实现。软件维护一致性的场景下,你需要在对共享数据操作前后插入合适的CMO。

注意:不要假设所有RISC-V平台都有硬件Cache一致性。很多嵌入式RISC-V核为了省面积是不做硬件一致性的,这时候软件必须显式维护。判断方法:看手册里有没有提到coherent interconnect或者snoop filter,没有的话基本就是软件维护。

3.4 CMO:Cache管理指令的正确打开方式

CMO是RISC-V里用来管理Cache的指令集合,主要包括:

  • cbo.clean:把Cache行写回内存并保持干净。
  • cbo.flush:把Cache行写回内存并无效化。
  • cbo.inval:无效化Cache行,不写回。
  • cbo.zero:把Cache行清零(某些实现支持)。

这些指令的操作对象是Cache行,所以使用时要按行大小对齐。典型使用场景:

场景一:DMA发送数据前。CPU准备好数据在Cache里,DMA要读内存。这时候需要对数据区域做cbo.clean或cbo.flush,确保数据写回内存。

场景二:DMA接收数据后。DMA把数据写到内存,CPU要读。这时候Cache里可能有旧数据,需要cbo.inval把对应行无效化,强制CPU从内存重新读。

场景三:指令Cache维护。修改了代码之后要执行新代码,需要对指令Cache做fence.i配合CMO。

# 示例:对地址a0开始的一段区域做clean操作 # 假设Cache行大小64字节 li t0, 64 add t1, a0, a1 # a1是长度,t1是结束地址 1: cbo.clean (a0) add a0, a0, t0 blt a0, t1, 1b fence

提示:CMO指令的执行可能需要特权级支持,U模式下通常不能直接执行。另外,CMO的性能开销不小,能批量做就不要逐行做,能少做就不要多做。我见过有人每次DMA传输都对整个Cache做flush,性能直接腰斩。

一个实操心得:把CMO操作封装成函数,传入起始地址和长度,内部按行大小循环。这样调用方不用关心行大小,也方便后续换平台时统一修改。同时在这些函数里加上fence,保证CMO的效果对其他hart可见。

3.5 MMU:虚拟地址翻译的细节与陷阱

MMU负责虚拟地址到物理地址的翻译,RISC-V的MMU规范叫Sv39/Sv48/Sv57,数字表示虚拟地址位数。以Sv39为例,虚拟地址39位,物理地址56位,页表三级。

页表项(PTE)里除了物理页号,还有权限位:V(有效)、R(读)、W(写)、X(执行)、U(用户态可访问)、G(全局)、A(已访问)、D(已脏)。这些位的组合决定了访问是否合法。

MMU配置的关键步骤:

  1. 设置satp寄存器,写入页表基址和模式(比如Sv39)。
  2. 执行sfence.vma刷新TLB。
  3. 确保页表本身在PMA里是可缓存的,否则性能会很差。
# 设置satp,启用Sv39 # 假设页表基址在0x80000000,MODE=8表示Sv39 li t0, 0x8000000000000000 # MODE=8 << 60 li t1, 0x80000000 >> 12 # PPN or t0, t0, t1 csrw satp, t0 sfence.vma

MMU的常见陷阱:

  • A/D位不更新:有些实现不自动更新Access和Dirty位,需要软件模拟,这会显著影响性能。如果你的平台有这个问题,考虑在页表项里预先设置A/D位。
  • TLB一致性:修改页表后必须执行sfence.vma,否则TLB里可能还是旧映射。多核场景下,一个核改了页表,其他核的TLB也要刷新,这通常需要通过IPI(核间中断)来触发。
  • 大页使用:Sv39支持2MB和1GB大页,合理使用能减少TLB miss。但大页的权限粒度粗,用错了会带来安全问题。

注意:MMU开启后,所有地址都变成虚拟地址,包括访问外设。所以外设寄存器区也需要在页表里建立映射,通常映射成不可缓存。忘了映射外设会导致访问直接page fault。

3.6 RVWMO:弱内存序下的编程心智模型

RVWMO是RISC-V的弱内存序模型。简单说,硬件可以为了性能对内存访问做重排,只要不违反规定的顺序约束。这跟x86的强内存序不同,写RISC-V多线程代码时不能想当然。

RVWMO的核心规则可以概括为几条:

  • 同一地址的访问保持程序顺序。
  • 带有acquire/release语义的指令会建立顺序约束。
  • fence指令可以显式指定顺序要求。
  • 原子指令(AMO)有特定的顺序语义。

实际编程中,最常用的是fence rw,rw(全屏障)和fence.i(指令屏障)。在实现锁、无锁队列、生产者消费者模式时,必须正确使用这些屏障。

// 示例:用RVWMO的release-acquire实现一个简单的标志位同步 // 生产者 data = 42; __atomic_store_n(&flag, 1, __ATOMIC_RELEASE); // 消费者 while (__atomic_load_n(&flag, __ATOMIC_ACQUIRE) == 0) {} assert(data == 42);

这里__ATOMIC_RELEASE和__ATOMIC_ACQUIRE会编译成带acquire/release语义的指令或者配合fence,保证data的写入在flag置1之前对其他hart可见。

提示:RVWMO的形式化模型(RVWMO axiomatic model)定义了允许和禁止的行为集合。如果你在做形式化验证或者写复杂的无锁算法,建议直接参考规范里的形式化定义,而不是靠直觉。我见过太多“看起来对”的代码在弱内存序下出问题。

一个实用建议:在RISC-V上写多线程代码,优先使用C11/C++11的原子操作和内存序,让编译器和运行时库去处理底层屏障。只有在性能极度敏感或者需要精细控制时才手写fence。另外,用工具做验证,比如用herd工具跑RVWMO模型检查你的代码模式,能提前发现很多问题。

4. 实操过程与核心环节实现

4.1 从零配置一个带MMU和Cache的RISC-V平台

假设我们要在一个支持Sv39和CMO的RISC-V平台上从裸机开始配置内存系统,目标是能跑一个简单的多任务程序。步骤如下:

第一步:确认硬件能力。读misa寄存器看支持的扩展,读设备树或硬件手册确认PMA区间、Cache行大小、是否支持CMO和Sv39。

第二步:配置PMA。根据硬件手册,把地址空间分段。典型的分段:

地址范围类型可缓存可执行备注
0x80000000-0x8FFFFFFFDRAM是是主内存
0x10000000-0x1000FFFF外设否否UART等
0x0-0x0FFFFFFFBoot ROM是是启动代码

第三步:配置ePMP。给M模式全权限,给S模式配置内核区域权限,给U模式配置用户区域权限。锁定关键区域。

第四步:建立页表。分配页表页,建立恒等映射(虚拟地址等于物理地址)作为初始映射,然后逐步切换到更精细的映射。外设区映射为不可缓存。

第五步:启用MMU。写satp,执行sfence.vma。注意启用MMU的那条指令本身需要在恒等映射的区域里,否则会取指失败。

第六步:配置Cache和CMO。确认Cache行大小,实现CMO封装函数。在DMA驱动里调用这些函数。

第七步:验证。写测试程序:多核读写共享内存、DMA传输、修改代码后执行,确认行为符合预期。

4.2 参数计算实例:PMP地址编码和页表项构造

PMP的pmpaddr寄存器存的是物理地址右移2位。假设要保护从0x80000000开始、大小4KB的区域,使用NA4模式:

  • 地址右移2位:0x80000000 >> 2 = 0x20000000
  • NA4模式下,pmpaddr直接写这个值,匹配单个4字节?不对,NA4匹配的是4字节,要匹配4KB需要用NAPOT。

NAPOT模式下,要匹配2的幂次大小且对齐的区域。4KB = 2^12,地址低12位为0。NAPOT编码要求地址低(12-2)=10位全1,再往上的位是地址高位。具体计算:

  • 4KB区域,NAPOT编码的pmpaddr = (base >> 2) | ((size/4 - 1) >> 1)?这里需要仔细按规范算。

实际上更简单的做法是用TOR模式:设置pmpaddr0为起始地址>>2,pmpaddr1为结束地址>>2,配置项选TOR。这样不容易算错。

页表项构造:Sv39的PTE是64位,PPN占位44-53(共10位,对应物理地址的12-21位?不对,Sv39的PPN是44位,分三段)。构造PTE时:

// 构造一个指向物理地址pa的叶页表项,权限R+W,4KB页 #define PTE_V (1UL << 0) #define PTE_R (1UL << 1) #define PTE_W (1UL << 2) #define PTE_X (1UL << 3) #define PTE_U (1UL << 4) #define PTE_A (1UL << 6) #define PTE_D (1UL << 7) uint64_t pte = ((pa >> 12) << 10) | PTE_V | PTE_R | PTE_W | PTE_A | PTE_D;

这里(pa >> 12) << 10是因为PPN在PTE的位10开始。这个计算容易错,建议封装成函数并写单元测试。

4.3 多核场景下的CMO和fence配合实录

在一个四核RISC-V平台上,我实现了一个核间通信的环形缓冲区。生产者核写数据到缓冲区,然后更新写指针;消费者核读数据,更新读指针。缓冲区内存是可缓存的。

关键代码模式:

// 生产者 buffer[widx] = data; __atomic_thread_fence(__ATOMIC_RELEASE); // 保证数据写入在指针更新前可见 __atomic_store_n(&wptr, widx + 1, __ATOMIC_RELAXED); // 消费者 while (__atomic_load_n(&wptr, __ATOMIC_ACQUIRE) == rptr) {} data = buffer[rptr]; __atomic_store_n(&rptr, rptr + 1, __ATOMIC_RELEASE);

这里没有直接用CMO,因为缓冲区是可缓存的,且平台有硬件一致性。如果平台没有硬件一致性,就需要在更新指针前后加CMO:

// 无硬件一致性时的生产者 buffer[widx] = data; cmo_flush(&buffer[widx], sizeof(data)); // 写回数据 __atomic_thread_fence(__ATOMIC_RELEASE); __atomic_store_n(&wptr, widx + 1, __ATOMIC_RELAXED); cmo_flush(&wptr, sizeof(wptr)); // 写回指针

注意:CMO和fence的顺序很重要。先flush数据,再fence,再更新指针,再flush指针。顺序错了可能导致消费者看到新指针但读到旧数据。

实测下来,有硬件一致性的平台性能明显更好,因为省掉了大量CMO。但即使有硬件一致性,fence还是不能省,因为硬件一致性保证的是Cache行级别的一致性,不保证不同地址之间的顺序。

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

5.1 内存架构问题速查表

现象可能原因排查方向解决方法
访问外设寄存器数据不对PMA属性配错确认地址落在哪个PMA区间修正PMA配置,外设区设为不可缓存
多核数据不同步Cache一致性或fence缺失检查是否有硬件一致性,检查fence使用加CMO或fence,或启用硬件一致性
DMA读到旧数据Cache未写回检查DMA前是否做了clean/flush在DMA启动前对数据区做cbo.clean
CPU读到DMA旧数据Cache未无效化检查DMA完成后是否做了inval在DMA完成后对数据区做cbo.inval
修改代码后执行异常指令Cache未同步检查是否执行了fence.i和CMO修改代码后执行fence.i,必要时对I-Cache做inval
page fault频繁页表映射缺失或权限不对检查页表项和satp补全映射,修正权限位
性能突然下降CMO过度使用或TLB miss高检查CMO调用频率和页表大小减少不必要的CMO,使用大页
多核TLB不一致页表修改后未刷新其他核TLB检查sfence.vma和IPI修改页表后通过IPI触发其他核sfence.vma

5.2 几个我踩过的坑和独家技巧

坑一:以为所有RISC-V都有硬件Cache一致性。早期做一个双核项目,核间共享内存通信,代码在模拟器上跑得好好的,上板子就出问题。后来查手册发现这个核根本没有硬件一致性,必须软件维护。教训:上板前一定确认硬件一致性能力,别靠模拟器。

技巧:用fence.i的时机。fence.i只保证当前核的指令流能看到之前的存储操作,不保证其他核。多核场景下修改共享代码,需要先让其他核停止执行该区域,修改后再让它们继续。这个在实现JIT或者动态加载时特别重要。

坑二:CMO操作没有按行对齐。有一次对一段非对齐地址做cbo.clean,结果部分数据没写回。CMO指令要求地址按Cache行对齐,不对齐的行为是实现定义的,可能只操作包含该地址的行,也可能触发异常。后来改成先对齐再操作,问题解决。

技巧:批量CMO的优化。如果要对一大段内存做CMO,逐行做开销很大。可以先用cbo.zero或者利用硬件的预取机制。另外,如果知道某段内存不会被缓存(比如刚分配还没访问过),可以跳过CMO。

坑三:RVWMO下想当然的代码。写了一个无锁队列,在x86上测没问题,移植到RISC-V就偶发失败。原因是x86的强内存序掩盖了缺少fence的问题。后来用C11原子操作重写,并加了__ATOMIC_ACQ_REL,问题消失。教训:在弱内存序平台上,任何跨线程共享数据都要显式处理内存序。

技巧:用形式化工具验证。对于复杂的无锁算法,可以用herd7工具配合RVWMO模型跑一下,看看有没有不允许的执行。虽然不能替代测试,但能发现一些微妙的顺序问题。

5.3 调试工具和方法

  • 读CSR:csrr读misa、mstatus、satp、pmpcfg等,确认当前配置。
  • 用模拟器:QEMU支持RISC-V,可以开-d int看异常,-d mmu看地址翻译。但模拟器的内存模型可能和硬件不同,只能参考。
  • 逻辑分析仪:如果条件允许,抓总线信号看实际的内存访问序列,对排查PMA和Cache问题很有帮助。
  • 性能计数器:很多RISC-V核有mhpmcounter,可以统计Cache miss、TLB miss等,用perf工具读取。

提示:调试内存问题时,先缩小范围。比如先确认单核单线程下行为是否正确,再加多核,再加DMA,再加MMU。每加一层就测一次,问题定位会快很多。

6. 写在最后:一些个人体会

这套内存架构的东西,刚接触时确实容易晕。我的经验是别想着一次全搞懂,而是按需深入。先搞清楚PMA和ePMP,保证裸机程序能正确访问内存和外设;然后搞Cache和CMO,解决DMA和多核的数据可见性;最后搞MMU和RVWMO,支撑操作系统和复杂并发。每一步都有明确的场景驱动,学起来不枯燥。

另外,手册和规范是必读的,但别指望读一遍就懂。我的做法是先读一遍有个印象,然后遇到具体问题时回去查对应章节,配合实际代码和调试器验证。这样反复几次,那些概念就真正变成自己的了。

最后分享一个小技巧:维护一个自己的“内存问题排查笔记”,每次遇到问题就记下现象、原因、解决方法。积累多了,你会发现很多问题其实是同一类根因的不同表现,排查速度会越来越快。这个习惯我在多个平台上都受益过,推荐你也试试。

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

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

立即咨询