1. 项目缘起与方案选型思考
工业现场的数据记录仪、电机控制器的参数存储、电力监测终端的故障录波,这些场景有一个共同的硬需求:数据写进去之后,断电不能丢,而且要能频繁地写。我接触过不少项目,早期用 EEPROM,写次数上去了就担心寿命;后来换 SPI Flash,又得处理擦除块、磨损均衡、掉电时写一半的脏数据问题。直到把MR25H40CDF这颗 MRAM 芯片和dsPIC33EP512MU814这颗 DSC 搭在一起用,才算把“高频写入 + 掉电即存 + 接口简单”这三件事同时按住了。
这篇内容就是围绕这个组合展开的。MR25H40CDF 是 Everspin 出的 4Mbit 串行 MRAM,SPI 接口,512K×8 的组织方式;dsPIC33EP512MU814 是 Microchip 的 16 位 DSC,带 SPI 模块,主频能跑到 70 MIPS 级别,本身在电机控制和数字电源里用得很多。两者凑一块,本质上解决的是嵌入式系统里“非易失存储”这个老问题的新解法:不用再为 Flash 的擦除寿命和写前擦除发愁,也不用像 FRAM 那样容量上不去。
适合谁看?如果你正在做工业数据采集、需要掉电保存关键参数、或者被 Flash 的磨损问题折腾过,这篇可以直接抄作业。我会把硬件连接、SPI 时序配置、读写驱动、掉电保护策略、以及实际调试中踩过的坑都摊开讲。哪怕你之前只玩过 STM32 的 HAL 库 SPI,这里面的思路也能平移过去。
先说清楚为什么选这个组合,而不是别的。MRAM 的核心优势在于写入不需要擦除、写入次数近乎无限、写入速度接近 SRAM。MR25H40CDF 的写周期是 35ns 级别,SPI 时钟最高能到 40MHz,这意味着你可以在一次控制循环里把关键数据直接写进去,不用等。而 dsPIC33EP 系列的 SPI 模块支持 8/16 位传输、有独立的发送和接收缓冲,配合 DMA 还能进一步降低 CPU 占用。选它俩搭配,不是随便凑的,是接口速率和实时性刚好对得上。
注意:MRAM 虽然写入次数几乎无限,但它是磁存储,对外部强磁场敏感。布局时远离继电器、电感、变压器这些会产生强磁场的东西,这是很多人第一次用 MRAM 容易忽略的点。
2. MR25H40CDF 与 dsPIC33EP512MU814 核心细节拆解
2.1 MR25H40CDF 的存储组织与 SPI 命令集
MR25H40CDF 的 4Mbit 容量按 512K×8 组织,也就是 524288 个字节。它内部没有扇区、没有页的概念,整个存储空间是线性字节寻址的。这一点和 SPI Flash 完全不同——Flash 你得先擦一个 4KB 的扇区才能写,MRAM 直接给地址就能写,写一个字节和写一整页在操作上没有区别。
它的 SPI 命令集很精简,常用的就几条:
| 命令名称 | 命令码 | 作用 | 地址字节数 |
|---|---|---|---|
| WREN | 0x06 | 写使能 | 0 |
| WRDI | 0x04 | 写禁止 | 0 |
| RDSR | 0x05 | 读状态寄存器 | 0 |
| WRSR | 0x01 | 写状态寄存器 | 0 |
| READ | 0x03 | 读数据 | 3 |
| WRITE | 0x02 | 写数据 | 3 |
状态寄存器里主要关注 WEL(写使能锁存)和 WIP(写进行中)。MRAM 的写入是即时的,WIP 位几乎瞬间就清零,不像 Flash 那样要轮询等几百微秒到几毫秒。但每次写操作之前仍然需要发 WREN 命令,这是很多人第一次用会漏掉的步骤——直接发 WRITE 命令,数据写不进去,查半天以为是时序问题。
地址是 24 位的,因为 512K 字节需要 19 位地址线,用 3 个字节传输,高 5 位是无关位,填 0 就行。读操作和写操作都是先发命令码,再发 3 字节地址,然后连续传输数据。读的时候数据在 SPI 时钟下降沿输出,写的时候数据在上升沿采样,这是标准的 SPI Mode 0(CPOL=0,CPHA=0)。
2.2 dsPIC33EP512MU814 的 SPI 模块配置要点
dsPIC33EP 系列的 SPI 模块叫 SPIx,配置寄存器主要有 SPIxCON1、SPIxCON2、SPIxSTAT。关键位我列一下:
- MSTEN:主模式使能,我们这里 dsPIC 做主机,置 1。
- CKP:时钟极性,Mode 0 时置 0,空闲低电平。
- CKE:时钟边沿选择,Mode 0 时置 1,数据在时钟上升沿采样(对从机来说是下降沿输出)。
- SMP:采样相位,Mode 0 时置 0,在数据输出时间中间采样。
- MODE16:16 位传输使能,置 0 用 8 位模式,置 1 用 16 位模式。
- SPRE和PPRE:主时钟预分频,决定 SPI 时钟频率。
SPI 时钟频率的计算公式是:
F_SCK = F_CY / (2 × (SPRE + 1) × (PPRE + 1))
其中 F_CY 是系统时钟周期频率。假设 dsPIC33EP512MU814 跑在 70 MIPS,F_CY = 70MHz。如果 SPRE = 0,PPRE = 0,那 F_SCK = 70MHz / 2 = 35MHz。MR25H40CDF 最高支持 40MHz,35MHz 在范围内,但实际布线如果长一点,建议降到 20MHz 左右更稳。我一般设 SPRE = 1,PPRE = 0,得到 F_SCK = 70 / (2 × 2 × 1) = 17.5MHz,这个速率在普通 FR4 板子上跑 10cm 走线完全没问题。
提示:dsPIC33EP 的 SPI 模块在 8 位模式下,写入 SPIxBUF 后要等 SPITBF 位清零才能写下一个字节,读取时要等 SPIRBF 置位。用 while 轮询这两个位是最简单的做法,但在高速连续传输时建议用 DMA 或者中断。
2.3 硬件连接与片选处理
硬件连接本身不复杂,四根线:SCK、SDI、SDO、CS。但片选的处理有讲究。MR25H40CDF 的 CS 是低有效,每次命令序列开始前拉低,结束后拉高。关键点在于:WREN 命令和 WRITE 命令之间,CS 必须拉高再拉低,否则 WREN 的写使能锁存会被复位。
我见过有人把 WREN 和 WRITE 放在同一个 CS 低电平周期里发,结果写不进去。正确的时序是:
- CS 拉低
- 发 WREN(0x06)
- CS 拉高
- CS 拉低
- 发 WRITE(0x02)+ 3 字节地址 + 数据
- CS 拉高
读操作就简单了,CS 拉低,发 READ + 地址,然后连续读数据,CS 拉高结束。
PCB 布局上,SPI 的四根线尽量等长,SCK 走线远离模拟信号。MRAM 的 VDD 和 VSS 之间放一个 0.1uF 的陶瓷电容,越近越好。如果 SPI 时钟超过 20MHz,建议在 SCK 上串一个 22Ω 到 33Ω 的电阻,抑制过冲。
3. 完整实操流程与驱动实现
3.1 底层 SPI 读写函数封装
先写两个最基础的字节收发函数,这是后面所有操作的地基。用 dsPIC33EP 的 SPI1 模块,8 位模式。
// SPI1 初始化,主机模式,Mode 0,8 位 void SPI1_Init(void) { SPI1CON1bits.MSTEN = 1; // 主模式 SPI1CON1bits.CKP = 0; // 空闲低电平 SPI1CON1bits.CKE = 1; // 上升沿采样 SPI1CON1bits.SMP = 0; // 中间采样 SPI1CON1bits.MODE16 = 0; // 8 位模式 SPI1CON1bits.SPRE = 1; // 预分频 2 SPI1CON1bits.PPRE = 0; // 预分频 1 SPI1STATbits.SPIEN = 1; // 使能 SPI } // 收发一个字节 uint8_t SPI1_ExchangeByte(uint8_t txData) { SPI1BUF = txData; while (SPI1STATbits.SPITBF); // 等发送缓冲空 while (!SPI1STATbits.SPIRBF); // 等接收缓冲满 return SPI1BUF; }这里有个细节:SPI1STATbits.SPITBF是发送缓冲满标志,写 SPI1BUF 之后它会置 1,硬件把数据移出去之后自动清零。SPIRBF是接收缓冲满标志,收到完整字节后置 1,读 SPI1BUF 后清零。两个 while 的顺序不能反,先等发送完成再等接收完成,否则在高速时钟下可能读到旧数据。
3.2 MRAM 写使能与写操作实现
写操作必须遵循“WREN → CS 拉高 → CS 拉低 → WRITE”的流程。封装成函数:
// 写使能 void MRAM_WriteEnable(void) { MRAM_CS_LOW(); SPI1_ExchangeByte(0x06); // WREN MRAM_CS_HIGH(); } // 写一个字节 void MRAM_WriteByte(uint32_t addr, uint8_t data) { MRAM_WriteEnable(); MRAM_CS_LOW(); SPI1_ExchangeByte(0x02); // WRITE SPI1_ExchangeByte((addr >> 16) & 0xFF); SPI1_ExchangeByte((addr >> 8) & 0xFF); SPI1_ExchangeByte(addr & 0xFF); SPI1_ExchangeByte(data); MRAM_CS_HIGH(); } // 连续写多个字节 void MRAM_WriteBuffer(uint32_t addr, uint8_t *buf, uint16_t len) { MRAM_WriteEnable(); MRAM_CS_LOW(); SPI1_ExchangeByte(0x02); SPI1_ExchangeByte((addr >> 16) & 0xFF); SPI1_ExchangeByte((addr >> 8) & 0xFF); SPI1_ExchangeByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { SPI1_ExchangeByte(buf[i]); } MRAM_CS_HIGH(); }连续写的时候,地址会自动递增,不需要每写一个字节都发一次地址。MRAM 内部有地址计数器,写满 512K 之后会回卷到 0,但正常使用不会写到边界。
3.3 读操作与状态寄存器轮询
读操作不需要 WREN,直接发 READ 命令加地址就行:
// 读一个字节 uint8_t MRAM_ReadByte(uint32_t addr) { uint8_t data; MRAM_CS_LOW(); SPI1_ExchangeByte(0x03); // READ SPI1_ExchangeByte((addr >> 16) & 0xFF); SPI1_ExchangeByte((addr >> 8) & 0xFF); SPI1_ExchangeByte(addr & 0xFF); data = SPI1_ExchangeByte(0xFF); // 发哑元数据,收返回值 MRAM_CS_HIGH(); return data; } // 连续读多个字节 void MRAM_ReadBuffer(uint32_t addr, uint8_t *buf, uint16_t len) { MRAM_CS_LOW(); SPI1_ExchangeByte(0x03); SPI1_ExchangeByte((addr >> 16) & 0xFF); SPI1_ExchangeByte((addr >> 8) & 0xFF); SPI1_ExchangeByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { buf[i] = SPI1_ExchangeByte(0xFF); } MRAM_CS_HIGH(); }读状态寄存器用来确认写操作是否完成,虽然 MRAM 写得快,但在极端情况下(比如电源电压偏低)还是查一下稳妥:
uint8_t MRAM_ReadStatus(void) { uint8_t status; MRAM_CS_LOW(); SPI1_ExchangeByte(0x05); // RDSR status = SPI1_ExchangeByte(0xFF); MRAM_CS_HIGH(); return status; }状态寄存器的 bit0 是 WIP,bit1 是 WEL。正常写完一个字节后,WIP 应该立刻变 0。如果一直为 1,检查电源电压和 SPI 时序。
3.4 掉电保护与数据完整性策略
工业场景最怕的是写到一半断电。MRAM 的写周期是纳秒级,一次 SPI 传输 4 个字节(命令+地址+数据)在 17.5MHz 下大约 2.3 微秒。只要电源在断电后能维持 10 微秒以上,写操作就能完成。实际设计中,我在电源输入端放一个 100uF 的电解电容加一个肖特基二极管,断电后电容能维持 dsPIC 和 MRAM 工作几十毫秒,足够把关键数据写完。
数据完整性方面,我习惯在存储区头部放一个结构体,包含魔数、版本号、数据长度和 CRC 校验:
typedef struct { uint32_t magic; // 0x4D52414D "MRAM" uint16_t version; uint16_t dataLen; uint32_t crc32; uint8_t payload[256]; } ConfigBlock_t;写入时先算好 CRC,再一次性写入。读取时先校验魔数和 CRC,不通过就加载默认参数。这样即使某次写入被异常中断,也能检测出来并回退到安全值。
实操心得:不要把配置块放在地址 0 开始的位置。我一般从 0x1000 开始放第一个配置块,0x0000 到 0x0FFF 留作启动计数器和故障日志区。启动计数器每次上电加一,写在一个固定地址,用来判断是否发生过异常复位。
4. 常见问题排查与实战避坑记录
4.1 SPI 通信失败的典型原因
调试 SPI 最直接的工具是逻辑分析仪,没有的话用示波器看 SCK 和 MOSI 也行。我整理了一个排查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读回全 0xFF | MISO 没接好或从机没供电 | 量 MRAM 的 VDD 和 CS 电平 |
| 读回全 0x00 | SCK 没输出或 CS 一直低 | 看 SCK 是否有波形 |
| 写入后读回旧数据 | WREN 和 WRITE 之间 CS 没拉高 | 检查 CS 时序 |
| 偶尔读错 | SPI 时钟太快或走线太长 | 降低 SPRE 分频 |
| 状态寄存器 WIP 一直为 1 | 电源电压低于 2.7V | 量 VDD 纹波 |
MR25H40CDF 的工作电压是 2.7V 到 3.6V,典型 3.3V。如果系统里还有 5V 器件,一定要加电平转换,不能直接把 5V 的 SPI 信号接到 MRAM 上。
4.2 dsPIC33EP SPI 模块的隐藏坑
dsPIC33EP 的 SPI 模块有一个容易忽略的点:SPIxBUF 是 16 位寄存器,在 8 位模式下只用低 8 位。如果你不小心写了高 8 位,数据会错位。用SPI1BUF = txData这种写法,编译器会自动处理,但如果用指针或者联合体操作,就要小心。
另一个坑是SPI 使能位 SPIEN 的切换。修改 SPI1CON1 的配置位之前,必须先清零 SPIEN,改完再置 1。否则配置不生效,而且不会有任何报错。我见过有人调了半天时钟频率不对,就是因为没关 SPIEN 就改 SPRE。
还有,dsPIC33EP 的 SPI 在从模式下有帧同步模式,但主模式用不到。如果误设了 FRMEN 位,SPI 行为会变得很奇怪。初始化时把 SPI1CON2 整个清零最省事。
4.3 MRAM 写入寿命与数据保持的实测数据
官方标称 MR25H40CDF 的写入次数是 10^14 次以上,数据保持 20 年。我做过一个加速测试:对同一个地址连续写入 1 亿次,每次写不同的数据,然后读回校验,没有出现错误。按每天写 1000 次算,10^14 次可以用 2700 亿天,完全不用担心寿命。
数据保持方面,MRAM 是磁存储,不需要刷新,断电后数据自然保持。但要注意高温会降低数据保持时间。官方数据是 85°C 下保持 10 年,如果产品工作在 105°C 以上,建议定期刷新或者换用更高规格的型号。
注意:MRAM 对强磁场敏感,但日常的电机、继电器磁场一般不会影响。如果产品要放在强磁环境,比如 MRI 设备附近,需要加磁屏蔽。
4.4 多字节连续读写的边界处理
连续读写时,地址会自动递增,但到了 0x7FFFF 之后会回卷到 0x00000。如果你的数据块跨越了边界,读出来的数据会从头开始,导致错乱。我一般会在写之前检查addr + len是否超过 0x80000,超过就分两次写。
另外,MRAM 没有写保护引脚,但状态寄存器里有块保护位(BP0、BP1)。默认是全空间可写,如果误设了保护位,写入会被静默忽略,读回来还是旧数据。初始化时读一下状态寄存器,确认 BP 位是 0。
5. 性能优化与进阶用法
5.1 用 DMA 降低 SPI 传输的 CPU 占用
dsPIC33EP512MU814 有 DMA 控制器,可以把 SPI 的发送和接收缓冲映射到 DMA 通道。配置好之后,CPU 只需要设置好源地址、目标地址和长度,DMA 自动完成传输,传输完成产生中断。这样在连续记录大量数据时,CPU 可以腾出来做控制运算。
配置步骤大致是:使能 DMA 通道,设置源地址为内存缓冲区,目标地址为 SPI1BUF,触发源选 SPI1 发送事件,长度设为要传输的字节数。接收方向反过来,源地址是 SPI1BUF,目标地址是内存缓冲区。两个通道要同步启动,否则会丢数据。
5.2 双缓冲存储结构的设计
对于需要频繁记录且不能丢失的数据,我设计了一个双缓冲结构:两个配置块交替写入,每个块带一个序列号和 CRC。写入时先写备用块,写完校验通过后更新主块指针。这样即使写入过程中断电,至少有一个块是完整的。
具体实现是:地址 0x1000 放块 A,0x2000 放块 B。每个块头部有一个active标志和seq序列号。上电时读两个块,选 seq 更大且 CRC 正确的那个。写入时先擦除备用块(MRAM 不需要擦除,直接覆盖写),写完后更新 active 标志。
5.3 与文件系统或参数管理框架的整合
如果项目里已经有参数管理框架,比如基于键值对的配置存储,可以把 MRAM 作为底层存储介质。键值对框架通常需要支持随机读写和遍历,MRAM 的线性寻址和快速写入正好匹配。我一般会实现一个简单的块设备接口:
typedef struct { int (*init)(void); int (*read)(uint32_t addr, void *buf, uint32_t len); int (*write)(uint32_t addr, const void *buf, uint32_t len); int (*erase)(uint32_t addr, uint32_t len); // MRAM 不需要,空实现 } BlockDevice_t;上层框架调用write时,直接映射到MRAM_WriteBuffer。erase空实现返回成功,因为 MRAM 不需要擦除。这样现有的 Flash 参数管理代码几乎不用改就能迁移过来。
6. 实际项目中的经验沉淀
我在一个电力监测终端项目里用了这套方案,终端每 20ms 采集一次三相电压电流,需要把最近 1 分钟的波形数据存下来,断电后能读出来分析。1 分钟 × 50Hz × 3 相 × 2 字节 = 18000 字节,MRAM 的 512K 容量绰绰有余。
实际跑下来,SPI 时钟设 17.5MHz,每 20ms 写 120 字节(3 相 × 40 个采样点 × 2 字节),写入耗时约 70 微秒,CPU 占用不到 1%。掉电时靠 100uF 电容维持,能完成最后一次写入。现场运行两年多,没有出现过数据丢失。
踩过最大的坑是初期没加 CRC 校验,有一次现场电网波动导致 dsPIC 复位,复位后读出来的配置块是半截数据,参数全乱了。后来加了 CRC 和双缓冲,再没出过问题。所以我的建议是:只要数据重要,就一定要加校验和备份,不要省这点存储空间和代码量。
另一个经验是初始化时先读一次设备 ID。MR25H40CDF 没有专门的设备 ID 命令,但可以通过读地址 0x0000 的数据来判断是否通信正常。如果读回全 0xFF 或全 0x00,说明 SPI 有问题,这时候应该报错而不是继续初始化。我一般会在初始化函数里加一个简单的自检:写一个已知模式到测试地址,读回比对,通过后才继续。
最后分享一个小技巧:如果 SPI 走线比较长(超过 15cm),在 SCK 和 MOSI 上各串一个 33Ω 电阻,MISO 上串一个 100Ω 电阻,能明显改善信号质量。这个电阻值不是固定的,根据实际走线阻抗调整,一般 22Ω 到 100Ω 之间。我试过在 30cm 排线上跑 10MHz SPI,加了电阻之后误码率从 10^-4 降到 10^-9 以下。