4. 主流嵌入式UI框架概览

输入设备移植,说白了就是让 LVGL 能「感知」到用户的操作。触摸屏点了哪里?按键按下了哪个?编码器转了几格?这些信号怎么从硬件传到 LVGL 的绘图引擎里?

我刚开始做嵌入式 GUI 时,总觉得显示搞定了就万事大吉。结果第一次把界面跑起来,发现点哪都没反应——嗯,输入设备压根没注册。从那以后,我养成了一个习惯:显示和输入,两手都要抓。

4.1 输入设备的核心抽象:indev_drv

LVGL 把所有输入设备都抽象成一个结构体——lv_indev_drv_t。不管你用的是触摸屏、鼠标还是按键,最终都要填充这个驱动结构体,然后注册到系统中。

核心思路:LVGL 不关心你的硬件长什么样,它只关心三件事——设备类型、读取函数、读取周期。

// 一个典型的输入设备驱动注册流程
lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);          // 初始化驱动结构体
indev_drv.type = LV_INDEV_TYPE_POINTER; // 设置设备类型
indev_drv.read_cb = my_touchpad_read;   // 绑定读取回调函数
lv_indev_t * my_indev = lv_indev_drv_register(&indev_drv); // 注册驱动

这里有个细节:lv_indev_drv_init() 会帮你把一些默认参数填好,比如读取周期默认是 5 毫秒。我个人习惯在注册前手动调整一下 indev_drv.read_timer 的周期,尤其是对编码器这类设备,周期太短反而容易误触发。

4.2 设备类型与读取回调

LVGL 定义了 5 种输入设备类型,每种类型的读取回调函数签名都一样,但返回的数据含义不同。

设备类型 枚举值 读取回调返回的数据 典型应用场景
触摸屏 / 鼠标 LV_INDEV_TYPE_POINTER 坐标 (x, y) + 按下状态 电容屏、电阻屏、USB 鼠标
物理按键 LV_INDEV_TYPE_KEYPAD 按键编码 (key code) 矩阵键盘、GPIO 按键
编码器 LV_INDEV_TYPE_ENCODER 差值 (diff) + 按键状态 旋转编码器、旋钮
按钮 LV_INDEV_TYPE_BUTTON 按钮 ID + 按下状态 屏幕外物理按钮

读取回调的签名是固定的:

void my_input_read(lv_indev_drv_t * drv, lv_indev_data_t * data);

你只需要在回调里填充 lv_indev_data_t 结构体。这个结构体包含三个关键字段:point(坐标)、key(按键码)、enc_diff(编码器差值),以及一个 state(按下/释放)。

小技巧:如果硬件暂时没有数据,记得把 data->continue_reading 设为 0,否则 LVGL 会一直调用你的回调,白白消耗 CPU。

4.3 触摸屏移植实战

触摸屏是最常见的输入设备。我移植过 FT5x06、GT911、XPT2046 这些型号,流程大同小异。

关键步骤就两步:

  1. 初始化触摸芯片(I2C/SPI 通信、复位、校准参数)
  2. 实现读取回调,把硬件坐标映射到屏幕坐标
static void my_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;

    // 读取触摸芯片寄存器
    uint8_t touch_points = 0;
    uint16_t x = 0, y = 0;
    if (touch_driver_read(&touch_points, &x, &y) != 0) {
        // 读取失败,保持上次坐标
        data->point.x = last_x;
        data->point.y = last_y;
        data->state = LV_INDEV_STATE_REL;
        return;
    }

    if (touch_points > 0) {
        // 坐标映射:硬件分辨率 → 屏幕分辨率
        data->point.x = (lv_coord_t)((uint32_t)x * LV_HOR_RES / TOUCH_MAX_X);
        data->point.y = (lv_coord_t)((uint32_t)y * LV_VER_RES / TOUCH_MAX_Y);
        last_x = data->point.x;
        last_y = data->point.y;
        data->state = LV_INDEV_STATE_PR;
    } else {
        data->point.x = last_x;
        data->point.y = last_y;
        data->state = LV_INDEV_STATE_REL;
    }
}

我曾经踩过的坑:坐标映射时忘记做整数溢出保护。触摸芯片返回的坐标是 12 位(0~4095),直接乘以屏幕分辨率再除以最大值,中间结果可能超过 uint16_t 范围。后来我统一改用 uint32_t 做中间运算,问题解决。

4.4 按键与编码器移植

按键和编码器在嵌入式设备上出场率很高。尤其是编码器,配合 LVGL 的滚轮控件,操作体验非常顺滑。

4.4.1 物理按键

按键设备类型是 LV_INDEV_TYPE_KEYPAD。读取回调里返回按键的 LVGL 键码,比如 LV_KEY_LEFT、LV_KEY_ENTER。

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

    uint8_t key_id = key_scan();  // 硬件扫描
    if (key_id != KEY_NONE) {
        // 映射硬件按键到 LVGL 键码
        switch (key_id) {
            case KEY_UP:    last_key = LV_KEY_UP;    break;
            case KEY_DOWN:  last_key = LV_KEY_DOWN;  break;
            case KEY_OK:    last_key = LV_KEY_ENTER; break;
            case KEY_BACK:  last_key = LV_KEY_ESC;   break;
            default:        last_key = 0;            break;
        }
        data->state = LV_INDEV_STATE_PR;
    } else {
        data->state = LV_INDEV_STATE_REL;
    }
    data->key = last_key;
}

4.4.2 旋转编码器

编码器比较特殊。它返回的是「差值」——正数表示顺时针转了几格,负数表示逆时针。同时编码器通常还带一个按钮,按下时返回按键状态。

static void my_encoder_read(lv_indev_drv_t * drv, lv_indev_data_t * data)
{
    static int16_t last_diff = 0;

    // 读取编码器硬件计数
    int16_t current_count = encoder_get_count();
    int16_t diff = current_count - last_diff;
    last_diff = current_count;

    data->enc_diff = diff;
    data->state = encoder_is_pressed() ? LV_INDEV_STATE_PR : LV_INDEV_STATE_REL;
}

个人经验:编码器容易产生抖动,尤其是机械编码器。我习惯在硬件上加 RC 滤波,同时在软件里做 2~3ms 的去抖延时。别小看这一步,能省掉你后面很多调试时间。

4.5 事件上报机制

输入设备注册好后,LVGL 内部是怎么把数据变成界面交互的?

流程其实很清晰:

  1. 定时器轮询:LVGL 内部有一个 lv_indev_read_timer_cb(),每隔几毫秒调用一次你的读取回调。
  2. 数据缓存:读取回调返回的数据被暂存到 lv_indev_data_t 中。
  3. 坐标命中检测:如果是触摸屏,LVGL 会遍历所有对象,找到触摸点下方的控件。
  4. 事件分发:根据设备类型和状态,触发对应的事件(LV_EVENT_PRESSED、LV_EVENT_CLICKED、LV_EVENT_LONG_PRESSED 等)。

你想想看:这个机制意味着你不需要在应用层写任何轮询代码。只要驱动注册对了,LVGL 会自动帮你把硬件信号翻译成 UI 事件。

4.6 多输入设备共存

一个设备上同时有触摸屏和物理按键,这种情况很常见。LVGL 支持同时注册多个输入设备,互不干扰。

// 注册触摸屏
lv_indev_drv_t touch_drv;
lv_indev_drv_init(&touch_drv);
touch_drv.type = LV_INDEV_TYPE_POINTER;
touch_drv.read_cb = my_touchpad_read;
lv_indev_t * touch_indev = 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 = my_keypad_read;
lv_indev_t * key_indev = lv_indev_drv_register(&key_drv);

// 注册编码器
lv_indev_drv_t enc_drv;
lv_indev_drv_init(&enc_drv);
enc_drv.type = LV_INDEV_TYPE_ENCODER;
enc_drv.read_cb = my_encoder_read;
lv_indev_t * enc_indev = lv_indev_drv_register(&enc_drv);

每个 lv_indev_t * 句柄可以单独设置分组(group),控制焦点切换。比如按键和编码器可以共享同一个分组,触摸屏则独立操作。

4.7 输入设备驱动框架总览

下面这张图梳理了整个输入设备移植的脉络,从硬件到 LVGL 事件,一目了然。

LVGL 输入设备驱动框架 LVGL 输入设备驱动框架 触摸屏硬件 物理按键 旋转编码器 touch_read_cb() keypad_read_cb() encoder_read_cb() lv_indev_drv_register() 注册驱动 LVGL 事件引擎(轮询 + 分发) 应用事件回调(LV_EVENT_xxx) 硬件无关,驱动适配

4.8 避坑指南与调试技巧

移植输入设备时,有几个问题我几乎每次都会遇到:

  • 坐标方向反了:触摸屏的 X/Y 轴和屏幕显示方向不一致。解决办法是在读取回调里做一次坐标变换,或者调整 lv_disp_drv_t 的 sw_rotate 标志。
  • 触摸无反应:先检查 read_cb 是否被调用。可以在回调里加个 GPIO 翻转,用示波器看波形。如果没调用,检查 lv_indev_drv_register 是否执行成功。
  • 按键重复触发:硬件去抖没做好。我习惯在回调里加一个简单的状态机,检测到按下后延迟 20ms 再确认一次。
  • 编码器跳数:机械编码器在旋转时会产生毛刺。除了硬件滤波,软件里可以设置一个阈值,只有差值绝对值大于 2 才认为是有效转动。

我曾经踩过的一个大坑:在 FreeRTOS 任务里直接调用 lv_indev_drv_register(),结果系统卡死。后来发现 LVGL 的输入设备注册必须在 lv_init() 之后、lv_timer_handler() 开始之前完成。说白了,注册驱动这件事要放在初始化阶段,不要在运行时动态注册。

嗯,输入设备移植这部分内容就这些。核心就是理解 lv_indev_drv_t 的结构,写好读取回调,然后注册上去。剩下的交给 LVGL 的事件引擎去处理。