去年我在一套工业检测设备上做数据记录模块,最初用了串行 NOR Flash,结果被现场问题折腾得够呛:设备每几十秒就要存一条运行状态,Flash 的擦写寿命只是个慢性问题,真正要命的是偶尔断电瞬间恰好撞上后台擦除,整块数据变成垃圾,客户那边没法解释。后来我把存储介质换成 Everspin 的 MR25H40CDF(4Mb SPI MRAM),主控继续用 TI 的 TM4C129LNCZAD,这个组合在工业数据存储和读取场景里出乎意料地顺。这篇文章把整套方案的选型逻辑、硬件连接、驱动时序和数据组织完整记下来,给同样在搞嵌入式存储的同行做个参考。文章涉及的内容不依赖特定厂商库,核心思路可以直接迁移到其他 MCU 和 SPI MRAM 上。
1. 为什么工业数据存储要选 MR25H40CDF:Flash 的致命短板被 MRAM 从原理上绕开了
1.1 三类非易失存储的硬指标对比
先把工业场景里最常见的三种非易失存储介质放在一张表里对比,参数取常用器件典型值,大家感受更直接:
| 指标 | NOR Flash | EEPROM | SPI MRAM(MR25H40CDF) |
|---|---|---|---|
| 写寿命 | 10万~100万次 | 100万次左右 | 理论约 1.6e12 次,实际可视为无限 |
| 写入前是否需要擦除 | 必须按扇区擦除 | 按字节擦除,但要等时间 | 不需要擦除,直接改写 |
| 单次写操作耗时 | 页编程几百微秒到毫秒级,擦除更慢 | 毫秒级 | 与 SPI 时钟同步,无额外等待 |
| 掉电安全性 | 擦除/编程窗口掉电易丢数据 | 有一定风险 | 写帧完成即生效,无擦写窗口 |
| 数据保持时间 | 10年左右 | 20年左右 | 85℃下约10年,常温更长 |
| 位密度/容量 | 大(512Mb~Gb级) | 小(Kb~Mb级) | 中等(Mb级) |
看到表格里“不需要擦除”和“无额外等待时间”这两条,很多做工业数据记录的人应该已经明白我为什么换介质了。工业设备里最常见的存储需求是“频繁小写”,比如每分钟写一条几十字节的状态记录、运行参数变更、告警事件,属于典型的随机小数据量写入。这类负载恰恰是 Flash 最不擅长的:每次改写之前要先擦除整个扇区,擦写寿命和写放大都成了绕不开的坎。MRAM 因为存储原理不同,把这两个麻烦从根上拔掉了。
1.2 磁隧道结如何实现“断电不丢、直接改写”
MRAM 的每个存储单元是一个磁隧道结(Magnetic Tunnel Junction,MTJ),结构上大致是“自由层/氧化物势垒层/参考层”三明治。数据不是存成电荷,而是存成自由层的磁化方向:自由层与参考层平行时,隧道结电阻小,代表一种逻辑状态;反平行时电阻大,代表另一种状态。写入就是给 bit 线加电流,用自旋转移矩把自由层的磁化方向翻过来,这个物理过程不需要先擦除,因为写入动作本身就是“把目标状态写进去”。
这个原理带来一个非常实用的结果:MRAM 的写操作是真正的“同步写”。主控通过 SPI 把字节移位进去,数据在时钟沿就被存进去了,没有 Flash 那种“先把数据搬进缓存,再启动内部擦写流程”的中间阶段。对嵌入式工程师来说,这意味着写一帧数据的时间是可预测的、确定的,不依赖芯片内部状态机。在掉电保护设计里,这种确定性非常宝贵。
1.3 对比 FRAM:MRAM 的工业温度表现更稳
有人会问,为什么不选 FRAM(铁电存储器)?FRAM 同样是无限次写入、无需擦除,曾经在电表等领域大量使用。但 FRAM 的问题是容量做大之后工艺难度高,常见器件集中在 Kb 到 Mb 级别,而且铁电材料在高温下疲劳和保持特性会劣化,工业宽温场景不如 MRAM 放心。MR25H40CDF 这类 SPI MRAM 的容量做到 4Mb(512KB),对大多数设备运行记录、参数备份和通信缓存来说够用了,温度范围和抗干扰能力也更适合现场环境。
当然,MRAM 不是没有缺点,单位比特成本仍然明显高于 Flash。如果你的设备只是上电写一次配置、之后基本不写,那 EEPROM 或者 NOR Flash 完全够用,没必要为用不到的写寿命买单。选型要先看负载,这是我一直强调的原则。
2. 硬件对接:TM4C129LNCZAD 的 SSI 外设、MR25H40CDF 引脚和 PCB 上的几个关键点
2.1 MR25H40CDF 的引脚功能与最小接线
MR25H40CDF 常见封装是 8 引脚 SOIC 或 DFN,引脚功能基本一致:CS(片选)、SO(串行输出)、WP#(写保护)、VSS(地)、SI(串行输入)、SCK(时钟)、HOLD#(暂停传输)、VDD(电源)。和 TM4C129LNCZAD 对接时,本质上就是一根标准 SPI 总线:MCU 的 SSI 模块产生 SCK、MOSI(接 SI)、MISO(接 SO),再用一个 GPIO 控制 CS。
接线时有两个引脚的处理容易翻车,我这里先给结论:WP# 和 HOLD# 必须做上拉,不能悬空。WP# 拉低后状态寄存器写入会被禁止,如果初始化代码里需要配置写保护寄存器,会发现写入无效;HOLD# 拉低时会让芯片暂停传输,SCK 继续跑但数据不前进,MCU 侧等不到完整响应,表现成偶发数据错乱。我后来在板上把这两个引脚都直接接到 VDD,同时各加一个 1nF 电容对地滤波,问题彻底消失。
电源方面,VDD 使用 3.3V,和 TM4C129LNCZAD 的 I/O 电平完全兼容,不需要额外电平转换。VDD 和 VSS 之间放 0.1uF 陶瓷电容,尽量靠近芯片引脚,旁边再加一颗 4.7uF 钽电容吸收低频波动。地平面保持完整,MCU 和 MRAM 不要跨分割区,这是所有 SPI 高速传输的基础。
2.2 TM4C129LNCZAD 的 SSI 模块与引脚复用
TM4C129LNCZAD 有 4 个 SSI 模块(SSI0~SSI3),都可以配置成标准 SPI 主机模式。选择哪个模块主要看引脚冲突:比如你的板子同时用了以太网、USB 或者多个 UART,SSI 引脚可能需要复用。我的做法是在 TivaWare 的 PinMux 工具里把所有外设需求列出来,让工具自动分配引脚,再手动检查一遍和板级电路是否一致。
以我用的 SSI2 为例,SCLK、FSS(片选)、XDAT0(MOSI)、XDAT1(MISO)分布在特定 GPIO 端口上,初始化代码里需要先使能对应 GPIO 端口和 SSI 外设时钟,再调用 PinTypeSSI 设置引脚复用功能。建议把片选信号单独用一个普通 GPIO 控制,不用 SSI 的 FSS 自动片选。原因很简单:自动片选会在每字节传输时拉低拉高一次,而 MRAM 的每条指令是一个完整帧,片选必须在整个指令期间保持低电平,用普通 GPIO 手动控制最灵活、最不容易出错。
2.3 信号完整性与驱动负载
SPI 时钟在 10MHz 以上时,走线长度就要注意了。MR25H40CDF 对 SPI 时钟频率的支持上限较高,实际工程中一般不会把 20MHz 以上的时钟拉到很长走线上,因为反射和串扰会让信号质量变差。我的经验是:SCK、SI、SO、CS 这四根线尽量等长、远离大电流走线,SI 和 SO 是单向信号,如果隔离开芯片和 MCU 之间加数字隔离器,注意 MISO 方向要选对,否则数据读不回来。示波器实测时,SCK 上升沿要干净,不要在边沿附近看到明显振铃。
硬件调试有个小技巧:先不写任何驱动,直接把 CS 拉低、持续发送 0x00,然后用示波器看 SCK 上是否有时钟、MISO 线上是否有响应。如果 SCK 有波形但 MISO 一直为高,多数是 HOLD# 被拉低了;如果完全没有时钟,就要回查 SSI 外设时钟和引脚复用配置。这种分步排查比直接跑读写程序高效得多。
3. 驱动代码的核心逻辑:写使能、状态寄存器和完整读写帧
3.1 SPI 模式和 MRAM 指令集
MR25H40CDF 支持 SPI Mode 0(CPOL=0, CPHA=0)和 Mode 3(CPOL=1, CPHA=1),TM4C129LNCZAD 的 SSI 模块配置成 Motorola 格式后可以对应这两种极性。我习惯用 Mode 0,也就是空闲时 SCK 为低、数据在上升沿采样,与大多数 SPI 器件的默认逻辑一致。配置 SSI 时设置主机模式、8 位数据宽度,时钟频率从低到高逐步试,新板子首次调试用 1MHz 起步最稳妥。
MR25H40CDF 的核心指令不多,工程上常用的就这六条:
| 指令 | 操作码 | 功能 |
|---|---|---|
| WREN | 0x06 | 写使能,置位状态寄存器 WEL 位 |
| WRDI | 0x04 | 写禁止,清除 WEL 位 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器(受 WP# 控制) |
| READ | 0x03 | 从指定地址读数据 |
| WRITE | 0x02 | 从指定地址写数据 |
另外还有 SLEEP(0xB9)和 WAKEUP(0xAB)用于低功耗管理,以及 0x9F 读 JEDEC ID 的指令,初始化时可以用来验证 SPI 连接是否正常。MR25H40CDF 上电后默认处于普通工作模式,所以大部分应用不需要显式调用 WAKEUP。
3.2 写使能机制:每次写前都要过的一道闸
SPI MRAM 和 SPI NOR Flash 一样,写入数据前必须先发 WREN(0x06)指令,把状态寄存器的 WEL 位置 1,否则后续 WRITE 或 WRSR 会被芯片忽略。这里要特别强调一个时序要点:WREN 本身是一个完整的单字节帧,CS 必须从拉低开始、到发送完 0x06 后拉高结束。不能把 WREN 和后面的 WRITE 地址数据放在同一个 CS 低电平周期里,否则芯片会把它们识别成一条异常指令。
WEL 位在每次写操作完成后会自动清零,所以最稳妥的做法是:每次写数据前都先执行 WREN,不要指望上一次的写使能还能生效。我见过新手为了省一个指令把 WREN 留在初始化时执行,随后所有写操作全部石沉大海。如果怀疑写使能没生效,可以用 RDSR(0x05)读出状态寄存器,检查 bit1(WEL)是否为 1。状态寄存器的 bit7 是 WPEN,配合 WP# 引脚控制状态寄存器写保护,工业环境下如果担心误写配置,可以把 WPEN 置 1 并拉低 WP#,这样状态寄存器就被锁住,需要重新上电才能解锁。
3.3 写数据帧和读数据帧的完整流程
写数据帧的工作流程是:CS 拉低 → 发送 0x02 → 发送 24 位地址(高字节在前) → 连续发送数据字节 → 全部发送完成后等待 SPI 发送完成 → CS 拉高。这个帧结构决定了写入的原子性:在 CS 拉高之前,芯片把所有接收到的字节当作一次写操作的内容;CS 拉高即提交。如果中途 CS 提前拉高,这次写帧会被丢弃,已有数据不会损坏。
读数据帧更简单:CS 拉低 → 发送 0x03 → 发送 24 位地址 → 连续读回数据字节 → CS 拉高。发完地址后,每个 SCK 时钟沿输出一个字节,地址会自动递增,所以可以一次读完任意长度的连续区域。
下面是一段用 TivaWare 风格写的简化驱动,重点展示帧结构和时序控制。实际项目中可以把片选、等待发送完成这些底层操作再封装一层:
#define MRAM_CS_LOW() GPIOPinWrite(GPIO_PORTD_BASE, GPIO_PIN_0, 0) #define MRAM_CS_HIGH() GPIOPinWrite(GPIO_PORTD_BASE, GPIO_PIN_0, GPIO_PIN_0) static void mram_wait_tx_done(void) { // 关键是等 SSI 发送移位寄存器完全空闲,只等 TX FIFO 空是不够的 while(SSIBusy(SSI2_BASE)); } static void mram_write_enable(void) { MRAM_CS_LOW(); SSIDataPut(SSI2_BASE, 0x06); mram_wait_tx_done(); MRAM_CS_HIGH(); } static uint8_t mram_read_status(void) { uint8_t status[2]; MRAM_CS_LOW(); SSIDataPut(SSI2_BASE, 0x05); SSIDataPut(SSI2_BASE, 0x00); mram_wait_tx_done(); SSIDataGet(SSI2_BASE, &status[0]); SSIDataGet(SSI2_BASE, &status[1]); MRAM_CS_HIGH(); return status[1]; } void mram_write_buf(uint32_t addr, const uint8_t *buf, uint16_t len) { uint16_t i; mram_write_enable(); MRAM_CS_LOW(); SSIDataPut(SSI2_BASE, 0x02); SSIDataPut(SSI2_BASE, (addr >> 16) & 0xFF); SSIDataPut(SSI2_BASE, (addr >> 8) & 0xFF); SSIDataPut(SSI2_BASE, addr & 0xFF); for(i = 0; i < len; i++) { SSIDataPut(SSI2_BASE, buf[i]); } // 这里必须先等发送完成再拉高 CS,否则最后几个字节会丢 mram_wait_tx_done(); MRAM_CS_HIGH(); } void mram_read_buf(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t i; uint8_t dummy; MRAM_CS_LOW(); SSIDataPut(SSI2_BASE, 0x03); SSIDataPut(SSI2_BASE, (addr >> 16) & 0xFF); SSIDataPut(SSI2_BASE, (addr >> 8) & 0xFF); SSIDataPut(SSI2_BASE, addr & 0xFF); for(i = 0; i < len; i++) { SSIDataPut(SSI2_BASE, 0x00); // 发送空字节产生时钟 SSIDataGet(SSI2_BASE, &dummy); } while(SSIBusy(SSI2_BASE)); MRAM_CS_HIGH(); // 读回的数据在 RX FIFO 中按发送顺序排列,需要逐个取出 for(i = 0; i < len; i++) { SSIDataGet(SSI2_BASE, &buf[i]); } }写这段代码时有个细节想提醒:SSI 的 RX FIFO 接收数据和 TX FIFO 发送数据不是同步的,读指令时每发一个空字节,时钟沿才会把 MISO 上的数据采进来。如果直接读 RX FIFO,可能读到的还是上一个字节,所以要先把所有字节发完、等待总线空闲,再统一从 RX FIFO 取数据,或者在每发一个字节后立即读一次 FIFO,确保接收队列不堆积。上面的简版代码在 len 较小时可用,工程上建议用 SSIRxDataGet 接口并配合 FIFO 深度做流控。
3.4 为什么 MRAM 没有“写后等待”这一说
用过 NOR Flash 的人都知道,写完一页数据要反复轮询状态寄存器里的 WIP(写忙)位,等芯片内部擦写完成。MR25H40CDF 的状态寄存器里没有 WIP 位,因为它根本不需要内部擦写流程,数据在时钟沿就同步写入存储单元了。这意味着写完一帧数据,CS 拉高的瞬间数据就已经生效,可以立刻发起读操作验证。这个特性在调试阶段非常舒服,写一个函数函数名我记不全,但“写完马上读回”这个操作再也不会被等待时间卡住。
4. 数据组织:把 4Mb 用出工业级可靠性,日志记录和掉电保护设计
4.1 512KB 的空间规划思路
MR25H40CDF 容量是 4Mb,也就是 512KB。对工业设备来说,这个容量说大不大,说小不小,关键看怎么组织。我通常把 512KB 分成几个区域:参数区 4KB(A/B 双份备份)、系统配置区 8KB、运行日志区约 480KB、尾部留 4KB 作为 ID 和设备信息区。参数区和配置区因为写入频率低、但可靠性要求高,采用双份交替写入的方式,每次先写备用区,再把主用区的切换标志更新掉,这样即使写入中途掉电,也总有一份完整参数可用。
运行日志区则用环形缓冲思路:固定每条记录长度,或者每条记录自带长度字段和 CRC 校验。写入指针顺序往后推,写到区域末尾就回到开头覆盖最老的数据。MRAM 不需要擦除、没有块边界限制,这让环形日志实现比 Flash 简单太多,不需要关心“块对齐”“擦除后写”“磨损均衡”这些问题,指针运算直接基于字节地址即可。
4.2 固定记录长度 vs 变长记录
日志记录设计上,固定长度最简单,比如每条 64 字节,前 8 字节是时间戳,中间是数据体,尾部放 CRC16。读取时可以按 64 字节对齐快速定位,不需要逐个扫描。变长记录更省空间,但每次读取都要先解析长度字段,而且掉电时如果长度字段半新半旧,判断逻辑会复杂一些。
我的实际经验是:如果日志数据是结构化的、字段数量固定的设备状态,一律用固定长度;只有在记录内容差异很大、比如同时存文本描述和二进制波形时,才用变长记录。变长记录的关键是给每条记录加一个魔数(Magic Number)头,比如 0xA5 0x5A,读取时先找魔数,再校验长度和 CRC,避免把垃圾数据误判成有效记录。
4.3 掉电保护设计:提交标志和双区切换
工业现场最怕的就是随时断电。MRAM 的同步写特性意味着:CS 拉高之前的那一帧数据,如果写到一半断电,整帧会被丢弃,不会出现半字节更新、半字节旧数据的情况。基于这个特性,掉电保护设计可以做得非常简洁。我常用的方法是“双缓冲 + 提交标志”:
写入一条新日志时,先把数据写到日志区,再把一个 4 字节的提交标志(比如当前记录序号和一个固定魔数)写到另一个地址。读取时发现提交标志存在,说明这条记录是完整的,可以正常显示;如果只有数据没有提交标志,就判定这是一条未完成的写入,直接跳过。掉电恰好发生在写提交标志之前时,数据区不完整,但旧记录不会被破坏;掉电发生在提交标志写完后,新记录已经有效。这个方案不依赖断电检测电路,纯软件实现,测试下来非常可靠。
如果数据本身特别重要,可以再加一级保险:同一份数据写两份,分别放在不同区域,读取时互相校验。MRAM 写寿命无限,双份写入的额外开销完全可以接受。
4.4 嵌入式文件系统该不该上
用不用文件系统,是嵌入式存储里一个绕不开的问题。512KB 的容量跑 FatFS 不是不行,但 FatFS 的文件分配表、目录项、日志提交这些机制会吃掉不少空间,而且为了擦写均衡和掉电安全,还要额外做一层封装,复杂度不低。我更倾向于做“轻量级裸管理”:如果数据是固定结构的记录,直接按记录格式读写,连文件系统都省了。
如果你确实需要文件名、目录这类抽象,可以考虑 LittleFS,它专门为小容量 NOR Flash 设计,支持掉电恢复和磨损均衡,对 MRAM 也能正常工作。但要注意 LittleFS 的默认区块大小和擦除逻辑假设,MRAM 没有擦除操作,配置时别照搬默认值,否则读目录会认为块是“脏的”,反而增加不必要的搬移开销。这个坑我实际踩过一次,后来直接把 block_cycles 设置成大值,关闭磨损均衡逻辑,才恢复正常。
5. 现场实测记录:20MHz 时钟下的连续读回、HOLD 悬空引发的错帧和几个排查思路
5.1 全片读写测试方法
驱动写完先别急着接业务逻辑,我习惯先跑一遍全片测试。测试程序按三段 pattern 填满 512KB:第一段全 0x55,第二段全 0xAA,第三段用 0~255 递增循环,然后再整体读回比对。递增 pattern 能暴露地址线错位,0x55/0xAA 能暴露数据线短路或 MISO 采样边沿问题。读回比对通过后,再跑一轮 CRC32 全片校验,CRC32 适合作为生产环境的上电自检手段,速度快且能发现大部分数据异常。
实测 SPI 时钟 20MHz 时,512KB 的纯读大约 0.25 秒,写入也是同样量级,因为 MRAM 不需要擦除等待。对比之前用 NOR Flash,光擦除一个大扇区就要几十毫秒,连续记录时还会有周期性卡顿。MRAM 写入全程无卡顿,这对需要严格时序的采集系统非常重要。
5.2 坑一:HOLD 悬空导致的随机数据错位
第一次做实验板时,我为了省事没接 HOLD# 的上拉,结果出现一种非常隐蔽的故障:大部分时间读写正常,但偶尔回读的数据会从某个位置开始整段偏移,像是几字节被吞掉。一开始怀疑供电纹波,后来又怀疑 SPI 时序,查了很久。最后用示波器同时抓 CS、SCK 和 SO 线,发现故障发生时 SO 线上有一段高电平时间明显变长,SCK 却还在继续跑——这说明从机暂停了输出,正是 HOLD# 被拉低的典型表现。
排查过程给我一个教训:对于带 HOLD# 的 SPI 器件,这个引脚必须强上拉到 VDD,不能指望芯片内部上拉。PCB 走线过长时,这个引脚很容易被相邻信号线耦合出低电平毛刺。后来我在 HOLD# 上加了 1nF 对地电容,形成低通滤波,此后再没出现过这种偶发错位。
5.3 坑二:CS 拉高过早,最后一个字节总是旧值
另一个典型的低级坑出在写操作末尾。我用 TivaWare 的 SSIDataPut 往 TX FIFO 塞数据时,函数返回只代表数据进了 FIFO,不代表已经移位到 MISO/MOSI 上。如果塞完数据立刻拉高 CS 做帧提交,FIFO 里最后几个字节可能还没发出去,导致写帧不完整。表现是:读回时大部分数据正确,但每条记录的尾部字节是上一次的旧值,非常迷惑人。
排查思路很简单:读回比对失败的数据集中在记录末尾,而不是开头,基本就能锁定是发送未完成就拉高了 CS。解决办法是在拉高 CS 前等待 SSI 移位寄存器空闲,也就是调用 while(SSIBusy(SSI2_BASE))。这个函数在 TivaWare 里是存在的,它判断的是移位寄存器是否仍在工作,比单纯查 TX FIFO 空标志更可靠。我把这句话写进所有写帧的末尾,问题消失。
5.4 坑三:不要照搬 Flash 的“写后轮询”习惯
因为大部分 SPI NOR Flash 驱动里都有等待 WIP 清零的循环,我最初写 MRAM 驱动时也习惯性地在写帧后加了一个轮询状态寄存器的函数。结果发现这个轮询在 MRAM 上是多余的,状态寄存器里根本没有 WIP 位,读取到的 bit0 始终是 0,不会因为写入动作变化。这个坑本身不致命,但如果轮询代码里有超时重试逻辑,就可能把“MRAM 状态寄存器没有忙位”误判成“芯片异常”,反复报错。
正确的做法是:写完一帧、等 SSI 发送完成、拉高 CS,下一秒就可以发起读操作,不需要任何额外等待。如果读回数据和预期不符,优先查硬件连接和帧结构,而不是怀疑“芯片还没忙完”。
5.5 掉电实测:断电 200 次后的数据完整性
为了验证掉电设计,我在日志写入循环的随机位置直接切断开发板电源,连续断电重启 200 次,每次上电后扫描日志区。结果符合预期:要么看到完整的带提交标志记录,要么看到未提交的残留数据被跳过,没有出现记录数据被截断成半新半旧的情况。这个结果比之前用 NOR Flash 时可靠得多,之前 Flash 在擦除窗口掉电,轻则丢一条记录,重则把整个扇区变成全 0xFF,恢复成本很高。
MRAM 能做到这一点,核心原因是写帧的原子性由 CS 高电平边界决定,不依赖芯片内部状态机的“完成时刻”。对嵌入式工程师来说,这意味着掉电保护可以完全软件化,不需要额外设计掉电检测中断、后备电源和“最后关断时序”,省心不少。
这套 MR25H40CDF 与 TM4C129LNCZAD 的组合在我这边已经跑了一年多,最大的体会就是“省心”两个字。MRAM 把擦除、磨损、掉电窗口这些问题从原理上干掉之后,上层逻辑可以做得非常直白,不需要整天担心存储介质成为现场故障点。最后再分享一个小经验:任何新板子第一次点 MRAM 驱动,先花十分钟读一下 JEDEC ID(发送 0x9F 读三字节),如果读到的 ID 和芯片手册对不上,大概率是引脚复用或者 CS/HOLD 上拉的问题,比反复跑读写测试定位要快得多。