平时做嵌入式GUI,提到 LVGL 的图表控件 lv_chart,很多人第一反应都是“用来显示波形曲线呗”,但真正想做一个像模像样的动态心电图效果时,却发现一堆细节:数据往哪塞、怎么滚动、为什么波形不动、为什么屏幕闪烁。我上个月正好用 lv_chart 复刻了一个动态心电图 demo,从模拟器到 STM32 硬件跑通,核心代码不超过三十行,总耗时大约一个晚上。这篇文章就把完整思路、可运行的代码、以及踩过的坑全部摊开讲。
这个 demo 的价值不只是“做出一个心跳线”,它能直接迁移成 ADC 波形监控、传感器数据曲线、CPU 占用率趋势条等场景。适合正在学 LVGL 的嵌入式初学者,也适合想把图表控件搬到正式项目里的开发者。你不需要懂太多数学知识,只要会一点 C 语言,跟着步骤走就能跑出效果。
1. 先搞清楚 lv_chart 到底能做什么
1.1 为什么图表控件容易被忽略
LVGL 里很多人先接触的控件是 lv_label、lv_btn、lv_list,因为界面交互离不开它们。图表控件 lv_chart 看起来像个“高阶玩具”,总觉得等到项目需要画曲线时再研究也不迟。实际上等你真正需要它的时候,最容易卡住的反而不是控件本身的 API,而是对数据模型理解不到位。
lv_chart 本质上是一个按坐标系绘制的控件,它通过“数据系列(series)”来管理一组组数值点,把这些点按顺序连成线、柱状图、或者散点图。它不只用于医疗设备,凡是需要把数组、传感器读数、波形数据可视化出来的界面,都能用得上。
1.2 动态心电图的核心难点不在画图,在数据流
动态心电图的“动态”二字是灵魂。静态显示一屏波形很简单,创建一个 chart,塞一组点,刷新完事。但要做到像监护仪那样持续滚动、波形从右往左平滑移动,就必须理解 lv_chart 的数据更新机制。
我的做法是把 lv_chart 当成一块“示波器屏幕”,用一个定时器周期性向图表推送新的数据点,同时让旧数据点自动左移。听起来像废话,但实际实现时你会发现:每推一个新点,图表内部会做一次数组搬移、重绘、坐标映射。如果频率控制不好、数据点数量选择不当,刷屏卡顿、闪烁、甚至死机都会出现。这也是我写这篇文章的主要原因,网上讲 lv_chart 基础 API 的教程不少,但真正把“动态滚动波形”完整讲透的不多。
1.3 效果指标参考
我最终实现的效果参数如下,你可以照着做参考:
- 图表尺寸:480 x 200 像素,适合常见 4.3 寸到 7 寸屏。
- 数据点数量:240 个,代表一屏显示两个完整心电周期。
- 刷新周期:20ms 推一个点,也就是每秒 50 帧的数据更新率。
- 波形形态:模拟 P 波、QRS 波群、T 波的伪心电图。
- 运行平台:PC 模拟器先行验证,随后移植到 STM32F407 + 4.3 寸 RGB 屏。
这个参数组合在模拟器和 MCU 上都跑得很流畅。如果屏幕更小或主频更低,可以适当把点数降到 120,后面我会专门讲怎么调。
2. 环境不等于硬件:先用模拟器跑起来
2.1 用什么模拟器最省事
很多人在 STM32 上直接开搞 LVGL 图表,结果每次调样式都要重新编译烧录,半天时间浪费在来回下载程序上。我强烈建议先把环境切到 PC 模拟器,LVGL 官方生态在这块已经非常成熟。
我用的方案是 VSCode + SDL2 的官方模板,仓库名是 lv_port_pc_vscode。拉下来之后目录里自带 lvgl、lv_drivers、SDL 驱动,基本不用自己写平台适配代码。操作步骤:
git clone --recursive https://github.com/lvgl/lv_port_pc_vscode.git cd lv_port_pc_vscode code .然后用 VSCode 打开,按 README 装好 CMake 插件和 SDL2 依赖,在 lv_conf.h 里确保LV_USE_CHART为 1,直接 F5 运行就能弹出一个 LVGL 模拟窗口。
如果你更习惯 Visual Studio,官方也有 lv_sim_visual_studio 模板,里面是现成的 .sln 工程,双击打开编译就能跑。两种方式我都试过,VSCode 跨平台更灵活,VS 上手更傻瓜。
2.2 LVGL 版本怎么选:8.x 还是 9.x
版本选择是个绕不开的问题。LVGL 9.x 的 API 相比 8.x 做了不少调整,比如lv_scr_act()改成了lv_screen_active(),lv_chart_add_series()增加了 axis 参数,颜色体系也更统一。如果你是从零开始,直接用 9.x 没毛病,官方文档和新出的例程都优先适配 9.x。
但如果你手头的老项目还在用 8.3 或者 8.4,也不用担心,lv_chart 的核心用法在 8.x 和 9.x 之间变化不算大。我下面的完整代码以 LVGL 9.x 为主,同时会标注出 8.x 需要修改的地方,兼容处理之后两边都能跑。
2.3 创建最小工程的三个关键配置
模拟器模板拉下来之后,有三次地方要特别注意:
第一,lv_conf.h里要把LV_USE_CHART打开,默认模板一般是 1,但有些精简配置会关掉。找不到LV_USE_CHART就翻到 lv_conf_template.h,复制成 lv_conf.h 再改。
第二,确认LV_COLOR_DEPTH和你最终目标屏一致。模拟器上 16 位 32 位无所谓,但硬件上用 16 位能省一半显存。
第三,LV_MEM_SIZE建议配置在 8KB 以上。图表控件创建系列时会动态分配一块缓冲区,点数量越多、系列越多,内存占用越大。模拟器默认值可能很大,但如果你最后要移植到 MCU,这个宏必须重点检查。
模拟器跑通后再去碰硬件,这样至少能保证 LVGL 逻辑层面的代码没问题,省掉一大半调试时间。
3. 理解 lv_chart 的数据模型,滚动才不会踩坑
3.1 点、系列、坐标范围、更新模式的关系
先说几个基本概念:
- 图表对象:就是屏幕上那块画着网格和曲线的区域。
- 数据系列:图表里的一条线。一个图表可以挂多个系列,比如 ECG 原始信号一条线,滤波后的信号另一条线。
- 点数量:每个系列内部有一个数组,数组长度就是
point_count。这个数组在lv_chart_add_series()时一次性分配好。 - 坐标范围:Y 轴显示的最小值和最大值,超出范围的点会被裁剪。
- 更新模式:决定新数据推入后旧数据怎么处理。
我画一个简单的内存视角来描述:假设point_count = 240,系列数组points[0]到points[239]存放着 240 个 Y 轴数值。每次调用lv_chart_set_next_value(),LVGL 会把数组整体左移一位,丢弃points[0],然后在points[239]放入新值。渲染时这些点会从左到右依次画出来,视觉效果就是旧波形往左移动,最右边不断长出新的波形。
3.2 四个高频 API 就够了
我在动态心电图 demo 里,实际上反复用到的核心 API 只有这几个:
| 函数 | 作用 | 注意事项 |
|---|---|---|
lv_chart_create(parent) | 创建图表对象 | 返回lv_obj_t * |
lv_chart_add_series(chart, color, axis) | 添加数据系列 | 9.x 需要第三个轴参数,8.x 不需要 |
lv_chart_set_next_value(chart, ser, value) | 推入新数据点并滚动 | 动态效果的关键函数 |
lv_chart_set_range(chart, axis, min, max) | 设置 Y 轴范围 | 值范围设置不好会吃波形 |
再加两个辅助的:lv_chart_set_point_count()设置一屏显示的点数,lv_chart_set_update_mode()设置更新模式。这六个函数组合起来,已经能覆盖绝大多数实时波形场景。
3.3 滚动模式与循环模式
lv_chart 有两种更新模式,理解它们的区别能少踩很多坑。
LV_CHART_UPDATE_MODE_SHIFT就是上面说的整体左移模式,适合动态心电图、实时数据流。它的特点是永远展示最近一段时间的窗口,新数据从最右边进入。
LV_CHART_UPDATE_MODE_CIRCULAR是循环覆盖模式,数据填满数组后,新数据从索引 0 继续覆盖。这种模式适合静态数据集或者不需要滚动的场景,比如只想显示一段时间内的历史记录,从头到尾画一次。
我一开始在滚动模式下折腾了很久,结果发现point_count设得太大、刷新频率太低时,波形滚动看起来像抽搐。后来把点数降到 240,刷新周期控制在 20ms,视觉效果才稳定。这就是为什么参数调优比 API 本身重要。
4. 实战:5 分钟写出动态心电图
4.1 伪心电信号生成:一个问题拆成两半
心电图的典型特征是一个 P 波、一个 QRS 波群、一个 T 波,然后一段相对平缓的基线。真实心电信号要从硬件采集,但做 UI demo 完全可以用程序模拟。
我没有手工填一个 120 个数的波形数组,而是写了一个生成函数,把 120 个点分成几段:前 20 个点是基线 0,然后生成 P 波,接着是 QRS 波群,再是 T 波,最后回到基线。这样想调整波形形状,只需要改每段的斜率和长度。
代码如下:
static void ecg_build_wave(int16_t *buf, uint16_t len) { uint16_t i; for (i = 0; i < len; i++) { if (i < 20) { buf[i] = 0; } else if (i < 30) { buf[i] = (int16_t)((i - 20) * 2); /* P 波上升沿 */ } else if (i < 34) { buf[i] = (int16_t)(20 - (i - 30) * 5); /* P 波下降沿 */ } else if (i < 40) { buf[i] = 0; } else if (i < 44) { buf[i] = (int16_t)((i - 40) * 12); /* QRS 波群上升沿 */ } else if (i < 48) { buf[i] = (int16_t)(48 - (i - 44) * 15); /* R 波回落 */ } else if (i < 52) { buf[i] = (int16_t)((i - 48) * 8); /* S 波 */ } else if (i < 56) { buf[i] = (int16_t)(32 - (i - 52) * 8); /* 回到基线 */ } else if (i < 70) { buf[i] = (int16_t)((i - 56) * 2); /* T 波上升沿 */ } else if (i < 80) { buf[i] = (int16_t)(28 - (i - 70) * 2); /* T 波回落 */ } else { buf[i] = 0; } } }这个波形的幅度范围大致在 -8 到 60 之间,所以 Y 轴范围我设成 -20 到 80 就能完整显示,还留了一点余量。真实心电信号幅度会更小,但 UI 展示追求的是形态合理,不用纠结绝对数值。
4.2 定时器刷新与数据缓冲设计
数据推入的节奏很关键。我用的是lv_timer_create()建一个定时器,周期设 20ms,回调里每执行一次就喂一个新点给图表。这样换算下来每秒 50 帧数据更新,心电图滚动看起来非常顺滑。
为什么不用lv_anim?因为动画更多是给位置、透明度这类属性用的,数据流这种持续递增的场景用定时器更直观。为什么不在中断里调用lv_chart_set_next_value()?因为 LVGL 内部不是完全线程安全的,在中断里操作 UI 容易内存错乱,正确做法是中断里只写环形缓冲,LVGL 定时器回调再从缓冲区取值喂给图表。这个原则在 FreeRTOS + LVGL 工程里尤其重要。
如果你后续要接 ADC 采样,我建议在 ADC 的中断回调里往一个环形缓冲区丢数据,lv_timer 每次取 1 个或几个值推给图表。千万别在中断里直接调用 lv_chart 系列函数。这是嵌入式 GUI 的经典红线。
4.3 样式美化:让波形更像医疗仪器
心电图效果要像那么回事,除了波形本身,配色和网格也很关键。医疗监护仪一般是黑底绿线,网格线淡灰色,波形明显亮绿。我用 LVGL 的样式设置实现:
- 图表背景:深色,我在主屏幕上设了
0x111111。 - 网格线:颜色用
0x333333,宽度 1 像素,横向 4 条、纵向 8 条分割线,接近心电图纸的感觉。 - 数据系列:亮绿色
0x00FF66,线宽 2 像素。 - 如果想让点状效果明显,可以把系列的点模式改成实心圆点,但心电图一般用连续线更自然,所以我不开点。
这里有个容易踩的样式坑:给系列对象设置样式时,样式段(part)要选对。网格线、背景这类属于图表的LV_PART_MAIN,而数据线本身属于系列的LV_PART_INDICATOR。用错 part 会导致样式不生效。
实际代码片段如下:
/* 图表网格线 */ lv_obj_set_style_line_color(chart, lv_color_hex(0x333333), LV_PART_MAIN); lv_obj_set_style_line_width(chart, 1, LV_PART_MAIN); /* 数据系列 */ lv_obj_set_style_line_width(ecg_series, 2, LV_PART_INDICATOR); lv_obj_set_style_line_color(ecg_series, lv_color_hex(0x00FF66), LV_PART_INDICATOR);4.4 完整代码示例(LVGL 9.x)
下面是一份基于 LVGL 9.x 的完整单文件 demo。把它复制到你的工程里,在初始化时调用一次ecg_demo_create(lv_screen_active())就能看到效果。
#include "lvgl.h" #define ECG_POINT_COUNT 240 #define ECG_PERIOD 120 static lv_chart_series_t *ecg_series; static uint16_t ecg_index = 0; static bool ecg_paused = false; static void ecg_build_wave(int16_t *buf, uint16_t len); static void ecg_timer_cb(lv_timer_t *timer); static void ecg_chart_event_cb(lv_event_t *e); void ecg_demo_create(lv_obj_t *parent) { lv_obj_t *chart = lv_chart_create(parent); lv_obj_set_size(chart, 480, 200); lv_obj_align(chart, LV_ALIGN_CENTER, 0, 0); lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, ECG_POINT_COUNT); lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, -20, 80); lv_chart_set_update_mode(chart, LV_CHART_UPDATE_MODE_SHIFT); lv_chart_set_div_line_count(chart, 4, 8); lv_obj_set_style_line_color(chart, lv_color_hex(0x333333), LV_PART_MAIN); lv_obj_set_style_line_width(chart, 1, LV_PART_MAIN); ecg_series = lv_chart_add_series(chart, lv_color_hex(0x00FF66), LV_CHART_AXIS_PRIMARY_Y); lv_obj_set_style_line_width(ecg_series, 2, LV_PART_INDICATOR); lv_obj_set_style_line_color(ecg_series, lv_color_hex(0x00FF66), LV_PART_INDICATOR); /* 预填一屏数据,避免刚启动时是一条直线 */ static int16_t wave[ECG_PERIOD]; ecg_build_wave(wave, ECG_PERIOD); for (uint16_t i = 0; i < ECG_POINT_COUNT; i++) { lv_chart_set_next_value(chart, ecg_series, wave[ecg_index]); ecg_index = (ecg_index + 1) % ECG_PERIOD; } /* 点击图表可暂停/恢复滚动 */ lv_obj_add_flag(chart, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(chart, ecg_chart_event_cb, LV_EVENT_CLICKED, NULL); /* 每 20ms 推入一个新点 */ lv_timer_t *timer = lv_timer_create(ecg_timer_cb, 20, chart); lv_timer_ready(timer); } static void ecg_build_wave(int16_t *buf, uint16_t len) { uint16_t i; for (i = 0; i < len; i++) { if (i < 20) { buf[i] = 0; } else if (i < 30) { buf[i] = (int16_t)((i - 20) * 2); } else if (i < 34) { buf[i] = (int16_t)(20 - (i - 30) * 5); } else if (i < 40) { buf[i] = 0; } else if (i < 44) { buf[i] = (int16_t)((i - 40) * 12); } else if (i < 48) { buf[i] = (int16_t)(48 - (i - 44) * 15); } else if (i < 52) { buf[i] = (int16_t)((i - 48) * 8); } else if (i < 56) { buf[i] = (int16_t)(32 - (i - 52) * 8); } else if (i < 70) { buf[i] = (int16_t)((i - 56) * 2); } else if (i < 80) { buf[i] = (int16_t)(28 - (i - 70) * 2); } else { buf[i] = 0; } } } static void ecg_timer_cb(lv_timer_t *timer) { lv_obj_t *chart = lv_timer_get_user_data(timer); if (ecg_paused) { return; } static int16_t wave[ECG_PERIOD]; static int16_t last_val = 0; (void)last_val; ecg_build_wave(wave, ECG_PERIOD); lv_coord_t v = wave[ecg_index]; ecg_index = (ecg_index + 1) % ECG_PERIOD; /* 加一点随机噪声,让波形更自然 */ int16_t noise = lv_rand(-3, 3); v += noise; lv_chart_set_next_value(chart, ecg_series, v); } static void ecg_chart_event_cb(lv_event_t *e) { lv_event_code_t code = lv_event_get_code(e); if (code == LV_EVENT_CLICKED) { ecg_paused = !ecg_paused; } }如果你是 LVGL 8.x 用户,改动点只有三处:lv_screen_active()改成lv_scr_act();lv_chart_add_series()去掉第三个轴参数;lv_rand(-3, 3)改成(rand() % 7) - 3,记得初始化随机种子。
4.5 移植到 STM32 的适配清单
从模拟器到 STM32,大多数人会遇到“在 PC 上正常,上板子后图标不显示”的问题,通常和配置有关。我的移植清单如下:
- 确认
LV_USE_CHART为 1,LV_MEM_SIZE不小于 8KB。如果你的工程字体、图片资源较大,建议用lv_mem_monitor打印剩余堆内存看看余量。 - 确认
lv_timer_handler()在主循环里被周期性调用。FreeRTOS 工程里可以单独建一个低优先级任务不断调用它,不要在多个任务里同时调用。 - 确认屏幕分辨率和你创建 chart 时设置的尺寸匹配。如果屏幕高度只有 200,你强行 create 一个 480x200 的 chart 会超出屏幕范围,看起来就像没显示。
- 如果用了 DMA 双缓冲,遇到图标重绘时闪屏,可以适当调大刷新周期,把每 20ms 推一个点改成每 30ms 或 40ms 推一个点。
- 在主频较低的 MCU 上,不要同时开太多动效,
LV_USE_ANIMATION会占用一定的运算量,实时波形场景偶尔可以关掉不必要动画。
5. 常见问题与排查技巧实录
5.1 波形空白或没有曲线
这个问题我遇到过一次,排查了半小时发现是lv_chart_add_series()里传的颜色和背景色一样。如果你背景是黑色、系列颜色也写成lv_color_hex(0x000000),曲线自然隐形。建议一上来就用高对比度颜色,比如亮绿、亮蓝。
另一个常见原因是point_count没有设置,默认可能是 0 或一个很小的值,导致图表根本没数据可画。调用lv_chart_set_point_count()设置成 240 之后再清点。
还有可能是 Y 轴范围设置不当。你把 range 设成 0 到 10,但波形幅度在 50 左右,那整条线都跑到绘图区域外面去了。可以先临时把 range 设宽松点,比如 -100 到 200,确认曲线可见之后再收窄到合适区间。
5.2 波形不滚动,只在原地跳
这是刚接触 lv_chart 的人最容易踩的坑。没有调用lv_chart_set_update_mode(chart, LV_CHART_UPDATE_MODE_SHIFT)时,图表默认可能不是滚动模式,新数据和旧数据会以一种你不太预期的姿势绘制,看起来像原地跳变。
另外检查你的定时器是否真的在跑。我调试的时候试过把lv_timer_create()的周期写成20,但主循环里lv_timer_handler()没被频繁调用,导致回调执行频率极低。模拟器里 FPS 可能看着正常,硬件上却像卡死一样。可以先在回调里加一个全局计数,在调试串口打印,确认回调周期正常。
还有一个小细节:lv_chart_set_next_value()只对传入的系列追加数据。如果你在定时器里每次都调lv_chart_add_series()重新创建系列,那老系列的数据永远不会滚动。正确做法是全局保存创建好的ecg_series指针,定时器里只用它。
5.3 画面闪烁、卡顿
闪烁通常和重绘区域有关。LVGL 局部刷新机制会只重绘改动区域,但图表滚动时整条曲线都在变,所以重绘范围很大。如果你用的是 RGB 接口屏幕且没有双缓冲,闪烁会很明显。
我的经验是从三方面优化:第一,点数量不要盲目拉大。有些人觉得点越多越流畅,实际上点越多重绘越吃力,240 点已经能覆盖绝大多数屏幕宽度。第二,刷新周期不要小于 20ms。更新太快不仅肉眼感知不明显,还会白白消耗 CPU。第三,确认工程开了LV_DISP_PAR_REFR 30这类刷新参数,并且把lv_timer_handler()的调用间隔控制在合理范围内。如果你在 FreeRTOS 里跑,注意任务优先级,低优先级任务里的 LVGL 刷新可能被其他任务饿死,表现为界面卡顿。
5.4 内存不足与 HardFault
HardFault 基本都和内存越界、空指针有关。图表控件每个系列会分配point_count * sizeof(lv_coord_t)的缓冲区,在常见配置下lv_coord_t是 int16_t,也就是每个点占 2 字节。240 个点就是 480 字节,一个系列不算夸张。但如果你创建了多个系列、每个系列 1000 个点,内存就会紧张。
遇到 HardFault 时,第一步看栈回溯,第二步看 LVGL 内存池。在lv_conf.h里把LV_MEM_MONITOR打开,运行时调用lv_mem_monitor(&info)打印可用内存,如果波动剧烈说明堆不够,调大LV_MEM_SIZE或者减小 point_count。
5.5 性能优化三板斧
仪表盘里如果同时有多个图表,刷新开销会成倍增长。我有三个建议:
第一,按需刷新。只有数据变化时才推入新点,不要用定时器空转推同样数据,白白触发重绘。
第二,降低非活动图表的刷新率。比如主波形 20ms 刷新一次,旁边的小波形 100ms 刷新一次就够了,肉眼看不出区别,CPU 占用明显下降。
第三,如果 MCU 性能不够,把point_count降低到屏幕像素宽度附近。屏幕宽度 320 像素,你放 480 个点很多就会重叠,视觉上反而更糊。让一个点尽量覆盖一个像素列,信息量最大。
6. 后续扩展方向与个人心得
6.1 把心电图改成通用数据监控曲线
这个 demo 做完后,我做的第一件事是把ecg_build_wave()换成真实传感器数据源。比如读取 MPU6050 的加速度值,或者读取 ADC 采样的温度电压,用同样的lv_chart_set_next_value()推送,立刻就能变成数据监控界面。LVGL 图表控件的通用性就在这里,波形是什么并不重要,数据流驱动显示的核心机制是通用的。
如果你想让数据源和 UI 解耦,可以抽出一个简单的环形缓冲区,采样线程往里面写值,LVGL 定时器读值。这个结构后面哪怕换传感器、换界面,都不用改图表刷新逻辑。
6.2 进阶方向:多系列、光标、暂停恢复
既然你已经能跑通一个系列,再加一个系列就是复制粘贴的事。比如在同一张图里叠加“原始信号”和“滤波后信号”,一个亮绿一个淡黄,对比效果非常好。每个系列在lv_chart_add_series()时会返回独立的 series 指针,之后分别调用lv_chart_set_next_value()推送即可。
还可以给图表加交互:点击图表暂停滚动、再次点击恢复。完整代码里已经带了事件回调,你只需要把它接成按钮事件或者触摸事件即可。这个功能在调试硬件时特别有用,波形停了可以仔细看细节。LVGL 图表本身不支持坐标轴缩放,如果你需要交互式缩放,可以自己用触摸事件和 range 联动实现。
6.3 最后一点体会
这个项目很小,但算是我把 LVGL 图表控件从“会用 API”提升到“能解决问题”的一次实战。最大的收获是理解了 lv_chart 的数据流设计:它不是一个一次性画完的静态控件,而是一个需要持续喂数据的实时渲染组件。所有动态显示类需求,本质上都在处理同一件事——如何用稳定的节奏把数据送进渲染管道。
如果你在移植过程中卡住了,建议优先回退到模拟器上验证,把环境变量、配置差异先排除掉,再回到硬件上排查。这是我在多个 LVGL 项目里用过最有效的排错方式。