做嵌入式搞过数据采集和掉电保存的人,应该都被两类存储折磨过:EEPROM 容量小、擦写慢,NOR Flash 擦除单位大、寿命有上限。MRAM 正好卡在两者中间,同时拿到“非易失”和“随便写”两个优势——MR25H40CDF 是 Everspin 的 4Mbit SPI MRAM,512KB 容量,可以按字节频繁写,断电不丢数据;配合 STM32F469II 这颗 180MHz 的 Cortex-M4F 主控,这套组合在工业数据记录、参数存储、故障日志等场景里非常能打。这篇文章就围绕这个组合,把选型思路、硬件接线、驱动实现和工程化避坑完整过一遍,给正在做嵌入式存储方案的朋友做一个能直接复用的参考。
1. 选型思路:为什么是 MRAM,而不是更便宜的 Flash/EEPROM
1.1 工业存储的普遍痛点
工业现场做存储,通常不是“不会存”,而是“不敢存”。典型场景包括:设备每次动作的状态记录、变频器/伺服驱动的运行参数、PLC 外扩的报文缓存、数据采集器里的事件日志。这些数据的特点是写入次数频繁、单条长度不大、要求断电不丢、最好还能在恶劣温度下保持稳定。
用 EEPROM,像 AT24C256,容量只有 32KB,写入寿命大约 100 万次,如果设备每秒钟记录一条日志,用不了多久就到寿命上限了。用 NOR Flash,寿命好一点,但擦除单位动不动就是 4KB 或 64KB,为了改一个字节得先把整块擦掉再重写,写放大严重,掉电时还容易把数据弄丢。而且 Flash 写入是以“页”为单位的,频繁追加日志时,总感觉在跟算法较劲。
MRAM 刚好把这些问题全部绕开了:按字节写、不需要擦除、寿命比 EEPROM 高出好几个数量级、掉电不丢。这种特性在数据记录类应用里非常难得,所以工业设备的非易失存储越用越偏 MRAM,不是没有道理的。
1.2 MRAM 技术原理与 MR25H40CDF 的关键规格
MRAM 全称 Magnetoresistive Random Access Memory,磁阻式随机存储器,核心存储单元是磁性隧道结。通俗点说,数据不是靠电荷保存的,而是靠磁性材料的磁化方向保存的。写操作就是改变磁矩的方向,读操作就是感知磁阻的变化。没有电荷泄漏,没有擦除周期,所以断电后数据天然保持,也几乎不存在写寿命问题。
MR25H40CDF 是 4Mbit 也就是 512KB 的 SPI 接口 MRAM,工业级温度范围。它和普通 SPI NOR Flash 长相类似,都是 CS#、SCK、SI、SO 这几个脚,但它内部不分区、不按页、不擦除,任何地址都能直接写。数据保持时间达到 20 年级别,写耐久度在 10^14 次以上,也就是说按每秒写 100 次算,也能跑很多年,基本不用考虑写穿的问题。
它还有一个很关键的特点:读写速度和 SRAM 处于同一数量级。如果 SPI 时钟跑到 40~50MHz,连续读写的有效带宽在 4~5MB/s 左右,这在非易失存储里算很快的。很多工程师第一次用 MRAM 会觉得“这哪是存储芯片,分明就是个掉电不丢的 SRAM”,这是对它最准确的定位。
1.3 为什么配 STM32F469II 而不是低端 M0
选 STM32F469II 不选 STM32G0 或者 STM32L4,核心原因是应用场景的复杂度。F469II 是 STM32F4 系列里资源非常丰富的一颗 MCU,Cortex-M4F 内核带 FPU,主频 180MHz,Flash 和 RAM 容量都很大,而且外设齐全:多个 SPI、FMC、LCD-TFT 控制器、SDRAM 接口、DMA 通道多。做高端工业人机交互设备时,往往既要带屏、又要跑复杂的控制算法、还要存储现场数据,这种场景下 F469II 能一把抓。
从存储驱动的角度看,F469II 的 SPI 外设挂在 APB2 上,SPI1 最高可以跑到 45Mbit/s,正好能把 MR25H40CDF 的带宽吃得比较满。同时它有足够的 DMA 通道,可以一边收传感器数据,一边把数据搬运到 MRAM,CPU 不用盯着字节挨个搬。相比低端 MCU,这套方案留出的性能余量大,以后加协议栈、加图形界面,也不至于推倒重来。
2. 硬件连接与板级注意点
2.1 MR25H40CDF 引脚与接线表
MR25H40CDF 常见封装是 SOIC-8 或 DFN-8,引脚功能保持 SPI NOR Flash 的通用习惯。除了电源和地,核心信号是 CS#、SCK、SI(MOSI)、SO(MISO),另外还有 WP#(写保护)和 HOLD#(暂停通信)两个控制脚。如果不需要动态切换写保护或者暂停通信,习惯上把 WP# 和 HOLD# 直接上拉到 3.3V,保证芯片随时可写、通信不被意外打断。
实际接线建议如下表:
| MR25H40CDF 引脚 | 连接到 STM32F469II | 说明 |
|---|---|---|
| CS# | PA4 | GPIO 推挽输出,片选信号 |
| SCK | PA5 | SPI1_SCK,复用功能 AF5 |
| SI | PA7 | SPI1_MOSI,复用功能 AF5 |
| SO | PA6 | SPI1_MISO,复用功能 AF5 |
| WP# | 3.3V | 不用写保护则上拉 |
| HOLD# | 3.3V | 不使用则上拉 |
| VDD | 3.3V | 靠近引脚放 100nF 去耦 |
| GND | GND | 尽量短回路 |
如果你的板子 PA4~PA7 被占用了,STM32F469II 的 SPI1 还能重映射到 PB3/PB4/PB5 等其他引脚组,具体以参考手册上的复用表为准。硬件上有一点要提醒:CS# 不要直接悬空靠上拉,一定要由 GPIO 明确控制,否则上电瞬间芯片可能会处于未选中状态,总线上的毛刺容易灌进芯片。
2.2 STM32F469II 侧 SPI 引脚与时钟配置
STM32F469II 的 SPI1 挂在 APB2 总线,APB2 默认时钟可以配到 90MHz,所以 SPI1 的时钟源是 90MHz,分频系数最低是 2,也就是最快 45Mbit/s。MR25H40CDF 的 SPI 峰值时钟如果标到 50MHz,那 45Mbit/s 已经非常接近满载了,工程上可以接受。如果希望留更多时序余量,可以分频系数选 4,跑 22.5Mbit/s,读写速度依然快过绝大多数 Flash 方案。
SCK 极性和相位方面,MR25H40CDF 支持 SPI Mode 0 和 Mode 3,也就是 CPOL=0/CPHA=0 或者 CPOL=1/CPHA=1。STM32 做主机时两种都能配,我习惯用 Mode 0,因为绝大多数逻辑分析仪默认波形就是按 Mode 0 看的,排查问题方便。F469II 的 SPI 配置里还要注意数据帧长度,MRAM 按字节操作最方便,设 8 位数据帧就够了;如果某次想连续写 16 位变量,可以把宽数据拆成两个字节,也可以临时切 16 位帧,但驱动逻辑会复杂些,不建议默认这么做。
2.3 电源、去耦与布局建议
工业环境里电源纹波是嵌入式存储最常见的隐形杀手。MR25H40CDF 工作电压是 3.3V,规格书上通常允许一定范围,但工程上最好保证供电纹波不超过 50mV,尤其在写入瞬间,电流变化快,电源不稳可能造成偶发数据错误。
我的习惯是在芯片 VDD 引脚旁边放 100nF 陶瓷电容,如果空间允许再并联一个 1~10μF 的钽电容或大容量陶瓷电容。PCB 走线方面,CS#、SCK、SI、SO 四条线尽量短,不要跨越大的电源平面分割区域;MISO 线上可以串一个 22Ω 电阻,减少振铃,SPI 时钟超过 20MHz 时这个电阻基本是必须的。另外 WP# 和 HOLD# 上拉电阻放在芯片附近,防止上电瞬间这两个脚悬空导致异常状态。
3. 驱动实现:从 SPI 初始化到可靠读写
3.1 命令集与协议流程
MR25H40CDF 的命令集和 SPI NOR Flash 很像,但没有擦除命令,这一点在写驱动时必须彻底转变思路。最常用的命令有这些:
| 命令 | 字节码 | 作用 |
|---|---|---|
| WRITE ENABLE | 0x06 | 设置写使能锁存,后续写命令才能生效 |
| WRITE DISABLE | 0x04 | 取消写使能锁存 |
| READ STATUS | 0x05 | 读取状态寄存器 |
| READ DATA | 0x03 | 读取任意地址数据,地址自动递增 |
| FAST READ | 0x0B | 高速读,适合把时钟拉满的场景 |
| WRITE DATA | 0x02 | 写入任意地址数据,可连续写任意字节 |
每次写操作之前必须先发 WRITE ENABLE,否则数据写不进去。这一点和 Flash 一样,但不像 Flash 那样需要等待“页擦除完成”,MRAM 的写入在 CS# 拉高的瞬间就生效了,不需要轮询 BUSY 位。状态寄存器里的写使能锁存位(WEL)可以用来确认命令是否被正确接收,读回验证是最稳妥的做法。
3.2 标准读写函数代码
下面这段是基于 STM32 HAL 库的读写函数,配合 SPI1 和 PA4 片选引脚。函数里把地址拆成三字节,写数据时先写命令头,再连续送数据,最后拉高 CS# 完成写入。读取时也是先送地址,再持续接收数据。
#include "stm32f4xx_hal.h" extern SPI_HandleTypeDef hspi1; #define MRAM_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define MRAM_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) #define MR25H40_CMD_WRITE_ENABLE 0x06 #define MR25H40_CMD_WRITE_DISABLE 0x04 #define MR25H40_CMD_READ_STATUS 0x05 #define MR25H40_CMD_READ_DATA 0x03 #define MR25H40_CMD_WRITE_DATA 0x02 #define MR25H40_CAPACITY 0x80000 /* 512KB */ static uint8_t mram_read_status(void) { uint8_t cmd = MR25H40_CMD_READ_STATUS; uint8_t val = 0; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); HAL_SPI_Receive(&hspi1, &val, 1, 10); MRAM_CS_HIGH(); return val; } static int mram_write_enable(void) { uint8_t cmd = MR25H40_CMD_WRITE_ENABLE; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); MRAM_CS_HIGH(); if ((mram_read_status() & 0x01) == 0) { return -1; /* WEL 位没有置起,写使能失败 */ } return 0; } int mram_write(uint32_t addr, const uint8_t *buf, uint32_t len) { uint8_t head[4]; if (addr + len > MR25H40_CAPACITY) { return -1; } if (mram_write_enable() != 0) { return -1; } head[0] = MR25H40_CMD_WRITE_DATA; head[1] = (uint8_t)(addr >> 16); head[2] = (uint8_t)(addr >> 8); head[3] = (uint8_t)addr; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, head, 4, 10); HAL_SPI_Transmit(&hspi1, (uint8_t *)buf, len, 100); MRAM_CS_HIGH(); return 0; } int mram_read(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t head[4]; if (addr + len > MR25H40_CAPACITY) { return -1; } head[0] = MR25H40_CMD_READ_DATA; head[1] = (uint8_t)(addr >> 16); head[2] = (uint8_t)(addr >> 8); head[3] = (uint8_t)addr; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, head, 4, 10); HAL_SPI_Receive(&hspi1, buf, len, 100); MRAM_CS_HIGH(); return 0; }这里有一个 STM32 HAL 的细节:调用HAL_SPI_Receive接收数据时,MOSI 线上其实是持续发送 0x00 或 0xFF 的,目的是给从机提供采样时钟。MRAM 对 MOSI 上的数据不敏感,所以读函数直接用HAL_SPI_Receive是安全的。如果你用的是中断或 DMA 方式,同样不用关心 MOSI 的占位值。
3.3 性能测算与实测口径
很多人关心 MRAM 到底能跑多快,这里给一个简单测算。假设 SPI 时钟 40MHz,写入 1KB 数据:一次写命令需要 4 字节的命令和地址开销加上 1KB 数据,总共 1028 字节,每字节 8 个时钟周期,合计约 8224 个时钟周期,除以 40MHz,大约是 205μs。再加上写使能命令和 CS# 切换,总耗时也就 210μs 左右。如果是连续批量写,数据吞吐量可以到 4~5MB/s。
对比一下,SPI NOR Flash 单页编程通常是 0.3~3ms,写 1KB 往往需要拆多页,加上擦除时间,普遍要 10ms 以上。MRAM 在这种场景下的优势不是快了一倍两倍,而是快了几十倍。我在实际设备上用逻辑分析仪抓过时序,单次 512 字节写入加读回校验,总时间大概 280μs,非常稳定,这在需要高频记录现场工况的设备里是质的提升。
4. 工程化落地:掉电保护、环形日志与并发安全
4.1 掉电保存的关键窗口设计
MRAM 本身不怕断电,但 MCU 掉电瞬间如果正在写数据,这次写入可能不完整。解决思路是在系统检测到电压跌落时,通过中断优先把最关键的数据写进 MRAM。
STM32F469II 自带可编程电压检测器 PVD,可以设定一个阈值,比如 3.05V。电压低于阈值会触发 EXTI 线 16 中断,此时 3.3V 电源可能还有几毫秒的维持时间,足够把几十个字节的关键标志位、状态机当前状态、故障原因码写进 MRAM。这个中断里不适合做复杂事情,只做一件事:紧急写关键缓存。代码里可以这样挂:
void PVD_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_PVD_VECTOR) != RESET) { mram_write(FAULT_FLAG_ADDR, (uint8_t *)&g_fault_record, sizeof(g_fault_record)); EXTI_ClearITPendingBit(EXTI_PVD_VECTOR); } }要注意的是,PVD 中断里调阻塞式 SPI 发送虽然不优雅,但在掉电窗口里是能接受的。关键是写数据量必须小,几十字节以内,不要把日志的完整搬运放在掉电中断里。正确的设计是平时把日志源源不断写进 MRAM,掉电时只补一个“设备正常/异常停机”的短记录,这样既有完整数据,又不会在最后关头掉链子。
4.2 环形日志与参数区数据布局
MRAM 可以按字节任意写,这为环形日志提供了很舒服的实现条件。我不建议在这种芯片上硬跑文件系统,因为 512KB 空间不大,文件系统的目录管理反而增加写放大。更实用的做法是把它划成三块:参数区、日志区、系统标志区。
参数区放在开头,固定长度,比如 64KB,用来存设备配置、标定值、用户设置。每次修改参数只写对应偏移地址,覆盖即生效。日志区放在中间,设计成环形缓冲区,头部记录写索引和长度,每一条日志定长,比如 32 字节,写入时直接计算偏移,覆盖最旧的数据。系统标志区放在最后,存一些需要原子更新的标志位,比如开机次数、上次掉电原因、累计运行时间。
日志区用环形缓冲的代码逻辑不复杂,核心公式是写入位置取模:
uint32_t offset = LOG_BASE_ADDR + (log_index % LOG_ENTRY_COUNT) * LOG_ENTRY_LEN; mram_write(offset, (uint8_t *)&entry, sizeof(entry)); log_index++;由于 MRAM 不用先擦除,覆盖旧记录就是零成本操作,不存在“写满之后需要搬移”的尴尬。如果用的是 Flash,环形日志写满后必须预留一个大块做擦除和搬移,那个调度逻辑在裸机和 RTOS 下都很讨人嫌。MRAM 把这一整块复杂性直接删掉了,代码写起来非常清爽。
4.3 RTOS 多任务下的互斥与 DMA 缓冲区问题
如果项目里跑了 FreeRTOS 或 RT-Thread,MRAM 的 SPI 操作必须加互斥保护。两个任务同时在写 MRAM,一个正在写数据流,另一个插进来发命令,会导致 CS# 时序完全错乱,轻则写失败,重则把日志区的数据混在一起。正规做法是为 MRAM 操作定义一个 Mutex 或关调度器临界区,确保一次读或写操作占据完整的 CS# 低电平窗口。
static SemaphoreHandle_t mram_mutex; void mram_task_write(void) { xSemaphoreTake(mram_mutex, portMAX_DELAY); mram_write(addr, buf, len); xSemaphoreGive(mram_mutex); }还有一个 STM32F4 特有的坑:F469 内部有一块 CCM SRAM,只能 CPU 直接访问,DMA 无法访问。如果你的 SPI DMA 缓冲区不小心分配到了这块区域,传输会一直卡死。解决办法是确保缓冲区定义在普通 SRAM,并且在 CubeMX 生成代码或链接脚本里不要把 SPI 缓冲放到 CCM。这个坑在调试时非常隐蔽,因为单步执行 CPU 写没问题,切到 DMA 就失败,排查到怀疑人生。
5. 常见问题排查与经验复盘
5.1 排障实录速查表
我在调这块芯片时遇到过几种典型现象,整理成一张速查表,照着查能省不少时间。
| 现象 | 可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 读回全是 0xFF | 写命令前没发 WRITE ENABLE | 读状态寄存器看 WEL 位 | 补发 0x06,确认置位后再写 |
| 读回全是 0x00 | SPI 极性配置错误 | 逻辑分析仪抓 SCK/MOSI | 改为 Mode 0 或 Mode 3 |
| 偶发一个字节跳变 | 电源纹波太大 | 示波器看 VDD 波形 | 加强去耦,检查 PCB 回路 |
| DMA 传输不结束 | 缓冲区落在 CCM | 查看地址区间 | 缓冲区改到普通 SRAM |
| 写操作没有生效 | WP# 引脚被拉低 | 量 WP# 电平 | 上拉到 3.3V |
| CS# 高电平后数据丢失 | 高位地址溢出 | 检查地址是否超过 0x7FFFF | 地址范围加边界判断 |
5.2 两个容易跳进去的坑
第一个坑是只用单字节读回验证。MRAM 写入虽然是即时的,但首次调试时我建议每写一批数据都读回整段比对,不要只读首尾两个字节。曾经遇到过 SPI 时钟频率拉太高之后,连续写 128 字节中间丢掉一两个字节的情况,单读首尾根本发现不了,整个日志文件在运行后期才暴露异常。后来把写函数改成批量校验模式,问题立刻定位。
第二个坑是不做地址越界检查。512KB 空间,地址是 20 位,如果代码里有个变量算错偏移地址,写到了 0x080000 以外,芯片并不会报错,而是会绕回或产生不可预期的行为。所有读写函数入口必须做地址范围判断,这是用 MRAM 时最低成本的保命手段。我的代码里每个读写函数都带addr + len > MR25H40_CAPACITY的挡板,返回 -1 让上层及时发现故障。
从整体项目复盘来看,MR25H40CDF 加 STM32F469II 这套方案最大的价值不是单个芯片的性能,而是把存储这一层的设计逻辑彻底简化了。不用再做坏块管理,不用再纠结擦写均衡,也不用担心寿命耗尽。工业设备的日志记录、参数保存、掉电事件追踪,都可以在半小时内完成基础驱动,把更多时间留给真正难搞的控制算法和通信协议。我个人现在在做新板子时,只要空间允许,第一选择已经慢慢从 NOR Flash 转向小容量 MRAM,相信这套组合会让你的项目也省掉不少麻烦。