1. 项目缘起与方案选型
1.1 为什么要在工业场景里折腾 MRAM 和 PIC
工业现场的数据记录有个很尴尬的处境:用 EEPROM 吧,写入速度慢得让人抓狂,擦写寿命也就百万次级别,高频记录的场景下没几个月就写废了;用 SRAM 加后备电池吧,电池在高温高湿环境下两三年就鼓包漏液,维护成本高得离谱;用 NOR Flash 吧,写之前得擦除整个扇区,掉电瞬间还容易丢数据,关键参数丢了整台设备就得返厂。我前几年做一个电力监测终端,现场环境温度夏天能到 70 度,电池方案直接放弃,Flash 方案又因为掉电丢参数被客户投诉过好几次。
后来接触到MRAM这类磁性随机存储器,才算找到了一个相对理想的平衡点。它的原理是用磁性隧道结的磁化方向来存储数据,不需要电荷保持,所以掉电不丢、写入不需要擦除、速度快、寿命近乎无限。这次项目用的MR25H40CDF就是一颗 4Mbit 的 SPI MRAM,而主控选了PIC18F47K40,一颗 8 位单片机,带硬件 MSSP 模块可以跑 SPI,工业温度范围也够宽。两者搭配,正好覆盖工业嵌入式里“频繁记录、掉电不丢、长期免维护”这类需求。
这篇文章我会把整个从选型、硬件连接、SPI 时序配置、读写驱动、掉电保护到现场踩坑的完整过程拆开讲。适合正在做工业数据记录、参数存储、黑匣子类功能的嵌入式工程师,也适合刚接触 MRAM 想找个落地案例的朋友。代码基于 MCC 生成的 HAL 风格,但思路对裸机寄存器操作同样适用。
1.2 MR25H40CDF 到底强在哪,和常见方案怎么比
先把这颗芯片的关键参数摆出来,方便你判断是否适合自己的场景:
| 特性 | MR25H40CDF | 典型 EEPROM | 典型 NOR Flash | 带电池 SRAM |
|---|---|---|---|---|
| 容量 | 4Mbit (512KB) | 通常 1Mbit 以内 | 4Mbit~64Mbit | 视 SRAM 而定 |
| 接口 | SPI | I2C/SPI | SPI | 并口/SPI |
| 写入前是否需擦除 | 不需要 | 不需要 | 需要整扇区擦除 | 不需要 |
| 字节写入时间 | 无内部写周期 | 5ms 级 | 扇区擦除几十 ms | 纳秒级 |
| 擦写寿命 | 近乎无限 | 约 100 万次 | 约 10 万次 | 无限 |
| 掉电保持 | 是,无需电池 | 是 | 是 | 否,需电池 |
| 工作温度 | 工业级 | 视型号 | 视型号 | 电池受限 |
从表里能看出来,MRAM 最大的价值就是**“SRAM 的速度 + Flash 的非易失 + 无限寿命”**。它没有内部写周期,意味着你写完一个字节,下一个 SPI 事务可以立刻开始,不用像 EEPROM 那样轮询等待“写完成”。这一点在需要连续记录传感器数据的场景里非常关键,比如每 1ms 记录一次电流采样值,EEPROM 根本扛不住,MRAM 却可以轻松应对。
注意:MRAM 虽然写入不需要擦除,但它是按字节可写的,没有扇区概念,所以软件上不需要做磨损均衡。这一点和 Flash 的思维习惯完全不同,别把 Flash 那套搬过来。
1.3 PIC18F47K40 作为主控的匹配度分析
选 PIC18F47K40 不是随便挑的。它有几个点正好契合这个项目:第一,内置 MSSP 模块支持 SPI 主模式,最高时钟可以到系统时钟的四分之一,跑 64MHz 系统时钟时 SPI 能到 16MHz,而 MR25H40CDF 的 SPI 最高支持 40MHz,完全喂得饱;第二,它有 128KB Flash 和 3.5KB RAM,对于记录类应用够用;第三,工业级温度范围 -40 到 125 度,和 MRAM 匹配;第四,封装小巧,QFN40 适合紧凑的工业板卡。
这里有个细节值得说:PIC18F47K40 的 MSSP 在 SPI 主模式下,片选信号 CS 是需要软件手动控制的,硬件不会自动拉低拉高。所以驱动里必须自己管理 CS 引脚,每次事务前拉低、事务后拉高。这一点和某些带硬件片选的 MCU 不同,写代码时要注意。
2. 硬件连接与 SPI 时序配置
2.1 引脚连接与 PCB 布局要点
MR25H40CDF 是 8 脚封装,SPI 接口,引脚定义很标准:CS、SO、SI、SCK、VCC、GND,另外还有 WP 和 HOLD 两个控制脚。实际连接时:
- CS 接 PIC 的任意 GPIO,我选的是 RC0,方便软件控制
- SCK 接 MSSP 的 SCK 引脚
- SI 接 MSSP 的 SDO
- SO 接 MSSP 的 SDI
- WP 和 HOLD 直接上拉到 VCC,除非你需要硬件写保护功能
PCB 布局上,SPI 走线尽量短,SCK 和 SI/SO 之间不要平行走长线,否则高速时容易串扰。我在第一版板子上把 SCK 走线拉到了 8cm,结果 16MHz 下读数据偶尔出错,后来缩短到 3cm 以内就稳了。另外 CS 走线要远离 SCK,因为 CS 是异步信号,容易被时钟耦合出毛刺导致误触发。
提示:MRAM 的 WP 引脚如果悬空,内部有弱上拉,但工业环境建议外部加 10K 上拉到 VCC,避免干扰导致误写保护。
2.2 SPI 模式选择与时钟计算
MR25H40CDF 支持 SPI 模式 0 和模式 3,也就是 CPOL=0/CPHA=0 或 CPOL=1/CPHA=1。我选的是模式 0,因为 PIC 的 MSSP 默认配置就是模式 0,省事。模式 0 的含义是:时钟空闲为低电平,数据在时钟上升沿采样。这个要和从机手册对齐,选错了读出来全是 0xFF 或 0x00。
时钟频率的计算:PIC18F47K40 系统时钟我配置为 64MHz,MSSP 的 SPI 时钟由 SSPADD 寄存器分频。公式是:
Fosc = 系统时钟 / (4 * (SSPADD + 1))要得到 16MHz 的 SPI 时钟,SSPADD 应该设为 0,因为 64MHz / 4 = 16MHz。如果系统时钟是 16MHz,那 SSPADD=0 时 SPI 只有 4MHz,想要更高就得提高系统时钟。实测 16MHz 下 MRAM 读写完全稳定,再高没试过,因为 PIC 的 SPI 上限就在这。
| 系统时钟 | SSPADD | SPI 时钟 | 是否可用 |
|---|---|---|---|
| 64MHz | 0 | 16MHz | 稳定 |
| 64MHz | 1 | 8MHz | 稳定 |
| 16MHz | 0 | 4MHz | 稳定但慢 |
| 64MHz | 3 | 4MHz | 稳定 |
2.3 MSSP 初始化代码与关键寄存器
初始化 MSSP 的代码不长,但每个寄存器都有讲究:
void SPI1_Initialize(void) { // 关闭 MSSP 再配置 SSP1CON1 = 0x00; // 时钟极性:空闲低电平 SSP1STATbits.CKE = 1; // 采样在时钟中间 SSP1STATbits.SMP = 0; // SPI 主模式,时钟 = Fosc/4 SSP1CON1bits.SSPM = 0b0000; // 时钟极性低 SSP1CON1bits.CKP = 0; // 使能 MSSP SSP1CON1bits.SSPEN = 1; // 片选引脚配置为输出 TRISCbits.TRISC0 = 0; LATCbits.LATC0 = 1; }这里SSP1STATbits.CKE = 1表示在时钟从活跃到空闲时传输数据,配合CKP=0就是模式 0。SMP=0表示在数据输出时间的中间采样,这样采样点最稳。SSPM=0b0000是 Fosc/4 分频。这些位如果配错,SPI 波形就不对,用逻辑分析仪一抓就能看出来。
注意:PIC18F47K40 有两个 MSSP 模块,SPI1 和 SPI2,别搞混了。我用的是 SPI1,对应 RC0/RC3/RC4/RC5 这组引脚。
3. 读写驱动实现与核心操作
3.1 MRAM 指令集与读写时序
MR25H40CDF 的指令集很简洁,常用的就几条:
| 指令 | 操作码 | 功能 |
|---|---|---|
| WREN | 0x06 | 写使能 |
| WRDI | 0x04 | 写禁止 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 读数据 |
| WRITE | 0x02 | 写数据 |
读操作的时序是:拉低 CS,发送 0x03,发送 24 位地址(因为 512KB 需要 19 位地址,但芯片用 24 位地址格式),然后连续读出数据,最后拉高 CS。写操作类似,但写之前必须先发 WREN 指令,否则写不进去。这一点和 EEPROM 一样,是防误写的机制。
地址是 24 位的,高 5 位保留,实际有效的是低 19 位。发送时按高字节、中字节、低字节的顺序。比如地址 0x000123,就发 0x00、0x01、0x23。
3.2 单字节读写函数实现
先看写使能和写禁止:
void MRAM_WriteEnable(void) { CS_LOW(); SPI1_ExchangeByte(0x06); CS_HIGH(); } void MRAM_WriteDisable(void) { CS_LOW(); SPI1_ExchangeByte(0x04); CS_HIGH(); }SPI1_ExchangeByte是阻塞式收发一个字节,MCC 生成的库里有,也可以自己写:
uint8_t SPI1_ExchangeByte(uint8_t data) { SSP1BUF = data; while(!SSP1STATbits.BF); return SSP1BUF; }写字节函数:
void MRAM_WriteByte(uint32_t addr, uint8_t data) { MRAM_WriteEnable(); CS_LOW(); SPI1_ExchangeByte(0x02); SPI1_ExchangeByte((addr >> 16) & 0xFF); SPI1_ExchangeByte((addr >> 8) & 0xFF); SPI1_ExchangeByte(addr & 0xFF); SPI1_ExchangeByte(data); CS_HIGH(); }读字节函数:
uint8_t MRAM_ReadByte(uint32_t addr) { uint8_t data; CS_LOW(); SPI1_ExchangeByte(0x03); SPI1_ExchangeByte((addr >> 16) & 0xFF); SPI1_ExchangeByte((addr >> 8) & 0xFF); SPI1_ExchangeByte(addr & 0xFF); data = SPI1_ExchangeByte(0xFF); CS_HIGH(); return data; }读的时候发 0xFF 是为了产生时钟,从机在这期间把数据推出来。这个 0xFF 是“哑数据”,内容无所谓,只要时钟在跑就行。
3.3 页写与连续读的优化
单字节写虽然简单,但每次都要发 WREN 和完整地址,开销大。MRAM 支持连续写,也就是发一次地址后连续写多个字节,地址自动递增。这个特性在批量记录时非常有用:
void MRAM_WritePage(uint32_t addr, uint8_t *buf, uint16_t len) { MRAM_WriteEnable(); CS_LOW(); SPI1_ExchangeByte(0x02); SPI1_ExchangeByte((addr >> 16) & 0xFF); SPI1_ExchangeByte((addr >> 8) & 0xFF); SPI1_ExchangeByte(addr & 0xFF); for(uint16_t i = 0; i < len; i++) { SPI1_ExchangeByte(buf[i]); } CS_HIGH(); }连续读同理,发完地址后连续读 len 个字节即可。实测连续写 256 字节比单字节写 256 次快了一个数量级,因为省掉了 256 次 WREN 和地址发送。
提示:MRAM 没有页边界限制,可以跨页连续写,地址到 0x7FFFF 后回绕到 0x00000。这一点比 Flash 友好太多,Flash 跨页写会回绕到页首覆盖数据。
4. 掉电保护与数据完整性设计
4.1 为什么 MRAM 还需要掉电保护
有人会问,MRAM 掉电不丢数据,那还需要掉电保护吗?答案是需要的,但保护的对象不是 MRAM 本身,而是写入过程的原子性。假设你正在写一条 16 字节的记录,写到第 8 字节时系统掉电了,MRAM 里就留下了一条半截记录。下次上电读出来,这条记录是无效的,如果不做校验,程序可能解析出错误数据。
所以掉电保护的核心是:保证每条记录要么完整写入,要么完全没写。常见做法是加一个“有效标志”字节,写数据前先清标志,写完数据再置标志。上电时检查标志,只有标志有效才认为记录完整。
4.2 双缓冲与序列号方案
更稳妥的做法是双缓冲加序列号。把存储区分成两个区域 A 和 B,交替写入。每条记录带一个递增的序列号,上电时比较 A 和 B 的序列号,取大的那个作为最新数据。这样即使写入过程中掉电,另一个区域的数据还是完整的。
具体实现:
typedef struct { uint32_t seq; uint8_t valid; uint8_t data[16]; uint16_t crc; } Record_t;写入时先算 CRC,写到非当前区域,写完更新序列号,再切换当前区域指针。上电时读两个区域,校验 CRC 和 valid,选序列号大的。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单区域加标志 | 简单 | 掉电瞬间可能丢当前记录 | 记录频率低 |
| 双缓冲加序列号 | 掉电不丢,可恢复 | 占用双倍空间 | 关键数据 |
| 日志式追加 | 保留历史 | 需要垃圾回收 | 高频记录 |
4.3 CRC 校验的选型与计算
CRC 我选的是 CRC-16/CCITT,多项式 0x1021,初值 0xFFFF。这个在嵌入式里很常见,查表法和逐位法都行。逐位法代码小,适合 Flash 紧张的场合:
uint16_t CRC16_Calc(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for(uint16_t i = 0; i < len; i++) { crc ^= (uint16_t)buf[i] << 8; for(uint8_t j = 0; j < 8; j++) { if(crc & 0x8000) crc = (crc << 1) ^ 0x1021; else crc <<= 1; } } return crc; }实测 16 字节数据算一次 CRC 大概几十微秒,对记录频率没影响。如果记录频率很高,可以换成查表法,速度快 5 到 10 倍,代价是占用 512 字节 Flash 存表。
5. 实操踩坑与问题排查
5.1 读出来全是 0xFF 或 0x00 怎么查
这是最常见的现象,原因通常有三个:第一,CS 没拉低或者拉低时机不对,从机根本没被选中;第二,SPI 模式配错,CPOL/CPHA 和从机不匹配;第三,SCK 没输出,MSSP 没使能或者引脚配置错了。
排查顺序:先用逻辑分析仪抓 CS、SCK、SI、SO 四根线,看 CS 有没有拉低,SCK 有没有波形,SI 上有没有指令和地址。如果 CS 正常但 SCK 没波形,检查 SSPEN 位和 TRIS 配置。如果 SCK 有波形但 SO 一直是高,检查 SPI 模式,试着把 CPOL 和 CPHA 都翻转一下。
我遇到过一次,读出来全是 0xFF,查了半天发现是 CS 引脚被配置成了模拟功能,数字输出被禁用了。PIC18F47K40 的引脚有 ANSEL 寄存器,默认有些引脚是模拟模式,必须手动设为数字。这个坑很隐蔽,因为编译不报错,波形上 CS 看起来也动了,但电平不对。
5.2 写入不生效的几种可能
写入不生效,读回来还是旧数据,常见原因:第一,忘了发 WREN,MRAM 默认是写保护的;第二,WP 引脚被拉低了,硬件写保护生效;第三,地址算错了,写到了别的位置;第四,CS 拉高的时机太早,最后一个字节还没移完就结束了事务。
WREN 这个最容易忘,因为读操作不需要 WREN,写操作需要,代码里如果读函数抄过来改的,很容易漏掉。我的做法是在写函数里强制先调 WREN,不管调用者有没有调过,多调一次 WREN 没有副作用。
注意:WREN 之后如果 CS 拉高,写使能会自动清除。所以 WREN 和实际写操作之间不能插入其他 SPI 事务,否则 WREN 失效。
5.3 高速 SPI 下的数据错误
16MHz 下如果出现偶发数据错误,先检查 PCB 走线和端接。SPI 在高速下对走线阻抗敏感,如果走线长且没有端接电阻,信号反射会导致采样错误。可以在 SCK 和 SI 上串 22 到 33 欧姆的电阻,抑制反射。另外 CS 拉低到第一个 SCK 沿之间要留足够的时间,MRAM 要求至少 5ns,实际留 100ns 以上比较稳。
如果降速到 8MHz 就正常,那基本可以确定是信号完整性问题,不是代码问题。这时候要么优化 PCB,要么降速使用。工业场景下稳定性优先,降速到 8MHz 完全够用,没必要为了跑满 16MHz 牺牲可靠性。
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 读全 0xFF | CS 未选中/模式错 | 逻辑分析仪抓波形 | 检查 CS 和 CPOL/CPHA |
| 读全 0x00 | SO 未连接/从机未供电 | 万用表测电压 | 检查供电和连线 |
| 写入无效 | 缺 WREN/WP 拉低 | 读状态寄存器 | 补 WREN,WP 上拉 |
| 偶发错误 | 信号完整性 | 降速测试 | 串阻/缩短走线 |
| 地址错乱 | 地址字节序错 | 对照手册 | 高字节在前 |
5.4 上电初始化的顺序问题
上电时 MRAM 和 MCU 的供电时序如果不一致,可能出现 MCU 先启动、MRAM 还没就绪的情况。虽然 MRAM 上电很快,但保险起见,初始化 SPI 后先读一次状态寄存器,确认能通信再开始读写数据。如果读不到,延时 10ms 再试,最多重试 3 次。
另外,PIC 的配置位里如果有看门狗,初始化时间太长可能被复位。我的做法是在初始化 MRAM 前先喂一次狗,初始化完成后再正常喂。这个细节在低功耗应用里尤其重要,因为低功耗模式下唤醒后外设就绪时间更长。
6. 性能实测与场景延展
6.1 实测读写速度数据
我用 16MHz SPI 实测了一组数据:
| 操作 | 耗时 | 说明 |
|---|---|---|
| 单字节写 | 约 3.5us | 含 WREN 和地址 |
| 单字节读 | 约 2.8us | 含地址 |
| 256 字节连续写 | 约 140us | 平均 0.55us/字节 |
| 256 字节连续读 | 约 135us | 平均 0.53us/字节 |
| CRC16 计算 16 字节 | 约 40us | 逐位法 |
连续写的速度比单字节写快了 6 倍多,所以批量记录一定要用连续写。按 256 字节 140us 算,理论上每秒能写 1.8MB,当然实际受限于 MCU 处理和其他任务,但应付工业记录绰绰有余。
6.2 工业数据记录器的完整思路
基于这套方案,可以做一个完整的工业数据记录器:定时器每 10ms 触发一次采样,采样值存入 RAM 缓冲区,缓冲区满 256 字节后连续写入 MRAM。每条记录带时间戳和 CRC,双缓冲交替写。上电时扫描两个缓冲区,找到最新有效记录,恢复现场。这样即使频繁掉电,数据也不会丢,而且 MRAM 寿命无限,不用担心写坏。
这个思路可以扩展到很多场景:电表参数存储、PLC 配方保存、医疗设备日志、车载黑匣子。核心都是“频繁写、掉电不丢、长期免维护”,正好是 MRAM 的强项。
6.3 从 MRAM 到其他非易失方案的迁移考虑
如果项目对成本敏感,MRAM 单价比 EEPROM 高不少,可以考虑混合方案:关键参数用 MRAM,历史日志用 Flash。或者用 FRAM,原理类似但接口和容量不同。迁移时主要改驱动层,上层的数据结构和 CRC 逻辑可以复用。我个人的经验是,先把驱动抽象成统一的nv_read和nv_write接口,底层换芯片时只改驱动,上层不动,这样迁移成本最低。
最后分享一个我在实际项目里养成的习惯:每次写 MRAM 后,立刻读回来比对一次。虽然多花一倍时间,但能第一时间发现写入失败,避免错误数据被后续逻辑当成有效值。这个习惯帮我抓到过好几次硬件接触不良的问题,比等到现场出故障再排查省事得多。