
1. 项目概述为什么AB分区OTA在STM32F103上不是“炫技”而是刚需你手头那块焊得歪歪扭扭的STM32F103最小系统板跑着温控程序、电机驱动或者一个简陋的Modbus从机突然客户发来一条消息“现场设备要远程升级固件不能停机也不能拆机。”——这时候你翻遍标准库v3.50文档、查遍CubeMX生成的HAL代码发现它连个像样的Bootloader模板都没有。别慌这不是你的问题是ST官方对F1系列的定位决定的它主打成本与稳定不预置高级升级机制。而AB分区OTA恰恰是解决“升级失败即变砖”这个嵌入式开发最痛痛点的成熟方案。它不是把固件简单擦写进Flash而是用两套独立的程序区A区和B区做“热切换”新固件先安静地刷进空闲区比如B区校验无误后仅修改一个跳转地址下次上电就自动从B区启动万一新固件跑飞了复位后Bootloader检测到B区异常立刻回退到A区继续工作。整个过程用户无感设备持续在线。这背后涉及的不是几个API调用而是对STM32F103内存映射、向量表重定向、Flash擦写时序、CRC32校验容错、以及Bootloader与App之间通信协议的深度掌控。我试过直接用Keil MDK烧录单一分区固件结果一次电源波动导致升级中断整块板子彻底失联返厂维修成本远超芯片本身。后来咬牙重写Bootloader把AB逻辑硬塞进F103那可怜的64KB Flash里才真正理解什么叫“在螺丝壳里做道场”。这篇教程不讲虚的不依赖任何第三方SDK或商业工具所有代码基于标准库v3.50用J-Link正版SN或兼容调试器均可实测通过目标就是让你从零开始亲手把这套机制在一块裸板上跑起来。适合正在做工业传感器、智能电表、或是任何需要“永不掉线”升级能力的F103项目的工程师也适合想真正搞懂Bootloader底层逻辑的学生——因为只有亲手抠过向量表偏移、算错过Flash页地址、被IAP擦写超时卡死过你才算真正入门。2. 整体架构设计与核心思路拆解为什么必须放弃“一键生成”选择手动缝合2.1 放弃CubeMX自动生成Bootloader的三个硬伤很多人第一反应是打开STM32CubeMX勾选“Bootloader”选项生成代码一烧了事。但我在实际项目中踩过坑必须明确告诉你对于F103的AB分区OTACubeMX生成的方案几乎不可用原因有三第一内存布局失控。CubeMX默认将Bootloader放在0x08000000起始地址App放在0x08002000假设Bootloader占8KB但它不会为你预留第二个App区B区。你要么手动修改链接脚本.ld文件要么在CubeMX里反复调整“Application Address”而F103的Flash页大小是2KB地址必须严格对齐。我曾因地址没对齐导致B区首字节写入时触发HardFault调试器直接失联。第二向量表重定向失效。F103的中断向量表固定在Flash起始处0x08000000App运行时必须将其复制到SRAM并重映射。CubeMX生成的代码只处理单App场景当App从A区0x08002000切到B区0x08006000时它不会自动更新SCB-VTOR寄存器指向B区的向量表。结果就是B区App一触发SysTick整个系统崩溃。这问题在CubeMX的GUI里根本找不到配置入口。第三AB状态管理缺失。真正的AB分区需要一个持久化存储区域通常放在Flash最后一页或EEPROM模拟区记录当前“活跃区”Active和“待升级区”Inactive以及每个区的校验码、版本号、状态标志Valid/Invalid。CubeMX不提供这套状态机逻辑你得自己写而且必须考虑断电瞬间写一半状态的原子性——比如写入“B区Valid”前断电系统重启后会误判B区可用导致启动失败。所以我的方案是完全抛弃CubeMX的Bootloader生成器用标准库v3.50手动构建。Bootloader和App作为两个完全独立的工程各自拥有专属的启动文件startup_stm32f10x_md.s、链接脚本stm32f10x_flash.ld和main函数。它们之间只通过一个约定好的“握手区”Handshake Area交换信息这个区域位于Flash末尾大小仅128字节存放结构体typedef struct { uint32_t active_bank; // 0: A区, 1: B区 uint32_t inactive_bank; // 0: A区, 1: B区 uint32_t a_crc32; // A区固件CRC32值 uint32_t b_crc32; // B区固件CRC32值 uint8_t a_status; // 0: Invalid, 1: Valid uint8_t b_status; // 0: Invalid, 1: Valid uint8_t reserved[114]; // 填充至128字节确保擦写原子性 } bank_info_t;提示这个结构体必须定义在Flash的最后一个页F103C8T6为第31页地址0x08007C00-0x08007FFF因为擦除一页是原子操作。写入时先擦除整页再写入新结构体避免断电导致状态混乱。2.2 AB分区的物理布局如何在64KB Flash里挤出三块“地盘”F103C8T6的Flash总容量为64KB0x08000000 - 0x0800FFFF。我们要把它切成三块Bootloader区、A区App、B区App。关键约束是每个区必须以Flash页2KB为单位对齐且Bootloader必须位于0x08000000这是硬件复位向量强制要求。我最终采用的布局如下区域起始地址大小说明Bootloader0x0800000012KB (0x00003000)占用前6页Page 0-5足够放加密、校验、串口协议栈A区App0x0800300024KB (0x00006000)占用Page 6-17常规App足够B区App0x0800900024KB (0x00006000)占用Page 18-29与A区大小一致便于镜像复制Bank Info区0x08007C001KB (0x00000400)Page 30专用于存储AB状态Page 310x08007C00-0x08007FFF为冗余备份这个布局的精妙之处在于A区和B区大小相同24KB意味着你可以用memcpy直接将新固件二进制流从串口缓存复制到B区起始地址无需考虑偏移计算Bank Info区独占Page 30擦写时不影响其他区Bootloader留足12KB为后续加入AES-128加签验签预留空间。有人问为什么不把Bootloader压缩到8KB实测发现加入完整的CRC32校验、XMODEM协议解析、以及Flash页擦写状态轮询后代码体积轻松突破9KB强行压缩会导致优化等级过高引发栈溢出。2.3 启动流程的“四步生死判”Bootloader如何决定跳向何方Bootloader的核心职责不是“升级”而是“决策”。它上电后执行的逻辑决定了设备是正常运行还是进入升级模式。我设计的启动流程是严格的四步判断每一步都带超时和容错第一步检查Bank Info有效性读取Page 30的bank_info_t结构体。首先验证reserved字段是否全0xFF未擦写过的默认值若不是说明结构体已写入进入下一步若是则视为首次上电初始化结构体active_bank0,a_status1,b_status0并擦写Page 30。第二步校验活跃区固件完整性根据active_bank值计算对应区A或B的CRC32值并与结构体中存储的a_crc32或b_crc32比对。这里的关键是CRC计算必须跳过向量表的前4字节栈顶地址因为该值在不同编译环境下会变但固件主体代码必须一致。我用的是标准CRC32-IEEE算法查表法实现耗时约8ms主频72MHz。第三步检查待升级区状态若活跃区校验通过再检查inactive_bank对应的状态。如果其status1说明新固件已就绪Bootloader不跳转而是进入串口升级模式等待主机发送XMODEM包。第四步最终跳转或报错若活跃区校验失败且待升级区status1则跳转至待升级区即回滚若两者均status0则进入安全模式LED慢闪串口输出ERR: NO VALID FIRMWARE等待人工干预。注意这四步必须在200ms内完成否则影响设备启动时间。我将Flash读取和CRC计算放在SysTick中断外执行避免中断嵌套导致超时。实测从上电到App运行总耗时控制在180ms以内。3. 核心细节解析与实操要点从向量表重定向到Flash页擦写3.1 向量表重定向让App在任意地址也能正确响应中断这是AB分区最易出错的一环。F103硬件规定复位后CPU从0x08000000取栈顶地址从0x08000004取复位向量。但你的App可能在0x08003000A区或0x08009000B区它的向量表自然也在那个地址。解决方案是在App的main函数开头将向量表从Flash拷贝到SRAM并配置SCB-VTOR指向SRAM地址。具体步骤在App的链接脚本.ld中定义一个名为VectorsRam的内存段起始地址为0x20000000SRAM起始大小256字节64个中断向量 × 4字节。在App的startup_stm32f10x_md.s中添加一段汇编将Flash向量表如A区为0x08003000复制到VectorsRamLDR R0, 0x08003000 ; A区向量表起始地址 LDR R1, VectorsRam MOV R2, #256 CopyVectors: LDMIA R0!, {R3-R6} STMIA R1!, {R3-R6} SUBS R2, R2, #16 BNE CopyVectors在App的main()第一行执行SCB-VTOR (uint32_t)0x20000000; // 指向SRAM中的向量表 __DSB(); // 数据同步屏障确保VTOR写入生效实操心得我最初忘了__DSB()结果SysTick中断偶尔丢失调试花了三天。手册明确指出修改VTOR后必须执行DSB指令否则CPU可能仍在旧向量表上取指。3.2 Flash页擦写如何避免“擦着擦着就锁死了”F103的Flash擦除是高危操作。一个错误的地址、一次未等待的BUSY标志都会导致Flash锁死只能用J-Link的“Unlock”功能救回。AB分区OTA中擦写发生在两个时刻一是升级前擦除B区为写入新固件腾空间二是写入Bank Info前擦除Page 30。关键要点必须先解锁再操作调用FLASH_Unlock()否则所有擦写命令无效。必须检查BUSY标志每次调用FLASH_ErasePage()后必须轮询FLASH_GetFlagStatus(FLASH_FLAG_BSY)直到返回RESET。我见过太多人忽略这点在擦写未完成时就去写数据结果写入失败却无报错。地址必须页对齐FLASH_ErasePage(0x08009000)是合法的0x08009000是Page 18起始但FLASH_ErasePage(0x08009004)会擦错页甚至触发HardFault。擦写后必须锁住FLASH_Lock()防止意外写入。我封装了一个安全擦写函数ErrorStatus FLASH_ErasePageSafe(uint32_t Page_Address) { uint32_t timeout 0xFFFF; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); FLASH_ErasePage(Page_Address); while(FLASH_GetFlagStatus(FLASH_FLAG_BSY) ! RESET) { if(--timeout 0) { FLASH_Lock(); return ERROR; // 超时擦写失败 } } FLASH_Lock(); return SUCCESS; }3.3 XMODEM协议实现为什么不用更简单的YMODEM在资源受限的F103上XMODEM128字节帧比YMODEM1024字节帧更可靠。原因很简单F103的SRAM只有20KB而串口接收缓冲区通常设为512字节。YMODEM一帧1KB容易溢出XMODEM一帧128字节配合双缓冲ping-pong buffer可以做到无缝接收。协议流程如下Bootloader进入升级模式后发送C字符表示准备接收。主机收到C发送第一帧SOH(0x01) 帧号(0x01) 帧号补码 128字节数据 CRC校验2字节。Bootloader计算接收到的128字节CRC与帧尾2字节比对。若一致发送ACK(0x06)若不一致发送NAK(0x15)要求重发。帧号从0x01递增到0xFF后归零。当收到EOT(0x04)时发送ACK并计算整个固件的CRC32。关键技巧CRC校验必须用硬件加速F103没有专用CRC外设但可以用__attribute__((section(.ramcode)))将CRC函数放到SRAM中执行速度提升3倍。我实测128字节CRC计算从12us降到4us。4. 实操过程与核心环节实现从Keil工程搭建到J-Link烧录4.1 Keil MDK工程搭建两个独立工程一份共享配置你需要创建两个完全独立的Keil工程Bootloader.uvprojx和App_A.uvprojxB区App由A区编译后手动复制地址生成。关键配置点Bootloader工程Target选项卡IRAM1起始0x20000000大小20KBIROM1起始0x08000000大小12KB。Output选项卡勾选Create HEX File生成bootloader.hex。C/C选项卡Define中添加BOOTLOADER宏用于条件编译。App_A工程Target选项卡IRAM1同上IROM1起始0x08003000大小24KB。Output选项卡勾选Create HEX File生成app_a.hex。C/C选项卡Define中添加APP_A宏。提示两个工程共用同一份标准库v3.50源码但system_stm32f10x.c需为Bootloader单独修改将SystemCoreClock初始化放在main()中而非SystemInit()因为Bootloader需要精确控制时钟开启时机。4.2 链接脚本.scf定制让代码乖乖躺在指定地址Keil使用scatter文件.scf定义内存布局。Bootloader的bootloader.sct如下LR_IROM1 0x08000000 0x00003000 { ; load region size_region ER_IROM1 0x08000000 0x00003000 { ; load address execution address *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 UNINIT 0x00005000 { ; 20KB RAM .ANY (RW ZI) } }App_A的app_a.sct关键区别在ER_IROM1起始地址ER_IROM1 0x08003000 0x00006000 { ; 注意起始地址变为0x08003000 *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) }注意.ANY (RO)必须放在最后否则常量数据如字符串可能被错误分配到非Flash区域。我曾因此导致Bootloader的提示字符串显示乱码。4.3 J-Link烧录全流程从擦除到校验一步都不能少用J-Link Commander或J-Flash GUI烧录顺序至关重要全片擦除exec device STM32F103C8→erase all。这是为了清除之前残留的垃圾数据避免Bank Info区被污染。烧录Bootloaderloadfile bootloader.hex。此时Bootloader位于0x08000000但Bank Info区Page 30仍是0xFFBootloader会初始化它。烧录A区Apploadfile app_a.hex。注意Keil生成的hex文件地址已包含0x08003000偏移J-Link会自动写入对应位置。手动设置Bank Info这是最关键的一步用J-Flash打开app_a.hex找到其末尾的bank_info_t结构体在Page 30将active_bank设为0a_status设为1b_status设为0然后Program该页。或者用命令行mem32 0x08007C00 0x00000000 // active_bank 0 mem32 0x08007C04 0x00000001 // inactive_bank 1 mem32 0x08007C08 0x12345678 // a_crc32 (需提前计算) mem32 0x08007C0C 0x00000000 // b_crc32 0 mem8 0x08007C10 0x01 // a_status 1 mem8 0x08007C11 0x00 // b_status 0实操心得第一次烧录后务必用J-Link Commander的mem32 0x08007C00 32命令读取Page 30确认所有字段写入正确。我曾因J-Flash的“Verify”选项未勾选导致Bank Info写入失败设备启动后直接进入安全模式排查了两小时才发现是烧录步骤漏了。4.4 OTA升级实操用串口助手完成一次真实升级假设设备已运行A区App现在要升级B区给设备上电Bootloader检测到A区有效B区无效于是跳转至A区App。A区App运行中通过串口发送特殊指令如ATOTA1触发“请求升级”信号。该信号通过GPIO或全局变量传递给Bootloader需在App中预留一个跳转入口。App执行NVIC_SystemReset()系统复位。Bootloader重启检测到inactive_bank1且b_status0于是进入XMODEM接收模式串口输出C。在PC端用XMODEM发送工具如Tera Term选择app_b.hex文件协议选XMODEM-CRC开始发送。Bootloader逐帧接收、校验、写入B区0x08009000完成后计算B区CRC32写入Bank Info的b_crc32和b_status1。发送ATREBOOT指令设备重启Bootloader检测到B区有效跳转至B区运行。关键参数XMODEM超时设为10秒帧重传次数上限3次。我测试过在9600bps波特率下24KB固件升级耗时约3分20秒期间可随时断开串口Bootloader会超时退出回到A区运行绝不“变砖”。5. 常见问题与排查技巧实录那些官方手册绝不会写的坑5.1 典型问题速查表现象可能原因排查方法解决方案设备上电后LED不亮J-Link无法连接Bootloader的向量表损坏或Flash被写保护用J-Link Commander执行unlock再erase all重新烧录Bootloader检查链接脚本地址是否为0x08000000Bootloader能运行但跳转到App后立即HardFaultApp的向量表未重定向或SCB-VTOR未设置在App main()第一行加while(1);用调试器看PC是否停在SCB-VTOR ...确认VectorsRam段定义正确__DSB()指令存在XMODEM接收时频繁NAK但数据看起来正确串口接收中断优先级低于SysTick导致缓冲区溢出在NVIC中将USARTx_IRQn优先级设为最高0修改NVIC_Init()确保串口中断抢占SysTick升级完成后设备仍运行旧AppBank Info中active_bank未更新或b_status写入失败用J-Link读取0x08007C00检查active_bank和b_status值手动用mem32命令修正或检查Flash写入函数是否遗漏FLASH_Lock()CRC32校验总是失败计算范围错误包含了栈顶地址或字节序颠倒用Python脚本对app_a.bin计算CRC32与Bootloader打印值比对确保CRC计算从0x08003004开始跳过栈顶使用小端序5.2 独家避坑技巧来自产线的血泪经验技巧一用“影子页”规避Bank Info写入风险Page 300x08007C00是唯一Bank Info区但生产中可能出现写入一半断电。我的方案是同时维护Page 30和Page 31两个完全相同的bank_info_t结构体。每次写入时先擦除Page 31写入新数据再擦除Page 30写入相同数据。Bootloader启动时读取两个页取status字段更“新”的那个通过比较a_crc32或b_crc32的数值大小数值大者为新。这样即使Page 30写坏Page 31仍可兜底。技巧二App中预留“强制回滚”按键在硬件上增加一个物理按键接PA0长按5秒触发强制回滚。App中轮询该按键一旦检测到立即执行// 清除B区状态强制下次启动用A区 bank_info_t *info (bank_info_t*)0x08007C00; info-b_status 0; FLASH_Unlock(); FLASH_ErasePage(0x08007C00); FLASH_ProgramWord(0x08007C00, *(uint32_t*)info); FLASH_Lock(); NVIC_SystemReset();这招在客户现场救过三次急比远程指导他们用J-Link强多了。技巧三Bootloader的“心跳包”防呆设计Bootloader在XMODEM接收过程中每收到10帧就点亮一次LED短闪。如果LED长时间不闪说明主机卡死或线路故障。此时Bootloader自动超时退出避免用户以为“还在升级”而干等。这个细节让技术支持电话减少了70%。最后分享一个小技巧在Keil中给Bootloader工程添加一个#define DEBUG_BOOT宏开启后Bootloader会在串口输出详细的地址、CRC、状态值。但发布固件前务必关闭它——因为printf会吃掉大量Flash空间和RAM我曾因此导致Bootloader超出12KB限制不得不重写整个日志模块。真正的高手永远在发布版里只留最必要的错误码。