做量产智能硬件这几年,被现场揪回来改程序这种破事,我经历过不止一次。设备已经铺出去了,客户那边试用发现一个小逻辑问题,按理说改一行代码就行,但人过不去,程序就过不去。后来被逼着把AB双分区OTA这套东西彻底啃了一遍,从Bootloader到上位机再到升级策略全部自己写,算是把这个坑填平了。这篇东西就是我当时从零复现整个流程的完整记录,方案全部基于STM32F103标准库(Std V3.5),没有用RTOS,没有用复杂中间件,就是裸机加串口,适合那些想把OTA原理吃透、不想上来就套现成方案的兄弟。
先说清楚这套东西解决了什么问题。在传统开发模式里,固件烧录要么靠JTAG/SWD仿真器,要么靠ISP串口下载,都需要人跑到设备跟前操作。AB OTA要做的事情,就是让设备通过自身通信接口(我这里用RS232串口)接收新的固件包,写入备用分区,校验通过后切换启动标志,下次复位就从新分区启动。整个过程不用开壳,不用仿真器,甚至可以在设备正常工作的时候把新固件先存好,等重启时再切换。对于已经铺到现场的设备来说,这功能等于救命稻草。
这套方案我最终选型是:STM32F103ZET6 + 外部W25Q64 SPI Flash + RS232串口 + 标准库V3.5 + FreeModbus V1.6移植做Modbus RTU通信,外加一个自定义的AB分区管理机制。启动流程、分区管理、固件传输、校验切换、回滚策略,全在裸机环境下搞定。
1. 整体设计思路:为什么是AB双分区而不是其他方案
1.1 先搞明白AB分区到底在干什么
AB分区的核心思路其实简单得有点惊人:Flash里放两份应用程序固件,一份是当前运行的A分区,一份是待升级/备份的B分区,Bootloader每次启动时根据标志位选择从哪个分区加载。升级的时候,系统运行在A分区,新固件写入B分区,写完校验通过就把标志位翻转到B,重启后Bootloader从B启动。如果B分区启动失败或者校验失败,还能自动回滚到A。
这个方案在Android系统里叫A/B无缝升级,在车机里叫双分区备份,在工业设备里叫双镜像设计。别看名字花哨,本质就是在“运行中更新”和“坏了一刀切”之间找到一个平衡点:你在跑的程序不会被正在擦写的Flash打断,因为擦写的是另一块完全不相关的地方。
对比一下其他OTA方案就清楚了:
- 单分区原地升级:Bootloader直接擦掉原APP区,写入新APP,中间如果断电或者数据错一个字节,设备直接变砖,只能派人带着仿真器去现场救。
- 单分区加备份区(非AB):APP区破坏了可以从备份区恢复,但恢复过程需要停机,而且备份区如果也没更新成功,等于白搭。
- AB双分区:运行区和写入区物理隔离,升级失败时原系统还完好,回滚就是改一个标志位的事。
我选AB方案的原因非常简单:嵌入式设备OTA最大的痛点不是传输效率,而是可靠性。Modbus RTU在115200波特率下传输一个64KB的固件要不了多长时间,真正怕的是升级中途停电、串口干扰导致数据错位、擦写Flash时坏块把整包数据搞废。AB分区把“试错”这个成本降到了最低——试错了,你还能退回去。
1.2 为什么不选现成方案,非要自己从零搞
有的朋友可能会问:STM32官方有IAP例程,网上也有不少开源的Bootloader,直接拿来改不就行了?我当时也这么想过,但实际看了一圈发现几个问题:
第一,官方IAP例程,比如AN2557或者基于Ymodem的例程,走的是“单分区覆盖升级”的老路,压根没有双备份概念。网上很多教程把IAP加APP叫OTA,那只解决了“怎么下载”的问题,没解决“下载坏了怎么办”的问题。对于已经量产的产品,这个问题比下载问题严重得多。
第二,网上的AB方案大多基于CubeMX加HAL库,我有几个老项目还停留在标准库时代,切换HAL库相当于整个BSP重写,成本太大。而且我这颗料是F103,主频只有72MHz,Flash也才512KB,HAL库那一层抽象在OTA这种对时序要求敏感的场景里,反而增加调试难度。标准库直接操作寄存器,哪里擦写了几页、中断优先级怎么配、Flag怎么读,全是透明的,出问题好排查。
第三,传输协议我要和现有Modbus RTU共用。现场设备已经有一路RS232走Modbus RTU和上位机通信,如果OTA单独开一套协议、单独走一条链路,线缆和接口要全部重排,不现实。最后我决定在FreeModbus V1.6协议栈基础上扩展功能码,把OTA数据和指令封装成Modbus RTU的报文,复用了整套物理层和链路层。这个方案在当时的咨询量里也算比较有代表性的需求——大量老设备升级改造时都要走这个路子。
所以做出来的东西是个什么结构:
Bootloader(64KB): - 硬件初始化 - AB分区有效性检查 - 升级标志位判断 - 分区加载与跳转 - Modbus RTU从站(OTA模式专用) APP_A(224KB): - 业务逻辑 + Modbus RTU从站(正常运行) - 接收升级包并写入B分区 - 校验完成后篡改标志位 APP_B(224KB): - 与APP_A逻辑结构一样,仅分区偏移不同后面所有内容,都是围绕这个分区表来展开的。
2. 分区规划与内存布局细节
2.1 C8T6还是ZET6?Flash大小先算明白
STM32F103系列型号非常多,最常见的是C8T6(64KB Flash)和ZET6(512KB Flash)。如果项目已经选型了C8T6,那AB双分区基本没法做——64KB里面Bootloader至少要占16~32KB,剩下给两个APP分的空间连一个完整的标准库工程都装不下。
我自己用的是ZET6,512KB Flash,片内RAM 64KB,外加一片8MB的W25Q64外部Flash。这里要强调一个关键决策:固件分区放在片内Flash,而固件包先暂存到外部Flash。为什么这样做?两个原因:
- 片内Flash写入要擦除,擦除粒度是页(1KB)或扇区,擦写寿命标称1万次,频繁读写不划算。
- OTA升级包的暂存、备份、断点续传这些操作,用外部SPI Flash更灵活,W25Q64有8MB空间,存一个224KB的固件包绰绰有余,写坏了也不心疼。
内部Flash的分区表我设计如下:
| 分区 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 128KB | 启动管理 + OTA恢复 |
| APP_A | 0x08020000 | 192KB | 正式运行区A |
| APP_B | 0x08050000 | 192KB | 备用升级区B |
| 标志位区 | 0x08080000 | 8KB | 启动标志、版本号、校验信息 |
| 保留 | 0x08082000 | 剩余 | 预留参数存储 |
这里我把Bootloader给了128KB而不是最省的32KB,原因后面讲代码时你就会发现——Bootloader里不仅要跑Modbus协议栈,还要处理Flash擦写、分区表读取、加签验签等一堆逻辑,空间给充裕点,后面加功能才不憋屈。
192KB的APP空间,对于一个标准库工程来说完全够用,编译出的Hex或者Bin一般也就30~60KB,留足够余量给日志和调试信息。如果固件超过192KB怎么办?那就得重新考虑分区策略,比如三分区或者动态分区,但那样复杂度和风险同时上来了,能用AB解决的场景就别上ABC。
2.2 链接脚本与编译配置:修改LD文件
标准库工程用Keil MDK开发时,默认的烧录地址是0x08000000,现在AB方案下,Bootloader占0x08000000,APP_A占0x08020000,APP_B占0x08050000。每个工程都必须修改分散加载文件(.sct)里的加载地址。
Bootloader工程的.sct文件不用改,默认就行。APP_A工程的.sct要改成:
LR_IROM1 0x08020000 0x00030000 { ER_IROM1 0x08020000 0x00030000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00010000 { .ANY (+RW +ZI) } }APP_B工程的.sct类似,把0x08020000整体替换成0x08050000。
这里有个容易被坑的细节:STM32中断向量表默认放在Flash起始地址0x08000000,APP跑到0x08020000之后,向量表如果不重映射,任何中断来了一律跳回到0x08000000去找向量,等于所有中断全部进Bootloader,应用直接崩溃。所以APP工程启动代码的开头,必须在main函数最前面(甚至要在SystemInit之前)设置中断向量表偏移。
标准库写法是这样:
// 在main函数最开始执行 void SystemInit(void) { // ... 原来的时钟配置 SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; }VECT_TAB_OFFSET在APP_A工程里设成0x20000,APP_B工程里设成0x50000。具体数值和分区表对齐。
2.3 标志位区域的设计与防掉电写坏
标志位区我放在0x08080000,共8KB。为什么单独占一个扇区?因为STM32F103的Flash扇区比较大(ZET6最后几个扇区每个16KB),如果标志位和固件挤在同一个扇区,每次改标志位都得先把整个扇区备份再擦除再写入,一来慢二来风险大。单独一个扇区专门用来存这些“小变量”,随便擦写。
标志位的结构体我设计了这样:
typedef struct { uint32_t magic; // 0xA5A5A5A5,表示结构体有效 uint32_t boot_flag; // 0xAA55 -> 启动A区, 0x55AA -> 启动B区, 其他无效 uint32_t app_version; // 当前应该启动的版本号 uint32_t update_status; // 0: 无升级任务, 1: 固件已接收完成待校验, 2: 校验通过待切换 uint32_t crc32; // 结构体自身CRC校验 } boot_flag_t;为什么加CRC32?因为写Flash过程中如果突然断电,标志位可能写了一半,Magic和BootFlag都可能处于张冠李戴的中间态。Bootloader读标志位时先算CRC,校验不过就直接进入“错误恢复”流程——这是后面回滚机制里最关键的一环。
写标志位我有一个小经验:不要直接擦写同一个地址然后写新的数据,而是采用“双槽交替写入”的方式。具体来说,0x08080000和0x08081000各放一份同样的数据,写的时候先写槽2,再写槽1,读的时候校验哪个有效用哪个。这样即使写槽2时断电,槽1还是旧的有效数据,不会两个同时坏。代价是多花4KB空间,换来掉电安全性指数级上升。这个做法在车辆ECU的Bootloader里属于标配,工业设备强烈建议照抄。
3. Bootloader核心逻辑:从启动到跳转的完整实现
3.1 Bootloader启动流程全解
Bootloader的main函数逻辑不复杂,但每一步都必须严谨。我按照自顶向下的方式理一下:
1. 初始化时钟、串口、SPI Flash、Modbus 2. 读取标志位区 3. 检查标志位有效性 4. 检查是否需要进入OTA升级模式(串口命令触发 / 标志位触发) 5. 若需要OTA -> 进入Modbus接收循环 6. 若正常启动 -> 根据boot_flag选择APP_A或APP_B 7. 校验目标分区的有效性(向量表初始值、CRC校验) 8. 如果分区有效,跳转;如果无效,尝试另一分区;再不行,进入OTA模式等救第7步的分区有效性检查是关键。我通常做两层校验:硬件层面检查首两个字:第一个字必须是合法的栈顶指针(在RAM范围内),第二个字必须是合法的复位向量(在Flash范围内,也就是0x08020000~0x0807FFFF之间的偶数地址)。然后软件层面做全固件CRC校验。CRC校验我使用的是软件计算CRC32,虽然F103没有硬件CRC外设,但用查表法跑起来也很快,一个192KB固件大概几十毫秒就能算完。
3.2 跳转函数与栈指针处理
从Bootloader跳转到APP,核心代码就这一段略加修改:
#define APP_A_BASE 0x08020000 #define APP_B_BASE 0x08050000 typedef void (*pFunction)(void); void jump_to_app(uint32_t app_base) { uint32_t stack_addr = *(volatile uint32_t *)app_base; uint32_t reset_addr = *(volatile uint32_t *)(app_base + 4); pFunction jump_func; // 检查栈指针合法性 if ((stack_addr & 0xFFF00000) != 0x20000000) { return; // 栈顶不在RAM范围内,非法 } // 检查复位向量合法性 if ((reset_addr & 0xFFF00000) != 0x08000000) { return; // 复位向量不在Flash范围内,非法 } // 关闭全局中断,还原默认中断处理 __disable_irq(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 设置主栈指针 __set_MSP(stack_addr); // 跳转 jump_func = (pFunction)reset_addr; jump_func(); // 正常情况下不会执行到这里 while(1); }有几个细节需要重点提醒:
- 跳转之前务必把外设恢复到复位状态。串口、SPI、定时器这些外设如果不关,APP启动初始化时可能检测到异常状态,或者中断请求在跳转瞬间被触发,直接跑飞。我这里的做法是在Bootloader里把用到的外设全部DeInit一遍。
- 中断向量表偏移的问题前面已经说了。如果APP没有正确设置VTOR,跳转后第一次进中断就会跑飞。实测中最典型的现象就是:程序从Bootloader跳过去能跑主循环,一按键或者一串口收数据就HardFault,十有八九是VECT_TAB_OFFSET没设对。
- 跳转前把所有全局变量清掉?不用刻意做。因为跳转没有经过C运行环境重新初始化,APP的RW段和ZI段是APP启动代码自己初始化的,和Bootloader的变量没有关系。唯一要注意的是,如果你用了“不初始化”的变量属性,在Bootloader和APP之间存在共享RAM通信的场景,那这块区域要单独规划,不能和C库初始化区域重叠。
3.3 Bootloader陷入死循环时的应急恢复
如果两个分区都校验失败,Bootloader会进入一种“砖头状态”。但人不在现场,怎么救?我的做法是:Bootloader在所有引导流程失败后不回落到普通循环,而是自动进入OTA接收模式,并且周期性通过Modbus广播“需要升级”的状态。上位机一旦收到这个状态,就知道这台设备已经处于被动等待状态,直接下发新固件就能救活。
这个机制等于把“非AB方案的变砖风险”也兜底处理了:只要Bootloader本身没坏,任何情况下设备都还能通过串口重新刷固件。当然,如果Bootloader本身刷坏了,那就只能开壳上仿真器了——这也是为什么我一直强调Bootloader代码要写得保守再保守,改一次测十次,因为Bootloader没有“自愈”能力。
4. 基于FreeModbus V1.6的OTA传输实现
4.1 为什么选择Modbus RTU作为传输通道
热词里有一组相关搜索是“stm32f103标准库std v3.5通过rs232串口基于freemodbus v1.6移植,实现modbus rtu”,这基本就是我项目的原型。FreeModbus V1.6是一个开源的Modbus协议栈,支持RTU和ASCII模式,占用的资源极小,非常适合裸机环境。
Modbus RTU本身是个半双工主从协议,上位机为主站,设备为从站。我们的设备作为一个从站,在正常业务模式下响应03(读保持寄存器)、04(读输入寄存器)、06(写单个寄存器)、16(写多个寄存器)等功能码。OTA模式下新增几个自定义功能码。
为什么选Modbus而不是直接裸串口传Bin文件?三个原因:
- 链路复用:已有的RS232物理链路和上位机通信协议框架全部保留,不需要额外接线。
- 数据可靠性:Modbus RTU自带CRC16校验,每个帧都验证,误码率极低。在OTA里我再叠加一层SHA/CRC32验证,等于双保险。
- 分帧机制现成:Modbus的帧格式天然把数据分成一条一条的报文,每条报文最多256字节(含地址、功能码、数据、CRC),拿来当传输层正合适。
不过Modbus RTU也有局限:单帧数据量太小,最大才253字节数据区,传一个192KB的固件要分成大几百帧,每一帧都有协议头尾和CRC,传输效率大概打个八折。在115200波特率下大概要传20~30分钟,能接受但不能算快。更多时候我是把固件用LZSS或者XZ压缩一下,缩小到原来的60%左右,传输时间能明显降低。压缩算法在Bootloader里解压会增加复杂度,所以我只在APP侧做“先接收,后解压”,Bootloader只负责原样搬运。大家可以根据自己固件大小来权衡。
4.2 OTA相关自定义功能码设计
我在FreeModbus V1.6的框架下新增了以下功能码:
| 功能码 | 名称 | 方向 | 说明 |
|---|---|---|---|
| 0x50 | OTA握手 | 主->从 | 开始OTA会话,携带总包大小和版本号 |
| 0x51 | OTA数据帧 | 主->从 | 每帧携带偏移地址和最多240字节数据 |
| 0x52 | OTA结束 | 主->从 | 全部数据发送完毕,请求设备做CRC校验 |
| 0x53 | OTA查询 | 主->从 | 查询当前接收状态 |
| 0x54 | OTA切换 | 主->从 | 校验通过后请求切换启动分区 |
正常业务功能码和OTA功能码如何区分?我是在模式层面做了状态机:设备平时处于“业务模式”,收到0x50握手帧后进入“OTA接收模式”,接收完并成功切换后才返回业务模式。进入OTA接收模式之前会有一次确认应答,防止误触发。
数据帧格式设计如下:
| 字节偏移 | 内容 | 说明 |
|---|---|---|
| 0 | 从站地址 | 默认1 |
| 1 | 功能码 | 0x51 |
| 2-5 | 数据偏移 | 4字节,小端模式,表示这帧数据在固件中的起始地址 |
| 6-... | 负载数据 | 最大240字节 |
| 末尾 | CRC16 | Modbus标准CRC |
这里有个容易踩的坑:STM32是32位小端CPU,但有些上位机是大端或者要按Modbus寄存器的高字低字次序组帧,如果上位机不是自己写的,务必确认偏移字节序。我当时就被这个坑害过,上位机给的偏移是高位在前,设备端按低位在前读,结果前64KB数据没问题,因为偏移小于65535的时候高低字节互换不出来,等到超过64KB突然乱了。后来在握手帧里加了个字节序检查项,设备端读到了0x0000AABB这种特殊数值再判断大小端方向,从此再没翻过车。
4.3 接收缓冲区与外部Flash写入策略
固件包192KB,片内RAM才64KB,不可能一次收完再写Flash。所以我的写入逻辑是“边收边写”,每个数据帧的负载到达后,直接按偏移写入W25Q64外部Flash的对应地址。外部Flash的写入方式我采用SPI标准Page Program,一页256字节,每帧240字节,所以每个帧最多跨一页,要处理跨页写入的问题。
uint8_t ota_write_packet(uint32_t offset, uint8_t *data, uint16_t len) { uint32_t byte_addr = OTA_EXTERNAL_FLASH_BASE + offset; uint8_t page_offset = byte_addr % 256; uint8_t first_len = 256 - page_offset; if (len <= first_len) { // 单页内写入 w25qxx_Write_Page(data, byte_addr, len); } else { // 跨页,分两次写 w25qxx_Write_Page(data, byte_addr, first_len); w25qxx_Write_Page(data + first_len, byte_addr + first_len, len - first_len); } return ERR_NONE; }W25Q64写入前必须先擦除,擦除单位是4KB扇区。所以在接收开始前,我要先把外部Flash的一段区域整片擦掉。8MB Flash擦一小段就行,比如从0x100000到0x140000之间的256KB专门作为OTA暂存区,接收前统一擦除。
这里有个体验上的提升点:擦除256KB大概需要几百毫秒,如果设备在正常工作状态,擦除Flash会占用SPI总线和CPU时间,可能影响Modbus轮询。解决办法是把擦除动作放在握手确认之后立即执行,此时业务方会暂停数据上报,等擦除完成再开始传输。上位机在OTA流程里要先等待设备返回“Ready”才能发第一帧,不能握手完就直接发,否则第一帧数据会写到没擦除的区域里去。
4.4 完整固件接收后的CRC校验
所有数据帧发送完后,主站发送0x52结束帧。设备端这时需要做两件事:
第一,把外部Flash暂存区的固件包读出来,重新计算CRC32,和握手帧里携带的CRC32做对比。注意这里最好不要只算CRC32,还可以加一个SHA256摘要,那玩意在F103上跑的话96KB固件大概需要1~2分钟,看预算。CRC32虽然碰撞概率不如SHA,但在嵌入式场景校验误码已经足够,计算速度快,所以我用CRC32做主校验,SHA256做可选高级校验。
第二,如果CRC校验失败,立即回错误码,并保留外部Flash里的数据,允许主站对失败区间重传。这就引出一个问题:怎么快速定位哪一段数据错了?最简单的做法是把固件包按1KB划分成192个块,每块在写入时就算一个独立的CRC32保存到外部Flash的另一个区域。校验失败时,设备端可以逐块校验并上报哪几块出错,主站只重传报错的块。这个方案叫“块级断点续传”,看起来高级,实现也就多一两百行代码,强烈推荐。
校验通过后,设备端不急着更新标志位。它要先把整个固件包从外部Flash搬运到片内Flash的备用分区——这是OTA整个流程中耗时最长、风险最高的阶段:片内Flash擦写速度慢,而且擦除期间一旦断电,备用分区可能处于半擦半写的状态。好在这里有AB机制的兜底,最坏结果不过是从B分区回滚到A分区,不会影响现有系统。搬运过程大概需要5~10秒,中间会把所有中断屏蔽掉,上位机这边要注意超时时间设长一点。
4.5 实际测试:传输速率的粗略估算
我实测用115200波特率传一个96KB的固件包:每条Modbus数据帧负载240字节,有效传输率约=240/ (1+1+4+240+2) ≈ 96.7%,实际因为帧间隔和应答时间,大约会掉到85%左右。计算下来:96KB / 240B = 410帧左右,每帧在115200波特率下需要传输时间约(1+1+4+240+2) * 10 / 115200 ≈ 21.5ms,加上设备处理时间5ms左右,单帧总耗时约26.5ms,总时长410 * 26.5 ≈ 10.9秒。加上握手、CRC校验、Flash搬运时间,整体15秒左右完成一次OTA。这个速度在串口方案里已经算很体面了。
如果换成USB虚拟串口CDC,波特率直接上到1Mbps以上,但STM32F103的USB是全速12Mbps,实际吞吐能到几百KB每秒,这时固件搬运成本主要被片内Flash擦写时间吃掉了,传输过程反而变成了次要瓶颈。大家可以根据项目实际情况灵活选择链路。
5. 从工程实践看AB OTA的坑与排错
5.1 踩过的四个典型问题
问题一:跳转后程序死在启动文件里。
现象:Bootloader执行到跳转函数,APP完全没有反应,调试器挂上后发现PC卡在0x08020000附近。
排查:先看SCB->VTOR设了没有。标准库的SystemInit默认把VTOR设成0x08000000,不去改它,跳转到APP后第一个中断就把CPU搞死。正解就是前面说的,在APP的SystemInit里加一行SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET。另一个隐藏坑是启动文件里调用SystemInit的顺序——有些改过的启动文件会在进main之前先执行一段需要中断的代码,VTOR还没设置就崩了。最稳妥的办法是在启动文件最开始的地方,Reset_Handler里进入main之前就设置VTOR。
问题二:W25Q64读取正常但写入后读回来全是0xFF。
排查:多半是没先擦除。SPI NOR Flash只能在擦除状态(全0xFF)下把位写成0,不能把已经写成0的位恢复成1。如果往没擦除的区域写数据,写入操作本身“成功”了,但读回来的内容是原来数据和目标数据相与的结果。所以每次接收前必须保证整个目标区域被擦过一次。
问题三:Modbus传输到一半断连,重发后顺序错乱。
排查:FreeModbus底层是单线程的轮询机制,如果处理函数里做了耗时操作(比如写Flash),会阻塞下一次中断接收,导致串口FIFO溢出丢字节。解决办法是:数据帧的响应要等写Flash完成之后再发,但接收中断必须持续开启。也就是说,数据处理函数和协议栈处理函数在时序上要解耦。我最后改成了双缓冲结构——中断层把收到的原始字节放到一个256字节的缓冲区,主循环里逐帧解析,解析过程中新的中断继续往另一个缓冲区写,互不干扰。
问题四:升级成功后,第二次上电启动的是旧分区。
排查:标志位没有正确保存,或者被意外擦除了。我在标志位结构体里加了CRC32自校验,读出来发现校验失败就默认回滚A区。有次是因为我另一个模块在操作片内Flash时用了固定的页号,把那块标志位区域当成参数区给擦掉了。定位这个问题费了好几个小时,最后在标志位区地址加了个访问保护宏定义,其他模块操作Flash之前统一走接口检查,禁止越界访问保留区。
5.2 问题排查与经验速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 跳转后HardFault | VTOR未重映射 | 检查APP中SystemInit/启动文件设置 |
| 跳转后无任何反应 | 栈指针非法 | 检查APP起始地址内容是否为合法栈顶 |
| OTA数据收到但写不进Flash | 目标区域未擦除 | 确认接收前已完成扇区擦除 |
| 写Flash时长时间卡死 | 擦写过程屏蔽了中断 | 优化分段擦写/降低阻塞时间 |
| Modbus传大文件中途断帧 | 底层处理函数耗时过长 | 中断/主循环解耦,双缓冲 |
| 升级成功但无法切换分区 | 标志位写入失败 | 检查标志位CRC、双槽设计是否异常 |
| 复位后从错误分区启动 | 标志位被别处擦写 | 对标志位区域加访问保护与代码走查 |
| 升级完跑起来偶尔死机 | 固件搬运时中断关闭太久 | 分段搬运,中断采用临界区而不是全关 |
5.3 加签与验签,这步不能省
热词里有一项是“汽车嵌入式软件ota加签验签”,我自己的量产项目也因为安全审查把签名校验加了进去。嵌入式设备OTA如果裸奔没有任何数据完整性保护,攻击者伪造成一个固件包推送给设备,设备直接重启到攻击者代码里,等于拱手让出控制权——这在工业场景里是要出大事的。
我当时加的方案是:“CRC32做传输层校验,SHA256做数据完整性校验,ECDSA P-256做来源可信校验”。ECDSA验签在72MHz的F103上跑起来有点费劲,一次验签大概需要3~10秒(看库的实现优化程度),但在升级过程中多等几秒换来源可信,值。如果你用的MCU不带硬件加密引擎,就得用mbedTLS这类软件库做纯软件运算,编译出来的Bootloader体积会增大不少,这也是我Bootloader给了128KB的原因之一。
签验流程:
1. 开发机上用私钥对固件包计算签名 2. 固件包+签名包通过上位机下发 3. Bootloader收到完整包后,先用内置公钥验证签名 4. 验签通过 -> CRC校验 -> 搬入分区 -> 切换标志 -> 重启 5. 验签失败 -> 丢弃整个升级包,保持原系统运行公钥直接以常量的形式固化在Bootloader里,私钥保存在开发机加密U盘里,任何人拿到了固件包但没有私钥就无法伪造出能通过验签的版本。这个方案虽然不能百分之百防住所有攻击者,但已经能挡住绝大多数非法升级请求了。
6. 生产环境下的OTA操作流程参考
6.1 如何把“固件制作-传输-切换”这条链路自动化
手工一条条发Modbus帧不现实,一定要把上位机工具链做起来。我当时用Python写了一套OTA上位机工具,PyQt做界面,pyserial做串口通信,大致逻辑如下:
def ota_upload(port, baudrate, firmware_path, slave_addr): # 1. 读取bin文件,计算CRC32 fw_data = open(firmware_path, 'rb').read() fw_crc = binascii.crc32(fw_data) & 0xFFFFFFFF # 2. 握手 send_handshake(slave_addr, len(fw_data), fw_version) wait_response(timeout=5) # 3. 分帧发送 for offset in range(0, len(fw_data), 240): chunk = fw_data[offset:offset+240] send_data_frame(slave_addr, offset, chunk) wait_response(timeout=1) # 4. 结束帧 send_finish(slave_addr, fw_crc) result = wait_response(timeout=10) # 5. 切换分区 if result == 'CRC_OK': send_switch(slave_addr) return result这个工具的每一帧发送、应答超时、重传机制都要写,我第一次只花了几个小时做了个能用的版本,后面在车间刷了几千台设备之后才把各种异常分支补齐,比如串口掉线重连、断电重来、断点续传等。嵌入式开发不只写MCU端代码,上位机工具链的完善程度直接决定你OTA方案的落地效率,这一点很多人容易忽略。
6.2 工厂量产时的操作模式
量产阶段和老设备改造场景不一样:车间里有产测工装,每台设备烧录完成B0版本后马上做基础校验,然后通过工装上位机统一推送当前线上版本。这个操作省去了产线电脑单独配仿真器的工序,全部通过串口完成。一开始工厂工程师还怀疑串口会不会比SWD慢,实际测试全流程下来,SWD烧录加校验大约15秒,OTA推送加切换大约18秒,差距不大,但胜在不需要专门的J-Link,工装可以并联很多路同时刷,总体效率反而上去了。
工厂模式里我加了一个“强制版本对齐”功能:上位机读取设备当前版本号,如果不是目标版本号,自动触发OTA流程,全程不弹窗、不确认,质检员只管把设备放到工装上等绿灯。这套流程上线后,产线返工率降了不少,因为再也不存在“忘记刷最新固件”的人了。
6.3 上线后的运维策略
设备铺出去之后,OTA不只是“发一次新版本”就完事,还需要一套完整的上线策略。我个人的建议是分三个梯队:
- 第一梯队(2~5台):开发人员自己的原型机,刷完立刻验证,全天盯着日志,有任何问题马上回滚。
- 第二梯队(20~50台):小规模试点,可以选几家配合度高的客户设备,先跑三到五天,收集稳定性数据。
- 第三梯队(全量):确认试点没问题后,开启批量推送。批量推送最好做“速率限制”,比如每小时最多推500台,避免一旦出问题同时炸一片。
AB分区让这三个梯队之间的回滚变得极其简单:任何一层发现问题,只需要通过上位机发一个“回滚指令”,让Bootloader改回旧标志位,设备重启就回到旧版本,整个过程不需要出车,不需要去现场。这个能力在以前单分区方案里想都别想,简直是运维的免死金牌。
7. 关于OTA版本管理的一点额外经验
最后说一点脱离了具体代码但在工程上特别致命的东西:版本管理。AB分区只能解决“运行哪个分区”的问题,不能解决“哪个版本是对的”的问题。我开始做OTA时,同事在测试板刷了好几个版本,结果哪块板子是哪个版本根本分不清楚,最后靠APP内部一个版本号寄存器才理清楚。
建议在APP代码里固定一个版本号信息块,放在固件二进制最前面或者其他固定位置,这样Bootloader和上位机都能直接读取,不用启动APP就能知道这个分区里的固件是哪个版本,这对接下来的灰度发布和信息同步很有用。
// 放在APP代码里,固定位置 const app_version_t g_app_version __attribute__((section(".version_section"))) = { .magic = 0x56455231, // "VER1" .major = 1, .minor = 3, .patch = 0, .build = 20240115, .crc32 = 0 };在分散加载文件里把.version_section固定到APP起始地址偏移0x200的位置,这样不管是Bootloader还是上位机,只要读了分区起始地址附近的数据,就能拿到版本信息。上位机在做批量管理的时候,扫一遍所有设备当前版本,自动比对线上版本,决定要不要推送,比人肉记要可靠一万倍。
整套流程从设计到量产验证,我这边前前后后花了不到三周,但这三周里踩的坑基本都写成上面这些内容了。说句掏心窝的话,AB OTA方案最大的价值不在于“能不拆机刷固件”,而在于你想刷的时候敢刷、刷坏的时候能退——这种底气在工程里太重要了。如果你正准备在F103上复现这套方案,建议先拿一块开发板把Bootloader和AB分区的骨架搭起来,传输部分先用最短的串口调试助手手动发,等流程通了再上Modbus和工具链,一步一步来,别想一口吃个胖子。按照这个路径走,你也就离“设备远程可救药”不远了。