第2章:PCIe事务层详解——TLP包格式与事务类型
各位同学,今天我们来啃一块硬骨头——PCIe的事务层。说实话,刚接触PCIe时,我也被TLP那一堆字段搞得头晕。但后来我发现,只要抓住几个关键点,这东西其实没那么复杂。
事务层是PCIe协议栈的最上层,它直接和软件打交道。你写的驱动程序,最终都会转化成TLP包在链路上传输。所以,理解TLP,你就理解了PCIe的灵魂。
2.1 TLP包格式:Header、Data与ECRC
一个完整的TLP包,由三部分组成:Header(头部)、Data Payload(数据负载)和可选的ECRC(端到端CRC)。
Header是必须的,它告诉接收方:这个包是干什么的。Data Payload是可选的,比如配置读写就没有数据负载。ECRC也是可选的,但我个人建议——永远开启ECRC。
为什么我这么强调ECRC?
我在一个高速数据采集项目中,遇到过数据偶尔出错的情况。链路层的LCRC能检测到传输错误,但无法覆盖整个路径。开启ECRC后,从发送端到接收端的整个路径都有了保护。那次排查花了我整整三天,最后发现是ECRC没使能。嗯,从那以后,ECRC就成了我的默认配置。
Header的格式取决于TLP的类型。但所有Header都有一些公共字段:
| 字段 | 位数 | 说明 |
|---|---|---|
| Fmt | 2 bits | 格式类型,决定Header长度(3DW或4DW)以及是否有数据负载 |
| Type | 5 bits | 事务类型,比如存储器读写、IO读写、配置读写、消息 |
| TC | 3 bits | 流量类别,用于QoS |
| TD | 1 bit | 表示是否包含ECRC |
| EP | 1 bit | 毒包标志,标记数据已损坏 |
| Attr | 2 bits | 属性,控制排序和缓存策略 |
| Length | 10 bits | 数据负载长度,单位是DW(4字节) |
你想想看,这10个bit的Length字段,最大能表示1023个DW,也就是4092字节。但实际中,Max Payload Size通常只有128、256或512字节。为什么?因为太大的包会增加延迟,而且出错重传的成本太高。
2.2 存储器读写事务
这是最常用的事务类型。说白了,就是CPU直接读写设备的某个地址空间。比如GPU的显存、网卡的FIFO,都是通过存储器事务访问的。
存储器读事务的Header格式如下:
DW0: [Fmt=2'b10][Type=5'b00000][Rsvd][TC][Rsvd][TD][EP][Attr][Rsvd]
DW1: [Length]
DW2: [Requester ID][Tag]
DW3: [Address[31:2]][Rsvd]
// 如果是64位地址,还有DW4: [Address[63:32]]
这里有个细节:地址的低2位被忽略了。因为PCIe要求地址按DW对齐。我见过有人写驱动时没注意这个,结果读回来的数据全是乱的。
避坑指南
我曾经调试一个FPGA加速卡,发现CPU读回来的数据总是错位。查了两天,最后发现是FPGA端的地址译码逻辑没处理低2位。记住:PCIe地址的低2位是无效的,硬件会自动忽略它们。
存储器写事务的Header和读类似,但多了个写数据阶段。写事务不需要等待完成,属于Posted事务。这意味着发送端发出后就不管了,不会收到确认。所以写操作的延迟更低,但可靠性也差一些。
2.3 IO读写事务
IO事务是PCI时代的遗产。在x86平台上,IO空间只有64KB,而且访问速度比存储器慢得多。现代设备基本都用MMIO(存储器映射IO)替代了IO空间。
但有些老设备,比如传统的串口、并口,还是用IO空间。所以PCIe必须向后兼容。
IO读写事务的Header和存储器事务很像,但Type字段不同。IO读是Non-Posted事务,必须等待完成包返回。IO写也是Non-Posted,这点和存储器写不一样。
注意:IO事务的性能很差
每次IO访问都要经过:发送请求→设备处理→返回完成。这个往返延迟在PCIe上大约几百纳秒。如果你在中断处理里频繁做IO读,系统性能会急剧下降。我建议:能用MMIO就别用IO。
2.4 配置读写事务
配置事务用于访问设备的配置空间。每个PCIe设备都有256字节的标准配置空间,扩展配置空间可达4KB。配置空间里放着Vendor ID、Device ID、BAR地址等重要信息。
配置读写的Header格式比较特殊:
DW0: [Fmt][Type=5'b00100/00101][Rsvd][TC][Rsvd][TD][EP][Attr][Rsvd]
DW1: [Length=1]
DW2: [Requester ID][Tag]
DW3: [Bus][Device][Function][Register][Rsvd]
注意Length字段固定为1,因为配置读写每次只能操作一个DW(4字节)。这也是为什么配置空间初始化很慢的原因——你要一个一个寄存器去读。
我记得有一次,系统启动时PCIe设备枚举特别慢。查了半天,发现是BIOS在枚举时用了配置读,但没开启ECRC。每次读都要重试好几次。开启ECRC后,枚举时间从5秒降到了0.5秒。
2.5 消息事务
消息事务是PCIe引入的新特性。它本质上是一个没有地址的写操作。消息可以用于中断、错误报告、电源管理等。
最常见的消息是MSI/MSI-X中断。传统PCI用边带信号传递中断,PCIe则通过消息事务来实现。设备写一个特定的消息到CPU的中断控制器,触发中断。
消息事务的Header格式:
DW0: [Fmt=2'b01][Type=5'b10000][Rsvd][TC][Rsvd][TD][EP][Attr][Rsvd]
DW1: [Length=0]
DW2: [Requester ID][Tag]
DW3: [Message Code][Rsvd]
Message Code字段告诉接收方这是什么消息。比如:
- 0x10:INTx中断断言
- 0x11:INTx中断解除
- 0x20-0x2F:电源管理消息
- 0x30-0x3F:错误消息
关于MSI的一个坑
我曾经遇到一个网卡,在压力测试下偶尔丢中断。排查发现,设备发送MSI消息时,CPU还没处理完上一个中断,新的MSI就被覆盖了。解决方案是改用MSI-X,它支持多个中断向量,每个向量有独立的地址和数据。从那以后,我设计新系统时一律用MSI-X。
小结
这一章我们讲了TLP包的格式和五种事务类型。你可能会觉得细节太多,但记住一句话:TLP就是PCIe世界的信封。Header是收件人地址,Data是信件内容,ECRC是防伪标签。不同的信封(存储器、IO、配置、消息)有不同的格式,但核心逻辑是一样的。
下一章,我们会深入数据链路层,看看TLP包是怎么被封装成数据链路层包的。到时候你会发现,事务层只是冰山一角。