STM32H5_IAP升级项目复盘——从缺陷到改进

STM32H5_IAP升级项目复盘——从缺陷到改进
STM32H5工业互联项目复盘二、缺陷一IAP 烧写失败后的假成功2.1 升级流程回顾2.1.1 整体流程PC 上位机 主控 STM32H5 │ │ │ 1. 写寄存器 0 0x55 │ │ (MODBUS_PRIVATE_CMD_ENTER_BOOT) │ │ ─────────────────────────────→ │ process_emergency_cmd() │ │ → ResetToBootloader() │ │ 写 FirmwareInfo(bEnterBootloader1) │ │ 软件复位 │ │ │ 2. 复位后进入 Bootloader │ │ │ isNeedToUpdate() 1 │ │ 停留在 Bootloader等待固件 │ │ │ 3. 文件头 (record_no0) │ │ ─────────────────────────────→ │ 解析 FileInfo擦除 Flash │ │ 擦除 APP 区 擦除 CFG 区 │ │ │ 4. 数据包 #1 (record_no1) │ │ ─────────────────────────────→ │ WriteFirmware() ← 写入 Flash │ │ │ 5. 数据包 #2..N │ │ ─────────────────────────────→ │ 同上 │ │ │ 6. 收完所有包后 │ │ │ WriteFirmwareInfo() │ │ → bEnterBootloader 0 │ │ → 下次启动进入 APP2.1.2 V1.0 核心代码burn_firmware()voidburn_firmware(uint8_t*msg,uint16_tmsg_len){// 省略解析过程...if(record_no0){// 文件头擦除 FlashEraseFlash(APP_LOAD_ADDR,tFileInfo.file_len);/* 擦除APP区 */EraseFlash(CFG_OFFSET,SECTOR_SIZE);/* 擦除配置区 */recv_len0;flash_addrAPP_LOAD_ADDR;}else{cur_lenmsg[2]-7;recv_lencur_len;WriteFirmware(msg[10],cur_len,flash_addr);// ← ① 返回值未检查flash_addrcur_len;if(recv_lentFileInfo.file_len){// ② 只要收够了长度就写启动 APP标志tFirmwareInfo.bEnterBootloader0;WriteFirmwareInfo(tFirmwareInfo);}}}2.2 缺陷分析2.2.1 缺陷触发场景假设如下场景步骤事件结果1PC 下发文件头✅ 擦除 APP 区和 CFG 区成功2PC 下发数据包 #1✅WriteFirmware()成功3PC 下发数据包 #2✅ 成功………N-1PC 下发数据包 #N-1❌WriteFirmware()失败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()失败会返回-1staticintWriteFirmware(uint8_t*firmware_buf,uint32_tlen,uint32_tflash_addr){for(inti0;ilen;i16){if(HAL_OK!HAL_FLASH_Program(FLASH_TYPEPROGRAM_QUADWORD,flash_addr,(uint32_t)firmware_buf)){g_bFlashProgramming0;HAL_FLASH_Lock();return-1;// ← 失败了但没人检查}flash_addr16;firmware_buf16;}return0;}缺陷②判断烧写完成的依据是recv_len file_len而不是检查WriteFirmware()的成功与否if(recv_lentFileInfo.file_len){// ← 只看收了多少数据tFirmwareInfo.bEnterBootloader0;// ← 没问 Flash 写进去没有WriteFirmwareInfo(tFirmwareInfo);}这个条件只能说明数据已经通过串口收完了但不能说明数据已经正确写入 Flash 了。协议层和应用层的成功被混淆了。2.2.3 问题严重性评估维度评估触发概率低正常通信正常 Flash 基本不会失败危害程度极高设备变砖需要人工拆机烧录恢复检测难度低运行中无报错重启后才暴露修复成本低几行代码2.3 改进方案2.3.1 方案一最小改动检查 WriteFirmware 返回值else{cur_lenmsg[2]-7;recv_lencur_len;// ★ 修复检查写入结果if(0!WriteFirmware(msg[10],cur_len,flash_addr)){// 写入失败不回写 bEnterBootloader0// 复位后 CFG 区全 FF → 停留在 Bootloader → 可重试SetUpdateStatus(0);return;// 放弃后续烧写}flash_addrcur_len;if(recv_lentFileInfo.file_len){tFirmwareInfo.bEnterBootloader0;WriteFirmwareInfo(tFirmwareInfo);}}效果一旦任何一包写入失败bEnterBootloader不会被设为 0CFG 区保持全0xFF。复位后isNeedToUpdate()返回 1设备停留在 BootloaderPC 可以重新开始升级。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_lentFileInfo.file_len){// 计算接收到的固件数据的 CRC32uint32_tcalc_crcGetCRC32((constchar*)APP_LOAD_ADDR,tFileInfo.file_len);if(calc_crctFileInfo.crc32){// 校验通过tFirmwareInfo.bEnterBootloader0;WriteFirmwareInfo(tFirmwareInfo);}else{// CRC 不匹配不写 bEnterBootloader0// 复位后进入 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_RxEventCallbackRxEventType触发条件Size 值HAL_UART_RXEVENT_HTDMA 传输达到半缓冲区(130字节)130HAL_UART_RXEVENT_TC空闲线检测到 或 DMA 传完整个缓冲区实际接收到的字节数正常情况下一帧数据可能只收到几十字节此时只触发 TC空闲线检测不会触发 HT。但如果数据量较大超过 130 字节就会先触发 HT再触发 TC。3.2 V1.0 核心代码没有 old_posV1.0 版本中这个回调函数的实现非常简单voidHAL_UARTEx_RxEventCallback(UART_HandleTypeDef*huart,uint16_tSize){PUART_Data pdata;if(huarthuart2)pdatag_uart2_data;if(huarthuart4)pdatag_uart4_data;/* 把 rx_buf 中 0~Size 的所有字节送入队列 */for(inti0;iSize;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 触发 (Size130) → for(i0; i130; i) → 推送字节 0~129 到队列 ✅ → 重启 DMA ← ★ 问题 1问题 1HT 时不应重启 DMA。重启后DMA 重新从缓冲区头部开始写入后半缓冲区130~259中尚未到达的数据会被覆盖。即使修复了重启 DMA 的问题还有第二个问题。假设 HT 时不重启 DMADMA 继续向后半缓冲区填充数据DMA 缓冲区 (260字节): ┌──────────────────────┬──────────────────────┐ │ 0 1 ... 129 │ 130 131 ... 259 │ └──────────────────────┴──────────────────────┘ ↑ TC/空闲触发 (Size260) → for(i0; i260; i) → 推送字节 0~259 到队列 → 字节 0~129 被第二次推送 ★ 问题 2问题 2TC 事件时for(i0; iSize; i)从 0 开始把整个缓冲区都推送了HT 时已经推送过的前半部分数据被重复推送。3.4 数据重复的后果以 Modbus RTU 通信为例假设一帧数据是01 03 04 00 64 00 65 78 B69 字节没有超过 130只触发 TC没问题。但如果收到大于 130 字节的数据流实际收到的数据: AA BB CC DD ... (200 字节) ↓ HT 事件 (Size130): 推送 AA~第130个字节 ✅ ↓ TC 事件 (Size200): 推送 AA~第130个字节 (重复) 第131~200字节 ↓ 队列中的数据: [AA..130] [AA..130] [131..200] ↑ 重复了两次对于 Modbus 协议来说数据重复意味着帧边界错乱接收方收到多余的字节无法正确解析帧CRC 校验失败多余的字节破坏了帧结构CRC 通不过通信中断连续 CRC 失败导致从机无响应3.5 根因分析这个 Bug 根因是没有区分 HT 事件和 TC 事件的处理逻辑。V1.0 的代码用同一个for(i0; iSize; 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_pos0;// 记录 HT 已处理位置if(huarthuart2)pdatag_uart2_data;if(huarthuart4)pdatag_uart4_data;/* 从 old_pos 开始推送新数据到队列 */for(intiold_pos;iSize;i)xQueueSendFromISR(pdata-rxQueue,pdata-rx_buf[i],NULL);old_posSize;// 记录已处理位置if(HAL_UART_RXEVENT_HT!huart-RxEventType)/* 非 HT → TC 事件 */{/* TC 事件一帧结束重启 DMA 并重置 old_pos */old_pos0;HAL_UARTEx_ReceiveToIdle_DMA(pdata-huart,pdata-rx_buf,UART_RX_BUF_LEN);}/* HT 事件不重启 DMA让 DMA 继续填后半缓冲区 */}效果事件触发条件V1.0错误V2.0修正HTSize130推 0~129重启 DMA❌推0~129old_pos130不重启 DMA✅TCSizeN推0~N数据重复❌推130~Nold_pos0重启 DMA✅3.8 一点深入思考这个old_pos变量被声明为static在当前项目中其实不会出问题——因为 UART2 和 UART4 有互斥锁保护底层 DMA 接收不会同时进行。但如果将来系统扩展需要更多 UART 通道或更高的并发度static共享就会成为隐患。更规范的做法是将old_pos放入UART_Data结构体中作为实例成员不过那是另一层面的改进了。对比缺陷一缺陷一中WriteFirmware()的返回值被忽略属于忘了这个缺陷属于不知道 DMA 半传输中断的存在。两种 Bug 类型不同但都说明了对硬件外设工作机制的深入理解是嵌入式开发的硬门槛。四、Cortex-M0 中断向量表重定位STM32F030 IAP4.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 指向 SRAMvoidRelocateVector(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 的代码有两个原因static 变量的惯性recv_len和tFileInfo被声明为static意味着它们跨多次函数调用保持状态。开发者可能更关注数据收完了没有而忽略了硬件写入是否成功。缺少防御性编程思维嵌入式开发中应该不会失败的地方恰好是最容易出问题的地方。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 bEnterBootloader0 仅靠 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