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 负责写 FlashAPP 负责跑业务两者不能互相覆盖。分区是第一优先级的设计决策我见过的翻车现场里七八成都是分区改乱了APP 链接地址和 Bootloader 擦写地址对不上或者升级时把 Bootloader 自己给擦了。以 F103C8T664KB Flash、1KB/页为例这套项目用的是三区布局区域起始地址大小说明Bootloader0x0800000032KB0x8000上电启动、串口收包、Flash 写包APP0x08008000约 31KB用户业务固件带自己的向量表标志区0x0800FC001KB存升级标志和 APP 成功运行标志Bootloader 占 32KB 是偏保守的做法换到 F103ZET6512KB这类高密度型号每页 2KB分区按页对齐即可逻辑不变。关键点只有一个Bootloader 大小必须是 N 页整数倍这样 APP 起始地址天然对齐页边界擦写不会串区。这套代码里 Bootloader 工程是独立的一个APP 工程也单独一个两边的 Flash 起始地址用宏写死改动时只动一处。2.2 升级协议帧头帧尾、长度字段与 ACK/NACK 应答协议层决定了上位机怎么把 bin 文件切碎、下发、确认。这套代码用的是「一次一包」的确认机制帧格式如下字段长度说明帧头2 字节0xAA 0x55命令1 字节0x01 握手 / 0x02 擦除 / 0x03 写数据 / 0x04 完成包序号2 字节小端用于排查乱序和重发定位数据长度2 字节0256数据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 每页 1KBfor (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 跳转 APPMSP 合法性检查与向量表切换跳转是整个 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大小设 0x7C0031KB这样编译出的 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 工程烧在 0x08000000IROM1 不用改。APP 工程要动三个地方IROM1 Start 改成 0x08008000Size 改成 0x7C00User 选项卡里加一条 fromelf 命令生成 bin烧录配置里要注意别把 Bootloader 覆盖掉。fromelf --bin --output app.bin app.axf这条命令挂在 Keil User 的 After Build 里编译完自动产出 app.bin。烧录时 Bootloader 用 ST-Link 直烧 0x08000000APP 用「只擦已用扇区」方式烧到 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 验证链路升级进入、断电再试、回跳闭环完整验证要跑五步缺一步都不算闭环全片擦除先烧 Bootloader 到 0x08000000再烧测试 APP 到 0x08008000上电应该直接跑 APP串口打印 APP 启动信息。在 APP 里通过调试串口发 UPGRADE 命令APP 写入升级标志并软复位。这一步验证的是 APP 主动让位给 Bootloader 的能力量产场景下就是远程升级的入口。Bootloader 上电看到标志打印 staying in boot进入升级等待状态。上位机发送 app.bin观察逐包 ACK 和完成跳转。此时串口日志里能看到完整的 IAP 流程。断电再上电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 改成自己的升级方案拿到手先别急着改协议把原有工程烧一遍确认升级链路畅通再动刀。我建议从三个方向改。第一帧结构加版本字段。现在的帧命令只有 0x010x04可以在命令字节后加 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都会强制走一遍这五步每轮都盯着串口日志里最后那一行跳转地址。希望帮到你。本文还有配套的精品资源点击获取