
1. 项目缘起与方案选型为什么是 MRAM 加 PIC18F96J94工业现场的数据记录仪、电力监测终端、医疗设备日志模块这类场景有一个共同的痛点数据必须频繁写入掉电不能丢而且设备寿命动辄十年起步。传统方案里EEPROM 擦写次数只有百万级铁电存储器容量又上不去带电池的 SRAM 还得考虑电池更换和环保问题。我前几年做过一个配电柜里的电能质量记录仪客户要求每秒采样一次、每 100ms 落一次盘算下来一天就是 86 万次写入EEPROM 根本扛不住三个月就报废了一批板子。后来接触到MR25H40CDF这颗 MRAM磁性随机存储器才算把这个问题彻底解决。它的核心优势在于写入次数近乎无限10^14 级别、写入速度是纳秒级、掉电数据保持 20 年以上、而且不需要擦除操作。你可以把它理解成一个既能像 RAM 一样随便写、又像 Flash 一样断电不丢的存储介质。MR25H40CDF 的容量是 4Mbit也就是 512KB通过SPI 接口通信最高时钟可以跑到 40MHz对于绝大多数嵌入式数据记录场景来说绰绰有余。那为什么主控选PIC18F96J94这颗 8 位单片机在工业圈子里口碑很稳它有 96KB Flash、3936 字节 RAM自带硬件 SPI 模块MSSP还有 16 通道 10 位 ADC、多个定时器和 UART。关键是它的工作温度范围是 -40°C 到 85°C抗干扰能力强供货周期稳定价格也合适。对于不需要跑操作系统、只需要稳定采集和存储的中低复杂度工业设备来说PIC18F96J94 加 MR25H40CDF 这个组合是我实测下来性价比和可靠性都很均衡的一套方案。这篇文章我会把整个项目的设计思路、硬件连接、SPI 驱动编写、MRAM 读写时序、数据管理策略、以及我在实际调试中踩过的坑全部拆开讲清楚。不管你是刚接触嵌入式的新手还是想找一颗靠谱非易失存储器的老工程师都能从里面找到可以直接抄作业的东西。2. 硬件设计MR25H40CDF 与 PIC18F96J94 的 SPI 连接细节2.1 MR25H40CDF 引脚定义与封装选择MR25H40CDF 常见的是 8 引脚 SOIC 封装引脚定义如下引脚编号名称功能说明1CS片选低电平有效2SO数据输出MISO3WP写保护低电平有效4VSS地5SI数据输入MOSI6SCK时钟7HOLD保持低电平有效8VDD电源2.7V~3.6V这里有几个细节值得展开说。WP 引脚在普通 SPI Flash 上通常用来做硬件写保护但 MRAM 的 WP 行为略有不同——它是在状态寄存器里配合使用的实际项目中我一般直接把 WP 拉高禁用写保护靠软件层面控制写入权限避免误操作。HOLD 引脚用于暂停当前 SPI 通信多主机或者需要分时复用总线时才用得上单主机场景直接拉高即可。注意MR25H40CDF 的供电范围是 2.7V~3.6V如果你用的是 5V 系统必须加电平转换。我见过有人直接把 5V 单片机的 SPI 接到 MRAM 上结果芯片发热、数据错乱最后整批板子返工。2.2 PIC18F96J94 的 MSSP 模块配置PIC18F96J94 的 MSSP 模块支持 SPI 和 I2C 两种模式我们这里用 SPI 主模式。相关寄存器主要有四个SSPCON1控制寄存器设置 CKP、SSPM 等位SSPCON2附加控制主模式下主要用不到SSPSTAT状态寄存器反映 BF、SMP、CKE 等状态SSPADD在 SPI 主模式下用来设置时钟分频SPI 模式选择通过 SSPCON1 的 SSPM3:SSPM0 四位来配置。主模式、时钟极性空闲为低、时钟边沿为上升沿采样的典型配置是SSPM 0b0000Fosc/4到0b0011TMR2 输出。我一般用 Fosc/4 或者 Fosc/16具体看主频和 MRAM 的时序余量。时钟极性CKP和时钟边沿CKE的配合决定了数据在哪个边沿采样。MR25H40CDF 支持 SPI Mode 0CPOL0, CPHA0和 Mode 3CPOL1, CPHA1。我习惯用Mode 0因为逻辑分析仪抓波形时最直观SCK 空闲低电平数据在上升沿采样、下降沿变化。2.3 硬件连接与 PCB 布局要点实际连线很简单PIC18F96J94 MR25H40CDF RC3 (SCK) ---- SCK (pin 6) RC5 (SDO) ---- SI (pin 5) RC4 (SDI) ---- SO (pin 2) RA5 (CS) ---- CS (pin 1) 3.3V ---- VDD (pin 8) GND ---- VSS (pin 4) 3.3V ---- WP (pin 3) 3.3V ---- HOLD(pin 7)PCB 布局上有几个经验点。SCK 走线尽量短如果超过 10cm建议在 SCK 上串一个 22Ω~33Ω 的电阻做阻抗匹配抑制过冲。CS 信号要干净因为 MRAM 对片选毛刺很敏感我遇到过因为 CS 走线经过一个继电器驱动区域导致偶发写入错误的情况后来在 CS 上加了一个 100pF 对地电容才解决。去耦电容必须放在 VDD 引脚旁边0.1μF 加 1μF 组合距离不超过 3mm。提示MRAM 虽然抗辐射、抗磁场能力比 Flash 强但它的写入过程依赖内部磁场翻转所以在大电流走线或者电感附近布局时还是要留出至少 5mm 的间距。3. SPI 驱动层实现从寄存器操作到读写函数封装3.1 MSSP 初始化代码与参数计算先看初始化代码我用的是 XC8 编译器void SPI_Init(void) { TRISC3 0; // SCK 输出 TRISC5 0; // SDO 输出 TRISC4 1; // SDI 输入 TRISA5 0; // CS 输出 CS 1; // 初始拉高 SSPCON1 0x00; // 先复位 MSSP SSPSTAT 0x40; // SMP0, CKE0Mode 0 SSPCON1 0x20; // SSPM0000, Fosc/4, 主模式 SSPADD 0; // 主模式下不用 SSPEN 1; // 使能 MSSP }这里SSPSTAT 的 SMP 位决定采样时刻SMP0 表示在数据输出时间的中间采样SMP1 表示在末尾采样。高速通信时用 SMP0 更稳。CKE 位决定时钟边沿CKE0 表示 SCK 从空闲到有效边沿时传输数据。时钟频率计算如果 PIC18F96J94 主频是 40MHz10MHz 晶振 × 4 倍频Fosc/4 就是 10MHzSPI 时钟就是 10MHz。MR25H40CDF 最高支持 40MHz所以 10MHz 完全在安全范围内。如果你想跑更快可以把 SSPM 设成 0b0001Fosc/16反而更慢要更快得用 Fosc/4 或者外部时钟源。3.2 字节收发函数与 CS 控制SPI 是全双工发一个字节的同时会收到一个字节。基础收发函数unsigned char SPI_Exchange(unsigned char data) { SSPBUF data; while(!BF); // 等待接收完成 return SSPBUF; }注意这里有个坑BF 标志在发送完成后就会置位但如果你连续调用必须先读走 SSPBUF 才能清 BF。我早期写过一个版本忘了读 SSPBUF结果第二次调用直接卡死在 while 循环里。CS 控制我封装成宏#define CS_LOW() (CS 0) #define CS_HIGH() (CS 1)MRAM 的 CS 建立时间和保持时间要求分别是 5ns 和 5nsPIC 的 GPIO 翻转速度完全满足不需要额外延时。但如果你用的是更高速的 MCU比如跑 200MHz 的 ARM那就得在 CS 拉低后加几个 NOP。3.3 MR25H40CDF 指令集解析MR25H40CDF 的指令集和普通 SPI Flash 类似但有几个关键区别指令名称指令码功能与 Flash 的区别WREN0x06写使能相同WRDI0x04写禁止相同RDSR0x05读状态寄存器相同WRSR0x01写状态寄存器相同READ0x03读数据相同WRITE0x02写数据无需擦除RDID0x9F读设备 ID相同最大的区别就是WRITE 指令可以直接覆盖写入不需要先擦除。Flash 必须先擦除整个扇区才能写而 MRAM 是字节级可寻址写哪个字节就改哪个字节。这直接简化了驱动层逻辑也避免了擦除导致的写入放大和寿命损耗。状态寄存器的 bit0 是 WEL写使能锁存bit1 是 BP0bit2 是 BP1bit7 是 SRWD。每次写入前必须先发 WREN写入完成后 WEL 自动清零。这个机制和 Flash 一样是为了防止总线噪声导致的误写入。4. 数据读写实操完整流程与关键代码4.1 单字节写入流程写一个字节到指定地址的完整流程void MRAM_WriteByte(unsigned long addr, unsigned char data) { CS_LOW(); SPI_Exchange(0x06); // WREN CS_HIGH(); CS_LOW(); SPI_Exchange(0x02); // WRITE SPI_Exchange((addr 16) 0xFF); SPI_Exchange((addr 8) 0xFF); SPI_Exchange(addr 0xFF); SPI_Exchange(data); CS_HIGH(); // 等待写入完成 while(MRAM_IsBusy()); }地址是 19 位512KB 2^19所以需要三个字节传输高 5 位是无效的但 MRAM 会忽略。WREN 和 WRITE 之间必须拉高 CS这是很多新手容易漏掉的一步。我见过有人把 WREN 和 WRITE 放在同一个 CS 低电平周期里结果写入完全没反应。4.2 页写入与批量传输MR25H40CDF 支持页写入每页 256 字节。如果你要连续写多个字节可以一次性发完地址会自动递增但不能跨页。跨页时地址会回绕到页首覆盖之前的数据。void MRAM_WritePage(unsigned long addr, unsigned char *buf, unsigned int len) { unsigned int page_remain 256 - (addr 0xFF); if(len page_remain) len page_remain; // 不跨页 CS_LOW(); SPI_Exchange(0x06); CS_HIGH(); CS_LOW(); SPI_Exchange(0x02); SPI_Exchange((addr 16) 0xFF); SPI_Exchange((addr 8) 0xFF); SPI_Exchange(addr 0xFF); for(unsigned int i 0; i len; i) SPI_Exchange(buf[i]); CS_HIGH(); while(MRAM_IsBusy()); }实测下来10MHz SPI 时钟下写 256 字节大约需要 210μs其中指令开销约 5μs数据传输约 205μs。这个速度对于每秒采样一次的应用来说占用 CPU 时间不到 0.03%。4.3 读取操作与连续读模式读取比写入简单不需要 WRENvoid MRAM_Read(unsigned long addr, unsigned char *buf, unsigned int len) { CS_LOW(); SPI_Exchange(0x03); SPI_Exchange((addr 16) 0xFF); SPI_Exchange((addr 8) 0xFF); SPI_Exchange(addr 0xFF); for(unsigned int i 0; i len; i) buf[i] SPI_Exchange(0x00); CS_HIGH(); }READ 指令支持连续读取地址自动递增可以一直读到 512KB 末尾然后回绕。如果你要读整个芯片直接发一次指令然后连续读 524288 次就行不需要每读一页重新发指令。4.4 状态轮询与忙等待优化MRAM 的写入周期非常短典型值 100ns 左右但状态寄存器的 WIP 位bit0还是会短暂置位。我的MRAM_IsBusy()实现unsigned char MRAM_IsBusy(void) { unsigned char status; CS_LOW(); SPI_Exchange(0x05); status SPI_Exchange(0x00); CS_HIGH(); return (status 0x01); }实际测试中WIP 位在 10MHz SPI 下几乎看不到置位因为写入太快了。但为了代码的健壮性我还是保留了这个轮询。如果你追求极致性能可以改成固定延时 1μs省掉状态读取的开销。实操心得MRAM 的写入寿命虽然近乎无限但读取操作不会影响寿命所以你可以放心地频繁读取。真正需要注意的是写入时的电源稳定性如果 VDD 在写入过程中跌落超过 0.1V可能导致写入数据错误。我在电源上加了一个 100μF 的钽电容做储能实测在 3.3V 掉到 2.7V 的 10ms 跌落过程中写入依然可靠。5. 数据管理策略如何组织 512KB 存储空间5.1 分区设计与地址映射512KB 看起来不大但如果只是简单地从地址 0 开始顺序写很快就会写满。我在项目里通常把 MRAM 分成几个区域区域名称起始地址大小用途配置区0x000004KB设备参数、校准系数日志区0x01000480KB循环记录采样数据索引区0x7900016KB记录每条日志的起始地址和时间戳保留区0x7D00012KB固件升级备份、故障快照配置区用双备份加 CRC 校验写入时先写备份区再写主区读取时对比两个区域不一致就用 CRC 正确的那个。日志区用环形缓冲区写满后从头覆盖索引区记录当前写指针和有效数据范围。5.2 环形缓冲区实现与写指针管理环形缓冲区的核心是两个指针写指针和读指针。写指针指向下一个要写入的位置读指针指向最旧的有效数据。当写指针追上读指针时说明缓冲区满了需要覆盖最旧的数据。typedef struct { unsigned long write_ptr; unsigned long read_ptr; unsigned long start_addr; unsigned long end_addr; } RingBuffer_t; void RingBuffer_Write(RingBuffer_t *rb, unsigned char *data, unsigned int len) { for(unsigned int i 0; i len; i) { MRAM_WriteByte(rb-write_ptr, data[i]); rb-write_ptr; if(rb-write_ptr rb-end_addr) rb-write_ptr rb-start_addr; if(rb-write_ptr rb-read_ptr) { rb-read_ptr; if(rb-read_ptr rb-end_addr) rb-read_ptr rb-start_addr; } } }这里有个性能问题逐字节写入效率太低。每次写一个字节都要发 WREN、WRITE、地址、数据指令开销占了 80% 以上。优化方法是攒够一页再写void RingBuffer_WriteBulk(RingBuffer_t *rb, unsigned char *data, unsigned int len) { while(len 0) { unsigned int page_remain 256 - (rb-write_ptr 0xFF); unsigned int chunk (len page_remain) ? len : page_remain; MRAM_WritePage(rb-write_ptr, data, chunk); rb-write_ptr chunk; data chunk; len - chunk; if(rb-write_ptr rb-end_addr) rb-write_ptr rb-start_addr; } }实测下来页写入比逐字节写入快 8 倍以上。10MHz SPI 下写 480KB 数据逐字节需要约 4.8 秒页写入只需要约 0.6 秒。5.3 掉电保护与数据完整性工业设备最怕的就是掉电时正在写入导致数据半新半旧。MRAM 本身写入是原子的一个字节要么写成功要么没写不存在中间状态。但如果你一次写多个字节掉电可能导致部分字节写入了、部分没写入。我的做法是加一个写入完成标志。每条记录前面加 4 字节的头部包含记录长度、CRC16 和一个状态标志。写入时先把状态标志设为 0xAA表示正在写写完后改成 0x55表示写入完成。读取时只认状态为 0x55 的记录遇到 0xAA 就认为这条记录不完整跳过。typedef struct { unsigned char status; // 0xAA写入中, 0x55完成 unsigned char length; unsigned short crc; unsigned char data[60]; } LogRecord_t;这个 64 字节的记录结构刚好对齐读取时一次读 64 字节效率很高。CRC16 用查表法计算在 PIC18F96J94 上大约 20μs 就能算完。6. 常见问题与排查技巧实录6.1 SPI 通信失败排查表现象可能原因排查方法解决方案读 ID 返回 0x00 或 0xFFCS 未拉低或接线错误用逻辑分析仪抓 CS、SCK、MOSI检查 CS 引脚定义和接线读 ID 正确但写入无效未发 WREN 或 WREN 后未拉高 CS抓 WREN 和 WRITE 之间的 CS 波形确保 WREN 和 WRITE 分属两个 CS 周期写入后读回数据错位地址字节顺序错误检查地址发送顺序高字节在前低字节在后偶发数据错误电源噪声或 CS 毛刺示波器看 VDD 和 CS 波形加去耦电容CS 加滤波高速通信时出错时钟太快或采样点不对降低 SPI 时钟测试调整 SMP 和 CKE 位6.2 我踩过的三个典型坑第一个坑WP 引脚悬空。早期画板子时觉得 WP 用不到就没接结果发现偶尔写入失败。后来查数据手册才知道WP 悬空时内部电平不确定可能随机进入写保护状态。WP 必须明确拉高或拉低不能悬空。第二个坑HOLD 引脚误触发。有一次把 HOLD 接到了另一个 SPI 从设备的 CS 上想共用总线结果两个设备互相干扰。HOLD 拉低时会暂停当前通信如果时序配合不好MRAM 会丢失正在传输的数据。单主机场景下 HOLD 直接拉高不要想着省引脚。第三个坑跨页写入。写 300 字节数据时没注意页边界结果从地址 0x0F0 开始写写到 0x100 时地址回绕到 0x000把配置区覆盖了。写入前必须计算页剩余空间跨页时分多次写。6.3 逻辑分析仪抓波形实战调试 SPI 最有效的工具就是逻辑分析仪。我一般抓四根线CS、SCK、MOSI、MISO。看波形时重点检查CS 拉低到第一个 SCK 边沿之间是否有足够建立时间至少 5nsSCK 空闲电平是否符合 Mode 0低电平数据在 SCK 上升沿是否稳定CS 拉高到下一个 CS 拉低之间是否有足够时间至少 5ns有一次遇到写入偶发失败抓波形发现 CS 在 SCK 还在翻转时就拉高了导致最后一个 bit 没传完。后来在 CS 拉高前加了一个while(!BF)等待问题解决。提示如果你没有逻辑分析仪可以用单片机的一个空闲 IO 做软件示波器在关键节点翻转电平用示波器看时序。虽然精度不高但排查 CS 和 SCK 的配合问题足够了。7. 性能实测与优化建议7.1 不同 SPI 时钟下的读写速度对比我用 PIC18F96J94 在 40MHz 主频下做了实测结果如下SPI 时钟写 256 字节耗时读 256 字节耗时写 480KB 耗时2.5MHz830μs820μs1.56s5MHz420μs410μs0.79s10MHz215μs205μs0.40s20MHz110μs105μs0.21s20MHz 时已经接近 MR25H40CDF 的极限波形开始出现轻微过冲但数据依然可靠。如果你要跑 20MHz 以上建议在 SCK 上串 33Ω 电阻并缩短走线。7.2 降低功耗的几种手段工业设备很多是电池供电或者低功耗要求MRAM 的待机电流只有 10μA 左右但 SPI 通信时电流会升到 5mA。降低功耗的方法降低 SPI 时钟2.5MHz 时电流约 2mA10MHz 时约 5mA减少写入次数攒够一页再写避免频繁的小数据写入空闲时拉高 CSCS 低电平时 MRAM 处于选中状态功耗略高使用 HOLD 引脚不通信时拉低 HOLDMRAM 进入低功耗状态我实测过在每秒写一次、每次 64 字节的场景下MRAM 的平均功耗约 0.3mA加上 PIC18F96J94 本身的 2mA整机功耗可以控制在 3mA 以内用 2000mAh 电池可以撑 600 小时以上。7.3 与 EEPROM、Flash 方案的对比总结特性MR25H40CDF (MRAM)24LC512 (EEPROM)W25Q64 (Flash)容量512KB64KB8MB写入次数10^1410^610^5写入速度纳秒级毫秒级毫秒级需擦除擦除需求不需要不需要必须擦除掉电保持20年200年20年接口SPII2CSPI单价较高低低MRAM 的单价确实比 EEPROM 和 Flash 高但如果你算上十年内不需要更换、不需要擦除管理、不需要电池这些隐性成本综合成本反而更低。我那个配电柜项目用 EEPROM 方案三年换了两次板子用 MRAM 方案跑了五年零故障。8. 项目扩展与个人体会这套 MRAM 加 PIC18F96J94 的方案后来我还扩展到了几个不同的场景。一个是医疗输液泵的日志模块要求记录每次输液的速度、剂量和时间数据不能丢MRAM 的掉电保持特性正好满足。另一个是车载数据记录仪工作温度范围宽振动大MRAM 没有机械部件抗振动能力比 SD 卡强太多。如果你想把容量再做大可以并联多颗 MR25H40CDF用不同的 CS 引脚片选软件上做一个简单的地址映射层。四颗就是 2MB八颗就是 4MB对于绝大多数工业记录场景都够用了。最后分享一个我在调试中总结的小技巧每次上电后先读一次设备 IDRDID 指令确认返回 0x18 0x20 0x00MR25H40CDF 的 JEDEC ID。如果读不到说明 SPI 通信有问题直接进入错误处理不要继续初始化。这个简单的检查帮我省了很多现场排查的时间因为很多故障其实是接线松动或者 CS 引脚虚焊读 ID 就能立刻定位。另外如果你在用 XC8 编译器记得把 SPI 相关函数放在#pragma指定的代码段里避免编译器优化导致时序错乱。我遇到过-O2优化下SPI_Exchange被内联后BF 等待循环被优化掉的情况后来加了volatile修饰 SSPBUF 才解决。这些细节看起来不起眼但在实际项目里往往就是它们决定了你能不能按时交付。