在工业现场待久了,你就会发现“存储”这个环节很容易被低估。很多仪表、电力终端、运动控制器要保存的不仅仅是配置参数,还有事件记录、曲线数据、掉电现场,这类数据会反复写入,而且掉电之后还必须能原样拿出来分析。这次我在一个计量项目里把采集结果往 MR25H40CDF 里写,主控用的是 PIC18F57Q43,整个过程跑下来,我对“SPI 接口 MRAM + 8 位 MCU”的组合有了很直接的体会。这篇文章就围绕 MR25H40CDF 和 PIC18F57Q43 存储、读取数据的完整流程展开,内容包括器件选型逻辑、硬件连接、SPI 初始化、读写实现、工业环境下的稳定性处理和故障排查,适合做嵌入式开发、工业控制、数据记录相关项目的人参考。
我按实际项目推进的顺序来写,尽量把“为什么这么做”讲清楚。因为很多时候大家不是不知道操作码,而是不知道在不同场景下该怎么选方案、怎么避坑。
1. 方案视角:为什么这次用 MRAM 而不是 EEPROM 或 SPI Flash
1.1 三类存储器的特性差异
工业应用里最常接触的存储芯片无非三类:并口或 SPI 的 EEPROM、SPI NOR Flash、以及 MRAM。EEPROM 和 Flash 大家都很熟,MRAM 相对小众,但它在某些场景下的表现确实非常“顶”。
我在选型表里把三个方向的核心参数拉了一下:
| 对比项 | 普通 SPI EEPROM | SPI NOR Flash | MR25H40CDF(MRAM) |
|---|---|---|---|
| 容量级别 | 常见 1Mbit 以下 | 1Mbit~256Mbit | 4Mbit |
| 写入前是否需要擦除 | 字节写无需擦除,页写内部处理 | 必须先擦除块,再编程 | 不需要擦除,直接写 |
| 擦写寿命 | 常见 10万~100万次 | 10万次左右已经很不错 | 无限次写,规格书里写的是无磨损 |
| 写入时间 | 字节写约 3~5ms | 页编程一般 1ms 级别 | 写入周期极短,实测可在 SPI 连续时钟内完成 |
| 数据保持 | 通常 20 年以上 | 通常 20 年以上 | 数据保持 20 年以上 |
| 掉电数据保持 | 保持 | 保持 | 保持 |
这里面最让我在意的,是“写入前要不要擦除”和“寿命能扛多少写入”这两件事。
之前项目里用过 SPI NOR Flash 做数据记录,最头疼的就是要先维护块擦除状态。记录数据时得先攒够一小块,然后擦除再写,中间一旦掉电,缓冲区索引和 Flash 状态可能不一致,恢复逻辑写起来很麻烦。做 EEPROM 版本时,寿命和容量又都偏紧,特别是要按秒记录事件、存几小时的历史波形时,几 KB 到几百 KB 的 EEPROM 根本不够用。
MR25H40CDF 这个片子的容量是 4Mbit,也就是 512KB。对 8 位 MCU 来说这个空间不算小,存几百条带时间戳的事件记录、几十帧波形数据完全够用。由于 MRAM 写操作不需要先擦除,我可以在任意地址直接改写任意长度数据,省掉了 Flash 的块管理逻辑,整个固件的复杂度降了一档。
1.2 工业应用真正在意的两个指标
工业现场对存储器件的要求,跟在开发板上跑通完全不是一回事。
第一是“连续写入不掉链子”。比如伺服驱动器每 10ms 记录一次电流包络,一天下来就是 860 万次写入。如果用的是寿命 10 万次的 Flash,几个月就报废。MRAM 因为结构上没有“浮置栅极反复隧穿”的损耗机制,理论上写多少遍都不会磨坏,这对高频日志类应用几乎就是量身定做。
第二是“掉电瞬间数据不能丢”。设备遇到突发的电源跌落时,MCU 可能只来得及做一件事。如果存储时序足够快,可以在电压降到阈值的几毫秒窗口里,把关键状态写进去。MRAM 的写时序接近 SRAM 级别,不需要等待几百微秒甚至几毫秒的编程时间,这个窗口能挤出来。实际项目中我就在掉电中断里直接写一个 32 字节的现场信息,测试下来成功率很高。
另外还有一个容易忽略的点:MRAM 和 SRAM 一样支持按字节随机写入。Flash 和 EEPROM 虽然也支持字节写,但一个受限于擦除块,一个受限于速度。MRAM 的逻辑更像是“内存地址 + 非易失保持”,这对上层软件来说非常友好,不用设计复杂的状态机。
2. 硬件连接与电路设计细节
2.1 引脚功能与最小电路
MR25H40CDF 是标准的 8 脚封装,接口是 SPI。具体到引脚功能,我按实际连接顺序说一下:
- CS#(片选):低有效,必须由 MCU GPIO 控制,不能直接接地。因为 SPI 总线上可能还有其他器件,而且每次操作前后要有明确的片选时序。
- SCK(时钟):接 MCU 的 SPI SCK 引脚。
- SI(数据输入):接 MCU 的 SDO 引脚,因为对 MRAM 来说是输入,对 MCU 来说是输出。
- SO(数据输出):接 MCU 的 SDI 引脚。
- WP#(写保护):低有效,如果为低则禁止写状态寄存器。正常使用时建议通过 10kΩ 电阻上拉到 VCC。
- HOLD#(暂停):低有效。拉低后会暂停 SPI 通信,保持输出状态。这个引脚非常关键,正常工作时必须上拉,否则线上干扰把它拉低就会出现“读到一半数据卡住”的诡异现象。
- VCC / VSS:电源和地,VCC 为 3.3V。
最小电路设计时,我在 VCC 和 GND 之间放了一个 0.1μF 陶瓷电容,靠近芯片引脚放置,同时并联一个 1μF 的钽电容做低频滤波。工业板子上电源噪声往往不干净,尤其是旁边有继电器、电机驱动的时候。MR25H40CDF 工作电压范围标称支持 2.4V 到 3.6V,如果电源波动超过这个范围,状态寄存器就会被改写,导致后续写入失败,所以电源退耦不能省。
WP# 和 HOLD# 这两个引脚,我是用 10kΩ 电阻统一上拉到 3.3V。有朋友问我能不能直接用 GPIO 驱动,可以,但当 GPIO 配置成默认输入且内部上拉不够强时,反而有风险。用固定上拉更省心,因为正常项目里没必要动态切换 HOLD#。
2.2 PCB 布局与干扰考虑
PCB 布局上,我习惯让 SPI 四根线尽量短,CS# 和 SCK 不要平行走太远,避免串扰。特别是在加长排线连接外部存储板时,建议 SCK、SI、SO 三根线之间加地线隔离,或者直接走 FFC 排线加屏蔽层。
还有一点是 CS# 的上拉。如果 MCU 复位期间又外部信号把 CS# 拉低了,MRAM 可能进入未知状态。我一般在 CS# 网络上也放一个 10kΩ 上拉,这样 MCU 还没初始化时,存储芯片保持未选中状态。多个 SPI 器件共用总线时,这个上拉更必要,能避免片选竞争。
另外要注意电平匹配。PIC18F57Q43 是 1.8V 到 5.5V 供电的芯片,如果它工作在 3.3V,而 MRAM 也是 3.3V,那就没有任何电平问题。但如果 MCU 工作在 5V,MRAM 在 3.3V,SI 和 SCK 引脚就需要串联电阻或做电平转换,不然会让 MRAM 输入超过数据手册最大额定值。反向的 SO 信号是 MRAM 输出 3.3V,5V MCU 读 3.3V 一般没问题,因为 HIGH 阈值通常低于 3.3V,但为了稳妥,我仍然会在 SO 串联一个 100Ω 电阻,减少振铃。
3. PIC18F57Q43 的 SPI 配置与初始化
3.1 PPS 引脚映射
PIC18F57Q43 属于 PIC18-Q 系列,外设引脚是可重映射的,也就是我们常说的 PPS。SPI 功能要分配到哪些引脚,不是固定死的,需要通过寄存器把外设输出映射到具体端口。
我用的连接方式是:
| 信号 | MCU 引脚方向 | PPS 映射目标 |
|---|---|---|
| SCK | MCU 输出 | SCK1OUT |
| SDO | MCU 输出 | SDO1OUT |
| SDI | MCU 输入 | SDI1IN |
| CS# | MCU GPIO 输出 | 普通 GPIO |
PPS 的好处是布线时可以绕开特殊功能引脚,坏处是初始化时如果漏配了 PPS,SPI 大概率不工作。我排查过几次“SPI 不输出时钟”的问题,最后发现都是 PPS 漏配。所以建议把 PPS 配置代码放在系统初始化最前面,并加上注释,避免后面维护的人误删。
3.2 MSSP 外设配置要点
PIC18F57Q43 的 SPI 外设基于 MSSP 模块。开发阶段我是用 MPLAB Code Configurator 先生成基础工程的,它会自动配置引脚、时钟和外设,再把生成代码嵌到主程序里。
如果完全手动写寄存器,核心配置就是这几项:
- SPI 主机模式,时钟极性 SPO = 0,相位 SPHA = 0,也就是模式 0。MR25H40CDF 手册默认推荐模式 0,当然它也支持模式 3。我统一用模式 0,减少混淆。
- 主时钟分频设为 FOSC/4 或 FOSC/2。PIC18F57Q43 我当时跑 16MHz 系统时钟,FOSC/2 就是 8MHz。MR25H40CDF 支持最高 40MHz 时钟,所以 8MHz 完全在范围内,线长十几厘米都能稳定跑。
- 配置 MSSP1 的收发方向。标准 SPI 是同时移位:发送一个字节的同时会接收到一个字节。所以读操作要先发一个任意字节(通常发 0x00)来产生 SCK 时钟,同时从 SO 取数据。这一点跟很多 MCU 的 SPI 外设行为一致,不明白的话会卡很久。
MCC 生成代码后,我习惯再手动检查一下SPI1CON0、SPI1CON1、SPI1CON2这几个寄存器的实际值。做嵌入式开发最怕的就是“图形界面点了、工具也编译了、到底配置成什么心里没底”。养成读生成代码的习惯,后面定位问题会快很多。
3.3 初始化代码参考
下面这段代码是精简版的初始化逻辑,重点展示思路,直接照搬时请根据你的系统时钟调整分频:
#define MRAM_CS LATBbits.LATB4 #define MRAM_CS_TRIS TRISBbits.TRISB4 #define MRAM_CS_LOW() MRAM_CS_TRIS = 0; MRAM_CS = 0; #define MRAM_CS_HIGH() MRAM_CS_TRIS = 0; MRAM_CS = 1; void SPI1_Init(void) { // 这里假设 PPS 和时钟已经在引脚层配置好 SPI1CON0 = 0x10; // SPI 使能,主机模式,模式0相关位 SPI1CON1 = 0x20; // 时钟分频,具体值以 MCC 生成为准 SPI1CON2 = 0x00; // 片选相关扩展控制不启用 MRAM_CS_HIGH(); } uint8_t SPI1_Transfer(uint8_t dat) { SPI1TXB = dat; while (!SPI1RXIF); return SPI1RXB; }这段代码追求的是把最核心的时序跑通,真正的工业级代码还需要加参数校验、超时处理和总线状态检查。比如 SPI 发送阻塞在标志位上,一旦 SPI 外设异常,程序就会卡死。实际工程里我一般会给发送循环加一个软件超时,超时后复位 SPI 外设并上报错误。
4. 从写使能到读写数据的核心流程
4.1 操作码和地址格式
MR25H40CDF 的指令集非常简洁,我这里列出常用的几条:
| 指令名 | 操作码 | 功能 |
|---|---|---|
| WREN | 0x06 | 写使能,将状态寄存器 WEL 置 1 |
| WRDI | 0x04 | 写禁止,将 WEL 清 0 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 从指定地址开始读数据 |
| WRITE | 0x02 | 从指定地址开始写数据 |
地址是 24 位长度的 SPI 地址。MR25H40CDF 容量 4Mbit,即 524288 字节,地址范围 0x00000 ~ 0x7FFFF,所以 24 位地址里高 7 位必须是 0。
有个细节:很多 SPI Flash 或 EEPROM 的地址是 16 位或 24 位,操作码也差不多。写代码时最容易犯的错误就是把地址左移右移搞混。我固定按“地址先发高字节,再发中字节,最后发低字节”的顺序写,且把地址变量明确声明成uint32_t,避免编译器把移位结果截断。
4.2 MRAM 写入不需要擦除的实际影响
传统 Flash 写数据前要先擦除,所以工程师养成了一种惯性:写地址前,先想象这个地址是不是 0xFF。MRAM 完全不需要。直接在任意地址写入任意数据,也不存在“写 1 必须先擦除”这种限制。你写 0x00,它就存 0x00;下次写 0xFF,它就直接变 0xFF。
这一点对代码架构影响很大。我在项目里可以像操作 SRAM 一样维护一个环形缓冲区,索引落在哪个扇区就写哪个扇区,完全不关心擦除对齐。数据记录模块不再需要“存储层”,只需要“指针 + 校验”。如果哪天从 MRAM 换回 SPI NOR Flash,这套代码不能直接复用,迁移时要有心理准备。
另外,MRAM 的写入时间非常短。传统 EEPROM 需要等内部写周期完成,有些驱动里会轮询WIP位。MR25 系列则基本是传输完最后一个字节后,数据立即变成新值。如果想更稳妥,可以发一个 RDSR 确认状态寄存器值,但在我的高速日志应用里,实测不额外等待也没有问题。工业项目如果追求稳定,建议在连续写多个字节之后,主动检查状态寄存器并做一次回读比对。
4.3 读写例程实现
下面给出我用 XC8 编写的精简读写函数,可以直接放入工程验证。
void MRAM_WriteEnable(void) { MRAM_CS_LOW(); SPI1_Transfer(0x06); // WREN MRAM_CS_HIGH(); } void MRAM_WriteByte(uint32_t addr, uint8_t dat) { MRAM_WriteEnable(); MRAM_CS_LOW(); SPI1_Transfer(0x02); // WRITE SPI1_Transfer((addr >> 16) & 0xFF); SPI1_Transfer((addr >> 8) & 0xFF); SPI1_Transfer(addr & 0xFF); SPI1_Transfer(dat); MRAM_CS_HIGH(); } uint8_t MRAM_ReadByte(uint32_t addr) { uint8_t dat; MRAM_CS_LOW(); SPI1_Transfer(0x03); // READ SPI1_Transfer((addr >> 16) & 0xFF); SPI1_Transfer((addr >> 8) & 0xFF); SPI1_Transfer(addr & 0xFF); dat = SPI1_Transfer(0x00); MRAM_CS_HIGH(); return dat; } void MRAM_ReadBuffer(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i; MRAM_CS_LOW(); SPI1_Transfer(0x03); SPI1_Transfer((addr >> 16) & 0xFF); SPI1_Transfer((addr >> 8) & 0xFF); SPI1_Transfer(addr & 0xFF); for (i = 0; i < len; i++) { buf[i] = SPI1_Transfer(0x00); } MRAM_CS_HIGH(); }读操作可以一次连续读很多字节,不需要每读一个字节就失能再使能 CS#,这跟普通 SPI NOR Flash 类似。写操作也可以连续写:CS# 拉低、发 WRITE 指令和起始地址,然后连续发送多个数据字节,MRAM 会按地址递增逐字节写入。MRAM 没有页缓冲限制,连续写长度可以一直延续到地址结尾,但实际应用中我一般限制单次写入不超过一个完整记录块,比如 256 字节,这样掉电恢复时数据边界更容易判断。
注意,写数据之前一定要发 WREN。MR25H40CDF 上电后默认写使能是关闭的,如果忘记执行0x06,发 WRITE 指令后数据根本不会写进去。在调试时,这个现象非常坑,因为写指令看起来是正常的,CS# 时序也对,SO 也没有报错,但回读就是旧值。这是我第一次调 MRAM 时踩过的坑,后来把所有写操作都封装成“内部先发 WREN 再发 WRITE”的公共函数,才彻底避免。
5. 工业环境中的稳定性与故障排查
5.1 常见问题速查表
把实际调试过程中遇到的问题整理成表格,方便快速对照:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 写之后回读,数据是旧值 | 没有发 WREN 指令 | 每次写操作前调用MRAM_WriteEnable() |
| 读固定地址出现全 0xFF | 地址逻辑错误,或 CS# 时序不对 | 检查 24 位地址发送顺序,用示波器看 CS# 下降和 SCK 首沿位置 |
| 数据每隔一段区域就错位 | 三字节地址被当成两字节发送 | 严格按(addr >> 16)、(addr >> 8)、addr拆字节 |
| 读数据时偶发卡死 | SPI 等待标志位未及时清除 | 给收发改加超时;检查 SDI 引脚是否被复用 |
| 一上电就写失败,运行一段时间又正常 | 电源不稳定或 MCU 复位后 CS# 被外部拉低 | 提高 CS# 上拉,增强电源退耦 |
| 写入速度慢,性能不对劲 | SPI 分频配置成了最大分频比 | 在 MRAM 允许范围内提高 SCK 频率 |
| 部分区域数据随机翻转 | HOLD# 被干扰拉低 | 必须把 HOLD# 上拉到 VCC,不要浮空 |
5.2 掉电保护、CRC 校验和看门狗
工业设备很少处在“正常断电”的理想环境。继电器弹跳、电网晃电、电机反电动势,随时都会让 MCU 电压跌落。为了在掉电时还能把最后的数据写进 MRAM,我在软件上做了三件事。
第一是电源监测。利用 PIC18F57Q43 的模拟比较器或外部电源监控芯片,在电压跌到 3.0V 时触发中断。中断里只做两件事:关闭中断源、把 32 字节现场信息写入 MRAM 固定地址。因为 MRAM 写速度足够快,这 32 字节在这个窗口内能写进去。如果是普通 EEPROM,每个字节 3ms,32 字节需要 96ms,电源早就没了。
第二是 CRC 校验。每次写入数据块时,在这个块的最后 2 字节附加一个 CRC16 校验值。读取时先计算数据区 CRC,再和存储的 CRC 比较。MRAM 本身可靠性很高,但系统总线、电源噪声仍可能在 MCU 和 MRAM 之间的传输途中引入了错误。CRC 能保证软件层面发现异常。
第三是看门狗超时。SPI 在工业环境下偶尔会被干扰拉死。我加了独立的看门狗定时器,如果 SPI 读写循环超过 10ms,就强制复位 MCU。复位后首先重新初始化 SPI 和 PPS,然后再继续数据记录。因为 MRAM 的内容在掉电或复位后保持不变,恢复逻辑只需要根据起始标记和 CRC 判断最后一条有效记录在哪。
5.3 实测中的几个坑
第一个坑,是 WP# 引脚没有上拉,结果状态寄存器被意外修改。这导致后续 WRITE 不生效。因为 MRAM 的写保护机制分为 WP# 引脚硬保护和状态寄存器软保护两层,两者都会阻止写操作。我用万用表量 WP# 发现一直是低电平,赶紧把上拉电阻加上就好了。以后我拿到带 WP# 或 HOLD# 的存储芯片,第一步就是确认两个引脚的电平状态,再动手调 SPI。
第二个坑,是在同一个 SPI 总线上挂了另一个从设备。MCU 的一个 MSSP 外设同时接了 Flash 和 MRAM,两个芯片的 CS# 由不同 GPIO 控制。有次我操作 MRAM 时忘记把 Flash 的 CS# 拉高,Flash 输出端跟 MRAM 的 SO 信号在总线上打架,读出来就是乱码。最后我把空闲设备的 CS# 强制拉高的逻辑统一放到了 SPI 操作开始前的初始化函数里,才算解决了。
第三个坑,是示波器测量时发现 CS# 下降沿到 SCK 第一个上升沿的时间太短。很多 SPI 从机对 CS# 建立时间有要求,虽然没有严格到哪微秒级别,但为了保险,我会在 CS# 拉低之后插入几个空指令等待,提供大约 1μs 的余量。写驱动时也可以利用 MSSP 的SPI1TCON来调整 CS# 到时钟之间的延迟。这种细节在开发板上跑不出来,但到批量产线就会暴露。
6. 压力测试与数据记录应用建议
6.1 循环读写 10 万次测试
代码调通后,我做了长时间的循环读写压力测试。测试方法是:在 0x00000 到 0x0001FFFF 区域内,按 256 字节为一个块,依次写入递增序列,然后回读校验。写完一遍后清空再用 0xA5 填充,再做反转测试。总共连续运行了几天,大概重复了十几万次块写读。结果没有出现一次数据不一致,MRAM 也没有表现出性能退化迹象。
当然,十万次对 MRAM 来说只是热身,因为它理论上没有寿命上限。不过压力测试依然有必要,它能暴露的是 SPI 时序和电源问题。我遇到过一种情况:连续写 128 字节时偶尔第 100 个字节以后数据错位,排查后确认是 SPI 分频在高速下加上长排线导致信号质量差,把时钟从 16MHz 降回 8MHz 后稳定运行。所以,MRAM 本身强悍,不代表整条链路就必然稳定。
6.2 在数据记录架构上的应用思考
对于事件记录、波形存储、在线参数表这类应用,MRAM 的“按字节直写”特性让架构可以做得非常干净。我建议不要照搬 Flash 的块管理逻辑,尽量把 MRAM 当 SRAM 用,只在数据记录块头部维护一个 8 字节元信息:块序号、写入时间、长度、CRC。每次写入新记录,就将块序号递增,写入对应地址。读取时扫描元信息,就能快速定位最新记录和剩余空间。
这样做的另一个好处是,系统升级固件时,不需要担心擦写对存储寿命的影响。MRAM 可以在固件运行中直接更新引导参数,还能在工厂测试阶段反复写入校准数据,不需要预留“恢复出厂设置”的磨损额度。这是很多工程师换用 MRAM 之后最明显的感觉:很多代码能写得直白多了。
我个人在实际操作中还有一个体会:MR25H40CDF 和 PIC18F57Q43 这套组合非常适合做“低功耗、高可靠、中等容量”的工业数据记录节点。项目初期的选型犹豫主要集中在成本上,MRAM 单价比普通 SPI Flash 高一些,但对比整个系统因数据丢失造成的维护成本和停机损失,这个差价完全可以接受。如果你手上正好有掉电保存需求,又不想维护 Flash 坏块和擦除状态机,MRAM 这个方向非常值得试一把。