2. I2C通信协议详解:从原理到实战
各位同学好,我是你们的嵌入式讲师。今天我们来聊聊I2C总线——这个在嵌入式世界里无处不在的通信协议。说实话,我最早接触I2C是在做智能手环项目时,当时要读写EEPROM存储用户数据,结果因为时序没搞对,数据老是丢。嗯,从那以后我就把I2C协议啃了个透。
I2C,全称Inter-Integrated Circuit,是飞利浦公司发明的。它只用两根线就能让多个设备通信,说白了就是「两根线走天下」。你想想看,在寸土寸金的手环PCB上,少一根线都是赚的。
2.1 I2C总线原理:两根线怎么干活?
I2C总线就两条线:
- SCL(串行时钟线)——由主设备控制,负责提供时钟节拍
- SDA(串行数据线)——双向传输,主从设备都能拉低它
这两根线都是开漏输出,需要外接上拉电阻。我习惯用4.7kΩ,但具体要看总线电容和通信速率。总线空闲时,SCL和SDA都是高电平。
为什么用开漏?因为这样多个设备可以「线与」——任何一个设备拉低,整条线就变低。这解决了多设备冲突问题。我个人觉得,这是I2C最巧妙的设计之一。
关键点:I2C是主从架构。主设备负责产生时钟、发起通信;从设备只能响应。一个总线上可以挂多个从设备,每个从设备有唯一地址。
2.2 起始条件与停止条件:通信的「开始」和「结束」
I2C通信的起始和停止,靠的是SDA和SCL的特定时序组合。这就像两个人对话,先说「喂」表示开始,说「拜拜」表示结束。
起始条件(Start Condition)
SCL为高电平时,SDA从高电平跳变到低电平。记住这个口诀:「时钟高,数据跳低,开始!」
// 起始条件的C语言实现(伪代码)
void i2c_start(void) {
SDA_HIGH(); // 确保SDA为高
SCL_HIGH(); // SCL拉高
delay_us(5); // 保持一段时间
SDA_LOW(); // SDA拉低,产生起始条件
delay_us(5);
SCL_LOW(); // 拉低SCL,准备传输数据
}
停止条件(Stop Condition)
SCL为高电平时,SDA从低电平跳变到高电平。口诀:「时钟高,数据跳高,结束!」
void i2c_stop(void) {
SDA_LOW(); // 确保SDA为低
SCL_HIGH(); // SCL拉高
delay_us(5);
SDA_HIGH(); // SDA拉高,产生停止条件
delay_us(5);
}
我的经验:起始和停止条件的时序非常关键。我曾经在STM32上用软件模拟I2C,因为延时不够,从设备死活不响应。后来用示波器一看,起始条件根本没形成。记住,延时至少5微秒,别省这点时间。
2.3 数据帧格式:一个字节怎么传?
I2C传输数据时,每个字节是8位,高位在前(MSB first)。每传完一个字节,从设备会回复一个ACK(应答位)。
数据帧的完整结构是这样的:
- 起始条件(S)
- 从设备地址(7位或10位)
- 读写位(R/W,0表示写,1表示读)
- 应答位(ACK/NACK)
- 数据字节(8位,可以多个)
- 应答位(每个数据字节后都有)
- 停止条件(P)
说白了,就是「开始→叫名字→说干啥→等回应→传数据→等回应→结束」这么个流程。
注意:应答位是第9个时钟周期。主设备在发送完8位数据后,会释放SDA线,从设备如果拉低SDA表示ACK(应答),保持高电平表示NACK(非应答)。
2.4 地址与读写位:怎么找到对的设备?
每个I2C从设备都有唯一地址。最常见的7位地址模式,地址范围是0x00到0x7F。但实际使用时,0x00是广播地址,0x7F是保留地址,所以可用地址是0x01到0x7E。
地址怎么组成?我以EEPROM AT24C02为例说明:
| 位 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| 含义 | 固定值(1010) | 器件地址(A2 A1 A0) | R/W | |||||
| 示例 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0/1 |
AT24C02的固定前缀是1010,后面3位是硬件引脚A2、A1、A0决定的。如果这三个引脚都接地,地址就是0x50(1010000)。加上读写位后:
- 写操作:0xA0(10100000)
- 读操作:0xA1(10100001)
避坑指南:很多新手搞混「设备地址」和「发送地址」。设备地址是7位的(如0x50),但发送时要左移一位,最低位放读写位。所以写地址是0xA0,读地址是0xA1。我见过有人直接发0x50,结果从设备根本不鸟他。
2.5 完整读写时序:手把手教你操作EEPROM
好了,理论讲完了,咱们来点实战。以AT24C02为例,看看怎么写入和读取一个字节。
写一个字节到EEPROM
// 向AT24C02的地址0x00写入数据0x55
void eeprom_write_byte(uint8_t dev_addr, uint8_t mem_addr, uint8_t data) {
i2c_start(); // 1. 起始条件
i2c_send_byte(dev_addr << 1 | 0); // 2. 发送设备地址 + 写位
i2c_wait_ack(); // 3. 等待应答
i2c_send_byte(mem_addr); // 4. 发送存储器地址
i2c_wait_ack(); // 5. 等待应答
i2c_send_byte(data); // 6. 发送数据
i2c_wait_ack(); // 7. 等待应答
i2c_stop(); // 8. 停止条件
delay_ms(5); // 等待EEPROM内部写入完成
}
读一个字节从EEPROM
// 从AT24C02的地址0x00读取数据
uint8_t eeprom_read_byte(uint8_t dev_addr, uint8_t mem_addr) {
uint8_t data;
// 第一步:伪写操作,设置读取地址
i2c_start();
i2c_send_byte(dev_addr << 1 | 0); // 写操作
i2c_wait_ack();
i2c_send_byte(mem_addr); // 发送要读取的存储器地址
i2c_wait_ack();
// 第二步:重新起始,切换为读操作
i2c_start(); // 重复起始条件
i2c_send_byte(dev_addr << 1 | 1); // 读操作
i2c_wait_ack();
data = i2c_read_byte(); // 读取数据
i2c_send_nack(); // 发送非应答,表示读完了
i2c_stop();
return data;
}
我的小技巧:读操作时用「重复起始条件」代替停止+重新开始,可以节省时间。这在高速通信时特别有用。另外,EEPROM写完后需要5ms左右的内部写入时间,别急着读,否则读到的是旧数据。我踩过这个坑,调试了一下午才发现。
2.6 常见问题与调试方法
做I2C通信,最怕的就是设备不响应。我总结了几种常见情况:
- 总线卡死:从设备没释放SDA线。解决办法:模拟9个时钟脉冲强制复位。
- 地址不对:检查设备地址是否左移了一位。用逻辑分析仪抓波形最直观。
- 上拉电阻不合适:电阻太大,上升沿太慢;电阻太小,功耗大。400kHz速率用4.7kΩ一般没问题。
- 时序不满足:起始条件保持时间、数据建立时间等。查数据手册的时序参数表。
说实话,调试I2C最好的工具就是逻辑分析仪。几十块钱的就能用,抓出波形一看,什么问题都清楚了。我每次做I2C项目,第一件事就是先看波形对不对。
总结一下:I2C协议不复杂,但细节决定成败。起始条件、停止条件、地址格式、应答机制,这四个点吃透了,读写任何I2C设备都不在话下。下一章我们讲EEPROM的页写入和连续读取,那才是真正发挥I2C效率的地方。