STM32串口IAP升级实战:Bootloader与App分区设计及避坑指南
简介STM32F103串口IAP升级程序是一套完整且可直接落地的固件在线升级方案面向嵌入式开发工程师及学习IAP原理的爱好者解决设备运行中无需断电、不使用编程器即可更新固件的需求。该工程整合了开发者原创的核心逻辑与部分参考代码包含完整的Bootloader、串口通信、固件接收与校验、Flash擦写、错误恢复及固件合法性校验机制并附带一个测试应用程序用于验证升级流程的稳定性代码结构清晰关键流程带有注释便于理解跳转与Flash操作细节。压缩包共290个文件以C源码和头文件为主h/c文件合计140个另有编译生成的axf/bin/hex可直接烧录文件、Keil工程文件uvprojx/uvoptx及map/lst/sct等辅助文档整体体积约5.68MB目录结构清晰便于直接查阅与二次开发。目前已有2786人学习下载适合希望深入理解IAP实现细节的STM32开发者可据此快速搭建自己的串口升级框架。1. 串口IAP升级为什么你的产品不能每次升级都靠J-Link做过量产设备的人都有过这种经历产品已经装进机箱、封好外壳结果客户说“逻辑要改”你只能拆机、飞线、接上烧录器战战兢兢地刷一遍固件。要是产品在异地那更是一场灾难。这份原创的STM32F103串口IAP升级程序解决的就是“不用拆机、不用J-Link、通过串口就能升级固件”这件事。它自带一个完整的Bootloader工程和一个测试App工程Bootloader负责接收串口数据并写入FlashApp负责跑业务逻辑并在启动时跳转到Bootloader进入升级模式。两个工程互相配合你拿来改一改就能用在自己的板子上。项目适合三类人正在做量产设备的嵌入式工程师想搞懂IAP跳转和Flash读写细节的STM32学习者以及手头有项目但一直用烧录器升级、想改成串口升级的开发者。整体技术栈就是STM32F103标准外设库没有依赖复杂的RTOS或第三方组件逻辑清晰移植成本低。下面我从架构开始拆然后给出具体跑通步骤和能直接抄的代码段。2. 架构设计两个工程的协作方式与Flash分区规划2.1 Bootloader与App的分区设计IAP的核心思想是把Flash分成两个区域Bootloader区运行在起始地址App区运行在高地址段。设备上电先跑BootloaderBootloader决定是进入App还是等待新的固件包。这份资源里的分区规划用的是最常见也是最稳妥的方案Bootloader放在0x08000000起始的32KB空间App放在0x08008000起始的剩余空间。// Bootloader工程中的Flash起始地址定义 #define BOOTLOADER_START_ADDR 0x08000000 // Bootloader起始地址 #define APP_START_ADDR 0x08008000 // App起始地址偏移32KB #define APP_MAX_SIZE 0x0000F000 // App最大空间60KB留出最后的标志位区域分区参数说明Bootloader占32KB足够放下串口接收、Flash擦写、协议解析这些逻辑。App最大60KB对于大多数中小型应用够了不够的话可以调整偏移量但要注意Bootloader和App的编译链接地址必须对应上。最后的0x1000空间我用来放升级标志避免每次上电都进Bootloader等待超时后面会详细讲。提示分区前先看自己的芯片型号F103C8T6是64KB FlashF103RBT6是128KB别把App大小设得超过实际容量。2.2 跳转实现从Bootloader到App的关键代码Bootloader判断条件满足后需要跳转到App的复位向量地址。这里有一个最容易踩的坑不能直接调用函数指针跳转必须先关闭全局中断、设置好主栈指针再从App的复位向量取出地址跳过去。以下是标准做法。// IAP_JumpToApp函数完成从Bootloader到App的跳转 typedef void (*pFunction)(void); pFunction JumpToApplication; void IAP_JumpToApp(uint32_t app_addr) { uint32_t app_reset_addr; // App复位向量地址存放在App起始地址的前4字节 uint32_t app_stack_addr; // App初始栈顶地址存放在App起始地址的前4字节的地址里 // 先关闭全局中断避免跳转过程中被打断 __disable_irq(); // 取App起始地址处的栈顶值前4字节 app_stack_addr *(volatile uint32_t *)app_addr; // 取App复位中断向量前4字节之后的4字节 app_reset_addr *(volatile uint32_t *)(app_addr 4); // 校验栈顶地址是否在RAM范围内防止跳到无效地址 if ((app_stack_addr 0x2FFF0000) ! 0x20000000) { return; // 栈顶异常不执行跳转 } // 设置主栈指针为App的栈顶值 __set_MSP(app_stack_addr); // 重新使能中断App主程序里会再次初始化中断向量表 __enable_irq(); JumpToApplication (pFunction)app_reset_addr; JumpToApplication(); // 跳转到App的Reset_Handler }这段代码的逻辑说明前4字节是SP值紧接着4字节是Reset_Handler地址这是Cortex-M3内核的规定。校验栈顶地址落在0x20000000到0x2000FFFF之间是为了防止Flash数据错误时程序飞到莫名其妙的地方。我在实际项目里还加过CRC校验但这份例程没有你自己加也很简单后面会提。跳转之前务必关闭中断否则中断响应时PC已经跳到AppNVIC却还挂着Bootloader的向量表会直接HardFault。2.3 App工程的编译地址设置与中断向量重映射App工程不能直接用默认的0x08000000编译必须把链接地址改到0x08008000否则Bootloader跳过去会跑飞。同时App内部的中断服务函数比如USART1_IRQHandler、定时器中断它们的地址是基于编译时指定的Flash地址算出来的所以需要在App启动的最早阶段重新设置中断向量表。// App工程main.c的启动部分 int main(void) { // 关键重映射中断向量表到App所在Flash区域 // 第一个参数是向量表基地址这里设为App起始地址 // 第二个参数是NVIC向量表重映射偏移量寄存器 NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x08008000); // 剩下的就是正常的时钟、外设初始化 SystemInit(); GPIO_Configuration(); USART_Configuration(); // ... 你的业务代码 }参数说明NVIC_SetVectorTable的第一个参数NVIC_VectTab_FLASH表示向量表在Flash里第二个参数是偏移量。如果用标准外设库这一步必须在任何可能触发中断的操作之前完成否则一旦使能外设中断CPU去旧地址找向量表就会异常。很多新手把这一行放到SystemInit()之后结果串口中断一进来就死机就是因为向量表还没有偏移过去。2.4 升级标志位上电时决定进Bootloader还是App如果Bootloader每次上电都要等待几秒看有没有升级指令那用户体验很差。常规做法是用Flash的最后一个页专门存升级标志。Bootloader启动时读这个标志为特定值就进入升级等待模式为另一个值就直接跳到App。// 升级标志地址定义使用Flash最后一页的起始地址 #define UPGRADE_FLAG_ADDR 0x0800FC00 // 以64KB Flash为例最后一页起始地址 #define UPGRADE_FLAG_VALUE 0xA5A5A5A5 // 需要升级时写入的固定值 // Bootloader启动流程 int main(void) { uint32_t flag_value; // 读取升级标志 flag_value *(volatile uint32_t *)UPGRADE_FLAG_ADDR; if (flag_value UPGRADE_FLAG_VALUE) { // 需要升级清除标志进入等待接收固件的模式 IAP_EraseFlashPage(UPGRADE_FLAG_ADDR); IAP_WaitForUpgrade(); } else { // 无升级需求直接跳转到App IAP_JumpToApp(APP_START_ADDR); } }这个设计的巧妙之处在于App端收到升级指令后先写升级标志然后软复位这样Bootloader上电后自然就走升级流程。App正常工作时这个标志位是无效值所以每次上电直接跳App几乎不增加启动延迟。这里注意擦除标志页之前要把标志读出来保存或者先擦除再写顺序反了会导致上电后标志还残留着一直进升级模式。3. 串口通信协议与固件接收一帧数据怎么被完整写入Flash3.1 协议帧格式设计IAP的数据链路用的是串口UART没有用现成的Ymodem协议而是自定义了一套简单的帧格式。这么做的好处是逻辑透明、容易二次修改坏处是你要自己保证校验和粘包处理。这份例程的帧格式定义如下字段长度说明帧头2字节固定为0xAA 0x55用于帧同步指令1字节0x00表示写入数据0x01表示结束升级0x02表示读取版本数据长度2字节本帧携带的有效数据字节数小端序数据区N字节固件二进制数据最大不超过256字节累加校验1字节前面所有字节的累加和取低8位// 协议解析中的帧缓冲区结构体 typedef struct { uint8_t buf[260]; // 接收缓冲区2帧头 1指令 2长度 256数据 1校验 uint8_t state; // 当前状态机的状态 uint8_t data_len; // 本次帧的有效数据长度 uint8_t received_len; // 当前已接收的字节数 } IAP_Frame_t;这种协议比裸发数据包要稳得多因为帧头、长度、校验都在接收方可以根据长度字段决定收多少字节再判断校验而不是靠固定长度的包来处理。实际产品里我还建议在数据区里加入地址偏移字段让上位机可以跳着写Flash这样即使传输中断也可以断点续传。3.2 串口状态机接收逐字节解析不丢数据串口接收如果采用简单的“收满一包再处理”可能因为与帧边界不齐导致丢帧。我在这份例程里用的是状态机方式每个字节进来都走一次状态迁移这样不管上位机从什么位置开始发都能同步上帧头。// 串口中断里逐字节调用IAP_ParseByte uint8_t IAP_ParseByte(IAP_Frame_t *frame, uint8_t byte) { switch (frame-state) { case 0: // 等待第一个帧头0xAA if (byte 0xAA) { frame-state 1; } break; case 1: // 等待第二个帧头0x55 if (byte 0x55) { frame-state 2; } else if (byte ! 0xAA) { frame-state 0; // 不是0x55也不是0xAA重新等帧头 } else { // 仍然是0xAA保持状态1继续等0x55 } break; case 2: // 接收指令字节 frame-buf[2] byte; frame-state 3; break; case 3: // 接收数据长度低字节 frame-buf[3] byte; frame-state 4; break; case 4: // 接收数据长度高字节 frame-buf[4] byte; frame-data_len frame-buf[3] | (frame-buf[4] 8); if (frame-data_len 256) { frame-state 0; // 长度异常重新同步 } else { frame-received_len 0; frame-state 5; } break; case 5: // 接收数据区 if (frame-received_len frame-data_len) { frame-buf[5 frame-received_len] byte; frame-received_len; if (frame-received_len frame-data_len) { frame-state 6; // 数据收满进入校验等待 } } break; case 6: // 接收校验字节 frame-buf[5 frame-data_len] byte; if (IAP_CheckSum(frame-buf, 5 frame-data_len) byte) { frame-state 0; return 1; // 校验通过通知主循环处理这帧数据 } frame-state 0; break; default: frame-state 0; break; } return 0; }逻辑说明状态0和1负责消抖帧头比如收到0xAA后收到0xAA不能把它当作第二个帧头得继续等0x55。状态3、4拼出长度后立刻判断上限防止缓冲区溢出。最后校验通过才返回1主循环拿到这个返回值再去执行Flash写入。这样串口中断里只有字节搬移和状态迁移没有耗时操作115200波特率下不会丢数据。参数说明IAP_CheckSum函数就是把所有字节累加取低8位上位机也要用同样的算法。如果你用的是带DMA的串口也可以把这一套逻辑放到DMA接收完成回调里但状态机本身不需要改。3.3 闪存写入页擦除与半字写入STM32F103的Flash写入规则是写入前必须先擦除整个页1KB然后按16位半字为单位写入不能直接写单字节。这也是为什么数据帧长度最好不要超过256字节的原因——一页1KB四帧数据正好填满一页方便边收边写。下面是处理固件数据帧的核心代码。// 处理一帧固件数据 void IAP_ProcessDataFrame(uint32_t app_base_addr, uint8_t *data, uint16_t len) { uint32_t target_addr; uint16_t i; static uint16_t offset 0; // 当前已写入的偏移量 uint16_t word_count; // 算出本次写入的目标Flash地址 target_addr app_base_addr offset; // 如果目标地址跨页需要先擦除新页 if ((target_addr % 1024) 0) { IAP_EraseFlashPage(target_addr); } // 把数据按半字16位写入Flash word_count len / 2; for (i 0; i word_count; i) { uint16_t half_word data[2 * i] | (data[2 * i 1] 8); FLASH_ProgramHalfWord(target_addr 2 * i, half_word); } // 处理奇数长度的情况最后一个字节单独补0写 if (len % 2) { uint16_t last_word data[len - 1]; // 高字节填0 FLASH_ProgramHalfWord(target_addr len - 1, last_word); } offset len; }关键点说明FLASH_ProgramHalfWord是标准外设库的Flash写函数必须确保调用前Flash已经解锁。擦除页的操作会阻塞CPU一般在擦除前关中断擦除完再开中断避免串口接收缓冲区溢出。这里的static uint16_t offset是个隐患如果中途升级失败重传需要先复位offset否则地址会错乱。更稳妥的做法是把offset作为参数传入由主循环管理。// Flash解锁与操作示例 void IAP_EraseFlashPage(uint32_t page_addr) { FLASH_Unlock(); // 解锁Flash控制寄存器 FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); // 擦除指定页F103每次擦1KB FLASH_ErasePage(page_addr); FLASH_Lock(); // 擦除完成后重新锁定 }注释说明FLASH_Unlock必须写在每次擦除和写入之前因为复位后Flash默认是锁定状态不解锁直接操作会返回写保护错误。擦除很快大约几十毫秒但对串口接收来说足够丢十来帧数据了所以上位机发送固件包的节奏一般是一帧一应答等应答后再发下一帧。3.4 完整升级流程上位机与下位机的握手过程整个串口IAP升级的时序可以分为四个阶段。阶段一是下位机进入升级等待状态后上位机发送0x02指令查询版本用于确认通信正常。阶段二是上位机发送擦除指令让下位机擦除整个App区域。阶段三是逐帧发送固件数据每帧下位机写入Flash后返回一个应答字节。阶段四是发送结束指令下位机校验整体长度和CRC可选然后跳转到App。// Bootloader主循环中的升级处理逻辑 void IAP_WaitForUpgrade(void) { IAP_Frame_t frame; uint32_t flash_write_addr APP_START_ADDR; while (1) { // 调用串口接收解析只有校验通过的帧才会返回1 if (IAP_ParseByte(frame, USART_ReceiveData(USART1))) { if (frame.buf[2] 0x00) // 写入固件数据 { IAP_ProcessDataFrame(flash_write_addr, frame.buf[5], frame.data_len); flash_write_addr frame.data_len; USART_SendByte(USART1, 0x01); // 应答成功 } else if (frame.buf[2] 0x01) // 结束升级 { // 清除升级标志位下次上电直接进App IAP_EraseFlashPage(UPGRADE_FLAG_ADDR); // 跳转到App IAP_JumpToApp(APP_START_ADDR); } } } }这段流程是简化过的实际工程里还要加超时判断如果3秒内没有新帧进来就自动复位跳转App防止升级过程中设备断电后卡死在Bootloader。另外每次写帧之前都要检查flash_write_addr是否超出APP_MAX_SIZE超出就要停止接收并上报错误。4. 测试App工程为什么需要一个假的业务程序来验证升级4.1 测试App的结构与LED控制逻辑这套资源里附带的App工程本质上是一个“用来证明升级成功”的试金石。它编译出的固件通过Bootloader烧进Flash后会控制开发板上的LED以某个频率闪烁并且串口会输出版本信息字符串。当你升级了一份新版AppLED闪烁频率发生变化就说明固件确实被更新了。// App工程中用于验证升级版本的主循环 int main(void) { NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x08008000); SystemInit(); LED_Init(); USART1_Init(115200); printf(App Version 2.0\r\n); // 这个版本号会在升级后改变 while (1) { LED_Toggle(); // 翻转LED DelayMs(500); // 旧版延时500ms新版改成300ms } }这个App工程的价值不只是跑起来它还是一个教学模板工程里已经把启动文件、链接脚本的Flash地址改到了0x08008000编译之前不用再手动改一堆配置。你把自己的业务代码填充到这份App骨架里就能直接生成可被Bootloader接收的固件。注意printf要重定向到串口标准外设库例程里一般会重写fputc函数。4.2 生成可烧录固件从Keil工程到bin文件Bootloader接收的数据是纯二进制文件也就是.bin不是Keil生成的.hex。因为.hex是文本格式每行有地址和校验不适合直接按帧刷到固定偏移。在Keil里生成bin文件的方法是在After Build的User选项卡里添加一条命令。fromelf --bin --output ..\output\app.bin ..\obj\app.axf命令说明fromelf是Keil自带的格式转换工具--bin表示输出二进制格式--output指定输出路径最后的参数是AXF文件名。你也可以用自己写的上位机直接解析HEX文件提取数据但那样上位机逻辑复杂很多不建议。4.3 测试App的修改方式调整版本号和闪灯频率验证升级效果不需要改业务逻辑只需要改两个地方把printf里的版本号从1.0改成2.0把LED翻转延时从500ms改成300ms。编译生成新的bin通过串口工具发送给Bootloader完成后看到LED闪烁变快升级就成功了。这个做法简单直观我每次给客户演示IAP功能时都靠这个手段当场证明新旧固件确实被切换了。5. 避坑与常见问题排查串口升级翻车记录5.1 跳转App后程序跑飞HardFault现象Bootloader通过串口完成升级复位后LED不亮串口无输出调试器看到程序停在HardFault_Handler里。原因最常见的是App工程的链接地址没有改成0x08008000。Bootloader跳到了0x08008000但App代码实际编译在0x08000000CPU取到的第一条指令就是错的。另一个原因是没有在App里调用NVIC_SetVectorTable虽然跳转时SP和PC都对但一旦App里发生任何中断CPU去0x08000000找中断向量表找到的是Bootloader的服务函数两个工程共用同一份向量表地址直接冲突。解决打开Keil的“Options for Target”把IROM1的Start地址改为0x08008000Size对应减小。同时在App的main函数第一行调用NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x08008000)并且保证这行代码在初始化任何中断之前在。5.2 升级过程中途断电设备变砖现象擦除Flash时突然断电再上电设备没有任何反应Bootloader也不进、App也跑不了。原因擦除动作发生在Flash里如果此时正好擦除了Bootloader所在的扇区比如把App起始地址设错了或者擦除函数参数传了0x08000000Bootloader自身就没了自然无法启动。还有一种情况是App区域擦到一半标志位还没清除上电后Bootloader认为需要升级一直等待串口数据但App已经不存在看起来也是“变砖”。解决把升级标志位区域和Bootloader区域分开不要放在同一个扇区。擦除App之前先检查目标地址是否大于等于0x08008000小于就报错。更保险的方案是“先接收后擦除”把完整固件先暂存到外部Flash或RAM如果RAM够大校验通过后再擦除App区并写入这样断电不会造成中间状态。5.3 串口能收到数据但写入Flash失败现象上位机发送固件帧下位机总返回错误应答或者明明写成功了跳转后运行结果不对。原因Flash操作前没有解锁或者一次写入的字节数不是偶数。F103的Flash编程必须按16位半字操作如果你发送的帧长度是奇数最后一个字节需要补0凑成半字。另外如果芯片有读保护RDPFlash写入会直接报错。解决在每次FLASH_ProgramHalfWord前调用FLASH_Unlock写完后FLASH_Lock。处理奇数长度帧时在数据末尾补一个0x00。在调试器里查看FLASH-CR寄存器的PG位和LOCK位确认解锁是否成功。读保护可以用烧录器解除但它会先全片擦除所以量产设备升级时一定要关掉读保护或者用支持在保护状态下写入的专用方案。5.4 上位机发送正常Bootloader却收不到完整帧现象串口助手里能看到数据在发但下位机一直没有应答或者偶尔能收到一次经常卡住。原因Bootloader的串口接收缓冲区溢出。Bootloader主循环一边擦Flash一边写擦除过程中CPU被阻塞串口中断没法及时把新到的字节搬走硬件FIFO溢出后后续字节直接丢弃。解决一是降低波特率到9600或19200延长单字节传输时间二是改为“边收边写”策略每收到一帧就立即擦写擦写前关串口中断擦写完再打开同时上位机等待应答再发下一帧三是在主循环里用查询方式轮询串口状态寄存器避免中断被屏蔽时丢字节。5.5 App跑起来后第一次串口中断就死机现象Bootloader跳转成功App的LED亮起来了但只要串口收发一个字节程序就跳到HardFault。原因这是典型的向量表没有重映射。App在main开始就初始化串口并使能中断但此时NVIC还在用Bootloader的向量表偏移量中断信号来了CPU去0x08000000查向量表找到的地址是Bootloader的USART1_IRQHandler但这个函数在App工程里并没有真正定义或者地址不对跳转过去就是非法地址。解决在App的main第一行加NVIC_SetVectorTable并且在跳转前通过Bootloader禁用全局中断App初始化完后再打开。如果Bootloader的跳转函数里没有关闭中断建议从Jump代码到App启动这段路径上全部屏蔽中断最稳妥的办法是在跳转前调用__disable_irq()App的SystemInit后调用__enable_irq()。6. 二次开发把这份IAP改造成你的量产升级方案拿到这份代码后不要直接拿来做产品先做你自己的适配。第一步是调整Flash分区查看你的芯片型号如果Flash总容量是64KBBootloader还是建议32KB起App区域剩下32KB如果是128KBBootloader可以压缩到16KB给App留下更多空间。分区调整后需要同步修改Bootloader里的APP_START_ADDR和App工程的链接脚本两边的值必须一字不差。第二步是升级传输协议。这套源码用的是串口直连但实际产品往往通过WiFi模块或者CAN总线转发升级数据。我的做法是把协议解析层和传输层完全分离串口接收直接映射到一个环形队列接口WiFi接收的数据写入同一个队列这样IAP解析函数不用改动。你只需要实现一个UART_SendByte的替代函数把数据送到你的WiFi透传通道即可。第三步是加校验。现在只有累加和建议升级为CRC32哪怕是个简化版。在结束升级指令里带上整个固件的CRC值Bootloader写完最后一段后重新读Flash计算CRC比对不一致就回滚到旧版本重新进入等待升级状态。具体做法是在App区末尾保留两个向量一个是“最新有效固件CRC”一个是“回滚地址”写失败时跳回上一个版本。// 二次开发时加入的整体校验伪代码 uint32_t crc 0; uint32_t flash_size IAP_GetAppSize(); // 从固件头部读取App实际长度 // 结束后校验 for (uint32_t addr APP_START_ADDR; addr APP_START_ADDR flash_size; addr 4) { crc IAP_CRC32(crc, *(volatile uint32_t *)addr); } if (crc ! expect_crc) { // 校验失败重新擦除并等待接收或者回滚到备份区 IAP_EraseFlashPage(APP_START_ADDR); IAP_WaitForUpgrade(); }这段逻辑适合放在结束指令的处理分支里此时所有数据都写完了重新读Flash的耗时虽然多但能够发现“写入中途Flash异常”这类数据区不可见的问题。我一般还会加一个“最大重试次数”上位机连续发送三次都校验失败就停止升级防止一直擦写把Flash寿命耗尽。第四步是处理掉电恢复。上一节提到的现象已经证明中间态断电是量产升级最怕的事。我习惯的改进方案是A/B面互备把App区分成A区和B区Bootloader先向B区写新固件校验成功后再把跳转地址切到B区。这样升级过程中A区始终是完整可跑的即使B区写坏了Bootloader自动回退到A区。代价是App可用空间减半但对于Flash容量充足的芯片来说这是最稳的路子。验证改造效果时不要只用LED闪灯我建议做一轮“异常注入测试”在升级过程中手动拔掉串口线、直接断电、发送损坏的帧。每一次异常后重新上电设备都应该要么回到旧版本继续工作要么停留在Bootloader等待重新升级绝不应该出现“卡死、无响应、无法再次升级”的状态。这个测试通过后你的IAP才算能上产线。我最初做IAP时也因为跳转没关中断、向量表没重映射这类问题翻过车从那以后我每次改完工程都会先检查三处App链接地址、NVIC重映射、跳转前的全局中断状态。这三个点任何一处错了表面症状都是“程序跑飞”但根因完全不同。这套检查流程我建议你也固化下来能省下大量调试时间。希望你能拿这份源码顺利跑通然后把它改造成真正属于你自己的可靠升级方案。本文还有配套的精品资源点击获取