MR25H40CDF与PIC18F97J60:工业数据掉电保存与网络上传实战
发布时间:2026/10/4 8:46:09 作者:尧图编辑部 阅读量:1,286

在工业项目里最怕的不是 CPU 算不动而是电一断数据没了。我以前习惯用 EEPROM 或者 SPI NOR Flash 存参数直到在一台要做断电保存和网络上传的设备上发现两个绕不开的问题EEPROM 容量太小日志一多就写不下Flash 虽然容量大但写几次擦除后就开始担心寿命现场一天可能写几百条记录按这个频率用不了几个月就报废。后来换成了 MR25H40CDF 这片 SPI 接口的 MRAM和自带以太网控制器的 PIC18F97J60 搭在一起把存储、掉电保持、远程上传整套流程都理顺了。今天就把从选型、接线、写驱动到掉电校验、上网传数据的完整过程捋一遍给同样在做工业数据采集、嵌入式联网设备的同行一个可以直接抄作业的参考。先说结论这套组合不是万能的它最适合的场景是“小数据量、高频写入、断电之后必须保住”的情况。好用是好用但里面有一些坑比如 WP# 引脚悬空导致写不进去、半途掉电读到脏数据这些不提前处理调试的时候非常折磨人。下面我一个一个讲。1. 为什么工业现场要把数据交给 MRAM存储介质选型的逻辑1.1 工业存储的三个“死穴”做工业设备的人都知道现场环境跟实验室完全是两回事。首先是掉电不可控强电回路被切断的时候单片机可能正拿着一条数据准备写进去电压跌到一半写操作就断了。其次是写入频率很多设备每个采样周期要存一条记录一秒一条很常见这样一天就是八万多条写入普通 Flash 根本扛不住。最后是数据一致性你不能让上位机从存储里读到一条半个字节、中间截断的记录这类问题在设备复位和掉电之后特别容易出现。这三个问题叠加在一起传统 EEPROM 和 NOR Flash 就很吃力。EEPROM 的擦写寿命多在百万次量级听起来不少但高频写入时一年左右就可能到寿命边缘NOR Flash 写一个字节前要先擦除整个扇区擦除次数一般在十万次量级而且写入速度慢频繁擦写还会引入“写放大”问题。更麻烦的是Nor Flash 和 EEPROM 在掉电的半写状态之后很难判断旧数据到底是完整还是残损。MRAM 则完全不同。它用磁性状态保存数据写入是“翻转磁化方向”不需要擦除写命令发过去直接被覆盖速度和访问方式都更像 SRAM但是断电之后数据依然保留。MR25H40CDF 就是这类 MRAM 中的一颗 SPI 接口产品工程上可以按“接近无限次写入”来使用。1.2 几类非易失存储器横向对比把常见几种非易失存储的基本特性放一起看差异非常直观指标MR25H40CDF (MRAM)EEPROMSPI NOR FlashFRAM写入寿命10^12 次量级10^5 ~ 10^6 次10^4 ~ 10^5 次擦除块10^12 次量级写入前是否需要擦除不需要不需要必须先擦除扇区不需要写单个字节开销低低高整块擦除低掉电保持磁状态保持电荷保持电荷保持铁电保持常见容量区间小到中等很小大小抗辐射和温度范围工业级别明显占优普通普通普通从这个表可以看出来MRAM 在“频繁小数据写入”这个维度上和 FRAM 一样是碾压级的存在。FRAM 的问题是供应商少、部分型号写入要考虑伪读问题MRAM 的通用性稍好一些封装引脚也直接兼容经典 25 系列 SPI 器件旧项目换过来很容易。1.3 为什么选中 MR25H40CDF 这一颗MR25H40CDF 是 4Mbit 的串行 MRAM按字节算大概是 512KB。这个容量看着不大但用在工业设备里非常合适系统参数、校准系数、网络配置、设备序列号这些固定信息占不了多少空间剩下的区域拿来存短时趋势日志、事件记录、现场故障快照完全够用。选择它还有一个很实际的原因接口是标准的 SPI直接和单片机上的 MSSP 模块相连不需要额外的并口总线和电平转换。它的命令集和常见的 25 系列 SPI NOR Flash 高度相似代码迁移成本很低。更重要的是由于写入不需要擦除我不需要在软件里做磨损均衡不需要维护擦除块映射表这让逻辑层简单了一大截。当然MRAM 也不是没有缺点。512KB 对海量历史数据来说仍然偏小如果设备要求存一周甚至一个月的连续采样记录单靠这一颗芯片是不够的。我在项目里对它的定位是“可靠高频写入的小型数据库”长时间大容量日志还是交给边缘计算盒子或者上位机去处理。2. PIC18F97J60 与 MR25H40CDF 的硬件搭档接线、电源与时序设计2.1 为什么是“自带以太网”的 PIC18F97J60如果把 MRAM 只接一颗普通单片机数据存进去之后要人工去读那在工业现场没有意义。工业项目的常态是设备每天产生数据管理员在上位机或者监控中心看历史趋势。这时候如果单片机自带以太网就可以把 MRAM 里的数据通过网络定时上传或者响应远程请求。PIC18F97J60 自带一个完整的以太网控制器实际上就是把常用的 ENC28J60 控制器集成进了芯片内部省掉了一颗外挂芯片。它还提供大概 128KB 程序 Flash 和几KB 数据 RAM对跑小型 TCP/IP 协议栈、Modbus TCP 这种协议来说资源是够的。10BASE-T 的速度听起来不快但工业现场上传几 KB 到几百 KB 的数据包绰绰有余关键是稳定。这里需要提醒一句PIC18F97J60 的网络接口是 10M 以太网不要拿它当高速数据卡用。它的价值在于“设备自带 IP 地址能和交换机直接通信”而不在于吞吐量。我把 MRAM 里的日志打包成 1KB 左右的数据块一点一点传网络压力很小可靠性也高。2.2 SPI 接线和悬空引脚处理MR25H40CDF 是 8 脚封装引脚基本可以对应到经典 SPI 存储CS#、SCK、SI、SO、VCC、GND另外还有 WP# 和 HOLD# 两个控制脚。和 PIC18F97J60 连接时我用的就是 SPI1 模块四根线加上片选MR25H40CDFPIC18F97J60 侧说明CS#任意 GPIO片选信号必须由软件控制SCKSCK1SPI 时钟线SISDO1主机输出、从机输入SOSDI1主机输入、从机输出WP#上拉到 VCC写保护低电平会禁止写操作HOLD#上拉到 VCC暂停通信低电平会让 SPI 从机忽略时钟最容易踩的坑就是 WP# 和 HOLD#。我第一版板子为了省事把这两个脚悬空了结果出现一个非常诡异的现象读数据一直正常写使能命令也发了但写进去的数据死活不生效。查了半天发现是因为悬空的 WP# 被干扰拉低写保护一直生效。后来把 WP# 上拉、HOLD# 上拉问题立刻消失。另外一个细节是 CS# 不要直接接地必须用 GPIO 独立控制。因为 MMRAM 的命令帧需要以 CS# 拉低开始、拉高结束如果 CS# 固定低电平整个通信逻辑都会乱套。SCK 和 SI 线上可以串 33Ω 左右的小电阻抑制振铃这对靠近电柜的工业环境很有帮助。2.3 电源去耦与上电时序MR25H40CDF 的工作电压是 3.3V和 PIC18F97J60 的 IO 电平一致可以直接连接不需要电平转换。但在电源处理上要多花点心思。我在芯片旁边放了 0.1μF 和 10μF 两个去耦电容尽量靠近 VCC 引脚。如果设备装在电机控制柜这种电磁干扰比较严重的地方建议在 3.3V 进线处再加一个小电感或者磁珠避免电源毛刺耦合进存储芯片。另一个需要注意的问题是上电时序如果单片机和 MRAM 供电不同步上电瞬间 CS# 可能处于不确定状态导致芯片接收到伪命令。硬件上我做了两个措施一是 CS#、WP#、HOLD# 都加上拉电阻保证上电时电平确定二是把 MRAM 的电源和单片机电源放在同一个 3.3V 域由同一颗 LDO 供电避免出现“单片机已经运行、存储还没起来”的窗口期。3. 从零手写 SPI 驱动命令集、读写流程与抽象层3.1 先弄懂 MRAM 的命令集MR25H40CDF 的命令集和多数 25 系列 SPI NOR Flash 很像但没有“扇区擦除”“块擦除”这类操作。这是本质区别MRAM 写入时直接覆盖目标地址不需要先擦除。因此我在软件里从来不需要维护什么擦除状态格式化也只是一个“往数据区写全 0 或者写全 1”的动作。实际用到的命令很有限核心是下面几组命令名指令码功能WREN0x06写使能发出写命令前必须先执行WRDI0x04写禁止RDSR0x05读状态寄存器判断忙状态WRSR0x01写状态寄存器配置写保护等READ0x03读数据3 字节地址WRITE0x02写数据3 字节地址和大多数 3 字节地址的 SPI 存储一样地址字段是高位在前。MR25H40CDF 的容量在 128Mbit 以下不需要使用 4 字节地址扩展命令地址直接用 24 bit 就够了。3.2 写使能、读状态、读写数据驱动骨架下面这组代码是我在板子上验证过的骨架引脚定义换成你自己的硬件就行。初始化时把 SPI 设置为主模式时钟极性相位选 mode 0即空闲时 SCK 为低数据在上升沿采样。如果你的系统里还有其他 SPI 设备注意 MRAM 通常都支持标准 SPI 模式不要被网上混乱的说法带偏。#define MRAM_CS LATCbits.LATC2 // 片选引脚自行映射 #define MRAM_CS_TRIS TRISCbits.TRISC2 static void mram_cs_low(void) { MRAM_CS 0; } static void mram_cs_high(void) { MRAM_CS 1; } uint8_t mram_transfer(uint8_t byte) { SSP1BUF byte; while (!PIR1bits.SSP1IF) { // 等待发送完成 } PIR1bits.SSP1IF 0; return SSP1BUF; } void mram_write_enable(void) { mram_cs_low(); mram_transfer(0x06); mram_cs_high(); } uint8_t mram_read_status(void) { uint8_t status 0; mram_cs_low(); mram_transfer(0x05); status mram_transfer(0x00); mram_cs_high(); return status; } void mram_wait_busy(void) { uint16_t timeout 0xFFFF; while ((mram_read_status() 0x01) timeout--) { // 等待内部写周期结束 } }读写函数的关键在于地址拆分。MR25H40CDF 的地址是 24 bit最多能覆盖 16MB 地址空间现在用不满但地址字段格式不变。void mram_read(uint32_t addr, uint8_t *buf, uint16_t len) { mram_cs_low(); mram_transfer(0x03); mram_transfer((addr 16) 0xFF); mram_transfer((addr 8) 0xFF); mram_transfer(addr 0xFF); while (len--) { *buf mram_transfer(0x00); } mram_cs_high(); } void mram_write(uint32_t addr, const uint8_t *buf, uint16_t len) { mram_write_enable(); mram_cs_low(); mram_transfer(0x02); mram_transfer((addr 16) 0xFF); mram_transfer((addr 8) 0xFF); mram_transfer(addr 0xFF); while (len--) { mram_transfer(*buf); } mram_cs_high(); mram_wait_busy(); }有几个容易被忽略的点。第一每次写命令前都要发 WREN否则写操作会被忽略。第二CS# 低电平期间传输的数据是连续自增的所以读和写都需要一次传完整个数据块如果你想读 256 字节就不能发一条命令读 10 字节后把 CS# 拉高再重新发地址。第三单片机的 RAM 有限单次数据块不建议超过 512 字节否则一方面缓冲区不够用另一方面会长时间占用 SPI 总线影响其他任务。3.3 给上层做一个存储抽象层驱动写完不要直接在上层业务里到处调用最好是封装成一个简单的抽象层让业务代码只关心“读写哪个区域、偏移多少字节、数据放哪”不关心底层是 MRAM 还是以后换的 EEPROM。我是这样定义的typedef enum { MRAM_REGION_SYS, // 系统参数区 MRAM_REGION_LOG, // 日志循环区 MRAM_REGION_TMP // 暂存区 } mram_region_t; int mram_region_read(mram_region_t region, uint32_t offset, void *buf, uint16_t len); int mram_region_write(mram_region_t region, uint32_t offset, const void *buf, uint16_t len);这个抽象层里维护一张区域映射表每个区域有起始地址和大小。业务代码如果要保存“当前设备温度”只需要调用mram_region_write(MRAM_REGION_SYS, 0x10, temp, 2)完全不需要关心绝对地址是多少。后面如果换了更大容量的存储芯片只要改映射表业务代码一行不动。这个习惯帮我省了非常多麻烦。有一次客户要求把参数区从原来的一处挪到另一处我只改了抽象层的地址映射十分钟就搞定了上层所有业务逻辑都没有涉及。3.4 实测能跑多快理论估算与实测结果MRAM 本身非常快实际瓶颈基本在 SPI 时钟和单片机处理上。我的系统把 SPI 时钟设在了几 MHz 量级一条 READ 命令需要 1 个字节命令、3 个字节地址然后每个数据字节通过 SPI 移位送出。粗略算一下读 256 字节需要发送 260 个字节在 8MHz SPI 时钟下大约需要 260μs 出头的传输时间再加上函数调用和 CS# 拉高拉低整体在 300μs 左右。写数据会慢一点因为写完还要读状态寄存器等待内部写周期结束。MRAM 的内部写周期非常短绝大多数情况下一点就结束了。实测下来写 256 字节的时间在 400~500μs 之间对于按秒甚至按毫秒记一条日志的场景来说这个速度完全够用。这里给出一个常见的性能参考表数据块大小读操作耗时约写操作耗时约16 字节25μs40μs128 字节160μs220μs512 字节650μs900μs结论就是MR25H40CDF 特别适合“高频小写”比如每秒写几十条小记录完全不会成为系统瓶颈。但如果你有一批几 MB 的波形数据要一次性搬进去那 SPI 的传输时间还是摆在那里的不能幻想有并行 Flash 的速度。4. 数据落“盘”之后掉电安全、CRC 校验与记录组织4.1 参数区和循环日志区怎么划分512KB 的空间如果全部用裸地址乱写很快就会出问题。我把它分成三大块系统参数区、日志循环区、暂存区。系统参数区放在起始地址大小 4KB 左右保存设备序列号、校准系数、网络 IP、设备状态这类“改了之后必须长期保存但不能频繁写”的数据。日志循环区从 0x1000 地址开始按固定长度记录存放每条记录 32 字节或者 64 字节写满后回卷覆盖最老的数据。暂存区留给远程升级、网络缓存这类用途。日志循环区的实现其实很简单每条记录有一个 16 bit 的序号上电时扫描整个区域找到序号最大的那条记录就可以确定当前写到哪个位置了。如果扫描到连续几条记录都是无效的就说明这个区域是空白的从区域起始地址开始写。这里的关键点是MRAM 不需要擦除就可以覆盖写入所以“回卷覆盖”的成本很低不需要像 Flash 那样先擦掉一整块也就不会有“擦到一半断电导致整块数据丢失”的情况。如果一条记录 32 字节1 秒写一条512KB 能存大约 16384 条连续记录四个半小时左右。如果你要存一整天就需要把记录压缩到 16 字节以内或者外接更大容量的存储。算清楚这个账再决定日志区的划分。4.2 魔数加 CRC唯一可靠的半写检测方式很多人觉得 MRAM 掉电不丢数据那么掉电瞬间一定安全。这个理解是片面的。MRAM 的存储单元本身能够保持数据但在掉电瞬间如果单片机正在发送一个写命令而电源跌落到阈值附近芯片内部状态可能处于“已经响应了命令但写动作没有完整完成”的中间状态。实际表现出来的结果就是这条记录可能没写进去也可能写了一半。解决这个问题的唯一可靠手段是在应用层给每条记录加上校验信息。我在每条日志里固定放一个 2 字节的魔数比如 0xA55A、2 字节序号、2 字节数据长度、数据体以及 2 字节 CRC16。读取时先看魔数对不对不对就直接丢弃魔数对的话再算一遍数据的 CRC和记录里存放的 CRC 比对不一致也视为无效。这样做的目的是让“存储介质可靠性”和“应用数据可靠性”分开。MRAM 保证的是单个 bit 的持久保持能力应用层保证的是每一条记录的逻辑完整性。哪怕掉电瞬间真的把一条记录写花了我读出来 CRC 不对就跳过这条记录然后继续读后面的不会影响整段日志的可用性。4.3 掉电瞬间怎么处理才算稳妥真正的工业环境里掉电不是“软件优雅退出”而是电压在一两个毫秒内骤然跌落。所以软件必须在掉电初期完成必要的收尾而不是等系统死了之后后悔。我在电路里加了一路电压检测用 PIC 的比较器监控 3.3V 电源。一旦电压低于阈值立即触发中断。中断里做的事情很少把 MRAM 的 CS# 拉高结束当前 SPI 事务然后通过 GPIO 把 WP# 拉低锁定写保护。这样做有两个好处。第一CS# 先拉高可以终止正在进行的字节传输避免后续杂乱时钟进入 MRAM。第二WP# 拉低之后就算单片机因为电压跌落而运行异常、疯狂输出写命令MRAM 也不接受写操作保护已有数据。上电之后需要做的第一件事就是把 WP# 恢复为高电平然后检查参数区里最后写入的序号和 CRC决定是否把上次未完成的半条记录标记为无效。我个人的经验是掉电处理最好不要在中断里写数据。中断时间有限电压跌落的窗口不确定这时候做复杂的保存操作反而容易引出新问题。更稳妥的做法是“掉电前把所有数据先写在内存缓冲里掉电中断只负责保护现场”正常情况下数据早就写进 MRAM 了掉电中断只需要阻止破坏发生。5. 把 MRAM 里的数据送上以太网一个真实可跑的扩展场景5.1 数据链路设计与内存约束PIC18F97J60 的价值在于网络所以不能让 MRAM 只做一个“离线存储”。我做的数据链路是这样现场设备通过传感器采集温度、压力、状态位定时把一条记录写入 MRAM 日志区同时以太网任务监听上位机请求当上位机通过 Modbus TCP 或者 HTTP 请求数据时PIC18F97J60 从 MRAM 读取记录分块发送出去。这里要特别注意单片机的 RAM 资源。PIC18F97J60 的数据 RAM 只有几 KB你把一整段 512KB 的日志区全部读到 RAM 再发送这是不可能的。所以我的设计是把网络发送缓冲区定义为 1KB每次只从 MRAM 读 1KB 数据放入缓冲区然后通过 TCP 发送确认之后再读下一块。这样即使单片机的 RAM 很小也能稳定上传大段日志。数据流大概是这个方向上位机请求日志 - PIC 计算日志起始地址 - 每次读取 1KB - TCP 发送 - 上位机收到并确认 - 继续下一块。整个过程不需要一次占用大内存这是 8 位单片机上写网络应用的通用思路。5.2 用 Modbus TCP 读取 MRAM 数据工业现场普遍使用 Modbus 协议我这边就把 MRAM 的概念区域暴露成 Modbus 寄存器或者文件记录。最简单的方式是把 MRAM 日志区映射成一组“保持寄存器”每一个寄存器 16 bit上位机用 Modbus TCP 的 0x03 功能码按顺序读取。比如日志区从地址 0x1000 开始上位机想读第 0 到第 127 个寄存器我就从 MRAM 对应地址读出 256 字节以寄存器数组的形式返回。在代码层面Microchip TCP/IP 协议栈已经把 TCP 连接和 Modbus TCP 报文解析框架搭好了我只需要在业务回调里做一个映射请求的寄存器地址对应到 MRAM 的绝对地址然后调用mram_read()读数据再把数据填入响应帧。这样写出来的代码非常直观现场调试的时候拿一个 Modbus 调试工具就能看到 MRAM 里的实时数据不用写特殊的上位机程序。5.3 分块上传512KB 数据不是一次发送的很多第一次做以太网数据上传的工程师会试图把日志数据一次性塞进 TCP 发送函数结果发现要么内存爆了要么断线重传后数据对不上。正确做法是明确“每一包数据的边界”。我的上位机和设备之间约定了一个很简单的协议上位机先发“开始上传请求”设备收到后把日志总数和每块大小返回然后上位机循环发“读取块 N 的请求”设备每次返回 1KB 数据和 CRC上位机校验 CRC 正确后继续请求下一块。如果 CRC 不一致上位机重发当前块请求设备再从 MRAM 重新读一次。由于 MRAM 读取不消耗寿命、速度也快重读一两个块毫无压力。这个方案的工程意义在于即使传输中断了从头继续或者从断点继续都很好处理因为每块数据在 MRAM 里有明确的地址偏移断点位置很容易计算。5.4 另一个很实用的用法固件暂存最后分享一个我后来才加上的用法远程升级文件暂存。设备通过以太网收到新的固件镜像后先把镜像写入 MRAM 暂存区全部接收完成后做一次整体校验。校验通过Bootloader 从 MRAM 逐块读取固件写入单片机自身的程序 Flash校验不通过直接丢弃不影响当前运行的程序。这个用法能成立是因为 MRAM 支持“字节级随机写、无限次写、断电保持”。固件暂存区需要反复写入不同长度的数据块如果用 Flash 做暂存每升级一次就要先擦除大块区域万一中途断电整个暂存区都毁了。用 MRAM 做暂存升级过程被断电打断最坏情况只是暂存区里有一段残破的固件Bootloader 检测到校验失败就不会执行升级当前系统依然可以正常运行。要说这套组合有什么明显的短板那就是容量。4Mbit 的 MRAM 想做海量存储是不现实的它更适合扮演“高可靠性小仓库”的角色。如果需要更大空间可以考虑多片 MR25H40CDF 级联或者外接更高容量的 MRAM 芯片驱动的抽象层只要多做一个片选管理就行。在我自己的板子上这套代码已经连续跑了大半年断电测试做了几十次唯一的遗憾是当初没有把 WP# 引脚的电平做成可以通过软件动态切换导致掉电中断里的保护逻辑依赖于 GPIO 驱动后面再做新版本时我会把这个细节从硬件上固化下来进一步减少软件出错的可能。