ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

STM32F103串口IAP升级:Bootloader与APP完整实现

STM32F103串口IAP升级:Bootloader与APP完整实现

简介:基于意法半导体STM32F103微控制器的串口应用内编程升级程序完整原创开源包,面向嵌入式开发者,解决设备运行状态下经串口更新固件、避免拆机烧录与依赖专用编程器的问题。工程实现了从引导程序、串口通信到闪存擦写、循环冗余校验、非法固件防范和失败回滚的完整升级链路,并附有用于多场景验证的测试应用程序。压缩包共290个文件、约5.68MB,以C语言源文件与头文件为主体,辅以工程配置文件、链接脚本、编译生成的二进制与十六进制固件、映射文件及批处理工具,能够清晰呈现从源码到最终固件的全流程。目前已有2786人学习下载,适合正在研究STM32在线升级机制或需要自主实现串口IAP功能的开发者;可借鉴其分层引导设计、校验策略、异常恢复思路,利用自带测试程序快速评估升级的可靠性与稳定性。

1. 量产之后才发现要改协议:串口 IAP 就是那粒后悔药

板子已经贴片量产,协议却要临时改,手边只剩一块 USB 转 TTL,连 ST-Link 都翻不出来。这种时候能救场的只有串口 IAP。这套完整版 STM32F103 串口 IAP 升级程序,把 Bootloader、测试 APP、串口固件协议和验证流程全打包了:Bootloader 负责在 Flash 里写固件,测试 APP 用来确认升级链路是否闭环,核心的 Flash 分区、握手、分包、跳转逻辑都在代码里,拿到手就能烧。适合正在做 F103 项目、需要给产品加升级能力的开发者,也适合被 Bootloader 跳转 APP 折磨过的新手——照着工程跑一遍,比翻十篇零散教程都直观。

2. 整体架构:Flash 分区、协议设计与双区协作

2.1 Flash 分区规划:Bootloader 区、APP 区与升级标志页

STM32F103 的 IAP 本质上是让 Bootloader 和 APP 住在同一颗 Flash 里,Bootloader 负责写 Flash,APP 负责跑业务,两者不能互相覆盖。分区是第一优先级的设计决策,我见过的翻车现场里七八成都是分区改乱了:APP 链接地址和 Bootloader 擦写地址对不上,或者升级时把 Bootloader 自己给擦了。

以 F103C8T6(64KB Flash、1KB/页)为例,这套项目用的是三区布局:

区域起始地址大小说明
Bootloader0x0800000032KB(0x8000)上电启动、串口收包、Flash 写包
APP0x08008000约 31KB用户业务固件,带自己的向量表
标志区0x0800FC001KB存升级标志和 APP 成功运行标志

Bootloader 占 32KB 是偏保守的做法,换到 F103ZET6(512KB)这类高密度型号,每页 2KB,分区按页对齐即可,逻辑不变。关键点只有一个:Bootloader 大小必须是 N 页整数倍,这样 APP 起始地址天然对齐页边界,擦写不会串区。这套代码里 Bootloader 工程是独立的一个,APP 工程也单独一个,两边的 Flash 起始地址用宏写死,改动时只动一处。

2.2 升级协议:帧头帧尾、长度字段与 ACK/NACK 应答

协议层决定了上位机怎么把 bin 文件切碎、下发、确认。这套代码用的是「一次一包」的确认机制,帧格式如下:

字段长度说明
帧头2 字节0xAA 0x55
命令1 字节0x01 握手 / 0x02 擦除 / 0x03 写数据 / 0x04 完成
包序号2 字节小端,用于排查乱序和重发定位
数据长度2 字节0~256
数据N 字节固件切片
CRC162 字节覆盖命令到数据段
帧尾2 字节0xCC 0x33

握手流程是先发 0x01 命令,Bootloader 回 0x06;再发 0x02 擦除,Bootloader 擦完整个 APP 区回 ACK;然后 0x03 一包一包写,每包回 ACK,上位机超时 500ms 就重发当前包;全部写完发 0x04 完成帧,Bootloader 验一遍末尾校验字,通过就跳转。这套协议没做滑动窗口,速度不是最快,实测 115200 波特率下大概 4KB/s,但胜在简单可靠,适合工程应用,也最适合拿来理解 IAP 链路。字段里没有设备地址,还是单机升级的思路,如果以后要多设备组网升级,在命令字节后面加一个地址段就行,这个我在最后一章展开。

2.3 Bootloader 主循环:标志优先、按键兜底

Bootloader 上电后的决策顺序是固定的:初始化时钟和串口 → 读取标志区 → 如果升级标志有效,留在 Bootloader 等上位机;否则延时 500ms 等用户按键,窗口期没有升级意图,就校验 APP 向量表合法性后跳转。代码里这个循环写得很直白:

int main(void) { SystemInit(); MX_GPIO_Init(); MX_USART1_UART_Init(); if (CheckUpgradeFlag() == 1) { printf("[IAP] upgrade flag set, wait for host...\r\n"); IAP_MainLoop(); // 不跳转,等升级命令 } else { printf("[IAP] no flag, check key...\r\n"); for (uint32_t i = 0; i < 500; i++) { if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) { IAP_MainLoop(); break; } HAL_Delay(1); } Boot_JumpToApp(); } }

逻辑说明:CheckUpgradeFlag 读的是标志区预写的魔数。APP 收到升级指令后会在标志区写 0xA5A5 再软复位,Bootloader 看到魔数就留驻;没有魔数就检查按键,500ms 内没人按就正常跳转 APP。这种「标志优先、按键兜底」的设计比纯按键成熟,量产设备不用开壳就能远程进入升级模式。

3. 核心代码拆解:串口收帧、Flash 擦写与跳转执行

3.1 串口接收状态机:逐字节解析完整固件帧

这套代码的优秀之处在于串口数据不是攒一批再解析,而是中断里逐字节喂状态机。F103 的 USART 没有 FIFO,一次中断收一个字节,状态机把「找帧头、收长度、收数据、验帧尾」拆成几个阶段,任何阶段校验失败都能立刻回退重新同步。

#define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define FRAME_TAIL1 0xCC #define FRAME_TAIL2 0x33 #define CMD_HANDSHAKE 0x01 #define CMD_ERASE 0x02 #define CMD_DATA 0x03 #define CMD_FINISH 0x04 static uint8_t rx_buf[260]; static uint16_t rx_len; static uint8_t rx_state; static uint16_t data_len; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { static uint8_t ch; HAL_UART_Receive_IT(huart, &ch, 1); // 立刻重挂,避免漏字节 switch (rx_state) { case 0: if (ch == FRAME_HEAD1) rx_state = 1; else rx_state = 0; break; case 1: if (ch == FRAME_HEAD2) { rx_len = 0; rx_state = 2; } else rx_state = 0; break; case 2: // 收 cmd + seq(2字节) rx_buf[rx_len++] = ch; if (rx_len == 3) rx_state = 3; break; case 3: // 收 len(2字节) rx_buf[rx_len++] = ch; if (rx_len == 5) { data_len = rx_buf[3] | (rx_buf[4] << 8); if (data_len > 256) { rx_state = 0; // 长度非法,重新同步 } else { rx_state = 4; } } break; case 4: // 收数据段 rx_buf[rx_len++] = ch; if (rx_len == 5 + data_len) rx_state = 5; break; case 5: // 收 CRC(2) + 帧尾(2) rx_buf[rx_len++] = ch; if (rx_len == 5 + data_len + 4) { if (rx_buf[rx_len-1] == FRAME_TAIL2 && rx_buf[rx_len-2] == FRAME_TAIL1) { ProcessFrame(rx_buf, rx_len); } rx_state = 0; } break; default: rx_state = 0; break; } }

逻辑说明:状态机核心是「逐字节位移 + 分段确认」。case 0 和 case 1 匹配双字节帧头,一旦第二个字节不匹配立刻回 0,绝不等到凑满一帧再校验,好处是干扰字节最多浪费两个中断;case 2、case 3 收命令和长度,先限制长度上限再进数据接收,防止一个坏节点把缓冲区撑爆;case 5 收满后先验帧尾再移交处理,CRC 校验放 ProcessFrame 里做。

参数说明:rx_buf 开 260 字节,数据段上限 256 加上命令、序号、长度字段留 4 字节余量;data_len 要存成全局变量,因为状态机跨多个中断执行;HAL_UART_Receive_IT 在回调开头重挂,如果忘了,串口只收一包就再也不进中断。中断优先级建议在 CubeMX 里配到最高,Flash 擦写期间不能被更高优先级的外设打断太久,否则 ORE 溢出标志会吞字节。

3.2 Flash 擦写:半字编程与页边界对齐

F103 的 Flash 编程粒度是 16 位半字,这是最容易翻车的地方。用 32 位整字写,HAL 库会返回 FLASH_ERROR_PG,很多人查半天找不到原因。这套代码里按半字循环写,干净利落:

static void IAP_WriteFlash(uint32_t app_addr, uint8_t *data, uint32_t len) { uint32_t addr = app_addr; HAL_FLASH_Unlock(); // 解锁 Flash 控制寄存器 for (uint32_t i = 0; i < len; i += 2) { uint16_t halfword = data[i] | (data[i + 1] << 8); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr, halfword); addr += 2; } HAL_FLASH_Lock(); }

逻辑说明:HAL_FLASH_Program 第三个参数是 16 位数,data 切片的长度必须保证偶数,奇数时补 0xFF。写入地址必须从 APP 起始地址连续递增,不能跳页写。写之前必须先做整页擦除,否则往不是 0xFF 的地址写会直接报错,擦除用 FLASH_ErasePage 按页循环,F103C8 每页 1KB:

for (uint32_t addr = APP_ADDR; addr < APP_ADDR + APP_SIZE; addr += 1024) { FLASH_ErasePage(addr); }

参数说明:APP_SIZE 按 0x7C00 算,F103C8 一共要擦 31 页;换高密度芯片时把 1024 改成对应页大小,比如 F103ZET6 是 2048,这个数值跟 Flash 型号绑定,我一般会在文件头写个宏,注明当前用的哪个型号,避免换板子时漏改。

3.3 跳转 APP:MSP 合法性检查与向量表切换

跳转是整个 IAP 里最玄学的部分,也是最容易跑飞的一步。这套代码的处理方式很稳,先校验再跳:

void Boot_JumpToApp(uint32_t app_addr) { uint32_t msp = *(volatile uint32_t *)app_addr; uint32_t reset = *(volatile uint32_t *)(app_addr + 4); // 栈顶指针必须是合法 RAM 地址,否则 APP 根本没烧进去 if (msp < 0x20000000 || msp >= 0x20005000) { printf("[IAP] app msp invalid: 0x%08x\r\n", msp); return; } __disable_irq(); // 关全局中断,跳转过程不允许被打断 HAL_DeInit(); // 外设复位到上电状态 SCB->VTOR = app_addr; // 向量表偏移到 APP 区 __set_MSP(msp); // 换栈顶 void (*entry)(void) = (void (*)(void))reset; entry(); // 跳转到 APP 复位向量 }

逻辑说明:跳转前置条件有三个,缺一不可。第一,msp 必须是合法 RAM 地址,这是判断 APP 有没有烧进去最便宜的办法,比全盘 CRC 快得多;第二,跳转前关中断,否则跳的过程中一个 SysTick 或串口中断进来,向量表还停在 Bootloader,必然跑飞;第三,HAL_DeInit 把时钟和外设复位干净,让 APP 从干净状态启动,不清理的话 APP 里 HAL_Init 极大概率初始化失败。

参数说明:msp 合法范围按 F103C8 的 20KB RAM 写死成 0x20005000,换大内存型号要改这里。SCB->VTOR 在 Cortex-M3 上实测可写,要求 64 字节对齐,Bootloader 和 APP 都按 1KB 对齐所以没问题。如果读者用的旧版标准库编译报错说 SCB 没有 VTOR 成员,换成 HAL 库或者直接写寄存器地址 0xE000ED08 都能解。

3.4 APP 侧配套:IROM1 地址与向量表偏移

APP 工程不能直接拿来就烧,要改两处。第一处是链接地址:Keil 的 Target 选项里 IROM1 起始地址设 0x08008000,大小设 0x7C00(31KB),这样编译出的 bin 自带正确的地址信息。第二处是 main 开头补向量表偏移:

int main(void) { SCB->VTOR = 0x08008000; // 让中断向量表指向 APP 自己 HAL_Init(); SystemClock_Config(); // ... 业务代码 }

逻辑说明:不改 VTOR 的话,APP 里任何中断来了都会去 Bootloader 的向量表找处理函数,表现就是「主循环能跑、所有中断全失效」。这里有个细节:启动文件里如果定义了固定向量地址,还要去 system_stm32f1xx.c 里把 VECT_TAB_OFFSET 改成 0x8000,两种方式做一处即可,我的习惯是直接在 main 里显式写 SCB->VTOR,因为宏定义那套间接写法太容易踩坑,代码里一眼能看到实际值。

4. 运行验证:编译配置、烧录顺序与升级全流程

4.1 工程配置:两个工程、几条关键设置

Bootloader 工程烧在 0x08000000,IROM1 不用改。APP 工程要动三个地方:IROM1 Start 改成 0x08008000,Size 改成 0x7C00;User 选项卡里加一条 fromelf 命令生成 bin;烧录配置里要注意别把 Bootloader 覆盖掉。

fromelf --bin --output app.bin app.axf

这条命令挂在 Keil User 的 After Build 里,编译完自动产出 app.bin。烧录时 Bootloader 用 ST-Link 直烧 0x08000000,APP 用「只擦已用扇区」方式烧到 0x08008000。新手最容易犯的错是选「Erase Full Chip」烧 APP,把 Bootloader 一起擦了,然后上电没有任何反应。正确做法是烧 APP 时选择只擦算法需要的扇区,或者每次全擦后按「先 Bootloader 后 APP」的顺序重烧一遍。

4.2 串口参数与上位机发送

串口参数固定 115200、8 数据位、无校验、1 停止位。上位机加载 app.bin 后,协议栈会自动按 256 字节切片,逐包发送并等待 ACK。项目自带了配套的上位机程序,Windows 下直接选串口和 bin 路径就能跑。上位机的发送节奏由 Bootloader 的 ACK 驱动:收到 ACK 发下一包,超时 500ms 重发当前包,这样天然避免了 Flash 擦除期间数据覆盖的问题。

串口日志长这样:

[IAP] Bootloader v1.0 [IAP] upgrade flag detected, stay in boot [IAP] CMD_HANDSHAKE, ACK [IAP] CMD_ERASE, erase 31 pages ... OK [IAP] CMD_DATA pkt 0, ACK [IAP] CMD_DATA pkt 1, ACK ... [IAP] CMD_DATA pkt 127, ACK [IAP] CMD_FINISH, crc check OK [IAP] jump to 0x08008000

如果波特率对不上,日志会直接乱码;如果擦除没完成就收到数据帧,Bootloader 会回 NACK 并停留在等待状态,上位机超时重发即可,不会把错误数据写进 Flash。

4.3 验证链路:升级进入、断电再试、回跳闭环

完整验证要跑五步,缺一步都不算闭环:

  1. 全片擦除,先烧 Bootloader 到 0x08000000,再烧测试 APP 到 0x08008000,上电应该直接跑 APP,串口打印 APP 启动信息。
  2. 在 APP 里通过调试串口发 UPGRADE 命令,APP 写入升级标志并软复位。这一步验证的是 APP 主动让位给 Bootloader 的能力,量产场景下就是远程升级的入口。
  3. Bootloader 上电看到标志,打印 staying in boot,进入升级等待状态。
  4. 上位机发送 app.bin,观察逐包 ACK 和完成跳转。此时串口日志里能看到完整的 IAP 流程。
  5. 断电再上电,Bootloader 应检测到 APP 合法、跳过升级直接跳转。再触发一次 UPGRADE,重复升级流程,确认链路可重复使用。

第五步很多人会略过,但恰恰是最有价值的:它验证了「断电重启后 Bootloader 不会误入升级模式」和「APP 升级标志能被正确清除」这两个边界条件。我自己的习惯是这五步连跑三轮,第二轮故意在第 100 包时拔线,确认 Bootloader 停在等待状态、上位机重连后能继续升级而不是把半截固件跳进 APP。

5. 避坑排查:升级失败最常见的五个现象与解决办法

5.1 现象一:跳转后程序跑飞或硬件崩溃

现象:Bootloader 打印 jump 之后,板子直接死机,串口无输出,DEBUG 停在 HardFault_Handler。

原因:最常见的有两种。一是跳转前没关中断,跳转过程中 SysTick 或串口中断触发,PC 指针还指向 Bootloader 向量表;二是 msp 从空的 Flash 地址读出来,得到 0xFFFFFFFF,明显不是合法 RAM 地址,跳过去必炸。

解决:跳转前调用 __disable_irq() 关全局中断,HAL_DeInit() 复位外设,再设置 VTOR 和 MSP。栈顶指针校验不能省,非法地址直接 return,留在 Bootloader 打印错误信息,比黑屏好排查一百倍。我见过有人把合法性检查注释掉,后来升级失败时只能靠猜。

5.2 现象二:APP 能跑但所有中断不响应

现象:APP 里的 while(1) 循环正常,点灯正常,但一开串口中断或定时器就卡死,没有任何报错。

原因:APP 启动后没改向量表,中断来了还去 Bootloader 的向量表找入口,找到的地址是 Bootloader 的函数,等于进入了错误的上下文。

解决:APP 的 main 开头执行 SCB->VTOR = APP_ADDR。如果换的是旧标准库,编译报错说 SCB 没这个成员,改成 HAL 库,或者自己按「0xE000ED08 |= APP_ADDR & 0xFFFFFF80」这种寄存器写法设置。另一个点:APP 工程的 IROM1 大小如果没改,编译出来的 bin 会超出 APP 区,烧写时覆盖到标志区甚至 Bootloader 区,这种问题单独看中断不响应是看不出来的,要先核对链接地址。

5.3 现象三:Flash 写入报 HAL_FLASH_ERROR_PG

现象:调用 HAL_FLASH_Program 后返回 HAL_ERROR,串口反复回 NACK,上位机重传到天荒地老。

原因:两个高频原因。一是没先解锁就调用 HAL_FLASH_Program,虽然 HAL 库内部会检查,但很多人手动把 Unlock 注释了;二是编程宽度用错,F103 是 16 位半字编程,有人直接拷 F4 的 WORD 模式代码,必报错。

解决:写之前 HAL_FLASH_Unlock、写之后 HAL_FLASH_Lock,这是固定搭配;编程宽度固定用 FLASH_TYPEPROGRAM_HALFWORD,数据长度保持偶数,不足补 0xFF。还有一个隐蔽问题:擦除用的页大小搞错。F103C8 是 1KB/页,F103ZET6 是 2KB/页,代码里写死 1024 的话,后者的页只擦了一半,新数据写进去就会撞上旧数据。

5.4 现象四:串口收到的日志全是乱码

现象:Bootloader 打印信息完全不可读,上位机握手永远等不到 ACK。

原因:八成是波特率两边不一致,但真正容易漏的是 CubeMX 时钟配置。系统时钟如果从外部晶振改成内部 HSI,而串口波特率按外部 8MHz 晶振计算,实际波特率差一截,出来的就是乱码。

解决:先在 CubeMX 里确认 HSE 频率和系统时钟,再看串口初始化里波特率是否匹配。定位技巧是先用逻辑分析仪或示波器量 TX 引脚,看每位宽度对应的实际波特率;没有示波器就把 Bootloader 的波特率降到 9600 试一次,能通说明代码问题在时钟不在协议。

5.5 现象五:升级到一半卡死,串口中途无响应

现象:擦除完成后,上位机发数据包,Bootloader 发送十几包 ACK 后突然没反应,上位机超时重发也没用。

原因:擦除 Flash 期间 CPU 被阻塞,串口中断虽然挂着,但 ORE(溢出错误)标志位被置位,导致后续字节全部丢失。115200 波特率下每字节约 86us,擦一页 1KB 要十几毫秒,期间来的数据全被硬件丢弃。上位机如果不做流控,一口气狂发几十包,必然翻车。

解决:协议层的 ACK 机制本身就是流控,上位机必须等 ACK 才能发下一包;同时 Bootloader 在处理 0x03 数据帧时,先把串口接收中断挂起,写完 Flash 再重新挂起接收。代码里处理完一帧后主动调 HAL_UART_Receive_IT 重新使能接收,就是干这个的。如果还有余量,可以把串口接收改成 DMA + 环形缓冲,但这套工程用 ACK 流控已经足够稳定。

6. 二次开发方向:把这套 IAP 改成自己的升级方案

拿到手先别急着改协议,把原有工程烧一遍,确认升级链路畅通,再动刀。我建议从三个方向改。

第一,帧结构加版本字段。现在的帧命令只有 0x01~0x04,可以在命令字节后加 2 字节固件版本号,握手时 Bootloader 对比版本,版本不新直接拒绝升级,避免把旧固件刷回去。这个改动上下位机要同步,注意协议帧长度变了,rx_buf 上限从 260 调到 264,长度校验的上限也要跟着改。

第二,补一个 CRC32 整片校验。现在帧内是 CRC16,只能保证单包传输没问题,如果上位机选错了 bin 文件、或者 Flash 写入本身有问题,Bootloader 是不知道的。APP 区写完后,Bootloader 对整个 APP 区算一遍 CRC32,和上位机在完成帧里带的期望值比对,对不上就停留在 Bootloader 等待重新升级。这一步挡住的是「传输正确但固件本身烧错」的边界情况。

第三,升级失败自动回退。在标志区再开一个字节当启动计数器,APP 每次正常启动后把它清零,Bootloader 发现计数器连续超过 3 次,就判定 APP 起不来,停留在 Bootloader 而不跳转。这个功能在量产设备上非常实用,固件刷挂了不至于变砖,重新走一遍升级流程就能救回来。代价是 APP 启动时要多写一次 Flash,对 Flash 寿命的影响可以忽略。

验证方法上,每次改完 Bootloader 或协议,强制走一遍「全片擦除 → 烧 Bootloader → 升级 APP → 断电再上电 → 触发升级 → 升级后再断电」这五步闭环。我第一次做 IAP 时栽在 APP 中断不响应上,排查了整整一天,最后发现就是忘了把向量表指到 APP 区。从那以后我每次做完 Bootloader,都会强制走一遍这五步,每轮都盯着串口日志里最后那一行跳转地址。希望帮到你。

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

RELATED READING

延伸阅读

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