蓝牙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总容量是512KB,RAM有64KB,BLE协议栈由SDK提供。我划分的分区表如下:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Boot区 | 0x00000 | 8KB | Bootloader、启动跳转代码 |
| App A区 | 0x02000 | 240KB | 正式运行的应用固件 |
| App B区 | 0x3C000 | 240KB | OTA暂存区,存放新固件 |
| 系统参数区 | 0x7E000 | 8KB | 升级标志、版本信息、设备参数 |
顺便说一句,中断向量表默认都在Flash起始的0x00000位置,也就是Boot区开头。App编译完后,链接脚本会把向量表单独放到App区的起始地址0x02000处,这样才能保证Bootloader跳转到App区后,芯片能从App区的向量表重新接管中断处理。
App A区给240KB看起来有点奢侈,实际编译出的固件只有110KB左右,留这么大空间是给后续版本功能增长留余地。App B区保持和App A区同样大小,方便做整块搬运。
2.2 升级标志位与Bootloader启动流程
系统参数区里我放了一个4字节的升级标志字,复位后不丢失。取值定义如下:
- 0xFFFFFFFF:无升级请求,正常运行模式
- 0xA5A50001:App 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 Service | 0xFFF0 | - | 自定义升级服务 |
| OTA_Control特征 | 0xFFF1 | Write / Notify | 下发命令、接收状态回包 |
| OTA_Data特征 | 0xFFF2 | WriteWithoutResponse | 传输固件二进制数据 |
为什么控制通道要带Notify?因为设备端在接收完一个数据块、写Flash完成之后,必须主动告诉手机“这块数据妥了”。没有Notify回包,手机端只能干等超时,效率极低。
实际项目里还可以在特征属性里加Indicate,但对OTA这种有序数据流来说Notify够用,还省一次应用层确认的复杂度。
3.2 命令集和状态帧格式
控制特征上传输的是二进制命令帧,一字节命令字加若干参数,接收方回同样结构的确认帧。命令定义如下:
| 命令值 | 名称 | 参数 | 说明 |
|---|---|---|---|
| 0x01 | GET_VERSION | 无 | 查询当前固件版本号 |
| 0x02 | START_OTA | 版本号、固件长度、CRC32 | 请求开始升级,App准备Flash |
| 0x03 | DATA_ACK | 块序号、状态 | App端回报数据块接收情况 |
| 0x04 | END_OTA | 无 | 全部数据发送完毕,触发校验 |
| 0x05 | REBOOT | 无 | 让设备复位的兜底命令 |
回包格式统一为:命令字+状态字节,状态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字节的头,结构如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| Magic | 4字节 | "SYDT",识别是否是OTA固件 |
| 主版本号 | 1字节 | 固件主版本 |
| 次版本号 | 1字节 | 固件次版本 |
| 固件长度 | 4字节 | 原始bin的长度 |
| CRC32 | 4字节 | 原始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', b'SYDT', major, minor, len(bin_data), crc32, 0, 0) open(fw_out, 'wb').write(header + bin_data) print("OTA pack done, size=%d, crc=0x%08X" % (len(bin_data), crc32))打包时主次版本号要跟App代码里定义的宏一致,否则升级完设备版本号看起来还是旧的,容易误判升级失效。
4.3 手机端十步升级流程
把升级逻辑排成十个步骤,每一步都不会空转:
- 扫描并连接目标设备。
- 连接成功后调用requestMtu请求MTU到247,Android端返回协商后的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库,也建议对照着看看它的分区是怎么切的、发送节奏是怎么控制的,踩过一遍坑之后,你会更明白这套设计的价值在哪。