做工业嵌入式项目,最怕的不是 MCU 性能不够,而是数据莫名其妙丢了。前阵子做一台配电监测终端,需要不停保存事件记录和校准参数,原来用 EEPROM,容量不够;换成 NOR Flash,又嫌擦除逻辑复杂、担心坏块。后来我换成 Everspin MR25H40CDF 与 PIC18F87J11 这套组合,MCU 用 SPI 直接读写 MRAM,问题一下就利索了。
这篇文章就把我自己的选型依据、电路连接、驱动实现、数据完整性策略和调试记录完整写下来。MR25H40CDF 是一颗 4Mbit 串行 SPI MRAM,内部是 512K 字节存储空间;PIC18F87J11 是 Microchip 的 8 位单片机,自带 MSSP 模块,做 SPI 主设备很顺手。这套方案适合仪器仪表、工业控制器、电力终端、小型物联网网关这类场景,也适合想把数据存储做到“掉电也不慌”的嵌入式开发者参考。
1. 为什么是 MR25H40CDF + PIC18F87J11
1.1 工业存储需求先拆清楚
很多项目选存储芯片,一上来就看容量和价格,却忽略了这个场景对“写寿命”和“掉电可靠性”的要求。我这次的需求其实很典型:
- 运行参数和标定数据:不常改,但绝对不能丢,丢了设备就是错的状态。
- 事件记录和故障日志:会高频追加,每次写几十到几百字节,一天可能要写几千次。
- 掉电瞬间可能需要保存关键状态:比如当前计量累计值、最后一次通信帧、报警发生时刻。
这类需求如果硬套 NOR Flash,最大的问题是“写之前要擦除”。NOR Flash 的最小擦除单位一般是 4KB 扇区,你想改一个 10 字节的参数,要先把整个扇区读回来、擦掉、再回写,稍不留神就会引入坏块。EEPROM 倒是能单字节写,但容量普遍偏小,写次数也有限。所以当我看到 MRAM 这种“像 SRAM 一样写、掉电不丢”的东西时,立刻觉得这才是工业存储该走的路线。
MR25H40CDF 的容量是 4Mbit,也就是 512KB,对日志型应用完全够用。它没有擦除操作,想写哪个地址就直接写,写操作没有 Flash 那种“等待内部擦写状态机”的过程,数据写到存储单元的机制是磁阻状态切换,不存在浮栅电荷损耗,也就没有传统意义上“写坏”的概念。对我这种常年做嵌入式小项目的人来说,这等于把“磨损均衡”和“坏块管理”这些事直接从需求里删掉了。
1.2 MRAM、NOR Flash、EEPROM、FRAM 到底怎么选
我一开始也犹豫过 FRAM,毕竟 FRAM 同样耐写。但对比之后,这颗 MRAM 在容量、SPI 速度和抗干扰方面更贴合我的项目。我自己做选型时习惯拉一个简单的比较表,不看厂商宣传,只看自己用的指标:
| 项目 | MRAM | NOR Flash | EEPROM | FRAM |
|---|---|---|---|---|
| 写入前是否需要擦除 | 不需要 | 需要 | 不需要 | 不需要 |
| 典型单位写次数 | 极高,基本不用考虑磨损 | 约 10 万次 | 约 100 万次 | 很高 |
| 字节/连续写 | 支持字节和连续写 | 页写为主 | 字节写为主 | 字节和连续写 |
| 写等待 | 极短,几乎即时 | 毫秒级擦写等待 | 毫秒级写周期 | 极短 |
| 数据掉电保持 | 好 | 好 | 好 | 较好 |
| 容量密度 | 可以做到较大 | 大 | 小 | 相对有限 |
MR25H40CDF 在实际工程里还有一个容易被忽略的优点:它不需要“先读后改”。你直接往地址里写新值,旧值就被覆盖了。这让代码逻辑非常简单,不用维护“影子缓冲区”,也不用担心“擦除一半掉电”这种破事。
1.3 为什么单片机选了 PIC18F87J11
选单片机不是堆规格,是够用。PIC18F87J11 最大的优势不是算力,而是“老老实实”。在工业现场,电源波动、脉冲群、静电这些东西随时存在,8 位 MCU 的启动逻辑、复位逻辑、外设行为都非常成熟,不会拿你开玩笑。
PIC18F87J11 自带 MSSP 模块,天生支持 SPI 主模式,而且有独立的 SSP1BUF 发送/接收缓冲,底层收发只要几行代码。它的 IO 电平是 3.3V,正好和 MR25H40CDF 的 VDD 匹配,不需要电平转换。再加上它有 BOR 和 LVD 这类电压检测外设,做掉电保护很方便,这是工业存储方案里非常关键的一环。
如果你以后想做嵌入式架构师,或者想把项目做成可复用的嵌入式开源模块,建议先把这种“小存储 + 硬件 SPI + 工业级电压监控”的组合跑熟。它虽然不起眼,但在大量实际设备里都用到,属于嵌入式基本功。
2. 硬件连接与电路设计
2.1 MR25H40CDF 的引脚和最小信号
MR25H40CDF 是 SPI 从机,引脚不算多,连接起来很轻松。典型器件有这些关键引脚:
| 引脚名 | 功能 | 接法建议 |
|---|---|---|
| CS | 片选,低有效 | 接 MCU 的普通 GPIO,独立控制 |
| SCK | SPI 时钟 | 接 MSSP 模块的 SCK |
| SI | 数据输入 | 接单片机 SDO,数据从 MCU 进 MRAM |
| SO | 数据输出 | 接单片机 SDI,数据从 MRAM 回 MCU |
| WP | 写保护 | 通常直接接 VDD |
| HOLD | 暂停通信 | 必须接上拉或直接接 VDD,不能悬空 |
| VDD | 电源 | 3.3V,就近放 0.1uF 去耦电容 |
| VSS | 地 | 接底层地,注意 DFN 底部焊盘接地 |
这里要重点说一句:CS 不要直接接地,也不要和别的 SPI 从机共用一组 CS 逻辑。MRAM 是靠 CS 的下沿开始接收命令、CS 的上沿锁存命令执行的。如果 CS 一直为低,它在总线上会不断把其他设备的 SPI 数据当成自己的命令,轻则数据错乱,重则误写存储区。
2.2 最小电路怎么接
我的实际接法是:单片机和 MRAM 同用一个 3.3V 电源轨,VDD 旁边放一个 0.1uF 陶瓷电容,再在电源根部放一个 10uF 钽电容。WP 直接接 VDD,HOLD 通过 10k 电阻接 VDD。之所以不用 GPIO 控制 WP,是因为应用里没有“整片锁死”的需求,WP 接高让块保护功能不生效,简单可靠。
SPI 的四根线,SCK、SI、SO、CS,全部用推挽输出,不加外部上拉。之前我试过在 SPI 线上加上拉电阻,反而增加了信号上升沿时间,高速时容易产生误采样。如果 PCB 布局不得不把 MRAM 拉得比较远,可以在 MCU 输出端串 33 欧姆电阻,改善振铃,但电阻不要太大。
注意:HOLD 悬空是这颗芯片最容易踩的坑。HOLD 引脚在串行通信过程中被拉低时,MRAM 会暂停当前的 SPI 操作,SO 变成高阻,等 HOLD 恢复后再继续。如果它悬空,现场干扰一旦把它拉低,整次读写就会变成半截操作,数据自然就错了。所以 HOLD 必须接上拉到 VDD。
2.3 PCB 布局与抗干扰经验
如果 MRAM 只是放在 MCU 旁边,走线短,抗干扰压力不大。但工业设备常常要把存储芯片和 MCU 放得比较远,或者靠近接插件,这时候就要注意几点:
- SCK 和 SO 不要平行长距离走线,SCK 的串扰可能直接耦合到 SO 上,导致读数据错位。
- 存储芯片下方不要走开关电源的电流回路,尤其是高频 dI/dt 比较大的开关节点。
- VDD 走线要宽,最好直接铺铜,避免负载变化时电压跌落。
- 如果电路板环境恶劣,CS、SCK、SI 上可以并联小电容到地,容量选 10pF 左右,别超过 22pF,否则会把时钟边沿拖得没法看。
MR25H40CDF 的工作温度范围和工业级 MCU 是匹配的,实际用 -40℃ 到 +85℃ 的场景没问题。焊接的时候注意 DFN 封装的散热焊盘,焊膏不能少,也不能让焊料流到引脚之间。如果手工贴片,回流焊后最好用放大镜检查 SI 和 CS 引脚有没有虚焊,这类问题在产线上最容易表现为“偶发读写失败”。
3. PIC18F87J11 的 SPI 驱动与读写实现
3.1 SPI 模式、时钟和 CS 控制
MR25H40CDF 支持 SPI Mode 0 和 Mode 3,我用的是 Mode 0:SCK 空闲为低,主设备在时钟下降沿改变数据,上升沿采样。PIC18F87J11 的 MSSP1 模块里,CKP 位决定 SCK 极性,CKE 位决定数据在哪个边沿变化。写驱动前一定要把这两位的组合调成 MRAM 手册要求的时序,不能想当然。
片选我坚持用普通 GPIO 控制,不用 MSSP 模块内部的自动 CS。原因很简单:SPI 上可能还有别的设备,CPU 需要在多设备之间灵活切换 CS;而且 MRAM 的整条命令必须在一个 CS 低电平周期内完成,用 GPIO 控制最直观。
时钟频率方面,我先把系统时钟设在 8MHz,MSSP 主模式配成 FOSC/4,也就是 2MHz 的 SCK。这个速度对 512KB 存储芯片来说足够快,又不会因为飞线和干扰搞得太紧张。如果你把系统时钟提高,SCK 可以更快,但一定要先量波形。
3.2 寄存器初始化和底层收发函数
用 XC8 写初始化很简单,下面是兼容大多数 PIC18 系列习惯写法。寄存器名在 PIC18F87J11 头文件里是 SSP1CON1、SSP1STAT、SSP1ADD、SSP1BUF,如果你的工程用 MCC 生成,逻辑是一样的。
// 根据自己的板卡定义片选引脚 #define MRAM_CS_TRIS TRISBbits.TRISB4 #define MRAM_CS LATBbits.LATB4 void mram_gpio_init(void) { MRAM_CS_TRIS = 0; // 输出 MRAM_CS = 1; // 片选默认释放 } void mram_spi_init(void) { // 假设 FOSC = 8MHz,SPI 时钟 = FOSC / (4 * (SSP1ADD + 1)) = 2MHz SSP1ADD = 0; // 按 SPI Mode 0 配置:SCK 空闲低,上升沿采样 SSP1STATbits.CKE = 1; SSP1CON1bits.CKP = 0; // SPI 主模式,使能 MSSP1 SSP1CON1bits.SSPEN = 1; SSP1CON1bits.SSPM3 = 0; SSP1CON1bits.SSPM2 = 0; SSP1CON1bits.SSPM1 = 1; SSP1CON1bits.SSPM0 = 0; }底层收发其实就是一个函数:往 SSP1BUF 写一个字节,然后等 BF 置位,再读 SSP1BUF。BF 标志会在读取 SSP1BUF 后自动清除。注意不要在中断和主循环里同时使用同一个 MSSP 模块,否则返回的数据可能是“下一笔操作”的数据。
uint8_t spi_xfer(uint8_t out) { SSP1BUF = out; while (!SSP1STATbits.BF) { } return SSP1BUF; }3.3 MRAM 写使能、写入和读取函数
MR25H40CDF 写操作前必须先发写使能命令 WREN(0x06),然后才能发写命令 WRITE(0x02)。否则写命令会被忽略。这也是很多人第一次调不通的根源。
MRAM 的地址是三字节结构,即使只访问很小的区域,也一定要把 3 个地址字节都发出去。512KB 对应地址范围 0x000000 到 0x7FFFF,高字节的高 5 位可以忽略,但程序里统一发 3 字节,以后换更大容量芯片不用改协议。
void mr25_write_enable(void) { MRAM_CS = 0; spi_xfer(0x06); // WREN MRAM_CS = 1; } void mr25_write(uint32_t addr, const uint8_t *buf, uint32_t len) { uint32_t i; uint8_t *p = (uint8_t *)buf; mr25_write_enable(); MRAM_CS = 0; spi_xfer(0x02); // WRITE spi_xfer((addr >> 16) & 0xFF); spi_xfer((addr >> 8) & 0xFF); spi_xfer(addr & 0xFF); for (i = 0; i < len; i++) { spi_xfer(p[i]); } MRAM_CS = 1; } void mr25_read(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t i; MRAM_CS = 0; spi_xfer(0x03); // READ spi_xfer((addr >> 16) & 0xFF); spi_xfer((addr >> 8) & 0xFF); spi_xfer(addr & 0xFF); for (i = 0; i < len; i++) { buf[i] = spi_xfer(0x00); } MRAM_CS = 1; }这里有个细节:MRAM 是串行从机,SI 上的数据在 SCK 边沿被芯片接收,SO 上的数据在同一次 SPI 交换里输出。所以发送地址时不关心 SO 返回什么,真正读取数据时,主机每个字节写 0x00 只是提供 SCK,把 SO 上的数据采回来。
注意:WREN 命令和随后的 WRITE 命令之间,CS 必须先拉高一次,再拉低发 WRITE。如果 CS 一直为低,MRAM 会把 WREN 命令和 WRITE 命令当成一次非法序列,写使能不生效。
3.4 连续读写和地址边界
MR25H40CDF 支持连续读写,一次 READ 或 WRITE 命令可以跟着很多数据字节,地址会自动递增。这和 NOR Flash 的“页缓冲”概念不同,它在递增过程中不需要等待内部操作。但有一个边界问题:当地址递增到存储阵列末尾时,很多 SPI 存储芯片会选择自动回卷到 0x000000。
所以我从不依赖这种回卷行为。应用层读写尽量按块操作,每块控制在 512 字节以内,并且先检查 addr + len 是否超过 0x80000。如果会跨边界,就拆成两次操作,一次读到边界结束,另一次从边界起始继续。这样即使芯片的行为版本有差异,程序也不会踩坑。
3.5 状态寄存器和写保护
如果写操作没生效,第一个该怀疑的就是写使能没成功。可以用 RDSR(0x05)命令读状态寄存器,查看 WEL 位是不是变成 1。
uint8_t mr25_read_status(void) { uint8_t st; MRAM_CS = 0; spi_xfer(0x05); st = spi_xfer(0x00); MRAM_CS = 1; return st; }如果状态寄存器读出来一直是 0,说明 WREN 根本没写进去。常见原因是 SPI 模式配置错、CS 没有完全拉低,或者 WREN 和 WRITE 之间 CS 没有重新拉高。MRAM 上电后写使能锁存位默认是关闭状态,所以每次写入前都必须调用 mr25_write_enable(),不能像 SRAM 一样直接写。
块保护功能我默认不使能。只有当你需要给引导区或关键参数区加保护时,才去设置状态寄存器里的 BP0、BP1 位。工业设备如果固件支持 Bootloader 升级,可以考虑把 Bootloader 所在区域设成块保护,防止应用飞跑时误写。否则保持默认,代码越简单越不容易出错。
4. 数据完整性和工业级可靠性设计
4.1 写入时掉电,MRAM 也救不了“整条记录”
MRAM 的单元切换速度很快,掉电时已经写入的字节不会丢。但是,这不代表“你发了一整条多字节数据,掉电时这条数据就一定是完整的”。SPI 接收和数据写入过程是逐字节进行的,如果写了一半电源断了,后面那半截地址可能还是旧数据,或者收到了一半的字节流。
所以真正可靠的做法是在应用层做好帧格式和双区备份。MRAM 解决了“存储介质不耐写、写入慢”的问题,但数据事务完整性还是要靠软件来保证。
4.2 给每条记录加魔数、长度、序列号和 CRC
我建议设计一种非常朴素的记录格式,无论存参数还是存事件,都用同一个结构:
| 字段 | 长度 | 作用 |
|---|---|---|
| 魔数 | 2 字节 | 标记这是一条有效记录,比如 0xAA 0x55 |
| 长度 | 2 字节 | 后面数据域的长度 |
| 序列号 | 4 字节 | 单调递增,用于判断新旧 |
| 数据域 | N 字节 | 实际载荷 |
| CRC16 | 2 字节 | 覆盖前面所有字段和数据 |
读取的时候先校验魔数,不匹配直接判无效;再校验 CRC,不通就判损坏;如果两张镜像记录都损坏,才判定存储区需要重新初始化。序列号的作用是判断哪个镜像更新,哪怕写入过程中掉电,也可以选择“最新且校验通过”的那份。
不要省略 CRC。MRAM 本身误码率非常低,但 SPI 线路上的干扰、单片机 IO 配置错误、PCB 焊接不良,都会导致读写路径上出现错误。CRC 是最后一道防线。
4.3 A/B 双区镜像,比单一地址更可靠
我在设备里划分了两个槽位,比如参数区放在地址 0x00000 和 0x00080,每份记录 128 字节。写入流程如下:
- 构造新记录,序列号在前一条基础上加 1。
- 先写到 A 区。
- 读回 A 区,校验 CRC。
- 如果校验通过,再写到 B 区。
- 再读回 B 区校验。
启动时把 A、B 都读出来,只要 CRC 通过,就选序列号大的那一条。如果某一次写 A 后正好掉电,A 可能已经是最新且完整,B 还是旧版本,没关系,启动时选 A。如果 A 写到一半损坏,CRC 失败,那就选 B。这样在任意单点掉电时刻,至少有一份完整的最新或次新记录。
因为 MRAM 不需要擦除,这个流程执行起来非常快,不会像 Flash 方案那样为了“少擦一次”去精打细算。
4.4 掉电检测和最后写入窗口
PIC18F87J11 内部有 BOR 和 LVD,可以在主电源跌落时产生复位或中断。我的板子在 3.3V 供电,把电压检测阈值设在 MCU 稳定运行的最低点附近,检测到掉电后进入一个“最后保存”流程:把当前累计值、状态机、最后一次错误码这些最关键的变量,一次性写入 MRAM 的紧急存储区。
这个过程不要做太多事。写紧急数据前先把看门狗关掉,或者确保这次写的时间远小于看门狗超时;写完立即进入低功耗或复位等待。我在实际项目里从检测到掉电到写完 64 字节,大约只用了几毫秒,完全来得及。
4.5 性能实测:2MHz SPI 足够用
SCK 2MHz 时,一个字节传输需要 8 个时钟周期,也就是 4 微秒。写一条 128 字节的事件记录,加上命令头、地址和片选开销,总体不到 1 毫秒。一天写几千条日志,对 MRAM 来说完全不构成压力。
如果以后吞吐量提高,可以继续把 SCK 往上配。但要注意,SPI 优势是简单,不是带宽。大量连续日志还是建议按块缓冲,一次写满一个 512 字节区块,减少命令头开销。对工业存储来说,“稳定不丢”永远比“跑满带宽”重要。
5. 调试实录:常见问题和排查方法
5.1 先做最小系统验证,别一上来写业务数据
第一次上电,我不会直接跑应用层。先把 MRAM 挂到 SPI 上,只做三件事:读状态寄存器、写一个测试字节、读回来对比。具体步骤是:
- 读状态寄存器,看是否返回 0x00。
- 发 WREN,再读状态寄存器,确认 WEL 位置 1。
- 对地址 0x000000 写 0xA5、0x5A、0x00、0xFF 几个测试字节。
- 读回同样的地址,逐字节比对。
如果状态寄存器读出来是 0xFF,说明 SDO 线可能没接对,或者 CS 根本没有选中芯片。如果读状态正常,但写后读回全 0xFF,优先怀疑 WREN 没成功。这一步做完,再进应用层不迟。
5.2 典型故障速查表
下面这些问题是实际调试中最常遇到的,我整理成了一张排查表:
| 现象 | 可能原因 | 排查和解决 |
|---|---|---|
| 读状态寄存器返回 0xFF | CS 没拉低,SCK 悬空,SO 接错 | 示波器量 CS、SO,确认引脚定义 |
| 读状态寄存器返回 0x00,但写不进去 | WREN 没执行,或 WREN 与 WRITE 之间 CS 没拉高 | 单步执行,抓 WREN 后 WEL 位 |
| 读回全 0x00 | 电源掉电,SI 没接对,SCK 极性错 | 检查电压,量 SCK 波形,核对 Mode 0 |
| 某个地址区域写不进 | 地址字节顺序写反,或跨了地址边界 | 检查 3 字节地址发送顺序 |
| 偶发数据错位,CRC 报错 | HOLD 悬空、SCK 干扰、电源纹波大 | HOLD 上拉,缩短 SPI 走线,加去耦 |
| 同一条命令执行两次才成功 | CS 低电平时间不够,命令被截断 | 看逻辑分析仪,确认 CS 低周期覆盖整条命令 |
5.3 用逻辑分析仪看时序,比猜快得多
调 SPI 设备最有效的办法是接一个逻辑分析仪,把 CS、SCK、SI、SO 四根线同时抓下来。重点看写命令这一段是否完整:CS 先拉低,然后依次出现 0x02、三字节地址、数据字节,最后 CS 拉高。只要这一段出现在同一个 CS 低周期内,并且边沿和模式正确,协议基本没问题。
我踩过最深的一个坑是:底层 spi_xfer 函数在每次传输后自动拉高 CS,导致整条命令被拆成很多个“CS 有效”的小段。MRAM 把每一段都当成独立命令,结果写操作完全失败。后来我把 CS 控制收到命令级函数里,整条 READ 或 WRITE 才真正闭合,问题立刻消失。
5.4 CRC 偶发失败怎么定位
如果故障是“跑了很久之后偶发 CRC 不过”,不要急着怀疑 MRAM 质量。先看是不是每次失败都集中在同一个物理地址,如果是,大概率是那个区域的数据被另外的程序覆盖了;如果失败位置随机,先排除 SPI 线干扰和电源纹波,再检查 HOLD 引脚是不是真的拉到了 VDD。
也可以在固件里加上写后读回和重试机制。即使第一遍写读不一致,重写一遍往往就好了。MRAM 不是易失介质,不会因为多写一遍就折寿。把重试逻辑放到底层接口里,应用层只要保证“写失败后能报错或重试”,长期运行的可靠性会明显提升。
6. 量产化和扩展的个人经验
6.1 产线测试不要只写固定模式
量产时,每块板子最好都跑一次完整的存储自检。我一般是这样做的:产线程序先擦除不了 MRAM,那就直接写测试 pattern,把 512KB 空间按 4 个象限分别写 0xA5、0x5A、0xFF、0x00,再逐地址读回比对。
全地址遍历在 2MHz SPI 下需要一点时间,但产线只需要做一次,值得。测试通过后,写测试区会被后续的应用数据覆盖,不影响出厂状态。这个测试能同时暴露焊接不良、虚焊、引脚短路、PCB 断线等硬件问题。
6.2 更大容量和多设备扩展
如果以后数据量超过 512KB,可以换更大容量的串行 MRAM,代码里只要把地址长度逻辑和块边界检查跟着改。也可以直接做双片镜像:两片 MRAM 挂同一组 SPI 总线,各用一片 CS。写入时同时写两片,读时优先读主片,主片 CRC 失败再读从片。工业设备想要更高的容错,这种冗余方案比单纯加大容量更有效。
MRAM 也可以和 SPI NOR Flash 共存,MRAM 存频繁修改的参数,Flash 存固件镜像和升级包。两条片选线各自独立,互不干扰。这种“各司其职”的组合在很多嵌入式开源项目里都有影子。
6.3 最后分享一个我自己踩过的坑
HOLD 引脚悬空的问题,我是在稳定运行测试的后半段才发现的。当时整机看门狗没有复位,但日志里偶发出现整段数据错位,CRC 一次错、一次对,毫无规律。用逻辑分析仪持续抓了很久,才发现 HOLD 引脚在 SCK 静止时会被附近的继电器的电磁干扰拉低,MRAM 因此暂停输出。后来把 HOLD 用 10k 电阻接到 VDD,并且走线避开继电器驱动区,问题再没出现过。
这颗电阻真的不能省。写完驱动、跑通读写只是第一步,真正的工业项目还要在电源、布局、掉电策略和测试流程上把每一环都卡死。MR25H40CDF 和 PIC18F87J11 这套组合,给我的最大感受是“简单、耐造、可控”,如果你也在做类似的工业存储方案,希望这篇东西能帮你少走几段弯路。