ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ATmega2560+SPI MRAM:替换EEPROM的工业参数存储方案

ATmega2560+SPI MRAM:替换EEPROM的工业参数存储方案

1. 从一次参数写坏说起:为什么我把EEPROM换掉了

先说结论:ATmega2560搭配SPI接口的MRAM,把参数存储从"小心翼翼省着写"变成"想写就写",这是我这两年做工业采集类小设备时最实用的一次硬件改动。标题里说的"无限次写入"不是营销话术,是MRAM的物理特性决定的——它靠磁隧道结的磁化方向存数据,没有EEPROM那种靠电子穿过氧化层注入浮栅的过程,也就不存在"写多了把氧化层写穿"这件事。对于需要频繁保存运行参数、掉电前抢救数据的场景,这套组合几乎是当前成本可接受范围内最省心的方案。这篇文章我打算把选型逻辑、硬件接线、驱动代码、实测数据和踩过的坑一次性说清楚,适合已经会用ATmega2560跑SPI外设、被EEPROM寿命折磨过的朋友,也适合刚接手这类需求、还在纠结用FRAM还是MRAM的新手对照着看。

1.1 十万次擦写寿命到底意味着多久

很多人对"EEPROM十万次擦写"这个指标没有具体概念,我当初也一样,直到某台设备在客户现场连续坏了三块板子才反应过来。ATmega2560片内EEPROM标称擦写寿命10万次,听起来很多,但你把这个数字除以实际写入频率,答案会很难看。

我做过一张换算表,贴在工位旁边提醒自己:

写入间隔每天写入次数10万次可以用多久
1秒86400约1.2天
10秒8640约11.6天
1分钟1440约69天
5分钟288约347天
1小时24约11.4年

看到第一行和第二行,你就明白为什么"每次采样都存一下"这种写法是灾难。我遇到过的一个真实需求是:设备每30秒记录一次累计流量,掉电不能丢。按30秒算,10万次大约是34.7天——一个多月就要换一块板子,这还没算EEPROM写坏之后不是直接罢工,而是悄悄变成0xFF或者某个固定值,属于最难查的软故障。

注意:EEPROM寿命指的是"擦写次数",读操作不消耗寿命,但写之前必须先擦除,所以一次写就是一次擦写。很多数据手册把这个数字写成"100,000 write/erase cycles",别把它理解成"可以写十万个字节"。

1.2 EEPROM在实时系统里最难受的不是寿命

寿命问题可以靠"只在参数变化时写"来缓解,真正让EE P R O M难受的是写入阻塞。ATmega2560片内EEPROM单字节写入时间是3.3毫秒(典型值),最大4.5毫秒,这段时间CPU要么死等,要么老老实实开中断做别的事,但下一次写还得继续排队。

3.3毫秒是什么概念?16MHz的AVR在这个时间里能执行大约五万条指令。我在做一款带脉冲输出的设备时踩过这个坑:参数一变就同步写EEPROM,结果脉冲时序直接被拉歪,输出的方波出现了肉眼可见的抖动。后来改成"标记脏页、空闲时统一写",抖动没了,但又引出一个新问题——正好在写EEPROM的窗口里掉电,那一块数据就是半新半旧,读出来是一堆无法解释的值。

EEPROM还有一个隐藏的脾气:写的时候如果VCC跌落,不只是当前字节写坏,有时候整个块的数据都会受影响,因为内部电荷泵在低压下工作异常。这一点数据手册写得很含糊,但实际调试时用可调电源慢慢降压,很容易复现。

1.3 MRAM能补上的三块短板

换成SPI接口的MRAM之后,上面这些问题基本消失了,具体补的是三块:

第一块是寿命。主流SPI MRAM的写次数指标在10的14次方量级以上,部分型号直接标"unlimited"。按每秒写一次算,10的14次方次需要三百多万年。这意味着在你的产品生命周期里,写入次数这个约束可以直接从设计文档里划掉,不用再做磨损均衡、不用再算哪个扇区写得最多。

第二块是写入延迟。MRAM写一个字节和读一个字节的时序几乎一样,写完立刻就能读回,不需要"写周期等待"。这一点和FRAM类似,但比EEPROM好出三个数量级。我实测64字节参数块整块写入,SPI跑到8MHz时总耗时不到70微秒,写完马上读回校验,一次事务里就能完成"写-验"闭环。

第三块是掉电安全。因为没有内部高压电荷泵、没有擦除阶段,MRAM的写入过程是逐字节立即生效的。掉电时最坏情况是"写到一半停下",但已经写进去的字节是正确的,不会出现EEPROM那种整块数据变成乱码的情况。配合双备份加CRC的结构,可以做到任何时刻掉电都不丢上一版有效数据。

这三块加起来,本质是把"存储"从一个需要小心伺候的稀缺资源,变成了一个普通外设。这个心态转变很重要——后面你会看到,很多原本为了省寿命而设计的复杂逻辑,换了MRAM之后可以整段删掉。

2. 方案选型:MRAM、FRAM、EEPROM、SRAM+电池怎么权衡

决定要换之后,摆在面前的选项不止MRAM一个。我在选型阶段把四类方案都拉出来做了对比,最后选MRAM不是因为它全面最优,而是它在"频繁写 + 掉电保存 + 接口简单"这三个条件同时成立时,综合成本最低。这一节把对比过程和结论摊开讲,你可以直接对着自己的场景套。

2.1 四种非易失存储的横向对比

先看这张表,数据来自几份公开数据手册的典型值,具体型号会有出入,但量级关系是准的:

方案写寿命单字节写时间掉电保存接口复杂度单位容量成本典型待机电流
片内EEPROM10万次3.3ms是极简(寄存器直读)极低(已集成)微安级
外置SPI EEPROM100万~400万次5ms(含写周期)是简单(SPI)低1~5µA
SPI FRAM10^12~10^14次与读同速是简单(SPI)中等偏高10~50µA
SPI MRAM≥10^14次与读同速是简单(SPI)偏高几十µA~几百µA
SRAM + 电池/超级电容无限纳秒级依赖电池简单(并口/SPI)中等电池自放电

选型时有几个判断点值得说清楚:

FRAM和MRAM怎么选。两者在"写寿命"和"写入速度"上几乎打平,差异主要在三个地方。一是容量档位,FRAM在256Kbit以上大容量段选择少、价格跳得厉害,MRAM在256Kbit到4Mbit这段供货更齐。二是抗磁场,FRAM基于铁电材料,虽然没有MRAM那种磁隧道结,但两者都需要注意远离强磁场,实际工程里差别不大。三是写电流,FRAM的写电流明显更低,如果你做的是电池供电的纽扣电池设备,FRAM更合适;如果板子上本来就有DC-DC、整体功耗在几十毫安,MRAM多出来的那点电流可以忽略。

为什么不选SRAM加电池。我早年做过一版,用SRAM加一颗纽扣电池保持,成本确实低。问题出在可维护性上:电池有自放电、有低温失效、有运输途中振动脱焊,而且设备出厂三年后电池没电,数据就没了。这类设计在售后端是灾难,因为你没法要求现场人员定期换电池。超级电容方案能撑几天到几周,但同样有漏液和老化问题。除非你的设备生命周期短、或者有定期维护协议,否则我不推荐。

外置EEPROM值不值得。如果只是想解决片内EEPROM寿命不够,外置SPI EEPROM是最便宜的路径,成本可能只有MRAM的十分之一。它的百万次寿命对"每小时写一次"的场景足够用几十年。但如果你的写入频率是"每秒几次",或者需要掉电瞬间连续保存多条记录,那它的5毫秒写周期仍然会让你很难受。

我最后的判断逻辑是:如果写入频率低于每分钟一次,且能接受毫秒级阻塞,用外置EEPROM;如果写入频率高、或者需要掉电瞬间抢救数据,直接上MRAM。中间地带才考虑FRAM。

2.2 容量档位与接口形式怎么定

容量这块不要一上来就买最大的。SPI MRAM常见的容量档位是256Kbit(32KB)、1Mbit(128KB)、4Mbit(512KB),价格随容量近似线性上涨,大容量型号的封装和供货也更容易缺。

我的建议是先算清楚你要存什么:

  • 纯参数区:几十个配置项,每个几字节,总共不超过2KB。这种情况256Kbit(32KB)已经绰绰有余,剩下的空间还能做环形日志。
  • 参数 + 运行日志:需要保存最近几百上千条记录做故障回溯。按每条32字节算,1000条是32KB,正好占满一颗256Kbit,这时候建议上1Mbit留余量。
  • 波形/采样数据缓存:采样率高、需要掉电前把缓冲区整块搬进去,那就按"最大缓冲区大小 × 2"来选,双备份是硬需求。

接口形式上,SPI是唯一合理的选择。并行接口的MRAM引脚太多,ATmega2560的IO本来就不宽裕;I2C接口的MRAM极少见,速度和地址空间都不理想。SPI四根线(CS、SCK、MOSI、MISO)正好和ATmega2560的硬件SPI对上,用中断或者轮询都行。

还有一点容易被忽略:地址位宽。256Kbit只需要15位地址,但绝大多数SPI MRAM的读写信令统一用24位(3字节)地址,高位补0。这意味着换更大容量的型号时,驱动代码不用改,直接把地址变量从uint16_t换成uint32_t就行。这个设计对后续升级很友好,我在第二版把容量翻倍时,只改了一个宏定义。

2.3 3.3V存储颗粒与5V单片机的电平这道坎

这是整个方案里最容易翻车的地方,必须单独拎出来讲。

ATmega2560的常规工作电压是4.5V到5.5V(16MHz时),而市面上绝大多数SPI MRAM是3.3V器件,绝对最大额定VCC通常是4.0V左右。也就是说,你不能把5V单片机的SPI信号直接怼到3.3V的MRAM上,MOSI、SCK、CS三根线的5V高电平会通过MRAM的输入保护二极管倒灌电流,轻则工作异常,重则永久损坏。

反过来,MRAM的MISO输出3.3V高电平,ATmega2560在5V供电时的输入高电平门限VIH是0.6×VCC=3.0V,理论上是够的,但余量只有0.3V,板子上只要有稍微明显的噪声或者走线长一点,就可能误判。这不是"应该能行",是"勉强能行"。

我实际用过三条路,各有适用场景:

路线一:整板降到3.3V,单片机降频。ATmega2560在3.3V下最高只能跑到8MHz左右(数据手册给的是8MHz@2.7V~4.5V的speed grade)。如果你的应用对算力要求不高,这是最干净的方案——没有电平转换、没有额外器件、功耗还低。我现在大部分新设计都走这条路,8MHz跑普通的采集控制和通信完全够。

路线二:加电平转换。单片机保持5V/16MHz,SPI三根输出线通过双向电平转换器件(比如单路方向可控的转换芯片,或者简单的MOSFET加电阻方案)降到3.3V,MISO那根线因为方向是输入,可以直接接,也可以在转换芯片里一并处理。用专用转换芯片省心,缺点是增加BOM成本和布板面积。

路线三:电阻分压,只在低速下用。MOSI、SCK、CS各串一个1kΩ电阻,对地接2kΩ,得到约3.33V。这个方案便宜到几乎不要钱,但有两个前提:SPI时钟不能太高(我实际测下来2MHz以内波形还能看,8MHz时上升沿已经明显变缓),以及MRAM那端不能有上拉。应急改造或者打样验证阶段可以用,量产不建议。

提示:如果你打算走路线一,注意晶振和熔丝位要一起改。ATmega2560在3.3V下用16MHz晶振属于超频,短时间能跑,但工业温度范围下会出问题。老老实实换8MHz晶振,重新算一遍所有定时器和波特率。

还有个小细节:MRAM的CS引脚内部通常没有上拉,上电复位期间单片机IO是高阻态,CS可能浮空导致误触发。一定要在CS上加一个10kΩ上拉到MRAM的VCC,这个电阻省不得。

3. 硬件落地:接线、去耦与掉电检测

选型和电平问题定下来之后,硬件部分其实不复杂,但有几个细节如果不注意,会在调试阶段耗掉你好几天。这一节把我板子上的实际连接、元件取值和布局注意点讲清楚。

3.1 ATmega2560的SPI引脚分配与总线共用

ATmega2560的硬件SPI固定在PB口:

引脚功能方向接到MRAM
PB0SS(片选)输出CS
PB1SCK输出SCK
PB2MOSI输出SI
PB3MISO输入SO

这四个引脚是硬件SPI的默认位置,用寄存器直接操作时不用做引脚复用配置。要注意的是PB0作为普通GPIO使用时,必须保证SPI主机模式下SS引脚的方向被设置为输出,否则当SS被拉低时硬件会自动把SPI从主机模式切到从机模式,这是AVR上一个非常经典的坑,现象是"SPI突然不工作了,发什么都是0xFF"。

如果板子上SPI总线上还挂着别的器件(比如SD卡、无线模块、显示屏),就需要用普通IO做片选,把硬件SS引脚固定输出高电平。我是这么处理的:PB0作为硬件SS,只给MRAM用,其他SPI器件用PC口或PL口的IO做片选。这样代码最简单,也不用担心某个从机没释放总线。

多从机共总线时,MISO是唯一的冲突点。所有从机的SO引脚理论上都应该是高阻态,但实际上有些模块的SO在未选中时并不是真正的高阻。稳妥的做法是给每个从机的CS单独控制,并且在切换从机之间插入几个空时钟或者短暂延时,让总线电平先回落。我在混挂一个无线模块时就遇到过MRAM读数据偶尔多一个0xFF,最后查出来是无线模块的SO在切换瞬间还没完全释放。

3.2 电源去耦、片选上拉与走线细节

去耦电容这块,我的固定搭配是0.1µF贴片 + 1µF贴片,两个都放在MRAM的VCC引脚旁边,距离控制在3毫米以内。0.1µF负责高频,1µF补中频,两个并联的效果比单个1µF好很多。

为什么不放大电容?因为MRAM在写入瞬间有电流突变,大容量电容(比如10µF以上)的等效串联电感反而会让高频响应变差。如果板子供电走线比较长,可以在电源入口处再放一个10µF的钽电容或者陶瓷电容做储能,但不要放在芯片脚边。

走线上有三个原则:

  • SPI四根线尽量短、尽量平行走,不要和晶振、复位线、电机驱动线平行长距离走。8MHz下走10厘米问题不大,但如果你的板子把SPI线和PWM输出线捆在一起走20厘米,就要小心串扰。
  • MISO线不要加缓冲器或者大电容,它是由从机驱动的,加了负载会拖慢上升沿。
  • MRAM下面铺完整的地平面,不要在芯片正下方走其他信号线。

还有一个我吃过亏的点:MRAM要远离继电器、电磁阀、大电流电感。MRAM靠磁隧道结存储,虽然厂商做了磁屏蔽设计,能抗一定的外磁场,但继电器吸合瞬间的磁场强度完全不是同一个量级。我在一款带电磁阀的设备上做过对比,MRAM距离电磁阀3厘米时数据正常,距离1厘米时开始出现偶发的位翻转。所以布板时把MRAM放在远离功率器件的一侧,实在避不开就加一块小铁片屏蔽。

3.3 掉电检测:BOD熔丝不够用的地方

很多人以为开了BOD(掉电检测)就万事大吉,其实BOD只能保证"电压低于阈值时单片机复位",它不保证"复位之前你有时间保存数据"。BOD的响应是微秒级的,而你的保存逻辑需要的准备时间可能更长。

我的做法是双保险:

第一层是BOD熔丝,设在2.7V左右(具体档位看你的供电设计),作用是防止单片机在低压下跑飞、执行错误指令。这一层纯粹是保护,不承担保存任务。

第二层是提前预警,用ADC监测电源电压。分压电阻选两个10kΩ,接到ADC的某个空闲通道,每隔几毫秒采一次。当检测到电压低于4.0V(5V系统)或者2.9V(3.3V系统)时,立刻进入"紧急保存"流程。从4.0V跌到2.7V,如果输入端有1000µF的电容,按50mA的负载算,能撑的时间大约是 (4.0-2.7)×1000µF/50mA ≈ 26毫秒,足够写完几十字节。

这里有个取值技巧:储能电容不要只按正常负载算,要按最坏情况算。如果板子上有蜂鸣器或者继电器,掉电瞬间它们的电流可能比平均值大很多,电容会放得更快。我一般按"最大瞬时电流 × 2"来估,宁可多放一点容量。

注意:ADC监测电源电压时,参考电压要选内部基准(比如2.56V)而不是VCC本身,否则VCC下跌时参考电压跟着跌,测量结果永远显示"正常",这个坑非常隐蔽。

保存流程的顺序也有讲究:检测到低压 → 关闭大功率外设(继电器、蜂鸣器、背光)→ 写MRAM → 读回校验 → 进入低功耗等待复位。关闭外设这一步能显著延长可用时间,因为它直接把负载电流降下来了。

4. 驱动代码:从SPI初始化到读写指令

软件这块,MRAM的指令集比EEPROM简单得多,没有页缓冲、没有擦除、没有写周期等待。我下面给的代码是实际项目里跑了一年多的版本,改动不大,你可以直接拿去用。

4.1 硬件SPI初始化与分频计算

ATmega2560的SPI时钟由SPCR的SPR1:0和SPSR的SPI2X共同决定,分频关系如下:

SPR1SPR0SPI2XSCK 频率(F_CPU=16MHz)
000F_CPU/4 = 4MHz
001F_CPU/2 = 8MHz
010F_CPU/16 = 1MHz
011F_CPU/8 = 2MHz
100F_CPU/64 = 250kHz
101F_CPU/32 = 500kHz
110F_CPU/128 = 125kHz
111F_CPU/64 = 250kHz

我的默认选择是F_CPU/8 = 2MHz,原因有三个:一是走线长一点也不会振铃;二是如果你的系统是5V单片机直连3.3V MRAM(加了分压或转换),2MHz的边沿更温和;三是2MHz已经能把64字节写到70微秒以内,对绝大多数应用足够了。

只有在数据吞吐要求高(比如整块搬几百KB)时才往8MHz调,那时候要特别注意PCB走线和从机的最快时钟限制。

#include <avr/io.h> #include <stdint.h> #include <stdbool.h> #define MRAM_CS_LOW() (PORTB &= ~(1 << PB0)) #define MRAM_CS_HIGH() (PORTB |= (1 << PB0)) void mram_spi_init(void) { /* PB0(SS) PB1(SCK) PB2(MOSI) 输出,PB3(MISO) 输入 */ DDRB |= (1 << PB0) | (1 << PB1) | (1 << PB2); DDRB &= ~(1 << PB3); PORTB |= (1 << PB0); /* CS 默认高,不选中 */ /* 主机模式,模式0(CPOL=0, CPHA=0),fosc/16 + SPI2X => fosc/8 */ SPCR = (1 << SPE) | (1 << MSTR) | (1 << SPR0); SPSR = (1 << SPI2X); } static inline uint8_t spi_xfer(uint8_t d) { SPDR = d; while (!(SPSR & (1 << SPIF))) { ; } return SPDR; }

这段初始化里,SPCR的SPR0置1、SPR1置0对应分频16,再打开SPI2X得到分频8。MRAM支持SPI模式0和模式3,我统一用模式0(空闲低电平,上升沿采样),这是最通用的。

spi_xfer里用轮询等SPIF标志,没有用中断。对于几十到几百字节的传输,轮询的代码开销比中断小,而且不会被打断时序。如果你要传几KB,可以改成中断加缓冲区,但说实话MRAM写得那么快,轮询已经够了。

4.2 MRAM的指令集与读写时序

我用的这颗MRAM常用指令就四条,和SPI Flash很像:

指令编码作用
READ0x03读数据,后跟3字节地址,地址自动递增
WRITE0x02写数据,后跟3字节地址,地址自动递增
RDID0x9F读器件ID,用于上电自检
RDSR0x05读状态寄存器(部分型号才有实际内容)

和SPI Flash最大的区别是:没有WREN(写使能)、没有扇区擦除、没有写周期等待。MRAM的写入是直接覆盖,发完WRITE指令加地址加数据,拉高CS就完成了,你可以立刻再发READ指令读回来验证。

这个"立刻能读回"的特性非常有用。我在参数保存函数里就做了写后立即校验,如果读回不一致就重试一次,重试还不行就报错。在EEPROM上这么干要先等5毫秒,根本没法在掉电窗口内完成。

时序上有几个硬要求:

  • CS必须在整个事务期间保持低电平,中途拉高会终止事务。有些新手在发完指令后手动拉高CS再拉低发地址,读出来全是错的。
  • 地址是24位,高位在前。256Kbit型号只用低15位,高9位补0。
  • 地址到达顶部后会自动回卷到0,连续读的时候要自己控制长度,别指望它停。
  • CS拉高之后要留一点时间再开始下一个事务,datasheet一般写几十纳秒,我习惯加一个__builtin_avr_delay_cycles(10),保险。

4.3 可直接用的读写驱动

#define MRAM_CMD_READ 0x03 #define MRAM_CMD_WRITE 0x02 #define MRAM_CMD_RDID 0x9F void mram_read(uint32_t addr, uint8_t *buf, uint16_t len) { MRAM_CS_LOW(); spi_xfer(MRAM_CMD_READ); spi_xfer((uint8_t)(addr >> 16)); spi_xfer((uint8_t)(addr >> 8)); spi_xfer((uint8_t)(addr)); while (len--) { *buf++ = spi_xfer(0x00); } MRAM_CS_HIGH(); } void mram_write(uint32_t addr, const uint8_t *buf, uint16_t len) { MRAM_CS_LOW(); spi_xfer(MRAM_CMD_WRITE); spi_xfer((uint8_t)(addr >> 16)); spi_xfer((uint8_t)(addr >> 8)); spi_xfer((uint8_t)(addr)); while (len--) { spi_xfer(*buf++); } MRAM_CS_HIGH(); }

就这么多,没有额外的状态轮询。写完之后如果要确认,直接调mram_read读回来比对即可。

上电自检我建议加一段读ID的逻辑:

bool mram_probe(void) { uint8_t id[3]; MRAM_CS_LOW(); spi_xfer(MRAM_CMD_RDID); id[0] = spi_xfer(0x00); id[1] = spi_xfer(0x00); id[2] = spi_xfer(0x00); MRAM_CS_HIGH(); /* 制造商ID非0x00且非0xFF即认为器件在线 */ return (id[0] != 0x00 && id[0] != 0xFF); }

这个自检很有必要。我曾经遇到过一批板子,MRAM的CS上拉电阻虚焊,平时工作正常(因为CS有内部弱驱动),但上电瞬间偶尔读不到器件。加了probe之后,自检失败就点亮一个错误指示灯,现场排查效率高很多。

4.4 双备份加CRC的掉电安全写法

虽然MRAM写入是逐字节立即生效的,但"写到一半掉电"仍然可能发生——数据块写了一半,剩下的是旧值。要保证任何时刻都有一份完整有效的数据,我用的是双槽交替 + 序号 + CRC的结构。

存储布局固定64字节一个槽,两个槽分别放在0x0000和0x0040。不用结构体直接映射,改成显式偏移,避免不同编译器版本的对齐差异:

#define SLOT_SIZE 64 #define SLOT_A 0x0000UL #define SLOT_B 0x0040UL #define OFF_DATA 0 /* 56 字节 */ #define OFF_LEN 56 /* 1 字节 */ #define OFF_CRC 57 /* 2 字节,覆盖 0~56 共 57 字节 */ #define OFF_SEQ 59 /* 2 字节,0 表示无效 */

CRC用CCITT多项式,算57字节在16MHz下大概几十微秒,完全能接受:

static uint16_t crc16_ccitt(const uint8_t *p, uint8_t n) { uint16_t crc = 0xFFFF; while (n--) { crc ^= (uint16_t)(*p++) << 8; for (uint8_t i = 0; i < 8; i++) { crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) : (uint16_t)(crc << 1); } } return crc; }

保存的核心逻辑是:先读出两个槽的序号,选序号较小的那个作为写入目标(这样最新的数据永远不会被覆盖),新序号取两者最大值加一,然后把序号位置先写成0标记无效,再写数据和CRC,最后写真实序号。

typedef struct { bool valid; uint16_t seq; uint8_t len; uint8_t data[56]; } cfg_t; static uint16_t read_seq(uint32_t base) { uint8_t b[2]; mram_read(base + OFF_SEQ, b, 2); return (uint16_t)b[0] | ((uint16_t)b[1] << 8); } bool cfg_save(const uint8_t *data, uint8_t len) { if (len > 56) { return false; } uint16_t seq_a = read_seq(SLOT_A); uint16_t seq_b = read_seq(SLOT_B); /* 选序号小的那个槽覆盖 */ uint32_t target = (seq_a <= seq_b) ? SLOT_A : SLOT_B; uint16_t new_seq = ((seq_a > seq_b) ? seq_a : seq_b) + 1; /* 1. 序号先置 0,标记该槽无效 */ uint8_t zero[2] = {0x00, 0x00}; mram_write(target + OFF_SEQ, zero, 2); /* 2. 写数据 */ mram_write(target + OFF_DATA, data, len); /* 3. 写长度 */ mram_write(target + OFF_LEN, &len, 1); /* 4. 写 CRC */ uint8_t buf[57]; mram_read(target + OFF_DATA, buf, 57); uint16_t crc = crc16_ccitt(buf, 57); uint8_t cb[2] = {(uint8_t)(crc & 0xFF), (uint8_t)(crc >> 8)}; mram_write(target + OFF_CRC, cb, 2); /* 5. 最后写序号,该槽正式生效 */ uint8_t sb[2] = {(uint8_t)(new_seq & 0xFF), (uint8_t)(new_seq >> 8)}; mram_write(target + OFF_SEQ, sb, 2); return true; }

读取的时候两个槽都读出来,各自校验CRC和序号,选序号大且校验通过的那个。只要有一个有效,数据就是安全的。

这套逻辑在EEPROM时代我没敢用,因为每次save要写六十多个字节,EEPROM需要两百多毫秒,掉电窗口根本不够。换到MRAM之后,整个save过程不到100微秒,可以在掉电检测中断里直接调用。

5. 实测性能与写入策略设计

代码跑通之后,我在实验室做了一轮实测,数据比预想的好。这一节把测量方法和结果贴出来,顺便讲讲换了MRAM之后哪些设计可以简化。

5.1 写入耗时实测:从毫秒级降到微秒级

测量方法很简单:用一个空闲IO在写操作前后翻转,用逻辑分析仪抓脉宽。SPI时钟2MHz和8MHz各测一遍,每次测100次取平均。

操作SPI 时钟数据量实测耗时
单字节写2MHz1 字节约 24 µs
64字节块写2MHz64 字节约 288 µs
64字节块写8MHz64 字节约 76 µs
64字节块读8MHz64 字节约 72 µs
片内EEPROM 64字节写(对照)—64 字节约 212 ms

EEPROM那一行的数字是按每字节3.3ms估算的,因为ATmega2560的EEPROM没有页写功能,只能一个一个来。288微秒对212毫秒,差了七百多倍,这个差距直接改变了很多设计约束。

举个例子:以前做参数存储,必须攒够一批改动才敢往EEPROM里写,导致"改了参数但还没生效"的状态管理变得很复杂,要维护一份RAM镜像和一份存储镜像,还要处理两者不同步的情况。现在改成"参数一改立刻存",代码里那一整块脏标记和延迟写入的逻辑全删了,bug也少了很多。

还有掉电保存:以前检测到低压只能本能地关掉所有外设、祈祷EEPROM能在电容放完前写完。现在可以从容地写两遍、读回校验一遍,还有富余时间。

提示:上面这些时间都是在"CS拉高后立即返回"的前提下测的。如果你在CS拉高后加了很长的延时,或者每次传输都重新初始化SPI,实测值会变差。SPI初始化只需在系统启动时做一次,不要放在读写函数里。

5.2 不用磨损均衡之后,程序设计反而简单了

EEPROM时代我写过好几版磨损均衡:把参数区切成若干块,用序号轮转,读的时候扫描所有块找最新的。这套逻辑不难,但很烦,因为每个块的大小、轮转策略、擦除时机都要考虑,而且一旦块大小没对齐,序号和数据容易错位。

换成MRAM之后,磨损均衡可以直接删掉。但这里要澄清一个概念:删掉磨损均衡不等于删掉双备份。双备份的目的不是"分摊磨损",而是"保证掉电原子性",这个在任何非易失存储上都成立。所以我的实现里仍然是两个槽,只是不再需要几十个槽轮转。

这个简化带来的间接好处是存储空间的利用率。256Kbit是32KB,两个64字节的槽加起来才128字节,剩下31.8KB全是空闲的。这些空间我用来做环形日志,记录最近两千条运行事件,每条16字节。如果还在用EEPROM,这32KB我根本不敢这么挥霍,因为每多一个写入点就多一处磨损。

环形日志的实现是标准的写指针加序号:

#define LOG_BASE 0x1000UL #define LOG_SLOT_SIZE 16 #define LOG_COUNT 2048 typedef struct { uint16_t seq; uint16_t crc; uint8_t payload[12]; } __attribute__((packed)) log_entry_t;

写指针存到一个固定位置,每次追加时读出指针、写到LOG_BASE + ptr * LOG_SLOT_SIZE、指针加一取模、再把指针写回。整个过程两次MRAM写,总共不到200微秒。日志写满之后自动覆盖最旧的记录,因为不需要考虑磨损,覆盖策略可以做得非常简单。

5.3 环形日志与参数快照的取舍

日志和参数快照要分开存,这个分区设计我建议一开始就定好,别后面再挪。

区域起始地址大小用途写入频率
参数槽A0x000064B配置备份1参数变更时
参数槽B0x004064B配置备份2参数变更时
运行日志0x100032KB事件记录每事件
统计计数0x7F00256B累计量、开机次数每分钟
预留0x7F80128B后续扩展—

统计计数那一块要特别注意写入频率。这类数据的特点是"几乎每次都变化",如果每次都写,虽然MRAM不在乎寿命,但会增加总线占用。我的做法是每60秒刷一次到MRAM,掉电时再补一次,最坏情况丢一分钟的数据,对累计量的影响可以忽略。

还有一种情况值得单独提:多字节计数器的一致性。如果累计量是32位(4字节),掉电正好发生在写到第2个字节时,读出来就是一个高16位新、低16位旧的离谱数值。解决办法还是CRC加序号那一套,或者把计数器拆成"主值 + 增量"两段,主值定期写、增量存RAM,掉电时再合并。我选的是后者,因为计数器更新太频繁,每次都写CRC不划算。

6. 踩过的坑与排查实录

从第一版打样到现在,这颗MRAM在四五个项目里跑过,踩过的坑不算多,但每一个都挺典型。下面按现象分类整理,遇到类似问题可以直接对照。

6.1 读回全是FF或全00

这是最常见的问题,原因基本集中在三个方向。

方向一:CS根本没选中。用示波器看CS引脚,上电时应该是高电平,读操作期间应该有一个明显的低脉冲。如果一直是高,检查DDRB有没有把PB0设成输出——很多人只设了PORTB,忘了DDRB,引脚一直是高阻态。如果一直是低,检查上拉电阻和初始化顺序。

方向二:SPI模式不对。MRAM支持模式0和模式3,但如果你的单片机设成了模式1或模式2(CPHA=1),数据会在错误的边沿采样,读回来全是错位的数据,看起来就像全FF或随机值。检查SPCR的CPOL和CPHA两位是否都为0。

方向三:地址越界或高位没清零。256Kbit型号的地址只有15位有效,如果你传了0x00010000这样的地址,高9位没清零,有些型号会忽略,有些会回卷到0。养成习惯,写地址前做一个掩码:addr &= 0x7FFF。

还有一个隐蔽的情况:读写之间没有拉高CS。有些代码为了省事,读和写放在同一个CS低电平周期里连续发,结果MRAM把WRITE指令之后的所有字节都当成数据,包括你以为的READ指令。每条指令必须独立成一个CS周期。

6.2 数据偶发错位一个字节

这个现象很折磨人:跑一万次测试,99.9%是对的,偶尔有一条数据整体偏移一个字节。

我遇到的原因有两个。一个是SPI传输函数里SPIF标志清除时机不对。AVR的SPIF在读了SPSR再读SPDR之后自动清除,如果你的代码先读SPDR再读SPSR,或者在中断里也操作SPI,可能出现标志竞争。我的做法是spi_xfer用内联函数、关中断保护,确保单次事务不被打断。

另一个原因是MISO在上一个事务结束后还没释放。多从机总线上,如果上一个从机的CS拉高后它的SO引脚需要几十纳秒才进入高阻,你紧接着选中MRAM并发送指令,第一个字节的MISO采样可能被上一个从机拉低。解决办法是切换CS之间加几微秒延时,或者降低SPI时钟。

我在代码里加了一个更彻底的防护:每次读操作完成后,立即用同样的地址再读一次,两次结果比对,不一致就重读第三次。MRAM读那么快,多读一遍的成本几乎为零。这个"读两次比对"的技巧在实际项目里救过我好几次。

6.3 上电偶发读不出数据

设备冷启动时,大概有百分之一到千分之一的概率读出来全是0xFF,重启一次就好。这个问题的根因是上电时序。

MRAM的VCC上升到有效电平需要一点时间,如果单片机的复位释放得比MRAM快,第一条SPI指令可能落在MRAM还没准备好的窗口里。特别是当单片机和MRAM用不同的稳压器供电、上电斜率不一致时,这个时间差可以达到几十毫秒。

解决办法有三个,我通常同时用前两个:

  • 上电后延时:在main函数最开始加_delay_ms(50),等所有电源都到位。这50毫秒对绝大多数设备无所谓。
  • 加probe重试:mram_probe()失败就延时10毫秒再试,最多重试5次,还不行才报错。
  • 复位芯片:如果板子上有电源监控芯片,它能把单片机复位一直保持到电压合格,这是最可靠的方案。

还有一种更隐蔽的情况:MRAM的VCC和单片机的VCC共用一个LDO,但MRAM的去耦电容太大,导致上电时LDO的软启动被拖慢,MRAM反而比单片机晚上电。这种情况把去耦电容从10µF降到1µF就能改善。

6.4 常见问题速查表

现象最可能原因排查动作解决方式
读回全0xFFCS未选中 / MOSI断开示波器看CS和MOSI波形检查DDRB配置、CS上拉
读回全0x00MISO被拉低 / 从机冲突断开其他SPI从机单独测试分时片选、加切换延时
数据错位一字节SPI模式错 / 标志竞争检查CPOL、CPHA改模式0、关中断保护
偶发位翻转外磁场干扰移开继电器/电感测试增加距离、加屏蔽
上电偶发失败电源时序不同步测VCC上升沿加延时、probe重试
写后读不一致CS提前拉高检查CS脉宽确保整个事务CS为低
高温下读写异常电平余量不足测MISO高电平幅度加电平转换、降SPI时钟
连续读越界地址自动回卷核对读取长度代码里限制长度

再补两个表格放不下的经验:

关于焊接。MRAM常见封装是8引脚SOIC或者DFN,DFN封装的散热焊盘如果没焊好,会出现"常温正常、高温失效"的怪现象。我有一批板子就是这样,用热风枪补焊散热焊盘之后就正常了。如果用DFN,钢网开孔一定要覆盖散热焊盘。

关于替换测试。怀疑MRAM本身有问题时,最快的验证方法是把它拆下来,换一颗新的上去。但如果手头没有备件,可以先在MRAM的位置焊一个8脚插座,插一颗确认好的器件对比。这个笨办法帮我排除过好几次"以为是软件问题,其实是器件被静电打伤"的情况。

关于静电。MRAM是磁隧道结器件,对静电的敏感度和普通CMOS差不多,但因为它贵,很多人拆装时反而容易疏忽。我现在的习惯是焊接前先摸一下接地金属,工作台上铺防静电垫,拆下来的器件放在防静电袋里,不要随手扔在桌面上。

最后分享一个我在实际使用中的体会:这套方案真正值钱的地方不是"寿命长"这个参数,而是它把存储从一个需要精打细算的资源变成了普通外设,让代码可以写得更直接、更笨、更容易维护。我后来回头看那些为了省EEPROM寿命而设计的复杂逻辑,大部分都可以整段删掉,删完之后bug少了,调试时间也短了。如果你的项目正卡在"参数存得太频繁"这件事上,早点换,别硬扛。

返回列表