STM32F103 Bootloader 与 OTA 升级原理及实现
一、引言
在嵌入式产品开发中,固件升级是绕不开的需求。开发阶段可以通过 SWD/JTAG 调试器烧录程序,但产品部署后,拆机接调试器是不现实的。因此需要一个驻留在 MCU 内部的引导程序——Bootloader,通过串口、CAN、网络等通用接口接收新固件并完成更新。
这里我们会介绍 Bootloader 与 OTA(Over-The-Air)升级的核心原理,涵盖 Cortex-M3 启动机制、Flash 存储器物理特性、分区策略、通信协议设计,以及下位机与上位机的实现要点。同时提供示例程序
二、Cortex-M3 启动机制与 Bootloader 跳转原理
2.1 向量表与上电启动
Cortex-M3 内核上电后,硬件自动从 Flash 起始地址 0x08000000 读取向量表的前两项:
| 偏移 | 内容 | 硬件动作 |
|---|---|---|
0x00 |
初始栈顶地址 (SP) | 自动装入 MSP 寄存器 |
0x04 |
复位向量 (Reset_Handler) | 自动装入 PC 寄存器,CPU 从此处开始执行 |
0x08 |
NMI 向量 | |
0x0C |
HardFault 向量 | |
| … | 其他异常/中断向量 |
以实际固件为例,向量表前 8 字节在 Flash 中的存储(小端序):
地址 原始字节 解析值 含义
0x08000000 00 50 00 20 0x20005000 SP = SRAM 顶部 (20KB)
0x08000004 79 56 00 08 0x08005679 PC = Reset_Handler 入口
硬件上电只做两件事:取 SP,取 PC,然后运行。
2.2 Bootloader 跳转的本质
Bootloader 跳转到 APP 的过程,本质上是在软件层面复现硬件上电的向量表加载动作:
uint32_t app_sp = *(volatile uint32_t *)APP_ADDR; // 读取 APP 的栈顶
uint32_t app_pc = *(volatile uint32_t *)(APP_ADDR + 4); // 读取 APP 的复位向量
__set_MSP(app_sp); // 设置栈指针
((void (*)(void))app_pc)(); // 跳转到 APP Reset_Handler
2.3 跳转前后的环境清理
Bootloader 在跳转前需要清理运行环境,避免对 APP 产生干扰:
SysTick->CTRL = 0; // 关闭 SysTick,防止跳转后中断异常
__set_PRIMASK(1); // 屏蔽所有可屏蔽中断,保证跳转的原子性
__set_MSP(app_sp); // 加载 APP 栈指针
Jump(); // 跳转
APP 启动后需要完成两件事:
SCB->VTOR = FLASH_BASE | 0x4000; // 向量表偏移寄存器指向 APP 区
__set_PRIMASK(0); // 恢复中断使能
若不设置 VTOR,中断发生时 CPU 仍从 Bootloader 的向量表(0x08000000)查找 ISR,导致 HardFault。若不恢复 PRIMASK,SysTick 和所有外设中断将被永久屏蔽,HAL_Delay() 等依赖 SysTick 的函数将死锁。
三、STM32 Flash 存储器特性对 OTA 设计的约束
3.1 Flash 的物理特性
STM32F103C8T6 内置 64KB Flash,具有以下关键特性:
| 特性 | 说明 | 设计约束 |
|---|---|---|
| 写入单向性 | Flash 只能将 bit 从 1 改写为 0,无法反向操作 | 写入前必须擦除为目标区域恢复 0xFF |
| 页擦除 | 擦除操作以页为单位,F103 页大小为 1KB | 数据块大小应设计为 1KB 的整数倍 |
| 写入位宽 | 仅支持 16-bit Half-Word 或 32-bit Word 写入 | 按 Byte 写入将触发 HardFault |
| 写入期间总线阻塞 | Flash 编程期间 CPU 无法从 Flash 总线取指 | 写入期间所有中断失效,UART 可能溢出 |
3.2 CPU 挂起对通信的影响
Flash 编程一页(1KB)约需 25ms。在此期间 CPU 完全冻结,UART 接收端仅有一个字节的硬件缓冲(DR 寄存器)。如果上位机在此期间持续发送数据,必然导致数据丢失。
这意味着 OTA 通信协议上位机不能无脑发送:需要发送一页数据后立即停止,等待下位机完成 Flash 写入并回复确认信号,再发送下一页。
3.3 Flash 写入的 HAL 实现
void OTA_WritePage(uint32_t addr, uint8_t *data)
{
HAL_FLASH_Unlock();
for (int i = 0; i < 1024; i += 4) {
uint32_t word = *(uint32_t *)(&data[i]);
HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i, word);
}
HAL_FLASH_Lock();
}
四、Flash 分区策略
4.1 单区方案的困境
如果整个 Flash 只存放一份用户程序,OTA 时将新固件直接覆盖写入 APP 区:一旦传输中断或写入失败,Flash 中既没有完整的新固件,旧固件也已被破坏,设备变砖。
在实际使用中,我们往往使用三区布局
┌────────────────┐
│ Bootloader │ 16KB 0x08000000 引导程序,永不修改
├────────────────┤
│ APP 运行区 │ 16KB 0x08004000 当前运行的固件
├────────────────┤
│ OTA 暂存区 │ 32KB 0x08008000 新固件临时存放,验证后搬运
└────────────────┘
三区布局的核心优势:
- OTA 与 APP 物理隔离:接收新固件时,旧固件完整保留在 APP 区,传输失败不影响设备正常运行
- 搬运而非覆盖:新固件先完整存入 OTA 区,Bootloader 验证通过后再搬至 APP 区
- 断电可恢复:搬运完成前,OTA 区和 APP 区各自完整,任何一步失败都可重试
4.2 Magic Number 机制
Bootloader 如何判断 OTA 区中是否存在待部署的新固件?我们可以在 OTA 区首地址写入一个 4 字节的魔数 0x5A5A5A5A:
OTA 区布局:
0x08008000: [0x5A 0x5A 0x5A 0x5A] [固件向量表 + 代码 ...] [0xFF 填充]
└── Magic Number ──┘
Bootloader 上电后读取该地址:
#define OTA_FLAG_ADDR 0x08008000U
#define MAGIC_NUMBER 0x5A5A5A5AU
if (*(volatile uint32_t *)OTA_FLAG_ADDR == MAGIC_NUMBER) {
OTA2APP(); // 有新固件,执行搬运
}
// 否则直接跳转现有 APP
选择 0x5A5A5A5A 的原则是:与全 0xFF(擦除态)、栈地址(0x2000xxxx)、复位向量(0x0800xxxx)均不冲突。
4.3 搬运流程
OTA2APP() 的步骤顺序直接决定系统的鲁棒性:
static void OTA2APP(void)
{
// 步骤一:擦除 APP 区 (16 页 × 1KB)
for (int page = 0; page < 16; page++)
ErasePage(APP_ADDR + page * 1024);
// 步骤二:从 OTA 区逐字拷贝到 APP 区 (16KB)
for (int offset = 0; offset < 16 * 1024; offset += 4)
ProgramWord(APP_ADDR + offset, *(uint32_t *)(OTA_DATA_ADDR + offset));
// 步骤三:擦除 OTA Magic (最后执行)
ErasePage(OTA_FLAG_ADDR);
}
我们选择最后才清除 Magic,这样做的好处是如果在步骤二中发生断电,APP 区虽不完整,但 OTA 区的 Magic 仍然存在。下次上电时 Bootloader 检测到 Magic 还在,会重新执行搬运流程,不会陷入"OTA 和 APP 同时损坏"的死局。
五、OTA 通信协议设计
5.1 物理层
本方案采用 USART1 作为通信接口,参数为 115200-8-N-1。选择 UART 的理由是通用性强,几乎任何 MCU 都具备该外设。以下协议设计思路适用于任何物理层。
5.2 信号定义
| 信号 | 方向 | 值 | 含义 |
|---|---|---|---|
| 握手请求 | 上位机 → 下位机 | 0xAA (1 Byte) |
请求开始固件传输 |
| 传输结束 | 上位机 → 下位机 | 0xAA 0x55 0xFF 0x00 (4 Bytes) |
告知全部数据已发送完毕 |
| ACK | 下位机 → 上位机 | 0x06 (1 Byte) |
当前操作已完成,可继续 |
5.3 会话流程
上位机与下位机之间的交互分为三个阶段:
阶段一:握手
上位机 → [0xAA] → 下位机 // 请求开始升级
下位机 → [0x06] → 上位机 // OTA 区已擦除,准备就绪
阶段二:数据传输
上位机 → [1024 bytes 固件数据] → 下位机 // 发送一页数据
下位机执行: OTA_WritePage() // 写 Flash (CPU 挂起 ~25ms)
下位机 → [0x06] → 上位机 // 写入完成,继续发送
... (循环至全部数据发送完毕)
阶段三:结束确认
上位机 → [0xAA 0x55 0xFF 0x00] → 下位机 // 结束魔数,告知传输完成
下位机 → [0x06] → 上位机 // 确认接收
5.4 数据格式
上位机发送的数据在固件 .bin 文件的基础上进行了预处理:
发送数据 = [Magic: 0x5A5A5A5A] + [原始 .bin 内容] + [0xFF 填充至 1KB 对齐]
- Magic 用于 Bootloader 识别新固件
0xFF填充是因为 Flash 只能整页写入,最后一页不足 1KB 时以闪存擦除态补齐
5.5 Stop-and-Wait 流控与超时重试
受 Flash 写入期间 CPU 挂起的限制,协议采用停等流控:上位机每发送一个数据块后即进入阻塞等待状态,直至收到下位机的 ACK 或超时。
for attempt in range(RETRY_MAX):
ser.write(chunk) # 发送 1KB
resp = ser.read(1, timeout=DATA_TIMEOUT) # 阻塞等待 ACK
if resp == ACK:
break # 成功
# 超时或无效响应 → 重试
5.6 结束信号的防误触发设计
结束信号若仅使用单字节(如 0xBB),在固件数据中随机出现的概率为 1/256,对实际固件而言误触发风险不可忽略。本方案采用 4 字节魔数 0xAA 0x55 0xFF 0x00,误触发概率降至 1/2³² ≈ 2.3×10⁻¹⁰。
下位机每完成一页写入后进入结束检测状态,连续接收 4 字节:
- 若 4 字节与结束魔数完全匹配 → 判定传输结束,回复 ACK,回到 IDLE
- 若任一字节不匹配 → 判定该 4 字节属于下一页数据,退回接收状态继续累积,已读取的 4 字节不丢弃
case END_CHECK:
check_buf[magic_idx++] = rx_byte;
if (magic_idx >= 4) {
if (memcmp(check_buf, magic, 4) == 0) {
send_ack();
state = IDLE; // 传输结束
} else {
state = HANDSHAKE;
page_offset = 4;
memcpy(page_buf, check_buf, 4); // 退回数据,不丢失
}
}
六、下位机实现要点
6.1 中断接收状态机
下位机(STM32 APP)端使用三状态状态机驱动串口接收:
┌──────────┐
上电 → │ IDLE │ 等待握手字节 0xAA
└────┬─────┘
│ 收到 0xAA → 回复 ACK
▼
┌──────────┐
┌───────→│ HANDSHAKE│ 逐字节接收,存入 1KB 缓冲区
│ └────┬─────┘
│ │ 收满 1024 字节
│ ▼
│ 主循环: OTA_WritePage() → 回复 ACK
│ │
│ ▼
│ ┌──────────┐
│ │END_CHECK │ 接收 4 字节,检测结束魔数
│ └──┬───┬───┘
│ │ │
│ 匹配 ──┘ └── 不匹配 → 退回 HANDSHAKE
│ (传输结束)
└────────────────────────────┘
注意中断回调只负责字节接收和状态切换,Flash 写入操作交给主循环。原因是 HAL 库的 Flash API 内部可能关闭全局中断,在中断上下文中执行 Flash 写入会导致不可预期的行为。
6.2 主循环与中断的协作
由于Flash写入比较耗时,不推荐在中断函数中完成写入操作,回到主函数中进行写入
// 主循环
while (1) {
if (rx_flag) {
OTA_WritePage(write_addr, page_buf); // 写 Flash
write_addr += 1024;
send_ack();
state = END_CHECK; // 进入结束检测
HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 重新使能中断接收
}
}
中断回调在填满 1024 字节后置位 rx_flag 并停止调用 HAL_UART_Receive_IT,由主循环在 Flash 写入完成后重新启动接收。这样保证了 Flash 操作期间不会有新的中断触发。
七、上位机实现要点
上位机使用 Python编写,依赖 pyserial 库。核心流程如下:
- 读取
.bin固件文件 - 在数据头部插入 4 字节 Magic Number(
0x5A5A5A5A) - 将总长度对齐至 1KB 边界,尾部以
0xFF填充 - 发送握手字节
0xAA,等待 ACK - 循环发送 1KB 数据块,每块等待 ACK
- 发送 4 字节结束魔数,等待 ACK
- 关闭串口,报告结果
关键点:Magic 由上位机插入而非下位机,因为 .bin 由编译器生成,不含 OTA 信息;0xFF 填充保证末页完整,且 0xFF 是 Flash 擦除态、写入时无副作用。
八、完整 OTA 升级流程
┌────────────────────────────────────────────────────────────────┐
│ 步骤 1: 设备上电 │
│ Bootloader 启动 → 检查 OTA Magic → 无新固件 → 跳转 APP │
│ │
│ 步骤 2: APP 运行 │
│ 初始化 UART → 擦除 OTA 区 32KB → 进入 IDLE 等待握手 │
│ │
│ 步骤 3: OTA 传输 │
│ 上位机发送握手 → 握手确认 → 逐页发送数据 → 结束魔数 → 确认 │
│ (此阶段旧固件始终在 APP 区完整运行) │
│ │
│ 步骤 4: 系统复位 (手动或看门狗) │
│ │
│ 步骤 5: Bootloader 再次启动 │
│ 检测到 OTA Magic → OTA2APP() 搬运 → 清除 Magic → 跳转新 APP │
│ │
│ 步骤 6: 新固件运行 │
└────────────────────────────────────────────────────────────────┘
九、实验步骤
这篇文章使用的项目地址
https://gitee.com/Hans_Rudle/stm32_bootloader
我们需要一个stlink下载原始程序和一个串口模块接到串口1上查看调试信息
下面是仓库目录讲解
Boot/
│
├── send_bin.py # PC 端 OTA 固件发送脚本
├── APP2.bin # 测试用固件 (5.79KB)
│
├── Bootloader/ # ── 工程 1: Bootloader ──
│ ├── STM32F103XX_FLASH.ld # 链接脚本: Flash 0x08000000, 16KB
│ ├── startup_stm32f103xb.s # 启动文件 (GCC)
│ ├── Drivers/ # HAL 库 + CMSIS
│ └── Core/
│ ├── Inc/
│ │ ├── bootloader.h # ★ 宏定义: APP_ADDR, OTA 魔数检查
│ │ └── main.h / usart.h / gpio.h
│ └── Src/
│ ├── main.c # ★ 主流程: 初始化 → 延时3s → 跳转APP
│ ├── bootloader.c # ★ 核心: OTA2APP搬运 + App_Loading跳转
│ ├── stm32f1xx_it.c # 中断服务函数
│ └── system_stm32f1xx.c # 系统初始化 (VTOR=0)
│
├── APP/ # ── 工程 2: 用户APP ──
│ ├── STM32F103XX_FLASH.ld # 链接脚本: Flash 0x08004000, 16KB
│ ├── startup_stm32f103xb.s # 启动文件 (GCC)
│ ├── Drivers/ # HAL 库 + CMSIS
│ └── Core/
│ ├── Inc/
│ │ └── main.h / usart.h / gpio.h
│ ├── BSP/ # ★ 自写的板级支持包
│ │ ├── Serial.h # OTA 接收接口声明
│ │ └── Serial.c # ★ 核心: 串口中断状态机 + Flash写入
│ └── Src/
│ ├── main.c # ★ 主循环: 初始化 → 擦OTA区 → 等OTA数据
│ ├── stm32f1xx_it.c # 中断服务 (含 USART1_IRQHandler)
│ ├── usart.c # UART 初始化
│ └── system_stm32f1xx.c # 系统初始化 (VTOR=0, main中手动改)
│
首先,我们将Bootloader程序和APP程序均编译出一个.hex文件
使用cubeprg擦除Flash区域,防止干扰
使用cubeprg烧写Bootloader和app(注意顺序 一定烧先烧Bootloader!)之后上电接上串口模块观察现象
其中Start To APP之前的文本都来自与Bootloader工程,APP输出为Hello world!说明为APP1
接下来我们使用python脚本,发送程序APP2.bin到单片机
终端输入
python send_bin.py COM24 APP2.bin

上位机发送完成后复位单片机 执行OTA升级流程
最后输出Bootloader 升级完成,我们可以重新复位一些看看是否升级成功
更多推荐


所有评论(0)