STM32 USB PD Sink主动读取Battery Status实现电量显示 做USB-C PD设备这几年我一直觉得最麻烦的不是把电压档位协商出来而是拿到电之后你根本不知道对面那个电池还剩多少电。去年我做一个带屏的USB-C便携仪表设备本身是纯Sink从一个带PD输出功能的电池包取电。屏幕要显示电池剩余电量一开始我只能从Source Capabilities里读到支持几伏几安至于电池本身是满电还是快没电了一点办法都没有。后来在STM32G0上用STM32Cube_FW_G0_V1.6.3的USB-PD协议栈实现了从Sink向Battery Source请求Battery Status这才能把电量百分比真正显示出来。整个过程涉及扩展消息机制、PE状态机、协议栈回调还有一堆容易踩的坑这篇就把实现细节和调试经验完整整理出来。1. 为什么Sink要主动去读Battery Status一个实际的USB-C取电场景1.1 Battery Status消息里到底有什么Battery Status是USB PD 3.0规范里定义的一种扩展消息由电池电源Battery Source发给Sink设备用来上报电池当前的工作状态。从功能上看它就像电池管理芯片里的电量寄存器只不过这些信息通过Type-C的CC引脚走BMC编码传出去。在我常用的实现里Battery Status扩展消息的数据载荷一般包含以下几类信息字段含义典型单位Battery Present电池是否在位布尔值Battery Discharging电池是否正在放电布尔值Battery Charging电池是否正在充电布尔值Battery Critical电池是否处于临界电量布尔值Charge Level当前剩余电量1%步进0~100Capacity电池容量状态1%步进0~100Battery Voltage当前电池电压100mV步进Battery Current当前电池电流10mA步进从应用的角度看Charge Level是最核心的字段Sink拿到之后可以直接显示剩余百分之多少或者用来做低电量报警。Battery Voltage和Current则可以用来估算实时负载判断是正在充电还是放电。1.2 从看到电压电流到看到电池电量Battery Status的协议地位很多刚开始接触USB PD的人会把Source Capabilities和Battery Status搞混。这里要分清楚Source Capabilities是Sink在协商阶段收到的PDO列表描述的是电源能输出哪些电压电流档位比如5V/3A、9V/3A、15V/3A这种它是电源能力的静态描述不包含电池剩多少电。而Battery Status是协商完成之后Sink额外主动去取的一包动态数据。换句话说Source Capabilities告诉你我能给你提供什么Battery Status告诉你我现在的电池状态怎么样。前者是菜单后者是运行状态。对于带屏的Sink设备菜单只能让用户看到支持65W快充但电池还剩下多少必须靠Battery Status才能拿到。从协议栈实现的角度来说Battery Status不是Sink直接往总线上发一条给我电池状态就能搞定的。在USB PD 3.x的协议框架里这类信息属于扩展消息Extended MessageSink需要先发送一个Get_Extended_Msg请求Source收到之后才会把对应的Battery Status扩展消息返回给Sink。1.3 为什么这会让你纠结PD版本这里要提前打个预防针Battery Status扩展消息是USB PD 3.0规范才引入的。如果你的Sink协议栈只支持PD 2.0或者对端Source只认PD 2.0那这条路走不通。实际测试中我遇到过一些宣称支持PD的充电宝内部协商之后Spec Revision停留在2.0无论怎么发Get_Extended_Msg都不回Battery Status。所以做这个功能之前第一步不是写代码而是确认两端协议版本都支持PD 3.x。在STM32的USB-PD协议栈里Source Capabilities消息的Message Header中带有Spec Revision字段代码里可以直接读到。这个字段也决定了PE状态机后续能不能走扩展消息的处理路径。2. STM32Cube_FW_G0_V1.6.3里USB-PD协议栈的消息路由2.1 UCPD外设、PE状态机和DPM用户回调的分工STM32G0上做USB PD绕不开UCPD外设。它负责Type-C连接检测、CC引脚上的BMC物理层编解码、发送和接收PD消息。对应用层来说UCPD只是个收发信件的邮局信的内容由上层协议栈解析。STM32Cube_FW_G0_V1.6.3里的USB-PD中间件大致分三层UCPD LLDLow Layer Driver直接操作UCPD外设寄存器负责PD消息的底层收发和中断处理。PEPolicy Engine实现USB PD的状态机比如PE_SNK_DISCOVERY、PE_SNK_READY这些状态。扩展消息的分块重组、消息重传、GoodCRC处理都在这一层。DPMDevice Policy Manager策略管理层包含DPM核心和DPM用户回调。用户代码主要在DPM_USER这一级。实际开发中你直接接触最多的是usbpd_dpm_user.c这个文件。PE层已经帮你处理好了消息时序DPM核心层根据PE状态调用用户回调用户回调里决定要不要接受这个PDO某个事件到了之后应用层该干嘛。2.2 扩展消息的快递通道Get_Extended_Msg机制普通PD数据消息比如Source_Capabilities、Request它们的数据载荷直接在标准Data Message里传输。但扩展消息不一样它的数据载荷更长而且消息类型通过扩展消息头里的ExtendedMsgType字段区分。Battery Status的扩展消息类型值是4Battery Capabilities是5Manufacturer Info是6。那Sink怎么让Source发一个Battery Status过来在PD 3.x协议中Sink通过发送Get_Extended_Msg数据消息来指定想要的扩展消息类型。这个请求本身是一个标准Data Message携带目标扩展消息类型。Source收到后如果支持并且当前状态允许就回复对应的Extended Message。我通常把这个过程理解成一个快递通道Get_Extended_Msg是下单Source的Extended Message是发货。Battery Status这种消息就是快递包裹支持用分块Chunked方式发送长内容但Battery Status本身数据量不大一般一包就能装下。2.3 为什么要先确认PE处于SNK_Ready刚开始做这个功能的时候我犯过一个错误在PD协商还没完成的时候就去发Get_Extended_Msg请求结果Source完全不回应。后来查PE状态机才明白Sink侧的PE状态必须已经进入PE_SNK_READY也就是Source_Capabilities收发、Request协商、Accept、PS_RDY这一整套流程全部走完Source才会认为当前连接处于稳定供电状态才有可能响应扩展消息请求。所以在应用层我加了一个isSnkReady的标志位。在USBPD_DPM_Notification回调里收到PS_RDY事件时置1收到HardReset或者断开事件时清0。每次发起Battery Status请求之前先检查这个标志避免在错误的时机发消息。还有一个细节发送Get_Extended_Msg请求时协议栈要求端口处于无正在发送消息的空闲状态。因为USB PD是半双工通信如果CC上还有未完成的传输新的请求会排队或者被协议栈直接丢弃。用DPM接口发送请求的话协议栈内部会做一定处理但应用层最好也控制一下节奏不要在过短的时间内连续发多个扩展消息请求。3. 手写一个Battery Status请求配置、代码与数据解析3.1 CubeMX里先把PDO和中断配置对用STM32Cube_FW_G0_V1.6.3开发第一步肯定是CubeMX生成工程。有几个配置点直接影响Battery Status能不能正常工作我一个个说。UCPD外设模式在CubeMX里选中UCPD1Type-C模式根据你的设备角色配置。我做的是纯Sink选UFP模式Upstream Facing Port。如果你是做双角色设备选DRP但要在代码里处理好角色切换。PDO配置Sink设备要配置Sink PDO告诉Source你希望输入什么电压电流。Battery Status请求通常在固定电压协商完成后发起所以Sink PDO里至少要有一个5V档。如果你想协商更高电压比如9V或15VSink PDO里要对应加档位。时钟UCPD外设需要正确的时钟源。STM32G0上UCPD的BMC发射器对时钟精度有要求CubeMX里通常用PLL输出给48MHz时钟域。时钟配错的话现象是CC引脚上的BMC信号完全无法被对端解析甚至Type-C检测都过不了。中断优先级UCPD中断的优先级要合理设置。PD消息时序有严格的时间要求比如GoodCRC要在收到消息后1.9ms内回复TPDMessage。如果UCPD中断被其他高优先级任务长期阻塞会导致协议栈超时Source那边会判定为通信异常并触发重传甚至Hard Reset。我把UCPD中断优先级设为高于普通外设但低于系统滴答定时器实测稳定。USB-PD中间件CubeMX里把USB-PD中间件加进去之后会生成usbpd_dpm_user.c和usbpd_dpm_core.c这些文件。注意检查固件包版本V1.6.3的中间件路径里STM32Cube_FW_G0_V1.6.3/Middlewares/ST/ST_USB-PD_Core/应该能直接找到扩展消息相关的代码。3.2 发送Get_Battery_Status请求的代码实现我实现的请求函数大概是这样的#define BATTERY_STATUS_REQ_PERIOD_MS 5000 extern uint8_t isSnkReady; void APP_SendBatteryStatusRequest(void) { if (!isSnkReady) { return; } /* 通过DPM层发送Get_Extended_Msg请求Battery Status扩展消息 */ USBPD_StatusTypeDef ret DPM_Get_Extended_Msg( USBPD_PORT_0, PE_EXTENDED_MSG_BATTERY_STATUS); if (ret ! USBPD_OK) { /* 协议栈忙或者参数不对可以选择记录日志或者直接丢 */ APP_Log(BatteryStatus request failed: %d\r\n, ret); } }这里的关键是PE_EXTENDED_MSG_BATTERY_STATUS这个枚举值在协议栈头文件里对应Battery Status扩展消息类型。DPM_Get_Extended_Msg是DPM层封装的接口最终会调用PE层的发送函数由PE状态机把消息发到CC总线上。至于什么时候调用我在定时器里做周期轮询5秒一次。也可以在特定的用户事件里触发比如按键点击、屏幕刷新、低电量报警。但注意不要过于频繁具体频率后面第五节会展开说。3.3 解析Source回传的Battery Status数据Source回复的Battery Status扩展消息到达后会经过UCPD外设、LLD、PE层最后通过USBPD_DPM_Notification回调通知到应用层。我在回调里这样处理USBPD_StatusTypeDef USBPD_DPM_Notification(USBPD_Port_TypeDef Port, uint32_t Event, void *Params) { USBPD_StatusTypeDef status USBPD_OK; switch (Event) { case USBPD_EVT_PS_RDY_RECEIVED: isSnkReady 1; break; case USBPD_EVT_HARD_RESET: case USBPD_EVT_UNCONNECTED: isSnkReady 0; break; case USBPD_EVT_EXTENDED_MSG_RECEIVED: if (Params ! NULL) { USBPD_ExtendedMsg_TypeDef *msg (USBPD_ExtendedMsg_TypeDef *)Params; if (msg-ExtendedMsgType PE_EXTENDED_MSG_BATTERY_STATUS) { APP_ParseBatteryStatus(msg-Data, msg-DataSize); } } break; default: break; } return status; }解析Battery Status数据时我按6字节有效载荷来解typedef struct { bool batteryPresent; bool discharging; bool charging; bool critical; uint8_t chargeLevel; uint8_t capacity; uint16_t voltage; /* 单位100mV */ uint16_t current; /* 单位10mA */ } BatteryStatus_t; void APP_ParseBatteryStatus(uint8_t *data, uint16_t len) { BatteryStatus_t bat; if (data NULL || len 6) { APP_Log(BatteryStatus length error: %d\r\n, len); return; } uint16_t flags data[0] | (data[1] 8); bat.batteryPresent (flags 0x01) ? true : false; bat.discharging (flags 0x02) ? true : false; bat.charging (flags 0x04) ? true : false; bat.critical (flags 0x08) ? true : false; bat.chargeLevel data[2]; bat.capacity data[3]; bat.voltage data[4] | (data[5] 8); bat.current data[6] | (data[7] 8); APP_Log(Battery: %d%%, %dmV, %dmA\r\n, bat.chargeLevel, bat.voltage * 100, bat.current * 10); }注意不同PD规范版本对Battery Status的字节布局定义可能略有差异。如果你的数据始终对不上建议用协议分析仪抓一包看实际数据布局。我的解析代码是基于当前项目抓包确认的结果字节序是小端模式uint16_t低字节在前。3.4 完整的Sink端代码骨架把上面的代码组合起来一个完整的Sink端请求Battery Status的流程大概是系统初始化USB-PD协议栈启动。Type-C连接建立PE状态机开始协商。收到PS_RDYisSnkReady置1。应用定时器每5秒调用一次APP_SendBatteryStatusRequest()。Source返回Battery Status扩展消息。协议栈在USBPD_DPM_Notification回调中上报事件。应用层解析数据刷新屏幕显示电量。如果发生HardReset或断开isSnkReady清0暂停请求。这个流程对应三个核心环节等待Ready、发送请求、回调解析。其他逻辑比如超时重试、低功耗联动都是在此基础上叠加的。4. 调试Battery Status请求时的四个老大难问题4.1 请求发出去石沉大海先检查PE状态和Source能力这是最常见的现象代码跑了请求函数也调了但回调里永远等不到Battery Status。我的排查顺序是先看PE状态。在APP_SendBatteryStatusRequest入口打印isSnkReady和PE状态值确认确实处于PE_SNK_READY。如果isSnkReady一直是0说明PD协商都还没完成问题在更早的阶段跟Battery Status请求无关。再看协议版本。打印协商成功后的Spec Revision确认是PD 3.x。如果协商结果是PD