
前阵子在一个基于 STM32WB55RG 的项目里我踩了一个非常典型的双核 MCU 复位坑设备上电启动一切正常BLE 广播正常发出手机也能正常连接但只要在运行过程中执行一次软件复位也就是 NVIC_SystemReset()重启之后 BLE 广播就彻底哑火了抓包器上什么都看不到其他设备也完全扫描不到它。这个现象折腾了我整整两天。一开始以为是射频硬件问题后来怀疑是广播参数被改坏了最后才发现真正的根子出在对双核架构下 BLE 协议栈初始化时序的理解上。这篇文章把这条排查路径、根因分析和最终修复方案完整记录下来给同样在 STM32WB55RG 上做 BLE 开发的朋友做一个参考。1. 问题现象与复现路径1.1 故障表现广播静默的几种姿势先说具体现象。这个设备是一个基于 STM32WB55RG 的传感器节点跑的是 ST 官方的 BLE 协议栈加自研应用层。正常工作流程是这样的上电后M4 核心完成系统初始化启动 BLE 栈调用 aci_gap_set_advertising_parameters 配置广播参数然后调用 aci_gap_start_advertising 开始广播。手机端用标准的 BLE 扫描器就能看到设备连接后能正常读写特征值。问题出在设备运行过程中应用层收到一条串口指令后执行了软件复位。复位完成后系统的串口日志正常打印说明 M4 核心跑起来了外设也初始化了BLE 相关的代码路径也执行了函数返回值看起来都是正常的。但实际用 nRF Connect 或者手机自带的蓝牙扫描界面去扫设备完全消失。我还试过几种不同的复位触发方式NVIC_SystemReset()、看门狗复位、甚至故意触发一次 HardFault 让它自复位结果都一样复位后 BLE 必然静默。而如果直接断电再上电一切都恢复正常。这就排除了程序里某处配置被永久改坏的可能问题一定是出在软件复位这条启动路径和上电复位这条启动路径的差异上。1.2 复现条件为什么只有软件复位会触发这个问题的复现条件非常稳定只要满足两个条件就必然出现系统已经正常运行过一段时间BLE 栈处于完全就绪状态。执行一次热复位软件复位或看门狗复位而不是冷复位断电再上电。有意思的是这个现象和 BLE 栈的工作状态没有直接关系。不管是正在广播、正在连接、还是广播了一会儿就关闭了广播只要系统处于BLE 栈已经初始化完成的状态再执行软件复位复位后 BLE 就起不来。我最初怀疑过是不是广播参数在复位前被某个操作改掉了于是在复位前把广播参数、设备地址、广播间隔都打进日志里复位后再打一遍两边完全一致。参数没问题那剩下的就是协议栈本身的启动流程问题了。2. STM32WB55RG 双核架构与 BLE 协议栈工作机制2.1 CPU1 与 CPU2 的分工这不是一颗普通 MCUSTM32WB55RG 和普通的单核 MCU 最大的区别在于它内部有两颗 Cortex 核心一颗是 Cortex-M4主频 64MHz负责跑应用代码另一颗是 Cortex-M0负责跑射频协议栈也就是 BLE 的链路层、L2CAP、ATT/GATT 等协议栈实体。ST 的文档里把 M4 称为 CPU1把 M0 称为 CPU2。M0 上跑的那套 BLE 协议栈固件不是用户写的而是 ST 提供的二进制库烧录在独立的 Flash 区域里。用户的应用代码不需要关心 BLE 协议细节只需要通过一套标准接口HCI 命令和事件的变体和 M0 通信就行。这种架构的好处是协议栈和应用完全隔离协议栈崩溃不会带崩应用坏处就是引入了双核之间的同步问题而这个问题恰恰是这次 Bug 的温床。2.2 BLE 协议栈的加载与启动过程每次系统上电或者复位后两个核心是同时开始启动的。M4 从用户 Flash 区加载应用M0 从无线协议栈的专用 Flash 区加载 BLE 固件。这个加载过程不是瞬间完成的M0 需要读取 Flash、初始化射频外设、做内部校准这个过程通常需要几毫秒到几十毫秒。关键是M4 和 M0 的启动速度是完全不同的。M4 的代码路径短从复位向量开始经过 SystemInit、HAL_Init、SystemClock_Config到主循环可能只需要几百微秒。而 M0 那边要加载整个 BLE 协议栈固件初始化射频、校准、建立事件循环这个时间明显比 M4 长得多。两个核心之间的通信是靠一个叫做 IPCCInter-Processor Communication Controller的外设完成的。M4 通过 IPCC 往共享内存里的 mailbox 写命令M0 通过 IPCC 中断来接收命令处理完后再把事件通过 mailbox 传回来。整个传输层在 ST 的代码里叫做 TLTransport Layer它依赖 IPCC 硬件中断和共享内存这套机制没有任何硬件握手来保证两端同时就绪。2.3 和单核 BLE MCU 的差异为什么不能想当然如果是 nRF52832 这类单核 BLE MCUBLE 协议栈和应用跑在同一颗核心上复位后两者是一起重启的根本不存在谁等谁的问题。STM32WB 的双核架构让这个问题变得隐蔽M4 这边的初始化函数返回值都是正常的因为 API 调用本身只是往 mailbox 里写数据只要 IPCC 寄存器能写入函数就返回成功。但实际上 M0 可能还没来得及把协议栈启动完这些命令被写入后根本没有被处理或者被协议栈在启动阶段直接丢弃。这个命令写入成功但不被处理的现象是最容易误导人的。我在日志里看到 aci_gap_start_advertising 返回了 0x00成功但实际上广播根本没发出去。因为这条命令只是被放到了 mailbox 里而 M0 端的协议栈还在启动中根本没开始消费 mailbox 里的命令。等它启动完这些命令要么已经丢失要么由于状态不对被内部拒绝了。3. 软件复位后 BLE 广播失败的根因分析3.1 复位后两个核心各自经历了什么要搞清楚软件复位后发生了什么得先明确一件事NVIC_SystemReset()触发的是系统级复位它会把 M4、M0、外设、时钟树全部复位。也就是说M0 也会被复位然后重新加载 BLE 协议栈。从复位源的角度看软件复位和上电复位在硬件层面并没有本质区别M0 同样会重新启动并重新加载协议栈。那么差异到底在哪差异在 M4 这边的启动代码路径。我的应用固件在主函数的开头有一段逻辑如果检测到是软件复位就跳过一部分只在冷启动时才需要执行的慢初始化代码直接进入快速启动流程。这个优化本意是为了加快复位后的启动速度但没想到它跳过的恰恰是等待 BLE 栈就绪的关键环节。3.2 三个关键失败节点时序、残留中断、状态误判根据我后来用 GPIO 打点测量的结果整个失败过程可以归纳为三个节点第一个节点是时序问题。M4 从复位到执行 SHCI_C2_BLE_Init() 的时间大约是几百微秒而 M0 完成协议栈加载并对外发出栈就绪事件的时间大约是 5 到 10 毫秒。在我的代码里SHCI_C2_BLE_Init() 调用完之后没有等待栈就绪事件而是直接继续执行后面的广播配置操作。于是在 M0 还没准备好之前M4 已经把一堆 HCI 命令塞进了 mailbox。第二个节点是 IPCC 中断残留。软件复位前IPCC 可能还有未处理完的中断标志。复位虽然会清掉外设寄存器但如果 M4 的初始化代码里没有显式清理 IPCC 的中断挂起标志复位后第一次进入 BLE 初始化时传输层可能会把残留的中断误判为一次有效事件从而导致后续的事件处理逻辑错乱。第三个节点是状态误判。我的代码里有一个静态变量记录 BLE 栈是否已初始化。在软件复位后这个变量的值依赖于内存是否被清零。奇怪的是我在部分复位场景下发现这个变量的值变成了已初始化于是代码跳过了完整的初始化流程直接去启动广播。后来查证发现这个变量没有被正确放置在 .bss 段导致复位后它的值是不确定的。3.3 为什么看起来初始化成功了却不广播这是这个 Bug 最坑的地方所有 API 调用的返回值都是 0x00也就是成功。原因我在前面提过因为这些 API 只是把命令写入共享内存 mailbox然后通过 IPCC 发送一个中断给 M0。只要 mailbox 能写入函数就返回成功至于 M0 有没有真正执行函数本身并不知道。所以你在 M4 侧看到的现象是初始化函数全返回成功、广播启动函数也返回成功、日志一切正常但空中就是没有广播包。这是典型的命令发送端正常、命令执行端异常的场景。要定位这种问题唯一可靠的办法是去确认 M0 侧协议栈的真实状态。4. 调试过程与关键排查手段4.1 第一步确认复位源排除杂散复位排查的第一步我先在 main 函数最开头加了复位源检测把 RCC_CSR 寄存器里的复位标志读出来通过串口打印。代码大概是这样的uint32_t reset_source HAL_RCC_GetResetSource(); if (reset_source RCC_RESET_SOURCE_SFTRST) { printf(Reset source: Software Reset\n); } else if (reset_source RCC_RESET_SOURCE_POR) { printf(Reset source: Power-On Reset\n); } else if (reset_source RCC_RESET_SOURCE_BOR) { printf(Reset source: Brown-Out Reset\n); }这一步确认了复位源确实是软件复位排除了实际上是某种异常复位的可能。同时顺手调用了一下__HAL_RCC_CLEAR_RESET_FLAGS()清掉标志避免下一次判断受影响。4.2 第二步用 GPIO 打点测量两个核心的启动时序为了确认M4 比 M0 快多少这个关键数据我在代码里加了两处 GPIO 翻转一处放在 M4 的 main 函数刚进入时另一处放在 BLE 栈就绪事件回调函数里。用逻辑分析仪抓这两个 GPIO 的上升沿时间差。实测结果让我很意外M4 进入 main 到 BLE 栈就绪事件到达之间相差了大约 6.8 毫秒。而我原来的代码在 SHCI_C2_BLE_Init() 之后紧接着就调用了 aci_gap_set_advertising_parameters()这两者之间隔了不到 1 毫秒。也就是说广播配置命令在 M0 尚未就绪时就已经发出去了。这一步是整个排查过程的关键转折点。有了这个时间差数据后面的方向就非常清晰了。4.3 第三步检查 HCI 层交互日志确认命令是否被处理为了进一步确认 mailbox 里的命令是否被 M0 消费我在传输层加了一组调试日志把每次写入 mailbox 的命令 opcode 和处理完成后的回调都打出来。对比冷启动和软件复位两次启动的日志发现了明显的差异冷启动时SHCI_C2_BLE_Init() 发出的命令会在几毫秒后收到一个类型为BLE 栈就绪的异步事件然后后续的每条 HCI 命令都会收到对应的事件回调。而软件复位后SHCI_C2_BLE_Init() 之后的几条命令全部石沉大海没有产生任何事件回调。这说明 M0 在复位后虽然启动了但启动完成后并没有进入正常的命令消费状态。后续命令全部堆在 mailbox 里没被读取或者协议栈在内部重启的某个阶段把命令直接丢弃了。4.4 第四步用抓包器验证物理层行为最后一步用 nRF52840 dongle 加 Wireshark 抓包在软件复位前后持续监听 2.4GHz 频段。抓包结果显示复位前设备地址的广播包规律出现复位后立刻消失并且之后再也没有出现任何来自该设备地址的广播帧。这个结果把物理层根本在发数据这个可能性排除了问题锁定在协议栈没有真正进入广播状态。到这里排查路径就完整了物理层正常、参数正常、复位源正常、API 返回正常唯一的异常就是 M0 的协议栈没有在预期时间内就绪导致后续所有命令都没有被真正执行。5. 修复方案正确的复位后 BLE 初始化流程5.1 核心思路把等待栈就绪变成强制约束修复方案的核心就是一句话在 SHCI_C2_BLE_Init() 之后必须等待 M0 发出BLE 栈就绪事件确认协议栈已经完成启动才能继续执行后续的广播配置命令。不能用固定延时因为不同复位场景下 M0 的启动时间会有波动固定延时要么太短导致仍偶发失败要么太长拖慢启动速度。从 ST 官方 BLE 中间件的代码里其实能找到这个机制。在 app_ble.c 里SHCI_C2_BLE_Init() 之后注册了一个回调事件到达后设置一个标志位。问题是我的代码在软件复位路径上把这个回调给绕过了只在冷启动路径上才会走到。修复的第一步就是让所有复位路径都严格执行完整的初始化流程。5.2 修改后的初始化代码引入栈就绪事件等待下面是我改写后的 BLE 初始化逻辑先在传输层注册就绪回调然后在 SHCI_C2_BLE_Init() 之后阻塞等待回调标志位。考虑到极端情况下协议栈可能启动失败我加了一个超时保护超时后进入错误处理而不是无限等待static volatile uint8_t ble_stack_ready_flag 0; /* BLE 栈就绪事件回调在 M0 完成协议栈启动后被调用 */ static void on_ble_stack_ready(void) { ble_stack_ready_flag 1; } void BLE_Init_With_Ready_Check(void) { /* 1. 清理残留的 IPCC 状态避免上一次会话的中断标志干扰 */ LL_IPCC_DisableInterrupt(IPCC, LL_IPCC_C1RX_CH1); LL_IPCC_DisableInterrupt(IPCC, LL_IPCC_C1TX_CH1); LL_IPCC_ClearFlag_Channel1(IPCC, LL_IPCC_C1RX_CH1); LL_IPCC_ClearFlag_Channel1(IPCC, LL_IPCC_C1TX_CH1); /* 2. 初始化传输层 */ TL_Init(NULL); /* 3. 注册 BLE 栈就绪回调 */ hci_register_ble_stack_ready_callback(on_ble_stack_ready); /* 4. 在 CPU2 上启动 BLE 协议栈 */ SHCI_C2_BLE_Init(); /* 5. 等待栈就绪事件带超时保护 */ ble_stack_ready_flag 0; uint32_t timeout HAL_GetTick() 3000; while (!ble_stack_ready_flag HAL_GetTick() timeout) { /* 这里需要保证底层的传输层中断正常被处理 */ __WFI(); } if (!ble_stack_ready_flag) { /* 处理协议栈启动超时建议进入错误处理 */ Error_Handler(); } /* 6. 只有确认协议栈就绪后才能配置并启动广播 */ aci_gap_set_advertising_parameters(...); aci_gap_start_advertising(...); }这段代码的关键在于第 5 步。在等待期间使用__WFI()进入休眠依赖 IPCC 中断唤醒这样不会浪费 CPU 时间也不影响系统的实时性。如果你的应用有 RTOS这一步可以用信号量或事件组来替代轮询效果是一样的。5.3 在 main 函数里统一复位后的启动路径除了修改 BLE 初始化函数我还在 main 函数里做了一个更重要的调整无论检测到哪种复位源都走完整的初始化流程。我删掉了之前那个软件复位跳过部分初始化的优化逻辑改为在复位后统一执行所有外设初始化和 BLE 初始化。int main(void) { HAL_Init(); SystemClock_Config(); /* 打印复位源便于现场问题定位 */ Print_Reset_Source(); /* 无论哪种复位都完整初始化所有模块 */ MX_GPIO_Init(); MX_IPCC_Init(); MX_RTC_Init(); /* BLE 初始化固定使用带就绪检查的流程 */ BLE_Init_With_Ready_Check(); /* 主循环 */ while (1) { /* 处理 BLE 事件、串口命令等 */ } }这样修改之后冷启动和软件复位走的是同一条初始化路径不存在哪条路径被优化掉的问题。实测下来软件复位后 BLE 广播能在 20 毫秒左右恢复正常和冷启动的恢复时间基本一致。5.4 一个备选思路只用 CPU1 复位来保留 BLE 栈状态这里额外说一个思路如果你不想让 M0 跟着一起复位想保留 BLE 栈的运行状态可以考虑只复位 M4 核心。STM32WB 的 RCC 里提供了对 CPU2 的独立复位和时钟控制寄存器理论上可以做到只重启 CPU1、不同时重启 CPU2。但在实际项目里我不太推荐这条路因为 BLE 栈和应用之间通过共享内存通信如果应用侧M4被复位重来但栈侧M0还保留着旧的状态两边很容易出现状态不一致的问题。比如 BLE 栈还认为当前有一个活动连接但应用侧已经完全不知道这个连接的存在了。所以除非你非常清楚自己在做什么否则最稳妥的方案还是让两个核心一起复位然后走完整的重新初始化流程。6. 避坑经验与排查速查表6.1 双核 BLE MCU 开发的三条实战体会这个 Bug 修完之后我在团队内部做了一个简单的复盘总结出三条对后续开发非常有用的经验。第一条双核架构下千万不要用固定延时来同步两个核心的启动。我看到过不少代码在 SHCI_C2_BLE_Init() 后面加一个 HAL_Delay(10) 来等 M0 启动。这种写法在最常见的场景下可能没问题但只要 M0 的启动时间因为 Flash 等待周期、温度、供电电压的变化而变长就会偶发失败而且这种偶发失败极难复现和定位。正确做法是使用事件回调或标志位来做真正的同步。第二条软件复位和上电复位要当成两条完全不同的启动路径来 review。很多初始化代码都是基于干净的上电启动写出来的在软件复位场景下外设寄存器、中断标志、内存内容都可能存在残留状态。每当你往固件里加一个跳过某些初始化的逻辑时一定要问自己如果这次是软复位跳过了一半下次硬复位又全量执行两种路径的最终状态是否完全一致第三条HCI API 返回成功不等于协议栈执行成功。在 STM32WB 这种通过共享内存 mailbox 通信的架构里发送命令和命令执行是异步的。函数返回值只代表命令已经被写入 mailbox不代表命令已经在协议栈中执行完成。收到命令对应的异步事件回调才是命令真正执行完毕的标志。6.2 问题排查速查表从现象到方案我把这次排查中用到的检查项整理成了一张表下次遇到类似问题可以按顺序过一遍几分钟就能定位到大致方向。检查步骤检查内容方法/工具典型结论1复位源读 RCC_CSR 寄存器确认是软复位还是掉电复位2M0 是否在运行通过 ST-LINK 连接查看 M0 核状态确认 M0 是否成功启动3HSE 是否稳定读 RCC_CR 的 HSERDY 位排除射频时钟问题4IPCC 中断是否有残留读 IPCC SR 寄存器确认清理是否到位5BLE 栈就绪事件在栈就绪回调里打 GPIO 翻转测量 M0 实际启动时间6是否有抓包环境BLE Sniffer Wireshark确认物理层是否真的在广播7HCI 命令是否有回包在命令回调里加日志确认命令是否被真正执行6.3 最后一个小提醒翻阅 errata sheet在我把这个 Bug 的根因定位到初始化时序之后顺手翻了一下 STM32WB55 系列的芯片 errata sheet确认这不是一颗已知的硅片 bug。但如果你在做 STM32WB 的开发我强烈建议你把 errata sheet 从头到尾读一遍里面记录的很多问题都极其隐蔽比如某些复位模式下的射频行为异常、某些低功耗模式下的时钟延迟等。这种问题一旦遇到不查 errata 的话可能三五天都排查不出来。就我个人而言这次经历最大的收获不是修好了一个 Bug而是对双核 MCU 的协同启动机制建立了敬畏心。现在我的所有 STM32WB 项目代码里任何涉及 CPU2 的操作我都会强制要求必须确认对端就绪后才能发送数据这个习惯已经帮我避免了好几个潜在的现场故障。如果你的项目里也有类似的复位后功能失效问题不妨先检查一下两个核心的启动握手是否真的可靠而不是急着怀疑硬件或者射频链路。