大家好,我是良许。
在嵌入式开发中,我们经常需要让主控芯片与各种外设进行通信,比如读取温湿度传感器的数据、控制OLED显示屏显示内容、读写EEPROM存储器等等。
这时候就需要用到各种通信协议,而I2C总线就是其中最常用的一种。
我刚入行做单片机开发的时候,第一个接触的通信协议就是I2C,当时用STM32读取一个温度传感器,虽然代码不多,但理解其工作原理还是花了不少时间。
今天就和大家详细聊聊I2C总线的方方面面。
1. I2C总线基础知识
1.1 什么是I2C总线
I2C总线是由飞利浦公司(现在的NXP)在1980年代开发的一种串行通信总线。
它最大的特点就是只需要两根信号线就能实现多个设备之间的通信,这两根线分别是SDA(Serial Data Line,串行数据线)和SCL(Serial Clock Line,串行时钟线)。
相比于并行总线需要8根、16根甚至更多的数据线,I2C总线大大节省了芯片的引脚资源和PCB板的布线空间。
I2C总线采用主从模式(Master-Slave),通信过程中必须有一个主设备来控制总线,从设备只能被动响应。
主设备负责产生时钟信号和发起通信,从设备则根据自己的地址来判断是否需要响应。
一条I2C总线上可以挂载多个主设备和多个从设备,理论上最多可以连接128个设备(7位地址模式)或1024个设备(10位地址模式)。
1.2 I2C总线的硬件连接
I2C总线的硬件连接非常简单。
SDA和SCL都是开漏输出(Open-Drain)或开集输出(Open-Collector),需要外接上拉电阻到电源。
典型的上拉电阻阻值在1kΩ到10kΩ之间,具体取值要根据总线电容负载和通信速率来确定。
总线电容越大、速率越高,上拉电阻就要选得越小。
开漏输出的特点是只能主动拉低电平,不能主动拉高电平,释放总线后依靠上拉电阻将电平拉高。
这种设计有两个好处:一是允许多个设备连接到同一条总线上而不会发生电气冲突,二是可以实现不同电压等级设备之间的通信(通过选择合适的上拉电压)。
在实际项目中,我曾经遇到过一个问题:I2C通信时好时坏,波形也不太正常。
后来发现是上拉电阻选得太大了(用的是10kΩ),总线电容负载比较大,导致信号上升沿太慢。
换成2.2kΩ的电阻后问题就解决了。
所以硬件设计时一定要注意这个细节。
1.3 I2C总线的速率模式
I2C总线定义了几种不同的速率模式:
标准模式(Standard Mode):时钟频率最高100kHz,这是最早的I2C标准,现在很多低速外设仍然使用这个速率。
快速模式(Fast Mode):时钟频率最高400kHz,是目前最常用的速率模式,能够满足大部分应用场景的需求。
快速模式增强版(Fast Mode Plus):时钟频率最高1MHz,用于对速度要求较高的场合。
高速模式(High Speed Mode):时钟频率最高3.4MHz,需要特殊的硬件支持,实际应用中比较少见。
超快速模式(Ultra Fast Mode):时钟频率最高5MHz,这是最新的标准,目前支持的设备还不多。
在实际开发中,我们最常用的是标准模式和快速模式。
选择哪种速率主要看外设芯片的支持情况和实际需求。
如果只是读取一个温度传感器,标准模式完全够用;如果要驱动一个OLED显示屏,可能就需要用到快速模式来提高刷新速度。
2. I2C通信协议详解
2.1 起始和停止条件
I2C通信的开始和结束都有特定的信号标志。
起始条件(Start Condition)是指在SCL为高电平期间,SDA由高电平变为低电平。
停止条件(Stop Condition)是指在SCL为高电平期间,SDA由低电平变为高电平。
这两个条件非常重要,它们定义了一次完整通信的边界。
主设备在发起通信前必须先发送起始条件,通信结束后必须发送停止条件。
从设备通过检测起始条件来知道通信开始了,通过检测停止条件来知道通信结束了。
还有一种特殊情况叫做重复起始条件(Repeated Start),就是在一次通信过程中,不发送停止条件,直接再发送一个起始条件。
这样可以在不释放总线的情况下改变通信方向或切换从设备,常用于连续读写操作。
2.1.1 数据传输格式
I2C总线上的数据传输以字节为单位,每个字节都是8位。
数据在SCL为低电平期间准备好,在SCL为高电平期间被采样。
数据传输遵循高位在前(MSB First)的原则,也就是先传输最高位。
每传输完一个字节,接收方都要发送一个应答位(ACK)或非应答位(NACK)。
应答位是在第9个时钟周期内,接收方将SDA拉低表示应答,如果SDA保持高电平则表示非应答。
主设备作为接收方时,通常在接收到最后一个字节后发送非应答位,告诉从设备数据传输结束了。
2.2 设备地址
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位地址模式用得比较少。
2.2.1 地址冲突问题
在设计系统时,必须确保总线上的每个从设备地址都不相同。
但有时候会遇到地址冲突的情况,比如需要在同一条总线上连接两个相同型号的传感器,而这两个传感器的地址是固定的。
解决办法有几种:一是选择支持地址配置的芯片,很多I2C设备都有几个地址选择引脚,通过接高电平或低电平可以改变设备地址。
二是使用I2C总线扩展器或多路复用器,将一条总线扩展成多条独立的总线。
三是如果可能的话,使用软件模拟I2C,用不同的GPIO引脚来连接不同的设备。
2.3 完整的通信时序
一次完整的I2C写操作时序如下:
- 主设备发送起始条件
- 主设备发送从设备地址和写标志(地址字节的最低位为0)
- 从设备发送应答位
- 主设备发送寄存器地址或数据
- 从设备发送应答位
- 重复步骤4和5,直到所有数据发送完毕
- 主设备发送停止条件
一次完整的I2C读操作时序如下:
- 主设备发送起始条件
- 主设备发送从设备地址和写标志
- 从设备发送应答位
- 主设备发送要读取的寄存器地址
- 从设备发送应答位
- 主设备发送重复起始条件
- 主设备发送从设备地址和读标志(地址字节的最低位为1)
- 从设备发送应答位
- 从设备发送数据
- 主设备发送应答位(如果还要继续读)或非应答位(如果这是最后一个字节)
- 重复步骤9和10,直到所有数据读取完毕
- 主设备发送停止条件
这个时序看起来比较复杂,但实际使用时,STM32的HAL库已经把这些细节都封装好了,我们只需要调用几个简单的函数就可以完成通信。
3. STM32的I2C编程实战
3.1 硬件I2C的配置
STM32芯片内部集成了硬件I2C控制器,可以自动处理时序、应答等细节,大大简化了编程工作。
使用STM32CubeMX配置I2C非常方便,只需要几个步骤:
- 在Pinout & Configuration页面,找到I2C外设(比如I2C1),点击Mode,选择I2C模式
- 系统会自动分配SDA和SCL引脚,也可以手动修改
- 在Configuration页面,设置I2C参数,主要是时钟速率(比如100kHz或400kHz)
- 生成代码
生成的代码中会有一个初始化函数,类似这样:
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();
}
}
3.2 I2C读写函数
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);
3.2.1 实战案例:读取MPU6050传感器
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位,因为库函数会自动添加读写位。
3.3 软件模拟I2C
有时候硬件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引脚。
4. I2C使用中的常见问题
4.1 通信失败的排查
在实际项目中,I2C通信失败是很常见的问题。
遇到这种情况,可以按照以下步骤排查:
第一步,检查硬件连接。
用万用表测量SDA和SCL是否正常,静态时应该是高电平(上拉电阻的作用)。
如果是低电平,可能是某个设备把总线拉低了,或者上拉电阻没接好。
第二步,检查设备地址。
很多初学者会忘记地址要左移1位,或者把读写位搞混了。
可以用逻辑分析仪抓取波形,看看实际发送的地址是否正确。
第三步,检查时序。
有些I2C设备对时序要求比较严格,如果时钟速率太高或者延时不够,可能导致通信失败。
可以尝试降低时钟速率,或者在关键位置增加延时。
第四步,检查设备状态。
有些设备需要先初始化才能正常工作,比如MPU6050默认是睡眠模式,必须先写电源管理寄存器唤醒它。
仔细阅读设备的数据手册,按照要求进行初始化。
4.2 总线冲突和仲裁
当多个主设备同时发起通信时,可能会发生总线冲突。
I2C协议定义了仲裁机制来解决这个问题:每个主设备在发送数据的同时监测总线状态,如果发现总线电平与自己发送的不一致,就说明有其他设备也在发送数据,这时候要立即停止发送,让出总线。
仲裁过程是按位进行的。
由于I2C是开漏输出,低电平会覆盖高电平,所以发送低电平的设备会赢得仲裁。
比如设备A发送地址0x50(01010000),设备B发送地址0x48(01001000),在第5位时,A发送1但检测到0,就知道自己输掉了仲裁,会停止发送。
不过在实际应用中,多主设备的情况比较少见。
如果确实需要多个主设备,要做好软件设计,避免同时发起通信,或者使用仲裁机制来处理冲突。
4.3 时钟延展
I2C协议允许从设备在需要更多时间处理数据时,通过拉低SCL来延长时钟周期,这叫做时钟延展(Clock Stretching)。
主设备在拉高SCL后,必须检测SCL是否真的变成高电平,如果SCL被从设备拉低了,就要等待从设备释放SCL。
有些STM32的硬件I2C控制器支持时钟延展,有些不支持。
如果不支持,遇到需要时钟延展的从设备就可能出现问题。
这时候可以尝试用软件模拟I2C,或者在软件中实现时钟延展检测。
4.4 电磁干扰
I2C总线的信号频率不高,但在强电磁干扰环境下仍然可能出现通信错误。
我之前做过一个项目,设备在实验室测试时一切正常,但到了工业现场就频繁出现通信失败。
后来发现是附近有大功率电机,产生了很强的电磁干扰。
解决电磁干扰问题的方法有:缩短I2C总线长度,理想情况下不要超过1米。
在SDA和SCL上串联小电阻(比如100Ω),可以抑制高频干扰。
使用屏蔽线或双绞线。
在软件中增加重试机制,检测到通信错误时自动重试。
5. 总结
I2C总线是嵌入式系统中最常用的通信协议之一,它结构简单、使用方便、节省引脚,非常适合连接各种低速外设。
掌握I2C的工作原理和编程方法,是每个嵌入式工程师的必备技能。
在实际开发中,我们既要理解I2C的底层时序,也要会使用HAL库等高层接口。
遇到问题时,要善于用逻辑分析仪等工具来分析波形,结合数据手册来排查原因。
只要多实践、多总结,很快就能熟练掌握I2C通信。
希望这篇文章能帮助大家更好地理解和使用I2C总线。
如果你在项目中遇到了I2C相关的问题,欢迎留言交流讨论。
更多编程学习资源
共同学习,写下你的评论
评论加载中...
作者其他优质文章