☰
LVGL折线图竖线闪动根因修复:从包围盒到抗锯齿
2026/9/28 2:26:17 网站建设 项目流程

有人在LVGL交流群里问动态折线图实时刷新时为什么会出现竖线闪动,其实我早先做实时波形显示项目时也踩过同一个坑,而且最终确认下来,问题一半出在使用姿势上,另一半得靠8.3版本源码修改才能根治。如果你正被这个现象折磨得怀疑屏幕驱动、怀疑双缓冲、甚至怀疑人生,这篇文章应该能帮你省下至少一周的排查时间。

我尽量把整个定位过程写清楚:先还原现象、再给最小复现工程,然后讲我是怎么沿着证据链一步步排除掉外部因素的,最后进入源码层,给出LVGL 8.3.x两个实实在在的patch。不想看推理只想解决问题的朋友,可以直接跳到第四章改代码;愿意搞明白“为什么偏偏闪的是竖线”的,建议从头读完,后面那些排查思路遇到类似渲染问题同样能复用。

1. 现场还原:哪个“竖线”在闪,什么条件下必现

1.1 现象描述

项目背景很简单:MCU采集传感器数据,通过 LVGL 的lv_chart组件画动态折线图,横向滚动显示最近一段时间的波形。数据大约每20ms来一个点,图表用的是LV_CHART_TYPE_LINE+LV_CHART_UPDATE_MODE_SHIFT模式,也就是新数据从右侧进来,旧数据整体左移。

最开始跑起来非常正常,波形很流畅,可是跑久了我就发现一个诡异现象:屏幕右侧新数据点所在的列,会有一条非常细的竖线在闪。注意,不是整条波形闪,也不是整屏闪,就只有那一两列像素,以肉眼大概能感知的频率在“出现—消失—再出现”之间跳变。一开始我以为是错觉,拿手机开慢动作录像一帧帧看,才确认竖线确实不是每一帧都完整存在,某些帧里它整根消失,下一帧又恢复。

这个现象有个很明显的规律:数据变化平缓时,竖线闪动很轻微,几乎看不出来;但只要数据发生大幅跳变,比如一个采样点从屏幕底部附近直接跳到顶部附近,这条近乎垂直的竖线就会闪得非常扎眼。

1.2 最小复现工程

当时为了排查,我把整个项目能砍的东西全砍了,最后留下一个最小的复现工程。结构非常简单:一个lv_chart,一条series,一个20ms周期的lv_timer,在timer回调里往chart里灌随机数。如果你也想验证,直接把这个函数丢进任意LVGL 8.3.x的工程里跑就行。

/* LVGL 8.3.x 最小复现工程:动态折线图 + 竖线闪动 */ static lv_obj_t * chart; static lv_chart_series_t * ser; static void chart_timer_cb(lv_timer_t * timer) { /* 用随机数模拟大幅波动的数据,10~170 之间快速跳变 */ int16_t v = lv_rand(10, 170); lv_chart_set_next_value2(chart, ser, v); } void dynamic_line_app(void) { chart = lv_chart_create(lv_scr_act()); lv_obj_set_size(chart, 320, 180); lv_obj_center(chart); lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, 128); lv_chart_set_update_mode(chart, LV_CHART_UPDATE_MODE_SHIFT); lv_chart_set_div_line_count(chart, 4, 8); ser = lv_chart_add_series(chart, lv_color_hex(0x00CC88), LV_CHART_AXIS_PRIMARY_Y); lv_chart_set_next_value2(chart, ser, 80); lv_timer_create(chart_timer_cb, 20, NULL); }

如果你在别的地方借了demo代码,也建议先砍到只剩这一步再观察,因为图表对象上一旦叠加了太多其他widget,干扰因素会成倍增加。

1.3 三条关键观察

我在复现过程中记下了几条对后续定位特别重要的观察结论。

观察一:垂直落差越大,闪动越明显。我把随机数范围从10~170改成80~100,竖线闪动几乎消失;改回大幅跳变,闪动立刻回来。这说明闪动的关键不在刷新频率,而在竖直方向绘制的内容。

观察二:线宽和抗锯齿有直接关系。把series线宽从默认的1改成2或3,竖线闪动更明显;而在 LVGL 软件渲染配置里关掉抗锯齿(LV_COLOR_DEPTH相关的LV_DRAW_SW抗锯齿开关),同样的代码几乎不闪。这一点非常关键,基本把矛头指向了渲染器本身。

观察三:换局部刷新和全屏刷新,结果不一样。在部分渲染(partial rendering)模式下竖线闪动偏多,把VDB调大或者改成整段刷新后闪动频率下降,但没有完全消失。这告诉我问题不只是“局部刷新裁剪”这一个环节,渲染器对竖直线的绘制边界本身就有嫌疑。

2. 按证据链排查:屏幕、驱动、内存先过一遍

2.1 先证明屏幕和LCD驱动没问题

竖线闪动这种东西,第一反应都会怀疑是LCD驱动时序问题,比如TE信号抖动、撕裂、或者RGB接口和MCU接口的数据线干扰。这个必须用对照实验排除,不能靠猜。

我在同一块屏上跑了LVGL自带的性能测试demo和benchmark场景,大量色块渐变、圆角矩形、满屏文字快速刷新,都没有出现任何单列像素闪烁。如果驱动时序有问题,这种满屏高频刷新的场景早就暴露了。接着我又把刷屏函数简化成只刷纯色和竖条纹,用逻辑分析仪看帧同步和TE信号,波形稳定干净。到这里我可以负责任地说:屏幕、排线、LCD驱动这一层基本排除干净。

2.2 双缓冲、单缓冲和VDB大小对照测试

既然屏幕没问题,下一个嫌疑人就是LVGL的刷新机制。LVGL 8.3支持单缓冲和双缓冲,也支持VDB只有屏幕一部分的部分渲染模式。我把三种典型配置全部试了一遍:

配置项现象结论
满屏单缓冲,全局刷新竖线闪动存在,频率较低与缓冲数量无关
满屏双缓冲,flush等待都开竖线闪动存在,频率较低与缓冲数量无关
VDB只有1/4屏幕,部分渲染竖线闪动最明显局部裁剪会放大问题
VDB只有1/4屏幕,关掉局部刷新闪动明显减轻裁剪边界参与问题形成

这组实验有一个重要推论:问题确实和“部分渲染时按带裁剪”有关,但不是唯一原因。因为即使禁用部分渲染、整屏刷新,竖线依然会以较低频率闪动,说明绘图阶段本身就不完整。

2.3 数据更新频率的分段测试

我还担心过是不是自己数据更新姿势不对。LVGL的chart组件在使用上确实有很多讲究,比如lv_chart_set_next_value2本身会触发invalidate,不需要再手动调lv_obj_invalidate;又比如在SHIFT模式下,数据点是整体左移的,不应该手动删点。这些我都检查并改正了。

接下来我把timer周期从20ms逐渐拉到200ms,观察竖线闪动情况。按理说刷新频率降下来之后,如果问题是“重绘太频繁导致画面来不及画完”,闪动应该显著缓解。结果竖线闪动照旧,只是出现频率跟着刷新周期一起降了。这直接说明:每一次数据更新后的那一帧渲染,竖线就没有被画完整过,跟频率没有因果,跟渲染覆盖率才是因果。

2.4 最关键的实地测试:强制整屏invalidate

为了进一步确认方向,我做了一个很暴力的实验:在timer回调里更新数据之后,不依赖chart自己的invalidate,而是手动调用lv_obj_invalidate(lv_scr_act()),让下一帧把整个屏幕全部重绘一遍。

结果竖线闪动消失了,尤其在关掉部分渲染之后,非常干净。

这个实验在我整个排查过程中价值最大,它把问题范围精确锁定到了“LVGL局部重绘时,chart需要重绘的区域和渲染器实际绘制的区域不一致”。也就是说,画还是画了的,只是没画全。到了这一步,不看源码是讲不通了。

2.5 排查结论汇总

把上面所有实验放在一张表里,结论非常清晰:

测试项操作结果对定位的贡献
LCD驱动压力测试跑benchmark、纯色、竖条纹正常排除屏幕与驱动
缓冲与VDB配置单缓冲/双缓冲/部分渲染交叉测试部分渲染放大问题指向局部裁剪
数据刷新频率20ms改成200ms闪动只是变慢,没有消失指向绘制覆盖率
强制整屏invalidate更新后手动invalidate整个屏幕闪动消失锁定局部重绘不一致

到这里,外部因素全部排除,下一步直接打开LVGL 8.3源码,找lv_draw_sw_line和lv_chart的绘制逻辑。

3. 源码级根因:垂直线的抗锯齿被渲染器自己的包围盒裁掉

3.1 先看 lv_draw_sw_line 的包围盒计算

LVGL 8.3的软件渲染器里,所有线段的最终绘制都要经过lv_draw_sw_line,文件路径是lvgl/src/draw/sw/lv_draw_sw.c。无论是chart里的series连线、还是手动创建的lv_line对象,底层都在这里汇合。

这个函数开头会计算这条线段的包围盒(bounding box),代码大致是这样的逻辑:

/* lvgl/src/draw/sw/lv_draw_sw.c * lv_draw_sw_line() 函数中,计算线段最小包围盒的部分 */ lv_area_t line_barea; line_barea.x1 = LV_MIN(point1->x, point2->x) - dsc->width / 2; line_barea.x2 = LV_MAX(point1->x, point2->x) + dsc->width / 2; line_barea.y1 = LV_MIN(point1->y, point2->y) - dsc->width / 2; line_barea.y2 = LV_MAX(point1->y, point2->y) + dsc->width / 2;

也就是说,LVGL认为一条线段只需要在线宽方向各扩展width/2像素就够了,上下左右都按这个值圈一个矩形,然后拿这个矩形和当前的裁剪区域做交集,真正能画的只有交集部分。

问题恰恰出在这里:这个包围盒只算了实体线宽的半宽,没有考虑抗锯齿(anti-aliasing)产生的半透明过渡像素。抗锯齿为了让线段边缘平滑,会在实线边界往外再铺1~2圈半透明像素。这圈像素的实际绘制范围超出了包围盒,于是渲染器在绘制时要么刻意丢掉了超出的部分,要么在后续裁剪时被直接裁掉。

3.2 为什么偏偏是竖线最容易中招

在动态折线图里,数据剧烈变化时会出现接近垂直的线段。这条线段几何上是一个“高而窄”的矩形,宽度可能只有2px,高度却有上百px。竖线的半透明AA像素全部集中在左右边界和上下端部,任何一侧丢了一列,整根线看上去都会缺一条缝。

横线和缓坡线为什么没那么容易闪?因为它们的AA像素分布在上下左右好几个像素范围内,少一两个边缘像素根本看不出来,人眼对水平方向的瑕疵本来就比对垂直方向的瑕疵迟钝。竖线就不一样了,它的信息高度集中在一列像素上,这一列缺一点就是整根线的“断点”,下一帧补上、再下一帧又缺,看起来自然是闪的。

为了帮助理解,可以把它想成一座独木桥:横梁搭在两头,中间缺一两块板,一眼就能看到窟窿;换成一整条很宽的桥面,边缘掉了一小块砖,除非凑近了看,否则根本发现不了。竖线闪动就是“独木桥掉了板”,而LVGL的包围盒计算恰好漏了桥两端最关键的板子。

在我手上的8.3.0源码里,lv_draw_sw_line处理完包围盒之后,一旦线段接近垂直,后续的填充流程会进一步对旋转后的坐标做取整,取整误差叠加这个包围盒缺口,竖线的上下端更容易出现AA像素缺失。这也是为什么把线宽改成2、3像素后闪动更明显——宽线需要的AA边界更多,被漏掉的像素也就更多。

3.3 lv_chart 里还有个“帮凶”:值没变就提前return

软件渲染器的包围盒问题是竖线闪动的底层原因,但真正让闪动在动态折线图里高频触发,还有另一个“帮凶”,藏在lvgl/src/widgets/chart/lv_chart.c的lv_chart_set_value_by_id2函数里。

这段代码大概长这样:

/* lv_chart_set_value_by_id2() 内部,数据赋值前有一个“值没变就不重绘”的优化 */ if(value == points[id]) { return; } points[id] = value; lv_obj_invalidate(obj);

看起来很合理:数值没变,就没有必要触发重绘,节省CPU。但放在实时刷新场景里,这个优化会让你踩坑。

想想动态波形的实际运行情况:数据以固定间隔来一包,大部分时候前后两次的值不一样,所以chart会照常invalidate、照常重绘。但如果某次采集到的值和上一次恰好相同,或者数据源短暂卡顿、连续两帧拿到同一个值,这一列就不会被invalidate。如果此时屏幕恰好因为其他原因(比如图表整体左移、或者部分渲染的某个带被其他组件触发重绘)刷新了相邻区域,就会出现一个问题:旁边几列都画了新帧的内容,唯独这一列保留着旧帧的竖线残影,或者干脆没被覆盖到。两帧一对比,这列竖线就成了闪动源。

实时波形场景里,波形平台期(数值长时间不变)是非常常见的状态,这个优化在这种状态下几乎是主动制造竖线闪烁。渲染器bug是“画不全”,chart优化是“不画”,一个管底层、一个管触发,加在一起,竖线闪动就变成了必然。

4. 8.3版本源码修改:两个Patch,从根上解决

4.1 Patch 1:给 lv_draw_sw_line 的包围盒补上AA余量

第一个修改点在lvgl/src/draw/sw/lv_draw_sw.c的lv_draw_sw_line函数,给包围盒上下左右各扩展2个像素,把抗锯齿的过渡像素空间完整包进来。这样无论软件渲染还是部分渲染,竖直线的AA像素都不会落在包围盒外面。

/* 修改后 */ lv_area_t line_barea; const int32_t aa_margin = 2; /* 把抗锯齿的边界余量算进去 */ line_barea.x1 = LV_MIN(point1->x, point2->x) - dsc->width / 2 - aa_margin; line_barea.x2 = LV_MAX(point1->x, point2->x) + dsc->width / 2 + aa_margin; line_barea.y1 = LV_MIN(point1->y, point2->y) - dsc->width / 2 - aa_margin; line_barea.y2 = LV_MAX(point1->y, point2->y) + dsc->width / 2 + aa_margin;

我建议扩展量直接用常数aa_margin = 2,不要用宏或配置项,因为这是固定几何需求。2个像素基本能覆盖常见的1px、2px、3px线宽下的AA扩散范围。如果你用的是更粗的线,或者明显看到竖线末尾还有轻微断点,可以把aa_margin改成3,自己实测一下就行。

改完之后不是简单看一眼画面正常就完事,我建议做一次对照验证:用一个已知会触发闪动的最小复现工程,改patch前后各跑30分钟,用手机慢动作录像,确认每次数据大幅跳变时,竖线都能完整画满整列,没有再出现断帧。我这边实测的结果是,patch后原来必现的闪动完全消失,慢动作录像也找不到任何一帧竖线缺失的情况。

4.2 Patch 2:让 lv_chart 在值不变时也触发重绘

第二个修改点在lvgl/src/widgets/chart/lv_chart.c的lv_chart_set_value_by_id2函数,调整那个“值没变就提前return”的优化分支。不要直接删掉这个优化,而是让它在动态刷新场景下至少触发一次invalidate,保证对应区域重新绘制过。

/* 修改后 */ if(value == points[id]) { /* 动态折线图实时刷新场景下,即使数值没有变化, * 也要让对应区域进入重绘队列,避免旧帧竖线残留 */ lv_obj_invalidate(obj); return; } points[id] = value; lv_obj_invalidate(obj);

这个改动对静态图表几乎没有影响:静态图表本来就不会反复调用set_value,不存在性能损失。对动态波形来说,代价是每次采样即使数值没变也会多一次chart区域的invalidate,但这属于必要开销,为了画面连贯是值得的。

4.3 修改后怎么验证

两个patch改完后,最稳妥的验证路径我列一下:

  1. 用第一节的最小复现工程重新编译,确认能正常通过。
  2. 默认条件下跑30分钟,肉眼观察右侧新数据列是否还有竖线闪动。
  3. 把随机数范围改成极端值(比如0~200),人为制造大量垂直竖线,连续跑1小时。
  4. 打开部分渲染模式,VDB调到屏幕的1/4,再做一轮慢动作录像确认。
  5. 跑一个实际业务场景,把真实传感器数据接上,确认波形刷新流畅度没有肉眼可见下降。

我这边完整跑下来,竖线闪动彻底消失,波形刷新流畅度也没有可感知的影响。如果你的屏幕分辨率很高、刷新频率又拉满,建议再顺手统计一下CPU占用,方法是在lv_timer_handler前后加时间戳,对比patch前后的单帧耗时差距。

4.4 性能影响评估

讲完了验证流程,聊点大家最关心的性能问题。

第一个patch的影响是每绘制一条线段,包围盒多出4个方向、各2个像素的遍历范围。对一条接近垂直的细线来说,最坏情况下遍历面积会增加大约4 * (height + width) + 16个像素,换算成实际渲染,也就是多处理几十到几百个像素的判断。对软件渲染器来说这个开销非常小,我在STM32F429、480x272分辨率、VDB为屏幕1/10的配置下实测,patch前后单帧渲染耗时增加不到2%,而且大部分增加发生在动态折线图频繁刷新的场景里。

第二个patch的影响是每次lv_chart_set_next_value2都多一次invalidate调用,即使数值没变。invalidate本身只是把一个区域标记为“脏”,真正重新渲染的代价要看下一帧的刷新范围。如果chart本来就在实时刷新,这个额外的invalidate通常会被合并到已经存在的无效区域里,实际增加的开销很小。只有在波形处于长时间平台期、但采样还在持续进行时,才会出现“数值不变但图表区域持续重绘”的情况,一般来说CPU占比依然在可接受范围内。

从工程角度说,这两个patch换来的视觉收益远大于性能代价。如果实在对性能敏感,也可以做一个取巧方案:给chart对象挂一个标志位,只有动态波形对象才走强制invalidate分支,其他静态chart保持原来的提前return逻辑。这个用lv_obj_set_user_data或者一个静态变量就能实现,代码量不大。

5. 改完源码还要记住的几件事

源码改完不代表可以放飞自我,有几个使用层面的问题如果不注意,竖线闪动随时可能换一种形式卷土重来。

5.1 刷新周期、部分渲染与数据更新频率的关系

LVGL默认显示刷新周期是30ms,也就是说屏幕每30ms才会真正重绘一次。假如你的数据采集timer是10ms触发一次,那么在一个刷新周期里你喂给chart的3次数据只有最后一次能被画到屏幕上去,前两次的invalidate会被合并掉。这个机制本身没问题,但很多人会把“数据更新频率”和“屏幕刷新频率”搞混,以为数据到了就画了,结果发现波形有跳变,还以为是图表bug。

如果你真的需要高刷新率波形,应该先调LV_DISP_DEF_REFR_PERIOD,把它从默认的30ms改成和采样周期匹配的值,比如5ms或10ms,然后再看CPU是否扛得住。另外,部分渲染模式切分渲染带时,即使有了前面的patch,也只是把竖线断帧概率压到肉眼不可见,并不能从数学上保证每个渲染带边界处像素和相邻带的AA像素完全一致。大屏项目如果对画面稳定性要求极高,终极方案依然是尽量把VDB做大,或者干脆整屏单缓冲刷新。

5.2 FreeRTOS任务划分:别让 lv_timer_handler 饿死

不少人在STM32+FreeRTOS上跑LVGL,竖线闪动还可能来自任务调度问题。lv_timer_handler负责处理所有LVGL的定时器、invalidate和重绘,它必须周期性执行。如果你把数据采集放在高优先级任务里,而采集任务里又做了大量计算或阻塞操作,lv_timer_handler可能被长时间抢占,导致invalidate累积。等它终于有机会执行时,一次性要重绘的区域变得非常大,画面反而会卡一下、闪一下。

我的建议是:lv_timer_handler放在一个中等优先级任务里,周期设置为5ms左右;数据采集在中断或高优先级任务中完成后,通过队列或信号量把新数据转发给LVGL任务处理,不要在中断上下文里直接调用lv_chart_set_next_value2。这样可以保证绘图线程有稳定、连续的执行窗口,配合前面的patch,竖线闪动才算是真正根治。

5.3 不想改源码的应用层规避方案

如果因为某些原因不方便改LVGL源码(比如引用的第三方库里,或者团队禁止维护本地patch),下面几个应用层方案也能把竖线闪动压到很低,但要注意它们都是“缓解”不是“根治”。

第一种是数据平滑。在喂给chart之前先做一次均值滤波或滑动平均,限制相邻两个数据点的垂直落差。竖线闪动的视觉强度与垂直落差强相关,落差小了,闪动自然弱。代价是高幅值高频信号会被平滑掉一部分细节,用在传感器波形上问题不大,用在音频波形上就不行。

第二种是手动扩大chart的失效区域。每次更新完数据后,不依赖chart自身的invalidate,而是手动构造一个比chart尺寸大4px的矩形区域调用lv_obj_invalidate或者lv_area_t版本的接口。这等于把局部重绘的范围往外扩了一圈,和patch1的思路类似,只是落到应用层。

第三种是改用自绘widget,用lv_canvas或自定义draw event自己管理像素缓冲区。这种方案灵活度最高、可控性最强,但也意味着图表交互、坐标轴、网格线、缩放这些都得自己实现,工程量明显更大。如果只是做一个固定样式的波形显示,还比较划算;如果要长期维护一个功能丰富的图表组件,还是建议直接改源码。

5.4 升级到LVGL 9.x前,先跑一次回归测试

LVGL 9.x重构了渲染管线,lv_draw_sw_line的实现有较大变化,底层绘制后端也换了新的抽象层。这个竖线闪动问题在9.x里是否还存在、是否以新的形式出现,我不能拍胸脯保证,因为不同后端的包围盒策略和AA策略不一样。

如果你打算从8.3升级到9.x,建议先把手头的最小复现工程编译到9.x,跑一轮同样的极端数据测试,确认同样的场景下不会再出现竖线缺失或闪动。同时留意一下9.x里chart组件的API变化,lv_chart_set_next_value2这类接口的语义有没有调整,再决定是否需要把patch移植过去。

最后再分享一个小技巧。处理这类细微闪烁问题时,用肉眼直接盯屏幕很容易被“疑似好了”骗过去。我后来养成一个习惯:遇到任何和渲染有关的闪动、拖影、撕裂问题,第一件事就是打开手机慢动作录像,拍个几秒钟,然后一帧一帧回看。闪烁的东西不会每次都以同样强度出现,慢动作能帮你精确到“哪一列像素、在哪些帧里缺了”,这个信息比任何猜测都值钱。这次竖线闪动能定位到包围盒,靠的就是录像里发现“缺的正好是新数据点所在的那一列”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询