
做嵌入式系统的人八成都在“数据存储”这件事上交过学费。我说的不是SD卡存照片而是设备里那些小体积、高频次、还不能丢的数据校准参数、事件记录、工况日志。最近一个工业网关项目里我用一颗富士通MR25H40CDF4Mbit SPI MRAM配合STM32F107VC把“存储和读取”这件事做成了一套可靠且几乎不用维护的方案。这篇文章我会从头梳理为什么不用Flash和EEPROM、硬件怎么接、驱动怎么写、现场跑出来了哪些坑。不管是做电力终端、工业采集器还是运动控制只要你有“频繁写参数还不许掉电丢”的需求这套思路可以直接参考。1. 为什么工业项目里我选了MR25H40CDF这颗MRAM1.1 传统NOR Flash和EEPROM的痛点先说说我原来踩过的坑。用NOR Flash存参数最难受的是擦除机制。NOR Flash写入之前必须先擦除而且擦除是按扇区进行的一个扇区常常是4KB甚至64KB。如果你的应用只是修改4个字节的校准值也得把整个扇区读进RAM、改好、擦除整个扇区、再写回去。这一套操作下来毫秒级都算快的关键是Flash的擦写寿命通常只有十万次左右工业设备一年不到就被磨穿了。用EEPROM比如24C256这种按字节写确实方便不用擦除但容量普遍偏小256Kbit也就32KB存个配置表还行想存点历史波形或者事件日志就捉襟见肘。而且EEPROM的写寿命一般是百万次虽然比NOR强一个数量级但和工业现场的写入频率比起来仍然是很现实的瓶颈。更麻烦的是掉电问题。NOR Flash在擦除或写入过程中突然掉电很容易出现扇区状态不一致轻则数据错误重则整块失效。为了防这个我还得额外设计双备份、启动时校验、损坏扇区隔离之类的机制代码量蹭蹭往上涨。1.2 MR25H40CDF核心参数与优势MR25H40CDF是富士通的4Mbit SPI MRAM换算过来512KB。它最吸引我的四个点第一不需要擦除直接覆盖写。这彻底改变了存储代码的复杂度——你不用再维护一个“擦除队列”想改哪里就发一条写命令跟SRAM一样随意。第二写入寿命是10的12次方也就是一万亿次。这是Flash的十万倍几乎可以视为“无限写”。就算每秒钟写一次同一个地址也要三万年才能把它写坏工业场景根本不用考虑磨损均衡。第三掉电不丢数据。MRAM的存储单元本质是磁隧道结靠磁化方向记录数据掉电后磁性状态不会变。它既不是靠浮栅电荷保存的Flash也不是靠电容保存的DRAM所以不存在“需要刷新”或者“电荷泄漏”的问题。你可以在任意时刻拔电数据稳稳留在那里。第四读写速度快。SPI接口的最高时钟频率能到40MHz级别而STM32F107的SPI外设上限是18MHz所以MRAM的速度完全不会拖后腿。写一个字节连命令带地址也就是几十个时钟周期的事比Flash那套“读-改-擦-写”流程快了不止一个数量级。如果要用一句话向别人解释MRAM的本质它是一颗非易失的SRAM或者一颗不需要擦除的EEPROM。这种特性在工业存储场景里实在太省心了。1.3 为什么配合STM32F107VC选MCU这part核心原因是F107VC这块片子特别适合做工业通信加数据存储的中枢。它是100引脚的Cortex-M3主频72MHz内置512KB Flash和64KB RAM外设方面直接带了以太网MAC、两个CAN、USB OTG做电力集中器、工业网关、轨交辅助控制器都很常见。存储这块F107VC的SPI1挂在APB2总线上外设时钟最高72MHzSPI1最高可以跑到18Mbit/s。虽然比不上MRAM标称的40MHz但只要把预分频设成4通信速率已经够用。再加上F107VC的代码生态极其成熟HAL库、标准库、寄存器版资料满天飞就算中途换人也容易接手。另一个现实考量是成本。F107VC在工业级Cortex-M3里不算贵MRAM单价确实比Flash贵不少但胜在省事。用一个MCU加一颗MRAM电路简单、驱动简单、后期维护也简单综合算下来比“MCU高性能NOR Flash磨损均衡算法掉电保护电路”更划算至少少了一大块固件复杂度。2. 硬件连接与SPI接口设计2.1 MR25H40CDF引脚与STM32接线MR25H40CDF是标准8脚DFN封装引脚不多但DFN封装的脚间距小设计PCB的时候要把引脚图看清楚。它的引脚包括CS#、SCK、SIMOSI、SOMISO、WP#、HOLD#、VCC、VSS其中WP#和HOLD#这两个脚是容易忽略的点。我的接线方案是MR25H40CDF引脚接STM32F107VC说明CS#PA4任意GPIO软件片选拉低有效SCKPA5 / SPI1_SCKSPI1时钟SIPA7 / SPI1_MOSI主机输出到MRAM数据输入SOPA6 / SPI1_MISOMRAM数据输出到主机输入WP#PA3GPIO控制平时拉高支持运行时保护配置区HOLD#VCC必须拉高不能悬空VCC3.3V并联100nF10uF去耦电容VSSGND接地SPI1的PA5、PA6、PA7这三个脚是固定的复用功能CS我特意选了普通GPIO而不是SPI的NSS硬件引脚原因很简单MRAM的命令时序里像写使能WREN和数据写入WRITE不能在同一个片选周期完成必须分成两次拉低CS。软件控制CS才能真正自由地控制时序硬件NSS在这种场景下反而添乱。2.2 SPI模式与时钟速率的选择逻辑MR25H40CDF支持SPI Mode 0和Mode 3也就是CPOL/CPHA的四种组合里选两种。我习惯用Mode 0也就是CPOL0、CPHA0空闲时钟为低电平数据在第一个时钟沿采样。原因没有多高深纯粹是大部分SPI NOR和EEPROM默认也是Mode 0后续如果要换芯片驱动改动最小。速率方面STM32F1系列的SPI外设最高支持18MHz。72MHz系统时钟下SPI1预分频选4分频得到18MHz刚好压线。实际使用中如果PCB走线超过5厘米或者现场干扰比较大我建议降到9MHz也就是预分频选8保证通信余量。这里有个细节MRAM这种SPI设备没有内部时钟恢复机制SCK必须干净稳定特别是在工业现场的电机变频器附近SCK走线过长容易被干扰。如果你的板子空间允许SCK和MISO、MOSI这三条线尽量短、尽量平行别在高频信号线旁边绕圈。2.3 电源去耦、HOLD引脚和DFN焊接电源部分MRAM的工作电压是3.3V电流消耗很小待机电流微安级运行时也就几个毫安所以不需要大电流稳压器。但去耦电容不能省VCC引脚旁边我习惯放一个100nF陶瓷电容加一个10uF电解电容前者滤高频噪声后者应对电压瞬变。HOLD引脚必须强调千万不能悬空。HOLD#低电平时MRAM会暂停SPI通信表现为MISO保持当前电平、SCK和MOSI的输入被忽略。如果这个引脚悬空在强电磁干扰环境下被噪声拉低整个存储器的通信就会莫名其妙“卡住”而且你怎么查都查不到原因。正确的做法是直接接VCC或者串联一个10k电阻到VCC确保它稳定在高电平。WP#硬写保护引脚我在开发调试阶段直接接的VCC。后面想保护配置区不被误写的时候改成GPIO控制置高允许写置低禁止写状态寄存器。注意WP#的具体保护范围要对着数据手册看不同厂商的MRAM定义略有差异比如有些只保护状态寄存器有些连数据区一起保护。DFN-8焊接是个体力活。热风枪加助焊剂是最稳的组合先给焊盘上锡再放芯片热风枪350度左右吹到锡珠融化镊子轻推一下芯片能自动复位基本就成了。焊完之后务必用万用表确认相邻引脚没有短路特别是VCC和VSS之间DFN封装的底下那个大焊盘如果没做热隔离很容易和旁边的引脚黏在一起。还有个硬件层面的经验MCU复位的时候GPIO会变成浮空输入CS#引脚如果恰好被干扰拉低MRAM可能接收到一串伪命令。解决办法是在MCU的初始化代码里第一步就把CS#配置成推挽输出并置高电平然后再去初始化SPI外设和其他外设这样能从软件上消除复位期间的片选毛刺。3. 驱动代码设计与读写实现3.1 指令集与状态寄存器MR25H40CDF的指令集和标准SPI EEPROM很接近核心就那么几条指令字节码功能WREN0x06写使能锁存WRDI0x04写禁止锁存RDSR0x05读状态寄存器WRSR0x01写状态寄存器READ0x03读数据WRITE0x02写数据状态寄存器里最关键的两个位是bit0的WIP写进行中和bit1的WEL写使能锁存。WIP为1表示芯片正在执行内部写入读数据没问题但再发写命令会被忽略WEL为1表示写使能已经打开允许执行WRITE或WRSR。每次上电后WEL默认是0所以想写入数据必须先发一条WREN指令把WEL置1。这里有个特别容易踩的坑WREN本身也要占一个完整的片选周期。你必须是CS#拉低、发送0x06、CS#拉高这样一个周期结束之后WEL才会真正置1。很多人偷懒CS#拉低之后连续发WREN和WRITE结果发现数据写不进去就是这个原因。3.2 HAL库底层驱动封装我用的是STM32F1的HAL库先把SPI1初始化和单字节收发封装好。SPI1的GPIO配置和SPI参数如下static void MX_SPI1_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_SPI1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); // PA4 片选先置高 GPIO_InitStruct.Pin GPIO_PIN_4; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // PA5 SCK, PA6 MISO, PA7 MOSI GPIO_InitStruct.Pin GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_MASTER; hspi1.Init.Direction SPI_DIRECTION_2LINES; hspi1.Init.DataSize SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity SPI_POLARITY_LOW; hspi1.Init.CLKPhase SPI_PHASE_1EDGE; hspi1.Init.NSS SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_4; hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; HAL_SPI_Init(hspi1); } static uint8_t MR25H40_SpiByte(uint8_t byte) { uint8_t rxByte; while (HAL_SPI_TransmitReceive(hspi1, byte, rxByte, 1, 100) ! HAL_OK); return rxByte; }这里有个小事要提醒HAL库的HAL_SPI_TransmitReceive在18MHz下单字节传输是有时间开销的如果你的日志写入量大可以换成DMA或者直接寄存器操作但这套代码队调试和中小数据量存储完全够用。另外不要在中断里直接调用这个函数否则嵌套中断容易把SPI状态机搞乱。3.3 读数据与写数据的完整实现读数据比较简单发送READ指令、24位地址然后连续读取字节直到CS拉高结束本次访问。void MR25H40_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); MR25H40_SpiByte(MR25H40_READ); MR25H40_SpiByte((addr 16) 0xFF); MR25H40_SpiByte((addr 8) 0xFF); MR25H40_SpiByte(addr 0xFF); for (uint32_t i 0; i len; i) { buf[i] MR25H40_SpiByte(0x00); } HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); }写数据的流程比读数据多了两步先发WREN使能写锁存再发WRITE指令完成写入。void MR25H40_WriteData(uint32_t addr, const uint8_t *buf, uint32_t len) { // 第一步WREN完整片选周期 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); MR25H40_SpiByte(MR25H40_WREN); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 第二步WRITE HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); MR25H40_SpiByte(MR25H40_WRITE); MR25H40_SpiByte((addr 16) 0xFF); MR25H40_SpiByte((addr 8) 0xFF); MR25H40_SpiByte(addr 0xFF); for (uint32_t i 0; i len; i) { MR25H40_SpiByte(buf[i]); } HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 第三步等待WIP清零 MR25H40_WaitBusy(); }等待写完成也很简单发RDSR命令后连续读状态寄存器直到bit0变成0static void MR25H40_WaitBusy(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); MR25H40_SpiByte(MR25H40_RDSR); uint8_t sr; do { sr MR25H40_SpiByte(0x00); } while (sr 0x01); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); }MRAM的写入速度极快内部写操作时间在微秒级所以这个等待循环通常一遍就过了。但保留它是正确的习惯——万一你换了一颗其他厂家的MRAM或者温度极端情况下内部时序变慢有这套等待才能保证兼容性。3.4 512KB空间的数据组织方式MRAM最大的好处之一就是不需要做磨损均衡所以存储区的规划可以完全按照业务逻辑来不需要像Flash那样考虑扇区、块、页和垃圾回收。我在这套工程里的划分方式是这样0x00000~0x0FFFF配置参数区A存设备编号、通信地址、校准系数带CRC32校验。0x10000~0x1FFFF配置参数区B和区A内容一样每次改动同时写两份读取时优先选校验通过的那份。这叫双镜像备份专门防写入过程意外掉电。0x20000~0x3FFFF事件日志区每条日志64字节头两个字节是序号循环写入。0x40000~0x7FFFF预留区存波形采样数据、临时变量等大块数据。为什么配置参数需要双镜像虽然MRAM不像Flash那样会因为掉电出现半扇区损坏但一次多字节写入如果中途掉电是有可能只完成前面几个字节的。双镜像加CRC校验就能识别出哪份镜像完整、哪份损坏启动的时候自动选择健康镜像下次写入时再修复损坏的那份。日志区用“序号头”的方式也特别省事。MRAM允许任意地址直接覆盖写我只需维护一个日志写指针每次追加一条表头有序号的记录重启后从第一条开始扫描就能知道最后写到哪。如果是Flash这种追加式日志还得考虑擦除对齐MRAM完全不用。3.5 多任务访问的注意点在实际项目中F107VC经常同时跑着通信协议栈和存储任务SPI1总线不能同时被两个任务操作。我的做法是给MRAM驱动加一个全局互斥量写操作和读操作都先拿锁。如果系统没有RTOS就用临界区进临界区间关中断出临界区恢复中断状态。尤其要注意的是写数据过程中不能被打断因为一个WRITE命令的片选周期一旦中断比如被高优先级中断抢占导致CS时序拉乱数据就会写到未知地址。即使MRAM便宜、寿命长也不能容忍这种总线错误。所以带状态保护的临界区是必须的。4. 工业现场的可靠性经验与问题排查4.1 掉电瞬间的安全机制在工业现场掉电是最常见、也最致命的事件。MRAM的好处是掉电不丢数据但“写入过程中的掉电”依然需要应用层做防护。比如你一次写10条日志写到第5条的时候电源断了那第1到第5条是有效的后5条可能还没进存储阵列。规避方案有两层。第一层是硬件层用STM32F107的PVD掉电检测功能。配置好PVD中断后当电源电压掉到阈值以下MCU会立刻进中断。在这个中断里我可以把最多几十字节的紧急参数写入MRAM然后让系统安全复位。第二层是软件层任何一片数据都带长度和CRC字段读取的时候先校验再使用校验失败就用备份或者标记为坏记录。MRAM本身有很强的抗掉电能力它的写入是原子性的不会出现Flash那种“半个扇区擦成随机数”的情况但多字节记录依然遵循“要么全写要么只写了一部分”的实际应用层的校验是最后一道防线。4.2 SPI通信异常时的三板斧工业环境里SPI通信最容易出现的现象就是误码。MRAM不像CAN那样自带CRCSPI协议本身没有应答机制。所以当传输距离偏长、干扰源多时我会做三件事。第一所有读操作加CRC校验。读回来的数据如果CRC不对就重新读一次。MRAM读操作不会破坏数据重读没有副作用。第二写操作之后加回读验证。写完关键配置之后立刻把同一地址读出来比对。比对不过就重新写连续三次失败就上报故障。这套“回读校验”在设备量产测试里非常有用能抓出一批虚焊或者PCBA工艺问题。第三SCK线贴上地线保护。如果布线空间紧张至少让SCK和GND之间走一条地线把SCK夹在中间能明显减少容性耦合干扰。4.3 实际踩过的五个坑第一个坑是最后一个字节丢失。刚开始写驱动时我用循环调用HAL_SPI_Transmit把数据全部发出去发完之后立刻拉高CS。结果每条记录最后一个字节经常写进去是0xFF。原因是SPI发送寄存器不是即时到位最后一字节还没从移位寄存器送出去CS就被拉高了。解决办法是使用上面那套TransmitReceive收发函数它会等待TXE标志确保每字节真正发送完成。第二个坑是WREN没有形成完整片选周期。这个前面已经说过CS#拉低、发0x06、CS#拉高三步缺一不可。我调试时遇到过WEL位始终是0查了半天发现是CS#操作顺序写反了先拉了高才发的指令。第三个坑是地址回绕。MR25H40CDF的地址是24位的但有效地址只有0x000000到0x07FFFF。如果你从0x07FFFE开始写三个字节第三个字节会回绕到0x000000覆盖你的配置文件。这个现象和SPI NOR一样不是芯片坏了而是地址线位数不够。解决办法是应用层限制写入范围凡是跨边界写入都做拆分。第四个坑是WP#碰地。有次我把WP#接到了板子上的一个测试点测试点默认是GND结果所有写状态寄存器的操作都没反应普通数据写入倒是正常一度以为是芯片型号有问题。后来测了下电平才知道是WP#被拉低了。所以调试时WP#直接接VCC最省心。第五个坑是JTAG引脚复用。F107的PB3、PB4默认是JTAG相关引脚如果你把CS#或者HOLD#接到PB3、PB4上并且调试器还在用JTAG模式上电后MCU会把这几个脚拉成调试模式MRAM通信直接废掉。我这块板子CS#差点画到PB3上还好画板前检查了数据手册。如果非要用这两个脚记得在代码里先禁用JTAG、只保留SWD。4.4 高低温与长期运行实测这个方案做完之后我在实验室跑了两个测试。第一个是日志写入压力测试每100毫秒写一条64字节日志连续跑一周总共写了大约六百万次。结束后读取全部日志逐条比对CRC全部通过。这个数据要是换成NOR Flash大概率已经磨掉好几轮的磨损均衡区块了。第二个是高低温测试用高低温箱把设备从-40度升到85度循环72小时过程中持续写读MRAM。读出来的数据和写入值完全一致。MRAM的数据保持能力在工业级温度范围内很稳这也是它敢标称20年数据保持期的底气。5. 一些想留给你参考的补充建议做存储方案没有万能的解MRAM也不是所有场景都合适。如果你的设备一年就写几百次配置EEPROM绰绰有余何必多花钱。但只要有高频次、多数据量、掉电不能丢这三个条件同时出现MRAM几乎是唯一不用做磨损均衡就能放心跑的方案。尤其是在“读改写”场景里Flash要先扇区备份再擦除再写回MRAM一步到位省掉的不只是代码还有故障概率。如果后续项目需要更大容量富士通和Everspin都有8Mbit、16Mbit甚至更高容量的SPI MRAM驱动层只需要改容量定义和地址相关参数整体架构不用动。这套驱动移植性也很好因为底层只依赖SPI读写和三个GPIO控制换到STM32G4、GD32、NXP的LPC系列都是很快的事。我个人在实际使用中最满意的其实是“调试体验”。以前用Flash做日志存储总得提心吊胆防备擦写磨损和掉电损坏动不动就要做坏块管理。换了MR25H40CDF之后存储相关的故障基本绝迹久违地感受到了“存数据就像写内存一样简单”的轻松感。工业嵌入式里能这样放心的存储方案真不多。