前阵子接了一个工业控制器项目,需要在主控里保存校准系数、配置参数和运行日志。现场条件不友好:设备随时可能断电,日志写入频率很高,而且程序要靠历史数据判断设备状态。同事一开始用的是 SPI EEPROM,用到后期开始担心寿命;换 NOR Flash,又绕不开擦除、块对齐和写等待。最后我把目光放到了 Everspin 的 MR25H40CDF 串行 MRAM 上,配合一颗 Microchip 的 PIC18F96J94 做主控,整套存储与读取方案才算真正稳定下来。MR25H40CDF 是一颗 4Mbit 的 SPI 接口 MRAM,最大的特点是无磨损限制、写入不需要擦除也不存在等待,非常适合工业现场无规律的高频数据记录;PIC18F96J94 自带 MSSP 串行外设、资源充足,成本也能压在合理范围。这篇文章我把关键点一次讲透,从硬件接线到底层驱动,再到掉电一致性和日志区设计,适合正在做嵌入式存储、数据采集或设备状态记录的开发者参考。
1. 为什么这个组合适合工业数据存储:选型逻辑
1.1 MR25H40CDF 到底是一颗什么样的芯片
MR25H40CDF 不是普通 EEPROM,也不是 Flash,它属于 MRAM,存储单元用的是磁隧道结技术。磁性状态一旦写入,即使断电也不会丢,不需要电池备份,也没有 Flash 那种“必须按页编程、写之前先擦除”的规则。
容量上,MR25H40CDF 是 4Mbit,按 8bit 组织就是 512K x 8,也就是 512KB。供电范围典型是 3.3V,接口是标准 SPI,支持 SPI Mode 0 和 Mode 3,串行时钟可以跑到几十 MHz,具体速率要看封装和温度档位。DF 后缀一般代表封装形式和温度等级,工业现场使用要选温度范围够宽的批次。
真正让它在工业场景里脱颖而出的是三点:
- 写入寿命极高。EEPROM 一般十万到百万次写,NOR Flash 往往只有十万次级别,MRAM 在正常使用中基本不用考虑磨损。
- 免擦除、免等待。SPI 时钟发完一个字节,数据就已经固化了,不像 NOR Flash 要等上几个毫秒甚至几十毫秒的页编程时间。
- 掉电保存。不需要超级电容、不需要电池,写进去就是非易失的。
简单说,MRAM 在软件眼里很像一片“掉电不丢的 SRAM”,读写逻辑非常简单。
1.2 PIC18F96J94 在这套方案里扮演什么角色
PIC18F96J94 是 Microchip 的 8 位 PIC18 系列,属于 J94 这个产品家族。它的定位不是做大量数据处理的高端处理器,而是在工业控制里干“采集、通信、控制”这些脏活累活。
在这套方案里,PIC18F96J94 主要做三件事:
- 通过 ADC、UART、Modbus 等接口采集现场数据;
- 把数据整理之后,通过 SPI 写入 MR25H40CDF;
- 上电之后从 MRAM 读回配置和日志,继续执行控制逻辑。
J94 系列内部集成有 MSSP 模块,可以配置成 SPI 主模式,直接和 MRAM 对接。代码里不需要用 GPIO 模拟 SPI,时序稳定性和 CPU 占用都会好很多。再加上 PIC18 的抗干扰能力和成熟生态,很多工业板卡仍会选这类 MCU 做主控,而不是一上来就用高性能应用处理器。
1.3 为什么不用 EEPROM、FRAM 或 NOR Flash
很多朋友一听“非易失存储”,第一反应就是 I2C EEPROM 或者 SPI NOR Flash。但在我们的场景里,它们各有各的别扭。
| 对比项 | MR25H40CDF | 串行 EEPROM | FRAM | SPI NOR Flash |
|---|---|---|---|---|
| 写入寿命 | 极高,几乎不考虑磨损 | 10^5 ~ 10^6 次 | 10^10 ~ 10^12 次 | 10^4 ~ 10^5 次 |
| 写入方式 | 按字节/连续写,免擦除 | 按字节写 | 按字节写 | 按页写,需先擦除 |
| 写入等待 | 命令发完即完成 | 一般 3~5ms | 命令发完即完成 | 页编程几 ms,擦除更慢 |
| 典型密度 | 4Mbit 常见 | 几 Kbit 到 1Mbit | 几百 Kbit 居多 | 4Mbit 以上很常见 |
| 软件复杂度 | 低 | 低 | 低 | 高,要管块、页、坏块 |
如果只是保存几个配置字,EEPROM 够用。如果做高频无规律的数据记录,EEPROM 寿命和写等待就是瓶颈。NOR Flash 容量大,但管理负担重,掉电一致性、磨损均衡、块擦除都得处理,不适合高频率小数据量的日志场景。FRAM 写入寿命也不错,但大容量型号不多,成本和供货也不如 MRAM 稳定。
所以结论很简单:频繁写、不定时写、还要保证掉电不丢,首选 MRAM。MR25H40CDF 的容量和 SPI 接口又刚好适合 8 位 MCU 方案,和 PIC18F96J94 这种带 MSSP 的芯片组合起来很顺。
2. 硬件接线:让 MR25H40CDF 和 PIC18F96J94 可靠通信
2.1 引脚映射与最小系统连接
MR25H40CDF 虽然叫 MRAM,但引脚逻辑和普通 SPI Flash 很像。核心引脚是这些:
- VDD、VSS:电源和地;
- CS#:片选,低电平有效;
- SCK:SPI 时钟;
- SI:主出从入,也就是主控发出去的数据线;
- SO:主入从出,主控接收 MRAM 数据的线;
- WP#:写保护引脚,低电平有效;
- HOLD#:暂停通信引脚,低电平有效。
连接 PIC18F96J94 时,原则是“主控的 SDO 接 MRAM 的 SI,主控的 SDI 接 MRAM 的 SO”。不要接反,接反之后读出来的数据永远是 0xFF 或者 0x00。
我手上的板子使用 MSSP1 外设,引脚分配大概如下:
| 主控功能 | 连接到 MR25H40CDF |
|---|---|
| GPIO(CS) | CS# |
| SCK | SCK |
| SDO | SI |
| SDI | SO |
| 3.3V | VDD |
| GND | VSS |
CS 必须用普通 GPIO 手动拉低拉高,不能图省事直接接到地。因为每条 SPI 命令都以 CS 的下降沿开始、上升沿结束,CS 一直拉低会让 MRAM 无法区分命令边界。
我习惯用宏定义把引脚隔离出来,方便移植:
#define MRAM_CS_LAT LATBbits.LATB2 #define MRAM_CS_TRIS TRISBbits.TRISB2 #define MRAM_SCK_TRIS TRISBbits.TRISB3 #define MRAM_SDO_TRIS TRISBbits.TRISB4 #define MRAM_SDI_TRIS TRISBbits.TRISB5不同封装的复用引脚不同,实际用哪几个脚,一定要去查 PIC18F96J94 数据手册里的引脚复用表。这里只是示例,不是所有型号都默认长这样。
2.2 电源去耦与地平面布置
MRAM 本身功耗不大,但在掉电瞬间和频繁读写时,电源纹波会有变化。如果电源处理不好,偶发读写错误会非常难查。
我在这颗 MRAM 的 VDD 附近放了两颗电容:一颗 0.1uF 陶瓷电容做高频去耦,一颗 4.7uF 到 10uF 的电容做低频储能。注意电容要尽量靠近 VDD 引脚,走线先过电容再到芯片,不要绕到板子另一边再绕回来。
地平面也要尽量完整。SPI 虽然不像高速差分总线那么挑剔,但时钟到几十 MHz 之后,回路面积过大会引入噪声。工业现场往往有变频器、继电器、电机,干扰源非常多,地线乱弹很容易造成 SPI 误码。
如果 MCU 和 MRAM 分属两块板子,比如主板和存储板通过排线连接,我会建议在 SPI 信号线上加 22~33Ω 的串联电阻,抑制振铃。长度超过 10cm 的话,优先降低 SPI 时钟频率,而不是一味追求高速。
2.3 HOLD、WP 与 CS 的上拉策略
HOLD# 引脚如果浮空,工业现场的噪声一旦把它拉低,MRAM 会立刻“冻结”当前通信,后续时钟全部忽略。主机侧可能一直等不到数据,逻辑分析仪又看不出明显问题。这是最容易忽视的坑。
处理办法很简单:HOLD# 不用就通过 4.7k 或 10k 电阻上拉到 3.3V。WP# 如果不动态改写保护位,直接接到 3.3V;如果想在程序里控制写保护,就接到 MCU 的 GPIO。CS# 建议同时加一个 10k 上拉,避免 MCU 上电初始阶段 IO 状态不定时,MRAM 被误选中。
另外要注意电平匹配。MR25H40CDF 工作电压是 3.3V 左右,如果 PIC18F96J94 工作在 5V,就必须做电平转换,或者在确认数据手册允许的情况下使用开漏加外部上拉的方案。不要直接硬接,长期超出绝对最大额定值会让芯片内部损伤。
3. PIC18F96J94 驱动:MSSP 配置与读写命令封装
3.1 MSSP 主模式初始化:先解决时钟极性和相位
PIC18F96J94 的 MSSP 配置成 SPI 主模式之后,最重要的两个参数是时钟极性和相位。MR25H40CDF 支持 SPI Mode 0 和 Mode 3,我这里统一用 Mode 0,也就是:空闲时 SCK 为低电平,数据在第一个时钟沿被采样。
Microchip 的 MSSP 寄存器里,控制这两个参数的是 CKP 和 CKE,很多人会在这里反直觉地出错:
- SPI Mode 0:CKP = 0,CKE = 1;
- SPI Mode 3:CKP = 1,CKE = 0。
不要看到 CKE=1 就以为是上升沿采样,必须结合 CKP 一起看。第一次调驱动的时候,我就在这里吃过亏。
初始化代码大致如下:
void mram_spi_init(void) { // CS 默认拉高,防止上电乱选中 MRAM_CS_LAT = 1; MRAM_CS_TRIS = 0; // 引脚方向:SCK、SDO 输出,SDI 输入 MRAM_SCK_TRIS = 0; MRAM_SDO_TRIS = 0; MRAM_SDI_TRIS = 1; // MSSP1,SPI 主模式,时钟 Fosc/64 // SSPEN=1, CKP=0, SSPM=0010 SSP1CON1 = 0b00100010; SSP1STATbits.CKE = 1; // SPI Mode 0 }如果系统时钟是 32MHz,SPI 时钟就是 32MHz / 64 = 500kHz。这个速度对 MRAM 来说很慢但绝对稳,先跑通功能再提速。
3.2 收发一个字节的基础函数
SPI 是同步全双工协议,主控每发送一个字节的同时,也会从 SO 线上收到一个字节。所以无论你是单纯发命令还是单纯读数据,底层函数本质都是同一个:
uint8_t mram_spi_xfer(uint8_t out) { SSP1BUF = out; while (!SSP1STATbits.BF); return SSP1BUF; }代码里用 BF 位判断接收缓冲是否有效,比靠延时靠谱。注意:如果上一次读 SSP1BUF 之后没有清标志,或者后续中断没有处理,最好先读一次清掉缓冲,再开始下一次传输。
实际项目中我不会让这个函数无限等下去。SPI 受干扰时,BF 可能一直不变,卡死在 while 里会导致整个主控看门狗超时。后面的章节会给带超时版本的写法。
3.3 命令层:WREN / RDSR / READ / WRITE
MR25H40CDF 的 SPI 命令风格和常见 SPI Flash 接近,但不需要擦除命令。我用的核心命令如下:
| 命令 | 操作码 | 说明 |
|---|---|---|
| WREN | 0x06 | 写使能,写入前必须先执行 |
| WRDI | 0x04 | 写禁止 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 按地址读数据 |
| WRITE | 0x02 | 按地址写数据 |
写使能的时序一定要单独用 CS 拉高拉低包起来:
void mram_write_enable(void) { MRAM_CS_LAT = 0; mram_spi_xfer(0x06); MRAM_CS_LAT = 1; }读状态寄存器:
uint8_t mram_read_status(void) { uint8_t st = 0; MRAM_CS_LAT = 0; mram_spi_xfer(0x05); st = mram_spi_xfer(0x00); MRAM_CS_LAT = 1; return st; }读任意长度数据,MR25H40CDF 是 512KB,地址需要 19bit,所以发送三个地址字节:
void mram_read(uint32_t addr, uint8_t *buf, uint32_t len) { if ((addr + len) > 0x80000U) len = 0x80000U - addr; MRAM_CS_LAT = 0; mram_spi_xfer(0x03); mram_spi_xfer((addr >> 16) & 0xFF); mram_spi_xfer((addr >> 8) & 0xFF); mram_spi_xfer(addr & 0xFF); while (len--) *buf++ = mram_spi_xfer(0x00); MRAM_CS_LAT = 1; }写入数据类似:
void mram_write(uint32_t addr, const uint8_t *buf, uint32_t len) { if ((addr + len) > 0x80000U) len = 0x80000U - addr; mram_write_enable(); MRAM_CS_LAT = 0; mram_spi_xfer(0x02); mram_spi_xfer((addr >> 16) & 0xFF); mram_spi_xfer((addr >> 8) & 0xFF); mram_spi_xfer(addr & 0xFF); while (len--) mram_spi_xfer(*buf++); MRAM_CS_LAT = 1; }MRAM 写操作不需要像 NOR Flash 那样等页编程完成,CS 拉高之后数据就已经是新的了。所以从软件角度看,这套命令封装比 Flash 简单很多。
3.4 逻辑分析仪验证时序
驱动写完后,先不要直接上大数据量,我习惯做一步最基础的验证:往地址 0x1000 写一个字节 0x5A,再读回来,看看对不对。
uint8_t test = 0x5A; mram_write(0x1000, &test, 1); test = 0; mram_read(0x1000, &test, 1); if (test != 0x5A) // 通信或接线有问题如果失败了,接上逻辑分析仪抓 CS、SCK、SI、SO 四条线。重点看第一条 WREN 命令:CS 拉低,发送 0x06,CS 拉高。不满足这个分离时序,MRAM 不会接受后续写命令。
我当时抓到的最典型错误是:代码里为了让“写使能 + 写数据”连续执行,CS 全程没有拉起来,MRAM 把 0x06 当成了普通数据,写操作自然失败。这个问题在逻辑分析仪上一眼就能看出来。
4. 存储架构设计:配置、日志和掉电一致性
4.1 配置区:多副本 + 序号 + CRC
MRAM 虽然写入可靠,但任何存储方案都不能假设“断电一定发生在两次操作之间”。比如正在写一个 128 字节配置块,写到第 80 字节时突然断电,这个块就是残缺的。
我采用的办法是保留多个配置副本,每个副本带上单调递增的序号和 CRC 校验。启动时逐个读取,选择“序号最大且 CRC 校验通过”的副本作为有效配置。
一次典型的启动流程如下:
- 读取副本 A、B、C;
- 对每个副本做魔数检查 + CRC 校验;
- 从通过校验的副本中选择序号最大的一个;
- 如果最高序号副本的数据异常,则回退到次新副本,并把异常副本标记为待修复;
- 正常运行时,更新配置时同时刷新所有副本。
因为 MRAM 不需要擦除,也不担心磨损,所以更新多个副本的成本很低。选择最高序号而不是最新时间,是为了避免各个副本的时间戳不一致。
4.2 日志区:固定长度记录 + 环形缓冲
工业现场常需要保存最近一段时间的数据,比如故障前后的 ADC 采样值、温度值、开关状态。MRAM 容量有 512KB,不适合存超大文件,但保存几千到几万条日志很合适。
我推荐的布局是环形缓冲区:
MRAM 日志区起始地址: [8 字节头部][记录 0][记录 1][记录 2] ... [记录 N-1]头部可以保存魔数、当前写入索引、缓冲区总长度、当前封装版本号。每条记录尽量用固定长度,比如 64 字节。固定长度最大的好处是计算位置方便:
record_addr = LOG_BASE + 8 + (write_index * RECORD_SIZE);写入顺序很关键。我先写记录数据本身,再更新头部中的 write_index。这样即使写入过程中断电,头部指向的还是上一条有效记录,已崩溃的半条记录会在下一次启动时被跳过或覆盖。
4.3 CRC 校验与脏数据识别
工业记录里我常用 Modbus CRC16,代码简单、计算量也小。每一条记录,除了业务数据,再追加 2 字节 CRC。读日志时如果 CRC 错误,只跳过这条,不中断后续记录。
配置区比日志区更严格。配置块结构大概是这样:
魔数 4 字节 序号 4 字节 业务数据 N 字节 CRC16 2 字节写入时,我习惯先写魔数和业务数据,最后写 CRC。校验时,如果发现魔数正确但 CRC 错误,说明数据写了一半,系统应该回退到上一版。因为 MRAM 免擦除,这种“多副本 + 校验”的实现成本非常低。
4.4 实测性能与瓶颈
把 SPI 时钟从 500kHz 提到 Fosc/4 之后,单片机 32MHz 主频下 SPI 时钟为 8MHz,MRAM 本身完全可以承受。实测下来,连续写 32KB 数据大约 35ms,连续读 512KB 大约 0.55s,具体数值会受中断占用和 CRC 计算影响,但比 EEPROM 快了一个量级。
对日志应用来说,即使每秒钟产生 64 字节记录,一天也不过 5.5MB,512KB 的 MRAM 不可能存一天。所以方案上我会做分层:高频数据缓存在 RAM 里,每 30 秒或 1 分钟批量写一次 MRAM。这样既可以保证掉电不丢太多,也不至于频繁搬运数据。
工业场景里,瓶颈往往不再存储芯片,而在主控的采样频率和通信速度。MRAM 只是把原来“能不能写、会不会磨损”的问题直接消掉了。
5. 调试中踩过的坑:从全 0xFF 到写保护
5.1 现象一:读回来全是 0xFF
用 mram_read 读配置区,返回缓冲区里全是 0xFF,这是 SPI 存储器最常见的问题。原因不是 MRAM 坏了,而是时钟相位和主控不匹配,SO 上采不到有效数据。
MR25H40CDF 支持 Mode 0 和 Mode 3。如果你 MSSP 配置成了 Mode 1 或 Mode 2,读状态寄存器也会得到 0xFF。先确认 CKP 和 CKE 的组合,再查 SO、SI 有没有接反,最后用逻辑分析仪看时钟边沿和数据有没有对齐。
排查顺序建议是:
- 检查 SCK 空闲电平;
- 检查数据变化沿和采样沿;
- 检查 CS 是否在每个命令之间正常拉高;
- 检查 SO 是否确实到了主控 SDI 引脚。
5.2 现象二:执行了 WRITE,但数据没进去
写操作返回正常,但读回地址内容还是 0xFF,十有八九是漏了 WREN。
MR25H40CDF 和大多数 SPI 非易失存储一样,默认状态是写禁止。WRITE 命令前必须发 WREN,把状态寄存器里的 WEL 位置 1。而且 WREN 必须是一条独立的命令,有自己的 CS 低高周期。
我这边踩过的坑是:为了省时间,把 WREN 和 WRITE 放在同一个 CS 低电平周期里,连续发 0x06 再发 0x02。MRAM 不会把它理解为“先写使能再写”,而是把它当成一个普通数据流。正确的做法是:
CS 拉低 -> 发送 0x06 -> CS 拉高 CS 拉低 -> 发送 0x02 + 地址 + 数据 -> CS 拉高每次写操作之前都调用 mram_write_enable,最省心。
5.3 现象三:写入“成功”但指定区域没变
还有一种更难查的情况:写入操作正常,WEL 也设置了,但目标区域始终是原始值。这时要去看状态寄存器里的块保护位。
MR25H40CDF 的状态寄存器里有 BP0、BP1、WPEN 等保护位。如果误操作把保护位设成了非 0,那么受保护区间会变成只读,WRITE 命令在这个区域不会生效,而且没有任何错误标志告诉主机“写入被拒绝”。
我当时就是调试程序时不小心执行了一次 WRSR,把一个区域保护了,结果所有写测试都失败。解决方法是先读状态寄存器:
uint8_t st = mram_read_status(); if ((st & 0x0C) != 0) // BP1、BP0 中有任意一个置位 { // 需要解除保护 mram_write_enable(); // 发送 WRSR,0x00 表示不保护任何区域 MRAM_CS_LAT = 0; mram_spi_xfer(0x01); mram_spi_xfer(0x00); MRAM_CS_LAT = 1; }注意如果 WP# 引脚被拉低,WRSR 可能也会被忽略,所以调试时 WP# 要先接高。
5.4 现象四:工业现场偶发卡在死循环
前面的坑都解决后,样机在实验室一切正常,上到现场却偶发死机。后来发现是底层 spi_xfer 的 while 等待没有超时,SPI 线上一个毛刺导致 BF 位始终不变,程序就卡死了。
这是嵌入式里非常危险的事情。任何通信等待都要带超时,不能单纯 while 死等。我把底层函数改成了:
uint8_t mram_spi_xfer_timeout(uint8_t out, uint16_t timeout) { SSP1BUF = out; while (!SSP1STATbits.BF) { if (timeout-- == 0) return 0xFF; // 超时 } return SSP1BUF; }超时之后可以重试,也可以上报错误让主控层处理。加了这个机制之后,现场再也没有出现过“永久卡死”的问题。
另外,HOLD# 引脚的上拉和电源去耦一定要做好。我曾经为了省一个电阻把 HOLD# 悬空,结果在继电器吸合的瞬间,日志偶发漏写。后来补上 10k 上拉,问题消失。
写保护、超时、SPI 模式、HOLD 上拉,这四件事看着都不起眼,但每一件都足以让整套存储方案在工业现场翻车。MRAM 本身足够稳,剩下的稳定性要靠主控侧驱动和 PCB 细节来兜底。