3. 性能分析基础

做IAP升级,说白了就是跟Flash打交道。你想想看,Bootloader要把新固件写进芯片里,总得有个靠谱的“搬运工”吧?这个搬运工就是Flash驱动。我见过不少项目,应用层代码写得花里胡哨,结果Flash驱动一跑就崩——要么擦除不干净,要么写入到一半被看门狗打断,整片数据全废了。

嗯,这一章我们就来把Flash驱动这块硬骨头啃下来。我会把我在几个量产项目里踩过的坑、总结的经验都抖出来。

3.1 Flash操作的基本认知

先聊点基础的。Flash存储器有几个特性,你必须刻在脑子里:

  • 擦除粒度大:通常按扇区(Sector)擦除,一个扇区可能是1KB、2KB、4KB甚至更大。你不能只擦一个字节。
  • 写入粒度小:可以按字节、半字(16位)或字(32位)写入,但写入前对应的区域必须是擦除状态(全0xFF)。
  • 写入次数有限:Flash的擦写寿命一般在1万到10万次之间。频繁擦写同一个扇区,它会“累死”的。
  • 读操作不影响寿命:随便读,读到天荒地老都没事。

核心原则:Flash操作期间,任何意外中断(掉电、复位、看门狗复位)都可能导致数据损坏。所以,驱动必须设计得“皮实”。

3.2 Flash驱动API封装

我个人习惯把Flash驱动封装成三个基础API:擦除、写入、读取。别小看这三个函数,里面的门道多着呢。

3.2.1 Flash擦除API

擦除是Flash操作里最耗时的,一个扇区擦除可能要几十到几百毫秒。这段时间里,CPU基本在干等。

/**
 * @brief  擦除指定扇区
 * @param  sector_addr : 扇区起始地址(必须对齐到扇区边界)
 * @retval 0: 成功, -1: 失败
 */
int Flash_EraseSector(uint32_t sector_addr)
{
    // 1. 检查地址是否在有效范围内
    if (!IS_FLASH_ADDR_VALID(sector_addr)) {
        return -1;
    }
    
    // 2. 检查地址是否扇区对齐
    if (sector_addr % FLASH_SECTOR_SIZE != 0) {
        return -1;  // 地址不对齐,直接拒绝
    }
    
    // 3. 解锁Flash控制寄存器(不同MCU操作不同)
    FLASH->KEYR = FLASH_KEY1;
    FLASH->KEYR = FLASH_KEY2;
    
    // 4. 等待上一次操作完成
    while (FLASH->SR & FLASH_SR_BSY);
    
    // 5. 设置擦除模式并启动
    FLASH->CR |= FLASH_CR_SER;          // 扇区擦除模式
    FLASH->CR |= (sector_num << 3);    // 选择扇区号
    FLASH->CR |= FLASH_CR_START;        // 启动擦除
    
    // 6. 等待擦除完成
    while (FLASH->SR & FLASH_SR_BSY);
    
    // 7. 检查错误标志
    if (FLASH->SR & FLASH_SR_PGERR) {
        FLASH->SR = FLASH_SR_PGERR;     // 清除错误
        return -1;
    }
    
    // 8. 锁定Flash
    FLASH->CR &= ~FLASH_CR_SER;
    FLASH->CR |= FLASH_CR_LOCK;
    
    return 0;
}

我的经验:擦除完成后,最好加一个读回校验。我曾经遇到一批芯片,擦除后读回来不是全0xFF,而是有几个bit卡在0。从那以后,我写的擦除函数末尾都会加一段校验代码。

3.2.2 Flash写入API

写入相对快一些,但也要注意时序。很多MCU要求写入前必须关全局中断,防止写入过程中被中断服务函数打断。

/**
 * @brief  写入数据到Flash
 * @param  addr  : 目标地址(必须按字对齐)
 * @param  data  : 要写入的数据指针
 * @param  len   : 数据长度(字节)
 * @retval 0: 成功, -1: 失败
 */
int Flash_Write(uint32_t addr, uint8_t *data, uint32_t len)
{
    uint32_t i;
    
    // 1. 参数检查
    if (!IS_FLASH_ADDR_VALID(addr) || data == NULL || len == 0) {
        return -1;
    }
    
    // 2. 地址对齐检查(假设按字写入)
    if (addr % 4 != 0) {
        return -1;
    }
    
    // 3. 解锁Flash
    FLASH->KEYR = FLASH_KEY1;
    FLASH->KEYR = FLASH_KEY2;
    
    // 4. 关中断(防止写入被打断)
    __disable_irq();
    
    // 5. 按字写入
    for (i = 0; i < len; i += 4) {
        // 等待上次操作完成
        while (FLASH->SR & FLASH_SR_BSY);
        
        // 写入一个字
        *(volatile uint32_t *)addr = *(uint32_t *)&data[i];
        
        // 等待写入完成
        while (FLASH->SR & FLASH_SR_BSY);
        
        // 检查错误
        if (FLASH->SR & FLASH_SR_PGERR) {
            FLASH->SR = FLASH_SR_PGERR;
            __enable_irq();
            return -1;
        }
        
        addr += 4;
    }
    
    // 6. 开中断
    __enable_irq();
    
    // 7. 锁定Flash
    FLASH->CR |= FLASH_CR_LOCK;
    
    return 0;
}

注意:关中断的时间不能太长!如果关中断超过看门狗喂狗间隔,系统会被复位。我建议在写入循环里分段喂狗,或者每写完一个扇区就开一次中断让系统喘口气。

3.2.3 Flash读取API

读取最简单,直接指针访问就行。但为了统一接口,我还是封装了一下:

/**
 * @brief  从Flash读取数据
 * @param  addr  : 源地址
 * @param  buf   : 目标缓冲区
 * @param  len   : 读取长度
 * @retval 0: 成功, -1: 失败
 */
int Flash_Read(uint32_t addr, uint8_t *buf, uint32_t len)
{
    uint32_t i;
    
    if (!IS_FLASH_ADDR_VALID(addr) || buf == NULL || len == 0) {
        return -1;
    }
    
    // 直接内存拷贝
    for (i = 0; i < len; i++) {
        buf[i] = *(volatile uint8_t *)addr;
        addr++;
    }
    
    return 0;
}

你看,读取函数不需要解锁、不需要关中断,直接读就行。这也是Flash最友好的操作了。

3.3 扇区保护策略

做IAP最怕什么?最怕把Bootloader自己住的扇区给擦掉了!一旦Bootloader被擦,芯片就变砖了。所以,扇区保护是重中之重。

3.3.1 扇区划分方案

我一般把Flash分成三个区域:

区域 起始地址 大小 内容 保护策略
Bootloader区 0x08000000 32KB Bootloader固件 写保护,永不擦除
参数存储区 0x08008000 8KB 升级标志、配置参数 按需擦写,但保留关键备份
应用区 0x0800A000 剩余空间 用户应用程序 升级时擦除,正常运行时只读

3.3.2 硬件写保护

很多MCU支持硬件写保护,通过配置选项字节(Option Bytes)来锁定某些扇区。一旦锁定,任何软件操作都无法擦写这些扇区,除非通过JTAG/SWD重新配置。

// 示例:配置STM32的Flash写保护(伪代码)
void Flash_EnableWriteProtect(void)
{
    // 1. 解锁选项字节
    FLASH->OPTKEYR = FLASH_OPTKEY1;
    FLASH->OPTKEYR = FLASH_OPTKEY2;
    
    // 2. 设置写保护位(保护Bootloader所在扇区)
    FLASH->OB->WRP = 0x0001;  // 保护扇区0
    
    // 3. 启动选项字节编程
    FLASH->CR |= FLASH_CR_OPTPG;
    while (FLASH->SR & FLASH_SR_BSY);
    
    // 4. 锁定
    FLASH->CR &= ~FLASH_CR_OPTPG;
}

建议:量产时,一定要把Bootloader扇区的写保护打开。这样即使应用层代码跑飞了,也擦不掉Bootloader。我曾经见过一个产品,因为没开写保护,OTA升级时网络异常导致Bootloader被部分覆盖,整批产品返厂——血的教训。

3.3.3 软件边界检查

除了硬件保护,软件层面也要做边界检查。每次擦除或写入前,都要确认目标地址不在保护区域内。

// 定义保护区域边界
#define BOOTLOADER_START    0x08000000
#define BOOTLOADER_END      0x08007FFF
#define PARAM_START         0x08008000
#define PARAM_END           0x08009FFF

int Flash_IsAddrProtected(uint32_t addr)
{
    // Bootloader区绝对保护
    if (addr >= BOOTLOADER_START && addr <= BOOTLOADER_END) {
        return 1;  // 受保护
    }
    
    // 参数区在非升级模式下也保护
    if (addr >= PARAM_START && addr <= PARAM_END) {
        if (!g_is_upgrade_mode) {
            return 1;  // 非升级模式,参数区也保护
        }
    }
    
    return 0;  // 允许操作
}

3.4 升级中断保护机制

升级过程中最怕什么?掉电、看门狗复位、意外中断。我设计了一套“三段式”保护机制:

  1. 擦除前备份关键参数:把升级标志、版本号等关键信息先备份到RAM或保留扇区。
  2. 写入时断点续传:每写完一个扇区,记录当前进度。下次启动时检查进度,从中断处继续。
  3. 完成后校验:全部写完后,对整个应用区做CRC校验,确保数据完整。

我的做法:在参数区里留两个字节作为“升级状态字”。擦除前写0xAA55,擦除完成写0x55AA,写入完成写0x0000。启动时检查这个状态字,就能知道升级进行到哪一步了。

3.5 本章知识结构图

下面这张图把Flash驱动的核心逻辑串起来了:

Flash驱动设计知识结构图 Flash驱动设计核心结构 擦除API 写入API 读取API 扇区保护策略 硬件写保护 软件边界检查 升级状态记录 升级中断保护机制 擦除前备份参数 写入时断点续传 完成后CRC校验 核心目标:保证升级过程不跑飞、不丢数据、不死机

3.6 避坑指南

最后,把我这些年踩过的坑列出来,你写驱动的时候绕着走:

  • 别在擦除/写入过程中喂狗:有些MCU的Flash操作会锁住总线,喂狗函数可能根本执行不到。我建议在操作前一次性喂狗,然后估算最大耗时,确保不会超时。
  • 地址对齐不是小事:很多MCU要求写入地址必须按字(4字节)对齐。不对齐的话,轻则写入失败,重则触发硬件错误。
  • 别忘了清除错误标志:Flash操作出错后,错误标志会一直挂着。不清除的话,下一次操作会直接失败。
  • 读回校验不能省:擦除和写入完成后,一定要读回来对比一下。Flash也有“软错误”,虽然概率低,但碰到了就是100%。
  • 参数区要留冗余:我习惯在参数区存两份相同的配置数据。如果一份校验失败,就用另一份恢复。这样即使升级中途掉电,也不会丢配置。

特别提醒:千万不要在Flash操作函数里调用printf打印调试信息!printf本身可能触发中断、占用大量时间,甚至导致Flash操作超时。我见过有人这么干,结果调试时一切正常,量产时批量死机——因为调试器连着的时printf跑得快,量产时串口没接,printf阻塞了。

好了,Flash驱动这块就聊到这儿。你把这些API封装好、保护策略做到位,后面的IAP流程才能跑得稳。记住一句话:驱动写得糙,升级两行泪。