尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32F103 OTA升级:AB分区方案从零实现详解

STM32F103 OTA升级:AB分区方案从零实现详解 直接讲结论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的F103Bootloader占16KB剩余240KBA分区从0x08004000开始分120KBB分区从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 A0x0800400024KB出厂默认运行区B分区App Slot B0x0800A00024KB备用运行区 / 升级写入区分区状态表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大小0x400016KB。App A工程填0x08004000大小0x600024KB。App B工程的理论地址是0x0800A000但实际编译App B时不需要重新开一个工程后面我会讲通过一份代码按偏移量切换的做法那才是工程上正用的方式。系统上电后Bootloader处于0x08000000这是芯片复位后的默认取指地址。Bootloader在main函数里做三件事读分区状态表决定跳哪个分区如果标志位表明需要升级进入升级模式接收固件否则直接跳转运行。代码写好后第一次烧录要把Bootloader烧进0x08000000App 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还是BApp启动时读这个标志就知道自己身处哪个分区。为什么要知道分区因为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补齐再写入。同理固件大小如果是奇数最后一字节补0xFFCRC计算时也要用补完的数据算否则发送端和接收端算出的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就行。希望这个从零复现的完整记录能帮你少走一点弯路。
返回列表