1. 为什么工业控制器要把存储拆成三级
1.1 数据不一样,“存法”就不能一样
做工业控制器这几年,我最大的感受就是:数据存储这件事,看着不起眼,真出事的时候最要命。PLC 的配方参数丢一次,整条产线就得停下来重新下发配置;伺服驱动器的报警日志丢了,售后工程师过去就是盲修;设备每天采集的电流波形找不回来,你做的所谓“健康诊断”就是个摆设。
说回到 STM32+FPGA 这套组合。为什么要分三级存储?因为控制器里的数据,从属性上根本就不是一类东西:
- 配置参数类:设备地址、校准系数、用户配方、零点标定值。这类数据量极小,可能几十到几百字节,但改得比较频繁,而且断电之后绝对不能丢。
- 运行数据类:报警记录、操作日志、运行统计、最近一段时间的采样快照。数据量从几 KB 到几十 MB,要求快速写入,允许按块覆盖,但要有一定历史保留。
- 批量采集类:振动波形、电流曲线、温度趋势,或者 FPGA 高速采集的 ADC 原始数据。一天可能产生几十 MB 甚至 GB 级文件,要求容量大、写入连续、读取便捷。
这就像家里存东西:身份证、存折要放保险柜(EEPROM),常用的工具和药品放床头柜(NOR Flash),换季的衣物和杂物塞进储物间(SD 卡)。你把被褥塞进保险柜肯定不现实,把存折扔储物间也迟早要出事。
所以标题里的“分级”这两个字,是这套方案的核心。不是简单地“多挂几个存储芯片”,而是根据数据的价值密度、访问频率、容量需求做一次正交划分,每一级存储干它最擅长的事。STM32 负责上面两层(EEPROM、NOR Flash),FPGA 做前端采集和高速数据的缓冲搬运,SD 卡挂在 STM32 的文件系统下,这样整个数据链路才是完整的。
1.2 分级存储的总体思路与数据流向
这套架构里,单片机和 FPGA 的分工其实很清晰:FPGA 管高速、实时、确定性的活;STM32 管协议、文件系统和“不着急”的活。
数据从采集到落盘的路径是这样走的:传感器信号进 ADC,FPGA 并行采样并以固定速率把数据写入内部的 FIFO;FIFO 满到一定水位后,通过并行总线或 SPI 高速接口通知 STM32 来搬数据;STM32 把数据先暂存在 NOR Flash 的缓冲区里,积累到一定量(比如一个 4KB 扇区),再批量写入 SD 卡的文件中。这个方法说白了就是先把数据存在 FPGA 的“临时仓库”,需要时再搬到“正式的仓库里”。
那么 EEPROM 和 NOR Flash 的边界怎么定?我的习惯是:小于 1KB、需要频繁改写、每一条数据都要保证独立完整性的,放 EEPROM;大于 1KB、按块改写可以接受、需要有日志追加能力的,放 NOR Flash;需要长周期留存、要能被 USB 读出来分析的历史数据,才考虑 SD 卡。分级越多,管理越复杂,实际项目中两级往往也能凑合,但加上 SD 卡之后,设备的“数据格局”才真正打开。
2. 三种存储介质怎么选:EEPROM、NOR Flash、SD 卡
2.1 EEPROM:存“丢了就要命”的小数据
EEPROM 是这几种存储里性格最“靠谱”的一个。按字节读写,写之前不用先擦除,掉电不丢,单字节写周期一般 5ms 左右。工业控制器里最常见的型号就是 AT24Cxx 系列,走 I2C 接口,容量从 2Kbit 到 512Kbit 都有。
选型时盯三个指标:容量、写寿命、页写缓冲。AT24C02 是 256 字节,存不了多少东西;一般建议直接上 AT24C128 或 AT24C256,容量适中也便宜。写寿命方面,EEPROM 标称 100 万次擦写,但这是按页测试的理论值,高温环境下要打折。如果你的设备每秒都在写参数,那寿命也就是十来天的事,这可能是个隐患。所以设计时要有意识地减少写次数——只有当参数值真的变化时才启动写操作,而不是周期性把同一份数据刷进去。
I2C 硬件上要注意几个细节:SCL/SDA 必须有上拉电阻,阻值 4.7k 到 10k 之间,取决于总线上的器件数量和速率;地址引脚 A0/A1/A2 不要悬空,该接地就接地;WP 写保护引脚建议拉出一个 0 欧电阻到地,想打开写保护的时候可以直接断开电阻、上拉到 VCC。这里有一个来自实践的建议:EEPROM 的页写缓冲要利用好。AT24C256 一次页写最多 64 字节,你要写一个 128 字节的配方,分两次页写,每次起始地址按 64 对齐,能省一半时间。
2.2 NOR Flash:当“中间仓库”用,别当 U 盘用
NOR Flash 经常被人拿来和 NAND 比较。在工业控制器里,选 NOR 而不是 NAND 的原因很简单:NOR 可以按字节随机读,读速度快,XIP 执行代码也方便,而且坏块率低得多。NAND 容量大、价格低,但块擦除、坏块管理、位翻转纠错这些事太折腾,只有上了文件系统加 ECC 才敢放心用,对单片机来说负担很重。
NOR Flash 最大的特性是“先擦后写”,而且擦除是以扇区(Sector)为单位的,常见的扇区大小是 4KB。这一条直接决定了它的用法:你不能像 EEPROM 一样改一个字节就写一个字节,而是要先读出整个扇区、修改、擦除、再整扇区写回。频繁小数据更新在 NOR 上效率很低,所以它适合存“顺序追加型”的数据——比如报警记录一次追加一条,写满一个扇区后再换下一个扇区。
另一个问题是擦写寿命。NOR 的擦写次数一般标 10 万次,听起来不少,但如果日志每 10 秒写一条,一天就是 8640 次,10 万次十几天就耗光了。所以使用 NOR Flash 必须做两个设计:一是软件磨损均衡,扇区轮流做循环写入,而不是死磕在 0 号扇区;二是双区备份,关键配置数据写两份,交替更新,启动时校验有效区。我做项目时经常用 W25Q64/128,8MB 到 16MB 容量,SPI 接口,QSPI 模式能跑到 80MHz,性价比很高。
有些工程师看到“模拟 EEPROM”这个词,是在用 Flash 模拟 EEPROM 的行为——在 STM32 内部 Flash 的专用扇区实现参数存取,其实就是把上述“读-改-写”的流程做成一个封装好的驱动。原理相同,好处是省一颗外部芯片,坏处是占用了代码 Flash 空间且同样存在擦写次数限制。设计中要注意的是这个思路能否作为外扩 Flash 驱动逻辑的基础,答案是可以,只是外部 Flash 容量大,没那么容易写满。
2.3 SD 卡:大容量历史数据进文件系统
SD 卡这类存储介质本来不是为工业设计的,但架不住它容量大、便宜、通用性最强。板载 Flash 存不下的波形数据,录制到 SD 卡里,拔出来插到电脑上就能分析,对客户来说体验极好。
SD 卡和单片机连接有两种方式:SDIO 和 SPI。SDIO 速度快,能跑到 48MHz,但占用的引脚多(CLK/CMD/D0-D3,通常 6 根),而且 SDIO 控制器的时序配置比 SPI 复杂;SPI 模式速度上限一般是 25MHz 左右,胜在引脚少、代码成熟,绝大多数工业控制器的采样率用 SPI 模式也够用了。如果不是做高速连续录波,建议优先走 SPI 模式,省 GPIO 也省事。
文件系统方面,首选 FatFS,这是嵌入式领域事实标准。需要留意的是 FatFS 的配置项要仔细调:_FS_TINY 选 1 可以省内存,_MAX_SS 按扇区大小设 512,_USE_LFN 如果文件名是纯 ASCII 或者 8.3 格式就关掉,否则长文件名缓冲区会比较吃内存。SD 卡读写是块操作,底层接口基于 disk_read/disk_write 实现,SPI 模式下需要注意 SD 卡初始化时的时钟频率不能太高——很多卡在 400kHz 以下才能完成上电初始化流程,识别之后才能切到高速模式,这是 SD 协议规范里明确规定的,直接用 10MHz 时钟初始化容易导致sdcard not mount这类问题。
3. 硬件电路设计与接线实操
3.1 STM32 侧总线分配:I2C 与 SPI 的引脚规划
硬件设计先从引脚规划说起。拿到一个 STM32 型号,先把外设资源分配清楚。以我常用的 STM32F407 为例,这个项目里分配给存储系统的引脚大致如下:
| 功能 | 接口 | 引脚 | 备注 |
|---|---|---|---|
| EEPROM (AT24C256) | I2C1 | PB6 (SCL), PB7 (SDA) | 都需要 4.7k 上拉电阻 |
| NOR Flash (W25Q64) | SPI1 | PA5 (SCK), PA6 (MISO), PA7 (MOSI), PB0 (CS) | SPI1 的 SCK 最大 42MHz |
| SD 卡 | SPI2 | PB13 (SCK), PB14 (MISO), PB15 (MOSI), PB12 (CS) | SPI2 模式,初始化时钟降频 |
| FPGA 数据/握手 | FSMC/并行 | PD0-PD15 + 控制线 | FPGA 作为外部存储器映射 |
几个容易踩坑的细节:
- 多个 SPI 挂在同一总线上:很多同学想省事,把 Flash 和 SD 卡挂在同一个 SPI 上,共用 SCK/MISO/MOSI,只靠 CS 区分。理论上可行,但 W25Q 和 SD 卡的最高时钟频率不同,初始化时序要求也不同。实践中很容易出现“Flash 正常、SD 卡偶发初始化失败”的怪问题,因为总线速率必须迁就慢的那个设备。我建议分两个 SPI 独立接,成本几乎为零,排查问题能少掉一半头发。
- 片选信号必须用硬件控制:不要把 CS 当成普通 GPIO 来软件翻转,尤其是有 DMA 参与的数据搬运时。GPIO 翻转速度和 DMA 传输速率不匹配,加上中断延迟,极容易导致传输错位。选一个带有 SPI 硬件 CS 功能的引脚,配置成 NSS 硬件管理模式。
- I2C 上拉电阻的取值:I2C1 的标准模式,上拉电阻取值 4.7k 比较保守;总线如果挂载多个设备且走线较长,可以降到 3.3k,但不要低于 2.2k,否则上升沿会太慢、低电平驱动能力不够。高速模式(400kHz)下建议 2.2k,这算是一个实用判断经验。
3.2 FPGA 侧缓存桥接:数据先入 FIFO 再搬家
FPGA 在整个架构里的职责是“快进快出”。ADC 采集速率很高,可能 1MHz 甚至更高,STM32 直接实时接收根本来不及处理——即使跑中断,一个字节一个字节地收,CPU 就被占满了。所以正确的做法是:FPGA 内部做一个异步 FIFO,把采集数据源源不断地缓冲起来,等到 FIFO 半满或达到阈值时,再通知 STM32 一次批量读取。
这个 FIFO 的设计有几个关键参数:
- 宽度:如果 ADC 是 12 位,建议直接按 16 位存储,以便后续处理,也避免跨字节拼接的麻烦。
- 深度:取决于 STM32 的平均搬移速度。比如 FIFO 每满 4KB 就触发一次中断,STM32 在中断里通过 DMA 搬走 4KB,耗时约几毫秒。在这几毫秒内 FIFO 不能溢出,所以深度要大于“最大搬移延时 × 采集速率”。一次经验取值是 8KB 到 16KB。
- 读写时钟域:写入时钟是 ADC 采样时钟,读时钟是 STM32 FSMC 总线时钟,两者完全异步,FIFO 必须做格雷码跨时钟域同步。
数据流上电之后,FPGA 先跑一个采集状态机,数据从 ADC 进来就写 FIFO;FIFO 满到阈值,拉高一个“数据就绪(RDY)”信号;STM32 收到中断后,置高“读请求(REQ)”信号,FPGA 端把 FIFO 读出端接在并行总线上,STM32 连续读若干次;读完后 FPGA 拉低 RDY,等下一次 FIFO 满。这套握手协议简单可靠,也不会有因跨时钟域丢数据的隐患。
我在实际的项目里,还会在 FPGA 里同时开一条低速 UART 通道做辅助调试。串口发一条命令就能把 FPGA 内部 FIFO 水位、握手状态寄存器全部回读出来,排查问题省力不少。这也算是对网上常问的“FPGA 怎么正确写 testbench”的一个回应——testbench 里最该做的就是模拟这种握手时序,验证 FIFO 读写水位和信号时序,避免到板子上才发现状态机跑飞。
3.3 供电与信号完整性:这些不起眼的坑
存储系统对供电的要求没有那么苛刻,但有两个地方确实要专门处理:一个是 SD 卡的 3.3V 供电,另一个是地回路。
SD 卡在 SPI 模式下也能吃 3.3V 逻辑电平,这一点要注意考虑周全——如果你选的主控是 5V 的板子,就需要电平转换。我遇到过的案例是:某人用 5V 单片机挂 SD 卡,想着 SD 卡座子自带稳压,结果做出来的板子一开始偶尔能识别,后面直接完全无法挂载。查了一圈,最后发现是逻辑电平不匹配——SD 卡的数据线和时钟线直接接到 5V 引脚上,把卡内部电平判定电路烧到异常。所以电平转换芯片或者电阻分压,在混合电压系统里绝不能省。
另外,SD 卡座子的 VCC 引脚要就近放一个 10uF 钽电容 + 0.1uF 陶瓷电容。SD 卡内部 NAND 写操作瞬时电流可能到 100mA 级别,没有储能电容的话,电源纹波会让卡在写入过程中掉电退出,非常难以排查。
地回路通常是“低级错误制造高级故障”的根源。FPGA、ADC、Flash、SD 卡分散在不同的区块,如果地平面被切割得支离破碎,高速 SPI 的回流路径就被迫绕路,信号完整性急剧恶化。设计时注意:SPI 时钟线尽量避免长距离平行走线,MISO/MOSI 不要和时钟线靠得太近,数据线和时钟线之间最好留出一倍线宽的距离,至少 4mil 以上。这些不是玄学,都是示波器上亲眼看到的振铃和过冲。
4. 分层软件架构与掉电保护
4.1 ARM 端的“分层存储服务”
硬件画好了,代码结构也要跟上。这套存储系统在软件上不能东写一块西写一块,必须统一封装成一个存储服务层。我常用的分层方式是:
- 应用层:设备的业务逻辑,比如“保存配方”“记录报警”“追加波形数据”。应用层只调用存储接口,完全不知道底层用的是哪种介质。
- 接口层:定义统一的数据读写服务,如
Storage_WriteParam(uint16_t id, uint8_t *buf, uint16_t len)、Storage_AppendLog(LOG_T *log)、Storage_CreateWaveFile(...)。在这一层根据数据 id 和参数类型决定路由到 EEPROM 还是 NOR Flash。 - 驱动层:EEPROM 的 I2C 驱动、Flash 的 SPI 驱动、SD 卡的 FatFS + SPI 驱动,以及各个介质的底层时序处理。
应用层绝不能直接调用W25Q_WriteSector()这类底层函数。否则三个月后你自己都记不清哪个参数写在哪个扇区,维护成本极高。
接口层里最核心的是“数据类型路由表”。比如:
| 数据类型 | 存储介质 | 写入策略 | 备注 |
|---|---|---|---|
| 设备配置参数 | EEPROM | 变化即写,双备份 | 最多 2KB |
| 校准系数 | EEPROM | 变化即写,带 CRC | 出厂后极少改 |
| 报警/事件日志 | NOR Flash | 循环扇区追加 | 保留最近 10000 条 |
| 采集波形文件 | SD 卡 | 批量写入,FATFS | 按日期命名 |
路由表可以做成静态配置数组,每个存储对象包含介质类型、起始地址、容量、备份策略这些属性。这样加一个新的存储对象,只需要在数组里加一行。
4.2 FPGA 状态机与握手协议
FPGA 端不要在寄存器层面散写散读,把采集控制、FIFO 管理、握手应答做成一个清晰的状态机。下面是一个简化版的采集控制状态机框架:
localparam IDLE = 3'd0; localparam ACQUIRE = 3'd1; localparam FIFO_FULL = 3'd2; localparam WAIT_HOST = 3'd3; localparam HOST_READ = 3'd4; reg [2:0] state; wire fifo_half = fifo_wr_count >= HALF_DEPTH; wire host_req = ext_host_req; always @(posedge clk) begin case (state) IDLE: begin if (start_acq) state <= ACQUIRE; end ACQUIRE: begin if (fifo_half) state <= WAIT_HOST; end WAIT_HOST: begin // 拉高 RDY,等待外部主机置高 REQ if (host_req) state <= HOST_READ; end HOST_READ: begin // 读取计数满一帧后回到 ACQUIRE if (read_count >= BURST_LEN) state <= ACQUIRE; end default: state <= IDLE; endcase end这里要特别强调的是握手信号的时序关系。RDY拉高之后,STM32 端会进入中断处理,然后拉高REQ。但中断响应时间是不确定的,可能在几个微妙到几十个微妙之间波动。所以 FPGA 端收到REQ之后不能立刻认为数据总线稳定,需要同步打两拍甚至三拍,确认REQ稳定后再开始驱动数据总线。这就是跨时钟域处理,省了这一步,就会偶发读到全 0 或全 F 的脏数据。
STM32 端的中断处理里,读数据的动作要尽可能快、尽可能简单。中断里只做一件事:触发 DMA 把 FSMC 数据总线上的一帧数据搬到内存缓冲区。DMA 搬完后在 main 循环里做后续的日志写入。这一设计能避免中断处理时间过长导致后续数据来不及采的情况发生,把这些实现的思路写出来,对入门 FPGA 和 STM32 联调的同学很有参考价值。
4.3 掉电保护三板斧
工业现场最恶心的场景就是“写数据写到一半掉电”。从硬件上来讲,控制器的电源通常会有一个掉电检测(PVD)中断,能在电压跌到阈值以下之前留出几毫秒的反应时间。但从软件层面,还必须有对应的防御机制。我总结为三板斧:
第一板斧:双区备份 + 事务标志。无论是 EEPROM 里的参数区,还是 NOR Flash 里的关键配置块,都准备两份物理区域。写入前,先记录一个“事务进行中”标志;写完主区,更新事务标志;写完备份区,清除事务标志。上电启动时,检查事务标志:
- 无事务 → 两份数据一致,正常启动。
- 正在事务中 → 主区可能写到一半,直接用备份区恢复主区。
- 备份区损坏且主区完好 → 用主区重启备份。
这样任何瞬间掉电都不会导致“数据彻底丢失”,最多回退到上一次完整写入的状态。
第二板斧:NOR Flash 循环写 + 磨损均衡。报警日志如果每次都从扇区 0 写起,几个月后扇区 0 就报废了。正确做法是维护一个“当前写入扇区索引”,把它也存储在 Flash 的固定块里。每次追加日志时,先写当前扇区,满了之后索引加 1;索引循环回 0 前,先擦除对应扇区。这样既实现了日志覆盖策略,也让擦写分布在所有扇区上,寿命提升好几个量级。
第三板斧:SD 卡掉电保护。FatFS 本身不是掉电安全的。写入过程中断电,FAT 表和目录项很容易损坏,轻则文件打不开,重则整个卡的分区无法识别。我的做法是:
- 文件以固定大小块写入,比如每 512 字节刷一次缓存,不要一次攒几 KB 才 flush。
- 写文件前先创建一个
.tmp文件,全部写完后f_rename成正式文件。上电后发现.tmp存在,就说明上一次写入未完成,直接删除。 - 日志文件名按日期区分,比如
LOG_20240615.BIN,第二天自动开新文件。这样即使某一天的文件坏了,也只丢一天的数据,不会波及历史档案。
这一组合下来,绝大多数掉电场景都能兜住。这也是“工业级”和“实验室级”最大的区别:实验室里不担心掉电,工业现场什么诡异场景都有可能发生。
5. 实测排障与经验补遗
5.1 高频故障速查表
这部分直接上干货,把我在调试这套系统过程中遇到的高频问题整理成了一张速查表,新做的板子可以直接照着这个方向排查:
| 故障现象 | 可能原因 | 排查手段 | 解决方向 |
|---|---|---|---|
| EEPROM 写操作一直 ACK 超时 | I2C 地址错误、WP 引脚拉高、总线无上拉 | 示波器量 SCL/SDA 波形,确认地址字节 | 检查地址引脚电平,WP 串 0Ω 电阻接地 |
| EEPROM 数据偶发错误 | 页写跨页边界或电源纹波 | 核对写入长度是否超过页边界 | 页写长度限制 64 字节内且地址按页对齐 |
| NOR Flash 读回全 0xFF | 片选没拉低、SPI 模式不对、时钟极性配置错误 | 读 ID 寄存器 0x90/0x9F | 确认 SPI 模式 0(CPOL=0, CPHA=0) |
| Flash 写失败 | 扇区未擦除就写入 | 调试器读扇区内容 | 写入前先执行扇区擦除 |
| SD 卡初始化失败 | 初始化时钟过高、供电不足、卡座虚焊 | 初始化阶段降频到 400kHz 重试 | 增加延时顺序,补齐电源电容 |
| SD 卡偶发写入失败 | 文件系统碎片、掉电导致 FAT 损坏 | 检查返回值,挂载时做一致性检查 | 固定块写入;损坏时自动重建文件系统(待评估) |
| FPGA 与 STM32 数据错位 | 握手信号未做同步、数据总线时序不满足 | 逻辑分析仪抓 RDY/REQ/总线波形 | 增加跨时钟域同步打拍 |
| 上电偶发存储区参数全丢 | 初始化顺序错误,先读后写导致误判 | 加打印日志追踪初始化流程 | 启动时先检查 FPGA 复位状态,再初始化外设 |
5.2 几个值得单独拎出来讲的实战细节
第一个是SD 卡内部寄存器锁死的问题。这个案例特别典型:SPI 模式下做初始化时,连续发送了 CMD0 之后卡片没有进入 SPI 模式,后续的 CMD8/ACMD41 全部失败。反复排查之后发现,问题出在上电时序——SD 卡要求至少等待 1ms(有些规范是 2ms)让内部电源稳定,然后时钟线要先输出至少 74 个时钟脉冲,片选要处于高电平。这个顺序错了,卡片就会进入一个“半初始化”状态,寄存器状态混乱,也就是网上经常说的“寄存器锁死”。解决办法是每次上电后,严格按 74 个时钟 + CMD0 + 等待响应 + CMD8 + ACMD41 的标准流程走,中间每一步都加足够延时,不要追求“快”。
第二个是I2C 用硬件外设还是软件模拟的问题。STM32 的硬件 I2C 在某些系列上确实有坑,典型的就是总线忙检测误判、卡死在 BUSY 状态。我自己的习惯是:如果时间充裕且存储量不大,EEPROM 用硬件 I2C(配合超时复位机制);如果项目功耗成本压力大,直接用 GPIO 模拟 I2C 反而更可控。软件模拟不需要处理复杂的硬件状态寄存器,时序完全由代码控制,对于低速 EEPROM 来说完全够用,而且代码更直观。FPGA 那边如果也要挂 EEPROM 做 FPGA 配置信息存储,则用 Verilog 写一个 I2C 控制器在读取上比较直接——网上能找到很多现成的例子,关键是起始条件和 ACK 时序要严格对。我见过有人直接抄了一个初始化序号写反的例程,结果 FPGA 侧 EEPROM 读取死活不对,这种细节要自己有判断力。
第三个是存储服务分层收益的延迟体现。最初我把所有存储代码都写在一块,自测没有问题,但后面加了一个“历史曲线回放”功能时,发现新功能需要同时读 EEPROM 里的设备参数和 SD 卡里的历史文件,业务逻辑和存储逻辑耦合在一起,改一处要动三处。花了半天时间把存储部分重构成分层架构,之后扩展就顺畅多了。这给我的教训是:写存储代码的第一天就要按分层来做,即使当时觉得小题大做。到后期再补课,重构成本往往是第一时间的十倍。这也是为什么我在这个系列里反复强调“先设计、后编码”的原因——存储系统的复杂度前期看不出来,后期爆发起来相当麻烦。
最后再分享一个对新手很实用的小技巧:在联调 STM32 与 FPGA 存储链路时,不要在真实采集数据上做验证,先用一个固定递增序列(比如 0x00-0xFF 循环)灌进 FIFO,然后在 STM32 端读出后做比对。数据比对一致之后,再去接真实 ADC 信号。这样一旦出错,你只需要判断是链路问题还是采集问题,不需要同时排查两个变量。这套方法也适用于 Flash 和 SD 卡驱动的验证——写入一个模式,读回比对,确认无误后再接实际业务数据。
还是那句话,数据存储的设计,决定了一台设备在严苛环境里到底能不能扛住。硬件方案选型、电路布局、分层软件、掉电保护,每一步都是躲不开的细节活。希望这篇记录能给正在做类似控制器存储设计的朋友提供一些参考。