- Implemented LED indicator feature (24 brightness) - Migrated UAC microphone to hardware xfer_cb (fixed endpoint collision) - Rolled back ATVV strict 0x03 check to prevent infinite ping-pong loop - Rolled back HOGP keyboard release to fix modifier sticky keys - Rolled back Volume keys to TAP with repeat to preserve stability
8.2 KiB
蓝牙遥控器与 USB 复合设备稳定性深度 Bug 修复记录 (Session 2)
本文档记录了在项目开发后期,排查并修复的三个极其隐蔽且致命的稳定性 Bug。这些 Bug 涉及到了 BLE (NimBLE) 底层状态机、HOGP 按键解析逻辑以及 TinyUSB UAC 麦克风端点死锁问题。
Bug 1: 语音键长按 2.5 ~ 3.5 秒后自动异常中断 (ATVV 状态机崩溃)
现象描述
遥控器开机后一切正常,但在长按语音键录音时,麦克风总是在录制大约 3 秒钟后突然没声。与此同时,即使用户依然紧按着语音键没有松手,电脑端也会收到一个“语音键已松开”的伪造事件。从串口日志观察发现,每次崩溃断开的时间点惊人地一致,且下次按键时遥控器的 session_id 会重置为 1。
根本原因
通过查阅 Google ATVV (Android TV Voice) 协议标准并分析数据包交互:
- 小米蓝牙语音遥控器采用的是 HTT (Hold-To-Talk, Reason=0x03) 模式,即“长按说话”模式。
- 在 HTT 模式下,遥控器端完全掌握着录音结束的决定权(只要按键未松开,就会一直发送音频,直到用户物理松手)。
- 之前代码中,借鉴了 PTT (Push-To-Talk) 模式的逻辑,每隔 2000 毫秒 (2 秒) 会向遥控器强制发送一个
AUDIO_EXTEND (0x0E)的保活帧。 - 当小米遥控器在 HTT 模式下正愉快地流式传输音频时,一旦收到这个画蛇添足的
0x0E保活指令,其内部的 ATVV 状态机就会彻底崩溃,从而触发异常中断流程:强行终止录音、发送伪造的 HOGP0x3E (UP)事件并重置状态机。
解决思路与修复方案
在 ble_remote_client.cpp 中的 ble_remote_task 轮询循环里,彻底移除了在 HTT 模式下发送 AUDIO_EXTEND (0x0E) 的代码。
修复后,只要用户不松手,遥控器就能稳定、连续不断地发送麦克风数据,再未出现中途断开现象。
Bug 2: 疯狂乱按遥控器导致普通按键“永久卡死 (Ghost/Stuck Key)”
现象描述
当用户疯狂、快速、无序地按下方向键、确认键等普通按键时,遥控器偶尔会陷入“某一个按键被永久按下”的疯狂触发状态(电脑端疯狂响应某个按键)。此时去按语音键甚至也会导致整个状态机错乱。
根本原因
蓝牙 BLE 在传输 HID Over GATT (HOGP) 数据包时:
- 按下按键时,遥控器发送包含该键码的 8 字节 Payload(例如
0x50)。 - 松开按键时,遥控器发送全零的 8 字节 Payload(
ALL ZEROES)代表Key UP。 - 如果用户在松开按键 A 之前,就非常急促地按下了按键 B(或者遥控器矩阵扫描来不及处理、出于省电逻辑主动丢弃),遥控器会直接忽略发送 A 的全零松开包,而直接发送包含 B 键码的按下包。
- 之前的解析器非常“老实”,由于没有收到 A 的全零包,导致键码映射引擎(
key_engine)永远认为 A 处于STATE_PRESSED(按下)状态,导致按键永久卡死。
解决思路与修复方案
在 ble_remote_client.cpp 的 on_hogp_report_notify 中引入了强制互斥与自动释放 (Auto-Release) 逻辑:
- 记录上一次按下的键码
s_last_hogp_key。 - 当收到新的 HOGP 数据包时,如果发现当前有一个旧按键处于按下状态,且新收到的键码不同于旧键码,则主动向
key_engine喂入一个旧键码的UP (Release)事件,强制使其抬起。 - 如果此时触发了专用的语音键 (
AUDIO_STARTATVV 协议包),同样执行一轮Auto-Release,防止普通 HOGP 按键在语音会话期间卡死。
Bug 3: Windows 下测试其他按键后,麦克风发生“永久性全链路死锁”
现象描述
刚通电时单独测试麦克风,录音完全正常。但只要测试过“其他普通按钮”(如主页键、返回键等)后,麦克风就会陷入永久无声状态。此时遥控器蓝牙连接正常,按键打印正常,甚至返回前台使用录音软件也无法恢复,只能重启 ESP32。
根本原因
这是一个横跨 Windows 操作系统机制与 TinyUSB 底层端点驱动的极其隐蔽的连环死锁:
- 遥控器上的主页键被映射为
Win + D(显示桌面)。 - 在 Windows 11 中,处于前台的 UWP “录音机”或“系统声音面板”一旦因为
Win + D失去焦点或被最小化,Windows 出于隐私保护,会立刻强制关闭该应用的麦克风收音权限,并向 USB 麦克风发送SET_INTERFACE 0指令(请求关闭音频流)。 - 此时,ESP32 侧的
uac_mic_task会停止调用 USB 传输函数(音频暂时中断,这本身是正常的)。 - 致命一击:当 Windows 发送
SET_INTERFACE 0并调用 TinyUSB 的usbd_edpt_close时,如果有音频包刚好正处于硬件 FIFO 传输队列中(In Flight),DWC2 硬件传输会被强行打断,导致usbd.c永远不会收到XFER_COMPLETE中断! - 因此,该端点的内部标志位
_usbd_edpt_busy被永久卡在了 True。 - 当用户切回前台(Windows 重新发送
SET_INTERFACE 1请求开启音频),我们的代码调用usbd_edpt_open重新打开端点。但因为busy标志位没有被清空,后续所有的usbd_edpt_claim调用都会直接失败返回false。ESP32 陷入了无休止的“端点忙碌”死锁,再也发不出一丝声音。
惊天大反转:最底层的物理撞车 —— 端点冲突 (Endpoint Collision)
起初,我们怀疑是多线程数据竞争,随后又怀疑是 dcd_dwc2.c 的 FIFO 内存泄露导致 usbd_edpt_close 失败,甚至我们直接修改了 ESP32-S3 的 DWC2 物理寄存器试图强制打断死锁,全部宣告失败!
在将整个 USB 通信链路拆解后,我们发现了极其荒诞但又非常致命的根源:
- 硬编码的端点号:在早期的 UAC 麦克风代码中,我们随手写死了一行代码
static uint8_t s_uac_ep_in = 2;,意图将麦克风数据发送给 端点 2 (EP 2 IN)。但这只是我们在上层一厢情愿的赋值,并没有向底层 TinyUSB 注册占用。 - 底层库的无知:在麦克风初始化之后,我们在
usb_composite.cpp中初始化了USBHID(模拟键盘和消费者控制)。ESP32 的 Arduino 内核在初始化USBHID时,自动调用了tinyusb_get_free_in_endpoint()去寻找空闲的端点。 - 物理级撞车:由于我们之前没有注册“端点 2”,
tinyusb_get_free_in_endpoint()高高兴兴地把 端点 2 分配给了USBHID(特别是 Consumer Control 接口)。
- 结果:麦克风和键盘,被分配到了同一个 USB 物理端点通道里!
当你脸滚键盘时,麦克风的
tud_task正在往端点 2 写连续的音频数据,而键盘的NimBLE线程也开始往端点 2 塞入按键数据。两路截然不同的数据在微秒级别同时强行塞进同一个 DWC2 硬件端点,直接导致硬件数据翻转错误(Frame Parity 错乱),DWC2 芯片当场死机!这根本不是单纯的丢帧,这是两个驱动抢夺同一个硬件外设导致的物理碰撞!
最终极的解决方案:动态端点分配与事件驱动架构
- 彻底消除端点冲突:我们删除了硬编码的端点赋值,改用标准的底层 API
tinyusb_get_free_in_endpoint()为麦克风动态申请专属的物理端点。现在,麦克风(UAC)和键盘(HID)拥有了各自独立的高速车道,再怎么滚键盘也绝对不会发生物理碰撞。 - 事件驱动单线程闭环:我们彻底删除了占用极高 CPU 且极其危险的
uac_mic_task轮询线程。现在的音频发送完全交给tud_task触发的uac_driver_xfer_cb中断回调去串行执行。 - 保留软件级看门狗作为安全网:由于早期的踩坑,我们发现即使发生网络波动导致 Windows 漏掉一次等时传输,旧版 TinyUSB 的
busy标志位也可能无法自行恢复。因此我们保留了一个超轻量级的看门狗:一旦监测到 50ms 无中断,就会使用“神级后门”usbd_edpt_clear_stall(0, ep_addr)无条件抹除残留的busy标志位并重启传输,为整个 USB 复合设备提供了坚不可摧的稳定性。