2、PCIe事务层详解:TLP包格式、地址路由与完成者路由、存储器读写与IO读写事务
好,咱们今天来啃一块硬骨头——PCIe事务层。说实话,很多工程师做了好几年PCIe设计,对物理层和数据链路层门儿清,但一到事务层就有点含糊。我当年也是这样,总觉得TLP包格式太复杂,记不住。后来被一个bug折磨了三天,才真正把这块吃透。
事务层是PCIe协议的核心。它定义了数据怎么打包、怎么路由、怎么回应。说白了,你的CPU要读一个外设的寄存器,最终就是靠事务层来完成的。
2.1 TLP包格式——你得看懂这张图
TLP,全称Transaction Layer Packet。它由三部分组成:头部(Header)、数据负载(Data Payload)和可选的摘要(Digest)。
我习惯把TLP想象成一封信:头部就是信封上的地址和说明,数据负载就是信纸,摘要就是信封上的防伪标签。
| 字段 | 长度 | 说明 |
|---|---|---|
| Header | 12B或16B或20B | 包含事务类型、路由信息、长度、标签等 |
| Data Payload | 0~4096B | 实际要传输的数据,必须按4B对齐 |
| Digest (ECRC) | 4B(可选) | 端到端CRC校验,我一般建议开启 |
头部格式根据事务类型不同而不同。最常见的三种:存储器读写(MemRd/MemWr)、IO读写(IORd/IOWr)、完成报文(Cpl/CplD)。
重点:TLP头部的前两个字节(Byte0和Byte1)包含了Format和Type字段。这两个字段决定了整个TLP的解析方式。我调试时第一件事就是抓这两个字节,看它是不是我期望的事务类型。
2.2 地址路由与完成者路由——数据怎么找到路?
PCIe有三种路由方式:地址路由、ID路由和隐式路由。咱们重点说前两个。
2.2.1 地址路由
地址路由用于存储器事务和IO事务。说白了,就是根据目标地址来决定这个TLP该发给谁。
每个PCIe设备都有一个或多个Base Address Register(BAR)。BAR里存放了设备在系统地址空间中的基地址和大小。当TLP经过一个Switch时,Switch会检查TLP的地址是否落在某个下游端口的BAR范围内。如果匹配,就转发下去;不匹配,就继续往上游走。
我曾经遇到过一个坑:某个FPGA设计的BAR大小设置错了,导致CPU发起的存储器读请求永远到不了FPGA内部。抓了三天波形才发现,BAR里写的size是1MB,但实际解码逻辑只响应了64KB地址范围。嗯,从那以后我每次配BAR都会反复核对。
我的习惯:在FPGA逻辑里加一个地址命中计数器。每当地址命中BAR范围时,计数器加1。调试时看这个计数器有没有变化,能快速判断地址路由是否正常。
2.2.2 完成者路由
完成者路由用于完成报文(Completion)。你想想看,CPU发了一个读请求,数据从目标设备返回时,怎么知道该回给谁?
答案就在TLP头部的Requester ID和Tag字段里。
- Requester ID:发起请求的设备的Bus/Device/Function号
- Tag:请求的编号,用于区分同一发起者的多个未完成请求
当目标设备返回完成报文时,会把Requester ID和Tag原封不动地复制到完成报文的头部。这样,发起者就能知道这个完成报文对应的是哪个请求。
我见过一个设计,Tag管理做得一塌糊涂。每次发请求都用同一个Tag,结果多个请求的完成报文混在一起,数据全乱了。后来我建议他们用环形缓冲区管理Tag,每个请求分配一个唯一Tag,问题就解决了。
2.3 存储器读写事务——最常用的操作
存储器读写是PCIe里最频繁的事务。CPU访问DDR、GPU读取纹理、网卡搬运数据包,本质上都是存储器读写。
2.3.1 存储器读(Memory Read)
存储器读请求的TLP头部格式如下:
Byte0: Fmt=001b (3DW头部, 无数据) / Type=00000b (MemRd)
Byte1: 其他属性
Byte2-3: Requestor ID
Byte4-5: Tag + Last DW BE + First DW BE
Byte6-7: 保留
Byte8-11: 目标地址[31:0] (32位地址)
Byte12-15: 目标地址[63:32] (64位地址, 可选)
发起者发出读请求后,目标设备会返回一个或多个完成报文(Completion with Data)。每个完成报文最多携带128B数据(Max Payload Size的限制)。
举个例子:CPU要读地址0x8000_0000处的256B数据。它会发一个存储器读TLP,地址填0x8000_0000,长度填256B。目标设备收到后,如果Max Payload Size是128B,就会返回两个完成报文,每个带128B数据。
注意:存储器读请求本身不带数据。数据是在完成报文里返回的。所以发起者必须有一个机制来关联请求和完成报文。这个机制就是前面说的Tag。
2.3.2 存储器写(Memory Write)
存储器写就简单多了。它不需要完成报文。发起者发出写TLP,目标设备收到后直接写入,然后默默结束。
为什么写不需要回应?因为PCIe是Posted事务。你想想看,如果每次写都要等对方确认,那性能就太差了。PCIe的设计哲学是:写操作可以"发了就不管",读操作才需要等待回应。
写TLP的头部和读类似,但Fmt字段不同:
Byte0: Fmt=010b (3DW头部, 有数据) / Type=00000b (MemWr)
数据负载紧跟在头部后面。注意,数据必须按4B对齐,长度必须是4B的整数倍。
我调试时经常遇到一个问题:写操作明明发了,但目标设备没收到。后来发现是地址对齐问题。PCIe要求存储器写的起始地址必须与数据宽度对齐。比如你写8B数据,起始地址必须是8的倍数。否则TLP会被当作Malformed TLP丢弃。
2.4 IO读写事务——老古董但还得支持
IO读写事务是PCIe为了兼容PCI遗留设备而保留的。说白了,就是访问IO空间用的。
IO空间和存储器空间是两套独立的地址空间。IO空间通常用于访问配置寄存器、控制寄存器等。在x86系统里,IO空间通过IN/OUT指令访问。
IO读写的TLP格式和存储器读写类似,但Type字段不同:
- IO读:Type=00010b
- IO写:Type=00011b
IO事务有一个重要特点:它必须使用完成报文。即使是IO写,目标设备也要返回一个不带数据的完成报文(Cpl),告诉发起者"我写完了"。
为什么会这样?因为IO操作通常涉及设备状态变化,发起者需要知道操作已经完成。比如你写一个设备的控制寄存器来启动DMA,如果不知道写操作是否完成,你就没法判断DMA是否已经开始。
避坑指南:我在设计FPGA的PCIe核时,曾经把IO写当作Posted事务处理,没返回完成报文。结果CPU一直卡在IO写指令上,整个系统都hang住了。后来查PCIe规范才发现,IO写是Non-Posted事务,必须返回完成报文。这个教训让我记住了:IO事务永远不是Posted。
2.5 实战经验总结
好了,说了这么多,我总结几个关键点:
- TLP头部是灵魂——调试时先看Fmt和Type,确定事务类型
- 地址路由靠BAR——BAR配置错了,数据就到不了目的地
- 完成者路由靠Tag——Tag管理不好,数据就会乱套
- 存储器写是Posted——发了就不管,性能好但调试难
- IO事务是Non-Posted——必须等完成报文,别偷懒
下一章咱们会深入数据链路层,看看TLP是怎么在链路上传输的。到时候我会分享一个我调试链路层CRC错误的真实案例,保证让你少走弯路。