STM32串口IAP升级实战:Bootloader跳转卡死与解决方案
2026/9/7 14:36:34 网站建设 项目流程

简介:针对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这条路

先明确一个概念:IAP(In Application Programming)就是在应用编程。和ISP那种需要改BOOT0引脚、利用芯片出厂自带的系统存储器程序下载不一样,IAP是自己写一个Bootloader放在Flash起始区域,上电先跑Bootloader,Bootloader判断有没有升级请求:有就接收新固件并写入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压到8KB,HAL库不开优化时还是有点紧张,后来干脆统一用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字节,效率不如YMODEM(1024字节),但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改成0x08004000,Size从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文件而不是hex

IAP升级的文件格式,我强烈建议用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独立验证清楚了再开始做串口升级:

  1. 用ST-Link直接把Bootloader工程烧到0x08000000,烧录完成后让板子停在Bootloader等待状态,此时可以先不管App。
  2. 把App工程的链接地址改好、SCB->VTOR加好,用ST-Link临时烧到0x08004000,然后手动复位,确认App能独立运行。这个步骤是为了验证App本身没问题,排除IAP的干扰因素。
  3. 重新回到Bootloader,用XCOM走一遍XMODEM升级流程,观察是否能通过串口把App的bin刷进Flash并能正常跳转。
  4. 升级成功后,把App区擦掉或者故意写坏,再试一次升级,确认坏App不会导致系统变砖,Bootloader还能重新收包。

这套流程走下来,基本能覆盖IAP的所有关键链路。如果哪一步和预期不符,问题的定位范围会小很多。比如第一步App独立运行正常,但串口升级后卡死,那问题大概率出在Bootloader的跳转代码或者App的VTOR设置上,而不是App的代码逻辑。

实测中我用115200波特率传输一个30KB的bin,XMODEM模式下大概二十多秒完成,偶尔有重传。如果把波特率降到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变量加1,HAL_Delay循环等待这个变量走到目标值。当App跳转后如果VTOR没有指向App的向量表,SysTick中断一旦触发,CPU会跑到Bootloader的向量表去找SysTick_Handler。如果Bootloader那边已经把这个中断关了,那uwTick永远不会更新,HAL_Delay就永远在死等。反过来,即使你开了中断,跑进去的也可能是错乱的中断服务函数,程序照样飞。

第三步,如果确认VTOR和MSP都对,还是卡死,那就要检查跳转前是否把Bootloader用过的外设中断都关干净了。我在第一版Bootloader里用USART1接收数据,跳转前没有关闭USART1的NVIC中断。结果App初始化串口时,如果Bootloader残留的接收中断还没来得及清Pending,App的串口中断一旦使能就会立刻触发一个陈旧的中断请求,而去向量表里找的又是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末尾,再跳回Bootloader;Bootloader发现这个标志,就强制进入升级模式而不是启动现有App。这样即使用户端App跑飞了,只要上位机能发升级指令,设备依然能进Bootloader。我在量产版本里就是把这个标志设成0xA5A5,Bootloader每次检测到它就跳过等待窗口,直接进入接收状态。

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接口传输。这一步自己做一遍之后,产线的升级效率能提升一个量级,而且彻底告别“手一抖点错按钮”的隐患。

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

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

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

立即咨询