3、输入设备驱动:触摸屏驱动、物理按键驱动、编码器驱动、输入设备注册与回调
输入设备,说白了就是让用户跟界面「对话」的桥梁。LVGL 本身不关心你的硬件怎么接,它只认一个东西——输入设备结构体。你只要把这个结构体填对,剩下的交给框架。
我刚开始移植 LVGL 时,踩得最多的坑就在这。触摸屏没反应、按键乱跳、编码器转一下跑三格……嗯,今天咱们就把这些事彻底理清楚。
3.1 输入设备驱动模型概览
LVGL 把所有输入设备抽象成同一个接口:lv_indev_drv_t。不管你用的是电阻屏、电容屏、矩阵键盘还是旋转编码器,最终都注册成这个结构体。
核心思路:你只需要实现一个「读取函数」,LVGL 会定时调用它,然后把数据转换成内部事件。
我习惯把输入设备驱动分成三层:
- 硬件层:读写寄存器、GPIO 电平、ADC 值
- 适配层:把原始数据转换成 LVGL 能理解的坐标/按键值
- 注册层:填充
lv_indev_drv_t并调用lv_indev_drv_register
下面这张图能帮你快速建立整体认知:
3.2 触摸屏驱动
触摸屏是嵌入式 UI 最常用的输入方式。LVGL 支持两种触摸类型:指针设备(单点触摸)和 多点触摸。绝大多数场景用指针设备就够了。
3.2.1 驱动实现步骤
- 初始化触摸芯片(比如 FT5x06、GT911、XPT2046)
- 实现读取函数,返回坐标和按下状态
- 填充
lv_indev_drv_t,类型设为LV_INDEV_TYPE_POINTER - 注册设备
来看一个实际例子。这是我给 FT5x06 写的驱动核心部分:
static void touchpad_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
static int16_t last_x = 0;
static int16_t last_y = 0;
uint8_t touch_points = 0;
uint16_t x = 0, y = 0;
// 读取触摸点数
ft5x06_read_reg(FT5x06_TD_STATUS, &touch_points, 1);
if (touch_points & 0x0F) {
uint8_t buf[4];
ft5x06_read_reg(FT5x06_TP1_XH, buf, 4);
x = ((uint16_t)buf[0] & 0x0F) << 8 | buf[1];
y = ((uint16_t)buf[2] & 0x0F) << 8 | buf[3];
// 坐标转换(如果屏幕方向不对)
data->point.x = y;
data->point.y = 480 - x;
data->state = LV_INDEV_STATE_PRESSED;
last_x = data->point.x;
last_y = data->point.y;
} else {
data->point.x = last_x;
data->point.y = last_y;
data->state = LV_INDEV_STATE_RELEASED;
}
}
我的经验:坐标转换是触摸屏移植最容易出问题的地方。我建议你在调试阶段先把原始 x、y 打印出来,用手指在屏幕四个角点一下,确认坐标范围后再做映射。别上来就写转换公式,容易把自己绕进去。
3.2.2 注册触摸设备
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);
就这么几行。注册完成后,LVGL 会在 lv_task_handler 里自动调用 touchpad_read,你不需要自己开线程或定时器。
3.3 物理按键驱动
物理按键在工业设备、家电面板上很常见。LVGL 把按键抽象成 LV_INDEV_TYPE_KEYPAD,每个按键对应一个 lv_key_t 枚举值。
3.3.1 按键映射
你需要把物理按键映射到 LVGL 的按键码上。常用的映射关系:
| 物理按键 | LVGL 按键码 | 作用 |
|---|---|---|
| 上 | LV_KEY_UP |
焦点上移 |
| 下 | LV_KEY_DOWN |
焦点下移 |
| 左 | LV_KEY_LEFT |
焦点左移 |
| 右 | LV_KEY_RIGHT |
焦点右移 |
| 确认 | LV_KEY_ENTER |
触发点击事件 |
| 返回 | LV_KEY_ESC |
返回/关闭 |
3.3.2 驱动实现
按键驱动最关键的是去抖。我曾经在一个项目里没做去抖,结果按一次触发五六次,用户差点把设备砸了。
static void keypad_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
static uint32_t last_key = 0;
static uint32_t last_tick = 0;
uint32_t now = lv_tick_get();
uint32_t key = 0;
// 读取 GPIO 电平,判断哪个按键被按下
if (HAL_GPIO_ReadPin(KEY_UP_GPIO_Port, KEY_UP_Pin) == GPIO_PIN_RESET) {
key = LV_KEY_UP;
} else if (HAL_GPIO_ReadPin(KEY_DOWN_GPIO_Port, KEY_DOWN_Pin) == GPIO_PIN_RESET) {
key = LV_KEY_DOWN;
} else if (HAL_GPIO_ReadPin(KEY_ENTER_GPIO_Port, KEY_ENTER_Pin) == GPIO_PIN_RESET) {
key = LV_KEY_ENTER;
}
// 简单去抖:50ms 内不重复上报
if (key != 0 && (now - last_tick) > 50) {
data->key = key;
data->state = LV_INDEV_STATE_PRESSED;
last_key = key;
last_tick = now;
} else if (key == 0) {
data->key = last_key;
data->state = LV_INDEV_STATE_RELEASED;
}
}
注意:按键释放时,data->state 必须设为 LV_INDEV_STATE_RELEASED,否则 LVGL 会认为按键一直被按住,焦点会持续移动。
3.4 编码器驱动
旋转编码器在菜单导航中体验极好。LVGL 专门为它设计了 LV_INDEV_TYPE_ENCODER 类型,支持旋转和按键两种操作。
3.4.1 编码器工作原理
编码器输出两路相位差 90° 的方波(A 相和 B 相)。通过判断 A、B 的边沿顺序,就能知道旋转方向。
- A 上升沿时 B 为高 → 顺时针(值 +1)
- A 上升沿时 B 为低 → 逆时针(值 -1)
3.4.2 驱动实现
static int16_t encoder_diff = 0;
static lv_indev_state_t encoder_btn_state = LV_INDEV_STATE_RELEASED;
static void encoder_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
data->enc_diff = encoder_diff;
data->state = encoder_btn_state;
// 读取后清零差值,避免重复累加
encoder_diff = 0;
encoder_btn_state = LV_INDEV_STATE_RELEASED;
}
// 在定时器或外部中断中调用
void encoder_handler(int direction, int btn_pressed)
{
encoder_diff += direction;
if (btn_pressed) {
encoder_btn_state = LV_INDEV_STATE_PRESSED;
}
}
我的习惯:编码器差值用 int16_t 累加,在 read_cb 里读取后立即清零。这样即使 LVGL 轮询频率不稳定,也不会丢失脉冲。我曾经试过用全局变量直接累加不重置,结果旋转快了界面反应完全跟不上。
3.5 输入设备注册与回调机制
所有输入设备最终都走同一套注册流程。我整理了一个对比表,方便你理解不同设备类型的差异:
| 设备类型 | type 枚举 |
关键数据字段 | 典型场景 |
|---|---|---|---|
| 触摸屏 | LV_INDEV_TYPE_POINTER |
point.x, point.y, state |
手机、平板、工控屏 |
| 物理按键 | LV_INDEV_TYPE_KEYPAD |
key, state |
家电面板、车载按键 |
| 编码器 | LV_INDEV_TYPE_ENCODER |
enc_diff, state |
旋钮菜单、音量调节 |
| 鼠标 | LV_INDEV_TYPE_POINTER |
point.x, point.y, state |
模拟器、带鼠标的 HMI |
3.5.1 注册流程
lv_indev_drv_t drv;
lv_indev_drv_init(&drv); // 1. 初始化驱动结构体
drv.type = LV_INDEV_TYPE_ENCODER; // 2. 设置设备类型
drv.read_cb = encoder_read; // 3. 绑定读取回调
lv_indev_t *indev = lv_indev_drv_register(&drv); // 4. 注册设备
注册后返回的 lv_indev_t * 指针可以用于后续操作,比如设置分组:
lv_group_t *g = lv_group_create();
lv_group_add_obj(g, btn1);
lv_group_add_obj(g, btn2);
lv_indev_set_group(indev, g); // 将编码器与分组绑定
3.5.2 回调机制详解
LVGL 的输入设备回调是轮询式的。每次 lv_task_handler 被调用时,LVGL 会遍历所有已注册的输入设备,调用它们的 read_cb。
回调函数必须遵守两个规则:
- 非阻塞:不能在回调里做延时等待,否则会卡死整个 UI
- 快速返回:读取完数据立即返回,不要在回调里处理业务逻辑
我曾经犯过的错:在 read_cb 里加了 100ms 的延时等待触摸芯片就绪,结果 UI 刷新率直接掉到 10fps。后来改成在外部中断里准备好数据,read_cb 只负责搬运,问题就解决了。
3.6 多输入设备共存
一个项目里同时使用触摸屏和物理按键,或者触摸屏加编码器,是很常见的。LVGL 支持同时注册多个输入设备,它们互不干扰。
举个例子,我做过一个带触摸屏和编码器的设备:
- 触摸屏用于直接点击按钮
- 编码器用于快速滚动列表
注册方式就是分别调用两次 lv_indev_drv_register:
lv_indev_drv_t touch_drv, enc_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_init(&enc_drv);
enc_drv.type = LV_INDEV_TYPE_ENCODER;
enc_drv.read_cb = encoder_read;
lv_indev_t *enc_indev = lv_indev_drv_register(&enc_drv);
// 编码器需要绑定分组
lv_group_t *g = lv_group_create();
lv_group_add_obj(g, list);
lv_indev_set_group(enc_indev, g);
你看,代码结构非常清晰。触摸屏不需要分组,因为它直接操作坐标;编码器必须绑定分组,否则它不知道焦点该往哪移。
总结一下:输入设备驱动的核心就三件事——读硬件、填结构体、注册。触摸屏注意坐标转换,按键注意去抖,编码器注意差值清零。把这些细节处理好,你的 UI 就能跟用户「好好说话」了。