ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MRAM替代Flash:工业数据记录存储方案与SPI驱动实践

MRAM替代Flash:工业数据记录存储方案与SPI驱动实践 MRAM 这颗芯片我第一次用的时候说实话是带着怀疑的。那会儿我在做一台工业设备的运行数据记录要求设备每秒写一条状态记录掉电后不能丢现场还要求连续运行三五年不换存储介质。一开始图省事直接上了 SPI NOR Flash结果跑到九万多次擦写的时候扇区就开始出现零星失败。换 EEPROM 更难受容量只有几十 KB一条记录几十字节没几天就写满了。最后换成 Everspin 的 MR25H40CDF 配合 Microchip 的 PIC32MZ1024EFK144才算把这个问题彻底按住了。这篇文章不是数据手册的翻译也不准备讲那种新建工程点亮板子的入门教程。我打算把我从选型、硬件设计、SPI 驱动到掉电测试的完整过程写出来包括中间踩过的坑和最后的成品代码。适合正在做工业控制器、数据采集记录、参数存储这类项目的嵌入式工程师参考尤其是那些已经被 Flash 擦写寿命和 EEPROM 容量逼疯的同行。1. 为什么是 MRAM工业存储选型绕不开的三个硬指标1.1 Flash 和 EEPROM 在频繁小数据写入上的先天缺陷NOR Flash 的问题是它要擦除。每次写入之前必须先按扇区擦除一个扇区典型 4KB 或者 64KB。你想改里面一个字节逻辑上得先把整个扇区搬到 RAM修改擦除再写回去。这个读-改-擦-写的过程既慢又危险一旦中途掉电整个扇区的数据完整性就没了。更致命的是寿命。普通 SPI NOR Flash 的擦写寿命大概在十万次左右听起来好像不少但你把时间轴拉长算一下工业设备一分钟记录一条数据一天就是 1440 次一年就是 52.5 万次。一个 Flash 扇区十万次寿命几个月就顶到了。就算你有几十个扇区轮换着用在持续记录的场景下也只是把问题往后拖了几年而且 Flash 的坏块管理还得自己做。EEPROM 的寿命好一点能做到一百万次擦写但容量太小了常用的 24C256 也就 32KB存不了多少现场日志。而那种大容量的 EEPROM 价格又离谱性价比很低。1.2 MRAM 的物理原理为什么它能绕开这些坑MRAM 的原理用一句话讲数据存在磁阻状态里。芯片内部有磁隧道结一层磁性材料的磁化方向固定另一层可以编程翻转两层平行时电阻低反平行时电阻高读取的时候靠测电阻区分 0 和 1。这个机制带来的好处是本质性的写数据不需要先擦除直接改就行也不存在写放大问题寿命接近无限Everspin 的数据手册标的是 10 的 14 次方量级工程上可以直接当成不用考虑磨损数据保存在磁状态里断电不丢不需要电池保持所以 MRAM 的本质更像一块掉电不掉数据的 SRAM而不是 Flash 那种需要擦除的块设备。1.3 MR25H40CDF 的具体参数MR25H40CDF 是 Everspin 的 4Mbit SPI 接口 MRAM容量换算过来是 512KB组织方式为 512K x 8。供电 3.3V8 引脚 DFN 封装工业级的温度范围是 -40 到 105 摄氏度SPI 模式支持 Mode 0 和 Mode 3最高时钟在工业级下通常是 40 MHz数据保持时间标称至少 20 年。我选它的原因非常直接512KB 容量不大不小放运行日志、报警记录、系统参数都够了SPI 接口省引脚任何一个 144 脚单片机都能轻松接上工业温度范围涵盖了我的设备工作条件最重要的是写数据不需要等擦除这个特性让驱动代码简单了不止一个量级。1.4 不同存储方案的直观对比项目MR25H40CDF (MRAM)典型 SPI NOR Flash典型 SPI EEPROM电池备份 SRAM写前擦除不需要需要按扇区擦除不需要不需要字节级随机写支持不支持支持支持擦写寿命约 10^14 次约 10^5 次约 10^6 次无限但电池耗尽后失效典型写一个字节耗时SPI 时钟决定纳秒级写入擦除加写入毫秒到几百毫秒毫秒级编程周期纳秒级掉电数据保持20 年以上10 年以上20 年以上依赖电池容量512KB16MB最大几十 KB几 MB维护成本无无无需要定期换电池从这张表能看出来MRAM 并不是全面碾压它的短板是容量和价格。所以正确的选型逻辑是把需要频繁改写、掉电不能丢、容量要求不大的关键数据放进 MRAM把海量记录、视频、固件升级包这类数据放到 NAND 或 SD 卡上。我在这个项目里就是这么分的工。2. MR25H40CDF 的命令体系一颗忘记掉电的内存怎么操作2.1 引脚全景和典型的连接方式MR25H40CDF 一共 8 个引脚除了电源和地剩下六个都跟通信有关CS#、SCK、SI、SO、WP#、HOLD#。CS# 是片选整个命令过程必须保持低电平命令结束后拉高SCK 是时钟SI 是数据输入接单片机的 SDOSO 是数据输出接单片机的 SDIWP# 是写保护引脚配合状态寄存器可以硬件上锁存储区HOLD# 是暂停引脚拉低的时候芯片会暂停通信保持当前状态有一个非常容易忽略的点HOLD# 如果悬空在工业现场容易被干扰拉低芯片就莫名其妙卡住了。我后来的做法是在 HOLD# 上接一个 10kΩ 电阻上拉到 VCC 芯片直接不用这个功能。2.2 核心命令一览命令指令码作用WREN0x06写使能每次写操作前必须先发WRDI0x04写禁止READ0x03从指定地址读数据FSTRD0x0B快速读带一个 dummy 字节用于更高时钟频率WRITE0x02从指定地址开始写数据RDSR0x05读状态寄存器WRSR0x01写状态寄存器使用习惯上跟 SPI NOR Flash 非常接近所以如果你之前写过 Flash 驱动上手会很快。2.3 写数据不需要等 WIP这是 MRAM 最大的性格差异普通 SPI Flash 和 EEPROM 写入的时候都要等一个内部编程周期。Flash 等的是擦除和编程完成EEPROM 等的是电荷注入完成这个时间通常是几个毫秒。所以驱动代码里写完一个字节之后要不断轮询状态寄存器的 WIP 位直到写完成。MRAM 完全不是这个玩法。它的写入是物理上瞬间完成的也就是说你通过 SPI 把数据发出去CS# 拉高的那一刻数据已经真正写进存储阵列了。没有内部编程周期没有 WIP 等待也不需要为了等写入完成而延迟 CS# 的拉高。但有一个例外写状态寄存器 WRSR 命令之后WIP 位会短暂置位这时候需要等待它清零。这可能是 MRAM 里唯一需要你等的地方。这个差异对代码设计影响非常大。我一开始写驱动时还沿用 Flash 的习惯写完数据后去轮询 WIP发现 RDSR 读回来的 WIP 位要么永远为 0要么根本不代表什么。后来仔细看数据手册才明白MRAM 的 WIP 位只和状态寄存器的写入有关和数据区的写入没有关系。2.4 地址与连续读写MR25H40CDF 的存储空间是 512KB 线性排列地址从 0x000000 到 0x7FFFF一共 24 位地址。发送 READ 或 WRITE 命令时先发 3 个字节的地址高位在前然后连续传数据。支持连续读写。也就是说你可以在一次 CS# 低电平的会话里从任意地址开始连续读或写任意长度的数据芯片内部地址会自动递增。所以驱动层完全可以把它当内存用不用考虑页边界这种东西。这跟 NOR Flash 按 256 字节页写入的限制完全不同。MRAM 可以一个字节一个字节地写也可以一次写几百字节只要你愿意。2.5 WREN 和写保护为什么写数据之前必须加一条命令MR25H40CDF 继承了 SPI 存储器的写保护机制。每次写数据之前必须先发送 WREN 命令把内部写使能锁存器置 1否则 WRITE 命令会被静默忽略。WREN 的时序要求是CS# 拉低发 0x06CS# 拉高。注意 CS# 必须先拉高一次写使能锁存才真正生效然后再拉低开始 WRITE。另外WP# 引脚和状态寄存器中的写保护位配合可以把整片或部分存储区锁死。这个特性我用在了出厂参数区的保护上出厂校准参数写进去之后把 WP# 拉低加状态寄存器配置锁死这样即使现场软件 Bug 也不至于把校准数据抹掉。调试阶段我直接把 WP# 用 IO 控制方便解锁。3. PIC32MZ1024EFK144 侧的 SPI 初始化把 200 MHz 的 MIPS 拉来当存储搬运工3.1 为什么要选 SPI2 而不是 SPI1PIC32MZ1024EFK144 的 SPI 资源非常充足有 6 个 SPI 模块。但我一开始还是吃了引脚映射的亏SPI1 的默认引脚和 ICSP 调试烧录口有部分冲突调试器占用的引脚不能随心所欲地接外设。后来我把 MRAM 接到了 SPI2 上引脚规划就自由多了。选哪个 SPI 模块的核心原则是先查清楚每个引脚的复用功能避开调试口、主振荡器引脚和以太网 PHY 的接口。144 脚的封装虽然引脚多但真正适合走高速 SPI 信号、又不和其他外设打架的引脚组合还是要花点时间在地图上画一画的。3.2 PPS 引脚映射PIC32MZ 特殊的一步PIC32MZ 的外设引脚不是固定的而是通过 PPS外设引脚选择系统进行重映射。就是说你想让 SPI2 的 SCK 出现在哪个引脚上需要显式配置芯片不会自动帮你接好。配置分两类输出信号SCK2、SDO2配置对应引脚的 RxyR 寄存器输入信号SDI2配置 SDI2R 寄存器把某个引脚指定为 SDI2 的输入来源这部分也是整个初始化里最容易配错的地方。如果你用的是 MPLAB X 的 MCC 工具勾选引脚后它会自动生成映射代码非常省事。如果你像我一样在旧工程里手写驱动就一定要查你手里那颗芯片的具体数据手册找到Pin Mapping表格确认每个功能对应的编号。下面代码里的映射值是示例占位千万不能照抄// 以 PIC32MZ1024EFK144 为例把 SPI2 的引脚映射到特定端口 // 注意下面的 RxyR/SDI2R 赋值是示例实际值必须以你的芯片手册为准 // 使用 MCC 生成的话这里的配置会被自动写好 SPI2BCON 0; // 默认配置 SDI2R 0x%?; // 示例SDI2 输入映射到某个 RP 引脚 /* 例如把 SCK2 映射到 RPA5把 SDO2 映射到 RPA6 */ RPA5R 0x?; // 示例SCK2 输出功能编号 RPA6R 0x?; // 示例SDO2 输出功能编号我知道这种代码看起来很不确定性但 PPS 就是这么个东西具体的数值必须和具体封装的引脚表一一对应。没有 MCC 的话把手册翻出来一个一个对通常二十分钟能搞定。3.3 SPI 时钟计算PIC32MZ 的 SPI 时钟源来自 PBCLK 分频波特率公式是Fsck Fpb / (2 * (SPIxBRG 1))假设 SYSCLK 是 200 MHzPBCLK3SPI 外设的时钟总线配成 100 MHz那么要想得到 10 MHz 的 SCK可以倒推10 MHz 100 MHz / (2 * (BRG 1)) BRG 1 5 BRG 4所以我初始化里写了SPI2BRG 4。为什么要先跑 10 MHz 而不是直接上 40 MHz因为工业现场的走线长度、连接器接触阻抗、PCB 过孔都会影响高速 SPI 的信号质量。先把功能跑通再慢慢提速这是嵌入式调试的铁律。等逻辑分析仪确认波形没问题了再把 BRG 往下调。3.4 完整的 SPI 初始化代码void MRAM_Init(void) { // 1. CS# 和 WP# 控制引脚配置为输出初始都拉高 TRISDbits.TRISD10 0; // CS# 引脚 LATDbits.LATD10 1; TRISBbits.TRISB0 0; // WP# 引脚可省略直接接 VCC 也行 LATBbits.LATB0 1; // 2. PPS 映射在这里SDI2R / RPA5R / RPA6R 按实际芯片手册填写 // 3. 复位 SPI2 模块 SPI2CON 0; SPI2STAT 0; // 4. 波特率PBCLK3100MHz, BRG4 - SCK10MHz SPI2BRG 4; // 5. 配置 SPI2 主模式、8 位数据、SPI Mode 0CKP0, CKE1 // 注意PIC32MZ 的 CKE 位方向和很多 ARM 单片机的 CPHA 命名是反的 // 务必用逻辑分析仪验证采样沿是否符合 MR25H40CDF 的时序要求。 SPI2CONbits.MSTEN 1; // 主模式 SPI2CONbits.CKP 0; // 空闲时钟为低Mode 0 SPI2CONbits.CKE 1; // 数据在上升沿采样Mode 0 常见配置 SPI2CONbits.MODE16 0; // 8 位模式 SPI2CONbits.MODE32 0; // 8 位模式 SPI2CONbits.ENHBUF 0; // 关掉增强缓冲用简单轮询模式 SPI2CONbits.ON 1; // 使能 SPI2 }这里多说一句CKE 和 CKP 的组合在 PIC32MZ 系列上要特别注意。实际调试时我遇到过读回来的数据全是 0xFF当时的第一反应是芯片坏了或者接线错了。后来拿逻辑分析仪一看SCK 的空闲电平和数据采样沿完全不对把 CKE 翻转一下就好了。所以那句话说得很实在SPI 模式配错了查波形永远比猜寄存器快。3.5 轮询收发的基础函数在关闭 ENHBUF 的普通模式下SPI 的收发只需要盯住两个状态位SPITBF 表示发送缓冲满SPIRBF 表示接收缓冲满。每发送一个字节同时会接收到一个字节所以 SPI 的读操作本质上也是写一个 dummy 字节进去然后把接收缓冲里的内容取出来。uint8_t MRAM_TransferByte(uint8_t tx) { // 等待发送缓冲空 while (SPI2STATbits.SPITBF); SPI2BUF tx; // 等待接收缓冲有数据 while (!SPI2STATbits.SPIRBF); // 如果接收溢出清掉标志位 if (SPI2STATbits.SPIROV) { SPI2STATbits.SPIROV 0; } return SPI2BUF; }SPIROV 这个位值得一提。在连续快速读写的时候如果你没有及时读取 SPI2BUF接收缓冲会溢出溢出标志置 1 后可能影响后续接收。所以我在每个字节接收之后都顺手清了标志。工业现场跑了一年这个处理至少帮我避开了不明原因的偶发错位。4. 驱动封装不写废话一个能直接抄的循环日志例程4.1 底层 API读写函数有了上面的初始化和字节传输函数下面就可以封装完整的读写接口了。#define MRAM_CAPACITY 0x80000 // 512KB #define MRAM_CMD_WREN 0x06 #define MRAM_CMD_WRITE 0x02 #define MRAM_CMD_READ 0x03 #define MRAM_CMD_RDSR 0x05 #define MRAM_CMD_WRSR 0x01 void MRAM_WriteEnable(void) { LATDbits.LATD10 0; MRAM_TransferByte(MRAM_CMD_WREN); LATDbits.LATD10 1; } uint8_t MRAM_ReadStatus(void) { LATDbits.LATD10 0; MRAM_TransferByte(MRAM_CMD_RDSR); uint8_t sr MRAM_TransferByte(0xFF); LATDbits.LATD10 1; return sr; } int MRAM_Write(uint32_t addr, const uint8_t *buf, uint16_t len) { if (addr len MRAM_CAPACITY) { return -1; } // MRAM 写数据之前必须先 WREN MRAM_WriteEnable(); LATDbits.LATD10 0; MRAM_TransferByte(MRAM_CMD_WRITE); MRAM_TransferByte((addr 16) 0xFF); MRAM_TransferByte((addr 8) 0xFF); MRAM_TransferByte(addr 0xFF); for (uint16_t i 0; i len; i) { MRAM_TransferByte(buf[i]); } LATDbits.LATD10 1; return 0; } int MRAM_Read(uint32_t addr, uint8_t *buf, uint16_t len) { if (addr len MRAM_CAPACITY) { return -1; } LATDbits.LATD10 0; MRAM_TransferByte(MRAM_CMD_READ); MRAM_TransferByte((addr 16) 0xFF); MRAM_TransferByte((addr 8) 0xFF); MRAM_TransferByte(addr 0xFF); for (uint16_t i 0; i len; i) { buf[i] MRAM_TransferByte(0xFF); } LATDbits.LATD10 1; return 0; }写数据前必须调用MRAM_WriteEnable()这一点很容易忘。如果写完读回全是 0xFF先检查这一条。4.2 循环日志区的布局设计日志区的基本思路是在 MRAM 里划出一块区域头部放管理信息后面是固定长度的日志条目写满后从头覆盖。这是工业设备最常见的记录方式不要求日志永久保存但要保证最近 N 条记录在掉电后依然可读。我用的布局是这样的0x000000 到 0x0000FF日志头区Magic 4 字节固定为 0x5A5AA5A5用来判断区域是否已初始化write_index 4 字节当前日志写指针total_count 4 字节累计写入条数head_crc 2 字节头部数据 CRC0x000100 到 0x07FFFF日志数据区每条日志固定大小 256 字节条目前 8 字节放 ID 和时间戳数据区域放运行参数、报警码等最后 2 字节放本条日志的 CRC为什么每条日志固定 256 字节因为固定长度让计算写指针变得非常简单write_index 加 1 乘 256 就是下一条日志的绝对地址。工业上用固定记录结构很常见不需要动态分配。4.3 循环日志的完整实现typedef struct { uint32_t magic; uint32_t write_index; uint32_t total_count; uint16_t head_crc; uint8_t reserved[6]; } LogHead; #define LOG_HEAD_ADDR 0x000000 #define LOG_DATA_ADDR 0x000100 #define LOG_ENTRY_SIZE 256 #define LOG_MAX_ENTRIES ((0x80000 - LOG_DATA_ADDR) / LOG_ENTRY_SIZE) // CRC-16/CCITT 简单实现用于头部和每条日志校验 uint16_t crc16(const uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ (data[i] 8); for (uint8_t b 0; b 8; b) { if (crc 0x8000) { crc (crc 1) ^ 0x1021; } else { crc 1; } } } return crc; } void MRAM_Log_Init(void) { LogHead head; MRAM_Read(LOG_HEAD_ADDR, (uint8_t *)head, sizeof(head)); if (head.magic 0x5A5AA5A5 crc16((uint8_t *)head, sizeof(head) - 2) head.head_crc) { return; // 已初始化直接使用 } // 首次上电初始化头部 memset(head, 0, sizeof(head)); head.magic 0x5A5AA5A5; head.write_index 0; head.total_count 0; head.head_crc crc16((uint8_t *)head, sizeof(head) - 2); MRAM_Write(LOG_HEAD_ADDR, (uint8_t *)head, sizeof(head)); } int MRAM_Log_Append(const uint8_t *data, uint16_t len) { if (len LOG_ENTRY_SIZE - 16) { return -1; } LogHead head; MRAM_Read(LOG_HEAD_ADDR, (uint8_t *)head, sizeof(head)); if (head.magic ! 0x5A5AA5A5) { return -2; // 未初始化 } uint8_t entry[LOG_ENTRY_SIZE]; memset(entry, 0, sizeof(entry)); uint32_t *p (uint32_t *)entry; p[0] head.total_count 1; // 日志 ID p[1] 0; // 时间戳由外部填充 memcpy(entry 8, data, len); uint16_t entry_crc crc16(entry, LOG_ENTRY_SIZE - 2); entry[LOG_ENTRY_SIZE - 2] (entry_crc 8) 0xFF; entry[LOG_ENTRY_SIZE - 1] entry_crc 0xFF; uint32_t addr LOG_DATA_ADDR (head.write_index % LOG_MAX_ENTRIES) * LOG_ENTRY_SIZE; MRAM_Write(addr, entry, LOG_ENTRY_SIZE); // 更新头部 head.write_index; head.total_count; head.head_crc crc16((uint8_t *)head, sizeof(head) - 2); MRAM_Write(LOG_HEAD_ADDR, (uint8_t *)head, sizeof(head)); return 0; } int MRAM_Log_ReadLatest(uint32_t offset, uint8_t *data, uint16_t len) { // offset0 表示最新一条offset1 表示上一条以此类推 LogHead head; MRAM_Read(LOG_HEAD_ADDR, (uint8_t *)head, sizeof(head)); if (head.write_index 0) { return -1; } uint32_t idx head.write_index - 1 - offset; // 防止下溢 if (head.write_index offset) { return -2; } uint32_t addr LOG_DATA_ADDR (idx % LOG_MAX_ENTRIES) * LOG_ENTRY_SIZE; uint8_t entry[LOG_ENTRY_SIZE]; MRAM_Read(addr, entry, LOG_ENTRY_SIZE); uint16_t entry_crc crc16(entry, LOG_ENTRY_SIZE - 2); if (entry[LOG_ENTRY_SIZE - 2] ! ((entry_crc 8) 0xFF) || entry[LOG_ENTRY_SIZE - 1] ! (entry_crc 0xFF)) { return -3; // CRC 校验失败 } memcpy(data, entry 8, len); return 0; }这个例程是可以直接拿去改的。头部信息里存了 write_index 和 total_count即使掉电发生在写入日志条目和更新头部之间最多丢一条记录但整片日志区的结构不会被破坏。因为写入日志条目本身是原子的MRAM 写即完成头部更新也是原子的两者之间只有极短的窗口。4.4 掉电验证我实测了三百次断电这个方案的掉电可靠性我实际做过测试。用继电器控制板子电源每隔几秒随机断电上电反复三百多次每次上电后读回最后 20 条日志做 CRC 校验。结果所有日志条目的 CRC 都正确头部 magic 也从未被破坏。对比以前用 Flash 的时候掉电测试经常出现上电后最后几条日志变成 0xFF或者说整个日志区结构被破坏。原因就是 Flash 在写扇区或擦除扇区的过程中断电状态不可控。MRAM 不存在这个问题写入的瞬间数据已经稳定CS# 拉高或者电源消失都不会影响已经写好的位。5. 从 FF 花屏到偶发卡死五段现场排查记录5.1 读回来全是 0xFF 或 0x00先查 SPI 采样沿这是我在初期调试时遇到的第一个问题。板子焊好烧进初始化代码第一次读 MRAM 的 ID 或者状态寄存器读回来全是 0xFF。当时怀疑芯片是假的怀疑虚焊怀疑地址线接错。后来把逻辑分析仪夹到 SCK、SI、SO 上面一眼就看明白了SCK 空闲电平不对数据的采样时刻正好落在电平翻转的附近。PIC32MZ 的 CKE 位跟常见的 CPHA 概念容易搞混我一开始按 ARM 的习惯配置结果在这里翻了车。调整方向也很简单先把 SCK 频率降到 1 MHz然后用示波器或逻辑分析仪对比 MR25H40CDF 数据手册的 Mode 0 时序图把 CKE 和 CKP 的组合调对。这个问题的排查顺序建议是先降频再看波形再猜寄存器。5.2 HOLD# 悬空导致偶发卡死第二颗雷是 HOLD# 引脚。我第一版 PCB 上没有给 HOLD# 做任何处理想着这个功能不用就不管它。结果设备在现场跑了一个多星期出现了一次SPI 通信完全无响应的故障。复位单片机又能恢复但过几天又犯。后来拿放大镜看手册的引脚说明HOLD# 是低电平有效的暂停输入。它悬空的时候引脚电平完全取决于周围电场遇到继电器动作、电机启停这种强干扰HOLD# 就有概率被拉低芯片进入暂停状态所有 SPI 命令都不响应。解决方案有两种一是把 HOLD# 直接拉高到 VCC二是用一个 IO 控制它但我通常选择直接接 10kΩ 上拉电阻到 VCC省一个 GPIO。5.3 写保护状态把写入静默丢弃MRAM 的写保护不是摆设。有一次我写完一组校准参数读回来发现所有值还是 0xFF。代码检查了一遍WREN 也发了地址也没问题读数据还正常。后来读到状态寄存器的值发现 WEL 位是 0说明写使能根本没生效。再往下查发现上电初始化代码里没有主动配置状态寄存器而 MRAM 的某些保护位在缺省状态下会拦住写操作。尤其是我把 WP# 引脚接到了 IO 上如果 IO 初始化为低电平配合状态寄存器里的保护位写命令会被直接忽略。解决方法是上电初始化里显式调用 WRSR把写保护范围配置成我们想要的状态同时保证 WP# 引脚初始化为高电平。另外调试验证的时候写完最好回读一下状态寄存器确认 WEL 位在写之前确实为 1。5.4 掉电瞬间的乱写需要硬件配合MRAM 虽然写入即完成但单片机在掉电瞬间的引脚状态是不可控的。如果系统的 3.3V 电源开始跌落PIC32MZ 的 GPIO 可能输出一个不确定的电平CS# 可能在这个区间出现毛刺SPI 时钟也可能乱跳。这种时候 MRAM 会把 GPIO 上随机传过来的数据当成合法命令写到不该写的地方。这个问题不能靠软件解决。硬件上要做三件事开启单片机的 BOD掉电检测电压低于阈值时强制复位用一个电源监控芯片控制 CS#掉电时把 CS# 拉高让 MRAM 退出任何可能进行中的通信如果对可靠性要求极高可以在 CS# 线上串联一个 RC 延时或者逻辑门确保供电稳定前 CS# 一直为高实测下来BOD 加 CS# 硬件上拉这两个措施加起来就能把掉电乱写的概率压到几乎为零。因为 MRAM 的写入不需要内部电压泵它对电源跌落其实比 Flash 更宽容但我们不能把希望寄托在芯片应该抗得住上面。5.5 SPI 接收溢出和高速走线最后一个坑和速度有关。我的板子布线比较紧SCK 走线绕了三个过孔SO 线上还挂了一个滤波电容。刚开始用 10 MHz 跑Windows 上测试没问题到设备旁边一跑就偶尔错一个字节。排查了很久最后发现一是 SO 线上的滤波电容把上升沿拖慢了导致采样点不稳二是 SPI 接收溢出标志一直没有清堆到某个时刻就乱了。对策分两层软件每次收发后清 SPIROV实际上代码里已经有这个处理硬件去掉 SO 线上的电容SCK 和 SI 线上串 22Ω 电阻减小反射走线尽量短如果这两步做完还有问题就把 SCK 降到 5 MHz。MRAM 的写入速度是纳秒级的SPI 时钟降一倍实际数据记录性能完全不受影响但可靠性提高显著。6. 换个视角这套组合还能怎么用6.1 系统参数热更新工业设备经常要在线修改 PID 参数、报警阈值、运行模式。如果这些参数每次修改都写 Flash几年下来 Flash 寿命是个隐患。放到 MRAM 里就完全没有负担随便改。每次改完写一个新版本号启动的时候读最新版本逻辑很清爽。6.2 配合 RTOS 做任务隔离如果用的是 FreeRTOS 这类系统MRAM 的访问要注意互斥。虽然 MRAM 本身写操作极快但一次日志读写也要占用 SPI 总线几百微秒如果多个任务同时调 MRAM_Write 和 MRAM_Read数据会互相干扰。我通常的做法是定义一个MRAM_Mutex用 FreeRTOS 的 mutex 包一层所有 MRAM 访问都必须拿到锁才能执行。6.3 继续上文件系统MRAM 的线性存储结构和极快的随机访问速度决定了它非常适合跑 littlefs 这种轻量级文件系统。littlefs 本来是为 Flash 设计的有磨损均衡和掉电保护跑在 MRAM 上不仅所有特性都能用而且因为不需要擦除性能会更好。我后来在另一块板子上就把 littlefs 直接放在了 MR25H40CDF 上做 SD 卡的掉电缓存效果不错。6.4 容量和成本怎么权衡如果项目只需要几十 KB 的关键数据存储MR25H40CDF 的 512KB 可能有点浪费可以考虑同系列的 MR25H40 下面更小容量的型号或者直接用性价比更高的 SPI EEPROM因为写入频率不高的话 EEPROM 完全够用。如果存储需求超过 1MBSPI MRAM 的性价比就开始下降了这时候优先考虑 SPI NAND 加文件系统或者并行接口的大容量 MRAM。选型归根结底要看你的写入频率和写入量而不是盲目追高配。我个人的经验是先把最高写入频率 x 单次数据量 x 运行天数算清楚得到总写入次数再对照 Flash 的十万次寿命判断是否需要升级到 MRAM。算完这笔账大多数频繁记录场景都会指向同一个结论——MRAM。说实话用 MRAM 的第一周我是不适应的。以前写存储驱动写完一个字节总要在心里等一个编程周期现在写完直接读回来读回来的就是刚写的内容反而觉得心里没底。直到做完整板掉电测试三百多次断电下来数据纹丝不动我才真正习惯这个写即完成的设定。如果你也在工业存储上被 Flash 的擦除和寿命折磨过不妨试试这个组合先拿逻辑分析仪把时序验证透剩下的事情会比你想象中顺利得多。
返回列表