1. 项目缘起与整体设计思路
工业现场的数据存储有个很尴尬的夹层:用 EEPROM 吧,容量小、写入慢,频繁记录日志很快就到寿命上限;用 SD 卡或者 eMMC 吧,文件系统复杂、掉电容易损坏、还要跑一套驱动栈,对实时性是个负担。我这次要聊的组合,就是专门填这个夹层的方案——MR25H40CDF这颗 4Mbit 的 SPI 接口 MRAM(磁性随机存储器),配上MK64FN1M0VDC12这颗 Kinetis K64 系列的 Cortex-M4 MCU,做工业与嵌入式场景下的数据存储与读取。
先说清楚这套组合到底解决什么问题。MR25H40CDF 的核心卖点是非易失 + 近乎无限的擦写寿命 + 纳秒级写入。它不像 Flash 那样需要先擦后写、也不像 EEPROM 那样写入要等几毫秒。你往里面写一个字节,它当场就存住了,掉电也不丢。这对工业设备意味着什么?意味着你可以把关键状态、故障快照、累计计量值、掉电前的最后一批采样数据,直接当成 RAM 一样随手写进去,不用做复杂的磨损均衡,也不用担心写坏。
MK64FN1M0VDC12 这边,1MB Flash、256KB SRAM、120MHz 主频,带多个 SPI 外设(SPI0/SPI1/SPI2),还有 FlexBus、DMA 这些外设。选它来驱动 MR25H40CDF,主要看中的是它的 SPI 速率和 DMA 能力——MRAM 本身支持很高的 SPI 时钟,如果 MCU 这边用 DMA 搬运,就能做到几乎不占 CPU 的连续数据记录。
整体设计思路我拆成三层来看:
- 物理层:MR25H40CDF 走标准 SPI 四线(SCLK、MOSI、MISO、CS),供电 3.3V,和 K64 的 IO 电平天然匹配,不需要电平转换。
- 驱动层:K64 的 SPI 外设配置成主机模式,CPOL/CPHA 按 MRAM 的 Mode 0 来设,速率先保守跑 10MHz 验证,稳定后再往上顶。
- 应用层:把 MRAM 当成一块线性的 512KB 存储空间来管理,前面放元数据区(魔数、版本、写指针),后面放环形日志区,掉电重启后靠元数据恢复写位置。
为什么不用文件系统?因为在这个场景里,文件系统带来的抽象成本远大于收益。工业设备要的是确定性——我知道第 N 个字节写在哪里,读回来就是什么,中间不经过任何缓存和索引层。MRAM 的随机访问特性刚好支持这种“裸地址读写”的用法。
提示:MR25H40CDF 是 512K x 8 的组织方式,也就是 512KB 字节寻址空间,地址线是 19 位(0x00000 到 0x7FFFF)。别和 4Mbit 这个标称容量搞混,换算过来就是 512KB。
2. 核心器件解析与选型背后的考量
2.1 MR25H40CDF 到底强在哪
很多人第一次接触 MRAM 会把它和 FRAM(铁电存储器)搞混。两者都是非易失、都快、都耐写,但机理不同。MRAM 用的是磁性隧道结(MTJ)来存数据,靠磁化方向表示 0 和 1;FRAM 靠铁电材料的极化方向。实际用下来,MRAM 在抗辐射、抗磁场干扰、高温稳定性上更有优势,工业级温度范围(-40 到 +85℃)内表现很稳。
MR25H40CDF 的关键参数我列一下,这些是选型时必须核对的:
| 参数项 | 数值 | 说明 |
|---|---|---|
| 容量 | 4Mbit / 512KB | 字节寻址 |
| 接口 | SPI Mode 0/3 | 支持最高 40MHz |
| 供电 | 2.7V ~ 3.6V | 典型 3.3V |
| 写入寿命 | 10^14 次以上 | 基本可以忽略磨损 |
| 数据保持 | 20 年以上 | 断电不丢 |
| 写入方式 | 无需擦除,直接写 | 这是和 Flash 最大的区别 |
| 封装 | 8-SOIC / 8-DFN | 工业板常用 SOIC |
这里最值得说的是“无需擦除”。Flash 写之前必须把整个扇区擦成 0xFF,擦除动作慢且伤寿命。MRAM 是字节级直接覆盖写,你写 0x5A 进去,它立刻变成 0x5A,不需要任何前置操作。这个特性直接简化了驱动逻辑——没有擦除状态机,没有等待轮询,写命令发完数据就完事。
2.2 MK64FN1M0VDC12 的 SPI 资源盘点
K64 这颗片子我用得比较多,它的 SPI 外设(在参考手册里叫 DSPI,即 SPI 模块)有几个点要提前搞清楚:
- SPI0 和 SPI1 是高速 SPI,挂在系统时钟上,可以跑到很高的分频;SPI2 相对弱一些。
- 每个 SPI 都有独立的PCS(片选)信号,支持硬件片选和软件片选两种模式。
- 支持FIFO,深度是 4 级,配合 DMA 可以做到连续传输不中断。
- 时钟极性和相位(CPOL/CPHA)可配,覆盖 Mode 0~3。
我一般把 MRAM 挂在SPI0上,因为它的时钟源更干净,分频选项多。K64 的 SPI 波特率计算公式是:
SPI 波特率 = SPI 模块时钟 / (Prescaler × (1 + 波特率分频值))假设 SPI0 模块时钟是 60MHz,我想跑 15MHz,那就选 Prescaler=2,分频值=1,得到 60/(2×2)=15MHz。这个计算在配置寄存器的时候必须算准,否则实际速率和预期差一截,调试时会很迷惑。
2.3 为什么是这两个器件配对
有人会问,MRAM 这么贵,为什么不用便宜的 SPI NOR Flash?答案在写入模式上。NOR Flash 适合“写一次读很多次”的场景,比如存固件、存配置。但工业设备经常要高频记录运行数据,比如每 10ms 记一次电机电流,一天就是 864 万次写入。NOR Flash 的扇区擦写寿命通常只有 10 万次,几天就写废了。MRAM 的 10^14 次寿命,按这个频率能写几千年。
而 K64 这边,它的 DMA 和 SPI FIFO 组合,能让 MRAM 的写入几乎不占 CPU。我实测过,用 DMA 往 MRAM 连续写 512KB,CPU 占用率不到 5%,主循环该干嘛干嘛。这个组合在“高频记录 + 实时响应”的工业场景里,是很务实的搭配。
3. 硬件连接与 SPI 时序实操要点
3.1 硬件连线与去耦
MR25H40CDF 的引脚定义很标准:CS、SO(MISO)、SI(MOSI)、SCK、VDD、VSS,加上 WP 和 HOLD(这两个在标准 SPI 模式下可以拉高不用)。接线时我踩过的坑主要在两个地方:
第一是去耦电容。MRAM 在写入瞬间的电流会有小波动,如果 VDD 上的去耦不到位,高速写入时会出现偶发数据错误。我的做法是在芯片 VDD 引脚旁边放一个 0.1uF 的陶瓷电容,紧贴引脚,再并一个 1uF 的钽电容做低频储能。这两个电容的走线要短,地要就近打过孔。
第二是CS 信号的处理。K64 的 SPI 支持硬件片选,但我更推荐用软件片选(普通 GPIO 控制 CS)。原因是硬件片选在 FIFO 和 DMA 模式下,片选的拉低拉高时机由外设控制,有时候和你的命令序列对不齐,尤其是 MRAM 这种“命令 + 地址 + 数据”的多段传输。用 GPIO 手动控制 CS,你能精确掌握每一帧的边界,调试起来心里有底。
注意:MRAM 的 CS 拉低后,第一个时钟沿开始采样命令字节。如果 CS 在传输中间被意外拉高,整个命令会作废,芯片回到空闲态。所以软件片选时,要确保一次完整操作期间 CS 保持低电平。
3.2 SPI 模式与时序确认
MR25H40CDF 支持 SPI Mode 0(CPOL=0, CPHA=0)和 Mode 3(CPOL=1, CPHA=1)。我一般用Mode 0,因为它在空闲时 SCK 为低,第一个边沿是上升沿采样,和大多数逻辑分析仪的默认触发方式一致,抓波形方便。
时序上要关注几个时间参数:
- CS 建立时间:CS 拉低到第一个 SCK 边沿之间,至少要等几个纳秒,K64 的 GPIO 翻转速度足够,一般不用刻意加延时。
- SCK 频率:初次调试建议从 1MHz 开始,确认读写正常后再逐步提高到 10MHz、20MHz。我实测 MR25H40CDF 在 3.3V 下跑 20MHz 很稳,40MHz 在长走线时会有边沿变缓的问题。
- 命令间间隔:连续操作之间,CS 拉高后要留一点时间(tSHSL),典型值几十纳秒,K64 的 GPIO 操作天然满足。
3.3 读写命令集速查
MR25H40CDF 的命令集不复杂,常用的就几条:
| 命令 | 操作码 | 说明 |
|---|---|---|
| WREN | 0x06 | 写使能,写之前必须发 |
| WRDI | 0x04 | 写禁止 |
| READ | 0x03 | 读数据,后跟 3 字节地址 |
| WRITE | 0x02 | 写数据,后跟 3 字节地址 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
注意地址是3 字节,虽然实际只用到 19 位,但高位补 0 即可。读操作是“发命令 + 发地址 + 连续读”,地址会自动递增,所以读一整块数据只需要一次 CS 拉低。
写操作稍微特殊:每次写之前必须先发 WREN(0x06),否则写命令会被忽略。这是很多新手第一次调不通的原因——直接发 WRITE 命令,数据写不进去,读回来还是旧值。WREN 之后 CS 要拉高再拉低,然后发 WRITE 命令和地址数据。
4. 驱动实现与 DMA 加速方案
4.1 基础读写函数实现
先给一个最朴素的字节读写实现,用轮询方式,方便验证硬件。这里以 K64 的寄存器操作为例,实际项目里可以用 SDK 的 SPI 驱动封装。
// 软件片选控制 #define MRAM_CS_LOW() (GPIOB_PDOR &= ~(1 << 10)) #define MRAM_CS_HIGH() (GPIOB_PDOR |= (1 << 10)) // SPI 单字节收发(轮询) uint8_t spi_transfer(uint8_t data) { while (!(SPI0_SR & SPI_SR_TFFF_MASK)); // 等待发送 FIFO 有空位 SPI0_PUSHR = data | SPI_PUSHR_PCS_MASK; // 写入数据 while (!(SPI0_SR & SPI_SR_RFDF_MASK)); // 等待接收完成 return SPI0_POPR & 0xFF; } // 读 MRAM void mram_read(uint32_t addr, uint8_t *buf, uint32_t len) { MRAM_CS_LOW(); spi_transfer(0x03); // READ 命令 spi_transfer((addr >> 16) & 0xFF); // 地址高字节 spi_transfer((addr >> 8) & 0xFF); spi_transfer(addr & 0xFF); for (uint32_t i = 0; i < len; i++) { buf[i] = spi_transfer(0x00); } MRAM_CS_HIGH(); } // 写 MRAM void mram_write(uint32_t addr, const uint8_t *buf, uint32_t len) { MRAM_CS_LOW(); spi_transfer(0x06); // WREN MRAM_CS_HIGH(); // 短暂延时,确保 WREN 生效 for (volatile int i = 0; i < 10; i++); MRAM_CS_LOW(); spi_transfer(0x02); // WRITE 命令 spi_transfer((addr >> 16) & 0xFF); spi_transfer((addr >> 8) & 0xFF); spi_transfer(addr & 0xFF); for (uint32_t i = 0; i < len; i++) { spi_transfer(buf[i]); } MRAM_CS_HIGH(); }这段代码能跑通,但效率不高。每次spi_transfer都在等 FIFO 标志,CPU 全程被占住。写 512KB 数据要花不少时间,而且主循环被阻塞。
4.2 用 DMA 把 CPU 解放出来
K64 的 SPI 支持 DMA 请求,发送和接收各有一个 DMA 通道。我的做法是:把“命令 + 地址 + 数据”拼成一个连续的发送缓冲区,然后启动 DMA 发送;同时启动接收 DMA 把 MISO 上的数据收回来。这样一次传输几百字节,CPU 只需要在开头配置和结尾检查,中间完全不管。
具体配置步骤:
- 配置 DMA 通道 0 为 SPI0 发送请求源,源地址是发送缓冲区,目的地址是
SPI0_PUSHR。 - 配置 DMA 通道 1 为 SPI0 接收请求源,源地址是
SPI0_POPR,目的地址是接收缓冲区。 - 设置传输长度,启动两个通道。
- 在 DMA 完成中断里拉高 CS,处理数据。
这里有个细节:SPI 的发送和接收是同步的,你发一个字节就必然收一个字节。所以读操作时,发送缓冲区里命令和地址之后要填 0x00 占位,接收缓冲区里前几个字节是无效的(对应命令和地址阶段),真正的数据从第 4 个字节开始。
提示:DMA 传输期间 CS 必须保持低电平。如果传输长度很大,要注意 MRAM 的地址自动递增不会越界回绕,超过 0x7FFFF 后行为未定义,所以单次传输长度要控制在剩余空间内。
4.3 环形日志区的设计
把 MRAM 当线性空间用还不够,工业场景需要“循环记录、掉电可恢复”。我的做法是在 MRAM 开头留 64 字节做元数据区:
- 偏移 0x00:魔数 0x4D52414D("MRAM"),用于判断是否首次使用。
- 偏移 0x04:写指针,记录下一个要写入的位置。
- 偏移 0x08:记录总条数。
- 偏移 0x0C:每条记录的长度。
日志区从 0x100 开始,到 0x7FFFF 结束。每次写一条记录,先更新数据,再更新写指针。写指针到达末尾就回绕到 0x100。掉电重启后,读魔数确认有效,再读写指针恢复状态。
这个设计的精髓在于写指针的更新顺序。一定要先写数据,再更新指针。如果反过来,掉电时可能出现指针已经前进但数据没写完的情况,恢复后会读到垃圾。先写数据后更新指针,最坏情况是最后一条记录不完整,但指针指向的位置之前的数据都是可信的。
5. 常见问题与排查技巧实录
5.1 读回来全是 0xFF 或 0x00
这是最常见的现象。排查顺序我总结成一张表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读回全 0xFF | MISO 没接好或芯片没供电 | 万用表量 VDD,示波器看 MISO 是否有翻转 |
| 读回全 0x00 | CS 没拉低或 SCK 没输出 | 逻辑分析仪抓 CS 和 SCK |
| 读回数据错位 | CPOL/CPHA 设错 | 改成 Mode 3 试试 |
| 写入无效 | 忘了发 WREN | 检查写函数是否先发 0x06 |
| 偶发错误 | 去耦不足或速率太高 | 降速到 1MHz 复测 |
我遇到最多的是忘了 WREN。因为读操作不需要 WREN,很多人调通读之后就以为写也一样,结果写不进去。记住:MRAM 的每一次写操作,前面都必须有一次独立的 WREN。
5.2 高速下的数据错误
把 SPI 提到 20MHz 以上后,如果出现偶发位错误,先别怀疑芯片。检查这几项:
- 走线长度:SCK 和 MOSI 的走线如果超过 10cm,边沿会变缓,建议缩短或加串联电阻匹配。
- 地回路:MRAM 的地和 MCU 的地要低阻抗连接,最好在同一层铺地。
- CS 时序:软件片选时,CS 拉低到第一个 SCK 之间如果太快,芯片可能还没准备好。加几个 NOP 延时试试。
我实测下来,在 4 层板上,走线 5cm 以内,20MHz 完全稳定。40MHz 需要更严格的阻抗控制,一般工业板不追求这个速率。
5.3 掉电后数据恢复异常
如果重启后读到的写指针是乱值,检查元数据区的写入逻辑。我的经验是:元数据区每次更新都要做校验。比如写指针旁边放一个 CRC8,恢复时先校验,校验不过就用默认值重新初始化。这样即使掉电时元数据写了一半,也能安全恢复。
另外,MRAM 虽然写入快,但也不是瞬间完成。数据手册里写的写入时间是纳秒级,但那是芯片内部完成的时间,SPI 传输本身还是要花时间的。所以掉电检测中断里,要确保最后一条记录的 SPI 传输已经完成,再让系统断电。如果电源掉得太快,可以在电源端加一个大电容,给 MCU 留出几十毫秒的“善后时间”。
6. 工业场景下的扩展用法
6.1 故障快照记录
工业设备出故障时,最宝贵的是故障前那几秒的运行数据。用 MRAM 做环形缓冲,以 1kHz 的频率记录关键变量(电流、电压、温度、位置),缓冲区存最近 10 秒的数据。一旦触发故障,立即停止写入,把这段数据锁定。事后读出来,就是一份完整的故障现场快照。
这个用法对 MRAM 的写入速度要求很高。1kHz 意味着每 1ms 写一次,每次可能几十字节。用 DMA 的话,CPU 几乎无感。我算过,每次写 32 字节,1ms 一次,平均带宽才 32KB/s,对 20MHz 的 SPI 来说绰绰有余。
6.2 累计计量与参数存储
电表、水表、流量计这类设备,需要累计计量值。这个值要频繁更新,而且掉电不能丢。传统做法是用 EEPROM,但 EEPROM 写入慢,只能每隔一段时间存一次,掉电时会丢最后一段。用 MRAM 可以每次计量都写,掉电时数据是完整的。
参数存储也是类似。设备的校准参数、配置项,存在 MRAM 里,读写都快,而且不怕频繁修改。我一般把参数区放在 MRAM 的前半部分,日志区放在后半部分,互不干扰。
6.3 与 RTOS 的配合
如果项目跑了 RTOS(比如 FreeRTOS),MRAM 的读写要注意临界区保护。SPI 总线是共享资源,多个任务同时访问会冲突。我的做法是给 MRAM 操作加一个互斥信号量,谁用谁申请。DMA 传输期间,任务可以挂起等待完成信号,不阻塞其他任务。
另外,如果用了文件系统或者日志库,要确认它们的底层写入是不是线程安全的。有些库内部有缓存,掉电时会丢数据。用 MRAM 的话,最好绕过缓存,直接写到底层。
7. 一些实测数据与经验值
最后分享几组我实测的数据,供参考:
- 写入速度:20MHz SPI,DMA 模式,连续写 512KB 耗时约 210ms,平均带宽约 2.4MB/s。
- 读取速度:同样条件下,连续读 512KB 耗时约 205ms,带宽约 2.5MB/s。
- CPU 占用:DMA 模式下,传输期间 CPU 占用低于 5%;轮询模式下,CPU 占用接近 100%。
- 功耗:MRAM 写入时电流约 15mA,读取时约 8mA,待机时低于 100uA。
这些数据在不同板子上会有差异,但量级可以参考。如果你的应用对速度要求不高,轮询模式也够用;如果要高频记录,DMA 是必须的。
我个人在实际操作中的体会是,这套组合最大的价值不是性能参数,而是确定性。你知道数据写进去就一定在,掉电也不丢,不需要复杂的掉电保护逻辑,不需要磨损均衡算法。对于工业设备来说,这种简单可靠比什么都重要。踩过的坑主要集中在 WREN 遗漏和高速下的信号完整性上,把这两点处理好,剩下的就是常规的 SPI 调试了。