简介:这份源码包面向使用STM32F103C8T6进行固件远程升级的嵌入式开发者,聚焦串口IAP(在应用编程)方案的完整实现,解决产品出厂后无法通过串口更新固件、需拆机烧录的痛点。包内共430个文件,以86个h头文件与86个c源文件构成核心驱动与应用逻辑,48个s汇编文件、18个asm及24个lsl、18个ld链接脚本负责启动与内存布局,另有uvproj、uvopt等Keil工程文件及bin、hex固件镜像,压缩包约2.23MB,结构完整可直接编译验证。资源配套详细说明文档与博客指引,涵盖IAP跳转、中断向量重映射、APP分区与串口协议等关键环节,便于读者对照源码理解升级流程并移植到自有项目。目前已有5135人学习下载,适合具备一定STM32基础、希望掌握串口IAP升级方案的工程师参考实践。
1. 串口IAP升级固件:为什么它是量产设备最省心的自救通道
做过量产硬件的工程师大概都遇到过这种场景:设备已经装到现场机柜里,突然发现固件里有个逻辑漏洞,拆机接调试器重新烧录根本不现实。这时候如果设备支持串口IAP(In-Application Programming),只需要一根USB转TTL线,发个升级命令,几分钟就能把新固件推上去。STM32F103C8T6这颗芯片虽然资源有限——64KB Flash、20KB RAM,但用来实现一套可靠的串口IAP升级机制完全够用。
这个方案解决的核心问题是:让MCU在不依赖外部调试器的情况下,通过已有的串口通信链路完成固件自更新。适合谁?做工业采集终端、串口屏控制器、传感器节点的嵌入式开发者,尤其是那些设备部署后不方便物理接触的项目。整套源码包通常包含Bootloader工程和APP工程两部分,Bootloader负责接收新固件并写入Flash,APP负责业务逻辑并通过串口触发升级流程。下面从Flash分区规划开始,一步步把这条路走通。
2. Flash分区与中断向量表重映射:IAP的地基怎么打
2.1 为什么必须做分区,不做会怎样
STM32F103C8T6的Flash起始地址是0x08000000,CPU上电后从这儿取第一条指令。如果只有一个APP工程,它默认就占在0x08000000开始的位置。IAP的本质是:在0x08000000放一段永远不升级的Bootloader代码,把APP挪到后面的地址去。Bootloader先跑,判断是否需要升级——需要就接收新固件写入APP区,不需要就跳转到APP区执行。
分区方案直接影响可用空间。C8T6总共64KB Flash,常见做法是Bootloader占前12KB(0x08000000~0x08002FFF),APP从0x08003000开始,可用空间52KB。如果Bootloader功能简单,也可以压到8KB,给APP留56KB。我一般会留12KB,因为Bootloader里要放串口收发、Flash擦写、CRC校验、超时处理这些逻辑,8KB有时候会紧张。
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 12KB | 接收固件、擦写Flash、跳转APP |
| APP | 0x08003000 | 52KB | 业务逻辑、触发升级 |
| 参数区 | 0x0800FC00 | 1KB | 存储升级标志、固件版本号 |
参数区放在Flash最后一页,用来存升级标志位。为什么需要这个标志?因为Bootloader每次上电都要判断“这次是正常启动还是要去接收新固件”。如果APP收到升级命令后直接跳回Bootloader,Bootloader怎么知道该进升级模式?靠的就是参数区里写一个魔术字。
2.2 中断向量表重映射的代码怎么写
APP从0x08003000开始执行后,中断向量表默认还指向0x08000000。如果不重映射,任何中断触发后CPU都会跳到Bootloader的向量表里去,程序直接跑飞。解决办法是在APP的main函数开头调用NVIC_SetVectorTable,把向量表偏移设到APP区的起始地址。
// APP工程 main.c 开头,必须在任何中断使能之前调用 #include "stm32f10x.h" #define APP_START_ADDR 0x08003000 // APP区起始地址 #define VECT_TAB_OFFSET 0x3000 // 相对于0x08000000的偏移量 int main(void) { // 重映射中断向量表到APP区 // 参数1:向量表基址,F103固定为FLASH_BASE // 参数2:偏移量,必须是0x200的整数倍 NVIC_SetVectorTable(NVIC_VECTTABLE_FLASH, VECT_TAB_OFFSET); // 此后所有中断都从APP区的向量表取地址 SystemInit(); // ... 业务初始化 }NVIC_SetVectorTable的第二个参数是偏移量,单位是字节,必须对齐到0x200边界。0x3000是0x200的整数倍,没问题。如果APP起始地址改成0x08004000,偏移量就写0x4000。这个值要和Bootloader里的跳转地址、Keil里的ROM起始地址三处保持一致,任何一处对不上都会导致中断响应异常。
2.3 Keil工程里必须改的两个配置
代码里改了向量表还不够,编译工具链也要知道APP的起始地址变了。在Keil MDK里打开APP工程的Options for Target,找到Target标签页:
第一处是IROM1的Start地址,从默认的0x08000000改成0x08003000,Size从0x10000改成0x0D000(52KB)。这告诉链接器:代码从0x08003000开始链接,可用空间到0x0800FFFF。
第二处是Linker标签页,取消勾选“Use Memory Layout from Target Dialog”,然后在Scatter File里指定或者手动确认链接脚本。大多数情况下改完IROM1就够了,Keil会自动生成对应的分散加载描述。
注意:改完IROM1后,编译输出的hex文件起始地址会变成0x08003000。Bootloader在接收固件时,要按这个地址来写Flash,不能假设从0x08000000开始。
3. Bootloader接收固件并写Flash:从串口数据到可执行代码
3.1 通信协议怎么定才不容易翻车
Bootloader和上位机之间的通信协议不需要多复杂,但必须解决三个问题:怎么判断一帧数据的开始、怎么校验数据完整性、怎么确认写入成功。我一般用这样的帧格式:
| 字节位置 | 内容 | 说明 |
|---|---|---|
| 0 | 帧头0xAA | 固定值 |
| 1 | 帧头0x55 | 固定值 |
| 2 | 命令字 | 0x01=开始升级,0x02=数据传输,0x03=结束升级 |
| 3~4 | 数据长度 | 小端格式,最大1024 |
| 5~N | 数据载荷 | 固件数据或参数 |
| N+1~N+2 | CRC16校验 | 对命令字到数据载荷的校验 |
帧头用两个字节0xAA 0x55,比单字节帧头更不容易误触发。CRC16校验覆盖命令字、长度和数据载荷,接收方算完CRC和帧里的比对,不一致就丢弃整帧并回NAK。这个协议在115200波特率下跑,每帧1KB数据,传输52KB固件大约需要5~6秒,加上Flash擦写时间,整体在10秒以内。
3.2 Flash擦写的关键代码和等待策略
STM32F103的Flash擦除最小单位是页,每页1KB。写数据前必须先擦除对应页,擦除后所有位变成0xFF,然后才能按半字(16位)写入。下面这段代码是Bootloader里最核心的Flash写入函数:
// Flash写入函数,addr必须是2字节对齐,data是半字数组 // len是半字个数,返回0表示成功 #include "stm32f10x_flash.h" #define APP_START_ADDR 0x08003000 #define PAGE_SIZE 1024 int flash_write(uint32_t addr, uint16_t *data, uint32_t len) { uint32_t i; uint32_t page_addr; // 解锁Flash FLASH_Unlock(); // 逐页擦除:先算出要写的数据跨越哪些页 // 擦除操作会整页清零,所以必须按页边界处理 for (page_addr = addr & ~(PAGE_SIZE - 1); page_addr < addr + len * 2; page_addr += PAGE_SIZE) { // 如果该页不是全0xFF,先擦除 if (*(volatile uint16_t *)page_addr != 0xFFFF) { FLASH_ErasePage(page_addr); } } // 逐半字写入 for (i = 0; i < len; i++) { // FLASH_ProgramHalfWord返回FLASH_Status枚举 if (FLASH_ProgramHalfWord(addr + i * 2, data[i]) != FLASH_COMPLETE) { FLASH_Lock(); return -1; // 写入失败 } // 等待写入完成,F103的Flash操作需要时间 while (FLASH_GetBank1Status() == FLASH_BUSY); } FLASH_Lock(); return 0; }这段代码有几个容易踩坑的地方。第一,擦除判断用*(volatile uint16_t *)page_addr != 0xFFFF来检查该页是否已经擦过,避免重复擦除浪费时间。第二,FLASH_ProgramHalfWord之后必须等FLASH_GetBank1Status不再是BUSY,否则连续写入会失败。第三,写入地址必须2字节对齐,如果上位机发来的数据长度是奇数,最后一个字节要特殊处理——通常的做法是补0xFF凑成半字再写。
3.3 跳转到APP的完整流程
固件接收完毕、CRC校验通过后,Bootloader要把控制权交给APP。跳转不是简单的函数调用,需要做几件事:关中断、设栈顶指针、设PC指针。
// 跳转到APP区执行 // app_addr是APP起始地址,比如0x08003000 typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { pFunction jump_func; uint32_t stack_top; // 1. 检查APP区第一个字(栈顶地址)是否合法 // 栈顶应该在SRAM范围内:0x20000000 ~ 0x20005000 stack_top = *(volatile uint32_t *)app_addr; if (stack_top < 0x20000000 || stack_top > 0x20005000) { return; // APP区没有有效固件,不跳转 } // 2. 关闭所有中断,防止跳转过程中触发中断 __disable_irq(); // 3. 清除所有中断挂起位 for (int i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } // 4. 设置主栈指针 __set_MSP(stack_top); // 5. 取复位向量地址(APP区第二个字),跳转执行 jump_func = (pFunction)(*(volatile uint32_t *)(app_addr + 4)); jump_func(); }栈顶检查这一步很关键。如果APP区是空的(全0xFF),栈顶值就是0xFFFFFFFF,直接跳过去必死。加上范围判断后,Bootloader发现APP区无效就停在升级模式等待,不会跑飞。__set_MSP设置主栈指针后,CPU的SP就指向APP的栈空间了。最后从APP区第二个字取出复位向量地址并调用,APP就开始跑了。
4. APP端触发升级与固件传输:把控制权交回去
4.1 APP怎么收到升级命令并写标志位
APP在正常运行时,串口中断里会收到上位机发来的升级请求。这个请求通常是一个特定命令,比如0x5A开头的一串数据。APP收到后不能直接跳Bootloader,因为跳转前需要把“下次启动进升级模式”这个信息存到参数区。
// APP端收到升级命令后的处理 #define PARAM_ADDR 0x0800FC00 // 参数区地址 #define UPGRADE_MAGIC 0x5A5A1234 // 升级魔术字 void enter_upgrade_mode(void) { // 1. 解锁Flash FLASH_Unlock(); // 2. 擦除参数区所在页 FLASH_ErasePage(PARAM_ADDR); // 3. 写入升级魔术字 FLASH_ProgramWord(PARAM_ADDR, UPGRADE_MAGIC); // 4. 写入固件长度(可选,用于Bootloader校验) FLASH_ProgramWord(PARAM_ADDR + 4, firmware_size); FLASH_Lock(); // 5. 关中断,跳转到Bootloader __disable_irq(); // 跳转前先设置MSP为Bootloader的栈顶 __set_MSP(*(volatile uint32_t *)0x08000000); // 取Bootloader复位向量并执行 ((pFunction)(*(volatile uint32_t *)(0x08000000 + 4)))(); }参数区在0x0800FC00,属于Flash最后一页。写入前必须擦除整页,因为Flash只能把1写成0,不能把0写回1。擦除后写入魔术字0x5A5A1234,Bootloader上电后读这个地址,如果值等于魔术字就进升级模式,否则跳APP。
4.2 上位机发送固件的脚本怎么写
上位机这边用Python写一个发送脚本最方便,不需要额外装驱动,pyserial库跨平台都能跑。核心逻辑是:读hex文件、解析成二进制、按帧打包、逐帧发送并等待ACK。
import serial import struct import time # 打开串口,波特率115200 ser = serial.Serial('COM3', 115200, timeout=2) def send_frame(cmd, data=b''): """发送一帧数据,返回True表示收到ACK""" frame = b'\xAA\x55' + bytes([cmd]) frame += struct.pack('<H', len(data)) # 小端长度 frame += data # CRC16计算(简化版,实际用标准CRC16-CCITT) crc = 0xFFFF for b in frame[2:]: crc ^= b << 8 for _ in range(8): crc = (crc << 1) ^ 0x1021 if crc & 0x8000 else crc << 1 crc &= 0xFFFF frame += struct.pack('<H', crc) ser.write(frame) # 等待ACK(0x06表示成功) resp = ser.read(1) return resp == b'\x06' # 读取bin文件(Keil编译后用fromelf生成) with open('app.bin', 'rb') as f: firmware = f.read() # 发送开始升级命令 if not send_frame(0x01, struct.pack('<I', len(firmware))): print("开始升级失败") exit(1) # 分帧发送固件数据,每帧1024字节 offset = 0 while offset < len(firmware): chunk = firmware[offset:offset+1024] if not send_frame(0x02, chunk): print(f"第{offset//1024}帧发送失败") exit(1) offset += len(chunk) time.sleep(0.01) # 给MCU留出Flash写入时间 # 发送结束命令 if send_frame(0x03): print("升级成功") else: print("升级失败")这个脚本里time.sleep(0.01)是给MCU擦写Flash留时间。F103擦一页大约20~40ms,如果连续发帧不等待,Bootloader还在擦上一页,新数据就丢了。实际调试时如果发现丢帧,把延时加到0.05秒再试。
4.3 固件格式转换:从axf到bin
Keil编译默认输出axf格式,里面包含调试信息和地址标记,不能直接烧到Flash。需要转成纯二进制bin文件。Keil自带fromelf工具,在Options for Target的User标签页里加一条命令:
fromelf --bin --output=app.bin ./Objects/app.axf这条命令把axf转成bin,bin文件里就是从0x08003000开始的纯机器码。注意bin文件不包含地址信息,Bootloader接收后直接按顺序写到APP区起始地址即可。如果APP工程里用了分散加载指定了多个不连续的区域,bin文件会按地址顺序拼接,中间空洞用0填充,这种情况要确认Bootloader的写入逻辑能正确处理。
5. 避坑指南:串口IAP升级中最容易翻车的五个点
5.1 跳转后不进中断,程序像死了一样
现象:Bootloader跳转到APP后,APP的main函数能跑,串口能发数据,但一开定时器中断就卡死。
原因:APP的中断向量表没有重映射,或者重映射的偏移量和实际APP起始地址不一致。中断触发后CPU去0x08000000取向量,取到的是Bootloader的向量表,跳到了错误的中断服务函数。
解决:确认APP的main函数第一行调用了NVIC_SetVectorTable(NVIC_VECTTABLE_FLASH, 0x3000),且Keil的IROM1起始地址也是0x08003000。两个地址必须严格对应。
5.2 升级到一半断电,设备变砖
现象:固件传输过程中断电,重新上电后Bootloader还在,但APP区是半截固件,跳转后跑飞。
原因:Bootloader没有做固件完整性校验,只要收到数据就跳转。
解决:在参数区里存一个“固件有效”标志。Bootloader接收完所有数据后,先算一遍CRC,和上位机发来的CRC比对,一致才写有效标志。上电时检查这个标志,无效就停在升级模式。另外可以在APP区头部放一个版本号和固件长度,跳转前校验这些字段。
5.3 串口接收丢数据,固件写入不完整
现象:升级过程中偶尔报CRC错误,重试几次又能成功。
原因:Bootloader在擦写Flash时会关闭中断或者阻塞串口接收,如果上位机发得太快,数据就丢了。
解决:Bootloader的串口接收用DMA+空闲中断,数据先存到RAM缓冲区,Flash写入在缓冲区满或者收到结束命令后统一处理。如果RAM不够,至少保证每帧数据接收完整后再擦写。上位机侧加延时和重传机制。
5.4 参数区写入后下次上电不生效
现象:APP写了升级标志,但重新上电后Bootloader还是直接跳APP,不进升级模式。
原因:参数区地址和Bootloader读取的地址不一致,或者写入前没有擦除导致写入失败。
解决:确认参数区地址在链接脚本里没有被APP占用。C8T6的Flash最后一页是0x0800FC00~0x0800FFFF,APP的IROM1 Size要设成0x0CC00(从0x08003000到0x0800FBFF),把最后一页留出来。写入前必须FLASH_ErasePage,擦除后再写。
5.5 升级后APP能跑但串口不通
现象:升级成功,APP正常执行,但串口收发异常。
原因:APP工程里串口的初始化代码被Bootloader残留的寄存器状态影响,或者APP的串口引脚配置和Bootloader冲突。
解决:APP的串口初始化里加上USART_DeInit,把串口寄存器复位到默认值再重新配置。另外检查Bootloader跳转前有没有把串口关闭,如果Bootloader开着串口中断就跳转,APP里再初始化可能产生冲突。跳转前调用USART_Cmd(USART1, DISABLE)和USART_ITConfig关掉所有串口中断。
6. 进阶技巧:用双区备份和版本回滚把升级风险降到最低
前面讲的方案是单区升级,APP区被覆盖后如果新固件有问题,只能再升一次。对于现场设备来说,升级失败意味着设备离线,代价很高。一个更稳妥的做法是在Flash里划两个APP区:APP_A和APP_B,Bootloader根据标志位决定从哪个区启动。
C8T6的64KB Flash做双区比较紧张,但可以压缩Bootloader到8KB,两个APP区各24KB,参数区1KB,还剩7KB做备份区。24KB对于功能不太复杂的采集终端够用。升级流程变成:当前跑APP_A,收到升级命令后把新固件写到APP_B,写完后在参数区标记“下次从APP_B启动”,然后重启。Bootloader读标志位跳APP_B。如果APP_B跑不起来(比如连续重启三次),Bootloader自动回滚到APP_A。
版本回滚的关键是参数区里存一个启动计数器。每次Bootloader跳转前把计数器加一,APP启动成功后在第一秒内把计数器清零。如果Bootloader发现计数器超过3,说明APP反复重启,就切回上一个可用区。
// 参数区结构体定义 typedef struct { uint32_t upgrade_magic; // 升级魔术字 uint32_t active_partition; // 当前活动分区:0=APP_A,1=APP_B uint32_t boot_count; // 启动计数器 uint32_t firmware_crc; // 当前固件CRC uint8_t reserved[240]; // 保留 } param_t; // Bootloader启动逻辑 void bootloader_main(void) { param_t *param = (param_t *)PARAM_ADDR; // 检查升级标志 if (param->upgrade_magic == UPGRADE_MAGIC) { // 进入升级模式,接收新固件 receive_firmware(); param->upgrade_magic = 0; param->active_partition = !param->active_partition; param->boot_count = 0; return; } // 启动计数器递增 param->boot_count++; // 超过3次说明APP有问题,回滚 if (param->boot_count > 3) { param->active_partition = !param->active_partition; param->boot_count = 0; } // 根据活动分区跳转 if (param->active_partition == 0) { jump_to_app(APP_A_ADDR); } else { jump_to_app(APP_B_ADDR); } }这个结构体占256字节,放在参数区一页里绰绰有余。boot_count在APP启动成功后要清零,通常放在APP的main函数初始化完成后。如果APP在初始化阶段就卡死,计数器不会清零,Bootloader下次上电就会回滚。
双区方案还有一个好处:升级过程中断电不会导致设备完全不可用。因为旧固件还在另一个区里,Bootloader检测到新固件不完整就继续从旧区启动。代价是Flash空间减半,需要根据实际固件大小权衡。
我自己在项目里踩过最深的坑是参数区地址和APP区重叠。当时APP的IROM1 Size设成了0x0D000,算下来结束地址是0x0800FFFF,正好把参数区覆盖了。APP一运行就擦写了参数区,导致升级标志丢失。后来把Size改成0x0CC00,留出最后一页,问题才解决。每次改分区方案,一定要拿计算器把地址算清楚,别凭感觉。
希望帮到你。
本文还有配套的精品资源,点击获取