STM32F103 OTA升级:AB分区方案从零实现详解
2026/9/6 8:40:34 网站建设 项目流程

直接讲结论:STM32F103做OTA,最省心的方案就是AB分区(也叫双分区、A/B slot),而不是市面上大多数教程教的“Bootloader引导 + APP自升级”单分区方案。单分区方案的问题在于,升级过程中一旦掉电或者写入Flash时程序跑飞,板子就变砖了,只能开盖用SWD重新烧。量产产品要是出现这种问题,售后成本直接拉满。AB分区方案虽然多占用一份Flash空间,但换来的是“最多回到上一个可用版本”的能力,这在工业控制、物联网网关、传感器节点这些不便拆机的场景下,价值是实打实的。

我这次从零复现的是一套完整可用的STM32F103 AB OTA链路:Bootloader负责校验和跳转,App负责业务逻辑和固件接收,上位机负责分包发送和进度反馈。整个过程不走任何付费库,全部基于标准外设库(StdPeriph 3.5)+ 串口Ymodem协议实现,硬件就是最常见的STM32F103C8T6最小系统板,外加一个USB转TTL模块。文章里会把内存布局、扇区划分、协议约定、跳转细节、掉电保护这几块全部拆开讲,附完整可编译的工程结构,争取做到你拿一块板子就能跟着跑通。

1. 为什么F103的OTA必须优先考虑AB分区

先说一个很多人踩过的坑。网上常见的STM32 OTA教程,基本是这样一个套路:Bootloader放Flash起始地址,APP从0x08008000开始放,APP内部实现一个接收固件的功能,收到完整固件后直接擦除自己的Flash区域,然后写入新固件,最后软复位跳到Bootloader,由Bootloader再跳到新APP。

这个方案原理上没毛病,工程上很危险。因为APP在擦写自己所在Flash区域的过程中,代码执行和Flash写入操作交叉发生——指令可能正在从即将被擦除的扇区取指。虽然通过“把擦写函数放到RAM中执行”能绕开这个问题,但还有一个更致命的问题:整包数据写入完成之前掉电,中间扇区残留半包数据,没有任何可靠手段恢复到旧版本,旧版本已经被覆盖了。也就是说,这个方案本质上是“要么升级成功,要么变砖”,没有中间态。

AB分区的思路完全不同:Flash里放两份固件区域,一份A分区运行当前正式版本,一份B分区作为备用区域。升级时,Bootloader接收新固件写入B分区,写入完成后做完整性校验,然后翻转启动标志,下次启动从B分区引导。如果B分区的固件运行异常(比如定时上报心跳给Bootloader失败),Bootloader再翻转回A分区启动。两个分区互覆盖的关系被彻底切断,升级失败永远有后路。

F103的Flash容量普遍在64KB到512KB之间,很多人觉得做AB分区容量不够用。这里要纠正一个观念:AB分区并不是要求两份固件完全镜像,而是A、B分区地址不同,但固件本身可以充分利用整个分区空间。比如一个256KB的F103,Bootloader占16KB,剩余240KB,A分区从0x08004000开始分120KB,B分区从0x0801E000开始分120KB。如果固件实际编译出来只有60KB,剩下60KB就是留给未来功能迭代的冗余,这比单分区方案里“Flash还剩一大堆但不敢扩容”要健康得多。

F103的Flash是扇区结构,不是页结构。小容量和中容量型号扇区大小是1KB,大容量型号是2KB。这个细节在分区设计时直接影响擦除策略,如果没搞清楚就写Flash操作代码,擦除范围很容易算错,后面会专门讲。另外,F103内部Flash的擦写寿命官方标称是10K次,AB方案里每次升级只擦写B分区的部分扇区,实际摊薄下来比整个Flash反复擦写要耐用。

所以我的结论是:如果你做的是样品、Demo、比赛作品,单分区方案够用,代码量也少;但只要你打算把设备装到现场去用,AB分区是底线。这个选择不是性能问题,是责任问题。

2. 从零搭建双分区工程:地址规划与内存布局

在写任何代码之前,先把Flash地图画清楚。这是整个AB OTA方案里最不该省的一步。我用的芯片是STM32F103C8T6,中容量产品,Flash 64KB,注意C8T6在淘宝买到的很多其实是64KB版本,个别标称“128KB”的超频版不在本文讨论范围。如果你用的是ZET6或者别的型号,把地址按扇区大小重新算一遍就行。

分区设计的基本原则:Bootloader要足够小,预留一定的扩展空间;AB两个分区要尽量对齐扇区边界,避免擦除时殃及隔壁分区;分区末尾放一份“分区状态表”,用来记录当前活跃分区、升级标志和固件校验信息。我最终采用的布局如下:

区域起始地址大小说明
Bootloader0x0800000016KB中断向量表、升级逻辑、跳转逻辑
A分区(App Slot A)0x0800400024KB出厂默认运行区
B分区(App Slot B)0x0800A00024KB备用运行区 / 升级写入区
分区状态表0x080100004KB存放分区标志、固件信息、校验值

把状态表单独放在最后一块区域,而不是放在Bootloader占用的Flash里,原因很简单:Bootloader擦写升级过程中自身的Flash内容不应该被改动,一旦Bootloader代码被破坏,整机彻底变砖。状态表独立出来后,擦写它不影响任何代码区域,而且它本身不需要频繁擦写,寿命压力小。

这里再强调一下扇区对齐的计算方式。F103C8T6的Flash扇区每块是1KB(注意,这里指的是数据手册上说的“扇区”,不是标准库里的Flash页概念)。0x08004000换算成十进制是524288,除以1024等于512扇区,刚好在扇区边界。0x0800A000对应640扇区,也是齐的。这样设计的好处是,擦除A分区只需要把512到639扇区整片擦掉,不会误伤Bootloader区域,也不用去处理参差不齐的残留地址。

接下来是工程层面的操作。我用的是标准外设库V3.5,工程模板是从一个标准库版的跑马灯工程改出来的。Bootloader单独成一个Keil工程,App单独成一个Keil工程,两个工程不要合并在一起编译,否则链接脚本的散列文件(sct文件)会相互干扰。每个工程都把自己的ROM起始地址配置在Keil的Target选项卡里,这个配置决定了中断向量表的位置,也决定了生成的HEX烧录地址。

Bootloader工程里,我把Flash起始地址填0x08000000,大小0x4000(16KB)。App A工程填0x08004000,大小0x6000(24KB)。App B工程的理论地址是0x0800A000,但实际编译App B时不需要重新开一个工程,后面我会讲通过一份代码按偏移量切换的做法,那才是工程上正用的方式。

系统上电后,Bootloader处于0x08000000,这是芯片复位后的默认取指地址。Bootloader在main函数里做三件事:读分区状态表,决定跳哪个分区;如果标志位表明需要升级,进入升级模式接收固件;否则直接跳转运行。代码写好后,第一次烧录要把Bootloader烧进0x08000000,App A烧进0x08004000,并通过串口工具把状态表初始化成“活跃分区=A”的标志,这样上电后Bootloader才晓得跳A。

在准备动手写代码之前,再提醒一个容易被忽略的点:Bootloader工程和App工程使用的外设库版本最好保持一致,尤其是Flash读写相关的固件库文件,不同版本在擦除等待逻辑上略有差异。我这次两个工程都用的是ST官方标准外设库3.5.0,三份工程文件(Bootloader、App、Bootloader里引用的公共库)的库文件全部统一,没有混用HAL库。不是HAL库不好,是临时混用容易把时间浪费在兼容性排查上。

3. Bootloader完整实现:串口协议、Flash写入与跳转逻辑

Bootloader是整个AB方案里最核心的部分。功能拆解下来就三块:接收固件包并把数据写入目标分区、对写入结果进行校验、根据状态表完成跳转。串口接收这块,网上很多人用中断配合环形缓冲区做,思路对,但工程上还有更省事的路子。

先说协议设计。我采用的是Ymodem协议最简子集,为什么不用自定义私有协议?因为Ymodem协议成熟,上位机直接用现成工具(如SecureCRT、ExtraPuTTY的Ymodem发送功能)就能测试,不需要一开始就写上位机。开发初期我先用SecureCRT做联调,把Bootloader调稳了,再写自己的上位机。这样做的好处是把问题域拆开:Bootloader只关心协议解析和Flash写入,不关心上位机逻辑;上位机只关心分包和发送,不关心Flash细节。两边各自成熟后合在一起,Bug率低很多。

Ymodem的核心交互流程:发送方先发一个文件名包(数据块0,包含文件名和文件大小),接收方应答ACK;然后发送方逐块发数据,每块128字节,接收方每收一块回一个ACK;发完全部数据后再发一个EOT(传输结束)信号,接收方回ACK和NACK,发送方再回一个空包确认,接收方回ACK,整个传输结束。

Bootloader收到文件名包后,解析出固件大小,然后根据状态表里的“目标分区地址”开始擦除对应扇区,准备接收数据。每收满128字节就写一次Flash。这里要特别小心F103的Flash编程方式:ST的库函数一次只能写16位(半字),不是字节写入。也就是说,128字节的数据要分成64次半字写入。固件库原版的FLASH_ProgramHalfWord只能处理16位数据,如果你的固件包不是偶数长度,最后一个字节要单独处理,我在代码里做了补齐,不足128字节的数据块按0xFF填充,保证断电后未写区域处于全FF状态,便于校验时识别。

void Bootloader_WriteFlash(uint32_t address, uint8_t *pBuffer, uint16_t len) { uint16_t i; uint32_t halfWordData; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (i = 0; i < len; i += 2) { halfWordData = pBuffer[i]; if (i + 1 < len) { halfWordData |= (uint16_t)(pBuffer[i + 1] << 8); } else { halfWordData |= 0xFF00; // 奇数长度最后字节补FF } FLASH_ProgramHalfWord(address + i, halfWordData); } FLASH_Lock(); }

每一块写入之前,我会先读取一遍该地址的数据,确认不是0xFFFFFFFF,避免重复写入。这不是必须的,但是能提前发现状态表指向错误或者地址越界的问题,定位起来快很多。

整包接收完成后,Bootloader会从目标分区起始地址把整个固件大小的数据读出来,和自己维护的CRC32校验值比较。CRC32的计算我在上位机发送端就完成,把校验值放在文件名的扩展字段里传过来。如果校验失败,状态表不翻转,直接回滚到旧分区启动,同时通过串口打印错误码。这个“先校验、再翻转、最后跳转”的顺序是AB方案的精髓,绝对不能省。

bool Bootloader_VerifyApp(uint32_t appAddr, uint32_t appSize, uint32_t expectCrc) { uint32_t i; uint32_t calcCrc = 0xFFFFFFFF; for (i = 0; i < appSize; i++) { calcCrc = CRC32_Byte(calcCrc, *(uint8_t *)(appAddr + i)); } return (calcCrc == expectCrc); }

跳转逻辑看起来简单,但坑不少。跳转前必须关掉所有中断,把系统时钟配置恢复到默认,关闭SysTick,将向量表重设到App的起始地址,然后从App起始地址取栈顶地址和复位向量。直接用一个函数指针跳过去是新手最容易犯的错误——直接跳到Reset_Handler而不是先设置MSP。正确写法如下:

typedef void (*pFunction)(void); void Bootloader_JumpToApp(uint32_t appAddr) { uint32_t appStack = *(volatile uint32_t *)appAddr; uint32_t appReset = *(volatile uint32_t *)(appAddr + 4); pFunction jumpFunc; __disable_irq(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; if ((appStack & 0x2FFE0000) != 0x20000000) { return; // 栈顶地址非法,拒绝跳转 } jumpFunc = (pFunction)appReset; __set_MSP(appStack); jumpFunc(); }

栈顶地址的合法性检查看着多余,实际救了我好几次。有一次Flash被写入错误数据,跳转前没有检查栈顶地址,结果程序跳到0xFFFFFFFF直接进HardFault。加了检查之后,非法数据直接丢弃,Bootloader继续等待下一轮升级,观感上和正常逻辑一致,设备也没有任何异常表现。

4. App端配合改造:中断向量偏移、链接脚本与双分区切换

App端的工作量比Bootloader小,但细节很抠。核心就两件事:让App知道自己运行在哪个分区地址,然后把中断向量表指向正确的位置。

STM32F103的中断向量表默认放在0x08000000,这是硬件复位后从Flash起始位置取中断向量的约定。App被Bootloader引导到0x08004000或0x0800A000运行时,如果向量表还在0x08000000,系统一旦产生中断(定时器、串口、滴答时钟等),去0x08000000找的向量依然是Bootloader的向量表,中断服务函数就会找错——表现就是运行起来一切正常,一开中断就飞。解决办法是设置SCB->VTOR寄存器,把它指向App的实际起始地址。

#define APP_SLOT_A_ADDR 0x08004000 #define APP_SLOT_B_ADDR 0x0800A000 uint32_t g_currentAppAddr; void App_InitVectorTable(void) { uint32_t vectorAddr; uint32_t activeSlot = Flash_ReadActiveSlot(); if (activeSlot == SLOT_B) { g_currentAppAddr = APP_SLOT_B_ADDR; } else { g_currentAppAddr = APP_SLOT_A_ADDR; } vectorAddr = g_currentAppAddr; SCB->VTOR = vectorAddr; }

这里有个细节:我自己写的状态表里记录了当前活跃的是A还是B,App启动时读这个标志就知道自己身处哪个分区。为什么要知道分区?因为App升级时要把新固件写到另一个分区,写完之后要把状态表翻转过来。如果App不知道自己在哪个分区,就不知道该写哪里,也不知道翻转成什么值。

然后是链接脚本。Keil下修改ROM起始地址有两条路:图形界面里改“IRAM1/ROM1”起始地址,或者在分散加载文件.sct里写死地址。图形界面改法适合快速验证,但它默认生成的.sct文件会在工程重新生成时被覆盖。我直接手写了一个.sct,内容很直观:

LR_IROM1 0x08004000 0x00006000 { ER_IROM1 0x08004000 0x00006000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00005000 { .ANY (+RW +ZI) } }

如果App B跟App A代码完全一致,其实不需要单独编译一份。Bootloader跳转前根据状态表选择跳A还是跳B,而App启动时读取状态表确认自己所在分区即可。这样一套App代码同时兼容两个分区,但前提是代码里不要写死某个分区的绝对地址。

App端另一件重要的事是实现固件下载和写入功能。我在App里开了一个简单TCP/IP场景以外的串口服务:App启动后每个500ms向串口发一次心跳,如果收到上位机发来的“进入升级模式”命令,App先把当前状态(比如业务参数、日志)保存到片外EEPROM或Flash状态表区,然后软复位进入Bootloader。Bootloader启动后检测到升级标志置位,自动进入Ymodem接收模式。

这个流程最大的好处是:升级动作的接收模块放在Bootloader里,App本身不写Flash(除了状态表),App代码复杂度大幅下降。App里只保留串口命令解析和软复位函数:

void App_HandleUpgradeCommand(void) { Bootloader_SetUpgradeFlag(UPGRADE_FLAG_READY); NVIC_SystemReset(); }

这里再加一个防呆逻辑:升级标志不是随便置位的。上位机必须先发一条包含产品ID和固件版本号的命令,App校验通过后才置位升级标志。避免现场误操作把无关数据当成升级包。固件版本号对比这一步尤其重要,它能防止把旧固件刷到新硬件上。

5. 完整验收流程:从编译下载到断电解码的实测记录

代码写完不算完,得按照严格的验收流程走一遍。这个流程我在实验室跑了不下二十轮,每一轮都记录结果,这里直接把最关键的环节和结果写出来。

第一轮联调我用的是SecureCRT的Ymodem发送功能,Bootloader侧跑Ymodem接收,串口参数115200-8-N-1,上位机选择App A编译生成的HEX文件。这种方式的缺点是HEX是Intel格式的,Ymodem按原始字节流发送的话,Bootloader收到的是HEX文件源码而不是纯二进制。我最初就吃过这个亏,校验一直失败。后面用objcopy先把HEX转成bin再发送:

arm-none-eabi-objcopy -I ihex -O binary app.hex app.bin

转出来的bin文件大小正好是固件实际大小,不是整个分区大小。Ymodem协议头里的文件大小字段用bin文件大小填充,Bootloader按这个大小收包、校验、写Flash。

第二轮联调开始接触真实的上位机逻辑。上位机我用Python写了个简单demo,使用pyserial发送Ymodem包。这个demo定位不是生产工具,而是帮我把协议交互逻辑梳理清楚。Python下实现Ymodem的难点在于状态机的状态转换,尤其是收到NACK之后要重发当前包而不是下一个包。我把状态机拆分出WAIT_FILE_NAME、RECEIVING_DATA、WAIT_EOT三个状态,每个状态超时重发当前包,最多重发10次,超过后中断传输并提示用户。

第二轮实测发现一个角色问题:Ymodem协议在标准实现里,接收方是主动发送C(字符0x43)来邀请发送方开始传输的。上位机点击“开始升级”后,不会自动发数据,必须要等Bootloader先发一个0x43。如果Bootloader的串口初始化时序稍有不对,上位机就卡在等C字符的环节,表现为界面一直显示“等待接收方就绪”。排查后确认是我的Bootloader在升级模式下才发C字符,而升级标志没置位时Bootloader直接跳App,上位机当然等不到。调整后Bootloader启动先读升级标志,置位才输出C字符,未置位直接跳App,联调就通了。

第三轮做了压测:同一份固件连续升级20轮,每轮升级完成后断电再上电,检查是否稳定运行在目标分区。压测前特意把Ymodem的超时重发次数调低到3次,模拟弱信号环境下的丢包重发场景。结果是20轮全部通过,其中有一轮在接收过程中人为拔掉串口线再插回去(模拟断连),Bootloader在超时后自动回滚到当前分区,设备没有变砖。

第四轮是断电时机测试,这是AB方案最核心的价值验证。我在Bootloader写入B分区到一半时突然断电,再上电观察Bootloader行为:由于目标分区的CRC校验不通过,状态表没有翻转,Bootloader直接跳A分区。日志输出里明确打印了“CRC check failed, rollback to slot A”。这个测试反复做,每一轮结果都一致。

测试项测试方法通过标准结果
正常升级Ymodem发送完整bin跳转新分区成功启动通过
丢包重传手动拔插串口线自动重传并完成升级通过
升级掉电写入过程中断电CRC校验失败回滚旧分区通过
非法固件发送篡改后的bin拒绝跳转并保留旧版本通过
Flash磨损连续升级20轮功能正常通过

整条链路验收通过后,我在开发板上加了一个LED状态指示灯,Bootloader启动时快闪三下表示进入升级模式,慢闪一下表示校验通过准备跳转,两长一短表示校验失败回滚。现场不用接串口也能大概判断状态,对麦调试和现场排障非常有帮助。

6. 我踩过的坑和最终的工程建议

最后把这轮从零复现里踩过、填平的几个坑集中写出来。这些坑在网上分散的教程里几乎没人系统性提。

第一个坑最容易让新手困惑——Bootloader升级模式下printf重定向异常。Bootloader里加了串口打印日志,结果升级过程中上位机一直收不到0x43邀请字符。排查到最后发现是printf重定向到串口的函数还没初始化,被我在main函数开头调用了一次。解决办法是按顺序初始化:先初始化串口和重定向,再发0x43。这种时序问题在调试器里很难发现,因为它不影响FLASH写入,只是影响协议交互。

第二个坑是Flash等待周期。F103在系统时钟72MHz下,Flash必须配置为两个等待周期。要是系统启动代码里忘了配置FLASH_SetLatency(FLASH_Latency_2),Flash运行不稳定,读出来数据时对时错,最典型的特征是复位后能跑,跑一会儿死机,或者CRC校验值每次都不一样。这个坑在标准库V3.5的SystemInit函数里默认会配置,但如果你的工程是从老工程改来的,检查一下里面是否真的被正确配置了。我遇到的情况是工程模板里该函数被注释掉了,花了两天才定位到。

第三个坑是Ymodem的128字节块大小与Flash半字编程的对齐冲突。Ymodem最后一个数据块很可能不满128字节,如果不处理就直接写Flash,字节错位会写花。我在写入前先检查剩余字节数,不足128字节的按0xFF补齐再写入。同理,固件大小如果是奇数,最后一字节补0xFF,CRC计算时也要用补完的数据算,否则发送端和接收端算出的CRC永远不一致。

第四个坑是App端的看门狗问题。我的App里开了IWDG(独立看门狗),独立看门狗一旦启动就无法在软件里关闭。结果App在升级模式下请求复位进入Bootloader时,IWDG的计数还在跑,Bootloader执行到一半系统被看门狗复位,永远无法完成升级。解决办法是App在进入升级模式前,先把IWDG的预分频器和重装载值改成最大值(延长喂狗时间),再复位。这样可以给Bootloader留出足够的执行窗口。当然更彻底的做法是在硬件上预留一个跳线,让Bootloader模式下关闭看门狗,但工程上不现实,因为Bootloader和App共用同一个芯片资源。

第五个坑确实是AB方案的“隐藏成本”:调试不便。App跑在0x0800400以上,Keil在线仿真时默认加载的是0x08000000的地址,如果不改调试配置,即使烧了App也断不到断点。我的做法是Keil的Target选项卡里把ROM起始地址改成跟App一致,烧录算法也改成从0x08004000开始烧,但调试时必须先把Bootloader烧进0x08008000,否则上电没有引导。平时调试App直接用Keil的“Download”功能,调Bootloader单独用另一个工程,两边切换时记得先烧一遍Bootloader,避免App跳飞后无法恢复。

最后多说一句关于状态表的设计思路。我用的状态表结构很简单,只包含活跃分区标志、升级标志、固件版本号、固件大小、CRC32这五个字段。每个字段除了真实值,我还存了一份取反后的备份在相邻地址,读的时候两个值都读出来对比,不一致说明Flash数据异常。这个“双备份对拷”的思路成本极低,但能让状态表本身具备一定的容错能力。如果你的项目对可靠性要求更高,可以考虑用双字(double word)写入加三份冗余存储的方式,但F103在页面擦除次数上要控制好,别把状态表所在的扇区磨损过快。

这套AB OTA方案目前已经稳定用在我手上的两个小项目里,一个是一套带传感器的采集网关,一个是电池供电的抄表模块。两个项目业务逻辑完全不同,但OTA这部分代码是同一套,改动的地方基本只有分区地址和串口引脚。下次再做带CAN或者以太网的固件升级,这套框架也能平移过去,只要把传输层从串口换成CAN或者TFTP就行。希望这个从零复现的完整记录,能帮你少走一点弯路。

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

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

立即咨询