
1. 项目缘起与方案选型为什么是 MRAM 加 PIC241.1 一个真实的需求场景前阵子接了个工业数据采集终端的活儿需求说起来不复杂设备装在配电柜里每隔几百毫秒采集一次电流、温度、开关量状态本地要留一份掉电不丢的历史记录方便事后追溯故障。客户给的约束条件很实在——环境温度夏天能到 70 度供电会频繁断电设备寿命要求十年以上而且成本不能太高。这种场景下选存储方案就成了整个项目最关键的一步。我一开始想的是最常规的路子外挂一颗 SPI Flash比如 W25Q 系列便宜、容量大、资料多。但仔细一算就发现问题了。采集频率按 500ms 一次算一天就是 17 万次写入一年 6000 多万次。Flash 的擦写寿命通常标称 10 万次就算做磨损均衡、按扇区轮换也撑不了太久。更麻烦的是 Flash 写入前必须先擦除整个扇区擦除期间如果正好断电那一整块数据都可能损坏这在工业现场是致命的。后来也考虑过 FRAM铁电存储器写入快、寿命几乎无限但容量普遍偏小而且价格比 Flash 贵不少。再往后看就注意到了 MRAM 这个方向。MRAM 是磁性随机存储器它的存储单元靠磁隧道结的磁化方向来记录 0 和 1本质上不是靠电荷所以既没有 Flash 的擦写寿命问题也没有 FRAM 的容量瓶颈读写速度还接近 SRAM。这次项目里选用的就是 Everspin 的 MR25H40CDF4Mbit 容量SPI 接口工业级温度范围正好卡在需求和成本之间。主控这边选的是 Microchip 的 PIC24FV32KA301。这颗片子是 16 位 MCU32KB Flash、2KB RAM带 5V 容忍的 IO工作电压范围宽抗干扰能力在工业环境里口碑一直不错。最关键的是它自带硬件 SPI 模块跟 MR25H40CDF 对接起来非常顺。整套方案下来BOM 成本可控可靠性有保障代码量也不大很适合中小型工业嵌入式项目。1.2 MR25H40CDF 到底是个什么东西先把这颗芯片讲清楚。MR25H40CDF 是 Everspin 出的 4Mbit 串行 MRAM注意单位是 bit换算成字节就是 512KB。它走标准 SPI 接口支持 SPI 模式 0 和模式 3最高时钟频率能到 40MHz。供电 2.7V 到 3.6V工业级版本工作温度覆盖 -40 到 85 度部分型号能到 105 度。它和普通 SPI Flash 最大的区别在于写入不需要擦除也不需要等待。Flash 写一个字节之前得先发擦除命令等几毫秒擦完再发写命令再等几毫秒写完。MRAM 没有这个过程你发完写命令数据当场就进去了下一个时钟周期就能读出来。这个特性对工业采集这种高频小数据量写入的场景来说简直是量身定做。另外它的写入寿命是无限的官方标称读写次数没有上限实际测试里连续写几十亿次也没问题。数据保持时间标称 20 年以上而且不像 Flash 那样有电荷泄漏导致的数据丢失风险。掉电瞬间数据就已经固化在磁隧道结里了不需要额外的掉电保护电路。1.3 PIC24FV32KA301 的 SPI 资源盘点PIC24FV32KA301 这颗 MCU 我用了很多年了它的 SPI 模块配置起来比较直观。它有两个 SPI 模块SPI1 和 SPI2我们这次用 SPI1 接 MRAM。相关引脚是SCK1时钟输出对应 RP 引脚可重映射SDI1数据输入SDO1数据输出SS1片选可以硬件控制也可以软件控制这里有个细节要注意PIC24 的引脚是 remappable 的也就是说 SPI 的物理引脚不是固定的需要通过 PPS 寄存器把外设功能映射到具体的 RP 引脚上。这个设计很灵活布线的时候能省不少事但配置的时候容易漏掉后面实操部分我会详细讲。SPI1 的时钟源来自系统时钟通过 SPI1CON1 里的主时钟分频位来设置。PIC24FV32KA301 在 3.3V 下最高能跑到 32MHz 系统时钟SPI 分频后可以做到 8MHz 或 16MHz对 MRAM 来说完全够用甚至还有余量。1.4 为什么不用软件模拟 SPI有人可能会问既然 SPI 协议这么简单为什么不用 GPIO 软件模拟我的答案是能用硬件 SPI 就别用软件模拟。原因有三点。第一是速度。软件模拟 SPI 受限于 GPIO 翻转速度和循环开销实际速率通常只有几百 KHz 到 1MHz 左右而硬件 SPI 轻松跑到 8MHz 以上。对于需要频繁读写 MRAM 的采集场景速度差距直接影响系统响应。第二是 CPU 占用。软件模拟 SPI 在传输期间 CPU 是满负荷跑循环的没法干别的事。硬件 SPI 传输时 CPU 可以去处理其他任务传完中断通知就行。工业设备里往往要同时处理采集、通信、显示CPU 时间很宝贵。第三是稳定性。软件模拟 SPI 的时序靠代码延时保证一旦中断打断或者编译器优化时序就可能出问题。硬件 SPI 的时序由外设硬件保证不受软件干扰在电磁环境复杂的工业现场更可靠。所以这个项目里硬件 SPI 是唯一合理的选择。2. 硬件设计与连接细节2.1 电路连接方案MR25H40CDF 和 PIC24FV32KA301 的连接非常直接标准四线 SPI 加电源地。具体连接关系如下表MR25H40CDF 引脚功能PIC24FV32KA301 引脚说明CS片选RP 引脚软件控制低电平有效SCK时钟SCK1由 MCU 输出SI数据输入SDO1MCU 输出到 MRAMSO数据输出SDI1MRAM 输出到 MCUVCC电源3.3V加 0.1uF 去耦VSS地GND就近接地这里片选我建议用软件控制也就是用一个普通 GPIO 来拉低拉高而不是用 SPI 模块自带的 SS1 硬件片选。原因后面会讲简单说就是软件片选更灵活能精确控制命令之间的间隔也方便在总线上挂多个从设备。2.2 去耦和布线要点工业环境的电磁干扰比实验室恶劣得多MRAM 的电源去耦必须做好。我的做法是在 MR25H40CDF 的 VCC 和 VSS 之间就近放一颗 0.1uF 的陶瓷电容距离芯片引脚不超过 2mm。如果板子空间允许再并一颗 1uF 的电容做低频滤波。SPI 信号线走线也有讲究。SCK 是时钟线翻转频率最高最容易辐射干扰走线要尽量短最好包地。SI 和 SO 是数据线可以并行走但不要和 SCK 长距离平行避免串扰。CS 线虽然频率低但它是命令的起始标志也不能太长否则边沿变缓可能导致误触发。还有一点容易被忽略MRAM 的 SO 引脚在片选无效时是高阻态如果总线上挂了多个从设备SO 线是共享的这时候必须保证同一时刻只有一个设备的片选有效否则会出现总线冲突。这也是我推荐软件片选的原因之一软件控制能确保片选切换的时序完全可控。2.3 电源设计的一个坑MR25H40CDF 的供电范围是 2.7V 到 3.6V标称 3.3V。PIC24FV32KA301 虽然支持 5V 供电但为了和 MRAM 电平匹配整个系统我统一用 3.3V。这里有个坑要提醒PIC24FV32KA301 的 IO 在 3.3V 供电时输出高电平大约是 3.3V输入高电平阈值大约是 0.7 倍 VDD也就是 2.3V 左右和 MRAM 的电平完全兼容不需要电平转换。但如果你用的是 5V 供电的 PIC24 型号那就必须注意了。5V 的 IO 直接接 3.3V 的 MRAM虽然 MRAM 的输入引脚大多能容忍 5V但长期工作在超规格电压下会缩短寿命。稳妥的做法是加电平转换芯片或者干脆整个系统都用 3.3V。我这次项目里所有器件都是 3.3V省掉了电平转换布线和成本都更简单。3. SPI 通信协议与 MRAM 命令集3.1 SPI 模式的选择SPI 有四种模式由时钟极性 CPOL 和时钟相位 CPHA 组合而成。MR25H40CDF 支持模式 0CPOL0CPHA0和模式 3CPOL1CPHA1。这两种模式的共同点是数据在时钟的上升沿采样区别在于空闲时时钟是高还是低。我一般选模式 0因为它是绝大多数 SPI 器件的默认模式调试的时候用逻辑分析仪抓波形模式 0 的波形最直观时钟空闲低电平第一个边沿上升沿采样数据。PIC24 的 SPI1CON1 寄存器里CKP 位控制时钟极性CKE 位控制时钟相位模式 0 对应 CKP0、CKE1注意 PIC24 的 CKE 定义和通用定义略有不同CKE1 表示数据在时钟从有效到空闲的边沿变化实际配置时要以数据手册为准。配置的时候我踩过一次坑一开始按通用定义设了 CKE0结果读出来的数据全是 0xFF。后来查了 PIC24 的数据手册才发现它的 CKE 位定义和标准说法是反的改成 CKE1 就正常了。这种细节一定要对着具体型号的手册看不能想当然。3.2 MRAM 的核心命令MR25H40CDF 的命令集很精简常用的就几条命令名称命令码功能说明WREN0x06写使能写操作前必须发WRDI0x04写禁止RDSR0x05读状态寄存器WRSR0x01写状态寄存器READ0x03读数据WRITE0x02写数据读数据的流程是拉低 CS发 0x03发 3 字节地址24 位地址因为 512KB 需要 19 位地址用 3 字节表示然后连续读数据读完拉高 CS。写数据的流程是拉低 CS发 0x06 写使能拉高 CS再拉低 CS发 0x02发 3 字节地址发数据拉高 CS。注意写操作前必须先发 WREN 命令而且 WREN 之后 CS 必须拉高再拉低这是 MRAM 的要求不能在同一次片选有效期内连续发 WREN 和 WRITE。这个细节和很多 Flash 不一样我第一次用的时候就是因为这个卡了半天读出来的数据一直不对。3.3 地址空间的划分512KB 的地址空间从 0x00000 到 0x7FFFF。我在项目里把它划分成几个区域0x00000 到 0x000FF系统配置区存设备参数、校准系数0x00100 到 0x00FFF实时数据区存最新的采集值0x01000 到 0x7FFFF历史记录区循环写入写满后从头覆盖历史记录区用循环队列的方式管理维护一个写指针每次写完一条记录指针后移到末尾就回到起始位置。这样不需要擦除也不需要复杂的文件系统简单可靠。每条记录固定长度比如 32 字节包含时间戳、电流值、温度值、状态位和校验和。512KB 减去前面的区域大约能存 16000 条记录按 500ms 一条算能存两个多小时的历史数据对故障追溯来说够用了。4. 底层驱动代码实现4.1 SPI 初始化配置先看 SPI1 的初始化代码。这段代码是整个驱动的基础配置错了后面全白搭。void SPI1_Init(void) { // 关闭 SPI1 再配置 SPI1STATbits.SPIEN 0; // 配置控制寄存器 // 主模式模式08位数据时钟分频 SPI1CON1 0x0137; // 0x0137 拆解 // bit15 SSEN 0软件片选 // bit14 CKP 0空闲时钟低 // bit13 CKE 1模式0对应 // bit12-10 保留 // bit9 SMP 0在数据输出末尾采样 // bit8-6 保留 // bit5-2 主时钟分频0111 表示 1:1 预分频再 1:1 后分频 // bit1-0 保留 // 设置波特率假设系统时钟 32MHz // SPI 时钟 32MHz / (2 * (SPI1BRG 1)) // 要得到 8MHzSPI1BRG 1 SPI1BRG 1; // 清中断标志关中断用轮询方式 SPI1STATbits.SPIROV 0; IFS0bits.SPI1IF 0; IEC0bits.SPI1IE 0; // 使能 SPI1 SPI1STATbits.SPIEN 1; }这里 SPI1BRG 的计算要说明一下。PIC24 的 SPI 波特率公式是SPI 时钟 FCY / (2 * (SPI1BRG 1))其中 FCY 是系统时钟。32MHz 系统时钟下SPI1BRG1 得到 8MHzSPI1BRG0 得到 16MHz。MRAM 最高支持 40MHz所以 16MHz 也没问题但考虑到工业现场的干扰和走线质量我保守选了 8MHz实测很稳。4.2 引脚重映射配置PIC24 的 SPI 引脚需要重映射这段代码不能漏void SPI1_PinMap(void) { // 解锁 PPS 配置 __builtin_write_OSCCONL(OSCCON 0xBF); // 配置输入引脚SDI1 映射到 RP10 _SDI1R 10; // 配置输出引脚SDO1 映射到 RP11SCK1 映射到 RP12 _RP11R 7; // 7 是 SDO1 的输出功能号 _RP12R 8; // 8 是 SCK1 的输出功能号 // 锁定 PPS 配置 __builtin_write_OSCCONL(OSCCON | 0x40); }这里的功能号 7 和 8 是查数据手册得到的不同型号可能不一样一定要对着自己芯片的手册确认。我见过有人直接抄别人的代码结果功能号不对SPI 死活不出波形查了半天才发现是映射错了。片选引脚我用的是 RP13配置成普通输出void CS_Init(void) { TRISBbits.TRISB13 0; // 输出 LATBbits.LATB13 1; // 默认拉高片选无效 }4.3 字节收发函数SPI 的底层收发函数用轮询方式实现uint8_t SPI1_Transfer(uint8_t data) { // 等待发送缓冲空 while (SPI1STATbits.SPITBF); // 写入数据 SPI1BUF data; // 等待接收完成 while (!SPI1STATbits.SPIRBF); // 返回接收到的数据 return SPI1BUF; }这个函数是阻塞式的每传一个字节都要等。对于 8MHz 的 SPI 时钟传一个字节大约 1 微秒512KB 全读一遍也就半秒多对采集应用来说完全可接受。如果追求更高效率可以用中断或 DMA但会增加代码复杂度这个项目里没必要。4.4 MRAM 读写函数有了底层收发MRAM 的读写就水到渠成了void MRAM_WriteEnable(void) { CS_LOW(); SPI1_Transfer(0x06); CS_HIGH(); } void MRAM_Write(uint32_t addr, uint8_t *buf, uint16_t len) { MRAM_WriteEnable(); CS_LOW(); SPI1_Transfer(0x02); SPI1_Transfer((addr 16) 0xFF); SPI1_Transfer((addr 8) 0xFF); SPI1_Transfer(addr 0xFF); for (uint16_t i 0; i len; i) { SPI1_Transfer(buf[i]); } CS_HIGH(); } void MRAM_Read(uint32_t addr, uint8_t *buf, uint16_t len) { CS_LOW(); SPI1_Transfer(0x03); SPI1_Transfer((addr 16) 0xFF); SPI1_Transfer((addr 8) 0xFF); SPI1_Transfer(addr 0xFF); for (uint16_t i 0; i len; i) { buf[i] SPI1_Transfer(0x00); } CS_HIGH(); }注意读操作里发送的是 0x00这是占位数据因为 SPI 是全双工的主机要产生时钟就必须发送数据从机在时钟驱动下把数据移出来。发什么无所谓发 0x00 就行。5. 数据可靠性与掉电保护5.1 校验和的设计工业现场的数据不能出错每条记录我都加了校验和。用的是简单的累加和取反计算快够用uint8_t CalcChecksum(uint8_t *buf, uint16_t len) { uint8_t sum 0; for (uint16_t i 0; i len; i) { sum buf[i]; } return ~sum; }写入的时候把校验和放在记录的最后一位读取的时候重新计算并比对。如果对不上说明数据损坏这条记录就丢弃。实测下来MRAM 本身几乎不会出错校验和主要是防总线干扰和软件 bug。5.2 掉电保护的天然优势前面说过MRAM 写入是即时的不需要等待。这意味着掉电保护变得极其简单你写完数据数据就已经在 MRAM 里了不需要任何额外的操作。相比之下Flash 方案需要检测掉电、启动备用电源、完成擦写、再断电电路和软件都复杂得多。不过有一点要注意虽然 MRAM 写入是即时的但 SPI 传输本身需要时间。如果掉电发生在 SPI 传输过程中这条记录可能只写了一半。我的做法是在每条记录开头加一个标志字节写完数据后再写标志字节表示记录有效。读取的时候先看标志字节无效就跳过。这样即使掉电打断也不会读到半截数据。5.3 循环队列的实现历史记录区用循环队列管理核心是一个写指针#define HISTORY_START 0x01000 #define HISTORY_END 0x7FFFF #define RECORD_SIZE 32 static uint32_t write_ptr HISTORY_START; void SaveRecord(uint8_t *record) { // 写标志字节为 0表示正在写 uint8_t flag 0; MRAM_Write(write_ptr, flag, 1); // 写数据 MRAM_Write(write_ptr 1, record, RECORD_SIZE - 1); // 写标志字节为 0xA5表示有效 flag 0xA5; MRAM_Write(write_ptr, flag, 1); // 指针后移 write_ptr RECORD_SIZE; if (write_ptr RECORD_SIZE HISTORY_END) { write_ptr HISTORY_START; } }这个实现简单粗暴但很可靠。写指针不需要持久化因为即使掉电丢失了指针最多是覆盖掉最老的记录不影响新数据的写入。如果要保留指针也可以定期把指针存到配置区但我觉得没必要循环队列的设计本来就是为了自动覆盖。6. 常见问题与排查实录6.1 读出来全是 0xFF 或 0x00这是最常见的现象通常有三个原因。第一是片选没拉低或者拉低了但引脚配置错了。用万用表量一下 CS 引脚发命令的时候应该是低电平。第二是 SPI 模式配错了CKP 和 CKE 不对数据采样边沿错了读出来就是乱的。第三是引脚重映射没配对SCK 或 SDO 没映射到正确的 RP 引脚上示波器量不到波形。排查顺序建议是先量 CS再量 SCK最后量 SDO 和 SDI。CS 和 SCK 正常了再看数据线。我一般会用逻辑分析仪抓一段波形对着 MRAM 数据手册的时序图比对一眼就能看出问题。6.2 写入后读出来不对如果读操作正常写操作不对重点查 WREN 命令。前面强调过MRAM 要求 WREN 和 WRITE 之间 CS 必须拉高再拉低。如果你的代码是拉低 CS 后连续发 0x06 和 0x02数据是写不进去的。这个坑我踩过改了半天代码才发现是时序问题。另一个可能是写保护。MR25H40CDF 有状态寄存器里面有块保护位如果被设成了保护状态写入会被忽略。读一下状态寄存器RDSR0x05看看保护位是不是被误设了。正常情况下状态寄存器应该是 0x00 或者只有 WEL 位为 1。6.3 高速时钟下数据出错SPI 时钟跑到 16MHz 以上时如果走线不好或者没有做好阻抗匹配数据可能出错。表现是低速正常高速就乱。解决办法有几个降低 SPI 时钟到 8MHz 或 4MHz缩短走线加串联电阻一般 22 到 33 欧姆做阻抗匹配或者在 SCK 上并一个小电容滤波。工业现场我一般不会把 SPI 跑太高8MHz 足够用稳定性优先。如果确实需要高速那 PCB 设计就要按高速信号来对待走线等长、包地、阻抗控制都要做。6.4 常见问题速查表现象可能原因排查方法解决措施读出全 0xFFCS 未拉低量 CS 引脚电平检查片选代码和引脚配置读出全 0x00SCK 无输出示波器量 SCK检查引脚重映射和 SPI 使能数据错位SPI 模式错误逻辑分析仪抓波形核对 CKP/CKE 配置写入无效WREN 时序错误抓 CS 和命令波形WREN 后 CS 拉高再拉低高速出错信号完整性差降速测试降时钟、加匹配电阻偶发错误电源干扰量电源纹波加强去耦、加滤波电容7. 性能实测与优化经验7.1 实测数据项目做完后我做了一轮实测数据如下测试项条件结果单字节写入8MHz SPI约 2 微秒32 字节记录写入8MHz SPI约 12 微秒512KB 全读8MHz SPI约 0.55 秒连续写入 100 万次8MHz SPI无错误高温 85 度连续运行72 小时无错误频繁断电测试1000 次数据无丢失这个性能对工业采集来说绰绰有余。500ms 采集一次每次写入 12 微秒CPU 占用率几乎可以忽略。7.2 几个优化技巧第一个技巧是批量写入。如果一次要写多条记录不要每条都发一次 WREN 和 WRITE可以在一次片选有效期内连续写多个地址MRAM 支持地址自动递增。这样能省掉很多命令开销。第二个技巧是读缓存。如果某些数据读得频繁可以在 RAM 里做一份缓存减少 SPI 访问次数。比如系统配置区上电时读一次到 RAM之后都从 RAM 读需要修改时才写回 MRAM。第三个技巧是合理规划地址。把频繁写入的区域和很少写入的区域分开避免频繁写入影响其他数据。虽然 MRAM 没有擦除寿命问题但减少不必要的写入总是好的。7.3 关于 SPI 时钟的取舍很多人觉得 SPI 越快越好其实不然。在工业环境里稳定性比速度重要得多。我一般会把 SPI 时钟设在 4MHz 到 8MHz 之间这个范围既能保证响应速度又有足够的时序余量抵抗干扰。实测 8MHz 下即使电源有 100mV 的纹波数据依然稳定。如果跑到 16MHz同样的电源条件下就偶尔会出错。另外SPI 时钟越快辐射干扰越强。工业设备要过 EMC 认证高速 SPI 往往是辐射超标的重灾区。降速是最简单有效的办法有时候比加屏蔽、加滤波都管用。8. 方案扩展与个人体会这套 MRAM 加 PIC24 的方案后来我又用在了几个类似的项目上比如智能电表的数据记录、工业网关的配置存储、医疗设备的日志记录。基本框架不用改只需要调整记录格式和存储区域划分就行。如果要扩展容量MR25H40CDF 是 4Mbit同系列还有 1Mbit 和 16Mbit 的型号引脚兼容代码只需要改一下地址范围。如果要更高速度可以换成 QSPI 接口的 MRAM四线并行速度能翻好几倍但主控也要支持 QSPIPIC24FV32KA301 不支持得换更高端的型号。我个人在实际操作中的体会是嵌入式存储方案的选择不能只看容量和价格要看整个生命周期的可靠性。Flash 便宜但擦写寿命和掉电风险是硬伤FRAM 可靠但容量小价格高MRAM 正好卡在中间容量够用、寿命无限、掉电安全对工业应用来说是很均衡的选择。多花的那点成本比起现场故障带来的维护成本根本不值一提。最后再分享一个小技巧调试 SPI 的时候如果手头没有逻辑分析仪可以用 MCU 的一个空闲 GPIO 做软件示波器在关键代码位置翻转电平用普通示波器就能看出代码执行流程和时序关系。这个方法虽然土但在现场调试的时候特别管用我靠它解决过好几次疑难问题。