4. USB PD 消息结构:控制消息与数据消息格式解析

咱们接着聊。上一章我们把 USB PD 的物理层——也就是那根 CC 线怎么传信号——给摸透了。这一章,我们来解剖 PD 协议的核心:消息。

说白了,PD 通信就是 Source 和 Sink 之间互发“电报”。你想想看,好比你用对讲机喊话:“我功率拉满了,你要多少?”对方回:“给我 20V 5A。”

这些“电报”不是乱发的。控制消息和数据消息,各有各的规矩。

4.1 控制消息 vs 数据消息——本质区别在哪?

我把 PD 协议栈想象成一个快递系统。控制消息就像那种薄薄一页的“收货确认单”——没有包裹内容,只告诉你“签收成功”或“拒收”。数据消息呢?它是货真价实的“包裹”——里面装着具体的能力数组、请求参数。

形式上,区别就在于包尾巴有没有那一截 4 字节的“数据”。

  • 控制消息:只有 4 字节的头部(Header) + 2 字节的 CRC。没有数据对象(Data Object)。长度固定为 6 字节。
  • 数据消息:头部 + 一个或多个数据对象 + CRC。长度根据数据对象的数量变长,最短 10 字节(带 1 个数据对象),最长 30 字节(带 7 个数据对象)。

我在项目中遇到的第一个坑,就是在收包时只分配了 6 字节的缓冲区,结果满脑子以为所有消息都那么短,直接越界。那个抓包日志啊,全是乱码,搞了我一整天。从那以后,我再也不写死消息长度了。

特征 控制消息 数据消息
组成 Header(4B) + CRC(2B) Header(4B) + Object(每 4B 一组,1~7组) + CRC(2B)
总长度 固定 6 字节 10~30 字节
典型用途 GoodCRC、Accept、Reject、Wait、PS_RDY Source_Capabilities、Request、BIST、VDM
带不带数据 不带 带(能力数组、电流请求、厂商自定义命令等)

关键记忆点:接收方收到控制消息,只需回一个 GoodCRC;收到数据消息,就要按数据对象的内容去做决策。比如收到 Source_Capabilities,你得解析那几个电压电流二元组,然后才能发 Request。

4.2 解剖头部——那 4 个字节里藏着什么

嗯,无论控制还是数据消息,开头那 4 字节头部(Header)的格式是一样的。我习惯拿一张纸,把这 4 字节拆成比特来看。你想想看,16 比特的信息,决定了一切。

Bit15 ~ Bit12: 消息类型(Message Type)
Bit11 ~ Bit10: 端口角色(Port Role)
Bit9:         规格版本(Spec Revision)
Bit8:         端口数据角色(Port Data Role)
Bit7:         保留位(Reserved,必须为 0)
Bit6 ~ Bit5:  信息数量(Number of Data Objects,NOD)
Bit4 ~ Bit0:  消息 ID(Message ID)

我刚开始啃协议时,对这堆比特位很头疼。后来我用一个小技巧:只记住三个最重要的字段——消息类型、信息数量和消息 ID。其他的,抓包时让协议分析器帮你算就行。

  • 消息类型(Message Type):取值 0~15。0x01 是 Source_Capabilities,0x02 是 Request,0x03 是 Accept,0x07 是 PS_RDY。这是整个协商流程的“主语”。
  • 信息数量(NOD):只有数据消息才用。控制消息永远为 0。它告诉你,这个包后面跟着几个 4 字节的数据对象。
  • 消息 ID(MID):用来去重。发送方每发一条新消息,MID 加 1。注意,是循环 0~7。我遇到过某个协议栈的实现,因为 MID 没处理好,导致重发了几条同样的消息,Sink 那边直接挂了。

调试小技巧:抓 PD 总线时,直接看头部的 Message Type 字段。如果 Accepted 和 GoodCRC 乱飘,大概率是时序问题,不是解析问题。

4.3 数据对象——那些“包裹”里的内容长什么样

咱们说数据消息,重点就在它的数据对象上。最常见的两种数据对象:Source_Capabilities 里的 PDO(Power Data Object)和 Request 里的 RDO(Request Data Object)。

PDO 的结构(32 位)

Bit31 ~ Bit30: 供电类型(00=Fixed, 01=Battery, 10=Variable, 11=PPS)
Bit29:         Dual-Role Power
Bit28:         USB Suspend Supported
Bit27:         Unconstrained Power
Bit26:         USB Communications Capable
Bit25:         Dual-Role Data
Bit24:         Unchunked Extended Messages Supported
Bit23~Bit20:   Peak Current(仅 Fixed 类型有效)
Bit19~Bit10:   Voltage in 50mV units(电压值)
Bit9~Bit0:     Current in 10mA units(电流值)

比如,你看到一个 PDO 的电压字段为 0x190(400 步),电流字段为 0x1F4(500 步)。那实际电压 = 400 × 50mV = 20V;实际电流 = 500 × 10mA = 5A。嗯,这个 Source 确实能输出 20V 5A。

RDO 的结构(32 位)

Bit31~Bit28:   Object Position(指向要请求的 PDO 索引,从 1 开始)
Bit27:         Give Back Flag
Bit26:         Capability MIS Match
Bit25~Bit24:   USB Communications Capable
Bit23~Bit20:   No USB Suspend
Bit19~Bit10:   Operating Current in 10mA units
Bit9~Bit0:     Max Current in 10mA units

平时容易出错的地方,就是 Object Position 字段。它告诉 Source:“我要第 3 个 PDO。” 注意,位置是从 1 计数的,不是 0。我曾经见过一个固件,直接拿数组下标 0 去填这个字段,Source 收到后懵了——找不到第 0 个 PDO,直接 Reject。

4.4 流程实例——看一次完整协商中的消息交换

咱们以一个充电器为 Source,手机为 Sink 为例,走一遍 4 类消息的交换过程。

  1. Source 发送数据消息:Source_Capabilities
    类型 0x01,包含 3 个 PDO:5V/3A、9V/2A、12V/1.5A。NOD = 3。
  2. Sink 回复控制消息:GoodCRC
    收到后拆出 MID,原样返回,告诉 Source:“我收到了。”
  3. Sink 发送数据消息:Request
    类型 0x02,RDO 中 Object Position = 2,指向 9V/2A。
  4. Source 回复控制消息:Accept
    类型 0x03,没有数据对象。表示:“我接受你的请求。”
  5. Source 发送控制消息:PS_RDY
    类型 0x07,等电压转变完成后发出,告诉 Sink:“我已经准备好,开整吧。”
  6. Sink 回复控制消息:GoodCRC
    确认收到 PS_RDY。此时可以拉走 9V/2A 了。

你看看,整个过程 6 条消息,4 条控制消息、2 条数据消息。高压输出到位。

避坑指南:我刚开始做 PPS 调试时,发现状态机怎么都跳不过 SRC_TRANSITION,后来抓包一看,Source 发出 PS_RDY 之后,Sink 的 GoodCRC 回慢了,超时!Source 直接掉回 Default。所以要记得,所有控制消息的回复(GoodCRC 和 Not_Supported 等)必须在 1ms 内发出。

USB PD 消息交换流程(Source ↔ Sink) Source Sink 1. Source_Capabilities (数据消息) 2. GoodCRC (控制消息) 3. Request (数据消息) 4. Accept (控制消息) 5. PS_RDY (控制消息) 6. GoodCRC (控制消息) 流程说明 1,2: 发现端口能力 3,4: 协商目标电压电流 5,6: 确认转变完成 之后 Sink 开始拉载

4.5 调试实战——我如何用逻辑分析仪确认消息类型

我不建议一上来就写代码。先拿逻辑分析仪抓一段完整的协商过程,看波形。我习惯用 PulseView,解析 USB PD 之后,你会看到类似这样的 ASCII 清单:

SOP: Source -> Sink  | 消息类型: Source_Capabilities  | 数据数: 3
SOP': Sink -> Source  | 消息类型: GoodCRC             | 数据数: 0
SOP': Sink -> Source  | 消息类型: Request             | 数据数: 1
SOP: Source -> Sink  | 消息类型: Accept              | 数据数: 0

重点观察三个地方:

  • 消息类型字段是否对应正确的流程步骤
  • 数据数量(NOD)是否正确:控制消息一定是 0,数据消息至少 1
  • GoodCRC 是否紧跟在每个消息之后(除了 GoodCRC 本身,它后面不需要 GoodCRC)

我个人习惯:在固件里加一串调试日志,在发送/接收头部的瞬间,把 4 字节头部打印出来。这样当我发现协商卡死时,看一眼日志就能定位到到底是没收到 Accept,还是收到 Reject 后没处理。

4.6 总结一张表厘清各消息用途

消息名称 类型 方向 用途 是否需要数据对象
GoodCRC 控制 双向 接收确认 否
Accept 控制 Source→Sink 接受 Request 否
Reject 控制 Source→Sink 拒绝 Request 否
Wait 控制 Source→Sink 稍后再试 否
PS_RDY 控制 Source→Sink 电压转变完成通知 否
Source_Capabilities 数据 Source→Sink 通告供电能力 是(PDO)
Request 数据 Sink→Source 请求指定电压/电流 是(RDO)
BIST 数据 双向 测试与自检模式 是(BDO)
VDM 数据 双向 厂商自定义信息 是(VDO)

嗯,你把这些消息串起来,USB PD 的协商就不再是黑盒了。下一层我们开始玩策略——进入实际的状态迁移。