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完成注册。
下面这张图能帮你快速建立整体认知:
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,旋转编码器看滑块是否平滑增减。