大家好,我是良许。
在嵌入式开发中,我们经常需要让主控芯片与各种外设进行通信,比如读取温湿度传感器的数据、控制 OLED 显示屏显示内容、读写 EEPROM 存储器等等。
这时候就需要用到各种通信协议,而 I2C 总线就是其中最常用的一种。
我刚入行做单片机开发的时候,第一个接触的通信协议就是 I2C,当时用 STM32 读取一个温度传感器,虽然代码不多,但理解其工作原理还是花了不少时间。
今天就和大家详细聊聊 I2C 总线的方方面面。
I2C 总线是由飞利浦公司(现在的 NXP)在 1980 年代开发的一种串行通信总线。
它最大的特点就是只需要两根信号线就能实现多个设备之间的通信,这两根线分别是 SDA(Serial Data Line,串行数据线)和 SCL(Serial Clock Line,串行时钟线)。
相比于并行总线需要 8 根、16 根甚至更多的数据线,I2C 总线大大节省了芯片的引脚资源和 PCB 板的布线空间。
I2C 总线采用主从模式(Master-Slave),通信过程中必须有一个主设备来控制总线,从设备只能被动响应。
主设备负责产生时钟信号和发起通信,从设备则根据自己的地址来判断是否需要响应。
一条 I2C 总线上可以挂载多个主设备和多个从设备,理论上最多可以连接 128 个设备(7 位地址模式)或 1024 个设备(10 位地址模式)。
I2C 总线的硬件连接非常简单。
SDA 和 SCL 都是开漏输出(Open-Drain)或开集输出(Open-Collector),需要外接上拉电阻到电源。
典型的上拉电阻阻值在 1kΩ 到 10kΩ 之间,具体取值要根据总线电容负载和通信速率来确定。
总线电容越大、速率越高,上拉电阻就要选得越小。
开漏输出的特点是只能主动拉低电平,不能主动拉高电平,释放总线后依靠上拉电阻将电平拉高。
这种设计有两个好处:一是允许多个设备连接到同一条总线上而不会发生电气冲突,二是可以实现不同电压等级设备之间的通信(通过选择合适的上拉电压)。
在实际项目中,我曾经遇到过一个问题:I2C 通信时好时坏,波形也不太正常。
后来发现是上拉电阻选得太大了(用的是 10kΩ),总线电容负载比较大,导致信号上升沿太慢。
换成 2.2kΩ 的电阻后问题就解决了。
所以硬件设计时一定要注意这个细节。
I2C 总线定义了几种不同的速率模式:
标准模式(Standard Mode):时钟频率最高 100kHz,这是最早的 I2C 标准,现在很多低速外设仍然使用这个速率。
快速模式(Fast Mode):时钟频率最高 400kHz,是目前最常用的速率模式,能够满足大部分应用场景的需求。
快速模式增强版(Fast Mode Plus):时钟频率最高 1MHz,用于对速度要求较高的场合。
高速模式(High Speed Mode):时钟频率最高 3.4MHz,需要特殊的硬件支持,实际应用中比较少见。
超快速模式(Ultra Fast Mode):时钟频率最高 5MHz,这是最新的标准,目前支持的设备还不多。
在实际开发中,我们最常用的是标准模式和快速模式。
选择哪种速率主要看外设芯片的支持情况和实际需求。
如果只是读取一个温度传感器,标准模式完全够用;如果要驱动一个 OLED 显示屏,可能就需要用到快速模式来提高刷新速度。
I2C 通信的开始和结束都有特定的信号标志。
起始条件(Start Condition)是指在 SCL 为高电平期间,SDA 由高电平变为低电平。
停止条件(Stop Condition)是指在 SCL 为高电平期间,SDA 由低电平变为高电平。
这两个条件非常重要,它们定义了一次完整通信的边界。
主设备在发起通信前必须先发送起始条件,通信结束后必须发送停止条件。
从设备通过检测起始条件来知道通信开始了,通过检测停止条件来知道通信结束了。
还有一种特殊情况叫做重复起始条件(Repeated Start),就是在一次通信过程中,不发送停止条件,直接再发送一个起始条件。
这样可以在不释放总线的情况下改变通信方向或切换从设备,常用于连续读写操作。
I2C 总线上的数据传输以字节为单位,每个字节都是 8 位。
数据在 SCL 为低电平期间准备好,在 SCL 为高电平期间被采样。
数据传输遵循高位在前(MSB First)的原则,也就是先传输最高位。
每传输完一个字节,接收方都要发送一个应答位(ACK)或非应答位(NACK)。
应答位是在第 9 个时钟周期内,接收方将 SDA 拉低表示应答,如果 SDA 保持高电平则表示非应答。
主设备作为接收方时,通常在接收到最后一个字节后发送非应答位,告诉从设备数据传输结束了。
I2C 总线上的每个从设备都有一个唯一的地址,主设备通过这个地址来选择要通信的从设备。
标准的 I2C 地址是 7 位,加上 1 位读写位,总共占用一个字节。
读写位为 0 表示写操作,为 1 表示读操作。
比如一个 EEPROM 芯片的地址是 0x50(二进制 1010000),当主设备要写数据时,发送的地址字节就是 0xA0(1010000 + 0);当主设备要读数据时,发送的地址字节就是 0xA1(1010000 + 1)。
有些 I2C 设备支持 10 位地址模式,这样可以在同一条总线上连接更多设备。
10 位地址的传输需要两个字节,第一个字节的高 5 位是 11110,后面跟着 10 位地址的最高 2 位和读写位;第二个字节是 10 位地址的低 8 位。
不过实际项目中,10 位地址模式用得比较少。
在设计系统时,必须确保总线上的每个从设备地址都不相同。
但有时候会遇到地址冲突的情况,比如需要在同一条总线上连接两个相同型号的传感器,而这两个传感器的地址是固定的。
解决办法有几种:一是选择支持地址配置的芯片,很多 I2C 设备都有几个地址选择引脚,通过接高电平或低电平可以改变设备地址。
二是使用 I2C 总线扩展器或多路复用器,将一条总线扩展成多条独立的总线。
三是如果可能的话,使用软件模拟 I2C,用不同的 GPIO 引脚来连接不同的设备。
一次完整的 I2C 写操作时序如下:
一次完整的 I2C 读操作时序如下:
这个时序看起来比较复杂,但实际使用时,STM32 的 HAL 库已经把这些细节都封装好了,我们只需要调用几个简单的函数就可以完成通信。
STM32 芯片内部集成了硬件 I2C 控制器,可以自动处理时序、应答等细节,大大简化了编程工作。
使用 STM32CubeMX 配置 I2C 非常方便,只需要几个步骤:
生成的代码中会有一个初始化函数,类似这样:
void MX_I2C1_Init(void)
{
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 100000; // 时钟速率100kHz
hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
hi2c1.Init.OwnAddress1 = 0;
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
hi2c1.Init.OwnAddress2 = 0;
hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
if (HAL_I2C_Init(&hi2c1) != HAL_OK)
{
Error_Handler();
}
}
HAL 库提供了多个 I2C 通信函数,最常用的有以下几个:
// 主设备发送数据 HAL_StatusTypeDef HAL_I2C_Master_Transmit(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout); // 主设备接收数据 HAL_StatusTypeDef HAL_I2C_Master_Receive(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint8_t *pData, uint16_t Size, uint32_t Timeout); // 向指定寄存器写数据 HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout); // 从指定寄存器读数据 HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout);
MPU6050 是一个常用的六轴姿态传感器,内部集成了三轴陀螺仪和三轴加速度计,通过 I2C 接口与主控芯片通信。
它的 I2C 地址是 0x68 或 0x69(取决于 AD0 引脚的电平)。
下面是一个读取 MPU6050 数据的完整例程:
#define MPU6050_ADDR 0xD0 // MPU6050地址左移1位(0x68 << 1)
#define WHO_AM_I_REG 0x75 // WHO_AM_I寄存器地址
#define PWR_MGMT_1_REG 0x6B // 电源管理寄存器
#define ACCEL_XOUT_H 0x3B // 加速度X轴高字节寄存器
// 初始化MPU6050
uint8_t MPU6050_Init(void)
{
uint8_t check;
uint8_t data;
// 读取WHO_AM_I寄存器,检查设备是否存在
HAL_I2C_Mem_Read(&hi2c1, MPU6050_ADDR, WHO_AM_I_REG, 1, &check, 1, 1000);
if(check == 0x68) // MPU6050的WHO_AM_I值是0x68
{
// 唤醒MPU6050(默认是睡眠模式)
data = 0;
HAL_I2C_Mem_Write(&hi2c1, MPU6050_ADDR, PWR_MGMT_1_REG, 1, &data, 1, 1000);
// 设置加速度计量程为±2g
data = 0x00;
HAL_I2C_Mem_Write(&hi2c1, MPU6050_ADDR, 0x1C, 1, &data, 1, 1000);
// 设置陀螺仪量程为±250°/s
data = 0x00;
HAL_I2C_Mem_Write(&hi2c1, MPU6050_ADDR, 0x1B, 1, &data, 1, 1000);
return 0;
}
return 1;
}
// 读取加速度数据
void MPU6050_Read_Accel(int16_t *AccelX, int16_t *AccelY, int16_t *AccelZ)
{
uint8_t data[6];
// 从ACCEL_XOUT_H开始连续读取6个字节
HAL_I2C_Mem_Read(&hi2c1, MPU6050_ADDR, ACCEL_XOUT_H, 1, data, 6, 1000);
// 组合高低字节
*AccelX = (int16_t)(data[0] << 8 | data[1]);
*AccelY = (int16_t)(data[2] << 8 | data[3]);
*AccelZ = (int16_t)(data[4] << 8 | data[5]);
}
// 主函数中的使用示例
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_I2C1_Init();
int16_t accel_x, accel_y, accel_z;
if(MPU6050_Init() == 0)
{
while(1)
{
MPU6050_Read_Accel(&accel_x, &accel_y, &accel_z);
// 这里可以对数据进行处理或显示
// printf("X: %d, Y: %d, Z: %d\r\n", accel_x, accel_y, accel_z);
HAL_Delay(100); // 延时100ms
}
}
else
{
// MPU6050初始化失败
while(1)
{
// 错误处理
}
}
}
这个例程展示了 I2C 通信的典型流程:先初始化设备,然后循环读取数据。
需要注意的是,HAL 库的 I2C 地址参数需要左移 1 位,因为库函数会自动添加读写位。
有时候硬件 I2C 引脚被占用了,或者需要在任意 GPIO 上实现 I2C 通信,这时候可以用软件模拟 I2C。
虽然软件模拟的效率不如硬件 I2C,但胜在灵活性高,而且对于低速设备来说完全够用。
软件模拟 I2C 的核心是用 GPIO 来产生 I2C 时序。下面是一个简单的实现:
// 定义SDA和SCL引脚
#define I2C_SCL_PIN GPIO_PIN_6
#define I2C_SCL_PORT GPIOB
#define I2C_SDA_PIN GPIO_PIN_7
#define I2C_SDA_PORT GPIOB
// 延时函数(用于控制时钟速率)
void I2C_Delay(void)
{
uint8_t i = 10; // 调整这个值可以改变速率
while(i--);
}
// 设置SDA为输出模式
void SDA_OUT(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = I2C_SDA_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(I2C_SDA_PORT, &GPIO_InitStruct);
}
// 设置SDA为输入模式
void SDA_IN(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = I2C_SDA_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(I2C_SDA_PORT, &GPIO_InitStruct);
}
// 产生起始条件
void I2C_Start(void)
{
SDA_OUT();
HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET);
HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET);
I2C_Delay();
HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET);
I2C_Delay();
HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET);
}
// 产生停止条件
void I2C_Stop(void)
{
SDA_OUT();
HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET);
HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET);
I2C_Delay();
HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET);
I2C_Delay();
HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET);
I2C_Delay();
}
// 发送一个字节
void I2C_Send_Byte(uint8_t byte)
{
uint8_t i;
SDA_OUT();
HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET);
for(i = 0; i < 8; i++)
{
if(byte & 0x80)
HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET);
else
HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET);
byte <<= 1;
I2C_Delay();
HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET);
I2C_Delay();
HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET);
}
}
// 等待应答
uint8_t I2C_Wait_Ack(void)
{
uint8_t ack;
SDA_IN();
HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET);
I2C_Delay();
if(HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN))
ack = 1; // 无应答
else
ack = 0; // 有应答
HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET);
return ack;
}
软件模拟 I2C 的代码比较长,这里只列出了部分关键函数。
完整的实现还需要接收字节、发送应答等函数。
虽然代码量比较大,但原理很清晰,就是严格按照 I2C 时序来操作 GPIO 引脚。
在实际项目中,I2C 通信失败是很常见的问题。
遇到这种情况,可以按照以下步骤排查:
第一步,检查硬件连接。
用万用表测量 SDA 和 SCL 是否正常,静态时应该是高电平(上拉电阻的作用)。
如果是低电平,可能是某个设备把总线拉低了,或者上拉电阻没接好。
第二步,检查设备地址。
很多初学者会忘记地址要左移 1 位,或者把读写位搞混了。
可以用逻辑分析仪抓取波形,看看实际发送的地址是否正确。
第三步,检查时序。
有些 I2C 设备对时序要求比较严格,如果时钟速率太高或者延时不够,可能导致通信失败。
可以尝试降低时钟速率,或者在关键位置增加延时。
第四步,检查设备状态。
有些设备需要先初始化才能正常工作,比如 MPU6050 默认是睡眠模式,必须先写电源管理寄存器唤醒它。
仔细阅读设备的数据手册,按照要求进行初始化。
当多个主设备同时发起通信时,可能会发生总线冲突。
I2C 协议定义了仲裁机制来解决这个问题:每个主设备在发送数据的同时监测总线状态,如果发现总线电平与自己发送的不一致,就说明有其他设备也在发送数据,这时候要立即停止发送,让出总线。
仲裁过程是按位进行的。
由于 I2C 是开漏输出,低电平会覆盖高电平,所以发送低电平的设备会赢得仲裁。
比如设备 A 发送地址 0x50(01010000),设备 B 发送地址 0x48(01001000),在第 5 位时,A 发送 1 但检测到 0,就知道自己输掉了仲裁,会停止发送。
不过在实际应用中,多主设备的情况比较少见。
如果确实需要多个主设备,要做好软件设计,避免同时发起通信,或者使用仲裁机制来处理冲突。
I2C 协议允许从设备在需要更多时间处理数据时,通过拉低 SCL 来延长时钟周期,这叫做时钟延展(Clock Stretching)。
主设备在拉高 SCL 后,必须检测 SCL 是否真的变成高电平,如果 SCL 被从设备拉低了,就要等待从设备释放 SCL。
有些 STM32 的硬件 I2C 控制器支持时钟延展,有些不支持。
如果不支持,遇到需要时钟延展的从设备就可能出现问题。
这时候可以尝试用软件模拟 I2C,或者在软件中实现时钟延展检测。
I2C 总线的信号频率不高,但在强电磁干扰环境下仍然可能出现通信错误。
我之前做过一个项目,设备在实验室测试时一切正常,但到了工业现场就频繁出现通信失败。
后来发现是附近有大功率电机,产生了很强的电磁干扰。
解决电磁干扰问题的方法有:缩短 I2C 总线长度,理想情况下不要超过 1 米。
在 SDA 和 SCL 上串联小电阻(比如 100Ω),可以抑制高频干扰。
使用屏蔽线或双绞线。
在软件中增加重试机制,检测到通信错误时自动重试。
I2C 总线是嵌入式系统中最常用的通信协议之一,它结构简单、使用方便、节省引脚,非常适合连接各种低速外设。
掌握 I2C 的工作原理和编程方法,是每个嵌入式工程师的必备技能。
在实际开发中,我们既要理解 I2C 的底层时序,也要会使用 HAL 库等高层接口。
遇到问题时,要善于用逻辑分析仪等工具来分析波形,结合数据手册来排查原因。
只要多实践、多总结,很快就能熟练掌握 I2C 通信。
希望这篇文章能帮助大家更好地理解和使用 I2C 总线。
如果你在项目中遇到了 I2C 相关的问题,欢迎留言交流讨论。
更多编程学习资源
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70049026/viewspace-3110733/ ,如需转载,请注明出处,否则将追究法律责任。