
1. 这个组合解决了什么问题从掉电保存的痛点说起在工业设备里你永远不知道下一次断电会发生在哪条指令之后。运行参数、计数值、校准系数、故障记录这些数据如果只放在 RAM 里一掉电就全没了如果放到 Flash 里你又担心擦写次数、擦除时间和“写一半掉电”的问题。我也遇到过类似的项目设备在生产过程中需要频繁写入运行状态又要求在非正常断电后上电能立刻恢复现场温度可能到 80℃供电还时不时被继电器、电机这类大负载拉出噪声。后来这个项目用了 MR25H40CDF 和 PIC18F46K22 这对组合才把数据存储这件事彻底理顺。这篇文章不是抄数据手册更多是我在实际嵌入式开发中把这个方案落地时的思路、电路、代码和调试记录。项目本身不复杂但真正能稳定跑在工业现场和“能在开发板上读写”完全不是一回事。如果你正在给设备选存储芯片或者手头已经有这两个器件但还没把时序调通可以参考一下。1.1 工业现场对数据存储的三个“反常识”要求第一个要求不是“容量大”而是“能扛住反复写”。很多控制器每隔几十毫秒就要存一次状态一天下来就是上百万次写入。普通 EEPROM 标称寿命通常只有 10 万到 100 万次页擦除型 Flash 虽然容量大频繁小数据写入不仅耗寿命还要处理擦除平衡和掉电丢数据的逻辑。MR25H40CDF 这类的 MRAM 写寿命能做到 10 的 12 次方量级在很多场景里基本可以认为“写不坏”。第二个要求是“写入时间确定”。工业控制中最怕的不是慢而是某一次写入突然特别慢。Flash 在擦除或者垃圾回收时可能出现几十毫秒的卡顿这对控制环路是灾难。MRAM 的写入没有擦除前置动作只要 SPI 时钟把数据移进去数据就已经是新的了没有“页编程完成等待”。第三个要求是“掉电瞬间数据还能是完整的”。注意这里不是“上电后恢复”而是掉电前最后几条记录要能落地。Flash 写一个页可能需要先擦后写掉电窗口很大MRAM 是字节级直接覆盖写配合电源检测可以在最后时刻把关键变量存进去。我整理了一张对比表方便你判断自己的项目到底适合哪种介质维度串行 EEPROM如 25LC256SPI NOR FlashMR25H40CDFSPI MRAM容量32KB 级数 MB 级512KB写寿命约 10^6 次约 10^5 次约 10^12 次写前是否要擦除不需要需要先擦后写不需要直接覆盖单字节/短数据写效率高很低要页操作高可任意地址写掉电保护难度中高低温度范围常见 -40~85℃常见 -40~85℃选工业级版本可达 105℃这个表不是否定 Flash如果你的数据是镜像、固件、历史文件Flash 依然合适。但如果是小体积、高频率、需要随时掉电保护的数据MRAM 的优势非常明显。1.2 MR25H40CDF 是什么SPI MRAM 的定位MR25H40CDF 是 Everspin 的 4Mbit 串行 MRAM换算一下就是 512KB 存储空间。它采用磁隧道结作为存储单元两个磁性层的相对方向决定存储 0 还是 1所以数据是靠磁状态保存的掉电不丢。写数据时内部电路切换自由层的磁化方向和 Flash 的电荷存储机理完全不同因此不需要擦除也没有写后延迟。在系统里看它的行为更像“掉电不丢的 SRAM”。你可以随便改任意字节不需要按页对齐不需要先读出来改写一整页也不需要维护坏块表。容量虽然没有 Flash 大但在工业参数存储、运行日志、故障记录这个量级上完全够用。MR25H40CDF 的“C”代表 3.3V 供电版本“DF”通常代表 DFN 封装。你拿到芯片后首先确认后缀对应的温度等级和封装工业项目首选工业级温度范围。它的 SPI 接口最高可以跑到几十兆赫兹但实际速度受 PIC18F46K22 的 SPI 外设限制通常工作在 1MHz~4MHz 就足够了。1.3 为什么搭配 PIC18F46K22而不是随便选一颗 MCU有人会问既然用 SPI 接口任何单片机都能接为什么是 PIC18F46K22我的理由有几点它自带 MSSP 硬件 SPI不需要用 GPIO 模拟时序稳定代码也简单。工作电压范围宽可以直接用 3.3V 供电和 MR25H40CDF 电平匹配省掉电平转换。内部振荡器在绝大多数串行存储场景下够用不需要外部晶振也能工作。引脚耐压和 I/O 结构在工业小信号处理上比较皮实EMC 表现我实测比很多 3.3V ARM 要省心。这颗芯片在工业小盒子、PLC 扩展模块、电力监控终端里非常常见长期供货和替代性都考虑过。当然如果你团队熟的是 STM32用 STM32 也一样能做底层逻辑完全相同。本文所有代码思路和电路设计都可以平移只是寄存器名字和引脚映射需要改一下。2. 硬件电路引脚、线序与电平匹配里的坑硬件部分看着简单就是 8 根脚的事但我见过太多人在这一环节翻车信号交叉接错、WP# 和 HOLD# 悬空、电平不匹配导致芯片烧掉。下面按我的实际接法展开。2.1 芯片引脚与典型接法MR25H40CDF 的 SPI 功能脚主要是 CS#、SCK、SI、SO加上 HOLD#、WP#、VDD、GND。先强调一个最容易绕晕的点芯片的 SI 是“串行输入”对应的是单片机的 MOSI/SDO 输出芯片的 SO 是“串行输出”对应的是单片机的 MISO/SDI 输入。我第一次画原理图时差点接反后来习惯在图纸上把信号方向和芯片内方向标清楚再拉线。以 PIC18F46K22 的 MSSP1 为例我默认引脚是信号MR25H40CDF 引脚PIC18F46K22 引脚说明片选CS#RC0任意 GPIO低有效空闲必须拉高时钟SCKRC3/SCK1外部时钟输入到 MRAM数据输入SIRC5/SDO1MCU 输出到 MRAM数据输出SORC4/SDI1MRAM 输出到 MCU保持HOLD#接 VDD经上拉不允许悬空写保护WP#接 VDD经上拉不需要动态保护时直接拉高电源VDD3.3V配合 100nF 去耦电容地GNDGND独立回地避免和功率地混走CS# 虽然可以任意接 GPIO但建议选一个复位期间是高阻的引脚。更重要的是在空闲时把 CS# 拉高否则 MRAM 会把 SO 输出使能和总线上其他器件打架。如果单片机复位瞬间 I/O 悬空CS# 又没有上拉芯片可能认为片选有效串行总线会莫名其妙被拉到一个不定的电平。我习惯在 CS# 引脚上外加一个 10kΩ 上拉电阻到 3.3V即便 MCU 还没初始化MRAM 也知道“没选我”。2.2 电平匹配3.3V 和 5V 系统怎么共存MR25H40CDF 的 VDD 是 3.3V不要指望它能 5V 供电。PIC18F46K22 的供电范围是 1.8V 到 5.5V所以最简单的办法是把整颗 MCU 也放到 3.3V 下。这样所有 SPI 信号都是 3.3V 电平完全不用电平转换芯片也省去分压电路。如果你当前系统已经有稳定的 5V那不能直接把 MRAM 接上去。两个选择MCU 继续用 5VSPI 信号通过 74LVC1T45 或类似电平转换芯片接到 MRAM。MCU 和 MRAM 都改到 3.3V 供电前提是系统的传感器、通信接口也能兼容 3.3V或者单独给这一路供电。我的观点是能整体降压就整体降压。工业现场干扰主要通过电源和地线传导混用 5V 和 3.3V 信号又加大意的话轻则数据错误重则芯片发热损坏。PIC18F46K22 在 3.3V 下跑 16MHz 内部振荡器没有问题满足大多数数据采集和控制场景。2.3 工业现场布线和去耦细节PCB 上 MRAM 离 MCU 越近越好不要用飞线拉到几十厘米远。SPI 时钟频率哪怕只有 1MHz长线上也会出现振铃特别是旁边有继电器、变频器的时候。我会在每条 SPI 信号线上靠近 MCU 输出端串 22Ω 到 33Ω 的小电阻不是必需但能有效降低过冲。电源去耦我习惯放两枚电容一枚 100nF 陶瓷电容放在 VDD 引脚旁边一枚 1uF 到 10uF 的电容放在电源入口。HOLD# 和 WP# 的上拉电阻也要靠近芯片让噪声不容易被耦合进去。如果芯片离 MCU 超过 5cm建议在 PCB 上加地线包络不要形成环形天线。3. 驱动代码PIC18F46K22 的 SPI 初始化与 MRAM 读写代码层面其实不复杂关键是把“片选”“命令”“地址”“数据”的顺序讲清楚。下面这段代码我在 XC8 环境下调试通过寄存器名称针对 PIC18F46K22 的 MSSP1。3.1 先把 MSSP 的引脚和时钟配置对PIC18F46K22 的 MSSP1 硬件 SPI 引脚固定映射是 RC3/SCK、RC4/SDI、RC5/SDO。RC3 和 RC5 是输出RC4 是输入。片选我用 RC0只当普通 GPIO。#define MRAM_CS LATCbits.LATC0 #define MRAM_CS_TRIS TRISCbits.TRISC0 void MRAM_SPI_Init(void) { // 1. 引脚方向 TRISCbits.TRISC3 0; // SCK1 输出 TRISCbits.TRISC5 0; // SDO1 输出 TRISCbits.TRISC4 1; // SDI1 输入 // 2. CS 引脚 MRAM_CS_TRIS 0; MRAM_CS 1; // 空闲高 // 3. MSSP 复位时先清错误标志 SSP1CON1bits.SSPOV 0; SSP1CON1bits.WCOL 0; // 4. SPI 主模式时钟 FOSC/16SCK 空闲低 // 0b00100001SSPEN1CKP0SSPM0001 SSP1CON1 0b00100001; SSP1STAT 0b00000000; // CKE0SMP0 }注意一个容易忽略的点MRAM 支持标准 SPI Mode 0 和 Mode 3但不同单片机寄存器里的 CKE/CKP 组合定义不一样。我上面的配置对应的是“SCK 空闲为低数据在下降沿变化在上升沿采样”的标准模式 0。如果你抄别人的代码发现时钟极性反了不要只改一个 CKP要看手册里 CKE 和 CKP 的配合。SPI 发送一个字节的函数是通用的static uint8_t SPI1_Exchange(uint8_t out) { SSP1BUF out; while (!SSP1STATbits.BF) { // BF 置位表示收到了一个字节 } return SSP1BUF; }这里的返回值是真正在 MISO 线上收到的字节。读 MRAM 时主机必须持续发送 0x00 来产生时钟读单字节和读连续数据唯一的区别就是在读数据期间是否继续给 SCK 时钟。3.2 读命令和写命令核心时序拆解MR25H40CDF 的读命令是 0x03后面跟 3 字节地址。由于是 4Mbit实际只有 19 位地址有效地址最高字节只用低 3 位。读数据流程是CS 拉低发送 0x03依次发送地址高字节、次高字节、低字节连续发送 0x00同时从 SO 读回数据CS 拉高代码如下uint8_t MRAM_ReadByte(uint32_t addr) { uint8_t val; MRAM_CS 0; SPI1_Exchange(0x03); SPI1_Exchange((uint8_t)((addr 16) 0x07)); SPI1_Exchange((uint8_t)(addr 8)); SPI1_Exchange((uint8_t)addr); val SPI1_Exchange(0x00); MRAM_CS 1; return val; }写命令稍微多一步必须先发 0x06 写使能让芯片内部 WEL 位置位然后再 CS 拉高重新 CS 拉低发 0x02、地址和数据。不能把 0x06 和 0x02 放在同一个 CS 低电平期间因为芯片要求 WREN 是一个独立片选周期。void MRAM_WriteByte(uint32_t addr, uint8_t val) { // 第一步写使能 MRAM_CS 0; SPI1_Exchange(0x06); // WREN MRAM_CS 1; // 第二步写数据 MRAM_CS 0; SPI1_Exchange(0x02); // WRITE SPI1_Exchange((uint8_t)((addr 16) 0x07)); SPI1_Exchange((uint8_t)(addr 8)); SPI1_Exchange((uint8_t)addr); SPI1_Exchange(val); MRAM_CS 1; }很多人会问写完之后要不要像 Flash 那样轮询状态寄存器不需要。MRAM 没有页编程等待最后一个数据位移入后数据就写进去了。但“不需要等待”不等于“不做校验”后面我会讲实际项目中怎么验证。3.3 连续读写别用单字节函数去刷 512KB如果你把一个一个大数组拆成单字节读写每字节都要重新发一遍地址和命令速度会低得离谱。正确做法是一次 CS 低电平期间连续读或连续写多个字节。连续写的示例void MRAM_WriteBlock(uint32_t addr, const uint8_t *buf, uint16_t len) { MRAM_CS 0; SPI1_Exchange(0x06); // WREN MRAM_CS 1; MRAM_CS 0; SPI1_Exchange(0x02); SPI1_Exchange((uint8_t)((addr 16) 0x07)); SPI1_Exchange((uint8_t)(addr 8)); SPI1_Exchange((uint8_t)addr); while (len--) { SPI1_Exchange(*buf); } MRAM_CS 1; }连续读类似只是命令先用 0x03后面每收一个字节就继续给一个 0x00 时钟。这里有一个细节必须提醒MR25H40C 系列的页写缓冲通常是按 256 字节页划分的。如果你的连续写跨越了页边界芯片内部地址计数器可能会绕回到当前页开头而不是自动继续下一页。所以在写长数据时我习惯在应用层做一次拆分判断保证每次写入的起始地址和长度不跨页。这不是难事写一个小的辅助函数就行void MRAM_WriteBlockSafe(uint32_t addr, const uint8_t *buf, uint16_t len) { while (len) { uint16_t left_in_page 256 - (uint16_t)(addr 0xFF); uint16_t chunk len; if (chunk left_in_page) chunk left_in_page; MRAM_WriteBlock(addr, buf, chunk); addr chunk; buf chunk; len - chunk; } }读操作同样建议按页边界拆虽然很多芯片在连续读时能跨页但拆页永远是最稳的做法。工业设备不是实验室没必要省这几行代码。4. 数据布局与掉电保护把存储抽象成三个区域芯片本身能读写只是第一步。真正让工业设备稳定的是“数据怎么放”“掉电时先写什么后写什么”“上电时怎么验证”。我把 512KB 的 MRAM 分成几个区域下面详细说明。4.1 配置区、日志区、变量区该怎么划我不会把整个 MRAM 当成一个大数组乱写。那样短期没问题长期维护时你不知道哪个地址是什么数据一旦程序升级换个数据结构旧的存储内容全都不兼容。建议是画出完整的内存地图放在代码注释里。配置区存放校准系数、设备参数、序列号、通信地址。这部分数据极少改但必须高可靠。我用头几个字节存放“配置区有效标志”和 CRC。每次固件升级后版本号变化就把配置区整体重写一遍。运行变量区存放当前运行模式、累计运行时间、断电前状态。这些变量可能在程序运行中被周期保存需要应对高频写入。日志区以环形队列方式记录故障事件、报警时间戳、操作记录。每条记录固定长度头尾指针单独存。具体的地址分配我没有统一标准但一定遵循“前 2KB 配置、中间 8KB 运行变量、剩下日志”这类设计。关键是不要把配置区和日志区混在一起否则日志写坏了配置数据整个设备参数就乱了。对于配置区我比较推荐“双备份”方案。即配置区里放两份完全相同的配置块每块前两个字节是 0xA5 和 0x5A随后是长度、内容、CRC。上电时先看 A 块CRC 不对就看 B 块两个都不对就恢复默认配置。代价是容量翻倍但换来的是配置数据几乎不可能全丢。4.2 掉电时刻的数据完整性BOR 与 MRAM 特性怎么配合MRAM 再快也架不住 MCU 在它写命令只执行到一半时突然断电。如果 CS 已经在低电平SCK 时钟中断芯片可能只收到半个指令这次写入会被拒绝。更危险的是主控程序跑到一半跳飞把地址写错到其他区域。我的做法是分三级防护第一级硬件电源检测。不要等 MCU 自己知道电压掉了而是用一颗简单的电压检测芯片监控 3.3V。当电压跌到阈值时产生一个下降沿信号给 PIC 的外部中断或 INT 引脚。在中断里只做一件事把最高优先级的运行变量写入 MRAM。因为 MRAM 不需要等待擦除一个中断里连续写 8~16 个字节足够。第二级软件启动校验。上电后不是立刻开始干活而是先对配置区和变量区做 CRC 检查。发现配置区坏了就用备份恢复发现变量区数据可疑则按默认值启动并记录一次“数据校验失败”事件。不要尝试把坏数据修得看起来正常工业设备里宁可牺牲一次运行数据也不能把错误数据当真。第三级日志写序。写一条新日志时我先在日志区写入记录内容最后再更新日志头部的写入索引。如果掉电发生最坏情况是日志区有一条不完整记录但索引还是旧的下次启动会覆盖掉这一条不会导致整个环形队列错乱。这个顺序看起来很简单但不少人会图省事先更新索引再写数据结果掉电后队列里出现一条“指向垃圾数据”的假记录。4.3 如何验证写进去的数据真的在MRAM 不像 Flash 需要读状态寄存器它写起来像 SRAM这容易让人产生“一定写成功了”的错觉。我吃过不少亏所以现在所有关键写操作后面都会跟着回读校验。最简单的校验方式是写后立即读回和你刚才写的字节比较int MRAM_WriteByteVerify(uint32_t addr, uint8_t val) { MRAM_WriteByte(addr, val); return (MRAM_ReadByte(addr) val) ? 0 : -1; }但注意单字节读回通过不等于你做对了。比如 SPI 模式反了可能你写 0xAA 读出 0x55编程上完全正确但逻辑上数据是反的。所以还应该做一组固定模式的测试写入多种数据组合再读回比对。对日志这种大块数据我不用逐个字节回读而是在写完后把整块记录读出来计算一次 CRC。这比逐字节比较更快也能覆盖总线噪声导致的随机位翻转。CRC16 的代码可以选 CCITT 多项式现场够用uint16_t Crc16Update(uint16_t crc, uint8_t data) { crc ^ data 8; for (uint8_t i 0; i 8; i) { if (crc 0x8000) crc (crc 1) ^ 0x1021; else crc 1; } return crc; }这个函数每次处理一个字节适合嵌入式项目。如果你存的是 Modbus 协议数据也可以用 Modbus CRC16反正原理一致重点是你上下电用的算法要统一。5. 实测数据与调试心得代码写完、板子焊好不代表能稳定跑了。我把自己实测到的速度数据和踩坑经历写下来帮你少走一点弯路。5.1 在 1MHz SPI 下的读写耗时我用 PIC18F46K22 的内部振荡器跑 16MHzSPI 时钟配置为 FOSC/16也就是 1MHz。在这种情况下读单字节发送命令 1 字节 地址 3 字节 读数据 1 字节一共 5 字节 40 个 SPI 时钟位理论耗时约 40us。写单字节WREN 一次 写命令一次也是约 40us。连续读写每字节需要 8 个时钟位约 8us。512KB 全部连续读大约 4.2 秒。这个速度对于工业参数保存和日志记录完全够用。如果你需要更快的吞吐可以把 SPI 时钟提高到 4MHz也就是把 SSP1CON1 的 SSPM 改成 FOSC/4。这时候单字节访问降到 10us 左右连续读写 512KB 大约 1 秒。但有一个点我会格外注意现场布线不够短时4MHz 的时钟沿会带来过冲和误采样。所以我宁可在生产项目里用 1MHz 换稳定性。MRAM 本身的最高时钟频率远高于这个瓶颈在 PCB 布局和 MCU 内部时钟精度不在芯片。5.2 我踩过的几个坑CS 悬空、WP/HOLD、SPI 模式第一次打板回来我把 HOLD# 和 WP# 悬空了。结果读数据时好时坏有时候一次读出来的是一串 0xFF有时候中间夹杂几个错误字节。查了很久才明白是 HOLD# 引脚被干扰拉低芯片进入保持模式停止输出。解决办法很简单HOLD# 和 WP# 都通过 10kΩ 电阻拉到 VDD。以后做任何 SPI 存储芯片这几个“看似多余”的功能脚我都会查一遍数据手册绝不悬空。第二个坑是 SPI 模式。我一开始照抄了一个 STM32 的示例把 CPOL/CPHA 配成了 Mode 3。结果读出来的数据一半是对的一半是乱的而且不是固定某一位错。后来用示波器看到 SCK 空闲是高电平才意识到是时钟极性问题。PIC18F46K22 的 MSSP 里 CKP 和 CKE 的配合方式和很多 ARM 芯片不一样不能拿到寄存器位就乱填。我的建议是拿到板子后先用逻辑分析仪抓一次 CS、SCK、SI 波形确认 SCK 空闲电平和数据变化沿再去改软件。第三个坑是 CS 上拉。前面硬件部分提过CS 悬空会导致单片机复位期间 MRAM 误选。这个现象特别难查因为程序刚跑起来时初始化代码会把 CS 拉高看起来一切都好。但如果复位或看门狗复位频繁MRAM 可能在 MCU 重构端口方向时短暂被选导致 SO 线上有驱动干扰相邻外设。加上 10kΩ 上拉后再没出现过。5.3 用示波器判断驱动是否正常的方法如果没有逻辑分析仪用一台双通道示波器也能定位大部分问题。我把探头分别接在 SCK 和 CS 上触发条件设为 CS 下降沿然后执行一次单字节读。波形应该满足几个特征CS 为低时SCK 输出稳定的一连串脉冲SI 线上先看到 0x03然后看到地址的二进制位SO 线上的数据不是从第一个 SCK 就开始有效而是在地址位之后的某个边沿才出现。如果你仅仅看 SO 不结合 SCK很难判断数据是否对齐。再有一个小技巧在 MISO 线上串一个 1kΩ 电阻再进 MCU不影响读写但万一程序把一个本应输出的引脚错误配成输入也不会互相硬推。工业现场插拔排线时这个电阻还能给引脚提供一点保护。做过几次完整的掉电测试后我对这套方案最有信心的部分反而不是“能存多少”而是“每次掉电都在最后关头写完关键数据”。你不需要担心 MRAM 写坏了也不需要等待擦除时间只需把注意力放在分区设计和启动校验上。如果你的项目也是频繁存参数、掉电不丢、环境又比较恶劣MR25H40CDF 配 PIC18F46K22 是一个值得认真考虑的选项。