BLE 调试工具读取 ADC 波形时要同时处理确认和前端性能
一个 BLE 波形采集项目需要在浏览器调试工具里查看 ADC 原始波形。这个功能看起来只是“从 MCU 取一批样本,再画到前端 canvas 上”,实际要处理两个边界:BLE 大帧分片可能中断,样本被读出后也不能提前从设备 FIFO 删除。

断链会丢样本
先保住数据
前端状态变复杂
收敛交互
浏览器成瓶颈
稳住消费节奏
采样模块先独立出来
ADC 采样最初和本地计算任务绑在一起。本地算法为了测速或省电被关闭时,ADC 采样也会停掉,前端自然读不到波形。
这次先把 ADC 采样拆成单独模块:定时器固定触发 ADC0 采样,原始 uint16_t 样本写入 FIFO。BLE 调试工具只消费这个 FIFO,不依赖本地计算任务是否启用。
拆分后,ADC 采样、上层处理任务、BLE 调试指令变成三个层次。采样层只保证数据进入 FIFO,应用层再决定如何读取和确认。
大 TinyFrame 要排队发送
一次读取 120 个 ADC 样本时,TinyFrame 响应已经明显大于单个 BLE ATT Notify 的有效载荷。MTU 仍是 23 时,一个应用响应会被拆成多个 20 字节左右的 Notify 分片。
最初直接在 TinyFrame 发送出口连续发送所有分片。小包可以通过,大包会出现浏览器 CRC 错误和等待超时。日志看起来像 TinyFrame 校验失败,实际是后半帧没有稳定发完。
修正方式是在协议层增加 TinyFrame Notify 发送队列。TinyFrame 生成完整帧后先进入发送缓冲,主循环和连接事件回调按 MTU 分片推进;协议栈忙时保留 offset,下次继续发送。浏览器仍然按一帧应用响应解析,底层分片由队列保证补齐。
读取后立即删除会丢样本
发送队列只能保证设备侧尽量把完整帧发出去,不能证明浏览器已经解析成功。ADC FIFO 如果在组响应时直接推进读指针,后续只要发生断链、超时、前端刷新或 ACK 丢失,这批样本就已经从设备侧消失。
这对波形调试也不能接受。FIFO 里保存的是采样时间序列,批量读取只是一次传输尝试,不应该等同于删除。
因此 ADC 读取改成待确认语义:读取指令只把当前批次复制到待确认缓存,并返回 batch_id。前端收到完整 TinyFrame、完成 CRC 校验、解析出样本并画到波形图后,再发送 ACK。MCU 只有收到匹配的 batch_id,才推进 FIFO 读指针删除这批样本。
FIFO 满时也不能覆盖未确认数据
旧实现为了保持实时波形,FIFO 满时会覆盖最旧样本。这个策略适合“只看最新趋势”的场景,但不适合用户明确要求不可丢包的采集链路。
现在 FIFO 满时保留旧数据,丢弃新来的样本并累计 overflow 计数。这样至少不会把已经进入待确认链路的数据覆盖掉。前端如果消费速度不足,应通过 FIFO 剩余数量和 overflow 计数暴露出来,避免静默删除旧样本。
前端也要参与确认
前端自动读取不能只循环发 GET。它必须等待上一批 GET 响应完成,再发送对应 ACK;ACK 返回成功后,才允许进入下一轮读取。
这个流程把责任边界分清楚:
- MCU 负责采样、缓存、返回待确认批次。
- BLE 发送队列负责把 TinyFrame 分片发完整。
- 浏览器负责校验、绘图,并对已消费批次发送 ACK。
- MCU 收到 ACK 后才删除 FIFO 中对应样本。
GET 和 ACK 分开后状态会变复杂
把读取和确认拆成两个指令后,设备侧的数据安全边界清楚了,但前端状态机变复杂了。一次完整消费要经历 GET、接收大 TinyFrame、解析、绘图、发送 ACK、等待 ACK 响应这几个步骤。任何一步被浏览器后台节流、BLE 写入失败、页面连点或旧响应延迟打断,前端都可能和设备侧状态错开。
实际调试时可以复现这个问题:手动连续点击几次读取 ADC,再打开自动读取,FIFO 剩余数量会长期降不下来。原因并不在 FIFO 本身。前端同时启动了多个 ADC 读取流程,等待响应对象会被后一次操作覆盖;旧 GET 或旧 ACK 响应回来后,又可能被新的等待者接收。页面刷新后状态清空,所以现象会临时恢复。
这个阶段暴露出另一个设计边界:ACK 单独成帧虽然语义直接,但它让浏览器必须维护两条相关的异步链路。GET 和 ACK 的先后顺序、帧 ID、超时清理、断线恢复都要处理。对一个持续采集工具来说,这个复杂度会直接影响稳定性。
下一次读取顺带确认上一批
后续把 ADC 读取流程收敛成单条主链路。前端收到一批 ADC 样本并成功绘图后,先记录这批 batch_id。下一次发送 GET 时,把上一次已经消费的 batch_id 一起带给设备。
设备收到新的 GET 后,先尝试确认上一批,再返回当前待确认批次或新批次。这样前端的主循环只需要维护“发起读取、等待对应帧 ID 的响应、绘图、记录下一次要确认的 batch_id”。确认动作跟下一次读取绑定在一起,减少一次 BLE 往返,也减少一个容易和 GET 交叉的异步状态。
这个方案还要做成可恢复语义。浏览器可能没有收到上一轮响应,但设备可能已经处理过其中的确认动作。下一次 GET 再带旧 batch_id 时,设备不能让链路停在错误状态,而应继续返回当前待确认批次,让前端重新对齐到设备侧状态。
前端侧也同步收紧状态机:
- ADC 读取过程加互斥,手动连点时忽略重复触发。
- ADC 响应按 TinyFrame 帧 ID 匹配,过期响应直接丢弃。
- 发送失败时立即清理 pending 状态,避免必须刷新页面。
- 页面进入后台时暂停自动读取,回到前台后再补一轮读取。
这张状态转移图来自手动连续读取 ADC 的三次前端截图。第一次读取拿到一个待确认批次;第二次读取会带上上一批 batch_id,设备先确认上一批,再返回下一批;前端继续记录新的待确认批次。

长时间运行后前端也会拖慢采集
合并 ACK 后,短时间自动读取可以把 FIFO 剩余数量从高位追到 0。但继续长时间运行后,又出现了新的现象:页面没有切到后台,FIFO 仍会慢慢升高。这个现象说明 BLE 链路和设备侧一开始是够用的,后面变慢更可能发生在浏览器前端。
排查后发现,调试页面本身也在消耗采集能力。每批 ADC 数据都会产生 TX、Notify、RX、ADC 摘要等多条日志。日志区不断追加 DOM 节点,每次写日志还会滚动到底部。运行时间越久,日志节点越多,浏览器主线程处理日志和布局的开销越大,自动读取的实际间隔就会被拉长。采样端还在持续产出数据,前端消费速度下降后,FIFO 剩余数量自然又会涨回去。
这次处理对象转到前端调试工具的运行成本。第一项改动是限制日志数量:日志只保留固定行数,超过后删除旧节点;ADC 自动读取时减少 TX、Notify、RX 细节输出,只保留低频摘要。这样日志仍能观察状态,也不会无限增长拖慢页面。
第二项改动是给自动读取做间隔补偿。原来用固定定时器触发读取,当前一轮读取、绘图或日志处理变慢时,后续节奏会跟着漂移。现在改成上一轮完成后再安排下一轮,并根据这一轮实际耗时扣减等待时间。目标间隔是 200 ms,如果这一轮已经花了 80 ms,下一轮就等待约 120 ms;如果这一轮已经超时,就尽快进入下一轮。这样可以减少前端主线程抖动对采集节奏的影响。
这个阶段的收获是,通信链路调通以后,瓶颈不一定还在设备或 BLE。浏览器调试工具也属于采集链路的一部分,日志、绘图、DOM 更新都会吃掉时间预算。持续采集类工具要把“可观察性”和“采集性能”分开设计,调试信息不能无限制地占用主线程。
结论
BLE 链路带宽足够时,仍然可能因为发送节奏和删除时机造成数据丢失。ADC 波形读取不能在批量读取后立即删除,必须先把读取和删除拆开。
拆开以后还要继续收敛前端状态机。最终结构是:ADC FIFO 保存原始样本,TinyFrame 返回待确认批次,Notify 队列保证大帧分片发送,前端解析成功后记录 batch_id,下一次 GET 顺带确认上一批,MCU 再删除样本。这样才能把“浏览器已经消费”和“设备允许删除”绑定在一起,同时避免 GET 和 ACK 两条异步链路互相干扰。
解决不丢包后,还要继续观察长时间运行。前端日志无限增长和固定定时器漂移也会让消费速度下降。最终处理围绕同一个目标逐层收敛:先保证数据不提前删除,再减少确认链路状态,再降低浏览器前端运行成本。工程调试经常就是这个过程,每解决一层问题,下一层瓶颈才会暴露出来。
本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。