4. I2C总线驱动开发:从协议到实战

I2C总线,说白了就是嵌入式设备里最常用的“短距离串行聊天协议”。两根线——SCL(时钟)和SDA(数据),就能挂一堆传感器、存储器、IO扩展芯片。我在鸿蒙南向开发中,几乎每个传感器项目都离不开它。

这一章,咱们从协议基础讲起,一路走到控制器驱动、设备驱动,最后拿一个温度传感器练练手。嗯,内容有点多,但都是干货。

4.1 I2C协议基础:两根线的默契

I2C是主从结构。主设备(通常是SoC)产生时钟,从设备(传感器)响应。每次通信以“起始条件”开始,以“停止条件”结束。

关键信号时序:

  • 起始条件(S):SCL高电平时,SDA从高变低。
  • 停止条件(P):SCL高电平时,SDA从低变高。
  • 数据采样:SCL低电平时SDA变化,SCL高电平时采样数据。
  • 应答(ACK):每字节后接收方拉低SDA表示收到。

地址呢?标准模式是7位地址,后面跟1位读写位。0表示写,1表示读。举个例子:一个温度传感器地址是0x48,你要读它,发送的地址字节就是0x48 << 1 | 0x01 = 0x91。

我的经验:很多新手搞混了“设备地址”和“发送的地址字节”。设备地址是7位,发送时左移一位,最低位填读写标志。我在项目中见过有人直接拿7位地址去发,结果设备死活不应答。

I2C的速率分几种:标准模式100kHz,快速模式400kHz,高速模式3.4MHz。传感器驱动一般用100kHz或400kHz,够用了。速率太快,线长一点就容易出问题。

模式 最大速率 适用场景
标准模式 100 kHz 大多数传感器、EEPROM
快速模式 400 kHz 高速传感器、音频编解码器
高速模式 3.4 MHz 视频、大容量存储

4.2 I2C控制器驱动:鸿蒙的I2C核心

在鸿蒙里,I2C控制器驱动属于HDF(硬件驱动框架)的Platform层。说白了,它负责管理SoC内部的I2C外设——配置时钟、处理中断、收发数据。

控制器驱动的核心接口就几个:

  • I2cOpen:打开一个I2C控制器,返回句柄。
  • I2cTransfer:执行一次I2C传输(包含起始、数据、停止)。
  • I2cClose:关闭控制器。

鸿蒙把I2C传输抽象成“消息队列”。你准备好一个I2cMsg结构体数组,每个结构体描述一段数据的方向、长度和标志。然后调用I2cTransfer一次性发出去。

struct I2cMsg {
    uint16_t addr;      // 从设备地址(7位,不包含读写位)
    uint16_t len;       // 数据长度
    uint8_t *buf;       // 数据缓冲区
    uint16_t flags;     // 标志:I2C_FLAG_READ(读)或 0(写)
};

注意:addr字段是7位设备地址,不是左移后的字节。鸿蒙内部会帮你左移并加上读写位。我曾经在移植驱动时,自己手动左移了一次,结果地址变成0x90,设备直接不理我。

控制器驱动的初始化流程,我总结了一下:

  1. 设备树或HCS配置中获取I2C控制器的基地址、中断号、时钟频率。
  2. 映射寄存器地址,复位I2C外设。
  3. 配置时钟分频,设置SCL频率。
  4. 注册中断处理函数(如果使用中断模式)。
  5. 调用PlatformDeviceAdd注册到HDF框架。

4.3 I2C设备驱动:让传感器开口说话

设备驱动是跑在控制器驱动之上的。它不关心寄存器怎么配,只关心怎么发数据、收数据。鸿蒙里,设备驱动通过HDF的I2cCntlr句柄来操作。

典型的I2C设备驱动结构:

  • Probe函数:获取I2C控制器句柄,初始化设备(比如配置寄存器、校准)。
  • 读写函数:封装I2cTransfer,实现寄存器读写。
  • 上报函数:将传感器数据通过HDF的Input或Sensor模型上报。

读写一个寄存器,通常需要两步:先写寄存器地址,再读数据。鸿蒙里可以用两个I2cMsg组合:

int32_t ReadReg(uint16_t devAddr, uint8_t regAddr, uint8_t *value) {
    struct I2cMsg msgs[2];
    uint8_t regBuf = regAddr;

    msgs[0].addr = devAddr;
    msgs[0].len = 1;
    msgs[0].buf = ®Buf;
    msgs[0].flags = 0;  // 写

    msgs[1].addr = devAddr;
    msgs[1].len = 1;
    msgs[1].buf = value;
    msgs[1].flags = I2C_FLAG_READ;  // 读

    return I2cTransfer(g_i2cHandle, msgs, 2);
}

避坑指南:有些传感器支持“重复起始条件”(Restart),即写地址后不发送停止,直接发读地址。鸿蒙的I2cTransfer默认会在消息之间插入停止条件。如果你需要Restart,得把两个消息合并成一个,或者使用支持Restart的控制器驱动。我遇到过一颗气压传感器,必须用Restart才能读到正确数据,折腾了半天。

4.4 实战:读写温度传感器

咱们拿一个常见的温度传感器——LM75A来练手。它的I2C地址是0x48(7位),内部寄存器如下:

寄存器地址 名称 描述
0x00 温度寄存器 只读,11位温度数据(补码)
0x01 配置寄存器 读写,控制工作模式
0x02 THYST 读写,迟滞温度
0x03 TOS 读写,过温阈值

温度数据格式:16位寄存器,高11位是有效数据,低5位为0。分辨率0.125°C。正温度直接读,负温度用补码。

驱动实现步骤:

  1. 在HCS配置中声明LM75A设备,绑定I2C控制器。
  2. 实现Probe函数:打开I2C控制器,读取设备ID(如果有)确认连接。
  3. 实现温度读取函数:读寄存器0x00,解析11位数据。
  4. 通过HDF的Sensor模型上报温度值。

核心代码片段:

static int32_t Lm75aReadTemp(int16_t *temp) {
    uint8_t regAddr = 0x00;
    uint8_t buf[2] = {0};
    struct I2cMsg msgs[2];

    msgs[0].addr = LM75A_ADDR;
    msgs[0].len = 1;
    msgs[0].buf = ®Addr;
    msgs[0].flags = 0;

    msgs[1].addr = LM75A_ADDR;
    msgs[1].len = 2;
    msgs[1].buf = buf;
    msgs[1].flags = I2C_FLAG_READ;

    int32_t ret = I2cTransfer(g_i2cHandle, msgs, 2);
    if (ret != 0) {
        return ret;
    }

    // 解析11位补码数据
    int16_t raw = (int16_t)((buf[0] << 8) | buf[1]);
    raw = raw >> 5;  // 右移5位,得到11位有效数据
    if (raw & 0x0400) {  // 符号位判断
        raw |= 0xF800;   // 扩展符号位
    }
    *temp = raw;  // 单位:0.125°C
    return 0;
}

注意:解析补码时,一定要做符号扩展。我见过有人直接取高11位,负数就变成很大的正数。温度显示85°C,其实实际是-10°C。

下面这张图,展示了整个I2C驱动栈的层次关系:

I2C驱动栈层次结构 应用 / 传感器服务 I2C设备驱动(LM75A) I2C控制器驱动(HDF Platform) I2C总线硬件(SCL / SDA) 设备驱动通过HDF的I2C API调用控制器驱动,控制器驱动直接操作硬件寄存器

实际项目中,我建议你在Probe函数里加一个“设备检测”步骤——写一个测试寄存器,读回来对比。这样能快速确认I2C通信是否正常。我曾经在一款开发板上,I2C引脚被复用成了GPIO,折腾了两天才发现。