STM32F103 A/B 双分区 OTA:从零复现到使用上位机
本教程完全按照仓库现有代码讲解,以 Windows 11 + STM32F103RET6 + ST-Link + 3.3 V USB-TTL 为主线。这里常说"OTA",严格讲当前实现是 UART IAP;接入无线透传模块后,它才成为更大系统中的空中升级链路。

目录
- 先把几个概念分清
- 硬件、软件与芯片核验
- [读懂工程目录和 Flash 地图](#读懂工程目录和 Flash 地图 "#3-%E8%AF%BB%E6%87%82%E5%B7%A5%E7%A8%8B%E7%9B%AE%E5%BD%95%E5%92%8C-flash-%E5%9C%B0%E5%9B%BE")
- [Cortex-M3 为什么能从 Bootloader 跳到 App](#Cortex-M3 为什么能从 Bootloader 跳到 App "#4-cortex-m3-%E4%B8%BA%E4%BB%80%E4%B9%88%E8%83%BD%E4%BB%8E-bootloader-%E8%B7%B3%E5%88%B0-app")
- [为什么同一 App 必须生成 app_a 和 app_b](#为什么同一 App 必须生成 app_a 和 app_b "#5-%E4%B8%BA%E4%BB%80%E4%B9%88%E5%90%8C%E4%B8%80-app-%E5%BF%85%E9%A1%BB%E7%94%9F%E6%88%90-app_a-%E5%92%8C-app_b")
- [构建 Bootloader 和两个 App](#构建 Bootloader 和两个 App "#6-%E6%9E%84%E5%BB%BA-bootloader-%E5%92%8C%E4%B8%A4%E4%B8%AA-app")
- [第一次用 ST-Link 部署](#第一次用 ST-Link 部署 "#7-%E7%AC%AC%E4%B8%80%E6%AC%A1%E7%94%A8-st-link-%E9%83%A8%E7%BD%B2")
- [连接串口并进入 OTA 模式](#连接串口并进入 OTA 模式 "#8-%E8%BF%9E%E6%8E%A5%E4%B8%B2%E5%8F%A3%E5%B9%B6%E8%BF%9B%E5%85%A5-ota-%E6%A8%A1%E5%BC%8F")
- [使用 Qt 图形上位机完成 A→B](#使用 Qt 图形上位机完成 A→B "#9-%E4%BD%BF%E7%94%A8-qt-%E5%9B%BE%E5%BD%A2%E4%B8%8A%E4%BD%8D%E6%9C%BA%E5%AE%8C%E6%88%90-ab")
- 验证后确认新固件
- [完成 B→A 反向升级](#完成 B→A 反向升级 "#11-%E5%AE%8C%E6%88%90-ba-%E5%8F%8D%E5%90%91%E5%8D%87%E7%BA%A7")
- 亲手验证四次复位回滚
- [协议、CRC 和 Metadata 原理](#协议、CRC 和 Metadata 原理 "#13-%E5%8D%8F%E8%AE%AEcrc-%E5%92%8C-metadata-%E5%8E%9F%E7%90%86")
- 命令行工具用法
- 常见故障排查
- 当前边界与量产化路线
- 代码导览与参考资料
1. 先把几个概念分清
1.1 三种"程序"
| 名称 | 存放位置 | 谁提供 | 本项目如何进入 |
|---|---|---|---|
| STM32 ROM/System Memory Bootloader | 芯片系统存储区 | ST 出厂固化 | 由 BOOT 配置进入,协议见 AN2606/AN3155 |
| 本项目 Bootloader | 用户 Flash 0x08000000 |
本仓库 | MCU 每次复位先运行;首字节收到 U 进入升级循环 |
| Application | Slot A 或 Slot B | 你的业务代码 | Bootloader 校验向量表后跳转 |
本项目自定义帧以 AA 55 开头,不是 ST ROM Bootloader 的 AN3155 协议。STM32CubeProgrammer 可以用于第一次通过 ST-Link 烧录,但 UART 上位机与 CubeProgrammer 不是同一种协议。
1.2 UART IAP 与联网 OTA
当前数据链路是:
text
Windows 上位机 ── USB-TTL / USART1 ──> 用户 Flash Bootloader
它属于 IAP(In-Application Programming)方案。若未来让 4G/Wi-Fi/蓝牙模块透明传输完全相同的字节流,才可以从系统角度称为 OTA。仓库目前没有 TCP、HTTP、MQTT 或无线驱动。
1.3 逻辑 A/B 不等于硬件 Dual-Bank
STM32F103xE 的这里没有使用硬件 Bank Swap。Slot A、Slot B 是同一片 Flash 中的两段地址,Bootloader 通过 SCB->VTOR、MSP 和 Reset_Handler 手动选择运行哪一段。
1.4 CRC 不是签名
- CRC16:发现单个串口帧传输误码;
- CRC32:比较完整固件在 PC 和 Flash 中是否一致;
- 它们都不能抵御主动篡改,也不能验证发布者身份。
量产设备还需要签名、公钥验证、版本策略和密钥管理。
2. 硬件、软件与芯片核验
2.1 推荐硬件清单
- STM32F103RET6 最小系统板或自制板;
- ST-Link V2/V3;
- 3.3 V USB-TTL(例如 CP2102、CH340、FT232 的 TTL 模式);
- 杜邦线;
- 稳定的 3.3 V 供电;
- 可选:SSD1306 OLED 模块,用于
07_ret6_oled示例。
不要把传统 ±RS-232 电平直接接到 PA9/PA10。USB-TTL 必须使用 3.3 V 逻辑电平,并与 MCU 共地。
2.2 RET6 与目录中的 ZET6
bootloader、app/app、07_ret6_oled 三个 .ioc 当前选择的实际器件均为 STM32F103RET6 / LQFP64。本教程据此编写。
核心容量前提:
text
Flash: 0x08000000 ~ 0x0807FFFF = 512 KiB
SRAM : 0x20000000 ~ 0x2000FFFF = 64 KiB
SRAM 顶端/尾后地址 = 0x20010000
初始 MSP 等于 0x20010000 是合法的,因为栈从高地址向低地址生长。README 旧版本把这个范围写成 128 KiB 是错误的。
若你的芯片是 ZET6:
- 用 CubeMX 打开每个
.ioc; - 将器件改为实物型号和 LQFP144 封装;
- 重新核对 LED、USART、OLED、晶振等引脚;
- 不要只因 Flash/SRAM 容量相同就直接沿用 RET6 的板级接线。
2.3 Windows 软件
建议安装:
- STM32CubeProgrammer
- Arm GNU Toolchain:确保
arm-none-eabi-gcc和arm-none-eabi-objcopy在 PATH - CMake 3.22 或更高版本
- Ninja
- Python 3.10 或更高版本
- 可选:CLion 或 VS Code
在 PowerShell 中核验:
powershell
arm-none-eabi-gcc --version
arm-none-eabi-objcopy --version
cmake --version
ninja --version
py --version
安装上位机依赖:
powershell
cd <仓库目录>
py -m pip install -r tools\requirements.txt
requirements.txt 默认安装 PySerial 和 PySide6。GUI 代码也兼容已安装的 PyQt5/PySide2,但教程以 PySide6 为准。
3. 读懂工程目录和 Flash 地图
text
stm32_ab_ota/
├── bootloader/ 用户 Flash Bootloader
│ └── Core/Src/
│ ├── main.c OTA 入口与正常启动状态机
│ ├── boot_protocol.c AA55 帧和命令处理
│ ├── boot_flash.c 页擦除、半字写入与分区边界
│ ├── boot_jump.c 镜像检查与跳转
│ ├── boot_metadata.c Metadata 读写
│ └── boot_crc.c CRC16 / CRC32
├── app/app/ LED App,同一源码链接成 A/B 两份
├── 07_ret6_oled/ 可选 OLED App,固定链接到 Slot B
├── tools/
│ ├── ota.py 协议实现与 CLI
│ └── ota_gui.py Qt 图形上位机
└── images/ 教程流程图
3.1 Flash 分区

图 1:A/B 是同一 Flash 中的逻辑槽位,不是两个硬件 Bank。
| 分区 | 起始地址 | 结束地址(含) | 大小 |
|---|---|---|---|
| Bootloader | 0x08000000 |
0x08007FFF |
32 KiB |
| Slot A | 0x08008000 |
0x0803FFFF |
224 KiB |
| Slot B | 0x08040000 |
0x08077FFF |
224 KiB |
| Metadata 预留 | 0x08078000 |
0x0807FFFF |
32 KiB |
常量来自 bootloader/Core/Inc/boot_config.h。Metadata 虽预留 32 KiB,当前 boot_metadata_save() 只擦写首个 2 KiB 页中的 28 字节,剩余空间尚未用于双副本或磨损均衡。
3.2 为什么每槽是 224 KiB
text
512 KiB - 32 KiB Bootloader - 32 KiB Metadata = 448 KiB
448 KiB / 2 = 224 KiB
F103xE Flash 页是 2 KiB,所以所有分区起点都应页对齐。
4. Cortex-M3 为什么能从 Bootloader 跳到 App
4.1 向量表前 8 字节

图 2:每个可启动镜像开头都有自己的向量表。
Cortex-M3 镜像的前两个 32 位数:
text
offset +0x00: 初始 MSP(栈顶)
offset +0x04: Reset_Handler 地址,最低位必须为 1(Thumb 状态)
本项目的 boot_app_is_valid() 检查:
- 起始地址必须精确等于 Slot A 或 Slot B;
- 镜像至少 8 字节且不越过槽位;
- MSP 位于 64 KiB SRAM 范围且 8 字节对齐;
- Reset_Handler 带 Thumb 位;
- 清除 Thumb 位后的地址位于这份镜像范围内。
4.2 跳转步骤

图 3:Bootloader 将 CPU 执行环境交给目标 App。
boot_jump_to_app() 的关键顺序:
c
__disable_irq();
HAL_DeInit();
SysTick->CTRL = 0;
SysTick->LOAD = 0;
SysTick->VAL = 0;
SCB->VTOR = app_address;
__DSB();
__ISB();
__set_MSP(app_stack);
((app_entry_t)app_reset_handler)();
App 也会用链接器符号再次设置自己的 VTOR。LED App 已按以下顺序恢复:
c
SCB->VTOR = (uint32_t)&g_pfnVectors;
__DSB();
__ISB();
__enable_irq();
必须最后重新打开全局中断,否则 Bootloader 留下的 PRIMASK=1 会使 SysTick 不运行,HAL_Delay() 看起来像"卡死"。
当前 C 跳转函数没有完整清除所有 NVIC enable/pending 状态,也没有使用汇编尾跳。这不妨碍当前教学复现,但量产项目应继续加固。
5. 为什么同一 App 必须生成 app_a 和 app_b
STM32F103 没有 MMU 帮程序在运行时整体搬家。链接器会按照 FLASH ORIGIN 安排向量表、函数和常量地址,因此同一源码必须分别链接。

图 4:app_a 与 app_b 是相同源码、不同链接地址的两个构建目标。
Slot A 链接脚本:
ld
FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 224K
Slot B 链接脚本:
ld
FLASH (rx) : ORIGIN = 0x08040000, LENGTH = 224K
app/app/CMakeLists.txt 实际定义:
cmake
add_executable(app_a)
add_executable(app_b)
然后分别绑定 STM32F103xx_FLASH.ld 和 STM32F103xx_FLASH_B.ld。这里没有 使用 -DBOOT_SLOT=A/B。
5.1 app_b 不是 OLED
app_a.bin:LED App 链接到 Slot A;app_b.bin:同一 LED App 链接到 Slot B;07_ret6_oled.bin:另一个独立 OLED 工程,链接到 Slot B,只是可选演示镜像。
先用 LED A/B 打通完整链路,再尝试 OLED,排错更简单。
6. 构建 Bootloader 和两个 App
以下命令均从仓库根目录执行。
6.1 构建 Bootloader
powershell
cmake --preset Debug -S bootloader
cmake --build bootloader\build\Debug --target bootloader
这里直接指定 preset 生成的构建目录,因此这些命令都可以停留在仓库根目录执行。也可以先 cd bootloader,再运行 cmake --preset Debug 和 cmake --build --preset Debug --target bootloader。
预期输出目录:
text
bootloader/build/Debug/
├── bootloader.elf
├── bootloader.hex
└── bootloader.bin
6.2 构建 app_a 与 app_b
powershell
cmake --preset Debug -S app\app
cmake --build app\app\build\Debug --target app_a app_b
预期产物:
text
app/app/build/Debug/
├── app_a.elf / app_a.hex / app_a.bin / app_a.map
└── app_b.elf / app_b.hex / app_b.bin / app_b.map
必须显式构建 app_b,不能因为目录里已有旧 app_a.bin 就认为两个目标都构建过。
6.3 可选:构建 OLED Slot B 示例
powershell
cmake --preset Debug -S 07_ret6_oled
cmake --build 07_ret6_oled\build\Debug --target 07_ret6_oled
OLED 软件 I²C 的 SCL/SDA 都配置为开漏。模块或板上必须有上拉电阻;否则总线无法可靠释放和读取 ACK。
6.4 检查 ELF / map / BIN
用 objdump 查看段地址:
powershell
arm-none-eabi-objdump -h app\app\build\Debug\app_a.elf
arm-none-eabi-objdump -h app\app\build\Debug\app_b.elf
你应看到向量表或 .text 从对应槽位附近开始:
text
app_a: 0x08008000
app_b: 0x08040000
用 Python 查看 BIN 前 8 字节:
powershell
py -c "import struct,pathlib; p=pathlib.Path(r'app\app\build\Debug\app_a.bin'); d=p.read_bytes(); print(p,len(d),[hex(x) for x in struct.unpack('<II',d[:8])])"
py -c "import struct,pathlib; p=pathlib.Path(r'app\app\build\Debug\app_b.bin'); d=p.read_bytes(); print(p,len(d),[hex(x) for x in struct.unpack('<II',d[:8])])"
检查规则:
- 第一个值通常接近
0x20010000,必须在 SRAM 合法范围; - 第二个值最低位是 1;清除最低位后,A 镜像地址在 A 的实际镜像范围,B 镜像地址在 B 的实际镜像范围;
- BIN 总长度必须为偶数且不超过 224 KiB。
7. 第一次用 ST-Link 部署
7.1 连接 ST-Link
最少连接:
text
ST-Link SWDIO -> MCU SWDIO
ST-Link SWCLK -> MCU SWCLK
ST-Link GND -> MCU GND
目标板供电按你的硬件方案连接
BOOT0 保持正常从用户 Flash 启动的状态(通常下拉为 0)。
7.2 推荐用 HEX 首次烧录
.hex 自带目标地址,最不容易填错:
- 连接 ST-Link;
- 如需清理旧工程,可在首次部署时执行一次整片擦除;
- 下载
bootloader.hex; - 下载
app_a.hex; - 复位。
7.3 用 BIN 时必须填写地址
| 文件 | CubeProgrammer Start address |
|---|---|
bootloader.bin |
0x08000000 |
app_a.bin |
0x08008000 |
app_b.bin |
0x08040000 |
07_ret6_oled.bin |
0x08040000 |

图 5:只更新 App 时不要再执行 Full Chip/Mass Erase。
烧 App 时若勾选 Mass Erase,0x08000000 的 Bootloader 也会消失,CPU 复位后无法启动。遇到"串口没有 banner、App 也不运行",优先重新烧录 Bootloader + Slot A。
8. 连接串口并进入 OTA 模式
8.1 接线
text
STM32 PA9 / USART1_TX ----> USB-TTL RX
STM32 PA10 / USART1_RX <---- USB-TTL TX
STM32 GND ----- USB-TTL GND
串口参数固定为:
text
115200 baud, 8 data bits, no parity, 1 stop bit, no flow control
GUI 现在只显示 115200,因为 Bootloader 源码没有动态波特率协商。
8.2 入口的关键细节
boot_wait_for_ota() 调用一次阻塞接收:
c
HAL_UART_Receive(&huart1, &command, 1, 2000)
只有第一个收到的字节 恰好为 ASCII U 才进入 OTA。它不是在 2 秒数据流中不停搜索 U。若第一字节是噪声或串口工具留下的字符,后面的 U 不会补救这次启动。
Python 上位机每 50 ms 重复发送 U,持续最多 10 秒。正确操作:
- 先打开串口并启动"连接设备";
- 看到"请复位"提示后立即按 RESET 或重新上电;
- 等待日志出现:
text
OK: Enter OTA mode
进入 OTA 循环后没有空闲超时,不发送 BOOT/SWITCH 就不会自动回到 App。

图 6:TX/RX 交叉连接,GND 必须共地。
9. 使用 Qt 图形上位机完成 A→B
9.1 启动 GUI
powershell
cd <仓库目录>\tools
py ota_gui.py
操作顺序:
- 点击"刷新",选择 USB-TTL 对应 COM 口;
- 波特率保持 115200;
- 点击"打开串口";
- 点击"连接设备";
- 按提示复位 MCU;
- 进入 OTA 后点击"查询信息"。
期望 INFO 内容:
text
Bootloader 版本: 0.1
Flash 总大小: 512 KB
Slot A: 0x08008000 (224 KB)
Slot B: 0x08040000 (224 KB)
GUI 每次真正开始升级时还会自动发送 INFO 并再次校验设备布局,所以即使没有预先手工点击"查询信息",布局不一致也会在擦除前被拒绝。
9.2 选择正确镜像
当前从 Slot A 升级到 Slot B:
text
固件:app_b.bin
目标槽位:Slot B (0x08040000)
回滚槽位:GUI 推导为 Slot A (0x08008000)
上位机无法从 INFO 知道 MCU 当前运行哪个槽位。确认对话框出现时,你必须保证 Slot A 确实是已经验证可用的旧固件。如果刚改变过默认槽或手工烧过 Flash,不要机械点击"是"。
GUI 会在写入前检查:
- 文件非空且至少 8 字节;
- 文件不超过 224 KiB;
- 长度为偶数;
- 初始 MSP 在 64 KiB SRAM 内且 8 字节对齐;
- Reset_Handler Thumb 位有效;
- Reset_Handler 位于所选目标镜像范围。
9.3 上位机实际发送什么

图 7:VERIFY 请求只发送 address + size,设备返回 Flash CRC32,PC 再与本地值比较。
GUI 执行:
text
1. ERASE(target, 向上取整后的页长度)
2. DATA(target + offset, 最多 240 字节数据) × N
3. VERIFY(target, 固件实际长度)
4. 比较设备返回 CRC32 与 zlib.crc32(data)
5. MARK_PENDING(target, confirmed, crc32, size),设备还会确认回滚槽含有基本可启动镜像
6. BOOT(target)
成功日志最后会提示 MCU 已离开 OTA 模式。此时 GUI 会把连接状态清除,这是正确行为:Bootloader 已经跳到新 App,不能直接继续发 CONFIRM。
10. 验证后确认新固件

图 8:当前项目没有 app_confirm_boot();确认命令来自 PC。
完整步骤:
-
BOOT后观察 Slot B 版本的 LED、OLED 或业务功能; -
如果功能正确,回到 GUI 点击"确认新固件";
-
GUI 先弹出说明;点击"确定"后,它才开始重复发送
U; -
看到日志中的"正在进入 OTA 模式"后立即按 RESET;
-
Bootloader 进入 OTA 后接收
CMD_CONFIRM; -
它重新检查 PENDING 的两个槽位都可启动,并检查目标 size、向量表和 Flash CRC;
-
写入:
textconfirmed_slot = pending_slot pending_slot = 0 state = CONFIRMED boot_count = 0 crc/size = 0 -
GUI 日志显示确认成功。
注意:CONFIRM 本身不跳转。确认后 MCU 仍停留在 OTA 命令循环。可再发送 BOOT,或手动复位后不要发送 U,让正常状态机启动新 confirmed 槽。
BOOT、MARK_PENDING、CONFIRM、SWITCH 的区别
| 操作 | 是否写 Metadata | 是否立即跳转 | 复位后的默认行为 |
|---|---|---|---|
BOOT(slot) |
否 | 是 | 仍按原 Metadata |
MARK_PENDING |
写 PENDING | 否 | 尝试 pending 并计数 |
| 升级流程 | MARK_PENDING 后 BOOT | 是 | 未确认时继续试 pending/最终回滚 |
CONFIRM |
PENDING→CONFIRMED | 否 | 启动新 confirmed |
SWITCH(slot) |
直接写 CONFIRMED | 是 | 默认启动指定槽 |
SWITCH 适合 A、B 都已知可用时人工选择默认槽;它没有完整固件 CRC 元数据,只做基本向量检查,因此不要把它当成安全升级确认流程。
11. 完成 B→A 反向升级
前提:Slot B 已通过上一节确认并能正常复位启动。
- 让 MCU 进入 Bootloader;
- GUI 选择
app_a.bin; - 目标槽位选 Slot A;
- 确认对话框中的回滚槽是 Slot B;
- 开始升级;
- App A 运行后验收;
- 点击"确认新固件",复位并重新进入 Bootloader;
- 确认成功后再复位验证。
不要把 app_b.bin 烧到 A,也不要把 app_a.bin 烧到 B。上位机会通过 Reset_Handler 地址拒绝这种错误。
12. 亲手验证四次复位回滚

图 9:直接 BOOT 不增加计数,之后三次普通复位各加 1,第四次回滚。
12.1 实验准备
假设当前:
text
Slot A = confirmed 且功能正常
准备把 app_b.bin 升级到 Slot B
12.2 实验步骤
- 正常完成 A→B 升级,直到 GUI 显示已 BOOT 到新固件;
- 不要点击确认新固件;
- 直接 BOOT 的首次试运行:
boot_count仍为 0; - 第 1 次普通复位:不要发送
U,Bootloader 保存0→1,运行 B; - 第 2 次普通复位:保存
1→2,运行 B; - 第 3 次普通复位:保存
2→3,仍运行 B; - 第 4 次普通复位:一开始看到
count>=3,状态回到 CONFIRMED,并启动 A。
12.3 为什么 HardFault 不一定自动回滚
本项目没有启用 IWDG/WWDG,也没有在 HardFault 中自动复位。若 B 卡在死循环:
boot_count不会在 App 中继续增加;- Bootloader 也不会自动重新运行;
- 必须人工按 RESET、重新上电,或由板外复位电路触发复位。
所以当前机制更准确地叫"跨复位的未确认回滚状态机",不是完整的自动健康监测闭环。
13. 协议、CRC 和 Metadata 原理
13.1 自定义帧

图 10:没有 EOF 字段;55 是两字节帧头的第二个字节。
text
[AA][55][CMD][SEQ][LEN_LO][LEN_HI][PAYLOAD...][CRC_LO][CRC_HI]
LEN:payload 长度,u16 小端;- payload 最大 256 字节;
- CRC16 覆盖
CMD + SEQ + LEN_LO + LEN_HI + PAYLOAD; - CRC16 本身按小端发送;
- 响应复用请求 CMD 并回显 SEQ。
CRC16 参数:
text
CRC-16/CCITT-FALSE
poly = 0x1021
init = 0xFFFF
refin = false
refout = false
xorout = 0
"123456789" -> 0x29B1
13.2 命令表
| 命令 | 值 | 请求 payload | 成功响应 payload |
|---|---|---|---|
| INFO | 0x01 |
空 | 22 字节版本/Flash/A/B 布局 |
| ERASE | 0x02 |
address:u32 + size:u32 |
status:u8 |
| DATA | 0x03 |
address:u32 + data |
status:u8 |
| VERIFY | 0x04 |
address:u32 + size:u32 |
status:u8 + crc32:u32 |
| BOOT | 0x05 |
slot:u32 |
status:u8,随后跳转 |
| CONFIRM | 0x06 |
空 | status:u8 |
| MARK_PENDING | 0x07 |
target + confirmed + crc32 + size |
status:u8 |
| SWITCH | 0x08 |
target:u32 |
status:u8,随后跳转 |
所有 u32 都是小端。MARK_PENDING 的字段顺序不能交换。
13.3 为什么 DATA 每块 240 字节
text
payload 上限 = 256
DATA 地址 = 4
可用数据上限 = 252
代码选 240 而不是 252:它是偶数,满足 STM32F1 半字写入,也给协议保持整齐余量:
text
4 + 240 = 244 <= 256
最后一块也必须是偶数,所以主机对奇数长度 BIN 直接拒绝,而不是偷偷填充并改变 CRC/size 语义。
13.4 整包 CRC32
算法是反射 CRC32:
text
poly = 0xEDB88320
init = 0xFFFFFFFF
xorout = 0xFFFFFFFF
"123456789" -> 0xCBF43926
等价 Python:
python
zlib.crc32(data) & 0xFFFFFFFF
VERIFY 请求中没有"期望 CRC"字段。Bootloader 根据 address + size 计算后返回,主机负责比较。
13.5 Metadata 与启动状态机
c
typedef struct {
uint32_t magic;
uint32_t state;
uint32_t confirmed_slot;
uint32_t pending_slot;
uint32_t boot_count;
uint32_t crc;
uint32_t size;
} boot_metadata_t;

图 11:只有 magic/state/槽位/size/向量/CRC/计数检查;没有按键、RAM magic 或 Metadata CRC。

图 12:状态属于 Metadata 全局记录,不是每个槽各自维护 EMPTY/INVALID 枚举。
状态机摘要:
text
magic 无效 -> 尝试 Slot A
state = CONFIRMED -> 启动合法 confirmed_slot
未知 state -> 不按 PENDING 解释,回退尝试 Slot A
state = PENDING:
pending/confirmed 槽地址非法或相同
-> 不修改 Metadata,尝试 confirmed;仅当 confirmed 不是 A 时再尝试出厂 Slot A
pending size/向量非法 -> 保存 CONFIRMED 并回滚
Flash CRC 不匹配 -> 保存 CONFIRMED 并回滚
count >= 3 -> 保存 CONFIRMED 并回滚
否则 -> 保存 count+1,再启动 pending
这里要区分两类校验范围:普通复位进入上述 PENDING 分支时,槽位关系合法后只对 pending 镜像 做 size、向量和 CRC 校验;MARK_PENDING 与 CONFIRM 命令则会额外检查 pending 与 confirmed 两个槽位的向量,确保试运行目标和回滚候选都具备基本可启动条件。
升级命令本身的 MARK_PENDING + BOOT 是直接跳转,不经过上述 count+1 路径。
13.6 Flash 边界保护
Bootloader 现在有两层边界:
- 协议层 ERASE 只允许从 A/B 槽首地址开始,DATA/VERIFY 的完整区间必须在单一槽中;
- 底层 Flash 接口只放行单一 Slot A、Slot B 或 Metadata 分区,禁止跨区、Bootloader 和 Flash 外地址。
这能阻止错误包直接擦写 Bootloader 或跨槽,但不能判断目标是不是"当前活动槽"。后者仍由操作者负责。
14. 命令行工具用法
查看帮助:
powershell
cd tools
py ota.py --help
A→B:
powershell
py ota.py ..\app\app\build\Debug\app_b.bin `
--port COM5 `
--target 0x08040000 `
--confirmed 0x08008000
B→A:
powershell
py ota.py ..\app\app\build\Debug\app_a.bin `
--port COM5 `
--target 0x08008000 `
--confirmed 0x08040000
命令运行后,在 10 秒内复位 MCU。CLI 会:
- 强制当前实现的 115200;
- 验证两个槽地址不同;
- 验证 BIN 长度、MSP、Thumb 位和 Reset_Handler;
- 查询 INFO 并校验设备布局;
- 检查每个响应的 CMD 和 SEQ;
- 完成 PENDING + BOOT。
CLI 当前只负责升级,不单独提供 confirm 子命令。确认请使用 GUI,或根据协议自行发送 CMD_CONFIRM。
15. 常见故障排查
| 现象 | 最可能原因 | 排查/解决 |
|---|---|---|
| 没有 banner、LED 也不运行 | Mass Erase 擦掉 Bootloader | 用 ST-Link 重烧 bootloader.hex 和 app_a.hex |
| GUI 找不到 COM 口 | 驱动、线材或端口占用 | 设备管理器确认端口;关闭串口助手;点击刷新 |
| 无法进入 OTA | TX/RX 未交叉、未共地、波特率错误、复位时机不对 | PA9→RX、PA10←TX、115200;先点连接再按 RESET |
| 偶尔进不去 | 第一个接收字节是噪声/旧数据 | 关闭其他串口软件,清输入缓存,缩短接线,重新复位 |
| ERASE 返回失败 | 地址不是槽首、长度为 0/越界 | 只选 GUI 的 A/B 槽;确认 INFO 布局一致 |
| DATA 最后一包失败 | BIN 长度是奇数 | 修正链接/objcopy 产物;主机现在会提前拒绝 |
| "固件与槽位不匹配" | 把 app_a.bin 选给 B,或反之 |
A 用 app_a.bin,B 用 app_b.bin |
| MSP 无效 | 镜像不是 Cortex-M App、芯片/链接脚本不对或文件损坏 | 检查 BIN 前 8 字节和链接 map |
| VERIFY CRC 不一致 | 传输/Flash 写入错误,或两端 CRC 范围不一致 | CRC 范围必须是固件实际 size;重试并检查供电 |
| 升级后直接 CONFIRM 超时 | MCU 已在 App,不在 Bootloader | 先验收,再点击确认并按 RESET 重新进入 OTA |
| CONFIRM status=1 | Metadata 不是 PENDING,或 Flash/向量/CRC 已变化 | 重新执行完整升级,不要在 PENDING 后手工改槽内容 |
新 App 运行后 HAL_Delay 卡死 |
App 未恢复全局中断 | VTOR + DSB/ISB 后调用 __enable_irq() |
| 三次复位后还在新固件 | 直接 BOOT 不计数,第三次只把计数变 3 | 第四次普通复位才回滚 |
| App 卡死却不自动回滚 | 未启用 watchdog/自动复位 | 人工复位;量产版增加健康检查和 IWDG |
| OLED 无 ACK/花屏 | SDA 推挽、无上拉或接线错误 | SCL/SDA 开漏,确认模块/板上上拉及地址 |
| ZET6 板引脚不对 | 教程按 RET6/LQFP64 | 在 CubeMX 按 ZET6/LQFP144 重新映射并核对原理图 |
16. 当前边界与量产化路线
16.1 当前已经做到
- A/B 两个独立链接镜像;
- 自定义串口帧、CRC16、整包 CRC32;
- 单槽边界擦写保护;
- PENDING、CONFIRMED 和跨复位尝试计数;
- 主机侧镜像向量检查;
- 人工重新进入 Bootloader 后确认;
- 不确认时第四次普通复位回滚。
16.2 当前没有做到
- 固件数字签名、认证、加密;
- anti-rollback 版本策略;
- 数据包重试、窗口、去重、断点续传;
- App 自确认 API;
- IWDG/WWDG 健康闭环;
- Metadata CRC、双副本、事务提交和掉电原子性;
- 自动识别当前活动槽;
- 完整 NVIC/控制寄存器清理与汇编尾跳;
- 网络下载、TLS、设备身份和密钥轮换。
16.3 推荐量产演进顺序
- 为 Metadata 增加双页副本、序列号、结构 CRC 和提交标志;
- 引入固件头:magic、硬件型号、版本、长度、hash、签名;
- 在 Bootloader 中做公钥签名验证和最低版本策略;
- 增加可重试、可去重、可续传协议;
- 用 IWDG + App 健康检查形成自动回滚闭环;
- 增加 App 请求重启进 Bootloader 的安全通道;
- 做断电注入、Flash 故障、模糊帧、重复包和边界测试。
可借鉴 MCUboot/wolfBoot 的设计思想,但不要在未移植代码时声称"项目已经使用 MCUboot/wolfBoot"。
17. 代码导览与参考资料
17.1 从哪几个函数开始读
| 主题 | 文件 / 函数 |
|---|---|
| 分区常量 | bootloader/Core/Inc/boot_config.h |
| 复位后的分流 | bootloader/Core/Src/main.c:main()、boot_run() |
U 入口 |
bootloader/Core/Src/boot_uart.c:boot_wait_for_ota() |
| 帧收发 | bootloader/Core/Src/boot_protocol.c:boot_receive_frame()、boot_send_frame() |
| 命令语义 | 同文件 handle_erase/data/verify/mark_pending/boot/confirm/switch |
| Flash 驱动 | bootloader/Core/Src/boot_flash.c |
| App 校验/跳转 | bootloader/Core/Src/boot_jump.c |
| Metadata | bootloader/Core/Src/boot_metadata.c、boot_metadata.h |
| CRC | bootloader/Core/Src/boot_crc.c |
| 双链接 | app/app/CMakeLists.txt 与两份 linker script |
| Python 协议 | tools/ota.py |
| Qt 工作流 | tools/ota_gui.py |
17.2 官方资料
- STM32F103xC/D/E Datasheet:容量、引脚、电气参数;
- RM0008:Flash、RCC、USART 等寄存器;
- PM0056:STM32F10x Flash 擦写;
- AN2557:ST 的 USART IAP 思路;
- UM1850:STM32F1 HAL;
- AN2606:ROM/System Memory Boot 模式;
- AN3155:ST ROM Bootloader USART 协议;
- Arm Cortex-M3 Devices Generic User Guide;
- CMSIS Core documentation。
17.3 开源项目与工具
- STM32CubeF1 v1.8.7 IAP example
- ferenc-nemeth/stm32-bootloader
- MCUboot design
- wolfBoot
- PySerial
- Qt for Python
这些资料用于学习通用方法。它们的协议、镜像格式、安全模型和本仓库不同,不能直接混称。
实板验收记录模板
完成实验后建议保存:
text
芯片丝印:
开发板/原理图版本:
Bootloader commit/日期:
COM 号:
ST-Link/CubeProgrammer 版本:
app_a.bin 大小与 CRC32:
app_b.bin 大小与 CRC32:
A→B 日志:
CONFIRM 日志:
B→A 日志:
未确认回滚四次复位现象:
LED/OLED 实际行为:
异常与处理:
本地静态检查和构建可以证明代码、地址与产物一致;真正的串口时序、供电稳定性和外设现象仍需要在你的实板上完成这一份记录。