ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

LVGL图表控件lv_chart实现动态心电图滚动波形全攻略

2026/9/28 23:26:58 拓冰建站 浏览量
LVGL图表控件lv_chart实现动态心电图滚动波形全攻略 平时做嵌入式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为 1LV_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 内存不足与 HardFaultHardFault 基本都和内存越界、空指针有关。图表控件每个系列会分配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 项目里用过最有效的排错方式。