做嵌入式项目,特别是和工业设备打过交道的人,应该都被同一个问题折磨过:怎么才能在掉电的一瞬间把关键数据安全存住。电子秤里的校准参数、伺服驱动器当前坐标、电表累计量……传统的做法是 EEPROM,一遇到高频写入就开始头疼。今天要聊的组合,是我在几个项目里反复用下来非常顺手的方案:MR25H40CDF 这颗 4Mbit 串行 MRAM,配 STM32F412ZG 这款 Cortex-M4 单片机,通过 SPI 接口把数据写入和读出来。MRAM 最大的特点就是写入前不需要擦除、读写寿命几乎无限、掉电后数据长期保留。如果你正在做需要频繁保存参数、记录故障日志、又对可靠性有要求的嵌入式产品,这篇内容值得耐心看完,我会从选型逻辑一直写到驱动代码和踩坑记录。
这类题目在嵌入式面试八股里也经常被翻出来,“MRAM vs Flash vs EEPROM 怎么选”“SPI 写时序怎么处理”,但真到产线上跑起来,你会发现现实要比八股复杂得多。我会尽量把最贴近实战的那一面讲清楚。
1. 选型时我把 EEPROM、Nor Flash 和 MR25H40CDF 拉出来比了一圈
先说项目背景。当时做的是一个现场控制器,每台设备每天都要记录运行状态、累计动作次数、每次故障的触发时刻。现场反馈故障率偏高,拆回来分析时发现板子上的 EEPROM 写坏了。用户一天开机几十次,每次开机要写计数,关机要写时长,再加上运行时参数频繁调整,一块普通的 I2C EEPROM,标称擦写寿命 100 万次,看起来很多,实际在这种强度下撑不过两三年。
我后来做选型时,把常见方案拉出来对比了一下:
| 指标 | I2C EEPROM | SPI Nor Flash | SPI MRAM(MR25H40CDF) |
|---|---|---|---|
| 写入前是否需要擦除 | 否,但页面写入有限制 | 必须,先擦后写 | 否 |
| 典型字节级写入周期 | 3~5ms | 页编程几十微秒,但擦除要几十到上百毫秒 | 无额外等待,随 SPI 时钟完成 |
| 擦写寿命 | 100 万次级别 | 大多 10 万次左右 | 标称 10^15 次以上,基本不用考虑磨损 |
| 数据保持 | 常温下 10 年以上 | 10~20 年 | 20 年级别 |
| 容量 / 成本 | 小容量便宜 | 大容量便宜 | 每比特成本偏高 |
| 抗辐射 / 高温能力 | 一般 | 一般 | 常用于电力、军工场合 |
这个表格看完,MRAM 的优势已经很直接了。它解决的是“频繁写、不能丢、不想等”这三个问题。
1.1 为什么 EEPROM 先被淘汰
EEPROM 在参数保存领域用了很多年,但它有两个硬伤。
第一,写周期太长。一个字节的写操作往往要几毫秒,如果掉电时正赶上写入,代码还没跑完,电源已经没了。第二,写入寿命有限。标称 100 万次听上去不少,但工程上要留降额,我习惯按 30%~50% 评估。高频参数记录场景下,100 万次真的撑不了太久。
有的工程师会说,那我加个磨损均衡,把写地址轮换不就行了?话是没错,但 EEPROM 容量本来就不大,地址轮换只是把寿命延长几倍,并不能改变“它本质上有磨损”这个事实。对工业设备来说,维护人员希望的是“装上就别因为存数据这事再来一遍”。
1.2 Nor Flash 的擦除机制为什么不适合高频日志
Nor Flash 的优势是容量大、价格低,但它的写入逻辑和 MRAM 完全不一样。Nor Flash 必须先擦除后写入,而擦除以“扇区”为单位。你要改 2 个字节,也得先把这个扇区的内容读出来、擦掉、再整个写回去。
这个机制导致两个问题:一是写入路径很长,意外掉电容易只擦不写,数据直接丢;二是擦写寿命同样有限,一般一万到十万次。对日志型应用来说,如果每 30 秒写一条记录,一天就是 2880 次,一块寿命 10 万次的 Nor Flash 个把月就废了。
1.3 MRAM 值得关注的代价
MR25H40CDF 不是没有缺点。它的容量只有 4Mbit,也就是 512KB;每比特成本也要比同等容量的 Nor Flash 贵不少。所以它不适合用来存大文件、固件升级包、图片素材这类东西。
它适合的是“关键性数据”:
- 运行参数:校准值、PID 参数、设备编号
- 高频计数值:动作次数、累计运行时间
- 故障日志:每次故障的现场快照
- 掉电前的最后状态:这样开机才能恢复
从物理原理上说,MRAM 用的是磁隧道结(MTJ)存储数据,靠磁性方向来表示 0 和 1,而不是像 Flash 那样靠浮栅电荷。电荷会泄漏、隧穿介质会磨损,磁性的翻转则没有这个疲劳问题。这就是它能达到极高写寿命的根本原因。
我也对比过 FRAM。FRAM 的写寿命同样很好,功耗也更低,但在高温范围和抗辐射能力上,MRAM 通常更有优势。工业现场经常有静电、浪涌、高温环境,MRAM 在这些方面更让我放心。所以最终定了 MR25H40CDF。
2. MR25H40CDF 的规格细节和 SPI 指令集:照着抄就能用对
先把这颗芯片的基础参数理清楚。
MR25H40CDF 是 Everspin 的串行 SPI MRAM,容量 4Mbit,十六进制地址范围就是 0x00000 到 0x7FFFF。工作电压 3.0V 到 3.6V,典型情况接 3.3V。它是一颗 8 引脚器件,引脚定义和 SPI Nor Flash 非常像,包含 CS#、SCK、SI、SO、WP#、HOLD#、VDD、VSS。这很重要,因为这意味着你在 PCB 上可以把它当作一颗 SPI Flash 来布局,很多工程习惯可以直接复用。
根据数据手册,SPI 时钟最高可以到 40MHz 上下,具体以你拿到的批次手册为准。我实际工程中很少顶着上限跑,一般用 25MHz 或 12.5MHz,后面讲初始化时我再展开。
2.1 指令表怎么记
MR25H40CDF 的指令集和常见的 SPI Flash 有重合,但也有几个关键区别。我列一下最常用的几条:
| 指令 | 操作码 | 作用 |
|---|---|---|
| WREN | 0x06 | 写使能,写类指令前必须发 |
| WRDI | 0x04 | 写禁用 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 标准读,地址后直接读数据 |
| FSTRD | 0x0B | 快速读,地址后加一个 dummy 字节 |
| WRITE | 0x02 | 写数据 |
| RDID | 0x9F | 读厂商/设备 ID |
和 Flash 最大的区别是:MRAM 没有擦除指令。你不用发 Sector Erase、Block Erase 那一套。这既是优点,也是很多人踩坑的起点——有人会习惯性地把 Flash 驱动里的擦除流程搬过来,结果擦除指令被 MRAM 当成无效操作,紧接着又把状态寄存器弄乱了。
2.2 读指令和写指令的关键差异
读数据,流程是 CS# 拉低,发 READ 操作码,再发 3 字节地址,然后连续读数据,最后 CS# 拉高。这个流程和 SPI Flash 几乎一样。
写数据,流程是 CS# 拉低,发 WRITE 操作码,再发 3 字节地址,然后连续写数据,最后 CS# 拉高。这里有一个很多人容易忽略的点:MRAM 的 WRITE 指令没有“页”的概念。
SPI Flash 的 Page Program,一次最多写 256 字节,超过页边界会回卷到页开头,所以驱动里都要做分页处理。MR25H40CDF 不需要,一条 WRITE 指令可以从地址 0 一直写到地址 0x7FFFF,前提是 CS# 在整条指令期间保持低电平。这意味着写日志时,驱动代码可以简单很多,不用维护“当前页还剩多少字节”这种状态。
另外,MRAM 写入完成后没有额外等待时间。EEPROM 写完一个字节要等几毫秒内部编程完成,Nor Flash 页编程后也要等 tPP,MRAM 在 CS# 拉高的瞬间数据就进去了,直接可以发下一条指令。这是 MRAM 在“写频繁”场景下最舒服的一点。
2.3 状态寄存器与 WREN 的关系
状态寄存器是 8 位的,常见位包括:
- bit1 WEL:写使能锁存
- bit4/bit5 BP0/BP1:块保护位
- bit7 WPEN:写保护引脚使能
读状态寄存器,用 RDSR 指令;写状态寄存器,用 WRSR 指令。
这里最关键的是 WEL 位。MCU 发 WREN 指令后,WEL 置 1,然后才能执行 WRITE 或 WRSR。一旦 CS# 拉高,完成了一条写类指令,WEL 会自动清 0。也就是说,下次要写数据,必须重新发一次 WREN。
很多新手第一次移植 MRAM 驱动时,写了 WRITE 指令发现数据没进去,最后查到原因往往是:上一条指令执行完了 WEL 已经清零,他没有重新 WREN。我自己一开始也犯过这个毛病。
3. STM32F412ZG 侧接线与 SPI 初始化:从 CubeMX 到 25MHz 稳定运行
这一节讲硬件连接。STM32F412ZG 主频 100MHz,Cortex-M4 内核,带多个 SPI 外设,对接 MRAM 非常合适。我习惯用 SPI1 来做这个事,因为 SPI1 挂在 APB2 总线上,时钟频率最高,能够吃满 MRAM 的时钟上限。
3.1 硬件连接:别让 HOLD/WP 悬空
先给一张接线表:
| MR25H40CDF 引脚 | STM32F412ZG 引脚 | 说明 |
|---|---|---|
| CS# | PB0(GPIO 输出) | 手动控制片选 |
| SCK | PA5(SPI1_SCK) | 时钟 |
| SI | PA7(SPI1_MOSI) | 主机输出 |
| SO | PA6(SPI1_MISO) | 主机输入 |
| WP# | 3.3V,经 10k 上拉 | 写保护,不用时拉高 |
| HOLD# | 3.3V,经 10k 上拉 | 保持线,不用时拉高 |
| VDD | 3.3V | 靠近引脚放 100nF+10uF 去耦 |
| VSS | GND | 地 |
这里我要重点强调一下 HOLD# 和 WP#。
这两个引脚如果悬空,悬浮电平很容易受外部干扰,从而导致 SPI 指令被硬件打断。我早期有一个板子,现场偶发性数据异常,查了很久,最后发现就是 HOLD# 悬空导致的。线缆振动、继电器吸合产生的电磁干扰从引脚串进去,芯片直接进入 hold 状态,SPI 时钟来了它也不响应,数据自然就坏了。
所以接上拉电阻不是可选项,而是必选项。如果你将来打算用块保护功能,WP# 也需要能够被 MCU 控制,不能直接焊死在 3.3V。不过绝大多数时候,把 WP# 用 10k 上拉到 3.3V 就够了。
3.2 SPI 时钟与初始化配置
在 STM32CubeMX 里,我这样配置 SPI1:
- SPI1 设为 Full-Duplex Master
- 数据大小:8 bit
- 帧格式:MSB First
- CPOL / CPHA:两者都设为 Low 或都设为 High 都可以,MRAM 支持 SPI Mode 0 和 Mode 3。习惯上用 Mode 0。
- NSS:设为 Software
- Prescaler:分频 4,也就是 25MHz
STM32F412ZG 的 APB2 最高 100MHz,SPI1 挂在这个总线上。分频 4 就是 25MHz,满足 MRAM 40MHz 上限。如果你的 PCB 布线比较长、连接器转接比较多,建议保守一点用分频 8,也就是 12.5MHz。
关于时钟频率,我补充一点:SPI 分频只能取 2、4、8、16 这些 2 的幂次,所以你要追求精确的 24MHz 是做不到的。25MHz 和 12.5MHz 是实际工程中最常见的两档。写代码时,建议把 SPI 波特率相关的配置放在 CubeMX 里做,后续要调整时钟树时,生成的代码会自动跟着改。
NSS 选择 Software,这个决定很关键。如果使用硬件 NSS,在 DMA 传输或者连续多次传输时,片选信号可能会提前变化,导致 MRAM 把一条连续指令拆断。MRAM 的所有操作都依赖 CS# 在整条指令期间保持低电平,所以我一直认为手动 GPIO 控制 CS# 是最稳的做法。
SPI 初始化代码,CubeMX 生成的函数基本够用:
void MX_SPI1_Init(void) { 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_4; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 10; HAL_SPI_Init(&hspi1); }CubeMX 的生成代码每个版本的字段名略有不同,但核心参数就是上面这些。重点是分频、模式、软 NSS。
4. 完整驱动代码:从 WREN 到 CRC 校验的一次性讲清
下面给出我自己项目里用的一套精简驱动。它不追求花哨,只求稳定。
4.1 底层 SPI 封装
#include "main.h" extern SPI_HandleTypeDef hspi1; #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) #define MRAM_SIZE 0x80000 // 512KB #define MRAM_ADDR_MASK 0x7FFFF static uint8_t mram_spi_xfer(uint8_t byte) { uint8_t rx = 0; // 超时时间给 100ms,足够覆盖极端情况 if (HAL_SPI_TransmitReceive(&hspi1, &byte, &rx, 1, 100) != HAL_OK) { return 0xFF; } return rx; }为什么用TransmitReceive而不是Transmit?因为 SPI 是全双工总线,你每发一个字节,MISO 上也会同时移出一个字节。读取 MRAM 数据时,主机也必须持续产生时钟,最简单的办法就是发送 0x00 或 0xFF 占位字节,同时把收到的数据保存下来。TransmitReceive正好一次完成这两个动作。
4.2 写使能与状态寄存器读取
static uint8_t mram_read_status(void) { uint8_t status = 0; MRAM_CS_LOW(); mram_spi_xfer(0x05); // RDSR status = mram_spi_xfer(0x00); // 读 1 字节 MRAM_CS_HIGH(); return status; } static void mram_write_enable(void) { MRAM_CS_LOW(); mram_spi_xfer(0x06); // WREN MRAM_CS_HIGH(); }每次写操作前,我都建议检查 WEL 位是否真正置位。有的场合下 SPI 引脚接触不良,或者 MCU 配置有问题,WREN 指令没有真正发出去,后续写操作自然失败。
#define MRAM_STATUS_WEL 0x02 static int mram_check_wel(void) { uint8_t status = mram_read_status(); if ((status & MRAM_STATUS_WEL) == 0) { return -1; } return 0; }4.3 读数据与写数据
int mram_read_bytes(uint32_t addr, uint8_t *buf, uint32_t len) { if (addr > MRAM_ADDR_MASK || len == 0) { return -1; } if ((addr + len) > MRAM_SIZE) { return -1; } MRAM_CS_LOW(); mram_spi_xfer(0x03); // READ mram_spi_xfer((addr >> 16) & 0xFF); mram_spi_xfer((addr >> 8) & 0xFF); mram_spi_xfer(addr & 0xFF); for (uint32_t i = 0; i < len; i++) { buf[i] = mram_spi_xfer(0x00); } MRAM_CS_HIGH(); return 0; } int mram_write_bytes(uint32_t addr, const uint8_t *buf, uint32_t len) { if (addr > MRAM_ADDR_MASK || len == 0) { return -1; } if ((addr + len) > MRAM_SIZE) { return -1; } mram_write_enable(); if (mram_check_wel() != 0) { return -2; } MRAM_CS_LOW(); mram_spi_xfer(0x02); // WRITE mram_spi_xfer((addr >> 16) & 0xFF); mram_spi_xfer((addr >> 8) & 0xFF); mram_spi_xfer(addr & 0xFF); for (uint32_t i = 0; i < len; i++) { mram_spi_xfer(buf[i]); } MRAM_CS_HIGH(); return 0; }这里要说一下地址发送。MR25H40CDF 的有效地址是 19 位,但 SPI 协议中地址固定是 3 字节,高字节只有低 3 位有意义。有些网上抄来的驱动只发送 2 字节地址,这在地址大于 0xFFFF 之后就会错位。我强烈建议按上面的写法,把 24 位地址的三字节全部发出去,高位强制为 0,不要省略。
写入后不需要轮询忙状态,也不要去等 tPP 之类的延时。CS# 拉高,写就完成了。但要注意,一次写完成后 WEL 自动清零,下一次写之前要重新 WREN。
4.4 用块保护把配置区锁起来
有些数据,比如设备序列号、量产校准参数,写入频率极低,但绝不能乱掉。为了防程序跑飞或者外部干扰误写,可以用状态寄存器里的 BP0/BP1 把配置区保护起来。
#define MRAM_STATUS_WPEN 0x80 #define MRAM_STATUS_BP1 0x20 #define MRAM_STATUS_BP0 0x10 int mram_write_status(uint8_t status) { mram_write_enable(); if (mram_check_wel() != 0) { return -2; } MRAM_CS_LOW(); mram_spi_xfer(0x01); // WRSR mram_spi_xfer(status); MRAM_CS_HIGH(); return 0; }将状态寄存器写成 0x30,也就是 BP0 和 BP1 都为 1,可以保护全部地址空间。此后除非先解除保护,否则常规 WRITE 指令无法写入。
解除保护的流程是:先将 WP# 引脚拉高(如果 WPEN 为 1 且 WP# 为低,WRSR 本身会被忽略),然后 WREN,再写状态寄存器把保护位清掉。这个流程容易出错,我建议在产线烧录程序里专门做一个“配置区解锁”函数,避免在主程序里频繁切换保护状态。
提示:WRSR 指令执行完成后,WEL 同样会被清零。所以写完状态寄存器后如果还要写数据,必须再发一次 WREN。
4.5 CRC16 数据完整性校验
工业环境不比实验室,数据在写入、传输、保存过程中要防偶然错误。我的做法是给每条日志记录加一个简单的帧格式:
| 字段 | 长度 | 说明 |
|---|---|---|
| Magic | 2 字节 | 固定值,用于同步 |
| Sequence | 4 字节 | 序号 |
| Length | 2 字节 | 有效数据长度 |
| Payload | N 字节 | 实际数据 |
| CRC16 | 2 字节 | 对前面所有字节的 CRC 校验 |
CRC16 的生成多项式用经典的 CCITT,也就是 0x1021。
uint16_t crc16_update(uint16_t crc, uint8_t byte) { crc ^= (uint16_t)byte << 8; for (int i = 0; i < 8; i++) { if (crc & 0x8000) { crc = (crc << 1) ^ 0x1021; } else { crc = crc << 1; } } return crc; }写入时,先计算 CRC,再把整包数据通过一次mram_write_bytes写进去。读出时,校验 CRC,失败就放弃这条记录,或者从镜像区恢复默认值。
5. 工业级可靠性设计:掉电、写保护、日志镜像这些细节别漏
能读写只是第一步,真正让产品在现场扛得住的,是掉电保护和数据冗余设计。这一节相当重要,我提几个我在产线上验证过的做法。
5.1 掉电保存的工程做法
STM32F412ZG 内置可编程电压检测器 PVD。当 VDD 降到设定阈值以下时,会触发中断。我用这个功能来做掉电保存。
具体思路是:把 PVD 阈值设置在一个比正常供电电压稍低的位置,例如选择 2.9V 附近。这样外部电源掉电后,在稳压器输出真正跌到 MCU 无法工作之前,系统还能留出几毫秒到几十毫秒的余量。这个时间足够把关键参数写入 MRAM。
void HAL_PWR_PVDCallback(void) { // 掉电标志置位,主循环里看到这个标志就执行保存 power_fail_flag = 1; }在中断回调里,我只放了一个标志位,没有直接执行 SPI 写入。原因是掉电瞬间电源环境比较恶劣,中断里阻塞等待 SPI 反而增加不确定性。我的做法是在主循环中检测到掉电标志后,立刻暂停其他任务,执行一次精简的 MRAM 写入,只保存最关键的数据,比如当前计数值和运行状态。
硬件上,还需要在电源输入端加一个大一点的储能电容,维持掉电检测到真正断电之间的时间。具体容量根据你保存数据量来算,一般几百微法到几毫法都有人用。
5.2 双镜像区与日志循环
如果产品的重要数据是不断更新的,一种很实用的方案是双镜像区。
配置两个相同的区域 A 和 B,每次写入时,先写 A 并写入递增的序列号;下次写 B,再下次写 A。读取时,比较 A 和 B 的序列号,取序列号大且 CRC 正确的那一份作为最新数据。
这样做的意义在于:即使写入一半突然掉电,导致其中一个镜像区损坏,另一个镜像区仍然是完整的。系统重启后能够自动回退到上一次正确的状态,不会因为一次掉电就把整条参数清空。
日志类数据则适合用环形缓冲。MRAM 没有页边界限制,这给环形缓冲带来了很大方便。你可以把 512KB 的一部分划分为日志区,写指针循环走动,每一条记录头部带时间戳、长度、CRC。由于 MRAM 写寿命极高,不用担心环形缓冲反复覆盖同一段地址导致磨损,这在 Flash 方案里是想都不敢想的。
5.3 电磁干扰与生产注意事项
工业现场最头疼的往往是静电和继电器产生的干扰。
MRAM 的磁隧道结器件对静电放电比较敏感。生产组装时,操作人员要佩戴防静电手环,焊接台和测试设备要可靠接地。这一点如果你的产品是委托 SMT 贴片,一定要在工艺文件里写明。
PCB 布局上,我建议:
- MRAM 尽量靠近 STM32F412ZG,SPI 走线短而直
- 电源引脚旁放 100nF 陶瓷电容,再并一个 10uF 钽电容或电解电容
- WP# 和 HOLD# 的上拉电阻靠近 MRAM 引脚放置
- SPI 的 SCK 和 MOSI 不要长距离平行走线,减少串扰
另外,如果控制器要过认证或者现场干扰严重,MRAM 的数据区可以考虑软件层面的重试机制。读取数据失败一次就重新读取,连续读取三次结果一致才采用。
6. 实测踩坑记录和我在产线上最终采用的读写策略
最后这部分,我把自己踩过的坑集中列一下。这里面每一条都是真金白银换来的经验。
6.1 坑一:HOLD 引脚悬空导致数据偶发损坏
最早一版设计,HOLD# 和 WP# 没有接上拉。实验室测试都正常,到了客户现场就出现偶发性数据错乱。一开始怀疑 SPI 时序,后来用示波器抓波形,发现 HOLD# 引脚上有毛刺,芯片时不时进入 hold 状态。把两个引脚都加上 10k 上拉电阻之后,问题再没出现过。
这个教训让我养成了习惯:凡是有 HOLD 引脚的外设,即使不用这个功能,也一定把引脚固定到无效电平。
6.2 坑二:把 Flash 的擦除逻辑带进了 MRAM 驱动
当时团队里有同事是从 SPI Flash 项目转过来的,他写驱动时顺手把 Sector Erase 的代码放在了写数据之前。MRAM 不响应擦除指令,但状态寄存器被后续操作搞乱,导致读出来的数据不对。
说到底,MRAM 驱动的核心思维和 Flash 完全不同:不需要擦除,不需要分页,不需要等待。写驱动之前,先把“擦除”“页缓冲”“busy 轮询”这些词从脑子里清空。
6.3 坑三:硬件 NSS 和 DMA 配合出问题
有一版我图省事,片选用了 STM32 的硬件 NSS。单独读写时一切正常,一旦把 spi 和 DMA 配合起来做大批量日志写入,偶尔出现首字节丢失。调试了很久才明白,硬件 NSS 在 DMA 传输的边界处理上并不总是符合 MRAM 对 CS# 电平的严格要求。
后来我全面改成 GPIO 手动控制 CS#,问题消失。现在我所有的 SPI 存储器件,片选一律用普通 GPIO。
6.4 坑四:地址发错导致读写错位
网上有些代码发送 2 字节地址,因为很多小容量 SPI NOR 只需要 16 位地址。但 MR25H40CDF 是 512KB,需要 19 位地址,对应 3 字节地址字段。如果照抄 2 字节地址的驱动,地址一旦超过 0xFFFF,读写就错位。
我的建议是无论芯片容量多大,驱动里都固定发送 3 字节地址,高字节补零。这样既兼容小容量,也不会在换大容量芯片时翻车。
6.5 我最终采用的读写策略
综合以上经验,我现在的新项目里,MR25H40CDF 的使用策略是比较固定的。
运行参数区,划分 0x00000 到 0x0FFFF,共 64KB。所有配置参数打包成结构体,每次修改后立即写入,同时更新序列号和 CRC。写完后设置块保护,防止普通运行代码误写。
日志区,划分 0x10000 到 0x7FFFF,共 448KB。MCU 在 RAM 里缓存 4KB 日志,攒满后通过 DMA 一次性写入 MRAM。因为 MRAM 写速度快,整包写入加上协议开销大概一帧的时间,完全不会拖累主循环。
开机自检时,先发 RDID 指令确认 MRAM 通信正常,然后读取两个镜像区,比较序列号和 CRC,选择有效的那份加载。
提示:如果你是第一次调 MRAM,建议先用轮询方式把读写跑通,再考虑 DMA。直接上 DMA 如果出了时序问题,排查难度会翻倍。
实测下来,在 25MHz SPI 时钟下,512KB 全片写入的理论时间约 170ms 左右,实际加上地址和指令开销也不会超过 200ms。这在 Flash 时代是无法想象的。MR25H40CDF 和 STM32F412ZG 这套组合,我用了几年,最大的感受就是“省心”。不需要操心磨损均衡,不需要操心擦除中断,不需要操心写保护被意外触发,只要把 SPI 时序捋顺,它就是一个简单、可靠、近乎无限耐写的存储设备。
最后再分享一条自己的习惯:每拿到一款存储芯片,先花一个小时把数据手册里的时序图和指令表完整读一遍,再写驱动。很多问题,本质上都是“没看手册”四个字。