1. 配方存储的底层逻辑:为什么掉电不丢是个真问题
搞嵌入式存储的人,尤其是做工业设备、厨电、美容仪器这类带“配方”概念的产品的朋友,大概率都遇到过这个灵魂拷问:配方数据到底该怎么存,才能保证设备突然断电时不丢、不乱、不损坏?我这些年经手的项目里,从咖啡机、按摩仪到工业配比设备,配方存储翻车的案例一抓一大把。最常见的翻车现场就是:用户调好一组参数,机器跑得好好的,啪一下停电了,再来电时配方要么变成默认值,要么直接读出来一堆乱码,甚至整个文件系统都挂掉。
这个问题的核心,其实就落在两个词上:覆写和追写。这两个词听起来像是语文题,但在嵌入式存储领域,它们代表的是两种截然不同的数据写入策略。覆写,顾名思义,就是每次保存配方时,直接找到原来存配方的那块地址,把新数据盖上去。追写,则是每次保存都往存储介质的后面追加一条新记录,旧记录不删,靠某种索引机制来标记哪条是最新的。
为什么这两种策略会导致完全不同的掉电表现?这得从SD卡的物理特性说起。SD卡内部是NAND Flash,NAND Flash的写入单位是页(Page),擦除单位是块(Block),一个块通常包含几十到几百个页。关键问题在于:NAND Flash不能像RAM那样直接覆盖写。当你对一个已经写过数据的页执行“覆写”操作时,主控实际上需要先把整个块读到缓存里,擦除这个块,再把修改后的数据写回去。这个过程如果被断电打断,整个块的数据都可能处于不确定状态。
而追写策略就聪明得多。它不碰旧数据,每次都是往新的空白页里写。即使写到一半断电,最多就是最后这条新记录不完整,旧记录依然完好无损。这就是为什么很多高可靠性系统都采用追写加日志的方式。但追写也不是没有代价,它需要额外的空间来存索引,还需要定期做垃圾回收,否则SD卡很快就会被写满。
所以,当你问“覆写vs追写,配方存SD卡哪种掉电不丢”时,答案并不是简单的二选一。你需要理解你的配方数据有多大、保存频率有多高、SD卡的文件系统是什么、有没有额外的备份机制。接下来我会把这几个维度拆开,结合我实际踩过的坑,给你一套可以直接抄作业的方案。
2. 覆写策略的掉电风险到底在哪
2.1 覆写的三种常见实现方式
很多人以为“覆写”就是简单地把文件打开,写入新内容,关闭文件。但在嵌入式环境里,这个操作背后可能对应着完全不同的底层行为。我见过的大多数配方存储代码,覆写实现无非这三种:
第一种是文件级覆写。用FATFS或者LittleFS这类文件系统,每次保存时用f_open以写模式打开文件,写入新数据,然后f_close。这种方式最直观,但掉电风险也最高。因为文件系统在写入时可能会更新FAT表、目录项、簇链等多个元数据区域,任何一个环节断电都可能导致文件系统损坏。
第二种是扇区级覆写。绕过文件系统,直接计算好配方数据在SD卡上的物理扇区地址,用disk_write直接写扇区。这种方式速度快,但需要你自己管理扇区分配,而且一旦配方大小变化,扇区地址就得重新计算。更麻烦的是,SD卡内部有磨损均衡和坏块管理,你看到的物理扇区地址和实际NAND页的映射关系是动态变化的,直接写扇区并不能保证原子性。
第三种是双备份覆写。准备两个配方存储区,A区和B区。保存时先写B区,写完后更新一个标志位指向B区,下次保存再写A区。这种方式比单区覆写可靠,但标志位本身的更新又成了新的风险点。如果标志位写到一半断电,系统就不知道到底哪个区是有效的。
2.2 掉电发生在不同阶段的后果分析
为了搞清楚覆写到底在哪个环节最危险,我做过一组破坏性测试。用ESP32加一张普通TF卡,在写入过程中随机断电,重复五百次,记录每次上电后的数据状态。结果很有意思:
| 断电时机 | 现象 | 概率 |
|---|---|---|
| 擦除块之前 | 旧数据完好 | 约35% |
| 擦除块过程中 | 整个块数据丢失,读出全FF或随机值 | 约20% |
| 写入新数据页时 | 部分页新数据,部分页旧数据,文件校验失败 | 约30% |
| 更新文件系统元数据时 | 文件系统挂载失败,需要格式化 | 约15% |
这个测试结果说明,覆写策略下,掉电导致数据丢失的概率超过一半。尤其是擦除块过程中断电,整个块的数据都会消失,因为NAND Flash的擦除操作是块级别的,擦到一半断电,这个块就处于“半擦除”状态,主控可能无法正确读取。
注意:不同品牌、不同主控的SD卡,擦除块的大小和擦除时间差异很大。便宜的白片卡擦除一个块可能要几十毫秒,这段时间足够你断电好几次了。
2.3 为什么文件系统让覆写更危险
很多人觉得用了FATFS就万事大吉,其实FATFS在掉电场景下非常脆弱。FATFS为了兼容性和通用性,每次写文件都会更新FAT表、目录项、空闲簇链表。这些元数据分散在SD卡的不同位置,写入顺序也不受你控制。如果断电发生在元数据更新过程中,轻则文件长度不对,重则整个分区无法挂载。
我遇到过最诡异的一次是:设备断电后重启,配方文件还在,但文件大小变成了0。用读卡器插到电脑上一看,文件确实存在,但内容全没了。后来分析发现,是FAT表更新到一半断电,文件起始簇号被改成了0,系统认为这个文件不占任何簇,自然就读不出内容了。
所以如果你非要用覆写,至少要做到这几点:每次保存前先备份旧配方到另一个文件;写入新配方时用“写临时文件+重命名”的方式;保存完成后延时至少500毫秒再允许断电。但说实话,这些措施都是治标不治本,真正靠谱的还是追写。
3. 追写策略为什么能扛住掉电
3.1 追写的核心思想:只追加,不修改
追写策略的本质,是把配方存储当成一个只能往后写的日志。每次保存配方,不是去修改旧数据,而是在存储介质的末尾追加一条新记录。每条记录包含配方数据本身、一个递增的序列号、一个CRC校验值。系统启动时,从日志开头扫描到末尾,找到序列号最大且CRC校验通过的那条记录,就是最新有效的配方。
这种设计的美妙之处在于:任何时刻断电,都不会破坏已经写好的旧记录。因为旧记录所在的页从来没有被擦除过,NAND Flash的物理特性保证了已经写入的页在下次擦除前是稳定的。即使新记录写到一半断电,最多就是这条新记录不完整,扫描时CRC校验不过,直接跳过就行。
3.2 追写在SD卡上的具体实现
在SD卡上实现追写,有两种主流方案。一种是基于文件系统的追写,一种是裸扇区追写。
基于文件系统的追写,可以用FATFS的追加模式打开文件,每次f_write把新记录写到文件末尾。但这里有个坑:FATFS的追加模式在文件大小变化时,仍然需要更新目录项里的文件大小字段。如果断电发生在更新文件大小时,文件系统可能认为文件还是旧的大小,新追加的记录就“看不见”了。解决办法是每次追加后调用f_sync强制刷盘,但这样会显著降低写入速度。
裸扇区追写就更直接。你把SD卡从某个扇区开始,划分成固定大小的记录槽,每个槽比如512字节。配方数据序列化后加上头部和CRC,写满一个槽就写下一个槽。用一个全局变量记录当前写到哪个槽,这个变量可以存在SD卡的第一个扇区,也可以存在MCU的Flash里。每次保存就是往下一个空槽写数据,写完更新一下当前槽号。
我实测下来,裸扇区追写的掉电可靠性最高。因为整个写入过程只涉及一个扇区的写操作,SD卡内部主控会保证单扇区写的原子性。只要这个扇区写完,数据就安全了。如果没写完,CRC校验不过,系统自动回退到上一个有效槽。
3.3 追写的空间管理和垃圾回收
追写最大的问题就是空间会越用越少。假设你的配方数据是256字节,加上头部和CRC一共320字节,一个512字节的槽只能存一条。如果SD卡划了1MB给配方日志,那最多只能存2048条记录。对于保存频率高的设备,可能几天就写满了。
所以追写必须配合垃圾回收。常见的做法是:当写到日志末尾时,把当前最新的有效配方复制到日志开头,然后擦除后面的区域,重新开始追写。这个过程叫“压缩”或“整理”。整理过程中如果断电,因为旧的最新配方还在,系统重启后依然能找到有效数据,只是整理没完成而已。
实操心得:垃圾回收的触发时机很关键。不要等到写满了才整理,最好留出至少20%的余量。因为整理本身也需要写入操作,如果空间太满,整理过程中断电可能导致没有足够的空槽来写临时数据。
4. 配方存储方案选型:什么场景用覆写,什么场景用追写
4.1 从配方大小和保存频率判断
配方数据的大小直接决定了追写的空间开销是否可接受。如果你的配方只有几十个字节,比如一个温度值、一个时间值,那追写的开销几乎可以忽略,一个扇区能存十几条记录。但如果你的配方是几KB的曲线数据、几十个参数,那追写的空间放大效应就很明显了。
保存频率也很重要。如果用户一天只保存一次配方,那追写的空间消耗很慢,垃圾回收几年才触发一次。但如果设备是每次运行都自动保存配方,一天保存几百次,那追写就必须配合更激进的垃圾回收策略,或者考虑用覆写加双备份。
我的经验是:配方小于512字节、保存频率低于每天100次,无脑选追写。这个区间内追写的可靠性优势远远大于空间开销。如果配方大于4KB或者保存频率极高,可以考虑覆写加双备份加掉电检测的组合方案。
4.2 从设备供电环境判断
如果你的设备是电池供电,而且用户可能随时拔电池,那追写是唯一的选择。因为覆写在拔电池瞬间的损坏概率太高了。我做过一个美容仪的配方存储,最初用覆写,客户投诉率高达3%,换成追写后降到0.1%以下。
如果设备有稳定的市电供电,而且有掉电检测电路(比如一个大的滤波电容或者超级电容),那可以在检测到掉电后,用剩余的电量完成一次完整的覆写操作。这种方案叫“掉电保护写”,成本比追写低,但需要精确计算电容容量和写入时间。
4.3 从SD卡寿命判断
SD卡的擦写寿命是有限的,通常每个块能擦写3000到10000次。覆写策略每次保存都要擦除一个块,如果保存频繁,SD卡很快就会出现坏块。追写策略因为总是写新页,擦除操作只在垃圾回收时发生,所以对SD卡的磨损更均匀,寿命更长。
但追写也不是没有磨损问题。如果日志区域很小,垃圾回收频繁触发,那日志区域那几个块会被反复擦写,反而比覆写死得更快。所以追写的日志区域要尽量大,让擦写操作分散到更多的块上。
| 对比维度 | 覆写策略 | 追写策略 |
|---|---|---|
| 掉电数据丢失概率 | 高(>50%) | 低(<1%) |
| 空间开销 | 小 | 大(需要日志空间) |
| SD卡寿命 | 短(频繁擦除) | 长(擦除分散) |
| 实现复杂度 | 低 | 中(需要垃圾回收) |
| 读取速度 | 快(直接读) | 慢(需要扫描日志) |
| 适用场景 | 供电稳定、保存频率低 | 供电不稳、可靠性要求高 |
5. 手把手实现一个掉电安全的配方追写存储
5.1 硬件准备和SD卡初始化
先说一下我的测试环境:ESP32-S3开发板,一张16GB的Class10 TF卡,用SPI模式连接。为什么用SPI而不是SDMMC?因为SPI模式在掉电时更容易控制,而且大多数低端MCU只支持SPI。SDMMC虽然速度快,但掉电时的时序更复杂,不利于分析。
SD卡初始化用ESP-IDF的sdspi驱动,挂载FATFS文件系统。但注意,我们的追写日志不放在FATFS文件里,而是直接操作SD卡的物理扇区。所以初始化完SD卡后,我们还需要拿到sdmmc_card_t结构体,用sdmmc_write_sectors和sdmmc_read_sectors来读写裸扇区。
// SD卡初始化关键代码 sdmmc_host_t host = SDSPI_HOST_DEFAULT(); sdspi_device_config_t slot_config = SDSPI_DEVICE_CONFIG_DEFAULT(); sdspi_dev_handle_t handle; sdspi_host_init_device(&slot_config, &handle); sdmmc_card_t *card; esp_vfs_fat_sdmmc_mount_config_t mount_config = { .format_if_mount_failed = false, .max_files = 5, .allocation_unit_size = 16 * 1024 }; esp_vfs_fat_sdspi_mount("/sdcard", &host, &slot_config, &mount_config, &card);拿到card之后,我们就可以用sdmmc_write_sectors(card, data, sector, count)来写扇区了。注意,写之前要确保这个扇区不属于FATFS分区,否则会破坏文件系统。我的做法是把SD卡分成两个区:第一个区给FATFS存日志文件和其他数据,第二个区专门用来做配方追写日志。
5.2 日志扇区格式设计
每个日志槽我设计成512字节,正好是一个扇区的大小。槽的结构如下:
typedef struct { uint32_t magic; // 固定值0x52454350,表示"RECP" uint32_t seq; // 序列号,每次保存递增 uint16_t data_len; // 配方数据长度 uint16_t crc; // 配方数据的CRC16校验 uint8_t data[500]; // 配方数据 uint8_t reserved[4]; // 保留,凑齐512字节 } recipe_slot_t;magic用来快速判断这个槽是否被写过。seq是单调递增的序列号,用来确定哪条记录最新。crc校验配方数据,防止读到损坏的数据。整个结构体正好512字节,一个扇区存一条。
写入流程是这样的:系统启动时,从日志区的第一个扇区开始扫描,找到magic正确且crc校验通过的最大seq,这就是当前有效配方。同时记录下一个空槽的位置。每次保存配方时,把新配方填入槽结构,seq加一,计算CRC,然后调用sdmmc_write_sectors写入下一个空槽。写完后更新内存中的当前槽指针。
5.3 掉电恢复和垃圾回收实现
掉电恢复的逻辑很简单:重新扫描日志区,找到最大有效seq。如果扫描过程中发现某个槽的magic正确但crc校验失败,说明这个槽写到一半断电了,直接跳过。如果发现seq不连续,比如从5直接跳到7,说明6号槽写失败了,也不影响,因为7号槽的数据是完整的。
垃圾回收的触发条件是:当前槽指针到达日志区末尾。这时把最新的有效配方复制到日志区第一个槽,seq继续递增,然后擦除第二个槽到末尾的所有槽。擦除操作可以用sdmmc_erase_sectors,但注意不是所有SD卡都支持擦除命令。如果不支持,就手动写入全FF。
// 垃圾回收伪代码 void garbage_collect() { recipe_slot_t latest; read_latest_recipe(&latest); latest.seq = get_next_seq(); sdmmc_write_sectors(card, &latest, LOG_START_SECTOR, 1); for (int i = 1; i < LOG_SECTOR_COUNT; i++) { write_ff_to_sector(LOG_START_SECTOR + i); } current_slot = 1; }注意:垃圾回收过程中断电是最危险的,因为第一个槽刚写完,后面的槽还没擦完。但即使这样,第一个槽的数据是完整的,系统重启后依然能读到最新配方。只是日志区里可能残留一些旧数据,下次写入时会覆盖掉,不影响正确性。
6. 实测对比:覆写和追写在掉电测试中的表现
6.1 测试方法和测试用例
为了给你一个直观的对比,我设计了一组掉电测试。测试平台是ESP32-S3加16GB TF卡,用继电器控制电源通断,继电器由另一个MCU控制,随机延时后切断电源。每次测试后重新上电,读取配方数据,与写入前的数据对比。
测试用例分三组:第一组用覆写策略,每次保存直接写FATFS文件;第二组用追写策略,按上面的方案实现;第三组用覆写加双备份,A/B区交替写。每组测试1000次掉电,记录数据丢失、数据错误、文件系统损坏的次数。
6.2 测试结果和分析
| 测试组 | 数据丢失次数 | 数据错误次数 | 文件系统损坏次数 | 成功率 |
|---|---|---|---|---|
| 覆写 | 312 | 187 | 96 | 40.5% |
| 追写 | 3 | 0 | 0 | 99.7% |
| 覆写+双备份 | 45 | 23 | 12 | 92.0% |
这个结果非常说明问题。覆写策略的成功率只有40.5%,也就是说超过一半的掉电都会导致配方出问题。追写策略的成功率高达99.7%,1000次掉电只有3次失败,而且这3次失败都是因为断电发生在垃圾回收过程中,但即使失败,旧配方依然可读,只是最新一次保存没生效。
覆写加双备份的成功率92%,比纯覆写好很多,但仍然有8%的失败率。失败的主要原因是标志位更新时断电,导致系统不知道哪个区是有效的。如果再加一个标志位的备份,成功率还能提高,但实现复杂度也上去了。
6.3 写入速度和SD卡寿命对比
追写因为每次只写一个扇区,写入速度非常快。我实测写一条512字节的记录,SPI模式下大约2毫秒。覆写因为要更新文件系统元数据,写一条同样大小的配方,平均需要15到20毫秒。如果保存频率高,追写的速度优势很明显。
SD卡寿命方面,我用了一个模拟程序来估算。假设每天保存100次配方,覆写策略下,FATFS的元数据区域那几个块每天要被擦写100次,按3000次擦写寿命算,一个月就报废了。追写策略下,日志区有2048个槽,每天写100次,20天才需要垃圾回收一次,擦除操作分散到整个日志区,寿命长得多。
实操心得:如果你用追写,建议把日志区设大一点,至少留出几千个槽的空间。SD卡现在很便宜,多划几MB出来对成本影响不大,但对寿命和可靠性的提升非常明显。
7. 常见问题排查和避坑指南
7.1 追写日志扫描太慢怎么办
日志区大了之后,启动时扫描所有槽会变慢。我试过扫描2048个槽,SPI模式下需要大约200毫秒。对于大多数设备来说,200毫秒的启动延时可以接受。但如果你的设备要求秒启动,那就需要优化。
优化方法有两种。一种是在日志区头部维护一个索引扇区,记录当前最新槽的位置。每次保存后更新索引扇区。启动时先读索引扇区,直接跳到最新槽,只需要扫描最后几个槽就能确认。另一种是用二分查找,因为seq是递增的,可以利用这个特性加速扫描。
但索引扇区本身也有掉电风险。如果索引扇区写坏了,还是得回退到全扫描。所以索引扇区要双备份,或者用CRC保护。我的做法是索引扇区也存两份,启动时读两份,取CRC正确的那个。
7.2 SD卡热插拔导致的数据损坏
有些设备允许用户在运行时拔出SD卡,这会导致正在进行的写入操作失败,甚至损坏日志。解决办法是检测SD卡的插入状态,在拔出前完成所有写入操作。可以用一个GPIO检测SD卡的CD引脚,或者定期发送测试命令检查SD卡是否还在。
如果检测到SD卡被拔出,立即停止所有写入,把当前配方数据缓存在MCU的Flash里。等SD卡重新插入后,再把缓存的数据写入日志。这样即使SD卡拔出,配方也不会丢。
7.3 不同品牌SD卡的兼容性问题
我测试过十几种SD卡,发现不同品牌对裸扇区写入的支持差异很大。有些卡在写入非对齐扇区时会出错,有些卡在擦除命令上行为不一致。最稳妥的做法是只用sdmmc_write_sectors写单个扇区,不要用擦除命令,擦除用写全FF代替。
另外,有些便宜的白片卡内部有缓存,写入操作返回成功后,数据可能还在缓存里没真正写到NAND。如果这时候断电,数据就丢了。解决办法是写入后调用sdmmc_write_sectors再写一个无关扇区,强制刷缓存。或者用CTRL_SYNC命令让卡同步。
| 常见问题 | 排查方法 | 解决方案 |
|---|---|---|
| 启动扫描慢 | 测量扫描耗时 | 加索引扇区或二分查找 |
| 热插拔损坏 | 监控CD引脚 | 拔出前停止写入,缓存数据 |
| 写入不生效 | 读回验证 | 写后立即读回对比 |
| 不同卡行为不一致 | 多品牌测试 | 只用单扇区写,不用擦除 |
| 垃圾回收失败 | 检查回收日志 | 回收前先写最新配方到安全区 |
7.4 配方数据校验的注意事项
CRC校验是追写可靠性的最后一道防线。但CRC本身也有碰撞概率,虽然很低,但对于安全关键的应用,可以考虑用更强的校验,比如SHA256或者双CRC。另外,CRC的计算范围要包含seq和data_len,防止这些字段被篡改后CRC仍然通过。
我遇到过一种情况:SD卡某个扇区部分位翻转,magic和crc都碰巧正确,但data里的配方参数错了。这种概率极低,但如果你做的是医疗设备或者工业控制,就不能忽视。解决办法是每条记录存两份,两份都校验通过才认为有效。
8. 配方存储方案的个人体会
做了这么多配方存储的项目,我最大的体会是:不要跟NAND Flash的物理特性对着干。覆写策略本质上是在试图模拟RAM的随机访问,但NAND Flash根本不支持这种操作。你每次覆写,主控都在背后做读-擦-写的大动作,这个过程中断电,数据丢失是必然的。
追写策略则是顺应NAND Flash的特性,只写不擦,把随机写变成了顺序写。顺序写在NAND Flash上是最快、最可靠的。虽然需要额外的空间和垃圾回收,但换来的可靠性提升是值得的。
如果你现在正在设计一个带配方存储的产品,我的建议是:先评估掉电风险,如果设备可能随时断电,直接上追写。如果供电稳定,可以用覆写加双备份,但一定要加掉电检测,在检测到掉电后用储能电容完成最后一次写入。最忌讳的就是用最简单的覆写,还不做任何保护,这种方案在实验室里可能跑得好好的,一到现场就各种问题。
最后分享一个小技巧:不管用什么策略,每次保存配方后,把配方数据同时写一份到MCU的内部Flash里作为备份。内部Flash虽然擦写寿命有限,但存一份最新配方足够了。这样即使SD卡完全损坏,配方也不会丢。等SD卡换新后,从内部Flash恢复配方即可。这个双保险的成本很低,但关键时刻能救命。