去年调试一套产线数据记录设备时,我碰到过一件特别糟心的事:设备现场突然断电,再上电后,上次存的31条工艺参数里,有3条全变成了0xFF。排查到最后一颗普通的SPI NOR Flash,写入次数已经逼近寿命上限,而且那天断电正好撞上它擦除块写一半的瞬间。后来我把存储芯片换成 Everspin 的 MR25H40CDF,主控板也一并迁到 STM32F415RG,这些问题才彻底消停。
这篇文章就把这套组合从头到尾讲一遍:为什么选 MRAM 而不是 Flash 或 EEPROM、硬件接线和布局要注意什么、STM32F415RG 端 SPI 驱动怎么写、掉电保护怎么做,以及我实测下来踩过的坑。内容偏向工业级数据记录、参数存储、故障日志这类场景,愿意在这类设计上少走弯路的工程师应该都能直接用上。
1. 为什么在工业场景里我放弃了Flash和EEPROM,转投MRAM
1.1 从一次现场故障说起
那次故障其实不是偶发。设备是产线上的温控采集器,每 30 秒要把当前温度、压力写入本地存储,断电后要能读出最后一段完整曲线。原方案用了一片 SPI EEPROM,按一次写 4 字节计算,一天要写 2880 次,一年就超过百万次。而普通 SPI EEPROM 的典型擦写寿命也就几十万次到一两百万次,一年半就到临界点了。更麻烦的是,EEPROM 是页写机制,掉电时如果正在写一个页,可能只写了一半字节就断掉,恢复后读到的是半新半旧的数据。
一开始我以为是代码问题,后来把所有写流程加了一遍幂等校验,发现问题不在逻辑,而在芯片本身。很多工程师选存储时只看容量和速度,很少问“这个芯片能扛住多少次写操作”“掉电瞬间写了一半怎么办”。工业设备不比其他产品,一个数据位错了可能直接导致工艺参数错乱。从那之后,存储选型在我这儿成了第一道安全门槛。
1.2 EEPROM、Flash、MRAM,到底有什么区别
用生活化类比来解释,可能更好记:EEPROM 像一支铅笔加橡皮,可以反复写,但橡皮擦得慢,擦的次数多了纸就破了;NOR Flash 像一块黑板,写之前必须先把整块黑板擦干净,擦一块区域要几十毫秒;MRAM 像一叠便利贴,想改哪一条就直接撕下来重写,不用擦整页,写进去的信息也不依赖电源维持。
从参数上对比更直观:
| 维度 | 典型 SPI EEPROM | 典型 SPI NOR Flash | MR25H40CDF(MRAM) |
|---|---|---|---|
| 写耐久 | 10万~100万次 | 约10万次 | 官方宣称可到1e12次量级 |
| 写前擦除 | 部分型号要 | 必须整块擦除 | 不需要 |
| 单次写入最小粒度 | 字节/页 | 按页写,先擦后写 | 字节/任意长度 |
| 写后等待 | 毫秒级 | 几毫秒到几十毫秒 | 无额外等待 |
| 数据保持 | 一般10~20年 | 一般10~30年 | 典型20年以上 |
| 工作温度 | 常见-40~85℃ | -40~85℃或-40~125℃ | 工业级-40~125℃ |
| 容量选择 | 小容量居多 | 大容量方便 | 常见4Mbit/32Mbit等 |
“具体数值以对应芯片的官方规格书为准,但对比关系是这种趋势。” Flash 的优势是容量大、成本低;EEPROM 稳定但小、慢、寿命短。MRAM 则同时把“快写、无擦除、高耐久、掉电不丢”集齐了。容量虽然不如 Flash 大,但在工业控制现场存参数和日志,往往 512KB 就足够,没必要为容量牺牲可靠性。
1.3 MR25H40CDF 这颗芯片的实际定位
MR25H40CDF 是 Everspin 家族的一颗 4Mbit(512KB)串行 SPI MRAM。它采用标准 SPI 接口,工作电压 3.3V,支持最高 40MHz 左右的时钟频率,具体上限看规格书。属于工业级温度范围,典型-40℃到125℃,在户外设备和机柜环境中特别有用。
这颗芯片最打动我的不是“省电”,而是“写操作太干净了”:不需要擦除,没有页缓冲的条条框框,CS 拉低后直接把地址和数据发过去就完成了。这点对嵌入式系统来说意义很大——老的一套 Flash 驱动里要处理擦除、写保护、页编程、读状态、等待忙等,换成这颗 MRAM,驱动代码量能砍掉一大半。
当然,它不是万金油。512KB 容量存不了音视频,也不适合放完整文件系统。它更适合高频率写入的数据记录、设备参数备份、掉电保存的现场快照、程序运行日志这类“小、快、频繁”的操作。如果单颗容量不够,还可以多颗级联,用不同片选分开管理。
2. MR25H40CDF与STM32F415RG的硬件连接细节
2.1 芯片引脚和接线方式
MR25H40CDF 常见的是 8 脚 SOP 封装,引脚定义和主流 SPI NOR Flash 并不是一一对应。下面是我实际用的接线表,主控选择 STM32F415RG 的 SPI1,片选用普通 GPIO 软件控制:
| 引脚编号 | 引脚名 | 接到STM32F415RG | 说明 |
|---|---|---|---|
| 1 | CS# | PA4 (GPIO输出) | 片选,低电平有效 |
| 2 | SCK | PA5 (SPI1_SCK) | SPI时钟 |
| 3 | SI | PA7 (SPI1_MOSI) | 主发从收 |
| 4 | SO | PA6 (SPI1_MISO) | 主收从发 |
| 5 | WP# | VDD (3.3V) | 写保护,拉高允许写 |
| 6 | VSS | GND | 地 |
| 7 | HOLD# | VDD (3.3V) | 保持,拉高禁用暂停 |
| 8 | VDD | 3.3V | 电源 |
这里特别提醒:WP# 和 HOLD# 一定要拉高,不能悬空。我在第一版板子上想省事,把 HOLD# 直接悬空,结果常温调试没问题,一到现场偶尔出现写操作莫名失效的情况。后来示波器抓才发现,是 HOLD# 引脚引入了噪声干扰,导致芯片进入了保持状态。把这两个引脚都处理成高电平之后,再没出现过类似问题。
片选脚 PA4 我没有复用成 SPI_NSS,而是用普通 GPIO 推挽输出。原因很简单:SPI_NSS 硬件模式出问题时,时序不好控制,软件片选更直观,写高写低自己心里有数。速度上,GPIO 翻转一次大概几个时钟周期,对低频片选没有压力。
2.2 最小电路与去耦布局
供电方面,MR25H40CDF 的 VDD 建议就近放 0.1uF 陶瓷电容和 10uF 钽电容组合。Mcu 侧的 3.3V 要保证纹波不能太大,尤其掉电瞬间,电压跌落的速度直接关系到能否保存最后一笔数据。
信号线上,我习惯在 SCK、SI、CS 上各串一个 22Ω 的小电阻。很多工程师觉得串电阻多余,但是在长距离排线连接、或 PCB 层叠不理想的情况下,这个电阻能显著减少信号过冲和振铃。如果做在 FPC 排线中间,效果更明显。MISO 这条线尽量不要和 SCK 平行走太长,中间最好隔一条地线,避免时钟信号串扰到返回数据上。
整体布局遵循一个原则:MRAM 要靠近 STM32F415RG 的 SPI 引脚端,中间不要跨多个过孔,电源线和信号线分开走。如果实在没法靠近,至少保证 CS 和 SCK 等长,否则高速擦写时可能因为时序偏斜而读取失败。
2.3 与常见SPI Flash的封装区分别
很多工程师会顺手把 MRAM 替换成之前用过的 SPI Flash 封装库,比如常见的 25Q16 引脚排列就不同。两者的 CS、SCK、SI、SO 位置不是完全一致的,尤其是 WP# 和 HOLD# 的顺序。出了 PCB 板之后想改电路很难,所以在画板前一定要对着数据手册的引脚图重新检查封装,不能想当然用 Flash 的封装库。
这一点我在一次交流会上还听人提过:有项目把 MR25H40 焊到 Flash PCB 上,上电后读数据全是 0xFF,最后才发现引脚错位。好在芯片没烧,拆下来重新贴到正确位置才恢复。
3. 驱动代码:从零配置SPI和操作指令
3.1 STM32F415RG的SPI初始化
STM32F415RG 的 SPI1 挂在 APB2 总线上,系统时钟 168MHz 时 APB2 默认 84MHz。MR25H40CDF 的 SPI 时钟上限标称 40MHz 左右,所以初始化的分频系数不能乱选。如果把 APB2 的 84MHz 直接 2 分频得到 42MHz,理论上是超过了这颗 MRAM 的上限,除非芯片批次和数据手册确认能稳跑,不然不建议冒险。
我实际用的是 4 分频,也就是 21MHz。对工业数据记录来说,这个速度已经很快了,且余量充足。初始化代码如下:
void MRAM_SPI_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; // 使能SPI1与GPIOA时钟 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE); // PA4 CS,输出推挽 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA5 SCK, PA7 MOSI,复用功能 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_Init(GPIOA, &GPIO_InitStructure); // PA6 MISO,复用功能,输入上拉 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; GPIO_Init(GPIOA, &GPIO_InitStructure); // 配置AF引脚为SPI1 GPIO_PinAFConfig(GPIOA, GPIO_PinSource5, GPIO_AF_SPI1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource6, GPIO_AF_SPI1); GPIO_PinAFConfig(GPIOA, GPIO_PinSource7, GPIO_AF_SPI1); SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; // SPI Mode 0 SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_4; // 84/4=21MHz SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_InitStructure.SPI_CRCPolynomial = 7; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); CS_HIGH(); // 片选默认拉高 }这个初始化里有两个容易被忽略的点。第一,PA6 作为 MISO,在 MCU 内部是接收引脚,但同样要配置成复用功能。第二,CS 脚 PA4 不能配成复用,它只是普通 GPIO,因为我不用硬件 NSS。
3.2 MR25H40CDF指令集与状态寄存器操作
MR25H40CDF 的指令集和普通 SPI NOR Flash 有不少相似之处,但写操作简单很多。我实际用到的核心命令如下:
| 命令 | 字节码 | 用途 |
|---|---|---|
| WREN | 0x06 | 写使能 |
| WRDI | 0x04 | 写禁止 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 普通读数据 |
| FREAD | 0x0B | 快速读数据 |
| WRITE | 0x02 | 写数据(无需擦除) |
状态寄存器里最重要的位是 WEL(写使能锁存位),一般在 bit 1。写操作之前必须先发 WREN,这是硬性要求。否则芯片直接忽略 WRITE 命令,整个写入流程看起来“执行了但数据没变”。
读写命令的实现比较简单:
// 写使能 void MRAM_WriteEnable(void) { CS_LOW(); SPI_SendByte(MRAM_CMD_WREN); CS_HIGH(); } // 读状态寄存器 uint8_t MRAM_ReadStatus(void) { uint8_t status = 0; CS_LOW(); SPI_SendByte(MRAM_CMD_RDSR); status = SPI_RecvByte(); CS_HIGH(); return status; } // 批量写,address 为24位地址,len 为字节数 void MRAM_WriteBytes(uint32_t address, const uint8_t *data, uint32_t len) { MRAM_WriteEnable(); CS_LOW(); SPI_SendByte(MRAM_CMD_WRITE); SPI_SendByte((address >> 16) & 0xFF); SPI_SendByte((address >> 8) & 0xFF); SPI_SendByte(address & 0xFF); for (uint32_t i = 0; i < len; i++) { SPI_SendByte(data[i]); } CS_HIGH(); } // 批量读 void MRAM_ReadBytes(uint32_t address, uint8_t *data, uint32_t len) { CS_LOW(); SPI_SendByte(MRAM_CMD_READ); SPI_SendByte((address >> 16) & 0xFF); SPI_SendByte((address >> 8) & 0xFF); SPI_SendByte(address & 0xFF); for (uint32_t i = 0; i < len; i++) { data[i] = SPI_RecvByte(); } CS_HIGH(); }这段代码几乎没有“等待忙”的过程。因为 MRAM 写操作不像 Flash 一样有“内部编程时间”,CS 拉高后数据就落到存储阵列里了,不需要轮询 WIP(写进行中)位。这对中断上下文、掉电保存场景特别友好。
3.3 数据长度与页边界的处理差异
如果是第一次从 SPI Flash 转向 MRAM,最容易写出“画蛇添足”的代码:写入前判断地址是否跨页、剩余长度是否超过页大小、要不要先页对齐。这些都是 Flash 驱动的惯性思维。
MR25H40CDF 没有页的概念。我实测过,CS 保持低电平,连续发送几 KB 数据,地址会自动递增,不需要像 Flash 那样每 256 字节断一次。当然前提是你的 SPI 控制器能持续发送且不会 FIFO 溢出。如果数据量很大,用 DMA 配合环形缓冲会更稳。裸机阻塞发送也没问题,就是耗时随数据量线性增加。
如果你是在嵌入式 Linux 或 RTOS 下用,标准 SPI 设备驱动能直接对接到 SPI 总线,但内核自带的 spi-nor 驱动可能不会直接认这颗 MRAM,因为 JEDEC ID 不属于 Flash 类型。更干净的做法是写一个独立的 spidev 驱动,或者用 RTOS 的 SPI 设备框架操作。
4. 把数据安全落地:掉电保护、校验与冗余
4.1 掉电的时候,MRAM能救什么,不能救什么
MRAM 不靠电荷保存数据,所以掉电后内容不会丢,这是它的先天优势。但“写入”这个动作本身还是需要供电的。换句话说:芯片能记住写入结果,但如果写一半时 VDD 已经低于最低工作电压,那一次的写入结果就无法保证是完整的。
所以掉电保存的关键点不在 MRAM,而在 MCU 侧怎么判断掉电,以及能在掉电后维持多久的有效供电。STM32F415RG 本身带 PVD(可编程电压检测),可以配置一个电压阈值,当 VDD 跌到阈值以下时触发中断。在这个中断里,我来不及做复杂的写日志,只把当前最关键的状态字和最新一条记录的尾巴写进 MRAM。
配合硬件,我会在电源入口放一个比较器,监控更前级的 24V/12V 输入。一旦检测到外电断开,立刻给 MCU 一个外部中断,MCU 中断服务里直接调用MRAM_WriteBytes,把当前运行参数保存到一个固定地址。储能电容要足够维持这段时间的供电,实测写 64 字节加几个辅助操作,总耗时 1ms 左右,一个 470uF 电容在 3.3V 域上撑几毫秒完全没问题。
别指望在掉电中断里写几百 KB 的日志,那不现实。掉电保存只救最关键的数据,其余可以靠周期性更新的辅助副本。
4.2 用CRC32和“双区”对抗数据损坏
MRAM 本身可靠性很高,但工业现场电磁干扰很复杂,主控和存储之间也可能因为某个瞬态脉冲读到错误数据。所以我在数据组织上还是做了三层保险:
第一,每一条记录都有固定帧结构:
typedef struct { uint32_t magic; // 固定魔数,如 0x4D52414D uint32_t crc; // CRC32 uint16_t length; // 数据长度 uint16_t seq; // 序列号,供乱序恢复 uint8_t payload[]; // 真正的内容 } LogRecord;每次读记录,先校验 CRC。CRC 不过,这条记录就不算数。如果魔数也对不上,说明记录区域可能被覆盖过,属于更严重的异常。
第二,关键参数采用主备用两份。主副本写在地址 A,备副本写在地址 B。启动时先读主副本,如果 CRC 失败再去读备副本。这个做法的成本很低,可以把“单片存储偶发错误”的概率再压下去一个数量级。
第三,启动自检阶段会全片扫描一次日志区:把所有非法魔数、CRC 错误、长度异常的记录标记出来,并把扫描结果打印到调试串口。这样问题在设备投运前就能发现,而不是等现场出故障再去分析。
4.3 磨损均衡这次真的不用折腾
以前用 Flash 的时候,磨损均衡几乎成了标配代码:每个块记录擦写次数、动态选择写入块、定期做块迁移。这套东西复杂且容易出 bug。换成 MRAM 之后,我直接砍掉了磨损均衡模块。
原因很简单:按每天写 10 万次算,一年也才 3650 万次,远不到 MRAM 官方的 1e12 次量级。就算保守按 1e10 次算,也能跑 20 多年。所以在这个应用里,与其冒着增加复杂度引入 bug 的风险去做磨损均衡,不如把精力放在 CRC 校验和双副本上。
当然,如果是把 MRAM 当小型文件系统用,仍然建议按固定区域循环写,不是为了寿命,而是为了定位数据版本和便于垃圾回收。但驱动的核心部分不会再被“先擦后写”的时序拖住。
5. 实测数据与踩坑记录
5.1 性能实测:写操作没有等待,是真的爽
我在 21MHz SPI 时钟下的实测数据(阻塞式收发,函数调用开销计入):
| 操作 | 内容 | 实测耗时 | 说明 |
|---|---|---|---|
| 写单字节 | WREN + WRITE + 1 byte | 约 10us | 主要是函数调用和CS翻转 |
| 写512字节 | WREN + WRITE + 512 byte | 约 260us | 无擦除,无页对齐要求 |
| 读512字节 | READ + 512 byte | 约 250us | 线性读 |
| 擦除+写512字节 | W25Q16 对比 | 约几十ms | Flash要先擦一个扇区再写页 |
这个对比差异非常直观。如果产线设备需要断电前保存 1KB 数据,MRAM 方案在 1ms 内就能完成,而 Flash 方案经常要等擦除。等的时间越长,掉电导致写了一半的概率越大。这也是工业记录设备选型时最容易被低估的点。
5.2 踩过的坑:SPI时钟调到42MHz,读了几天后偶然出错
我第一版把 SPI1 分频调到 2,得到 42MHz,想着 MR25H40CDF 标称 40MHz,应该差一点没关系。结果常温验证没问题,连续跑了两天,第三天读到一大块数据出现错位。排查下来不是芯片坏了,而是信号完整性边缘问题。
原因有三层:一是 42MHz 确实超出官方标称上限,虽然芯片内部可能能跑,但数据手册不给保证;二是板子上 SO 和 SCK 在 PCB 上并行走了一段,串扰在临界时钟沿上被放大;三是 STM32 引脚驱动强度过大,信号边沿太陡,产生了振铃。
解决办法是分频改到 4(21MHz)、SCK/SI 上各串 22Ω、SO 和 SCK 之间加地线隔离。改完之后连续跑了半个月,再没出过错。这个坑给我的教训是:标称 40MHz 不等于你可以跑满 40MHz,工业产品要在时序上留出至少 20% 余量。
5.3 抓波形时要注意的三个细节
如果你要验证时序,示波器抓 CS#、SCK、SI 三个信号就够了。我通常会在写数据时加一个 SCK 的放大视图,确认 SCK 低电平中间的数据建立保持时间是否足够。
第一个容易忽略的细节:CS# 拉低后,必须等 SCK 第一个上升沿之前至少一个时钟周期的准备时间,芯片才能真正开始接收命令。有些 STM32 库函数在拉低 CS 后立刻发首字节,速度太紧可能导致首字节被当成噪声。我会在CS_LOW()后加 3 个 nop 延时,效果稳定。
第二个细节是片选释放的时机。CS# 拉高太早,最后一位数据可能还没被锁存。用示波器看 CS# 和 SCK 的关系,确保 CS# 拉高时 SCK 已经跑完最后一个周期的下降沿,并且保持了一段时间的高电平。简单说就是“指令发完再抬CS”。
第三个细节是用逻辑分析仪长期监测写入地址和状态寄存器。我遇到过某批次代码因为编译优化,导致写地址变量被意外截断,写到了错误的地址范围。逻辑分析仪把地址抓出来一看就发现问题,比盲调代码快得多。
后来我把这一套存储方案沉淀成了一个可复用的驱动模块,新项目只要改片选和 SPI 引脚就能直接用。MR25H40CDF 和 STM32F415RG 这对组合,在工业数据记录、参数备份、掉电快照这些场景里,省掉了太多 Flash/EEPROM 的糟心事。如果你正在为“频繁写入怕寿命不够、掉电保存怕数据丢”纠结,不妨认真考虑一下 MRAM,它给设计带来的简化,远比那颗小芯片的表面积更有价值。