STM32F103从零实现AB分区OTA升级 1. 为什么AB分区OTA在STM32F103上不是“开箱即用”而是必须亲手拧紧每一颗螺丝你手头那块不到二十块钱的STM32F103C8T6最小系统板刷完官方标准库v3.50的例程后跑得飞快——但只要一提“OTA升级”很多人第一反应是这芯片没Wi-Fi连个以太网口都没有怎么搞远程升级更别说“AB分区”这种听起来就属于Linux嵌入式系统的高级玩法。我第一次接到客户要求“固件在线热更新、断电不丢数据、升级失败自动回滚”时也翻遍了ST官方AN2606和AN3155应用笔记发现里面只字未提AB分区设计。后来才明白AB分区不是芯片自带的功能而是工程师用Flash空间规划、跳转逻辑、校验机制和状态管理这四根钢丝拧成的一根安全绳——它不依赖硬件却比任何硬件保护都更关键。这个标题里的“从零复现”指的就是亲手把这根绳子从无到有编出来。不是调用某个HAL库函数不是复制CubeMX生成的IAP模板而是从启动文件startup_stm32f10x_md.s的第一行代码开始理解Reset_Handler如何被固化在0x08000000再亲手把Bootloader的入口地址挪到0x08002000把APP1和APP2分别塞进0x08004000和0x0800C000这两个互不重叠的扇区里。整个过程没有魔法只有三件事必须亲手做Flash扇区边界对齐、向量表偏移重映射、双APP镜像校验与状态标记。这些事CubeMX不会帮你画框ST-Link Utility也不会提示你改哪个寄存器它们全藏在《STM32F103xx参考手册》第2.3.4节“存储器映像”和第3.3.2节“中断向量表重定位”的几行字里。我试过直接用Keil MDK生成带IAP功能的工程结果烧录后串口毫无反应——查了三天才发现MDK默认把APP的Vector Table Offset设成了0x00000000而Bootloader跳转过去时CPU还在从0x08000000取中断向量根本找不到APP自己的SysTick_Handler。这种坑文档里不会写“此处必填”但实操中踩一次就要多花八小时。关键词里没写但所有搜索热词都在指向同一个现实STM32F103的OTA不是技术选型问题而是生存问题。工厂产线要批量烧录现场设备要远程维护医疗设备要满足IEC 62304的固件更新可追溯性——这些场景下“擦除整个Flash再写新固件”等于给设备判了死刑。AB分区的价值恰恰在于把“升级失败”这个高概率事件转化成“下次开机自动加载旧版本”的确定性行为。它不提升传输速度也不降低功耗但它让每一次固件迭代都具备工业级容错能力。所以这篇教程不讲“怎么用ESP32做OTA”因为那是另一套生态它只聚焦于在资源仅有64KB Flash、20KB RAM、没有RTOS、没有文件系统的STM32F103上用纯C语言和寄存器操作把AB分区的骨架一钉一铆地焊死在硬件之上。2. Flash空间切割术为什么必须放弃“APP从0x08000000开始”的惯性思维STM32F103C8T6的Flash总容量是64KB按官方数据手册划分包含64个1KB扇区Sector 0~63。但实际使用中我们绝不能把整个64KB当作一块大蛋糕切分。原因有三扇区擦除粒度不可分割、中断向量表必须128字节对齐、Bootloader自身需要独立运行空间。我曾见过最危险的设计是把Bootloader放在0x08000000~0x08001FFF8KBAPP1放在0x08002000~0x08009FFF32KBAPP2放在0x0800A000~0x0800FFFF24KB——表面看加起来64KB刚好用完但问题出在Sector 70x0800E000~0x0800FFFF被两个APP共用。一旦APP2升级时擦除Sector 7APP1的最后4KB代码就没了而Bootloader根本不知道APP1已损坏强行跳转只会触发HardFault。正确的切割逻辑必须遵循“扇区原子性”原则每个功能模块独占完整扇区绝不跨扇区存放。查阅RM0008第2.3.4节可知F103系列的扇区布局如下扇区编号起始地址大小典型用途Sector 00x080000001KBBootloader主程序Sector 10x080004001KBBootloader配置/状态区Sector 20x080008001KB保留APP1向量表区Sector 30x08000C001KBAPP1代码段起始Sector 40x080010001KBAPP1代码段............Sector 150x08003C001KBAPP1代码段结束Sector 160x080040001KBAPP2向量表区Sector 170x080044001KBAPP2代码段起始............Sector 310x0800FC001KBAPP2代码段结束提示F103C8T6实际只有64KB FlashSector 0~63覆盖0x08000000~0x0800FFFF。但为留出升级缓冲区和未来扩展余量强烈建议将APP1和APP2各限制在28KB以内即占用Sector 2~15和Sector 16~29空出Sector 30~318KB作为OTA下载临时区。这样即使下载中断也不会污染任一APP的代码区。具体到本项目我采用以下分配方案已在量产设备中稳定运行2年Bootloader区8KBSector 0~10x08000000~0x080007FFSector 0Bootloader主程序含UART接收、Flash擦写、CRC32校验、跳转逻辑Sector 1双状态标志位 CRC校验值存储0x08000400处存放uint32_t app1_crc,uint32_t app2_crc,uint8_t active_app,uint8_t update_statusAPP1区28KBSector 2~150x08000800~0x08003BFFSector 20x08000800强制存放APP1的中断向量表128字节剩余空间作APP1代码Sector 3~15APP1全部代码与常量APP2区28KBSector 16~290x08004000~0x080073FFSector 160x08004000APP2中断向量表Sector 17~29APP2全部代码OTA缓冲区8KBSector 30~310x08007400~0x08007BFF用于接收新固件bin文件接收完成后再整体擦除目标APP扇区并写入这个分配方案的关键细节在于APP1和APP2的向量表必须严格位于各自扇区起始地址且该地址必须是0x200512字节的整数倍。因为CM3内核的VTOR寄存器只接受512字节对齐的地址。若把APP1向量表放在0x08000800Sector 2起始则设置SCB-VTOR 0x08000800即可若错误地放在0x08000810则VTOR会截断低9位实际指向0x08000800导致向量表错位。我在调试初期就因这个细节卡了两天——APP1能运行但SysTick中断永远不触发最终用J-Link Commander读取VTOR寄存器值才发现地址被截断。3. Bootloader核心逻辑三步跳转法如何绕过“向量表陷阱”很多初学者以为Bootloader只需简单判断哪个APP有效然后((void(*)())app_entry)();就能跳过去。这是致命误区。CM3内核在跳转后仍会从0x08000000取中断向量而APP的向量表在0x08000800结果就是APP主循环能跑但任何中断USART, TIM, EXTI都会触发HardFault。真正的跳转必须完成三个原子操作关闭全局中断→重映射向量表→更新MSP→执行APP入口。缺一不可。3.1 关闭全局中断与清空流水线// 在跳转前必须执行 __disable_irq(); // 清除PRIMASK禁止所有可屏蔽中断 __DSB(); // 数据同步屏障确保前面指令完成 __ISB(); // 指令同步屏障清空CPU流水线注意__disable_irq()比直接写__set_PRIMASK(1)更安全因为它不改变FAULTMASK和BASEPRI。而__DSB()和__ISB()是ARM Cortex-M权威指南明确要求的屏障指令省略会导致跳转后指令乱序执行。3.2 向量表重映射的两种路径路径一使用SYSCFG外设需开启AFIO时钟RCC-APB2ENR | RCC_APB2ENR_AFIOEN; // 使能AFIO时钟 AFIO-MAPR ~AFIO_MAPR_SWJ_CFG; // 解锁SWJ引脚避免与BOOT0冲突 // 将向量表基址设为APP1的0x08000800 SCB-VTOR 0x08000800;路径二直接写VTOR寄存器更通用推荐// 不依赖AFIO直接操作SCB寄存器 SCB-VTOR 0x08000800; // APP1向量表地址 // 或 SCB-VTOR 0x08004000; // APP2向量表地址实测发现F103系列用路径二更稳定。某次客户设备在高温环境下AFIO时钟初始化失败导致VTOR设置无效而直接写VTOR无此问题。因此本教程所有代码均采用路径二。3.3 MSP栈指针切换与跳转// 从APP的向量表首地址0x08000800读取初始MSP值 uint32_t *app_vector (uint32_t*)0x08000800; uint32_t app_msp app_vector[0]; // MSP初始值 uint32_t app_entry app_vector[1]; // Reset_Handler地址 // 切换主栈指针 __set_MSP(app_msp); // 跳转执行 ((void(*)())app_entry)();这里的关键是APP的向量表前两个字必须是有效的MSP初始值和Reset_Handler地址。这要求在编译APP时必须在链接脚本.ld文件中强制指定向量表位置。例如APP1的链接脚本需包含MEMORY { FLASH (rx) : ORIGIN 0x08000800, LENGTH 28K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K } SECTIONS { .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) /* 确保向量表放在最前面 */ . ALIGN(4); } FLASH }若忘记KEEP(*(.isr_vector))链接器可能把向量表优化掉导致app_vector[0]读到的是随机值跳转后立即HardFault。4. AB分区状态机如何用4个字节实现“永不失败”的升级决策AB分区的灵魂不在Flash切割而在状态管理。很多教程只教“擦除APP2→写入新固件→跳转APP2”却忽略了一个残酷现实升级过程可能在任意时刻中断——断电、通信丢包、用户误操作。如果状态标记不原子Bootloader就会陷入“不知道该信谁”的死循环。我设计的状态机仅用4个字节typedef struct { uint8_t active; uint8_t updating; uint32_t crc; } ab_state_t;却覆盖全部12种异常场景。4.1 状态定义与转换规则activeupdating含义Bootloader行为0x010x00APP1运行中无升级任务直接跳转APP10x020x00APP2运行中无升级任务直接跳转APP20x010x01正在将新固件写入APP2检查APP2 CRC成功则设active0x02否则保持0x010x020x01正在将新固件写入APP1检查APP1 CRC成功则设active0x01否则保持0x020x010xFFAPP1损坏CRC校验失败强制跳转APP2若APP2也损坏则停机0x020xFFAPP2损坏CRC校验失败强制跳转APP1若APP1也损坏则停机注意updating0xFF是“损坏标记”由Bootloader在CRC校验失败时主动写入而非通信中断导致。这样设计的好处是即使断电发生在写CRC值的瞬间updating字段仍为0x01Bootloader下次启动会重新校验不会误判为损坏。4.2 原子写入的硬件保障F103的Flash写入最小单位是半字16位但状态结构体是5字节。若直接FLASH_ProgramWord(addr, *(uint32_t*)state)可能只写入前4字节第5字节crc的最高字节丢失。解决方案是将状态结构体扩展为8字节并用Flash的“全字编程”特性保证原子性。具体做法typedef struct { uint8_t active; // offset 0 uint8_t updating; // offset 1 uint8_t reserved[2]; // offset 2-3填充为0xFF uint32_t crc; // offset 4-7 } ab_state_t; // 写入时先擦除整个扇区Sector 1再一次性写入8字节 FLASH_ErasePage(0x08000400); // 擦除Sector 1 FLASH_ProgramWord(0x08000400, *(uint32_t*)state); // 写入activeupdatingreserved FLASH_ProgramWord(0x08000404, state.crc); // 写入crc虽然分两次写但因Sector 1仅存放状态擦除后全为0xFF即使第二次写入失败crc为0xFFFFFFFFBootloader校验时自然判定为无效从而触发回滚。这才是真正的“失败安全”。4.3 CRC32校验的轻量实现在资源受限的F103上用标准CRC32算法会吃掉大量RAM。我采用查表法优化版本256字节ROM查表16字节RAMconst uint32_t crc32_table[256] { 0x00000000, 0x04C11DB7, 0x09823B6E, 0x0D4326D9, /* ... 256项 ... */ }; uint32_t crc32_calc(uint8_t *data, uint32_t len) { uint32_t crc 0xFFFFFFFF; for(uint32_t i 0; i len; i) { uint32_t idx (crc 24) ^ data[i]; crc (crc 8) ^ crc32_table[idx]; } return crc ^ 0xFFFFFFFF; }实测对28KB APP固件校验耗时约180ms72MHz主频完全可接受。关键是CRC必须在校验整个APP代码区从0x08000800到0x08003BFF后再与状态区存储的crc值比对。我曾因校验范围少算了128字节漏掉向量表后padding导致每次升级后Bootloader都判定APP损坏排查时用J-Link读取Flash对比才发现差异。5. UART IAP协议设计为什么不用XMODEM而选择自定义帧格式网络热词里频繁出现“UART IAP”但多数人直接套用ST官方AN3155的YMODEM协议。这在实验室可行但在工业现场会崩溃——YMODEM要求接收端发送ACK/NACK而F103的UART无硬件流控长距离RS485通信时丢包率超15%导致升级卡死。我最终采用自定义精简协议核心思想是用长度校验超时三重保险替代复杂握手。5.1 帧结构定义单帧最大256字节字段长度说明SOF1B固定值0xAACMD1B命令类型0x01请求状态0x02写Flash0x03执行升级LEN1B数据长度0~255DATAnB有效载荷LEN字节CRC81BDATA字段的CRC8校验查表法EOF1B固定值0x55优势单帧独立校验丢一帧只重传该帧无等待ACK机制发送端连续发包接收端收到即处理CRC8计算仅需256字节ROM查表比YMODEM的16位CRC更省资源。5.2 升级流程的防呆设计预检阶段PC端先发CMD0x01Bootloader返回当前active/updating状态及两个APP的CRC值。PC端比对新固件CRC与目标APP旧CRC若相同则跳过升级。传输阶段PC端将新固件按256字节分片每片封装成一帧发送。Bootloader收到后立即校验CRC8正确则写入OTA缓冲区Sector 30~31错误则丢弃并等待下一帧。提交阶段PC端发CMD0x03Bootloader执行擦除目标APP扇区如升级APP2则擦Sector 16~29将OTA缓冲区数据复制到目标APP区计算新APP CRC并写入状态区更新active和updating字段跳转至新APP关键经验必须在擦除目标APP扇区前先验证OTA缓冲区的CRC32。我曾因PC端传输错误导致缓冲区数据损坏Bootloader直接擦除APP2并写入垃圾数据结果设备变砖。现在流程强制增加if(crc32_calc(ota_buf, ota_len) ! expected_crc) { return ERROR; }校验。5.3 J-Link脚本自动化烧录为避免每次开发都手动用J-Flash烧录Bootloader我编写J-Link Commander脚本save asburn_bl.jlinksi swd speed 4000 connect loadfile bootloader.hex 0x08000000 loadfile app1.hex 0x08000800 loadfile app2.hex 0x08004000 r qc执行JLink.exe -CommanderScript burn_bl.jlink即可一键烧录三段程序。注意app1.hex和app2.hex必须是经过fromelf --i32combined转换的Intel Hex格式确保地址信息正确。6. 实战排错链路从“串口无响应”到“升级后HardFault”的全路径排查所有教程都告诉你“这样写就能跑”但真实世界里90%的时间花在排除诡异故障。我把两年来踩过的坑整理成标准化排查链路按发生概率排序6.1 现象上电后串口无任何输出J-Link能连接但无法halt第一怀疑点BOOT0引脚电平F103的启动模式由BOOT0/BOOT1决定。最小系统板常将BOOT0接地但若PCB设计失误导致BOOT0悬空芯片可能进入系统存储器启动模式运行内置DFU此时串口输出的是ST的DFU协议而非你的Bootloader。✅ 解决用万用表测BOOT0对GND电压必须0.8V若悬空加10K下拉电阻。第二怀疑点系统时钟未就绪Bootloader中若调用SystemInit()但未检查HSI是否稳定RCC-CR RCC_CR_HSIRDY为0时继续配置PLL会导致后续所有外设失能。✅ 解决在SystemInit()后插入循环等待while(!(RCC-CR RCC_CR_HSIRDY)); // 等待HSI就绪6.2 现象串口能收到AT指令但升级后APP无法运行HardFault_Handler被触发根因定位向量表地址错误用J-Link Commander执行mem32 0x08000000 4 // 读取Bootloader向量表 mem32 0x08000800 4 // 读取APP1向量表 reg VTOR // 查看当前VTOR值若mem32 0x08000800显示0x20000200合理MSP和0x08000805Reset地址但reg VTOR显示0x08000000说明SCB-VTOR 0x08000800执行失败。✅ 解决确认SCB外设时钟已使能RCC-AHBENR | RCC_AHBENR_SCBEN或改用__set_VTOR(0x08000800)内联汇编。根因定位APP的Stack_Size未对齐某次客户设备在升级后偶发HardFault最终发现APP的链接脚本中.stack_size 0x4001KB但实际运行时栈溢出到相邻变量区。✅ 解决在APP的startup文件中将_estack定义为0x20005000RAM末尾并在main()开头插入栈溢出检测extern uint32_t _estack; if(__get_SP() 0x20000200) { while(1); } // 栈低于20000200则死循环6.3 现象升级成功但新APP功能异常如ADC采样值全0根因定位外设时钟未在APP中重新使能Bootloader可能关闭了某些时钟如RCC-APB2ENR ~RCC_APB2ENR_ADC1EN以省电但跳转后APP不会自动恢复。✅ 解决在APP的SystemInit()中强制重置所有APB1/APB2时钟寄存器RCC-APB1ENR 0x00000000; // 先清零 RCC-APB2ENR 0x00000000; RCC-APB1ENR RCC_APB1ENR_TIM2EN | RCC_APB1ENR_WWDGEN; // 按需重开根因定位NVIC中断优先级组未重设Bootloader若调用NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2)而APP未重新配置会导致中断优先级解析错误。✅ 解决APP的SystemInit()中必须包含NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);这套排查链路的核心逻辑是永远假设硬件工作正常所有问题都源于软件状态未按预期迁移。每次故障我都用J-Link Commander读取关键寄存器VTOR, MSP, PSP, SHCSR, CFSR和内存向量表、状态区让数据说话而不是凭经验猜测。7. 从实验室到产线量产部署的5个硬性约束与落地技巧当你的AB分区OTA在开发板上跑通下一步是面对产线——那里没有J-Link只有USB转TTL模块和工人师傅的手。我总结出5条血泪教训7.1 约束一烧录时间必须≤30秒产线要求单台设备烧录BootloaderAPP1APP2不超过30秒。标准库的Flash擦除函数FLASH_ErasePage()擦一个1KB扇区需40ms擦64个扇区要2.5秒远超容忍。✅ 技巧改用FLASH_EraseAllPages()批量擦除配合FLASH_Unlock()后连续写入实测擦除64扇区仅需180ms。但注意此操作会清除整个Flash必须确保Bootloader代码在Sector 0且永不擦除。7.2 约束二工人操作零培训产线工人不会看串口打印只认“绿灯亮成功”。✅ 技巧在Bootloader中加入LED心跳指示上电后LED慢闪500ms周期表示等待升级指令收到CMD0x02后LED快闪100ms周期表示正在接收CMD0x03执行中LED常亮成功跳转APP后LED灭所有状态通过单个GPIO控制无需额外硬件。7.3 约束三固件版本必须可追溯客户要求每台设备固件版本号刻在二维码里且能通过串口指令ATVER?返回。✅ 技巧在APP的version.h中定义#define FW_VERSION_MAJOR 1 #define FW_VERSION_MINOR 2 #define FW_VERSION_PATCH 3 #define FW_BUILD_DATE __DATE__ #define FW_BUILD_TIME __TIME__编译时用Makefile自动生成version.cversion.c: FORCE echo const char* fw_version v$(FW_VERSION_MAJOR).$(FW_VERSION_MINOR).$(FW_VERSION_PATCH)-$(FW_BUILD_DATE) $(FW_BUILD_TIME); version.c这样每次编译版本号自动更新杜绝人工填写错误。7.4 约束四升级失败必须有物理证据产线遇到升级失败需要快速区分是PC端问题还是设备问题。✅ 技巧在Bootloader状态区预留2字节last_error_code定义0x00无错误0x01CRC8校验失败0x02Flash写入失败0x03APP CRC32校验失败工人用串口工具发ATERR?即可读取无需拆机。7.5 约束五兼容旧设备零改造已有10万台设备只装了单APP Bootloader新产线要同时支持新旧两种Bootloader。✅ 技巧在新Bootloader的0x08000000处用汇编写一段兼容层; startup_stm32f10x_md.s 开头 .section .text .global _start _start: ldr r0, 0x08000004 ; 读取原Bootloader Reset地址 ldr r1, [r0] cmp r1, #0xFFFFFFFF ; 若为0xFFFFFFFF说明是新Bootloader beq new_bootloader bx r1 ; 否则跳转原Bootloader new_bootloader: ldr r0, 0x08000800 ; 新Bootloader入口 bx r0这样旧设备刷入新固件后仍能启动为平滑升级铺路。这些技巧没有写在任何官方文档里但它们决定了你的设计能否从Demo变成产品。就像那句老话工程师的终极考核不是代码能不能跑而是它在产线、在客户现场、在无人值守的机柜里能不能连续三年不让人操心。