3、输入设备驱动移植:触摸屏与物理按键

输入设备,说白了就是让用户和屏幕交互的桥梁。没有它,你的仪表盘再漂亮也只是个摆设。

这一章我们重点讲触摸屏和物理按键的驱动移植。我会带着你一步步写 lv_port_indev.c,把坐标校准、事件上报这些坑都踩一遍。最后,我会分享一个我在项目中遇到的触摸抖动问题——嗯,那会儿差点把我整崩溃。

3.1 输入设备在LVGL中的角色

LVGL把输入设备抽象成了几个类型:触摸屏(指针设备)、物理按键、编码器、键盘等。每个类型对应一个 lv_indev_drv_t 结构体。

我个人习惯把输入设备驱动单独放在 lv_port_indev.clv_port_indev.h 里。这样移植起来清爽,后期维护也方便。

核心流程其实就三步:

  1. 初始化硬件(触摸IC、GPIO等)
  2. 注册读取函数(告诉LVGL怎么拿数据)
  3. 在LVGL的tick循环里调用读取函数

你想想看,LVGL本身不关心你的触摸屏是I2C还是SPI,它只认一个回调函数——你给我坐标,我就处理。

3.2 编写lv_port_indev.c

我们先从触摸屏开始。假设你用的是FT5x06电容触摸屏,I2C接口。

这是最基本的框架:

#include "lv_port_indev.h"
#include "lvgl.h"

static lv_indev_drv_t indev_drv;
static lv_indev_t *indev_touchpad;

// 触摸屏读取函数
static void touchpad_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
    static lv_coord_t last_x = 0;
    static lv_coord_t last_y = 0;

    if (touchpad_is_pressed()) {
        touchpad_get_xy(&last_x, &last_y);
        data->point.x = last_x;
        data->point.y = last_y;
        data->state = LV_INDEV_STATE_PR;
    } else {
        data->point.x = last_x;
        data->point.y = last_y;
        data->state = LV_INDEV_STATE_REL;
    }
}

void lv_port_indev_init(void)
{
    // 初始化触摸硬件
    touchpad_init();

    // 注册输入设备
    lv_indev_drv_init(&indev_drv);
    indev_drv.type = LV_INDEV_TYPE_POINTER;
    indev_drv.read_cb = touchpad_read;
    indev_touchpad = lv_indev_drv_register(&indev_drv);
}

这里有个细节:data->point.xdata->point.y 在释放状态时,最好保留上一次的坐标。为什么?因为LVGL内部有些手势识别需要知道最后的触摸位置。我刚开始没注意这个,结果滑动事件一直不触发,查了半天才发现是坐标清零了。

3.3 物理按键的驱动方式

物理按键在仪表盘上也很常见,比如菜单键、确认键、返回键。LVGL支持将按键映射成编码器或者键盘类型。

我个人更推荐用 LV_INDEV_TYPE_KEYPAD,因为可以配合 lv_group 做焦点切换。

static uint32_t keypad_get_key(void)
{
    if (HAL_GPIO_ReadPin(KEY_UP_GPIO_Port, KEY_UP_Pin) == GPIO_PIN_RESET) {
        return LV_KEY_UP;
    }
    if (HAL_GPIO_ReadPin(KEY_DOWN_GPIO_Port, KEY_DOWN_Pin) == GPIO_PIN_RESET) {
        return LV_KEY_DOWN;
    }
    if (HAL_GPIO_ReadPin(KEY_ENTER_GPIO_Port, KEY_ENTER_Pin) == GPIO_PIN_RESET) {
        return LV_KEY_ENTER;
    }
    return 0;
}

static void keypad_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
    static uint32_t last_key = 0;

    uint32_t key = keypad_get_key();
    if (key != 0) {
        data->key = key;
        data->state = LV_INDEV_STATE_PR;
        last_key = key;
    } else {
        data->key = last_key;
        data->state = LV_INDEV_STATE_REL;
    }
}

嗯,这里要注意:按键的消抖最好在硬件层或者驱动层做掉,不要丢给LVGL。LVGL只关心「按下」和「释放」两个状态。

3.4 坐标校准与事件上报

触摸屏的坐标校准,说白了就是把ADC原始值映射到屏幕分辨率上。比如你的屏幕是480x272,但触摸IC返回的坐标范围是0~4095。

最简单的做法是线性映射:

static void touchpad_get_xy(lv_coord_t *x, lv_coord_t *y)
{
    uint16_t raw_x, raw_y;
    touchpad_read_raw(&raw_x, &raw_y);

    // 线性映射到屏幕分辨率
    *x = (lv_coord_t)((uint32_t)raw_x * 480 / 4096);
    *y = (lv_coord_t)((uint32_t)raw_y * 272 / 4096);
}

但实际项目中,触摸屏的物理坐标和屏幕坐标往往有偏移和旋转。这时候就需要做三点校准或者五点校准。

我在项目中用过一种简单粗暴的方法:在屏幕上画几个十字标记,点击后记录原始坐标,然后计算仿射变换矩阵。代码量不大,但效果很好。

小技巧: 如果你的触摸屏有硬件校准寄存器(比如GT911系列),直接在初始化时写入校准参数,比软件校准更稳定。

3.5 触摸抖动问题——我踩过的坑

终于到了这一节的重点。触摸抖动,说白了就是手指明明没动,但上报的坐标在几个像素之间来回跳。

为什么会这样?

  • 触摸IC的ADC噪声
  • 电源纹波干扰
  • 触摸屏表面有水滴或污渍
  • 手指轻微颤抖(尤其是长时间触摸时)

我曾经在一个车载项目中遇到过这个问题。仪表盘上有个滑动条,用户想拖动它,结果因为坐标抖动,滑块像抽风一样来回晃。客户当场就炸了。

我当时的解决方案是「滑动平均滤波 + 死区判断」:

#define FILTER_WINDOW 4
#define DEAD_ZONE     3

static lv_coord_t filter_buf_x[FILTER_WINDOW];
static lv_coord_t filter_buf_y[FILTER_WINDOW];
static uint8_t filter_index = 0;

static void touchpad_filter(lv_coord_t *x, lv_coord_t *y)
{
    filter_buf_x[filter_index] = *x;
    filter_buf_y[filter_index] = *y;
    filter_index = (filter_index + 1) % FILTER_WINDOW;

    uint32_t sum_x = 0, sum_y = 0;
    for (int i = 0; i < FILTER_WINDOW; i++) {
        sum_x += filter_buf_x[i];
        sum_y += filter_buf_y[i];
    }

    lv_coord_t avg_x = sum_x / FILTER_WINDOW;
    lv_coord_t avg_y = sum_y / FILTER_WINDOW;

    // 死区判断:如果滤波后的坐标和上次相差小于3个像素,就不更新
    static lv_coord_t last_x = 0, last_y = 0;
    if (abs(avg_x - last_x) > DEAD_ZONE || abs(avg_y - last_y) > DEAD_ZONE) {
        *x = avg_x;
        *y = avg_y;
        last_x = avg_x;
        last_y = avg_y;
    } else {
        *x = last_x;
        *y = last_y;
    }
}

这个方案把抖动从±5像素降到了±1像素以内。滑动条终于不抽风了。

注意: 滤波窗口不要太大,否则会引入明显的延迟。我试过8点平均,结果手指划过去了,光标还在后面追。4点平均是比较好的折中。

另外,我还发现一个容易被忽略的问题:触摸中断的触发方式。有些触摸IC支持「按下时连续中断」,但如果你在中断里直接读取坐标,可能会读到不稳定的值。我后来改成在定时器里轮询读取,反而更稳定。

3.6 事件上报的时机

LVGL的事件上报,其实是在 lv_task_handler() 里触发的。它会调用你注册的 read_cb 函数,然后根据返回的 statepoint 生成事件。

这里有个性能相关的点:如果你的 read_cb 函数执行时间太长(比如I2C通信有延迟),会拖慢整个LVGL的刷新率。

我建议的做法是:

  • read_cb 里只做简单的状态判断和坐标读取
  • 把复杂的滤波算法放在后台任务里,更新一个全局变量
  • read_cb 直接拿这个全局变量返回

说白了,就是「生产者和消费者」模式。触摸中断负责生产数据,LVGL的读取函数负责消费数据。中间用队列或者全局变量缓冲一下。

3.7 本章小结

这一章我们讲了输入设备驱动的核心要点:

  • lv_port_indev.c 的编写框架
  • 触摸屏和物理按键的驱动方式
  • 坐标校准的线性映射方法
  • 触摸抖动问题的实战解决方案
  • 事件上报的性能优化思路

下一章我们会进入LVGL的性能优化部分。说实话,输入设备驱动只是开胃菜,真正的硬骨头还在后面——内存管理、帧率优化、DMA加速……到时候我们一个一个啃。

嗯,今天就到这里。如果你在移植过程中遇到什么奇怪的问题,欢迎在课程群里交流。我踩过的坑,说不定你也会遇到。