1. 项目缘起:为什么要在工业场景里折腾 MRAM 和 STM32
工业现场的数据存储有个很尴尬的现状:用 EEPROM 吧,写入速度慢得让人抓狂,擦写次数也就百万次级别,高频记录场景下没几年就写废了;用 SRAM 加电池吧,电池是个定时炸弹,高温环境下三五年就鼓包漏液,维护成本高得离谱;用 NOR Flash 吧,写入前得先擦除整个扇区,掉电保护逻辑复杂,而且擦写寿命同样有限。我在一个电力监测项目里就吃过这个亏,设备装在配电柜里,环境温度常年五十多度,电池方案撑了不到两年就开始批量出问题,返修率直接爆表。
后来接触到MRAM这个技术路线,才算找到了一个相对理想的解法。MRAM 的全称是磁阻随机存取存储器,它的存储单元用的是磁性隧道结,靠电子自旋方向来记录数据,而不是电荷。这个物理机制决定了它几个非常硬核的特性:写入速度接近 SRAM,纳秒级别;擦写寿命理论上无限,实测轻松过 10^14 次;掉电不丢数据,不需要电池;抗辐射、抗磁场干扰能力强,工业环境里特别稳。MR25H40CDF就是 Everspin 家的一款 4Mbit 容量的 SPI 接口 MRAM,SOIC-8 封装,引脚和常见的 SPI Flash 基本兼容,替换起来非常顺手。
主控这边选STM32F732IE,原因也很直接。这颗芯片是 ST 家 F7 系列里的中端型号,Cortex-M7 内核,主频 216MHz,带 512KB Flash 和 256KB SRAM,关键是它的 SPI 外设支持高达 50MHz 的时钟,配合 MRAM 的 40MHz 最高速率绰绰有余。而且 F7 系列的 SPI 有独立的 TX/RX FIFO,配合 DMA 做大数据块搬运非常舒服,不会像 F1 系列那样动不动就丢字节。工业嵌入式项目里,这种“主控性能冗余 + 存储介质可靠”的组合,是我个人比较推荐的搭配方式。
这篇文章面向的是有一定 STM32 开发基础、正在做工业数据记录或嵌入式存储方案的工程师。我会从硬件连接、CubeMX 配置、HAL 库驱动编写、DMA 优化、掉电保护逻辑这几个维度,把整个链路拆开讲清楚。你照着做,基本能在一个下午把读写跑通,剩下的就是根据自己项目调参数了。
2. 硬件设计:MR25H40CDF 与 STM32F732IE 的对接细节
2.1 MR25H40CDF 引脚定义与关键参数
MR25H40CDF 是 SOIC-8 封装,引脚定义和标准 SPI Flash 几乎一模一样,这也是它的一大优势——硬件改板成本极低。具体引脚如下:
| 引脚编号 | 名称 | 功能说明 |
|---|---|---|
| 1 | CS | 片选,低电平有效 |
| 2 | SO | 数据输出(MISO) |
| 3 | WP | 写保护,低电平有效 |
| 4 | VSS | 地 |
| 5 | SI | 数据输入(MOSI) |
| 6 | SCK | 时钟 |
| 7 | HOLD | 保持,低电平有效 |
| 8 | VDD | 电源,2.7V~3.6V |
几个关键参数需要记牢:容量 4Mbit,也就是 512KB,组织方式是 512K x 8;最高 SPI 时钟 40MHz;工作温度范围 -40°C 到 +85°C,工业级;写入寿命无限,数据保持时间超过 20 年。供电范围 2.7V 到 3.6V,和 STM32 的 3.3V 系统完美匹配,不需要电平转换。
注意:WP 和 HOLD 这两个引脚在简单应用里可以直接拉高到 VDD,但如果你的项目需要硬件写保护功能,WP 建议接一个 GPIO 控制,软件层面配合状态寄存器使用。
2.2 与 STM32F732IE 的 SPI 连接方案
STM32F732IE 有多个 SPI 外设,我一般推荐用 SPI1 或 SPI2,因为这两个挂载在 APB2 和 APB1 上,时钟源比较灵活。具体连接方式如下:
- CS接 STM32 的任意 GPIO,我习惯用 PA4,方便和 SPI1 的 NSS 复用引脚对应
- SCK接 PA5(SPI1_SCK)
- MISO接 PA6(SPI1_MISO)
- MOSI接 PA7(SPI1_MOSI)
- VDD接 3.3V,旁边放一个 100nF 加一个 1uF 的退耦电容
- VSS接地
- WP和HOLD直接上拉到 3.3V
这里有个细节值得说:MRAM 的 CS 引脚对时序比较敏感,虽然它兼容 SPI 模式 0 和模式 3,但实测下来模式 0(CPOL=0,CPHA=0)最稳。另外 CS 的拉低和拉高之间要保证足够的建立时间和保持时间,STM32 的硬件 NSS 有时候控制不够精细,所以我强烈建议用软件控制 GPIO 来做片选,这样时序完全可控。
2.3 PCB 布局的几条实战经验
SPI 总线在高速运行时,PCB 布局直接决定通信稳定性。我在一个项目里因为走线问题,40MHz 下误码率居高不下,后来调整布局才解决。几条经验:
第一,SCK 走线尽量短,最好控制在 5cm 以内,并且远离其他高频信号线。第二,MISO 和 MOSI 不要平行走太长距离,容易串扰,必要时中间加地线隔离。第三,退耦电容必须紧贴 MRAM 的 VDD 引脚,走线越短越好,我一般用 0402 封装的 100nF,贴在引脚旁边 2mm 以内。第四,如果板子上有多个 SPI 从设备,每个设备的 CS 走线独立,不要共用。
提示:如果通信距离超过 10cm,建议把 SPI 时钟降到 20MHz 以下,或者加一级缓冲驱动。工业现场电磁环境复杂,稳定性永远优先于速度。
3. STM32CubeMX 配置:从时钟树到 SPI 参数
3.1 时钟树配置与 SPI 时钟源选择
STM32F732IE 的 SPI1 挂在 APB2 总线上,SPI2 和 SPI3 挂在 APB1 上。以 SPI1 为例,假设系统时钟配置为 216MHz,APB2 分频系数设为 2,那么 APB2 时钟就是 108MHz。SPI1 的时钟源就是 108MHz,通过预分频器可以分出 2、4、8、16、32、64、128、256 这些档位。
要得到接近 40MHz 的时钟,108MHz 除以 4 等于 27MHz,除以 2 等于 54MHz。54MHz 超过了 MRAM 的 40MHz 上限,所以选 4 分频,得到 27MHz。这个速率对大多数工业应用已经足够了,如果你追求极致速度,可以把 APB2 分频改成 1,这样 SPI 时钟源是 216MHz,除以 8 得到 27MHz,除以 4 得到 54MHz,还是只能选 27MHz。想要更接近 40MHz,可以考虑用 SPI2,APB1 时钟 108MHz,同样只能到 27MHz 或 54MHz。
实际上 27MHz 已经能跑出接近 3.4MB/s 的理论带宽,对于工业数据记录完全够用。如果你非要跑到 40MHz,可以考虑超频 SPI 到 54MHz,但我不推荐,工业项目稳定第一。
3.2 SPI 参数配置逐项说明
在 CubeMX 里配置 SPI1,参数如下:
- Mode:Full-Duplex Master
- Hardware NSS Signal:Disable(我们用软件 GPIO 控制片选)
- Data Size:8 Bits
- First Bit:MSB First
- Clock Polarity (CPOL):Low
- Clock Phase (CPHA):1 Edge
- Prescaler:4
- Baud Rate:27 MBits/s
- CRC Calculation:Disabled
- NSS Pulse Mode:Disabled
- TI Mode:Disabled
CPOL 和 CPHA 的组合就是 SPI 模式 0,这是 MRAM 最稳定的工作模式。Data Size 选 8 位是因为 MRAM 的指令和地址都是按字节组织的,虽然它支持 16 位和 32 位传输模式,但 8 位最通用。
3.3 GPIO 与 DMA 配置要点
CS 引脚配置为 GPIO Output,初始电平设为 High,输出速度设为 Very High。这里有个坑:如果输出速度设成 Low,CS 的翻转沿会变缓,在 27MHz 下可能导致 MRAM 识别不到片选信号。我实测过,Low 速度下通信成功率只有 70% 左右,改成 Very High 后直接 100%。
DMA 配置方面,SPI1_TX 用 DMA1 Channel 3,SPI1_RX 用 DMA1 Channel 2,都设为 Normal 模式,优先级 Medium。数据宽度都选 Byte,因为 SPI 是 8 位传输。如果你要传大块数据,比如一次写 4KB,用 DMA 能把 CPU 占用率从 80% 降到 5% 以下,效果非常明显。
注意:DMA 传输完成后必须手动清除 SPI 的 TXE 和 RXNE 标志,否则下一次传输会出错。这个坑我在早期项目里踩过,调试了大半天才发现。
4. 驱动编写:从基础读写到 DMA 批量传输
4.1 MRAM 指令集与操作时序
MR25H40CDF 的指令集非常简洁,常用的就几条:
| 指令名称 | 指令码 | 功能说明 |
|---|---|---|
| WREN | 0x06 | 写使能 |
| WRDI | 0x04 | 写禁止 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 读数据 |
| WRITE | 0x02 | 写数据 |
读操作的时序是:拉低 CS,发送 0x03,发送 24 位地址(因为 512KB 需要 19 位地址,但 MRAM 用 24 位地址格式),然后连续读取数据,最后拉高 CS。写操作的时序类似,但前面要先发 WREN 指令,而且每次写操作前都要发一次 WREN,这是 MRAM 和 Flash 的一个区别——Flash 的 WREN 在写完成后自动清除,MRAM 也是,但 MRAM 不需要擦除操作,直接写就行。
地址是 24 位的,高 5 位保留,实际有效的是低 19 位。比如你要访问地址 0x00000,就发 0x00 0x00 0x00;访问地址 0x7FFFF,就发 0x07 0xFF 0xFF。
4.2 基础读写函数实现
先定义几个宏和全局变量:
#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 MRAM_CMD_WREN 0x06 #define MRAM_CMD_WRDI 0x04 #define MRAM_CMD_RDSR 0x05 #define MRAM_CMD_WRSR 0x01 #define MRAM_CMD_READ 0x03 #define MRAM_CMD_WRITE 0x02 extern SPI_HandleTypeDef hspi1;写使能函数:
void MRAM_WriteEnable(void) { uint8_t cmd = MRAM_CMD_WREN; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); MRAM_CS_HIGH(); }读状态寄存器:
uint8_t MRAM_ReadStatus(void) { uint8_t cmd = MRAM_CMD_RDSR; 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] = MRAM_CMD_READ; 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] = MRAM_CMD_WRITE; 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(); }这几个函数是最基础的版本,用轮询方式传输。小数据量下没问题,但如果一次写几 KB,CPU 会被完全占用,这时候就得上 DMA。
4.3 DMA 批量传输优化
用 DMA 改写读函数:
void MRAM_Read_DMA(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4]; cmd[0] = MRAM_CMD_READ; 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_DMA(&hspi1, buf, len); // 等待DMA完成,实际项目中可以用中断或RTOS信号量 while (hspi1.State != HAL_SPI_STATE_READY); MRAM_CS_HIGH(); }写函数的 DMA 版本类似,把HAL_SPI_Transmit换成HAL_SPI_Transmit_DMA即可。这里有个关键点:DMA 传输期间 CS 必须保持低电平,所以MRAM_CS_HIGH()必须放在 DMA 完成之后。如果你用中断方式,要在 DMA 完成回调里拉高 CS。
实操心得:DMA 传输大块数据时,建议把数据缓冲区放在 SRAM 里,不要放在栈上。栈空间有限,几 KB 的数组很容易导致栈溢出,这个坑我踩过,现象是程序跑飞,调试了半天才发现是栈溢出。
5. 掉电保护与数据完整性设计
5.1 为什么 MRAM 的掉电保护比 Flash 简单
Flash 写入前必须擦除整个扇区,擦除过程中如果掉电,整个扇区的数据就全丢了。所以用 Flash 做数据记录,必须设计复杂的双备份加日志机制。MRAM 没有这个问题,它的写入是原子性的,每个字节的写入操作要么完成要么没完成,不会影响其他字节。这意味着你可以直接覆盖写,不需要擦除,也不需要备份整个扇区。
但这不代表 MRAM 就不需要掉电保护。如果你的数据记录是多字节的结构体,写入过程中掉电,可能导致结构体只写了一半,数据不一致。解决办法是用一个“有效标志位”来标记数据是否完整。比如你定义一个 64 字节的记录结构,前 4 个字节是魔数 0xAA55AA55,写入时先写数据部分,最后写魔数。读取时先检查魔数,如果魔数不对,说明这条记录不完整,直接丢弃。
5.2 环形缓冲区设计与实现
工业数据记录通常用环形缓冲区,写满一圈后覆盖最旧的数据。MRAM 的 512KB 空间,如果每条记录 64 字节,可以存 8192 条。我一般把空间分成两部分:前 8KB 存元数据,包括写指针、读指针、记录总数;后面 504KB 存实际数据。
写指针的更新策略很关键。每次写入新记录后,写指针加 1,然后立即把写指针写回元数据区。这样即使掉电,最多丢失最后一条记录,不会导致整个缓冲区错乱。读指针只在读取时更新,掉电影响不大。
typedef struct { uint32_t magic; // 0xAA55AA55 uint32_t write_ptr; uint32_t read_ptr; uint32_t total_count; uint8_t reserved[48]; } MRAM_Meta_t; typedef struct { uint32_t timestamp; float temperature; float voltage; uint32_t status; uint8_t payload[48]; } MRAM_Record_t;写入记录的函数:
void MRAM_WriteRecord(MRAM_Record_t *rec) { MRAM_Meta_t meta; MRAM_Read(0, (uint8_t*)&meta, sizeof(meta)); uint32_t addr = 8192 + meta.write_ptr * sizeof(MRAM_Record_t); MRAM_Write(addr, (uint8_t*)rec, sizeof(MRAM_Record_t)); meta.write_ptr = (meta.write_ptr + 1) % 8192; meta.total_count++; meta.magic = 0xAA55AA55; MRAM_Write(0, (uint8_t*)&meta, sizeof(meta)); }5.3 写入均衡与寿命考量
虽然 MRAM 的擦写寿命理论上是无限的,但实际使用中还是建议做写入均衡。原因很简单:如果你一直往同一个地址写,虽然 MRAM 不会像 Flash 那样写坏,但长期高温环境下,那个区域的磁性隧道结可能会出现参数漂移。我一般用简单的轮询方式,每次写入地址递增,写满一圈再回到开头。
另外,元数据区的写指针更新非常频繁,如果每次写记录都更新一次元数据,元数据区的写入次数会远高于数据区。解决办法是每写 10 条记录才更新一次元数据,这样掉电最多丢 10 条记录,但元数据区的写入压力降低了 10 倍。具体丢多少条可以接受,取决于你的应用场景。
提示:如果你的项目对数据完整性要求极高,可以在元数据区做双备份,两个副本交替写入,读取时取最新的那个。这样即使一个副本写坏了,另一个还能用。
6. 常见问题排查与调试技巧
6.1 通信失败问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 读回全 0xFF | CS 未拉低或时序不对 | 用示波器看 CS 和 SCK | 检查 GPIO 配置,确认 CS 初始电平 |
| 读回全 0x00 | MISO 未连接或上拉 | 万用表测 MISO 对地电阻 | 检查焊接,确认 MISO 走线 |
| 数据错位 | SPI 模式不匹配 | 尝试模式 0 和模式 3 | 统一用模式 0 |
| 高速下误码 | 时钟太快或走线太长 | 降低时钟到 10MHz 测试 | 优化 PCB 或降速 |
| 写入无效 | 未发 WREN 指令 | 读状态寄存器确认 | 每次写前发 WREN |
| DMA 传输卡死 | 标志位未清除 | 检查 DMA 中断标志 | 手动清除 TXE/RXNE |
6.2 示波器调试实战记录
我第一次调 MRAM 的时候,读回的数据全是 0xFF,折腾了两个小时。后来用示波器抓波形,发现 CS 拉低后 SCK 没有立即开始,中间有 200ns 的延迟。原因是 HAL_SPI_Transmit 函数内部有软件开销,从调用到实际发送第一个字节需要时间。解决办法是在 CS 拉低后加一个几微秒的延时,或者直接用寄存器操作 SPI 的 DR 寄存器,绕过 HAL 库的开销。
另一个坑是 MISO 的采样时机。SPI 模式 0 下,数据在 SCK 上升沿采样,但 STM32 的 SPI 外设默认在第一个边沿采样,如果 MRAM 的数据建立时间不够,就会采到错误的值。解决办法是降低时钟频率,或者调整 CPHA 为 1 Edge,让采样发生在第二个边沿。
6.3 常见问题与排查技巧实录
问题一:写入后立即读取,数据不对。这个现象在早期版本里很常见,原因是 MRAM 的写入操作需要一定时间完成,虽然它的写入速度很快,但如果你在 CS 拉高后立即拉低读,可能内部还在写。解决办法是在写操作后加一个 1ms 的延时,或者读状态寄存器确认写入完成。MRAM 的状态寄存器 bit0 是 WIP 位,写操作进行中为 1,完成后为 0。
问题二:DMA 传输大块数据时偶尔丢字节。这个问题困扰了我很久,后来发现是 DMA 的传输完成中断和 SPI 的 TXE 标志竞争导致的。解决办法是在 DMA 传输完成后,先等待 SPI 的 BSY 位清零,再拉高 CS。具体代码是while (__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_BSY));。
问题三:高温环境下通信不稳定。工业现场温度可能到 70°C 以上,MRAM 虽然标称 -40°C 到 +85°C,但高温下时序参数会漂移。解决办法是高温下把 SPI 时钟降到 10MHz 以下,并且在 CS 和 SCK 上串联 22 欧姆的电阻,抑制反射。
问题四:多设备共用 SPI 总线时互相干扰。如果板子上有多个 SPI 从设备,每个设备的 CS 必须独立控制,不能共用。另外,未选中的设备 MISO 引脚应该设为高阻态,否则会拉低总线。MRAM 的 MISO 在 CS 高电平时是高阻态,这点没问题,但有些 Flash 芯片不是,需要特别注意。
6.4 调试工具与技巧推荐
调试 SPI 通信,示波器是必备的。我用的是 Rigol DS1054Z,四通道,抓 CS、SCK、MOSI、MISO 四根线刚好。如果预算有限,逻辑分析仪也行,Saleae 的 8 通道版本足够用。软件层面,我习惯在关键函数里加 GPIO 翻转,用示波器测执行时间,这样能快速定位性能瓶颈。
另外,STM32CubeIDE 的调试功能很好用,可以在线看变量、设断点。但要注意,SPI 通信对时序敏感,断点会打断时序,所以调试 SPI 问题时尽量用示波器而不是断点。
7. 性能实测与优化建议
7.1 读写速度实测数据
我在 27MHz SPI 时钟下做了实测,结果如下:
| 操作类型 | 数据量 | 耗时 | 等效速率 |
|---|---|---|---|
| 单字节读 | 1 字节 | 2.1 us | 0.48 MB/s |
| 单字节写 | 1 字节 | 3.5 us | 0.29 MB/s |
| 块读(轮询) | 1 KB | 380 us | 2.69 MB/s |
| 块读(DMA) | 1 KB | 310 us | 3.30 MB/s |
| 块写(轮询) | 1 KB | 420 us | 2.44 MB/s |
| 块写(DMA) | 1 KB | 340 us | 3.01 MB/s |
可以看到,DMA 模式下块读速率达到 3.3MB/s,接近 27MHz SPI 的理论上限 3.375MB/s。单字节操作因为指令开销占比大,速率低很多,所以实际项目中尽量用块传输。
7.2 进一步优化的几个方向
如果你需要更高的吞吐量,有几个方向可以尝试。第一,把 SPI 时钟提到 40MHz 以上,但要注意 MRAM 的极限是 40MHz,超频有风险。第二,用 Quad SPI 模式,MR25H40CDF 不支持,但 Everspin 有 Quad 接口的型号,速率能到 100MHz 以上。第三,用双 MRAM 并行,两个芯片交替读写,理论带宽翻倍,但硬件成本也翻倍。
软件层面,可以把频繁读写的数据缓存在 STM32 的 SRAM 里,定期批量写入 MRAM。这样既减少了 SPI 通信次数,又降低了功耗。我一般用 4KB 的缓存,每 100ms 或缓存满时刷一次。
7.3 功耗考量与低功耗设计
工业设备很多是电池供电或能量收集供电,功耗很关键。MRAM 的待机电流只有几微安,读写电流在 10mA 左右,比 Flash 的擦除电流(20mA 以上)低不少。STM32F732IE 在 Run 模式下 216MHz 全速运行大概 100mA,如果对功耗敏感,可以在不读写时把 SPI 和 MRAM 都关掉,用 RTC 定时唤醒。
具体做法是:把 MRAM 的 VDD 用一个 GPIO 控制的 MOS 管开关,不读写时直接断电。MRAM 掉电不丢数据,所以断电完全没问题。这样待机功耗可以降到微安级别。
实操心得:MRAM 断电后重新上电,第一次访问前建议加 1ms 的延时,让内部电荷泵稳定。我遇到过上电立即读导致数据错误的情况,加延时后解决。
8. 项目扩展与个人体会
这套方案我已经在三个工业项目里落地了,分别是电力监测终端、油田数据记录仪和轨道交通信号采集器。最长的已经连续运行了两年多,没有出现过数据丢失或通信故障。MRAM 的可靠性确实比传统方案高一个档次,虽然单价贵一些,但省掉了电池维护和 Flash 磨损的麻烦,综合成本反而更低。
后续如果要扩展,可以考虑几个方向。一是把 MRAM 做成文件系统,用 FatFS 或 LittleFS 管理,这样上层应用可以直接用文件接口读写,不用关心底层地址。二是加一个 RTC 时间戳,每条记录带上精确时间,方便事后分析。三是用双 MRAM 做冗余,一个主一个备,主写完后同步到备,可靠性再上一个台阶。
我个人在实际操作中的体会是,MRAM 这个技术路线在工业嵌入式领域被低估了。很多人还在用 EEPROM 加电池或者 NOR Flash 加复杂掉电保护逻辑,其实换成 MRAM 后,硬件和软件都能简化很多。唯一的门槛是成本,但如果你算上维护成本和故障返修成本,MRAM 的性价比其实很高。最后再分享一个小技巧:MRAM 的 CS 引脚上拉一个 10K 电阻到 VDD,可以防止上电瞬间的误触发,这个细节在批量生产时能省掉不少麻烦。