简介这份资源围绕STM32的IAP应用程序内编程技术展开面向需要实现固件在线升级的嵌入式开发者与STM32学习者。它通过UART串口通信演示双APP分区升级方案解决产品出厂后固件无法便捷更新的痛点并为进一步扩展4G、WiFi等无线OTA升级或U盘、TF卡等OTG升级提供可参考的实现思路。压缩包共272个文件以150个.h头文件与78个.c源文件为主体辅以11个bin固件、6个ld链接脚本、3个ioc配置及若干工程与启动文件整体约1.86MB结构完整便于直接编译与移植。资源内含APP0与APP1不同版本固件可直观对比双APP切换与版本管理逻辑。目前已有1722人学习下载适合具备一定STM32基础、希望掌握IAP升级流程与双APP架构的开发者参考实践。1. 从一次产线升级翻车说起STM32 UART双APP的IAP到底解决什么问题去年帮一个做工业控制器的朋友处理售后现场装了三百多台设备固件有个小 bug 需要修。设备装在配电柜里拆机接 SWD 烧录根本不现实返厂成本比硬件本身还贵。当时他们唯一的升级通道就是板子上引出的那对 UART 排针。这件事让我重新认真看了一遍手头这份 STM32 UART 双 APP 的 IAP 工程——它要解决的正是这个场景没有调试器、没有 USB、只有一根串口线怎么把新固件安全地刷进去而且刷坏了还能自己爬起来。这份资源是一个完整的 STM32 IAPIn-Application Programming工程核心特点是「双 APP」——Flash 里同时存在两个应用程序区配合 Bootloader 做搬运和切换。相比常见的单 APP IAP它多了一层回滚保险新固件校验不过或者启动失败可以退回旧版本不至于变砖。适合正在做远程升级、串口升级、需要固件容错的中小团队开发者也适合想搞懂 Bootloader 跳转、中断向量表重映射、Flash 分区这些底层细节的人。下面我按「它是什么 → 怎么跑起来 → 怎么改」的顺序把这份工程拆开讲。2. 工程结构与 Flash 分区双 APP 方案的内存布局怎么划拿到一份 IAP 工程第一件事不是看代码是看它怎么切 Flash。分区划错了后面跳转、搬运、回滚全是坑。这份工程用的是 STM32 常见的内部 Flash 方案下面按我实际拆解的顺序讲。2.1 三个固件、四块区域的划分逻辑双 APP 方案里Flash 被切成四块Bootloader 区、APP_A 区、APP_B 区、参数区。Bootloader 常驻不删负责搬运和跳转APP_A 和 APP_B 是两块等大的应用区同一时刻只有一块是「当前运行」的参数区存升级标志、固件长度、CRC 校验值这些元数据。以 STM32F103C8T664KB Flash为例一种常见的划分方式如下区域起始地址大小用途Bootloader0x0800000016KB上电启动、搬运、跳转参数区0x080040002KB升级标志、长度、CRCAPP_A0x0800480022KB应用区 AAPP_B0x0800A00022KB应用区 B这里有个血泪经验参数区一定要单独占一个扇区。STM32 的 Flash 擦除最小单位是页F103 是 1KB 或 2KB如果你把参数和 APP 放在同一页里擦 APP 的时候会把参数一起擦掉升级标志就丢了设备下次上电不知道该跑哪个 APP。我见过有人图省事把标志位塞在 APP 区末尾结果升级到一半断电标志位和固件一起废掉。2.2 链接脚本与中断向量表偏移分区定好之后两个 APP 工程都要改链接脚本把起始地址从默认的 0x08000000 挪到各自的区。以 Keil 为例在 Target 选项的 IROM1 里改 Start 地址用 GCC 的话改.ld文件/* APP_A 的链接脚本片段 */ MEMORY { FLASH (rx) : ORIGIN 0x08004800, LENGTH 22K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K }光改链接地址还不够中断向量表必须跟着搬。Cortex-M3/M4 上电默认从 0x08000000 取向量表APP 跑在 0x08004800如果不重映射一进中断就跳到 Bootloader 的向量表直接跑飞。在main()开头加这一句/* 把向量表偏移到 APP_A 的起始地址 */ SCB-VTOR 0x08004800;参数说明SCB-VTOR是向量表偏移寄存器值必须是 0x200 的整数倍向量表对齐要求。APP_B 同理改成 0x0800A000。这一步漏了现象是「主循环能跑一进串口中断就死机」很多人第一次调 IAP 都栽在这。2.3 Bootloader 的启动决策流程Bootloader 上电后干的事其实不复杂但顺序不能乱。我把它拆成四步void bootloader_main(void) { /* 1. 读参数区看有没有升级请求 */ upgrade_info_t info; flash_read(PARAM_ADDR, (uint8_t *)info, sizeof(info)); /* 2. 如果有升级请求把暂存区固件搬到目标 APP 区 */ if (info.flag UPGRADE_PENDING) { flash_copy(info.src_addr, info.dst_addr, info.length); /* 搬运完校验 CRC通过才改标志 */ if (crc32_check(info.dst_addr, info.length) info.crc) { info.flag UPGRADE_DONE; info.active_app info.dst_addr; } else { info.flag UPGRADE_FAILED; /* 校验失败保留旧 APP */ } flash_write(PARAM_ADDR, (uint8_t *)info, sizeof(info)); } /* 3. 根据 active_app 决定跳哪个区 */ uint32_t app_addr info.active_app ? info.active_app : APP_A_ADDR; /* 4. 校验 APP 栈顶指针合法再跳转 */ uint32_t sp *(uint32_t *)app_addr; if ((sp 0x2FFE0000) 0x20000000) { jump_to_app(app_addr); } /* 校验不过就停在 Bootloader等串口重刷 */ while (1); }逻辑说明第 1 步读参数第 2 步搬运并做 CRC 校验校验通过才改标志位这是回滚的关键——如果搬运中途断电标志位还是 PENDING下次上电重新搬。第 4 步校验栈顶指针是因为 APP 区可能是空的全 0xFF直接跳过去会 HardFault。(sp 0x2FFE0000) 0x20000000是判断栈顶是否落在 SRAM 范围的常用技巧。跳转函数本身也有讲究void jump_to_app(uint32_t app_addr) { typedef void (*app_func_t)(void); uint32_t jump_addr *(uint32_t *)(app_addr 4); /* 复位向量 */ app_func_t app (app_func_t)jump_addr; __disable_irq(); /* 关中断防止跳转途中被打断 */ __set_MSP(*(uint32_t *)app_addr); /* 重设主栈指针 */ SCB-VTOR app_addr; /* 重映射向量表 */ __enable_irq(); app(); /* 跳过去不再返回 */ }参数说明app_addr 4取的是复位向量地址向量表第二项第一项是栈顶。__set_MSP必须在跳转前调用否则 APP 用的是 Bootloader 的栈容易踩内存。这几行是 IAP 的「标准动作」背下来都不为过。3. 串口升级协议与上位机交互数据怎么从 PC 进到 Flash分区和跳转搞明白接下来是升级数据怎么进来。这份工程走的是 UART协议设计得比较克制没有上复杂的握手适合串口这种半双工、易丢包的通道。3.1 帧格式与命令字设计协议采用「帧头 命令 长度 数据 校验」的定长头变长体结构。常见做法是这样字段字节数说明帧头2固定 0xAA 0x55命令10x01 握手 / 0x02 擦除 / 0x03 写数据 / 0x04 校验 / 0x05 跳转长度2数据段字节数小端数据N命令相关CRC162从命令到数据的校验命令字不多但每个都有明确职责。握手命令用来确认设备在 Bootloader 里、版本匹配擦除命令带目标区地址和长度写数据命令带偏移和固件分片校验命令触发整包 CRC 比对跳转命令让设备重启进 APP。不要设计成「一条命令写完整包」串口缓冲区扛不住分片是必须的。3.2 接收状态机与超时处理串口收数据最怕两件事粘包和断包。粘包是两帧连在一起断包是一帧收了一半没了。这份工程用状态机解帧核心思路是「按字节喂状态驱动」typedef enum { STATE_HEAD1, STATE_HEAD2, STATE_CMD, STATE_LEN_L, STATE_LEN_H, STATE_DATA, STATE_CRC } rx_state_t; void uart_rx_byte(uint8_t byte) { static rx_state_t state STATE_HEAD1; static uint16_t data_idx 0; switch (state) { case STATE_HEAD1: if (byte 0xAA) state STATE_HEAD2; break; case STATE_HEAD2: state (byte 0x55) ? STATE_CMD : STATE_HEAD1; break; case STATE_CMD: rx_frame.cmd byte; state STATE_LEN_L; break; case STATE_LEN_L: rx_frame.len byte; state STATE_LEN_H; break; case STATE_LEN_H: rx_frame.len | (byte 8); data_idx 0; state (rx_frame.len 0) ? STATE_DATA : STATE_CRC; break; case STATE_DATA: rx_frame.data[data_idx] byte; if (data_idx rx_frame.len) state STATE_CRC; break; case STATE_CRC: /* 收满两字节 CRC 后校验通过则置帧就绪标志 */ if (crc16_accumulate(byte)) { frame_ready 1; } state STATE_HEAD1; break; } }逻辑说明状态机在中断里逐字节推进主循环轮询frame_ready标志处理完整帧。这样中断里只做搬运不做耗时操作避免丢字节。超时处理放在主循环如果 500ms 没收到新字节且状态不在 HEAD1就复位状态机防止半帧卡死。这个超时值我一般设 200~500ms太短会误判慢速上位机太长会让设备「假死」很久。3.3 上位机侧的发送节奏设备端状态机再稳上位机发太快照样丢。串口没有流控的情况下PC 端每发一帧要等设备回 ACK 再发下一帧这就是「停等协议」。虽然慢但可靠。实测 115200 波特率下22KB 固件大概 8~10 秒传完对现场升级完全够用。上位机发送分片的伪代码def send_firmware(ser, firmware: bytes, chunk256): total len(firmware) for offset in range(0, total, chunk): piece firmware[offset:offset chunk] frame build_frame(CMD_WRITE, offset.to_bytes(4, little) piece) ser.write(frame) ack wait_ack(ser, timeout1.0) # 等设备回 ACK if not ack: raise IOError(foffset {offset} 无应答重试)参数说明chunk256是分片大小太小协议开销大太大设备缓冲区可能溢出256 是串口升级里比较稳的经验值。wait_ack超时设 1 秒超时就重发当前片重发三次还失败就中止升级——中止时不要改标志位设备下次上电还是跑旧 APP。4. 避坑与排查双 APP IAP 最容易翻车的五个地方这部分是我调这类工程踩过的坑按「现象 → 原因 → 解决」写每条都是真金白银换来的。4.1 现象APP 能跑一进中断就死原因中断向量表没重映射。APP 链接到了 0x08004800但SCB-VTOR还是默认的 0x08000000中断发生时 CPU 去 Bootloader 的向量表取地址跳到了 Bootloader 的中断服务函数而那个函数可能根本没实现直接 HardFault。解决在 APP 的SystemInit()之后、main()之前设置SCB-VTOR APP_BASE_ADDR。注意顺序必须在使能任何中断之前。用 HAL 库的话HAL_Init()里会调SystemInit所以 VTOR 设置要放在HAL_Init()之后。4.2 现象升级到 99% 断电设备变砖原因搬运和标志位更新不是原子的。如果先擦目标 APP 区、再搬数据、最后改标志擦完断电目标区是空的标志位还指向它下次上电跳过去就是空区。解决采用「先搬后切」策略。新固件先写到暂存区比如 APP_B校验通过后再改active_app指向它旧 APP 区保留不动。这样任何时刻断电至少有一个完整 APP 可用。这份工程的双 APP 设计正是为此。4.3 现象串口升级偶尔丢几帧固件 CRC 不过原因接收中断里做了耗时操作比如收到一帧就立刻写 Flash。Flash 写入期间 CPU 可能阻塞串口 FIFO 溢出丢字节。解决中断只做收字节和状态机推进完整帧丢进环形缓冲区主循环取出来处理。写 Flash 前先关串口接收中断写完再开或者用 DMA 接收。Flash 写和串口收必须解耦这是嵌入式通信的基本纪律。4.4 现象跳转后 APP 的全局变量初值不对原因跳转前没关中断或者没重设 MSP。Bootloader 里可能开着 SysTick 中断跳过去后 SysTick 还在跑中断向量却指向 APP时序错乱。另外 MSP 没重设的话APP 的栈用的是 Bootloader 的栈空间全局变量初始化时踩到 Bootloader 的数据。解决跳转函数里严格按「关中断 → 设 MSP → 设 VTOR → 开中断 → 跳转」的顺序。跳转前最好把用到的外设UART、定时器都 DeInit 掉让 APP 从干净状态启动。4.5 现象两个 APP 编译出来大小不一样切换后行为异常原因APP_A 和 APP_B 是两份独立工程如果代码不同步可能出现 A 有某个功能 B 没有切换后表现不一致。更隐蔽的是两份工程的链接脚本、优化等级、宏定义不一致导致同一份源码编译出不同行为。解决两个 APP 共用同一份源码只改链接地址和 VTOR 宏。用条件编译区分#if defined(APP_SLOT_A) #define APP_BASE_ADDR 0x08004800 #elif defined(APP_SLOT_B) #define APP_BASE_ADDR 0x0800A000 #endif编译时通过宏切换保证逻辑完全一致。这样升级前后行为可预期回滚才有意义。5. 二次开发改造把双 APP IAP 接到自己的项目里讲完原理和坑最后落到怎么改。这份工程是个模板直接拿去用要动几个地方我按改造顺序说。5.1 改分区按你的芯片和固件大小重划第一步永远是重划 Flash。先量出你的固件实际大小编译后的.bin文件大小留 20% 余量再决定 APP 区大小。比如固件 18KBAPP 区就划 22KB。然后按「Bootloader → 参数区 → APP_A → APP_B」的顺序排地址注意每个区起始地址要落在 Flash 页边界上。改完地址后三处必须同步改Bootloader 里的地址宏、两个 APP 的链接脚本、上位机的地址参数。这三处任何一处漏改现象都是「升级成功但跳转失败」排查起来很费时间。我一般把地址定义集中在一个iap_config.h里三边都引用它。5.2 换通道从 UART 换成 CAN 或无线UART 只是传输层协议层可以复用。要换成 CAN改的是「收字节」那一层把uart_rx_byte换成 CAN 接收回调把send_frame换成 CAN 发送。状态机、命令字、Flash 操作全都不用动。无线模块比如 LoRa、蓝牙透传同理只要它能提供「收字节 / 发字节」接口就能套进来。这里有个注意点无线通道的 MTU 通常比串口小分片大小要跟着调。比如 LoRa 单包有效载荷可能只有 64 字节那chunk就得设成 48 左右留出协议头空间。5.3 加校验CRC32 替换 CRC16工程默认用 CRC16对 22KB 固件来说碰撞概率够用。但如果你的固件上百 KB建议换 CRC32。改动点两处设备端校验函数、上位机校验函数算法要一致。常见做法是用查表法速度快/* CRC32 查表法表省略标准多项式 0xEDB88320 */ uint32_t crc32_calc(const uint8_t *data, uint32_t len) { uint32_t crc 0xFFFFFFFF; while (len--) { crc crc_table[(crc ^ *data) 0xFF] ^ (crc 8); } return crc ^ 0xFFFFFFFF; }参数说明初始值 0xFFFFFFFF、结果异或 0xFFFFFFFF 是标准 CRC32 的约定上位机用 Python 的zlib.crc32能直接对上。校验算法不一致是升级失败的高频原因改完记得两端对拍一组测试数据。5.4 验证方法三步确认改造成功改完之后别急着上产线按这三步验证正常升级从 APP_A 升级到 APP_B重启后确认跑的是新版本读版本号寄存器或串口打印。断电回滚升级到一半拔电重新上电确认设备还能跑旧 APP且能重新发起升级。坏固件拒绝故意发一个 CRC 错误的固件确认设备拒绝切换、保留旧 APP并回错误码。这三步走完基本能覆盖 90% 的现场异常。我自己的习惯是每次改完 IAP 相关代码这三步强制走一遍不管改动多小——因为 IAP 的 bug 往往在「升级失败」这种边界场景才暴露平时跑得好好的不代表没问题。从那以后我每次动 Flash 分区或者跳转逻辑都会先把「断电回滚」这条测一遍再提交代码。希望这份拆解能帮你少走点弯路把串口升级这件事稳稳落地。本文还有配套的精品资源点击获取