1. 工业控制器存储架构的整体设计思路
工业控制器和消费类电子产品最大的区别在于:它可能要在无人值守的配电柜里连续跑十年,断电重启是家常便饭,现场电磁环境恶劣,而且一旦参数丢失或日志断档,排查成本极高。所以存储方案的设计目标从来不是"能存就行",而是分级可靠、各司其职、掉电不丢关键数据。
STM32 加 FPGA 这个组合在工业控制器里非常常见:STM32 负责业务逻辑、通信协议栈、人机交互;FPGA 负责高速采集、多路并行时序、实时信号处理。两者各自有存储需求,又需要共享一部分数据,这就决定了存储不能只挂一颗芯片了事,而要按"访问频率、数据量、掉电敏感性、写入寿命"四个维度做分级。
我一般把工业控制器里的数据分成四类,对应四种存储介质:
| 数据类别 | 典型内容 | 数据量 | 写入频率 | 掉电要求 | 推荐介质 |
|---|---|---|---|---|---|
| 配置参数 | 校准系数、设备ID、通信地址、量程 | 几十字节~几KB | 极低(出厂/维护时) | 绝对不能丢 | EEPROM |
| 关键日志 | 故障记录、动作事件、运行统计 | 几十KB~几MB | 中低频 | 不能丢 | NOR Flash |
| 大批量数据 | 波形采样、图像帧、历史曲线 | 几十MB~GB | 高频 | 可容忍少量丢失 | SD 卡 |
| 运行缓存 | 实时变量、DMA 缓冲 | 与 RAM 同量级 | 极高 | 无需掉电保持 | SRAM/DDR |
这个分级逻辑背后的核心考量是写入寿命和访问粒度的匹配。EEPROM 可以按字节擦写,寿命通常在百万次级别,适合存那种"一年改不了几次但一次都不能错"的参数;NOR Flash 按扇区擦除,寿命十万次量级,支持 XIP 和随机读,适合存结构化的日志和固件;SD 卡容量大、单位成本低,但擦写寿命和掉电一致性都比较脆弱,只适合存"丢了也能接受"的大数据。
注意:很多新手会把所有数据一股脑塞进 SD 卡,觉得容量大省事。工业现场一次意外断电就可能让 FAT 文件系统直接损坏,整卡数据全废。这个坑我见过太多次了。
STM32 和 FPGA 的分工也要在存储设计里体现出来。STM32 侧通常通过 I2C 挂 EEPROM、通过 SPI 挂 NOR Flash、通过 SDIO 挂 SD 卡,这三条总线在 STM32 上都有成熟的 HAL 库支持。FPGA 侧如果要独立存数据,一般用 SPI 访问 NOR Flash,或者通过并行总线直接读写 SRAM,再或者把数据通过 FMC/FSMC 交给 STM32 统一落盘。具体选哪种,取决于数据产生的速率和实时性要求。
2. 三种存储介质的核心原理与选型依据
2.1 EEPROM:为什么参数区非它不可
EEPROM 的全称是电可擦除可编程只读存储器,它的存储单元是浮栅 MOS 管。写数据时通过隧穿效应把电子注入浮栅,擦除时把电子抽走,靠浮栅上电荷的有无来表示 0 和 1。这个物理机制决定了它两个关键特性:按字节寻址、可反复擦写。
工业上常用的 EEPROM 型号有 AT24C 系列(I2C 接口)、93C 系列(Microwire 接口)、25 系列(SPI 接口)。AT24C02 是 2Kbit(256 字节),AT24C256 是 256Kbit(32KB),基本覆盖了参数存储的需求。I2C 接口只需要两根线,STM32 的硬件 I2C 或者软件模拟都能驱动,布线简单,这是它被广泛采用的原因。
选 EEPROM 存参数,核心看三点:
- 字节级擦写:改一个参数不用擦整个扇区,不会影响旁边的数据。
- 寿命足够:100 万次擦写寿命,按每天改 10 次算能用 270 年,实际项目根本用不完。
- 掉电保持:数据保存 40 年以上,工业设备的生命周期完全覆盖。
但 EEPROM 也有明显的短板:容量小、写入速度慢(I2C 100kHz 下写一页要几毫秒)、单位成本高。所以它只适合存参数,绝不能拿来存日志或大数据。
2.2 NOR Flash:日志和固件的可靠载体
NOR Flash 的存储单元结构和 EEPROM 类似,也是浮栅,但它的读取方式和 EEPROM 完全不同——支持随机访问,可以像读 RAM 一样按地址读任意字节,这就是所谓的 XIP(eXecute In Place)能力。很多 STM32 项目把程序直接放在外部 NOR Flash 里跑,就是利用这个特性。
NOR Flash 的写入规则和 EEPROM 差别很大,这是新手最容易踩坑的地方:
- 必须先擦后写:写入只能把 1 变成 0,要把 0 变回 1 必须擦除。
- 擦除按扇区:最小擦除单位通常是 4KB 或 64KB,不能按字节擦。
- 擦除寿命有限:一般 10 万次,比 EEPROM 低一个数量级。
工业控制器里用 NOR Flash 存日志,通常的做法是环形日志 + 扇区轮转。把 Flash 分成若干个扇区,每个扇区写满后擦除最老的扇区继续写,这样能把擦写次数均匀分摊到所有扇区上,避免某个扇区被写穿。W25Q 系列(W25Q64、W25Q128)是工业项目里最常见的 SPI NOR Flash,容量从 8Mbit 到 128Mbit 都有,价格便宜,驱动成熟。
2.3 SD 卡:大容量数据的性价比之选
SD 卡内部其实是 NAND Flash 加一个控制器,控制器负责坏块管理、磨损均衡、ECC 纠错。对主控来说,SD 卡就是一个块设备,通过 SDIO 或 SPI 接口按扇区(512 字节)读写。
SD 卡的优势很直接:容量大(几 GB 到几十 GB)、单位容量成本极低、可插拔更换。但它的坑也最多:
- 掉电一致性差:写操作进行到一半掉电,FAT 表可能损坏,整卡数据读不出来。
- 写入寿命不透明:消费级 SD 卡用的是 TLC 甚至 QLC 颗粒,擦写寿命可能只有几百次,工业级 SLC 卡能到几万次,价格差好几倍。
- 控制器行为不可控:卡内部什么时候做垃圾回收、什么时候搬数据,主控完全不知道,可能导致写入延迟突然飙高。
所以 SD 卡在工业控制器里的定位很明确:存那些丢了不影响设备运行的大数据,比如历史曲线、波形记录、图像帧。关键参数和故障日志绝对不能只存 SD 卡。
实操心得:如果项目必须用 SD 卡存关键数据,一定要做双备份——SD 卡存一份,NOR Flash 再存一份精简版。我做过一个项目,现场电网波动频繁,SD 卡平均三个月坏一张,后来加了 NOR Flash 备份才稳住。
3. STM32 侧存储驱动的实操实现
3.1 I2C 读写 EEPROM 的完整流程
STM32 驱动 AT24C 系列 EEPROM,硬件上 SCL、SDA 各接一个 4.7k 上拉电阻到 3.3V,A0/A1/A2 决定器件地址。以 AT24C02 为例,7 位地址是 1010 加 A2A1A0,如果三个脚都接地,地址就是 0x50。
写一个字节的时序是:起始条件 → 发送设备地址+写位 → 等待 ACK → 发送字地址 → 等待 ACK → 发送数据 → 等待 ACK → 停止条件。写完一个字节后 EEPROM 需要 5ms 左右的内部门写周期,这期间它不会响应任何命令,所以连续写多个字节时必须等这个周期结束。
用 HAL 库写的话,核心代码大概是这样:
#define EEPROM_ADDR 0xA0 // 8位地址形式,HAL库左移后使用 HAL_StatusTypeDef EEPROM_WriteByte(uint16_t memAddr, uint8_t data) { uint8_t buf[3]; buf[0] = (uint8_t)(memAddr >> 8); // 高地址(大容量型号需要) buf[1] = (uint8_t)(memAddr & 0xFF); buf[2] = data; // AT24C02 地址是单字节,这里按单字节处理 return HAL_I2C_Mem_Write(&hi2c1, EEPROM_ADDR, memAddr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); }页写能显著提升效率。AT24C02 的页大小是 8 字节,AT24C256 是 64 字节。页写时要注意不能跨页,如果起始地址在页尾,写到页边界就必须停下来,否则会回卷覆盖页首数据。这个坑我在早期项目里踩过,写 16 个字节的参数,起始地址是 0x0A,结果后 6 个字节把 0x08 开始的数据覆盖了,排查了半天。
读操作相对简单,支持当前地址读、随机读、顺序读。顺序读可以一次把整个参数区读出来,效率最高。
3.2 SPI 驱动 NOR Flash 的关键细节
W25Q 系列 NOR Flash 的 SPI 命令集是标准化的,常用命令有:
| 命令 | 编码 | 功能 |
|---|---|---|
| Write Enable | 0x06 | 写使能,每次写/擦前必须发 |
| Read Status | 0x05 | 读状态寄存器,判断忙闲 |
| Read Data | 0x03 | 读数据 |
| Page Program | 0x02 | 页编程,一页 256 字节 |
| Sector Erase | 0x20 | 擦除 4KB 扇区 |
| Block Erase | 0xD8 | 擦除 64KB 块 |
| Chip Erase | 0xC7 | 全片擦除 |
写数据的标准流程是:发写使能 → 发页编程命令+地址+数据 → 轮询状态寄存器等待 BUSY 位清零。擦除流程类似,只是把页编程换成扇区擦除。
这里有个容易被忽略的点:页编程不能跨页。一页 256 字节,如果起始地址是 0xF0,最多只能写 16 字节,写到 0x100 就会回卷到页首。所以写大块数据时,驱动层必须做地址对齐和分页处理。
void NOR_WriteBuffer(uint32_t addr, uint8_t *buf, uint32_t len) { while (len > 0) { uint32_t pageRemain = 256 - (addr % 256); uint32_t writeLen = (len < pageRemain) ? len : pageRemain; NOR_WriteEnable(); NOR_PageProgram(addr, buf, writeLen); NOR_WaitBusy(); addr += writeLen; buf += writeLen; len -= writeLen; } }擦除操作要特别注意:擦除前必须确认该扇区的数据已经不需要了。NOR Flash 擦除是按扇区的,一旦擦掉整个 4KB 都没了。做日志系统时,我通常会在每个扇区头部放一个魔数和序号,擦除前先校验,避免误擦有效数据。
3.3 SD 卡在 STM32 上的挂载与文件操作
STM32 挂 SD 卡有两条路:SDIO 接口和 SPI 接口。SDIO 速度快(4 位模式下能到 20MB/s 以上),但引脚多、时序要求高;SPI 慢(通常 1~2MB/s),但引脚少、兼容性好。工业控制器如果只是偶尔存点日志,SPI 模式完全够用;如果要存波形或图像,必须上 SDIO。
用 FatFs 文件系统的话,STM32CubeMX 可以直接勾选 FatFs 中间件,生成初始化代码。挂载流程是:初始化 SDIO/SPI → 初始化 FatFs → f_mount 挂载 → 打开文件读写。
FATFS fs; FIL fil; FRESULT res; UINT bw; res = f_mount(&fs, "0:", 1); if (res != FR_OK) { /* 挂载失败处理 */ } res = f_open(&fil, "0:/log.txt", FA_OPEN_ALWAYS | FA_WRITE); if (res == FR_OK) { f_lseek(&fil, f_size(&fil)); // 定位到文件末尾追加 f_write(&fil, "event\r\n", 7, &bw); f_close(&fil); }SD 卡最大的风险是掉电导致文件系统损坏。缓解办法有几个:一是定期 f_sync,把缓存刷到卡上;二是用追加模式写日志,避免频繁修改 FAT 表;三是关键数据双写,SD 卡和 NOR Flash 各存一份。
注意:SD 卡热插拔时一定要先 f_mount(NULL, "0:", 0) 卸载,再断电拔卡。直接拔卡可能导致卡内数据损坏,尤其是正在写的时候。
4. FPGA 侧存储访问与双芯片协同
4.1 FPGA 访问 NOR Flash 的 Verilog 实现
FPGA 访问 SPI NOR Flash,本质是用 Verilog 实现一个 SPI 主机状态机。核心模块包括时钟分频、状态控制、移位寄存器、片选控制。以读数据为例,状态机流程是:拉低 CS → 发送 0x03 → 发送 24 位地址 → 连续接收数据 → 拉高 CS。
// SPI 读 NOR Flash 状态机核心片段 localparam IDLE = 3'd0, CMD = 3'd1, ADDR = 3'd2, DATA = 3'd3, DONE = 3'd4; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; cs_n <= 1'b1; end else begin case (state) IDLE: if (start) begin cs_n <= 1'b0; shift <= {8'h03, addr}; // 命令+地址共32位 state <= CMD; end CMD: begin if (bit_cnt == 5'd31) begin state <= DATA; bit_cnt <= 0; end else begin sck <= ~sck; if (sck) shift <= {shift[30:0], 1'b0}; bit_cnt <= bit_cnt + 1; end end DATA: begin // 接收数据位,每8位拼成一个字节写入FIFO end endcase end endFPGA 侧做存储访问,最大的优势是并行和确定性。STM32 读 Flash 要经过指令周期、总线仲裁,延迟不确定;FPGA 可以用状态机精确控制每个时钟沿,采集数据的同时把数据流直接写进 Flash,中间不经过 CPU,实时性极好。
4.2 STM32 与 FPGA 的数据交换与落盘分工
STM32 和 FPGA 之间通常用 FSMC/FMC 并行总线、SPI、或者双口 RAM 交换数据。存储分工上,我一般这样设计:
- FPGA 负责采集和预处理:高速 ADC 采到的数据先在 FPGA 内部做滤波、抽取、打包,降低数据率。
- STM32 负责存储管理:FPGA 把打包好的数据通过 FMC 或 SPI 传给 STM32,STM32 决定存哪里——关键事件写 NOR Flash,大批量数据写 SD 卡。
- 共享参数区:校准系数、量程这些参数存在 EEPROM 里,STM32 启动时读出来,通过寄存器或双口 RAM 同步给 FPGA。
这样分工的好处是各用所长:FPGA 做它擅长的实时并行处理,STM32 做它擅长的文件系统和协议管理。如果让 FPGA 直接管 SD 卡文件系统,那简直是自找麻烦——FAT 文件系统的复杂度用 Verilog 实现,调试成本高到离谱。
4.3 掉电保护与数据一致性设计
工业现场掉电是常态,存储设计必须考虑掉电瞬间的数据完整性。我的做法是三层保护:
第一层:硬件掉电检测。用一个比较器监测电源电压,电压跌到阈值以下时产生中断,STM32 在中断里紧急保存关键数据。这需要一个大电容维持几百毫秒的供电,让保存操作能完成。
第二层:软件双备份。关键参数在 EEPROM 里存两份,一份是当前值,一份是备份值,每份带 CRC 校验。读取时两份都校验,哪份对用哪份,两份都对取当前值,两份都错用默认值。
第三层:日志原子写。NOR Flash 写日志时,先在扇区头部写"写入中"标志,数据写完后再改成"写入完成"。上电时扫描所有扇区,发现"写入中"标志就说明上次掉电了,该条日志作废,从下一条继续。
typedef struct { uint32_t magic; // 0x55AA55AA 表示有效 uint32_t seq; // 序号,用于判断新旧 uint16_t len; // 数据长度 uint16_t crc; // 数据CRC uint8_t data[64]; } LogEntry; // 写入流程 void Log_Write(LogEntry *entry) { entry->magic = 0x00000000; // 先清magic,表示写入中 NOR_Write(addr, (uint8_t*)entry, sizeof(LogEntry)); entry->magic = 0x55AA55AA; // 再写magic,表示完成 NOR_Write(addr, (uint8_t*)&entry->magic, 4); }这个"先清标志再写数据最后置标志"的顺序很关键。如果反过来先置标志再写数据,掉电后标志是有效的但数据是残缺的,上电校验 CRC 才发现问题,但已经浪费了一次判断。先清标志的话,上电看到标志无效直接跳过,逻辑更干净。
5. 常见问题排查与避坑经验实录
5.1 存储相关故障速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| EEPROM 读全 0xFF | 器件未应答/地址错 | 用逻辑分析仪抓 I2C 波形 | 检查 A0-A2 地址脚和上拉电阻 |
| EEPROM 写后读不一致 | 未等写周期结束 | 读状态轮询或延时 5ms | 每次写后加延时或 ACK 轮询 |
| NOR Flash 读 ID 错误 | SPI 模式不对/CS 时序 | 读 JEDEC ID(0x9F)验证 | 检查 CPOL/CPHA 和片选建立时间 |
| NOR Flash 写不进 | 忘记写使能 | 读状态寄存器看 WEL 位 | 每次写擦前发 0x06 |
| SD 卡挂载失败 | 卡未初始化/格式不对 | 用 f_mount 返回值判断 | 格式化为 FAT32,重新初始化 |
| SD 卡写文件损坏 | 掉电时正在写 FAT | 检查文件系统错误 | 定期 f_sync,关键数据双写 |
| 数据 CRC 校验失败 | 掉电写入不完整 | 检查日志标志位 | 用原子写+双备份机制 |
5.2 几个我踩过的坑
坑一:I2C 上拉电阻选错。有次用 10k 上拉,100kHz 下波形上升沿太缓,EEPROM 偶尔不应答。换成 4.7k 后稳定。上拉电阻的计算要考虑总线电容,线长超过 20cm 时建议用 2.2k。
坑二:NOR Flash 扇区边界没对齐。日志系统按固定长度写,结果某条日志跨了两个扇区,擦除时只擦了一个扇区,数据就乱了。后来改成每条日志固定占一个扇区,浪费点空间但逻辑简单可靠。
坑三:SD 卡在低温下不工作。工业现场冬天零下 20 度,消费级 SD 卡直接罢工。换成工业级宽温卡(-40~85 度)才解决。这个坑让我明白,工业项目的物料选型不能只看参数表,工作温度范围必须留余量。
坑四:FPGA 和 STM32 同时访问 NOR Flash 冲突。早期设计里两者共用一条 SPI 总线,没有仲裁机制,偶尔同时发起访问导致数据错乱。后来加了片选仲裁逻辑,同一时刻只允许一方访问,问题消失。
5.3 存储寿命的估算方法
选型时经常要估算存储寿命够不够用。以 NOR Flash 日志为例:
假设每天产生 1000 条日志,每条 64 字节,一天写入 64KB。Flash 容量 8MB,分成 2048 个 4KB 扇区。环形写入时,每个扇区大约 2048 天被擦一次,一年擦 0.18 次。10 万次擦写寿命,理论上能用 55 万年。实际受温度、电压影响会打折,但余量巨大。
SD 卡就没这么乐观了。假设每天写 1GB,卡容量 32GB,每天全盘擦写 1/32 次,一年约 11 次。消费级 TLC 卡 500 次寿命,理论上能用 45 年。但这是理想情况,实际卡内磨损均衡算法、写入放大都会让寿命大幅缩短,所以工业项目里 SD 卡一般按 3~5 年更换来设计。
实操心得:估算寿命时,写入放大系数一定要考虑。NOR Flash 的写入放大接近 1,SD 卡可能到 3~5。也就是说实际写入量是逻辑写入量的好几倍,寿命要按这个打折。
6. 分级存储方案的落地建议
把 EEPROM、NOR Flash、SD 卡三级存储用起来,硬件上其实不复杂:STM32 的 I2C1 挂 EEPROM,SPI1 挂 NOR Flash,SDIO 挂 SD 卡,三条总线互不干扰。PCB 布局时注意 I2C 和 SPI 的走线尽量短,SDIO 的时钟线要包地处理,避免高速信号串扰。
软件架构上,我建议做一个统一的存储抽象层,把三种介质的读写接口统一成storage_read、storage_write、storage_erase三个函数,上层业务代码不关心数据存在哪里,由抽象层根据数据类型路由到对应介质。这样后期换料或者调整分级策略时,只需要改抽象层,业务代码不动。
typedef enum { STORAGE_PARAM, // 参数 -> EEPROM STORAGE_LOG, // 日志 -> NOR Flash STORAGE_BULK // 大数据 -> SD 卡 } StorageType; int storage_write(StorageType type, uint32_t offset, uint8_t *buf, uint32_t len) { switch (type) { case STORAGE_PARAM: return eeprom_write(offset, buf, len); case STORAGE_LOG: return nor_write(offset, buf, len); case STORAGE_BULK: return sdcard_write(offset, buf, len); default: return -1; } }这个抽象层还有个好处:方便做数据迁移。比如产品升级后 NOR Flash 容量不够了,可以把日志迁到 SD 卡上,抽象层改一下路由规则就行,上层完全无感。
最后再分享一个小技巧:调试存储问题时,逻辑分析仪比示波器好用。I2C、SPI 的协议解码功能能直接把波形翻译成命令和数据,一眼就能看出是地址发错了还是时序不对。我调试 EEPROM 和 NOR Flash 问题时,逻辑分析仪基本是标配,能省掉大量猜测时间。SD 卡的问题则更多靠软件日志,在 FatFs 的 diskio 层加打印,能看到每次读写的扇区号和返回值,定位问题很快。