BLE 多连接崩溃:定时器回调里处理业务的坑与工作队列方案

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

一句话:崩溃不在你的代码里,不代表根因不在你的代码里。回调里只提交,不干活。

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,没有就消息队列加线程手搓;定时器要用,只让它当闹钟。

你在项目里处理中断/回调里的活,是用标志位轮询、定时器,还是工作队列?定时器的坑你踩过哪个?留言区聊聊你的排查过程。

有用的话点个在看,让更多踩过这个坑的工程师看到。

往期推荐

  • 嵌入式异步事件处理实战:从中断死锁到环形队列分发