从一次诡异的传感器读数说起

上周调一块车载环境感知板,MPU6050传感器偶尔会读出一串0xFF。示波器抓了一下SDA线,发现时钟拉伸(clock stretching)阶段SCL被拉低的时间超时了。问题出在I2C适配器的时钟控制逻辑没处理好从设备的等待。这种问题在嵌入式开发里太典型了——你以为在写传感器驱动,实际上是在和I2C控制器与总线协议打交道。

I2C驱动在Linux里分为两层:适配器驱动(控制I2C控制器)和客户端驱动(操作具体设备)。很多工程师只关心后者,但真正踩坑往往在前者。


I2C适配器驱动:总线上的“交警”

适配器驱动对应i2c_adapter,它抽象了SoC内部的I2C控制器。以NXP的IMX6ULL为例,它的I2C控制器驱动在drivers/i2c/busses/i2c-imx.c。注册一个适配器的核心是填充i2c_algorithm

static const struct i2c_algorithm i2c_imx_algo = {
    .master_xfer    = i2c_imx_xfer,        // 最关键的传输函数
    .functionality  = i2c_imx_func,        // 告诉系统支持哪些模式
};

master_xfer是实际发起总线时序的函数。这里有个坑:如果控制器不支持时钟拉伸,而你的传感器需要它(比如某些温湿度传感器),那就得用GPIO模拟I2C或者换硬件方案。检查functionality返回的I2C_FUNC_I2C等标志很重要。

适配器驱动一般由芯片原厂提供,但如果你用FPGA软核或自定义I2C控制器,就得自己实现。这时候要特别注意时序——Linux的I2C核心不帮你处理超时和重试,这些都得在master_xfer里考虑清楚。


客户端驱动:如何“对话”传感器

客户端驱动对应i2c_client,代表挂载在总线上的具体设备。以MPU6050为例,注册一个客户端驱动有三种常见方式:

1. 设备树绑定(推荐)

// 设备树节点
mpu6050: imu@68 {
    compatible = "invensense,mpu6050";
    reg = <0x68>;                    // 7位地址,别写成0xD0
    interrupt-parent = <&gpio4>;
    interrupts = <15 IRQ_TYPE_EDGE_RISING>;
    vdd-supply = <&reg_3v3>;         // 电源管理,很多驱动漏了这个
};

驱动里通过of_match_table匹配:

static const struct of_device_id mpu6050_of_match[] = {
    { .compatible = "invensense,mpu6050" },
    {}
};
MODULE_DEVICE_TABLE(of, mpu6050_of_match);

2. 板级信息注册(老内核遗留)
现在基本不用了,但你可能在旧项目里看到i2c_board_info静态定义,然后调用i2c_register_board_info。这种硬编码方式不灵活,改个地址都要重新编译内核。

3. 用户空间实例化
手动创建设备文件:

echo mpu6050 0x68 > /sys/bus/i2c/devices/i2c-1/new_device

调试时特别有用,不用重新编译驱动就能测试设备。


传感器驱动实战:MPU6050的读写模式

I2C设备有两种基本操作:i2c_master_sendi2c_master_recv。但传感器常用的是i2c_transfer,因为它支持复合消息(写地址后立刻读数据)。

// 读取MPU6050的WHO_AM_I寄存器(0x75)
static int mpu6050_read_id(struct i2c_client *client)
{
    u8 reg = 0x75;
    u8 val;
    struct i2c_msg msg[2] = {
        {
            .addr = client->addr,
            .flags = 0,                    // 写标志
            .len = 1,
            .buf = &reg,
        },
        {
            .addr = client->addr,
            .flags = I2C_M_RD,            // 读标志
            .len = 1,
            .buf = &val,
        },
    };
    
    int ret = i2c_transfer(client->adapter, msg, 2);
    if (ret < 0) {
        dev_err(&client->dev, "读ID失败: %d\n", ret);
        return ret;
    }
    
    dev_info(&client->dev, "WHO_AM_I = 0x%02x\n", val);
    return 0;
}

注意i2c_transfer返回的是成功传输的消息数,不是字节数。上面例子中如果返回2才表示完全成功。很多新手在这里判断错误,导致异常分支进不去。


那些年踩过的I2C坑

1. 地址对齐问题
I2C设备地址是7位,但内核里经常看到左移一位(client->addr << 1)的写法。这是因为老协议里地址占用8位,最低位是读写标志。现在大部分驱动已经处理好了,但如果你自己封装底层函数,要确认适配器期望的地址格式。

2. 超时与重试
总线忙或者从设备无响应时,默认超时可能不够。可以通过调整适配器的timeout参数(单位毫秒):

client->adapter->timeout = 2000;  // 2秒超时

但别设太大,否则系统可能卡死。更好的做法是在驱动里实现重试机制,并记录失败次数用于诊断。

3. 电源管理
传感器通常有VDD和VDDIO两种电源。VDD是核心电源,VDDIO是IO电平电源。两者电压不一致时,需要在设备树里分别指定vdd-supplyvddio-supply。上电顺序不对可能导致通信失败。

4. 并发访问
一个适配器挂多个设备时,驱动要保证i2c_transfer的原子性。好消息是Linux的I2C核心已经用适配器锁(adapter->lock)处理了这个问题。但如果你在驱动里自己操作GPIO模拟I2C,记得加锁。

5. 调试技巧
打开I2C调试信息:

echo 1 > /sys/module/i2c_core/parameters/debug

或者动态修改日志级别:

dev_dbg(&client->dev, "传输数据: %*ph\n", len, buf);

%*ph是内核特有的打印hex数组的格式,比循环打印方便多了。


个人经验建议

I2C驱动调试,三分看代码,七分看硬件。遇到通信失败:

第一,用示波器或逻辑分析仪抓波形,确认起始信号、地址、ACK是否正常。很多问题其实是上拉电阻不合适(通常4.7kΩ,但长总线要减小)或者电源不稳。

第二,检查设备树里reg地址是否正确,以及是否与其他设备冲突。I2C地址可以软件修改的传感器(比如通过配置引脚),一定要确认硬件状态和软件配置一致。

第三,关注/sys/bus/i2c/devices/下的信息。这里能看到所有注册的适配器和客户端,以及它们的名称、地址。i2cdetect工具(来自i2c-tools包)在用户空间快速扫描设备,比写驱动测试更快。

最后,嵌入式Linux的I2C子系统已经相当成熟,除非做芯片原厂开发,否则不建议自己写适配器驱动。把精力放在客户端驱动的稳定性、功耗管理和错误恢复上,这些才是产品差异化的地方。

车载环境里,I2C总线常受电源噪声干扰。遇到偶发通信错误时,除了检查硬件滤波,可以在驱动里加入CRC校验和自动重试。我们曾经在胎压监测模块里实现三级重试:立即重试、延迟10ms重试、复位传感器后重试。这个策略后来成了我们车载传感器的标准错误处理流程。

驱动写完后,用i2c-stress测试工具跑个压力测试。它能模拟并发访问和异常场景,比你自己写测试用例全面得多。记住:稳定的I2C驱动不是一次调通的,是在各种边缘情况下磨出来的。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐