STM32F103RCT6 自定义 UART Bootloader 完整实战教程
从零创建工程、划分 Flash、修改 Com APP,到使用 Python 成功升级 74,016 字节固件
工程实录 / Windows 10 + STM32CubeIDE + STM32CubeProgrammer + VSCode + CH340C
记录日期:2026-11-01;适用型号:STM32F103RCT6。
适用范围:当前这套"PC USB → CH340C → F103 USART2 → Flash"的实测方案;不是 F401 中转升级方案,也不是 STM32 出厂 ROM Bootloader 的教程。
| 总 Flash | Bootloader 预留 | Com APP 可用 | 已实测文件 |
|---|---|---|---|
| 256 KiB | 32 KiB | 224 KiB | 72.28 KiB |
本文给出了:从初学者出发的原理解释、CubeIDE 逐项设置、链接脚本修改、向量表设置、Bootloader 关键 C 代码、串口协议、完整的实测 Python 脚本、串口助手试验、Flash 回读、故障排查、上线前风险检查。
验证范围先讲清楚 :用户实际升级日志已证明 V/B1 → S/K → 290 个 D/K → F/K;Com.bin 74,016 字节,CRC32 为 9F551106。这不等于已经证明应用的 CANopen 功能正常,也不等于已经独立完成本轮升级后的全量 ST-LINK 回读比对。 后两项在文中作为独立验收步骤。
阅读路线(适合第一次做 Bootloader)
| 阶段 | 要做什么 | 完成标志 |
|---|---|---|
| 认识原理 | 理解 Bootloader / APP / bin / Flash | 能画出两个程序所在地址 |
| 配置底层 | CubeIDE、引脚、时钟、串口 | Bootloader 编译通过 |
| 改 APP | 链接地址、向量表、bin 输出 | Com.bin 前 8 字节合法 |
| 烧录与单项验证 | ST-LINK、串口助手 V/S/D |
得到 B1,APP 能擦写 |
| 正式升级 | 虚拟环境、pyserial、Python 脚本 | F 返回 K |
| 严格验收 | 回读比对、跳转、APP 功能 | 逐字节相同且 APP 运行正常 |
01 为什么需要自定义 Bootloader?
常规 STM32 程序每次通过 ST-LINK 下载,适合开发阶段。设备装进机箱后,现场可能不方便接 SWD。Bootloader(引导程序)是一个先于业务应用运行的小程序:它等待上位机升级请求,负责接收固件、擦除指定 Flash、写入新固件、校验、再启动业务程序。
本方案把 F103 中的程序分为两部分:
- Bootloader:负责 USART2 收命令、接收升级文件、擦除 APP、分包写 Flash、CRC32 检查、跳转。
- Com APP:原有业务程序,例如 CANopen。本文不修改其业务逻辑,只改变它放在 Flash 的位置以及中断向量位置。
重要区分:STM32 芯片还有厂商提供的 System Memory ROM Bootloader ,那是另一个东西。本项目是用户自己编写的、存放在 0x08000000 的 Bootloader。PC 端跑的 upgrade_com.py 也是为本项目自定义通信协议编写的,不能直接拿 ST 官方串口下载工具替代。
一次正常上电
text
F103 上电/复位
|
v
执行 Flash 0x08000000 的 Bootloader
|
+-- 收到 V --> 返回 B1
+-- 收到 S --> 擦除 APP,进入升级状态
| |
| +-- 多个 D --> 写入数据
| +-- F --> 验证 CRC,最后写向量表
+-- 收到 J --> 检查有效后,跳转 APP
|
+-- 未升级且 APP 有效,3 秒窗口结束 --> 自动跳转 APP
Com.bin 不是"自动运行的文件"。它只是连续的机器码字节流;只有 链接地址正确、写入位置正确、启动向量正确,CPU 才能启动它。
02 本项目硬件与软件(不要把它当通用配置硬套)
| 项目 | 本次实测设备 / 配置 |
|---|---|
| MCU | STM32F103RCT6,Arm Cortex-M3,256 KiB Flash,48 KiB SRAM |
| PC 到 MCU | PC USB → 板载 CH340C USB-UART → F103 USART2 |
| USART2 | PA2 = TX,PA3 = RX;115200 bps,8 数据位,无校验,1 停止位(8N1) |
| 电平 | 板载 CH340C 的 UART 侧为 3.3 V 电平;以原理图实际供电和连线为准 |
| 板上串联电阻 | R75 / R76 各约 33 Ω;按照自己板子的原理图核对 |
| 烧录/调试 | ST-LINK/V2,SWDIO、SWCLK、GND(及正确的目标供电/参考电压) |
| 时钟 | 本项目使用 HSE 8 MHz,PLL ×9,SYSCLK 72 MHz;APB1 36 MHz |
| 软件 | STM32CubeIDE、STM32CubeProgrammer、VSCode、Python 3.15(uv 管理)、pyserial |
| 本次可用串口 | COM7;这是当时 Windows 分配的值,其他电脑/USB 插口可能改变 |
接线的实际方向
text
PC USB --- CH340C --- USART2 --- STM32F103RCT6
TXD ------------> PA3 / USART2_RX
RXD <------------ PA2 / USART2_TX
GND ------------- GND
如果自己外接 USB-UART,务必交叉 TX/RX,不要用 RS-232 的 ± 电压直接接 MCU。如果板载 CH340C 已焊接,使用其 Type-C 接口即可,不要重复接第二个串口转换器。
关于 BOOT0 :一般正常从用户 Flash 启动时,需确认启动配置选择主 Flash;该项目依赖 MCU 先从 0x08000000 运行自定义 Bootloader。实际 BOOT 管脚组合以板子的硬件设计为准。
03 Flash / RAM 内存怎么分配?
这一步决定整个项目能否正常跳转,是最容易出错的地方。
3.1 先算清楚总容量
STM32F103RCT6 的主 Flash 从 0x08000000 开始,共 256 KiB,也就是 0x40000 字节:
text
Flash 起点 Flash 末尾(不含)
0x08000000 ------------------------ 0x08040000
|<- Bootloader 0x8000 ->|<--- APP 0x38000 --->|
0x08000000 0x08008000 0x08040000
| 32 KiB | 224 KiB |
| 16 页 (2KiB/页) | 112 页 |
| 区域 | 起始地址(包含) | 末尾地址(包含) | 长度(字节 / KiB) |
|---|---|---|---|
| Bootloader | 0x08000000 |
0x08007FFF |
0x8000 = 32768 = 32 KiB |
| Com APP | 0x08008000 |
0x0803FFFF |
0x38000 = 229376 = 224 KiB |
| 全 Flash | 0x08000000 |
0x0803FFFF |
0x40000 = 262144 = 256 KiB |
| SRAM | 0x20000000 |
0x2000BFFF |
0xC000 = 49152 = 48 KiB |
FLASH_LIMIT = 0x08040000 是末尾之后的第一个地址,不是能够写入的地址。把它设成"不包含上界"可以更方便做地址范围检查。
特别提醒 :
32 KiB只是给 Bootloader 预留的区域 ,不意味着它本身占满 32 KiB。以前备份的某版Bootloader.bin约 4.4 KiB;预留 32 KiB 是为了后续扩展并让 APP 起始地址固定。
3.2 为何 APP 必须从 0x08008000 开始?
因为 0x08000000 ~ 0x08007FFF 已经留给 Bootloader。Com 必须链接到后面的区域。不能仅在烧录时把原来链接到 0x08000000 的 bin 偏移下载到 0x08008000;那样程序内部中断向量和跳转地址仍可能指向错误位置。
APP 原本可能是:
text
原 Com 链接地址:0x08000000
改后 Com 链接地址:0x08008000
这需要修改 APP 项目的链接脚本 .ld ,并处理 向量表 SCB->VTOR,两者都不能少。
3.3 RAM 要不要也拆成两份?
本项目不需要。Bootloader 和 Com 不是并行运行;跳转后 Com 会使用自己的初始栈及 RAM 变量。所以两个项目都可以配置为:
ld
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 48K
本次 Com.bin 的前四字节对应 0x2000C000,恰好是 48 KiB SRAM 的栈顶地址,这是正常的。栈顶是末尾后的地址,不是 RAM 内要存程序的最后一个字节。
3.4 Flash 页大小为什么是 2048?
本型号属于 STM32F103 高密度系列,主 Flash 按 2 KiB 一页擦除,因此:
c
#define PAGE_BYTES 2048UL
#define APP_CAPACITY (0x08040000UL - 0x08008000UL)
// 229376 / 2048 = 112 页
切记:不能把别的 F103 型号的 1 KiB 页设置直接套过来。 F103 不同容量密度的页大小不同,量产前按芯片完整型号和数据手册再次确认。
04 先创建 Bootloader 工程:STM32CubeIDE 从零设置
4.1 新建工程
- 打开 STM32CubeIDE,选择
File → New → STM32 Project。 - 在 MCU/MPU Selector 中搜索 STM32F103RCT6,不要误选 F103C8T6、F103RB 等不同容量芯片。
- 创建工程,例如
F103_Bootloader;选择 STM32Cube HAL 方案,保存工程。 - 打开工程
.ioc配置界面。CubeIDE 界面细节可能随版本不同,但下列功能名称相同或相近。
4.2 设置调试引脚
在 .ioc 中找到 System Core → SYS:
Debug选 Serial Wire,给 ST-LINK 保留 SWDIO / SWCLK。- 如果硬件有外部 8 MHz 晶振,在
System Core → RCC中将 High Speed Clock (HSE) 设为与硬件一致的晶振模式;如果硬件不是晶振而是输入时钟,不能直接照抄 Crystal 选项。
4.3 设置 USART2
在 Connectivity → USART2 中:
| 参数 | 配置 |
|---|---|
| Mode | Asynchronous |
| Baud Rate | 115200 |
| Word Length | 8 Bits |
| Parity | None |
| Stop Bits | 1 |
| Hardware Flow Control | None |
| TX / RX | 默认映射 PA2 / PA3,核对 Pinout 无冲突 |
本项目使用轮询式 HAL_UART_Receive(),不依赖 UART DMA、中断接收,也不需要把 USB 外设设成 CDC。PC 侧虽然插 Type-C,MCU 实际收到的仍是 USART2 数据。
4.4 配置时钟
根据本项目的外部晶振配置:
text
HSE = 8 MHz
PLL source = HSE
PLL multiplier = ×9
SYSCLK = 72 MHz
HCLK = 72 MHz
APB1 = 36 MHz(预分频 /2)
CubeIDE 中若时钟树标红,先按实际硬件修正。F103 的 APB1 不能直接照搬成 72 MHz。完成 Pinout 与 Clock Configuration 后生成代码。
4.5 修改 Bootloader 链接脚本
在 Bootloader 工程根目录找到生成的 STM32F103RCTX_FLASH.ld 或类似命名文件(实际以工程文件为准),定位 MEMORY:
ld
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 48K
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 32K
}
只修改对应 MEMORY 容量/起点,不要不加区分地删除原链接脚本的 .isr_vector、.text、.data 等段定义。检查构建输出没有 region FLASH overflowed。
4.6 新增 boot_upgrade.h 与 boot_upgrade.c
工程中添加:
text
F103_Bootloader/
Core/
Inc/
main.h
boot_upgrade.h <- 新增
Src/
main.c
boot_upgrade.c <- 新增
STM32F103RCTX_FLASH.ld
boot_upgrade.h 内容:
c
#ifndef BOOT_UPGRADE_H
#define BOOT_UPGRADE_H
#include "main.h"
void Boot_Run(UART_HandleTypeDef *huart);
#endif
boot_upgrade.c 的关键函数在第 07~10 章讲解,完整参考整理版 另附源文件。来源说明:该参考 C 文件来自项目现有协议代码,并加入了边界检查与 30 秒测试超时;新增的防护修改没有重新在你的板子上单独烧录验证,不能说它与原实测二进制完全一致。
4.7 main.c 中在哪里调用?
只把下面的代码放进 CubeIDE 的 USER CODE 区域,避免后续重新生成 .ioc 时被覆盖。
c
/* USER CODE BEGIN Includes */
#include "boot_upgrade.h"
/* USER CODE END Includes */
找到 main() 初始化的位置,确保 USART2 初始化后再调用:
c
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART2_UART_Init();
/* USER CODE BEGIN 2 */
Boot_Run(&huart2); // 常驻循环;正常情况下不会返回
/* USER CODE END 2 */
Bootloader 工程的 .ld 仍是 0x08000000 。注意不要把 Bootloader 和 Com 的 .ld 改反。
05 修改 Com APP 工程:链接地址 + 中断向量表
这是整个项目最关键的一章。 有人升级数据显示全部成功,但程序不运行,常见原因就在这里。
5.1 先备份现有 Com 工程
建议另存一份工程,例如 Com_before_bootloader,确保原始工程可以随时恢复。只改和 Bootloader 兼容所需的启动地址,不要在第一次联调时同时重构 CANopen 功能代码。
5.2 修改 Com 的链接脚本 .ld
在 Com 工程 的 .ld 找 MEMORY,改为:
ld
MEMORY
{
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 48K
FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 224K
}
这个设置使 Com 的 .isr_vector 起始于 0x08008000,后面的 .text/.rodata 也按新的 Flash 地址链接。APP 不得超过 224 KiB,否则编译链接时应报空间不足。
5.3 修改 Com 的向量表偏移:新版 CubeF1 写法
进入 Com 工程 的 Core/Src/system_stm32f1xx.c,搜索:
c
/* #define USER_VECT_TAB_ADDRESS */
在使用 ST 新版 CMSIS 模板时,取消注释,并设置:
c
#define USER_VECT_TAB_ADDRESS
#define VECT_TAB_OFFSET 0x00008000U
新版模板的 SystemInit() 内通常有:
c
#if defined(USER_VECT_TAB_ADDRESS)
SCB->VTOR = VECT_TAB_BASE_ADDRESS | VECT_TAB_OFFSET;
#endif
前提是 VECT_TAB_BASE_ADDRESS 为 FLASH_BASE。最终效果必须是:
c
SCB->VTOR == 0x08008000U
不同 STM32CubeF1 版本可能不同 :有的旧模板没有 USER_VECT_TAB_ADDRESS 这个开关,而是直接使用 VECT_TAB_OFFSET。因此要检查实际文件的 SystemInit() 源码,不能只复制宏定义却不确认它被使用。
另一个等效验证办法是在 APP 很早的启动阶段、使能中断前 设置 SCB->VTOR = 0x08008000U;但不要不分析就同时加入多处相互冲突的重定位代码。最可靠的是让 SystemInit() 使用正确偏移,并在调试时读取 VTOR 寄存器确认。
5.4 为什么 Bootloader 设置一次 VTOR 还不够?
跳转函数 jump_app() 会先设置 SCB->VTOR = APP_BASE,但是 Com 的启动过程可能再次执行 SystemInit() 并覆盖向量表设置。所以 Com 项目自己也必须知道它的向量位置。这也是"主循环能跑,但定时器 / CAN / UART 中断异常"的重要排查点。
5.5 打开 .bin 文件输出
在 Com 项目 右键 Properties → C/C++ Build → Settings → Tool Settings → MCU Post build outputs,勾选:
text
Convert to binary file (-O binary)
重新 Build 后,通常在工程的 Debug 或 Release 输出目录生成 Com.bin(具体名称以工程名和构建配置为准)。
.elf:含段地址、调试符号,适合 ST-LINK 调试。.bin:纯字节流,无嵌入的烧录起始地址,正是这个 Python 脚本需要的文件。.hex:通常含地址信息,但本文协议不直接接收 Intel HEX。
5.6 检查生成的 Com.bin 前 8 字节
本次实际文件的开头是:
text
00 C0 00 20 B5 0B 01 08
| 4字节SP | |4字节Reset|
按小端序解释:
text
SP = 0x2000C000 (RAM 栈顶,合法)
Reset = 0x08010BB5 (位 0 为 1,Thumb 入口)
注意 Reset 的最低位 1 是 Cortex-M Thumb 状态标志,它代表实际指令地址 0x08010BB4;不是地址写错。
如果文件开头是 FF FF FF FF ...,或者复位入口仍然指向旧 APP 布局,先解决工程链接和 bin 生成,再尝试串口升级。
06 Bootloader 的"自定义参数"集中在哪里?
boot_upgrade.c 顶部定义了一组宏,理解它们比一开始死记代码更重要。
c
#define APP_BASE 0x08008000UL
#define FLASH_LIMIT 0x08040000UL
#define APP_CAPACITY (FLASH_LIMIT - APP_BASE)
#define SRAM_BASE 0x20000000UL
#define SRAM_LIMIT 0x2000C000UL
#define PAGE_BYTES 2048UL
#define PACKET_BYTES 256U
#define UART_TIMEOUT 30000U
| 宏 | 含义 | 什么时候改 |
|---|---|---|
APP_BASE |
APP 第一字节的地址 | 修改分区方案时,与 APP .ld 同步 |
FLASH_LIMIT |
Flash 末尾的下一地址 | MCU Flash 容量变化时 |
APP_CAPACITY |
APP 最大文件长度 | 自动计算,勿凭感觉填写 |
SRAM_BASE/LIMIT |
判断启动向量的栈指针是否落在有效 RAM | MCU RAM 容量变化时 |
PAGE_BYTES |
页擦除粒度 | 按 MCU 型号查数据手册 |
PACKET_BYTES |
每个 D 包的数据上限 256 字节 | PC / MCU 协议必须一致 |
UART_TIMEOUT |
S/D 接收指定字节数的超时,单位 ms | 实测用 30000(30 秒) |
三个不同"时间"不要混淆:
UART_TIMEOUT = 30000U:接收 S 元数据、D 长度或 D 数据时的单次等待上限,30 秒。timeout = 100U:主循环轮询"下一个命令字节"的单次等待上限,100 ms。HAL_GetTick() - start >= 3000U:Bootloader 初次上电后的 APP 自动跳转窗口,3 秒。
S 已回 K 到下一次 D 之间没有固定的 30 秒时限 ;在 MCU 不复位、升级状态仍有效的前提下可以稍后发送。UART_TIMEOUT 只在对应 HAL_UART_Receive() 被调用时计时。
07 自定义 UART 通信协议(PC 和 STM32 必须完全一致)
协议不是 HAL 库自带的,由本项目的 Boot_Run 自己解析。
| 命令 | 首字节 HEX | 后续数据 | 正常回复 |
|---|---|---|---|
V 版本查询 |
56 |
无 | 42 31(ASCII B1) |
S 开始升级 |
53 |
size 4 字节 + crc32 4 字节 |
4B(K) |
D 数据包 |
44 |
len 2 字节 + len 字节固件数据 |
4B(K) |
F 完成升级 |
46 |
无 | 4B(K) |
J 运行 APP |
4A |
无 | 4B 后尝试跳转 |
失败一般返回 45(ASCII E)。V 返回两字节 B1,而其他成功回复一般是单字节 K,不能把 B1 按一个字节读取。
7.1 S 的 8 字节元数据
S 的包结构:
text
+------+-----------------------+-----------------------+
| 'S' | firmware size (4B LE) | firmware CRC32 (4B LE)|
+------+-----------------------+-----------------------+
0x53 小端序 小端序
本次实测 Com.bin 长度 74016 = 0x00012120;CRC32 0x9F551106。小端编码为:
text
size = 20 21 01 00
CRC32 = 06 11 55 9F
所以针对这一次确切的 Com.bin ,完整 S 数据为:
text
53 20 21 01 00 06 11 55 9F
这 9 字节会触发 APP 整区擦除,发之前必须有备份。不允许拿这个固定 CRC 头去升级后来重新编译的其他文件,文件内容变了,CRC 就可能变。
7.2 D 的数据包结构
text
+------+------------------+--------------------------+
| 'D' | len (2B little) | 固件原始 data(1~256B) |
+------+------------------+--------------------------+
0x44
发送一包,等到 K,再发下一包。每包都包含 D 和自己的 len,不能把整个 Com.bin 当成裸数据一次"文件发送"。
本次 74016 字节按 256 字节分包为:
text
289 包 × 256 字节 = 73984 字节
最后 1 包 × 32 字节 = 32 字节
总计 290 包
STM32F1 的 Flash 按 16 位半字 写,所以本项目要求每个非最后数据包的长度为偶数;最后一包可以有 1 字节尾数,由代码补 0xFF 对齐。最省事就是由脚本固定每包最多 256 字节。
7.3 CRC32 的算法必须完全一致
STM32 代码使用:
c
crc = 0xFFFFFFFFUL; // 初始值
// 每个字节参与 CRC,反射多项式 0xEDB88320
final_crc = crc ^ 0xFFFFFFFFUL;
Python 使用:
python
crc = zlib.crc32(firmware) & 0xffffffff
两者针对完整的 Com.bin 所有字节 算出的结果一致。这里使用的是常见的反射 CRC-32(IEEE/ZIP),并非直接调用 STM32 外设 CRC 单元默认算法,因为默认多项式/输入处理约定可能不同。
08 Bootloader 关键 C 代码:怎么擦除?怎么写入?
8.1 升级函数顺序
text
Boot_Run()
|
+-- 'S' -> begin_upgrade() -> erase_app()
|
+-- 'D' -> receive_packet() -> program_halfwords()
|
+-- 'F' -> finish_upgrade() -> crc_step()
|
+-- 'J' -> jump_app()
8.2 真正擦除 APP 的代码
c
static int erase_app(void)
{
FLASH_EraseInitTypeDef cfg = {0};
uint32_t page_error = 0;
cfg.TypeErase = FLASH_TYPEERASE_PAGES;
cfg.PageAddress = APP_BASE;
cfg.NbPages = APP_CAPACITY / PAGE_BYTES; // 112 pages
return HAL_FLASHEx_Erase(&cfg, &page_error) == HAL_OK;
}
它擦的是从 APP_BASE 开始的 112 页 = 224 KiB ,不是只擦除本次文件的 72 KiB。调用前必须 HAL_FLASH_Unlock(),结束后 HAL_FLASH_Lock()。S 回复 K 是"固件报告本次擦除函数成功",如要独立证明某个地址真正变成 FF,用 STM32CubeProgrammer 读取 Flash。
为什么不能 Full Chip Erase? 因为整片擦除会把 0x08000000 的 Bootloader 一并删掉;设备接下来就无法靠这套 Bootloader 做远程升级,必须借助 ST-LINK 等恢复。
8.3 把 RAM 收到的字节写进 Flash
STM32F103 Flash 半字编程:每次写 uint16_t(2 字节),地址需要 2 字节对齐。示意核心:
c
uint16_t half = src[i];
half |= (uint16_t)((i + 1U < n ? src[i+1] : 0xFFU) << 8);
if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, address+i, half) != HAL_OK)
return 0;
if (*(volatile uint16_t *)(address+i) != half)
return 0;
建议的边界保护(当前最终粘贴代码的地址判断不够完整;下面是建议修正,须重新编译/上板确认):
c
if ((address & 1U) != 0U ||
address < APP_BASE ||
address >= FLASH_LIMIT ||
n == 0U ||
n > FLASH_LIMIT - address ||
((n & 1U) != 0U && n == FLASH_LIMIT - address)) {
return 0;
}
为什么要先检查 address < APP_BASE / address >= FLASH_LIMIT?因为 FLASH_LIMIT - address 是无符号减法 ,如果地址已越界,减法可能下溢而绕回很大的数,错误数据可能侥幸通过仅有的长度检查。千万不要在 APP 下载功能里省略地址范围保护。
8.4 为什么前 8 字节最后写?
Com.bin 的前 8 字节是:
- 初始栈指针 MSP(4 字节)。
- Reset_Handler 复位入口(4 字节)。
如果先写它们、后来升级断电,下一次启动时 Bootloader 可能认为 APP"看起来有效",但其他指令还没写完。当前方案先将前 8 字节暂存:
c
static uint8_t vector_bytes[8];
uint32_t skip = received < 8U ? 8U - received : 0U;
if (skip > n) skip = n;
if (skip) memcpy(vector_bytes + received, packet, skip);
最后通过 F 完成所有检查,才把 vector_bytes 写到 APP_BASE。这能减少"半升级 APP 被误启动"的风险,但不能完全代替持久化的升级有效标志和容错机制。
8.5 F 命令检查了什么?
text
F 到来
-> updating == 1 ?
-> received == file_size ?
-> 接收流 CRC32 == expected_crc ?
-> 栈指针/Reset 向量合理 ?
-> 拼接"前 8 字节+Flash 中其他数据"计算 CRC 正确 ?
-> 最后写入前 8 字节启动向量
-> 从 Flash 整体回读计算 CRC32 正确 ?
-> 返回 K,否则 E
这也是为什么只做完 D 不能称作"升级成功"。D 的 K 只表示这一包 通过,只有 F 返回 K 才表示 Bootloader 确认整个文件大小和 CRC 校验成功。
09 Bootloader 如何跳到 Com?上电 3 秒又是什么?
9.1 查启动向量是否有效
c
static int app_valid(void)
{
uint8_t v[8];
memcpy(v, (const void *)APP_BASE, sizeof(v));
return valid_vectors(v);
}
valid_vectors() 主要检查栈指针落在 SRAM 范围且 8 字节对齐、Reset 入口位于 APP Flash 区并设置 Thumb 位。这个检查不等于整个 APP 的 CRC 检查。 它只能判断"入口看起来合理"。
9.2 这份最新源码恢复了 3 秒自动跳转
c
uint32_t start = HAL_GetTick();
while (1) {
uint8_t cmd;
uint32_t timeout = 100U;
if (rx(&cmd, 1, timeout) != HAL_OK) {
if (!updating && app_valid() &&
HAL_GetTick() - start >= 3000U)
jump_app();
continue;
}
// V/S/D/F/J 命令处理...
}
不是每次接到命令后重新数 3 秒 ,而是从进入 Boot_Run() 时记录的 start 开始计时。只有主循环本轮 rx() 没收到命令、且 updating==0、且 APP 向量有效,才会自动跳转。
有个容易被忽略的实际后果:如果整次升级已花费超过 3 秒,F 成功将 updating 清零后,下一次 100ms 等待命令超时,就可能立刻自动跳到 Com 。因此 Python 显示"暂不发送 J",只是说 Python 没主动发 J,不能据此推断 MCU 一定继续留在 Bootloader。
9.3 jump_app() 的主要动作
text
读取 APP_BASE 的 MSP / Reset
停用 Bootloader 侧 UART 与 HAL、恢复时钟状态
关中断、停 SysTick、清 NVIC 使能和挂起位
SCB->VTOR = APP_BASE
设置 MSP = APP 新栈顶
跳转到 APP Reset_Handler(Thumb 地址)
下面是本方案的核心跳转语句(完整上下文在附带参考 C 文件):
c
SCB->VTOR = APP_BASE;
__DSB();
__ISB();
__asm volatile (
"msr msp, %0\n"
"cpsie i\n"
"bx %1\n"
: : "r" (sp), "r" (reset) : "memory");
这不是调用普通 C 函数。跳转后,主程序会从自己编译产生的 Reset_Handler 开始执行,配置自己的 .data/.bss、时钟和外设。若 APP 启动仍异常,先确认链接地址、向量表、时钟初始化和中断配置,而不是重新猜测 UART 升级没成功。
9.4 我该用 3000、1000 还是 500ms?
| 等待窗口 | 优点 | 风险/建议 |
|---|---|---|
| 3000 ms | 开发调试方便、Python 有充分时间握手 | 设备上电后 APP 运行慢,当前验证阶段适合 |
| 1000 ms | 平衡 | 仍须测试 PC 端能否及时进入升级 |
| 500 ms | 业务启动快 | PC 开串口/USB 枚举可能赶不上,不建议仅靠时间窗口做升级 |
对于你的工业 CANopen 从站,后续可采用 APP 接到升级请求 → 写升级标志/握手 → 软复位进入 Bootloader 的专用模式。这个机制目前还没写进此次实测代码,不要把它当作已实现功能。
10 第一次烧录与串口助手单项测试
10.1 先把 Bootloader 烧到 0x08000000
- 用 ST-LINK 接 F103 的 SWD 接口,打开 STM32CubeProgrammer。
- 建立连接,确认识别出正确的 STM32F103 芯片。
- 首次部署时将 Bootloader 的
.elf/.hex或.bin写到0x08000000(使用.bin要明确输入下载地址)。 - 后续更新 Com 不能再使用整片擦除 ,尤其不要随手勾选
Full Chip Erase。 - 烧录 Bootloader 后复位,进入串口助手测试 USART2。
首次部署允许在完全确认备份和恢复方式后重新规划整个 Flash;但一旦 Bootloader 有用了,日常 Com 升级必须保护 0x08000000 ~ 0x08007FFF。
10.2 V:验证 Bootloader 和串口
串口助手配置 115200、8N1、HEX 发送,发送:
text
56
应收到:
text
42 31 // ASCII B1
当时先选 COM12 得到异常响应,后来在 COM7 成功。串口号应以当前设备管理器为准,不要凭记忆固定。
10.3 S:测试擦除(破坏性操作)
只有准备好备份和恢复条件才执行。 测试用假元数据:
text
53 00 00 01 00 00 00 00 00
意思是:S + 假设文件 65536 字节 + 测试 CRC = 0。它会触发 APP 擦除,成功时返回 4B。这个头不是用于正式 Com.bin 的,后面的 F 无法用少量测试字节通过。
10.4 D:测试半字写入
在S 已成功返回 K,且 MCU 未复位的前提下,串口助手一次性 HEX 发送:
text
44 0A 00 00 C0 00 20 01 81 00 08 11 22
这里:44 就是 ASCII D,不用另外再发字母 D 。0A 00 表示后续 10 字节;前 8 字节作为测试向量暂存,11 22 被写入 0x08008008。用户实际收到 K,并通过 STM32CubeProgrammer 看到了:
text
0x08008000: FFFFFFFF
0x08008004: FFFFFFFF
0x08008008: FFFF2211
这是 32 位显示格式与小端字节顺序共同形成的结果,并非把 11 22 写反了。此时不能发送 F 期望通过,因为从 65536 字节中只发送了 10 字节。

实测截图:0x08008008 为 FFFF2211;这是手工测试图,不是本轮正式 Com.bin 升级后的完整回读证据。
10.5 为什么串口助手一发 S 或 D 就收到 E?
S 后 MCU 要接收 8 字节元数据 。只发送单个 53,超时后就会 E。D 也是一样,必须提供 2 字节长度 + 对应数量的数据。手工方式最好一次点击发送完整帧,而不是先发一个字符再慢慢打剩余字节。
不要在 S 得到 K 与 D 之间通过 ST-LINK 让 MCU 复位 。升级状态 updating 保存在 RAM,复位后就丢了。观察 Flash 可以等完整升级结束后再做。
11 Windows + VSCode + Python 环境配置(实测遇到的问题)
11.1 为什么 py 和 python 都不能直接用?
本次 Windows 上的 py -m pip install pyserial 报错:
text
py : 无法将"py"项识别为 ...
python --version 也报了"系统无法访问此文件"。这并不能推出 Python 根本没安装。VSCode 实际检测到由 uv 管理的 CPython 3.15.0,路径形如:
text
%APPDATA%\uv\python\cpython-3.15.0-windows-x86_64-none\python.exe

实测截图:VSCode 中可选择 uv 安装的 Python 3.15.0。
尝试直接全局 pip install pyserial 后又遇到了:
text
error: externally-managed-environment
This Python installation is managed by uv
正确做法是:在升级项目文件夹创建独立 .venv 虚拟环境,不要为了安装一个串口库去破坏全局 uv 管理环境。
11.2 VSCode 中准备目录
例如:
text
C:\Users\Administrator\Desktop\xiazai\
Com.bin
upgrade_com.py
.venv\ <- 创建后出现
打开 VSCode,选择 File → Open Folder,打开这个 xiazai 文件夹;在 Terminal → New Terminal 打开终端。
11.3 在 PowerShell 创建虚拟环境
如果 VSCode 已能选择 uv 管理的 Python,仍可按本次实际路径执行:
powershell
& "$env:APPDATA\uv\python\cpython-3.15.0-windows-x86_64-none\python.exe" -m venv .venv
之后不需要激活脚本,直接调用虚拟环境 Python:
powershell
.\.venv\Scripts\python.exe -m pip install pyserial
如果提示没有 pip:
powershell
.\.venv\Scripts\python.exe -m ensurepip --upgrade
.\.venv\Scripts\python.exe -m pip install pyserial
验证:
powershell
.\.venv\Scripts\python.exe -c "import serial; print(serial.__version__)"
本次脚本用的是 pyserial,Python 代码中 import serial 是正常的。不要装一个不相关的名为 serial 的包来代替。
11.4 找当前 COM 串口
设备管理器 → 端口 (COM 和 LPT) → 找 USB-SERIAL CH340 (COMx);或者在 PowerShell:
powershell
[System.IO.Ports.SerialPort]::GetPortNames()
多个 COM 口时,可以比较拔掉/插上设备前后的列表。用户实际最后成功的是 COM7,不是最早测试过的 COM12。
12 正式升级:Com.bin 的真实参数与成功记录
12.1 实际固件参数(已从附件校验)
| 参数 | 实测值 |
|---|---|
| 文件名 | Com.bin |
| 文件大小 | 74016 字节 = 72.28125 KiB ≈ 72.28 KiB |
| CRC32(zlib) | 0x9F551106 |
| APP 允许容量 | 229376 字节 = 224 KiB |
| 文件占 APP 容量 | 约 32.27% |
| 数据包 | 290 包,最多 256B / 包 |
| 初始栈顶 | 0x2000C000 |
| Reset 入口 | 0x08010BB5 |
12.2 正式升级命令
进入 C:\Users\Administrator\Desktop\xiazai(实际文件夹以你电脑为准),关闭串口助手,复位 F103 到 Bootloader,在 VSCode PowerShell 执行:
powershell
.\.venv\Scripts\python.exe upgrade_com.py --port COM7 --file Com.bin
这条命令会自动发送 S 并擦除 APP,不要把它当无害的"查询命令"。过程中不要断电、不要连接可能引发复位的调试器、不要让串口助手同时占用 COM7。
12.3 本次成功日志(原样记录)
text
PS C:\Users\Administrator\Desktop\xiazai> .\.venv\Scripts\python.exe upgrade_com.py --port COM7 --file Com.bin
固件: Com.bin | 74016 字节 | CRC32=9F551106
警告: S 命令会擦除 APP 区域 0x08008000~0x0803FFFF
Bootloader 握手成功: B1
APP 擦除成功
已写入 74016/74016 字节
升级成功!F 返回 K,固件已完成 CRC32 校验。
暂不发送 J;可用 ST-LINK 回读 Flash 验证。
这份日志证明 F103 当前 Bootloader 报告完整升级成功;它没有显示实际秒数,所以博客中不能声称"实际耗时 10 秒"之类的未测量结论。
12.4 Python 是怎样分包的?
脚本中的核心循环:
python
for offset in range(0, size, 256):
block = firmware[offset:offset + 256]
ser.write(b'D' + struct.pack('<H', len(block)) + block)
wait_ack(ser, f'D/偏移 0x{offset:X}')
它对每一包发送 D + len + block,等到 K 才发下一包。最后:
python
ser.write(b'F')
wait_ack(ser, 'F/CRC32 完成校验')
这就是串口助手不能直接"发送整个裸 BIN 文件"的原因。
13 如何严格证明 Flash 写对了?
13.1 先查前 8 字节
升级完成后再用 STM32CubeProgrammer + ST-LINK 连接(这时不会打断升级状态,因为 F 已完成)。在 Memory & File Editing 读取:
text
Address : 0x08008000
Size : 0x100
应该看到的前 8 字节:
text
按字节:00 C0 00 20 B5 0B 01 08
32位列:2000C000 08010BB5
显示差异来自小端存储和界面的 Data width 选项,不要把 32-bit 列看成线性字节顺序。
13.2 全量回读才是独立验收
真正严谨的方法是从 0x08008000 开始读取 74,016 个字节 ,保存成 Com_after_readback.bin(不包含 Bootloader 前 32 KiB)。将回读文件与原始 Com.bin 逐字节比较。
在 VSCode 终端可以执行:
powershell
.\.venv\Scripts\python.exe -c "from pathlib import Path; a=Path('Com.bin').read_bytes(); b=Path('Com_after_readback.bin').read_bytes(); print('PASS: exact match' if a==b else 'FAIL: mismatch or different length')"
只有确保保存时读取地址就是 0x08008000、长度恰好是 74016 ,上面的全文件比较才有效。如果读的是整个 224 KiB APP,则应只比较回读文件的前 74016 字节,而且仍须核对它的起始地址。
记录边界 :之前存在一份较早的 com_readback.bin,不能把它冒充为这次成功升级后的回读证据。必须在这次升级完成后重新保存回读文件。
13.3 跳转成功与业务成功是两件事
串口发送 4A(ASCII J),若返回 4B,表示 Bootloader 接受跳转请求。是否真正启动 Com,需要通过调试器断点、业务状态、LED 或最终 CANopen 通信来确认。
本记录中的重点是固件升级功能已跑通。业务程序功能、上电自动跳转后的长期运行稳定性属于后续测试。
14 调试 Com APP 时不覆盖 Bootloader 的做法
14.1 CubeIDE 直接调试 APP 可以吗?
可以。两个工程分别链接 :Bootloader 在 0x08000000,Com 在 0x08008000。只要 IDE 的调试下载行为只擦除必要的 APP 页,Com 可以通过 ST-LINK 反复开发。
操作要检查:
- 调试配置引用的是 Com 的
.elf,不是 Bootloader.elf。 - Com 的链接脚本 FLASH
ORIGIN已改到0x08008000。 - CubeIDE / Programmer 下载擦除策略是按需擦除区域/扇页,不应开启 Full Chip Erase。
- APP 的
VTOR在其自身启动时正确重定位;出现中断问题先查这一项。
注意 :调试器如何启动,取决于 Debug Configuration 的启动 / 复位策略。有的配置从 ELF 入口接管,有的会硬复位先运行 Bootloader。不要笼统认为"按 Debug 必然跳过 Bootloader",最好读一下当前 PC、VTOR,或在 main() 设断点确认。
14.2 修改了 Bootloader 源码,芯片会自动变吗?
不会。你最新贴出的 Boot_Run() 有 3 秒自动跳转逻辑,但它只有在重新编译、重新烧录 Bootloader 之后才会替代旧版本。改 C 文件 ≠ 已更新芯片 Flash。
14.3 什么时候还需要 ST-LINK?
Bootloader 丢失、写保护误配置、错误跳转导致无法进入升级、Flash 被 Full Chip Erase 等情况,仍需要 SWD / ST-LINK 作为恢复手段。正式产品应保留必要的下载/维护接口。
15 经历过的典型错误:原因、诊断、解决
| 现象 | 真正要检查的点 | 建议处理 |
|---|---|---|
V 不是 B1 |
COM 口不对、串口助手 HEX/ASCII 混用、MCU 已进入 APP、噪声 | 复位 F103、关闭其他串口软件、核对 CH340 端口,HEX 发 56 |
只发 S 回复 E |
缺少 8B size+CRC32 | 一次发送完整 9B 帧,不要只发 53 |
S 完整帧仍 E |
接收超时、文件长度非法、Flash Unlock 或 Erase 失败 | 区分失败阶段,确认 HAL 返回值,不要盲目重复擦除 |
D 回复 E |
未先成功 S、复位导致 updating=0、len/数据缺失、写入失败 |
不要在 S 与 D 之间复位;发送完整 44+len+data |
D 收到 K 但前 8B 是 FF |
这是设计 | 前 8B 启动向量保留到 F 再写入 |
F 回复 E |
数据没收齐、CRC 不符、向量非法、回读 CRC 不匹配 | 根据 received/file_size 和计算值定位,不要直接 J |
J 回复 K 但 APP 不运行 |
J 仅接受跳转,未证明业务已正常启动 | 检查 APP .ld、SystemInit/VTOR、启动入口、业务初始化 |
| 串口握手收到异常字节 | 返回的不是期望的 B1 |
核对 COM 口、复位后重试、清空串口接收缓存 |
py 命令不存在 |
Python launcher 未提供/未在 PATH | 改用已有 Python 的完整路径或 .venv |
全局安装提示 externally-managed-environment |
Python 安装由 uv 管理 | 新建 .venv,安装 pyserial,不改全局环境 |
| 上电卡在 Bootloader | 实际芯片还是"禁用自动跳转"的旧版,或 APP 向量无效 | 查烧录是否更新、app_valid()、是否已到 3 秒 |
| 3 秒很慢 | 是设计的升级窗口,不是 UART_TIMEOUT 30 秒 | 调试阶段用 3 秒,正式设备设计专用升级入口 |
16 上线前必须解决的不足(当前是跑通的实验版本)
这套协议已经在你的 F103 板子上证明"能写入并校验固件",但还不应直接称为高可靠量产方案。
A. 断电恢复 :S 一开始会擦除整个 APP。若升级中断,旧 APP 已不存在;系统需要可稳定再次进入 Bootloader 的机制,最好再考虑备份镜像 / A-B 分区(当前 256KiB 容量要重新评估分区)。
B. 包级完整性 :目前仅有每包 K/E 和最终全文件 CRC,没有包序号和逐包 CRC。串口乱序、丢包或主机重试可能使双方状态不一致。应增加序号、数据包校验和明确重传机制。
C. 镜像有效性标志 :当前 app_valid() 只检查头 8 字节;建议持久化记录 APP 有效状态/版本,并区分"已收齐文件""CRC 成功""APP 健康启动"等状态。
D. 超时与状态机 :如果升级过程中 PC 掉线,updating 可能一直为 1,现有主循环又没有全局升级会话超时。应定义升级超时、取消、重入及错误恢复策略。
E. 稳定进入升级模式:未来将自动启动窗口缩至 500ms 时,电脑可能来不及发送 V/S。量产优先设计 APP 触发升级标志或硬件恢复入口。
F. 安全性 :CRC32 能检测常见传输错误,不能防止恶意替换固件。如果设备可能接入不受信任的上位机或外部网络,需考虑固件签名、鉴权和安全升级策略。
G. 调试器擦除设置:确保修改 APP 时不会意外擦除 Bootloader;正式发布前用备用板反复验证断电、错误大小、CRC 错、丢包、复位恢复。
17 一张最终核对清单(每次升级按此检查)
- F103 型号确实为 STM32F103RCT6,Flash 256 KiB / RAM 48 KiB。
- Bootloader
.ld:FLASH 起点0x08000000,长度32K。 - Com
.ld:FLASH 起点0x08008000,长度224K。 - Com 向量表 VTOR 为
0x08008000,不是默认的0x08000000。 - CH340C 与 USART2 PA2/PA3 接线、115200 8N1 正确。
-
Com.bin有效,当前大小不超过 229376 字节,前 8B 向量合法。 - Python 使用
.venv,pyserial 已安装,串口号为当前实际 COM 口。 - 升级之前保留 Com 备份与可用的 ST-LINK 恢复手段。
- 关闭串口助手,不要两个程序同时占用同一 COM 口。
- Python 收到 B1、S 回复 K、全部 D 回复 K、F 回复 K。
- 升级后按 0x08008000 起点回读,与原
.bin比较;不能用旧回读替代。 - 确认 APP 跳转与业务功能;对上电自动跳转单独测试。
- 发布博客时注明"实测条件"和"未完成的量产可靠性增强"。
附录 A:最终成功的 upgrade_com.py 完整源代码(原文件原样保留)
下面内容直接取自本次上传的
upgrade_com.py,不做算法改写 。这正是产生Bootloader 握手成功: B1、已写入 74016/74016、F 返回 K日志的脚本。运行前pip install pyserial并确认 COM 口和文件名。
python
import argparse
import struct
import sys
import time
import zlib
try:
import serial
except ImportError:
print('请先安装: py -m pip install pyserial')
sys.exit(1)
def wait_ack(ser, stage):
response = ser.read(1)
if response != b'K':
raise RuntimeError(f'{stage} 失败: 收到 {response.hex(" ") if response else "超时"}(预期 4B)')
def main():
parser = argparse.ArgumentParser(description='STM32F103 UART Bootloader Com.bin 升级')
parser.add_argument('--port', required=True, help='串口号,例如 COM5')
parser.add_argument('--file', default='Com.bin', help='固件 BIN 文件路径')
parser.add_argument('--baud', type=int, default=115200)
args = parser.parse_args()
with open(args.file, 'rb') as f:
firmware = f.read()
size = len(firmware)
crc = zlib.crc32(firmware) & 0xffffffff
if not (8 <= size <= 0x38000):
raise ValueError(f'固件大小 {size} 超出 APP 容量')
sp, reset = struct.unpack_from('<II', firmware)
if not (0x20000000 <= sp <= 0x2000C000 and sp % 8 == 0):
raise ValueError(f'栈指针异常: 0x{sp:08X}')
if not (0x08008000 <= (reset & ~1) < 0x08040000 and reset & 1):
raise ValueError(f'复位入口异常: 0x{reset:08X}')
print(f'固件: {args.file} | {size} 字节 | CRC32={crc:08X}')
print('警告: S 命令会擦除 APP 区域 0x08008000~0x0803FFFF')
with serial.Serial(args.port, args.baud, timeout=35, write_timeout=10) as ser:
# 避免串口助手遗留的回显干扰协议解析
ser.reset_input_buffer()
ser.write(b'V')
version = ser.read(2)
if version != b'B1':
raise RuntimeError(f'Bootloader 握手失败: {version!r},请复位 MCU 并确认串口号')
print('Bootloader 握手成功: B1')
ser.write(b'S' + struct.pack('<II', size, crc))
wait_ack(ser, 'S/擦除')
print('APP 擦除成功')
for offset in range(0, size, 256):
block = firmware[offset:offset + 256]
ser.write(b'D' + struct.pack('<H', len(block)) + block)
wait_ack(ser, f'D/偏移 0x{offset:X}')
print(f'\r已写入 {offset + len(block)}/{size} 字节', end='', flush=True)
print()
ser.write(b'F')
wait_ack(ser, 'F/CRC32 完成校验')
print('升级成功!F 返回 K,固件已完成 CRC32 校验。')
print('暂不发送 J;可用 ST-LINK 回读 Flash 验证。')
if __name__ == '__main__':
try:
main()
except (OSError, RuntimeError, ValueError, serial.SerialException if 'serial' in globals() else OSError) as exc:
print(f'错误: {exc}', file=sys.stderr)
sys.exit(1)
附录 B:Bootloader 参考源码的结构(完整版另附)
boot_upgrade_参考整理版.c、boot_upgrade.h 可以放入 Core/Src 和 Core/Inc,与第 04 章 main.c 调用配合。它们保留了 V/S/D/F/J 的协议,并对用户最后粘贴的 program_halfwords 范围检查做了安全性修正;该源码是博客复现参考,不冒称已重新烧录成功。实际使用时须检查工程生成的 HAL、链接脚本、编译错误和板上测试结果。
Bootloader 主要函数索引:
| 函数名 | 作用 |
|---|---|
Boot_Run |
主循环,串口命令分发与自动跳转 |
begin_upgrade |
解析 S、校验大小、擦除 APP、初始化状态 |
receive_packet |
解析 D、接收数据、保存前 8B、半字写入、更新 CRC |
finish_upgrade |
检查大小 / CRC / 向量,最后写入启动向量并回读校验 |
program_halfwords |
半字写 Flash,写后立即读回验证 |
erase_app |
用 112 页的页擦除覆盖全部 APP |
jump_app |
清理运行环境、设置 VTOR/MSP、跳转 APP Reset |
自定义参数修改时的同步关系
| 想改什么 | 必须同步确认什么 |
|---|---|
| APP 从 0x08008000 改地址 | 两工程链接脚本、APP_BASE、APP VTOR、PC 脚本中的校验范围 |
| 每包 256 改成其他大小 | PACKET_BYTES、PC 的分块步长、最大包长验证、内存缓冲区 |
| 使用新的 F1 型号 | Flash/RAM 大小、页大小、启动向量范围、时钟、串口引脚 |
| 3 秒改 500ms | Boot_Run 的 3000U;同时必须设计可靠的进入升级模式途径 |
| 30 秒接收超时改 2 秒 | UART_TIMEOUT,要保证 PC 完整帧发送不会中途超时 |