1. 从一个真实场景说起:为什么需要关心内存属性
几年前我第一次在 RISC-V 平台上调试一块以太网控制器,DMA 描述符写进去之后,网卡死活收不到包。用调试器读内存,数据明明写对了,但设备侧看到的却是旧值。折腾了大半天才反应过来——这段内存被标记成了可缓存(Cacheable),CPU 的写入还躺在缓存里没落到物理内存,DMA 引擎自然读不到。把这段区域改成非缓存属性之后,问题立刻消失。
这个坑在 RISC-V 开发里非常典型。x86 和 ARM 上我们习惯了用 MTRR、PAT 或者页表属性来管理内存类型,到了 RISC-V,这套机制换了个名字,叫PMA(Physical Memory Attributes,物理内存属性)和PBMT(Page-Based Memory Types,基于页的内存类型)。前者是平台层面的物理地址属性,后者是 RISC-V 特权架构里通过 Svpbmt 扩展提供的页级控制手段。两者配合,才能把"哪段内存能缓存、哪段必须直写、哪段可以合并写"这件事说清楚。
这篇内容适合谁看?如果你正在做 RISC-V 的裸机开发、BSP 移植、驱动编写,或者单纯想搞明白 Linux 内核里那些dma_alloc_coherent、ioremap背后到底发生了什么,那这篇东西应该能帮你少走不少弯路。我会从 PMA 的基础概念讲起,把 PBMT 的编码格式拆开揉碎,再落到非缓存访问路径的实际实现上,中间穿插我自己踩过的坑和实测有效的排查手法。
需要提前说明的是,Svpbmt 是 RISC-V 特权规范里的一个可选扩展,不是所有核都实现了。你在动手之前,第一件事应该是翻你手上那颗芯片的文档,确认它到底支不支持。这个后面会详细讲怎么查。
2. PMA 基础:物理内存属性的底层逻辑
2.1 PMA 到底是什么,为什么它不是页表能管的事
要理解 PMA,得先建立一个认知:内存属性这件事,在 RISC-V 里被拆成了两个层次。一个是物理地址层面的,跟地址本身绑定,不管你怎么映射页表,这段物理地址的属性是固定的;另一个是虚拟地址层面的,通过页表项来指定,可以动态变化。
PMA 属于前者。它描述的是"物理地址空间里某一段区域,硬件允许你用什么方式访问"。这个属性由平台(SoC 设计者)决定,通常写死在硬件里,或者通过一些平台特定的配置寄存器来设定。CPU 发出一个物理地址访问请求时,总线或者内存控制器会根据这个地址落在哪个 PMA 区域,来决定这次访问能不能缓存、能不能乱序、能不能合并。
为什么不让页表全权负责?因为有些访问根本不经过页表。比如机器模式(M-mode)下的代码,默认是不开 MMU 的,它发出的就是物理地址。再比如 DMA 引擎,它看到的是物理地址,压根不知道页表的存在。所以必须有一个不依赖页表的机制来约束物理地址的访问行为,这就是 PMA 存在的意义。
我习惯用一个类比来解释:PMA 像是小区大门的门禁规则,规定了这个小区里哪些楼栋允许访客进入、哪些必须登记、哪些禁止通行;而页表更像是每户人家自己的门锁,管的是具体哪个虚拟地址映射到哪个物理地址。门禁规则是物业(平台)定的,你住户改不了;门锁是你自己可以换的。两者各管一层,缺一不可。
2.2 PMA 的典型属性有哪些
不同平台的 PMA 实现细节不一样,但核心属性大同小异。下面这张表是我根据常见 RISC-V 平台整理出来的,实际芯片可能还有额外属性:
| 属性 | 含义 | 典型用途 |
|---|---|---|
| Cacheable | 允许缓存,读写可经过 Cache | 普通 DRAM 主存 |
| Non-cacheable | 禁止缓存,每次访问直达设备 | 外设寄存器、DMA 缓冲区 |
| Read-only | 只允许读 | ROM、Boot ROM |
| Write-only | 只允许写 | 某些 FIFO 端口 |
| Idempotent | 重复读返回相同值,无副作用 | 判断能否做投机访问 |
| Coherent | 与其它主设备保持缓存一致 | 多核共享区域 |
| AMO 支持 | 是否允许原子内存操作 | 同步原语所在区域 |
| Execute | 是否允许取指 | 代码段 |
这里有个容易混淆的点:Non-cacheable 不等于 Strongly Ordered。很多新手以为只要标成非缓存就万事大吉,其实非缓存还分好几种强弱程度。比如 ARM 里有 Device-nGnRnE、Device-nGnRE 这些细分,RISC-V 的 PMA 虽然没有完全对应的命名,但通过 IO 属性、顺序性约束等组合,也能表达类似语义。这个区别在写驱动时很关键,后面讲非缓存访问路径时会展开。
2.3 怎么查你手上芯片的 PMA 配置
这是实操层面最要紧的一步。PMA 不像页表那样有统一的遍历方式,它高度依赖具体实现。我一般按这个顺序去找:
先翻芯片的数据手册(Datasheet)和参考手册(Reference Manual),找 "Memory Map" 章节。正规的芯片手册会有一张物理地址映射表,标注每段区域的属性。如果手册里只给了地址范围没给属性,那就得往下走。
看设备树(Device Tree)。Linux 下很多平台的 PMA 信息会体现在设备树的
ranges、reg属性以及一些平台特定的节点里。比如有些 SoC 会用memory-region或者自定义的 compatible 来描述保留区域和它的属性。查核的微架构手册。PMA 的检查逻辑通常在 CPU 核或者总线互连(Interconnect)里实现。如果你用的是 SiFive、Andes、平头哥这类厂商的核,它们的核手册里会有 PMA checker 的描述。
实在找不到就实测。写一段代码往目标地址写值,然后从另一个主设备(比如 DMA)读回来,看能不能读到最新值。读不到说明有缓存;能读到说明至少对这条路径是非缓存的。这个方法土,但管用。
注意:不要想当然地认为"外设地址段一定是非缓存的"。我见过一些平台,外设区域默认是 cacheable 的,需要软件显式配置某些寄存器才能改成非缓存。如果你直接按经验办事,很可能踩坑。
3. Svpbmt 与 PBMT 编码:页表级的属性控制
3.1 Svpbmt 扩展解决了什么问题
前面说 PMA 是物理地址层面的,改不了。那问题来了:如果我想让同一段物理内存,在某些场景下走缓存、在另一些场景下不走缓存,怎么办?PMA 做不到,因为它跟物理地址绑定死了。
这时候就需要Svpbmt。它是 RISC-V 特权规范里的一个扩展,全称是 "Sv39/Sv48/Sv57 Page-Based Memory Types",允许在页表项(PTE)里编码内存类型。这样一来,同一段物理内存,通过不同的虚拟地址映射,可以有不同的缓存行为。这跟 ARM 的页表属性、x86 的 PAT 是同一个思路。
Svpbmt 的核心价值在于灵活性。举几个实际场景:
- 驱动里用
ioremap映射一段外设寄存器,希望它是非缓存的;同时内核的直接映射(linear mapping)里这段物理地址可能是缓存的。有了 Svpbmt,两种映射可以共存,各走各的属性。 - 用户态做高性能计算,想把某块缓冲区标成 write-combining,减少写内存的开销。
- 调试时临时把某段内存改成非缓存,方便观察 DMA 行为。
没有 Svpbmt 的话,这些需求要么做不到,要么得靠平台特定的 hack。
3.2 PBMT 的编码格式详解
Svpbmt 在 PTE 里占用了两个位,通常记作PBMT[1:0]。这两个位的编码含义如下:
| PBMT 值 | 名称 | 含义 |
|---|---|---|
| 00 | PMA | 使用物理内存属性,即回到 PMA 决定 |
| 01 | NC | Non-cacheable,非缓存 |
| 10 | IO | I/O 类型,通常带更强的顺序性约束 |
| 11 | 保留 | 当前规范保留,遇到应视为错误 |
这个编码设计很巧妙。00是默认值,意味着"我不在页表层面做特殊指定,交给 PMA 去管"。这样保证了向后兼容——不支持 Svpbmt 的软件把这两位当保留位写 0,行为跟以前一致。01和10分别对应两种不同强度的非缓存语义,11留给未来扩展。
具体这两个位在 PTE 里的位置,跟 Sv39/Sv48/Sv57 的 PTE 布局有关。以 Sv39 为例,PTE 是 64 位,其中 bit 0-9 是标志位,bit 10-53 是物理页号,bit 54-60 保留,bit 61-63 是 PBMT 和 N 位。准确地说,PBMT 占的是 bit 62-61,bit 63 是 N(NAPOT)位。这个位置在不同版本的规范里可能有微调,写代码时一定要对着你手上那份规范确认。
提示:如果你在写内核代码,Linux 里对应的宏是
_PAGE_PBMT,定义在arch/riscv/include/asm/pgtable-bits.h里。不同内核版本这个宏的位定义可能不一样,别直接抄网上的代码。
3.3 NC 和 IO 到底差在哪
这是很多人搞不清的地方。既然都是非缓存,为什么还要分两种?区别在于顺序性和副作用的约束强度。
NC(Non-cacheable):不经过缓存,但硬件仍然可以对访问做一定程度的优化,比如合并多个写操作、预取读操作。它假设这段内存是"正常的",重复读返回相同值,写操作没有奇怪的副作用。适合 DMA 缓冲区这种场景——你希望它别被缓存,但也不介意硬件做点优化。
IO:约束更强。通常要求访问严格按程序顺序执行,不允许合并、不允许预取、不允许投机。它假设这段内存背后是设备寄存器,读一次可能有副作用(比如读清中断标志),写一次可能触发动作。适合 MMIO 寄存器映射。
用生活化的例子:NC 像是"快递放门口就行,你可以一次拿好几个";IO 像是"每份文件必须当面签收,不能代签、不能攒着一起签"。约束强度不同,代价也不同——IO 更安全但性能更差。
实际选型时,我的经验是:DMA 描述符和缓冲区用 NC,设备寄存器用 IO。如果拿不准,先用 IO,确认没有副作用再降级到 NC。反过来做容易出玄学问题。
4. 非缓存访问路径:从 CPU 到设备的完整链路
4.1 一次非缓存写操作经历了什么
理解非缓存访问路径,最好的办法是跟着一次写操作走一遍。假设 CPU 要往一段标记为 NC 的地址写一个 32 位数据,大致流程是这样的:
- CPU 执行 store 指令,产生虚拟地址。
- MMU 查页表,把虚拟地址翻译成物理地址,同时读出 PTE 里的 PBMT 位。如果 PBMT 是
01或10,这次访问就被标记为非缓存。 - 请求进入 L1 Cache。因为是非缓存访问,L1 直接 bypass,不分配 cache line,也不查 tag。
- 请求进入 L2/L3 Cache。同样 bypass。有些实现里,非缓存访问会走一条独立的旁路通道,避免污染缓存。
- 请求到达总线互连(Interconnect)。这里可能会做地址解码,判断目标是从设备还是主存。
- 请求到达目标设备或内存控制器,完成实际读写。
这条路径上,任何一环出问题都会导致非缓存访问失效。我遇到过最隐蔽的一次,是总线互连里有个配置寄存器,默认把某段地址的访问重新路由到了缓存路径,导致明明 PTE 标了 NC,实际还是走了缓存。这种问题只能靠抓总线波形或者用性能计数器来定位。
4.2 内存屏障:非缓存访问的好搭档
非缓存访问不等于自动有序。这一点必须强调。即使你把内存标成 IO 类型,CPU 和编译器仍然可能重排指令。要保证顺序,必须显式加屏障。
RISC-V 里的屏障指令主要是fence。常用的几种:
# 保证之前的读写都完成后,才执行之后的读写 fence rw, rw # 只保证写顺序 fence w, w # 保证设备 I/O 顺序,配合 IO 属性使用 fence iorw, iorw在 C 代码里,对应的封装是mb()、wmb()、rmb()这些宏。写驱动时,典型的模式是:
writel(value, reg_addr); wmb(); /* 确保写操作真正到达设备 */ writel(next_value, next_reg_addr);这里有个坑:很多人以为非缓存访问就不需要屏障了。错。非缓存只保证"这次访问不经过缓存",不保证"这次访问相对于其它访问的顺序"。CPU 的乱序执行引擎、写缓冲(Write Buffer)、总线上的重排序,都可能打乱顺序。屏障是必须的。
注意:
fence指令本身也有性能开销。不要无脑到处加,只在真正需要顺序保证的地方加。加多了会让性能掉得很难看。
4.3 实测:用 DMA 验证非缓存路径是否生效
光看代码不够,得实测。我常用的验证方法是这样的:
准备两块内存,一块标成 NC,一块保持默认 cacheable。然后:
- CPU 往两块内存各写一个已知值。
- 立刻用 DMA 引擎从这两块内存读数据到另一个地方。
- 比较 DMA 读到的值和 CPU 写的值。
如果 NC 那块 DMA 读到了最新值,cacheable 那块读到旧值(或者不确定),说明非缓存路径生效了。如果两块都读到最新值,可能是 cacheable 那块刚好被刷出去了,多试几次或者加大数据量。如果两块都读到旧值,那说明 NC 没生效,得回去查 PTE 或者 PMA 配置。
这个测试我做过很多次,成功率很高。唯一要注意的是 DMA 引擎本身的缓存一致性能力——有些高端 DMA 是 IO-coherent 的,它自己能 snoop CPU 缓存,这种情况下测试就不准了。得先确认你的 DMA 是不是 coherent 的。
5. 常见问题与排查技巧实录
5.1 问题速查表
下面这张表是我这些年攒下来的,覆盖了大部分非缓存访问相关的疑难杂症:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| DMA 读到旧数据 | 内存被缓存 | 查 PTE 的 PBMT 位、查 PMA 配置 |
| 写寄存器没反应 | 访问被合并或重排 | 加 fence,确认属性是 IO 而非 NC |
| 性能异常低 | 属性过于严格 | 确认是否误用了 IO,尝试降级到 NC |
| 系统随机崩溃 | 缓存一致性问题 | 检查多核间的共享内存属性 |
| PTE 写入后不生效 | TLB 未刷新 | 执行 sfence.vma |
| 非缓存访问仍然很慢 | 总线路由问题 | 抓总线波形,查互连配置 |
5.2 几个我踩过的坑
坑一:忘了刷 TLB。改完 PTE 的 PBMT 位之后,如果不执行sfence.vma,TLB 里可能还缓存着旧的翻译结果,新属性不生效。这个坑我踩过不止一次,尤其是在动态修改内存属性的场景下。记住:改页表必刷 TLB。
坑二:PMA 和 PBMT 冲突。如果 PMA 说这段地址是 cacheable,但 PTE 里 PBMT 标了 NC,最终以谁为准?规范里的说法是 PBMT 优先,但实际实现可能有差异。我遇到过一颗核,它的行为是"取两者中更严格的",也就是只要有一个说非缓存就非缓存。这种实现差异只能靠实测确认。
坑三:编译器优化掉了访问。写驱动时,如果连续写同一个寄存器,编译器可能觉得"反正结果一样"就优化掉一次。解决办法是把寄存器指针声明成volatile,或者用writel这类封装。这个坑在开-O2时特别容易遇到。
坑四:多核下的缓存一致性。非缓存访问绕过了缓存,但如果另一个核通过缓存访问了同一段物理内存,两边看到的数据可能不一致。这种场景下要么全用非缓存,要么用硬件一致性协议,不能混着来。
5.3 独家排查手法:二分法定位属性问题
当你不确定问题出在 PMA 还是 PBMT 还是别的地方时,我推荐用二分法:
第一步,把 PTE 的 PBMT 设成00,完全交给 PMA。如果问题消失,说明是 PBMT 配置错了;如果问题还在,往下走。
第二步,检查 PMA 配置。如果平台允许改 PMA(有些可以通过平台寄存器改),把目标区域改成非缓存试试。如果问题消失,说明是 PMA 的问题;如果还在,往下走。
第三步,怀疑总线或设备。这时候就得上示波器、逻辑分析仪或者总线追踪工具了。这一步成本高,但前面两步能排除掉大部分软件问题。
这个方法帮我省了很多时间。大部分情况下,问题在前两步就能定位。
6. 工具选型与调试环境搭建
6.1 调试工具怎么选
搞 RISC-V 底层开发,工具链的选择很关键。我目前的主力配置是这样的:
- 编译器:官方 GNU 工具链或者 LLVM。两者对 Svpbmt 的支持都还行,但 LLVM 在某些扩展上更新更快。如果你要用到比较新的特性,建议用 LLVM。
- 调试器:OpenOCD + GDB 是标配。如果预算允许,J-Link 对 RISC-V 的支持也不错,速度快、稳定性好。
- 仿真器:QEMU 是首选。它支持 Svpbmt,可以在没有硬件的情况下验证代码逻辑。Spike 也可以,但生态没 QEMU 丰富。
- 总线追踪:如果要做深度调试,需要硬件支持。有些 FPGA 平台可以挂 ILA(Integrated Logic Analyzer),能抓到总线上的实际访问。
6.2 QEMU 下验证 Svpbmt 的配置
QEMU 是个很好的验证环境。配置起来大概是这样:
qemu-system-riscv64 \ -machine virt \ -cpu rv64,svpbmt=true \ -m 2G \ -kernel your_kernel.elf \ -nographic \ -s -S关键是-cpu rv64,svpbmt=true这一项,显式打开 Svpbmt 扩展。不加的话,QEMU 默认可能不启用,你的 PBMT 位写了也没用。
启动之后,可以用 GDB 连上去,在页表相关的代码处下断点,检查 PTE 的实际值。我一般会写个小脚本,把关键 PTE dump 出来,确认 PBMT 位是不是按预期设置的。
6.3 硬件平台上的注意事项
真机上调试比 QEMU 麻烦得多。几个经验:
第一,先确认核支持 Svpbmt。查misa寄存器或者设备树里的riscv,isa属性。如果不支持,别浪费时间。
第二,注意 PMA 的默认配置。有些平台出厂时 PMA 配置得很保守,所有地址都是非缓存的,性能很差。需要软件初始化时改过来。
第三,保留区域的属性要特别小心。比如 Boot ROM、安全区域,这些地方的属性通常是锁死的,改不了。别在这些区域上折腾。
第四,多准备几块板子。底层调试有时候会把板子搞挂,有备用的能省很多事。我就有过把一块开发板的 PMA 配置改错,导致板子起不来的经历,最后只能靠 JTAG 救回来。
7. 性能优化:非缓存访问的代价与取舍
7.1 非缓存访问到底慢多少
这个问题没有标准答案,取决于具体平台。但可以给个量级参考。在我测过的一颗 1.5GHz 的 RISC-V 核上:
- 缓存命中的读:约 1-2 个周期
- 非缓存读(到 DRAM):约 80-120 个周期
- 非缓存读(到外设):约 150-300 个周期
- 非缓存写(带 fence):约 100-200 个周期
也就是说,非缓存访问比缓存命中慢两个数量级。这个代价在频繁访问的场景下是致命的。所以原则是:能用缓存就用缓存,非缓存只用在必须的地方。
7.2 减少非缓存访问开销的几种手法
如果确实需要非缓存访问,有几个优化方向:
批量访问。与其一次读一个字节,不如一次读一整块。很多平台支持非缓存的突发传输(Burst Transfer),能显著提高吞吐。写代码时尽量用memcpy这类批量操作,而不是逐个字节循环。
减少屏障。屏障很贵,能省则省。只在真正需要顺序保证的地方加。比如一段连续的寄存器写,如果它们之间没有依赖关系,可以只在最后加一个屏障。
用 write-combining。如果平台支持,把只写不读的缓冲区标成 write-combining,硬件会攒一批写操作再一起发出去,效率高很多。RISC-V 的 PBMT 编码里没有直接的 WC 类型,但有些平台通过 PMA 或者自定义扩展支持。
考虑 DMA。如果数据量大,与其让 CPU 一个个非缓存访问,不如交给 DMA。CPU 只负责配置 DMA 描述符,实际搬运让 DMA 干。这样 CPU 的开销小,DMA 也能跑满带宽。
7.3 一个实际的优化案例
我之前优化过一个网络驱动的收包路径。原始实现是 CPU 逐个读描述符,每个描述符都要非缓存访问,CPU 占用率很高。改成批量读之后,一次读 16 个描述符,CPU 占用率降了大概 40%。
具体做法是把描述符环设计成连续的,然后用一次memcpy把 16 个描述符拷到本地缓存里,再在缓存里处理。这样只有一次非缓存访问,后续都在缓存里操作。代价是需要保证描述符环的对齐和大小合适,不然memcpy本身也会退化成多次非缓存访问。
这个优化的关键是理解访问模式。如果你的访问是随机的、零散的,批量优化效果有限;如果是顺序的、密集的,批量优化收益很大。动手之前先分析访问模式,别盲目优化。
8. 写在最后的一点个人体会
RISC-V 的内存属性管理这套机制,刚接触时确实容易懵。PMA、PBMT、Svpbmt 这几个概念绕来绕去,文档又分散在不同地方。但一旦理清楚了,会发现它的设计其实很清晰:PMA 管物理层,PBMT 管页表层,两者配合覆盖了所有场景。
我个人在实际操作中的体会是,遇到非缓存相关的问题,先别急着改代码,先确认三件事:核支不支持 Svpbmt、PMA 是怎么配的、PTE 里的位写对没有。这三件事确认完,大部分问题都能定位。剩下的就是总线、设备层面的问题,那得上工具了。
还有一点,别迷信文档。文档说的和硬件实际行为可能有出入,尤其是早期芯片。我遇到过手册说支持 Svpbmt 但实际不支持的,也遇到过手册没提但实际支持的。最终还是要靠实测。手边常备一个能跑的最小测试程序,遇到疑问就跑一遍,比翻半天文档管用。
最后分享一个小技巧:如果你在调一个复杂的驱动,怀疑是内存属性问题,可以先把所有相关内存都标成非缓存,确认功能正常之后,再一块一块改回缓存,看哪块改回去会出问题。这样能快速定位到具体是哪段内存的属性配错了。这个方法笨,但有效,我在好几个项目里都用过。