简介:这是一份面向STM32嵌入式开发者的MB85RC64铁电存储器(FRAM)驱动源码,采用C语言编写,代码分成源文件和头文件两个部分,便于模块化集成与接口调用。MB85RC64由富士通公司推出,兼具高速读写、低功耗和掉电数据保持能力,适合需要频繁写入且对存储寿命要求较高的场景,如参数保存、运行日志记录等。压缩包内共2个文件,包含一个.c源文件和一个.h头文件,整体大小仅3KB,结构精简易懂。驱动代码涵盖芯片初始化、数据写入、读取以及错误处理等基础操作,封装后可通过简洁API进行读写访问,能帮助开发者快速完成FRAM底层适配,免去对照数据手册逐项调试寄存器的繁琐流程。目前已有1748人学习下载,适合正在使用STM32并需要可靠非易失性存储方案的软硬件工程师参考与二次开发。 MB85RC64 这颗芯片,我第一次在原理图里看到时第一反应是:这不就是个 I2C EEPROM 吗?等拿到样片开始调驱动才发现,FRAM 和 EEPROM 虽然引脚兼容、时序相似,但骨子里完全是两种东西。它不需要页缓冲,写入没有 5ms 的 tWR 等待,寿命标称 10 的 10 次方次,这些特点让你的驱动代码可以写得比 EEPROM 简单,却也容易让人在细节上翻车。这篇东西我从硬件接线、I2C 时序、用户态验证写到内核驱动骨架,最后附上实测中遇到的坑,适合正在调 MB85RC64 驱动的朋友直接对照参考。
1. 项目概述:先弄懂 MB85RC64 和 EEPROM 的差别
1.1 一颗 8KB 的铁电存储芯片
MB85RC64 是一颗 64Kbit 的 FRAM(铁电存储器),换算过来就是 8KB,存储结构是 8192 x 8 bit,走 I2C 接口,最高支持 1MHz 总线时钟。市面上常见的有普通 3.3V 版本和低电压版本,低电压版本可以在 1.8V 下工作,所以做低功耗手持设备时很讨喜。
FRAM 最核心的特点有三个:写入速度极快、寿命极长、功耗低。它不像 EEPROM 或 Flash 那样靠电荷泵和浮栅隧穿来存数据,而是利用铁电晶体的极化方向翻转来记录 0 和 1,写入过程本身就是一个物理翻转动作,所以写完立刻生效,不需要等内部充电完成。我跟同事打过一个不严谨但好懂的比方:EEPROM 写数据像在纸上用铅笔写字,写完还要等墨干;FRAM 写数据像按开关,按下去灯就亮了。
| 对比项 | EEPROM | MB85RC64 FRAM | NOR Flash |
|---|---|---|---|
| 写入单位 | 字节/页 | 字节 | 页/扇区 |
| 写入等待 | 约 5ms | 无 | 擦除需等待 |
| 写寿命 | 10^5~10^6 次 | 10^10 次 | 10^5 次 |
| 随机读写 | 支持 | 支持 | 不支持随机写 |
| 典型用途 | 参数存储 | 掉电保存/日志 | 代码存储 |
这个特性决定了驱动代码的主线思路:MB85RC64 可以像 SRAM 一样频繁写、随机写,不用担心把某个地址写坏,也不用为“写一页再等待”设计状态机。项目中我拿它存设备校准参数和运行日志,写入频率比 EEPROM 时代高了一个数量级,跑了大半年没有出现丢数据的问题。
1.2 为什么驱动不能照抄 EEPROM 代码
网上搜 MB85RC64 驱动,很容易找到从 24Cxx 移植过来的代码,直接照用通常会出问题。最大的坑就是把“写后延时”原封不动搬进来,白白浪费 FRAM 的写入优势;更隐蔽的坑是读操作的方式。
EEPROM 的随机读和 FRAM 的随机读在 I2C 总线上看着差不多,都是先写 2 字节内存地址,再发起读事务。但很多 EEPROM 驱动代码喜欢用lseek+read或者先write地址再直接read数据,这套逻辑在 i2c-dev 下并不总是成立。因为read()系统调用只是发起一个读方向的 I2C 事务,它不会自动帮你携带前面的内存地址,MB85RC64 收到读命令后,默认从“当前地址”开始读,而这个“当前地址”并不会因为你之前写入了 2 字节地址就自动变成目标地址。
所以设计驱动时,我建议先把 I2C 事务拆清楚:写数据是“写地址 + 写内存地址 + 写数据”,读数据是“写内存地址 + 重复起始 + 读数据”。这两个组合必须显式地组织成完整事务,不能想当然。后面第三部分我会给出直接用 ioctl I2C_RDWR 的正确写法。
2. 硬件接线与 I2C 时序:驱动前必须吃透的细节
2.1 引脚定义、器件地址和 WP 写保护
MB85RC64 的封装很常规,总共 8 个引脚,核心就是 I2C 的两根线加上地址和写保护引脚。地址引脚为 A0、A1、A2,三根引脚组合出 8 个可选地址,7 位器件地址固定是1010 A2 A1 A0。所有地址引脚接地时,7 位地址是 0x50,I2C 写地址是 0xA0,读地址是 0xA1。很多工程师拿着 0xA0 去对地址,发现 i2cdetect 扫出来却是 0x50,就是这个原因,一个是 8 位总线地址,一个是 7 位内核地址。
| 引脚 | 功能 | 驱动注意事项 |
|---|---|---|
| A0/A1/A2 | 地址选择 | 接 GND 为 0,接 VDD 为 1,不能悬空 |
| SDA/SCL | I2C 数据/时钟 | 需要上拉电阻,一般 2.2k~10k |
| WP | 写保护 | 高电平禁止写入,正常使用接 GND |
| VDD/VSS | 电源/地 | 加 0.1uF 去耦电容 |
WP 引脚是很多人第一次调驱动失败的主要原因。它内部有下拉?不同批次的手册描述不完全一样,我踩过的情况是 WP 悬空时,附近有电机或继电器开关,电平被干扰拉高,导致写入静默失败,读出来还是旧数据。稳妥的做法是硬件上给 WP 加一个 10k 下拉电阻,软件上每次写操作前也可以读一下状态确认,不过驱动层一般不额外处理,主要还是靠硬件保证。
2.2 四类基本 I2C 操作时序
MB85RC64 的操作时序本质上就是 I2C 标准事务的排列组合,但有几个细节值得单独说。
写操作:主机先发 START,然后发送器件写地址(0xA0),芯片 ACK 后发送 2 字节内存地址,再发送要写入的数据,最后 STOP。这里内存地址是 16 位的,因为芯片容量是 8KB,地址范围 0x0000~0x1FFF,如果驱动写成 8 位地址,你会发现数据总是写到低 256 字节里打转。
当前地址读:主机直接发器件读地址(0xA1),芯片会把内部地址计数器指向的地址数据发出来。这个操作常用于连续读,或者在调试时用来探测芯片是否在线。
随机读:这是最常用的读取方式。主机先发器件写地址,写入 2 字节内存地址,接着不发送 STOP,而是再次发 START(重复起始),然后发送器件读地址,再读取数据。用文字描述就是这样:
START + 0xA0 + Addr_H + Addr_L + 重复START + 0xA1 + 数据 + STOP顺序读:在读操作过程中,每读一个字节,内部地址自动加 1。注意地址递增到 0x1FFF 后再继续读,会回卷到 0x0000。驱动如果要读取跨回卷边界的数据,必须自己拆成两段,否则读到一半数据会跳到开头。
2.3 WP 和电平匹配的那些坑
写保护这块我多说一句。MB85RC64 的 WP 引脚是纯粹的硬件写保护,它不像某些芯片有软件写保护寄存器,所以排查写入问题时,用万用表量 WP 引脚电平是最快的判断手段。我曾经遇到过焊盘虚焊的情况,WP 引脚看起来接地了,但实际是悬空,症状就是“有时能写有时不能写”,折腾了一天才定位到。
电平匹配也很重要。如果主控是 5V 单片机,MB85RC64 是 3.3V 供电,直接连接 SDA/SCL 可能导致芯片锁死或写入异常。建议 I2C 总线用电平转换电路,或者选择支持宽电压的芯片后缀。驱动开发时如果发现 ACK 时有时无,先别怀疑代码,量一下 SDA 线上的高电平是否超过芯片 VDD 的要求。
3. 驱动落地:用户态验证、内核驱动和 STM32 移植
3.1 先用 i2c-dev 在用户态跑通硬件
正式写内核驱动前,我强烈建议先在用户态用 i2c-dev 把硬件验证完。Linux 下只要内核开了 CONFIG_I2C_CHARDEV,就会出现 /dev/i2c-0、/dev/i2c-1 这类设备节点。先用 i2cdetect 确认设备和地址,再用 C 语言写个几十行的测试程序,比反复编译内核模块快得多。
这里给出一个可以直接用的随机读写示例。注意读取部分用的是 ioctl 的 I2C_RDWR,组织“发送内存地址 + 读取数据”两个消息,而不是简单 write 再 read。这是最容易写错的地方。
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #include <linux/i2c-dev.h> #include <linux/i2c.h> #include <string.h> #define I2C_BUS "/dev/i2c-1" #define FRAM_ADDR 0x50 /* 7-bit 地址,对应 8 位写地址 0xA0 */ static int fram_write(int fd, unsigned int addr, unsigned char *buf, int len) { unsigned char out[2 + len]; out[0] = (addr >> 8) & 0xFF; out[1] = addr & 0xFF; memcpy(out + 2, buf, len); if (write(fd, out, 2 + len) != 2 + len) return -1; return 0; } static int fram_read(int fd, unsigned int addr, unsigned char *buf, int len) { unsigned char addrbuf[2] = { (addr >> 8) & 0xFF, addr & 0xFF }; struct i2c_msg msgs[2]; struct i2c_rdwr_ioctl_data rdwr; msgs[0].addr = FRAM_ADDR; msgs[0].flags = 0; msgs[0].len = 2; msgs[0].buf = addrbuf; msgs[1].addr = FRAM_ADDR; msgs[1].flags = I2C_M_RD; msgs[1].len = len; msgs[1].buf = buf; rdwr.msgs = msgs; rdwr.nmsgs = 2; if (ioctl(fd, I2C_RDWR, &rdwr) < 0) return -1; return 0; } int main(void) { int fd = open(I2C_BUS, O_RDWR); if (fd < 0) return 1; if (ioctl(fd, I2C_SLAVE, FRAM_ADDR) < 0) return 1; unsigned char wbuf[4] = {0x11, 0x22, 0x33, 0x44}; unsigned char rbuf[4] = {0}; if (fram_write(fd, 0x0010, wbuf, 4) < 0) { perror("write"); return 1; } if (fram_read(fd, 0x0010, rbuf, 4) < 0) { perror("read"); return 1; } for (int i = 0; i < 4; i++) printf("rbuf[%d] = 0x%02X\n", i, rbuf[i]); close(fd); return 0; }这段代码在树莓派、各种 Linux 开发板上都能直接编译运行。写函数可以只用一个 write 完成,因为 write 会组合成一个 I2C 写事务;但读函数必须用 I2C_RDWR,让内核在第一条消息结束后自动产生重复起始,再接第二条读消息。
如果不想写 C 代码,也可以用 i2c-tools 里的 i2ctransfer 快速验证:
# 向 0x0010 地址写入 0xAA i2ctransfer -y 1 w3@0x50 0x00 0x10 0xAA # 读取 0x0010 地址的一个字节 i2ctransfer -y 1 w2@0x50 0x00 0x10 r13.2 内核驱动:i2c_driver 加字符设备骨架
用户态验证没问题后,再上内核驱动就顺理成章了。内核驱动的好处是可以向应用层提供一个清晰的语义接口,比如用 pread/pwrite 指定偏移量读写,或者做成 miscdevice 让应用层当成文件操作。驱动核心就是注册一个 i2c_driver,在 probe 里初始化字符设备。
#include <linux/i2c.h> #include <linux/miscdevice.h> #include <linux/uaccess.h> #define FRAM_SIZE 8192 static struct i2c_client *fram_client; static ssize_t fram_file_read(struct file *file, char __user *buf, size_t count, loff_t *offs) { u8 tmp[FRAM_SIZE]; u8 addrbuf[2]; struct i2c_msg msgs[2]; int ret; if (*offs >= FRAM_SIZE) return 0; if (*offs + count > FRAM_SIZE) count = FRAM_SIZE - *offs; addrbuf[0] = (*offs >> 8) & 0xFF; addrbuf[1] = *offs & 0xFF; msgs[0].addr = fram_client->addr; msgs[0].flags = 0; msgs[0].len = 2; msgs[0].buf = addrbuf; msgs[1].addr = fram_client->addr; msgs[1].flags = I2C_M_RD; msgs[1].len = count; msgs[1].buf = tmp; ret = i2c_transfer(fram_client->adapter, msgs, 2); if (ret < 0) return ret; if (copy_to_user(buf, tmp, count)) return -EFAULT; *offs += count; return count; } static const struct file_operations fram_fops = { .owner = THIS_MODULE, .read = fram_file_read, }; static struct miscdevice fram_miscdev = { .minor = MISC_DYNAMIC_MINOR, .name = "mb85rc64", .fops = &fram_fops, }; static int fram_probe(struct i2c_client *client) { fram_client = client; return misc_register(&fram_miscdev); } static void fram_remove(struct i2c_client *client) { misc_deregister(&fram_miscdev); } static const struct i2c_device_id fram_id[] = { { "mb85rc64", 0 }, { } }; MODULE_DEVICE_TABLE(i2c, fram_id); static struct i2c_driver fram_driver = { .driver = { .name = "mb85rc64", }, .probe = fram_probe, .remove = fram_remove, .id_table = fram_id, }; module_i2c_driver(fram_driver); MODULE_LICENSE("GPL");设备树里要加对应的节点,compatible 属性用驱动的名字。例如:
mb85rc64@50 { compatible = "mb85rc64"; reg = <0x50>; };写操作的内核实现和用户态类似,同样用 i2c_transfer 构造一个写事务,把内存地址和要写的数据拼在同一个缓冲区里。要注意并发访问,如果应用层可能多线程读写,得在驱动里加 mutex 锁保护,避免两个线程交叉操作导致总线时序错乱。
3.3 移植到 STM32 HAL 的思路
如果是在 STM32 或单片机平台,MB85RC64 驱动就更简单了,HAL 库直接提供了内存读写接口。16 位内存地址时需要指定 I2C_MEMADD_SIZE_16BIT,这一步经常有人漏掉,结果写入地址被截断成低 8 位。
#define FRAM_WRITE_ADDR 0xA0 #define FRAM_READ_ADDR 0xA1 uint8_t buf[16]; HAL_I2C_Mem_Write(&hi2c1, FRAM_WRITE_ADDR, 0x0010, I2C_MEMADD_SIZE_16BIT, buf, 16, 100); HAL_I2C_Mem_Read(&hi2c1, FRAM_WRITE_ADDR, 0x0010, I2C_MEMADD_SIZE_16BIT, buf, 16, 100);注意 HAL 库的第二个参数虽然是uint16_t DevAddress,但实际传的是 8 位总线地址,所以写地址是 0xA0,读操作也是传 0xA0(HAL 内部会在 R/W 位上自动处理)。有没有更底层的需求?如果不用 HAL,就需要自己控制起始、地址、数据、停止和 ACK 位,逻辑与第二节的时序完全对应,照着写就行。
4. 实测记录:三个典型坑和排查思路
4.1 用 i2cdetect 探测总线,确认芯片在线
拿到一块新板子,我第一件事永远是先看系统里有哪些 I2C 总线,再扫描总线上的设备:
i2cdetect -l i2cdetect -y 1如果 MB85RC64 的地址引脚全部接地,第二行命令会在 0x50 位置看到一个50。注意有些 i2cdetect 版本默认用快速读模式扫描,对某些 I2C 设备可能会产生误判,可以加-r参数再扫一次。如果扫描不到,先检查地址引脚电平、SDA/SCL 是否接反、总线号是否选对,这比直接查代码效率高得多。
4.2 写入失败与读回全 F 的现场复盘
有一次客户反馈“数据存进去,断电重启就丢”。我拿到板子复测,现象是:写入时没有任何错误,但读回全是 0xFF。排查过程大概走了三十分钟,先怀疑软件,再看硬件,最后定位到是 WP 引脚的问题。那颗料在 PCB 上 WP 走线靠近一个 DC-DC 电感,电感开关噪声耦合到 WP 上,导致写入被芯片悄悄拒绝。硬件上把 WP 通过 10k 电阻下拉后,问题彻底消失。
第二个典型案例是读回数据错乱。写入正常,但随机读出来的数据和写进去的不一样。用示波器抓波形发现,写内存地址之后没有产生重复起始,而是 STOP 之后又发了一个新的 START,中间多了一个 STOP,芯片内部地址计数器已经变化。这是因为 i2c-dev 里先write()再read(),每次系统调用都是一个独立事务。改成 I2C_RDWR 合并消息后,波形恢复正常,数据完全一致。
第三个坑是总线速率。MB85RC64 虽然是 1MHz 器件,但如果主控端 I2C 外设时钟配置不当,实际 SCL 可能超过 1MHz,芯片就会冒出随机 NACK。把这个板子的 I2C 时钟从 1MHz 降到 400kHz,通信立刻稳定,说明不是芯片质量问题,而是边沿太陡或频率超限。
4.3 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| i2cdetect 扫描不到设备 | A0/A1/A2 拉错、SDA/SCL 无上拉、总线号错误 | 确认地址引脚电平,检查上拉,用 i2cdetect -l 查总线 |
| 写操作无报错但读回旧数据 | WP 引脚为高或受干扰 | WP 加 10k 下拉,万用表实测引脚电平 |
| 读回数据全是 0xFF | 实际执行的是当前地址读,或芯片写保护 | 用 I2C_RDWR 组随机读,检查写保护 |
| 随机读数据错位 | write 与 read 分成了两个独立事务 | 改为一条 ioctl I2C_RDWR,包含两个 i2c_msg |
| 高速通信时随机 NACK | 总线速率超限、上拉电阻过小 | 降到 400kHz/100kHz,调整上拉电阻 |
| 连续读写越过 0x1FFF 数据错乱 | 地址回卷到了 0x0000 | 驱动层做地址边界检查,分段传输 |
调试这类 I2C 驱动,示波器或逻辑分析仪几乎是必备工具。我见过太多工程师凭感觉改代码,改了半天不如抓一次波形看得明白。重点观察 START 条件、器件地址后的 ACK、重复起始是否存在、STOP 位置是否正确,基本十分钟就能定位问题。
最后再说一个个人习惯:驱动里所有地址相关的参数,比如 7 位地址和 8 位地址、16 位内存地址和 8 位内存地址,我都用宏定义加上注释区分,避免日后维护时看晕。MB85RC64 驱动本身不复杂,把 I2C 事务结构理清楚后,无论是 Linux 内核、用户态还是单片机,都只是同一套时序的不同表达方式。
本文还有配套的精品资源,点击获取