在嵌入式系统中,I2C(Inter-Integrated Circuit)是一种高效的同步串行通信协议,凭借两线制(SDA 数据线 + SCL 时钟线)、主从架构、多设备组网等优势,广泛应用于 EEPROM、传感器、OLED 显示屏等外设通信场景。

一、I2C 核心概念

1.1 I2C 协议核心特性

I2C 的核心是 “时序约定” 与 “总线规则”。

  • 总线结构:仅需两根线(SDA 串行数据线、SCL 串行时钟线),所有设备共享总线,支持多主多从模式;
  • 通信类型:同步通信、半双工;
  • 寻址方式:7 位从机地址(支持 128 个设备)+1 位读写方向位(0 = 写,1 = 读),共 8 位构成通信起始的地址帧。

1.2 总线关键特性

I2C 总线能实现多设备共享,核心依赖 “开漏输出” 与 “上拉电阻” 的组合。

1.2.1 线与逻辑

当多个设备连接到 SDA/SCL 总线时,总线实际电平由 “所有设备输出的最小值” 决定:

设备 A 输出设备 B 输出总线实际电平说明
高电平(1)高电平(1)高电平(1)无设备拉低总线
高电平(1)低电平(0)低电平(0)任一设备拉低,总线即低
低电平(0)高电平(1)低电平(0)任一设备拉低,总线即低
低电平(0)低电平(0)低电平(0)多设备同时拉低,无冲突
1.2.2 推挽模式与开漏模式

I2C 总线必须使用开漏输出模式,二者区别如下:

序号MOS1MOS2pad电平
1高电平(1)低电平(0)高电平(1)
2低电平(0)高电平(1)低电平(0)
3低电平(0)低电平(0)高阻态 / 悬空态
4高电平(1)高电平(1)不允许
模式控制逻辑核心优势适用场景
推挽1、2共同作用(1:推 / 2:挽)驱动能力强,速度快GPIO 输出(如 LED)
开漏2、3共同作用支持线与逻辑,多设备共享总线I2C 总线(SDA/SCL)

注意:I2C 总线需外接 4.7k~10kΩ 上拉电阻到 3.3V,确保高阻态时总线为高电平。

1.3 I2C 核心通信时序

1.3.1 开始信号
  • 条件:总线空闲时(SDA、SCL 均为高电平);
  • 动作:SCL 保持高电平时,SDA 产生下降沿
  • 意义:通知所有从设备,主机即将开始通信。
1.3.2 终止信号
  • 条件:SCL 为高电平时;
  • 动作:SDA 产生上升沿
  • 意义:通知所有从设备,通信结束,总线恢复空闲。
1.3.3 数据传输规则
  • 数据位:每帧 8 位,按MSB(最高位)优先传输;
  • 时钟同步:
    • SCL 为低电平时,只允许发送方改变数据 SDA 线,接收方不得采样 SDA。
    • SCL 为高电平时,只允许接收方采样数据 SDA 线,发送方需保证 SDA 稳定。
1.3.4 应答机制
  • 每传输 1 字节(8 位)后,紧跟 1 位应答位(第 9 位);
  • ACK(应答):接收方拉低 SDA(电平 0),表示成功接收数据;
  • NACK(非应答):接收方不拉低 SDA(电平 1),表示接收失败或无需继续接收(如读操作最后 1 字节)。

二、底层驱动实现

2.1 核心寄存器宏定义

先定义 I2C 控制器关键寄存器的位掩码,简化代码编写,避免直接写魔法数:

// I2C控制寄存器(I2CR)关键位
#define I2CR_IEN    (1 << 7)  // I2C使能位:1=使能,0=失能(复位状态)
#define I2CR_MSTA   (1 << 5)  // 主从模式选择:1=主机模式(产生Start/Stop),0=从机模式
#define I2CR_MTX    (1 << 4)  // 收发模式选择:1=发送模式,0=接收模式
#define I2CR_TXAK   (1 << 3)  // 应答类型控制:1=发送NACK,0=发送ACK
#define I2CR_RSTA   (1 << 2)  // 重发起始信号:1=产生Repeat Start
 
// I2C状态寄存器(I2SR)关键位
#define I2SR_ICF    (1 << 7)  // 数据传输完成标志:1=传输完成
#define I2SR_IBB    (1 << 5)  // 总线忙标志:1=总线忙,0=总线空闲
#define I2SR_IAL    (1 << 4)  // 仲裁丢失标志:1=失去总线仲裁(多主机冲突)
#define I2SR_IIF    (1 << 1)  // 中断标志:1=产生中断(传输1字节完成)
#define I2SR_RXAK   (1 << 0)  // 接收应答标志:1=接收NACK,0=接收ACK

2.2 I2C 初始化函数

初始化流程:引脚配置 → I2C 控制器复位 → 时钟配置 → 使能 I2C,确保控制器处于就绪状态:

void i2c_init(I2C_Type *base)
{
  // 引脚复用配置
  IOMUXC_SetPinMux(IOMUXC_UART4_RX_DATA_I2C1_SDA, 1);  // 复用为I2C1_SDA
  IOMUXC_SetPinMux(IOMUXC_UART4_TX_DATA_I2C1_SCL, 1);  // 复用为I2C1_SCL
  
  // 电气特性配置
  IOMUXC_SetPinConfig(IOMUXC_UART4_RX_DATA_I2C1_SDA, 0x10B0);
  IOMUXC_SetPinConfig(IOMUXC_UART4_TX_DATA_I2C1_SCL, 0x10B0);
  
  // 复位I2C控制器
  base->I2CR &= ~I2CR_IEN;
  
  // 配置I2C时钟频率
  base->IFDR = 0x15;
  
  // 使能I2C控制器
  base->I2CR |= I2CR_IEN;
}

2.3 应答等待函数

用于等待 1 字节传输完成,并检查从机应答状态,是 I2C 通信的 “时序同步核心”:

int wait_i2c_iif(I2C_Type *base)
{
  uint32_t timeout = I2C_TIMEOUT_MS * 1000; // 超时计数(微秒级)
  // 等待IIF标志置1(1字节传输完成)
  while ((base->I2SR & I2SR_IIF) == 0)
  {
    // 设计超时机制
    timeout--;
    if(timeout == 0)
    {
      return -2; // 超时错误
    }
    delay_us(1);
  }
  
  // 清除IIF中断标志(写0清除)
  base->I2SR &= ~I2SR_IIF;
  
  // 检查RXAK标志:1=从机回复NACK(通信失败),0=ACK(成功)
  if ((base->I2SR & I2SR_RXAK) != 0)
  {
    return -1;  // 应答失败
  }
  return 0;  // 应答成功
}

2.4 I2C 写操作函数

I2C 写操作流程如下:

写操作流程图

代码实现:

// 宏定义:启用AT24C02页写入适配
#define AT24C02 1
 
void i2c_write(I2C_Type *base, unsigned char dev_addr, unsigned short reg_addr, int reg_len, unsigned char *data, unsigned int len)
{
  int stat = 0;
  // 0. 清除IAL(仲裁丢失)、IIF(中断标志)
  base->I2SR &= ~(I2SR_IAL | I2SR_IIF);
 
  // 1. 设置为主机发送模式
  base->I2CR |= I2CR_MTX;
 
  // 2. 发送起始信号(MSTA=1表示主机模式)
  base->I2CR |= I2CR_MSTA;
 
  // 3. 发送设备地址 + 写位(最低位0表示写)
  base->I2DR = (dev_addr << 1) | 0;
  stat = wait_i2c_iif(base);
  if (stat != 0)
    goto stop; // 无应答/超时,直接停止
 
  // 4. 发送寄存器地址(支持多字节寄存器地址,适配不同设备)
  int i = reg_len - 1;
  for (; i >= 0; i--)
  {
    base->I2DR = (reg_addr >> (i * 8)) & 0xFF;
    stat = wait_i2c_iif(base);
    if (stat != 0)
      goto stop;
  }
 
  // 5. 发送数据(核心:适配AT24C02页写入)
  for (i = 0; i < len; i++)
  {
    base->I2DR = data[i];
    stat = wait_i2c_iif(base);
 
#if AT24C02
    // AT24C02每8字节一页,超过页边界需重启通信
    if (((reg_addr + (i + 1)) % 8) == 0 && ((i + 1) < len))
    {
      // 5.1 发送停止信号,等待EEPROM写入完成
      base->I2CR &= ~I2CR_MSTA;
      delay_ms(5);
 
      // 5.2 重新发送起始信号
      base->I2CR |= I2CR_MSTA;
 
      // 5.3 重新发送设备地址+写位
      base->I2DR = ((dev_addr << 1) | 0);
      stat = wait_i2c_iif(base);
      if (stat != 0)
        goto stop;
 
      // 5.4 重新发送新的寄存器地址(跨页后的地址)
      int j = reg_len - 1;
      for (; j >= 0; j--)
      {
        base->I2DR = ((reg_addr + (i + 1)) >> (j * 8)) & 0xFF;
        stat = wait_i2c_iif(base);
        if (stat != 0)
          goto stop;
      }
    }
#endif
    if (stat != 0)
      goto stop;
  }
 
stop:
  // 发送停止信号,等待总线空闲
  base->I2CR &= ~I2CR_MSTA;
  while ((base->I2SR & I2SR_IBB) != 0) // 等待IBB位清零(总线忙标志)
  {
    delay_us(1);
  }
#if AT24C02
  delay_ms(5); // AT24C02写入后需要等待数据固化
#endif
}

核心逻辑解析

  • 起始信号 + 设备地址:I2C 通信的第一步是发送起始信号,然后是设备地址(左移 1 位,最低位表示读写);
  • 寄存器地址发送:支持多字节寄存器地址(如 reg_len=2 时适配 16 位寄存器);
  • AT24C02 页写入:每 8 字节为一页,当 (寄存器地址+已发送字节数)%8==0 且未发完数据时,需停止→等待→重新起始→发送新地址,避免跨页写入失败;
  • 停止信号:通信结束后必须发送停止信号,释放 I2C 总线。

2.5 I2C 读操作函数

I2C 读操作流程如下:


读操作流程

代码实现:

void i2c_read(I2C_Type *base, unsigned char dev_addr, unsigned short reg_addr, int reg_len, unsigned char *data, unsigned int len)
{
  int stat = 0;
  // 0. 清除标志位
  base->I2SR &= ~(I2SR_IAL | I2SR_IIF);
 
  // 1. 第一步:伪写(发送寄存器地址,为读取做准备)
  base->I2CR |= I2CR_MTX;  // 发送模式
  base->I2CR |= I2CR_MSTA; // 起始信号
  // 1.1 发送设备地址+写位
  base->I2DR = (dev_addr << 1) | 0;
  stat = wait_i2c_iif(base);
  if (stat != 0)
    goto stop;
  // 1.2 发送寄存器地址
  int i = reg_len - 1;
  for (; i >= 0; i--)
  {
    base->I2DR = (reg_addr >> (i * 8)) & 0xFF;
    stat = wait_i2c_iif(base);
    if (stat != 0)
      goto stop;
  }
 
  // 2. 第二步:重新发起起始信号,切换为读模式
  base->I2CR |= I2CR_RSTA; // 重发起始信号
  // 2.1 发送设备地址+读位(最低位1表示读)
  base->I2DR = ((dev_addr << 1) | 1);
  stat = wait_i2c_iif(base);
  if (stat != 0)
    goto stop;
 
  // 3. 切换为接收模式,配置应答策略
  base->I2CR &= ~I2CR_MTX; // 接收模式
  if (len > 1)
  {
    base->I2CR &= ~I2CR_TXAK; // 多字节读取:除最后一个字节外,发送ACK
  }
  else
  {
    base->I2CR |= I2CR_TXAK;  // 单字节读取:直接发送NACK
  }
 
  // 4. 伪读:触发第一次数据接收(IMX6ULL I2C控制器要求)
  data[0] = base->I2DR;
 
  // 5. 循环读取数据
  for (i = 0; i < len; i++)
  {
    stat = wait_i2c_iif(base);
    // 倒数第二个字节时,切换为NACK(告知从机停止发送)
    if (i == len - 2)
    {
      base->I2CR |= I2CR_TXAK;
    }
    // 最后一个字节时,切换为发送模式,准备停止
    if (i == len - 1)
    {
      base->I2CR |= I2CR_MTX;
    }
    data[i] = base->I2DR; // 读取数据
    if (stat != 0)
      goto stop;
  }
 
stop:
  // 发送停止信号,释放总线
  base->I2CR &= ~I2CR_MSTA;
  while ((base->I2SR & I2SR_IBB) != 0)
  {
    delay_us(1);
  }
}

核心难点解析

  • 伪写阶段:I2C 读操作必须先告知从机 “要读哪个寄存器”,因此需要先写寄存器地址;
  • 重发起始信号:从 “写模式” 切换到 “读模式” 需重新发送起始信号,避免总线释放;
  • 应答策略:I2C 协议规定,主机接收数据后需发送 ACK/NACK—— 多字节读取时,最后一个字节必须发 NACK,否则从机会继续发送数据;
  • 伪读操作:IMX6ULL 的 I2C 控制器需要先读一次 I2DR 寄存器,才能触发后续的数据接收流程。

2.6 封装通用接口

为了简化调用,封装 transfer 函数,通过 I2C_Msg 结构体统一管理读写参数:

2.6.1 定义 I2C_Msg 结构体
// i2c.h中添加
typedef enum
{
  I2C_Write = 0,
  I2C_Read
} I2C_Dir;
 
typedef struct
{
  unsigned char dev_addr; // 设备地址
  unsigned short reg_addr; // 寄存器地址
  int reg_len; // 寄存器地址长度(1/2字节)
  unsigned char *data; // 数据缓冲区
  unsigned int len; // 数据长度
  I2C_Dir dir; // 读写方向
} I2C_Msg;
2.6.2 transfer 函数实现
void transfer(I2C_Type *base, struct I2C_Msg *msg)
{
  if (base == NULL || msg == NULL)
  {
    return; // 参数校验
  }
 
  if (msg->dir == I2C_Write)
  {
    i2c_write(base, msg->dev_addr, msg->reg_addr, msg->reg_len, msg->data, msg->len);
  }
  else
  {
    i2c_read(base, msg->dev_addr, msg->reg_addr, msg->reg_len, msg->data, msg->len);
  }
}

三、实战:读取 LM75 温度传感器

3.1 代码实现

#include "lm75.h"
#include "i2c.h"
#include "stdio.h"
#include "string.h"
 
float get_lm75_temp(void)
{
  unsigned char read_buff[32] = {0};
  struct I2C_Msg lm75_msg =
  {
    .dev_addr = 0x48,
    .reg_addr = 0x00,
    .reg_len = 1,
    .data = read_buff,
    .len = 2,
    .dir = I2C_Read
  };
 
  printf("start:LM75\n");
  transfer(I2C1, &lm75_msg); // 调用通用接口读取数据
 
  // LM75温度数据解析:11位有效数据,分辨率0.5℃
  unsigned short temp = (read_buff[0] << 8) | (read_buff[1] << 0);
  temp = temp >> 7; // 右移7位,提取有效温度值
  printf("lm75: temp = %u\n", temp);
  float ftemp = temp * 0.5; // 转换为实际温度
  return ftemp;
}

3.2 主函数调用

int main(void)
{
  system_interrupt_init();
  clock_init();
  beep_init();
  led_init();
  key_init();
  gpt1_init();
  uart1_init();
  i2c_init(I2C1); // 初始化I2C1
 
  while (1)
  {
    float ftemp = get_lm75_temp();
    int n = (int)(ftemp * 100) / 100; // 整数部分
    int m = (int)(ftemp * 100) % 100; // 小数部分
    printf("temp = %d.%d\n", n, m);
    delay_ms(100); // 100ms读取一次
  }
  return 0;
}

四、总结

        本文基于 IMX6ULL 实现了完整的 I2C 读写驱动,重点讲解了写操作的 AT24C02 页适配、读操作的 “伪写 - 重读” 逻辑,以及通用 transfer 接口的封装。通过 LM75 温度传感器的实战验证,完整覆盖了 I2C 驱动开发的核心要点。

        这套驱动框架可直接适配大多数 I2C 设备,只需修改dev_addr、reg_addr等参数即可快速移植,是嵌入式 I2C 开发的通用方案。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐