PCIe TLP 解析入门:抓包之后,怎么读懂一个数据包
PCIe 链路上跑的 AI 推理数据、模型权重、SSD 读写、网卡收包,拆到最底层都是一串 TLP(Transaction Layer Packet)。本文讲清一个 TLP 长什么样、字段怎么读、四类核心事务怎么区分,最后给出一个能跑通的解析器。
📚 基本概念速读
| 名称 | 定义 | 记忆点 |
|---|---|---|
| TLP | Transaction Layer Packet,事务层包 | PCIe 上数据传输的基本单位 |
| Header | TLP 头部,12 或 16 字节 | 物流单:谁发的、发给谁、访问哪、多长 |
| Payload | 数据载荷 | 真正的货 |
| MWr / MRd | Memory Write / Memory Read Request | 写请求 / 读请求 |
| Cpl / CplD | Completion(不带 / 带数据) | 读请求的回应 |
| Requester ID | 发起者的身份编号 | Bus + Device + Function,共 16 位 |
🔌 先看清楚:包从哪来、往哪去
1 | CPU ── Root Complex ──┬── GPU / NPU (Endpoint) |
| 角色 | 位置 | 职责 |
|---|---|---|
| Root Complex | CPU 侧 | 代 CPU 发起和接收 TLP |
| Endpoint | 链路末端 | GPU、NPU、网卡、SSD 等设备 |
| Switch | 链路中间 | 扩展端口,让多个设备共享上游链路 |
设备与主机之间只有两种交互方式:
| 交互方式 | 谁发起 | 做什么 | 链路特征 |
|---|---|---|---|
| MMIO | CPU → 设备 | 读写设备 BAR 寄存器:下命令、读状态、写 doorbell | 小包为主,量少 |
| DMA | 设备 → 主机内存 | 设备自己读写主机 DRAM:搬数据、读命令队列 | 大包,量大 |
要分析链路上真正传输的业务数据,盯的是 DMA:GPU 把推理结果写回主机内存、网卡把收到的包写进内存、SSD 把读到的数据吐给内存,走的都是 DMA 路径。
抓包位置决定你能看到什么
| 抓包位置 | 能拿到的东西 |
|---|---|
| 物理层 / 链路层原始数据 | Framing 标记 + DLLP 序号 + TLP + LCRC |
| 事务层 | 干净的 TLP:Prefix + Header + Payload + Digest |
| 仅统计或镜像端口 | 只有包长、方向、时序,看不到内容 |
本文只讨论第二种情况:手上已经有一个完整 TLP,怎么把它读明白。
📦 一个 TLP 的解剖
1 | ┌─────────────┬──────────────┬───────────────┬──────────────┐ |
图里的 DW 是 Double Word,1 DW = 4 字节。这是 PCIe 沿用的 x86 字长命名:Byte 8 位、Word 16 位、Double Word 32 位。后文的 Header 长度、Length 字段、Byte Enables 都以它为单位。
三个要点:
- Header 有两种长度:3 DW(12 字节)或 4 DW(16 字节)
- Payload 长度 = Length 字段 x 4 字节,且 4 字节对齐
- TLP Prefix 每个占 4 字节,可能叠加多个,解析时要先循环跳过
🔤 头 4 个字节:所有 TLP 共有的字段
不管什么类型,TLP 的前 4 个字节布局都一样:
| 字节 | 位 | 字段 | 含义 |
|---|---|---|---|
| Byte 0 | 7:5 | Fmt[2:0] |
包的格式 |
| 4:0 | Type[4:0] |
包的类型 | |
| Byte 1 | 7 | T9 |
Tag 扩展位,PCIe 4.0 起 |
| 6:4 | TC[2:0] |
Traffic Class,QoS 分级 | |
| 3 | T8 |
Tag 扩展位,同 T9 | |
| 2 | Attr[2] |
ID-Based Ordering | |
| 1 | LN |
Lightweight Notification | |
| 0 | TH |
TLP Hints | |
| Byte 2 | 7 | TD |
1 表示末尾带 TLP Digest(ECRC) |
| 6 | EP |
Error Poisoned,1 表示该包已被标记为坏 | |
| 5:4 | Attr[1:0] |
Relaxed Ordering / No Snoop | |
| 3:2 | AT[1:0] |
Address Type,地址翻译相关 | |
| 1:0 | Length[9:8] |
长度高位 | |
| Byte 3 | 7:0 | Length[7:0] |
长度低位 |
Length 的单位是 DW,不是字节,而且
Length == 0 表示 1024 DW:10 位字段里 1 到 1023
按原值编码,上界 1024 落在 0 上。
版本提醒:
Fmt从 PCIe 3.0 起才占 3 位(Byte 0 的 7:5)。PCIe 2.x 及更早只有 2 位,Byte 0 的 bit 5 是保留位。T8/T9是 4.0 起才有的位。如果手头规范是 2.x 版本,位图和本文会有出入。
📖 字典:把 Fmt 和 Type 翻成事务名
Fmt 编码
Fmt[2:0] |
含义 |
|---|---|
000b |
3 DW header,无 payload |
001b |
4 DW header,无 payload |
010b |
3 DW header,有 payload |
011b |
4 DW header,有 payload |
100b |
TLP Prefix,后面还跟着真正的 TLP |
这三位各有含义,记住拆位规则就能免去查表:
| 位 | 含义 |
|---|---|
Fmt[2] |
1 表示这是 TLP Prefix,不是普通 TLP |
Fmt[1] |
1 表示带 payload |
Fmt[0] |
1 表示 4 DW header(64 位地址),0 表示 3 DW(32 位地址) |
Fmt + Type 联合编码
解析时会遇到的类型基本都在这里:
| 事务 | Fmt[2:0] |
Type[4:0] |
payload | Header |
|---|---|---|---|---|
| MRd | 000 / 001 |
0 0000 |
无 | 3DW / 4DW |
| MWr | 010 / 011 |
0 0000 |
有 | 3DW / 4DW |
| Cpl | 000 |
0 1010 |
无 | 3DW |
| CplD | 010 |
0 1010 |
有 | 3DW |
| CfgRd0 / CfgWr0 | 000 / 010 |
0 0100 |
无 / 有 | 3DW |
| CfgRd1 / CfgWr1 | 000 / 010 |
0 0101 |
无 / 有 | 3DW |
| Msg / MsgD | 001 / 011 |
1 0rrr |
无 / 有 | 4DW |
| MRdLk | 000 / 001 |
0 0001 |
无 | 3DW / 4DW |
| FetchAdd / Swap / CAS | 010 / 011 |
0 1100 / 0 1101 / 0 1110 |
有 | 3DW / 4DW |
⚠️ MRd 和 MWr 的
Type完全相同(都是0 0000),Cpl 和 CplD 也完全相同(0 1010)。区分读还是写、有没有数据,只能靠Fmt。只看 Type 就下判断,是最常见的错误。
🚚 四类核心事务
日常解析中绝大多数包都是这四种,它们两两配对:
| 步骤 | 方向 | 事务 | 含义 |
|---|---|---|---|
| 1 | Host → GPU | MWr | 主机把数据写进设备 |
| 2 | Host → GPU | MRd | 主机读设备,发出读请求 |
| 3 | GPU → Host | CplD | 设备把读到的数据带回来 |
| 4 | GPU → Host | MWr | 设备 DMA 写回主机内存 |
一个读操作的完整往返:
1 | Host GPU |
三个要点
- MWr 是 Posted 请求,不要求回应。链路上出现 MWr 之后不会跟着一个对应的 Cpl,不要傻等
- 方向不等于发起方。MWr 既可能是主机写给设备,也可能是设备 DMA 写回主机内存(上表步骤 4)。判断方向要看 Requester ID,不是看包类型
- 设备到主机的大数据流走的是步骤 4。GPU 把计算结果写回主机内存,就是一连串 MWr,payload 里就是结果本身
一个读请求会拆成多个 Completion
一次 MRd 返回的数据经常被拆成多个 CplD,每个不超过
Max_Payload_Size。拼回去的关键在两个字段:
| 字段 | 含义 | 陷阱 |
|---|---|---|
Byte Count[11:0] |
还剩多少字节没返回 | 不是本包长度 |
Lower Address[6:0] |
本包数据在目标地址里的起始字节偏移 | 用于定位拼接位置 |
所以正确做法是:按 Requester ID + Tag 归组,再看
Byte Count
是否归零。少了这一步,大块数据必然拼错。
🔍 Header 逐字段详解
Request 头部(MRd / MWr)
4 DW,64 位地址(Fmt[0] = 1):
| 字节 | 字段 |
|---|---|
| 0–3 | 公共头(见上一节) |
| 4–5 | Requester ID[15:0] |
| 6 | Tag[7:0](T8 / T9 在 Byte 1) |
| 7 | Last DW BE[3:0] (7:4) | First DW BE[3:0] (3:0) |
| 8–11 | Address[63:32] |
| 12–15 | Address[31:2] |
| 16… | Payload |
3 DW,32 位地址(Fmt[0] = 0):
| 字节 | 字段 |
|---|---|
| 0–3 | 公共头 |
| 4–5 | Requester ID[15:0] |
| 6 | Tag[7:0] |
| 7 | Last DW BE | First DW BE |
| 8–11 | Address[31:2] |
| 12… | Payload |
地址位到字节的对应关系:32 位格式下 Address[31:24] 在
Byte 8,Address[23:16] 在 Byte
9,Address[15:8] 在 Byte 10,Address[7:2] 在
Byte 11 的高 6 位。
Completion 头部(Cpl / CplD)
Completion 固定使用 3 DW header,并且走 ID 路由:
| 字节 | 字段 |
|---|---|
| 0–3 | 公共头(此处的 AT 为 00b) |
| 4–5 | Completer ID[15:0],谁回的 |
| 6 | Cpl Status[2:0] (7:5) | BCM (4) | Byte Count[11:8] (3:0) |
| 7 | Byte Count[7:0] |
| 8–9 | Requester ID[15:0],回给谁,与请求里的值相同 |
| 10 | Tag[7:0],与请求里的值相同 |
| 11 | R (7) | Lower Address[6:0] (6:0) |
| 12… | Payload,仅 CplD 有 |
两个解析要点:
Byte Count编码:0000 0000 0001b表示 1 字节,0000 0000 0000b表示 4096 字节Cpl Status为000b时是成功(SC)。先判 Status,非 0 的包没有有效数据
Requester ID 的构成
Requester ID 是 16 位,唯一标识一个 Function,编码由
Bus、Device、Function 三段拼成:
| 字段 | 位置(非 ARI) |
|---|---|
Bus[7:0] |
ID 的第 1 个字节,即 Request 头的 Byte 4 |
Device[4:0] |
第 2 个字节的高 5 位(Byte 5 的 7:3) |
Function[2:0] |
第 2 个字节的低 3 位(Byte 5 的 2:0) |
⚠️ 常见踩坑:
Bus在低地址字节,Device / Function在高地址字节。用struct.unpack("<H")读出来的整数,低 8 位是 Bus,高 8 位是 Device 和 Function,搞反了会把设备认错。ARI 设备没有 Device 字段,Function 占满 8 位。
Byte Enables
Byte Enables 固定在 Byte 7,指示 payload 里哪些字节是有效数据:
| 字段 | 位置 | 影响的 payload 字节 |
|---|---|---|
First DW BE[0..3] |
Byte 7 的 bits 3:0 | Byte 0 / 1 / 2 / 3 |
Last DW BE[0..3] |
Byte 7 的 bits 7:4 | Byte N-4 / N-3 / N-2 / N-1 |
使用规则有强度之分:
Last DW BE只在数据长度大于 1 DW 时才使用- 对 I/O 与 Configuration 请求,规范强制要求
Last DW BE[3:0] = 0000b - 对
TH置位的 Memory Read,BE 字段被挪作ST[7:0](Steering Tag)使用
DMA 传输经常不对齐,BE 就是用来告诉你哪些字节是真实数据、哪些是补齐填充。丢掉 BE 会得到错位的字节流。
🧪 写一个解析器
把上面这些字段落成代码,覆盖 Mrd / MWr / Cpl / CplD 四类:
1 | import struct |
这段代码用构造的合成 TLP
跑过一组用例,覆盖四类事务识别、Length = 0 转 1024
DW、Byte Count = 0 转 4096 字节、TLP Prefix 跳过、payload
切分和地址解析。它没有在真实抓包上回归过,落地前建议拿一次已知内容的传输反查
payload 是否与预期字节流一致。
尚未处理的部分:EP 坏包、TLP Digest(TD=1
时末尾多 4 字节 ECRC)、AT 非 0 的地址翻译场景。
⚠️ 容易踩的坑
| 误区 | 正解 |
|---|---|
只看 Type 判断读写 |
MRd 和 MWr 的 Type 相同,必须结合 Fmt |
把 Length 当字节数 |
单位是 DW,要乘以 4;0 表示 1024 DW |
把 Byte Count 当本包长度 |
它是剩余字节数,用于判断读取是否收齐 |
| 认为 MWr 会有回应 | MWr 是 Posted 请求,正常情况下不产生 Completion |
| 按抓包顺序拼接数据 | 必须按 Requester ID + Tag 归组,再看地址和 Byte
Count |
| 忽略 Byte Enables | 不对齐传输靠 BE 定位有效字节,丢掉会错位 |
| 认为链路上一定是明文 | 链路若启用 IDE(Integrity and Data Encryption),payload 是密文,解析出来是高熵数据 |
📌 小结
| 概念 | 要点 |
|---|---|
| TLP 结构 | Prefix + Header(12 或 16 字节)+ Payload + Digest |
| 公共头 | 前 4 字节定格式与长度,Length 单位是 DW |
| 类型判定 | Fmt[2:0] 与 Type[4:0] 联合决定,Type
单独不够用 |
| 四类事务 | MRd 问、CplD 答、MWr 单向写,判断方向看 Requester ID |
| 数据拼接 | 按 Requester ID + Tag 归组,用 Byte Count
判断收齐 |
| 字段定位 | Requester ID 的 Bus 在低字节,BE 固定在 Byte 7 |
下一步可以顺着两个方向深入:一是把 TLP 放回整个协议栈,看数据链路层的序号与 LCRC 怎么保证可靠传输;二是往上看设备驱动如何编排这些事务,理解一次 DMA 在软件侧长什么样。
Happy Hacking! 🎉