1. 项目背景与整体拆解:从USB插拨到充电事件上报
做了这些年BSP和内核驱动,我越来越觉得,真正折磨人的往往不是某个算法或者框架本身,而是硬件状态和软件事件这两条线之间的对接。最近我刚好在基于kernel 5.15的一套移动平台上,处理了一个典型的“USB状态更新充电事件”问题:USB口的状态变化没有正确驱动PMIC充电单元更新,导致上层拿到的充电事件要么不来,要么来了也是错的。
字面上看,这个问题就是:当用户把USB线插入设备,或者拔掉设备,内核能不能及时把物理连接状态翻译成一个充电器驱动能理解的“输入源类型”,再通过power_supply框架把这个类型和充放电状态以事件的形式通知出去。这看起来是个小功能,但在手机、平板、IoT设备上牵一发动全身。上层App收到“正在充电”事件后,才会限制某些高耗电功能,比如关掉后台大流量下载、降低屏幕亮度;系统服务收到“拔电”事件后,才会从性能模式切回省电策略。所以这条链路是否通畅,直接影响用户的充电体验和整机功耗表现。
在kernel 5.15上做这件事,比在4.x上多了一些需要重新梳理的地方。5.15是Android GKI的核心版本之一,内核把很多通用驱动框架重新做了分层,平台相关的驱动如果还按老方式直接挂在某一个Kconfig下,很容易出现编译过了但运行时不工作的情况。我在实际项目中就遇到过:extcon和typec两套体系同时存在,充电器驱动订阅了某一个事件源,但实际插拔时另一个事件源在传播,导致状态永远不对。
这篇文章的读者,我默认是BSP驱动工程师、内核开发人员,或者被充电相关Bug缠身的应用开发者。文中我会从机制讲到实操,再给出我实际调试时的现场记录和排错清单。如果你想临时修一个充电图标不显示的问题,可以直接跳到第四部分看排查表;如果你想把这条链路彻底搞懂,建议顺着看完。
1.1 核心链路参与方:USB控制器、PMIC充电单元、power_supply框架
先把术语和角色对齐,后面聊起来会高效很多。
第一参与方是USB控制器。在绝大多数移动SoC里,USB控制器同时支持device模式、host模式和OTG,常用驱动是dwc3。dwc3关注的是USB协议本身,包括连接、复位、枚举、挂起唤醒。当插上充电器时,dwc3不一定真的完成USB枚举——很多充电头根本没有数据传输能力,只会拉高VBUS并往D+/D-上加偏置电压。这时候USB控制器可能只产生一个连接中断,然后停在未枚举状态。
第二参与方是PMIC里面的充电管理单元。它掌握着电池充电路径,例如输入过压保护、输入限流、预充电、恒流、恒压这些流程都由它完成。PMIC内部会配置一组与充电状态相关的寄存器,像是输入源类型、充电状态机等。大部分PMIC还自带VBUS检测和充电状态中断,能够独立感知“外部电源接入了”,但是感知不到接入的是PC、充电头还是快充适配器。
第三参与方是内核的power_supply框架。它屏蔽了底层硬件的差异,把充电器、电池统一抽象成类设备。用户空间看到的是/sys/class/power_supply/下面的一个个节点,例如usb、ac、main_battery。充电器驱动在检测到输入源变化后,会更新这些节点的属性并调用power_supply_changed(),触发uevent广播。上层框架只要监听uevent就能刷新状态。
这三者之间的关系可以概括为:USB控制器和PHY负责“识别”,PMIC负责“执行”,power_supply负责“广播”。“USB状态更新充电事件”这件事,本质上就是把第一方识别到的客观事实,翻译成第三方能广播给用户空间的充电事件。
1.2 kernel 5.15带来的变数:GKI模块化与Type-C新老框架并存
要理解kernel 5.15上这个题目为什么值得单独写一篇,还得说清楚GKI和Type-C的变数。
GKI把内核核心部分固定下来,厂商只能以内核模块形式加入驱动。这在逻辑上更干净,但也带来实际约束:一些原本通过内建符号直接调用的逻辑,现在必须使用EXPORT_SYMBOL接口;一些驱动加载顺序受模块加载依赖制约。充电器驱动如果依赖USB状态通知,而这个通知来自另一个晚加载的模块,就会出现注册notifier时对方还没就绪、然后一直等不到状态更新的情况。
Type-C框架的引入同样值得注意。以前很多设备还在用micro-B口,USB状态基本靠VBUS和D+/D-识别,逻辑相对简单;现在Type-C口带CC引脚,状态检测既可以通过CC引脚的电阻实现,也可以通过TCPC用I2C访问。kernel 5.15里,完整的软件栈包括typec_class、typec_connector_class、tcpm、tcpc,外加上层端口管理。这就形成了一套和老的extcon并行的新传播路径。适配时稍不注意,充电器驱动订阅的是extcon的上报,但硬件状态实际走的是typec框架,两条路永远不碰头。
所以,这篇文章既讲通用机制,也讲我在kernel 5.15平台上的实际落地做法。下面从机制开始,逐步进入实操。
2. USB状态检测与充电事件的机制拆解
2.1 物理插入到“接入类型确认”的完整过程
进入代码前,先模拟一遍插入充电头的物理过程。
当你把充电线插入设备,首先是VBUS线接触,PMIC和USB PHY都会看到VBUS电压开始上升。VBUS上升不是瞬间完成的,因为线上有电容和接地寄生,会有一个从几百微秒到几毫秒不等的爬升过程。硬件上,这个上升沿通常会被两个位置捕获:USB PHY的VBUS检测比较器,以及PMIC的VBUS GPIO或ADC通道。
接下来是角色判断。如果是普通USB口,驱动会进入BC1.2握手流程。检测原理大致是:先看D+和D-是否有短路关系。DCP专用充电端口的D+和D-之间通常是短路的;CDP则是先把D+拉高,看D-有没有同步响应;SDP是标准电脑口,D+/D-电压由USB host侧控制。通过一组时序状态机,内核驱动最终得到接入类型。
如果是Type-C口,判断则由CC引脚上的电阻决定。接入的受电方和供电方各自通过CC引脚的上拉或下拉电阻来表达自己的角色和电流能力。Type-C控制器解码这些电阻值,得到“能提供多少电流”的初始能力,再通过PD协商进一步获取上报的功率档位。
这类信息在驱动里的最终形态,不是一个单一标志,而是一组状态。常见的有:连接事件、cable方向、USB设备角色、充电器类型、电流能力。只有把这一组状态全部确认完,才能可靠地生成充电事件。
2.2 充电器类型与充电参数的关系
充电器驱动在拿到类型后到底要干什么?这需要把类型对应的参数说清楚。
在USB BC1.2规范里,充电端口分成三类。
- SDP(Standard Downstream Port):标准USB端口,最大输出一般500mA,主要用于数据传输,电流非常有限。
- CDP(Charging Downstream Port):既可以传输数据,又能提供最大1.5A左右的充电电流。
- DCP(Dedicated Charging Port):专用于充电,数据线短接或不接,能提供1.5A甚至更高的电流。
除此之外,现在的设备还面临PD协议。PD协商成功之后,电流能力不再是一档固定值,而是可动态协商的变量。例如一个30W适配器,可能协商出5V/3A、9V/3A、12V/2.5A等不同档位。这个信息最终也要反馈给充电器驱动,用于设置输入限流和目标电压。
充电器驱动拿到这些信息之后,PMIC的输入限流寄存器、输入过压阈值和动态电源路径管理选项就会跟着调整。比如识别到SDP,系统可能把输入限流设置到500mA以内,甚至提示“慢速充电”;识别到DCP,则按高电流充电配置。如果USB状态没有更新或者类型判断错了,充电参数自然就是错的。实际项目里最常见的现象是插上快充头却只有普通慢充速度,多半就发生在这一环。
2.3 为什么kernel 5.15里这条链路更“脆弱”
把机制讲完,回答一下为什么kernel 5.15上问题更容易暴露。我的观点有三个。
第一,模块化导致依赖关系复杂。GKI框架下,模块加载顺序不完全由链接顺序控制,而是由加载时的依赖关系决定。如果上游模块要监听下游模块的状态,而下游还没加载完,notifier注册就会失败。很多驱动为了规避这个问题,用了延迟注册或轮询,反而增加了调试难度。
第二,接口多样化。老的extcon、新的Type-C子系统、PMIC自带的VBUS中断,三条路径都可以上报充电器的存在。但它们上报的数据格式、时机、去抖策略不一致。同一个平台如果同时开启这些路径,又没有协调好,就会出现事件重复上报、状态覆盖等怪问题。
第三,5.15内核里power_supply框架做了不少调整。属性更新的并发控制更严格,如果驱动里用了不规范的原子操作或漏了锁,往往不会编译报错,而是在实际运行中随机丢uevent。这也是调试时最让人头疼的部分。
3. 在kernel 5.15上打通USB状态与充电事件的实操记录
3.1 确认基本软件框架与配置
拿到一个可疑的充电事件丢包问题,我习惯先做三件事:确认内核配置、确认设备树描述、确认驱动加载顺序。这一步能过滤掉一半的问题。
内核配置方面,建议检查这几个开关是否打开:
- CONFIG_USB_DWC3:USB控制器驱动
- CONFIG_EXTCON:外部连接器框架
- CONFIG_TYPEC、CONFIG_TCPM:Type-C控制器框架
- CONFIG_POWER_SUPPLY:电源抽象框架
- 平台配套的充电器驱动Kconfig
设备树方面,USB控制器节点、PHY节点、Type-C端口控制器节点和PMIC充电器子节点的关系要理清楚。充电器子节点通常挂在PMIC的I2C下,它和USB节点之间不一定直接引用,而是通过一个extcon或typec的phandle来绑定。我在实际项目里看到过设备树里把phandle写错或漏写的情况,导致驱动加载正常、日志正确,但状态事件就是过不来。
驱动加载顺序可以通过启动日志确认。在log里搜dwc3、typec、pmic charger这几类驱动的probe时间点,如果发现充电器驱动先于USB状态源注册,就要引起警惕。
3.2 在驱动代码里追踪状态更新路径
一次完好的状态更新路径,在日志里应该有迹可循。我先描述一条标准路径,再结合系统调试手段说明如何验证。
假设平台用的是Type-C口,流程大致是这样:
- 插入充电器,TCPC通过I2C中断报告CC状态变化。
- tcpm状态机运行,在收到CC和电压信息后确认连接。
- 连接确认后,Type-C框架会通过typec_connector_class报告Port Partner信息,同时,如果配置了extcon桥接,它会触发extcon_set_state。
- 充电器驱动里注册了extcon notifier或者typec notifier,回调被触发,驱动读取当前cable类型。
- 驱动根据类型更新power_supply属性,比如online、status,然后调用power_supply_changed()。
- uevent事件带上“POWER_SUPPLY_STATUS/ONLINE”等内容广播到用户空间。
查看日志时,重点关注“type_attach”、“USB_ENTER”、“charger online”这类关键字。各平台日志关键字差异很大,但思路是顺着事件源往外看。如果你在日志里看到了上游的type attach,却没有下游的charger online,那说明断链在notifier回调或属性更新之前。
3.3 上报事件的代码路径怎么补
如果发现链路确实断了,而且断点在状态订阅这一步,就需要补代码。下面是一个简化但结构完整的示例模板,适合在vendor charger驱动里实现事件订阅和上报。
static struct notifier_block usb_nb = { .notifier_call = usb_state_notifier, }; static int usb_state_notifier(struct notifier_block *nb, unsigned long state, void *data) { struct device *dev = usb_chg_psy.dev; enum power_supply_type chg_type; if (state & EXTCON_USB) { chg_type = detect_input_source_type(); usb_chg_psy.properties[POWER_SUPPLY_PROP_ONLINE] = 1; usb_chg_psy.properties[POWER_SUPPLY_PROP_STATUS] = POWER_SUPPLY_STATUS_CHARGING; power_supply_changed(&usb_chg_psy); } else { usb_chg_psy.properties[POWER_SUPPLY_PROP_ONLINE] = 0; power_supply_changed(&usb_chg_psy); } return NOTIFY_OK; }这只是示意,真实驱动里还会涉及锁、去抖、并发控制和寄存器操作。但核心思路是三步:拿到状态、更新属性、通知变化。如果平台已经有类似notifier但没生效,检查注册时机和返回值的处理。notifier回调一定要返回标准值,比如NOTIFY_OK或NOTIFY_DONE,返回值错误会导致后续notifier链中断。
power_supply_changed()触发后,内核会通过kobject_uevent_env()广播uevent。广播是否成功可以在调试期间用监听netlink uevent的方式验证。实际开发板上,我通常还会同时cat一下/sys/class/power_supply/usb/online,看看属性值是否已经更新。如果属性更新了但没有uevent,问题多半出在kset或kobject注册上面。
3.4 验证修复效果的完整步骤
当你改完代码,建议按这个顺序验证:
- 编译内核模块并更新。
- 插入电脑(SDP)确认枚举和“正在充电”事件。
- 插上DCP充电头确认充电类型切换。
- 验证Type-C口插入时是否还能持续监听PD协商结果的变化。
- 反复快速插拔,观察是否有状态滞留或者双重上报。
验证修复效果时,反复快速插拔最能暴露问题。正常插拔都能过关,但连续插拔十几次后就会偶现状态不对。这一般是内核work queue延迟或者notifier回调里处理了耗时操作导致的。在驱动里别做长时间阻塞操作,能用线程的不要用硬中断上下文。
4. 实际调试中遇到的典型问题与排查思路
到了这一段,我把这次调试过程中真正碰到的几个问题列出来。每个问题给出现象、初步判断和最终定位,方便你对照排查。
4.1 现象一:插上充电器完全不进入充电状态
先看dmesg,搜索VBUS相关日志。多数情况下有中断日志,但没有后续充电器online日志。
我遇到的一次根因是Type-C控制器已经识别到CC连接,但因为DTS里没有把Type-C端口的中断及时映射到充电器驱动的事件源,导致tcpm上报之后没有人消费。最终修改是在充电器驱动的notifier注册路径上增加对typec事件的监听,而不是只监听老的extcon。
排查要点:确认事件源是谁、notifier注册没有、回调是否被调用。
4.2 现象二:能充电但功率始终是普通档
这个问题的典型原因是充电器类型识别卡在SDP。插的是DCP或快充头,但内核认为接入的是普通USB口。
我用电流仪确认了实际输入电流只有500mA,然后在sysfs里读到充电器类型确实错误。进一步定位发现是BC1.2检测的时序没有走完,因为检测依赖的D+/D-电压采样线程被系统调度延迟,错过了握手窗口。
这类问题不能靠乱调时间窗,更合理的做法是把检测线程优先级提高或改成wakeup高优先级work。同时检查一下检测完成后有没有把结果正确写回PMIC寄存器。
4.3 现象三:拔掉USB后状态仍然停留在“充电中”
这类问题最烦人,因为线下很难稳定复现。我用ftrace抓到一次现场后发现,power_supply_changed()被调用了,但uevent广播前状态属性又被后面一个慢的callback覆盖回去。
究其原因,是拔电瞬间USB控制器和PMIC两个中断几乎同时产生,两个notifier回调并发执行,由于没有加锁,导致后执行的旧值覆盖了新值。解决办法:在充电器驱动的状态更新入口统一加互斥,并且对VBUS下降沿做去抖。
去抖时间可以定在80ms左右,既能保证插拔响应速度,又能滤掉接触不良产生的毛刺。去抖时间不是越长越好,太长会导致用户拔线后充电图标延迟消失,反而产生新投诉。
4.4 常见问题速查表
| 现象 | 可能原因 | 优先检查项 |
|---|---|---|
| 插入不充电 | 事件源notifier未注册 | dmesg、extcon/typec状态 |
| 有日志但无uevent | power_supply注册不完整 | /sys/class/power_supply下节点 |
| 电流档位偏低 | 充电器类型识别错误 | sysfs读取charge_type |
| 拔线状态残留 | 并发覆盖/去抖不足 | 锁、去抖处理 |
| 偶发不更新 | workqueue延时 | 阻塞操作、优先级 |
这张表我贴在工位上很久了,每次排查充电问题都会先扫一遍。
5. 基于kernel 5.15调试的几条避坑建议
5.1 善用sysfs快速确认静态状态
在手机UI上看不到具体值,但开发板上可以通过sysfs快速验证。
查看所有供电设备节点:
ls /sys/class/power_supply/读取USB充电器状态:
cat /sys/class/power_supply/usb/online cat /sys/class/power_supply/usb/status cat /sys/class/power_supply/battery/status这几个节点是否更新、是否同步,能帮你快速缩小问题范围。如果online已经是1但界面不亮,问题可能更靠上层;如果online始终是0,就专心看内核驱动链路。
5.2 调试时先关掉用户空间的干扰
Android系统上层有Healthd、电池服务、Settings等多个模块会响应uevent。它们的行为会掩盖一部分内核问题。遇到纯充电状态Bug时,建议先在开发板上关掉不必要的上层服务,甚至直接用串口在内核态观察。否则你看到的现象是“App没刷新”,但实际是内核本来就发错事件。
5.3 代码审查时重点看并发与锁
内核对充电事件的处理几乎都是异步的。VBUS中断、TCPC中断、用户态读操作随时可能并发。驱动里凡是读写充电状态共享变量,都应该有明确的内存序和锁保护。review代码时我首先看的是:两个notifier回调是否可能同时写同一个属性;power_supply_changed()前后是否持锁。这两个点基本能覆盖大多数偶发Bug。
5.4 用好ftrace对比前后时间线
最后分享一个经验:调试这类靠事件驱动的问题,肉眼阅读dmesg是低效的。我第一次定位拔电状态残留问题,就是靠ftrace把notifier回调、workqueue执行和power_supply_changed()的时间点拉出来对比,才确认了两个回调的先后顺序。建议在项目里提前把tracepoints跑通,需要时能省半天时间。后来我整理这套处理流程时,又把这个办法用在了USB状态更新与充电事件的时间对齐上,确实能把一类模糊的问题变得可观测、可验证。