12C总线和协议

3 阅读19分钟

大家好,我是良许。

在嵌入式开发中,我们经常需要让主控芯片与各种外设进行通信,比如读取温湿度传感器的数据、控制 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 相关的问题,欢迎留言交流讨论。

更多编程学习资源