拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PCIe事务层破译:一次内存读请求的完整旅程

PCIe事务层破译:一次内存读请求的完整旅程

写这个系列之前,我一直在犹豫:事务层协议到底该怎么讲,才不至于让读者背完一堆字段名、一上板子还是不知道该看哪里。寄存器、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是0000000000
TC[2:0]3bit流量类别,默认0,用于VC映射000
TD/EP2bitDigest是否存在、包是否被标记为毒化0/0
Attr[1:0]2bit排序/一致性属性,如Relaxed Ordering、No Snoop00
Length[9:0]10bit数据载荷长度,单位是DW。读4字节=11
Requester ID[15:0]16bit请求方BDF,RC自己的门牌0000
Tag[7:0]8bit请求标签,用于匹配完成包2A
First/Last DW BE8bit首尾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/TypeCplD是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值名称含义
000SC成功完成
001UR不支持的请求,地址无人认领
010CA完成者中止,EP自己执行失败
011CRS配置请求重试状态,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池打满后的性能雪崩,欢迎来交流。调试经验这东西,多聊一次,就少一个半夜对着逻辑分析仪发愣的人。

返回列表