
1. 项目缘起与方案选型1.1 为什么要在工业场景里折腾 MRAM做过工业设备的人都有一个共识数据存储这块选型选错了后面全是坑。我手头这个项目是一个工业数据采集终端主控用的是 TI 的 TM4C1294KCPDT需要周期性记录设备运行参数、故障日志和校准系数。一开始团队想当然地用了常见的 SPI NOR Flash结果在实验室跑得好好的到了现场就出问题——写入延迟不稳定掉电瞬间偶尔丢数据而且擦写寿命在频繁记录的场景下捉襟见肘。后来我们把目光转向了 MRAM具体型号是 MR25H40CDF。这颗芯片是 4Mbit 的磁阻随机存储器SPI 接口最关键的特性是写入不需要擦除、写入速度接近 SRAM、几乎无限的擦写寿命官方标称 10^14 次以上、掉电数据不丢失。这几点对于工业场景来说简直是量身定做。传统 EEPROM 写入慢、寿命有限NOR Flash 有擦除块的概念写入前要先擦掉电窗口期风险大FRAM 虽然也不错但容量和价格摆在那里。MRAM 在这几个维度上找到了一个很舒服的平衡点。TM4C1294KCPDT 这颗 MCU 本身带多个 SPI 外设主频 120MHzCortex-M4F 内核工业温度范围用来驱动 MR25H40CDF 绰绰有余。两者搭配一个负责高速可靠存储一个负责实时控制与数据处理整个方案在成本和可靠性上都说得过去。1.2 硬件连接与 SPI 模式选择MR25H40CDF 支持 SPI 模式 0 和模式 3具体用哪个取决于主控的配置。TM4C1294 的 SSI同步串行接口模块可以灵活配置时钟极性和相位。我这边选的是模式 0CPOL0CPHA0原因是 TM4C1294 的 SSI 在模式 0 下时序最干净示波器抓波形的时候上升沿采样稳定不容易出现建立时间不足的问题。硬件连接上MR25H40CDF 的引脚定义很清晰CS 片选、SCK 时钟、SI 数据输入、SO 数据输出加上 VCC 和 GND。注意这颗芯片的 VCC 范围是 2.7V 到 3.6V典型值 3.3V和 TM4C1294 的 IO 电平完全匹配不需要电平转换。CS 引脚我接的是 TM4C1294 的 PA3配置为 GPIO 输出用软件控制片选。为什么不用硬件片选因为 TM4C1294 的 SSI 硬件片选在某些封装下引脚复用比较紧张而且软件片选在调试阶段更方便抓时序想什么时候拉低就什么时候拉低灵活性更高。注意MR25H40CDF 的 CS 引脚内部没有上拉悬空时可能误触发。建议在 PCB 上靠近芯片的位置加一个 10k 上拉到 VCC确保主控未初始化时芯片处于未选中状态。SPI 时钟频率方面MR25H40CDF 最高支持 40MHz。我实际跑的是 20MHz原因有两个一是 PCB 走线不算短20MHz 下信号完整性更稳妥二是 TM4C1294 的 SSI 在 20MHz 时分频系数比较整时钟抖动小。实测 20MHz 下连续读写 4KB 数据耗时大约 1.6ms对于我的采集周期100ms 一次来说完全够用。1.3 存储结构规划MR25H40CDF 的 4Mbit 容量换算过来是 512KB地址空间从 0x00000 到 0x7FFFF。我把它划分成几个区域前 64KB 存放系统配置和校准系数中间 384KB 用于循环记录运行日志最后 64KB 预留做固件升级备份区。每个区域用独立的地址偏移管理读写的时候直接算地址不需要文件系统。工业场景下裸地址管理比文件系统更可靠也更容易做掉电保护。日志区采用环形缓冲结构写指针到达区域末尾后自动回绕到起始地址。每条日志固定 32 字节包含时间戳、设备 ID、参数值和 CRC 校验。这样设计的好处是写入地址可预测不需要动态分配掉电后恢复也简单——只需要扫描最后几条记录找到有效数据即可。2. MR25H40CDF 的底层驱动实现2.1 SPI 初始化与底层读写函数TM4C1294 的 SSI 初始化我封装成了一个函数核心配置包括时钟源、分频系数、数据宽度和模式。代码基于 TivaWare 库编写直接操作寄存器也可以但用库函数开发效率更高。下面是我实际使用的初始化代码#include driverlib/ssi.h #include driverlib/gpio.h #include driverlib/pin_map.h #include inc/hw_memmap.h #define MRAM_CS_PORT GPIO_PORTA_BASE #define MRAM_CS_PIN GPIO_PIN_3 void MRAM_SPI_Init(void) { // 使能 SSI2 和 GPIOA 时钟 SysCtlPeripheralEnable(SYSCTL_PERIPH_SSI2); SysCtlPeripheralEnable(SYSCTL_PERIPH_GPIOA); while(!SysCtlPeripheralReady(SYSCTL_PERIPH_SSI2)); while(!SysCtlPeripheralReady(SYSCTL_PERIPH_GPIOA)); // 配置 PA2/PA4/PA5 为 SSI2 功能引脚 GPIOPinConfigure(GPIO_PA2_SSI2CLK); GPIOPinConfigure(GPIO_PA4_SSI2RX); GPIOPinConfigure(GPIO_PA5_SSI2TX); GPIOPinTypeSSI(GPIO_PORTA_BASE, GPIO_PIN_2 | GPIO_PIN_4 | GPIO_PIN_5); // CS 引脚配置为普通 GPIO 输出 GPIOPinTypeGPIOOutput(MRAM_CS_PORT, MRAM_CS_PIN); GPIOPinWrite(MRAM_CS_PORT, MRAM_CS_PIN, MRAM_CS_PIN); // 默认拉高 // SSI2 配置主模式、20MHz、8位数据、模式0 SSIConfigSetExpClk(SSI2_BASE, SysCtlClockGet(), SSI_FRF_MOTO_MODE_0, SSI_MODE_MASTER, 20000000, 8); SSIEnable(SSI2_BASE); }这段代码里有个细节值得说SSIConfigSetExpClk的最后一个参数是数据宽度设为 8 位。MR25H40CDF 的指令、地址和数据都是按字节传输的8 位宽度最直接。地址是 24 位的需要分三次发送每次 8 位。底层读写函数我写了两个一个用于发送单字节并接收返回一个用于连续读写多字节。SPI 的本质是全双工移位寄存器发送和接收同时进行所以读数据的时候要发送哑元数据通常是 0xFF来产生时钟。uint8_t MRAM_SPI_Transfer(uint8_t txData) { uint32_t rxData; SSIDataPut(SSI2_BASE, txData); while(SSIBusy(SSI2_BASE)); SSIDataGet(SSI2_BASE, rxData); return (uint8_t)rxData; } void MRAM_CS_Low(void) { GPIOPinWrite(MRAM_CS_PORT, MRAM_CS_PIN, 0); } void MRAM_CS_High(void) { GPIOPinWrite(MRAM_CS_PORT, MRAM_CS_PIN, MRAM_CS_PIN); }实操心得SSIDataPut之后一定要等SSIBusy清零再读数据否则读出来的是上一次的残留值。我一开始没加这个等待调试的时候数据偶尔错位查了半天才发现是时序问题。2.2 MR25H40CDF 指令集解析MR25H40CDF 的指令集不复杂常用的就几条WREN写使能0x06、WRDI写禁止0x04、RDSR读状态寄存器0x05、WRSR写状态寄存器0x01、READ读数据0x03、WRITE写数据0x02。注意它没有擦除指令这是 MRAM 和 Flash 最大的区别——写入就是直接覆盖不需要先擦后写。写操作之前必须先发 WREN 指令把内部的写使能锁存器置位。这个锁存器在每次写操作完成后自动复位所以每次写之前都要重新发 WREN。读操作不需要 WREN直接发 READ 指令加 24 位地址就能连续读出数据。状态寄存器的 bit0 是 WIPWrite In Progress位写入过程中为 1完成后为 0。虽然 MRAM 写入很快但保险起见写完关键数据后我还是会轮询一下 WIP 位确保数据真正落盘。实测单字节写入后 WIP 位在 200ns 内就清零了比 Flash 的毫秒级等待快了几个数量级。2.3 单字节与页写入的实现差异MR25H40CDF 支持单字节写和页写。页写一次最多可以写 256 字节地址的低 8 位在页内自动递增高 16 位不变。如果跨页写地址会回绕到当前页的起始位置而不是自动跳到下一页。这个行为和很多 Flash 芯片类似用的时候要特别注意。我的日志记录是 32 字节一条远小于 256 字节所以直接用页写指令一次把整条记录写进去。这样比逐字节写效率高得多——逐字节写每条记录需要 32 次 CS 拉低拉高页写只需要一次。实测页写 32 字节耗时约 20us逐字节写要 200us 以上。void MRAM_PageWrite(uint32_t addr, uint8_t *data, uint16_t len) { if(len 256) len 256; // 页写上限 MRAM_CS_Low(); MRAM_SPI_Transfer(0x06); // WREN MRAM_CS_High(); MRAM_CS_Low(); MRAM_SPI_Transfer(0x02); // WRITE MRAM_SPI_Transfer((addr 16) 0xFF); MRAM_SPI_Transfer((addr 8) 0xFF); MRAM_SPI_Transfer(addr 0xFF); for(uint16_t i 0; i len; i) { MRAM_SPI_Transfer(data[i]); } MRAM_CS_High(); // 等待写入完成 while(MRAM_ReadStatus() 0x01); }注意WREN 指令和 WRITE 指令之间必须拉高 CS 再拉低不能连续发送。我试过在同一个 CS 低电平周期内先发 WREN 再发 WRITE结果写不进去。后来查手册才发现WREN 锁存器在 CS 上升沿才会生效所以必须分开。3. 数据存储与读取的完整实操3.1 系统配置区的读写流程配置区我放在地址 0x00000 开始的位置结构体定义如下typedef struct { uint32_t magic; // 固定值 0x4D52414D用于识别有效配置 uint16_t deviceId; // 设备编号 uint16_t sampleRate; // 采样率单位 Hz float calibK; // 校准系数 K float calibB; // 校准系数 B uint32_t crc32; // 前面所有字节的 CRC32 } SysConfig_t;上电初始化时先从 MRAM 读出这个结构体检查 magic 和 CRC。如果 magic 不对或者 CRC 校验失败说明配置区是空的或者损坏了这时候加载默认配置并写回 MRAM。这个流程保证了设备第一次上电或者配置丢失后能自动恢复。写配置的时候我先把结构体所有字段填好计算 CRC32然后调用页写函数一次性写入。注意结构体在内存中可能有填充字节直接按sizeof(SysConfig_t)写入会把填充字节也写进去。这些填充字节的值是不确定的会导致 CRC 计算和校验不一致。解决办法是用#pragma pack(1)取消结构体填充或者手动逐字段序列化到缓冲区再写。我选的是后者更可控。3.2 环形日志区的写入与回绕日志区从地址 0x10000 开始长度 0x60000384KB每条记录 32 字节总共可以存 12288 条。写指针存在一个单独的变量里每次上电时从 MRAM 的固定位置读出来。写指针本身也占 4 字节我把它放在地址 0x0FFF0 的位置和配置区隔开。写入流程是这样的先判断写指针是否到达日志区末尾如果是就回绕到起始地址然后组装 32 字节的记录数据计算 CRC最后页写入 MRAM更新写指针并写回。整个过程在 100us 以内完成对主循环的影响可以忽略。#define LOG_BASE 0x10000 #define LOG_SIZE 0x60000 #define LOG_ENTRY 32 #define LOG_MAX (LOG_SIZE / LOG_ENTRY) void WriteLogEntry(uint8_t *entry) { uint32_t wp GetWritePointer(); if(wp LOG_MAX) wp 0; uint32_t addr LOG_BASE wp * LOG_ENTRY; MRAM_PageWrite(addr, entry, LOG_ENTRY); wp; SetWritePointer(wp); }读取日志的时候从写指针的前一条开始往前读遇到 magic 不对或者 CRC 错误的记录就停止。这样能保证读出来的都是有效数据不会把旧数据或者空白区域误读进来。3.3 掉电保护与数据完整性验证工业现场掉电是家常便饭数据完整性必须重点考虑。MRAM 本身的写入是原子性的——要么写成功要么没写不存在写了一半的中间状态。这一点比 Flash 强太多Flash 在擦除过程中掉电整个扇区都可能变成乱码。但写指针的更新和日志写入是两个独立操作如果写完日志还没来得及更新写指针就掉电了下次上电会覆盖掉刚写的那条记录。为了解决这个问题我把写指针也存了两份一份在地址 0x0FFF0一份在地址 0x0FFF4互为备份。上电时读两份如果一致就用不一致就用较大的那个因为写指针是递增的较大的说明是较新的。这个策略简单有效实测在反复掉电测试中没有丢过数据。CRC 校验我用的是标准 CRC32 多项式 0xEDB88320查表法实现速度很快。每条日志写入前计算 CRC读取时重新计算并比对。如果 CRC 不匹配说明数据损坏这条记录就丢弃。实测在强电磁干扰环境下MRAM 本身没有出现过位翻转但为了保险CRC 还是保留着。4. 常见问题与排查实录4.1 SPI 通信失败的典型原因调试 SPI 设备十次有八次是通信问题。我整理了一个排查清单按优先级排列现象可能原因排查方法读出来全是 0xFFMISO 没接好或芯片没供电万用表测芯片 VCC示波器看 MISO 波形读出来全是 0x00CS 没拉低或时钟没输出示波器看 CS 和 SCK 引脚数据偶尔错位时钟相位配置错误确认 CPOL/CPHA 与芯片手册一致写入后读出来不对WREN 没发或 CS 时序不对抓 WREN 和 WRITE 之间的 CS 波形高速下数据出错信号完整性差或时钟太快降低 SPI 时钟检查走线长度我遇到最坑的一次是 CS 引脚配置成了 SSI 硬件片选但代码里又用 GPIO 去控制结果两个功能打架CS 波形乱七八糟。后来把 SSI 的硬件片选功能关掉纯用 GPIO 控制问题就消失了。4.2 写入速度与等待时间的权衡MR25H40CDF 的写入速度很快但也不是零等待。单字节写入后 WIP 位清零大约需要 200ns页写 256 字节大约需要 1ms。如果连续写多条日志每条之间要等 WIP 清零累积起来也有一定开销。我的优化策略是批量写把多条日志先缓存在 MCU 的 RAM 里攒够 8 条256 字节再一次性页写。这样写次数减少到原来的八分之一等待时间也大幅缩短。RAM 里开一个 256 字节的缓冲区对 TM4C1294 来说毫无压力。实操心得批量写的时候要注意缓冲区满或者超时的强制刷新。我设了两个条件缓冲区满 256 字节或者距离上次写入超过 1 秒满足任一条件就触发写入。这样既保证了效率又不会让数据在 RAM 里停留太久。4.3 多设备共用 SPI 总线的注意事项如果系统里还有其他 SPI 设备比如显示屏或者第二颗存储芯片就要注意片选管理。每个设备有独立的 CS 引脚同一时刻只能拉低一个。我在代码里封装了一个 SPI 总线锁每次操作 MRAM 之前先获取锁操作完释放。虽然裸机环境下不存在真正的并发但这样写逻辑清晰以后移植到 RTOS 上也方便。另外不同 SPI 设备的时钟频率可能不同。MRAM 可以跑 20MHz但有些显示屏可能只支持 10MHz。切换设备的时候要重新配置 SSI 的时钟分频。我写了一个SPI_SetSpeed函数根据设备类型动态调整避免频繁修改初始化代码。5. 性能实测与优化建议5.1 读写速度实测数据我在实验室用逻辑分析仪抓了实际波形整理出以下数据操作类型数据量耗时等效速率单字节读1 字节2.4us约 3.3Mbps连续读256 字节110us约 18.6Mbps单字节写1 字节3.2us约 2.5Mbps页写256 字节1.1ms约 1.9Mbps页写不含等待256 字节105us约 19.5Mbps可以看到读操作的速率接近 SPI 时钟的理论值20MHz 下 256 字节理论耗时 102us效率很高。写操作的瓶颈在 WIP 等待实际数据传输只占 105us剩下 1ms 都在等芯片内部完成。所以批量写是提升写效率的关键。5.2 降低功耗的几种手段工业设备很多是电池供电或者对功耗有要求。MR25H40CDF 的待机电流典型值 100uA写入电流 5mA 左右。降低功耗可以从几个方面入手一是减少不必要的写入能批量就批量二是写完后让 CS 保持高电平芯片自动进入低功耗待机三是在 SPI 空闲时把 SSI 模块关掉需要时再使能。TM4C1294 本身也支持多种低功耗模式在等待采集周期的时候可以进 Sleep 模式SPI 时钟关闭MRAM 保持待机。实测整机平均电流从 45mA 降到了 12mA效果很明显。5.3 长期运行的可靠性观察这个方案我已经在三个现场部署了半年多累计写入超过 500 万次没有出现过数据丢失或芯片失效。MRAM 的擦写寿命是 10^14 次按每天写 10 万次算可以用几千年完全不用担心寿命问题。唯一需要注意的是温度。MR25H40CDF 的工业级温度范围是 -40°C 到 85°C在高温环境下写入电流会略有增加但功能正常。我在 70°C 的恒温箱里连续跑了 72 小时读写数据全部正确。如果应用场景超过 85°C就要考虑军级或者车规级的 MRAM 型号了。最后分享一个小技巧MRAM 的 CS 引脚在 PCB 布局时尽量靠近芯片走线短而直避免和其他高速信号平行走线。我第一版板子 CS 走线太长旁边又走了 USB 差分线结果偶尔出现误触发。第二版把 CS 走线缩短到 5mm 以内问题就再也没出现过。