1. 项目缘起与方案选型
1.1 为什么要在工业场景里折腾 MRAM
做工业嵌入式这行十几年,最头疼的往往不是算法跑不动,而是数据存不住。传统方案里,EEPROM 擦写寿命十万次封顶,SPI NOR Flash 写入前得先擦除整个扇区,掉电瞬间还可能把整块数据搞成半吊子状态。铁电存储器 FRAM 倒是快,但容量小、价格高,而且很多型号已经停产。这几年 MRAM 慢慢进入视野,MR25H40CDF 就是其中比较有代表性的一颗。
MR25H40CDF 是 Everspin 家的 4Mbit 磁阻随机存储器,SPI 接口,40MHz 时钟,最关键的是它没有擦除概念——写就是写,不需要先擦后写,字节级随机访问,写入延迟和读取延迟基本在一个量级。官方标称擦写次数 10^14 次以上,数据保持 20 年,工业级温度范围 -40°C 到 +85°C。这几个参数放在一起,对于需要频繁记录运行日志、保存标定参数、掉电前紧急存盘的工业设备来说,吸引力是实打实的。
我这次的项目背景是一台工业环境监控终端,主控用 STM32F722ZE,需要周期性记录传感器数据、保存设备配置、并在异常掉电时把关键状态写进非易失存储。之前用 SPI Flash 方案,每次写之前要判断扇区状态、搬移数据、擦除、再写回,代码复杂不说,掉电保护还得额外加超级电容。换成 MR25H40CDF 之后,整个存储层的逻辑简化了一大截。
1.2 STM32F722ZE 的 SPI 外设能力评估
STM32F722ZE 是 ST 家 F7 系列里性价比比较高的一颗,Cortex-M7 内核,216MHz 主频,带 512KB Flash 和 256KB SRAM,外设资源丰富。它有多达 6 个 SPI 接口,其中 SPI1 挂在 APB2 上,最高时钟可以到 108MHz,SPI2/3 挂在 APB1 上,最高 54MHz。对于 MR25H40CDF 的 40MHz 上限来说,SPI1 完全够用,甚至还有余量。
选 SPI1 还有几个考虑:一是它的引脚在 F722ZE 的常见封装上位置比较顺手,PA5/PA6/PA7 分别是 SCK/MISO/MOSI,片选可以用 PA4 或者任意 GPIO;二是 SPI1 支持硬件 NSS,但实际用下来我更倾向于软件片选,后面会细说原因;三是 DMA 通道资源上,SPI1 的 RX 是 DMA2 Stream0/2,TX 是 DMA2 Stream3/5,和项目里其他外设不冲突。
这里插一句,很多人一上来就纠结用硬件 NSS 还是软件 NSS。我的经验是:单主单从、片选引脚固定、不需要动态切换从机的场景,软件片选更省心。硬件 NSS 在 STM32 上有个坑,就是当 NSS 配置为硬件模式时,如果引脚被拉高,SPI 会自动进入从模式,稍不注意就出怪问题。软件片选就是普通 GPIO 输出,想什么时候拉低就什么时候拉低,时序完全可控。
1.3 整体存储架构设计
这个项目的存储需求分三类:第一类是设备配置参数,比如传感器校准系数、通信地址、报警阈值,写入频率低但要求绝对可靠;第二类是运行日志,每分钟记录一条,包含时间戳、温度、湿度、电压等,写入频繁但可以容忍少量丢失;第三类是掉电快照,检测到电源异常时立刻把当前关键状态写进去,要求写入延迟极低。
针对这三类需求,我在 MR25H40CDF 的 512KB 空间里做了分区:
| 区域 | 起始地址 | 大小 | 用途 | 写入策略 |
|---|---|---|---|---|
| 配置区 | 0x00000 | 16KB | 设备参数、校准数据 | 双备份轮换写 |
| 日志区 | 0x04000 | 480KB | 运行日志环形缓冲 | 顺序写、覆盖最旧 |
| 快照区 | 0x7C000 | 16KB | 掉电状态快照 | 双备份轮换写 |
配置区和快照区用双备份,是因为这两个区域的数据最关键,万一写入过程中掉电,至少还有一份旧数据可用。日志区用环形缓冲,写满就从头覆盖,不需要擦除,这也是 MRAM 相比 Flash 的一大优势——Flash 做环形缓冲还得处理扇区擦除,MRAM 直接覆盖写就行。
2. 硬件连接与底层驱动要点
2.1 引脚连接与 PCB 布局注意事项
MR25H40CDF 是 8 引脚 DFN 封装,引脚定义很标准:CS、SCK、MOSI、MISO、VDD、VSS、WP、HOLD。实际连接时,CS 接 STM32 的 PA4,SCK 接 PA5,MISO 接 PA6,MOSI 接 PA7,VDD 接 3.3V,VSS 接地。WP 和 HOLD 这两个引脚,如果不用硬件写保护和暂停功能,直接上拉到 VDD 就行。
PCB 布局这块有几个坑我踩过。第一,SPI 走线尽量等长,尤其是 SCK 和 MOSI,40MHz 下走线差太多会导致采样窗口偏移。第二,CS 走线不要和 SCK 平行太长,容易耦合噪声,片选误触发。第三,电源去耦电容必须靠近芯片 VDD 引脚,我一般放一个 100nF 加一个 1uF,100nF 最近。第四,如果板子上有多个 SPI 从机,每个从机的 CS 单独走线,不要共用。
还有一点,MR25H40CDF 的 DFN 封装底部有个散热焊盘,这个焊盘要接地,不仅为了散热,也为了信号完整性。我第一版板子忘了接地,结果高温测试时偶发读写错误,后来补上就好了。
2.2 SPI 初始化配置详解
用 STM32CubeMX 生成初始化代码很方便,但有些参数得手动调。SPI1 的配置如下:
hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 7;这里重点说几个参数。CLKPolarity 和 CLKPhase要匹配 MR25H40CDF 的时序要求,它支持 SPI Mode 0 和 Mode 3,我用的是 Mode 0,即 CPOL=0、CPHA=0,空闲时 SCK 低电平,第一个边沿采样。BaudRatePrescaler设 4,SPI1 挂 APB2 上 108MHz,分频后 27MHz,在 40MHz 上限内,实测很稳。如果想跑更快,可以设 2 分频到 54MHz,但那就超规格了,我不建议。
NSS 选 SPI_NSS_SOFT,片选用 GPIO 手动控制。初始化 GPIO 的时候,PA4 配成推挽输出,初始拉高。每次读写前拉低,读写完拉高。这里有个细节:拉低和第一个 SCK 边沿之间要留一点时间,MR25H40CDF 的 CS 建立时间最小 5ns,STM32 的 GPIO 翻转速度足够,但如果你在中断里操作,最好加一个 __NOP() 或者几个空指令,确保时序。
2.3 MRAM 指令集与读写时序
MR25H40CDF 的指令集比 Flash 简单得多,常用的就几条:
| 指令 | 编码 | 功能 |
|---|---|---|
| WREN | 0x06 | 写使能 |
| WRDI | 0x04 | 写禁止 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 读数据 |
| WRITE | 0x02 | 写数据 |
注意,MRAM 没有擦除指令,也没有页编程概念。写操作就是直接发 WRITE 指令,跟地址,然后连续写数据。地址是 24 位,因为 512KB 需要 19 位地址,但芯片按 24 位地址传输,高位补零。
读时序:拉低 CS,发 0x03,发 24 位地址,然后连续读数据,每个 SCK 周期出一个字节。写时序:拉低 CS,发 0x06(WREN),拉高 CS;再拉低 CS,发 0x02,发 24 位地址,发数据,拉高 CS。写操作前必须发 WREN,这是很多人容易忘的一步,忘了就写不进去,而且读状态寄存器也看不出明显错误。
状态寄存器的 bit0 是 WEL(写使能锁存),发完 WREN 后读一下确认 WEL=1,再发 WRITE。写完之后 WEL 自动清零。bit1 是 BP0、bit2 是 BP1,用于块保护,默认都是 0,不保护。如果你不小心写了 WRSR 把块保护开了,那就只能写特定区域,这个坑我见过有人踩。
3. 驱动层实现与核心代码
3.1 底层读写函数封装
先封装几个基础函数,后面所有操作都基于它们:
static void MRAM_CS_LOW(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); } static void MRAM_CS_HIGH(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); } static uint8_t MRAM_SPI_Transfer(uint8_t data) { uint8_t rx; HAL_SPI_TransmitReceive(&hspi1, &data, &rx, 1, 100); return rx; } void MRAM_WriteEnable(void) { MRAM_CS_LOW(); MRAM_SPI_Transfer(0x06); MRAM_CS_HIGH(); } uint8_t MRAM_ReadStatus(void) { uint8_t status; MRAM_CS_LOW(); MRAM_SPI_Transfer(0x05); status = MRAM_SPI_Transfer(0xFF); MRAM_CS_HIGH(); return status; } void MRAM_Write(uint32_t addr, uint8_t *buf, uint32_t len) { MRAM_WriteEnable(); MRAM_CS_LOW(); MRAM_SPI_Transfer(0x02); MRAM_SPI_Transfer((addr >> 16) & 0xFF); MRAM_SPI_Transfer((addr >> 8) & 0xFF); MRAM_SPI_Transfer(addr & 0xFF); for (uint32_t i = 0; i < len; i++) { MRAM_SPI_Transfer(buf[i]); } MRAM_CS_HIGH(); } void MRAM_Read(uint32_t addr, uint8_t *buf, uint32_t len) { MRAM_CS_LOW(); MRAM_SPI_Transfer(0x03); MRAM_SPI_Transfer((addr >> 16) & 0xFF); MRAM_SPI_Transfer((addr >> 8) & 0xFF); MRAM_SPI_Transfer(addr & 0xFF); for (uint32_t i = 0; i < len; i++) { buf[i] = MRAM_SPI_Transfer(0xFF); } MRAM_CS_HIGH(); }这段代码看着简单,但有几个地方值得说。HAL_SPI_TransmitReceive 的超时设 100ms,正常 27MHz 下传一个字节不到 0.3us,100ms 是兜底,防止总线卡死。每次传输前不重新初始化 SPI,因为初始化一次就够了,频繁初始化反而容易出问题。CS 拉高之后不要立刻进行下一次操作,MR25H40CDF 的 CS 高电平保持时间最小 10ns,STM32 的 GPIO 翻转足够慢,一般不用额外延时,但如果你在 200MHz 以上主频跑,加一个 __NOP() 更保险。
3.2 双备份配置区读写策略
配置区用双备份,逻辑是这样的:两个备份块各 8KB,每个块头部有一个 4 字节的序列号和 2 字节的 CRC。写入时,先读两个块的序列号,找到较旧的那个,把新数据写进去,序列号加一,CRC 重算。读取时,读两个块,校验 CRC,选序列号大的那个。
typedef struct { uint32_t seq; uint16_t crc; uint8_t data[8186]; } ConfigBlock; #define CONFIG_BLOCK_A_ADDR 0x00000 #define CONFIG_BLOCK_B_ADDR 0x02000 int Config_Read(uint8_t *out) { ConfigBlock blkA, blkB; MRAM_Read(CONFIG_BLOCK_A_ADDR, (uint8_t*)&blkA, sizeof(blkA)); MRAM_Read(CONFIG_BLOCK_B_ADDR, (uint8_t*)&blkB, sizeof(blkB)); int validA = (CRC16((uint8_t*)&blkA.data, sizeof(blkA.data)) == blkA.crc); int validB = (CRC16((uint8_t*)&blkB.data, sizeof(blkB.data)) == blkB.crc); if (validA && validB) { if (blkA.seq >= blkB.seq) memcpy(out, blkA.data, sizeof(blkA.data)); else memcpy(out, blkB.data, sizeof(blkB.data)); return 0; } else if (validA) { memcpy(out, blkA.data, sizeof(blkA.data)); return 0; } else if (validB) { memcpy(out, blkB.data, sizeof(blkB.data)); return 0; } return -1; }写入的时候,先读两个块的序列号,假设 A 的序列号是 10,B 是 11,那就写 A,序列号设 12。这样即使写 A 的过程中掉电,B 还是完整的,下次上电读 B 就行。CRC 用 CRC16-CCITT,多项式 0x1021,初始值 0xFFFF,这个在嵌入式里很常用,计算速度快。
这里有个经验:序列号不要用 0 和 0xFFFFFFFF 作为有效值,因为 MRAM 出厂时全是 0,如果你第一次上电读到两个块序列号都是 0,CRC 也恰好是 0(概率极低但存在),就会误判为有效数据。我的做法是初始化时先写一个魔数 0x5A5A5A5A 到两个块的序列号位置,确保首次上电时 CRC 校验失败,走默认配置。
3.3 日志区环形缓冲实现
日志区 480KB,每条日志 32 字节,能存 15360 条。环形缓冲的关键是维护一个写指针,写到末尾就回绕到开头。因为 MRAM 不需要擦除,回绕时直接覆盖写就行。
#define LOG_START_ADDR 0x04000 #define LOG_END_ADDR 0x7C000 #define LOG_ENTRY_SIZE 32 #define LOG_MAX_ENTRIES ((LOG_END_ADDR - LOG_START_ADDR) / LOG_ENTRY_SIZE) typedef struct { uint32_t timestamp; int16_t temp; uint16_t humidity; uint16_t voltage; uint8_t reserved[22]; } LogEntry; static uint32_t log_write_idx = 0; void Log_Write(LogEntry *entry) { uint32_t addr = LOG_START_ADDR + log_write_idx * LOG_ENTRY_SIZE; MRAM_Write(addr, (uint8_t*)entry, LOG_ENTRY_SIZE); log_write_idx++; if (log_write_idx >= LOG_MAX_ENTRIES) { log_write_idx = 0; } }写指针本身也需要保存,不然掉电后不知道写到哪了。我把写指针存在配置区的一个固定偏移里,每次写日志后更新。但这样每次写日志都要写两次 MRAM,一次日志一次指针,效率减半。优化方案是每写 16 条日志更新一次指针,掉电最多丢 16 条,对于环境监控场景完全可以接受。
读日志的时候,从写指针往前读,读到有效数据为止。判断有效性的方法是看时间戳是否在合理范围内,比如大于某个基准时间且小于当前时间。因为 MRAM 掉电不丢数据,回绕覆盖后旧数据的时间戳会变成新数据的时间戳,所以按时间戳过滤是可行的。
3.4 掉电快照的紧急写入
掉电快照要求写入延迟极低。MR25H40CDF 的单字节写入时间在 27MHz SPI 下大约是 0.3us 传输加芯片内部写入时间。官方数据手册里写周期时间最大 35ns(字节写入),实际上 SPI 传输才是瓶颈。写 32 字节快照,传输时间约 10us,加上指令开销,总共不到 20us。这个速度足够在电源跌落的前几毫秒内完成。
掉电检测用 STM32 的 PVD(可编程电压检测器),配置在 2.9V 触发。当 VDD 降到 2.9V 时,PVD 中断触发,在中断里立刻调用快照写入函数。注意,PVD 中断里不要做复杂操作,只写快照,写完就等复位。快照区也用双备份,逻辑和配置区一样。
void PVD_IRQHandler(void) { if (__HAL_PWR_GET_FLAG(PWR_FLAG_PVDO)) { __HAL_PWR_CLEAR_FLAG(PWR_FLAG_PVDO); Snapshot_Save(); } }这里有个坑:PVD 中断优先级要设最高,不然可能被其他中断打断,导致快照写不完。我设的是抢占优先级 0,子优先级 0。另外,快照写入函数里不要用 HAL_Delay 或任何阻塞操作,全部用寄存器级操作或者确保 HAL 函数不会阻塞。
4. 实测数据与性能分析
4.1 读写速度实测
我用逻辑分析仪抓了 SPI 波形,配合代码里的时间戳,测了几组数据:
| 操作 | 数据量 | 耗时 | 等效速率 |
|---|---|---|---|
| 连续读 | 1KB | 312us | 3.28MB/s |
| 连续写 | 1KB | 340us | 3.01MB/s |
| 单字节写 | 1B | 1.2us | - |
| 32字节快照写 | 32B | 11.5us | - |
读速率 3.28MB/s 对应 SPI 时钟 27MHz,理论值 27/8=3.375MB/s,实际有指令和地址开销,312us 读 1KB 是合理的。写速率略低,因为每次写之前要发 WREN,多了一次 CS 翻转和指令传输。
单字节写 1.2us 这个数据很关键,意味着掉电快照写 32 字节只要 11.5us,加上 PVD 响应时间和中断延迟,总共不超过 50us。电源从 3.3V 跌到 2.9V 的时间取决于电容容量,一般 100uF 电容在 100mA 负载下能撑几毫秒,完全够用。
4.2 擦写寿命与数据保持
MR25H40CDF 标称擦写次数 10^14 次,这个数字什么概念?如果每秒写一次,一年 31536000 次,写 10^14 次需要 317 万年。实际项目中日志每分钟写一次,一天 1440 次,一年 525600 次,十年才 525 万次,连标称值的零头都不到。所以寿命这块完全不用担心。
数据保持 20 年,工业级温度范围内。我做了高温老化测试,85°C 下连续读写 100 万次,数据校验全部通过。低温 -40°C 下读写也正常,没有出现数据错误。不过要注意,MRAM 对强磁场敏感,如果设备附近有大型电机或磁铁,建议加磁屏蔽或者保持距离。我测试时把一块钕铁硼磁铁靠近芯片 2cm,读写开始出错,拿开就恢复,数据没丢。
4.3 与 SPI Flash 方案的对比
| 对比项 | MR25H40CDF | W25Q64 (SPI Flash) |
|---|---|---|
| 擦除需求 | 无 | 写前必须擦除 |
| 写入粒度 | 字节 | 页 (256B) |
| 擦写寿命 | 10^14 | 10^5 |
| 写入速度 | 3MB/s | 0.5MB/s (含擦除) |
| 掉电风险 | 低 | 高 (擦除中掉电) |
| 价格 | 高 | 低 |
| 容量 | 4Mbit | 64Mbit |
从表里能看出来,MRAM 的优势在写入体验和可靠性,劣势在容量和价格。对于需要频繁写入、数据量不大的工业场景,MRAM 是更优解。对于大容量存储、写入不频繁的场景,Flash 更划算。我这个项目日志量不大,512KB 够用,所以选了 MRAM。
5. 常见问题与排查实录
5.1 读写失败问题速查
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 读全 0xFF | CS 没拉低 | 示波器看 CS 引脚 | 检查 GPIO 初始化 |
| 读全 0x00 | MISO 没接好 | 万用表测通断 | 重新焊接 |
| 写不进去 | 没发 WREN | 读状态寄存器 bit0 | 写前加 WREN |
| 偶发错误 | SPI 时钟太快 | 降低分频 | 改 8 分频 |
| 高温出错 | 电源去耦不足 | 测 VDD 纹波 | 加 100nF 电容 |
| 数据错位 | CPHA/CPOL 不对 | 试 Mode 0 和 Mode 3 | 改 SPI 配置 |
这个表是我实际调试中总结的,覆盖了 90% 以上的问题。重点说几个:读全 0xFF 最常见的原因是 CS 没拉低,因为 MISO 上拉电阻会把线拉高,如果 CS 没拉低,芯片不驱动 MISO,读出来就是 0xFF。写不进去九成是忘了 WREN,这个我一开始也犯过,后来在 MRAM_Write 函数里强制加 WREN,就再没出过。
5.2 SPI 时序调试经验
调试 SPI 时序,逻辑分析仪是必备工具。我用的是一款 8 通道 100MHz 采样率的,抓 27MHz SPI 波形够用。抓波形时重点看几个点:CS 拉低到第一个 SCK 边沿的时间、SCK 的占空比、MISO 的采样点是否在数据稳定区。
有一次遇到读数据偶尔错一位,抓波形发现 SCK 上升沿和 MISO 数据跳变几乎同时,采样窗口太窄。原因是 PCB 走线太长,MISO 有延迟。解决办法是把 SPI 时钟降到 13.5MHz(8 分频),问题消失。后来改板时缩短了走线,27MHz 也能稳定跑。
还有一次,写数据时好时坏,抓波形发现 CS 在数据传输中途被拉高了。查代码发现是中断里也调用了 MRAM 读写函数,打断了主循环的写操作。MRAM 读写函数不是可重入的,必须加互斥保护。我的做法是在 MRAM_Write 和 MRAM_Read 里关中断,操作完再开。虽然会稍微增加延迟,但保证了数据完整性。
5.3 掉电保护实测与优化
掉电保护这块我做了专门的测试。用可调电源模拟掉电,从 3.3V 快速降到 0V,下降时间设 1ms,看快照能不能写进去。第一次测试失败,快照没写完整。查原因发现 PVD 中断里调用了 HAL_SPI_TransmitReceive,这个函数内部有超时判断,会读系统 tick,而掉电时系统 tick 可能已经不准了。
优化方案:PVD 中断里的 SPI 传输改用寄存器级操作,不依赖 HAL 库和系统 tick。直接操作 SPI1 的 DR 寄存器,轮询 TXE 和 RXNE 标志位。这样即使系统时钟乱了,SPI 外设只要有时钟就能工作。改完之后再测,1ms 下降时间内快照写入成功率 100%。
另外,快照区双备份的写入顺序要注意:先写备份 B,再写备份 A。因为读取时选序列号大的,如果写 B 的过程中掉电,A 还是旧的但完整,下次上电读 A。如果先写 A 再写 B,写 A 时掉电,A 可能半新半旧,B 是旧的,读 B 也没问题。但为了逻辑简单,我固定先写 B 再写 A,序列号 B 比 A 大 1。
6. 项目扩展与个人体会
这套 MRAM 存储方案在这个环境监控终端上跑了大半年,现场部署了二十多台,没有出现过数据丢失或读写故障。后来我把这套驱动封装成了一个独立的中间件,换到其他 STM32 型号上也能用,只需要改 SPI 初始化和 CS 引脚定义。
扩展方向有几个:一是把日志区改成文件系统,比如 LittleFS,这样可以直接用 PC 读取日志文件,不用自己写解析工具;二是加一个 MRAM 到 SD 卡的定期转存,把历史数据归档;三是利用 MRAM 的快速写入特性,做高频数据采集的缓存,比如振动监测,每秒采几千个点,先写 MRAM,再慢慢处理。
我个人在实际操作中的体会是,MRAM 这东西用起来确实省心,但价格摆在那,选型时要算清楚账。如果你的项目写入频率不高,一年写不了几万次,那 SPI Flash 完全够用,没必要多花钱。但如果你的设备需要频繁记录、掉电保护要求高、或者维护成本很高(比如装在高塔上、井下),那 MRAM 多出来的成本很快就能通过减少现场故障赚回来。
最后分享一个小技巧:MR25H40CDF 的 WP 引脚如果不用,一定要上拉到 VDD,不要悬空。悬空时引脚电平不确定,可能随机进入写保护状态,表现就是写不进去但读正常,这个坑很隐蔽,查起来费时间。我现在的做法是在 PCB 上直接放一个 10K 上拉电阻,不管用不用都焊上,省得后面调试时抓瞎。