这是充电协议系列进阶篇的上篇,只干一件事:把 PD、PPS、QC 的代码实现拆开看。基础篇讲了它们怎么握手、怎么谈定电压电流,下篇讲 Type-C 口里谁先开口的五种角色,这篇专讲合同条文是怎么写进代码的。
读 PD 代码,先分清三层
基础篇里我们把充电器插上那几毫秒讲成了一桩生意:充电器把能给的档位列出来、手机从中挑一个、成交。这篇继续往后挖——合同条文在代码里长什么样。分三层讲:
一次协商,就是两边各跑一个状态机,状态名直接抄规范。
报文怎么打包、怎么签收,一个报文就是一辆小货车。
充电器的档位表、手机的请求对象,都是 32 个比特的位运算;PPS 的对账循环、QC 的电压暗号,各自怎么落成代码。
一次协商,两边各跑一个状态机
Linux tcpm 的状态名几乎是规范状态机的直译,Source 侧和 Sink 侧各跑各的,靠 CC 上的报文对表:
代码里就是 switch(state) 大分发,配一个超时定时器。状态对不上就 HARD_RESET 或 SOFT_RESET 重来——你抓包偶尔能看到一对 Soft Reset,就是两边对不上表在“重新自我介绍”。
一条报文长什么样
PD 报文物理上是一串 4b/5b 编码的比特,逻辑上分四段:前导码 → SOP → 消息头 + 数据对象 → CRC。
SOP 分三种:SOP 是主机和设备之间,SOP' 发给线缆,SOP'' 发给插头里的芯片——所以 PD 能跟“一根线”单独说话,报线缆能力。
消息头 16 个比特,最重要的几个字段:
消息头字段 | 位置 | 干嘛用的 |
|---|---|---|
消息类型 | bit 11-9 | Source_Capabilities、Request、Accept、PS_RDY、Get_PPS_Status 全在这编码 |
数据对象个数 | bit 14-12 | 后面跟 0 到 7 个 32 位数据对象 |
扩展消息标志 | bit 15 | 1 表示这是带扩展头的大报文 |
还有个细节:每个报文都得回一个 GoodCRC 确认,像快递签收。所以你抓包看到的报文永远成双成对——一发一签,签收完才算数。
档位表是怎么拼出来的
充电器广播的档位表叫 Source Capabilities,每个档位是一个 32 位 PDO。拼法就是位运算,看着唬人,拆开全是“挪几位、填个数”:
// Fixed PDO:电压按 50mV 一档存,电流按 10mA 一档存 uint32_t fixed_pdo(uint32_t mv, uint32_t ma) { uint32_t v = mv / 50; // 20000mV -> 400 uint32_t i = ma / 10; // 5000mA -> 500 return (0b00 << 30) // 类型:Fixed Supply | (0b00 << 28) // 属性位 | (v << 10) // 电压放 20-11 位 | i; // 电流放 10-0 位 } // 20V/5A 拼出来就是:(0b00 << 30) | (400 << 10) | 500PPS 档是另一种类型,叫 APDO。它的电压电流范围是充电器“报区间”,不是报死值——这就是为什么你能在抓包里看到 5-11V、5-20V 这种浮动区间:
// APDO:最大/最小电压按 100mV 一档存,最大电流按 50mA 一档存 uint32_t pps_apdo(uint32_t max_mv, uint32_t min_mv, uint32_t max_ma) { return (0b11 << 30) // 类型:APDO | (0b00 << 28) // 子类型:PPS | (max_mv / 100 << 17) // 最大电压(11000mV -> 110) | (min_mv / 100 << 8) // 最小电压(5000mV -> 50) | (max_ma / 50); // 最大电流(3000mA -> 60) }手机怎么选档
手机回 Request,里面填 Object Position 指向档位表第几档,再带上运行参数。PPS 档的 RDO 特别一点:输出电流 50mA 一档、输出电压 20mV 一档——这就是“8.2V”这种小数电压的来历。它不是档位表里的固定值,是手机根据电池电压现场算出来、填进 RDO 的:
// 请求第 pos 档,运行电压 mv、运行电流 ma uint32_t pps_rdo(uint8_t pos, uint32_t mv, uint32_t ma) { return (0b11 << 30) // 类型:APDO 请求 | (0b00 << 28) // 子类型:PPS | (ma / 50 << 21) // 运行电流,50mA 一档 | (mv / 20 << 9) // 输出电压,20mV 一档 | pos; // 第几档(1 起算) }PPS 的“对账”循环,代码里怎么转
PPS 最“磨叽”也最实用的地方在协商完之后。固定档谈完,手机基本闭嘴了;PPS 不一样,电压要一直微调。
基础篇我们放过抓包:手机先 Request PPS 8.2V/1A,充电器把电压顶到 8.303V,然后手机每隔一秒多就 Get PPS Status 轮询一次实际输出——8.025V/1.019A、8.097V/0.614A……像对账一样。代码视角就是一个循环:
while (charging) { uint32_t target = battery_target_mv(); // 按电池电压现算目标 if (abs(target - current_mv) >= 20) // 差一格(20mV)就调 send_request(target, target_ma); // 发 Request(PPS 档) pps_status_t st = get_pps_status(); // 轮询充电器实际输出 current_mv = st.output_mv; // 拿实测值记账 delay(1000); // 一秒多对一次账 }20mV 一格、50mA 一格,是 USB PD 3.0 规范给 PPS 定的最小调节粒度。为什么这么细?因为要贴着电池的最佳充电曲线走——电压调过头一点,电流就可能超标,电池温度就压不住。固定档谈完就定死,PPS 得一直盯着电压调。
QC 没有报文,只有电压暗号
QC 跟 PD 是两个路子。PD 是数字化谈判——CC 引脚、报文、状态机;QC 是模拟暗号——D+/D- 两根数据线的电压。QC 的便宜之处也在这:D+/D- 是数据线,本来就在,充电器加个 ADC 和几个电阻就够,不用上协议芯片。老安卓机和一大堆“QC 兼容”充电器都是这么干的。
QC2.0 的握手像对暗号,拆开看是五步:
充电器刚上电时把 D+ 和 D- 短接在一起。手机按老协议 BC1.2 一测——DCP(专用充电口),先按 5V 普通充电对待。
手机想升压,就在 D+ 上拉一个 0.325V 的小电压,保持 1.25 秒以上。这就是暗号的第一句:“我要快充”。
充电器检测到这个电平维持够久,断开 D+/D- 短接——D- 电压跟着掉下来。
手机看到 D- 电压掉了,知道充电器认账了,按目标档位把 D+、D- 拉成对应的组合电平。
充电器读到组合电平,把输出电压切到对应档位。
注意第二步和第四步不是一回事:0.325V 保持 1.25s 是“我要快充”的敲门信号,真正定档靠的是 D+/D- 的组合电平。QC2.0 的档位表长这样:
目标电压 | D+ | D- | 说明 |
|---|---|---|---|
5V(默认) | 0.6V | 0V | 没谈成,老实 5V |
9V | 3.3V | 0.6V | 最常用的升压档 |
12V | 0.6V | 0.6V | A 级就到这 |
20V | 3.3V | 3.3V | B 级才有,一般配 Type-C |
QC3.0 更进一步。它进“连续模式”后,用 D- 上的一串方波脉冲告诉充电器“再高 200mV”“再低 200mV”,在 3.6V-20V 之间 0.2V 一档连续调。所以 QC3.0 的代码里多一个“数脉冲”的活:
// classify 把 ADC 读值归成三档:0V / 0.6V / 3.3V,带迟滞窗口抗干扰 enum level { LVL_ZERO, LVL_MID, LVL_HIGH }; enum level dp = classify(adc_read(D_PLUS)); enum level dm = classify(adc_read(D_MINUS)); if (dp == LVL_HIGH && dm == LVL_MID) set_output(9000); // 9V else if (dp == LVL_MID && dm == LVL_MID) set_output(12000); // 12V else if (dp == LVL_HIGH && dm == LVL_HIGH) set_output(20000); // 20V(B 级) else set_output(5000); // 默认 5VQC3.0 在连续模式里还多了个“数脉冲”的分支:D- 上每来一个上升沿,输出就往上顶 200mV,下降沿就往下收。原理就是这么朴素——电压对上了,就切;对不上,就 5V。
一张表收尾
把四种协议的“谈法”并排看一遍,差异一目了然:
协议 | 通道 | 协商方式 | 电压调节 | 一句话 |
|---|---|---|---|---|
PD Fixed | CC(报文) | 报档位 + 选档 | 固定档位 | 数字化谈生意 |
PD PPS | CC(报文) | 报档位 + 选档 + 对账 | 20mV 连续调 | 电压现算现调 |
QC2.0 | D+/D-(电压) | 敲门信号 + 组合电平 | 固定档位 | 模拟暗号 |
QC3.0 | D+/D-(脉冲) | 敲门信号 + 数脉冲 | 200mV 连续调 | 暗号加密码本 |
最后一句话总结:PD 靠 CC 上的报文把每一条都写成明码,QC 靠 D+/D- 的电压把档位藏在电平里。前者贵在协议芯片,后者便宜在几颗电阻——这也是为什么到今天,两套协议还能同时活在同一个充电器里。
参考资料
正文里的报文细节和取整规则,主要依据以下公开资料,可按需点开核对:
[1] USB-IF《USB Power Delivery》培训材料——PDO/APDO 位定义与取整规则。D2T2-1.pdf
[2] ST 应用笔记 AN5562——PPS 20mV/50mV 调节粒度与 VBUS 控制算法。AN5562.pdf
[3] Linux 内核 USB Type-C 驱动文档与 tcpm 源码(drivers/usb/typec/tcpm.c)——状态机命名。typec.html
[4] 高通官方博客《Qualcomm Quick Charge 2.0 technology explained》——QC2.0 背景与功率档位。Qualcomm 原文
[5] 腾讯云开发者社区《QC 快充协议实战:从 QC2.0 握手到 QC3.0 动态调压》——D+/D- 组合电平与脉冲波形实测。QC 快充实战