做嵌入式这几年,存储选型是个绕不开的活儿。早期很多项目无非就是 24C02 之类的 EEPROM,容量不够就换 25 系列 SPI Flash,但一旦涉及到工业现场,“频繁写入”“快速保存”“断电不能丢”这三个条件凑到一起,传统方案的短板就会暴露出来。我之前在一个工业数据记录终端项目里用过 MR25H40CDF 和 PIC18F97J60 的组合,一个是很典型的 8 位以太网工业单片机,一个是串行 MRAM 存储芯片,配合下来效果出乎意料地稳。这篇文章把整体思路、硬件接线、读写实现和调试踩坑都整理出来,给正在做同类选型的嵌入式工程师一个参考。全程用的是实际工程里跑过的代码和流程,可以直接照着改。
1. 为什么偏偏是 MR25H40CDF 配 PIC18F97J60
1.1 工业存储的三大硬指标
先说我个人的选型标准。工业现场的内存存储需求,跟消费电子产品不太一样,总结下来就是三个字:快、稳、长。
第一是快。这里的“快”不是指大带宽吞吐,而是“任意时刻写一个字节都不会让系统等太久”。你用 EEPROM,写一个字节要等 5ms 到 10ms 的写周期时间,写多字节还要一页一页地等;项目里如果有中断或实时任务在跑,这 10ms 会变得非常尴尬。第二是稳,稳定性不只是数据保持十年,而是写入过程中不怕掉电。Flash 写一半掉电可能丢数据,EEPROM 虽然写原子性好一些,但页写时也有半写状态。第三是长,指擦写寿命。运行日志、计费记录、校准参数这些数据,频繁更新,一天写几百次很正常,普通 EEPROM 标称 100 万次擦写寿命,看着够用,实际上如果每次都写整个配置块,很容易三五年就到极限。
MR25H40CDF 这类串行 MRAM 正好把这三个指标全占了。它是磁阻式随机存储器,本质上是 RAM 的特性,但数据掉电不丢。写一个字节不用擦除、不用等周期,SPI 传完即写完;擦写寿命的标称值在 10^14 次级别,工程上可以当作无限寿命;工作温度范围普遍做到 -40°C 到 +85°C,在工业现场非常能打。
我做过的那个项目里,MR25H40CDF 承担的是三百多条运行参数的频繁保存和近万条事件日志的循环记录任务。如果用 EEPROM,光磨损均衡就得写不少代码;用 SPI NOR Flash,还得考虑扇区擦除的耗时和坏块管理。换 MRAM 之后,驱动复杂度直接降了一个量级,逻辑上就当一块“掉电不丢失的 SRAM”来用。
1.2 PIC18F97J60 在组合里的角色
PIC18F97J60 是 Microchip 一款集成以太网 MAC 和 PHY 的单片机,不用外挂以太网控制器芯片,这对于小尺寸工控板很友好。它本身是增强型 8 位内核,带 64KB 程序 Flash 和 3.8KB 左右的 RAM,内置多个串行通信模块,其中 MSSP 模块可配成 SPI 主模式,直接驱动串行 MRAM。
选择它的另一个理由是生态成熟。工业控制领域用了很多年的器件,资料文档、参考设计、TCP/IP 协议栈例程非常齐全。配合 MR25H40CDF 时,最舒服的一点是两者都吃 3.3V 供电,IO 电平直接兼容,不用做任何电平转换,硬件上少了一堆麻烦。
在项目里的角色划分很明确:PIC18F97J60 负责采集传感器数据、跑网络协议栈、跟触摸屏或上位机通信;MR25H40CDF 负责把采集到的关键数据、设备配置、故障事件落盘。两者各干各的,一个管“脑子”,一个管“记性”,配合起来不需要引入额外的存储管理芯片。
2. 硬件连接:就这么几根线,但每一根都不能马虎
2.1 MR25H40CDF 引脚认知
MR25H40CDF 是 4Mbit(512K×8)的串行 MRAM,容量说大不大,说小不小,存配置和日志完全够用。它的封装是 SOP-8 之类的 8 脚小封装,引脚定义跟常见的 SPI 存储器风格一致:CS# 片选、SCK 时钟、SI 数据输入、SO 数据输出、WP# 写保护、HOLD# 保持、VDD 和 VSS。
其中有两个引脚需要特别留意,就是 WP# 和 HOLD#。很多工程师第一次用串行 MRAM 时容易把这两个脚悬空,结果调试时出现各种诡异问题。WP# 拉低会禁止写操作,HOLD# 拉低会暂停通信且让输出脚变为高阻。比较好的做法是正常应用下把这两个引脚直接接到 VDD,或者软件里不做特殊控制就都上拉到 3.3V。
还需要注意它的地址是 3 字节格式,也就是 24 位地址,对应 512K 字节空间。调用读写函数时地址参数一定要用 32 位无符号整数,否则高字节被截掉,只能访问到低 64K,而且越界写也不会报错,但数据会回卷覆盖前面的内容。
2.2 PIC18F97J60 接线要点
硬件连线方面,PIC18F97J60 的 SPI 模块派出 SCK、SDO、SDI 三根线,再随便拿一个 GPIO 做片选 CS#。典型的连接关系是下面这样:
| MR25H40CDF 引脚 | PIC18F97J60 引脚 | 说明 |
|---|---|---|
| CS# | GPIO(任意数字输出) | 片选,低电平有效,平时拉高 |
| SCK | SCK1 或 SCK2 | SPI 时钟 |
| SI | SDO1/SDO2 | 主机输出、从机输入 |
| SO | SDI1/SDI2 | 从机输出、主机输入 |
| WP# | 3.3V | 禁用写保护 |
| HOLD# | 3.3V | 禁用保持功能 |
| VDD | 3.3V | 电源 |
| VSS | GND | 地 |
需要补充的是 PIC18F97J60 有多组 SPI 引脚可以映射,不同封装和不同引脚功能复用表有差异。接线前必须查一下具体数据手册的引脚说明,确认你用的那组引脚没有被模拟输入或者 JTAG 功能占用。我自己的习惯是优先选以太网和串口之外的空闲引脚,免得后续调试网络时来回折腾。
2.3 电路上补的那几手
硬件上除了几根信号线,还有几个容易忽略的细节值得提前做。SPI 信号线串接一个 22Ω 到 33Ω 的电阻,尤其是 SCK 和 SI 线,在工业现场能明显抑制振铃,特别是当 PCB 走线超过 5 厘米的时候。VDD 引脚旁边放一个 0.1μF 陶瓷电容紧贴引脚,再加一个 10μF 电解电容做电源平滑,这对防止 SPI 通信瞬间掉电压翻车很有用。
CS# 线要加上拉电阻。这个我多说一句,因为真的在批量产品上吃过亏。单片机上电复位或程序下载期间,GPIO 方向处于未确定状态,如果外部电路又恰好在此时把 CS# 拉低,MRAM 芯片会莫名其妙地被选中,此时 SDO 引脚也可能输出数据,导致总线冲突。解决办法很简单,用 10kΩ 电阻把 CS# 上拉到 VDD,保证无主控控制时它维持在高电平。
HOLD# 和 WP# 也是一样,就算接 VDD 也要通过上拉电阻或直接硬连,不要用代码里去 spice 模拟“好像接了”的方式。实际操作中我见到过因为 HOLD# 悬空、手摸一下板子就导致通信中断的案例,原因就是悬空引脚受人体静电感应,电平乱跳。
3. 读写实现:从命令集到工程代码
3.1 初始化 SPI
MR25H40CDF 支持标准 SPI Mode 0 和 Mode 3,也就是 CPOL/CPHA 两种组合均可用。对于 PIC18F97J60,在 MSSP 模块里做相应配置即可。我个人习惯用 Mode 0,全行业的大多数 SPI 从机也默认兼容 Mode 0,后续如果想换别的传感器、别的存储芯片,代码改动最小。
初始化的关键是先把 SPI 时钟压到最慢档。我开始调试时通常选择 FOSC/64 甚至更低,跑通流程后再逐步提速,保证第一步排除时序问题。下面这段是基于 MPLAB XC8 的寄存器级初始化参考:
void SPI_MRAM_Init(void) { // 将用到的引脚设为数字 IO,关闭模拟输入功能 // 具体寄存器要看实际选用的引脚和单片机的 ADCON/ANCON 配置 // 配置 MSSP1 为主模式,起始时钟 FOSC/64 SSP1CON1 = 0b00100010; // SSPEN=1, CKP=0, SSPM=0010 SSP1STATbits.CKE = 1; // 配合 CKP=0 得到 SPI Mode 0 // 片选引脚设为数字输出,初始拉高 MRAM_CS_HIGH(); }注意:不同批次或不同型号的 PIC18F97J60 引脚命名有细微差异,比如 SDO/SDI 也可能是 RB、RC 口上的复用功能,一定要以实际数据手册的 MSSP 引脚映射为准。
3.2 单字节读写命令
MR25H40CDF 的 SPI 指令集很简洁,核心是四个命令:WREN(06h)写使能、WRITE(02h)写数据、READ(03h)读数据、RDSR(05h)读状态寄存器。逻辑上跟串行 EEPROM 差不多,但少了很多“页写”之类的限制。
先封装一个最底层 SPI 字节交换函数:
unsigned char SPI_Exchange(unsigned char dat) { SSP1BUF = dat; // 写入要发送的数据 while (!SSP1STATbits.BF); // 等待接收缓冲满 return SSP1BUF; // 返回收到的数据 }然后是写使能和单字节写入。写使能是 MRAM 写操作的前置条件,虽然 MRAM 本身不需要像 Flash 那样擦除,但命令序列里 WREN 还是必须的,而且要遵守“CS 拉低、发 WREN、CS 拉高”这个完整时序:
#define MRAM_CMD_WREN 0x06 #define MRAM_CMD_WRITE 0x02 #define MRAM_CMD_READ 0x03 #define MRAM_CMD_RDSR 0x05 void MRAM_WriteByte(unsigned long addr, unsigned char dat) { // 写使能 MRAM_CS_LOW(); SPI_Exchange(MRAM_CMD_WREN); MRAM_CS_HIGH(); // 写数据 MRAM_CS_LOW(); SPI_Exchange(MRAM_CMD_WRITE); SPI_Exchange((addr >> 16) & 0xFF); SPI_Exchange((addr >> 8) & 0xFF); SPI_Exchange(addr & 0xFF); SPI_Exchange(dat); MRAM_CS_HIGH(); }注意地址发送顺序是高位在前、低位在后,也就是先发 A16~A23,再发 A8~A15,最后发 A0~A7。这个顺序跟多数 SPI NOR Flash 一样。我之前一开始习惯性先发低位字节,结果读出地址全乱了,卡了好半天。
单字节读取更简单,不需要写使能:
unsigned char MRAM_ReadByte(unsigned long addr) { unsigned char dat; MRAM_CS_LOW(); SPI_Exchange(MRAM_CMD_READ); SPI_Exchange((addr >> 16) & 0xFF); SPI_Exchange((addr >> 8) & 0xFF); SPI_Exchange(addr & 0xFF); dat = SPI_Exchange(0x00); // 这个字节是读的,随便发一个 0 MRAM_CS_HIGH(); return dat; }这里有个细节:SPI 是同步全双工协议,读的时候主机也必须产生时钟,所以要发一个“假字节”把 SO 线上的数据带回来。发 0x00 就行,这是常规操作。
3.3 多字节读写与环形日志
MRAM 最让我舒服的一点是没有“页缓冲”的概念。SPI NOR Flash 写一页通常限制在 256 字节,超过一页就要人工处理边界换页;EEPROM 也类似。MR25H40CDF 只要 CS# 拉低期间,可以连续不断地送地址后面的数据字节,地址自动递增,直到把整个存储空间写完。这意味着多字节写入的驱动代码非常简单:
void MRAM_WriteBuffer(unsigned long addr, unsigned char *buf, unsigned int len) { MRAM_CS_LOW(); SPI_Exchange(MRAM_CMD_WREN); MRAM_CS_HIGH(); MRAM_CS_LOW(); SPI_Exchange(MRAM_CMD_WRITE); SPI_Exchange((addr >> 16) & 0xFF); SPI_Exchange((addr >> 8) & 0xFF); SPI_Exchange(addr & 0xFF); while (len--) SPI_Exchange(*buf++); MRAM_CS_HIGH(); } void MRAM_ReadBuffer(unsigned long addr, unsigned char *buf, unsigned int len) { MRAM_CS_LOW(); SPI_Exchange(MRAM_CMD_READ); SPI_Exchange((addr >> 16) & 0xFF); SPI_Exchange((addr >> 8) & 0xFF); SPI_Exchange(addr & 0xFF); while (len--) *buf++ = SPI_Exchange(0x00); MRAM_CS_HIGH(); }页边界问题完全消失,这对做日志系统是巨大的简化。我以前用 SPI NOR Flash 做日志,每存满一页就要算下一页地址,同时为了防止“跨页写”还要拆分成多次操作,驱动又长又容易出 bug。换成 MRAM 之后,我只需要维护一个“写指针”和“读指针”,循环写就行。
做环形日志时,我建议每条记录固定长度,比如 64 字节,头部放 4 字节序号、2 字节长度、2 字节 CRC,后面跟数据。写入顺序要注意:先把记录数据本身写完,最后再写序号和 CRC。这样即使写入过程中掉电,最多丢一条未完成的记录,已经写好的完整记录不会被半更新的 CRC 污染,恢复时遇到 CRC 校验失败的记录跳过就行。
3.4 存储布局建议
MR25H40CDF 有 512K 字节,怎么划分空间建议提前规划好。我常用的一种布局是:
| 区域 | 地址范围 | 用途 |
|---|---|---|
| 配置区主区 | 0x00000 ~ 0x000FF | 256 字节,存设备编号、网络参数、校准系数 |
| 配置区备份 | 0x00100 ~ 0x001FF | 256 字节,配置备份,写入时交替更新 |
| 日志区 | 0x01000 ~ 0x7FFFF | 大约 500K 字节,事件日志循环存储 |
| 预留区 | 0x00200 ~ 0x00FFF | 其他临时存储、自检标记 |
配置区为什么用双备份?因为配置参数往往是“关键资产”,掉电写坏一个字节都可能让设备失联。我的做法是写配置时先写备份区,再写主区,校验位放在每条配置的末尾。启动时先读主区,CRC 校验不过就回退读备份区,再校验不过就恢复默认参数。MRAM 写入速度快,做这种双备份几乎不增加耗时开销,完全是白赚的可靠性。
4. 调试记录:我在现场踩过的坑
4.1 写进去读不出来
我第一次调 MR25H40CDF 时,遇到的情况是写函数执行完,回头读同一个地址,读出来的全是 0xFF。排查了半个多小时,最后才想起来把状态寄存器读出来看一眼。一读发现 WEL 位是 0,说明 WREN 根本没生效,或者被 MCU 复位清掉了。
后来总结出三条检查顺序:第一,确认 CS# 在 WREN 命令后确实拉高又拉低了,很多 SPI 存储器要求写使能之后 CS# 必须回到高电平,再拉低进入下一个命令;第二,确认 WP# 引脚确实接在 VDD,而不是悬空或拉低;第三,读一下状态寄存器,看 WEL 位是否为 1。
状态寄存器读取命令很简单,用 RDSR(05h)就能把状态字节读回来。我建议在调试期写一个单独的测试函数,专门读状态寄存器并打印出来,这样就很容易区分“命令没发对”“写保护开启”和“芯片没焊好”三类问题。
4.2 偶发乱码与抗干扰
还有一个项目里出现的现象:正常通信没问题,但一到电机启动或者继电器吸合的时候,从 MRAM 读出来的数据时不时错一位。用逻辑分析仪抓 SPI 波形,发现 SCK 线在干扰瞬间有明显的振铃,数据线上的波形也不是很干净。
这个问题的元凶是长走线加没有匹配电阻。我当时为了布局方便,把 MRAM 放得离单片机比较远,SCK 走线大约 8 厘米,还穿过了一个过孔。解决办法是:在 SCK、SI、CS# 上串了 22Ω 电阻,另外把 SPI 时钟从 FOSC/16 降到 FOSC/64,通信速率降下来之后错误立刻消失。再往深处说,工业现场本身就存在传导干扰,SPI 信号地要做好,走线避免跟电源、继电器控制线平行长距离走在一起。
可靠性优先的场景下,我建议在协议层加一道 CRC 校验,不要只依赖硬件。无论是配置读取还是日志写入,数据都带上 CRC 校验值,接收方校验不过就重试一次。MRAM 本身出错率极低,但通信链路会有干扰,加一道协议校验的成本很低,收益却很直观。读取时如果连续多次 CRC 失败,立即把 CS# 拉高复位通信状态,重新同步一次往往就恢复了。
4.3 掉电瞬间的“灵异事件”
还有一个让我印象深刻的问题,是有一次做掉电测试时发现,系统在掉电瞬间偶尔会往 MRAM 里写进去一条乱码记录。代码逻辑很明确,只在收到掉电中断后才写数据,但乱码记录依然出现了。
排查到最后,发现不是软件问题,而是掉电瞬间 SPI 信号乱飞。单片机的电源电压跌落过程中,IO 引脚的电平变得不可控,如果此时 CS# 处于低电平,MRAM 就会被误选中,SCK 和 SI 上的毛刺会被当成命令和数据执行。解决办法就是前面提到的:CS# 加上拉电阻,确保 MCU 未正常初始化时 CS# 保持高电平。另外,我在硬件设计里加了电压检测芯片,只有当 VDD 稳定在 3.0V 以上时才允许主控访问 MRAM;一旦检测到电压跌落到阈值以下,立即触发掉电中断,外设全部复位,CS# 置高。这个机制在工业设备里很有必要。
4.4 常见问题速查表
| 现场现象 | 常见原因 | 排查、解决建议 |
|---|---|---|
| 写之后读出来全 0xFF | WREN 未生效,或 WP# 被拉低 | 检查 WREN 时序,读状态寄存器确认 WEL 位 |
| 读出来全 0x00 | SPI Mode 不匹配,或 SI/SO 接反 | 用逻辑分析仪看第一个字节,核对引脚连接 |
| 只有低 64K 能访问 | 地址高字节被截断 | 地址参数用 unsigned long,确认 3 字节地址都发送 |
| 偶发一位数据错误 | 走线太长、无匹配电阻、干扰 | 降 SPI 时钟、串 22Ω 电阻、缩短走线、加 CRC |
| 掉电瞬间写入脏数据 | CS# 悬空/低电平被误选中 | CS# 外部上拉,掉电检测,复位前强制拉高 CS# |
| 通过外部复位后通信失败 | HOLD# 电平不稳 | HOLD#、WP# 接 VDD,不要悬空 |
| 连续写几千字节后出错 | 电源供电不足,压跌 | 增大电容,检查 3.3V 电源裕量 |
5. 把这套组合放到更大的场景里
5.1 以太网远程配置和日志上传
PIC18F97J60 既然带以太网,把 MRAM 里的数据通过网口传出去就是顺理成章的事。我在项目里做了两层应用:第一层是远程配置,上位机通过 Modbus TCP 协议把设备参数写入到 MRAM 的配置区,断电后不丢,重启后自动加载;第二层是日志上传,本地存储的故障事件日志,可以按时间段批量读取出来,通过 TCP 连接发给上位机。
这里要提醒一点:PIC18F97J60 的 RAM 不够大,不要把整个日志区一次性读进内存再发送,那样 3.8KB 的 RAM 根本装不下。正确做法是边读边发,每次从 MRAM 搬一小块缓冲到内存,发完再读下一块。读取时保持 CS# 低电平连续读,效率高,也省内存。
5.2 后续扩展的几种玩法
MR25H40CDF 这块空间只用来存日志和配置有点可惜。我后来还扩展了几个用途:一个是“运行状态掉电快照”,把当前运行模式、累计运行时间、变频器频率等关键状态量在掉电前快速写入 MRAM 的一个固定区域,下次上电后直接从快照恢复;另一个是“校准参数版本管理”,在配置区头部的两个字节里存一个软件版本号,升级固件时自动做参数迁移。
还有个小技巧是可以把 MRAM 当“隔离的共享信箱”用。如果系统里有多个任务需要交换非易失状态,比如一个任务负责采集,另一个任务负责上报,它们之间通过 MRAM 特定区域传递数据,省去复杂的互斥锁设计。因为 MRAM 写入速度快,这个方式的实时性在大多数工控场景下都能接受。
最后分享一个我个人的习惯:新环境调试 MRAM 时,先别急着上逻辑分析仪,把 SPI 时钟降到最慢档,发 WREN、写一个字节、读回来对比预期值,先把基本链路跑通。链路通了再提速,再考虑抗干扰和协议层的 CRC,问题会少掉一半。这套 MR25H40CDF 加 PIC18F97J60 的组合,我实际跑了快两年,批量产品在工业现场没有出现过一例存储异常。如果你正在做类似的工业数据记录或者远程配置设备,这套方案值得直接抄作业。