
1. 为什么要在 STM32F103C8T6 上折腾串口 IAPSTM32F103C8T6 这颗芯片搞嵌入式的基本都摸过。72MHz 主频、64KB Flash、20KB SRAM蓝色最小系统板十块钱出头就能买到性价比高到离谱。但很多人拿它做项目固件烧进去之后就固定了每次改代码都得把板子从设备里拆出来插上 DAPLink 或者 ST-Link 重新下载。产品一旦装到现场这种操作根本不现实。串口 IAPIn-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。这个页大小很重要后面擦写的时候要按页对齐。我的分区方案是这样的区域起始地址结束地址大小用途Bootloader0x080000000x08002FFF12KB引导程序用户程序0x080030000x0800EFFF48KB业务固件标志区0x0800F0000x0800F3FF1KB升级标志与固件信息保留0x0800F4000x0800FFFF3KB预留扩展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大小0xC00048KB。在 Options for Target - Target 里改。GCC 的话改链接脚本里的FLASH ORIGIN。这一步不做编译出来的固件还是按0x08000000链接的烧进去必挂。2.4 串口通信协议的设计取舍固件传输协议我设计得很简单因为 STM32F103C8T6 的 SRAM 只有 20KB搞太复杂的协议栈不现实。核心思路是上位机按固定格式发数据包Bootloader 收到后解析、写入、回复 ACK。数据包格式字段长度说明帧头2字节固定 0xAA 0x55命令1字节0x01 升级请求0x02 数据包0x03 结束长度2字节数据段长度小端数据N字节实际固件数据最大 1024CRC162字节从命令到数据的校验为什么选 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 库都有封装但我建议直接操作寄存器代码更可控也省空间。解锁 FlashFLASH-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 --outputfirmware.bin Objects\project.axf在 Keil 的 User 选项卡里可以配置编译后自动执行这条命令。GCC 的话用objcopyarm-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超时设 500ms6. 进阶优化与扩展思路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位就行。