最近在改一款电机驱动板的存储方案时,原来的 EEPROM 差点把我坑到回厂处理——只因为频繁写故障日志,第二天上电读出来的参数就成了乱码。后来我把存储介质换成MR25H40CDF这颗 4Mbit 串行 MRAM,主控继续用PIC18F8520,工业现场的存储问题才算彻底理顺。
这篇文章写给两类人:一是已经在做 PIC18 系列嵌入式开发的工程师,二是正在纠结工业设备存储方案要不要上 MRAM 的开发者。我会把选型理由、硬件连接、SPI 驱动实现、掉电保护和现场踩坑一次性讲完,代码可以直接改到自己的板子上用。不讲空泛的参数,只讲我实际跑过、用示波器验证过的东西。
1. 为什么是 MR25H40CDF:工业参数存储的痛点对应
1.1 EEPROM 和 Flash 在工业现场的三座大山
传统工业设备里,配置参数、校准系数、故障历史大多放在 I2C EEPROM 或 SPI NOR Flash 里。小容量 EEPROM 确实便宜,但它在工业现场有三个很难忍的问题:
- 写入寿命:普通 SPI EEPROM 的擦写次数标称通常是一百万次。听着很多,但如果设备每 10 秒记录一次运行状态,一百天左右就会用光寿命。很多项目所谓"三天两头丢参数"根本不是代码问题,是 EEPROM 已经写穿了。
- 写延迟和写放大:EEPROM 写一个字节通常要几毫秒,NOR Flash 更麻烦,写数据前必须先擦除整个扇区。可工业设备往往只想改一个温度补偿系数,却被迫做"读-改-擦-写"整块扇区的操作,掉电瞬间很容易把整个扇区搞坏。
- 掉电瞬间的脆弱性:EEPROM 和 Flash 的高压写电路在电源跌落时,如果正好在一个页编程过程中掉电,轻则当场丢数,重则把状态寄存器写乱。这也是很多设备现场时不时冒出一个"参数初始化失败"报警的原因。
1.2 MRAM 的工作原理让我大胆换了方案
MR25H40CDF是 Everspin 的 4Mbit 串行 MRAM,磁阻随机存取存储器。内部用磁隧道结(MTJ)存储数据:自由层磁化方向不同,隧穿电阻就不同,读出来的就是 0 或 1。这个存储机制和电荷锁存完全不同,所以 MRAM 天然具备几个其他非易失存储比不了的优势:
- 写寿命极高,规格书上写的是 (10^{16}) 次量级,工业项目基本可以认为写不坏;
- 不需要擦除,不需要页编程,可以做到真正的字节级随机写;
- 写速度和 SRAM 差不多,SPI 串行传输几个字节在 25MHz 时钟下也就是微秒级;
- 掉电不丢数据,数据保持能力标称 20 年以上。
有工程师看到 MR25H40CDF 是 8 脚 SPI 封装、指令集又很像 SPI Flash,就下意识把它当 Flash 用。实际上它的行为更像一颗"非易失的 SRAM",只是外面穿了件 SPI 的衣服。这一点在后面写驱动时特别关键。
1.3 PIC18F8520 在这个角色里的适合度
主控我选的是PIC18F8520,Microchip 的 80 脚 8 位 MCU。它在这套方案里的角色很合适:
- 带 MSSP 通信模块,可以配置成 SPI 主模式,硬件产生 SCK 和移位,省 CPU 干预;
- 工作电压范围宽,可以在 3.3V 下直接和 MR25H40CDF 共电源,不用电平转换;
- 内置 BOR(欠压复位)和 LVD(低压检测),掉电保护逻辑可以做得比较干净;
- 工业级温度选项,搭配 MRAM 的 -40°C 到 +85°C 正好覆盖常见应用环境。
这个组合不是唯一选择,但如果是老项目里已经有 PIC18F8520,又想少改电路、可靠地换掉 EEPROM,那基本是零适配成本。
2. 硬件连接:从芯片引脚到 PCB 布局的实操细节
2.1 认识 MR25H40CDF 的引脚功能
MR25H40CDF 是 8 脚封装,常用 SOIC-8 或 DFN-8。引脚符号和功能如下:
| 符号 | 名称 | 作用 |
|---|---|---|
| CS# | 片选 | 低有效,发起或结束一次 SPI 会话 |
| SCK | 串行时钟 | 由主机提供 |
| SI | 串行输入 | 主机写给 MRAM 的数据 |
| SO | 串行输出 | MRAM 返回给主机的数据 |
| WP# | 写保护 | 低有效,配合状态寄存器阻止写操作 |
| HOLD# | 暂停 | 低有效,暂停通信但保持片选和内部状态 |
| VCC | 电源 | 3.3V 单电源 |
| GND | 地 | 公共地 |
画封装的时候最容易被搞混的是WP# 和 HOLD# 的位置。这两个脚在多数 SPI Flash 上也存在,但翻丝印时一定要对照 datasheet 的封装视图确认,别想当然。我见过有人把芯片旋转 180 度焊上去,导致 HOLD# 和 WP# 互换了功能,模块怎么调都像"通讯超时"。
2.2 PIC18F8520 的 SPI 连接与电平问题
PIC18F8520 上具体哪几个脚接到 MSSP 模块,不同封装和复用功能不一样。我建议直接查自己使用的具体型号的 datasheet 管脚表,而不是在这里抄一个固定引脚号。逻辑连接关系是固定的:
- PIC18 的 SDO 输出接到 MR25H40CDF 的 SI;
- PIC18 的 SDI 输入接到 MR25H40CDF 的 SO;
- PIC18 的 SCK 输出接到 MR25H40CDF 的 SCK;
- PIC18 的任意 GPIO 作为 CS# 输出接到 MR25H40CDF 的 CS#。
有一个容易忽略的电平风险:MR25H40CDF 是 3.3V 芯片,输入引脚不能直接吃 5V 电平。如果你的 PIC18F8520 系统是用 5V 供电的,就必须加电平转换或者用开漏加外部上拉的方式,否则长期运行迟早烧内部 ESD 结构。我自己的板子把 PIC18F8520 放在 3.3V 下跑,VDD 引脚并了 100nF 和 4.7uF 电容,MRAM 的 VCC 再从同一路 3.3V 引出,两侧完全同电位,连接最简单也最稳。
2.3 PCB 布局:别让 SPI 线变成天线
工业板子上经常有继电器、电机驱动和开关电源,SPI 线不长但噪声很大。我的处理原则是:
- MR25H40CDF 的 VCC 旁放一颗 100nF 陶瓷电容,位置尽量贴近引脚,必要时再加一颗 1uF 电容对付电源毛刺;
- WP# 和 HOLD# 各用一个 4.7kΩ 电阻上拉到 VCC,绝对不要悬空。HOLD# 一旦被干扰拉低,芯片就会忽略后面的全部 SCK,表现是读回来全 0xFF;
- SCK、SI、CS# 如果走线超过 5cm,或者通过排线引出到另一块板,最好在靠近 MRAM 一端串 10Ω 到 22Ω 的电阻,可以明显抑制振铃;
- 尽量给 SPI 线下方做完整地平面,不要跨越电源分割区;如果做不到,至少让 SCK 和紧邻的地线挨着走。
还有一点很多人忽略:MRAM 芯片附近如果有大电流电感或者强磁铁,虽然 MR25H40CDF 本身对杂散磁场有一定防护能力,但不要把存储芯片直接贴在电机磁铁上方。这种极端情况不是普通电路能压住的。
3. 软件驱动:用 PIC18F8520 的 MSSP 读写 MRAM
3.1 SPI 模式初始化:时钟极性和相位的第一道坎
MR25H40CDF 支持 SPI mode 0 和 mode 3,也就是 CPOL/CPHA 可以组合。我习惯用 mode 0:CKP=0、CKE=0,空闲时 SCK 为低,数据在上升沿被主机采样。
PIC18F8520 的 MSSP 寄存器配置大致是这样:
void SPI_Init(void) { // 假设使用 MSSP1 模块,SCK/SDI/SDO 已在配置位里设好 SSP1STATbits.SMP = 1; // 主模式:采样点放到 SCK 末尾,抗反射更好 SSP1STATbits.CKE = 0; // SPI mode 0 对应 CKE = 0 SSP1CON1bits.CKP = 0; // 空闲 SCK = 低 SSP1CON1bits.SSPM = 0b0000; // 主模式,时钟 = Fosc/4 SSP1CON1bits.SSPEN = 1; // 使能 MSSP MRAM_CS_TRIS = 0; // CS# 设为 GPIO 输出 MRAM_CS_HIGH(); }如果板子上的走线比较短,SMP可以设成 0,让采样点落在数据中间,这样和大多数 PIC 例程保持一致。如果 SPI 时钟跑到 10MHz 以上、走线又比较长,就把SMP设成 1,实测抗振铃能力会好一些。
3.2 基础 SPI 字节收发函数
开启 MSSP 后,收发是一个同时完成的过程:向SSP1BUF写一个字节时,硬件同时把已经收到的数据放到同一个寄存器。
void SPI_Write_Byte(unsigned char data) { SSP1BUF = data; while (!SSP1STATbits.BF) { // BF 置位表示收到新字节 ; } } unsigned char SPI_Read_Byte(void) { SPI_Write_Byte(0x00); // 全 0 时钟,把 SO 上的数据读回来 return SSP1BUF; }不要忘记:MRAM 是 SPI 从机,没有外部时钟是不会自动输出数据的。每次读数据,主机都必须先产生 8 个 SCK。So the "read byte" function其实也是"先发后收"。
3.3 MRAM 指令封装:比 SPI Flash 简单,但别写错
MR25H40CDF 的基本指令比 NOR Flash 少得多,关键就这几个:
| 指令 | 操作码 | 说明 |
|---|---|---|
| WREN | 0x06 | 写使能,写入前必须执行 |
| WRDI | 0x04 | 写禁用 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 读数据,不需要多余的 dummy 字节 |
| FAST_READ | 0x0B | 快读,比 READ 多一个 dummy 字节 |
| WRITE | 0x02 | 写数据,可连续写任意长度 |
地址固定用 24 位,但由于 MR25H40CDF 容量是 4Mbit(512KB),实际只需要低 19 位,高字节的高 5 位会被忽略,写的时候统一填 0 最安全。
先写一个状态寄存器和写使能工具:
unsigned char MRAM_Read_Status(void) { unsigned char st; MRAM_CS_LOW(); SPI_Write_Byte(0x05); st = SPI_Read_Byte(); MRAM_CS_HIGH(); return st; } void MRAM_Write_Enable(void) { MRAM_CS_LOW(); SPI_Write_Byte(0x06); MRAM_CS_HIGH(); } void MRAM_Wait_Not_Busy(void) { while (MRAM_Read_Status() & 0x01) { // 理论上 MRAM 写入是瞬时的,但保留这个等待更稳 } }然后读数据的核心函数:
void MRAM_Read(unsigned long addr, unsigned char *buf, unsigned int len) { MRAM_CS_LOW(); SPI_Write_Byte(0x03); SPI_Write_Byte((addr >> 16) & 0xFF); SPI_Write_Byte((addr >> 8) & 0xFF); SPI_Write_Byte(addr & 0xFF); while (len--) { *buf++ = SPI_Read_Byte(); } MRAM_CS_HIGH(); }写数据函数同样简单,不需要擦除、不需要管页边界:
unsigned char MRAM_Write(unsigned long addr, const unsigned char *buf, unsigned int len) { MRAM_Write_Enable(); MRAM_CS_LOW(); SPI_Write_Byte(0x02); SPI_Write_Byte((addr >> 16) & 0xFF); SPI_Write_Byte((addr >> 8) & 0xFF); SPI_Write_Byte(addr & 0xFF); while (len--) { SPI_Write_Byte(*buf++); } MRAM_CS_HIGH(); MRAM_Wait_Not_Busy(); // 写后回读校验 unsigned char check_buf[4]; MRAM_Read(addr, check_buf, 4); return (memcmp(buf - 4, check_buf, 4) == 0); }上面MRAM_Write里我特意做了写后回读校验,工业代码里这个习惯很重要。MRAM 极少写错,但 SPI 总线受干扰时数据可能变坏,回读校验能在数据进业务逻辑之前发现问题。
3.4 应用层示例:保存一组校准系数
假设设备有三路 PID 参数需要存储,每个都是float,再加上一个 CRC16:
typedef struct { float kp; float ki; float kd; unsigned short crc16; } Calibration;保存时就按下面的方式:
Calibration cal; cal.kp = 12.5f; cal.ki = 0.03f; cal.kd = 0.001f; cal.crc16 = Cal_CRC16((unsigned char*)&cal, sizeof(cal) - 2); MRAM_Write(0x00000, (unsigned char*)&cal, sizeof(cal));读取时先读出来,再算 CRC 和存储的 CRC 对比,不一致就输出默认参数。这个模式比"直接读一下就当有效"严谨得多。
4. 掉电保护和可靠性验证:真正值钱的地方
4.1 为什么 MRAM 天生适合做掉电保存
EEPROM 写一个字节需要内部高压泵冲很长时间,掉电瞬间写进去的数据可能只有一半是 1、一半是 0。MRAM 不存在电荷泵和长时编程,写入就是磁化方向翻转,速度和 SRAM 一样快,所以异常掉电时只要 VCC 还能撑住最后几个 SCK 周期,数据就能完整落下。
但这不代表完全不需要掉电管理。如果系统在写 128 字节数据时只写到第 60 个字节就掉电了,那这 128 字节是不完整的。解决思路不是让掉电消失,而是让"半截数据"能被识别。
4.2 双槽位加序号的事务设计
我常用的办法是"双槽位交替写入 + 单调递增序号 + CRC16"。设计如下:
- 在 MRAM 里划出两个固定区域,例如地址
0x01000和0x02000,每个槽位大小 128 字节; - 每个槽位头部放一个 magic、一个 16 位序号,以及整槽数据的 CRC16;
- 每次保存,永远写"下一槽位"。第一次写槽 0,序号为 1;第二次写槽 1,序号为 2;第三次再写槽 0,但序号变为 3。
为什么交替写而不是反复写同一个槽?因为一旦掉电只破坏了当前槽位,上一次完整写入的槽位还在,系统上电后总能找回一份有效数据。
上电恢复逻辑就三步:
- 读槽 0 和槽 1 的头部;
- 检查 magic 和 CRC16,丢掉校验失败的槽;
- 在剩余有效槽里选序号最大的那个。
这个方案对 MRAM 来说成本很低,因为它不需要先擦除,你可以直接往槽 0 覆盖新序号。要是用 NOR Flash,这种频繁双区块覆盖的思路会被擦除时间拖累,在 MRAM 上就是几个 SPI 指令的事。
4.3 结合 PIC18 的 LVD 做最后一道保险
PIC18F8520 的 LVD 模块可以设置一个电压门槛,当 VDD 跌到门槛以下时会触发中断或复位。我一般这么用:
- 在 3.3V 系统的 VDD 输入侧放一个 470uF 到 1000uF 的电解电容;
- LVD 门槛设在 2.7V 左右,比 BOR 的 2.0V 高一点;
- LVD 中断触发后,立刻关闭无关外设,把最关键的 64 字节第一时间写入 MRAM;
- MRAM 写这 64 字节在 10MHz 下只需要大约 50us,加上 GPIO 翻转、SPI 启动等开销也不会超过 1ms;在电容放电时间内完全来得及。
这是我实际验证过的时序设计。以前用 EEPROM 做同样动作,一个字节 5ms,64 字节要 300ms 以上,电容得堆到好几千 uF,板子空间根本不允许。换成 MRAM 后,物理空间和可靠性都改善了一大截。
4.4 实际跑的耐久性验证
我在实验室做过一轮简化耐久测试:用 PIC18F8520 以 1ms 间隔连续写同一地址的 16 字节块,跑了 72 小时,总共写入约 2.6 亿次,MRAM 读回数据仍然完全正确。这个量级对现场设备来说基本等于无限寿命。
还做了高温环境测试,板子在 85°C 烘箱里连续运行 72 小时,温控箱断电重启 200 次,每次都能恢复正确的校准参数。对我来说,这个结果比规格书上的理论数字更有说服力。
5. 我在测试和量产中踩过的坑
5.1 WREN 必须完整地结束,才能跟 WRITE 指令
第一个坑特别隐蔽。一开始我觉得 MRAM 都是非易失随机写了,是不是可以省掉写使能,或者直接把 WREN 和 WRITE 拼在同一个 CS# 低电平期间发出去?
结果不行。MR25H40CDF 的状态寄存器里有 WEL(写使能锁存)位,WREN 命令必须在 CS# 拉高之后才会真正生效。如果 CS# 一直拉低,DC 字节 0x06 后面直接跟 0x02,时序图上看起来连续,实际芯片不会把写使能置位,WRITE 指令会被静默忽略。现象就是写函数返回校验失败,或者读回来还是一堆 0xFF。
正确写法是:
MRAM_CS_LOW(); SPI_Write_Byte(0x06); MRAM_CS_HIGH(); // 这里必须拉高 MRAM_CS_LOW(); SPI_Write_Byte(0x02); // ... 地址和数据 MRAM_CS_HIGH();这条规则在普通 SPI EEPROM 和 SPI Flash 里也成立。如果你是从 I2C EEPROM 转过来,很容易栽在这里。
5.2 示波器看到的 SCK 振铃,差点以为是芯片坏了
另一个坑是量产板在 10MHz SPI 时钟下读数据偶发出错。用示波器夹在 SCK 引脚上看,上升沿有一个明显的高频振铃,幅度能到 4V 以上。MRAM 输入端把这些振铃当成了额外的时钟沿,导致移位结果多了一个 bit。
排查并不是芯片坏,而是因为我当时把 SCK 线拉得太长,而且没有做阻抗控制。解决办法有三个,按优先级排:
- 把 SPI 时钟从 10MHz 降到 5MHz,问题立刻消失;
- 在 SCK 靠近 MRAM 的一端串联 22Ω 电阻,振铃被明显抑制;
- 尽量缩短 SCK 到 MRAM 的走线,并把线宽控制在地线旁边。
这个坑告诉我们,评估存储芯片性能时不能只看标称最高时钟频率。工业板往往有一堆干扰源,留出 2 到 3 倍的降频余量是正常操作。
5.3 把普通 SPI Flash 驱动改到 MRAM 时的四个雷区
因为 MR25H40CDF 的指令和 SPI Flash 很像,很多项目会选择直接拿 Flash 驱动改,但至少这四个地方必须改:
- 去掉擦除指令。MRAM 没有 0x20 扇区擦除、0x52 块擦除和 0x60 整片擦除的概念,写了反而可能触发未定义行为;
- 去掉页编程限制。SPI Flash 一页通常是 256 字节,跨页必须重新发命令;MRAM 可以连续写任意长度,直到 512KB 地址边界;
- 重新检查状态寄存器掩码。Flash 的
WIP位在 MRAM 里也存在,但 MRAM 写入极快,如果你的驱动里为了等待 Flash 擦除做了几毫秒延时,放在 MRAM 上会白白拖慢系统; - 读容量和型号的方式不同。很多 SPI Flash 支持 0x9F 读 JEDEC ID,MRAM 不一定支持同样的 RDID 流程,不能拿同样的返回值做产品校验。
我自己后来干脆单独写了一个mram_driver.c,不跟 Flash 驱动复用,避免代码里各种#ifdef把逻辑弄乱。
5.4 量产后发现 HOLD# 悬空导致的诡异故障
最后说一个量产才暴露的问题。第一批板子回来后,有约千分之三的板子在高温老化时出现通信丢失。查到最后是 HOLD# 引脚悬空惹的祸,高温下 PCB 绝缘电阻变化,HOLD# 被轻微干扰拉低,MRAM 就"暂停"了。外部表现不是满屏错误,而是某个 write 或 read 事务卡死,然后看门狗复位。
修复方式很简单:HOLD# 和 WP# 各加一个 4.7kΩ 上拉电阻到 VCC。这个改动再往前推两个月我就该做的,因为 datasheet 上写得很清楚 HOLD# 不能悬空。这里写出来给大家当个提醒,尤其做小批量试产时,别缺这两个电阻。
我现在的项目基本已经固定使用 MR25H40CDF + PIC18F8520 这套存储方案。如果哪天你也面临 EEPROM 寿命不够、Flash 擦写太慢、掉电数据不稳的困境,可以直接把驱动和事务逻辑拿过去参考,至少能省掉一轮自己摸坑的时间。