1. 为什么最后选了这对组合MR25H40CDF 与 PIC32MX675F256L 的配套逻辑前段时间帮客户更新一台产线检测设备主控是 PIC32MX675F256L外部存储这块起初用的是一颗 64Kbit 的串行 EEPROM。设备每 50ms 要记录一组工艺参数还要在掉电前把当时的运行状态、报警码和最近几十条生产记录一次写走。EEPROM 的问题很明显容量不够而且字节写速率摆在那里掉电瞬间根本没时间把一条完整记录写完。后来我把目光放到 Everspin 的 MR25H40CDF 上这是一颗 SPI 接口的 4Mbit MRAM也就是 512KB 的非易失磁性存储。刚看这个容量和接口不觉得有什么特殊但真正用过之后才发现它的价值并不在于比 EEPROM 大而在于写的方式完全不同。这篇文章就把我在 PIC32 上驱动它、并且跑完掉电保存测试的完整过程写出来包含原理图注意点、寄存器级驱动代码、踩过的坑以及最后怎么规划这 512KB。1.1 现场真正的矛盾不是容量而是写入时机先说需求背景。设备正常运行时每 50ms 产生一条记录每条大概 32 字节。这个速率在很多时候是突发性的比如某段工艺过程持续 5 分钟就会疯狂往内存里塞记录。如果直接写 Flash需要先把扇区擦掉再写擦除一次可能几十毫秒完全做不到实时落盘。如果写 EEPROM虽然能字节写但连续写多个字节时也要处理页缓冲和内部写周期速度不够用。MRAM 解决这个问题的方式是写操作就是普通 SPI 写命令写完就完事没有擦除等待没有页编程时间更不需要像 Flash 那样维护坏块表。所以我在内存里维护一个环形缓冲区每攒够 128 字节或者 256 字节就直接写入 MRAM整个过程在几百微秒内完成不会阻塞主循环。另外一点很实际掉电保存。设备主控检测到电压跌落以后留给软件的时间通常只有几毫秒。这时候如果要保存 2KB 的关键数据EEPROM 可能需要几十毫秒Flash 更不用说MRAM 则可以做到发命令、写数据、拉高片选、收工。没有任何内部写周期要等待这是工业现场最看重的特性。1.2 把 MRAM 和 Flash、EEPROM、FRAM 放在一张表里对比我见过不少工程师在选型时习惯性用外部 SPI NOR Flash理由是便宜、容量大。但在频繁写入 掉电保存 中等容量这个场景下Flash 的三个短板会非常致命擦除慢、寿命有限、写入前必须处理对齐。FRAM 也不错但大容量 SPI FRAM 目前可选型号不如 MRAM 丰富而且我在这颗 MCU 的 SPI 资源下没找到特别合适的引脚兼容方案。下面这张表是我实际评估时用的直接列在项目评审文档里存储类型写入方式整片/扇区擦除读写寿命掉电写入窗口写速度SPI NOR Flash先擦后写需要10 万次量级很紧张周期长慢页编程等待串行 EEPROM字节写不需要100 万次量级尚可一般SPI FRAM字节写不需要100 万亿次量级好快SPI MRAMMR25H40CDF字节写/连续写不需要可视为无限非常好快MRAM 的写入耐久性很高数据手册上给的寿命基本可以理解为写入次数对寿命没有实际限制。这意味着我可以放心地把运行参数、计数器和日志全部放进去不用设计磨损均衡。相比之下Flash 哪怕做了均衡也始终要面对坏块管理、掉电时的元数据损坏这些麻烦。1.3 PIC32MX675F256L 在这里的角色PIC32MX675F256L 属于 Microchip 的 32 位 MIPS 内核系列片上有 256KB Flash、64KB RAM还有多个 SPI 模块做产线设备的主控非常合适。我选择它不是因为性能参数有多夸张而是因为老产品已经在用它固件成熟而且它能在 3.3V 下直接与 MR25H40CDF 对接不需要额外电平转换。它的 SPI 外设让我比较满意的一点是发送和接收都有硬件缓冲配合简单轮询就能跑 10MHz 以上不需要上系统、不需要上 DMA 也能满足本项目的数据吞吐。如果以后记录频率翻倍再考虑用 DMA 中断驱动接口我一开始就按地址、缓冲、长度这种通用形式封装后面换实现不影响上层。2. 硬件连接原理图、引脚规划与 SPI 信号完整性硬件这部分看着简单五根线而已但我在测试中被 HOLD# 引脚坑过一次所以还是把细节展开讲。2.1 引脚接线与片选规划MR25H40CDF 的标准 SPI 引脚是 SCK、SI、SO、CS#另外还有 WP# 和 HOLD#。PIC32MX675F256L 与它的连接关系我整理成下面这张表MR25H40CDF 引脚PIC32MX675F256L 引脚说明SCKSCK1SPI 时钟输出SISDO1主输出从输入SOSDI1主输入从输出CS#普通 GPIO例如 RB0软件控制片选WP#3.3V 或 GPIO拉高允许写入拉低保护HOLD#3.3V 通过上拉电阻不能悬空否则可能误暂停VDD3.3V必须加去耦电容GNDGND单点接地星型布线片选我建议用一个独立的 GPIO不要和别的 SPI 从机共用 CS#也不要接到固定低电平。原因很简单软件需要完全控制 CS# 的时序读写命令发出后再拉高如果 CS# 一直是低芯片可能把后续无关字节当成命令一部分处理。2.2 SPI 模式、速率与上下拉处理MR25H40CDF 支持 SPI Mode 0 和 Mode 3这两者都可以工作。PIC32MX675F256L 的 SPI 模块配置里要注意 CKE 和 CKP 这两个位很多人在这里栽过跟头。Mode 0 对应 CPOL0、CPHA0Mode 3 对应 CPOL1、CPHA1。我实际用的是 Mode 0下面是基于 PIC32 寄存器的初始化思路// GPIO 和重映射请按数据手册先把 SCK/SDI/SDO 分配到对应引脚 // 初始化 PBCLK 之后先关 SPI再配置 SPI1CONbits.ON 0; // 先关避免配置过程中产生非法时序 // 主模式 SPI1CONbits.MSTEN 1; // Mode 0CKP 0, CKE 0 // 注意不同系列芯片里 CKE 的极性含义不完全一样 // 务必对照 PIC32MX675F256L 数据手册里的 SPI 时序图核对 SPI1CONbits.CKP 0; SPI1CONbits.CKE 0; // 波特率分频先按 1MHz 左右调试后续再提速 // PPRE 和 SPRE 的组合取决于你的 PBCLK 频率 // SPI1BRG 或分频位以数据手册表格为准 SPI1CONbits.ON 1; // 开启 SPI调试的时候我建议先把速率压到 1MHz确认读写正常后再提高。高速下如果波形边沿不够陡传输距离稍微长一点就会出错往往表现为偶发的读回字节错误这种问题排查起来非常折腾。上拉电阻方面CS#、SCK、SI 在 MCU 驱动为主时不需要额外上拉但 SO 是芯片输出在片选拉低之前处于高阻态如果旁边没有上拉读命令时会先读到不定电平。我一般会在 SDO 到 MRAM 的 SO 这条线上加 10K 上拉到 3.3V确保空闲状态稳定。HOLD# 必须上拉这个引脚在低电平时会暂停通信如果悬空静电或噪声一干扰芯片就处于暂停状态表现为偶尔卡死。2.3 电源、去耦与防误写入这颗 MRAM 工作在 3.3V工业现场电源纹波比较大时一定要在芯片电源脚旁边放 0.1uF 陶瓷电容并且尽量靠近引脚。如果实在走得远我习惯再加一个 4.7uF 的钽电容做二级滤波。别小看这个动作SPI 写入过程中如果电源瞬间跌落虽然 MRAM 有非易失特性但总线电平可能紊乱导致误写入错误数据。防误写入还有两道保险。第一道是 WP# 引脚如果系统里根本没有程序需要改写存储内容可以直接接地强制写保护。第二道是软件层面的写使能锁存位WEL后续驱动里会讲。工业环境里我最担心的是程序跑飞后误操作 GPIO 导致 SPI 发送随机数据所以正常运行时写完数据立刻发 WRDI 关闭写使能能减少一多半风险。3. 驱动层实现从 SPI 收发器到 MRAM 命令集驱动这部分我用的是裸机轮询方式没有依赖 Harmony 的组件库。原因是这个项目硬件资源足够简单轮询代码可读性更强而且后期换成别的 MCU 平台时移植成本低。3.1 一个最小 SPI 收发函数PIC32 的 SPI 发送和接收是同一个缓冲区写入 SPI1BUF 后等待接收标志置位读出的字节就是 MISO 上的数据。这个函数是基础uint8_t spi_xfer(uint8_t out) { SPI1BUF out; while (!(SPI1STATbits.SPIRBF)) { // 等待接收完成 } return SPI1BUF; }这里有个细节读 SPI1BUF 时会把 SPIRBF 清掉所以在高速连续传输时要注意不能漏读。轮询方式下每一字节都要等待单个字节开销略大但稳定。后面如果想要更高吞吐可以改成 FIFO 或 DMA但核心命令格式不用变。3.2 读、写、状态寄存器操作MR25H40CDF 的命令列表和常见 SPI Flash 很接近基本命令包括 READ、WRITE、WREN、WRDI、RDSR。具体操作码如下命令操作码功能WREN0x06写使能写入前必须先发WRDI0x04写禁止清除 WELRDSR0x05读状态寄存器READ0x03读数据WRITE0x02写数据读数据的驱动代码void mram_read(uint32_t addr, uint8_t *buf, uint32_t len) { gpio_cs_low(); spi_xfer(0x03); // READ 命令 spi_xfer((addr 16) 0xFF); // 地址 A17-A15按实际容量调整 spi_xfer((addr 8) 0xFF); // 地址 A14-A8 spi_xfer(addr 0xFF); // 地址 A7-A0 while (len--) { *buf spi_xfer(0x00); // 发送任意字节同时接收数据 } gpio_cs_high(); }读命令不受写使能位限制任何时候都能执行所以自检和回读都用它。写数据前必须先发 WREN否则写命令会被忽略就像 Flash 一样。我的写法是每次写操作前都发 WREN然后检查状态寄存器里的 WEL 位确认写使能已经置位这是防止偶发失败的关键。写数据的驱动代码static void mram_wren(void) { gpio_cs_low(); spi_xfer(0x06); // WREN gpio_cs_high(); } static uint8_t mram_rd_sr(void) { uint8_t sr; gpio_cs_low(); spi_xfer(0x05); // RDSR sr spi_xfer(0x00); gpio_cs_high(); return sr; } void mram_write(uint32_t addr, const uint8_t *buf, uint32_t len) { uint8_t sr; mram_wren(); sr mram_rd_sr(); if ((sr MR_WEL_MASK) 0) { // WEL 没置位说明写使能失败直接返回错误 return; } gpio_cs_low(); spi_xfer(0x02); // WRITE 命令 spi_xfer((addr 16) 0xFF); spi_xfer((addr 8) 0xFF); spi_xfer(addr 0xFF); while (len--) { spi_xfer(*buf); } gpio_cs_high(); mram_wrdi(); // 写完立刻关写使能 }这段代码里我刻意加入了 WEL 检查。正常环境下一百次可能也遇不到一次失败但工业设备跑几个月之后一旦 SPI 线被干扰WEL 检查能把写失败提前揪出来而不是让上层傻傻地以为写成功了。3.3 写保护控制和连续读写优化MRAM 最大的优点是没有 Flash 那种扇区擦除所以连续读、连续写都非常自然。地址可以不按页对齐也不存在越界进入下一个扇区要重新擦除的问题。但为了代码健壮性我还是建议上层把写入长度限制在地址连续区间内比如一次最大写 256 字节需要跨一大段地址就分两次操作。这不是因为 MRAM 有页限制而是为了将来代码迁移到 FRAM 或 Flash 时不用大改。写保护控制分两层。第一层是软件每次写完立即执行 WRDI把 WEL 位清掉。第二层是硬件WP# 引脚如果在原理图上接了 GPIO可以在系统启动后拉低等需要写数据时再拉高。我一般让 WP# 固定接高靠软件 WRDI 防止误写因为程序里写完数据之后马上 WRDI 已经足够安全GPIO 驱动反而引入额外动作。3.4 高速模式下如何压榨带宽MR25H40CDF 数据手册上标注的极限 SPI 时钟大约在 40MHz 这个量级PIC32MX675F256L 的 SPI 分频之后跑到这个速度问题不大但真正影响实际吞吐的是 CS# 切换和每字节的间隙。我在项目里用了一个比较粗暴但有效的办法底层 spi_xfer 不变上层一次写入时尽量把数据凑成 256 字节块减少 CS# 连续切换次数。实测下来10MHz 时钟下 256 字节连续写大约在 200 微秒左右完成。这个速度对 50ms 的记录周期来说绰绰有余完全没必要为了压榨极限去上 DMA。如果以后记录频率上到 1ms 一条我会改用 SPI FIFO 模式把数据连续填充进发送缓冲区只在收尾时等最后一个字节完成。驱动接口仍然保持mram_write(addr, buf, len)上层完全不用感知。4. 掉电保护与数据一致性把非易失变成可靠很多人看到非易失三个字就觉得万事大吉其实不是。MRAM 能保证字节本身不丢但如果你用目录、索引、环形缓冲这类结构管理数据掉电瞬间可能把整个数据结构的元数据写了一半导致启动后找不到有效记录。这个问题的源头不是存储介质而是应用层的数据组织方式。4.1 MRAM 的掉电窗口为什么这么小普通 Flash 写一页数据需要先擦除擦除期间如果掉电这部分数据可能处于未定义状态。EEPROM 在内部写周期内掉电同样有风险。MRAM 不一样写命令发出后数据立即非易失CS# 拉高后芯片内部不需要任何长时间操作因此掉电窗口缩短到几十纳秒级别。但字节不会丢和数据不会乱是两回事。如果你的数据格式是先写数据区再写索引区掉电发生在两者之间就会出现数据区是新内容、索引区还是旧内容的情况。比如索引指向记录 100实际数据区已经写了记录 100 的新内容但校验信息没更新。所以设计时必须考虑原子性和可恢复性。4.2 A/B 双区 摘要标记的原子更新我的做法是模仿工业界常用的双缓冲配置类数据和关键运行参数各保存两份分别放在 A 区和 B 区每次更新时按顺序写把新内容完整写入 B 区。在 A 区末尾写一个包含 CRC 的摘要区标记当前有效版本在 B 区。下次更新时反过来先写 A 区再更新摘要指向 A 区。这样任何时刻只要摘要区是完整的系统就能确定有效数据在哪一边。如果掉电发生在写数据区过程中摘要区还指向旧版本启动时最多读到旧数据不会读到半新半旧的数据。如果掉电发生在摘要在写一半的过程中我用摘要区内部再放一个 4 字节魔术字和 CRC读到不完整就默认回退到上一版本。伪代码#define MAGIC_A 0xA5A5A5A5 #define MAGIC_B 0x5A5A5A5A struct version_header { uint32_t magic; uint16_t length; uint16_t crc; uint32_t seq; }; void update_config(const uint8_t *new_data, uint16_t len) { // 当前活动版本是 A则写 B uint8_t active_version read_active_version(); // 写非活动区数据 if (active_version VERSION_A) { mram_write(ADDR_B_DATA, new_data, len); write_version_header(ADDR_B_HEADER, MAGIC_B, len, crc16(new_data, len), seq); } else { mram_write(ADDR_A_DATA, new_data, len); write_version_header(ADDR_A_HEADER, MAGIC_A, len, crc16(new_data, len), seq); } }启动时先读两个版本的 header选择 magic 正确、CRC 通过、且序号更大的那一份。这个方法成本是容量翻倍但 512KB 的 MRAM 容量足够大双区开销完全能接受。4.3 掉电检测触发后的保存流程掉电保护分两层。第一层是检测PIC32MX675F256L 的电压跌落检测或外部电压监控器可以触发中断。第二层是持久化在主控检测到掉电后利用电源端大电容释放的维持时间把关键数据写入 MRAM。我实际的做法是检测到电压跌落立即关闭中断、停止无关任务。把 RAM 里的关键参数按固定格式打包到临时缓冲。调用mram_write写入预留的掉电保护区。写一个掉电保存完成标志。进入低功耗循环等待断电。这整个流程在 10MHz SPI 下写 1KB 数据实测不到 2 毫秒加上头部开销也有充足余量。很多方案里掉电保护麻烦是因为 Flash 擦除太慢要把保存流程拆成先擦、再写、再更新标志三个步骤而 MRAM 把这个流程压缩成了一步普通写入。5. 实测中的四个坑和完整排查链路这一部分我拿出来单独写因为驱动代码本身不算难真正的坑都藏在硬件和时序的细节里。我前前后后调了一个多星期每一个坑都对应一类典型故障希望后来者少走弯路。5.1 坑一读出来全 0xFF不是芯片坏了而是 HOLD# 悬空现象非常干净上电后读取整个 MR25H40CDF所有地址返回 0xFF。这看起来像是芯片不存在或者焊接不良但我把 SO 引脚断开飞线直接在芯片上量发现 MISO 有数据波形。再仔细看HOLD# 引脚悬空。HOLD# 的活动电平是低电平悬空时引脚电位不定偶发被拉低后芯片就会暂停响应。只要芯片处于 HOLD 状态后续 SPI 命令全部无效MISO 自然不输出有效数据。解决办法是把这个脚通过 10K 电阻上拉到 VDD然后重新测试。排查过程就是典型的先看跑步磁铁再看马的姿势先确认芯片供电、再确认 SPI 波形、再检查控制引脚。很多时候不是芯片坏了而是某个控制引脚没处理好。5.2 坑二写回正常、断电后丢数据先怀疑物料再怀疑代码这个坑最阴险。现象是写数据后立即读回完全正确但整机断电重启后发现有一部分数据变成了随机值或者全部变成 0xFF。第一反应肯定是怀疑 MRAM 的掉电保存能力于是我用一颗全新的正品 MR25H40CDF 替换问题复现。再检查原理图、电源、焊接都没发现问题。最后用逻辑分析仪抓了完整写入时序发现 CS# 在最后一个字节还没完全传输完时就提前拉高了。问题出在我之前用的那颗芯片采购渠道不正规内部可能不是真正的 MRAM而是某些商家用 SPI Flash 打磨翻新的片。Flash 在接收完数据后还需要一段内部编程时间提前拉高 CS# 会导致写入失败。排查建议遇到读正常、断电丢数据的情况第一步不要怀疑代码先用示波器或逻辑分析仪确认 CS# 拉高时刻再检查物料来源。如果时序完全没问题果断换一批芯片试试。5.3 坑三PIC32 的 SPI 模式位CKE 和 CPOL 的镜像关系有一阵子数据读回总是错几位尤其是连续读取时后面的字节经常整体偏移。后来确认是 SPI 模式设置错了。MR25H40CDF 要求 SPI Mode 0 或 Mode 3PIC32 的 SPI 模块里 CPOL 对应 CKPCPHA 对应 CKE 但极性关系非常容易搞反。我的调试经验是先把时钟降到 1MHz然后发一个已知的 READ 命令读状态寄存器如果没有返回就切换 CKE 位再试。不要同时改 CPOL 和 CKE一次只动一个这样很快能定位。// Mode 0 SPI1CONbits.CKP 0; SPI1CONbits.CKE 0; // Mode 3 SPI1CONbits.CKP 1; SPI1CONbits.CKE 1;这个坑不只在 MRAM 上会遇到接其他 SPI 设备也一样。关键点是理解协议上的 CPOL/CPHA 与 MCU 内部寄存器位的对应关系不要只看函数名。5.4 坑四同一条 SPI 总线上其他片选放行的复用冲突这个项目里 SPI1 总线上除了 MRAM还有一颗 SPI Flash 做固件备份。某次调试中发现往 MRAM 写数据时会偶尔往 Flash 里写进脏数据或者反过来 Flash 的内容被改写。查到最后发现是 CS# 控制逻辑里某个中断服务程序在处理另一个事务时把 Flash 的片选提前拉低而 MRAM 的写命令还没结束总线上的字节被 Flash 误判成自己的命令。解决办法很粗暴MRAM 的写操作期间不允许任何其他 SPI 从机片选拉低。我在代码里给 MRAM 操作加了一个互斥标志进入操作前关闭会导致片选变化的中断操作完成后再打开。片选复用本身没问题但必须保证同一时刻只有一张片选为低。5.5 排查工具的底线建议调这种 SPI 存储芯片至少要准备一个逻辑分析仪。示波器能看波形但逻辑分析仪能把整串命令解码出来直接看到地址对不对、数据有没有偏差。我现在习惯把 CS#、SCK、SI、SO 四根线全部接到逻辑分析仪上写一个空读命令对比芯片手册里的时序图。如果连逻辑分析仪都没有也有一个低成本排查法先用 1MHz 低速写一个固定模式比如 0xA5、0x5A、0x00、0xFF 的循环再读回比对。哪一位错了就对照时序图看是不是时钟边沿采样时机不匹配。这种方法虽然慢但一定能定位问题。6. 应用层规划把 512KB 空间整理成工业现场能长期维护的数据区驱动跑通只是第一步真正决定设备长期稳定运行的是应用层的数据规划。512KB 听着不小但如果不加规划日志写满、配置覆盖、版本混乱这些问题迟早会来。6.1 地址分区设计我建议把整个 MRAM 地址空间做成分区管理不要在同一个地址段里既存配置又存运行日志。下面是我在这个项目里的实际分区起始地址大小用途0x0000032KBA 区配置和运行参数0x0800032KBB 区配置镜像0x1000064KB掉电保护区0x20000256KB环形运行日志区0x60000128KB大块历史数据缓冲分区的依据是访问频率和数据重要性。配置区很少写但每次写必须可靠所以用 A/B 双区。掉电保护区只保存关键上下文容量小但响应快。运行日志是最常写的区域占用最大。6.2 环形日志和目录区日志区我设计成环形结构每条记录头部带时间戳、长度和 CRC。缓冲区尾部有最后写入的序号但我不依赖它因为掉电可能发生在序号更新之前。更好的做法是让日志区内的记录自描述启动时从环形缓冲区的潜在起始位置扫描找到 CRC 校验失败的那条记录就认为是上一个有效记录的真正结尾。MRAM 的高耐久性让这种频繁覆盖模式没有压力如果是 Flash这种设计会很快把扇区写坏。数据结构大致是struct log_entry { uint32_t timestamp; uint16_t length; uint16_t crc; uint8_t payload[256]; };每条日志固定 256 字节简化计算。查找最新记录时先以最后一个已知位置为中心向前后扫描若干条找到序号最新的有效条目即可。6.3 双副本与 CRC16 校验配置区和掉电保护区我都保存双份副本但 CRC 校验一定不能省。原因很简单MRAM 的字节不会无故丢失但 SPI 总线可能被干扰软件可能写错地址甚至程序跑飞时可能把随机数据写进存储区。CRC 是最后一道防线。我用的 CRC16 实现代码在嵌入式里很常见这里就不贴完整表了。给一点建议如果一个项目打算长期在工业现场跑不要自己发明校验方式直接用 CRC16-CCITT查表实现速度足够快。所有从 MRAM 读回来的数据在上层使用前先过一遍 CRC不通过的记录直接丢弃。最后再说一个我个人的操作习惯物料采购时除了看 MR25H40CDF 这个前缀一定要核对后缀对应的温度等级和封装同系列不同后缀可能代码兼容但工作温度范围不同。产线设备如果夏天发热严重用了商业级芯片出现偶发读写异常排查起来会非常痛苦。选型文档里把型号写完整不要只写Everspin MR25H40采购那边才不会买错。