news 2026/9/11 10:27:14

STM32F103 AB分区OTA实战:Bootloader与回滚机制详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 AB分区OTA实战:Bootloader与回滚机制详解

这两年被OTA这个词快刷烂了,智能硬件、物联网设备、甚至充电头都在讲OTA。但我发现一个很有意思的现象:聊ESP32、Linux设备OTA的文章遍地都是,轮到STM32F103这种Cortex-M3老将,尤其是AB双分区这种自带回滚能力的方案,能讲清楚的不多。要么就是拿现成库一烧了事,要么就是只讲原理不给代码,真正想从零复现的人,往往卡在半路。

这篇文章我就把自己完整走通一遍的STM32F103 AB OTA方案拿出来拆开讲。从Flash分区规划、Bootloader跳转细节,到App侧的固件下载与校验、异常回滚机制,全部带代码和踩坑记录。如果你手头正好有一块F103最小系统板,想给它加上一个靠谱的、能自动回退的升级能力,这篇文章应该能帮你省下几个通宵。

我默认你用的是标准外设库(StdPeriph,就是那个V3.5),IDE用Keil MDK。为什么选这组合?因为F103的资料十有八九都是基于这套环境,后面我才单独说HAL库的移植差异。

1. 整体方案设计:为什么选AB分区,而不是常见的Boot+App单分区

1.1 单分区OTA的痛点,AB方案是怎么解决的

先聊聊传统的Boot+App方案。Bootloader放在起始地址,App放在后面一块固定区域。升级时Bootloader接收完整固件包,直接覆盖写入App区,写完跳过去执行。这个方案简单、占用Flash少,但有个致命问题:如果在写入过程中断电,或者固件包本身有问题、校验逻辑有疏漏,App区就变成一个写了一半的残废镜像。设备重启后Bootloader发现App校验不过,就只能干瞪眼,俗称“变砖”。

AB分区方案本质上就是多花一倍的App存储空间,换一个“永远有可用固件”的保障。它把用户程序区拆成两个对等的分区,比如A区和B区。Bootloader每次都从其中一个分区启动,升级时也只写另一个不活动的分区。当且仅当新固件写完、校验通过、并且能正常运行一段时间后,才把“启动标志”翻转过来。任何一个环节出问题,Bootloader都能回退到之前那个完好无损的分区。

这就像你电脑要装系统更新,不是直接覆盖当前系统,而是先在另一个分区装好,确认能开机了才切过去。Windows的Windows Update、安卓的A/B无缝升级,都是这个思想。对STM32这种Flash资源紧张、又没有MMU保护的MCU来说,AB分区虽然不是唯一解,但绝对是“稳定优先”场景下最不容易出错的一种。

1.2 整个系统的数据流和工作流程

先把完整流程捋一遍,后面所有的代码都是围绕这张流程图展开的。

正常启动时,Bootloader读取某个固定Flash地址上的分区状态标志。根据标志决定从分区A还是分区B加载App,做CRC校验后跳转。如果标志显示“上次升级没完成”,则自动回退到上一个健康分区,并把标志清理掉。

升级触发时,运行中的App通过网络(HTTP)或串口拿到新固件包。固件包先写入一个单独的“下载暂存区”,不直接动另一个分区。完整下载完成后,App置一个“请求升级”标志,然后软复位。Bootloader看到这个标志,就把暂存区的固件校验一遍,写入非活动分区。写入成功后,更新分区标志,跳转到新分区。新App启动后跑一段时间(比如30秒),如果没有异常复位,就把“确认”标志写回,这个升级才真正闭合。

为什么要加暂存区而不是直接写入非活动分区?两个原因。第一,网络下载是断断续续的,写到一半的分区不能作为启动候选,一旦复位就只能干等下载完成。第二,暂存区可以用一个简单的“帧序号+长度+CRC”做断点续传或者数据完整性校验,避免把垃圾数据直接刷进App区。

实际上F103的Flash资源很紧张,拿一块出来做暂存区并不宽裕。后面我会给一版更激进的做法:如果固件包实在大,暂存区和两个App分区合计放不下,可以砍掉暂存区、直接写非活动分区,但必须加帧校验和“写前擦除、写后读回”双重保护。本教程先讲带暂存区的稳妥方案。

1.3 为什么选STM32F103而不直接换颗大Flash芯片

F103现在看确实老了,主频72MHz,Flash最多512KB,RAM也就64KB。但选择它做OTA复现实操有一颗特别好的地方:Flash控制器简单、启动逻辑直白、几乎没有安全加密单元的干扰。你在它上面把AB OTA的流程吃透了,换到H7、G4甚至其他家的MCU,思路完全通用,区别只是寄存器和Flash驱动API。

我在实际项目里见过有人把F103从升级方案里剔除,理由是“资源不够”。但多数情况下不是资源不够,而是没把空间规划好。F103的512KB Flash,Bootloader给32KB,两个App区各给224KB,剩余好几十KB做参数区,这布局跑一个常见的物联网设备固件绰绰有余。工程上不是选最贵的芯片,而是把手头的芯片榨干,这也是我写这篇教程的初衷。

2. Flash布局与分区表设计:这一步错了后面全崩

2.1 具体分区规划和地址计算

F103的Flash起始地址0x08000000,按扇区划分,小容量芯片是1KB一扇区,大容量是2KB,中容量是1KB,实际擦写要以你的型号对应的数据手册为准。我这里按常见的512KB大容量型号举例,也就是STM32F103ZET6或RCT6这类,扇区大小为2KB。

我推荐的布局如下:

  • Bootloader区:0x08000000 ~ 0x08007FFF,共32KB
  • 分区A:0x08008000 ~ 0x0803FFFF,共224KB
  • 分区B:0x08040000 ~ 0x0807FFFF,共224KB
  • 下载暂存区:0x08080000 ~ 0x0809FFFF,共128KB
  • 参数与标志区:0x080A0000 ~ 0x080FFFFF,共384KB(实际用不到这么多,但留足余量)

眼尖的你会发现,两个App区各224KB加起来448KB,Bootloader 32KB,暂存区128KB,总和已经超过512KB。这是因为我这版布局是给1MB Flash的F103(比如ZET6)设计的。如果你用的是512KB芯片,有两种调整思路。

第一种,砍掉暂存区。固件包通过串口或HTTP接收时,边收边写入非活动分区,每个数据帧都带CRC,写完整个分区后再做一次全量校验。这种方案对固件包大小没额外要求,但对传输稳定性要求很高。

第二种,缩小分区。Bootloader压到16KB(足够放一个精简的串口驱动和Flash操作),两个App区各给192KB,暂存区给64KB,参数区留32KB。512KB总计刚好用完。

我自己调试时用的是1MB的芯片,分区宽裕,调试起来少了很多“空间不够”的干扰。建议你也先在开发板上配1MB型号。

2.2 分区表的结构体定义和存储策略

分区表不是写死在代码里的字符串,而是存放在参数区的一个结构体,Bootloader和App都要读它,保证两边对分区的理解一致。如果将来想给A/B区扩容,只需更新这个结构体,不用重新编译Bootloader。

我建议把分区表放在最后一片Flash的末尾扇区,理由是它最不可能被App和Bootloader的代码覆盖,而且可以通过单独的小工具在量产时烧写。

typedef struct { uint32_t magic; // 0x5A5AA5A5 用于校验结构合法性 uint32_t version; // 分区表版本,升级后递增 uint32_t bootloader_addr; // Bootloader起始地址 uint32_t bootloader_size; uint32_t app_a_addr; uint32_t app_a_size; uint32_t app_b_addr; uint32_t app_b_size; uint32_t staging_addr; uint32_t staging_size; uint32_t param_addr; uint32_t param_size; uint32_t crc32; // 整个结构体的CRC } partition_table_t;

Bootloader在启动时先读这个表,如果CRC验不过,就直接用代码里编死的“默认分区表”。这样即使参数区被意外擦掉,Bootloader也有保底方案。

实际写App时,我还会把编译时间、Git版本号、固件大小一并塞进App的头部结构体里,Bootloader在跳转前会打印出来,排查现场版本混乱特别有用。

2.3 为什么Bootloader区必须是32KB,不能更小

网上很多裸机Bootloader只要8KB、甚至4KB。但你要想清楚,8KB的Bootloader只能做一件事:检测标志、跳转。一旦你想加串口日志、加Flash校验打印、加恢复出厂固件功能,8KB立马捉襟见肘。我的建议是Bootloader至少预留24KB,最好32KB。按照Keil默认的编译配置,一个含串口重定向printf、Flash读写、CRC32校验、分区表解析的Bootloader,编译出来大概在14KB到20KB之间。32KB给你留了一半以上余量,后续想加功能不用重新规划整个Flash。

有人会问,Bootloader不也应该支持OTA吗?是的,理想情况下Bootloader也可以自我升级,但这就引入了“Bootloader+Optiboot”式的双Bootloader设计,复杂度直线上升。我在量产项目里一般把Bootloader看成只读系统,只在出厂时烧录,后续升级只动App分区——这套路最稳。

3. Bootloader实现:跳转逻辑、CRC校验和启动标志的实战代码

3.1 启动流程主逻辑

Bootloader的main函数极其简洁,心得是不要让Bootloader干太多事,它越简单越可靠。

int main(void) { uint8_t boot_ready = 0; uint32_t dst_addr = 0; SystemInit(); uart_init(115200); partition_table_load(); led_init(); printf("======== Bootloader v1.2 ========\r\n"); // 读取启动标志 boot_flag_t flag = boot_flag_read(); printf("boot flag: state=%d, target=%d, boot_count=%d\r\n", flag.state, flag.target, flag.boot_count); // 异常掉电恢复检查:上一轮升级可能没完成 if (flag.state == FLAG_STATE_COMMIT_PENDING) { // 目标分区的固件可能未写完或未校验,强制回滚 printf("upgrade not committed, rollback!\r\n"); flag.state = FLAG_STATE_NORMAL; boot_flag_write(&flag); } // 根据标志选择目标分区 if (flag.state == FLAG_STATE_UPGRADE_READY && flag.target == PARTITION_B) { dst_addr = partition_table.app_b_addr; } else if (flag.state == FLAG_STATE_UPGRADE_READY && flag.target == PARTITION_A) { dst_addr = partition_table.app_a_addr; } else { // 正常启动:从上次运行的标志位所在分区启动 if (flag.active_partition == PARTITION_A) { dst_addr = partition_table.app_a_addr; } else { dst_addr = partition_table.app_b_addr; } } // 目标分区固件头校验 app_header_t hdr; flash_read(dst_addr, (uint8_t *)&hdr, sizeof(hdr)); if (hdr.magic != APP_MAGIC || hdr.total_size == 0) { printf("bad app header, fallback to other partition!\r\n"); dst_addr = (dst_addr == partition_table.app_a_addr) ? partition_table.app_b_addr : partition_table.app_a_addr; } // 全量CRC校验(可选,但强烈建议保留) uint32_t crc = flash_crc32(dst_addr + sizeof(hdr), hdr.total_size); if (crc != hdr.crc32) { printf("CRC failed, fallback to other partition!\r\n"); dst_addr = (dst_addr == partition_table.app_a_addr) ? partition_table.app_b_addr : partition_table.app_a_addr; } // 跳转前关闭中断、失能SysTick,避免App启动时被打扰 __disable_irq(); jump_to_app(dst_addr); while (1); }

这段逻辑里有几个细节值得展开。

FLAG_STATE_COMMIT_PENDING这个状态是整个回滚机制的核心。我把App的“确认运行正常”设计成一个状态机:Bootloader写好新分区后,不直接切标志,而是置成COMMIT_PENDING。新App启动后跑满30秒,主动把标志置为NORMAL并刷新active_partition。如果这30秒内设备复位了,Bootloader看到COMMIT_PENDING就会知道“新固件还没确认”,自动回退。

CRC32校验虽然是全Flash扫描,在72MHz主频下也就一二百毫秒的事,完全可接受。但注意F103的硬件CRC外设只支持CRC32(以太网那个版本,不是标准CRC32),和软件计算的CRC32对不上,所以我直接用软件查表法算,编译时开-O2优化,速度没问题。

3.2 跳转函数的关键细节

跳转不是简单拿函数指针指过去,还要处理两个坑。

第一个坑是中断向量表。Cortex-M3的中断向量表默认在Flash起始地址0x08000000,但我们的App从0x08008000开始,向量表也得跟着搬。F103没有Cortex-M4/M7上那种VTOR寄存器,不能直接改向量表偏移地址。正确做法是在启动文件中,把SystemInit之后调用一个宏,把0x08008000的向量表复制到SRAM开头,然后把SRAM开头的地址映射为向量表基址。

#define VECT_TAB_SRAM #define VECT_TAB_OFFSET 0x20000 // 对应0x08000000 + 0x20000 void jump_to_app(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)app_addr; uint32_t app_pc = *(volatile uint32_t *)(app_addr + 4); // 检查栈顶指针是否在RAM范围内 if ((app_sp & 0xFFF00000) != 0x20000000) { printf("invalid stack pointer 0x%08X\r\n", app_sp); return; } // 重新设置MSP为App的栈顶 __set_MSP(app_sp); // 在SRAM中准备新的向量表 uint32_t *vt = (uint32_t *)(0x20000000); for (int i = 0; i < 48; i++) { vt[i] = *(volatile uint32_t *)(app_addr + i * 4); } SCB->VTOR = 0x20000000; // 把VTOR指到SRAM // 跳转 void (*jump)(void) = (void (*)(void))app_pc; jump(); }

STM32F103的SCB->VTOR寄存器在Cortex-M3中是存在的,可以直接写SRAM地址,这是最常见的做法。不过我见过有人把向量表放Flash末尾实现,也行,只是在Bootloader里要从Flash读每个中断向量并填充到SRAM,逻辑完全一样。

第二个坑是中断屏蔽。跳转前必须关闭所有外设中断、滴答定时器,更重要的是清掉PendSV和SysTick的挂起。如果带着活跃的中断上下文跳过去,App启动大概率hardfault。我习惯在跳转前把RCC时钟树里除了必要的GPIO、USART外全部复位,让App有一个干净的起点。

3.3 升级时的固件搬移函数

Bootloader真正干的重活是upgrade_firmware()。它从暂存区按扇区读数据,先擦除目标分区扇区,再写入,再读回比对。

int upgrade_firmware(uint32_t staging_addr, uint32_t dst_addr, uint32_t total_size) { uint32_t offset = 0; uint8_t buf[2048]; uint32_t dst_sector_start, dst_sector_end; while (offset < total_size) { uint32_t len = (total_size - offset) > sizeof(buf) ? sizeof(buf) : (total_size - offset); flash_read(staging_addr + offset, buf, len); dst_sector_start = (dst_addr + offset) / 2048; dst_sector_end = (dst_addr + offset + len - 1) / 2048; for (uint32_t sec = dst_sector_start; sec <= dst_sector_end; sec++) { if (flash_sector_erased(sec) == 0) { flash_erase_sector(sec); } } flash_write(dst_addr + offset, buf, len); // 读回校验,每写一扇区就确认一次 uint8_t verify_buf[2048]; flash_read(dst_addr + offset, verify_buf, len); if (memcmp(buf, verify_buf, len) != 0) { printf("flash verify failed at offset 0x%08X\r\n", offset); return -1; } offset += len; } return 0; }

注意扇区擦除的选择条件。我写的是“先判断是否已擦除,没擦才擦”,这是为了省时间,Flash擦除一次要几十毫秒,整片擦几次就能累计到秒级。另外,F103的Flash写操作前必须确保对应扇区是空的,否则会报编程错误。上面代码在写之前统一擦除涉及的扇区,就是为了规避这个问题。

如果闪存写入失败,可以读取FLASH->SR寄存器的错误位,定位是写保护还是对齐问题。不过日常调试中,最常见的是扇区擦除做漏了。

4. App侧实现:固件下载、写入暂存区、置标志、软复位

4.1 App的Flash驱动注意点

App侧不能直接用标准库的FLASH_ProgramHalfWord写大块数据,速度太慢,而且容易在中断嵌套时出问题。我一般把Flash驱动独立成一个模块,只保留三个函数:整扇区擦除、半字编程、读。App升级模块调用它们,再加上一个简单的环形缓冲,从串口或网络收数据,边收边写。

在App里操作Flash有一个必须处理的点:如果你的App代码本身存放在0x08008000,而暂存区起始地址在0x08080000,两者不重叠,这是硬件设计上最基础的保障。如果你把暂存区放在Bootloader区域,那是灾难,App跑着跑着把自己底下的Bootloader擦了,这是新手最容易犯的错误。

4.2 固件包接收协议

我规定固件包总长不超过暂存区大小,格式如下:

  • 包头固定16字节:魔数0xABAB、版本号4字节、固件长度4字节、CRC32 4字节、保留4字节
  • 数据段:按帧传输,每帧1KB,帧头含2字节序列号,帧尾含2字节CRC16
  • 包尾:包含整包CRC32的重复值,用于二次校验

App的接收逻辑用状态机实现,这样任何一帧丢包、错序,都能在状态机层面挡住。

typedef enum { RX_WAIT_HEADER, RX_WAIT_FRAME, RX_WAIT_TAIL, RX_ERROR } rx_state_t; void ota_task(void) { while (1) { int byte = uart_get_byte(); if (byte < 0) break; switch (rx_state) { case RX_WAIT_HEADER: // 收满16字节,解析出total_len、crc32 break; case RX_WAIT_FRAME: // 检查帧号是否连续,写入暂存区对应偏移 // 不连续就置RX_ERROR,重新等待包头 break; case RX_WAIT_TAIL: // 收到包尾后,比对CRC,通过则进入升级提交 break; } } }

网络传包和串口传包可以做成同一个状态机,只不过字节来源从UART的RX中断改成HTTP回调。我在教程里先基于串口讲,因为不用处理TCP分包粘包,专注理解状态机本身。

4.3 升级提交与软复位

固件包完整收完、CRC通过后,App不能立刻复位。它必须先把暂存区的固件头解析出来,再全量校验一次暂存区的CRC,确保暂存区数据不是“半截货”。全部通过后,写FLAG_STATE_UPGRADE_READY标志,指明目标分区(当前运行的是A,目标就是B;当前是B,目标就是A),然后调用NVIC_SystemReset()软复位。

void ota_commit(void) { // 1. 再次校验暂存区整包CRC uint32_t crc = crc32_region(partition_table.staging_addr, current_ota_pkg_size); if (crc != current_ota_pkg_crc) { printf("staging CRC mismatch, abort upgrade.\r\n"); return; } // 2. 更新启动标志 boot_flag_t flag = boot_flag_read(); flag.state = FLAG_STATE_UPGRADE_READY; flag.target = (flag.active_partition == PARTITION_A) ? PARTITION_B : PARTITION_A; boot_flag_write(&flag); // 3. 软复位 NVIC_SystemReset(); }

注意boot_flag_write内部要先擦除标志所在扇区再写,因为Flash不能原地改写。标志区最好是单独一个扇区,不要让标志和业务参数放一起,否则每次写参数都会触发擦除,影响寿命。

4.4 App的“升级确认”机制

这是AB OTA里最容易被忽略但最关键的环节。Bootloader把新App跑起来之后,它怎么知道要不要回滚?答案是App自己举手说自己活着。

我在App的主循环里放了一个30秒定时器。如果App能连续运行30秒不重启,就写一次确认标志:

void app_boot_check(void) { static uint32_t last_tick = 0; if (uptime_ms - last_tick >= 30000) { boot_flag_t flag = boot_flag_read(); if (flag.state == FLAG_STATE_COMMIT_PENDING) { flag.state = FLAG_STATE_NORMAL; flag.active_partition = flag.target; boot_flag_write(&flag); printf("OTA confirmed.\r\n"); } last_tick = uptime_ms; } }

如果你确认一个产品“代码绝不会有问题”,可以把这个时间缩短到1秒。但我还是建议保留,因为有时候不是代码问题,是外部晶振没起来导致跑飞,确认机制能兜住这类偶发故障。

5. 固件包管理和本地验证:别让上位机拖后腿

5.1 用Python脚本生成带包头和CRC的bin文件

写嵌入式固件的人往往重下位机、轻上位机。但实际上OTA链路有一半的坑是在打包和传输阶段埋下的。我写了个Python脚本,把Keil编译出来的axf转成bin,然后添加上面说的包头,计算CRC,输出可发布的固件包。

#!/usr/bin/env python3 import struct import zlib import sys APP_MAGIC = 0xABABABAB def pack_firmware(input_bin, output_bin, version): with open(input_bin, 'rb') as f: data = f.read() crc = zlib.crc32(data) & 0xFFFFFFFF hdr = struct.pack('<III4x', APP_MAGIC, version, len(data), crc) # 这里用4x占4字节保留字段 with open(output_bin, 'wb') as f: f.write(hdr) f.write(data) # 补齐到2KB对齐,方便暂存区写入 if len(data) % 2048 != 0: f.write(b'\xff' * (2048 - len(data) % 2048)) print(f"pack ok: {len(data)} bytes, crc=0x{crc:08X}") if __name__ == '__main__': pack_firmware(sys.argv[1], sys.argv[2], int(sys.argv[3]))

注意我写的是zlib.crc32。如果你Bootloader和App里用的也是标准CRC32查表法,两边结果一致。如果用的是其他多项式,比如CRC32C,必须在上位机里同步改算法,这个错位很难查。

调试时建议在Bootloader启动日志里把固件包里的版本号打出来,和上位机生成的版本号一一对应,能省掉大量“我到底有没有刷进去”的猜测。

5.2 串口传输工具和半自动测试方法

开发阶段,我不建议直接上HTTP,先用串口把整个链路打通。原因很简单:串口是字节流,协议的边界和错误处理更好调。等串口链路稳定了,再在网络层替换数据源。

我调试时常用一个简单的Python串口发送器,把固件包按1KB一帧发给设备,每帧等ACK再发下一帧。这能模拟网络环境中的丢包重传,也方便测试断线恢复。

import serial import time ser = serial.Serial('COM10', 115200, timeout=1) with open('app_v3.bin', 'rb') as f: pkg = f.read() FRAME_SIZE = 1024 seq = 0 for i in range(0, len(pkg), FRAME_SIZE): chunk = pkg[i:i+FRAME_SIZE] frame = struct.pack('<HH', 0xAAAA, seq) + chunk + struct.pack('<H', crc16(chunk)) ser.write(frame) ack = ser.read(4) if ack != b'OKOK': print(f"resend seq {seq}") ser.write(frame) # 重发一次 seq += 1

“每帧等ACK”这个设计在生产环境中我会去掉,因为成本太高。但在开发阶段保留它,能一帧一帧地观察设备端状态机的行为,效率高得多。

5.3 用Nginx搭一个最简单的固件下载服务器

当你确认串口链路没问题,就可以上HTTP了。这里给一个最精简的Nginx配置,把固件目录暴露出来即可。F103不带操作系统,HTTP客户端得自己写,或者用cURL库裁剪。但如果你用的模块自带TCP/IP协议栈,比如AT指令的Wi-Fi模块、4G模块,那其实更简单,模块先下载文件到内存或流式转发给MCU,MCU专注做Flash写入。

server { listen 8080; server_name _; location /fw/ { alias /var/www/firmware/; default_type application/octet-stream; autoindex on; } }

设备端请求http://server:8080/fw/app_v3.bin,按Content-Length分块读取,每块写入暂存区。这里有个注意点:HTTP响应的分块数据长度不是固定的,可能和TCP包边界不一致,你的接收状态机必须按“累计长度”而不是“包长度”来切割。

6. 常见问题与排查技巧实录

6.1 跳转后App跑飞/卡死

这是复现AB OTA时遇到最多的问题。排查路径按这个顺序走:

第一步,查栈顶地址是否正确。跳转代码里我特意加了app_sp的地址范围检查,如果App的Linker脚本起始地址和Bootloader里的偏移不一致,这里很容易暴露。

第二步,查中断向量表有没有生效。在App的main函数第一行打个串口打印,如果打印都不出来,说明压根没跳过去,或者跳过去后向量表错乱。可以在Bootloader跳转前把App所在的Flash起始4个字节和App里SystemInit附近的字节打印出来,人工确认。

第三步,查外设状态。跳转前如果没有禁用SysTick、PendSV、UART中断,App初始化时可能被残留中断打断。我见过的一个经典案例是:Bootloader里开了串口中断接收升级指令,跳转前没关,导致App启动后一直进串口中断,主循环跑不出来。

第四步,查Linker脚本。App的分散加载文件里ROM起始地址改了,但中断向量表偏移没改;或者改了中断向量表偏移,但启动文件里SystemInit后没有执行VECT_TAB_OFFSET。这两个是F103移植里最容易犯的错误。

6.2 固件写了一半断电,重启后设备反复进入Bootloader

这个现象其实是正常策略,但如果你发现它一直卡在Bootloader可升级阶段、不恢复,大概率是COMMIT_PENDING状态没有被正确清除。你的App侧确认机制应该放在“main函数启动后经过一段稳定运行时间”后执行,而不是放在中断里,否则一次误中断就会提前确认。

还有一种情况是Bootloader每次启动都因为CRC校验失败而回退,回退到另一个分区后再次校验又失败。这通常是两个App分区都没有完成过完整升级导致的。排查方法是把App烧到A区后,先不要走OTA逻辑,直接在Bootloader日志里看它能不能正常跳到A区。如果连这一步都过不了,问题在分区地址或者烧录方式,不在OTA逻辑。

6.3 Flash写入报编程错误

F103的Flash编程每次必须半字(16位)对齐,长度也必须是偶数。如果你从文件读出的固件长度是奇数,在写入最后半个字节时就会报错。解决方法是打包阶段自动补齐到偶数,甚至直接补齐到2KB对齐,省心。

另一个常见错误是写入前忘了擦除。F103的编程操作只能在擦除后的Flash上进行,如果目标扇区有数据,会触发PGERR标志。我建议在每次写入前都检查目标地址是否处于已擦除状态,必要时先擦再写。

6.4 CRC校验总是失败

如果Bootloader的CRC和上位机算的CRC对不上,先检查字节序。通常固件包是小端,CRC算完之后按小端存储在包头里,Bootloader读出来也是小端,对齐就没有问题。有人喜欢在打包脚本里用struct.pack('>I')转成网络字节序,到了MCU端忘了转回来,就会造成永远校验不过去。

排查时可以先用一个固定字符串做CRC测试,比如对"123456789"算标准CRC32,结果应该一致。如果上位机用的zlib和MCU端用的查表法对不上,用这个标准测试向量立刻能查出来。

6.5 固件包体积压缩

F103的Flash有限,固件太大时OTA就变得难做。两个思路:用压缩算法传输固件,到MCU边解压边写入。LZ4或MiniLZO在MCU上都很常见,解压几十KB的固件只要几百毫秒。另一个思路是差异化升级,只传输增量部分,复杂度和风险都高,不推荐新手尝试。

我在量产项目里通常选LZ4,理由是解压代码占用少、CPU开销低,而且压缩率对固件代码有不错的收益。但注意,压缩后的固件在暂存区里也是压缩状态,Bootloader搬移时必须保持原样,等到跳转前解压到目标分区再校验。这样暂存区大小也能压缩。

6.6 双Bank还是单Bank?

严格来说,F103本身没有硬件双Bank概念,双Bank是H7、G4这类新芯片才有的。F103的AB OTA更准确地说是“两个逻辑分区+一个Bootloader”,完全靠软件实现。所以你在网上看到别人写STM32F103双Bank,多半也是软件分区,不是硬件双Bank。

这带来一个影响:F103不能在运行A分区代码的同时安全地写B分区吗?其实可以,只要两个分区不重叠,Flash编程和代码执行是可以并行的。换句话说,A分区运行时,可以让B分区接收新固件,写完后复位进Bootloader完成切换。这跟我们前面的流程是一致的。

7. 实操总结

按照这套流程,在开发板上从零复现一轮AB OTA,我大约用了两个晚上。第一晚把Bootloader和Flash分区跑通,能跳转、能回滚;第二晚补上App侧的下载状态机和确认机制,实现完整的“写A区、升级B区、确认、再回退”闭环。

如果用一句话总结这套方案的特点,就是:用Flash空间换稳定性。AB OTA不是资源最紧张MCU的首选,但它是故障现场最容易排查、上线后最有底气的一种升级方案。你想省Flash可以,那就必须在传输校验、异常恢复上投入更多精力,否则一次升级事故的代价比省下那几十KB的Flash高得多。

我个人的建议是,如果你第一次做STM32的OTA,先从串口+暂存区+AB分区的组合开始,别一上来就搞网络传输和压缩解压。把这个闭环吃透以后,再逐步把网络层、加密认证、双分区自动扩展加进来。每一次只改一个变量,出了问题是最好定位的。

最后说一个小技巧。调试AB OTA时,在Bootloader里保留一个跳线检测,跳线短路时强制进入Bootloader的串口命令行模式。这个模式可以手动执行“擦除分区”“全量拷贝”“强制切换分区”等指令,调试效率能提升一个量级。量产时把这个跳线功能留着也别删,放一个隐藏的串口指令就行,万一现场设备出了问题,至少还有一扇后门能打开。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 10:26:02

GGX法线分布函数:原理、实现与优化策略

1. GGX法线分布函数的前世今生2007年&#xff0c;Bruce Walter等人首次提出了GGX&#xff08;现称Trowbridge-Reitz&#xff09;分布函数&#xff0c;彻底改变了基于物理的渲染&#xff08;PBR&#xff09;领域的光照模型格局。这个看似简单的数学公式背后&#xff0c;蕴含着对…

作者头像 李华
网站建设 2026/9/11 10:22:40

Temu跨境电商实战陪跑哪里可靠?以结果为导向,不是卖课程的那种

先回答标题里的问题&#xff1a;市场上确实存在以结果为导向的实战陪跑&#xff0c;但它和卖课程的机构长得太像了&#xff0c;普通人很难分辨——两边都叫培训&#xff0c;都在讲Temu多赚钱&#xff0c;价格还差不多。这篇文章讲清楚两者的本质区别在哪、可靠的陪跑长什么样、…

作者头像 李华
网站建设 2026/9/11 10:21:31

Disruptor在Spring Boot中的正确集成:高性能内存队列原理与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 10:20:42

51单片机入门指南:从点灯到项目实战的嵌入式底层之路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 10:20:18

Wand-Enhancer 指南:如何用本地补丁免费解锁高级功能

Wand-Enhancer 指南&#xff1a;如何用本地补丁免费解锁高级功能 【免费下载链接】Wand-Enhancer Advanced UX and interoperability extension for Wand (WeMod) app 项目地址: https://gitcode.com/GitHub_Trending/we/Wand-Enhancer 你盯着 Wand 界面上那个灰掉的&qu…

作者头像 李华
网站建设 2026/9/11 10:20:11

工业多协议设备监控系统架构与优化实践

1. 多协议设备监控系统的核心价值 工业现场的设备监控系统就像医院的监护仪&#xff0c;需要实时采集各种"生命体征"。传统单协议方案如同只用听诊器检查心跳&#xff0c;而多协议系统则相当于同时配备心电图、血氧仪和血压计的全套监测方案。我在某智能制造项目中&a…

作者头像 李华