BLE 多连接崩溃:定时器回调里处理业务的坑与工作队列方案
发布时间:2026/10/30 8:11:14 锦皓数字建站

一句话崩溃不在你的代码里不代表根因不在你的代码里。回调里只提交不干活。BLE 多连接联调时设备偶发空指针崩溃。崩溃点在原厂协议栈内部栈回溯指到的全是协议栈自己的代码。我的业务代码一行都没崩崩的是协议栈。这种崩溃最难查。看崩溃现场每一行都是原厂代码加打印、复现压力测试时好时坏。最后定位到根因的时候我愣了很久是我在软定时器回调里调度发送无线数据。就这么一件事。下面把这个坑完整讲一遍为什么定时器回调里不能干活定时器方案错在哪我最后用工作队列怎么解的。这篇文章复盘一个 BLE 多连接项目上真实发生的协议栈崩溃案例排查思路是通用的只要项目里存在比业务代码优先级更高的上下文——底层协议栈、SDK、中断——都可能踩进同一个坑与芯片型号、无线协议无关。定时器回调里干活的反噬在任何 MCU、任何 RTOS 上原理相同。一、两处活当时都借了定时器先交代背景。BLE 设备支持主机和从机两种角色可以互相连接。连接建立后主机这边走 GATT 服务发现、订阅相关服务从机这边要发 MTU 更新请求马上发容易失败惯例做法是延时 50 到 100ms 再发。所以从机的需求就是一句话连接建立事件到来延迟 50ms执行一次发包动作结束。我第一版方案是定时器连接建立回调里启动一个 50ms 的定时器到期回调里发 MTU 更新请求。除了这个还有一个地方也在用定时器每收到一包无线数据延时 1ms然后在定时器回调里做无线处理和发送。当时的考虑有两个一是底层收包回调里直接跑业务代码不安全回调里栈空间小直接跑会栈溢出二是适配层没有现成的业务线程可以投递为一个收包处理专门开线程又觉得重就借了 1ms 定时器把活挪个地方。两处用法当时看都没问题。二、复现规律单角色稳如老狗主从一体才崩先说压测结果这个规律对后面定位很关键。设备只做单一角色1 个主机和 1 个从机互连连接、服务发现、订阅、收发数据、断开整套动作循环压测连跑 7 天零异常。设备做主从一体同时连着多个从机和多个主机还是同样一套动作循环压测每隔几天崩一次每次都崩在底层而且每次崩溃的位置都不一样——日志指向异常访问无效指针指针指向的是已经断开的连接句柄。崩溃点像随机游走在每个位置查都查不到自己的问题。同一份代码、同一个协议栈单角色压一周零异常多连接压测每隔几天崩一次而且每次崩溃的位置都不一样。这说明问题不在某个单点流程里而在连接多了这件事本身放大了什么东西。三、崩溃现场我的代码一行都没崩崩的是协议栈崩溃的现象值得单独说因为它的迷惑性太强了。空指针崩在原厂协议栈内部访问的是某个连接的句柄而那个连接其实已经断开了。按直觉排查能怀疑的方向就三个我一个一个排除。方向一协议栈有 bug。站不住脚。单角色压 7 天没事说明协议栈基础流程没问题而且崩溃访问的句柄确实已经失效访问失效句柄是结果不是原因。挂起。方向二断连流程没走干净我的断开处理漏了资源释放。我把断连回调、句柄释放的路径全部走查了一遍释放顺序没问题也没有双重释放。挂起。方向三内存被踩。开了内存越界检测钩子压测崩溃现象一模一样崩溃点附近的数据没有被动过的痕迹。挂起。三个直觉方向全排除了剩下的只有笨办法对照实验。第一轮我把定时器回调里的活逐个砍掉发现一个规律——只要定时器到期回调里的处理流程不执行改成空函数多连接压多久都不崩把处理流程放回去崩了。问题锁定在定时器到期回调里干重活这一件事上。但两个定时器都在到期回调里干活MTU 更新和收包处理到底哪个是主犯第二轮对照实验就简单粗暴了压测流程里连接建立后把通信测试去掉让链路上没有数据收发1ms 定时器自然一次都不会触发——剩下的只有 MTU 更新那点活。这样跑不崩。把通信测试加回来1ms 定时器跟着收包节奏跑起来崩了。通信测试数据收发1ms 收包定时器多连接压测结果关不触发不崩只剩 MTU 更新的活开高频触发每隔几天崩一次主犯落网不是 MTU 更新是收包处理那个 1ms 定时器。它跟着数据的节奏高频触发每次回调里都做无线处理和发送多连接时密度和重量一起上来。MTU 更新一类的一次性动作只是被牵连的次要角色。但这里还有最后一层迷惑定时器回调是我写的崩溃点却在协议栈里中间隔着好几层。回调里的动作是怎么把几十行之外的协议栈搞崩的真实的因果链是这样的。那个 1ms 定时器的回调跑在软定时器线程里而这个线程的优先级在系统里是最高档。多连接、数据量一大每收一包数据就触发一次回调回调跟着数据的节奏高频执行里面还做无线处理和发送。多个回调在这个高优先级线程里排队执行长时间霸占 CPU。底层协议栈的时序消息来不及处理。BLE 协议栈是强时序的连接事件、断连指示这些消息要按时处理线程优先级却被定时器线程压着。协议栈内部的状态机收不到及时的通知连接实际已经断掉协议栈里对应的状态还停留在连接中。然后协议栈自己访问了那个失效句柄。崩溃点、责任方看起来都是协议栈但根因在我我在一个不该干活的上下文里干了比较重的活。单角色时连接少、数据量小定时器回调执行得稀疏时序挤一挤还过得去主从一体多连接回调密度和重量一起上来挤不动了雷就炸了。这个坑值得单独记一句崩溃不在你的代码里不代表根因不在你的代码里。四、定时器方案错在哪三个坑一个比一个隐蔽复盘下来定时器方案有三个问题。第一个是平台差异第二、三个才是致命的。坑一有些平台只有硬件定时器回调就是中断软定时器不是哪都有的。有的平台只提供硬件定时器定时器回调本质就是中断服务程序。把处理逻辑写进定时器回调等于换了个地方继续在中断里干活——中断里不能阻塞、不能干重活的规矩一条都没绕开。更要命的是业务代码看不出区别。同一个启动定时器回调里发包的封装在 A 平台跑在软定时器线程在 B 平台直接跑在中断上下文。跨平台一移植雷就炸了。坑二就算有软定时器它的上下文也是高优先级这是我这次崩溃的直接原因。软定时器线程为了保证定时精度优先级通常排在系统最高档。你在回调里干的每一件事都是在和底层协议栈、和其他高实时性模块抢 CPU。坑一的结论在这里同样成立定时器回调和原厂底层回调是同一种东西都自带高优先级上下文。你的业务逻辑放进去就是把自己的优先级强行抬到系统最高。坑三一个连接一个定时器资源模型根本不对回头看需求连接建立后延迟 50ms执行一次。这是个一次性的需求。而定时器的语义是周期性它是个节拍器不是闹钟。拿节拍器当闹钟用就得一个连接占一个定时器连接数 N定时器就是 N 个多连接场景下定时器资源可能直接不够用每个定时器的创建、销毁时机要和断连事件严格对齐一个竞态就是泄漏或者悬垂句柄本来一个延后执行一次的简单需求被做成了资源管理问题。用周期性的资源去实现一次性的需求模型从根上就选错了。这才是定时器方案在 BLE 多连接场景必炸的根本原因。五、正解工作队列——回调里只提交不干活我把方案换成了工作队列workqueue。核心原则一句话回调里只做一件事把要做的事打包提交出去立刻返回。活交给普通线程上下文去干。先澄清一个容易混的点很多人分不清工作队列和消息队列消息队列传的是数据A 把数据交给指定的任务 B解决给谁的问题工作队列传的是活本身提交一个函数加参数交给系统里公共的工作线程执行提交者不用关心谁来执行解决在哪跑的问题我那个 MTU 更新需求传的不是数据是50ms 后执行发包流程这个动作。工作队列正好对口。实际效果对号入座换成工作队列后两处用法一起改。MTU 更新那处连接建立回调里只调一次提交接口把MTU 更新这个 work 项、带上 50ms 延时参数扔进工作队列回调立刻返回几微秒的事。50ms 后工作线程在自己的普通线程上下文里执行发包流程。收包处理那处同样收到无线数据的回调里不再启 1ms 定时器直接把处理这包数据提交进工作队列交给低优先级的工作线程去干底层协议栈的时序和自己的数据处理都各归各位。对照前面的三个坑逐个消失平台差异work 的执行永远在工作线程里和有没有软定时器无关优先级反噬工作线程优先级自己定放在比协议栈低的档位时序消息畅通无阻资源模型N 个连接就是 N 个 work 项几字节的结构体不用 N 个定时器断连时取消对应的 work 项就行一次性语义归一次性机制管改完之后多连接压测连跑数天协议栈再没崩过。六、workqueue 不趁手自己补一层但有两个关键设计不是所有平台的 workqueue 都拿来就能用。Zephyr 的k_work自带延时提交LiteOS 有工作队列但没有延时功能FreeRTOS 原生没有。我的需求里50ms 后执行这个延时是核心所以海思 LiteOS 平台上我是在它的工作队列之上用延时机制 专用工作线程补齐了这一层落地了一版。这里只讲两个最核心的设计完整的实现细节启停竞态、优雅退出这些篇幅所限不展开。设计一提交侧轻如中断队列里只传一个指针Submit只做两件事置一个原子标志把 work 结构体的指针写进消息队列。就写 4 个字节和写一个全局变量的开销同量级。队列里传完整数据其实也可以但代价大队列深度要按最长的数据预留有些平台入队时还会复制一遍内容。所以我选传指针并定死一条纪律传的内容动态申请处理完就释放。申请多少用多少内存的账随时算得清。所以从底层回调里调用它是安全的。回调里能安全做的事必须小到 Submit 这个量级。这就是回调里只提交、不处理、快返回的落地。工作线程那边就是最朴素的一个循环从队列取包把包里的 data 指针强转回 work 结构体调用它的 handler。handler 跑在这个普通线程的上下文里能阻塞、能等资源优先级我自己排。上下文归我控制了这是整个方案的本质。骨架就这么多一行不多typedef struct { uint32_t flag; /* 三态标志CAS 守门 */ void *handler_arg; /* 业务参数动态申请 */ work_handler_t handler; /* 活本身一个函数 */ } my_work_t; /* 提交侧回调里只调它写一个指针就返回 */ int work_submit(my_work_t *work); /* 工作线程主体取包 → 执行 → 释放循环往复 */ static void work_thread(void *arg) { my_work_t *work; while (work_fifo_get(work) 0) { work-handler(work-handler_arg); free(work-handler_arg); /* 用完即释放 */ } }后面的设计二补的只是延时提交这一环怎么接进来。设计二定时器没有扔把它降级成闹钟这是整个改造里我认为最有价值的一处认知转变。50ms 的延时还是要定时但定时器到期回调里的内容从执行发包流程改成了只做两件事static void delay_work_expire(void *arg) { my_work_t *work (my_work_t *)arg; uint32_t expected WORK_FLAG_DELAY_PENDING; /* 只做两件事CAS 状态转换 入队不执行 work-handler */ if (!work_cas(work-flag, expected, WORK_FLAG_SUBMITTED)) { return; /* 已被取消不入队任务不丢、不重复 */ } work_fifo_put(work, sizeof(work)); /* 到期把活转交工作线程 */ }定时器从干活的人降级成了叫醒的人。它到期后唯一的工作是把 work 项挪进队列剩下的一概不碰。定时器回调还是短小得像中断。本来它就跑在 swtmr 那个高优先级线程里这条纪律不能破。回头看崩溃前的我同样的定时器同样的回调位置唯一的变化是回调里干了多少活。活越重离协议栈的雷越近。不丢任务不重复执行CAS 状态机守门延时提交、取消、定时器到期三方会并发操作同一个 work 项状态最容易乱。我的做法是给每个 work 项一个标志位三态转换全用原子 CAS 守门。CAS 是 Compare-And-Swap比较并交换的缩写一条硬件级原子指令——只有当前标志位等于预期值才允许改成新值否则改不动。三方同时来抢永远只有一个抢得动抢不动的那些也就知道状态已被别人先处理了。靠它能做到IDLE → DELAY_PENDING提交延时任务DELAY_PENDING → SUBMITTED定时器到期入队DELAY_PENDING → IDLE到期前被取消执行完成回IDLE可再次提交任何一方 CAS 失败就说明状态已被另一方抢到。比如取消时发现已经入队了那就让它执行完不丢也不重复。多连接下 N 个 work 项各自独立走这套状态机互不干扰。七、判断表什么场景用什么方案把这次的教训沉淀成一张表遇到中断/回调里的活怎么处理时直接对照需求特征用什么原因周期性固定节奏干活心跳、轮询采样定时器这才是定时器的本职事件驱动延迟 N ms 后执行一次工作队列延时提交一次性需求归一次性机制N 个会话就是 N 个 work 项中断/回调收到数据交给指定任务处理消息队列传数据收件人明确中断里活重又不想专门开任务工作队列复用公共工作线程省资源多连接/多会话各自要延迟任务工作队列千万别一个会话开一个定时器一句口诀收尾定时器管节奏工作队列管事件消息队列管数据。写在最后这次排查给我最大的教训不是学会了用工作队列而是搞懂了一件更基础的事回调函数的代码是你的上下文是别人的。在别人的上下文里干活优先级、时序、阻塞约束全是别人的规矩你写的每一行业务代码都在用别人的上下文冒险。落地就三句话。回调里只提交、不处理、快返回跨平台项目优先封装一层 work 接口有 SDK 用 SDK没有就消息队列加线程手搓定时器要用只让它当闹钟。你在项目里处理中断/回调里的活是用标志位轮询、定时器还是工作队列定时器的坑你踩过哪个留言区聊聊你的排查过程。有用的话点个在看让更多踩过这个坑的工程师看到。往期推荐嵌入式异步事件处理实战从中断死锁到环形队列分发
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。