FreeRTOS + LVGL 的智能手表项目,最近在嵌入式圈子里讨论很多。这个组合的本质并不复杂:FreeRTOS 负责任务调度、消息通信和资源管理,LVGL 负责把界面画出来,并且处理触摸、按键和动画。真正容易踩坑的,是这两者协作时的临界区、栈空间、刷屏时序和内存分配。这篇主要面向想用 STM32、ESP32 这类单片机构造一个手表雏形的开发者。最值得关注的点,不是把 Demo 跑起来,而是学会如何把界面刷新变成一个可调度的任务,再判断自己的内存和帧率到底够不够撑起主表盘、菜单、通知和动画。
1. 先把项目拆清楚:FreeRTOS 和 LVGL 各管哪一半
很多新手拿到这个项目,第一反应是把 LVGL 跑起来,然后才考虑 FreeRTOS。这个顺序其实反了。智能手表不是单一功能设备,它要在屏幕刷新的同时处理时间更新、触摸事件、电池电量、传感器数据和按键响应。如果所有逻辑都堆在 main 循环里,代码很快就会变成一团乱麻。
1.1 为什么智能手表特别适合 FreeRTOS + LVGL
先看 FreeRTOS 解决了什么。它给你提供了任务、队列、信号量、软件定时器这些基础能力。你可以把“刷新界面”“读加速度计”“更新电池电量”“响应按键”分别拆成独立任务,各自有明确的执行周期和优先级。
再看 LVGL 解决了什么。它是嵌入式图形库,不依赖具体硬件。你要做的只是实现两个核心回调:一个是把像素数据写到屏幕的 flush 回调,另一个是读取触摸或按键输入的 read 回调。剩下的按钮、列表、弧形仪表盘、动画、事件分发,LVGL 都处理好了。
手表这个场景之所以特别合适,是因为它同时具备三个特征:
- 界面元素多,表盘、菜单、通知、设置页面需要统一管理。
- 外部事件杂,按键、触摸、传感器、通信都会触发状态变化。
- 资源有限,随手表屏幕、MCU 内存都不是桌面级别。
所以 FreeRTOS 负责“什么时候做什么”,LVGL 负责“界面长什么样、交互怎么响应”。两者不冲突,但需要你明确它们之间的调用关系。
1.2 两者之间的数据流
理解数据流比理解代码更重要。LVGL 不是独立的操作系统,它只是一个库,必须被某个 FreeRTOS 任务调用。最典型的调用方式是一个 GUI 任务循环执行lv_timer_handler(),这个函数会处理所有待办的界面刷新、动画推进和事件分发。
数据流可以简化成这样:
- 触摸或按键中断产生事件,通过队列发给 GUI 任务。
- GUI 任务调用
lv_timer_handler(),LVGL 内部从输入设备回调读取事件,更新对象状态。 - 对象状态变化后,LVGL 调用显示驱动的 flush 回调,把像素数据写到屏幕。
- 传感器任务通过消息队列把数据发给 GUI 任务,GUI 任务再调用
lv_label_set_text()更新界面。
这里最容易犯的错误,是在中断服务函数里直接操作 LVGL 对象。LVGL 本身不是中断安全的。正确做法是:中断里只发送消息,真正的 UI 更新放到 GUI 任务里做。
2. 选型和环境:先从能跑 Demo 的配置开始
做智能手表项目,第一步不是写代码,而是确认你的硬件能不能跑起来。一个 240x240 的 RGB565 屏幕,一帧数据大概是 112KB。如果你的 MCU 只有 20KB RAM,那全屏缓冲区方案基本不用想,只能靠局部缓冲加逐行刷新。
2.1 常见的 MCU + 屏幕组合
从社区项目来看,最常见的组合是 STM32 系列搭配 1.28 寸或 1.3 寸 IPS 屏幕,分辨率一般是 240x240,接口以 SPI 为主,也有用 RGB 接口的。屏幕尺寸小、分辨率不高、SPI 驱动简单,非常适合学习。
| 主控 | RAM 典型范围 | 适合场景 | 注意事项 |
|---|---|---|---|
| STM32F103 | 20KB - 64KB | 入门跑 LVGL 小界面 | 内存紧张,屏幕缓冲区要开小 |
| STM32F407 | 128KB - 192KB | 常用选择,能跑比较完整的手表 UI | 注意内部 SRAM 分区 |
| STM32H750 | 大容量 RAM | 复杂表盘、较多动画 | 涉及 cache 一致性问题 |
| ESP32 / ESP32-S3 | 大容量 PSRAM | 界面复杂、需要联网 | 用 PSRAM 做 LVGL 内存池很方便 |
这里不是说 F103 就不能做手表。而是说,如果你追求流畅的多页面滑动和动画,F103 会比较吃力,建议把界面复杂度降下来,或者直接用 F407、ESP32 这类资源更充裕的芯片。
如果只是学习 LVGL 的机制,先用屏幕刷起来就行。如果目标是完整产品,建议直接按正式产品的内存余量来选型,否则后面动画、字体、图片资源一加,内存立刻就不够用了。
2.2 开发环境与工程准备
常见开发方式有三种:
- Keil MDK + 官方库,适合 STM32 用户,项目文件直观。
- VS Code + CMake + GCC,适合想统一管理代码的人。
- 先做 PC 模拟器,再做真机移植,适合想快速验证 UI 的人。
PC 模拟器特别值得单独说。LVGL 官方提供了 PC 模拟器工程,你可以在 Windows/Linux 上编译运行,用鼠标模拟触摸,直接把 UI 调好,再搬到单片机。这样能极大缩短调试周期,尤其是调表盘布局、菜单层级、动画参数这些跟硬件无关的部分。
做真机移植时,建议把 LVGL 源码目录和你的应用代码目录分开。lv_conf.h文件要放在编译器能找到的 include 路径里,常见做法是放在lvgl源码目录外面,方便后面升级 LVGL 版本。
2.3 LVGL 移植的三个最基础配置
lv_conf.h是所有行为的核心。很多莫名其妙的问题,比如界面不动、刷新极慢、一直卡死,最后都发现是这里没配好。
第一个要确认的是LV_COLOR_DEPTH。如果你的屏是 RGB565,就设 16;如果是 RGB888,就设 32。设错了会出现颜色奇怪、花屏或者刷新异常。
第二个是LV_MEM_SIZE。这个值是 LVGL 内部自己管理的内存池大小。手表项目一般建议从 16KB 起步,具体要看你的界面复杂程度。表盘加菜单、再加几个动画,32KB 是相对稳妥的起步值。
第三个是LV_TICK_CUSTOM。LVGL 需要知道时间流逝,才能推进动画和按钮的按下状态。一般是通过定时器或者 SysTick 调用lv_tick_inc(1),每隔 1ms 喂一次。如果你用 FreeRTOS,也可以在lv_conf.h里开启LV_TICK_CUSTOM,配一个能返回当前 tick 计数的函数。
注意:LVGL 的 tick 和 FreeRTOS 的 tick 不是一回事。FreeRTOS tick 用于任务调度,LVGL tick 用于界面动画计时,两个都要工作,界面才会正常。
3. 任务切分和调度:界面帧率不是越高越好
把 LVGL 跑起来之后,就要考虑 FreeRTOS 任务的划分了。智能手表项目里,我一般会拆成这么几个任务。
3.1 手表项目里常见的任务清单
| 任务 | 周期 | 优先级 | 说明 |
|---|---|---|---|
| GUI 任务 | 尽量保持 10-30ms 一次 | 较高 | 调用lv_timer_handler() |
| 触摸/按键任务 | 短周期轮询 | 高 | 读取输入设备,发事件 |
| 传感器任务 | 100ms | 中 | 读取加速度计、心率传感器 |
| 电池任务 | 1s | 低 | 采样电压,计算电量 |
| 通信任务(可选) | 按需 | 中 | 蓝牙、WiFi 数据收发 |
优先级设计的原则是:交互响应不能卡。触摸和按键的读取优先级可以高,但处理逻辑要轻。GUI 任务负责界面更新,优先级要比传感器、电池这类后台任务高。否则界面会明显掉帧。
有一点要特别注意:FreeRTOS 的优先级数值越大优先级越高。很多人从别的 RTOS 转过来,习惯性把高优先级任务填了小数,结果 GUI 一直被抢占,界面卡成幻灯片。
3.2 GUI 任务的写法
GUI 任务的标准结构是这样:
void gui_task(void *arg) { while (1) { lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }为什么这里要加一个 5ms 延时?因为lv_timer_handler()只处理当前需要处理的事件,执行完就返回。如果不做任何延时,这个任务会占用全部 CPU,其他任务就饿死了。
加延时之后,GUI 任务每 5ms 至少被调度一次。LVGL 默认的刷新周期一般是 30ms,所以 5ms 的延时足够让界面保持在正常刷新节奏,同时把 CPU 时间让给传感器、通信和其他任务。
如果界面刷新特别慢,优先看lv_timer_handler()的执行时间,而不是盲目调高任务优先级。它执行时间太长,通常说明某一次刷新内容太多,或者显示缓冲区设置不合理。
3.3 栈大小、堆大小怎么估
FreeRTOS 任务创建时最容易犯错的地方是栈大小。GUI 任务要调用 LVGL 的绘制函数,这些函数调用的层级比较深,局部变量又多。如果一个任务的栈给太小,跑着跑着就会溢出,表现就是随机卡死、花屏、或者系统突然复位。
Cortex-M 平台下,GUI 任务栈我一般从 4096 字节起步,也就是 1024 个 32 位字。触摸、传感器这类轻量任务,1024 字节通常够。如果你开了文件系统、蓝牙协议栈,那相关任务要单独重新评估。
判断栈够不够,不要只靠猜。FreeRTOS 提供了uxTaskGetStackHighWaterMark(),可以查一个任务历史剩余的最小栈空间。把任务跑一段时间,触发各种界面操作之后,打印这个值,如果低于 100 字节,就说明栈给小了,需要加大。
建议开发阶段直接把 FreeRTOS 的堆栈溢出检测打开,方法是把
configCHECK_FOR_STACK_OVERFLOW设为 1 或 2,再实现vApplicationStackOverflowHook(),一旦溢出立刻能看到日志。
堆大小configTOTAL_HEAP_SIZE也要留余量。系统里所有任务的栈、队列、信号量、软件定时器都从堆里分配。如果你的堆是 64KB,GUI 任务栈占 4KB,传感器任务、触摸任务、消息队列再占走一块,剩下的才留给 LVGL?不对,LVGL 的内存池用的是自己的LV_MEM_SIZE,不走 FreeRTOS 堆。这两个是分开的,但很多人会混在一起算。
4. 手表界面怎么做:从表盘到菜单和滑动
界面部分是 LVGL 的主场。手表 UI 通常分成几个页面:表盘、菜单、通知、设置。不同的页面可以建不同的 screen,用lv_scr_load_anim()做切换动画。
4.1 表盘、菜单和通知的布局思路
表盘一般包含时间、日期、电池、步数这几个核心元素。时间用lv_label,电池可以用lv_bar或者自定义图标,步数用lv_label或者小图标。
需要注意:表盘不是每毫秒都要刷新。每分钟更新一次时间文本就够了,频繁更新标签反而会触发 LVGL 重绘,浪费 CPU。
菜单页更常见的是网格布局,图标加文字。LVGL 有lv_gridview一类的滚动容器,也可以直接用lv_obj加lv_btnmatrix实现。滑动切换页面可以用 LVGL 的对象拖拽检测,也可以根据触摸结束后的位移方向手动切页。
通知页用一个列表就够了。通知多了就限制显示条数,避免长时间滚动导致内存和帧率压力。
4.2 触摸和按键接入
LVGL 的输入设备接口有两类关键回调:
read_cb:读取当前输入状态。indev类型:决定是触摸、鼠标还是按键。
触摸屏接入时,read_cb里要填充坐标和按下状态。LVGL 自己处理单击、长按、滑动等手势。你做移植时,只需要把触摸芯片读出来的坐标转换成屏幕坐标系,交给 LVGL。
按键的接入方式和触摸不太一样。按键通常使用LV_INDEV_TYPE_KEYPAD,然后为每个按键映射一个 LVGL 键码。比如:
static uint32_t keypad_map[] = { KEY_LEFT, LV_KEY_LEFT, KEY_RIGHT, LV_KEY_RIGHT, KEY_ENTER, LV_KEY_ENTER, KEY_BACK, LV_KEY_ESC, };这样 LVGL 的按钮、列表、输入框都能响应按键事件。手表上如果有实体按键,一般用短按、长按、组合按来区分功能,具体逻辑可以放在按键任务里,再通过队列通知 GUI 任务切换页面。
4.3 动画与刷新率的取舍
LVGL 的动画本质上是在多个刷新周期里逐步改变对象的颜色、位置、透明度。动画多了,CPU 占用和内存都会上涨。
手表这种小屏设备,建议动画数量控制在两三个以内,而且是局部动画。比如表盘上用秒针旋转动画,菜单切换时做一个页面平移动画,图标点击给一个缩放反馈。全屏大范围的模糊、渐变效果在 MCU 上不现实,不要硬试。
刷新周期参数是LV_DISP_DEF_REFR_PERIOD,默认 30ms,也就是约 33fps。对智能手表来说,这个帧率通常够用。强行改成 10ms 并不会让画面更流畅,反而可能让 MCU 一直在刷屏,其他任务都跑不动,界面反而更卡。
5. 内存、字体和资源:手表项目最容易翻车的三个点
这一节是经验最密集的地方。项目跑通 Demo 之后,真正的工作才开始。下面三个方向,是我在手表项目里摔过跟头的地方。
5.1 LVGL 内存分配方式
LVGL 默认用内部内存池管理动态内存,LV_MEM_CUSTOM设为 0 时,使用自己的分配器。好处是不依赖 C 库的 malloc,行为可控,碎片化情况也可以监控。
如果你把LV_MEM_CUSTOM设为 1,LVGL 就会走 FreeRTOS 堆或其他 malloc 实现。两种方式都能用,但手表项目我更建议先使用 LVGL 内部内存池,方便用lv_mem_monitor()查看内存占用。
内存池大小LV_MEM_SIZE怎么定?建议先按场景估算:
- 显示缓冲区:如果开 1/10 屏缓冲,240x240x2 / 10 约 11KB。
- 界面对象:一个标签页大概几十到几百字节。
- 图片解码和动画短暂占用的临时内存。
- 字符串、事件结构体等。
最省事的验证方式是把LV_MEM_SIZE先设大一点,比如 48KB,跑所有界面操作后用lv_mem_monitor()看最高水位,再慢慢调小。
LVGL 内存碎片也是一个现实问题。频繁创建和销毁对象、频繁设置和释放 style,时间长了会产生碎片。手表 UI 界面相对固定,建议启动时把所有页面对象建好,运行期用lv_obj_add_flag/lv_obj_clear_flag控制显示隐藏,而不是频繁创建销毁。
5.2 显示 buffer 和 flush 的关系
LVGL 需要一块显示缓冲区。常见有三种方案:
| 方案 | 内存占用 | 效果 | 适用场景 |
|---|---|---|---|
| 1/10 屏缓冲 | 约 11KB | 一般,低端 MCU 也能跑 | 入门、低内存芯片 |
| 半屏缓冲 | 约 55KB | 较好,减少刷屏次数 | 中等内存芯片 |
| 全屏缓冲 | 约 110KB | 最佳,动画最流畅 | 大内存或 PSRAM 芯片 |
1/10 屏缓冲能不能用?能用,但要注意,LVGL 会先把部分内容画进缓冲区,再调 flush 写到屏幕。缓冲越小,刷屏次数越多,耗时越长。
flush 回调的写法要特别注意异步问题。如果你用 SPI + DMA 传输数据,调用lv_disp_flush_ready()前要确保 DMA 真的传完了。很多情况下 DMA 用中断通知完成,那么在传输完成中断里调用lv_disp_flush_ready()就是正确做法。如果你不等待 DMA 完成就调用这个函数,LVGL 会把缓冲区填上新的数据,导致屏幕出现撕裂或者花屏。
如果 MCU 带 DCache,小屏幕项目可能不明显,但 H7 这类芯片上要注意 cache 一致性问题。传输完成前要执行 cache clean,否则屏幕上可能出现残留的旧数据。
5.3 字体、图标和图片压缩
字体是手表 UI 的内存大头。LVGL 默认带的 Montserrat 字体可以跑,但你要在lv_conf.h里决定只包含哪些字号。包含的字体越多、字符集越大,占用的 Flash 和 RAM 越多。
对于手表项目,我的建议是:
- 时间显示用一个大号数字字体,比如 28px 或 32px。
- 正文和标签用一个中号字体,比如 14px。
- 图标尽量用字体图标或者小图片,不要用大图。
图片资源方面,不要直接在 MCU 上解码 JPG 或 PNG。如果 LVGL 开启了 PNG 解码库,会占用额外内存。手表 UI 里的小图标,推荐的做法是先用 LVGL 的图片转换工具转成 C 数组,或者放在外部 Flash 里,运行时直接读取。
LV_IMG_CACHE_DEF_SIZE图片缓存大小也要留意。如果图片很多但缓存很小,每次加载图片都要重新读取 Flash,界面会明显变慢。合理设置缓存,通常能改善滑动菜单的流畅度。
6. 卡死、花屏、闪烁、响应慢:按这个顺序排查
很多问题看起来像功能缺陷,实际原因往往在配置和时序上。下面给出我觉得效率最高的排查顺序。
6.1 直接卡死或看门狗复位
遇到系统卡死,先看这几个地方:
- 任务栈溢出。打开
configCHECK_FOR_STACK_OVERFLOW,看vApplicationStackOverflowHook()有没有被调用。 - 中断里调用了 LVGL 或者 FreeRTOS 的非中断安全 API。
- 多任务同时操作 LVGL 对象,没有加锁保护。LVGL 9.x 如果需要多线程访问,要开启
LV_USE_OS或者自己加互斥锁。 - 某个任务吃掉全部 CPU,其他任务永远得不到调度。优先检查优先级最高的任务里有没有死循环。
先看日志,再看任务状态。FreeRTOS 的vTaskList()可以打印所有任务的状态和占用 CPU 时间,这是定位“哪个任务一直运行”的最快方式。
6.2 花屏和闪烁
花屏和闪烁通常不是 LVGL 的 bug,而是显示配置问题。建议按这个顺序确认:
LV_COLOR_DEPTH和屏幕的颜色位数是否一致。- RGB 颜色顺序是否反了。很多屏幕需要 BGR 顺序,改一下屏初始化参数就能解决。
- flush 回调是不是没有调用
lv_disp_flush_ready()。漏了这一句,LVGL 会认为 DMA 还在忙,显示内容一直不完整。 - DMA 是否覆盖了 LVGL 正在写入的缓冲区。如果缓存区被 DMA 读取的同一块内存又被 CPU 写入,就会出现花屏。
闪烁还有一个常见原因:每次刷新都把整屏数据通过 SPI 重发。可以考虑把显示缓冲区调大一档,或者开启 LVGL 的不活动区域刷新,减少实际发送的数据量。
6.3 界面响应慢
界面慢,先不要急着调动画复杂度。先确认 LVGL 的刷新时钟在工作。如果lv_tick_inc()没有被调用,LVGL 的所有动画和事件判定都会停滞,界面就跟死了一样。
然后看 GUI 任务的优先级。如果传感器任务或者通信任务优先级太高,GUI 任务一直得不到调度,界面自然卡。把 GUI 任务优先级提到传感器和通信之上,通常立刻能感觉到流畅度变化。
再看显示缓冲区大小。1/10 屏缓冲跑动画会比较吃力,有条件就改成半屏缓冲。
最后看有没有大量重复创建销毁的对象。比如每秒钟创建一个标签又删除,这会触发内存分配和释放,产生碎片,还会让 LVGL 反复重绘。
6.4 修改 LVGL 版本后 API 变化
LVGL 8.x 和 9.x 的 API 有不少差异。比如 8.x 里叫lv_task_handler(),9.x 改名为lv_timer_handler()。很多老工程的代码直接搬到新版本会报错。
如果你用的是 9.x,建议先去官方迁移指南查对应名称,特别是:
- 对象创建函数:
lv_scr_act()、lv_obj_create()的用法有变化。 - 动画接口:
lv_anim的起始和结束回调有调整。 - 颜色宏:部分宏定义从
LV_COLOR_MAKE改成了lv_color_hex()。
升级版本前,先确认自己项目里用到的接口在目标版本里是否还兼容。不要为了新功能而盲目升级,手表项目UI跑得好好的,就尽量别动版本。
7. 从 Demo 到可用的固件:还缺什么
Demo 跑通只是第一步。真正做成手表固件,你会遇到屏幕常亮、睡眠唤醒、数据刷新、低电量处理这些具体问题。
7.1 屏幕常亮、休眠和唤醒
手表不可能一直全速刷屏。屏幕常亮时,可以降低刷新频率;检测到一段时间没有交互,就关闭背光,进入低功耗模式。
LVGL 本身不负责低功耗。你可以通过 FreeRTOS 任务控制屏幕背光的 GPIO 和 PWM。在进入睡眠前调用lv_timer_pause()或者直接停止 GUI 任务,唤醒后再恢复。RTC 定时唤醒是最常用的方案,每秒或每分钟唤醒一次,更新时间显示,然后继续睡眠。
这里要注意:唤醒后如果 LVGL 界面内容没变化,不需要重新创建页面,只需要更新对应标签文本。频繁调用lv_scr_load()反而会触发不必要的重绘。
7.2 时间、传感器和 UI 联动
时间来源一般是 RTC。传感器数据通过 I2C/SPI 读取。建议把传感器任务的数据通过 FreeRTOS 消息队列发给 GUI 任务,GUI 任务在lv_timer_handler()之前检查队列,有数据就更新界面。
不要在多个任务里直接调用lv_label_set_text()。所有 LVGL 的界面操作尽量集中在 GUI 任务里执行。这样既避免并发冲突,也方便你排查问题。如果传感器任务确实很急,也应该是发消息,而不是跨任务直接改 UI。
数据刷新频率也要控制。步数、心率这类数据,几百毫秒到一秒更新一次足够。每 10ms 刷新一次心率文本,除了浪费 CPU,没有任何用户可见的价值。
7.3 电量显示和低电量策略
电量显示用 ADC 采样电池电压,然后换算成百分比。这里要注意滤波,单次采样结果波动很大,一般做一个滑动平均。
低电量时要主动降低功耗:
- 降低屏幕亮度。
- 降低 UI 刷新率。
- 关闭动画。
- 长时间不操作进入深度睡眠。
建议把电量策略写在独立任务里,根据电量阈值向 GUI 任务发事件,由 GUI 任务决定是否切换低功耗主题或关闭动画。不要到处散落逻辑,否则后面改阈值会非常痛苦。
7.4 调试手段
开发阶段最好把串口日志打好。至少打印这些信息:
- 每个 FreeRTOS 任务的栈剩余量。
- LVGL 内存池的剩余量。
- 每次界面切换的时间点。
- 关键传感器数据。
串口日志会占用 CPU,建议做成可开关的宏。量产固件关闭详细日志,但保留错误日志。
还有一个容易被忽略的点:不要一上来就把代码拆得特别复杂。先让单个表盘页面跑稳定,再加入菜单,再加入动画,最后接传感器和低功耗。每一步都保留一个小版本,出现问题能快速回退。
写在最后
这个项目的核心,不在于你把 LVGL 的某个控件用得多花哨,而在于你能不能把界面刷新、任务调度、内存和显示时序这几件事理顺。FreeRTOS 和 LVGL 各自都是成熟的开源组件,难的是让它们在有限资源下稳定协同。
我个人更建议:先把单页面跑稳,再考虑多页面和动画。先把栈和内存的余量测出来,再往里加功能。真正常跑下来的手环和手表固件,往往不是功能最多的那个,而是资源分配最克制、日志和异常处理最完整的那个。