PCIe posted write与AXI deferrable写属性映射 📅 发布时间:2026/9/18 15:13:19 👁 浏览次数: 调试一块 PCIe 加速卡的时候最让人怀疑人生的不是寄存器读不对而是我明明写了读回来却是旧值。代码里先填好描述符、再敲一下门铃寄存器逻辑上严丝合缝可设备那边看到的描述符就是残的。折腾两天之后往往会发现问题既不在驱动也不在设备固件而是藏在 PCIe 的 posted write 语义和 AMBA AXI 的写属性之间——这道缝的名字就叫deferrable write可延迟写。这篇文章不打算照着手册念一遍协议而是把这套东西拆成三个问题PCIe 侧一条 Memory Write 从发出到落地中间到底被谁攒过AMBA 侧的 AxCACHE 属性是怎么决定这个写能不能晚点交的以及当两套语义通过一个桥对接时哪一种配置会出问题、出什么样的问题、怎么查。做 PCIe 驱动、FPGA 加速卡、SoC 里带 PCIe Root Complex 的同学看完应该能直接对上自己手上那块板子的现象。1. 一条 PCIe 写为什么会在 AMBA 侧卡住1.1 从一次描述符残写说起先还原一个非常典型的现场。主机侧驱动往一段共享内存里填 DMA 描述符填完之后写设备的 doorbell 寄存器通知设备来取。设备取回来的描述符里length字段是对的addr字段却还是上一轮的旧值。单步调试看不出任何问题因为在 CPU 的视角里那两行赋值语句确实都执行完了编译器和 CPU 都没有乱序。问题出在赋值语句执行完之后。现代 SoC 里CPU 的一次 store 指令要经过 L1、L2、互连NoC、内存控制器最后才落到 DDR。如果中间任何一级把它放进了写缓冲write buffer那么指令执行完和数据可见就是两件完全不同的事。设备通过 PCIe 总线去读那段内存走的是另一条路径——它绕过了 CPU 的 cache直接打内存控制器。两条路径的时间尺度差着数量级于是设备读到旧值。有意思的是这种写还在路上的现象在 PCIe 和 AMBA 两边都有对应的机制而且两边的处理方式完全不一样。PCIe 那边叫posted writeAMBA 那边叫bufferable / deferrable write。名字不同、动机不同、边界条件也不同但它们在同一个桥里碰面时必须有人负责把语义对齐。对齐得不好就是上面这种残写对齐得好你连它的存在都感觉不到。1.2 posted write 与 AXI 写响应两套承诺PCIe 的 Memory Write 是posted的——主设备发出去就不用管了不会收到任何完成包Completion。这种设计是为了让总线跑满如果每个内存写都要等一个往返确认那 PCIe 的吞吐基本就废了。代价是主设备完全不知道这个写什么时候真正落到目标内存甚至不知道它有没有落地。AMBA AXI 则是一个严格的请求-响应模型。写地址通道AW和写数据通道W发出去之后从设备必须通过写响应通道B回一个BRESP。从协议字面上看每个写都有明确的完成时刻。但 AXI 又留了一个后门对于 Normal memory 的写如果属性允许互连可以在数据进入写缓冲的那一刻就提前把BRESP返回给主设备而不等它真正写进 DDR。这个提前响应就是两边能对接的基础。所以当一条 PCIe Memory Write 进到一个 PCIe-to-AXI 的桥里时桥要做的事情是把没有完成包的 PCIe 写翻译成一个带 B 响应的 AXI 写然后立刻伪造一个 B 响应返回给上游。听起来很取巧但它在 AXI 的规则里是合法的——前提是属性位配对。配错了就是第 5 节要讲的几类故障。2. AMBA 侧的 deferrable write延迟的到底是什么2.1 AxCACHE 里的那几位属性谁在决定能不能晚点交AXI 用写地址通道上的AWCACHE读通道上是ARCACHE这 4 位来描述一笔事务的内存属性。这 4 位里包含几个维度的信息这块地址是 Device 还是 Normal memory、是 Write-Back 还是 Write-Through、是否允许 Read-Allocate、是否允许 Write-Allocate、是否 Bufferable。其中和本文直接相关的是Write-Allocate这一档语义。一笔写事务如果被标成 Write-Allocate就意味着主设备允许下游把它攒起来互连可以先把这批写数据扣在写缓冲里等同一个 cache line 上的其他写凑齐或者等总线空闲再一次性合并成一个完整的 line 写进 DDR。这种允许被推迟提交、允许被合并的写就是所谓的deferrable write。需要说明的是AXI 各个版本手册里对这几位属性的命名和编码有细微差别ACE、CHI 里更是换成了完全另一套事务类型比如 CHI 的 WriteUnique、WriteNoSnp 系列。但这笔写可以被推迟提交这个核心语义是一致的后面讨论的现象也不受版本差异影响。真正要记住的是能不能延迟是由属性位说了算不是由总线协议类型说了算。把 ARM 架构里的内存类型和 AXI 属性对上能得到一张很实用的对照表ARM 内存类型典型 AxCACHE允许缓冲允许合并/延迟Device-nGnRnE0b0000否否Device-nGnRE / Device-GRE0b0001是否Normal Non-cacheable0b0010 / 0b00110b0011 允许否Normal Write-Back Write-Allocate0b1111是是最后一档才是真正的 deferrable write。前两档是设备寄存器该用的第三档是 DMA buffer 常用的第四档才是随便攒、随便合并的档位。2.2 提前给 B 响应的边界可延迟不等于可丢弃提前返回 B 响应这件事协议给了一个非常硬的约束互连必须保证后续对同一地址的读能看到写缓冲里还没落盘的最新数据。换句话说写缓冲不是黑盒垃圾桶它是一个必须被读路径穿透检查的存储层级。互连如果做不到这一点就不能把这笔写标成可延迟。这条约束在实际设计里意味着几件事。第一写缓冲的深度是有限的一旦满了主设备就会被反压AWREADY或WREADY拉低。第二写缓冲里同一 cache line 的多个写要维护字节级别的合并逻辑靠WSTRB区分哪些字节有效不能简单覆盖。第三写缓冲里的数据在被合并落盘之前不能被后续的读跳过——这就是为什么很多互连在收到对同一地址的读时会先把写缓冲里对应的数据回填给读通道而不是直接去访问 DDR。我在实际项目中见过一次很典型的翻车某互连的写缓冲实现在读穿透时只按 cache line 地址匹配没考虑字节使能。结果一个 4 字节的部分写被攒在缓冲里紧接着一个对该 line 其他字节的读进来读通道直接从 DDR 取了整行把缓冲里的部分写绕过去了。表现就是读出来的数据偶尔缺一块概率极低跑一整天出一次。这种问题用波形抓能抓到读通道在写缓冲非空时就直接返回数据的那一刻。2.3 Normal 与 Device属性选错比性能差更可怕初学者最容易犯的错是觉得Device 类型慢那我全用 Normal 不就行了。恰恰相反Device 类型是为寄存器访问设计的Normal 类型是为内存设计的用反了不是慢一点的问题是功能错。Device-nGnRnE 里的 nR 是 no ReorderingnE 是 no Early acknowledgement。也就是说这类访问不允许乱序、不允许提前响应必须一路走到底。用它访问 DDR 里的 DMA buffer性能会难看到离谱——每次写都要等一次完整的 DDR 往返几百个字节的描述符填下来时间全花在等确认上了。但用它访问门铃寄存器、中断状态寄存器、复位寄存器是绝对正确的选择。反过来用 Normal Write-Back 去访问设备寄存器问题就大了。设备寄存器是可读可写的硬件逻辑不是存储单元。如果互连把一个 写寄存器 1 的操作攒在写缓冲里然后 CPU 又读了一次同一个寄存器判断状态读通道完全可能从写缓冲里把刚写进去的值回给 CPU——CPU 以为硬件生效了其实信号根本没到设备。更糟的是合并如果两次对同一个寄存器写不同值被合并成最后一次前一次写的副作用就凭空消失了。所以记住一句话deferrable write 只对真正的内存有意义对设备寄存器一律关掉。这句话说起来简单但在一个 PCIe 桥对接的 SoC 里判断某段地址到底落在内存还是落在设备往往需要你去看地址映射表。3. PCIe 侧本来就有延迟的基因3.1 Memory Write TLP 天生没有完成包PCIe 的事务类型分三类Posted、Non-Posted、Completion。Memory Write 属于 Posted也就是前面说的发射后不管。它没有完成包也不需要 Completion Timeout 的监管。Memory Read 属于 Non-Posted必须等一个带数据的 Completion 回来。Configuration Write 和 I/O Write 也是 Non-Posted因为配置空间和 I/O 空间的写必须被确认。这个分类决定了整套排序模型。因为 posted write 不需要等确认PCIe 允许它相对其他事务被重新排序。规范里的排序表大意是posted 请求可以超越 posted 请求也可以超越 non-posted 请求non-posted 请求不能超越前面的 posted 请求completion 不能超越前面的 posted 请求。posted 可以超越 posted这一条杀伤力最大。它意味着在没有任何额外约束的情况下先后发出的两条 Memory Write到达目标端的顺序是不保证的。生产者-消费者模型里的经典写法——先写数据缓冲再写一个 flag 告诉对方数据就绪——如果这两条写落进了不同的内部队列或者经过 Switch 时被分到了不同的路径对方可能先看到 flag 再看到数据。这条规则和 AMBA 侧的同一 AWID 的顺序必须保持形成鲜明对比。AXI 里只要保证同一个 ID从设备的写响应顺序就必须和主设备发出写的顺序一致互连不会帮忙乱序。所以当你把 PCIe 的写桥进 AXI 时桥通常会把所有 inbound 写映射到同一个 ID 上靠 ID 来兜住顺序。这也是为什么桥的 ID 分配策略很关键——如果桥给不同的写分配了不同的 ID而互连又允许不同 ID 乱序那顺序保证就没了。3.2 Relaxed Ordering / No Snoop 落到 AXI 上是什么PCIe TLP 头里有两位属性非常关键Relaxed OrderingRO和No SnoopNS。RO 位置起来表示发送方允许这个 TLP 打破默认的排序规则走更快的路径。典型场景是批量 DMA大块数据传输之间没有依赖让它们自由乱序能显著提高性能。NS 位置起来表示这个 TLP 不需要做 cache 一致性探测直接从内存取值就行。在纯 PCIe 环境里这两位就是性能开关。但到了 PCIe-to-AXI 的桥里它们要被翻译成 AXI 的属性位。常见的映射逻辑是RO 0 且 NS 0通常映射到 Device 或 Normal Non-cacheable Non-bufferable严格顺序RO 1 或 NS 1映射到 Normal Non-cacheable Bufferable允许缓冲和合并如果桥支持一致性域NS 0 的场景可能映射到带 shareability 的 cacheable 事务交给互连的 snoop 过滤器处理。这张映射关系是桥的固件或者硬件配置决定的很多时候在 IP 的 GUI 里根本看不到。但它是决定你系统能不能正常工作的关键。我遇到过的一个案例是桥把所有 inbound 写都固定映射成0b0011Normal Non-cacheable Bufferable结果驱动侧的强序寄存器写也被缓冲了读写状态机直接跑飞。后来在互连的地址区域覆盖attribute override里把那段寄存器区间强制改回 Device才恢复。3.3 桥内部那次属性翻译决定了后面所有事从纯技术角度看PCIe-to-AXI 桥做的三件事——拆分突发、转换 ID、映射属性——里前两件是机械劳动第三件是真正需要设计判断的。拆突发好理解PCIe 的 Memory Write 可以带 128 字节甚至更大的 payloadAXI 的突发有 4KB 边界约束和最大长度约束桥要按边界切开。ID 转换也好理解PCIe 的 TLP 有 Requester IDAXI 有 AWID桥要做映射表。属性映射难在哪难在 PCIe 的 RO/NS 只是两个 bit而 AXI 的内存属性是四维的Device/Normal × Write-Back/Through × Allocate 策略 × Bufferable再加上 shareability 和 QoS。两个 bit 的信息量根本不够覆盖这些维度。所以桥必须做假设而假设就可能和你的软件用法冲突。最常见的冲突就是前面说的软件把一段 BAR 空间当成必须严格顺序的寄存器用桥却把它映射成了可缓冲的 Normal memory。这类问题在 x86 主机上很少出现因为 x86 的 MTRR/PAT 表会把 BAR 空间统一标成 UCuncacheable行为非常保守。但在 ARM 主机上BAR 空间的属性由ioremap系列函数和互连配置共同决定自由度大出错的概率也大。4. 把两边摊开对比能对齐的和对不齐的4.1 一张表看清两种写的生命周期把 PCIe posted write 和 AXI 的几种写放在一起对比差异会非常直观维度PCIe Memory Write (Posted)AXI Device 写AXI Normal Bufferable 写AXI deferrable write是否有完成响应无有 B且必须落到终点有 B可提前返回有 B可提前返回是否允许合并不保证不合并否允许字节级合并允许整行合并是否允许乱序允许超越其他 posted否受 ID 顺序约束受 ID 顺序约束读能否看到未落盘的写取决于端点实现必然可见必须可见必须可见典型用途DMA、大块搬运寄存器DMA 描述符、缓冲区缓存行填充、批量小写看这张表要注意一点允许合并和必须可见这两列是配套的。任何允许把写攒起来的机制都必须同时保证读能穿透写缓冲看到最新值。如果某个互连只实现了前半句那它就是一个会丢数据的互连。4.2 三种常见映射配置与它们的后果实际项目里PCIe inbound 写的属性映射基本就三种配置各有各的代价第一种全映射成 Device-nGnRnE0b0000。这是最保守的做法功能上永远不会错顺序也永远是对的。代价是性能。设备的每个小包写都要一路走到 DDR 等确认描述符环更新这种几百个 4 字节写的场景吞吐会被压到原来的十分之一甚至更低。第二种全映射成 Normal Non-cacheable Bufferable0b0011。这是最激进而又不算越界的做法。写可以被缓冲、可以被字节级合并DDR 那边的访问模式从零散小写变成整行突发写效率提升非常明显。但如果这段地址里混了寄存器就会出 3.2 节说的那种状态机跑飞。而且0b0011不带 cache 一致性如果 CPU 那边对同一段内存有 cacheable 映射就会产生真正意义上的一致性冲突——CPU 的 cache 里是旧值设备写进 DDR 的是新值两边永远对不上。第三种按地址区间分别配置。把寄存器区间设成 Device把 DMA 描述符和缓冲区设成 Normal Non-cacheable Bufferable 或者真正的 deferrable write。这是工程上的正解但需要地址映射表在硬件设计阶段就切分清楚。我见过不少项目是在调试阶段才发现这段地址既能当寄存器读又被硬件当描述符写最后只能回硬件改地址划分。顺带提一句第三个坑如果 CPU 侧对同一段内存做了 cacheable 映射那么PCIe 设备写进 DDR 的数据不会让 CPU 的 cache 失效。这时候必须靠软件显式 invalidate或者干脆把这段内存全部映射成 non-cacheable。这也是为什么 DMA 缓冲区在 ARM 上通常用ioremap而不是普通kmalloc加 cache flush。5. 踩坑实录deferrable write 引发故障的完整排查链路5.1 症状一读到旧值而且只在特定时序下复现这是最经典的一类。现象是 CPU 或设备读到旧数据但复现概率不高加点打印就消失了。这种加了打印就好了的现象本身就是属性问题的最强信号——打印语句引入了额外的内存访问或者时间延迟把写缓冲冲掉了。排查的第一步是确认读的路径。如果读来自 CPU先看那段内存的页表属性是 Device、Normal Non-cacheable 还是 Normal Cacheable。如果是 CPU 侧 cacheable 而设备只写了 DDR那就是一致性问题不是延迟写的问题两回事。第二步是确认写的路径。如果写来自设备PCIe inbound去看桥给这段地址配的 AxCACHE 是多少。如果桥有 debug 寄存器能读出来直接读如果没有就用 ILA 抓 AXI 的AW通道把AWCACHE那几位打出来看。这是我每次遇到这类问题做的第一件事比看代码有效得多。第三步是验证读穿透。构造一个最简场景设备写一个值CPU 立刻读同一地址看能不能读到新值。如果读不到把DSB加在读之前如果加了DSB就能读到说明写缓冲确实存在且没有被读路径穿透那就要从互连配置或者地址属性入手而不是靠加 barrier 硬扛——barrier 能救功能但会把性能吃掉。5.2 症状二字节使能丢了写坏半个字这一类更隐蔽因为数据看起来是部分正确。4 字节的写只有低 2 字节更新了高 2 字节还是旧值或者反过来高 2 字节被写成了上一轮的数据。根因通常在属性合并环节。PCIe Memory Write TLP 头里有 First DW BE 和 Last DW BE 两个字段用来表示首尾双字的字节有效范围中间的字节默认全有效。AXI 侧对应的是WSTRB每一位对应一个字节。桥在把 TLP 拆成 AXI 突发时必须把 BE 无损地翻译成WSTRB。如果桥做的是按 cache line 合并的延迟写那么它必须维护一个字节级的有效位图。读-改-写RMW的时候只能覆盖有效位对应的字节其他字节要从 DDR 读回来保持原值。这里只要有一处实现偷懒——比如直接用WSTRB全 1 的突发把整行写回去——就会把同一行里其他设备写的数据抹掉。我在一次调试里抓到过这个两个不同的 PCIe 功能单元往同一段内存写地址落在同一个 64 字节 line 的不同位置。因为两个写的 ID 不同互连允许它们并行写缓冲里对同一 line 的两笔延迟写没有正确合并最后落地的那一笔把另一笔的字节清零了。这类问题的特征就是数据丢失的粒度总是 cache line 或者双字。5.3 症状三属性不合法的组合导致行为未定义AXI 规范里AxCACHE只有一部分组合是合法的剩下的保留编码行为未定义。桥的固件如果产生了保留编码互连可能按 Device 处理也可能按 Normal 处理甚至可能直接报错。这种问题最难查因为它没有任何规律换个芯片就换个现象。常见的非法组合有两种。一种是把 Device 类型和 Write-Allocate 混在一起——Device 内存本来就不该被缓存、不该被分配标了 Write-Allocate 属于语义矛盾。另一种是 Write-Through 和 Write-Allocate 的组合在有些实现里被当成合法有些实现里被当成非法跨厂商必然踩坑。这里的工程建议很直接不要自己构造AxCACHE的值。能让代码生成的就别手写能让 IP 配置的就别在 RTL 里硬编码。如果一定要硬编码就把这几位当成枚举而不是位掩码来用——只使用手册里明确列出的那几种组合一个字节都不要多。5.4 从现象到波形我通常按这个顺序查把上面的经验汇总成一个可复用的排查顺序第一步定性。是读到旧值、数据被破坏还是顺序错乱这三种对应完全不同的根因。读到旧值大概率是属性太保守写被延迟但读没穿透或者 CPU cache 没失效数据被破坏几乎一定是合并或字节使能的问题顺序错乱则是 ID 分配和排序模型的问题。第二步抓 AXI 波形。重点看AWID、AWCACHE、AWSIZE、AWBURST、WSTRB、BRESP以及BVALID出现的时间点和WLAST的先后关系。如果BVALID在WLAST之前就拉起来了说明桥在提前响应这时候就要确认提前响应是否合法。第三步抓 PCIe 侧 TLP。看 Memory Write 的地址、长度、TC、RO、NS 和 BE 字段。如果手边没有协议分析仪很多 FPGA 平台的 PCIe IP 自带 TLP 抓包或者 AXI-to-PCIe 的 monitor够用。第四步对照两侧。一个 PCIe TLP 对应几个 AXI 突发属性变成了什么BE 有没有丢这一步是定位问题的核心因为绝大多数 bug 都发生在这次翻译里。第五步改配置验证。先改地址属性Device ↔ Normal再改 ID 分配策略最后才考虑加 barrier。每次只改一个变量否则你不知道是哪个改动生效的。6. 该开还是该关deferrable write 的适用边界6.1 值得开的场景批量小写和描述符环deferrable write 的价值在大量小尺寸、彼此独立、不要求立刻可见的写场景里体现得最明显。最典型的是 DMA 描述符环。一个 32 项的环形队列每项 16 字节驱动一轮更新下来是几百个字节的零散写。如果这些写全走 Device 属性每个写都要等一次完整确认DDR 那边看到的访问模式是零散的 4 字节或 8 字节写行激活次数极高效率极低。换成可缓冲、可合并的属性之后互连会把落在同一 cache line 上的写攒成一整行再落盘DDR 那边的访问模式变成整行突发效率提升是数量级的。第二类是统计计数器和状态位图的更新。这类写的共同特点是只写不依赖读结果而且更新频率高。允许合并和延迟能把总线上的事务数压下来。第三类是共享内存里的 ring buffer 生产者端。生产者只负责写消费者读到什么就处理什么生产者不需要知道写什么时候生效。这种模型天然适合延迟写。6.2 必须关的路径门铃、控制和任何会立刻被读的地址与上面相反有三类地址绝对不能开延迟写。第一类是门铃和中断相关寄存器。门铃的本质是写这个地址会触发一个动作动作的时机必须确定。如果写被延迟轻则响应变慢重则因为写被合并而丢触发。第二类是任何写完立刻读的地址。典型是握手寄存器、状态机控制字、FIFO 的读写指针寄存器。这类访问形成的是一个读写依赖环写被延迟而读没穿透环就断了。判断方法很简单只要软件在这段地址上有 read-after-write 的依赖就不该用可延迟属性。第三类是配置空间和错误状态寄存器。这类访问本身就要求严格的顺序和可见性PCIe 侧它们走的是 Non-Posted到了 AXI 侧必须映射成 Device 属性否则一个配置读可能读回还没生效的旧值。6.3 落地时的组合拳属性覆盖、地址映射与 barrier最后说点实操层面的组合打法。在硬件设计阶段地址映射表上就要把寄存器区和缓冲区分开别混在同一段地址里。寄存器区在互连上配置成 Device 属性缓冲区配置成 Normal Non-cacheable Bufferable。互连一般都有地址区域的属性覆盖功能可以把某段地址的AxCACHE强制改成固定值这是最省事的手段——软件不用改硬件层面就把属性钉死了。在驱动侧BAR 空间的映射方式要选对。x86 上ioremap默认是 UC行为保守但对寄存器是安全的如果要给 DMA 缓冲区用需要显式申请 write-combining 的映射。ARM 上则要区分ioremapDevice和ioremap_wcNormal Non-cacheable允许写合并后者才是让 BAR 写变快的正确方式而且必须确认这段 BAR 里不含需要严格顺序的寄存器。在软件同步点上barrier 要用但别滥。DSB能保证它之前的所有内存访问在之后的访问之前完成是我需要确保写已经落地时最直接的手段。但它拦的是顺序不是属性——如果属性本来就是可延迟的DSB会强制把缓冲刷出去功能对了性能也回到原点。所以正确的思路是用属性决定性能上限用 barrier 处理少数关键同步点。一个系统里如果到处都在加DSB那基本可以断定是属性配置没做对。我个人的习惯是在新板子 bring-up 阶段先把所有 PCIe inbound 地址都配成最保守的 Device 属性把功能跑通然后用 ILA 抓一遍流量看瓶颈在哪一段再针对性地把那一段改成可缓冲或可延迟的。这样每次改动都有明确的性能收益可以量化出了错也容易回退——毕竟在 PCIe 和 AMBA 交界的地方能快速回退的配置才是好配置。