
简介针对STM32F103C8T6的串口IAP升级固件源码包面向嵌入式开发与单片机学习者解决产品量产和远程维护中的固件在线升级难题。压缩包仅2.23MB内部包含430个文件其中C源码与头文件各86个另配汇编启动文件、链接脚本、IAR及Keil工程配置、批处理脚本和bin/hex烧录文件覆盖从底层驱动到应用协议的完整研发链路可直接导入开发环境编译验证。目前已有5076人学习下载。源码包给出Bootloader与App双分区跳转的完整范例详细实现串口分包接收、帧校验、Flash擦写、中断向量重定向等关键机制同时附带的辅助脚本与烧录工具可提高测试和调试效率。源码结构规划清晰各模块职责分明重要函数附有注释便于移植到其他STM32系列芯片。作者还发布配套博客说明具体用法并支持邮件咨询适合打算深入掌握单片机在线升级技术的开发者。 最近在帮人调一批基于STM32F103C8T6最小系统板的设备产线那边反馈说每台机器出厂前都要用ST-Link插着SWD口刷固件一天刷几十台光是拔插调试器和搬运板子就占了大半工时。于是把串口IAP方式的固件升级完整走了一遍——也就是给芯片先烧一个Bootloader之后所有固件更新都走串口完成彻底告别调试器。这篇文章就把这趟实操里踩过的坑、改过的代码和验证过的流程整理出来尤其是热词里那个“iap跳转后卡死hal_delay”的问题我会单独拉一节完整复盘。1. 为什么我最终选了串口IAP这条路先明确一个概念IAPIn Application Programming就是在应用编程。和ISP那种需要改BOOT0引脚、利用芯片出厂自带的系统存储器程序下载不一样IAP是自己写一个Bootloader放在Flash起始区域上电先跑BootloaderBootloader判断有没有升级请求有就接收新固件并写入Flash没有就直接跳转到用户App。整个过程不需要BOOT0跳线也不需要ST-Link一根USB转串口线就够。对于STM32F103C8T6这颗芯片来说串口IAP尤其合适。它只有64KB Flash和20KB RAM本身定位就是小资源、低成本场景。用在这种场景里的产品通常体积小、数量多、分布散比如传感器节点、小仪表、门禁模块。如果每台都要拆外壳接调试器维护成本直接不可接受。而串口是C8T6板子几乎必带的接口CH340转出来的USB口用户设备上到处都是学习成本也低。还有一个点容易被忽略IAP不只是为了方便量产它也是你在开发阶段的一个“后悔药”。比如固件烧进去之后发现某个逻辑问题如果你没有引出一路可用的升级通道就只能再拿调试器改。我这次把Bootloader设计成“上电后短暂等待升级指令超时自动启动App”的模式开发时只要电脑开着串口助手提前发指令再按复位就能直接进升级流程省了物理跳线的麻烦。适合参考这篇文章的人我建议是这几类刚接触STM32、想搞懂Bootloader和App分区逻辑的初学者手里有C8T6最小系统板、想摆脱ST-Link反复烧录的开发者以及做小批量产、需要给设备留一条稳定远程升级通道的硬件工程师。2. Bootloader分区设计与我最终采用的传输协议2.1 C8T6的Flash容量决定了分区没那么随意STM32F103C8T6的Flash从0x08000000开始共64KB页大小是1KB一页。分区首先得保证Bootloader和App物理隔离Bootloader永远不能把自己擦掉。我给Bootloader分配了16KB也就是0x08000000~0x08003FFF共16页App从0x08004000开始长度48KB。这个分配对Bootloader来说非常宽裕即使HAL库编出来体积偏大也基本不会超过10KB剩下的空间还可以存一些引导参数。为什么Bootloader要多留一点因为Bootloader里不止一个“跳转函数”还包括串口驱动、XMODEM协议解析、Flash擦写逻辑、超时状态机。特别是XMODEM接收必须按照包序号一包一包处理状态机写起来比想象中占代码空间。我之前试着把Bootloader压到8KBHAL库不开优化时还是有点紧张后来干脆统一用16KB方案一劳永逸。如果你用的是标准外设库8KB也能放下但没必要冒这个险。App这48KB够不够用取决于你的业务代码量。一个用CubeMX生成的HAL库工程什么都不写就有十几KB再加点协议栈、状态机48KB确实不算富裕。但这恰恰是IAP这种方案对C8T6的约束如果App代码超过48KB就得把Bootloader压缩到8KB或者干脆换更大Flash的型号。分区这件事一定要在项目一开始就定下来否则后面推倒重来的成本很高。2.2 自定义协议和XMODEM我为什么选了后者串口升级传输协议常见的就是两条路自己定义帧格式或者直接套XMODEM。我在这个项目里最终选了XMODEM原因很简单省事且不容易出错。自己定义协议看上去很灵活比如你可以设计“帧头长度地址数据CRC”的结构一次性把所有包写死。但真正实现的时候会发现丢包重传、粘包处理、超时重发全部要自己写代码量翻一倍测试的时候还容易漏边界条件。XMODEM是上世纪就定型的经典协议128字节一个包包号从1递增每包带CRC16校验接收方通过ACK/NAK告诉发送方继续还是重发。虽然单包只有128字节效率不如YMODEM1024字节但C8T6的App也就三四十KB用115200波特率传几十秒就能传完完全没有必要为了那几秒去折腾更复杂的协议。用XMODEM还有个大好处现成的上位机工具遍地都是。XCOM、SSCOM、SecureCRT都内置了XMODEM发送不用自己写上位机。这一点在产线上非常重要——工人不需要懂协议选好bin文件点发送完事。2.3 Bootloader主流程和两个关键函数Bootloader的逻辑不复杂就是把“等指令、擦Flash、收数据、跳App”串起来。核心主循环大概长这样while (1) { /* 上电后等一小段时间接收升级指令 */ if (check_upgrade_command()) { /* 进入升级模式通过XMODEM接收固件并写入Flash */ if (process_xmodem() XMODEM_OK) { Jump_To_Application(); /* 升级完成跳转 */ } else { /* 接收失败继续留在Bootloader等待重试 */ } } else { if (app_is_valid()) /* 检查App区合法性 */ { Jump_To_Application(); /* 无升级请求启动已有App */ } /* App不合法继续等待升级指令 */ } HAL_Delay(10); }这里有两个点值得展开说。第一什么叫“App区合法”我在跳转前做两个地址检查读App起始地址的栈顶值确认它在SRAM范围内0x20000000~0x20005000再读App起始地址4处的复位向量确认它是Flash地址0x08000000~0x0800FFFF。如果这两个检查不过说明App区还是空的或者数据是乱的跳过去必死。第二跳转函数是IAP最核心的部分也是“IAP跳转后卡死”的高发区。我最终采用的跳转逻辑是这样的#define APP_BASE_ADDR 0x08004000UL typedef void (*pFunction)(void); void Jump_To_Application(void) { uint32_t app_stack *(volatile uint32_t *)APP_BASE_ADDR; uint32_t app_reset *(volatile uint32_t *)(APP_BASE_ADDR 4); pFunction jump; /* 栈顶地址必须在SRAM范围复位向量必须在Flash范围 */ if ((app_stack 0xFFF00000U) ! 0x20000000U) return; if ((app_reset 0xFFF00000U) ! 0x08000000U) return; /* 跳转前关闭所有中断、停止SysTick避免残留中断干扰App */ __disable_irq(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; /* 先切栈指针再设置向量表最后跳转 */ __set_MSP(app_stack); SCB-VTOR APP_BASE_ADDR; jump (pFunction)app_reset; jump(); }这段代码里有一个非常容易被忽略的点__set_MSP(app_stack)。很多人写跳转函数时只做了SCB-VTOR偏移就跳过去结果App跑起来各种硬件错误。原因在于函数指针跳转不等同于硬件复位Cortex-M3内核不会自动帮你把MSP换成App向量表里的新栈顶。如果不手动切换栈App会继续用Bootloader的栈一旦Bootloader的栈帧布局和App的预期不一致第一次函数调用压栈就会出问题。所以正确顺序一定是关中断、停SysTick、切栈、设VTOR、跳转。3. App端最容易翻车的两个改动点3.1 偏移后的中断向量表与链接地址必须严格对应App工程如果直接从0x08000000编译烧到0x08004000去跑第一件事就是跑飞。因为CPU拿到中断响应时还是习惯性去0x08000000读向量表那里是Bootloader的领地读出的中断服务函数地址全是错的。所以App工程必须同时改两个地方缺一个都不行。第一是链接脚本的FLASH起始地址。如果用的是STM32CubeIDE打开STM32F103C8Tx_FLASH.ld找到这一行FLASH (rx) : ORIGIN 0x08004000, LENGTH 48K如果用的是MDK则在Options for Target里把IROM1的Start从0x08000000改成0x08004000Size从0x10000改成0xC000。这一步决定着编译器生成的所有代码地址、常量地址、函数地址全部落在0x08004000之后。第二是中断向量表偏移寄存器SCB-VTOR。在App的main()函数最开始任何初始化之前加这一句SCB-VTOR 0x08004000U;为什么必须在最开始因为HAL_Delay()依赖SysTick中断SysTick一旦触发CPU要在向量表里找SysTick_Handler。如果VTOR还是默认的0x08000000它找到的是Bootloader的SysTick_Handler而不是App里的这时候程序要么卡死要么跑飞。网上搜到的“IAP跳转后卡死HAL_Delay”八成就是VTOR没设或者设得太后。这里补充一个细节有些人习惯直接修改system_stm32f1xx.c里的VECT_TAB_OFFSET宏把它改成0x4000这样SystemInit会帮你设置VTOR。我个人的做法是不太建议只在SystemInit里改因为有些版本的启动文件执行顺序有差异万一SystemInit没跑到就出问题排查起来很绕。直接在main第一行写SCB-VTOR逻辑清晰、可控性最强。3.2 生成bin文件而不是hexIAP升级的文件格式我强烈建议用bin而不是hex。hex格式里包含了地址信息而且每一行的地址是相对的如果上位机软件对hex解析不严谨很容易把数据写到错误的地址。bin文件就是纯粹的固件数据烧录时从App起始地址开始连续写入简单直接出错的概率低。MDK生成bin的方法是在Options for Target的User页签里After Build/Rebuild栏填一行命令fromelf.exe --bin --output.\Output\app.bin .\Output\app.axf注意fromelf的路径MDK安装在不同盘符时可能不同建议写绝对路径或者把Keil的ARMCC目录加进系统PATH。GCC工具链则更简单arm-none-eabi-objcopy -O binary app.elf app.bin生成的bin文件大小就是实际占用的代码量升级前看一眼大小如果接近48KB就要警惕链接器报错或者App覆盖到Bootloader区域的隐患。3.3 App侧的Flash写入要注意半字对齐Bootloader接收完XMODEM数据包之后要把数据写进Flash。F103的Flash编程最小单位是16位半字所以用HAL库时写操作长这样HAL_FLASH_Unlock(); /* 每收到一包先擦除对应页再逐半字写入 */ for (uint16_t i 0; i XMODEM_PACKET_SIZE; i 2) { uint16_t half_word (uint16_t)(data[i] | (data[i 1] 8)); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, flash_addr, half_word) ! HAL_OK) { /* 写入失败退出升级 */ } flash_addr 2; } HAL_FLASH_Lock();擦除时用的是FLASH_EraseInitTypeDef erase; uint32_t page_error 0; HAL_FLASH_Unlock(); erase.TypeErase FLASH_TYPEERASE_PAGES; erase.PageAddress target_page_start_addr; /* 必须是1KB页边界 */ erase.NbPages page_count; HAL_FLASHEx_Erase(erase, page_error); HAL_FLASH_Lock();这里最容易踩的坑是用32位整字写入。F103的Flash在标准库和HAL库下整字写入方式在不同型号上支持情况不一致一旦地址不对齐直接HardFault。统一用半字写入虽然慢一点但稳。还有擦除地址必须按1KB页对齐比如0x08004000对齐没问题0x08004002就是非对齐地址擦除函数会返回错误。我在调试第一个版本时就是栽在这上面接收数据全对擦除却报错找了一下午才发现是页地址没对齐。4. 上位机烧录与实操流程4.1 驱动与工具准备C8T6最小系统板自带的USB转串口芯片最常见的是CH340或CH341。先把驱动装好Windows设备管理器里能看到一个USB-SERIAL CH340的COM口一般会自动识别如果识别不了就去沁恒官网下对应版本的驱动。装好之后在设备管理器里把波特率设为115200数据位8停止位1无校验无流控。这一步看似基础但产线上很多人卡在“打开串口失败”多半就是驱动没装好或者COM口被其他软件占用。调试时我用的是XCOM串口助手它自带的XMODEM发送功能非常稳。操作顺序我建议这样先打开XCOM选好COM口并打开串口然后加载要升级的bin文件再点XMODEM发送按钮。此时先不要按目标板复位键等目标板上电或者复位后进入Bootloader等待窗口的那一瞬间XMODEM发送才会被识别并开始传输。有个小细节值得提一下。我的Bootloader设计成在500ms等待窗口内如果收到XMODEM起始帧SOH才进入升级模式。也就是说上位机如果复位前没有开始发送Bootloader等完这段时间就直接启动现有App了。所以从实际操作讲正确顺序是“选好文件并开始发送再按目标板复位”。如果你反着来先复位再点发送500ms很容易就错过了又得重新复位一次。4.2 从ST-Link首烧到串口升级的完整链路第一次使用这套IAP方案时我建议按以下步骤走一遍把Bootloader和App独立验证清楚了再开始做串口升级用ST-Link直接把Bootloader工程烧到0x08000000烧录完成后让板子停在Bootloader等待状态此时可以先不管App。把App工程的链接地址改好、SCB-VTOR加好用ST-Link临时烧到0x08004000然后手动复位确认App能独立运行。这个步骤是为了验证App本身没问题排除IAP的干扰因素。重新回到Bootloader用XCOM走一遍XMODEM升级流程观察是否能通过串口把App的bin刷进Flash并能正常跳转。升级成功后把App区擦掉或者故意写坏再试一次升级确认坏App不会导致系统变砖Bootloader还能重新收包。这套流程走下来基本能覆盖IAP的所有关键链路。如果哪一步和预期不符问题的定位范围会小很多。比如第一步App独立运行正常但串口升级后卡死那问题大概率出在Bootloader的跳转代码或者App的VTOR设置上而不是App的代码逻辑。实测中我用115200波特率传输一个30KB的binXMODEM模式下大概二十多秒完成偶尔有重传。如果把波特率降到57600会牺牲一些速度但更稳适合在线上走线较长的产线场景。5. 从“跳转后卡死HAL_Delay”开始的完整排错“IAP跳转后卡死HAL_Delay”这个关键词很典型我一开始也踩过而且踩得很深。现象描述通常是这样的Bootloader通过串口正常接收完固件也能看到Flash写入成功但跳转后程序卡死在某个HAL_Delay()调用里LED不闪串口无输出整个App毫无反应。下面按我排查顺序复盘一遍至少能覆盖80%的同类问题。排查第一步先在Jump_To_Application()最后一句跳转前加一个串口打印或者把某个GPIO拉高。这样能确认Bootloader确实执行到了跳转点。很多情况下程序其实根本没跳到App是卡在跳转之前的某个环节比如Flash校验失败或者地址检查没过。第二步如果确认跳转指令已经执行那问题大概率集中在这几个根因现象根因解决办法跳转后直接HardFaultMSP没有切换到App栈跳转前调__set_MSP(app_stack)跳转后HAL_Delay卡死SCB-VTOR没有设置或设置晚于首次中断main最开头设置VTOR为0x08004000跳转后乱跑、各种异常链接脚本的FLASH起始地址没改确认App编译链接地址起始于0x08004000跳转后偶发随机死机Bootloader的SysTick未被关闭跳转前关闭SysTick并清Pending跳转后外设中断异常触发串口/定时器等外设中断未关闭跳转前关闭Bootloader用到的外设时钟和NVIC这里我再重点解释一下为什么“HAL_Delay卡死”和VTOR的关系那么大。HAL_Delay的实现依赖于SysTick中断SysTick每毫秒触发一次在中断里把uwTick变量加1HAL_Delay循环等待这个变量走到目标值。当App跳转后如果VTOR没有指向App的向量表SysTick中断一旦触发CPU会跑到Bootloader的向量表去找SysTick_Handler。如果Bootloader那边已经把这个中断关了那uwTick永远不会更新HAL_Delay就永远在死等。反过来即使你开了中断跑进去的也可能是错乱的中断服务函数程序照样飞。第三步如果确认VTOR和MSP都对还是卡死那就要检查跳转前是否把Bootloader用过的外设中断都关干净了。我在第一版Bootloader里用USART1接收数据跳转前没有关闭USART1的NVIC中断。结果App初始化串口时如果Bootloader残留的接收中断还没来得及清PendingApp的串口中断一旦使能就会立刻触发一个陈旧的中断请求而去向量表里找的又是App的串口中断处理函数行为完全不可预测。后来我在跳转函数里把所有NVIC中断都给清了for (uint8_t i 0; i 8; i) { NVIC-ICER[i] 0xFFFFFFFFUL; NVIC-ICPR[i] 0xFFFFFFFFUL; }这样相当于把NVIC里所有使能位和挂起位全部清掉App启动后重新初始化自己的外设干净利落。还有一个很容易忽视的点如果你的Bootloader和App用的时钟配置不一样跳转后App在SystemClock_Config里会重新配置PLL。但有些芯片的PLL切换需要等待就绪标志如果Bootloader里配置过PLL后又改了系统时钟App复位PLL时可能卡在等待超时。这种问题比较少见但一旦遇到排查方向一定是沿着时钟初始化的代码逐行看。排查链路走完我的经验是先看VTOR再看MSP再看NVIC残留最后才怀疑时钟。这三个点基本覆盖了绝大多数跳转卡死场景。6. 升级失败保护与耐用量产的两个建议6.1 升级失败后绝不能变砖嵌入式产品最忌讳的就是升级失败变成砖特别是已经部署到现场的设备。C8T6只有64KB Flash做不了高端芯片那种A/B双Bank无缝回滚但至少要做到“升级失败后还能重新升级”。我采用的策略很简单Bootloader只在确认App区完全合法后才跳转。也就是前面说的栈顶检查和复位向量检查。如果App区数据不完整比如擦除一半断电、XMODEM收到一半取消那么检查必然失败系统会一直停在Bootloader等待新的升级数据。对于现场设备来说最多是设备暂时不工作但只要串口还能连上重新发一个正确的bin就能恢复不存在变砖的可能。如果你要做得更稳还可以在App内部加一个“升级开始标志”。App在接收升级指令后先写一个标志位到Flash末尾再跳回BootloaderBootloader发现这个标志就强制进入升级模式而不是启动现有App。这样即使用户端App跑飞了只要上位机能发升级指令设备依然能进Bootloader。我在量产版本里就是把这个标志设成0xA5A5Bootloader每次检测到它就跳过等待窗口直接进入接收状态。6.2 读保护、国产替代和其他补充如果你担心固件被抄可以给MCU开启读保护RDP Level 1。开了之后ST-Link没法直接读Flash但芯片内部Bootloader依然可以通过IAP方式擦写App区这意味着“读保护和串口IAP升级”完全不冲突。我实际验证过在RDP Level 1状态下XMODEM升级流程一切正常。但要注意如果想用ST-Link重新烧录需要先解除读保护而解除读保护的操作会触发全片擦除Bootloader也没了等于要从头再来一遍。所以量产前想清楚自己是否需要读保护以及要保留多少调试后门。另一个在C8T6圈子经常被问到的问题是国产替代。GD32F103C8T6、APM32F103C8T6这些芯片硬件引脚基本兼容串口IAP的整个思路也一致。但Flash操作底层封装不一定完全兼容比如部分国产芯片的擦写时序或页大小有差异。如果你想在国产芯片上跑同一套IAP代码务必先翻对应芯片的数据手册和Flash编程章节别直接拿STM32的HAL库盲烧。有些国产厂商会提供自己的BSP库用它们自己的Flash驱动最稳。最后说一下量产工具的建议。开发阶段用XCOM没问题但产线升级如果每次都让人手动点XMODEM按钮容易漏点或点错。我后来写了一个简单的Python脚本用pyserial定时发0xA5A5唤醒Bootloader然后调用xmodem库发送bin文件全程自动校验并打印结果。你如果也想做核心逻辑不复杂串口打开后先发几个字节的魔数等Bootloader进入升级状态再调用xmodem的send接口传输。这一步自己做一遍之后产线的升级效率能提升一个量级而且彻底告别“手一抖点错按钮”的隐患。本文还有配套的精品资源点击获取