RK3588S + STM32F103C8T6 工业光源控制器·第二篇
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_WriteByte 和 FRAM_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 的驱动实现,核心要点:
- 选型依据:工业场景频繁写入 + 掉电可靠,F-RAM 是 Flash/EEPROM 的最优替代
- WREN 必须先发:每次写操作前必须单独发送写使能指令,是最容易踩的坑
- 地址空间设计:预留间隔、大端序存储16位数据、bitmask压缩bool数组
- 参数校验:从 F-RAM 读出的所有参数都做范围校验,
0xFF防护是关键 - 上电恢复顺序:先恢复所有参数到寄存器,最后才使能输出,防止上电抖动
下一篇将介绍 Modbus RTU over TCP 通信的固件实现,包括完整寄存器地址表、FC03/FC05/FC06 的处理框架,以及触发模式下占空比写入的隔离设计。
如果本文对你有帮助,欢迎点赞收藏,系列持续更新中 🚀
标签:
#STM32#嵌入式硬件#单片机#F-RAM#SPI
更多推荐


所有评论(0)