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模拟器工程,接下来的流程很短:
- 新建一个c文件,把第三节的完整代码复制进去。
- 在main初始化LVGL之后调用lv_demo_ecg()。
- 编译运行,看到绿色波形持续向左滚动就成功了。
- 确认主循环里有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屏刷新问题的排查链路
如果你也遇到"波形一跳一跳",按这个顺序排查:
- 看lv_conf里的刷新周期。老版本LV_DISP_DEF_REFR_PERIOD默认30ms,主循环一忙还会被拉长。先调到20ms试效果。
- 查底层flush函数。LVGL渲染完成后会调用display driver注册的flush_cb,你的DMA传输完成中断里有没有调用lv_disp_flush_ready?如果SPI发送用轮询等待,等待时间内整个UI都被卡住。
- 看是否开了双缓冲。LVGL推荐用两个buffer配合DMA乒乓,一个buffer在送显时另一个可以继续渲染。没有双缓冲,帧率上限很低。
- 最后才查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.3 | LVGL 9.x |
|---|---|---|
| 坐标类型 | lv_coord_t(16或32位) | int32_t |
| 刷新周期宏 | LV_DISP_DEF_REFR_PERIOD | LV_DEF_REFR_PERIOD |
| 动态内存 | LV_MEM_SIZE统一配置 | 默认走系统堆,可配置 |
| create类API | 返回lv_obj_t* | 相同 |
| chart常用API | lv_chart_create/set_range/add_series等 | 基本相同 |
如果你从8.x升级到9.x遇到chart编译报错,先看是不是类型宽度问题,再看颜色宏和style设置。这类兼容问题在官方迁移文档里写得很详细,直接对着文档改就行,不用大动代码。
最后聊一点个人经验。这个心电图效果,是我给手头仪表项目做调试页时偶然玩出来的。当时只是想验证lv_chart能不能接受高频数据,看到绿色波形在屏幕上滚动起来的那一刻,整个项目的调试体验都提升了——以前我只能看一串串十六进制数,现在能直观看到信号形态,很多异常一眼就能认出来。以后你要是接到带屏幕产品的需求,比如在嵌入式设备上显示实时波形,这个demo可以直接当模板:把ecg_wave数组换成ADC采样的缓冲区,把定时器改成数据到达就更新,UI逻辑基本不用动。用LVGL做动态图表,坑主要集中在那三件事:更新模式、缓冲内存、底层刷新。这三件事摸透了,图表控件会成为你手里很有价值的一张牌。