
做嵌入式开发和驱动开发这些年我始终保留一个习惯:每隔一段时间或者在某个项目被现场问题卡住之后把 PCIe 协议重新读一遍。很多人一听“重读协议”就以为是翻手册其实完全不是同一件事。第一次读协议是学结构带着好奇从头看到尾觉得每一章都讲得有道理重读协议是带着现场问题回去找根因读法完全不同收获也完全不同。我最近一次重读就是被一个 Gen3 x4 链路带宽跑不满、DMA 压力测试偶尔卡死、MSI-X 中断出现丢失的复合问题逼回去的。这篇文章想把这次重读的脉络、重点章节和落回项目的排查过程整理一遍给同样在做驱动、FPGA 或者系统级验证的同行做一个参考。1. 为什么要“重读”而不是“初读”1.1 初读和重读的差别初读 PCIe 协议大多数人会按目录顺序来先看架构概览再读事务层、数据链路层、物理层最后翻配置空间和电源管理。这个顺序适合建立全局观但你读完以后往往记不住细节更谈不上把条款和实际故障对应起来。重读就不一样。重读的起点是一个明确的故障现象比如“DMA 读请求在重负载下偶尔超时”“链路协商速度明明是 8GT/s实际吞吐量却只有理论值的一半”“中断偶尔不来或者来了但驱动没收到”。带着这些问题去翻协议你会有目的地搜索事务层的数据包格式、数据链路层的确认重传机制、物理层的链路训练状态机每一段文字都能落到电路、寄存器或者波形上。这就是我理解的“重读”不是把规范再看一遍而是让问题引导你回到规范原文把当时没想清楚的机制补上。1.2 用三个现场问题划定重读范围我这次重读的触发点有三个每个都指向协议里的一块核心机制第一个某个测试主控在用 DMA 做连续读的时候跑一段时间后会出现请求超时。停止业务之后重新加载驱动又能恢复。这种“看起来像软件 bug实际可能是链路可靠性问题”的故障必须回去看数据链路层的重传机制和错误计数器。第二个Gen3 x4 的链路理论单向带宽接近 4GB/s实测却停在 2GB/s 以下。这种问题一般不在物理层而要从事务层的 TLP 大小、读请求拆分、完成包的开销去查。第三个MSI-X 中断在高速收发场景下会出现“丢失”。一开始怀疑中断控制器最后发现是 MSI-X 表项的 Mask 位被驱动代码误写。要真正理解这个问题必须读懂 MSI-X 的表结构和向量控制位。这三个问题分别对应事务层、数据链路层和配置空间几乎覆盖了 PCIe 协议最核心的内容。把这三个问题串起来整本协议的一半就被重新读透了。1.3 重读时该重点翻哪几章对于一个已经工作过一段时间的开发者我建议按问题类型去选择章节而不是从第一章慢慢扫。下面这张表是我个人常用的映射关系借着这次重读也整理了出来。现场现象优先重读的章节重点概念带宽跑不满、延迟偏高事务层、配置空间TLP 结构、MPS/MRRS、Completion 开销随机超时、偶发挂死数据链路层Ack/Nak 重传、序列号、LCRC、Flow Control链路降速、不稳定物理层LTSSM、链路训练、均衡、编码方式中断异常、MSI/MSI-X 失效配置空间、事务层MSI-X 表、Mask 位、PBA、Message 路由枚举不到设备、BAR 异常配置空间Type0/Type1 头、总线号分配、BAR 解码这张表的核心逻辑很简单协议里没有一章是“为了存在而存在”的每个机制背后都对应一类真实工程问题。你只要把故障现象映射到机制归属就知道该翻哪里。2. 分层模型每一层为什么会存在2.1 事务层、数据链路层、物理层的职责边界PCIe 是出了名的分层协议但很多人在实际调试中还是喜欢把“PCIe”当成一个整体概念来用出问题以后分不清是事务层的事还是物理层的事。重读到协议的分层模型时我特意把每一层的职责边界重新梳理了一遍这对我后面的排查帮助非常大。事务层负责“表达意图”。驱动要读某个地址事务层会生成一个 Memory Read 请求 TLP设备完成读操作以后再生成一个 Completion 包。这个层面只关心“读什么、写什么、谁来收”它不关心数据是怎么在物理通道上正确传输的。数据链路层负责“可靠传递”。它给每个 TLP 加上序列号计算 LCRC然后在收到 Ack/Nak 之后决定是否重传。事务层感知不到数据链路层的重传就像 TCP 上层感知不到 IP 层的丢包重传一样。链路偶尔出现 bit 翻转数据链路层会在硬件上把正确的数据再送一次。物理层负责“信号搬运”。它把数据变成高速差分信号完成链路训练、速度协商、加扰、编码、均衡。物理层的状态直接决定链路是 Gen1 还是 Gen4、是 x1 还是 x16也直接决定你能不能看到稳定的 L0 状态。我习惯用快递打个比方事务层负责填写快递单和拆包裹数据链路层给每个包裹贴加密编号并在丢件时补发物理层是那辆公路上的车。你填错快递单是事务层问题车跑不稳翻车是物理层问题包裹丢在半路被补发是数据链路层问题。三者隔离排查问题才能朝正确方向走。2.2 分层最大的收益不是“好理解”而是“可独立演进”很多人以为分层设计只是为了降低理解难度其实协议分层最大的收益是让每一代物理实现可以独立演进而事务层几乎不用跟着改。物理层从 Gen1 的 2.5GT/s 一路走到 Gen5 的 32GT/s编码方式从 8b/10b 换成了 128b/130b链路宽度可以从 x1 到 x16均衡机制越来越复杂。但是对事务层来说一个 Memory Read TLP 的格式几乎没有发生破坏性变化驱动和设备的交互方式也没有变。数据链路层在 Gen4 之后虽然引入了新的 CRC 机制但对外依然提供“可靠的 TLP 传递”这个抽象。这就导致一个结果你可以用一套相同的驱动代码去适配不同代际的 PCIe 设备只需要根据配置空间里的能力位去协商速度、MPS、MRRS 等参数。重读协议时理解这种“隔离晋级”的思路比死背某个字段值更重要。2.3 数据包过三关每处都可能出问题数据在 PCIe 链路上传输时要从事务层一路往下到物理层接收方向再从物理层一路往上恢复。任何一个层面出了问题都会表现出不同的症状。如果事务层 TLP 格式不对接收端可能直接丢弃或者报 Malformed TLP。如果数据链路层连续重传失败可能触发 Link Down 或者 Fatal 错误。如果物理层信号劣化链路会先尝试 Recovery再多次失败后降速或者彻底断开。排查 PCIe 问题第一步就是判断问题到底出在哪一层。这个判断能力不是凭感觉而是靠对分层模型的熟悉程度。你看到错误日志里的 Correctable Error 计数不断上涨优先去查物理层和数据链路层你看到某个读请求超时但链路状态正常优先去查事务层的路由、Tag 和配置空间。分层模型的价值就在这时候体现出来它让问题有了归属也让排查有了方向。3. 事务层里最容易被忽略的 TLP 细节3.1 TLP 头部结构决定了包怎么走、怎么回事务层的一切操作最终体现为 TLPTransaction Layer Packet。一个 TLP 由三部分组成TLP 头、可选的数据载荷和可选的 ECRC 校验。驱动软件最常见接触的是 TLP 头里的几个字段Fmt/Type 决定包类型Tag 用来匹配请求和完成包Requester ID 是谁发的Completer ID 是谁回的。对于读操作事务层会生成 Non-Posted 的 Memory Read 请求。所谓 Non-Posted就是“必须等对方回完成包”这跟 Memory Write 这种 Posted 请求不同。写操作发完就结束不需要等确认读操作则必须用 Tag 把请求和完成包一一对应上。理解 Posted 和 Non-Posted 的区别对排查读超时、Tag 耗尽这类问题至关重要。很多人会忽略 Tag 的调度问题。驱动如果大量并发读请求每个请求都要占用一个 TagTag 被占完后续请求就只能在 FIFO 里等待。这种阻塞不是死锁但会表现为吞吐量上不去、延迟变大。你如果只盯着 DMA 描述符看永远找不到原因回到协议看 Tag 机制问题才浮出水面。3.2 配置请求与地址映射事务层里另一个重头戏是配置请求。PCIe 枚举过程中处理器通过配置读写 TLP 访问每个设备的配置空间配置空间的头分 Type0 和 Type1。Type0 头用于端点设备里面有 6 个 BAR 寄存器Type1 头用于桥设备里面是主总线号、次级总线号和下级总线号。为什么协议要分 Type0 和 Type1因为配置请求的路由方式不同。端点设备在配置空间里存放 BAR表示设备内部寄存器在系统内存中的映射位置桥设备则负责从上游总线过来的访问转发到下游总线。重读配置空间相关章节后我对“枚举就是给每个设备分配总线号、然后读取 BAR”这个流程有了更清晰的认识如果一个设备枚举不到无非是链路训练失败、配置请求路由不到、或者设备返回的 BAR 不合法逐一排除即可。实际调试中利用配置空间的 Capability 结构很重要。PCIe 的能力都藏在各种 Capability 里PCIe Capability 表示链路能力MSI/MSI-X Capability 表示中断能力Device Capability 里藏着 MPS 的最大值。每次配置链路参数之前都应该先读对应 Capability 结构确认硬件支持到哪一档再写配置。这个习惯能避免非常多的无效尝试。3.3 MPS 和 MRRS吞吐量提升的两个旋钮重读事务层我最想强调的是 MPS 和 MRRS 这两个参数。它们都出现在配置空间里名字也像但作用完全不同。MPS即 Max Payload Size是 TLP 里数据载荷的最大长度可选值一般是 128B、256B、512B、1024B、2048B甚至更大。MPS 决定一个 TLP 能装多少数据也决定接收端缓冲区要准备多大。MRRS即 Max Read Request Size是发起一次读请求时允许请求拆成的最大请求大小。注意MRRS 不是“完成包的最大大小”完成包的大小依然受 MPS 限制。为什么要区分这两个参数因为读请求会被拆成多个 TLP 发送而每个 TLP 都要带头部开销还要占用一次事务调度。假设驱动要读 4KB 数据MPS128B 时这 4KB 要拆成 32 个读请求 TLP并且收到 32 个完成 TLP把 MPS 提高到 256B请求数降到 16 个。包数量减少一半完成的头部开销和调度开销都会明显下降。在我遇到的带宽不足案例里默认协商结果常常是 MPS128B因为链路两端支持的能力最小值是 128B。这种协商出来的值特别保守跑点小流量没问题跑大块 DMA 就吃亏。修改 MPS 需要链路两端都支持更大的值并且最好在驱动初始化、尚未发起业务流量时设置。改完之后再做带宽测试数值提升往往非常明显。MRRS 同样值得关注。如果 MRRS 偏小即使 MPS 很大驱动发起的大块读请求也可能被拆成多个较小的读请求增加延迟和 Tag 占用。对大部分 DMA 类业务MRRS 可以适当调高比如 512B 或 1024B以降低读路径上的往返次数。但不要盲目调到比 RC 支持能力更大的值否则配置不生效甚至可能被硬件忽略。3.4 Tag、TC 和 VC并发度和优先级从哪里来事务层的并发能力不仅来自 DMA 描述符更来自 Tag、TCTraffic Class和 VCVirtual Channel机制。Tag 给每个未完成的读请求一个身份TC 给不同业务标注优先级类别VC 则是对应不同缓冲队列的资源。协议引入了多个 VC目的是让不同 TC 的业务可以在物理链路上“分车道”。比如高优先级控制消息和低优先级批量数据分别走不同 VC避免低优先级大流量把高优先级消息堵在队列里。对大多数普通设备而言只有 VC0 被启用复杂多 VC 的场景比较少。但你至少要理解当多个业务共享链路时Tag 和 TC 直接决定了请求调度的公平性和优先级看到传输延迟抖动不能只怪 DMA 通道。重读这部分之后我给自己的一个建议是设计驱动的命令队列时不要把未完成请求数一味调大Tag 资源是有限的。过度承诺并发只会让协议层在 Tag 调度上频繁阻塞最终表现为“队列满但硬件不干活”。4. 数据链路层决定链路可靠性的机械结构4.1 序列号加 LCRC 的确认重传闭环事务层把 TLP 交给数据链路层后数据链路层要做两件关键的事加序列号、算 LCRC。序列号是发送方和接收方对包进行编号的基础LCRC 是链路层自己的校验码。接收方收到 TLP 后先检查序列号是否连续再校验 LCRC 是否正确然后回送 Ack 或 Nak。如果接收方发现校验失败会返回 Nak发送方收到 Nak 后会把重传缓冲区里的包重新发送。如果允许延迟的 ACK 超时发送方也会主动重传。这套机制本质上跟 TCP 的重传很相似只不过它运行在硬件层面延迟是微秒级不需要操作系统参与。我当初读数据链路层时最震撼的认知是一个 TLP 只要被数据链路层接受理论上它对事务层就是“可靠”的。可靠性不是事务层实现的而是数据链路层在后台默默兜底。这意味着驱动里读到某个寄存器数据偶尔异常不能简单甩锅给“链路不稳定”而要从事务层语义去查因为数据链路层的重传已经把绝大多数链路错误消化掉了。4.2 基于信用的流控到底控制什么与有些人想象的“发送方发多少接收方就收多少”不同PCIe 用的是基于信用的流控机制。接收方在初始化阶段通过 FC 初始化 DLLP 告诉发送方自己各类型缓冲区能容纳多少数据之后每收到一个 TLP会归还对应的信用接收方缓存不够时发送方就不能再往这个方向发更多数据。为什么要用信用流控而不用简单的“停等一下再发”因为 PCIe 要求高吞吐、低延迟不可能像串口那样每发一包就等对方回应。信用机制让发送方根据对方缓冲区余量决定是否继续发避免缓冲溢出同时又能保持流水线满载。信用流控里最容易出现的问题是初始化没完成就开始发包或者 Tarffic Class 与 VC 的信用配置不对。一旦信用值错误设备会出现诡异的“只发一个包就停住等半天又继续”的现象。排查时如果发现事务层链路速率正常但数据吞吐呈锯齿状一定要回去查数据链路层的 FC 初始化 DLLP 和信用值而不是怀疑 DMA 引擎。4.3 可校正错误与重传对性能的实际影响重传虽然保证了可靠性但代价是性能。每发生一次重传发送方要停止新数据的发送把重传缓冲区里的旧包重新送一遍。如果链路质量差重传比例高有效吞吐会明显下跌甚至出现传输抖动。PCIe 把这类问题归类为可校正错误Correctable Error。设备状态寄存器里会有对应计数比如 Bad TLP、Replay Timer Timeout 等。我在稳定性和性能测试时习惯先记录这些计数器的初始值跑完以后再看增量。如果增量巨大说明物理层信号质量已经差到数据链路层频繁兜底了这时候改软件参数是没用的必须回到硬件设计、连接器、 PCB 走线、均衡去查。另外一个容易被忽略的点是数据链路层重传不会产生中断只有可校正错误计数在涨。很多驱动开发者在业务层观察不到异常但性能就是不达标。如果你知道去读这些计数器问题就清晰得多。5. 物理层链路训练其实是一场悄悄发生的握手5.1 LTSSM 状态机和链路训练物理层的核心逻辑都落在 LTSSMLink Training and Status State Machine上。链路从插入、上电开始依次经过 Detect、Polling、Configuration、L0再到各种低功耗和恢复状态。设备要正常工作必须跑在 L0 状态并且链路速度和宽度协商完成。链路训练靠发送 TS1/TS2 有序集来完成。训练过程中两端交换各自支持的最高速度、链路宽度、是否支持均衡等信息最终协商出一个双方都能稳定工作的参数。我在实际调试中见过最多的现象是链路在 Contact 或 Polling 阶段反复始终进不了 L0。这时候用示波器看差分信号不一定马上看出门道但读链路状态寄存器往往能提供明确线索要么速度协商不到目标代际要么宽度不够要么均衡训练失败。很多驱动工程师不关心物理层觉得那是硬件的事。但一个明显的反例是同一个 PCB改了一段走线之后链路从 Gen3 x4 稳定变成了 Gen3 x2原因就是其中两根差分对的信号完整性不达标。物理层的“稳定”不只是一个电子学问题它最终会反映到配置空间里的链路状态中软件可以通过读取这些状态寄存器来判断问题。5.2 编码方式变化带来的带宽换算重读物理层编码方式是一个无论如何都绕不开的话题。Gen1 和 Gen2 用 8b/10b 编码每传 10 bit 只有 8 bit 有效数据编码效率 80%。从 Gen3 开始改成 128b/130b每传 130 bit 里只有 128 bit 有效数据编码效率约 98.5%同时也让单通道速度可以大幅提升。换算单条通道的有效带宽时用链路速度乘以编码效率即可。Gen1 单 Lane 是 2.5GT/s乘以 80%大约 250MB/sGen2 是 5GT/s乘以 80%大约 500MB/sGen3 是 8GT/s乘以 128/130单 Lane 大约 984MB/sGen4 是 16GT/s大约 1.97GB/s。x4、x8、x16 再对应相乘。这里有一个常见的误区只算数据链路层结合 TLP 头的最终效率实际有效带宽还要再打折扣。因为链路不仅要传 TLP 数据还要定期或按需发送 DLLP、FC 更新、Ack/NakTS 有序集等协议开销事务层每个数据 TLP 都带头部Header 本身也要占用链路带宽。所以哪怕在理想情况下Gen3 x4 的理论单向带宽约 3.94GB/s实测能跑到 3.2~3.5GB/s 已经算很好。如果实测只有 1.5GB/s那就不是协议开销能解释的必须去查配置和业务模型。5.3 均衡、预加重和信号完整性链路速度越往上走信号完整性就越关键。Gen3 以后引入均衡机制发送端和接收端会在链路训练时通过一系列系数协商让信号经过 PCB、连接器后依然能被干净地接收。这些系数配置就是我们常说的 Preset、FOM、独立系数调整等。对工程排查看均衡的重要性体现在如果两端的均衡能力不匹配或者 PCB 损耗太大链路会反复进入均衡训练最终可能只在较低速度下稳定。表现为 Gen4 协商失败后掉到 Gen3Gen3 又不稳定再掉到 Gen2。这种多级降速现象靠驱动是修不好的只能通过硬件整改或均衡参数调整来改善。我个人在重读物理层时得到的最重要经验是看到“链路速度异常”不要急着去查 PHY 寄存器里的每一个位先看训练完成的最终状态比如当前速度、宽度、以及均衡完成标志。链路状态寄存器已经帮你汇总了结果顺着结果再逆向查训练过程效率会高很多。5.4 从寄存器看链路健康状态PCIe 物理层信息大多开放给了软件典型的是 PCIe Capability 里的 Link Capability 和 Link Status。在 Linux 环境下一条很常见的命令就能看到关键信息lspci -vvv -s 01:00.0 | grep -E LnkCap:|LnkSta:输出大致会显示LnkCap: Port #0, Speed 16GT/s, Width x4 LnkSta: Speed 8GT/s, Width x4如果 LnkCap 是 16GT/s 而 LnkSta 只有 8GT/s说明硬件支持更高速度但当前协商结果没到顶可能是链路对端不支持也可能是均衡训练失败后降速了。如果 LnkSta 的宽度只有 x2而设计是 x4说明至少有一对差分信号没有训练成功。这些信息比示波器更直接也是驱动工程师掉坑以后最应该第一眼去看的地方。再深度一点可以读取 AER 能力里的错误计数器确认可校正错误的增量。如果 Correctable Error 的 Bad TLP 或 Replay Timeout 计数暴涨物理层或链路质量就跑不掉。链路健康状态不是“通”或“不通”的二值判断而是长期稳定性和性能冗余的晴雨表。6. 把重读结果落回项目一个带宽排查复盘6.1 现象Gen3 x4 链路跑不出预期带宽实测环境是一块 Gen3 x4 的测试主控DMA 从对端读取 4KB 数据块持续传输。理论上单向带宽接近 4GB/s实际跑到 1.9GB/s 左右差距非常大。业务侧反复看了 DMA 描述符、队列深度、缓存对齐都没有明显问题。这个现象让我决定把 PCIe 协议的关键部分重新读一遍。我和同事当时的判断是链路本身应该正常因为物理层协商到了 Gen3 x4没有出现降速。问题大概率出在事务层要么是 MPS 太小导致包数量过多要么是完成包开销过大。顺着这个判断去看配置空间里的 MPS/MRRS 参数。6.2 定位过程先查链路参数再看包拆分读配置空间的结果显示MPS 被协商成了 128B。这个值来自链路两端支持能力的交集可能是对端设备的初始默认值太低也可能是 RC 侧能力值被保守设置。在这种配置下一次 4KB 的读请求会被拆成 32 个 TLP每个 TLP 的完成包都要带一个头部并且传输方向还要穿插很多 Ack/Nak 和 FC 更新实际带宽自然不高。同时MRRS 也只有 128B。这意味着即使 MPS 提高到 256B驱动的一次 4KB 读请求也不会自动变成 4KB TLP而是会被拆成多个 128B 的读请求代价是一样的。所以修改时MPS 和 MRRS 必须一起考虑。在准备修改之前我先确认两端 Device Capability 里支持的 MaxPayloadSize 和 MaxReadRequestSize确保设备支持 512B 甚至 1024B。如果不做这一步直接把寄存器改成目标值硬件可能忽略写入或者需要 reset 才能生效。6.3 修改 MPS/MRRS 后的变化将 MPS 提高到 512BMRRS 提高到 1024B重新跑同样的 DMA 压力测试吞吐量从 1.9GB/s 提升到 3.3GB/s 左右。3.3GB/s 已经接近 Gen3 x4 考虑协议开销后的合理上限说明传输链路的“包拆分开销”才是主要瓶颈。这个改动让我确认了一件事在 PCIe 性能排查里MPS 和 MRRS 是优先级非常高的一组配置参数。默认值不一定是最优值协议规范允许你根据实际业务选择更大的值。只要两端都支持修改后的收益立竿见影。同一次压力测试里我们还遇到了中断丢失的问题。一开始怀疑是中断控制器拥塞后来用调试手段读取 MSI-X 表项内容发现该中断向量的 Mask 位被写成了 1。追根溯源是初始化代码直接对整个 DWORD 写入配置值而不是做读-改-写操作把表项里的控制位覆盖成了屏蔽状态。重读协议中 MSI-X 表项结构之后修正了驱动写表方式中断丢失消失。这个问题和 TLP、物理层都没有关系纯粹是没读懂配置空间的细节。6.4 经验总结协议重读到底换来了什么重读 PCIe 协议最直接的价值是把你从“试参数碰运气”变成“看机制找答案”。MPS/MRRS 为什么会影响带宽Tag 为什么会塞住读请求链路状态寄存器为什么不等于物理层信号质量这些问题在协议里都有答案。只是答案分散在不同章节需要你用工程问题把线索串起来。我现在的做法是每逢复杂 PCIe 问题先明确故障在事务层、数据链路层还是物理层再决定去读协议的哪几个章节。读的时候不贪多只把和现场问题相关的那十几个字段、状态和机制彻底吃透然后立刻回到项目里验证。经过这么三轮五轮协议已经不是一本需要刻意背的书而是一张依附在工程记忆里的地图。如果你也有那种“明明照着参考代码写却怎么都不对”的 PCIe 问题不妨先把代码放到一边去协议原文里把机制重新读一遍——大部分答案都写在你不愿意翻的那一页上。