1. 为什么要在 STM32F103C8T6 上折腾串口 IAP
STM32F103C8T6 这颗芯片,搞嵌入式的基本都摸过。72MHz 主频、64KB Flash、20KB SRAM,蓝色最小系统板十块钱出头就能买到,性价比高到离谱。但很多人拿它做项目,固件烧进去之后就固定了,每次改代码都得把板子从设备里拆出来,插上 DAPLink 或者 ST-Link 重新下载。产品一旦装到现场,这种操作根本不现实。
串口 IAP(In-Application Programming)解决的就是这个问题。简单说,就是让单片机自己通过串口接收新固件,然后写进自己的 Flash 里,写完跳过去执行。整个过程不需要拆机,不需要仿真器,一根 USB 转 TTL 线就能搞定。对于 STM32F103C8T6 这种资源紧张的芯片来说,IAP 方案的设计尤其需要精打细算——64KB Flash 要分给 Bootloader 和用户程序,20KB SRAM 要支撑接收缓冲和 Flash 写入,每一步都得算清楚。
这篇文章面向的是已经能跑通 STM32F103C8T6 基础工程、会用 Keil 或 GCC 编译、对 Flash 和中断有基本概念的嵌入式开发者。如果你刚点亮 LED,建议先把 GPIO、串口收发、Flash 读写这几个基础实验做一遍再来看。我会从分区规划开始,一步步拆解 Bootloader 的设计逻辑、用户程序的地址偏移处理、固件传输协议的设计,最后给出完整的代码框架和踩坑记录。整套方案在 STM32F103C8T6 最小系统板上实测通过,代码可以直接移植到同系列的其他型号。
2. 整体方案设计与 Flash 分区规划
2.1 Bootloader 与用户程序的职责划分
IAP 的核心思路是把 Flash 分成两块:一块放 Bootloader,负责接收新固件并写入;另一块放用户程序,也就是实际干活的业务代码。上电后先跑 Bootloader,它检查有没有升级请求——有就接收固件写进去,没有就直接跳转到用户程序。
Bootloader 的职责很明确:初始化串口、等待升级命令、接收固件数据、擦写 Flash、校验完整性、跳转执行。它不需要跑业务逻辑,所以代码量可以控制得很小。用户程序则完全不知道 Bootloader 的存在,它只管自己从某个偏移地址开始运行,中断向量表也要相应偏移。
这里有个关键决策:Bootloader 要不要支持“回滚”?也就是说,如果新固件写坏了,能不能退回旧版本。STM32F103C8T6 只有 64KB Flash,做双备份不现实。我的做法是在 Flash 末尾留一个标志区,记录当前固件是否有效。如果用户程序启动后在一定时间内没有“确认自己活着”,Bootloader 就认为升级失败,停在等待升级的状态。这个机制后面会详细讲。
2.2 64KB Flash 的精确分配
STM32F103C8T6 的 Flash 起始地址是0x08000000,总共 64KB,结束地址是0x0800FFFF。页大小是 1KB,也就是说擦除的最小单位是 1KB。这个页大小很重要,后面擦写的时候要按页对齐。
我的分区方案是这样的:
| 区域 | 起始地址 | 结束地址 | 大小 | 用途 |
|---|---|---|---|---|
| Bootloader | 0x08000000 | 0x08002FFF | 12KB | 引导程序 |
| 用户程序 | 0x08003000 | 0x0800EFFF | 48KB | 业务固件 |
| 标志区 | 0x0800F000 | 0x0800F3FF | 1KB | 升级标志与固件信息 |
| 保留 | 0x0800F400 | 0x0800FFFF | 3KB | 预留扩展 |
Bootloader 给 12KB 是经过实测的。一个带串口收发、Flash 擦写、CRC 校验、跳转逻辑的 Bootloader,用 Keil 开 -O2 优化,代码大概 6-8KB。留 12KB 有足够余量,万一以后要加加密校验或者双串口支持也够用。用户程序 48KB 对于大多数中小型项目绰绰有余,如果你代码实在大,可以压缩 Bootloader 到 8KB,把用户区扩到 52KB。
标志区放在0x0800F000,单独占一页。这一页专门用来存升级状态、固件长度、CRC 值这些元信息。为什么不放在 Bootloader 区域里?因为 Bootloader 区域在跳转后一般不会被擦写,但标志区需要在每次升级时更新,独立出来更清晰,也避免误擦 Bootloader。
注意:STM32F103C8T6 的 Flash 擦除是按页进行的,写入是按半字(16位)进行的。擦除一页需要约 20-40ms,这个时间必须等,不能省略。写入前必须确保目标区域已经擦除,否则写入会失败。
2.3 中断向量表的偏移处理
这是 IAP 最容易翻车的地方。Cortex-M3 的中断向量表默认从0x08000000开始,但用户程序实际烧在0x08003000。如果不做处理,用户程序里任何一个中断触发,CPU 都会去0x08000000找中断服务函数,结果跑到 Bootloader 的向量表里去了,程序直接跑飞。
解决办法是在用户程序的main函数开头,尽早设置SCB->VTOR寄存器,把向量表偏移到用户程序的起始地址。具体代码:
// 用户程序 main 函数开头 SCB->VTOR = 0x08003000; __DSB(); __ISB();这三行必须在任何中断使能之前执行。我一般放在SystemInit()之后、外设初始化之前。__DSB()和__ISB()是数据同步和指令同步屏障,确保 VTOR 写入立即生效。
另外,Keil 工程里还需要设置 IROM1 的起始地址为0x08003000,大小0xC000(48KB)。在 Options for Target -> Target 里改。GCC 的话改链接脚本里的FLASH ORIGIN。这一步不做,编译出来的固件还是按0x08000000链接的,烧进去必挂。
2.4 串口通信协议的设计取舍
固件传输协议我设计得很简单,因为 STM32F103C8T6 的 SRAM 只有 20KB,搞太复杂的协议栈不现实。核心思路是:上位机按固定格式发数据包,Bootloader 收到后解析、写入、回复 ACK。
数据包格式:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 固定 0xAA 0x55 |
| 命令 | 1字节 | 0x01 升级请求,0x02 数据包,0x03 结束 |
| 长度 | 2字节 | 数据段长度,小端 |
| 数据 | N字节 | 实际固件数据,最大 1024 |
| CRC16 | 2字节 | 从命令到数据的校验 |
为什么选 CRC16 而不是校验和?校验和对突发错误的检测能力太弱,固件传输过程中一个字节出错就可能导致整个程序跑飞。CRC16 计算量小,在 72MHz 的 M3 上跑 1KB 数据也就几十微秒,完全可接受。
数据包最大 1024 字节,是因为 Flash 一页正好 1KB。收到一包就擦一页、写一页,逻辑最顺。SRAM 里开一个 1024 字节的接收缓冲,加上其他变量,20KB 完全够用。
实操心得:串口波特率不要一上来就飙到 115200 以上。STM32F103C8T6 的 USART 在 72MHz 下,115200 的误差率约 0.16%,很稳。但如果你用的是内部 RC 振荡器(HSI),误差会大很多,建议先用 57600 测试,稳定后再往上提。我实测 115200 配合外部 8MHz 晶振,连续传 48KB 固件零丢包。
3. Bootloader 核心代码实现与关键细节
3.1 Flash 擦写驱动的编写要点
STM32F103C8T6 的 Flash 操作有一套固定的流程:解锁、擦除、写入、锁定。标准库和 HAL 库都有封装,但我建议直接操作寄存器,代码更可控,也省空间。
解锁 Flash:
FLASH->KEYR = 0x45670123; FLASH->KEYR = 0xCDEF89AB;这两个密钥必须连续写入,中间不能有中断打断。所以解锁前最好关一下全局中断,解锁完再开。
擦除一页:
FLASH->CR |= FLASH_CR_PER; // 页擦除模式 FLASH->AR = page_address; // 目标页地址 FLASH->CR |= FLASH_CR_STRT; // 启动擦除 while (FLASH->SR & FLASH_SR_BSY); // 等待完成 FLASH->CR &= ~FLASH_CR_PER; // 退出页擦除模式写入半字:
FLASH->CR |= FLASH_CR_PG; // 编程模式 *(volatile uint16_t*)addr = data; // 写入半字 while (FLASH->SR & FLASH_SR_BSY); // 等待完成 FLASH->CR &= ~FLASH_CR_PG; // 退出编程模式这里有个坑:写入地址必须是半字对齐的,也就是偶数地址。如果你传进来的地址是奇数,写入会失败,而且 Flash 的 SR 寄存器会置位错误标志。我的做法是在写入前强制addr & 0xFFFFFFFE,确保对齐。
还有一个细节:每次写入前要检查FLASH->SR的PGERR和WRPRTERR位,如果置位了说明写入出错,要清掉标志并返回错误。不清的话下次操作会直接失败。
3.2 串口接收的状态机设计
Bootloader 的串口接收不能用“收完一帧再处理”的阻塞方式,因为固件包可能很大,阻塞接收会丢数据。我用的是环形缓冲加状态机的方式。
环形缓冲开 2048 字节,串口中断里只管往缓冲里塞数据,主循环里从缓冲里取数据解析。这样中断处理时间极短,不会丢包。
状态机解析数据包的逻辑:
typedef enum { STATE_HEADER1, // 等待 0xAA STATE_HEADER2, // 等待 0x55 STATE_CMD, // 命令字节 STATE_LEN_L, // 长度低字节 STATE_LEN_H, // 长度高字节 STATE_DATA, // 数据段 STATE_CRC_L, // CRC 低字节 STATE_CRC_H // CRC 高字节 } parse_state_t;每收到一个字节,根据当前状态决定下一步。收到完整包后,计算 CRC 并比对,通过就处理,不通过就丢弃并请求重发。
注意:串口中断里不要做任何耗时操作,包括 CRC 计算。CRC 放到主循环里做。中断里只负责把数据塞进环形缓冲,然后更新写指针。读指针在主循环里更新。这样即使主循环正在擦 Flash(耗时几十毫秒),串口数据也不会丢,因为环形缓冲够大。
3.3 跳转到用户程序的正确姿势
跳转前要做几件事:关掉所有中断、关闭外设时钟、设置主栈指针、设置向量表偏移、跳转。
void jump_to_app(uint32_t app_addr) { uint32_t sp = *(volatile uint32_t*)app_addr; uint32_t pc = *(volatile uint32_t*)(app_addr + 4); __disable_irq(); // 关闭 SysTick SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; // 关闭所有外设中断 for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } // 设置主栈指针 __set_MSP(sp); // 设置向量表偏移 SCB->VTOR = app_addr; // 跳转 void (*app_entry)(void) = (void (*)(void))pc; app_entry(); }这段代码有几个关键点。第一,app_addr处存放的是用户程序的栈顶地址,app_addr + 4处是复位向量。第二,关中断必须在设置 MSP 之前,否则中断触发会用到旧的栈。第三,SCB->VTOR在跳转前设置一次,用户程序里还会再设置一次,双保险。
我踩过的坑:有一次忘了关 SysTick,跳转后 SysTick 还在跑,中断触发后跑到 Bootloader 的 SysTick_Handler 里去了,因为向量表还没来得及切换。所以 SysTick 一定要在跳转前关掉。
3.4 升级标志与固件有效性检查
标志区我定义了一个结构体:
typedef struct { uint32_t magic; // 0x5A5A5A5A 表示标志有效 uint32_t fw_len; // 固件长度 uint32_t fw_crc; // 固件 CRC32 uint8_t fw_valid; // 1 表示固件有效 uint8_t reserved[3]; } fw_info_t;Bootloader 上电后先读这个结构体。如果magic不对,说明标志区没初始化过,直接进升级模式。如果fw_valid为 1,说明上次升级成功,跳转到用户程序。如果fw_valid为 0,说明上次升级中途断电了,固件不完整,停在升级模式等待重新传输。
用户程序启动后,第一件事是把fw_valid置 1,表示“我活着”。这个操作放在main函数开头,越早越好。这样即使后续初始化失败,至少标志已经置上了,下次上电不会误判。
实操心得:标志区的写入要注意,Flash 写入只能把 1 变成 0,不能把 0 变成 1。所以更新标志时,要么先擦除整页再写,要么设计成只写 0 不写 1 的逻辑。我选择的是每次升级前擦除标志页,升级完成后写入新标志。擦除一页 1KB 耗时约 20ms,可以接受。
4. 用户程序的适配与固件生成
4.1 Keil 工程地址偏移配置
用户程序的 Keil 工程需要改两个地方。第一是 Target 里的 IROM1 起始地址和大小:
IROM1: Start = 0x08003000, Size = 0xC000第二是system_stm32f10x.c里的VECT_TAB_OFFSET:
#define VECT_TAB_OFFSET 0x3000这个宏决定了SystemInit()里设置的向量表偏移。改完这两处,编译出来的固件就是按0x08003000链接的。
如果你用的是 GCC,链接脚本里改:
MEMORY { FLASH (rx) : ORIGIN = 0x08003000, LENGTH = 48K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K }4.2 生成可传输的固件文件
Keil 编译出来的是.axf文件,不能直接用于 IAP 传输。需要转成.bin文件。Keil 自带fromelf工具:
fromelf --bin --output=firmware.bin Objects\project.axf在 Keil 的 User 选项卡里可以配置编译后自动执行这条命令。GCC 的话用objcopy:
arm-none-eabi-objcopy -O binary project.elf firmware.bin生成的.bin文件就是从0x08003000开始的纯二进制数据,大小就是实际代码量。上位机直接把这个文件按包发送即可。
4.3 上位机工具的选择与编写
上位机我推荐用 Python 写,跨平台,改起来方便。核心逻辑就是读 bin 文件、分包、加协议头、发串口、等 ACK。
import serial import struct import time def crc16(data): crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc def send_packet(ser, cmd, data): length = len(data) payload = struct.pack('<BH', cmd, length) + data crc = crc16(payload) packet = b'\xAA\x55' + payload + struct.pack('<H', crc) ser.write(packet) # 等待 ACK ack = ser.read(1) return ack == b'\x06'发送流程:先发升级请求命令,Bootloader 回复 ACK 并擦除用户区;然后按 1KB 分包发送数据,每包等 ACK;最后发结束命令,Bootloader 校验整个固件的 CRC,通过后置fw_valid为 1,回复成功。
注意:每包之间的超时时间要设够。Bootloader 擦一页 Flash 需要 20-40ms,加上写入和 CRC 校验,一包的处理时间可能到 50ms。上位机的超时建议设 500ms 以上,重试次数 3 次。我一开始设了 100ms,结果大量超时重传,传 48KB 花了快两分钟。改成 500ms 后,稳定在 15 秒左右。
4.4 固件 CRC 校验的完整流程
CRC 校验分两级。第一级是每包的 CRC16,确保单包数据没出错。第二级是整个固件的 CRC32,在结束命令时由 Bootloader 计算并比对。
Bootloader 在接收数据的同时,把每个字节喂给一个 CRC32 计算器。收到结束命令后,把计算结果和上位机发来的 CRC32 比对。一致就认为固件完整,不一致就要求重传。
CRC32 我用的是标准多项式0xEDB88320,查表法实现,速度很快。48KB 数据算下来不到 1ms。
uint32_t crc32_update(uint32_t crc, uint8_t data) { crc ^= data; for (int i = 0; i < 8; i++) { if (crc & 1) crc = (crc >> 1) ^ 0xEDB88320; else crc >>= 1; } return crc; }上位机端用 Python 的zlib.crc32()计算,两边结果一致。注意 Python 的zlib.crc32()返回的是有符号整数,要& 0xFFFFFFFF转成无符号再发送。
5. 常见问题排查与实战避坑记录
5.1 跳转后程序不运行的排查思路
这是 IAP 最常遇到的问题。跳转过去了,但用户程序没跑起来。排查顺序如下:
第一,检查用户程序的链接地址。用fromelf --text -v或者arm-none-eabi-objdump -h看固件的入口地址是不是0x08003000。如果还是0x08000000,说明工程配置没改对。
第二,检查向量表偏移。在用户程序的main开头打断点,看SCB->VTOR的值是不是0x08003000。如果不是,检查VECT_TAB_OFFSET宏和SystemInit()的调用。
第三,检查栈顶地址。用调试器看0x08003000处的值,应该是0x20005000左右(20KB SRAM 的栈顶)。如果是0xFFFFFFFF,说明 Flash 里没数据,固件没写进去。
第四,检查中断。如果程序跑起来了但一进中断就挂,多半是向量表没设对。确认SCB->VTOR在中断使能前就设置好了。
我遇到过一次,跳转后程序能跑,但串口中断不响应。查了半天发现是 Bootloader 里开了串口中断,跳转前没关,用户程序里又没重新配置 NVIC,导致中断优先级混乱。后来在跳转函数里加了NVIC->ICER全清,问题解决。
5.2 Flash 写入失败的典型原因
Flash 写入失败一般有这几个原因:
- 目标页没擦除。STM32 的 Flash 只能把 1 写成 0,如果目标地址已经是 0,再写就会失败。每次写入前必须确保页已擦除。
- 地址没对齐。写入地址必须是偶数。奇数地址写入会触发
PGERR。 - 没解锁。
FLASH->KEYR没写或者写错,所有操作都会被忽略。 - 写保护。如果芯片的写保护选项字节被设置了,Flash 写入会被拒绝。用 ST-Link Utility 检查一下选项字节。
排查的时候,每次操作后读FLASH->SR,看有没有错误标志。有就清掉,然后根据标志位判断原因。
5.3 串口丢包与超时的处理
串口丢包在 IAP 里很致命,一个字节丢了整个固件就废了。丢包的原因通常有三个:
第一,波特率误差太大。用外部晶振,115200 没问题。用内部 HSI,建议降到 57600 或 38400。
第二,接收缓冲太小。Bootloader 擦 Flash 的时候会关中断或者长时间不处理串口,如果缓冲不够大,数据就丢了。我开 2048 字节缓冲,擦一页 1KB 的时间足够缓冲后续数据。
第三,上位机发送太快。每包之间要等 ACK,不能连续发。Python 的ser.write()是异步的,写完不等 ACK 就发下一包,Bootloader 处理不过来。必须ser.read(1)等 ACK 再发下一包。
实操心得:调试 IAP 的时候,建议先用小固件测试,比如就闪个 LED 的 bin 文件,几 KB 大小。跑通了再传大固件。另外,Bootloader 里加个 LED 指示很有用:等待升级时慢闪,接收数据时快闪,升级成功时常亮。不用串口打印也能知道状态。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 跳转后无反应 | 用户程序链接地址不对 | 检查 IROM1 起始地址 |
| 跳转后进中断跑飞 | 向量表偏移未设置 | 设置 SCB->VTOR 并加屏障 |
| Flash 写入失败 | 页未擦除或地址未对齐 | 擦除后写入,地址强制偶数对齐 |
| 串口丢包 | 波特率误差或缓冲不足 | 降波特率,增大环形缓冲 |
| 升级后程序不跑 | 固件 CRC 校验失败 | 检查上位机 CRC 计算和字节序 |
| 反复进入升级模式 | 标志区 fw_valid 未置位 | 用户程序开头置位 fw_valid |
| 擦除时间过长 | 正常现象 | 每页 20-40ms,超时设 500ms |
6. 进阶优化与扩展思路
6.1 加入固件加密与校验
如果产品需要防抄板或者防篡改,可以在固件传输前做 AES 加密,Bootloader 收到后解密再写入。STM32F103C8T6 没有硬件 AES 加速,软件 AES 解密 48KB 数据大概需要几百毫秒,可以接受。密钥可以存在 Flash 的保留区,或者用芯片唯一 ID 派生。
另一个思路是固件签名。上位机用私钥对固件签名,Bootloader 用公钥验签。这样即使固件被截获,没有私钥也无法伪造合法固件。不过 RSA 验签在 M3 上跑比较慢,ECC 会快很多,但实现复杂度高。对于大多数项目,CRC32 加简单的异或加密就够了。
6.2 双串口备份升级通道
STM32F103C8T6 有 3 个 USART,可以同时支持两个升级通道。比如 USART1 接调试口,USART2 接设备外部接口。Bootloader 同时监听两个串口,哪个先收到升级请求就用哪个。这样现场升级更灵活,不用拆机找特定的接口。
实现上,两个串口各开一个环形缓冲,主循环轮询两个缓冲。哪个有数据就解析哪个。注意两个串口的波特率要一致,否则协议解析会乱。
6.3 从串口 IAP 扩展到 CAN IAP
如果设备用的是 CAN 总线,把串口换成 CAN 即可,协议层不用大改。CAN 的帧格式和串口不同,但分包、CRC、ACK 的逻辑是一样的。STM32F103C8T6 自带 CAN 控制器,加个收发器就能用。CAN 的抗干扰能力比串口强得多,适合工业现场。
6.4 固件版本管理与回滚机制
在标志区里加一个版本号字段,Bootloader 记录当前运行的固件版本。升级时如果新版本号比当前低,可以拒绝升级或者提示确认。回滚的话,因为 Flash 空间不够存两份固件,只能靠上位机重新传旧版本。但可以在标志区里存一个“上一版本有效”的标志,如果新固件启动失败,Bootloader 自动进入升级模式,等待上位机传回旧版本。
这个机制的关键是“启动失败”的判定。我的做法是用户程序启动后,在 5 秒内必须往标志区写一个“心跳”值。Bootloader 在跳转前启动一个定时器,如果 5 秒内没收到心跳,就认为启动失败,复位并停在升级模式。心跳可以用 RTC 备份寄存器实现,不用擦 Flash。
最后分享一个小技巧:Bootloader 的串口最好支持自动波特率检测。上位机先发一个 0x7F 字节,Bootloader 测量脉宽算出波特率,然后回复确认。这样不管上位机用什么波特率都能连上,现场调试很方便。STM32F103C8T6 的 USART 支持自动波特率检测,配置一下
USART_CR3的ABREN位就行。