X-Track 中的 LVGL 输入设备(indev)移植实战指南

原创2026-10-03 09:11:30226 阅读
文章标签:智能硬件嵌入式硬件开发

X-Track 中的 LVGL 输入设备(indev)移植实战指南

LVGL 通过"输入设备接口"(Input device interface,简称 indev)统一抽象触摸屏、鼠标、键盘、旋转编码器和外部按键等交互硬件。本文以 X-Track(离线地图 GPS 自行车码表)中实际移植的 LVGL 8.x 分支为背景,完整讲解 lv_indev_drv_t 的注册流程、四类输入设备的 read_cb 编写方法、参数调优与缓冲读取机制,并结合仓库中真实移植代码(旋转编码器 + 蜂鸣反馈)与 Windows 模拟器驱动给出可复制的实战方案。读完本文,你将能够为自己的嵌入式 LVGL 项目接入任意输入设备,并理解 X-Track 的按键交互是如何在 HAL 与 LVGL 之间流动的。

输入设备移植的第一步:驱动注册

任何输入设备接入 LVGL 前,都需要初始化一个 lv_indev_drv_t 结构体变量并完成注册。务必先注册至少一个显示驱动,再注册输入设备,因为输入设备的事件处理依赖显示对象树与屏幕坐标。

lv_disp_drv_register(&disp_drv);

static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);      /*基本初始化*/
indev_drv.type = ...;               /*设备类型,见下文四种取值*/
indev_drv.read_cb = ...;            /*周期回调,向 LVGL 上报设备状态*/
/*注册驱动并保存创建的输入设备对象*/
lv_indev_t * my_indev = lv_indev_drv_register(&indev_drv);

lv_indev_drv_t 的定义位于 lv_hal_indev.h,所有输入设备的底层读写逻辑由 lv_hal_indev.c 与 lv_indev.c 统一调度。type 成员可取四种枚举值:

类型宏 典型硬件 上报的数据字段
LV_INDEV_TYPE_POINTER 触摸屏、鼠标 point.x / point.y / state
LV_INDEV_TYPE_KEYPAD 全键盘、少按键键盘 key / state
LV_INDEV_TYPE_ENCODER 带按键的旋转编码器 enc_diff / state(可选 key)
LV_INDEV_TYPE_BUTTON 屏幕旁外部按键 btn_id / state

read_cb 是一个函数指针,LVGL 会以周期定时器(默认周期由 LV_INDEV_DEF_READ_PERIOD 决定)轮询调用它,每次调用把当前设备状态写入 lv_indev_data_t *data。lv_indev_drv_register 的返回值 lv_indev_t * 后续可用于设置光标、绑定对象组、绑定显示器等操作。

关于输入设备的整体概念(事件、手势、滚动等高层行为),可进一步阅读 Input devices 总览。

指针类设备:触摸屏与鼠标

触摸屏和鼠标都属于"能在屏幕某点点击"的指针设备,只需在 read_cb 中上报当前坐标与按下状态:

indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = my_input_read;

void my_input_read(lv_indev_drv_t * drv, lv_indev_data_t * data)
{
  if(touchpad_pressed) {
    data->point.x = touchpad_x;
    data->point.y = touchpad_y;
    data->state = LV_INDEV_STATE_PRESSED;
  } else {
    data->state = LV_INDEV_STATE_RELEASED;
  }
}

要点说明:

  • 松开时无需清空坐标,LVGL 只关心 state 的 LV_INDEV_STATE_PRESSED / LV_INDEV_STATE_RELEASED 切换;为保证手势连续性,建议像上面示例一样在按下时"记住"坐标。
  • 鼠标需要光标时,用 lv_indev_set_cursor(my_indev, &img_cursor) 绑定一个光标图像,my_indev 即注册时的返回值。
  • LVGL 官方移植模板 lv_port_indev_template.c 中给出了 touchpad_read 与 mouse_read 的完整骨架(含 touchpad_is_pressed()、touchpad_get_xy() 等底层函数占位)。

常见触摸控制器的现成驱动

X-Track 仓库随附的 lv_drivers 目录为指针类设备提供了多个可直接使用的驱动实现:

  • XPT2046:XPT2046.c,经典的 SPI 电阻屏控制器(常见于各种 TFT 屏模块)。
  • FT5406EE8:FT5406EE8.c,I2C 电容屏控制器。
  • AD_touch:AD_touch.c,基于 ADC 采样的触摸实现。
  • evdev / libinput / xkb:面向 Linux 桌面与嵌入式的输入子系统驱动,其中 xkb 还支持键盘布局。

这些驱动的头文件(如 mouse.h)内部都遵循同一模式:提供 xxx_init() 初始化和 xxx_read(lv_indev_drv_t *, lv_indev_data_t *) 读取函数,后者直接填入 lv_indev_drv_t.read_cb。注意 mouse.h 与 mousewheel.h 开头带有 #warning "Deprecated, use the SDL driver instead",说明旧鼠标/滚轮驱动已被推荐改用 SDL 驱动,新项目建议优先选择 lv_drivers/sdl 或 lv_drivers/win32drv。

键盘类设备:Keypad 与键盘

全键盘或仅有几个导航键的简单键盘均属于 LV_INDEV_TYPE_KEYPAD。键盘类设备依赖"对象组(group)"实现焦点导航,使用步骤比指针类多两步:

  1. 注册 LV_INDEV_TYPE_KEYPAD 类型的 read_cb。
  2. 创建对象组:lv_group_t * g = lv_group_create(),并把需要被按键操作的对象加入组:lv_group_add_obj(g, obj)。
  3. 把对象组绑定到输入设备:lv_indev_set_group(my_indev, g),my_indev 为 lv_indev_drv_register 的返回值。
  4. 在 read_cb 中使用 LV_KEY_... 系列键值导航组内对象(具体键值见 lv_group.h)。
indev_drv.type = LV_INDEV_TYPE_KEYPAD;
indev_drv.read_cb = keyboard_read;

void keyboard_read(lv_indev_drv_t * drv, lv_indev_data_t * data)
{
  data->key = last_key();            /*获取最近按下或释放的键*/

  if(key_pressed()) data->state = LV_INDEV_STATE_PRESSED;
  else data->state = LV_INDEV_STATE_RELEASED;
}

常见的 LV_KEY_* 控制键包括:LV_KEY_NEXT、LV_KEY_PREV、LV_KEY_LEFT、LV_KEY_RIGHT、LV_KEY_UP、LV_KEY_DOWN、LV_KEY_ENTER、LV_KEY_ESC 等。官方模板 lv_port_indev_template.c 中的 keypad_read 展示了如何把硬件扫描码映射为这些 LVGL 控制键——例如把按键 1~5 分别映射为 NEXT/PREV/LEFT/RIGHT/ENTER,这种"翻译层"正是键盘移植的核心工作。对象组的编辑/导航行为定义在 lv_group.c 中,lv_group_set_default() 可把某个组设为默认组,便于所有新建对象自动入组。

编码器设备:旋转 + 按压的经典交互

旋转编码器是 X-Track 这类手持设备最常用的交互方案,它支持四种操作:短按、长按、左旋、右旋。编码器输入设备的工作逻辑如下:

  • 旋转编码器 → 焦点在组内对象间前后移动;
  • 在简单对象(如按钮)上按下 → 触发点击;
  • 在复杂对象(如列表、消息框)上按下 → 进入编辑模式,此时旋转可在对象内部导航;
  • 长按 → 退出编辑模式。

使用方式与 Keypad 类似,需要把对象加入对象组。标准的 read_cb 如下:

indev_drv.type = LV_INDEV_TYPE_ENCODER;
indev_drv.read_cb = encoder_read;

void encoder_read(lv_indev_drv_t * drv, lv_indev_data_t * data)
{
  data->enc_diff = enc_get_new_moves();

  if(enc_pressed()) data->state = LV_INDEV_STATE_PRESSED;
  else data->state = LV_INDEV_STATE_RELEASED;
}

enc_diff 是两次读取之间编码器的相对步数(正负表示方向),由驱动层累加或由硬件中断计数器提供;state 表示编码器按钮是否按下。

用普通按键模拟编码器逻辑

如果硬件上没有旋转编码器而只有少量按键,可以把编码器的"逻辑"复用到按键上,用三个键模拟:LV_KEY_ENTER 模拟按下编码器按钮,LV_KEY_LEFT / LV_KEY_RIGHT 模拟左右旋转,其余按键原样传给当前焦点对象。长按按键时会以 indev_drv.long_press_rep_time 指定的周期持续模拟编码器步进,实现连续翻页效果:

indev_drv.type = LV_INDEV_TYPE_ENCODER;
indev_drv.read_cb = encoder_with_keys_read;

void encoder_with_keys_read(lv_indev_drv_t * drv, lv_indev_data_t * data)
{
  data->key = last_key();            /*取最近按键,LV_KEY_ENTER 用于模拟编码器按压*/
  if(key_pressed()) data->state = LV_INDEV_STATE_PRESSED;
  else {
    data->state = LV_INDEV_STATE_RELEASED;
    /*如果同时有真实编码器,也可叠加上报步数*/
    data->enc_diff = enc_get_new_moves();
  }
}

这种"按键模拟编码器"的写法非常适合只有 2~3 个物理键的极简 UI,X-Track 的设计文档与按键定义思路可参考 ButtonEvent 库。

源码实锤:X-Track 真机上的编码器移植

X-Track 固件在 lv_port_indev.cpp 中真实移植了编码器输入设备,代码与官方模板一脉相承:

static void encoder_init(void);
static void encoder_read(lv_indev_drv_t * indev_drv, lv_indev_data_t * data);

void lv_port_indev_init(void)
{
    static lv_indev_drv_t indev_drv;

    encoder_init();

    /*注册编码器输入设备*/
    lv_indev_drv_init(&indev_drv);
    indev_drv.type = LV_INDEV_TYPE_ENCODER;
    indev_drv.read_cb = encoder_read;
    lv_indev_t* indev = lv_indev_drv_register(&indev_drv);

    /*创建对象组并绑定到编码器,同时设为默认组*/
    lv_group_t* group = lv_group_create();
    lv_indev_set_group(indev, group);
    lv_group_set_default(group);
}

static void encoder_read(lv_indev_drv_t * indev_drv, lv_indev_data_t * data)
{
    static bool lastState;
    data->enc_diff = HAL::Encoder_GetDiff();

    bool isPush = HAL::Encoder_GetIsPush();

    data->state = isPush ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED;

    /*按下/松开瞬间触发蜂鸣反馈*/
    if(isPush != lastState)
    {
        HAL::Buzz_Tone(isPush ? 500 : 700, 20);
        lastState = isPush;
    }
}

这段代码展示了三个值得借鉴的工程细节:

  1. HAL 抽象:HAL::Encoder_GetDiff() 与 HAL::Encoder_GetIsPush() 把底层 GPIO 中断、正交解码等硬件细节全部封装在 HAL 层(接口声明见 HAL.h),read_cb 只做数据搬运,便于移植到不同 MCU。
  2. 蜂鸣反馈:直接在 read_cb 内对按压沿进行检测,按下响 500 Hz、松开响 700 Hz,各 20 ms——这正是"在用户操作时生成反馈"的典型实现,与文档中 feedback_cb 的设计思想一致。
  3. 默认对象组:lv_group_set_default(group) 使页面代码中新建的控件默认进入该组,配合 LVGL 的焦点系统即可让编码器在页面间自由导航。X-Track 各页面(Dialplate、LiveMap、SystemInfos 等,见 USER/App/Pages)正是依赖这套 group 机制实现旋钮式交互。

在 Windows 模拟器一侧,硬件编码器被替换为鼠标滚轮,其入口在 LVGL.Simulator.cpp:调用 lv_win32_add_all_input_devices_to_group(NULL) 把 Win32 驱动创建的鼠标/滚轮/键盘输入设备统一加入对象组;底层滚轮增量与按键状态由 lv_drivers 的 mousewheel_read(见 mousewheel.h,内部转发到 SDL 驱动)转换为 enc_diff,从而让模拟器与真机共用同一套应用层交互代码。模拟器的 HAL 层实现见 HAL_Encoder.cpp,其 Encoder_GetDiff() 在模拟环境恒返回 0,因为输入已由 Win32/SDL 驱动接管。

外部按键:把屏幕坐标映射到物理键

LV_INDEV_TYPE_BUTTON 指屏幕旁边的物理"硬按键",按下时模拟在指定屏幕坐标上的点击(效果类似触摸屏)。使用 lv_indev_set_button_points(my_indev, points_array) 把按键绑定到坐标数组:

const lv_point_t points_array[] = { {12,30},{60,90}, ...};
lv_indev_set_button_points(my_indev, points_array);

重要约束:points_array 不能超出作用域,必须声明为全局变量或函数内的 static 变量,否则坐标数组失效会导致按键点击位置错乱。

read_cb 的编写要点是维护"最近按下的按键 ID":

indev_drv.type = LV_INDEV_TYPE_BUTTON;
indev_drv.read_cb = button_read;

void button_read(lv_indev_drv_t * drv, lv_indev_data_t * data)
{
    static uint32_t last_btn = 0;   /*保存最近按下的按键*/
    int btn_pr = my_btn_read();     /*读取被按下按键的 ID(0,1,2...)*/
    if(btn_pr >= 0) {               /*有按键被按下(-1 表示无按键)*/
       last_btn = btn_pr;           /*记住该按键 ID*/
       data->state = LV_INDEV_STATE_PRESSED;
    } else {
       data->state = LV_INDEV_STATE_RELEASED;
    }

    data->btn = last_btn;           /*上报最近按键 ID*/
}

模板 lv_port_indev_template.c 的 button_read 使用 data->btn_id 字段,并给出 button_get_pressed_id() 的轮询写法:遍历所有物理键,返回第一个被按下的 ID,全部未按下则返回 -1。

输入设备的高级特性

可调参数

以下参数均可在 lv_indev_drv_t 中覆盖默认值:

参数 含义
scroll_limit 触发实际滚动前需要滑动的像素数,值越大滚动越"迟钝"
scroll_throw 滚动惯性(动量)衰减百分比,值越大减速越快
long_press_time 触发 LV_EVENT_LONG_PRESSED 的按压时长(毫秒)
long_press_rep_time 连续触发 LV_EVENT_LONG_PRESSED_REPEAT 的间隔(毫秒)
read_timer 读取该输入设备的 lv_timer 指针,可用 lv_timer_...() 系列函数修改其周期;默认读取周期由 lv_conf.h 中的 LV_INDEV_DEF_READ_PERIOD 定义

对于 scroll_limit 和 scroll_throw,在指针类触摸屏上滚动列表时可结合滚动体感反复调试;long_press_rep_time 则直接影响"按键模拟编码器"场景下长按连发的步进速度。

反馈回调 feedback_cb

除 read_cb 外,lv_indev_drv_t 还可指定 feedback_cb。该回调在输入设备产生任意类型事件(与设备类型无关)时被调用,用于为用户生成反馈,例如在 LV_EVENT_CLICKED 时播放音效。X-Track 的 encoder_read 直接在读回调里做蜂鸣虽然也能工作,但更规范的做法是把这类反馈统一收敛到 feedback_cb 中,避免读回调夹杂非数据逻辑。

与显示器关联

每个输入设备都关联一个显示器。默认情况下,新注册的输入设备会被关联到最后一个创建的、或被 lv_disp_set_default() 显式选中的显示器。关联关系存储在驱动的 disp 字段中,可随时修改。多显示器系统(如副屏、调试屏)需要为每个显示器分别管理输入设备归属。

缓冲读取与事件驱动

LVGL 默认周期轮询 read_cb,这种间歇式采样可能漏掉快速手势(如快速滑动)。解决办法是编写"事件驱动"驱动:硬件中断/线程实时把测量数据写入缓冲区,read_cb 每次只从缓冲区取数而非直接读硬件;同时置位 data->continue_reading 标志,告诉 LVGL"还有更多数据待读",从而连续多次调用 read_cb 直到缓冲区清空。这是处理高刷新率触摸屏、避免手势丢失的标准做法。

把移植模板变成自己的驱动

仓库同时提供两份可直接对照的模板:

  • 官方模板:lv_port_indev_template.c——同时给出 Touchpad、Mouse、Keypad、Encoder、Button 五种设备的注册骨架与 read_cb 占位实现,文件开头 #if 0 改为 #if 1 并复制为 lv_port_indev.c 即可启用。
  • X-Track 实际移植:lv_port_indev.cpp——在官方模板基础上精简为单编码器方案,并接入 HAL:: 硬件抽象层与蜂鸣反馈,是"模板落地到真实产品"的直接范例。真机入口调用链为:lv_port_indev_init() → 注册 LV_INDEV_TYPE_ENCODER → encoder_read() → HAL::Encoder_GetDiff() / HAL::Encoder_GetIsPush()。

移植新硬件时建议按如下顺序排查:先确认 lv_conf.h 中相关使能宏与 LV_INDEV_DEF_READ_PERIOD 取值;再逐项核对 read_cb 上报字段与设备类型是否匹配;最后验证对象组创建、对象入组与 lv_indev_set_group 是否在页面初始化之前完成。

更完整的输入设备底层 API(驱动初始化、数据读取、事件派发)可查阅 lv_hal_indev.h 的 doxygen 文档;高层交互特性(事件、手势、滚动)见 Input devices 总览 与 Display 特性说明。结合 X-Track 的 HAL 层源码 与 页面实现,即可完整复现"旋钮驱动的码表 UI"这一交互范式。

登录后查看全文
X-TRACK