1. 项目来源与硬件背景:为什么这组合是入门首选
做单片机开发,尤其是刚从51、AVR转过来玩STM32的,第一次接触I2C总线,我强烈建议从AT24C02这块芯片入手。原因很简单:它便宜、时序标准、逻辑清晰,而且是I2C协议里最典型的一类从机设备——带器件地址、带片内寄存器寻址、支持随机读和顺序读。你把它跑通了,市面上90%的I2C传感器(比如温湿度SHT30、陀螺仪MPU6050、OLED屏SSD1306)的驱动框架你基本就心里有数了。
STM32F103的I2C外设是个很有意思的"历史遗留"问题——它的硬件I2C模块早期版本存在一些bug传闻,导致很多老工程师宁可拿GPIO软件模拟I2C,也不碰硬件外设。实际上在F103系列里,硬件I2C只要配置正确,是完全可以稳定工作的,只不过它的事件标志管理比较绕,不像软件模拟那样每个时序步骤都清清楚楚。这个坑我在后面会专门讲,先说结论:如果你是为了学协议本身,软件模拟I2C能看到每个bit怎么翻;如果你是为了工程效率,硬件I2C配合中断或DMA省CPU资源。这篇文章两种方式都会覆盖,但主线用软件模拟的方式把时序讲透,这样你理解协议后,再去调硬件I2C就事半功倍了。
先看硬件连接。AT24C02是8引脚SOIC或者DIP封装,引脚定义如下:
- A0、A1、A2:地址选择引脚,接地或接VCC决定器件I2C地址的低三位。
- WP:写保护引脚,接高电平禁止写入,接地或悬空允许写入。
- SCL:时钟线,接STM32的PB6(硬件I2C1_SCL)或任意推挽输出引脚(软件模拟)。
- SDA:数据线,接STM32的PB7(硬件I2C1_SDA)或任意引脚。
- VCC、GND:电源。
这里最容易被新手忽略的是上拉电阻。I2C总线是开漏结构,SCL和SDA两根线必须各自接一个上拉电阻到3.3V或5V,阻值常见4.7kΩ到10kΩ。如果你用的是STM32F103最小系统板,板载的EEPROM芯片(有些板子会焊一个AT24C02在背面)已经帮你处理好上拉了,但如果你是自己飞线搭的电路,一定要加上拉电阻,否则总线拉不高,通信必失败。我实测过,不加上拉电阻时,SDA线在高电平期间被拉成1.2V左右的浮空电平,读回来的数据全是0xFF。
供电方面,AT24C02工作电压范围是1.8V到5.5V,STM32F103是3.3V逻辑,直接共电源3.3V即可,不需要电平转换。至于热词里提到的"stm32f103 5v转3.3v电路",那是另一个话题——如果你用5V供电的模块去接3.3V的MCU,注意别让5V直接灌进IO口,AT24C02没有这个问题,但I2C总线上如果挂了5V的器件,就得考虑电平匹配了。通常情况下,系统内所有I2C器件统一用3.3V供电是最省心的方案。
2. 协议核心:从时序图到代码实现的思维转换
I2C协议其实特别像两个人对话时的约定:说话前先举手示意(起始条件),说完一句要等对方回应(应答位),最后说"我说完了"(停止条件)。理解了这个思维模型,再看时序图就不会觉得抽象了。
2.1 起始条件与停止条件的精准卡点
I2C的起始条件是:SCL为高电平期间,SDA从高电平跳变到低电平。停止条件是:SCL为高电平期间,SDA从低电平跳变到高电平。注意这两个条件都要求SDA的跳变发生在SCL为高的时候,这跟数据传输阶段"数据只能在SCL低电平期间变化"是相反的规则。
为什么要这样设计?因为如果数据和起始/停止条件不加区分,从机就无法判断总线上正在发生的是"传输数据"还是"开始通信"。起始和停止条件在物理上就是SDA在SCL高电平期的跳变,这个跳变在数据传输中是被禁止的,所以从机能明确识别。
软件模拟时,关键是用延时函数保证时序的占空比。标准I2C速率有100kHz(标准模式)和400kHz(快速模式),AT24C02支持400kHz。如果用GPIO模拟,通常做到100kHz就足够用了,毕竟EEPROM写入本身就要等5ms左右的内部写周期,速率再高,写操作瓶颈还是在芯片内部闪存。代码实现如下:
// 微秒级延时函数,注意实际延时时间需根据主频调整 void I2C_Delay_us(uint32_t us) { // 72MHz主频下,简单循环逼近,这里不做精确延时库的引入 uint32_t i; for (i = 0; i < us * 8; i++) { __NOP(); } } void I2C_Start(void) { SDA_H(); // 先拉高数据线 SCL_H(); // 时钟线在高电平期间 I2C_Delay_us(5); SDA_L(); // SDA拉低,产生下降沿,起始条件建立 I2C_Delay_us(5); SCL_L(); // 拉低时钟线,准备发送数据 }起始条件后,总线处于"忙"状态,直到停止条件发出。中间任何一个字节传输错误,都不能用简单的拉高拉低来"复位",而应该完整走一遍停止条件时序,让从机释放总线。
2.2 字节传输与应答位的"约定"细节
数据位的传输规则是:SCL高电平期间,SDA上的电平必须保持稳定;SCL低电平期间,SDA允许变化。也就是说,发送方在SCL低电平期间准备好数据,拉高SCL让从机采样,再从高拉低,准备下一位。这就是为什么I2C传输每个bit需要一个完整的时钟脉冲。
一个字节是8位,高位先发(MSB First),第9个时钟脉冲是应答位。主机发送完8位数据后,释放SDA(拉高),然后给一个时钟脉冲,此时从机如果拉低了SDA,说明从机收到了数据(ACK);如果SDA保持高,说明从机没收到或者拒绝处理(NACK)。
发送字节的函数核心:
uint8_t I2C_WriteByte(uint8_t data) { uint8_t i, ack_bit; for (i = 0; i < 8; i++) { if (data & 0x80) { SDA_H(); } else { SDA_L(); } data <<= 1; I2C_Delay_us(2); SCL_H(); I2C_Delay_us(5); SCL_L(); I2C_Delay_us(2); } // 第9个时钟,读取从机应答 SDA_H(); // 主机释放SDA I2C_Delay_us(2); SCL_H(); I2C_Delay_us(5); ack_bit = SDA_READ(); // 读取SDA电平 SCL_L(); I2C_Delay_us(2); return ack_bit; // 0表示应答,1表示无应答 }有个细节:在SCL拉高之前,SDA电平必须稳定。代码里先设置SDA,再延时,再拉高SCL。这个延时如果太短,从机可能来不及采样;如果太长,会拉低总线速率。实测5微秒的延时在72MHz主频下用软件循环是稳定的,如果你用定时器做精确微秒延时,效果更好。
2.3 器件地址的高三位与引脚电平的硬关联
AT24C02的器件地址是8位,其中前4位是固定的1010(这是AT24C系列的身份标识),第5到第7位是A2、A1、A0三个引脚的电平状态,第8位是读写标志位(1表示读,0表示写)。
举个实际例子:如果A0、A1、A2全部接地,那么器件地址就是0xA0(写操作)和0xA1(读操作)。如果你把A0接到3.3V,那么写地址变成0xA2,读地址变成0xA3。这个地址计算方法在各种I2C设备里都通用,比如有些传感器芯片有多个I2C地址可选,就是通过地址引脚高低电平切换来实现的。
我见过不少人在这一步翻车:原理图上EEPROM的A0引脚画了,但是忘记连到MCU的GPIO,或者悬空处理。数据手册里明确写了A0/A1/A2内部没有下拉,悬空时电平不定,可能导致器件地址随机漂移。解决方法是:三个地址引脚要么接地,要么接VCC,根据你的实际需求写死,绝对不允许悬空。
3. 软件驱动的分层设计:基础函数到业务逻辑的过渡
写驱动代码不能一上来就写"往地址0x00写0xAA",那样谁都能写,但换个芯片换个场景就废了。好的做法是分三层:底层的GPIO/时序层、中间的总线传输层、上层的存储器操作层。
底层是跟硬件打交道的,比如GPIO模式配置、拉高拉低操作。中间层实现起始、停止、发字节、收字节、应答检查这些通用I2C原语,这层不关心你接的是什么从设备。上层才是AT24C02特有的操作——按地址写、按地址读、页写、连续读。
这种分层有个直接好处:如果你以后要接OLED或者温湿度传感器,底层和中间层完全复用,只需要新写上层代码。我自己的项目里,I2C的中间层代码几乎从STM32F103移植到GD32、CH32V307、ESP32等不同平台时,除了延时函数和GPIO操作,其他逻辑一行没改。
GPIO配置方面,用软件模拟I2C时,SCL和SDA初始化成推挽输出即可,读应答时将SDA切换到输入模式读取,读完再切回输出。这里有一个性能细节——频繁切换模式会引入时间开销,有一种做法是:SDA保持开漏输出模式,读的时候写1到输出寄存器,让外部上拉把电平拉高,然后读输入寄存器。开漏模式下写0可以强制拉低,写1相当于释放总线,天然就适合I2C的半双工特性。用这种配置,就不需要来回切换GPIO模式了。
3.1 内存操作的四个基本指令
AT24C02的数据手册上定义了几个指令码(其实就是控制字节和设备寻址的组合),搞懂这些才能正确操作:
| 操作类型 | 起始后发送的字节 | 说明 |
|---|---|---|
| 写字节 | 0xA0 | 器件地址+写标志,后面跟1个存储地址和1个数据 |
| 页写 | 0xA0 | 器件地址+写标志,后面跟起始存储地址和最多8字节数据 |
| 当前地址读 | 0xA1 | 器件地址+读标志,不指定地址,读的是内部地址计数器指向的位置 |
| 随机读 | 0xA0然后0xA1 | 先写器件地址和存储地址,再发读地址,形成一个假写动作定位地址 |
| 连续读 | 0xA1 | 随机读之后连续读,每收一字节回ACK,直到收到NACK停止 |
写字节和页写的区别在于页写能一次写入8个字节(AT24C02的页大小是8字节),但页写有严格的边界限制——如果写入的地址跨越了页边界,数据会回卷到页首覆盖原数据。这是AT24C02最容易踩的坑之一,后面单独讲。
3.2 写周期的等待策略:延时 vs 查询应答
AT24C02每次写入(包括页写)后,内部会有一段写周期(write cycle),典型时间为5ms,最大10ms。在这段时间内,芯片不响应任何I2C指令,你一发送器件地址,它不会回复ACK。
业界有两种应对方式:
方式一:固定延时。写完数据后delay_ms(10),简单粗暴,但会阻塞CPU,如果系统里有多个I2C设备,效率会比较低。
方式二:查询应答(ACK Polling)。不断发送器件地址(写地址0xA0),直到从机返回ACK为止。因为芯片内部写周期结束后会恢复正常响应,而发送地址本身不会触发写入操作,所以这是最高效的方法。
uint8_t AT24C02_WaitWriteComplete(void) { uint8_t retry = 200; // 最多重试200次 while (retry--) { I2C_Start(); // 发送器件地址+写标志,如果收到ACK,说明写周期结束 if (I2C_WriteByte(0xA0) == 0) { I2C_Stop(); return 1; // 写完成 } I2C_Stop(); delay_ms(1); } return 0; // 超时失败 }实际工程中我一般写一个函数把"写数据+等待写周期"封装起来,上层调用者不需要关心底层是延时还是轮询。如果你要做低功耗或者有实时性要求,把这个等待过程放到非阻塞的RTOS任务里也是可以的,但那是进阶话题了。
4. 实操环节:从单字节读写到页写全流程
这部分是全文的核心实操环节,直接给出可复制的代码和操作步骤,配合调试过程讲解。
4.1 握手检测:确认总线通信正常
在真正读写EEPROM之前,我习惯先做一个总线扫描:向器件地址0xA0发送一个字节(注意不是数据,而是地址字节),看从机是否回复ACK。这等于跟设备打个招呼"你在吗?"
uint8_t AT24C02_Check(void) { I2C_Start(); uint8_t ack = I2C_WriteByte(0xA0); // 0xA0 = 1010 0000,器件地址+写标志 I2C_Stop(); if (ack == 0) { return 1; // 收到ACK,设备在线 } return 0; // 无应答,设备离线或地址不对 }这一步很重要,能帮你快速定位问题。如果这里就失败,别急着查后面读写的代码,先查这几个点:供电是否正常、I2C引脚是否接对、上拉电阻有没有、地址引脚电平是否正确。省下的都是调试时间。
4.2 单字节写:总把"五步走"记牢
往指定地址写一个字节的完整时序如下:
- 起始条件
- 发送设备地址+写标志(0xA0)
- 发送存储地址(如0x00)
- 发送要写入的数据
- 停止条件
uint8_t AT24C02_WriteByte(uint16_t addr, uint8_t data) { I2C_Start(); // 发送器件地址+写标志,检查应答 if (I2C_WriteByte(0xA0) != 0) { I2C_Stop(); return 0; } // 发送存储地址(AT24C02只有256字节,地址8位就够了) if (I2C_WriteByte((uint8_t)addr) != 0) { I2C_Stop(); return 0; } // 发送数据 if (I2C_WriteByte(data) != 0) { I2C_Stop(); return 0; } I2C_Stop(); // 等待内部写周期完成 return AT24C02_WaitWriteComplete(); }注意一点:每次I2C_WriteByte返回非0,都要调用I2C_Stop()来释放总线。如果中间某一字节后从机NACK,总线上还处于起始后的状态,此时不停掉的话,后续通信会乱套。这是新手最容易忽略的——任何一个分支都要保证总线状态的完整性。
4.3 单字节读:一个假写就能定位
随机读的步骤比写多一步,因为你需要先告诉芯片"我要读哪个地址",这就要先发起一个写操作来装载地址,然后又发起一个读操作来取数据:
- 起始条件
- 发送设备地址+写标志(0xA0)——这是假写
- 发送存储地址(目标地址)
- 再发起始条件(即重复起始条件,Repeated Start)
- 发送设备地址+读标志(0xA1)
- 读取数据字节(此时主机发出NACK表示只读一字节就结束)
- 停止条件
uint8_t AT24C02_ReadByte(uint16_t addr) { uint8_t data = 0; I2C_Start(); // 假写 if (I2C_WriteByte(0xA0) != 0) { I2C_Stop(); return 0; } if (I2C_WriteByte((uint8_t)addr) != 0) { I2C_Stop(); return 0; } // 重复起始条件 I2C_Start(); // 发送器件地址+读标志 if (I2C_WriteByte(0xA1) != 0) { I2C_Stop(); return 0; } // 读取一个字节,主机返回NACK data = I2C_ReadByte(0); // 参数0表示主机发NACK I2C_Stop(); return data; }这里的重复起始条件(Repeated Start)和简单的"停止再启动"是有区别的。重复起始不释放总线,中间不会有Stop条件,总线始终被主机占用,其他从机插不进来。在随机读的场景里,走"Stop再Start"也能工作,但重复起始是更规范的做法,尤其当总线上挂了多个设备时,能避免地址竞争。
I2C_ReadByte的实现大概是:主机在每个时钟周期的高电平期读取SDA电平,循环8次收齐一个字节。第9个时钟,主机根据参数决定发ACK(继续读下一字节)还是NACK(结束本次读)。
uint8_t I2C_ReadByte(uint8_t ack_mode) { uint8_t i, data = 0; for (i = 0; i < 8; i++) { SCL_H(); I2C_Delay_us(5); data <<= 1; if (SDA_READ()) { data |= 0x01; } SCL_L(); I2C_Delay_us(5); } // 第9个时钟,主机发送应答位 if (ack_mode) { SDA_L(); // 主机拉低,表示ACK,继续读 } else { SDA_H(); // 主机释放,表示NACK,结束读 } SCL_H(); I2C_Delay_us(5); SCL_L(); SDA_H(); // 释放SDA return data; }4.4 页写与边界保护:8字节一组的"坑"
页写能大幅提升写入效率,一次写8个字节,比单字节写快8倍(虽然内部写周期时间不变,但省去了7次地址装载和起始停止开销)。但页写有边界限制:起始地址的页内偏移加上写入长度不能超过页大小(AT24C02的页大小是8字节)。
举个反面教材:如果起始地址是0x06,连续写4个字节,那么这4个字节的地址范围是0x06、0x07、0x08、0x09。但AT24C02的页0覆盖0x00~0x07,页1覆盖0x08~0x0F,当写入跨过0x07到0x08时,芯片内部的地址计数器会回卷到0x00,也就是说,你以为写到0x08的数据,实际上写到了0x00。这个bug非常隐蔽,写进去的数据读出来完全对不上,而且不报错。
正确的写法是要对页写做边界处理:要么把一次跨页的写拆成多次,要么强制保证写入长度+起始地址偏移不超过8。我做了一个通用的页写函数来处理这个问题:
uint8_t AT24C02_WritePage(uint8_t addr, uint8_t *data, uint8_t len) { uint8_t i; // 检查是否跨页:页内剩余空间是否足够 uint8_t page_remain = 8 - (addr % 8); if (len > page_remain) { // 简化处理:只写页内剩余部分,或者拆开写 len = page_remain; } I2C_Start(); if (I2C_WriteByte(0xA0) != 0) { I2C_Stop(); return 0; } if (I2C_WriteByte(addr) != 0) { I2C_Stop(); return 0; } for (i = 0; i < len; i++) { if (I2C_WriteByte(data[i]) != 0) { I2C_Stop(); return 0; } } I2C_Stop(); return AT24C02_WaitWriteComplete(); }工程上更稳妥的方案是封装一个"任意地址任意长度写"的函数,内部判断跨页后自动拆分多次写。这种函数在配置存储参数、保存系统状态时特别实用,用户上层根本不需要关心页边界的问题。
4.5 连续读:一个地址顺序读完256字节
连续读和随机读的区别在于:随机读只读一个字节,读完后主机发NACK结束;连续读则是一次性把地址之后的内容连续读出,每读一字节主机发ACK,直到主机想结束才发NACK。
uint8_t AT24C02_ReadContinuous(uint8_t addr, uint8_t *buf, uint16_t len) { uint16_t i; I2C_Start(); if (I2C_WriteByte(0xA0) != 0) { I2C_Stop(); return 0; } if (I2C_WriteByte(addr) != 0) { I2C_Stop(); return 0; } // 重复起始 I2C_Start(); if (I2C_WriteByte(0xA1) != 0) { I2C_Stop(); return 0; } for (i = 0; i < len; i++) { // 最后一字节发NACK,之前的发ACK if (i == len - 1) { buf[i] = I2C_ReadByte(0); } else { buf[i] = I2C_ReadByte(1); } } I2C_Stop(); return 1; }这个函数可以用来做系统重启后的参数恢复——把关键数据一次性读到内存中,避免逐个地址去读产生的总线上多次开销。
5. 调试实战:示波器、逻辑分析仪与常见坑位盘点
这部分分享一下我在实际开发中踩过的坑和排查方法。做I2C调试,工具很关键。
5.1 调试工具的选择与基本观察方法
逻辑分析仪是I2C调试最实用的工具,市面上的山寨逻辑分析仪用Saleae逻辑分析仪软件就能解码I2C协议,价格几十到几百不等,采样率24MHz以上的基本够用。使用要点是:把通道0接SCL,通道1接SDA,设置好触发条件(下降沿触发),就可以看到完整的波形和解码后的数据。
不买工具也不是不能调试,一个简单粗暴的方法:代码里在关键节点来回翻转一个测试GPIO,用示波器或LED闪烁次数来判断程序走到哪一步。早期没有调试工具的时候,我甚至用串口打印中间变量的方式排查,效率比较低,但思路值得借鉴——先确认"是没走到这步"还是"走到了但这步结果不对"。
有了逻辑分析仪后,怎么判断时序正确?看这几处:
- 起始条件是否是SCL高期间的SDA下降沿。
- 每字节是否8位+1个应答位。
- 应答位是不是在第9个时钟沿采样。
- 停止条件后总线释放,SDA和SCL都被上拉拉高。
5.2 常见问题速查表
| 故障现象 | 可能原因 | 排查/解决 |
|---|---|---|
| 发送地址后永远NACK | 器件地址不对 | 核对A0/A1/A2引脚电平与代码地址是否一致 |
| 发送地址后永远NACK | 上拉电阻缺失或阻值过大 | 检查原理图,补上拉4.7kΩ |
| 发送地址后永远NACK | 从机正处于内部写周期 | 等待5~10ms再发 |
| 写数据后读到全0xFF | 写成功但读地址错误 | 检查随机读的"假写"地址是否正确 |
| 能读不能写 | WP引脚被拉高 | WP接地或悬空 |
| 页写后部分数据丢失 | 跨页写导致地址回卷 | 按页边界拆分写入 |
| 通信不稳定、偶发错误 | SCL速率过快 | 增加延时,降到100kHz |
| 通信不稳定、偶发错误 | 电源纹波大或地线长 | 加100nF退耦电容,缩短飞线长度 |
5.3 硬件I2C vs 软件模拟 I2C:我的项目选择策略
STM32F103的硬件I2C确实存在一些资料提到的兼容性问题——不是完全不能用,而是需要掌握它的EV(事件)处理机制,尤其要注意**总线忙标志(BUSY)**的处理。在硬件I2C模式下,如果总线被异常拉低,LIB线会卡在BUSY状态,必须走"总线释放"流程(强制切换GPIO模式产生停止条件)才能恢复。
软件模拟I2C的优势是:时序完全可控、依赖只有一个延时函数、移植方便。缺点是:占用CPU时间、无法同时挂多个从机且速率不可调(但用SysTick做精确定时的话可以做到时序稳定)。
我的选择标准很简单:
- 学习/验证芯片驱动,用软件模拟。先把逻辑搞通,可以在线改时序。
- 做产品原型,用硬件I2C+中断,但加上总线异常恢复机制。
- 做低功耗产品,软件模拟更可控,因为可以完全不依赖外设:休眠前把GPIO配置成低功耗模式,唤醒后重新初始化。
5.4 一个反直觉的坑:I2C总线被锁死
调试中你可能遇到这种情况:程序正常运行,突然死机,逻辑分析仪显示SDA线一直被拉低。这不是EEPROM的问题,而是总线上某个设备把SDA拉住了。
常见原因:从机处于异常状态,等待主机发送时钟信号才能恢复;或者主机程序中途崩溃,SDA停留在低电平状态没被释放。
解决办法:在初始化时加一个总线恢复例程——让SCL翻转9个时钟脉冲,把SDA释放。这个操作在Linux的i2c子系统里有个专门的术语叫"总线恢复"(bus recovery),但在裸机开发中,很多人的驱动里没写这个处理。我给自己的I2C中间层加了一个"初始化前总线恢复"的函数:
void I2C_BusRecovery(void) { uint8_t i; SDA_H(); SCL_H(); for (i = 0; i < 9; i++) { SCL_L(); I2C_Delay_us(5); SCL_H(); I2C_Delay_us(5); } // 发一次停止条件,让总线状态归零 SDA_L(); I2C_Delay_us(5); SCL_H(); I2C_Delay_us(5); SDA_H(); I2C_Delay_us(5); }这个函数在每次系统启动初始化I2C引脚后调用,能够大幅降低因为硬件复位造成的总线锁死问题。
6. 写在最后
我在实际项目中用AT24C02做过系统参数存储、设备校准数据保存、运行日志掉电保护等功能,总体来说这枚芯片用起来还是很皮实的,只要注意地址、上拉、页写边界这三个核心点,基本不会有什么大问题。如果你是想通过它练习I2C协议,建议把软件模拟版本的时序自己从头到尾写一遍,不要直接抄代码,然后把逻辑分析仪的波形图和协议对比着看一遍,收获绝对比你跑通一百遍示例代码来得多。
一个值得扩展的方向是在Proteus里仿真验证整套逻辑,再用真实硬件做交叉验证。仿真环境下的时序参数和真实芯片有差异,但用来检查协议状态机的正确性是足够的。另外多说一句,如果你调试中遇到的总线问题实在排查不出来,先检查是不是杜邦线接触不良——我遇到过一个"玄学"问题,后来发现是面包板上一根SDA线虚接,手指轻轻一碰读数就变化,这种问题最容易让人怀疑人生。
有问题欢迎在评论区交流。