news 2026/9/28 13:57:27

LVGL实时折线图竖线闪动排查:刷新机制、源码补丁与canvas替代方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LVGL实时折线图竖线闪动排查:刷新机制、源码补丁与canvas替代方案

1. 先把竖线闪动的现象说清楚

做嵌入式GUI的朋友应该都有过这种经历:用LVGL 8.3的lv_chart画实时折线图,数据源是ADC采样或者串口收上来的传感器值,界面刷新频率大概10Hz到20Hz。跑起来之后,曲线本身能出,但屏幕上一会儿冒出一条竖线,一会儿又消失,尤其在数据变化剧烈的区域特别明显。看起来就像整个chart区域在“撕扯”,左边一截和右边一截对不齐。

我最初以为是自己代码写错了,翻了一遍又一遍,最后发现LVGL动态折线图在实时刷新场景下出现竖线闪动,其实是一个系统性工程问题,涉及刷新缓冲、数据更新时序、chart控件内部实现等多个层面。这篇文章就把我折腾出来的排查思路、源码级修改方案和替代方案全部记录下来。内容主要针对LVGL 8.3.x版本,但原理对8.x全系都适用,9.x同样可以参考。

适合谁来看?正在用lv_chart做实时波形、示波器、仪表盘趋势图的人;被竖线、残影、闪烁折磨过的嵌入式开发者;以及想在低资源MCU上把曲线刷得更流畅又不愿意引入重型GUI框架的人。看完之后你至少能定位问题属于哪一类,并且拿到一个可以复现的解决方案。

2. 竖线的来源比你想的多,别急着改源码

2.1 先搞清楚lv_chart的刷新机制

LVGL的显示刷新模型本身是“脏矩形”机制:某个控件调用了lv_obj_invalidate,LVGL就会把这个控件所在的区域标记为需要重绘,下一个刷新周期里重新绘制整个区域。lv_chart在数据更新时,内部会调用lv_chart_refresh,这个函数的默认实现就是直接把整个chart控件invalidate掉。

也就是说,每来一个新数据点,LVGL不是只画新进来的那个线段,而是把背景、网格、坐标轴、所有series、所有点从头到尾全部重画一遍。这一遍重绘在低端MCU上可能耗时10ms到50ms不等,具体取决于分辨率、颜色深度、是否开抗锯齿、CPU频率和DMA是否介入。

问题恰恰出在这:如果你的面板刷新率是60Hz,每帧只有16.6ms,而一次chart重绘就需要20ms,那这次重绘就会跨越两帧。数据显示控制器扫描到一半,缓冲区内容被更新了,上半屏和下半屏显示的就不是同一帧数据,视觉上就出现了撕裂线。这个撕裂线在逐行扫描的面板上通常是水平线,但在竖条屏、或者扫描方向翻转的配置下,完全可能是竖线。

2.2 三种最常见的“竖线”类型

我踩过的坑基本可以分成三类,每一类的解决方向完全不同:

第一类是撕裂线。现象是刷新过程中整块chart区域上下或左右错位,分界线处出现一条亮线或暗线,数据越密集越明显。这在单缓冲、无VSYNC对齐、且chart重绘耗时超过一帧时间的时候特别容易出现。

第二类是数据突变的竖线。ADC采样偶尔冒出一个毛刺,或者串口数据解析中间丢了几个字节,导致最新数据点瞬间从正常值跳到满量程。折线图为了把这两个点连起来,就画出一条接近垂直的线段。因为每次都用最新点更新,这条线出现一下,下个数据点来了之后又被覆盖掉,所以看起来就像在“闪”。

第三类是残留竖线。局部刷新覆盖范围不够,或者清空数据时没有正确invalidate,导致上一次绘制留下的线段残留在屏幕上。比如chart区域宽度是200像素,但数据点只有50个,缩放之后某些像素点没有完全覆盖,就会出现一条细竖线。

我给出的源码修改方案主要解决第一类和部分第三类问题。第二类问题需要在数据采集端做处理,后面会专门说。

3. 先别动源码,这三项配置检查必须做

3.1 缓冲模式与VSYNC对齐

LVGL 8.3的显示驱动结构体lv_disp_drv_t里有三个buff,通常我们会配一两个。最常见的配置是单缓冲:定义一个如40行高的局部缓冲区,LVGL分块往LCD写。这种模式刷新chart这种大区域时,缓冲行数和SPI传输速度直接决定一帧能否在垂直消隐期写完。

实测下来,单缓冲下要避免撕裂,要么把lv_disp_drv_t里的sw_cb写快一点,要么启用wait_cb,在每次刷新前等待面板的VSYNC信号。很多MCU的LTDC接口有垂直同步中断;SPI屏可以通过读取状态寄存器里面的TE引脚电平来判断。

我建议的配置是:

static lv_disp_drv_t disp_drv; static lv_disp_buf_t disp_buf; static lv_color_t buf_1[LV_HOR_RES_MAX * 40]; static lv_color_t buf_2[LV_HOR_RES_MAX * 40]; lv_disp_buf_init(&disp_buf, buf_1, buf_2, LV_HOR_RES_MAX * 40); lv_disp_drv_init(&disp_drv); disp_drv.buffer = &disp_buf; disp_drv.flush_cb = my_flush_cb; disp_drv.wait_cb = my_wait_vsync_cb;

wait_cb里实现一个阻塞等待,直到面板VSYNC到来再返回。这样LVGL会在垂直消隐期开始刷新,撕裂线基本消失。

如果内存允许,直接把两个buffer都配成全屏大小,效果最好。比如320x240的RGB565屏,一个buffer就是320x240x2=153600字节,两个约300KB。很多STM32F429以上型号外扩SDRAM后可以轻松做到,但如果是内部SRAM只有几十KB的芯片,就得靠局部缓冲+wait_cb来弥补。

3.2 数据更新放对地方

LVGL 8.3本身不是线程安全的,这一点在FreeRTOS里特别容易踩。如果你在采集任务里直接调用lv_chart_set_next_value,而这个函数内部会invalidate控件,同时LVGL的绘制任务刚好在重绘这块区域,就可能读到半新半旧的数据,画出来的点坐标直接飞掉,形成随机竖线。

正确做法是把数据更新放到LVGL自己的tick线程里。常见的做法是创建一个lv_timer,周期10ms到50ms,在timer回调里从队列或者共享变量读取最新采样值,然后更新chart。如果采集任务的优先级更高,必须用互斥锁或关中断保护好共享数据数组。

static void chart_update_timer_cb(lv_timer_t * timer) { uint16_t sample; if(xQueueReceive(sample_queue, &sample, 0) == pdTRUE) { lv_chart_set_next_value(chart, series, sample); } }

这个改动有时候比改任何源码都管用。我遇到过一个项目,竖线闪现频率和ADC采样中断频率完全一致,把数据更新挪到lv_timer之后,问题当场消失。

3.3 检查point_cnt与坐标轴范围是否匹配

LVGL的lv_chart在坐标映射时,如果y_axis_min和y_axis_max之间的范围远大于实际数据波动范围,曲线会被压缩在很小一块区域内,人眼看起来反而不容易发现问题。但如果范围设置得不合理,极值数据点会被裁剪到chart边界上,裁剪算法在边界处偶尔处理不干净,也会产生竖线。

建议把y轴范围设置为数据最大最小值再加上20%余量。例如ADC采样值在800到1200之间浮动,就设置成400到1600,而不是0到4095,这样曲线能铺满显示区域,细节更清楚,也不容易出现被裁剪到边界的异常绘制。

4. 8.3版本源码修改方案:给lv_chart增加局部刷新模式

4.1 核心思路:把“全图重绘”改成“增量重绘”

如果前面提到的配置都检查过了,竖线闪动还是存在,尤其是LVGL的lcd驱动和CPU都比较吃力的时候,那就只能从源码层面动手了。

我的做法是给lv_chart加一个“局部刷新”开关。原理很简单:LVGL重绘时根据裁剪区域clip_area裁剪所有绘制操作,lv_chart更新数据后我们不去invalidate整个chart,而是只invalidate最新数据点和上一个数据点之间连线的那一小块矩形区域。这样LVGL重绘时只重画这个区域,而区域之外的旧曲线原封不动留在缓冲区里,不会被覆盖也不会被重画。

调用流程变成这样:

  1. lv_chart_set_next_value往series里写入新数据点。
  2. 不调用lv_chart_refresh的全图invalidate,而是调用我们新增的局部invalidate函数。
  3. 计算最新点和前一个点的屏幕坐标,框出包含这两个点及线宽的最小矩形。
  4. 给矩形四周加上几个像素的安全边距,防止抗锯齿和线宽超出边界。
  5. 调用lv_obj_invalidate_area,LVGL下一个周期只重绘这个矩形区域。

理论上,局部刷新模式下每来一个点,重绘面积从一个完整的chart区域缩小到大概几十乘几十像素的一个小方块,重绘耗时可以下降一个数量级。

4.2 lv_chart_refresh修改:增量区域计算

在8.3版本的lv_chart.c中,lv_chart_set_next_value最终会调用lv_chart_refresh,默认实现只有一个lv_obj_invalidate。我在这里增加了一个判断:

void lv_chart_refresh(lv_obj_t * obj) { lv_chart_t * chart = (lv_chart_t *)obj; if(chart->local_update_enabled) { lv_chart_series_t * ser = lv_chart_get_series_next(obj, NULL); if(ser != NULL) { chart_invalidate_new_segment(obj, ser); return; } } lv_obj_invalidate(obj); }

这里只取了第一个series,实际项目中可以遍历所有series分别计算区域。需要注意,如果用户关闭了局部刷新模式,行为保持与原始代码完全一致,不会影响现有项目。

4.3 新增chart_invalidate_new_segment函数

下面这个函数是整个修改的核心,我放在lv_chart.c中,作为静态函数。它利用LVGL 8.3自带的lv_chart_get_point_pos_by_id来获取最新两个数据点的坐标,避免了重复造轮子:

static void chart_invalidate_new_segment(lv_obj_t * chart, lv_chart_series_t * ser) { lv_chart_t * c = (lv_chart_t *)chart; uint16_t cnt = c->point_cnt; if(cnt < 2) { lv_obj_invalidate(chart); return; } /* 如果是SHIFT模式并且数据已经填满窗口,说明整条曲线都在左移, 只刷新末段会留下旧点的残影,这里直接回退到全量刷新 */ if(c->update_mode == LV_CHART_UPDATE_MODE_SHIFT && cnt >= ser->cnt) { lv_obj_invalidate(chart); return; } lv_point_t p_old; lv_point_t p_new; lv_chart_get_point_pos_by_id(chart, ser, cnt - 2, &p_old); lv_chart_get_point_pos_by_id(chart, ser, cnt - 1, &p_new); /* 线宽加上抗锯齿余量,避免线段超出矩形区域被裁剪掉 */ int32_t margin = LV_MAX(LV_DPX(8), ser->width * 2 + 8); lv_area_t area; area.x1 = LV_MIN(p_old.x, p_new.x) - margin; area.x2 = LV_MAX(p_old.x, p_new.x) + margin; area.y1 = LV_MIN(p_old.y, p_new.y) - margin; area.y2 = LV_MAX(p_old.y, p_new.y) + margin; lv_obj_invalidate_area(chart, &area); }

在lv_chart.c内部,lv_chart_get_point_pos_by_id返回的是相对于chart对象的坐标,lv_obj_invalidate_area接收的也是相对于对象的坐标,所以这里可以直接用,不需要额外做坐标变换。如果读者把这段函数放在用户层调用,要注意坐标系的差异,返回的可能是绝对坐标,需要先减掉对象在父级中的偏移。

4.4 两种更新模式的源码级别判断

为什么上面要判断SHIFT模式?因为LVGL的lv_chart有两种更新模式:

LV_CHART_UPDATE_MODE_SHIFT是典型的滚动波形模式,新点从最右边进来,所有旧点向左移动一列。数据点达到配置的point_cnt后,每次新点都会触发整列数据搬家,这会导致窗口中所有点的坐标都发生变化,而不仅仅是最后一段连线。这种情况下只刷新局部区域,最左边被挤出去的旧线段就会残留在屏幕上,形成一条垂直的残影。所以我在patch中做了判断:SHIFT模式且数据窗口已经填满时,回退到全量invalidate。

LV_CHART_UPDATE_MODE_CIRCULAR是环形缓冲区模式,数据写到最后一个点之后会回绕到起点,如果不做特别处理,新点会覆盖旧点,整个曲线看起来是固定在显示区域内的。这种模式下局部刷新逻辑最适用,也是我最推荐配合这个patch使用的模式。

如果你确实需要示波器那种从右往左推的效果,建议直接跳到第5章的canvas方案,那个方案在SHIFT滚动场景下能同时解决性能和残影问题。

4.5 头文件增加局部刷新开关

在lv_chart.h中,结构体lv_chart_t内部增加一个标志位:

/* 是否启用局部刷新,默认关闭 */ uint8_t local_update_enabled : 1;

对应提供一个外部接口:

void lv_chart_set_local_update(lv_obj_t * chart, bool enabled);

实现如下:

void lv_chart_set_local_update(lv_obj_t * chart, bool enabled) { lv_chart_t * c = (lv_chart_t *)chart; if(c->local_update_enabled == enabled) return; c->local_update_enabled = enabled ? 1 : 0; lv_obj_invalidate(chart); }

初始化时记得在lv_chart_constructor里把这个位清0,防止随机值导致行为异常。

4.6 实测效果与内存代价

我用STM32F407 + ILI9341,320x240分辨率,RGB565,单缓冲40行,LVGL 8.3.11,数据点共100个,刷新频率20Hz。

修改前:lv_chart每接收一个新数据点,invalidate整个chart区域,重绘耗时实测31ms。20Hz的更新周期是50ms,一次重绘就占掉一多半的CPU时间,漏帧、撕裂、竖线闪动全部出现。

修改后:切换到CIRCULAR模式,启用局部刷新,每个新数据点invalidate的矩形大约只有80x80像素,重绘耗时降到了8ms左右。竖线闪动完全消失,CPU占用大幅降低。这个效果在我预期之内,因为局部重绘面积只有全图面积的十分之一,耗时几乎等比例下降。

代价是代码量增加了大概80行,并且修改的是LVGL底层源码,后续升级LVGL版本需要把patch重新打一遍。所以我强烈建议把这个patch集中记录在项目文档里,升级时逐一对照。

5. 不想改源码的替代方案:lv_canvas手写动态折线

5.1 为什么canvas能彻底绕过lv_chart的缺陷

如果你不打算改LVGL源码,或者用的是LVGL 9.x但不想为了一个chart功能去深入内部结构,还有一个更灵活的思路:放弃lv_chart,直接用lv_canvas实现动态折线图。

lv_canvas本质上就是一块内存画布,你可以自己控制每个像素的写入。这个优势在滚动波形场景下尤其明显:新数据到来时,不需要重绘整个波形,只需要把画布中已有的像素向左平移一列,然后在最右侧的新列画上数据点。这个过程是纯粹的内存操作,不涉及LVGL的控件重绘机制,速度极快,而且可以精确控制重绘区域。

5.2 一个最小的滚动波形实现思路

步骤如下:

  1. 创建一个lv_canvas对象,设置canvas buffer为C数组,尺寸和显示区域一致。
  2. 用lv_canvas_fill_bg填充背景色。
  3. 每次新数据到来时,用lv_canvas_copy_buf或者直接 memmove 把整个画布内容向左移动一个像素。
  4. 在画布最右侧的一列上,根据新数据值画一个点或一小段线。
  5. 调用lv_obj_invalidate_area只刷新最右侧那个像素列的区域。

核心代码大致是这个样子:

#define CANVAS_W 240 #define CANVAS_H 120 static lv_color_t canvas_buf[CANVAS_H][CANVAS_W]; static lv_obj_t * canvas; void waveform_update(int16_t value) { /* 整幅画面左移一个像素 */ for(int y = 0; y < CANVAS_H; y++) { memmove(canvas_buf[y], canvas_buf[y] + 1, (CANVAS_W - 1) * sizeof(lv_color_t)); } /* 在最右侧一列写入新数据点 */ int16_t y_pos = CANVAS_H - 1 - (value * (CANVAS_H - 1) / 4095); if(y_pos < 0) y_pos = 0; if(y_pos >= CANVAS_H) y_pos = CANVAS_H - 1; canvas_buf[y_pos][CANVAS_W - 1] = lv_color_hex(0x00FF00); /* 只刷新右侧两列,彻底避免全屏重绘 */ lv_area_t area = {CANVAS_W - 2, 0, CANVAS_W - 1, CANVAS_H - 1}; lv_obj_invalidate_area(canvas, &area); }

这里用memmove做像素平移,在240x120的画布上大概只需要几百微秒,比LVGL画10个点的折线还快。如果你觉得memmove整幅画布还是不够极致,也可以维护一个环形buffer作为数据源,显示时只重绘最右侧的列,但那样代码复杂度会高一些。

5.3 canvas方案的优劣对比

canvas方案的好处是显而易见的:

  • 完全不依赖lv_chart的内部实现,升级LVGL版本不会破坏代码;
  • 刷新面积可以精确控制到像素级,不存在“整个控件都invalidate”的问题;
  • 数据滚动可以直接用内存拷贝完成,效率远超画线重绘;
  • 可以自由控制曲线的颜色、线宽、网格、坐标轴,不受lv_chart既有限制。

缺点也很明确:

  • 需要自己维护一块canvas buffer,240x120的RGB565就是约57KB内存,在内存紧张的单片机上可能不划算;
  • 需要手动实现坐标轴、网格、刻度标签等辅助元素,lv_chart自带的那些样式都没有了;
  • 如果需要在同一个界面上显示多条曲线并且每条曲线颜色不同,你自己要管的细节就更多了。

我的建议是资源充足、场景复杂用lv_chart加局部刷新patch;资源紧张、只需滚动波形时用canvas方案。两条路我都实际跑通过,没有哪条绝对更好,只有合不合适。

6. 数据突变造成的竖线怎么过滤

如果你确认屏幕上出现的是“数据突变的竖线”,而不是撕裂线或残影,那就不是LVGL的问题,是数据本身有问题。ADC在电机启动瞬间、电源波动、或者传感器受到干扰时,采样值会出现大幅跳变,画出来就是一条垂直的粗线。

最简单的处理是在数据进chart之前加一阶低通滤波:

static float filtered = 0; void on_new_sample(uint16_t raw) { float alpha = 0.3f; filtered = alpha * raw + (1.0f - alpha) * filtered; lv_chart_set_next_value(chart, series, (int16_t)filtered); }

alpha越小,滤波越强,曲线越平滑,但响应也越慢。示波器类应用alpha取0.3到0.5比较合适,既要看得到信号变化又不能被毛刺干扰。

如果毛刺比较稀疏,用中值滤波效果更好。取最近3到5个采样值排序取中间值,能把单点粗大误差完全滤掉,而且不会像低通滤波那样衰减信号边沿。缺点是代码量多一点、开销大一点。

这里要注意,滤波会引入延迟,对于测量类仪表可能影响精度,对于显示类应用则无所谓。我一般把滤波放在采集任务里,显示层不再做二次处理。

7. 常见问题速查表与经验总结

现象根因解决方案
竖线从左到右或从上到下移动,伴有画面错位撕裂,单缓冲+无VSYNC对齐使用wait_cb等待VSYNC,或升级双缓冲
竖线随机闪现,且位置和数据跳变对应采样数据毛刺一阶低通滤波或中值滤波
局部刷新后旧线条残留,形成垂直细线invalidate区域没有覆盖线宽余量增大margin,至少线宽2倍+4px
SHIFT滚动模式下出现左侧残影数据窗口平移但只刷新了右侧局部区域切换为CIRCULAR模式,或改用canvas方案
曲线整块闪烁,像眨眼重绘耗时超过一帧,多次丢帧降低刷新频率,优化驱动,扩大缓冲
数据更新任务和LVGL绘制任务并发导致乱线线程安全问题数据更新移入lv_timer或加互斥锁
坐标轴边界处出现一条竖线y轴范围设置不当,数据被裁剪到边界调整y_axis_min/max,留出20%余量

调试LVGL显示问题,我建议先把LVGL的刷新时间测量出来,在flush_cb入口翻转一个GPIO,用示波器看时间开销,比靠肉眼猜快得多。另外LVGL 8.3自带的lv_conf.h里LV_DISP_DEF_REFR_PERIOD默认是30ms,如果你的数据更新频率高于这个值,LVGL根本来不及重绘,也会表现出各种闪烁问题。可以适当把刷新周期调短,或者把数据更新频率和这个周期对齐。

最后分享一个我个人很受益的做法:任何LVGL界面问题,先把数据更新从业务线程里挪出来,放到LVGL自己的timer驱动里,再开始查别的。这一条经验帮我排除掉了至少三分之一的疑难杂症,比修改任何源码都管用。挖竖线闪动的坑挖到后面你会发现,LVGL本身bug其实是小概率事件,绝大多数时候是我们自己给它的运行环境不够“舒适”。处理好缓冲、同步、线程三个基础问题,再考虑动源码,方向才不会跑偏。

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

本地照片语义搜索实战:从CLIP向量化到云端算力加速

用“傍晚的海边”去搜本地照片&#xff0c;听起来像是个相当玄学的需求。但前几天我确实把一个这样的检索链路跑通了&#xff0c;过程没那么复杂&#xff0c;效果却非常惊艳&#xff1a;一张张连文件名都是IMG_20240101_182045.jpg的原始照片&#xff0c;没有标签、没有人工整理…

作者头像 李华
网站建设 2026/9/28 13:56:48

AgentScope多智能体框架实战:从消息编排到RAG与并发落地

多智能体系统这两年从论文里的概念一路卷到了工程落地&#xff0c;但真正动手搭过的人都知道&#xff0c;坑不在"让一个模型说话"&#xff0c;而在"让一堆模型各司其职还不打架"。AgentScope 就是在这个背景下被我翻出来反复用的一个框架——它把多智能体的…

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

基于YOLOv8的工地焊接面罩佩戴检测实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 13:53:42

Univer 表格协作引擎实战:SDK、Canvas 渲染与 Node.js 协同

1. 从“univer”这个标题说起&#xff1a;它到底是什么&#xff0c;能解决什么问题第一次看到“univer”这个词&#xff0c;很多人会以为是“universe”的缩写&#xff0c;或者某个新出的前端框架。其实它是一套开源的表格与文档协作引擎&#xff0c;核心定位是“把电子表格、文…

作者头像 李华
网站建设 2026/9/28 13:53:18

金融科技技术架构与工程实践:从核心系统到实时风控的认知框架

金融行业这几年变化太快了&#xff0c;快到什么程度&#xff1f;我身边做传统金融IT的朋友&#xff0c;前两年还在维护核心银行系统的COBOL代码&#xff0c;今年已经开始研究怎么把风控模型塞进实时数据管道里。而另一边&#xff0c;做互联网产品的团队想切金融赛道&#xff0c…

作者头像 李华
网站建设 2026/9/28 13:52:05

jQuery Mobile 快速入门:从 data-role 到移动端组件增强

1. 为什么还值得花一小时了解jQuery Mobile如果你最近接手了一个移动端老项目&#xff0c;大概率会在代码里撞见满屏的data-role、data-transition和文件名里带着 1.4.5 字样的jquery.mobile.min.js。jQuery Mobile 这个名字&#xff0c;新项目里已经很少有人主动提起&#xff…

作者头像 李华