☰
STM32F103 I2C实战:AT24C02 EEPROM读写从入门到避坑
2026/10/30 1:14:33 网站建设 项目流程

1. 为什么AT24C02是I2C入门的最佳练手对象

搞STM32的朋友大概率都经历过这个阶段:GPIO点灯玩腻了,串口打印也调通了,想找个外设练练手,结果一看SPI、CAN、USB这些协议,寄存器一大堆,直接劝退。这时候I2C加AT24C02的组合就特别适合拿来过渡——协议本身不算复杂,AT24C02这颗芯片的逻辑又足够简单,读写的全流程能跑通一遍,基本上I2C的硬件层和协议层就都有感觉了。

AT24C02是一颗2Kbit容量的EEPROM,换算过来就是256字节,页大小8字节,I2C地址固定部分是1010,后面三位由A2/A1/A0引脚决定。这颗芯片在市面上随处可见,价格便宜到可以忽略不计,而且它支持100kHz和400kHz两种速率,宽电压范围1.8V到5.5V,跟STM32F103的3.3V系统配合起来毫无压力。很多人拿它做参数存储、掉电保存、设备ID记录这类小数据量的持久化场景,实际产品里用得非常多。

这篇文章面向的是已经能跑通STM32F103最小系统、会用Keil MDK或者STM32CubeMX搭建工程、对GPIO和串口有一定了解的读者。我会从硬件连接开始,把I2C的时序逻辑、AT24C02的读写协议、STM32F103的I2C外设配置、代码实现、常见问题排查这几个环节全部拆开讲一遍。不管你是用标准库还是HAL库,核心逻辑都是通的,区别只在于API的调用方式。

注意:AT24C02虽然简单,但它的写周期时间(tWR)典型值是5ms,最大不超过10ms。这个参数在连续写操作时如果不处理,会直接导致数据写不进去,后面我会详细说怎么规避。

2. 硬件层:最小系统、上拉电阻与地址配置

2.1 STM32F103最小系统与AT24C02的接线方案

先看硬件连接。STM32F103C8T6最小系统板上,I2C1的默认引脚是PB6(SCL)和PB7(SDA),这两个引脚需要配置为复用开漏模式。AT24C02这边,SCL和SDA分别接到PB6和PB7,VCC接3.3V,GND接地,WP引脚接地表示允许写入,如果接高电平就进入写保护状态,只能读不能写。

上拉电阻是必须的,I2C总线是开漏输出,没有上拉电阻的话总线根本拉不高。一般用4.7kΩ的电阻,一端接3.3V,另一端分别接SCL和SDA。有些模块板上已经集成了上拉电阻,接线前先确认一下,如果模块上已经有了,就不需要额外再加,否则并联之后阻值变小,可能导致上升沿太陡,反而引起信号完整性问题。

A2/A1/A0三个地址引脚全部接地,这时候AT24C02的7位I2C地址就是1010000,即0x50。如果三个引脚都接VCC,地址就是1010111,即0x57。同一个I2C总线上最多可以挂8片AT24C02,通过地址引脚区分,这个设计在做多路参数存储的时候很方便。

2.2 上拉电阻选型的计算依据

上拉电阻的选型不是随便拍脑袋定的。I2C总线的上升时间由RC时间常数决定,R是上拉电阻,C是总线电容。标准模式100kHz下,上升时间要求不超过1000ns;快速模式400kHz下,上升时间不超过300ns。总线电容一般估算在100pF到200pF之间,取200pF来算,快速模式下RC要小于300ns,R就要小于1.5kΩ。但电阻太小又会导致功耗增加,低电平时的灌电流也会变大。

实际工程中,4.7kΩ是最常用的折中值,100kHz下完全够用,400kHz下如果总线电容不大也能跑。如果通信不稳定,可以试着降到2.2kΩ。我实测下来,在面包板上搭电路,线长不超过10cm的情况下,4.7kΩ跑400kHz基本没问题。但如果用杜邦线拉得比较长,电容增大,就得降到2.2kΩ甚至1kΩ。

提示:如果你用的是STM32F103最小系统板加AT24C02模块的组合,先拿万用表量一下模块上SCL和SDA对VCC的阻值,确认有没有板载上拉。有的话就不要再外接了。

2.3 地址引脚配置与多设备挂载

AT24C02的7位地址格式是1 0 1 0 A2 A1 A0,最低位是读写位。写操作时发送0xA0(0x50左移一位),读操作时发送0xA1。这个地址在代码里通常写成0xA0和0xA1,或者用宏定义区分。

如果总线上挂多片AT24C02,比如两片,一片A0接地地址是0x50,另一片A0接VCC地址是0x51。代码里定义两个设备地址宏,读写的时候切换就行。注意AT24C02的地址引脚不能悬空,必须明确接VCC或GND,悬空会导致地址不确定,通信失败。

3. I2C协议层:时序逻辑与AT24C02的读写规则

3.1 I2C起始、停止与应答的时序本质

I2C协议的核心就是两根线配合出来的几种特定波形。起始条件(START)是SCL为高电平时,SDA从高变低;停止条件(STOP)是SCL为高电平时,SDA从低变高。这两个条件必须由主机产生,从机只负责检测。

数据传输的时候,SCL为高电平期间SDA必须保持稳定,数据在SCL低电平期间变化。每传输8位数据后,第9个时钟周期主机释放SDA,从机拉低SDA表示应答(ACK),保持高电平表示非应答(NACK)。主机读数据的时候,读完最后一个字节要发送NACK,然后发停止条件。

这些时序在STM32F103的硬件I2C外设里是自动处理的,你只需要调用发送和接收函数,硬件会帮你生成正确的波形。但如果你用GPIO模拟I2C,就得手动控制引脚的电平变化,每个时钟周期都要精确控制。模拟的好处是引脚灵活,不受硬件外设限制,缺点是占用CPU时间,高速通信时容易出问题。

3.2 AT24C02的字节写与页写流程

AT24C02的写操作分两种:字节写和页写。字节写的流程是:起始条件 → 发送设备地址(写)→ 等待ACK → 发送字地址 → 等待ACK → 发送数据字节 → 等待ACK → 停止条件。然后芯片进入内部写周期,大约5ms后才能响应下一次操作。

页写是一次性写入最多8个字节,流程类似,但发送完第一个数据字节后不发送停止条件,而是继续发送后续数据字节,直到发完或者凑满一页。AT24C02的页大小是8字节,如果跨页写,地址会自动回卷到当前页的开头,覆盖之前的数据。比如从地址0x07开始写8个字节,第1个字节写到0x07,第2个字节写到0x00,第3个写到0x01,以此类推。这个回卷特性很容易踩坑,写跨页数据的时候必须手动分页。

3.3 当前地址读、随机读与顺序读的区别

读操作比写操作稍微复杂一点,分三种模式。当前地址读是直接发送设备地址(读),然后接收数据,地址指针是上次操作后的位置。随机读是先发送设备地址(写),再发送要读的字地址,然后重新发送起始条件,发送设备地址(读),最后接收数据。顺序读是在随机读的基础上连续接收多个字节,每接收一个字节主机发送ACK,接收最后一个字节时发送NACK。

实际用的时候,随机读是最常用的,因为每次都要指定读取的地址。顺序读适合批量读取连续地址的数据,比如一次读出一整页。注意读操作不需要等待写周期,可以连续进行。

4. STM32F103的I2C外设配置与代码实现

4.1 硬件I2C与软件模拟I2C的选型对比

STM32F103的硬件I2C外设一直有个“不太好用”的名声,早期标准库的I2C驱动确实存在一些死锁问题,尤其是在总线被拉低或者从机不响应的时候容易卡住。但HAL库出来之后,配合CubeMX生成的代码,硬件I2C的稳定性好了很多。我实测在F103C8T6上用HAL库跑400kHz,读写AT24C02连续几千次没有出现死锁。

硬件I2C的优点是速度快、CPU占用低、时序精确,缺点是引脚固定,调试的时候如果出问题不太好定位。软件模拟I2C的优点是引脚随便选、时序完全可控、调试方便,缺点是占用CPU、速度受限、代码量大。如果你只是读写AT24C02这种低速设备,两种方式都行。如果总线上还有其他高速设备,建议用硬件I2C。

对比项硬件I2C软件模拟I2C
速度最高400kHz通常100kHz以下
CPU占用低高
引脚灵活性固定任意GPIO
调试难度较高较低
代码量少多
稳定性HAL库下良好取决于延时精度

4.2 CubeMX配置I2C1的关键参数

用CubeMX配置I2C1的时候,几个关键参数需要留意。I2C Speed Mode选Standard或者Fast,对应100kHz和400kHz。Clock Speed根据实际需求填,一般填400000。Duty Cycle在快速模式下选2:1或者16:9,2:1适合总线电容小的情况,16:9适合电容大的情况。Analog Filter和Digital Filter都使能,可以滤掉一些毛刺。

GPIO设置里,PB6和PB7自动配置为复用开漏,上拉电阻选择Very High或者外部上拉。如果模块上已经有上拉,这里选No Pull。NVIC里不需要开I2C中断,轮询方式就够了。生成代码后,HAL库会初始化I2C外设,你只需要调用HAL_I2C_Mem_Write和HAL_I2C_Mem_Read这两个函数就能完成读写。

4.3 字节写、页写与随机读的代码实现

先定义设备地址和超时时间:

#define AT24C02_ADDR_W 0xA0 #define AT24C02_ADDR_R 0xA1 #define AT24C02_TIMEOUT 100

字节写函数:

uint8_t AT24C02_WriteByte(uint16_t addr, uint8_t data) { return HAL_I2C_Mem_Write(&hi2c1, AT24C02_ADDR_W, addr, I2C_MEMADD_SIZE_8BIT, &data, 1, AT24C02_TIMEOUT); }

页写函数,注意分页处理:

uint8_t AT24C02_WritePage(uint16_t addr, uint8_t *data, uint8_t len) { uint8_t page_offset = addr % 8; if (page_offset + len > 8) { len = 8 - page_offset; } return HAL_I2C_Mem_Write(&hi2c1, AT24C02_ADDR_W, addr, I2C_MEMADD_SIZE_8BIT, data, len, AT24C02_TIMEOUT); }

随机读函数:

uint8_t AT24C02_ReadByte(uint16_t addr, uint8_t *data) { return HAL_I2C_Mem_Read(&hi2c1, AT24C02_ADDR_R, addr, I2C_MEMADD_SIZE_8BIT, data, 1, AT24C02_TIMEOUT); }

顺序读函数:

uint8_t AT24C02_ReadBuffer(uint16_t addr, uint8_t *buf, uint16_t len) { return HAL_I2C_Mem_Read(&hi2c1, AT24C02_ADDR_R, addr, I2C_MEMADD_SIZE_8BIT, buf, len, AT24C02_TIMEOUT); }

写完一个字节后必须延时至少5ms才能进行下一次写操作,这个延时可以用HAL_Delay实现,也可以用一个循环等待。读操作不需要延时。

4.4 写周期等待与超时处理的实现细节

写周期等待是AT24C02驱动里最容易忽略的地方。芯片在收到停止条件后开始内部写,这段时间内不会响应任何I2C命令。如果你紧接着发下一个写命令,芯片不会应答,HAL库会返回HAL_ERROR或者HAL_TIMEOUT。

正确的做法是每次写操作后延时5ms到10ms。如果觉得固定延时浪费时间,可以用应答查询的方式:发送起始条件加设备地址,如果收到ACK说明写周期结束,可以继续操作;如果收到NACK就继续等待。这种方式比固定延时效率高,但代码复杂一些。

void AT24C02_WaitReady(void) { uint32_t timeout = 0; while (HAL_I2C_IsDeviceReady(&hi2c1, AT24C02_ADDR_W, 1, 100) != HAL_OK) { timeout++; if (timeout > 100) break; } }

HAL_I2C_IsDeviceReady这个函数就是专门用来做应答查询的,内部会发送设备地址并检测ACK。用这个函数替代固定延时,代码更健壮。

5. 实操验证:从写入到读出的完整流程

5.1 测试用例设计与数据校验方法

验证AT24C02读写是否正常,我一般分三步走。第一步写一个字节到地址0x00,延时后读回来对比。第二步写一串数据到地址0x10开始的位置,比如0x01到0x08共8个字节,正好一页,读回来对比。第三步跨页写,从地址0x05开始写10个字节,验证分页逻辑是否正确。

数据校验用简单的memcmp就行,如果读回来的数据和写入的不一致,就打印出具体哪个地址对不上。我习惯在串口里输出十六进制的原始数据,方便对比。

uint8_t write_buf[8] = {0x11,0x22,0x33,0x44,0x55,0x66,0x77,0x88}; uint8_t read_buf[8] = {0}; AT24C02_WritePage(0x10, write_buf, 8); AT24C02_WaitReady(); AT24C02_ReadBuffer(0x10, read_buf, 8); if (memcmp(write_buf, read_buf, 8) == 0) { printf("Page write/read OK\r\n"); } else { printf("Data mismatch!\r\n"); }

5.2 用逻辑分析仪抓I2C波形验证时序

代码跑通了不代表时序没问题。我强烈建议用逻辑分析仪抓一下SCL和SDA的波形,确认起始条件、地址字节、ACK位、数据字节、停止条件都符合预期。特别是ACK位,如果从机没有拉低SDA,说明地址不对或者芯片没响应。

逻辑分析仪上看到的现象应该是:起始条件后,SCL出现8个时钟脉冲,对应地址字节,第9个脉冲时SDA被从机拉低。然后又是8个脉冲对应字地址,第9个脉冲ACK。接着8个脉冲对应数据,第9个脉冲ACK。最后停止条件。整个波形的SCL高电平期间SDA保持稳定,低电平期间SDA变化。

如果发现SCL被拉低不释放,大概率是总线死锁,需要重新初始化I2C外设或者手动发送9个时钟脉冲解锁。如果发现ACK位是高电平,说明从机没应答,检查设备地址和硬件连接。

5.3 连续读写压力测试与数据保持验证

单次读写通过之后,我会跑一个压力测试:连续写256个字节,每个字节写入后读回校验,循环100次。这个测试能暴露写周期等待不充分、地址回卷、总线死锁等问题。我实测下来,如果每次写后不加延时,大概写到第3个字节就会失败。加上HAL_I2C_IsDeviceReady等待后,连续跑1000次没有出错。

数据保持验证是断电后重新上电,读取之前写入的数据,确认没有丢失。AT24C02的数据保持时间标称100年,实际使用中只要不超出擦写次数(100万次),数据可靠性很高。我有一块测试板写了数据放了两年多,重新上电读出来还是对的。

6. 常见问题排查与避坑经验

6.1 通信失败:从地址、上拉、时序三个方向排查

I2C通信失败是最常见的问题,排查顺序我一般是从地址开始。先用HAL_I2C_IsDeviceReady扫描0x50到0x57这8个地址,看哪个能应答。如果都不应答,检查硬件连接:VCC有没有电、GND有没有共地、SCL和SDA有没有接反、上拉电阻有没有装。

地址对了但通信不稳定,重点查上拉电阻和线长。杜邦线太长、上拉电阻太大、总线电容过大都会导致波形上升沿变缓,高速通信时数据出错。把速率降到100kHz试试,如果降速后正常,说明是信号完整性问题。

时序问题一般出在软件模拟I2C上,延时不够或者延时太长都会导致时序不满足。用逻辑分析仪抓波形,对照AT24C02的数据手册看时序参数,逐个调整。

6.2 写入无效:写周期等待与WP引脚的检查

写入无效分两种情况:一种是完全写不进去,读出来全是0xFF或者0x00;另一种是偶尔写失败,大部分时候正常。完全写不进去先查WP引脚,如果WP接高电平,芯片处于写保护状态,所有写操作都会被忽略。把WP接地再试。

偶尔写失败大概率是写周期等待不够。AT24C02的tWR最大10ms,如果你只延时了1ms,芯片还没写完你就发下一个命令,肯定失败。把延时加到10ms,或者用HAL_I2C_IsDeviceReady做应答查询。

还有一种情况是页写跨页导致数据被覆盖。比如你从地址0x07开始写8个字节,第1个字节写到0x07,后面的字节回卷到0x00到0x06,把之前的数据覆盖了。写之前先算一下当前地址到页边界的剩余空间,不够一页就分两次写。

6.3 总线死锁:SCL被拉低不释放的恢复方法

总线死锁的表现是SCL一直为低电平,I2C外设无法产生起始条件。这种情况通常发生在从机正在发送数据时主机突然复位,从机还在等待时钟脉冲,把SDA拉低不放。恢复方法是把SCL配置为GPIO输出,手动发送9个时钟脉冲,让从机把剩下的数据发完,然后发送停止条件。

void I2C_BusRecovery(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); for (int i = 0; i < 9; i++) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); } HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); HAL_Delay(1); MX_I2C1_Init(); }

这段代码先把SCL和SDA切成普通GPIO,手动发9个时钟,再补一个停止条件,最后重新初始化I2C外设。实测下来,大部分死锁都能用这个方法恢复。

6.4 常见问题速查表

现象可能原因排查方法解决措施
设备无应答地址错误扫描0x50-0x57确认A2/A1/A0引脚
设备无应答硬件连接问题量VCC、GND、SCL、SDA重新接线,检查上拉
写入后读出为0xFFWP引脚拉高量WP引脚电平WP接地
偶尔写失败写周期等待不足增加延时到10ms用IsDeviceReady查询
跨页数据错乱页回卷检查写入地址和长度分页写入
SCL被拉低总线死锁量SCL电平手动发9个时钟恢复
高速通信出错上拉电阻过大抓波形看上升沿减小上拉电阻
数据偶尔跳变电源噪声示波器看VCC纹波加去耦电容

提示:AT24C02的VCC引脚旁边一定要加一个0.1uF的去耦电容,位置尽量靠近芯片。这个电容能滤掉电源上的高频噪声,对数据可靠性影响很大。我遇到过好几次数据偶尔出错的情况,最后都是去耦电容没加或者容值不对导致的。

7. 从AT24C02延伸出去的几个实用方向

AT24C02跑通之后,I2C的基本操作就没什么障碍了。接下来可以拿其他I2C设备练手,比如OLED屏幕(SSD1306驱动)、温湿度传感器(SHT30、AHT20)、气压计(BMP280)、实时时钟(DS3231)等等。这些设备的读写逻辑跟AT24C02大同小异,区别在于寄存器地址和数据格式。

如果你要做参数存储,AT24C02的256字节可能不够用,可以换成AT24C32、AT24C64这些容量更大的型号,地址格式从单字节变成双字节,代码里把I2C_MEMADD_SIZE_8BIT改成I2C_MEMADD_SIZE_16BIT就行。再大一点可以用AT24C256,32KB容量,足够存不少配置数据。

软件模拟I2C的代码也值得自己写一遍,不依赖HAL库,纯GPIO操作。写完之后你对I2C时序的理解会深很多,以后遇到任何I2C设备都能快速上手。模拟I2C的延时可以用DWT计数器或者SysTick来实现微秒级精度,比HAL_Delay的毫秒级精度更适合高速通信。

最后分享一个我在实际项目中用到的小技巧:把AT24C02的读写封装成带CRC校验的接口,写入的时候计算CRC一起存进去,读取的时候校验CRC。这样即使偶尔出现位翻转,也能及时发现并重写。CRC用简单的累加和或者异或校验就行,不需要上CRC16那么复杂。这个做法在工业现场设备里很实用,能避免因为EEPROM数据出错导致的异常行为。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询