写这个系列之前,我一直在犹豫:事务层协议到底该怎么讲,才不至于让读者背完一堆字段名、一上板子还是不知道该看哪里。寄存器、TLP类型、路由方式、流量控制,每样拆开都能讲几个小时,但拼在一起总是散。后来我换了个思路——既然PCIe存在的意义就是让数据从A点到B点,那不如就跟着一个内存读请求完整走一趟。从CPU执行一条load指令开始,看它怎么变成一个TLP包,穿越RC、Switch、链路层、对端的EP,再带着数据原路返回。这一趟走完,事务层协议的那点骨架,基本就立住了。
这篇文章是系列的第二篇,主题是事务层,主角只有一个:一次内存读(MRd)的完整旅程。适合正在调PCIe驱动的Linux工程师、用FPGA做PCIe IP核的开发者,以及对PCIe协议停留在“知道概念、没串起来”阶段的人。
1. 故事的起点:一次内存读指令如何变成总线事务
1.1 别把MMIO当成“读内存”
先从一个很基本的认知开始:软件里写一句data = *(volatile uint32_t *)0x88001000;,这条指令本身经过CPU流水线之后,并不会真的走到某个内存颗粒里去取数。它要先去查MMU/TLB,把虚拟地址翻译成物理地址,然后由CPU核心把这个物理地址的读请求发给互连总线(比如Intel的Mesh或AMD的Infinity Fabric),最后送进Root Complex。
Root Complex(RC)拿到这块物理地址后,问自己一个问题:这个地址有没有被映射到某个PCIe设备上?怎么判断的?靠的是枚举阶段建立的地址映射。PCIe设备插入系统后,软件枚举(也就是网上常说的“pcie枚举过程”)会给每个设备的BAR(Base Address Register)分配一段地址空间。以Intel系统为例,RC内部维护着一张“出站地址窗口”(Outbound Window)对照表,物理地址落在哪个窗口,就说明要发往哪条下游链路、哪个设备。
所以MMIO读的本质是:CPU发起的读请求到达RC后,被翻译成一个PCIe事务,然后通过PCIe链路发出去。RC是CPU与PCIe树之间的翻译官,它把CPU能理解的地址翻译成PCIe世界能理解的TLP。
1.2 一张全局地图:RC、Switch与EP的角色分工
要把PCIe协议讲明白,拓扑概念绕不开。一个典型系统长这样:
Root Complex ├── 端口0:连接NIC(BDF 01:00.0) ├── 端口1:连接PCIe Switch上游口(BDF 02:00.0) │ ├── 下游口A:连接NVMe SSD(BDF 03:00.0) │ └── 下游口B:连接FPGA(BDF 04:00.0) └── 端口2:连接显卡(BDF 05:00.0)这里的BDF是Bus/Device/Function的缩写,枚举阶段由软件分配,是PCIe世界里设备的“门牌号”。RC本身也有BDF,不同的RC层设备、甚至RC的每个虚拟口都有独立的BDF,这决定了后续Completion包怎么回家。
在这个地图里,角色分三种。RC是发起者,负责代表CPU发请求;Endpoint(EP)是最终执行者,收到请求后访问自己的内部资源、返回结果;Switch是邮局,不做业务,只按路由规则把包转给正确的端口。请记住这个邮局的比喻,后面讲路由时会反复用到。
这里有个容易被忽略的点:EP不是只能被动接收。DMA场景里,是EP自己发起MRd去读系统内存,RC反而变成Completer。所以“发起者”和“完成者”不是固定身份,而是针对某一次事务而言的。本文主线是CPU读EP,所以RC是Requester,EP是Completer。
1.3 从CPU Load到TLP:请求从哪里诞生的
回到那行代码。假设地址0x8800_1000落在上图中FPGA的BAR0里,枚举时分配的范围是0x8800_0000到0x8800_FFFF。RC收到读请求后,查窗口表发现目标在端口1的Switch下游,于是开始构造一个Memory Read请求(MRd)。
构造一个TLP需要什么信息?目标地址(0x8800_1000)、请求主体的ID(RC自己的BDF,通常是00:00.0一类)、读多少字节(CPU这条load读4字节,即一个DW)、以及给这个“在途请求”分配一个唯一的Tag编号。
这就是事务层工作的精髓:把CPU侧简单的“读这块地址”翻译成PCIe总线侧标准化的、带完整上下文的TLP。上下文信息包括“谁发的、要读哪里、读多少、怎么标识这次请求”,没有这些,对端EP就算收到请求也无法答复,就算答复了RC也不知道该把数据送回哪里、匹配给哪个CPU指令。
2. 拆开TLP:事务层协议的基本句型
2.1 TLP的总体轮廓:头、载荷、尾巴
TLP是Transaction Layer Packet的缩写,是事务层交换信息的基本单元。一个TLP由三部分组成:TLP Prefix(可选)、TLP Header(固定必须有)、Data Payload(部分类型才有),以及可选的TLP Digest(用于端到端CRC校验)。
你可以把TLP想象成快递包裹:Header是面单,写着收件人和寄件人信息;Data Payload是货物本身;Digest是贴在上面的防伪标签。对于一次内存读的MRd请求来说,它只有一个Header——货物为空,因为你只是去问别人要东西,自己身上不用带东西。真正带数据的包头在回程的CplD里。
事务层和上下层的关系是:事务层负责组装/拆解TLP、做路由判断和流量控制;数据链路层在TLP前面加Sequence Number、在尾部加LCRC,并负责ACK/NACK重传;物理层再把数据流拆成一个个字符(Symbol),做编码、串行化发送。从语义角度,你只需要记住:TLP在发送方的事务层出生,在对端的事务层才算真正死亡,中间经过的链路层和物理层只负责“运输”,不修改TLP内容。
2.2 逐字段拆解MRd请求头
MRd请求的Header长度可以是3DW(32位地址)或4DW(64位地址)。一个DW是4字节,所以4DW就是16字节。本文例子用64位地址,所以是4DW头。Header最关键的是前两个DW和地址字段,我把关键字段拆开讲:
| 字段 | 长度 | 作用 | 例子值 |
|---|---|---|---|
| Fmt[1:0] | 2bit | 头长度和是否有载荷。MRd 64位是01表示4DW无数据 | 01 |
| Type[4:0] | 5bit | 事务类型。MRd是00000 | 00000 |
| TC[2:0] | 3bit | 流量类别,默认0,用于VC映射 | 000 |
| TD/EP | 2bit | Digest是否存在、包是否被标记为毒化 | 0/0 |
| Attr[1:0] | 2bit | 排序/一致性属性,如Relaxed Ordering、No Snoop | 00 |
| Length[9:0] | 10bit | 数据载荷长度,单位是DW。读4字节=1 | 1 |
| Requester ID[15:0] | 16bit | 请求方BDF,RC自己的门牌 | 0000 |
| Tag[7:0] | 8bit | 请求标签,用于匹配完成包 | 2A |
| First/Last DW BE | 8bit | 首尾DW的字节使能,表示读哪些字节 | F/F |
| Address[63:32] | 32bit | 目标地址高32位 | 0 |
| Address[31:2] | 30bit | 目标地址低30位(低2位恒为0,DW对齐) | 88001000 |
综合起来,这批请求就是:一个64位寻址的内存读请求,目标地址0x8800_1000,读取1个DW(4字节),由BDF 00:00.0的RC发起,Tag编号42(0x2A)。
关于Tag多说一句:Tag是8位,所以理论上一个Requester最多同时有256个“在途请求”。这个编号就是让回程的CplD能对上号用的身份证。对CPU读来说,RC要保证同一时刻每个未完成请求的Tag不重复,这个在后面章节展开。
2.3 一个具体的MRd例子
根据上面的字段,构造出来的一帧MRd报文(十六进制字节流)大致长这样:
20 00 00 01 00 00 2A 0F 88 00 10 00 00 00 00 00我来逐个字节对上。20是Fmt=01、Type=00000拼出来的(001 00000);00代表TC=0、TD=0、EP=0、Attr=00;00 01是Length,表示1个DW;00 00是Requester ID(BDF=00:00.0);2A是Tag;0F是首尾字节使能都有效(读4字节全要);后面的88 00 10 00 00 00 00 00就是64位地址0x00000000_88001000。
这里不纠结抓包工具的大小端显示差异,你只需要建立起“字段和值能对上”的直觉。真正上手调试时,看到一帧16字节的Header,能一眼认出这是不是MRd、目标地址是什么、Tag是多少,这个能力比背协议快得多。
注意一个细节:读请求的Length和字节使能是配合使用的。Length告诉对端“这次总共要读几个DW”,First/Last BE告诉对端“首尾DW里具体要哪几个字节”。当Length=1时,首尾BE都作用于同一个DW,所以通常都写成有效。
2.4 Length、MRRS与多DW读请求的关系
如果CPU一次读8字节,或者DMA一次读128字节,MRd的Length会相应变大。但是Length不能无限大,它要受MRRS(Max Read Request Size)约束。MRRS是PCIe设备能力寄存器里的一个参数,常见值是128B、256B、512B、4KB。它规定了单个读请求最多能请求多少数据,超过就要拆成多个请求。
举个例子:软件想从EP读取1KB数据,MRRS是256B,那么RC会拆成4个MRd请求,每个请求Length=64DW(256字节),分别分配不同的Tag。对端EP对这4个请求分别返回CplD,RC再把4个响应拼起来交给软件。
这个“拆”的过程对软件透明,但你在协议层抓包时看得清清楚楚。MRRS设得越大,读性能越好(请求数量少、Tag占用少、完成包数量少),但代价是EP侧内部的响应逻辑复杂度上升、缓冲区要求变大。很多FPGA开发者喜欢把MRRS设成128B来简化设计,代价就是吞吐上不去。这个矛盾后面还会再提。
3. 请求的下行之旅:地址路由与层层转发
3.1 三种路由方式:地址、ID、隐式
MRd请求构造好后,从RC的端口发出去。在PCIe总线世界里,一个包能不能准确到达目标,取决于路由方式。一共三种:
- 地址路由:Memory请求和IO请求用目标地址来路由,根据地址落在哪个设备的地址窗口决定走向。
- ID路由:Completion请求和Configuration请求用BDF来路由,根据Requester ID/Completer ID确定去向。
- 隐式路由:Message请求用系统约定好的特殊路由,比如广播给所有设备或只给RC,不需要具体地址和ID。
MRd属于Memory请求,所以用的是地址路由。这也是为什么读请求的Header里必须携带完整的目标地址——它是这一站一站路线的判断依据。
3.2 地址路由在Switch上的决策过程
假设RC的端口1连着Switch,而FPGA在Switch的下游口B。RC发出的MRd到达Switch的上游口后,Switch内部要做一次查表:这个地址是不是我某个下游端口下设备的窗口?
这个表是怎么来的?交换机内部有多个桥(Bridge),每个下游端口对应一个PCIe桥。枚举时,软件通过配置这些桥的Base/Limit寄存器,告诉Switch“哪个下游口负责哪一段地址区间”。比如上游口收到目标地址0x8800_1000的包,一查就知道要转到下游口B,因为FPGA的BAR0窗口覆盖了这个地址。
如果地址不在任何下游端口的窗口里,Switch会把它交给上游口继续往上走(对RC来说就是回到更高层级的RC端口)。如果一路都没有设备认领,RC会收到一个UR(Unsupported Request)错误,后面章节细说。
这个“查表转发”的过程和网络交换机的MAC地址表非常像,只是PCIe交换机转发的依据是地址窗口而不是MAC地址。理解了这一点,很多路由问题就变得很直观。
3.3 发送侧的TLP没被吃掉:链路层和物理层做了什么
MRd从RC的事务层“交给”数据链路层后,事务层的使命就先暂停了。数据链路层给TLP加上一个序列号(Sequence Number)和LCRC校验值,然后存到重传缓冲区里。发送过程中,如果对端的链路层发现LCRC错误,会回一个NACK,发送方就从缓冲区里取出来重发;如果收到ACK,说明包已经安全到达,缓冲区里的副本就可以释放。
再下一层是物理层。物理层把包加上一些定界符,编码后变成一串比特流,通过SerDes差分对高速发出去。这个过程涉及8b/10b或128b/130b编码、均衡(Equalization,就是热词里常出现的pcie均衡概念)、时钟恢复等。这些话题是链路层/物理层的主角,与本文主线无关,就不展开了。
有一个值得记住的点:数据链路层只保证“包到了对端链路层”,不感知TLP语义。它不知道这个包是读请求还是写数据,也不知道目标是哪个EP。TLP只要过了LCRC校验,就原样交给对端事务层,由事务层来做真正的路由裁决。
3.4 接收端EP的解析流程与BAR匹配
MRd到达FPGA的PCIe硬核后,事务层开始验收。流程可以拆成几步:
第一步,判断这个包是不是发给自己的。FPGA的PCIe IP核对地址路由要做一次BAR匹配:地址0x8800_1000落在BAR0(0x8800_0000~0x8800_FFFF)范围内,匹配成功,接收;如果落在所有BAR之外,事务层必须立即生成一个错误完成包(UR)送回给请求方,绝不能默默丢弃。
第二步,检查流控信用。FPGA内部维护着接收缓冲区信用值,如果MRd所需的非Posted信用不够,事务层会把这个包挂起,等对端发来信用更新。这一步是防缓冲溢出的闸门。
第三步,把请求转成内部总线操作。FPGA一般会在PCIe硬核后面接一个AXI桥(Xilinx/Intel的PCIe IP都这么做),把MRd翻译成AXI总线的读地址通道请求。从地址偏移量和BAR基地址计算出内部寄存器/内存的偏移,然后驱动内部逻辑把数据准备好。
这一步对FPGA开发来说是最容易出现问题的环节——PCIe协议本身没问题,但AXI桥的地址映射、读响应时序、跨时钟域处理都可能让事务层永远等不到数据。
3.5 如果地址谁都不认:UR和CA
EP内部访问目标资源也可能失败。比如地址映射到了EP内部一段没有实际存储器的保留地址,AXI端返回错误。这时PCIe事务层会生成一个**Completer Abort(CA)**完成包,告诉请求方“我认了这个地址,但我自己执行失败了”。
UR和CA是两种不同的错误语义:UR是“没设备认领这个请求”,CA是“有设备认领但执行失败”。板卡调试时看到这两种错误,排查方向完全不同。UR优先查地址映射、路由窗口、BAR配置;CA优先查EP内部逻辑、AXI访问状态。
4. Completion的回程:请求的后半段才是最复杂的
4.1 EP如何装配一个CplD
FPGA内部读逻辑把4字节数据取回来后,会交给PCIe事务层,由事务层生成一个**Completion with Data(CplD)**包。CplD的Header也是3DW,关键字段如下:
| 字段 | 说明 |
|---|---|
| Fmt/Type | CplD是010 01010,表示3DW头带数据 |
| TC | 必须和发起请求的TC一致 |
| Completer ID | 填写EP自己的BDF(如04:00.0) |
| Status | 成功是000,UR是001,CA是010 |
| Tag | 必须原样回填请求里的Tag |
| Byte Count | 本次完成包携带的字节数 |
| Lower Address | 本次返回数据在请求起始地址中的低7位偏移 |
注意一个命名坑:Cpl Header里那个字段虽然叫“Requester ID”,但对Completion来说,它填写的是Completer(也就是EP自己)的BDF。头一次看协议的人很容易被这个字段名带偏。这里要填你的门牌号,不是RC的门牌号。
CplD的载荷就是读取到的数据,按Length字段指示的DW数量搬运。一次MRd如果可以返回全部数据,就一个CplD完成;如果数据量太大,EP可以拆成多个CplD,每个带有自己的Byte Count和Lower Address,方便RC重组。
4.2 ID路由:Completion不靠地址回家
CplD没有地址字段,所以它不能用地址路由。它是靠ID路由回家的:Switch在收到CplD时,读取Header里的Requester ID(也就是RC的BDF),查ID路由表,看这个ID属于哪个端口。
举个例子:如果RC的Requester ID是00:00.0,而CplD从FPGA返回到达Switch的下游口B,Switch一查ID路由表,发现00:00.0在上游方向,就把包转到上游口,送到RC的端口1。RC的端口1再根据ID和内部端口映射,把CplD交给负责CPU读请求的那个RC部件。
这解释了为什么Enumuration阶段BDF分配如此重要:BDF不只是“门牌号”,它本身就是路由表的一部分。如果枚举时某个设备的BDF分配异常,或者驱动里读到了虚假的Device ID,那么后续的Completion路由就会跟着出错。
另一个关键点:当系统里RC有多个端口时,RC侧自己也必须维护一个“ID→端口”的映射表,否则CplD从哪个口回来、该交给哪个CPU核,都有可能放错位置。
4.3 RC侧的解包:Tag索引与“在途请求表”
CplD到达RC事务层后,接下来就是匹配过程。RC内部维护着一张在途请求表(Outstanding Request Table),每个未完成的读请求占一个表项,表项的索引就是Tag。
RC拿到CplD后,先检查Tag,查表找到对应请求;再检查Status和Byte Count,把数据整理好,转成CPU总线能识别的读返回数据。对于多CplD的读请求(比如256B的MRd拆成两个128B的CplD返回),RC要一直等齐所有CplD,才能结束这个表项、释放Tag。
释放Tag很重要:Tag不释放,这个“槽位”就永远占着,后续新请求没有Tag可用,吞吐就会掉到地板。很多性能问题的根因就在“Tag池被耗尽”。所以排除吞吐问题的时候,除了调MRRS/MPS,记得看一眼在途请求深度有没有打满。
4.4 拆包返回:为什么一个读请求可能拆成多个CplD
同一个MRd请求,EP可能发回多个CplD,原因有几种:
- 数据量超过MPS(Max Payload Size)。MPS规定了单个TLP最多携带多少字节载荷,读写都受此约束。如果请求了256B,而MPS只有128B,EP必须拆成两个CplD。
- EP内部生成数据的时间不连续,比如从慢速接口取数,可以先返回一部分,之后再返回剩余部分。
- 设备支持“任意字节数完成”(不是一下子返回全部),这是允许的,只要最终所有CplD的Byte Count合计等于请求长度。
这些CplD可以乱序到达吗?可以。因为每个CplD都带着Lower Address和Byte Count,RC完全可以根据这两个字段重组。这也是为什么协议里专门给CplD设计了这些字段,而不是简单地按顺序堆数据。
多个不同请求的CplD返回顺序也可以和请求顺序不一致。RC靠Tag区分是哪个请求,靠Byte Count重组顺序,不需要对端回来得整整齐齐。
4.5 错误完成的几种面孔(UR/CA/CRS速查)
Completion Status字段只有3位,常见值如下:
| Status值 | 名称 | 含义 |
|---|---|---|
| 000 | SC | 成功完成 |
| 001 | UR | 不支持的请求,地址无人认领 |
| 010 | CA | 完成者中止,EP自己执行失败 |
| 011 | CRS | 配置请求重试状态,EP还没准备好 |
CRS比较特殊,它只用于配置请求(读配置空间早期EP固件还在初始化时的情形)。RC收到CRS后可能重试,重试到超时后放弃。热词里提到的“pcie热插拔功能”就和CRS有密切关系:板卡刚插入、链路还在训练、EP的配置逻辑还没起来时,CRS是保护EP不被过早访问的机制。但对于内存读请求来说,标准规定不允许返回CRS,一般都是UR/CA。
拿到一个错误完成包后,RC事务层会把它记录到AER错误状态里(如果使能了Advanced Error Reporting),CPU则可能收到一个Machine Check或NMI。Linux下调试时,dmesg里看到“PCIE Bus Error: severity=Uncorrected, Unsupported Request”基本就是UR。
5. 看不见的交通灯:Tag、流控与并发限制
5.1 Tag是飞行中请求的身份证
前面已经反复强调Tag的作用,这里把它系统化。Tag是一个8位编号,RC每发一个新读请求,就占一个Tag。在请求得到全部完成包之前,这个Tag不能复用。所以RC的并发度(Outstanding Requests)上限是256。
注意,Tag分为非Posted请求和Completion两类信用空间。实际控制芯片中,RC可能限制Tag池更小,比如某些Root Complex只支持几十个在途请求。这就是为什么DMA性能高不上去的瓶颈有时在CPU侧而不是设备侧。
对于FPGA的EP设计来说,Tag处理是个隐藏考点:EP收到MRd时,除了在CplD中回填Tag之外,不需要为Tag做什么保留——RC才是Tag的所有者。但如果EP自己作为Requester发起DMA读,它就需要管理自己的Tag池了。
5.2 流控:信用额度维持的秩序
流控(Flow Control,FC)是事务层另一个核心机制。可以把它理解成一条双向的“备菜额度”:发送方每发一个TLP,就要消耗接收方给它预留的一个仓位;接收方处理完一个TLP后,通过UpdateFC DLLP把额度返还。
流控按事务类型独立管理,分三组:Posted(P)、Non-Posted(NP)、Completion(CPL)。每一组又分成Header信用和数据信用。MRd是Non-Posted请求,消耗NP Header信用;CplD是Completion,消耗CPL Header和CPL Data信用。
链接训练完成后,两端通过InitFC1/InitFC2 DLLP交换信用上限,之后正常收发运行时双方都按额度来。信用一旦耗尽,发送方必须等,不能硬发。这就是为什么打高吞吐时会看到很多UpdateFC包来回飞——它们在补充“仓位”。
流控死锁是理论上的经典问题:假如RC发出大量请求把EP的信用耗尽,而EP必须靠发送CplD才能腾出信用,同时CplD又被RC的接收窗口堵住,两边就互相等。PCIe从协议层面和驱动约束层面都做了设计防止这种死锁,工程师一般不用操心,但调FPGA IP时如果看到“传输卡死在FC状态”,就要往这个方向想。
5.3 Posted、Non-Posted与Completion是三条独立车道
PCIe事务按是否需要响应分成三类,这是理解事务层行为框架的基础:
- Posted事务:发完即走,不需要对端回复。典型是Memory Write(MWr)。因为不需要回确认,Post的信用消耗小、效率高,适合大流量写数据。
- Non-Posted事务:需要对端回复。典型是MRd和IO请求。
- Completion事务:是Non-Posted的响应,典型是Cpl/CplD。
这三个类别在流控和排序上是独立的。MWr不会被MRd阻塞?在默认排序规则下,posted请求可以越过前面更早的non-posted请求(只要不发生一致性冲突)。这听起来违反直觉,但这是PCIe为了不让写操作被慢速读拖住而特意设计的。CplD也有排序规则,比如它不能无限期被后续的Posted Write超越,其细节在PCIe Base Spec的Ordering章节里定义得非常细。
我把排序规则的核心说清楚:保证的是数据一致性。一个设备发出MWr之后又发一个MRd,如果MWr还没到,MRd先到,对端读到旧数据,这显然是错的。协议通过给每一类事务定义“能否越过其他事务”的规则来避免这种情况。调试时遇到读回脏数据的诡异问题,别只查逻辑,先把排序规则捋一遍。
5.4 VC与TC:给流量分专用的通道
Virtual Channel(VC)是流控的物理载体,Traffic Class(TC)是包的优先级标签。默认所有包都是TC0,走VC0。TC0/VC0之外的VC需要显式配置和初始化。
为什么有VC?因为PCIe想让不同类型流量物理隔离。比如视频流的高带宽低时延数据可以和普通CPU读写走不同VC,互不干扰。配置多VC要求每个VC做流控初始化、仲裁配置,复杂度直线上升,大多数系统只用VC0。
对常见的主机CPU读写场景,TC/VC只是背景知识。但如果是做交换机/Switch芯片的,或者嵌入式环境要做确定性时延的,VC就是必修课了。本文点到为止。
6. 现场排查:内存读异常怎么抓
6.1 现象一:读回来全FF,像设备不存在
板卡调试最经典的场景:CPU去读BAR空间,读回来全是0xFFFFFFFF,或者直接报UR。
排查思路按下述顺序来:
- 先确认设备有没有被枚举成功:
lspci -vvv能不能看到设备?BDF对不对? - 确认BAR有没有被分配地址:读配置空间里的BAR寄存器,是不是非零值?BAR0的值是否和你期望的地址段一致?
- 确认访问地址是否落在BAR范围内:用
setpci直接读BAR,然后手动构造一个落在BAR地址范围内的访问。 - 确认RC侧有没有对应的出站映射:对带自定义RC的嵌入式平台,这一步特别容易漏。
如果在Linux下看到“Unsupported Request”,UOS错误日志里通常能抓到端倪。用debugfs的aer或lspci -xxx看配置空间,能读到AER状态寄存器里的错误类型。UR的根源九成在地址映射,路径不对才是重点。
6.2 现象二:EP收到请求但一直没完成
如果EP侧逻辑分析仪/ILA里已经看到MRd进来了,地址也对,但就是没有CplD发回去,问题基本出在三个方面:
- EP内部AXI侧卡住:读请求转成AXI后,读数据通道永远没有响应。检查AXI的ready/handshake、检查被读寄存器/内存的复位状态。
- 流控信用耗尽:EP的Completion信用没有初始化,或者被大量CplD占满。查InitFC寄存器的值,看RC有没有分配信用。
- Tag/状态机死锁:EP的状态机设计不良,比如要求所有CplD必须按特定顺序返回但RC不配合。
这里有个实用的建议:用Xilinx/Intel PCIe IP核时,强烈建议在IP核的事务层入口拉出AXI接口进行观测。把MRd变成一笔AXI读请求的过程完全在AXI侧可见,问题定位速度能快几倍。
6.3 现象三:Switch拓扑下路由失效
系统里挂了Switch后,问题模式会变多。典型的一种是:直接挂在RC端口上的设备读写正常,但Switch下游的设备读不到。
排查重点换到路由表上。Switch上游口的Bridge Base/Limit寄存器决定哪个地址范围转给下游;ID路由表决定Completion回家方向。如果Switch固件或枚举逻辑没配好这些窗口,Crossing的包就会走错路。
另一个常见问题是MPS/MRRS不一致。如果一个设备的MPS只有128B,而RC发来的MRd长度是256B(MRRS=256B),EP会因为“请求长度超过自己能处理的范围”直接回UR或直接丢弃。检查两端的MPS/MRRS协商结果,确保它们是一致的,这是Switch环境下最容易忽略的坑。
6.4 内存读故障速查表
| 现象 | 可能原因 | 优先排查项 |
|---|---|---|
| 读回全FF | 地址未映射/设备未枚举 | lspci、配置空间BAR值 |
| UR错误完成 | 地址路由失败/请求类型不支持 | RC出站窗口、EP BAR匹配 |
| CA错误完成 | EP内部访问失败 | AXI侧逻辑、EP内部地址空间 |
| 读超时无完成 | 信用不足/内部忙/死锁 | FC寄存器、ILA观测AXI |
| 读性能极低 | Tag池太少/MRRS太小 | 在途请求深度、MRRS/MPS |
| Switch下读失败 | 路由窗口未配对 | Bridge Base/Limit寄存器 |
6.5 给FPGA开发者和驱动工程师的调试建议
如果你是FPGA工程师,两条经验最值得记住:
第一条,把PCIe事务层的调试关口前移到AXI侧。不要在物理层或链路层面纠结太久,事务层做完BAR匹配后基本都会转成AXI/Avalon接口。AXI上看到什么地址、什么长度、什么响应,几乎等价于协议层看到的TLP,但调试起来直观得多。ILA抓AXI总线比抓PCIe总线简单,这是无数项目验证过的路径。
第二条,Completion的构造顺序不要想当然。有些IP核允许CplD乱序返回,有些IP核内部强制顺序处理。如果是自己写事务层状态机,强烈建议先用最简单的“顺序处理、一次返回全部数据”模式跑通,再考虑拆分和乱序优化。先把正确性做出来,性能是后面的事。
驱动工程师则建议从Linux的lspci -vvv、setpci、/sys/bus/pci/devices/*/config这些基础工具入手,配合AER日志。遇到错误不要先怀疑协议栈,先把地址、BAR、路由、MPS/MRRS四个维度查一遍,能解决九成问题。
写在最后
我最初学PCIe事务层时犯的最大错误,是把注意力全部放在TLP类型和字段上,背得很熟,但完全不知道一个包从发出到返回,中间每一步是怎么串起来的。后来开始跟着一个读请求走完整条路——从CPU地址到RC查表、到Switch路由、到EP读写、到CplD返回、到RC靠Tag匹配——整个协议才真正在脑子里立体起来。这篇写得比较长,就是把这条路走完整、走清楚。
下一篇打算讲Memory Write的完整旅程,或者反过来,从EP发起DMA读主机内存的角度把角色互换讲一遍。这两个方向都值得仔细展开。如果你在实际调板时也踩过什么玄学坑,比如MPS不匹配导致的诡异UR、Tag池打满后的性能雪崩,欢迎来交流。调试经验这东西,多聊一次,就少一个半夜对着逻辑分析仪发愣的人。