做工业控制器最头疼的问题之一,就是数据存储。STM32+FPGA 这套组合在电控、仪器、边缘计算设备里太常见了,但“数据怎么存”一直没人系统讲清楚。EEPROM、NOR Flash、SD 卡,三种介质各有脾气,用错了轻则数据丢,重则现场返工。这篇硬件篇第 12 篇,我把实际项目里的分级存储方案完整拆开:什么数据放 EEPROM,什么数据必须进 NOR Flash,什么数据才配用 SD 卡,以及这三者在 STM32+FPGA 架构下怎么分工、驱动怎么调、坑在哪里,一次性讲透。
这套方案适合正在做工业控制器、数据采集终端、或者打算用 STM32 当主控、FPGA 做前端采集的工程师参考。如果你只在单片机里写过几个变量存 Flash,没系统想过存储架构,这篇能帮你把整个数据存储体系理顺。
1. 为什么工业控制器要搞分级存储?——先想清楚再动手
1.1 三种存储介质的本质差异
先说个很多人容易忽略的前提:EEPROM、NOR Flash、SD 卡不是“谁替代谁”的关系,它们的工作原理完全不同,适合干的活也完全不同。
EEPROM(比如 24C02、24C256)是字节级擦写,按字节写入,写一个字节大概要 5ms 到 10ms,寿命在 100 万次擦写左右,容量从几 Kbit 到几 Mbit。它的核心价值是“按字节改、改完就稳”,特别适合存那些改动频率低、但绝对不能丢的参数。我常用一个类比:EEPROM 像个随手记事本,你在上面改一个字,其他字都不受影响。
NOR Flash(比如 W25Q64、W25Q128)是按扇区擦除、按页编程。以 W25Q64 为例,一个扇区 4KB,擦除一次要 400ms 左右,页编程一次最多 256 字节,寿命在 10 万次擦写左右。它跟 EEPROM 的本质区别是:NOR Flash 不能直接改单个字节,必须先把整个扇区擦掉再重写。这就像储物柜,你要换柜子里的一张纸,得先把整个柜子清空再重新摆放。
SD 卡则是块设备,内部有主控做坏块管理和磨损均衡,对外提供扇区读写接口,容量可以做到几十 GB,单次写一个扇区(512 字节)在 SDIO 高速模式下只需几百微秒。它的本质是一个完整的“小型文件系统载体”,适合大量数据按文件组织存储。我把它类比成仓库,东西多、种类杂,但管理靠仓库管理员(SD 卡主控),你只管往里送大件。
| 介质 | 写入基本单位 | 典型写入耗时 | 擦写寿命 | 典型容量 | 适合数据 |
|---|---|---|---|---|---|
| EEPROM | 字节 | 5~10ms/字节 | 约100万次 | Kbit~Mbit | 参数、校准系数 |
| NOR Flash | 扇区擦除+页编程 | 擦除400ms、写页1ms | 约10万次 | 4~128MB | 固件、配置、日志 |
| SD 卡 | 扇区(512B) | 数百μs~ms | 取决于主控 | GB~TB | 海量采集数据 |
1.2 分级存储的分工逻辑
搞清楚了介质差异,分级存储的设计逻辑就自然浮现了:不是“哪个快用哪个”,而是“数据的性质决定它该放哪”。
我把工业控制器的数据分成三层。第一层是设备参数类,比如设备序列号、通信地址、PID 参数、传感器校准系数,这些数据一年可能才写几次,但一旦丢失设备就废了,必须放 EEPROM,因为它字节级写入最可靠,而且抗掉电。第二层是固件和运行配置类,比如固件升级包、用户配置块、最近一小时的操作日志,这些数据需要频繁擦写、单次数据量几百字节到几 MB,适合放 NOR Flash,因为它的随机读速度快、支持 XIP 原地执行,而且坏块管理逻辑比 SD 卡简单可靠得多。第三层是海量采集数据,比如振动波形、温度曲线、高速 ADC 采样流,这些数据量大、以“文件”为单位管理、掉电后允许割尾,必须用 SD 卡。
这个分层逻辑的核心思想是“可靠性与成本匹配”。工业现场最怕的是数据全丢,但全丢的根源往往不是存储介质选了差的,而是把参数和日志混在一个存储区里,频繁擦写把关键参数区搞坏了。分级存储从一开始就把不同性质的数据物理隔离,让每次写入的代价和它保护的数据价值相匹配。
2. 硬件架构:STM32 和 FPGA 各自管什么存储
2.1 存储拓扑方案
工业控制器里 STM32+FPGA 的分工通常是:STM32 跑通信协议栈、控制逻辑、人机交互,FPGA 做高速采集、信号处理、实时控制。那么存储应该挂在哪边?这个问题我在项目里试过两种拓扑,差距很大。
方案一是 STM32 统一管理三种存储,FPGA 通过并行总线(FSMC/FMC)和 STM32 交换数据。STM32 的 I2C 接 EEPROM,SPI 接 NOR Flash,SDIO 接 SD 卡,FPGA 采集完的高速数据先打进 STM32 的 DMA,再由 STM32 决定写哪儿。优点是软件栈集中,存储调度逻辑简单;缺点是高速采集数据过 STM32 会有瓶颈,如果 ADC 采样率超过几 Msps,STM32 的中断和 DMA 压力会非常大,数据容易丢。
方案二是 FPGA 直接接管 SD 卡写入,STM32 只管 EEPROM 和 NOR Flash。FPGA 内部用 FIFO 缓存高速采样流,自己控制 SDIO 或 SPI 接口写 SD 卡,写满一帧就切换缓冲区,完全不占 STM32 的 CPU。STM32 只负责参数存储、固件管理和文件系统的元数据协调。这个方案在高速数据采集场景下非常稳,但 FPGA 里写 SD 卡驱动(尤其是 SDIO 带 FATFS)工作量明显更大。
我的实际选择是混合方案:如果采样率在 1Msps 以下,用方案一,开发快、调试方便;如果采样率在几 Msps 以上,用方案二,让 STM32 专注业务,FPGA 专注吞吐。需要说明的是,SD 卡的 FATFS 文件系统我依然放在 STM32 侧管理,FPGA 只做裸扇区写入,这样避免在 FPGA 里折腾文件系统的复杂度。
2.2 电路设计要点
无论选哪种拓扑,有几个电路细节我必须单独拎出来,这几处翻了车,驱动写再漂亮也白搭。
第一是上拉电阻。I2C 的 SCL、SDA 必须接上拉电阻,典型值 4.7kΩ,如果总线上挂的设备多、线长,要降到 2.2kΩ。SPI 的 MISO 线在高速模式(超过 10MHz)下建议串联 22Ω 电阻,减少振铃。SDIO 的数据线、时钟线同样要串联电阻,我实测在 25MHz SDIO 模式下不加串联电阻,CLK 线上的过冲能超过 3.3V,直接导致读卡不稳定。
第二是 SD 卡的电源和上电时序。SD 卡必须用独立的 3.3V LDO 供电,不能直接从 STM32 的 VCC 拉,因为 SD 卡写入时电流能冲到 100mA 以上,会和主控产生串扰。上电时序上,先给 SD 卡供电,等电源稳定 10ms 之后再去初始化 SDIO,否则会偶发初始化失败。很多新手直接在主控初始化代码里操作 SD 卡,结果低温环境下一半概率找不到卡。
第三是写保护引脚。NOR Flash 的 WP(写保护)引脚不要直接接地,要接上拉电阻再连 STM32 的 GPIO,这样可以在固件里动态控制写保护,防止误擦除。SD 卡的 WP 引脚(卡座上的写保护检测)不是标准的,很多卡座根本没引出,一般的做法是直接把卡的 Data3/CD 脚复用为卡检测,在初始化时读一下电平状态。
第四是 FPGA 的 IO 电平匹配。现在的 STM32 大多是 3.3V IO,FPGA 如果用 2.5V bank,和 STM32 互联时要做电平转换。我的惯例是 FPGA 和 STM32 的所有存储相关信号统一走 3.3V bank,并且 PCB 上让这些信号线在 FPGA 端加 33Ω 串联电阻,防止 FPGA 内部 IO 驱动能力过强导致信号边沿过陡。
3. 驱动层实操:三块存储的读写实现
3.1 EEPROM:I2C 读写与掉电保护
EEPROM 的驱动本身不复杂,但有几个点必须处理到位。以 24C256 为例,I2C 地址是 0xA0(7 位地址 0x50),页大小是 64 字节。写多个字节时不能跨页写,如果数据跨页,必须拆分写入,否则会回卷覆盖同页其他数据。代码上我会这样处理:
uint8_t eeprom_write_bytes(uint16_t addr, uint8_t *buf, uint16_t len) { uint16_t offset = 0; while (offset < len) { uint16_t page_remain = 64 - (addr % 64); // 当前页剩余空间 uint16_t chunk = (len - offset > page_remain) ? page_remain : (len - offset); // 按 16 位地址 + 数据块的方式写入 i2c_start(); i2c_send_byte(0xA0); // 器件地址,写模式 i2c_send_byte((addr >> 8) & 0xFF); i2c_send_byte(addr & 0xFF); for (uint16_t i = 0; i < chunk; i++) { i2c_send_byte(buf[offset + i]); } i2c_stop(); delay_ms(10); // 等待内部写周期完成 addr += chunk; offset += chunk; } return 0; }注意那个 delay_ms(10) 不能省。EEPROM 每次页写最多 64 字节时内部写周期约 5ms,在 5ms 完成前发新的写命令,总线会无响应,这是 I2C 最常见的坑。我见过不少人想当然把延时缩到 1ms 靠轮询 ACK 来提速,结果在工业现场温度偏高时大面积丢数据。EEPROM 写慢就慢点,可靠优先。
掉电保护更关键。我在这块踩过坑:设备突然断电,EEPROM 数据变成全 0xFF 或全 0x00。后来排查发现是写入过程中掉电,EEPROM 内部电荷泵没完成编程。解决方案是双备份,参数区在 EEPROM 里放两份:主区放在前一半,备份区放后一半,每次写入先写备份区并校验,再写主区并校验,上电时比较两个区,如果主区校验失败且备份区校验通过,就用备份区恢复主区。这个策略简单但极其有效。
3.2 NOR Flash:SPI 接口与擦写策略
NOR Flash 驱动核心是三件事:读状态寄存器等待空闲、扇区擦除、页编程。以 W25Q64 为例,写过一次的都知道,擦除前必须先写使能(0x06),否则擦除命令无效。扇区擦除(0x20)后必须轮询状态寄存器的 BUSY 位(bit0),等它变 0 才能写下一页。
uint8_t nor_flash_sector_erase(uint32_t sector_addr) { // 等待上次操作完成 while (nor_read_status() & 0x01); nor_write_enable(); CS_LOW(); spi_send_byte(0x20); // Sector Erase spi_send_byte((sector_addr >> 16) & 0xFF); spi_send_byte((sector_addr >> 8) & 0xFF); spi_send_byte(sector_addr & 0xFF); CS_HIGH(); // 轮询 BUSY,直到擦除完成 while (nor_read_status() & 0x01); return 0; }这里有两个容易忽视的地方。第一,擦除一个 4KB 扇区要 400ms,这段时间内系统不能断电,否则扇区数据会处于未定义状态。所以要有掉电检测中断:检测到主电源掉电瞬间,先用超级电容或大电容的能量,把正在擦写的扇区标记为“擦除未完成”,上电后如果发现这个标记就把整个扇区重擦。第二,页编程最多 256 字节,超了必须分页,否则数据会回卷。
磨损均衡在 NOR Flash 上常被忽略,但日志类的循环写入必须做。我的做法是划分一个日志区,比如 1MB 的空间分成 256 个扇区,写日志时按顺序写扇区,写满一个扇区就移到下一个,整个区域写满后再从第一个扇区开始擦除覆盖。这样每个扇区的擦写次数均匀,不会出现集中在某几个扇区导致提前坏块。实测在 10 万次擦写寿命下,1MB 日志区按 4KB 扇区分,可以支撑接近 2560 万次日志写入,工业设备完全够用。
3.3 SD 卡:SDIO 模式与文件系统接入
SD 卡是三种介质里驱动最重的。我建议直接用 STM32 的 SDIO 外设,别用 SPI 模式。SPI 模式虽然简单,但写入速度只有几百 KB/s,工业上要存高速采样数据完全不够。SDIO 四线模式跑到 25MHz 可以获得 10MB/s 左右的写速度,对于大部分工业采集场景足够。
接口上我直接在 STM32 上挂 FatFS,完整流程是:SDIO 底层驱动初始化 → 卡识别(ACMD41、读取 CSD) → 设置块长度 → 挂载 FatFS → f_open 写文件。关键在写入策略:千万别一个字节一个字节写,要攒缓冲区,按扇区对齐写。我常用 2KB 的缓冲区,攒满一次写 4 个扇区,实测比单扇区写快 3 倍以上,而且对 SD 卡寿命友好。
// 批量写一帧数据到文件 FRESULT write_log_frame(uint32_t frame_id, uint8_t *buf, uint32_t len) { FATFS fs; FIL file; UINT written; f_mount(&fs, "", 1); f_open(&file, "0:/data/log.bin", FA_OPEN_APPEND | FA_WRITE); f_write(&file, &frame_id, 4, &written); // 帧头 f_write(&file, buf, len, &written); // 数据 f_sync(&file); // 刷到物理层 f_close(&file); f_mount(NULL, "", 0); return FR_OK; }注意 f_sync 的调用频率,不用每帧都调,否则性能崩。正确的做法是每写满 4KB 或者积累到一定数据量再 sync 一次,掉电时最多丢失最近未 sync 的 4KB。这里有个行业常见误区:以为用了 FatFS 就安全了,其实 FatFS 为了追求性能,f_write 只是把数据写入缓冲,并没有真正落盘,只有 f_sync 才把数据写到 SD 卡扇区,所以掉电丢失的本质是 “缓冲区没落盘”。如果你的设备允许掉电丢最后一小段数据,那就攒到 4KB 再 sync,这是性能和数据安全的平衡点。
4. 数据分层策略:什么数据放哪里
4.1 第一级:设备参数与校准系数(EEPROM)
EEPROM 区我存五类数据:设备序列号(8 字节)、通信地址和波特率(4 字节)、PID 参数(12 字节)、传感器校准系数(16 字节)、错误标志位(2 字节)。这些数据的共同特征是:写入次数极低、掉电绝对不许丢。
实操上有两个技巧。第一是参数区要建一个结构体,统一序列化后写入。千万别把参数散着存,否则 EEPROM 空间碎片化,后面想加一个参数还要迁移数据。我习惯这样定义一个参数结构体,放在一个公共头文件里:
typedef struct { uint32_t magic; // 魔数,用于校验 0x5A5A0101 uint32_t device_sn; uint8_t comm_addr; uint8_t comm_baud_idx; float pid_kp; float pid_ki; float pid_kd; float sensor_gain; float sensor_offset; uint16_t error_flags; uint16_t crc16; // 结构体整体 CRC } DevParams;写之前先填充结构体,算 CRC16,再整体写进 EEPROM。读出来时先检查 magic 和 CRC16,任何一项不对就认为参数区损坏,启用备份区恢复。第二是写入策略:写参数前先写“正在更新”标志位,全部写完后写“更新完成”标志位,上电后如果发现“正在更新”标志存在而“更新完成”标志不存在,说明写入中途掉电,直接用备份区覆盖主区。这套双标志加双备份的机制,我在十几个项目里验证过,再也没出现过参数区神秘丢失的问题。
4.2 第二级:固件、配置与近期日志(NOR Flash)
NOR Flash 区我存三类:固件备份区(用于 IAP 升级)、系统配置文件、近期运行日志(环形覆盖)。
固件备份区用 A/B 双分区方案。当前固件在 A 区运行,新固件先下载到 B 区,下载完成后校验 CRC,校验通过才把启动标志指向 B 区,然后软复位。如果新固件启动失败,Bootloader 检测到启动标志异常,自动回滚到 A 区。这个方案的本质是:利用 NOR Flash 的可随机读特性做原地校验,比从 SD 卡升级更可靠。
系统配置文件(比如用户设置的操作参数)也放这个区,因为它的数据量比 EEPROM 大(几十 KB),而且希望支持批量更新。这里要注意:配置文件不能放在固件备份区附近,否则升级固件时容易误删。我把一个 8MB 的 W25Q64 按 4MB + 2MB + 2MB 划分,A/B 固件各 4MB,配置区 2MB,日志区 2MB,彼此物理隔离。
日志区用环形覆盖的算法我上文提过,再补充一个细节:日志文件在开机时要检查头部的“当前写位置”指针,如果发现指针异常(比如上次掉电停留在半扇区),就把那个扇区标记为无效并跳过,从下一个扇区开始写。这样日志总是最新的且可解析的,不会因为一个坏扇区让整个日志区读不出来。
4.3 第三级:海量采集数据(SD 卡)
SD 卡上跑的是真正的大数据:振动波形(每通道 16 位 @ 50kHz)、温度趋势(每秒一条)、事件记录(每次告警前后的原始数据)。这些数据按天分文件,文件名规则是“设备号+日期”,例如 DEV01_20250601_A.bin,一天一个文件。
这里重点说一下 FPGA 高速数据怎么落到 SD 卡。我的架构是:FPGA 内部开两个 8KB 的 FIFO,交替缓存,当一个 FIFO 满时通过中断通知 STM32,STM32 把 FIFO 数据搬到一个 4KB 的 DMA 缓冲区,攒满两个 DMA 缓冲区(8KB)后对齐写 SD 卡。这样 STM32 的 CPU 占用率控制在 5% 以内,数据吞吐能达到 4MB/s 左右。如果你用方案二(FPGA 直接写 SD 卡),FPGA 侧就是裸扇区写入,不经过 FatFS,写完一个 4KB 块就往 FAT 表里写一次更新,性能更极限但调试难度翻倍。
数据落盘之后,还少不了一个“文件收割”机制。SD 卡不是无限大,要定期清理老文件,但工业设备不能等 SD 卡满了再处理,所以我会在系统里维护一个“剩余空间阈值”检查,低于 20% 时自动删除 30 天前的文件,并记录一条删除日志到 NOR Flash。这样即使现场长期无人维护,设备也能自己滚动留存数据。
5. 常见问题与排查实录
5.1 EEPROM 写入丢失与 I2C 总线卡死
症状:设备持续运行一段时间后,参数偶尔变 0xFF,或者 I2C 总线上 SDA 一直为低,主控无法再通信。
排查思路:先把 SCL 和 SDA 的波形抓出来看,如果 SDA 被拉死,几乎可以断定是从设备把 SDA 拉住了。最常见的原因是 EEPROM 在写周期内收到了新的写命令,此时它不应答且可能锁住总线。解决有两个方向:I2C 时序严格遵守写周期延时,另外写后立即读 ACK,如果没有 ACK 就发 STOP 条件并复位 I2C 外设。硬件上我建议在 I2C 上加上拉电阻到 VCC,且阻值不要大于 4.7kΩ,否则噪声会把 SDA 拉低到误触发。
关键教训:不要用 I2C 中断驱动去做 EEPROM 写入,除非你非常清楚时序,否则老老实实用阻塞式写入加延时,稳定压倒一切。EEPROM 的价值是可靠性,不是速度。
5.2 NOR Flash 擦写变慢与坏块处理
症状:设备用了一两年后,NOR Flash 擦写明显变慢,写入日志偶尔失败。
原因多半不是芯片坏了,而是你的日志算法没有磨损均衡,导致某一个扇区被反复擦写,先达到了寿命上限。NOR Flash 的擦写次数到了之后不是“立即失效”,而是擦除时间变长、偶尔数据校验失败。
排查方法:读取每个扇区的擦写计数。如果你没有在驱动里存储每个扇区的擦写计数,很可能根本看不出来,所以我建议在扇区的末尾预留 4 个字节存计数,每擦一次自增一次。日志算法上,不要用“从区头写到区尾”的线性模式,改成“随机起始扇区 + 顺序循环”的方式,让磨损分布更均匀。如果一个扇区出现两次写入校验失败,就把它标记为坏块,跳过它继续用后面的扇区。NOR Flash 坏块管理是自己做的,没有 SD 卡主控替你兜底。
5.3 SD 卡掉电文件损坏与读写性能
症状:设备突然断电后,SD 卡上的最近一个文件打不开,或者 f_open 直接返回错误,需要格式化才能恢复。
先说结论:FatFS 本身在掉电时很容易产生文件损坏,因为 FAT 表和数据区的写入不是原子的。靠硬件上电时序做不了完美保护,但可以大幅降低损坏率。我的经验是:重要数据不要以“持续追加单个文件”的形式写,而是采用“固定大小分块文件”策略。具体做法是每写满 8MB 就关闭当前文件,创建一个新文件,这样损坏最多影响一个 8MB 文件,其余的完好。其次,写入过程开启 FatFS 的 f_sync 周期,同时保证掉电检测中断触发时,第一时间把 DMA 缓冲区的数据写完并且 f_sync 一次。
性能问题往往来自 FATFS 的默认配置。STM32 的 FatFS 默认是每写一个扇区都要读取 FAT 表,在小文件连续写入时非常慢。我调过一版配置:开启 FA_SEEKEND、打开 f_fsync 批量刷、提高内部缓冲区到 4KB,实测 16KB 连续块的写入速度从 300KB/s 提升到了 2MB/s 以上,空间占用还更均匀。
5.4 排查速查表
| 现象 | 大概率原因 | 解决方向 |
|---|---|---|
| EEPROM 数据偶尔丢失 | 写入中掉电、跨页写回卷 | 双备份+双标志、拆分页写、写后校验 |
| I2C 总线卡死 SDA 低 | 写周期内重复发命令/上拉过弱 | 延长写周期等待、4.7k 上拉、I2C 外设复位 |
| NOR Flash 擦写慢 | 无磨损均衡、扇区寿命耗尽 | 擦写计数、循环扇区、坏块跳过 |
| 日志区读取乱码 | 擦除时掉电、半扇区残留 | 扇区头写状态标志、上电扫描恢复 |
| SD 卡文件损坏 | 掉电 FAT 表不一致 | 分块文件、f_sync 批量刷、掉电中断处理 |
| SD 卡写入速度低 | FatFS 配置不当/SPI 模式 | 开 SDIO 四线、加大缓冲区、扇区对齐写 |
6. 一点扩展心得
写到这,后面再做存储方案,我的习惯是先画一张“数据流表”:把设备里所有需要存的数据列出来,标上写入频率、数据量、掉电是否允许丢、是否需要掉电保持,然后再去看它该进哪种介质。别一上来就选芯片、写驱动,先把“什么数据放哪”这个逻辑想清楚,后面能省大半调试时间。
另外分享一个我踩过的比较深的坑:早期我以为 FATFS 挂在 SD 卡上就万事大吉,某次在客户现场,设备连续运行 72 小时后,SD 卡目录结构整个崩溃。后来查出来是 FatFS 长时间运行后打开文件句柄泄漏,尤其是在频繁开关文件时容易出现。解决的土办法是每半小时做一次 f_mount 全部卸载再挂载,虽然这个操作耗掉几十毫秒,但能彻底清除文件系统内部状态。如果后续有精力,我会把“存储状态机”和“功耗管理”也单独写一篇,存储在其他项目里拿来直接用。
最后提醒一句:工业存储,永远不要赌运气。该做备份做备份,该加校验加校验,哪怕多占一点容量,也要保证现场哪怕掉电、干扰、异常复位,数据都在。这套分级方案在多个项目里跑了几十万小时的运行记录,希望它也能让你少走几天弯路。