ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

BlueNRG Flash操作与BLE事件互斥处理:从断连到稳定

BlueNRG Flash操作与BLE事件互斥处理:从断连到稳定 最近在调试一块基于BlueNRG-2的板子遇到一个让我折腾了两天的怪问题只要往内部Flash写配置参数BLE连接就掉线严重的时候设备直接假死。手机App连上设备点一下“保存配置”界面转个圈就提示“连接已断开”。一开始怀疑是供电问题换电容、加稳压问题依旧后来把Flash擦写代码整段注释掉连接稳如老狗才把故障锁定在Flash操作和BLE事件处理之间的资源冲突上。借着这个案例我把BlueNRG系列芯片Flash操作与BLE事件的互斥处理这件事完整梳理了一遍。这篇笔记编号LAT1216内容不光是记录问题现象更想讲清楚三件事为什么会冲突、怎么设计互斥机制、以及实际调试时怎么定位和避坑。如果你正在做BlueNRG-1/BlueNRG-2的产品尤其是涉及参数存储、OTA升级或者批量产线烧录这篇文章应该能帮你少走不少弯路。1. 一擦Flash就断连问题到底出在哪1.1 故障现象与初步排查我的测试环境其实不复杂一块BlueNRG-2定制板SDK版本是官方BlueNRG-1_2 DK手机用nRF Connect连接自定义GATT服务里有三个特征值分别读写设备名称、MAC地址和一组工作参数。用户点击“保存”后从机把收到的数据经过校验、打包写入内部Flash的一个固定页然后读回做校验。第一次实测就翻车了。现象有几个特征连接状态下执行Flash写操作大约一半概率会触发连接断开断开时机通常在擦除动作开始后几十毫秒内。不断开的情况下GATT Write Response明显变慢手机端偶发超时。擦写期间如果同时有Notify事件要下发HCI队列会堆积表现为App收到数据的时延大幅增加。连续执行多次Flash写操作后偶尔会出现设备死机看门狗都救不回来。我用串口打印把执行时序拉出来看Flash擦写函数的耗时大约在20到30毫秒量级具体时间和擦除页数、供电电压、芯片温度都有关系。而BLE连接事件间隔用的是默认的30毫秒也就是说一次Flash擦写的时间几乎和一个连接事件周期相当冲突几乎不可避免。更深层的原因在于BlueNRG这颗SoC把应用代码、协议栈和用户数据都放在同一颗内部Flash里Flash控制器在擦写时必须占用内部总线和CPU取指通道这个过程中CPU实际上是处于停顿状态的。BLE协议栈虽然是以库的形式存在但它跑在同一个Core上连接事件到来时如果CPU没法及时响应链路层就会错过事件窗口。1.2 BlueNRG的Flash操作与BLE事件争抢的是什么很多人刚开始可能和我一样觉得“互斥”不就是加个锁吗其实这里的互斥和RTOS里保护共享数据的互斥不太一样。Flash操作与BLE事件争抢的不是某一块内存而是三个层面的资源。第一个层面是CPU执行权。BlueNRG的Flash擦除和编程操作会阻塞CPU取指擦除一个页的时候所有正在执行的代码都会停下来。BLE协议栈底层是用事件驱动的方式运行的连接事件、广播事件、GATT请求都依赖CPU在特定时间点去处理链路层中断。CPU被堵住链路层中断响应自然就被推迟了。第二个层面是中断响应延迟。Flash控制器工作时NMI、外设中断、协议栈定时器中断的中断响应时序都会受到影响。实际测试中如果擦写Flash期间刚好有定时器触发定时器回调执行时间会被整体拉长这在用定时器做协议栈时基的工程里很容易引发连锁超时。第三个层面容易被忽视是协议栈内部对Flash的访问。BlueNRG协议栈在配对绑定、存储静态随机地址或者维护NVDS信息时会访问内部Flash区域。如果应用层正在擦写用户Flash页协议栈同时也在写自己的NVDS页两边直接打架轻则数据错乱重则把Flash状态机搞崩。所以“互斥处理”这个词准确的描述是在Flash控制器忙碌期间CPU必须长时间停顿这段停顿不能落在BLE链路层事件的处理窗口内同时还要避免应用层和协议栈同时对Flash发起写操作。1.3 为什么默认连接参数下更容易暴露默认情况下从机连接参数很多是主机说了算。手机侧的连接间隔可能只有7.5毫秒也可能被配成30毫秒、50毫秒。Flash页擦除时间如果按20毫秒算连接间隔7.5毫秒的话一次擦除会错过两到三个连接事件。链路层是有容错机制的错过一两个连接事件本身不一定会断连因为supervision timeout一般有几秒的余量。真正的坑在于如果从机在错过连接事件的同时主机刚好发来一个GATT写请求这个请求要等下一个连接事件重传重传几次如果还是没响应主机端的GATT层就会报超时。很多App对GATT超时的处理很粗暴直接断连于是你看到的现象就是“一写Flash就掉线”。另外还有一个隐藏问题Flash擦除期间CPU停顿协议栈的调度时钟也会被跳过。BlueNRG的协议栈时钟是基于SysTick或者硬件定时器的如果这个tick在Flash擦除期间没有及时累加协议栈内部的时间基准会产生偏移轻则导致定时事件晚触发重则让链路层误判从机失联。简单说默认连接参数下连接间隔和Flash擦写耗时的比值越接近、甚至擦写耗时大于连接间隔问题就越明显。想要解决冲突思路也就围绕这两点要么缩短单次Flash占用CPU的时间要么让BLE事件在Flash操作期间“合法跳过”。2. Flash与BLE事件互斥处理的整体思路与方案选型2.1 先明确“互斥”的对象动手设计之前我把需要互斥的资源列了一张清单这样后面写代码才不会漏资源谁在用冲突后果CPU执行权应用代码、BLE协议栈、Flash控制器CPU停顿导致连接事件处理延迟内部总线Flash控制器读改写、协议栈取指Flash忙时协议栈指令无法立即执行Flash控制器应用层擦写、协议栈NVDS写入两边同时写Flash导致状态机错乱中断响应窗口协议栈链路层中断、外设中断中断延迟导致定时器、UART等丢数据连接事件窗口链路层收发的BLE数据包错过连接事件导致主从不同步这张表里的每一项在具体设计时都可以对应到一个处理策略。比如“CPU执行权”可以通过延长连接参数来缓解“Flash控制器”可以通过统一操作入口来避免并发“连接事件窗口”可以通过设置Slave Latency让从机合法跳过连接事件。2.2 主流处理方案的横向对比我把网上能搜到的、以及自己试过的方案捋了一遍主要有这么几类方案实现难度对实时性的影响使用场景全局关中断保护Flash操作低极差BLE必断不推荐Flash操作前等待事件空闲中中等仍可能撞上连接事件简单参数存储调整连接参数 Slave Latency中较好给Flash留出窗口连接中擦写断开连接后再操作Flash低最安全但体验割裂大块数据写入/OTA状态机统一调度较高好配合其他方案生产级产品全局关中断这个方案我在早期验证时试过代码简单但结果很惨Flash擦写期间所有中断都被屏蔽BLE链路层中断也进不来10次里有8次直接掉线剩下2次是运气好擦写时间短恰好赶在连接事件间隙。这个方案只适合那些完全不需要维持BLE连接的场景。断开连接再擦写体验确实不好用户点保存之后蓝牙断开几秒钟很多客户接受不了。最均衡的做法是状态机统一调度同时把连接参数调整到位让Flash操作尽量落在BLE事件允许的空窗期里。这里要特别提醒一点网上有不少文章提到用RTOS信号量保护Flash操作这个思路在数据一致性上没错但解决不了CPU被Flash控制器阻塞的问题。信号量能保证两个任务不会同时操作Flash可它不能保证BLE协议栈在Flash擦写期间还活着。真实场景里Flash互斥的核心矛盾是CPU停顿而不是任务竞争。2.3 推荐架构统一Flash任务加空闲窗口执行我的最终方案选择了“统一Flash任务 空闲窗口执行”的组合。核心思路是所有应用层对Flash的请求都不直接执行而是打包成一个请求结构体放进一个pending队列主循环每轮先处理完BLE协议栈事件再检查是否有Flash请求需要执行同时连接参数通过L2CAP更新请求调整到适合Flash操作的窗口配置。这个架构的好处有三个。第一Flash操作的入口收敛在一个函数里后续加读保护、加校验、加断点续写都只改一处。第二主循环天然给了BLE事件更高的优先级每轮先处理协议栈再处理Flash可以降低HCI事件堆积的概率。第三配合Slave LatencyFlash擦写期间即使错过一个连接事件链路层也认为这是正常跳频不会触发超时。设计上Flash操作状态机只有三个状态IDLE、PENDING、BUSY。应用层提交请求时如果当前状态不是IDLE直接返回忙由上层决定重试还是丢弃。主循环扫描到PENDING状态立即将其切到BUSY并执行擦写流程。执行完成后回到IDLE通过回调通知上层这次操作的结果。3. 代码实操状态机设计与BLE握手协作3.1 定义Flash操作状态机下面这段代码是我在工程里实际使用的核心结构API名称按照BlueNRG SDK的命名习惯做了整理不同版本SDK可能略有差异但逻辑是通用的。typedef enum { FLASH_OP_IDLE 0, FLASH_OP_PENDING, FLASH_OP_BUSY } flash_op_state_t; typedef struct { uint32_t target_addr; /* 目标Flash地址 */ uint16_t length; /* 数据长度 */ uint8_t data[256]; /* 写入缓冲区 */ uint8_t operation; /* 0擦除 1写入 2擦写 */ void (*done_cb)(int); /* 完成回调 */ } flash_op_request_t; static flash_op_state_t s_flash_state; static flash_op_request_t s_flash_req;提交请求的函数长这样int flash_store_config(uint8_t *buf, uint16_t len, void (*cb)(int)) { if (s_flash_state ! FLASH_OP_IDLE) { return -1; /* 忙拒绝本次请求 */ } if (len sizeof(s_flash_req.data)) { return -2; /* 数据过长 */ } memcpy(s_flash_req.data, buf, len); s_flash_req.length len; s_flash_req.operation 2; /* 先擦后写 */ s_flash_req.target_addr CONFIG_FLASH_ADDR; s_flash_req.done_cb cb; s_flash_state FLASH_OP_PENDING; return 0; }这层封装的要求很简单任何模块想写Flash只能调用flash_store_config不允许直接操作Flash寄存器。刚开始同事还觉得这个中间层多余后来有一次发现有人私自调了Flash写函数导致协议栈NVDS区域被覆盖才意识到统一入口有多重要。3.2 在主循环中挂载Flash操作主循环的处理逻辑很关键。BlueNRG SDK的典型主循环是这样的while (1) { /* 处理BLE协议栈事件必须优先 */ ble_stack_process(); /* Flash任务只在事件空闲时执行 */ if (s_flash_state FLASH_OP_PENDING) { s_flash_state FLASH_OP_BUSY; int ret do_flash_operation(); s_flash_state FLASH_OP_IDLE; if (s_flash_req.done_cb) { s_flash_req.done_cb(ret); } } /* 其他应用任务 */ app_task_process(); /* 低功耗处理 */ low_power_enter_if_allowed(); }在IDE里别小看这个顺序把ble_stack_process放到Flash任务之前处理意味着每次进入Flash操作前协议栈至少处理完了一轮所有pending的HCI事件。如果BLE事件队列里有连接更新、GATT写请求这些都能在Flash操作开始前被消化掉能明显降低协议栈事件堆积导致的异常。还有一个细节BLE事件处理完之后如果设备空闲我习惯加一个小的延时或者切到低功耗模式让链路层把后续的LL层确认包发完再进Flash操作。这个“喘口气”的窗口实测能减少很多时序冲突。3.3 封装擦除、写入与校验do_flash_operation里我做了四件事备份旧数据、擦除目标页、写入新数据、回读校验。整个过程中不关闭全局中断只关闭对时间不敏感的无关中断同时用连接参数来保证BLE事件被合法跳过。static int do_flash_operation(void) { uint8_t backup[256]; /* 备份旧数据防止写入失败后设备变砖 */ memcpy(backup, (uint8_t *)s_flash_req.target_addr, s_flash_req.length); /* 关闭无关外设中断但绝不关BLE协议栈中断 */ disable_irq_not_for_ble(); /* 擦除页 */ flash_erase_page(s_flash_req.target_addr); /* 编程写入 */ flash_program(s_flash_req.target_addr, s_flash_req.data, s_flash_req.length); enable_irq_not_for_ble(); /* 回读校验 */ if (memcmp((uint8_t *)s_flash_req.target_addr, s_flash_req.data, s_flash_req.length) ! 0) { /* 校验失败尝试恢复备份 */ flash_erase_page(s_flash_req.target_addr); flash_program(s_flash_req.target_addr, backup, s_flash_req.length); return -3; } return 0; }备份这一步不是多余的。有一次我在产线上遇到Flash写入后校验不过就是因为操作过程中电源有毛刺写入的某个字节错了。没有备份恢复逻辑的板子就变成废板要拆机重烧有备份逻辑的板子重新上电后还能从备份里恢复至少不会变砖。3.4 用连接参数给Flash操作腾退时间窗既然Flash擦写会导致CPU停顿那最直接的办法是让链路层在这段时间内“原谅”从机。BLE协议栈里有现成的Slave Latency机制从机可以声明自己会跳过若干个连接事件主机在这期间不会认为设备失联。我实际用的参数组合是连接间隔75到100毫秒Slave Latency为2Supervision Timeout为4秒。这样算下来从机最多可以连续错过两三个连接事件而不触发超时Flash擦写的20到30毫秒窗口刚好能覆盖。连接参数更新请求放在GATT连接建立后的稳定期发送/* 连接建立且服务发现完成后请求更新连接参数 */ aci_l2cap_connection_parameter_update_request( conn_handle, 60, /* min interval, 60*1.25ms 75ms */ 80, /* max interval, 80*1.25ms 100ms */ 2, /* slave latency */ 400); /* supervision timeout, 400*10ms 4000ms */这里要注意连接参数更新请求不是发送了就一定生效。主机端如果不同意新参数比如手机蓝牙协议栈强制用自己偏好的连接间隔那从机只能被动接受。实测Android手机对这个请求的兼容性还行iOS端有时会忽略所以代码里要在连接事件回调中确认参数是否真的更新成功如果没成功就要考虑把Flash操作延后到无连接状态或者引导用户重新连接。4. 调试手段与常见问题排查实录4.1 用GPIO翻转定位Flash占用窗口排查这类问题时我建议先做一件事把Flash开始擦写和擦写完成这两个点映射到两个GPIO上用逻辑分析仪或者示波器抓下来同时把BLE的连接事件也通过另一个GPIO翻出来。这样做一次你就能直观地看到Flash操作是否正好压在连接事件的窗口上。具体操作很简单在do_flash_operation入口处拉高一个测试引脚出口处拉低在BLE协议栈的链路层事件回调里翻转另一个引脚。逻辑分析仪上如果看到两个信号频繁重叠就说明冲突是真实存在的后面调整连接参数也有依据可循。我之前遇到一个奇怪现象有时候Flash操作时间很短只有几百微秒但还是断连。后来用GPIO抓波形才发现真正出问题的是Flash写入前的地址解锁操作触碰了Flash控制器的锁定状态导致后续协议栈访问NVDS时被拒。这类问题光靠看日志发现不了必须靠波形时间关联。4.2 蓝牙抓包确认断连根因如果条件允许建议用蓝牙抓包工具抓一下空中的包。抓包能清清楚楚看到从机错过连接事件后主机重传了几次最后触发断连的具体原因。我遇到的情况是主机在连接事件N发送了一个LL_DATA_PDU从机没有回应事件N1重传依然没有回应事件N2重传从机终于回应了但主机已经累计重传超过阈值发起连接终止。抓包数据里会明确标记“Connection Supervision Timeout”或者“Failed to receive packet”。有了抓包结果再去和对应的时间戳比对就能确认是Flash操作导致还是射频问题导致。这个方法在OTA调试时尤为重要因为OTA过程Flash操作频繁断连根因可能是GATT Write Long Timeout也可能是链路层重传超时两种根因的解决方案完全不同。4.3 常见问题速查表把这段时间积累的问题整理成一个速查表方便后面遇到问题直接对照现象可能原因解决办法Flash写入后回读全是0xFF目标页没有先擦除写入前先执行页擦除擦写期间连接必断连接间隔太短Flash操作覆盖多个连接事件请求更新连接参数增大间隔和Slave Latency偶发HardFaultFlash操作非法地址或总线错误检查地址对齐、Flash大小边界、读保护配置协议栈HCI事件堆积主循环长时间阻塞未调用ble_stack_process把Flash操作拆到空闲窗口避免长阻塞配对信息丢失应用层擦写覆盖了协议栈NVDS区域明确Flash分区禁止越界写GATT写响应超时GATT事务处理期间从机错过连接事件确保Flash写操作不在GATT回调链路中同步执行OTA升级中途断连擦写时间过长导致链路过早超时OTA期间调整连接参数或采用双Bank方案写Flash后设备重启看门狗未及时喂狗Flash操作前后喂狗或将Flash任务拆分为多个短任务4.4 关中断保护Flash操作的惨痛教训这段经历写出来是希望后来的工程师不要重蹈覆辙。早期设计时我以为只要在Flash擦写期间把中断全关了就能保证Flash操作不被外部干扰于是写了类似下面的代码__disable_irq(); flash_erase_page(...); flash_program(...); __enable_irq();乍一看逻辑没问题实际上跑起来马上露馅。BLE链路层中断被屏蔽后链路层无法及时处理连接事件主机端发现从机连续失联直接断开连接。更严重的是如果关中断期间产生了UART接收中断数据缓冲区会溢出丢数据。这个方案测试了十几个小时最后确认根本不能用。后来我彻底想明白Flash擦写期间CPU本来就是停顿的中断能不能进来根本不重要重要的是链路层能不能在CPU恢复之后快速赶上协议栈的处理节奏。关中断只会让恢复后的积压中断更多反而增加系统恢复时间。正确的做法是让链路层提前知道接下来会有停顿通过Slave Latency机制合法跳过而不是暴力屏蔽所有中断。5. OTA与量产擦写场景的额外功课5.1 OTA升级时的Flash互斥策略参数存储场景下Flash操作通常只擦一页耗时几十毫秒调整连接参数就能解决。但OTA升级是另一个量级的问题固件镜像大擦写经常持续几百毫秒到几秒中间还要维持BLE连接传输数据这时候互斥策略要复杂得多。我的做法是分阶段处理。OTA下载阶段连接参数直接拉到最大值Slave Latency设到最大允许值尽量减少连接事件对Flash操作的影响。固件数据先暂存到外部Flash或者RAM缓冲区攒够一块再写入内部Flash。每写完一块立即让出CPU让BLE协议栈恢复通信同时发一个通知给手机端告诉它当前进度。如果产品支持双Bank升级时优先写Bank2写完再切换启动地址这样即使升级失败设备还能从Bank1正常启动。没有双Bank的话退而求其次至少要把关键引导区和用户配置区保护起来防止固件覆盖配置数据。5.2 量产阶段的Flash预烧录与防误擦批量生产时很多产品先在产线烧录固件再在整机上通过BLE配置设备参数。这个阶段最容易出现的问题是产线误操作在设备已经配对甚至正在OTA时触发Flash擦写。所以量产用的测试工装和App我会专门做一版“产线模式”。产线模式里设备上电后先停在广播状态不自动连接到手机也不接受任何GATT写请求只有收到产线特定的解锁命令后才开放Flash配置接口。这个设计看着多了一层流程但真的能防止产线员工手滑把正在无线升级的设备重启掉。Flash保护寄存器也要在量产初始化时设置好。把引导区和协议栈区设为只读应用层只能写用户的配置区。这样即使某台设备在产线上跑到了错误的测试项也不会把系统区擦掉返修成本能低很多。5.3 一些踩坑后的个人经验最后说几条个人体会都是踩过坑之后才知道的。第一不要在GATT回调函数里直接做Flash操作。GATT写请求到达回调时链路层正处于事务处理的关键路径上这时候做Flash操作几乎必然导致GATT响应超时。正确做法是把数据拷贝到RAM缓冲区置一个标志位回到主循环后再处理。第二Flash操作前后各喂一次看门狗。Flash擦写期间的CPU停顿非常容易触发看门狗超时尤其当Flash操作被连接事件推迟时喂狗间隔会变得不规律。我的习惯是在do_flash_operation函数入口和出口各调用一次看门狗刷新并且把整段操作耗时估算进看门狗超时余量里。第三Flash写操作尽量合并成一次连续写入不要一个字节一个字节地磨。BlueNRG的Flash编程操作次数有限频繁的短写入会加快Flash磨损同时每次编程都伴随CPU停顿对BLE事件也是一种干扰。实际产品里用户参数一般就几十字节攒够一个缓冲区再整块写比每次改一个参数就写一次要可靠得多。第四所有Flash操作都要有失败恢复路径。哪怕你觉得某次写入不可能失败也要把旧数据备份、校验失败重试这种逻辑加上。产品上线之后你没法预料用户会在什么场合拔电、掉线、供电抖动没有恢复路径的设备一旦写入中途出问题就只能在现场干瞪眼。BlueNRG系列芯片本身的Flash控制器并不复杂难的是让它在BLE这种强实时性的通信场景下乖巧工作。整个问题梳理下来核心还是那句话不要让Flash操作的CPU停顿时间撞上BLE链路层的生命线。把连接参数、状态机、事件处理顺序这三件事做好Flash和BLE其实能相处得很融洽。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进