1. 为什么 MRAM 在嵌入式存储里越来越受关注
搞嵌入式的人大多有过这种经历:设备跑在现场,突然断电,Flash 里的配置参数丢了一半,或者写日志写到一半掉电,整个文件系统直接挂掉。传统方案要么加超级电容,要么加铁电存储器(FRAM),要么用带掉电检测的复杂软件逻辑去兜底。这些办法都能用,但成本和复杂度摆在那里。
MR25H40CDF 这颗芯片之所以值得单独拿出来聊,是因为它把“非易失”和“像 SRAM 一样随便写”这两件事合到了一起。它是 Everspin 出的 4Mbit 串行 MRAM,SPI 接口,40MHz 时钟,没有写延迟,没有擦除动作,写多少次都不会坏。你把它当成一个通过 SPI 访问的 SRAM 来用就行,断电之后数据还在。这个特性在工业数据记录、参数存储、掉电保护场景里非常实用。
STM32F410RB 是 ST 的 Cortex-M4 入门级 MCU,100MHz 主频,128KB Flash,32KB SRAM,带硬件 SPI。它不算高端,但胜在便宜、好买、生态成熟,做工业传感器节点、数据采集终端、小型控制器绰绰有余。用它来驱动 MR25H40CDF,硬件上只需要几根线,软件上用 HAL 库的 SPI 接口就能跑通。
这套组合解决的核心问题是:在低成本嵌入式系统里,实现高可靠、高频次、掉电安全的数据存储。适合做工业现场设备、电力监控终端、医疗数据记录仪、车载黑匣子这类对数据完整性有要求的场景。如果你正在用 Flash 模拟 EEPROM 或者外挂 FRAM,这篇文章可以给你一个替代思路。
2. 硬件设计:从引脚到 PCB 的实操细节
2.1 MR25H40CDF 的引脚定义与连接方式
MR25H40CDF 是 8 引脚 SOIC 封装,引脚定义很标准:
| 引脚编号 | 名称 | 功能说明 |
|---|---|---|
| 1 | CS | 片选,低电平有效 |
| 2 | SO | 数据输出(MISO) |
| 3 | WP | 写保护,低电平有效 |
| 4 | VSS | 地 |
| 5 | SI | 数据输入(MOSI) |
| 6 | SCK | 时钟 |
| 7 | HOLD | 保持,低电平有效 |
| 8 | VDD | 电源,2.7V~3.6V |
跟 STM32F410RB 的连接方式很直接:
- CS 接 MCU 的任意 GPIO,建议用 SPI 的硬件 NSS 引脚,也可以用软件片选
- SCK、SI、SO 分别接 SPI 的 SCK、MOSI、MISO
- WP 和 HOLD 如果不使用,直接上拉到 VDD
- 电源引脚旁边放 0.1uF 去耦电容,越近越好
这里有个细节:MR25H40CDF 的 WP 引脚是低电平有效,也就是说你如果把它悬空,内部没有上拉的话,写保护状态是不确定的。我一般直接在 PCB 上把 WP 和 HOLD 通过 10K 电阻上拉到 VDD,需要写保护的时候再用 GPIO 拉低。这样既保证了默认状态可写,又保留了硬件写保护的能力。
2.2 SPI 模式选择与时钟配置
MR25H40CDF 支持 SPI 模式 0 和模式 3。模式 0 是 CPOL=0、CPHA=0,模式 3 是 CPOL=1、CPHA=1。两种模式都能用,但我在实际项目里更倾向用模式 0,因为大部分 SPI Flash 和传感器都用模式 0,统一起来调试方便。
时钟频率方面,MR25H40CDF 最高支持 40MHz。STM32F410RB 的 SPI1 挂在 APB2 上,最高 100MHz,分频后可以到 50MHz、25MHz、12.5MHz 等。我一般先用 12.5MHz 调通,再往上提到 25MHz 跑稳定性测试。40MHz 不是不能用,但要看 PCB 走线和线长,短线、阻抗控制好的情况下没问题,排线长了就容易出错。
注意:SPI 时钟极性相位配置错了,读出来的数据全是 0xFF 或者 0x00,这是新手最容易踩的坑。先用逻辑分析仪抓一下波形,确认 SCK 空闲电平和数据采样边沿。
2.3 PCB 布局布线的几个关键点
SPI 信号线虽然速度不算特别高,但 MRAM 对时序比较敏感,尤其是高速写入的时候。我总结了几条经验:
- SCK 走线尽量短,不要跨分割地平面
- MISO 和 MOSI 不要平行走太长,避免串扰
- 去耦电容放在 VDD 引脚 2mm 以内,地线直接打过孔到地平面
- 如果板子上有多个 SPI 从设备,片选线要分开走,不要共用
有一次我做一个工业采集板,MRAM 放在板子边缘,SPI 线走了 8cm,25MHz 下读写偶尔出错。后来把 MRAM 挪到 MCU 旁边,线长控制在 3cm 以内,问题直接消失。所以别小看这几厘米的走线,高速信号面前,物理距离就是电气性能。
3. STM32F410RB 的 SPI 初始化与 HAL 库配置
3.1 CubeMX 里的 SPI 参数设置
用 STM32CubeMX 配置 SPI1 的步骤不复杂,但有几个参数容易搞错:
- Mode:选 Full-Duplex Master,MRAM 是标准 SPI 从设备
- Data Size:8 Bits,MR25H40CDF 按字节传输
- Clock Polarity:Low(模式 0)
- Clock Phase:1 Edge(模式 0)
- NSS:Software,用 GPIO 手动控制片选
- Baud Rate Prescaler:先选 16 分频,得到 6.25MHz,调通后再改
- First Bit:MSB First
生成代码后,HAL 会自动初始化 SPI 外设。但片选引脚需要自己配置成 GPIO 输出,默认拉高。
// SPI1 初始化片段(CubeMX 生成) hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;3.2 片选控制的两种方式与选择依据
STM32 的 SPI 支持硬件 NSS 和软件 NSS。硬件 NSS 在单主单从的时候很方便,但多从设备的时候就不行了。我一般用软件 NSS,把 CS 引脚当普通 GPIO 控制。
#define MRAM_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define MRAM_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET)软件片选的好处是灵活,你可以在一次事务里控制多个从设备,也可以精确控制片选和时钟的时序关系。缺点是每次传输都要手动拉低拉高,代码稍微多几行。但这点代码量换来的是调试方便和逻辑清晰,值得。
提示:片选拉低到第一个时钟沿之间要留一点时间,MR25H40CDF 要求 tSLCH 最小 5ns,STM32 的 GPIO 翻转速度足够快,一般不用额外加延时。但如果你的主频很低,比如 16MHz,最好在片选拉低后加一个 __NOP()。
3.3 SPI 读写基础函数封装
HAL 库提供了 HAL_SPI_Transmit 和 HAL_SPI_Receive,但直接调用它们需要自己管理片选。我习惯封装一层:
void MRAM_WriteEnable(void) { uint8_t cmd = 0x06; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); MRAM_CS_HIGH(); } void MRAM_WriteDisable(void) { uint8_t cmd = 0x04; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); MRAM_CS_HIGH(); } uint8_t MRAM_ReadStatus(void) { uint8_t cmd = 0x05; uint8_t status; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_SPI_Receive(&hspi1, &status, 1, 100); MRAM_CS_HIGH(); return status; }这里有个细节:HAL_SPI_Transmit 和 HAL_SPI_Receive 分开调用的时候,片选不能中间拉高,否则命令和数据的时序就断了。所以必须把命令发送和数据接收放在同一个片选周期里。
4. MR25H40CDF 的读写操作与数据管理
4.1 状态寄存器与写使能机制
MR25H40CDF 的状态寄存器只有几位有效:
| 位 | 名称 | 说明 |
|---|---|---|
| 0 | WIP | 写进行中,MRAM 没有擦除,这位基本一直是 0 |
| 1 | WEL | 写使能锁存,执行 WREN 后置 1,写完自动清 0 |
| 2 | BP0 | 块保护位 0 |
| 3 | BP1 | 块保护位 1 |
| 7 | SRWD | 状态寄存器写保护 |
跟 Flash 不一样的是,MRAM 没有擦除操作,也不需要等待写完成。WIP 位在 MRAM 上基本没用,因为写入是即时的。但 WEL 位还是要检查,每次写之前必须发 WREN 命令,否则写不进去。
void MRAM_WriteByte(uint32_t addr, uint8_t data) { uint8_t cmd[5]; cmd[0] = 0x02; // WRITE 命令 cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; cmd[4] = data; MRAM_WriteEnable(); MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 5, 100); MRAM_CS_HIGH(); }地址是 24 位的,因为 4Mbit 等于 512KB,需要 19 位地址,但 MR25H40CDF 用 3 字节地址格式,高位补零。
4.2 页写与连续读的效率优化
MR25H40CDF 支持页写,每页 256 字节。如果你要写一大块数据,逐字节写效率很低,每次都要发 WREN 和 5 字节命令头。用页写可以一次发命令头,然后连续写 256 字节。
void MRAM_WritePage(uint32_t addr, uint8_t *buf, uint16_t len) { uint8_t cmd[4]; cmd[0] = 0x02; cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; MRAM_WriteEnable(); MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Transmit(&hspi1, buf, len, 1000); MRAM_CS_HIGH(); }注意页写不能跨页。如果起始地址是 0x00FF,写 10 个字节,写到第 2 个字节就跨到下一页了,MRAM 会从页首覆盖。所以写之前要算一下当前页剩余空间,分两次写。
连续读就更简单了,发完读命令和地址后,时钟不停,数据就一直往外吐:
void MRAM_ReadData(uint32_t addr, uint8_t *buf, uint16_t len) { uint8_t cmd[4]; cmd[0] = 0x03; cmd[1] = (addr >> 16) & 0xFF; cmd[2] = (addr >> 8) & 0xFF; cmd[3] = addr & 0xFF; MRAM_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Receive(&hspi1, buf, len, 1000); MRAM_CS_HIGH(); }读操作没有页限制,可以从任意地址开始读任意长度,地址会自动递增,到顶部回卷到 0。
4.3 数据分区与地址规划建议
512KB 的空间不算大,但也不小。我一般这样分区:
| 区域 | 地址范围 | 用途 |
|---|---|---|
| 系统参数区 | 0x00000~0x00FFF | 设备配置、校准系数 |
| 运行日志区 | 0x01000~0x3FFFF | 循环记录运行数据 |
| 掉电快照区 | 0x40000~0x7FFFF | 掉电瞬间的关键状态 |
系统参数区用双备份加 CRC 校验,写的时候先写备份区,再写主区,读的时候对比两个区的 CRC,哪个对用哪个。运行日志区用环形缓冲,写指针存在 MRAM 固定位置,每次写日志先更新指针再写数据。掉电快照区在检测到掉电中断时紧急写入,因为 MRAM 没有写延迟,从检测到掉电到写完关键数据,几百微秒就够了。
实操心得:MRAM 虽然写不坏,但地址规划还是要留余量。我见过有人把参数区放在 0x00000,日志区紧跟着从 0x00100 开始,结果参数区结构一变,日志区全乱套。分区的时候多留点 padding,以后扩展不头疼。
5. 工业场景下的可靠性设计与掉电保护
5.1 掉电检测与数据保存策略
工业现场最怕的就是掉电丢数据。MRAM 的优势在这里体现得淋漓尽致:不需要擦除,不需要等待,写进去就生效。但前提是你要在电源掉到 MCU 工作电压以下之前,把关键数据写进去。
我的做法是用一个比较器监测电源电压,阈值设在 3.0V(假设系统是 3.3V 供电)。当电源掉到 3.0V 时,比较器输出下降沿触发 MCU 的外部中断。在中断里,MCU 还有几十毫秒的时间(取决于电容大小),足够把几百字节的关键数据写进 MRAM。
void EXTI0_IRQHandler(void) { if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET) { __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0); // 紧急保存关键数据 MRAM_WritePage(SNAPSHOT_ADDR, (uint8_t*)&g_criticalData, sizeof(g_criticalData)); // 标记快照有效 uint8_t flag = 0xA5; MRAM_WriteByte(SNAPSHOT_FLAG_ADDR, flag); } }这里的关键是:中断里不要做复杂运算,不要调用可能阻塞的函数,直接写 MRAM。MRAM 的页写 256 字节在 25MHz SPI 下大约需要 82 微秒,加上命令开销不到 100 微秒。只要电源电容能撑住 1 毫秒,就绰绰有余。
5.2 数据校验与坏块管理
MRAM 不像 NAND Flash 有坏块,出厂就是好的,写不坏。但数据在传输过程中可能出错,尤其是 SPI 线长、干扰大的工业环境。所以校验还是必要的。
我一般用 CRC16 对每个数据块做校验,存在块尾。读的时候先算 CRC,对比存储的 CRC,不一致就说明数据坏了,用备份区恢复。
typedef struct { uint32_t magic; uint16_t crc; uint16_t len; uint8_t data[250]; } MRAM_Block_t; uint16_t CalcCRC16(uint8_t *buf, uint16_t len) { uint16_t crc = 0xFFFF; for (uint16_t i = 0; i < len; i++) { crc ^= buf[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x0001) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } } return crc; }坏块管理在 MRAM 上其实不需要,但数据版本管理需要。我习惯在每个块头加一个递增的序列号,读的时候取序列号最大的那个有效块。这样即使写的过程中掉电,也能恢复到上一个有效版本。
5.3 温度与长期稳定性考量
MR25H40CDF 的工业级温度范围是 -40°C 到 +85°C,数据保持时间在 85°C 下超过 10 年,在 125°C 下也有 2 年。这个指标在工业场景里够用了。
但要注意的是,MRAM 的写电流随温度变化。低温下写电流增大,如果电源驱动能力不足,可能写失败。我在 -40°C 的低温箱里测试过,3.3V 供电、25MHz SPI 下写入正常,但如果你把电压降到 2.7V,低温下偶尔会出错。所以工业产品建议电源设计留足余量,LDO 选 3.3V 输出、至少 100mA 能力的。
长期稳定性方面,MRAM 没有电荷泄漏问题,不像 Flash 那样放几年数据就淡了。我有个项目用了 5 年的 MRAM,数据读出来跟刚写进去一样。这一点在需要长期保存校准数据的设备上特别有价值。
6. 调试过程中遇到的典型问题与排查方法
6.1 SPI 通信失败的常见原因
调试 SPI 最怕的就是读出来全是 0xFF 或者 0x00。我整理了一个排查顺序:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读全 0xFF | MISO 没接、片选没拉低、从设备没供电 | 万用表测 MISO 对地电压,逻辑分析仪抓波形 |
| 读全 0x00 | SCK 没输出、MOSI 没接、模式配置错误 | 检查 SPI 初始化代码,确认 CPOL/CPHA |
| 数据偶尔错 | 时钟太快、线太长、干扰大 | 降低 SPI 时钟,缩短走线,加屏蔽 |
| 写不进去 | 没发 WREN、WP 引脚被拉低、地址越界 | 读状态寄存器确认 WEL 位,检查 WP 电平 |
有一次我调一个板子,读出来全是 0xFF,查了半天发现是 MRAM 的 VDD 引脚虚焊了。所以遇到问题先查硬件,再查软件,这个顺序不能反。
6.2 写入数据不生效的排查思路
写不进去的情况,我遇到过几种:
第一种是忘了发 WREN。MR25H40CDF 每次写之前都要发 WREN,写完 WEL 自动清零。如果你连续写多个字节,每个字节都要重新发 WREN。用页写的时候,一次 WREN 可以写一整页。
第二种是 WP 引脚被拉低了。WP 是硬件写保护,低电平有效。如果你在 PCB 上把 WP 直接接地了,那就永远写不进去。检查一下 WP 的电平,正常应该是高。
第三种是地址算错了。MR25H40CDF 是 512KB,地址范围 0x00000 到 0x7FFFF。如果你写 0x80000,地址回卷到 0x00000,数据就写到开头去了。这种 bug 很隐蔽,因为读的时候你读 0x80000 也能读出数据,但其实是 0x00000 的内容。
6.3 高速 SPI 下的信号完整性问题
25MHz 以上的 SPI 时钟,信号完整性问题就开始显现了。我遇到过 SCK 上升沿过冲、MISO 数据眼图闭合的情况。解决办法:
- SCK 线上串 22Ω 电阻,抑制过冲
- MISO 线上加 10pF 电容到地,滤高频噪声
- 缩短走线,最好控制在 5cm 以内
- 用四层板,SPI 线参考完整地平面
如果这些措施都做了还是不稳定,那就降时钟。25MHz 不行就 12.5MHz,12.5MHz 不行就 6.25MHz。工业产品稳定第一,速度第二。
避坑技巧:调试 SPI 的时候,先用低速跑通,再逐步提高时钟。每次提高后跑 24 小时压力测试,写满整个 MRAM 再读出来对比。我一般用 0x55、0xAA、0x00、0xFF 四种模式交替写,这样能覆盖大部分数据模式,容易发现时序问题。
7. 性能实测与方案对比
7.1 读写速度实测数据
我在 STM32F410RB 上跑了一组实测数据,SPI 时钟 25MHz,模式 0:
| 操作 | 数据量 | 耗时 | 等效速率 |
|---|---|---|---|
| 单字节写 | 1B | 3.2us | 312KB/s |
| 页写 | 256B | 98us | 2.6MB/s |
| 连续读 | 256B | 92us | 2.8MB/s |
| 连续读 | 4KB | 1.42ms | 2.9MB/s |
单字节写慢是因为每次都要发 WREN 和 5 字节命令头,开销大。页写效率高很多,256 字节只发一次命令头。连续读的效率最高,因为读命令发完之后时钟不停,数据连续输出。
跟 SPI Flash 对比,MRAM 的优势不在速度,而在写入不需要擦除。Flash 写一个字节要先擦一个扇区(4KB),擦除时间几百毫秒,MRAM 写一个字节就是写一个字节,3.2 微秒搞定。在频繁写小数据的场景里,MRAM 的实际吞吐量比 Flash 高几个数量级。
7.2 与 FRAM、EEPROM、Flash 的对比
| 特性 | MRAM | FRAM | EEPROM | NOR Flash |
|---|---|---|---|---|
| 写入方式 | 直接写 | 直接写 | 直接写 | 先擦后写 |
| 写寿命 | 无限 | 10^14 | 10^6 | 10^5 |
| 写速度 | 纳秒级 | 纳秒级 | 毫秒级 | 毫秒级 |
| 容量 | 4Mbit | 通常 1Mbit 以下 | 通常 1Mbit 以下 | 4Mbit~1Gbit |
| 功耗 | 中 | 低 | 中 | 高 |
| 成本 | 中高 | 高 | 低 | 低 |
MRAM 的定位在 FRAM 和 Flash 之间。容量比 FRAM 大,成本比 FRAM 低,写入速度比 Flash 快几个数量级。在需要频繁写、数据量中等、对可靠性要求高的场景里,MRAM 是很合适的选择。
7.3 功耗表现与电池供电场景
MR25H40CDF 的功耗指标:
- 读电流:8mA @ 40MHz
- 写电流:12mA @ 40MHz
- 待机电流:10uA
- 掉电模式电流:2uA
在电池供电的场景里,MRAM 的待机功耗很低,适合做长期数据记录。写的时候电流大一点,但写的时间极短,平均功耗很低。我算过一笔账:每天写 1000 次,每次写 256 字节,25MHz SPI,写电流 12mA,每次写 98 微秒,一天总写入时间 98 毫秒,平均电流 12mA × 98ms / 86400s ≈ 13.6nA。这个功耗对电池来说可以忽略不计。
相比之下,Flash 写之前要擦除,擦除电流 15mA,擦除时间 300ms,一天擦 1000 次就是 300 秒,平均电流 15mA × 300s / 86400s ≈ 52uA。差了将近 4000 倍。所以在电池供电、频繁写的场景里,MRAM 的功耗优势非常明显。
8. 项目实战:工业数据记录仪的完整实现
8.1 系统架构与任务划分
我拿一个实际做过的工业数据记录仪来举例。这个设备采集 4 路 4-20mA 信号,每 100ms 记录一次,数据存 MRAM,通过 RS485 上传。系统架构很简单:
- STM32F410RB 做主控
- MR25H40CDF 做数据存储
- 4 路 ADC 采集
- RS485 收发器做通信
- 电源管理带掉电检测
任务划分上,我用了一个简单的前后台架构:主循环处理 ADC 采集和 RS485 通信,定时器中断每 100ms 触发一次数据记录,掉电中断处理紧急保存。
8.2 数据记录与循环存储的实现
数据记录用环形缓冲,写指针存在 MRAM 的固定地址 0x00000,数据从 0x01000 开始循环写。
#define LOG_START_ADDR 0x01000 #define LOG_END_ADDR 0x7FFFF #define LOG_ENTRY_SIZE 16 typedef struct { uint32_t timestamp; uint16_t ch0; uint16_t ch1; uint16_t ch2; uint16_t ch3; uint16_t crc; } LogEntry_t; void RecordData(void) { uint32_t wp = ReadWritePointer(); uint32_t addr = LOG_START_ADDR + wp * LOG_ENTRY_SIZE; if (addr + LOG_ENTRY_SIZE > LOG_END_ADDR) { addr = LOG_START_ADDR; wp = 0; } LogEntry_t entry; entry.timestamp = HAL_GetTick(); entry.ch0 = g_adcValues[0]; entry.ch1 = g_adcValues[1]; entry.ch2 = g_adcValues[2]; entry.ch3 = g_adcValues[3]; entry.crc = CalcCRC16((uint8_t*)&entry, sizeof(entry) - 2); MRAM_WritePage(addr, (uint8_t*)&entry, sizeof(entry)); wp++; WriteWritePointer(wp); }写指针的更新要在数据写入之后,这样即使写数据的过程中掉电,写指针没更新,下次上电会覆盖掉那条不完整的数据,不会产生空洞。
8.3 上位机通信与数据导出
RS485 通信我用的是 Modbus RTU 协议,上位机发命令读日志。命令格式很简单:读日志命令 + 起始序号 + 数量。MCU 收到后从 MRAM 读出对应数据,打包返回。
void HandleReadLog(uint16_t startIdx, uint16_t count) { uint8_t buf[LOG_ENTRY_SIZE]; for (uint16_t i = 0; i < count; i++) { uint32_t addr = LOG_START_ADDR + (startIdx + i) * LOG_ENTRY_SIZE; if (addr + LOG_ENTRY_SIZE > LOG_END_ADDR) addr = LOG_START_ADDR + (addr - LOG_END_ADDR); MRAM_ReadData(addr, buf, LOG_ENTRY_SIZE); SendModbusResponse(buf, LOG_ENTRY_SIZE); } }这里要注意地址回卷的处理,环形缓冲的地址计算容易出错,我一般写个专门的函数来处理,不要在业务逻辑里直接算。
8.4 现场部署的注意事项
这个设备在现场跑了两年多,总结了几条经验:
- 电源一定要加 TVS 和滤波,工业现场电网波动大,电源毛刺会干扰 SPI 通信
- RS485 接口要加隔离,不然地环流会烧收发器
- MRAM 的数据要定期备份到上位机,虽然 MRAM 可靠,但设备本身可能损坏
- 外壳要接地,屏蔽线要单端接地,避免地环路
还有一点:MRAM 的 WP 引脚我最终没有用硬件写保护,而是通过软件控制。因为现场调试的时候经常需要改参数,硬件写保护每次都要跳线,太麻烦。软件写保护在参数区加个标志位,需要保护的时候置位,正常运行时清除。
9. 代码组织与可移植性设计
9.1 驱动层与应用层分离
写嵌入式代码最怕的就是驱动和应用搅在一起,换个 MCU 就要重写一遍。我的做法是把 MRAM 驱动做成独立的模块,只依赖几个底层的 SPI 读写函数。
// mram_driver.h typedef struct { void (*cs_low)(void); void (*cs_high)(void); int (*spi_transmit)(uint8_t *data, uint16_t len); int (*spi_receive)(uint8_t *data, uint16_t len); } MRAM_Interface_t; void MRAM_Init(MRAM_Interface_t *iface); void MRAM_Read(uint32_t addr, uint8_t *buf, uint16_t len); void MRAM_Write(uint32_t addr, uint8_t *buf, uint16_t len);这样换 MCU 的时候,只需要重新实现 cs_low、cs_high、spi_transmit、spi_receive 这四个函数,MRAM 驱动的逻辑一行都不用改。我在 STM32F410RB 和另一款国产 MCU 之间移植过,半天就搞定了。
9.2 参数配置的持久化方案
设备参数我用一个结构体管理,存在 MRAM 的 0x00000 地址,双备份加 CRC:
typedef struct { uint32_t magic; uint16_t crc; uint16_t version; float k0, k1, k2, k3; uint32_t sampleInterval; uint8_t reserved[32]; } DeviceConfig_t; void SaveConfig(DeviceConfig_t *cfg) { cfg->magic = 0x5A5A5A5A; cfg->crc = CalcCRC16((uint8_t*)cfg, sizeof(DeviceConfig_t) - 2); MRAM_Write(CONFIG_ADDR_A, (uint8_t*)cfg, sizeof(DeviceConfig_t)); MRAM_Write(CONFIG_ADDR_B, (uint8_t*)cfg, sizeof(DeviceConfig_t)); } bool LoadConfig(DeviceConfig_t *cfg) { DeviceConfig_t cfgA, cfgB; MRAM_Read(CONFIG_ADDR_A, (uint8_t*)&cfgA, sizeof(DeviceConfig_t)); MRAM_Read(CONFIG_ADDR_B, (uint8_t*)&cfgB, sizeof(DeviceConfig_t)); bool validA = (cfgA.magic == 0x5A5A5A5A) && (cfgA.crc == CalcCRC16((uint8_t*)&cfgA, sizeof(DeviceConfig_t) - 2)); bool validB = (cfgB.magic == 0x5A5A5A5A) && (cfgB.crc == CalcCRC16((uint8_t*)&cfgB, sizeof(DeviceConfig_t) - 2)); if (validA && validB) { *cfg = (cfgA.version >= cfgB.version) ? cfgA : cfgB; return true; } else if (validA) { *cfg = cfgA; return true; } else if (validB) { *cfg = cfgB; return true; } return false; }双备份的好处是,写的时候如果掉电,至少有一个备份是完整的。读的时候取版本号大的那个,保证参数是最新的。
9.3 版本升级与数据迁移
产品迭代的时候,参数结构体会变。我的做法是在结构体里留 reserved 字段,新版本用 reserved 里的空间,不改变结构体大小。如果实在要改大小,就在版本号上做文章,读的时候根据版本号做兼容处理。
if (cfg.version == 1) { // 旧版本,字段少,做兼容转换 newCfg.k0 = oldCfg.k0; newCfg.k1 = oldCfg.k1; newCfg.k2 = 0.0f; newCfg.k3 = 0.0f; } else if (cfg.version == 2) { // 当前版本,直接拷贝 newCfg = cfg; }这种兼容处理虽然麻烦,但能保证老设备升级固件后参数不丢。工业设备现场升级不容易,能少跑一趟就少跑一趟。
10. 几个容易被忽略的细节
10.1 上电初始化的时序要求
MR25H40CDF 上电后需要一段时间才能接受命令,数据手册里写的是 tPUW,最大 10ms。我一般在上电初始化的时候加 20ms 延时,确保 MRAM 准备好了再发命令。
void SystemInit(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); HAL_Delay(20); // 等待 MRAM 上电稳定 MRAM_Init(&mramIface); }这个延时看起来不起眼,但少了它,偶尔会出现上电后第一次读 MRAM 失败的情况。尤其是电源上升慢的场合,10ms 可能不够。
10.2 SPI 片选信号的毛刺问题
软件片选有个隐患:GPIO 初始化的时候,片选引脚可能会有一个短暂的毛刺。如果 MRAM 把这个毛刺当成片选有效,就会误触发。解决办法是在 GPIO 初始化的时候先把引脚置高,再配置成输出模式。
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); GPIO_InitStruct.Pin = GPIO_PIN_4; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);先写高电平,再配置成输出,这样引脚从输入变成输出的瞬间就是高电平,不会有毛刺。
10.3 多设备共享 SPI 总线的注意事项
如果板子上有多个 SPI 从设备,共享 SCK、MOSI、MISO,片选分开。这时候要注意:
- 每个从设备的 MISO 输出要加三态缓冲,或者用带片选控制的 SPI 从设备
- 片选切换的时候要留时间,让上一个从设备释放 MISO
- 不同从设备的 SPI 模式可能不同,切换的时候要重新配置 SPI
我一般会在片选切换后加一个 __NOP() 或者几微秒的延时,确保总线稳定。如果从设备多,建议用 SPI 多路复用器,虽然多花几毛钱,但省心。
10.4 数据手册里没写但实际会遇到的问题
数据手册是理想情况下的参数,实际用的时候会遇到各种意外。我遇到过 MRAM 在强电磁干扰下写数据出错的情况,后来在 SPI 线上加了共模电感才好。还遇到过 MRAM 在高温下写电流增大,LDO 带不动导致复位的情况,换了更大电流的 LDO 解决。
这些经验数据手册里不会写,只能靠实际项目积累。我的建议是:工业产品一定要做环境测试,高低温、湿热、振动、EMC 都要过一遍。MRAM 本身很可靠,但系统级的可靠性要靠整体设计来保证。
11. 写在最后的一些个人体会
这套 MRAM + STM32 的方案,我从 2019 年开始用,前后做了五六个项目,有电力监控的,有医疗数据记录的,也有车载的。最大的感受是:MRAM 确实省心。不用管擦除,不用管寿命,不用管掉电,写就完了。代码逻辑简单了,出问题的概率也低了。
但 MRAM 也不是万能的。容量小、成本高,这两点决定了它不适合做大容量存储。我的经验是:关键数据、频繁写的数据、掉电必须保存的数据,用 MRAM;大块的历史数据、日志文件,用 Flash 或者 SD 卡。两者配合,各取所长。
STM32F410RB 这颗 MCU 也值得说一句。它不算新,也不算强,但胜在稳定、便宜、资料多。做工业产品,稳定比性能重要,便宜比先进重要。F410 在这两点上都做得不错。
最后分享一个调试小技巧:如果你怀疑 MRAM 读写有问题,先写一个全盘测试程序,从 0x00000 写到 0x7FFFF,写 0x55、0xAA、0x00、0xFF 四种模式,每写一遍读出来对比。这个测试跑一遍大概 2 秒,能快速定位是硬件问题还是软件问题。我每次新板子回来,第一件事就是跑这个测试,跑通了再调业务逻辑。