STM32F103 AB分区OTA升级方案:Modbus RTU远程固件更新与自动回滚实现
2026/9/6 8:48:37 网站建设 项目流程

手头有块STM32F103开发板,想给它做一个真正能用于生产环境的升级机制——不是JTAG插上去下载程序那种小打小闹,而是通过串口远程把新固件发过去、断电能自动恢复、刷挂了还能自动回滚的完整方案。这篇教程就是围绕这个目标来的:从零复现一套基于STM32F103的AB双分区OTA升级系统,传输层用标准库v3.5 + FreeModbus v1.6移植Modbus RTU,Bootloader负责引导和刷写,App负责业务逻辑与升级确认。

文章会覆盖你可能关心的所有细节:Flash分区怎么规划、向量表偏移怎么改、Bootloader怎么跳转App、FreeModbus移植要注意什么、AB分区状态机怎么设计、断点续传和自动回滚怎么实现、上位机脚本怎么写。适合两类读者:一是已经跑通过STM32点灯、UART收发,想进阶IAP和OTA的开发者;二是正在做工业设备远程维护方案,需要一套稳定可靠、可上线的升级架构的嵌入式工程师。

1. 项目整体拆解:AB OTA到底在解决什么问题

1.1 一个设备升级事故引出的双分区方案

先说一个我早年踩过的坑。那时候给一批现场设备做升级,所有设备从同一个起始地址烧固件,升级过程依赖串口一次性把整个bin写进Flash。结果某次现场网络抖动,升级包传了一半断掉,Flash里的程序被擦了一半,设备变砖,最后只能派人扛着编程器去现场拆机恢复。

后来我意识到,传统IAP方案最大的问题就是"单分区":当前运行的程序和即将写入的程序共用同一块Flash区域,升级过程中一旦断电、误码或者协议异常,唯一可运行的程序也损坏了,设备自然起不来。

AB双分区方案就是为了解决这个问题。简单说,把存储区拆成两个应用分区:A分区和B分区。任意时刻,Bootloader只启动其中一个,比如默认启动A。要升级时,把新固件写入当前不运行的B分区,写完后置一个"待切换"标志,重启后Bootloader从B启动。如果B运行失败,Bootloader再回滚到A。A始终保留着上一个可用版本,任何时刻都有一份能跑的固件。

这跟手机的双系统分区一个逻辑:系统升级失败大不了回退旧版本,设备始终能用。对嵌入式设备来说,这几乎是远程升级方案里最稳妥的做法。你要知道,工业现场一台设备动辄几万块,派人去现场刷机不仅成本高,还可能耽误产线运行,AB分区方案能把这些风险压到最低。

1.2 为什么选择Modbus RTU作为升级传输通道

很多做OTA的教程默认用自定义串口协议或者Ymodem,这些都用过,也都能跑,但我在实际项目里最终选择了FreeModbus v1.6移植的Modbus RTU作为设备端通信协议,原因很简单:很多工业设备本来就在跑Modbus总线

Modbus RTU是工业现场最常见的总线协议之一。如果你的设备已经在用Modbus RTU和上位机/PLC通信,那直接用同一条链路做OTA,不需要额外引入新协议,也不需要动总线拓扑。上位机端打开一个串口,既能查寄存器、控制参数,也能触发升级,整个心智负担小很多。

另外Modbus RTU自带CRC16校验,帧格式完整,协议栈成熟。FreeModbus v1.6是一个开源Modbus从站协议栈,移植到STM32F103并不复杂,只要处理好串口底层和定时器中断就能跑。相比之下,Ymodem虽然传输效率更高,但在Modbus网络里不兼容,也不方便做进度查询、分区切换、版本管理这些定制逻辑。

我在这个项目里也重新写了一个上位机Python脚本,直接通过Modbus RTU功能码与Bootloader交互。这样不仅本教程可以用,你以后接到Modbus设备OTA需求时,思路基本可以原样搬过去。

1.3 Bootloader与App的职责划分

AB OTA方案里,Flash里跑的程序被分成两部分:

  • Bootloader(引导程序):上电先运行,读参数区标志,决定启动A还是B,或者进入OTA升级模式。它内部集成了一套OTA协议处理逻辑,能接收上位机发来的固件,写入指定分区,做CRC校验,管理升级状态机。
  • App(应用程序):实际业务程序,比如电机控制、数据采集、显示界面等等。它通过一个寄存器写入命令触发软复位,把控制权交回Bootloader;App启动后还需要执行"提交更新"动作,告诉Bootloader自己运行正常。

把OTA协议栈放在Bootloader里,是我在实际项目中反复推敲后选定的架构。有人喜欢让App自己触发固件写入,Bootloader只做跳转。这么做Bootloader虽然轻量,但App一旦跑飞、死机或者被错误代码干扰,设备就完全失去远程恢复能力。而如果我让Bootloader具备完整的OTA能力,即使App完全没有响应,上位机也能强制进入Bootloader重新刷写,相当于设备多了一道保险。

Bootloader体积会因为集成OTA协议栈而变大,但考虑到STM32F103的Flash普遍在64KB到512KB之间,一个带Modbus协议的Bootloader控制在16KB以内完全可行,代价是可以接受的。

2. Flash布局与启动机制设计

2.1 地址空间规划:分区表怎么排才干得漂亮

这套方案的第一步是给存储空间做分区。我以STM32F103RCT6(256KB Flash)为例给出一个实践中验证过的分配方案,你可以根据自己芯片的Flash大小做比例缩放。

区域起始地址大小说明
Bootloader区0x0800000016KB (0x4000)引导程序 + OTA协议栈
App分区A0x08004000100KB (0x19000)默认启动的应用
App分区B0x0801D000100KB (0x19000)备用应用,用于承载新版本
参数/标志区0x080360004KB (0x1000)分区状态、版本号、升级标志
预留区0x0803700036KB日志、参数存储、字库等

如果你手里的板子是C8T6这种64KB Flash的芯片,可以采用紧凑布局:Bootloader 12KB,A和B各22KB,尾部留4KB参数区。App体积和时间戳一定要控制好,不然22KB很快用完。

有几个关键点需要强调:

  • 参数区必须单独划一小块Flash,不能和程序区混在一起。它的作用是持久化保存"当前从哪个分区启动""是否有待切换标志""启动失败计数"等信息。程序运行时可以随时修改,掉电不丢。
  • 分区起点要对齐Flash页大小。STM32F103中,中容量芯片(64KB/128KB)Flash页大小为1KB,大容量芯片(256KB/512KB)为2KB。分区边界按页大小对齐能避免跨页擦除的麻烦,所以上面表格里所有分区起始地址都是整数倍对齐的。
  • 预留区不是可有可无。项目后期你可能需要存字库、记录运行日志、保存标定参数,如果没有预留区,到时候就得痛苦地迁移分区表。

2.2 向量表偏移:跳转后第一个大坑

分区规划好之后,马上会遇到一个让无数人翻车的问题:向量表偏移

单片机上电后默认从0x08000000读取向量表,也就是从Bootloader的起始地址找栈顶指针和复位处理函数。当Bootloader跳转到App时,App的向量表并不在0x08000000,而在A分区或B分区的起始地址。如果不告诉CPU"App的向量表换位置了",那么任何中断发生(比如串口收一个字节),CPU都会从原向量表去找中断服务函数,找错地方自然死机。

处理方式就是在App启动代码里设置Cortex-M3的向量表偏移寄存器(VTOR):

SCB->VTOR = APP_ADDRESS; // APP_ADDRESS 为当前分区的起始地址

在STM32F103的标准库里,也可以在system_stm32f10x.c里找到宏定义VECT_TAB_OFFSET,直接改成对应偏移量。比如A分区从0x08004000开始,偏移就是0x4000。但注意,B分区的偏移是0x1D000,和A分区不一样。如果App的工程是同一份代码分别编译成A/B两个版本,不同版本要保证VECT_TAB_OFFSET不同。

我在实践中更推荐的做法是:不在App工程里写死偏移,而是由Bootloader在跳转前设置好VTOR。跳转函数里把目标分区地址写入SCB->VTOR,App里就不必再关心自己到底在哪个分区。这样做的好处是同一份App固件既能刷到A区也能刷到B区,上位机只需要根据目标地址把它写到对应位置即可,不用编译两个版本。

2.3 启动状态机与自动回滚设计

AB方案的核心灵魂是状态机。参数区里我定义一个结构体,把这几个关键字段持久化:

字段含义
magic0xA5A55A5A参数区有效性标记
boot_flag0xAA正常启动A分区
0xBB正常启动B分区
0xCC待切换到未运行分区(升级完成待确认)
boot_count0~N启动尝试计数
version_a / version_b版本号各分区固件版本信息

上电后Bootloader的执行逻辑:

  1. 读取参数区,检查magic。无效则初始化参数区,默认boot_flag = 0xAA,启动A分区。
  2. 如果boot_flag == 0xCC,说明上次升级后还没被确认。此时把boot_count加1,写入参数区(需要做Flash擦写),再跳转目标分区。
  3. 如果boot_flag == 0xAA或0xBB,直接启动对应分区。
  4. App启动后,若boot_flag仍为0xCC,App会在初始化成功后把boot_flag改为正常启动标志,boot_count清零,然后正常继续运行。
  5. 如果App启动后崩溃、死循环、看门狗复位,Bootloader再次启动时发现boot_flag仍为0xCC且boot_count超过阈值(比如3次),就判定升级失败,自动把boot_flag改成另一个分区对应的正常启动标志,回滚到旧版本。

这套机制解决了一个关键问题:什么算"升级成功"?不是固件写进Flash就算成功,而是新固件能正常启动并运行一段时间才算成功。否则可能出现"固件写进去了,一运行就死机,之后每次上电都死在半路"的尴尬局面。

我在这里用了独立看门狗(IWDG)配合。做法是:Bootloader跳转到目标分区之前启动IWDG,超时设为几秒;App启动后立即喂狗。如果App卡死,IWDG会复位MCU,Bootloader再次上电后就能感知到"这次切换失败了"。Bootloader自己的OTA流程不启用IWDG,因为升级过程中可能要等上位机慢慢发包,不能被打断。这是很容易忽略的细节,很多人直接在主程序里统一开看门狗,结果OTA等包时被反复复位。

3. Bootloader工程从零搭建

3.1 工程骨架:标准库v3.5 + FreeModbus v1.6

Bootloader工程的搭建,我建议从标准库v3.5开始,稳定成熟,网上资料也多。新建一个Keil工程,芯片选STM32F103系列,加入标准库的启动文件、系统初始化代码、GPIO/UART/Flash/Timer相关外设驱动,以及FreeModbus v1.6的源码。

FreeModbus v1.6需要移植的底层接口不多,核心就几个:

  • 串口初始化与字节收发:xMBPortSerialInitxMBPortSerialPutBytexMBPortSerialGetByte
  • 串口中断上报:收字节后调prvvUARTRxISR,发完后调prvvUARTTxReadyISR
  • 定时器实现3.5字符超时:初始化一个定时器,产生周期性tick(如50us),调用vMBPortTimersEnablevMBPortTimersDisable,在定时器中断里调prvvTIMERExpiredISR
  • 数据回调:eMBRegHoldingCB用于读写保持寄存器,这是OTA协议的主要入口

移植时一个最容易出问题的地方是3.5字符时间计算。Modbus RTU要求帧与帧之间至少有3.5个字符时间的间隔,用于从站判断一帧数据的结束。115200波特率、8N1格式下,一个字符需要10bit时间,3.5字符时间约为3.5 * 10 / 115200 ≈ 0.304ms。所以定时器tick选50us或100us都能满足要求。波特率越低,3.5字符时间越长,比如9600波特率下大约是3.65ms,此时换一个慢速定时器更合适。

我用的定时器tick是100us,这样的精度对115200足够了。串口中断优先级设置也需要注意,我把它设成比主循环临界区保护更高的优先级,避免丢字节。

3.2 Flash驱动:页擦除、半字编程与预擦除策略

STM32F103的Flash操作规则和很多芯片不太一样,编程以半字(16位)为最小单位,擦除以页为最小单位。标准库提供三个关键函数:FLASH_Unlock()解锁、FLASH_ErasePage()擦页、FLASH_ProgramHalfWord()写半字。

写数据函数我封装成这样:

void flash_write_buf(uint32_t addr, uint8_t *buf, uint32_t len) { FLASH_Unlock(); for (uint32_t i = 0; i < len; i += 2) { uint16_t half = buf[i] | ((uint16_t)buf[i + 1] << 8); FLASH_ProgramHalfWord(addr + i, half); } FLASH_Lock(); }

这里有两个硬性要求:写地址必须是2字节对齐,长度必须是2的倍数。如果不满足,Flash编程会报错,或者写入数据错乱。我在实际中把上位机下发的数据包长度固定为偶数,并在Buffer里做对齐拷贝后再调用写入函数,问题迎刃而解。

擦除函数也不复杂:

void flash_erase_range(uint32_t addr, uint32_t len) { FLASH_Unlock(); uint32_t page_size = 1024; // 中容量芯片1KB/页,大容量2KB/页 for (uint32_t a = addr; a < addr + len; a += page_size) { FLASH_ErasePage(a); } FLASH_Lock(); }

一个我强烈建议的做法是:开始升级时先把整个目标分区擦空,而不是边收数据边擦页。原因有两个:

  • 擦除Flash需要时间,中容量F1擦一页通常要20~40ms。如果每一次数据包都需要临时擦页,Modbus响应时间会变得不稳定,上位机容易超时。
  • 预擦除后,写入阶段只需要做半字编程,每半字耗时微秒级,整个240字节数据包写入不到1ms,对Modbus协议栈几乎无感。

代价是如果升级中途放弃,目标分区全0xFF,表面上像"废了"。但AB方案下这不影响运行中的分区,你随时可以重新发起升级覆盖。

3.3 OTA寄存器协议:一条升级指令的完整拆解

FreeModbus移植好、Flash驱动写好后,就要设计OTA协议了。我的思路是让它兼容标准Modbus协议,用保持寄存器(Holding Register)作为交互窗口。

保持寄存器映射表如下:

寄存器地址读写功能
0x0000OTA控制命令
0x0001目标分区号(0=A,1=B)
0x0002-0x0003固件总长度(32位,高16位在0x0003)
0x0004-0x0005当前数据包的Flash写入偏移(32位)
0x0010-0x00D0数据缓冲区(224字节)
0x00E0升级状态寄存器
0x00E1-0x00E2当前活动分区版本号
0x00F0软复位并进入Bootloader升级模式

控制命令定义如下:

  • 写入1:开始升级。内部执行预擦除目标分区、清零状态。
  • 写入2:写入当前缓冲区数据。从偏移地址开始,将0x0010-0x00D0里的224字节数据编程进Flash。
  • 写入3:结束升级。计算已写入数据的CRC32,尝试切换分区。
  • 写入4:查询状态。读0x00E0获取当前升级状态。

一次完整的数据包写入流程是:上位机先写目标偏移到0x0004-0x0005,再写224字节数据到0x0010-0x00D0,最后写控制命令2到0x0000。Bootloader在eMBRegHoldingCB回调里解析控制命令,调用Flash编程函数完成写入。

为什么缓冲区选224字节?这是结合Modbus RTU帧长限制算出来的。Modbus RTU最大帧长是256字节,减去从站地址1字节、功能码2字节(0x10写多个寄存器功能码+起始地址)、寄存器数量2字节、字节数1字节和CRC2字节,再减去我们需要的偏移寄存器、控制寄存器等开销,224字节是安全且高效的选择。实测在115200波特率下,一帧数据从发出到固件写入Flash完成,耗时约25ms,整包100KB的固件传输只要不到30秒。

这里涉及到一个关键实现:Flash编程操作直接在FreeModbus的回调里同步执行。因为预擦除已经做好,每次编程耗时很短,不会导致Modbus超时,这样做最简单,不需要异步状态机。但要注意,回调里做Flash操作时必须先关闭全局中断,否则擦写过程中被中断打断可能出问题。标准库的FLASH_ProgramHalfWord内部会关闭中断,FLASH_ErasePage也是,所以直接用标准库问题不大。如果是自己写Flash寄存器操作,要留意这一点。

4. App工程改造要点

4.1 Keil工程地址修改与bin文件生成

App工程的改造核心是让链接器知道"我的程序不在0x08000000开局了"。

在Keil的Options for Target中:

  1. 打开Target标签页。
  2. 修改IROM1起始地址和大小。比如A分区起始0x08004000,大小0x19000。
  3. 确保勾选Create HEX File。同时配置生成bin文件,在User标签页的After Build/Rebuild里加一行:
fromelf --bin -o "$L@L.bin" "#L"

这样每次编译都会生成一个和工程同名的.bin文件,这正是OTA需要烧写的文件。它的第一个字节就是从分区起始地址开始的可执行代码,不需要额外偏移。

一个常见的错误是:只改了起始地址,但是App里某个绝对指针还是指向0x08000000,或者链接器把常量池放到了错误位置。最直接的验证方式就是看map文件。打开生成的.map文件,搜索__initial_sp,正常情况下它应该落在分区地址范围内,比如0x08004000附近。再搜索Reset_Handler,确认复位处理函数也在分区内。如果发现符号地址仍然显示为0x08000000开头,说明链接脚本没生效,App烧进去必死。

4.2 上电提交与进入升级模式

App启动后第一件事,就是处理"待确认"标志。前面说过,Bootloader在跳转前把boot_flag设成了0xCC(待切换),如果App不能正常工作,就没有机会修改这个标志。所以App代码最好在main函数的很靠前位置执行提交逻辑:

void cli_ota_commit(void) { OtaParam_t param; flash_read_param(&param); // 从参数区读到RAM if (param.magic == 0xA5A55A5A && param.boot_flag == BOOT_FLAG_PENDING_SWITCH) { param.boot_flag = BOOT_FLAG_NORMAL; param.boot_count = 0; flash_write_param(&param); // 整页擦写回参数区 } }

这里有个细节值得说明:参数区的读写不能直接映射到Flash地址再改内容,因为写Flash前必须整页擦除,擦除期间如果代码还在从Flash取指令,CPU取指令会失败。正确做法是先把整个参数结构拷贝到RAM,修改完后再一次性擦写回Flash。App运行期间,所有状态操作都基于RAM副本,只在关键时刻回写Flash。

"进入升级模式"的触发方式也很简单。App里注册一个Modbus寄存器(或者直接接受业务命令),当上位机写入0x00F0寄存器为特定值时,App保存必要业务状态,然后写参数区boot_flag为"进入Bootloader升级模式",最后执行软件复位:

void cli_enter_bootloader(void) { flash_write_param_enter_upgrade(); NVIC_SystemReset(); }

Bootloader上电发现这个标志后,就不再启动App,直接进入OTA等待状态,上位机就能开始发固件了。如果上位机一直不出现,Bootloader可以设置一个超时时间,超过后强制启动App,保证设备不会永久停在升级模式里。

4.3 独立看门狗IWDG的正确配合方式

我前面提到了IWDG,这里展开讲讲为什么、怎么做。

AB方案最怕一种情况:新固件能启动,但运行几秒后死机。如果没有任何恢复机制,设备就会卡死在新固件里。IWDG就是用来兜底的。

具体做法是:

  1. Bootloader在判定需要切换分区时,初始化IWDG,预分频和重装值算好,让超时时间为5秒左右。
  2. Bootloader跳转到目标分区。App启动main函数后,立刻调用IWDG_ReloadCounter()喂狗。
  3. App在提交更新(boot_flag改成正常标志)之前,必须持续喂狗。如果App崩溃,IWDG超时复位,Bootloader重新上电,发现boot_flag仍然是0xCC,boot_count已经超过阈值,于是回滚到旧分区。
  4. 关键:App发现boot_flag是0xCC时,要延迟提交。比如先运行3秒、确认外设初始化正常,再调用cli_ota_commit。这3秒内IWDG正常工作,可以精准捕获"启动即崩溃"和"初始化阶段卡死"这两类故障。

IWDG配置代码:

void iwdg_start(void) { IWDG_WriteAccessCmd(IWDG_WriteAccess_Enable); IWDG_SetPrescaler(IWDG_Prescaler_64); // 40kHz / 64 = 625Hz IWDG_SetReload(3125); // 约5秒 IWDG_ReloadCounter(); IWDG_Enable(); }

注意IWDG的时钟源是独立的LSI时钟,大约40kHz,不同芯片可能偏差。实测发现有的芯片LSI误差可以到±10%,所以超时时间不要设计得太紧,给足余量。我一般取5到8秒,这个时间足够App初始化外设和喂狗了。

5. 上位机脚本与整机联调复现

5.1 Python上位机核心逻辑

Modbus RTU通信可以用Python的pyserial库直接拼帧,不需要额外依赖。核心函数是Modbus CRC16计算和帧组装:

import serial import struct import time def modbus_crc(data: bytes) -> int: crc = 0xFFFF for byte in data: crc ^= byte for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc def build_frame(slave: int, pdu: bytes) -> bytes: frame = bytes([slave]) + pdu crc = modbus_crc(frame) return frame + struct.pack('<H', crc) def write_regs(slave: int, addr: int, values: list, ser: serial.Serial): cnt = len(values) pdu = struct.pack('>BHHB', 0x10, addr, cnt, cnt * 2) for v in values: pdu += struct.pack('>H', v & 0xFFFF) ser.write(build_frame(slave, pdu)) resp = ser.read(8) def write_one_reg(slave: int, addr: int, value: int, ser: serial.Serial): pdu = struct.pack('>BHH', 0x06, addr, value & 0xFFFF) ser.write(build_frame(slave, pdu)) resp = ser.read(8)

上位机升级流程骨架:

def ota_upgrade(ser, bin_path, target_part): # 1. 发开始升级命令 write_one_reg(SLAVE, 0x0001, target_part, ser) write_one_reg(SLAVE, 0x0000, 1, ser) # 控制字=1 开始 time.sleep(0.5) # 等待预擦除完成 data = open(bin_path, 'rb').read() for offset in range(0, len(data), 224): chunk = data[offset:offset + 224] if len(chunk) < 224: chunk = chunk + b'\xff' * (224 - len(chunk)) # 补齐 # 写目标偏移 write_one_reg(SLAVE, 0x0004, offset & 0xFFFF, ser) write_one_reg(SLAVE, 0x0005, (offset >> 16) & 0xFFFF, ser) # 写数据缓冲区 regs = [int.from_bytes(chunk[i:i+2], 'big') for i in range(0, 224, 2)] write_regs(SLAVE, 0x0010, regs, ser) # 触发写入 write_one_reg(SLAVE, 0x0000, 2, ser) # 查询状态,确认写入成功 status = read_reg(SLAVE, 0x00E0, 1, ser) if status != SUCCESS: raise Exception('write chunk failed at offset %d' % offset) print('progress: %d%%' % (offset * 100 // len(data))) # 2. 结束升级,触发切换 write_one_reg(SLAVE, 0x0000, 3, ser)

注意补全数据时用0xFF而不是0x00,因为Flash擦除后是0xFF,未编程区域本来就是0xFF,这样补的字节不会影响固件有效性。如果补齐成0x00,这部分Flash实际上还是0xFF,CRC校验就会失败。

5.2 正常升级全流程演示

正常升级链路是这样一个闭环:

  1. 设备当前运行A分区固件v1.0。上位机通过App的0x00F0寄存器触发"进入Bootloader升级模式",设备软复位。
  2. Bootloader启动,识别到升级模式标志,初始化FreeModbus和OTA服务,向上位机返回"设备在线"状态。
  3. 上位机连接串口,读取新固件bin文件,先发送开始升级命令,目标分区选B。
  4. Bootloader擦除B分区全部页面。此时设备仍运行在Bootloader中,A分区固件原封不动。
  5. 上位机循环发送数据包,每包224字节。Bootloader接收后写入B分区,并实时更新进度状态。
  6. 全部发送完成后,上位机发送结束升级命令。Bootloader对B分区固件做全量CRC32校验,校验通过后置boot_flag=0xCC,然后跳转到B分区。
  7. B分区固件启动,IWDG开始计时。固件初始化完成,3秒后执行cli_ota_commit,boot_flag改为0xBB,boot_count清零。
  8. 下次复位时Bootloader看到boot_flag=0xBB,直接启动B分区。升级完成。

这个流程我在实际测试中,100KB固件在115200波特率下耗时约35秒,包括擦除和校验时间。相比JTAG烧写确实慢一些,但胜在可以远程操作,而且全程不需要打开机箱。

5.3 异常场景实测:断电中断、固件损坏、启动失败回滚

写升级系统不能只看正常流程,异常场景才是考验方案设计水平的地方。我实测了三种典型故障,结果都符合预期:

断电中断:升级包传到一半时给设备断电。重新上电后,Bootloader发现boot_flag不是0xCC,仍然启动A分区旧固件,设备正常工作。B分区里残留半个固件,但无伤大雅,下次升级时会被重新擦除覆盖。

固件损坏:用上位机人为修改bin文件中间几个字节,让其CRC校验不通过。结束升级时Bootloader计算CRC32,对比失败,拒绝切换分区,设备继续运行A分区固件。这里我把CRC32写在结束升级命令里,Bootloader对整个目标分区数据进行遍历计算,避免依赖单包校验的盲区。

新固件启动失败:故意让新固件在初始化时进入死循环。Bootloader切换后IWDG超时复位,再次进入Bootloader,发现boot_count=1,boot_flag仍为0xCC,继续尝试。连续3次失败后,Bootloader自动把boot_flag改回0xAA,启动A分区旧固件,设备恢复正常。

需要提醒的是,回滚后最好在参数区记录一个"上次升级失败"的状态,上位机查询时能看到。否则运维人员会困惑:明明升级成功了,怎么版本还是旧的。

6. 常见问题排查与避坑清单

6.1 典型故障速查表

我把复现过程中踩过的坑和同行反馈过的经典问题整理成一张速查表,方便你遇到问题时一眼定位。

现象直接原因解决办法
Bootloader跳转后黑屏/死机向量表偏移未设置或设置错误在跳转函数和App里都设置SCB->VTOR,确认偏移值正确
串口收不到Modbus响应FreeModbus定时器T35配置不对重算3.5字符时间,115200下tick小于0.3ms即可
升级到一半设备复位Flash擦除/编程时间过长,触发看门狗升级期间关闭IWDG,或加强刷新机制
数据包写入后校验不过Flash地址非2字节对齐或长度非偶数统一使用2字节对齐地址,数据长度补齐偶数
App跳转后进不了main函数代码链接地址与实际烧录地址不一致查看map文件确认Reset_Handler地址,核对IROM设置
升级次数多了参数区损坏参数区频繁擦写导致Flash寿命耗尽低频升级场景下问题不大,避免高频写参数区
新固件能启动但无法提交更新App提交逻辑被业务初始化阻塞cli_ota_commit移到main函数最前面
上位机发完包没有进入切换结束升级命令的CRC32不匹配统一用相同CRC32算法(IEEE 802.3,初始0xFFFFFFFF)比对

6.2 调试利器:map文件与串口日志联合分析

调试OTA这类涉及地址跳转的问题,最有效的两件工具是map文件和串口日志。

map文件前面提过,编译后一定要打开看。我特别关注三个符号:__initial_spReset_Handlermain。在Keil里,编译完成后双击.map文件,搜索这些符号,确认地址落在预期分区范围内。如果发现__initial_sp地址在0x20000000之前,或者和链接起始地址对不上,那App烧进去之后大概率直接硬件错误。

串口日志则要讲究层级。Bootloader的日志和App的日志建议共用同一个UART,但加上不同的前缀,比如[BT][APP]。这样即使程序在跳转后崩溃,你也能从串口输出判断代码执行到了哪个阶段。比如Bootloader打印[BT] jump to 0x0801D000,如果这条日志之后没有任何[APP]输出,说明App可能根本没跑起来。这时候再查向量表偏移和链接地址,定位很快。

如果条件允许,在Bootloader里加一个LED指示状态也是个好习惯。上电亮一下表示Bootloader正常,进入OTA模式闪烁,跳转前熄灭。这在大批量设备现场排查时,比接串口方便得多。

6.3 这个方案还能怎么扩展

AB OTA这套框架定型后,扩展方向很多,都是我在实际项目中接到过类似需求的:

  • 固件加密签名:目前协议里只有CRC32校验,防误码不防篡改。如果设备暴露在不可信环境,可以在固件包里加一段签名(比如AES-GCM标签或RSA签名),Bootloader在切换分区前验签,防止恶意固件或非法固件被刷入。
  • 多分区:AB两分区是基础版,有些产品需要额外分区承载出厂固件、语音包、字库文件。这套思想可以扩展成ABC甚至更多分区,只要状态机设计得当。
  • 从Modbus RTU换成其他链路:有些设备走CANopen或者以太网Modbus TCP,传输协议的替换不会影响AB分区的核心逻辑,Bootloader和App之间还是同样的Flash状态机。
  • 上报升级进度到云端:AB OTA的上位机如果对接云端,每包数据上报进度,就能实现"生产环境大批量设备分批升级"。

结尾

最后聊聊我个人的体会。做OTA方案,很多人一上来就急着写代码、组帧、烧板子,真正把分区和状态机想清楚的不多。我在反复迭代这套AB OTA架构之后,最大的心得是:通讯协议可以换,波特率可以提高,Flash驱动可以优化,但分区规划和回滚状态机是一开始就必须想明白的地基。地基歪了,后面再漂亮的应用代码也站不稳。

如果你打算在自己的项目里落地这套方案,建议先不要急着做上位机,而是先手工模拟整个流程:用串口助手手动发几条指令,观察Bootloader怎么擦除、怎么接收、怎么跳转,把每个环节都吃透,再写自动化脚本。这个过程会帮你建立对系统行为最直观的感觉。

还有一个小技巧,调试时可以做一个"强制进入Bootloader"的按键逻辑:上电时检测某个按键按下,就跳过App启动,直接进入OTA模式。这在开发调试阶段非常救命,哪怕App被刷坏了,你也能通过按键进入Bootloader,重新烧写固件。等整套流程稳了,再把按键逻辑换成纯粹的远程触发也不迟。

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

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

立即咨询