USB Type-C / PD 电源协商实战:同一台设备,换个充电器就从 20V 掉到 5V——最后 bug 是我自己那 4 行 PDO 解析
上周被一只充电器教做人了。
板子是个用 Type-C 口取电的工业手操器:主控带 PD 控制器(TCPC 那类),要从适配器取 12V/2A 给内部功放和 DC/DC。样机阶段我手边三只充电器全部正常,实验室里连续烤了两天没出事。送去客户现场,反馈很干脆——”一半充电器只能 5V,还有一只能用的,但插上以后大约每秒重连一次,屏幕跟着闪”。
更难受的是它不像坏。5V 也能跑起来,只是功放推不满;每秒重连也只是”偶尔闪一下”。于是这案子在售后那边挂了两个星期,没人当回事。
我从三个方向动手:CC 线上的静态电压、PD 报文的解析、还有协商的状态机时序。三天后定位到两个问题——一个是硬件上我省掉的 2 只电阻,一个是我自己写的 4 行 PDO 解析。这篇就把整条链子从头捋一遍。
一、先把 CC 这条线当成”模拟口 + 数字口”两个身份来看
Type-C 口上最容易被轻视的就是 CC(Configuration Channel)。它平时是个分压电路,可一旦 PD 协商开始,它就是一条 300kbps 的半双工总线。同一根线上同时跑”直流分压判读”和”BMC 编码通信”,这两个身份的电气要求是打架的,我后面踩的硬件坑几乎都源于这一点。
三个电阻,各管一件事
| 电阻 | 谁挂 | 标称 | 作用 |
|---|---|---|---|
| Rp | Source(供电方)在 CC1、CC2 各挂一个上拉 | 56kΩ / 22kΩ / 10kΩ(对 5V) | 告诉对方”我没协商前能给你多少电流” |
| Rd | Sink(受电方)在 CC1、CC2 各挂一个下拉 | 5.1kΩ | 我就是个受电设备,顺便用来判插入方向 |
| Ra | 带 e-marker 的线缆,挂在将被用作 VCONN 的那根 CC 上 | 1kΩ | “我是电子标记线,请给我 VCONN”,同时也是线缆能力上报的入口 |
Rp 分压出三种 CC 电压,这就是大家手里”万用表量出 0.4V / 0.9V / 1.7V”的来历:
| Rp | CC 电压(Rd=5.1kΩ) | 电压区间 | 未协商前的电流上限 | 判定门限 |
|---|---|---|---|---|
| 56kΩ | 0.417 V | 0.25~0.61 V | 默认 USB 档(500mA 量级) | < 0.66 V |
| 22kΩ | 0.941 V | 0.70~1.16 V | 1.5 A @5V | < 1.23 V |
| 10kΩ | 1.689 V | 1.31~2.04 V | 3.0 A @5V | ≥ 1.23 V |
顺便一个小工具,我把这几档做成了脚本的一个模式:
$ python usbc_pd_calc.py cc 56
CC 静态电压(Rd=5.1kΩ,名义 5V 上拉)
Rp = 56.0 kΩ -> V(CC) ≈ 0.417 V
判定 : 默认 USB 档:未协商前最多约 500mA(USB2.0 口径 100mA~500mA,按枚举结果)
$ python usbc_pd_calc.py cc 10
Rp = 10.0 kΩ -> V(CC) ≈ 1.689 V
判定 : 3A 档:未协商前最多 3A @5V;PD3.0 下该 Rp 值同时表示 SinkTxOK(允许 Sink 主动发起 AMS)
坑一:为了省两只 5.1kΩ,我把可逆性做掉了
我在样机上把 CC1、CC2 的 Rd 并在了一起,只用一个 5.1kΩ 到地。理由很朴素:两个 0.1% 精度的电阻要占 BOM 两行,而且”反正都是到地”。
这是错的。CC 的两个脚一个用来通 CC 信号,另一个要留给 VCONN;两只独立的 Rd 既是方向识别的基础,也是 e-marker 检测的前提。共用 Rd 的典型症状就是”换个方向插就不认”,而 Type-C 是可逆的,客户有一半概率插反——于是就成了”一半充电器不行”。
后来我又知道了第二件事:死电池(dead battery)场景下,两个 Rd 也都要挂。设备电池耗尽时主控还没上电,只能靠两只电阻被 Source 识别成一个合法 Sink,才能拿到 5V 把自己唤醒。共用一只电阻时,反向插入连唤醒的机会都没有。
坑二:我在 CC 上顺手并了一只 100nF
PCB review 时我加了一只 100nF 到地,理由是”CC 是敏感检测脚,加点滤波稳”。同时 CC 到连接器的走线我留了 3cm,旁边还放了一颗结电容不低的 TVS。
三样东西叠起来,CC 上的总电容到了 250pF 量级。后果分两层:
- 模拟层:ADC 抓分压要等建立,我在插上后 10ms 就采样,读到的是没充满的中间值,偶尔把 1.5A 档判成默认档;
- 数字层:PD 通信是 300kbps 的 BMC(下节细说),比特周期 3.33µs,边沿是唯一的信息载体。250pF 挂在上面,加上 TVS 的漏电,边沿直接被拉圆,接收端锁定不上——那只”每秒重连一次”的充电器,一半的账要算在这里。
结论很硬:CC 就是信号线,不是电源检测线,不要顺手加电容。TVS 要选低结电容(pF 量级)的型号,CC 走线尽可能短,别和 VBUS 长长的并排跑。
坑三:把”CC 分压”当成了”我能取多少电”
写自检程序时我犯过一个逻辑错:测到 1.68V 就认为”我拿到了 3A 权限”,于是不去做 PD 协商,直接按 3A 拉负载,结果被适配器过流保护拉掉,设备重启。
CC 分压只说明未协商前的默认电流上限,而且它是个很粗的信息。PD 一旦协商成功,电流上限由 PD 合同(RDO 里你申请的那个值)决定,跟 CC 静态电压没关系了。反过来说,CC 分压读数只有三个用途:判断插没插、判断方向、判断对方(未协商时)最大能给多少。
还有一个反直觉的点:在 PD3.0 里,Rp 的值被复用成了碰撞避免信号——Source 用 3A@5V 的 Rp 表示 SinkTxOK(允许 Sink 主动发起消息序列),用 1.5A@5V 表示 SinkTxNG(Sink 先别说话)。所以你在 Sink 侧读到的 CC 电压,语义会随着 PD 协商的推进改变,别把它当成一个”插上就固定”的状态量。
二、报文层:BMC、4b5b、16 位头,和”三个不同单位”
Vendor 给的资料里 PD 报文结构就一页纸,但真到了”为什么我的请求发出去对方不回”的时候,这一页纸是不够的。
帧格式
一次 PD 传输(Packet)的结构是:
Preamble(64 位) | SOP*(4 个 K 码, 20 位) | Message Header(16 位) | Data Objects(0~7 个 × 32 位) | CRC32(32 位) | EOP
几个容易忽略的细节:
- 前导码是”0/1 交替”的 64 位序列,以 0 开头、以 1 结尾,而且不做 4b5b 编码。它的唯一目的是让接收端锁定时钟(所以它本质是在告诉你”位率就是 300kbps”)。数据部分才做 4b5b——4 位数据编成 5 位符号,用来保证直流平衡。
- BMC 的规则是:每个比特时间(UI)的起点必有一次跳变;如果是 1,那么这个 UI 的中点再来一次跳变。所以信息不在电平上,在边沿的疏密上。这就是为什么第一节那只 100nF 是致命的:它不动电平,专杀边沿。
- 物理层用无线电式的 K 码做定界:SOP = Sync-1 ×3 + Sync-2;SOP’ = Sync-1,Sync-1,Sync-3,Sync-3;SOP” = Sync-1,Sync-3,Sync-1,Sync-3;硬重置 = RST-1 ×3 + RST-2;线缆重置 = RST-1,Sync-1,RST-1,Sync-3。接收端对 4 个 K 码允许 1 个错(三中取二),这是抗单比特错的设计。
- CRC 用 CRC-32,多项式 0x04C11DB7,初值 0xFFFFFFFF,残余量 0xC704DD7B,覆盖 header + 数据对象,不覆盖前导码和 K 码。
三个寻址目标是另一件容易忽视的事:
| 有序集 | 发给谁 | 典型用途 |
|---|---|---|
| SOP | 端口伙伴(充电器 ↔ 我的设备) | 能力协商、Request/Accept/PS_RDY |
| SOP’ | 近端线缆插头 | e-marker 的 Discover Identity |
| SOP” | 远端线缆插头 | 同上,另一头 |
我在调试器里抓包时干过一件蠢事:把所有非 SOP 的帧都当成噪声丢掉,结果 e-marker 的回应全被我扔了,线缆能力永远读不到,Source 也就永远只广播 3A 档——这就是”20V 只能出 60W”的直接原因之一。
Message Header 的 16 位
| 位 | 字段 | 说明 |
|---|---|---|
| bit 15 | Extended | 1 = 扩展消息(后面跟扩展头) |
| bits 14:12 | Number of Data Objects | 数据对象个数;为 0 且 Extended=0 即控制消息 |
| bits 11:9 | Message ID | 3 位滚动计数器,收到 GoodCRC 才加一 |
| bit 8 | Port Power Role | 0 = Sink,1 = Source |
| bits 7:6 | Specification Revision | 00 = 1.0,01 = 2.0,10 = 3.x |
| bit 5 | Port Data Role | 0 = UFP,1 = DFP |
| bits 4:0 | Message Type | 消息类型(控制/数据/扩展三张表) |
这里的 3 位 Message ID 是坑最多的一处,我在第五节单独说。
三、PDO / RDO:三套单位,我就在这里翻了车
Source 广播的 Source_Capabilities 里带 1~7 个 PDO(供电能力对象),Sink 回的 Request 里带一个 RDO(请求对象)。32 位,看手册五分钟就能”看懂”,但单位不统一:
| 对象 | 电压单位 | 电流单位 | 备注 |
|---|---|---|---|
| Fixed PDO / RDO | 50 mV / 10 mA | 10 mA | 固定档,如 5V/9V/15V/20V |
| Battery PDO | 50 mV | 功率 250 mW | 声明的是电压区间 + 总功率 |
| Variable PDO | 50 mV | 10 mA | 电压区间 + 电流 |
| APDO(PPS) | 100 mV | 50 mA | 电压区间;申请时另算 |
| PPS RDO | 20 mV | 50 mA | 20mV/50mA 步进就体现在这里 |
四个对象里出现了三种电压单位和三种电流/功率单位,而且它们都长成同一个 uint32_t。这就是我那个 bug 的温床。
我的 4 行代码
我最初的解析是这样的(错误版本,请勿照抄):
/* 错误示范:想当然按 100mV / 50mA 解所有 PDO */
uint16_t mv = ((raw >> 10) & 0x3FF) * 100; /* 单位用错:固定 PDO 是 50mV/LSB */
uint16_t ma = ((raw & 0x3FF)) * 50; /* 单位用错:固定 PDO 是 10mA/LSB */
uint8_t kind = (raw >> 30) & 0x03; /* 判了类型,但没按类型分支 */
对固定 PDO 来说,电压是 50mV/LSB、电流是 10mA/LSB。于是充电器广播的 20V/5A,被我解成了 10V/2.5A;9V 被我解成了 4.5V。
后果是链式的:我按解出来的能力去匹配”我想要的档位”,匹配到的 PDO 序号(Object Position)就跟着错。Sink 发出的 Request 里那个 Object Position 是 1-based,指向 Source 广播列表里的第几个 PDO——序号错了,等于我在跟充电器说”请给我第 3 档”,而第 3 档实际是 15V。于是要么被 Reject(然后我重试、超时、Hard Reset),要么对方给了一个我 DC/DC 不认识的电压,我的后级输入欠压复位——“一会 20V 一会掉 5V”的现象就是这么来的。
用工具把真报文解出来,问题一眼可见
我写了个离线解码器,把抓到的四个 PDO 贴进去:
$ python usbc_pd_calc.py pdo -x 0x2141912C,0x204641F4,0xC06E213C,0x4FC2D0B4
PDO 0x2141912C
类型:Fixed Supply(固定电压档)
电压 : 5.000 V (bits19:10, 50mV/LSB, 原始 100)
最大电流 : 3.000 A (bits9:0, 10mA/LSB, 原始 300)
最大功率 : 15.000 W
标志位 : DRP=1 ... EPR能力=1 峰值电流=0
PDO 0x204641F4
类型:Fixed Supply(固定电压档)
电压 : 20.000 V (bits19:10, 50mV/LSB, 原始 400)
最大电流 : 5.000 A (bits9:0, 10mA/LSB, 原始 500)
最大功率 : 100.000 W
PDO 0xC06E213C
类型:APDO(增强型供电对象)子类型:SPR PPS(可编程电源,PD3.0)
最高电压 : 11.000 V (100mV/LSB, 原始 110)
最低电压 : 3.300 V (100mV/LSB, 原始 33)
最大电流 : 3.000 A (50mA/LSB, 原始 60)
PDO 0x4FC2D0B4
类型:Battery(电池档,按电压区间+功率声明)
最高电压 : 12.600 V (bits29:20, 50mV/LSB)
最低电压 : 9.000 V (bits19:10, 50mV/LSB)
最大功率 : 45.000 W (bits9:0, 250mW/LSB, 原始 180)
一眼就看到”20V 档”在,而且它前面只有 5V 一档——我代码里那个”第 3 档”其实是 PPS 的 APDO,根本不接受固定档请求。
(上面这套解码:Fixed 判电压用 (raw >> 10) & 0x3FF、×50mV;电流 raw & 0x3FF、×10mA;Battery 的功率用 ×250mW;APDO 判类型用 (raw >> 30) == 3。位定义以手头规范版本的表格为准,PD3.1 在固定 PDO 里新增了 EPR 能力位等几个标志。)
RDO 的两个”同名不同义”
请求方向也有一个几乎必踩的坑,这是我把 pdo 解完才注意到的:
$ python usbc_pd_calc.py rdo 0x2004B12C
类型:固定/可变/电池 RDO(Sink 的请求)
对象位置 : 第 2 个 PDO (bits31:28;注意 0 为保留值,PDO 序号从 1 开始)
工作电流 : 3.000 A (bits19:10, 10mA/LSB)
最大电流 : 3.000 A (bits9:0, 10mA/LSB)
$ python usbc_pd_calc.py rdo 0x50038428 pps
类型:PPS RDO(Programmable RDO)
对象位置 : 第 5 个 APDO (bits30:28 —— 注意与固定 RDO 的 bits31:28 差一位)
输出电压 : 9.000 V (bits19:9, 20mV/LSB, 原始 450)
工作电流 : 2.000 A (bits6:0, 50mA/LSB, 原始 40)
固定 RDO 的 Object Position 在 bits31:28,PPS RDO 的在 bits30:28,差一位。 而且 PPS RDO 的电压在 bits19:9(20mV/LSB),固定 RDO 的电流在 bits19:10(10mA/LSB)——同一个位区间,两种含义。我用一份 build_rdo() 函数同时服务两种请求,”给 PPS 用固定格式组包”,对方的回复就是”给 3.3V”(PPS 的最小电压)或者干脆不给。
四、时序与”重置四兄弟”:为什么它会自己掉电
协议能跑通之后,剩下的是时序。PD 里最重要的几个定时器我整理成了一张表(也是脚本的一个模式):
| 参数 | 值 | 含义 |
|---|---|---|
| CRCReceiveTimer | 0.9~1.1 ms | 发完一帧等 GoodCRC 的窗口,超时即本次发送失败、重传 |
| tSenderResponse | 24~30 ms(PD2)/ PD3 CTS 27~33 ms | 等对端响应的窗口,取 27ms 两头都满足 |
| Source_Cap 广播周期 | 100~200 ms | 连接后 Source 反复广播直到收到 GoodCRC |
| tTypeCSinkWaitCap | 310~620 ms | Sink 等不到 Source_Cap 就发 Hard Reset |
| tPSHardReset | 25~35 ms | Source 收到 Hard Reset 后保持当前电压 |
| tSafe5V | ≤ 275 ms | 从高压降到 vSafe5V(4.75~5.5V) |
| tSafe0V | ≤ 650 ms | 降到 vSafe0V(≤ 0.8V) |
| tSrcTurnOn | ≤ 275 ms | 重新给 VBUS 上电到 5V |
| tSrcRecover | 0.66~1 s(SPR)/ 1.085~1.425 s(EPR) | Hard Reset 后 VBUS 保持在 0V 的时长 |
| SPR 档位转换 | 450~550 ms(nom 500) | 换档到新电压/电流 |
| EPR 档位转换 | 830~1020 ms(nom 925) | EPR 模式下的换档 |
四个”重置”要分清,不然面对现场日志你根本不知道该找谁:
| 类型 | 复位什么 | VBUS | 谁发起 |
|---|---|---|---|
| Soft Reset | 只复位协议层(MessageID 计数、重试计数)与策略引擎 | 不动 | 任一方 |
| Hard Reset | 电源 + 协议都回到默认态;退出 EPR 与所有备用模式 | 必须降到 vSafe0V 再回 5V | 任一方 |
| Data Reset | 复位数据链路、退出备用模式、给 VCONN 来一次上电循环 | 不动 | 任一方 |
| Cable Reset | 只复位线缆插头 | 不动 | 只有 DFP |
那只”每秒重连一次”的充电器
现在可以把症状对上号了:这是 Hard Reset 循环。链路里的某一方在按 tSenderResponse 的上限等不到响应,超时就发 Hard Reset;VBUS 归零、回到 5V、重新协商;协商又失败,循环。用户观感就是”每秒重连一次、屏幕闪一下”。
我这边有两处贡献:
第一处,MessageID 递增时机写错了。 我最初的代码长这样:
/* 错误示范:只要把帧发出去就把 MessageID 加一 */
send_pd_frame(msg);
g_msg_id = (g_msg_id + 1) & 0x07;
规范的要求是:MessageID 由发送方维护,只有在成功收到对端对本帧的 GoodCRC 之后才递增;如果收到的是自己发的重传,对端要靠 MessageID 判重。我提前加一,就制造了两种事故:对方收到的帧 ID 无规律,我的重传和对方的应答对不上;而当对端回了一个带”上一帧” ID 的 GoodCRC 时,我不知道该不该再发。正确写法是把 ID 绑在”待确认帧”上:
/* 正确:ID 与待确认帧绑定,收 GoodCRC 才推进 */
static uint8_t s_msg_id;
static uint8_t s_retry;
static void pd_label_retry(void) {
s_retry++;
if (s_retry > PD_N_RETRY_COUNT) {
pd_error_count[PD_ERR_RETRY_EXCEED]++;
s_msg_id = (s_msg_id + 1) & 0x07; /* 重试用尽,换 ID 再发 */
s_retry = 0;
}
}
static void pd_on_goodcrc(uint8_t ack_id) {
if (ack_id == s_msg_id) {
s_msg_id = (s_msg_id + 1) & 0x07; /* 这一帧确认过了,推进 */
s_retry = 0;
pd_timer_stop(TIM_SENDER_RESPONSE);
}
}
第二处,Hard Reset 后把 VBUS 归零当成了”拔出”。
/* 错误示范:VBUS 跌破 2V 就重建 Attach 流程 */
if (vbus_mv < 2000) {
phy_detach();
state = ST_UNATTACHED;
return;
}
这个判断害了我整整一天。Hard Reset 过程中 VBUS 必然降到 0.8V 以下,而且规范要求它停在 vSafe0V 达 tSrcRecover(0.66~1s),然后才重新上 5V。这是预期行为,不是拔出:判据是 CC 上的 Rd 声明还在不在(Hard Reset 不改变 Rp/Rd 声明),而不是 VBUS 有没有电。我按 VBUS 判拔出,于是每次对方 Hard Reset,我这边都当成拔插重建,两边的状态机永远对不上表——正好构成”每秒重连一次”。
顺便说 vSafe0V 的判据:规范写的是 ≤ 0.8V。我见过有人用 2V 当阈值(”电压很低了就算 0″),也见过用 1V 的。阈值放宽会误判”已经掉电”(其实 Source 还在放电途中),阈值收紧又永远等不到。这个数没必要改,就按 0.8V 写。
五、如果要做 PPS:20mV / 50mA 步进带来的麻烦
同一项目里我还试过用 PPS 给内部电池充电(跟随电池电压,少一级 DC/DC,少一份热)。PPS 的落地有三个点必须知道。
第一,PPS 是”范围 + 步进”,不是一个固定电压。
脚本里有个模式专门做这件事:
$ python usbc_pd_calc.py pps --v 8.4 --i 2.5
目标:8.400 V / 2.500 A
PPS 请求档位(RDO 编码 20mV / 50mA 步进)
电压字段 : 420 (x20mV) -> 实际请求 8.40 V
电流字段 : 50 (x50mA) -> 实际请求 2.50 A
合同功率 : 21.0 W
可覆盖该电压的 PPS 档(APDO 上限,Source 侧按 PDP 广播):
-- 5V Prog 3.3~5.9 V
OK 9V Prog 3.3~11.0 V
OK 15V Prog 3.3~16.0 V
OK 20V Prog 3.3~21.0 V
APDO 声明的是”最低电压 ~ 最高电压、最大电流”(电压 100mV/LSB、电流 50mA/LSB),你在 RDO 里申请时才用 20mV/50mA 步进。我给 PPS 组包时把 APDO 的 Max Voltage 按 50mV 解了(脑子里把 PPS RDO 的 20mV 糊到了 APDO 上),结果 11V 被解成 5.5V,于是我以为 PPS 只能到 5.5V,就挑了别的档——白折腾半天。
第二,PPS 要持续重协商,不是协商一次就完事。 电池电压在变,充电电流也跟着变,Sink 需要周期性地重发 Request 把 CHG 的工作点往上推。我第一版是”连上协商一次、然后就一直用”,结果充电电流卡在一个很低的值——因为合同里的工作点早就不是最优了。另外,PPS 里给电压”降档”是很常见的动作(源侧到功率上限时会降压保功率),所以你的后级 DC/DC 输入范围必须按 APDO 声明的全范围设计(3.3~21V),而不是按”我打算用 12V”设计。
第三,别把 PPS 和 EPR 混着谈。 PD3.1 的 EPR 是另一套:固定档扩到 28V / 36V / 48V,再加一个 AVS(可调电压电源,15~48V 区间),进入 EPR 之前要有一轮专门的模式协商,而且要求端口和线缆都支持、PDP 上限到 240W。EPR 的 APDO 与 SPR PPS 的 APDO 字段划分不同,代码里如果复用同一段解析,几乎一定出错。我的建议是:先支持固定档 + SPR PPS,把两件事都跑稳,再考虑 EPR。
六、固件侧:把”偶发”变成”可统计”
这一类现场问题的共同点是”偶发、说不清、复现不了”。我的做法和之前几篇一样:把每一次协商结果都变成能被上报的整数计数。
零 malloc、禁 %f,报文解析缓冲区用静态数组,中断里只做边沿捕获和位时间测量,解析放到任务里:
/* PD 报文缓冲:header 2B + 最多 7 个数据对象 28B(扩展消息另按最大长度开静态缓冲) */
#define PD_MSG_MAX 34
static uint8_t s_rx_buf[PD_MSG_MAX];
static uint8_t s_rx_len;
typedef enum {
PD_ERR_NONE = 0,
PD_ERR_CRC, /* 物理层 CRC 错 */
PD_ERR_BAD_SOP, /* K 码识别失败 */
PD_ERR_BAD_ID, /* MessageID 不连续(对端重传/我丢帧) */
PD_ERR_GOODCRC_TO, /* 等 GoodCRC 超时 */
PD_ERR_RESPONSE_TO, /* 等对端响应超时 */
PD_ERR_REJECT, /* 请求被 Reject */
PD_ERR_UNSUPPORTED, /* 对端回 Not_Supported */
PD_ERR_MAX
} pd_err_t;
static uint16_t s_err_cnt[PD_ERR_MAX];
static uint16_t s_hard_reset_cnt;
static uint16_t s_soft_reset_cnt;
static uint16_t s_nego_ok_cnt;
static uint16_t s_nego_fail_cnt;
上报的时候不用 %f(本站一路的约定:%f 在 newlib-nano 里会走 _dtoa_r 从而触发 malloc,Release 配置下还可能直接输出空字符串),自己定点格式化:
/* 把 mV 打成 "12.000" 这样的定点字符串,不用 %f、不分配内存 */
static void fmt_mv(char *out, int mv) /* out 至少 9 字节 */
{
int i = 0;
if (mv < 0) { out[i++] = '-'; mv = -mv; }
out[i++] = (char)('0' + (mv / 10000) % 10);
out[i++] = (char)('0' + (mv / 1000) % 10);
out[i++] = '.';
out[i++] = (char)('0' + (mv / 100) % 10);
out[i++] = (char)('0' + (mv / 10) % 10);
out[i++] = (char)('0' + mv % 10);
out[i] = '\0';
}
最后一个很值钱的手段:把 PD 合同和实测 VBUS 做交叉校验。
/* 合同是 12V/2A,实测只有 5V -> 对方回退了(发生过 Hard Reset 或重协商失败) */
if (pd_contract_mv > 7000 && vbus_mv < 6000) {
s_nego_fail_cnt++;
log_evt(EVT_PD_CONTRACT_LOST, pd_contract_mv, vbus_mv); /* 整数上报,时间戳用 tick */
}
这件事的价值不在”发现故障”——它本来就在闪;而在于把”某只充电器不行”变成”这只充电器在 30 分钟内 Hard Reset 47 次、每次卡在 tSenderResponse 超时”。有了这个,售后才肯把故障件寄回来,我也才有机会在实验室里用同一只充电器复现。
七、现场分诊表
这是我这次沉淀下来的东西,按”症状 → 可能原因 → 先查什么”排:
| 症状 | 可能原因 | 先查什么 |
|---|---|---|
| 永远只拿 5V,从不升压 | CC 被判成默认档;没发 Request;Request 被 Reject;PDO 单位解析错 | 量化 CC1/CC2 静态电压;再看有没有发 Request、对象位置对不对 |
| 插上就每秒重连一次 | Hard Reset 循环:MessageID 计数错 / 应答超时 / GoodCRC 没及时发 | 数硬重置次数;核对 MessageID 只在收到 GoodCRC 后递增 |
| 一会 20V 一会掉 5V | Sink 侧 DC/DC 输入欠压复位;电流超合同触发 Source 回退 | 同时抓 VBUS 与合同值,分清是对方回退还是自己复位 |
| 20V 只能出 60W | 线缆没有 e-marker,系统把上限锁在 3A | 换 5A 线复测;确认代码处理了 SOP’ 的 Discover Identity 回应 |
| 某只充电器完全不识别 | Rd 共用/方向识别失败;CC 上并联电容;Ra 未处理 | CC1/CC2 各挂独立 Rd;CC 走线短、不要并电容 |
| PPS 请求 9V 却给 3.3V | 请求电压超出 APDO 声明区间(单位算错最常见) | 把 APDO 的 Max/Min 按 100mV 解出来,核对请求值在不在区间内 |
| 充电电流上不去 | PPS 需要跟随电池电压持续重协商 | 看有没有周期重发 Request,有没有 GiveBack |
仪器方面,能借到带 PD 解码的协议分析仪/电压电流表是最省事的。没有的话,退而求其次的两招:一是用 GPIO 在状态机关键迁移点(收到 Source_Cap / 发 Request / 收 PS_RDY / Hard Reset)打标,与 VBUS 波形一起看,四通道示波器足够;二是把 CC 直接接到一个支持边沿捕获的定时器上数位时间——300kbps 的位周期是 3.33µs,如果捕获到的间隔忽长忽短,说明你的 CC 负载或者对方驱动有问题,不用猜。
线缆压降这种事也别凭感觉,脚本直接算:
$ python usbc_pd_calc.py drop --awg 20 --len 1 --i 3
线缆压降估算(铜,20AWG,长度 1.00 m,电流 3.00 A)
单根导体电阻 : 0.0333 Ω (0.0333 Ω/m)
往返回路电阻 : 0.0666 Ω(VBUS 去 + GND 回;连接器触点另计 20~50mΩ/触点)
线缆压降 : 0.200 V
线缆发热功率 : 0.599 W
VBUS 20.0 V -> 设备端约 19.800 V(压降占比 1.00%)
1 米 20AWG 在 3A 下压降 0.2V、发热 0.6W;换成 28AWG 的”细软线”同一条件压降 1.28V、发热 3.8W——这就是为什么”随手拿的那根线”经常是电源问题的第一嫌疑人。
八、落地自查清单
- ☐ Sink 侧 CC1、CC2 各挂一个独立的 5.1kΩ Rd(死电池场景也要能识别)
- ☐ CC 线上不并联滤波电容;TVS 选低结电容、靠连接器放置;CC 走线尽量短
- ☐ 分压判读用 0.66V / 1.23V 门限,且明确它只约束”未协商前的默认电流”
- ☐ 明确 CC 电压在 PD3.0 下还带 SinkTxOK / SinkTxNG 语义,不能当固定状态量
- ☐ PDO 解析分类型:Fixed/Variable 用 50mV+10mA,Battery 用 50mV+250mW,APDO 用 100mV+50mA
- ☐ RDO 分类型:固定 RDO 的 Object Position 在 bits31:28,PPS RDO 在 bits30:28 且电压 20mV、电流 50mA
- ☐ Object Position 是 1-based,与 Source 广播列表逐项对齐(我按
[0]取过一次,正好错一档) - ☐ MessageID 只在收到 GoodCRC 后递增;重传沿用同一个 ID
- ☐ tSenderResponse 取 27ms(同时落在 PD2 的 24~30ms 与 PD3 CTS 的 27~33ms 区间)
- ☐ 判”拔出”看 CC 上的 Rd 是否还在,不看 VBUS;Hard Reset 期间 VBUS 归零是预期行为
- ☐ vSafe0V 判据按 ≤0.8V 写,vSafe5V 按 4.75~5.5V 写
- ☐ 正确处理 SOP’(线缆 e-marker):不把非 SOP 的帧当噪声丢掉
- ☐ >3A 场景必须有 e-marker 线缆,并且 Source 得读到线缆能力才敢广播 5A
- ☐ 若做 PPS:APDO 按区间校验请求值、RDO 用 20mV/50mA 步进、跟随电池电压持续重协商
- ☐ 后级 DC/DC 输入范围按 APDO 全范围(3.3~21V)或 EPR 范围设计,不按单一电压设计
- ☐ 固件侧统计:硬重置次数、CRC 错、GoodCRC 超时、响应超时、Reject 次数、协商成功/失败
- ☐ 上报用整数 + 定点格式化,零 malloc、禁用
%f
相关阅读
- 上拉/下拉电阻取值实战:同一颗 I2C,为什么别人 4.7kΩ 我得换 2.2kΩ 才稳 —— 分压、门限、静态功耗的通用算法
- 信号完整性与串阻源端匹配实战:一条 25MHz 时钟线上的振铃,逼我把 33Ω 的位置和取值都算了个明白 —— 为什么”给信号线并个电容”通常是错的方向
- ESD/浪涌防护实战:一次雷雨季的成批返修,逼我把 TVS、放电管和接地路径从头补了一遍课 —— 结电容是真的会毁边沿
- LDO 与 DCDC 电源设计实战:一次 ADC 采样跳动,逼我把整块板子的电源重做了一遍 —— 输入电压范围与纹波预算怎么留
- 多路电源上电时序控制实战:一块”启动看运气”的板子,逼我把上电顺序当成正式设计项来做 —— 电源来回切换时后级会发生什么
- CAN FD 与经典 CAN 混合组网实战 —— 同样是”协议层分诊 + 物理层实测量”的组合拳
- 电池管理与 SOC 估算实战 —— 同一台设备里”取电”的另一半
本文配套脚本:_tools/usbc_pd_calc.py —— PDO/RDO 解码(Fixed / Battery / Variable / APDO 各自单位区分)、CC 静态电压与档位判定、PPS 20mV/50mA 档位换算、线缆压降与线损估算、PD 关键定时器速查、现场症状分诊表。全部离线,纯标准库,不联网。
下次打算写:充电 IC 外围设计里的坑(截止电压精度、充电电流与温升、NTC 放哪儿、以及”涓流阶段到底该不该让它涓流”),或者 Type-C 口在工业现场的 ESD/浪涌防护。哪个先写,看这周的返修件先到哪个。