简介:这是一份STM32 UART双APP IAP完整工程资源,面向需要实现固件远程升级或本地更新的嵌入式开发者。资源围绕IAP(在应用编程)原理展开,展示了通过串口通信将新版本固件写入Flash、并在APP0与APP1两个分区之间灵活切换的典型实现,尤其适合理解Bootloader跳转、版本管理和升级容错机制的读者。工程共272个文件,以150个H头文件和78个C源文件为主,配合11个bin固件验证包以及ld链接脚本、ioc工程配置文件等,约1.86MB,目录结构清晰,可直接导入STM32开发环境进行编译烧录;其中c/h源文件覆盖了bootloader与app端的关键逻辑。内容预览中的多组APP0/APP1不同版本bin,可用来实际演示升级前后版本变化。目前已有1722人学习,对于正在搭建UART/OTA升级方案的中高级嵌入式开发者,这份工程是一份值得参考的落地代码。
1. 双APP的IAP:让STM32升级掉电也不再变砖
做设备运维的人都懂一个场景:现场几十台设备分布在不同的站房,固件要升级,工程师只能拎着笔记本和串口线挨个去刷。有一次我刷到一半,工人误拔了电源,再上电时设备彻底变砖,只能返厂拆Flash。这个项目方案就是给STM32做一套基于UART的双APP在线升级(IAP),核心思路是Bootloader加两个独立的应用程序分区:一个正常跑业务,另一个当备份;升级写入备份区,校验通过后再切换启动。就算升级途中断电、传输中断、固件写入一半,重启后Bootloader依然能回到旧版本继续跑。适合正在做Bootloader、设备远程维护或者产品量产固件更新的人,也适合拿来做毕业设计里“在线升级”这一块的工程参考。
2. 架构与分区设计:Boot识别该跑A区还是B区的启动链路
2.1 Boot、参数区与双APP的三级Flash分区
先把Flash分区分清楚,整个IAP的根就在这张表上。以常见的STM32F103VE为例,512KB Flash足够放下Bootloader加两个完整的应用区。按1KB一页对齐切分,每一段的边界都非常干净:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x08000000 | 16KB | 上电引导、串口升级、跳转管理 |
| 参数区 | 0x08004000 | 4KB | 保存升级标志、当前分区、启动计数、固件版本 |
| App A | 0x08005000 | 128KB | 业务固件A区 |
| App B | 0x08025000 | 128KB | 业务固件B区(备份/待升级区) |
Bootloader单独放在最低地址段,复位后MCU先跑它。App A和App B大小完全一致,代码完全一致,只是链接地址不同;一个在跑,另一个就是影子备份。参数区夹在Boot和App之间,专门放启动决策要用的状态信息,这样App随便擦、随便写,Boot的判断依据永远在独立的一小片Flash里。
提示:分区必须按页对齐,F103每页1KB,所有起始地址都要落在0x08000000加上整数个1KB的位置,否则擦除的时候会把别的区擦掉。
2.2 启动流程:Boot怎么决定跳A还是跳B
Boot上电后的决策逻辑并不复杂,核心就是读参数区,判断哪个分区有效、哪个分区可以启动。参数区我习惯用一个固定结构体来定义,写代码和调试都直观:
typedef struct { uint32_t magic; // 参数区有效标记,固定为 0x4F544131 uint32_t active_slot; // 当前有效分区:0 = App A,1 = App B uint32_t next_slot; // 下次启动分区 uint32_t boot_count; // 连续启动失败计数 uint32_t fw_version; // 当前固件版本号 uint32_t reserved[2]; // 保留 uint32_t crc32; // 参数区CRC校验 } param_block_t;每一条记录写入前,要先擦除参数区所在的4KB页,再按32位字写入。Boot启动时读出这个结构体,按以下顺序判断启动目标:
void boot_decision(void) { param_block_t param; read_param_block(¶m); if (param.magic != PARAM_MAGIC_OK) { // 参数区无效(比如首次烧录),默认从App A启动 jump_to_app(APP_A_START); return; } if (param.boot_count > MAX_BOOT_FAIL) { // 连续启动失败次数超限,说明当前分区不可用,切换到另一个分区 uint32_t fallback = (param.active_slot == SLOT_A) ? APP_B_START : APP_A_START; param.active_slot = (param.active_slot == SLOT_A) ? SLOT_B : SLOT_A; save_param_block(¶m); jump_to_app(fallback); return; } // 正常情况,跳转到 next_slot 指定的分区 if (param.next_slot == SLOT_B) { jump_to_app(APP_B_START); } else { jump_to_app(APP_A_START); } }这里的核心设计是boot_count计数:新App启动后,如果业务代码正常运行,会在初始化末期把boot_count清零;如果App崩溃、死循环、看门狗没喂上,系统复位回到Boot,boot_count就会加1。连续几次都起不来,Boot直接放弃这个分区,切回另一个分区——这就是双APP方案“掉电不砖”的底气所在。
2.3 资源包内的工程结构
拿到资源包,第一件事是先看目录结构。这个包是典型的“Boot + 双App + 上位机”三层结构,工程之间通过链接地址联动:
. ├── Boot/ │ ├── Boot.uvprojx # Bootloader工程,烧录在0x08000000 │ ├── Core/ │ │ ├── boot_main.c # 启动决策、跳转函数 │ │ ├── flash_if.c # Flash擦写封装 │ │ └── uart_boot.c # 串口接收状态机、帧解析 │ └── App/ ├── App_A/ │ ├── App.uvprojx # App工程,IROM1起始0x08005000 │ ├── Core/ │ │ ├── main.c # main函数开头重映射向量表 │ │ └── iap_cmd.c # App里响应升级指令,跳回Boot ├── App_B/ │ ├── App.uvprojx # 与App_A完全相同的工程,IROM1改为0x08025000 │ └── ... ├── HostTool/ │ ├── iap_send.py # Python上位机:握手、分包、等待ACK、重传 │ └── protocol.md # 帧协议说明 └── Doc/ ├── 分区说明.md └── 烧录步骤.mdApp_A和App_B实际上就是同一个工程复制出来改了两个地方:Keil里Target的IROM1起始地址,以及main.c里SCB->VTOR的偏移值。双分区的好处在这个时候体现出来了——不用维护两套代码,只维护一套App源码,编译两次,分别烧到两个地址段即可。
注意:Boot和App的串口引脚要保持一致,最好用同一个USART,否则升级的时候Boot已经从串口收到数据了,App却不知道串口在哪。
3. 串口升级协议:一帧一ACK怎么把固件可靠送进Flash
3.1 通信帧格式
既然走UART升级,协议就是整个IAP的心脏。我先定义了统一的帧格式,所有命令——握手、开始、数据、结束、应答——都走这一个壳:
typedef struct { uint8_t head[2]; // 帧头:0xAA 0x55 uint8_t type; // 帧类型:握手/开始/数据/结束/应答/错误 uint16_t length; // 数据区长度,小端模式 uint16_t seq; // 帧序号,从0递增 uint8_t data[]; // 数据区,长度可变 uint16_t crc16; // 帧尾的CRC校验,覆盖type到data末尾 } frame_t;帧类型定义成枚举,协议清晰又能扩展:
| 帧类型 | 值 | 方向 | 说明 |
|---|---|---|---|
| CMD_HANDSHAKE | 0x01 | 上位机 → Boot | 请求握手,获取版本/分区信息 |
| RSP_HANDSHAKE | 0x02 | Boot → 上位机 | 返回Boot版本、目标分区、支持的最大帧长 |
| CMD_START | 0x03 | 上位机 → Boot | 开始升级,带固件总长度、总CRC值 |
| CMD_DATA | 0x04 | 上位机 → Boot | 固件数据块,固定1KB |
| CMD_END | 0x05 | 上位机 → Boot | 全部数据发送完毕 |
| RSP_ACK | 0x81 | Boot → 上位机 | 成功应答,ACK里带帧序号 |
| RSP_NACK | 0x82 | Boot → 上位机 | 失败应答,NACK里带原因 |
CRC16采用最常见的多项式0x8005,初始值0xFFFF,先算低字节后发。整个帧除了帧头不参与CRC计算,type、length、seq和data全部参与,帧尾的CRC本身不做计算。这个区分我第一次写的时候搞反了,导致上位机收不到一个ACK,排查了整整半天。
uint16_t calc_crc16(const uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) { crc = (crc >> 1) ^ 0xA001; } else { crc >>= 1; } } } return crc; }这里的0xA001就是0x8005的反向多项式,右移计算时要用它。看到的CRC计算代码里满大街都是0x8005正向写法,但在STM32这种逐字节流式处理场景下,反向算法更顺。
3.2 完整升级时序:一帧一确认
整个升级过程是典型的“请求—应答”模型,Boot不主动发数据,所有主动权在上位机手里:
- 上位机发送
CMD_HANDSHAKE,Boot应答RSP_HANDSHAKE,返回Boot版本号、当前可用的目标分区、最大数据帧长度; - 上位机读入固件bin文件,计算整个文件的CRC32,发送
CMD_START,里面带固件总字节数和总CRC; - Boot收到开始帧后,擦除目标分区的全部页,准备接收数据;
- 上位机把bin文件切成长度不超过1024字节的块,逐块发送
CMD_DATA,每发一帧就等ACK,收到ACK再发下一帧; - 全部发完后发送
CMD_END,Boot把逐帧累加的CRC和开始帧里的总CRC做最终比对; - 比对通过,Boot把
next_slot改成新分区、active_slot同步更新,然后软复位跳转新固件。
为什么不用“连续灌数据”的传输方式?因为Flash擦写有耗时,擦一页1KB在F103上要几十毫秒,如果上位机连续发,Boot的串口接收FIFO立刻溢出。一帧一ACK看起来慢,但在115200波特率下传128KB也就两分钟左右,稳定优先。
3.3 上位机端怎么握手和发包
资源包里的iap_send.py是我用Python写的一个简洁上位机,核心逻辑两百行左右,重点是把帧拼对、把应答等待和重传写好。核心发送函数如下:
import serial import struct def crc16(data: bytes) -> int: 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_frame(ser: serial.Serial, ftype: int, seq: int, payload: bytes): body = struct.pack("<BHI", ftype, len(payload), seq) + payload frame = b"\xAA\x55" + body + struct.pack("<H", crc16(body)) ser.write(frame)参数说明:ftype对应协议里的帧类型;seq是本次帧序号;payload是数据区字节流;struct.pack("<BHI", ...)是按小端序打包帧类型、双字节长度、双字节序号。整个body做CRC计算后追加到帧尾。
发送数据帧时的重传逻辑也很直接:Boot每收到一帧合法数据就回ACK,ACK里带当前帧序号;上位机如果发出去后1秒内没收到ACK,就把同一帧重发,连续重发5次仍无应答就中断升级并报错。这种策略下,即使现场环境干扰导致单帧偶尔损坏,整包也不会中断。
4. 核心代码实战:Flash驱动、向量表偏移与跳转逻辑逐段拆
4.1 Flash底层擦写封装
Boot里所有写Flash的操作都封装在flash_if.c中,应用层只允许调用三个接口:页擦除、半字写入、整块写入。不封装直接裸调寄存器,后期改代码时容易到处踩坑。
void flash_page_erase(uint32_t page_addr) { FLASH_Unlock(); // 解锁Flash控制器 while (FLASH_ErasePage(page_addr) != FLASH_COMPLETE); // 擦除整页 FLASH_Lock(); // 重新上锁 } void flash_write_halfword(uint32_t addr, uint16_t data) { FLASH_Unlock(); while (FLASH_ProgramHalfWord(addr, data) != FLASH_COMPLETE); FLASH_Lock(); }写Flash为什么用ProgramHalfWord?因为STM32的Flash编程操作支持的最小单位是半字(16位),你用字节写它会直接报错。常见做法是将接收缓冲区里的每两个字节拼成一个半字,再调用flash_write_halfword写入目标地址。整块接收、整块写入能缩短解锁和上锁的窗口时间,减少中断打扰。
App A区域的写入就是典型的“按帧擦写”:收到一帧1KB数据、CRC校验通过后,先把这1KB对应的Flash页擦掉,再把数据写进去。整个128KB的App区,会被拆成128个页,每页擦一次、写一次。
4.2 Boot跳转App的防呆实现
跳转是整个IAP最纠结的地方,一不留神就往错误地址飞了。标准做法是从App起始地址读前两个字:第一个字是初始栈指针(MSP),第二个字是复位中断向量地址。跳转前做合法性检查,防止App区是空的或者写了一半导致跳飞:
typedef void (*fnc_ptr)(void); void boot_jump(uint32_t app_addr) { uint32_t sp = *(volatile uint32_t *)app_addr; // App起始地址的前4字节 = 栈顶 uint32_t pc = *(volatile uint32_t *)(app_addr + 4); // 紧接着4字节 = 复位向量 // 防呆:栈顶必须在SRAM范围,复位向量必须在Flash代码区 if ((sp & 0xFFF00000) != 0x20000000) return; if ((pc & 0xFFF00000) != 0x08000000) return; __disable_irq(); // 跳转前必须关中断,防止旧中断在跳转瞬间闯入 SCB->VTOR = app_addr; // 重定向向量表到App区起始地址 __set_MSP(sp); // 切换到App自己的栈顶 ((fnc_ptr)pc)(); // 直接跳转,不再返回 }怎么理解这段代码?sp和pc不是通过函数调用去App,而是直接取出App映像头里的两个关键值;合法性检查就是判断这两个值是否落在合理的地址范围内;SCB->VTOR这行是关键中的关键,如果没有重定向向量表,App一旦产生中断,CPU会去Boot的中断向量表里找ISR,下标对不上就进HardFault。__disable_irq()也是必须的——跳转的瞬间如果有一个中断在Pendding状态,折腾完向量表再跳过去,执行流就乱了。
4.3 App侧必须做的向量表重定位
App工程拿到手,第一件事就是改main.c开头的SCB->VTOR。这个配置只对App有效,Boot自己不需要偏移。
#define APP_BASE_ADDR 0x08005000UL int main(void) { SCB->VTOR = APP_BASE_ADDR; // 重映射中断向量表到App自身地址 HAL_Init(); SystemClock_Config(); SystemInit(); // 业务逻辑从这里开始 ... }同时,Keil工程的IROM1起始地址必须同步改成0x08005000,大小填写0x20000(128KB)。如果只改了代码里的VTOR,没改链接脚本,App编译出来的中断向量表还是按0x08000000链接的,照样跑不对。
对比一下两个App工程的差异:App_A的IROM1是0x08005000,App_B把起始地址改成0x08025000,main.c里的宏定义同步改成0x08025000。编译出两个hex,分别下载到两个分区。后续升级时Boot只往目标分区写,不会动另一个分区。
4.4 串口接收状态机与写Flash策略
Boot里的串口接收不能简单用中断把字节放进数组就完事。固件传输是连续的字节流,帧边界需要靠状态机切分才能准确识别。
typedef enum { WAIT_H1, // 等待帧头第1字节 0xAA WAIT_H2, // 等待帧头第2字节 0x55 WAIT_TYPE, // 等待帧类型 WAIT_LEN, // 等待长度 WAIT_SEQ, // 等待序号 WAIT_DATA, // 等待数据区 WAIT_CRC // 等待CRC } frame_state_t;状态机每次从串口中断挤出一个字节,按当前状态逐步推进。WAIT_DATA状态下每收一个字节就累积计算CRC,收满length个字节后进入WAIT_CRC,收齐帧尾CRC后整帧校验:
void uart_rx_byte(uint8_t ch) { switch (state) { case WAIT_H1: if (ch == 0xAA) state = WAIT_H2; break; case WAIT_H2: if (ch == 0x55) { state = WAIT_TYPE; rx_len = 0; rx_crc = 0; } else state = WAIT_H1; break; case WAIT_TYPE: frame_type = ch; rx_crc = crc16_update(rx_crc, ch); state = WAIT_LEN; break; // ... case WAIT_DATA: if (rx_len < MAX_FRAME_SIZE) { rx_buf[rx_len++] = ch; rx_crc = crc16_update(rx_crc, ch); } if (rx_len == frame_length) state = WAIT_CRC; break; } }写Flash策略上,我踩过一次性能坑:最初每次收到帧都直接调用flash_write_halfword,一次只写2字节,128KB固件要写六万多次,每次还带Unlock和Lock,耗时受不了。后来改成每帧攒满1KB,先擦页再连续写半字,整个升级时间缩短了一半以上。Boot把擦页动作放在收到完整数据帧、CRC校验通过之后,这样既不会在接收过程中频繁中断,也能保证擦完立刻写入。
5. 现场排查与避坑:升级翻车的6个高发点
5.1 App下载完跳转死机,反复进HardFault
现象:升级进度走到100%,Boot软复位后设备黑屏,用调试器挂上看停在HardFault_Handler。
原因:App的SCB->VTOR没设置成App自己的起始地址,中断向量表还指向Boot开头,任何一个中断触发都会跳到错误位置。还有一种情况是跳转前有中断Pending,__disable_irq()没调用。
解决:App的main()第一行就写SCB->VTOR = APP_BASE_ADDR;Boot跳转前加__disable_irq();同时在跳转前把所有已使能的外设时钟复位一遍,防止外设中断状态带到App里。
5.2 用串口助手发送bin文件,传了一半突然停止
现象:直接用某款串口调试助手选“发送文件”,固件发到一半Boot不再回ACK,等多久都没反应。
原因:很多串口助手在“发送文件”模式下会做文本化处理,把0x1A当作EOF截断文件,或者自动在换行处插入0x0D 0x0A,把二进制数据改了。
解决:资源包里直接用Python脚本通过pyserial二进制方式发送,不要经过串口助手的“文件发送”功能。如果必须用串口助手,要确认发送模式是Binary/Hex,且关闭“追加新行”选项。
5.3 升级断电后连Boot都起不来
现象:升级过程中人为断电,重新上电,没有任何程序运行,调试器读Flash发现Boot区域数据被冲掉了一部分。
原因:擦写Flash的过程中没有关闭全局中断,某个中断服务函数内部恰好访问了Flash区域,导致总线错误;另外,擦写地址越界,把Boot所在的低地址段给擦掉了。
解决:flash_page_erase和flash_write_halfword内部加上__disable_irq()/__enable_irq()保护;所有Flash操作地址必须先做边界检查,目标地址小于APP_A_START或大于Flash末尾时直接返回错误码,不执行任何擦写。
5.4 升级成功后复位,跑的还是旧版本固件
现象:上位机显示升级成功,Boot也返回了成功应答,但设备重启后业务行为没变化,一查版本号还是旧版。
原因:Boot在启动决策时读的next_slot没有被更新成新固件所在分区,参数区的状态没改,Boot每次仍然按旧逻辑跳到A区。
解决:升级数据全部接收并比对总CRC通过后,Boot立刻把next_slot置为新分区、active_slot更新,写入参数区后复位;新App启动后正常自检,再把boot_count清零并回写参数区,形成完整的闭环。
5.5 帧发太快导致NACK越来越多,越补越发不出去
现象:上位机把波特率调到460800,想着传快点,结果中途NACK刷屏,进度反而比115200还慢。
原因:Boot是串口中断接收,但Flash擦写期间CPU在执行擦除指令,无法兼顾串口数据;擦一页期间进来的字节直接丢弃,帧就碎了。
解决:把串口FIFO缓冲做大,至少能缓冲两个完整数据帧;上位机保持“发一帧等ACK”的流控节奏,不要超过Boot处理能力。
5.6 用调试器擦片重新烧录后,Boot直接跳过升级逻辑
现象:全片擦除后重新烧了Boot,上电却发现Boot没有进入串口等待升级的状态,直接像跑App一样跑飞。
原因:这是典型的“擦除后的0xFF被当成了有效标志”的坑。参数区全片擦除后内容全是0xFF,如果Boot判断magic != MAGIC_OK时直接走了“从A区启动”的逻辑,而A区此时也是空,自然跑飞。
解决:Boot必须区分“参数区无效首次上电”和“参数区有效但分区损坏”两种状态。参数区magic在写入时显式写一个特征值,擦除后的0xFF走的是“初始化参数区→进入串口等待升级”分支,而不是“启动App”分支。
6. 二次开发:从UART IAP走向无线远程升级的改造路线
这套方案的扩展空间很大,我自己的习惯是先把有线链路跑通,再根据产品形态做迁移。
6.1 把自定义协议替换成YMODEM
如果不想维护自己的帧格式和上位机,可以直接把Boot的接收层换成YMODEM协议。YMODEM成熟、现成工具多,Boot只需要实现YMODEM的接收端状态机,重点把CRC算法换成XMODEM的CRC16,多项式从0x8005改成0x1021,初始值从0xFFFF改成0x0000。改造时要注意:YMODEM每个数据包固定128字节或1024字节,Boot需要支持这两种包长切换;上位机部分可以直接用现成的开源工具,省掉自己写Python脚本的工作。
6.2 给固件加一层签名校验
产品要量产,安全校验得跟上。常见做法是升级包在bin文件末尾追加一段SHA256摘要和签名信息,Boot收到CMD_END后先对整个App区内容算一次SHA256哈希,与包内携带的摘要比对,匹配才跳转;升级包在加密时再用AES-128-CBC加密,Boot内置密钥,烧录前先在Boot里完成解密再写Flash。密钥不要放在App区,否则拿到固件就能逆向出来,放在Boot里,配合读保护更稳妥。
6.3 把串口链路换成无线模块
把USART接到WiFi透传模块或者4G模组的串口上,上位机把虚拟串口映射到TCP端口,业务上几乎不用改。真正要改的是超时参数——无线链路延迟波动比有线大得多,1秒超时容易误判丢帧,改成3到5秒重传更稳;同时把“一帧一ACK”改成“收到ACK前的窗口内连续发4帧”,传输效率能提升一倍。
从那以后,我每次做IAP都会强制自己走一遍掉电测试:在随机帧序号后断电、重启,反复来十来次,直到确认双分区回滚链路完全稳定才敢交付。这个习惯帮我挡掉过好几次现场返工的血泪教训,希望帮到你。
本文还有配套的精品资源,点击获取