1. 项目缘起与方案选型
1.1 为什么要在工业场景里折腾 MRAM 和 PIC24
工业现场的数据记录有个很尴尬的处境:用 EEPROM 吧,写入速度慢得让人着急,擦写寿命也就百万次级别,高频采集场景下没几个月就写废了;用 SRAM 加后备电池吧,电池在高温高湿环境下三五年就鼓包漏液,维护成本高得离谱;用普通 Flash 吧,写之前还得先擦一个扇区,掉电瞬间正好在擦除,整块数据直接归零。我前几年做一个电力监测终端,现场返修率最高的就是存储模块,拆开一看十有八九是 Flash 写坏或者电池失效。
后来接触到MR25H40CDF这颗磁阻存储器,才算找到比较舒服的解法。它是 4Mbit(512KB)容量的MRAM,SPI 接口,最关键的一点是:写入不需要擦除,字节级随机写,写一个字节和写一整页在时序上没有本质区别,而且官方标称的耐久度是 10^14 次以上,理论上你每秒写一万次也能撑几百年。掉电数据保持靠的是磁隧道结的物理状态,不需要任何后备电源,-40℃到85℃工业温区稳定工作。这些特性放在工业数据记录场景里,几乎是量身定做的。
主控这边选PIC24HJ256GP610,是 Microchip 自家的 16 位 DSC,256KB Flash、16KB RAM,带 DMA、多个 SPI 模块,主频可以跑到 40MIPS。选它有两个现实原因:一是和 MRAM 同厂,参考手册和时序配合上少踩很多坑;二是这颗片子在我手上的老项目里跑了快八年,抗干扰和长期供货都比较放心。工业产品最怕的就是主控突然停产,PIC24HJ 系列虽然不算新,但生命周期管理做得还算稳。
这套组合要解决的问题很具体:在强电磁干扰、宽温、频繁掉电的工业环境里,可靠地存储和读取关键数据。适合谁看?做工业仪表、电力终端、医疗设备、车载记录仪的嵌入式工程师,尤其是那些被存储可靠性折磨过的朋友。下面我把整个设计思路、硬件连接、SPI 时序配置、读写流程和踩过的坑,按实际项目顺序捋一遍。
1.2 整体架构与数据流设计
整个系统的数据流其实不复杂,但每一层都有讲究。传感器或者上位机通过 UART/CAN 把数据送到 PIC24HJ256GP610,主控在 RAM 里做一帧数据的组包和校验,然后通过 SPI 总线写入 MR25H40CDF。读取的时候反过来,主控发起 SPI 读命令,把数据搬到 RAM,校验后再往上送。
这里有个设计决策值得说:要不要用文件系统。我一开始想过移植一个精简的 FAT 或者 LittleFS,后来放弃了。原因是 MRAM 的写特性和 Flash 完全不同,文件系统的磨损均衡、擦除块管理这些逻辑对 MRAM 来说是多余的负担,反而增加了代码复杂度和出错概率。最终采用的是固定分区 + 环形缓冲区的方案:把 512KB 切成若干块,比如 64KB 给配置区、448KB 给数据记录区,数据区用环形写入,写满从头覆盖。这样地址计算简单,掉电恢复也容易,上电只需要读一个写指针就能接着写。
数据帧结构我固定成 32 字节一帧:4 字节帧头、2 字节长度、20 字节有效数据、4 字节 CRC32、2 字节帧尾。为什么是 32 字节?因为 MRAM 的页大小是 256 字节,32 字节刚好是 1/8 页,SPI 传输时凑整比较方便,而且 32 字节对齐后 DMA 搬运效率高。这个帧长不是拍脑袋定的,是结合数据采集频率和存储容量算出来的:假设每秒记录 10 帧,448KB 能存大约 14000 帧,也就是 23 分钟左右一轮覆盖,对于故障录波场景够用了。
2. 硬件连接与 SPI 时序关键点
2.1 引脚连接与硬件片选的处理
MR25H40CDF 是标准的 8 引脚 SOIC 封装,引脚定义很清晰:CS、SO、SI、SCK、VDD、VSS、WP、HOLD。和 PIC24HJ256GP610 连接时,我用的是一组硬件 SPI 模块,具体是 SPI2,因为 SPI1 被另一路 ADC 通信占用了。
连接关系如下表:
| MR25H40CDF 引脚 | PIC24HJ256GP610 引脚 | 说明 |
|---|---|---|
| CS | RB2(GPIO 输出) | 硬件片选,软件控制 |
| SO | SDI2(RP10) | 主入从出 |
| SI | SDO2(RP11) | 主出从入 |
| SCK | SCK2(RP12) | 时钟 |
| WP | 上拉到 VDD | 不用的写保护 |
| HOLD | 上拉到 VDD | 不用的保持功能 |
| VDD | 3.3V | 电源 |
| VSS | GND | 地 |
这里有个细节:CS 我用的是普通 GPIO 而不是 SPI 模块自带的片选。原因是我需要在两次传输之间精确控制 CS 的拉高拉低时序,尤其是写操作之后要等一个内部写周期。PIC24HJ 的 SPI 模块片选在某些模式下会自动拉高,反而不好控制。用 GPIO 手动控制,代码里LATBbits.LATB2 = 0和LATBbits.LATB2 = 1一目了然,调试时用逻辑分析仪抓波形也清楚。
注意:CS 拉低到第一个 SCK 上升沿之间要留至少 5ns 的建立时间,MR25H40CDF 手册里写的是 tCSS。实际用 40MHz 主频时,一条 GPIO 操作指令就够这个时间了,但如果你的主频特别高,建议在 CS 拉低后加一个
Nop()。
2.2 SPI 模式与时钟配置的计算过程
MR25H40CDF 支持 SPI 模式 0(CPOL=0,CPHA=0)和模式 3(CPOL=1,CPHA=1)。我选的是模式 0,因为 PIC24HJ 的 SPI 模块在模式 0 下配置最直接,而且大部分逻辑分析仪的默认解码也是模式 0,调试方便。
时钟频率方面,MR25H40CDF 最高支持 40MHz。PIC24HJ256GP610 在 40MIPS 下,SPI 时钟由主频分频得到。SPI2 的时钟源是 Fcy,也就是 40MHz。分频寄存器 SPI2CON1bits.SPRE 和 PPRE 组合决定最终时钟。
我实际用的是10MHz,不是 40MHz。为什么降频?两个原因:一是工业现场的 SPI 走线如果超过 10cm,40MHz 下信号完整性会变差,尤其是没有阻抗匹配的情况下,SCK 上升沿会有振铃;二是 MRAM 的读写在 10MHz 下已经足够快,写一个 32 字节帧加上命令开销也就 30 微秒左右,对毫秒级的采集周期来说完全够用。降频换来的稳定性提升,在工业场景里比那点速度重要得多。
分频计算:Fcy = 40MHz,目标 10MHz,分频比 4。设置SPI2CON1bits.PPRE = 0b01(主分频 4:1),SPI2CON1bits.SPRE = 0b111(次分频 1:1),最终时钟 = 40MHz / 4 / 1 = 10MHz。这个计算过程在手册的 SPI 章节有详细公式,我把它记在代码注释里,方便以后换主频时重新算。
2.3 电源与去耦的实操经验
MRAM 虽然不像 Flash 那样有擦除时的电流尖峰,但对电源纹波还是敏感的。我在 VDD 引脚旁边放了两个电容:一个 100nF 的陶瓷电容紧贴引脚,一个 10uF 的钽电容在 5mm 范围内。这两个电容的作用不一样,100nF 滤高频噪声,10uF 提供瞬态电流。
PCB 布局上,MRAM 尽量靠近主控,SPI 走线等长,SCK 和 SI/SO 之间保持 3 倍线宽以上的间距,减少串扰。地平面完整,不要被其他信号割裂。这些是常规操作,但我在一个项目里因为把 MRAM 放在了板子边缘,靠近一个继电器驱动电路,结果继电器动作时 SPI 通信偶发失败,后来把 MRAM 挪到主控旁边就好了。工业板子上,存储芯片的位置比你想的重要。
3. 底层驱动实现与读写流程
3.1 SPI 初始化代码与关键寄存器配置
先上初始化代码,这是整个驱动的基础。我用的是 Microchip 的 XC16 编译器,寄存器操作直接写。
void SPI2_Init(void) { // 配置引脚 TRISBbits.TRISB2 = 0; // CS 输出 LATBbits.LATB2 = 1; // CS 默认拉高 TRISDbits.TRISD10 = 1; // SDI2 输入 TRISDbits.TRISD11 = 0; // SDO2 输出 TRISDbits.TRISD12 = 0; // SCK2 输出 // 关闭 SPI 再配置 SPI2STATbits.SPIEN = 0; // 配置 SPI2CON1 SPI2CON1bits.CKP = 0; // 空闲低电平,模式 0 SPI2CON1bits.CKE = 1; // 数据在 SCK 上升沿采样 SPI2CON1bits.SMP = 0; // 中间采样 SPI2CON1bits.MSTEN = 1; // 主模式 SPI2CON1bits.PPRE = 0b01; // 主分频 4:1 SPI2CON1bits.SPRE = 0b111; // 次分频 1:1 SPI2CON1bits.MODE16 = 0; // 8 位模式 SPI2CON1bits.DISSCK = 0; // 使能 SCK SPI2CON1bits.DISSDO = 0; // 使能 SDO // 清空接收缓冲 SPI2BUF = 0; while (SPI2STATbits.SPIRBF) { SPI2BUF; } // 使能 SPI SPI2STATbits.SPIEN = 1; }这段代码里有两个地方容易出错。一个是CKE和CKP的组合,模式 0 要求 CKP=0、CKE=1,我见过有人写成 CKE=0,结果数据在下降沿采样,读出来全是错位。另一个是SMP位,在 10MHz 下用中间采样(SMP=0)比较稳,如果跑到 40MHz,可能要改成 SMP=1 在末尾采样,给信号更多建立时间。
3.2 字节收发函数与 CS 控制
SPI 收发一个字节的函数,核心是等发送缓冲空、写数据、等接收缓冲满、读数据。
uint8_t SPI2_ExchangeByte(uint8_t data) { SPI2BUF = data; while (!SPI2STATbits.SPIRBF); return SPI2BUF; }这个函数看起来简单,但有个坑:如果 SPI 模块之前有残留数据,第一次读会读到旧值。所以我在初始化里加了清空缓冲的循环。另外,这个函数没有超时机制,如果 MRAM 没接好或者 CS 没拉低,程序会死在这里。工业代码里我一般会加一个计数器,超时后返回错误码,但为了简洁,这里先省略。
CS 控制我封装成宏:
#define MRAM_CS_LOW() LATBbits.LATB2 = 0 #define MRAM_CS_HIGH() LATBbits.LATB2 = 1写命令的时候,先拉低 CS,发命令字节,发地址,发数据,最后拉高 CS。读命令类似,只是数据方向反过来。
3.3 MRAM 写操作与内部写周期等待
MR25H40CDF 的写命令是 0x02,后面跟 24 位地址(实际只用 19 位,因为 512KB 需要 19 位地址),然后是要写的数据。写完之后,MRAM 内部有一个写周期,手册上写的是 tWR,最大 15ns。这个时间极短,实际上 SPI 传输完最后一个字节的时钟沿之后,写就已经完成了,不需要像 Flash 那样轮询状态寄存器。
但这里有个细节:CS 拉高之后要等至少 tWR 才能发起下一次操作。15ns 对 40MHz 主频来说不到一个指令周期,所以我在代码里 CS 拉高后直接继续,实测没问题。不过为了保险,我在连续写多帧的时候,会在帧与帧之间加一个Nop(),确保时序余量。
写一帧 32 字节数据的函数:
void MRAM_WriteFrame(uint32_t addr, uint8_t *buf, uint16_t len) { MRAM_CS_LOW(); SPI2_ExchangeByte(0x02); // 写命令 SPI2_ExchangeByte((addr >> 16) & 0xFF); SPI2_ExchangeByte((addr >> 8) & 0xFF); SPI2_ExchangeByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { SPI2_ExchangeByte(buf[i]); } MRAM_CS_HIGH(); }地址是 24 位,但 MR25H40CDF 只有 19 位地址线,高 5 位会被忽略。我习惯把地址统一按 24 位处理,这样以后换更大容量的 MRAM 时不用改代码。
3.4 读操作与数据校验
读命令是 0x03,后面跟 24 位地址,然后连续读出数据。读的时候 SO 线上会依次输出数据,主控发 0xFF 作为 dummy 字节来产生时钟。
void MRAM_ReadFrame(uint32_t addr, uint8_t *buf, uint16_t len) { MRAM_CS_LOW(); SPI2_ExchangeByte(0x03); SPI2_ExchangeByte((addr >> 16) & 0xFF); SPI2_ExchangeByte((addr >> 8) & 0xFF); SPI2_ExchangeByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { buf[i] = SPI2_ExchangeByte(0xFF); } MRAM_CS_HIGH(); }读出来的数据不能直接用,必须做 CRC 校验。我在每帧末尾放了 4 字节 CRC32,读回来之后先算一遍 CRC,和帧里的比对,不一致就丢弃并记录错误计数。这个错误计数会存在配置区,方便现场排查是偶发干扰还是芯片本身有问题。
CRC32 我用的是标准多项式 0x04C11DB7,查表法实现,速度够快。这里不展开代码,网上有很多现成的表。
4. 环形缓冲区与掉电恢复设计
4.1 环形写入的地址管理
数据区从 0x10000 开始,到 0x7FFFF 结束,总共 448KB。每帧 32 字节,能存 14336 帧。写指针是一个 32 位变量,存在配置区的固定位置,每次写一帧就更新一次。
写指针的更新有个顺序问题:先写数据,再更新指针。如果反过来,掉电时指针已经指向下一帧,但数据还没写进去,上电后读到的就是空白或者旧数据。先写数据再更新指针,最坏情况是掉电时指针没更新,上电后重写同一位置,数据被覆盖一次,但不会读到无效数据。
指针更新也不是每次都写 MRAM,那样太频繁。我的做法是每写 16 帧更新一次指针,也就是 512 字节更新一次。这样掉电最多丢失 15 帧,对大多数工业记录场景可以接受。如果要求一帧都不能丢,那就每帧都更新指针,MRAM 的耐久度完全撑得住,只是 SPI 传输次数翻倍。
4.2 上电初始化与数据扫描
上电之后,主控先读配置区,拿到写指针和错误计数。然后从写指针位置开始,往前扫描最近若干帧,找到最后一帧有效数据。扫描的时候逐帧读 CRC,遇到 CRC 错误的帧就跳过,直到找到连续几帧都正确的数据。
这个扫描过程不能太长,否则上电时间会很久。我限制最多扫描 64 帧,也就是 2KB 数据,一般几毫秒就完成了。如果 64 帧都无效,就认为数据区损坏,重新初始化写指针到起始位置。
实操心得:扫描的时候不要从写指针往后扫,要往前扫。因为写指针指向的是下一个要写的位置,它前面的数据才是最近写入的。这个方向搞反了,会读到很旧的数据。
4.3 掉电检测与紧急写入
工业现场掉电是常态,所以掉电检测电路必不可少。我用的是 PIC24HJ 的 LVD(低压检测)模块,配置成在电源降到 2.9V 时触发中断。中断里立刻把当前 RAM 里未写入的数据紧急写进 MRAM。
这里有个时间预算问题:从 2.9V 降到 MRAM 最低工作电压 2.7V,中间只有 200mV 的窗口。电源上的电容容量决定了这个窗口有多长。我算过,100uF 电容在 10mA 负载下,200mV 能撑大约 2ms。2ms 内要写完一帧 32 字节,SPI 10MHz 下需要大约 30 微秒,加上中断响应和函数调用开销,总共不到 100 微秒,余量很足。
但前提是掉电中断的优先级要足够高,不能被其他中断打断。我把 LVD 中断设成最高优先级,并且在中断里关掉其他所有中断。
5. 常见问题与排查实录
5.1 SPI 通信失败的几种典型表现
调试 SPI 最直接的工具是逻辑分析仪,抓 CS、SCK、SI、SO 四根线,一眼就能看出问题。下面这张表是我这些年遇到过的典型故障和对应原因:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读出来全是 0xFF | SO 线没接好或 MRAM 没供电 | 量 VDD 电压,检查 SO 焊接 |
| 读出来全是 0x00 | CS 没拉低或 SCK 没输出 | 抓 CS 和 SCK 波形 |
| 数据错位一位 | SPI 模式不对,CKP/CKE 配错 | 对照手册改模式 0 或 3 |
| 偶发 CRC 错误 | 时钟太快或走线太长 | 降频到 5MHz 试试 |
| 写进去读出来不对 | 地址算错或写周期没等 | 检查地址对齐和 tWR |
| 上电后数据全丢 | 掉电时序不对或指针更新顺序错 | 检查写指针更新逻辑 |
5.2 一个真实的偶发故障排查过程
有个项目现场反馈,设备运行几天后数据记录出现断档,但重启后又正常。这种偶发问题最难查。我先在实验室复现,用继电器模拟现场的大电流负载,发现继电器动作时 SPI 读 MRAM 会偶发 CRC 错误。
用示波器抓 SCK 和 SO 波形,发现继电器动作瞬间,SCK 上叠加了大约 500mV 的尖峰噪声,导致 SO 上的数据被误采样。解决方案有三个:一是在 SCK 和 SO 上串 22 欧姆电阻,减缓边沿;二是在 MRAM 电源脚加一个 100nF 电容更靠近引脚;三是把 SPI 时钟从 10MHz 降到 5MHz。
我三个都做了,问题消失。后来分析,根本原因是 MRAM 的 PCB 位置离继电器太近,SPI 走线又比较长,形成了天线效应。工业板子上,存储芯片的布局和 SPI 走线的抗干扰设计,比软件配置重要得多。
5.3 MRAM 与 Flash 混用时的注意事项
有些项目里既有 MRAM 又有 SPI Flash,共用一组 SPI 总线。这时候 CS 的控制就特别关键。我见过一个案例,Flash 的 CS 和 MRAM 的 CS 接在同一个 GPIO 上,结果写 Flash 的时候 MRAM 也被选中,数据全乱。
正确的做法是每个 SPI 从设备独立 CS,主控在访问某个设备前,先确保其他设备的 CS 都是高电平。代码里我封装了一个SPI_SelectDevice(dev_id)函数,统一管理 CS 状态,避免手动操作出错。
另外,Flash 的擦除时间很长(几十毫秒到几秒),擦除期间 SPI 总线被占用,MRAM 的紧急写入会被阻塞。如果系统里有掉电紧急写入需求,MRAM 和 Flash 最好分到不同的 SPI 模块,或者至少保证 MRAM 的写入优先级高于 Flash 操作。
6. 性能实测与优化建议
6.1 读写速度实测数据
我在 10MHz SPI 时钟下做了实测,结果如下:
| 操作 | 数据量 | 耗时 | 说明 |
|---|---|---|---|
| 写单帧 | 32 字节 | 约 30 微秒 | 含命令和地址开销 |
| 读单帧 | 32 字节 | 约 30 微秒 | 含命令和地址开销 |
| 连续写 1000 帧 | 32KB | 约 30 毫秒 | 无擦除等待 |
| 连续读 1000 帧 | 32KB | 约 30 毫秒 | 无等待 |
| 写指针更新 | 4 字节 | 约 5 微秒 | 每 16 帧一次 |
这个速度在工业记录场景里绰绰有余。作为对比,同容量的 SPI Flash 写 32KB 需要先擦除一个扇区(约 50ms),再写入(约 10ms),总共 60ms 以上,而且擦除期间不能掉电。MRAM 的 30ms 是纯写入,没有擦除开销,也没有掉电风险窗口。
6.2 提升可靠性的几个实操技巧
第一个技巧是数据双备份。我在数据区末尾留了 4KB,专门存最近 128 帧的副本。主区写入的同时,副本区也写一份。读取时如果主区 CRC 错误,就从副本区恢复。这个做法牺牲了不到 1% 的容量,但可靠性提升明显。
第二个技巧是写指针三副本。写指针存在三个不同地址,更新时三个都写,读取时三个都读,取多数值。如果某个副本损坏,还能靠另外两个恢复。这个做法对 MRAM 来说成本很低,因为写入不费劲。
第三个技巧是定期自检。系统空闲时,主控随机读几帧数据做 CRC 校验,统计错误率。如果错误率超过阈值,就在日志里记录,提醒维护人员检查。这个自检不影响正常记录,但能提前发现潜在问题。
6.3 什么情况下不建议用这套方案
MRAM 虽好,但也不是万能的。如果你的项目对成本极度敏感,MRAM 的单价确实比 Flash 高不少,512KB 的 MR25H40CDF 比同容量 Flash 贵好几倍。这种情况下,如果写入频率不高(比如一天写几次),用 Flash 加磨损均衡也能凑合。
另外,如果数据量特别大,比如要存几 GB 的日志,MRAM 的容量和价格都不合适,还是得用 eMMC 或者 SD 卡。MRAM 的定位是中小容量、高频写入、高可靠性的场景,找准这个定位,它才能发挥最大价值。
我在实际项目里,一般是把最关键的配置参数、故障录波数据、掉电前的状态快照放在 MRAM 里,大块的日志和历史数据还是放 Flash 或者外部存储。这样既保证了关键数据的可靠性,又控制了整体成本。这个搭配思路,供你参考。