USB Type-C / PD 电源协商实战:同一台设备,换个充电器就从 20V 掉到 5V——最后 bug 是我自己那 4 行 PDO 解析

2026年10月8日 0 By admin

上周被一只充电器教做人了。

板子是个用 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

相关阅读


本文配套脚本:_tools/usbc_pd_calc.py —— PDO/RDO 解码(Fixed / Battery / Variable / APDO 各自单位区分)、CC 静态电压与档位判定、PPS 20mV/50mA 档位换算、线缆压降与线损估算、PD 关键定时器速查、现场症状分诊表。全部离线,纯标准库,不联网。

下次打算写:充电 IC 外围设计里的坑(截止电压精度、充电电流与温升、NTC 放哪儿、以及”涓流阶段到底该不该让它涓流”),或者 Type-C 口在工业现场的 ESD/浪涌防护。哪个先写,看这周的返修件先到哪个。