2. LVGL 输入设备驱动:触摸屏、按键与编码器适配

好,咱们直接进入正题。上一章我们把 LVGL 的基础显示驱动搭好了,屏幕能亮了。但光能看不行,得能交互。这一章,我就带你搞定 LVGL 的三大主流输入设备:触摸屏、物理按键、旋转编码器。

说白了,LVGL 的输入设备驱动核心就一个概念——输入设备(indev)。不管你用哪种方式操作,最终都要注册一个 lv_indev_drv_t 结构体,然后告诉 LVGL:“嘿,这是我的读取函数,你按时来取数据就行。”

核心要点:LVGL 通过 lv_indev_drv_t 统一管理所有输入设备。驱动适配的本质就是实现 read_cb 回调函数。

2.1 输入设备驱动框架概览

先看整体结构。我习惯把输入设备驱动分成三层:

  • 硬件抽象层:直接操作寄存器或调用 SDK 接口,获取原始数据。
  • 数据转换层:把原始数据转换成 LVGL 能理解的坐标、按键值或编码器差值。
  • LVGL 注册层:填充 lv_indev_drv_t,调用 lv_indev_drv_register 完成注册。

下面这张图能帮你快速建立整体认知:

LVGL 输入设备驱动适配框架 硬件抽象层 I2C/SPI/GPIO 读取 数据转换层 坐标/键值/差值计算 LVGL 注册层 indev_drv_register 触摸屏 LV_INDEV_TYPE_POINTER 物理按键 LV_INDEV_TYPE_KEYPAD 编码器 LV_INDEV_TYPE_ENCODER 💡 适配要点 • 触摸屏:read_cb 返回坐标 (x, y) 和按下状态 (pressed) • 按键:read_cb 返回 LV_KEY_* 枚举值 • 编码器:read_cb 返回旋转差值 (diff) 和按键状态

2.2 触摸屏驱动适配

触摸屏是嵌入式 UI 里最常用的输入设备。LVGL 把它归类为 LV_INDEV_TYPE_POINTER。说白了,你只需要告诉 LVGL 两个信息:手指按在哪,以及按没按。

2.2.1 标准适配流程

我个人习惯先写一个硬件读取函数,把触摸芯片的数据读出来。比如用 FT6x36 或 GT911 这类电容触摸芯片,通常走 I2C 接口。

// 硬件读取函数:从触摸芯片获取原始数据
typedef struct {
    uint16_t x;
    uint16_t y;
    bool     pressed;
} touch_data_t;

static touch_data_t touch_read_hw(void) {
    touch_data_t data = {0};
    uint8_t buf[6] = {0};
    
    // 读取触摸芯片寄存器(以 FT6x36 为例)
    i2c_read(FT6x36_ADDR, FT6x36_REG_TOUCH_DATA, buf, 6);
    
    // 解析触摸点数
    uint8_t touch_points = buf[0] & 0x0F;
    if (touch_points > 0) {
        data.pressed = true;
        data.x = ((buf[1] & 0x0F) << 8) | buf[2];
        data.y = ((buf[3] & 0x0F) << 8) | buf[4];
    } else {
        data.pressed = false;
    }
    
    return data;
}

然后实现 LVGL 的 read_cb 回调。这里有个坑——触摸坐标可能需要旋转或镜像。我记得第一次做竖屏转横屏适配时,坐标死活不对,后来发现是 X/Y 没交换。

static void touchpad_read(lv_indev_drv_t *drv, lv_indev_data_t *data) {
    touch_data_t hw_data = touch_read_hw();
    
    // 坐标转换:假设屏幕是 480x320,触摸芯片原始坐标范围是 0-4095
    // 需要做线性映射
    static int16_t last_x = 0;
    static int16_t last_y = 0;
    
    if (hw_data.pressed) {
        // 映射到屏幕分辨率
        data->point.x = (int16_t)((uint32_t)hw_data.x * 480 / 4096);
        data->point.y = (int16_t)((uint32_t)hw_data.y * 320 / 4096);
        last_x = data->point.x;
        last_y = data->point.y;
    } else {
        // 松开时,LVGL 需要知道上一次的坐标
        data->point.x = last_x;
        data->point.y = last_y;
    }
    
    data->state = hw_data.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
}

⚠️ 重要提醒:松开手指时,data->point 必须保留上一次的坐标。否则 LVGL 在处理滑动释放事件时会丢失位置信息,导致手势识别异常。

2.2.2 注册触摸设备

void touchpad_init(void) {
    lv_indev_drv_t indev_drv;
    lv_indev_drv_init(&indev_drv);          // 初始化驱动结构体
    
    indev_drv.type = LV_INDEV_TYPE_POINTER;  // 类型:触摸屏
    indev_drv.read_cb = touchpad_read;       // 注册读取回调
    
    lv_indev_t *touch_indev = lv_indev_drv_register(&indev_drv);
    // 返回的指针可以用于后续操作,比如设置光标
}

2.3 按键驱动适配

有些场景不适合用触摸屏,比如工业设备、白色家电。这时候物理按键就派上用场了。LVGL 把按键归类为 LV_INDEV_TYPE_KEYPAD。

按键驱动的核心是键值映射。你需要把物理按键的 GPIO 状态转换成 LVGL 定义的按键枚举。

2.3.1 按键映射表

LVGL 按键枚举 含义 典型物理按键
LV_KEY_UP 上移 方向键 ↑
LV_KEY_DOWN 下移 方向键 ↓
LV_KEY_LEFT 左移 方向键 ←
LV_KEY_RIGHT 右移 方向键 →
LV_KEY_ENTER 确认 OK 键
LV_KEY_ESC 返回/取消 Back 键

2.3.2 按键驱动实现

// 按键 GPIO 映射表
typedef struct {
    uint32_t gpio_pin;
    uint32_t lv_key;
} key_map_t;

static const key_map_t key_table[] = {
    {GPIO_PIN_UP,    LV_KEY_UP},
    {GPIO_PIN_DOWN,  LV_KEY_DOWN},
    {GPIO_PIN_LEFT,  LV_KEY_LEFT},
    {GPIO_PIN_RIGHT, LV_KEY_RIGHT},
    {GPIO_PIN_OK,    LV_KEY_ENTER},
    {GPIO_PIN_BACK,  LV_KEY_ESC},
};

static uint32_t last_key = LV_KEY_ENTER;  // 默认焦点键

static void keypad_read(lv_indev_drv_t *drv, lv_indev_data_t *data) {
    // 扫描所有按键
    for (int i = 0; i < sizeof(key_table)/sizeof(key_map_t); i++) {
        if (gpio_read(key_table[i].gpio_pin) == 0) {  // 低电平有效
            data->key = key_table[i].lv_key;
            data->state = LV_INDEV_STATE_PRESSED;
            last_key = key_table[i].lv_key;
            return;
        }
    }
    
    // 无按键按下
    data->key = last_key;
    data->state = LV_INDEV_STATE_RELEASED;
}

💡 经验之谈:按键消抖最好在硬件读取层做,不要在 read_cb 里加延时。LVGL 的 read_cb 是在主循环里周期性调用的,加延时会影响 UI 刷新率。我一般用状态机做 20ms 的软件消抖。

2.4 编码器驱动适配

编码器在智能家居、音频设备里很常见。它既能旋转,又能按下。LVGL 用 LV_INDEV_TYPE_ENCODER 来处理。

编码器驱动的核心是计算旋转方向。A、B 两相脉冲的相位差决定了你是顺时针还是逆时针。

2.4.1 编码器状态机

// 编码器状态机:检测旋转方向和步数
typedef enum {
    ENC_STATE_IDLE,
    ENC_STATE_A_FIRST,
    ENC_STATE_B_FIRST,
} encoder_state_t;

static int8_t encoder_get_diff(void) {
    static encoder_state_t state = ENC_STATE_IDLE;
    static int8_t diff = 0;
    
    uint8_t a = gpio_read(ENC_GPIO_A);
    uint8_t b = gpio_read(ENC_GPIO_B);
    
    switch (state) {
        case ENC_STATE_IDLE:
            if (a && !b) state = ENC_STATE_A_FIRST;  // A 先变高 → 顺时针
            if (!a && b) state = ENC_STATE_B_FIRST;  // B 先变高 → 逆时针
            break;
        case ENC_STATE_A_FIRST:
            if (b) { diff = 1; state = ENC_STATE_IDLE; }  // 顺时针 +1
            break;
        case ENC_STATE_B_FIRST:
            if (a) { diff = -1; state = ENC_STATE_IDLE; } // 逆时针 -1
            break;
    }
    
    return diff;
}

2.4.2 编码器 read_cb 实现

static void encoder_read(lv_indev_drv_t *drv, lv_indev_data_t *data) {
    // 读取旋转差值
    data->enc_diff = encoder_get_diff();
    
    // 读取按键状态(编码器本身带按键)
    data->state = gpio_read(ENC_GPIO_SW) ? LV_INDEV_STATE_RELEASED : LV_INDEV_STATE_PRESSED;
    
    // 注意:enc_diff 是累积值,LVGL 每次读取后会清零
    // 所以这里直接返回当前周期的差值即可
}

⚠️ 避坑指南:我曾经在一个项目里发现编码器旋转时偶尔会跳两个步进。排查了半天,原来是编码器硬件没有加 RC 滤波,导致毛刺被当成有效脉冲。解决方案:要么硬件上加 100nF 电容,要么软件里做 1ms 的去毛刺采样。

2.5 多输入设备共存

实际项目中,触摸屏和按键经常同时存在。比如平板设备既有触摸屏,又有物理 Home 键。LVGL 支持同时注册多个输入设备。

void all_input_init(void) {
    // 注册触摸屏
    lv_indev_drv_t touch_drv;
    lv_indev_drv_init(&touch_drv);
    touch_drv.type = LV_INDEV_TYPE_POINTER;
    touch_drv.read_cb = touchpad_read;
    lv_indev_drv_register(&touch_drv);
    
    // 注册按键
    lv_indev_drv_t key_drv;
    lv_indev_drv_init(&key_drv);
    key_drv.type = LV_INDEV_TYPE_KEYPAD;
    key_drv.read_cb = keypad_read;
    lv_indev_t *key_indev = lv_indev_drv_register(&key_drv);
    
    // 设置按键为默认组控制设备
    lv_group_t *g = lv_group_create();
    lv_indev_set_group(key_indev, g);
    lv_group_set_default(g);
}

关键理解:触摸屏和按键可以同时工作。触摸屏直接操作控件,按键通过组(group)来导航焦点。两者互不干扰,LVGL 内部会分别处理。

2.6 调试与验证

驱动写完了,怎么验证对不对?我分享几个实用方法:

  • 触摸屏:在屏幕上创建一个 lv_label,实时显示坐标值。手指划过屏幕,看坐标是否连续变化。
  • 按键:创建一个 lv_btnmatrix,用按键切换焦点。看焦点是否能按预期上下左右移动。
  • 编码器:创建一个 lv_slider,旋转编码器看滑块是否平滑增减。