如果你做嵌入式设备,一定碰到过这种闹心事:设备在客户现场跑了几个月,某天断电重启,配置参数变成了出厂默认值。更要命的是运行日志可能整个扇区花掉,想排查故障连数据都没了。我之前有个工业控制器项目就是被这个问题反复折磨,最后换成 MR25H40CDF 这颗 SPI 接口的 MRAM,配合 STM32L081CB 做控制端,把存储和读取这块整个重做了一遍。这篇文章就是那次改造的完整复盘,适合正在做工业数据采集、参数存储、日志记录的嵌入式工程师参考,也可以当做一个嵌入式数据存储方案的选型案例来读。
1. 先从一次掉电事故说起:为什么我要把项目里的Flash换成MRAM
事情是这样的:一块现场控制板,工作环境有振动、电压偶尔不稳,客户反馈断电重启后设备参数丢失,有的板子甚至开机后停在“配置文件损坏”的状态。前几版设计用的是 SPI NOR Flash 和 I2C EEPROM 混合存储,参数存在 EEPROM,日志存在 Flash,两个都出过问题。
排查下来有两个核心原因。第一,EEPROM 的写寿命虽然比 Flash 高不少,但被人为设计成了“每次上电都写一次上电计数”,一天开关几十次,一年多就把寿命耗得差不多了。第二,Flash 写入前必须先擦除整个扇区,掉电正好卡在擦除中的概率不是零,一旦发生就是完整扇区的旧数据全部丢失,连恢复的机会都没有。
那时候我认真对比了一圈存储方案,最后把目光放在 MRAM 上。MRAM 的全称是 Magnetoresistive Random Access Memory,磁阻随机存取存储器,核心存储单元是磁性隧道结(MTJ),数据靠磁性状态保持,不是靠电荷保持。所以它天然具备三个让我没法拒绝的特性:写入前不用擦除、写入速度快到接近 SRAM、读写寿命基本是无限的。
当时找了一圈,MR25H40CDF 是各方面比较均衡的一颗:4Mbit(512KB)容量、标准 SPI 接口、和普通 SPI NOR Flash 引脚兼容,数据手册写着工业级温度范围。最关键的驱动成本很低,因为指令集跟常见 SPI NOR Flash 高度重合,改起来不费劲。下面是当时做的一张选型对比表,也是后来跟同事讨论“为什么不能用回原来的方案”时用的。
| 特性 | MRAM | NOR Flash | EEPROM | SRAM+后备电池 |
|---|---|---|---|---|
| 非易失性 | 磁状态保持 | 浮栅电荷保持 | 浮栅电荷保持 | 断电靠电池维持 |
| 写前擦除 | 不需要 | 需要先擦扇区 | 实际也是先擦后写 | 不需要 |
| 写擦寿命 | 理论无限 | 约10万次 | 约100万次 | 无物理磨损,电池有寿命 |
| 单次写入时延 | 微秒级 | 毫秒级 | 毫秒级 | 纳秒级 |
| 掉电保护难度 | 低 | 擦除掉电风险高 | 字节写掉电风险 | 电池维护麻烦 |
| 存储成本 | 相对高 | 相对低 | 低 | 低但维护成本高 |
结论很直接:如果产品对“参数不能丢”“日志持续写几年”有硬要求,Flash 和 EEPROM 都有物理瓶颈,MRAM 是不想伺候磨损均衡算法的省心选择。当然,MRAM 的容量密度和价格暂时比不上大容量 Flash,但我当时的需求本来就不是存视频、存固件,而是小批量但高频次的数据写入——这正是 MRAM 的主场。
2. MR25H40CDF这颗MRAM芯片,和普通SPI Flash到底差在哪
2.1 芯片参数与引脚功能
MR25H40CDF 是 4Mbit 容量,也就是 512KB,通过 SPI 接口访问,支持 Mode 0 和 Mode 3。供电电压和逻辑电平都在常见 3.3V 系统范围内,不需要额外电平转换。封装是 DFN 8 脚,体积很小,适合做手持设备或者小板卡。
引脚功能上,CS、SCK、SI、SO、WP、HOLD、VCC、GND,总共 8 个脚。这个脚位跟很多 SPI NOR Flash 几乎一样,意味着你在硬件上可以把 MRAM 放到一个兼容 Flash 的封装位置上,软件驱动层面再切换。当然,如果真要做现场替换,还得仔细核对具体引脚定义和封装丝印,不能想当然直接焊上去。
机械和温标方面,工业级型号覆盖 -40℃ 到 85℃,对大多数现场设备来说够用。数据保留时间按 Everspin 的官方资料通常标到 20 年以上,比我之前用的 EEPROM 的数据手册漂亮不少。我没有拿 20 年去做完整老化验证,但从技术原理看,磁状态保持确实比浮栅电荷泄漏要稳。
2.2 指令集相似,但千万别照搬Flash驱动
MR25H40CDF 的指令集是 SPI NOR Flash 的“子集加简化版”。常用的几条命令如下:
| 指令 | 代码 | 作用 |
|---|---|---|
| READ | 0x03 | 24位地址,之后连续时钟输出数据 |
| WRITE | 0x02 | 24位地址,之后直接写入数据,不需要擦除 |
| WREN | 0x06 | 置位写使能锁存 |
| WRDI | 0x04 | 清除写使能锁存 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| RDID | 0x9F | 读器件ID,可用于确认芯片在位 |
最大的差异在哪?Flash 的 Page Program 写命令发完,芯片内部还要花几毫秒做编程,软件必须轮询状态寄存器等 BUSY 位清零才算写完成。MRAM 不需要这个流程,它在 SPI 命令发送的过程中就把数据写进去了,命令结束、CS 拉高,数据已经稳定。也就是说,MRAM 根本没有“忙等待”这个概念,驱动里少了一大段轮询逻辑。
我当时第一次移植驱动时下意识保留了等 BUSY 的循环,结果发现每次读完状态寄存器都是“不忙”,后来看时序图才反应过来——MRAM 压根没有内部编程状态机,写入是在主控发数据的时钟周期里同步完成的。这也是跟同事聊的时候最容易闹笑话的地方:别把 Flash 那套“写命令后必须等 WIP/BSY 标志”的习惯带过来。
2.3 状态寄存器与写保护设计
MR25H40CDF 的状态寄存器也有 WEL、WPEN 这类跟 Flash 类似的标志位,但它没有块保护位,也就是没有 BP0、BP1 那套按区域禁写的逻辑。它更接近“要么完全允许写,要么硬件引脚锁死写”的模型。
常规操作顺序是:先把 CS 拉低,发 WREN(0x06),再把 CS 拉高;之后发写命令时,芯片内部才会允许真正的写入。如果你不先发 WREN,直接发 WRITE,写入操作会被忽略。我习惯在 WREN 之后读一次状态寄存器,确认 WEL 位确实置位,再继续发 WRITE。这样多花几微秒,但能提前暴露上电时序不干净或者 SPI 通信错位的问题。
需要提醒一句:WEL 位在状态寄存器的具体位置,不同批次、不同型号的 Everspin 芯片手册可能有不一致,一定要以你自己手里那颗芯片的官方 datasheet 为准。代码里的掩码如果按 NOR Flash 的习惯写死,一旦寄存器布局不同,很容易踩坑。
硬件写保护由 WP 引脚承载。我的做法是把 WP 引脚接到 MCU 的普通 GPIO,平时拉高允许写;只有在明确进入“维护模式”或“出厂配置锁定模式”时才拉低,禁止一切软件写操作。如果嫌 GPIO 不够用,也可以直接把 WP 上拉到 VCC,前提是你的程序里不会因为地址越界、堆栈跑飞等原因误写 MRAM。
2.4 硬件接线的几个实际考虑
接线上有几个点不是数据手册会强调、但实际特别好使的经验。
第一,去耦电容不能省。VCC 引脚旁边放一枚 0.1μF 陶瓷电容,再并联一枚 1μF~10μF 的钽电容或陶瓷电容,离引脚越近越好。MRAM 虽然不像 Flash 擦除时那样有大的电流尖峰,但作为 SPI 从机,时钟沿上的瞬态电流还是有的,电源不稳会直接表现为偶发读错。
第二,HOLD 引脚绝对不能悬空。这个我后面专门讲,是最容易忽略的坑。简单说,HOLD 引脚在低电平时会让 SPI 通信暂停,悬空状态很容易被板上的噪声干扰误触发。
第三,CS 不要跟其它 SPI 设备共用一根线,即便是两个设备轮换访问也不行。CS 必须独立 GPIO 控制,因为 MRAM 要求 CS 在整条命令期间保持低电平,命令结束后干净地拉高。如果 CS 线上有毛刺,芯片可能把下一条命令截断。
3. STM32L081CB对接MR25H40CDF:CubeMX配置与HAL驱动实现
3.1 为什么控制端是STM32L081CB
STM32L081CB 是一颗带超低功耗定位的 MCU,Cortex-M0+ 内核,主频最高 32MHz,Flash 128KB、RAM 20KB,工作在 3.3V。它自带 SPI、I2C、USART,引脚不多,封装不大,适合做传感器节点、工业采集模块这类对尺寸和功耗有要求的设备。
选它做 MRAM 的控制端,几个理由:一是接口直接,自带 SPI 主机,硬件上不需要额外桥接芯片;二是它的低功耗模式丰富,系统大部分时间可以睡在 STOP 或 STANDBY,只在需要记录数据时醒来,和 MRAM 的按需读写很搭;三是这个芯片是工业界铺货量很大的型号,供应链成熟,不是那种小众到没人会用的片子。
有的人可能会问:干嘛不选带硬件 FSMC 或并行总线的 MCU?并行总线确实带宽高,但 MR25H40CDF 本身就是 SPI 接口,硬要接并行反而多此一举。SPI 接口在 16MHz 时钟下已经能满足我那个项目的吞吐需求,而且节省引脚,这比“看起来跑得快”更实际。
3.2 CubeMX下的关键配置
我在 STM32CubeMX 里新建工程时,SPI 外设按下面的参数配:
| 配置项 | 值 | 说明 |
|---|---|---|
| Mode | Full-Duplex Master | 全双工主机 |
| Data Size | 8 bits | 命令、地址、数据都是字节流 |
| CPOL | Low | SPI Mode 0 |
| CPHA | 1 Edge | 第一个边沿采样 |
| NSS | Software | 不依赖硬件NSS,CS由GPIO控制 |
| 波特率预分频 | 先分到4MHz左右 | 跑通后再逐级提速 |
Pin 映射上,我用的 STM32L081CB 是 LQFP48 封装,SPI1 的默认复用引脚是 PB3 做 SCK、PB4 做 MISO、PB5 做 MOSI。CS 选了 PA4,WP 选了 PA5,HOLD 直接由 PA6 控制。如果你的封装不同,复用引脚可能不一样,CubeMX 里它会根据你选的封装自动算出来。
有人觉得 NSS 用硬件模式更省事,但我的建议是软件控制 CS。硬件 NSS 在主从切换时容易产生多余的电平变化,对 MRAM 这种要求 CS 干净的从机来说不友好。软件控制 CS 就是两次 GPIO 操作,没有任何副作用。
还有一个经验:首次点灯调试时,SPI 时钟频率不要一上来就拉到上限。先降频到 4MHz 左右,把读写函数跑通,再用逻辑分析仪确认时序没有毛刺,之后逐级往上提速。很多“偶尔读写错误”都是在一开始就把时钟拉满留下的隐患。
3.3 HAL驱动代码:带着注释的完整实现
命令宏先定义好,后面代码都好读:
#define MRAM_CMD_WRITE 0x02 #define MRAM_CMD_READ 0x03 #define MRAM_CMD_WREN 0x06 #define MRAM_CMD_WRDI 0x04 #define MRAM_CMD_RDSR 0x05 #define MRAM_CMD_WRSR 0x01 #define MRAM_CAPACITY 0x80000 /* 512KB */读写函数的核心是:CS 拉低,发命令和 24 位地址,然后连续传输数据,最后 CS 拉高。整个过程必须在 CS 持续低电平时完成,中间不能拉高。以下是读取函数:
static void mram_cs_low(void) { HAL_GPIO_WritePin(MRAM_CS_GPIO_Port, MRAM_CS_Pin, GPIO_PIN_RESET); } static void mram_cs_high(void) { HAL_GPIO_WritePin(MRAM_CS_GPIO_Port, MRAM_CS_Pin, GPIO_PIN_SET); } int mram_read(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t hdr[4]; if (addr + len > MRAM_CAPACITY) { return -1; } hdr[0] = MRAM_CMD_READ; hdr[1] = (addr >> 16) & 0xFF; hdr[2] = (addr >> 8) & 0xFF; hdr[3] = addr & 0xFF; mram_cs_low(); if (HAL_SPI_Transmit(&hspi1, hdr, 4, 100) != HAL_OK) { mram_cs_high(); return -2; } /* 全双工模式下,Receive会同时发送0x00来产生时钟 */ if (HAL_SPI_Receive(&hspi1, buf, len, 1000) != HAL_OK) { mram_cs_high(); return -3; } mram_cs_high(); return 0; }写入函数比读取多一个前置动作:写使能。写完 WREN 之后,我习惯马上读状态寄存器,确认 WEL 已经置位:
static int mram_write_enable(void) { uint8_t cmd; uint8_t sr; cmd = MRAM_CMD_WREN; mram_cs_low(); if (HAL_SPI_Transmit(&hspi1, &cmd, 1, 10) != HAL_OK) { mram_cs_high(); return -4; } mram_cs_high(); /* 读状态寄存器,确认WEL位;具体掩码以官方手册为准 */ cmd = MRAM_CMD_RDSR; mram_cs_low(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); HAL_SPI_Receive(&hspi1, &sr, 1, 10); mram_cs_high(); return (sr & 0x02) ? 1 : 0; } int mram_write(uint32_t addr, const uint8_t *buf, uint32_t len) { uint8_t hdr[4]; if (addr + len > MRAM_CAPACITY) { return -1; } if (mram_write_enable() <= 0) { return -4; } hdr[0] = MRAM_CMD_WRITE; hdr[1] = (addr >> 16) & 0xFF; hdr[2] = (addr >> 8) & 0xFF; hdr[3] = addr & 0xFF; mram_cs_low(); if (HAL_SPI_Transmit(&hspi1, hdr, 4, 100) != HAL_OK) { mram_cs_high(); return -2; } if (HAL_SPI_Transmit(&hspi1, (uint8_t *)buf, len, 1000) != HAL_OK) { mram_cs_high(); return -3; } mram_cs_high(); return 0; }这里有一个细节:HAL_SPI_Transmit 的第二个参数类型是uint8_t*,如果你传入的是 const 缓冲区,编译器会报警告。实际使用时可以定义一个非 const 的局部指针做一次强转,或者干脆在应用层保证缓冲区可变。
如果你的 SPI 还接了别的从机,MISO 上可能有多个输出源。MRAM 的 SO 在 CS 拉高后是高阻态,所以共用一个 MISO 没问题,前提是其它从机的 CS 也为高。我在项目里就是这样把 MRAM 和一块 SPI 屏幕挂在同一条 SPI 总线上,正常工作没毛病。
3.4 中断、DMA与低功耗下的调用注意事项
你可能会觉得上面代码太“阻塞”了。实际上,对大多数工业参数存储场景,阻塞调用反而简单可靠。一次写 4 字节,在 16MHz SPI 时钟下不到 10μs 就完成了,加上 HAL 函数调用开销也就几十微秒,这点时间对主循环、RTOS 任务来说完全可以接受。
但如果要在定时器中断里直接写 MRAM,就要仔细算时间。比如你的控制周期是 1ms,中断里写 64 字节日志,SPI 传输加上 HAL 开销大约 100μs 左右,占掉了 10% 的预算,必须评估是否影响其它实时任务。更稳妥的做法是中断里只把数据塞进 RAM 队列,由主循环或专用任务去写 MRAM,这样既保证中断响应,又避免 SPI 操作重入。
大量连续写入时,可以考虑用 SPI DMA。STM32L0 系列支持 SPI DMA 请求,配置也不复杂。使用 DMA 时注意:CS 的拉低拉高仍然由 GPIO 控制,DMA 只是接管了数据搬移;DMA 传输完成中断里再拉高 CS,否则数据会少一截。
低功耗模式上,MRAM 本身没有睡眠唤醒的负担,你不访问它时把 CS 拉高,它就是一个静态存储体,待机电流很小。STM32L081CB 进入 STOP 前要确保 SPI 传输已经结束、DMA 已经停止,不然停外设时钟的瞬间会有未完成的数据。另外,进入低功耗前可以把 SCK、MOSI 都设置成 GPIO 输出低,避免引脚悬空通过寄生二极管漏电。这个细节在电池供电的设备上特别有用。
4. 应用层数据布局:用MRAM的单字节写特性做事务型存储
4.1 分四区管理512KB空间
驱动跑通只是开始,真正的设计重点在“怎么组织数据”。我把 512KB 分成了几个逻辑区:
| 区域 | 起始地址 | 大小 | 存放内容 |
|---|---|---|---|
| 系统信息区 | 0x00000 | 1KB | 设备ID、出厂日期、固件版本 |
| 配置参数区A | 0x00400 | 2KB | 主参数,含CRC |
| 配置参数区B | 0x00C00 | 2KB | 备份参数,含CRC |
| 运行日志区 | 0x01400 | 64KB | 环形覆写日志 |
| 采集数据区 | 0x11400 | 剩余 | 批量采集数据 |
分区的理由并不是因为 MRAM 需要按块管理,而是为了调试方便。地址边界清晰以后,日志覆写不可能碰到参数区,参数区也不可能意外覆盖系统信息区。每个人习惯不同,你也可以按记录动态分配,但对工程维护来说,固定分区最简单可靠。
4.2 带CRC的记录头设计
每一条落盘的记录前边我都会放一个定长的记录头,结构长这样:
typedef struct { uint16_t magic; /* 固定魔数,例如0xA53C,用于识别有效记录 */ uint8_t version; /* 结构体版本,方便以后扩展字段 */ uint8_t flags; /* 状态标志:具体见下面的提交协议 */ uint32_t length; /* 数据长度 */ uint32_t crc32; /* 对整个记录头+数据负载的CRC32校验 */ uint64_t timestamp; /* 64位时间戳,跨年跨世纪都不怕 */ } record_head_t;magic 的作用是快速判断“这个位置有没有有效记录”。如果读出来的 magic 不对,说明该地址要么从没写过,要么已被破坏,直接当成空位处理。CRC32 我用查表法实现,STM32L0 本身有硬件 CRC 外设,但它的初始值和位序跟软件常规用法不完全一致,我嫌麻烦直接软件算了。日志记录和配置参数都是低频数据,CRC 计算对性能影响不大。
4.3 提交协议:先写标志,再写数据,最后翻转标志
这是我在这个项目里最喜欢的一段设计。Flash 和 EEPROM 都没法轻易做到“频繁翻转一个标志字节”——EEPROM 寿命吃不消,Flash 得先擦除整个扇区。MRAM 不一样,它支持单字节直接改,所以可以用一种极简的事务提交协议保证数据一致性。
具体流程分四步:
- 在记录头所在位置写入一条新记录,
flags先写成 0x02,表示“写入进行中”。 - 紧接着写入数据负载,等待 SPI 传输结束。
- 修改同一个记录头里的
flags,从 0x02 改成 0x01,表示“数据有效”。 - 读回
flags做一次确认,只有读到 0x01 才认为提交成功。
如果步骤 2 或者步骤 3 之间发生了掉电、复位,下次上电读到的flags就还是 0x02。此时程序就可以理直气壮地丢弃这条记录,回退到上一个有效副本。对配置参数区,我采用的正是双副本方案:A 区写最新参数,B 区写上一版可用参数。每次上电先检查两边的 CRC 和 flags,选版本号大的可用副本加载。
这个思路的本质是:用标志字节把“数据写入过程”和“数据已生效”隔离开。MRAM 就算传输中被打断,也不会像 Flash 那样出现“擦除到一半、整片逻辑都对不上”的灾难状态,最多就是一条记录废掉,可以被检测并回滚。
4.4 不需要磨损均衡,但不能不防地址越界
很多从 Flash 转过来的人会灵魂拷问:你日志一直往同一个环形区写,MRAM 会不会写穿?答案是,MRAM 的写寿命远超 Flash 和 EEPROM,Everspin 官方标称是近乎无限次。你那套“把写地址匀开”的磨损均衡算法,在 MRAM 这儿属于多余的复杂度,可以放心去掉。
这算是解脱,但引出了另一个问题:既然写不坏,代码里最容易犯的错就是不再检查地址。我见过同事的代码在缓冲区长度写错后,一口气把参数区后面几百个字节全刷成 0xFF,因为芯片扛得住写,所以问题直到重启才暴露。我的经验是所有公共写接口都得做边界检查,addr + len大于容量直接返回错误。这个检查一定要放在驱动入口,而不是上层任务里挨个判断。
5. 实测数据与验证:读写速度、连续写入、掉电恢复
5.1 测试环境
我的测试板就是最终产品的原型板:STM32L081CB 主控,SPI1 接 MR25H40CDF,3.3V 单电源,PCB 双层板,SPI 走线长度控制在 20mm 以内。测试工具是一台逻辑分析仪和一台可编程电子负载,用来模拟负载电流和随机断电。
速度测试直接用定时器计时间戳,从函数调用前到函数返回后算差值,包含 CS 翻转和 HAL 函数调用开销,尽量贴近实际使用的真实耗时。耐久测试写了一个独立任务,对同一个地址连续写 8 字节带 CRC 的数据,另一个任务读回校验。
5.2 读写性能对照
下表是当时记录的一组典型数值,不同板卡和 SPI 时钟下会有出入,但量级可以参考:
| SPI时钟 | 读4字节 | 写4字节 | 读512字节 | 写512字节 |
|---|---|---|---|---|
| 4 MHz | 约18μs | 约21μs | 约1.06ms | 约1.08ms |
| 16 MHz | 约6μs | 约7μs | 约270μs | 约280μs |
对比我以前用的 I2C EEPROM,写 512 字节数据要按页写、一页一页等写周期,最坏情况总耗时几十毫秒。MRAM 在 16MHz 下的写入速度是 EEPROM 的几十倍,这个差距在“每次启动都要写入几百字节配置”的场景里非常明显,甚至能直接缩短启动时间。
更值得注意的是写使能开销。每次写入前都要发一条 WREN 并读状态寄存器,等于在每次短短几微秒的写入外面多套了一层操作。所以在高频小数据写入场景,把多次字节写合并成一次连续写会明显提高总吞吐,我后面在日志模块里就强制按 32 字节或 64 字节一个记录块来写。
5.3 百万次连续写入验证
耐久测试我直接对着同一个地址连续写了 100 万次,每写一次都带一个递增计数和 CRC32 校验值,写完立即回读比对。整个过程大约 1 分钟出头,寄存器里记录的失败次数始终是 0。
这个测试如果换成原来的 NOR Flash,先不说寿命,光擦除加写入的时间就可能要跑几小时。这也是我后来向团队推荐 MRAM 时最有说服力的数据:不是“理论上能写很多次”,而是“我实际写了几百万次,它连一次错误都没出过”。
测试程序我特意保留了统计逻辑,写失败时会主动记录失败次数和错误地址。正式产品里也可以把类似统计存进 MRAM 的一个独立健康状态区,用来长期监测存储子系统的稳定性。虽然 MRAM 大概率永远用不上这种监测,但对质量部门来说,这组数据在出厂巡检时很能说明问题。
5.4 掉电与复位测试
掉电测试做了 200 次随机断电。方法是用继电器随机断开整板电源,主程序每隔几十毫秒往 MRAM 写一条带 flags 和 CRC 的日志。上电后检查最后一条日志的完整性和提交标志。
结果分两类:如果断电发生在一次写操作完全结束之后,重启后日志完整、flags 是 0x01;如果断电刚好切在一次写操作过程中,重启后看到的是上一条有效日志,而那一条半截记录的 flags 停留在 0x02,程序按设计把它丢弃并回滚。200 次测试里没有出现“flags 为 0x01 但 CRC 不对”的脏数据,也没有出现两条日志内容串位的情况。
这说明两点:第一,MRAM 本身的字节写入确实够快,只要 SPI 传输结束、CS 拉高,数据就是稳的;第二,如果中途断电实在躲不开,flags 提交协议会把损失限制在单条记录丢弃,而不会污染整个存储区。
6. 工程落地绕不开的坑:从HOLD引脚到SPI时钟速率
6.1 坑一:HOLD引脚悬空,偶发数据错乱
第一次打样时我按“最小设计”把 HOLD 引脚空着,觉得这个功能不用就无所谓。结果设备在实验室跑了半天,偶尔读出一两个错误的配置参数,复位后又能正常。用逻辑分析仪连续抓波形才发现,MRAM 的 SPI 传输在某些时刻会突然“停顿”一拍,MISO 在那一小段里变成无效电平——正好就是 HOLD 功能被触发的表现。
HOLD 引脚的设计是低电平有效:传输过程中拉低,通信暂停;拉高,通信继续。如果这个引脚悬空,板上噪声一耦合,就可能出现几纳秒的低电平毛刺,把正在进行的 SPI 传输打断。解决办法不复杂:HOLD 引脚用 10kΩ 电阻上拉到 VCC,或者直接用 MCU 的 GPIO 控制。我选了后者,因为 GPIO 还可以在维护模式下主动拉低 HOLD,让外部工具也无法操作 MRAM,算是一层额外的保护。
这个坑提醒我一件事:从机芯片的功能引脚,不能因为“功能不用”就直接悬空。任何有低电平触发语义的引脚,最好都做上拉或由主控明确驱动。
6.2 坑二:SPI时钟速率和走线质量的组合问题
第二次踩坑是在调高 SPI 时钟的时候。数据手册标称 MR25H40CDF 可以跑很高的时钟速率,我照着理论值直接配 16MHz,一开始在开发板上测试也正常。后来把 MRAM 挪到一块飞线连接的子板上,问题就来了:连续读写几百字节后,偶尔会出现一个数据位错乱,而且是随机位置。
排查到最后,问题出在飞线本身。MRAM 子板到主控板之间用了几根十几厘米长的杜邦线,SPI 时钟沿在长线上反射严重,加上 MISO 回传路径的 GND 不连续,16MHz 下的建立时间余量不够。把波特率降到 4MHz 后,相同硬件完全不报错。
后来重新画板,SCI 走线缩短到 20mm 以内、地平面完整,再把 SPI 时钟调回 16MHz,连续读写几百万次也没问题。我的经验是:SPI 时钟能跑多快不只取决于芯片标的,更取决于你的 PCB 布局。新项目一律 4MHz 起步跑通,再用 8MHz、16MHz 逐级验证,每一级都跑 100 万次连续读写才放心。别为了让“实测能跑到标称值”好看,把可靠性搭进去。
6.3 坑三:HAL阻塞调用和看门狗打架
还有一次比较隐蔽。当时把写日志的函数直接放在主循环里,按流程跑完 SPI 也就几十微秒。结果某次现场回来后,设备出现“周期性复位”的故障。查日志发现复位原因是看门狗超时,而超时前的最后一条日志总是缺失。
逐行排查后发现,问题不是 MRAM 写入本身慢,而是我在另一处中断服务函数里做了一段比较耗时的浮点运算,中断关闭时间超过看门狗喂狗间隔。MRAM 的 SPI 写入在里面被夹住,整个主循环长时间得不到调度,看门狗就翻了。
这其实不是 MRAM 的错,但它提醒我把“SPI 占用时间”和“最坏情况下的总阻塞时间”分开评估。写驱动时要注意 HAL_SPI_Transmit 的超时参数,如果 SPI 外设没初始化好或者总线被占用,这个函数会按超时时间返回错误,而不是永远卡死。正式产品里,关键数据写完后要读回校验,失败时重试一次,再失败就进错误处理,不能指望着靠看门狗复位来“自愈”。
6.4 给后来者的设计清单
项目完结后,我把这套方案整理成一份检查清单,每次画板或者移植驱动前过一遍:
- WP 引脚接 MCU GPIO,平时拉高,需要锁写时再拉低。
- HOLD 引脚上拉或由 GPIO 控制,严禁悬空。
- CS 引脚独立 GPIO,推挽输出,不要开漏。
- 所有 MRAM 读写接口做地址边界检查。
- 每条记录带 magic + flags + CRC32。
- SPI 时钟从 4MHz 起步,逐级提速,每级跑连续读写压力测试。
- 进入低功耗模式前确保 SPI 传输完成。
- 如果 MRAM 和别的 SPI 设备共用总线,确认各从机 CS 互不干扰。
这套组合在我手里最大的收获,是可以把存储问题从“需要反复评估寿命、做磨损均衡、祈祷别掉电”变成“写就完了,反正写不坏、掉电也丢不了”。MR25H40CDF 和 STM32L081CB 这个搭配,本质上是把“非易失、写得快、写得久”三个需求同时满足了。你在设计工业参数存储、运行日志、电池供电的采集设备时都可以按这个思路去选型。上面的驱动函数和记录头结构,封装成独立的 hal_mram.c 和 mram_record.c 之后,后续换主控芯片只需要改底层 SPI 传输接口,应用层逻辑可以原样复用。