把一块不存在的电池“插”进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 Page,Windows就会自动完成驱动加载,不需要安装任何厂商文件。很多朋友看到设备管理器里的“HID UPS Battery”以为是个Bug,其实这套机制一直在后台工作,我们这个项目就是主动去利用它。
1.2 这个方案能干什么、不能干什么
虚拟电池能做的事情,比大部分人想象的要广:
- 给不带电池的台式机模拟一个电池实体,让Windows的电源策略真正跑起来。
- 调试低电量告警、关键电量操作、休眠唤醒等逻辑,不用真把笔记本电池耗干。
- 让powercfg /batteryreport有数据可看,方便做电源管理相关的统计分析。
- 接一个电量采集电路,把真实电池的电压、电流换算成容量,通过HID上报给系统。
- 结合上位机脚本,在测试中实时改变电量数值,验证应用在不同电量下的表现。
不能做的事也要说清楚:它只是“状态模拟”,不是“能源改造”。虚拟电池不会给机器供电,也没法绕过系统的电池安全机制。在真实笔记本上乱报电量,轻则系统误判,重则触发意外断电或充电策略异常。正确用法是在台式机、工控机或者测试环境里做开发和验证。
2. 原理拆解:HID报告描述符如何让Windows“看见”电池
2.1 识别核心:Battery System Usage Page
USB HID设备的“自我介绍”靠的是报告描述符(Report Descriptor),主机通过解析它来判断设备是什么、能干什么。HID Usage Tables里专门定义了一个Battery System Usage Page,ID是0x85。当Windows枚举到带这个Usage Page的HID设备时,hidclass.sys会认为设备具备电池属性,把它挂到HID电池驱动链路上。
在0x85这个Usage Page里,对我们最有用的几个Usage大概是这样:
| Usage名称 | Usage ID | 作用 |
|---|---|---|
| Battery System | 0x02 | 应用集合的顶层Usage,Windows靠它识别“这是一个电池系统” |
| Battery Strength | 0x40 | 电量百分比,逻辑值通常映射为0~100,用一个字节表示 |
| Battery Status | 0x44 | 电池状态位图,表达充电、放电、外接电源等状态 |
| Battery Present Status | 0x46 | 电池是否存在,一般常驻为“存在” |
这里要提个醒,HID Usage Tables的版本不同,个别Usage ID会有微调,写代码之前一定要以USB-IF官方文档1.12或1.13版本为准。我最初就是照着网上老帖子的ID抄,结果Windows怎么都不认,后来逐字节对比文档才发现是两个Usage的ID对不上。
2.2 完整驱动栈:从USB枚举到hidbatt.sys加载
把USB设备插上后,Windows侧的完整处理流程大致是这样:
- USB总线驱动先枚举设备,读取设备描述符、配置描述符、接口描述符、HID描述符和报告描述符。
- 如果接口类是HID(bInterfaceClass=0x03),系统加载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 System,UPS的Usage Page是0x84,两者都包含电池状态的Usage,但语义和Windows侧的驱动路径不完全一样。
如果报告描述符里出现了0x84 UPS Usage Page,Windows可能会把设备识别成“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全速要求48MHz,Pico SDK默认已经配好,STM32F103则要检查PLL配置,保证USB外设时钟是48MHz。
- 设备描述符里的VID/PID可以随便填一个合法的,不会和真实设备冲突就行。
- 接口描述符的接口类必须设为HID(0x03),子类和协议设为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、虚拟游戏控制器。关键是理解报告描述符和系统驱动之间的映射关系,把这个看透了,后面都是举一反三的事。