2、I2C接口协议详解:I2C时序、MTK I2C控制器配置、从设备地址与读写操作

2.1 先聊聊I2C总线的基本概念

I2C总线,说白了就是两根线搞定一切通信。一根SCL(时钟线),一根SDA(数据线)。我刚开始接触这个协议时,觉得它太简单了,不就是两根线嘛。后来踩了不少坑才明白,越是简单的协议,细节越要命。

I2C是主从架构,主设备发起通信,从设备响应。MTK平台里,主设备通常是AP或者MCU,从设备就是各种外设——传感器、音频Codec、PMIC等等。你想想看,一个系统里挂十几个I2C设备很常见,地址冲突的问题我遇到过好几次。

核心要点:I2C总线上的每个从设备必须有唯一的7位或10位地址。MTK平台默认使用7位地址,除非你特别配置了10位地址模式。

2.2 I2C时序,你真的看懂了吗?

时序这东西,看datasheet觉得都懂,一上示波器就懵了。我来说说几个关键点。

2.2.1 起始条件和停止条件

起始条件:SCL高电平时,SDA从高变低。
停止条件:SCL高电平时,SDA从低变高。

嗯,这里要注意:这两个条件是I2C通信的「开关」。没有起始条件,从设备不会理你。我曾经在调试一个TP(触摸屏)时,发现怎么发数据都没反应,最后用示波器一看,起始条件没做对——SCL还没拉高,SDA就跳了。

2.2.2 数据采样与ACK/NACK

数据采样发生在SCL的上升沿。发送方在SCL低电平时改变SDA,接收方在SCL高电平时读取SDA。每个字节8位,后面跟一个ACK位。

  • ACK(应答):接收方在第9个SCL周期拉低SDA,表示「收到,继续」。
  • NACK(非应答):接收方在第9个SCL周期释放SDA(保持高),表示「别发了,我收够了」或者「出错了」。

我的经验:调试时如果发现从设备一直NACK,先别急着怀疑硬件。检查一下地址对不对,尤其是7位地址左移一位加上读写位这个操作,新手很容易搞错。

2.2.3 时钟拉伸(Clock Stretching)

有些从设备处理速度慢,会拉低SCL让主设备等一等。这叫时钟拉伸。MTK的I2C控制器默认支持这个功能,但有些低端外设不支持。我遇到过一颗PMIC,它偶尔会拉伸时钟,结果主设备超时了,整个系统卡死。解决方案是把I2C控制器的超时时间调大一点。

2.3 MTK I2C控制器配置实战

MTK平台的I2C控制器,说白了就是一组寄存器。你配置好时钟、地址、传输模式,然后往FIFO里丢数据就行。但配置过程有几个坑,我一个个说。

2.3.1 时钟频率配置

I2C的时钟频率由SCL的高低电平时间决定。MTK的控制器里,有专门的寄存器配置高电平和低电平的计数值。

// MTK I2C时钟配置示例(以MT6765为例)
// 目标频率:400kHz(Fast Mode)
// 源时钟:26MHz
// 计算公式:SCL高电平时间 = (HCNT + 1) / 源时钟频率
//           SCL低电平时间 = (LCNT + 1) / 源时钟频率

#define I2C_CLK_SRC 26000000  // 26MHz
#define TARGET_FREQ 400000    // 400kHz

// 计算HCNT和LCNT
// 占空比一般设为50%,即HCNT ≈ LCNT
uint32_t half_period = (I2C_CLK_SRC / TARGET_FREQ) / 2;
uint32_t HCNT = half_period - 1;
uint32_t LCNT = half_period - 1;

// 写入寄存器
I2C_TIMING_REG = (HCNT << 16) | (LCNT & 0xFFFF);

注意:实际项目中,HCNT和LCNT不能直接按公式算完就用。因为PCB走线、上拉电阻、从设备负载都会影响实际频率。我习惯先算个理论值,然后用示波器实测,再微调。一般HCNT比LCNT稍微大一点,因为上升沿受上拉电阻影响会慢一些。

2.3.2 传输模式选择

MTK的I2C控制器支持几种传输模式:

模式 说明 适用场景
FIFO模式 数据通过FIFO传输,CPU参与较少 小数据量传输(<64字节)
DMA模式 数据通过DMA直接搬运,CPU几乎不参与 大数据量传输(如摄像头配置)
中断模式 每传输一个字节触发一次中断 实时性要求高的场景

我个人习惯:小数据量用FIFO模式,大数据量用DMA模式。中断模式很少用,因为频繁中断会影响系统性能。

2.3.3 从设备地址配置

MTK的I2C控制器里,从设备地址是单独配置的。注意:地址是7位的,但写入寄存器时要左移1位,最低位是读写位。

// 假设从设备地址为0x68(7位)
// 写操作:0x68 << 1 | 0 = 0xD0
// 读操作:0x68 << 1 | 1 = 0xD1

uint8_t slave_addr = 0x68;
uint8_t write_addr = (slave_addr << 1) | 0;  // 0xD0
uint8_t read_addr  = (slave_addr << 1) | 1;  // 0xD1

// 配置到控制器
I2C_SLAVE_ADDR_REG = write_addr;

避坑指南:我曾经遇到一个传感器,它的datasheet上写的是8位地址0xD0。我直接把这个值写进寄存器,结果怎么都通信不上。后来才发现,datasheet上给的其实是左移后的地址。所以一定要看清楚,datasheet给的是7位还是8位地址。

2.4 读写操作详解

I2C的读写操作,说白了就是主设备发地址,然后发数据或者收数据。但具体流程有几个变种,我一个个说。

2.4.1 写操作流程

写操作是最简单的:

  1. 主设备发送起始条件
  2. 主设备发送从设备地址 + 写位(0)
  3. 从设备回复ACK
  4. 主设备发送寄存器地址(要写到哪里)
  5. 从设备回复ACK
  6. 主设备发送数据(一个字节或多个字节)
  7. 每发一个字节,从设备回复ACK
  8. 主设备发送停止条件

嗯,这里要注意:有些从设备支持连续写,就是发完寄存器地址后,连续发多个字节,地址自动递增。但有些设备不支持,必须每次写之前重新发寄存器地址。

2.4.2 读操作流程

读操作稍微复杂一点,因为要先告诉从设备读哪个寄存器,然后再读数据。这中间需要一次「重启条件」(Repeated Start)。

  1. 主设备发送起始条件
  2. 主设备发送从设备地址 + 写位(0)
  3. 从设备回复ACK
  4. 主设备发送寄存器地址
  5. 从设备回复ACK
  6. 主设备发送重启条件(不是停止条件!)
  7. 主设备发送从设备地址 + 读位(1)
  8. 从设备回复ACK
  9. 从设备发送数据,主设备每收一个字节回复ACK
  10. 主设备收到最后一个字节后回复NACK
  11. 主设备发送停止条件

关键点:第6步的重启条件很多人会搞错。如果你发了停止条件再发起始条件,就成了两次独立的传输。有些从设备会因此丢失寄存器地址信息。所以一定要用重启条件。

2.4.3 MTK平台下的读写API

MTK的Linux内核驱动里,I2C读写有封装好的API。我一般直接用这些,很少去操作寄存器。

// MTK平台I2C读写示例
#include <linux/i2c.h>

// 写操作
int mtk_i2c_write(struct i2c_client *client, uint8_t reg, uint8_t *data, int len)
{
    struct i2c_msg msg;
    uint8_t buf[len + 1];
    
    buf[0] = reg;
    memcpy(&buf[1], data, len);
    
    msg.addr = client->addr;
    msg.flags = 0;  // 写操作
    msg.len = len + 1;
    msg.buf = buf;
    
    return i2c_transfer(client->adapter, &msg, 1);
}

// 读操作
int mtk_i2c_read(struct i2c_client *client, uint8_t reg, uint8_t *data, int len)
{
    struct i2c_msg msgs[2];
    
    // 第一条消息:写寄存器地址
    msgs[0].addr = client->addr;
    msgs[0].flags = 0;  // 写
    msgs[0].len = 1;
    msgs[0].buf = &reg;
    
    // 第二条消息:读数据
    msgs[1].addr = client->addr;
    msgs[1].flags = I2C_M_RD;  // 读
    msgs[1].len = len;
    msgs[1].buf = data;
    
    return i2c_transfer(client->adapter, msgs, 2);
}

我的习惯:写驱动时,我一般会封装一个「写寄存器」和「读寄存器」的函数,参数就是寄存器地址和值。这样上层调用起来很方便。另外,调试时我会加一些打印,把每次读写的数据打出来,方便定位问题。

2.5 常见问题与调试技巧

做I2C调试这么多年,我总结了几条经验:

  • 总线卡死:如果SDA一直被拉低,说明有设备在占用总线。可以试试复位那个设备,或者重新初始化I2C控制器。
  • 地址冲突:两个设备地址一样,会导致通信混乱。用I2C扫描工具扫一遍,看看哪些地址有回应。
  • 时序不满足:频率太高或者上升沿太慢,会导致数据采样错误。降频试试,或者换小一点的上拉电阻。
  • 从设备没回应:先检查供电,再检查地址,最后检查时序。我遇到过一颗传感器,上电后要等100ms才能通信,datasheet里写了,但我没注意。

好了,I2C协议这部分就讲到这里。下一章我们聊聊SPI,那个协议比I2C快,但线也多,各有各的坑。