去年做一台小型伺服驱动器的参数管理单元时,我遇到一个很现实的问题:设备每几十毫秒就要更新状态,掉电瞬间还得把最后位置和工艺参数保存下来,不能丢。用 EEPROM 反复写,寿命撑不住;用 NOR Flash 又牵扯到页擦除、磨损均衡、写入等待,代码越搞越复杂。后来我把存储介质换成 Everspin 的 MR25H40CDF(4Mbit SPI MRAM),主控用 NXP 的 MK20DN128VFM5,这套组合把“频繁写、掉电保存、工业环境可靠”这几个要求一次性解决了。这篇文章就是把当时的选型思路、硬件连接、驱动代码和踩过的坑完整写出来,给正在做同类工业节点、仪器仪表、电机控制项目的朋友一个可以直接抄作业的参考。
1. 为什么工业现场我选 MRAM,而不是继续折腾 Flash 和 EEPROM
1.1 先把需求说清楚:频繁记录、掉电保存、快速响应
工业设备的数据存储往往不是大容量需求,而是对“写”这件事极其苛刻。典型场景有三类:
- 运行状态记录:设备启动后,控制器需要周期性记录电流、温度、位置、报警状态。写频率可能是 10ms 一次,也可能是一分钟一次,但长期积累下来,普通 EEPROM 的百万次擦写寿命根本扛不住。
- 掉电参数保存:伺服电机断电瞬间,必须把当前绝对位置、正在执行的工艺参数、时间戳写进非易失存储。此时硬件只剩储能电容提供的几毫秒窗口,存储介质必须写得够快、够简单。
- 校准参数频繁修改:设备在现场调试时,用户会反复调整 PID 参数、传感器零点、通信地址,每次调整都要立即保存并掉电不丢。
拿 EEPROM 算一笔账就明白问题在哪:一颗常见的 AT24C256,标称写入寿命约 100 万次,如果设备每 10ms 写一次同一地址,100 万次只够用两三个小时。即便用软件做磨损均衡,写一个字节也要先擦除整个页,等待时间较长。NOR Flash 容量大一点,但是按扇区擦除,一个 4KB 扇区擦一次要几十毫秒,而且最怕擦写过程中掉电,扇区变成“半擦半写”状态,恢复逻辑非常麻烦。
MRAM 的逻辑完全不同:它不需要先擦除再写,写入直接覆盖存储单元;寿命标称在 10 的 14 次方次以上,工程上可以当作无限次;写入完成后数据立刻处于非易失状态,不需要像 Flash 那样等待内部编程完成。可以说它是“RAM 的速度 + Flash 的持久性”,唯一的代价是单位容量价格偏高,但工业节点里需要保存的数据通常很小,贵的这部分很值。
1.2 MR25H40CDF 的核心参数与定位
MR25H40CDF 是 Everspin 推出的 4Mbit 串行 MRAM,换算过来是 512KB,通过标准 SPI 接口访问。我在项目里看重它的几个参数:
- 容量 4Mbit,适合存配置参数、报警记录、运行日志这类小但关键的数据。
- 支持标准 SPI 模式 0 和模式 3,接主控方便,几乎任何带硬件 SPI 的 MCU 都能直接驱动。
- 数据手册标称时钟最高 40MHz,我工程上习惯跑 20MHz,留两倍余量。
- 写入无需擦除、无需等待,没有“写完还要轮询忙标志”这种流程。
- 工业温度范围覆盖 -40℃~+85℃,具体档位要看采购后缀,我们的板子在高温老化箱里跑过,表现正常。
说人话就是:它像一片 SRAM,随便读随便写,掉电不丢;又像一块 Flash,可以长期保存数据,但不需要页擦除,也不用考虑磨损。存储单元本身靠磁阻状态保存数据,写入过程是磁性状态翻转,不是电荷的充放电,所以从原理上不存在电荷泄漏导致的寿命问题。
1.3 MK20DN128VFM5 为什么是好搭档
MK20DN128VFM5 属于 NXP Kinetis K20 系列,Cortex-M4 内核,最高 48MHz,128KB Flash、32KB RAM,QFN32 小封装,3.3V 供电,工业温度版本。选它和 MR25H40CDF 搭配,有几个直接原因:
第一,电平匹配,都是 3.3V 系统,不需要额外电平转换。第二,K20 内置硬件 DSPI 模块,可以跑 SPI 主模式,20MHz 时钟完全够,还能配合 DMA 做大块数据搬运,读写 512KB 日志时不会占用 CPU。第三,这芯片虽然个头小,但外设完整,UART、I2C、FlexTimer 都有,适合同时干传感采集、PWM 输出、通信协议栈这种活。第四,NXP 的 MCUXpresso SDK 对 Kinetis 系列支持很成熟,SPI 驱动可以直接调用,省掉自己造轮子的时间。
工业场景里这颗 MCU 经常被用在电机控制、变送器、数据采集前端,而 MRAM 正好补上它片上 Flash 容量小、频繁写不方便的短板。一个负责算,一个负责存,角色分得很清楚。
2. 硬件连接与电气细节:让 MRAM 在强干扰下不“犯浑”
2.1 SPI 引脚分配与控制信号
MR25H40CDF 的 SPI 引脚不算多,核心是 CS\、SCK、SI、SO 这四根,另外有 WP\ 写保护脚和 HOLD\ 暂停脚。连接关系如下:
| MRAM 引脚 | 连接目标 | 说明 |
|---|---|---|
| VDD | 3.3V 电源 | 靠近管脚放去耦电容 |
| GND | 系统地 | 保证回路短粗 |
| CS\ | MCU GPIO 或 DSPI PCS0 | 低电平选中,带 10kΩ 上拉 |
| SCK | MCU 的 SPI SCK | SPI 时钟 |
| SI | MCU 的 SPI MOSI | 主发从收 |
| SO | MCU 的 SPI MISO | 主收从发 |
| WP\ | 10kΩ 上拉到 VDD | 低电平使能写保护 |
| HOLD\ | 10kΩ 上拉到 VDD | 低电平暂停 SPI 通信 |
Kinetis 的引脚复用比较灵活,同一个引脚可能映射多种外设功能,所以 PCB 上到底用哪个物理引脚,一定要查 MK20DN128VFM5 数据手册里的 Signal Multiplexing 表,把对应端口的 PCR 寄存器 MUX 位配到 SPI 功能。我自己的经验是:先把硬件原理图对应的引脚写死在工程头文件里,再在驱动初始化时统一配置 PORT 复用,避免后面改板时软件和硬件对不上。
2.2 WP\ 和 HOLD\ 这三个“小细节”千万别偷懒
我第一次画板时,想着 WP\ 和 HOLD\ 平时用不到,直接悬空,结果调试时数据一会儿能写一会儿不能写,非常折腾。这两个脚必须给确定电平:
- WP\ 拉高,允许正常写;拉低则写操作被忽略,适合做防误写保护。
- HOLD\ 拉高,SPI 正常通信;拉低会让 SCK 暂停,数据线状态被锁存。
工业板卡上我推荐都用 10kΩ 电阻上拉到 VDD,不要直接短接。原因很简单:如果后续想用 MCU 的 GPIO 控制写保护,只需要在电阻节点处再接一条 GPIO 线,改软件就行,不用改 PCB。HOLD\ 功能如果确定不用,上拉到 VDD 就完事。
VDD 去耦电容也是老生常谈但真的有人不重视。MRAM 功耗不大,但周围常有电机驱动、继电器、开关电源,VDD 上的纹波会造成读写不稳定。我在 MRAM 的 VDD 管脚 1mm 以内放 0.1uF 陶瓷电容,旁边再放一个 1uF 电容,条件允许就加一颗小磁珠串联在 VDD 供电线路上。实测在电机启停瞬间,这个措施有效削减了读写错误。
2.3 上电时序和 PCB 布线层面的工业经验
工业环境里,MCU 和 MRAM 的上电顺序不一定被严格保证。如果 MCU 的 SPI 引脚在复位期间输出低电平,而 CS\ 又没有上拉,MRAM 可能在上电瞬间被“假选中”,收到一串无用时钟,导致内部状态错位。规避方案很简单:
- CS\ 必须接上拉电阻,保证上电时处于高电平。
- MCU 初始化代码里,先把 CS\ 配成 GPIO 输出并置高,再初始化 SPI 外设。
- 如果板上 SPI 走线较长,可以在 SCK 和 MOSI 上各串一个 22Ω 到 33Ω 电阻,抑制振铃。
PCB 布局上,SPI 信号线尽量远离逆变桥、继电器驱动、PWM 输出这些强干扰源,至少保持 10mm 距离。板子面积允许的话,SCK、MOSI、MISO 下面铺完整地平面,让信号回路面积尽量小。MRAM 如果要装在靠近连接器的位置,连接器处加 TVS 管做 ESD 保护,避免现场插拔线缆时静电打坏芯片。
3. 把 MR25H40CDF 的 SPI 命令读透
3.1 最小命令集:读、写、写使能、读状态
MRAM 虽然是“新物种”,但它的 SPI 命令集延续了串行存储器的老传统,学起来不费劲。我实际项目里用到的命令不超过五个:
| 命令 | 操作码 | 功能 |
|---|---|---|
| WREN | 0x06 | 写使能,把状态寄存器里的 WEL 位置 1 |
| WRDI | 0x04 | 写禁止,清 WEL 位 |
| WRITE | 0x02 | 写内存数据,3 字节地址 + 数据 |
| READ | 0x03 | 读内存数据,3 字节地址 + 连续输出 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器,配置块保护 |
| RDID | 0x9F | 读设备 ID,产线自检可用 |
地址发送采用 3 字节方式,地址高位在前。MR25H40CDF 是 4Mbit,也就是 512KB,地址只需要 19 位,实际范围 0x00000 到 0x7FFFF,发送 3 字节地址时最高字节只有低 2 位有意义。
这里有个容易被忽略的点:很多串行存储器的写操作前都要先发 WREN,把 WEL 位置 1,否则写入无效。我最早用 MR25H40 时也默认它和普通 SPI RAM 一样不需要,结果读回的全是旧数据。后来的做法是驱动封装里统一“先 WREN,再 WRITE”,多条指令成本极低,兼容性却好很多。即便你的批次手册明确写内存阵列不需要 WEL,多这一步也不会出错。
3.2 单字节读写与页写:什么时候用哪个
READ 命令的时序很简单:CS\ 拉低,发 0x03,再发 3 字节地址,然后持续产生 SCK,MRAM 从当前地址开始连续输出数据。CS\ 拉高时结束。地址会在内部自动递增,读完 0x7FFFF 后回卷到 0x00000。
WRITE 命令也是类似结构:CS\ 拉低,发 0x02,发 3 字节地址,然后发待写数据。一个 WRITE 事务最多可以写入 256 字节,超出 256 字节后地址会回卷到当前 256 字节块的起始位置。这和很多 SPI Flash 的 page 概念一致,但区别在于 MRAM 不需要先擦除这一页,直接覆盖写就可以了。
实际使用时分两种情况:
- 单字节或少量字节写:比如保存一个 4 字节的绝对位置,直接用 WRITE 带一个数据即可。
- 多字节连续写:比如一次性写入 120 字节的日志记录,可以拼成一个 WRITE 事务,但要注意不要跨越 256 字节边界。我建议在驱动层做一个分包函数:先算当前地址到本页末尾还剩多少字节,然后按剩余长度切片发送,最后再把剩下的数据写到下一页。
3.3 用 RDID 做产线自检,比盲目读写靠谱
MRAM 的 RDID 命令(0x9F)会返回一串厂商和器件识别码。产线组装后,如果每块板子都能正确读到预设的 ID,说明 SPI 接线、焊接、电源基本没问题。如果直接做“写一个数再读回来”的自检,一旦 MRAM 的 WP\ 被意外拉低,会出现“写失败读成功”的假阳性,反而不好定位。
我建议把 RDID 放在上电自检流程里,和内部 Flash 校验、传感器校准检查并列。读回来的字节和手册给出的厂商 ID、器件 ID 比对,匹配才能进入正常运行状态。如果读到的数据全 0xFF,多半是 CS\ 没拉下来或 MISO 没接对;如果读到的是 0x00,多半是焊接短路。
不过要注意:不同批次、不同封装的 MR25H40 识别码可能略有差异,最好以实际购买芯片的数据手册为准,不要把某个数值写死在代码里,而是预留一个配置表,换批次时只改表不翻代码。
4. 驱动代码落地:从 SPI 初始化到读写封装
4.1 MK20 DSPI 初始化的两种姿势
用 Kinetis 写 SPI 驱动,有两种常见路子。第一种是直接用 SDK 里的函数,适合赶项目的场景。以 MCUXpresso SDK 为例,初始化流程大致是:
spi_master_config_t config; SPI_MasterGetDefaultConfig(&config); config.baudRate_Bps = 20000000U; config.polarity = kSPI_ClockPolarityActiveHigh; config.phase = kSPI_ClockPhaseFirstEdge; SPI_MasterInit(SPI0, &config, CLOCK_GetCoreSysClkFreq());这段代码把 SPI0 配成主模式、20MHz、CPOL=0/CPHA=0,也就是模式 0。之后收发数据用SPI_MasterTransfer或者更底层的SPI_WriteReadData这类接口,具体函数名视 SDK 版本略有不同。
第二种是寄存器级操作,适合想彻底掌控时序或者不用 SDK 的情况。Kinetis 的 DSPI 核心配置是 MCR 里的 MSTR 位置位,以及 CTAR0 里的帧大小、CPOL、CPHA、波特率分频。大致结构如下:
// 使能 SPI0 时钟 SIM->SCGC4 |= SIM_SCGC4_SPI0_MASK; // 配置引脚复用为 SPI0 功能,具体引脚按原理图确定 PORTB->PCR[3] = PORT_PCR_MUX(2); PORTB->PCR[4] = PORT_PCR_MUX(2); PORTB->PCR[5] = PORT_PCR_MUX(2); // 设置 SPI 主模式 SPI0->MCR = SPI_MCR_MSTR_MASK; // 配置 CTAR0:8 位帧、模式 0、波特率按外设时钟分频 SPI0->CTAR0 = SPI_CTAR_FMSZ(7) | SPI_CTAR_PBR(0) | SPI_CTAR_BR(1) | SPI_CTAR_CPOL(0) | SPI_CTAR_CPHA(0);寄存器的具体位定义不同芯片版本有细微差异,我建议还是以 SDK 头文件里的宏定义为准,别凭记忆硬写。工程实践上,我的原则是:底层 SPI 收发函数保持单一入口,上层代码全部调用抽象接口。这样即使从寄存器版换成 SDK 版,MRAM 驱动一行都不用改。
4.2 MRAM 驱动封装:先定义 HAL 再写接口
不管底层用哪种 SPI 方式,MRAM 驱动最好都建立在四五个基础操作上:
- spi_xfer:收发一个字节
- cs_low / cs_high:控制片选
- 延时函数:很少用到,但保留
以最简驱动为例:
static uint8_t spi_xfer(uint8_t tx) { uint8_t rx; // 调用你的底层 SPI 收发函数, // 例如 SDK: SPI_MasterWriteRead(SPI0, &tx, &rx, 1); // 或寄存器版:写 PUSHR,读 POPR。 return rx; } #define MRAM_CMD_WREN 0x06 #define MRAM_CMD_WRITE 0x02 #define MRAM_CMD_READ 0x03 #define MRAM_CMD_RDSR 0x05 #define MRAM_CMD_WRSR 0x01 #define MRAM_CMD_RDID 0x9F static void mram_cs_low(void) { // GPIO 拉低 CS } static void mram_cs_high(void) { // GPIO 拉高 CS }有了 HAL 层,后面的接口就很自然了。写使能封装:
int mram_write_enable(void) { mram_cs_low(); spi_xfer(MRAM_CMD_WREN); mram_cs_high(); return 0; }单字节读写封装:
int mram_write_byte(uint32_t addr, uint8_t data) { // 统一先 WREN,再 WRITE mram_write_enable(); mram_cs_low(); spi_xfer(MRAM_CMD_WRITE); spi_xfer((addr >> 16) & 0xFF); spi_xfer((addr >> 8) & 0xFF); spi_xfer(addr & 0xFF); spi_xfer(data); mram_cs_high(); return 0; } int mram_read_bytes(uint32_t addr, uint8_t *buf, uint32_t len) { mram_cs_low(); spi_xfer(MRAM_CMD_READ); spi_xfer((addr >> 16) & 0xFF); spi_xfer((addr >> 8) & 0xFF); spi_xfer(addr & 0xFF); for (uint32_t i = 0; i < len; i++) { buf[i] = spi_xfer(0xFF); } mram_cs_high(); return 0; }4.3 页写函数:避免地址回卷这个大坑
多字节连续写要特别注意 256 字节边界。MRAM 的 WRITE 命令允许一次连续写最多 256 字节,超过这个边界后地址自动回卷到本块起始地址。如果你一次要写的数据是 300 字节,直接连发 300 个数据,后 44 字节会写到块开头,把已经写好的数据覆盖掉。
正确的做法是分包:
#define MRAM_PAGE_SIZE 256U int mram_write_bytes(uint32_t addr, const uint8_t *buf, uint32_t len) { while (len > 0) { uint32_t page_remain = MRAM_PAGE_SIZE - (addr % MRAM_PAGE_SIZE); uint32_t chunk = (len > page_remain) ? page_remain : len; mram_write_enable(); mram_cs_low(); spi_xfer(MRAM_CMD_WRITE); spi_xfer((addr >> 16) & 0xFF); spi_xfer((addr >> 8) & 0xFF); spi_xfer(addr & 0xFF); for (uint32_t i = 0; i < chunk; i++) { spi_xfer(buf[i]); } mram_cs_high(); addr += chunk; buf += chunk; len -= chunk; } return 0; }这段代码的核心逻辑是:先算当前地址距离本页末尾还剩多少字节,本次最多写这么多,写完再移动到下一页。MRAM 不需要等待页擦除,所以包与包之间不需要额外延时,只要能保证 CS\ 正确拉高拉低即可。
驱动写完以后,我建议做一个全片自检流程:按页写入递增数据,读回比对;然后写 0xAA 和 0x55 交替,再读回比对。这能在开发早期把大多数接线问题暴露出来。
5. 现场调试最容易踩的四个坑
5.1 SPI 时钟极性与相位对不上
症状:读出来的数据整体是对的,但第一个字节偶尔错,或者每个字节的某个位稳定出错。
原因基本都是 MCU 和 MRAM 的 CPOL/CPHA 没配对。MR25H40CDF 支持模式 0(CPOL=0,CPHA=0)和模式 3(CPOL=1,CPHA=1),但如果你配成模式 1 或模式 2,采样沿和输出沿就会错位。SPI 没有像 I2C 那样的应答机制,错位了不会报错,只是数据在边界上不稳。
排查时不要先怀疑芯片,直接用示波器看 SCK、MOSI、MISO 三根线的相对位置。正常的模式 0 下,主机在 SCK 上升沿锁存 MOSI,MRAM 在 SCK 下降沿切换数据。如果看到数据变化沿和采样沿重叠,那就是相位反了。改配置里的一两个参数就好。
5.2 片选信号毛刺导致“丢首字节”
症状:代码里明明是连续读写,偶尔会整体偏移一个字节,像是第一个命令字节被吞了。
这类问题经常发生在上电瞬间或者 GPIO 复用配置还没完成的时候。CS\ 在复位期间如果处于不确定电平,MRAM 可能误以为被选中,此时 SCK 上的噪声进入芯片,让内部状态机跳到错误命令。等 MCU 真正发起 READ 时,MRAM 可能还在消化前面的伪命令,第一个字节就丢了。
解决方法是 CS\ 加 10kΩ 上拉,MCU 初始化时先把 CS\ 配置为 GPIO 输出并拉高,再做 SPI 外设初始化。如果用的是 Kinetis DSPI 的硬件 PCS 片选,记得检查 MCR 里的 DIS_RXF、DIS_TXF 位和 PCSIS 位,确保上电时 PCS 处于高电平。
5.3 WP\ 和 HOLD\ 悬空,数据“写不进去”
症状:读操作一切正常,写操作也看不到错误,但每次读回都是旧数据。
这个坑我前面提过,但值得单独再说一次。WP\ 如果被拉低,整个内存阵列的写操作都会被忽略,而读操作完全不受影响。HOLD\ 如果被拉低,SCK 信号会被内部锁存,后续时钟变化不生效,表现为数据半截卡住。
排查方法是先用万用表量 WP\ 和 HOLD\ 引脚电平。工业板卡上,这两个脚附近如果有走线毛刺,也可能把电平短暂拉低。我的习惯是在这两个脚上都放一个 10kΩ 上拉,并且串联一个 0Ω 电阻到电容做滤波,方便调试时断开测量。
5.4 高速时钟配合长走线,误码率上升
症状:低速下读写没问题,把 SPI 时钟提到 30MHz 或者走线超过 10cm 后,读到的数据偶尔出现错位、跳变。
这是典型的信号完整性问题。工业板上如果没有足够的铺地,SCK 和 MOSI 之间的串扰、反射都会在高频下放大。MR25H40CDF 虽然支持 40MHz,但工业环境里没有必要跑满速。
我的建议是:默认跑 20MHz,如果走线长或者板上干扰源多,降到 10MHz 更稳。SCK、MOSI 上串 22Ω 电阻放在源端,MISO 走线保持短直,不要跨过分割的地平面。大量读数据时,可以在读取完成后对整块数据做一次 CRC 校验,发现错误就重新读一遍。工业数据存储不差这两三毫秒的校验时间,可靠性优先。
6. 往深处做:配置区、日志区和掉电关键数据的组织方式
6.1 把 512KB 规划成三块区域
MRAM 容量虽然只有 512KB,但合理分区以后,工业节点完全够用。我常用的划分方式是这样的:
| 区域 | 地址范围 | 容量 | 用途 |
|---|---|---|---|
| 配置区 | 0x000000 ~ 0x001FFF | 8KB | 设备参数、校准系数、通信配置 |
| 事件区 | 0x002000 ~ 0x0FFFFF | 约 62KB | 报警记录、操作日志,循环覆盖 |
| 采集区 | 0x100000 ~ 0x7FFFFF | 约 448KB | 高频采集的数据暂存区 |
配置区虽然只有 8KB,但对 512KB 来说已经很大,一条参数记录几十字节,能存几百条历史版本。事件区每条日志固定 64 字节,能存约 1000 条,循环写入。采集区用来放高速采样点,定时上送到上位机后再清空。
分区的好处是让日志循环写不会踩到配置参数,避免一个意想不到的越界把校准数据冲掉。代码里可以用几个宏定义把分区地址写死,读写接口都带分区校验。
6.2 带 CRC 和版本号的配置记录
工业数据存储最怕静默损坏,所以我在 MRAM 里的配置记录都带固定帧头:
| 帧头 (2B) | 版本号 (2B) | 数据长度 (2B) | 数据体 (N B) | CRC16 (2B) |写入时把整条记录拼好,一次写入配置区;读取时先验证帧头,再对数据体做 CRC 校验。校验不过就认为这条记录无效,尝试读备份记录。
这个方法在普通 EEPROM 上也能做,但 MRAM 的方便之处在于修改单字节参数时,不需要先读整个页、擦除、再重写整页。我可以只更新当前记录所在小区域,连续写入两次形成 A/B 备份,代码量少很多。
6.3 掉电事务处理与双备份设计
掉电瞬间保存数据,很多工程师第一反应是加个大电容,让系统有时间完成“读、改、擦、写”全套流程。用 MRAM 之后,这个过程被大大简化:掉电中断里只需要把关键 4 字节拼成一个 WRITE 事务发出去,CS\ 拉高,就结束了。因为不需要擦除,也几乎没有内部编程等待,对储能电容的要求低很多。
如果系统要求更高,比如防止掉电瞬间写入中途被打断,可以再用双备份加指针的方式:把同一份参数写两份,分别放在配置区的两个槽位。每次启动读取两份,CRC 都通过就取版本号大的一边;只有一边通过就用另一边;两边都通过且版本号相同,说明上次写入完整结束。
MRAM 没有擦写次数限制,所以双份写也不会产生磨损问题。这套“双备份 + CRC + 版本号”的组合,我在现场测试里基本没再遇到过参数丢失的情况。
最后分享一个小体会:MRAM 不是用来替代大容量 Flash 的,它的定位是“小但关键的数据,频繁写,不能错”。做工业设备选型时,不要只盯着算力或容量,先想清楚系统里哪些数据掉电不能丢、多久写一次、能接受多长的写入时间,答案基本就出来了。MR25H40CDF 和 MK20DN128VFM5 这套组合,对付这类需求非常顺手。