STM32F103 AB OTA升级实战:Bootloader、Modbus RTU与双分区回滚方案
2026/9/6 10:07:44 网站建设 项目流程

做嵌入式这一行,最怕的就是设备已经部署到客户现场,结果发现固件有Bug。带电开壳、飞线接JTAG、蹲在现场反复烧录……先不谈专业技术含量,光是客户的夺命连环Call就够你喝一壶。如果你手里有不少基于STM32F103的产品,又想把“远程升级”这件事从“看着就头大”真正落地成一套稳妥的方案,那这篇AB OTA复现教程应该正好对胃口。文章不聊空话,直接给出一套能跑起来的最小系统:STM32F103系列芯片、标准库V3.5开发环境、RS232串口作为传输通道、Freemodbus v1.6迁移实现Modbus RTU通信,固件层面采用A/B双分区设计,配合Bootloader完成升级、校验、跳转和回滚。无论你是刚入手F103的老哥,还是已经在用CubeMX开发但被OTA困扰的工程师,这套思路都能直接拿过去参考。

1. 项目整体设计与分区规划

1.1 为什么要做AB OTA:现场升级不翻车的底线设计

先回答一个很多人纠结过的问题:升级方案那么多,为什么偏偏选AB分区?单分区加备份恢复不行吗?Bootloader加网络下载直接覆盖不行吗?

单分区方案最容易理解:Bootloader把新固件直接写进App区,写完跳转。问题是如果写入过程中掉电、固件本身有隐藏Bug、或者CRC校验逻辑写得不严谨,设备直接变砖,只能回厂或者现场拆机刷写。很多老工程师回忆“OTA升级项目半年,一大半时间在救砖”就是因为这个。

AB分区的核心思路是:在Flash里同时保留两个App镜像——分区A和分区B,其中一个运行,另一个作为升级目标。升级时只动非运行区,写完之后切换标志位,重启后在Bootloader里验证新分区可用,再决定继续切换还是回滚到旧分区。

这套方案的工程意义非常直接:

  • 任何时候Flash里都有一颗“已知能跑”的固件,升级失败不至于变砖
  • 回滚不需要重新下载数据,只需改一个标志位
  • 如果客户现场有双版本回退需求(比如算法调参想切回旧版),AB分区天然支持

代价是Flash容量占用翻倍。但现在的F103高容量型号(比如RCT6有256KB Flash、ZET6有512KB Flash),分两个80~100KB的App区完全够用。如果你用的是F103C8T6这种64KB的小容量芯片,AB方案就会比较紧张,建议评估一下固件实际大小再动手。

1.2 Flash分区与地址计算

我这次用STM32F103RCT6做演示,256KB Flash,从零开始规划分区。

分区名起始地址大小说明
Bootloader区0x0800000032KB (0x8000)升级引导、串口驱动、Freemodbus协议
App A区0x0800800096KB (0x18000)运行分区A,固件链接地址
App B区0x0802000096KB (0x18000)运行分区B,固件链接地址
参数/日志区0x0803800032KB (0x8000)升级标志、版本记录、状态保存

几个计算过程给大家拆开讲。

Bootloader为什么留32KB?如果你只是做一个跳转小程序,8KB绰绰有余。但我们要在Bootloader里跑Freemodbus、做Flash擦写、做CRC32校验、打印调试日志,标准库加printf一链接,体积很容易冲到20KB以上。留32KB是给自己留余地。记住:Bootloader功能做得越完整,App越省心,但Bootloader体积越大,留给App的Flash就越少,这个平衡要看实际项目。

App区为什么是96KB?RCT6总容量256KB,减去Bootloader 32KB和参数区32KB,剩余192KB,A/B对半分,每个区96KB。App编译出来只要不超过96KB就能放。生成Map文件后,用文本编辑器打开搜索“Total ROM Size”或者查看*.map文件里各个段的结束地址,就能确认固件实际占了多大空间。

参数区放最后32KB,我实际上只用了最后几页(F103大容量芯片每页2KB),剩下的大半留作日志或者固件升级缓存。这个区域存放一个结构体,用来记录当前活跃分区、待切换分区、固件长度、CRC值、版本号、启动次数等信息。

1.3 AB OTA的完整升级状态机

分区定好了,接下来是最容易写乱的状态机。很多初次接触AB OTA的朋友,代码写一半就开始混乱,就是因为没有先把状态定义清楚。

我这套方案里定义了六个状态:

状态含义可能来源
IDLE空闲,无升级任务设备上电初始化
DOWNLOADING正在接收固件数据收到开始传输命令
VERIFY固件接收完成,正在校验CRC32收到结束传输命令
PENDING新固件校验通过,等待激活Bootloader确认结果
ACTIVE新分区已经成为正式运行分区App启动后上报成功
ROLLBACK新固件运行失败,回滚旧分区启动次数超阈值

完整流程是这样:设备在A区正常运行,上位机通过Modbus RTU发送升级命令,App收到后进入DOWNLOADING状态,开始往B区(非当前运行区)写入固件数据。每帧写进去都做一次回读校验或者累加校验,全部写完做整体CRC32校验。校验通过后把参数区的状态改成PENDING,然后软复位进入Bootloader。Bootloader判断参数区有PENDING状态的待切换固件,先再校验一次(保险起见,防止App端写错位置),然后置为ACTIVE并跳转到B区。B区App启动后,先上报一条“升级成功”的消息给上位机,同时把参数区的启动计数清零。如果App在B区压根没跑起来,Bootloader里的看门狗会超时复位,启动计数累加,超过预设阈值就自动回滚到A区。

这段链路听起来长,但每一步拆开都很清晰。状态机是整个AB OTA的骨架,代码可以之后再写,状态转移必须先想明白。

2. Bootloader:从启动到跳转

2.1 Bootloader整体启动流程

Bootloader是上电后第一个执行程序,也是AB OTA最核心的守门员。它的任务不是“下载固件”(那是App和上位机的事),而是“决定跳到哪里去”。我把Bootloader的启动流程精简成以下伪代码:

int main(void) { system_init(); // 时钟、串口、Flash、看门狗 param_load(&g_param); // 从参数区读取升级记录 if (g_param.flag == PENDING) { // 有等待激活的新固件,先验证一次 if (verify_image(g_param.target_addr, g_param.image_len, g_param.image_crc)) { g_param.active = g_param.target; g_param.flag = ACTIVE; param_save(&g_param); } else { // 校验失败,放弃切换,标记回滚 g_param.flag = IDLE; param_save(&g_param); } } if (g_param.flag == ACTIVE) { // 启动计数器,防止新固件起不来 g_param.boot_count++; param_save(&g_param); if (g_param.boot_count > MAX_BOOT_COUNT) { // 新固件连续启动失败,回滚 switch_to_rollback(); } } uint32_t app_addr = (g_param.active == PART_A) ? APP_A_ADDR : APP_B_ADDR; jump_to_app(app_addr); }

有几点实践经验要说明。

第一,看门狗必须尽早打开,而且Bootloader里不能喂狗,至少在判断回滚逻辑之前不能喂。这样如果App跑不起来,复位后Bootloader能再次获得控制权。独立看门狗IWDG超时建议设1秒左右,够Bootloader完成判断,又不会让系统卡死太久。

第二,参数区读写要加额定信号。我用的magic number是0xA5A5A5A5,每次读参数区先检查magic,不是这个值就按出厂默认处理。第一次烧录全新芯片时参数区全是0xFF,不加magic判断的话会把一堆垃圾数据当成有效参数。

第三,Bootloader里也加一个串口命令入口。虽然正常情况下它几十毫秒就跳走了,但调试阶段或者参数区被写坏时,你能用一个超级终端连接设备,输入特定命令强制停留在Bootloader里做恢复。这个“隐藏后门”在开发期价值极高。

2.2 跳转函数与中断向量表重映射

跳转这段有很多细节,处理不好就是HardFault。先贴一个我验证过的跳转函数:

typedef void (*p_app_func)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)app_addr; p_app_func app_reset = (p_app_func)(*(volatile uint32_t *)(app_addr + 4)); // 检查栈顶指针是否指向RAM区 if ((app_sp & 0xFFF00000) != 0x20000000) { return; } // 关闭全局中断 __disable_irq(); // 清除所有挂起的中断 for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } // 设置主栈指针并跳转 __set_MSP(app_sp); // 重映射中断向量表,F103的VTOR在0xE000ED08 SCB->VTOR = app_addr; app_reset(); }

这里有几个关键点:

检查栈顶地址,是因为App的起始4字节存放的是初始栈指针(MSP值)。如果这个值不在RAM地址范围内(F103的RAM从0x20000000开始),说明这段Flash里压根没有有效固件,跳过去必然死机。

跳转前要关闭所有中断并清空NVIC的挂起标志。原因很朴素:Bootloader里可能打开了串口中断、定时器中断,跳转到App后,App的启动代码要重新配置这些外设。如果中断在切换瞬间触发,而App的中断服务函数还没准备好,程序就会跑飞。我见过不止一个项目死在这个细节上。

清挂起标志最好用NVIC->ICERNVIC->ICPR分别关闭使能并清除挂起。只清挂起不关使能也可能有问题,两个都做最稳。

SCB->VTOR就是中断向量表偏移寄存器。老版本标准库有NVIC_SetVectorTable(),但V3.5里已经不建议用,我直接用寄存器操作。App端也需要做同样的处理,否则中断向量表还在Flash起始地址,一旦App发生任何中断都会跳到Bootloader区域去取向量,结果必然是HardFault。

2.3 链接脚本与编译配置要点

跳转解决完,下一个坑就是链接地址。F103不像Cortex-A芯片有MMU能做地址映射,代码里的绝对地址在编译时就定死了。所以A区和B区的App必须在不同的链接地址下各编译一次。

我是这样做的。工程目录下放两个链接脚本:

app_a.ld关键片段:

MEMORY { FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 96K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 48K }

app_b.ld关键片段:

MEMORY { FLASH (rx) : ORIGIN = 0x08020000, LENGTH = 96K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 48K }

用Makefile或者批处理脚本调两次编译,一次用app_a.ld,一次用app_b.ld,分别产出app_a.binapp_b.bin。编译完记得打开Map文件确认一下,__Vectors符号的地址确实在对应分区的起始位置。

App的启动文件startup_stm32f10x_hd.s里,中断向量表默认放在Flash起始地址,也就是链接脚本里FLASH的ORIGIN位置,所以不需要手动修改启动文件,链接器会自动对齐。

App端main函数开头做两件事:第一,把SCB->VTOR设成自己所在分区的起始地址;第二,把自己所在的分区号写进一个全局变量,方便OTA逻辑判断当前运行在哪个区。区分A/B的办法最土也最有效:各分区固件在编译时通过宏定义写入不同分区号,或者运行时直接从SCB->VTOR读取当前地址来判断。

3. 串口链路与Modbus RTU协议实现

3.1 传输选型:为什么是RS232加Modbus RTU

OTA的传输通道可选项很多:以太网、4G模块、Wi-Fi模块、LoRa、CAN、串口。为什么这套教程挑RS232加Modbus RTU?

原因很简单,这是工业现场最“不挑环境”的组合。很多电力、工控、仪器仪表类设备本身就带RS232或者RS485接口,硬件不用改。Modbus RTU协议栈有现成的开源库,标准统一,哪怕你换一套上位机软件,只要遵循相同的功能码和数据格式,协议层完全不用动。更重要的是,Bootloader里也要实现同样的通信协议,Freemodbus是C语言写的,去掉操作系统相关代码后可以跑得非常精简,塞进Bootloader毫无压力。

如果你的设备是RS485,原理完全一样,只是把波特率和收发方向控制(DE/RE引脚)多处理一下。串口初期调试建议先用RS232,逻辑简单,不需要控制收发切换,等协议层跑通再换RS485不迟。

3.2 Freemodbus v1.6移植要点

Freemodbus库本身不复杂,核心文件是mb.cmbfunc.cmbcrc.c,需要你根据平台填充的是几个移植层文件:

  • portserial.c:串口底层驱动,负责串口初始化、发送一帧数据、接收字节中断回调
  • porttimer.c:定时器驱动,用于产生Modbus RTU的3.5字符超时中断
  • port.h:定义字节类型、临界区(关/开中断)、字节序等宏

我用标准库V3.5,串口用USART2,波特率115200。注意波特率越高,Modbus RTU对定时器的精度要求越高。3.5字符时间间隔的计算公式是:3.5乘以字符位数除以波特率。115200波特率、8数据位、1停止位、无校验的情况下,1 bit时间 = 1/115200 = 8.68us,一个字符算11位(含起始位和停止位),3.5字符时间大约是3.5 * 11 / 115200 = 334us。定时器建议直接基于SysTick或者TIM2做微秒级计数,精度不够会让RTU拆帧错乱。

具体的移植方式,Freemodbus包里有portserial.c模板。你需要把里面几个函数填上:

BOOL xMBPortSerialInit(UCHAR ucPORT, ULONG ulBaudRate, UCHAR ucDataBits, UCHAR ucParity) { // 配置USART2的GPIO、模式、波特率 USART_InitStructure.USART_BaudRate = ulBaudRate; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, &USART_InitStructure); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); USART_Cmd(USART2, ENABLE); return TRUE; } void vMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable) { // 使能/禁止接收、发送中断 if (xRxEnable) { USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); } else { USART_ITConfig(USART2, USART_IT_RXNE, DISABLE); } } BOOL xMBPortSerialPutByte(CHAR ucByte) { while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET); USART_SendData(USART2, (uint8_t)ucByte); return TRUE; } BOOL xMBPortSerialGetByte(CHAR *pucByte) { *pucByte = (CHAR)USART_ReceiveData(USART2); return TRUE; }

串口中断里收到一个字节后调用pxMBFrameCBByteReceived(),发送完成(TXE或者TC)后调用pxMBFrameCBTransmitted(),Freemodbus内部会自己处理帧组装和CRC校验。

3.3 自定义OTA功能码与帧格式设计

Freemodbus自带的标准功能码(03读寄存器、06写单个寄存器、16写多个寄存器等)足够做常规数据交互,但OTA传输需要的是“大块流水数据”,所以我在标准库之外自定义了一组私有功能码。Modbus规范里非官方功能码区域主要集中在0x65~0x720x78~0x7F,实际工业项目里很多人也用0x50附近做私有扩展,只要上位机和下位机约定一致就没有兼容性问题。

我定义的OTA私有功能码:

功能码名称方向含义
0x50OTA_START上位机→设备开始升级,携带目标分区、固件长度、CRC32
0x51OTA_DATA上位机→设备传输一帧固件数据,携带帧序号和数据体
0x52OTA_END上位机→设备结束传输,触发整体校验
0x53OTA_STATUS设备→上位机查询设备当前升级状态

0x50开始升级的报文格式这样定义:

字节偏移内容长度说明
0从机地址1如0x01
1功能码10x50
2目标分区10xAA表示A区,0xBB表示B区
3~6固件长度4大端,单位字节
7~10CRC324整包固件的CRC32校验值
11~18版本号8ASCII字符串
之后CRC162Modbus RTU帧校验

0x51数据帧报文中,固件数据区我每帧固定200字节。为什么是200?Modbus RTU最大帧长度是256字节,去掉地址1字节、功能码1字节、帧序号2字节、数据长度1字节、CRC校验2字节,真正的数据空间上限是247字节。留点余量选200字节,一帧总长206字节,紧凑又不越界。

计算一下总帧数:96KB固件除以200字节大约需要491帧。115200波特率下每帧约18ms,纯数据时间大约9秒。如果加大前导头和校验,全流程15秒以内能完成。如果你用9600波特率,总时间会拉到两三分钟,也不是不能用,但现场操作体验会差很多。

3.4 串口传输可靠性:帧间隔和超时重传

Modbus RTU是靠“静默时间”来分割帧的:一帧结束之后,如果线路上超过3.5个字符时间没有新数据,从机就认为当前帧接收完毕。Freemodbus的porttimer.c就是在做这件事:接收中断每收到一个字节就重置定时器,定时器超时(3.5字符时间)就调用vMBPortSerialEnable关闭接收中断,然后把完整帧交给上层解析。

OTA传输最怕的就是帧间隔太长被误拆帧。因为固件包里可能连续出现多个字节的0x00或者0xFF,这些字节本身不会让UART产生额外延迟,但如果上位机发送时两次写串口之间间隔超过3.5字符时间(115200波特率下约334us),下位机就会把一帧拆成两帧,OTA数据就乱了。

解决办法有两个,实践中最稳的是上位机每帧一次性写入完整缓冲区,不要一个字节一个字节地写。串口驱动层面或者操作系统调度层面都要保证连续发送。还有一个辅助办法:把RTU超时计时器放宽到5字符时间,牺牲一点点实时性换取对噪声的容忍,很多工业环境里这样改更好用。

重传机制放在上位机侧实现。上位机发一帧0x51数据后,等待设备回一个应答(我用的应答是设备已写入的帧序号),超时500ms没有应答就重发当前帧。设备侧要记录“最后成功写入的帧序号”,这样重传时直接告诉上位机从哪里继续。

4. App端接收固件与Flash操作

4.1 Flash擦写策略:边收边写还是收完再写

在App里接收OTA数据和写到Flash,顺序上有个纠结:是一边收一边写,还是全部收到RAM再写?F103的RAM只有48KB,装不下96KB固件,所以主流做法是边收边写。

边收边写又分两种细节策略。第一种:收到一整帧(200字节)后,立即擦除对应页并写入。这种策略实时性最高,但F103大容量芯片每页是2KB,擦除粒度大于写入粒度,每次擦页都会连带把新旧数据混在一起,所以更合理的做法是边收边“按页缓存”,攒够2KB再擦写一整页。

我实际用的方案是按页缓存:RAM里开一个2KB缓冲区,每收到一帧200字节就往缓冲区里塞,塞满10帧(2KB)后,一次性擦除目标Flash页,把缓冲区写入,然后清空继续。如果最后一页不够2KB,在OTA_END时把残余数据补齐写入。

这样做的好处是大幅减少Flash页擦除次数。96KB固件如果每200字节就擦一次页,要擦约491次;按页缓存后只需擦48页,寿命和耗时都友好很多。

4.2 Flash写入代码与掉电保护

标准库V3.5的Flash驱动函数还是老一套:FLASH_UnlockFLASH_ErasePageFLASH_ProgramHalfWord。要点是写入操作必须按半字(16位)对齐。如果数据长度是奇数,最后一字节要单独处理,否则会写坏地址。

我的写入函数做了地址对齐和半字写入:

void flash_write_buf(uint32_t addr, uint8_t *buf, uint32_t len) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); uint16_t *p16 = (uint16_t *)buf; uint32_t aligned_len = len & ~1; // 偶数部分 for (uint32_t i = 0; i < aligned_len; i += 2) { FLASH_ProgramHalfWord(addr + i, *p16++); } if (len & 1) { // 奇数尾部处理 uint16_t last_word = buf[len - 1]; FLASH_ProgramHalfWord(addr + aligned_len, last_word); } FLASH_Lock(); }

注意:写Flash期间绝对不能关中断或者喂看门狗把时间拖长。F103在擦写页时是阻塞的,一次页擦除典型耗时20~40ms。如果看门狗超时时间设太短(比如几十ms),烧录过程中会被狗咬复位。所以OTA写入期间,要么暂时延长看门狗喂狗间隔,要么在每次擦页前先喂一次狗。掉电保护靠的是“状态机+双区”:写入过程中掉电,参数区标记还没改为PENDING,App侧最多丢一帧数据,重新上电后继续传输即可,不需要额外做复杂的掉电续传。

4.3 固件校验与版本检查

固件写完不是结束,必须做整体校验。在OTA_END流程里,App把所有已经写入的数据读回来,算一遍CRC32,和OTA_START帧中携带的CRC32比对。

CRC32算整个Flash区域时,注意不要读参数区,只读目标分区范围内的数据。Flash读取速度远快于串口,96KB全程读回来算CRC,在72MHz主频下几十毫秒就能完成,几乎没有时间成本。

版本检查要放在校验之前做,而且必须做“防呆”处理。我遇到过现场同事拿着旧版本的升级包去升新设备,结果固件越升越旧的情况。所以在OTA_START时,App会拿新版本号和当前运行固件版本号对比:新版号才允许升级;相同版本号提示重复;旧版本号默认拒绝,除非上位机带强制降级标志。这个策略避免了很多低级事故。

校验不通过时,App要返回明确错误码,并且不修改任何分区切换标志。设备继续跑老固件,完全不受影响。

4.4 双分区切换与回滚机制实现

这是整套方案最有含金量的部分。App在接收到完整固件并通过CRC校验后,往参数区写入目标分区号和PENDING标志,然后软复位。具体参数结构体这样定义:

typedef struct { uint32_t magic; // 0xA5A5A5A5 uint8_t active_region; // 0xAA=A区 0xBB=B区 uint8_t pending_flag; // 0x00=无 0x01=待激活 uint8_t boot_count; // 启动计数 uint8_t reserved; uint32_t image_len; // 新固件长度 uint32_t image_crc; // 新固件CRC32 uint8_t version[8]; // 新固件版本号 } sys_param_t;

回滚的判定逻辑是这样的:Bootloader每次跳转到ACTIVE状态的分区前,把boot_count加1。App正常运行后(比如跑了10秒,或者收到上位机确认消息),把boot_count清零。这样,如果新固件一启动就死机,看门狗复位后Bootloader再跑,发现boot_count已经超过阈值(比如3次),就自动把active_region改回旧分区,回滚完成。

这个方案在工程实践里最大的坑是“App清了启动计数,但实际上功能有隐藏Bug,运行几十分钟才崩溃”。AB回滚只能防启动崩溃,防不了运行时逻辑错误。所以更严谨的做法是App运行后上报心跳,上位机确认新版本稳定运行一定时间后,再发指令固化状态、允许下次覆盖。简单场景下,Bootloader的启动计数回滚已经足够用了。

5. 实战踩坑与问题排查实录

5.1 跳转后HardFault:从症状反推原因

跳转后最常见的现象就是HardFault。排查的时候按顺序来:

先确认跳转地址对不对。调试器停在HardFault_Handler里,查看PC寄存器和LR寄存器,如果地址落在0x08020000附近(B区地址)但代码里却按A区地址取值,那就是链接脚本和实际跳转地址对不上。

再确认SCB->VTOR有没有设置成功。在jump_to_app里设置后,可以加一行读回检查:

SCB->VTOR = app_addr; uint32_t check = SCB->VTOR;

如果读回值和期望值不一致,大概率是不小心关了总线时钟或者访问了错误地址。

最后确认中断向量表是否真的被编译到对应分区起始位置。打开Map文件,搜索__Vectors,它必须在0x08000000加上分区偏移的位置。如果还在0x08000000,说明启动文件或者链接脚本有问题。

5.2 串口丢帧帧乱码的排查套路

串口层的问题比Flash问题更隐蔽。如果你的OTA总是传输到一半就CRC失败,或者Freemodbus频繁超时,按这三个方向排查。

第一,量一下波形。示波器挂到TX/RX引脚上,看帧与帧之间的间隔是否稳定。如果间隔抖动明显,大概率是上位机发送端有延时。我遇到过一次,Windows下用Python的serial.write()每帧分多次拼接,前导数据之间间隔拉到好几毫秒,下位机直接拆帧。

第二,检查波特率误差。F103用外部8MHz晶振,如果晶振本身精度差或者内部RC没配准,115200波特率会累积误差。用示波器量一下实际波形周期,如果偏差超过2%,就要换晶振或者降低波特率。

第三,检查Freemodbus定时器配置。porttimer.c里的计时周期要和波特率严格匹配。我踩过最坑的一次是把定时器改成了微秒计数,但宏定义用的还是US_TIMER_TICKS_PER_US=1,结果帧间隔判断整整快了一倍,所有合法的RTU帧都被当成噪声丢弃。

5.3 Flash写入失败和校验不过的原因分析

Flash写入失败除了地址越界、Flash锁没解锁之外,最常见的原因是FLASH_ProgramHalfWord的地址没对齐。RAM缓冲区起始地址如果是uint8_t数组,取地址时看末位是不是2的倍数。用uint16_t *强转之前,先确认原地址对齐,否则直接崩溃。

校验不过的情况要区分是写入错误还是读取错误。我的排查方法是:写入完成后,立即把同一个地址的数据读出来和源缓冲区对比,打印出错时的Flash地址、期望值和实际值。如果出错地址按规律偏移(比如总是差一个偶数偏移量),恭喜你,写入时地址指针算错了。

5.4 再进一步:升级包加签验签和防回滚

基础AB OTA跑通后,如果产品要出批量,强烈建议加上固件签名验证。方案不需要太复杂,用对称加密或者简单哈希加盐也行,但正规产品一般用非对称签名:固件编译后用私钥签名,Bootloader里内置公钥,跳转前验证签名,非法固件直接拒绝。

加签之后,Bootloader的校验流程变成这样:先算CRC32确认数据完整,再做签名验证确认来源可信,两者都通过才允许切换分区。防回滚则在参数区加一个版本号字段,只允许版本递增,不允许降级。这些措施在汽车电子和电力设备行业已经是硬性要求,如果你做的产品以后要走认证,提前把验签框架搭好能省很多事。

6. 复现这套方案的一点实践经验

最后分享几个我做完整套方案后最有体感的细节。

第一,开发调试阶段,Bootloader一定要留串口命令行交互口。我一开始把Bootloader写得“一把梭”,上电就跳App,结果参数区改错一次,整块板子变砖,只能拿ST-Link重新烧。后来加了一个调试后门:上电后PC发任意字符,设备就停留在Bootloader命令行模式,可以读Flash、看参数区、擦除固件、写测试数据。这个后门让我后续调试效率提升了不止一倍。

第二,A/B两套固件的区分标志要在App里做得很显眼。比如开机后串口打印APP-A v1.2.0或者APP-B v1.2.0,OLED屏上也显示当前分区。否则测试的时候很容易搞不清楚自己到底在跑哪份固件,明明改了B区代码却一直在看A区现象,最后排查半天发现方向错了。

第三,升级过程最好周期性打印进度。哪怕只是串口输出一行[OTA] 45/491 frame received,配合上位机日志,能够极其高效地定位是上位机发送问题、串口链路问题还是下位机写入问题。一条清晰的数据流水线,比任何花哨的调试工具都管用。

AB OTA这套东西,其实不复杂,但细节量非常大。从分区规划、链接脚本、Bootloader跳转、Modbus协议封装到App侧的Flash写入和回滚策略,每一环单拎出来都是基础功,串起来就是一套能上生产环境的升级体系。照着上面的步骤把最小系统跑通,再根据自己的产品形态替换通信链路(比如换成4G模块、以太网、CAN),剩下的工作就是填协议适配的边边角角了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询