FM25L16B F-RAM 驱动实现——工业级掉电保存的正确打开方式


系列回顾:

  • 第一篇:RK3588S + STM32F103C8T6 工业光源控制器——硬件架构全解析
  • 本篇(第二篇):FM25L16B F-RAM 驱动——工业级掉电保存的正确打开方式
  • 第三篇:Modbus RTU over TCP 通信——寄存器设计与固件实现(待更新)
  • 第四篇:双模式PWM光源控制——常亮与外部触发模式(待更新)
  • 第五篇:生产测试通过!联调全流程实录与量产经验总结(待更新)

一、为什么工业场景不能用 Flash 保存参数

嵌入式系统里保存参数的方案有三种:片内 Flash、外部 EEPROM、F-RAM(铁电随机存取存储器)。很多工程师在做工业产品的时候,图省事直接用片内 Flash 保存参数,结果踩了一系列的坑。我们先把这三种方案放在一起比较一下:

对比项 片内 Flash 外部 EEPROM(I²C) F-RAM(FM25L16B)
写入次数 约 10,000 次 约 1,000,000 次 无限次(>10¹²)
写入延迟 需要擦除,耗时 ms 级 页写入完成需 5ms 写入无延迟(ns级)
掉电安全 写入中断电数据损坏 写入中断电数据损坏 掉电数据不丢失
读取速度 随机读取快 I²C 速度慢 SPI,9MHz,随机读写
数据保持 > 20年 > 200年 > 10年
接口 内部总线 I²C SPI
成本 免费(片内)

片内 Flash 的核心问题有两个:

第一,写入次数只有约 10,000 次。看起来很多,但工业场景下参数频繁修改(光源亮度调节、触发延时调整),如果每次修改都写 Flash,一台设备一年运行下来很容易超限,超限后写入失败但不报错,参数悄悄丢失,问题极难定位。

第二,Flash 写入前必须先擦除整个扇区(STM32F103 的 Flash 最小擦除单元是 1KB),擦除期间 CPU 无法执行 Flash 中的代码(需要在 RAM 中运行或使用 HAL 的等待机制),写入耗时可达数十毫秒。如果在写入过程中突然断电,扇区数据直接损坏。

F-RAM 的工作原理决定了它没有这些问题。 铁电材料的极化状态对应存储的 0/1,切换极化方向在纳秒内完成,不需要擦除操作,写入完成即持久化,掉电数据立即锁定。这是物理特性带来的本质优势,不是软件能弥补的。

💡 结论:工业产品需要频繁保存、掉电可靠、写入实时的场景,F-RAM 是最佳选择。FM25L16B 容量 2KB,价格不到 ¥5,没有理由不用。


二、FM25L16B 基本特性

FM25L16B 是 Cypress(现已并入 Infineon)出品的 16Kbit(2048 Byte)SPI F-RAM,关键参数:

  • 容量:2048 字节,地址范围 0x000 ~ 0x7FF(11位地址)
  • 接口:SPI,支持 Mode 0(CPOL=0, CPHA=0)和 Mode 3
  • 最高时钟:40MHz(FM25L16B),本项目使用 9MHz(保守设计)
  • 写入次数:无限次
  • 数据保持:10年(工业级)
  • 工作电压:2.7V ~ 3.65V(与 STM32F103 的 3.3V 直接兼容)
  • 封装:SOP-8

引脚定义:

引脚 名称 功能
1 CS# 片选,低有效
2 SCK SPI 时钟
3 SI SPI 数据输入(MOSI)
4 SO SPI 数据输出(MISO)
5 WP# 写保护,本项目接 VCC 不使用
6 HOLD# 暂停通信,本项目接 VCC 不使用
7 VDD 电源 3.3V
8 VSS GND

与 STM32F103C8T6 的连接:

STM32F103C8T6          FM25L16B
PA4  (CS#)    ───────  CS#  (pin1)
PA5  (SCK)    ───────  SCK  (pin2)
PA7  (MOSI)   ───────  SI   (pin3)
PA6  (MISO)   ───────  SO   (pin4)
3.3V          ───────  WP#  (pin5)  不使用写保护
3.3V          ───────  HOLD#(pin6)  不暂停通信
3.3V          ───────  VDD  (pin7)
GND           ───────  VSS  (pin8)

三、SPI 指令集

FM25L16B 的操作指令只有 6 条,简洁明了:

指令 代码 功能
WREN 0x06 写使能锁存(每次写操作前必须发送)
WRDI 0x04 写禁止
RDSR 0x05 读状态寄存器
WRSR 0x01 写状态寄存器
READ 0x03 读数据(指令 + 2字节地址 + 读数据)
WRITE 0x02 写数据(WREN → 指令 + 2字节地址 + 写数据)

WREN 指令的必要性:

FM25L16B 上电后处于写禁止状态,每次执行 WRITE 操作之前,必须先单独发送一次 WREN 指令。WREN 是一个独立的 SPI 事务(CS# 拉低 → 发 0x06 → CS# 拉高),然后紧接着才能执行写操作。

很多工程师第一次使用 F-RAM 时会疑惑:为什么写进去读出来是 0xFF?九成是忘了发 WREN。


四、驱动代码详解

4.1 SPI1 初始化(FRAM_Init)

void FRAM_Init(void)
{
    GPIO_InitTypeDef  GPIO_InitStruct;
    SPI_InitTypeDef   SPI_InitStruct;

    /* 时钟使能:SPI1 挂载 APB2,GPIO 也在 APB2 */
    RCC_APB2PeriphClockCmd(FRAM_SPI_CLK | FRAM_GPIO_CLK, ENABLE);

    /* CS — 普通推挽输出,默认高电平(禁止片选) */
    GPIO_InitStruct.GPIO_Pin   = FRAM_CS_PIN;        /* PA4 */
    GPIO_InitStruct.GPIO_Mode  = GPIO_Mode_Out_PP;
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(FRAM_GPIO_PORT, &GPIO_InitStruct);
    FRAM_CS_HIGH();                                  /* 初始化完立即拉高 */

    /* SCK / MOSI — 复用推挽(SPI 硬件控制) */
    GPIO_InitStruct.GPIO_Pin  = FRAM_SCK_PIN | FRAM_MOSI_PIN;  /* PA5, PA7 */
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_Init(FRAM_GPIO_PORT, &GPIO_InitStruct);

    /* MISO — 浮空输入(接收 F-RAM 数据) */
    GPIO_InitStruct.GPIO_Pin  = FRAM_MISO_PIN;       /* PA6 */
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(FRAM_GPIO_PORT, &GPIO_InitStruct);

    /* SPI1 配置 */
    SPI_InitStruct.SPI_Direction         = SPI_Direction_2Lines_FullDuplex;
    SPI_InitStruct.SPI_Mode              = SPI_Mode_Master;
    SPI_InitStruct.SPI_DataSize          = SPI_DataSize_8b;
    SPI_InitStruct.SPI_CPOL              = SPI_CPOL_Low;   /* Mode 0 */
    SPI_InitStruct.SPI_CPHA              = SPI_CPHA_1Edge; /* Mode 0 */
    SPI_InitStruct.SPI_NSS               = SPI_NSS_Soft;   /* 软件控制 CS */
    SPI_InitStruct.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; /* 72M/8=9MHz */
    SPI_InitStruct.SPI_FirstBit          = SPI_FirstBit_MSB;
    SPI_InitStruct.SPI_CRCPolynomial     = 7;
    SPI_Init(FRAM_SPI, &SPI_InitStruct);
    SPI_Cmd(FRAM_SPI, ENABLE);
}

几个关键点:

① CS 为什么用软件控制(SPI_NSS_Soft)? STM32 的 SPI 硬件 NSS 管理在多从机场景下不灵活,且硬件 NSS 的时序不一定满足 F-RAM 的要求。使用软件 GPIO 控制 CS,时序完全可控,是 SPI 外设驱动的标准做法。

② 波特率预分频为什么选 8(9MHz)? FM25L16B 最高支持 40MHz,但本项目保守选择 72MHz/8=9MHz。原因是 PCB 走线较长,9MHz 在不加终端匹配的情况下信号完整性更有保证,实测无误码。

③ CPOL=0,CPHA=0 对应 SPI Mode 0,是 FM25L16B 支持的两种模式之一(另一种是 Mode 3),Mode 0 是最常用的配置。


4.2 单字节收发(SPI_TransferByte)

static uint8_t SPI_TransferByte(uint8_t byte)
{
    /* 等待发送缓冲区为空 */
    while (SPI_I2S_GetFlagStatus(FRAM_SPI, SPI_I2S_FLAG_TXE) == RESET) {}
    SPI_I2S_SendData(FRAM_SPI, byte);
    /* 等待接收缓冲区非空(全双工,发完即收到) */
    while (SPI_I2S_GetFlagStatus(FRAM_SPI, SPI_I2S_FLAG_RXNE) == RESET) {}
    return (uint8_t)SPI_I2S_ReceiveData(FRAM_SPI);
}

SPI 是全双工协议,发送一个字节的同时接收一个字节。这个函数是整个驱动的核心原语,所有读写操作都基于它构建。

⚠️ 注意:必须先等 TXE(发送缓冲空),再发数据;发完后等 RXNE(接收缓冲非空),再读数据。顺序不能颠倒,否则读到的是上一次的残留数据。


4.3 写使能(FRAM_WriteEnable)

static void FRAM_WriteEnable(void)
{
    FRAM_CS_LOW();
    SPI_TransferByte(FRAM_CMD_WREN);  /* 0x06 */
    FRAM_CS_HIGH();
    /* CS# 必须拉高后,写使能才生效 */
}

WREN 是一个独立的 SPI 事务,CS# 上升沿时写使能锁存器被置位,之后紧接着的 WRITE 操作才有效。这个函数在 FRAM_WriteByteFRAM_WriteBuffer 中每次调用前都会先执行。


4.4 读写接口

读单字节:

uint8_t FRAM_ReadByte(uint16_t addr)
{
    uint8_t data;
    FRAM_CS_LOW();
    SPI_TransferByte(FRAM_CMD_READ);        /* 指令:0x03 */
    SPI_TransferByte((uint8_t)(addr >> 8)); /* 地址高字节 */
    SPI_TransferByte((uint8_t)(addr));      /* 地址低字节 */
    data = SPI_TransferByte(0xFF);          /* 发 0xFF 驱动时钟,接收数据 */
    FRAM_CS_HIGH();
    return data;
}

写单字节:

void FRAM_WriteByte(uint16_t addr, uint8_t data)
{
    FRAM_WriteEnable();              /* 每次写之前必须先写使能 */
    FRAM_CS_LOW();
    SPI_TransferByte(FRAM_CMD_WRITE); /* 指令:0x02 */
    SPI_TransferByte((uint8_t)(addr >> 8));
    SPI_TransferByte((uint8_t)(addr));
    SPI_TransferByte(data);
    FRAM_CS_HIGH();
}

连续读(FRAM_ReadBuffer):

void FRAM_ReadBuffer(uint16_t addr, uint8_t *buf, uint16_t len)
{
    FRAM_CS_LOW();
    SPI_TransferByte(FRAM_CMD_READ);
    SPI_TransferByte((uint8_t)(addr >> 8));
    SPI_TransferByte((uint8_t)(addr));
    while (len--) {
        *buf++ = SPI_TransferByte(0xFF);
    }
    FRAM_CS_HIGH();
    /* CS# 保持低电平期间,地址自动递增,一次 CS 事务连续读多个字节 */
}

连续写(FRAM_WriteBuffer):

void FRAM_WriteBuffer(uint16_t addr, const uint8_t *buf, uint16_t len)
{
    FRAM_WriteEnable();              /* 连续写只需一次 WREN */
    FRAM_CS_LOW();
    SPI_TransferByte(FRAM_CMD_WRITE);
    SPI_TransferByte((uint8_t)(addr >> 8));
    SPI_TransferByte((uint8_t)(addr));
    while (len--) {
        SPI_TransferByte(*buf++);    /* 地址自动递增 */
    }
    FRAM_CS_HIGH();
}

💡 F-RAM 连续写的优势:与 EEPROM 的页写入不同,F-RAM 连续写不需要考虑页边界,一次 WREN + 一次 CS 事务可以连续写入任意长度的数据,地址自动递增,效率极高。


五、地址空间映射设计

FM25L16B 共 2048 字节(0x0000 ~ 0x07FF),本项目的地址分配如下:

地址 大小 宏定义 存储内容
0x0000 1 字节 FRAM_ADDR_SLAVE_ADDR Modbus 从机地址(1~247)
0x0001 1 字节 FRAM_ADDR_BAUDRATE 波特率索引(预留)
0x0002 1 字节 FRAM_ADDR_PWM_MODE PWM 工作模式(0=常亮,1=触发)
0x0003 ~ 0x0006 4 字节 FRAM_ADDR_PWM_DUTY CH1~CH4 占空比(各 1 字节,0~255)
0x0007 1 字节 FRAM_ADDR_DOUT_STATE OUT1~OUT4 输出状态(bit0~bit3)
0x0010 ~ 0x0017 8 字节 FRAM_ADDR_TG_OFF_MS TG1~TG4 关断时间(各 2 字节,大端)
0x0018 ~ 0x001F 8 字节 FRAM_ADDR_TG_ON_DELAY_MS TG1~TG4 开启延时(各 2 字节,大端)
0x0020 1 字节 FRAM_ADDR_PWM_CH_ENABLE PWM 通道开关状态(bit0~bit3)
0x0021 ~ 0x07FF 剩余 用户扩展区(空闲)

地址设计的几个考量:

0x0008 ~ 0x000F 预留了 8 字节的空白间隔,这是有意为之的——未来增加参数时不用担心地址冲突,直接往后追加。

TG_OFF_MS 和 TG_ON_DELAY_MS 使用 大端序(Big-Endian) 存储 uint16_t,高字节在前:

/* 保存:高字节先写 */
FRAM_WriteByte(FRAM_ADDR_TG_OFF_MS + i*2,     (uint8_t)(g_tg_off_ms[i] >> 8));
FRAM_WriteByte(FRAM_ADDR_TG_OFF_MS + i*2 + 1, (uint8_t)(g_tg_off_ms[i] & 0xFF));

/* 恢复:高字节先读,移位拼合 */
g_tg_off_ms[i] = ((uint16_t)FRAM_ReadByte(FRAM_ADDR_TG_OFF_MS + i*2) << 8)
               |  (uint16_t)FRAM_ReadByte(FRAM_ADDR_TG_OFF_MS + i*2 + 1);

六、参数保存与恢复(核心逻辑)

6.1 FRAM_SaveParams

void FRAM_SaveParams(void)
{
    uint8_t i;

    /* 基础参数 */
    FRAM_WriteByte(FRAM_ADDR_SLAVE_ADDR, g_modbus_addr);
    FRAM_WriteByte(FRAM_ADDR_PWM_MODE,   g_pwm_mode);
    FRAM_WriteBuffer(FRAM_ADDR_PWM_DUTY, g_pwm_duty, 4);
    FRAM_WriteByte(FRAM_ADDR_DOUT_STATE, (uint8_t)g_dout_state);

    /* 触发参数:关断时间 + 开启延时 */
    for (i = 0; i < 4U; i++) {
        FRAM_WriteByte(FRAM_ADDR_TG_OFF_MS + i*2U,
                       (uint8_t)(g_tg_off_ms[i] >> 8));
        FRAM_WriteByte(FRAM_ADDR_TG_OFF_MS + i*2U + 1U,
                       (uint8_t)(g_tg_off_ms[i] & 0xFF));
    }
    for (i = 0; i < 4U; i++) {
        FRAM_WriteByte(FRAM_ADDR_TG_ON_DELAY_MS + i*2U,
                       (uint8_t)(g_tg_on_delay_ms[i] >> 8));
        FRAM_WriteByte(FRAM_ADDR_TG_ON_DELAY_MS + i*2U + 1U,
                       (uint8_t)(g_tg_on_delay_ms[i] & 0xFF));
    }

    /* PWM 通道开关:4个bool压缩为1字节bitmask */
    {
        uint8_t ch_mask = 0;
        for (i = 0; i < 4U; i++) {
            if (g_pwm_ch_enable[i]) ch_mask |= (uint8_t)(1U << i);
        }
        FRAM_WriteByte(FRAM_ADDR_PWM_CH_ENABLE, ch_mask);
    }
}

SaveParams 的触发时机:本项目通过 Modbus 寄存器 SAVE_PARAMS(写 1 触发)来主动保存,而不是每次参数修改都自动保存。这是有意的设计——避免上位机频繁调参时产生大量写操作(虽然 F-RAM 理论上无限次写入,但减少不必要的写入仍是好习惯)。


6.2 FRAM_LoadParams(关键细节)

void FRAM_LoadParams(void)
{
    uint8_t addr;
    uint8_t i;

    /* Modbus 从机地址:有效范围 1~247,超出则用默认值 */
    addr          = FRAM_ReadByte(FRAM_ADDR_SLAVE_ADDR);
    g_modbus_addr = (addr >= 1 && addr <= 247) ? addr : MODBUS_SLAVE_ADDR;

    /* PWM 工作模式:只取 bit0,防止读到非法值 */
    g_pwm_mode = FRAM_ReadByte(FRAM_ADDR_PWM_MODE) & 0x01U;

    /* 占空比:直接读取4字节 */
    FRAM_ReadBuffer(FRAM_ADDR_PWM_DUTY, g_pwm_duty, 4);

    /* 数字输出状态:只取低4位(OUT1~OUT4) */
    g_dout_state = FRAM_ReadByte(FRAM_ADDR_DOUT_STATE) & 0x0FU;

    /* 触发参数恢复... */

    /* PWM 通道开关:bitmask 解包 */
    {
        uint8_t ch_mask = FRAM_ReadByte(FRAM_ADDR_PWM_CH_ENABLE);

        /* ★ 关键防护:全FF说明是新片或地址未初始化,默认全关 */
        if (ch_mask == 0xFFU) ch_mask = 0x00U;

        for (i = 0; i < 4U; i++) {
            g_pwm_ch_enable[i] = (ch_mask >> i) & 0x01U;
        }
    }
}

ch_mask == 0xFF 防护的意义:

F-RAM 出厂默认所有存储单元为 0xFF。如果是全新的 F-RAM 芯片(或者芯片被意外擦写),读出来的 ch_mask 就是 0xFF,如果不加判断直接解包,4个通道的 enable 全为 1,上电就全部输出 PWM——对光源和被测物来说是意外输出,可能造成损坏。

加了 if (ch_mask == 0xFFU) ch_mask = 0x00U 这一行,遇到未初始化状态时默认所有通道关闭,上电安全。

类似的防护逻辑应该对所有从 F-RAM 读取的参数做:

/* g_pwm_mode 只取 bit0 */
g_pwm_mode = FRAM_ReadByte(FRAM_ADDR_PWM_MODE) & 0x01U;

/* g_modbus_addr 范围校验 */
g_modbus_addr = (addr >= 1 && addr <= 247) ? addr : MODBUS_SLAVE_ADDR;

/* g_dout_state 只取低4位 */
g_dout_state = FRAM_ReadByte(FRAM_ADDR_DOUT_STATE) & 0x0FU;

⚠️ 工程经验:从任何非易失存储读取的参数,都应该做范围校验或掩码处理,不能盲目信任读出的原始值。新片、老化失效、意外写入,都可能导致读出非法值。


七、上电恢复完整流程

结合 main.c 的初始化顺序,参数恢复的完整流程如下:

上电
  │
  ├─ FRAM_Init()          → SPI1 初始化,CS# 默认高
  │
  ├─ FRAM_LoadParams()    → 从 F-RAM 读取所有参数到全局变量
  │     ├─ g_modbus_addr  (范围校验)
  │     ├─ g_pwm_mode     (bit0 掩码)
  │     ├─ g_pwm_duty[4]  (直接读取)
  │     ├─ g_dout_state   (低4位掩码)
  │     ├─ g_tg_off_ms[4]       (大端16bit 重建)
  │     ├─ g_tg_on_delay_ms[4]  (大端16bit 重建)
  │     └─ g_pwm_ch_enable[4]   (bitmask解包 + 0xFF防护)
  │
  ├─ PWM_Init()           → TIM1 初始化,OE# 默认低(禁止输出)
  │
  ├─ for i in 0..3:       → 恢复占空比到 TIM1 CCR 寄存器
  │     PWM_SetDuty(i+1, g_pwm_duty[i])
  │
  ├─ for i in 0..3:       → 恢复通道开关状态
  │     PWM_SetChEnable(i+1, g_pwm_ch_enable[i])
  │
  ├─ GPIO_IO_Init()       → 数字 I/O + EXTI 初始化
  │
  ├─ 恢复 OUT1~OUT4 输出状态(根据 g_dout_state)
  │
  └─ PWM_Enable()         → 最后才拉高 OE#,开始输出
        ↑
        此时 CCR 已经设置好,OE# 使能后输出即稳定
        不会出现上电瞬间的意外跳变

这个顺序的核心原则是:先把所有寄存器准备好,最后才开启输出。对于光源控制应用,上电瞬间的意外输出可能影响相机第一帧图像,这个顺序设计保证了输出的可预期性。


八、Modbus 触发保存的设计

本项目通过 Modbus 寄存器主动触发保存,而不是参数变化时自动保存:

/* modbus.c — 处理写保持寄存器(FC06) */
case REG_SAVE_PARAMS:           /* 地址 0x001A */
    if (value == 1) {
        FRAM_SaveParams();      /* 触发保存 */
    }
    break;

上位机(或 RK3588S 的 Linux 应用)调整完所有参数后,最后发一条 FC06 写 REG_SAVE_PARAMS = 1,触发一次性保存。这样的设计优点是:

  • 调参过程中的中间状态不写入 F-RAM,避免反复写入
  • 上位机可以批量修改参数后统一保存,操作逻辑清晰
  • F-RAM 的写入时机完全受上位机控制,便于调试

总结

本篇完整介绍了 FM25L16B F-RAM 的驱动实现,核心要点:

  1. 选型依据:工业场景频繁写入 + 掉电可靠,F-RAM 是 Flash/EEPROM 的最优替代
  2. WREN 必须先发:每次写操作前必须单独发送写使能指令,是最容易踩的坑
  3. 地址空间设计:预留间隔、大端序存储16位数据、bitmask压缩bool数组
  4. 参数校验:从 F-RAM 读出的所有参数都做范围校验,0xFF 防护是关键
  5. 上电恢复顺序:先恢复所有参数到寄存器,最后才使能输出,防止上电抖动

下一篇将介绍 Modbus RTU over TCP 通信的固件实现,包括完整寄存器地址表、FC03/FC05/FC06 的处理框架,以及触发模式下占空比写入的隔离设计。


如果本文对你有帮助,欢迎点赞收藏,系列持续更新中 🚀

标签#STM32 #嵌入式硬件 #单片机 #F-RAM #SPI


Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐