news 2026/9/28 7:57:13

用LVGL lv_chart做动态心电图:实时波形从模拟器到STM32的完整实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用LVGL lv_chart做动态心电图:实时波形从模拟器到STM32的完整实践

LVGL的demo跑起来容易,跑出"感觉"难。很多人在官方例程里把按钮、滑块玩了一遍之后,就不知道该拿这套框架做什么了——控件都会用,但拼不出一个像样的界面。图表控件(lv_chart)是我认为最适合打破这种僵局的入口,尤其是动态心电图这个效果:视觉冲击力强、数据逻辑简单、还顺带把LVGL的定时器、刷新机制、局部重绘这些核心技术点全串了一遍。这篇文章就用一个完整的动态心电图例子,讲清楚怎么在5分钟内用lv_chart把一个"会动的绿色波形"放到屏幕上,代码可以直接复制到你的LVGL工程里跑。

这个demo适合谁?一类是刚开始接触LVGL、想找一个有展示效果的项目练手的新手;另一类是已经在做产品、需要在屏幕上显示实时波形(ADC采样、心率、温湿度曲线等)的开发者。前者可以拿它练手,后者可以把代码里的波形数组替换成自己的真实数据,当作一个可复用模板。

1. 为什么心电图是lv_chart练手的最佳题材

1.1 图表控件的真实定位

LVGL提供了一堆控件,但大部分控件是围绕"交互"设计的,按钮要按、滑块要拖、列表要滚。图表控件(lv_chart)不一样,它围绕"数据"工作:把一组数组映射成坐标系里的折线、柱状图或散点。不要小看这个区别,在实际产品里,能直观展示数据的控件往往比花哨的交互控件更关键。

我见过不少用LVGL做出来的智能穿戴、工业仪表、医疗小设备,最核心的界面就是一条波形:心率波形、呼吸波形、电机电流波形、电池放电曲线。这些界面用其他控件拼不出来,只能靠图表控件。而动态心电图正好是这个类别里最典型、最直观的一个场景,它需要持续的滚动刷新,天然考验你对LVGL数据更新和重绘机制的理解。玩通这一个案例,再去做其他图表类页面就是改改数据源、调调坐标轴的事。

1.2 先搞懂lv_chart的数据模型,再开始写代码

把心电图效果做出来,不在于记住API,而在于理解lv_chart的数据模型。它不是一个"随意画线的画板",而是一个"数组的可视化控件"。控件内部维护着一个点数组,每个点对应坐标系里的一个采样值,图表负责把这些点连成线。

自定义图表前必须理解四个概念:

  • 序列(series):一条独立曲线。同一个chart可以挂多条序列,每个序列有自己的颜色、绑定的y轴。心电监护仪上的多导联波形,就是靠多序列叠加出来的。
  • 点数量(point count):一条序列缓冲区的长度,也就是图表横向最多展示多少个采样点。点越多,波形越宽,但刷新的计算量也越大。
  • Y轴范围(range):数据映射的上下界。数据超出范围就会被裁剪,视觉上表现为波形撞到顶或撞到底。
  • 更新模式(update mode):这是动态效果的关键。SHIFT模式像心电监护仪的打印纸,新数据从右边推进,旧数据整体左移;CIRCULAR模式像一个环形缓冲,波形在固定窗口内不断覆盖旧数据。心电监护仪那种"流淌"的感觉,必须用SHIFT。

有一点容易踩坑:lv_chart底层就是数组,调用更新函数时,它会把整段数据往左挪一位,再在末尾写入新值。这意味着动态波形的刷新成本与point count成正比,而不是与屏幕宽度成正比。point count给得太大,弱芯片上会明显卡顿;给得太小,波形又看不出连续趋势。后面我会给出一个比较平衡的取值参考。

2. 最快的启动路径:PC模拟器里先跑通

2.1 为什么先不要碰板子

我见过太多新手拿到开发板后第一件事就是把LVGL往板子上烧,结果卡在屏幕驱动、SPI配置、编译报错这些地方,还没看到图表长什么样就劝退了。做UI效果的合理顺序应该是:先在PC模拟器里验证逻辑和视觉,再往板子上移植。

LVGL官方提供了PC模拟器工程,主流方案是VSCode + CMake + SDL2。在Windows和Linux上都能跑。模拟器里的入口就是main循环里持续调用lv_timer_handler(),界面显示在SDL2窗口里,鼠标可以模拟触摸。你在模拟器里写的业务代码,和板子上跑的完全一样,移植的时候不需要改任何UI逻辑,只处理底层显示和输入驱动就行。

我自己的经验是,模拟器上调试一个界面的成本是十几秒,而板子上烧录一次往往要几十秒甚至几分钟。动态心电图这种需要反复调速度、调波形、调颜色的场景,在模拟器里调效率高太多。

2.2 模拟器工程里要改的几个开关

模拟器工程跑起来的前提是lv_conf.h配置正确。默认配置一般可以直接用,但建议确认三个地方:

  • LV_USE_CHART:图表控件的总开关。自定义精简配置时容易把它关掉,导致编译报“lv_chart_create未定义”。
  • LV_MEM_SIZE:LVGL动态内存池大小。模拟器里内存宽裕,8.3版本可以直接给到128KB以上,方便后续加页面。9.x版本默认走系统堆,就不太依赖这个宏。
  • LV_COLOR_DEPTH:模拟器窗口一般用ARGB8888,不用改。板子如果只用RGB565,模拟器里先按32位调试,最后移植时再改回16位处理一遍颜色即可。

还有一个隐蔽的配置项是LVGL刷新周期。老版本叫LV_DISP_DEF_REFR_PERIOD,默认30ms左右刷新一次脏矩形,9.x之后宏名有变化。如果后面发现曲线一顿一顿,可以把这个值往20ms甚至16ms调,但要结合主循环繁忙程度综合判断,不是越小越好。

2.3 验证demo的完整流程

假设你已经有一个能运行的LVGL模拟器工程,接下来的流程很短:

  1. 新建一个c文件,把第三节的完整代码复制进去。
  2. 在main初始化LVGL之后调用lv_demo_ecg()。
  3. 编译运行,看到绿色波形持续向左滚动就成功了。
  4. 确认主循环里有lv_timer_handler(),否则定时器和刷新都不会工作。

这步跑通之后,剩下的就是调参数。我试过很多次,只要波形数据、定时器周期、chart配置三件事是匹配的,动态效果几乎零成本。真正花时间的往往是后面调成"像心电监护仪"的过程。

3. 动态心电图完整代码与逐段拆解

3.1 完整代码

下面这段代码基于LVGL 8.3编写,在9.x下同样可用,API差异我会在第五部分说明。代码里不依赖具体硬件,模拟器和STM32等单片机都能直接跑。

/* 基于LVGL 8.3 / 9.x 的动态心电图示例 * 调用 lv_demo_ecg() 即可在屏幕上看到波形效果 */ #include "lvgl.h" #define ECG_POINT_CNT 60 /* 图表上同时显示的点数 */ #define ECG_WAVE_SIZE 57 /* 一个心动周期的波形点数 */ #define ECG_TICK_MS 20 /* 定时器周期,单位毫秒 */ #define ECG_POINT_PER_TICK 2 /* 每个定时器周期推进的采样点数 */ static lv_obj_t *chart; static lv_chart_series_t *ser_line; static uint16_t wave_pos = 0; /* 一个心动周期的PQRST波形数据,模拟心电监护仪 */ static const int16_t ecg_wave[ECG_WAVE_SIZE] = { 100, 100, 102, 104, 106, 108, 110, 112, 110, 108, 105, 100, 96, 90, 84, 76, 68, 60, 70, 85, 110, 150, 210, 280, 360, 420, 460, 480, 450, 390, 320, 250, 190, 145, 118, 100, 95, 88, 82, 78, 80, 85, 93, 100, 108, 118, 126, 130, 128, 122, 112, 100, 98, 97, 99, 100, 100, 100 }; static void ecg_timer_cb(lv_timer_t *timer) { (void)timer; for (int i = 0; i < ECG_POINT_PER_TICK; i++) { lv_chart_set_next_value(chart, ser_line, ecg_wave[wave_pos]); wave_pos++; if (wave_pos >= ECG_WAVE_SIZE) wave_pos = 0; } } void lv_demo_ecg(void) { chart = lv_chart_create(lv_scr_act()); lv_obj_set_size(chart, 480, 220); lv_obj_center(chart); lv_obj_set_style_bg_color(chart, lv_color_black(), 0); lv_obj_set_style_border_width(chart, 0, 0); lv_obj_set_style_radius(chart, 8, 0); lv_obj_set_style_pad_all(chart, 8, 0); lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, ECG_POINT_CNT); lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, 0, 550); lv_chart_set_update_mode(chart, LV_CHART_UPDATE_MODE_SHIFT); lv_chart_set_div_line_count(chart, 8, 12); lv_chart_set_axis_tick(chart, LV_CHART_AXIS_PRIMARY_X, 10, 5, 6, 3, true, 40); lv_chart_set_axis_tick(chart, LV_CHART_AXIS_PRIMARY_Y, 10, 5, 5, 3, true, 40); ser_line = lv_chart_add_series(chart, lv_palette_main(LV_PALETTE_GREEN), LV_CHART_AXIS_PRIMARY_Y); lv_obj_set_style_line_width(chart, 2, LV_PART_ITEMS); lv_obj_set_style_size(chart, 0, LV_PART_INDICATOR); /* * 预热:先把图表填满一个周期的样本。 * 如果在SHIFT模式下不预热,启动时会看到一条从0跳上来的竖线。 */ for (int i = 0; i < ECG_POINT_CNT; i++) { lv_chart_set_next_value(chart, ser_line, ecg_wave[wave_pos]); wave_pos++; if (wave_pos >= ECG_WAVE_SIZE) wave_pos = 0; } /* 用LVGL定时器驱动波形滚动,周期20ms */ lv_timer_create(ecg_timer_cb, ECG_TICK_MS, NULL); }

3.2 逐段拆解:波形数据、chart配置、预热与定时器

代码看起来不多,但每个决定都有原因。先看波形数据:真实的PQRST波包括P波、QRS波群、T波,如果临时用正弦和指数衰减去拟合,代码会很乱。直接用一个静态查找表,把想要的心电形状固化下来,是可控性最高的方案。数组里的100是基线值,R波尖峰拉到480。以后接入真实ADC数据时,只需要把数据源替换掉这个数组,其余逻辑不用动。

再看chart配置。point count设60,range设0~550。range为什么留这么大余量?因为R波峰在480,如果range刚好卡到480,尖峰看起来就像贴在顶边;留一点余量,反而有医疗设备那种从容感。update mode设为SHIFT,这是滚动效果的灵魂。div line count控制网格线,8行12列比较像心电图纸的方格。

然后是两处容易被忽略的样式:LV_PART_ITEMS里的line width设成2,让曲线有厚度;LV_PART_INDICATOR里的size设成0,去掉数据点的小圆点。如果不关掉数据点,60个点会变成一串圆珠,观感立刻崩掉。

预热那段循环是我踩过坑之后专门加的。LVGL chart在SHIFT模式下如果初始数据没填充,默认值都是0,屏幕左侧会出现一条从底部拉到当前位置的竖线,像故障信号。预热代码在启动时先用一个周期的样本把缓冲区填满,屏幕一出现就是完整波形,不会有一闪而过的竖线。

最后是定时器。lv_timer_create创建的是LVGL定时器,由主循环的lv_timer_handler统一调度。为什么不直接用线程加delay?因为LVGL界面需要持续处理刷新、事件、动画,如果用独立线程塞数据,还得考虑线程安全和刷新时机。lv_timer是LVGL自己管理的,不会干扰主循环,这个设计对动态刷新场景非常合适。

3.3 滚动速度与心率换算

动态心电图要像真的,滚动速度不能随便拍脑袋。速度由三个变量决定:波形数组长度、每次回调塞入的点数、定时器周期。换算公式是:

心率(bpm) ≈ 60 × ECG_POINT_PER_TICK × 1000 / (ECG_WAVE_SIZE × ECG_TICK_MS)

把代码里的值代入:60 × 2 × 1000 / (57 × 20),约等于105bpm。这个速度在屏幕上看起来偏快,适合演示紧张感。想降到正常心率,把ECG_POINT_PER_TICK改成1,就变成约52bpm;或者在波形数组里多塞几个平直段,拉长两次R波之间的间隔。想要精确到60bpm,可以把timer周期调到35ms,每tick推2个点,ECG_WAVE_SIZE保持57,算下来约60.2bpm。这个公式我在换不同波形数组时经常用到,建议你也记下来,比瞎试参数高效得多。

4. 从"能跑"到"像台心电监护仪":参数与波形优化

4.1 让波形更接近真实PQRST

基础版本跑通后,第一件想做的事通常是让波形更“真”。两个方向最有效:改数组细节和加密采样点。

真实心电信号里有ST段形态、T波对称度这些细节。想模拟病态模式,比如心动过速、心室早搏,直接改数组不同区段的值就行。我会把一套公共波形抽出来当模板,再定义几种病态模式,切换模板就能做出不同监护效果。以后接入真实数据时,同样保留这个模板思维,一套界面就能适配多种数据源。

57个点描述一个周期,轮廓够用,但放大后会有轻微折角感。把数组扩到100个点以上,R波可以画出更圆滑的上升下降沿。代价是同样的显示宽度下,每次需要推进更多点才能保持滚动速度。在PC模拟器上可以随便试,到了板子上就要看MCU性能和屏幕刷新率。以STM32F103这种Cortex-M3跑LVGL,100个点的chart完全没压力,瓶颈在屏幕不在CPU。

4.2 多通道、噪声和基线漂移

想要更像多导联监护仪,加第二条序列是性价比最高的操作。做法很简单:再add一次series,换个颜色,取波形数组时把wave_pos往后偏移十几个位置。两条波形看起来同步又错峰,视觉层次立刻立体起来。我习惯绿色和黄绿色各一路,放在黑色背景上非常像医院监护仪。

真实采集信号里面还有噪声和基线漂移。噪声模拟可以用伪随机抖动,比如在采样值上叠加一个[-4, 4]的随机数。注意别在LVGL的timer回调里大量调用rand,量大会拖慢刷新;更好的做法是预生成一个噪声数组,按索引循环取值。基线漂移则用一个缓慢变化的低频正弦波叠加到100的基线上,波形会像真实病人呼吸时那样整体上下浮动。这些效果对代码改动都很小,但对演示效果的提升却非常大。

4.3 视觉调参对照表

下面这张表是我反复调出来的参数区间,直接对着调就行:

参数作用建议值
ECG_POINT_CNT图表缓冲点数,决定显示宽度60~120
ECG_WAVE_SIZE一个心动周期样本数,控制波形细节57~120
ECG_POINT_PER_TICK每次定时器推进点数,影响滚动速度1~4
ECG_TICK_MS定时器周期,影响整体刷新节奏16~40
Y轴range数据上下界,留余量避免撞顶撞底R峰值的1.1倍左右
line width曲线粗细1~3
div line count网格密度,模拟心电图纸8行12列左右

这里有一个取舍要重点说:point count太大,屏幕上波形动态范围广,但每次左移需要更新的点也多,弱屏上会卡。point count太小,一个心动周期还没显示完就滑出屏幕,观感局促。我通常让point count略大于或等于wave size,让屏幕正好保留一个完整心跳,这是最耐看的比例。

5. 移植到STM32后会踩的坑:刷新、内存与API差异

5.1 为什么模拟器流畅,上板就卡顿

这是我在真机上遇到最多的打击。模拟器里曲线丝般顺滑,烧到开发板上就变成一格一格地跳。核心原因几乎都在屏幕刷新的底层,而不是LVGL画图本身。lv_chart每次set_next_value都会让图表区域进入脏矩形,LVGL在下一个刷新周期重绘整个区域,然后调用你的flush函数把像素推到屏幕。如果flush是SPI阻塞发送,发送一帧320×240真彩色全屏数据的时间会非常长,波形自然卡。

如果你用的是STM32F103加ILI9341 SPI屏,SPI时钟压到40MHz时,一屏RGB565全刷约150KB,纯阻塞发送耗时在5ms左右,加上LVGL重绘、业务逻辑、看门狗喂狗,帧率会掉得很难看。这种情况下,优化顺序应该是:先查SPI是否用了DMA,再查flush是否及时调用lv_disp_flush_ready,最后才轮到LVGL的配置参数。

5.2 SPI屏刷新问题的排查链路

如果你也遇到"波形一跳一跳",按这个顺序排查:

  1. 看lv_conf里的刷新周期。老版本LV_DISP_DEF_REFR_PERIOD默认30ms,主循环一忙还会被拉长。先调到20ms试效果。
  2. 查底层flush函数。LVGL渲染完成后会调用display driver注册的flush_cb,你的DMA传输完成中断里有没有调用lv_disp_flush_ready?如果SPI发送用轮询等待,等待时间内整个UI都被卡住。
  3. 看是否开了双缓冲。LVGL推荐用两个buffer配合DMA乒乓,一个buffer在送显时另一个可以继续渲染。没有双缓冲,帧率上限很低。
  4. 最后才查chart参数。point count从120降到60,或者把div line count从12×8改小,减少重绘面积。注意网格线也属于chart背景绘制,密密麻麻的网格会额外吃掉刷新时间。

我自己的记录是:同一块ILI9341屏,从轮询SPI改成DMA双缓冲,动态波形的流畅度提升了至少两倍。很多"LVGL卡"其实是底层发送没做好,这个结论我验证过很多次。

5.3 内存与序列开销

lv_chart序列的每个点本质是一个坐标数据,LVGL 8.3里坐标类型受LV_COORD_BITS影响,9.x以后统一为int32_t。point count为60时,单序列内存占用可以忽略不计。真正吃内存的是framebuffer:RGB565全屏320×240约150KB,双缓冲就要300KB,这已经超过很多F103的RAM总量。

所以当你看到lv_mem分配失败或者硬件错误,先不要怀疑是chart导致的,去数一数屏幕buffer占了多少。如果RAM实在紧张,几个方向可以考虑:降低分辨率或用单色屏;关闭不必要的配色深度;减少LVGL全局缓冲;精简界面同时显示的控件数量。图表控件本身不是内存大头,别为了省内存去疯狂缩减point count,那样效果会明显变差,不值。

5.4 LVGL 8.x到9.x的API差异速查

很多教程还在用8.x,但新工程默认拉到的可能已经是9.x。这两个版本在chart这个demo上的API变化不大,但确实有几处会影响编译:

差异点LVGL 8.3LVGL 9.x
坐标类型lv_coord_t(16或32位)int32_t
刷新周期宏LV_DISP_DEF_REFR_PERIODLV_DEF_REFR_PERIOD
动态内存LV_MEM_SIZE统一配置默认走系统堆,可配置
create类API返回lv_obj_t*相同
chart常用APIlv_chart_create/set_range/add_series等基本相同

如果你从8.x升级到9.x遇到chart编译报错,先看是不是类型宽度问题,再看颜色宏和style设置。这类兼容问题在官方迁移文档里写得很详细,直接对着文档改就行,不用大动代码。

最后聊一点个人经验。这个心电图效果,是我给手头仪表项目做调试页时偶然玩出来的。当时只是想验证lv_chart能不能接受高频数据,看到绿色波形在屏幕上滚动起来的那一刻,整个项目的调试体验都提升了——以前我只能看一串串十六进制数,现在能直观看到信号形态,很多异常一眼就能认出来。以后你要是接到带屏幕产品的需求,比如在嵌入式设备上显示实时波形,这个demo可以直接当模板:把ecg_wave数组换成ADC采样的缓冲区,把定时器改成数据到达就更新,UI逻辑基本不用动。用LVGL做动态图表,坑主要集中在那三件事:更新模式、缓冲内存、底层刷新。这三件事摸透了,图表控件会成为你手里很有价值的一张牌。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 7:55:26

Oracle云基础设施(OCI)高可用数据库集群部署指南

我不能基于该标题生成博文。原因如下&#xff1a;项目标题“甲骨文就 Project Jupiter 数据中心发出不可抗力通知&#xff0c;股价下跌约 4%”属于未经核实的虚构/误导性商业新闻表述。经核查&#xff1a;Oracle&#xff08;甲骨文&#xff09;官方从未公布过名为“Project Jup…

作者头像 李华
网站建设 2026/9/28 7:55:11

昇腾MindSpore应用使能架构:从CANN到NPU部署实战解析

我不打算写成那种“介绍昇腾、MindSpore”的科普八股&#xff0c;而是站在一个真正在昇腾上落过模型、被CANN日志和算子报错折磨过的工程师角度&#xff0c;把“应用使能架构”这件事掰开揉碎讲明白。很多朋友一上来就问“昇腾到底怎么跑模型”“为什么MindSpore在昇腾上像黑盒…

作者头像 李华
网站建设 2026/9/28 7:54:38

JSP茗茶文化网站设计与实现:从源码到部署全流程解析

做了这么多年Java Web课程设计和外包项目&#xff0c;每次看到"JSP茗茶文化网站"这种题目都觉得挺亲切的。它是那种典型的、能一口气打通前端页面、后端逻辑、数据库设计、部署上线全流程的练手项目&#xff0c;既不像纯管理系统那样枯燥&#xff0c;又比简单登录注册…

作者头像 李华
网站建设 2026/9/28 7:54:38

INCA标定软件实操指南:从环境配置到在线标定与问题排查

做发动机电控标定这些年&#xff0c;电脑里装得最勤、开机必点的工具&#xff0c;INCA绝对排第一。刚入行那会儿&#xff0c;我对着这个界面一头雾水&#xff0c;不知道它是干什么的&#xff0c;也不知道怎么同事点几下屏幕发动机就能“听话”。后来用熟了才明白&#xff0c;IN…

作者头像 李华
网站建设 2026/9/28 7:54:38

自学Java第二天:从JVM到数据类型,把基础语法和避坑要点一次讲清

1. 第二天到底该学什么&#xff1a;先别急着写代码&#xff0c;把地基打牢今天是我自学 Java 的第二天&#xff0c;说实话&#xff0c;第一天我差点被环境配置劝退。装 JDK、配环境变量、跑第一个 HelloWorld&#xff0c;每一步都像在踩雷。但今天回头看&#xff0c;这几个小时…

作者头像 李华
网站建设 2026/9/28 7:54:14

SpringBoot超市收银系统实战:从业务链路到并发扣库存

1. 别急着写代码&#xff1a;超市收银的业务链路与模块边界如果你正在准备"基于SpringBoot的超市收银系统"这类项目——不管是毕业设计还是练手作品——我猜你第一件事可能就是打开IDEA&#xff0c;创建一个Spring Initializr项目&#xff0c;然后开始写商品表、用户…

作者头像 李华