去年我调试一台工业检测设备时,被一个看似不起眼的问题折腾了挺久:设备每次断电后,记录运行次数的Flash扇区时不时就会出坏块,连续掉电三次,运维统计直接没法看。换过W25Q128,也试过EEPROM,最后换成Everspin的4Mbit SPI MRAM(型号MR25H40CDF),配合NXP的MK24FN1M0VDC12这颗Cortex-M4F处理器,才把问题彻底根治。今天想把这一整套在工业与嵌入式场景里存储和读取数据的经验完整写下来,包括硬件接线、SPI驱动、掉电保存策略,还有我亲手踩过的几个坑,给同样在做工业存储、嵌入式数据记录方案的朋友做个参考。
1. 为什么我在工业项目里把Flash换成了MRAM
1.1 工业存储场景的三座大山:掉电、磨损、等待
工业设备的数据存储,和消费电子产品有本质区别。消费级设备写坏一个扇区最多少存一张照片,工业设备写坏一次,轻则丢失校准参数,重则整个设备停机待检。
我做的那台设备,核心功能是记录每次上料、检测、下料的周期数,还要存最近一次运行的详细状态。这些数据的特点是:单个记录不大,但写入频率很高,而且设备随时可能被操作员直接断电。
同样是存储,NOR Flash在这类场景里至少有三座大山。
第一座大山是擦写寿命。通用NOR Flash的擦写次数一般是10万次左右,听起来很多,可如果设备每秒钟写一次日志,一天就是86400次,10万次寿命不到两天就用完了。就算把日志分散到多个扇区做磨损均衡,也只是把崩溃时间往后推,并不能真正解决问题。
第二座大山是擦除与编程粒度。NOR Flash写入前必须擦除,擦除单位是4KB扇区或者64KB块,而最小写单位是1字节或者一页256字节。这就导致一个尴尬的局面:我只是想改日志文件里的两个字节,却不得不先把整个扇区读出来,在内存里改好,再擦除整个扇区,然后把整块数据写回去。这个过程专业术语叫读-改-写,中间任何一步掉电,扇区就可能处于半擦除半编程的中间状态。
第三座大山是时间等待。写入一小页数据,有些Flash要等3ms到5ms的编程时间,擦除一个扇区更是要几十毫秒。MCU在这段时间内要么死等,要么靠状态查询中断。如果在等待期间突然掉电,写入是否成功就是个未知数。
其实不只是Flash,I2C EEPROM也有类似问题。24C系列虽然支持单字节写、不需要擦除,但100万次的寿命和高频写入场景相比仍然偏少,而且I2C在400kHz速率下写大数据块的效率本身就有限。
1.2 MRAM的底层原理:为什么它能同时做到“快”和“不丢”
MRAM全称是磁阻随机存取存储器,它存储数据的核心结构叫磁隧道结,英文MTJ。MTJ由两层铁磁材料和一层极薄的绝缘隧道层构成,其中一层铁磁材料的磁化方向是固定的,另一层可以自由翻转。两层方向一致时,隧道结电阻小,代表0;方向相反时电阻大,代表1。
写入数据的时候,相当于给自由层施加一个脉冲电流,让它的磁化方向翻转到目标状态。这个翻转过程是物理层面的磁化方向改变,不是电荷累积,也不存在绝缘层被反复充电击穿的问题。所以MRAM的擦写寿命在手册上直接写的是unlimited,翻译过来就是无限制,理论上想写多少次就写多少次。
同时,磁化方向在没有外界强磁场干扰的情况下能够长期保持,掉电不丢失,这也是它被称为非易失性存储器的原因。MRAM没有擦除步骤,任何地址都是直接覆盖写,不需要先擦后写,也就不用担心写了一半掉电把扇区搞坏。
我用一个生活化的例子给你感受一下:NOR Flash像用铅笔在纸上写字,写之前必须先用橡皮擦干净格子,纸擦得多了会破;MRAM像一块磁性白板,上面本来就是可反复擦写的,你拿起白板笔写新内容,旧内容直接被覆盖,不需要先擦除,写多少次板面都不会坏。
1.3 MR25H40CDF与常见存储方案的取舍
MR25H40CDF是Everspin的4Mbit串行MRAM,换算下来正好512KB,SPI接口,最大时钟40MHz,供电2.7V到3.6V。它最大的特色就是把前面说的三大痛点一次性全解决:没有磨损问题、没有擦除步骤、写入后立即可用。
选型之前我做过一张对比表,把当时在考虑的几种方案放在一起过了一遍:
| 存储方案 | 接口 | 容量 | 写入粒度 | 最大擦写寿命 | 写入速度 | 掉电可靠性 |
|---|---|---|---|---|---|---|
| MR25H40CDF | SPI | 4Mbit | 单字节/连续 | 无限制 | 单字节约1µs@40MHz | 极高 |
| W25Q128JV | SPI | 128Mbit | 页/扇区 | 10万次 | 页编程约3ms | 掉电需处理 |
| 24LC256 | I2C | 256Kbit | 单字节/页 | 100万次 | 写2字节约5ms | 较高 |
| FM25V20A | SPI | 2Mbit | 单字节/连续 | 无限制 | 与MRAM相当 | 高 |
| SRAM+备份电池 | 并行/SPI | 视型号 | 单字节 | 无限制 | 最快 | 依赖电池 |
从表格能看出来,MRAM和FRAM(铁电存储器)在写入寿命和写入速度上有明显优势。FRAM的唯一弱点是容量普遍偏小,常见是8Kbit到4Mbit,而且在大容量档位往往比同容量的MRAM更贵。MR25H40CDF做到4Mbit,在需要存一段日志或者中等规模配置表的场景里,容量刚刚好。
接下来说说MRAM不合适的地方。首先是成本,单颗MR25H40CDF比同容量的NOR Flash贵不少,所以它适合的是对可靠性和写入频次敏感的数据区,不适合拿来存几十MB的固件镜像;其实是存储密度,4Mbit在MRAM里算中等容量,和动不动128Mbit、256Mbit的NOR Flash不在一个量级。我的做法是:固件继续跑在MK24FN1M0VDC12内部的1MB Flash里,MRAM只用来放运行日志、参数配置和掉电保护数据。
这个分工很快就在后续开发里体现出了价值:所有需要高频写入、掉电不丢的数据,一律往MRAM上甩,再也不用做磨损均衡,不用设计坏块管理,省了一大堆应用层代码。
2. MR25H40CDF与MK24FN1M0VDC12的硬件搭配
2.1 为什么选MK24FN1M0VDC12作为主控
MK24FN1M0VDC12是NXP Kinetis K24系列的一颗工业级MCU,Cortex-M4F内核,主频最大120MHz,带硬件FPU,内置1MB Flash和192KB SRAM。后缀DC12对应VFBGA封装,适合对板面积要求比较紧的工业模块。
选择这颗芯片的时候,我看中的不只是性能,更多的是它的外设组合。Kinetis K24系列自带多路DSPI模块,支持最高协议时钟,FIFO深度足够,还支持DMA触发。DSPI相关的几个寄存器可以灵活配置时钟极性、相位和分频系数,非常适合对接MR25H40CDF这类标准SPI设备。
另外,MK24FN1M0VDC12内置的电压检测模块LVD非常关键。工业设备掉电保护功能必须提前检测到电源跌落,K24支持可编程的掉电检测阈值,检测到VDD跌落到设定值以下时会触发中断,我可以在这个中断里做紧急数据保存。这个特性在后文的掉电保护流程里会详细展开。
当然,如果你手头不是K24这颗芯片,用GD32、STM32、ESP32也完全不影响MRAM读写逻辑。SPI协议是通用的,MR25H40CDF对主控的要求就是:能提供SPI Mode 0或者Mode 3时序,IO电平3.3V,CS信号可控。我选MK24FN1M0VDC12只是因为项目整体定位,加上它自身资源充足,可以顺手把CAN通信、以太网、人机交互全部跑在同一个主控上。
2.2 接线表与硬件设计细节
MR25H40CDF采用标准SPI接口,引脚不多,但有几个细节必须注意。先看接线表:
| MR25H40CDF引脚 | 功能说明 | 连接到MK24FN1M0VDC12 |
|---|---|---|
| VDD | 电源正 | 3.3V,建议0.1µF去耦电容靠近引脚 |
| VSS | 地 | GND |
| CS# | 片选,低有效 | 任意GPIO输出,建议带弱上拉 |
| SCK | SPI时钟 | DSPI模块的SCK引脚,例如SPI0_SCK |
| SI | 主出从入(MOSI) | DSPI模块的SOUT引脚 |
| SO | 主入从出(MISO) | DSPI模块的SIN引脚 |
| WP# | 写保护,低有效 | 不用时直接接3.3V |
| HOLD# | 通信暂停,低有效 | 不用时直接接3.3V |
第一版电路图上,我把WP#和HOLD#两个引脚漏接了,认为不使用的引脚悬空问题不大。结果调试时发现,MR25H40CDF偶尔写入失败,读出来的数据时对时错。后来翻手册、用示波器量时序,才确认是HOLD#悬空导致信号在临界电平上跳动,把通信暂停了。工业设计里,所有控制引脚必须有一个确定电平,不能放任自流。
这个教训在硬件评审清单里应该单列一条:只要是MRAM/FRAM这类带使能控制引脚的存储器,WP#、HOLD#必须上拉到高电平。有些设计为了省一颗电阻把WP#和HOLD#并在一起上拉,也可以,但千万别悬空。
PCB布局上还有一个容易被忽略的点:SCK和SI(MOSI)的走线应该尽量短且保持大体等长,尤其是在SPI时钟跑到20MHz以上的时候。MR25H40CDF支持40MHz时钟,但40MHz下信号边沿本身就比较陡,走线过长或者经过过孔太多,反射会把波形搞难看。我的经验是:优先保证SCK、SI、SO、CS#四条线的长度差小于2cm,如果实在做不到,就把SPI时钟降到20MHz,换取稳定的通信。
电源去耦也要稍微讲究一点。MR25H40CDF内部在翻转磁场时需要消耗瞬时电流,电源引脚旁边放一颗0.1µF的MLCC还不够的话,可以再加一颗1µF或10µF的钽电容。尤其是设备同时驱动继电器、电机或者传感器时,电源纹波大,MRAM读写出错率会明显上升,我在测试中遇到过VDD跌落到2.9V以下时状态寄存器读取不稳定。
2.3 供电电压和温度范围
MR25H40CDF的工作电压范围是2.7V到3.6V,MK24FN1M0VDC12的IO口电平是3.3V,两边直接对接没有电平转换问题。不过要注意:Kinetis在VDD低于3.0V时,内部Flash编程可能受限,但MRAM这边只要供电高于2.7V就正常工作,所以3.3V单电源供电即可。
温度方面,我选择的是工业级温度范围的型号,可以在-40℃到+105℃环境里长期工作。设备如果做户外环境试点,冬天低温、夏天暴晒,存储芯片的温度指标都必须逐一核对,这一点MR25H40CDF的后缀含义最好以当时购买批次的数据手册为准,不同后缀可能对应不同工作温度。
3. 从零写驱动:SPI指令集与关键代码
3.1 MR25H40CDF的指令集:记住“没有擦除”这件事
MR25H40CDF本质上是一颗4Mbit的SPI MRAM,指令集和常见的SPI NOR Flash有点像,但又少了很多东西。
用的最多的是这六条指令:
| 指令名称 | 操作码 | 功能 |
|---|---|---|
| READ | 0x03 | 从指定地址连续读数据 |
| FAST_READ | 0x0B | 快速读,额外加一个dummy字节 |
| WRITE | 0x02 | 从指定地址连续写数据 |
| WREN | 0x06 | 设置写使能锁存位WEL |
| WRDI | 0x04 | 清除写使能锁存位 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器(配置写保护) |
和NOR Flash最大的不同,就是没有擦除指令,也不需要页编程指令。WRITE指令支持单字节写,也支持连续写,如果你愿意,可以从0x00000连续写到0x7FFFF,不需要按扇区对齐,没有分页限制,地址可以随时停下,完全自由。
这里有一个特别容易翻车的细节:MR25H40CDF虽然是4Mbit存储空间,地址线实际只需要19位,但它的指令格式里地址字段固定是24位,也就是3字节。所以不管是发READ还是WRITE指令,片选拉低之后,必须依次发送:操作码、地址高字节、地址中字节、地址低字节。比如地址0x00000,就必须发0x00 0x00 0x00三个字节,哪怕高字节全是0也不能省略。
我第一次移植的时候,习惯性地按照2字节地址去发,结果写进去的数据全都错位,因为MRAM把第三个字节当成了数据而不是地址。这个坑几乎每个第一次做SPI MRAM的人都会踩,后面会用代码再强调一遍。
3.2 SPI初始化:Mode 0,先从20MHz起步
MK24FN1M0VDC12的SPI初始化,我使用的是NXP MCUXpresso SDK风格,方便照抄。核心配置就是选择DSPI模块、设置主模式、时钟极性和相位、波特率。
#include "fsl_spi.h" #define MRAM_SPI_BASEADDR SPI0 #define MRAM_SPI_CLK_FREQ 120000000U spi_master_config_t mram_spi_config; void MRAM_SPI_Init(void) { SPI_MasterGetDefaultConfig(&mram_spi_config); mram_spi_config.baudRate_Bps = 20000000U; // 先跑20M,稳定后再提40M mram_spi_config.clockPolarity = kSPI_ClockPolarityActiveHigh; // CPOL=0 mram_spi_config.clockPhase = kSPI_ClockPhaseFirstEdge; // CPHA=0 SPI_MasterInit(MRAM_SPI_BASEADDR, &mram_spi_config, MRAM_SPI_CLK_FREQ); }MR25H40CDF支持SPI Mode 0和Mode 3,默认按Mode 0使用即可。片选CS#不要挂在DSPI硬件PCS上,建议用普通GPIO控制。一是因为后续要做的WREN和WRITE之间片选要拉高再拉低,用GPIO控制更直接;二是如果用硬件PCS,切换不同SPI设备时容易出问题。
片选和底层收发函数如下:
#define MRAM_CS_GPIO GPIOB #define MRAM_CS_PIN 6 static inline void MRAM_CS_Low(void) { GPIO_PinClear(MRAM_CS_GPIO, MRAM_CS_PIN); } static inline void MRAM_CS_High(void) { GPIO_PinSet(MRAM_CS_GPIO, MRAM_CS_PIN); } static void MRAM_SpiTransfer(const uint8_t *tx, uint8_t *rx, uint32_t len) { /* SDK的SPI_MasterTransfer会同时完成发送和接收 */ spi_transfer_t transfer = {0}; transfer.txData = (uint8_t *)tx; transfer.rxData = rx; transfer.dataSize = len; SPI_MasterTransferBlocking(MRAM_SPI_BASEADDR, &transfer); }3.3 核心读写函数:三个地址字节一个都不能少
下面是一套最基础、最容易移植的读写函数。读单字节、写单字节、连续读、连续写,都是标准流程。
void MRAM_WriteEnable(void) { uint8_t cmd = 0x06; MRAM_CS_Low(); MRAM_SpiTransfer(&cmd, NULL, 1); MRAM_CS_High(); } void MRAM_WriteDisable(void) { uint8_t cmd = 0x04; MRAM_CS_Low(); MRAM_SpiTransfer(&cmd, NULL, 1); MRAM_CS_High(); } uint8_t MRAM_ReadByte(uint32_t addr) { uint8_t txBuf[4] = {0x03, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr}; uint8_t rxBuf[4] = {0}; MRAM_CS_Low(); MRAM_SpiTransfer(txBuf, rxBuf, 4); MRAM_CS_High(); return rxBuf[3]; } void MRAM_WriteByte(uint32_t addr, uint8_t data) { uint8_t txBuf[5] = {0x02, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr, data}; MRAM_WriteEnable(); MRAM_CS_Low(); MRAM_SpiTransfer(txBuf, NULL, 5); MRAM_CS_High(); MRAM_WriteDisable(); } void MRAM_ReadBuffer(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[3] = {0x03, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr}; MRAM_CS_Low(); MRAM_SpiTransfer(cmd, NULL, 4); MRAM_SpiTransfer(NULL, buf, len); MRAM_CS_High(); } void MRAM_WriteBuffer(uint32_t addr, const uint8_t *buf, uint32_t len) { uint8_t cmd[4] = {0x02, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr}; MRAM_WriteEnable(); MRAM_CS_Low(); MRAM_SpiTransfer(cmd, NULL, 4); MRAM_SpiTransfer((uint8_t *)buf, NULL, len); MRAM_CS_High(); MRAM_WriteDisable(); }这套函数有三个值得说的地方。
第一,MRAM_WriteByte里先调用了MRAM_WriteEnable,也就是发0x06指令。WREN指令的作用是把状态寄存器里的WEL位置1,表示允许写入。如果跳过这一步,WRITE指令会被忽略。写完数据之后,我又调了一次MRAM_WriteDisable,把WEL清除,防止误操作。
第二,MRAM_ReadBuffer里我调用了两次MRAM_SpiTransfer,第一次发命令和地址,第二次连续读数据。因为片选在整个过程中保持低电平,中间切换一次DSPI收发并不影响操作。
第三,WRITE指令没有“页边界”概念,所以MRAM_WriteBuffer不需要像Flash那样限制在页内末尾截断,你可以一次写任意长度,哪怕横跨整个512KB空间。这一点在实现日志系统时非常舒服。
如果你要从MRAM里连续读取比较大数据块,还可以用FAST_READ指令0x0B。它的好处是地址后面多了一个dummy字节,这个dummy可以弥补地址发出后主控切换收发模式的延迟,让高速时钟下读取更稳定。代码上就是把命令字节换成0x0B,然后多传一个随便什么字节。
void MRAM_FastRead(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[5] = {0x0B, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr, 0x00}; MRAM_CS_Low(); MRAM_SpiTransfer(cmd, NULL, 5); MRAM_SpiTransfer(NULL, buf, len); MRAM_CS_High(); }3.4 状态寄存器与写保护配置
MR25H40CDF的状态寄存器结构不复杂,但理解它是避免“莫名其妙写不进数据”的关键。
状态寄存器各位的定义大约是:bit0是WEL(写使能锁存),bit1到bit3是BP0到BP2(块保护位),bit4是WPEN(写保护引脚使能),剩余位保留。出厂默认状态寄存器值一般是0x00,也就是写保护全部关闭,全片可写。
读取状态寄存器用0x05指令:
uint8_t MRAM_ReadStatus(void) { uint8_t txBuf[2] = {0x05, 0x00}; uint8_t rxBuf[2] = {0}; MRAM_CS_Low(); MRAM_SpiTransfer(txBuf, rxBuf, 2); MRAM_CS_High(); return rxBuf[1]; }如果你希望进一步防止误写,可以用WRSR指令把块保护打开。比如保护存储器最前面的1/4空间:
void MRAM_SetBlockProtect(uint8_t bpBits) { uint8_t txBuf[2] = {0x01, bpBits}; // bpBits对应状态寄存器bit1~bit3 MRAM_WriteEnable(); MRAM_CS_Low(); MRAM_SpiTransfer(txBuf, NULL, 2); MRAM_CS_High(); MRAM_WriteDisable(); }调试阶段建议先保持全片可写。产品阶段,如果配置区在MRAM低地址,启动后先把配置写进去,再打开只保护配置区的块保护位,这样运行日志区即使被误操作,系统参数也不会被动掉。
4. 掉电时序设计与应用层数据布局
4.1 掉电紧急保存流程:为什么MRAM是“最后一根稻草”
工业设备掉电是最常见的异常事件,操作员可能在任何时刻按下急停或者切断电源。传统的处理流程是MCU检测到掉电,然后抢时间把关键状态写入非易失存储器。
问题在于,如果这个非易失存储器是Flash,掉电中断里写入的那一刻往往是灾难——因为Flash要先擦除再编程,整个流程要好几毫秒甚至几十毫秒,电源电压从3.3V跌落到MCU最低工作电压1.71V的时间窗口经常不够用,结果就是写入失败或者扇区损坏。
MR25H40CDF天然适配这种场景。掉电检测到之后,只需要把关键数据通过WRITE指令写进MRAM,一次连续写64字节,在20MHz时钟下大约只需要30µs左右,40MHz时钟下更快。这个时间量级,即使电源已经掉到2.5V,只要MCU还在运行,就足够完成写入。
我实际用的掉电保护流程是:
- MK24FN1M0VDC12的LVD模块配置一个掉电阈值,比如3.0V,当VDD下降到阈值以下时触发掉电中断,中断优先级设为最高。
- 掉电中断里,先把当前运行状态、电机位置、累计计数、错误码等数据组织成一个结构体,计算CRC32,然后写入MRAM的专用掉电保护区。
- 写入完成后,设置一个掉电标志位,表示这次关机是“干净关机”。
- 上电启动时,先读掉电保护区。如果掉电标志有效,就恢复上次运行状态;如果无效,说明上次是异常断电或者数据损坏,走默认启动流程。
因为MRAM写入不需要等待编程完成时间,整个掉电保存流程可以从容完成,这是其他方案很难替代的价值。
4.2 存储区规划:双备份配置区加环形日志区
存储区规划直接决定应用层代码复杂度和数据可靠性。我习惯把512KB的MRAM空间划分为三块。
第一块是配置参数区,从地址0x00000开始,占4KB。这里存放设备编号、校准系数、通信地址、量程上下限等参数。因为参数可能在调试过程中反复修改,所以我在同一区域里做了两个备份:主拷贝一份,镜像拷贝一份。每次写入先写主拷贝,再写镜像拷贝,读取时两个拷贝都读出来做CRC校验,选校验通过的那份。这样即使写镜像拷贝过程中掉电,主拷贝还是完好的。
第二块是运行日志区,从0x01000开始,占128KB。日志记录采用环形覆盖方式,每条日志固定64字节,依次写入。MRAM无限擦写寿命让日志管理简化到了极致:不需要磨损均衡,不需要日志合并/压缩/翻页,只需要一个递增的日志序号和一个“区块擦除”的用户逻辑索引。
第三块是掉电保护区,放在存储空间末尾,占64KB。上面说的掉电紧急状态就写在这里,固定地址,每次覆盖。
这样做数据布局有四个原则可以总结:
- 配置区必须有双备份加CRC校验,因为配置损坏可能让整个设备无法正常工作;
- 日志区不需要磨损均衡,因为MRAM寿命无限,但要设计环形覆盖,防止512KB空间写满后无法继续;
- 掉电保护区不要和日志区混在一起,因为它写入频率最低,但重要性最高;
- 所有区块首地址都尽量按4KB对齐,方便以后如果要换成其它存储芯片时,逻辑改动最小。
4.3 数据校验:CRC32比简单累加可靠得多
工业数据校验,我不建议用简单的8位校验和。配置参数一个字节被改写,简单校验和有可能算出来仍然相同,尤其某些位翻转同时影响两个字节时更不可靠。我在关键数据结构上统一用CRC32。
CRC32的实现网上有很多表驱动版本,这里不整段贴代码了,只讲讲流程:
typedef struct { uint32_t magic; // 固定魔数,防止读到未初始化的区域 uint32_t version; // 数据结构版本号 uint32_t crc32; // CRC32校验值 uint64_t runCount; // 累计运行次数 float lastAlgTemp; uint16_t lastErrCode; uint8_t reserved[40]; } SystemStatusBlock; #define SYSTEM_STATUS_MAGIC 0xA55A5AA5 void SystemStatusSave(SystemStatusBlock *block) { block->magic = SYSTEM_STATUS_MAGIC; block->crc32 = CRC32_Calc((uint8_t *)block + sizeof(block->crc32), sizeof(SystemStatusBlock) - sizeof(block->crc32)); MRAM_WriteBuffer(MRAM_STATUS_SAVE_ADDR, (uint8_t *)block, sizeof(SystemStatusBlock)); } int SystemStatusLoad(SystemStatusBlock *block) { MRAM_ReadBuffer(MRAM_STATUS_SAVE_ADDR, (uint8_t *)block, sizeof(SystemStatusBlock)); if (block->magic != SYSTEM_STATUS_MAGIC) { return -1; } uint32_t calc = CRC32_Calc((uint8_t *)block + sizeof(block->crc32), sizeof(SystemStatusBlock) - sizeof(block->crc32)); if (calc != block->crc32) { return -1; } return 0; }CRC32的初始种子、多项式系数,关键是要在整个工程里固定下来,最好直接复用成熟的开源实现。实际运行中,CRC32校验能抓出绝大多数位翻转和地址错位问题。
5. 实测数据与踩坑记录
5.1 连续写入和长时间运行实测
硬件和驱动写好之后,我跑了三轮测试:第一轮是压力写入,第二轮是掉电模拟,第三轮是温度箱测试。
压力写入测试用MK24FN1M0VDC12做循环写入,每轮写512字节数据到指定地址,写完立刻读回比对。40MHz SPI时钟下,一次512字节的写入耗时大约110µs,读回512字节耗时大约105µs。相比之下,用W25Q128写一页256字节需要3到5ms,差距是几十倍。
我又做了单字节随机写测试,随机生成100万个地址,逐个写入随机值再读回,全部正确。写入速度方面,单字节的指令开销占比大,但也在几微秒级别,完全可以接受。
掉电模拟测试相对复杂一点。我用一个继电器在设备正常运行时随机切断电源,每次断电后重新上电,启动后检查MRAM里的状态块和日志。连续做了200次掉电测试,没有出现一次日志数据损坏。这个结果和Flash方案形成的对比非常明显:同样200次掉电,Flash方案大概率已经出现了扇区损坏。
温度箱测试是从-20℃做到85℃,每个温度点保持2小时,期间持续读写MRAM,全部通过。我选的是工业级温度范围的版本,如果你的设备使用环境有更高温或更低温需求,一定要核对手册的温度规格。
5.2 我踩过的三个坑,换个主控也跑不掉
先说第一个坑,也是最常见的:地址字节数错误。MR25H40CDF即使容量只有4Mbit,也必须发满3字节地址。我第一次移植时参考的是2字节地址的SPI存储器代码,结果写数据错位,读数据也错位,排查了半天才发现地址格式不同。这个坑在PDF手册里其实写得清楚,但人很容易惯性思维,看到“4Mbit”就觉得2字节地址够用。
第二个坑:WREN和WRITE之间的片选时序。写入之前必须先拉低CS发WREN,然后拉高CS让WREN生效,再拉低CS发WRITE命令和数据。如果WREN发完之后CS一直保持低电平,状态寄存器WEL位不会置位,后续WRITE会被拒绝。如果你发现状态寄存器能读、写操作却无效,优先检查这段时序。
第三个坑:HOLD#引脚悬空。这个前面接线部分已经提到了,HOLD#和WP#必须老老实实接上拉电阻。HOLD#拉低会冻结数据传输,WP#拉低会禁止写保护区域写入。悬空状态下的浮空电平会导致完全随机、时好时坏的故障,非常难排查。
除此之外还有一个软件层面的坑:MRAM写入不需要等待,所以很多人容易忽略写保护位的状态。出厂默认全片可写没问题,但一旦你在初始化代码里调用了WRSR开启块保护,之后所有写操作就必须先检查状态寄存器的BP位。我在一次升级固件时把配置代码重新写了一遍,忘了恢复块保护位,结果现场设备参数写了没反应,运维反馈“参数改了,重启后变成默认值”。所以说,块保护功能是双刃剑,用之前一定要把整个初始化流程理清楚。
5.3 一些可以让你少走弯路的建议
最后分享几个实操建议。
第一,SPI时钟不要一上来就拉满40MHz。先用20MHz验证功能和读写数据一致性,把波形稳定了再提到40MHz。40MHz下很多问题来自PCB布局和信号完整性,20MHz可能能跑,40MHz就开始偶发错误。我最后产品上跑的是30MHz,性价比比较高,留了足够裕量。
第二,CS引脚务必用GPIO而不是硬件PCS。MRAM的操作序列对片选时序要求灵活,WREN结束要拉高再拉低,用GPIO执行起来最直观、最可控。某些MCU的硬件PCS会自动附加延时,在极端情况下会影响WREN置位。
第三,把MRAM读写函数封装成独立驱动文件,不要散落在业务代码里。后来我把这套驱动从一个项目移植到另一个项目,只改了SPI基址、CS引脚和时钟配置,其余代码原封不动。
第四,如果你的应用需要记录大量历史数据,我建议把日志数据设计成“尾部写指针+固定结构体”的模式,读取时按指针位置反向扫描。MRAM寿命无限,所以不用考虑搬移旧日志,应用层逻辑简单很多,也更容易维护。
第五,硬件评审时一定要检查MR25H40CDF的WP#、HOLD#上拉电阻是否存在,电源去耦电容是否近距离放置。这两个问题最容易在原理图评审时被漏掉,但它俩一出问题就是很难查的“软故障”。
这套MR25H40CDF加MK24FN1M0VDC12的组合,我在设备上用了将近一年,后续生产的批次没有一例因存储问题退货。如果你也在做工业数据记录或者高频参数存储方案,可以考虑往MRAM这个方向试一下,至少在“存储和读取数据”这件事上,你会少操心很多。