去年给客户做一台现场数据记录仪表,核心需求很简单:把温度、运行状态、累计电量这类参数定期存下来,断电之后不能丢。最开始用一颗 SPI EEPROM,结果客户现场电源质量比较差,变频器一启动就是电压跌落,几次之后参数变成 0xFFFF,记录直接乱掉。后来把存储换成 Everspin 的 MR25H40CDF,主控用 Microchip 的 PIC18LF45K50,问题才算彻底解决。这篇文章把整个项目的选型思路、硬件连接、驱动实现、掉电保护设计,以及我在现场踩过的坑完整写一遍,给同样在工业数据存储、参数掉电记忆、运行日志记录上折腾的人做个参考。
1. 为什么工业现场我最终选了 MRAM:EEPROM 和 Flash 都坑过我
1.1 现场数据丢失事件
先说最初那台设备。控制板是一颗常见的 8 位单片机,外挂 8KB 的 SPI EEPROM,用来保存累计运行时间和最近几十条告警记录。逻辑很简单:每次产生新数据就写一次,掉电前再补一次。静态测试怎么都正常,但到了客户现场,只要变频器重载启动或者接触器吸合,偶尔就会出现“参数全丢”或者“读回来是一堆 0xFF”的现象。
排查到最后,问题其实不在软件逻辑,而在介质特性。EEPROM 写一个字节要 3~5ms,现场电压跌落几十毫秒内就可能出现供电坍缩,掉电检测程序还没等到数据写完,芯片已经进入欠压状态。更麻烦的是,EEPROM 的写寿命通常只有 10 万到 100 万次,频繁覆盖写加上电压不稳,坏块概率会明显上升。这种场景下,再优化代码也只能降低概率,不能从根上解决。
1.2 三种非易失存储器的真实差异
很多工程师第一反应是“换一颗容量更大的 EEPROM”,但那只解决容量问题,不解决写入速度和寿命问题。工业频繁写、掉电随机的场景,我更倾向于直接对比三类常用器件:SPI EEPROM、SPI NOR Flash、SPI MRAM,也有人会考虑 FRAM。
| 项目 | MR25H40CDF(MRAM) | 普通 SPI EEPROM | SPI NOR Flash | FRAM |
|---|---|---|---|---|
| 写入寿命 | 官方按 10^14 次量级标识,工程上可以按无限次算 | 10 万~100 万次 | 1 万~10 万次 | 10^12 次左右,也很高 |
| 写一个字节的延时 | 几乎无写延时,写入在 SPI 事务内完成 | 3~5ms | 页编程加扇区擦除,毫秒级起步 | 几乎无写延时 |
| 是否需要擦除 | 不需要,直接覆盖写 | 不需要 | 必须先擦除整个扇区再写 | 不需要 |
| 掉电敏感度 | 磁存储,电荷丢失问题不存在 | 电荷存储,掉电半写容易坏 | 电荷存储,掉电可能丢整页 | 相对不敏感 |
| 典型容量 | 几 Mbit 到几十 Mbit | 通常 1Mbit 以内 | 大容量为主 | 容量选择较少 |
| 价格 | 最贵 | 最便宜 | 便宜 | 中等偏上 |
表格里最打动我那一条,其实是“不需要擦除”。NOR Flash 每次改写都要先擦整块,意味着代码里要维护扇区状态、写磨损均衡;EEPROM 虽然没有擦除,但字节写速度实在太慢。MRAM 把这两者的短板都避开了:覆盖写、快、寿命长。
1.3 MR25H40CDF 的值在哪里
MR25H40CDF 是 Everspin 的一颗 4Mbit SPI MRAM,换算下来是 512KB,做参数区加运行记录区完全够用。它内部存储单元是磁隧道结,写数据靠电流改变磁化方向,而不是像 Flash 那样靠电荷保存状态,所以天然不怕长时间断电,也不容易出现“半写”状态。
实际工程里我最看重三个点。第一,写操作不需要擦除,任意地址都能按字节覆盖,代码里不需要维护“哪个扇区干净”这种状态。第二,写寿命极高,运行参数每次变化都写一次也没压力,不用像对 Flash 那样精心设计磨损均衡。第三,工作温度范围和抗干扰特性比普通 EEPROM 更适合工业控制柜环境。
价格确实比 EEPROM 贵不少,但在现场跑一趟换板子的人工成本、停机损失,一般都比芯片差价款高得多。这个账算清楚之后,选型就很自然了。
2. 硬件搭台:PIC18LF45K50 与 MR25H40CDF 怎么接才靠谱
2.1 PIC18LF45K50 选型理由
主控选 PIC18LF45K50,最直接的原因是这个项目需要 USB 接口做现场数据导出,同时希望整块板子工作在 3.3V 单电源下。PIC18LF45K50 是 Microchip 的 8 位 USB MCU,工作电压可以到 1.8~3.6V,内置 32KB Flash、1.5KB 左右的 SRAM、EEPROM、双 MSSP 模块和丰富的中断资源。
MR25H40CDF 的供电范围也是 3.3V 系统合适,所以单片机和存储之间不需要电平转换,SPI 直连即可。MSSP 模块既能当 SPI 用也能当 I2C 用,一个模块接 MRAM,另一个模块可以留给外部传感器或者调试口,硬件上很灵活。
另外,PIC18LF45K50 内置 BOR 和 LVD 掉电检测,这对做掉电数据保存非常重要。后面章节我会专门讲怎么用这些资源配合 MRAM 做“最后 100 微秒”的完整写入。
2.2 SPI 接线与外围电路
MR25H40CDF 是标准 SPI 从机,接口信号只有片选、时钟、数据输入、数据输出四条,再就是写保护和保持引脚。我的接法如下表,实际硬件按原理图微调即可。
| MR25H40CDF 引脚 | 方向 | 推荐接法 |
|---|---|---|
| CS | 输入 | 接 PIC 任意 GPIO,本项目用 RC0,同时加 10k 上拉到 3.3V |
| SCK | 输入 | 接 PIC MSSP1 的 SCK1 |
| SI | 输入 | 接 PIC MSSP1 的 SDO1 |
| SO | 输出 | 接 PIC MSSP1 的 SDI1 |
| WP | 输入 | 必须接 3.3V,不能悬空 |
| HOLD | 输入 | 必须接 3.3V,不能悬空 |
| VDD | 电源 | 3.3V,靠近芯片放 0.1uF 陶瓷电容 |
| VSS | 地 | 接 GND,单点回流 |
WP 和 HOLD 这两个引脚最容易被人忽略。WP 一旦被拉低,MRAM 的写操作会被禁止,程序表现就是“写命令发了,数据读回来没变”。HOLD 一旦被拉低,SPI 时钟和数据会被冻结,直接导致总线通信错乱。工业现场电磁环境复杂,这两个引脚绝对不要悬空,最稳妥的办法就是直接连接到 3.3V,如果要用软件控写保护,那也必须通过 GPIO 输出确定的高电平。
SPI 走线尽量短,片选和时钟线距离 MRAM 控制 5cm 以内。我在 SCK、SI、CS 线上各串了一个 33Ω 电阻,用来抑制振铃和过冲,这在现场长连接线时非常管用。
2.3 电源和复位对存储可靠性的隐形影响
很多人以为 MRAM 既然不怕掉电,电源就可以随便做。实际不是这样。SPI 通信时 MCU 和存储芯片同时翻转,VDD 上的噪声如果超过一定幅度,读回来的数据就可能错 bit。工业仪表里常见做法是宽压输入再加一级 LDO 稳压到 3.3V,不要直接从高压 DC-DC 的输出取电给存储系统。
我给 MRAM 的 VDD 脚放了 0.1uF,给 MCU 的每个电源脚也放了 0.1uF,整个 3.3V 域再加了一颗 10uF 钽电容做低频储能。这样做的另一个原因是配合掉电检测,给最后的数据提交提供能量缓冲。
复位方面也要留意。MCU 异常复位时如果 CS 恰好处于低电平,MRAM 会把后续总线状态当成一个新的命令序列,有可能误写。所以 CS 信号除了接 MCU GPIO,一定要外加 10k 上拉电阻,保证 MCU 复位期间 CS 稳定在高电平,从机不被意外选中。
3. 驱动代码:SPI 初始化、状态机和多字节读写实现
3.1 MSSP 配置成 SPI 主模式
下面这套代码基于 XC8 编译器和 PIC18LF45K50 的 MSSP1 模块。MR25H40CDF 支持 SPI Mode 0 和 Mode 3,我习惯用 Mode 0,也就是 CPOL=0、CPHA=0。PIC 的 MSSP 寄存器描述里的 CKE、CKP 组合要按数据手册核对,别照搬其他单片机示例。
#include <stdint.h> #define MR_CS_LAT LATCbits.LATC0 #define MR_CS_TRIS TRISCbits.TRISC0 #define MR_WP_LAT LATCbits.LATC1 #define MR_WP_TRIS TRISCbits.TRISC1 #define MR_HOLD_LAT LATCbits.LATC2 #define MR_HOLD_TRIS TRISCbits.TRISC2 #define MR_CMD_READ 0x03 #define MR_CMD_WRITE 0x02 #define MR_CMD_RDSR 0x05 #define MR_CMD_WRSR 0x01 #define MR_CMD_WREN 0x06 #define MR_CMD_WRDI 0x04 #define MRAM_SIZE 0x80000UL // 512KB void MRAM_SPI_Init(void) { // MSSP1 配置为 SPI 主模式,时钟 = Fosc/4 SSP1STAT = 0xC0; // SMP=1, CKE=0 -> 对应 SPI Mode 0 SSP1CON1 = 0x20; // SSPEN=1, CKP=0, SSPM=0000 -> Master, Fosc/4 MR_CS_TRIS = 0; MR_WP_TRIS = 0; MR_HOLD_TRIS = 0; MR_CS_LAT = 1; MR_WP_LAT = 1; MR_HOLD_LAT = 1; }时钟选择上,Fosc/4 在我这个项目里是 12MHz,MR25H40CDF 完全有能力跑这个速度。如果现场走线长、干扰大,我建议直接把分频降下来,后面第 5 章会讲实测对比。
3.2 底层收发与状态寄存器操作
SPI 是全双工总线,MSSP 模块每发送一个字节的同时也会收到一个字节。所以“读 MRAM 数据”这个动作,本质上是主机持续发送 0x00,把从机 SO 上的数据一个个换回来。
unsigned char MRAM_spi_io(unsigned char tx) { SSP1BUF = tx; while (!SSP1STATbits.BF) { // 等待发送完成并接收完成 } return SSP1BUF; }片选操作和状态寄存器读取如下:
void MRAM_CS_LOW(void) { MR_CS_LAT = 0; } void MRAM_CS_HIGH(void) { MR_CS_LAT = 1; } unsigned char MRAM_ReadStatus(void) { unsigned char sr; MRAM_CS_LOW(); MRAM_spi_io(MR_CMD_RDSR); sr = MRAM_spi_io(0x00); MRAM_CS_HIGH(); return sr; } void MRAM_WriteEnable(void) { MRAM_CS_LOW(); MRAM_spi_io(MR_CMD_WREN); MRAM_CS_HIGH(); } void MRAM_WaitReady(void) { // MRAM 几乎没有内部写延时,WIP 位通常很快为 0 // 但保留轮询操作,方便后续迁移到其他 SPI 存储 while (MRAM_ReadStatus() & 0x01) { // 等待写操作完成 } }读状态寄存器的操作里,我会额外加一个简短超时计数,防止总线异常导致死循环。工业现场单片机跑飞是常见事,任何 while 循环都建议有溢出退出的保护。
3.3 多字节读写函数与缓冲区策略
MR25H40CDF 的容量是 512KB,地址需要 24bit,也就是三字节。READ 和 WRITE 命令都遵循同一个格式:命令字节 + 3 字节地址 + 数据。
void MRAM_Read(uint32_t addr, unsigned char *buf, uint32_t len) { if ((addr + len) > MRAM_SIZE) { return; } MRAM_CS_LOW(); MRAM_spi_io(MR_CMD_READ); MRAM_spi_io((addr >> 16) & 0xFF); MRAM_spi_io((addr >> 8) & 0xFF); MRAM_spi_io(addr & 0xFF); while (len--) { *buf++ = MRAM_spi_io(0x00); } MRAM_CS_HIGH(); } void MRAM_Write(uint32_t addr, const unsigned char *buf, uint32_t len) { if ((addr + len) > MRAM_SIZE) { return; } MRAM_WriteEnable(); MRAM_CS_LOW(); MRAM_spi_io(MR_CMD_WRITE); MRAM_spi_io((addr >> 16) & 0xFF); MRAM_spi_io((addr >> 8) & 0xFF); MRAM_spi_io(addr & 0xFF); while (len--) { MRAM_spi_io(*buf++); } MRAM_CS_HIGH(); MRAM_WaitReady(); }写入之前必须先发 WREN 命令,否则 MRAM 会忽略后面的 WRITE 操作。这是 SPI 存储类器件的通用规矩,很多人第一次调的时候会在这个地方卡住:数据看起来发出了,但读回来全是旧值。
因为 MRAM 没有擦除限制,代码里不需要处理页边界、扇区擦除这些逻辑。我直接把存储规划成两块:低地址 0x00000 附近放参数区,高地址 0x40000 附近放运行记录区,两边的写入都是简单的覆盖式。
4. 掉电保存与数据完整性:工业场景的保命设计
4.1 掉电监测与供电电容的配合
片子本身再快,也得给 MCU 一个“知道自己要死了”的机会。PIC18LF45K50 内置的 LVD 低电压检测模块可以在 VDD 跌到设定阈值时触发中断,我把它配置在 3.0V 左右。
一旦 LVD 中断触发,程序立即停止非必要的任务,只做一件事:把手头这一帧数据的 CRC 算好,写到 MRAM 的提交标志位,然后进入低功耗等待。这个过程有多长?按 128 字节数据帧计算,SPI 时钟 12MHz,发送 1 字节命令 + 3 字节地址 + 128 字节数据,总共大约 (1+3+128) x 8 / 12MHz,也就是不到 100 微秒。
供电电容这边,我在 3.3V 域放了 100uF 的储能电容。按掉电时整板工作电流 10mA 估算,电容从 3.3V 跌到 2.7V 能维持的时间大约是 100uF x 0.6V / 10mA = 6ms。这足够覆盖掉最后那 100 微秒的写事务,还能给 BOR、LVD 复位留出余量。实际验证时,用电子负载模拟从 3.3V 快速跌到 0V,观察 MRAM 读回的数据帧依然是完整的。
4.2 双区交替写入与 CRC 校验
MRAM 天然不容易丢数据,但工程上不能只靠器件特性。我习惯在 MRAM 里做“双槽”方案:同一份业务数据准备两个存储槽位,交替写入,每次启动时选择最新且合法的一帧。
帧结构大致如下:
| 偏移 | 长度 | 内容 |
|---|---|---|
| 0 | 2 | 帧头 magic,例如 0xAA55 |
| 2 | 2 | 帧序号 seq,递增 |
| 4 | 2 | 数据长度 len |
| 6 | N | 业务数据 |
| 6+N | 2 | CRC16 校验 |
| 8+N | 1 | 提交标志,例如 0xC3 |
写入顺序上有个关键点:先写数据体和 CRC,最后写提交标志。提交标志放在单独偏移,最好单独做一次 CS 拉低、拉高的写事务。这样哪怕中间掉电,提交标志还是旧值,启动时就会判定这一帧无效,继续用另一个槽的旧数据。MRAM 不需要擦除,所以覆盖写旧帧非常直接,不像 Flash 那样还得先擦块、再写页码,代码量省了一截。
启动读取时,我把两个槽都读出来,依次检查 magic、CRC、seq。合法的槽中,seq 较大者作为当前有效数据。运行过程中每隔一段时间再写一次,两个槽轮流使用,即使发生物理损坏,也还能退化到单槽工作。
4.3 工业现场抗干扰的几道防线
SPI 本身不是抗干扰最强的总线,长度一长就容易出问题。工业控制柜里变频器、接触器在旁边,我的处理思路是分层防御:
- 物理层:SPI 四根线尽量短并远离动力线,必要时加磁珠;SCK、SI、CS 串 33Ω 电阻,HOLD 和 WP 强制拉高。
- 协议层:每次读写都带地址边界检查,读状态寄存器有超时保护,通信异常就重新初始化 MSSP 模块,不让 SPI 状态机卡死在半空。
- 数据层:帧头、CRC、seq 三重校验,任何一关不过就丢弃这一帧。因为 MRAM 写寿命长,即使偶发错误真的写脏了一个帧,下次启动也能自动回退到另一个槽。
- 复位层:MCU 开启看门狗,跑飞后强制复位,重新初始化时 CS 确定拉高,MRAM 不可能残留一个被中断的写序列。
实际现场跑了两个月,没有再出现一例参数丢失。这中间当然有软件配合,但介质选对了,问题确实会简单很多。
5. 实测数据与踩坑记录(含时序抓波)
5.1 不同时钟频率下的表现与最佳工作点
我在实验室用同一块板子测了 1MHz、4MHz、12MHz、20MHz 四档 SPI 时钟,数据线长度控制在 3cm 左右,结果如下。
| SPI 时钟 | 实验结果 | 说明 |
|---|---|---|
| 1MHz | 稳定 | 兼容性最好,适合长线 |
| 4MHz | 稳定 | 现场抗干扰和速度兼顾 |
| 12MHz(Fosc/4) | 稳定 | 我项目里的工作点 |
| 20MHz | 勉强稳定 | 短走线、电源干净时可以,电源纹波大时偶尔读错 |
最终我把量产版本锁定在 4MHz。原因很简单:现场走线长度不可控,4MHz 的性能已经完全够用,没必要为了节省几十微秒去赌电源质量。MRAM 的数据手册标称可以跑更高,但“手册标称值”和“现场长期稳定值”之间,我永远选择后者。
5.2 容易翻车的几处细节
整个调试过程中最典型的几个坑,我专门记一下。
第一,SI 和 SO 接反。表现是写入后立刻读,数据对不上,或者读回来全是 0xFF。排查这种问题最快的方法是写一个固定 pattern,比如在 0x00000 写 0xA5、0x5A、0x55、0xAA,再原地址读回比较。如果第一个字节就对不上,先怀疑接线,而不是怀疑驱动代码。
第二,WP、HOLD 悬空。WP 悬空时,周围电磁干扰可能把它拉低,导致写入被拒绝;HOLD 悬空更加隐蔽,数据传到一半被暂停,读回来的内容错位。解决方法是硬件直接接 3.3V,程序里也顺手把对应 GPIO 拉高。
第三,WRITE 命令之前漏了 WREN。SPI MRAM 上电后写使能锁存默认是关闭状态,必须先发 0x06 再发 WRITE。漏掉的典型现象是写入函数没有报错,数据却一直是旧值。
第四,地址超界。512KB 空间最大地址是 0x7FFFF,连续读或写一旦越过末尾,部分 SPI 存储芯片会回卷到起始地址继续操作。我在读写函数开头做了 addr + len 的边界检查,宁可函数直接返回,也不要出现悄悄覆盖 0x00000 参数区的情况。
第五,状态寄存器的块保护位。MRAM 默认状态寄存器是 0x00,写保护关闭。但如果你或者同事曾经调用过 WRSR 设置了 BP0、BP1,MRAM 就会把部分地址区域变成只读,此时写入看起来成功,读回来却是旧数据。遇到“写不进去”的问题,第一步先读状态寄存器,确认保护位状态。
5.3 给后续移植/扩展的一些建议
这套驱动虽然写在 PIC18LF45K50 上,但命令序列完全遵循通用 SPI 存储协议,移植到 STM32、ESP32 或者 Linux 的 spidev 都很直接。换平台时重点检查三处:SPI 模式选 Mode 0 还是 Mode 3,MRAM 都支持;地址宽度是不是 24bit,取决于你用的具体容量;CS 引脚在初始化时默认拉高,避免一上电就误选中从机。
如果后续要扩容到更大容量的 MRAM,读写函数基本不用动,只需要改MRAM_SIZE宏和地址掩码。如果是换到只有 16bit 地址的小容量 MRAM,记得把地址发送从三字节改成两字节,这个最容易漏。我现在的习惯是样机阶段就把 SPI 测试点留出来,所有读写都先用逻辑分析仪抓一遍波形,确认 CS、SCK、最后一位数据的时序都干净了再进产线。逻辑分析仪比示波器好用,因为可以快速验证整帧命令和数据的字节顺序。