
STM32F103做OTA升级很多人第一反应是“不就做个IAP嘛Bootloader跳进去就行”。但真到了产品量产阶段会发现单分区OTA有个致命问题升级到一半断电设备就变砖了只能拆机用烧录器救。这体验客户一旦遇上基本就对你失去信心了。我这次要复现的是STM32F103上的AB分区OTA方案也就是把Flash里放两份App镜像A区和B区Bootloader在启动时选择有效的一份运行升级时写入另一份空闲分区写失败也不影响当前系统运行。这套机制是安卓系统最早普及开的现在在物联网设备上也越来越常见。这篇教程我会从底层原理、分区设计、代码实现到完整复现步骤全部拆开讲适合已经跑通过基础IAP、想给产品加上可靠升级机制的中级开发者也适合刚接触OTA、想搞明白“AB分区到底怎么落地”的初学者——只要你手里有一块STM32F103最小系统板就能跟着做出来。1. 整体架构为什么选AB分区而不是传统方案1.1 传统单分区升级的痛点所谓的“单分区OTA”其实就是把Flash划分为Bootloader区和App区升级时先擦除App区再把新固件写入App区最后跳转运行。这种方案结构简单很多现成例程都是这么写的跑通很容易。但实际用起来问题不少升级过程是“先擦后写”擦除和写入中间存在一个窗口期一旦断电或通信中断Flash里就是半擦半写的状态轻则App起不来重则连Bootloader都跳不进去如果擦除范围或标志位处理不当。没有回滚能力。新版本写进去之后哪怕一运行就死机也没办法恢复到上一个稳定版本——旧固件已经被覆盖了。一旦进入这种“半死”状态远程恢复手段基本为零只能走售后返修或者现场拆机代价极高。我最早给一个设备做远程升级时用的就是单分区方案结果客户在升级过程中直接拔了电设备变砖最后快递回来我拿J-Link刷了半天。从那以后凡是要求远程升级稳定性的项目我都优先上AB分区。1.2 AB分区的核心设计逻辑AB分区的思路一句话就能说清楚同一时刻只有一份App在运行另一份App留作备份升级永远只动还没被使用的那一块。具体到STM32F103上Flash空间通常为256KB或512KB。我们规划时Bootloader占一块App A占一块App B占一块再留一小块参数区存放启动状态和升级标志。Bootloader在每次上电时读取参数区的状态记录如果当前运行分区为A且B有新的待升级镜像则擦除/写入B写入完成后设置B为新启动分区复位如果当前分区标记为“已运行但未确认”则说明新固件可能有问题此时可以回滚到另一分区如果两边都有效则按默认启动顺序比如优先A执行跳转。这样带来的好处非常明显原子性升级操作只对空闲分区进行当前运行分区完全不受影响。写入过程中出任何问题重启后依然能回到旧版本。可回滚新分区首次启动后App必须主动上报“运行成功”并确认标志位。如果上电后一段时间内没有确认Bootloader会判断升级失败自动切回旧分区。支持“升级后测试”你完全可以先升级到B跑几天验证没问题再确认确认后下次再从B启动A就变成了空闲区为下一次升级做准备。这和“OTA”的产品语义是天然匹配的。OTA升级的核心不是“能把新代码传上去”而是“在不可靠的传输环境和无法预判的用户行为下保证设备永远可用”。AB分区就是冲着这一点去的。1.3 一个简洁可行的AB流程示例为了不把思路搞乱我在这个项目里给它定义成一套状态机分区编号用active_slot和update_slot表示空闲IDLE - 收到升级包 - 接收DOWNLOADING 接收完成CRC校验通过 - 写入W分区FLASHING 写入完成 - 设置启动标志PENDING - 软复位 Bootloader启动 - 发现PENDING - 跳转新分区 App运行正常 - 上报成功 - 标志改为CONFIRMED App运行异常超时未确认 - Bootloader回滚 - 切回旧分区这个流程里最关键的设计是“两个分区互相备份标志位引导切换”。实际代码我后面会给出。先说清楚STM32F103因为没有硬件双Bank和自动切换机制所有AB状态管理都要靠软件在Flash参数区维护一个结构体这个结构体要防掉电丢数据我用的办法是“互为备份的双槽位结构”即同一标志信息写两份启动时两份都读出来做交叉校验选其中合理的一份。这块在问题排查章节我会专门讲。2. 环境与准备硬件选型、工具链和Flash规划2.1 硬件最低要求复现这个项目不需要很贵的板子我的实验环境是这样一套STM32F103C8T6最小系统板中容量64KB Flash或者STM32F103ZET6大容量512KB Flash更适合跑完整AB演示。USB转TTL模块用于Bootloader和PC通信我用的CH340稳定便宜。3.3V电源、按键复位板上自带也行。一根杜邦线或者调试器ST-Link/J-Link用于第一次烧录Bootloader和App以及救砖。如果你用的是C8T6Flash只有64KBBootloader占8KB两个App区各占24KB再加2KB参数区总共58KB刚好塞下。这种挤压式的分配适合验证原理如果是ZET6512KB空间就很充裕了Bootloader给16KBApp A和B各给192KB参数区4KB剩下100多KB还能做资源区和日志区。我下面的示例代码按ZET6这种大Flash来写但链接脚本里给出注释方便你改成小Flash。2.2 软件工具链这里我不纠结IDE你自己顺手就好。我实际用的组合是Keil MDK 5.x工程配置方便AXF转bin一条命令搞定。STM32CubeMX用来快速初始化时钟、串口、按键、看门狗省去手写寄存器配置的时间。STM32F1xx HAL库或标准外设库两种我都用过这篇代码基于HAL库因为用CubeMX生成工程更省事逻辑也更清晰。自定义PC端升级工具Python脚本写的读bin文件、分包发送、做CRC校验和ACK/NAK交互。你完全可以用SSCOM或XCOM手发hex数据代替但为了复现效率建议用脚本。还需要一个小工具srec_cat或者Keil自带的fromelf用来生成带偏移地址的bin文件后面讲App工程配置的时候会细说。2.3 Flash分区表设计分区规划是整个AB OTA的地基。我第一次做的时候随手把App A放在0x0800C000App B放在0x08010000结果两个App编译出来的链接脚本没对应上Bootloader跳转过去直接HardFault。后来我学乖了先把分区表打印出来贴在屏幕前照着写。以下是这套模板的分区表以512KB Flash为例分区名起始地址大小说明Bootloader0x0800000016KB包含启动代码、AB管理逻辑、跳转代码App A0x08004000192KB默认启动分区App B0x08034000192KB备份/升级目标分区Params0x080640004KB存放启动标志、升级状态、确认计数Reserved0x08065000剩余可做日志、字库、配置区注意几个细节Bootloader的起始地址必须等于Flash的起始地址因为STM32上电固定从0x08000000开始取MSP和PC指针。App区的起始地址必须按Flash扇区大小对齐。F1大容量系列的扇区是1KB前4个和2KB后面的只要你地址不是1KB的整数倍擦除就会出问题。所以App A和App B的起始地址最好都是至少2KB的倍数。Params区不要放在最后一个扇区的末尾因为扇区擦除粒度是固定的你要擦大就可能涉及到别的数据。单独留一块4KB里面再用双槽结构一个槽一个扇区这样擦除时不会互相干扰。3. 核心代码实现Bootloader与App联动的关键细节这一章是全篇的精华我会把能直接用起来的代码片段写出来并解释每段代码背后的意义。代码基于STM32 HAL库工程由CubeMX生成主频72M串口1PA9/PA10用于升级通信串口2作为日志输出可选。3.1 链接脚本给App“搬家”App工程里最重要的一件事就是让编译出来的代码认为自己活在App区起始地址。如果你在Keil里用需要两步在工程选项的“Target”页把IROM1起始地址改成0x08004000大小改成0x30000192KB。同时把IRAM设置成0x20000000大小0x1000064KB根据你的芯片实际溢出调整。勾选“Create HEX File”并在“After Build”里添加一行命令fromelf --bin --output.\Output\app.bin .\Output\app.axf这样编译后直接生成App的bin文件这个文件里记录的指令地址全部以0x08004000为基址。GCC环境就改链接脚本里的FLASH (rx) : ORIGIN 0x08004000, LENGTH 192K思路一样。3.2 中断向量表重定向这是“App跑飞”和“中断不响应”的最常见原因。STM32F1没有像F4那样专门的VTOR寄存器需要在App启动的最早阶段把向量表复制到SRAM然后修改SCB-VTOR指向SRAM里的新向量表。我通常在App工程里的main()第一行调用这个函数void JumpToApp_Init(void) { // 向量表大小按中断数量来实测给64个就够了 #define VECT_TABLE_SIZE 64 // 中断向量表在Flash中的首地址就是App区的起始地址 #define APP_BASE_ADDR 0x08004000 SCB-VTOR SRAM_BASE | (APP_BASE_ADDR 0xFFFF); // 如果芯片支持可以从Flash直接映射F1没有硬件映射必须复制到RAM // 下面把Flash中的向量表复制到SRAM起始位置 for (uint32_t i 0; i VECT_TABLE_SIZE; i) { *((volatile uint32_t *)(SRAM_BASE (i 2))) *((volatile uint32_t *)(APP_BASE_ADDR (i 2))); } __DSB(); __ISB(); }注意SCB-VTOR是Cortex-M3内核自带寄存器F1也能用但它的地址偏移是0xE000ED08老的一些标准外设库没开放这个定义你可以直接*(volatile uint32_t *)0xE000ED08 ...。我踩过坑有些精简标准库在低版本编译环境里找不到SCB_VTOR用这种方式反而更直接。还有一点跳转进入App前Bootloader最好关掉所有自身用到的中断、滴答定时器和DMA否则App一启动中断来了向量表还指在SRAM底部如果没复制好就是HardFault。3.3 Bootloader跳转逻辑跳转不是简单地把PC指过去而是要做完整的三步校验App区开头4字节栈顶指针是否落在SRAM范围内校验App区开头第5~9字节复位中断向量是否落在Flash范围内设置MSP为主堆栈指针再跳转。代码示例typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_msp *(volatile uint32_t *)app_addr; uint32_t app_reset_vector *(volatile uint32_t *)(app_addr 4); // 跨校验 if ((app_msp 0xFFF00000) ! 0x20000000) { error_handler(); return; } if ((app_reset_vector 0xFFF00000) ! 0x08000000) { error_handler(); return; } // 关闭全局中断 __disable_irq(); // 关掉SysTick清挂起 SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 跳转 pFunction jump_func (pFunction)app_reset_vector; HAL_RCC_DeInit(); __set_MSP(app_msp); jump_func(); while(1); }这里为什么先校验因为如果App区是空的或者擦了一半这两个地址值很可能是0xFFFFFFFF跳过去就是进HardFault然后再复位设备就卡死在Bootloader里。Bootloader本身要做成“永不卡死”的逻辑异常了原地死循环但可以等串口命令重新升级。3.4 升级标志与AB状态机AB分区“谁启动、谁待升级”靠参数区里保存的一组标志来管理。因为Flash只能由1写0、擦除变全1所以标志位最稳妥的协议是“以0x0000表示有效以0xFFFF表示无效/废弃”而不建议用0x55AA这种正逻辑否则擦除后全1反而容易误判。我设计了一个简单的结构体#define PARAM_BASE_A 0x08064000 #define PARAM_BASE_B 0x08064200 // 双槽备份 typedef struct { uint32_t magic; // 0xA5A5A5A5 表示结构体有效 uint32_t active_slot; // 0: AppA, 1: AppB uint32_t boot_status; // 0: 正常, 1: 待确认, 2: 待回滚 uint32_t version; // 当前运行App版本号便于日志 uint32_t crc32; // 对这个结构体前n个字段做CRC } param_t;读参数的操作会比较绕先读A槽读完对CRC如果CRC不对就读B槽如果两边都有效以boot_status更合理的一边为准如果都无效直接默认A区启动。这样做是避免升级过程中突然断电写了一半把唯一的一份参数刷坏。写入参数时我先擦除一个槽再写入新内容然后擦除另一个槽并写入同样的新内容。擦两个槽之间有掉电风险所以逻辑上只要有一个槽是完整的就能恢复出正确的状态。写成代码就是两个函数param_read()和param_write(new_param)。这里有个官方文档不会写的心得写好参数后最好再加一条__DSB()指令强制等待Flash写入完成再继续。因为F1的Flash写是异步的如果立即软复位可能参数还没落盘下一次Bootloader读出来的还是旧值导致升级循环。3.5 通信协议与数据校验OTA升级的传输层我用的是一套简单可靠的私有协议一帧长度 4字节帧头 2字节命令 2字节长度 n字节数据 4字节CRC32。帧头0x5A 0xA5 命令 0x01 握手请求上位机发 0x02 握手应答设备发 0x03 开始升级包含版本号、总长度 0x04 数据块每块256字节 0x05 结束升级包含CRC 0x06 升级成功 0x0F 错误流程上位机发握手请求设备回版本和就绪状态上位机发开始升级设备擦除空闲分区并返回擦除完成上位机循环发数据块每包数据设备接收到后先放到RAM缓冲写进Flash后回ACK全部发完后上位机发结束升级设备对整包做CRC32对比相同则写“待确认”标志再软复位。数据块大小我用256字节为什么不是512或者1024因为STM32F103的Flash每次字节编程是16位粒度半字我按半字写入256字节一次DMA或者循环能较好地匹配Flash内部写操作时序同时在串口115200波特率下每包传输时间不至于太长出错了重传成本也低。更大的包虽然效率高但出错后整个包都要重传调试时很不方便。上位机Python脚本里有一个很关键的小函数读取固件并打包发送我贴核心部分def send_packet(cmd, datab): frame bytearray() frame b\x5A\xA5 frame bytes([cmd]) frame len(data).to_bytes(2, little) frame data crc zlib.crc32(frame) 0xFFFFFFFF frame crc.to_bytes(4, little) ser.write(frame)设备端解析时一定不要只依赖帧头CRC32必须做。我见过有人图省事只校验长度不校验内容结果Flash里写进去一堆错数据还莫名奇妙跑起来一个错误版本排查了一个通宵。4. 完整复现步骤从零手把手跑起来这一章我会按顺序走一遍流程你跟着做就行。假设你已经有CubeMX生成的基础工程知道怎么点引脚、配时钟。4.1 第一步搭建最小Bootloader工程用CubeMX新建工程芯片选STM32F103ZET6或C8T6时钟用外部晶振频率配到72MHz串口1使能中断波特率115200一个按键接PA0作为手动回滚测试一个LED接PC13作为状态指示。生成工程后把上面讲的“跳转函数”和“参数读取函数”放进去main()里做一个轮询按下按键强制清除待确认标志跳转当前active_slot否则直接跳转active_slot。这里要注意Bootloader本身不要开SysTick和任何外设中断。因为一旦开了中断跳转到App之前你有可能忘了关App刚起来中断就来了然后RAM里的向量表还没准备好直接HardFault。我在Bootloader里基本只用阻塞式读取串口不开中断升级期间偶尔点个LED。编译设置里把整个Bootloader的优化等级设为-O0或者-Og方便调试。4.2 第二步制作App工程含偏移配置新建另一个工程作为App。CubeMX里芯片型号相同但把IROM1改到0x08004000大小写0x30000然后写一段测试代码每500ms翻转LED并通过串口打印“App Run”。再加一个50ms间隔的看门狗刷新任务模拟真实产品。在App工程里加入“中断向量表重定向”函数在main的最前面调用。否则你会发现Bootloader跳过去后系统滴答中断退不出来因为向量表还指向Flash最前面而那里是Bootloader的东西。编译后用Keil的fromelf生成app.bin。为了验证偏移是否正确用十六进制编辑器看一眼app.bin的前16字节。正常应该是第0~3字节栈顶地址形如 0x2000xxxx 第4~7字节复位向量形如 0x080041xx如果这里不是0x2000/0x0800带头说明你的IROM设置没生效往后全部白做。4.3 第三步实现升级协议与Flash写入设备端主控代码分成“透明转发”和“Flash写入”两块。串口中断收到一帧完整数据后进解析器void OTA_Parse(uint8_t cmd, uint8_t *data, uint16_t len) { static uint32_t write_addr; static uint32_t total_len; static uint16_t recv_count; switch(cmd) { case CMD_HANDSHAKE: { // 返回当前active_slot和版本号 } break; case CMD_START_UPDATE: { total_len (data[0] 0) | (data[1] 8) | (data[2] 16) | (data[3] 24); write_addr GetUpdateSlotBaseAddr(); // 返回B区或A区地址 EraseUpdateSlot(); // 按扇区擦除 recv_count 0; SendACK(CMD_START_UPDATE); } break; case CMD_DATA: { // 数据长度固定256不足时用0xFF填充 ProgramFlash(write_addr, data, len); // 按半字写 write_addr len; recv_count len; SendACK(CMD_DATA); } break; case CMD_END_UPDATE: { uint32_t host_crc 0; memcpy(host_crc, data, 4); if (host_crc ComputeFlashCRC(GetUpdateSlotBaseAddr(), total_len)) { // 写入待确认标志 SetBootStatus(STATUS_PENDING); SendACK(CMD_END_UPDATE); delay(100); NVIC_SystemReset(); } else { SendNACK(); } } break; } }擦除空闲分区这一步是整个升级过程中耗时最久的在192KB的扇区上大概需要1~2秒所以上位机那边等待响应要把超时放长一点。我一开始按1秒设超时结果总是提示失败后来才发现是擦除还没完成。4.4 第四步AB切换与回滚验证升级完成后设备会自动软复位Bootloader读取参数区发现boot_status是“待确认”于是跳转到新的分区去执行。App这边要承担“确认”任务void App_confirm_slot(void) { // 假设确认条件运行10秒无异常或收到用户确认指令 if (power_on_time 10) { SetBootStatus(STATUS_OK); // 清除待确认 } }如果你不主动确认Bootloader端要有个机制在升级前记录当前启动次数。比如Bootloader发现新分区status待确认它就把一个启动计数器加1如果计数器超过3次还没有被App确认为OK就强制回滚到旧分区。代码里就是判断counter#define MAX_BOOT_COUNT 3 if (param.boot_status STATUS_PENDING) { boot_count_erased_increment(); // 在Flash里递增计数器 if (param.boot_count MAX_BOOT_COUNT) { // 回滚 param.active_slot OLD_SLOT; param.boot_status STATUS_ROLLBACK; param_write(param); JumpToApp(GetOldSlotBaseAddr()); } else { JumpToApp(GetNewSlotBaseAddr()); } }这里有个坑如果你在Bootloader里把计数器写在参数区的同一个扇区里每加一次就要擦除整个扇区擦除次数多了寿命是个问题。所以我单独开一个2KB的计数器区专门做“擦写计数”因为它是OTA中高频写的区域。参数区本身只是改active_slot时偶尔写一次寿命压力很小。回滚验证的实操方法把App A的代码写一个“碰碰爆”的逻辑——串口打印但是LED不闪然后升级到BB跑起来后你把电源断开再上电发现Bootloader在3次内发现新App没有给出OK就会回滚到A。这个测试能直观感受AB的好处。5. 常见问题与排查技巧实录这一章我会把复现这个AB OTA过程中最容易踩的坑列出来附上我当时的排查思路。你如果遇到类似现象先别急着怀疑芯片坏了。5.1 现象速查表现象可能原因解决思路跳转后HardFaultApp向量表没重定向 / 复位向量非法检查SCB-VTOR执行时机检查App bin前8字节App能运行但所有中断不响应向量表还在Flash起点确认向量表已复制到SRAM且SCB-VTOR写好升级完成后反复重启待确认标志没清除 / Bootloader计数器逻辑错在App里增加确认函数Bootloader里检查boot_countFlash写入返回错误地址越界/未按半字对齐/擦除不彻底检查写入地址是否在App区范围内数据长度是否2字节对齐参数区读出来全是0xFF双槽都没写入成功检查param_write流程是否擦了整个扇区再写入升级到一半串口不响应Bootloader开了中断没关闭Bootloader使用阻塞模式或跳转前记得关中断B区写成功了但跳过去还是A参数区active_slot没改检查“设置待确认标志”时是否顺手改了active_slot下载速度太慢波特率低或包太小提高波特率到460800数据块设512字节5.2 跳转失败怎么办我调试时最常碰到的就是跳转失败。第一步永远是看App区前8字节。用ST-Link的mem窗口读0x08004000如果看到“FF FF FF FF FF FF FF FF”说明App根本没烧进去如果看到0x20000800 0x08004131说明App区有数据问题多半在App自身。如果是“跳到App后白屏/跑飞”先看App是否调用了向量表重定向函数再看App工程的Target里IROM是不是真的改了。很多人改了IRAM忘了改IROM编出来的代码仍然是0x08000000开头Bootloader跳过去实际上还是执行Bootloader的代码调试器Debug一下就会发现PC值很奇怪。还有个坑App编译时如果开了微库/微函数栈顶地址可能不在SRAM范围内你可以在启动文件里固定栈大小并查一下编译map文件里__initial_sp的值确保在0x20000000~0x2000FFFF之间。5.3 Flash擦写异常F1的Flash擦写有几个特殊规则不满足就报错或者写不进去写入必须按半字16位进行不能像F4那样一次写1字节的任意模式。你如果用uint8_t数组逐字节编程编译器会拆成两个半字操作结果很容易错乱。擦除前必须解锁Flash操作完后最好重新上锁。一个扇区擦除后不能再对扇区中未写的地址写数据除非再次整扇区擦除。我在写ProgramFlash时是这样做的void ProgramFlash(uint32_t addr, uint8_t *data, uint32_t len) { HAL_StatusTypeDef status; uint32_t half_words len / 2; uint16_t *p (uint16_t *)data; status HAL_FLASH_Unlock(); if (status ! HAL_OK) { /* error */ } __disable_irq(); for (uint32_t i 0; i half_words; i) { status HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr i*2, p[i]); if (status ! HAL_OK) { __enable_irq(); HAL_FLASH_Lock(); return; } } __enable_irq(); HAL_FLASH_Lock(); }这里我额外做了一件事写Flash期间关闭全局中断。因为Flash编程时如果来一个中断HAL库会卡在等待BSY标志上而中断服务函数里也许还想访问Flash就会死锁。我遇到过两次这种情况关中断后问题消失。代价是损失几十微秒的中断响应对于OTA写入这种低速任务可以接受。5.4 关于双Bank的小知识可能有朋友会问STM32F103不是也有双Bank Flash吗这里要区分一下F1的双Bank是指大容量产品里可以把Flash分成两个Bank独立擦写但和AB OTA的“两个App分区”不是一回事。F1的Flash双Bank通常用来做串行擦写和读取并行或者配合片内Bootloader做某种双镜像启动但没有硬件自动AB切换。真正意义上“硬件AB自动切换”的一般是F4系列的部分型号和STM32F7/H7里面有个Flash双Bank配置可以让CPU从一个Bank启动另一个Bank在线更新。F103这边还是得靠软件控制所以本文这套逻辑放到F4/H7上也通用你只需要把跳转地址、扇区擦除粒度相应调整就行。5.5 经验心得最简单有效的回滚测试方法我自己的标准测试流程供你参考先在A区烧一个稳定版本比如LED慢闪version1.0升级到B区version2.0LED快闪运行5秒后自动确认再改A区故意放一个死循环version3.0从B区升级到A区让它待确认复位两次Observe看到Bootloader三次后回滚到B区。这个流程把“升级成功→确认正常→升级异常→自动回滚”整个闭环跑通能有效验证你的状态机并且能逼出很多边界bug比如确认标志没写、计数器溢出、参数区读错等。6. 写在最后这套东西还能怎么扩展复现到这个程度AB OTA的核心机制你已经完整掌握。你可以在此基础上做的事还有很多——比如把升级包从裸bin换成带签名的固件包防止固件被篡改比如把校验算法升级为SHA256加速用硬件CRC比如给Bootloader加一个串口命令行方便产线测试时手动强制切换分区再比如把参数区做成类似文件系统的小Log区记录每次升级时间、版本、结果方便售后定位问题。我在实际项目中一般是把Bootloader做成只负责“选择跳转回滚”的最小角色真正的网络下载、认证、验签都放到App层App先下载完整固件包到外部Flash再请求Bootloader做最终的写入。这样能降低擦写失败的风险也方便做多级版本管理。你如果做产品建议也这么考虑。别把Bootloader弄得过于臃肿毕竟它一旦坏了整个设备都进不了系统越简单越可靠。如果你第一次跑这个教程遇到了坑欢迎把你日志里的现象和错误代码发到评论区我们一起把问题拆透。嵌入式这东西一个跳转地址差一位就是云泥之别但多踩几次坑你自然就懂它了。