单片机参数如何掉电保存:Flash、EEPROM和配置存储完整设计

单片机参数如何掉电保存:Flash、EEPROM和配置存储完整设计

本文适用于 STM32、GD32、HK32、PY32、NXP、AVR 等常见单片机平台。

重点不只是介绍如何调用 Flash 或 EEPROM 写入函数,而是讲清楚一套可落地的参数保存方案:参数结构体、CRC 校验、版本管理、A/B 双备份、掉电保护、默认参数恢复和磨损均衡。


一、为什么单片机需要保存参数

单片机运行时,大部分变量保存在 RAM 中。

例如:

复制代码
uint8_t device_address = 1;
float calibration_k = 1.0f;
uint16_t fan_start_temperature = 45;
uint32_t communication_baudrate = 115200;

RAM 的特点是读写速度快,但断电后数据会丢失。

设备重新上电后,如果这些参数全部恢复为程序编译时的初始值,就会出现问题:

  • 设备地址恢复,多个设备地址冲突;

  • 传感器校准参数丢失;

  • 风扇启动阈值恢复;

  • 串口波特率和校验位恢复;

  • 用户设置失效;

  • 生产序列号或设备标识丢失。

因此,下列参数通常需要掉电保存:

复制代码
设备地址
设备序列号
校准零点
校准系数
传感器阈值
风扇启停温度
通信波特率
串口校验位
Modbus站号
工作模式
报警阈值
用户配置
累计运行数据

这类数据需要保存在非易失性存储器中,常见方案包括:

  • 单片机内部 Flash;

  • 单片机内部 EEPROM;

  • 外部 EEPROM;

  • 外部 SPI Flash;

  • FRAM;

  • 备份寄存器;

  • 带电池保持的 SRAM。

本文主要讨论最常见的 Flash 和 EEPROM。


二、Flash 和 EEPROM 有什么区别

Flash 和 EEPROM 都属于非易失性存储器,也就是断电后数据不会立即丢失。

但它们的擦除、写入方式和适用场景不同。

对比项目 Flash EEPROM
主要用途 程序代码、大块数据、少量配置 少量且需要修改的参数
容量 通常较大 通常较小
擦除单位 页、扇区或块 字节、页或小块,视器件而定
写入前是否擦除 通常需要 由器件内部处理或按页处理
写入灵活性 较低 较高
擦写寿命 通常低于 EEPROM 通常高于普通 Flash
写入速度 取决于芯片和操作方式 通常存在毫秒级写周期
典型参数 固件、较少变化配置 地址、校准参数、通信配置

可以简单理解为:

Flash 容量较大,但修改麻烦,适合少改多读。
EEPROM 容量较小,但改写更灵活,适合保存少量配置参数。

需要注意:

并不是所有单片机内部都有 EEPROM。

很多 STM32 型号只有内部 Flash,没有真正的 EEPROM。此时通常采用以下方案:

复制代码
内部 Flash 模拟 EEPROM
外接 I2C EEPROM
外接 SPI EEPROM
外接 FRAM

具体选型必须查看芯片数据手册。


三、Flash 的读取、写入和擦除

3.1 Flash 读取

Flash 的读取通常比较简单。

对于片内 Flash,很多单片机可以直接按地址读取:

复制代码
uint32_t value = *(volatile uint32_t *)flash_address;

或者通过厂商提供的驱动接口读取。

读取 Flash 通常具有以下特点:

  • 读取速度快;

  • 不会消耗擦写寿命;

  • 可以频繁读取;

  • 很多芯片可以像读取普通内存一样读取。

因此,参数可以在上电时从 Flash 读取到 RAM,之后程序运行期间主要使用 RAM 中的副本。

推荐流程:

复制代码
设备上电
   ↓
从 Flash 读取参数
   ↓
检查版本和 CRC
   ↓
复制到 RAM
   ↓
运行期间使用 RAM 参数

不要在每一次业务计算时都反复读取 Flash。


3.2 Flash 写入

Flash 写入通常只能把数据位从 1 修改为 0

假设擦除后的一个字节为:

复制代码
1111 1111

可以写成:

复制代码
1010 0101

因为其中部分位由 1 变成了 0

但是不能直接把:

复制代码
1010 0101

重新写成:

复制代码
1111 0000

因为其中有些位需要从 0 恢复为 1

Flash 中的 0 想重新变回 1,通常必须先执行擦除操作。

因此,Flash 的修改过程通常不是:

复制代码
直接覆盖旧数据

而是:

复制代码
读取原数据
   ↓
在 RAM 中修改
   ↓
擦除 Flash 页或扇区
   ↓
重新写入完整数据

3.3 Flash 擦除

Flash 擦除后,存储单元通常恢复为全 1

复制代码
0xFF
0xFFFF
0xFFFFFFFF

但是 Flash 一般不能只擦除某一个字节。

它的擦除单位通常是:

  • Page,页;

  • Sector,扇区;

  • Block,块。

假设一个 Flash 页中保存了:

复制代码
参数A
参数B
参数C
参数D

现在只需要修改参数 C,实际流程可能是:

复制代码
1. 把整页读取到 RAM
2. 修改 RAM 中的参数 C
3. 擦除整页 Flash
4. 把整页重新写回

所以,在 Flash 中修改一个很小的参数,也可能需要重写一整页。


四、什么是页和扇区

页和扇区都是 Flash 的组织和擦除单位。

4.1 页 Page

页通常是较小的擦除或写入管理单位。

常见大小可能是:

复制代码
1 KB
2 KB
4 KB
8 KB

例如某个单片机内部 Flash 的最后一页被划分为配置区:

复制代码
Flash末尾
┌────────────────────────────┐
│ 参数存储页                 │
│ magic                      │
│ version                    │
│ device_address             │
│ calibration                │
│ crc                        │
└────────────────────────────┘

具体页大小必须查看芯片参考手册。


4.2 扇区 Sector

扇区通常比页更大,但不同芯片的定义并不完全一致。

某些单片机内部 Flash 按扇区擦除,例如:

复制代码
16 KB
64 KB
128 KB

外部 SPI Flash 常见的最小擦除单位可能是:

复制代码
4 KB Sector
32 KB Block
64 KB Block

不要根据其他芯片的经验直接填写地址和大小。

必须确认:

  • Flash 起始地址;

  • Flash 总容量;

  • 页或扇区大小;

  • 最小写入粒度;

  • 写入对齐要求;

  • 擦除方式;

  • 参数区是否与程序区重叠。


五、为什么不能频繁写 Flash

5.1 擦写寿命有限

Flash 存储单元存在擦写寿命。

不同芯片可能是:

复制代码
1万次量级
10万次量级
更高或更低

准确数值必须以芯片数据手册为准。

假设某个参数每秒保存一次,Flash 擦写寿命为 1 万次:

复制代码
10000秒 ÷ 3600 ≈ 2.78小时

也就是说,仅几个小时就可能达到额定擦写次数。

即使每分钟写一次:

复制代码
10000分钟 ÷ 60 ÷ 24 ≈ 6.94天

这种设计明显不可接受。


5.2 每次擦除影响整页或整扇区

即使只修改一个设备地址,也可能需要擦除整个参数页。

频繁擦除会带来:

  • Flash 寿命快速下降;

  • 写入时间增加;

  • 参数损坏风险增加;

  • 掉电保护更加复杂;

  • 程序运行受到影响。


5.3 写入期间可能影响程序执行

部分单片机在写入或擦除内部 Flash 时,可能无法同时从同一个 Flash Bank 正常取指。

可能出现:

  • CPU等待;

  • 中断响应延迟;

  • 实时任务超时;

  • 通信数据丢失;

  • 控制周期被破坏。

不同芯片是否支持双 Bank、读写同时执行、RAM 中执行写 Flash 代码等,必须参考对应手册。


5.4 掉电可能造成数据不完整

假设程序执行:

复制代码
擦除旧参数
   ↓
写入新参数

如果刚擦除完就掉电,新参数还没有写完,那么旧参数和新参数可能都不可用。

这也是为什么工程中不能简单采用:

复制代码
erase();
write();

而不做备份和校验。


六、参数不要分散保存,建议使用结构体

错误设计可能是:

复制代码
#define DEVICE_ADDR_FLASH_ADDRESS   0x0801F000
#define FAN_TEMP_FLASH_ADDRESS      0x0801F010
#define BAUDRATE_FLASH_ADDRESS      0x0801F020
#define CALIB_K_FLASH_ADDRESS       0x0801F030

这种设计的问题是:

  • 参数地址分散;

  • 新增参数容易冲突;

  • 难以统一校验;

  • 难以进行版本升级;

  • 难以进行双备份;

  • 维护成本高。

更推荐把参数整理成结构体:

复制代码
#include <stdint.h>

typedef struct
{
    uint16_t device_address;
    uint16_t station_number;

    float calibration_k;
    float calibration_b;

    int16_t fan_on_temperature;
    int16_t fan_off_temperature;
    int16_t alarm_temperature;

    uint32_t baudrate;
    uint8_t parity;
    uint8_t stop_bits;

    uint8_t work_mode;
    uint8_t reserved[7];
} DeviceConfig_t;

参数全部集中管理:

复制代码
设备地址
校准参数
风扇阈值
通信参数
工作模式

6.1 为什么使用固定宽度类型

建议使用:

复制代码
uint8_t
uint16_t
uint32_t
int16_t

而不是直接使用:

复制代码
int
long

因为不同编译器和平台中,intlong 的长度可能不同。

固定宽度类型更有利于:

  • 数据结构长期兼容;

  • 固件升级;

  • 跨平台解析;

  • 上位机读取;

  • 参数文件导出。


6.2 注意结构体填充

编译器可能在结构体字段之间插入填充字节。

例如:

复制代码
typedef struct
{
    uint8_t flag;
    uint32_t value;
} Example_t;

实际大小可能不是 5 字节,而是 8 字节。

可以通过以下方式处理:

  1. 合理调整字段顺序;

  2. 显式保留填充字段;

  3. 使用序列化和反序列化;

  4. 必要时使用编译器打包属性;

  5. 保存结构体长度并进行检查。

例如:

复制代码
typedef struct
{
    uint32_t value;
    uint8_t flag;
    uint8_t reserved[3];
} Example_t;

不要盲目依赖 packed,因为非对齐访问在某些 MCU 上可能降低效率,甚至触发异常。


七、推荐的完整参数记录格式

仅保存业务结构体还不够。

工程中通常需要增加参数头信息:

复制代码
#define CONFIG_MAGIC       0x43464731UL
#define CONFIG_VERSION     1U

typedef struct
{
    uint32_t magic;
    uint16_t version;
    uint16_t length;

    uint32_t sequence;

    DeviceConfig_t config;

    uint32_t crc32;
    uint32_t commit;
} ConfigRecord_t;

各字段含义如下:

字段 作用
magic 判断存储区域是否是合法参数块
version 参数结构版本
length 数据长度
sequence 参数更新序号
config 实际业务参数
crc32 完整性校验
commit 写入完成标志

7.1 magic 魔数

魔数用于判断某段 Flash 数据是不是参数记录。

例如:

复制代码
#define CONFIG_MAGIC 0x43464731UL

上电读取时首先检查:

复制代码
if (record.magic != CONFIG_MAGIC)
{
    // 参数区可能未初始化
}

首次上电时 Flash 通常为:

复制代码
0xFFFFFFFF

自然不会等于合法魔数。


7.2 version 版本号

参数结构未来可能升级。

第一版:

复制代码
typedef struct
{
    uint16_t device_address;
    uint32_t baudrate;
} DeviceConfigV1_t;

第二版增加风扇阈值:

复制代码
typedef struct
{
    uint16_t device_address;
    uint32_t baudrate;
    int16_t fan_on_temperature;
    int16_t fan_off_temperature;
} DeviceConfigV2_t;

如果没有版本号,新固件可能把旧数据按照新结构错误解析。

使用版本号后,可以这样处理:

复制代码
version == 当前版本
    ↓
直接加载

version == 旧版本
    ↓
执行参数迁移

version 不认识
    ↓
恢复默认参数

7.3 length 长度

长度字段用于检查:

  • 参数结构是否完整;

  • 编译后的结构大小是否匹配;

  • 数据是否来自其他版本;

  • 读取时是否发生截断。

例如:

复制代码
if (record.length != sizeof(DeviceConfig_t))
{
    return false;
}

7.4 sequence 序号

每保存一次参数,序号加 1:

复制代码
第1次保存:sequence = 1
第2次保存:sequence = 2
第3次保存:sequence = 3

当存在多份有效参数时,可以选择序号更新的一份。

这对以下功能非常重要:

  • A/B 双备份;

  • 多槽轮转;

  • 磨损均衡;

  • 掉电恢复。


八、为什么需要 CRC 校验

Flash 中读取到数据,不代表数据一定正确。

可能出现:

  • 写入过程中掉电;

  • Flash 存储单元损坏;

  • 地址计算错误;

  • 程序越界写入;

  • 电磁干扰;

  • 旧版本数据被错误解析。

CRC 用于判断参数块是否完整。

保存时:

复制代码
参数数据
   ↓
计算 CRC
   ↓
把参数和 CRC 一起保存

上电时:

复制代码
读取参数
   ↓
重新计算 CRC
   ↓
与保存的 CRC 比较

如果一致:

复制代码
参数可能可信

如果不一致:

复制代码
参数已损坏或不完整

8.1 CRC 计算范围

通常不应把 crc32 字段本身参与计算。

例如:

复制代码
#include <stddef.h>

uint32_t Config_CalculateCrc(const ConfigRecord_t *record)
{
    return Crc32_Calculate(
        (const uint8_t *)record,
        offsetof(ConfigRecord_t, crc32)
    );
}

此时 CRC 覆盖:

复制代码
magic
version
length
sequence
config

不覆盖:

复制代码
crc32
commit

具体计算范围也可以根据项目要求调整,但保存和校验必须保持一致。


8.2 不要把 CRC 当成加密

CRC 能发现数据异常,但它不是安全加密算法。

CRC 不能防止:

  • 恶意修改;

  • 参数伪造;

  • 固件攻击;

  • 未授权写入。

如果存在安全需求,应使用:

  • HMAC;

  • 数字签名;

  • 加密存储;

  • 安全启动;

  • 密钥管理。

普通设备配置保存通常使用 CRC 已经足够。


九、上电时如何加载参数

推荐的检查顺序如下:

复制代码
1. 读取参数记录
2. 检查提交标志
3. 检查 magic
4. 检查版本号
5. 检查长度
6. 检查 CRC
7. 检查业务参数范围
8. 合法则加载
9. 非法则恢复默认参数

示例:

复制代码
#include <stdbool.h>
#include <string.h>

#define CONFIG_COMMITTED 0x00000000UL

static bool Config_IsBusinessValid(const DeviceConfig_t *config)
{
    if (config->device_address == 0U ||
        config->device_address > 247U)
    {
        return false;
    }

    if (config->baudrate != 9600U &&
        config->baudrate != 19200U &&
        config->baudrate != 38400U &&
        config->baudrate != 115200U)
    {
        return false;
    }

    if (config->fan_off_temperature >=
        config->fan_on_temperature)
    {
        return false;
    }

    return true;
}

static bool Config_RecordIsValid(const ConfigRecord_t *record)
{
    uint32_t calculated_crc;

    if (record->commit != CONFIG_COMMITTED)
    {
        return false;
    }

    if (record->magic != CONFIG_MAGIC)
    {
        return false;
    }

    if (record->version != CONFIG_VERSION)
    {
        return false;
    }

    if (record->length != sizeof(DeviceConfig_t))
    {
        return false;
    }

    calculated_crc = Config_CalculateCrc(record);

    if (calculated_crc != record->crc32)
    {
        return false;
    }

    if (!Config_IsBusinessValid(&record->config))
    {
        return false;
    }

    return true;
}

这里除了检查 CRC,还检查了参数是否符合业务范围。

例如,一个 CRC 正确但设备地址为 65535 的参数,仍然不能直接使用。


十、默认参数如何设计

参数区首次使用时,可能没有任何有效配置。

此时需要加载默认参数。

示例:

复制代码
static void Config_LoadDefaults(DeviceConfig_t *config)
{
    memset(config, 0, sizeof(*config));

    config->device_address = 1U;
    config->station_number = 1U;

    config->calibration_k = 1.0f;
    config->calibration_b = 0.0f;

    config->fan_on_temperature = 45;
    config->fan_off_temperature = 40;
    config->alarm_temperature = 70;

    config->baudrate = 115200U;
    config->parity = 0U;
    config->stop_bits = 1U;

    config->work_mode = 0U;
}

上电加载逻辑:

复制代码
bool Config_Load(DeviceConfig_t *config)
{
    ConfigRecord_t record;

    if (!Storage_ReadRecord(&record))
    {
        Config_LoadDefaults(config);
        return false;
    }

    if (!Config_RecordIsValid(&record))
    {
        Config_LoadDefaults(config);
        return false;
    }

    *config = record.config;
    return true;
}

如果参数无效,可以:

复制代码
加载默认参数
   ↓
设备先正常运行
   ↓
把默认参数重新保存

但要注意:

不要在参数保存失败的情况下,每次启动都反复擦写 Flash。

可以设置故障标志,或者限制自动重写次数。


十一、为什么单份参数存储不够安全

假设只有一个参数区:

复制代码
参数区A

保存新参数时:

复制代码
擦除A
   ↓
写入新数据

如果在写入过程中掉电:

复制代码
旧参数已被擦除
新参数尚未写完整

最终可能没有任何可用参数。

因此,单份覆盖写不适合对掉电可靠性要求较高的设备。


十二、A/B 双备份方案

准备两个参数区:

复制代码
参数区A
参数区B

正常情况下,其中一个是当前有效参数,另一个作为备用区。

假设当前使用 A:

复制代码
A:有效,sequence = 10
B:无效或旧版本

需要保存新参数时:

复制代码
1. 不修改A
2. 擦除B
3. 把新参数写入B
4. 读取B并校验CRC
5. 最后写入B的提交标志
6. B成为最新有效参数

成功后:

复制代码
A:有效,sequence = 10
B:有效,sequence = 11

启动时选择:

复制代码
CRC正确
并且 sequence 最大

的一份。


12.1 为什么提交标志要最后写

写入新参数时,不能一开始就把记录标记为有效。

推荐顺序:

复制代码
擦除备用区
   ↓
写入头部、参数和CRC
   ↓
回读检查
   ↓
最后写入commit字段

因为 Flash 擦除后通常为:

复制代码
0xFFFFFFFF

可以把未提交状态设计为:

复制代码
#define CONFIG_NOT_COMMITTED 0xFFFFFFFFUL

完成后把 commit 写为:

复制代码
#define CONFIG_COMMITTED 0x00000000UL

如果在写入过程中掉电,commit 仍然是未提交状态,启动时直接忽略这一份。


12.2 掉电发生在不同阶段会怎样

情况一:擦除 B 时掉电

复制代码
A仍然完整
B无效

下次启动使用 A。

情况二:写 B 数据时掉电

复制代码
A仍然完整
B数据不完整
B未提交

下次启动使用 A。

情况三:B 数据写完,但 CRC 不正确

复制代码
A完整
B校验失败

下次启动使用 A。

情况四:B 数据和 CRC 都写完,提交标志还没写时掉电

复制代码
A完整
B未提交

下次启动仍使用 A。

情况五:B提交成功后掉电

复制代码
A有效,序号较旧
B有效,序号较新

下次启动选择 B。

这就是 A/B 双备份的核心价值:

更新过程中始终尽量保留至少一份有效参数。


十三、双备份加载逻辑

示例:

复制代码
static bool Sequence_IsNewer(uint32_t a, uint32_t b)
{
    return (int32_t)(a - b) > 0;
}

bool Config_SelectValidRecord(
    const ConfigRecord_t *record_a,
    const ConfigRecord_t *record_b,
    ConfigRecord_t *selected)
{
    bool a_valid = Config_RecordIsValid(record_a);
    bool b_valid = Config_RecordIsValid(record_b);

    if (a_valid && b_valid)
    {
        if (Sequence_IsNewer(record_a->sequence,
                             record_b->sequence))
        {
            *selected = *record_a;
        }
        else
        {
            *selected = *record_b;
        }

        return true;
    }

    if (a_valid)
    {
        *selected = *record_a;
        return true;
    }

    if (b_valid)
    {
        *selected = *record_b;
        return true;
    }

    return false;
}

这里使用差值判断序号新旧,是为了处理 uint32_t 序号回绕。


十四、推荐的保存流程

参数运行时保存在 RAM 中:

复制代码
static DeviceConfig_t g_device_config;
static bool g_config_dirty;

用户修改参数时,只修改 RAM:

复制代码
void Config_SetDeviceAddress(uint16_t address)
{
    if (address == 0U || address > 247U)
    {
        return;
    }

    if (g_device_config.device_address != address)
    {
        g_device_config.device_address = address;
        g_config_dirty = true;
    }
}

不要每次修改都立刻写 Flash。

可以使用延迟保存:

复制代码
参数发生变化
   ↓
设置 dirty 标志
   ↓
等待一段时间
   ↓
期间如果继续变化,重新计时
   ↓
参数稳定后统一保存一次

例如:

复制代码
#define CONFIG_SAVE_DELAY_MS 3000U

static uint32_t g_config_last_change_tick;

void Config_MarkDirty(uint32_t current_tick)
{
    g_config_dirty = true;
    g_config_last_change_tick = current_tick;
}

void Config_Process(uint32_t current_tick)
{
    if (!g_config_dirty)
    {
        return;
    }

    if ((current_tick - g_config_last_change_tick) <
        CONFIG_SAVE_DELAY_MS)
    {
        return;
    }

    if (Config_Save(&g_device_config))
    {
        g_config_dirty = false;
    }
}

这样可以把短时间内多次修改合并为一次写入。

例如用户连续设置:

复制代码
风扇阈值:40
风扇阈值:42
风扇阈值:45

最终只保存一次 45,而不是连续写 Flash 三次。


十五、通用保存代码框架

底层存储操作可以抽象为:

复制代码
bool Storage_Read(uint32_t address,
                  void *buffer,
                  uint32_t length);

bool Storage_Erase(uint32_t address,
                   uint32_t length);

bool Storage_Write(uint32_t address,
                   const void *buffer,
                   uint32_t length);

上层参数管理不直接依赖某个芯片的 HAL。

示例:

复制代码
#define CONFIG_SLOT_A_ADDRESS  0x0801F000UL
#define CONFIG_SLOT_B_ADDRESS  0x0801F800UL

static bool Config_ReadSlot(uint32_t address,
                            ConfigRecord_t *record)
{
    return Storage_Read(address,
                        record,
                        sizeof(*record));
}

保存记录:

复制代码
static bool Config_WriteSlot(
    uint32_t address,
    const ConfigRecord_t *source)
{
    ConfigRecord_t verify_record;
    ConfigRecord_t write_record;
    uint32_t commit = CONFIG_COMMITTED;

    write_record = *source;
    write_record.commit = CONFIG_NOT_COMMITTED;
    write_record.crc32 =
        Config_CalculateCrc(&write_record);

    if (!Storage_Erase(address, sizeof(write_record)))
    {
        return false;
    }

    /*
     * 先写commit之前的数据。
     */
    if (!Storage_Write(
            address,
            &write_record,
            offsetof(ConfigRecord_t, commit)))
    {
        return false;
    }

    /*
     * 回读检查。
     */
    if (!Storage_Read(address,
                      &verify_record,
                      sizeof(verify_record)))
    {
        return false;
    }

    if (verify_record.magic != write_record.magic ||
        verify_record.version != write_record.version ||
        verify_record.length != write_record.length ||
        verify_record.sequence != write_record.sequence ||
        verify_record.crc32 != write_record.crc32)
    {
        return false;
    }

    if (Config_CalculateCrc(&verify_record) !=
        verify_record.crc32)
    {
        return false;
    }

    /*
     * 所有数据确认正确后,最后写提交标志。
     */
    if (!Storage_Write(
            address + offsetof(ConfigRecord_t, commit),
            &commit,
            sizeof(commit)))
    {
        return false;
    }

    return true;
}

完整保存:

复制代码
bool Config_Save(const DeviceConfig_t *config)
{
    ConfigRecord_t record_a;
    ConfigRecord_t record_b;
    ConfigRecord_t new_record;

    bool a_valid;
    bool b_valid;

    uint32_t target_address;
    uint32_t latest_sequence = 0U;

    Config_ReadSlot(CONFIG_SLOT_A_ADDRESS, &record_a);
    Config_ReadSlot(CONFIG_SLOT_B_ADDRESS, &record_b);

    a_valid = Config_RecordIsValid(&record_a);
    b_valid = Config_RecordIsValid(&record_b);

    if (a_valid && b_valid)
    {
        if (Sequence_IsNewer(record_a.sequence,
                             record_b.sequence))
        {
            latest_sequence = record_a.sequence;
            target_address = CONFIG_SLOT_B_ADDRESS;
        }
        else
        {
            latest_sequence = record_b.sequence;
            target_address = CONFIG_SLOT_A_ADDRESS;
        }
    }
    else if (a_valid)
    {
        latest_sequence = record_a.sequence;
        target_address = CONFIG_SLOT_B_ADDRESS;
    }
    else if (b_valid)
    {
        latest_sequence = record_b.sequence;
        target_address = CONFIG_SLOT_A_ADDRESS;
    }
    else
    {
        latest_sequence = 0U;
        target_address = CONFIG_SLOT_A_ADDRESS;
    }

    memset(&new_record, 0xFF, sizeof(new_record));

    new_record.magic = CONFIG_MAGIC;
    new_record.version = CONFIG_VERSION;
    new_record.length = sizeof(DeviceConfig_t);
    new_record.sequence = latest_sequence + 1U;
    new_record.config = *config;
    new_record.commit = CONFIG_NOT_COMMITTED;

    return Config_WriteSlot(target_address,
                            &new_record);
}

这部分是通用逻辑,实际项目还需要适配:

  • Flash 写入对齐;

  • 最小编程宽度;

  • 页和扇区边界;

  • 中断保护;

  • Cache 清理;

  • ECC;

  • 双 Bank;

  • 看门狗喂狗;

  • RTOS 互斥。


十六、外部 EEPROM 还要注意什么

以常见 I2C EEPROM 为例,通常需要注意:

16.1 页写入限制

很多 EEPROM 支持页写入,例如一页可能是:

复制代码
8字节
16字节
32字节
64字节

如果一次写入跨越页边界,部分器件可能回绕覆盖同一页前面的数据。

因此写入函数需要拆分:

复制代码
当前地址到页末剩余长度
   ↓
写一段
   ↓
等待写周期完成
   ↓
继续写下一页

16.2 写周期

EEPROM 写入后通常需要内部编程时间,可能是几毫秒。

不能发送写命令后立即假设数据已经完成保存。

常见方法是:

  • 固定延时;

  • ACK Polling;

  • 查询器件忙状态。

推荐使用 ACK Polling,而不是盲目延时过长。


16.3 擦写寿命仍然有限

EEPROM 的写入寿命通常比普通 Flash 高,但并不是无限。

高频数据例如:

复制代码
实时计数
每秒运行时间
每次采样值
电机瞬时转速

仍不适合每次变化都直接写 EEPROM。

可以采用:

  • RAM 累计;

  • 定时批量保存;

  • 关机前保存;

  • 多槽轮转;

  • FRAM;

  • 外部日志存储。


十七、掉电检测是否能解决所有问题

有些设备会通过电压检测或掉电中断,在电源下降时保存参数:

复制代码
检测到掉电
   ↓
立即保存参数
   ↓
等待电源关闭

这种方案可以使用,但不能完全依赖。

因为掉电时可能存在:

  • 剩余时间不足;

  • 电源下降过快;

  • Flash 擦除时间太长;

  • MCU 已进入欠压状态;

  • 外设通信无法完成;

  • 系统已经失控;

  • 掉电中断没有响应。

如果需要掉电保存,应确保:

复制代码
储能电容提供足够时间
电压检测阈值合理
Flash写入时间可控
参数区不需要临时擦除
掉电保存代码放在RAM执行
中断优先级足够高

更稳妥的方式仍然是:

正常运行过程中提前保存,掉电时只处理极少量必要数据。

不要把全部可靠性都压在掉电中断上。


十八、磨损均衡是什么

A/B 双备份只能在两个存储区域之间切换,寿命提升有限。

如果参数更新较频繁,可以准备多个槽位:

复制代码
Slot 0
Slot 1
Slot 2
Slot 3
Slot 4
...

每次保存写入下一个槽:

复制代码
Slot0 sequence=100
Slot1 sequence=101
Slot2 sequence=102
Slot3 sequence=103

启动时扫描所有槽位,找到:

复制代码
commit有效
magic正确
版本合法
CRC正确
sequence最新

的一份。

这种方案叫日志式保存或磨损均衡。


18.1 多槽轮转流程

复制代码
1. 扫描所有槽位
2. 找到最新有效记录
3. 计算下一个空槽
4. 写入新记录
5. 最后写提交标志
6. 所有槽写满后,擦除旧页
7. 继续循环使用

这样可以把擦写次数分散到多个存储位置。

假设一个页能保存 32 条参数记录:

复制代码
每次修改只追加一条
32条写满后才擦除一次整页

相比每次修改都擦除一次,寿命可以大幅提升。


十九、哪些参数不应该频繁保存

下面这些数据通常不应每次变化就写 Flash:

复制代码
实时温度
实时电流
实时转速
每次采样结果
通信收发计数
每秒运行时间
循环计数器
频繁变化的状态机状态

可以采用:

  • RAM 中维护;

  • 定期批量保存;

  • 达到一定变化量再保存;

  • 每隔数分钟保存;

  • 正常关机时保存;

  • 外部 FRAM;

  • 日志文件系统。

例如累计运行时间:

复制代码
RAM中每秒增加
每10分钟保存一次
正常关机时再保存一次

如果突然断电,最多损失 10 分钟累计值,而不是频繁磨损 Flash。


二十、常见错误设计

1. 每次主循环都写 Flash

复制代码
while (1)
{
    Flash_SaveConfig();
}

结果:

  • Flash 很快达到擦写寿命;

  • 程序实时性下降;

  • 参数损坏风险增加。


2. 参数变化一次就立即保存

例如旋钮连续调整时:

复制代码
41
42
43
44
45

如果每次都写,一次操作可能擦写数十次。

应该使用防抖或延迟保存。


3. 只有数据,没有 CRC

能够读出数据,不代表数据正确。

至少应检查:

复制代码
magic
version
length
CRC
业务范围

4. 只有一份参数

擦除或写入过程中掉电,可能导致唯一参数丢失。

应使用:

复制代码
A/B双备份
或多槽轮转

5. 新固件直接读取旧结构体

参数结构升级后,如果没有版本号,会出现字段错位。


6. 直接保存带指针的结构体

错误示例:

复制代码
typedef struct
{
    uint8_t *buffer;
    char *name;
} Config_t;

指针指向的是 RAM 地址。

设备重启后,保存下来的指针值没有实际意义。

持久化结构体中不要直接保存:

  • 指针;

  • 函数指针;

  • RTOS句柄;

  • 动态内存地址;

  • 文件句柄;

  • 外设句柄。


7. 忽略浮点格式和字节序

如果参数需要跨平台读取,例如 PC 上位机解析,应该明确:

  • 小端或大端;

  • 浮点格式;

  • 对齐方式;

  • 字段长度;

  • CRC算法;

  • 版本结构。


8. 参数区地址写死但未检查固件大小

固件升级后,程序代码可能增长并覆盖参数区。

必须在链接脚本中预留参数存储区域,而不是仅靠一个手写地址。


二十一、推荐的软件模块划分

建议把参数管理分成三层。

1. 底层存储驱动层

负责:

复制代码
Flash/EEPROM初始化
擦除
写入
读取
忙状态检查
地址对齐
硬件异常处理

接口示例:

复制代码
bool Storage_Read(uint32_t address,
                  void *data,
                  uint32_t size);

bool Storage_Write(uint32_t address,
                   const void *data,
                   uint32_t size);

bool Storage_Erase(uint32_t address,
                   uint32_t size);

2. 参数存储管理层

负责:

复制代码
magic
版本号
长度
sequence
CRC
A/B双备份
提交标志
磨损均衡
默认参数恢复

接口示例:

复制代码
bool Config_Load(DeviceConfig_t *config);
bool Config_Save(const DeviceConfig_t *config);
void Config_RestoreDefaults(DeviceConfig_t *config);

3. 业务参数层

负责:

复制代码
设备地址合法范围
温度阈值关系
波特率选择
校准系数范围
用户设置
参数修改权限

例如:

复制代码
bool Config_SetFanTemperature(int16_t on_temp,
                              int16_t off_temp);

业务层不应该直接操作 Flash 地址。


二十二、推荐的完整启动流程

复制代码
系统上电
   ↓
初始化底层硬件
   ↓
读取参数区A和B
   ↓
检查commit
   ↓
检查magic
   ↓
检查version和length
   ↓
检查CRC
   ↓
检查业务范围
   ↓
选择sequence最新的有效记录
   ↓
复制到RAM
   ↓
没有有效记录?
   ├─ 否:使用保存参数
   └─ 是:加载默认参数并安排重新保存

二十三、推荐的参数更新流程

复制代码
用户或业务修改参数
   ↓
检查新值是否合法
   ↓
只修改RAM参数
   ↓
设置dirty标志
   ↓
等待参数稳定
   ↓
生成新记录
   ↓
写入备用区
   ↓
回读并校验CRC
   ↓
最后写commit标志
   ↓
保存完成

二十四、工程方案总结

一套可靠的单片机参数保存方案,建议至少包含以下内容:

复制代码
1. 参数集中放入结构体。

2. 使用固定宽度数据类型。

3. 保存magic、version和length。

4. 使用CRC检查数据完整性。

5. 保存sequence序号判断新旧。

6. 使用A/B双备份防止写入中断。

7. 提交标志必须最后写入。

8. 上电时同时检查格式、CRC和业务范围。

9. 参数非法时恢复默认值。

10. 参数先在RAM修改,不要直接操作Flash。

11. 多次修改应合并、延迟和限频保存。

12. 高频参数使用EEPROM、FRAM或磨损均衡。

13. 参数区应在链接脚本中明确预留。

14. 通过主动断电测试验证可靠性。

最终可以把可靠参数保存概括为:

能写、能读、能校验、能恢复、能升级、能抗掉电,还要控制存储磨损。


二十五、测试建议

参数保存功能完成后,至少进行以下测试。

正常功能测试

  • 修改设备地址后重启,参数是否保留;

  • 修改校准值后重启,参数是否保留;

  • 修改波特率后重新连接,配置是否生效;

  • 恢复默认值后,所有参数是否正确。

异常数据测试

  • 手动破坏 CRC;

  • 手动破坏 magic;

  • 修改版本号;

  • 修改长度字段;

  • 写入非法设备地址;

  • 写入异常温度阈值。

预期结果:

复制代码
系统不加载非法参数
恢复默认配置
输出错误日志

掉电测试

分别在以下阶段切断电源:

复制代码
擦除备用区时
写入参数头时
写入业务参数时
写入CRC时
写入commit前
写入commit后

每次重新上电后检查:

  • 是否至少存在一份有效参数;

  • 是否能正确选择新旧副本;

  • 是否错误恢复默认值;

  • 是否出现启动失败;

  • 是否发生无限重启。

寿命和频率测试

  • 连续修改参数;

  • 检查实际 Flash 写入次数;

  • 验证延迟保存是否生效;

  • 验证磨损均衡槽位是否轮转;

  • 验证序号回绕处理。


结语

单片机参数掉电保存,看起来只是把一段数据写入 Flash 或 EEPROM。

但真正的工程难点是:

复制代码
写入途中突然掉电怎么办?
数据读出来后怎么判断是否可信?
固件升级后旧参数怎么兼容?
Flash寿命耗尽怎么办?
参数全部损坏后设备能否启动?

只实现一个 Flash_Write() 函数,并不能称为完整的参数保存系统。

可靠的参数存储方案应该具备:

复制代码
结构化管理
CRC校验
版本控制
默认恢复
A/B双备份
提交机制
写入限频
磨损均衡
掉电测试

对于设备地址、校准参数、风扇阈值和通信配置这类关键数据,推荐采用:

复制代码
RAM运行副本
+
参数结构体
+
magic/version/length
+
CRC
+
sequence
+
A/B双备份
+
延迟保存

这套结构适合大多数工业控制、传感器采集、通信设备和嵌入式控制器项目。


复制代码
STM32
嵌入式开发
单片机
Flash
EEPROM
CRC
参数保存
掉电保护
磨损均衡
C语言
相关推荐
麦聪聊数据3 小时前
企业数据市场建设(三):API 化服务封装,让数据开箱即用、避免重复开发
数据库
麦聪聊数据3 小时前
企业数据市场建设(四):流程闭环与价值运营,让数据市场真正转起来
运维·数据库
正儿八经的少年4 小时前
redis 的大 key 和热 key 详解
数据库·redis·缓存
AI砖家4 小时前
多智能体系统实战:架构设计、数据库表设计与 Skill 体系
数据库·多智能体·skill·agent架构设计·agengt
暗暗别做白日梦4 小时前
Pulsar 消息同步机制
c#·linq
十月的皮皮4 小时前
STM32从零到量产开发:四路继电器工业控制模块开发原理小灶 · 合集
stm32·单片机·嵌入式硬件·stm32cubemx·hal库
海盗12344 小时前
微软技术周报——2026-07-22
microsoft·c#·.net
海盗12344 小时前
微软技术日报 ——2026-07-21
microsoft·c#·.net
夜雪一千4 小时前
MySQL查询条件的顺序是否影响查询效率
数据库·mysql
-银雾鸢尾-4 小时前
C#中的泛型约束
开发语言·c#