为了账号安全,请及时绑定邮箱和手机立即绑定

12C总线和协议

标签:
C++

大家好,我是良许。

在嵌入式开发中,我们经常需要让主控芯片与各种外设进行通信,比如读取温湿度传感器的数据、控制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写操作时序如下:

  1. 主设备发送起始条件
  2. 主设备发送从设备地址和写标志(地址字节的最低位为0)
  3. 从设备发送应答位
  4. 主设备发送寄存器地址或数据
  5. 从设备发送应答位
  6. 重复步骤4和5,直到所有数据发送完毕
  7. 主设备发送停止条件

一次完整的I2C读操作时序如下:

  1. 主设备发送起始条件
  2. 主设备发送从设备地址和写标志
  3. 从设备发送应答位
  4. 主设备发送要读取的寄存器地址
  5. 从设备发送应答位
  6. 主设备发送重复起始条件
  7. 主设备发送从设备地址和读标志(地址字节的最低位为1)
  8. 从设备发送应答位
  9. 从设备发送数据
  10. 主设备发送应答位(如果还要继续读)或非应答位(如果这是最后一个字节)
  11. 重复步骤9和10,直到所有数据读取完毕
  12. 主设备发送停止条件

这个时序看起来比较复杂,但实际使用时,STM32的HAL库已经把这些细节都封装好了,我们只需要调用几个简单的函数就可以完成通信。

3. STM32的I2C编程实战

3.1 硬件I2C的配置

STM32芯片内部集成了硬件I2C控制器,可以自动处理时序、应答等细节,大大简化了编程工作。

使用STM32CubeMX配置I2C非常方便,只需要几个步骤:

  1. 在Pinout & Configuration页面,找到I2C外设(比如I2C1),点击Mode,选择I2C模式
  2. 系统会自动分配SDA和SCL引脚,也可以手动修改
  3. 在Configuration页面,设置I2C参数,主要是时钟速率(比如100kHz或400kHz)
  4. 生成代码

生成的代码中会有一个初始化函数,类似这样:

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相关的问题,欢迎留言交流讨论。

更多编程学习资源

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
Linux系统工程师
手记
粉丝
105
获赞与收藏
288

关注作者,订阅最新文章

阅读免费教程

  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号

举报

0/150
提交
取消