工业控制器这类设备有个很现实的问题:它不像消费电子那样可以随便断电重启,也不像纯软件系统那样能靠云存储兜底。现场跑着的设备,参数、标定值、运行日志、故障记录,这些东西一旦丢了,轻则产线停线,重则整批产品报废。所以数据存储方案在工业控制器里从来不是"随便挂个Flash"那么简单的事。
我这些年做过的板子里,STM32配FPGA这个组合出现频率很高。STM32负责控制逻辑、通信协议、人机交互,FPGA负责高速采集、实时时序、并行处理,两边各管一摊。但存储这件事,往往是最容易被低估的部分——很多人画原理图的时候随手挂一片EEPROM就完事了,等到现场发现参数写不进去、日志丢数据、SD卡挂载失败,才开始回头补课。
这篇内容就是围绕STM32+FPGA架构下的分级存储方案展开,把EEPROM、NOR Flash、SD卡这三种介质各自该管什么、怎么接、怎么用讲清楚。适合正在做工业控制器、数据采集终端、边缘网关这类项目的硬件和嵌入式工程师参考,也适合刚接触存储设计、想搞明白"为什么不能只用一种存储"的读者。
1. 为什么工业控制器必须做分级存储
1.1 单一存储介质在工业场景下的真实困境
先说说只用一种存储会出什么问题。我见过不少项目,设计阶段图省事,整块板子就挂一片大容量NOR Flash,所有数据都往里塞。结果跑起来之后问题一个接一个。
参数区被日志区覆盖是最常见的。NOR Flash按扇区擦除,你写日志的时候如果没做好地址隔离,一次擦除操作就可能把旁边的标定参数一起抹掉。这种问题在实验室很难复现,因为实验室里参数改得少、日志写得也少,到了现场设备连续跑几个月,日志量上来了,冲突概率就大了。
另一个问题是擦写寿命。NOR Flash的擦写次数通常在十万次量级,EEPROM能到百万次甚至更高。如果把频繁更新的运行计数、累计工时这类数据放在NOR Flash里,用不了多久那块扇区就废了。而SD卡虽然容量大、单位成本低,但它的写入机制决定了它不适合存关键参数——掉电瞬间正在进行的写入操作可能损坏整个文件系统,而且SD卡的可插拔特性意味着它可能被人为拔走或者接触不良。
所以分级存储的核心逻辑不是"多挂几种存储显得专业",而是让每种介质干它最擅长的事:EEPROM管高频小数据,NOR Flash管关键配置和固件,SD卡管大容量非关键数据。
1.2 三种介质的角色分工与选型依据
把三种介质的特性摆在一起对比,分工就清楚了。
| 介质类型 | 典型容量 | 擦写寿命 | 写入速度 | 掉电安全性 | 适合存什么 |
|---|---|---|---|---|---|
| EEPROM | 2KB~512KB | 100万次以上 | 慢(ms级) | 高,字节级写入 | 标定参数、设备ID、运行计数 |
| NOR Flash | 1MB~64MB | 10万次左右 | 中等 | 较高,扇区级操作 | 固件、配置表、故障记录 |
| SD卡 | GB级 | 取决于卡质量 | 较快 | 低,需文件系统 | 历史数据、日志文件、采集原始数据 |
EEPROM选型上,I2C接口的24系列是最常见的选择,比如24C02、24C256、24C512。它按字节读写,不需要擦除操作,写一个字节就是一个字节,这对频繁更新的参数特别友好。缺点是容量小、速度慢,但存参数本来就不需要快。
NOR Flash方面,SPI接口的W25Q系列用得最多,W25Q64、W25Q128这些型号在工业板子上随处可见。它支持扇区擦除和页编程,适合存那些"写一次读很多次"的数据。固件升级的时候也是写到NOR Flash里,STM32从NOR Flash启动或者搬运到内部Flash执行。
SD卡就是标准的大容量存储,走SDIO或者SPI接口。它的价值在于容量和可更换性,适合存那些"丢了也不影响设备运行"的数据,比如历史曲线、运行日志、采集样本。
1.3 STM32与FPGA各自该管哪类存储
在STM32+FPGA的架构里,存储的访问路径也需要分工。我的习惯是:STM32负责管理EEPROM和NOR Flash,FPGA负责高速数据流到SD卡的通道。
原因很简单。EEPROM和NOR Flash的访问频率不高,但对可靠性要求高,STM32的I2C和SPI外设成熟稳定,中断和DMA机制也方便做重试和校验。而SD卡写入涉及大量数据搬运,FPGA可以做乒乓缓冲和流式写入,不占用STM32的CPU资源。特别是采集类应用,FPGA从ADC拿到数据之后直接打包写SD卡,STM32只需要定期读取文件列表或者做索引管理。
当然也有例外。如果FPGA资源紧张,SD卡也可以挂在STM32的SDIO接口上,用FatFs文件系统管理。这种方案开发快,但写入带宽受限于STM32的总线性能,高速采集场景下可能会丢数据。
2. EEPROM:参数存储的第一道防线
2.1 I2C EEPROM的硬件连接要点
24系列EEPROM走I2C总线,硬件上看起来简单,两根线一挂就完事,但实际布线有几个坑要注意。
上拉电阻的取值需要根据总线电容和速率来算。标准模式100kHz下,4.7kΩ是常见值;快速模式400kHz下,建议用2.2kΩ到4.7kΩ之间。如果总线上挂了多个从机,电容增大,上拉电阻要相应减小,否则上升沿变缓会导致通信失败。我遇到过一块板子,I2C总线上挂了EEPROM、温度传感器和IO扩展芯片,上拉用的10kΩ,结果400kHz下偶尔读不到数据,换成2.2kΩ之后稳定了。
地址引脚A0/A1/A2不能悬空。很多原理图里把这三个脚直接接地,这没问题,但要注意如果总线上有多片同型号EEPROM,必须通过地址引脚区分。悬空的话,引脚电平不确定,可能出现地址冲突。
写保护引脚WP要处理好。WP接高电平的时候EEPROM只能读不能写,接低电平才能写。有些设计为了"安全"把WP直接接VCC,结果调试的时候死活写不进去,查了半天才发现是WP的问题。我的做法是WP通过一个电阻下拉到地,同时预留一个跳线或者GPIO控制,需要写保护的时候再拉高。
2.2 页写入与字节写入的取舍
EEPROM的写入方式有两种:字节写入和页写入。字节写入就是每次写一个字节,写完等5ms左右的内部写周期,再写下一个。页写入是一次性写入一页(通常8到64字节),页内地址自动递增,跨页的时候需要重新发起写操作。
从效率角度看,页写入明显更快。写64个字节,字节写入需要64次写周期,大概320ms;页写入只需要一次写周期,5ms左右。但页写入有个限制:写入的数据不能跨页边界。比如页大小是32字节,你从地址0x1F开始写4个字节,就会跨越到下一页,这时候EEPROM的行为是回卷到本页开头覆盖数据,而不是顺序写到下一页。
我的经验是,参数存储用页写入,但每次写入前先算好地址对齐。如果参数结构体大小超过一页,就拆成多次页写入,每次保证页内对齐。下面是一个典型的页写入函数框架:
#define EEPROM_PAGE_SIZE 32 #define EEPROM_ADDR 0xA0 uint8_t EEPROM_WritePage(uint16_t memAddr, uint8_t *data, uint16_t len) { uint16_t pageOffset = memAddr % EEPROM_PAGE_SIZE; uint16_t writeLen; while (len > 0) { writeLen = EEPROM_PAGE_SIZE - pageOffset; if (writeLen > len) writeLen = len; if (I2C_WriteBytes(EEPROM_ADDR, memAddr, data, writeLen) != 0) return 1; HAL_Delay(6); // 等待内部写周期完成 memAddr += writeLen; data += writeLen; len -= writeLen; pageOffset = 0; } return 0; }这段代码的关键在于每次写入前重新计算页内偏移,保证不会跨页。HAL_Delay(6)是等待EEPROM内部写周期,不同型号的写周期时间不一样,24C02是5ms,24C256也是5ms,但有些型号会到10ms,查数据手册确认。
2.3 参数区的双备份与校验机制
工业现场最怕的就是参数丢失或者损坏。EEPROM虽然可靠,但也不是不会出问题——电源异常、总线干扰、器件老化都可能导致数据出错。我的做法是参数区做双备份加CRC校验。
具体来说,把参数结构体存两份,地址A和地址B。每次写入的时候先写A,校验通过后再写B。读取的时候先读A,CRC对就用A;A不对再读B,B对就用B并把B的内容回写到A。如果两个都不对,就加载默认参数并记录一次故障。
参数结构体的定义也要注意。不要直接把结构体按内存布局写进去,因为编译器可能会做字节对齐填充,不同编译选项下布局可能不一样。稳妥的做法是手动序列化成字节数组,或者用#pragma pack(1)强制紧凑排列。
typedef struct { uint32_t magic; // 固定标识,用于判断参数区是否初始化 uint16_t version; // 参数版本号 float kp; float ki; float kd; uint32_t runHours; uint32_t powerOnCount; uint8_t reserved[16]; uint16_t crc; } ParamStruct_t;magic字段很重要。第一次上电的时候EEPROM里全是0xFF,如果没有magic判断,程序会把0xFFFFFFFF当成有效参数加载,行为就不可控了。version字段用于固件升级后参数结构的兼容处理,新版本固件发现旧版本参数时可以走迁移逻辑。
2.4 写次数均衡与寿命估算
EEPROM标称100万次擦写寿命,听起来很多,但如果某个地址每秒写一次,算下来也就十几天。工业控制器里有些数据更新很频繁,比如运行计时、累计产量,这些不能直接往固定地址写。
写次数均衡的思路是把频繁更新的数据分散到多个地址轮换写。比如分配16个槽位,每次写下一个槽位,读的时候取最新的那个。每个槽位带一个序号,序号最大的就是最新数据。这样写次数就被均摊到16个地址上,寿命延长16倍。
#define LOG_SLOT_COUNT 16 #define LOG_SLOT_SIZE 8 typedef struct { uint16_t seq; uint32_t value; uint16_t crc; } LogSlot_t; uint16_t FindLatestSlot(void) { uint16_t maxSeq = 0; uint16_t latestIdx = 0; LogSlot_t slot; for (int i = 0; i < LOG_SLOT_COUNT; i++) { EEPROM_Read(LOG_BASE_ADDR + i * LOG_SLOT_SIZE, (uint8_t*)&slot, sizeof(slot)); if (slot.crc == CalcCRC16((uint8_t*)&slot, sizeof(slot) - 2)) { if (slot.seq > maxSeq) { maxSeq = slot.seq; latestIdx = i; } } } return latestIdx; }这种方案在电表、水表这类需要记录累计量的设备里很常见。代价是读取的时候要遍历所有槽位,但对于EEPROM的读取速度来说,16个槽位的遍历也就几毫秒的事。
3. NOR Flash:固件与关键记录的可靠载体
3.1 SPI NOR Flash的扇区结构与擦除规则
W25Q系列NOR Flash的内部结构是分层的:页(Page)256字节,扇区(Sector)4KB,块(Block)64KB。写入的最小单位是页,擦除的最小单位是扇区。这意味着你不能像EEPROM那样直接覆盖写一个字节,必须先擦除整个扇区再写。
这个特性决定了NOR Flash的使用方式:它适合"整块写、整块读"的场景。固件存储就是典型例子,升级的时候擦除目标扇区,然后按页写入新固件。配置表也是类似,把整个配置区当成一个整体来管理。
擦除操作的时间比较长,4KB扇区擦除典型值45ms,64KB块擦除150ms左右。如果在擦除过程中断电,那个扇区可能处于不确定状态。所以关键数据不能只存一份在NOR Flash里,必须有备份或者校验恢复机制。
3.2 固件存储区的地址规划
固件在NOR Flash里的布局需要提前规划好。我的习惯是把NOR Flash分成几个固定区域:
| 区域名称 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| Bootloader | 0x000000 | 64KB | 启动引导,负责固件校验和跳转 |
| App Firmware A | 0x010000 | 512KB | 主固件区 |
| App Firmware B | 0x090000 | 512KB | 备份固件区,用于升级回滚 |
| Config | 0x110000 | 64KB | 系统配置参数 |
| Fault Log | 0x120000 | 256KB | 故障记录,循环写入 |
| Reserved | 0x160000 | 剩余 | 预留扩展 |
双固件区的设计是为了支持安全升级。升级的时候先把新固件写到B区,校验通过后更新Bootloader里的启动标志,重启后从B区启动。如果新固件有问题,还可以回滚到A区。这种方案在工业设备里几乎是标配,因为现场升级失败的成本太高了。
Bootloader里的启动标志也要做备份。通常是在Bootloader区域里分配两个扇区,各存一份启动信息,带CRC校验。读取的时候取校验通过的那份。
3.3 故障记录的循环写入策略
故障记录的特点是"写入频率不确定,但一旦写就不能丢"。设备正常运行的时候可能几天都不写一条,出故障的时候可能短时间内连续写很多条。这种场景适合用循环缓冲区的方式管理。
具体做法是把Fault Log区域分成固定大小的记录槽,比如每条记录128字节,256KB的区域可以存2048条。维护一个写指针,每次写新记录的时候指针加一,写到末尾就回绕到开头。每条记录带一个递增的序号和时间戳,读取的时候按序号排序就能还原时间顺序。
#define FAULT_LOG_BASE 0x120000 #define FAULT_LOG_SECTOR 4096 #define FAULT_RECORD_SIZE 128 #define FAULT_RECORDS_PER_SECTOR (FAULT_LOG_SECTOR / FAULT_RECORD_SIZE) typedef struct { uint32_t seq; uint32_t timestamp; uint16_t faultCode; uint8_t faultData[64]; uint16_t crc; } FaultRecord_t;写记录的时候要注意:如果当前扇区已经写满,需要先擦除下一个扇区再写。擦除操作会阻塞一段时间,如果这时候有新的故障产生,要有缓冲机制。我的做法是在RAM里开一个小的故障队列,擦除完成后再把队列里的记录写进去。
3.4 NOR Flash的磨损与坏块处理
NOR Flash虽然比NAND Flash可靠,但也不是完全没有坏块。出厂的时候厂家会保证前几个扇区无坏块,但使用过程中扇区也可能因为擦写次数过多而失效。
对于故障记录这种循环写入的区域,每个扇区的擦写次数是均匀的,因为循环缓冲区会依次使用每个扇区。但配置区如果频繁更新,就需要做写均衡。简单的做法是配置区也做双备份,每次更新交替写两个区域,这样擦写次数减半。
坏块检测方面,写入后回读校验是最直接的方法。如果某个扇区写入后回读数据不一致,连续几次都这样,就标记为坏块,从可用列表中剔除。这个逻辑在Bootloader里实现比较合适,因为Bootloader是最后一道防线。
4. SD卡:大容量数据的归宿
4.1 SDIO与SPI两种接口模式的取舍
SD卡有两种访问模式:SDIO和SPI。STM32的SDIO接口支持4位数据线,理论带宽可以到48MHz时钟下的24MB/s(4位模式)。SPI模式只用一根数据线,速度受限于SPI时钟,通常也就几MB/s。
选哪个取决于数据量。如果只是存日志文件,每天几MB,SPI模式足够了,而且SPI接口通用性好,引脚少,布线简单。如果是高速采集,比如FPGA做图像采集或者振动信号采集,数据率到几MB/s甚至十几MB/s,那就必须用SDIO的4位模式。
SDIO的硬件设计要注意几点:CLK线要尽量短,走线阻抗控制好;CMD和DAT线需要上拉,通常用10kΩ到50kΩ;电源去耦要到位,SD卡在写入瞬间电流波动比较大,建议在卡座电源脚旁边放一个100μF的电解电容加一个0.1μF的陶瓷电容。
SPI模式就简单多了,标准SPI四线接法,CS、CLK、MOSI、MISO,加上电源和地。但要注意SD卡在SPI模式下的初始化流程和SDIO模式不一样,需要发送CMD0进入SPI模式,然后CMD8、ACMD41、CMD58等一系列命令完成初始化和容量识别。
4.2 FatFs文件系统的移植与配置
STM32上跑SD卡,FatFs是最常用的文件系统。移植FatFs主要做两件事:实现diskio.c里的五个底层函数,配置ffconf.h里的选项。
diskio.c需要实现的函数是:
disk_initialize:初始化SD卡,返回0表示成功disk_status:返回卡状态disk_read:从指定扇区读数据disk_write:向指定扇区写数据disk_ioctl:获取扇区数量、扇区大小等信息
ffconf.h里几个关键配置:
#define _FS_READONLY 0 // 需要写入,设为0 #define _FS_MINIMIZE 0 // 不裁剪功能 #define _USE_STRFUNC 1 // 启用字符串函数 #define _USE_FIND 1 // 启用文件查找 #define _USE_MKFS 1 // 启用格式化功能 #define _USE_FASTSEEK 1 // 启用快速定位 #define _FS_LOCK 1 // 启用文件锁,多任务环境下需要 #define _FS_REENTRANT 1 // 启用重入保护如果系统里有多任务同时访问SD卡,_FS_REENTRANT必须打开,并且要实现ff_req_grant和ff_rel_grant两个同步函数。我见过一个项目,STM32跑FreeRTOS,一个任务写日志一个任务读配置,没开重入保护,结果文件系统内部结构被破坏,卡里的数据全乱了。
4.3 掉电保护与文件完整性
SD卡最大的风险是掉电时正在写入。FatFs在写入过程中会更新FAT表和目录项,如果这时候断电,文件系统可能处于不一致状态。轻则最后写入的文件损坏,重则整个分区无法挂载。
降低风险的办法有几个。第一是定期调用f_sync,把缓存数据刷到卡里。FatFs默认有写入缓存,不调用f_sync的话数据可能还在RAM里。但f_sync太频繁会影响写入速度,需要根据数据重要性权衡。
第二是采用"写临时文件再重命名"的策略。新数据先写到data.tmp,写完后关闭文件,再重命名为data.log。重命名操作在文件系统层面是原子的,即使断电,要么是旧的data.log完好,要么是新的data.log完整,不会出现半截文件。
第三是在硬件上做掉电检测。用一个ADC通道监测电源电压,当电压降到阈值以下时,触发中断,在中断里完成f_sync和f_close操作。这个时间窗口很短,通常只有几毫秒到几十毫秒,所以电源端的储能电容要足够大。
4.4 FPGA直写SD卡的数据通路设计
高速采集场景下,数据从FPGA直接写SD卡可以减轻STM32的负担。FPGA实现SD卡控制器有两种思路:一种是FPGA实现SPI主机,按SPI协议访问SD卡;另一种是FPGA实现SDIO控制器,速度更快但逻辑更复杂。
SPI方式的FPGA实现相对简单。FPGA内部维护一个发送FIFO和一个接收FIFO,状态机负责发送命令、接收响应、读写数据块。SD卡的SPI协议规定数据以512字节块为单位传输,FPGA需要把采集数据打包成512字节的块再写入。
// SD卡SPI写入状态机简化框架 module sd_spi_writer ( input wire clk, input wire rst_n, input wire wr_en, input wire [7:0] wr_data, output reg wr_ready, output reg spi_cs_n, output reg spi_clk, output reg spi_mosi, input wire spi_miso ); localparam S_IDLE = 3'd0; localparam S_CMD = 3'd1; localparam S_RESP = 3'd2; localparam S_DATA = 3'd3; localparam S_WAIT = 3'd4; reg [2:0] state; reg [7:0] cmd_buf[5:0]; reg [5:0] cmd_idx; reg [8:0] byte_cnt; reg [7:0] data_buf[511:0]; // 状态机逻辑省略,核心是发送CMD24写命令, // 等待响应0x00,然后发送数据起始令牌0xFE, // 接着发送512字节数据,最后发送2字节CRC endmoduleFPGA直写SD卡的关键在于缓冲管理。采集数据是连续流,SD卡写入是块操作,中间需要乒乓缓冲。用两个512字节的BRAM,一个在采集数据,另一个在往SD卡写,写完后交换。这样采集不会因为SD卡写入的等待时间而丢数据。
STM32在这个架构里的角色是管理文件系统。FPGA只负责往SD卡的物理扇区写数据,文件系统的维护还是由STM32来做。两边通过一个共享的RAM或者寄存器接口交换信息,STM32告诉FPGA当前可写的扇区地址,FPGA写完后更新写指针。
5. 三种存储的协同管理与数据一致性
5.1 上电初始化顺序与检测流程
系统上电后,三种存储的初始化顺序有讲究。我的做法是先初始化EEPROM,再初始化NOR Flash,最后初始化SD卡。
EEPROM最先初始化,因为参数加载依赖它。如果EEPROM读取失败,系统应该能加载默认参数继续运行,而不是卡死。参数加载完成后,系统的基本行为就确定了。
NOR Flash第二个初始化,主要是读取配置区和检查固件完整性。如果Bootloader已经完成了固件校验,这一步可以简化。配置区的读取和EEPROM类似,也要做CRC校验和默认值回退。
SD卡最后初始化,因为它最耗时,而且不是系统运行的必要条件。SD卡初始化失败不应该阻止系统启动,只是标记为"存储不可用",日志改为写到NOR Flash的故障记录区。
初始化流程里要加超时机制。I2C总线可能因为某个从机拉低时钟线而卡死,SPI总线也可能因为SD卡接触不良而无响应。每个初始化步骤都要有超时计数,超时后跳过并记录故障。
5.2 数据分级写入的策略设计
数据写入的策略要根据数据性质来定。我把数据分成三类:
第一类是"关键参数",比如PID系数、标定值、设备ID。这类数据变化不频繁,但绝对不能丢。写入策略是:先写EEPROM,回读校验,校验通过后更新RAM缓存。如果EEPROM写入失败,重试三次,三次都失败就报警并保持RAM中的值不变。
第二类是"重要记录",比如故障记录、升级记录。这类数据写入频率中等,允许少量丢失但不能大面积损坏。写入策略是:写到NOR Flash的循环缓冲区,每条记录带CRC。如果当前扇区写满需要擦除,先把记录暂存到RAM队列,擦除完成后再写入。
第三类是"批量数据",比如采集日志、运行曲线。这类数据量大,允许丢失最后几条。写入策略是:通过FatFs写到SD卡,定期f_sync。如果SD卡不可用,数据直接丢弃并记录一次存储故障。
5.3 掉电时刻的数据保护
掉电保护需要硬件和软件配合。硬件上,电源输入端要有足够的储能电容,保证断电后系统还能运行几十毫秒。软件上,要有一个掉电中断服务程序,在检测到电压下降时快速执行关键操作。
掉电中断里能做的事情有限,因为时间窗口很短。我的做法是:中断触发后,立即停止所有非关键任务,把RAM中待写入的关键数据写到EEPROM,调用f_sync刷新SD卡文件,然后进入死循环等待电源完全耗尽。
这里有个细节:掉电中断的优先级要设到最高,而且要确保中断服务程序本身不会被打断。EEPROM的写入需要几毫秒,如果在这期间被其他中断打断,可能来不及完成。所以掉电中断里要关掉其他中断,只做最必要的操作。
void PVD_IRQHandler(void) { __disable_irq(); // 关闭所有中断 // 保存关键参数到EEPROM SaveCriticalParams(); // 刷新SD卡文件系统 if (sd_mounted) { f_sync(&log_file); f_close(&log_file); } // 标记掉电事件 MarkPowerLossEvent(); while (1); // 等待电源耗尽 }STM32的PVD(可编程电压检测)外设可以配置在电压降到某个阈值时触发中断。阈值要选在系统还能正常工作的电压之上,比如3.3V系统选2.9V,留出0.4V的余量给电容放电。
5.4 存储故障的降级运行与告警
工业控制器不能因为存储故障就停机。三种存储任何一种出问题,系统都应该能降级运行。
EEPROM故障时,参数无法保存,但RAM里的参数还能用。系统应该继续运行,但每次修改参数时提示"保存失败",并记录故障。如果重启,参数会回到上次成功保存的值。
NOR Flash故障时,故障记录无法写入,但固件和配置还在。系统继续运行,故障记录改为写到SD卡或者通过通信接口上传。
SD卡故障时,批量数据无法存储,但关键功能不受影响。系统继续运行,日志改为写到NOR Flash的故障记录区,同时通过通信接口发送存储故障告警。
降级运行的逻辑要在系统设计阶段就规划好,不能等到出了问题再临时加。每个存储介质的健康状态要定期检测,比如EEPROM每次写入后校验,NOR Flash定期读取校验,SD卡定期检查挂载状态和剩余空间。
6. 实战中踩过的坑与排查思路
6.1 EEPROM写入失败但读取正常的诡异现象
有一次调试一块新板子,EEPROM读取完全正常,参数能读出来,但写入之后回读发现数据没变。查了I2C波形,写操作的时序也对,ACK也正常。
排查过程是这样的:先确认WP引脚电平,用万用表量了是低电平,写保护没生效。然后怀疑是写周期等待时间不够,把HAL_Delay从5ms加到10ms,还是不行。接着用逻辑分析仪抓完整的写操作波形,发现写命令发出后EEPROM回了ACK,但紧接着的写周期里SDA线被拉低了很长时间,而我们的程序在写周期结束前就发起了下一次操作。
问题出在I2C总线的仲裁上。那块板子的I2C总线上还挂了一个RTC芯片,RTC的中断输出偶尔会拉低SDA线,干扰了EEPROM的写周期。解决办法是把RTC的中断输出改到别的引脚,I2C总线上只保留EEPROM和温度传感器。
这个坑的教训是:I2C总线上的设备要尽量少,特别是那些会主动拉低总线的设备。如果必须挂多个设备,要仔细检查每个设备的引脚行为,确保不会在总线空闲时干扰。
6.2 NOR Flash擦除后数据全变0xFF的误判
NOR Flash擦除后所有位都是1,也就是0xFF。这个特性本身没问题,但如果你在擦除后没有正确初始化数据结构,读取的时候就会把0xFF当成有效数据。
我遇到过一个问题:故障记录区擦除后,程序读取第一条记录,发现seq是0xFFFFFFFF,timestamp也是0xFFFFFFFF,CRC校验居然通过了(因为CRC计算的是0xFF字节的校验值)。结果系统认为这是一条有效记录,显示了一个荒谬的故障时间和代码。
解决办法是在记录结构里加一个"有效标志"字段,写入的时候设置为特定值(比如0x5A5A),读取的时候先检查这个字段。擦除后的0xFFFF不等于0x5A5A,就会被正确识别为无效记录。
typedef struct { uint16_t validFlag; // 0x5A5A表示有效 uint32_t seq; uint32_t timestamp; uint16_t faultCode; uint8_t data[64]; uint16_t crc; } FaultRecord_t; int IsRecordValid(FaultRecord_t *rec) { if (rec->validFlag != 0x5A5A) return 0; if (rec->crc != CalcCRC16((uint8_t*)rec, sizeof(FaultRecord_t) - 2)) return 0; return 1; }6.3 SD卡挂载失败但卡本身正常的排查链路
SD卡挂载失败是最常见的问题之一,原因可能出在硬件、驱动、文件系统任何一个环节。我的排查顺序是这样的:
第一步,确认卡本身没问题。把卡插到电脑上,能正常读写就说明卡是好的。如果电脑上也读不了,换张卡再试。
第二步,检查硬件连接。用示波器看SDIO的CLK线有没有波形,CMD线在初始化时有没有响应。如果CLK没有波形,说明SDIO外设没启动或者时钟配置有问题。如果CMD线一直高电平没有下拉,说明卡没有响应,可能是接触不良或者卡座焊接问题。
第三步,检查供电。SD卡在初始化瞬间电流会有一个尖峰,如果电源带载能力不足,电压会被拉低导致初始化失败。在卡座电源脚旁边并一个100μF电容试试。
第四步,检查初始化流程。SD卡的初始化命令序列比较长,CMD0、CMD8、ACMD41、CMD58,每一步都要检查响应。如果某一步响应错误,根据错误码判断问题。比如CMD8响应错误说明卡不支持2.0协议,可能是老卡;ACMD41一直返回busy说明卡初始化还没完成,需要循环等待。
第五步,检查文件系统。如果卡能初始化但挂载失败,可能是卡上没有文件系统或者文件系统损坏。用f_mkfs格式化一下再试。如果格式化也失败,检查diskio.c里的disk_ioctl函数是否正确返回了扇区数量和扇区大小。
6.4 多任务环境下文件系统崩溃的复现与修复
前面提到过FreeRTOS下多任务访问SD卡导致文件系统崩溃的问题,这里详细说一下排查过程。
现象是设备运行几个小时后,SD卡里的日志文件突然变成0字节,或者文件名变成乱码。重启后卡能正常挂载,但之前的数据丢了。
排查的时候先怀疑是掉电导致的,但设备一直连着电源,没有断电记录。然后怀疑是SD卡质量问题,换了几张卡问题依旧。最后用调试器跟踪FatFs的内部状态,发现FatFs结构体里的fs_type字段偶尔会变成0,说明文件系统对象被破坏了。
根因是两个任务同时调用了f_write,一个在写日志,一个在写配置。FatFs在没有开启重入保护的情况下,内部使用的全局变量会被并发访问破坏。解决办法是打开_FS_REENTRANT,实现同步函数:
int ff_req_grant(FIL *fp) { return (xSemaphoreTake(sd_mutex, pdMS_TO_TICKS(1000)) == pdTRUE) ? 1 : 0; } void ff_rel_grant(FIL *fp) { xSemaphoreGive(sd_mutex); }同时,所有访问SD卡的任务在调用FatFs函数前都要先获取互斥量。更稳妥的做法是只用一个任务专门负责SD卡操作,其他任务通过队列发送请求。
6.5 存储方案的成本与可靠性平衡
最后说说成本。三种存储都加上,BOM成本会增加不少。EEPROM便宜,几毛钱;NOR Flash看容量,W25Q128大概几块钱;SD卡座加卡,十几块钱。对于大批量产品,这个成本差异会被放大。
我的建议是根据产品定位来取舍。高端工业控制器,三种存储都上,可靠性优先。中低端产品,可以砍掉NOR Flash,固件存在STM32内部Flash里,故障记录写到EEPROM或者SD卡。如果连SD卡都砍掉,那就只剩EEPROM,适合参数简单、不需要历史记录的场景。
但有一条底线:EEPROM不能省。它是参数存储的最后一道防线,没有它,设备断电后所有配置都丢了,用户体验会非常差。NOR Flash和SD卡可以根据需求裁剪,EEPROM是必须的。
选型的时候还要考虑供货和生命周期。工业产品的生命周期通常五到十年,选的存储芯片要保证长期供货。W25Q系列和24系列EEPROM的供货比较稳定,SD卡座选标准型号就行,卡本身是消耗品,坏了换一张。