用USB HID虚拟电池实现Windows零驱动电源管理调试
把一块不存在的电池“插”进Windows让系统在设备管理器里凭空多出一个电池设备不需要写驱动、不需要签inf就能读到电量百分比、生成电池报告——这不是系统漏洞而是USB HID规范早就留好的一个口子。最近为了给台式机做电源管理自动化测试我用STM32做了一个USB HID虚拟电池Windows 11零驱动直接识别成“Microsoft HID Battery”powercfg /batteryreport也能正常生成电量曲线。这篇文章会把从报告描述符设计、固件实现、到Windows侧验证和问题排查的完整过程整理出来给做嵌入式USB、电源管理调试、自动化测试的同学做个参考。1. 项目背景为什么需要一块不存在的“虚拟电池”1.1 零驱动方案的来源Windows自带的HID电池驱动链台式机、工控机、部分迷你主机没有物理电池Windows的电源管理在这些机器上基本是摆设低电量告警体验不到休眠策略没法测围绕电源状态写的自动化脚本也找不到触发源。如果能够给系统接一个USB设备让它告诉Windows“我是一块电池”Windows就会把它当作电池设备挂进电源管理框架电池图标、电量报告、低电量事件就全都活过来了。这里最值钱的是“零驱动”三个字。普通USB外设要暴露特殊功能通常要写inf、签驱动、装sys文件麻烦不说还容易被组策略和安全软件拦下来。HID不一样Windows内核里本来就有完整的HID栈hidclass.sys负责枚举和解析报告描述符hidparse.sys负责解析HID Usage针对电池场景还有hidbatt.sys。只要设备报告描述符里出现电池相关的Usage PageWindows就会自动完成驱动加载不需要安装任何厂商文件。很多朋友看到设备管理器里的“HID UPS Battery”以为是个Bug其实这套机制一直在后台工作我们这个项目就是主动去利用它。1.2 这个方案能干什么、不能干什么虚拟电池能做的事情比大部分人想象的要广给不带电池的台式机模拟一个电池实体让Windows的电源策略真正跑起来。调试低电量告警、关键电量操作、休眠唤醒等逻辑不用真把笔记本电池耗干。让powercfg /batteryreport有数据可看方便做电源管理相关的统计分析。接一个电量采集电路把真实电池的电压、电流换算成容量通过HID上报给系统。结合上位机脚本在测试中实时改变电量数值验证应用在不同电量下的表现。不能做的事也要说清楚它只是“状态模拟”不是“能源改造”。虚拟电池不会给机器供电也没法绕过系统的电池安全机制。在真实笔记本上乱报电量轻则系统误判重则触发意外断电或充电策略异常。正确用法是在台式机、工控机或者测试环境里做开发和验证。2. 原理拆解HID报告描述符如何让Windows“看见”电池2.1 识别核心Battery System Usage PageUSB HID设备的“自我介绍”靠的是报告描述符Report Descriptor主机通过解析它来判断设备是什么、能干什么。HID Usage Tables里专门定义了一个Battery System Usage PageID是0x85。当Windows枚举到带这个Usage Page的HID设备时hidclass.sys会认为设备具备电池属性把它挂到HID电池驱动链路上。在0x85这个Usage Page里对我们最有用的几个Usage大概是这样Usage名称Usage ID作用Battery System0x02应用集合的顶层UsageWindows靠它识别“这是一个电池系统”Battery Strength0x40电量百分比逻辑值通常映射为0~100用一个字节表示Battery Status0x44电池状态位图表达充电、放电、外接电源等状态Battery Present Status0x46电池是否存在一般常驻为“存在”这里要提个醒HID Usage Tables的版本不同个别Usage ID会有微调写代码之前一定要以USB-IF官方文档1.12或1.13版本为准。我最初就是照着网上老帖子的ID抄结果Windows怎么都不认后来逐字节对比文档才发现是两个Usage的ID对不上。2.2 完整驱动栈从USB枚举到hidbatt.sys加载把USB设备插上后Windows侧的完整处理流程大致是这样USB总线驱动先枚举设备读取设备描述符、配置描述符、接口描述符、HID描述符和报告描述符。如果接口类是HIDbInterfaceClass0x03系统加载hidclass.sys作为功能驱动。hidclass.sys解析报告描述符发现0x85 Battery System Usage Page后创建子设备。PnP管理器为这个子设备寻找匹配驱动找到系统自带的hidbatt.sys并加载。hidbatt.sys作为Battery MiniClass驱动注册到Windows电池类驱动battery.sys上对外暴露标准的电池设备接口GUID_DEVICE_BATTERY。也就是说设备端的固件不需要实现任何Windows专有协议只需要老老实实做一个“符合HID规范的电池设备”就行。上层是Windows自己把这些语义翻译成电池状态。这正是“零驱动”的根本原因HID协议栈和电池类驱动都在系统里躺好了缺的只是一个会说话的设备。2.3 两个思路Battery Page与UPS Page做HID电池方向时你会发现网上资料经常把Battery System和UPS两个Usage Page混在一起。0x85是Battery SystemUPS的Usage Page是0x84两者都包含电池状态的Usage但语义和Windows侧的驱动路径不完全一样。如果报告描述符里出现了0x84 UPS Usage PageWindows可能会把设备识别成“HID UPS Battery”在设备管理器里归到电池分类但实际走的是UPS驱动链路。如果只有0x85 Battery System Page在Win10 1809以后的版本上更可能显示为“Microsoft HID Battery”。从“虚拟电池”这个需求出发我更推荐清清爽爽只用0x85避免Windows在UPS和电池两条驱动路径之间摇摆报告数据也更可控。3. 硬件与开发环境选型3.1 三款主控对比STM32F103、RP2040、ATtiny85实现USB HID虚拟电池主控选择很多关键是带USB外设或者能用软件模拟USB。我实际对比过三种方案主控USB方式开发难度成本适合场景STM32F103C8T6硬件USB FS中等10元左右需要稳定量产、需要同时采集外部电池参数的场景RP2040树莓派Pico硬件USB FS低15元左右快速原型验证、TinyUSB生态、上位机联调ATtiny85 V-USB软件模拟USB较高5元左右极简体积、纯学习、对稳定性要求不高的场景STM32的优势是资料多、CubeMX生成工程快F103的USB外设虽然老但做HID设备非常稳。RP2040用TinyUSB库写自定义HID很顺手特别是tud_hid_get_report_cb这类回调跟报告描述符配合起来特别直观很适合第一次做HID电池的人。ATtiny85加V-USB是追求极致的玩法软件USB对时序敏感稍微有点干扰就容易枚举失败不推荐作为第一个方案。3.2 开发环境准备与工程结构我用的是RP2040 TinyUSB环境是Raspberry Pi Pico SDK加CMake也可以用Arduino加TinyUSB库差别不大。关键配置有几点系统时钟必须拉准到USB需要的频率。RP2040的USB全速要求48MHzPico SDK默认已经配好STM32F103则要检查PLL配置保证USB外设时钟是48MHz。设备描述符里的VID/PID可以随便填一个合法的不会和真实设备冲突就行。接口描述符的接口类必须设为HID0x03子类和协议设为0。报告描述符要单独定义TinyUSB会通过tud_hid_report_desc_cb把它返回给主机。工程里建议把“报告描述符定义”“电池状态维护”“HID回调处理”拆成三个文件后面调试会轻松很多。第一次做的时候我把所有代码堆在一个main.c里结果一改报告描述符就要重新编译半天后来拆开才感觉正常。4. 固件实现从报告描述符到电池状态机4.1 报告描述符逐字节拆解报告描述符是整个方案最核心的部分。我用的实际描述符大致长这样uint8_t const desc_hid_report[] { 0x05, 0x85, // USAGE_PAGE (Battery System) 0x09, 0x02, // USAGE (Battery System) 0xA1, 0x01, // COLLECTION (Application) // Battery Strength 0x85, 0x01, // REPORT_ID (1) 0x09, 0x40, // USAGE (Battery Strength) 0x15, 0x00, // LOGICAL_MINIMUM (0) 0x26, 0x64, 0x00, // LOGICAL_MAXIMUM (100) 0x75, 0x08, // REPORT_SIZE (8) 0x95, 0x01, // REPORT_COUNT (1) 0xB1, 0x02, // FEATURE (Data,Var,Abs) // Battery Status 0x85, 0x02, // REPORT_ID (2) 0x09, 0x44, // USAGE (Battery Status) 0x15, 0x00, // LOGICAL_MINIMUM (0) 0x26, 0xFF, 0x00, // LOGICAL_MAXIMUM (255) 0x75, 0x08, // REPORT_SIZE (8) 0x95, 0x01, // REPORT_COUNT (1) 0xB1, 0x02, // FEATURE (Data,Var,Abs) 0xC0 // END_COLLECTION };简单解释一下每个环节的作用。顶层USAGE_PAGE 0x85和USAGE 0x02是Windows识别的关键少了这两个后面定义再多字段也白搭。Battery Strength字段的逻辑范围0~100对应电量百分比因为用了Feature类型报告主机可以主动GET_REPORT来读取。Battery Status是一个字节的状态位图Windows会用它来判断当前是充电、放电还是外部供电。为什么用Feature报告而不是Input报告我实际测试下来Windows的hidbatt驱动主要是通过GET_REPORT(Feature)去拉取电池信息的Input报告在部分版本上读取不积极。所以干脆把电池状态放在Feature报告里兼容性更好。当然不同Windows版本对报告类型的偏好不完全一样如果你的设备在某个系统上读不到电量可以试试同时提供Input报告多一条路。4.2 响应主机的GET_REPORT请求报告描述符只是“声明”真正让Windows拿到数据的是GET_REPORT回调。在TinyUSB里这个回调是tud_hid_get_report_cb当主机发送GET_REPORT请求时固件会把对应的报告数据填进buffer返回。uint16_t tud_hid_get_report_cb(uint8_t report_id, hid_report_type_t report_type, uint8_t* buffer, uint16_t reqlen) { if (report_type HID_REPORT_TYPE_FEATURE) { if (report_id 1) { buffer[0] battery_percent; return 1; } else if (report_id 2) { buffer[0] battery_status_bits; return 1; } } return 0; }这段逻辑很直白主机要1号报告就把当前电量百分比填进去要2号报告就返回状态位图。注意返回长度一定要和报告描述符里定义的长度一致我之前在状态报告里多返回了一个字节Windows那边电池状态就变成了乱码排查半天才发现是长度问题。在STM32的USB Device中间件里思路是一样的只是要在控制传输处理的地方找到GET_REPORT请求分支把Feature报告的数据放到发送缓冲区。不同中间件的实现位置不一样但基本都是围绕bRequest HID_REQ_GET_REPORT来做分发。4.3 模拟电池容量的状态机有了报告通道接下来就是让“电池”动起来。所谓电池状态机本质上就是周期性更新battery_percent和battery_status_bits两个变量。我做了这样一个简单的状态机初始电量100%状态为“放电”。每30秒电量减1模拟放电过程。电量降到20%时把低电量标志位置1。按下开发板上的一个按键模拟插入充电器状态切到“充电”电量每10秒加1。电量回到100%状态切回“放电”。这个状态机用一个定时器中断推进主循环不阻塞HID回调任何时候进来都能拿到最新的电量值。状态机的代码不复杂但有一点要注意电量变化不能太频繁Windows读取电量的周期本身就有几十秒你每秒钟变一次它也不一定立刻反应反而会让系统误以为设备不稳定。真实项目里状态机的输入可以换成ADC采样值比如用电阻分压采集电池电压通过电压曲线估算剩余容量再映射到0~100。这样虚拟电池就变成了一个真正的“电量采集上报器”配合Type-C供电检测还能自动判断外接电源插入。5. 接入Windows后的验证与坑5.1 设备管理器、电池图标与powercfg报告把编译好的固件烧进去USB线插到电脑上设备管理器里出现电池分类下的新设备。我在Win11 22H2上看到的是“Microsoft HID Battery”另一个Win10 1909的机器上则显示“HID UPS Battery”名称不同但行为都正常。如果你只看到“HID兼容设备”或者未知设备那说明报告描述符没有被正确解析先回头查0x85这个Usage Page是否真的出现在描述符里。设备管理器确认后用powercfg生成电池报告验证powercfg /batteryreport执行完会在当前目录生成batteryreport.html里面会列出系统检测到的所有电池设备。正常的话能看到我们虚拟电池的设计容量、当前容量、循环次数等信息报告页面里还会有一条电量变化曲线。这时候系统托盘的电池图标一般也会自动出现台式机用户基本没见过这个图标看到还是挺有成就感的。如果只是做状态查询PowerShell里直接看Win32_Battery也行Get-WmiObject Win32_Battery | Select-Object Name, EstimatedChargeRemaining, BatteryStatus5.2 常见问题排查速查表做这个项目过程中我踩了不少坑也帮朋友排查过几回最典型的问题整理成了一张速查表现象大概率原因排查/解决思路设备管理器显示未知设备报告描述符没有Battery System Usage Page用HID调试工具抓描述符确认0x85是否存在识别成“HID UPS Battery”描述符混入了0x84 UPS Page或系统版本差异不影响功能就继续用否则去掉UPS相关Usage电量一直是0%GET_REPORT回调没触发或返回长度不对在回调里打断点确认主机是否请求了对应Report ID电量永远100%不变化状态机没跑起来检查定时器中断是否正常工作battery_percent有没有被更新设备反复重枚举D上拉、供电或时钟不准检查USB硬件电路和48MHz时钟配置Win10 1809以下不识别hidbatt驱动支持不完整升级系统版本或改用Input报告方式排查HID问题最好用的工具是USBPcap加Wireshark抓一次枚举过程能看到Windows发送了哪些请求、设备返回了什么数据。特别是检查GET_REPORT请求是否真的到了设备端一抓包就清清楚楚。5.3 微软已知问题与版本兼容性这个方案能成立靠的是Windows对HID电池设备的原生支持但原生支持不代表没有坑。最典型的是某些Windows更新之后原来正常的HID UPS Battery设备会突然消失或者被重新枚举微软官方把这个问题归为已知兼容性问题。我自己也遇到过Win10更新后设备管理器里的虚拟电池不见了重新拔插才恢复。应对办法有几个项目对系统版本敏感的话建议锁定Windows Update里驱动更新策略或者在固件层面把设备做得更“皮实”一些比如在报告描述符和报告格式上严格对照hidbatt驱动的期望减少Windows解析时的歧义。如果你是做产品最好在Win10 1809、Win10 22H2、Win11几个版本上都过一遍兼容性测试确认名称和行为差异都在可接受范围内。6. 应用扩展与安全提醒6.1 低电量联动、UPS监控与自动化测试虚拟电池真正实用的是和Windows策略联动。举个例子在电量降到某个阈值时触发一次休眠或者执行一个脚本这在真实笔记本上很难反复测试用虚拟电池就很轻松while ($true) { $battery Get-WmiObject Win32_Battery if ($battery.EstimatedChargeRemaining -lt 20) { Write-Host Battery low, triggering PowerShell action... # 这里放你自己的自动化逻辑 break } Start-Sleep -Seconds 5 }类似的思路还可以做UPS监控。单片机读取市电状态和电池电压通过HID报告给Windows就能让普通台式机具备类似UPS的状态上报能力。Windows原生电池框架会显示剩余电量和供电状态不需要安装UPS厂商的专用软件。我后来在这个基础上加了一个串口日志功能把每次GET_REPORT请求的时间点打印出来测完发现Windows读取电池状态的周期大约在几秒到几十秒之间不是固定的。这个信息对调低电量告警逻辑很有用系统读得没那么频繁脚本轮询间隔就别设太短。6.2 别把虚拟电池用在不该用的地方最后必须强调一点虚拟电池是开发和测试工具不是用来“欺骗”系统安全机制的道具。在真实笔记本上使用它可能和硬件电池冲突导致系统读到两个电池设备充电策略异常甚至在关键时刻误判电量触发强制休眠。我的建议是测试时优先用台式机、工控机或者虚拟机USB透传环境。不要在正在正常使用的笔记本上长期挂着虚拟电池。模拟电量状态时不要把状态位图乱填特别是“充电中”“电量耗尽”这些关键状态要符合实际逻辑。如果接了真实电池务必做好电压保护和滤波HID上报的数据要经过校准不要直接把ADC原始值丢给Windows。做了几轮测试下来我个人的体会是USB HID这套东西的边界比大多数人以为的要宽。平时大家只拿它做键鼠实际只要报告描述符写得对它就能变成系统眼里的各种设备。虚拟电池只是一个例子顺着这个思路你还能做出虚拟温湿度传感器、虚拟LED、虚拟游戏控制器。关键是理解报告描述符和系统驱动之间的映射关系把这个看透了后面都是举一反三的事。