
最近把一块 Everspin 的 MR25H40CDF4Mbit SPI MRAM接到了 STM32F303RC 上替代原来板子里的 EEPROM 和 SPI Flash保存运动控制器的掉电位置、运行参数和循环日志。从选型、硬件接线到驱动调试折腾了半个多月中间踩了几个挺隐蔽的坑最后形成的这套读写方案已经跑了一百多万次连续写入没出问题。这篇就围绕这颗 MRAM 和 STM32F303RC 的组合把存储选型逻辑、SPI 读写协议、驱动实现和实测排障过程完整记录下来给正在纠结工业数据存存储方案的嵌入式工程师一个参考。1. 为什么我在工业项目里宁可换掉EEPROM也要上这颗MRAM1.1 传统EEPROM和Flash到底哪里让人难受做工业控制器的人应该都有同感最烦人的不是算法而是那点参数怎么存的破事。以前用 EEPROM比如 24C02/24C256接口简单、成本低但用起来有口气憋着——写一次要等 3 到 5 毫秒容量普遍几十 KB 封顶寿命标称才十万次擦写。你别觉得十万次很多如果一个设备每秒记录一次状态变量一天就是 86400 次写入最慢的设备撑不过两天就把 EEPROM 写到寿命边缘了。所以以前做日志型的存储只能用大一点的 SPI NOR Flash寿命从十万次量级提到十万次但代价是必须先擦除块再写页动不动要按 4KB 或 64KB 管理写一个字节也要先准备一块空区域项目里不得不加一层模拟EEPROM的磨损均衡逻辑代码量瞬间上去还总担心擦写到一半掉电把整个 sector 搞坏。这两个痛点混在一起项目组开会时经常讨论到底有没有一种存储器既不像 EEPROM 那样小气也不用像 Flash 那样伺候MRAM 就是这个问题的答案。我这次用的 MR25H40CDF就是 Everspin 产的一颗 4Mbit 磁阻随机存取存储器容量约 512KB走标准 SPI 接口3.3V 供电最核心的特点是写入非易失、直接按字节改写、不需要擦除也没有 Flash 那种页和块的结构限制。1.2 MRAM有什么资本颠覆选择要理解 MRAM 为什么适合工业场景得先说清楚它的底层原理。MRAM 的存储单元用磁性隧道结来保存数据改变磁化方向就能改写数据数据存的是磁场方向而不是电荷所以掉电后信息不会消失理论上写周期寿命达到 10 的 14 次方量级这在存储芯片里是碾压级的存在。日常使用中你完全不需要考虑这颗芯片会被写坏也不用做磨损均衡。我整理了一张对比表方便大家直观感受它和 EEPROM、NOR Flash 的差异对比项传统 EEPROMSPI NOR FlashMR25H40CDF (MRAM)接口I2C/SPISPI/QSPISPI写入方式按字节改写先擦除块再写页按字节/连续改写免擦除单次写入等待毫秒级约3-5ms擦除百毫秒级/编程微秒级纳秒级无写等待擦写寿命约10万次约10万次10^14量级几乎无磨损概念容量常见几KB到几百KBKbit到Gbit级别512KB写入原子性字节级页级掉电易损坏整块字节级局部改写安全成本低中偏高从这张表能看出来MRAM 不是要取代 Flash 去做大容量代码存储它最适合的是需要频繁写、快速写、掉电不丢、又不想被擦写逻辑折磨的那部分数据。而 STM32F303RC 作为带 FPU 的 Cortex-M4F 主控72MHz 主频SPI 外设也够用两者搭配在电机控制和工业仪表这类项目里是非常自然的组合。1.3 适合哪些场景不适合哪些场景这个话说在前面MRAM 不是万能药。它在工业里最值得用的地方我总结有三类。第一类是掉电关键数据保存比如伺服电机断电瞬间的位置、变频器的当前频率、设备运行状态机掉电前想往 EEPROM 里写先算算那 5ms 写等待够不够用而 MRAM 的写入没有内部编程等待CS 拉高即完成掉电中断里能抢到更多的余量。第二类是高频日志记录一秒写几次、几十次都不会有寿命焦虑。第三类是频繁更新的校准参数生产线上反复标定不用怕芯片提前报废。不适合的场景也有。比如你要存几百 MB 的固件升级包或者音视频数据那别用 MRAM成本和容量都不划算老老实实上 eMMC 或者大容量 NAND。再比如产品对 BOM 成本极敏感一颗 MRAM 的价格能买好几颗大容量 Flash那就算性能好也得低头。所以我的经验是把 MRAM 当作工业控制器里最精贵的那块数据保险箱而不是代替所有存储介质。2. 硬件连接STM32F303RC的SPI接口与MRAM引脚处理2.1 一颗MR25H40CDF的引脚和STM32的连线MR25H40CDF 的封装引脚不多和普通 SPI 存储芯片一样一共就那么几个关键脚SCK时钟、SI数据输入接主控 MOSI、SO数据输出接主控 MISO、CS片选、WP写保护、HOLD暂停传输、VDD 和 VSS。在 STM32F303RC 上我把它挂到了 SPI1使用 PA5 做 SCKPA7 做 MOSIPA6 做 MISOCS 则选用任意一个 GPIO我这里用了 PA4。这里要特别强调一下 CS 的选择。很多新手习惯把 CS 接到芯片的硬件 NSS 引脚上然后在 HAL 库里开硬件片选但我不建议在 MRAM 这种一帧式操作里用硬件 NSS。因为 MRAM 的每个读写操作都由 CS 的下降沿开始、上升沿结束中间命令、地址、数据是连续一帧而且写使能命令WREN和随后的写命令之间要严格保持 CS 低或者重新拉起硬件 NSS 的状态切换和时序控制不如 GPIO 直接所以最好用 GPIO 软控片选。连接方式我列一下VDD 接 3.3VVSS 接地VDD 旁边放 0.1uF 和 10uF 去耦电容。SCK、SI、SO 分别接 STM32F303RC 的 SPI1_SCK、SPI1_MOSI、SPI1_MISO。CS 接 PA4推挽输出默认高电平操作时软件拉低。WP 和 HOLD 都必须通过 4.7K 或 10K 电阻上拉到 3.3V。WP 和 HOLD 这两个引脚非常容易被忽略。WP 低电平会让状态寄存器的写保护生效HOLD 低电平时芯片会暂停外部通信、忽略 SCK 上的时钟。如果它们悬空在工业现场电磁干扰比较强的环境里随时可能被毛刺拉低导致 SPI 通信突然中断或者读回的数据完全错乱。这种问题在实验室里不容易出现因为环境安静可一到电机旁边就原形毕露。2.2 PCB上容易翻车的三个细节关于 PCB 布局我说几个自己实际踩过的点。第一MRAM 尽量靠近 MCUSPI 走线不要太长尤其是 SCK 信号时钟线长了之后反射和过冲会让数据采错。如果空间实在受限SCK 上可以串一个 22 欧姆到 33 欧姆的电阻抑制振铃。第二去耦电容要放在芯片电源引脚旁边不要隔着过孔很远接否则供电瞬时波动可能让芯片进入异常状态。第三WP 和 HOLD 的上拉电阻老老实实靠近 MRAM 放不要图省事放到 MCU 旁边中间那段走线如果被干扰拉低上拉也救不回来。还有一个细节是电源域。STM32F303RC 的很多开发板上有 5V 稳压到 3.3V 的设计如果 MRAM 的 VDD 是从同一路 3.3V 取电要确认 MCU 和 MRAM 共地良好不要让数字地和模拟地在这里形成环路。我的做法是MCU、MRAM、Flash 全部单点接地SPI 线上不放任何电平转换因为 MRAM 的工作电压就是 3.3V和 STM32F303RC 的 IO 电平正好匹配。2.3 SPI模式选择Mode 0还是Mode 3MR25H40CDF 的数据手册写明它支持 SPI Mode 0CPOL0CPHA0和 Mode 3CPOL1CPHA1两种模式。常规来说我直接用 Mode 0 就够也就是空闲时 SCK 为低数据在第一个时钟沿采样。如果你在其他板子上跑过这颗芯片发现模式 0 读出来不对检查一下是不是 STM32 的 SPI 配置把 CPHA 选错了。我建议在初始化里把 SPI 参数固定设置为SPI_MODE0同时在 MCU 复位后读取一次厂商 ID 或者状态寄存器确认通信正常再继续跑业务这样能避免后续排查时序问题时还要怀疑模式。3. 读写协议拆解写使能锁存、命令码与地址自增逻辑3.1 命令集和状态寄存器MR25H40CDF 的 SPI 命令非常精简主要就五条0x06 写使能WREN、0x04 写禁止WRDI、0x05 读状态寄存器RDSR、0x03 读数据READ、0x02 写数据WRITE。这里面的核心机制是写使能锁存——MRAM 和很多 SPI Flash 一样任何写操作之前必须先发一个 WREN 命令把状态寄存器里的 WEL 位置 1然后才能在后续的写命令里真正把数据写进去。写完一次之后WEL 会被自动清掉下次要写必须重新发 WREN。为什么要设计这个保护机制就是为了防止 SPI 总线上意外杂波把存储内容改掉。如果没有写使能这道闸门一根干扰线就可能把芯片里的参数改得面目全非。我实际做驱动的时候会做一个mram_write_enable()函数然后在每次写命令前调用并且顺手读一次状态寄存器确认 WEL 已经是 1再发起写。这个确认动作虽然多花几个时钟周期但能极大提高调试效率尤其是前期硬件还不稳定的时候。3.2 READ和WRITE的帧格式与3字节地址编码READ 和 WRITE 的帧格式非常像先把 CS 拉低然后发送命令码再发送 3 字节地址最后要么连续接收数据READ要么连续发送数据WRITE整个过程中 CS 始终保持低电平一帧结束后拉高 CS。注意MR25H40CDF 的地址虽然是 19 位对应 0x00000 到 0x7FFFF共 512KB但在 SPI 帧里依然按 3 字节地址发送高位字节只用到低几位比如地址 0x000123 就发送 0x00、0x01、0x23。很多从 SPI NOR Flash 转过来的人会习惯只发 2 字节地址结果就是高地址区域读写错乱这是个很容易忽略的细节。读数据的时候不需要写使能CLK 连续给芯片会从指定地址开始地址自动递增一直输出数据直到 CS 拉高为止。写数据同样支持连续写内部地址会自动递增而且不像 Flash 那样有页边界不用操心这一包不能跨页。我们可以利用这个特性做日志流式写入一次写几百个字节都没问题地址自动往上走走到 0x7FFFF 之后会回卷到 0x00000这在设计循环日志的时候特别方便。另外再说一下 WP 引脚和状态寄存器的关系。MR25H40CDF 的 WP 低电平时主要是禁止通过 WRSR 命令修改状态寄存器而普通的 WRITE 命令往存储阵列写数据并不会被 WP 低电平拦住所以有人想看“写保护”拦住整颗芯片是理解错了。不过为了板子稳定我仍然把 WP 上拉到高电平免得它在噪声环境里乱跳。4. 驱动代码实战从HAL库SPI底层到应用层封装4.1 SPI初始化与片选控制STM32F303RC 的 HAL 库初始化 SPI 比较简单我直接把关键配置贴出来。这里有一个容易出错的点HAL_SPI_Transmit和HAL_SPI_Receive在处理 CS 时不会自动控制 GPIO所以 CS 的拉高拉低必须自己包在函数里而且要保证整帧期间 CS 一直为低。SPI_HandleTypeDef hspi1; void MX_SPI1_Init(void) { 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_8; hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; HAL_SPI_Init(hspi1); }把CLKPolarity配成 LOW、CLKPhase配成 1EDGE对应的就是 SPI Mode 0。BaudRatePrescaler具体取几要看 APB 时钟频率我建议先把 SPI 时钟控制在 10MHz 以内跑通再去尝试更高频。MRAM 虽然写操作不需要等待但 SPI 时钟太快的时候信号完整性会先出问题这个后面排障部分会细说。片选宏定义我写成这样#define MRAM_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define MRAM_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET)4.2 核心驱动函数写使能、读状态、读数据、写数据接下来是最关键的四个函数。先看写使能和读状态#define MRAM_CMD_WREN 0x06 #define MRAM_CMD_WRDI 0x04 #define MRAM_CMD_RDSR 0x05 #define MRAM_CMD_READ 0x03 #define MRAM_CMD_WRITE 0x02 static void mram_write_enable(void) { uint8_t cmd MRAM_CMD_WREN; MRAM_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 1, 10); MRAM_CS_HIGH(); } uint8_t mram_read_status(void) { uint8_t cmd MRAM_CMD_RDSR; uint8_t sr 0; MRAM_CS_LOW(); HAL_SPI_Transmit(hspi1, cmd, 1, 10); HAL_SPI_Receive(hspi1, sr, 1, 10); MRAM_CS_HIGH(); return sr; }注意mram_write_enable里 CS 是先拉低、发完命令再拉高这是一个独立的 SPI 帧。如果在 WREN 之后直接接着发 WRITE 而不把 CS 重新拉高有些芯片会不认所以我们统一按命令帧数据帧两个阶段来处理。读状态寄存器也是一样先发 RDSR 命令CS 保持低然后读一个字节这个字节的 bit0 就是 WEL 位。再看读数据和写数据#define MRAM_SIZE 0x80000 // 512KB 4Mbit int mram_read(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t tx[4]; if ((addr len) MRAM_SIZE) return -1; MRAM_CS_LOW(); tx[0] MRAM_CMD_READ; tx[1] (addr 16) 0xFF; tx[2] (addr 8) 0xFF; tx[3] addr 0xFF; HAL_SPI_Transmit(hspi1, tx, 4, 10); HAL_SPI_Receive(hspi1, buf, len, 1000); MRAM_CS_HIGH(); return 0; } int mram_write(uint32_t addr, const uint8_t *buf, uint32_t len) { uint8_t tx[4]; if ((addr len) MRAM_SIZE) return -1; mram_write_enable(); MRAM_CS_LOW(); tx[0] MRAM_CMD_WRITE; tx[1] (addr 16) 0xFF; tx[2] (addr 8) 0xFF; tx[3] addr 0xFF; HAL_SPI_Transmit(hspi1, tx, 4, 10); HAL_SPI_Transmit(hspi1, (uint8_t*)buf, len, 1000); MRAM_CS_HIGH(); return 0; }读数据时先把命令和地址发出去然后立刻用HAL_SPI_Receive读回数据整个过程 CS 一直接地。有些初学者会在发完地址之后把 CS 拉高再拉低那样会终止当前帧读回来的全是不确定数据。写数据同理一定记住整个命令地址数据是一帧中间不能断 CS。至于为什么读操作不需要写使能很好理解读不会改变存储内容所以芯片厂商没给读加锁。4.3 应用层封装参数块、MAGIC与循环覆盖底层驱动写好后真正的业务诉求实际上是把一条记录安全地存起来、再安全地读出来。我在这颗 MRAM 上做了一套非常简单但实用的记录格式每个逻辑块固定 256 字节开头 4 字节放 MAGIC比如 0xA55A5AA5紧接着 4 字节放记录长度后面放数据体最后放两个字节的 CRC16。上电初始化的时候我会扫描一个预设参数区从地址 0x00000 开始每隔 256 字节读一个块先检查 MAGIC再算 CRCMAGIC 和 CRC 都正确才算有效记录。如果有人看到这里觉得这个做法眼熟那很正常这就是把常见的DB 记录格式搬到 MRAM 里因为 MRAM 不需要擦除所以扫描恢复的代价非常低不像 Flash 那样还有块对齐限制。日志区我单独规划成环形区维护一组写指针。每次要写日志就读出当前的写指针从指针处往 MRAM 里连续写一条记录写完把指针加上记录长度如果指针加上长度超过了日志区末尾就回到日志区起始地址直接覆盖最老的记录。整个过程不需要擦除不像 Flash 还要先找个空块把数据搬来搬去代码量能砍掉一半。5. 实测排障时钟频率、掉电时序和数据校验的三个大坑5.1 读回数据偶尔乱码HOLD引脚悬空和时钟过快调试初期我遇到一个非常头疼的问题大部分时间读写都正常但设备放在电机旁边偶尔会读回一大片 0xFF。最开始我怀疑是软件问题加了各种延时也没用后来拿示波器挂上 SCK 和 CS 才发现CS 信号在电机启动瞬间有长达几十纳秒的毛刺而 HOLD 引脚我当时设计了上拉电阻但还没焊接的时候确实等于悬空毛刺一旦串到 HOLD 上芯片就会进入暂停模式通信自然中断。把 HOLD 上拉电阻焊上之后毛刺问题立刻消失。这个经历给了一个教训MRAM 这类芯片的 HOLD 和 WP 引脚在原理图上看起来是不常用引脚但它们是真实功能脚悬空就等于把开关交给了噪声。我后来在所有项目里都强制要求这两只脚必须有明确的上拉而且原理图评审时不再允许预留不上拉。5.2 时钟频率太高导致的末尾数据错乱还有一个现象是连续读 512 字节时前面 400 字节都正常后面 100 字节偶尔出现随机错误。这让我一度怀疑芯片坏块后来深入排查发现是 SPI 时钟被超频了。STM32F303RC 的 SPI 时钟来自 APB 分频我最初把预分频设得偏小SCK 接近 20MHz而 MRAM 和 STM32 之间的走线长度又偏长信号过冲严重导致芯片在高位地址切换时采样出错。解决办法很简单把BaudRatePrescaler调大一档SCK 降到 10MHz 左右再测试一整天错误彻底消失。所以这里给各位一个比较保守的建议在没有认真做信号完整性分析之前SPI 时钟先按 10MHz 跑不要挑战数据手册上的标称最高值。MRAM 写得快不代表这种快能承受随意布线的板子。5.3 掉电瞬间最后一条数据丢失CS时序和双备份设计工业设备最怕的就是掉电瞬间。我做过一个掉电保存实验在电源断开的同时让 MCU 的掉电检测中断把当前电机位置写进 MRAM然后看 1000 次掉电后能恢复多少次。结果发现大约 2% 的情况最后一条位置数据是坏的。抓波形才发现掉电时 MCU 电压快速下降SPI 外设可能已经在发送命令的过程中停止此时 CS 还没来得及拉高芯片处于一个半工作状态数据根本没写完整。这不是芯片的问题而是外部供电时序的问题。针对这种情况我有三招第一掉电中断里只做最关键、最短的一次写操作不要尝试写几百字节的日志第二写完后CS拉高动作尽量用寄存器直接写不要在中断里调用带延时的HAL函数第三在设计上无论如何都保留双备份机制也就是同一份关键数据写两个不同地址读的时候先校验主备份坏掉就自动切到副备份。MRAM 的字节改写特性让双备份实现特别简单写完主份写副份中间没有擦除等待几乎不会出现两份同时损坏的情况。5.4 连续写100万次的实测结果为了验证 MRAM 的寿命声明我专门写了个压力测试每隔 100 毫秒往同一片区域的固定地址写 4 字节数据同时记录写入次数另一路线程读回比较。跑了大约 28 个小时写入次数超过 100 万次读回比较全部通过状态寄存器里的 WEL 也始终正常。这个实验虽然不能证明芯片能写 10 的 14 次方但至少证明了工业设备的日常高频写入对它来说完全不是负担。这个结论如果用 EEPROM 来做估计早就把芯片写废了。6. 再往后扩展分区规划、循环日志和多片级联思路6.1 用分区表管理512KB空间拿到 512KB 之后如果没有规划很快就会变成一锅粥。我建议上电初始化时先定义一张分区表比如起始地址结束地址用途说明0x000000x07FFF固定参数区产品序列号、固件版本、出厂校准值0x080000x27FFF循环日志区运行日志、故障记录0x280000x37FFF双备份关键数据区掉电位置、状态机快照0x380000x3FFFF系统保留区将来存放引导信息或诊断数据分区的好处是日志区不会因为写得太多把参数区覆盖掉参数区里的数据可以单独做频繁更新而不影响布局双备份区又给最关键的掉电数据提供最后一道保险。这种规划方式在 Flash 时代就得想在 MRAM 时代同样要想而且因为不用考虑擦除块对齐分区粒度可以做到很小。6.2 环形日志的写指针维护环形日志是最能发挥 MRAM 优势的应用。以前用 Flash 做循环日志时每次回卷都要先擦除一个 sector如果在擦除过程中掉电整个 sector 的数据全丢。MRAM 上做环形日志就简单得多记录区头部固定一小块存当前写指针和当前读指针每次写日志先把指针读数出来然后把新记录追加到指针指向的地址写完更新指针。因为 MRAM 允许按任意字节覆盖指针更新也是普通写操作整个过程没有擦除风险。如果日志区写满回卷直接覆盖旧数据完全不用在意这一块是不是脏块。我在实际代码里把写指针设计为两个副本因为指针本身也是非易失数据如果只存一份偶尔掉电导致指针错乱整段日志就废了。指针每次更新写两份读的时候优先取版本号更大的一份这样极端情况下也能找回日志大概位置。6.3 与STM32F303RC的更多SPI资源和级联扩展一片 512KB 不够怎么办STM32F303RC 上的 SPI 外设足够支持挂多片 MRAM。最简单的办法是给每片 MRAM 分配一个独立的 GPIO 做 CSSCK、MOSI、MISO 全部并联同一时刻只有一片 CS 为低剩下的器件处于高阻状态数据不会冲突。我测试过两片级联读写都正常速度基本没有损失。如果要更大容量还可以考虑往 Everspin 更高密度的 MRAM 型号走但那样 SPI 帧格式可能要跟着变换型号之前一定要重新看数据手册不要想当然沿用老的驱动。如果项目里既有 MRAM 又必须保留大容量 Flash我建议让 MRAM 走 SPI1Flash 走 SPI2两个外设互不干扰。这样既能享受 MRAM 的可靠写入又不牺牲 flash 的大容量优势。6.4 当掉电RAM用把关键变量直接映射到MRAM最后再分享一个更大胆的用法把实时性要求不高的关键变量直接映射到 MRAM 的地址上让 MCU 每隔一小段时间就主动刷新一次。比如设备不停更新的累计运行时间、当前模式状态你完全可以开一个 100ms 的定时器把这几项打包写进 MRAM而不必再单独做掉电保存流程。好处是逻辑很简单缺点是多花了 SPI 带宽但对于 STM32F303RC 这种主频 72MHz 的 MCU 来说毫无压力。我个人觉得这套 MR25H40CDF 与 STM32F303RC 的组合最大的价值不是某个具体函数写得有多巧妙而是它彻底解决了一个长期困扰工业设备开发者的矛盾既要非易失又要随便写。如果你正在做类似的控制器、仪器仪表或者数据记录设备可以放心尝试这套方案记得把 HOLD 和 WP 的上拉、SPI 时钟、双备份这几个点提前做好后面能省一大半排障时间。