第三章 输入设备驱动移植:触摸屏、物理按键与编码器
输入设备,说白了就是让用户跟屏幕「对话」的桥梁。没有它,界面再漂亮也只是个摆设。我在做第一个LVGL项目时,就吃过输入设备没调好的亏——界面画出来了,点上去没反应,那种感觉就像你对着对讲机喊了半天,对方却听不见。
这一章,我们就把触摸屏、物理按键、编码器这三种最常见的输入设备,一个一个搞定。嗯,还会聊聊输入设备回调函数怎么写才优雅。
3.1 触摸屏驱动移植
触摸屏是嵌入式GUI里最常用的输入设备。LVGL对触摸屏的支持很完善,但移植时有几个关键点,我踩过坑,得跟你讲讲。
3.1.1 触摸屏驱动的基本结构
LVGL的触摸屏驱动,核心就是一个结构体:lv_indev_drv_t。你需要做的是:
- 初始化这个结构体
- 注册一个读取触摸数据的回调函数
- 把驱动注册到LVGL中
代码长这样:
static lv_indev_drv_t indev_drv;
static lv_indev_t * touch_indev;
void touch_driver_init(void)
{
/* 初始化触摸屏硬件 */
touch_hw_init();
/* 配置输入设备驱动 */
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = touch_read_cb;
/* 注册驱动 */
touch_indev = lv_indev_drv_register(&indev_drv);
}
这里有个细节:LV_INDEV_TYPE_POINTER 表示这是一个指针型输入设备。触摸屏、鼠标都属于这一类。
3.1.2 读取回调函数的实现
回调函数是驱动的心脏。LVGL会周期性地调用它,询问「有没有触摸数据?」。你需要把硬件读取到的坐标填进去。
static void touch_read_cb(lv_indev_drv_t * drv, lv_indev_data_t * data)
{
static lv_coord_t last_x = 0;
static lv_coord_t last_y = 0;
static bool last_pressed = false;
/* 从硬件读取触摸状态 */
bool pressed = touch_get_pressed(&last_x, &last_y);
if (pressed) {
data->point.x = last_x;
data->point.y = last_y;
data->state = LV_INDEV_STATE_PRESSED;
} else {
data->state = LV_INDEV_STATE_RELEASED;
}
}
我个人习惯在回调函数里加一个静态变量缓存上一次的坐标。为什么?因为有些触摸屏在手指抬起时会返回一个无效坐标,缓存可以避免界面「跳一下」。
3.1.3 坐标校准与旋转
我在项目中遇到过最头疼的问题,就是触摸屏坐标和屏幕显示方向不一致。比如屏幕横着放,触摸屏却按竖着的坐标来。解决办法很简单:
/* 坐标映射函数 */
static void touch_remap(lv_coord_t * x, lv_coord_t * y)
{
/* 假设触摸屏原始坐标范围是0-4095,屏幕分辨率是480x320 */
*x = (*x) * 480 / 4096;
*y = (*y) * 320 / 4096;
/* 如果触摸屏方向与屏幕方向不一致,做旋转 */
/* 例如:触摸屏顺时针旋转了90度 */
lv_coord_t temp = *x;
*x = *y;
*y = 480 - temp;
}
你想想看,如果坐标不校准,用户点左上角,光标却跑到右下角,这产品谁敢用?
3.2 物理按键驱动移植
物理按键虽然看起来「古老」,但在工业设备、家电面板上依然很常见。LVGL把按键抽象为 LV_INDEV_TYPE_KEYPAD。
3.2.1 按键驱动的配置
按键驱动的配置比触摸屏简单,但有个关键点:你需要告诉LVGL每个按键对应哪个LVGL键值。
static lv_indev_drv_t kb_drv;
static lv_indev_t * kb_indev;
void keypad_driver_init(void)
{
/* 初始化按键硬件 */
key_hw_init();
/* 配置输入设备驱动 */
lv_indev_drv_init(&kb_drv);
kb_drv.type = LV_INDEV_TYPE_KEYPAD;
kb_drv.read_cb = keypad_read_cb;
/* 注册驱动 */
kb_indev = lv_indev_drv_register(&kb_drv);
/* 重要:将按键输入设备与一个组关联 */
lv_group_t * g = lv_group_create();
lv_indev_set_group(kb_indev, g);
lv_group_set_default(g);
}
这里有个容易忽略的地方:按键输入设备必须绑定到一个 lv_group_t 上,否则按键事件不会生效。我曾经在这个问题上卡了整整一个下午。
3.2.2 按键回调函数的实现
按键回调函数需要返回当前按下的键值。LVGL预定义了一些标准键值,比如 LV_KEY_UP、LV_KEY_DOWN、LV_KEY_ENTER 等。
static void keypad_read_cb(lv_indev_drv_t * drv, lv_indev_data_t * data)
{
static uint32_t last_key = 0;
static lv_indev_state_t last_state = LV_INDEV_STATE_RELEASED;
/* 读取按键状态 */
uint32_t key = key_scan();
if (key != 0) {
/* 将硬件按键映射到LVGL键值 */
switch (key) {
case KEY_UP:
last_key = LV_KEY_UP;
break;
case KEY_DOWN:
last_key = LV_KEY_DOWN;
break;
case KEY_ENTER:
last_key = LV_KEY_ENTER;
break;
case KEY_BACK:
last_key = LV_KEY_ESC;
break;
default:
last_key = 0;
break;
}
last_state = LV_INDEV_STATE_PRESSED;
} else {
last_state = LV_INDEV_STATE_RELEASED;
}
data->key = last_key;
data->state = last_state;
}
3.3 编码器驱动移植
编码器(Encoder)在嵌入式设备里越来越流行。它比按键更直观,比触摸屏更便宜。LVGL专门为编码器提供了 LV_INDEV_TYPE_ENCODER 类型。
3.3.1 编码器的工作原理
编码器本质上是一个旋转开关,输出两路相位差90度的脉冲信号。通过判断两路信号的相位关系,可以知道旋转方向。
我习惯用状态机来处理编码器信号,这样更稳定:
typedef enum {
ENC_STATE_IDLE,
ENC_STATE_A,
ENC_STATE_B,
ENC_STATE_BOTH
} encoder_state_t;
static encoder_state_t enc_state = ENC_STATE_IDLE;
static int16_t enc_diff = 0;
void encoder_isr_handler(void)
{
bool pin_a = gpio_read(PIN_ENC_A);
bool pin_b = gpio_read(PIN_ENC_B);
switch (enc_state) {
case ENC_STATE_IDLE:
if (pin_a && !pin_b) enc_state = ENC_STATE_A;
else if (!pin_a && pin_b) enc_state = ENC_STATE_B;
break;
case ENC_STATE_A:
if (pin_b) {
enc_diff++;
enc_state = ENC_STATE_IDLE;
}
break;
case ENC_STATE_B:
if (pin_a) {
enc_diff--;
enc_state = ENC_STATE_IDLE;
}
break;
default:
enc_state = ENC_STATE_IDLE;
break;
}
}
3.3.2 编码器驱动的配置
编码器驱动的回调函数需要返回两个信息:旋转的差值(diff)和按键状态(编码器通常带一个按压开关)。
static void encoder_read_cb(lv_indev_drv_t * drv, lv_indev_data_t * data)
{
static int16_t last_diff = 0;
static lv_indev_state_t last_btn_state = LV_INDEV_STATE_RELEASED;
/* 读取编码器旋转差值 */
int16_t diff = encoder_get_diff();
/* 读取编码器按键状态 */
bool btn_pressed = encoder_get_button();
data->enc_diff = diff;
data->state = btn_pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;
}
enc_diff 值可以是正数(顺时针)或负数(逆时针)。LVGL会根据这个值自动处理焦点移动或数值增减。你不需要在回调函数里做任何界面逻辑,只管把硬件数据传上去就行。
3.4 输入设备回调函数详解
回调函数是LVGL输入设备驱动的灵魂。说白了,LVGL不知道你的硬件长什么样,它只通过回调函数来获取数据。所以,回调函数的实现质量,直接决定了用户体验。
3.4.1 回调函数的执行机制
LVGL会在 lv_timer_handler() 中周期性地调用所有注册的输入设备回调函数。默认周期是5毫秒。这意味着你的回调函数必须快速返回,不能在里面做延时操作。
我曾经见过有人在回调函数里加了个 delay(10),结果整个界面卡成幻灯片。嗯,这种错误犯一次就够了。
3.4.2 多输入设备的协同
一个系统里可以同时存在多个输入设备。比如触摸屏用于主操作,编码器用于快速选择,物理按键用于返回和确认。LVGL会分别处理每个设备的事件。
| 设备类型 | 典型场景 | 注意事项 |
|---|---|---|
| 触摸屏 | 直接点击、滑动 | 坐标校准、防抖 |
| 物理按键 | 菜单导航、确认取消 | 需要绑定Group |
| 编码器 | 音量调节、列表滚动 | 旋转方向一致性 |
3.4.3 回调函数的性能优化
回调函数里不要做复杂计算,不要动态分配内存,不要调用可能会阻塞的函数。我一般只在回调函数里做三件事:
- 读取硬件寄存器
- 简单的数据转换(比如坐标映射)
- 填充
lv_indev_data_t结构体
如果需要对原始数据做滤波或平滑处理,建议在另一个任务或定时器里做,然后用原子变量或队列把结果传给回调函数。
lv_... 开头的API来修改界面。回调函数运行在LVGL的内部循环中,修改界面可能会导致死锁或未定义行为。正确的做法是:回调函数只负责提供数据,界面更新由LVGL的事件系统处理。
3.5 本章知识体系
下面这张图,把三种输入设备的移植流程和关键点串起来了。你可以把它当作一个快速参考。
这张图从LVGL输入设备驱动这个中心点出发,分出了三条主线。每条线都对应一种设备类型,下面跟着的是移植过程中必须处理的关键环节。最后三条线汇聚到注册回调函数和LVGL主循环上。你照着这个结构走,基本不会漏掉什么。