一、为什么一块板子会长出三种存储
这块主控板最早只有一层存储:片内 Flash 的最后一个扇区拿来放参数,其余放代码。 当时数据只有设备编号、量程、几组整定值,几年也改不了几次,这么做完全够用。 后来需求一点点加上来:要记每天加了多少、每班加了多少、每一笔明细的金额与时间, 要存设置变更日志和异常日志,最后还要留一块地方接收新的固件镜像做现场升级。
问题就出现了。这三类数据的脾气完全不一样:
- 参数:几天、几个月才写一次,但每个控制周期都要读,读的次数是天文数字;
- 累计值与明细:每完成一次加注就要加一笔,运行期间一直在写,而且每一笔都不能丢;
- 日志与固件镜像:单块体积大,写的次数不多,但要求能按顺序追加、断电后还能认出“写到哪了”。
把它们塞进同一个扇区,冲突是必然的:片内 Flash 必须整页擦除才能写入, 为了改一笔明细去擦掉一整页,等于把同页里的参数一起搭进去;而 Flash 的擦除寿命本来就有限, 按明细的写入频率去擦同一个页,寿命会先耗尽。
所以我后来不再问“哪个便宜用哪个”,而是先问四个问题: 这块数据多久写一次、一次写多少字节、掉电丢一笔的后果是什么、写坏一次的代价是什么。 四个问题的答案不同,落到的介质自然就不同——这就是三层分工的由来。
二、第一层:片上 Flash 存“几乎不变”的参数
片内 Flash 存的是一整套设备配置:设备身份与编号、量程与单位、标定值、各种整定值与保护值、 密码等级、以及“恢复出厂”的默认值表。它们的共同特点是写极少、读极多。
正因为“读极多”,我做了两件事:
- 上电时把整个参数区读进 RAM 一份副本,运行期所有控制逻辑只读 RAM 副本, 不直接碰存储。这样做的收益不是省几次读,而是让控制周期里完全没有等待存储的抖动。
- 只有设置项被真正改写时才写回,而且写完立刻更新 RAM 副本, 保证“内存里的值”和“介质里的值”永远同步。这一点看似啰嗦,但它消灭了“参数改了没生效”这类问题。
还有一个容易忽略的动作:参数区必须单独占一个完整的擦除单元, 不能和代码挤在一起。否则改一个参数就要动代码所在的页,风险和代价都不成比例。 这也是我在别的工程里见过的教训——参数区和代码区在同一个扇区里, 一次参数保存失败,设备连启动都启动不起来。
参数本身我建议用“表”来描述,而不是散落在代码里的赋值语句。每一项至少记四件事: 最小值、最大值、出厂值、类型标志。 有了这张表,范围校验、显示格式化(小数点几位)、权限分级(哪一档密码才能改)、 恢复出厂,全都可以由同一份数据驱动,不需要为每个参数各写一段代码。 关于四元组参数表和类型标志位域的展开,可以看 《一张参数表驱动的设备配置体系》; 参数区的可靠性加固(双备份、序号、头校验、版本迁移)我在 《Flash 参数区的双备份与原子切换》里单独写过。
片内 Flash 的参数区读写,代码形状其实很短,核心就是“先擦后写、注意边界”:
/* 按个人理解重写的最小骨架:只保留“先擦后写、按页对齐”的形状。
PARAM_AREA_BYTES / PARAM_PAGE_ADDR 是分区规划时定下的符号,
具体取值属于产品参数,本文不展开。 */
static int param_save(const void *buf, unsigned len)
{
if (len > PARAM_AREA_BYTES) {
return -1; /* 边界检查:一个擦除单元放不下就拒绝 */
}
if (flash_erase_page(PARAM_PAGE_ADDR) != 0) {
return -2; /* 擦除失败就别往下写,避免写出半页 */
}
return flash_write(PARAM_PAGE_ADDR, buf, len);
}这段骨架里唯一值得说的是顺序:擦除和写入不能颠倒,也不能省略擦除去“覆盖写”。 Flash 的写入只能把 1 变成 0,不能把 0 变回 1,所以对已经写过的位置直接再写一次, 得到的不是新值,而是新旧值的按位与——一个既不像旧值也不像新值的数。
三、第二层:EEPROM 存索引与累计值
第二层是挂在 I2C 上的外置 EEPROM。选它不是因为容量,而是因为两点: 它可以按字节写,不需要为了改几个字节擦掉一整页; 它的擦写寿命通常比片内 Flash 高一个数量级以上(具体看各自的数据手册)。 这两点正好对上“每完成一次动作就要记一笔”的写入频率。
这一层放的是业务数据:日累计、班累计、总累计(金额、数量、笔数、时间戳), 以及明细记录与各类日志的索引。累计值的特点是“只能加不能减、掉了就对不上账”, 所以它必须掉电不丢,而且必须“要么完整写进去、要么完全没写”。
用 EEPROM 也有代价,而且我在这上面吃过一次明显的亏:
- 慢:I2C 的速率和片内总线不是一个量级,写完一笔还要等器件内部写周期结束, 单笔耗时是毫秒级甚至更长。
- 页写会回绕:多数 I2C EEPROM 的页写缓冲只有几十字节, 一次连续写入超过页边界时,地址会绕回页首覆盖本页开头的数据, 而不是顺延到下一页。我最初就是按“连续写一整块结构体”来写的, 结果结构体跨页的那部分把页首字段冲掉了。
- 时序敏感:位翻转(软件模拟)I2C 时,起始信号之后的毛刺、 以及每次电平变化之间的延时,都要用一个统一的延时宏来保证, 不要在每处各写一个循环。
四、第三层:外挂 Flash 存大块数据与固件镜像
第三层是挂在 SPI 上的外置 Flash。它承担两类“大块”内容: 需要按顺序长期追加的明细与日志,以及 现场升级用的新固件镜像。
放这里的原因很直接:容量比 EEPROM 大得多、单位容量成本低得多; 而它对“擦写寿命”的要求本来就低,因为日志是顺序追加的,写满一圈才回头覆盖最旧的一条, 每个位置的写入次数远低于累计值那种“反复改同一处”的场景。
用这一类芯片要记住三个粒度:
| 粒度 | 含义 | 对代码的影响 |
|---|---|---|
| 写入粒度(页) | 多数 SPI NOR Flash 的页写粒度是 256 字节(以具体数据手册为准) | 一次写入不能跨页,跨页要自己拆成两次 |
| 擦除粒度(扇区/块) | 擦除的最小单位通常是几 KB 的扇区 | 想改一个字节,实际要搬走并重写整个扇区 |
| 位操作方向 | 擦除后全为 1,写入只能把 1 变 0 | 任何“改小一点”的写入都必须先擦 |
固件镜像区我的做法是:镜像区与数据区在地址空间上完全分开, 并且在镜像区头部留一小块元信息(版本、长度、下载进度), 镜像本体单独从后面开始排。这样分的好处是元信息可以频繁小改, 而镜像本体只在整包下载时写一次,两者不互相干扰。
写入顺序也很关键,它直接决定“下载到一半掉电”之后设备还能不能起来:
- 先把镜像本体按页写入镜像区,此时元信息里的“有效标志”还没有置起来;
- 本体写完并回读校验通过后,再写元信息(版本、长度、校验值);
- 最后才置“有可用新固件”的升级标志,交给 Bootloader 去搬。
任何一步掉电,元信息都保持“不完整”的状态,Bootloader 就能判断出这一包不能用, 继续跑旧版本,而不是跳进一个半截的镜像里。 跳转与校验的细节可以看《Bootloader 与 OTA 跳转》, 发布版本要带哪些开关、升级标志怎么和出厂状态区分,我在 《从能跑到能出厂》里整理过。
五、三层之间怎么划界:一张分工表
把三层放在一起对比,分界线就清楚了。下表是我现在实际使用的判断表, 左边四行是“数据属性”,右边一行是“结论”:
| 判断维度 | 片内 Flash(第一层) | I2C EEPROM(第二层) | 外挂 SPI Flash(第三层) |
|---|---|---|---|
| 写入频率 | 极低(出厂、现场设置、恢复出厂) | 高(每完成一次动作加一笔) | 中(顺序追加,写满一圈才回头) |
| 单次数据量 | 小,几个字节到几十字节 | 小到中,一条记录几十字节 | 大,一条明细到一整包固件镜像 |
| 读取频率 | 极高(每个控制周期都读) | 低(上电统计、查询时读) | 低(查询、升级时读) |
| 掉电敏感度 | 低(写的时候本来就少) | 极高(丢一笔就对不上账) | 高(镜像不完整就不能升级) |
| 寿命成本 | 擦除单元大、寿命有限,必须省着用 | 擦写寿命高一级,适合反复写 | 容量大、单位成本低,适合顺序写 |
| 典型内容 | 设备身份、量程、标定值、整定值、保护值、默认值表 | 累计值、明细索引、日志索引、支付状态 | 历史明细、运行日志、固件镜像 |
落到具体一块数据上,我的判断顺序是固定的,四步走完就有结论:
- 先看写频率。一年写不到几次的,一律进第一层,因为它读得多、写得少, 片内 Flash 是最省事也最快的选择。
- 再看单笔大小与是否需要“就地改”。需要频繁就地改几个字节的,进第二层, 因为片内 Flash 改几个字节的代价是擦一整页。
- 再看掉电丢一笔的后果。丢了要对不上账、要扯皮的,必须有索引与序号, 并且要有“重新对上”的补救路径。
- 最后看总量。要存几个月的明细、或者要放一整包固件镜像的,进第三层。
四步里最重要的是第一步。我见过(也自己写过)把累计值直接放在片内 Flash 参数区的做法, 刚出厂时一切正常,等设备真正跑起量来,参数所在的那个扇区被反复擦写, 先坏的不是数据而是整个参数区——因为累计值和参数本来就住在同一页里。
六、索引设计:为什么累计值需要一张指针表
明细记录是“不定长、按顺序追加”的。这带来两个必须回答的问题: 下一条该写在哪里,以及哪一条才是最新的。 如果每次都从头扫描整片区域去找,代价会随着记录变多而线性上升, 而且在掉电后扫描结果还可能不可信。
我的做法是把“记录本体”和“索引”彻底分开: 本体放在数据区,索引放在另一块固定长度的区域,一条索引对应一条数据。 索引条目只描述“这条记录在哪里、是第几条、状态如何”,字段大致如下:
| 字段 | 作用 | 说明 |
|---|---|---|
| 序号 | 标识新旧的唯一依据 | 只递增、不回绕归零;“序号最大的有效条目”就是最新一条 |
| 数据位置 | 指向数据区里的那条记录 | 让“追加”退化成“读一条索引”,不需要扫描数据区 |
| 状态 | 空 / 有效 / 作废 | 写入过程分两步,状态用于标记“这条还没写完整” |
| 校验值 | 判断本条索引是否可信 | 索引区被写坏时,宁可丢一笔,也不要把乱值当记录 |
| 长度 | 本条记录占多少字节 | 变长记录必须显式记长度,不能靠推算 |
找最新一条的流程可以写成这样一个骨架(所有长度、条数、地址都用符号表示):
/* 伪代码骨架:具体条数、块长度、区域地址都是分区规划里的符号,本文不给取值 */
最新序号 = 无效;
写入位置 = 索引区首条;
for (每条索引 in 索引区) {
if (索引.状态 == 有效 && 索引.序号 > 最新序号) {
最新序号 = 索引.序号;
写入位置 = 索引.数据位置; /* 只记位置,不搬数据 */
}
}
下一条位置 = 定位到(写入位置 之后的下一块);
if (下一条位置 越界) {
下一条位置 = 索引区首条; /* 写满一圈回到开头,序号继续递增 */
}
if (校验不通过(下一条位置 的索引)) {
视为空位,直接覆盖; /* 宁可丢一笔,不可乱一笔 */
}这套设计里有三个点,是我改了几版之后才定下来的:
- 环形覆写时序号不归零,继续递增。如果写满一圈就把序号清回起点, 那么“覆盖最旧一条”和“这是全新的一条”就无法区分了, 分不清新旧的索引表还不如没有。
- 先写数据、再写索引。反过来的话,索引会指向一块还没写好的数据; 而按这个顺序,最坏情况只是有一条数据没人认领(浪费一点空间),不会读出脏数据。
- 索引里带校验。索引本身也可能被写坏, 没有校验的话,一个位翻转就会让“数据位置”指到别处, 读出来的是另一条记录甚至一段参数。
七、三种成熟度:从“整块擦写无校验”到“分区 + 索引”
整理自己写过的几份工程之后我发现,存储这块的写法大致能分成三代。 它们并不是“对与错”,而是可靠性和工作量之间的三个档位; 但如果一直停在第一代还接了真实业务,问题迟早会来。
| 代次 | 载体与写入方式 | 校验 | 版本迁移 | 典型表现 |
|---|---|---|---|---|
| 第一代 | 片内 Flash 单独一页,整个结构体一起擦、一起写 | 无 | 无 | 实现最快,但字段一改、掉一次电,读回来的值就可能是错位的 |
| 第二代 | 片内 Flash 分区,空片判断(全 1 视为未初始化)+ 首次上电写默认值 | 局部校验(至少有个头校验) | 有版本号,不认识就回默认 | 新板子第一次上电能自己初始化,参数区被擦空也能开机 |
| 第三代 | 三层介质分工,按区/按块划分,索引与数据分离 | 每条记录带校验,索引单独校验 | 版本号 + 长度,逐版本迁移 | 业务数据可长期追加,参数与业务互不牵连 |
之所以说“停在第一代迟早出问题”,是因为 “结构体整块擦写 + 无 CRC + 无版本迁移”这个默认做法有三个明确的风险:
- 无校验:错的值不会报错,只会静静地错。 无论是写到一半掉电,还是一个位翻转,读回来的都是“一个值”, 而不是“读失败”。差一位的整定值不会让设备停机,只会让它的行为变得奇怪—— 这种问题在现场最难查,因为所有代码看起来都是对的。
- 无版本迁移:改了结构体,旧数据就被按新布局解释。 我见过同一块参数区被 Bootloader 和应用程序各自定义了一份结构体, 两份定义后来不一致(一边多出两个字节),于是同一个位置读出来的字段含义就错位了。 结构体里必须放版本号和长度,读到不认识的版本就走迁移或者直接回默认值。
- 无备份:擦除之后、写入之前掉电,参数区就是一片空白。 擦除和写入之间总会有一小段时间,只要掉电点落在这一小段里, 设备下次上电就没有参数可用。这就是为什么参数区至少要留两份, 并且用序号或标志位决定“哪一份是新的、完整的那一份”。
这三条加固——双备份、序号、头校验——加上版本号与默认值回退, 合起来就是一套很小的工作量,却能把第一代直接抬到能接业务的程度。 具体做法我写在《Flash 参数区的双备份与原子切换》里。
八、几条我反复用到的经验
- 先按写入频率分层,再按容量微调。写频率决定了寿命成本,容量只是次要因素。
- 参数和业务数据不要住同一页。它们的写入频率差着好几个数量级,放一起就是互相伤害。
- 参数上电读一份到 RAM,运行期只用副本。控制周期里不该出现等待存储的抖动。
- 索引只描述“记录在哪里”,不要借位表达别的含义。字段语义一旦复用,取值范围就失控了。
- 环形覆写时序号继续递增,不要归零。分不清新旧的索引表比没有索引更糟。
- 先写数据、再写索引、最后更新汇总值。这个顺序的最坏情况是浪费空间,而不是读出脏数据。
- 任何进入参数区的结构体都要带版本号和长度,哪怕现在只有一版。
- 写盘不要放在控制流程里。控制流程只入队,落盘交给后台任务,掉电时由队列状态决定怎么补。
- 掉电时的写入预算是算出来的,不是猜出来的。它取决于电源上还能撑多久, 这部分我在《计量脉冲的判向、倍频与消抖》的姊妹篇 《掉电检测与重启抑制》里展开。
- 时间戳的单位要写死在类型里。累计值一旦带上时间,秒与毫秒混用就会变成账目对不上, 这类单位坑我单独整理在《定时器与单位:五个反复出现的坑》。
参考资料与说明
- MCU 参考手册中的 Flash 控制器章节:擦除/编程的最小单位、编程顺序、寿命与等待周期说明,属于公开文档。
- I2C 串行 EEPROM 数据手册:页写缓冲大小、页写回绕行为、写周期时间与擦写寿命参数,属于公开文档。
- SPI NOR Flash 数据手册:页编程与扇区/块擦除的粒度、状态寄存器与忙判断方式,属于公开文档。
- 关于“用片内 Flash 模拟 EEPROM”的通用应用笔记,以及磨损均衡与掉电原子性的通用做法,属于公开技术资料。
- 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码, 示例代码为按个人理解重写的通用片段。文中的分区偏移、索引条目数、容量与地址等产品特定参数已全部省略。
- 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。