这几年我做的几个工控小项目里,掉电保存这件事一直躲不开。数控机床的刀具补偿参数、伺服驱动器的 PID 整定值、生产线传感器的标定系数,这些数据既要随时改,又不能在掉电时丢。最开始大家图省事直接用 SPI Flash,后来发现某些参数一天要刷几十次,Flash 的擦写寿命根本扛不住;换 EEPROM 又觉得容量小、速度慢。后来我在一块以 STM32F030R8 为主控的板子上换用了 MR25H40CDF,这是一个 4Mbit 的 SPI 接口 MRAM,也就是磁阻随机存储器,把整件事彻底变了样。这篇就把我在这个组合上做的读写驱动、硬件细节和踩坑过程整理出来,给正在做类似嵌入式项目的人一个参考。
1. 为什么我把这枚磁阻存储芯片放进工业设备
MR25H40CDF 这类 MRAM 芯片,很多人第一次听说时都会把它理解成"高级一点的 EEPROM",这个理解不能说全错,但会直接影响你后续使用它的思路。EEPROM 和 SPI Flash 是靠电荷存储数据的,写数据之前要把半导体结构里的电荷先搬走、再搬进来,所以写入慢、擦写次数有限。MRAM 不一样,它的每个存储单元是一个磁性隧道结,数据靠磁化方向来记录,就像一块磁铁翻了个面,翻多少次都不会"翻坏",写的时候也不需要先把旧数据清理掉,直接覆盖就行。
我当时在设备里给它安排的活儿是这样的:一份 128 字节的工艺参数,每天至少被修改保存 200 次,一年就是 70000 多次。如果用普通 SPI NOR Flash,典型擦写寿命也只有 10 万次左右,一年半就逼近寿命极限了;换 EEPROM 虽然单字节能写 100 万次,但容量普遍很小,而且写一个字节要等好几毫秒。MR25H40CDF 给的答案是:容量 512KB,SPI 接口,擦写寿命基本可以按无限次计算,写入延时接近 SRAM,写进去就是掉电不丢。
选型的时候我还专门和 FRAM 比较过。FRAM 也就是铁电存储器,同为非易失随机存储,写入寿命同样很高。但 FRAM 容量通常做到几百 Kbit 就到顶了,MRAM 密度能做得更大一些,而且 FRAM 对温度比较敏感,高温性能衰减明显,MRAM 的磁性材料在工业温度区间表现更稳定。考虑到我们的设备要装在产线电柜里,夏天柜内温度经常逼近 60 度,又需要存几千条报警记录,MRAM 的综合指标更适合。
下表是我当时做评审用的对比,做选型的人可以直接抄:
| 参数 | MR25H40CDF (MRAM) | 常见 SPI NOR Flash | 常见 SPI EEPROM |
|---|---|---|---|
| 容量 | 512KB | 512KB 起步 | 通常 2KB~64KB |
| 擦写寿命 | 极高,工程上视为无限次 | 约 10 万次 | 约 100 万次 |
| 写单元 | 按字节直接写 | 必须先擦扇区再写 | 按字节写 |
| 写入速度 | SPI 时钟速度 | 受擦除速度拖累 | 单字节数毫秒 |
| 掉电保存 | 常温下保存超过 10 年 | 数据保持约 20 年 | 数据保持约 40 年 |
| 抗冲击/振动 | 强,磁场记录不受机械振动影响 | 一般 | 一般 |
如果你只是存个出厂序列号、几组标定值,一年写不了几百次,用 SPI Flash 便宜大碗完全没问题。但只要是频繁掉电保存、频繁改写、数据还要求长年不丢的场景,MRAM 的优势就会非常明显。
2. 硬件接线与电源细节,先讲清楚再写代码
2.1 MR25H40CDF 的引脚定义与接线表
MR25H40CDF 是 8 脚封装,引脚和普通 SPI Flash 完全兼容,所以很多已有的 Flash 板子可以直接换上它。我的板子上用 STM32F030R8 的 SPI1 接口和它通信,片选脚没有用硬件 NSS,而是挑了 PC0 做软件 GPIO 控制,这样片选的压低抬高的时序完全由代码掌控,不容易被外设的自动行为干扰。
| MR25H40CDF 引脚 | 引脚号 (SOIC-8) | 接到了哪里 | 说明 |
|---|---|---|---|
| /CS | 1 | STM32F030R8 PC0 | SPI 片选,低电平有效 |
| HOLD | 2 | 3.3V | 拉高,禁用保持功能,禁止悬空 |
| /WP | 3 | 3.3V 或 GPIO | 拉高允许改写;若要硬件写保护可接 GPIO 控制 |
| GND | 4 | 地 | 电源地 |
| SI | 5 | STM32F030R8 PA7 | SPI1_MOSI |
| SCLK | 6 | STM32F030R8 PA5 | SPI1_SCK |
| SO | 7 | STM32F030R8 PA6 | SPI1_MISO |
| VCC | 8 | 3.3V | 供电,VCC 附近放 100nF 去耦电容 |
这里有几个硬性注意点。第一,MR25H40CDF 的供电范围是 2.7V~3.6V,千万别直接从 5V 系统里拉电,我见过同事的板子以为是 Flash 就顺手给了 5V,上电冒烟。第二,HOLD 引脚一定不要悬空,它是保持功能脚,低电平时芯片会暂停接收时钟信号,悬空时一旦被外部噪声拉到低电平,SPI 通信就会莫名卡住,表现出来就是读回来的内容全是 0xFF。第三,/WP 引脚如果不做硬件写保护,就直接拉高,别让它悬空。
2.2 STM32F030R8 的 SPI 选型与时钟配置
STM32F030R8 是 Cortex-M0 内核,主频最高 48MHz,片上 Flash 64KB,RAM 16KB。挑这颗料不是因为它性能强,而是它便宜、供货稳、工业级温度范围,且自带 2 路 SPI。我使用 SPI1,端口映射为 SCK=PA5、MISO=PA6、MOSI=PA7,三个引脚都复用为 SPI 功能,片选 PC0 作为普通推挽输出。
SPI 时钟极性极性的设置上,MR25H40CDF 支持 SPI Mode 0(CPOL=0,CPHA=0)和 SPI Mode 3(CPOL=1,CPHA=1),我用的是 Mode 0,这也是绝大多数 SPI 存储芯片默认的模式。时钟分频这里有一个实际取舍:F030 的 SPI1 挂载时钟最高约 48MHz,分频系数可以做到 2、4、8 等。理论上 MR25H40CDF 在 3.3V 供电时能跑几十 MHz 的时钟,但实际工业板子上 PCB 走线长、还会经过排针转接,我把 SPI 时钟分频设成 4,实际工作频率 12MHz。这个速度对存配置参数、存日志已经完全够用,而且留出了充足的信号裕量,哪怕板子受到一些电磁干扰也不会轻易丢数据。
2.3 板上电源处理的一点建议
MRAM 芯片不像 Flash 那样需要高电压去擦除,所以它本身的功耗其实不高,但工业设备里电源波动大,电机启停、继电器吸合都会在 3.3V 上砸出毛刺。我给 MR25H40CDF 的 VCC 脚旁边就近放了 100nF 陶瓷电容,又在板子的电源入口放了一颗 10uF 电容做储能,实测在继电器频繁动作的场合,没有再出现过程序跑飞导致数据错乱的问题。如果还要给"掉电瞬间仍要写完最后一批数据"这样的需求做保障,可以在 3.3V 和地之间加一个大一点的电容器,但那是另一个数据管理的话题,后面第五节再讲。
3. 指令集与读写控制逻辑,先把底层机理吃透
3.1 4Mbit 的地址空间与命令表
MR25H40CDF 容量是 4Mbit,换算过来是 512KB,访问一个字节需要 3 字节地址。地址范围是 0x00000 到 0x7FFFF,高地址字节只有低 3 位有意义。它支持的指令不多,我实际用的也就这几条:
| 指令名称 | 命令码 | 功能说明 |
|---|---|---|
| WREN | 0x06 | 写使能 |
| WRDI | 0x04 | 写禁止 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 从指定地址连续读 |
| WRITE | 0x02 | 从指定地址连续写,按字节直接写入 |
和 Flash 最大的区别是,这里完全没有"扇区擦除""页编程"这些概念。Flash 的规律是写之前必须先擦成 0xFF,所以哪怕你只改一个字节,通常也得先执行一个扇区擦除命令,那个时间是以几十上百毫秒计的。MRAM 直接就是那张表,发出 WRITE 命令,跟着 3 字节地址再加数据,时钟输进去多少,芯片就给你存进去多少。写完之后也不需要等内部编程完成,因为压根没有这个内部过程,这给时序控制和代码逻辑省了太多事。
正因如此,很多"擦写负载均衡""磨损均衡"算法在 MRAM 上根本不需要做。如果你在代码里引入了一大套专门给 Flash 设计的磨损均衡逻辑,反而徒增复杂度和出错概率,这是刚用 MRAM 的人最容易走的一条弯路。
3.2 写使能机制和状态寄存器
MR25H40CDF 上电后默认是不允许写的,必须先发一条 WREN 命令把状态寄存器里的 WEL 位置 1,然后才能执行 WRITE 或者 WRSR。每完成一次写操作,WEL 位会自动清零,所以下一次写之前又要重新发 WREN。这个机制其实是从 SPI Flash 那儿继承来的,目的是防止总线上的毛刺误触发写入。
WREN 命令的时序有一个容易被忽略的细节:发完 0x06 之后,必须把片选拉高,让芯片在 /CS 上升沿锁存写使能状态。如果发完 WREN 之后一直没拉高,或者拉高前又多发了几个字节,写使能不一定生效,后面跟的 WRITE 指令就会被芯片丢弃。我见过有人调试时怪芯片不写,打印发现 WEL 位一直是 0,就是这个问题。
状态寄存器主要用到两个位。Bit 6 是 WEL,写使能锁存状态,这个位只读。Bit 7 是 WP,代表硬件写保护状态,当 /WP 引脚为低电平时这个位为 1,芯片进入全片写保护。如果 /WP 引脚拉高,则这个位为 0,允许写入。这里我强烈建议,如果你的应用就是想让单片机随时能写,请把 /WP 直接接高电平,不要把它接到 GPIO 上去做"动态保护",因为一旦程序跑飞前不小心把 GPIO 拉低,恰好又有一个写操作在排队,数据会被静默保护,写失败,而下一次读的时候你才会发现参数还是旧值,排查起来很费劲。
3.3 读操作和 "超越边界" 的约定
读数据用 READ 命令,发送 0x03 加 3 字节地址后,芯片会从指定地址开始连续输出数据,只要时钟继续给,它就一直往外吐。需要注意的是,这个连续输出是有边界的,如果地址冲过了 0x7FFFF,不同的 MRAM 批次或者说芯片内部设计,可能出现地址回卷或者输出未知数据,数据手册一般不会给你保证这个行为。所以驱动代码里必须在软件层做地址范围检查,宁可报错返回,也不能让上层调用带着一个越界地址去读,否则你会在某次特殊操作中读回一堆莫名其妙的字节。
4. 基于 STM32F030R8 的驱动代码实现
4.1 在 CubeMX 里的基本配置
我在 STM32CubeMX 里对 SPI1 做如下设置:SPI mode 选 Full-Duplex Master,SCK 引脚 PA5,MISO 引脚 PA6,MOSI 引脚 PA7。Clock Polarity 选 Low,Clock Phase 选 1 Edge,也就是 Mode 0。Baud Rate Prescaler 我选 4,这样 SPI 时钟 12MHz。片选 PC0 设置为 GPIO Output,默认电平拉高,这样上电后不会误选中芯片。
这里要特意说一下片选引脚。F030 的硬件 NSS 引脚可以配置成 SPI 模式,但我不推荐。因为你可能后面还要在同一总线上挂别的 SPI 器件,硬件 NSS 的自动片选管理在这种多设备场合反而碍事。用普通 GPIO 做软片选,代码写清楚高低电平,逻辑一目了然,排查问题也方便。
4.2 最小驱动代码
下面这份代码我故意把 HAL 库的封装也用了,因为大多数用 STM32 的人已经在了 HAL 工程里,直接抄着用最省事。我把它设计成简单的层次:最底层是 SPI 字节收发,中层封装 READ、WRITE、WREN 和 RDSR 命令,上层才去暴露"读一段数据""写一段数据"给业务调用。
/* unreal 顶层注释 */ #define MRAM_CMD_WREN 0x06 #define MRAM_CMD_WRDI 0x04 #define MRAM_CMD_RDSR 0x05 #define MRAM_CMD_WRSR 0x01 #define MRAM_CMD_READ 0x03 #define MRAM_CMD_WRITE 0x02 #define MRAM_SIZE 0x80000u /* 512KB */ #define MRAM_CS_LOW() HAL_GPIO_WritePin(MRAM_CS_GPIO_Port, MRAM_CS_Pin, GPIO_PIN_RESET) #define MRAM_CS_HIGH() HAL_GPIO_WritePin(MRAM_CS_GPIO_Port, MRAM_CS_Pin, GPIO_PIN_SET) static uint8_t mram_transfer_byte(uint8_t tx) { uint8_t rx = 0; HAL_SPI_TransmitReceive(&hspi1, &tx, &rx, 1, 10); return rx; } static void mram_set_write_enable(void) { uint8_t cmd = MRAM_CMD_WREN; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); MRAM_CS_HIGH(); /* 必须抬高 CS,WEL 才会被锁存 */ } static void mram_send_address(uint32_t addr) { uint8_t buf[3]; buf[0] = (uint8_t)((addr >> 16) & 0xFF); buf[1] = (uint8_t)((addr >> 8) & 0xFF); buf[2] = (uint8_t)(addr & 0xFF); HAL_SPI_Transmit(&hspi1, buf, 3, 10); } int mram_read(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd = MRAM_CMD_READ; if (addr >= MRAM_SIZE || len > MRAM_SIZE - addr) { return -1; } MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); mram_send_address(addr); while (len--) { *buf++ = mram_transfer_byte(0xFF); } MRAM_CS_HIGH(); return 0; } int mram_write(uint32_t addr, const uint8_t *buf, uint32_t len) { uint8_t cmd = MRAM_CMD_WRITE; if (addr >= MRAM_SIZE || len > MRAM_SIZE - addr) { return -1; } mram_set_write_enable(); MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); mram_send_address(addr); while (len--) { mram_transfer_byte(*buf++); } MRAM_CS_HIGH(); return 0; }我每次写比较大的数据块之前,习惯先写一个简单的校验接口,比如直接读回开头的几个字节和末尾几个字节,跟写入的内容比对一遍。MRAM 写入出错概率很低,但工业环境电磁干扰无奇不有,回读校验是个成本低、安全感高的操作。
4.3 读 ID:一条自查链路的命令
很多 SPI 芯片都有读 ID 命令,MR25H40CDF 可以用 0x9F 来读取厂商信息。我在驱动里加了这么个函数,主要不是靠它区分型号,而是靠它快速判断硬件链路是不是通的。
int mram_check_link(void) { uint8_t cmd = 0x9F; uint8_t id[3] = {0, 0, 0}; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); id[0] = mram_transfer_byte(0xFF); id[1] = mram_transfer_byte(0xFF); id[2] = mram_transfer_byte(0xFF); MRAM_CS_HIGH(); if (id[0] == 0xFF && id[1] == 0xFF && id[2] == 0xFF) { return -1; } return 0; }如果三个字节全是 0xFF,基本可以断定主机没和芯片建立起有效通信。剩下的问题可能是供电不对、HOLD 悬空导致芯片挂起、或者是 MOSI/MISO 接反。这个检查函数我一般让它上电后跑一次,并记录到日志里,方便以后远程排查问题。
4.4 用上 DMA 能省多少事
F030R8 的 RAM 只有 16KB,所以我没法一次性把大块数据全放到内存里做 DMA 传输。但有一个场景很适合 DMA:连续读取一条几百字节的报警记录。把目标缓冲区数组放在全局区,用 DMA 方式做收发,CPU 基本不用管字节搬运,可以腾出来做别的事。
uint8_t mram_dma_read_buf[512] __attribute__((aligned(4))); void mram_read_dma(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd = MRAM_CMD_READ; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); mram_send_address(addr); HAL_SPI_TransmitReceive_DMA(&hspi1, (uint8_t *)mram_dma_read_buf, buf, len); /* 等待传输完成,或者注册回调判断 */ while (HAL_SPI_GetState(&hspi1) != HAL_SPI_STATE_READY) {} MRAM_CS_HIGH(); }要注意的是,HAL_SPI_TransmitReceive_DMA 需要一个发送缓冲区,这里用一个全 0xFF 的数组做占位。DMA 传输完成之后要先等 SPI 状态机回到 READY,再拉高片选,否则最后一个字节可能还没完全移出时钟。曾经有人在这里吃过亏,DMA 中断一进就拉高层,最后一个数据字节实际没读全。
5. 工业现场踩过的几个坑与排查过程
5.1 读出来全是 0xFF,先怀疑 HOLD 引脚
第一次把板子装到现场,客户反馈设备运行一段时间后,参数偶尔会"清零"。我拿到故障板子后,先用逻辑分析仪抓 SPI 读写,指令、地址、数据看起来都正常,可芯片回的数据确实是全 0xFF。一开始以为是芯片坏了,把它焊下来放到测试座上,读写一切正常。这时候才意识到,问题出在板级装配上。
查了一圈发现,HOLD 引脚在原理图上是有的,但是贴片环节没贴那个 10k 上拉电阻,导致 HOLD 引脚变成了悬空状态。设备在运行中一旦产生振动,HOLD 引脚上感应出低电平毛刺,芯片就会进入保持状态,期间所有时钟输入都被忽略,输出引脚则被强制为高阻或保持不变,主机读到的自然就是全 0xFF。这个问题的排查过程给了我一个教训:MRAM 的 HOLD 引脚和 WP 引脚,画 PCB 时不要只依赖内部下拉或上拉,一定要在芯片旁边放一颗实际的电阻。
5.2 片选毛刺导致的数据被意外改写
另一个项目里,单片机和 MRAM 之间用了较长的排线连接,片选信号线上没加任何滤波。某次 EMC 测试打浪涌时,CS 线上出现了负毛刺,正好落在一条写指令的地址和数据中间,导致地址错位,数据被写到了另一个位置。排查时最迷惑的是:程序逻辑上明明不会去写那个地址,但数据就是变了。
后来我在 CS 引脚上加了一个 10k 上拉电阻,SPI 时钟线串了一个 33 欧姆电阻做阻抗匹配,毛刺就基本消失。另外在软件上,我给关键数据区写完后立刻回读校验,发现不一致就上报错误并重写一份备份。所谓"MRAM 不怕写"也存在"怕被意外写"的问题,硬件层的防护不能省。
5.3 越界读回绕导致的现象级 Bug
有次调试了一个多小时,表现是这样的:读地址 0x7FFF0 处的 32 字节,末尾几个字节内容感觉像是已经读回到了芯片开头的几个数据。当时我怀疑自己驱动有问题,但看代码逻辑也没错。后来翻数据手册里关于连续读的边界行为说明,才意识到 MRAM 的连续读操作越过地址最大值之后,行为是不保证的,我这个型号在实测中确实出现了地址回绕。
从那以后,我在驱动层坚持做了地址范围检查,所有调用 MRAM 的入口都在上层保证地址不越界。这个 Bug 之所以迷惑,是因为它只在地址边界出现,如果系统里只有一两个位置接近边界,概率更低,跑起来更难复现,浪费时间。
5.4 电源跌落瞬间丢最后一笔数据的处理
工业设备断电不是一瞬间完成的,3.3V 会有一个缓慢下降的过程。如果正好在电压掉到芯片工作阈值以下时,单片机还在对 MRAM 发起写操作,就会出现写了一半就断电,数据既不是全新的,也不是干净的旧值。MRAM 不像 Flash 需要时间擦除,它的写操作是即时完成的,但前提是电源电压够。
我给这个场景加了一个简单方案:在 3.3V 供电链路里并一个大容量电容,同时单片机用 ADC 检测电源电压,当电压低于 3.0V 时触发紧急保存流程,把所有需要掉电保持的变量在几十毫秒内全部快速写入 MRAM,然后进入低功耗停机。MRAM 写 512 字节在 12MHz SPI 下只需要零点几毫秒,紧急保存完全来得及。
6. 数据管理、掉电保护与一点扩展思路
6.1 双槽原子切换
工业现场最怕的是半截数据。如果一次存 128 字节,中途断电,这一条记录既不是旧值也不是新值,下次开机就废了。我采用双槽机制解决这个问题:把同一份配置放在两个固定地址 A 和 B,每条记录头部放一个 4 字节 CRC,再放一个递增的序列号。写入时先写槽 A,再写槽 B;读取时比较两个槽的 CRC,如果只有一个有效,就用那个有效的;如果两个都有效,用序列号更新的那个。
这个方案之所以叫"原子切换",是因为它利用了一个简单特性:单字节写操作在 MRAM 上是可靠的,完整的双槽写虽然不能保证断电时两个槽都写完,但总有一个槽是完整可用的,下次开机用 CRC 就能把好数据挑出来。Flash 时代这套方案也可以做,但每个槽都要考虑擦除时间,复杂度明显更高。
6.2 日志记录区的环形缓冲设计
因为我这里是 MRAM,做一个日志环形缓冲非常简单。例如我分配了 128KB 给日志区,写指针就是一个 4 字节计数器,每次写入一条日志,计数器加一,实际存储地址就等于计数器对 128KB 取模。每次都直接覆盖写,不需要擦除,不考虑磨损均衡。开机时读计数器,就知道上次写到哪里了。
使用 Flash 做环形日志的人可能会觉得这个设计太"粗暴",但 MRAM 的特性恰好支撑这种粗暴,写就是写,覆盖就是覆盖,没有损耗,没有擦除延迟。这会让代码砍掉一大截,整个日志系统的可靠性更高。我实测这个方案在设备上跑了大半年,日志区从没出过问题。
6.3 与看门狗配合时的访问纪律
MCU 复位时,SPI 外设的寄存器状态是确定的,MRAM 的片选处于释放状态,所以单纯复位不会对 MRAM 造成影响。但如果系统里用了 IWDG 独立看门狗,而且业务代码里有一个长时间阻塞的写操作,那就要小心复位发生在写流程中间。我的做法是:给 MRAM 的写操作加上一个超时保护,调用前先关中断或做临界区保护,确保单条命令序列不会中途被看门狗打断。
F030 是 M0 内核,没有硬件临界区指令,所以通常用 PRIMASK 来实现临界区,但临界区里时间不能太长,否则看门狗会饿死。另一种方法更简单:把写 MRAM 的操作拆成短指令序列,每条指令之间的间隔远小于看门狗超时时间,并且把片选引脚在关键位置置高,让任何情况下 MRAM 都不会停留在一个未完成的状态。这个思维和写 EEPROM 很像,只是 MRAM 不需要等内部编程时间,操作窗口短得多,更容易满足看门狗纪律。
我在实际项目中还有一个使用习惯:所有通过 MRAM 保存的数据,在系统启动时统一加载到内存副本,运行期间只修改内存,只有到关键切换点才整体写回 MRAM。这样做减少了写次数,也减少了意外写入窗口,让整套存储逻辑足够简单,简单到不会出 Bug。MR25H40CDF 加上 STM32F030R8 这套组合,给我的感受就是两个字,"踏实"。它不像 Flash 那样需要小心呵护擦写次数,也不像 RAM 那样断电即失,你以一种近乎自然的方式使用它,它就把数据给你稳稳守在那里。后面如果再选型,只要成本允许,我大概率还会把类似的 MRAM 芯片放进设计里,尤其是那种"参数老要改,数据还不能丢"的场合,用起来是真的省心。