一次PCIe内存读的完整旅程:深入解析事务层MRd与CPLD机制
发布时间:2026/9/30 22:55:32 作者:尧图编辑部 阅读量:1,286

有没有过这种经历lspci -vv明明列出了一个 PCIe 设备BAR 地址也分配了软件去读寄存器却返回全0xFF或者干脆卡在readl里迟迟不返回最后上报一个总线错误。很多人第一反应是驱动写错了、中断没配好却忽略了 PCIe 事务层协议里最基础的一环——内存读请求和完成响应是不是真的对上了。这次就用一次内存读的完整旅程把 PCIe 事务层协议里最关键的部分逐段拆开看完之后再去排这一类问题思路会清晰很多。这篇文章的主场景是 CPU 发起一次对设备映射地址的 32 位 MMIO 读。一次读在 PCIe 世界里从来不只是一个方向的动作先要发一个内存读请求Memory Read Request, MRd设备收到后还必须回一个完成报文Completion with Data, CPLD。这中间涉及 TLP 构造、流控信用、地址路由、标签匹配、完成拆分、错误状态等一系列机制。适合正在做 PCIe 驱动开发、FPGA PCIe 接口调试、或者刚开始读 PCIe 协议但被规范文档绕晕的工程师参考。1. 先画一条路径一次内存读在 PCIe 里到底要经过哪些环节1.1 为什么用“读”来拆事务层而不是“写”PCIe 的写事务是“单程票”。Posted 报文发出去就完事不需要对方回执而读事务天然是“一问一答”请求发出后请求方必须等待完成报文回来整个往返过程才会结束。正是这个“回答”机制把事务层的报文类型、路由方式、标签管理、流控信用、错误状态全都带了出来。打个比方写事务就像寄出一个包裹单号只是方便你事后查读事务则是寄出一张查询单要求对方盖个回执再寄回来。回执如果丢了、慢了、寄错了人立刻就能体现出异常。所以把一次读吃透写、消息、配置读写这些事务的规则基本也能顺势理解一大半。1.2 一次读的完整旅程可以拆成七个站点不用画流程图我用文字把这次读的经过列出来后面所有内容都是围绕这条链路展开的CPU/驱动程序访问一段已映射的 MMIO 地址这个地址在 Root Complex 里被识别为 PCIe 域。Root Complex 的事务层生成一个内存读请求 TLP也就是 MRd。如果拓扑里有 SwitchTLP 按目标地址被路由到对应的下游端口。端点接收 TLP先由数据链路层做完整性校验再交给事务层解析。端点根据地址命中自己的 BAR 空间把内部寄存器或存储器的数据读出来。端点构造完成报文 CPLD携带数据和状态返回。Root Complex 根据请求者 ID 和 Tag 匹配完成最终把数据交给 CPU 或系统内存。这七个站点是典型场景实际拓扑可能没有 Switch直接从 Root Port 到设备那第 3 步和第 6 步的路由判断就更简单但核心机制完全一样。我用一个表格把每个站点涉及的关键知识点先摆出来阶段主要动作涉及事务层知识点Root Complex 发起地址翻译、TLP 构造BAR、请求头部、TagSwitch 下行按地址路由地址路由表、内存窗口端点接收校验并解析请求LCRC、事务层接收逻辑端点数据源BAR 命中并读取内部寄存器/RAM 映射端点应答构造 CPLD完成头、状态、拆分规则Switch 回程按 ID 路由Completer IDRoot Complex 匹配完成队列配对Requester ID Tag1.3 MMIO 读和 DMA 读是两条不同方向的路很多朋友看到“内存读”会条件反射想到 DMA也就是端点设备主动去读系统内存。这确实是 PCIe 里最常见的业务之一但和本文主讲的 MMIO 读方向正好相反。对比项MMIO 读DMA 读发起方Root ComplexEndpoint读取对象端点的 BAR 空间主机侧系统内存请求头里的地址含义目标设备内部地址主机物理内存地址完成方向Endpoint - Root ComplexRoot Complex - Endpoint常见故障点BAR 没使能、设备不回完成Bus Master Enable 没开、IOMMU 映射错误DMA 读的核心 TLP 机制和 MMIO 读其实完全同源只是“问路”的方向反了过来。把 MMIO 读这条链路彻底搞懂DMA 读的请求和完成关系也可以直接迁移过去。2. 事务层的“快递单”TLP 头部到底写了什么2.1 TLP 在 PCIe 分层里的位置PCIe 事务层的核心产物就是 TLP。事务层负责把 CPU 侧的内存访问翻译成标准报文在接收侧又把报文恢复成内存操作。TLP 往下走的时候数据链路层会在前面加一个序列号、在末尾加 LCRC物理层再负责编码和高速串行传送。在这一堆处理中事务层才是真正有“业务逻辑”的地方链路层只是保证搬运不丢不坏物理层解决传输介质问题。理解这一点很有用平时用协议分析仪或者 FPGA 里的 TLP tracer 抓到的数据本质上都是把物理层符号解码、把链路层序号和 CRC 剥掉以后得到的事务层内容。只要你会看 TLP就等于拿到了真正的主线剧透。2.2 内存读请求头部字段逐项拆解一个 32 位地址的 MMIO 读请求通常使用 3DW 的 TLP 头部。我按实际意义把关键字段列出来字段说明一次 32 位读的典型取值Fmt / Type报文格式和类型Memory ReadMRd3DW 头Length本次要读的总长度单位 DW1即读取 4 字节Requester ID请求发起方的 BDF如 00:01.0Tag请求编号用于配对由发起方分配的未用号Last DW BE / First DW BE首尾 DW 内哪些字节有效全有效时是 0xF / 0xFAddress目标地址BAR 基址加偏移TC / Attr / EP / TD服务质量与完整性属性常规读通常为 0Length字段要特别注意对于读请求TLP 内部并不携带数据Length 代表的是“希望对方返回多少 DW”。这和写请求完全不一样写请求是Length个 DW 的数据实际跟着请求走。地址超过 32 位时头部会变成 4DW地址字段占 64 位。日常很多设备的 BAR 被系统分配在低 32 位地址空间所以 3DW 头部出现得最多但在 64 位系统里设备资源也可能被分配到高地址这时不能用老眼光去看报文。First/Last DW BE这组字段容易被人忽略。软件用readl读 4 字节时这两个字段都是0xF表示 4 个字节全要。但有些驱动会用readw或readb操作同一个地址那时 Byte Enable 就只对应相应字节有效。设备侧如果不按 Byte Enable 处理就很容易出现“读回来总是整字但驱动读半字时数据错位”的问题。2.3 完成报文 CPLD 的头部和请求头有何不同完成报文是读请求的响应头部结构有明显差别。它不再有目标地址取而代之的是完成方自己的信息字段说明本例典型值Fmt / Type带数据的完成CPLDCompleter ID完成方的 BDF目标端点的 BDFRequester ID发起方 BDF必须和请求头一致Tag原请求的编号必须原样返回Status完成状态0成功1UR2CAByte Count本次完成包含多少数据字节等于请求的 Length * 4Lower Address完成数据在地址空间的起始信息请求地址低 bit 对齐后的值Data读出的有效载荷寄存器里的 4 字节值这里最关键的一点完成报文靠Requester ID Tag找到对应的请求而不是靠地址。也就是说请求头携带了“到哪里去读”的地址完成头则只带了“回给谁”的身份证。后面讲回程路由时这个机制会越来越重要。3. 出站之旅请求怎么从根端口跑到目标设备3.1 CPU 的 MMIO 访问如何变成 PCIe 报文一次readl从 CPU 视角看是简单的访存指令但硬件背后很复杂。系统在枚举阶段已经为每个设备 BAR 分配了一段物理地址CPU 访问这段地址时Host Bridge / Root Complex 会识别出它属于 PCIe 域随后开始构造 TLP。这个过程通常由 RC 硬件自动完成不需要驱动介入。驱动拿到ioremap以后的虚拟地址写下去或者读出来RC 会把 CPU 指令里的地址和长度翻译成 3DW 或 4DW 的 MRd。如果目标地址小于 4GBRC 通常用 3DW 头如果落在 64 位地址空间则使用 4DW 头。这里最值得注意的一点CPU 指令位宽不完全决定 TLP 头部格式决定因素是目标地址范围和平台实现。所以在排查“读寄存器数据不对”时第一步永远先确认 BAR 分配是否正确。PCIe 枚举过程里 BIOS 或 OS 会把 BAR 写成某一可用地址如果 BAR 里完全没有使能内存空间位或者地址窗口还没建立RC 就没法把 CPU 地址和任何设备对上自然也无法构造有效的读请求。3.2 发送前必须做流控事务层不是想发就发PCIe 的流控机制和很多总线不一样。它不靠接收方发“忙”信号而是基于信用量Credit。每个虚拟通道里分了三类信用Posted、Non-Posted、Completion每一类又区分 Header 和 Data。内存读请求属于 Non-Posted 类型需要消耗一个 NP Header Credit。由于读请求本身不携带数据体所以不需要消耗 NP Data Credit。发送方的事务层在把 TLP 交给数据链路层之前会检查对应的 Credit 是否足够不够就先等待。这个机制特别像酒店办理入住时的预授权你得先把额度占住后面退房再结算。接收方收到 TLP 并消化之后会向发送方归还 Credit双方才能继续“转账”。工程上有一个很隐蔽的坑当接收方没有及时归还 Credit或者 Credit 初始化时配置成 0发送方就会一直等表现是“请求发出去了但链路上什么都看不到”。这种问题在普通软件调不见非得上 TLP 抓包才能定位。3.3 Switch 怎么知道该把读请求转到哪个下游端口当拓扑中有 PCIe Switch 时RC 发出的内存读请求先到达 Switch 的上游端口。Switch 需要根据地址信息决定往哪个下游端口转。这个决定不靠广播而是靠枚举阶段建立的内存窗口。每个下游端口在配置空间里会记录一组 Memory Base 和 Memory Limit本质就是一张地址路由表。Switch 拿到 MRd 之后取出请求头里的地址和各个下游端口的窗口比对命中就往对应端口转发没有命中就返回一个 Unsupported Request 完成或者直接丢弃。这和以太网的泛洪转发完全不同PCIe 的内存读不会“全员广播”每跳都必须有唯一判定。这也是 PCIe 拓扑越复杂对地址窗口配置要求越高的原因。3.4 穿过链路层和物理层时发生了什么事务层把 TLP 构造好之后数据链路层会给它加 2 字节的序列号并在尾部算一份 LCRC。接收端检查这些信息如果发现 CRC 不对会通过链路层重传机制要求发送方重发。物理层则负责把数据变成串行信号送到线上期间还有均衡、扰码、编码等操作。对事务层讨论来说这一层只是“可靠的搬运工”。但是排障时要知道链路层如果频繁丢包重传最终也会表现为事务层“请求发出去了但一直等不到完成”容易被误判成设备故障。这时候需要用lspci -vvv看链路带宽和链路状态确认物理链路是否稳定。4. 端点收到读请求以后在干什么4.1 接收侧的顺序先拆包再判断是不是自己的菜端点从线上收到数据后物理层先还原出符号数据链路层检查序列号和 LCRC确认无误后才把 TLP 提交给事务层。事务层接收模块要做的第一件事是根据Fmt/Type判断报文的类别和方向如果是内存读请求再取出地址和 BAR 空间做匹配。这里有个容易被驱动程序忽略的点接收侧必须负责归还 Credit。设备和 RC 一样会在内部实现一个信用接收端口收到 TLP 后按规则释放缓冲。如果设备固件或逻辑设计有缺陷收了一个报文后 Credit 没归还发送方就会慢慢卡住直到所有 Credit 耗尽。我调试 FPGA 板卡时遇到过几次“开始还能正常读跑了几百次以后就彻底没响应”的情况最后都是 Credit 管理状态机写错了。4.2 BAR 命中以后数据源从哪里来对端点的内部实现来说BAR 就像一扇门。门打开了以后内部地址偏移决定访问哪个寄存器或哪块 RAM。FPGA 实现 PCIe 时最常见的做法是把 BAR 空间映射到一组 CSR 寄存器、Block RAM、或者 DMA 描述符区域。内存读请求携带的地址会被内部译码成寄存器地址或 RAM 地址再经过组合逻辑把数据取出来。这里有一个特别值得提醒的细节事务层协议保证不了数据是“新鲜”的保证的只是数据能取回。如果设备内部读取逻辑没有做同步比如寄存器还没被上游模块刷新读请求来了就直接返回旧数据软件是感知不到的只会发现“为什么我写进去的值读出来不对”。这种问题通常要靠补同步逻辑或者加入读时钟域处理来解决。4.3 构造完成报文时要遵守的拆分规则端点读取到数据后并不是想怎么回就怎么回。PCIe 协议对读完成有两个硬性约束第一个约束是 Max Payload SizeMPS。如果一次读请求的 Length 很大而完成者的 MPS 只有 128 字节它就不能把 256 字节的数据一次性塞进一个 CPLD而是要拆成多个完成报文。第二个约束是 Read Completion BoundaryRCB。RCB 定义了完成报文拆分时必须对齐的地址边界。RC 的完成者可以通过配置空间把 RCB 选为 64B 或 128B非 RC 的完成者固定是 64B。这也是为什么同一个设备在有些平台上表现特别好换一个 Root Complex 以后大块读性能就掉下来——很可能是 RCB 策略变了。每个拆分出来的 CPLD 都带有自己的Lower Address和Byte Count请求方拿到这些完成报文后会按地址和 Tag 重组数据。编写驱动时不要假设第一个返回的 CPLD 一定包含最前面一段数据协议允许完成者按自己的拆包策略返回所以软件端如果要处理多完成的情况必须按头部信息重新拼装。5. 回程逻辑完成报文凭什么能找到“原路返回”的路5.1 完成靠 ID 路由不是靠地址路由这是事务层协议里最容易被误解的部分。内存读请求往下走用地址路由大家都好理解但完成报文头部没有目标地址它拿什么路由答案是靠Completer ID和Requester ID。完成报文从端点发出时Completer ID就是端点自己的 BDFRequester ID是当初请求方的 BFD。Switch 收到完成报文后根据 ID 判断应该往上游端口还是下游端口转最终一路转到根端口。可以这样理解地址路由是“按收货地址送货”ID 路由则是“按客户编号找寄件人”。快递单上写了客户编号分拣站看一眼编号就知道这个回执该回到哪去和寄件时写的收货地址没有直接关系。最关键的一点是完成报文只会被 Requester ID 对应的那个请求方接收并处理其他设备即使看到也直接忽略不能劫持更不能当作广播消息去处理。5.2 Tag 空间不足会让读请求“堵车”PCIe 允许一个请求方同时挂多个未完成的读请求不要求发一个等一个。每个未完成请求都要占用一个 Tag。Tag 就像餐饮店排队叫号号发完了后面的人只能等。标准里 Tag 字段是 8 位因此理论上最多可以有 256 个未完成请求。但实际实现未必用满很多老设备默认只支持一小部分 Tag需要通过配置空间的 Extended Tag 能力开启更多 Tag 位。链路两侧必须都支持并正确配置否则请求方很快就会把 Tag 耗尽表现为“新请求发不出去读吞吐上不去”。软件层面还有一个常见误区CPU 顺序代码执行readl时RC 内部很可能会串行处理这些 MMIO 读不会自动把多个 Tag 用满。不要指望一个 for 循环里的几百次readl就能自动产生几百个并行 outstanding 请求。优化大块读性能要依赖 DMA 或者 CPU 端的 non-blocking read 机制不能拿顺序读来硬刚。5.3 状态字段里的 UR、CA 和读超时完成报文的状态字段有三种常见编码需要牢记0表示成功完成1表示 Unsupported RequestUR2表示 Completer AbortCA。当设备收到了一个无法识别的请求比如地址没命中任何 BAR它可以返回 UR当设备内部功能无法完成操作比如模块被复位或者正在初始化它可以返回 CA。这类错误完成最后会通过 RC 转化为软件可见的总线错误或者记录在 AER 寄存器里。驱动如果只看到“读寄存器超时”或“总线错误”却不深入看完成状态很容易把问题归到硬件不稳定上去实际上协议已经给出了非常明确的错误类型。如果请求发出后完成报文一直不回来就会进入 Completion Timeout。RC 的 Device Control 2 配置空间里可以设置超时值不同平台默认值可能很不相同。更隐蔽的情况是设备链路过早进入低功耗状态比如 L1 或热插拔之后设备还没有完成重新初始化读请求发出后卡很久才超时。这时需要先看链路状态而不是盯着驱动代码。6. 内存读排障实录几个最常见的深坑6.1 读寄存器总是得到全 (0xFF) 或全 0看到全 (0xFF) 时先冷静不要直接判断“设备坏了”。按这个顺序排查用setpci查看 Command 寄存器确认 Memory Space Enable 是否置 1。用lspci -vvv查看 BAR 值确定地址分配合法且没有被其他资源覆盖。用setpci手动读 BAR确认硬件返回的不是0xFFFFFFFF。排除设备内部存在偏移未实现导致端点返回 FF 的预期行为。全0的现象通常比全FF更麻烦。全FF大概率是地址没命中或信号悬空全0往往是设备软核逻辑直接返回了固定值不是协议层面的问题。这两种都要结合设备手册确认每个偏移位的默认值。6.2 readl 卡死或者直接总线错误先查完成路径readl卡住、进而触发系统 bus error本质是 CPU 等了太久仍然没有等到 CPLD。按照协议定位时注意区分两种场景MMIO 读场景下EP 不需要开启 Bus Master Enable 也能回应 RC 的读请求因为该请求由 RC 发起。此时如果无响应优先检查端点是否真的收到了 TLP、内部译码是否命中、完成逻辑是否正常。DMA 读场景则反过来EP 主动读系统内存前提是 EP 的 Bus Master Enable 必须打开还要有正确的 IOMMU/SMMU 映射。很多 DMA 读异常根因都是 DMA 映射没建立或 BME 没置位。如果 TLP 已经发出但一直收不到完成建议直接用带 TLP tracer 的调试工具抓一次。确认 MRd 有没有出 RCCPLD 有没有从 EP 回来。如果只看到 MRd 没有 CPLD问题基本锁定在端点侧如果 CPLD 已发出但 RC 没匹配上则要考虑 Tag 是否被占用、ID 路由是否正确、完成队列是否满了。6.3 大块读性能上不去多半是 MPS 和 RCB 的锅有时测量 PCIe 读带宽发现远低于链路速率但不是链路没训练好而是事务层参数不合适。最常见两个因素第一Max Payload Size 太小。如果 MPS 只有 128B一次 256B 的读请求会被拆成多个完成报文返回头部开销占比和事务数都会增加吞吐自然上不去。第二RCB 设置不合理。RCB 影响完成报文能否在更宽的边界上对齐非 RC 设备固定 64BRC 侧可以通过配置选择 64B 或 128B。用lspci -vvv可以快速看到 MaxPayload 和 RCB 配置。注意链路两侧的 MPS 必须协商出一个共同值如果一端支持 256B另一端只到 128B最后按最小侧对齐。这个时候不是换根线或者调驱动能解决的要看设备固件和 RC 的配置协商。症状重点检查项常见根因读返回全 FFBAR、Command、偏移映射内存空间未使能或地址没命中读卡死/超时TLP 抓包、完成队列端点未回完成、Credit 未归还DMA 读失败BME、IOMMU 映射总线主控未打开或地址翻译错误读性能低MPS、RCBPayload 过小或拆分边界限制最后分享一个我自己的习惯真要把 PCIe 事务层搞透与其背一大堆寄存器名不如实打实抓一组 MMIO 读的 TLP。在 Linux 里对某个 BAR 地址执行一次devmem同时在总线上抓包把 MRd 请求头和 CPLD 完成头按本文表格逐个字段展开对比一个往返看下来比看十篇文档都管用。曾经有一回我在 FPGA 板卡上故意把一段 BAR 映射内部的寄存器做成慢速读取但没有在 RTL 里做读同步结果 CPU 读到的总是上一次的旧数据最后就是通过对比 TLP 的完成顺序发现的问题。如果你也在做 PCIe EP 的设计这类事务层的细节早晚会找上门。