STM32F103 AB双分区OTA远程升级实战:Bootloader引导与回滚机制
2026/9/10 5:20:59 网站建设 项目流程

做嵌入式开发,最头疼的问题之一就是产品出货之后发现Bug。代码改动本身不难,难的是设备已经在客户手里用着了,总不能让售后挨个拆机、开盖、连烧录器。我以前遇到这种情况基本只能认栽,直到把OTA升级这套东西梳理清楚,尤其是AB双分区方案,才算真正解决了这个收尾难题。

这次要复现的项目是STM32F103 + AB双分区 + OTA远程升级。STM32F103这颗芯片大家都很熟,Cortex-M3内核、主频最高72MHz、Flash最大512KB,虽然不是什么新料,但胜在便宜、稳定、资料多,至今仍然大量活跃在物联网终端、工业控制板、智能硬件和小家电板卡上。AB分区的思路也不复杂:Flash里同时存放两个应用分区,一个作为当前运行区,一个作为备用升级区,Bootloader统一负责引导和切换,升级失败还能自动回滚。这套组合既能防“升级变砖”,又给后续功能迭代留出了足够的操作空间。

这篇文章会从零开始,把AB OTA的完整复现过程写清楚,包括Flash分区规划、Bootloader引导逻辑、App端升级跳转、串口传输通道搭建,以及我实际踩过的坑和对应的排查方法。适合刚开始接触Bootloader和OTA的嵌入式开发者,也适合想给现有STM32F103项目加上远程升级能力、但一直没找到完整参考的硬件工程师。

1. 整体设计思路:为什么是AB双分区

1.1 从“返厂刷机”到远程升级

先聊一个现实问题:设备在现场运行一段时间后,发现串口偶发死机、功耗异常、通信协议需要变更,这时候你手里只有一个已经发出去的产品。没有OTA能力的设备,只能走售后流程,要么返厂,要么让工程师带着烧录器和笔记本电脑到现场。一台两台还好,设备数量上了量级,这个成本完全不可接受。

所以OTA本质上解决的问题是“交付之后的维护成本”。对STM32F103这类MCU来说,OTA并不是什么高不可攀的功能,它的Flash容量足够大,比如ZET6有512KB,完全可以划出两个应用区加一个Bootloader区,只要设计得当,远程升级、失败回滚都不成问题。

1.2 单分区OTA与AB双分区的对比

有些方案为了省Flash,只做“Bootloader + 单一App区”,升级时直接用新固件覆盖App区。这种方式结构简单、Flash占用少,但存在一个致命弱点:升级过程如果断电、传输中断、写入出错,App区就是一个残缺状态,设备直接变砖。而且这种变砖没办法自己恢复,必须把设备拿回来重新烧录。

AB双分区方案则是在Flash里放两个App区,常见的叫法有AppA / AppB或者Slot0 / Slot1。运行时只跑其中一个分区,升级时把新固件写入另一个空闲分区,写完以后校验整包CRC,确认无误再切换启动目标。整个过程即使中途掉电,最多只是浪费了一次升级机会,旧App仍然完整保留,下次上电还能正常启动。

用生活化的类比:单分区方案就像家里只有一辆车,车送去修的时候你就没车可用;AB方案等于有两辆车,一辆正在开,另一辆在车库备用,换胎、保养都不影响日常出行。

1.3 AB OTA的整体运行流程

我用文字描述一下完整流程,不看图也能把逻辑理顺:

设备正常状态下,Bootloader引导进入AppA,AppA完成业务功能。需要升级时,上位机或者云端平台通过串口、CAN、RS485、网络等通道,把新固件分包发送给设备。AppA收到完整固件包后,写入AppB分区,写完校验CRC32,并在Flash标志区记录“下次从AppB启动”,然后复位。

设备重启后进入Bootloader,Bootloader读取标志区,发现要求启动AppB,再对AppB分区做一次CRC校验,通过则跳转进入AppB。AppB启动后正常运行,确认业务稳定后,再向标志区写入“当前运行分区=AppB”,下一次升级时就会往AppA写入新固件,形成交替升级。

这里还需要一个“回滚”机制。如果AppB启动后立刻死机、反复重启,Bootloader就要有能力发现异常,自动切回AppA。常见的做法是启动计数:App每次启动先把计数器加1,正常运行一段时间后清零;Bootloader发现计数器超过阈值,就认为当前App不稳定,强制切换回另一个分区。

1.4 Flash分区规划与容量评估

整个规划的第一步,也是最容易出错的一步,就是Flash分区。

以STM32F103ZET6为例,它有512KB Flash,地址范围是0x08000000到0x0807FFFF。我采用的分区方案是这样:

分区名称起始地址结束地址大小用途
Bootloader0x080000000x08007FFF32KB引导程序、Ymodem接收、CRC校验
AppA0x080080000x0803FFFF224KB常规运行分区
AppB0x080400000x0807EFFF252KB备用升级分区
标志区0x0807F0000x0807FFFF4KB升级标志、版本号、运行计数

这里说明几个选择理由。Bootloader放32KB是足够用的,一个带串口Ymodem收发、Flash写、CRC校验的Bootloader,代码体积通常不会超过20KB,留一点余量。AppA和AppB给到200KB以上,足以覆盖绝大多数业务代码,哪怕以后要加FreeRTOS、加协议栈、加UI界面,空间也够。标志区放在Flash末尾、独立成区,原因是要保证擦写App分区时不会误伤标志区数据。如果标志区和App区挨得太近,擦除页的时候边界稍微算错,就会把升级状态一起擦掉,导致Bootloader无从判断该启动谁。

还要注意一个细节:STM32F103不同容量的型号,Flash页大小不一样。大容量型号(256KB及以上)每页2KB,中容量型号(64KB/128KB)每页1KB。写Flash擦除函数时,一定要按实际页大小来擦,否则会多擦或少擦,多擦的后果就是把相邻分区的代码抹掉。

2. 核心细节解析:Bootloader引导与回滚机制的实现

2.1 启动过程与中断向量表偏移

STM32F103上电后,硬件会从0x08000000读取栈顶地址(MSP初始值),然后从0x08000004读取复位向量,再跳转到复位向量地址执行。Bootloader烧录在0x08000000,所以上电后首先执行的是Bootloader代码。

Bootloader要做的事本质上很简单:判断该启动哪个App,校验App区数据,跳过去。但跳转这件事有几个核心细节,很多人第一次写都会在这里翻车。

最关键的细节就是中断向量表。Cortex-M3内核通过“向量表”来确定中断服务函数的入口地址。默认情况下,硬件认为向量表在0x08000000,也就是Flash起始地址。如果把App放在0x08008000,那么App的中断向量表也在0x08008000,此时就必须把向量表偏移到0x08008000,否则中断一旦发生,CPU还是会去0x08000000找向量表,找到的却是Bootloader的向量,轻则串口中断不工作,重则直接HardFault。

Cortex-M3内核自带VTOR寄存器,地址是0xE000ED08,可以通过往这个寄存器写入新的向量表基址来实现偏移。我在App工程的main函数开头,会直接执行这样一句:

SCB->VTOR = APP_A_ADDR;

如果用的是标准外设库V3.5.0,也可以在system_stm32f10x.c文件里修改VECT_TAB_OFFSET宏,但那种方式对AB双分区不太灵活,因为你编译AppA和AppB时要分别改偏移量。我更推荐在main函数里根据条件动态设置,或者干脆用编译宏区分。

2.2 升级标志区与Flash记录设计

标志区是整个AB升级流程的“记忆中枢”。它记录了三件事:下一次要从哪个分区启动、新固件的版本号、当前分区是否稳定运行。

我在Flash末尾设计了一个简单的结构体,用于存储升级信息:

#define MAGIC_FLAG 0xA5A5A5A5 #define FLAG_BASE_ADDR 0x0807F000 typedef struct { uint32_t magic; // 固定魔数,用于判断标志是否有效 uint32_t boot_target; // 0x01=AppA,0x02=AppB uint32_t fw_version; // 固件版本号 uint32_t fw_length; // 固件长度 uint32_t fw_crc32; // 固件CRC32 uint32_t boot_count; // 启动计数,用于失败回滚 uint32_t reserved[2]; // 保留字段 } UpgradeFlag;

magic字段非常关键。Flash擦除后所有字节都是0xFF,如果直接读取结构体的magic去判断,很可能会把“空标志”误判成有效标志。加入魔数之后,只有等于0xA5A5A5A5才认为标志区有效。

boot_count是实现“启动失败自动回滚”的基础。App启动成功后,会把boot_count清零;App如果起不来,boot_count不会被清零,Bootloader每次启动时把它加1,超过比如5次,就判定当前分区异常,强制切换到另一个分区。

这里有个技巧:boot_count加1的操作要在Flash里完成,而Flash有擦写寿命限制,不能频繁写。所以Bootloader里可以设置一个阈值,只有boot_count小于阈值时才去改写Flash,超过阈值就直接切换分区。这样能有效减少Flash擦写次数,延长芯片寿命。

2.3 跳转函数怎么写才不会HardFault

跳转是Bootloader最核心的动作,也是最容易出问题的地方。直接给出我验证过的跳转函数:

typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_stack = *(volatile uint32_t *)app_addr; pFunction app_entry = (pFunction)(*(volatile uint32_t *)(app_addr + 4)); // 检查栈顶地址是否在RAM范围内,防止跳到非法地址 if ((app_stack & 0xFFF00000) != 0x20000000) { return; } // 跳转前关闭全局中断,并复位外设状态 __disable_irq(); SysTick->CTRL = 0; HAL_NVIC_DisableAllIRQs(); // 设置中断向量表偏移 SCB->VTOR = app_addr; // 设置主栈指针并跳转 __set_MSP(app_stack); app_entry(); while (1); }

这个函数里有几个细节值得注意。

第一,校验栈顶地址的范围。STM32F103的RAM地址空间是0x20000000起始,如果读出来的“栈顶地址”不在这个范围,说明这个地址上根本没有有效的固件,不能跳转。

第二,跳转之前必须关闭全局中断和SysTick。App在启动时会重新初始化时钟和中断系统,但如果在跳转瞬间还有外设中断挂起,CPU进入App后可能直接触发异常。我踩过的一个典型坑就是串口DMA还在传输,跳过去之后DMA中断把新App的向量表读错位置,直接HardFault。

第三,设置MSP的顺序。要先设置SCB->VTOR,再设置MSP,最后调用入口函数。如果顺序颠倒,中断向量表指向旧地址但栈指针已经是新的,一旦中间产生异常,回来时就找错了处理函数。

2.4 CRC校验与版本号策略

OTA升级必须保证写入的固件包是完整且正确的,CRC就是最后一道防线。

我使用的是标准CRC32算法,用查表法实现,计算速度很快。上位机在发送固件包之前,先对整个bin文件做一次CRC32计算,并把结果放在包头里一起发送。设备端收到完整数据后,先对Flash中的固件数据重新计算CRC32,再和包头里携带的值对比,完全一致才允许切换分区。

版本号策略也很重要。建议使用uint32_t表示的简单顺序版本号,比如V1.0.0映射为0x00010000,高16位是主版本,低16位是次版本和修订号。Bootloader或App在升级前可以先比较版本号,防止把旧固件刷到新固件上。虽然这不算强制要求,但在实际项目中能避免不少低级失误,尤其是调试阶段经常会发生“传错文件”的情况。

3. 实操过程:从零搭建AB OTA完整工程

3.1 硬件与环境准备

我复现用的硬件是ST官方的STM32F103ZET6核心板,Flash 512KB,串口1通过USB转TTL模块连接到电脑。这套配置的好处是Flash够大、引脚全部引出、调试方便。如果你手里只有C8T6(64KB Flash)或RCT6(256KB Flash),也可以做AB OTA,但分区大小要按实际Flash容量重新规划,App区会比较紧张。

软件方面,我用的是Keil MDK 5.x,配合STM32F103标准外设库V3.5.0。标准外设库虽然比较老,但在这个场景下足够稳定,而且网上能找到的资料非常多,遇到问题容易搜索到解决方案。如果你更习惯用STM32CubeMX + HAL库,也可以,核心逻辑完全相同,只是Flash操作和串口初始化的函数名会有些差异。

还需要准备的资料包括:STM32F103中文参考手册(查寄存器和Flash操作)、启动文件startup_stm32f10x_hd.s(大容量芯片用HD版本)、以及一个支持Ymodem协议发送的串口工具(SecureCRT、MobaXterm都行)。

3.2 Bootloader工程搭建

Bootloader工程相对独立,不依赖任何App代码。新建Keil工程,选择STM32F103ZET6,Flash起始地址保持0x08000000,大小保持0x80000,不需要修改。

Bootloader需要实现以下几部分功能:

  • 串口1初始化,波特率115200,用于接收固件数据;
  • Flash擦写函数,负责往AppA或AppB分区写入数据;
  • CRC32校验函数;
  • Ymodem协议接收逻辑,用于从PC端接收固件包;
  • 启动判断逻辑,读取标志区,决定跳转目标。

Bootloader的main函数大致长这样:

int main(void) { UART_Init(115200); upgrade_flag_read(&flag); if (flag.magic == MAGIC_FLAG) { if (flag.boot_target == TARGET_APP_B) { if (check_crc32(APP_B_ADDR, flag.fw_length, flag.fw_crc32)) { jump_to_app(APP_B_ADDR); } else { // CRC失败,回退到AppA flag.boot_target = TARGET_APP_A; upgrade_flag_write(&flag); jump_to_app(APP_A_ADDR); } } else { if (check_crc32(APP_A_ADDR, flag.fw_length, flag.fw_crc32)) { jump_to_app(APP_A_ADDR); } else { jump_to_app(APP_B_ADDR); } } } else { // 标志区无效,默认尝试AppA jump_to_app(APP_A_ADDR); } // 如果走到这里说明两个App都无法启动 // 进入Ymodem接收模式,等待重新烧录 ymodem_receive(APP_B_ADDR); while (1); }

这个逻辑有两层保险:正常情况下根据标志区跳转;如果两个分区都校验失败,Bootloader会进入Ymodem接收模式,等待上位机重新发送固件。换句话说,即使AB分区全部损坏,也能通过串口把Bootloader重新“激活”,不会彻底变砖。

3.3 App工程编译地址修改

App工程是在现有业务代码的基础上改出来的,但有两处必须修改,否则App无法在偏移后的地址运行。

第一处是Keil的Target选项卡。如果是AppA,IROM1的起始地址改成0x08008000,大小改成0x38000(224KB)。如果是AppB,IROM1起始地址改成0x08040000,大小改成0x3F000(252KB)。RAM地址保持0x20000000不变,除非你有特殊的内存布局需求。

第二处是中断向量表偏移。在main函数最开头加上:

SCB->VTOR = APP_ADDR;

这里的APP_ADDR要根据当前编译的是AppA还是AppB来定义。我习惯在Keil的C/C++选项卡里定义预处理宏,比如AppA工程定义APP_A,AppB工程定义APP_B,然后在代码里条件编译。

修改完这两处,App代码本身基本不需要大改。编译、烧录到对应地址后,App就能在跳转过来的情况下正常运行。

3.4 升级传输通道:Ymodem与简单分帧

传输通道是OTA的“最后一公里”,它负责把固件包从PC端传输到设备端。我首选Ymodem协议,原因是几乎所有主流串口工具都内置了Ymodem发送功能,不需要额外开发上位机,调试时非常省事。

Ymodem协议本身带128字节或1024字节的数据块、块编号、CRC16校验,传输可靠性有保证。在Bootloader里实现Ymodem接收,本质上就是解析串口数据帧,把有效载荷数据写入Flash指定地址。帧格式不复杂,网上有很多现成的参考实现,也可以直接用正点原子、野火等开发板例程中的ymodem.c,稍作修改即可。

如果不想用Ymodem,也可以设计一个更简单的分帧协议。比如每帧固定256字节,帧头是0xAA55,接着是帧序号、数据、CRC32,上位机逐帧发送,设备端逐帧写入Flash。这种方式的好处是协议透明、容易扩展,配合自己写Python脚本非常方便。下面是一个简化版的Python发送端核心逻辑:

import serial import struct import zlib def send_firmware(port, bin_path, chunk_size=256): with open(bin_path, 'rb') as f: data = f.read() crc = zlib.crc32(data) & 0xFFFFFFFF total_len = len(data) # 发送包头 header = struct.pack('<III', 0xA5A5A5A5, total_len, crc) ser.write(header) # 分帧发送数据 seq = 0 for i in range(0, total_len, chunk_size): chunk = data[i:i+chunk_size] frame = struct.pack('<HH', 0xAA55, seq) + chunk frame += struct.pack('<I', zlib.crc32(frame) & 0xFFFFFFFF) ser.write(frame) seq += 1 time.sleep(0.002)

这个脚本只是个示例,实际使用时还要加应答机制和超时重传,避免串口丢包导致固件不完整。

3.5 生成带包头bin文件

App编译后生成的是.axf或.hex格式,但OTA传输需要裸的bin文件。Keil里可以通过fromelf命令把axf转换成bin:

fromelf --bin --output app.bin .\Objects\app.axf

转出来的bin文件可以直接使用。如果需要在bin文件前面加自定义包头(包含版本号、长度、CRC32等信息),可以用Python脚本处理:

import struct import zlib with open('app.bin', 'rb') as f: fw_data = f.read() version = 0x00010001 crc = zlib.crc32(fw_data) & 0xFFFFFFFF header = struct.pack('<III', version, len(fw_data), crc) with open('app_ota.bin', 'wb') as f: f.write(header) f.write(fw_data)

设备端在接收时先解析包头,再根据包头里的长度接收后续数据,这样即使传输过程中出现异常,也能提前判断数据长度是否合理。

3.6 首次烧录与完整升级验证

整个流程验证我一般分三步走:

第一步,先用ST-Link把Bootloader烧进0x08000000,再把AppA编译生成的hex烧进0x08008000。注意烧录器烧录时要选择正确的起始地址,不要用默认的全片擦除模式,否则会把Bootloader一起擦掉。

第二步,上电后观察串口日志,确认Bootloader成功跳转到AppA,App正常运行。这一步能验证跳转函数、向量表偏移是否都正确。

第三步,回到PC端,用SecureCRT连接设备串口,进入Ymodem发送模式,选择AppB编译生成的bin文件(或者带包头的app_ota.bin),发送给设备。设备接收完成后,自动校验CRC、写标志区、复位。复位后再观察日志,确认Bootloader跳转到了AppB,AppB运行正常。

如果一切顺利,此时设备已经在AppB上运行,而此时Flash里的AppA仍然保留着旧固件。以后再升级,就会往AppA写入新固件,从而形成AB交替升级。

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

4.1 跳转后HardFault

这是Bootloader + App最常见的问题,原因通常集中在三处:

第一,App的IROM1起始地址没有改对。如果App仍然编译在0x08000000,但跳转地址写的是0x08008000,跳过去执行的第一条指令就是0xFFFF或者其他随机数据,不HardFault才怪。

第二,跳转前没有关闭外设中断。DMA、定时器、串口中断在跳转瞬间挂起,新App的中断向量表还没初始化完成,异常一来就找不到处理入口。解决方法是跳转前执行__disable_irq(),并把SysTick停掉。

第三,向量表偏移没有设置。即使App运行在了正确的地址,如果SCB->VTOR没有指向App地址,中断依然会去0x08000000找向量表,导致无法正常工作。注意,向量表偏移要在App的main函数里第一时间设置,越早越好,最好在进入main后的第一句就执行。

4.2 App串口中断失灵

现象很典型:App能运行,LED闪烁正常,但只要一用串口收发数据,程序就跑飞或者完全没有反应。

这种问题最常见的根因就是中断向量表偏移没设置或者设置太晚。App启动过程中,初始化代码执行了SystemInit,但SystemInit不会自动帮你设置VTOR到0x08008000。如果main里没显式设置,串口接收中断发生时就找不到ISR,程序直接异常。

排查技巧:在App的main函数第一行设置SCB->VTOR = APP_ADDR;,然后用调试器在线调试,看App全局变量的地址是否在0x20000000范围内、中断处理函数是否位于App区地址范围。如果中断处理函数地址指向了Bootloader区,那么一定是链接脚本或向量表偏移出了问题。

4.3 升级中途失败如何保证不砖

这是AB方案最有价值的地方,也是很多人在设计时容易忽略的细节。

升级过程可以大致分成两个阶段:数据写入阶段和分区切换阶段。数据写入阶段,新固件被写入AppB,此时AppA完全不受影响,即使写入一半断电,AppA还是完整的,设备上电后Bootloader判断AppB校验失败,自动跳回AppA。分区切换阶段,标志区记录“下次从AppB启动”,如果这个标志写入成功,但AppB实际上无法运行,就需要启动回滚策略兜底。

所以我的建议是,boot_count回滚机制一定要实现,而且Bootloader跳转App之前要完全校验目标分区的CRC,而不是只看标志区的记录。实践下来,这套组合拳能让设备在绝大多数异常场景下保持可用。

4.4 Flash擦写跨区问题

这是分区规划时最隐蔽的坑。我最初规划AppA大小为224KB,擦除AppA时直接按“从0x08008000开始连续擦到0x0803FFFF”来算,结果中途发现STM32F103大容量型号的Flash页大小是2KB,擦除时必须按页对齐。如果某次擦除的结束地址多算了一页,就会把AppB开头的区域一块擦掉,而当时AppB里存着上一版固件,直接把回滚兜底给毁掉了。

后来我写了一个页对齐的辅助函数,每次擦除前先打印出起始页和结束页,确认无跨区后再执行擦除。这个习惯直到现在还在用,尤其是OTA调试阶段,日志里能看到每次Flash操作的地址范围,能省下很多排查时间。

4.5 CRC校验失败但传输明明没问题

有时候传输过程看起来很顺利,数据也能收完,但CRC就是校验不过。我排查过几次,原因基本都是包头里的CRC计算范围和设备端不一致。上位机对整个bin文件算CRC,设备端写入时包含了自定义包头,结果两边掐的不是同一段数据。

解决方案是明确CRC的计算范围。我在设计协议时规定:设备端在写入固件数据时,只对“纯固件数据区”计算CRC,不包括包头;如果上位机发送的是带包头的文件,则设备端先解析包头、剥离包头,再对后面的数据部分计算CRC。两边只要对齐这个规则,CRC就能稳定通过。

4.6 扩展方向:CAN/485/ESP32组合OTA

串口Ymodem只是OTA的“入门姿势”,实际项目中传输通道往往是CAN、RS485、网络甚至无线。

如果走CAN,需要自己定义上层传输协议,比如用ISO-TP或者自定义分帧格式,把固件包分成长度固定的CAN帧下发。CAN帧的数据场一般只有8字节,所以每帧只能带少量数据,帧号、总帧数、CRC这些字段都要设计好,否则很容易乱序。

如果走RS485,可以基于FreeModbus V1.6移植Modbus RTU协议,利用自定义功能码来触发升级和传输固件数据。工业现场很多设备都是RS485组网,这种方式可以直接复用现有总线,不需要额外布线。

如果走网络,比较常见的组合是ESP32 + STM32:ESP32通过WiFi/以太网从服务器或云端平台拉取固件,再通过串口或SPI把固件转发给STM32。这套方案是物联网产品的标准形态,升级入口从“接根线到电脑”变成了“在云端点一下按钮”,体验完全不同。

4.7 注意事项速查表

检查项说明
分区地址不同Flash容量的芯片页大小不同,擦除前务必核对起始地址和页编号
向量表偏移App的main函数第一行设置SCB->VTOR,否则中断异常
IROM1配置AppA和AppB的起始地址必须与分区规划完全一致
跳转前关中断关闭全局中断、停止SysTick,避免跳转瞬间异常触发
标志区独立标志区不要紧挨着App区末尾,擦除时容易跨界误伤
CRC计算范围上位机和设备端必须使用同一套CRC计算范围
启动计数回滚依赖boot_count,App正常后要清零,否则会影响下次启动判断
bin文件生成用fromelf转换后,确认bin文件大小没有超出分区容量

我个人在实际操作中最深的一个体会是:AB OTA虽然概念听起来高大上,但真正落地时,90%的精力都花在“地址、向量表、中断”这些底层细节上。把这三个点吃透,整个方案就成功了一大半。调试阶段还有一个很实用的技巧:在Bootloader和App的串口初始化处都打印一条带标识的启动日志,比如BootLoader->JumpToA、AppA_Running、AppB_Running,这样每次复位后,从串口日志就能直观看到当前跑在哪个分区、跳转是否成功,排查问题效率能提升一大截。

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

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

立即咨询