1. 为什么要在STM32F103C8T6上折腾串口IAP
STM32F103C8T6这颗芯片,玩嵌入式的朋友基本都绕不开。72MHz主频、64KB Flash、20KB SRAM,蓝色药丸最小系统板十块钱出头就能拿下,性价比高到离谱。但很多人用它做项目时会遇到一个很现实的问题:板子装在设备内部、外壳封死、调试口没引出来,固件有bug要改怎么办?总不能每次都拆壳、接SWD、重新烧录吧。
串口IAP就是解决这个痛点的。IAP全称In-Application Programming,翻译过来叫“在应用编程”,核心思路是让单片机自己具备“给自己刷固件”的能力。你把新固件通过串口发给它,它自己擦写Flash、跳转执行,全程不需要仿真器。这个方案在工业现场、远程设备维护、量产烧录等场景里非常实用。
我拿STM32F103C8T6做串口IAP前后折腾了好几版,踩过的坑包括但不限于:中断向量表没重映射导致跳转后跑飞、Flash擦除地址算错把Bootloader自己擦了、串口接收丢包导致固件校验失败、APP里开了中断但Bootloader没关干净导致HardFault。这些问题在网上的教程里往往一笔带过,但实际调试时能耗掉你一整天。
这篇文章面向的是有STM32基础、会用Keil或STM32CubeIDE、能看懂基本寄存器操作的嵌入式开发者。我会从Flash分区规划开始,一步步拆解Bootloader的设计逻辑、APP的改造要点、串口传输协议的设计、完整的代码实现,以及那些只有实际动手才会遇到的坑。代码基于标准外设库,但思路对HAL库同样适用。
2. Flash分区规划与Bootloader设计思路
2.1 STM32F103C8T6的Flash结构你得先摸清楚
STM32F103C8T6的64KB Flash在物理上是一整块,但操作时按页(Page)管理,每页1KB,总共64页。这一点很关键,因为擦除操作的最小单位就是页,你没法只擦512字节。写操作倒是可以按半字(16位)或字(32位)来,但前提是目标区域已经被擦除过(内容为0xFF)。
地址范围从0x08000000到0x0800FFFF。系统存储器(System Memory)在0x1FFFF000附近,里面固化了ST官方的Bootloader,支持串口下载,但那个是出厂自带的,我们做IAP用的是用户Flash区域。
分区方案我推荐这样切:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 12KB | 存放IAP引导程序 |
| APP | 0x08003000 | 48KB | 存放用户应用程序 |
| 参数区 | 0x0800F000 | 4KB | 存放升级标志、固件信息等 |
为什么Bootloader给12KB而不是更小?因为你要在里面放串口驱动、Flash擦写函数、通信协议解析、可能还有校验算法(比如CRC32)。我试过压缩到8KB,功能一多就捉襟见肘。12KB留了余量,编译出来大概7-8KB,剩下的空间以后加功能也不慌。
APP从0x08003000开始,48KB对于F103C8T6来说够用了。参数区放在最后4KB,用来存升级请求标志、固件长度、CRC校验值这些元数据。这样Bootloader启动时先读参数区,判断是正常启动还是进入升级模式。
注意:分区边界一定要按1KB对齐,因为擦除是按页来的。0x08003000是12KB的整数倍,0x0800F000是60KB的整数倍,都满足对齐要求。
2.2 Bootloader到底该干什么
Bootloader的本质是一个“永远不被覆盖的小程序”,它的职责链条是这样的:
- 系统上电或复位,先跑Bootloader
- 检查参数区是否有升级请求标志
- 如果有升级请求,进入串口接收模式,等待上位机发送固件
- 接收完成后校验固件完整性(长度、CRC)
- 校验通过则写入APP区,清除升级标志,跳转到APP
- 如果没有升级请求,直接跳转到APP
这里有个关键设计决策:升级请求标志怎么设置?常见做法有两种。一种是在APP里收到升级命令后,主动写参数区标志然后软复位;另一种是Bootloader启动时检测某个GPIO按键,按住就进升级模式。我两种都实现了,实际用下来按键方式更适合现场调试,APP触发方式更适合远程升级。
跳转到APP的核心操作就三件事:关闭所有中断、设置主栈指针(MSP)、跳转到复位向量。代码大概长这样:
typedef void (*pFunction)(void); pFunction Jump_To_Application; uint32_t JumpAddress; /* 检查APP区栈顶地址是否合法 */ if (((*(__IO uint32_t*)APP_ADDR) & 0x2FFE0000) == 0x20000000) { JumpAddress = *(__IO uint32_t*)(APP_ADDR + 4); Jump_To_Application = (pFunction)JumpAddress; __set_MSP(*(__IO uint32_t*)APP_ADDR); Jump_To_Application(); }那个栈顶地址检查很关键。APP区的第一个字是栈顶地址,F103C8T6的SRAM从0x20000000开始,大小20KB,所以栈顶地址一定在0x20000000到0x20005000之间。用0x2FFE0000做掩码就是判断高两位是不是0x2000开头。如果APP区是空的(0xFFFFFFFF),这个检查就会失败,避免跳转到无效地址导致HardFault。
2.3 中断向量表重映射:最容易翻车的地方
APP从0x08003000开始,但STM32默认从0x08000000取中断向量表。如果不做重映射,APP里任何一个中断触发,CPU都会跑到Bootloader的向量表里去,结果就是跑飞或者HardFault。
解决方法是在APP的main函数开头,尽早设置向量表偏移寄存器(SCB->VTOR):
SCB->VTOR = APP_ADDR;这行代码必须在任何中断使能之前执行。我一般放在SystemInit()之后、main()的第一行。用标准库的话,也可以在system_stm32f10x.c里改VECT_TAB_OFFSET,但那样不灵活,还是直接在APP里设VTOR更直观。
实操心得:如果你在APP里用了FreeRTOS,VTOR设置必须在osKernelStart()之前。我见过有人在任务里才设VTOR,结果SVC中断和PendSV中断全跑错了地方,系统直接卡死。
3. 串口传输协议设计与上位机配合
3.1 为什么不能直接裸发bin文件
最简单的IAP就是串口收到什么就往Flash写什么,但实际项目中这么干风险很大。串口传输可能丢字节、可能被干扰、上位机可能发错文件。没有协议保护的话,写进去的固件是坏的,设备直接变砖。
我设计的协议包结构是这样的:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2字节 | 0xAA 0x55 |
| 命令 | 1字节 | 0x01升级请求 0x02数据包 0x03结束 |
| 长度 | 2字节 | 数据段长度 |
| 数据 | N字节 | 实际固件数据 |
| CRC16 | 2字节 | 从命令到数据的校验 |
每包数据最大256字节,因为F103C8T6的SRAM只有20KB,Bootloader里还要留缓冲区,太大容易爆栈。256字节一包,48KB固件大概192包,115200波特率下传输时间大概十几秒,可以接受。
CRC16用查表法实现,比逐位计算快很多。多项式用0x1021(CCITT),初始值0xFFFF。这个校验能挡住绝大多数传输错误。
3.2 上位机端怎么做
上位机我用Python写了一个简单的脚本,用pyserial库。核心流程是:打开串口、发送升级请求、等待Bootloader应答、按包发送固件数据、每包等ACK、发完后发结束命令、等待校验结果。
import serial import struct import time def crc16(data): crc = 0xFFFF for byte in data: crc ^= byte << 8 for _ in range(8): if crc & 0x8000: crc = (crc << 1) ^ 0x1021 else: crc <<= 1 crc &= 0xFFFF return crc def send_packet(ser, cmd, payload=b''): header = b'\xAA\x55' length = len(payload) frame = header + struct.pack('<BH', cmd, length) + payload crc = crc16(frame[2:]) frame += struct.pack('<H', crc) ser.write(frame) # 等待ACK ack = ser.read(1) return ack == b'\x06'上位机每发一包等一个ACK,超时就重发,重发三次还失败就报错退出。这个机制在实测中很稳,偶尔丢一包也能自动补上。
注意:Python的struct.pack('<BH')是小端模式,STM32也是小端,所以直接对应。如果你用大端的上位机工具,字节序要反过来。
3.3 Bootloader端的接收状态机
Bootloader的串口接收我用的是中断+环形缓冲区的方式。每收到一个字节进中断,存到缓冲区,主循环里解析。状态机有这几个状态:等待帧头、等待命令、等待长度、接收数据、校验CRC。
为什么不直接在中断里解析?因为Flash擦写操作耗时较长(页擦除大概20-40ms),在中断里做会阻塞其他中断,串口容易丢数据。所以中断只负责收,主循环负责处理。
环形缓冲区大小我设的512字节,够存两包数据。如果主循环处理不过来导致缓冲区满,就丢弃最旧的数据并置错误标志,上位机超时后会重发。
4. APP工程的改造与固件生成
4.1 Keil工程需要改哪些地方
APP工程不能按默认配置编译,否则生成的固件地址从0x08000000开始,和Bootloader冲突。在Keil里要改两个地方:
第一,Target选项卡里的IROM1起始地址改成0x08003000,大小改成0xC000(48KB)。这样链接器就知道代码从0x08003000开始放。
第二,在main函数最前面加VTOR设置。标准库的话,SystemInit()里默认会把VTOR设成0x08000000,你可以在SystemInit()之后覆盖它。
int main(void) { SCB->VTOR = 0x08003000; // 其他初始化... }如果用STM32CubeMX生成的HAL库工程,VTOR设置可以放在HAL_Init()之后、SystemClock_Config()之前。HAL库的HAL_Init()里会调SystemInit(),所以要在它之后改。
4.2 生成bin文件而不是hex
IAP传输的是纯二进制bin文件,不是hex。hex文件带地址信息,Bootloader解析起来麻烦。Keil里生成bin文件需要加一个User Command:
fromelf --bin --output=.\Objects\app.bin .\Objects\app.axf在Options for Target -> User -> After Build/Rebuild里填这行命令。编译完自动生成bin文件,大小就是实际代码占用的Flash空间。
实操心得:bin文件大小最好在发送前检查一下,如果超过APP区大小(48KB),Bootloader应该拒绝接收并报错。我见过有人APP编译出来52KB还硬发,结果写到一半地址越界,把参数区擦了,设备直接变砖。
4.3 固件版本管理和回滚思路
实际项目中,升级失败要能回滚。我的做法是在参数区存两个APP区:主APP区和备份APP区。但F103C8T6的Flash只有64KB,分不出两个48KB。所以退而求其次,升级前先把当前APP的版本号和CRC存到参数区,升级后如果新固件启动失败(比如看门狗复位次数超限),Bootloader可以尝试恢复。
更简单的方案是:升级时先擦除备份区(如果空间够),把当前APP复制过去,再写新APP。但64KB实在紧张,这个方案只适合Flash更大的型号。F103C8T6上我一般就做个简单的“升级失败则停留在Bootloader等待重传”的逻辑,不搞复杂回滚。
5. 完整代码实现与关键函数解析
5.1 Flash擦写函数
标准库的Flash操作函数在stm32f10x_flash.c里,但直接调用要注意解锁和锁定的配对。我封装了两个函数:
#define APP_ADDR 0x08003000 #define APP_END 0x0800F000 #define PAGE_SIZE 1024 uint8_t Flash_Erase(uint32_t start_addr, uint32_t end_addr) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); uint32_t addr = start_addr; while (addr < end_addr) { if (FLASH_ErasePage(addr) != FLASH_COMPLETE) { FLASH_Lock(); return 1; // 失败 } addr += PAGE_SIZE; } FLASH_Lock(); return 0; } uint8_t Flash_Write(uint32_t addr, uint8_t *data, uint32_t len) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (uint32_t i = 0; i < len; i += 2) { uint16_t halfword = data[i] | (data[i+1] << 8); if (FLASH_ProgramHalfWord(addr + i, halfword) != FLASH_COMPLETE) { FLASH_Lock(); return 1; } } FLASH_Lock(); return 0; }擦除前一定要先解锁,擦完立刻锁定,防止误操作。FLASH_ClearFlag那行是清错误标志,如果之前有写保护错误没清,后续操作会一直失败。
注意:FLASH_ProgramHalfWord要求目标地址是2字节对齐,数据长度也得是偶数。如果上位机发来的最后一包是奇数长度,要在后面补一个0xFF凑成偶数再写。
5.2 串口接收中断与环形缓冲区
#define RX_BUF_SIZE 512 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_head = 0; volatile uint16_t rx_tail = 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); uint16_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { rx_buf[rx_head] = data; rx_head = next; } // 缓冲区满则丢弃 USART_ClearITPendingBit(USART1, USART_IT_RXNE); } } uint16_t RingBuf_Available(void) { return (rx_head - rx_tail + RX_BUF_SIZE) % RX_BUF_SIZE; } uint8_t RingBuf_Read(void) { if (rx_head == rx_tail) return 0; uint8_t data = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; return data; }这个环形缓冲区实现很经典,head和tail都是无符号16位,用取模运算处理回绕。判断空的条件是head == tail,判断满的条件是(next == tail)。中断里只写head,主循环只读tail,不需要关中断保护。
5.3 协议解析状态机
typedef enum { STATE_IDLE, STATE_HEADER1, STATE_HEADER2, STATE_CMD, STATE_LEN_L, STATE_LEN_H, STATE_DATA, STATE_CRC_L, STATE_CRC_H } ParseState; void Parse_Byte(uint8_t byte) { static ParseState state = STATE_IDLE; static uint8_t cmd, data[256]; static uint16_t len, idx, crc_recv; switch (state) { case STATE_IDLE: if (byte == 0xAA) state = STATE_HEADER1; break; case STATE_HEADER1: state = (byte == 0x55) ? STATE_CMD : STATE_IDLE; break; case STATE_CMD: cmd = byte; state = STATE_LEN_L; break; case STATE_LEN_L: len = byte; state = STATE_LEN_H; break; case STATE_LEN_H: len |= (byte << 8); idx = 0; state = (len > 0) ? STATE_DATA : STATE_CRC_L; break; case STATE_DATA: data[idx++] = byte; if (idx >= len) state = STATE_CRC_L; break; case STATE_CRC_L: crc_recv = byte; state = STATE_CRC_H; break; case STATE_CRC_H: crc_recv |= (byte << 8); // 校验并处理 Process_Packet(cmd, data, len, crc_recv); state = STATE_IDLE; break; } }状态机每收到一个字节推进一步,不阻塞主循环。Process_Packet里根据命令类型做不同处理,升级请求就擦除APP区并回ACK,数据包就写入Flash并回ACK,结束命令就校验整个固件并跳转。
6. 常见问题排查与避坑经验
6.1 跳转后跑飞或HardFault
这是最高频的问题。原因通常有三个:VTOR没设对、MSP没设对、中断没关干净。排查顺序是先用调试器看APP入口的汇编,确认第一条指令地址是不是0x08003000+4对应的复位向量。然后在Bootloader跳转前加一句__disable_irq(),跳转后在APP的main里再__enable_irq()。
还有一个隐蔽的坑:Bootloader里开了串口中断,跳转前只关了总中断但没清NVIC的使能位。APP里如果用了同一个中断号,可能会触发一次意外的中断。稳妥做法是跳转前把NVIC的ICER寄存器全写0,彻底关掉所有中断使能。
6.2 串口接收丢数据
115200波特率下,如果Bootloader主循环里有耗时操作(比如Flash擦除),串口中断可能来不及响应。F103的USART只有1字节接收缓冲,中断响应慢了就溢出。解决办法是提高中断优先级,把USART1_IRQn设成最高(抢占优先级0),Flash操作期间中断照样能进。
如果还是丢,就降低波特率到57600或38400。实测57600下基本不丢包,传输时间翻倍但稳定性好很多。
6.3 固件校验失败
CRC校验失败通常是传输错误或Flash写入错误。先检查上位机发的bin文件CRC和Bootloader算的是否一致。如果上位机算的对但Bootloader算的不对,可能是接收过程中丢了字节。可以在每包数据里加个包序号,Bootloader检查序号连续性,断了就要求重传。
Flash写入错误的话,检查目标地址是否已经擦除。STM32的Flash只能把1写成0,不能把0写成1。如果没擦就写,写入的数据会是原值和新值的按位与,结果肯定不对。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 跳转后无反应 | VTOR未设置 | 检查APP的SCB->VTOR值 |
| HardFault | MSP未设置 | 检查APP区第一个字是否在0x20000000-0x20005000 |
| 串口丢包 | 中断优先级低 | 提高USART中断抢占优先级 |
| CRC校验失败 | 传输错误 | 加包序号和重传机制 |
| Flash写入失败 | 未擦除或写保护 | 检查FLASH_CR寄存器的PER和PG位 |
| APP运行异常 | 中断向量错位 | 确认APP编译地址和VTOR一致 |
实操心得:调试IAP时,先在RAM里跑通跳转逻辑,再放到Flash里。RAM调试可以随便改,不用反复擦Flash,效率高很多。具体做法是在Keil的Target里把IROM1去掉,只留IRAM1,然后设置调试器下载到RAM。跳转目标也改成RAM地址。等逻辑通了再切回Flash。
7. 进阶优化与量产考虑
7.1 加入固件加密
量产产品不希望固件被随便读出来。F103C8T6支持读保护(RDP),设置RDP Level 1后通过调试口读Flash会返回全0。但开了读保护后,IAP自己写Flash也会受影响,需要在Bootloader里先解除保护、写完后重新使能。这个操作有风险,搞不好会把芯片锁死,建议先在废板上试。
更轻量的方案是固件本身加密,Bootloader收到后解密再写。AES128在F103上跑得动,但会增加Bootloader体积。如果只是防君子不防小人,做个简单的异或加密就够了。
7.2 双串口备份升级
工业现场串口线可能松动,单串口升级失败率不低。有条件的话用两个串口,主串口失败自动切备用串口。或者用CAN总线升级,抗干扰能力比串口强很多。F103C8T6自带CAN控制器,加个收发器就能用。
7.3 升级超时与看门狗
Bootloader等待升级请求要有超时,不能无限等。我设的是上电后3秒内没收到升级请求就跳APP。等待期间喂独立看门狗(IWDG),防止Bootloader自身死机。跳转前记得关看门狗,否则APP里没及时喂狗会不断复位。
APP里也要处理升级标志的清除。如果APP启动后确认运行正常,应该主动清除参数区的升级请求标志,防止下次上电又进Bootloader。这个清除操作放在APP的main函数里,延时几秒后执行,确保系统稳定了再清。
7.4 批量生产时的烧录策略
产线上先用SWD烧Bootloader,然后通过串口批量烧APP。Bootloader里可以加个命令,收到后返回芯片唯一ID和当前固件版本,上位机记录到数据库。这样每台设备的固件版本可追溯,售后维护时能快速判断是否需要升级。
Bootloader本身也要留升级通道。我的做法是Bootloader里包含一个“自升级”命令,收到后把新Bootloader写到参数区旁边的保留页,然后跳转到保留页执行,由保留页把新Bootloader写到0x08000000。这个过程比较复杂,量不大的话直接返厂用SWD重烧更省事。
整体调下来,STM32F103C8T6的串口IAP并不复杂,核心就是Flash分区、VTOR重映射、串口协议这三块。代码量不大,Bootloader大概500行左右,APP改动就两行。但细节坑很多,尤其是中断和跳转部分,建议先在最小系统板上把流程跑通,再往实际项目里移植。我前后做了三版,第一版跳转就HardFault,第二版串口丢包严重,第三版才稳定下来。把这些问题都踩一遍之后,后面再做其他型号的IAP就轻车熟路了。