
搞RISC-V时间长了你会发现流水线那些东西其实没那么玄真正让人头大的是“内存属性”这四个字。PMA、PBMT、Svpbmt光是缩写就能劝退一批人更别说调DMA时突然出现的诡异行为。我当年第一次调RISC-V核的内存路径同一个物理地址换了一种页表映射之后行为完全变了后来查了半天才发现是PMA和PBMT在背后较劲。这篇就把Svpbmt这套机制完整过一遍从PMA的定位、PBMT的字段编码到一次非缓存访问从页表到总线的完整路径最后附上我实际踩过的一些坑。适合正在做RISC-V核验证、写裸机启动代码、或者被DMA缓存一致性问题折磨的朋友。1. 先说PMA内存属性的物理底盘1.1 先理解一个原则PMA不是软件可以随便改的状态在RISC-V体系里每一个物理地址区间都绑定了一组属性这些属性由平台硬件决定统称为Platform Memory Attributes也就是PMA。比如SoC里DDR控制器对应的物理窗口通常被定义为可读可写可执行、可缓存、地址空间幂等而UART、GPIO、中断控制器这些外设寄存器窗口则是不可缓存、非幂等、访问有副作用。这个分类不是某个CSR位能够随手修改的更不是页表能覆盖的它更像一份物理世界的规则表CPU每次访问物理地址硬件后端都会把这条规则拉出来参与判断。这个设计跟x86很不一样。x86开发者习惯性认为“页表改一改属性就都变了”但在RISC-V里PMA是芯片实现层面的基础约束页表属性是在PMA允许的范围内做选择。你可以通过页表让一个“可缓存”区域变成“不缓存”但绝对不可能通过页表让一个本来不可缓存的MMIO区域变成可缓存。认清这个主从关系后面所有问题都好解。我举个例子。假设某颗SoC把0x10000000到0x10001000划给UARTPMA里明确写着non-idempotent且non-cacheable。那么即使页表把这段地址标成CacheableCPU访问它时也不会真正建立缓存行因为PMA在硬件层面已经把缓存通路关掉了。反过来如果某段DDR在PMA里是Cacheable但某块DMA buffer不想被缓存软件就可以通过Svpbmt提供的PBMT字段在页表这一层对单个页面做特殊标注。这就是PMA和页表属性之间的分工PMA划底线PBMT做微调。1.2 PMA里到底有哪些属性PMA不是一个单一比特而是一组属性常见的包括Idempotency幂等性指读访问是否会产生副作用。普通内存是幂等的随便读多少次结果都一样设备寄存器通常不是幂等的读一次可能就触发中断清除或FIFO弹出。Cacheability可缓存性这个区域是否允许分配缓存行。有些区域只能透传不能进L1/L2。Coherency一致性多个CPU、DMA等agent访问同一地址时硬件是否自动保证一致性。对普通共享内存来说这很重要对设备MMIO来说通常不适用。访问类型允许读、允许写、允许执行三者可以自由组合。Atomicity原子性支持支持多大规模的原子操作是否支持AMO指令不同PMA区域可能不一样。这些属性组合起来就是硬件做PMA检查时的判断依据。实际RTL设计里通常是一个地址匹配模块把CPU发出来的物理地址跟一组PMA窗口做比对命中后输出对应的控制信号。比如命中不可缓存窗口就禁止L1分配命中非幂等窗口就禁止写合并和乱序排队。我在做核验证时经常需要查看这类信号最直接的办法是翻开SoC手册找到“Memory Map”章节一般都会列出每个地址窗口的属性和访问权限。如果实在没有文档那就看设备树RISC-V Linux和OpenSBI里已经有很多平台把PMA窗口写进设备树了比如soc { uart10000000 { reg 0x0 0x10000000 0x0 0x1000; ... }; };虽然设备树不直接写PMA属性但很多固件会通过特定compatible或附加属性来描述可缓存性、non-idempotent等信息。总之PMA信息不是靠猜的是平台层面的物理事实。1.3 PMA检查发生在流水线的哪个位置PMA检查的位置挺关键。CPU发出一个访存请求经过TLB、页表属性判断之后还要跟PMA做一次汇合。通常发生在L1 cache miss之后、L2或总线请求发出之前。这个位置决定了缓存层能看到什么属性总线端又能看到什么属性。如果一块区域在PMA里被标记为non-cacheable即使页表给了CacheableL1的分配逻辑也不会给这次访问建立缓存行L2继续往后透传。所以你在总线trace里看到的现象是每次load/store都真实地看到了总线上有事务而不是第一次读完之后后续都在缓存命中。这就是PMA比页表属性更“硬”的体现。所以调试这类问题的时候我建议养成一个习惯先看PMA再看PTE最后再看TLB行为。顺序反了容易浪费时间。2. Svpbmt与PBMT编码给页表项埋两个控制位2.1 为什么有了PMA还不够还要引入Svpbmt想象一个场景某个系统有一块DDR既跑普通代码又有一块DMA ring buffer。普通代码希望缓存命中越高越好但DMA buffer那边CPU和设备同时访问如果还走缓存就需要CPU每次访问之前手动clean访问之后手动invalidate非常麻烦。更好的办法是把这块buffer映射成non-cacheableCPU读直接读DDRDMA写也写DDR两边都不经过缓存一侧少做一堆维护操作。但PMA的定义是面向物理地址段的它只能一刀切要么整块DDR都标可缓存要么整块都标不可缓存。标成可缓存DMA buffer就要受cache一致性折磨标成不可缓存普通代码性能又掉得厉害。两个需求都有道理就需要一个机制让软件在页表层面按页调整内存属性。这就是Svpbmt要做的事。Svpbmt全称Page-Based Memory Types核心思路是在叶子页表项里增加一个字段用来表示这个页面的内存类型提示。这样同一块物理内存通过不同虚拟页面映射时可以带上不同的缓存策略。硬件在地址翻译时读到这个字段把相应的属性传给缓存和总线系统。2.2 PTE里PBMT字段的位置以最常见的RV64Sv39为例一个叶子PTE是64位常用字段大家都很熟悉。最高位bit63是N表示非叶子还是叶子bits[62:61]在Svpbmt扩展下就是PBMT字段bits[60:54]仍然保留bits[53:10]是物理页号PPNbits[9:0]是R/W/X/V这些标志位。RV32下的Sv32页表项只有32位保留位不够所以Svpbmt对RV32不适用。PBMT字段可以写成这样63 62:61 60:54 53:10 9:0 ---------------------------------------------- N PBMT 保留 PPN V/R/W/X这4位虽然叫编码但实际只有两个bit一共四种取值。这里要特别注意PBMT字段是在页表项中出现的不是单独某个CSR。它和PTE里的R/W/X一样属于软件配置内存管理单元的一部分。2.3 PBMT编码与其核心语义四种取值的具体含义如下0b00默认完全按PMA处理。软件不干预这是最常用的取值。0b01NCnon-cacheable。表示这个页面不缓存适用于想要绕开缓存的普通内存。0b10IOnon-cacheable且non-idempotent。适用于外设寄存器、MMIO空间访问不能被合并也不能被重排。0b11保留当前规范下不建议使用写了行为未定义。NC和IO的区别值得细说。NC虽然不缓存但地址空间仍然是幂等的也就是说访问普通内存时合理的写合并和少量乱序还是可以接受的。IO就不一样了它对应的地址空间是非幂等的每一次load/store都可能触发设备副作用所以硬件必须把每个访存请求当成独立事务处理不能合并、不能预取、不能随便重排。举个极端例子如果你把一个UART FIFO地址映射成NC而不是IOCPU可能把两次相邻写入合并成一个半字写结果本来只想写两个字符设备却收到了一个拼接起来的奇怪数据。这类问题在调试时非常隐蔽因为它不是每次都出而且看起来像是外设驱动的问题。2.4 PBMT与PMA的优先级以及一个容易忽略的合法性问题PBMT是对PMA的补充不是覆盖。这里再强调一遍优先级如果PMA说某个区域不可缓存PTE怎么写都不能让它变成可缓存。如果PMA说某个区域可缓存软件可以通过PBMTNC或IO让某页访问不缓存。如果PMA说某个区域非幂等PBMT00和IO都可以正常工作但PBMTNC就可能出问题因为NC的前提是地址幂等。本质上页表属性只能让访问变得“更严格”不能变得更宽松。这一点是很多初学者容易犯迷糊的地方。遇到“为什么我页表已经标了Cacheable可这个地址的访问还是没被缓存”这类问题十有八九是PMA根本不让你缓存而不是页表配错了。另外Svpbmt还有使能条件。就算实现了Svpbmt如果对应模式的使能位没有置1硬件会直接忽略PTE里的PBMT。在不同实现里这个使能位可能在mstatus或sstatus相关位置动手实验前务必查一下具体平台手册确认PBMTE或等价位的配置。我在实机上就碰到过PTE写的都对、但访问属性却没变的情况最后发现就是固件没把使能位打开。3. 非缓存访问路径实战把PTE配下去之后发生了什么3.1 裸机手工创建NC映射下面这段代码以RV64Sv39为例我假设物理地址0x80000000是一段DDR现在想把其中一部分映射成non-cacheable。#define PTE_V (1UL 0) #define PTE_R (1UL 1) #define PTE_W (1UL 2) #define PTE_X (1UL 3) #define PBMT_NC (1UL 61) #define PBMT_IO (2UL 61) uint64_t pa 0x80000000ULL; // 物理地址 uint64_t va 0xFFFF_8000_0000_0000ULL; // 虚拟地址按实际平台映射关系调整 uint64_t ppn pa 12; uint64_t pte (ppn 10) | PTE_V | PTE_R | PTE_W | PTE_X | PBMT_NC; // 把pte写到页表对应位置 *(uint64_t *)page_table_entry_addr(va) pte; // 写完之后必须刷TLB __asm__ volatile(sfence.vma ::: memory);这里最关键的是两个点一个是PBMT_NC必须写进PTE的bit61另一个是写完PTE之后必须执行sfence.vma刷掉旧映射。如果忽略sfence.vma老TLB项可能还在服务新的属性根本不会生效。3.2 一次非缓存访问的完整路径假设现在NC映射已经生效然后CPU执行一次store。整条数据通路大致是这样的CPU发出虚拟地址进入MMU查找TLB。PTE命中同时PBMT信号解析为NC属性。请求进入L1 Cache逻辑由于属性是NCL1不进行常规的tag查找和数据加载直接把请求下推到下一级。到了L2/LLC同样因为NC属性而不分配缓存行。请求会带着“no-allocate”这类透传标记继续往总线走。请求经过互连总线到达内存控制器或外设桥事务最终落到DDR或者设备寄存器。NC和IO在这条链路上的差别主要体现在总线和写合并逻辑。NC仍然被当作幂等内存访问处理所以硬件可以做有限度的写合并相邻字节能合并成一个总线事务这有利于提升性能。IO则要求每个load/store都单独成一个事务绝不能合并。判断PTE是否生效的最直接方法就是在总线侧看有没有分离的小事务。如果连续store看到一个区域总线端出现了很多小粒度写事务说明NC/IO路径已经生效如果先看到一次大的总线回填随后大量命中那说明映射仍是CacheablePTE大概率没写对。我当初调试一颗支持Svpbmt的核时写了一个循环对两段同样大小的buffer做访问一段Cacheable映射一段NC映射。用cycle计数器统计同样的写循环NC比Cacheable慢出好几倍这是符合预期的因为NC每一次写访问都要真实走到DDR。如果两端时间几乎一样就要回头检查PBMT和使能位了。3.3 Linux和OpenSBI里的实际映射路径在实际平台上很少有人会在裸机环境手动构造页表。标准Linux内核里Svpbmt已经被封装好了。比如ioremap()映射设备寄存器时默认走PBMTIO保证MMIO不会被乱合并pgprot_writecombine()则走PBMTNC适合DMA共享缓冲。设备树里也可以预留一块非缓存内存供驱动做DMA buffer使用reserved-memory { #address-cells 2; #size-cells 2; dma_buf: dma-buffer80000000 { compatible shared-dma-pool; reg 0x0 0x80000000 0x0 0x100000; no-map; }; };然后驱动里通过memremap()等接口映射这段内存最终形成的PTE就会带上对应的PBMT属性。这套路径在Linux RISC-V里已经比较成熟不需要驱动自己操作PTE。OpenSBI层面也有PMA相关处理。S-mode访问某些特殊物理地址时OpenSBI会参考平台PMA窗口做检查或者限制。如果你在用标准固件很多PMA信息已经通过设备树或者OpenSBI内部配置表达清楚了动手前多留意固件日志不会吃亏。4. 踩坑记录与排查清单4.1 典型症状与对应根因症状可能原因建议排查方向映射了NC但总线trace仍看到缓存回填PBMTE未使能或PTE的bit61/62没写对打印PTE原始值检查使能位访问MMIO寄存器出现多字节合并写用了NC而不是IO改成PBMT_IODMA数据偶发不一致CPU侧Cacheable映射与DMA侧没有同步改用NC映射或维护cache一致性API读外设寄存器总是0xFF或0x1F物理地址PMA窗口不匹配访问被丢弃或mask核对SoC Memory MapNC访问性能比预期慢很多这是正常现象NC每次都要走总线用性能计数器确认是否真的走NC路径4.2 排查方法实录如果怀疑PBMT没写成我习惯三步走打印PTE原始64位值确认bit61和bit62的编码和预期一致。这一步能筛掉大部分低级错误比如左移位数算错、物理页号算错。检查SATP模式和对应的PBMTE使能位。不要想当然认为实现一定支持Svpbmt很多简化核或者教学核根本没实现这个扩展。执行sfence.vma后再次观察TLB行为。还可以用性能计数器对比缓存命中率Cacheable映射和NC映射的缓存行分配行为差异非常明显。如果问题出在PMA上就需要查平台文档或RTL。PMA错误比PTE错误更隐蔽因为CPU访问时一般不会报异常只会默默按错误属性走完流程最后表现成数据错、性能异常或者总线事务形态不对。在仿真环境里可以通过波形观察PMA匹配信号看它命中的是哪个窗口属性输出是什么。这比实机上靠猜高效得多。4.3 几个实打实的经验这里分享三个我实际碰过的教训MMIO一定用IO不要图省事用NC。NC虽然不缓存但仍然允许写合并和宽松排序。这不是设备寄存器想要的。我调一个外设状态寄存器时写完配置马上读状态逻辑上应该能读到新值但读到的总是旧值查了半天发现就是NC映射把两次操作合并了最终改成IO才恢复正常。NC不解决多核缓存一致性问题。NC只表示“CPU侧不走缓存”不表示“系统帮你做了跨核同步”。两个核同时读同一个NC变量仍然需要原子指令和fence来保证顺序。很多DMA一致性问题的根因并不在缓存属性而是在并发模型没做对。核验证阶段就写好PMA自查的脚本。我在仿真环境里写过一个简单函数遍历全部虚拟地址映射表逐一比对PTE里的PBMT编码和PMA窗口是否冲突。这个脚本虽然很笨但在大量RTL修改之后跑一遍能提前发现很多基础配置错误比每次靠波形盯半天省力得多。最后再分享一个快速验证技巧在裸机上准备两块同样大小的buffer一段Cacheable映射一段NC映射各做足够多次数的写循环统计cycle数。正常来说NC路径比Cacheable路径慢出一个数量级如果两边差不多那PBMT十有八九没生效。这个方法不需要高级调试工具一条UART打印就能搞定特别适合拿来快速判断平台是否真的把Svpbmt整个链路拉通了。RISC-V这套内存属性机制的核心就一句话PMA是物理底线PBMT是页表微调。把这张图刻在脑子里非缓存访问就再也不会让你困惑了。