1. 项目缘起与方案选型:为什么是 MRAM 加 STM32F446ZE
1.1 一个真实的需求场景
去年接手一个工业数据采集终端的项目,设备安装在配电柜里,环境温度夏天能到 70 度,冬天户外柜内能到零下 30 度。设备每 200 毫秒采集一次电流、电压、温度数据,需要把关键参数和故障记录保存下来,断电不能丢,而且要求写入次数足够多——因为有些状态量是秒级刷新的,一天下来写入次数轻松上万次。
一开始我选的是常见的 SPI NOR Flash,比如 W25Q 系列,便宜、资料多、STM32 的 HAL 库直接支持。但跑了一段时间发现两个问题:第一,写入速度慢,页编程一次 256 字节要几毫秒,频繁写入时 CPU 被阻塞得厉害;第二,擦写寿命有限,虽然标称 10 万次,但实际在高温环境下会打折扣,而且擦除操作必须按扇区来,写之前还得先擦,逻辑复杂。
后来换成了 FRAM,铁电存储器,读写快、寿命几乎无限,但容量小、价格高,而且高温下数据保持能力会下降。再后来接触到 MRAM,磁性随机存储器,才算是找到了平衡点。这次用的就是 Everspin 的 MR25H40CDF,4Mbit 容量,SPI 接口,工业级温度范围,读写寿命号称无限次,数据保持 20 年以上。
主控选的是 STM32F446ZE,Cortex-M4 内核,180MHz 主频,带 FPU,SPI 接口丰富,工业场景里很常见。两者搭配,一个负责高速可靠存储,一个负责实时控制和数据处理,算是嵌入式存储方案里比较务实的一个组合。
1.2 MR25H40CDF 到底特别在哪
MR25H40CDF 是一颗 4Mbit(512K 字节)的 MRAM,SPI 接口,最高时钟 40MHz。和传统存储介质比,它的特点很鲜明:
- 写入不需要擦除:这是和 Flash 最大的区别。Flash 写之前必须先擦除整个扇区,MRAM 可以直接覆盖写,像 RAM 一样。这意味着写入逻辑简单很多,不需要管理擦除块,也不需要做磨损均衡。
- 写入速度快:没有擦除等待,单字节写入和页写入的时间基本就是 SPI 传输时间。40MHz 时钟下,写 256 字节大概几十微秒,比 Flash 快一个数量级。
- 寿命几乎无限:官方标称读写次数无限制,实际测试中连续写入几天几夜也没有出现坏块。对于需要频繁记录状态的应用,这一点非常关键。
- 数据保持时间长:磁性存储原理,断电后数据保持 20 年以上,而且不需要像 Flash 那样担心电荷泄漏。
- 功耗低:写入电流比 Flash 小,待机电流也低,适合电池供电或低功耗场景。
当然也有缺点:容量比同价位的 Flash 小,价格贵一些,而且国内货源不如 Flash 那么充足。但对于工业场景里需要频繁写入、高可靠性的小数据量存储,MRAM 是很合适的选择。
1.3 STM32F446ZE 的 SPI 资源分配
STM32F446ZE 有 6 个 SPI 接口,其中 SPI1、SPI4 支持最高 45MHz,SPI2、SPI3 支持 22.5MHz。MR25H40CDF 最高支持 40MHz,所以理论上可以用 SPI1 跑到 45MHz,但实际布线要考虑信号完整性,我一般会降到 20MHz 到 30MHz 之间,留点余量。
在这个项目里,我把 MR25H40CDF 挂在 SPI1 上,引脚分配如下:
| 功能 | 引脚 | 说明 |
|---|---|---|
| SCK | PA5 | SPI1 时钟 |
| MISO | PA6 | 主入从出 |
| MOSI | PA7 | 主出从入 |
| CS | PA4 | 软件片选,GPIO 控制 |
| VCC | 3.3V | 电源 |
| GND | GND | 地 |
片选我用的是软件片选,而不是硬件 NSS。原因后面会详细说,简单讲就是软件片选更灵活,可以精确控制时序,避免多从机场景下的冲突。
2. 硬件设计与 SPI 通信基础
2.1 MRAM 的引脚连接与去耦
MR25H40CDF 是 8 引脚 SOIC 封装,引脚定义很标准:
- 1 脚 CS:片选,低有效
- 2 脚 SO:数据输出
- 3 脚 WP:写保护,低有效,不用的话接高
- 4 脚 GND:地
- 5 脚 SI:数据输入
- 6 脚 SCK:时钟
- 7 脚 HOLD:保持,低有效,不用的话接高
- 8 脚 VCC:电源
实际布线时,VCC 和 GND 之间要加一个 0.1uF 的陶瓷电容,尽量靠近芯片引脚。如果走线较长,再并一个 1uF 的电容。SPI 信号线尽量等长,SCK 走线避免直角,如果板上还有其他高速信号,MRAM 的走线要远离。
注意:WP 和 HOLD 引脚不要悬空,悬空可能导致意外写保护或通信异常。我一般直接接 3.3V,如果不需要硬件写保护功能的话。
2.2 SPI 模式选择:Mode 0 还是 Mode 3
MR25H40CDF 支持 SPI Mode 0 和 Mode 3。Mode 0 是 CPOL=0,CPHA=0,时钟空闲低电平,第一个边沿采样;Mode 3 是 CPOL=1,CPHA=1,时钟空闲高电平,第二个边沿采样。
我一般用 Mode 0,因为 STM32 的 HAL 库默认配置就是 Mode 0,而且大多数逻辑分析仪和示波器在 Mode 0 下解码更直观。实际测试中,两种模式在 20MHz 以下都能稳定工作,但 Mode 0 的时序余量更大一些。
配置 SPI 时,关键参数如下:
hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 10;STM32F446ZE 的 SPI1 挂在 APB2 总线上,时钟 90MHz。分频系数 8 的话,SPI 时钟是 11.25MHz。如果想跑到 22.5MHz,分频系数改成 4。我实测 22.5MHz 下读写稳定,但为了兼容不同批次的 MRAM 芯片,最终产品里用的是 11.25MHz。
2.3 软件片选 vs 硬件片选
STM32 的 SPI 支持硬件 NSS 和软件 NSS。硬件 NSS 在单主单从场景下没问题,但多从机时容易出问题,因为 NSS 引脚的电平变化和 SPI 使能时序可能不匹配。
软件片选就是用一个普通 GPIO 控制 CS 引脚,在每次传输前拉低,传输后拉高。这样做的好处是:
- 时序完全可控,可以在 CS 拉低后加一点延时再发时钟,确保 MRAM 准备好。
- 多从机时,每个从机独立片选,不会互相干扰。
- 调试方便,逻辑分析仪上能清楚看到每次传输的边界。
代码里这样操作:
#define MRAM_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define MRAM_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) void MRAM_Select(void) { MRAM_CS_LOW(); // 可选:加1us延时,确保MRAM检测到片选 } void MRAM_Deselect(void) { MRAM_CS_HIGH(); // 可选:加1us延时,确保MRAM完成内部操作 }实操心得:CS 拉低后不要立刻发时钟,尤其是长线缆或高时钟频率下,加 1 到 2 微秒的延时能显著提高稳定性。这个延时可以用
__NOP()循环实现,也可以用定时器。
3. MRAM 驱动实现:从指令集到读写函数
3.1 MR25H40CDF 的指令集
MR25H40CDF 的指令集和标准 SPI Flash 类似,但简化了很多,因为不需要擦除。常用指令如下:
| 指令名称 | 指令码 | 说明 |
|---|---|---|
| WREN | 0x06 | 写使能 |
| WRDI | 0x04 | 写禁止 |
| RDSR | 0x05 | 读状态寄存器 |
| WRSR | 0x01 | 写状态寄存器 |
| READ | 0x03 | 读数据 |
| WRITE | 0x02 | 写数据 |
| RDID | 0x9F | 读设备ID |
状态寄存器只有一位有用,就是 WEL(写使能锁存),bit1。每次写操作前必须先发 WREN 指令,否则写不进去。写完成后 WEL 自动清零。
读设备 ID 返回 7 个字节,前几个是厂商 ID 和产品 ID,可以用来检测芯片是否正常连接。MR25H40CDF 的厂商 ID 是 0xEverspin 的代码,具体值可以查数据手册。
3.2 底层字节读写函数
先实现最基础的字节收发函数,基于 HAL 库:
uint8_t MRAM_SPI_Transfer(uint8_t txData) { uint8_t rxData; HAL_SPI_TransmitReceive(&hspi1, &txData, &rxData, 1, HAL_MAX_DELAY); return rxData; }这个函数发一个字节,同时收一个字节。SPI 是全双工的,发和收同时进行。
然后实现写使能:
void MRAM_WriteEnable(void) { MRAM_Select(); MRAM_SPI_Transfer(0x06); MRAM_Deselect(); }读状态寄存器:
uint8_t MRAM_ReadStatus(void) { uint8_t status; MRAM_Select(); MRAM_SPI_Transfer(0x05); status = MRAM_SPI_Transfer(0xFF); MRAM_Deselect(); return status; }3.3 页写与连续读的实现
MR25H40CDF 支持页写,每页 256 字节。写操作可以跨页,但跨页时地址会回绕到页首,所以最好按页对齐写。
写数据函数:
void MRAM_Write(uint32_t addr, uint8_t *data, uint16_t len) { MRAM_WriteEnable(); MRAM_Select(); MRAM_SPI_Transfer(0x02); MRAM_SPI_Transfer((addr >> 16) & 0xFF); MRAM_SPI_Transfer((addr >> 8) & 0xFF); MRAM_SPI_Transfer(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { MRAM_SPI_Transfer(data[i]); } MRAM_Deselect(); // 等待写完成,实际MRAM写入很快,但保险起见延时 HAL_Delay(1); }读数据函数:
void MRAM_Read(uint32_t addr, uint8_t *buffer, uint16_t len) { MRAM_Select(); MRAM_SPI_Transfer(0x03); MRAM_SPI_Transfer((addr >> 16) & 0xFF); MRAM_SPI_Transfer((addr >> 8) & 0xFF); MRAM_SPI_Transfer(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { buffer[i] = MRAM_SPI_Transfer(0xFF); } MRAM_Deselect(); }注意:地址是 24 位的,因为 4Mbit 等于 512K 字节,需要 19 位地址,但 MRAM 用 24 位地址格式,高 5 位忽略。发送地址时要从高到低依次发送。
3.4 写保护与状态检查
每次写之前,最好检查一下状态寄存器的 WEL 位,确保写使能成功。虽然大多数情况下没问题,但在电磁干扰强的工业环境里,偶尔会出现 WREN 指令没被正确识别的情况。
uint8_t MRAM_WriteWithCheck(uint32_t addr, uint8_t *data, uint16_t len) { MRAM_WriteEnable(); if ((MRAM_ReadStatus() & 0x02) == 0) { return 1; // 写使能失败 } MRAM_Select(); MRAM_SPI_Transfer(0x02); // ... 发送地址和数据 MRAM_Deselect(); return 0; }这个检查会增加一点开销,但对于可靠性要求高的场景是值得的。
4. 数据存储策略与磨损管理
4.1 为什么 MRAM 不需要磨损均衡
Flash 需要磨损均衡是因为擦写寿命有限,每个扇区擦写次数不能超过 10 万次。MRAM 没有这个限制,理论上可以无限次写入,所以不需要复杂的磨损均衡算法。
但这不意味着可以随便写。实际使用中,我还是会做一些优化:
- 批量写入:不要每采集一个数据就写一次,攒够一定数量再写,减少 SPI 传输次数。
- 缓存机制:在 RAM 里维护一个缓存区,定期同步到 MRAM。
- 关键数据优先:只把真正需要断电保存的数据写到 MRAM,其他数据放 RAM。
4.2 数据结构设计
在这个项目里,我把存储区分成几个区域:
| 区域 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| 配置区 | 0x00000 | 4KB | 设备参数、校准系数 |
| 日志区 | 0x01000 | 256KB | 运行日志、故障记录 |
| 数据区 | 0x41000 | 252KB | 采集数据、统计值 |
配置区用结构体存储,每次修改后整体写入。日志区用环形缓冲区,新日志覆盖旧日志。数据区按时间戳索引,支持快速查询。
结构体定义示例:
typedef struct { uint32_t magic; // 0x55AA55AA,用于检测是否初始化 float voltage_gain; // 电压校准系数 float current_gain; // 电流校准系数 uint16_t sample_rate; // 采样率 uint8_t device_id[16]; // 设备ID uint32_t crc; // 校验值 } DeviceConfig;写入时先算 CRC,读出来时校验,防止数据损坏。
4.3 环形日志缓冲区的实现
日志区用环形缓冲区,写指针到达末尾后回到开头。每条日志包含时间戳、事件类型和数据。
typedef struct { uint32_t timestamp; uint8_t event_type; uint8_t data[11]; } LogEntry; // 16字节对齐写日志时,先读当前写指针,写入数据,然后更新写指针。写指针本身也保存在 MRAM 里,每次更新。
void Log_Write(uint8_t type, uint8_t *data) { uint32_t write_ptr = GetWritePointer(); LogEntry entry; entry.timestamp = HAL_GetTick(); entry.event_type = type; memcpy(entry.data, data, 11); MRAM_Write(LOG_BASE + write_ptr * sizeof(LogEntry), (uint8_t*)&entry, sizeof(LogEntry)); write_ptr = (write_ptr + 1) % LOG_MAX_ENTRIES; SetWritePointer(write_ptr); }实操心得:写指针更新和日志写入最好放在一个原子操作里,或者用双备份机制。我遇到过写日志过程中断电,导致写指针和实际数据不一致的情况。后来改成写指针存两份,读取时比较,不一致就用备份恢复。
5. 性能测试与优化实录
5.1 读写速度实测
用逻辑分析仪抓 SPI 波形,实测数据如下:
| 操作 | 时钟频率 | 耗时 | 说明 |
|---|---|---|---|
| 单字节写 | 11.25MHz | 约 3us | 含 WREN 和地址 |
| 256字节页写 | 11.25MHz | 约 240us | 含 WREN 和地址 |
| 单字节读 | 11.25MHz | 约 2us | 含地址 |
| 256字节读 | 11.25MHz | 约 230us | 含地址 |
| 读设备ID | 11.25MHz | 约 8us | 7字节返回 |
对比 W25Q64 Flash,页写 256 字节需要约 3ms(含擦除),MRAM 快了 10 倍以上。读速度两者差不多,因为都受 SPI 时钟限制。
5.2 优化技巧:DMA 传输
当需要读写大量数据时,用 DMA 可以解放 CPU。STM32F446ZE 的 SPI1 支持 DMA 请求。
配置 DMA:
hdma_spi1_tx.Instance = DMA2_Stream3; hdma_spi1_tx.Init.Channel = DMA_CHANNEL_3; hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_spi1_tx.Init.Mode = DMA_NORMAL; hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH;用 DMA 后,CPU 可以在数据传输期间处理其他任务,整体效率提升明显。但要注意,DMA 传输完成前不能拉高 CS,否则数据会截断。
5.3 电源管理与低功耗
工业设备有时需要低功耗模式。MR25H40CDF 待机电流约 10uA,写入电流约 5mA,读电流约 3mA。STM32F446ZE 在 STOP 模式下电流约 100uA。
低功耗策略:
- 不访问 MRAM 时,把 SPI 外设关闭,CS 保持高电平。
- 数据采集间隔较长时,STM32 进入 STOP 模式,用 RTC 定时唤醒。
- 唤醒后先初始化 SPI,再访问 MRAM。
实测下来,整体平均电流可以降到 1mA 以下,对于电池供电的设备很关键。
6. 常见问题与排查技巧
6.1 读写失败排查表
| 现象 | 可能原因 | 排查方法 | 解决方法 |
|---|---|---|---|
| 读设备ID返回全0或全FF | SPI接线错误 | 检查MISO/MOSI是否接反 | 交换MISO和MOSI |
| 写入后读出来不对 | 写使能失败 | 读状态寄存器WEL位 | 重新发WREN,检查CS时序 |
| 偶尔读写错误 | 时钟太快 | 降低SPI时钟测试 | 降频或加延时 |
| 高温下数据丢失 | 电源不稳 | 测VCC纹波 | 加去耦电容,检查电源 |
| 多从机冲突 | 片选逻辑错误 | 逻辑分析仪看CS信号 | 确保同一时间只有一个CS低 |
6.2 一个典型的时序问题
有一次调试,发现写入 256 字节后,前几个字节正确,后面的数据错乱。用逻辑分析仪抓波形,发现 CS 在数据传输过程中有毛刺。
原因是软件片选用的是 GPIO,但在中断里也操作了同一个 GPIO 端口,导致 CS 被意外拉高。解决方法是在操作 CS 时关中断,或者用独立的 GPIO 端口。
void MRAM_Select(void) { __disable_irq(); MRAM_CS_LOW(); __enable_irq(); }注意:关中断时间要尽量短,只包住 CS 操作,不要包住整个数据传输。
6.3 数据保持与校验
虽然 MRAM 数据保持时间很长,但在工业环境里,电磁干扰、电源波动都可能影响数据。我一般会做以下校验:
- 每个数据块加 CRC16 或 CRC32 校验。
- 关键配置存两份,读取时比较。
- 定期巡检,发现校验失败就用备份恢复。
CRC 计算可以用 STM32 的硬件 CRC 外设,速度快,不占 CPU。
7. 项目落地经验与扩展思路
7.1 从原型到产品的注意事项
原型阶段用杜邦线连接,跑通了就行。但到产品阶段,要考虑的事情多很多:
- PCB 布线:SPI 信号线要短,最好在同一层,避免过孔。SCK 和 MOSI 不要平行走太长,减少串扰。
- 电源滤波:MRAM 的 VCC 引脚旁边必须放 0.1uF 电容,如果板上开关电源噪声大,再加一个磁珠。
- 连接器:如果 MRAM 通过连接器连接,要选可靠的连接器,工业环境振动大,接触不良是常见故障。
- 防护:SPI 信号线可以加 TVS 管,防止静电和浪涌。
7.2 扩展方向:文件系统与数据管理
如果数据量再大一些,可以考虑在 MRAM 上跑一个轻量级文件系统,比如 LittleFS 或 SPIFFS。这样可以用文件的方式管理数据,不用自己写环形缓冲区。
但文件系统有开销,会占用一些存储空间和 CPU 时间。对于小数据量、高频写入的场景,直接操作地址更高效。
另一个扩展方向是加一颗 NOR Flash 做冷数据存储,MRAM 做热数据缓存。热数据频繁更新,冷数据偶尔写入,两者结合,成本和性能都能兼顾。
7.3 我在这个项目里踩过的坑
第一个坑是 CS 时序。一开始没加延时,CS 拉低后立刻发时钟,在 22.5MHz 下偶尔出错。后来加了 1us 延时,问题消失。
第二个坑是写指针更新。日志区用环形缓冲区,写指针存在 MRAM 里。有一次断电后重启,发现写指针指向的位置和实际数据不匹配,导致日志错乱。后来改成写指针双备份,问题解决。
第三个坑是 DMA 传输。用 DMA 写 MRAM 时,DMA 传输完成中断里拉高 CS,但有时候中断响应慢,CS 拉高晚了,导致多写了一个字节。后来改成在 DMA 传输完成回调里拉高 CS,并且加了一个标志位,确保不会重复操作。
这些坑在数据手册里不会写,只有实际做过才会遇到。希望这些经验能帮到正在做类似项目的朋友。