LVGL实现触摸功能
213.3.12 LVGL实现触摸功能
/* lv_port_indev_template.c:打开输入设备移植模板。 */
#if 1 /* 让本文件中的输入设备代码参与条件编译。 */
#include "lv_port_indev_template.h" /* 引入本移植文件自己的接口声明。 */
#include "../../lvgl.h" /* 引入 LVGL 的类型、驱动接口和状态宏。 */
#include "touch.h" /* 引入 TP_Init、tp_dev 和 TP_PRES_DOWN。 */
static void touchpad_init(void); /* 提前声明触摸屏初始化适配函数。 */
static void touchpad_read(lv_indev_drv_t * indev_drv, lv_indev_data_t * data); /* 提前声明 LVGL 的触摸读取回调。 */
static bool touchpad_is_pressed(void); /* 提前声明按压状态检测函数。 */
static void touchpad_get_xy(lv_coord_t * x, lv_coord_t * y); /* 提前声明坐标读取函数。 */
lv_indev_t * indev_touchpad; /* 保存注册后得到的 LVGL 触摸输入设备对象。 */
void lv_port_indev_init(void) /* 初始化并向 LVGL 注册触摸输入设备。 */
{ /* 开始输入设备初始化。 */
static lv_indev_drv_t indev_drv; /* 驱动描述符必须长期有效,因此使用 static。 */
touchpad_init(); /* 先初始化底层电阻触摸屏驱动。 */
lv_indev_drv_init(&indev_drv); /* 用 LVGL 默认值初始化输入驱动描述符。 */
indev_drv.type = LV_INDEV_TYPE_POINTER; /* 本设备提供屏幕坐标,类型设为指针设备。 */
indev_drv.read_cb = touchpad_read; /* 把课程保留的触摸读取回调交给 LVGL。 */
indev_touchpad = lv_indev_drv_register(&indev_drv); /* 注册驱动并保存返回的输入设备指针。 */
} /* 输入设备初始化结束。 */
static void touchpad_init(void) /* 对接模板要求的触摸初始化函数。 */
{ /* 开始初始化底层触摸屏。 */
TP_Init(); /* 调用原有 touch.c 中的触摸屏初始化接口。 */
} /* 触摸屏初始化结束。 */
static void touchpad_read(lv_indev_drv_t * indev_drv, lv_indev_data_t * data) /* LVGL 周期调用的读取回调;本课保留模板实现。 */
{ /* 开始生成一份 LVGL 输入数据。 */
static lv_coord_t last_x = 0; /* 保存最近一次有效按压的 X 坐标。 */
static lv_coord_t last_y = 0; /* 保存最近一次有效按压的 Y 坐标。 */
if(touchpad_is_pressed()) { /* 扫描并判断触摸屏当前是否按下。 */
touchpad_get_xy(&last_x, &last_y); /* 按下时读取当前坐标并更新历史坐标。 */
data->state = LV_INDEV_STATE_PR; /* 向 LVGL 报告 pressed 状态。 */
} /* 按下状态处理结束。 */
else { /* 当前没有按压。 */
data->state = LV_INDEV_STATE_REL; /* 向 LVGL 报告 released 状态。 */
} /* 释放状态处理结束。 */
data->point.x = last_x; /* 无论按下或释放,都提交最近一次有效 X 坐标。 */
data->point.y = last_y; /* 无论按下或释放,都提交最近一次有效 Y 坐标。 */
} /* 本次触摸读取结束。 */
static bool touchpad_is_pressed(void) /* 把底层状态位转换为 true 或 false。 */
{ /* 开始扫描并判断按压状态。 */
tp_dev.scan(0); /* 扫描触摸屏,并把原始值换算为屏幕坐标。 */
if(tp_dev.sta&TP_PRES_DOWN) /* 检查 sta 的按下标志位是否为 1。 */
{ /* 检测到屏幕正在被按下。 */
return true; /* 把“按下”返回给 touchpad_read。 */
} /* 按下分支结束。 */
return false; /* 没有按下时返回 false。 */
} /* 按压状态判断结束。 */
static void touchpad_get_xy(lv_coord_t * x, lv_coord_t * y) /* 把底层首个触点坐标写到调用者变量中。 */
{ /* 开始读取坐标。 */
(*x) = tp_dev.x[0]; /* 解引用 x 指针并写入第 0 个触点的 X 坐标。 */
(*y) = tp_dev.y[0]; /* 解引用 y 指针并写入第 0 个触点的 Y 坐标。 */
} /* 坐标读取结束。 */
/* lv_port_indev_template.h:头文件也要打开,并向 main.c 声明初始化接口。 */
#if 1 /* 让头文件中的声明参与条件编译。 */
void lv_port_indev_init(void); /* 声明输入设备移植层的初始化函数。 */
/* 三个 Widgets 图片资源文件:修正课程实际遇到的 lvgl.h 包含路径。 */
#include "lvgl.h" /* img_clothes.c 通过 Keil 的包含目录找到 LVGL。 */
#include "lvgl.h" /* img_demo_widgets_avatar.c 使用相同改法。 */
#include "lvgl.h" /* img_lvgl_logo.c 使用相同改法。 */
/* lv_conf.h:启用 Widgets demo、性能监视器和内存监视器。 */
#define LV_USE_DEMO_WIDGETS 1 /* 编译官方 Widgets 综合演示。 */
#define LV_USE_PERF_MONITOR 1 /* 在右下角显示 FPS 与 CPU 占用率。 */
#define LV_USE_MEM_MONITOR 1 /* 在左下角显示 LVGL 内存池占用与碎片率。 */
/* main.c:加入输入端口和 Widgets demo,并按正确顺序初始化。 */
#include "lv_port_indev_template.h" /* 让 main.c 能调用 lv_port_indev_init。 */
#include "lv_demo_widgets.h" /* 让 main.c 能调用 lv_demo_widgets。 */
lv_init(); /* 初始化 LVGL 核心。 */
lv_port_disp_init(); /* 注册 LCD 显示输出驱动。 */
lv_port_indev_init(); /* 注册触摸输入驱动;漏掉它就无法交互。 */
lv_demo_widgets(); /* 创建本课用于触摸验证的官方 Widgets 界面。 */
本节源码边界: 上述代码来自第 17 号配套工程,保留了课程实际使用的函数名和处理顺序。本课只完成触摸输入端口、Widgets demo 及监视信息的接入;显示提速属于下一课内容,本节不提前加入。三个资源文件中的
#include "lvgl.h"是三个独立文件各自的一行,不是要求在同一个文件里重复包含三次。
一、LVGL实现触摸功能
1.1 测试触摸功能
课程开头先展示目标效果:开发板复位后出现 LVGL 官方 Widgets 综合演示,点击不同控件可以切换页面、改变颜色,并能操作图表等组件。这里展示的是本节完成后的效果,不是要求直接照着画界面;其目的在于同时验证显示输出和触摸输入。
本课在第 16 号显示工程基础上复制出第 17 号工程,并在工程名中加入 Touch。课程目录里虽然已有第 15 号相近工程,但它是早期的半成品;本节应以第 16 号上一课结果为起点,避免混入不完整或错误配置。
完整的数据通路是:
手指按压电阻触摸屏
→ 原有 touch.c 扫描、校准并更新 tp_dev
→ lv_port_indev_template.c 转换状态和坐标
→ LVGL 输入设备驱动 read_cb 周期读取
→ LVGL 判断哪个控件被按下并执行交互
→ 显示驱动把变化后的界面刷新到 LCD
触摸“有坐标”并不等于 LVGL 已能使用。底层触摸驱动只负责得到按压状态和坐标;还必须把这些信息转换成 LVGL 规定的 lv_indev_data_t,再注册为输入设备。
1.2 触摸屏移植
1.2.1 修改条件编译
原笔记中的 in_dev、ping、f0/f1 都是 OCR 错误。准确名称如下:
| OCR 错词 | 正确内容 | 含义 |
|---|---|---|
in_dev | indev | input device,输入设备 |
ping | porting | LVGL 的移植模板目录 |
f0 / f1 | #if 0 / #if 1 | C 预处理条件编译开关 |
在 midware/lvgl/examples/porting 下,把 lv_port_indev_template.c 第 7 行和 lv_port_indev_template.h 第 8 行的 #if 0 都改成 #if 1。.c 文件打开实现,.h 文件打开接口声明,两处必须同步。
#if 0 会让预处理器丢弃直到配对 #else/#endif 之间的代码;改成 #if 1 后,这部分才真正参与编译。行号只对应课程当前版本,查找时应认宏和文件名,不能把行号当成固定接口。
lv_port_disp_template.c 负责显示输出,lv_port_indev_template.c 负责输入。二者分别解决“LVGL 怎样把像素送到 LCD”和“LVGL 怎样从触摸屏取得状态与坐标”。
1.2.2 删掉无用函数
LVGL 输入模板同时给出了触摸屏、鼠标、键盘、旋转编码器和外部按钮示例。本工程只接电阻触摸屏,因此课程删除其余设备的:
- 静态函数声明;
- 驱动对象或辅助变量;
lv_port_indev_init()中的注册代码;mouse_init/read、keypad_init/read、encoder_init/read、button_init/read等示例函数。
保留 Touchpad 部分及文件末尾配对的括号、#else、#endif。这一步主要是清理模板,并非触摸生效的必要条件;不删除未使用示例也可以,但删除时很容易误删函数大括号或条件编译边界。课程在清理后立即编译,结果无 error,用小步编译确认结构没有破坏。
1.2.3 在touchpad_init函数添加TP_Init
在 lv_port_indev_template.c 中包含 touch.h,然后在模板的 touchpad_init() 内调用 TP_Init()。touch.h 提供了:
TP_Init()的函数声明;_m_tp_dev类型定义;- 全局对象
tp_dev的extern声明; TP_PRES_DOWN等触摸状态宏。
因此,“添加 TP_Init”准确地说是添加函数调用,不是把 TP_Init() 的底层实现复制到移植文件。它与上一课在显示端口的 disp_init() 中调用 LCD_Init() 是同一种分层方式:LVGL 端口函数负责接入,已有 BSP 驱动负责具体硬件初始化。
lv_port_indev_init() 先调用 touchpad_init(),再初始化 lv_indev_drv_t,把类型设置为 LV_INDEV_TYPE_POINTER、把 read_cb 指向 touchpad_read(),最后调用 lv_indev_drv_register()。只有注册完成,LVGL 才会建立输入读取定时器并周期调用回调。
1.2.4 添加触摸函数
1.2.4.1 添加触摸函数前的准备
课程不是新增三个完全独立的接口,而是填充模板已经准备好的 Touchpad 适配层:
| 函数 | 本课职责 | 是否由 LVGL 直接调用 |
|---|---|---|
touchpad_init() | 调用底层 TP_Init() | 否,由 lv_port_indev_init() 调用 |
touchpad_read() | 组织 lv_indev_data_t | 是,注册为 read_cb |
touchpad_is_pressed() | 扫描并返回按下/释放 | 否,由 touchpad_read() 调用 |
touchpad_get_xy() | 读取首个触点坐标 | 否,由 touchpad_read() 调用 |
模板注释 Your code comes here 的含义是“在这里填入用户硬件适配代码”,不是原笔记 OCR 得到的“优酷的 com here”。课程保留了模板的 touchpad_read(),主要补上初始化、状态判断和坐标读取三处。
1.2.4.2 补充触摸函数
tp_dev 在 touch.c 中定义,在 touch.h 中通过 extern _m_tp_dev tp_dev; 对外声明。包含 touch.h 后,输入端口文件可以访问同一个全局触摸设备对象,而不是重新创建一份对象。
tp_dev 内与本课直接相关的成员包括:
| 成员 | 作用 |
|---|---|
scan | 函数指针,当前指向底层 TP_Scan() |
sta | 保存当前按压相关状态位 |
x[]、y[] | 保存触点坐标;本课读取索引 0 |
在 lv_port_indev_init() 中,static lv_indev_drv_t indev_drv 不能随函数返回而失效。配套代码将它声明为静态局部变量,使 LVGL 注册后仍能持有有效的驱动描述符。注册返回的 indev_touchpad 则保存 LVGL 输入设备对象指针,供后续需要时使用。
1.2.4.3 触摸函数中坐标值的获取
touchpad_get_xy() 的两个形参都是指针:
x ──► touchpad_read() 中的 last_x
y ──► touchpad_read() 中的 last_y
所以 (*x) = tp_dev.x[0] 不是给指针本身赋值,而是把第 0 个触点的 X 坐标写入 last_x;Y 坐标同理。当前底层驱动是单点电阻触摸用法,故只读取 [0]。
tp_dev.scan(0) 的参数 0 表示按屏幕坐标方式扫描。底层 TP_Scan() 会读取原始触摸值,并利用 xfac/yfac/xoff/yoff 换算为 LCD 坐标。因此交给 LVGL 的不是未经校准的 ADC 原始值,而是与当前屏幕方向相匹配的坐标。
模板只在检测到按压时更新 last_x/last_y;释放时仍把最近一次有效坐标写给 LVGL。这一点很重要:底层驱动在持续释放后可能把当前坐标清成 0xFFFF,而“在原位置释放”比突然上报一个越界点更符合指针设备语义。
1.2.4.4 触摸函数中触摸屏状态的检测
状态判断分两步:
tp_dev.scan(0)更新tp_dev.sta和坐标;tp_dev.sta & TP_PRES_DOWN检查按下标志位。
本工程的 TP_PRES_DOWN 为 0x80,也就是检查 sta 的最高位。按位与结果非 0 表示正在按下,函数返回 true;否则返回 false。这里检查的是一个状态位,不能把 sta 整体简单理解成只会取 0 或 1 的布尔变量。
随后 touchpad_read() 把布尔结果翻译成 LVGL 状态:
| 底层判断 | LVGL 状态 | 含义 |
|---|---|---|
true | LV_INDEV_STATE_PR | pressed,正在按下 |
false | LV_INDEV_STATE_REL | released,已经释放 |
PR 和 REL 是当前 LVGL v8.3 工程为 PRESSED、RELEASED 提供的简写宏。LVGL 不会直接理解 TP_PRES_DOWN,这一层转换正是端口文件的作用。
1.2.4.5 触摸函数代码的填充与编译
填充后的调用关系如下:
lv_timer_handler()
→ LVGL 输入读取定时器(本工程默认周期 30 ms)
→ touchpad_read()
├─ touchpad_is_pressed()
│ └─ tp_dev.scan(0)
└─ touchpad_get_xy()
→ data->state + data->point
→ LVGL 控件事件处理
课程编译无 error,说明符号声明和基本语法正确。编译通过只能证明静态构建成功,不能证明触摸方向、坐标校准和运行时注册都正确,所以还要用可交互 demo 烧录验证。
显示端与输入端的职责应分开理解:
- 显示端口通过
disp_flush()把 LVGL 像素缓冲送到 LCD; - 输入端口通过
touchpad_read()把底层触摸数据送入 LVGL; lv_timer_handler()驱动 LVGL 的刷新、动画和输入轮询;- 两个端口都要初始化,只有显示初始化时,界面能出现但不会响应触摸。
1.2.5 触摸测试
1.2.5.1 添加头文件路径
本课使用 LVGL 官方 Widgets demo。第 16 号工程已经有 ../midware/demos 和 stress demo 的包含路径,本课在 Keil Options for Target → C/C++ → Include Paths 中再加入:
..\midware\demos\widgets
只需要把含有 lv_demo_widgets.h 的 widgets 目录加入头文件搜索路径。widgets/assets 目录只有本课使用的 .c 图片资源,没有需要由 main.c 包含的头文件,因此不必作为 Include Path 添加。
头文件搜索路径和源文件加入工程是两件事:前者帮助 #include 找声明,后者让 .c 文件真正被编译并参与链接,不能互相替代。
1.2.5.2 修改lv_conf.h头文件
在 lv_conf.h 的 Demo Usage 区把:
#define LV_USE_DEMO_WIDGETS 0
改为:
#define LV_USE_DEMO_WIDGETS 1
课程先口述第 715 行,随后更正为当前文件第 751~752 行附近。可靠做法是搜索宏名 LV_USE_DEMO_WIDGETS,而不是依赖会随代码变化的行号。改完应保存文件,否则下一次编译仍会采用旧配置。
LV_USE_DEMO_WIDGETS 是编译开关;即使把 demo 的 .c 文件加入工程,宏仍为 0 时,功能代码也会被条件编译排除。
1.2.5.3 添加c文件
在 Keil 的 midware\demo 代码组中加入 4 个源文件:
| 目录 | 文件 |
|---|---|
midware/demos/widgets | lv_demo_widgets.c |
midware/demos/widgets/assets | img_clothes.c |
| 同上 | img_demo_widgets_avatar.c |
| 同上 | img_lvgl_logo.c |
主文件创建页面、控件和事件,三个资源文件把衣服、头像和 LVGL 标志图片编译成程序可用的数据。遗漏资源文件通常会在链接阶段出现相应图片符号未定义;只添加图片资源而漏掉主文件,则没有 lv_demo_widgets() 的实现。
课程第一次操作时误把第 15 号工程中的文件加入当前工程,编译失败后全部移除,再从第 17 号工程目录重新添加。Keil 工程树里显示相同文件名不代表来源相同,遇到异常时应检查文件属性中的完整路径。
1.2.5.4 main.c添加头文件
在 main.c 中加入:
#include "lv_demo_widgets.h"
并在 lv_init()、lv_port_disp_init() 之后调用 lv_demo_widgets(),同时停用上一课的 lv_demo_stress() 调用,避免两个演示同时创建界面。准确函数名是 lv_demo_widgets(),原笔记中的 LV_demo_vgis、VGZ 都是语音转写错误。
仅创建 Widgets 界面还不够。main.c 还必须包含 lv_port_indev_template.h,并在创建 demo 前调用 lv_port_indev_init()。推荐按本课最终工程的顺序理解:
lv_init()
→ lv_port_disp_init()
→ lv_port_indev_init()
→ lv_demo_widgets()
→ while(1) 中反复执行 lv_timer_handler()
1.2.5.5 应用案例
1. 编译错误:加入了错误工程的源文件
课程最初误选第 15 号工程文件。处理方法不是盲目继续加 Include Path,而是先移除错误文件,再确认从第 17 号工程的 midware/demos/widgets 和 assets 目录加入正确文件。
2. 编译错误:图片资源中的相对包含路径不适配当前目录布局
三个图片资源文件原来写的是:
#include "../../../lvgl.h"
从 midware/demos/widgets/assets 向上三级只能回到 midware,而本工程实际头文件位于 midware/lvgl/lvgl.h,所以该相对路径找不到目标。工程的 Include Paths 已含 ../midware/lvgl,课程最终把三个资源文件都改成:
#include "lvgl.h"
这样编译器会通过配置好的搜索目录定位头文件。lv_demo_widgets.h 中原有的 #include "../lv_demos.h" 与实际目录匹配,课程没有改它。
3. 能显示但不能触摸:遗漏输入设备初始化
第一次成功编译和烧录后,Widgets 页面能显示,手指点击却没有反应。由于显示链路已经工作,排查重点应转向输入注册。课程检查 main.c 后发现只调用了 lv_port_disp_init(),漏掉:
lv_port_indev_init();
补上 lv_port_indev_template.h 和该调用,重新编译、烧录后,点击控件可以产生响应。这是本节功能验证的关键闭环。
4. 一个实用的分层排查顺序
| 现象 | 优先检查 |
|---|---|
| 找不到头文件 | #include 拼写、相对路径、Include Paths |
| 找不到函数或图片符号 | 相应 .c 是否加入工程、配置宏是否启用 |
| 页面不出现 | 显示端口初始化、demo 调用、定时处理函数 |
| 页面出现但触摸无效 | 输入端口是否启用、初始化并注册 |
| 能触摸但位置不准 | 底层校准参数、屏幕方向和坐标映射 |
| 能交互但很卡 | 显示刷新耗时;本课只记录现象,下一课处理 |
1.2.5.6 修改帧率显示
原笔记把文件名写成了 display template.c,实际修改的是 lv_conf.h:
#define LV_USE_PERF_MONITOR 1
#define LV_USE_MEM_MONITOR 1
在课程版本中,它们分别位于约第 282 行和第 289 行。启用后的显示位置由紧邻的配置决定:
LV_USE_PERF_MONITOR_POS为LV_ALIGN_BOTTOM_RIGHT,右下角显示 FPS 和 CPU 占用率;LV_USE_MEM_MONITOR_POS为LV_ALIGN_BOTTOM_LEFT,左下角显示 LVGL 内存池用量和碎片率;- 内存监视要求
LV_MEM_CUSTOM == 0,本工程恰好采用 LVGL 内部内存池,满足条件。
这些监视标签本身也会参与界面绘制,适合开发阶段观察,不应误认为它们是 LCD 控制器硬件直接给出的测量值。
二、触摸功能实现
2.1 投屏显示
补齐 lv_port_indev_init() 并重新烧录后,投屏画面中的按钮、颜色选择和图表等控件都能响应触摸,说明以下环节同时成立:
底层触摸屏可以扫描
→ 按压状态和坐标成功送入 LVGL
→ Widgets 控件收到输入事件
→ 变化后的画面又能通过显示端口输出
不过交互非常迟缓:点击后页面变化需要明显等待。启用性能监视后,课程画面右下角约为 4 FPS,即监视器统计到每秒约 4 帧,和肉眼观察到的卡顿一致。右下角标签实际还包含 CPU 百分比;左下角则是内存信息,不能把下方所有数字都当作 FPS。
课程口述“正常最高约 33 FPS”只适用于本工程当前配置,不是所有 LVGL 系统的固定上限。本工程:
[ LV_DISP_DEF_REFR_PERIOD=30\text{ ms} ]
因此默认刷新定时器对应的理论调度上限约为:
[ FPS_{limit}=\left\lfloor\frac{1000}{30}\right\rfloor=33\text{ FPS} ]
它只是当前 30 ms 刷新周期形成的上限;真正能达到多少还取决于界面变化量、绘制耗时和 LCD 数据传输耗时。把 4 FPS 直接解释成“单片机每秒固定刷四张完整屏幕”也不够严谨,LVGL 主要刷新失效区域,性能监视器统计的是达到条件的刷新帧。
本课到此只确认了触摸功能完整可用以及显示速度偏低,没有修改底层传输。下一课才从第 17 号工程复制出第 18 号工程并处理帧率优化,不能把后续优化代码提前并入本节成果。