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 四种非易失存储的横向对比
先看这张表,数据来自几份公开数据手册的典型值,具体型号会有出入,但量级关系是准的:
| 方案 | 写寿命 | 单字节写时间 | 掉电保存 | 接口复杂度 | 单位容量成本 | 典型待机电流 |
|---|---|---|---|---|---|---|
| 片内EEPROM | 10万次 | 3.3ms | 是 | 极简(寄存器直读) | 极低(已集成) | 微安级 |
| 外置SPI EEPROM | 100万~400万次 | 5ms(含写周期) | 是 | 简单(SPI) | 低 | 1~5µA |
| SPI FRAM | 10^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 |
|---|---|---|---|
| PB0 | SS(片选) | 输出 | CS |
| PB1 | SCK | 输出 | SCK |
| PB2 | MOSI | 输出 | SI |
| PB3 | MISO | 输入 | 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共同决定,分频关系如下:
| SPR1 | SPR0 | SPI2X | SCK 频率(F_CPU=16MHz) |
|---|---|---|---|
| 0 | 0 | 0 | F_CPU/4 = 4MHz |
| 0 | 0 | 1 | F_CPU/2 = 8MHz |
| 0 | 1 | 0 | F_CPU/16 = 1MHz |
| 0 | 1 | 1 | F_CPU/8 = 2MHz |
| 1 | 0 | 0 | F_CPU/64 = 250kHz |
| 1 | 0 | 1 | F_CPU/32 = 500kHz |
| 1 | 1 | 0 | F_CPU/128 = 125kHz |
| 1 | 1 | 1 | F_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很像:
| 指令 | 编码 | 作用 |
|---|---|---|
| READ | 0x03 | 读数据,后跟3字节地址,地址自动递增 |
| WRITE | 0x02 | 写数据,后跟3字节地址,地址自动递增 |
| RDID | 0x9F | 读器件ID,用于上电自检 |
| RDSR | 0x05 | 读状态寄存器(部分型号才有实际内容) |
和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 时钟 | 数据量 | 实测耗时 |
|---|---|---|---|
| 单字节写 | 2MHz | 1 字节 | 约 24 µs |
| 64字节块写 | 2MHz | 64 字节 | 约 288 µs |
| 64字节块写 | 8MHz | 64 字节 | 约 76 µs |
| 64字节块读 | 8MHz | 64 字节 | 约 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 环形日志与参数快照的取舍
日志和参数快照要分开存,这个分区设计我建议一开始就定好,别后面再挪。
| 区域 | 起始地址 | 大小 | 用途 | 写入频率 |
|---|---|---|---|---|
| 参数槽A | 0x0000 | 64B | 配置备份1 | 参数变更时 |
| 参数槽B | 0x0040 | 64B | 配置备份2 | 参数变更时 |
| 运行日志 | 0x1000 | 32KB | 事件记录 | 每事件 |
| 统计计数 | 0x7F00 | 256B | 累计量、开机次数 | 每分钟 |
| 预留 | 0x7F80 | 128B | 后续扩展 | — |
统计计数那一块要特别注意写入频率。这类数据的特点是"几乎每次都变化",如果每次都写,虽然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 常见问题速查表
| 现象 | 最可能原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 读回全0xFF | CS未选中 / MOSI断开 | 示波器看CS和MOSI波形 | 检查DDRB配置、CS上拉 |
| 读回全0x00 | MISO被拉低 / 从机冲突 | 断开其他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少了,调试时间也短了。如果你的项目正卡在"参数存得太频繁"这件事上,早点换,别硬扛。