☰
STM32 UART双APP IAP实战:Flash分区、Bootloader跳转与远程固件升级
2026/10/16 14:14:10 网站建设 项目流程

简介:这是一份面向STM32嵌入式开发者的IAP(应用程序内编程)实战工程,聚焦UART串口通信下的双APP固件升级方案。资源以STM32微控制器为平台,演示如何通过任意通信接口获取新版bin固件包并完成自升级,可延伸至4G、WiFi模块的OTA升级以及U盘、TF卡的OTG升级场景,适合已掌握基础外设驱动、希望深入Bootloader与固件管理的中级开发者。压缩包共272个文件,包含150个h头文件、78个c源文件、11个bin固件、6个ld链接脚本及ioc、launch等工程配置,整体约1.86MB,目录结构完整,覆盖APP0与APP1双分区版本管理。内容预览可见APP0、APP1多版本bin文件,便于验证跳转与回滚逻辑。目前已有1722人学习下载,读者可从中获取双APP分区设计、版本切换、串口传输升级等关键实现思路,是理解STM32 IAP机制的实用参考工程。

1. 从一次OTA升级翻车说起:STM32 UART双APP的IAP到底解决了什么

去年帮一个做工业采集终端的朋友救急,他们的设备装在现场十几米高的料仓顶上,固件有个ADC采样通道配置错了,要改一行代码。按老办法得派人爬上去拆壳、插ST-Link、重新烧录,一趟下来大半天,还得停机。他们当时就问我:STM32能不能像手机一样,通过串口把新固件传进去自己升级?答案就是IAP——In Application Programming,在应用编程。这份「STM32 UART双APP的IAP」资源,核心就是干这件事:用UART串口作为传输通道,在Flash里放两套APP,一套负责跑业务,一套负责接收新固件并搬运,实现不掉电、不拆机的远程升级。它适合正在做STM32量产设备、需要现场维护固件的嵌入式开发者,尤其是那些设备装在高处、井下、密闭机柜里,物理接触成本极高的场景。双APP的设计比单APP更稳,因为升级过程中旧固件不会被立刻擦掉,万一新固件传输出错,还能回滚到旧版本继续跑,不至于变砖。

2. 双APP IAP的Flash分区与启动逻辑:Bootloader怎么决定跳哪

2.1 为什么是双APP而不是单APP

单APP的IAP方案很常见:Bootloader占一小块Flash,APP占一大块,升级时Bootloader把新固件写到APP区,覆盖旧代码。问题是覆盖过程中如果断电,APP区就是半新半旧的垃圾数据,设备直接变砖。双APP的思路是在Flash里划出两个独立的APP槽位,比如APP_A和APP_B,再加一个标志区记录当前该跑哪个、哪个是待升级的。升级时往空闲槽写新固件,写完整校验通过后,改标志位,下次重启就跳到新槽。旧槽的固件保留着,相当于留了一颗后悔药。

这个项目用UART作为传输通道,意味着Bootloader里要跑一个简单的串口协议:接收上位机发来的固件分包、写入Flash、校验、更新标志。整个流程不依赖任何RTOS,纯裸机状态机就能跑通,对资源紧张的STM32F103这类芯片很友好。

2.2 Flash分区的典型布局

以STM32F103C8T6为例,64KB Flash,常见做法是这样切的:

区域起始地址大小用途
Bootloader0x0800000012KB启动判断、UART接收、Flash写入
标志区0x080030002KB记录当前APP槽、升级请求、固件长度、CRC
APP_A0x0800380024KB业务固件A
APP_B0x0800980024KB业务固件B
保留0x0800F8002KB参数存储或对齐

这个布局不是死的,具体大小要看你的APP编译出来多大。我一般会先编一版APP看map文件里的Code+RO Data大小,再往上留20%余量。Bootloader本身功能简单,12KB足够,但如果你的UART协议复杂、要支持YMODEM,可能得给到16KB。

标志区的设计是关键。常见做法是定义一个结构体,存magic number、当前运行槽、待升级槽、固件长度、CRC32校验值。每次Bootloader启动先读这个结构体,magic不对就初始化成默认值,然后根据当前槽跳转。

// 标志区结构体定义,放在固定地址 typedef struct { uint32_t magic; // 0x5A5A1234,判断标志区是否已初始化 uint8_t active_slot; // 0=APP_A, 1=APP_B uint8_t upgrade_slot; // 待升级的目标槽 uint8_t upgrade_flag; // 1=有升级请求 uint8_t reserved; uint32_t fw_size; // 新固件字节数 uint32_t fw_crc; // 新固件CRC32 } IAP_Flag_t; #define FLAG_ADDR 0x08003000 #define FLAG_MAGIC 0x5A5A1234

逻辑说明:magic用于判断这块Flash是不是第一次用,如果是空白Flash读出来是0xFFFFFFFF,跟magic不匹配就写入默认值。active_slot决定Bootloader跳哪个槽。upgrade_flag和upgrade_slot配合,告诉Bootloader「APP_B里已经写好新固件了,校验通过就切过去」。fw_size和fw_crc是给Bootloader做二次校验用的,防止APP写Flash时出错。

2.3 启动跳转的代码实现

Bootloader的main函数里,第一步就是判断标志区、决定跳转目标:

// 跳转到APP的通用函数 typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t stack_ptr = *(volatile uint32_t*)app_addr; // 检查栈顶指针是否在合法RAM范围,防止跳到空区域 if (stack_ptr < 0x20000000 || stack_ptr > 0x20005000) { return; // 非法,不跳 } pFunction app_entry = (pFunction)(*(volatile uint32_t*)(app_addr + 4)); __set_MSP(stack_ptr); // 重设主栈指针 SCB->VTOR = app_addr; // 重定向中断向量表 app_entry(); // 跳过去,不再返回 }

参数说明:app_addr是APP槽的起始地址,比如0x08003800。STM32的APP固件开头4字节是栈顶地址,接着4字节是复位向量。跳转前必须做两件事:把MSP设成APP的栈顶,把VTOR指向APP的向量表。少了VTOR重定向,APP里的中断会跑到Bootloader的向量表去,现象就是串口收不到数据、定时器不进中断,这种玄学问题我踩过不止一次。

注意:APP工程里也要改两个地方。一是链接脚本里ROM起始地址改成对应槽的地址,二是system_stm32f1xx.c里的VECT_TAB_OFFSET要设成相对于0x08000000的偏移。这两处不改,APP编出来还是从0x08000000跑,跳过去必挂。

3. UART传输协议与Flash写入:一包固件从串口到Flash的完整链路

3.1 自定义分包协议的设计

UART传固件,最怕的是丢包和错位。上位机发一大串二进制,Bootloader怎么知道哪是包头、哪是数据、哪是校验?常见做法是定义一个简单的帧格式:

| 帧头(2B) | 包序号(2B) | 数据长度(2B) | 数据(N B) | CRC16(2B) |

帧头固定0xAA55,包序号从0开始递增,数据长度一般取512或1024字节(跟Flash页大小对齐)。CRC16校验这一包数据,Bootloader收到后先验CRC,不对就回NAK让上位机重发。这种协议比YMODEM简单,自己实现起来可控,调试也直观。

// 接收一包固件的状态机(简化版) typedef enum { WAIT_HEAD_H, WAIT_HEAD_L, WAIT_SEQ_H, WAIT_SEQ_L, WAIT_LEN_H, WAIT_LEN_L, WAIT_DATA, WAIT_CRC_H, WAIT_CRC_L } RxState_t; void iap_uart_rx_byte(uint8_t byte) { static RxState_t state = WAIT_HEAD_H; static uint16_t seq, len, data_idx; static uint8_t buf[1024]; static uint16_t crc_recv; switch (state) { case WAIT_HEAD_H: if (byte == 0xAA) state = WAIT_HEAD_L; break; case WAIT_HEAD_L: state = (byte == 0x55) ? WAIT_SEQ_H : WAIT_HEAD_H; break; case WAIT_SEQ_H: seq = byte << 8; state = WAIT_SEQ_L; break; case WAIT_SEQ_L: seq |= byte; state = WAIT_LEN_H; break; case WAIT_LEN_H: len = byte << 8; state = WAIT_LEN_L; break; case WAIT_LEN_L: len |= byte; data_idx = 0; state = (len > 0 && len <= 1024) ? WAIT_DATA : WAIT_HEAD_H; break; case WAIT_DATA: buf[data_idx++] = byte; if (data_idx >= len) state = WAIT_CRC_H; break; case WAIT_CRC_H: crc_recv = byte << 8; state = WAIT_CRC_L; break; case WAIT_CRC_L: crc_recv |= byte; // 校验通过则写Flash,否则回NAK if (crc16(buf, len) == crc_recv) { flash_write_packet(seq, buf, len); uart_send_ack(seq); } else { uart_send_nak(seq); } state = WAIT_HEAD_H; break; } }

逻辑说明:这是一个逐字节驱动的状态机,每收到一个字节就推进一次。好处是不用开大缓冲区等一整包,RAM占用小。seq用于上位机确认哪包没到,重发时只重发那一包。flash_write_packet里要根据seq算出目标地址:目标地址 = 槽起始地址 + seq * 每包数据长度。

参数说明:每包数据长度建议取Flash页大小的整数倍。STM32F1的Flash页是1KB,F4是扇区,大小不一。取512字节的话,写的时候要凑够1KB再擦写,或者用半字编程逐次写。我一般直接取1024,跟页对齐,省得处理跨页。

3.2 Flash擦写的关键细节

STM32的Flash写之前必须先擦,擦的最小单位是页(F1)或扇区(F4)。擦的时候整页变0xFF,所以不能边收边擦正在写的页。正确顺序是:收到升级开始命令后,先把目标槽的所有页擦掉,然后再逐包写入。

// 擦除目标APP槽 void flash_erase_slot(uint32_t slot_addr, uint32_t size) { FLASH_Unlock(); uint32_t page_addr = slot_addr; uint32_t end_addr = slot_addr + size; while (page_addr < end_addr) { FLASH_ErasePage(page_addr); // F1用页擦除 page_addr += 1024; // 每页1KB } FLASH_Lock(); } // 写入一包数据(半字编程) void flash_write_packet(uint16_t seq, uint8_t *data, uint16_t len) { uint32_t addr = TARGET_SLOT_ADDR + seq * PACKET_SIZE; FLASH_Unlock(); for (uint16_t i = 0; i < len; i += 2) { uint16_t half_word = data[i] | (data[i+1] << 8); FLASH_ProgramHalfWord(addr + i, half_word); } FLASH_Lock(); }

逻辑说明:擦除必须在写入之前全部完成,不能写一包擦一页。因为擦除操作会让整个页变0xFF,如果这页前面已经写了数据,一擦就全没了。所以协议里要有一个「开始升级」命令,收到后先擦目标槽,再进入收包状态。

参数说明:FLASH_ProgramHalfWord是F1的标准库函数,每次写2字节。写入地址必须是偶数,数据长度最好是偶数,奇数长度要补0xFF。写完之后要读回来比对,确认写入成功,这一步别省,Flash写入失败在电压不稳时真的会发生。

3.3 升级完成后的校验与切换

所有包收完后,Bootloader要做三件事:算整个固件的CRC32、跟标志区里记录的fw_crc比对、一致就改active_slot并清upgrade_flag,然后软复位。

// 升级完成校验 uint8_t iap_finalize(uint32_t fw_size, uint32_t fw_crc) { uint32_t calc_crc = crc32_flash(TARGET_SLOT_ADDR, fw_size); if (calc_crc != fw_crc) { return 0; // 校验失败,不切换 } IAP_Flag_t flag; flash_read(FLAG_ADDR, (uint8_t*)&flag, sizeof(flag)); flag.active_slot = flag.upgrade_slot; // 切到新槽 flag.upgrade_flag = 0; flash_erase_page(FLAG_ADDR); flash_write(FLAG_ADDR, (uint8_t*)&flag, sizeof(flag)); return 1; }

逻辑说明:crc32_flash逐字节读Flash算CRC,虽然慢但只在升级结束时跑一次,可以接受。校验通过才改标志区,不通过就保持原样,设备重启后还跑旧固件。改完标志区后调用NVIC_SystemReset()软复位,Bootloader重新读标志,跳到新槽。

参数说明:fw_size和fw_crc来自上位机在「开始升级」命令里带的参数,Bootloader收到后先存到标志区,最后校验时用。CRC32比CRC16更可靠,固件几万字节,CRC16碰撞概率还是有点高。

4. 避坑与排查:双APP IAP最容易翻车的五个地方

4.1 跳转后中断不响应

现象:Bootloader跳转到APP后,APP的main函数能跑,串口能发数据,但收不到数据,定时器也不进中断。

原因:APP工程的中断向量表偏移没设对。STM32默认从0x08000000取向量表,APP在0x08003800,中断发生时CPU还是去0x08000000找中断服务函数,找到的是Bootloader的,自然不对。

解决:在APP的main函数开头、初始化外设之前,加一句SCB->VTOR = 0x08003800;,或者在system_stm32f1xx.c里改VECT_TAB_OFFSET。两个地方改一个就行,我习惯在main里显式设,看得见。

4.2 升级到一半断电,设备起不来

现象:升级过程中断电,重新上电后设备卡在Bootloader,不跳APP。

原因:标志区的upgrade_flag已经置1,但固件没传完,Bootloader启动时看到升级请求就去校验目标槽,CRC不过就不跳转,卡住了。

解决:Bootloader启动逻辑里加超时或重试机制。如果upgrade_flag为1但校验失败,清掉upgrade_flag,跳回原active_slot。另外标志区写入要用「先擦后写」,擦除过程中断电标志区可能变0xFF,magic不匹配就初始化成默认值,跳APP_A。

4.3 串口收包丢字节导致CRC错

现象:升级过程中频繁NAK重发,进度条卡住不动。

原因:Bootloader在写Flash时会关中断或阻塞,UART接收中断被延迟,上位机发太快就丢字节。STM32的Flash写入期间CPU取指会暂停,如果UART没有硬件FIFO,一个字节没及时读走就丢了。

解决:上位机每发一包等ACK再发下一包,不要连续发。Bootloader写Flash时先把数据存到RAM缓冲,写完再开中断。或者用DMA接收,DMA不占CPU,Flash写入期间也能收。

4.4 APP编译出来超过槽大小

现象:APP编译没报错,但IAP升级后跑不起来,或者跑着跑着HardFault。

原因:APP的Code+RO Data超过了槽的24KB,链接时没报错是因为链接脚本里ROM大小写的是64KB,实际写进去覆盖了下一个槽或标志区。

解决:APP工程的链接脚本里ROM起始地址和大小都要改成槽的实际范围,比如ROM (rx) : ORIGIN = 0x08003800, LENGTH = 24K。改完编译,如果超了链接器会报region overflow,提前发现。

4.5 升级后旧槽数据残留影响新固件

现象:从APP_A升级到APP_B,新固件跑起来后读写参数异常。

原因:APP_B槽擦除不彻底,或者参数存储区跟APP槽重叠了。有些方案把参数存在APP槽末尾,双APP切换后参数地址变了,读出来是旧数据。

解决:参数存储单独划一个区,不放在APP槽里。擦除时确认目标槽全部页都擦了,可以擦完后读回来检查是不是全0xFF。

5. 二次开发改造:把双APP IAP接到你自己的协议栈上

这份资源的骨架是Bootloader+双APP+UART协议,实际项目里你大概率要改三个地方:传输通道、上位机工具、APP的链接配置。

先说传输通道。UART只是其中一种,如果你的设备有CAN、以太网、甚至无线模块,把iap_uart_rx_byte这个状态机换成对应的接收回调就行,协议帧格式不用动。我做过一个CAN版本,把每包数据拆成8字节的CAN帧,加个多帧组包逻辑,其余代码原样复用。关键是把「收字节」和「写Flash」解耦,收包状态机只管攒数据,攒够一包交给写Flash的函数。

再说上位机。资源里如果带了上位机源码,重点看它怎么分包、怎么算CRC、怎么处理NAK重发。改造时最容易出问题的是超时重发逻辑:等ACK等多久、重发几次算失败、失败后是重传当前包还是从头来。我的习惯是等ACK超时500ms,重发3次,3次都失败就中止升级并提示。别设太短,Flash擦除一页在F1上要20~40ms,加上传输延迟,200ms以下容易误判。

最后是APP的链接配置。每个APP工程都要改ROM起始地址和大小,还要改VTOR。如果两个APP共用一套代码,可以用宏区分:

// 在APP的main.c里 #if defined(APP_SLOT_A) #define APP_BASE_ADDR 0x08003800 #elif defined(APP_SLOT_B) #define APP_BASE_ADDR 0x08009800 #endif int main(void) { SCB->VTOR = APP_BASE_ADDR; // 重定向向量表 // ... 其余初始化 }

编译时用-DAPP_SLOT_A或-DAPP_SLOT_B切换,链接脚本也准备两份,或者用变量传进去。这样一套代码编出两个槽的固件,维护成本低。

验证方法我一般分三步走:第一步,用调试器手动改标志区,确认Bootloader能正确跳两个槽;第二步,用上位机走一遍完整升级,故意在传输中途拔掉串口线,看能不能回滚;第三步,把新固件里加一个版本号打印,升级后读出来确认跑的是新版本。这三步走完,基本就稳了。

从那以后我每次做IAP方案,都强制走一遍「断电回滚」测试——升级到一半直接拉电,再上电看设备是不是还活着。这个测试比任何代码审查都管用,因为Flash操作和标志区写入的时序问题,只有真断电才能暴露出来。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询