STM32 Bootloader与OTA升级
深入理解STM32程序升级原理,打造永不"变砖"的可靠系统
前言
在嵌入式开发中,程序升级是一个绕不开的话题。无论是开发阶段的频繁烧录,还是产品交付后的远程维护,一套可靠的固件更新机制都是系统稳定运行的基石。本文将结合STM32芯片特性,从最基础的启动架构讲起,逐步深入到企业级的两段式、三段式Bootloader设计,同时涵盖CAN总线、LoRa等多样化升级通道的实现。本文内容基于实际项目经验与ST官方应用笔记AN2606、AN3155等资料,力求完整、可落地。
一、STM32程序下载的三种方式
STM32微控制器的程序烧录与更新主要有三种途径:ICP、ISP和IAP。理解三者的区别和适用场景,是设计Bootloader的前提。
1.1 ICP(在线编程)
ICP(In-Circuit Programming)是指通过专用的调试/编程接口(如SWD、JTAG),在芯片焊接到电路板上之后直接烧录Flash。
- 特点 :
- 需要专用烧录器:ST-Link、J-Link、ULINK等。
- 接口通常占用2个引脚(SWD:SWCLK、SWDIO)或更多(JTAG)。
- 可以随时读写任意Flash地址,支持硬件断点调试。
- 烧录速度快,适合开发阶段频繁下载和工厂量产。
- 常见应用 :
- 开发调试:Keil、IAR、STM32CubeIDE直接下载。
- 生产测试:工装夹具配合烧录器批量烧写。
- 维修刷机:若设备预留SWD接口,可直接刷写。
1.2 ISP(系统内编程)
ISP(In-System Programming)利用STM32芯片出厂时预置在系统存储器(System Memory)中的Bootloader程序,通过UART、USB、CAN、I2C、SPI等串行接口接收固件并写入Flash。
- 特点 :
- 不需要专用烧录器,只需一个串口/USB转串口模块。
- 需要在上电或复位时配置BOOT引脚(BOOT0=1,BOOT1=0)进入系统存储器自举模式。
- 支持使用官方工具如STM32CubeProgrammer、Flash Loader Demonstrator。
- 通信协议是固定的,例如USART协议使用
0x7F同步字节自动检测波特率。
- 硬件要求 (以USART1为例):
- PA9(TX)连接上位机RX,PA10(RX)连接上位机TX。
- 未使用的外设RX引脚(如USART2_RX、CAN_RX等)在检测期间不能悬空,需拉高或拉低,否则会影响自动检测。
- 适用场景 :
- 现场升级(无需打开外壳,通过预留的串口接口)。
- 无调试接口的批量生产。
1.3 IAP(应用内编程)
IAP(In-Application Programming)是指用户应用程序自己调用Flash擦写函数,在运行时更新固件。
- 特点 :
- 完全由用户程序控制,不依赖BOOT引脚配置。
- 可实现远程升级(OTA),通过WiFi、4G、LoRa、以太网接收固件。
- 需要自己设计Bootloader(通常位于Flash起始地址)和APP之间的跳转协议。
- 必须处理Flash擦写过程中的中断响应、掉电保护、双备份等复杂问题。
- 典型应用 :
- 物联网设备的无线固件升级(FOTA)。
- 本地通过SD卡、U盘自动升级。
- 车载ECU、工业控制器的远程维护。
IAP vs OTA:IAP强调"应用程序自主更新"的能力,OTA(Over-The-Air)特指通过无线通信网络远程升级。两者本质相同,只是传输介质不同。本文统一使用IAP/OTA表示用户自编程升级。
二、STM32启动架构基础
2.1 BOOT引脚配置与启动模式
STM32通过BOOT0和BOOT1引脚(或选项字节中的nBOOT1位)在复位时刻的电平状态,决定从哪个存储区域启动。
| BOOT1 | BOOT0 | 启动模式 | 说明 |
|---|---|---|---|
| X | 0 | 主闪存(Main Flash) | 从地址0x08000000开始执行,正常运行用户APP |
| 0 | 1 | 系统存储器(System Memory) | 执行出厂Bootloader,用于ISP下载 |
| 1 | 1 | 内置SRAM | 从SRAM启动,调试或临时运行 |
注意:
- 复位后,硬件在SYSCLK第四个上升沿锁存BOOT引脚状态。
- 对于部分STM32型号(如F0、F3系列),BOOT1并不是一个物理引脚,而是选项字节中的nBoot1位。通过修改选项字节可将nBoot1置1或清0来实现BOOT1的逻辑电平。
- 当选项字节中设置读保护级别2时,系统存储器自举模式会被禁用。
2.2 为什么Bootloader必须放在Flash最前面?
很多初学者疑惑:为什么Bootloader(通常称为B区)必须放在内部Flash的开头(0x08000000),而应用程序(A区)放在后面?
这完全由ARM Cortex-M内核的硬件启动机制决定:
- 芯片复位后,程序计数器(PC)被强制初始化为
0x00000000,但实际物理地址映射取决于启动模式。当从Flash启动时,Flash的物理地址0x08000000被映射到0x00000000,所以CPU第一条指令取自0x08000000。 0x08000000处存放的是中断向量表 。向量表的前两个条目是:- 第一个32位数据:初始主堆栈指针(MSP)。
- 第二个32位数据:复位向量(Reset_Handler),即复位后要执行的第一条指令地址。
- 因此,任何希望上电立即运行的代码,都必须放在
0x08000000开始的地址。
错误的分区方案:如果把APP放在前面(0x08000000),Bootloader放在后面(例如0x08010000),则上电后会先执行APP。如果APP损坏或需要更新,系统永远无法进入Bootloader,设备直接变砖。所以正确的做法是:
- Bootloader(B区)占用起始地址,大小为固定预留空间(如32KB)。
- APP(A区)从Bootloader结束地址开始(如0x08008000)。
2.3 系统存储器自举模式的细节
STM32芯片出厂内置的Bootloader位于System Memory,支持多种外设接口。下表列出了常见系列的支持情况:
| STM32系列 | 支持的ISP接口 |
|---|---|
| F1系列(小/中/大容量) | USART1 |
| F1超大容量 | USART1、USART2 |
| F105/107(互连型) | USART1、USART2、CAN2、DFU(USB) |
| F2/F4系列 | USART1、USART3、CAN2、DFU |
| L1系列(大容量) | USART1、USART2、DFU |
| F0系列(051/050) | USART1、USART2 |
| F3系列(302/303) | USART1、USART2、DFU、I2C |
以USART方式进入ISP的步骤:
- 配置BOOT0=1,BOOT1=0,复位。
- 上位机发送
0x7F字节(一个起始位、8位数据0x7F、偶校验、一个停止位)。 - Bootloader使用SysTick定时器测量
0x7F低电平的时间,自动计算波特率。 - 计算成功后返回
0x79(ACK),表示准备好接收命令。
2.4 APP安全启动的核心流程
Bootloader跳转到APP前,必须完成以下操作:
c
// 定义函数指针类型
typedef void (*pFunction)(void);
void JumpToApplication(uint32_t appStartAddr)
{
// 1. 检查APP起始地址是否合法:向量表第一个字必须是有效的栈顶地址(RAM范围内)
if ((*(__IO uint32_t*)appStartAddr & 0x2FFE0000) == 0x20000000)
{
// 2. 关闭所有外设中断,避免跳转后产生异常
__disable_irq();
// 3. 设置主堆栈指针为APP的初始栈顶
__set_MSP(*(__IO uint32_t*)appStartAddr);
// 4. 获取复位向量地址(向量表第二个字)
uint32_t jumpAddr = *(__IO uint32_t*)(appStartAddr + 4);
pFunction resetHandler = (pFunction)jumpAddr;
// 5. 重新映射中断向量表(APP内部还需再次设置)
SCB->VTOR = appStartAddr;
// 6. 跳转
resetHandler();
}
else
{
// 无效APP,停留在Bootloader或报错
}
}
注意 :APP的链接脚本(.ld或.sct)必须将起始地址设置为
0x08008000(假设Bootloader占32KB),并且在APP的启动代码中再次设置SCB->VTOR = 0x08008000,否则中断无法正确响应。
三、两段式Bootloader设计(基础IAP)
两段式Bootloader是指:Bootloader(B区)+ 应用程序(A区),外加一个外部存储(SPI Flash)和EEPROM。这是最经典、最简洁的IAP方案。
3.1 系统组成与角色分工
| 组件 | 型号举例 | 功能 | 角色 |
|---|---|---|---|
| 内部Flash B区 | Bootloader程序 | 上电首先运行,负责升级决策和Flash擦写 | 总指挥 |
| 内部Flash A区 | 应用程序(APP) | 实现业务逻辑,接收新固件并存储到外部Flash | 执行者 |
| SPI Flash | W25Q32(4MB) | 临时存放从网络/串口接收的完整固件包 | 中转仓库 |
| EEPROM | M24C02(256字节) | 存储升级状态标志、版本号、CRC值 | 状态记事本 |
为什么需要W25Q32?
- 内部Flash在擦写时无法同时执行代码(虽然可以执行RAM中的代码,但复杂且不稳定)。
- 固件下载过程较慢,若边接收边擦写内部Flash,容易因通信中断导致Flash损坏。
- 外部SPI Flash容量大(4MB~64MB),可暂存完整固件,待校验通过后再搬运。
为什么需要M24C02?
- EEPROM支持单字节擦写,无限次读写,非常适合频繁更新状态标志。
- 内部Flash虽然也能存储,但需要按页擦除(耗时且容易影响程序执行)。
- 掉电后数据不丢失,可用于记录升级进度,实现断点续传和掉电恢复。
3.2 状态标志位设计(存储在EEPROM)
c
// 定义在EEPROM固定地址,例如0x00
#define EEPROM_OTA_FLAG_ADDR 0x00
#define EEPROM_APP_CRC_ADDR 0x04
#define EEPROM_APP_VERSION 0x08
// 标志位取值
#define OTA_FLAG_NORMAL 0x00 // 正常启动
#define OTA_FLAG_PENDING 0xFF // 需要升级
#define OTA_FLAG_SUCCESS 0xAA // 升级成功(可正常跳转)
#define OTA_FLAG_FAIL 0x55 // 升级失败(需回滚或重试)
3.3 正常启动流程
- 上电/复位,CPU从
0x08000000开始执行B区Bootloader。 - Bootloader初始化必要硬件(时钟、EEPROM I2C、调试串口等)。
- 读取EEPROM中的
OTA_FLAG:- 若为
OTA_FLAG_NORMAL:验证APP区域的CRC(可选)。 - 若CRC校验通过:设置SCB->VTOR,跳转到APP起始地址。
- 若CRC失败:停留在Bootloader,等待串口/CAN/无线更新。
- 若为
- APP开始运行业务程序。
3.4 OTA升级流程(以USART Ymodem或WiFi下载为例)
第1步:APP端准备工作
- 当APP运行时收到升级指令(例如串口收到"UPDATE"命令,或服务器下发的MQTT消息)。
- APP通过通信接口(WiFi/4G/串口)分块接收新固件,每包按页(256字节)写入W25Q32。
- 接收完成后,计算整个固件的CRC32(或SHA256),与上位机/服务器提供的校验值比对。
- 若校验通过,则将
OTA_FLAG在EEPROM中写入OTA_FLAG_PENDING,并保存新固件大小、版本号。 - 最后执行软件复位:
NVIC_SystemReset()。
第2步:Bootloader执行升级
- 系统复位后,Bootloader检测到
OTA_FLAG == OTA_FLAG_PENDING。 - Bootloader擦除内部Flash的A区(从
APP_START_ADDR到APP_END_ADDR)。- 注意:STM32F103ZET6的扇区大小为2KB,需按扇区擦除。
- Bootloader从W25Q32中循环读取固件数据(每页256字节),写入内部Flash的A区地址。
- 每次写入后可选立即读回校验,或者整体写完后再计算CRC。
- 全部写入完成后,计算A区的CRC32,与之前保存的CRC值比较。
- 相等:将
OTA_FLAG更新为OTA_FLAG_SUCCESS,并更新版本号、CRC。 - 不相等:将
OTA_FLAG更新为OTA_FLAG_FAIL,并保留原A区(若未擦除干净可考虑回滚)。
- 相等:将
- Bootloader再次复位或直接跳转到A区。若
OTA_FLAG为SUCCESS,跳转后APP正常运行;若为FAIL,则停留在Bootloader并上报错误。
四、三段式Bootloader(A/B分区工业级方案)
对于要求极高可靠性的设备(如工业控制器、医疗设备、汽车ECU),两段式方案仍存在风险:如果在擦除A区后、写入新固件途中断电或写入失败,设备会变砖。三段式(A/B分区)通过双备份彻底解决此问题。
4.1 Flash内存划分
假设总Flash大小为256KB,Bootloader占32KB,两个APP区各占96KB,剩余为配置信息区。
| 分区 | 地址范围 | 大小 | 说明 |
|---|---|---|---|
| Bootloader | 0x08000000 ~ 0x08007FFF | 32KB | 永不更新,负责决策和搬运 |
| APP1区 | 0x08008000 ~ 0x0801FFFF | 96KB | 工厂版本或稳定版本 |
| APP2区 | 0x08020000 ~ 0x08037FFF | 96KB | OTA更新目标版本 |
| 配置信息区 | 0x08038000 ~ 0x0803FFFF | 32KB | 存储状态标志、当前活动分区、版本、CRC |
注意:两个APP区的链接脚本需分别设置不同的起始地址,编译出两个独立的bin文件。
4.2 关键状态机(存储在配置信息区)
定义以下状态值(可以使用结构体保存):
c
typedef struct {
uint8_t upgrade_state; // 0x00=正常, 0x01=待升级, 0x02=升级成功, 0x03=升级错误
uint8_t active_slot; // 当前运行的分区:1或2
uint16_t app1_version;
uint16_t app2_version;
uint32_t app1_crc;
uint32_t app2_crc;
} BootConfig;
4.3 正常启动流程
- Bootloader读取配置信息,获取
active_slot。 - 计算对应APP区的CRC,与存储的CRC比较:
- 校验通过 → 跳转到该APP区运行。
- 校验失败 → 尝试切换另一个分区(若另一个分区也失败,则停留在Bootloader)。
- APP运行后,若
upgrade_state == UPGRADE_SUCCESS,则将该状态清除为正常状态。
4.4 升级流程(以OTA为例)
- 当前APP(例如运行在APP1区)收到新固件,下载并存储到外部Flash(或直接写入非活动的APP2区)。
- 校验新固件完整性后,更新配置信息区:
upgrade_state = UPGRADE_PENDING- 目标分区为APP2(更新对方分区)
- 复位进入Bootloader。
- Bootloader检测到
UPGRADE_PENDING,将外部Flash或暂存区的固件写入APP2区。 - 写入完成后校验APP2区CRC,若成功:
- 将
active_slot改为2,upgrade_state = UPGRADE_SUCCESS。
- 将
- 跳转到APP2区运行。
4.5 升级失败回滚流程(核心优势)
若新固件在APP2区运行后出现异常(如死机、看门狗复位),Bootloader再次启动时检测到upgrade_state == UPGRADE_SUCCESS,但是通过某种机制(例如心跳检测失败后设置的失败标志)发现新APP不可用,则执行回滚:
- Bootloader将
active_slot改回APP1区。 upgrade_state设置为UPGRADE_ERROR。- 跳转到APP1区运行,设备恢复到升级前的稳定版本。
- 可选上报错误信息给服务器。
注意:回滚触发条件可以设计为:在APP2运行期间,若一定时间内没有向EEPROM写入"正常心跳"标志,或者手动触发恢复按键,则Bootloader判定本次升级失败并回滚。
4.6 超大容量器件的双存储区自举(BFB2位)
对于STM32F101/103超大容量器件(Flash≥768KB),硬件支持真正的双Bank机制。通过设置选项字节中的BFB2位,可以在不运行用户Bootloader的情况下实现从Bank2启动。这是一种硬件A/B切换,但本文不展开,感兴趣可查阅AN2606第6节。
五、可靠性机制详解
5.1 外置Flash(W25Q32)操作规范
W25Q32是一款4MB的SPI NOR Flash,页大小256字节,扇区大小4KB,块大小64KB。在使用时需注意:
- 写使能 :每次写入前必须发送
Write Enable (0x06)指令。 - 擦除:写入前必须擦除,且擦除的最小单位是4KB扇区(或64KB块)。擦除后所有位变为1。
- 页编程:一次最多写入256字节,若超过页边界会回绕覆盖开头,必须分页写入。
- 状态检查 :写入或擦除后需轮询
Read Status Register (0x05)的BUSY位,直到变为0。 - 写保护:本设计中将WP引脚接高电平,禁用写保护。
5.2 CRC32校验实现
c
#include "crc.h"
// 使用STM32硬件CRC32(多项式0x04C11DB7)
uint32_t CalculateCRC32(uint32_t *pData, uint32_t len)
{
HAL_CRC_Calculate(&hcrc, pData, len);
return hcrc.Instance->DR;
}
// 对整个APP区域进行校验
uint32_t VerifyAppCRC(uint32_t appStartAddr, uint32_t appSize, uint32_t expectedCRC)
{
uint32_t calc = CalculateCRC32((uint32_t*)appStartAddr, appSize / 4);
return (calc == expectedCRC);
}
5.3 断电保护策略(结合EEPROM状态机)
由于升级过程可能意外断电,需要设计状态机保证上电后可恢复。定义以下状态(存储在EEPROM):
| 状态码 | 含义 | 断电恢复后的操作 |
|---|---|---|
| 0x00 | 空闲,无升级 | 正常跳转APP |
| 0x01 | 正在下载固件到W25Q32 | 从断点续传(记录已下载字节数) |
| 0x02 | 下载完成,待校验 | 重新校验W25Q32中的固件 |
| 0x03 | 校验通过,待写入内部Flash | 擦除目标分区并写入 |
| 0x04 | 正在写入内部Flash | 从上次写入中断的地址继续(不推荐,复杂)或重新全量写入 |
| 0x05 | 写入完成,待验证 | 验证内部Flash CRC,成功则置为0x00,失败则回滚 |
实现原则:
- 每次进入下一个状态前,先将新状态写入EEPROM并确认写入成功。
- 然后执行操作。如果操作中途断电,上电后Bootloader根据状态决定是重试还是回滚。
- 对于写内部Flash操作,务必确保写入地址正确,且在擦除前备份原数据(A/B分区天然支持,无需备份)。
5.4 Flash擦写代码示例(基于HAL)
以下是参考《OTA远程升级 BootLoader.pdf》中的Inf_InteriorFlash.c实现的内部Flash操作:
c
// 擦除指定起始地址的N页(每页大小需要根据芯片获取)
HAL_StatusTypeDef Flash_ErasePages(uint32_t startAddr, uint32_t numPages)
{
HAL_StatusTypeDef status;
uint32_t pageError = 0;
FLASH_EraseInitTypeDef eraseInit = {0};
HAL_FLASH_Unlock();
eraseInit.TypeErase = FLASH_TYPEERASE_PAGES;
eraseInit.PageAddress = startAddr;
eraseInit.NbPages = numPages;
status = HAL_FLASHEx_Erase(&eraseInit, &pageError);
HAL_FLASH_Lock();
return status;
}
// 半字(16位)写入
HAL_StatusTypeDef Flash_WriteHalfWord(uint32_t addr, uint16_t data)
{
HAL_StatusTypeDef status;
HAL_FLASH_Unlock();
status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr, data);
HAL_FLASH_Lock();
return status;
}
六、多样化的升级通道
6.1 CAN总线升级(工业有线)
CAN总线具有高抗干扰、多节点、长距离(10km)特点,适合工业现场固件升级。
- 硬件:STM32内嵌CAN控制器,外加TJA1050等收发器。
- 协议:可采用自定义协议,每个升级包按CAN帧分段(每帧最多8字节数据)。使用AES-128加密固件,防止非法窃取。
- 流程:上位机通过CAN发送升级指令→设备进入升级模式→接收并存储到W25Q32→校验后更新内部Flash。
6.2 LoRa无线升级(远距离低功耗)
LoRa调制技术以低速率换取长距离和低功耗。根据您提供的资料,LLCC68/SX1278等模块的负载大小受扩频因子(SF)、带宽(BW)、编码率(CR)影响:
| 配置参数 | 典型值 | 最大负载(字节) |
|---|---|---|
| SF=12, BW=125kHz, CR=4/8 | 约300bps | 约51字节/包 |
| SF=9, BW=250kHz, CR=4/5 | 约1.5kbps | 约115字节/包 |
升级策略:
- 由于每包负载小,需设计分包协议,支持校验和重传。
- 固件通常几十KB,整个升级可能持续数分钟到数十分钟,适合对时间不敏感的设备。
- LoRaWAN协议标准支持Firmware Over-The-Air (FUOTA) 规范,但实现复杂,一般使用自定义简单协议。
6.3 串口(USART)IAP(本地有线)
最基础的方式,适合开发调试或本地维护。
- 使用Ymodem协议:具有CRC校验、重传、取消等机制,广泛用于嵌入式文件传输。
- 实现步骤:Bootloader中集成Ymodem接收函数,通过串口接收bin文件,写入内部Flash。
- 优点:无需外部Flash,可直接写入(但边收边写需小心断电风险)。
七、完整OTA流程工程化实践

八、常见问题与避坑指南(FAQ)
Q1:OTA状态位什么时候改变为可升级?
答 :必须在固件完整下载到W25Q32并完成CRC校验无误后 ,才将EEPROM中的标志改为PENDING。绝对不要在下载中途设置,否则断电后Bootloader会以为有完整固件而开始升级,导致写入损坏。
Q2:升级过程中断电怎么办?
答:利用EEPROM记录多级状态(下载中、校验通过、写入中、完成)。上电后Bootloader根据状态执行续传或重新下载。详细状态机见第五章5.3节。
Q3:如何防止升级失败后设备变砖?
答:采用三段式A/B分区设计。即使新分区写入失败或运行崩溃,Bootloader仍然可以启动旧分区。配合硬件看门狗,若新APP长时间不喂狗,则复位后自动回滚。
Q4:中断向量表需要注意什么?
答 :APP必须重映射中断向量表。方法:在APP的启动代码(或main开头)调用SCB->VTOR = APP_BASE_ADDRESS;。否则,当APP运行时发生中断(如串口中断、定时器中断),CPU会去0x08000000处找中断服务函数,而那里是Bootloader的向量表,导致HardFault。
Q5:如何测试Bootloader是否稳定?
答:建议进行以下测试:
- 正常跳转测试:上电后能否稳定进入APP。
- 升级测试:模拟完整升级10次以上,每次都校验CRC。
- 断电测试:在升级过程中不同阶段(擦除前、擦除后、写入第1/2/...页)随机断电,重新上电后观察是否能恢复或回滚。
- 边界测试:固件大小刚好是扇区整数倍、非整数倍,写入是否正常。
Q6:W25Q32的页回绕问题是什么?
答:W25Q32的Page Program指令每次最多写入256字节。如果你从页内偏移200开始写,数据长度60字节,那么会从200写到255(56字节),剩下的4字节会写到该页的0~3地址,覆盖原有数据。因此必须自己处理分页:每次写入前检查剩余长度,若超出页尾则分段写入。
九、总结与推荐实践
一套完善的Bootloader系统是企业级嵌入式产品的基石。基于上述分析,我们推荐:
| 应用场景 | 推荐方案 | 理由 |
|---|---|---|
| 开发调试 | ICP (SWD) + 串口IAP | 快速调试,简单可靠 |
| 成本敏感消费品(无远程升级需求) | 两段式Bootloader + 串口ISP | 占用Flash小,无需外部Flash |
| 工业设备(需远程升级) | 三段式A/B分区 + W25Q32 + EEPROM | 高可靠,支持回滚和断电恢复 |
| 物联网终端(无线升级) | 三段式 + LoRa/4G/WiFi下载 | 远程便捷,双备份防变砖 |
| 汽车ECU | 硬件双Bank + 带硬件安全模块的Bootloader | 满足功能安全标准 |
无论选择哪种方案,核心要点可归纳为:
- 启动顺序决定分区:Bootloader必须在Flash最前端。
- 状态持久化:用EEPROM或内部Flash专用页存储关键状态,支持断电恢复。
- 双重校验:下载后校验 + 写入后校验,防止静默错误。
- 安全回滚:A/B分区确保任何时候都至少有一个可用版本。
- 冗余设计:关键操作前备份重要数据,出错可恢复。
希望本文能帮助您构建稳定可靠的固件升级系统。如有疑问或需要更具体的代码实现,欢迎留言交流。