首页 / 资讯中心 / 文章详情

蓝牙OTA升级实战:BLE设备双Bank方案与Bootloader设计

蓝牙OTA升级实战:BLE设备双Bank方案与Bootloader设计 ★ FEATURED ARTICLE
蓝牙OTA升级这活说白了就是给已经出货的BLE设备“远程改代码”。设备壳子封好了、电池焊死了、现场不方便拆机这时候固件想升级只能走无线。我前阵子在某国产低功耗蓝牙SoC平台下文按项目代号叫SYDTEK平台上做了一整套OTA蓝牙升级功能顺带整理了一个可以直接跑的Demo工程。这篇把整体方案选型、Flash分区设计、GATT协议制定、工程实操和踩坑记录一次性讲透给后面做同类功能的开发者少绕几圈。1. OTA升级方案设计与选型思路1.1 产品都封好壳了还怎么改程序吐槽归吐槽项目需求并不会因为设备难拆就消失。常见场景有这么几类消费类小家电/智能硬件已经量产发货发现低功耗模式下偶尔睡死或者上报数据格式要改只能OTA补丁修复。采集器/传感器节点装在管道井、农田、屋顶这类环境人过去一趟成本很高远程升级几乎成了刚需。协议或服务端字段调整蓝牙从设备对外广播格式、服务器UUID变更不改固件就要返厂。大家用的手段其实都一样在芯片里保留一小块常驻的Bootloader代码App运行期间通过蓝牙把新固件取回来存到备用Flash区复个位Bootloader负责把新固件搬到正式运行区。本质上就是个“引导程序双区搬运”问题。1.2 一条OTA链路中的三个角色拆开看一次成功的OTA升级至少涉及三个角色手机或PC端工具负责读固件文件、发起升级命令、按帧推送数据、解析回包。设备端App固件当前正在跑的应用程序承担接收数据、写入备用Flash、置升级标志、触发热复位。Bootloader上电后先跑判断需不需要搬运固件需要就执行Flash拷贝最后跳转启动。有人会问为什么不让Bootloader直接跑蓝牙协议栈来收数据理论上可行但Boot区域一般只有几KB到十几KB塞一个完整的BLE协议栈非常吃力而且把大量Flash读写逻辑放进Boot区域一旦写坏了连恢复入口都没了。所以我选的方案是App负责收数据和写暂存区Boot只负责搬运和跳转。职责分离风险最小。1.3 放弃原地覆盖采用双Bank缓冲最开始我也考虑过最简单的方式新固件来了直接覆盖App区边收边写。这个方案看起来省空间但有个致命问题——万一写到一半断电或者传输过程中校验失败设备就真的变成一块“砖”只能拆壳烧录。所以我坚持用双Bank。也就是在Flash里划分出两个等大小的应用区域App A区放当前正式固件App B区作为OTA暂存区。新固件先完整写到App B区写完后校验CRC再让Bootloader把App B区拷贝到App A区并启动。这样任何时刻App A区里总有一个“至少能跑”的旧固件最坏情况也只是升级失败旧固件还能继续工作。对于Flash容量比较紧张的芯片双Bank会多占一部分空间但可靠性提升的收益远大于那几十KB的成本。这个取舍我建议千万别犹豫直接双Bank。2. SYDTEK平台的Flash分区与启动跳转实现2.1 512KB Flash到底怎么切SYDTEK平台内部Flash总容量是512KBRAM有64KBBLE协议栈由SDK提供。我划分的分区表如下区域起始地址大小用途Boot区0x000008KBBootloader、启动跳转代码App A区0x02000240KB正式运行的应用固件App B区0x3C000240KBOTA暂存区存放新固件系统参数区0x7E0008KB升级标志、版本信息、设备参数顺便说一句中断向量表默认都在Flash起始的0x00000位置也就是Boot区开头。App编译完后链接脚本会把向量表单独放到App区的起始地址0x02000处这样才能保证Bootloader跳转到App区后芯片能从App区的向量表重新接管中断处理。App A区给240KB看起来有点奢侈实际编译出的固件只有110KB左右留这么大空间是给后续版本功能增长留余地。App B区保持和App A区同样大小方便做整块搬运。2.2 升级标志位与Bootloader启动流程系统参数区里我放了一个4字节的升级标志字复位后不丢失。取值定义如下0xFFFFFFFF无升级请求正常运行模式0xA5A50001App B区新固件已就绪等待搬运0xA5A50002上次搬运异常需要做完整性检查Bootloader上电后的逻辑很直接读系统参数区的升级标志。如果不是正常值先校验App B区的前16字节和整包CRC32。CRC通过就把App B区整块拷贝到App A区拷贝完成后写回正常标志跳转App A区。CRC不通过清除升级标志直接跳转旧App A区保底。没有升级标志直接跳转App A区。考虑到Flash拷贝过程中依然可能掉电我在拷贝前会把标志改成0xA5A50002表示“搬运中”。下次上电发现这个值就知道上次搬运没完成于是先校验再决定重搬还是放弃。这个二次确认机制让设备在极端情况下不至于完全变砖。2.3 Bootloader代码要克制别什么都塞进去Bootloader区我一开始写得很激进加了串口日志、按键检测、甚至想加GATT服务结果编译出来9KB多把8KB的Boot区挤爆了。后来痛定思痛Boot里只留三件事读标志、清标志。Flash整块搬运函数。跳转App区入口。连日志输出都只在确认跳转失败时才打一条。串口调试不该放Boot里App启动之后的日志足够定位绝大多数问题。Boot区代码越少出问题的面就越小这个原则适用于所有OTA方案。3. OTA协议与GATT服务定义3.1 自定义服务和两条特征BLE侧的GATT服务我按最通用的做法定义了一个自定义OTA服务和一个特征组。UUID结构采用标准128位自定义UUID基础部分统一方便Android和iOS端扫描解析。项目UUID属性说明OTA Service0xFFF0-自定义升级服务OTA_Control特征0xFFF1Write / Notify下发命令、接收状态回包OTA_Data特征0xFFF2WriteWithoutResponse传输固件二进制数据为什么控制通道要带Notify因为设备端在接收完一个数据块、写Flash完成之后必须主动告诉手机“这块数据妥了”。没有Notify回包手机端只能干等超时效率极低。实际项目里还可以在特征属性里加Indicate但对OTA这种有序数据流来说Notify够用还省一次应用层确认的复杂度。3.2 命令集和状态帧格式控制特征上传输的是二进制命令帧一字节命令字加若干参数接收方回同样结构的确认帧。命令定义如下命令值名称参数说明0x01GET_VERSION无查询当前固件版本号0x02START_OTA版本号、固件长度、CRC32请求开始升级App准备Flash0x03DATA_ACK块序号、状态App端回报数据块接收情况0x04END_OTA无全部数据发送完毕触发校验0x05REBOOT无让设备复位的兜底命令回包格式统一为命令字状态字节状态0表示成功非0表示对应错误码。比如START_OTA之后设备端返回0x02 0x00就表示允许升级返回0x02 0x01表示CRC参数异常0x02 0x02表示App B区擦写失败。这样一个简单的协议栈总共不超过两百行代码但足够支撑稳定升级。别看不起这五个命令后续要加断点续传时只需要在START_OTA参数里多带一个“断点偏移”字段就行不会伤筋动骨。3.3 数据帧怎么做分帧、确认和重传OTA_Data特征通道传的是裸固件数据但裸数据也得有帧边界不然手机发完了设备根本不知道一包从哪开始、到哪结束。我定的数据帧格式第一字节是序号序号seq从0递增到255后回绕后面全部是固件有效数据。注意这里不是TCP那种严格按序重组而是利用序号做“丢包粗检”设备端如果发现某个块的seq和预期对不上就通过状态回包让手机重发整个块。真正的流量控制靠“块确认”机制。我把固件数据按4KB划成一个块跟Flash页大小对应手机端每发出4KB数据后必须停下来等待设备端返回DATA_ACK。设备端收到4KB数据后先擦除对应Flash页、写入数据然后回一个DATA_ACK状态包。手机收到ACK后才发下一个4KB块。如果超过2秒没收到ACK手机端就把整个4KB块重发一遍。这个机制比逐包确认吞吐量高得多也比无脑全速发送稳得多。实测下来块内单次写入手机端可以做连续发送中间不用停而块与块之间天然有节流点设备端不会因为接收缓冲溢出而丢数据。3.4 传输通道为什么用无响应写既然有2秒超时重发机制理论上用WriteWithResponse一包一确认更稳。但问题是响应包会带来大量空中的往返时延。BLE在2.4GHz物理层上经过GATT层的WriteWithResponse有效吞吐可能只有2-3KB/s传一个120KB的固件要将近一分钟。换成WriteWithoutResponse之后手机可以把连续数据包一帧接一帧地往链路层塞吞吐提升非常明显。代价就是没有逐包ACK链路层偶尔丢一两包接收端发现seq断层会在这个4KB块结束时要求重发损失可控。安卓端在实现时要注意一个细节虽然WriteWithoutResponse不要求对方回包但依然要等onCharacteristicWrite回调后才会往底层塞下一帧。如果你一次循环里连写几千包底层队列会溢出写操作直接被安卓系统丢弃。正确做法是在回调里逐帧推进或者维护一个十几帧的滑动窗口。4. Demo工程全流程实操4.1 Demo组成设备端工程与手机端工具这次整理的Demo包含两个部分设备端SDK工程我在SDK自带的标准外设例程基础上添加了OTA服务源码和Flash读写逻辑。编译生成两个固件一个是Bootloader固件一个是带OTA能力的App固件。把Bootloader先烧进去之后所有升级都走空中。手机端工具一个简单的安卓调试APK支持扫描、连接、读取设备版本号、选择OTA固件文件、显示升级进度。工具源码也给了逻辑不算复杂可以当模板改。iOS端因为手头没有开发者证书用了通用的LightBlue之类的工具做验证流程一致只是需要手动操作。工程本身是独立可跑的一套东西不依赖额外服务端适合直接落地。4.2 固件打包给编译产物加OTA头SDK编译完成后生成的是裸bin文件不能直接推给设备。我写了一个小脚本在bin前面加16字节的头结构如下字段长度说明Magic4字节SYDT识别是否是OTA固件主版本号1字节固件主版本次版本号1字节固件次版本固件长度4字节原始bin的长度CRC324字节原始bin的CRC32校验值保留2字节填0脚本核心逻辑不长直接贴一下import struct import zlib import sys fw_in sys.argv[1] fw_out sys.argv[2] major int(sys.argv[3]) minor int(sys.argv[4]) bin_data open(fw_in, rb).read() crc32 zlib.crc32(bin_data) 0xFFFFFFFF header struct.pack(4sBBIIHH, bSYDT, major, minor, len(bin_data), crc32, 0, 0) open(fw_out, wb).write(header bin_data) print(OTA pack done, size%d, crc0x%08X % (len(bin_data), crc32))打包时主次版本号要跟App代码里定义的宏一致否则升级完设备版本号看起来还是旧的容易误判升级失效。4.3 手机端十步升级流程把升级逻辑排成十个步骤每一步都不会空转扫描并连接目标设备。连接成功后调用requestMtu请求MTU到247Android端返回协商后的MTU值iOS端一般会自动协商。订阅OTA_Control特征的Notify通知。发送GET_VERSION命令确认设备当前固件版本。点击“选择固件”加载带SYDT头的升级文件。解析文件头校验Magic和CRC字段是否合法。发送START_OTA命令携带版本号、固件长度、CRC32。收到允许升级的回包后按帧发送OTA_Data数据。每发满16帧暂停等设备端返回的DATA_ACK。全部数据发完发送END_OTA命令。设备端收到后做全量CRC校验写入升级标志自动复位。设备重启后重新广播手机重连并发送GET_VERSION新版本号正确即升级完成。这十步里最容易出问题的是第8步的发送节奏。Demo里我用了带滑动窗口的发送队列队列窗口默认16帧窗口满了就停收到ACK才继续推进。别小看这个细节我第一次写裸循环连发一包都没收设备端直接丢包丢到面目全非。4.4 实测两百KB固件耗时、速率与表现我把一个约110KB的App固件打包后进行实测MTU协商到247数据帧有效载荷每帧192字节4KB一个确认块。升级全程约12秒出头平均吞吐在8.5KB/s左右。如果改用WriteWithResponse逐包确认同样固件要40多秒体验感差距非常明显。测试中特意模拟了三种异常情况传输到一半拔掉手机蓝牙、传输中途手动关设备电源、传输完成后立刻断电。结果如下异常场景设备状态恢复行为传一半断开App旧固件继续跑重新升级即可App B区写入中掉电上电后Boot发现标志异常校验失败跳旧App可重试搬运阶段掉电标志为搬运中Boot重新执行校验和搬运当然如果是Flash物理损坏这种极端情况任何OTA方案都救不回来。但常规的断电、断链、干扰这套设计都能兜住。5. 常见问题与排查实录5.1 升级到一半卡死、掉线这个现象遇到最多。最初我看到手机端进度条卡在某个百分比然后设备连接断掉第一反应以为是RF干扰。后来加了日志才发现大部分是这两类原因一是Flash擦除时间太长导致GATT事件来不及处理蓝牙协议栈直接挂了。Flash页擦除一次大概20到30ms如果在BLE事件处理过程中长时间阻塞协议栈收包会超时。解决方式是把Flash擦写放到低优先级任务并用尽量少的连续擦除操作。4KB块确认机制其实也正好给了擦除窗口数据块4KB正好一页收完一页擦写一页不会连续擦几十页。二是Android端发送过快。只要不等onCharacteristicWrite回调就连续写几十帧底层队列必然堵死。解决方式就是前面说的滑动窗口。5.2 升级完成后不跳转或启动后不开机升级完成后设备不启动大概率是搬运好的App区头几个字节就不对。ARM Cortex-M架构启动时Flash起始处的4字节是初始栈指针接下来4字节是复位向量。如果App区起始地址没放这两个值跳转就是空跳。排查时我在Bootloader里加了启动前检查读App A区起始4字节判断栈指针是否落在RAM地址范围内读复位向量是否落在App区内。如果检查不过直接放弃搬运并跳旧App A区同时把错误码写进系统参数区。这样即使搬运出错也不会“真砖”。5.3 MTU协商与iOS/Android兼容iOS日常用LightBlue测试时发现它默认MTU只有23字节一帧只能塞20字节有效载荷传110KB固件会又慢又容易出错。后来在设备端广播参数里加了一个“Preferred MTU”字段iOS连接后会自动协商到185或者247速度立刻起来了。Android端更简单连接后主动调requestMtu(247)。但要注意requestMtu是异步回调必须等到onMtuChanged之后才能开始OTA否则发送长度超MTU写操作直接失败。5.4 断点续传与异常恢复的取舍Demo最终版没有做真正的断点续传原因是成本收益不划算。原因很简单升级一个固件只要12秒用户重试一次也就是多等12秒而断点续传需要记录已写入的块号、Flash剩余空间、手机端进度上下文代码复杂度会翻倍。但如果是几百KB的大型固件或者升级场景经常要断断续续那建议还是做一下。实现思路是设备端把“已确认块号”存进系统参数区手机重新连接后通过START_OTA参数里的断点字段告诉设备从哪一块继续。设备端跳过已写入的块从断点开始收。断点续传本质上就是个辅助优化不影响核心升级流程。5.5 问题速查表现象优先排查点处理建议手机连不上设备广播间隔/扫描过滤检查App是否正常启动广播一写数据就断开MTU协商未完成请求MTU后再发数据进度卡在100%不重启END_OTA命令未收到检查设备端命令解析返回值升级后版本没变打包脚本版本号不对核对main函数里的版本宏偶发数据错误Flash擦写时序加长块超时检查Flash驱动接口升级后反复重启复位向量非法检查App链接脚本起始地址最后再说几句实在话OTA功能看着简单难的是把各种断电、断链、Flash擦写异常都想清楚。设备端最重要的是“保底”不管怎么折腾至少旧固件能跑手机端最重要的是“节流”别一口气把数据全倒出去。按这套思路去改哪怕芯片平台不是SYDTEK整个架构也能直接平移。如果手头SDK里已经带了现成的OTA库也建议对照着看看它的分区是怎么切的、发送节奏是怎么控制的踩过一遍坑之后你会更明白这套设计的价值在哪。
阅读完成 · 觉得有帮助?
咨询建站