ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 UART双APP IAP实战:Flash分区、Bootloader跳转与串口升级避坑指南

STM32 UART双APP IAP实战:Flash分区、Bootloader跳转与串口升级避坑指南

简介:这份资源围绕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 IAP(In-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 校验值这些元数据。

以 STM32F103C8T6(64KB Flash)为例,一种常见的划分方式如下:

区域起始地址大小用途
Bootloader0x0800000016KB上电启动、搬运、跳转
参数区0x080040002KB升级标志、长度、CRC
APP_A0x0800480022KB应用区 A
APP_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, chunk=256): 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, timeout=1.0) # 等设备回 ACK if not ack: raise IOError(f"offset {offset} 无应答,重试")

参数说明:chunk=256是分片大小,太小协议开销大,太大设备缓冲区可能溢出,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 区大小。比如固件 18KB,APP 区就划 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 验证方法:三步确认改造成功

改完之后别急着上产线,按这三步验证:

  1. 正常升级:从 APP_A 升级到 APP_B,重启后确认跑的是新版本(读版本号寄存器或串口打印)。
  2. 断电回滚:升级到一半拔电,重新上电,确认设备还能跑旧 APP,且能重新发起升级。
  3. 坏固件拒绝:故意发一个 CRC 错误的固件,确认设备拒绝切换、保留旧 APP,并回错误码。

这三步走完,基本能覆盖 90% 的现场异常。我自己的习惯是,每次改完 IAP 相关代码,这三步强制走一遍,不管改动多小——因为 IAP 的 bug 往往在「升级失败」这种边界场景才暴露,平时跑得好好的不代表没问题。

从那以后我每次动 Flash 分区或者跳转逻辑,都会先把「断电回滚」这条测一遍再提交代码。希望这份拆解能帮你少走点弯路,把串口升级这件事稳稳落地。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进