1. 为什么项目里最后选了 MRAM:EEPROM 和 Flash 的坑,我先用数字帮你算一遍
1.1 这个项目要存什么数据
我做的是一个工业现场状态监测终端,主控用的 PIC18F86J16,需要长时间保存三类数据:
- 运行参数:PID 调节系数、温度补偿值、设备地址、校准标定数据,大概占 4KB 左右,平时不常改,但改的时候必须立刻生效;
- 历史记录:每 30 秒记录一次现场数据,一条记录 16 字节,需要保留最近 30 天的完整数据,算下来就是 57600 条记录,约 900KB;
- 故障日志:记录最近 200 次报警和复位原因,每次写 32 字节,按固定结构维护一个循环缓冲区,大约 6.4KB。
如果把运行参数和历史记录加起来,实际需要的非易失存储空间超过了 900KB。这个量级放在常规 8 位 MCU 方案里是个很尴尬的位置:I2C EEPROM 最大也就 128KB 左右,SPI Flash 容量够,但写入性能和寿命都是问题。
项目是在配电柜里运行的,常年环境温度 40℃ 到 70℃,现场还有变频器、接触器、伺服驱动器这些强干扰源。我之前在别的项目里用过 SPI NOR Flash 保存运行曲线,结果半年后出现整块数据偶发读回 0x00 的情况。这让我下定决心换存储方案。
1.2 三类非易失存储的对比与选型
对常见的非易失存储方案,我用一张表对比过它们的核心矛盾:
| 存储类型 | 典型容量 | 写寿命 | 写入是否需要擦除 | 写入速度 | 掉电保护 |
|---|---|---|---|---|---|
| I2C/SPI EEPROM | 几KB ~ 128KB | 100万次 | 否 | 慢,I2C 1MHz 封顶 | 一般 |
| SPI NOR Flash | 256KB ~ 128MB | 10万次 | 是,按扇区擦除 | 比 EEPROM 快,但擦除很慢 | 中等 |
| SPI MRAM | 256KB ~ 16MB | 1e14~1e15 次 | 否 | 跟随 SPI 时钟,无等待 | 强,内置掉电自动保护 |
关键是两个数字:写寿命和写入是否需要擦除。Flash 的 10 万次擦写寿命,意味着如果每 30 秒写一次,最理想情况下大概 34 天就磨完一个扇区,必须做磨损均衡。而磨损均衡在日志型写入场景里会引入大量读改写操作,代码复杂度和实时性都受影响。
MRAM 的 1e14 写入寿命,折算下来相当于每天写 10 万次、写上 2700 年。这个“无限写入”的特性让我彻底不用考虑磨损均匀的问题。
另一个关键点是写入是否需要擦除。MRAM 本质上是磁性存储,写入时直接覆盖旧值,不需要像 Flash 那样先擦除整个扇区再写入,也不存在"写页对齐"的问题。这一点对日志记录特别友好,我可以在任意地址起始连续写入任意长度,写完后立刻生效。
1.3 最终选型结果
最后选了 Everspin 的 MR25H40CDF。这是一颗 4Mbit 的 SPI 接口 MRAM,容量 512KB,工作电压 3.3V,工业级温度范围 -40℃ 到 +105℃,数据保持能力在 105℃ 环境下是 10 年以上。SPI 接口正好和 PIC18F86J16 的 MSSP 硬件模块对上,连 IO 都省了。
容量上 512KB 虽然不能一次装下 900KB 的历史数据,但我最后把历史记录的采样间隔从 30 秒调整到 1 分钟,配合循环覆盖策略,实际数据量降到了 480KB,加上参数区和日志区整好落在 512KB 里。剩下的一点空间做双备份,正好够用。这个取舍在后面我会细讲。
2. 硬件接线与引脚规划:把 MR25H40CDF 接上 PIC18F86J16
2.1 芯片的引脚与电气要求
MR25H40CDF 是标准的 8 引脚 SOIC 封装,引脚定义和普通 SPI NOR Flash 一样:
| 引脚号 | 名称 | 功能 |
|---|---|---|
| 1 | CS# | 片选,低有效 |
| 2 | SO | 串行数据输出(接 MCU 的 SDI/MISO) |
| 3 | WP# | 写保护,低有效 |
| 4 | GND | 地 |
| 5 | SI | 串行数据输入(接 MCU 的 SDO/MOSI) |
| 6 | SCK | 串行时钟 |
| 7 | HOLD# | 暂停传输,低有效 |
| 8 | VDD | 电源 3.3V |
PIC18F86J16 有几个 MSSP 模块可以配置成 SPI 主模式,我用的是 MSSP1,默认引脚是:SCK1 = RC3,SDO1 = RC5,SDI1 = RC4。CS# 没有绑定硬件片选,直接用任何一个 GPIO 引脚控制,灵活性更大,我选了 RB0。
这里要注意一个容易搞错的地方:MRAM 的 SO 引脚必须接到 PIC 的 SDI 引脚,MRAM 的 SI 引脚必须接到 PIC 的 SDO 引脚。我见过不少新手把这两个信号接反,结果读回来的数据永远是 0xFF,这个后面调试部分还会提到。
2.2 连线方案与去耦、上拉的处理
原理图连线比较简单,但有几个细节是经验里总结出来的:
- WP# 和 HOLD# 不能悬空。WP# 悬空可能导致意外进入写保护状态,HOLD# 悬空时干扰可能让 SPI 时钟暂停,造成数据错位。这两个引脚我都通过 10kΩ 电阻上拉到 3.3V。
- VDD 旁边必须放 100nF 陶瓷电容,而且要尽量靠近芯片的电源引脚。MRAM 内部逻辑切换速度很快,电源噪声会引起读数据错乱。
- CS# 上拉 10kΩ到 VDD,防止上电瞬间 GPIO 默认状态不确定导致芯片被误选中。
- 串联 33Ω 电阻放在 SCK、SI、SO 线上,配合走线阻抗可以明显削弱信号振铃和过冲。
连线对应关系:
| MR25H40CDF | PIC18F86J16 | 备注 |
|---|---|---|
| CS# | RB0(GPIO) | 软件控制,低有效 |
| SCK | RC3(SCK1) | SPI 时钟 |
| SI | RC5(SDO1) | 主出从入 |
| SO | RC4(SDI1) | 主入从出 |
| WP# | 3.3V 通过 10k 上拉 | 不使用写保护 |
| HOLD# | 3.3V 通过 10k 上拉 | 禁用暂停功能 |
3.3V 电源我用的是单片 AMS1117-3.3 稳压器,MRAM 和 PIC 共用同一路电源。刚开始我担心 MRAM 瞬时大电流会把 PIC 的 ADC 参考电压拉偏,实测下来 MRAM 的静态电流和动态电流都很低,没有这个问题。
2.3 PCB 布局的关键细节
PCB 布局方面,我对这部分的建议是:把 MRAM 放在 PIC 旁边,走线越短越好。
我第一版 PCB 因为结构原因把 MRAM 放到了板子角落,SPI 走线绕了将近 8cm,工作频率一提高就出问题。后来改成紧挨 PIC,走线缩短到 2cm 以内,信号质量立刻正常了。
如果现场走线实在不可避免要超过 5cm,建议在靠近驱动端串联 22-47Ω 电阻,并把 SPI 时钟频率降下来。工业环境里的电磁干扰不比实验室,别去挑战极限频率。我实际项目里就把 SPI 时钟设置在 Fosc/64,对应大约 750kHz,这个速度对 512KB 的读写来说完全够用。
3. 驱动代码实现:从 MSSP 配置到 MRAM 读写命令
3.1 SPI 模式的确定与初始化代码
MR25H40CDF 支持的 SPI 模式是 Mode 0 和 Mode 3,最常用的是 Mode 0:CPOL=0(空闲时时钟为低),CPHA=0(上升沿采样数据)。PIC18F86J16 的 MSSP 模块里,这两个参数分别对应 CKP 位和 CKE 位。
- CKP=0,对应 CPOL=0,空闲时钟低电平;
- CKE=0,配合 CKP=0 就是 SPI Mode 0,数据在 SCK 上升沿被主机锁存。
初始化代码:
void MRAM_SPI_Init(void) { // 方向设置:SCK1=RC3、SDO1=RC5、SDI1=RC4 TRISCbits.TRISC3 = 0; // SCK 输出 TRISCbits.TRISC5 = 0; // SDO 输出 TRISCbits.TRISC4 = 1; // SDI 输入 // CS 使用 RB0,先拉高 TRISBbits.TRISB0 = 0; LATBbits.LATB0 = 1; // 清除 MSSP1 相关中断标志 PIR1bits.SSP1IF = 0; SSP1STAT = 0x40; // SMP=1, CKE=0,SPI Mode 0 SSP1CON1 = 0x31; // SSPEN=1, CKP=0, 主模式 Fosc/16 }这里我选择 Fosc/16 而不是 Fosc/4,因为系统时钟是 48MHz,Fosc/4 就是 12MHz,MRAM 规格上虽然支持到 40MHz 以上,但板子走线、连接器接触电阻这些都会打折扣。Fosc/16 大约 3MHz,实测读写速度快且稳定,对日志记录场景绰绰有余。
3.2 核心传输函数与片选时序
MSSP 的 SPI 传输是一发一收同步完成的。向 SSP1BUF 写入一个字节,硬件就自动开始移位收发,BF 标志位会在接收完成时置 1。所以我用一个统一的传输函数处理收发:
unsigned char MRAM_SPI_Transfer(unsigned char byte) { unsigned int timeout = 0; SSP1BUF = byte; while (!SSP1STATbits.BF) { if (++timeout > 10000) { return 0xFF; // 超时保护,防止死循环 } } return SSP1BUF; }那个超时保护非常关键。工业设备上如果 SPI 时钟线被干扰拉死,或者接触不良,BF 标志可能永远不置位,没有超时的话程序就死在这里,看门狗也只能反复复位。
片选时序是整个驱动里最容易被忽略的地方。操作 MRAM 时,CS# 拉低后可以连续发送命令字节、地址字节和数据字节,完成后必须把 CS# 拉高。一个完整的写使能操作是:
void MRAM_WriteEnable(void) { MRAM_CS_Low(); MRAM_SPI_Transfer(0x06); // WREN 命令 MRAM_CS_High(); }写使能之后,芯片内部 WEL 位会被置 1,接下来的一次写命令会被接受,写完之后 WEL 位自动清零。所以每次写操作之前都要先发一次写使能,这是 SPI 存储芯片的统一要求。
有个细节是:CS# 从拉低到第一个时钟上升沿之间,要留出至少几十纳秒的建立时间。MSSP 硬件本身不会帮你控制这个时序,完全取决于代码中软件操作的间隔。如果 CS# 刚拉低就立刻传输数据,虽然大多数时候没问题,但在高温和电压偏低的情况下偶尔会失效。稳妥做法是在 CS# 拉低后插入几条 NOP 指令。
3.3 读取、写入与状态查询的实现
MRAM 支持三种读命令:普通的 0x03 读、带 dummy 字节的快速读 0x0B,以及双倍速率模式。常规场景用 0x03 就够了。读操作不需要写使能,可以直接发起:
void MRAM_ReadBytes(unsigned long addr, unsigned char *buf, unsigned int len) { unsigned int i; MRAM_CS_Low(); MRAM_SPI_Transfer(0x03); // READ 命令 MRAM_SPI_Transfer((unsigned char)(addr >> 16)); // A23~A16 MRAM_SPI_Transfer((unsigned char)(addr >> 8)); // A15~A8 MRAM_SPI_Transfer((unsigned char)(addr)); // A7~A0 for (i = 0; i < len; i++) { buf[i] = MRAM_SPI_Transfer(0x00); // 发送空字节,同时接收数据 } MRAM_CS_High(); }写命令是 0x02,地址同样是三字节。因为 MRAM 没有页对齐的概念,所以只要地址连续,len 可以一直写到芯片末尾,想写多长写多长,不需要像 Flash 那样分页处理。
void MRAM_WriteBytes(unsigned long addr, const unsigned char *buf, unsigned int len) { unsigned int i; MRAM_WriteEnable(); MRAM_CS_Low(); MRAM_SPI_Transfer(0x02); // WRITE 命令 MRAM_SPI_Transfer((unsigned char)(addr >> 16)); MRAM_SPI_Transfer((unsigned char)(addr >> 8)); MRAM_SPI_Transfer((unsigned char)(addr)); for (i = 0; i < len; i++) { MRAM_SPI_Transfer(buf[i]); } MRAM_CS_High(); }地址是 24 位的,但 MR25H40CDF 只有 512KB,实际只有 19 位有效地址,最高 5 位会忽略。所以初始化时 512KB 以上的地址空间不会卷绕回低地址,而是落在同一个物理存储区。我在代码里做了强制取模,把地址限制在 0x000000 ~ 0x07FFFF 范围内,防止越界。
状态寄存器读取命令是 0x05,返回 S0(写进行中)和 S1(写使能锁存)两位信息。对 MRAM 来说,写入是即时完成的,不需要等待内部编程时间,所以 S0 位实际上很少为 1。不过我还是保留了状态查询函数,用来测试通信是否正常:
unsigned char MRAM_ReadStatus(void) { unsigned char status; MRAM_CS_Low(); MRAM_SPI_Transfer(0x05); // RDSR 命令 status = MRAM_SPI_Transfer(0x00); MRAM_CS_High(); return status; }上电自检时,我会连续读三次状态寄存器,如果三次结果一致且不是 0x00 也不是 0xFF,基本可以判断 SPI 通信链路正常。这是整机自检的一部分。
4. 工业现场绕不开的问题:掉电、干扰与数据一致性
4.1 掉电瞬间的写保护策略
工业设备最讨厌的场景就是突然断电,尤其是正在写数据的时候断电。虽然 MRAM 本身有内置的掉电数据保护机制,我在系统层面仍然做了三层防护:
第一层,PIC18F86J16 启用欠压复位功能(BOR)。当 3.3V 跌落到阈值以下时,MCU 立刻进入复位状态,不再执行后续指令,避免在电源不稳时继续操作 SPI。
第二层,利用一个 GPIO 检测 3.3V 电源的直流电压,在电压跌落到 3.1V 以下之前提前判断掉电。检测到掉电信号后,我立即完成最后一批关键数据的写入。MRAM 写一个字节只要几个 SPI 时钟周期,从检测到掉电到写完全部关键参数,实测只需要不到 2ms。
第三层,电源端并联了一个 470μF 电解电容,给 MCU 争取足够的掉电延迟时间。这个电容配合 BOR 和掉电检测,能保证关键的运行参数在断电瞬间完整写入。
这里有个经验:不要把关键数据散落在一个大区域里写,而是集中写在固定地址段。掉电检测触发后,我只需要调用一次写函数,把 256 字节的参数块一次写完,512KB 的 MRAM 写入这 256 字节总耗时不超过 1ms,非常快。
4.2 EMC 干扰下的重试与校验机制
工业现场里,变频器和接触器的启停会产生强烈的电磁干扰,SPI 线上的信号可能被瞬间毛刺打乱。干扰导致的典型表现是数据位翻转,比如写进去 0xAA 读出来 0x2A 或者 0x8A。
对这种瞬态错误,我的处理方式分三级:
- 读回校验:每次写完后,立即读回对比。不一致就重写,最多重试三次。
- CRC 校验:重点数据块后面附加 2 字节 CRC16。读取时先算 CRC,发现不匹配就走恢复流程。
- 定期巡检:每 10 分钟扫描一遍重要参数区,发现校验错误立刻从备份区恢复。
实测下来,读回校验能解决绝大部分偶发干扰问题。CRC 主要防的是那种读回时恰好对了一次、但实际数据已经损坏的极端情况。定期巡检是最后一道防线,用于处理环境变化导致的长期漂移。
CRC16 的实现我用的是查表法,初始化 256 项查找表,计算 16 字节数据只要 16 次查表和异或,在 48MHz 主频下开销可以忽略。
4.3 双区备份与事务性存储设计
对运行参数和标定数据这类"改错一次就可能出大事故"的数据,我不能只存一份。实际设计里把 512KB 空间规划成了两个 128KB 的参数区和一个 256KB 的日志区:
- 参数区 A:0x000000 - 0x01FFFF
- 参数区 B:0x020000 - 0x03FFFF
- 日志区:0x040000 - 0x07FFFF
参数写入时,先写 A 区,写完后写入一个事务标志字节。如果写 B 区之前掉电了,下次上电会发现 A 区事务标志完整、B 区没有有效事务标志,于是从 A 区加载参数。如果 A 区写到一半掉电,事务标志会处于错误值,B 区反而是完整的,就从 B 区加载。这个双区乒乓机制是嵌入式里最经典的可靠存储手段。
日志区我采用追加式写入,每条记录开头放 4 字节时间戳、2 字节数据块长度、2 字节 CRC16,结尾放 1 字节校验状态。读取时按时间戳排序,发现 CRC 错误就直接跳过该条记录。这种设计保证任何异常掉电后,日志区最多损失最后一条不完整记录,前面的完整记录永远可读。
5. 调试中我实际踩过的坑:从波形到代码
5.1 读回全 0xFF:SPI 极性问题,示波器说了话
第一次跑通代码时,读取 MRAM 缓冲区,发现所有字节都是 0xFF。我当时第一反应是芯片引脚没焊好或者地址错误,但万用表测过之后都正常。
后来用示波器同时抓 SCK 和 SO 脚,发现数据确实输出到了 SO 引脚,但 PIC 侧采样的时序不对。问题出在 CKE 位的配置上。我一开始把 SSP1STAT 配成 SMP=0、CKE=1,这实际上是 SPI Mode 1 的采样方式,在下降沿采样。而 MRAM 在 Mode 0 下是上升沿输出数据、下降沿锁存数据,两边完全错开了。
改成 SSP1STAT = 0x40(SMP=1、CKE=0)之后,SO 线上的数据和 SCK 上升沿完全对齐,数据立刻读对了。
这个坑的教训是:调试 SPI 通信,不要只盯着代码逻辑,拿示波器看波形是最快的定位手段。时钟极性和相位配错,软件上怎么改都没有意义。
5.2 长线传输导致的数据错位
第一版 PCB 上因为布局原因,SPI 走线绕了 8cm,信号经过一整条狭长走线后出现了明显的过冲和振铃。示波器上能清楚看到 SCK 信号的上升沿有过冲超过 4V、下降沿有下冲到 1V 以下的情况。这种振铃会导致 MSSP 硬件在同一个时钟周期里多识别出几个边沿,数据自然就错了。
处理办法是把 SPI 时钟从 Fosc/4 降到 Fosc/64,同时在 SCK、SI、SO 三条线上各串了 33Ω 电阻。降频之后振铃幅值显著减小,数据完全稳定了。
所以 PCB 布局阶段最好就把存储芯片放在 MCU 附近。如果实在没法缩短距离,优先用降频而不是加缓冲器。在工业设备里,信号完整性比传输速率重要得多,一个 512KB 的存储芯片就算用 750kHz 时钟,整片读一遍也只要不到 1 秒。
5.3 片选时序不规范,数据写不进去
还有一个隐蔽的问题是写入偶尔会失败。现象是程序运行了几个月后,某次参数修改明明提示写入成功,但重启后还是旧值。
排查到最后发现是写使能和写入命令之间的 CS# 时序间隔不够。我的代码是这样的:
MRAM_CS_Low(); MRAM_SPI_Transfer(0x06); MRAM_CS_High(); // 立即再次拉低 CS 发写命令 —— 这里间隔太短 MRAM_CS_Low(); MRAM_SPI_Transfer(0x02); ...由于两次 CS# 拉低之间几乎没有间隔,MRAM 内部的写使能锁存可能还没来得及稳定,第二次写命令就没有被正确响应。在常温下这种问题很隐蔽,但在高温、电压偏低的条件下就会显现出来。
修正方法是在 WREN 命令的 CS# 拉高后,插入大约 1μs 的延时,或者干脆调用几个空循环,确保 WEL 位稳定后再发起写命令。从那以后,我把延时写入驱动库,再也没出现过这个故障。
6. 长期运行与产品化心得:写入策略、完整性与存储规划
6.1 地址空间怎么规划才不乱
存储规划直接决定后期维护难度。我的分区方式:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| 参数区 A | 0x000000 | 128KB | 运行参数主副本 |
| 参数区 B | 0x020000 | 128KB | 运行参数备份副本 |
| 日志区 | 0x040000 | 128KB | 历史数据循环记录 |
| 故障区 | 0x060000 | 64KB | 故障日志循环记录 |
| 系统区 | 0x070000 | 64KB | 配置信息、自检状态、预留 |
每个区域我都预留了 1KB 的空白隔离带,防止相邻区域的越界写入互相干扰。实际使用中还会在每个区域头部写一个 4 字节的魔数标识,比如 0xA5A5A5A5,上电初始化时先检查魔数是否正确,不正确就直接判定该区域数据无效。
这个规划看起来简单,但非常实用。我见过不少项目把参数和日志混在一起存,改一个参数要扫描整个存储空间定位,维护成本极高。提前划分好区域,后期增加功能时只需要调整各区域大小和地址,不用重构存储逻辑。
6.2 日志型写入设计与 CRC 校验
日志区我用的是"追加式"写入而不是覆盖式。新日志永远写在当前写指针位置,写满一圈后从最早的数据开始覆盖。这样设计有两个好处:
一是任何一次掉电,最多丢一条未写完整的记录,不会被坏数据污染整片日志区; 二是不需要频繁修改固定地址的"最新记录指针",大大减少了写次数。
每条日志记录的格式:
typedef struct { uint32_t timestamp; // 时间戳,Unix 格式 uint16_t length; // 数据体长度 uint16_t crc; // CRC16,覆盖从 timestamp 开始的数据 uint8_t payload[32]; // 数据体 uint8_t valid; // 有效标志,=0xA5 表示有效 } LogRecord;写记录时先填充除 valid 外的所有字段,计算 CRC 后写整条记录,最后写 valid 标志。读取时如果 valid 不是 0xA5,或者 CRC 对不上,就认为这条记录不完整,直接跳过。这个"最后写有效标志"的技巧是事务性存储的简化版,实现简单,可靠性却非常高。
CRC 我用的是 CRC16-CCITT,多项式 0x1021。在 PIC18F 上实现查表法,计算一条 40 字节的日志记录大概耗时 100μs,完全不影响运行。
6.3 环境试验、批量一致性与给产品定型的三条建议
批量生产后有件事被很多人忽略:MRAM 芯粒之间的电气特性有细微差异,不是每颗都保证最优时序。
我做过 200 颗样本的批量测试,方法很简单:上电后对全片做一遍 0x55/0xAA 交替写入再读回验证,连续跑 10 轮。全部通过才算合格。虽然 MRAM 的失效率非常低,但这种 5 分钟的产线自检能拦截掉贴片虚焊、引脚连锡这类不良品,实际价值很大。
环境试验方面,我把设备放在恒温箱里做过 -40℃ 到 +85℃ 的循环测试,同时反复开关机,验证掉电写入逻辑。MRAM 在这些极端条件下表现非常稳定,这一点上比普通 SPI Flash 省心得多。SPI Flash 在低温下擦写性能会明显变差,而 MRAM 基本感觉不到变化。
最后提一个系统性问题:驱动代码里尽量保证每次上位机下发的参数校验通过后再写 MRAM,不要在存储芯片的实际数据里依赖上位机的正确性。我在代码里做过一个防呆保护,上位机下发参数时先按工程单位范围检查一遍,超范围的值直接丢弃。这样可以避免因为上位机故障把错误数据写到存储区,导致设备一上电就进入错误状态。
这块存储方案做完之后,设备已经在现场稳定跑了两年多,没有出现过一次数据丢失或者读回异常。MRAM 的写入性能和掉电可靠性确实没有让我失望。如果说有什么遗憾,那就是当初没直接选 8Mbit 的 MR25H80,512KB 在日志记录场景下还是略显紧张,现在每次日志写满都要提前规划压缩策略。如果你也有工业级的需求,建议优先考虑容量余量充足一点的型号,别等装备定型了再后悔。