
简介面向物联网与ESP32开发者的LVGL图形库实践例程聚焦图表控件lv_chart的调用与界面集成。代码基于Visual Studio Code与ESP-IDF环境编写采用C语言在ESP32-S3上运行模块接线在源文件中均有定义并附有注释说明便于按自身硬件调整移植。资源包共1215个文件约23.48MB以C语言源文件为主含466个c、222个h覆盖控件初始化、数据更新、坐标轴及样式配置等核心逻辑另有150个py脚本、135个md文档、57个png图片以及字体资源分别用于构建辅助、阅读说明与界面素材。已有139人学习下载。通过该例程可完整了解lv_chart在嵌入式屏幕上的实际用法结合图片与字体素材可直接迁移至项目界面开发压缩包目录结构清晰适合需要快速上手LVGL图表功能的ESP32开发人员参考学习。1. 从一段波形显示说起为什么嵌入式 UI 离不开 lv-chart做 ESP32-S3 物联网设备数据上屏的最后一公里往往比采集还折腾。温湿度、ADC 电压、传感器曲线这些数据如果只拿文本或仪表盘展示用户看不出趋势如果自己写绘图函数又要在 EVG 缓冲区里手推像素、处理坐标变换和局部刷新一个缩放操作就能把刷新率拖垮。LVGL 的 lv-chart 控件解决的就是这个矛盾它以官方组件的形式把数据序列、坐标轴、网格线和缩放逻辑封装好我们只需要把数据喂进去剩下的绘制交给控件内部处理。这个例程和网上那些 demo 最大的差异在于它把 lv-chart 用在真实的数据采集中而不是画一条固定死板的演示曲线。例程里带着 bmp 格式的位图素材和从 LVGL 官方 demo 提取的图片 C 数组这意味着它覆盖的不只是 chart 控件本身还包括图像数据如何与图表组合展示——这在温控器、电能监测、环境监测站的屏显程序里几乎都是刚需。这套代码基于 ESP-IDF 和 C 语言在 ESP32-S3 上跑通改改引脚和数据类型就能复用到自己的项目里。2. lv-chart 的数据模型序列、数据点和刷新机制2.1 从 lv_chart_series_t 理解它的存储结构lv-chart 在底层把图表拆成两部分负责坐标和背景的“图表本体”以及负责具体数值的“数据序列”。代码里我们用lv_chart_add_series()拿到一个lv_chart_series_t *指针后续所有数据更新都围绕这个指针来操作。lv_obj_t *chart lv_chart_create(lv_scr_act()); lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, 50); lv_chart_series_t *ser_temp lv_chart_add_series(chart, lv_palette_main(LV_PALETTE_BLUE), LV_CHART_AXIS_PRIMARY_Y); lv_chart_series_t *ser_humi lv_chart_add_series(chart, lv_palette_main(LV_PALETTE_RED), LV_CHART_AXIS_SECONDARY_Y);这段代码创建了一个折线图点数为 50并且挂上了两条序列。注意LV_CHART_AXIS_PRIMARY_Y和LV_CHART_AXIS_SECONDARY_Y的区别前者使用左侧 Y 轴后者使用右侧 Y 轴。如果两个传感器的量纲差别很大比如温度是 20 到 30湿度是 40 到 90共用一根 Y 轴会让小数值曲线在图表下半部分压成一条平线拆成主副轴就能各显示各的刻度。2.2 三种数据更新方式的性能取舍lv_chart_set_next_value()适合持续追加数据点它内部会维护一个游标位置数据满了自动回绕。lv_chart_set_value_by_id()则适合随机修改某个指定的点位。还有一个更底层的写法直接拿到序列的values数组指针用memcpy整批灌入数据适合一次刷新大量历史数据。static void update_chart_data(lv_chart_series_t *ser, int16_t *new_data, uint32_t len) { lv_chart_set_point_count(lv_chart_get_parent(ser), len); lv_coord_t *values lv_chart_get_y_array(ser); memcpy(values, new_data, len * sizeof(lv_coord_t)); lv_chart_refresh(lv_chart_get_parent(ser)); }用memcpy的方式要特别注意两点一是lv_coord_t在 ESP32-S3 上是 int16_t别拿 int 数组直接拷二是lv_chart_get_parent(ser)取回的是 chart 对象因为序列本身没有提供 refresh 接口。数据量大的时候这个方案比逐点调用 save 函数快得多但你必须自己保证数组长度一致否则就会踩到内存越界。实际上 LVGL 的 API 设计里values数组是对开发者暴露的这也意味着你可以把传感器 DMA 采集缓冲区直接映射进来省掉一次拷贝。2.3 数据缓冲区和 lv_chart_refresh 的关系lv_chart_set_point_count()会触发 chart 内部重新分配缓冲区这个操作是有开销的。频繁调整点数会导致内存碎片化FreeRTOS 的 heap 在长时间运行后可能出现大块连续内存不足的情况。所以我在项目里的做法是初始化时把point_count设成能容纳的最大值后面只通过修改数据来更新不反复调整点数。#define CHART_MAX_POINTS 100 lv_chart_set_point_count(chart, CHART_MAX_POINTS); int16_t *raw_data heap_caps_malloc(CHART_MAX_POINTS * sizeof(int16_t), MALLOC_CAP_8BIT);这里用MALLOC_CAP_8BIT主要是因为 ADC 采集数据缓存在 PSRAM 中的时候8-bit 可访问内存是必要条件。每次 DMA 传输完成后直接把新数据拷到 chart 的 values 数组里再调用一次lv_chart_refresh()触发重绘整体开销很小。正因为这种设计lv-chart 才能在资源有限的 MCU 上流畅绘制几十个点甚至更多而不拖垮帧率。3. 在 ESP-IDF 工程里落地一个 lv-chart 数据面板3.1 从零搭建带 lv-chart 的 LVGL 环境在 ESP32-S3 上用 ESP-IDF 做 LVGL 开发一般通过idf.py create-project建工程然后用idf.py add-dependency lvgl/lvgl拉取组件。在main/idf_component.yml里锁定版本号很重要LVGL 的 API 在不同 minor 版本之间会变比如lv_chart_set_next_value在 v8 和 v9 里的行为就有差异v8 系列比较成熟v9 系列性能更好但需要适配新的 API。dependencies: lvgl/lvgl: ^8.3.0工程结构建议把显示驱动和输入驱动拆开不要把初始化代码和业务逻辑混在一起。推荐这样的目录组织main/ ├── CMakeLists.txt ├── idf_component.yml ├── ui/ │ ├── ui_chart_screen.c │ ├── ui_chart_screen.h │ └── ui_assets.h └── main.cLVGL 的显示驱动接法在 v8 之后简化了只要实现disp_flush回调并设置好颜色格式即可。在 ESP32-S3 上如果用的 RGB LCD 屏接口lv_disp_draw_buf_init的缓冲区大小直接影响刷新率建议至少分配两个全屏大小我一般会先开两个 240x320 大小的缓冲区看看不行再压缩。3.2 传感器数据接入 lv-chart 的完整流程传感器数据采集和 LVGL 刷新要分开跑。ADC 采样放在专属的 FreeRTOS task 里高优先级运行通过队列把数据传给 UI task。LVGL 的lv_timer_handler()需要周期性调用一般做法是建一个lvgl_task每 5ms 做一次 tick 和 UI 刷新。下面是一个最基本的结构static void sensor_task(void *arg) { int16_t adc_buf[CHART_MAX_POINTS]; while (1) { // 读取ADC多通道或I2C传感器数据 adc_buf[adc_fill_index] esp_adc_read(); xQueueSend(data_queue, adc_buf[adc_fill_index], 0); vTaskDelay(pdMS_TO_TICKS(100)); } } static void lvgl_task(void *arg) { while (1) { lv_timer_handler(); vTaskDelay(pdMS_TO_TICKS(5)); } }其中esp_adc_read()是对 ADC 读取的封装具体用哪个通道取决于硬件接线ESP32-S3 的 ADC1 支持 GPIO1-GPIO10多通道之间切换需要留出采样稳定时间。xQueueSend如果队列满了就用 0 超时丢数据UI 绘制以最新数据为准宁可丢点到不了刷新这么频繁也千万别阻塞采样的 task不然采集周期会漂移。3.3 定时器驱动的自动滚动刷新实际项目里我一般会做一个独立的lv_timer负责数据源更新和lv_timer_handler区分开来。用 LVGL 自带的定时器有个额外的好处它跟 UI 跑同一个线程不需要担心数据竞态——不需要加锁因为整个 update 都在 LVGL 的上下文里执行。lv_timer_t *timer lv_timer_create(chart_data_update_timer, 100, NULL); static void chart_data_update_timer(lv_timer_t *timer) { static int16_t cnt 0; int16_t val read_sensor_value(); lv_chart_set_next_value(chart, ser_temp, val); lv_chart_set_next_value(chart, ser_humi, humidity_value); cnt; }这里的read_sensor_value()是模拟函数真实项目里它去队里取一次值或者从全局变量里读一下最新 AD 值。注意lv_timer_create的周期单位是毫秒最小可以到 10ms 左右如果数据超过 100Hz 就不要再靠纯软件定时器去刷了应该让 LVGL 通过lv_timer_pause()和lv_timer_resume()配合外部中断触发。4. 纵向滚动、缩放与静态动态模式选择4.1 LV_CHART_TYPE 的选型与实际差异LVGL 提供了LV_CHART_TYPE_NONE、LV_CHART_TYPE_LINE、LV_CHART_TYPE_BAR、LV_CHART_TYPE_SCATTER四种模式。很多新手不知道 NONE 模式存在的意义——其实它是最灵活的只绘制点和连线之外的原始数据方便你用自己的绘制函数做二次开发。static void chart_type_change_event_cb(lv_event_t *e) { lv_obj_t *chart lv_event_get_target(e); lv_chart_type_t types[] { LV_CHART_TYPE_NONE, LV_CHART_TYPE_LINE, LV_CHART_TYPE_BAR, LV_CHART_TYPE_SCATTER }; lv_chart_set_type(chart, types[sel_index]); }如果选择 SCATTER 模式注意它的数据存储方式不同x 和 y 坐标是配对存储的不能像 LINE 模式那样只喂一个值。BAR 模式下多个系列的柱子会并排显示假如你的两个传感器都是湿度只是测量不同位置那 BAR 模式加双序列非常直观。而 NONE 模式通常配合lv_chart_refresh()由外部自行绘制此时时序没有自动连线呈现的是独立的散点。4.2 Y 轴刻度范围和自动缩放配置lv-chart 默认会在你设置的值范围内自动调整 y 轴但自动缩放有副作用如果数据里有瞬间尖峰整个曲线的起伏会变小。此时你需要手动固定y_min和y_max。注意硬限制和软限制的区别——lv_chart_set_y_range()设置了硬边界数据超出就截断而LV_CHART_AXIS_DRAW_END_TICK这些标志只影响刻度线的绘制不影响数据范围。lv_chart_set_y_range(chart, LV_CHART_AXIS_PRIMARY_Y, 0, 100); lv_chart_set_y_range(chart, LV_CHART_AXIS_SECONDARY_Y, 0, 1000); lv_chart_set_axis_tick(chart, LV_CHART_AXIS_PRIMARY_Y, 5, 1, 5, 1, true, 50);上面代码里的 7 个参数含义依次是主刻度线长度 5 像素、次刻度线长度 1 像素、主刻度数量 5 个、次刻度数量 1 个、最后一位表示是否绘制刻度标签、50 是标签与轴线的距离。把 Y 轴拆成两个范围即便温度是 25 度、湿度是 60%两组曲线各自的波峰波谷都能占据合理的绘图区域不用担心被压扁。4.3 缩放与滑动的中断驱动方式lv-chart 的缩放是通过触摸屏手势触发的。在设备没有触摸屏的场景比如只接了几个按键你可以用按键事件来模拟缩放操作static void key_zoom_cb(lv_event_t *e) { lv_obj_t *chart lv_event_get_user_data(e); lv_chart_set_zoom_x(chart, lv_chart_get_zoom_x(chart) * 1.2); lv_chart_set_zoom_y(chart, lv_chart_get_zoom_y(chart) * 1.2); }注意这里zoom_x和zoom_y是放大倍率LVGL 内部把 1.0 编码为 256所以在做增量乘除时直接用原始值参与运算。缩放后如果没有把LV_CHART_UPDATE_MODE_SHIFT设成 shift 模式曲线是原地放大设置成 shift 模式则新数据进来时会整体左移产生类似示波器的滚动效果。这两种模式分别适合查看历史数据和持续监控实时趋势。5. 动态更新与极值标注让图表真正“动”起来5.1 复刻例程中的音乐频谱可视化逻辑例程里包含img_lv_demo_music_wave_bottom_large.c和img_lv_demo_music_wave_top_large.c这两个文件这是 LVGL 音乐播放器 demo 中的频谱底部/顶部装饰素材。这些素材展示了 lv-chart 的另一类典型用法数据点不来自传感器而是来自音频 FFT 或预生成的波形数组。在实际项目中我们可以用它来做音频可视化或电机振动分析。void music_wave_visualize(lv_obj_t *parent, int16_t *fft_magnitudes, uint8_t bins) { lv_obj_t *chart lv_chart_create(parent); lv_chart_set_type(chart, LV_CHART_TYPE_LINE); lv_chart_set_point_count(chart, bins); lv_chart_set_range(chart, LV_CHART_AXIS_PRIMARY_Y, 0, 255); lv_chart_series_t *ser lv_chart_add_series(chart, lv_palette_main(LV_PALETTE_GREEN), LV_CHART_AXIS_PRIMARY_Y); lv_coord_t *values lv_chart_get_y_array(ser); for (int i 0; i bins; i) { values[i] fft_magnitudes[i] * 2; } lv_chart_refresh(chart); }这里把 FFT 幅值乘以 2 是为了让数据映射到 0-255 的可视范围实际增益系数需要根据你的 ADC 参考电压和窗函数类型调整。如果发现曲线在顶部削波说明增益过高如果整体波动太微弱则说明数值范围只用了很少一部分可以加大缩放系数。注意lv_chart_set_range要调用在add_series之前否则数据写入时可能被旧的裁剪范围截断。5.2 极值高亮给图表加上“最值”标记单纯的曲线只能看到大致趋势无法看到最高点、最低点的具体数值。我通常会在 chart 上方叠加两个lv_label用LV_EVENT_VALUE_CHANGED事件来更新极值。这里有一个细节lv-chart 的LV_EVENT_VALUE_CHANGED只有在数据被真正设置的时候才会触发也就是说如果你用lv_chart_set_next_value每 add 一个点就会触发一次正好用于实时刷新极值标签。static void chart_event_cb(lv_event_t *e) { lv_obj_t *chart lv_event_get_target(e); lv_chart_series_t *ser lv_chart_get_series_next(chart, NULL); lv_coord_t *arr lv_chart_get_y_array(ser); int16_t max arr[0]; int16_t min arr[0]; for (uint16_t i 1; i lv_chart_get_point_count(chart); i) { if (arr[i] max) max arr[i]; if (arr[i] min) min arr[i]; } lv_label_set_text_fmt(max_label, MAX: %d, (int)max); lv_label_set_text_fmt(min_label, MIN: %d, (int)min); }每一次数据更新都遍历 100 个点找极值这个开销在 ESP32-S3 240MHz 主频下毫无压力。但如果你的点数超过 1000建议滑动窗口维护极值否则每帧都全量扫描会白白浪费 CPU——极端情况下会将 LVGL 刷新事件挤掉。也可以用lv_chart_set_div_line_count增强网格密度让但有渐变时由眼睛定位最高点网格足够密就可以用静态方式标注不一定非要动态更新标签。5.3 多序列共享坐标轴的数据对齐问题温度、湿度、PM2.5 三个序列挂在同一个图表上时它们的数据更新频率往往不一样。温度每 2 秒一个点而 PM2.5 可能要 10 秒才出一个值。比较稳妥的工程方案是设一个共同的 tick比如每 2 秒让三个序列都获得一个新值没有真实数据的序列就保持原值或插入空值保证时间轴对齐。if (pm25_ready) { lv_chart_set_next_value(chart, ser_pm, pm25_val); } else { lv_chart_set_next_value(chart, ser_pm, lv_chart_get_y_array(ser_pm)[lv_chart_get_point_count(chart) - 1]); }这种“补前一帧”的做法会带来一条平坦的直线段视觉上不会产生误导。如果想看到真正的“断点”那就要用LV_CHART_TYPE_NONE模式并且额外维护一个有效性掩码在绘图回调里跳过值为无效的数据点但这个方案复杂度较高一般场景不需要。6. 性能优化和常见死机坑ESP32-S3 上的实战排查6.1 chart 卡顿和闪烁的排查路径图表掉帧通常不是绘图算法的问题而是缓冲区配置和刷新策略的问题。在 ESP32-S3 上跑 320x240 全屏图表如果lv_disp_draw_buf_init只分配了一个缓冲区那么每次全屏刷新时都会出现撕裂感。这时候打开 LVGL 内部计时器把lv_timer_handler的执行时间打出来超过 30ms 就要考虑优化。uint32_t time_start esp_timer_get_time(); lv_timer_handler(); uint32_t time_cost (esp_timer_get_time() - time_start) / 1000; if (time_cost 20) { ESP_LOGW(UI, lv_timer_handler cost %lu ms, time_cost); }日志中time_cost高于 20ms 时优先检查一是是否意外开启了全屏抗锯齿效果二是是否在 chart 上叠加了太多透明背景的子控件三是point_count是否设置过大超过 1000 对 MCU 来说就是压力了。如果确认是绘制的数据点数过多可以采用降采样只保留 500 个点但要保证不含弃掉峰值——每隔 3 点取一个最大值保留这比简单均匀采样更保真。6.2 内存不足导致的随机重启与花屏lv-chart 的每个点默认占用 2 字节因为lv_coord_t是 int16_t100 个点就是 200 字节看似很小。但别忘了lv_chart_add_series会为每条序列独立分配数组如果你创建了 4 条序列、每条 500 点、还开启了阴影效果瞬间就可能吃进去 4KB 以上。在 ESP32-S3 上这不算大但如果你开了 PSRAM 且启用了CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LVGLLVGL 的 buffer 有可能会被分配到 PSRAM而 PSRAM 带宽比内部 SRAM 低很多最终导致刷新率骤降 30%-50%。heap 碎片化的排查办法很简单在空闲时跑一次heap_caps_get_free_size(MALLOC_CAP_8BIT)并连续记录十分钟如果单调递减且没有回升那就说明存在内存泄漏或碎片化。LVGL 的lv_mem_monitor也可以给出 LVGL 内部堆的使用情况lv_mem_monitor_t mem_mon; lv_mem_monitor(mem_mon); ESP_LOGI(MEM, used: %d, free: %d, frag: %d%%, mem_mon.total_size - mem_mon.free_size, mem_mon.free_size, mem_mon.frag_pct);这里frag_pct通常保持在 10% 以内是正常的如果超过 20%图表又是在反复add_series和删对象就要考虑是否频繁创建销毁控件了。在static上创建 UI 并始终复用资源远比反复lv_obj_clean再重建要稳定。6.3 常见的 lv-chart 使用误区和规避方法新手最容易犯的错误是在传感器采集 task 里直接调用lv_chart_set_next_value()然后发现系统崩溃或者图表偶尔不更新。原因是 LVGL 的 API 不是线程安全的所有对控件树和数据序列的修改必须发生在 LVGL task 的上下文中。正确做法是用队列做缓冲在lv_timer_handler同一线程里取数据并更新。第二个常见误区和lv_chart_refresh有关如果修改了values数组后忘了调用lv_chart_refresh()图表不会自动重绘。反过来如果在没有修改任何数据的情况下调用了refresh则会触发一次无意义的全量重绘消耗 CPU。所以要在数据变化和重绘之间建立严格的因果逻辑不要每帧都硬刷。还有一个容易忽视的问题用lv_chart_set_value_by_id时如果传的 id 大于point_count - 1会让序列数组越界严重时直接触发abort()。在 ESP-IDF 里可以用lv_chart_get_x_start_point(ser)来检查当前绘制起点确保数据写入处于合法范围内避免崩溃。6.4 把例程改造为自己的数据监控面板例程自带的 bmp 素材和音乐封面 C 数组可以直接替换成自己的 logo 和背景图。图片转换推荐用 LVGL 官方的在线图片转换工具把 PNG 转成 C 数组后放进ui_assets.h。用lv_img_set_src()加载图片时注意如果你的屏是 RGB565 格式素材也要导出为 RGB565否则颜色会错乱。LV_IMG_DECLARE(example_24bit); lv_obj_t *img lv_img_create(parent); lv_img_set_src(img, example_24bit); lv_obj_center(img);关于新数据接入有一点很实用ESP32-S3 自带温度传感器不需要外接模块可以直接用temprature_sensor_install()接口读取芯片内部的温度值喂给 lv-chart 画出一条 CPU 温度曲线。你甚至可以用两条序列分别显示芯片温度和 WiFi 信号强度观察发热与通信之间的相关性。最终效果就是通过这个例程把 lv-chart 的机制跑通然后自由组合传感器、图像和控件层级做出属于自己的物联网监控面板。本文还有配套的精品资源点击获取