ARTICLE DETAIL

资讯详情

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

ATmega2560+SPI MRAM 掉电存储与EEPROM寿命替代实践

ATmega2560+SPI MRAM 掉电存储与EEPROM寿命替代实践

做过带掉电保存功能的设备的人都懂那种别扭:参数明明只改了几下,日志却要每秒落盘一次,选存储介质的时候手就开始抖——用片内 EEPROM,怕擦写次数用完;用铁电,容量和价格顶不住;用 NOR Flash,得先擦扇区,还要等几毫秒。这个项目我把 ATmega2560 和一颗 SPI 接口的 MRAM(磁阻随机存储器)拼到一起,跑了三个多月循环写入测试,算是把 EEPROM 擦写焦虑这件事彻底放下了。这篇文章想讲清楚我为什么这么选、线该怎么接、驱动怎么写、掉电保护结构怎么设计,以及调试过程中踩过的那些坑。内容适合正在做数据记录仪、设备参数存储、工控黑匣子这类东西的朋友,代码是寄存器级 C,直接往工程里搬就行,不需要额外依赖库。读完你至少能判断一件事:你的项目到底值不值得为"无限次写入"多花那点物料成本。

1. 为什么要换掉片内 EEPROM:一次真实的寿命账本

1.1 把"10 万次"换算成天数,焦虑就具象了

ATmega2560 片内 EEPROM 是 4KB,手册里标的擦写耐久度是 10 万次量级。这个数字单看挺大,但一换算是另一回事。假设一块采集板每秒记录一条 32 字节的状态日志,并且每次都写到同一个地址区间(很多早期代码就是这么写的),那么每个字节每秒被写一次,10 万次除下来大约 1.16 天就到寿命了。即使把周期放宽到 10 分钟一次,也不过一年出头。更麻烦的是,EEPROM 是按字节独立计寿命的,一旦某个字节先坏,往往表现为"这一个字节永远是 0xFF"或者"写进去读出来是旧值",这种坏法特别隐蔽,等你在现场发现参数不对,设备可能已经跑了大半年。

我见过不少项目用"磨损均衡"来续命,思路是把 4KB 当成一个环形缓冲区,轮流写。理论上能延长上百倍,但实现成本不低:你需要维护一块元数据区记录当前写指针,而元数据区本身又要写,又变成新的寿命瓶颈。绕来绕去,本质上还是在跟"有限次擦写"这个物理限制较劲。既然介质本身的物理机制决定了上限,那就该从换介质这个方向去想,而不是在软件层面硬扛。

1.2 MRAM 凭什么敢说"近乎无限次"

MRAM 的存储单元是磁隧道结:两层磁性材料夹一层极薄的绝缘势垒,信息存在自由层的磁化方向里,而不是电荷里。这一点是关键差异——EEPROM 和 Flash 靠往浮栅里注入/抽出电子来存数据,注入过程要穿过氧化层,每次都会对氧化层造成微小损伤,损伤累积到一定程度就漏电、就写不进去了,这就是擦写寿命的物理来源。MRAM 写数据是靠电流产生的自旋力矩去翻转磁化方向,没有电荷穿隧这个动作,氧化层不承受应力,所以耐久度能报到 10 的 14 次方这个量级,很多型号干脆标"无限次"。

另一个常被忽略的好处是写入行为。EEPROM 和 Flash 写之前要擦,擦除是整扇区的事,写一个字节也得走完整的擦写周期;MRAM 是字节级就地覆盖写,不需要预擦除,写入完成后 CS 一拉高数据就落定了,没有内部写周期,也不需要轮询 WIP 状态位。这意味着你可以在中断里直接写,不用担心"正在写的时候来中断会不会破坏数据",也不用算"写完要等几毫秒"。对实时性有要求的场合,这个差别比寿命差别更值钱。

1.3 几种掉电保存方案摆在一起比

选型阶段我把能找到的方案都列了一遍,按我这边的需求打分:容量 32KB 以内、字节级写入、写入次数极多、掉电数据不丢、接口简单、成本可控。

方案写入粒度是否需要预擦除单字节写入耗时擦写耐久度掉电后数据我的评价
片内 EEPROM字节内部自动约 3.3ms10 万次量级保持便宜但寿命焦虑
SPI NOR Flash页(256B)需要,按扇区擦除几毫秒到几十毫秒10 万次量级保持适合存固件,不适合高频小数据
FRAM字节不需要微秒级10 的 12 次方以上保持很好,但同容量价格偏高
SRAM + 备用电池字节不需要纳秒级无限依赖电池电池是新的失效点
SPI MRAM字节不需要受 SPI 时钟限制10 的 14 次方以上保持综合最省心

表格里"单字节写入耗时"这一列我特意没写 MRAM 的具体数字,因为它不取决于介质,而取决于你的 SPI 时钟——MRAM 本身没有内部写延迟,你 SPI 跑多快它就多快。这也是它跟 EEPROM 最本质的区别:EEPROM 的 3.3ms 是物理等待,你怎么优化代码都跳不过去;MRAM 的耗时是纯总线传输时间,加个时钟或者缩短走线就能改善。

1.4 这个组合适合谁、不适合谁

先说适合的:需要高频记录小数据块的设备(传感器日志、运行状态快照)、参数需要在运行中反复调整并立即生效的控制板、掉电瞬间必须把最后几条关键数据落盘的场合、以及那些用了 EEPROM 但已经开始担心现场返修率的存量产品。这类项目的共同点是"数据量不大但写入频繁",正好是 MRAM 的甜区。

不太适合的:要存几兆字节历史数据的场景,MRAM 单颗容量普遍不大,位成本还是高于 NOR Flash,这种需求老老实实上 Flash 加文件系统更划算;另外对成本敏感到几分钱都要抠的消费类产品,也很难说服采购。我个人判断标准很简单:如果这个设备一旦在现场因为存储失效被退回,损失远大于一颗 MRAM 的差价,那就该换。

2. 硬件层:ATmega2560 的 SPI 到底该怎么接

2.1 引脚对应关系与最常见的那个坑

ATmega2560 的硬件 SPI 固定在 PORTB 上:PB0 是 SS、PB1 是 SCK、PB2 是 MOSI、PB3 是 MISO。接 MRAM 的时候,CS 我一般不用 PB0,而是另找一个普通 IO 比如 PB4 或 PH5,原因是 PB0 作为 SS 有一个非常阴险的行为:当 SPCR 里的 MSTR 置 1(主机模式)而 SS 引脚被配置为输入且被外部拉低时,硬件会强制把 MSTR 清零,SPI 直接退出主机模式。表现出来就是"初始化完还能读一次,之后 SPI 就哑了",很多人查半天以为是芯片坏了。所以我的规矩是:PB0 一定要配置成输出并保持高电平,哪怕你不用它;真正的片选信号用别的引脚软件控制。

接线就四根:SCK、MOSI、MISO、CS。MISO 是 MRAM 输出、MCU 输入,其余三根都是 MCU 输出。如果板子上还有别的 SPI 从设备,注意每个设备都要有独立的 CS,绝对不能共用——SPI 没有地址概念,靠的就是片选区分,共用 CS 会导致两个器件同时驱动 MISO 打架。

2.2 电平、供电和去耦,这三件事决定稳定性

ATmega2560 常见的工作电压是 5V,而大部分 SPI MRAM 是 3.3V 器件。这里有个容易被忽略的细节:MRAM 输出高电平时大约 3.3V,而 5V 供电的 ATmega2560 的输入高电平门限是 0.6 倍 VCC,也就是 3.0V。3.3V 勉强过线,常温下能跑,但一到低温或者电源波动就可能误判,表现为"偶尔读错一个字节"。我的做法是 MISO 线上加一级电平转换,或者至少用电阻分压加肖特基钳位;反方向 MCU 输出 5V 到 3.3V 器件,如果器件 IO 不是 5V 容忍的,必须降压,别赌。更省事的方案是整板跑 3.3V,ATmega2560 在 8MHz 下 3.3V 完全能工作,把电平问题从根上消掉。

去耦是另一件不能省的事。MRAM 在写入瞬间有电流尖峰,虽然不大但持续时间极短,如果电源上只有一颗远端的 10uF,尖峰会被走线电感顶出一个凹陷,表现就是"高速写的时候偶发数据错误"。标准做法是器件 VCC 引脚旁边贴一颗 100nF 陶瓷电容,距离控制在 2mm 以内,再在电源入口放一颗 1uF 到 10uF 的储能电容。

提示:MRAM 的 CS 引脚上建议加一个 10k 上拉到 VCC。某些 MCU 在上电复位期间 IO 是高阻态,CS 处于浮空可能被噪声拉低,误触发一次写操作,把不该改的地址写了。这类问题极难复现,但一旦发生就是数据事故。

2.3 SPI 时钟怎么定:算清楚再写寄存器

ATmega2560 的 SPI 时钟由 SPCR 的 SPR1:SPR0 和 SPSR 的 SPI2X 共同决定,分频关系如下表(假设主频 16MHz)。

SPR1SPR0SPI2X分频实际 SCK 频率
00044 MHz
00128 MHz
01182 MHz
10132500 kHz
11164250 kHz

MRAM 本身能跑到几十兆赫兹,瓶颈完全在 MCU 这边,所以理论上直接上 8MHz 就是 ATmega2560 的极限了。但我不建议一上来就用 8MHz:面包板或者杜邦线连接时,8MHz 的边沿配合长走线很容易振铃,读回数据错位。我的习惯是先用 2MHz 把功能调通、确认读写正常,再把频率往上顶,每提一档跑一遍全片写读比对。真正做成 PCB、走线控制在 5cm 以内并且有完整地平面的时候,8MHz 是很稳的。

另外 SPI 模式要选对,绝大多数 SPI MRAM 支持模式 0(CPOL=0,CPHA=0)和模式 3(CPOL=1,CPHA=1)。我默认用模式 0,因为它在 CS 拉低后第一个时钟上升沿采样,逻辑最直观,示波器上看波形也最容易对。如果读出来数据整体错一位,十有八九是模式选错了,先换 CPHA 试试。

3. 驱动实现:从寄存器配置到掉电安全的数据结构

3.1 先搞懂指令集和地址空间

这类 SPI MRAM 的指令集跟 SPI NOR Flash 很像,但语义简单得多。常用的是这几条:0x03 读数据、0x02 写数据、0x05 读状态寄存器、0x01 写状态寄存器、0x06 写使能、0x04 写禁止。地址是 24 位,也就是三字节,高位在前,所以 32KB 容量对应地址 0x000000 到 0x007FFF。指令字节之后紧跟三字节地址,然后是数据流,整个过程 CS 必须一直保持低电平,中途拉高就中断了这次事务。

状态寄存器里最需要注意的是块保护位(BP0/BP1)和状态寄存器写保护位。出厂时有些器件默认把部分地址空间设成只读,你写进去读回来还是旧值,非常迷惑。所以我的初始化流程里固定加一步:读状态寄存器,如果保护位非零,先发写使能,再写状态寄存器清零,最后再读一次确认。

#include <avr/io.h> #include <util/delay.h> #define MRAM_CS_LOW() (PORTB &= ~(1 << PB4)) #define MRAM_CS_HIGH() (PORTB |= (1 << PB4)) #define CMD_WRITE 0x02 #define CMD_READ 0x03 #define CMD_WRSR 0x01 #define CMD_WRDI 0x04 #define CMD_RDSR 0x05 #define CMD_WREN 0x06 static void mram_spi_init(void) { /* PB0(SS) PB1(SCK) PB2(MOSI) PB4(CS) 输出,PB3(MISO) 输入 */ DDRB |= (1 << PB0) | (1 << PB1) | (1 << PB2) | (1 << PB4); DDRB &= ~(1 << PB3); PORTB |= (1 << PB0) | (1 << PB4); /* SS 与 CS 空闲均为高 */ /* 主机模式,模式0,先跑 fosc/8 = 2MHz */ SPCR = (1 << SPE) | (1 << MSTR) | (0 << CPOL) | (0 << CPHA) | (0 << SPR1) | (1 << SPR0); SPSR = (0 << SPI2X); }

上面这段里有个小细节值得说:PORTB |= (1 << PB0)必须在 DDRB 设置之后立刻做,顺序反了会有一个极短的窗口 PB0 处于输入且内部上拉未开的状态。虽然时间很短,但如果板子上有别的电路正好在这个窗口拉低 PB0,就可能触发前面说的 MSTR 被清零。这种时序问题不会每次都出现,但一旦出现就要查很久。

3.2 收发函数与读写封装

底层收发就是往 SPDR 写、等 SPIF 置位、再读 SPDR。这里有个易错点:SPIF 标志是"读 SPSR 再读 SPDR"这个组合来清除的,如果你在中断里用,还要考虑 SPIF 和 SPDR 的访问顺序。我这里用轮询方式,最直接。

static uint8_t spi_xfer(uint8_t d) { SPDR = d; while (!(SPSR & (1 << SPIF))) { ; } return SPDR; } static uint8_t mram_rdsr(void) { uint8_t v; MRAM_CS_LOW(); spi_xfer(CMD_RDSR); v = spi_xfer(0x00); MRAM_CS_HIGH(); return v; } static void mram_wrsr(uint8_t v) { MRAM_CS_LOW(); spi_xfer(CMD_WREN); MRAM_CS_HIGH(); MRAM_CS_LOW(); spi_xfer(CMD_WRSR); spi_xfer(v); MRAM_CS_HIGH(); } void mram_write(uint32_t addr, const uint8_t *buf, uint16_t len) { MRAM_CS_LOW(); spi_xfer(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(); /* 拉高即完成存储,无内部写周期 */ } void mram_read(uint32_t addr, uint8_t *buf, uint16_t len) { MRAM_CS_LOW(); spi_xfer(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(); }

注意mram_write里 CS 拉高之后什么都不用等。这一点跟写 Flash 完全不同,写 Flash 你得轮询状态位等擦除完成,写 MRAM 拉高就结束了。你在中断服务函数里调用它也是安全的,因为它不会被内部写周期卡住。

3.3 掉电安全的数据结构:双槽 + 序号 + CRC

介质再好,也架不住"写到一半掉电"。虽然 MRAM 的写入是原子性较强的双稳态翻转,但一次记录跨越多个字节,MCU 完全可能在写到第 10 个字节的时候断电,剩下 22 个字节还是旧值,这就形成了一个半新半旧的畸形记录。解决办法是业界最经典的双槽交替法:把存储区分成 A、B 两块,每块 64 字节,一条记录 32 字节,每次写当前"较旧"的那一块,写完立刻回读校验。

记录结构我设计成这样:

typedef struct __attribute__((packed)) { uint32_t seq; /* 单调递增序号,判断新旧 */ uint16_t crc; /* 前面所有字段 + 载荷的校验 */ uint8_t len; /* 实际有效载荷长度,0~25 */ uint8_t payload[25]; /* 业务数据 */ } nvrec_t; /* 合计 32 字节 */

32 字节不是随便定的。它正好是二进制整数字节,地址计算不用做乘法以外的运算;同时它跟常见的 ATmega2560 内部 RAM 行宽、SPI 传输块大小都比较友好。如果你业务数据超过 25 字节,我的建议是拆成两条记录或者换更大的槽,而不是硬塞,因为结构一旦不规整,后续做地址运算和 CRC 覆盖范围判断时非常容易出错。

CRC 用 CCITT 多项式 0x1021,初值 0xFFFF,代码短,检错能力对于这种长度的数据足够:

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++) { if (crc & 0x8000) { crc = (uint16_t)((crc << 1) ^ 0x1021); } else { crc = (uint16_t)(crc << 1); } } } return crc; }

CRC 的计算范围要覆盖 seq、len、payload 三段,但不包括 crc 字段自身。这个顺序在写入和读取时必须完全一致,我吃过一次亏:写入时按"seq→payload"算,读取时按"seq→len→payload"算,结果所有记录校验都失败,表现出来就像"数据全丢了",其实是校验范围写错了。现在我的做法是把结构体里 crc 字段挪到中间,用两段式计算避开"跳过某个字段"这种容易写错的逻辑。

3.4 保存与加载的完整逻辑

保存时先读两个槽的头部,比出哪个 seq 更旧,然后往旧的那个位置写新记录。加载时把两个槽都读出来算 CRC,只保留校验通过且 seq 更大的那条。这样即使写到一半掉电,最坏情况是丢最后一次写入,上一条记录仍然完好。

#define SLOT_A_ADDR 0x000000UL #define SLOT_B_ADDR 0x000040UL static uint8_t nv_save(const nvrec_t *rec) { nvrec_t a, b, out; uint8_t target_is_a; uint32_t next_seq; mram_read(SLOT_A_ADDR, (uint8_t *)&a, sizeof(a)); mram_read(SLOT_B_ADDR, (uint8_t *)&b, sizeof(b)); /* 选序号较小的槽作为待覆盖目标;序号相同或都无效时写 A */ if ((int32_t)(a.seq - b.seq) > 0) { target_is_a = 0; next_seq = a.seq + 1; } else { target_is_a = 1; next_seq = b.seq + 1; } out = *rec; out.seq = next_seq; out.crc = crc16_ccitt((const uint8_t *)&out, sizeof(out) - sizeof(out.crc)); if (target_is_a) { mram_write(SLOT_A_ADDR, (const uint8_t *)&out, sizeof(out)); } else { mram_write(SLOT_B_ADDR, (const uint8_t *)&out, sizeof(out)); } /* 回读校验,写失败了立刻返回错误,别装作成功 */ { nvrec_t chk; uint8_t ok; if (target_is_a) { mram_read(SLOT_A_ADDR, (uint8_t *)&chk, sizeof(chk)); } else { mram_read(SLOT_B_ADDR, (uint8_t *)&chk, sizeof(chk)); } ok = (chk.seq == out.seq) && (chk.crc == crc16_ccitt((const uint8_t *)&chk, sizeof(chk) - sizeof(chk.crc))); return ok; } }

代码里的seq比较用了(int32_t)(a.seq - b.seq) > 0这种写法,目的是正确处理计数器从 0xFFFFFFFF 回绕到 0 的情况。直接比大小在回绕那一刻会翻车,把旧记录当成新记录,这个坑我在另一个项目里踩过,代价是设备重启后参数回退到几个月前的值。用差值再转有符号数比较,是处理单调计数器的标准手法。

加载函数就是把两个槽都读一遍、各算一次 CRC、取有效的里面 seq 较大的那条。逻辑简单,但一定要处理"两个都无效"的情况——比如首次上电,存储器里全是 0xFF,这时候应该返回默认参数而不是把 0xFF 当数据用。

4. 实测数据:性能、寿命和掉电到底怎么样

4.1 写入速度对比,差距是数量级的

我用逻辑分析仪抓 CS 的下降沿到上升沿,量单次写入事务的耗时。测试条件是 SPI 8MHz、一次写 32 字节记录。

介质与配置单次写入 32 字节耗时说明
片内 EEPROM约 106 ms3.3ms/字节 × 32,是物理等待,无法压缩
SPI MRAM @2MHz约 150 us含指令头,总线传输为主
SPI MRAM @8MHz约 40 usATmega2560 的 SPI 上限

这里算一笔细账,让数字更有说服力。8MHz 下一个字节传输是 8 个时钟周期,也就是 1 微秒。一次写事务包含 1 字节指令 + 3 字节地址 + 32 字节数据 = 36 字节,纯传输 36 微秒,再加上 CS 前后各一点建立保持时间,实测 40 微秒左右,对得上。而 EEPROM 的 3.3ms 是每个字节独立的内部写周期,写 32 个字节就是 32 次,累计 100 毫秒以上,这段期间 CPU 虽然能跑,但一旦掉电,正在写的那个字节就废了,而且那一整片数据的一致性也没法保证。

这个差距带来的实际影响是:我可以在主循环里每 10 毫秒存一次完整状态快照而不影响任何其他任务的时序。换成 EEPROM,一次写就把 100 毫秒的主循环卡没了,控制周期直接被破坏。所以对我这个项目来说,换 MRAM 不只是解决寿命问题,更是把"存储"从一个阻塞操作变成了一个几乎可以忽略的小动作。

4.2 掉电测试:模拟最恶劣的时机

寿命测试好做,掉电测试才是真正见功夫的地方。我的方法是:写一个循环,每次写入前用随机延时决定"什么时候拉掉电源",用一个 MOS 管做电源开关,由另一块板子控制,反复上电、写入、随机时刻断电、再上电校验,跑了一千次。判断标准是每次上电后,双槽里至少有一条 CRC 通过的记录,且序号单调不倒退。

一千次跑下来,最差的一次是丢了最后一条正在写的记录,前一条完好。这个结果符合预期,因为双槽交替本来就是为这个场景设计的。我还专门试过一种更刁钻的情况:断电后电源缓慢跌落,电压在 2V 附近徘徊了几百毫秒才彻底归零,这种时候 MCU 可能处于半工作状态,乱发指令。结果 MRAM 这边数据没有出现花屏式的损坏,最坏还是丢最后一条。

我个人的经验是,掉电测试别只做十次八次就下结论。十次可能全过,因为随机延时不一定撞上最危险的窗口。至少跑几百次,并且专门统计"写第几个字节时断电"的分布,你才能对这套机制的可靠性有底。

注意:测试时别用可调电源直接拧旋钮断电,那种断电极慢且带过冲,跟真实场景差太远。用 MOS 管或者继电器做硬切换更接近真实。

4.3 长时间循环写入:跑了三个月

循环写入测试跑了大约三个月,8MHz SPI,每条记录 32 字节,间隔 10 毫秒,累计写入次数超过七百万次。期间每天比对一次数据完整性,没有出现单次校验失败。按 10 的 14 次方耐久度算,这个量连零头都算不上,但至少验证了驱动逻辑本身在长时间运行下没有累积性错误,比如地址计算溢出、seq 回绕这些问题。

对比之下,同样的循环如果跑在片内 EEPROM 上,按照 10 万次的规格,每个字节在写完一万条记录之后就开始进入危险区了。七百万次这个量级,EEPROM 根本走不到。

我还顺手测了功耗:MRAM 待机电流在微安级,写入时峰值电流比 EEPROM 的写入电流略高一点点,但因为写的时间极短,平均功耗反而更低。对电池供电的设备来说,这个特性很实在——不是省那点峰值,而是省掉了"每次写都要等 100 毫秒"期间 MCU 醒着的那部分能量。

5. 问题排查实录:那些让人怀疑人生的故障

5.1 读回来全是 0xFF 或者全是 0x00

全 0xFF 通常意味着 MISO 线上根本没有有效驱动,最可能的原因有这么几个:CS 没真正拉低(软件宏定义写错引脚,或者被别的地方改回来了);MISO 引脚方向配置错了,被设成了输出;SPI 根本没使能,SPE 位没置;再或者就是前面说的 SPI 模式不对,采样边沿错位读到的全是连续的高电平。我排查这个的顺序是:先拿示波器看 SCK 有没有波形,有波形再看 MISO 上有没有数据跳变,两级一测就能定位到是 MCU 侧没发还是器件侧没回。

全 0x00 相对少见,一般是 MISO 被某个器件死死拉低,比如另一个 SPI 从设备的 CS 没有正确拉高,它在总线上持续驱动低电平。这时候你把那个设备的 CS 断开再测,如果立刻恢复正常,问题就找到了。还有一种可能是供电不足,器件上电复位没完成,IO 处于异常状态。

5.2 写得进去但读回来是旧值

这个现象十有八九是块保护位没清。有些器件出厂默认把状态寄存器的 BP 位置起来了,你对受保护区域发写指令,器件会静默丢弃,不报错,你读回来还是旧数据,非常容易误判成"驱动写错了"。我的做法是初始化里强制做一次"探针写入":往最后一个不常用地址写两个自己知道的字节,读回来比对,不一致就先清保护位再重试,重试还失败才报硬件故障。这个自检放在上电后 20 毫秒内完成,成本极低,但能省掉大量现场排查。

另一个可能的原因是写使能没有正确发出。虽然纯 MRAM 写入通常不需要 WREN,但状态寄存器写入是需要的,如果你的代码里把 WREN 和 WRSR 连续发在同一个 CS 周期里,有些器件不认,必须中间拉高一次 CS。这个细节各家不一样,看手册的时候别只看指令表,要连同 CS 时序图一起看。

5.3 排查速查表

现象高概率原因快速验证方法
读到全 0xFFCS 未拉低、MISO 方向错、SPI 未使能示波器看 SCK 与 MISO 是否有跳变
读到全 0x00MISO 被其他器件拉低、供电异常断开其他 SPI 从设备再测
写后读回旧值块保护位未清、WREN 时序错读状态寄存器,做探针写入自检
偶发单字节错误走线过长、SPI 过快、去耦不足降到 2MHz 复测,加 100nF 电容
初始化后 SPI 失效PB0(SS) 被拉低导致 MSTR 清零确认 PB0 设为输出且保持高电平
数据整体错一位SPI 模式(CPOL/CPHA)不匹配换模式 3 试试
地址错乱数据串位24 位地址字节顺序写反用小地址 0x000001 写读比对

5.4 几条不上手册的实操心得

信号完整性这块,我建议 CS 和 SCK 这两根线优先布线,能短则短,如果实在要拉长超过 10cm,在源端串一个 33 欧姆电阻抑制振铃,实测有效。地线一定要和信号线成对走,别只拉一根信号线跨板子,那种接法在 8MHz 下基本靠运气。

软件这块,我强烈建议保留一个"总线自检"函数,上电跑一次,运行中每隔一段时间再跑一次,往一块专门的测试地址写读两个字节。这个函数在调试期帮你定位问题,在量产之后能帮你提前发现焊点开裂这类渐进式故障。代价是几十微秒,收益是现场不会突然变成"参数全丢"。

还有一个细节:结构体一定要加__attribute__((packed))。AVR 的编译器对结构体默认可能做对齐填充,你不加这个属性,sizeof 出来的值和你算的 32 字节对不上,地址就全乱了。这种错误在写的时候不报,读的时候才对不上,排查起来特别费劲。

6. 还能怎么用:几个值得试的扩展方向

6.1 当环形日志缓冲区用

32KB 的容量,如果每条记录 32 字节,能放 1024 条。做成环形队列,头尾指针也存在 MRAM 里,就能实现"永远保留最近 1024 条运行日志"的效果。因为写入没有寿命顾虑,这个环可以一直转下去,不用担心某个位置被写坏。实现上的关键是头尾指针的原子更新:先写数据,再更新尾指针,读取的时候如果尾指针指向的记录 CRC 不对,就认为这条还没写完,跳过它。这套逻辑在任何掉电时刻都能保持一致性。

6.2 参数双区加版本号

如果参数结构比较复杂,改一次要动十几个字段,双槽方法依然适用,但建议在记录里加一个"参数版本号"字段。升级固件后参数结构变了,靠版本号判断需不需要迁移,而不是靠猜。我一般把版本号放在 payload 的第一个字节,迁移逻辑写成一个小函数,读出来版本低就走默认值,版本相同就直接用。

6.3 把存储操作彻底移出主循环

既然单次写只要 40 微秒,完全可以在定时器中断里做。但我不建议真的在中断里写,因为 SPI 传输本身要占用 40 微秒的 CPU 时间,对高优先级中断的响应还是有影响的。更好的做法是在主循环里维护一个脏标志,变了才写,写完清标志。这样既能保证数据及时落盘,又不会无谓地占用总线。

我在实际项目里最后是用了一个 10 毫秒的软定时器去检查脏标志,效果挺好:参数变化后最多 10 毫秒落盘,主循环几乎感觉不到存储的存在。这套组合跑到现在,最让我踏实的一点不是它能写多少次,而是我再也不用在代码里写"写完等 5 毫秒"这种注释了——那种等待本身就是设计上的妥协,能去掉就早点去掉。

返回列表