
1. 工业控制器存储方案的整体设计思路工业控制器和消费类电子产品最大的区别在于它可能要在无人值守的配电柜里连续跑十年断电重启是家常便饭现场电磁环境还特别脏。这种场景下数据存储方案的第一原则不是“快”而是“稳”——掉电不能丢数据擦写次数要扛得住坏了还得能查出来是哪颗芯片的问题。我手上这个项目是一台多轴运动控制器主控用STM32F407配一颗FPGA做多路编码器和PWM的实时处理。系统里需要存的东西分三类第一类是标定参数和配置比如每根轴的脉冲当量、PID参数、限位坐标这些数据量小但极其重要丢了整台设备就得重新校准第二类是运行日志和故障记录每次报警的时间戳、故障码、当时的电流电压值量不大但写入频繁第三类是工艺文件和历史波形单次可能几百KB到几MB用于事后追溯和工艺优化。这三类数据的特征完全不同用同一种介质去存就是给自己找麻烦。我的方案是分级存储EEPROM存最关键的标定参数NOR Flash存日志和故障记录SD卡存工艺文件和大块波形数据。STM32负责整体调度和文件系统管理FPGA则承担高速数据流的缓冲和搬运两者通过FSMC总线和SPI通道协作。为什么这么分先说EEPROM。它按字节擦写不需要整块擦除写入前不用搬数据特别适合那种“改一个参数就存一次”的场景。而且EEPROM的擦写寿命通常在100万次以上标定参数一天改十次也能用几百年。缺点是容量小、速度慢所以只存最核心的那几百字节。NOR Flash的特点是支持随机读取可以按扇区擦除擦写寿命约10万次。它比EEPROM容量大得多常见几MB到几十MB适合存日志这种“追加写入、偶尔读取”的数据。但NOR Flash写入前必须先擦除整个扇区所以不能像EEPROM那样直接覆盖写需要设计一个日志环或者双备份机制。SD卡容量大、单位成本低但它的致命弱点是掉电保护差——写入过程中断电可能导致文件系统损坏甚至整张卡报废。所以SD卡只存那些“丢了也能重新生成”的数据比如历史波形和工艺文件。而且必须配合文件系统和写入策略来降低风险。这个分级思路的核心逻辑是按数据的重要性和访问特征匹配介质的物理特性。重要的、频繁改的用小容量高寿命介质量大的、写入频繁的用中等介质大块的、可再生的用大容量介质。三者各司其职谁也不越界。注意分级存储不是简单地把数据分散到不同芯片而是要在软件层面建立统一的管理接口。上层应用不应该关心数据存在哪里只调用统一的读写API由存储管理层根据数据类型路由到对应介质。这样后期更换介质或调整分区时上层代码不用动。2. 三种存储介质的核心原理与选型依据2.1 EEPROM的字节级擦写特性与I2C接口设计EEPROM的全称是电可擦可编程只读存储器它的存储单元是两个晶体管组成的浮栅结构。写入时通过隧穿效应把电子注入浮栅擦除时把电子拉出来。关键在于它支持按字节操作不需要像Flash那样整块擦除。这个特性决定了它最适合存“零散但关键”的参数。我选的是AT24C512512Kbit也就是64KB容量I2C接口页大小128字节。为什么不用更大容量的因为EEPROM的容量和价格不成正比64KB已经够存几百个参数了再大就不如用FRAM或者带备份电池的SRAM。而且I2C总线速度有限标准模式100kHz快速模式400kHz写一个字节加上协议开销大概要几十微秒不适合大量数据。I2C的硬件设计有几个坑要注意。第一上拉电阻不能省典型值4.7kΩ到10kΩ阻值太大会导致上升沿变缓高速通信时误码率飙升阻值太小则功耗增加低功耗场景要权衡。第二EEPROM的写周期是5ms左右写完一个字节后芯片内部在忙不会响应总线必须等或者用应答轮询。第三WP写保护引脚要接GPIO控制正常运行时拉低允许写入关键参数写完后拉高防止误改。STM32这边用硬件I2C还是软件模拟我建议用硬件I2CF4系列的I2C外设虽然有些历史bug但配置正确后稳定性没问题。关键是中断优先级要设对别被其他高优先级中断打断导致时序错乱。如果对时序要求特别严格可以用软件模拟但会占用CPU时间。// EEPROM页写入示例AT24C512页大小128字节 #define EEPROM_ADDR 0xA0 #define PAGE_SIZE 128 uint8_t EEPROM_WritePage(uint16_t memAddr, uint8_t *data, uint16_t len) { uint16_t offset memAddr % PAGE_SIZE; uint16_t writeLen PAGE_SIZE - offset; if (writeLen len) writeLen len; HAL_I2C_Mem_Write(hi2c1, EEPROM_ADDR, memAddr, I2C_MEMADD_SIZE_16BIT, data, writeLen, 100); HAL_Delay(6); // 等待内部写周期完成 if (len writeLen) { return EEPROM_WritePage(memAddr writeLen, data writeLen, len - writeLen); } return 0; }这段代码的关键在于跨页处理。AT24C512的页大小是128字节如果写入地址不是页对齐的写到页边界就会回卷到页首覆盖之前的数据。所以必须计算当前页剩余空间分多次写入每次写完等5ms以上。2.2 NOR Flash的扇区擦除机制与SPI驱动要点NOR Flash和NAND Flash的区别经常被混淆。简单说NOR的读取速度接近RAM支持随机访问代码可以直接在上面运行NAND的读取是按页的适合大块数据存储。工业控制器里用NOR Flash主要是因为它可靠、接口简单、支持XIP。我选的是W25Q128128Mbit也就是16MB容量SPI接口扇区大小4KB块大小64KB。擦写寿命10万次数据保持20年。这个容量存日志足够了按每条日志64字节算16MB能存25万条循环覆盖也能用很久。NOR Flash的写入流程是先发写使能命令然后发页编程命令最多256字节一页。但写入前必须确保目标区域是擦除过的0xFF否则写入的数据是原数据和新数据的按位与。擦除的最小单位是扇区4KB擦除时间典型值45ms最坏情况400ms。这个时间必须等不能提前发下一条命令。SPI驱动方面STM32的硬件SPI配置成模式0或模式3都可以W25Q系列两种都支持。关键是时钟极性要和Flash匹配我一般用模式0CPOL0CPHA0。速度方面F407的SPI最高42MHz但W25Q128在标准SPI下最高104MHz所以STM32这边反而是瓶颈。如果数据量大可以考虑用QSPI或者双SPI模式但接线会复杂一些。// NOR Flash扇区擦除 void NOR_EraseSector(uint32_t sectorAddr) { NOR_WriteEnable(); NOR_CS_Low(); SPI_Transmit(0x20); // 扇区擦除命令 SPI_Transmit((sectorAddr 16) 0xFF); SPI_Transmit((sectorAddr 8) 0xFF); SPI_Transmit(sectorAddr 0xFF); NOR_CS_High(); NOR_WaitBusy(); // 等待擦除完成轮询状态寄存器 } // 页编程 void NOR_PageProgram(uint32_t addr, uint8_t *data, uint16_t len) { NOR_WriteEnable(); NOR_CS_Low(); SPI_Transmit(0x02); // 页编程命令 SPI_Transmit((addr 16) 0xFF); SPI_Transmit((addr 8) 0xFF); SPI_Transmit(addr 0xFF); for (uint16_t i 0; i len; i) { SPI_Transmit(data[i]); } NOR_CS_High(); NOR_WaitBusy(); }这里有个经验NOR_WaitBusy()不能简单延时要轮询状态寄存器的BUSY位。因为擦除时间受温度、电压、磨损程度影响固定延时要么不够要么浪费。轮询间隔可以设1ms用系统滴答定时器实现。2.3 SD卡的文件系统与掉电保护策略SD卡在工业环境里是个“危险品”。它的内部是NAND Flash加控制器控制器负责磨损均衡和坏块管理但掉电时如果正在写FTL映射表整张卡可能直接变砖。我见过太多现场因为直接拔卡导致数据全丢的案例。所以SD卡的使用原则是只存可再生数据必须用文件系统必须支持热插拔检测必须做写入缓冲。文件系统选FatFs这是嵌入式领域最成熟的方案R0.14版本支持exFAT和长文件名配置起来也简单。掉电保护的核心是减少写入次数和缩短写入窗口。具体做法数据先在RAM里攒着攒够一个簇通常4KB或8KB再一次性写入写入时用f_sync()强制刷新但不要每写一次就sync那样会大幅缩短卡寿命系统掉电检测中断触发后立即停止所有SD卡操作等电容放电完毕再断电。SD卡的SPI模式比SD模式简单但速度慢。工业控制器里数据量不大SPI模式足够。接线时注意CMD和DAT线要加上拉电阻CLK线要远离模拟信号。如果板子上有电机驱动SD卡座要尽量远离功率器件否则干扰会导致初始化失败。// FatFs写入示例带缓冲 FATFS fs; FIL logFile; uint8_t writeBuf[4096]; uint16_t bufIndex 0; void Log_Append(uint8_t *data, uint16_t len) { if (bufIndex len sizeof(writeBuf)) { UINT bw; f_write(logFile, writeBuf, bufIndex, bw); bufIndex 0; } memcpy(writeBuf[bufIndex], data, len); bufIndex len; } void Log_Flush(void) { if (bufIndex 0) { UINT bw; f_write(logFile, writeBuf, bufIndex, bw); f_sync(logFile); // 强制刷新到卡 bufIndex 0; } }提示SD卡初始化时如果返回FR_NOT_READY先检查供电电压。SD卡在初始化瞬间电流可能冲到100mA如果LDO带载能力不足电压跌落会导致初始化失败。我习惯在SD卡电源脚并一个100uF钽电容。3. STM32与FPGA的分工协作与数据流设计3.1 为什么需要FPGA参与存储管理有人会问STM32F407主频168MHz带FSMC和DMA存个数据还要FPGA帮忙单看存储本身确实不需要但这个控制器里FPGA还要做多路编码器计数、PWM生成、ADC采样这些任务对实时性要求极高不能让STM32频繁中断去搬数据。FPGA的角色是“数据搬运工”和“缓冲池”。它内部用Block RAM实现一个FIFO深度2048×16bit。编码器数据、ADC采样值先写入FIFOSTM32通过FSMC总线批量读取一次读几百个点而不是每个点都中断。这样STM32的CPU占用率从70%降到15%以下。存储方面FPGA还负责SD卡的高速数据流写入。当系统需要记录高速波形时STM32把数据通过FSMC写入FPGA的DDR缓存FPGA再以SD卡的时序把数据推出去。这样STM32不用等SD卡的写入延迟可以继续处理控制任务。3.2 FSMC总线地址映射与时序配置STM32的FSMC可以把外部存储器映射到地址空间像访问内存一样访问FPGA的寄存器。我用的映射方式是0x60000000开始的区域映射到FPGA的FIFO数据口0x64000000映射到FPGA的控制寄存器。FSMC的时序配置是关键。F407的FSMC支持多种模式我选模式ASRAM/PSRAM因为读写时序最简单。关键参数地址建立时间ADDSET、数据建立时间DATAST、地址保持时间ADDHLD。这些值要根据FPGA的时序要求来算。假设FPGA的FIFO读周期最小50nsSTM32的HCLK是168MHz一个周期约5.95ns。那么DATAST至少需要50/5.95≈9个周期。实际配置时留余量设成12。ADDSET设成2ADDHLD设成1。这些值在CubeMX里可以直接填生成的代码会自动计算寄存器值。// FSMC初始化片段CubeMX生成后手动调整 hsram1.Instance FSMC_NORSRAM_DEVICE; hsram1.Extended FSMC_NORSRAM_EXTENDED_DEVICE; hsram1.Init.NSBank FSMC_NORSRAM_BANK1; hsram1.Init.DataAddressMux FSMC_DATA_ADDRESS_MUX_DISABLE; hsram1.Init.MemoryType FSMC_MEMORY_TYPE_SRAM; hsram1.Init.MemoryDataWidth FSMC_NORSRAM_MEM_BUS_WIDTH_16; hsram1.Init.BurstAccessMode FSMC_BURST_ACCESS_MODE_DISABLE; hsram1.Init.WaitSignalPolarity FSMC_WAIT_SIGNAL_POLARITY_LOW; hsram1.Init.WriteOperation FSMC_WRITE_OPERATION_ENABLE; hsram1.Init.ExtendedMode FSMC_EXTENDED_MODE_DISABLE; hsram1.Init.AsynchronousWait FSMC_ASYNCHRONOUS_WAIT_DISABLE; hsram1.Init.WriteBurst FSMC_WRITE_BURST_DISABLE; hsram1.ReadWriteTimingStruct.AddressSetupTime 2; hsram1.ReadWriteTimingStruct.AddressHoldTime 1; hsram1.ReadWriteTimingStruct.DataSetupTime 12; hsram1.ReadWriteTimingStruct.BusTurnAroundDuration 1; hsram1.ReadWriteTimingStruct.CLKDivision 2; hsram1.ReadWriteTimingStruct.DataLatency 2;实测下来这套时序在168MHz下跑得很稳。如果发现读回来的数据偶尔错位优先检查DATAST是不是太小或者FPGA那边的FIFO空标志有没有正确接到FSMC的WAIT信号上。3.3 分级存储的数据路由与统一接口上层应用不应该关心数据存在EEPROM还是SD卡所以我在STM32上做了一个存储管理层提供统一的Storage_Read()和Storage_Write()接口。调用时传入数据类型枚举管理层根据类型路由到对应介质。typedef enum { STORAGE_TYPE_CALIB, // 标定参数 - EEPROM STORAGE_TYPE_LOG, // 日志 - NOR Flash STORAGE_TYPE_WAVEFORM, // 波形 - SD卡 STORAGE_TYPE_CONFIG // 配置 - EEPROM } StorageType_t; int Storage_Write(StorageType_t type, uint16_t id, void *data, uint16_t len) { switch (type) { case STORAGE_TYPE_CALIB: case STORAGE_TYPE_CONFIG: return EEPROM_Write(id, data, len); case STORAGE_TYPE_LOG: return NOR_LogAppend(data, len); case STORAGE_TYPE_WAVEFORM: return SD_WriteFile(id, data, len); default: return -1; } }这个接口设计的好处是后期如果EEPROM容量不够要换成FRAM只需要改EEPROM_Write()的实现上层代码一行不用动。同样如果SD卡换成eMMC也只影响SD_WriteFile()。数据路由的策略还要考虑优先级。标定参数的写入必须立即生效不能缓冲日志可以缓冲攒够一页再写波形数据可以异步写入用DMA搬运。这些策略在存储管理层里配置对上层透明。4. 实操过程与关键环节实现4.1 硬件连接与PCB布局要点先列一下完整的硬件连接介质接口STM32引脚备注EEPROM AT24C512I2C1PB6(SCL), PB7(SDA)4.7k上拉NOR Flash W25Q128SPI1PA5(SCK), PA6(MISO), PA7(MOSI), PA4(CS)模式0SD卡SPI2PB13(SCK), PB14(MISO), PB15(MOSI), PB12(CS)上拉10kFPGAFSMCPD0-D15, PE7-PE15等16位数据总线PCB布局有几个硬性要求。第一EEPROM和NOR Flash要靠近STM32走线尽量短SPI时钟线不要跨分割地平面。第二SD卡座要放在板边方便插拔但卡座下方不要走敏感信号。第三FPGA和STM32之间的FSMC总线要走等长偏差控制在5mm以内否则高速读写时数据会错。电源方面EEPROM和NOR Flash用3.3VSD卡也用3.3V但电流要大。我一般给SD卡单独一路LDO或者至少用磁珠隔离防止卡内电荷泵的噪声串到模拟电路。4.2 EEPROM参数存储的完整实现标定参数的结构体定义如下typedef struct { uint32_t magic; // 0x55AA55AA用于判断是否首次上电 float pulsePerMm[4]; // 四轴脉冲当量 float pidKp[4]; // PID比例系数 float pidKi[4]; // PID积分系数 float pidKd[4]; // PID微分系数 int32_t limitPos[4]; // 正限位坐标 int32_t limitNeg[4]; // 负限位坐标 uint16_t crc; // 校验和 } CalibParams_t;写入流程先算CRC然后分页写入。AT24C512的页是128字节这个结构体大概100字节一页能放下。但为了安全我用了双备份地址0x0000存主份0x0080存备份。写入时先写备份再写主份读取时如果主份CRC错就自动切到备份。void Calib_Save(CalibParams_t *params) { params-magic 0x55AA55AA; params-crc CRC16((uint8_t*)params, sizeof(CalibParams_t)-2); // 先写备份区 EEPROM_WritePage(0x0080, (uint8_t*)params, sizeof(CalibParams_t)); HAL_Delay(10); // 再写主区 EEPROM_WritePage(0x0000, (uint8_t*)params, sizeof(CalibParams_t)); HAL_Delay(10); } uint8_t Calib_Load(CalibParams_t *params) { EEPROM_Read(0x0000, (uint8_t*)params, sizeof(CalibParams_t)); if (params-magic 0x55AA55AA params-crc CRC16((uint8_t*)params, sizeof(CalibParams_t)-2)) { return 0; // 主份有效 } EEPROM_Read(0x0080, (uint8_t*)params, sizeof(CalibParams_t)); if (params-magic 0x55AA55AA params-crc CRC16((uint8_t*)params, sizeof(CalibParams_t)-2)) { return 1; // 备份有效 } return 2; // 都无效需要恢复出厂设置 }注意CRC计算要覆盖除CRC字段本身之外的所有字节。我见过有人把CRC也算进去结果永远校验失败。另外结构体要按1字节对齐否则不同编译器下padding不一样读出来的数据会错位。4.3 NOR Flash日志系统的环形缓冲设计日志系统的需求是持续追加空间满了自动覆盖最旧的记录支持按时间范围查询。我用的是环形缓冲加索引表的方式。16MB的NOR Flash分成4096个扇区每个扇区4KB。前16个扇区存索引表每个索引项8字节记录该条日志的起始扇区、时间戳、长度。后面4080个扇区存日志内容。写入时先检查当前写指针位置如果剩余空间不足一条日志的长度就回卷到起始位置同时更新索引表。索引表本身也要擦写所以用双缓冲索引表A和索引表B交替写读取时选最新的那个。typedef struct { uint32_t timestamp; uint16_t startSector; uint16_t length; uint16_t crc; } LogIndex_t; void NOR_LogAppend(uint8_t *data, uint16_t len) { static uint32_t writeSector 16; // 从第16扇区开始 static uint16_t sectorOffset 0; // 检查是否需要擦除新扇区 if (sectorOffset 0) { NOR_EraseSector(writeSector * 4096); } // 写入数据 NOR_PageProgram(writeSector * 4096 sectorOffset, data, len); sectorOffset len; // 更新索引 LogIndex_t idx; idx.timestamp Get_RTC_Time(); idx.startSector writeSector; idx.length len; idx.crc CRC16((uint8_t*)idx, 6); NOR_IndexWrite(idx); // 扇区写满则切换到下一个 if (sectorOffset 4096) { sectorOffset 0; writeSector; if (writeSector 4096) writeSector 16; // 回卷 } }这个设计的坑在于擦除一个扇区要45ms如果日志写入频率很高擦除会成为瓶颈。解决办法是提前擦除当当前扇区写到一半时后台开始擦除下一个扇区。STM32没有后台任务可以在主循环的空闲时间做或者用DMA触发。4.4 SD卡文件系统的挂载与波形存储SD卡用FatFs挂载流程f_mount()-f_open()-f_write()-f_close()。波形文件用二进制格式每个采样点2字节文件头存采样率、通道数、起始时间。void SD_SaveWaveform(uint16_t *data, uint32_t points) { FIL file; UINT bw; char filename[32]; sprintf(filename, wave_%lu.bin, Get_RTC_Time()); if (f_open(file, filename, FA_CREATE_ALWAYS | FA_WRITE) ! FR_OK) { return; // 打开失败可能是卡满或文件系统损坏 } // 写文件头 WaveHeader_t header; header.sampleRate 10000; header.channels 4; header.points points; header.timestamp Get_RTC_Time(); f_write(file, header, sizeof(header), bw); // 写数据分块写入 for (uint32_t i 0; i points; i 2048) { uint32_t chunk (points - i 2048) ? 2048 : (points - i); f_write(file, data[i], chunk * 2, bw); } f_close(file); }SD卡写入最怕的是写到一半断电。FatFs的f_write()只是写到缓存f_close()才真正刷新到卡。如果在这之间断电文件会损坏。我的做法是每写1MB就调用一次f_sync()这样最多丢1MB数据不会损坏整个文件系统。另外SD卡的热插拔检测用GPIO中断。卡拔出时立即关闭文件防止后续写入操作访问已移除的卡导致总线挂死。卡插入时重新挂载文件系统。5. 常见问题与排查技巧实录5.1 EEPROM读写失败排查现象可能原因排查方法读回全0xFF器件地址错用逻辑分析仪抓I2C波形确认地址字节读回全0x00上拉电阻未接万用表测SDA/SCL对VCC电阻偶尔读错时序太快降低I2C时钟到100kHz测试写入后读不到未等写周期写后延时5ms再读部分字节错跨页写入检查写入地址是否页对齐我遇到最诡异的一次是EEPROM读出来的数据每隔几个字节就错一位。查了半天发现是I2C走线太长和SPI时钟线平行走了10cm串扰导致。后来把I2C线改短并加地线隔离就好了。5.2 NOR Flash擦除失败与数据保持问题NOR Flash最常见的问题是擦除不干净。表现是写入后读出来的数据有0变成1的位。原因是擦除时间不够或者擦除时电压不稳。解决办法擦除后读回全扇区确认全是0xFF再写入。如果发现非0xFF的位重新擦除一次。另一个问题是数据保持。NOR Flash标称20年但那是常温。如果设备工作在70度以上数据保持时间会大幅缩短。工业控制器如果用在高温环境建议每5年做一次数据刷新读出来擦除再写回去。5.3 SD卡初始化失败与文件系统损坏SD卡初始化失败的原因很多按概率排序供电不足、上拉电阻缺失、SPI模式不匹配、卡本身不兼容。我整理了一个排查流程测SD卡座VCC电压初始化时不能低于3.0V检查CMD和DAT线的上拉电阻必须是10kΩ用示波器看CLK线初始化阶段应该在400kHz以下换一张不同品牌的卡测试有些工业级卡对时序要求更严如果还不行降低SPI时钟到100kHz用最慢速度初始化文件系统损坏的修复FatFs没有内置修复工具但可以格式化。如果卡上有重要数据先用读卡器在电脑上备份然后重新格式化。预防措施是每次写入后调用f_sync()并且不要在写入过程中断电。5.4 FSMC通信误码与FPGA时序调整FSMC读写FPGA偶尔出错表现为读回的数据随机翻转。排查步骤用示波器看FSMC的片选、读写、地址线确认时序余量检查FPGA端的时序约束确保建立保持时间满足降低FSMC时钟频率测试如果降频后正常说明时序余量不足在FSMC数据线上串22Ω电阻减少反射我实际项目中遇到的是FPGA端FIFO空标志响应太慢STM32读的时候数据还没准备好。解决办法是在FPGA里把空标志提前一个周期拉低或者STM32读之前先查询状态寄存器。提示FSMC的WAIT信号如果不用可以接固定电平。但建议接上FPGA可以在数据未准备好时拉低WAITSTM32会自动插入等待周期比软件轮询可靠得多。6. 存储方案的扩展与优化方向这套分级存储方案目前跑在一台四轴控制器上连续运行了八个月没出过数据丢失。但有几个地方还可以优化。EEPROM的写入次数虽然标称100万次但那是单字节。如果频繁写同一个地址局部磨损会加速。我的做法是在EEPROM里划一块区域做磨损均衡参数不直接写固定地址而是写到一个环形队列里每次写新位置读取时找最新的有效记录。这样写入分散到整个区域寿命能延长几十倍。NOR Flash的日志系统目前是单索引表如果索引表所在的扇区坏了整个日志就丢了。改进方案是索引表也做双备份并且定期把索引表备份到SD卡。这样即使NOR Flash完全损坏也能从SD卡恢复日志索引。SD卡方面工业级SD卡和消费级的价格差三倍但寿命差十倍。如果项目对可靠性要求高建议用工业级卡或者直接用eMMC芯片焊在板上。eMMC的掉电保护比SD卡好而且没有插拔接触问题。最后分享一个调试技巧在存储管理层的每个读写函数入口加一个计数器记录每种介质的读写次数和错误次数。系统运行时可以通过串口打印这些统计信息。我靠这个发现过NOR Flash的擦除次数异常偏高查出来是日志写入频率比预期高了一个数量级后来调整了日志级别才解决。