做工业嵌入式这些年,参数存储一直是个绕不开的老问题。项目里既要跑数据采集、又要做掉电保存,NOR Flash 的擦写寿命和擦除等待让人头疼,EEPROM 容量又实在太小。最近的电力采集项目里,我把存储颗粒换成了 Everspin 的 MR25H40CDF,主控用项目里常驻的 PIC32MX664F064L,一套 SPI 驱动跑下来,读写逻辑比想象中简单,稳定性也确实让人放心。这篇文章就从头到尾记录这次开发过程,包括硬件连接、寄存器配置、完整 C 驱动,以及我在实测中踩过的那几个坑,给同样在做嵌入式存储选型和底层驱动的朋友一个参考。
1. 为什么工业嵌入式存储,这次我没选 Flash 而是选了 MRAM
1.1 MRAM 的读写原理:磁隧道结与传统电荷存储的区别
MRAM 全称 Magnetoresistive Random Access Memory,磁性随机存储器。它跟 Flash、EEPROM 的根本区别在于:Flash/EEPROM 靠电荷存储在浮栅里表示 0 和 1,而 MRAM 靠的是磁隧道结(MTJ)的电阻状态。
磁隧道结的结构有点像一块“磁化方向三明治”:两层磁性材料中间夹着一层极薄的绝缘层。其中一层磁性材料的磁化方向是固定的,叫参考层;另一层是自由的,叫自由层。当自由层的磁化方向与参考层平行时,电子穿过绝缘层的概率高,器件呈现低电阻,对应逻辑 0;当方向相反时,隧穿概率降低,呈现高电阻,对应逻辑 1。写入数据时,通过施加电流产生磁场或者利用自旋转移矩效应,改变自由层的磁化方向,整个过程是物理磁状态的翻转,不涉及电荷的注入和泄放。
这个原理带来两个非常实用的结果。第一,写入前不需要擦除,任何地址都可以随时写入,没有“先擦后写”的步骤;第二,写入不会对存储介质造成累计损伤,耐久性比 Flash 高好几个数量级。说得通俗一点,MRAM 就是一块“断点也不会忘事的 SRAM”,虽然速度比不上真正的 SRAM,但存储介质的体验已经很接近了。
1.2 MR25H40CDF 参数逐项解读:4Mb、SPI、3.3V 背后的工业含义
MR25H40CDF 是 Everspin 的 4Mbit MRAM 芯片,我选它主要看中几个点:
- 容量 4Mb,也就是 512KB。对工业现场的参数存储、设备日志、标定数据来说,512KB 是一个很舒服的容量,比 EEPROM 的 K 级大得多,又不会有 NOR Flash 那种大容量带来的擦除负担。
- 接口是标准 SPI,最高 SPI 时钟可以跑到 40MHz 级别。绝大部分 MCU 都有 SPI 外设,不需要专门的总线控制器,引脚占用也只有 4 根。
- 工作电压 3.3V,和 PIC32MX664F064L 的 I/O 电压完全匹配,不需要额外电平转换。
- 封装是 8 脚 DFN,体积很小,适合板卡空间受限的工业模组。
我最关心的其实是它的写时序。MR25H40CDF 写入后不需要等待内部编程完成,CS 拉高数据就已经进去了。对比 NOR Flash 每次 Page Program 之后的那个“等待忙状态置低”的循环,MRAM 这种即写即完成的体验,在频繁小数据写入的场景里优势特别明显。
1.3 和 NOR Flash、EEPROM、带电池 SRAM 的横向对比
工业嵌入式领域,参数存储常见方案就那几种。我把 MR25H40CDF 和它们放在一起比过之后,选型逻辑就清楚了:
| 对比项 | MR25H40CDF(MRAM) | 串行 NOR Flash | 串行 EEPROM(如 24Cxx) | SRAM + 备份电池 |
|---|---|---|---|---|
| 写入前是否擦除 | 否 | 是,按扇区/块擦除 | 否 | 否 |
| 典型擦写寿命 | 10^12 次级别 | 10^4~10^5 次 | 10^6 次 | 无限(算介质) |
| 单字节随机写 | 支持 | 不支持,必须先擦块 | 支持 | 支持 |
| 掉电数据保持 | 20 年级别 | 10~20 年 | 10 年以上 | 依赖电池,需维护 |
| 写后等待 | 无 | 需要等待页编程完成 | 需要等待 | 无 |
| 容量密度 | 中 | 大 | 小 | 中 |
表格里最刺眼的就是擦写寿命。NOR Flash 标称 10 万次擦写,听着不少,但如果在做秒级数据采样,一小时 3600 次写入,一天就是 8.6 万次,一块 NOR Flash 大概两天就磨到寿命边缘了。这也是为什么很多工业设备日志系统里,NOR Flash 频繁被写坏,最后只能换 MRAM 或者 FRAM 的原因。FRAM 也很好,但容量通常做到几百 Kbit,和 MR25H40CDF 这种 Mbit 级比,还是差了一截。
2. PIC32MX664F064L 与 MR25H40CDF 的硬件互联设计
2.1 SPI1 的引脚映射与主模式配置
PIC32MX664F064L 属于 Microchip PIC32MX6 系列,MIPS M4K 内核,主频 80MHz。SPI 外设很完整,支持标准 SPI 模式、FIFO、DMA 配合等。我在这个项目里使用 SPI1 模块,引脚映射按手里的 100 脚 TQFP 封装来看,一组可用的默认映射是:
| 功能信号 | PIC32MX664F064L 引脚 | 方向 |
|---|---|---|
| SCK1(时钟) | RC3 | 输出 |
| SDO1(数据输出,接 MRAM SI) | RC5 | 输出 |
| SDI1(数据输入,接 MRAM SO) | RC4 | 输入 |
| CS1 / 片选 | 任意 GPIO,我用 RF0 | 输出 |
不同封装和不同引脚组合下,SPI1 的可选映射可能不一样,一定要对着手里的数据手册 Pin Description 表格确认。这里我把 CS 明确用 GPIO 控制,原因后面会讲。
2.2 连接方式与片选控制
MR25H40CDF 的 SPI 接口是标准的四线加控制引脚。完整连线如下:
| MR25H40CDF 引脚 | 连接目标 | 说明 |
|---|---|---|
| CS# | PIC32 RF0(GPIO 输出) | 片选,低有效 |
| SCK | PIC32 RC3(SCK1 输出) | SPI 时钟 |
| SI | PIC32 RC5(SDO1 输出) | 主机输出,从机输入 |
| SO | PIC32 RC4(SDI1 输入) | 主机输入,从机输出 |
| WP# | VDD,经 10kΩ 上拉 | 硬件写保护,接高解除 |
| HOLD# | VDD,经 10kΩ 上拉 | 保持引脚,接高禁用 |
| VDD | 3.3V | 电源 |
| VSS | GND | 地 |
为什么片选不用 PIC32 SPI 自带的硬件从机选择功能(MSSEN)?因为 MRAM 的读写序列是“命令 + 地址 + 数据”,CS 低电平必须覆盖整个过程,用 GPIO 控制最直接、最可控,也方便将来在同一根 SPI 总线上挂多个设备,每个设备用一个 GPIO 做片选。硬件 CS 模式在多设备总线上反而容易受时序约束,不如 GPIO 灵活。
2.3 电源、上拉与 PCB 布线里的工业细节
工业设备最怕的不是芯片参数不够,而是板级细节掉链子。MR25H40CDF 的电源处理我按常规工业标准来:每个 VDD 引脚旁边放一个 1µF 陶瓷电容,再并一颗 100nF 高频去耦电容,就近接地。SPI 信号线尽量短,走线不要绕远,SCK、SI、SO 三条线之间不要平行走太长,避免串扰。
特别要强调的是 WP# 和 HOLD# 这两个引脚。很多开发者第一次用带 WP# 和 HOLD# 的 SPI MRAM,会想“功能不用就不接”,结果引脚悬空。悬空引脚在有电磁干扰的工业现场很容易被感应出不确定电平,HOLD# 一旦被拉低,MRAM 会进入保持模式,SCK 时钟来了也不动作,表现出来就是读写全部无响应,问题极难排查。我这边统一用 10kΩ 电阻把 WP# 和 HOLD# 上拉到 VDD,从根上把这个问题排除掉。
3. SPI 底层打通:寄存器配置和第一次读回
3.1 初始化代码与波特率计算
先看 PIC32MX664F064L 的 SPI1 初始化函数。以 80MHz 外设时钟(FPB)为例,SPI 波特率计算公式是:
FSCK = FPB / (2 × (SPI1BRG + 1))
如果想得到 1MHz 的 SPI 时钟用于调试,代入公式就是 1000000 = 80000000 / (2 × (BRG + 1)),解得 BRG = 39。这个公式要记住,后面调速度只需要改这个值。下面这段代码把 SPI1 配成标准模式 0 的主模式:
#include <xc.h> #define MRAM_CS_TRIS TRISFbits.TRISF0 #define MRAM_CS_LAT LATFbits.LATF0 void spi1_master_init(uint32_t sck_hz) { // 关闭 SPI1,进入配置状态 SPI1CON = 0; SPI1STATbits.SPIROV = 0; // 配置引脚方向:SCK1 和 SDO1 输出,SDI1 输入 TRISCbits.TRISC3 = 0; // SCK1 TRISCbits.TRISC5 = 0; // SDO1 TRISCbits.TRISC4 = 1; // SDI1 // 片选默认拉高,SPI 从机未选中 MRAM_CS_TRIS = 0; MRAM_CS_LAT = 1; // 波特率 SPI1BRG = (80000000UL / (2 * sck_hz)) - 1; // SPI Mode 0:CKP=0,CKE=1;主模式,8 位数据 SPI1CONbits.MSTEN = 1; SPI1CONbits.CKP = 0; SPI1CONbits.CKE = 1; SPI1CONbits.SMP = 0; // 清接收溢出标志 SPI1STATbits.SPIROV = 0; // 开启 SPI1 SPI1CONbits.ON = 1; } uint8_t spi1_xfer(uint8_t tx) { SPI1BUF = tx; while (!SPI1STATbits.SPIRBF) { // 等待接收完成 } return SPI1BUF; }这段代码的核心是 spi1_xfer。SPI 是全双工,主机每发出一个字节,同时收到一个字节,所以读写共用同一个函数。后面 MRAM 的所有命令、地址、数据交换,最终都塌缩到这个收发函数上。
3.2 CKP/CKE 与标准 SPI 模式的对应,别再靠猜
PIC32 的 SPI 极性控制位和市面上常见的 CPOL/CPHA 命名不完全一样,很多人在这里栽跟头。Microchip 的叫法是 CKP(Clock Polarity)和 CKE(Clock Edge),其中 CKP 对应 CPOL,但 CKE 和 CPHA 的关系不是一一对应,而是像“互补”一样。
我这次用标准 SPI Mode 0,也就是 CPOL=0、CPHA=0:SCK 空闲时低电平,第一个跳变沿(上升沿)采样数据。换算成 PIC32 寄存器,就是 CKP=0、CKE=1。这个组合我在好几个 PIC32 项目里都验证过,读回的数据完全正常。如果你发现 MRAM 读回来的数据全是 0xFF 或者乱码,第一件事就是把 CKE 翻一下,改成 0 再试,多半就能恢复。
不要背模式表,用逻辑分析仪或者示波器抓一下 SCK 和 SI/SO 的波形,看数据是在哪个沿跳变、哪个沿采样,一抓就明白。工业开发工具链里,几十块钱的国产逻辑分析仪就够用。
3.3 先读状态寄存器验证通信
硬件焊好、驱动写完,先不急着读写大块数据,第一步做连通性测试。最稳的验证方式是读 MRAM 的状态寄存器,命令码是 0x05。流程很简单:CS 拉低,发一个字节 0x05,再收一个字节,CS 拉高。
uint8_t mram_read_status(void) { uint8_t sr = 0; MRAM_CS_LAT = 0; spi1_xfer(0x05); sr = spi1_xfer(0x00); MRAM_CS_LAT = 1; return sr; }如果这个函数能正常返回一个 0x00(出厂默认状态),说明 SPI 物理链路、引脚映射、时钟极性和波特率全都没问题。如果返回全 0xFF,优先查 SCK/SI/SO 是否接反;如果返回乱跳的数,大概率是 CKE 配错。这一步通过后,后面的事都顺了。
4. MR25H40CDF 的命令集与写保护机制
4.1 READ、WRITE、WREN 是最常用的三条命令
MR25H40CDF 的 SPI 命令集和普通 SPI NOR Flash 有相似之处,但更精简。我梳理一下实际开发中必须掌握的命令:
| 命令码 | 名称 | 功能 |
|---|---|---|
| 0x06 | WREN | 写使能,抬升 WEL 位 |
| 0x04 | WRDI | 写禁用,清除 WEL 位 |
| 0x05 | RDSR | 读状态寄存器 |
| 0x01 | WRSR | 写状态寄存器 |
| 0x03 | READ | 读数据,3 字节地址 |
| 0x02 | WRITE | 写数据,3 字节地址,后跟数据 |
读操作是最直观的:CS 拉低,发 0x03,后跟 3 字节地址(高字节在前),然后连续发任意字节的时钟,MRAM 的 SO 引脚就会依次送出数据。地址范围是 0x00000 到 0x7FFFF,对应 512KB 空间,注意发地址时高 4 位其实用不到,但习惯上发全 3 字节。
写操作也很直接:CS 拉低,发 0x02,后跟 3 字节地址,再连续发要写入的数据,CS 拉高。最关键的一点是,MRAM 不需要像 Flash 那样限制在页内写,也不需要在写之前执行擦除操作,一次写命令可以连续写完整个 512KB,只要你愿意保持 CS 低电平。这个特性在存储大块运行日志时特别香。
4.2 状态寄存器、WP# 引脚和块保护
MR25H40CDF 有一个 8 位状态寄存器,里面最重要的几个位是 WEL(写使能锁存)和 BP1、BP0(块保护选择位)。每次写数据之前,必须先发 WREN 命令把 WEL 置 1,否则 WRITE 命令会被芯片静默忽略。
块保护位 BP1、BP0 可以规定地址范围不可写。比如把 BP1、BP0 配置成 01,就能保护地址空间的高端区域。硬件写保护引脚 WP# 配合状态寄存器里的 WPEN 位一起工作。对于大多数应用,我建议初始化时把状态寄存器清 0,也就是关闭块保护,同时把 WP# 接高,这样软件层面不需要额外处理保护逻辑。
4.3 写保护策略在参数存储中的实际用法
虽然初始化时解除保护,但在实际产品里,块保护还是很有用的。工业设备固件升级后,如果跑飞的代码误操作 SPI,向参数区乱写数据,整台设备的配置就毁了。更安全的做法是:把设备的关键标定参数放在受保护的高地址区,配置好 BP1、BP0 后不再轻易改动;只有进入专门的“标定维护模式”,才临时写状态寄存器解除保护,写完马上恢复保护。
这个思路很像把重要文件设置成只读。MRAM 本身已经不容易写坏数据,但软件层面多一层保护,能让设备在异常情况下更抗造。我最终实现里保留了一个mram_set_protection函数,配合出厂标定流程使用。
5. 完整驱动实现与掉电安全设计
5.1 底层 SPI 收发与片选封装
把前面的 spi1_xfer 和片选操作封装成 MRAM 驱动,是整篇代码的主干。我先放一个功能完整、可以直接抄进工程的版本:
#define MRAM_SIZE (512 * 1024) static void mram_cs_low(void) { MRAM_CS_LAT = 0; } static void mram_cs_high(void) { MRAM_CS_LAT = 1; } void mram_write_enable(void) { mram_cs_low(); spi1_xfer(0x06); mram_cs_high(); } void mram_write_disable(void) { mram_cs_low(); spi1_xfer(0x04); mram_cs_high(); } uint8_t mram_read_status(void) { uint8_t sr; mram_cs_low(); spi1_xfer(0x05); sr = spi1_xfer(0x00); mram_cs_high(); return sr; } void mram_write_status(uint8_t value) { mram_write_enable(); mram_cs_low(); spi1_xfer(0x01); spi1_xfer(value); mram_cs_high(); mram_write_disable(); }5.2 读写函数的正确打开方式
地址空间是 19 位有效,所以读写地址必须拆成 3 个字节。这个拆分要注意字节顺序,MRAM 和绝大多数 SPI 存储芯片一样,都是高字节在前。读函数和写函数结构基本对称,区别是读函数后面跟着的是“空字节”,写函数后面跟着的是“数据”。
int mram_write_buffer(uint32_t addr, const uint8_t *buf, uint32_t len) { if (addr + len > MRAM_SIZE) { return -1; } // MRAM 写必须使能,否则整个写命令被忽略 mram_write_enable(); mram_cs_low(); spi1_xfer(0x02); spi1_xfer((addr >> 16) & 0xFF); spi1_xfer((addr >> 8) & 0xFF); spi1_xfer(addr & 0xFF); for (uint32_t i = 0; i < len; i++) { spi1_xfer(buf[i]); } mram_cs_high(); mram_write_disable(); return 0; } int mram_read_buffer(uint32_t addr, uint8_t *buf, uint32_t len) { if (addr + len > MRAM_SIZE) { return -1; } mram_cs_low(); spi1_xfer(0x03); spi1_xfer((addr >> 16) & 0xFF); spi1_xfer((addr >> 8) & 0xFF); spi1_xfer(addr & 0xFF); for (uint32_t i = 0; i < len; i++) { buf[i] = spi1_xfer(0x00); } mram_cs_high(); return 0; }这里有一点实践经验要提醒:MRAM 的写操作本身不需要等任何内部状态,CS 拉高后数据立即生效,但你最好在写完一帧后做一次回读校验。回读校验不是为了防 MRAM 写错,而是为了查 SPI 通信链路是否被外部干扰。工业现场噪声复杂,偶尔 SDO 线上灌进去一个毛刺,会导致读回的数据和写入的不一致。回读校验加上错误重试,是工业设备里很常见的保险手段。
5.3 Magic + CRC16 + 双区备份的存储布局
真正放到工业设备里的参数存储,不能只是“读写函数能跑就行”。掉电可能发生在任何时刻,哪怕 MRAM 不会因此坏扇区,也可能出现数据写到一半断电,设备恢复后读到一份不完整的数据。要解决这个问题,我在 MRAM 上做了一个简单的存储协议:
存储区按 256 字节为一个逻辑块,块头部固定 8 字节:
- 4 字节 Magic,固定值 0xA5 0x5A 0x3C 0xC3,用来判断该区域是否被初始化过。
- 2 字节块序列号,每次写入递增,用于双区备份里的新旧判断。
- 2 字节数据长度,说明后面有效 payload 是多少。
块尾再加 2 字节 CRC16,覆盖块头部和 payload。写入顺序是:先把 payload、长度、序列号、CRC 全部写进去,最后才写 Magic。读的时候发现 Magic 不对,就认为这个块是“写入未完成”,直接丢弃,去读备份区。
双区备份的逻辑更简单:配置区有 A、B 两块,每次写入时镜像写入两份,序列号大的那份是新的。开机读取时,先读 A,再读 B,选序列号大的、CRC 校验通过的那份作为有效配置。如果一份损坏,另一份还能兜底。这套逻辑虽然不复杂,但对工业设备来说非常管用,毕竟现场维护一次的成本远高于代码里多几十行逻辑的成本。
6. 实测性能、踩坑记录与选型建议
6.1 40MHz 等级 SPI 下的实际读写吞吐
我把 SPI1BRG 配成 3,FPB 80MHz 下 SPI 时钟就是 10MHz。用 mram_write_buffer 连续写 1024 字节,再用 mram_read_buffer 回读,实测单次 1KB 写入加读取的总耗时在 2ms 左右。换算一下,单方向吞吐接近 1.25MB/s,完全够工业日志和参数存储用。
对比 NOR Flash 的体验差异在哪?写 1KB 数据到 NOR Flash,先擦一个扇区可能就要几十毫秒,再页编程,总耗时是以毫秒到几十毫秒为单位跳的。MRAM 的写入时间和读时间几乎一样,没有“写放大”的概念。对运行日志这类一天要写几万次的场景,这种确定性延迟非常重要。
6.2 坑一:HOLD# 悬空让一切读写变成“假成功”
第一次做测试板的时候,我以为 HOLD# 功能不用就真不用管了,结果板子回来后出现一个诡异现象:写 MRAM 后回读,前 16 字节正常,后面全是上一个区域的值,甚至有的区域整整一片不变。用逻辑分析仪抓 SPI 波形,SCK、SI、SO 波形看起来都是对的,说明主机这边的时序完全没问题。
查到最后,问题出在 HOLD# 引脚。测试板在人工手焊时,HOLD# 焊盘旁边有一小坨松香残留,导致引脚和地之间出现了微弱导通。HOLD# 被拉低后,MRAM 在保持模式下会忽略 SCK 边沿,接收到的数据全被丢掉。当时这个现象还带随机性,很迷惑。从此以后,我所有板卡上 WP# 和 HOLD# 一律串上拉电阻固定到 VDD,从源头杜绝问题。如果你也遇到“SPI 波形正确但存储数据不对”的怪问题,优先量一下这两个引脚的电平。
6.3 坑二:忘记 WREN,写操作被芯片悄悄忽略
这个坑属于从 Flash/EEPROM 转过来的人最容易踩的。SPI NOR Flash 写数据之前也有 WREN 的步骤,但有些型号在“非保护区域”允许直接写;EEPROM 的时序则完全没这个要求。MRAM 不一样,WRITE 命令发出去之后,芯片内部先检查状态寄存器的 WEL 位,如果 WEL 为 0,写命令会被直接丢弃,而且状态寄存器的 WEL 不会自动置位。
我有一个非常典型的翻车经历:主函数里初始化完后,没调 mram_write_enable 就调用 mram_write_buffer,写完后回读,发现整片区域还是 0xFF。当时怀疑芯片坏了,还换了一片,结果换完依旧。冷静下来翻了 datasheet 才想起来是 WEL 位没抬起来。所以现在我的每个写函数里,第一行必然是 mram_write_enable,写完后再 mram_write_disable,形成一个完整的“使能-写-禁用”闭环。回读校验也能第一时间发现这种问题。
6.4 一点工程建议
MR25H40CDF 和 PIC32MX664F064L 这个组合,适合那些“频繁写、需要掉电保存、数据量又不小”的工业嵌入式场景,比如电量采集终端、电机驱动器标定参数、轨旁设备黑匣子。MRAM 的缺点是价格比普通 NOR Flash 高,容量上限也没有 Flash 大,所以大容量固件存储还是老老实实用 NOR Flash 或者 SD 卡,MRAM 专注存参数和状态数据就够了。
从这次项目体验来看,MR25H40CDF 的驱动难度比 SPI NOR Flash 还低,因为不需要处理擦除、坏块、页边界这些事。真正要把产品做稳,功夫反而在硬件引脚处理、存储协议设计和回读校验上。以后再做需要频繁小数据写入的嵌入式项目,我第一反应就是先看 MRAM 容量够不够、价格吃不吃得消,而不是再拿 NOR Flash 硬扛。