MRAM 这类存储介质在工业现场其实一直有点"叫好不叫座"的味道——参数漂亮,但真到选型落地的时候,工程师往往还是习惯性抓一颗 EEPROM 或者 FRAM 了事。我最近在一个数据采集模块上把存储方案从传统的 SPI Flash 换成了 MR25H40CDF,主控用的是 STM32F303VE,整个过程踩了不少坑,也积累了一些实测数据。这篇就把这套组合的完整落地思路拆开讲清楚,包括为什么选 MRAM、SPI 时序怎么配、读写驱动怎么写、掉电保护怎么做,以及工业现场那些文档里不会写的细节。
如果你正在做工业仪表、电力监测、PLC 扩展模块这类需要高频写入又要保证掉电不丢数据的场景,这套方案可以直接抄作业。即便你用的是别的 MCU 或者别的 MRAM 型号,底层的 SPI 通信逻辑和存储管理思路也是通用的。
1. 为什么在工业场景里把 Flash 换成 MR25H40CDF
1.1 传统方案的三个死穴
先说清楚为什么要折腾。工业嵌入式设备里存数据,常见的选择无非三种:EEPROM、NOR Flash、FRAM。EEPROM 的问题是写入速度慢,字节写入典型值在 5ms 级别,而且擦写寿命只有 100 万次左右。如果你的设备每秒要记录一次运行参数,一天就是 86400 次,一年下来 3000 多万次,EEPROM 根本扛不住。
NOR Flash 的问题是必须先擦后写,擦除单位是扇区(通常 4KB),写入前要整块擦掉。这意味着你不能只改一个字节,得把整个扇区的数据读出来、改掉目标字节、再整块写回去。更麻烦的是擦除时间长,一个 4KB 扇区擦除动辄几十毫秒,这期间如果掉电,整个扇区的数据就废了。
FRAM 倒是解决了速度和寿命问题,但它的容量普遍偏小,而且很多型号的 SPI 接口速率上不去,在需要快速批量写入的场景下会成为瓶颈。
1.2 MR25H40CDF 的核心优势
MR25H40CDF 是一颗 4Mbit(512KB)的 MRAM(磁性随机存储器),SPI 接口。它最吸引我的地方在于三点:
第一,写入不需要擦除。MRAM 的存储单元是磁性隧道结,写入直接改变磁化方向,没有 Flash 那种"先擦后写"的物理限制。你可以直接对任意地址写任意字节,就像操作 SRAM 一样。
第二,近乎无限的擦写寿命。官方标称的写入耐久度是 10^14 次以上,这个数量级基本可以认为"随便写"。对于需要高频记录数据的工业场景,这一点是决定性的。
第三,写入速度快。SPI 时钟可以跑到 40MHz,单次字节写入没有内部写周期等待,写完之后立刻可以读,不需要轮询状态寄存器等"写完成"。
下面这张表是我实际选型时做的对比,数据来自各型号手册的典型值:
| 特性 | EEPROM (典型) | NOR Flash (典型) | FRAM (典型) | MR25H40CDF |
|---|---|---|---|---|
| 写入前擦除 | 不需要 | 必须 | 不需要 | 不需要 |
| 字节写入时间 | 5ms | 依赖扇区擦除 | 即时 | 即时 |
| 擦写寿命 | 100万次 | 10万次 | 10^12次 | 10^14次 |
| SPI 最高速率 | 10MHz | 50MHz+ | 20MHz | 40MHz |
| 掉电数据保持 | 10年 | 20年 | 10年 | 20年 |
| 容量 | 小 | 大 | 中 | 512KB |
1.3 什么场景适合,什么场景不适合
MRAM 不是万能的。它的单位容量成本比 NOR Flash 高不少,所以如果你的应用只是存个固件、配置参数,写入频率极低,那用 NOR Flash 完全够,没必要上 MRAM。
但如果你的场景符合下面任意一条,MR25H40CDF 就值得考虑:
- 数据写入频率高,比如每秒甚至每毫秒记录一次
- 要求掉电瞬间数据不丢,且不能有擦除窗口期
- 需要频繁修改小批量数据,不想做复杂的扇区管理
- 工作温度范围宽,工业级要求 -40°C 到 +85°C
我这次的项目是电力监测终端,需要以 10ms 的间隔记录三相电压电流的采样值,同时还要保存累计电量、事件日志。这种写入强度,EEPROM 和 Flash 都撑不住,MRAM 是唯一合理的选择。
2. STM32F303VE 与 MR25H40CDF 的硬件连接设计
2.1 SPI 外设选型与引脚分配
STM32F303VE 有三个 SPI 外设:SPI1、SPI2、SPI3。SPI1 挂在 APB2 总线上,时钟频率最高,SPI2 和 SPI3 挂在 APB1 上。因为 MRAM 支持 40MHz,我优先选 SPI1。
引脚分配上,我用的是标准四线 SPI:
- SCK:PA5(SPI1_SCK)
- MISO:PA6(SPI1_MISO)
- MOSI:PA7(SPI1_MOSI)
- CS:PA4(GPIO 手动控制)
这里有个关键决策:片选到底用硬件 NSS 还是软件 GPIO。STM32 的硬件 NSS 在多主机场景下有用,但单主机单从机的情况下,硬件 NSS 反而会带来时序上的麻烦——它在每个字节传输间隙会自动拉高,而 MRAM 的某些命令(比如连续读)需要 CS 在整个传输过程中保持低电平。所以我用软件 GPIO 控制 CS,这样能精确控制片选的拉低和拉高时机。
注意:CS 引脚一定要配置为推挽输出,速度设为 High 或 Very High。如果设成开漏又没有外部上拉,片选信号会不稳定,导致偶发的通信错误。
2.2 电源与去耦的细节
MR25H40CDF 的工作电压是 2.7V 到 3.6V,和 STM32F303VE 的 3.3V 系统可以直接对接,不需要电平转换。但去耦电容不能省。
我在 MRAM 的 VCC 引脚旁边放了两个电容:一个 100nF 的陶瓷电容紧贴引脚,再加一个 1uF 的陶瓷电容稍微远一点。100nF 负责滤高频噪声,1uF 负责应对写入瞬间的电流波动。MRAM 在写入时会有短暂的电流尖峰,如果去耦不到位,可能导致写入失败或者数据错误。
另外,SPI 的信号线如果走线较长(超过 10cm),建议在 SCK 和 MOSI 上串一个 22Ω 到 33Ω 的电阻,用来抑制反射。我在第一版 PCB 上没加,结果在 20MHz 以上时钟时偶尔出现误码,后来补上串阻就稳定了。
2.3 PCB 布局的注意事项
SPI 是同步通信,时序余量相对充裕,但布局还是有讲究:
- MRAM 尽量靠近 MCU,走线越短越好
- SCK 和 MOSI 是主要的干扰源,尽量远离模拟信号线
- MISO 是高阻输入,走线不要和其他高速信号平行走太长
- 地平面要完整,不要被信号线割裂
我见过一个案例,工程师把 MRAM 放在板子另一头,SPI 线走了将近 15cm,还没加串阻,结果在高温环境下通信频繁出错。后来缩短走线加串阻才解决。这种问题在实验室常温下可能测不出来,一到现场就暴露。
3. SPI 时序配置:从时钟极性到实际波形验证
3.1 MR25H40CDF 的 SPI 模式要求
MR25H40CDF 支持 SPI Mode 0 和 Mode 3。这两种模式的区别在于时钟极性(CPOL)和时钟相位(CPHA):
- Mode 0:CPOL=0,CPHA=0。空闲时 SCK 为低电平,数据在 SCK 上升沿采样
- Mode 3:CPOL=1,CPHA=1。空闲时 SCK 为高电平,数据在 SCK 上升沿采样
两种模式都能用,我选的是 Mode 0,因为这是最常用的配置,和大多数 SPI 器件的默认模式一致,调试起来方便。
在 STM32CubeMX 里配置 SPI1 的时候,对应设置是:
- Clock Polarity:Low
- Clock Phase:1 Edge
- Data Size:8 Bits
- First Bit:MSB First
- NSS:Software
3.2 时钟分频的计算
STM32F303VE 的 SPI1 挂在 APB2 上,APB2 时钟我配置为 72MHz。SPI 的波特率分频系数可以是 2、4、8、16、32、64、128、256。
MRAM 最高支持 40MHz,所以分频系数最小只能取 2,得到 36MHz。这个频率在 40MHz 以内,是安全的。但实际调试时我发现,36MHz 下用杜邦线连接的测试板偶尔会出错,换成正式 PCB 后就稳定了。所以如果你的硬件条件一般,建议先用分频 4(18MHz)跑通,再逐步往上提。
计算方式很简单:
SPI 时钟 = APB2 时钟 / 分频系数 36MHz = 72MHz / 2 18MHz = 72MHz / 43.3 用示波器验证时序
配置完之后,一定要用示波器看波形。我重点看三个地方:
第一,CS 拉低到第一个 SCK 边沿的间隔。MRAM 要求 CS 建立时间(tSLCH)最小 5ns,一般都能满足,但如果你的 GPIO 翻转太慢就要注意。
第二,SCK 的占空比和上升沿质量。36MHz 下如果上升沿有明显的过冲或振铃,说明走线阻抗不匹配,需要加串阻。
第三,最后一个 SCK 边沿到 CS 拉高的间隔。MRAM 要求 CS 保持时间(tCHSH)最小 5ns,同样一般没问题,但如果你的代码在发送完数据后立刻拉高 CS,而 SPI 外设的最后一个字节还没完全移出,就会出问题。
提示:STM32 HAL 库的
HAL_SPI_Transmit函数在返回前会等待 BUSY 标志清除,所以正常调用不会出现"最后一个字节没发完就拉 CS"的问题。但如果你用的是 DMA 传输,就必须在 DMA 传输完成回调里再拉高 CS,不能在启动 DMA 后立刻拉高。
4. 驱动实现:从底层读写到上层存储管理
4.1 MRAM 的命令集
MR25H40CDF 的命令集很简洁,常用的就几条:
| 命令 | 操作码 | 功能 |
|---|---|---|
| WREN | 0x06 | 写使能 |
| WRDI | 0x04 | 写禁止 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 读数据 |
| WRITE | 0x02 | 写数据 |
注意,MRAM 的写操作也需要先发 WREN 命令。这一点和 EEPROM 类似,但不同的是,MRAM 写完不需要等待,WREN 之后直接发 WRITE 命令加地址加数据就行。
4.2 底层读写函数
先看写使能和写禁止的实现:
void MRAM_WriteEnable(void) { uint8_t cmd = 0x06; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); MRAM_CS_HIGH(); } void MRAM_WriteDisable(void) { uint8_t cmd = 0x04; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); MRAM_CS_HIGH(); }读状态寄存器:
uint8_t MRAM_ReadStatus(void) { uint8_t cmd = 0x05; uint8_t status = 0; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_SPI_Receive(&hspi1, &status, 1, 100); MRAM_CS_HIGH(); return status; }读数据函数,支持任意地址和任意长度:
void MRAM_Read(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4]; cmd[0] = 0x03; cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Receive(&hspi1, buf, len, 1000); MRAM_CS_HIGH(); }写数据函数:
void MRAM_Write(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4]; cmd[0] = 0x02; cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; MRAM_WriteEnable(); MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Transmit(&hspi1, buf, len, 1000); MRAM_CS_HIGH(); }这里有个细节:MRAM 的地址是 19 位(512KB = 2^19),所以需要 3 个地址字节。命令字节加 3 个地址字节,一共 4 个字节的头部。
4.3 为什么不用 HAL_SPI_TransmitReceive
有人可能会问,为什么读的时候用HAL_SPI_Transmit发命令再用HAL_SPI_Receive收数据,而不是用HAL_SPI_TransmitReceive一次搞定。
原因是:HAL_SPI_TransmitReceive是全双工操作,发送和接收同时进行。但 MRAM 的读操作是半双工的——先发命令和地址,然后才接收数据。如果用全双工函数,在发命令阶段收到的数据是无效的,需要额外处理。分开调用虽然多一次函数开销,但逻辑更清晰,也不容易出错。
当然,如果你追求极致效率,可以用 DMA 加全双工的方式,把命令和数据的收发合并,但代码复杂度会上升不少。对于大多数工业应用,分开调用的性能已经足够了。
4.4 上层存储管理:环形缓冲区设计
底层读写跑通之后,上层怎么组织数据是个更重要的问题。我的需求是持续记录采样数据,同时保留最近一段时间的历史。这种场景最适合用环形缓冲区。
设计思路是:把 MRAM 的 512KB 分成两部分,前 64KB 存配置参数和累计量,后 448KB 作为数据环形缓冲区。环形缓冲区用一个写指针和读指针管理,写满之后从头覆盖。
#define MRAM_DATA_BASE 0x10000 #define MRAM_DATA_SIZE 0x70000 #define RECORD_SIZE 32 typedef struct { uint32_t write_index; uint32_t record_count; } RingBufferInfo; RingBufferInfo rb_info; void RingBuffer_Write(uint8_t *record) { uint32_t addr = MRAM_DATA_BASE + (rb_info.write_index * RECORD_SIZE); MRAM_Write(addr, record, RECORD_SIZE); rb_info.write_index++; if (rb_info.write_index >= (MRAM_DATA_SIZE / RECORD_SIZE)) { rb_info.write_index = 0; } if (rb_info.record_count < (MRAM_DATA_SIZE / RECORD_SIZE)) { rb_info.record_count++; } // 更新索引信息 MRAM_Write(0x00, (uint8_t *)&rb_info, sizeof(rb_info)); }这里有个关键点:索引信息每次写入后都要更新到 MRAM。因为 MRAM 写入是即时的,不存在"写缓存"的问题,所以只要MRAM_Write返回,数据就已经落盘了。这一点比 Flash 省心太多——Flash 还要考虑页缓存、擦除块管理,MRAM 完全不用。
5. 掉电保护与数据完整性验证
5.1 掉电检测的硬件设计
工业现场掉电是常态,所以掉电保护必须做。我的方案是在电源输入端加一个电压监测电路,用电阻分压把 24V 降到 MCU 的 ADC 量程内,然后通过 ADC 持续监测。
当检测到电压低于阈值(比如 18V)时,说明主电源已经断开,此时依靠板上的大电容储能,MCU 还有几十毫秒的时间完成最后的写入操作。
这个"几十毫秒"够不够,取决于你要写多少数据。MRAM 的写入速度是 SPI 时钟决定的,36MHz 下写 32 字节大约需要 10 微秒,所以几十毫秒足够写几千条记录。关键是要提前把数据准备好,不能在掉电中断里才去采集数据。
5.2 掉电中断的处理流程
我的做法是:在正常运行时,数据先写到 MCU 的 RAM 缓冲区,每积累到一定数量(比如 16 条)再批量写入 MRAM。同时在掉电中断里,把 RAM 缓冲区里还没写入的数据紧急写入 MRAM。
void PVD_IRQHandler(void) { if (__HAL_PVD_GET_FLAG()) { __HAL_PVD_CLEAR_FLAG(); // 紧急写入未保存的数据 if (ram_buffer_count > 0) { uint32_t addr = MRAM_DATA_BASE + (rb_info.write_index * RECORD_SIZE); MRAM_Write(addr, (uint8_t *)ram_buffer, ram_buffer_count * RECORD_SIZE); rb_info.write_index += ram_buffer_count; MRAM_Write(0x00, (uint8_t *)&rb_info, sizeof(rb_info)); } // 标记系统正常关闭 uint8_t flag = 0xA5; MRAM_Write(0x10, &flag, 1); } }STM32F303VE 内置了可编程电压检测器(PVD),可以配置在电压低于某个阈值时触发中断。我用的是 PVD 级别 6(约 2.9V),这样在 3.3V 电源开始下降时就能及时响应。
5.3 数据完整性校验
MRAM 本身可靠性很高,但工业现场的电磁干扰可能导致 SPI 通信误码。所以我在每条记录后面加了 CRC16 校验。
typedef struct { uint32_t timestamp; uint16_t voltage[3]; uint16_t current[3]; uint16_t crc; } SampleRecord;写入时计算 CRC 并附在记录末尾,读取时重新计算并比对。如果 CRC 不匹配,说明这条记录在传输或存储过程中出了问题,可以选择丢弃或者标记为无效。
实测下来,在加了串阻和良好布局的 PCB 上,CRC 错误率极低,连续运行一个月没有出现过一次校验失败。但在早期用杜邦线的测试板上,36MHz 时钟下每天会出现几次 CRC 错误,降到 18MHz 后就消失了。这也印证了前面说的:时钟频率要和硬件条件匹配。
6. 实测性能与踩坑记录
6.1 写入速度实测
我用逻辑分析仪抓了实际波形,测量不同时钟频率下的写入耗时。测试条件是写入 32 字节的记录:
| SPI 时钟 | 单条记录写入耗时 | 连续写入 1000 条耗时 |
|---|---|---|
| 9MHz | 约 42us | 约 42ms |
| 18MHz | 约 22us | 约 22ms |
| 36MHz | 约 12us | 约 12ms |
这个速度比 EEPROM 快了三个数量级。EEPROM 写一个字节就要 5ms,写 32 字节就是 160ms,1000 条记录要 160 秒。MRAM 只要 12 毫秒,差距是万倍级别。
6.2 踩过的坑:CS 时序问题
第一个坑是 CS 拉高的时机。我最初的代码是这样的:
MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Transmit(&hspi1, buf, len, 100); MRAM_CS_HIGH();看起来没问题,但实际上HAL_SPI_Transmit在发送最后一个字节后,函数返回时最后一个字节可能还在移位寄存器里没完全移出。如果此时立刻拉高 CS,MRAM 可能收不到完整的最后一个字节。
正确的做法是在拉高 CS 之前,等待 SPI 的 BUSY 标志清除:
MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Transmit(&hspi1, buf, len, 100); while (__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_BSY)); MRAM_CS_HIGH();不过实际上,HAL 库的HAL_SPI_Transmit函数内部已经包含了等待 BUSY 清除的逻辑,所以正常调用不会出问题。但如果你的 SPI 配置了 DMA,或者用了 LL 库直接操作寄存器,就必须自己处理这个等待。
6.3 踩过的坑:WREN 命令的时机
第二个坑是 WREN 命令的发送时机。我一开始把 WREN 和 WRITE 命令合并在一次 CS 低电平周期内:
MRAM_CS_LOW(); uint8_t wren = 0x06; HAL_SPI_Transmit(&hspi1, &wren, 1, 100); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Transmit(&hspi1, buf, len, 100); MRAM_CS_HIGH();这样写在某些 MRAM 型号上是可以的,但 MR25H40CDF 要求 WREN 命令必须在独立的 CS 周期内发送。也就是说,WREN 发完之后要先拉高 CS,再拉低 CS 发 WRITE 命令。我按照手册改成独立周期后就正常了。
这个细节在数据手册的时序图里有体现,但如果不仔细看很容易忽略。建议大家在写驱动之前,先把手册里的命令时序图仔细过一遍。
6.4 踩过的坑:地址边界处理
第三个坑是地址越界。MRAM 的容量是 512KB,地址范围是 0x00000 到 0x7FFFF。如果写入时地址加上长度超过了 0x7FFFF,MRAM 会回卷到 0x00000 继续写,而不是报错。
这个行为在环形缓冲区场景下其实是有用的,但如果不注意,可能导致数据覆盖。我在代码里加了边界检查:
void MRAM_WriteSafe(uint32_t addr, uint8_t *buf, uint32_t len) { if (addr + len > MRAM_TOTAL_SIZE) { // 处理越界,可以选择分段写入或报错 uint32_t first_part = MRAM_TOTAL_SIZE - addr; MRAM_Write(addr, buf, first_part); MRAM_Write(0, buf + first_part, len - first_part); } else { MRAM_Write(addr, buf, len); } }6.5 长期运行稳定性
这套方案我在三个现场设备上跑了半年多,最长的连续运行了 200 天,没有出现过数据丢失或通信故障。每天的写入量大约 800 万次(10ms 一条记录),累计写入超过 16 亿次,远低于 MRAM 的 10^14 次寿命上限。
温度方面,现场环境温度在 -20°C 到 +60°C 之间波动,MRAM 的数据保持能力没有受到影响。这一点比 Flash 强——Flash 在高温下数据保持时间会缩短,而 MRAM 的磁性存储对温度不敏感。
7. 这套方案还能怎么扩展
如果你已经跑通了基本的读写,可以考虑几个进阶方向。
第一个是双缓冲区设计。把 MRAM 分成两个独立的环形缓冲区,一个用于实时数据,一个用于事件日志。这样即使实时数据写满了覆盖,事件日志也不会丢。
第二个是磨损均衡。虽然 MRAM 寿命极长,理论上不需要磨损均衡,但在极端高频写入的场景下,做一层简单的地址轮转仍然是有益的。实现方式是在环形缓冲区的基础上,每次写入时把物理地址做一次伪随机偏移。
第三个是数据压缩。如果采样数据的规律性较强(比如电压电流在稳态下变化很小),可以在写入前做差分编码或简单的游程压缩,这样同样的 MRAM 容量能存更长时间的数据。
第四个是多片级联。如果 512KB 不够用,可以用多片 MR25H40CDF 并联,每片用独立的 CS 控制,SPI 总线共用。这样容量可以线性扩展,软件上只需要在读写函数里增加片选参数。
我在实际项目里用的是第一种和第四种的组合:一片 MRAM 存实时数据,一片存事件日志,两片共用 SPI1,用 PA4 和 PB0 分别做片选。代码上只是把 CS 控制抽象成一个参数,改动量很小。
最后分享一个调试技巧:如果你怀疑 SPI 通信有问题,但示波器又抓不到异常,可以写一个简单的测试程序,往 MRAM 的某个地址写入已知模式(比如 0x55AA55AA),然后读回来比对。连续跑几万次,如果有任何一次不匹配,就说明通信链路存在偶发问题。这个方法比单纯看波形更容易发现间歇性故障。