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
2
3
CPU ── Root Complex ──┬── GPU / NPU    (Endpoint)
├── NIC / SSD (Endpoint)
└── PCIe Switch ── 更多 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
2
3
4
5
┌─────────────┬──────────────┬───────────────┬──────────────┐
│ TLP Prefix │ TLP Header │ Data Payload │ TLP Digest │
│ (optional) │ 3DW or 4DW │ (optional) │ TD=1 only │
└─────────────┴──────────────┴───────────────┴──────────────┘
4B each 12B / 16B Length x 4B 4B

图里的 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
2
3
4
5
6
Host                                 GPU
│ │
├── MRd ──────────────────────────► │ ← 读请求
│ │
│ ◄──────────────────────────── CplD┤ ← 带数据
│ │

三个要点

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import struct

def parse_tlp(buf: bytes) -> dict:
"""buf 从 TLP 起点开始,不含 DLLP 的序号和 LCRC。"""
off = 0
# 跳过 TLP Prefix(Fmt == 100b,每个 4 字节)
while ((buf[off] >> 5) & 0b111) == 0b100:
off += 4

h = off
b0, b1, b2, b3 = buf[h], buf[h + 1], buf[h + 2], buf[h + 3]
fmt = (b0 >> 5) & 0b111 # bits 7:5
typ = b0 & 0b1_1111 # bits 4:0
tc = (b1 >> 4) & 0b111 # bits 6:4 of byte 1
td = (b2 >> 7) & 1
ep = (b2 >> 6) & 1
length_dw = ((b2 & 0b11) << 8) | b3
if length_dw == 0:
length_dw = 1024 # 规范定义的特例

has_data = bool(fmt & 0b010) # Fmt[1]
four_dw = bool(fmt & 0b001) # Fmt[0]
hdr_len = 16 if four_dw else 12
payload_len = length_dw * 4

out = {"fmt": fmt, "type": typ, "tc": tc, "td": td, "ep": ep,
"length_dw": length_dw, "has_data": has_data,
"header_bytes": hdr_len, "payload_len": payload_len}

if typ == 0b0_0000: # Memory Read / Write,靠 Fmt 区分
lb = buf[h + 7]
out["kind"] = "MWr" if has_data else "MRd"
out["requester_id"] = buf[h + 4] | (buf[h + 5] << 8) # 低字节是 Bus
out["tag"] = buf[h + 6]
out["last_dw_be"] = (lb >> 4) & 0b1111
out["first_dw_be"] = lb & 0b1111
if four_dw:
out["address"] = struct.unpack_from("<Q", buf, h + 8)[0] & ~0b11
else:
out["address"] = struct.unpack_from("<I", buf, h + 8)[0] & ~0b11

elif typ == 0b0_1010: # Completion
out["kind"] = "CplD" if has_data else "Cpl"
out["completer_id"] = buf[h + 4] | (buf[h + 5] << 8)
out["cpl_status"] = (buf[h + 6] >> 5) & 0b111
out["byte_count"] = ((buf[h + 6] & 0b1111) << 8) | buf[h + 7]
if out["byte_count"] == 0:
out["byte_count"] = 4096
out["requester_id"] = buf[h + 8] | (buf[h + 9] << 8)
out["tag"] = buf[h + 10]
out["lower_address"] = buf[h + 11] & 0b111_1111

if has_data:
out["payload"] = buf[h + hdr_len:h + hdr_len + payload_len]
return out

这段代码用构造的合成 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! 🎉