ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

USB PD协议实战:从CC物理层到PDO协商与状态机调试指南

USB PD协议实战:从CC物理层到PDO协商与状态机调试指南

做硬件的人应该都遇到过这种尴尬:手里明明有一个65W的PD充电头,插上一块只要12V的板子,示波器一看VBUS纹丝不动,还是5V。反过来,有些板卡只有一个Type-C口,想靠它给电池组充电,却怎么都拉不起功率。这些问题表面上都像“供电不行”,但真正在背后做决定的,就是USB PD协议——谁先说话、谁听谁的、什么时候升压、什么时候断电,全都由一套协议栈说了算。

这篇文章我会从一个实际做电源和嵌入式开发的角度,把USB PD协议的物理层、协商流程、状态机和常见坑完整讲清楚。不管你是准备给自己的产品加Type-C受电口,是想做个PD诱骗器从充电头里榨出20V,还是单纯在调试充电异常,都能在文章里找到对应的答案。我不会只贴规范条文,而是尽量把“规范为什么这样设计”和“实际调试时的表现”一起说。

1. 为什么USB PD协议能成为充电事实标准

1.1 从USB2.0的500mA到240W:电源能力跃迁

USB接口最开始根本不是用来做大功率供电的。USB 2.0时代默认只有5V/500mA,2.5W,连个移动硬盘都带不动。后来出现BC 1.2(Battery Charging),把充电电流提高到1.5A,但通信识别要靠D+/D-短路,本质上是模拟域的检测,不是真正意义上的协商。Type-C口普及之后,接口物理形态统一了,供电能力却没跟上,手机、平板、笔记本都需要更高的功率,于是USB-IF在Type-C之上定义了USB PD(Power Delivery)协议。

这里有个常见误解:很多人以为Type-C就等于PD,其实不是。Type-C只是物理接口,PD是一套跑在CC线上的独立通信协议。一条普通Type-C线插上充电头,如果充电头不支持PD,那就是5V/3A顶天了。而支持PD的充电头,可以通过CC线发送消息,商量出一个双方都接受的电压和电流。

PD协议本身的版本演进也很有意思。PD 1.0时代用了BFSK调制,速度慢、兼容性一般,实际市面上很少见到。PD 2.0改成了BMC调制,定义了5V/9V/15V/20V这一组标准电压,最大冲到100W,这时候笔记本充电器才开始统一到Type-C口上。PD 3.0增加了PPS,也就是可编程电源,手机快充彻底活了。PD 3.1又加了一组EPR扩展电压范围,28V/36V/48V,最大240W,这就覆盖到了游戏本、显示器甚至小型电动工具。

协议版本主要电压档位最大功率关键变化
PD 1.05V/12V/20V100W引入通信协商概念,但BFSK调制,市场少见
PD 2.05V/9V/15V/20V100WBMC调制,Type-C普及,笔记本充电统一
PD 3.0固定档位+PPS100W可编程电源,手机快充大量采用
PD 3.1增加28V/36V/48V240WEPR扩展,覆盖大功率设备

1.2 只有PD做不到的事:QC、BC1.2和私有协议各管一摊

聊PD之前,先把几个容易混淆的“快充”概念理清。高通的QC快充是另一套协议,走的是D+/D-两根线,用不同的电压电平告诉充电头“我要9V”还是“我要12V”。OPPO的VOOC、华为的SuperCharge也都有自己的私有协议。这些协议跟PD完全不兼容,物理层不同、消息格式不同,更不能互相通信。

其实USB-IF一直想把充电协议统一成PD,但现实是手机厂商都想保留私有协议,于是市面上出现了大量“多协议融合”的充电头。这些充电头内部往往有一颗协议芯片,比如SC2001、SW2303之类,同时支持QC、PD、VOOC等,靠CC和D+/D-上的检测结果自动切换。所以你在一个支持QC的充电头上插PD诱骗器,是绝对协商不出20V的,因为充电头此刻压根没启用PD通路。这也是很多新手做诱骗器最容易忽略的一点:先确认目标充电头是真的支持PD,再谈协议解析。

2. 物理层与CC通道:一次握手从电阻开始

2.1 Rp和Rd:5.1k电阻为什么是Sink的身份证

USB PD通信不是随便找两根线就能跑的。Type-C口里有专门的CC引脚,全称是Configuration Channel。这个引脚承担两件事:第一,检测连接的方向和角色;第二,在上面跑BMC通信。

检测角色的原理特别朴素。Source端(供电方,比如充电头)会在CC1或CC2上拉一个电阻Rp,Sink端(受电方,比如你的板子)则下拉一个Rd,标准值是5.1kΩ。当Type-C插头插入时,Source通过ADC或比较器看到CC引脚电压,就能判断出对面是个Sink。同时,Sink也能判断出自己插的是哪个方向——如果CC1上有电压,说明线缆方向是CC1;如果是CC2有电压,则反一反。这就是为什么Type-C正反插都能工作,因为它定义了CC1和CC2两个引脚来处理镜像。

顺着这个思路,还有人会想到:如果我在CC上下拉一个电阻,就相当于“骗”充电头以为我是Sink,这就是最原始的PD诱骗雏形。但要注意,只靠5.1k电阻,充电头只会为你提供默认的5V/3A,因为此时还没有发生任何PD协商。想拿到9V、15V、20V,就必须在CC线上发出真正的PD消息。

Rp的阻值也不是随便选的。Type-C规范里,56k、22k、10k分别对应不同的源端供电能力:56k表示只能提供USB默认电流,22k表示1.5A,10k表示3A。这些属于Type-C层面的物理握手,跟PD协商没关系。很多协议芯片会同时检测Rp电压和后续PD能力,两套系统并行工作。

2.2 BMC编码、消息帧和CRC:低速通信也可以很可靠

PD通信的物理层用BMC编码,全称是Biphase Mark Coding。这个概念可以用一个很简单的方式理解:每一比特的时间是固定的,但每个比特内部必须有跳变,用跳变的位置编码“1”和“0”。这种编码方式不依赖绝对电平高低,只依赖跳变节奏,所以抗干扰能力强,也很容易用边缘检测恢复时钟。

PD 2.0之后的最大波特率是300kbps上下。有人第一次听到这个速率会笑,觉得太慢了。但这就是PD工程的务实之处:CC线上只有一个引脚,还要同时供电检测和通信,不需要高速。你想传的内容无非是几个PDO(电源数据对象)和一个请求,几十个字节就能完成一次协商,300kbps完全够用。

消息帧也不是光秃秃地丢几个字节。一个完整的PD消息包含起始位(SOP)、消息头、若干数据对象、CRC32校验和结束位。SOP还分SOP1、SOP2等,专门用于跟线缆内部的E-Marker芯片通信,这在后面讲线缆坑时还会提到。CRC32是整个消息可靠性的关键,接收方一旦CRC校验失败,就不会回复GoodCRC,发送方会等超时重发。这套机制听起来复杂,但实际在调试时,看到CC线上有波形但协商没反应,十有八九就是CRC不过、电平不对或者Source根本就没发出能力包。

3. 源设备与受电设备的“谈判桌”:PDO、RDO和PPS

3.1 PDO是菜单,RDO是点菜:最实用的解析方法

USB PD最核心的设计概念,是把“供电能力”定义成一串PDO。PDO就是Power Data Object,相当于充电头提供的菜单。充电头会把自己的能力用一条Source_Capabilities消息发给Sink,Sink从里面挑一个最合适的,然后用RDO(Request Data Object)回复给充电头。

PDO有几种类型,我在实际调试中见得最多的就是固定电压PDO(Fixed Supply)。它在32位数据里的格式很直接:最高2位是类型编码,固定电压是00;中间10位是电压值,以50mV为单位;最低10位是最大电流,以10mA为单位。我常在调试脚本里写这样一个Python函数来解析:

def parse_fixed_pdo(pdo_bytes): val = int.from_bytes(pdo_bytes, byteorder='big') pdo_type = val >> 30 max_current_ma = (val & 0x3FF) * 10 voltage_mv = ((val >> 10) & 0x3FF) * 50 return pdo_type, voltage_mv, max_current_ma # 假设从充电头上抓到一个完整PDO值 pdo_type, volt_mv, curr_ma = parse_fixed_pdo(bytes([0x2D, 0xC1, 0x44, 0x00])) print("PDO类型:", pdo_type) print("电压(mV):", volt_mv) print("最大电流(mA):", curr_ma)

解析出来的值只要除以1000就是常见的“9V/3A”“20V/5A”这种标称。除了固定电压PDO,还有可变电压PDO、电池PDO,以及PD 3.0新增的PPS类型的APDO。可变电压PDO通常用于一些特殊设备,允许Sink在某个区间内选择一个电压,但电流上限不随电压变化。电池PDO则是为了对接那些内部直接充电电池的设备,设计上更像一个功率总包,而不是某个固定电压档位。

PDO类型编码常见用途特点
Fixed Supply00手机、笔记本、板卡供电固定电压,电流上限固定
Battery01电池直充类设备以功率为维度描述能力
Variable Supply10老式设备、特殊模块电压范围可调,但电流上限固定
APDO / PPS11手机直充、精密调压电压可编程,步进约20mV

RDO这边的关键字段不是电压电流,而是Object Position,也就是“我选菜单里的第几个”。Sink端在发Request时,会把自己期望的电压、电流、以及所选PDO的序号都填进去,Source收到后如果同意,就会回Accept,然后开始把VBUS调到那个电压。整个流程像一个点餐过程:菜单摆上来,我选一个菜,后厨说能做到,然后上菜。理解了这个类比,后面看状态机就轻松了。

3.2 一次完整协商的抓包视角

我习惯用示波器并联在CC1和VBUS上看整个协商过程,这套视角比单纯看芯片手册直观得多。完整流程大概是这样的:

  1. 插上充电头,VBUS先输出5V。这个5V是Type-C物理握手成功后,Source主动给的默认电压。
  2. Source在CC线上发一条Source_Capabilities,里面带一串PDO。
  3. Sink收到后,在自己支持的电压集合里找一个交集,比如Source有20V,正好我也想要20V,就发一条Request,Object Position指向20V那个PDO。
  4. Source收到Request,检查参数合理,发Accept,然后把VBUS从5V往20V切换。
  5. 切换完成后,Source发PS_RDY,告诉Sink电压已稳定。
  6. Sink收到PS_RDY,才开始真正拉大电流。

整个协商过程通常只有几十毫秒,但对示波器来说足够了。我在实际调试中发现,很多自研Sink的问题出在第4步和第5步之间:Source切换电压需要时间,Sink必须等待PS_RDY才能开负载,如果Sink提前开负载,VBUS会被瞬间拉垮,Source于是进入保护或者复位。这个问题在规范里叫“Transtion Time”,不同电压之间的变化需要满足一定时序,后面专门讲状态机时会展开。

3.3 PPS:给手机直充准备的可变油门

PD 3.0引入PPS之后,手机快充才真正进入“软件控制电源”的阶段。PPS的全称是Programmable Power Supply,它不再只提供固定的几个电压档,而是允许Sink在某个范围内精细调节电压,最小步进是20mV,电流步进50mA。也就是说,Sink可以从15V一路调到20V,中间每一档都能选。

这个能力对手机直充特别重要。锂电池充电最理想的策略是恒流恒压,电池电压会随电量上升,如果能跟着电池电压微调充电电压,就能始终让充电板内部的降压电路工作在最高效率点。传统固定PDO只能给9V或者12V,手机内部的充电IC还得再降一档,效率损失不小。PPS相当于把“粗调”变成“细调”,给手机厂商留出了巨大的调优空间。

我调试过的很多手机原装头,特别是那些标称“55W”“66W”“120W”的,几乎全是PPS方案。它们的PDO列表里往往藏着一条APDO,比如3.3V到11V、5A这样一个范围。如果你用一个不支持PPS的普通PD诱骗器去套,很可能只能拿到5V或者9V,拿不到标称大功率,就是因为没去解析APDO,或者没有按APDO格式发Request。

4. 状态机与错误恢复:调试PD时最容易被忽略的半壁江山

4.1 你的Sink代码本质上是个状态机

很多刚开始做PD Sink的人,会以为只要把PDO解析出来、发个Request就行,结果实际调的时候发现各种奇怪现象。原因就是PD协议本身被实现成一个状态机,Source和Sink都有严格的初始化和状态跳转流程。

Sink侧的状态大致是:Attached.Snk表示物理层检测到CC上拉,进入SnkStartup;这时候Sink会开始等待Source发送Source_Capabilities,对应状态是SnkWaitCapabilities;收到能力包后进入SnkEvaluateCapability,判断哪个PDO匹配自己的需求;然后进入SnkSelectCapability,发送Request,等待Accept;最后进入SnkReady,等待PS_RDY并开始供电。

Source那边则是另一套对称状态:SrcStartup、SrcSendCapabilities、SrcNegotiateCapability、SrcReady。我在调试过程中发现,很多问题出在“状态没对齐”上。比如Sink已经进入等待PS_RDY状态,但Source因为某些原因发起了新的Source_Capabilities,或者直接复位了,Sink如果没有按时序去处理这个新的消息,就会卡在旧状态,表现为VBUS电压不对、电流拉不上来,甚至完全没有反应。

我给自己的Sink代码写宏定义时,会把每个状态都定义成独立的整型,然后每个状态里只处理对应的事件。实际上没有别的捷径,PD协议的状态表是USB-IF官方文档里画好的,照着实现就能少踩一半坑。

4.2 Hard Reset:为什么设备会反复重启

如果你在调试中看到设备连着又断开、充电指示灯一亮一灭,大概率是触发了Hard Reset。PD协议里Hard Reset是一个应急动作,Source会把VBUS快速拉低,让Sink重新开始一次完整的物理层和协议层初始化。这个过程不是“断电重来”那么简单,它有一套专门的时序:Source拉低VBUS,保持一段时间,然后重新输出5V,重新进入协商流程。

触发Hard Reset的原因很多。最常见的是Sink在收到Source_Capabilities后一直没回复Request,Source等超时了,就通过Hard Reset来强制恢复。还有一种是Sink发了Source无法满足的请求,有些充电头设计得比较“暴躁”,直接复位而不回Reject消息。所以当你看到设备反复重启时,不要急着骂硬件,先抓CC线上的波形,看看是不是有Hard Reset信号出现,再往前排查Sink的Request内容是否合法。

我遇到过一个很经典的案例:自研Sink代码里没有处理Reject/ Hard Reset这两个消息,结果Sink一直停留在等待状态,Source超时后进入Hard Reset,整个系统就像呼吸一样周期性地重启。后来在状态机里补全了对Hard Reset的响应,强制回到SnkStartup重新协商,问题立刻消失了。

4.3 示波器上该盯的时序窗口

调试PD协议,时序和对错一样重要。我常用的做法是同时看三路:CC1、VBUS、负载电流,用示波器的Trigger抓Single模式,专等电压突变。

关键要看的窗口包括:Source发出Accept之后,VBUS开始上升的延迟;VBUS上升过程中是否存在明显过冲;PS_RDY出现前,VBUS是否已经稳定在目标电压;Sink开始拉电流,VBUS有没有跌落。这些时间参数在规范里有定义,比如Source在Accept后多久必须切换电压、PS_RDY发送的要求等。

实际项目中,我见过很多Sink电路把VBUS上的输入电容放得特别大,导致Source切换电压时充电时间变长,触发了Source的超时保护。这个属于电源路径设计问题,跟协议芯片关系不大,会让调试者误以为是PD时序出错。遇到这种情况,先降低输入电容试一次,往往就能定位问题。

5. 把协议跑起来:诱骗器、Sink设计与芯片选型实战

5.1 最速原型:用FUSB302自己发Request

很多人的需求其实不是做完整的产品级Sink,而是想从PD充电头里掏出一个高电压来给设备供电,比如DIY便携电源、给锂电池组充电、给电机驱动器供电。这时候最快的路子是做一枚“PD诱骗器”。诱骗器的本质就是一个主动型Sink,它不会真正消耗多少电流,只负责跟Source完成协商,把电压“骗”出来,真正的功率由后级电路从VBUS获取。

我做过一个最小的原型,用的是FUSB302这颗PD PHY芯片加一颗MCU。FUSB302只处理物理层和BMC编解码,协议逻辑全部由MCU实现,用I2C读写寄存器。它的好处是灵活,任何PD逻辑都能自己写;坏处是寄存器配置繁琐,新手容易卡在初始化上。核心步骤大致是这样的:

  1. 初始化I2C,配置FUSB302的开关和中断。
  2. 读取PD状态寄存器,判断当前CC1还是CC2上检测到Source。
  3. 使能对应的CC通道,打开接收Source_Capabilities消息。
  4. 解析消息里的PDO,找到目标电压档位。
  5. 构造一条Request消息,Object Position填目标PDO的序号。
  6. 等待Accept和PS_RDY,然后通知后级系统“可以上电了”。

FUSB302的代码框架在网上能搜到很多,但驱动部分我建议直接参考官方例程,特别是BMC收发和CRC校验,不要自己从头写,容易在物理层细节上折腾很久。如果你想更快,可以直接买现成的PD诱骗模块,然后自己改后级电路,但那样乐趣也就少了大半。

5.2 产品化路线:STUSB4500这类Sink专用芯片

如果要做产品而不是玩具,我会选STUSB4500这类内置协议状态机的Sink专用芯片。它最大的优势是不需要MCU参与协商,芯片自己就能完成Source_Capabilities的接收、PDO选择、Request发送、Accept等待、PS_RDY检测这一整套流程。你只需要通过I2C接口把期望的电压电流配置到芯片里的NVM里,剩下的事情它自己处理。

STUSB4500支持配置多个PDO,比如我们常配成5V/9V/12V/15V/20V几个档位,芯片上电后会自动从中选一个Source支持的来请求。这种芯片尤其适合对稳定性要求高的嵌入式设备,相当于把PD协议栈固化成了硬件。代价是灵活性低,改电压需要重新配置NVM,不像FUSB302那样改代码就行。

选型判断标准其实很简单:如果板子上本来就有MCU,且你需要动态切换不同电压来适配不同场景,用FUSB302这类PHY芯片更合理;如果设备是固定需求,电源路径里面不想塞复杂的协议代码,STUSB4500是最稳的。

5.3 VBUS电容、浪涌和负载开启顺序

PD Sink的电源路径设计,比传统5V供电要讲究,因为VBUS可能从5V跳到20V,幅度很大。我自己的经验是,VBUS输入端不要放超大容量的电容,尤其不要在协议协商完成前就把它充满。你想想,电容两端电压从5V跳到20V,如果电容量是几百微法,充电瞬间的冲击电流会很大,轻则触发Source的保护,重则拉低VBUS导致协议复位。

更稳妥的做法是:先让协议协商完成,接收PS_RDY后再逐步开启后级负载。如果必须放较大电容,我会在VBUS和电容之间加一级电子开关或通路MOS,协商完成后才把电容和后级电路切入。另外,VBUS路径上的TVS管也建议选低钳位电压的型号,因为20V瞬间可能超过某些后端器件的极限。

负载开启顺序同样重要。很多设备在协商成功后,CPU或主控会瞬间拉大电流,如果前端没有做缓启动,电流斜率太陡,充电头会以为发生了短路。我会在代码里做“软启动”:先开一个小负载,比如几十毫安,稳定几十毫秒后再开主负载。这个习惯帮我避开了很多莫名其妙的保护。

6. 翻车高发区:E-Marker线缆、第三方头和兼容性

6.1 E-Marker:你的5A线可能并不“聪明”

USB-C线缆里面,有一种带芯片的“聪明线”,芯片叫E-Marker,它挂在CC线上,通过内置的SOP’通信把自己支持的能力广播给Source和Sink。E-Marker里存了线缆的电流能力、数据传输速率、线材长度等信息。

为什么要这个芯片?因为USB-C口太通用,一根看起来一模一样的线,可能只支持600mA,也可能支持5A。物理外形区分不出来,那就在线缆里放芯片来“自报家门”。按规范,支持5A大电流的线缆,或者支持USB 3.2/4高速信号的线缆,基本都该带E-Marker。但是现实很骨感,市面上大量廉价“5A线”压根没有E-Marker,甚至用了劣质线芯,大电流一过就发热。

在PD协商里,如果Sink向Source请求超过3A电流,Source往往会读取线缆的E-Marker确认线缆能力,读不到或者芯片数据不对,就拒绝你拉大电流。很多第三方便携显示器、笔记本扩展坞推荐你“必须用原装线”,本质原因就在这。我自己调小板子时,常备几根确认带E-Marker、并且线芯足够粗的线,才敢做满载测试,否则出了问题真分不清是板子问题还是线的问题。

6.2 兼容性问题的根源:不同Source的“性格”

市面上支持PD的设备,行为并不完全一致。苹果充电头、笔记本原装头、手机头、杂牌头,同样是PD 3.0,处理细节五花八门。有些充电头会先发一轮Source_Capabilities,如果Sink不理它,它不会一直等,而是直接复位重来;有些充电头对不支持的Request会返回Reject,而有些干脆不响应;还有些充电头虽然标称支持PPS,但PPS的电压范围和电流范围写得跟宣传不一致。

我在做兼容性测试时,标准流程就是拿一桌不同品牌的充电头逐个试,把CC信号和VBUS波形记录下来,再跟预期的协商序列对比。这个工作看起来琐碎,但恰恰能提前暴露产品里那些“偶发充不进电”的bug,因为很多问题只有在某个特定充电头的特定时序下才触发。

如果自己做Sink,建议至少把Reject、Wait、Hard Reset、SenderResponse超时这些情况都处理到位。代码里可以用一个默认优先级策略:“如果我请求的电压不支持,就退回最高可用的固定电压档;如果协商失败,就保持在5V工作而不是直接死掉。”这种降级策略在产品里非常重要,毕竟用户用一根烂线或者烂头时,至少还能充上电,而不是“不通”。

6.3 用什么工具把PD握手“看”明白

最后说工具。我调试PD协议常用的有三类:协议分析仪、示波器和电子负载。协议分析仪里比较常见的有Power-Z这类带PD解码的仪器,能把PDO列表、消息类型直接解析出来,太省事了。我第一次用Power-Z时,看到它把Source_Capabilities里的每个PDO都列成电压电流表格,心里直呼以前手抓波形纯属浪费生命。

不过示波器还是不可替代的。协议分析仪只告诉你“逻辑上发生了什么”,示波器能看到“电上发生了什么”——VBUS过冲、掉电、CC上的毛刺,这些才是电源问题的根源。调试时我会把示波器探头接在VBUS和CC上,波特率对不上时,示波器里能看到明显的BMC波形;CRC不过时,能看到消息发出去但没有GoodCRC回复。

电子负载用来做最终负载测试。协商出20V后,不能拿个灯珠就算完,应该用电子负载从0.1A逐级加到标称电流,观察VBUS是否稳定、电流是否被Source限制。尤其是做诱骗器时,我建议带上电子负载把电流一步步拉满,顺便验证线缆和接插件的压降。很多诱骗器能骗出电压,但一上大电流就掉压,重新协商,就是内部接触电阻太大导致的。

做协议类东西急不来。我最初调第一块自研PD板时,连续三个晚上都在跟Hard Reset较劲,翻来覆去就是没意识到Sink端少处理了Reject消息,被Source强制复位了。后来养成的习惯是:每个PD消息类型都规定自己该怎么处理,宁愿多打印状态,也不放过一个分支。现在拿到任何一款充电头,我第一件事已经不是看标称功率了,而是看它的PDO列表和CC波形——标称可以通过电流虚标,但PDO和时序不会骗人。这些细节,就是协议它最让人着迷的地方。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进