一、引言

在嵌入式产品开发中,固件升级是绕不开的需求。开发阶段可以通过 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 库。核心流程如下:

  1. 读取 .bin 固件文件
  2. 在数据头部插入 4 字节 Magic Number(0x5A5A5A5A
  3. 将总长度对齐至 1KB 边界,尾部以 0xFF 填充
  4. 发送握手字节 0xAA,等待 ACK
  5. 循环发送 1KB 数据块,每块等待 ACK
  6. 发送 4 字节结束魔数,等待 ACK
  7. 关闭串口,报告结果

关键点: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 升级完成,我们可以重新复位一些看看是否升级成功
在这里插入图片描述

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐