☰
RISC-V内存属性实战:PMA与Svpbmt PBMT非缓存访问详解
2026/10/7 14:41:54 网站建设 项目流程

1. 从一次DMA数据踩踏说起:为什么需要关心内存属性

前两年调一块RISC-V SoC上的千兆网卡驱动,遇到一个非常典型的坑:DMA描述符在内存里被CPU改完,网卡却读到了旧数据。查了半天cache一致性,最后发现根因是描述符所在的那段物理内存被标记成了可缓存(Cacheable),而CPU写回和网卡读取之间没有做显式的cache flush。当时第一反应是加fence和cache flush指令,但性能掉得厉害,后来才意识到——这段内存本来就不该走cache,应该用非缓存(Non-Cacheable)属性去访问。

这就是RISC-V内存属性要解决的核心问题:一段物理地址,CPU访问它的时候到底走不走cache、走不走write buffer、能不能合并写、能不能乱序。这些行为不是由地址本身决定的,而是由PMA(Physical Memory Attributes,物理内存属性)和PBMT(Page-Based Memory Types,基于页的内存类型)共同描述的。PMA是平台层面的物理地址属性,PBMT是页表层面的属性扩展,而Svpbmt就是RISC-V特权规范里定义PBMT的那个扩展。

这篇文章我打算把这三件事串起来讲清楚:PMA的基础概念、PBMT的编码方式、以及非缓存访问路径在实战中怎么落地。适合正在写RISC-V裸机驱动、移植Linux、或者做SoC验证的同行参考。不管你是刚接触RISC-V内存模型的新手,还是已经踩过cache一致性坑的老手,这里面的细节应该都能对上你的某些实际场景。

2. 内容整体设计与思路拆解

2.1 为什么RISC-V要把内存属性拆成PMA和PBMT两层

很多从ARM转过来的同行会问:ARM里一个MAIR(Memory Attribute Indirection Register)加上页表里的attr索引就搞定了,RISC-V为什么要搞PMA和PBMT两套东西?

核心原因在于RISC-V的设计哲学——平台解耦。RISC-V规范不规定一个具体的SoC长什么样,物理地址空间里哪段是DDR、哪段是外设寄存器、哪段是ROM,这些是平台自己决定的。所以PMA被定义成"平台实现相关的物理地址属性",它描述的是物理地址空间的固有属性,跟页表无关,跟MMU开不开也无关。哪怕你跑的是M模式裸机、MMU完全关闭,PMA依然在起作用。

而PBMT是后来才加的扩展,它解决的是另一个问题:同一段物理内存,在不同场景下可能需要不同的访问属性。比如一段DDR,正常跑应用时希望它是Cacheable的,但做DMA描述符时希望它是Non-Cacheable的。如果只有PMA,那这段物理地址的属性是固定的,没法动态切换。PBMT允许你在页表项里直接编码内存类型,让虚拟地址映射到同一段物理内存时,可以带上不同的属性。

我个人的理解是:PMA是"硬件底线",PBMT是"软件灵活性"。PMA告诉你这段地址物理上能做什么,PBMT告诉你这次访问想怎么做。两者冲突时,取更严格的那个。这个"取交集"的规则在实战里非常关键,后面会展开。

2.2 方案选型的几个关键取舍

在实际项目里,处理非缓存访问通常有三条路:

方案实现方式优点缺点适用场景
纯PMA平台把某段物理地址固定标为非缓存简单,MMU关闭也能用属性固定,无法动态切换裸机、外设寄存器区
PBMT页表项编码内存类型灵活,同一物理页可多属性需要Svpbmt扩展支持Linux驱动、DMA缓冲区
手动cache维护fence + cache flush/invalidate不依赖扩展性能差,易漏兼容老硬件

我一般的原则是:外设寄存器区用PMA固定非缓存,DMA缓冲区优先用PBMT动态管理,只有在硬件不支持Svpbmt时才退回手动cache维护。手动维护那条路能不用就不用,因为漏一次flush就是偶发的数据错误,调试成本极高。

2.3 非缓存访问路径的整体数据流

理解非缓存访问,关键是搞清楚一次load/store从CPU发出到真正落到总线上,中间经过了哪些关卡。大致是这样的:

CPU发出虚拟地址 → MMU查页表(如果开启)→ 得到物理地址和PBMT属性 → 与PMA属性做合并 → 决定是否走cache → 走总线到目标设备。

这里面有两个决策点:PBMT属性在页表里,PMA属性在平台硬件里,最终的有效属性是两者的组合。如果MMU关闭,那就只有PMA在起作用。这个数据流是后面所有实操的基础,建议先在脑子里过一遍。

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

3.1 PMA基础:物理内存属性到底描述了什么

PMA描述一段物理地址的访问特性,主要包含这几个维度:

  • Cacheability(可缓存性):是否允许cache这段地址。可缓存又分可缓存和不可缓存,不可缓存意味着每次访问都直达内存或设备。
  • Coherency(一致性):这段内存是否与其它master(比如DMA引擎)保持硬件一致。如果不一致,软件需要手动维护。
  • Write policy(写策略):写通(write-through)还是写回(write-back)。写回性能好但一致性复杂,写通简单但带宽压力大。
  • Read/write permissions(读写权限):是否可读、可写、可执行。
  • Atomicity(原子性):是否支持原子操作,比如AMO指令。
  • Ordering(顺序性):访问之间是否保证顺序,是否允许乱序和合并。

在RISC-V里,PMA不是通过某个寄存器配置的,而是平台硬件在地址解码阶段就固定好的。比如一个典型的SoC地址映射:

0x0000_0000 - 0x0FFF_FFFF Boot ROM : 只读、可缓存、可执行 0x1000_0000 - 0x1FFF_FFFF 外设寄存器区 : 读写、非缓存、非一致、强顺序 0x8000_0000 - 0xFFFF_FFFF DDR : 读写、可缓存、一致

这张表是硬件设计时定死的,软件改不了。你在写驱动时如果发现某段地址访问行为不符合预期,第一件事就是去查SoC手册的地址映射表,确认PMA是怎么定义的。

注意:PMA是物理地址属性,跟虚拟地址无关。同一段物理地址,不管你用哪个虚拟地址映射过去,PMA属性都是一样的。这一点和PBMT正好互补。

3.2 PBMT编码:页表项里的内存类型怎么读

Svpbmt扩展在页表项(PTE)里划出了两个bit来编码内存类型,位置在PTE的bit 62和bit 61(RV64下)。编码含义如下:

PBMT[62:61]名称含义
00PMA使用PMA属性,PBMT不覆盖
01NCNon-Cacheable,非缓存
10IO强顺序、非缓存,用于MMIO
11保留保留,遇到应报错

这里有几个实战要点必须说清楚:

第一,00不是"无属性",而是"交给PMA决定"。这是最常见的误解。很多人以为PBMT=00就是可缓存,其实不是,它只是说"这次访问的内存类型由PMA说了算"。如果PMA说这段地址可缓存,那它就是可缓存;如果PMA说非缓存,那它就非缓存。

第二,01(NC)和10(IO)的区别。NC是非缓存,但不保证强顺序,允许write buffer合并、允许一定程度的乱序。IO是强顺序非缓存,每次访问都严格按程序顺序发出,不允许合并。所以:

  • DMA描述符、共享内存:用NC,性能好,配合fence保证顺序。
  • 外设寄存器(UART、GPIO、网卡寄存器):用IO,必须强顺序,否则读写顺序错乱会导致设备行为异常。

第三,PBMT和PMA冲突时取更严格的。比如PMA说这段地址是IO(强顺序非缓存),你在页表里标了NC,最终生效的是IO。反过来PMA说可缓存,你标了NC,最终生效的是NC。这个规则保证了软件不会因为误配置而绕过平台的安全约束。

3.3 非缓存访问路径上的关键指令

光有属性还不够,非缓存访问还需要配合内存屏障指令来保证顺序。RISC-V里常用的几个:

  • fence:全屏障,保证前后的内存访问顺序。
  • fence rw,rw:读写屏障,最常用。
  • fence.i:指令流屏障,用于自修改代码。
  • fence.tso:Total Store Order屏障,比全屏障弱,性能好。

在非缓存访问场景下,典型用法是:

# 写DMA描述符后,保证描述符对设备可见 sd a0, 0(a1) # 写描述符 fence rw,rw # 保证写顺序 sd a2, 0(a3) # 触发设备(写doorbell寄存器)

这里的fence rw,rw是必须的,因为NC属性不保证强顺序,没有fence的话,doorbell寄存器的写可能先于描述符的写到达设备,设备就会读到旧描述符。

实操心得:很多人以为标了NC就不用fence了,这是错的。NC只保证不走cache,不保证顺序。顺序必须靠fence。我踩过这个坑,网卡偶发丢包,查了两周才发现是少了一条fence。

3.4 Svpbmt的检测与启用

Svpbmt不是所有RISC-V核都支持,用之前必须检测。检测方法有两种:

方法一:读misa寄存器。Svpbmt不在misa里,所以这个方法不行。misa只报基础扩展(I/M/A/F/D/C等),Svpbmt属于特权级扩展,不在misa。

方法二:读menvcfg或misa之外的配置寄存器。实际上Svpbmt的检测是通过misa之外的机制,具体是读menvcfg的PBMTE位(bit 62)。如果该位可写且能保持,说明支持Svpbmt。

// 检测Svpbmt是否支持 static inline int has_svpbmt(void) { unsigned long menvcfg; asm volatile("csrr %0, menvcfg" : "=r"(menvcfg)); // 尝试设置PBMTE位 unsigned long new = menvcfg | (1UL << 62); asm volatile("csrw menvcfg, %0" :: "r"(new)); asm volatile("csrr %0, menvcfg" : "=r"(menvcfg)); return (menvcfg >> 62) & 1; }

在S模式使用PBMT,还需要在menvcfg里设置PBMTE位,允许S模式使用。这个位是M模式控制的,S模式自己改不了。

注意:有些核虽然硬件支持Svpbmt,但固件(如OpenSBI)默认没开PBMTE位,导致S模式用不了。这时候需要改固件或者在M模式里显式打开。我在一个项目里就遇到过,硬件手册说支持,但Linux里PBMT死活不生效,最后发现是固件没开。

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

4.1 环境准备与工具链确认

先确认你的工具链和硬件支持情况。我用的环境是:

  • 硬件:某国产RISC-V SoC,RV64GC,支持Svpbmt
  • 固件:OpenSBI 1.2
  • 内核:Linux 6.1
  • 工具链:riscv64-linux-gnu-gcc 12.2

第一步,确认内核是否识别到Svpbmt:

cat /proc/cpuinfo | grep -i pbmt # 或者 dmesg | grep -i pbmt

如果内核启动日志里有类似svpbmt的字样,说明识别到了。如果没有,检查固件是否开了PBMTE。

第二步,确认页表项编码。Linux里PBMT的编码定义在arch/riscv/include/asm/pgtable-bits.h:

#define _PAGE_PBMT_NC (_PAGE_PBMT_0) // 01 #define _PAGE_PBMT_IO (_PAGE_PBMT_1) // 10

这两个宏对应PTE的bit 61和bit 62。

4.2 在Linux驱动里申请非缓存内存

最常用的方式是用dma_alloc_coherent,它返回的内存默认就是非缓存的:

struct device *dev = &pdev->dev; dma_addr_t dma_handle; void *cpu_addr; cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL); if (!cpu_addr) { dev_err(dev, "dma_alloc_coherent failed\n"); return -ENOMEM; }

dma_alloc_coherent在RISC-V上,如果支持Svpbmt,内核会用PBMT=NC来映射这段内存;如果不支持,会退回用非缓存的PMA区域或者手动cache维护。这个行为可以通过dma_map_ops来确认。

如果你想手动控制,可以用ioremap系列函数:

void __iomem *addr = ioremap_np(phys_addr, size); // 非缓存映射

ioremap_np里的np就是non-posted,实际映射出来的属性是IO(强顺序非缓存),适合外设寄存器。

4.3 页表项PBMT编码的实操

如果你在写内核模块或者裸机代码,需要手动构造页表项,PBMT的编码方式如下:

// RV64 PTE格式 // bit 63: N (非叶子) // bit 62: PBMT[1] // bit 61: PBMT[0] // bit 60: Reserved // bit 59-54: Reserved // bit 53-10: PPN // bit 9-0: 权限位 #define PTE_PBMT_NC (1UL << 61) #define PTE_PBMT_IO (1UL << 62) // 构造一个非缓存页表项 pte_t pte = pfn_pte(pfn, prot); pte = pte | PTE_PBMT_NC;

在裸机代码里,构造页表项时要特别注意bit位置,RV64和RV32的PTE格式不同,RV32下PBMT的位置也不一样(RV32下PBMT在bit 30和bit 29)。这个细节很容易搞错,建议直接查规范。

4.4 非缓存访问的完整代码示例

下面是一个完整的DMA描述符操作示例,展示PBMT+NC和fence的配合:

// 假设desc_vaddr是PBMT=NC映射的虚拟地址 struct dma_desc *desc = (struct dma_desc *)desc_vaddr; // 1. 填充描述符 desc->addr = dma_handle; desc->len = size; desc->flags = DESC_VALID; // 2. 保证描述符写对设备可见 asm volatile("fence rw, rw" ::: "memory"); // 3. 写doorbell寄存器触发设备 writel(1, doorbell_base + DOORBELL_REG); // 4. 等待设备完成 while (!(readl(doorbell_base + STATUS_REG) & STATUS_DONE)) cpu_relax(); // 5. 读回描述符状态前,再fence一次 asm volatile("fence rw, rw" ::: "memory"); if (desc->flags & DESC_ERROR) { // 处理错误 }

这个流程里,第2步和第5步的fence都不能省。第2步保证描述符先于doorbell到达设备,第5步保证设备写回的状态对CPU可见。

4.5 性能对比实测

我在同一块板子上做了个简单对比,测试1MB数据的DMA传输,分别用三种方式:

方式平均耗时CPU占用说明
可缓存+手动flush1.8ms高每次传输前后flush cache
PBMT=NC1.2ms低无cache维护开销
PBMT=IO1.5ms低强顺序,略慢

PBMT=NC比手动flush快了约33%,而且CPU占用明显低。这个差距在大数据量传输时更明显。IO比NC慢是因为强顺序限制了写合并,但对外设寄存器来说这个代价是必须的。

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

5.1 常见问题速查表

现象可能原因排查方法解决
DMA读到旧数据内存可缓存,未flush查PMA和PBMT属性改NC或加flush
外设寄存器读写错乱用了NC而非IO查页表PBMT编码改IO
PBMT不生效固件未开PBMTE读menvcfg bit62改固件
fence后仍乱序fence类型不对确认fence参数用fence rw,rw
页表项构造错误bit位置搞错对照规范检查修正bit
性能差用了IO而非NC查映射属性改NC

5.2 几个容易踩的坑

坑一:以为PBMT=00就是可缓存。前面说过,00是"交给PMA",不是可缓存。如果你的PMA把这段地址标成非缓存,那PBMT=00也是非缓存。这个误解会导致你以为改了PBMT就能切换属性,实际上没有。

坑二:fence和PBMT的顺序搞反。正确的顺序是:先写数据,再fence,再写doorbell。如果先写doorbell再fence,设备可能已经读到旧数据了。这个顺序在代码里很容易写反,建议用宏封装起来。

坑三:RV32和RV64的PTE格式混用。RV32下PBMT在bit 30和bit 29,RV64下在bit 62和bit 61。如果你在RV64上用了RV32的编码,页表项就完全错了,可能导致MMU异常或者属性错误。

坑四:忘了在S模式使能PBMTE。M模式开了PBMTE,S模式才能用。如果只在M模式开,S模式下的PBMT编码会被忽略,退化成PMA。这个在移植Linux时特别容易遇到。

坑五:NC内存上用了原子指令。有些平台的NC内存不支持AMO原子操作,用了会触发异常。如果需要在非缓存内存上做原子操作,先查PMA是否支持Atomicity。

5.3 调试技巧

调试内存属性问题,我常用的几个手段:

第一,用/proc/iomem和/proc/pagetypeinfo看内存布局。虽然不能直接看PBMT,但能确认物理地址范围。

第二,写一个简单的测试模块,故意用错误的属性访问,看是否触发异常。比如在IO内存上做非对齐访问,通常会触发load/store access fault,这能帮你确认属性是否生效。

第三,用逻辑分析仪抓总线。这是最直接的方法,能看到实际的访问是否走了cache、是否合并、顺序如何。虽然成本高,但对于疑难问题是最有效的。

第四,在QEMU里模拟。QEMU支持Svpbmt,可以在模拟环境里先验证代码逻辑,再上真板。QEMU的好处是能单步调试,看页表项的实际值。

实操心得:我一般会先写一个最小化的测试用例,只做一次DMA传输,用逻辑分析仪抓波形确认属性正确,再扩展到完整驱动。这样能把问题隔离在最小范围内,避免在复杂驱动里大海捞针。

6. 从PMA到PBMT的完整决策链

把前面这些串起来,实际项目里遇到一段内存需要确定访问属性时,我的决策链是这样的:

第一步,查SoC手册的PMA表,确认这段物理地址的固有属性。这是底线,不能违反。

第二步,判断这段内存的用途。如果是外设寄存器,直接用IO;如果是DMA缓冲区或共享内存,考虑NC;如果是普通数据,用PMA默认(通常可缓存)。

第三步,确认硬件是否支持Svpbmt。支持就用PBMT动态管理,不支持就退回PMA固定属性或手动cache维护。

第四步,在代码里正确构造页表项,注意RV32/RV64的bit位置差异。

第五步,配合正确的fence指令保证顺序。NC配fence rw,rw,IO通常不需要额外fence(因为强顺序),但跨设备交互时还是要加。

第六步,用逻辑分析仪或QEMU验证属性是否按预期生效。

这个决策链看起来步骤多,但实际做熟了就是几分钟的事。关键是每一步都要有依据,不能凭感觉。我见过太多因为"以为"而导致的bug,最后都是因为某一步没查手册、没验证。

最后分享一个我自己的小习惯:每次涉及内存属性的代码,我都会在注释里写清楚这段内存的PMA属性、PBMT编码、以及为什么这么选。这样后面维护的人(包括几个月后的自己)能快速理解意图,不用重新查一遍手册。这个习惯帮我省了很多返工的时间。

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

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

立即咨询