搞嵌入式开发久了,总会遇到一个灵魂拷问:产品已经卖出去几百上千台,固件出了Bug或者要加新功能,总不能让人家寄回来拆机烧录吧。这时候IAP(In-Application Programming,应用内编程)就该登场了。这篇文章不聊虚的,就围绕STM32平台,从零开始把一个串口IAP框架完整地搭建起来,重点拆解BootLoader和App之间双向跳转的实战细节。整篇内容的核心关键词很明确:STM32、串口IAP、BootLoader、App、双向跳转。
我会尽量把那些容易踩坑的细节,比如中断向量表偏移、Flash分区规划、跳转指令的写法、怎么从App安全跳回BootLoader等,全部摊开来揉碎了讲。不管你是刚接触IAP的新手,还是已经做过OTA想做一次系统性梳理的老手,这篇内容应该都能给你一些参考。项目本身以STM32F103系列为蓝本讲解,但原理通用于F4、H7等几乎所有Cortex-M内核芯片。
1. 串口IAP整体设计思路拆解
1.1 IAP与BootLoader的核心关系
先理清概念。IAP的意思是芯片在运行过程中,自己对自己的一部分Flash进行擦除和写入。而BootLoader就是一段专门负责“接收新固件并写入指定Flash区域”的独立程序。
很多初学者容易搞混“烧录器下载”和“IAP下载”的本质区别。用ST-Link或者J-Link下载程序,是通过调试接口(SWD/JTAG)把固件写到Flash起始地址0x08000000,这个过程由外部工具控制,芯片本身只是被动接受。而IAP是芯片内部的程序(也就是BootLoader)主动通过串口、USB、CAN或者以太网接口接收数据,然后调用Flash编程接口把数据写进应用区。
所以整个架构就分成了两块:BootLoader程序跑在Flash的低地址区(比如0x08000000),App应用程序跑在Flash的高地址区(比如0x08008000或0x08010000)。芯片上电后先执行BootLoader,BootLoader根据条件决定是进入“升级模式”等待接收新固件,还是直接跳转到App执行正常功能。
这就像电脑的BIOS和操作系统。BIOS负责开机自检、引导系统,系统坏了还能进BIOS重装。对应到STM32嵌入式设备,BootLoader就是那个BIOS,App就是操作系统。串口IAP就是通过串口这个通道,完成“重装操作系统”的动作。
1.2 Flash分区与内存规划方案
规划Flash分区是整个IAP框架的基础,这一步如果设计不合理,后面所有功能都会受影响。以STM32F103C8T6为例,它只有64KB Flash,起始地址0x08000000,结束地址0x0800FFFF。虽然这个型号Flash不大,但用来演示IAP完全够了。
我习惯这样分区:
| 区域 | 地址范围 | 大小 | 用途 |
|---|---|---|---|
| BootLoader区 | 0x08000000 - 0x08003FFF | 16KB | 启动、跳转、固件接收与写入 |
| App区 | 0x08004000 - 0x0800BFFF | 32KB | 应用程序主功能 |
| 参数/标志存储区 | 0x0800C000 - 0x0800FFFF | 16KB | 升级标志、固件信息、配置参数 |
这里有个关键选择:BootLoader给多少空间合适?我给16KB是基于两个考虑。第一,STM32F103C8T6的Flash按页擦除,一页是1KB,16KB正好是16页,分区对齐很干净。第二,Ymodem协议接收程序加Flash驱动加跳转逻辑,紧凑点写大概6-10KB就够,留出一些余量方便后续加功能(比如固件加密解密、版本校验)。
App起始地址0x08004000必须遵循一个原则:对齐到Flash的擦除块边界。F103的页大小是1KB,0x08004000正好是16的倍数,没问题。如果换到F407,它的扇区大小不同(前4个扇区是16KB,后面是64KB/128KB),分区时必须按扇区边界对齐,否则擦除时会误伤相邻区域的数据。这个坑我在实际项目中踩过,后面用F407的时候一定要注意。
参数存储区放什么?主要是升级标志(比如0xA5A5表示需要升级)、当前App版本号、升级文件长度和CRC校验值。这些信息掉电不能丢,所以放在独立的Flash区域,不跟代码混在一起。
1.3 为什么选择串口作为升级通道
既然是串口IAP,升级通道自然是串口。有些朋友会问,现在都什么年代了,为什么不直接用USB、CAN或者以太网?原因很现实:串口最简单、最通用、最容易调试。几乎所有STM32型号都有USART外设,只要引出TX、RX两根线加一个USB转串口模块,就能完成升级。而且串口调试信息在开发阶段本来就需要,一套硬件两用。
从实现角度看,串口接收中断写环形缓冲区,主循环里处理数据帧,这套逻辑比USB的枚举、端点通信要简单几个量级。CAN和以太网IAP在车载、工业领域确实更常用,但那是基于串口IAP框架的延伸——协议栈换成CANopen或者TCP/IP包的解析,Flash写入和跳转逻辑完全一样。先把串口这个最简单的链路跑通,以后再往其他通道迁移,底层Flash驱动和分区方案都不用动。
所以,串口IAP不是“过时”的方案,而是所有IAP方案的“基础功”。就像练武功先扎马步,串口IAP就是这个马步。
2. 双向跳转的机制原理与实现
2.1 中断向量表偏移:跳转的第一道门槛
理解跳转之前,必须先搞懂中断向量表。STM32是Cortex-M内核,它的中断机制是这样的:CPU发生中断时,内核从向量表中取出对应的中断服务函数地址,然后跳转过去执行。向量表默认放在Flash起始地址0x08000000,也就是上电复位后第一条指令从哪里取。
问题来了:App运行在0x08004000,它的中断向量表也在0x08004000处。但芯片上电后,内核始终从0x08000000读取向量表。如果BootLoader直接跳转到App的main函数,而没有修改中断向量表的位置,App里的任何中断(串口中断、定时器中断、外部中断)都会取到错误的中断处理函数地址,程序瞬间跑飞。
解决的办法是设置VTOR寄存器(Vector Table Offset Register),这是Cortex-M3/M4内核提供的向量表偏移寄存器。把VTOR的值设为App的起始地址,告诉内核“我的中断向量表搬家了,以后中断从这里取”。
ST官方的HAL库里有这样一段代码:
#define APPLICATION_ADDRESS 0x08004000 void jump_to_app(void) { uint32_t app_code_addr = *(volatile uint32_t *)APPLICATION_ADDRESS; uint32_t app_sp = *(volatile uint32_t *)(APPLICATION_ADDRESS + 4); if (app_sp == 0xFFFFFFFF || app_code_addr == 0xFFFFFFFF) { return; // Flash区为空,不能跳转 } // 关闭全局中断(重要,防止跳转过程中断响应) __disable_irq(); // 设置中断向量表偏移 SCB->VTOR = APPLICATION_ADDRESS; // 设置主堆栈指针 __set_MSP(app_sp); // 跳转到App的Reset_Handler void (*jump)(void) = (void (*)(void))app_code_addr; jump(); }有几点要说清楚。为什么跳转前要关闭全局中断?因为跳转过程中,如果来了一个中断,而App的中断处理函数还没准备好(比如App的静态变量还没初始化),程序就会异常。最稳妥的做法是关中断、改VTOR、设置SP、跳到App的Reset_Handler。App的Reset_Handler会重新配置系统时钟、初始化中断向量表(HAL库会自动读VTOR或者直接用SystemInit设置)、初始化用户外设,等它跑完再打开中断,一切才安全。
另一个细节:跳转前要设置MSP。Cortex-M内核在复位后从地址0x00000000取出初始SP值。App在编译时,由链接脚本指定它的初始SP就是App区首地址的前4个字节。如果只改PC不改SP,栈可能会指到BootLoader的栈空间,两个程序的栈就打架了。
2.2 为什么用软复位的方式实现App跳回BootLoader
只讲BootLoader跳App还不够,标题里写的是“双向跳转”,意味着App还要能回到BootLoader,完成一次升级的闭环。设想一个场景:设备已经跑了App,用户通过上位机发来“进入升级模式”的指令,此时App需要停下来,把控制权交还给BootLoader,等待新固件数据。
最容易想到的方案是:App直接调用类似jump_to_bootloader的函数,跟BootLoader跳App一样,改VTOR、设SP、跳过去。这个方案可行,但我强烈不建议在产品代码里这么做。为什么?
因为直接跳转会绕过系统复位,当时所有外设的状态(DMA、中断、定时器、看门狗)都是“脏”的。比如App开了DMA搬运传感器数据,跳转瞬间DMA还在工作,内存里的数据读到一半;又比如看门狗已经启动,BootLoader如果没来得及喂狗,程序直接死机。这些都是实际工程里血泪换来的教训。
标准做法是软复位:App程序里设置一个标志位,存放在一个复位后不会丢的地方,然后调用NVIC_SystemReset()复位整个芯片。芯片重启后从0x08000000执行,BootLoader第一时间检查这个标志位,发现值等于约定值(比如0xA5A5),就知道“App让我进升级模式”,于是停在原地等待串口数据,不再跳回App。
标志位放哪里?有讲究。有些教程教你放在某个固定的RAM地址,比如0x20000000处。芯片软复位时,RAM一般不会清零,所以理论上可行。但有个致命前提:启动代码里不能有“把RAM全部清零”的操作。而很多启动文件或者RTOS的启动逻辑确实会这么做。标准启动文件startup_stm32f103xb.s里,会执行一个循环把所有未初始化的RAM填成0,如果BootLoader入口检查标志位的代码在启动文件运行之后执行,RAM标志位可能早就被清零了。
另一个思路是把标志放备份寄存器(BKP)或者RTC后备域,这个区域在软复位时确实能保持,但F103的BKP寄存器在芯片完全掉电后依赖VBAT供电,如果板上没有接电池,掉电就丢。而且BKP的操作比RAM麻烦,还要开启PWR和BKP时钟。
我实测下来最稳妥的方案是在Flash的参数区专门划分一个word长度的空间存升级标志。写入Flash虽然比RAM慢,但只写一次,不影响性能,而且只要App在软复位前启动一次Flash写入流程,数据绝对安全。代价是Flash有擦写寿命限制(F103标称1万次),但正常升级操作根本到不了这个数量级,所以大可放心。
App端跳回BootLoader的伪代码是这样:
void go_to_bootloader(void) { // 写标志位到Flash参数区 flash_erase_param_page(); flash_write_word(BOOT_FLAG_ADDR, 0xA5A5); // 保证标志位写入完成 while (flash_is_busy()); // 发送应答给上位机,告诉它“我要重启了” // 注意,这里需要延迟几百毫秒等数据发完,否则串口DMA可能截断最后一个字节 HAL_Delay(200); // 软复位 NVIC_SystemReset(); }段代码里的200ms延迟是很多人忽略的细节。PC端发完“进入升级模式”的指令后,App要回一句应答“收到,马上重启”,如果把这句话塞进串口发送缓冲区就立刻复位,DMA还没把数据发出去,缓冲区里的内容全丢了。PC端等不到应答会报超时,用户体验极差。实测串口波特率115200时,发20字节的数据大概1.7ms就能发完,但保险起见延迟200ms没问题,反正用户也不在乎这0.2秒。
2.3 双向跳转的核心代码实现
有了前面的原理铺垫,完整的双向跳转逻辑就可以串起来了。BootLoader的main函数和跳转逻辑放在一起:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_Init(); // 检查升级标志 uint32_t boot_flag = *(volatile uint32_t *)BOOT_FLAG_ADDR; if (boot_flag == 0xA5A5) { // 清除标志,避免下次复位又进入升级模式 flash_erase_param_page(); // 进入升级模式,等待串口数据 enter_update_mode(); } else { // 正常启动,跳转到App jump_to_app(); } while (1); }这个流程看着简单,隐藏了一个常见错误:如果跳转到App之后,App又通过软复位回到BootLoader,而此时BootLoader判定“没有升级标志”,就会再次jump_to_app,造成死循环。所以jump_to_app之前,必须保证没有遗留的升级标志,或者明确区分“App自己请求复位”和“意外复位”两种情况。工程上通常的做法是在App的复位请求中把标志设为0xA5A5,BootLoader发现标志后先清零再等待升级,防止误判。
3. 串口升级协议与传输设计
3.1 为什么要用Ymodem协议
串口升级核心问题之一就是数据怎么传。裸传原始bin文件也可以,自己定一套帧头帧尾、长度、校验的协议,写着也不难。但我的建议是直接用Ymodem协议,理由有三:
第一,Ymodem是成熟协议,有现成的PC端工具支持(SecureCRT、Xshell、MobaXterm、ExtraPuTTY都内置Ymodem传输)。这意味着不用自己写上位机,调试阶段用SecureCRT就能把固件传下去。自己的产品则可以用Python或Qt写个简单的Ymodem发送端,几行代码的事。
第二,Ymodem带CRC16校验(CCITT多项式0x1021),每个128字节数据包都有校验,传输出错能及时发现并要求重传,可靠性有保障。第三,Ymodem支持文件名、文件大小等元信息传输,BootLoader可以拿到文件名和长度,校验固件版本、防止串数据。
Ymodem协议流程大概分四个阶段:
- 接收方(BootLoader)发送字符'C',表示“我准备好了,以CRC模式接收”。
- 发送方(PC)收到'C'后,先发送一个包含文件名、文件大小等信息的块0(128字节)。
- 接收方确认块0后,发送方开始逐个发送数据块(编号从1开始),每块128字节或1024字节,发送完一块等接收方回ACK再发下一块。
- 全部发完后发送方发送EOT结束传输,接收方回ACK后,发送方发一个空块或再次EOT确认,接收方回ACK,传输完成。
实际实现时,我建议把接收流程做成状态机:
typedef enum { YMODEM_STATE_WAIT_START, // 等待块0 YMODEM_STATE_WAIT_DATA, // 等待数据块 YMODEM_STATE_WAIT_END, // 等待传输结束 YMODEM_STATE_DONE, // 传输完成 YMODEM_STATE_ERROR // 出错 } ymodem_state_t;单线程的BootLoader不适合在接收数据时做复杂超时管理,所以用状态机+串口中断+环形缓冲区是最清晰的架构。串口每收到一个字节,中断里扔进环形缓冲区;主循环不断从缓冲区取数据,喂给Ymodem状态机处理。
3.2 Ymodem接收流程在STM32上的实现要点
Ymodem实现在网上有大量参考代码(ST官方也提供了一份tftp和ymodem的demo),但很多代码存在隐性问题,我把自己整理过的版本核心思路写一下。
uint8_t ymodem_receive(uint8_t *buf, uint32_t *file_len) { // 定义全局状态机变量 static ymodem_state_t state = YMODEM_STATE_WAIT_START; // 数据缓冲区 static uint8_t packet_buf[1024]; switch (state) { case YMODEM_STATE_WAIT_START: // 收到SOH(0x01)表示128字节块0 if (packet_buf[0] == SOH) { // 校验块0的序号、补码、CRC // 解析文件名和文件大小 // 应答ACK + C,进入等待数据块状态 state = YMODEM_STATE_WAIT_DATA; } else if (packet_buf[0] == EOT) { // 收到EOT说明没有块0,异常结束 state = YMODEM_STATE_ERROR; } break; case YMODEM_STATE_WAIT_DATA: if (packet_buf[0] == SOH || packet_buf[0] == STX) { // 判断数据长度(128字节还是1024字节) // 校验块序号、CRC // 写入Flash(按页擦除、按地址写入) // 应答ACK } else if (packet_buf[0] == EOT) { // 应答ACK,等待对方的结束处理 state = YMODEM_STATE_WAIT_END; } break; case YMODEM_STATE_WAIT_END: // 收到空块(块序号为0)或再次EOT,应答ACK // 传输结束 state = YMODEM_STATE_DONE; break; default: break; } return state == YMODEM_STATE_DONE; }有个细节值得留意:当传输的文件大小不是128字节的整数倍时,最后一个数据块的内容是不完整的,剩余位置用0x1A(Ctrl-Z)填充。BootLoader写入Flash时不能把填充字节也写进去,否则App区末尾会多出一段无意义的数据。好在Ymodem协议块0里带了文件大小,BootLoader可以据此判断实际有效数据长度,写Flash时精确控制写入量。
3.3 Flash写入:擦除和编程的坑
STM32的Flash编程有几个硬性规则,写代码时一定要遵守:
第一,写Flash前必须擦除。Flash只能把1写成0,要把0变成1只能擦除。F103的页大小是1KB,擦除以页为单位。如果你要覆盖App区32KB,就得擦除32页。
第二,写入必须按16位(半字)对齐。HAL库的HAL_FLASH_Program支持8位、16位、32位甚至64位宽度,但STM32的Flash控制器硬件上要求地址对齐。很多人在这踩坑:用数组接收串口数据,数组首地址是8位对齐的,直接传给HAL_FLASH_Program就报错。正确做法是先把串口收到的数据搬到一个32位对齐的缓冲区,再写入Flash。
第三,擦写操作期间,CPU会暂停执行Flash区域的代码。这意味着Flash驱动必须在RAM里运行,或者至少关键擦写步骤的代码要放在RAM里。在F103上,KEIL默认把启动代码放到Flash,但HAL_FLASH_Program函数内部的主循环在擦写期间会等待BSY标志位,此时指令预取会被阻塞。实测F103在Flash擦写期间,中断仍然可以响应(中断向量表在Flash里也可以执行),但性能会打折。所以写IAP时,升级过程中尽量关闭那些耗时敏感的中断,只保留串口接收中断。
第四,写Flash之前要检查目标地址是否超过了BootLoader区。如果BootLoader程序本身没有做边界保护,收到一个超长固件,擦写时把BootLoader区自己擦掉了,那整个设备就直接变砖。这个问题在正式产品中必须处理:解析出固件长度后,先判断app_start_addr + file_len <= bootloader_start_addr + bootloader_len,不满足就直接拒绝升级。
以下是写入Flash的示例代码,整合了上述要点:
#define APP_START_ADDR 0x08004000 #define FLASH_PAGE_SIZE 1024 #define APP_MAX_SIZE 32 * 1024 uint32_t flash_write_buffer(uint32_t addr, uint8_t *data, uint32_t len) { uint32_t page_offset = (addr - APP_START_ADDR) % FLASH_PAGE_SIZE; uint32_t page_base = addr - page_offset; if (addr + len > APP_START_ADDR + APP_MAX_SIZE) { return FLASH_ERROR_SIZE; // 超出App区边界 } HAL_FLASH_Unlock(); // 如果跨页,按页擦除并写入 while (len > 0) { // 计算当前页剩余空间 uint32_t page_remain = FLASH_PAGE_SIZE - page_offset; uint32_t write_len = (len > page_remain) ? page_remain : len; // 擦除当前页 FLASH_ErasePage(page_base); // 按32位对齐写入 for (uint32_t i = 0; i < write_len; i += 4) { uint32_t word = data[i] | (data[i+1] << 8) | (data[i+2] << 16) | (data[i+3] << 24); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i, word) != HAL_OK) { HAL_FLASH_Lock(); return FLASH_ERROR_WRITE; } } addr += write_len; data += write_len; len -= write_len; page_offset = 0; // 后续都从页首开始 page_base = addr; } HAL_FLASH_Lock(); return FLASH_OK; }擦除周期、写周期本身需要时间,F103擦一页典型耗时20-40ms,写半字典型50us左右。32KB固件完整写入,擦除加编程跑下来大概1-2秒。这期间如果看门狗没喂,设备会不断复位。所以BootLoader的升级模式下最好关闭看门狗,或者在看门狗超时前定期喂狗。利用Flash内置的中断可以做到“边擦写边喂狗”,但这属于进阶玩法,新手先把裸机流程跑通再说。
4. 从零搭建:工程结构与核心代码实现
4.1 工程目录与编译配置
整个框架涉及两个独立的工程:BootLoader工程和App工程。它们共享一部分底层驱动代码(比如Flash驱动、串口驱动),但编译产物和加载地址完全不同。
我的习惯是建一个总目录:
uart_iap/ ├── bootloader/ │ ├── Core/ │ ├── Drivers/ │ └── bootloader.uvprojx ├── app/ │ ├── Core/ │ ├── Drivers/ │ └── app.uvprojx └── tools/ ├── ymodem_sender.py └── merge_hex.pyBootLoader工程在KEIL里配置两个关键项:
- Target选项卡,把IROM1的起始地址设为0x08000000,大小0x4000(16KB)。
- C/C++选项卡,在Define里加上
APP_START_ADDR=0x08004000。
App工程配置稍不同:
- IROM1的起始地址设为0x08004000,大小0x8000(32KB)。
- IRAM1保持0x20000000,大小0x5000(20KB)。注意,这里不用改IRAM,因为App的栈和堆完全由App自己管理,跟BootLoader没关系。
- 在C/C++选项卡的Define里加
VECT_TAB_OFFSET=0x4000,这样HAL库的SystemInit会自动设置VTOR,不需要手动调SCB->VTOR。
用CubeMX生成工程时有个方便的选项:Project Manager → Project → Linker Settings里可以直接设置Flash起始地址和大小。即便用标准库,也推荐在CubeMX里把工程搭好再换成标准库,能省不少配置麻烦。
4.2 App工程的链接脚本与启动文件
如果不用KEIL,而是用GCC工具链,需要在链接脚本里指定App的起始地址。以stm32f103c8tx_flash.ld为例:
MEMORY { FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 32K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K }启动文件方面,F103的startup_stm32f103xb.s里定义了复位向量。App的Reset_Handler会调用SystemInit,SystemInit内部会根据VECT_TAB_OFFSET宏设置SCB->VTOR。如果你用的是标准库,记得在system_stm32f10x.c里找到:
#ifdef VECT_TAB_OFFSET SCB->VTOR = FLASH_BASE | VECT_TAB_OFFSET; #endif如果用的是HAL库,CubeMX生成的代码通常已经处理好了。但很多人在移植App库时,会忘记在编译选项里定义VECT_TAB_OFFSET,结果App能跑但中断全部失效。判断方法很简单:App里开个定时器或者串口,如果中断不触发,大概率就是VTOR没设置。
4.3 上位机工具与烧录验证
升级链路跑通后,验证是重点。我的习惯是先用SecureCRT做一次手动升级,确认BootLoader接收逻辑没问题,再写脚本做自动化验证。SecureCRT的操作方式:
- 打开串口,波特率115200,8N1。
- 给设备上电复位,BootLoader启动后进入升级等待状态。
- SecureCRT菜单栏点击“传输”→“发送Ymodem”,选择编译生成的bin文件(KEIL里勾选“Create HEX File”生成hex,再用工具转成bin)。
- 等待传输进度条完成,设备自动跳到App。
第一次做的时候往往会有几个问题:串口发'C'后没有回应、Ymodem传输到一半卡死、传输完成App跑不起来。不要急着改代码,先在BootLoader的串口中断里加调试打印,把Ymodem每个状态的收包情况打印出来,基本能定位80%的问题。
SecureCRT通过后,再用Python写个自动化脚本做回归验证。用pyserial和现成的ymodem库(或者自己实现一个简单的发送端),脚本逻辑:
import serial import time from xmodem import YMODEM ser = serial.Serial('COM3', 115200, timeout=1) # 等待BootLoader启动 time.sleep(1) # 发送Ymodem文件 def getc(size, timeout=1): return ser.read(size) or None def putc(data, timeout=1): return ser.write(data) ymodem = YMODEM(getc, putc) with open('app.bin', 'rb') as f: ymodem.send(f)这个脚本价值很大。固件每次更新,跑一遍脚本确认升级链路没被App新功能搞坏,相当于给IAP加了一道自动化测试。
4.4 完整升级流程时序
把前面所有的内容串起来,一次完整的串口IAP升级是这样发生的:
- App正常运行,通过串口收到上位机下发的“进入升级模式”命令。
- App在Flash参数区写入0xA5A5标志,等待200ms应答发出,然后调用NVIC_SystemReset()软复位。
- 芯片重启,BootLoader从0x08000000启动,检查参数区标志位为0xA5A5。
- BootLoader擦除标志位(防止下次误判),初始化串口,发送字符'C',进入Ymodem接收状态。
- 上位机(SecureCRT或Python脚本)收到'C',启动Ymodem发送,BootLoader逐包接收、校验CRC、擦除App区Flash并写入数据。
- 传输完成,BootLoader校验固件完整性(可加一个全局CRC校验),然后跳转到App区首地址,App启动,升级完成。
整个过程用户看到的就是“设备重启了几秒、新固件自动跑起来了”,体验非常顺。
5. 常见问题与排查技巧实录
5.1 跳转后App跑飞:先检查VTOR和SP
跳转后App跑飞是IAP开发里最常见的问题,没有之一。表现各异:要么直接HardFault,要么没反应,要么跑了一段才崩。我总结的排查顺序:
第一步,检查App区首地址的数据对不对。用ST-Link读Flash 0x08004000处的内容,正常情况前4字节是初始SP值(0x2000xxxx),紧接着4字节是Reset_Handler地址(0x0800xxxx)。如果读出来全是0xFF,说明App根本没烧进去,或者烧到别的地址去了。
第二步,检查VTOR是否设置到位。在App的main函数开头设一个断点,看SCB->VTOR的值,应该等于0x08004000。如果不等于,检查VECT_TAB_OFFSET宏有没有在编译选项里定义。
第三步,检查跳转前是否关闭了中断。跳转时全局中断没关,跳转过程中入了中断,PC被改写,App直接崩。加__disable_irq()试试。
第四步,调试器连接不上App时,把App工程的Debug设置里“Download”改成按地址下载,指定0x08004000,否则KEIL会把App工程默认烧到0x08000000覆盖BootLoader。
5.2 Ymodem传输卡死:超时机制和线性缓冲
Ymodem传输卡在某个包半天不动,大概率是超时机制没处理好。Ymodem协议规定接收方应该在3秒内响应ACK或NAK,如果响应不及时,发送方会认为出错重发。但BootLoader主循环里如果写Flash花了1秒,加上串口缓冲区处理,响应时间很容易超过3秒。
解决办法有两个:一是提高主循环效率,把Flash写入拆成小块,每写完一小块就处理一下串口缓冲区和发送ACK;二是修改上位机超时时间。用SecureCRT的话,可以在全局选项里把Ymodem发送超时时间调大到10秒。用Python脚本则直接改timeout参数。
另一个跟缓冲区相关的问题是:串口接收缓冲区太小,导致Ymodem的数据包被截断。Ymodem每个数据块最大1024字节(STX模式),如果环形缓冲区小于这个大小,一包数据还没收完,缓冲区就满了,后面的字节全部丢弃。设计时缓冲区至少要能缓存完整的一包数据,我通常做2048字节。
5.3 升级标志丢失:Flash写入失败的排查
App执行软复位前写Flash标志位,偶尔会写不进去,导致App重启后BootLoader认为不需要升级,直接跳到旧App,升级操作“明明点了但没反应”。这个问题的根源多半是Flash没解锁,或者标志位地址写错。
F103的Flash上电默认是锁定的,写之前必须调用HAL_FLASH_Unlock(),写完再HAL_FLASH_Lock()。很多人写了擦除逻辑但忘了解锁,返回错误码也没看,标志位静默失败。调试时建议检查HAL_FLASH_Program的返回值。
另外注意擦除参数区的次数。Flash擦写寿命虽然标称1万次,但如果你在App里每次启动都写标志、擦标志,反复刷几十次就可能触发坏块保护。实际设计时应该给标志区做磨损均衡,或者只在确实需要升级时才写一次。
5.4 双向跳转联动失败的隐蔽坑
升级完成后App跳回BootLoader这个环节,最常见的隐蔽坑是:BootLoader给App执行jump_to_app,App正常跑起来,但用户再发升级命令,App的软复位标志写进去了,芯片重启后BootLoader却直接跳回App而不是进升级模式。
这种情况十有八九是标志位没写进去或者被启动代码擦了。先说启动代码擦了的情况:如果你用的是GCC工具链,链接脚本里如果定义了_sdata和_edata,启动文件会先把Flash里的data段拷贝到RAM。这个拷贝区域如果恰好覆盖了你放标志位的地址,标志就被旧数据覆盖。解决方法是把标志位地址定义在RAM末尾并且不参与起始代码的初始化,可以用__attribute__((section(".noinit")))定义变量,放在noinit段,启动代码不清零它。
还有一种是调试器自动擦除。KEIL烧录时默认会全片擦除(Erase Full Chip),如果选这个选项,App程序里写的标志在烧录时直接被擦掉,设备永远进不了升级模式。KEIL烧录设置里改成“Erase Sectors”按扇区擦除,就不会误伤别的区域。
5.5 实用排查速查表
| 现象 | 可能原因 | 排查/解决办法 |
|---|---|---|
| 跳转后App直接HardFault | VTOR未设置 / SP错误 / 中断未关 | 检查SCB->VTOR,检查App区首4字节,跳转前__disable_irq() |
| 跳转后串口中断不触发 | VTOR未设置 | 检查VECT_TAB_OFFSET宏是否定义并参与编译 |
| Ymodem传输中途卡死 | 超时时间太短 / 缓冲区不足 | 增大上位机超时,环形缓冲区扩到≥1024字节 |
| 升级标志写不进去 | Flash未解锁 / 地址不对 | 检查HAL_FLASH_Unlock返回值,确认地址在参数区 |
| 升级后启动直接进App | 标志被启动代码清除 | 用noinit段存放标志,或改用备份寄存器/Flash存储 |
| 传输完成但App跑不起来 | App未编译进正确地址 | 检查App工程的IROM1起始地址,确认烧录地址 |
| 固件太大覆盖BootLoader | 缺少长度边界检查 | 写入前判断app_len + app_addr <= boot区域边界 |
6. 一些实际的体会
前面把串口IAP的框架、跳转原理、协议实现和问题排查都聊了一遍,最后说几点我个人的体会。
我最初接触IAP时,犯过一个特别低级但又特别典型的问题:App工程和BootLoader工程用的延时基准不一样,App里用了SysTick延时,而SysTick的中断向量在App区,跳转后SysTick中断第一次触发就HardFault。后来才理解,跳转不是“从一个函数跳到另一个函数”,而是“换了一个完整的软件系统”,所有外设和中断都要重新初始化。
另外一点,IAP的调试一定要学会用“日志先行”。在BootLoader的串口里加调试打印,把每个Ymodem状态、每个Flash操作结果打出来,然后把输出接到串口助手看。很多人上来就凭着“感觉”猜,效率极低。这套框架我搭了三个不同芯片的版本(F103、F407、H750),每次都是调日志最快定位问题。
如果你打算在正式产品里用这套框架,建议再往深做两层:给固件加版本号和CRC校验,BootLoader在写入完成后做一次全局校验,防止固件损坏;加入固件加密(比如AES-XTS轻量模式),防止别人从Flash读出bin文件反编译抄板。这两层不复杂,但对产品安全帮助很大。先把这篇文章里的框架跑通,再逐步加,你会对IAP的理解扎实很多。