简介这份源码包面向使用STM32F103C8T6进行产品开发或嵌入式学习的工程师聚焦串口IAP在线升级固件这一常见需求帮助读者在资源受限的Cortex-M3平台上搭建可远程更新程序的Bootloader与App框架。压缩包共430个文件约2.23MB以86个h头文件与86个c源文件为核心配合48个s汇编、18个ld链接脚本、24个lsl、18个asm启动代码及多套工程配置uvproj、cproject、ewp等覆盖Keil、IAR、GCC等多种工具链另含bin、hex、map等编译产物便于直接验证。资源围绕串口通信、Flash分区、跳转与校验等关键环节组织读者可据此理解IAP升级的完整链路掌握固件搬运、中断向量重映射与升级失败回滚等排错思路并对照博客文章完成移植与调试。目前已有5135人学习下载适合具备一定STM32基础、希望补齐Bootloader实战经验的开发者参考。1. 串口IAP升级固件为什么它是量产设备最省心的“后悔药”手里有一块 STM32F103C8T6 的最小系统板焊在设备里之后才发现固件有 bug拆机接 SWD 又费时费力——这是很多做小家电、工控模块、传感器节点的工程师都遇到过的场景。串口 IAPIn-Application Programming就是解决这个问题的让 MCU 在运行状态下通过 UART 接收新固件并写入自身 Flash不用拆机、不用仿真器一根串口线就能完成升级。它适合谁适合那些设备已经部署到现场、外壳封死、但需要远程或本地维护固件的开发者。核心难点不在“能不能写 Flash”而在分区规划、跳转逻辑、中断向量表重定向和传输可靠性这四件事上。把这四件事做对串口 IAP 就是量产设备最省心的后悔药做错一件升级变砖的概率就会直线上升。2. 分区规划与 Bootloader 最小系统先把 Flash 地图画清楚2.1 为什么 STM32F103C8T6 的 64KB Flash 要精打细算STM32F103C8T6 的 Flash 只有 64KBSRAM 20KB。这个资源量做 IAP 并不宽裕所以第一件事不是写代码而是画 Flash 地图。常见做法是把 Flash 分成三段Bootloader 区、APP 区、参数/备份区。Bootloader 负责上电判断、接收固件、擦写 APP 区、跳转APP 区放业务代码参数区存升级标志、固件长度、CRC 校验值。我一般会这样分区域起始地址大小用途Bootloader0x0800000012KB启动判断、串口接收、Flash 擦写APP0x0800300044KB业务固件参数区0x0800E0004KB升级标志、固件长度、CRC保留0x0800F0004KB预留防止越界这里有个关键点APP 区的起始地址必须是 Flash 页大小的整数倍。STM32F103C8T6 的 Flash 页大小是 1KB所以 0x08003000 是合法的。Bootloader 给 12KB 是因为串口接收缓冲、CRC 计算、Flash 操作函数加起来差不多这个量级留太少后面加功能会捉襟见肘。2.2 Bootloader 的最小可运行框架Bootloader 不需要 RTOS裸机跑一个状态机就够了。核心流程是上电 → 读参数区升级标志 → 如果标志有效则进入接收模式 → 否则检查 APP 区是否有效 → 跳转 APP。// bootloader main.c 核心逻辑 #include stm32f10x.h #include flash_if.h #include uart_if.h #define APP_ADDR 0x08003000 #define PARAM_ADDR 0x0800E000 #define UPGRADE_FLAG 0x5A5A5A5A typedef struct { uint32_t flag; uint32_t fw_len; uint32_t fw_crc; } param_t; int main(void) { param_t param; flash_read(PARAM_ADDR, (uint8_t *)param, sizeof(param)); if (param.flag UPGRADE_FLAG) { // 进入升级模式等待串口数据 uart_init(115200); if (iap_receive_firmware() 0) { // 升级成功清除标志 param.flag 0; flash_write(PARAM_ADDR, (uint8_t *)param, sizeof(param)); } } // 校验 APP 区栈顶指针是否合法 uint32_t app_stack *(volatile uint32_t *)APP_ADDR; if ((app_stack 0x2FFE0000) 0x20000000) { jump_to_app(APP_ADDR); } while (1); // 兜底不应该到这里 }这段代码的逻辑说明先读参数区判断是否有升级请求如果有进入串口接收流程升级完成后清除标志。然后校验 APP 区第一个字栈顶指针是否落在 SRAM 范围0x20000000 起始合法才跳转。参数说明UPGRADE_FLAG是一个魔数避免 Flash 空白时误判app_stack 0x2FFE0000是检查高 14 位是否为 0x2000这是 STM32 判断 APP 是否有效的常用手法。2.3 跳转 APP 前必须做的三件事跳转不是一句函数指针调用就完事。血泪经验是跳转前必须关中断、复位外设、设置主栈指针。少做一件APP 跑起来就可能 HardFault。// 跳转到 APP 的完整实现 typedef void (*app_func_t)(void); void jump_to_app(uint32_t app_addr) { uint32_t jump_addr; app_func_t app_entry; // 1. 关闭所有中断 __disable_irq(); // 2. 复位 SysTick 和所有外设 SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; RCC-APB1ENR 0; RCC-APB2ENR 0; // 3. 设置主栈指针为 APP 区第一个字 __set_MSP(*(volatile uint32_t *)app_addr); // 4. 取复位向量地址并跳转 jump_addr *(volatile uint32_t *)(app_addr 4); app_entry (app_func_t)jump_addr; app_entry(); }逻辑说明__set_MSP把主栈指针指向 APP 区第一个字这是 APP 编译时链接脚本决定的栈顶。app_addr 4是复位向量地址因为 Cortex-M3 的向量表前两个字分别是栈顶和复位入口。参数说明app_addr就是 0x08003000必须和 APP 的链接脚本一致。注意RCC-APB1ENR 0会关掉所有外设时钟如果 APP 里重新初始化外设这是安全的但如果 APP 依赖 Bootloader 留下的外设状态就不能这么干。3. APP 端改造中断向量表重定向与链接脚本3.1 为什么 APP 必须重定向向量表STM32F103C8T6 的向量表默认在 0x08000000。APP 被放到 0x08003000 后如果中断发生时 CPU 还去 0x08000000 取向量就会跳到 Bootloader 的中断服务函数结果要么跑飞要么 HardFault。所以 APP 启动后第一件事就是重定向向量表。// APP main.c 开头必须做的事 #include stm32f10x.h #define VECT_TAB_OFFSET 0x3000 // 相对于 0x08000000 的偏移 int main(void) { // 重定向向量表到 APP 区 NVIC_SetVectorTable(NVIC_VectTab_FLASH, VECT_TAB_OFFSET); // 使能全局中断 __enable_irq(); // 后续业务初始化 uart_init(115200); while (1) { // 业务逻辑 } }逻辑说明NVIC_SetVectorTable是标准库函数内部写 SCB-VTOR 寄存器。参数说明NVIC_VectTab_FLASH表示向量表在 Flash 中VECT_TAB_OFFSET是相对于 0x08000000 的偏移这里 APP 在 0x08003000所以偏移是 0x3000。这个值必须和 Bootloader 里的 APP_ADDR 一致否则中断会跳错地方。3.2 Keil 链接脚本怎么改光改代码不够链接脚本也要改否则编译出来的 APP 还是从 0x08000000 开始链接烧进去直接覆盖 Bootloader。在 Keil MDK 中打开 Options for Target → Target 选项卡把 IROM1 的 Start 改成 0x08003000Size 改成 0xB00044KB。或者在分散加载文件里写; APP 的分散加载文件 app.sct LR_IROM1 0x08003000 0x0000B000 { ER_IROM1 0x08003000 0x0000B000 { *.o (RESET, First) *(InRoot$$Sections) .ANY (RO) } RW_IRAM1 0x20000000 0x00005000 { .ANY (RW ZI) } }逻辑说明LR_IROM1是加载域起始地址 0x08003000大小 0xB000。ER_IROM1是执行域地址相同。RW_IRAM1是 RAM 区20KB 的 SRAM 用 0x5000 表示。参数说明Size 0xB000 是 44KB因为 64KB 减去 12KB Bootloader 再减去 4KB 参数区再减去 4KB 保留剩下 44KB。如果 APP 编译后超过这个大小链接会报错这时候要么压缩代码要么重新规划分区。3.3 串口接收固件的协议设计Bootloader 接收固件不能裸收必须有协议保证完整性。常见做法是自定义一个简单帧格式帧头 长度 数据 CRC。// 串口接收状态机 typedef enum { STATE_IDLE, STATE_HEADER, STATE_LEN, STATE_DATA, STATE_CRC } rx_state_t; #define FRAME_HEADER 0xAA55 #define MAX_FRAME 1024 int iap_receive_firmware(void) { uint8_t buf[MAX_FRAME]; uint16_t len, crc, recv_crc; uint32_t total 0; while (1) { // 接收帧头 if (uart_recv_byte_timeout(header, 1000) ! 0) break; if (header ! FRAME_HEADER) continue; // 接收长度 uart_recv_bytes((uint8_t *)len, 2, 1000); if (len MAX_FRAME) continue; // 接收数据 uart_recv_bytes(buf, len, 5000); // 接收 CRC uart_recv_bytes((uint8_t *)recv_crc, 2, 1000); crc crc16(buf, len); if (crc ! recv_crc) { uart_send_nack(); continue; } // 写入 Flash flash_write(APP_ADDR total, buf, len); total len; uart_send_ack(); // 判断是否接收完毕由上位机发送结束帧 if (len 0) break; } // 校验整个固件 CRC return iap_verify_firmware(total); }逻辑说明状态机逐字节接收先找帧头 0xAA55再读长度再读数据最后校验 CRC16。每帧写入 Flash 后回 ACK上位机收到 ACK 才发下一帧。参数说明MAX_FRAME设为 1024是因为 STM32F103C8T6 的 RAM 只有 20KB缓冲区不能太大uart_recv_byte_timeout的超时时间 1000ms 是等待帧头的超时5000ms是等待数据的超时实际项目中可以根据波特率调整。注意 Flash 写入前必须先擦除STM32F103C8T6 的 Flash 擦除是按页1KB进行的所以写入地址要按页对齐。4. 避坑与排查串口 IAP 最容易翻车的五个地方4.1 跳转后 APP 不运行卡在 HardFault现象Bootloader 跳转后APP 没有任何输出调试器显示 HardFault。原因最常见的是向量表没重定向或者跳转前没关中断导致中断触发时跳到 Bootloader 的向量表。解决确认 APP 的NVIC_SetVectorTable在main第一行调用确认跳转前__disable_irq()已执行确认 APP 链接脚本的起始地址和 Bootloader 里的APP_ADDR一致。4.2 升级过程中断电设备变砖现象升级到一半断电重新上电后 Bootloader 还在但 APP 区是半截固件跳转后跑飞。原因没有备份机制APP 区被擦除后新固件没写完。解决在参数区加一个“升级中”标志上电时如果发现这个标志有效就停留在 Bootloader 等待重新升级不跳转 APP。另外可以保留一个备份区升级前先把旧固件备份失败后回滚。4.3 串口接收丢数据CRC 校验频繁失败现象上位机发送固件Bootloader 经常回 NACK或者接收到的数据长度不对。原因波特率太高比如 115200 以上时Bootloader 在擦写 Flash 期间无法及时接收串口数据导致溢出。解决擦写 Flash 时先关闭串口接收中断擦完再开或者降低波特率到 57600或者用 DMA 接收但 STM32F103C8T6 的 DMA 通道有限需要权衡。我一般会在 Flash 擦除期间让上位机等待擦除完成后发一个“就绪”信号再继续。4.4 APP 区大小超限链接报错现象Keil 编译 APP 时报 “region IROM1 overflowed”。原因APP 代码量超过了分区规划的大小。解决先看 map 文件找出占用最大的模块如果是标准库或 HAL 库占用大可以换成 LL 库或直接寄存器操作如果是业务代码大考虑压缩分区比如把 Bootloader 从 12KB 减到 8KB参数区从 4KB 减到 2KB。但要注意 Bootloader 太小可能放不下串口协议和 Flash 操作函数。4.5 升级成功后 APP 不启动但手动复位可以现象升级完成后Bootloader 跳转 APP 失败但按复位键后 APP 能正常运行。原因升级完成后没有清除升级标志或者清除标志后没有重新校验 APP 区。解决升级成功后先写参数区清除标志再软复位NVIC_SystemReset()让 Bootloader 重新走一遍上电流程。不要直接跳转因为跳转前的环境可能已经被升级流程污染。5. 进阶技巧用 CRC32 校验和双备份把升级成功率拉到 99%5.1 为什么 CRC16 不够什么时候该上 CRC32CRC16 在固件小于 32KB 时碰撞概率已经很低但 STM32F103C8T6 的 APP 区最大 44KB加上参数区和备份区总数据量接近 64KB。如果对可靠性要求极高比如医疗设备或工业控制器建议上 CRC32。CRC32 的计算量比 CRC16 大但在 72MHz 的 STM32F103C8T6 上44KB 数据的 CRC32 计算时间大约几十毫秒完全可以接受。// CRC32 查表法实现片段 static const uint32_t crc32_table[256] { ... }; uint32_t crc32_calc(const uint8_t *data, uint32_t len) { uint32_t crc 0xFFFFFFFF; while (len--) { crc (crc 8) ^ crc32_table[(crc ^ *data) 0xFF]; } return crc ^ 0xFFFFFFFF; }逻辑说明CRC32 查表法用 256 个 32 位表项每次处理一个字节。参数说明初始值 0xFFFFFFFF最终结果异或 0xFFFFFFFF这是标准 CRC32 算法。表可以放在 Flash 里不占 RAM。5.2 双备份升级A/B 分区怎么划如果 Flash 够大可以做 A/B 双备份。STM32F103C8T6 只有 64KB做双备份比较紧张但可以这样分Bootloader 8KBAPP A 区 24KBAPP B 区 24KB参数区 4KB保留 4KB。升级时先写 B 区写完后校验校验通过后把参数区的“当前运行区”指向 B 区然后复位。如果 B 区校验失败继续用 A 区。这样即使升级失败设备也不会变砖。区域起始地址大小用途Bootloader0x080000008KB启动判断、串口接收APP A0x0800200024KB当前运行固件APP B0x0800800024KB升级备份固件参数区0x0800E0004KB运行区标志、CRC保留0x0800F0004KB预留这种分区的代价是 APP 最大只能 24KB对代码量大的项目不友好。所以双备份适合业务逻辑简单、但可靠性要求高的场景。5.3 上位机发送端的配合技巧Bootloader 做得再好上位机拖后腿也会翻车。我一般会在上位机做三件事第一每帧数据之间加 10ms 延时给 Bootloader 留出 Flash 擦写时间第二收到 NACK 后重发当前帧最多重试 3 次第三整个固件发送完成后等待 Bootloader 回一个“升级成功”的确认帧再关闭串口。上位机可以用 Python 写用 pyserial 库代码量不大但能显著提升升级成功率。# 上位机发送固件的核心逻辑Python import serial import time import struct def send_firmware(port, firmware_path): ser serial.Serial(port, 115200, timeout2) with open(firmware_path, rb) as f: data f.read() total_len len(data) offset 0 while offset total_len: chunk data[offset:offset1024] frame struct.pack(H H, 0xAA55, len(chunk)) chunk crc crc16(chunk) frame struct.pack(H, crc) ser.write(frame) # 等待 ACK ack ser.read(1) if ack b\x06: offset len(chunk) else: time.sleep(0.01) # 重试前延时 time.sleep(0.01) # 帧间延时 # 发送结束帧 ser.write(struct.pack(H H H, 0xAA55, 0, 0)) print(升级完成)逻辑说明每帧 1024 字节帧头 0xAA55长度 2 字节数据CRC16 2 字节。收到 ACK0x06后偏移量增加否则延时后重发。参数说明timeout2是串口读超时time.sleep(0.01)是帧间延时实际项目中可以根据 Bootloader 的擦写速度调整。注意 Python 的struct.pack默认小端序和 STM32 一致。5.4 一个我踩过的坑Flash 擦除时间被低估STM32F103C8T6 的 Flash 擦除一页1KB典型时间是 20ms最大 40ms。如果 Bootloader 在擦除期间还开着串口接收中断上位机发来的数据就会丢失。我一开始没注意上位机连续发数据Bootloader 擦一页丢几帧CRC 频繁失败。后来改成擦除前发一个“忙”信号上位机收到后暂停发送擦除完成后再发“就绪”信号问题才解决。这个细节在数据手册里有写但很容易被忽略。希望帮到你。本文还有配套的精品资源点击获取