做嵌入式最怕的不是代码写不完,而是设备已经装到现场、塞进配电柜里,突然发现固件逻辑有个低级问题要改。你总不能拎着下载器翻山越岭去刷机吧。OTA升级就是解决这个问题的:让设备通过串口或网络收到新固件,自己完成更新,不拆壳、不断线,甚至不耽误产线运行。这篇文章用STM32F103这颗老当益壮的芯片,从零复现一套AB双分区OTA方案。升级链路走串口,通信风格带点Modbus RTU的调调,Bootloader负责引导和接收固件,两个App区互为备份,正常升级、中途断电、固件损坏、回滚恢复这些场景我会挨个讲透。适合想把产品OTA能力落地、或者刚开始接触IAP、Flash分区、引导跳转的开发者参考。
先说结论:F103没有硬件双Bank,也没有独立的OTA控制器,但靠“Bootloader + 两片App区 + 参数区”这种逻辑分区方式,照样能做出很稳的AB升级。整个方案不依赖RTOS、不带外部Flash、不搞加密,一个标准库v3.5工程就能跑起来。我会把分区地址为什么这么算、跳转前为什么要关中断、Flash为什么必须整页擦、App改了地址为什么就能跑这些原理全部拆开讲。
1. 方案选型:为什么是AB分区
1.1 几种常见OTA方案的差距
先梳理一下主流的几种升级思路,免得选型时踩坑。
第一种是单区覆盖式升级。Bootloader只管从串口/网口收包,直接覆盖当前App区。优点是简单,缺点是升级过程中一旦掉电,当前App区可能是半截残废,设备就变砖了。这种方案在玩具层面能玩,产品上基本不敢用。
第二种是“出厂备份回滚式”。Flash里除了Bootloader,还有一个出厂固件区。升级只覆盖运行区,失败时Bootloader从出厂区恢复。这种方式比单区进步不少,但缺陷也很明显:只能回滚到出厂固件,等于只给你一次反悔机会。如果产品迭代到第三四个版本,出问题时可能想回到上一个稳定版,而不是远古出厂版。
第三种就是我们今天要做的AB双分区。Flash里同时放两个完整的App镜像,一个叫A包,一个叫B包。Bootloader决定启动哪个。平时在A区跑,升级时往B区写新固件,写完校验通过后切换启动到B区;如果B区跑不起来,Bootloader自动回滚到A区。AB包这个名字就是这么来的,你也可以把A理解为“当前运行区”,B理解为“备用升级区”。
还有一类进阶玩法是差分升级,用bsdiff之类的算法只传输新旧固件的差异部分,省带宽省Flash。但对F103这种178MHz都跑不到的Cortex-M3来说,解差分包要么放在上位机做、要么得有相当大的RAM缓冲,新手不建议直接上。全量AB包虽然传输量稍大,但逻辑最稳,出错面最小。
1.2 F103的Flash资源和限制
F103不同型号的Flash页大小不一样,这是新手最容易搞错的地方。中密度器件如C8T6,每页1KB;高密度器件如ZET6,每页2KB。做AB分区前一定要先查自己芯片的手册,别看型号都叫F103就觉得一样。
我这个示例以STM32F103ZET6为例,它是高密度,512KB Flash、64KB RAM,页大小2KB,Flash编程最小单位是16位半字,擦除最小单位是一整页。擦写寿命大约一万次,听起来不多,但对OTA场景足够用了——一个产品生命周期内升级几十次已经算重度。需要注意,写Flash时CPU会被阻塞等待操作完成,如果代码正好从被擦除的页里取指,会出大问题。所以擦写代码必须放在Bootloader区,擦写目标只能是对应的App区,Bootloader区自身绝对不能碰。
另外一个隐藏限制是读保护。如果你在产品上开了RDP读保护,App区对调试器和Bootloader的访问策略会完全改变,轻则写Flash报错,重则直接锁定芯片。做OTA的产品,在RDP开启时想从Bootloader升级,需要提前规划“临时解除读保护但不擦除用户数据”的选项字节流程。这个知识点在《STM32F103中文参考手册》的Flash章节写得比较绕,先记住结论:初期开发阶段别开读保护,把AB流程跑通再说。
1.3 整体架构和数据流
这套方案最核心的架构就是四段式划分:
| 区域 | 起始地址 | 大小 | 页数(2KB/页) | 作用 |
|---|---|---|---|---|
| Bootloader | 0x08000000 | 64KB | 32 | IAP引导、串口升级、校验、回滚 |
| APP_A | 0x08010000 | 192KB | 96 | 出厂运行区(A包) |
| APP_B | 0x08040000 | 192KB | 96 | 备用运行区(B包) |
| 参数区 | 0x08070000 | 64KB | 32 | 分区选择标志、版本、启动计数 |
四段加起来正好512KB。这个布局不是唯一答案,但很经典。Bootloader给64KB不是浪费,因为如果你后续要集成FreeModbus v1.6、CAN驱动甚至加密验签,代码量很容易突破32KB。App区各分192KB,看起来比一般工程大不少,但AB方案要求两个App必须是完整镜像,每个镜像可以从自己分区起始地址独立编译运行,所以App区空间不能抠得太死。参数区我给64KB,主要是为了Flash磨损均衡和冗余设计留余地,基线方案其实一页2KB就够用。
数据流也简单:设备上电,Bootloader先跑,读参数区判断该跳A还是跳B;如果收到升级指令,就收包、擦除目标App区、写入新固件、CRC校验、置位待启动标志、复位;复位后再启动对应App。这个流程里,Bootloader是唯一的“交通警察”,所有切换决策都发生在参数区,不在App里,这样App崩了也能恢复。
2. Flash分区与内存布局
2.1 为什么要按这个地址切分
我见过不少新手自己画分区表,看哪块空就放哪,结果地址不对齐、向量表错位、编译出来的App跳转就挂。分区的第一原则是:每个区间起始地址必须按Flash页大小对齐,同时要按App向量表可能占用的最大长度对齐。F103的向量表有68个中断向量左右,每个4字节,总共不到300字节,但Flash页对齐要求更硬。ZET6每页2KB,起始地址如果落在0x08010000这种地址上,天然就是2KB的整数倍,没问题。
我还喜欢把各分区大小尽量设成“好记的整块”。0x08010000减去0x08000000等于0x10000,也就是64KB,正好对应Bootloader;0x08040000减去0x08010000等于0x30000,也就是192KB,正好对应APP_A;0x08070000开始是参数区。为什么Bootloader用64KB而不是32KB?因为以后你还可能往里加Modbus从站协议、升级日志、密钥校验这些代码,30KB左右的Flash放不下的场景我见多了,干脆一步到位。
2.2 分区计算示例
假设你手里的产品实际App编译出来只有60KB,那192KB的App区是不是浪费?从本质上看,浪费的只是Flash空间,升级时传输的固件包大小还是按实际bin长度算,并不会因为你把分区划大了就多传数据。但有一点要注意:每个App区必须能容纳它自己编译出来的完整镜像,也就是说你编译给B区的固件如果超过192KB就崩了。编译完务必看一眼.map文件或者生成的bin文件大小。
具体操作时,Keil里改两处。一是Target页的IROM1起始地址,A区工程填0x08010000、Size填0x30000,B区工程填0x08040000、Size同样填0x30000;二是从工程里生成bin文件,在User页的After Build/Rebuild里加一行:
fromelf --bin --output=.\Output\app_b.bin .\Output\app_b.axf生成之后自己去资源管理器看一眼bin大小,超过0x30000就说明App超限了。这里顺便说一句:网上那些所谓“OTA提取器”、从设备里把固件抠出来的工具,跟从零开发OTA没有半毛钱关系,固件一定是IDE编译出来的bin,不存在“提取”一说。
2.3 APP中断向量表重定向原理
这一节是整个方案最容易翻车的地方,也是很多教程语焉不详的地方。
Cortex-M3上电后,CPU从地址0x08000000取出初始栈指针,从0x08000004取出复位向量,然后开始执行。这没问题,因为Bootloader烧在0x08000000。但如果你跳转到了0x08010000的App,App的向量表也在0x08010000,CPU却还默认去0x08000000取中断向量。此时一旦来了串口中断或定时器中断,CPU会抓取Bootloader向量表里对应位置的内容,然后拿着一个莫名其妙的地址去跳转,结果就是死机。
解决办法是设置VTOR寄存器,把向量表基址从0x08000000重定位到0x08010000。F103用的是Cortex-M3内核,VTOR寄存器是真实存在的,地址在0xE000ED08,直接用标准库的NVIC_SetVectorTable就行。关键操作放在App的main函数最前面,甚至放在启动文件里的SystemInit之后越早越好:
#include "stm32f10x.h" int main(void) { // 必须在初始化外设之前重定位向量表 NVIC_SetVectorTable(FLASH_BASE, 0x10000); // APP_A // NVIC_SetVectorTable(FLASH_BASE, 0x40000); // APP_B // 然后才是时钟、串口、外设初始化... }如果你用的是Keil + 标准库v3.5,也可以直接在启动文件startup_stm32f10x_hd.s里把VECT_TAB_OFFSET改成0x10000或0x40000,这样SystemInit内部会自动设置VTOR。两种方式选一种即可,但要注意:B区App一定要用0x40000这个偏移量重新编译,不能把A区编译出的bin直接丢到B区,那样向量表、绝对跳转地址全部指向A区,跑起来必死。
3. Bootloader设计与实现
3.1 Bootloader主流程
Bootloader的整体逻辑并不复杂,我习惯把它做成一个带超时检测的引导状态机。上电后先初始化时钟和串口,然后读参数区,根据启动标志决定是直接跳App还是进入升级模式。
核心流程如下:
- 初始化时钟、串口、Flash接口。
- 读参数区,解析magic、当前启动区、启动计数、升级就绪标志。
- 若升级就绪标志有效,先校验目标区固件完整性(栈顶、复位向量、CRC)。
- 校验通过则跳转到目标区;不通过则回退到另一个区。
- 若没有升级标志,则默认跳转当前应启动的区。
- 在跳转之前,打开一个“串口升级窗口”,一般等待500ms到2s。窗口期内收到主机发来的握手/升级命令,就留在Bootloader执行升级,不再跳App。
这个等待窗口非常关键。没有它,设备上电瞬间你根本没机会打断正常启动流程。有了窗口,你可以通过串口随时让设备进Bootloader。当然窗口时间也不能太长,否则设备每次开机都延迟1秒才进系统,体验很差。产品上如果对启动时间有要求,就把窗口压到200ms,靠上位机主动抢发指令。
3.2 跳转APP的关键代码
跳转函数是Bootloader的“心脏”。网上能搜到各种版本,我用的这个版本加了栈指针和Thumb位检查,可靠性高很多,避免跳到非法地址后进HardFault。
typedef void (*pFunction)(void); #define APP_BAK_ADDR 0x08010000UL /* 分区A */ #define APP_NEW_ADDR 0x08040000UL /* 分区B */ int check_app_valid(uint32_t addr) { uint32_t stack_top = *(volatile uint32_t *)addr; uint32_t reset_vec = *(volatile uint32_t *)(addr + 4); /* 栈顶指针必须在SRAM范围内 */ if ((stack_top & 0xFFF00000) != 0x20000000) { return 0; } /* 复位向量必须是Thumb指令,最低位置1 */ if ((reset_vec & 1) == 0) { return 0; } return 1; } void jump_to_app(uint32_t addr) { uint32_t stack_top; pFunction app_entry; if (!check_app_valid(addr)) { return; } stack_top = *(volatile uint32_t *)addr; app_entry = (pFunction)(*(volatile uint32_t *)(addr + 4)); /* 跳转前关闭全局中断,复位SysTick,关闭外设时钟 */ __disable_irq(); SysTick->CTRL = 0; RCC_DeInit(); /* 清除所有NVIC挂起中断 */ for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } __set_MSP(stack_top); app_entry(); while (1); }这里的门道有几个。第一,检查栈顶指针是否指向0x20000000那段SRAM区间,能筛掉大量无效地址;第二,检查复位向量最低位是否为1,因为Cortex-M3必须工作在Thumb模式;第三,跳转前关中断、关SysTick、复位外设时钟,是为了避免App启动时遇到残留的NVIC挂起中断,Gate一个莫名其妙的外设中断进来直接把App打懵。
3.3 Flash擦写函数实现
F103没有硬件双Bank,所以升级时只能在线擦写目标App区。擦写操作必须放在Bootloader里做,因为App区代码在执行时也不能擦自己。标准库的Flash接口在stm32f10x_flash.c里,使用起来很直接。
void flash_erase_page(uint32_t page_addr) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); if (FLASH_ErasePage(page_addr) != FLASH_COMPLETE) { /* 擦除失败处理 */ } FLASH_Lock(); } void flash_write_buf(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t *half_word = (uint16_t *)buf; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (uint16_t i = 0; i < len / 2; i++) { if (FLASH_ProgramHalfWord(addr + i * 2, half_word[i]) != FLASH_COMPLETE) { /* 写入失败处理 */ } } FLASH_Lock(); }重点注意三件事。一是擦除和写入前必须调用FLASH_Unlock,否则写保护会直接拦掉;二是高密度ZET6的页是2KB,擦除地址必须2KB对齐,擦0x08010000没问题,擦0x08010100就是非法参数;三是程序写的是半字,但上位机发下来的数据是字节流,我在实际项目里会让上位机保证每帧数据长度为偶数,不够就补0xFF,省得在Bootloader里做字节拼接。这种做法看似简单,却能省掉不少调试时间。
3.4 Bootloader里加Modbus RTU的扩展思路
如果你的产品本来就用RS485加Modbus RTU通信,完全可以把这套升级协议包装成Modbus自定义功能码。FreeModbus v1.6是现成的开源协议栈,移植到F103标准库很方便,它已经把CRC、地址匹配、帧超时处理都做好了。Bootloader里只需要注册几个功能码处理函数:比如0x44擦除请求、0x45写入请求、0x46校验切换请求,外部上位机看起来就是在跟一个普通Modbus从机通信,实际内部正在升级固件。
这样做的最大好处是复用。产线已有的Modbus主机、测试工具、远程透传链路,不用做任何改动就能升级设备。缺点是需要控制Bootloader代码量,FreeModbus本身有几KB占用,再加上协议处理和Flash驱动,64KB的Bootloader空间依然够用,但再往上加协议就有点吃力了。
4. 升级通信协议设计:串口 + 自定义帧
4.1 通信方式选择
升级通道我选了串口,具体硬件可以是板上UART直连CH340,也可以是RS232、RS485。F103的USART驱动是成熟方案,串口协议调试也方便。如果你想做远程升级,后面可以把串口这层换成ESP8266透传、4G模组或者CAN总线,Bootloader的逻辑不用大改,收包、写Flash、校验这套核心流程完全复用。
协议层面,我推荐在Bootloader里实现一套简洁的二进制帧,不走AT指令这种文本协议。文本协议虽然肉眼可读,但解析效率低、长度容易超、CRC处理也麻烦。二进制帧加CRC16,解析快、可靠性够、实现代码不超过一百行。
4.2 自定义二进制帧格式
我用的帧格式如下:
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | 帧头 | 2字节 | 固定0xAA 0x55 |
| 2 | 长度 | 1字节 | 数据区字节数 |
| 3 | 命令 | 1字节 | 0x01~0x06 |
| 4 | 数据区 | N字节 | 具体命令参数 |
| 4+N | CRC16 | 2字节 | Modbus CRC16,仅计算到数据区结束 |
命令定义如下:
| 命令 | 方向 | 数据区 | 说明 |
|---|---|---|---|
| 0x01 握手 | 主机→设备 | 无 | 设备返回当前运行区、版本号 |
| 0x02 开始升级 | 主机→设备 | 目标区(1B)、总长度(4B)、版本(4B) | 设备返回ACK,启动Flash准备 |
| 0x03 写数据 | 主机→设备 | 偏移(4B) + payload(N≤240B) | 设备收到后返回本帧CRC16应答 |
| 0x04 结束校验 | 主机→设备 | 整包CRC32(4B) | 设备对已写入flash的数据做CRC32校验 |
| 0x05 切换启动 | 主机→设备 | 目标区(1B) | 设备写参数区并复位 |
| 0x06 查询状态 | 主机→设备 | 无 | 返回状态码 |
协议设计的核心点在于“边收边写”。设备端每收到一帧写数据,就按偏移直接写入对应Flash地址,不攒缓冲区。这样哪怕一次只发240字节,也能写完一个几十KB的固件。因为AB方案有一个天然安全垫:写入的目标区即使写到一半断电,也不影响另一区启动,下次上电Bootloader发现目标区固件不完整,会直接放弃它,继续启动旧区。
4.3 上位机打包脚本
这里放一个最精简的Python打包脚本,它负责把编译好的bin文件加上自定义头部,生成我们需要的升级包文件。头部我习惯用一个“FWUP”魔数加版本、长度、CRC32、目标区,5个字段各4字节,总共20字节。
import struct import zlib import sys def make_pkg(bin_path, out_path, version=0x0100, target=1): with open(bin_path, 'rb') as f: data = f.read() # 补偶数长度,避免F103半字写入问题 if len(data) % 2: data += b'\xFF' crc = zlib.crc32(data) & 0xFFFFFFFF header = struct.pack('<4sIIII', b'FWUP', version, len(data), crc, target) with open(out_path, 'wb') as f: f.write(header + data) print(f'out={out_path}, size={len(header) + len(data)}, crc32=0x{crc:08X}') if __name__ == '__main__': make_pkg(sys.argv[1], sys.argv[2])设备端收到整个升级包后,并不需要校验头部那20字节,真正要校验的是固件体CRC32。Bootloader在边收边写的过程中,对每个字节更新一次CRC32,全部写完后和设备端已经收到的整包CRC32比对。注意PC端zlib.crc32的计算方式:初始值为0xFFFFFFFF,最终结果异或0xFFFFFFFF。设备端必须用同样的初始值、同样的尾异或,否则两边算出来的CRC永远对不上。这个坑我帮你们踩过了,真的容易忽略。
4.4 设备端CRC32实时计算
设备端做CRC32校验,我推荐用查表法,但为了代码简洁,先用逐位法也能跑。升级速度要求不高时,几十KB的固件逐位算也就几百毫秒,完全可接受。
uint32_t crc32_update(uint32_t crc, uint8_t data) { crc ^= data; for (int i = 0; i < 8; i++) { if (crc & 1) { crc = (crc >> 1) ^ 0xEDB88320UL; } else { crc >>= 1; } } return crc; }每次写数据帧时,都要对payload里的每个字节调用这个函数累加CRC,最后在0x04命令时和上位机传下来的CRC32做比对。这套逻辑我建议封装成一个“升级上下文”结构体,里面记录当前写入偏移、累计写入长度、累计CRC当前值、目标分区等,别用全局变量满天飞。
5. APP端改造:让旧固件能被跳转
5.1 Keil工程里的地址修改
App端的改造是很多教程一笔带过、但实际最容易出错的地方。拿APP_A工程举例,打开Keil的Options for Target,Target标签页里IROM1起始地址写0x08010000,Size写0x30000,这个必须和分区表严格一致。APP_B工程则把起始地址改成0x08040000。RAM地址一般不用改,F103的SRAM基地址就是0x20000000。
改完之后,一定要重新编译全部代码,不要只改地址不重编,那等于没改。编译完打开生成的.map文件,搜索“Base_Address”或者“Execution Region”,确认RO段的基地址已经变成0x08010000,说明链接器确实把代码放到App区了。
5.2 向量表重定向代码放置位置
关于向量表,正文前面已经提过原理,这里强调一下代码位置:应该在App初始化外设之前执行。最稳妥的方式是放在main函数的第一行,甚至在标准库的SystemInit之后紧接着执行。如果你用的标准库v3.5,系统启动文件里默认定义VECT_TAB_OFFSET为0,你去startup_stm32f10x_hd.s里把它改成0x10000,SystemInit内部会自动设置VTOR。
两个App用同一个工程模板时,建议用宏来控制偏移,比如:
#if defined(APP_B) #define APP_VECT_OFFSET 0x40000 #else #define APP_VECT_OFFSET 0x10000 #endif NVIC_SetVectorTable(FLASH_BASE, APP_VECT_OFFSET);这样编译A包和B包只需要在Keil的C/C++里预定义宏,不用每次手改代码。
5.3 运行状态上报与自动回滚
AB分区不光是能把新固件切过去,还要能证明“新固件确实能跑起来”。我在参数区里放了两个关键字段:启动计数boot_count和运行状态app_ok。Bootloader每次跳转前会把目标区的boot_count加1,App启动后如果经过基础初始化、主循环能正常跑起来,就主动去参数区写一个app_ok标志,并把boot_count清零。
假设新固件本身有问题,上电就死机,那么app_ok永远不会被写,看门狗复位后Bootloader发现boot_count已经超过阈值(比如3),就判定这个区“带不动”,自动回滚到另一个区。这是整套方案能自称“安全OTA”的最后一块拼图,缺了它只能算“能切换”,不能叫“可回滚”。
参数区的读写也要小心。Flash不能像EEPROM那样改一个字节,必须整页擦除再写。所以参数区我建议设计成多个有效槽位,每次写新状态前先写一个“脏标志”,然后擦除整页,再写入新的有效数据。否则你每次升级都在擦一个特定页,那一页的寿命会被快速消耗。
6. 实操流程:从编译到一次完整升级
6.1 工程搭建与工具准备
我实际用到的环境如下:Keil MDK5、STM32标准库v3.5、STM32F103ZET6核心板、ST-Link烧录器、一个USB转TTL模块,还有Python3配合串口库。标准的F103最小系统板都够用,不需要额外硬件。把这些准备齐了就可以开工。
先建Bootloader工程,再建App工程,两个工程共用标准库文件。Bootloader工程不需要跑业务逻辑,只做串口、Flash、分区判断。App工程就是你自己的业务代码,按前文说的改好地址和向量表偏移。注意两个工程的编译输出必须区分开,别烧错文件,我第一次实操就把B区App烧到了A区地址,结果跳转后直接HardFault。
6.2 首次烧录与出厂固件
首次烧录建议用ST-Link手动完成,别一上来就玩串口升级。步骤是:
- 编译Bootloader,用ST-Link烧到0x08000000。
- 编译APP_A,用ST-Link烧到0x08010000。
- 编译APP_B,用Python脚本打成一个升级包文件,暂时放PC上备用。
- 打开串口助手,复位设备,观察Bootloader的窗口等待和跳转日志。
如果一切正常,设备会上电后先打印几行Bootloader信息,然后跳到A区,串口里出现App的启动打印。到了这一步,说明Bootloader跳转和App向量表重定向都没问题,这是整个方案的地基。
6.3 通过串口执行一次完整升级
把PC当作上位机,用串口助手或者Python脚本发升级帧。我习惯用Python脚本做全流程自动化,体验和产品化更接近。
一次成功升级的日志大概是这样的:
[BL] boot start, wait 500ms... [BL] recv cmd=0x01, cur_app=A, fw_ver=1.0 [BL] recv cmd=0x02, target=B, total=56320 [BL] erase page 0x08040000 ok [BL] write offset=0x0000 len=240 crc=0x2A3B [BL] write offset=0x00F0 len=240 crc=0x77C1 ... [BL] recv cmd=0x04, verify crc32=0x8E4F2A11, ok [BL] recv cmd=0x05, set boot area=B, reset [APP_B] boot from 0x08040000, report OK看到[APP_B]的打印,说明整个升级链路已经通了。这时候再去检查参数区,current_run_area已经变成B,boot_count清0,app_ok置1。
6.4 模拟升级失败和回滚
升级链路通了之后,一定要做一次失败演练。我的做法是故意用脚本把升级包的CRC32改错一个字节,让设备写完固件后校验失败。此时设备应该不会切换启动区,而是继续停留在Bootloader,上报校验失败状态。之后再故意制造“新固件能写入但启动即死机”的现场,方法很粗暴:把一个空数组作为App或者让App入口直接while(1)。Bootloader切到B区后会发现boot_count不断累加,超过阈值后自动回到A区。这个过程能真实检验你的回滚逻辑是不是可靠。
6.5 升级过程中突然断电会怎样
AB方案最让人踏实的一点就在这里。我在开发板上做过二十多次随机断电实验:写A区写到一半拔电,再上电,Bootloader判断A区固件不完整或CRC不对,直接退回B区;写B区写到一半拔电,则在A区继续照常运行。因为参数区的“待启动分区标志”是最后才置位的,没写完固件前根本不会切区。这就是为什么AB分区比单区覆盖先进那么多,真正做到了“升级失败不伤现有系统”。
7. 常见问题与排查技巧
7.1 跳转后直接HardFault
这个现象太典型了,排查顺序我建议按下面来。先看Keil的IROM1起始地址是不是和分区表一致,A区必须是0x08010000,B区必须是0x08040000;再看App里有没有设置VTOR,没设置的话中断一来就死;然后用调试器单步到jump_to_app函数,检查取出来的栈顶指针是否落在0x20000000区间,复位向量最低位是否为1。还有一个经常被忽略的点:Bootloader跳转前关了全局中断,但如果你还在中断回调里打印日志,那打印会永远发不出去,这不是代码死机,是流程问题。先关外设再关中断,最后再跳转。
7.2 Flash擦写失败
如果FLASH_ProgramHalfWord返回值不是FLASH_COMPLETE,优先看FLASH_SR寄存器里的WRPRTERR位。置位说明Flash被写保护了,最常见原因是开了读保护,或者忘了调用FLASH_Unlock。其次检查页对齐,ZET6每页2KB,擦除地址必须2KB对齐。还有一种隐蔽问题:如果你的Bootloader本身很大,已经超过了分区表里给Bootloader预留的区域,那么擦写App区时可能会擦到Bootloader的尾巴,这种行为极其危险,会让Bootloader程序错乱,必须通过分区表预留足够空间来避免。
7.3 升级中掉电后变砖
变砖这个说法对AB方案来说基本不适用,因为总有一个区是完整的。如果真出现上电无法运行,大概率是Bootloader本身被覆盖或损坏了,这也再次印证了Bootloader区不应该参与OTA的基本纪律。我见过有人图省事,让Bootloader也通过网络升级,结果升级失败把引导程序写废,整机变砖,只能开壳用烧录器恢复。如果确实需要升级Bootloader,建议用双Bootloader或者Bootloader放只读区这种更高阶方案,别拿单Bootloader冒险。
7.4 串口升级窗口超时怎么办
上电后Bootloader只等几百毫秒,上位机手速不够就错过了。解决办法两个方向:一是把等待时间拉长到1秒甚至2秒,但这会影响启动时间;二是上位机持续发握手帧,等设备复位后立刻响应。实际现场调试时我通常把窗口调到2秒,产品量产前再缩回500ms。另外串口波特率要统一,擦写Flash期间Bootloader处于阻塞状态,上位机收不到应答不要立刻重发,可以把单帧超时设置到100ms以上,避免连环重发把缓冲区打满。
7.5 回滚误判
App明明正常运行,但因为业务初始化很慢,迟迟没写app_ok标志,Bootloader误判为“启动失败”然后回滚。解决方法是把写app_ok的时机放在主循环开始的早期,比如定时器初始化完成后就写,不要等网络连接、文件系统挂载、业务逻辑全部起来再写。还有一个细节:启动计数阈值建议设3次,避免因为看门狗偶发复位就误回滚。
8. 后续还能怎么扩展
这套AB OTA跑通之后,你会发现它最大的价值不只是升级,而是给了整个设备一个安全重启的底座。我在产品上把串口链路替换成CAN接口后,逻辑完全没变,Bootloader照样收包、校验、切换;再往后还可以把链路上移成Wi-Fi模块透传,远程升级就能落地。每次换通道,Bootloader里最多改一个驱动层,分区状态机和回滚逻辑不用动。
如果要进一步提升安全性,可以加固件签名验签。F103算力有限,跑非对称加密比较吃力,可以考虑用HMAC-SHA256做摘要校验,或者在服务器端签名、设备端只验证固定长度的签名摘要。这类防护主要防固件在传输过程中被篡改,对正规产品来说属于加分项,不是必需品。先把AB分区、ESP8266透传、CRC校验、回滚机制这套基础打牢,比什么都重要。我自己带项目的习惯是:新方案先在开发板上跑十遍完整升级,再故意做二十次断电实验,确认怎么折腾都不会变砖,才敢往量产设备上引。嵌入式这行,稳比快重要,OTA更是如此。