单片机参数如何掉电保存: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
因为不同编译器和平台中,int、long 的长度可能不同。
固定宽度类型更有利于:
-
数据结构长期兼容;
-
固件升级;
-
跨平台解析;
-
上位机读取;
-
参数文件导出。
6.2 注意结构体填充
编译器可能在结构体字段之间插入填充字节。
例如:
typedef struct
{
uint8_t flag;
uint32_t value;
} Example_t;
实际大小可能不是 5 字节,而是 8 字节。
可以通过以下方式处理:
-
合理调整字段顺序;
-
显式保留填充字段;
-
使用序列化和反序列化;
-
必要时使用编译器打包属性;
-
保存结构体长度并进行检查。
例如:
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语言