做工业控制器这些年,存储这件事我一直觉得是最容易被低估的一环。很多人一开始觉得“芯片里那点 Flash 够了”,或者干脆外挂一张 SD 卡全丢进去,结果设备跑到现场,不是配置参数丢,就是日志文件损坏,越重要的数据越容易出幺蛾子。我这个 STM32+FPGA 的控制器项目写到硬件篇第 12 期,正好把“数据怎么存”的设计思路完整整理出来:EEPROM 管配置、NOR Flash 管固件和关键备份、SD 卡管海量历史数据。这套方案不是一上来就拍脑袋定的,而是结合介质特性、数据寿命、掉电行为一点点磨出来的,这篇文章把决策过程和踩过的坑都摊开讲。
1. 为什么工业数据存储要做分级?
1.1 先给控制器里的数据分个类
工业控制器里跑的数据,形态差距大到没法用同一种介质去装。我一般先把数据分成四类:配置参数类,比如设备序列号、通信地址、PID 系数、校准值,通常就是几十到几百字节;程序与镜像类,包括 STM32 固件、FPGA 配置文件,几百 KB 到几 MB;过程日志类,比如报警记录、启停事件,每天几 MB 到几十 MB;历史趋势类,例如温度、压力、电流的长时间采样数据,动不动就是 GB 级。
这四类数据的共同点在哪里?都能掉电保存。但差异也很致命:更新频率不同,配置参数可能一个月才改一次,历史趋势每一秒都在写;寿命要求不同,配置参数丢了设备直接瘫痪,历史数据丢了最多查不到曲线;访问模式也不同,配置参数要求随机写、随机读,历史数据的要求是持续追加写入。
如果把代码、配置、日志全部塞进一张 SD 卡,会出现什么局面:SD 卡在频繁写的时候如果掉电,FAT 表损坏,配置和数据一起没了;就算没掉电,卡内部 FTL 磨损均衡对外部不可见,你根本不知道它什么时候把某个逻辑块搬到哪里了,这对实时控制来讲是一个巨大的不确定性。分级存储的核心思路就是一句话:让每种数据住进最适合它的房子,别让高频小数据和高流量大数据挤在一个屋檐下。
1.2 EEPROM、NOR Flash、SD 卡到底差在哪
三种介质常常被混为一谈,因为它们底层都跟浮栅晶体管沾边,但工程属性完全不同。
EEPROM 是我认为最被低估的器件。它支持按字节擦写,普通 Flash 要“先擦后写”它根本不需要,写入寿命普遍标称 100 万次,工业级型号甚至更高。这意味着配置参数可以频繁地改写,不用像 Flash 一样考虑磨损。代价也很明显:容量做不大,市面上主流 AT24C 系列也就是 Kbit 到 Mbit 级别,单价按字节算远高于 Flash,密度一上去成本立刻爆炸。
NOR Flash 的特点是读随机性好、按字节寻址,这对固件存储非常友好。但它写之前必须把目标扇区整体擦除,擦除操作又慢又费寿命,标称通常是 10 万次擦写。所以 NOR Flash 适合放“写得不那么频繁、但内容很关键”的数据,比如固件镜像、FPGA 配置文件、系统级参数快照。
SD 卡的本质是一个带主控的微型闪存设备,内部集成了 FTL 层,坏块管理、磨损均衡都由卡自己完成,用户层的操作很简单,容量也大得夸张。问题在于内部机制完全不透明,掉电到底丢什么、什么时候丢,你只能靠外部策略兜底。我把这三种介质比作三种保存方式:EEPROM 像随身小本子,翻开就能改;NOR Flash 像白板,要写就得先擦掉一块才能画;SD 卡像一个外聘的大仓库管理员,吞吐量很大,但你管不到仓库内部怎么理货。
| 参数 | EEPROM(AT24C256) | NOR Flash(W25Q64) | SD 卡(工业级) |
|---|---|---|---|
| 典型容量 | 32KB | 8MB | 数 GB |
| 最小擦除单位 | 无(逐字节改写) | 4KB 扇区 | 卡内块管理,外部不可见 |
| 标称擦写寿命 | 100 万次 | 10 万次 | 按卡内算法而定 |
| 写速度 | 慢(约 5ms 每页) | 中(页编程约 0.7ms) | 快(SDIO 模式可达数十 MB/s) |
| 掉电风险 | 较低(部分写入需校验) | 中(擦除中掉电损失整扇区) | 高(FAT 表损坏、半块写入) |
| 单位成本 | 高 | 中 | 低 |
1.3 STM32+FPGA 架构里,存储这件事归谁管
在这个架构里,FPGA 通常负责高速实时逻辑:多路 ADC 采集、编码器计数、自定义总线协议,或者把采集到的数据先做一层滤波/缓存,再通过内部 FIFO 或双口 RAM 交给 STM32。STM32 负责跑协议栈、做参数管理、与上位机通信。于是存储管理权天然落在 STM32 这边,因为文件系统、I2C 读写、掉电检测这些任务都适合在 Cortex-M 核上跑。
但有一点必须在硬件设计阶段想清楚:FPGA 把数据推给 STM32 的通道有多宽。假如 FPGA 以 1MSps、16bit 的速率连续采集,裸数据就是 16Mbps,STM32 即便用 DMA 搬运到 SD 卡,中间还要过一轮内存缓冲。这个数据量用 STM32H7 的 SDMMC 写 SD 卡是扛得住的,但如果 FPGA 的通道是 32bit 并行、好几路同时灌,MCU 就变成瓶颈了。那时候就得考虑让 FPGA 直接通过硬件状态机往 NOR Flash 或 SD 卡写数据,STM32 只做策略下发。
我这次方案属于中低速工业控制器,最终选型是 STM32 统一管存储,FPGA 只确认数据缓冲区和握手信号,这样逻辑最简单,后续移植和维护都省事。如果哪天数据吞吐真的上了 100Mbps 量级,我会单独出一篇讲 FPGA 直写 DDR 记录方案,那属于另一个战场。
2. EEPROM:配置参数的最终归宿
2.1 为什么参数存储用 EEPROM 而不是 Flash
项目刚开始时,我也动过“STM32 内部 Flash 模拟 EEPROM”的念头,把一个扇区拿来当配置区。STM32H750 这种型号的内置 Flash 容量本来就不富裕,做固件就占了七七八八,再分出扇区给参数,擦写次数又扛不住。工业设备现场换一台的参数 5 分钟改一次,半年下来就是 4 万多次写入,逼近 Flash 寿命红线。就算舍得用,还得考虑擦除期间中断阻塞的问题,复杂度不比外挂一片 EEPROM 低。
EEPROM 的优势是单独一颗芯片,坏了不影响主控,寿命也宽裕得多。AT24C256 这种 32KB 器件对工业控制器来说容量过剩,我通常只用到一半:真正放配置参数,另外一半做备份区和校验区。预算敏感的场景可以用 AT24C64,16KB 足够。更重要的是 EEPROM 的可读确定性,写入周期和页边界都是明确可测的,不像 SD 卡内部逻辑换来换去。
选型时要注意温度范围和写周期参数。工业级 AT24C256 一般标 -40℃ 到 +85℃,写周期典型值 5ms,最大值 10ms。低温环境下写周期会有劣化,驱动层不能按典型值去卡时间,宁可多等几个毫秒。我习惯在驱动里加一个“准备完成”轮询,伪代码如下:
// 读 EEPROM ACK 判断内部写周期是否结束 void eeprom_wait_ready(uint16_t dev_addr) { uint8_t dummy = 0; I2C_Status st; do { st = i2c_master_transmit(dev_addr, &dummy, 1, 10); } while (st != I2C_OK); }这个轮询实际上是在发一个“写命令头”但是不带数据,如果器件还在内部编程周期,SDA 会保持非 ACK,直到编程完成才重新响应。
2.2 页写边界:最容易踩的驱动坑
AT24C256 的页大小是 64 字节。最关键的一个硬件行为是:一次页写如果越过当前页末尾,地址计数器会绕回同一页的开头,把开头的数据覆盖掉。你要是连续写 100 字节,从页中间某个地址开始,后半段数据就会莫名其妙覆盖到本页开头。这个问题我看过太多新手踩坑,表现是配置参数里某个字段总是“灵异”变化。
正确的驱动写法是在写入前做页边界拆分。
uint8_t eeprom_write_block(uint16_t dev_addr, uint16_t mem_addr, const uint8_t *buf, uint16_t len) { while (len > 0) { uint16_t page_offset = mem_addr % 64; uint16_t page_left = 64 - page_offset; uint16_t chunk = (len < page_left) ? len : page_left; // 发送器件地址 + 内存地址 + chunk 字节 i2c_start(dev_addr << 1 | I2C_WRITE); i2c_write_byte((mem_addr >> 8) & 0xFF); i2c_write_byte(mem_addr & 0xFF); for (uint16_t i = 0; i < chunk; i++) { i2c_write_byte(buf[i]); } i2c_stop(); eeprom_wait_ready(dev_addr); mem_addr += chunk; buf += chunk; len -= chunk; } return EEPROM_OK; }这段代码在每写完一小块之后,都要等 EEPROM 内部写周期结束再切下一页。STM32 的 I2C 外设如果配置成 400kHz 快速模式,在连续写大块参数时很容易把 EEPROM 写“飞起来”——其实不是器件飞了,是驱动没等 ACK。所以务必在每次 stop 之后调用 eeprom_wait_ready。
还有个细节:地址变量用 uint16_t,因为 AT24C256 需要 2 字节地址。如果项目之前做过 AT24C02(1 字节地址)你直接搬代码,地址会错位,数据写到奇怪的地方去。
2.3 寿命不够用怎么办:双备份与轮转映射
虽然 EEPROM 标称 100 万次,但工业现场苛刻起来真的会有高频参数写入场景。比如伺服调机时上位机每秒下一次目标值,如果每次写,一天就是 86400 次,照这样算 12 天就能把一片 EEPROM 写坏。我在项目里做了两个机制,实测把写入寿命放大了至少一个数量级。
机制一:写入前比较。把准备写入的数据先读出来,跟新数据按字节比对,完全相同直接跳过写操作。这个策略能挡掉绝大多数“参数没变但上位机一直在下发”的场景。比较操作本身是读 EEPROM,读不消耗写寿命,成本低到可以忽略。
机制二:区域轮转。把地址空间切成 4 个逻辑块,每块开头存一个序列号。写入时先读 4 个块头部的序列号,找到最大号的那块,往下一块写,写完更新下一块的序列号。读取时取序列号最大的那块。这样把 100 万次寿命平均分散到 4 个区域,配合写入前比较,设备跑个十年毫无压力。
#define EEPROM_BLOCK_NUM 4 #define EEPROM_BLOCK_SIZE 0x1000 uint32_t eeprom_read_seq(uint8_t block) { uint32_t seq = 0; eeprom_read_block(EEPROM_ADDR, block * EEPROM_BLOCK_SIZE, (uint8_t*)&seq, 4); return seq; } uint16_t eeprom_find_latest_block(void) { uint32_t max_seq = 0, min_seq = 0xFFFFFFFF; // 找出最大和最小序列号 // 写入目标 = (max 所在块 + 1) % 4,写前先把该区域整体置 0xFF 再写 }这个方案需要注意:轮转块不能正好擦除到一半掉电,所以每个块头部还要放 CRC。读的时候如果当前最大序列号的块 CRC 校验失败,就回退到第二大的块。掉电保护的原则在后文还会反复出现:任何存储介质都可能掉电丢数据,必须从读取端做校验兜底。
3. NOR Flash:固件与备份日志的管家
3.1 NOR Flash 的“擦-写”模型
NOR Flash 用在我这个项目里主要是两大块:FPGA 配置文件和系统日志。选型是 W25Q64,8MB 容量,SPI 接口,4KB 扇区,一个 4KB 扇区的擦除时间典型值 45ms,最大可以到 400ms。这个时间在实际工程里非常关键——绝对不能在中断服务函数里擦 NOR Flash,45ms 的中断阻塞意味着实时性崩盘。
写 NOR Flash 分两步:先擦除,后编程。擦除是把整个扇区所有位写成 0xFF,编程只能把 1 写成 0。所以一个扇区如果要更新 10 个字节,通常整扇区擦掉重写。W25Q64 的页编程单位是 256 字节,一页的编程典型时间约 0.7ms。
uint8_t nor_flash_erase_sector(uint32_t sector_base) { nor_flash_write_enable(); // 发送 0x06 nor_flash_send_cmd(0x20, sector_base); // Sector Erase while (nor_flash_read_status() & 0x01) {;} // BUSY 位等待 nor_flash_write_disable(); return OK; }很多资料会把 erase 之后的等待用一个固定 delay 代替,我不是很推荐。温度、电压波动都会影响擦除时间,固定延时要么太短读出来全是脏数据,要么太长浪费启动时间。轮询状态寄存器最稳妥。
3.2 FPGA 配置与系统固件怎么放
使用惯例是:NOR Flash 的地址空间从 0 开始划分。我这边的 8MB 分区表如下:
| 起始地址 | 大小 | 内容 |
|---|---|---|
| 0x000000 | 2MB | FPGA 配置文件(bitstream) |
| 0x200000 | 1MB | STM32 固件 A 区 |
| 0x300000 | 1MB | STM32 固件 B 区(备份升级) |
| 0x400000 | 1MB | 参数/出厂校准快照 |
| 0x500000 | 3MB | 环形日志区 |
FPGA 上电时通过 SPI Slave Select MAP 模式从 0 地址加载 bitstream,STM32 也通过 SPI 访问同一颗 Flash。这里有一个大坑:两个主机共享一颗 SPI Flash,必须做好总线仲裁。我的做法是加一个硬件互斥信号:STM32 在访问 Flash 之前,往 FPGA 拉一个 GPIO,FPGA 看到这个信号后立刻让出 SPI 总线,然后把片选信号释放;STM32 完成操作后释放 GPIO。如果 FPGA 正在上电配置阶段,STM32 千万不能碰 Flash,否则 bitstream 加载到一半被改写或干扰,FPGA 可能进入未知状态。
固件升级采用 A/B 双区:新固件先写入 B 区,写入完成后更新 A 区的启动标志并校验 CRC。启动时 bootloader 检查 A 区和 B 区的 CRC,哪个有效启动哪个。这样固件升级中断电也不会变砖。很多工业设备的升级失败率居高不下,就是因为只有单区固件,升级过程中断电就直接报废,只能返厂刷机。
3.3 环形日志区的磨损均衡设计
NOR Flash 的 10 万次擦写寿命单看挺多,但如果日志老是写同一个扇区,几个月就报废。我用的是经典的环形日志策略:把 3MB 日志区分成 768 个扇区,维护一个“当前写扇区”索引,写满一个扇区就递增到下一个,全部用完了从头开始覆盖最老的扇区。
索引存储在设计上要特别小心。我把它放在日志区的头部和尾部各写一份,每次更新索引前先写一份,确认成功后写第二份。读取时两个索引都读出来,比较版本号,取版本较新且 CRC 通过的那个。这样掉电时最多损失一条索引记录,不会把整个日志区搞乱。
磨损均衡的真实收益:768 个扇区平摊 10 万次擦写,总共约 7680 万次,按每天写满 100 个扇区日志算,能用 2100 天。如果设备日志量少,实际寿命是设备本身的十几倍。最关键的是日志数据允许丢失和覆盖,对一致性要求不高,环形策略非常适合。
4. SD 卡:历史数据的托盘
4.1 SDIO 还是 SPI,以及初始化时序
SD 卡是本方案里容量最大也是最难伺候的介质。STM32H750 的 SDMMC 外设支持 SDIO 协议,实测读写在 20MB/s 以上,写日志足够用。但 SDMMC 模式下初始化时序依然要遵守底层的 SD 协议,并不是拉了根线就能用。
SD 卡上电后必须经过一个初始化序列:卡电源稳定后,主机要先发送至少74 个时钟周期,让卡内部完成上电状态机,然后再发 CMD0 让卡进入 SPI 模式或识别模式,接着发 CMD8、CMD55 + ACMD41 握手。很多初始化失败案例都是因为时序太紧或者卡槽供电能力不足。用杜邦线连接 SD 卡座时,线长超过 10cm 就可能出现初始化时好时坏的问题,尤其要注意 SD 卡的 VDD 引脚要加 10μF 级去耦电容,别指望主板的 3.3V 干净到哪去。
还有个细节:工业级 SD 卡和消费级 SD 卡差价不小,但不要省。现场环境明明是 70℃ 高温运转,消费卡写几个月后就开始丢块,数据读出来是坏的,那才叫真正的费用爆炸。
4.2 FATFS 和裸扇区怎么选
SD 卡存历史数据,最常见的思路是用 FATFS。好处是数据导出来直接插电脑就能读,缺点是文件系统层在掉电时非常脆弱。我在这项目里做了个折中:
- 低频事件类数据(报警、启停、操作记录)用 FATFS 写文件,因为这类数据量小,每次写完后立刻 f_sync,掉电最多丢最后一条记录。
- 高频连续趋势数据(1 秒 1 条以上)不经过文件系统,直接按扇区顺序写裸数据块,每块写满后打上时间戳和序号。
- 每次开机时创建一个新的趋势文件,用“整文件落盘”方式写。
FATFS 有个必踩坑是f_write 返回成功不等于数据已经物理写入 SD 卡。MCU 侧 DMA 把数据搬给 SD 卡,SD 卡内部还有缓存。必须在关键节点调用 f_sync 甚至 f_mount 重新挂载来确认。工业现场断电频繁,强行断电十次,FAT 表损坏的概率非常高。所以只要日志不是特别金贵,我宁可文件写坏的接受度放宽,也不能让文件系统把整张卡锁死。所有重要数据在写入时都带 CRC,读取时发现 CRC 不对就跳过这一条,而不是把整个日志文件标记为损坏。
4.3 掉电保护:工业现场最残酷的一课
SD 卡掉电后果有两个层面。第一层是 FAT 表损坏,整张卡在电脑上无法识别;第二层是正在写的块写入一半,数据逻辑上断裂,可能把后续数据点关联错。
我的处理方案从硬件和软件两头入手:
硬件上,系统要有掉电检测。STM32 用一个 GPIO 接电源监控芯片,当 24V 输入掉到阈值以下,系统进入“保存模式”:立即关闭中断、停止新的采集任务、把 SD 卡当前写缓冲的数据和元数据 flush 掉、关 SD 卡电源。这里要注意掉电检测必须在主电源真正跌落之前触发,也就是说掉电检测电压阈值要比 LDO 的输出跌落早 50ms 以上。我实际用的是 TPS3808,最小复位延时 1.25ms 到 10ms 可调,再配合大电容储能,足够把最后一笔数据落盘。
软件上,设计“logical 结束标记”。每一条趋势数据记录写完以后,再写一个 4 字节的 CRC 和结束标志。当程序下次开机扫描时,遇到结束标志不完整或 CRC 失败,就知道这是最后一次掉电留下的残片,直接跳过。这个方法简单有效,不依赖文件系统也能保证数据可恢复。
typedef struct { uint32_t seq; uint64_t timestamp; float ch1; float ch2; uint16_t crc16; uint16_t end_flag; // 固定 0xA55A } trend_record_t;CRC16 和 end_flag 放在记录尾部,读取端检查这两个字段就知道这一条是否完整。实际测试下来,这条策略让 SD 卡中的历史数据在反复断电后依然能够完整拼接,除了最后一条大概率丢失,前面所有数据都还是可用的。
5. 掉电保护与实战排查实录
5.1 常见问题速查表
| 现象 | 大概率原因 | 排查思路 |
|---|---|---|
| EEPROM 写不进 / 读出全 FF | I2C 地址错、写保护 WP 脚被拉高 | 示波器抓 SDA/SCL 波形,检查 WP 引脚默认电平 |
| EEPROM 连续写的数据被覆盖 | 页写越过 64 字节边界 | 按页边界拆分写入 |
| EEPROM 数据偶发丢失 | 掉电瞬间正在写,电荷泵供电不足 | 加入掉电检测,断电时停止写 EEPROM;配置区做双备份 |
| NOR Flash 写完读出不对 | 写之前没擦除 / 擦除未完成就写程序 | 先擦后写,擦除后轮询状态寄存器 |
| FPGA 上电配置失败 | NOR Flash 总线被 STM32 占用或者读出的 bitstream 不全 | 硬件互锁信号 + 配置完成握手 |
| SD 卡初始化有时成功有时失败 | 上电时序不足 / SDIO 线太长 / 供电电压过低 | 增加 74 个时钟,缩短走线,检查 VDD 去耦电容 |
| FATFS 挂载失败 | 上一次运行掉电导致 FAT 表损坏 | 定期 f_sync,关键文件用临时文件+改名,FATFS _FS_READONLY 等配置按需打开 |
| 历史趋势数据中间出现日期跳变 | 时序里的 timestamp 来源不统一 | 统一用 RTC + 开机校时,写入前做时间戳单调递增校验 |
5.2 一个排查实录:SD 卡频繁挂载失败的真相
有一次测试跑了一周,反馈说设备上的 SD 卡打不开,插电脑上提示“需要格式化”。我先怀疑是卡本身质量问题,换了一张工业卡之后照样在一周内坏掉,于是觉得卡端策略有问题。排查过程很有意思:挂上逻辑分析仪抓 FATFS 日志区的写入时序,发现频繁写入的文件都是通过 f_open 和 f_close 反复开关句柄,而 FATFS 默认在 f_close 时会 flush 一次。正常情况下这没问题,但工业卡在间歇性写入时,热损耗导致卡内温度过高,主控发生异常。
又往下查,发现因为用户操作频繁,程序里每次收到上位机指令都会立刻往 SD 卡打开一个日志文件,写入然后关闭。这样一天下来开关文件几百次,FATFS 表区域被反复改写,更要命的是掉电瞬间正落在表更新窗口里。但后来真正让我改变设计的是另一次失误:SD 卡座旁边有一个电解电容靠近电源,测试时碰了一下辅助电源的输入,MCU 先掉电,SD 卡的 VDD 由大电容维持着继续供电,逻辑分析仪显示 SD 卡在 MCU 掉电后还在发 CMD,卡内主控不知所措,来回切换状态,最终把一块正在写的数据搞成坏块。
解决方式是一套组合拳:
- 所有关键日志从“频繁开关文件”改成“常开句柄 + 定时 f_sync”。
- 掉电检测响应后,系统立刻屏蔽 SD 卡写入,把数据维持到 RAM 缓冲,等下次上电补写。
- 硬件上把 SD 卡电源和 MCU 电源统一由同一个 LDO 输出,保证掉电时序同步,不再出现 SD 卡比 MCU 活得久的情况。
- 升级为环形文件方案:日志按小时分包,旧文件超过一定数量自动删除,避免 FAT 表无限膨胀。
这套改动之后,同一个测试场景一个多月没有再出现挂载失败。所以说到底,存储方案不仅仅是驱动怎么写,还牵涉到掉电检测、电源树设计、文件系统策略几个层面,少一环都可能翻车。
5.3 设计初期就定好存储策略表
最后想分享一件做硬件设计时最有价值的事:在原理图出来之前,先写一张存储策略表。表格里列清楚:数据项名称、数据类型、运行时的更新频率、掉电之后需要保留多久、容量估算、不能丢的程度评级。基于这张表再决定哪些数据走 EEPROM、哪些走 NOR Flash、哪些走 SD 卡。如果一开始就把存储当作“一个 SD 卡接口 + 一片 Flash”来规划,后面所有的问题都会变成补丁,项目越做越难受。
我个人的习惯是每次评审硬件时,都带着存储策略表逐项过:这个数据现在放哪,容量够不够,寿命多久,掉电会不会有不可接受的风险。这次做 STM32+FPGA 控制器最深刻的体会就是,分级存储真正的价值在于把“可靠性”拆解成了每一级自己该扛的责任:EEPROM 扛住参数,NOR Flash 扛住固件和关键日志,SD 卡扛住可再生的历史数据。每一级都只需要面对自己最擅长的那类读写模式,整体可靠性反而比“一张卡全搞定”高得多。