1. 项目缘起:为什么要在工业场景里折腾 MRAM 和 PIC24
工业现场的数据记录有个很尴尬的处境:用 EEPROM 吧,写入速度慢得让人着急,擦写寿命也就百万次级别,高频采集场景下没几个月就写废了;用 SRAM 加后备电池吧,电池在高温高湿环境下三五年就鼓包漏液,维护成本高得离谱;用 Flash 吧,写之前得先擦一整块,掉电瞬间还可能把整块数据搞丢。我前两年做一个电力监测终端项目,现场环境温度常年 60 度往上,客户要求每秒记录一次关键参数并且掉电不能丢,试了好几种方案都不太满意,直到把目光投向了MRAM这种磁性随机存储器。
这次要聊的组合是MR25H40CDF这颗 4Mbit 的 SPI MRAM 芯片,搭配PIC24EP512GU810这颗 Microchip 家的 16 位高性能单片机。选这两个凑一对不是随便拍的,背后有很实在的工程考量。MR25H40CDF 的卖点在于:写入没有延迟(不像 EEPROM 要等 5ms 的写周期)、擦写寿命理论上无限(官方标称 10^14 次以上)、掉电数据保持 20 年以上,而且接口就是标准的 SPI,接线简单。PIC24EP512GU810 则是 70MIPS 的 16 位 MCU,自带多个 SPI 模块,512KB Flash 加 52KB RAM,在工业控制里属于够用又不贵的档位。
这篇文章适合谁看?如果你正在做工业数据采集、设备参数存储、黑匣子记录这类需要高可靠非易失存储的嵌入式项目,或者你手上有 PIC24 系列 MCU 想接一颗靠谱的存储芯片,那这篇内容应该能帮你少走不少弯路。我会从选型逻辑、硬件连接、SPI 时序配置、读写代码实现、到实际踩过的坑,完整地讲一遍。MRAM这东西用起来和普通 SPI Flash 有相似之处,但细节上的差异如果没注意到,调试起来会很痛苦。
2. 方案选型:MR25H40CDF 与 PIC24EP512GU810 的搭配逻辑
2.1 为什么是 MRAM 而不是 EEPROM 或 Flash
先把这个核心问题说清楚,不然选型就是瞎选。工业嵌入式存储的需求可以归纳成四条:写入要快、寿命要长、掉电要稳、接口要简单。我们拿三种方案对比一下。
| 特性 | EEPROM(如 25LC256) | SPI Flash(如 W25Q64) | MRAM(MR25H40CDF) |
|---|---|---|---|
| 写入速度 | 字节写约 5ms | 页编程约 0.7ms,但需先擦除 | 无写延迟,总线速率即写入速率 |
| 擦写寿命 | 约 100 万次 | 约 10 万次 | 10^14 次以上 |
| 擦除需求 | 无需擦除 | 写前必须擦除整扇区 | 无需擦除 |
| 掉电保持 | 200 年 | 20 年 | 20 年 |
| 接口 | SPI | SPI | SPI |
| 单价 | 低 | 低 | 较高 |
从表里能看出来,MRAM 几乎是碾压式的优势,唯一的短板就是价格。MR25H40CDF 单颗价格大概是同容量 SPI Flash 的十几倍,所以它不适合拿来做大容量数据存储(比如存图片、日志文件),但非常适合做高频写入的关键参数存储。我那个电力监测项目里,需要频繁更新的其实就几百个字节的实时参数和累计量,用 4Mbit(512KB)的 MRAM 绰绰有余,成本增加在可接受范围内。
提示:MRAM 不是用来替代大容量 Flash 的,它的定位是"高频写入的小容量关键数据区"。如果你的项目要存几 MB 的历史曲线,还是老老实实上 Flash 或者 SD 卡,MRAM 只负责存那些最不能丢、写得最频繁的核心数据。
2.2 PIC24EP512GU810 的 SPI 资源盘点
PIC24EP512GU810 这颗片子我用了挺久,它的 SPI 模块配置灵活,但坑也不少。它内部有 3 个 SPI 模块(SPI1、SPI2、SPI3),每个都支持主从模式,最高波特率可以到 25MHz(在 70MIPS 下)。不过实际用的时候,我一般不会跑满,原因后面讲时序的时候会说。
这颗 MCU 的 SPI 有几个关键寄存器需要配置:SPIxCON1控制时钟极性和相位、主从模式、波特率预分频;SPIxCON2控制帧模式、片选行为;SPIxSTAT是状态寄存器,用来查询发送缓冲是否满、接收缓冲是否有数据。很多人第一次用 PIC24 的 SPI 会栽在 SPIxCON2 的 FRMEN 位和 SPIFSD 位上,这两个位决定了片选是硬件自动控制还是软件手动控制,配错了就会出现"数据发出去但片选不动"的诡异现象。
选 PIC24EP512GU810 配 MR25H40CDF,一个很实际的好处是:PIC24 的 SPI 支持 8 位和 16 位两种帧格式,而 MR25H40CDF 的指令和地址是标准的 8 位格式,用 8 位帧模式最省心。另外 PIC24EP 系列有独立的 SPI 发送和接收缓冲,配合 DMA 可以做高速连续读写,不过对于参数存储这种场景,普通中断或轮询方式就够了,没必要上 DMA 增加复杂度。
2.3 硬件连接方案与片选策略
接线本身不复杂,MR25H40CDF 是 8 脚 SOIC 封装,引脚定义是:CS(片选)、SO(数据输出)、SI(数据输入)、SCK(时钟)、VCC、VSS、WP(写保护)、HOLD(保持)。和 PIC24 的连接方式如下。
| MR25H40CDF 引脚 | PIC24EP512GU810 引脚 | 说明 |
|---|---|---|
| CS | RB2(可配置为 SPI1 的 SS 或普通 IO) | 片选,低有效 |
| SO | SDI1(RPINR20 配置输入引脚) | 数据从 MRAM 到 MCU |
| SI | SDO1(RPOR 配置输出引脚) | 数据从 MCU 到 MRAM |
| SCK | SCK1(RPOR 配置输出引脚) | 时钟 |
| WP | 接 VCC 或 IO 控制 | 写保护,低有效 |
| HOLD | 接 VCC | 保持,低有效,不用就拉高 |
这里有个关键选择:片选用硬件还是软件。PIC24 的 SPI 模块可以配置成硬件自动控制片选(SSEN 位使能),也可以把 SS 当普通 IO 用软件拉低拉高。我强烈建议用软件片选,原因有两个:一是 MR25H40CDF 的指令序列是"拉低 CS → 发指令 → 发地址 → 读写数据 → 拉高 CS",整个过程中 CS 必须一直保持低电平,硬件片选在某些帧模式下会在字节之间自动翻转,导致指令序列被打断;二是软件片选调试起来直观,用逻辑分析仪一看就知道时序对不对。
注意:WP 和 HOLD 引脚千万别悬空。WP 悬空时芯片内部有弱上拉,理论上不会误写,但在工业强干扰环境下,悬空引脚容易耦合噪声导致误触发。我的做法是 WP 直接接 VCC(不需要写保护功能时),HOLD 也接 VCC,简单可靠。
3. SPI 通信底层:时序配置与寄存器操作细节
3.1 SPI 模式选择与时钟极性相位
MR25H40CDF 支持 SPI 模式 0(CPOL=0,CPHA=0)和模式 3(CPOL=1,CPHA=1)。这两种模式的区别在于时钟空闲电平和数据采样边沿。模式 0 下,SCK 空闲为低,数据在上升沿采样;模式 3 下,SCK 空闲为高,数据在下降沿采样。两种都能用,我习惯用模式 0,因为大部分逻辑分析仪的默认解码设置就是模式 0,调试方便。
在 PIC24 上配置模式 0,需要设置 SPI1CON1 的 CKP 位(时钟极性)为 0,CKE 位(时钟边沿)为 1。这里有个容易搞混的地方:PIC24 的 CKE 位定义和标准 SPI 的 CPHA 不是一一对应的。CKE=1 表示数据在 SCK 从空闲态到有效态的边沿变化(模式 0 下就是上升沿),CKE=0 表示在另一个边沿变化。所以模式 0 对应 CKP=0、CKE=1,模式 3 对应 CKP=1、CKE=0。我第一次配的时候把 CKE 搞反了,读出来的数据全是 0xFF,查了半天才发现是采样边沿错了。
3.2 波特率计算与实测选择
PIC24EP512GU810 在 70MIPS 下,系统时钟 Fc 是 70MHz(外设时钟 Fp 通常是 Fc/2 = 35MHz,具体看配置)。SPI 波特率的计算公式是:
Fsck = Fp / (2 * (SPI1BRG + 1))其中 SPI1BRG 是波特率寄存器值。MR25H40CDF 的最高 SCK 频率是 40MHz,理论上 PIC24 可以跑到 25MHz 以上。但我实测下来,建议把 SCK 控制在 10MHz 以内。原因有三:一是工业现场走线往往比较长,高速下信号完整性变差,容易出现误码;二是 MRAM 虽然写入无延迟,但内部还是有读写周期的,过高的时钟频率在连续读写时可能触及内部时序边界;三是 10MHz 对参数存储来说已经绰绰有余,一次 512 字节的页读也就 50 微秒左右。
按 10MHz 算,SPI1BRG = 35MHz / (2 * 10MHz) - 1 = 0.75,取整为 0,实际波特率是 35MHz / (2 * 1) = 17.5MHz。要精确到 10MHz 左右,可以设 SPI1BRG = 1,得到 35MHz / 4 = 8.75MHz,这个值比较稳妥。我一般就用 8.75MHz,实测连续读写几万次没有出过错。
3.3 初始化代码与关键寄存器配置
下面是我实际项目里用的 SPI 初始化代码,基于 Microchip 的 XC16 编译器。
void SPI1_Init(void) { // 配置引脚:SCK1 和 SDO1 为输出,SDI1 为输入 // RPOR 寄存器配置外设引脚映射,具体值根据实际引脚定 RPOR1bits.RP2R = 8; // 假设 RP2 映射为 SDO1 RPOR1bits.RP3R = 9; // 假设 RP3 映射为 SCK1 RPINR20bits.SDI1R = 4; // 假设 RP4 作为 SDI1 输入 // 配置 CS 为普通 IO 输出 TRISBbits.TRISB2 = 0; // CS 引脚 LATBbits.LATB2 = 1; // 初始拉高 // SPI1 配置 SPI1CON1bits.CKP = 0; // 时钟极性:空闲低 SPI1CON1bits.CKE = 1; // 数据在上升沿采样(模式 0) SPI1CON1bits.MSTEN = 1; // 主模式 SPI1CON1bits.SMP = 0; // 在数据输出末尾采样 SPI1CON1bits.MODE16 = 0;// 8 位帧模式 SPI1CON1bits.SPRE = 0b110; // 次级预分频 2:1 SPI1CON1bits.PPRE = 0b10; // 主预分频 4:1 SPI1BRG = 1; // 波特率寄存器 SPI1CON2 = 0x0000; // 关闭帧模式,普通 SPI SPI1STATbits.SPIEN = 1; // 使能 SPI 模块 }这段代码里,SPRE 和 PPRE 的组合决定了最终波特率。PPRE=0b10 是 4:1,SPRE=0b110 是 2:1,合起来分频比是 8:1,Fp=35MHz 时 SCK = 35/8 = 4.375MHz。如果要更快,可以把 PPRE 改成 0b00(1:1),SPRE 改成 0b111(1:1),配合 SPI1BRG=1,得到 35/4 = 8.75MHz。我建议先用低速调通,再逐步提速。
实操心得:PIC24 的 SPI 发送和接收是同一个缓冲寄存器 SPI1BUF。写 SPI1BUF 就是发送,读 SPI1BUF 就是接收。但要注意,发送和接收是同时进行的,你发一个字节出去,同时会收到一个字节。读 MRAM 的时候,前面发的指令和地址字节会收到一堆无效数据,必须把这些"垃圾数据"读掉丢弃,否则接收缓冲会溢出。
4. MRAM 读写实操:从指令序列到完整驱动
4.1 MR25H40CDF 的指令集与操作流程
MR25H40CDF 的指令集很简洁,常用的就几条。
| 指令名称 | 指令码 | 功能说明 |
|---|---|---|
| WREN | 0x06 | 写使能,写操作前必须发 |
| WRDI | 0x04 | 写禁止 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 读数据 |
| WRITE | 0x02 | 写数据 |
和 EEPROM 不同的是,MRAM 的写操作不需要等待写周期完成。EEPROM 发完写指令后要等 5ms 左右,期间芯片不响应任何指令,你得轮询状态寄存器或者死等。MRAM 发完数据,拉高 CS,写操作就完成了,下一條指令可以立即发。这个特性在高速数据记录场景下太重要了,我那个每秒记录一次的项目,如果用 EEPROM,光等待写周期就占掉了大量 CPU 时间。
读操作的流程是:拉低 CS → 发 READ 指令(0x03)→ 发 3 字节地址(MR25H40CDF 是 4Mbit,需要 19 位地址,用 3 字节表示)→ 连续读数据 → 拉高 CS。地址是高位在前,比如要读地址 0x000100,就发 0x00、0x01、0x00 三个字节。
写操作的流程是:拉低 CS → 发 WREN 指令(0x06)→ 拉高 CS → 拉低 CS → 发 WRITE 指令(0x02)→ 发 3 字节地址 → 发数据 → 拉高 CS。注意 WREN 和 WRITE 之间必须拉高一次 CS,这是很多新手会漏掉的步骤。WREN 是独立的指令周期,必须用 CS 的上升沿来锁存。
4.2 单字节读写函数实现
先看最基础的单字节读写,理解了这两个函数,页读写就是循环调用的事。
// SPI 收发一个字节 uint8_t SPI1_ExchangeByte(uint8_t txData) { SPI1BUF = txData; // 写入发送缓冲 while (!SPI1STATbits.SPIRBF); // 等待接收完成 return SPI1BUF; // 读取接收数据 } // 读 MRAM 一个字节 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); // 读数据,发 0xFF 产生时钟 MRAM_CS_HIGH(); // 拉高片选 return data; } // 写 MRAM 一个字节 void MRAM_WriteByte(uint32_t addr, uint8_t data) { MRAM_CS_LOW(); SPI1_ExchangeByte(0x06); // WREN MRAM_CS_HIGH(); // 必须拉高锁存 WREN 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(); // 拉高完成写入 }读函数里那个SPI1_ExchangeByte(0xFF)是精髓。SPI 是全双工,主机要产生时钟才能收到从机的数据,所以必须发一个任意字节(通常用 0xFF 或 0x00)来"换取"从机的数据。发什么内容无所谓,MRAM 会忽略它,但时钟必须得有。
写函数里 WREN 之后拉高 CS 再拉低,这个动作看起来多余,但绝对不能省。我见过有人把 WREN 和 WRITE 放在同一个 CS 低电平周期里,结果写不进去,查了一整天。MR25H40CDF 的数据手册里写得很清楚,WREN 指令通过 CS 的上升沿生效,所以必须有一个完整的 CS 脉冲。
4.3 页读写与连续读写优化
单字节读写虽然能用,但效率太低。MR25H40CDF 支持连续读写,地址会自动递增,所以可以一次读一大片数据。写操作虽然没有页大小的限制(MRAM 不像 Flash 有页边界),但连续写的时候要注意,WRITE 指令发出后,只要 CS 保持低电平,地址就会自动递增,可以一直写下去。
// 连续读多个字节 void MRAM_ReadBuffer(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t i; MRAM_CS_LOW(); SPI1_ExchangeByte(0x03); SPI1_ExchangeByte((addr >> 16) & 0xFF); SPI1_ExchangeByte((addr >> 8) & 0xFF); SPI1_ExchangeByte(addr & 0xFF); for (i = 0; i < len; i++) { buf[i] = SPI1_ExchangeByte(0xFF); } MRAM_CS_HIGH(); } // 连续写多个字节 void MRAM_WriteBuffer(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t i; MRAM_CS_LOW(); SPI1_ExchangeByte(0x06); // WREN MRAM_CS_HIGH(); MRAM_CS_LOW(); SPI1_ExchangeByte(0x02); // WRITE SPI1_ExchangeByte((addr >> 16) & 0xFF); SPI1_ExchangeByte((addr >> 8) & 0xFF); SPI1_ExchangeByte(addr & 0xFF); for (i = 0; i < len; i++) { SPI1_ExchangeByte(buf[i]); } MRAM_CS_HIGH(); }连续写的时候有个细节:地址递增到芯片末尾(0x7FFFF)后会回绕到 0x00000。如果你要写的数据跨越了末尾,它会自动从头开始覆盖。这个行为在大多数场景下不是问题,但如果你做的是循环缓冲区,反而可以利用这个特性省掉地址回绕的判断。
注意:连续写的时候,WREN 只需要发一次,后面整个连续写序列都在同一个 CS 低电平周期内完成。不要每写一个字节就发一次 WREN,那样效率极低,而且容易出错。
5. 数据可靠性设计:校验、掉电保护与磨损均衡
5.1 数据校验的必要性
工业现场电磁干扰强,SPI 总线虽然比 I2C 抗干扰好一些,但长距离走线时仍然可能出错。我在一个变频器旁边的设备上就遇到过:MRAM 读出来的数据偶尔会有一位翻转。虽然 MRAM 本身的存储单元很可靠,但传输过程可能被干扰。所以关键数据必须加校验。
最简单的方案是加 CRC16 校验。每个数据块后面跟两个字节的 CRC,读的时候重新计算比对。CRC16 的计算可以用查表法,速度快,占用空间小。
// CRC16-Modbus 查表法 static const uint16_t crc16_table[256] = { /* 表内容省略 */ }; uint16_t CRC16_Calc(uint8_t *data, uint16_t len) { uint16_t crc = 0xFFFF; uint16_t i; for (i = 0; i < len; i++) { crc = (crc >> 8) ^ crc16_table[(crc ^ data[i]) & 0xFF]; } return crc; }存储的时候,把数据块和 CRC 一起写入。读取的时候,先读数据块,再读 CRC,重新计算比对。如果不一致,说明数据损坏,可以触发重读或者使用备份数据。这个开销很小,一个 64 字节的数据块,CRC 计算也就几十微秒,但可靠性提升是数量级的。
5.2 掉电保护与双备份策略
MRAM 的写入是原子性的吗?严格来说,MR25H40CDF 的单字节写入是原子的,但多字节连续写不是。如果在连续写的过程中掉电,可能只写了一部分。对于关键参数,我采用双备份加标志位的策略。
具体做法是:把参数区分成 A、B 两个区域,每个区域包含一个"有效标志"、数据本体和 CRC。写入的时候,先写 B 区,写完后更新 B 区的有效标志;下次写入时写 A 区,更新 A 区的有效标志。读取的时候,比较 A、B 两区的有效标志和 CRC,选择最新的有效数据。
typedef struct { uint32_t magic; // 有效标志,如 0xAA55AA55 uint8_t data[60]; // 实际数据 uint16_t crc; // 数据 CRC } ParamBlock; ParamBlock blockA, blockB; // 读取时选择有效的块 uint8_t* Param_Read(void) { uint8_t validA = 0, validB = 0; MRAM_ReadBuffer(ADDR_A, (uint8_t*)&blockA, sizeof(blockA)); MRAM_ReadBuffer(ADDR_B, (uint8_t*)&blockB, sizeof(blockB)); if (blockA.magic == 0xAA55AA55 && CRC16_Calc(blockA.data, 60) == blockA.crc) validA = 1; if (blockB.magic == 0xAA55AA55 && CRC16_Calc(blockB.data, 60) == blockB.crc) validB = 1; if (validA && validB) { // 两个都有效,需要额外的序号来判断哪个更新 // 可以在 data 里放一个递增的序号 return (blockA.data[0] > blockB.data[0]) ? blockA.data : blockB.data; } else if (validA) { return blockA.data; } else if (validB) { return blockB.data; } return NULL; // 都无效,使用默认值 }这个策略的好处是:无论在哪一步掉电,至少有一个块是完整的。MRAM 写入快,双备份带来的额外开销几乎可以忽略。
5.3 磨损均衡在 MRAM 上还需要吗
理论上 MRAM 的擦写寿命是 10^14 次以上,按每秒写一次算,可以写三百多万年,磨损均衡完全没必要。但实际工程中,我还是会做一个简单的地址轮转,原因不是为了延长寿命,而是为了降低单点故障风险。如果某个存储单元因为制造缺陷或者外部应力(比如局部过热)提前失效,固定地址写入会一直失败,而轮转写入可以把风险分散开。
我的做法是:把参数区划分成 16 个槽位,每次写入轮转到下一个槽位,每个槽位都带序号和 CRC。读取时扫描所有槽位,找序号最大且 CRC 正确的那个。这样即使某个槽位坏了,其他槽位仍然可用。这个逻辑不复杂,但能显著提升长期运行的可靠性。
6. 调试实录:那些让我熬夜的坑与排查方法
6.1 读出来全是 0xFF 或 0x00
这是最常见的症状,原因通常有三个。第一是片选没拉低,或者片选引脚配置错了。用万用表量一下 CS 引脚在读写时有没有变化,如果没有,检查 TRIS 寄存器和引脚映射。第二是SPI 模式配错,CKP 和 CKE 的组合不对,导致采样边沿错误。用逻辑分析仪抓一下 SCK 和 SI 的波形,看看数据在哪个边沿变化,对比 MR25H40CDF 数据手册的时序图。第三是地址发错,比如把 3 字节地址发成了 2 字节,后面全错位了。
我遇到过一次特别隐蔽的:SPI1CON1 的 SMP 位设成了 1,导致在数据输出中间采样,读出来的数据高位正确低位错误。改成 SMP=0(在数据输出末尾采样)就正常了。这个位在数据手册里的描述很简略,但实际影响很大。
6.2 写入后读出来不对
写入不成功,先检查 WREN 有没有正确发送。用逻辑分析仪看 CS 波形,确认 WREN 和 WRITE 之间有没有 CS 的上升沿。如果没有,说明代码里漏了拉高 CS 的步骤。另外检查 WP 引脚,如果 WP 被拉低,写操作会被硬件禁止,但读操作正常,这个现象很有迷惑性。
还有一种情况是写入成功了但读出来是旧数据。这通常是因为读地址和写地址不一致。检查地址计算,特别是位移操作有没有写错。我见过有人把(addr >> 16) & 0xFF写成了(addr >> 16),结果高位字节带了一堆无关位,地址就偏了。
6.3 高速读写时偶发错误
低速正常、高速出错,基本可以断定是信号完整性问题。排查步骤:先用逻辑分析仪看 SCK 波形,有没有过冲、振铃、上升沿变缓。如果有,检查 PCB 走线,SCK 和 SI、SO 之间有没有串扰,地线回流路径是否完整。可以在 SCK 上串一个 22 欧姆的电阻,减缓边沿速率。另外,SPI 走线尽量短,不要和 PWM、电机驱动线平行走。
如果硬件上不好改,就降速。把 SCK 从 8.75MHz 降到 4.375MHz,错误率通常会大幅下降。对于参数存储这种对速度不敏感的场景,降速是最省事的解决方案。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 读出全 0xFF | CS 未拉低、SPI 未使能 | 量 CS 电平、查 SPIEN 位 | 修正片选逻辑、使能 SPI |
| 读出全 0x00 | 时钟未输出、模式错误 | 逻辑分析仪看 SCK | 检查 CKP/CKE、预分频配置 |
| 写入无效 | WREN 未生效、WP 拉低 | 看 CS 波形、量 WP 电平 | 补 CS 上升沿、拉高 WP |
| 数据偶发错误 | 信号完整性、干扰 | 看 SCK 波形质量 | 降速、串阻、改善走线 |
| 地址错位 | 地址字节数错误 | 核对地址发送顺序 | 确保发 3 字节地址 |
| 连续读越界 | 地址回绕 | 检查读取长度 | 限制长度或处理回绕 |
实操心得:调试 SPI 设备,逻辑分析仪是必备工具。没有逻辑分析仪的话,至少用一个示波器看 SCK 和 CS 的波形。光靠 printf 打印调试信息,效率太低,而且 SPI 的问题往往在时序层面,软件打印看不出来。我用的是一款几十块钱的 8 通道逻辑分析仪,配合开源软件,解码 SPI 协议很方便,强烈建议入手一个。
7. 性能实测与场景扩展
7.1 实测数据:读写速度与功耗
我在实验室环境下做了一组实测,PIC24EP512GU810 跑 70MIPS,SPI 时钟 8.75MHz,MR25H40CDF 供电 3.3V。
| 操作 | 数据量 | 耗时 | 备注 |
|---|---|---|---|
| 单字节读 | 1 字节 | 约 4.5 微秒 | 含指令和地址开销 |
| 单字节写 | 1 字节 | 约 6 微秒 | 含 WREN 和 WRITE 两段 |
| 连续读 | 512 字节 | 约 590 微秒 | 平均 1.15 微秒/字节 |
| 连续写 | 512 字节 | 约 600 微秒 | 平均 1.17 微秒/字节 |
| 待机电流 | - | 约 1.5 毫安 | CS 高电平,无操作 |
| 读写电流 | - | 约 8 毫安 | 连续读写时 |
从数据看,连续读写的效率远高于单字节操作,因为指令和地址的开销被摊薄了。所以实际项目中,尽量用连续读写,把要更新的参数攒在一起批量写。我那个电力监测项目,每秒把 200 多个字节的参数一次性写入,耗时不到 300 微秒,对 1 秒的周期来说完全无压力。
7.2 扩展场景:数据黑匣子与参数管理
MRAM 加 PIC24 这个组合,除了参数存储,还适合做数据黑匣子。比如设备运行状态记录,每秒钟记录一次关键参数,MRAM 的 512KB 容量可以存几十万条记录。因为写入无延迟,不会阻塞主控制循环。读取的时候通过串口或者 USB 把数据导出来分析。
另一个场景是频繁校准参数的存储。有些传感器需要定期校准,校准系数要频繁更新。用 EEPROM 的话,每次更新都要等写周期,而且寿命有限。用 MRAM 就完全没这个问题,校准系数可以随时更新,写多少次都行。
如果数据量更大,可以多颗 MR25H40CDF 并联,用不同的片选引脚扩展容量。PIC24EP512GU810 有足够的 IO 来驱动多颗芯片,软件上只需要在读写函数里增加片选选择参数即可。
7.3 代码组织与项目结构建议
最后说一下代码组织。我习惯把 MRAM 驱动分成三层:底层是 SPI 收发函数,中间层是 MRAM 指令级操作(读字节、写字节、读缓冲、写缓冲),上层是应用级接口(参数读取、参数保存、黑匣子记录)。这样分层的好处是,如果以后换 MCU 或者换存储芯片,只需要改底层和中间层,上层应用代码不动。
文件结构大概是:
spi1_drv.c/h:SPI 初始化与字节收发mram_drv.c/h:MRAM 指令级读写param_mgr.c/h:参数管理,含双备份和 CRCblackbox.c/h:黑匣子记录,含环形缓冲
每个模块的接口尽量简单,比如参数管理只暴露Param_Read()和Param_Write()两个函数,内部实现细节对外透明。这样调试的时候也方便,可以单独测试每一层。
我在实际项目里还加了一个自检功能:上电时往 MRAM 的保留区域写入一个测试模式,读回来比对,确认芯片工作正常。如果自检失败,设备进入安全模式,使用默认参数运行,同时上报故障。这个功能帮我提前发现过一批焊接不良的板子,省了不少现场返工的麻烦。