只需要在嵌入式设备上点亮一块屏幕、画几个按钮,很多开发者第一时间就会想到 LVGL。可一旦交互方式从“触摸屏”变成“USB 鼠标”,事情就变得有些微妙了。很多人在移植完 LVGL 之后,发现鼠标插上没反应,光标不显示,点按事件始终不触发,最后只能去查驱动、查事件队列、查坐标转换。
这篇文章要讲的就是一条完整的解决路径:在沁恒 RISC-V 单片机 ch32V303 上跑 LVGL,并且用 USB 鼠标控制界面。项目本身并不复杂,但中间涉及到的“LVGL 指针设备驱动”、“USB HID 设备对接”、“坐标系归一化”这些环节,如果没有梳理清楚,很容易卡住几天。读完这篇文章,你不仅能跑通鼠标控制 LVGL 的最小示例,还能搞清楚鼠标事件从硬件到应用层的完整链路,以后换屏幕、换主控、换输入设备都能复用这套思路。
1. 为什么要在 MCU 上做“鼠标控制界面”
先回答一个很多人会问的问题:嵌入式 GUI 不是触摸屏的天下了吗,为什么还需要鼠标?
从实际项目来看,至少有两个明确场景。
第一个场景是工业 HMI 与医疗设备。这类设备通常没有触摸屏,或者出于防护、消毒、防误触的考虑,禁止使用触摸交互,而是用外接的 USB 鼠标、轨迹球、定制 HID 设备操作界面。医疗设备尤其常见这种模式,医生戴着橡胶手套,点触控屏容易误触,用鼠标更精准。
第二个场景是开发调试与界面预览。很多工程师在 PC 上用 LVGL 模拟器写完界面,再交叉编译到开发板上,但开发板只有一块 RGB 屏,没有电容触摸屏。这时直接插一个 USB 鼠标,就能像操作模拟器一样操作开发板,不需要额外硬件成本,还能用“光标位置 + 点击事件”快速验证 UI 布局。
这两个场景指向同一个技术结论:LVGL 的输入设备抽象层(indev)本身支持鼠标,但MCU 侧必须先完成 USB HID 设备的枚举和数据读取,再把这个数据源“翻译”成 LVGL 能够识别的指针设备输入。换句话说,问题不在 LVGL,而在底层的 USB 主机驱动。
2. 硬件平台与核心概念
2.1 ch32V303 平台特点
ch32V303 是沁恒(WCH)推出的一款基于 RISC-V 内核的通用微控制器,主频最高可以跑到 144MHz,集成 USB2.0 高速/全速控制器。它有几个和本项目密切相关的特性:
- 内置 USB PHY,支持 Host 与 Device 模式,不需要外接 USB 转接芯片。
- 有较高的 Flash 与 RAM 容量,能够承载 LVGL 的运行时数据。
- 支持通过 WCH-Link 调试下载,开发工具链简单。
这意味着,用 ch32V303 做 USB 鼠标宿主,不需要再外挂一块 CH375 或其他 USB Host 芯片,硬件成本能压得非常低。对于嵌入式 GUI 开发者来说,这是最直观的吸引力。
2.2 LVGL 输入设备抽象层
LVGL 内部把输入设备分为五类,在 v8 中包括LV_INDEV_TYPE_POINTER(指针设备)、LV_INDEV_TYPE_KEYPAD(键盘)、LV_INDEV_TYPE_ENCODER(编码器)、LV_INDEV_TYPE_BUTTON(外部按键),以及 v9 之后对指针设备做了进一步细分。鼠标属于指针设备(Pointer Device),触摸屏也属于指针设备。
在代码层面,LVGL 驱动指针设备需要实现一个read_cb回调,这个回调会被 LVGL 的定时器周期性调用:
void mouse_read(lv_indev_drv_t * drv, lv_indev_data_t * data) { // 填充 x、y 坐标 >USB 鼠标硬件 ↓ 1. USB Host 枚举 + HID Report 数据 ch32V303 USB Host 驱动 ↓ 2. 解析鼠标按键与相对位移 鼠标状态管理模块(累加坐标) ↓ 3. 填充 lv_indev_data_t LVGL Input Device 驱动 (lv_port_indev / read_cb) ↓ 4. 触发对象事件 LVGL UI 控件(按钮、列表、仪表盘)第 1 层是 USB 协议栈的事情。ch32V303 使用库函数或 SDK 完成 USB Host 初始化,并注册 HID 设备的回调接口,这样每当鼠标上报数据,程序就能获得到原始 HID Report。
第 2 层是报文解析。把 4 字节的鼠标位移数据识别出来,并拆解出按键状态。
第 3 层是坐标累计。鼠标是相对位移设备,而 LVGL 的指针设备需要绝对坐标,所以要在内存中维护一个mouse_x和mouse_y,每次收到位移就加进去,并做屏幕边界裁剪。
第 4 层是 LVGL 轮询。LVGL 每次刷新周期都会调用mouse_read,此时把第 3 层维护的坐标和按键状态填入lv_indev_data_t。
在这四层中,最容易出错的是第 3 层,一旦坐标累加和边界裁剪没做,鼠标光标会飞出屏幕或者卡在边缘。这个细节会直接影响用户体感,建议在写驱动时优先重视。
5. USB HID 鼠标驱动移植与代码实现
从驱动的角度来说,USB HID 鼠标移植并不需要理解整个 USB 协议栈,只需要按照芯片 SDK 的示例修改。此处在示例代码中做一个典型的 HID 鼠标读取与解析逻辑演示,方便移植到自己的工程里。
5.1 USB Host 初始化(示例伪代码)
在 ch32V303 的 USB Host 库中,一般会提供一个回调机制。以下代码展示一个典型的初始化与回调流程,具体 API 需按实际 SDK 调整:
// 文件路径:USB/usb_host_hid.c #include "usb_host_hid.h" static volatile int16_t mouse_dx; static volatile int16_t mouse_dy; static volatile uint8_t mouse_buttons; void usb_host_hid_init(void) { // 1. 初始化 USB Host 控制器 USBH_Init(); // 2. 设置 HID 类请求,等待鼠标接入 USBH_HIDSetInterface(); USBH_HIDStart(); } void usb_host_hid_process(void) { // 3. 轮询 USB 主机状态,处理枚举事件 USBH_Process(); } // USB Host 库在收到 HID Report 时会调用这个回调 void HID_ReportReceived(uint8_t *report, uint16_t len) { if (len < 3) return; mouse_buttons = report[0]; // 按键 mouse_dx = (int8_t)report[1]; // X 位移 mouse_dy = (int8_t)report[2]; // Y 位移 }注意:report[1]是有符号的 8 位整数,移动方向是右正左负、下正上负。这个符号方向在不同的鼠标之间是统一的,但最终在 LVGL 上显示的时候,屏幕坐标系的 Y 轴方向可能相反,届时需要做一次取反处理。
5.2 坐标累计与边界裁剪
这是鼠标驱动的核心,也是很多人忽略的一步。LVGL 的鼠标驱动需要绝对坐标,而 USB 鼠标只给出相对位移,因此需要在驱动中维护一个“光标坐标”:
// 文件路径:user/lv_port_indev.c #include "lvgl.h" #include "usb_host_hid.h" #define MOUSE_RES_W 480 #define MOUSE_RES_H 272 static int16_t mouse_x = MOUSE_RES_W / 2; static int16_t mouse_y = MOUSE_RES_H / 2; static void mouse_update_from_hid(void) { int16_t dx = usb_host_hid_get_dx(); int16_t dy = usb_host_hid_get_dy(); // 如果屏幕 Y 轴与鼠标 Y 轴方向相反,这里做取反 mouse_x += dx; mouse_y += dy; // 边界裁剪 if (mouse_x < 0) mouse_x = 0; if (mouse_x >= MOUSE_RES_W) mouse_x = MOUSE_RES_W - 1; if (mouse_y < 0) mouse_y = 0; if (mouse_y >= MOUSE_RES_H) mouse_y = MOUSE_RES_H - 1; }有一个容易被忽视的细节:当鼠标插到屏幕上后,第一次移动鼠标时,光标应该从屏幕中间开始移动,还是从左上角开始?不同产品有不同的设计取向。建议开发阶段把初始点放到屏幕中心,这样不管鼠标朝哪个方向移动,都能立即看到光标响应;如果是生产设备,可以设计成“上电居中”或者“记忆上次位置”。
5.3 LVGL 指针设备驱动注册
LVGL 官方提供了lv_port_indev_template.c模板,我们只需要在模板之上补充鼠标数据源:
// 文件路径:user/lv_port_indev.c static lv_indev_drv_t indev_drv; static lv_indev_t * mouse_indev = NULL; void lv_port_indev_init(void) { // 初始化鼠标坐标 mouse_x = MOUSE_RES_W / 2; mouse_y = MOUSE_RES_H / 2; // 注册指针设备驱动 lv_indev_drv_init(&indev_drv); indev_drv.type = LV_INDEV_TYPE_POINTER; indev_drv.read_cb = mouse_read; mouse_indev = lv_indev_drv_register(&indev_drv); } static void mouse_read(lv_indev_drv_t * drv, lv_indev_data_t * data) { mouse_update_from_hid(); >// 文件路径:app/main.c #include "lvgl.h" #include "usb_host_hid.h" #include "lv_port_indev.h" int main(void) { // ... 系统时钟、显示屏初始化 ... lv_init(); lv_port_disp_init(); // 显示驱动初始化 lv_port_indev_init(); // 鼠标输入设备初始化 usb_host_hid_init(); // USB Host 初始化 while (1) { usb_host_hid_process(); // 处理 USB 事件 lv_task_handler(); // LVGL 任务处理 lv_tick_inc(1); // LVGL 时基 delay_ms(1); } }需要注意,lv_tick_inc(1)的调用周期要准确。如果系统有硬件定时器,更好的是在定时器中断里调用lv_tick_inc,主循环里只保留lv_task_handler和 USB 处理。
6. 完整示例:LVGL 鼠标控制柱状图
为了验证鼠标交互,这里做一个最直观的示例:鼠标移动到一个柱状图的上方,点击左键,柱状图的高度朝点击位置变化。这个场景模拟了“鼠标点哪儿,哪儿显示柱状图”的交互,能直接验证坐标、事件和 UI 刷新三条链路。
6.1 UI 初始化代码
创建两个柱状条和一个提示标签:
// 文件路径:app/ui_demo.c #include "ui_demo.h" static lv_obj_t * bar1; static lv_obj_t * bar2; static lv_obj_t * label; void ui_demo_create(lv_obj_t * parent) { lv_obj_t * scr = lv_scr_act(); // 柱状条1 bar1 = lv_bar_create(scr); lv_obj_set_size(bar1, 80, 200); lv_obj_set_pos(bar1, 80, 30); lv_bar_set_range(bar1, 0, 100); lv_bar_set_value(bar1, 30, LV_ANIM_OFF); // 柱状条2 bar2 = lv_bar_create(scr); lv_obj_set_size(bar2, 80, 200); lv_obj_set_pos(bar2, 240, 30); lv_bar_set_range(bar2, 0, 100); lv_bar_set_value(bar2, 70, LV_ANIM_OFF); // 提示标签 label = lv_label_create(scr); lv_obj_set_pos(label, 40, 10); lv_label_set_text(label, "Click bar to change value"); } void ui_demo_set_bar_value(int index, int value) { if (index == 0) lv_bar_set_value(bar1, value, LV_ANIM_ON); else lv_bar_set_value(bar2, value, LV_ANIM_ON); } void ui_demo_set_label(const char * text) { lv_label_set_text(label, text); }6.2 鼠标点击事件关联
接下来在main.c中轮询鼠标事件,并把鼠标坐标换算成“点击哪一个柱状条”:
// 文件路径:app/main.c void mouse_demo_handle_click(int16_t x, int16_t y) { if (x >= 80 && x <= 160 && y >= 30 && y <= 230) { // 点击柱状条1,将在柱状条中心区域显示当前Y指针的值 int value = (230 - y) * 100 / 200; ui_demo_set_bar_value(0, value); ui_demo_set_label("Bar1 clicked"); } else if (x >= 240 && x <= 320 && y >= 30 && y <= 230) { int value = (230 - y) * 100 / 200; ui_demo_set_bar_value(1, value); ui_demo_set_label("Bar2 clicked"); } }这里的坐标换算逻辑是:柱状条顶部表示 100,底部表示 0。鼠标点击得越低,y值越大,柱状条数值越小。这一层业务逻辑完全在 LVGL 之上实现,不需要依赖 LVGL 具体控件的事件系统,便于理解鼠标坐标到 UI 区域的映射。
6.3 与 LVGL 事件系统结合
如果希望使用 LVGL 原生事件系统,可以给每个柱状条注册lv_obj_add_event_cb,并在回调里调用lv_indev_get_act()获取当前活动输入设备,读取坐标:
// 文件路径:app/ui_demo.c static void bar_click_event_cb(lv_event_t * e) { lv_obj_t * bar = lv_event_get_target(e); lv_indev_t * indev = lv_indev_get_act(); if (indev == NULL) return; lv_point_t point; lv_indev_get_point(indev, &point); lv_obj_t * scr = lv_scr_act(); lv_coord_t x = point.x; lv_coord_t y = point.y; lv_coord_t bar_x = lv_obj_get_x(bar); lv_coord_t bar_y = lv_obj_get_y(bar); lv_coord_t bar_h = lv_obj_get_height(bar); if (x >= bar_x && x <= bar_x + lv_obj_get_width(bar) && y >= bar_y && y <= bar_y + bar_h) { int value = (bar_h - (y - bar_y)) * 100 / bar_h; lv_bar_set_value(bar, value, LV_ANIM_ON); } }在ui_demo_create中注册:
lv_obj_add_event_cb(bar1, bar_click_event_cb, LV_EVENT_CLICKED, NULL); lv_obj_add_event_cb(bar2, bar_click_event_cb, LV_EVENT_CLICKED, NULL);这里要强调的是,LVGL 的事件触发依赖输入设备的状态机:当鼠标按下时,LVGL 记录按下对象;当鼠标释放时,LVGL 检查释放位置是否还在同一对象上,如果“按下”和“释放”发生在同一个对象内,才触发LV_EVENT_CLICKED。因此,如果只按不松手,事件不会触发,此时应先用LV_EVENT_PRESSED调试。
7. 运行验证与效果确认
编译烧录后,插上 USB 鼠标,移动鼠标,观察屏幕。正常情况下,你会看到一个光标(如果启用了 LVGL 鼠标光标)在屏幕上移动,点击柱状条时数值会变化。
7.1 验证步骤
- 屏幕显示 UI 界面,鼠标光标出现在屏幕中心。
- 缓慢移动鼠标,光标应跟随鼠标方向移动,且不会飞出屏幕边缘。
- 左键单击柱状条,数值条会以动画方式变化,提示文字相应改变。
- 快速甩动鼠标,光标不应乱跳,松开按键后不会误触。
7.2 如何判断鼠标链路是否通
如果光标不动,按下顺序检查:
| 排查步骤 | 操作 | 预期结果 |
|---|---|---|
| 检查 USB 枚举 | 插上鼠标,观察串口日志或调试变量 | 有设备接入日志 |
| 检查 HID Report | 打印mouse_dx、mouse_dy、mouse_buttons | 移动鼠标时数值变化 |
| 检查 LVGL 注册 | 在mouse_read中打印坐标 | point.x、point.y在变化 |
| 检查 UI 控件事件 | 点击柱状条,打印事件回调 | 回调触发 |
如果第 2 步正常而第 3 步不正常,问题大概率在坐标累计或边界裁剪;如果第 4 步不正常,问题大概率在事件注册或对象尺寸判断。
8. 常见问题与排查思路
8.1 鼠标插入后没有反应
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 鼠标完全无响应 | USB Host 未正确初始化 | 查看 USB 中断标志和枚举状态 | 检查 USB Host 初始化顺序,确保时钟开启 |
| 鼠标枚举失败 | 鼠标供电不足 | 查看开发板 USB 口带载能力 | 外接有源 USB Hub |
| 有响应但无法移动 | HID Report 解析错误 | 打印 report 原始字节 | 检查 report 长度和偏移位 |
8.2 光标能移动但方向反了
USB HID 的 Y 轴正方向定义为向下,而某些屏幕驱动在 LVGL 中使用的是左上角原点、Y 轴向下。通常不会出现反向问题,但如果你的显示屏驱动做了镜像或旋转,就会出现 Y 轴反向。解决办法是在坐标累加时做取反:
mouse_y -= dy;或者使用lv_disp_set_rotation调整屏幕方向后,反向问题随之消失。
8.3 点击没反应
这个问题的原因比较多,按可能性排序:
- 释放位置和按下位置不在同一对象上。LVGL 的
LV_EVENT_CLICKED要求按下和释放都发生在同一个对象内。如果鼠标移动太快,按下时在按钮 A 上,释放时已经移动到按钮 B 上,就不会触发 A 的点击。 - 对象尺寸太小。LVGL 默认有
lv_obj_set_ext_click_area可以扩大点击区域,也可以临时打印坐标确认。 - 事件类型写错。要确认注册的是
LV_EVENT_CLICKED而不是LV_EVENT_PRESSED,两者触发时机完全不同。
8.4 光标显示不出来
LVGL 在指针设备驱动中默认不显示光标图标,需要显式创建光标对象:
LV_IMG_DECLARE(mouse_cursor_icon); lv_obj_t * cursor_obj = lv_img_create(lv_scr_act()); lv_img_set_src(cursor_obj, &mouse_cursor_icon); lv_indev_set_cursor(mouse_indev, cursor_obj);mouse_cursor_icon需要是一个已声明的图像资源。可以在 LVGL 模拟器中将鼠标光标导出成 C 数组,加入工程编译。
8.5 内存不足或显示卡顿
LVGL 在 MCU 上最常见的问题就是内存不足。ch32V303 的资源虽然比大多数 Cortex-M0 芯片强,但还是要控制内存占用。可以采取几个手段:
- 减少
LV_MEM_SIZE以外的静态缓冲区分配,改用 LVGL 自带内存管理器。 - 将显示缓冲区从全屏改为部分缓冲,通常两行缓冲就能满足大多数 UI。
- 简化字体,使用内置字体而不是全字库。
- 关闭不必要的动画效果,或者设置更短的动画时长。
关于内存优化,可以参考 LVGL 官方文档中关于lv_conf.h的LV_MEM_SIZE、LV_DPI_DEF的配置建议,更多是根据自己的屏幕尺寸和控件数量来调。
9. 最佳实践与工程建议
9.1 从 PC 模拟器开始
强烈建议先在 PC 上用 LVGL 模拟器(如 VSCode + SDL 模拟器插件,或 LVGL 官方模拟器工程)搭建 UI,验证鼠标交互逻辑。模拟器天然支持鼠标,能让你快速验证坐标换算、边界裁剪、点击热区等逻辑,再交叉编译到 ch32V303 上,可以节省大量调试时间。
9.2 不要让主循环“两个都等”
注意lv_task_handler和usb_host_hid_process的调用关系。如果usb_host_hid_process内部有阻塞等待操作,LVGL 的刷新就会出现明显卡顿。建议把 USB 数据处理放在定时器中断或让 USB 过程不阻塞。
9.3 USB 鼠标的不稳定因素
市面上很多廉价鼠标的 HID Report 格式并不标准,有些是 4 字节,有些是 8 字节,甚至有些带多媒体按键的鼠标会在同一个 interface 上报多组数据。所以在解析时不要假定 report 长度固定为 4,要读取实际的 report length,并做至少 3 字节的越界保护。
9.4 光标与 UI 的关系
如果产品最终不使用鼠标,其实可以把光标隐藏,只保留坐标和点击事件,这样等于把鼠标当轨迹球用,界面观感上更接近触摸屏。如果产品面向 PC 用户,光标就必须保留,否则用户会感到不习惯。
10. 总结与后续学习方向
这篇文章从 ch32V303 的 USB Host 特性、LVGL 输入设备抽象层、HID 鼠标报文解析,一步步讲到了鼠标控制 LVGL 的完整实现。核心结论是:LVGL 并不关心鼠标协议是什么,它只关心read_cb里给出的坐标和按键状态;而 MCU 侧要解决的问题,是把 USB 的相对位移转成屏幕上的绝对坐标,同时做好边界裁剪和事件触发。
如果条件允许,可以先在 PC 模拟器完成鼠标 UI 的交互验证,再到开发板上跑通完整链路。接下来值得继续深入的方向包括:LVGL v9 的事件系统差异、多输入设备混合(触摸屏 + 鼠标同时工作)、USB 鼠标的休眠唤醒机制,以及将这套输入框架应用到更复杂的工业 HMI 界面中。只要掌握了“底层驱动取数据、设备层改坐标、LVGL 层注册回调”这个三层模型,以后无论是换主控、换屏幕,还是换成其他 HID 输入设备,都不会再陷入无从下手的困境。