1. 为什么在工业场景里我会优先考虑 MRAM 而不是 EEPROM
做工业嵌入式这行十几年,最怕听到客户说一句话:“现场数据又丢了。”尤其是那些部署在配电柜、远程泵站、冷链仓储里的设备,断电是家常便饭,有些场合甚至是每天定时拉闸。早些年我用 24C 系列 EEPROM 存参数,用着用着就发现两个绕不开的坎:一是写入速度慢,字节写要等 5ms 左右,页写也就那样,频繁记录运行日志的时候 CPU 大量时间耗在等写完成上;二是擦写寿命有限,标称 100 万次,实际在高温高湿环境下打个对折都算乐观,日志写得勤一点,两三年就开始出现坏块。
后来接触到 MR25H40CDF 这颗 MRAM,才算真正把“频繁写 + 断电不丢 + 长寿命”这三件事同时解决掉。MRAM 的存储机理和 Flash、EEPROM 完全不同,它靠的是磁性隧道结的磁化方向来记录 0 和 1,没有浮栅、没有电荷泵、没有擦除动作。带来的直接好处就是:写入不需要先擦除,字节级随机写,写一个字节和写一整页在时间上没有本质差别,而且官方标称的耐久度是 10^14 次以上量级,这个数字对绝大多数工业应用来说基本等于“无限次”。
MR25H40CDF 的容量是 4Mbit,也就是 512KB,组织成 512K × 8 位。接口是标准的 SPI,支持 Mode 0 和 Mode 3,最高时钟能跑到 40MHz。这个容量放在今天看不算大,但对于存配置参数、校准系数、运行日志、故障快照、掉电前的现场状态这些用途,绰绰有余。我手上一个远程监控终端项目,用它存每 10 秒一条的运行记录,每条 32 字节,算下来能存 16000 多条,按环形覆盖的方式写,跑了大半年一点问题没有。
选它还有一个很现实的原因:SPI 接口意味着几乎任何 MCU 都能直接挂。这次我搭配的是 PIC18F87K22,一颗 8 位机里比较能打的型号,自带 MSSP 模块支持 SPI 主模式,128KB Flash、4KB RAM,引脚多、外设全,工业现场用得很广。8 位机配 512KB 的 MRAM,听起来有点“小马拉大车”,但实际用下来完全够,因为 MRAM 的读写是流式的,MCU 只管把字节推出去,不需要自己做复杂的块管理。
提示:MRAM 虽然写入不需要擦除,但它并不是“随便写不用管”的存储器。上电后的写保护配置、状态寄存器轮询、以及 SPI 时序的建立/保持时间,这几处如果处理不好,照样会出现写不进去或者读出来全是 0xFF 的情况。后面我会把这些坑一个个拆开讲。
这篇文章我打算按实际项目的推进顺序来写:先讲硬件怎么连、SPI 模式怎么定,再讲 PIC18F87K22 的 MSSP 初始化细节,然后是 MR25H40CDF 的读写指令集和状态机,接着是数据可靠性的处理(校验、掉电保护、磨损均衡其实不需要但环形管理要做),最后是实测中踩过的坑和排查过程。如果你手上正好有这两颗料,或者正在选型工业存储方案,这篇可以直接拿去抄作业。
2. 硬件连接与 SPI 模式选择:别小看那两根线的走法
2.1 MR25H40CDF 的引脚定义与 PIC 端映射
MR25H40CDF 是 8 引脚封装,引脚定义很标准:CS(片选,低有效)、SO(数据输出,从机到主机)、SI(数据输入,主机到从机)、SCK(时钟)、VDD、VSS,另外还有 WP(写保护)和 HOLD(保持)两个引脚。这里有个细节很多人会忽略:WP 和 HOLD 在 MR25H40CDF 上是复用功能的,具体行为取决于状态寄存器的配置位。如果你不打算用硬件写保护和总线暂停功能,这两个脚必须拉到 VDD,不能悬空。我见过有人把 HOLD 悬空,结果 SPI 通信时好时坏,查了两天才发现是 HOLD 引脚被干扰拉低,芯片进入了保持状态,SO 变成高阻。
PIC18F87K22 这边,我用的是 MSSP1 模块,映射到 RC3(SCK)、RC4(SDI/SI)、RC5(SDO/SO),片选用 RB0 普通 IO 控制。这里要注意 PIC18F87K22 的 MSSP 模块在 SPI 主模式下,SDO 是输出、SDI 是输入,和 MRAM 的 SI/SO 是交叉连接的,别接成同名对同名。
| MR25H40CDF 引脚 | 方向 | PIC18F87K22 引脚 | 说明 |
|---|---|---|---|
| CS | 输入 | RB0 | 软件控制片选,低有效 |
| SCK | 输入 | RC3/SCK | MSSP1 时钟输出 |
| SI | 输入 | RC5/SDO | 主机输出到从机输入 |
| SO | 输出 | RC4/SDI | 从机输出到主机输入 |
| WP | 输入 | VDD | 不用硬件写保护则拉高 |
| HOLD | 输入 | VDD | 不用总线暂停则拉高 |
| VDD | 电源 | 3.3V | 注意电压域 |
| VSS | 地 | GND | 就近接地 |
2.2 电压域与电平匹配的坑
MR25H40CDF 的工作电压是 2.7V 到 3.6V,典型 3.3V。PIC18F87K22 有宽压版本,但我用的是 3.3V 供电的那档,所以电平直接匹配,不需要电平转换。如果你手上是 5V 供电的 PIC18 型号,那就必须加电平转换,否则 MRAM 的输入脚会被 5V 打坏。这一点在选型阶段就要确认清楚,别等板子打回来才发现。
另外,MRAM 的 VDD 去耦电容我建议放 0.1μF 加 1μF 组合,尽量靠近芯片引脚。MRAM 在写入瞬间的电流会有小幅波动,虽然不像 Flash 编程那样有大的尖峰,但工业现场电源纹波本来就大,多一颗电容不亏。
2.3 SPI 模式 0 还是模式 3
MR25H40CDF 支持 SPI Mode 0(CPOL=0,CPHA=0)和 Mode 3(CPOL=1,CPHA=1)。这两种模式的区别在于空闲时时钟是高还是低,以及数据在哪个边沿采样。我一般选 Mode 0,原因是 PIC18F87K22 的 MSSP 在 Mode 0 下配置最直观,SCK 空闲为低,数据在 SCK 上升沿被从机采样,下降沿输出。Mode 3 也能用,但如果你系统里还挂了别的 SPI 从设备,统一模式能省很多事。
配置 MSSP1 的关键寄存器是 SSP1CON1 和 SSP1STAT。Mode 0 对应 SSP1CON1 的 CKP=0(空闲低),SSP1STAT 的 CKE=0(数据在时钟从空闲态到有效态的边沿发送)。实际写出来就是:
// PIC18F87K22 MSSP1 初始化,SPI 主模式,Mode 0,时钟 Fosc/16 SSP1STAT = 0x40; // CKE=0,SMP=0(在数据输出末尾采样) SSP1CON1 = 0x21; // SSPEN=1,CKP=0,SPI 主模式 Fosc/16 SSP1ADD = 0x00; // 不用地址掩码 TRISC3 = 0; // SCK 输出 TRISC5 = 0; // SDO 输出 TRISC4 = 1; // SDI 输入 TRISB0 = 0; // CS 输出 CS = 1; // 初始拉高,不选中时钟分频这里我选 Fosc/16。假设系统时钟 64MHz,分频后 SCK 是 4MHz,MRAM 完全能吃下。为什么不直接上 40MHz?因为 PIC18F87K22 的 MSSP 在 64MHz 系统时钟下最高也就 Fosc/4 即 16MHz,而且长走线、工业干扰环境下,时钟太快容易出错。4MHz 是个稳妥的折中,实测读写 512KB 全片也就一百多毫秒,对工业应用完全够。
注意:SSP1STAT 的 SMP 位决定采样时刻。SMP=0 是在数据输出时间的末尾采样,SMP=1 是在中间采样。高速时用 SMP=0 更稳,低速时两者都行。我统一用 SMP=0。
3. PIC18F87K22 的 MSSP 驱动封装:把字节收发做扎实
3.1 单字节收发的底层函数
SPI 通信的本质就是“换字节”:主机发一个字节出去,同时从机回一个字节进来。PIC18F87K22 的 MSSP 用 SSP1BUF 寄存器完成这件事,写进去启动发送,读出来拿到接收数据。判断完成靠 SSP1IF 中断标志或者 PIR1 的 SSP1IF 位。我习惯用查询方式,简单可靠:
unsigned char SPI1_ExchangeByte(unsigned char txData) { SSP1BUF = txData; // 写入待发送数据,启动传输 while (!PIR1bits.SSP1IF); // 等待传输完成 PIR1bits.SSP1IF = 0; // 清标志 return SSP1BUF; // 读取接收到的数据 }这个函数看着简单,但有两个地方容易出问题。第一,如果 SCK 配置错误或者 CS 没拉低,SSP1IF 永远不置位,程序就死在这里。所以实际项目里我会加超时保护:
unsigned char SPI1_ExchangeByte_Timeout(unsigned char txData, unsigned int *timeout) { SSP1BUF = txData; while (!PIR1bits.SSP1IF) { if (--(*timeout) == 0) return 0xFF; // 超时返回 } PIR1bits.SSP1IF = 0; return SSP1BUF; }第二,SSP1BUF 是双缓冲的,读之前必须确认 SSP1IF 已经置位,否则读到的是上一次的残留数据。这个坑我在早期调试时踩过,读出来的 ID 总是差一位,后来发现是没等标志就提前读了。
3.2 片选控制的时序要求
MR25H40CDF 对 CS 的时序有明确要求:CS 拉低到第一个 SCK 上升沿之间要有至少 5ns 的建立时间,最后一个 SCK 下降沿到 CS 拉高之间也要有至少 5ns 的保持时间。在 4MHz 时钟下,一个时钟周期 250ns,这些时间自然满足。但如果你把时钟提到 16MHz 以上,就要注意在 CS 操作前后加几个 NOP 或者用示波器确认。
我的做法是把片选操作封装成宏,并在拉低和拉高前后各加一个空操作,保证时序余量:
#define MRAM_CS_LOW() do { CS = 0; NOP(); } while(0) #define MRAM_CS_HIGH() do { NOP(); CS = 1; } while(0)3.3 多字节读写与地址递增
MR25H40CDF 的读指令(READ,0x03)和写指令(WRITE,0x02)都支持地址自动递增。发完 3 字节地址后,可以连续读或写任意多个字节,芯片内部地址指针会自动加一,到 0x7FFFF 后回卷到 0x00000。这个特性让批量读写非常方便:
void MRAM_ReadBytes(unsigned long addr, unsigned char *buf, unsigned int len) { unsigned int i; MRAM_CS_LOW(); SPI1_ExchangeByte(0x03); // READ 指令 SPI1_ExchangeByte((addr >> 16) & 0xFF); // 地址高字节 SPI1_ExchangeByte((addr >> 8) & 0xFF); // 地址中字节 SPI1_ExchangeByte(addr & 0xFF); // 地址低字节 for (i = 0; i < len; i++) { buf[i] = SPI1_ExchangeByte(0x00); // 发哑元,收数据 } MRAM_CS_HIGH(); }写操作稍微复杂一点,因为写之前要发 WREN(0x06)指令使能写锁存,写完还要等状态寄存器的 WIP 位清零:
void MRAM_WriteBytes(unsigned long addr, const unsigned char *buf, unsigned int len) { unsigned int i; MRAM_CS_LOW(); SPI1_ExchangeByte(0x06); // WREN MRAM_CS_HIGH(); MRAM_CS_LOW(); SPI1_ExchangeByte(0x02); // WRITE 指令 SPI1_ExchangeByte((addr >> 16) & 0xFF); SPI1_ExchangeByte((addr >> 8) & 0xFF); SPI1_ExchangeByte(addr & 0xFF); for (i = 0; i < len; i++) { SPI1_ExchangeByte(buf[i]); } MRAM_CS_HIGH(); MRAM_WaitWriteComplete(); // 等 WIP 清零 }3.4 状态寄存器轮询:WIP 位到底要不要等
MRAM 的写操作是异步完成的,写入指令发完后,芯片内部还在把数据写进存储阵列,这时候状态寄存器的 WIP(Write In Progress)位是 1。理论上你必须等 WIP 变 0 才能发下一条写指令,否则新指令会被忽略。但 MRAM 的写入速度极快,一个字节的写入时间在几十纳秒量级,实际上等你发完 CS 拉高再发下一条指令,WIP 早就清零了。
不过我在代码里还是保留了轮询,原因是工业现场温度范围宽,-40℃ 到 +85℃ 都要工作,低温下写入时间会变长。轮询函数这样写:
void MRAM_WaitWriteComplete(void) { unsigned char status; do { MRAM_CS_LOW(); SPI1_ExchangeByte(0x05); // RDSR status = SPI1_ExchangeByte(0x00); MRAM_CS_HIGH(); } while (status & 0x01); // WIP 位 }这里有个细节:RDSR 指令读出来的状态寄存器,bit0 是 WIP,bit1 是 WEL(写使能锁存),bit2 是 BP0,bit3 是 BP1。BP0/BP1 是块保护位,可以保护部分地址区域不被写入。如果你不需要块保护,初始化时把状态寄存器写成 0x00 就行。
4. MR25H40CDF 的指令集与初始化流程
4.1 上电后的第一件事:读 ID 确认通信
每次上电,我做的第一件事不是急着读写数据,而是读 MRAM 的设备 ID。MR25H40CDF 支持 RDID(0x9F)指令,返回 1 字节的制造商 ID 和 2 字节的设备 ID。制造商 ID 是 0xE0,设备 ID 是 0x22 和 0x08(具体以数据手册为准)。读 ID 能一次性验证三件事:SPI 模式对不对、片选有没有生效、芯片有没有坏。
unsigned char MRAM_ReadID(unsigned char *idBuf) { MRAM_CS_LOW(); SPI1_ExchangeByte(0x9F); // RDID idBuf[0] = SPI1_ExchangeByte(0x00); // 制造商 ID idBuf[1] = SPI1_ExchangeByte(0x00); // 设备 ID 高字节 idBuf[2] = SPI1_ExchangeByte(0x00); // 设备 ID 低字节 MRAM_CS_HIGH(); return (idBuf[0] == 0xE0) ? 1 : 0; }如果读出来全是 0xFF,说明 SO 线没接好或者 CS 没拉低;如果读出来全是 0x00,说明 SI 线可能短路到地。这两种情况我都遇到过,排查起来其实很快,关键是养成上电先读 ID 的习惯。
4.2 状态寄存器配置与写保护
MR25H40CDF 的状态寄存器只有 1 个字节,可写的位包括 WEL、BP0、BP1。WEL 是写使能锁存,每次写操作前必须通过 WREN 指令置 1,写完成后自动清零。BP0/BP1 用来保护地址区域:
| BP1 | BP0 | 保护区域 |
|---|---|---|
| 0 | 0 | 无保护 |
| 0 | 1 | 0x60000 - 0x7FFFF |
| 1 | 0 | 0x40000 - 0x7FFFF |
| 1 | 1 | 全片保护 |
工业项目里我一般把关键参数放在低地址区,日志放在高地址区,然后把 BP 位设成 0b01,保护高地址区的日志不被误写。但要注意,一旦设了块保护,写保护区域内的数据就写不进去了,调试阶段容易忘了这回事,以为芯片坏了。
4.3 初始化流程的完整顺序
把上面的东西串起来,上电初始化按这个顺序走:
- 配置 PIC18F87K22 的 MSSP1 为 SPI 主模式,Mode 0,Fosc/16。
- 配置 CS、SCK、SDO 为输出,SDI 为输入。
- CS 拉高,延时 1ms 等 MRAM 内部上电稳定。
- 读 RDID,确认返回 0xE0。
- 读状态寄存器,确认 WIP=0。
- 如需块保护,发 WREN 后写状态寄存器。
- 开始正常读写。
这个顺序里第 3 步的延时容易被省掉。MRAM 的上电时间是微秒级,但 PIC 的 MSSP 初始化也需要时间,加 1ms 延时能避免上电瞬间的时序竞争。我在一个低温测试项目里,-40℃ 下如果不加这个延时,读 ID 会偶发失败,加了之后就稳了。
5. 数据可靠性设计:校验、环形日志与掉电保护
5.1 为什么 MRAM 也需要数据校验
有人会问,MRAM 不是不会丢数据吗,为什么还要校验?这里要区分两件事:存储单元的可靠性和数据传输的可靠性。MRAM 的存储单元确实很稳,10^14 次写入寿命、数据保持 20 年以上,但 SPI 传输过程中如果受到工业干扰,字节可能传错。另外,如果 MCU 在写数据的过程中掉电,可能只写了一半,留下一条不完整的记录。所以校验和掉电保护还是要做。
我的做法是每条记录加 2 字节 CRC16 和 1 字节状态标志。状态标志用 0xA5 表示“这条记录有效”,写入顺序是先写数据、再写 CRC、最后写状态标志。这样即使掉电发生在写状态标志之前,这条记录也不会被误认为是有效的。
typedef struct { unsigned long timestamp; unsigned char data[24]; unsigned short crc; unsigned char valid; // 0xA5 表示有效 } LogRecord;5.2 环形日志的地址管理
512KB 的空间,我划出前 64KB 存配置参数和校准系数,后 448KB 做环形日志区。环形日志的关键是维护一个写指针,每次写完一条记录,指针后移,到末尾回卷到起始地址。写指针本身也要存,我把它存在配置区的固定位置,每次上电读出来。
这里有个细节:写指针的更新和日志记录的写入不是原子的。如果写完日志、还没更新写指针就掉电,下次上电会覆盖同一条记录。解决办法是写指针用“双备份 + 序号”的方式存,两个位置轮流写,每个位置带一个递增序号,上电时取序号大的那个。这个思路和很多文件系统的超级块管理是一样的。
5.3 掉电检测与紧急保存
PIC18F87K22 有内置的欠压复位(BOR)和低压检测(LVD)模块。我把 LVD 配置成在电源跌落到 2.9V 时触发中断,中断里立刻把关键状态写进 MRAM。因为 MRAM 写入不需要擦除、速度快,从检测到掉电到写完几十字节,在毫秒级时间内就能完成,而电源上的大电容能撑住这几十毫秒。
void __interrupt(high_priority) HighISR(void) { if (PIR2bits.LVDIF) { PIR2bits.LVDIF = 0; MRAM_WriteBytes(EMERGENCY_ADDR, (unsigned char*)&g_state, sizeof(g_state)); // 其他紧急处理 } }这个机制我在一个冷链监控项目里实测过,直接拔电源,每次都能把最后的状态存下来。注意 LVD 的阈值要选得比 BOR 高一点,否则 BOR 先复位了,中断根本没机会执行。
6. 实测踩坑记录:从读不出 ID 到稳定运行
6.1 第一个坑:读 ID 返回 0xFF
板子第一次上电,读 ID 返回全 0xFF。按经验,0xFF 通常意味着 MISO 线上没有数据回来。我先用示波器看 SCK,有波形,频率也对;再看 CS,拉低正常;最后看 SO,发现 SO 一直是高电平,没有翻转。查原理图发现 SO 和 SI 接反了——我把主机的 SDO 接到了从机的 SO 上,等于两个输出对顶。改过来之后,ID 正常读出 0xE0。
这个坑的教训是:SPI 的 SI/SO 命名是从从机角度定义的,主机和从机之间必须交叉连接。很多新手会按同名连接,结果就是读不到数据。
6.2 第二个坑:写入后读出来是旧数据
ID 读出来后,我写了一段测试数据,再读出来发现还是旧数据。排查发现是 WREN 指令没有正确发送。WREN 必须在 CS 拉低后单独发一个字节,然后 CS 拉高,再重新拉低发 WRITE 指令。我一开始把 WREN 和 WRITE 放在同一个 CS 周期里,结果 WREN 被忽略了。MRAM 的写使能锁存是在 CS 上升沿锁存的,所以 WREN 必须独立一个 CS 周期。
6.3 第三个坑:高速时钟下的偶发错误
把 SCK 从 4MHz 提到 10MHz 后,连续读写测试跑一万次会出现几次数据错误。用示波器看波形,发现 SCK 上升沿有过冲,SO 线上的数据在采样点附近有振铃。这是典型的信号完整性问题。解决办法是在 SCK 和 SO 线上串 22Ω 电阻,并把走线缩短。改板后 10MHz 下跑十万次无错误。后来我干脆把时钟定在 4MHz,工业现场稳定压倒一切。
6.4 第四个坑:低温下写不进去
产品做高低温测试,-40℃ 下写日志偶尔失败。查下来是 MRAM 在低温下写入时间变长,而我的代码在写完后没有等 WIP 清零就发下一条指令。加上MRAM_WaitWriteComplete()之后问题消失。这也印证了前面说的,WIP 轮询在宽温场景下不能省。
7. 几个让项目更稳的工程习惯
7.1 地址空间规划表
项目一开始就把地址空间规划好,写进文档,后面维护会轻松很多。我的习惯是:
| 地址范围 | 用途 | 大小 |
|---|---|---|
| 0x00000 - 0x000FF | 设备信息与版本 | 256B |
| 0x00100 - 0x00FFF | 配置参数 | 3.5KB |
| 0x01000 - 0x01FFF | 校准系数 | 4KB |
| 0x02000 - 0x0FFFF | 预留 | 56KB |
| 0x10000 - 0x7FFFF | 环形日志区 | 448KB |
7.2 读写函数的返回值设计
底层读写函数我全部返回状态码,而不是 void。这样上层调用能知道这次操作到底成没成,方便做重试和错误统计。比如MRAM_ReadBytes返回 0 表示成功,返回 1 表示超时,返回 2 表示 CRC 错误。工业设备最怕的就是“悄悄失败”,有返回值就能在日志里记下来。
7.3 定期自检
设备运行过程中,我每隔 24 小时做一次存储自检:在预留区写一个测试模式,读回来比对,然后擦掉(MRAM 不需要擦,直接覆盖)。这个自检能提前发现存储器的潜在问题,比等用户报故障再查要好得多。
7.4 关于 MRAM 的寿命,别被数字冲昏头
10^14 次这个数字确实很吓人,但要注意它是“每个存储单元”的耐久度,而且是在特定测试条件下的标称值。实际使用中,高温会加速老化,频繁写入同一地址总归不是好事。所以我在做环形日志时,尽量让写入地址均匀分布,不要盯着一个地址反复写。虽然 MRAM 不像 Flash 那样有明确的擦写次数限制,但均匀写入总归是更稳妥的工程习惯。
8. 这套方案适合什么场景,不适合什么场景
MR25H40CDF 加 PIC18F87K22 这套组合,最适合的是中小容量的工业数据存储场景:设备配置参数、校准数据、运行日志、故障快照、掉电现场保存。它的优势是写入快、寿命长、接口简单、宽温可靠。512KB 的容量对于存文本日志和参数来说够用,但如果你要存图像、音频或者大量历史数据,这个容量就捉襟见肘了,那种场景应该考虑更大容量的存储方案。
另外,PIC18F87K22 是 8 位机,处理能力有限,如果你需要跑复杂的文件系统或者网络协议栈,建议换 32 位平台。但如果你只是做数据采集、逻辑控制加存储,这套组合的性价比和可靠性都很能打。我在好几个远程终端项目里用这套方案,现场跑了三年多,没有因为存储问题返修过。
最后分享一个我在调试阶段常用的小工具:写一个简单的串口命令解析器,通过串口发指令来读写 MRAM 的任意地址,比如read 0x10000 64读 64 字节,write 0x10000 01 02 03写几个字节。这个工具在排查数据问题时特别有用,不用反复烧程序,直接在线看存储内容。代码量不大,但能省下大量调试时间。