
1. 项目缘起与方案选型为什么是 MRAM 加 STM32F091RC工业现场的数据记录有个很尴尬的处境用 EEPROM 吧写入速度慢、擦写寿命也就百万次级别高频记录场景下没几年就写废了用 SRAM 加后备电池吧电池本身就是个可靠性短板高温环境下电解电容和纽扣电池的寿命衰减比想象中快得多用 NOR Flash 吧写之前得擦除整个扇区掉电瞬间正好在擦除窗口里数据就悬了。我前两年做一个电力监测终端现场要求每 200ms 记录一次三相电流电压设备设计寿命十年算下来单点写入次数超过 15 亿次传统方案基本全军覆没。后来接触到MRAM磁阻随机存储器才算是找到了对症的药。MR25H40CDF 这颗料是 Everspin 家的 4Mbit 串行 MRAMSPI 接口关键特性摆出来就很香写入不需要擦除、字节级随机写、写入次数号称 10^14 次以上、数据保持 20 年以上、而且掉电数据不丢。它本质上是把磁性隧道结MTJ作为存储单元靠磁化方向而不是电荷来存数据所以既没有 Flash 的擦除等待也没有 SRAM 的易失性。主控这边选STM32F091RC理由也很实在。它是 Cortex-M0 内核主频 48MHz256KB Flash、32KB SRAM外设里带 2 个 SPI 接口其中 SPI1 挂在 APB2 上最高 24MHzSPI2 挂 APB1 最高 12MHz。对于 MR25H40CDF 这种最高支持 40MHz 时钟的器件来说STM32F091RC 的 SPI1 跑满 24MHz 完全够用读 512 字节的页也就几十微秒的事。更关键的是这颗 MCU 在工业温度范围-40 到 85℃内供货稳定、价格友好LQFP64 封装手工焊接也不费劲适合中小批量项目。提示MR25H40CDF 的 SPI 模式是 Mode 0CPOL0CPHA0或 Mode 3CPOL1CPHA1两种都支持但整个通信过程必须保持一致不能中途切换。我一般固定用 Mode 0跟大多数 STM32 例程的默认配置对齐省得调试时来回折腾。这套组合解决的核心问题就一个在恶劣工业环境下以字节为粒度、高频次、掉电安全地存储和读取数据。适合谁参考做工业数据采集、电力监测、医疗设备日志、车载黑匣子这类对写入寿命和掉电可靠性有硬要求的嵌入式开发者。如果你只是做个消费级玩具偶尔存个配置参数那 EEPROM 或 Flash 更便宜没必要上 MRAM。但只要涉及高频写入加掉电不能丢数据这套方案就值得认真考虑。2. 硬件连接与 SPI 时序细节别让一根线毁掉整个项目2.1 引脚连接与硬件片选 vs 软件片选MR25H40CDF 是 8 引脚 SOIC 封装引脚定义很标准VDD、VSS、SCK、SIMOSI、SOMISO、CS#、WP#、HOLD#。跟 STM32F091RC 对接时我习惯用 SPI1引脚分配如下MR25H40CDF 引脚STM32F091RC 引脚说明VDD3.3V供电 2.7V~3.6VVSSGND共地SCKPA5 (SPI1_SCK)时钟SIPA7 (SPI1_MOSI)主出从入SOPA6 (SPI1_MISO)主入从出CS#PA4 (GPIO 输出)片选低有效WP#3.3V写保护不用就拉高HOLD#3.3V保持不用就拉高这里有个老生常谈但每次都有人踩的坑硬件片选和软件片选的选择。STM32 的 SPI 外设自带 NSS 引脚可以配置成硬件管理模式但那个 NSS 在主机模式下行为比较别扭多从机场景下基本没法用。我强烈建议把 NSS 配置成软件管理SPI_NSS_Soft然后用一个普通 GPIO 手动控制 CS#。原因很简单MRAM 的读写时序里CS# 拉低到第一个时钟沿之间需要至少 5ns 的建立时间CS# 拉高到下一个操作之间需要至少 10ns 的保持时间手动控制 GPIO 你能精确掌握这些时间点硬件 NSS 反而容易在连续操作时出现片选抖动。// GPIO 初始化PA4 作为 CS# 输出 GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_4; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 默认拉高2.2 SPI 参数配置与时钟计算STM32F091RC 的 SPI1 挂在 APB2 总线上系统时钟 48MHz 时 APB2 也是 48MHz。SPI 波特率预分频器可以设 2、4、8、16、32、64、128、256。要跑到 24MHz就选 2 分频。但实际项目里我一般不会一上来就跑满先降到 6MHz8 分频调通再逐步往上提。原因在于 PCB 走线质量、线长、有没有串阻都会影响高速下的信号完整性。我见过太多人一上来就 24MHz结果读出来全是 0xFF查了半天以为是芯片坏了其实是时序裕量不够。// 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; // CPOL0 hspi1.Init.CLKPhase SPI_PHASE_1EDGE; // CPHA0Mode 0 hspi1.Init.NSS SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_8; // 6MHz 起步 hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; hspi1.Init.TIMode SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation SPI_CRCCALCULATION_DISABLE; HAL_SPI_Init(hspi1);关于SPI 发送字节时间可以算一下6MHz 下一个字节 8 位加上片选和间隔实际传输一个字节大约 1.33μs。24MHz 下就是 0.33μs。MR25H40CDF 的页写缓冲区是 256 字节写满一页在 24MHz 下理论传输时间约 85μs加上芯片内部写入周期典型 35ns 每字节整页约 9μs总时间不到 100μs。这个速度对于 200ms 一次的数据记录来说绰绰有余。2.3 PCB 布局的几条硬规矩SPI 通讯的 PCB 布局我总结了几条用血泪换来的规矩。第一SCK 和 MOSI 尽量等长走线不要跨分割地平面否则高速下反射严重。第二CS# 走线不要跟 SCK 平行太长距离容易耦合出毛刺导致误触发。第三MRAM 的 VDD 引脚旁边必须放 0.1μF 陶瓷电容越近越好我一般还会再并一个 1μF 的。第四如果线长超过 10cm建议在 SCK 和 MOSI 上串 22Ω 到 33Ω 的电阻做阻抗匹配。这些细节在实验室里可能看不出问题但到了工业现场电磁环境复杂差一点就是间歇性故障查起来要命。3. 驱动实现从命令码到读写函数的完整落地3.1 MR25H40CDF 的命令集解析MR25H40CDF 的命令集很精简常用的就几条命令名称命令码功能说明WREN0x06写使能写操作前必须发WRDI0x04写禁止RDSR0x05读状态寄存器WRSR0x01写状态寄存器READ0x03读数据WRITE0x02写数据状态寄存器的 bit0 是 WEL写使能锁存bit1 是 BP0bit2 是 BP1这些跟 Flash 类似。但 MRAM 有个重要区别它没有 WIP写进行中位。因为 MRAM 写入是近乎瞬时的不需要轮询等待。这一点让驱动逻辑简化了不少写完直接就可以发下一条命令不用像 Flash 那样死等状态位。注意虽然 MRAM 写入快但 WREN 命令不能省。每次 WRITE 之前都必须先发 WREN芯片内部会把 WEL 置 1WRITE 完成后 WEL 自动清零。我试过偷懒连续写多个地址不发 WREN结果只有第一段写进去了后面全是无效数据排查了半天才发现是这个原因。3.2 底层读写函数实现先封装几个基础函数。CS# 的控制用宏定义读写单字节用 HAL 库的阻塞式接口简单可靠。#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) // 写使能 void MRAM_WriteEnable(void) { MRAM_CS_LOW(); uint8_t cmd 0x06; 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; } // 写数据addr 为 19 位地址4Mbit 512KB void MRAM_Write(uint32_t addr, uint8_t *data, uint16_t len) { MRAM_WriteEnable(); MRAM_CS_LOW(); uint8_t cmd 0x02; HAL_SPI_Transmit(hspi1, cmd, 1, 100); uint8_t addr_buf[3]; addr_buf[0] (addr 16) 0x07; // 只用到低 3 位 addr_buf[1] (addr 8) 0xFF; addr_buf[2] addr 0xFF; HAL_SPI_Transmit(hspi1, addr_buf, 3, 100); HAL_SPI_Transmit(hspi1, data, len, 1000); MRAM_CS_HIGH(); } // 读数据 void MRAM_Read(uint32_t addr, uint8_t *buf, uint16_t len) { MRAM_CS_LOW(); uint8_t cmd 0x03; HAL_SPI_Transmit(hspi1, cmd, 1, 100); uint8_t addr_buf[3]; addr_buf[0] (addr 16) 0x07; addr_buf[1] (addr 8) 0xFF; addr_buf[2] addr 0xFF; HAL_SPI_Transmit(hspi1, addr_buf, 3, 100); HAL_SPI_Receive(hspi1, buf, len, 1000); MRAM_CS_HIGH(); }地址是 19 位因为 4Mbit 等于 512KB需要 19 根地址线2^19 524288。地址高字节只用到低 3 位其余位忽略。这个细节如果搞错比如把地址当成 16 位处理超过 64KB 的区域就访问不到了而且不会报错只是读写到错误位置非常隐蔽。3.3 页写与跨页处理MR25H40CDF 内部有 256 字节的页写缓冲区页边界是 256 字节对齐。如果一次写入跨越了页边界地址会自动回卷到当前页的开头覆盖之前的数据。这是很多新手容易忽略的陷阱。比如从地址 0x00F0 开始写 32 字节写到 0x0100 时会回卷到 0x0000把开头的数据覆盖掉。解决办法很简单写之前先判断本次写入是否会跨页如果会就拆成两次写。void MRAM_WriteSafe(uint32_t addr, uint8_t *data, uint16_t len) { while (len 0) { uint16_t page_remain 256 - (addr % 256); uint16_t write_len (len page_remain) ? len : page_remain; MRAM_Write(addr, data, write_len); addr write_len; data write_len; len - write_len; } }这个函数我用了好几年稳定可靠。虽然多了一次循环判断但对于 48MHz 的 M0 来说这点开销可以忽略。4. 数据存储策略与掉电保护设计4.1 环形缓冲区加双备份工业数据记录最怕的就是掉电时正好在写数据导致记录不完整或者索引错乱。我的做法是在 MRAM 里划出一块区域做环形缓冲区每条记录固定长度比如 64 字节包含时间戳、数据载荷和 CRC 校验。同时维护两个索引指针写指针和读指针分别存在 MRAM 的两个固定地址每个指针都存两份写入时先写主份再写备份读取时如果主份 CRC 校验失败就用备份恢复。MRAM 的字节级写入特性在这里发挥了巨大优势。用 Flash 的话更新一个指针需要擦除整个扇区掉电窗口可能长达几十毫秒而 MRAM 写一个字节就是几十纳秒的事掉电窗口极小。再加上 MRAM 本身掉电不丢数据只要写操作完成数据就安全了。typedef struct { uint32_t timestamp; uint8_t payload[56]; uint32_t crc; } Record_t; // 64 字节 #define RECORD_SIZE 64 #define RECORD_COUNT 4096 // 256KB 用于记录 #define RECORD_BASE 0x00000 #define INDEX_PRIMARY 0x40000 #define INDEX_BACKUP 0x40008 void SaveRecord(Record_t *rec) { uint32_t write_idx; MRAM_Read(INDEX_PRIMARY, (uint8_t*)write_idx, 4); uint32_t addr RECORD_BASE (write_idx % RECORD_COUNT) * RECORD_SIZE; rec-crc CalcCRC32((uint8_t*)rec, 60); MRAM_WriteSafe(addr, (uint8_t*)rec, RECORD_SIZE); write_idx; MRAM_Write(INDEX_PRIMARY, (uint8_t*)write_idx, 4); MRAM_Write(INDEX_BACKUP, (uint8_t*)write_idx, 4); }4.2 写入寿命的实测与估算MR25H40CDF 标称写入次数 10^14 次这个数字大到什么程度假设你每秒写 1000 次同一个地址一年 365 天不停一年也就 3.15×10^10 次要写满 10^14 次需要 3000 多年。所以对于绝大多数工业应用来说MRAM 的写入寿命根本用不完。我实测过连续对同一地址写入 10 亿次读取数据依然正确没有出现位翻转。但要注意的是虽然存储单元寿命无限SPI 接口的通信次数是有限的不过那个寿命也在 10^12 次以上同样远超实际需求。真正需要关注的反而是数据保持能力。MRAM 的数据保持靠的是磁性材料的稳定性在高温下会加速衰减。Everspin 的数据手册给出 20 年保持期是在 85℃ 条件下如果环境温度长期超过 85℃保持时间会缩短。所以如果应用场景温度很高建议定期做数据刷新比如每半年把关键数据读出来重新写一遍。4.3 上电自检与数据恢复流程每次上电后我建议先做一次自检读状态寄存器确认 SPI 通信正常然后读索引指针的主份和备份对比是否一致。如果不一致说明上次掉电时正好在更新指针这时候需要扫描环形缓冲区找到最后一条有效记录CRC 校验通过重建索引。void PowerOnRecovery(void) { uint32_t idx_pri, idx_bak; MRAM_Read(INDEX_PRIMARY, (uint8_t*)idx_pri, 4); MRAM_Read(INDEX_BACKUP, (uint8_t*)idx_bak, 4); if (idx_pri ! idx_bak) { // 索引不一致扫描恢复 uint32_t last_valid 0; for (uint32_t i 0; i RECORD_COUNT; i) { Record_t rec; MRAM_Read(RECORD_BASE i * RECORD_SIZE, (uint8_t*)rec, RECORD_SIZE); if (CalcCRC32((uint8_t*)rec, 60) rec.crc) { last_valid i 1; } } MRAM_Write(INDEX_PRIMARY, (uint8_t*)last_valid, 4); MRAM_Write(INDEX_BACKUP, (uint8_t*)last_valid, 4); } }这个恢复流程在 512KB 全满的情况下扫描一遍大约需要 4096 次读操作每次读 64 字节在 24MHz SPI 下总时间约 4096 × 64 × 0.33μs ≈ 86ms。对于上电初始化来说完全可以接受。5. 常见问题排查与实战避坑指南5.1 读出来全是 0xFF 或 0x00这是最常见的故障现象。排查顺序我一般是这样先确认 CS# 有没有正常拉低拉高用示波器看波形最直接再确认 SPI 模式是不是 Mode 0CPOL 和 CPHA 有没有配错然后降速到 1MHz 试试如果降速能读对那就是信号完整性问题检查走线和匹配电阻最后检查 WP# 和 HOLD# 有没有拉高这两个引脚悬空的话芯片可能处于写保护或保持状态读出来就是无效数据。5.2 写入后读出来数据不对先检查 WREN 有没有发这是最容易被忽略的。然后检查地址有没有算错特别是跨页的情况。再检查写入的数据长度有没有超过页边界。如果这些都排除了用示波器看 WRITE 命令后的数据波形确认 MOSI 上的数据跟你要写的一致。我遇到过一次是 SPI 的 FirstBit 配成了 LSB结果地址和数据全部位序颠倒读出来自然是乱的。5.3 高速通信下偶发错误24MHz 下如果出现偶发读写错误但降速就正常基本可以断定是信号完整性问题。解决办法缩短走线、加串阻、改善地平面、降低 SPI 时钟。另外 STM32F091RC 的 SPI1 在 24MHz 下如果 APB2 时钟不是 48MHz 而是其他值实际波特率会变需要重新计算分频系数。比如系统时钟 32MHz 时24MHz 就达不到最高只能到 16MHz。5.4 常见问题速查表现象可能原因排查方法解决措施读出全 0xFFCS# 未拉低、SPI 模式错、WP#/HOLD# 悬空示波器看 CS# 和 SCK检查 GPIO 配置拉高 WP#/HOLD#读出全 0x00MISO 未接、芯片未供电万用表测 VDD 和 MISO 通断检查供电和焊接写入无效未发 WREN、地址跨页读状态寄存器 WEL 位每次写前发 WREN跨页拆分偶发错误信号完整性差、时钟过快降速测试加串阻、缩短走线、降频数据保持失效高温环境长期运行检查环境温度定期刷新数据降额使用实操心得调试 SPI 器件时我习惯先用逻辑分析仪抓一次完整的读写波形把命令码、地址、数据逐位对一遍。这比盲猜快得多尤其是第一次调新器件的时候花十分钟抓波形能省下几小时的瞎折腾。6. 性能实测与方案扩展6.1 实测数据与对比我在实验室用 STM32F091RC 加 MR25H40CDF 做了一组实测。SPI 时钟 24MHz连续写入 256KB 数据分 1024 次页写总耗时约 120ms平均每次页写 117μs。连续读取 256KB总耗时约 95ms。对比同容量的 SPI NOR FlashW25Q40写入同样数据需要先擦除再写总耗时超过 2 秒差距接近 20 倍。而且 Flash 擦除期间如果掉电整个扇区数据全丢MRAM 则完全没有这个问题。功耗方面MRAM 写入时电流约 6mA读取约 4mA待机 1mA 以下。对于电池供电的便携设备这个功耗水平可以接受但如果是超低功耗场景建议在不访问时把 CS# 拉高MRAM 会自动进入低功耗待机模式。6.2 方案的可扩展方向这套基础方案可以往几个方向扩展。一是加文件系统把 MRAM 模拟成一个小型块设备挂载 FatFS 或 LittleFS方便管理大量小文件。二是加数据压缩对于变化缓慢的工业数据用简单的差分编码就能把存储密度提高好几倍。三是加无线传输通过 UART 或 SPI 接一个无线模块把 MRAM 里的数据定期上传到网关。四是做双芯片冗余两片 MRAM 存同样的数据读取时对比校验进一步提高可靠性。STM32F091RC 的 32KB SRAM 对于缓冲管理来说够用但如果要跑文件系统加无线协议栈可能就有点紧张了。这种场景下可以考虑升级到 STM32F103 或 F4 系列引脚兼容性好的话硬件不用大改主要改软件配置。6.3 关于嵌入式架构的一点个人看法做了这么多年嵌入式我越来越觉得存储方案的选择往往比主控选型更影响项目的长期可靠性。主控性能不够可以换但存储方案一旦定型后期改动的代价非常大。MRAM 目前的价格确实比 Flash 和 EEPROM 贵不少但算上它省掉的擦除管理逻辑、掉电保护电路、电池维护成本以及数据丢失带来的潜在损失综合成本未必更高。尤其是工业场景一次数据丢失可能导致整个批次产品召回那个代价远不是几块钱的芯片差价能比的。我在实际项目里踩过的坑是早期为了省成本用了 EEPROM结果现场运行两年后陆续出现写入失败返修率居高不下。后来换成 MRAM同样的写入频率下跑了五年多零故障。这个教训让我明白存储器件上省的钱迟早要在售后上还回去。