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] | 名称 | 含义 |
|---|---|---|
| 00 | PMA | 使用PMA属性,PBMT不覆盖 |
| 01 | NC | Non-Cacheable,非缓存 |
| 10 | IO | 强顺序、非缓存,用于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占用 | 说明 |
|---|---|---|---|
| 可缓存+手动flush | 1.8ms | 高 | 每次传输前后flush cache |
| PBMT=NC | 1.2ms | 低 | 无cache维护开销 |
| PBMT=IO | 1.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编码、以及为什么这么选。这样后面维护的人(包括几个月后的自己)能快速理解意图,不用重新查一遍手册。这个习惯帮我省了很多返工的时间。