1. 项目缘起与方案选型:为什么是 MRAM 加 PIC18
1.1 一个真实的需求场景
前阵子接手了一个工业数据采集终端的改造项目,设备装在配电柜里,环境温度夏天能到 70 度,冬天户外柜内能到零下 30 度。原来的方案用的是 EEPROM 存配置参数和掉电前的累计数据,问题出在两个地方:一是写入速度太慢,一次参数保存要等 5 到 10 毫秒,主循环被拖得很难受;二是擦写寿命只有 100 万次左右,设备每 30 秒写一次运行状态,算下来不到一年就开始出现坏块,现场返修换板子的成本高得离谱。
后来把存储介质换成了 MR25H40CDF 这颗 MRAM,主控用的是 PIC18F86K22,整套方案跑下来效果很稳。这篇就把整个设计思路、硬件连接、SPI 驱动、读写时序、踩过的坑完整梳理一遍,给做工业嵌入式存储的朋友一个可以直接抄的参考。
MR25H40CDF 是 Everspin 出的 4Mbit 串行 MRAM,SPI 接口,512K 乘 8 的组织方式。它最大的特点是非易失、写入无延迟、擦写寿命几乎无限(官方标称 10 的 14 次方以上),而且不需要像 EEPROM 那样等待内部写周期。PIC18F86K22 是 Microchip 的 8 位单片机,64 脚,自带 MSSP 模块可以跑硬件 SPI,主频最高 64MHz,对于这种中等数据量的存储读写完全够用。
1.2 为什么不用 Flash 或 FRAM
选型的时候我把几个候选都摆出来对比过,这里直接上表,省得大家再走一遍弯路。
| 存储类型 | 写入速度 | 擦写寿命 | 掉电保持 | 写入前是否需擦除 | 成本 |
|---|---|---|---|---|---|
| EEPROM | 慢,毫秒级 | 约 100 万次 | 10 年以上 | 否 | 低 |
| NOR Flash | 慢,需整扇区擦 | 约 10 万次 | 10 年以上 | 是 | 低 |
| FRAM | 快,无延迟 | 约 10 的 12 次方 | 10 年以上 | 否 | 中高 |
| MRAM | 快,无延迟 | 10 的 14 次方以上 | 20 年以上 | 否 | 中 |
Flash 的问题在于写之前必须擦除整个扇区,而且擦除时间长,对于需要频繁记录运行日志的场景非常不友好。FRAM 其实也能满足需求,但当时供货周期不稳定,价格也比 MRAM 高出一截。MRAM 在写入速度、寿命、保持时间三个维度上都是最优解,虽然单价贵一点,但算上后期维护成本,整体反而更划算。
提示:MRAM 的写入是真正的随机访问,不需要擦除,也不需要等待内部写完成。这一点和 EEPROM、Flash 有本质区别,驱动写法上可以省掉很多状态轮询的逻辑。
1.3 整体架构设计
整个系统的数据流是这样的:PIC18F86K22 通过硬件 SPI 与 MR25H40CDF 通信,MRAM 里划分成几个区域,分别是设备配置区、运行日志区、掉电保护区和固件升级标志区。配置区存设备 ID、通信参数、校准系数这些不常变的数据;日志区循环写入运行状态,每条记录带时间戳和 CRC;掉电保护区在检测到电源异常时紧急写入当前关键变量;升级标志区就一个字节,用来标记是否需要进入 Bootloader。
这种分区方式的好处是各区域互不干扰,日志区写满了直接覆盖最旧的数据,不会影响配置区。而且 MRAM 的随机写入特性让循环日志的实现变得非常简单,不需要像 Flash 那样做复杂的磨损均衡。
2. 硬件连接与 SPI 时序要点
2.1 引脚连接与硬件设计
MR25H40CDF 是 8 脚 SOIC 封装,引脚定义很标准:CS 片选、SO 数据输出、SI 数据输入、SCK 时钟、VDD、VSS,另外还有 WP 写保护和 HOLD 保持脚。和 PIC18F86K22 的连接方式如下。
| MR25H40CDF 引脚 | PIC18F86K22 引脚 | 说明 |
|---|---|---|
| CS | RC2 | 硬件片选,也可用普通 IO 软件控制 |
| SO | RC4 | SPI 数据输入 |
| SI | RC5 | SPI 数据输出 |
| SCK | RC3 | SPI 时钟 |
| WP | 接 VDD | 不用的写保护直接拉高 |
| HOLD | 接 VDD | 不用的保持功能直接拉高 |
| VDD | 3.3V | 供电 |
| VSS | GND | 地 |
这里有个细节要注意,PIC18F86K22 的 IO 电压是 5V 容忍的,但 MR25H40CDF 是 3.3V 供电,所以 SPI 信号线上最好加电平转换,或者确认主控的 SPI 输出电平在 MRAM 的输入高电平阈值以上。我实测下来,PIC 在 3.3V 供电时直接连没问题,但如果 PIC 跑 5V,SI 和 SCK 这两根线必须做电平转换,否则长期工作会损伤 MRAM 的输入级。
PCB 布局上,去耦电容一定要紧贴 MRAM 的 VDD 和 VSS 引脚,我用的是一颗 0.1uF 加一颗 1uF 并联。SPI 的四根线尽量等长,走线不要跨分割地平面。如果线长超过 10 厘米,建议在 SCK 上串一颗 22 欧姆的电阻做阻抗匹配,抑制过冲。
2.2 SPI 模式与时钟配置
MR25H40CDF 支持 SPI 模式 0 和模式 3,也就是 CPOL 和 CPHA 的组合。模式 0 是时钟空闲低电平,数据在上升沿采样;模式 3 是时钟空闲高电平,数据在上升沿采样。两种模式都能用,我选的是模式 0,因为 PIC 的 MSSP 模块配置成模式 0 最直观。
时钟频率方面,MR25H40CDF 最高支持 40MHz 的 SCK。PIC18F86K22 在 64MHz 主频下,MSSP 的 SPI 时钟可以配置到 16MHz 或 10.67MHz 等分频值。我实际用的是 10MHz 左右,原因是我的 PCB 走线比较长,10MHz 下信号质量最稳,而且对于 512K 的容量来说,10MHz 的读写速度已经完全够用。算一下,读一整片 512K 字节需要 512K 乘 8 等于 4M 个时钟周期,加上命令开销,10MHz 下大约 0.4 秒,实际应用里根本不需要全片读取,都是按地址随机访问,单次读写几个字节到几百字节,耗时在微秒级。
配置 MSSP 的关键寄存器就几个:SSPCON1 设置 SSPEN 使能、CKP 时钟极性、SSPM 模式选择;SSPSTAT 设置 SMP 采样相位和 CKE 时钟边沿;SSPADD 在主机模式下设置时钟分频。具体值我后面在驱动部分给出。
2.3 命令集与地址组织
MR25H40CDF 的命令集和标准 SPI Flash 很像,但少了擦除相关的命令。核心命令有这几个。
| 命令名称 | 命令码 | 说明 |
|---|---|---|
| WREN | 0x06 | 写使能,写之前必须发 |
| WRDI | 0x04 | 写禁止 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 读数据 |
| WRITE | 0x02 | 写数据 |
地址是 24 位的,因为 512K 字节需要 19 位地址,用 3 个字节传输,高 5 位忽略。读写的时序都是先拉低 CS,发送命令码,然后发送 3 字节地址,接着就是数据字节的连续传输。读操作可以连续读任意长度,地址会自动递增;写操作也一样,但要注意写之前必须先发 WREN 命令,而且 CS 拉高之后写使能会自动复位。
注意:WREN 命令之后必须紧跟写命令,中间不能插入其他操作,否则写使能可能被清除。我在调试时就遇到过因为中间插了一个状态查询导致写不进去的情况。
3. 底层驱动实现与关键代码
3.1 SPI 初始化与基础收发函数
先看 PIC 这边的 SPI 初始化。我用的是 MSSP1 模块,配置成 SPI 主机模式,时钟模式 0,时钟频率约 10MHz。
void SPI1_Init(void) { TRISCbits.TRISC3 = 0; // SCK 输出 TRISCbits.TRISC5 = 0; // SDO 输出 TRISCbits.TRISC4 = 1; // SDI 输入 TRISCbits.TRISC2 = 0; // CS 输出 LATCbits.LATC2 = 1; // CS 默认拉高 SSP1STAT = 0x40; // SMP=0 中间采样, CKE=0 空闲低电平 SSP1CON1 = 0x20; // SSPEN=1, SSPM=0000 主机模式 Fosc/4 SSP1ADD = 1; // 时钟分频,实际约 10MHz }这里 SSP1ADD 的值需要根据主频计算。PIC18F86K22 的 SPI 时钟是 Fosc 除以 (4 乘 (SSP1ADD 加 1))。如果 Fosc 是 64MHz,SSP1ADD 设为 1,得到 64M 除以 8 等于 8MHz。设成 0 就是 16MHz。我选 1 是为了留余量。
基础收发函数很简单,一个字节换一个字节。
uint8_t SPI1_ExchangeByte(uint8_t data) { SSP1BUF = data; while(!PIR1bits.SSP1IF); // 等待传输完成 PIR1bits.SSP1IF = 0; return SSP1BUF; }这个函数是阻塞式的,对于低速应用完全够用。如果系统对实时性要求高,可以改成中断或 DMA 方式,但 MRAM 的读写本身很快,阻塞几十微秒对大多数工业应用没影响。
3.2 MRAM 读写函数实现
读函数先拉低 CS,发 READ 命令,发 3 字节地址,然后连续读数据。
void MRAM_Read(uint32_t addr, uint8_t *buf, uint16_t len) { uint8_t i; CS_LOW(); SPI1_ExchangeByte(0x03); // READ 命令 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); } CS_HIGH(); }写函数多一步 WREN。
void MRAM_Write(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t i; CS_LOW(); SPI1_ExchangeByte(0x06); // WREN CS_HIGH(); 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]); } CS_HIGH(); }写完 CS 拉高之后,数据就已经真正写进 MRAM 了,不需要任何等待。这一点和 EEPROM 完全不同,EEPROM 写完还要等 5 毫秒左右才能进行下一次操作,MRAM 是立即生效的。
3.3 状态寄存器与写保护
MR25H40CDF 的状态寄存器只有几位有效,主要是 BP0 和 BP1 两位用来做块保护,还有 WEL 位表示写使能锁存。读状态寄存器用 RDSR 命令。
uint8_t MRAM_ReadStatus(void) { uint8_t status; CS_LOW(); SPI1_ExchangeByte(0x05); status = SPI1_ExchangeByte(0xFF); CS_HIGH(); return status; }如果应用里需要保护某些区域不被误写,可以通过 WRSR 命令设置 BP 位。比如把配置区所在的块设为只读,只有特定条件下才解锁写入。这个功能在工业设备里很有用,防止程序跑飞时把校准参数写坏。
实操心得:我一般会在初始化时读一次状态寄存器,确认 WEL 位是 0,BP 位是 0,确保 MRAM 处于可写状态。如果读回来全是 0xFF,说明 SPI 通信有问题,优先检查 CS、SCK 和供电。
4. 数据组织与掉电保护策略
4.1 存储区域划分
512K 字节的空间看着不大,但合理规划之后能存不少东西。我的划分方案如下。
| 区域名称 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| 配置区 | 0x00000 | 4KB | 设备参数、校准系数 |
| 日志区 | 0x01000 | 256KB | 循环运行日志 |
| 掉电保护区 | 0x41000 | 4KB | 关键变量快照 |
| 升级标志区 | 0x42000 | 4KB | Bootloader 标志 |
| 预留区 | 0x43000 | 剩余 | 后续扩展 |
配置区用双备份加 CRC 的方式,写的时候先写备份区,校验通过再写主区,读的时候如果主区 CRC 错就自动切到备份区。这样即使写入过程中掉电,也不会丢失配置。
日志区做成环形缓冲区,每条记录固定 32 字节,包含 4 字节时间戳、2 字节数据类型、24 字节数据和 2 字节 CRC。写指针存在掉电保护区里,每次写日志前先更新指针,再写数据。MRAM 的写入无延迟特性让这个流程非常流畅,不需要考虑写等待。
4.2 掉电检测与紧急写入
掉电保护是工业设备的核心需求。PIC18F86K22 的电源监测可以用内部 LVD 模块,也可以外部分压电阻接到 ADC 或者比较器。我用的是外部比较器方案,电源正常时比较器输出高,一旦电源跌落到 4.5V 以下,比较器翻转触发中断。
中断服务程序里要尽快把关键变量写进 MRAM。因为 MRAM 写入不需要等待,一个 32 字节的记录写入时间在 10MHz SPI 下大约是 30 微秒,加上函数调用开销,总共不到 50 微秒。电源从 4.5V 跌到 PIC 最低工作电压 2.0V,靠板上的电容储能通常能撑几百微秒到几毫秒,时间完全够用。
void __interrupt(high_priority) HighISR(void) { if(PIR2bits.CMP2IF) { PIR2bits.CMP2IF = 0; // 紧急保存关键变量 MRAM_Write(POWER_FAIL_ADDR, (uint8_t*)&critical_data, sizeof(critical_data)); // 置位掉电标志 power_fail_flag = 1; } }注意:掉电中断里不要做复杂运算,也不要调用可能阻塞的函数。写入的数据最好提前组织好放在一个结构体里,中断里直接整块搬过去。
4.3 数据校验与恢复
MRAM 本身可靠性很高,但为了万无一失,每条记录都带 CRC16 校验。读的时候先算 CRC,对不上就跳过这条记录,继续读下一条。日志区是环形的,读的时候从写指针往前找,找到第一条 CRC 正确的记录作为最新有效数据。
配置区的恢复逻辑稍微复杂一点。主区和备份区各有一个有效标志和 CRC。上电时先读主区,如果有效就用主区;如果主区无效,读备份区,有效就用备份区并回写主区;如果两个都无效,加载默认配置并写入两个区。这套逻辑跑下来,即使反复掉电也不会丢配置。
5. 实测性能与常见问题排查
5.1 读写速度实测数据
我用逻辑分析仪抓了 SPI 波形,同时用定时器测了函数执行时间。在 10MHz SPI 时钟下,单字节读写和批量读写的耗时如下。
| 操作类型 | 数据长度 | 实测耗时 | 说明 |
|---|---|---|---|
| 单字节写 | 1 字节 | 约 8 微秒 | 含 WREN 和地址开销 |
| 单字节读 | 1 字节 | 约 5 微秒 | 含命令和地址 |
| 批量写 | 256 字节 | 约 220 微秒 | 地址开销摊薄 |
| 批量读 | 256 字节 | 约 215 微秒 | 连续读 |
| 全片读 | 512KB | 约 0.45 秒 | 理论值附近 |
这个速度对于工业数据采集完全够用。我实际应用里每 100 毫秒写一次 32 字节的日志,占用 CPU 时间不到 30 微秒,对主循环几乎没有影响。
5.2 常见问题速查表
调试过程中踩了不少坑,整理成表格方便大家对照排查。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 读回全 0xFF | CS 未拉低或 SPI 未使能 | 示波器看 CS 和 SCK | 检查初始化代码和引脚方向 |
| 读回全 0x00 | MISO 未接或上拉 | 万用表测 SO 引脚 | 检查焊接和连线 |
| 写入后读回旧数据 | WREN 未发或 CS 时序错 | 逻辑分析仪抓完整时序 | 确保 WREN 后紧跟写命令 |
| 偶发数据错误 | SPI 时钟太快或走线太长 | 降低时钟测试 | 降速或加匹配电阻 |
| 状态寄存器读不对 | 命令码错误 | 对照数据手册 | 确认 RDSR 是 0x05 |
| 掉电后数据丢失 | 中断响应太慢 | 测中断延迟 | 优化中断优先级和代码 |
5.3 几个容易忽略的细节
第一个是 CS 的时序。MR25H40CDF 要求 CS 拉低之后到第一个 SCK 上升沿之间至少有 5 纳秒的建立时间,CS 拉高之前最后一个 SCK 下降沿到 CS 上升沿之间也要有 5 纳秒。在 10MHz 下这个时间很充裕,但如果跑到 40MHz,就必须仔细检查代码里 CS 操作和 SPI 传输之间的指令周期。
第二个是 WP 和 HOLD 引脚。这两个脚如果不使用,一定要拉到 VDD,不能悬空。悬空时引脚电平不确定,可能导致内部写保护误触发或者通信被保持。我一开始把 HOLD 悬空了,结果偶尔出现读写失败,查了半天才发现是这个原因。
第三个是电源上电顺序。如果 PIC 和 MRAM 用不同的电源轨,要确保 MRAM 先上电或者同时上电。如果 PIC 先上电而 MRAM 还没准备好,PIC 发出的命令会被忽略,初始化时读到的状态就是错的。我的做法是在初始化 MRAM 之前加 10 毫秒延时,等电源稳定后再操作。
第四个是 SPI 模式的一致性。PIC 的 MSSP 配置成模式 0 之后,MRAM 也必须工作在模式 0。如果发现读出的数据位错位,比如 0x03 读成 0x06,那多半是采样边沿搞反了,检查 SSP1STAT 的 SMP 和 CKE 位。
6. 工程化建议与扩展思路
6.1 驱动分层与移植性
虽然这个项目用的是 PIC18 平台,但驱动最好做成两层:底层是 SPI 收发和 CS 控制,上层是 MRAM 的命令封装。这样换到 STM32 或者别的平台时,只需要改底层几个函数,上层逻辑完全不用动。
我在实际项目里把底层抽象成四个函数:SPI_Init、SPI_Transfer、CS_Low、CS_High。上层 MRAM_Read、MRAM_Write、MRAM_ReadStatus 都基于这四个函数实现。后来把这个驱动移植到 STM32F103 上,只花了不到半小时改底层,上层代码一行没动。
6.2 日志系统的优化
如果日志写入频率很高,可以考虑在 RAM 里做一个缓冲区,攒够一批再一次性写入 MRAM。虽然 MRAM 单次写入很快,但每次都有命令和地址开销,批量写能把这部分开销摊薄。我实测过,单字节写 256 次耗时约 2 毫秒,而一次性写 256 字节只要 220 微秒,差了将近十倍。
不过批量写要注意掉电风险。如果数据还在 RAM 缓冲区里没写进 MRAM 就掉电了,这部分数据就丢了。折中方案是设置一个合理的批量大小,比如 64 字节,同时配合掉电中断,在检测到掉电时立即把缓冲区刷进 MRAM。
6.3 后续可以扩展的方向
这套存储方案跑通之后,还可以往几个方向扩展。一是加文件系统,把 MRAM 当成一个小型磁盘,用 FatFS 或者 LittleFS 管理,这样上层应用可以直接用文件接口读写,不用关心地址分配。二是做双机冗余,两片 MRAM 同时存相同数据,读的时候对比两片的结果,进一步提高可靠性。三是结合 RTC,给每条日志加上精确的日历时间,方便后期数据分析。
我个人在实际操作中的体会是,MRAM 这种器件最大的价值不在于速度快,而在于它把“写”这个操作变得像写 RAM 一样简单。一旦习惯了这种无延迟、无擦除、无限寿命的写入方式,再回去用 EEPROM 或者 Flash 就会觉得处处受限。对于工业设备这种需要长期稳定运行、频繁记录数据的场景,MR25H40CDF 加 PIC18F86K22 这套组合确实是一个省心的选择。最后再分享一个小技巧:调试 SPI 通信时,先用低速时钟把功能跑通,确认数据正确后再逐步提高时钟频率,这样能把时序问题和逻辑问题分开排查,效率高很多。