USB状态更新充电事件:内核5.15链路原理与量产排障指南
年前在跟一个量产项目现象很刁钻同一台设备插上原装充电器状态栏没有任何变化拔掉电源线的那一刻反而弹出了“正在充电”。最开始怀疑是充电IC坏了换了两块主板依然复现后来才意识到问题根本不在硬件而是 USB 状态更新没有按预期转换成充电事件。这个项目用的是 Unisoc 平台内核切在 kernel 5.15问题恰好卡在 USB 状态检测、内核事件上报和充电IC联动这条链路上。当时我把/sys/class/power_supply/usb/online打开盯了好几分钟看着它从 1 变 0 又变 1才彻底搞明白标题里写的“USB 状态更新充电事件”本质上不是“读到VBUS高电平就完事”这么简单而是要保证一次插拔动作能准确地、按顺序地触发上层关心的充电状态变化。这篇文章就围绕这个主题把链路原理、方案选型、设备树配置、内核上报、充电类型映射以及实测中踩过的坑完整梳理一遍适合做 Android BSP、充电驱动、TWS 充电仓或者智能硬件 Linux 驱动的工程师参考。1. 需求拆解一次USB插拔要穿越多少层才能变成充电图标1.1 用户看到的“正在充电”到底是什么从用户视角看充电图标只是一个 UI 变化。但从内核工程师的视角看这背后是一条完整的事件链物理 VBUS 出现高电平驱动检测到 USB 插入power_supply或extcon子系统更新状态内核发出uevent用户态的healthd/HealthService收到事件最终由SystemUI刷新锁屏充电状态。每一步之间都有可能导致状态不一致。尤其是 Unisoc 平台这类项目BSP 驱动往往由芯片厂商、方案商、整机厂多轮 patch 叠加USB 状态更新链路可能被不同的驱动分别维护。你这个“充电事件”只要有一层没有同步呈现出来的现象就千奇百怪插上没反应、拔了还在充、充电速率被锁死、Type-C 正反插表现不一致等等。1.2 USB状态与充电事件为什么不能直接画等号很多初次接触这块的同学容易把“USB 状态”和“充电事件”当成一回事这是最大的误区。USB 状态是一个宽泛概念它至少包括连接状态、断开状态、Type-C 方向、数据角色、PD 协商结果等。而充电事件只是其中一小部分而且它关心的是另外三个问题线是否插上了online 状态插上的是什么类型的 USB 口SDP/CDP/DCP/PD当前允许从 VBUS 抽取多大电流。在 kernel 5.15 上驱动至少要同时回答这三个问题才能算一次完整的充电事件上报。如果只把“VBUS 拉高”当成“充电器插入”来处理就会得到一台能显示充电、但电流永远被锁在 500mA 的机器。原因很简单没有告诉上层这是 DCP 还是 SDP上层只能按最保守的 USB 口来推测。1.3 为什么在 kernel 5.15 上这个问题更值得关注kernel 5.15 相比老内核变化最明显的就是 USB Type-C 子系统的成熟度大幅提升typec、usb_role_switch、extcon这些机制都在往“各司其职”的方向演进。但现实项目里Android 传统链路依然保留了power_supply那一套两套体系交叉作用很容易出现状态源重复或冲突。我在实际项目里遇到过类似状态typec驱动负责上报 USB 方向与角色充电驱动负责上报charge_type两者各自维护一套状态机只要有一方没有把状态同步到另一方就会出现“在线状态已经变了但充电事件没跟上”的情况。这也是为什么在 Unisoc kernel 5.15 这种组合下必须把链路理清楚再动手改代码。2. VBUS检测方案选型ADC轮询、GPIO中断还是PHY状态2.1 三种主流检测方式的取舍Unisoc 平台做 USB 状态检测最常用的就是下面三种方式。我按实际工程中的使用频率和坑的多少来对比检测方式原理优点缺点典型场景PMIC ADC 轮询通过 ADC 通道读 VBUS 电压阈值判定不依赖 GPIO 占用硬件简单能读到电压数值实时性差VBUS 放电慢时在线状态残留低成本方案、工程机GPIO 中断VBUS 经分压之后接到 GPIO上升/下降沿触发响应快逻辑简单功耗低需要确认分压与电压域对毛刺敏感绝大部分量产机USB PHY 状态PHY 内部监测总线有效状态状态最准确不需要额外检测硬件硬件排线必须把 VBUS 检测接到 PHY部分平台不好移植标准 Type-C 口我在量产项目里更倾向于“GPIO 中断为主 ADC 复核为辅”的组合。GPIO 负责快速响应插拔ADC 负责二次确认防止 GPIO 检测线上因为分压电容、ESD 器件导致的电压爬升或跌落过慢而误触发。2.2 设备树里我是怎么配的以某个 Unisoc 平台项目为例这里不写具体芯片型号配置思路是通用的设备树中通常定义一个节点来描述 VBUS 检测 GPIOvbus_detect: vbus-detect { compatible vendor,vbus-detect; vbus-gpio ap_gpio 47 GPIO_ACTIVE_HIGH; debounce-ms 30; wakeup-source; status okay; };三个字段需要注意vbus-gpioGPIO 的active电平取决于硬件分压方式有些硬件 VBUS 在线时 GPIO 被拉低有些是拉高必须对照原理图确认不要只看默认配置。debounce-ms这个值太短会引入大量误报太长会丢失快速插拔。我一般从 20ms 到 50ms 之间调最后量产机型稳定在 30ms。wakeup-source关闭屏幕时VBUS 上升沿必须能够唤醒 AP。这个字段漏掉之后会出现息屏插入充电器系统不亮屏也不提示充电的诡异现象。2.3 中断触发驱动的几个工程细节GPIO 中断注册没那么复杂但有几个点非常容易被坑第一上升沿和下降沿都必须注册。很多驱动只处理了上升沿结果 USB 断开时状态完全没更新表现为“拔了线还在充”。这个 bug 很隐蔽因为拔掉瞬间电量还在涨测试不容易立刻发现。第二中断处理函数里不要做重量级操作。正确做法是request_threaded_irq或者只置标志位、起一个delayed_work真正去更新online状态和上报 uevent 的动作放到线程上下文。如果在中断上下文直接操作充电IC的 I2C大概率会遇到 I2C 总线忙、mutex 冲突直接把系统卡住。第三针对 GPIO 毛刺驱动里要配合mod_delayed_work做二次确认中断触发后不立即更新状态而是等若干毫秒再读一次 GPIO 电平确认稳定后才算数。我遇到过一次项目ESD 防护器件导致插拔瞬间 GPIO 出现约 10ms 的抖动不加确认机制的话一次插拔会触发三四次状态翻转。3. 状态上报路径选择power_supply与extcon谁才是充电事件的出口3.1 两条路径的本质区别检测到 VBUS 之后下一步就是状态上报。Unisoc 平台 kernel 5.15 里常见的有两条路径power_supply子系统上报或者extcon子系统上报。它们的分工其实很清楚。extcon面向外部连接器适合上报 Type-C 方向、USB 线类型、音频附件等内核里监听的一方是其他驱动power_supply面向电源设备上报的是online、type、voltage_now、current_now这些属性最终用户态healthd读的就是它。如果你把充电事件放在extcon里上报用户态不一定能直接感知还需要另一层驱动做中转。所以我的结论很直接最终给上层看的充电在线状态必须以power_supply为出口。extcon可以作为中间状态存在但不能把外设驱动对外报的关键信息漏掉。3.2 一次uevent是怎么组装出来的power_supply触发上报的核心调用是power_supply_changed()它会生成一条 uevent。在/sys/class/power_supply/usb/节点上uevent文件里可以看到类似内容POWER_SUPPLY_NAMEusb POWER_SUPPLY_TYPEUSB_DCP POWER_SUPPLY_ONLINE1这里有一个关键点POWER_SUPPLY_TYPE和POWER_SUPPLY_ONLINE的出现顺序是由驱动内部更新属性的时序决定的。我在项目里经过反复验证得到的结论是插入时一定要先更新type再更新online拔线时则反过来先清online再回退type。为什么用户态处理 uevent 时通常是按事件整体来读。如果online1先到而type还是旧的USB上层可能按 SDP 限流逻辑走一遍后面再收到 type 变更事件时充电IC已经按 500mA 初始化了需要额外一次 renegotiate 才能提升电流。这个时间差在现场表现为“插上适配器之后要过几秒钟电流才上来”。3.3 5.15内核里typec、extcon、power_supply如何协同kernel 5.15 上还有一个绕不开的角色typec子系统。它会管理 CC 引脚、数据角色、PD 协商等但它的状态并不直接等于充电状态。我的处理习惯是typec驱动只负责上报连接器状态和角色的变化extcon或专门的 VBUS 检测驱动负责监听这些变化并过滤出“对外设来说有价值”的信息power_supply驱动作为最后的出口把过滤后的结果转换成online和type充电IC驱动挂在power_supply上通过power_supply_changed的回调收到事件。这样就把“检测到底层状态”和“消费上层事件”解耦了。调试的时候也容易隔离开看typec状态判断物理连接看power_supply状态判断充电逻辑哪一层不对就查哪一层。4. 充电事件加工从online1到充电IC输入限流的完整映射4.1 为什么BC1.2识别和PD协商要排在状态后面“VBUS 有电压”并不等于“这是个充电器”。USB 口类型决定了 VBUS 能提供多大的电流所以在power_supply的type属性上不能简单填一个USB。常见的类型包括USB默认 SDP 逻辑电流上限一般 500mAUSB_DCP专用充电口比如常见的充电头电流按适配器能力来USB_CDP可以一边传数据一边充电的类型USB_PDPD 协商后的受电能力电流电压由 PD 协议决定。Unisoc 平台的充电驱动一般会先通过 D/D- 检测完成 BC1.2 识别或者等待 PD 协商结果然后才把type更新成最终类型。在这之前online可以先置 1但type不要急着改。如果这个顺序没处理好上层会误以为这是一个标准 USB 口把充电电流限制得很保守。BC1.2 检测本身需要时间D 和 D- 的采样、比较器的建立稳定、软件消抖可能让 type 更新比 VBUS 出现晚几十到几百毫秒。这个时间窗口里充电IC需要有一个明确的默认状态VBUS 在线但类型未知按 500mA 预充等类型确认后再切换限流。4.2 一张表看懂USB状态到充电事件的映射我在量产项目里维护过一张状态映射表排查问题全靠它对照底层状态power_supply typeonline充电行为VBUS未连接USB0关闭充电路径charge_typeNONEVBUS在线BC1.2未完成USB1按SDP预充限制500mABC1.2识别为DCPUSB_DCP1按DCP限流走适配器快速充电BC1.2识别为CDPUSB_CDP1允许数据充电同时进行BC1.2识别为SDPUSB_SDP1按标准USB口限流识别为PD适配器USB_PD1等待PD协商按PDO电压电流配置这张表看着简单但实际项目里有不少分支细节。比如支持无线充电的设备无线充电器的online也是 1但它的usb_type是USB或者Unknown不能因为充电事件触发就顺手去初始化 USB 的 BC1.2 检测逻辑。又比如带着 USB 线做 DFU 升级时online1是必然的但此时不能走正常充电路径去降低数据通信的可靠性。4.3 顺序问题与事件节流的工程处理插拔状态变化时最忌讳的就是每个字段都独立触发一次power_supply_changed()。一次插入动作如果同时更新了online、type、voltage_now就会连续冒出好几条 uevent上层逻辑在短时间窗口内会被反复唤醒日志里能看到一长串重复事件。我的做法是做一个简单的“事件节流器”状态发生变化时先把所有需要更新的字段写入本地缓存再统一调用一次power_supply_changed()。这样一次插拔最多触发一条有效事件。如果状态连续抖动比如劣质线材导致接触不良还可以在驱动里加一个状态机要求新状态必须稳定保持超过 100ms 才允许对外上报。这个设计我当时是从“防 uevent 风暴”的角度做的后来发现它顺带解决了另一个问题用户态拿到的事件是全量的、自洽的不会读到“typeUSB_DCP 但 online0”这种中间态。5. 踩坑复盘误报、漏报和时延问题的完整排查链路5.1 案例一拔线后状态栏一直显示“正在充电”这个就是我文章开头说的那个诡异的量产项目问题。先说排查链路再给结论。第一步硬件确认。用万用表量 VBUS拔线之后电压确实是按预期跌落的。这说明问题不在物理电源。第二步确认 GPIO 电平。在设备树节点下读 GPIO 值发现拔线瞬间 GPIO 已经被硬件拉到了空闲电平。说明硬件检测部分也是对的。第三步看内核态状态。cat /sys/class/power_supply/usb/online发现它依然是 1。到这里问题就锁定在驱动软件层面GPIO 中断没有成功触发或者触发了但状态没有更新。第四步开 dynamic_debug 看中断日志。结果发现拔线下降沿没有打印任何中断服务函数日志。再翻代码发现驱动注册中断时用的是IRQF_TRIGGER_RISING只处理了上升沿。根因就是这么简单驱动只注册了 VBUS 插入中断把断开中断漏掉了。修复方法也很直接改成IRQF_TRIGGER_RISING | IRQF_TRIGGER_FALLING同时配合 debounce 机制。这个坑我认为是这一类问题里最常见的一类排查链路本身比最终修复更有参考价值。5.2 案例二插入后充电状态更新明显偏慢另外一个项目里插入充电器后锁屏显示充电图标要等 2 到 3 秒。如果只看现象很容易怀疑是系统 UI 性能问题但其实链路卡在内核。排查过程里最关键的证据来自 tracepoint。kernel 5.15 里power_supply_changed事件可以在/sys/kernel/tracing/events/power/power_supply_changed/下抓到。我通过 trace 发现VBUS 中断早就触发了但对应的power_supply_changed()调用被阻塞了很久。继续往下挖阻塞点在充电IC驱动向power_supply_changed注册的回调里这个回调直接在做 I2C 读操作而另一个并发线程持有 I2C bus 的 mutex刚好赶上它在做长时间的温度补偿计算。这类问题的改法不是改 USB 状态检测而是改充电IC驱动的回调策略。中断来的那一刻回调里只标记“需要处理”由独立的工作队列去做真正的 I2C 操作和状态轮询。这样 USB 状态上报链路就不会被 I2C 卡住。5.3 调试USB状态更新充电事件的三板斧调试这类问题我最常用是三个手段优先级从高到低sysfs 节点cat /sys/class/power_supply/usb/online、cat /sys/class/power_supply/usb/type、cat /sys/class/power_supply/battery/status最快的状态确认方式。dynamic_debugecho file drivers/power/supply/* p /sys/kernel/debug/dynamic_debug/control把内核日志打开直接看power_supply层发生了什么。tracepoint/sys/kernel/tracing/events/power/power_supply_changed/能准确抓到每次上报的时间点用于判定“是没上报还是上报太晚”。用户态这边logcat -s HealthService BatteryProperties可以直接看到上层收到的事件。如果一个事件在内核里已经上报了但用户态没有收到那问题一般在ueventd的权限过滤或者 SELinux 策略上而不是驱动本身。5.4 量产前的USB充电状态验证清单最后分享一份我在项目里持续维护的验证清单专门用来测 USB 状态更新到充电事件的完整性测试项操作预期结果标准充电器插入息屏状态下插入状态栏充电图标出现typeUSB_DCP电流达到适配器能力标准充电器拔出充电中直接拔掉状态栏充电图标消失online0charge_type回落PC USB口插入插电脑USB口typeUSB_SDP限流500mA数据功能正常正向/反向插入Type-C 正反各插一次状态事件一致性无差异化表现快速连续插拔3秒内插拔5次无异常死机无 uevent 风暴状态稳定跟随低电量插入电量低于5%时插入充电路径开启无断流图标正常显示息屏唤醒息屏时插入充电器设备唤醒并提示充电状态我个人调试这类问题的通用套路是先把链路每一步的可观测性做出来。硬件上脉冲计数看插拔是否真实发生sysfs 看内核状态是否跟随tracepoint 看事件上报时延logcat 看用户态是否消费。哪一层数据对不上就缩小到哪一层去查。大多数 USB 状态更新充电事件相关的疑难日志追到最后往往不是高深的技术而是某个驱动少注册了一个中断、某个回调里做了重操作、或者 devicetree 里漏掉了一个wakeup-source。把链路看清楚了问题就藏不住。