简介:一套可直接嵌入STM32工程的AT24C系列EEPROM驱动程序,覆盖AT24C32、AT24C64、AT24C128三种容量,适用于物联网设备、智能家居、工业控制等需要存储配置信息与用户数据的嵌入式场景。基于I²C接口设计,既适配硬件I²C外设配置,也兼顾软件模拟方式;驱动代码中包含初始化、读写函数、设备地址与寄存器偏移处理、错误处理等关键模块,便于开发者快速移植到不同STM32型号。压缩包内共2个文件,即1个头文件与1个源文件,整体仅3KB,结构精简,可直接加入现有工程后按需修改。已有4242人学习下载,对于正在学习STM32 I²C通信或需要快速完成AT24C系列驱动接入的开发者,这份源码能节省查阅手册与调试时间,并提供清晰的代码参考。 最近在做一款带参数存储的小设备,主控是STM32,要存校准系数、设备序列号、运行日志这类掉电不能丢的数据,于是又把AT24C系列EEPROM翻了出来。选型时在AT24C32、AT24C64、AT24C128之间犹豫了一下,后来发现这三颗芯片在驱动层面高度统一,干脆写了一套通用驱动,一套代码直接兼容三颗芯片。这篇文章就把这几颗芯片的差异、驱动设计思路、完整代码实现和踩过的坑一次性说清楚,给正在用STM32驱动AT24C系列的朋友做个参考。
1. 项目概述:AT24C32/64/128驱动为什么可以合并成一套
很多人在第一次接触AT24C系列时,会习惯性地按照型号分别找驱动,觉得AT24C32、AT24C64、AT24C128是三颗完全不同的芯片。实际拆开来看,它们都是I2C接口的EEPROM,核心协议完全一致,区别只在容量、页大小和器件地址格式上。搞清楚这三点,就能用一套驱动统一搞定,而不是维护三份几乎一样的代码。
1.1 三颗芯片的差异:容量、地址格式、页大小
先看一张参数对比表,这是选型和写驱动之前必须明确的信息。
| 参数 | AT24C32 | AT24C64 | AT24C128 |
|---|---|---|---|
| 容量 | 32Kbit(4KB) | 64Kbit(8KB) | 128Kbit(16KB) |
| 字地址范围 | 0x0000 - 0x0FFF | 0x0000 - 0x1FFF | 0x0000 - 0x3FFF |
| 字地址字节数 | 2字节 | 2字节 | 2字节 |
| 页大小 | 32字节 | 32字节 | 64字节 |
| 器件地址位 | A2/A1/A0 三引脚可选 | A2/A1/A0 三引脚可选 | 仅A2引脚,A1/A0位置为P1/P0 |
| 典型写周期 | 5ms | 5ms | 5ms |
这里最容易被忽略的是AT24C01/02和AT24C32及以上型号的分水岭:AT24C01/02使用单字节字地址,而AT24C32开始全部使用双字节字地址。如果你以前写的是AT24C02驱动,直接拿来改AT24C32,寄存器地址会乱套,数据读出来永远是错位。这是第一个需要划重点的设计差异。
页大小决定了页写操作的边界。所谓页写,就是一次I2C总线传输最多能连续写的字节数。AT24C32/64一页是32字节,AT24C128一页是64字节。如果写入的数据跨越了页边界,地址计数器会从页首重新开始,把已经写入的数据覆盖掉。关于这个问题的细节,后面第2章和第3章会仔细讲。
1.2 驱动分层的整体设计思路
既然三颗芯片协议一致,驱动就应该分成两层:底层是I2C通信函数,负责起始/停止信号、字节收发、ACK应答;上层是AT24C协议处理,负责器件地址拼装、双字节字地址发送、页边界拆分。底层与具体芯片无关,上层通过一个参数结构体区分不同型号。
我在实际项目中把AT24C驱动设计成下面这种结构:
- i2c_port层:提供I2C起始、停止、写字节、读字节、等待应答等基础函数,这里用软件模拟I2C,方便在各型号STM32之间移植。
- at24c_dev层:定义设备参数结构体,包含器件基地址、页大小、容量上限,提供读写接口。
- app层:业务数据直接调用读写接口,存入偏移地址,不关心底层怎么传输。
这样分层的好处很直接:换芯片型号时只需要改一下参数结构体;换MCU平台时只需要重写i2c_port层。我在项目里从STM32F103换到STM32G030,上层业务代码一行没动,只重新实现了底层GPIO翻转和延时函数。
2. 硬件电路与I2C协议的基础要点
软件写得再好,硬件上出了问题一样白搭。AT24C系列虽然简单,但电路连接和I2C协议里有几个地方特别容易翻车,尤其是上拉电阻和器件地址,几乎每个新项目都会有人来问我。
2.1 引脚接法和上拉电阻怎么选
AT24C系列通常只有8个引脚:VCC、GND、SCL、SDA、WP、A0/A1/A2(或A2/P1/P0)。其中WP是写保护引脚,接高电平时芯片禁止写入,接低电平时允许正常写入。很多人的数据写不进去,查了半天发现是WP引脚悬空,被内部上拉或外部干扰拉高了。
上拉电阻是I2C总线的必需品,因为SDA和SCL都是开漏输出。我一般选4.7kΩ,在VCC是3.3V、总线长度不超过10cm的情况下很稳定。如果总线距离较长或挂载设备较多,可以降到2.2kΩ。最小阻值可以用欧姆定律算:保证IO口灌电流不超过规格书的极限值。比如VCC=3.3V,VOL=0.4V,灌电流以3mA算,Rpmin = (3.3 - 0.4) / 0.003 ≈ 967Ω。也就是说上拉电阻不能小于1k左右,否则灌电流可能超限。最大阻值则受总线电容和上升时间约束,10k在短距离下问题不大,但长线场景不建议。
顺便提醒一点:3.3V的STM32和5V的AT24C混接时,SCL和SDA上必须做电平匹配。AT24C系列虽然宽电压支持,但如果总线电平不匹配,轻则通信不稳定,重则损坏IO口。最简单的方案是选支持1.8V-5.5V的型号,并把上拉电阻接到3.3V电源轨,SCL/SDA信号都由STM32输出,总体就是3.3V电平逻辑。
2.2 器件地址:最容易踩坑的地址位配置
AT24C的I2C器件地址格式是:1010 + 3位地址位 + R/W位。发送地址时最低位是读写标志,所以写地址是0xA0,读地址是0xA1,这是默认情况下的值。如果A2/A1/A0引脚接了不同电平,器件地址会相应改变。
AT24C32/64的3位地址位对应A2/A1/A0三个引脚,理论上可以在一条I2C总线上挂8颗芯片,互不冲突。但AT24C128就特殊了,它只有A2引脚是硬件地址引脚,原来的A1/A0位置被固定为P1/P0。这导致一个典型问题:如果你在总线上挂了两颗AT24C128,想通过A1/A0区分,抱歉,做不到。A2只能区分两颗,P1/P0是在器件地址里直接写死的,不是外部引脚。
实际项目中,如果总线上只有一颗AT24C128,直接把A2接地,器件地址写成0xA0就行。但如果你曾经把AT24C64驱动直接搬到AT24C128上,且误把A1/A0引脚当成地址位去配置,就会遇到设备无响应或者访问到错误地址空间的问题。这是AT24C系列里比较隐蔽的坑。
2.3 页写边界与写周期延时
页写是提高写入效率的关键操作。AT24C32/64一次页写最多32字节,AT24C128一次页写最多64字节。但页写有个致命限制:写入过程中如果地址越过当前页的末尾,地址计数器不会自动进入下一页,而是回卷到本页页首。这意味着你在页边界附近连续写入时,如果没有做拆分,后写的数据会把页首的数据覆盖掉。
举个例子,AT24C32的页大小是32字节,页0地址范围0x00到0x1F。如果从0x1E开始连续写5个字节,地址会这样走:0x1E写入,0x1F写入,然后地址回卷到0x00继续写入。于是0x00和0x01处本来保存的数据就被新数据覆盖了。这个错误在调试时很难发现,因为读出来的数据看起来有规律,但跟预期就是差了几个字节。
另一个必须处理的是写周期延时。EEPROM在接收到一帧写命令后,内部擦写需要时间,典型值5ms。在这段时间内,芯片不响应任何I2C命令,包括对它自己器件地址的应答。有两种等待方式:固定延时5ms到10ms,或者用ACK轮询。前者简单但浪费时间,后者效率更高:写入完成后不断发送器件地址,直到收到应答位,就说明内部写操作已经结束。我倾向于在驱动里实现ACK轮询,批量写入大量参数时可以节省不少时间。
3. 驱动代码实现:一套代码兼容三颗芯片
下面给出一个可直接使用的驱动实现思路,用软件模拟I2C,再在AT24C协议层做统一封装。代码以C语言编写,依赖标准库的GPIO操作,不绑定具体型号,稍作修改就能移植到任何STM32工程。
3.1 软件模拟I2C底层函数
硬件I2C在STM32上的口碑一直两极分化,早期版本确实存在一些灵异问题。为了最大程度减少调试成本,我在项目里选择软件模拟I2C,时序完全可控,换平台成本也低。
#define AT24C_SCL_H() GPIO_SetBits(GPIOB, GPIO_Pin_6) #define AT24C_SCL_L() GPIO_ResetBits(GPIOB, GPIO_Pin_6) #define AT24C_SDA_H() GPIO_SetBits(GPIOB, GPIO_Pin_7) #define AT24C_SDA_L() GPIO_ResetBits(GPIOB, GPIO_Pin_7) #define AT24C_SDA_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7) static void i2c_delay(void) { // 约2.5us,在72MHz下约为180个时钟周期 delay_us(2); } static void i2c_start(void) { AT24C_SDA_H(); AT24C_SCL_H(); i2c_delay(); AT24C_SDA_L(); i2c_delay(); AT24C_SCL_L(); } static void i2c_stop(void) { AT24C_SDA_L(); AT24C_SCL_H(); i2c_delay(); AT24C_SDA_H(); } static void i2c_ack(void) { AT24C_SDA_L(); AT24C_SCL_H(); i2c_delay(); AT24C_SCL_L(); AT24C_SDA_H(); } static void i2c_nack(void) { AT24C_SDA_H(); AT24C_SCL_H(); i2c_delay(); AT24C_SCL_L(); } static uint8_t i2c_write_byte(uint8_t data) { uint8_t i; for (i = 0; i < 8; i++) { if (data & 0x80) AT24C_SDA_H(); else AT24C_SDA_L(); data <<= 1; AT24C_SCL_H(); i2c_delay(); AT24C_SCL_L(); i2c_delay(); } // 释放SDA,读取应答 AT24C_SDA_H(); AT24C_SCL_H(); i2c_delay(); if (AT24C_SDA_READ()) { AT24C_SCL_L(); return 1; // 无应答 } AT24C_SCL_L(); return 0; } static uint8_t i2c_read_byte(void) { uint8_t i, data = 0; AT24C_SDA_H(); // 释放SDA线 for (i = 0; i < 8; i++) { data <<= 1; AT24C_SCL_H(); i2c_delay(); if (AT24C_SDA_READ()) data |= 0x01; AT24C_SCL_L(); i2c_delay(); } return data; }这段代码的关键点在i2c_write_byte的释放SDA动作。每次写完8位数据后,必须把SDA线释放为高阻输入模式,否则MCU一直驱动SDA低电平,EEPROM无法拉低SDA来发ACK。我在早期写驱动时就是这个细节没处理好,导致每颗芯片都不应答。另外,SCL空闲时必须保持低电平,只有产生起始/停止信号时才拉高,这能避免误触发。
3.2 AT24C协议层与跨页写入
AT24C协议层用一个结构体保存设备参数,根据不同型号初始化不同的页大小和地址上限。
typedef struct { uint16_t dev_addr; // 器件写地址,如0xA0 uint16_t page_size; // 页大小:AT24C32/64为32,AT24C128为64 uint16_t max_addr; // 最大地址:AT24C32为0x0FFF,AT24C64为0x1FFF,AT24C128为0x3FFF } at24c_dev_t; static void at24c_select_device(const at24c_dev_t *dev, bool is_read) { uint8_t addr = dev->dev_addr; if (is_read) addr |= 0x01; // 最低位置1表示读操作 i2c_write_byte(addr); } static void at24c_send_word_addr(const at24c_dev_t *dev, uint16_t addr) { i2c_write_byte((addr >> 8) & 0xFF); // 高字节地址 i2c_write_byte(addr & 0xFF); // 低字节地址 } static uint8_t at24c_wait_ready(const at24c_dev_t *dev) { uint8_t retry = 0; while (1) { i2c_start(); if (i2c_write_byte(dev->dev_addr) == 0) // 收到应答说明内部写完成 { i2c_stop(); return 0; } i2c_stop(); if (++retry > 200) return 1; delay_ms(1); } }跨页写入是驱动设计里最容易出错的部分,我用一个边界检查来拆分数据长度。计算当前地址到页尾还剩多少空间,要写的长度超过这个空间就只先写一段,然后继续下一页。
uint8_t at24c_write(const at24c_dev_t *dev, uint16_t addr, const uint8_t *buf, uint16_t len) { if (addr + len > dev->max_addr + 1) return 1; // 超出容量 while (len > 0) { uint16_t remain_in_page = dev->page_size - (addr % dev->page_size); uint16_t write_len = len; if (write_len > remain_in_page) write_len = remain_in_page; i2c_start(); if (i2c_write_byte(dev->dev_addr) != 0) { i2c_stop(); return 2; } at24c_send_word_addr(dev, addr); for (uint16_t i = 0; i < write_len; i++) { if (i2c_write_byte(buf[i]) != 0) { i2c_stop(); return 3; } } i2c_stop(); if (at24c_wait_ready(dev) != 0) return 4; // 写超时 addr += write_len; buf += write_len; len -= write_len; } return 0; }remain_in_page = dev->page_size - (addr % dev->page_size)这行代码是整个跨页处理的核心。它先算出当前地址在页内的偏移,再用页大小一减,得到本页剩余可写的字节数。每次写完后地址递增,循环继续,直到所有数据写完。没有这层处理,直接在页边界附近写入长数据,必然会出现回卷覆盖。
读取操作相对简单,AT24C的随机读是先发送要读的字地址,然后重新发送起始信号,切换成读模式,让EEPROM输出数据。连续读取时,除了最后一个字节需要发NACK之外,前面的字节都要发送ACK,否则EEPROM会提前结束输出。
uint8_t at24c_read(const at24c_dev_t *dev, uint16_t addr, uint8_t *buf, uint16_t len) { if (addr + len > dev->max_addr + 1) return 1; i2c_start(); if (i2c_write_byte(dev->dev_addr) != 0) { i2c_stop(); return 2; } at24c_send_word_addr(dev, addr); i2c_start(); // 重复起始信号 if (i2c_write_byte(dev->dev_addr | 0x01) != 0) { i2c_stop(); return 3; } for (uint16_t i = 0; i < len; i++) { buf[i] = i2c_read_byte(); if (i == len - 1) i2c_nack(); else i2c_ack(); } i2c_stop(); return 0; }读操作里的重复起始信号是个易错点。在写完字地址之后,不能直接发送停止信号再开始,而是要在SCL为高电平时把SDA拉低,产生重复起始信号,然后立即发送读地址。如果先STOP再START,虽然多数情况下也能工作,但不符合I2C规范,部分严格的从设备可能出现异常。
3.3 实际调用示例
驱动写好后,调用起来非常简洁。下面是一个完整的示例:开机初始化设备和驱动,然后把一个结构体参数写入EEPROM,再读回来校验。
const at24c_dev_t at24c128_dev = { .dev_addr = 0xA0, .page_size = 64, .max_addr = 0x3FFF }; typedef struct { uint16_t calib_offset; uint8_t gain; uint8_t mode; uint16_t threshold; } sys_param_t; void param_save(const at24c_dev_t *dev) { sys_param_t param = {100, 30, 2, 500}; if (at24c_write(dev, 0x0000, (uint8_t *)¶m, sizeof(param)) == 0) { // 写入成功 } }这种直接把结构体指针强制转换成uint8_t *的写法,要求结构体成员紧凑排列。C语言结构体默认有对齐,如果你用的是类似#pragma pack(1)的设置或者精心设计成员顺序,直接整体写入没问题。但为了避免不同编译器带来的对齐差异,我建议在自定义协议格式时,把每个字段手动按偏移写入缓冲区,这样更稳。
4. 常见问题与排查技巧
AT24C系列调试过程中,我几乎把能踩的坑都踩了一遍。整理成一张速查表,给正在调试的朋友做个对照参考。
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| 写入数据读出来全是0xFF | WP引脚被拉高,写保护生效 | 将WP接地,检查外部上拉干扰 |
| 写入数据读取后偏移几个字节 | 器件地址或字地址字节时序不对 | 确认AT24C32及以上用双字节字地址 |
| 写入长度超过页大小,数据被覆盖 | 触发页回卷 | 用跨页拆分逻辑,限制单次写入长度 |
| 写入后立即读,数据是旧值 | 未等待写周期完成 | 增加延时或使用ACK轮询 |
| 总线卡死,SCL/SDA被拉低 | 通信异常导致从设备锁死 | 尝试9个时钟恢复,或重新上电 |
| ACK一直无应答 | 器件地址错误或芯片未供电 | 用逻辑分析仪抓I2C波形确认地址 |
| 掉电后部分参数丢失 | 写期间掉电或写入未完成 | 增加CRC校验和双备份存储 |
4.1 总线锁死的恢复逻辑
I2C从设备偶尔会把SDA线拉死,尤其是通信中途被打断或者主设备发送半截数据时。一旦EEPROM检测到异常状态,可能一直等待一个完整的停止信号,SDA被它拽着不放。解决方法是让主机主动发送9个SCL时钟脉冲,把从设备内部的移位寄存器状态清干净,然后发送停止信号。我实际测试下来,这个办法对AT24C系列的恢复率很高,能解决大部分总线锁死问题,省去断电重启的麻烦。
4.2 逻辑分析仪的重要性
调I2C通信,强烈建议备一个逻辑分析仪,最便宜的二三十块钱的就能用。写驱动遇到ACK无应答、数据错乱这类问题时,逻辑分析仪抓一下起始条件、地址、数据帧和停止条件,基本一眼就能定位问题。比对着代码盲猜快得多。我调试上位机软件时也习惯在I2C的SCL和SDA上同时观察时序,确认每一位的电平翻转是否符合预期。
5. 进阶优化:参数存储的可靠性设计
驱动能跑通只是第一步,产品化过程中真正考验人的是可靠性设计。EEPROM本身硬件可靠性还不错,但系统级错误仍然会导致数据损坏,比如写入过程中掉电、写入非法地址、数据结构不完整等。所以参数存储层一定要做校验和备份,这个思路对AT24C32/64/128完全通用。
5.1 数据布局与CRC校验
在EEPROM中保存参数时,我建议把存储区按“信息头+数据+校验”的格式组织。信息头里放一个固定的魔数,用来判断该区域是否已经初始化。校验值用CRC16或者简单的累加和,每次写入时计算,每次读取时重新校验。如果校验失败,说明存储区数据不可信,需要走恢复流程。
具体布局可以这样定义:
- 0x0000 - 0x0003:魔数,比如0xAA55A55A,上电读取时先判断这个值。
- 0x0004 - 0x0005:数据版本号,方便以后升级参数结构。
- 0x0006 - 0x001F:业务参数区,具体按需定义。
- 0x0020 - 0x0021:CRC16校验值,覆盖上面的数据区。
这样设计的好处是,任何时候读出数据后,先校验CRC再信任内容。如果CRC不对,可以继续尝试读备份区。如果两个区都坏了,才恢复出厂默认值。这个策略虽然会占用双倍存储空间,但在产品里值得。
5.2 双备份与磨损均衡
EEPROM的擦写寿命通常在100万次左右,看起来很多,但如果系统每秒写一次参数,不到两周就会磨穿。因此需要做两点:一是尽量减少无意义的写入次数,只有参数真正变化时才写;二是必要时做磨损均衡。
双备份的思路是维护A/B两个参数区,每次写入时轮流写两个区。读取时先读A区,校验失败再读B区,如果B区校验通过,就把它恢复到A区。这样即使某次写入过程中掉电,也只损坏一个区,另一个区仍然可用。
磨损均衡则更进一步,把多个小区域轮流作为写入目标,避免一直擦写同一片地址。最简单的做法是增加一个“写入计数区”或者使用“滑动窗口”策略,每次从不同的偏移地址写入。不过对于大多数应用来说,双备份已经足够。我当时在做设备参数存储时就是A/B双区加CRC,用了半年多,没有出现一次参数丢失。
最后一个经验:如果你要存设备掉电时的实时状态(比如运行累计时间),建议用多次写入加末位有效的方式来抗掉电抖动,每次在同一个逻辑记录里追加写入,上电后按最新有效记录恢复。单纯靠EEPROM掉电保护是不现实的,因为写入过程本身需要时间,掉电时刻不可控,必须用软件来容错。
这套驱动和存储方案我在几个项目里都已经跑量,稳定性比较有数。代码不复杂,但每一处设计都有实际案例支撑。如果你在移植时遇到具体问题,欢迎带着现象和波形图来聊,这样比猜快得多。
本文还有配套的精品资源,点击获取