3、I2C触摸屏驱动:I2C协议基础、I2C设备驱动注册、I2C数据传输与中断处理

说到触摸屏驱动,I2C 协议是绕不开的坎。市面上绝大多数电容式触摸屏都跑在 I2C 总线上。我刚开始调触摸屏时,总觉得 I2C 简单——不就两根线嘛,SDA 和 SCL。但真正上手才发现,坑全藏在细节里。

这一章,咱们就把 I2C 触摸屏驱动的三个核心环节拆开揉碎:协议基础、驱动注册、数据传输与中断。嗯,都是实战中每天要打交道的。

3.1 I2C 协议基础:两根线上的舞蹈

I2C 是飞利浦(现 NXP)搞出来的串行总线协议。它只用两根线:SDA(数据线)和 SCL(时钟线)。所有设备都挂在这两根线上,通过地址区分。

说白了,I2C 就是个主从结构。主设备(一般是 SoC)发起通信,从设备(触摸屏控制器)响应。每次通信都遵循一套固定流程:

  1. 起始条件:SCL 高电平时,SDA 从高拉低
  2. 发送从设备地址 + 读写位:7 位地址 + 1 位 R/W
  3. 等待应答:从设备拉低 SDA 表示 ACK
  4. 数据传输:每字节 8 位,后面跟 ACK/NACK
  5. 停止条件:SCL 高电平时,SDA 从低拉高

关键点:触摸屏的 I2C 地址通常是 7 位,比如 0x38 或 0x41。但有些芯片支持地址配置引脚,同一个型号可能有两个地址。我遇到过一块板子,原理图上地址引脚悬空,结果读出来是 0x38,另一批板子拉了高电平,变成 0x39。驱动里写死地址就翻车了。

触摸屏的 I2C 通信有个特点:数据量不大。一次触摸事件通常只传 6~12 字节。但实时性要求高——你手指滑过去,驱动得在几毫秒内把坐标报上去。

下面这张图展示了 I2C 触摸屏驱动的整体知识结构,方便你建立全局认知:

I2C 触摸屏驱动知识体系 I2C 触摸屏驱动知识体系 I2C 协议基础 设备驱动注册 数据传输 中断处理 i2c_add_driver / probe 设备树匹配 / 地址匹配 i2c_master_send / i2c_master_recv i2c_transfer / 消息队列 request_threaded_irq 上半部 / 下半部 分工 ⚠ 常见坑点:地址冲突 | 时钟延展 | 中断丢失 | 传输超时 最终目标:稳定、低延迟的触摸数据上报

3.2 I2C 设备驱动注册:让内核认识你的触摸屏

驱动注册说白了就是告诉内核:「嘿,我这儿有个 I2C 设备,地址是 0x38,你帮我管着。」在 Linux 内核里,这事通过 i2c_add_driver 完成。

标准的注册流程分三步:

  1. 定义 i2c_device_id 表:声明设备名称和驱动数据
  2. 实现 probe 函数:设备被发现时调用,做初始化
  3. 注册 i2c_driver 结构体:把上面两个东西绑在一起

来看一段典型的触摸屏驱动注册代码:

static const struct i2c_device_id ft5x06_ts_id[] = {
    { "ft5x06", 0 },
    { }
};
MODULE_DEVICE_TABLE(i2c, ft5x06_ts_id);

static const struct of_device_id ft5x06_of_match[] = {
    { .compatible = "focaltech,ft5x06" },
    { }
};
MODULE_DEVICE_TABLE(of, ft5x06_of_match);

static int ft5x06_ts_probe(struct i2c_client *client,
                const struct i2c_device_id *id)
{
    struct ft5x06_ts_data *ts;
    int ret;

    // 分配私有数据结构
    ts = devm_kzalloc(&client->dev, sizeof(*ts), GFP_KERNEL);
    if (!ts)
        return -ENOMEM;

    ts->client = client;
    i2c_set_clientdata(client, ts);

    // 初始化 I2C 传输相关资源
    // ... 后续会展开

    return 0;
}

static struct i2c_driver ft5x06_ts_driver = {
    .driver = {
        .name   = "ft5x06_ts",
        .of_match_table = ft5x06_of_match,
    },
    .probe      = ft5x06_ts_probe,
    .id_table   = ft5x06_ts_id,
};

module_i2c_driver(ft5x06_ts_driver);

个人习惯:我写 I2C 驱动时,一定会同时提供 i2c_device_id 和 of_match_table。前者兼容老式板级代码,后者用于设备树。两个都写上,不管内核用哪种方式匹配,都能正常工作。

设备树里的节点长这样:

&i2c2 {
    status = "okay";
    clock-frequency = <400000>;

    ft5x06: touchscreen@38 {
        compatible = "focaltech,ft5x06";
        reg = <0x38>;
        interrupt-parent = <&gpio1>;
        interrupts = <8 IRQ_TYPE_EDGE_FALLING>;
        reset-gpios = <&gpio1 9 GPIO_ACTIVE_LOW>;
    };
};

这里有个细节:reg = <0x38> 就是 I2C 从设备地址。probe 时内核会把 client->addr 设成 0x38。你想想看,如果设备树里地址写错了,probe 根本不会触发——我踩过这个坑,查了两天才发现是设备树地址和芯片实际地址对不上。

3.3 I2C 数据传输:把触摸坐标读回来

驱动注册成功只是第一步。真正的硬仗是数据传输——怎么从触摸屏芯片里把坐标数据读出来。

Linux 内核提供了两套 I2C 传输 API:

API 说明 适用场景
i2c_master_send / i2c_master_recv 单次发送或接收,封装了起始/停止条件 简单的读写操作
i2c_transfer 支持消息队列,一次调用可组合多个读写操作 复合操作(如先写寄存器地址再读数据)
i2c_smbus_read_xxx / i2c_smbus_write_xxx SMBus 协议封装,更高级的接口 兼容 SMBus 的设备

触摸屏最常用的模式是:先写一个寄存器地址,然后连续读多个字节。用 i2c_transfer 最合适:

static int ft5x06_read_data(struct i2c_client *client, u8 reg,
                u8 *buf, int len)
{
    struct i2c_msg msg[2];
    int ret;

    // 消息 0:写寄存器地址
    msg[0].addr = client->addr;
    msg[0].flags = 0;           // 写
    msg[0].len = 1;
    msg[0].buf = &reg;

    // 消息 1:读数据
    msg[1].addr = client->addr;
    msg[1].flags = I2C_M_RD;    // 读
    msg[1].len = len;
    msg[1].buf = buf;

    ret = i2c_transfer(client->adapter, msg, 2);
    if (ret == 2)
        return 0;
    else
        return -EIO;
}

我曾经犯过一个低级错误:在中断上下文里直接调用 i2c_transfer。I2C 传输可能会睡眠(尤其是时钟延展时),而中断上下文不允许睡眠。结果内核直接报 BUG: scheduling while atomic。正确的做法是用 threaded IRQ,把 I2C 传输放到进程上下文。

说到时钟延展,这是 I2C 协议里一个容易忽略的点。从设备如果来不及处理数据,会把 SCL 拉低,逼主设备等待。大多数 SoC 的 I2C 控制器支持硬件时钟延展,但有些需要软件配合。嗯,这里要注意:如果遇到 I2C 传输超时,先拿逻辑分析仪看看 SCL 是不是被拉低了。

3.4 中断处理:别让触摸事件丢了

触摸屏的中断处理是驱动性能的关键。用户手指点下去,芯片通过 INT 引脚通知 SoC。SoC 在中断服务程序里发起 I2C 读操作,把坐标取回来。

Linux 里处理这种场景,我推荐用 threaded IRQ:

static irqreturn_t ft5x06_ts_irq_handler(int irq, void *dev_id)
{
    struct ft5x06_ts_data *ts = dev_id;
    struct i2c_client *client = ts->client;
    u8 buf[6];
    int ret;

    // 读取触摸数据
    ret = ft5x06_read_data(client, FT5X06_REG_TD_STATUS, buf, 6);
    if (ret < 0) {
        dev_err(&client->dev, "read touch data failed: %d\n", ret);
        return IRQ_HANDLED;
    }

    // 解析坐标并上报
    ft5x06_report_touch(ts, buf);

    return IRQ_HANDLED;
}

static int ft5x06_ts_probe(struct i2c_client *client,
                const struct i2c_device_id *id)
{
    // ... 前面的初始化代码 ...

    // 注册 threaded IRQ
    ret = devm_request_threaded_irq(&client->dev,
                    client->irq,
                    NULL,                       // 上半部:NULL 表示只用下半部
                    ft5x06_ts_irq_handler,      // 下半部:处理 I2C 传输
                    IRQF_TRIGGER_FALLING | IRQF_ONESHOT,
                    "ft5x06_ts", ts);
    if (ret) {
        dev_err(&client->dev, "request irq failed: %d\n", ret);
        return ret;
    }

    return 0;
}

这里有几个设计要点:

  • IRQF_ONESHOT:保证中断线在 threaded handler 执行期间被屏蔽,防止中断嵌套。触摸屏中断很频繁,不加这个标志容易丢中断。
  • 上半部传 NULL:触摸屏中断不需要做紧急的硬件操作,直接在下半部处理即可。省掉一个不必要的上半部函数。
  • 中断触发方式:大多数触摸芯片用下降沿触发。但有些芯片支持电平触发,需要根据 datasheet 配置。

避坑指南:我曾经遇到一个 case,触摸屏偶尔会丢失第一个触摸点。查了很久发现是中断触发方式配置错了。芯片手册写的是「下降沿触发」,但实际测试发现芯片在初始化后会先拉高 INT 引脚,然后才拉低产生中断。如果驱动在 probe 时没有清除芯片内部的中断状态寄存器,就会导致第一次中断丢失。解决方案是在 probe 最后加一次虚拟读操作,清空 pending 的中断。

3.5 实战经验:调试 I2C 触摸屏的常用手段

调了这么多年触摸屏驱动,我总结了几条实用经验:

  1. 先用 i2c-tools 验证硬件:在用户态用 i2cdetect -y <bus> 看设备地址是否正确。再用 i2cget 和 i2cset 手动读写寄存器,确认通信正常。
  2. 逻辑分析仪是神器:I2C 协议用逻辑分析仪抓波形最直观。看起始条件、地址、ACK、数据、停止条件,一眼就能发现问题。
  3. 加调试打印要克制:中断处理里别加 printk,会拖慢系统。用 tracepoint 或 ftrace 替代。
  4. 注意 I2C 时钟频率:触摸屏通常支持 400kHz(快速模式)。但有些廉价芯片在 400kHz 下不稳定,降到 100kHz 就好了。设备树里配 clock-frequency 就行。