PCIe posted write与AXI deferrable写属性映射
2026/9/18 15:13:05 网站建设 项目流程

调试一块 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 write,AMBA 那边叫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 响应这件事,协议给了一个非常硬的约束:互连必须保证后续对同一地址的读,能看到写缓冲里还没落盘的最新数据。换句话说,写缓冲不是"黑盒垃圾桶",它是一个必须被读路径穿透检查的存储层级。互连如果做不到这一点,就不能把这笔写标成可延迟。

这条约束在实际设计里意味着几件事。第一,写缓冲的深度是有限的,一旦满了,主设备就会被反压,AWREADYWREADY拉低。第二,写缓冲里同一 cache line 的多个写要维护字节级别的合并逻辑(靠WSTRB区分哪些字节有效),不能简单覆盖。第三,写缓冲里的数据在被合并落盘之前,不能被后续的读"跳过"——这就是为什么很多互连在收到对同一地址的读时,会先把写缓冲里对应的数据回填给读通道,而不是直接去访问 DDR。

我在实际项目中见过一次很典型的翻车:某互连的写缓冲实现在读穿透时只按 cache line 地址匹配,没考虑字节使能。结果一个 4 字节的部分写被攒在缓冲里,紧接着一个对该 line 其他字节的读进来,读通道直接从 DDR 取了整行,把缓冲里的部分写"绕"过去了。表现就是读出来的数据偶尔缺一块,概率极低,跑一整天出一次。这种问题用波形抓,能抓到读通道在写缓冲非空时就直接返回数据的那一刻。

2.3 Normal 与 Device:属性选错比性能差更可怕

初学者最容易犯的错,是觉得"Device 类型慢,那我全用 Normal 不就行了"。恰恰相反,Device 类型是为寄存器访问设计的,Normal 类型是为内存设计的,用反了不是慢一点的问题,是功能错。

Device-nGnRnE 里的 nR 是 "no Reordering",nE 是 "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 Ordering(RO)No Snoop(NS)

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 写都固定映射成0b0011(Normal Non-cacheable Bufferable),结果驱动侧的强序寄存器写也被缓冲了,读写状态机直接跑飞。后来在互连的地址区域覆盖(attribute override)里把那段寄存器区间强制改回 Device,才恢复。

3.3 桥内部那次属性翻译,决定了后面所有事

从纯技术角度看,PCIe-to-AXI 桥做的三件事——拆分突发、转换 ID、映射属性——里前两件是机械劳动,第三件是真正需要设计判断的。

拆突发好理解:PCIe 的 Memory Write 可以带 128 字节甚至更大的 payload,AXI 的突发有 4KB 边界约束和最大长度约束,桥要按边界切开。ID 转换也好理解:PCIe 的 TLP 有 Requester ID,AXI 有 AWID,桥要做映射表。

属性映射难在哪?难在 PCIe 的 RO/NS 只是两个 bit,而 AXI 的内存属性是四维的(Device/Normal × Write-Back/Through × Allocate 策略 × Bufferable),再加上 shareability 和 QoS。两个 bit 的信息量根本不够覆盖这些维度。所以桥必须做假设,而假设就可能和你的软件用法冲突。

最常见的冲突就是前面说的:软件把一段 BAR 空间当成"必须严格顺序"的寄存器用,桥却把它映射成了可缓冲的 Normal memory。这类问题在 x86 主机上很少出现,因为 x86 的 MTRR/PAT 表会把 BAR 空间统一标成 UC(uncacheable),行为非常保守。但在 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-nGnRnE(0b0000)。这是最保守的做法,功能上永远不会错,顺序也永远是对的。代价是性能。设备的每个小包写都要一路走到 DDR 等确认,描述符环更新这种"几百个 4 字节写"的场景,吞吐会被压到原来的十分之一甚至更低。

第二种,全映射成 Normal Non-cacheable Bufferable(0b0011)。这是最激进而又不算越界的做法。写可以被缓冲、可以被字节级合并,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 波形。重点看AWIDAWCACHEAWSIZEAWBURSTWSTRBBRESP,以及BVALID出现的时间点和WLAST的先后关系。如果BVALIDWLAST之前就拉起来了,说明桥在提前响应,这时候就要确认提前响应是否合法。

第三步,抓 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 上则要区分ioremap(Device)和ioremap_wc(Normal Non-cacheable,允许写合并),后者才是让 BAR 写变快的正确方式,而且必须确认这段 BAR 里不含需要严格顺序的寄存器。

在软件同步点上,barrier 要用但别滥。DSB能保证它之前的所有内存访问在之后的访问之前完成,是"我需要确保写已经落地"时最直接的手段。但它拦的是顺序,不是属性——如果属性本来就是可延迟的,DSB会强制把缓冲刷出去,功能对了,性能也回到原点。所以正确的思路是:用属性决定性能上限,用 barrier 处理少数关键同步点。一个系统里如果到处都在加DSB,那基本可以断定是属性配置没做对。

我个人的习惯是,在新板子 bring-up 阶段先把所有 PCIe inbound 地址都配成最保守的 Device 属性,把功能跑通,然后用 ILA 抓一遍流量看瓶颈在哪一段,再针对性地把那一段改成可缓冲或可延迟的。这样每次改动都有明确的性能收益可以量化,出了错也容易回退——毕竟在 PCIe 和 AMBA 交界的地方,能快速回退的配置才是好配置。

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

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

立即咨询