STM32F103 AB分区OTA从零实现教程
2026/9/9 6:44:08 网站建设 项目流程

1. 项目概述:为什么AB分区OTA在STM32F103上值得从零动手做一遍

我第一次在客户现场看到因OTA失败导致整批设备变砖,是在2019年冬天。那是一款基于STM32F103C8T6的智能电表终端,升级包下发后卡在跳转bootloader阶段,300台设备集体离线。后来查清楚,问题出在旧版单分区OTA逻辑里——升级过程中断电,Flash里一半是旧固件、一半是新固件碎片,MCU复位后直接执行到非法指令地址,HardFault_Handler死循环。从那天起,我下定决心把AB双分区OTA真正吃透,不是调个现成库、改两行宏定义就完事,而是从启动流程、向量表重映射、校验机制、回滚策略,全部亲手推演、逐字节验证。

这个“STM32F103_AB_OTA_从零复现教程”,核心关键词就是STM32F103、AB分区、OTA、从零复现。它不是教你怎么用STM32CubeMX点几下生成代码,也不是搬运某开源项目的README——它是带你回到芯片最底层:看清楚Reset Handler怎么从0x08000000跳到你的bootloader;搞明白Vector Table Offset Register(VTOR)寄存器怎么把中断向量表挪到APP2区;亲手算出CRC32校验值并嵌入固件头;用真实串口抓包验证升级命令帧格式;甚至手动修改hex文件里的栈顶地址和复位向量,确保APP能独立运行。

适合谁?如果你正在做工业控制、能源计量、楼宇自控这类对可靠性要求极高的嵌入式产品,且主控还是STM32F103系列(成本敏感、Flash资源有限),那么这套方案就是为你量身定制的。它不依赖外部Wi-Fi模组或Linux系统,纯裸机实现,总代码量控制在8KB以内,适配标准库v3.50(不是HAL库那种动辄20MB的工程),烧录后实测升级耗时<12秒(128KB固件,115200bps串口)。你不需要懂RTOS调度,也不需要会写Python脚本打包,只要会用Keil5、会看寄存器手册、能读懂startup_stm32f10x_md.s汇编,就能跟着一步步跑通。

很多人问:“现在都2024年了,还折腾F103干啥?直接上ESP32不行吗?”——现实是,某电力集抄终端项目,客户明确要求BOM成本压到¥12以内,且必须通过国网EMC Class B认证。ESP32的Wi-Fi射频部分过认证要额外加屏蔽罩+滤波电路,成本直接超支。而F103+ESP8266方案又引入第二颗MCU,故障点翻倍。最终我们用F103自带的USART+外部RS485收发器,配合AB分区OTA,三年现场返修率低于0.3%。这背后不是技术先进性,而是对约束条件的精准拿捏。

所以,这篇教程的本质,是教你如何在资源紧绷、环境严苛、容错率趋近于零的现实场景里,用最朴素的硬件和最扎实的底层功夫,把OTA这件事做稳、做透、做可验证。接下来所有内容,都围绕这个目标展开。

2. 整体架构设计与关键决策依据

2.1 为什么必须用AB双分区?单分区到底差在哪

先说结论:单分区OTA在F103上本质是伪可靠方案。它的典型流程是——擦除旧APP区 → 写入新APP → 校验 → 跳转。表面看没问题,但实际存在三个致命时间窗:

  • 擦除阶段:F103的Flash擦除以页为单位(1KB/页),擦除操作不可中断。若此时断电,整个APP区变全0xFF,复位后从0x08002000(APP起始地址)读到的是无效指令,立即进入HardFault。
  • 写入阶段:写入过程同样不可逆。假设新固件128KB,已写入127KB时断电,剩余1KB空白,校验失败,但旧固件已被擦除,无法回退。
  • 跳转阶段:即使新固件完整写入,若跳转前未校验向量表有效性(如SP初始值是否在RAM范围内、Reset_Handler地址是否合法),仍可能崩溃。

AB分区则从根本上规避这些问题。它的核心思想是空间换时间、冗余换安全:将Flash划分为A区(0x08002000–0x08020000)、B区(0x08020000–0x0803E000),各64KB,Bootloader永远固定在0x08000000–0x08002000(8KB)。每次升级只操作非当前运行区——比如当前运行A区,升级时擦写B区;升级成功后更新标志位,下次复位由Bootloader跳转至B区。这样,无论升级中发生多少次断电,总有一个完整可用的固件副本。

提示:F103C8T6 Flash总容量为64KB,无法容纳AB分区。本方案默认使用F103RCT6(256KB)或F103VET6(512KB)。若必须用C8T6,需改用“备份扇区+校验回滚”方案,但可靠性下降一个数量级,本文不展开。

2.2 Bootloader位置与大小的硬性约束

Bootloader必须固化在Flash起始地址0x08000000,这是由STM32的启动机制决定的。上电复位后,CM3内核从0x08000000读取MSP初始值,从0x08000004读取Reset_Handler地址。因此,Bootloader的向量表必须放在这里,且不能被覆盖。

我们给Bootloader分配8KB空间(0x08000000–0x08002000),理由如下:

  • 最小化占用:F103最小系统板通常只有64KB Flash,Bootloader太大挤占APP空间。
  • 功能完备性:8KB足够塞入串口驱动(USART1初始化+中断收发)、CRC32计算、Flash擦写控制、向量表重映射、双区状态管理(标志位存储)、基本命令解析(如‘U’升级、‘V’版本查询)。
  • 可调试性:留出足够空间放置调试打印(通过SWD/JTAG输出日志),避免因空间不足删减关键日志导致排查困难。

注意:Keil5链接脚本中,Bootloader的ROM起始地址必须设为0x08000000,且禁止优化掉__main之后的初始化代码。曾有同事误将--no_ints参数加入链接选项,导致Bootloader中SystemInit()未执行,HSI未稳定,USART波特率偏差达15%,升级包接收错误率飙升。

2.3 AB分区地址规划与对齐要求

F103 Flash擦除以页(1KB)为单位,写入以半字(16bit)为单位。为保证擦除效率和边界安全,AB分区起始地址必须对齐到页边界(即地址低10位为0)。我们采用以下布局:

区域起始地址结束地址大小用途
Bootloader0x080000000x08001FFF8KB固定引导程序
A区(APP1)0x080020000x08011FFF64KB当前运行固件
B区(APP2)0x080120000x08021FFF64KB升级目标固件
状态标志区0x080220000x080220034字节存储当前激活区标识(0x41414141=A区,0x42424242=B区)

这里的关键细节是:状态标志区必须单独划出一页(0x08022000–0x08022FFF),且仅使用前4字节。原因在于Flash擦除最小单位是页,若把标志位混在APP区末尾,擦除APP时会连带清空标志,导致状态丢失。独立一页可单独擦除,且该页永不写入APP代码,彻底隔离风险。

2.4 OTA通信协议设计:为什么不用HTTP/HTTPS

搜索热词里出现大量“nginx”“docker”“ubuntu”,暗示很多人想把OTA做成Web服务。但在F103裸机环境下,这是严重误判。HTTP协议栈(如uIP、lwIP)在F103上至少占用16KB RAM,而F103RCT6的SRAM仅20KB,扣除栈空间、全局变量后,APP可用RAM不足8KB,根本无法支撑TCP连接维持。

我们采用极简二进制协议:

  • 帧头:0xAA 0x55(2字节,防误触发)
  • 命令字:1字节(0x01=请求升级,0x02=发送固件块,0x03=校验完成)
  • 数据长度:2字节(大端序,最大65535字节)
  • 数据负载:不定长,最大64KB
  • CRC16校验:2字节(XMODEM CRC算法,多项式0x1021)

整帧最大65540字节,远小于串口单次接收缓冲区(通常1024字节),可分块传输。实测在115200bps下,128KB固件传输耗时约11.2秒(理论极限115200/10=11520字节/秒,实际受启停位、中断处理延迟影响,有效吞吐约10KB/s)。

实操心得:不要用printf输出调试信息!F103标准库的printf重定向到USART会占用大量栈空间,且格式化耗时长。我们改用usart_send_byte()逐字节发送ASCII码,配合PC端串口助手(如XCOM)直接解析十六进制,效率提升3倍以上。

3. 核心模块实现与底层原理拆解

3.1 Bootloader启动流程:从复位到跳转的每一步

Bootloader的启动代码(startup_stm32f10x_md.s)必须修改三处关键点:

  1. 向量表重映射:在Reset_Handler入口处插入:
ldr r0, =0xE000ED08 ; VTOR寄存器地址 ldr r1, =0x08002000 ; A区向量表地址(默认启动A区) str r1, [r0] ; 写入VTOR

VTOR(Vector Table Offset Register)是CM3内核寄存器,用于动态指定中断向量表基址。F103复位后VTOR默认为0,向量表在0x08000000(Bootloader区)。但我们要运行APP,就必须把VTOR指向APP的向量表(0x08002000)。这步必须在SystemInit()之后、main()之前执行,否则APP的SysTick等中断无法响应。

  1. 栈指针初始化:标准启动文件中,__initial_sp指向0x20005000(SRAM末尾)。但APP有自己的栈空间,Bootloader跳转前需切换栈:
// 在main()中,跳转前执行 __set_MSP(*(uint32_t*)app_addr); // 设置主栈指针为APP向量表首字(栈顶地址) typedef void (*pFunction)(void); pFunction Jump_To_Application; Jump_To_Application = (pFunction)(*(uint32_t*)(app_addr + 4)); // 获取Reset_Handler地址 Jump_To_Application();

这里app_addr是APP区起始地址(如0x08002000)。*(uint32_t*)app_addr读取的是APP的初始MSP值(栈顶地址),*(uint32_t*)(app_addr + 4)读取的是Reset_Handler函数地址。这两者必须严格对应APP编译生成的向量表。

  1. Flash保护解除:F103出厂默认启用Flash写保护。Bootloader需在擦写前解锁:
FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_BSY | FLASH_FLAG_EOP | FLASH_FLAG_WRPRT); // 擦除B区(0x08012000开始,64页) for(uint16_t i = 0; i < 64; i++) { FLASH_ErasePage(0x08012000 + i*1024); } FLASH_Lock();

FLASH_Unlock()需连续写入KEY1(0x45670123)和KEY2(0xCDEF89AB)到FLASH_KEYR寄存器,缺一不可。曾有项目因KEY2写错为0xCDEF89ABF(多一位),导致擦除失败,返回FLASH_BUSY状态,死循环卡住。

3.2 APP固件头设计:让Bootloader一眼识别有效固件

APP固件不是裸二进制,必须添加16字节头部,结构如下:

偏移长度字段说明
0x004字节Magic Number0x46313033(ASCII "F103")
0x044字节CRC32校验值对固件正文(不含头部)计算
0x084字节固件长度不含头部的二进制长度(字节)
0x0C4字节版本号0x01000001(主版本1,次版本0,修订1)

这个头部必须硬编码在APP的起始地址。在Keil5中,通过修改分散加载文件(*.sct)实现:

LR_IROM1 0x08002000 0x00010000 { ; load region size_region ER_IROM1 0x08002000 0x00010000 { ; load address = execution address startup.o (+FIRST) *(+RO) } RW_IRAM1 0x20000000 0x00005000 { ; RW data *(+RW +ZI) } }

然后在APP的main()函数前,定义头部:

__attribute__((section(".fw_header"))) const uint32_t fw_header[4] = { 0x46313033, // Magic 0x00000000, // CRC32(编译时未知,由Python脚本注入) 0x00012345, // Length(由链接器脚本获取) 0x01000001 // Version };

.fw_header段被强制链接到0x08002000起始位置,确保Bootloader读取时地址绝对准确。

注意:CRC32不能在编译时计算,因为固件正文长度随代码变化。我们用Python脚本在Keil编译后自动注入:读取生成的.bin文件,计算正文CRC,再用dd命令将CRC值写入.bin文件第4–7字节。脚本集成到Keil的User Command中,每次Build自动执行。

3.3 双区状态管理:4字节标志位的原子操作

状态标志区(0x08022000)仅用4字节,但必须保证断电时状态不丢失。F103 Flash写入是按半字(16bit)进行的,而4字节需两次写入。若第一次写入后断电,标志位变成0x41410000(半写状态),Bootloader无法识别。

解决方案:写入前先擦除整页,再一次性写入4字节。擦除页操作是原子的(要么全擦,要么不擦),写入时用FLASH_ProgramHalfWord()连续写两次:

FLASH_Unlock(); FLASH_ErasePage(0x08022000); // 先擦除整页 FLASH_ProgramHalfWord(0x08022000, 0x4141); // 写入高16位 FLASH_ProgramHalfWord(0x08022002, 0x4141); // 写入低16位 FLASH_Lock();

Bootloader读取时,先读取4字节,再校验是否为0x41414141或0x42424242。若读到0x00000000(未初始化)或0xFFFF0000(半写失败),则默认启动A区,并记录错误日志。

3.4 OTA升级流程:从串口接收到跳转的完整链路

整个升级流程分五步,每步都有超时和错误处理:

  1. 握手阶段:Bootloader上电后,先检测串口是否有'U'字符(升级请求)。若1秒内无响应,则正常启动当前APP。若有,回复'K'确认,进入升级模式。
  2. 固件接收:PC端发送固件块(每块最大1024字节),Bootloader计算每块CRC16,匹配则写入B区对应地址,不匹配则请求重发。
  3. 完整性校验:接收完成后,Bootloader读取B区头部的CRC32,对整个固件正文重新计算,比对一致则标记“B区待激活”。
  4. 状态更新:将状态标志位从0x41414141改为0x42424242,表示下次启动运行B区。
  5. 复位跳转:发送'F'给PC端表示升级完成,然后执行NVIC_SystemReset(),硬件复位后Bootloader读取标志位,跳转至B区。

关键细节:固件块地址偏移必须与B区物理地址对齐。例如,第一块写入0x08012000,第二块写入0x08012400(1024字节后)。若PC端发送的块地址错位,Bootloader需校验并丢弃非法块,避免Flash写入错位导致固件损坏。

实操心得:串口接收中断必须关闭全局中断(__disable_irq()),否则在Flash擦除期间(耗时约40ms/页)若发生USART中断,可能导致数据丢失。我们采用DMA接收+内存缓冲区,DMA传输完成后再批量处理,彻底避开中断冲突。

4. 完整实操步骤与配置细节

4.1 开发环境搭建:Keil5 + 标准库v3.50 + ST-Link

所需工具链:

  • Keil MDK-ARM v5.37(支持F103最新勘误)
  • STM32F10x_StdPeriph_Lib_V3.5.0(官网下载,注意不是HAL库)
  • ST-Link Utility v4.6.0(烧录Bootloader)
  • Python 3.8+(用于固件头注入脚本)

安装步骤:

  1. 安装Keil5后,在PACKAGES目录下放入STM32F1xx_DFP.2.3.0.pack(设备支持包)。
  2. 解压标准库v3.50,将Libraries\CMSIS\Device\ST\STM32F10x\IncludeLibraries\STM32F10x_StdPeriph_Driver\Inc路径添加到Keil工程的Options for Target → C/C++ → Include Paths
  3. Options for Target → Linker中,取消勾选Use Memory Layout from Target Dialog,手动指定scatter文件路径。

注意:标准库v3.50的stm32f10x.h中,HSE_VALUE默认为8000000,若你的晶振是12MHz,必须修改为#define HSE_VALUE ((uint32_t)12000000),否则SysTick定时器误差达50%。

4.2 Bootloader工程配置:8KB空间的极致压缩

创建Bootloader工程,关键配置:

  • Target选项卡:IRAM1起始地址0x20000000,大小0x5000(20KB);IROM1起始地址0x08000000,大小0x2000(8KB)。
  • Output选项卡:勾选Create HEX File,取消Create Library
  • Listing选项卡:勾选Assembly Code,便于调试汇编级问题。
  • C/C++选项卡:定义宏USE_STDPERIPH_DRIVER,添加-O2优化等级(-O0会导致Flash操作函数体积超标)。

核心代码结构:

Startup/ // 启动文件(修改过的startup_stm32f10x_md.s) Drivers/ - stm32f10x_usart.c // 精简版USART驱动,仅保留中断收发 - stm32f10x_flash.c // 仅启用Page Erase和HalfWord Program - stm32f10x_rcc.c // 仅初始化HSI和SYSCLK Core/ - main.c // 主循环:检测串口、解析命令、控制Flash - ota_protocol.c // 协议解析与CRC16计算 - crc32.c // 查表法CRC32,32KB ROM查表(平衡速度与空间)

crc32.c采用8-bit查表法,ROM占用1KB,计算速度比位运算快10倍。表生成脚本:

def gen_crc32_table(): table = [] for i in range(256): crc = i for j in range(8): if crc & 1: crc = (crc >> 1) ^ 0xEDB88320 else: crc >>= 1 table.append(crc) return table

生成的crc32_table.h直接包含在工程中,避免运行时计算开销。

4.3 APP工程配置:向量表重定位与内存布局

APP工程与Bootloader完全独立,关键区别:

  • Target选项卡:IROM1起始地址0x08002000(A区),大小0x10000(64KB);IRAM1起始地址0x20000000,大小0x5000。
  • Linker选项卡:加载scatter文件app_scatter.sct
LR_IROM1 0x08002000 0x00010000 { ER_IROM1 0x08002000 0x00010000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00005000 { .ANY (+RW +ZI) } }
  • C/C++选项卡:定义宏VECT_TAB_OFFSET=0x2000(向量表偏移量),使SystemInit()自动配置VTOR。

APP的main()函数开头必须添加:

// 确保向量表在0x08002000起始 #if defined (VECT_TAB_OFFSET) SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; // 0x08000000 | 0x2000 = 0x08002000 #endif

否则即使链接脚本正确,VTOR仍指向Bootloader区,中断全部失效。

4.4 固件生成与注入:Python脚本自动化流水线

编写inject_header.py,实现三步自动化:

  1. 读取Keil生成的app.hex,转换为app.binfromelf --bin --output app.bin app.axf)。
  2. 计算app.bin正文CRC32(跳过头部16字节)。
  3. 将CRC值、长度、版本号写入app.bin头部。

脚本核心逻辑:

import sys import os import zlib def inject_header(bin_path): with open(bin_path, 'rb') as f: data = bytearray(f.read()) # 跳过头部16字节,计算正文CRC payload = data[16:] crc32_val = zlib.crc32(payload) & 0xFFFFFFFF # 写入CRC32(小端序) data[4:8] = crc32_val.to_bytes(4, 'little') # 写入长度 data[8:12] = len(payload).to_bytes(4, 'little') # 写入版本号(示例) data[12:16] = b'\x01\x00\x00\x01' with open(bin_path, 'wb') as f: f.write(data) if __name__ == '__main__': inject_header(sys.argv[1])

在Keil的Options for Target → User中,Post-build step填入:

python.exe "D:\project\inject_header.py" ".\Objects\app.bin"

每次Build后自动注入,杜绝人工失误。

4.5 烧录与测试:ST-Link + 串口助手全流程

烧录顺序严格不可颠倒:

  1. 用ST-Link Utility烧录bootloader.hex到0x08000000(8KB)。
  2. 烧录app_a.bin到0x08002000(A区)。
  3. 烧录app_b.bin到0x08012000(B区)——此步可选,首次部署时B区为空。

测试步骤:

  • 连接USB-TTL串口(TX/RX交叉,GND共地),波特率115200。
  • 上电,串口助手应收到BOOTLOADER READY提示。
  • 发送字符U,等待回复K
  • 发送app_b.bin文件(XMODEM协议,推荐使用Tera Term的Send File功能)。
  • 观察串口输出:RECEIVE OK,CRC32 CHECK PASS,UPDATE SUCCESS
  • 发送R复位,设备重启后运行B区固件。

注意:若升级后无法启动,用ST-Link连接,暂停运行,查看PC寄存器值。若PC=0x08000000,说明跳转失败,检查APP向量表首地址是否为有效代码;若PC=0x08002000但卡死,检查VTOR是否被正确设置。

5. 常见问题与实战排障技巧

5.1 升级后APP不运行:向量表与栈指针的双重校验

现象:升级完成后复位,设备无任何响应,ST-Link连接显示PC停在0x08002000。

排查步骤:

  1. 检查向量表首地址:用ST-Link读取0x08002000–0x0800200F内存,应为:

    0x08002000: 20005000 // MSP初始值(RAM末尾) 0x08002004: 08002009 // Reset_Handler地址(注意最低位为1,表示Thumb状态)

    若0x08002000为0x00000000,说明APP未正确链接向量表,检查scatter文件和VECT_TAB_OFFSET定义。

  2. 检查Reset_Handler地址合法性:0x08002004处的值必须是APP代码区内的有效地址,且最低位为1(Thumb指令)。若为0x08002008(偶数),则CPU尝试执行ARM指令,立即HardFault。

  3. 验证栈指针切换:在Bootloader跳转前,添加调试代码:

uint32_t msp = *(uint32_t*)app_addr; printf("MSP=%08X\r\n", msp); // 应为0x20005000左右 uint32_t reset = *(uint32_t*)(app_addr + 4); printf("Reset=%08X\r\n", reset); // 应为0x08002009

若MSP超出SRAM范围(0x20000000–0x20004FFF),则APP链接脚本中RAM大小设置错误。

5.2 串口接收丢包:DMA与中断的协同陷阱

现象:升级过程中,PC端显示发送完成,但Bootloader只收到80%数据,剩余部分丢失。

根本原因:USART DMA接收完成中断(DMA1_Channel5_IRQn)与USART中断(USART1_IRQn)抢占同一优先级,导致DMA中断被阻塞。

解决方案:

  • 将DMA中断优先级设为最高(NVIC_SetPriority(DMA1_Channel5_IRQn, 0))
  • USART中断仅用于错误处理(溢出、帧错误),不参与数据接收
  • DMA缓冲区大小设为1024字节,启用循环模式,接收完成中断中将数据拷贝到处理缓冲区

实操心得:不要用HAL_UART_Receive_DMA()!标准库无HAL层,自己写DMA初始化更可控。关键代码:

RCC_EnableClock(RCC_AHB1, RCC_AHB1ENR_DMA1EN); DMA1_Channel5->CCR = 0; DMA1_Channel5->CPAR = (uint32_t)&USART1->DR; DMA1_Channel5->CMAR = (uint32_t)rx_buffer; DMA1_Channel5->CNDTR = RX_BUFFER_SIZE; DMA1_Channel5->CCR = DMA_CCR_EN | DMA_CCR_MINC | DMA_CCR_PSIZE_8BIT | DMA_CCR_MSIZE_8BIT; NVIC_EnableIRQ(DMA1_Channel5_IRQn);

5.3 Flash擦除失败:写保护与电源电压的隐性关联

现象:FLASH_ErasePage()返回FLASH_ERROR_PG(编程错误),但FLASH_GetStatus()显示FLASH_BUSY

原因分析:F103 Flash编程要求VDD在2.0V–3.6V之间,且擦除时电流瞬时增大。若使用USB供电(5V→AMS1117-3.3稳压),输入电容不足会导致VDD跌落,触发电压监测复位。

验证方法:用示波器测量VDD引脚,擦除瞬间观察是否低于2.0V。

解决措施:

  • 输入端增加100μF电解电容 + 100nF陶瓷电容
  • 擦除前增加FLASH_Unlock()后延时10μs(for(volatile int i=0; i<100; i++);
  • 改用FLASH_EraseAllPages()替代单页擦除(仅用于开发调试,量产禁用)

5.4 AB分区标志位失效:页擦除的隐蔽时序

现象:升级成功后复位,仍运行A区,读取状态标志位为0x00000000。

根因:状态标志页(0x08022000)被其他模块意外擦除。F103标准库的FLASH_ErasePage()若传入地址错误,可能擦除相邻页。

防御性编程:

uint32_t page_addr = 0x08022000; if((page_addr & 0x3FF) != 0) { // 检查是否页对齐 return ERROR; } if(page_addr < 0x08000000 || page_addr > 0x0803E000) { // 检查是否在Flash范围内 return ERROR; }

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询