简介:在嵌入式开发中,固件升级能力是产品量产和售后维护的核心需求之一。串口IAP(In-Application Programming)允许设备通过串口在不拆机、不连接调试器的情况下完成固件替换,其本质是依靠一段常驻Flash的引导程序(Bootloader)管理升级流程。实现IAP的关键在于合理划分Boot与App的Flash地址空间、准确完成中断向量表重定位,并设计可靠的通信协议与校验机制。规范的引导程序还能在升级失败时维持设备可用,避免现场变砖。这一技术在产线批量烧录、远程设备维护、OTA前置等场景有广泛应用。本文以STM32F103为例,深入讲解从Flash分区、跳转函数到自定义帧协议、双槽位回滚的完整工程实践,并梳理常见HardFault、Flash写入失败等问题的排查方法,为开发者提供一套可直接落地的串口IAP升级参考方案。
1. 串口 IAP 升级:能救产品于水火的那十几 KB 引导代码
STM32F103 的串口 IAP(In-Application Programming)话题被翻来覆去讨论了很多年,可网上流传的模板大多是演示级的:Boot 能跳转、App 能跑,真碰上产线批量升级、售后返修要刷固件,这些模板往往顶不住。这份资源是完整的原创 STM32F103 串口 IAP 升级程序,Bootloader、测试 App、串口升级流程一应俱全,代码结构可以直接移植,也可以当作学习整套引导程序设计的实例。它解决的核心问题很明确:产品出货之后,不开壳、不接调试器,一条串口线就能完成固件替换,升级失败还能安全留在 Bootloader 里重来。适合正在设计产线工装、要给现有产品加串口升级能力,或者被「Boot 和 App 地址怎么切、跳转怎么跳才不出 HardFault」这件事困住的人。
2. 引导程序架构:Flash 分区、启动流程与向量表重定位
Bootloader 的职责其实只有三件事:等升级指令、把数据写进指定 Flash 地址、跳转到 App。听起来不难,但 Flash 分区、栈指针、中断向量表这三个点任何一个没摆到位,轻则升级后设备白屏,重则直接进 HardFault 死循环。这一章先把引导程序的骨架讲清楚。
2.1 Flash 空间怎么切:Bootloader 与 App 的地址边界
STM32F103 每次上电都从 0x08000000 取第一条指令,这个起始地址是芯片设计死的。所以 Bootloader 必须占据 0x08000000 这个坑位,否则设备永远跑不进引导程序。Bootloader 后面才是 App 的地盘,两者的边界由 Flash 控制器的页大小决定——擦除是按页整页擦的,Boot 区域的长度最好是页大小的整数倍,不然擦 App 的时候可能碰坏 Boot 的尾巴。
F103 中容量型号(比如 C8T6)每页 1KB,高容量型号(比如 RCT6)每页 2KB。这份资源里 Boot 规划为 16KB,即 0x08000000 到 0x08003FFF,App 从 0x08004000 开始。对 C8T6 来说 App 还剩 48KB,换成 RCT6 则是 240KB,普通产品固件完全够用。
| 项目 | Bootloader | 测试 App |
|---|---|---|
| 起始地址 | 0x08000000 | 0x08004000 |
| 区域大小 | 16KB | 48KB(C8)/ 240KB(RC) |
| 中断向量表位置 | 0x08000000 | 0x08004000 |
| Flash 页对齐要求 | 1KB 对齐 | 1KB / 2KB 对齐 |
我一般还会在 App 起始地址预留一小块区域存版本号、编译时间这些元数据,Boot 在握手阶段就能读出来发给上位机,产线扫码核对版本时不用拆壳看丝印。这块元数据建议放在 App 偏移 0x80 附近,避开向量表前 64 个字节(每项 4 字节,共 256 字节),既不干扰复位逻辑,读起来也方便。
2.2 跳转函数:从 Boot 到 App 的那一次「换台」
Cortex-M3 的复位动作就是读两个地址:将 0x08000000 处的值赋给主栈指针 MSP,将 0x08000004 处的值赋给程序计数器 PC。手工跳转本质上是把这两步在 App 的起始地址重做一遍。
typedef void (*pFunction)(void); /** * @brief 从 Bootloader 跳转到 App * @param app_addr App 起始地址,例如 0x08004000 */ void jump_to_app(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)app_addr; /* App 入口前 4 字节:栈顶 */ uint32_t app_pc = *(volatile uint32_t *)(app_addr + 4); /* 后 4 字节:复位向量 */ /* 合法性检查:栈顶必须落在 SRAM 区,PC 必须落在 Flash 区 */ if ((app_sp & 0xFFF00000) != 0x20000000) return; if ((app_pc & 0xFFF00000) != 0x08000000) return; __disable_irq(); /* 关全局中断,跳转过程中不许被打断 */ SCB->VTOR = app_addr; /* 重定位中断向量表到 App 区域 */ __set_MSP(app_sp); /* 换上 App 的栈指针 */ NVIC_SetPriorityGrouping(0); /* 清掉 Boot 设置的优先级分组 */ pFunction jump = (pFunction)app_pc; jump(); }这段代码里,两个合法性检查是网上很多模板没有的。0x20000000 是 F103 SRAM 基地址,0x08000000 是 Flash 基地址。实际产线升级时如果中途断电,Flash 里可能是一段半截数据,跳转前不查这两个掩码,PC 会指到野地址,整个设备就是黑匣子,只能靠调试器救。加上检查后,哪怕固件损坏,程序也能安全停在 Bootloader 里等待重新升级。
关于 __set_MSP 与直接写 SP 的区别:__set_MSP 是 ARMCC 的编译器内建函数,明确操作主栈指针 MSP。如果程序在中断上下文里跳转,还要处理 PSP,但 IAP 流程里跳转永远发生在 main 主循环,不存在这个问题。跳转前把 NVIC 优先级分组清零也是很多模板漏掉的——Boot 和 App 若用了不同分组方式,App 里中断优先级会乱套。这一行对大多数外设没影响,但对使用中断嵌套的工程是必须的。
2.3 中断向量表重定位:不搬这个表,App 第一个中断就翻车
如果 App 工程直接编译而不做任何改动,它的中断向量表会排在 0x08000000。但 0x08000000 在 Flash 里放的恰恰是 Bootloader 的向量表,于是 App 主循环可能跑得好好的,一旦来一个串口中断或定时器中断,CPU 去 0x08000000 查中断入口,找到的是 Boot 的中断服务函数——App 直接跑飞。
解决手段是把向量表重新定位到 App 的起始地址。F103 的 SCB->VTOR 寄存器在 0xE000ED08,Cortex-M3 手册规定偏移量最低 7 位忽略、需 128 字节对齐。App 放在 0x08004000,是 1KB 对齐的,满足硬件要求。常见做法是在 App 工程的 SystemInit() 末尾加一行:
/* App 工程的 SystemInit() 末尾 */ SCB->VTOR = 0x08004000;MDK 的启动流程在进入 main 之前会先调用 SystemInit(),所以放在这里能保证任何外设中断使能之前,向量表已经搬好。有人习惯把这行写在 main 函数第一行,也能工作,但前提是 SystemInit 里没有使能中断——如果有,那就必须放进 SystemInit。用 GCC 工具链的话,可以在启动文件 Reset_Handler 里进入 main 之前完成同样的操作,逻辑一致。
提示:App 起始地址务必按 256 字节对齐,最好是 1KB 对齐。如果放在 0x08004080 这种非对齐地址,低 7 位会被硬件忽略,向量表整体错位,到时候连 HardFault 都难定位。
这一章的三个点——分区、跳转、向量表——是引导程序的地基。地基打歪了,后面协议写得再好都是白搭。
3. 升级协议设计:帧格式、CRC 校验与超时重传
跳转做好只是引导程序的一半,另一半是「怎么把固件安全地送进 Flash」。这一章讲升级协议和 Bootloader 侧的状态处理。
3.1 帧格式怎么定:命令字、地址、长度、校验
串口升级第一个分歧就是:用现成 Ymodem 协议,还是自己定协议。Ymodem 的优势是现成工具多,但它是面向文件传输设计的,分包、校验、应答都不完全贴合 Flash 编程场景。这份资源采用自定义的轻量帧协议,上位机实现非常直接。
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 字节 | 0xAA 0x55,用于字节流中找帧 |
| 命令字 | 1 字节 | 0x01 握手 / 0x02 擦除 / 0x03 写入 / 0x04 校验 / 0x05 跳转 |
| 地址 | 4 字节 | 目标 Flash 地址,小端序 |
| 长度 | 2 字节 | 数据域长度,最大 256 |
| 数据域 | N 字节 | 写入命令时携带固件数据 |
| 校验 | 1 字节 | 命令字到数据域末尾的累加和,取低 8 位 |
为什么用累加和而不是 CRC32?F103 自带硬件 CRC32 外设,但它按 32 位字喂数据,对不定长帧处理比较别扭。Bootloader 里校验的首要目的是防串口误码和线路噪声,不是防黑客,累加和在工程上够用,而且代码量少、可读性好。上位机读固件文件后按 256 字节分包,最后一包不足 256 字节就补 0xFF。补 0xFF 有个天然好处:Flash 擦除后本来就是 0xFF,补的字节写进去等于无额外修改,不影响固件连续性。
3.2 Bootloader 接收状态机:收帧、擦除、写入、校验
Bootloader 的串口接收,我采用「中断写环形缓冲,主循环解析」的模型。新手常见的错误是在串口中断里直接解析协议、直接调 Flash 写入函数。Flash 编程是阻塞操作,写一页要几十毫秒,中断里待那么久,后续字节必然丢失。环形缓冲加主循环轮询,是 F103 这种小 RAM 芯片上最稳的组合。
#define RX_BUF_SIZE 512 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_head = 0; volatile uint16_t rx_tail = 0; /* 串口中断:只收字节,不做协议解析 */ void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t byte = USART_ReceiveData(USART1); uint16_t next = (rx_head + 1) % RX_BUF_SIZE; if (next != rx_tail) { /* 缓冲满则丢弃,主循环靠超时复位 */ rx_buf[rx_head] = byte; rx_head = next; } } } /* 主循环:取帧、找帧头、解析命令 */ uint8_t iap_parse_frame(uint8_t *frame, uint16_t *len) { uint16_t n = 0; while (rx_tail != rx_head && n < 264) { /* 264 = 帧头+命令+地址+长度+数据+校验 */ frame[n++] = rx_buf[rx_tail]; rx_tail = (rx_tail + 1) % RX_BUF_SIZE; } if (n == 0) return 0; if (frame[0] != 0xAA || frame[1] != 0x55) { /* 帧头不匹配,清空接收缓冲等待重新同步 */ rx_head = 0; rx_tail = 0; return 0; } uint16_t data_len = (uint16_t)frame[6] | ((uint16_t)frame[7] << 8); if (n < 9 + data_len) return 0; /* 帧还没收全,留在缓冲里继续等 */ uint8_t sum = 0; for (uint16_t i = 2; i < 8 + data_len; i++) sum += frame[i]; if (sum != frame[8 + data_len]) return 0; /* 校验失败,丢弃整帧 */ *len = n; return 1; }注意 RX_BUF_SIZE 定为 512:单包最多 264 字节,缓冲留两倍空间给「中断先收进来、主循环还没来得及取走」的瞬间余量。115200 波特率下主循环轮询频率足够,正常不会溢出。如果产品里主循环还被其他阻塞任务占用,建议把缓冲扩到 1KB,或者把 Ring Buffer 单独放一个文件管理。
写入阶段必须先整页擦除再编程,Flash 不能像 SRAM 那样直接覆盖:
void iap_flash_write(uint32_t addr, uint8_t *data, uint16_t len) { uint32_t page_size = 0x400; /* F103 中容量 1KB;高容量改为 0x800 */ /* 协议规定写入地址必须页对齐 */ if (addr & (page_size - 1)) return; FLASH_Unlock(); /* 先擦除本帧涉及的页 */ for (uint32_t a = addr; a < addr + len; a += page_size) { FLASH_ErasePage(a); } /* 半字编程:Flash 编程以半字为最小单位 */ for (uint16_t i = 0; i < len; i += 2) { uint16_t hw = data[i]; if (i + 1 < len) hw |= (uint16_t)data[i + 1] << 8; FLASH_ProgramHalfWord(addr + i, hw); } FLASH_Lock(); }这里有个隐藏细节:擦除是按页的,而帧数据可能只占半页,比如最后一帧只有几十字节。按页擦除会把这一页里之前写过的内容全部抹掉,所以上位机发送时必须保证「同一页只发送一次、且从页边界开始」。最简单做法是发送端把固件从 App 起始地址按页排列,最后一页不足部分用 0xFF 补齐,避免跨页交错。如果产品里开着 IWDG 看门狗,Flash 擦写这几十毫秒也要算进喂狗周期里,否则 Boot 会在写一半时被狗咬复位。
3.3 上位机时序:分包大小、延时与重传策略
上位机脚本的发送节奏直接决定升级成功率。经验值如下:
- 先发 0x01 握手,Boot 回复带 Bootloader 版本号的 ACK;
- 发 0x02 擦除命令,带 App 起始地址和总长度,Boot 擦完后回 ACK;
- 循环发 0x03 写入,每包 256 字节,收到一个 ACK 才发下一包;
- 发 0x04 校验命令,Boot 读回整个 App 区域重新做累加和比对;
- 发 0x05 跳转命令,Boot 关中断、置位向量表、跳转。
每包超时设在 500ms,连续 3 次失败就中断会话。为什么不是 50ms?115200 波特率下 256 字节要约 22ms 传完,Boot 处理这包还要擦页(20~40ms),整个周期 50ms 以上。超时设太短,上位机容易在 Boot 还没处理完时就以为丢了包,自己先陷入重传的泥潭。
import serial, time def send_packet(port, cmd, addr, data=b''): pkt = bytes([0xAA, 0x55, cmd]) pkt += addr.to_bytes(4, 'little') pkt += len(data).to_bytes(2, 'little') pkt += data pkt += bytes([sum(pkt[2:]) & 0xFF]) # 累加和从命令字开始算,不含帧头 port.write(pkt) deadline = time.time() + 0.5 while time.time() < deadline: ack = port.read(1) if ack == b'\x06': # ACK return True if ack == b'\x15': # NAK return False return False一个非常容易踩的坑:累加和的计算范围。上面代码里 sum(pkt[2:]) 是从命令字开始累加,不包括帧头;Boot 侧校验必须用同样范围,否则两边算出来的数永远对不上。我见过好几回「握手正常、写第一包就 NAK」的排查,最后发现就是上位机把帧头也算进校验了。另外,地址和长度字段统一定为小端序,C 语言里直接按字节拼接即可,Python 端用 to_bytes(4, 'little'),两边别各用各的。
4. App 侧配合:链接脚本偏移与跳回 Boot 的两种套路
Bootloader 写完了,App 工程如果还是默认配置,编译出来的固件往 0x08000000 放,两者一叠加必然冲突。这一章讲 App 侧必须做的三件事:链接脚本改地址、启动时搬向量表、以及主动跳回 Boot 触发下一次升级。
4.1 修改链接脚本:把 App 挪到 0x08004000
MDK 工程看 Target 选项卡:在 IROM1 区域把起始地址改成 0x08004000,大小改成芯片 Flash 总容量减去 16KB。以 64KB 的 C8T6 为例,Size 填 0xC000(48KB)。GCC 工程则改链接脚本的 MEMORY 段:
MEMORY { FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 48K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K }注意,改动后编译出的 hex 文件地址就是从 0x08004000 开始的,Bootloader 只管把 hex 里的数据按地址写入,不需要关心 App 内部怎么排布。有些工程用 axf 转 bin 直接烧录,bin 是从 0x08004000 开始的裸数据,上位机发送时也要按这个地址写入。
这里有个容易忽略的点:Boot 和 App 是两个独立工程,验证 IAP 时不能把两个工程同时下到同一台芯片,否则后烧的会覆盖先烧的。正确流程是先烧 Boot,再通过串口把 App 发进去。芯片出厂时没有 Boot,第一次烧录必须借助 SWD,这是 IAP 方案的固有前提。
4.2 App 启动阶段:向量表重定位和 SystemInit 的顺序
前面 2.3 讲的是原理,这里说实际操作。向量表重定位在 App 里同样要做,而且必须在任何中断使能之前完成。MDK 的启动文件会先调用 SystemInit() 再进 main(),所以放在 SystemInit 末尾最稳妥:
/* 测试 App 的 system_stm32f10x.c,SystemInit 函数末尾 */ void SystemInit(void) { /* ...原有的时钟初始化... */ SCB->VTOR = 0x08004000; /* 向量表搬到 App 区域 */ }有个细节:F103 的 SystemInit 里除了时钟配置还会开一些外设时钟,但不会使能中断,所以 VTOR 写在时钟配置之后仍然安全。如果自己的工程在 SystemInit 里调用了外设初始化并使能中断,那 VTOR 这行就必须提到函数最前。GCC 工程的 startup 文件里,Reset_Handler 会先取向量表首项设 MSP,再跳 SystemInit。链接脚本改过之后,向量表默认就在 0x08004000,硬件自动从正确位置读栈顶,SystemInit 只是把 VTOR 指过去保证中断能查表。很多人漏掉这一步,症状就是 App 主循环正常、一碰中断就死。
4.3 App 主动跳回 Boot:升级触发的两种设计
产品需要升级时,Bootloader 怎么被唤醒?常见两种设计。第一种是上电检测:Boot 每次启动后延时 500ms 等上位机握手命令,超时直接跳 App。缺点是设备每次上电都要白白等半秒。第二种是运行时跳转:App 收到串口升级指令后写标志位到备份寄存器,然后软复位;Boot 上电后先查这个标志,有则进入升级模式。响应快,更适合量产设备。
/* App 收到升级指令,跳回 Boot */ void app_jump_to_boot(void) { uint32_t cmd = 0x5A5A0001; /* 约定的升级标志 */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE); PWR_BackupAccessCmd(ENABLE); BKP_WriteBackupRegister(BKP_DR1, cmd); /* 写入备份寄存器 */ NVIC_SystemReset(); /* 软复位,Boot 检查标志后进升级模式 */ }Boot 侧上电后读同一个寄存器:
uint32_t flag = BKP_ReadBackupRegister(BKP_DR1); if (flag == 0x5A5A0001) { BKP_WriteBackupRegister(BKP_DR1, 0); /* 清标志 */ iap_mode = 1; /* 停在 Boot,等待升级 */ } else { jump_to_app(APP_ADDR); /* 正常启动 App */ }用备份寄存器而不是 RAM 标志,是因为软复位不会清掉备份寄存器,而 RAM 标志在复位过程中可能被启动代码初始化覆盖。F103 的 BKP 寄存器需要先使能 PWR 时钟和备份访问权限,否则写入静默失败。还有个边界情况:如果设备彻底断电,备份寄存器内容会丢,此时 Boot 按正常流程跳 App。若此时 App 是坏的,跳转前的合法性检查会把它拦住,设备仍然停留在 Boot,所以不会出现「进不去也跳不走」的死局。
这份资源里的测试 App 就实现了「收到指令→置标志→软复位→Boot 进入升级模式」的完整闭环,可以直接照搬。
5. 避坑手册:串口 IAP 最常见的五个翻车现场
这部分是我实际调试这类引导程序时遇到最多的问题,每一条都是血泪经验。按「现象 → 原因 → 解决」拆开写。
5.1 跳转后 HardFault:App 的第一个中断就打飞
现象:Boot 打印完跳转信息,App 的 LED 闪了两下,一开串口中断马上死,调试器停在 HardFault_Handler。
原因:App 的 SCB->VTOR 没有重定位,中断来了以后 CPU 查的是 Boot 的向量表,服务函数地址对不上。
解决:确认 App 工程里 SCB->VTOR = 0x08004000 已经执行,且放在开启任何中断之前。最好在跳转函数里也强制写一次 SCB->VTOR,双保险。验证方法:在 App 的 HardFault_Handler 里打断点看栈回溯,如果栈顶地址落在 Boot 区域,基本锁定是向量表问题。
5.2 Flash 写一半卡死:页边界和已擦状态没对齐
现象:升级到某个包(通常是最后一包)后,Boot 卡在 FLASH_ErasePage 或 ProgramHalfWord,不响应任何命令。
原因:跨页发送数据。前一包写完了第一页后半段,下一包从第二页开始写时,擦页把上一包的字节也抹掉了;或者最后一包不足一页,擦页把之前写入的内容连带擦掉。更隐蔽的是中容量 1KB 页和高容量 2KB 页混用,页大小宏定义错误。
解决:上位机发送端强制按页对齐分包。App 起始 0x08004000、页 1KB 时,每包数据地址必须是 0x08004000 + n*0x400。最后一包不满一页补 0xFF。检查工程里 FLASH_PAGE_SIZE 宏是否匹配芯片型号。
5.3 升级成功但不运行:App 向量表没生效
现象:Boot 正常写完、校验通过、打印跳转,然后设备没反应,示波器看串口也没有任何输出。
原因:App 侧向量表没搬,或搬了又被改写。有段时间我以为这是玄学,后来定位到是某些库函数初始化时把 SCB->VTOR 写回了默认值 0x08000000。
解决:在所有外设初始化之前设置一次 SCB->VTOR,App 里不要再单独改它。特别注意 HAL 库工程:SystemClock_Config 不会碰 VTOR,但如果你把 VTOR 写在 main 里、又开了中断,顺序就反了。
5.4 串口收包错位:环形缓冲溢出导致帧一直对不上
现象:升级第一次能跑通,第二次开始握手就失败,上位机报超时;或收到一堆 0xAA 0x55 却解析不出命令。
原因:上次升级失败后 Boot 接收缓冲区残留半帧数据,或上位机发送过快导致环形缓冲溢出丢字节。溢出时我们选择丢弃,但整个帧已经错位,后续对帧头永远失败。
解决:每次进入升级模式时先清空 rx_head、rx_tail;上位机发送端严格等 ACK 再发包;RX_BUF_SIZE 从 512 提到 1KB 可以缓解,但根本解法是发送节奏稳一点,别用千兆串口线跑 115200 还疯狂发包。
5.5 握手成功写包失败:校验和范围不一致
现象:0x01 握手、0x02 擦除都正常,0x03 第一包就回 NAK。
原因:上位机和 Boot 的累加和计算范围不一致。最常见的是上位机把 0xAA 0x55 也算进校验,Boot 只从命令字开始算;或地址、长度的大小端序反了。
解决:统一定义校验范围——从命令字开始到数据域末尾。调试时在 Boot 侧把收到的原始字节通过调试器打印出来,和上位机发的一帧逐字节比对,几秒钟就能定位。
6. 进阶:给升级流程加上「后悔药」与 CRC 双保险
基础 IAP 能跑通之后,产品化还差两步:升级失败怎么回滚,校验能力能不能更硬。先说校验。累加和能拦住大部分误码,但硬件 CRC32 更稳。F103 的 CRC 外设按 32 位字喂数据,做法是先把固件按 4 字节对齐补零,一边写 Flash 一边累加 CRC,最后一帧传完后上位机再发一个 CRC 校验命令,Boot 计算整个 App 区域的 CRC 和上位机发来的对比。用硬件外设算一遍 240KB 也就几百毫秒,体验上完全无感。
回滚方案上,F103 高容量型号 Flash 够大,可以规划 App_A 和 App_B 两个槽位。Boot 在 Flash 末尾维护一个「当前有效槽位」标志,升级写 App_B 时 App_A 不受影响,写完后校验通过才切换标志。下次启动 Boot 先读标志、再检查对应槽位的 CRC,CRC 不过就自动切到另一个槽位。这等于给现场升级买了份后悔药:升级失败还能自动回到旧固件,不用派售后带调试器。
#define APP_SLOT_A 0x08004000 #define APP_SLOT_B 0x08020000 /* 视 Flash 容量调整 */ #define FLAG_ADDR 0x0801F800 /* 高容量型号 Flash 末尾,存放槽位标志 */ uint32_t iap_get_active_slot(void) { /* Flash 擦除态是 0xFF,默认指向槽位 A */ if (*(volatile uint32_t *)FLAG_ADDR == 0x00000002) { return APP_SLOT_B; } return APP_SLOT_A; } void iap_set_active_slot(uint32_t slot) { FLASH_Unlock(); FLASH_ErasePage(FLAG_ADDR); /* 擦除标志页 */ FLASH_ProgramHalfWord(FLAG_ADDR, (uint16_t)(slot & 0xFFFF)); FLASH_ProgramHalfWord(FLAG_ADDR + 2, (uint16_t)((slot >> 16) & 0xFFFF)); FLASH_Lock(); }双槽位对 Bootloader 的改动集中在跳转前:对目标槽位做一次 CRC 校验,失败就尝试另一个槽位。升级流程也改成「先擦目标槽位→写入→CRC 比对→通过后切标志→跳转」,失败时标志不变,上电依然进旧固件。这套方案多占一个槽位的 Flash,但换来的是升级失败不黑屏的底线保障。
从那以后,我每次做串口 IAP 都强制走一遍完整验证链路:跳转地址合法性检查、App 向量表加载顺序、页对齐分包、超时重传、失败回滚,一条都不能省。这份资源里的 Bootloader 源码和测试 App 工程已经把这几环串好了,照着第 4 章的改动清单移植到自己工程,能省下大把调参时间。希望帮到你。
本文还有配套的精品资源,点击获取