STM32H5_IAP升级项目复盘——从缺陷到改进
2026/7/18 23:52:27 网站建设 项目流程

STM32H5工业互联项目复盘

二、缺陷一:IAP 烧写失败后的"假成功"

2.1 升级流程回顾

2.1.1 整体流程
PC 上位机 主控 STM32H5 │ │ │ 1. 写寄存器 0 = 0x55 │ │ (MODBUS_PRIVATE_CMD_ENTER_BOOT) │ │ ─────────────────────────────→ │ process_emergency_cmd() │ │ → ResetToBootloader() │ │ 写 FirmwareInfo(bEnterBootloader=1) │ │ 软件复位 │ │ │ 2. 复位后进入 Bootloader │ │ │ isNeedToUpdate() = 1 │ │ 停留在 Bootloader,等待固件 │ │ │ 3. 文件头 (record_no=0) │ │ ─────────────────────────────→ │ 解析 FileInfo,擦除 Flash │ │ 擦除 APP 区 + 擦除 CFG 区 │ │ │ 4. 数据包 #1 (record_no=1) │ │ ─────────────────────────────→ │ WriteFirmware() ← 写入 Flash │ │ │ 5. 数据包 #2..N │ │ ─────────────────────────────→ │ 同上 │ │ │ 6. 收完所有包后 │ │ │ WriteFirmwareInfo() │ │ → bEnterBootloader = 0 │ │ → 下次启动进入 APP
2.1.2 V1.0 核心代码:burn_firmware()
voidburn_firmware(uint8_t*msg,uint16_tmsg_len){// 省略解析过程...if(record_no==0){// 文件头:擦除 FlashEraseFlash(APP_LOAD_ADDR,tFileInfo.file_len);/* 擦除APP区 */EraseFlash(CFG_OFFSET,SECTOR_SIZE);/* 擦除配置区 */recv_len=0;flash_addr=APP_LOAD_ADDR;}else{cur_len=msg[2]-7;recv_len+=cur_len;WriteFirmware(&msg[10],cur_len,flash_addr);// ← ① 返回值未检查!flash_addr+=cur_len;if(recv_len>=tFileInfo.file_len){// ② 只要收够了长度,就写"启动 APP"标志tFirmwareInfo.bEnterBootloader=0;WriteFirmwareInfo(&tFirmwareInfo);}}}

2.2 缺陷分析

2.2.1 缺陷触发场景

假设如下场景:

步骤事件结果
1PC 下发文件头✅ 擦除 APP 区和 CFG 区成功
2PC 下发数据包 #1WriteFirmware()成功
3PC 下发数据包 #2✅ 成功
N-1PC 下发数据包 #N-1WriteFirmware()失败(Flash 写入错误)
NPC 下发最后一个数据包 #N❌ 也失败
recv_len >= file_len成立bEnterBootloader被设为 0
WriteFirmwareInfo()✅ 写入 CFG 区
复位isNeedToUpdate()返回 0跳转到损坏的 APP → 跑飞!
2.2.2 根因分析

问题出在两处

缺陷①WriteFirmware()的返回值被忽略

WriteFirmware(&msg[10],cur_len,flash_addr);// 返回值被丢弃!

WriteFirmware()内部如果HAL_FLASH_Program()失败,会返回-1

staticintWriteFirmware(uint8_t*firmware_buf,uint32_tlen,uint32_tflash_addr){for(inti=0;i<len;i+=16){if(HAL_OK!=HAL_FLASH_Program(FLASH_TYPEPROGRAM_QUADWORD,flash_addr,(uint32_t)firmware_buf)){g_bFlashProgramming=0;HAL_FLASH_Lock();return-1;// ← 失败了,但没人检查}flash_addr+=16;firmware_buf+=16;}return0;}

缺陷②:判断"烧写完成"的依据是recv_len >= file_len,而不是检查WriteFirmware()的成功与否

if(recv_len>=tFileInfo.file_len){// ← 只看收了多少数据tFirmwareInfo.bEnterBootloader=0;// ← 没问 Flash 写进去没有WriteFirmwareInfo(&tFirmwareInfo);}

这个条件只能说明"数据已经通过串口收完了",但不能说明"数据已经正确写入 Flash 了"。协议层和应用层的成功被混淆了。

2.2.3 问题严重性评估
维度评估
触发概率低(正常通信+正常 Flash 基本不会失败)
危害程度极高(设备变砖,需要人工拆机烧录恢复)
检测难度低(运行中无报错,重启后才暴露)
修复成本低(几行代码)

2.3 改进方案

2.3.1 方案一(最小改动):检查 WriteFirmware 返回值
else{cur_len=msg[2]-7;recv_len+=cur_len;// ★ 修复:检查写入结果if(0!=WriteFirmware(&msg[10],cur_len,flash_addr)){// 写入失败,不回写 bEnterBootloader=0// 复位后 CFG 区全 FF → 停留在 Bootloader → 可重试SetUpdateStatus(0);return;// 放弃后续烧写}flash_addr+=cur_len;if(recv_len>=tFileInfo.file_len){tFirmwareInfo.bEnterBootloader=0;WriteFirmwareInfo(&tFirmwareInfo);}}

效果:一旦任何一包写入失败,bEnterBootloader不会被设为 0,CFG 区保持全0xFF。复位后isNeedToUpdate()返回 1,设备停留在 Bootloader,PC 可以重新开始升级。

2.3.2 方案二(更完善):加入 CRC 校验

代码中FirmwareInfo结构体已经预留了crc32字段:

typedefstructFirmwareInfo{uint32_tversion;uint32_tfile_len;uint32_tload_addr;uint32_tcrc32;// ← 已预留但未使用uint8_tfile_name[16];uint32_tbEnterBootloader;}FirmwareInfo;

bootloader.c中也已经实现了 CRC32 计算函数GetCRC32()。可以利用起来:

// 文件头发送时,上位机计算整个固件的 CRC32 并存入 FileInfo// 接收完毕后,主控重新计算 CRC32 对比if(recv_len>=tFileInfo.file_len){// 计算接收到的固件数据的 CRC32uint32_tcalc_crc=GetCRC32((constchar*)APP_LOAD_ADDR,tFileInfo.file_len);if(calc_crc==tFileInfo.crc32){// 校验通过tFirmwareInfo.bEnterBootloader=0;WriteFirmwareInfo(&tFirmwareInfo);}else{// CRC 不匹配,不写 bEnterBootloader=0// 复位后进入 Bootloader,可重试Draw_String(0,64,"CRC ERROR! Retry...",0xFF0000,0);}}
2.3.3 方案三(终极方案):双备份 + 回滚

在 Bank2 中保留一份上一次正常运行的回退固件

Flash 布局(改进版): 0x0800_0000 Bootloader (256KB) 0x0804_0000 APP 主分区 (Active) ... 0x0810_0000 APP 备份分区 (Backup) ← 新加 ... 0x081F_E000 FirmwareInfo

升级策略

1. 新固件写入备份分区(而不是主分区) 2. 全部写入完成 + CRC 校验通过 → 再写 FirmwareInfo 切换启动分区 3. 如果新固件启动失败 → Bootloader 检测到启动超时 → 自动切回旧固件

这是工业级产品中常见的A/B 双备份 (Dual Bank) 升级方案,但需要更大的 Flash 空间支持。

2.4 三种方案对比

方案改动量防护效果缺点
方案一:检查返回值3 行代码✅ 防止写入失败后"假成功"无法检测数据在传输中损坏(CRC 正确但内容错乱)
方案二:+ CRC 校验10 行代码✅ ✅ 同时检测写入错误和数据损坏需要上位机配合计算 CRC;无法回滚
方案三:双备份较大重构✅ ✅ ✅ 完全防止变砖需要额外 Flash 空间;启动逻辑更复杂

推荐路线:以方案一为底线(必须修),方案二为标准(推荐),方案三为长期目标。


三、缺陷二分析:DMA 半传输中断导致数据重复推送

除了 IAP 烧写的缺陷,UART 接收部分也有一个值得复盘的 Bug,位于HAL_UARTEx_RxEventCallback中。

3.1 背景:DMA + IDLE 接收的 HT/TC 机制

项目使用 STM32H5 的DMA + 空闲中断方式接收 UART 数据,DMA 缓冲区大小为 260 字节,模式为DMA_NORMAL(非循环)。HAL 库在两种情况下触发回调HAL_UARTEx_RxEventCallback

RxEventType触发条件Size 值
HAL_UART_RXEVENT_HTDMA 传输达到半缓冲区(130字节)130
HAL_UART_RXEVENT_TC空闲线检测到 或 DMA 传完整个缓冲区实际接收到的字节数

正常情况下,一帧数据可能只收到几十字节,此时只触发 TC(空闲线检测),不会触发 HT。但如果数据量较大(超过 130 字节),就会先触发 HT,再触发 TC。

3.2 V1.0 核心代码(没有 old_pos)

V1.0 版本中,这个回调函数的实现非常简单:

voidHAL_UARTEx_RxEventCallback(UART_HandleTypeDef*huart,uint16_tSize){PUART_Data pdata;if(huart==&huart2)pdata=&g_uart2_data;if(huart==&huart4)pdata=&g_uart4_data;/* 把 rx_buf 中 0~Size 的所有字节送入队列 */for(inti=0;i<Size;i++)xQueueSendFromISR(pdata->rxQueue,&pdata->rx_buf[i],NULL);/* 重新启动 DMA + IDLE 接收 */HAL_UARTEx_ReceiveToIdle_DMA(pdata->huart,pdata->rx_buf,UART_RX_BUF_LEN);}

3.3 缺陷触发场景

当一帧数据超过 130 字节时,DMA 会先填满前半缓冲区,触发HT 事件

DMA 缓冲区 (260字节): ┌──────────────────────┬──────────────────────┐ │ 0 1 ... 129 │ 130 131 ... 259 │ └──────────────────────┴──────────────────────┘ ↑ HT 触发 (Size=130) → for(i=0; i<130; i++) → 推送字节 0~129 到队列 ✅ → 重启 DMA! ← ★ 问题 1

问题 1:HT 时不应重启 DMA。重启后,DMA 重新从缓冲区头部开始写入,后半缓冲区(130~259)中尚未到达的数据会被覆盖。

即使修复了重启 DMA 的问题,还有第二个问题。假设 HT 时不重启 DMA,DMA 继续向后半缓冲区填充数据:

DMA 缓冲区 (260字节): ┌──────────────────────┬──────────────────────┐ │ 0 1 ... 129 │ 130 131 ... 259 │ └──────────────────────┴──────────────────────┘ ↑ TC/空闲触发 (Size=260) → for(i=0; i<260; i++) → 推送字节 0~259 到队列 → 字节 0~129 被第二次推送! ★ 问题 2

问题 2:TC 事件时,for(i=0; i<Size; i++)从 0 开始,把整个缓冲区都推送了,HT 时已经推送过的前半部分数据被重复推送

3.4 数据重复的后果

以 Modbus RTU 通信为例,假设一帧数据是01 03 04 00 64 00 65 78 B6(9 字节),没有超过 130,只触发 TC,没问题。

但如果收到大于 130 字节的数据流:

实际收到的数据: AA BB CC DD ... (200 字节) ↓ HT 事件 (Size=130): 推送 AA~第130个字节 ✅ ↓ TC 事件 (Size=200): 推送 AA~第130个字节 (重复!) + 第131~200字节 ↓ 队列中的数据: [AA..130] [AA..130] [131..200] ↑ 重复了!两次

对于 Modbus 协议来说,数据重复意味着:

  • 帧边界错乱:接收方收到多余的字节,无法正确解析帧
  • CRC 校验失败:多余的字节破坏了帧结构,CRC 通不过
  • 通信中断:连续 CRC 失败导致从机无响应

3.5 根因分析

这个 Bug 根因是:没有区分 HT 事件和 TC 事件的处理逻辑

V1.0 的代码用同一个for(i=0; i<Size; i++)处理两种事件。对于 HT,应该只处理前半缓冲区(0~Size);对于 TC,应该只处理后半缓冲区(old_pos~Size)。这两种情况下,"新数据"的起始位置是不同的。

3.6 问题严重性评估

维度评估
触发概率(只在单次接收超过 130 字节时触发,Modbus 命令帧通常很小,但固件升级分包传输时可能触发)
危害程度(数据重复 → CRC 错乱 → 通信中断)
检测难度低(逻辑清晰,代码审查即可发现)
修复成本极低(引入一个 old_pos 变量即可)

3.7 改进方案

引入old_pos变量来记录 HT 事件时已经处理到的位置,TC 事件时从这个位置开始继续推送:

voidHAL_UARTEx_RxEventCallback(UART_HandleTypeDef*huart,uint16_tSize){PUART_Data pdata;staticuint16_told_pos=0;// 记录 HT 已处理位置if(huart==&huart2)pdata=&g_uart2_data;if(huart==&huart4)pdata=&g_uart4_data;/* 从 old_pos 开始推送新数据到队列 */for(inti=old_pos;i<Size;i++)xQueueSendFromISR(pdata->rxQueue,&pdata->rx_buf[i],NULL);old_pos=Size;// 记录已处理位置if(HAL_UART_RXEVENT_HT!=huart->RxEventType)/* 非 HT → TC 事件 */{/* TC 事件:一帧结束,重启 DMA 并重置 old_pos */old_pos=0;HAL_UARTEx_ReceiveToIdle_DMA(pdata->huart,pdata->rx_buf,UART_RX_BUF_LEN);}/* HT 事件:不重启 DMA,让 DMA 继续填后半缓冲区 */}

效果

事件触发条件V1.0(错误)V2.0(修正)
HTSize=130推 0~129,重启 DMA0~129,old_pos=130,不重启 DMA
TCSize=N0~N数据重复130~N,old_pos=0,重启 DMA

3.8 一点深入思考

这个old_pos变量被声明为static,在当前项目中其实不会出问题——因为 UART2 和 UART4 有互斥锁保护,底层 DMA 接收不会同时进行。但如果将来系统扩展,需要更多 UART 通道或更高的并发度,static共享就会成为隐患。更规范的做法是将old_pos放入UART_Data结构体中作为实例成员,不过那是另一层面的改进了。

对比缺陷一:缺陷一中WriteFirmware()的返回值被忽略,属于"忘了";这个缺陷属于"不知道 DMA 半传输中断的存在"。两种 Bug 类型不同,但都说明了对硬件外设工作机制的深入理解是嵌入式开发的硬门槛。


四、Cortex-M0 中断向量表重定位(STM32F030 IAP)

4.1 背景问题:为什么 APP 的中断会跑飞?

STM32F030 使用Cortex-M0内核,它没有SCB->VTOR(向量表偏移寄存器)。VTOR 是 Cortex-M3/M4/M7 才有的功能。在 M0 上,CPU 永远从0x00000000获取中断向量表。

IAP 方案中,Bootloader 需要跳转到 APP(位于0x08020000)执行。如果没有特殊处理,APP 启动后一旦发生中断:

CPU 发生中断 → 从 0x00000000 取向量 (此时仍映射到 Bootloader 所在 Flash) → 拿到 Bootloader 的向量 → 跳转到 Bootloader 的中断服务函数 → APP 崩溃 ❌

4.2 解决方案:RelocateVector

解决方法是把 APP 的中断向量表拷贝到 SRAM 开头,再通过内存重映射让 0x00000000 指向 SRAM

voidRelocateVector(void){memcpy((void*)VECTOR_ADDR_AT_RAM,(void*)APP_LOAD_ADDR,VECTOR_SIZE_AT_RAM);__HAL_SYSCFG_REMAPMEMORY_SRAM();}
步骤动作效果
拷贝向量表SRAM 开头 (0x20000000) 拥有了 APP 的向量表
内存重映射0x00000000不再指向 Flash,改为指向 SRAM0x20000000
中断响应CPU 从0x00000000取向量 → 实际访问 SRAM → 找到 APP 的中断处理函数

4.3 内存布局

// bootloader.h 中的定义#defineAPP_LOAD_ADDR0x08020000// APP 在 Flash 中的起始地址#defineVECTOR_ADDR_AT_RAM0x20000000// 向量表在 SRAM 中的目标地址#defineVECTOR_SIZE_AT_RAM200// 向量表大小 (50 个向量 × 4 字节)
Flash 布局: 0x08000000 ┌────────────────────┐ │ Bootloader 代码 │ 0x08020000 ├────────────────────┤ │ APP 代码 (原地执行) │ ← CPU 直接从 Flash 读取指令执行 (XIP) │ APP 只读常量 │ 0x0803F800 ├────────────────────┤ │ 固件配置信息 │ 0x08040000 └────────────────────┘ SRAM 布局: 0x20000000 ┌────────────────────┐ │ 向量表 (200 字节) │ ← RelocateVector() 将 APP 向量表拷贝至此 0x200000C7 ├────────────────────┤ 0x200000C8 │ │ │ APP 变量/堆栈 │ ← IRAM1 (起始地址 = 0x200000C8) │ (.data / .bss) │ 0x2000XXXX └────────────────────┘

IRAM1 的起始地址设为0x200000C8,正是为了预留前 200 字节 (0xC8) 给向量表,让应用代码的变量和堆栈不占用这段保留空间。


五、更深层的反思

5.1 为什么这个 Bug 会写出来?

分析 V1.0 的代码,有两个原因:

  1. static 变量的"惯性"recv_lentFileInfo被声明为static,意味着它们跨多次函数调用保持状态。开发者可能更关注"数据收完了没有",而忽略了"硬件写入是否成功"。

  2. 缺少防御性编程思维:嵌入式开发中,"应该不会失败"的地方恰好是最容易出问题的地方。Flash 编程虽然通常可靠,但在电源不稳、EMC 干扰等工业现场,失败是现实存在的。

5.2 从这次复盘学到的原则

原则说明
所有硬件操作函数都必须检查返回值HAL_FLASH_Program()HAL_FLASHEx_Erase()
“看起来完成"不等于"实际完成”协议层的成功不代表应用层的成功
升级失败要能回退宁可停留在 Bootloader,不要跳转到损坏的 APP
预留字段及时用上crc32字段已经预留了,但没有真正用起来,等于没留
失败要显式处理不要因为"不会发生"就忽略异常分支

5.3 关于"变砖"的进一步思考

在这个项目中,即使修复了上述 Bug,最坏情况下——比如在EraseFlash()执行中途断电——设备依然可能变砖(因为 Bootloader 自身代码区可能被擦出问题?不会,Bootloader 位于0x0800_0000,擦除的是0x0804_0000开始的 APP 区,不会擦到自己)。

更安全的设计是:Bootloader 自身固件永不擦除(通过写保护),且 Bootloader 只烧写 APP 区,确保自身独立于升级流程之外。本项目符合这个设计原则,这是值得肯定的。


六、总结

IAP 烧写缺陷 ──────────────────────────────────────────────────────────── V1.0 缺陷 改进 效果 WriteFirmware() 检查返回值 失败后不写入 返回值被忽略 + 提前 return bEnterBootloader=0 仅靠 recv_len 判断 + CRC 校验 全面防护数据完整性 烧录完成 无备份机制 + 双备份升级 最大程度避免变砖 UART DMA 接收缺陷 ──────────────────────────────────────────────────────────── V1.0 缺陷 改进 效果 HT/TC 事件未区分 old_pos 记录已处理位置 数据不会重复推送 HT 时重启 DMA HT 时不重启 DMA 后半缓冲区数据不丢失 TC 时从 0 开始推送 TC 时从 old_pos 开始推送 数据不重复 Cortex-M0 向量表重定位 ──────────────────────────────────────────────────────────── 知识点 说明 无 VTOR 寄存器 CPU 永远从 0x00000000 取向量 RelocateVector() 拷贝向量表到 SRAM + 重映射 IRAM1 起始 = 0x200000C8 预留前 200 字节给向量表 代码在 Flash 原地执行 (XIP) 仅 200 字节向量表拷贝到 SRAM

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

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

立即咨询