1. 工业控制器数据存储的分级思路与方案选型
工业控制器和消费类电子产品最大的区别在于:它得在强电磁干扰、宽温、频繁掉电、长期无人值守的环境里,把关键数据一个字节都不能丢地存下来。我做过好几个基于 STM32 + FPGA 架构的控制器项目,从最早的“所有数据往一颗 EEPROM 里塞”到后来被迫重构成分级存储,中间踩的坑足够写一本小册子。这篇就围绕STM32 + FPGA 分级存储方案(EEPROM / NOR Flash / SD 卡)这个主题,把硬件选型、接口设计、分区规划、读写时序、掉电保护这些环节掰开揉碎讲一遍。
先说清楚这套方案解决什么问题。工业控制器里的数据其实分三六九等:有的一秒钟变几十次但断电丢了也无所谓(比如实时采样缓存),有的几个月才写一次但丢了整台设备就报废(比如校准系数、设备序列号、累计运行时长),还有的需要大容量记录历史曲线和日志(比如几个月的工艺参数)。如果只用一种介质,要么容量不够,要么擦写寿命扛不住,要么成本高得离谱。分级存储的核心逻辑就是:按数据的“变化频率、重要性、容量需求”三个维度,把数据分配到最合适的介质上。
为什么是 STM32 + FPGA 这个组合?STM32 负责系统管理、协议栈、文件系统、对外通信,FPGA 负责高速数据采集、实时逻辑和并行接口时序。存储这块,STM32 天然带 I2C、SPI、SDIO 控制器,接 EEPROM、NOR Flash、SD 卡都很顺手;而 FPGA 可以通过并行总线或 SPI 直接访问 NOR Flash,做高速数据流缓存,不占用 STM32 的 CPU 周期。两者分工明确,这是这套架构能落地的关键。
具体怎么分?我一般按下面这个原则来:
| 数据类型 | 典型内容 | 变化频率 | 推荐介质 | 理由 |
|---|---|---|---|---|
| 关键配置参数 | 校准系数、设备ID、通信地址 | 极低(出厂一次) | EEPROM | 字节级擦写、寿命长、掉电安全 |
| 运行统计与日志 | 累计运行时长、故障记录 | 中低频 | NOR Flash | 扇区擦写、容量适中、可磨损均衡 |
| 历史数据与波形 | 工艺曲线、采样记录 | 高频、大容量 | SD 卡 | 容量大、成本低、可更换 |
这个表不是拍脑袋定的,背后是每种介质的物理特性决定的。EEPROM 支持字节级擦写,擦写寿命通常在 100 万次以上,适合存那种“偶尔改一次但绝不能丢”的参数。NOR Flash 擦写必须按扇区(通常 4KB)来,寿命约 10 万次,适合存中等频率更新的数据,配合磨损均衡算法能用很久。SD 卡容量大、单位成本低,但它的擦写寿命和掉电安全性最差,所以只适合存“丢了也能接受”的历史数据,而且必须做文件系统层的掉电保护。
注意:分级存储不是简单地把数据分三份存,而是要设计一套统一的存储管理中间层,让上层应用不用关心数据到底存在哪。这个中间层我后面会详细讲。
2. 三种存储介质的硬件接口设计与关键参数
2.1 EEPROM 的 I2C 接口设计与地址规划
EEPROM 我一般选 24C 系列的,比如 24C02、24C16、24C512,容量从 2Kbit 到 512Kbit 不等。工业场景下我更倾向用 24C512 或更大容量的,因为校准参数、设备信息、通信配置加起来很容易超过 2KB。接口用 I2C,两根线 SCL 和 SDA,加上拉电阻(通常 4.7K),STM32 这边用硬件 I2C 或者软件模拟都行。
硬件设计上有几个细节必须注意。第一,I2C 上拉电阻的阻值要根据总线电容和速率来算。标准模式 100kHz 下,4.7K 一般没问题;快速模式 400kHz 下,如果总线走线长、挂的设备多,可能要降到 2.2K 甚至 1.5K。第二,EEPROM 的写保护引脚(WP)一定要接,我习惯接到 STM32 的一个 GPIO 上,正常运行时拉低允许写,检测到异常或者进入安全模式时拉高禁止写,防止误擦除。第三,A0/A1/A2 地址引脚要规划好,如果一条 I2C 总线上挂多颗 EEPROM,地址不能冲突。
STM32 这边读写 EEPROM 的代码很成熟,但有个坑:页写边界。24C512 的页大小是 128 字节,如果你一次写的数据跨了页边界,地址会回卷到页首,把前面的数据覆盖掉。我见过不止一个项目在这里翻车。正确的做法是写之前先算好当前地址到页尾还剩多少字节,分多次写。
// EEPROM 页写示例(24C512,页大小128字节) #define EEPROM_PAGE_SIZE 128 #define EEPROM_ADDR 0xA0 HAL_StatusTypeDef EEPROM_WriteBuffer(uint16_t memAddr, uint8_t *pData, uint16_t len) { uint16_t bytesToWrite; while (len > 0) { // 计算当前页剩余空间 bytesToWrite = EEPROM_PAGE_SIZE - (memAddr % EEPROM_PAGE_SIZE); if (bytesToWrite > len) bytesToWrite = len; HAL_I2C_Mem_Write(&hi2c1, EEPROM_ADDR, memAddr, I2C_MEMADD_SIZE_16BIT, pData, bytesToWrite, 100); HAL_Delay(5); // 等待内部写周期完成,24C512典型5ms memAddr += bytesToWrite; pData += bytesToWrite; len -= bytesToWrite; } return HAL_OK; }那个HAL_Delay(5)不能省,EEPROM 写完一个页需要内部擦写时间,期间不响应任何命令。你可以用“应答轮询”代替固定延时,效率更高,但固定延时更简单可靠,工业场景下 5ms 的延迟完全可以接受。
2.2 NOR Flash 的 SPI 接口与扇区管理
NOR Flash 我常用 W25Q 系列,比如 W25Q64、W25Q128,容量 8MB 到 16MB,SPI 接口,STM32 和 FPGA 都能直接驱动。选它的理由是:支持扇区擦除(4KB)、块擦除(64KB)、整片擦除,读写速度快,数据保持时间长(20年以上),而且价格便宜。
硬件连接上,SPI 四根线(CS、CLK、MOSI、MISO)加上 WP 和 HOLD 引脚。WP 和 HOLD 我一般直接上拉到 VCC,不用动态控制,因为工业场景下很少需要硬件写保护,软件层面控制就够了。如果 FPGA 和 STM32 都要访问同一颗 NOR Flash,必须做总线仲裁,否则会冲突。我的做法是:NOR Flash 的 SPI 总线只挂在一个主控上,要么 STM32 管,要么 FPGA 管,另一个通过共享内存或通信接口来请求数据。这样最简单,也最不容易出问题。
NOR Flash 的擦写特性决定了它不能像 EEPROM 那样随便写。写之前必须先擦除对应扇区,擦除后整个扇区变成 0xFF,然后才能按页(通常 256 字节)写入。这里有个关键点:擦除次数是有限的,W25Q 系列标称 10 万次擦写寿命。如果你每秒写一次同一个扇区,不到 28 小时就把这个扇区写废了。所以必须做磨损均衡。
我的磨损均衡策略比较简单实用:把 NOR Flash 分成若干个逻辑块,每个逻辑块对应一个数据记录区,写的时候轮询使用物理扇区,记录一个“写指针”,写满一圈后再从头开始。这样每个物理扇区的擦写次数就被均匀分摊了。具体实现时,我会在 NOR Flash 的头部留一个“元数据区”,记录每个逻辑块的当前写指针和擦写计数。
// NOR Flash 磨损均衡简化实现 typedef struct { uint32_t logicBlockId; uint32_t writePointer; // 当前写到哪个物理扇区 uint32_t eraseCount; // 该逻辑块累计擦除次数 } WearLevelingInfo; // 写入时轮询物理扇区 uint32_t GetNextPhysicalSector(WearLevelingInfo *info, uint32_t sectorCount) { uint32_t sector = info->writePointer; info->writePointer = (info->writePointer + 1) % sectorCount; if (info->writePointer == 0) { info->eraseCount++; } return sector; }这个逻辑看起来简单,但实际用的时候要注意:元数据本身也要存,而且元数据的更新频率可能比数据还高。我的做法是把元数据缓存在 STM32 的 RAM 里,定期(比如每 100 次写入或每 10 分钟)同步到 NOR Flash 的一个专用扇区,并且用双备份加 CRC 校验,防止元数据本身损坏。
2.3 SD 卡接口设计与文件系统选型
SD 卡用 SDIO 接口,4 位数据线,STM32 的 SDIO 控制器直接支持。硬件上要注意:SDIO 的时钟线、命令线、数据线都要加适当的上拉电阻(通常 10K 到 50K),走线尽量等长,避免信号完整性问题。电源方面,SD 卡的供电要稳定,最好单独加一颗 LDO,并且在电源引脚附近放 10uF 和 0.1uF 的电容。
文件系统我一般用 FatFs,这是嵌入式领域最成熟的方案,STM32 官方也有移植好的例程。但 FatFs 有个致命问题:掉电时文件系统容易损坏。因为 FatFs 的目录项和 FAT 表更新不是原子的,如果在写文件的过程中掉电,轻则文件内容丢失,重则整个分区无法挂载。
解决这个问题有几个思路。第一,用“先写临时文件,写完再重命名”的策略,这样即使掉电,最多丢失临时文件,正式文件不受影响。第二,定期调用f_sync()强制刷盘,减少掉电时未写入的数据量。第三,在 SD 卡上划分两个分区,一个用于存储数据,一个用于备份文件系统元数据,挂载失败时可以从备份恢复。
我实际项目里用的是第一种加第二种的组合:数据先写到data.tmp,每写 1KB 调用一次f_sync(),写完后关闭文件,删除旧的data.dat,把data.tmp重命名为data.dat。这样即使掉电,最多丢失最后 1KB 的数据,而且不会破坏已有文件。
注意:SD 卡的擦写寿命和写入放大问题比 NOR Flash 更严重。消费级 SD 卡在频繁小数据写入场景下,可能几个月就出现坏块。工业场景下建议用工业级 SD 卡(SLC 颗粒),并且控制写入频率,比如每 10 秒或每分钟批量写一次,而不是每来一个数据就写一次。
3. 分级存储管理中间层的设计与实现
3.1 统一存储接口抽象
分级存储最大的价值在于“上层应用不用关心数据存在哪”。所以我设计了一个统一的存储接口层,上层调用Storage_Write(dataId, pData, len)和Storage_Read(dataId, pData, len),底层根据 dataId 自动路由到对应的介质。
dataId 的编码规则我这样设计:高 4 位表示数据类型(0x1 表示配置参数,0x2 表示运行统计,0x3 表示历史数据),低 12 位表示具体的数据项编号。这样最多支持 16 种数据类型和 4096 个数据项,足够大多数工业控制器用了。
// 存储接口抽象层 typedef enum { STORAGE_TYPE_CONFIG = 0x1, // 配置参数 -> EEPROM STORAGE_TYPE_STAT = 0x2, // 运行统计 -> NOR Flash STORAGE_TYPE_HIST = 0x3, // 历史数据 -> SD 卡 } StorageType; typedef struct { uint16_t dataId; StorageType type; uint16_t offset; // 在对应介质内的偏移 uint16_t length; // 数据长度 uint32_t updateCount; // 更新次数,用于磨损均衡 } StorageItem; // 存储路由表 static const StorageItem storageTable[] = { {0x1001, STORAGE_TYPE_CONFIG, 0x0000, 4, 0}, // 设备ID {0x1002, STORAGE_TYPE_CONFIG, 0x0004, 8, 0}, // 校准系数 {0x2001, STORAGE_TYPE_STAT, 0x0000, 4, 0}, // 累计运行时长 {0x2002, STORAGE_TYPE_STAT, 0x0004, 64, 0}, // 故障记录 {0x3001, STORAGE_TYPE_HIST, 0x0000, 512,0}, // 历史曲线 };这个路由表是静态的,编译时就确定了。如果数据项需要动态增加,可以把路由表存在 NOR Flash 里,启动时加载到 RAM。但静态表更简单可靠,工业场景下我倾向于静态表。
3.2 掉电保护与数据一致性
工业控制器最怕的就是掉电时数据写了一半。我的做法是在每个存储介质上都留一个“事务区”,记录当前正在进行的写操作。写数据之前,先在事务区标记“开始写 dataId X”,写完数据后,再标记“写完 dataId X”。如果系统启动时发现事务区有“开始写”但没有“写完”的记录,就知道上次掉电时数据可能不完整,需要从备份恢复或者丢弃这次写入。
对于 EEPROM,因为支持字节级擦写,事务区可以很简单:用一个字节表示状态,0xFF 表示空闲,0xAA 表示正在写,0x55 表示写完。写数据前先写 0xAA,写完后再写 0x55。启动时读这个字节,如果是 0xAA,说明上次写操作没完成,需要处理。
对于 NOR Flash,因为擦除是按扇区的,事务区要单独占一个扇区,并且用双备份加 CRC。写数据前先擦除事务扇区并写入“开始”标记,写完数据后再更新事务扇区为“完成”标记。启动时检查事务扇区,如果发现“开始”但没有“完成”,就触发恢复流程。
对于 SD 卡,因为文件系统本身有掉电风险,我采用“影子文件”策略:数据同时写到data.dat和data.bak,写完data.dat后,把data.dat的内容复制到data.bak。启动时如果data.dat损坏,就从data.bak恢复。这个策略会占用双倍空间,但工业场景下 SD 卡容量通常够用,可靠性优先。
3.3 数据校验与错误恢复
所有存储的数据都要加 CRC 校验。我在每个数据块后面附加 4 字节的 CRC32,读取时先校验 CRC,校验失败就尝试从备份恢复,备份也失败就返回错误码,让上层决定怎么处理。
CRC 的计算我用 STM32 的硬件 CRC 单元,速度快,不占 CPU。如果 STM32 型号没有硬件 CRC,就用软件查表法,速度也够用。关键是 CRC 的多项式和初始值要固定,不能这次用这个下次用那个,否则校验永远失败。
// 数据块格式:数据 + CRC32 typedef struct { uint8_t data[512]; uint32_t crc; } DataBlock; uint32_t CalculateCRC32(uint8_t *pData, uint32_t len) { uint32_t crc = 0xFFFFFFFF; for (uint32_t i = 0; i < len; i++) { crc ^= pData[i]; for (int j = 0; j < 8; j++) { if (crc & 1) crc = (crc >> 1) ^ 0xEDB88320; else crc >>= 1; } } return ~crc; }错误恢复的策略我分三级:第一级,CRC 校验失败,尝试从备份读取;第二级,备份也失败,尝试从历史版本恢复(如果存了多个版本);第三级,都失败,返回错误码,上层可以选择用默认值或者报警。工业场景下,我一般会保留最近 3 个版本的数据,这样即使当前版本和备份都损坏,还有机会恢复。
4. 实操过程与关键环节实现
4.1 硬件连接与初始化流程
先列一下我实际项目里的硬件连接清单,以 STM32F407 + FPGA(Xilinx Spartan-6)为例:
| 介质 | 接口 | STM32 引脚 | FPGA 引脚 | 备注 |
|---|---|---|---|---|
| EEPROM 24C512 | I2C1 | PB6(SCL), PB7(SDA) | 不接 | 上拉 4.7K |
| NOR Flash W25Q128 | SPI2 | PB13(CLK), PB14(MISO), PB15(MOSI), PB12(CS) | 不接 | 独立片选 |
| SD 卡 | SDIO | PC8-D0, PC9-D1, PC10-D2, PC11-D3, PC12(CK), PD2(CMD) | 不接 | 4位模式 |
| FPGA 配置 Flash | SPI | 不接 | 专用引脚 | 仅 FPGA 使用 |
初始化顺序很重要:先初始化 EEPROM,读取关键配置参数;再初始化 NOR Flash,加载运行统计和磨损均衡元数据;最后初始化 SD 卡,挂载文件系统。如果 SD 卡挂载失败,系统仍然可以正常运行,只是历史数据无法记录,这时候要给出告警但不影响核心功能。
void Storage_Init(void) { // 1. 初始化 EEPROM EEPROM_Init(); if (EEPROM_CheckID() != EEPROM_ID_OK) { // EEPROM 异常,使用默认配置 LoadDefaultConfig(); } else { LoadConfigFromEEPROM(); } // 2. 初始化 NOR Flash NORFlash_Init(); if (NORFlash_CheckID() != NORFLASH_ID_OK) { // NOR Flash 异常,格式化 NORFlash_Format(); } LoadWearLevelingInfo(); // 3. 初始化 SD 卡 if (SD_Init() == SD_OK) { if (f_mount(&fs, "0:", 1) != FR_OK) { // 挂载失败,尝试格式化 f_mkfs("0:", 0, 0); f_mount(&fs, "0:", 1); } } else { g_sdCardStatus = SD_STATUS_ERROR; } }这个初始化流程我跑了不下几十次,最常出问题的是 SD 卡挂载。有时候 SD 卡在电脑上格式化过,文件系统类型不对,STM32 挂载失败。解决办法是在代码里加一个“挂载失败自动格式化”的逻辑,但要注意:格式化会丢失所有数据,所以只在首次使用或者确认数据可丢失时才这么做。
4.2 EEPROM 读写实操与参数计算
EEPROM 的读写看起来简单,但实际用的时候有几个参数要算清楚。第一个是 I2C 速率。24C512 支持 400kHz 快速模式,但实际能达到的速率取决于上拉电阻和总线电容。总线电容的估算公式是:C_total = C_pcb + C_pin + C_wire,其中 C_pcb 约 1pF/cm,C_pin 约 10pF,C_wire 约 1pF/cm。如果走线 10cm,挂 2 个设备,C_total 约 10 + 20 + 10 = 40pF。上拉电阻 R 和上升时间 tr 的关系是:tr ≈ 0.847 × R × C_total。400kHz 下 tr 要小于 300ns,算下来 R 要小于 8.8K。所以我用 4.7K 是安全的。
第二个是写周期时间。24C512 的典型写周期是 5ms,最大 10ms。如果你连续写多个页,每页之间要等 5ms,写 512 字节需要 4 页,总共 20ms。这个时间在工业控制器里完全可以接受,但如果你的数据更新频率很高,就要考虑用 NOR Flash 或者 RAM 缓存。
第三个是数据保持时间。24C512 在 25°C 下数据保持 200 年,在 85°C 下保持 40 年。工业场景下温度可能到 70°C,保持时间约 100 年,足够用了。但要注意,如果 EEPROM 长期在高温下工作,擦写寿命会下降,从 100 万次降到 50 万次甚至更低。所以关键参数不要频繁写,我一般只在参数变化时才写,而且写之前先比较,相同就不写。
// 带比较的 EEPROM 写入,避免不必要的擦写 HAL_StatusTypeDef EEPROM_WriteIfChanged(uint16_t addr, uint8_t *pData, uint16_t len) { uint8_t oldData[256]; if (len > sizeof(oldData)) return HAL_ERROR; EEPROM_ReadBuffer(addr, oldData, len); if (memcmp(oldData, pData, len) == 0) { return HAL_OK; // 数据相同,不写 } return EEPROM_WriteBuffer(addr, pData, len); }这个EEPROM_WriteIfChanged函数看起来简单,但实际能大幅延长 EEPROM 寿命。我有个项目,原本每秒写一次校准参数,EEPROM 半年就坏了;改成比较后再写,用了三年还没出问题。
4.3 NOR Flash 扇区擦写与磨损均衡实操
NOR Flash 的擦除时间比写入长得多。W25Q128 的扇区擦除(4KB)典型时间 45ms,最大 400ms;页写入(256 字节)典型时间 0.7ms,最大 3ms。所以擦除是瓶颈,磨损均衡的核心就是减少擦除次数。
我的做法是把 NOR Flash 分成 16 个逻辑块,每个逻辑块 1MB(256 个扇区)。每个逻辑块内部做扇区级磨损均衡,逻辑块之间做块级磨损均衡。写数据时,先找到当前逻辑块的写指针,写到对应扇区,写指针加一;写指针到末尾时,擦除下一个逻辑块,把写指针移过去。这样每个扇区的擦写次数基本均匀。
// NOR Flash 磨损均衡写入 HAL_StatusTypeDef NORFlash_WriteWithWearLeveling(uint32_t logicBlock, uint8_t *pData, uint32_t len) { static uint32_t writePtr[16] = {0}; static uint32_t eraseCnt[16] = {0}; uint32_t sectorAddr = logicBlock * LOGIC_BLOCK_SIZE + writePtr[logicBlock] * SECTOR_SIZE; // 擦除扇区 NORFlash_SectorErase(sectorAddr); // 写入数据 NORFlash_PageProgram(sectorAddr, pData, len); // 更新写指针 writePtr[logicBlock]++; if (writePtr[logicBlock] >= SECTORS_PER_BLOCK) { writePtr[logicBlock] = 0; eraseCnt[logicBlock]++; // 保存磨损均衡信息到元数据区 SaveWearLevelingInfo(logicBlock, writePtr[logicBlock], eraseCnt[logicBlock]); } return HAL_OK; }这个实现有个问题:writePtr和eraseCnt是静态变量,掉电就丢了。所以每次更新后要保存到元数据区。但元数据区本身也是 NOR Flash,也有擦写寿命。我的优化是:元数据区用两个扇区交替写,每个扇区写 100 次后切换到另一个,这样元数据区的擦写次数也被分摊了。
4.4 SD 卡文件系统操作与掉电保护实操
SD 卡的操作我用 FatFs,但做了一些封装来增强掉电保护。核心思路是:数据先写到临时文件,写完调用f_sync(),然后重命名。重命名操作在 FatFs 里是原子的(只修改目录项),所以不会出现半写状态。
// 带掉电保护的 SD 卡写入 FRESULT SD_WriteWithProtection(const char *filename, uint8_t *pData, uint32_t len) { FIL fil; FRESULT res; char tmpName[32]; // 构造临时文件名 snprintf(tmpName, sizeof(tmpName), "%s.tmp", filename); // 写临时文件 res = f_open(&fil, tmpName, FA_CREATE_ALWAYS | FA_WRITE); if (res != FR_OK) return res; UINT written; res = f_write(&fil, pData, len, &written); if (res != FR_OK || written != len) { f_close(&fil); return FR_DISK_ERR; } // 强制刷盘 res = f_sync(&fil); f_close(&fil); if (res != FR_OK) return res; // 删除旧文件 f_unlink(filename); // 重命名临时文件为正式文件 res = f_rename(tmpName, filename); return res; }这个流程的关键是f_sync()和f_rename()。f_sync()确保数据真正写到 SD 卡上,而不是停留在缓存里。f_rename()在 FatFs 里是原子操作,只修改目录项,不会出现半写状态。实测下来,即使在这两个操作之间掉电,文件系统也不会损坏,最多丢失最后一次写入的数据。
注意:
f_sync()会阻塞直到数据写完,SD 卡的写入速度可能只有几百 KB/s,所以不要频繁调用。我的做法是每写 1KB 调用一次,或者每 100ms 调用一次,根据数据量来定。
5. 常见问题与排查技巧实录
5.1 EEPROM 读写失败排查
EEPROM 读写失败是最常见的问题,我整理了一个排查表:
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 读回全 0xFF | 器件未应答 | 用示波器看 SCL/SDA 波形 | 检查上拉电阻、地址引脚 |
| 读回数据错位 | 页写边界回卷 | 检查写入地址是否跨页 | 分页写入 |
| 写入后立即读失败 | 写周期未完成 | 读应答轮询 | 增加延时或轮询 ACK |
| 偶发读写失败 | 总线干扰 | 示波器看波形质量 | 降低速率、加滤波电容 |
| 数据保持时间短 | 高温或频繁擦写 | 检查工作温度和擦写次数 | 减少写入频率、降温 |
我最常遇到的是“读回全 0xFF”,90% 的情况是 I2C 地址不对。24C512 的地址是 0xA0(写)和 0xA1(读),但有些型号是 0xA2/0xA3,取决于 A0/A1/A2 引脚。用逻辑分析仪抓一下波形,看看从机有没有应答,很快就能定位。
5.2 NOR Flash 擦除失败与数据丢失
NOR Flash 擦除失败通常有两个原因:一是擦除过程中掉电,二是擦除次数超限。擦除过程中掉电会导致扇区处于不确定状态,可能读出来全是 0x00 或者随机值。我的处理方法是:擦除前先在另一个扇区记录“正在擦除扇区 X”,擦除完成后清除标记。启动时检查标记,如果有未完成的擦除,就重新擦除该扇区。
擦除次数超限的问题更隐蔽。W25Q128 标称 10 万次擦写寿命,但实际使用中,如果某个扇区擦写次数明显高于其他扇区,它会先坏掉。我的做法是在元数据区记录每个扇区的擦写次数,当某个扇区擦写次数超过 8 万次时,就把它标记为“坏扇区”,不再使用。这样虽然损失了一些容量,但保证了可靠性。
5.3 SD 卡挂载失败与文件系统损坏
SD 卡挂载失败的原因很多,我遇到过的主要有这几种:
第一种是卡本身的问题。有些 SD 卡在电脑上能用,但在 STM32 上就是挂载失败。原因是卡的初始化时序不兼容,或者卡的容量超过 STM32 SDIO 控制器的支持范围。解决办法是换卡,或者降低 SDIO 时钟频率。
第二种是文件系统类型不对。STM32 的 FatFs 默认支持 FAT32,如果 SD 卡是 exFAT 或者 NTFS,就挂载失败。解决办法是在电脑上格式化成 FAT32,或者在代码里加 exFAT 支持。
第三种是掉电导致文件系统损坏。这个最麻烦,因为 FatFs 没有内置的修复工具。我的做法是在 SD 卡上留一个备份分区,定期把文件系统的关键数据(FAT 表、目录项)备份到备份分区。挂载失败时,从备份分区恢复。
// SD 卡挂载失败恢复流程 FRESULT SD_MountWithRecovery(void) { FRESULT res = f_mount(&fs, "0:", 1); if (res == FR_OK) return res; // 尝试从备份恢复 res = RestoreFSFromBackup(); if (res == FR_OK) { res = f_mount(&fs, "0:", 1); if (res == FR_OK) return res; } // 恢复失败,格式化 res = f_mkfs("0:", 0, 0); if (res != FR_OK) return res; return f_mount(&fs, "0:", 1); }这个恢复流程我实测过多次,大部分情况下能从备份恢复,只有极少数情况需要格式化。格式化的代价是丢失所有历史数据,所以我在格式化之前会尽量把能读的数据读出来,存到 NOR Flash 或者通过通信接口上传。
5.4 掉电测试与数据一致性验证
掉电测试是验证存储方案可靠性的关键环节。我的测试方法是:让系统持续写入数据,然后用继电器随机切断电源,重新上电后检查数据一致性。测试至少要做 100 次,覆盖各种写入阶段(EEPROM 写入中、NOR Flash 擦除中、SD 卡写入中)。
测试结果我一般用表格记录:
| 测试次数 | 掉电时机 | EEPROM 数据 | NOR Flash 数据 | SD 卡数据 | 结论 |
|---|---|---|---|---|---|
| 1 | EEPROM 写入中 | 旧值 | 正常 | 正常 | 通过 |
| 2 | NOR Flash 擦除中 | 正常 | 旧值 | 正常 | 通过 |
| 3 | SD 卡写入中 | 正常 | 正常 | 旧值 | 通过 |
| 4 | 多介质同时写入 | 旧值 | 旧值 | 旧值 | 通过 |
关键结论是:只要每个介质都有事务保护和备份,掉电不会导致数据损坏,最多丢失最后一次未完成的写入。这个结论在我多个项目里都得到了验证。
注意:掉电测试一定要用真实的继电器切断电源,不能用软件模拟。软件模拟无法复现电源跌落时的真实波形,很多问题只有在真实掉电时才会暴露。
6. 性能优化与扩展思路
6.1 写入性能优化
分级存储的写入性能瓶颈主要在 NOR Flash 的擦除和 SD 卡的写入。我的优化策略是:RAM 缓存 + 批量写入。在 STM32 的 RAM 里开辟一块缓存区,数据先写到缓存,当缓存满或者定时时间到时,再批量写到存储介质。这样 NOR Flash 的擦除次数和 SD 卡的写入次数都大幅减少。
缓存大小我一般设为 4KB,对应 NOR Flash 的一个扇区。定时时间设为 1 秒,这样即使掉电,最多丢失 1 秒的数据。对于关键数据,我不用缓存,直接写 EEPROM,保证实时性。
// RAM 缓存批量写入 typedef struct { uint8_t buffer[4096]; uint32_t writeIndex; uint32_t lastFlushTime; } StorageCache; void StorageCache_Write(StorageCache *cache, uint8_t *pData, uint32_t len) { if (cache->writeIndex + len > sizeof(cache->buffer)) { StorageCache_Flush(cache); } memcpy(&cache->buffer[cache->writeIndex], pData, len); cache->writeIndex += len; } void StorageCache_Flush(StorageCache *cache) { if (cache->writeIndex == 0) return; NORFlash_WriteWithWearLeveling(0, cache->buffer, cache->writeIndex); cache->writeIndex = 0; cache->lastFlushTime = HAL_GetTick(); }这个缓存机制实测能把 NOR Flash 的擦写次数降低 90% 以上。原来每秒写一次,一天擦写 86400 次,扇区几天就废了;现在每秒写缓存,1 秒刷一次,一天擦写 86400 次,但每次擦写的是一个扇区,而且有磨损均衡,寿命延长了几十倍。
6.2 读取性能优化
读取性能的瓶颈主要在 SD 卡的文件系统查找。FatFs 查找文件需要遍历目录项,如果目录下文件很多,查找会很慢。我的优化是:用固定文件名 + 索引文件。数据文件命名为data_000.dat、data_001.dat,索引文件index.dat记录每个数据文件的时间范围和起始位置。读取时先查索引,直接定位到文件,不用遍历目录。
索引文件本身也用缓存 + 批量写入的策略,减少 SD 卡写入次数。索引文件的大小我控制在 4KB 以内,这样一次读取就能全部加载到 RAM。
6.3 扩展思路:从单机到边缘网关
这套分级存储方案不仅适用于单机控制器,还可以扩展到边缘网关场景。边缘网关通常需要采集多个设备的数据,本地存储一段时间后上传到云端。这时候 SD 卡的角色就更重要了,它作为“数据缓冲区”,在断网时缓存数据,联网后批量上传。
扩展时要注意:边缘网关的数据量更大,SD 卡的写入频率更高,磨损更快。我的做法是用两块 SD 卡做 RAID 1(镜像),一块写坏了另一块还能用。或者用 eMMC 代替 SD 卡,eMMC 的擦写寿命和可靠性都比 SD 卡好,但成本更高,焊接也更麻烦。
另外,边缘网关通常有操作系统(Linux),文件系统可以用 ext4 或者 F2FS,它们对掉电的保护比 FAT32 好。但 STM32 跑 Linux 比较吃力,所以如果坚持用 STM32 + FPGA 架构,还是用 FatFs + 掉电保护策略更实际。
我个人在实际操作中的体会是:分级存储方案的核心不是硬件选型,而是数据分类和事务保护。硬件选型只要遵循“EEPROM 存关键参数、NOR Flash 存运行统计、SD 卡存历史数据”这个原则就不会有大问题。真正花时间的是事务保护的设计和掉电测试,这部分做不好,再好的硬件也白搭。最后再分享一个小技巧:每次修改存储方案后,一定要做至少 100 次掉电测试,而且要用真实的继电器切断电源,不要用软件模拟。我见过太多项目在实验室里跑得好好的,一到现场就丢数据,原因就是掉电测试做得不够。