☰
STM32F103硬件I2C实战:AT24C02读写全流程与避坑指南
2026/10/7 7:53:10 网站建设 项目流程

1. 为什么AT24C02是I2C入门的最佳陪练

搞STM32的人几乎都绕不开I2C,而AT24C02这颗2Kbit的EEPROM,基本就是I2C实战的"标准靶子"。原因很实在:它便宜、好买、时序规整、手册清晰,而且掉电不丢数据这个特性,让它在参数存储、设备序列号、校准值保存这些场景里一直有活干。你拿它练手,练的不是一颗芯片,而是整套I2C主机控制的通用能力——起始、停止、应答、时序、地址、页写、轮询,全都能在这颗小芯片上跑一遍。

STM32F103的硬件I2C外设,江湖上名声比较复杂。有人说它稳,有人说它容易卡死。我的实际体会是:F103的硬件I2C能用,但你必须理解它的状态机逻辑,不能像用SPI那样"发完就走"。很多新手一上来就调库,结果总线一挂就懵了,根本不知道卡在哪一步。所以这篇我打算把AT24C02的读写全流程拆开讲,从硬件连接到寄存器操作,再到HAL库和寄存器两种写法,最后把常见的坑一个个摆出来。

这篇文章适合谁看?如果你刚搭好STM32F103最小系统,想找个外设练手,AT24C02是很好的起点;如果你已经会点灯、会串口打印,但I2C总是调不通,那这篇能帮你把时序和状态机彻底理清;如果你在用HAL库但只会调现成函数,不知道底层发生了什么,我也会把寄存器层面的东西补上。读完你应该能做到:不看例程,自己从零把AT24C02读写跑通,并且知道每一步为什么这么写。

先说清楚一个前提:AT24C02是2Kbit = 256字节的容量,按页组织,每页8字节。这个"页"的概念非常关键,后面讲页写的时候会反复用到。地址空间是0x00到0xFF,一个字节的地址就能覆盖,所以它的寻址比大容量EEPROM简单一档。

2. 硬件连接与上拉电阻:那些被忽略的细节

2.1 引脚连接与地址配置

AT24C02是8脚封装,核心引脚就几个:SDA、SCL、WP、A0/A1/A2,加上VCC和GND。SDA和SCL接STM32F103的I2C引脚,比如PB6(SCL)和PB7(SDA),这是I2C1的默认复用引脚。A0、A1、A2三个引脚决定器件地址的低3位,全部接地时,7位器件地址是1010000,也就是0x50。写操作时地址字节是0xA0,读操作是0xA1——注意这里说的是"地址字节",包含了读写位。

WP是写保护引脚,接高电平时芯片进入只读模式,接低电平才能写。调试阶段我建议直接接地,等确认读写都正常了,再考虑要不要用GPIO控制它做写保护。很多人调不通写操作,最后发现是WP悬空或者被拉高了,这种低级错误其实很常见。

2.2 上拉电阻到底该选多大

I2C是开漏总线,SDA和SCL都必须接上拉电阻,这一点没有商量余地。STM32F103的I2C引脚要配置成开漏复用输出(AF_OD),不能配成推挽。为什么?因为I2C允许多个设备共享总线,任何设备都只能把线拉低,不能主动拉高,拉高靠的是上拉电阻。如果配成推挽,两个设备一个想拉高一个想拉低,直接短路,芯片都可能烧。

上拉电阻的取值是个权衡。典型值是4.7kΩ,但这个值不是随便定的。电阻越小,上升沿越陡,能跑的速度越高,但静态电流越大;电阻越大,功耗越低,但上升沿变缓,高速时可能来不及拉高就被采样了。粗略估算:总线电容一般在100pF到200pF量级,上升时间τ≈R×C。取R=4.7kΩ、C=100pF,τ≈0.47μs,标准模式100kHz下完全够用。如果你跑400kHz快速模式,或者总线上挂了很多设备导致电容增大,就得把电阻降到2.2kΩ甚至1.8kΩ。

提示:如果你用杜邦线在面包板上搭,线长和接触电阻会让波形很难看。调试I2C时,示波器或者逻辑分析仪几乎是必备的,光靠"能不能读到数据"来判断,效率太低。

2.3 电平匹配问题

STM32F103是3.3V供电,AT24C02一般也是3.3V或5V都能工作。如果两者都接3.3V,直接连就行,不需要电平转换。但如果你用的是5V的AT24C02模块,而STM32是3.3V,那就要注意了:AT24C02的SDA/SCL在5V供电时,高电平接近5V,直接接STM32的3.3V引脚可能超出其耐压范围。这种情况下要么把AT24C02也降到3.3V供电,要么加电平转换电路。我个人的建议是统一用3.3V,省事又安全。

3. I2C时序的本质:把状态机刻进脑子里

3.1 起始、停止与应答的物理含义

I2C的时序图看着复杂,其实核心就三件事:起始条件、数据传输、停止条件。起始条件是SCL为高时,SDA从高变低;停止条件是SCL为高时,SDA从低变高。这两个条件必须由主机产生,而且SDA的变化必须发生在SCL为低的时候,否则会被误判成起始或停止。

应答(ACK)机制是I2C可靠性的关键。每传输8位数据后,接收方要把SDA拉低一个时钟周期,表示"我收到了"。如果接收方不拉低(NACK),发送方就知道出问题了。AT24C02在写操作时会应答,但在写周期内(内部擦写)不会响应任何命令,这时候主机发地址会收到NACK,这就是为什么写完之后要轮询等待。

3.2 AT24C02的写周期与应答轮询

AT24C02有个"写周期时间"(tWR),典型值5ms,最大可能到10ms。在这段时间内,芯片内部在把数据真正写进存储单元,它不会响应总线上的任何命令。如果你写完立刻读,大概率读到的是旧数据或者直接NACK。

正确的做法是应答轮询(Acknowledge Polling):写完一页后,主机反复发送起始条件加器件地址(写方向),如果收到ACK,说明芯片忙完了;如果收到NACK,就继续等。这比死等5ms要高效得多,尤其在连续写多页的时候。我见过不少人直接delay(10)了事,能用,但不够优雅,而且如果芯片实际需要更长时间,delay就不够了。

3.3 页写与跨页问题

AT24C02每页8字节。页写的意思是:你给一个起始地址,然后连续写最多8个字节,芯片内部地址会自动递增,但递增到页边界后会回卷到本页开头,而不是进入下一页。这是个非常容易踩的坑。

举个例子:从地址0x07开始写4个字节。0x07是第0页的最后一个字节,写完0x07后,地址会回卷到0x00,接下来的数据会覆盖0x00、0x01、0x02。你以为写到了0x08、0x09、0x0A,实际上把前面的数据覆盖了。所以写跨页数据时,必须手动分页,每页单独发起一次写操作。

操作类型最大连续字节地址行为注意事项
字节写1指定地址最简单,每次都要完整时序
页写8页内递增,到边界回卷必须按页对齐或手动分页
连续读无限制全地址空间递增,到0xFF回卷到0x00读完发NACK+停止

4. 寄存器级驱动:从零手写I2C通信

4.1 GPIO与I2C外设初始化

先看寄存器层面的配置。以I2C1、PB6/PB7为例,步骤是:开GPIOB和I2C1的时钟,配置PB6/PB7为复用开漏输出,然后配置I2C的时钟控制寄存器。

// 使能时钟 RCC->APB2ENR |= RCC_APB2ENR_IOPBEN; RCC->APB1ENR |= RCC_APB1ENR_I2C1EN; // PB6(SCL) PB7(SDA) 复用开漏 50MHz GPIOB->CRL &= ~(0xFF << 24); GPIOB->CRL |= (0xEE << 24); // 复用开漏,最大速度50MHz // I2C时钟:PCLK1=36MHz,目标100kHz // CCR = 36MHz / (2 * 100kHz) = 180 I2C1->CR2 = 36; // 输入时钟频率36MHz I2C1->CCR = 180; // 标准模式 I2C1->TRISE = 37; // 最大上升时间 36+1 I2C1->CR1 |= I2C_CR1_PE; // 使能I2C

这里CCR的计算要解释一下:标准模式下,CCR = PCLK1 / (2 × 目标频率)。36MHz / (2 × 100kHz) = 180。TRISE是最大上升时间,标准模式规定1000ns,对应36MHz下一个周期约27.8ns,1000/27.8≈36,加1就是37。这些值不是拍脑袋来的,手册RM0008里有明确公式。

4.2 完整的字节写函数

寄存器操作I2C最麻烦的是状态机。每一步都要等对应的标志位,然后检查状态码。下面是一个字节写的核心流程:

uint8_t AT24C02_WriteByte(uint8_t addr, uint8_t data) { // 1. 产生起始条件 I2C1->CR1 |= I2C_CR1_START; while(!(I2C1->SR1 & I2C_SR1_SB)); // 等SB置位 // 2. 发送器件地址+写 I2C1->DR = 0xA0; while(!(I2C1->SR1 & I2C_SR1_ADDR)); // 等ADDR (void)I2C1->SR1; (void)I2C1->SR2; // 清ADDR // 3. 发送内存地址 while(!(I2C1->SR1 & I2C_SR1_TXE)); I2C1->DR = addr; // 4. 发送数据 while(!(I2C1->SR1 & I2C_SR1_TXE)); I2C1->DR = data; // 5. 等待传输完成 while(!(I2C1->SR1 & I2C_SR1_BTF)); // 6. 停止条件 I2C1->CR1 |= I2C_CR1_STOP; return 0; }

注意第2步里读SR1再读SR2这个操作,这是清ADDR标志的标准动作,少读一个都会导致状态机卡住。这是F103硬件I2C最经典的坑之一。

4.3 读操作的时序差异

读比写多一个"重启"步骤。流程是:起始→发写地址→发内存地址→重启→发读地址→读数据→发NACK→停止。最后读一个字节时要发NACK,告诉从机"我不再需要数据了",然后主机产生停止条件。

uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t data; // 起始 + 写地址 I2C1->CR1 |= I2C_CR1_START; while(!(I2C1->SR1 & I2C_SR1_SB)); I2C1->DR = 0xA0; while(!(I2C1->SR1 & I2C_SR1_ADDR)); (void)I2C1->SR1; (void)I2C1->SR2; // 发内存地址 while(!(I2C1->SR1 & I2C_SR1_TXE)); I2C1->DR = addr; while(!(I2C1->SR1 & I2C_SR1_BTF)); // 重启 + 读地址 I2C1->CR1 |= I2C_CR1_START; while(!(I2C1->SR1 & I2C_SR1_SB)); I2C1->DR = 0xA1; while(!(I2C1->SR1 & I2C_SR1_ADDR)); (void)I2C1->SR1; (void)I2C1->SR2; // 准备NACK和停止 I2C1->CR1 &= ~I2C_CR1_ACK; I2C1->CR1 |= I2C_CR1_STOP; // 读数据 while(!(I2C1->SR1 & I2C_SR1_RXNE)); data = I2C1->DR; I2C1->CR1 |= I2C_CR1_ACK; // 恢复ACK return data; }

这段代码里,NACK和STOP的设置顺序很讲究。要在读DR之前就把ACK清掉、STOP置上,这样读完最后一个字节后硬件会自动发NACK并产生停止条件。顺序反了就会多读一个字节或者总线卡住。

5. HAL库写法与寄存器写法的取舍

5.1 HAL库的便利与隐藏成本

用CubeMX生成工程,HAL库把I2C封装成了HAL_I2C_Mem_Write和HAL_I2C_Mem_Read,一行就能读写EEPROM:

HAL_I2C_Mem_Write(&hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100); HAL_Delay(5); HAL_I2C_Mem_Read(&hi2c1, 0xA0, addr, I2C_MEMADD_SIZE_8BIT, &data, 1, 100);

方便是真方便,但代价是你不知道里面发生了什么。HAL库的超时机制、错误处理、状态机都在后台跑,一旦总线出问题,返回个HAL_ERROR或者HAL_TIMEOUT,你很难定位到底卡在哪一步。而且HAL库的I2C实现里有一些已知的边界问题,比如在特定时序下会进入死循环等待。

我的建议是:产品开发用HAL库提高效率,但调试阶段一定要能看懂寄存器层面的东西。当HAL库返回错误时,你能通过读SR1、SR2寄存器判断是总线忙、还是NACK、还是仲裁丢失,这比盲目重试有用得多。

5.2 两种写法的性能对比

我实测过同一颗AT24C02,寄存器写法和HAL库写法在单字节写上的耗时差异。寄存器写法从起始到停止大约几十微秒,加上5ms写周期,总时间主要被写周期占据。HAL库因为有多层函数调用和超时检查,单次操作会多出几微秒到十几微秒,但在EEPROM这种毫秒级写周期的场景下,这点差异可以忽略。

真正影响性能的是页写。如果你要写256字节,按字节写需要256次写周期,每次5ms,总共1.28秒。按页写,每页8字节,需要32次写周期,总共160ms。差了8倍。所以只要数据量超过1字节,就应该用页写。

写入方式写周期次数256字节总耗时(按5ms/周期)适用场景
单字节写256约1280ms零星参数更新
页写(8字节)32约160ms批量数据存储
连续读0微秒级读取无限制

5.3 页写函数的实现要点

页写函数的关键是处理跨页。我的做法是先算出当前地址到页边界的剩余空间,取剩余空间和待写长度的较小值作为本次写入量,写完一页后重新计算下一页。

void AT24C02_WritePage(uint8_t addr, uint8_t *buf, uint16_t len) { while(len > 0) { uint8_t page_remain = 8 - (addr % 8); // 本页剩余空间 uint8_t write_len = (len < page_remain) ? len : page_remain; // 发起一次页写 I2C_Start(); I2C_SendByte(0xA0); I2C_WaitAck(); I2C_SendByte(addr); I2C_WaitAck(); for(uint8_t i = 0; i < write_len; i++) { I2C_SendByte(buf[i]); I2C_WaitAck(); } I2C_Stop(); // 应答轮询等待写完成 AT24C02_WaitStandby(); addr += write_len; buf += write_len; len -= write_len; } }

这个8 - (addr % 8)就是页内剩余空间的计算。比如addr=0x07,7%8=7,8-7=1,本页只能再写1个字节。这个逻辑必须写对,否则跨页数据就会错乱。

6. 调试实录:那些让I2C卡死的瞬间

6.1 总线忙死锁与恢复

F103的硬件I2C有个让人头疼的问题:如果通信过程中出现异常(比如从机突然断电、总线被干扰),I2C外设可能进入BUSY状态,而且怎么复位都不出来。这时候I2C1->SR2的BUSY位一直是1,任何新的起始条件都发不出去。

我遇到过一次,是在热插拔AT24C02模块的时候。解决办法是手动模拟时序恢复总线:把SCL和SDA临时切成普通GPIO,发9个时钟脉冲,让从机把残留的数据位移完,然后手动产生一个停止条件,再把引脚切回I2C复用模式。

void I2C_BusRecover(void) { // 切为普通开漏输出 GPIOB->CRL &= ~(0xFF << 24); GPIOB->CRL |= (0x77 << 24); // 通用开漏 GPIOB->BSRR = GPIO_Pin_6; // SCL高 GPIOB->BSRR = GPIO_Pin_7; // SDA高 delay_us(5); for(int i = 0; i < 9; i++) { GPIOB->BRR = GPIO_Pin_6; // SCL低 delay_us(5); GPIOB->BSRR = GPIO_Pin_6; // SCL高 delay_us(5); } // 手动停止条件:SCL高时SDA由低变高 GPIOB->BRR = GPIO_Pin_7; delay_us(5); GPIOB->BSRR = GPIO_Pin_6; delay_us(5); GPIOB->BSRR = GPIO_Pin_7; delay_us(5); // 切回复用开漏 GPIOB->CRL &= ~(0xFF << 24); GPIOB->CRL |= (0xEE << 24); }

这段恢复代码我建议直接放进你的驱动里,在初始化时先调用一次,能解决大部分"上电就BUSY"的问题。

6.2 写保护引脚导致的写失败

前面提过WP引脚,这里再强调一次。我见过一个案例,AT24C02的WP接了上拉电阻,结果写操作全部失败,读操作正常。因为读不受写保护影响,所以现象很迷惑——能读不能写。排查了半天才发现是WP的问题。所以调试写操作时,第一件事就是确认WP接地。

6.3 地址计算错误

AT24C02的地址是8位的,0x00到0xFF。但如果你用的是AT24C32或更大容量的,地址就是16位的,需要发两个地址字节。有人拿AT24C02的代码去驱动AT24C32,只发一个地址字节,结果读写全乱。这个坑在换芯片时特别容易踩。判断方法很简单:看手册里的地址字节数,2Kbit及以下用8位地址,4Kbit及以上用16位地址。

6.4 上拉电阻缺失或阻值不当

没有上拉电阻,SDA和SCL永远是低电平,总线直接死。阻值太大,上升沿太慢,高速下数据出错。我建议手头常备4.7kΩ和2.2kΩ两种,标准模式用4.7kΩ,快速模式或者总线设备多用2.2kΩ。用示波器看波形时,重点看上升沿是否在规范时间内到达高电平,如果是个缓慢的斜坡,就是电阻太大了。

7. 从AT24C02延伸到更实用的存储方案

7.1 参数存储的结构化设计

实际项目里,我们很少直接按字节读写EEPROM,而是把参数组织成结构体。比如一个设备配置:

typedef struct { uint16_t device_id; uint8_t baudrate_index; uint16_t calibration; uint8_t checksum; } DeviceConfig_t;

存的时候把结构体整体写入,读的时候整体读出,最后校验checksum。这样比零散地读写单个字节可靠得多。但要注意结构体的字节对齐问题,不同编译器可能插入填充字节,导致存储布局不一致。稳妥的做法是手动序列化成字节数组再写。

7.2 磨损均衡的朴素实现

EEPROM有擦写寿命,AT24C02标称100万次。如果你频繁写同一个地址(比如每秒记录一次数据),很快就会写坏。朴素的磨损均衡思路是:把存储区分成多个块,轮流写入,用一个指针记录当前写到哪一块。这样每个块的擦写次数就被摊薄了。对于AT24C02这种小容量,可以简单地把256字节分成16块,每块16字节,轮流使用。

7.3 数据校验与掉电保护

EEPROM写入过程中如果掉电,可能写入一半数据,导致内容损坏。常见的保护手段是双备份加校验:同一份数据存两个副本,每个副本带CRC校验。读取时如果主副本校验失败,就用备份副本恢复。写入时先写备份,再写主副本,确保任何时候至少有一份完整数据。这个策略在工业设备里很常见,虽然多占一倍空间,但可靠性提升明显。

8. 我踩过的坑和几条实在建议

调试I2C这些年,最大的体会是:逻辑分析仪比万用表有用一百倍。I2C是时序协议,用万用表只能看静态电平,根本看不出起始条件、应答位、数据位。一个几十块的逻辑分析仪,配合上位机软件,能把整条总线的通信过程解码出来,哪一步NACK、哪一步数据不对,一目了然。这个投入绝对值得。

第二条建议是先跑通单字节读写,再上页写和连续读。很多人一上来就写复杂的批量读写函数,结果出问题时分不清是时序问题还是逻辑问题。先用最简单的单字节写、单字节读验证硬件和基础时序,确认无误后再逐步增加复杂度。这个调试顺序能帮你省下大量时间。

第三条是关于HAL库的:不要完全依赖HAL库的错误返回值。HAL库返回HAL_BUSY、HAL_ERROR、HAL_TIMEOUT,但这些状态背后的原因需要你自己去读寄存器判断。我习惯在HAL库调用失败后,手动读一下SR1和SR2,把状态码打印到串口,这样能快速定位是NACK、总线忙还是其他问题。

最后说一个容易被忽略的点:AT24C02的写周期内,芯片不响应任何命令,包括读。所以写完之后的应答轮询是必须的,不能省。我见过有人写完立刻读,读到旧数据,以为是写失败,其实是芯片还在忙。加上应答轮询,这个问题就消失了。

这套AT24C02的读写流程,看起来简单,但把每个细节都抠清楚,其实涵盖了I2C通信的绝大部分知识点。把这颗芯片吃透,再去驱动OLED、传感器、RTC这些I2C设备,基本就是换个器件地址和寄存器地址的事。底层的东西是相通的,这也是为什么我一直建议新手从AT24C02入手学I2C。

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

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

立即咨询