1. 项目缘起与方案选型:为什么是 MRAM 加 PIC18F97J94
1.1 一个真实的需求场景
工业现场的数据记录仪、电力监测终端、医疗设备日志模块,这类设备有一个共同特点:需要频繁写入小批量关键数据,断电不能丢,现场环境还经常伴随高温、振动和电磁干扰。我接手过一个配电柜监测模块的改造项目,原来的方案是铁电存储器加后备电池,运行两年后陆续出现电池失效导致数据丢失的投诉。拆机分析发现,问题不在电路设计,而是电池本身在高温环境下的寿命衰减远超预期。
这个痛点直接把我推向了 MRAM 方案。MRAM 的全称是磁阻随机存取存储器,它的存储原理和传统 Flash、EEPROM 完全不同——利用磁性隧道结的磁化方向来记录数据,而不是靠电荷。这个物理机制带来了几个对工业应用极其友好的特性:写入速度是纳秒级、擦写寿命近乎无限、不需要擦除操作、断电后数据保持十年以上、工作温度范围宽。这些特性恰好对应了工业场景里最头疼的几个问题。
选定了存储介质,接下来是主控。PIC18F97J94 是 Microchip 旗下一颗 8 位单片机,主频最高 64MHz,自带 SPI 外设,有 128KB Flash 和近 4KB RAM,外设资源丰富,封装从 64 脚到 100 脚都有。它在工业控制领域出货量大、供货稳定、开发工具链成熟,用 C18 或 XC8 编译器就能开发,对中小批量项目非常友好。MR25H40CDF 是 Everspin 的 4Mbit(512KB)SPI MRAM,标准 SPI 接口,最高 40MHz 时钟,SOIC-8 封装,和常见的 SPI Flash 引脚兼容。这两颗芯片搭配,构成了一个"主控够用、存储可靠、接口简单"的组合。
1.2 为什么不用 SPI Flash 或 EEPROM
很多人第一反应是:存数据用 SPI Flash 不就行了,便宜量大。这个想法在消费类产品里没问题,但在工业场景里会踩坑。SPI Flash 的擦写寿命通常在 10 万次量级,而且写入前必须先擦除整个扇区,擦除时间长(毫秒级),擦除过程中如果断电,整个扇区的数据都可能损坏。EEPROM 虽然可以字节级写入,寿命约 100 万次,但写入速度慢,每次写字节需要几毫秒,频繁写入的场景下 CPU 会被拖住。
MR25H40CDF 的写入不需要擦除,直接覆盖,单次写入时间在纳秒到微秒级,寿命标称 10^14 次以上。这意味着你可以把它当成 RAM 一样用,随时写、随便写,不用担心寿命和擦除问题。对于需要记录运行日志、故障快照、参数变更历史的设备,这个特性是决定性的。
1.3 整体架构设计思路
整个方案的核心思路是:PIC18F97J94 通过硬件 SPI 接口连接 MR25H40CDF,把 MRAM 当作一个"非易失的扩展 RAM"来使用。软件层面分三层:底层是 SPI 驱动,负责字节收发和片选控制;中间层是 MRAM 读写接口,封装地址操作和页写入逻辑;上层是应用数据管理,负责数据结构定义、校验和掉电保护。
这个分层的好处是,底层驱动和 MRAM 芯片解耦,将来换成其他 SPI 存储器件只需要改中间层。上层应用不关心底层是 MRAM 还是别的介质,只调用读写接口。这种设计在项目迭代和器件替换时能省下大量返工时间。
提示:MRAM 虽然写入不需要擦除,但它的写入时序对片选信号和时钟极性有严格要求,配置错误会导致写入数据错乱,这一点在调试初期最容易踩坑。
2. 硬件连接与 SPI 时序关键细节
2.1 引脚连接与片选策略
MR25H40CDF 是 SOIC-8 封装,引脚定义很简洁:CS(片选)、SO(数据输出)、SI(数据输入)、SCK(时钟)、VCC、GND,加上 WP(写保护)和 HOLD(保持)。实际连接时,CS 接到 PIC18F97J94 的一个普通 GPIO,SO/SI/SCK 接到 SPI 外设对应的引脚上。
这里有个关键选择:片选用硬件片选还是软件片选。PIC18F97J94 的 MSSP 模块支持硬件片选,但硬件片选在某些情况下时序不够灵活。我实测下来,用普通 GPIO 做软件片选更可控,因为 MRAM 的读写操作需要精确控制 CS 的拉低和拉高时机,软件片选能让你在字节之间自由控制,避免硬件片选自动翻转带来的时序问题。
WP 引脚建议接高电平(禁用写保护),HOLD 引脚也接高电平(禁用保持功能)。如果产品有特殊安全需求,可以把 WP 接到 GPIO 上,通过软件控制写保护状态,防止误写。
2.2 SPI 模式选择与时钟配置
MR25H40CDF 支持 SPI 模式 0(CPOL=0,CPHA=0)和模式 3(CPOL=1,CPHA=1)。这两种模式的区别在于时钟空闲电平和采样边沿。我一般选模式 0,因为它是绝大多数 SPI 器件的默认模式,调试时用逻辑分析仪抓波形更容易对照。
时钟频率方面,MR25H40CDF 最高支持 40MHz,但 PIC18F97J94 在 64MHz 主频下,SPI 时钟分频后的实际频率需要计算。假设系统时钟 64MHz,SPI 分频系数设为 4,则 SCK 频率为 16MHz。这个频率下 MRAM 读写完全没问题,而且留有余量。如果追求极致速度,可以把分频系数设为 2,SCK 达到 32MHz,但要注意 PCB 走线和信号完整性。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| SPI 模式 | 模式 0 | CPOL=0, CPHA=0,最通用 |
| 时钟频率 | 16MHz | 分频系数 4,稳定可靠 |
| 片选方式 | 软件 GPIO | 时序可控,调试方便 |
| WP 引脚 | 接高电平 | 禁用写保护 |
| HOLD 引脚 | 接高电平 | 禁用保持功能 |
2.3 PCB 布局注意事项
SPI 信号线属于高速信号,虽然 16MHz 不算特别高,但在工业环境下,走线不当会引入干扰。我的经验是:SCK 线尽量短,避免和模拟信号线平行走线;SO/SI 线如果长度超过 5cm,建议串联 22 欧姆电阻做阻抗匹配;MRAM 的电源引脚旁边必须放 0.1uF 去耦电容,越近越好。
地平面处理也很关键。如果 PCB 是双层板,MRAM 下方尽量铺地;如果是四层板,把 MRAM 放在靠近地平面的层。工业现场的电磁干扰复杂,这些细节在实验室里看不出问题,到了现场就可能出现偶发读写错误。
注意:MRAM 对电源纹波比较敏感,如果系统里有大功率开关器件,建议给 MRAM 单独加一级 LDO 供电,或者在电源入口加磁珠和电容滤波。
3. 底层驱动实现:从字节收发到页写入
3.1 SPI 字节收发函数
底层驱动的基础是字节收发。PIC18F97J94 的 MSSP 模块提供了 SSPBUF 寄存器,写入数据后硬件自动发送,同时接收数据也存入 SSPBUF。下面是一个典型的字节收发函数:
unsigned char SPI_ExchangeByte(unsigned char data) { SSPBUF = data; // 写入发送数据 while (!SSPIF); // 等待发送完成 SSPIF = 0; // 清除中断标志 return SSPBUF; // 返回接收数据 }这个函数看起来简单,但有几个细节需要注意。SSPIF 标志位的清除时机很关键,必须在读取 SSPBUF 之前清除,否则可能读到旧数据。另外,如果 SPI 时钟太快,while 循环可能来不及响应,建议在初始化时把 SPI 时钟先设低一点,调试通过后再提高。
3.2 MRAM 写使能与状态检查
MR25H40CDF 的写入操作需要先发送写使能命令(WREN,命令码 0x06),然后才能执行写操作。写操作完成后,芯片进入内部写周期,此时状态寄存器的 WIP 位(Write In Progress)为 1,表示正在写入。虽然 MRAM 的写周期极短,但为了保险,还是建议检查 WIP 位。
void MRAM_WriteEnable(void) { CS_LOW(); SPI_ExchangeByte(0x06); // WREN 命令 CS_HIGH(); } void MRAM_WaitReady(void) { CS_LOW(); SPI_ExchangeByte(0x05); // RDSR 命令 while (SPI_ExchangeByte(0xFF) & 0x01); // 等待 WIP 位清零 CS_HIGH(); }这里有个实操心得:MRAM 的写周期通常在几百纳秒内完成,实际上调用 MRAM_WaitReady 时 WIP 位往往已经清零了。但为了代码的健壮性,这个检查不能省,尤其是在低温环境下,MRAM 的写入速度会略有下降。
3.3 页写入与地址管理
MR25H40CDF 的存储空间是 512KB,地址范围 0x00000 到 0x7FFFF,需要 19 位地址。SPI 命令中,写命令(WRITE,命令码 0x02)后面跟 3 个字节的地址,然后是数据。MRAM 支持页写入,每页 256 字节,但也可以跨页连续写入,因为它没有 Flash 那种页边界限制。
void MRAM_WriteData(unsigned long addr, unsigned char *buf, unsigned int len) { MRAM_WriteEnable(); CS_LOW(); SPI_ExchangeByte(0x02); // WRITE 命令 SPI_ExchangeByte((addr >> 16) & 0xFF); SPI_ExchangeByte((addr >> 8) & 0xFF); SPI_ExchangeByte(addr & 0xFF); for (unsigned int i = 0; i < len; i++) { SPI_ExchangeByte(buf[i]); } CS_HIGH(); MRAM_WaitReady(); }地址的高位处理要注意,PIC18F97J94 是 8 位机,unsigned long 是 32 位,但实际地址只有 19 位有效。移位操作时确保高位补零,避免地址错乱。另外,写入长度 len 如果超过 256 字节,MRAM 会自动递增地址,不需要手动分页,这是 MRAM 相比 Flash 的一大优势。
3.4 读取操作的实现
读取操作比写入简单,发送读命令(READ,命令码 0x03)和地址后,直接连续读取数据即可。MRAM 支持任意地址起始的连续读取,没有页边界限制。
void MRAM_ReadData(unsigned long addr, unsigned char *buf, unsigned int len) { CS_LOW(); SPI_ExchangeByte(0x03); // READ 命令 SPI_ExchangeByte((addr >> 16) & 0xFF); SPI_ExchangeByte((addr >> 8) & 0xFF); SPI_ExchangeByte(addr & 0xFF); for (unsigned int i = 0; i < len; i++) { buf[i] = SPI_ExchangeByte(0xFF); } CS_HIGH(); }读取时发送 0xFF 是 SPI 的标准做法,因为主机需要产生时钟信号才能接收数据,发送什么内容无所谓。但要注意,有些 SPI 从器件对发送内容有要求,MRAM 没有这个限制,0xFF 或 0x00 都可以。
4. 应用层数据管理:校验、掉电保护与日志结构
4.1 数据帧结构与校验和
把 MRAM 当裸存储用没问题,但实际项目里需要一套数据管理机制。我的做法是定义固定长度的数据帧,每帧包含帧头、数据长度、数据内容、校验和。帧头用两个字节的固定值(比如 0xAA55),校验和用 CRC16 或者简单的累加和。
typedef struct { unsigned short header; // 0xAA55 unsigned short length; // 数据长度 unsigned char data[64]; // 数据内容 unsigned short crc; // CRC16 校验 } DataFrame;写入时先构造帧,计算 CRC,然后整帧写入 MRAM。读取时先读帧头,验证帧头正确后再读长度和 CRC,校验通过才认为数据有效。这套机制能有效防止掉电导致的半写数据被误读。
4.2 掉电保护策略
工业设备最怕的是写入过程中掉电,导致数据处于"写了一半"的状态。MRAM 的写入速度极快,掉电窗口很小,但理论上仍然存在。我的做法是采用"双备份加标志位"策略:数据同时写入两个区域,每个区域有一个状态标志。写入时先写数据区,再写标志位;读取时检查标志位,如果标志位无效,则读取备份区。
具体实现上,把 MRAM 分成两个 bank,每个 bank 存储相同的数据结构。写入时先写 bank A,更新 bank A 的标志位,再写 bank B,更新 bank B 的标志位。读取时优先读 bank A,如果标志位无效则读 bank B。这样即使写入过程中掉电,至少有一个 bank 的数据是完整的。
提示:标志位的写入顺序很重要,必须先写数据再写标志位。如果先写标志位再写数据,掉电后标志位有效但数据不完整,会导致误判。
4.3 日志存储的环形缓冲设计
对于需要记录运行日志的场景,环形缓冲是最合适的数据结构。在 MRAM 里划出一块区域作为日志区,每个日志条目固定长度,写满后从头覆盖。维护一个写指针,每次写入后指针递增,超过区域大小时归零。
环形缓冲的好处是不需要擦除,写入永远是覆盖操作,MRAM 的无限寿命特性在这里发挥得淋漓尽致。日志区的大小根据需求定,比如 64KB 可以存 1024 条 64 字节的日志。写指针本身也需要存储,可以放在 MRAM 的固定位置,每次写入日志后更新指针。
typedef struct { unsigned long write_ptr; // 写指针 unsigned long log_count; // 日志总数 } LogHeader; void Log_Write(unsigned char *entry) { LogHeader header; MRAM_ReadData(LOG_HEADER_ADDR, (unsigned char*)&header, sizeof(header)); unsigned long addr = LOG_BASE_ADDR + (header.write_ptr % LOG_MAX_ENTRIES) * LOG_ENTRY_SIZE; MRAM_WriteData(addr, entry, LOG_ENTRY_SIZE); header.write_ptr++; header.log_count++; MRAM_WriteData(LOG_HEADER_ADDR, (unsigned char*)&header, sizeof(header)); }这个设计里,日志头部的更新是每次写入都要做的,所以日志头部的写入频率很高。MRAM 的无限寿命让这个设计成为可能,换成 EEPROM 的话,头部区域很快就会写坏。
5. 调试实录:常见问题与排查技巧
5.1 读写数据错乱的排查
调试初期最常见的问题是读出来的数据和写入的不一致。排查思路按以下顺序进行:先确认 SPI 模式是否正确,用逻辑分析仪抓 SCK 和 SI 的波形,对照 MRAM 数据手册的时序图;再检查片选信号,确保 CS 在命令和数据传输期间保持低电平,传输结束后才拉高;最后检查地址字节顺序,MRAM 是高位在前,如果地址字节顺序搞反,读写地址就会错位。
我遇到过一次诡异的问题:单字节读写正常,连续读写就出错。查了半天发现是 CS 拉高的时机不对,在最后一个字节的时钟边沿还没结束时 CS 就拉高了,导致最后一个字节丢失。解决办法是在 SPI_ExchangeByte 返回后加一个微秒级延时,再拉高 CS。
5.2 写入后立即读取返回旧数据
这个问题通常是因为没有等待写周期完成。虽然 MRAM 写周期很短,但如果代码里写入后立即读取,而 SPI 时钟又比较快,可能在 WIP 位还没清零时就发起了读操作。解决办法是在写入后调用 MRAM_WaitReady,或者加一个短延时。
另一个可能的原因是 SPI 时钟太快,MRAM 还没完成内部写入,主机就开始下一次传输了。可以尝试降低 SPI 时钟频率,如果问题消失,说明是时序余量不足。
5.3 工业现场偶发数据错误
实验室里一切正常,到了现场偶尔出现数据错误,这类问题最难查。我的经验是先从电源入手,用示波器看 MRAM 电源引脚上的纹波,如果纹波超过 50mV,就需要加强滤波。再从信号完整性入手,检查 SCK 和 SO/SI 线上是否有过冲和振铃,必要时串联电阻或加电容。
还有一个容易被忽略的点是地线。工业现场的地电位差可能很大,如果 MRAM 和主控的地线处理不当,SPI 信号的电平参考就会漂移。建议在 MRAM 的 GND 引脚附近单独打孔接地,不要和功率地混在一起。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 读写数据错乱 | SPI 模式错误 | 逻辑分析仪抓波形 | 改为模式 0 |
| 连续读写出错 | CS 时序不对 | 检查 CS 拉高时机 | 加延时后拉高 |
| 写入后读旧数据 | 未等待写周期 | 检查 WIP 位 | 调用 WaitReady |
| 现场偶发错误 | 电源纹波大 | 示波器看电源 | 加强滤波 |
| 现场偶发错误 | 地线干扰 | 检查接地 | 单独接地 |
5.4 调试工具与技巧
调试 SPI 器件,逻辑分析仪是必备工具。我用的是 8 通道 24MHz 的逻辑分析仪,价格不贵但足够用。抓波形时重点看 CS、SCK、SI、SO 四根线,对照数据手册的时序图逐段核对。如果条件允许,用带 SPI 解码功能的示波器更省事,可以直接把波形翻译成十六进制数据。
另一个技巧是在代码里加调试打印,把每次读写的地址、长度、数据内容通过串口打印出来。这样即使没有逻辑分析仪,也能通过串口日志定位问题。但要注意,打印本身会占用时间,可能影响 SPI 时序,所以调试打印要在问题定位后移除。
6. 性能优化与扩展思路
6.1 提高读写速度的几种方法
SPI 时钟频率是最直接的影响因素。PIC18F97J94 的 SPI 时钟最高可以到系统时钟的四分之一,64MHz 主频下就是 16MHz。如果系统时钟可以超频到 80MHz,SPI 时钟就能到 20MHz。但超频会带来功耗和稳定性问题,工业场景下不建议。
另一个方法是减少片选翻转次数。每次读写操作都需要拉低再拉高 CS,这个动作本身有时间开销。如果需要连续读写多个不连续地址,可以把它们合并成一次传输,中间不拉高 CS。MRAM 支持连续地址自动递增,但不支持随机地址跳转,所以合并的前提是地址连续。
还可以用 DMA 来搬运数据,但 PIC18F97J94 没有 DMA 控制器,这个方案不适用。如果换成带 DMA 的 PIC32 系列,SPI 传输可以完全不占 CPU,效率提升明显。
6.2 多器件共享 SPI 总线
一个系统里往往不止一个 SPI 器件,比如 MRAM、显示屏、传感器可能都挂在同一条 SPI 总线上。这时候片选信号的管理就很重要。每个器件单独一根 CS 线,同一时刻只能有一个 CS 有效。软件上要确保在切换器件时,先拉高当前器件的 CS,再拉低目标器件的 CS,中间加一个小延时。
如果 SPI 总线上的器件比较多,GPIO 不够用,可以用译码器来扩展片选。比如 3-8 译码器可以用 3 根 GPIO 控制 8 个片选信号。但译码器的输出有延时,高速 SPI 下可能来不及响应,需要选高速译码器。
6.3 从 MRAM 到其他存储介质的迁移
如果项目后期需要换存储介质,比如换成 FRAM 或 NVSRAM,底层驱动的改动量取决于抽象层设计得好不好。我的做法是把 MRAM 的读写接口定义成函数指针或者宏,上层应用只调用接口,不直接操作 SPI。这样换器件时只需要重写底层驱动,上层代码不动。
MRAM 和 FRAM 的 SPI 命令集不完全一样,FRAM 通常有更丰富的命令,比如睡眠模式、设备 ID 读取等。迁移时要注意命令码的差异,以及时序参数的差异。FRAM 的写入速度也很快,但寿命比 MRAM 低几个数量级,如果应用场景是频繁写入,MRAM 仍然是更好的选择。
6.4 低功耗场景的考虑
电池供电的工业设备对功耗敏感。MR25H40CDF 的待机电流很低,但 SPI 时钟翻转时会消耗动态功耗。降低功耗的方法包括:降低 SPI 时钟频率、减少不必要的读写操作、在空闲时把 CS 拉高让 MRAM 进入待机模式。
PIC18F97J94 本身有睡眠模式,在睡眠模式下 SPI 外设可以关闭。如果系统大部分时间处于空闲状态,可以设计成"唤醒-读写-睡眠"的循环,把平均功耗降到微安级。但要注意,MRAM 从待机模式唤醒需要时间,如果唤醒太频繁,反而会增加功耗。
7. 项目落地后的几点体会
这套方案我在三个项目里实际用过,最长的已经稳定运行四年多,现场没有出现过数据丢失的投诉。MRAM 的可靠性确实对得起它的价格,虽然单颗成本比 SPI Flash 高不少,但省掉了电池、省掉了擦除逻辑、省掉了数据丢失的售后成本,综合算下来是划算的。
调试过程中最大的坑是 SPI 时序,尤其是 CS 的控制。数据手册上的时序图看起来很清晰,但实际代码里如果 CS 拉高早了一个时钟周期,数据就会丢。我的建议是调试初期把 SPI 时钟降到 1MHz 以下,用逻辑分析仪把每个字节的波形都看清楚,确认时序完全正确后再逐步提高频率。
还有一个经验是:MRAM 的写入虽然不需要擦除,但写入前必须发 WREN 命令,这个命令不能省。我见过有人为了省事,初始化时发一次 WREN 就以为后面都能写了,结果只有第一次写入成功。WREN 是每次写入前都要发的,这是 MRAM 和 EEPROM 的一个区别。
最后分享一个小技巧:如果项目里需要存储配置参数,可以把参数区放在 MRAM 的起始地址,日志区放在后面。配置参数用双备份加 CRC 校验,日志区用环形缓冲。这样一套存储结构可以覆盖大多数工业应用的需求,代码复用率高,新项目直接移植就行。