Files
RemoteMapper-ESP32/BLE_HID_UAC_BUGS_POSTMORTEM.md
T
cuicuiV5 57e6e30578 chore(savepoint): rollback to stable LED and UAC state
- 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
2026-08-29 23:40:59 +08:00

8.2 KiB
Raw Blame History

蓝牙遥控器与 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) 协议标准并分析数据包交互:

  1. 小米蓝牙语音遥控器采用的是 HTT (Hold-To-Talk, Reason=0x03) 模式,即“长按说话”模式。
  2. 在 HTT 模式下,遥控器端完全掌握着录音结束的决定权(只要按键未松开,就会一直发送音频,直到用户物理松手)。
  3. 之前代码中,借鉴了 PTT (Push-To-Talk) 模式的逻辑,每隔 2000 毫秒 (2 秒) 会向遥控器强制发送一个 AUDIO_EXTEND (0x0E) 的保活帧。
  4. 当小米遥控器在 HTT 模式下正愉快地流式传输音频时,一旦收到这个画蛇添足的 0x0E 保活指令,其内部的 ATVV 状态机就会彻底崩溃,从而触发异常中断流程:强行终止录音、发送伪造的 HOGP 0x3E (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) 逻辑:

  1. 记录上一次按下的键码 s_last_hogp_key。
  2. 当收到新的 HOGP 数据包时,如果发现当前有一个旧按键处于按下状态,且新收到的键码不同于旧键码,则主动向 key_engine 喂入一个旧键码的 UP (Release) 事件,强制使其抬起。
  3. 如果此时触发了专用的语音键 (AUDIO_START ATVV 协议包),同样执行一轮 Auto-Release,防止普通 HOGP 按键在语音会话期间卡死。

Bug 3: Windows 下测试其他按键后,麦克风发生“永久性全链路死锁”

现象描述

刚通电时单独测试麦克风,录音完全正常。但只要测试过“其他普通按钮”(如主页键、返回键等)后,麦克风就会陷入永久无声状态。此时遥控器蓝牙连接正常,按键打印正常,甚至返回前台使用录音软件也无法恢复,只能重启 ESP32。

根本原因

这是一个横跨 Windows 操作系统机制与 TinyUSB 底层端点驱动的极其隐蔽的连环死锁:

  1. 遥控器上的主页键被映射为 Win + D(显示桌面)。
  2. 在 Windows 11 中,处于前台的 UWP “录音机”或“系统声音面板”一旦因为 Win + D 失去焦点或被最小化,Windows 出于隐私保护,会立刻强制关闭该应用的麦克风收音权限,并向 USB 麦克风发送 SET_INTERFACE 0 指令(请求关闭音频流)。
  3. 此时,ESP32 侧的 uac_mic_task 会停止调用 USB 传输函数(音频暂时中断,这本身是正常的)。
  4. 致命一击:当 Windows 发送 SET_INTERFACE 0 并调用 TinyUSB 的 usbd_edpt_close 时,如果有音频包刚好正处于硬件 FIFO 传输队列中(In Flight),DWC2 硬件传输会被强行打断,导致 usbd.c 永远不会收到 XFER_COMPLETE 中断!
  5. 因此,该端点的内部标志位 _usbd_edpt_busy 被永久卡在了 True。
  6. 当用户切回前台(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 通信链路拆解后,我们发现了极其荒诞但又非常致命的根源:

  1. 硬编码的端点号:在早期的 UAC 麦克风代码中,我们随手写死了一行代码 static uint8_t s_uac_ep_in = 2;,意图将麦克风数据发送给 端点 2 (EP 2 IN)。但这只是我们在上层一厢情愿的赋值,并没有向底层 TinyUSB 注册占用。
  2. 底层库的无知:在麦克风初始化之后,我们在 usb_composite.cpp 中初始化了 USBHID(模拟键盘和消费者控制)。ESP32 的 Arduino 内核在初始化 USBHID 时,自动调用了 tinyusb_get_free_in_endpoint() 去寻找空闲的端点。
  3. 物理级撞车:由于我们之前没有注册“端点 2”,tinyusb_get_free_in_endpoint() 高高兴兴地把 端点 2 分配给了 USBHID(特别是 Consumer Control 接口)。
  • 结果:麦克风和键盘,被分配到了同一个 USB 物理端点通道里! 当你脸滚键盘时,麦克风的 tud_task 正在往端点 2 写连续的音频数据,而键盘的 NimBLE 线程也开始往端点 2 塞入按键数据。两路截然不同的数据在微秒级别同时强行塞进同一个 DWC2 硬件端点,直接导致硬件数据翻转错误(Frame Parity 错乱),DWC2 芯片当场死机!这根本不是单纯的丢帧,这是两个驱动抢夺同一个硬件外设导致的物理碰撞!

最终极的解决方案:动态端点分配与事件驱动架构

  1. 彻底消除端点冲突:我们删除了硬编码的端点赋值,改用标准的底层 API tinyusb_get_free_in_endpoint() 为麦克风动态申请专属的物理端点。现在,麦克风(UAC)和键盘(HID)拥有了各自独立的高速车道,再怎么滚键盘也绝对不会发生物理碰撞。
  2. 事件驱动单线程闭环:我们彻底删除了占用极高 CPU 且极其危险的 uac_mic_task 轮询线程。现在的音频发送完全交给 tud_task 触发的 uac_driver_xfer_cb 中断回调去串行执行。
  3. 保留软件级看门狗作为安全网:由于早期的踩坑,我们发现即使发生网络波动导致 Windows 漏掉一次等时传输,旧版 TinyUSB 的 busy 标志位也可能无法自行恢复。因此我们保留了一个超轻量级的看门狗:一旦监测到 50ms 无中断,就会使用“神级后门” usbd_edpt_clear_stall(0, ep_addr) 无条件抹除残留的 busy 标志位并重启传输,为整个 USB 复合设备提供了坚不可摧的稳定性。