FEATURED · 精选文章

STM32F103 AB分区在线升级:从Flash规划到断点续传的实现指南

发布时间 / 2026/9/11 12:42:15
来源 / 创域科博编辑部
栏目 / 资讯中心
STM32F103 AB分区在线升级:从Flash规划到断点续传的实现指南 最早给STM32F103做在线升级时我用的还是经典IAPBootloader从串口收到固件擦掉整个App区再写新固件然后跳转。这套方案在样板调试阶段没有任何问题直到有一台样机在升级过程中被拔了电源——旧固件已经被擦掉新固件才写了一半。从那以后我只要提到IAP都会先问一句你考虑过AB分区吗。AB_OTA其实并不高深它只是把App区拆成A、B两份Bootloader永远引导其中一个能正常工作的系统升级永远写入另一个分区。这篇文章就基于STM32F103从Flash分区、Bootloader跳转、App改造、上位机协议一直讲到断线续传和回滚给你一条可以照着抄的完整实现路径。我默认你用标准外设库V3.5或者HAL库都行工具链用Keil MDK下载器用ST-Link/J-Link。如果你手里已经有了一块能亮灯的STM32F103最小系统板那就足够了。下面这些内容你照着做一遍就能明白AB_OTA到底是怎么转起来的。1. 为什么需要AB分区而不是简单IAP1.1 传统IAP的“一锤子买卖”问题传统IAP最容易出事的点就是升级过程中直接擦写当前正在运行的程序区。STM32F103的Flash不像PC硬盘它没有“覆盖写”的概念编程前必须先整页擦除擦完这一页之后如果数据没写完断电了那一页就是全0xFF也就是一段“无意义指令”。后果是什么旧App已经被擦掉新App又只有一半上电后Bootloader跳过去PC指针跑向空白Flash轻则HardFault重则直接飞了。这个时候唯一能救设备的办法就是重新进Bootloader用烧录器把固件再灌进去。客户在现场可不会给你拆壳返修成本一下就上来了。我见过不少项目只做“单区IAP”理由是Flash不够、代码量小、升级频率低。但实际产品一旦联网或者需要远程维护升级就是常态。单区方案每升一次都是赌一次运气因为你控制不了用户什么时候断电、什么时候拔线。1.2 AB_OTA的核心思想AB_OTA的思路很简单把App区拆成两个独立分区一次只往其中一个分区里写数据。Bootloader里维护一个“当前激活分区”的标志位每次启动都根据这个标志位选择从A区还是B区引导。升级的时候Bootloader只往非激活分区里写新固件写完之后校验CRC校验通过再翻转激活标志最后复位。如果升级过程中断电或者新固件本身有问题Bootloader在下一次启动时会检查当前运行区是否有效无效就回滚到另一个分区。也就是说任意时刻Flash里都保留着一个“能正常启动的App”。即使升级写了一半备用分区里的旧固件依然完好。这个“永远有一条退路”的设计就是AB方案和普通IAP最大的区别。1.3 STM32F103做AB_OTA的现实约束F103的片内Flash从64KB到512KB都有。如果你用的是F103C8T6Flash只有64KBBootloader占16KB以后只剩48KB两个App各分24KB对很多项目来说确实紧张。所以做AB_OTA最好选中大容量型号比如F103RCT6256KB或者F103ZET6512KB我把后者的分区方案放到下一节你拿过来就能用。另外要注意F103属于Cortex-M3内核中断向量表默认放在0x08000000App如果挪到别的地址启动必须修改VTOR寄存器否则任何中断一触发就直接跑飞。这也是初学者复现AB_OTA最容易卡住的地方后面我会单独讲。2. Flash分区规划与地址计算2.1 先把Flash地图画出来做AB_OTA之前第一件事不是写代码而是把Flash分区表定死。分区一旦定了后面Bootloader、两个App工程、参数区都要按照这个表来改中途改地址非常痛苦。我以STM32F103ZET6为例512KB Flash从0x08000000到0x0807FFFF给你一套经过验证的分区方式区域起始地址大小用途Bootloader0x0800000016KB串口升级、跳转、回滚AppA0x08004000192KB当前运行分区AAppB0x08010000192KB备用/升级分区B参数区0x0807F0004KB激活标志、版本号、升级状态保留区0x0807E000之前剩余预留这里的参数区放得很巧妙放在Flash末尾的4KB里。F103ZET6每页是2KB4KB刚好两页写参数的时候按页擦除不会影响到App区。如果你用F103C8T664KB Flash每页是1KB参数区也得重新按页对齐。2.2 地址计算的依据为什么Bootloader用16KB因为Bootloader只承担串口接收、Flash写入、跳转和参数管理这些固定功能不跑业务逻辑16KB完全够用。如果你还要在里面塞Modbus协议栈建议留到32KB具体看你的代码量。AppA从0x08004000开始是因为0x08000000到0x08004000正好16KBBootloader占满后AppA必须紧跟着Bootloader末尾才能保证地址连续。两个App各192KB加起来384KB再加上Bootloader的16KB和参数区4KB一共404KB512KB的Flash还剩100多KB做保留以后想加功能也还有地方。2.3 Keil工程里怎么设置这个分区Bootloader工程和App工程最好分开建两个Keil工程不要混在一起。Bootloader工程的IROM1设置IROM1: Start 0x08000000, Size 0x4000AppA工程的IROM1设置IROM1: Start 0x08004000, Size 0x30000AppB工程按同样方式把起始地址改成0x08010000。Size不变还是0x30000。这里0x30000就是192KB。很多人会忽略一点AppA和AppB虽然是两套不同的地址但业务代码完全一样所以最好用同一个工程复制一份只改IROM1地址确保两边的代码逻辑一致。把AppA编译出来的bin烧到0x08004000把AppB编译出来的bin烧到0x08010000Bootloader才能正常引导。3. Bootloader端代码实现3.1 Bootloader的启动流程Bootloader上电后的逻辑要非常清晰可以拆成三步初始化时钟、串口、GPIO和看门狗。判断是否进入升级模式。一般有两种方式上电时检测某个按键/跳线或者读参数区里的“强制升级标志”。没有升级请求就走正常启动。读取参数区里的“当前激活分区”检查该分区首地址的栈顶指针和复位向量是否合法合法则跳转不合法则尝试另一个分区两个分区都不合法就停在Bootloader里等待串口升级。我用的判定函数大概是这样的int check_app_valid(uint32_t app_addr) { uint32_t stack_top *(volatile uint32_t *)app_addr; uint32_t reset_vector *(volatile uint32_t *)(app_addr 4); // 栈顶必须在RAM范围内复位向量必须在Flash范围内 if ((stack_top 0xFFF00000) ! 0x20000000) return 0; if ((reset_vector 0xFFF00000) ! 0x08000000) return 0; return 1; }这个检查非常关键。如果不做合法性检查直接跳到一个全0xFF的Flash地址基本就是HardFault。3.2 Flash擦写与数据完整性保护F103的Flash编程有几个硬件限制写代码之前必须先搞清楚。第一写Flash之前必须擦除所在页。中容量1KB一页大容量2KB一页擦除是按页来的不能只擦几个字节。第二标准库的FLASH_ProgramWord一次写32位也就是4字节所以你要写入的数据最好按4字节对齐。如果你从串口收到的固件包长度不是4的倍数最后几个字节要单独处理。第三Flash写入过程中CPU不能从同一块Flash取指也就是说如果你一边从Bootloader的Flash执行代码一边写App区的Flash是可以的因为地址不重叠。如果Bootloader自己把代码放到RAM里跑那就没必要。我用标准库V3.5写了一个简单的擦写函数void flash_write_buffer(uint32_t addr, uint8_t *buf, uint16_t len) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (uint16_t i 0; i len; i 4) { uint32_t word 0; word | buf[i]; word | ((uint32_t)buf[i 1]) 8; word | ((uint32_t)buf[i 2]) 16; word | ((uint32_t)buf[i 3]) 24; FLASH_ProgramWord(addr i, word); } FLASH_Lock(); }实际项目里不会每次收到一包就擦写一次更常见的做法是每一包数据先存到RAM缓冲区攒到刚好一页再整页擦写。这样减少擦除次数也减少升级时间。有一件事特别容易踩坑擦除Flash的那几十毫秒里如果开了看门狗必须提前喂狗或者把喂狗间隔拉长否则擦写到一半系统就复位了。3.3 升级协议与数据包格式串口升级协议不需要做得太复杂但要能支撑断点续传和校验。我用的帧格式很简单字段长度说明帧头2字节固定0xAA55命令字1字节0x01握手、0x02开始、0x03数据、0x04结束包序号2字节大端从0开始数据长度2字节本帧负载长度最大256数据N字节固件数据或版本信息CRC162字节对整个帧内容做校验帧尾2字节固定0x0D0A用CRC而不是简单的累加和是因为串口偶尔会出现连续多位错误累加和拦不住。CRC16虽然不如CRC32强但对固件升级来说足够用了Bootloader里实现也很快。升级的核心流程是这样的上位机先发握手帧Bootloader回复当前版本号和可用分区上位机再发开始帧Bootloader根据当前激活分区决定把数据写到哪个App区然后上位机按序号连续发数据帧Bootloader边收边写全部发完后发结束帧Bootloader对目标分区做整体CRC校验校验通过就更新参数区里的激活标志复位跳转。3.4 跳转函数的实现细节跳转是整个Bootloader里最敏感的一段代码。我见过太多人直接拿网上代码一贴结果跳过去就是HardFault。跳转之前有几件事必须做。第一关闭全局中断。如果跳转的时候还有串口中断在排队进入App后中断向量表还没有切换到位中断一来CPU就懵了。第二把Bootloader初始化过的外设全部DeInit掉尤其是串口、定时器、DMA不然App重新初始化这些外设时会因为状态残留而出问题。第三设置MSP为App的栈顶地址再取App复位向量跳转。typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_stack *(volatile uint32_t *)app_addr; uint32_t app_reset *(volatile uint32_t *)(app_addr 4); if ((app_stack 0xFFF00000) ! 0x20000000) return; if ((app_reset 0xFFF00000) ! 0x08000000) return; __disable_irq(); NVIC_DeInit(); RCC_DeInit(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; pFunction jump (pFunction)app_reset; __set_MSP(app_stack); jump(); }注意__set_MSP之后不要返回直接调用函数指针跳过去。跳过去之后CPU会先执行App的复位向量也就是启动文件里的Reset_Handler然后走SystemInit再进入main。只要App端的中断向量表偏移设置正确整个系统就能正常跑起来。4. App端需要改动的三个地方核心改造4.1 App中断向量表偏移App跑在0x08004000但CPU默认认为中断向量表在0x08000000。如果不改VTOR任何中断发生CPU都会从0x08000000处取向量那取到的是Bootloader的中断向量跑到App里自然就错了。F103是Cortex-M3支持VTOR寄存器修改向量表地址。标准库V3.5里直接在main函数开头加一句SCB-VTOR 0x08004000;AppB工程里改成0x08010000。如果你用的是HAL库也可以直接操作SCB寄存器或者通过__HAL_SYSCFG_REMAPMEMORY_FLASH之类的方式但说实话F103这么做最省事。这里有一个细节VTOR要求向量表地址对齐到0x100的倍数0x08004000和0x08010000都是满足的所以没问题。如果哪天有人把App地址定在0x08004100这种地方就要小心了。4.2 编译器IROM1和bin文件生成App工程除了要设置中断向量表偏移编译器的地址也必须改成对应分区地址。否则你编译出来的代码链接地址还是0x08000000跳过去根本跑不对。用Keil MDK的话在Options for Target - Target页面里修改IROM1AppAStart 0x08004000Size 0x30000AppBStart 0x08010000Size 0x30000不要动IRAMF103的RAM起始地址永远是0x20000000大小根据芯片型号来。然后还要让Keil生成bin文件方便上位机发送。在Options for Target - User页面的After Build/Rebuild里加上一行fromelf --bin --outputL.bin #L这行命令的意思是把当前编译生成的axf文件转换成bin文件输出到工程目录。烧录的时候Bootloader用的就是bin文件。4.3 App启动后确认与回滚触发AB_OTA的回滚不能只靠Bootloader检查Flash是否合法因为“固件能跳转”和“固件能正常工作”是两回事。很多时候App能启动但初始化外设失败、跑起来就死机Bootloader光看栈顶指针是发现不了的。所以我在App里加了一个“启动确认”机制App正常运行5秒后主动往参数区写一个“运行正常”的标记。Bootloader在下一次启动时如果发现当前激活分区没有这个标记就认为App异常自动回滚到另一个分区。具体实现不复杂就是在App的main函数里加个延时延时后调用一个写参数区的函数。为了不让这个标记一直留在Flash里每次升级成功写完新固件的时候Bootloader会把该分区的确认标记清掉等App运行正常后再补写。5. 升级流程设计与上位机实现5.1 完整升级时序我用串口做过一版最简单的AB_OTA上位机流程整体跑下来非常稳Bootloader上电初始化串口等待握手命令。PC上位机发送“握手”帧Bootloader回复当前版本、空闲分区、Flash大小。PC发送“开始升级”帧Bootloader根据当前激活分区选择另一个分区作为写入目标。PC按包序号循环发送固件数据帧每包256字节。Bootloader收到后先写入RAM缓冲区攒满一页再擦写Flash。全部数据发送完毕PC发送“结束”帧Bootloader对刚写入的分区做整体CRC校验。校验通过Bootloader更新参数区激活标志复位跳转到新固件。新固件启动正常运行5秒后写入确认标记。如果5秒内死机Bootloader下次启动时回滚到旧分区。这个流程有两个好处一是断电发生在步骤4的任何时刻旧分区都完好无损下次开机还能正常进旧App二是断电发生在步骤7之前Bootloader会认为新分区没有确认直接回滚。5.2 写一个Python上位机我用Python写了个最小实现依赖pyserial逻辑很直白import serial import time import struct import binascii PORT COM10 BAUD 115200 BIN_FILE app_A.bin ser serial.Serial(PORT, BAUD, timeout0.5) def send_frame(cmd, seq, data): body struct.pack(BBH, cmd, seq, len(data)) data crc binascii.crc_hqx(body, 0xFFFF) frame b\xAA\x55 body struct.pack(H, crc) b\x0D\x0A ser.write(frame) time.sleep(0.05) # 1. 握手 send_frame(0x01, 0, b) time.sleep(0.2) print(handshake:, ser.read(64)) # 2. 开始升级 send_frame(0x02, 0, b\x01) # 0x01表示目标分区为B time.sleep(0.2) # 3. 发送数据 with open(BIN_FILE, rb) as f: data f.read() seq 0 for i in range(0, len(data), 256): chunk data[i:i256] send_frame(0x03, seq, chunk) seq 1 # 4. 结束 send_frame(0x04, seq, b) time.sleep(1) print(bootloader response:, ser.read(128)) ser.close()真实产品里建议加上每一帧的应答和重传机制我这里是演示主流程所以没写。但工程上千万不要把串口当UDP用不确认就发下一条一旦干扰丢包整个升级就白干了。5.3 把传输层换成Modbus RTU、CAN或NRF24L01串口做升级是最常见的但很多项目升级通道不是串口而是Modbus、CAN甚至无线。这个思路是通用的只是传输层换一下。如果你用的是STM32F103标准库V3.5 FreeModbus V1.6移植过的兄弟应该知道Modbus RTU本身就是基于串口的你只要把串口收到的那一帧Modbus报文解析出来把升级协议封装在Modbus的保持寄存器里就行。比如用功能码0x10批量写多个寄存器一个寄存器16位两个寄存器拼一个32位Flash字。这里有个细节RS485的收发切换引脚DE必须在发送完成后再拉低否则最后一字节会丢。很多人Modbus升级丢包一半是DE脚切换时序的问题。如果用CAN做升级F103内部有bxCAN报文最大8字节效率比串口低不少但抗干扰能力强。CAN波特率参数里有一个SJW同步跳跃宽度SJW设得越大对时钟容差越宽容但同步精度会下降。比如波特率设置时BS18Tq、BS27Tq、Prescaler4、CAN时钟36MHz波特率就是36M / 4 / (187) 562.5kbps这个时候SJW保持1Tq或2Tq就够用。如果板上晶振精度差再适当加大SJW。如果用NRF24L01这种SPI无线模块一次只有32字节升级几百KB固件耗时很长而且2.4G环境容易丢包所以协议里必须做帧序号、重传和超时。我当时用CubeMX生成HAL库的SPI读写例程再在上面加一层数据包重组调了一周才稳定。想省事的话还是优先考虑用串口或者RS485做升级通道。5.4 断点续传与升级失败补救断点续传是AB_OTA在工程上很实用的能力。做法是Bootloader在参数区里记录“当前已经写入的包序号”上位机每次连接时可以查询这个序号从断点继续发不用从头开始。F103的Flash写入速度大概1KB/s到几KB/s300KB的固件可能要传一两分钟没有断点续传传输中途掉线就只能重头再来。我这里说的“断点”指的是传输断点不是Flash写入断点。Bootloader每收到一包数据并写入Flash后才更新参数区里的已写序号。这样即使突然断电再次上电时已写序号还是上次完成的那一包不会出现半包状态。6. 踩坑记录与排查方法6.1 跳转后进HardFault这个问题出现频率最高。我去排查的时候一般按顺序查三件事一是看App分区首地址的数据对不对。用调试器读一下0x08004000前4字节应该是合法RAM地址也就是0x2000xxxx第5到8字节应该是Flash地址也就是0x0800xxxx。如果读出来全是0xFF说明App根本没烧进去或者烧到了别的地址。二是看VTOR有没有设置。直接用调试器在App的main里打断点跑到之后查看SCB-VTOR的值必须等于0x08004000或者0x08010000。三是看跳转前有没有关中断。如果跳转前串口中断还开着跳转瞬间某一个串口中断触发CPU就会拿着App的栈指针去执行中断向量一执行就飞。6.2 App能启动但中断不正常如果程序能跑但一按键、一串口收发就死机那基本可以确定是中断向量表没设置对。还有一种情况是启动文件选错了F103大容量和小容量Flash的启动文件不一样如果用错Flash大小和向量偏移对不上也会出这种诡异问题。如果你是直接用J-Flash或者ST-Link往0x08004000里面烧bin文件烧完想Debug记得要把仿真器的Download地址改成0x08004000同时把PC指针手动设到0x08004004位置也就是复位向量。很多人直接在调试器里按F5结果CPU从0x08000000开始跑当然不对。6.3 Keil生成不了bin文件After Build里加的命令要注意语法fromelf --bin --output$LL.bin #L$L是当前连接器输出的axf文件路径#L是输入文件。如果命令写错编译输出窗口会报错但不影响hex文件生成所以你很容易忽略。最稳妥的办法是编译完以后去工程目录的Listings或者Objects目录里找找不到就去User命令输出窗口看有没有乱码报错。6.4 串口升级到一半卡死从Bootloader串口收到数据开始到写完一页Flash中间如果开了看门狗一定要算好喂狗时间。F103擦除一页Flash大概几十毫秒写几十页要几百毫秒超过看门狗超时时间没喂狗系统就复位了。另外串口中断里不要做Flash擦写先把数据收到RAM缓冲区主循环里再处理Flash操作。中断里擦Flash会阻塞其他中断一旦数据帧太长溢出标志直接把你打断。6.5 常见问题速查表问题现象可能原因排查/解决跳转后HardFault栈顶地址不合法/中断未关闭检查App首地址前4字节跳转前__disable_irq中断不触发或触发后跑飞VTOR未设置或地址不对main最开头加SCB-VTOR烧录后无法运行下载地址与IROM1不一致仿真器下载地址设为App起始地址升级包校验失败串口丢包或CRC计算错误每包加应答重传确认CRC算法一致传输中途卡死看门狗没喂/Flash擦除期间复位擦除前喂狗或调整看门狗超时回滚没有生效激活标志没更新/App确认标记没写查参数区读写函数RS485收不到最后一字节DE引脚切换太慢发送完成后延时再拉低DE根据我个人经验复现AB_OTA时先把Bootloader和两个App都编译成最简单的点灯程序去掉一切复杂业务逻辑先把“Bootloader跳转AppA/AppB”这条链路打通。链路通了以后再加串口升级、再加参数回滚。一次不要贪多一口吃不成胖子尤其做嵌入式底层稳比快重要得多。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻