ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ESP32上LVGL图表控件lv_chart实战:从曲线绘制到内存调优

ESP32上LVGL图表控件lv_chart实战:从曲线绘制到内存调优 简介面向物联网嵌入式开发者的ESP32实战例程聚焦LVGL开源图形库中图表控件lv_chart的使用基于Visual Studio Code ESP-IDF环境以C语言编写适用于在ESP32-S3上快速搭建仪表盘、曲线图等数据可视化界面也可为其他型号ESP32开发者提供移植参考。压缩包共1215个文件大小23.48MB除核心的466个C源码与222个头文件外还包含150个Python脚本、135个Markdown文档、图片、字体及构建配置文件等覆盖完整工程结构便于阅读、编译与二次开发。代码中已预留传感器/模块的接线定义与详细注释并附带演示图像与字体素材可直接观察图表控件在不同场景下的显示效果包内还有操作指南和配套例程入口并配有MP4演示辅助理解整体流程适合物联网项目开发、毕业设计或GUI学习。目前已有139人浏览学习适合需要快速上手LVGL的嵌入式开发者。1. 为什么ESP32屏上画数据曲线最容易被lv_chart卡住ESP32接上屏幕跑LVGL之后很多人第一反应是用lv_label不断刷新数字来展示传感器值等到想画一条温度历史曲线、ADC波形或者电量走势才意识到LVGL里真正用于数据可视化的控件是lv_chart。它从布局到样式都和普通控件没什么区别但真去改例程时才会明白几个关键的坑点数是创建时定死的不是写多少自动扩多少更新模式有SHIFT和CIRCULAR两种选错表现完全不一样坐标轴范围设错曲线画出来只剩一条斜线甚至空白。这篇按“原理 → 最小实现 → 传感器实时曲线 → 触摸交互 → 分辨率与内存调优”的顺序把lv_chart在ESP32开发板上从能显示到好用这段路拆开讲清。适合刚把LVGL跑通的物联网、嵌入式工程师也适合从stm32 LVGL方案迁移到ESP32的人参考。2. lv_chart的坐标模型、数据模型与LVGL版本差异2.1 在LVGL控件树中先定位lv_chartlv_chart在LVGL里并不是一个独立于体系的特殊控件它继承自lv_obj和lv_label、lv_btn一样挂在对象树上。创建时用lv_chart_create(parent)parent可以是屏幕、容器或某个面板。它在LVGL 8.x里位于lvgl/src/extra/widgets/chart在LVGL 9.x里被移到了lvgl/src/widgets/chart但这个位置变化不影响使用时对API的理解。lv_chart的真正特别之处在于它内部维护了一组“点集”并把这些点集绘制成折线、柱状图或散点图。它没有像Qt里的QCustomPlot那样把数据单独建模成数据容器而是把数据和绘图直接绑定序列series就是一条曲线给序列塞数据序列立刻变成待重绘区域的内容。数据什么时候提交、以什么方式提交决定了曲线是“实时滚动”还是“整段刷新”。从项目组织角度看在嵌入式开发例程里引入lv_chart的正确时机是UI已经能稳定刷出主界面剩余内存还有余量并且确实需要把连续数据可视化。如果只想显示当前温度数值lv_label足够了。lv_chart适合的是趋势、波形、历史轨迹这一类有时间维度的展示。2.2 序列与点集LVGL不帮你动态扩容每个序列对应一个lv_chart_series_t结构内部有一块连续内存用于存放每个点的y值。创建序列后LVGL根据lv_chart_set_point_count设定的数量一次性分配内存。这块内存不会随着数据增多而增长写入超出点数量的数据时行为由更新模式决定而不是自动扩容。这一点在实际开发中的影响非常直白创建时把point_count设成10后面想画60个点必须重建chart或重新设置point_count后再设置数据。因此我在项目里通常先把预期显示的数据量固定下来用宏定义写成#define CHART_POINT_CNT 160 #define CHART_SERIES_NUM 2然后创建chart和序列lv_obj_t *chart lv_chart_create(lv_scr_act()); lv_chart_set_point_count(chart, CHART_POINT_CNT); lv_chart_series_t *s1 lv_chart_add_series(chart, lv_color_hex(0x2F9BFF), LV_CHART_AXIS_PRIMARY_Y); lv_chart_series_t *s2 lv_chart_add_series(chart, lv_color_hex(0xFF5A5A), LV_CHART_AXIS_PRIMARY_Y);这里能明显看到内存开销的线性关系内存占用等于点数乘以序列数再乘以每个点占用的字节数。在LVGL 8.x中lv_coord_t默认是int16_t一个点占2字节如果移植时修改了LV_COORD_TYPE为int32这个值会翻倍。计算一下160点乘以2个序列在默认配置下只要640字节在MCU上完全可以接受但如果点数加到1000且序列增加到8个就是16KB这时候就要认真考虑内存策略了。2.3 坐标轴到底谁负责X轴是索引Y轴才是数据值lv_chart的坐标模型和很多桌面图形库不一样它没有真正的X轴数值映射。X轴方向上是按点的索引平均分布也就是说第一个点在最左边最后一个点在最右边中间按等距摆放。点的x坐标不会因为时间戳、频率而改变间距想要体现不均匀的时间间隔只能在数据采集端做插值或归一化。Y轴才是真数据轴。lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, min, max)设置了Y轴的数据上下限绘制时负责把数据值线性映射到绘图区的像素范围。假设绘图区高度为p_hY轴范围min到max那么数据值v在绘图区内的y坐标是pad_top (max - v) * p_h / (max - min)这个公式说明了三个易错点范围设成min等于max分母为0曲线会异常或空白范围设置远大于真实数据曲线会被压成一条水平线比如ADC满量程4095但范围设成0到10000曲线看着就“变矮”了padding区域过小曲线峰值会贴到chart边框影响观感。实际项目中我一般会把Y轴范围设成略大于传感器物理量程比如NTC温度表显示0到100摄氏度再留一点余量ADC采集则设成0到4095避免裁剪。2.4 LVGL 8.x与9.x在lv_chart上的接口变化LVGL版本切换是接手新项目时最容易踩雷的地方。lv_chart在LVGL 8.x和9.x间接口基本兼容但有几处改动值得列出来移植例程时逐条确认能省不少调试时间。操作LVGL 8.x写法LVGL 9.x注意点创建chartlv_chart_create(parent)相同设置类型lv_chart_set_type(chart, LV_CHART_TYPE_LINE)相同设置点数lv_chart_set_point_count(chart, cnt)相同设置Y轴范围lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, min, max)函数存在内部坐标结构有调整添加序列lv_chart_add_series(chart, color, LV_CHART_AXIS_PRIMARY_Y)9.x之后axis参数被移除直接lv_chart_add_series(chart, color)写入数据lv_chart_set_next_value / set_value_by_id函数名相同从8.x升级到9.x时最明显的处理是lv_chart_add_series的第三个参数没有了y轴绑定改由lv_chart_set_range内部的axis参数控制。此外9.x对内部结构体做了一定程度的重命名项目里如果直接访问了lv_chart_series_t的成员需要重新检查字段名。稳妥的建议是升级后只调用官方头文件导出的函数不要访问结构体内部字段。3. 在ESP32上跑通lv_chart最小曲线例程3.1 创建画布、添加序列的完整初始化这一节按LVGL 8.x的API写9.x用户注意上面表格里的差异即可。创建一个320x240分辨率下的基础折线图初始化代码可以放在UI创建函数里/* 创建图表并设置尺寸 */ lv_obj_t *chart lv_chart_create(lv_scr_act()); lv_obj_set_size(chart, 280, 180); lv_obj_center(chart); /* 折线图类型点数60Y轴范围为0~4095 */ lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, 60); lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, 0, 4095); /* 给绘图区留出边距防止曲线贴边 */ lv_obj_set_style_pad_all(chart, 8, LV_PART_MAIN); /* 添加一条蓝色序列 */ lv_chart_series_t *series lv_chart_add_series(chart, lv_color_hex(0x2F9BFF), LV_CHART_AXIS_PRIMARY_Y); /* 隐藏不必要的刻度分区先保证主曲线干净 */ lv_chart_set_div_line_count(chart, 0, 0);初始化时几个参数值得单独说明。lv_chart_set_type只接受三种类型LV_CHART_TYPE_NONE表示不绘制点也不连线一般用作纯自定义绘制LV_CHART_TYPE_LINE是折线也是默认最常用的LV_CHART_TYPE_BAR是柱状图适合做离散的统计对比。lv_obj_set_style_pad_all这里给LV_PART_MAIN加了8像素边距没有这个边距曲线峰值会被chart的border裁剪掉一部分。lv_chart_set_div_line_count把横向和纵向的分隔线去掉UI更干净需要网格时再重新设置。3.2 三个写数据函数怎么选lv_chart的数据写入接口是很多人容易混淆的地方它们适用场景不同改错地方表现也不同。按使用频率排序一次说清/* 方式一推进式写入新数据放在最右边旧数据左移 */ lv_chart_set_next_value(chart, series, new_value); /* 方式二按索引修改指定点的值 */ lv_chart_set_value_by_id(chart, series, 10, new_value); /* 方式三把某个序列所有点一次性设为同一个值 */ lv_chart_set_all_value(chart, series, 0);lv_chart_set_next_value主要用于实时流数据每调用一次点集就像传送带一样往左移一格最右侧是刚进来的新值最左侧的数据被顶出正好适合“最近60个点”这种窗口场景。lv_chart_set_value_by_id用于修改已经存在的点比如返回历史数据修正、触摸选中后改值不改变整体排列。lv_chart_set_all_value则多用于清空数据或者所有点统一初始值。这里还需要补充一个设置参数更新模式。lv_chart_set_update_mode有两个可选值默认SHIFT模式配合set_next_value使用效果等同于滚动窗口CIRCULAR模式配合set_next_value则是原地循环覆盖绘制起始点会变化。用SHIFT做监视窗口用CIRCULAR做高速采样存储后面第4章再展开。3.3 用lv_timer驱动一条正弦波要验证lv_chart链路是否通最快的方法是生成一组模拟数据。LVGL自带三角函数lv_trigo_sin单位不是角度而是0到3600对应0度到360度返回范围是-1000到1000。基于它生成正弦波放到lv_timer回调里刷新chart就能看到一个连续滚动的曲线。static lv_chart_series_t *demo_series; static void chart_timer_cb(lv_timer_t *timer) { static int32_t phase 0; int32_t sin_val lv_trigo_sin(phase); int16_t value (sin_val 1000) / 2; /* 转换到0~1000 */ lv_chart_set_next_value(lv_timer_get_user_data(timer), demo_series, value); phase 10; if (phase 3600) { phase - 3600; } } void chart_demo_create(lv_obj_t *parent) { lv_obj_t *chart lv_chart_create(parent); lv_obj_set_size(chart, 280, 180); lv_obj_center(chart); lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, 60); lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, 0, 1000); lv_chart_set_update_mode(chart, LV_CHART_UPDATE_MODE_SHIFT); lv_obj_set_style_pad_all(chart, 8, LV_PART_MAIN); demo_series lv_chart_add_series(chart, lv_color_hex(0x00BFFF), LV_CHART_AXIS_PRIMARY_Y); lv_chart_set_div_line_count(chart, 4, 8); /* 100ms周期更新一次约10帧每秒人眼观察足够流畅 */ lv_timer_create(chart_timer_cb, 100, chart); }代码中lv_timer_create的第三个参数会作为user_data传入回调这里把chart对象传进去回调里用lv_timer_get_user_data读回来比用全局指针更干净。lv_timer的周期设为100ms原因是屏幕刷新本身也就60Hz左右10Hz的数据更新在视觉上已经比较连续更新过快反而会把LVGL的重绘时间拉长造成UI其他部分卡顿。3.4 曲线画出来是斜线或空白时的排查顺序第一次跑通这段代码常见的异常有四种按排查优先级列出来。曲线全空可能是point_count没有设置或者Y轴范围不对。检查lv_chart_set_range中min和max是否相等范围是否覆盖数据值。曲线是一条斜线从左上角画到右下角说明数据被当成了“点的索引当前值”以外的东西通常是set_next_value里有某个点被写成了异常大或异常小的值把整体范围拉爆了。这时把range临时扩大观察曲线形状或者改回set_all_value先清成常量看曲线是否为直线能定位是数据问题还是控件问题。曲线只在chart中心一小块区域波动是range设置过大的典型表现比如实际数据量程只有1000却把range设成10000。按实际传感器量程收窄范围即可。曲线锯齿感明显这是LVGL折线不做抗锯齿导致硬件能力有限可以接受。想要更平滑可以增大point_count让相邻点之间像素距离变小。4. 把实时传感器数据送上曲线任务调度与更新模式4.1 ADC采集到lv_chart之间的时序缝隙把正弦波换成真实数据最典型的是ESP32内置ADC采集。ESP-IDF里推荐用adc_oneshot接口初始化后读取原始值adc_oneshot_unit_handle_t adc1_handle; void adc_init(void) { adc_oneshot_unit_init_cfg_t unit_cfg { .unit_id ADC_UNIT_1, .ulp_mode ADC_ULP_MODE_DISABLE, }; adc_oneshot_new_unit(unit_cfg, adc1_handle); adc_oneshot_chan_cfg_t chan_cfg { .atten ADC_ATTEN_DB_12, .bitwidth ADC_BITWIDTH_12, }; adc_oneshot_config_channel(adc1_handle, EXAMPLE_ADC1_CHAN0, chan_cfg); } static int read_adc_raw(void) { int raw 0; ESP_ERROR_CHECK(adc_oneshot_read(adc1_handle, EXAMPLE_ADC1_CHAN0, raw)); return raw; }ADC初始化中atten决定输入电压范围ADC_ATTEN_DB_12对应约0到2.5V的测量范围bitwidth设为12位返回值落在0到4095正好匹配上一章的Y轴range。这里有个容易忽略的点adc_oneshot_read是阻塞采样转换时间在几十微秒级别如果直接在lv_timer回调里读取没问题但在需要高采样率的场景下就要考虑采集和UI刷新解耦。lv_chart_set_next_value本身只是往内存里写一个数重绘由LVGL事件的脏标记机制触发下一次lv_timer_handler遍历就会刷新对应区域。所以从ADC读一个数到图表上出现一个点存在一到两个调度周期的延迟这在嵌入式UI场景里通常可以忽略。4.2 FreeRTOS任务里别直接调用lv_chartLVGL 8.x默认不是线程安全的lv_timer_handler、lv_chart_set_next_value这类调用必须都在同一个UI任务上下文里执行。如果采样逻辑放在独立FreeRTOS任务直接在任务里调lv_chart_set_next_value轻则数据偶然错乱、重启重则内存访问冲突导致hardfault。常见做法是生产者消费者模型采集任务只负责把数据写进环形队列UI任务的lv_timer回调里批量读取并写入chart。Espressif的示例里常直接使用xRingbuffer但自写一个带读写索引的环形数组更直观单生产者单消费者场景不需要加锁#define RB_SIZE 256 static int32_t rb_buf[RB_SIZE]; static volatile uint16_t rb_head 0, rb_tail 0; static inline void rb_write(int32_t v) { uint16_t next (rb_head 1) % RB_SIZE; if (next ! rb_tail) { /* 满则丢弃 */ rb_buf[rb_head] v; rb_head next; } } static inline int rb_read(int32_t *out) { if (rb_head rb_tail) return 0; *out rb_buf[rb_tail]; rb_tail (rb_tail 1) % RB_SIZE; return 1; }采集任务里读ADC后写队列void sampler_task(void *arg) { while (1) { int raw read_adc_raw(); rb_write(raw); vTaskDelay(pdMS_TO_TICKS(50)); } }UI定时器回调里消费队列static void ui_chart_cb(lv_timer_t *timer) { int32_t raw; while (rb_read(raw)) { lv_chart_set_next_value(chart, series, raw); } }这样lv_chart的所有API调用都收敛在UI上下文里。esp32s3这类双核芯片会把LVGL任务和采集任务分核运行队列机制仍然有效因为真正重要的是UI上下文外的“隔离”不是核心数量。4.3 SHIFT与CIRCULAR两种更新模式的取舍lv_chart_set_update_mode只在配合lv_chart_set_next_value时发挥作用。两种模式语义差异很大选错了曲线表现和预期完全对不上。更新模式数据写入行为性能适用场景LV_CHART_UPDATE_MODE_SHIFT所有点左移一位新点写到最右每次写入要搬运整段点数据时间窗口监视比如温度历史走势LV_CHART_UPDATE_MODE_CIRCULAR写指针循环覆盖旧点只写一个位置无搬运持续高速采样比如音频波形预览SHIFT模式最符合“示波器滚动窗口”的直觉新数据从右边出来左边旧数据逐渐退出。代价是每次set_next_value都要做一次逐点内存移动在点数很大时会有可感知的开销。CIRCULAR模式通过环形缓冲区避免搬运但视觉上不是左移滚动而是曲线从头开始画画满后回到左端从旧点开始覆盖更像一个循环缓冲区在时间轴上的回放。实际开发中监视传感器趋势用SHIFT采集高速数据并用页面切换查看历史段时用CIRCULAR不要因为CIRCULAR性能好就盲目选它。4.4 多序列曲线的内存开销一块chart上可以挂多个序列每个序列被点集数量乘出来的一份内存。创建多条序列时CPU开销还有额外部分LVGL每帧都要重新遍历所有序列并重绘。ESP32在320x240分辨率下跑4条序列、每序列120点绘制时间还能接受到800x480时点数翻倍序列再翻倍单帧绘制时间可能从10ms涨到40ms直接掉帧。多序列场景下先把共用的点数量降下来。屏幕横向像素是有限的横轴每个点至少占1到2个像素才有区分度320像素宽的屏幕放240个点已经接近分辨极限。点数设得太高屏幕上多条曲线会粘成一片对数据解读毫无帮助。按这个逻辑推导出的经验值在最后一章给出。5. 让曲线可交互光标、触摸定位与缩放5.1 添加光标并进行触摸点映射lv_chart自带光标功能lv_chart_add_cursor会生成一条十字线或垂直线挂在某个方向上。要让它跟着触摸点走需要先把触摸坐标换算成数据点索引。static lv_chart_cursor_t *cursor; void chart_enable_cursor(lv_obj_t *chart) { cursor lv_chart_add_cursor(chart, lv_color_hex(0xFFFFFF), LV_CHART_CURSOR_RIGHT); lv_chart_set_cursor_point(chart, cursor, (lv_point_t){0, 0}); } static void chart_press_cb(lv_event_t *e) { lv_obj_t *chart lv_event_get_target(e); lv_indev_t *indev lv_indev_get_act(); if (indev NULL) return; lv_point_t tp; lv_indev_get_point(indev, tp); /* 把触摸点换算为数据索引 */ lv_coord_t pl lv_obj_get_style_pad_left(chart, LV_PART_MAIN); lv_coord_t pr lv_obj_get_style_pad_right(chart, LV_PART_MAIN); lv_coord_t pw lv_obj_get_width(chart) - pl - pr; int32_t cnt lv_chart_get_point_count(chart); int32_t idx (tp.x - pl) * cnt / pw; if (idx 0) idx 0; if (idx cnt) idx cnt - 1; lv_chart_set_cursor_point(chart, cursor, (lv_point_t){idx, 0}); }lv_chart_set_cursor_point里的lv_point_t参数是数据空间坐标x是点的索引y在cursor只关心横向时要传0。把lv_chart加LV_EVENT_PRESSING事件用lv_indev_get_act拿到当前输入设备lv_indev_get_point读到的就是屏幕像素坐标。换算取的padding来自LV_PART_MAIN的pad_left和pad_right保证触摸位置和画图区对齐。5.2 缩放和平移图表内容区域LVGL给lv_chart提供了放大能力lv_chart_set_zoom_x和lv_chart_set_zoom_y的基准值是100数值越大放大倍数越高。放大到200后曲线绘制区域相对chart尺寸被放大用户想看其他部分需要平移平移通过scroll机制完成lv_chart_set_zoom_x(chart, 200); lv_chart_set_zoom_y(chart, 200); lv_obj_set_scroll_x(chart, 120); lv_obj_set_scroll_y(chart, 60);实现双指捏合缩放时识别出两点距离变化后按比例调整zoom值并限制下界100、上界400避免缩放过小导致曲线被压缩到无法辨认也避免过大把绘制范围扩大到超出屏幕可读范围。注意lv_obj_set_scroll_x设置的是内容区域相对滚动的偏移数值上限受内容区实际尺寸约束传一个超界值会被LVGL裁剪。5.3 选中数据点后回显值的组合写法交互的最终目的是让用户看到某个点的具体数据值。结合上面的光标索引读取对应序列的值并更新到一个label完整链路如下lv_coord_t v lv_chart_get_value_by_id(chart, series, idx); lv_label_set_text_fmt(value_label, ADC: %d, v);这里lv_chart_get_value_by_id返回的是该序列在指定索引位置的y值配合第4章的ADC采集就是用户点一下曲线旁边显示这个点当时的ADC码值。再往上一步可以从ADC码反推电压或温度显示格式化后的物理量。整套交互从触摸换算到索引、从索引取值、再刷新label事件处理时间只有几个微秒不会拖累UI帧率。6. 分辨率适配与内存调优里的三个确定参数6.1 point_count不要按“感觉”设point_count的合理值不是越大越精细它应该取决于屏幕横向像素。曲线每个点至少占用一个像素超过横向分辨率后只能在同一列重复绘制白白消耗MCU算力和内存。我一般用这个经验式uint16_t points lv_disp_get_hor_res(NULL) / 2;320像素宽屏幕取160点800像素宽屏幕取400点。如果数据本身采样率很高比如每秒10个点point_count取160意味着只能显示最近16秒需要更长的时间窗就把系数从2改成4curve分辨率会换取更长的观察区间。6.2 从320×240换到800×480要动的几个值屏幕变大后lv_chart对象尺寸跟着变大但曲线相关参数不会自动缩放。检查这几个值的逻辑是padding按屏幕宽度比例增加曲线线宽按分辨率比例增加div_line的横向密度保持视觉一致。参数320×240800×480chart宽度280760point_count160400pad_all816序列线宽23纵向div_line48表格里这些值按比例变化即可。另外注意lv_chart在LVGL里不会自动响应屏幕方向变化分辨率切到横屏后chart需要调用lv_obj_align重新对齐。6.3 一个可直接套用的适配片段void chart_apply_display_size(lv_obj_t *chart) { lv_coord_t hres lv_disp_get_hor_res(NULL); uint16_t points hres / 2; lv_chart_set_point_count(chart, points); lv_obj_set_style_pad_all(chart, hres / 40, LV_PART_MAIN); lv_obj_set_style_line_width(chart, hres / 160, LV_PART_MAIN); lv_chart_set_div_line_count(chart, 4, hres / 80); }hres是横向像素数hres/40在320屏上得到8800屏上得到20padding随分辨率增长画图区域在视觉上保持一致的呼吸感。lv_obj_set_style_line_width按hres/160计算320屏为2像素800屏为5像素曲线在高分屏上依然清晰可见。最后一个参数hres/80控制纵向网格密度320屏画4条竖网格800屏画10条不会出现高分辨率下网格过密的情况。如果你接下来要在ESP32-S3跑LVGL 9.x先确认lv_coord_t在当前工程里占几个字节再套用上面的point_count公式。坐标类型从int16变成int32后同样400点、4个序列的内存占用会从3.2KB涨到6.4KB这个差值在内存紧张的项目里足以决定曲线数据是留在本地还是换成更保守的方案。本文还有配套的精品资源点击获取
返回列表