
去年我做一个小型工控设备主控用的STM32F103C8T6。第一批货发出去之后现场反馈要升级固件我第一反应是派人带ST-Link去刷后来算了一笔账一台一台拆机、接线、烧录、装机光差旅费就够买好几个开发板了。于是我把AB分区OTA完整做了一遍从Bootloader到App侧、从传输协议到异常回滚前后折腾了大概两周。这篇文章就是一次完整的从零复现记录把Flash怎么分区、跳转代码怎么写、App怎么改、回滚怎么做这些事全讲清楚。不管你是第一次接触STM32F103 OTA还是已经做过单备份升级想换AB方案这篇都能直接抄作业。1. 触达痛点F103为什么也要做AB分区OTA1.1 AB分区OTA到底解决什么问题先说结论AB分区OTA的本质是把Flash里留出两份可以独立运行的固件区。当前运行A区时如果想升级就把新固件写到B区写完后置一个标志位重启后Bootloader跳到B区运行。如果B区跑不起来还能退回A区。这种方案在手机上叫A/B无缝升级在汽车电子里叫双Bank OTA原理一样。那F103这种老片子为什么要做AB分区常见替代方案是单备份升级只有一个App区Bootloader接收完固件后直接覆盖当前App。听着简单但有一个致命问题如果写入过程中断电或者写入的新固件本身有问题设备就变砖了还得拆机恢复。AB方案把“正在升级的区”和“正在运行的区”彻底分开写入失败或运行失败都不影响旧固件这是它最大的价值。实际项目里F103还大量存在尤其是小家电、传感器节点、电机控制器这种对成本敏感的场景。这些设备没有以太网也没有大容量Flash但通过UART、RS485甚至CAN做OTA完全可行。AB方案虽然牺牲了一部分Flash空间但换来了“远程升级不怕断”的底气。1.2 学习方案与量产方案的差异如果你是刚接触OTA最容易踩的坑是看别人的教程用STM32F407Flash有1MB随便分了256KB一个区然后你也照着在F103上抄结果编译出来的App直接放不进去。F103最常见的最小系统板C8T6只有64KB FlashF103RCT6是256KBF103ZET6是512KB容量差异非常大方案必须跟着容量走。我自己复现时用的就是最便宜的那种蓝色C8T6最小系统板64KB Flash、20KB RAM资源很紧。所以我的分区是按“能跑通AB流程”的最小规模来设计。如果你手里的片子是256KB以上的可以把Bootloader和App区都扩大思路完全一样只是地址不同。再说量产方案的一个坑AB分区要求App能适应两个不同的运行地址。换句话说同一份源码要能编译出A区固件和B区固件两者的启动地址和中断向量地址不一样。有些人会想“我只编译一份固件烧到A区能跑下次升级直接把它写到B区”这绝对不行因为编译时指定的地址已经决定了跳转和中断向量表的位置。2. Flash分区规划与工程准备2.1 一张表说清Flash分区以STM32F103C8T6为例Flash从0x08000000开始总大小64KB也就是0x10000字节。F103C8的中容量Flash页大小是1KB所以擦除最小单位是1KB。我的分区如下区域起始地址大小用途Bootloader0x0800000016KB上电判断、跳转、升级兜底App A区0x0800400024KB正常运行固件AApp B区0x0800A00023KB备用固件B末尾留1KB给标志页升级标志页0x0800FC001KB保存版本、CRC、启动计数、切换标志有人会问为什么B区只有23KBA区是24KB这样不对称是不是有毛病这就是64KB Flash上做AB的尴尬之处。Bootloader不能太小否则YMODEM或自定义协议、Flash擦写、CRC校验这些代码塞不下。A、B两个App区各占24KB的话Flash就用完了没有地方放标志页。我的做法是从B区末尾借了1KB出来放标志所以B固件实际可用空间是0x0800A000到0x0800FBFF共23KB。这个分区方案不是死的。如果你的App很小比如编译出来只有10KB那可以改成Bootloader 16KB、A区24KB、B区24KB、标志页1KB预留这样两边都还是24KB。如果换用256KB的RCT6我建议Bootloader 32KB、A区112KB、B区112KB最后留1KB或4KB标志区空间宽裕很多。2.2 环境准备CubeMX、Keil与串口工具工程基础我推荐用STM32CubeMX生成选HAL库。F103的老牌标准外设库V3.5.0也能实现OTA但HAL库在处理Flash擦写、串口收发时更省事而且CubeMX能直接配置时钟和串口新手不容易在初始化上翻车。具体准备工作安装STM32CubeMX、Keil MDK以及对应芯片的器件包。准备一个USB转TTL模块推荐用CH340或CP2102连接F103的USART1PA9-TX接模块RXPA10-RX接模块TXGND必须共地。下载工具起码准备两个一个用来烧Bootloader比如ST-Link或J-Link一个用来做OTA传输测试比如XCOM或SecureCRT。这里有个小经验调试OTA功能时串口线质量很重要。我之前用一根劣质杜邦线跑115200波特率偶发性错包排查了一上午后来换短线、降低到57600才稳定。OTA传输对误码率很敏感不要上来就挑战921600这种高速率。用CubeMX配置USART1异步模式波特率1152008位数据、无校验、1位停止位。另外把USART1的全局中断打开方便后面接收分包数据。GPIO不用做额外配置PA9和PA10会自动设置成复用功能。2.3 Bootloader与App的编译地址配置地址配置是AB OTA最容易出错的一步。Keil工程里Options for Target - Target - IROM1决定代码放在哪、能占多大。Bootloader工程IROM1起始地址0x08000000大小0x400016KB。App A工程IROM1起始地址0x08004000大小0x600024KB。App B工程IROM1起始地址0x0800A000大小0x5C0023KB。IRAM1保持默认0x20000000、大小0x500020KB即可。这里要特别强调App A和App B虽然是同一份源码但必须维护两个不同的工程配置或者用宏定义切换链接参数。我自己的做法是在Keil里用两个Project Target一个叫APP_A一个叫APP_B它们的IROM1地址不同编译时切换一下就能出两份bin。有人会问为什么不用同一个地址然后靠运行时把App复制到另一份空间因为Cortex-M3的中断向量表位置和链接地址是紧绑定的不能让代码以为自己跑在0x08004000实际上却把固件放到0x0800A000执行这样一进中断就全乱。3. Bootloader与App核心代码实现3.1 Bootloader跳转代码细节决定成败Bootloader是整个AB OTA的地基。它的核心任务就三件事检查启动标志、决定跳去A还是B、如果两边都不可用则进入升级等待。先看最关键的跳转函数#define APP_A_ADDR 0x08004000 #define APP_B_ADDR 0x0800A000 typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_sp *(volatile uint32_t *)app_addr; uint32_t app_pc *(volatile uint32_t *)(app_addr 4); pFunction jump; /* 关闭全局中断 */ __disable_irq(); /* 关闭SysTick并清空计数值 */ SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; /* 清空所有中断的Pending和Enable */ for (uint32_t i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFF; NVIC-ICPR[i] 0xFFFFFFFF; } /* 设置MSP后跳转 */ __set_MSP(app_sp); jump (pFunction)app_pc; jump(); }这段代码很多人抄过但有几个细节常被忽略。第一关闭全局中断只是第一步NVIC里可能还挂着Pending中断如果不清跳过去App刚启动就进中断而App的SP还没完全就绪直接HardFault。所以我用ICER清使能、ICPR清挂起双管齐下。第二SysTick要归零不然Bootloader里如果用了HAL_DelaySysTick中断会在App里触发App又没有对应的处理函数也会出问题。第三SCB-VTOR的设置放在App侧但有人喜欢在Bootloader里先设好两种都行保持前后一致即可。主函数里的判断逻辑大致是这样int main(void) { HAL_Init(); SystemClock_Config(); UART_Init(); ota_flag_t flag; ReadFlag(flag); if (flag.magic ! OTA_MAGIC) { /* 没有有效标志进入升级等待 */ EnterUpdateMode(); } if (flag.pending PENDING_TO_B) { if (CheckAppValid(APP_B_ADDR)) { flag.boot_cnt; WriteFlag(flag); JumpToApp(APP_B_ADDR); } else { RollbackToA(flag); } } else if (flag.pending PENDING_TO_A) { /* 类似逻辑跳回A */ } else { if (flag.active ACTIVE_A) JumpToApp(APP_A_ADDR); else JumpToApp(APP_B_ADDR); } }实际工程里还要加一个判断如果active指向A但A区首地址的值不是有效栈顶比如全0xFF说明从来就没烧过App这个时候不要硬跳应该进入串口升级等待状态。我的做法是检查SP是否落在0x20000000到0x20005000范围内以及复位向量是否落在0x08000000到0x08010000范围内两个条件都满足才认为是有效App。3.2 App侧中断向量偏移与启动自检App侧最重要的改动是设置中断向量表偏移。F103的Cortex-M3核支持通过SCB-VTOR寄存器指定向量表位置。在App的main函数最前面加上#define APP_BASE_ADDR 0x08004000 int main(void) { SCB-VTOR APP_BASE_ADDR; HAL_Init(); SystemClock_Config(); // ... }这一行必须放在HAL_Init之前还是之后我的习惯是放在最前面至少要在第一次进入中断之前执行。HAL库的SystemInit函数里会默认把VTOR设置为0x08000000如果你在HAL_Init之后才设置中间一旦有中断发生就会跳到Bootloader的向量表去执行而这时SP已经切到App了轻则跑飞重则HardFault。所以稳妥做法是main函数大括号后第一行就写VTOR。App A和App B的源码工程宏定义不同#if defined(APP_USE_B) #define APP_BASE_ADDR 0x0800A000 #else #define APP_BASE_ADDR 0x08004000 #endif这样同一份业务代码编译时加一个宏就能出A、B两份固件不需要维护两份源码副本。App启动后续还要做一次自检。自检不只是“GPIO灯亮一下”最好是做完整一点的健康检查比如Flash里关键参数能读、ADC自检正常、和外部芯片通信正常。这些检查通过之后才去把升级标志提交成“已确认状态”。如果自检不通过可以让系统故意不复位、不喂狗等看门狗超时后Bootloader自动回滚。3.3 升级协议设计分包、校验与标志位OTA传输我走的是USART 自定义分包协议。为什么不用YMODEMYMODEM成熟SecureCRT直接就能发文件但它在Bootloader里实现起来代码量大控制字符多而且想加版本校验、业务鉴权还得改协议。自定义协议虽然要写上位机但逻辑透明出了问题好排查。帧格式设计如下字段长度说明帧头2字节0xAA 0x55帧类型1字节0x01开始升级0x02数据帧0x03结束确认包序号2字节从0开始数据长度2字节每包256字节最后一包可为0CRC162字节整个帧的校验数据N字节固件内容App收到0x01帧后解析出总固件长度和整包CRC32然后返回ACK。收到0x02数据帧后先把数据放到一个256字节的缓冲校验帧CRC再调用Flash写入函数写进非活动分区。全部数据帧收完后App收到0x03结束帧这时需要二次确认本地累计计算的CRC32和之前收到的一致固件长度也是对的才更新标志页。关键代码是Flash写入void FlashWriteBuffer(uint32_t write_addr, uint8_t *buf, uint32_t len) { if (write_addr 0x08004000 || write_addr len 0x0800FBFF) { return; /* 防止越界刷掉Bootloader和标志页 */ } HAL_FLASH_Unlock(); uint32_t first_page write_addr ~(0x400 - 1); FLASH_EraseInitTypeDef erase; erase.TypeErase FLASH_TYPEERASE_PAGES; erase.PageAddress first_page; erase.NbPages 1; uint32_t page_error 0; HAL_FLASHEx_Erase(erase, page_error); for (uint32_t i 0; i len; i 4) { uint32_t word 0xFFFFFFFF; for (uint32_t j 0; j 4; j) { if (i j len) { word (buf[i j] (j * 8)); } } HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, write_addr i, word); } HAL_FLASH_Lock(); }写之前必须先擦页F103擦除后Flash内容全变成0xFF然后再按32位写。这里有个性能问题如果一包256字节就擦一次页写一个24KB固件要擦96次速度很慢而且费Flash寿命。我的优化是收到数据后先在RAM里攒够一页1KB再擦写一次这样24KB固件只需要擦24次。具体做法是维护一个1KB的buffer填满一页就写最后剩余不足一页的单独处理。还有一个容易被忽视的点F103写Flash时CPU访问Flash会被阻塞如果你的串口中断正好在写Flash期间触发中断函数里的代码执行会很慢但不会丢数据因为串口外设有硬件接收缓冲区。不过如果你用DMA接收记得在Flash擦写期间暂停DMA或处理好缓冲区环形结构否则DMA写RAM和Flash写并行会有竞态。3.4 完整升级流程与回滚机制我最终的升级状态机是这样的以当前运行A区为例上位机发送升级开始命令包含固件长度和CRC32。App A接收命令回复ACK进入升级模式。App A擦除B区对应的页然后一包一包接收数据边收边写B区。全部写完后App A把累计CRC与开始命令里的CRC32比对。CRC通过后更新标志页pending PENDING_TO_B记录B固件CRC和版本号active保持Aboot_cnt清零。App A调用NVIC_SystemReset()软复位。Bootloader上电发现pending是PENDING_TO_B检查B区是否有效把boot_cnt加1写入标志页跳转B。B App运行B启动自检自检通过后修改标志页active ACTIVE_Bpending 0boot_cnt 0。如果B自检失败不提交标志等IWDG超时复位。Bootloader再次启动发现boot_cnt超过阈值比如3判定B起不来清pending并跳回A。这个流程最关键的地方是boot_cnt和IWDG配合。Bootloader在跳转前就把IWDG启动并设置一个比较短的时间比如3秒。App正常启动后必须及时喂狗如果App卡死或者自检失败狗超时触发复位Bootloader就能抓住“B区没起来”这个事实。代码示意if (flag.pending PENDING_TO_B) { flag.boot_cnt; WriteFlag(flag); if (flag.boot_cnt MAX_BOOT_FAIL) { flag.pending 0; flag.active ACTIVE_A; flag.boot_cnt 0; WriteFlag(flag); JumpToApp(APP_A_ADDR); } else { JumpToApp(APP_B_ADDR); } }有人可能会问Bootloader跳转前启动了IWDGApp里如果没有初始化IWDG喂狗函数会不会不生效不会IWDG是独立看门狗一旦使能就不能关闭App里只需要往键值寄存器写0xAAAA就能喂狗。所以标准做法是Bootloader负责启动IWDGApp只负责喂狗和自检两边不要重复初始化。4. 常见问题与避坑实录4.1 跳转死机与HardFault排查跳转后死机是最常见的问题现象多种多样屏幕不亮、串口无输出、Debug停在HardFault_Handler、或者反复复位。排查顺序我建议这样第一确认跳转地址对不对。如果App A编译的起始地址是0x08004000但你写的是0x08006000那第一个word读出来的不是SP第二word也不是复位向量跳过去必然死。先用调试器读一下目标地址的0x08000000到0x08000007第一个word应该是0x2000xxxx这种RAM地址第二个word应该落在0x0800xxxx范围。第二确认VTOR。很多人跳转过去后发现printf能打印第一行但一进中断就死这种情况十有八九是SCB-VTOR没设置或者设置的位置太晚。第三确认外设状态。Bootloader里如果初始化过串口、定时器等外设跳转前最好把用到的外设DeInit一下否则App初始化相同外设时可能遇到挂起中断。我踩过的坑是Bootloader里用轮询方式发过串口数据跳转前没有关闭USART结果App的串口中断一开启立刻进入未处理的中断服务函数。4.2 Flash擦写失败与地址越界Flash擦写失败有几个典型原因。最蠢也最常见的是不小心把Bootloader区域擦掉了。App在接收固件时写入地址算错了偏移比如忘了把Flash基地址加上去往0x00004000这种非Flash地址写或者写到了0x08000000把Bootloader冲了。所以App端的写函数一定要做地址边界检查我这个项目的边界是只允许写0x08004000到0x0800FBFF超出直接丢弃。第二个常见原因是Flash写保护。如果芯片在开发时开启了RDP读保护后面想用OTA写Flash会失败。用ST-Link Utility或者CubeProgrammer把读保护等级降到0会触发全片擦除等于所有固件都没了记得操作前备份。第三个原因是中断抢占。STM32F103写Flash时如果中断优先进来也尝试访问Flash外设可能造成操作卡死。保险做法是写Flash期间关闭全局中断或者把业务中断优先级设得比Flash操作相关的中断高但最简单就是分区块屏蔽中断。4.3 回滚失效与看门狗配合如果你发现新固件跑不起来但Bootloader没有自动回滚大概率是IWDG没启动或者App在死机前已经把标志提交了。我遇到过一种情况App在main开头就把active改成自己结果后续初始化卡死Bootloader以为提交成功就不回滚了。这个设计顺序很重要必须全部自检通过后再提交不能一启动就提交。还有一种情况就是boot_max设置成1而掉电时序比较差造成新固件其实能启动但恰好在上电瞬间出了问题Bootloader判断失败就回滚形成“升级永远不成功”的假象。我的建议是boot_max设3并且每次启动后如果App正常立即清零boot_cnt。这样即使一次偶然掉电导致启动异常也不会误判。4.4 下载/调试阶段的其它坑开发时最常见的坑是DAP下载失败。F103 BOOT0引脚如果被拉高芯片会进入系统存储器启动模式你烧的固件根本不会运行。出现“下载失败”或“No target connected”时先查BOOT0是不是低电平BOOT1保持低。我自己的板子上BOOT0默认接地但连接ST-Link时SWDIO和SWCLK线序接反也会导致下载失败先不要怀疑芯片坏了用万用表量一下。另一个开放坑是串口乱码。F103如果外部晶振没焊接好HSE起振失败CubeMX生成的工程会默认继续初始化但PLL可能锁不住串口波特率全是偏的。最小系统板上经常有这种“看起来能跑但串口乱码”的问题排查时先用示波器看晶振引脚波形或者干脆在CubeMX里把时钟源改成HSI内部8MHz先排除硬件因素。5. 从这套方案还能扩展什么5.1 移植到CAN、RS485、无线模块这套AB OTA的核心框架和传输介质无关。我把串口换成RS485时只多做了一个操作在发送数据前把DE引脚拉高发送完成后拉低。但要注意RS485总线是半双工如果上位机发送完一包立刻切到接收状态而单片机的回复和上位机的下一包在总线上碰撞就会出现CRC错乱。解决办法是上位机发完一包后等足够长的间隔比如3个字节时间再切收发。如果走CAN每帧CAN最多传8字节数据意味着要把256字节一包的数据帧拆成32个CAN帧协议头也要重新设计。CAN模式下SJW同步跳跃宽度等采样参数会影响抗干扰能力实际项目里我把SJW配置成1个时间单元波特率500KOTA跑下来丢帧率很低。但CAN OTA的调试复杂度比串口高不少不建议第一次做。如果走LoRa、4G等无线模块核心逻辑不变只是把串口收发换成模组的AT指令透传。这时要注意流控无线模块发送数据有延迟上位机不能一发十包然后闷头等要按照确认机制一包一包推。5.2 在其它MCU上复现AB思路这套思路我在ESP32上也跑过结论是如果平台自带的OTA机制够用就别自己再造轮子。ESP32的esp-idf里本来就有AB分区和ota机制配置好分区表后直接调用esp_ota_ops接口就行。但在一些资源更小的MCU上比如STC8、GD32、CH32V还是要手动做Bootloader AB切换。原理是一样的预留Boot区两个App区一个标志区跳转前处理好中断向量、栈指针、看门狗。不过要特别注意不同芯片的中断向量表重定位方式不一样。F103通过SCB-VTOR改向量表地址GD32F103兼容但有些8位单片机没有向量表重定向机制只能把所有中断函数放到固定地址或者用编译器提供的特殊段功能实现复杂度会上升。所以AB OTA不是随便抄一份代码就能通的底层细节一定要逐项核对。我在实际项目中还总结出一条经验OTA功能一定要做一个“假升级”演练也就是故意放一个坏固件进去确认回滚机制真正有效再发版。别等到产品已经在客户那边才第一次试丢包、断电、写坏这些场景。AB方案最大的价值不是“升级成功”而是“升级失败也能回家”这套保障机制本身才是产品质量的一部分。