STM32F103_Bootloader程序开发13 - 巧用逆向拷贝,实现固件更新的“准原子”操作,无惧升级中的意外掉电
导言
经过前面12篇文章的层层铺垫,我们已经构建了一个相当健壮的IAP Bootloader。它拥有独立的下载区,支持CRC32固件校验,具备从升级中断中自愈恢复的能力,甚至还能处理上位机失联的异常。从功能上看,我们的逻辑闭环似乎已经非常完美。
然而,在追求极致可靠性的道路上,一个真正的工程师会像侦探一样,审视代码中每一个可能被忽略的细节。今天,我们就来探讨一个潜藏在OP_Flash_Copy()固件搬运函数中的、极其微妙但却可能导致严重后果的风险——“有效”但已损坏的App。
让我们设想一个可能发生的场景:
- Bootloader校验完下载区的固件,确认无误后,开始执行OP_Flash_Copy(),将新固件从下载区搬运到App区。
- 搬运操作是从头到尾顺序执行的。 在执行了几个毫秒后,固件的头部,也就是包含栈指针(MSP)和复位向量(Reset_Handler) 的前8个字节,已经被成功写入了App区的起始位置。
- 就在这一刻,设备意外掉电了!
当设备重新上电后,Bootloader的启动决策逻辑开始运行。它会调用我们精心设计的Is_App_Valid_Enhanced()函数来快速检查App区是否存在有效程序。此时,问题出现了:
- 该函数检查栈指针,发现它在合法的RAM范围内。
- 它检查复位向量,发现它是一个有效的奇数地址,并且在App区范围内。
- 它检查两者都不是0xFFFFFFFF。
结论是:Is_App_Valid_Enhanced()函数会返回true! Bootloader被“欺骗”了,它认为这是一个可以尝试启动的App,但实际上,这个App的固件本体残缺不全,一旦跳转过去,系统几乎必然会陷入HardFault或其他未知错误,控制板变成“砖头”了。
本篇文章,我们将聚焦于填补这最后一块可靠性的拼图。我们将通过一个极其精妙的技巧——逆向拷贝(Reverse Copy),来彻底关闭这个风险窗口。这个技巧的核心思想是:将最关键的、决定App是否“可启动”的向量表,作为整个拷贝过程的最后一步写入。
通过这个改进,我们将深入探讨逆向拷贝的原理,展示其代码实现,并最终将我们的Bootloader锻造成一个真正无惧任何意外掉-电的、拥有“准原子”更新能力的工业级程序。让我们开始吧!
项目地址:
- Gitee (国内推荐): https://gitee.com/wallace89/MCU_Develop/tree/main/bootloader12_stm32f103_advance_power_down
- GitHub: https://github.com/q164129345/MCU_Develop/tree/main/bootloader12_stm32f103_advance_power_down
一、代码
1.1、op_flash.c
如上所示,左边是之前的代码,右边的是现在优化后的代码。
/********************************************************************************* @file op_flash.c* @brief STM32F103 Flash操作模块实现文件*******************************************************************************/#include "op_flash.h"/*** @brief 判断Flash地址是否合法* @param addr Flash地址* @retval 1 合法,0 非法*/
static uint8_t OP_Flash_IsValidAddr(uint32_t addr)
{return ( (addr >= STM32_FLASH_BASE_ADDR) && (addr < (STM32_FLASH_BASE_ADDR + STM32_FLASH_SIZE)) ); // F103ZET6为512K
}/*** @brief Flash整区域擦除(按页为单位擦除指定区域)* @param start_addr 起始地址(必须是有效的Flash地址,并且为页首地址)* @param length 擦除长度(字节),建议为页大小的整数倍,不足时向上取整* @retval OP_FlashStatus_t 操作结果,成功返回 OP_FLASH_OK,失败返回错误码** @note* - STM32F1系列的Flash擦除以页(Page)为最小单位,F103ZET6一页为2K字节* - 擦除前需先解锁Flash(HAL_FLASH_Unlock),擦除完成后再上锁* - HAL_FLASHEx_Erase() 内部会等待擦除完成,不需用户等待* - 若擦除区域包含重要数据,请务必提前做好备份*/
OP_FlashStatus_t OP_Flash_Erase(uint32_t start_addr, uint32_t length)
{HAL_StatusTypeDef status; //!< HAL库返回状态uint32_t PageError = 0; //!< 记录擦除出错的页号(由HAL库维护)FLASH_EraseInitTypeDef EraseInitStruct;//!< Flash擦除配置结构体//! 1. 判断起始地址和结束地址是否合法,防止操作非法区域if (!OP_Flash_IsValidAddr(start_addr) || !OP_Flash_IsValidAddr(start_addr + length - 1)) {return OP_FLASH_ADDR_INVALID;}uint32_t pageSize = STM32_FLASH_PAGE_SIZE; //!< 每页大小,通常为2K字节//! 2. 计算擦除的首页页号uint32_t firstPage = (start_addr - STM32_FLASH_BASE_ADDR) / pageSize;//! 3. 计算要擦除的页数(不足一页时也要整页擦除)uint32_t nbPages = (length + pageSize - 1) / pageSize;//! 4. 解锁Flash,允许写入和擦除HAL_FLASH_Unlock();//! 5. 配置擦除参数EraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES; //!< 选择页擦除方式EraseInitStruct.PageAddress = start_addr; //!< 擦除起始地址(页首地址)EraseInitStruct.NbPages = nbPages; //!< 擦除页数//! 6. 调用HAL库进行实际擦除操作status = HAL_FLASHEx_Erase(&EraseInitStruct, &PageError);//! 7. 操作结束后立即上锁,防止误操作HAL_FLASH_Lock();//! 8. 返回结果,HAL_OK即为成功,否则为错误return (status == HAL_OK) ? OP_FLASH_OK : OP_FLASH_ERROR;
}/*** @brief Flash写入(以32位为单位,要求地址和数据4字节对齐)* @param addr 目标地址,必须是有效Flash地址且4字节对齐* @param data 数据指针,指向待写入的数据缓冲区* @param length 写入长度(单位:字节),必须为4的整数倍* @retval OP_FlashStatus_t 操作结果,成功返回OP_FLASH_OK,失败返回错误码** @note* - STM32F1的Flash写入最小单位为32位(即4字节,一个word)* - 写入前需解锁Flash(HAL_FLASH_Unlock),写入结束后需重新上锁(HAL_FLASH_Lock)* - 地址或长度未4字节对齐时,写入操作会直接失败* - Flash写入只能将1变为0,不能将0变为1,如需写入新内容需先擦除* - 建议一次性写入不超过一页数据(2K),如需大量写入可分多次调用*/
OP_FlashStatus_t OP_Flash_Write(uint32_t addr, uint8_t *data, uint32_t length)
{//! 1. 检查目标地址是否合法,防止误操作if (!OP_Flash_IsValidAddr(addr) || !OP_Flash_IsValidAddr(addr + length - 1)) {return OP_FLASH_ADDR_INVALID;}//! 2. 检查地址和长度是否4字节对齐(硬件要求)if ((addr % 4) != 0 || (length % 4) != 0) {return OP_FLASH_ERROR; //!< 非4字节对齐}HAL_StatusTypeDef status = HAL_OK; //!< HAL库函数返回状态//! 3. 解锁Flash,允许写操作HAL_FLASH_Unlock();//! 4. 按word(32位,4字节)为单位逐步写入for (uint32_t i = 0; i < length; i += 4) {uint32_t word;//! 将data[i~i+3]拷贝为32位word,防止字节序问题memcpy(&word, data + i, 4);//! 执行实际写入操作(一次写入4字节)status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i, word);if (status != HAL_OK) {//! 遇到写入错误,立即上锁并返回HAL_FLASH_Lock();return OP_FLASH_ERROR;}}//! 5. 写入结束后上锁,避免Flash被误操作HAL_FLASH_Lock();return OP_FLASH_OK;
}/*** @brief Flash区域拷贝(典型应用:将App缓存区的固件搬运到App区)* @param src_addr 源区起始地址(如:缓存区起始地址 FLASH_APP_CACHE_ADDR)* @param dest_addr 目标区起始地址(如:App区起始地址 FLASH_APP_ADDR)* @param length 拷贝长度(单位:字节,必须为4字节对齐)* @retval OP_FlashStatus_t 操作结果,成功返回OP_FLASH_OK,失败返回错误码** @note* - 该函数会自动擦除目标区域,然后分段搬运数据,节省RAM,适用于大容量固件升级* - 一般用于Bootloader从下载缓存区升级App的场景* - 内部采用分块拷贝,防止一次分配过大缓冲区导致内存溢出* - 所有写入操作都基于4字节对齐,STM32F1系列Flash不支持非对齐写入* - 操作前请确保源数据已完整写入且校验通过(如CRC32校验)*/
OP_FlashStatus_t OP_Flash_Copy(uint32_t src_addr, uint32_t dest_addr, uint32_t length)
{//! 1. 检查参数有效性:长度为0、未对齐均非法if ((length == 0) || ((src_addr % 4) != 0) || ((dest_addr % 4) != 0) || (length % 4) != 0) {return OP_FLASH_ERROR; //!< 对齐检查}//! 2. 检查源区和目标区的起始地址是否合法if (!OP_Flash_IsValidAddr(src_addr) || !OP_Flash_IsValidAddr(dest_addr)) {return OP_FLASH_ADDR_INVALID;}//! 3. 擦除目标区域,确保写入前目标区全部为0xFFif (OP_Flash_Erase(dest_addr, length) != OP_FLASH_OK) {return OP_FLASH_ERROR;}#define FLASH_COPY_BUFSIZE 512 //!< 分块搬运缓冲区大小,单位字节,必须为4的倍数static uint8_t buffer[FLASH_COPY_BUFSIZE];uint32_t remaining = length; // 待复制的剩余字节数while (remaining > 0) {//! 4. 计算本次要搬运的数据块大小// 取 剩余量 和 缓冲区大小 中的较小者。uint32_t chunk_size = (remaining > FLASH_COPY_BUFSIZE) ? FLASH_COPY_BUFSIZE : remaining;//! 5. 计算当前块在源地址和目标地址的位置// 从末尾向前计算uint32_t current_src_addr = src_addr + remaining - chunk_size;uint32_t current_dest_addr = dest_addr + remaining - chunk_size;//! 6. 从源地址读取一个数据块到缓冲区memcpy(buffer, (const void*)current_src_addr, chunk_size);//! 7. 将数据块写入目标地址if (OP_Flash_Write(current_dest_addr, buffer, chunk_size) != OP_FLASH_OK) {return OP_FLASH_ERROR;}//! 8. 更新剩余字节数remaining -= chunk_size;}return OP_FLASH_OK;
}
二、测试IAP升级
测试一下,修改op_flash.c的函数OP_Flash_Copy()将“正向拷贝“改为“逆向拷贝”后,OTA升级会不会被影响?
2.1、python终端
2.2、RTT
如上所示,从RTT打印的log看来,OTA成功了。
三、另外一个关键改进- 使用参数区作为“更新日志”
“逆向拷贝”解决了检测固件是否正常的问题,但它本身并不能帮助我们恢复系统。当Bootloader检测到App损坏后,它需要知道:“我上次是想更新一个什么样的固件?那个固件还在不在?”
这时,我们额外规划出的参数区(Parameter Area)就派上了用场。我们可以把它当作一个不会轻易被修改的“日志本”。在执行风险操作OP_Flash_Copy()之前,我们先向这个“日志本”里写入一条记录,即本次待更新固件的元信息,其中最重要的就是固件总长度(totalSize)。
这个操作相当于在说:“哦,Bootloader,现在准备开始将一个大小为totalSize字节的新固件,从下载区搬运到App区。”
3.1、param_storage.c
/*** @file param_storage.c* @brief Flash参数存储模块实现*/
#include "param_storage.h"
#include "op_flash.h" // 依赖底层的Flash驱动
#include "soft_crc32.h" // 用于计算参数结构体自身的CRC
#include <string.h>/*** @brief 全局参数缓存*/
ParameterData_t g_params;/*** @brief 计算并返回给定参数结构体的CRC32值*/
static uint32_t calculate_params_crc(const ParameterData_t* p_params)
{// CRC计算不包含crc32字段本身return Calculate_Firmware_CRC32_SW((uint32_t)p_params, sizeof(ParameterData_t) - sizeof(uint32_t));
}/*** @brief 安全地将全局参数g_params写入Flash* @note 这是一个内部函数,封装了擦写和CRC更新的完整流程。*/
static bool flush_params_to_flash(void)
{// 1. 更新全局缓存中的CRC值g_params.paramsCRC32 = calculate_params_crc(&g_params);// 2. 擦除整个参数扇区if (OP_Flash_Erase(FLASH_PARAM_START_ADDR, FLASH_PARAM_SIZE) != OP_FLASH_OK) {// log_printf("Failed to erase parameter sector!\r\n");return false;}// 3. 将带有新CRC的整个结构体写回Flashuint32_t write_len = (sizeof(ParameterData_t) + 3) & ~3; // 4字节对齐if (OP_Flash_Write(FLASH_PARAM_START_ADDR, (uint8_t*)&g_params, write_len) != OP_FLASH_OK) {// log_printf("Failed to write parameters to Flash!\r\n");return false;}return true;
}/*** @brief 初始化参数* @note 从Flash参数区读取数据到全局缓存 g_params* @return true 成功* @return false 失败*/
bool Param_Init(void)
{// 1. 从Flash参数区读取数据到全局缓存 g_paramsmemcpy(&g_params, (void*)FLASH_PARAM_START_ADDR, sizeof(ParameterData_t));// 2. 校验数据完整性 (检查参数结构体自身是否损坏)if (g_params.paramsCRC32 == calculate_params_crc(&g_params)) {log_printf("Parameters loaded successfully from Flash.\r\n");return true;} else {// 3. CRC校验失败,加载默认值log_printf("Parameter CRC check failed! Loading default parameters.\r\n");memset(&g_params, 0, sizeof(ParameterData_t));g_params.structVersion = 0x010000;g_params.appFWInfo.totalSize = 0; // 默认参数return false;}
}/*** @brief 获取参数* @note 返回全局缓存 g_params 的指针* @return const ParameterData_t* */
const ParameterData_t* Param_Get(void)
{return &g_params;
}/*** @brief 保存参数* @note 将全局缓存 g_params 写入Flash* @param p_new_params 新的参数数据* @return true 成功* @return false 失败*/
bool Param_Save(const ParameterData_t* p_new_params)
{// 1. 复制新数据到全局缓存// 不直接修改g_params是为了保持原子性,但在C中这样做已经足够memcpy(&g_params, p_new_params, sizeof(ParameterData_t));// 2. 将更新后的全局缓存写入Flashif (!flush_params_to_flash()) {log_printf("Failed to save parameters to Flash!\r\n");return false;}log_printf("Parameters saved to Flash successfully.\r\n");return true;
}/*** @brief 更新固件信息* @note 将全局缓存 g_params 写入Flash* @param p_fw_info 新的固件信息* @return true 成功* @return false 失败*/
bool Param_UpdateFirmwareInfo(const Firmware_Info_t* p_fw_info)
{// 1. 更新全局缓存中的固件信息部分memcpy(&g_params.appFWInfo, p_fw_info, sizeof(Firmware_Info_t));// 2. 将更新后的全局缓存写入Flashif (!flush_params_to_flash()) {// log_printf("Failed to update firmware info in Flash!\r\n");return false;}// log_printf("Firmware info updated successfully in parameters.\r\n");return true;
}
3.2、param_storage.h
/*** @file param_storage.h* @brief Flash参数存储模块接口* @author Wallace.zhang* @date 2025-07-31* @version 1.1.0* @copyright* (C) 2025 Wallace.zhang. 保留所有权利.* @license SPDX-License-Identifier: MIT*/
#ifndef __PARAM_STORAGE_H
#define __PARAM_STORAGE_H#include <stdint.h>
#include <stdbool.h>
#include "flash_map.h" // 引入Flash分区定义/*** @brief 固件元数据结构体* @note 在您现有的代码中,此结构体似乎定义在IAP处理逻辑中。* 为了解耦,最好将其定义在一个公共的头文件中,或者在此处定义。*/
typedef struct {uint32_t totalSize;uint32_t SingalSliceSize;uint32_t SliceCount;
} Firmware_Info_t;/*** @brief 存储在Flash参数区的数据结构体* @note 此结构体只负责存储数据,不包含任何校验逻辑。*/
typedef struct {uint32_t structVersion; /**< 参数结构体版本,用于未来扩展 */Firmware_Info_t appFWInfo; /**< 当前App固件的信息(由IAP流程更新) */uint8_t copyRetryCount; /**< 固件拷贝重试计数器 */// ... 在此添加其他需要持久化的参数 ...// 我们在此增加一个针对本结构体自身的CRC,以确保参数读出的可靠性。// 这与固件CRC是两回事,强烈建议保留。uint32_t paramsCRC32; /**< 参数结构体自身的CRC32校验值 */
} ParameterData_t;/*** @brief 初始化参数模块* @details 尝试从Flash加载参数。如果加载失败(如首次上电或数据损坏),* 则将参数恢复为默认值。* @return bool: true 表示成功加载有效参数, false 表示已恢复为默认值。*/
bool Param_Init(void);/*** @brief 获取当前系统参数的只读指针* @return const ParameterData_t*: 指向全局参数结构体的指针。*/
const ParameterData_t* Param_Get(void);/*** @brief 保存新的参数到Flash* @details 此函数会执行安全的“读-改-擦-写”操作,并自动更新自身CRC校验值。* @param p_new_params 指向包含新数据的结构体指针。* @return bool: true 保存成功, false 保存失败。*/
bool Param_Save(const ParameterData_t* p_new_params);/*** @brief 更新Flash中的固件信息* @param p_fw_info 指向新的固件信息结构体* @return bool: true 更新成功, false 更新失败*/
bool Param_UpdateFirmwareInfo(const Firmware_Info_t* p_fw_info);#endif /* __PARAM_STORAGE_H */
3.3、main.c