
简介面向STM32F103开发者的铁电存储器驱动代码包基于SPI接口实现FM25CL64的读写操作代码精简且支持直接读写单个字节。代码中提供SPI_Flash_Init初始化函数涵盖GPIO时钟使能、引脚复用配置以及SPI外设参数设置读写函数则封装了片选控制、地址发送与数据接收流程方便开发者直接调用。包内共2个文件分别以C源文件和头文件组织完整覆盖底层IO配置与SPI通信封装整体仅2KB大小适合作为轻量级参考模板压缩包结构简单便于快速导入工程。已有1712人学习下载。通过研读代码可熟悉SPI外设配置、FM25CL64读写时序以及铁电存储器同其他存储芯片的差异对理解嵌入式存储驱动编写和SPI总线应用具有实际帮助也能为后续扩展其他FM25系列器件提供参考同时节省查阅数据手册和调试底层通信的时间。1. 铁电存储器 FM25CL64一颗掉电不丢数据的 SRAMSPI 驱动该这么写做单片机开发的人多半被 Flash 或 E2PROM 的擦写机制坑过写一页要等几毫秒扇区擦除要等更久意外断电还会丢数据。FM25CL64 属于铁电存储器它的写入不需要先擦除也没有单独擦除指令写一个字节和读一个字节在时序上几乎一样快。这就是它最有价值的反直觉点你以为它和 W25Q64 一样要等 tPP实际上它把写入做的和读一样直接改写目标地址的内容。驱动代码这种资源拿到手先看三件事SPI 初始化是不是对的、片选时序有没有踩毛刺、写使能指令有没有漏发——把这三件事确认完这颗芯片基本不会给你找麻烦。2. FM25CL64 的技术边界为什么它适合做参数掉电保存2.1 铁电存储器和 Flash / E2PROM 的本质区别FM25CL64 的存储介质是铁电晶体它靠电容极化方向来保存数据而不是像 Flash 那样靠电荷俘获。这意味着它没有“块擦除”这种操作对任意地址直接写就行写入速度和读取速度一样快。这点在开发中体现出的实际差异是你用 Flash 保存系统参数必须先查状态寄存器确认上次擦写完成再页写入再等忙标志而 FM25CL64 只需要把写使能指令发出去然后直接写目标字节一个 SPI 传输周期内完成。寿命方面FM25CL64 的页读耐久性是 1e12 次字节写耐久性 1e10 次。对比 AT24C02 的 100 万次擦写这个数字大了三个数量级左右。如果你做的是频繁记录运行数据的设备比如每次开关机都更新计数、掉电前存最后状态铁电的成本虽然比 E2PROM 高一点但省下来的时间、代码和排错成本其实更划算。数据保持时间大约 10 年量级作为参数存储够用。还需要注意容量FM25CL64 是 64Kbit也就是 8K 字节地址范围 0x0000 到 0x1FFF。开发时如果存日志8K 很容易打满所以它适合存参数、校准值、运行状态不适合当日志存储池用。2.2 FM25CL64 和 W25Q64、AT24C02 的选型对比这里列一个我实际选型常用的对比表帮助大家看清这颗芯片在存储方案里占什么位置。对比项FM25CL64W25Q64AT24C02容量8K 字节8M 字节256 字节写入方式直接改写无擦除先擦除后写支持页写无擦除写字节耗时一个 SPI 帧时间页编程约 0.7~3ms写周期约 5ms擦除耗时无扇区擦除 150ms 量级无写耐久1e10 次1e5 次1e6 次掉电保持约 10 年约 20 年约 100 年主要用途频繁改写参数固件、日志、文件系统配置参数从这张表能直接看出选型逻辑如果你只是偶尔烧一次校准参数AT24C02 够用如果要存固件升级文件必须用 W25Q64 这种大容量 Flash如果每次开机都要更新运行时间、计件数、历史故障别犹豫可以直接上铁电。用铁电存储频繁改写数据的另一个好处是你不用做磨损均衡——以 1e10 次写入来看每秒写一次也能连续写 300 多年磨损均衡属于无用功。3. 硬件连接与 SPI 初始化把引脚和寄存器先理清楚3.1 FM25CL64 引脚功能与最小硬件连接FM25CL64 是 SOP-8 封装引脚不算多但有两个脚容易被忽略HOLD 和 WP。HOLD 脚置低时暂停通信不影响片选逻辑WP 脚置低时写保护生效此时写使能 WREN 指令也无法完成写操作。我见过不少新手只接四根线SO、SI、SCK、CS把 HOLD 和 WP 悬空结果芯片内部上拉不强出现偶发写失败。最常见的做法是 HOLD 接 VCC、WP 也接 VCC把功能关掉只留最基本的读写。CS 片选脚要接单片机的 GPIO用软件控制。因为 FM25CL64 支持多个 SPI 从设备挂在同一总线上CS 引脚不能接固定电平。SCK、SI、SO 分别接 STM32F103 的 SPI 时钟、主出从入、主入从出。5V 和 3.3V 的兼容性方面FM25CL64 的工作电压范围比较宽可以直接由 STM32F103 的 3.3V 供电SI、SO 信号线在 3.3V 电平下正常通信。还值得注意的一点是 SPI 的模式。FM25CL64 支持模式 0CPOL0、CPHA0和模式 3CPOL1、CPHA1两种模式数据都在 SCK 上升沿采样。和它搭配的 STM32 SPI 外设只要把 CPOL/CPHA 配成这两个组合之一就没问题。我的习惯是优先用模式 0因为和大部分 SPI 设备的默认时序一致逻辑分析仪抓波形时更容易看懂。3.2 STM32F103 SPI1 引脚初始化示例下面这段代码在标准库和 CubeMX 生成的工程里都能用这里按 HAL 库风格写因为 CubeMX 生成的代码比较常规。核心是把 SPI1 的引脚复用、时钟、模式、速率设置对。void FM25CL64_SPI_Init(void) { // 使能 SPI1 和 GPIOA 时钟 __HAL_RCC_SPI1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef GPIO_InitStruct {0}; // SCK: PA5 复用推挽输出; MOSI: PA7; MISO: PA6 GPIO_InitStruct.Pin GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_AF_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); // CS: PA4 普通推挽输出默认拉高 GPIO_InitStruct.Pin GPIO_PIN_4; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // SPI1 配置模式08位数据主模式 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; HAL_SPI_Init(hspi1); }这段初始化代码有几个参数值得单独说明。SPI_BAUDRATEPRESCALER_8在 72MHz 主频下得到 9MHz 的 SPI 时钟FM25CL64 支持 25MHz 时钟频率理论上没问题但实际项目里我会把分频系数调到 16 甚至 32也就是 4.5MHz 或 2.25MHz。原因很简单铁电芯片不像 Flash 那样要等内部编程时间它的瓶颈在 PCB 走线和信号完整性降速换稳定性非常划算特别是在飞线连接的情况下。NSS配置成软模式是因为 CS 由 GPIO 手动控制方便做连续读写的时序控制。FirstBit一定要配成 MSB 先发这是 SPI 协议的基本要求FM25CL64 也是高位先传。4. 驱动核心读、写、状态寄存器与连续传输4.1 指令集与状态寄存器的操作码FM25CL64 的指令集不算复杂核心指令只要记住六条。WREN0x06是写使能任何写操作之前都必须先发这条指令否则芯片直接忽略写请求。WRDI0x04是写禁止执行后所有写操作被拒绝除非重新 WREN。RDSR0x05读状态寄存器WRSR0x01写状态寄存器。READ0x03读存储阵列WRITE0x02写存储阵列。状态寄存器各位的定义也和普通 E2PROM 很不一样。最低位是 WEL写使能锁存位执行 WREN 后置 1完成一次写操作后自动清 0。Bit 2 和 Bit 3 是 BP1 和 BP0块保护位把这两个位置 1 可以保护存储阵列的上半部分或全部上电默认 0也就是不保护。Bit 7 是 WPEN写保护使能位只有当 WP 引脚拉低且 WPEN 为 1 时BP 保护区域才无法被 WRSR 修改。这个状态寄存器里完全没有“忙标志位”——这也是前面说的铁电写入不需要等待时间指令发完就写完了。4.2 基础读写函数代码与参数说明先写底层字节交换函数再包装成存储器的读写 API这样代码结构清晰以后换芯片也方便。SPI 收发我用 HAL_SPI_TransmitReceive全双工模式下同时发指令并回收数据。// 底层写一个字节并读回一个字节 uint8_t FM25CL64_SPI_SwapByte(uint8_t tx_byte) { uint8_t rx_byte 0x00; HAL_SPI_TransmitReceive(hspi1, tx_byte, rx_byte, 1, 100); return rx_byte; } // 写使能WREN 指令执行后 WEL 位置 1 void FM25CL64_WriteEnable(void) { FM25CL64_CS_LOW(); FM25CL64_SPI_SwapByte(0x06); FM25CL64_CS_HIGH(); } // 写禁止WRDI 指令执行后 WEL 位清 0 void FM25CL64_WriteDisable(void) { FM25CL64_CS_LOW(); FM25CL64_SPI_SwapByte(0x04); FM25CL64_CS_HIGH(); } // 读状态寄存器返回 8 位状态值 uint8_t FM25CL64_ReadStatus(void) { uint8_t status 0x00; FM25CL64_CS_LOW(); FM25CL64_SPI_SwapByte(0x05); status FM25CL64_SPI_SwapByte(0x00); FM25CL64_CS_HIGH(); return status; }这里有几个芯片级细节容易写错。CS 拉低之后芯片才开始响应指令所以每次操作都必须从 CS 拉低开始、CS 拉高结束。CS 拉高之后芯片回到待机状态这也是为什么每次写操作之前都要重新执行 WREN。很多人会想既然写使能位在上电后是 0那我干脆每次都发 WREN 再写不用判断状态寄存器。这个思路是对的但注意 WREN 必须紧跟在 CS 拉低之后发不能在 CS 拉低之前发。4.3 存储阵列读写与地址格式注意事项FM25CL64 的地址是 9 位因为容量 8K需要区分 A0 到 A8。但 SPI 指令通常按字节传输所以地址分两个字节发第一个字节是高 8 位地址第二个字节是低 8 位不对。仔细看数据手册会注意到指令后跟两个地址字节第一个字节的低 7 位是 A6-A0这里有个常见的翻车点。正确格式是READ 指令后先发地址高字节其中最低位是 A8高 7 位填充 0再发低字节包含 A7-A0。也就是说9 位地址被拆成“1 位 8 位”放进两个字节里。写代码时一定要先算好地址位移否则从 255 字节边界往后全乱。下面给出带容错处理的读写实现。// 从指定地址读 len 字节到 buf void FM25CL64_ReadBuffer(uint16_t addr, uint8_t *buf, uint16_t len) { uint16_t i; FM25CL64_CS_LOW(); FM25CL64_SPI_SwapByte(0x03); // 地址高字节bit0 是 A8高 7 位为 0 FM25CL64_SPI_SwapByte((uint8_t)((addr 8) 0x01)); // 地址低字节A7-A0 FM25CL64_SPI_SwapByte((uint8_t)(addr 0xFF)); for (i 0; i len; i) { buf[i] FM25CL64_SPI_SwapByte(0x00); } FM25CL64_CS_HIGH(); } // 从指定地址写 len 字节 void FM25CL64_WriteBuffer(uint16_t addr, uint8_t *buf, uint16_t len) { uint16_t i; FM25CL64_WriteEnable(); FM25CL64_CS_LOW(); FM25CL64_SPI_SwapByte(0x02); FM25CL64_SPI_SwapByte((uint8_t)((addr 8) 0x01)); FM25CL64_SPI_SwapByte((uint8_t)(addr 0xFF)); for (i 0; i len; i) { FM25CL64_SPI_SwapByte(buf[i]); } FM25CL64_CS_HIGH(); }写缓冲函数的地址上限需要注意FM25CL64 内部没有页面边界的概念跨 256 是可行的只要两个地址都在 8K 范围内。这和 W25Q64 完全不同W25Q64 写入跨越页边界时会自动回卷导致数据错位。铁电虽然允许连续写但跨 0x1FFF 边界时会回卷到 0x0000所以调用层应该自行为上层应用做 8K 边界检查。4.4 写状态寄存器配置块保护有些项目需要把存储区前半部分设成只读防止参数被意外改写。FM25CL64 的 WRSR 指令加上块保护位可以做到把 BP0、BP1 置位后对应的地址区间就不能写入写操作会被芯片忽略。// 设置块保护保护全部 8K 空间 void FM25CL64_EnableFullBlockProtection(void) { FM25CL64_WriteEnable(); FM25CL64_CS_LOW(); FM25CL64_SPI_SwapByte(0x01); FM25CL64_SPI_SwapByte(0x0C); // BP11, BP01 FM25CL64_CS_HIGH(); }参数 0x0C 对应二进制 0000_1100也就是把状态寄存器的 Bit 2 和 Bit 3 置 1。执行完这条指令后再对任何地址发 WRITE 都不会有效果。注意一个细节WRSR 写完后 WEL 位会自动清 0如果要解除保护得先发 WREN 再写 0x00 到状态寄存器。5. 避坑实测五个真实案例从片选毛刺到地址错位5.1 读回全是 0xFF 或 0x00现象写进去的数据再读出来变成 0xFF 或全 0查看缓冲区全是同一字节。原因分析读回全 FF先怀疑引脚模式。MOSI、MISO、SCK 任意一根引脚配置成开漏或者没有复用成外设功能信号就会不正常。另一个常见原因是 SPI 模式不匹配CPOL 和 CPHA 与芯片期望相反数据在错误的时钟沿采样。读回全 0 则多半是 MISO 引脚一直输出低电平CS 没有真正选中芯片或者芯片进入写保护后读到的数据本身保持初始值。解决思路先做引脚回环测试MOSI 和 MISO 短接发 0xA5 接收 0xA5能通过说明 SPI 外设没问题。再检查 CS 波形用示波器或逻辑分析仪确认 CS 拉低时间覆盖整个指令和地址发送周期。最后用 FM25CL64_ReadStatus 读状态寄存器确认通信链路是真的通了。5.2 写操作无效果状态寄存器 WEL 位始终不置 1现象调用写函数后读回数据不变RDSR 读到的状态值 Bit0 一直是 0。原因分析WEL 位没有置 1说明 WREN 指令没有生效。通常是时序问题CS 拉低后 SCK 第一个沿开始采样之前WREN 指令字节必须已经在 SI 上稳定输出。如果代码用了 SPI_DATASIZE_16BIT 或者发送时混入了多余字节芯片就解不出 0x06。也可以是 WP 引脚被拉低硬件上禁用了写使能。解决思路检查 SCK 和 SI 的时序用逻辑分析仪抓 CS 拉低后的第一个字节。软件上把 FM25CL64_WriteEnable 单独抽出来写完立刻 RDSR 读状态确认 WEL 为 1 再继续写。硬件上确认 WP 引脚接 VCC 而不是悬空或接地。5.3 地址写入和读出的内容错位现象写入地址 0x0100 的数据从 0x0100 读不出来却在 0x0000 读到了。原因分析这就是前面强调的 9 位地址拆分问题。如果把地址高字节直接传 (addr 8)那 0x0100 会得到 0x01这个字节显然超出了 A8 的单比特范围。芯片会把多余的位当成指令扩展或者直接忽略地址就被截断了。还有另一种写法用 8 位地址模式调用只发一个地址字节结果超过 256 字节的地址全部回卷。解决思路统一封装一个 SendAddress 函数输入 uint16_t 地址内部强制拆成两个字节发送。写完地址后用 0x00 填充多读几个字节做自检对比回读数据和写入数据。如果你是从其他 2K/4K 容量的铁电芯片移植过来的代码尤其要注意地址拆分逻辑因为容量不同拆分方式往往也不同。5.4 CS 控制毛刺导致芯片误操作现象正常读写工作但偶发性出现数据写乱逻辑分析仪上看到 CS 波形有多个短脉冲。原因分析GPIO 速度配置太高加上 PCB 走线过长CS 线上出现振铃。单片机引脚输出驱动能力过强时跳变瞬间产生的过冲可能触发芯片的内部逻辑。另一个典型场景是 DMA 传数据期间CS 在传输结束前被提前拉高最后一次收发还没完成就结束了片选周期。解决思路软件上把 CS 拉低和拉高之间留出至少一个 SPI 字节的时间不连续操作时让 CS 保持高电平久了再操作。硬件上在 CS 引脚串一个 33Ω 电阻或者并一个 10pF 电容抑制边沿振铃。如果用了 SPI DMA检查传输完成回调里再操作 CS不要用定时器延时猜时间。5.5 跨 8K 地址回卷导致数据覆盖现象写入地址 0x1FFF 末尾的数据在地址 0x0000 也出现了同样的内容。原因分析FM25CL64 的地址计数器在 0x1FFF 之后再递增会回卷到 0x0000。这在技术上是被芯片允许的行为但如果你在各层之间没做边界检查日志记录或者参数批量写入时就会悄无声息地覆盖最早的数据没有报错也没有标志。解决思路在驱动之上再加一层地址范围检查len 加上起始地址超过 8192 就直接返回错误。也可以用“分段存储”思路把 8K 拆成两个 4K 区参数区只写前半部分日志区只写后半部分从设计上避免跨界。驱动函数里保留地址回卷的特性应用层必须自己维护地址边界。6. 进阶验证用掉电复读和边界压力测试确认驱动可靠驱动代码写完不等于能用铁电存储器的验证比普通 E2PROM 简单但比想象中容易遗漏。我拿到驱动工程后一般做三组验证地址边界、频率极限、掉电保持。这三组做完才算真正把驱动“验收”过了。第一组验证针对地址边界。写一个测试函数把 0x0000、0x00FF、0x0100、0x0FFF、0x1FFF 这几个关键地址各写一个特征字节比如 0xAA、0x55、0x01、0x02、0x03。断电重启后再读回来比对任何一个对不上都说明地址拆分或者 CS 时序还有问题。跨 255 和跨 255 边界的地址特别值得测一遍因为多字节地址的读写最容易在这一段翻车。第二组验证针对 SPI 时钟频率。把分频系数从 8 一路改到 128也就是 9MHz 到 562.5KHz每个分频下写 1000 字节再读回比对。这组测试不用在产线做但如果你准备把驱动放进产品至少测一次。我遇到过一次芯片在 9MHz 偶发错误降到 4.5MHz 完全正常的案例最后定位是 PCB 走线过长导致 SI 信号建立时间不足。这种问题在没有逻辑分析仪的情况下很难抓频率扫描是最直接的排查手段。第三组验证模拟真实掉电场景。写一个 count 变量到地址 0x0000每次上电先读出来加一再写回去。人为断电再上电循环 50 次确认 count 连续递增没有跳变。这一步实际验证的是芯片在电压跌落到临界值时是否出现半写入状态。铁电存储器理论上不存在半写入但 CS 信号如果先于 VCC 掉电可能出现不可预期的行为。测试时用一个可控的电源开关断电瞬间故意让 CS 拉低观察数据是否损坏。完整验证代码可以封装成下面这样uint8_t FM25CL64_SelfTest(void) { uint8_t buf[8]; uint8_t i; uint16_t test_addrs[4] {0x0000, 0x00FF, 0x0100, 0x1FFF}; // 在每个边界地址写入固定模式 for (i 0; i 4; i) { buf[0] 0xA5; buf[1] (uint8_t)i; FM25CL64_WriteBuffer(test_addrs[i], buf, 2); } // 回读并比较 for (i 0; i 4; i) { FM25CL64_ReadBuffer(test_addrs[i], buf, 2); if (buf[0] ! 0xA5 || buf[1] ! i) { return 1; // 自检失败 } } return 0; // 全部通过 }自检函数本身写的比较紧凑实际项目里我会把测试失败时的地址和期望值打印出来定位更快。另外如果工程里有 RTOS建议把自检放在初始化任务里不要放在中断上下文因为 SPI 通信本身需要时间片。最后说一个我踩过的细节FM25CL64 的 WP 引脚如果不接 VCC而是留了 GPIO 控制那每次上电时 GPIO 默认状态可能是低电平导致解锁之前就被写保护了。从那以后我设计硬件时要么把 WP 直接上拉到 VCC要么在单片机初始化代码的第一行就把 WP 引脚写高。同样的习惯也用在 HOLD 脚上。这两个引脚处理干净驱动代码里就能少两个排查方向。希望上面的读写示例和排查思路能帮到你在项目里少走几步弯路。本文还有配套的精品资源点击获取