1实验实物
esp32-s3
st7789-tn屏+电阻屏
2开发环境
esp-idf5.2
3目录结构

4ui目录结构
bash
你的工程/
└── main/
├── main.c ← 你写的(#include "ui.h" 后就能用 ui_btn_ok)
└── ui/ ← SquareLine Studio 导出的整个目录
├── ui.h ← ★ extern 声明全在这里("目录/清单")
├── ui.c ← ui_init():调各屏的 screen_init + 加载 Screen1
├── ui_helpers.h / .c ← 辅助函数(对齐、动画等工具)
├── ui_events.h / .c ← 只有在 Events 面板配过事件才有
└── screens/
├── ui_Screen1.c ← ★ 变量定义 + 创建控件的代码("实体")
└── ui_Screen2.c ← 每个屏幕一个文件
5个人理解
显示原理
lvgl:主要作用就是经行图形的绘画,将绘制好的图放入缓冲区。lvgl在调用外来函数控制硬件经行绘画
触摸原理
lvgl获取到到坐标,检查当前控件上是否有对应的坐标有即触发对应的事件,
6移植流程
bash
阶段0 硬件准备(纯ESP-IDF,与LVGL无关)
├─ SPI/RGB 总线初始化、背光GPIO
└─ 触摸侧:PENIRQ 引脚配置 + 中断 + 信号量 + 触摸任务(读XPT2046→写共享变量)
│
阶段1 注册显示(先把"画布"立起来)
├─ lv_init(esp_lvgl_port_init 内含)
├─ 创建显示设备 lv_display_create + 挂 flush_cb
└─ 心跳 lv_tick(esp_lvgl_port 已代接)
│
阶段2 搭 UI(往画布上摆东西)
└─ lv_obj / lv_label / lv_button / lv_slider ... 建在 active screen 上
│
阶段3 注册输入(把"手"接上)★必须在显示之后
├─ lv_indev_create
├─ lv_indev_set_type(..., LV_INDEV_TYPE_POINTER)
├─ lv_indev_set_display(..., disp) ← 坐标映射到屏幕,v9 必须有
└─ lv_indev_set_read_cb(..., touch_read_cb) ← 你只填这个函数体
│
阶段4 挂事件(谁被点了干什么)
└─ lv_obj_add_event_cb(btn, 回调, LV_EVENT_CLICKED, user_data)
│
阶段5 跑起来
└─ esp_lvgl_port 自带任务循环调 lv_timer_handler()(你不用写循环)
两版分阶段示例:版本A(port 套餐)+ 版本B(裸 LVGL)------LVGL 全写出,硬件注释带过
版本 A|ESP-IDF + esp_lvgl_port(有 port,库替你干三件事)
阶段 0|硬件准备(与 LVGL 无关,注释黑盒)
c
复制
esp_lcd_panel_handle_t hardware_init(void)
{
/* SPI总线 + ST7789面板初始化 → panel_handle
背光拉高点亮
触摸:PENIRQ中断 + 信号量 + touch_task(读XPT2046→写 tp_x/tp_y/tp_pressed) */
...
return panel_handle;
}
阶段 1|注册显示
c
复制
lv_display_t *disp = NULL;
void display_init(esp_lcd_panel_handle_t panel_handle)
{
const lvgl_port_cfg_t lvgl_cfg = ESP_LVGL_PORT_INIT_CONFIG();
lvgl_port_init(&lvgl_cfg); /* ★lv_init + 心跳tick + 任务循环,一次全包 */
const lvgl_port_display_cfg_t disp_cfg = {
.panel_handle = panel_handle,
.hres = 320, .vres = 240,
.buffer_size = 320 * 40,
.flags.swap_bytes = false, /* ★红蓝颠倒时改true */
};
disp = lvgl_port_add_disp(&disp_cfg); /* ★flush_cb四步由port内置代写 */
}
阶段 2|搭 UI(两版完全相同)
c
复制
lv_obj_t *g_btn, *g_slider, *g_label;
void ui_init(void)
{
g_btn = lv_button_create(lv_screen_active());
lv_obj_set_size(g_btn, 120, 50);
lv_obj_center(g_btn);
g_slider = lv_slider_create(lv_screen_active());
lv_obj_align(g_slider, LV_ALIGN_CENTER, 0, 40);
g_label = lv_label_create(lv_screen_active());
lv_label_set_text(g_label, "0");
lv_obj_align_to(g_label, g_slider, LV_ALIGN_OUT_BOTTOM_MID, 0, 5);
}
阶段 3|注册输入(两版完全相同)
c
复制
void input_init(void)
{
lv_indev_t *indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_display(indev, disp); /* v9必须 */
lv_indev_set_read_cb(indev, touch_read_cb);
}
static void touch_read_cb(lv_indev_t *indev, lv_indev_data_t *data)
{
if (tp_pressed) { /* 阶段0的touch_task维护 */
data->point.x = tp_x;
data->point.y = tp_y;
data->state = LV_INDEV_STATE_PRESSED;
} else {
data->state = LV_INDEV_STATE_RELEASED;
}
}
阶段 4|挂事件(两版完全相同)
c
复制
static void btn_cb(lv_event_t *e)
{
lv_label_set_text(lv_event_get_user_data(e), "Clicked!");
}
static void slider_cb(lv_event_t *e)
{
lv_label_set_text_fmt(lv_event_get_user_data(e), "%d",
lv_slider_get_value(lv_event_get_target(e)));
}
void event_init(void)
{
lv_obj_add_event_cb(g_btn, btn_cb, LV_EVENT_CLICKED, g_label);
lv_obj_add_event_cb(g_slider, slider_cb, LV_EVENT_VALUE_CHANGED, g_label);
}
阶段 5|跑起来(port 代跑,不写循环)
c
复制
void app_main(void)
{
display_init(hardware_init()); /* 0+1 */
ui_init(); /* 2 */
input_init(); /* 3 */
event_init(); /* 4 */
/* 5:结束,port内置任务在跑 lv_timer_handler() */
}
版本 B|裸 LVGL(没 port,三件事收归己有)
阶段 0|硬件准备(与版本A完全相同)
c
复制
esp_lcd_panel_handle_t hardware_init(void)
{
/* 同版本A:SPI总线 + ST7789 → panel_handle
背光 + 触摸中断/信号量/touch_task */
...
return panel_handle;
}
阶段 1|注册显示(★差异集中区:自己 lv_init + 心跳 + 手写 flush_cb)
c
复制
lv_display_t *disp = NULL;
/* ★你写的flush_cb:贴屏 + 喊收工 */
static void my_flush_cb(lv_display_t *d, const lv_area_t *area, uint8_t *px_map)
{
esp_lcd_panel_draw_bitmap(panel_handle,
area->x1, area->y1,
area->x2 + 1, area->y2 + 1, /* esp_lcd不含→+1 */
px_map);
/* ★红蓝颠倒时解开:
lv_draw_sw_rgb565_swap(px_map,
(area->x2-area->x1+1) * (area->y2-area->y1+1)); */
lv_display_flush_ready(d); /* 漏了=画一笔就黑屏卡死 */
}
void display_init(esp_lcd_panel_handle_t panel_handle)
{
/* ① lv_init(port代劳→现在自己来) */
lv_init();
/* ② 心跳tick(port代劳→现在自己来):esp_timer每1ms喊一次 */
const esp_timer_create_args_t tick_args = {
.callback = (esp_timer_cb_t)lv_tick_inc,
.arg = (void *)1, .name = "lv_tick",
};
esp_timer_handle_t tick_timer;
esp_timer_create(&tick_args, &tick_timer);
esp_timer_start_periodic(tick_timer, 1000); /* 1000us=1ms */
/* ③ 注册显示四步(port内置→现在手写) */
disp = lv_display_create(320, 240);
lv_display_set_color_format(disp, LV_COLOR_FORMAT_RGB565);
lv_display_set_flush_cb(disp, my_flush_cb); /* 交地址,不调用 */
static uint8_t buf[320 * 40 * 2]; /* ★必须static */
lv_display_set_buffers(disp, buf, NULL, sizeof(buf),
LV_DISPLAY_RENDER_MODE_PARTIAL);
}
阶段 2|搭 UI(与版本A逐字相同)
c
复制
/* 同版本A:ui_init(),三个控件存全局 g_btn / g_slider / g_label */
阶段 3|注册输入(与版本A逐字相同)
c
复制
/* 同版本A:input_init()四件套 + touch_read_cb函数体 */
阶段 4|挂事件(与版本A逐字相同)
c
复制
/* 同版本A:event_init() + btn_cb / slider_cb */
阶段 5|跑起来(★差异:循环自己写)
c
复制

引用
static void lvgl_task(void *arg)
{
for (;;) {
uint32_t next = lv_timer_handler(); /* port内置任务里就是这行 */
vTaskDelay(pdMS_TO_TICKS(next > 5 ? 5 : next));
}
}
void app_main(void)
{
display_init(hardware_init()); /* 0+1:含lv_init+tick+flush_cb */
ui_init(); /* 2 */
input_init(); /* 3 */
event_init(); /* 4 */
xTaskCreate(lvgl_task, "lvgl", 4096, NULL, 5, NULL); /* ★5:自己起循环任务 */
}
7事件如何创建
两步挂事件:写回调函数体 + add_event_cb 填名字
两步公式
c
复制
/* 第1步:写回调函数体(被点了干什么) */
static void btn1_cb(lv_event_t *e)
{
lv_label_set_text(ui_Label1, "Clicked!"); /* 直接用你的全局名 */
}
/* 第2步:名字填进第一个槽位 */
lv_obj_add_event_cb(ui_Button1, /* ← 你的名字就是指针,直接填 */
btn1_cb,
LV_EVENT_CLICKED,
NULL);
位置要求:放在控件创建之后 (即 ui_init() / SquareLine 生成的初始化跑完之后):
c
复制
ui_init(); /* 控件先存在 */
lv_obj_add_event_cb(ui_Button1, btn1_cb, LV_EVENT_CLICKED, NULL); /* 再挂事件 */
两种取 label 的写法(都能用)
| 写法 | 代码 | 适用 |
|---|---|---|
| 直接用全局名 | 回调里写死 ui_Label1 |
简单粗暴,一个按钮改一个label |
| 传 user_data | add_event_cb(..., ui_Label1) 回调里 lv_event_get_user_data(e) |
同一个回调复用给多个按钮,各改各的 |
c
复制
/* user_data 版:一个回调服务多个按钮 */
static void btn1_cb(lv_event_t *e)
{
lv_obj_t *label = lv_event_get_user_data(e); /* 拿挂事件时传的指针 */
lv_label_set_text(label, "Clicked!");
}
lv_obj_add_event_cb(ui_Button1, btn1_cb, LV_EVENT_CLICKED, ui_Label1);

引用
常用事件码速查(第三个槽位填什么)
| 事件码 | 触发时机 | 常配控件 |
|---|---|---|
LV_EVENT_CLICKED |
按下并松开 | 按钮 |
LV_EVENT_VALUE_CHANGED |
值变了 | 滑块、开关、下拉框 |
LV_EVENT_PRESSING |
按住期间连续触发 | 画板、长按效果 |
完整示例
bash
/**
* ============================================================================
* 工程:ESP32-S3 + ST7789 LCD (SPI2) + XPT2046 触摸 (SPI3)
* 版本:LVGL 中断版 v2.0 ------ SquareLine Studio UI(LVGL 9.5)
*
* 【总体架构】两条独立总线、两个外设、三种执行上下文协作:
*
* ┌────────────┐ SPI2 @40MHz ┌─────────┐
* │ ESP32-S3 ├───────────────→│ ST7789 │ 只出不让(MISO 未接),
* │ │ CS/DC/RST/BK │ 240x320│ 显示完全由 LVGL 任务驱动
* │ │ └─────────┘
* │ │ SPI3 @1MHz ┌─────────┐
* │ ├───────────────→│ XPT2046 │ 电阻触摸 ADC
* │ │←───────────────┤ │ 按下时 IRQ 脚拉低
* │ GPIO14 │←──(中断线)─────┤ T_IRQ │
* └────────────┘ └─────────┘
*
* 【三种执行上下文 & 它们各自能做什么】
* 1) 中断上下文 touch_pen_isr:只能发通知,禁止 SPI/printf/延时
* 2) 触摸任务 touch_task: 干所有脏活(SPI 采样、滤波、映射)
* 3) LVGL 任务(esp_lvgl_port 自带):跑 UI、调 touch_read_cb 读坐标
*
* 【触摸工作流】(与裸机中断版一致)
* 手指按下 → T_IRQ 拉低 → 下降沿中断 touch_pen_isr()
* → vTaskNotifyGiveFromISR() 唤醒触摸任务
* 触摸任务被唤醒 → 消抖 → 读 Z1 确认真按下 → 采样滤波 → 更新共享坐标
* → 循环直到 T_IRQ 变高(抬笔)→ 回到休眠
* LVGL 触摸回调:只读共享变量(s_pressed/s_x/s_y),不做任何 SPI 操作
*
* 【UI】SquareLine Studio 导出(main/ui/ 目录)
* ui_init() 创建并加载 Screen1
* 事件绑定:自动查找 Screen1 上第一个 button 控件(不依赖控件命名,
* 这样 SquareLine 里控件改名字也不用改本文件)
*
* ★ 初始化顺序(错了会崩):
* 建触摸任务(拿句柄) → LVGL/显示/输入 → 挂 GPIO 中断 → ui_init()
* 原因:中断一挂就可能立刻触发,而 ISR 要用 s_touch_task,
* 所以句柄必须先就位;ui_init 最后是因为它要在锁内跑,
* 且创建控件需要显示设备和输入设备都已注册。
* ============================================================================
*/
/* ============================================================================
* 第一部分:硬件参数宏定义
* 全部用宏而非魔法数字散落在代码里------改引脚/改屏只动这一段
* ========================================================================== */
/* ---------------- LCD(ST7789 @ SPI2) ---------------- */
#define LCD_HOST SPI2_HOST /* 用 SPI2 控制器驱动屏;
* 触摸用 SPI3,两条总线互不干扰,
* 屏 40MHz 高速、触摸 1MHz 低速可并存 */
#define PIN_NUM_SCLK 6 /* SPI 时钟线 */
#define PIN_NUM_MOSI 7 /* 主出从入:ESP32 → LCD 数据线 */
#define PIN_NUM_MISO (-1) /* 主入从出:ST7789 只写不读,省一根线,-1 = 不用 */
#define PIN_NUM_CS 5 /* 片选:低电平选中 LCD */
#define PIN_NUM_DC 4 /* ★ Data/Command 选择脚(SPI 屏特有):
* 低 = 后面字节是命令(如 0x2A 设列地址)
* 高 = 后面字节是显存数据/参数
* esp_lcd 组件根据 lcd_cmd_bits 自动拉低/拉高 */
#define PIN_NUM_RST 8 /* 硬复位:上电时序要求复位脉冲 ≥10us */
#define PIN_NUM_BK 9 /* 背光使能:高电平点亮(普通 GPIO,不走 SPI) */
#define LCD_WIDTH 240 /* 逻辑宽度 ------ 竖屏方向 */
#define LCD_HEIGHT 320 /* 逻辑高度 */
#define LCD_CMD_BITS 8 /* 每条命令占 8 个时钟(ST7789 规定) */
#define LCD_PARAM_BITS 8 /* 每个参数占 8 个时钟 */
#define LCD_PCLK_HZ (40 * 1000 * 1000) /* 40MHz。ST7789 标称最高 ~62.5MHz,
* 但受 FFC 排线长度/PCB 走线影响,
* 40MHz 是稳定性与刷新率的实测平衡点 */
/* ---------------- 触摸(XPT2046 @ SPI3) ---------------- */
#define TOUCH_HOST SPI3_HOST /* 独立总线。也有共总线的方案(省引脚),
* 但 LCD 40MHz 和触摸 1MHz 淡总会互相拖慢,
* 且 CS 管理复杂,双总线最省心 */
#define T_PIN_CLK 13 /* 触摸 SPI 时钟 */
#define T_PIN_SDI 12 /* MOSI:ESP32 → XPT2046(发命令字) */
#define T_PIN_SDO 11 /* MISO:XPT2046 → ESP32(收 ADC 值) */
#define T_PIN_CS 10 /* 片选 */
#define T_PIN_IRQ 14 /* ★ 中断版必须接牢!按下为低(笔尖压屏
* → XPT2046 内部 PENIRQ 输出导通到地) */
#define TOUCH_CLK_HZ (1 * 1000 * 1000) /* 1MHz。XPT2046 在 2.7V 下手册标称
* 最高 2.5MHz,但高速会使内部采样建立
* 时间不足 → 坐标抖动。1MHz 是精度优先 */
#define TOUCH_DEBOUNCE_MS 10 /* 中断唤醒后的消抖延时:笔尖接触瞬间金属
* 触点会弹跳几个 ms,立即读数会拿到毛刺 */
#define TOUCH_DRAW_MS 30 /* 按住时的采样周期:约 33Hz,
* 略高于 LVGL 默认读输入频率(30ms),
* 保证画滑动轨迹时不跳点 */
#define TOUCH_SAMPLES 5 /* 每次坐标采样 5 遍取中位数。
* ★ 必须是奇数:中位数才有唯一值,
* 且 5 次能滤掉最多 2 个野值 */
/* ---------------- 触摸校准(实测有效) ----------------
* 电阻屏每块个体的 ADC 边缘区线性度差(边缘几十~几百 LSB 不可用),
* RAW_MIN/MAX 是实测"屏幕四角对应的 ADC 原始值",用于线性映射。
* 换屏/换触摸膜后必须重新实测:分别点屏幕四个角,记录串口打印的原始值 */
#define RAW_X_MIN 300 /* X 通道 ADC 最小有效值(0~4095 范围内) */
#define RAW_X_MAX 3800 /* X 通道 ADC 最大有效值 */
#define RAW_Y_MIN 300 /* Y 通道 ADC 最小有效值 */
#define RAW_Y_MAX 3800 /* Y 通道 ADC 最大有效值 */
#define TOUCH_SWAP_XY 1 /* ★ 电阻屏物理布线方向与屏幕像素方向可能
* 成 90°,实测触摸的"X 通道"对应屏幕的
* "Y 方向"时置 1(触摸板竖着贴屏导致) */
#define TOUCH_INVERT_X 0 /* 触摸左移像素坐标反而减小时置 1(镜像) */
#define TOUCH_INVERT_Y 0 /* 同上,Y 方向镜像 */
#define TOUCH_VALID_MIN 100 /* 原始值 <100 视为无效(接近 0 通常是
* 抬笔瞬间回路断开的残值),直接丢弃 */
#define TOUCH_Z_THRESHOLD 20 /* Z1 压力兜底阈值:未按压时 Z1≈0,
* 按下随压力增大。低于 20 = 假按下(EMI
* 毛刺/抖动),配合中断消抖双重过滤 */
/* ---------------- 像素偏移补偿(实测 Y+20) ----------------
* 电阻屏膜片与 LCD 玻璃贴合存在机械装配误差,
* 实测整体 Y 方向偏了 20 像素,加常数修正。
* 若要做严格的 5 点校准,应换成两点/五点映射算法 */
#define TOUCH_X_OFFSET 0
#define TOUCH_Y_OFFSET 20
/* ---------------- 调试开关(稳定后改 0) ----------------
* 0 = 编译期直接裁掉 printf,零运行时开销(#if 而非 if 的意义) */
#define TOUCH_DEBUG 0
/* ============================================================================
* 第二部分:头文件
* ========================================================================== */
#include <stdbool.h> /* bool / true / false */
#include <stdio.h> /* printf / snprintf */
#include <string.h> /* (预留:memset 等) */
#include "freertos/FreeRTOS.h" /* FreeRTOS 核心类型/宏(必须先于 task.h) */
#include "freertos/task.h" /* xTaskCreate / vTaskDelay / 任务通知 API */
#include "driver/spi_master.h" /* SPI 主机驱动:总线初始化、加设备、收发事务 */
#include "driver/gpio.h" /* GPIO 配置、电平读写、中断服务框架 */
#include "esp_lcd_panel_io.h" /* 抽象"IO 层":SPI + DC 脚 → 面板 IO 句柄 */
#include "esp_lcd_panel_vendor.h"/* 厂商面板驱动:st7789 等初始化序列 */
#include "esp_lcd_panel_ops.h" /* 面板操作层:reset/init/draw_bitmap/disp_on */
#include "esp_lvgl_port.h" /* 乐鑫官方 LVGL 移植层:
* - 自带 LVGL 任务 + 定时器(自动调 lv_timer_handler)
* - 提供 lvgl_port_lock/unlock(跨线程互斥)
* - 提供 lvgl_port_add_disp(把 esp_lcd 面板接给 LVGL)*/
#include "esp_attr.h" /* IRAM_ATTR:把函数放进内部 RAM,
* flash 缓存被占用时(写 flash/禁 cache)也能执行 */
#include "lvgl.h" /* LVGL 9.5 核心 API */
#include "ui.h" /* ★ SquareLine 生成的界面(ui_init/ui_Screen1) */
/* ============================================================================
* 第三部分:XPT2046 命令字(详见前述控制字节位结构)
* bit7=1起始 | bit6~4通道 | bit3=0(12位) | bit2=0(差分) | bit1~0=00(掉电+PENIRQ)
* ========================================================================== */
#define XPT2046_CMD_X 0x90 /* 1001 0000 → 通道 001:测 X 坐标分压 */
#define XPT2046_CMD_Y 0xD0 /* 1101 0000 → 通道 100:测 Y 坐标分压 */
#define XPT2046_CMD_Z1 0xB0 /* 1011 0000 → 通道 010:测 Z1(压力辅助,
* 这里只用作"是否真按下"的判据) */
/* ============================================================================
* 第四部分:模块状态(文件级 static 变量)
* 初始化函数写入 → 运行时代码读取,生命周期 = 整个程序
* ========================================================================== */
static esp_lcd_panel_handle_t s_lcd = NULL; /* 面板层句柄:init/draw/开关屏 */
static esp_lcd_panel_io_handle_t s_lcd_io = NULL; /* IO 层句柄:SPI+DC 协议封装,
* 注册给 LVGL 时两把句柄都要给 */
static spi_device_handle_t s_touch_dev = NULL; /* SPI3 上的 XPT2046 设备句柄,
* 每次读 ADC 都通过它发事务 */
static TaskHandle_t s_touch_task = NULL; /* ★ 触摸任务句柄:ISR 靠它
* 发通知唤醒任务,必须先于
* 挂中断就位,否则 ISR 拿到
* NULL 直接崩溃 */
/* 触摸任务 → LVGL 回调 的共享数据(无锁生产者-消费者)
* 16 位对齐写在 Xtensa 上是原子的(单条 store 指令完成),
* volatile 禁止编译器把变量缓存在寄存器里;
* LVGL 每帧读一次,最多滞后一帧,无需加锁。
* 注意:uint16_t 必须自然对齐(默认就是),不能打包进位域/结构体packed */
static volatile bool s_pressed = false; /* true=手指按着屏幕 */
static volatile uint16_t s_x = 0, s_y = 0; /* 最近一次有效像素坐标 */
/* ============================================================================
* 第五部分:XPT2046 底层驱动
* ========================================================================== */
/**
* 读一次 12 位 ADC
* @cmd 命令字(选通道),如 XPT2046_CMD_X
* @return 0~4095 的原始 ADC 值
*
* 时序(由 SPI 硬件 + command_bits=8 自动完成):
* CS↓ → [8clk 发命令] → [16clk 收数据] → CS↑
* 16 位回读格式:[BUSY 1bit][D11~D0 12bit][补零 3bit]
* 所以有效 12 位在 bit14~3,需 >>3 对齐
*/
static uint16_t xpt2046_read(uint8_t cmd)
{
static const uint8_t zeros[2] = {0}; /* 只收不发,但 SPI 全双工必须有 TX 缓冲;
* static 使它进 .rodata,不在栈上反复构造 */
uint8_t rx[2] = {0};
spi_transaction_t t = {
.cmd = cmd, /* ★ 命令字放这里,硬件自动先发 8 位
* (因为设备配置了 command_bits=8) */
.length = 16, /* 命令之后的时钟数:16 位 = 收 2 字节 */
.tx_buffer = zeros, /* 发送内容(全 0,仅为提供时钟) */
.rx_buffer = rx, /* 接收缓冲 */
};
/* polling_transmit = CPU 轮询方式阻塞完成,不走中断/DMA 队列。
* 选它而非 spi_device_queue_trans 的原因:
* 1) 每次 24 个时钟 @1MHz ≈ 24us,极短,轮询比"投队列+等信号量"更快
* 2) 本函数只在触摸任务里调用,短暂阻塞无副作用 */
ESP_ERROR_CHECK(spi_device_polling_transmit(s_touch_dev, &t));
/* 拼接 2 字节 → 右移 3 位对齐 → 掩出 12 位有效值 */
return (((uint16_t)rx[0] << 8) | rx[1]) >> 3 & 0x0FFF;
}
/**
* 5 点中位数滤波(插入排序实现)
* @a 长度为 TOUCH_SAMPLES(5) 的数组,会被原地排序
* @return 中位数(排序后第 3 个)
*
* 为什么用中位数而不是平均数:
* 平均数会被单个野值(如 4095 的毛刺)拉偏;
* 中位数直接丢弃野值,对电阻屏的瞬时尖峰干扰效果最好。
* 数据量只有 5 个,插入排序(O(n²)但 n=5)比 qsort 调用开销更小 */
static uint16_t median5(uint16_t *a)
{
for (int i = 1; i < TOUCH_SAMPLES; i++) { /* 经典插入排序 */
uint16_t key = a[i];
int j = i - 1;
while (j >= 0 && a[j] > key) {
a[j + 1] = a[j]; /* 比 key 大的右移一位 */
j--;
}
a[j + 1] = key; /* key 插入正确位置 */
}
return a[TOUCH_SAMPLES / 2]; /* 取正中:a[2] */
}
/**
* 原始 ADC → 屏幕像素坐标
* 处理顺序有讲究:交换轴 → 镜像 → 线性映射 → 偏移补偿
* (先在"原始值域"做交换/镜像,再映射到像素域,最后加偏移)
*/
static void touch_map(uint16_t rx, uint16_t ry, uint16_t *sx, uint16_t *sy)
{
#if TOUCH_SWAP_XY
uint16_t t = rx; rx = ry; ry = t; /* 触摸板与屏成 90°:X/Y 通道互换 */
#endif
#if TOUCH_INVERT_X
rx = RAW_X_MIN + RAW_X_MAX - rx; /* 镜像 = 在原始值域绕中点翻转 */
#endif
#if TOUCH_INVERT_Y
ry = RAW_Y_MIN + RAW_Y_MAX - ry;
#endif
/* 线性映射 + 双端钳位(防除零/防越界):
* rx <= MIN → 直接给 0(边缘死区钳位)
* rx >= MAX → 直接给最大像素(同时挡住除零:MAX==MIN 时不可能走到除法)*/
*sx = (uint16_t)(rx <= RAW_X_MIN ? 0 :
rx >= RAW_X_MAX ? LCD_WIDTH - 1 :
/* ★ 中间乘法用 uint32_t:raw 差最大 3500 × 239 ≈ 83.6 万,
* uint16_t 会溢出截断,坐标就乱了 */
(uint32_t)(rx - RAW_X_MIN) * (LCD_WIDTH - 1) / (RAW_X_MAX - RAW_X_MIN));
*sy = (uint16_t)(ry <= RAW_Y_MIN ? 0 :
ry >= RAW_Y_MAX ? LCD_HEIGHT - 1 :
(uint32_t)(ry - RAW_Y_MIN) * (LCD_HEIGHT - 1) / (RAW_Y_MAX - RAW_Y_MIN));
/* 机械装配偏差补偿:像素域加常数偏移。
* 先转 int32_t 再运算,避免 uint16_t 下溢回绕(0-1 变 65535) */
int32_t ox = (int32_t)*sx + TOUCH_X_OFFSET;
int32_t oy = (int32_t)*sy + TOUCH_Y_OFFSET;
/* 补偿后再钳位到屏幕范围内 */
*sx = (uint16_t)(ox < 0 ? 0 : ox >= LCD_WIDTH ? LCD_WIDTH - 1 : ox);
*sy = (uint16_t)(oy < 0 ? 0 : oy >= LCD_HEIGHT ? LCD_HEIGHT - 1 : oy);
}
/* ============================================================================
* 第六部分:中断与触摸任务(核心状态机)
* ========================================================================== */
/**
* ★ T_IRQ 下降沿中断服务函数(手指按下瞬间触发)
*
* ISR 铁律:
* 1. IRAM_ATTR ------ 函数必须放进内部 RAM。若留在 flash,恰逢
* LVGL 刷屏禁用 flash cache 时中断到来 → 取指失败 → Cache 崩溃
* 2. 只做"发通知"这一件事,禁止 printf/SPI/vTaskDelay 等任何耗时操作
* 3. FreeRTOS 的 FromISR 系列 API 是中断安全版本,必须用它
*/
static void IRAM_ATTR touch_pen_isr(void *arg)
{
BaseType_t higher_wakeup = pdFALSE;
/* 向触摸任务发一个"计数型通知"(通知计数 +1)。
* 若触摸任务正阻塞在等通知且优先级(5)高于被打断的任务,
* higher_wakeup 会被置位 */
vTaskNotifyGiveFromISR(s_touch_task, &higher_wakeup);
if (higher_wakeup) {
portYIELD_FROM_ISR(); /* 中断退出时立即切换到高优任务,
* 把触摸延迟压到最小(否则要等时间片轮转) */
}
}
/**
* 触摸任务(中断驱动,空闲 0 CPU)
* 状态机:
* [休眠] --中断通知--> [消抖10ms] --Z1确认通过--> [按住循环采样] --IRQ变高--> [休眠]
* --Z1过低------→ [等抬笔] ------→ [休眠]
*/
static void touch_task(void *arg)
{
/* 采样缓冲:5 组 X + 5 组 Y,共 20 字节,放栈上即可 */
uint16_t xs[TOUCH_SAMPLES], ys[TOUCH_SAMPLES];
for (;;) { /* 任务永不退出 */
/* ---- 1. 深度休眠等中断 ----
* ulTaskNotifyTake(pdTRUE, ...):
* pdTRUE = "取走并清零"模式:只要计数 >0 就立即返回并把计数清 0;
* 计数 ==0 则阻塞,portMAX_DELAY = 永久等待。
* 中断里 vTaskNotifyGiveFromISR 把计数 +1 → 这里醒来。
* 醒来后无论计数原本是几,一律归零------防止快速连按时通知堆积 */
(void)ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
/* ---- 2. 消抖:等机械/电气抖动过去(约 10ms)----
* 不消抖的话,按下瞬间的多次弹跳会导致首帧坐标乱跳 */
vTaskDelay(pdMS_TO_TICKS(TOUCH_DEBOUNCE_MS));
/* ---- 3. Z1 压力确认(防 EMI 毛刺的第二道防线)----
* 中断是"电平变化"触发的,干扰信号也可能拉低 IRQ 造成假中断;
* 真按下时 Z1 会有明确读数,假中断 Z1≈0 */
if (xpt2046_read(XPT2046_CMD_Z1) < TOUCH_Z_THRESHOLD) {
/* 误触发处理:等抬笔(IRQ 变高)再回去休眠。
* ★ 不能直接 continue------线还停在低电平,不会再产生下降沿,
* 直接休眠会永久错过本次按压(假死),必须轮询等它变高 */
while (gpio_get_level(T_PIN_IRQ) == 0) {
vTaskDelay(pdMS_TO_TICKS(10));
}
continue; /* 回到循环开头,继续休眠 */
}
/* ---- 4. 确认真按下:进入按住采样循环 ---- */
s_pressed = true; /* 通知 LVGL"有手指了",本行无锁,原子写 */
#if TOUCH_DEBUG
bool logged = false; /* 每次按压只打印第一帧坐标,避免刷屏 */
#endif
do {
/* 采 5 组 X/Y(每组约 2×24us,总耗时约 0.5ms) */
for (int i = 0; i < TOUCH_SAMPLES; i++) {
xs[i] = xpt2046_read(XPT2046_CMD_X);
ys[i] = xpt2046_read(XPT2046_CMD_Y);
}
uint16_t rx = median5(xs); /* 中位数滤波去野值 */
uint16_t ry = median5(ys);
/* 原始值有效性检查:接近 0 说明回路已断(抬笔瞬间),
* 丢弃本次,保留上一帧坐标 */
if (rx >= TOUCH_VALID_MIN && ry >= TOUCH_VALID_MIN) {
uint16_t sx, sy;
touch_map(rx, ry, &sx, &sy);
s_x = sx; /* ★ 先写坐标再被 LVGL 读到,
* s_pressed 早已为 true,
* 保证 LVGL 读到的坐标一定有效 */
s_y = sy;
#if TOUCH_DEBUG
if (!logged) {
printf("[touch] press at(%3u,%3u)\n",
(unsigned)sx, (unsigned)sy);
logged = true;
}
#endif
}
/* 按住时以 30ms 周期持续刷新坐标(画滑动轨迹用)。
* 这个延时同时让出 CPU,SPI 总线也可被其他任务间歇使用 */
vTaskDelay(pdMS_TO_TICKS(TOUCH_DRAW_MS));
} while (gpio_get_level(T_PIN_IRQ) == 0);
/* IRQ 变高 = XPT2046 检测到抬笔(PENIRQ 内部断开,上拉电阻拉高)。
* 用轮询而非中断检测抬笔:更简单可靠,且本任务反正要持续采样 */
s_pressed = false; /* 通知 LVGL"手指离开了"→ 触发 RELEASED 事件 */
/* 落回 for(;;) 开头,ulTaskNotifyTake 再次休眠,等下一次按压 */
}
}
/* ============================================================================
* 第七部分:LVGL 输入设备回调(在 LVGL 任务上下文执行)
* ========================================================================== */
/**
* LVGL 每隔 LV_INDEV_DEF_READ_PERIOD(默认30ms) 调一次本函数询问输入状态。
* 设计原则:★这里绝不做 SPI 操作------
* SPI 采样全部由触摸任务完成,本回调只"抄"共享变量的最新值。
* 好处:LVGL 刷屏线程不会被 1MHz SPI 的十几次事务拖慢,UI 不卡顿。
*
* @data 出参:填状态和坐标
*/
static void touch_read_cb(lv_indev_t *indev, lv_indev_data_t *data)
{
if (s_pressed) {
data->state = LV_INDEV_STATE_PRESSED;
data->point.x = s_x; /* volatile 读,拿到任务写的最新坐标 */
data->point.y = s_y;
} else {
data->state = LV_INDEV_STATE_RELEASED; /* 抬笔或空闲 */
}
/* last_point 不需要手动维护:LVGL 内部会用上一帧数据
* 自动推算拖动(drag)速度和方向 */
}
/* ============================================================================
* 第八部分:按钮事件处理
* ========================================================================== */
/**
* 更新按钮上的文字。
* SquareLine 生成的按钮结构固定:button → child(0) = label,
* 所以取第 0 个子控件并确认它确实是 label 类再改文本(防御性编程) */
static void set_btn_label(lv_obj_t *btn, const char *txt)
{
lv_obj_t *l = lv_obj_get_child(btn, 0);
if (l != NULL && lv_obj_check_type(l, &lv_label_class)) {
lv_label_set_text(l, txt);
}
}
static int click_count = 0; /* 点击计数(演示用) */
static bool long_pressed = false; /* "刚长按过"标志:用于吞掉长按松手时
* LVGL 附加发出的 CLICKED 事件 */
/**
* 按钮事件回调(一个函数处理三种事件码)
* LVGL 事件时序:
* 按下 → PRESSED
* 按住超时长按阈值(默认400ms) → LONG_PRESSED(可能多次)
* 松手 → 若无长按 → CLICKED;若长按过 → RELEASED(但仍可能跟一次 CLICKED)
*/
static void btn_event_cb(lv_event_t *e)
{
lv_event_code_t code = lv_event_get_code(e); /* 本次事件类型 */
char buf[32]; /* 格式化缓冲(栈上) */
switch (code) {
case LV_EVENT_PRESSED:
long_pressed = false; /* ★ 新按压开始:清标志。
* 没有这行的话,长按一次后所有后续
* 短按点击都会被误过滤 */
break;
case LV_EVENT_LONG_PRESSED:
long_pressed = true; /* 打标记:本次按压升级为长按 */
printf("按钮被长按!计数清零\n");
click_count = 0;
set_btn_label(lv_event_get_target(e), "Click me"); /* 文字复位 */
break;
case LV_EVENT_CLICKED:
if (long_pressed) {
break; /* 长按后松手的"余波 CLICKED",忽略,
* 避免长按清零后立刻又 +1 的 bug */
}
click_count++;
printf("按钮被点击了!第 %d 次\n", click_count);
snprintf(buf, sizeof(buf), "Clicked %d", click_count);
set_btn_label(lv_event_get_target(e), buf);
break;
default:
break; /* 其他事件码(SCROLL/RELEASED 等)不关心 */
}
}
/**
* 递归查找 parent 子树下第一个 button 控件。
* 深度优先遍历:先看当前孩子,不是按钮就钻进去找它的孩子。
* 好处:SquareLine 里按钮叫什么名字(btn_xxx)都无所谓,
* 甚至按钮嵌在多层容器里也能找到 */
static lv_obj_t *find_first_button(lv_obj_t *parent)
{
uint32_t n = lv_obj_get_child_count(parent);
for (uint32_t i = 0; i < n; i++) {
lv_obj_t *c = lv_obj_get_child(parent, i);
if (lv_obj_check_type(c, &lv_button_class)) { /* 类型检查(RTTI式) */
return c;
}
lv_obj_t *r = find_first_button(c); /* 递归深入容器 */
if (r != NULL) {
return r;
}
}
return NULL; /* 本子树没有按钮 */
}
/**
* SquareLine UI 初始化 + 事件绑定。
* ★ 必须在 lvgl_port_lock/unlock 之间调用:
* LVGL 不是线程安全的,所有 lv_xxx 对象操作都要和 LVGL 任务互斥 */
static void ui_setup(void)
{
ui_init(); /* SquareLine 生成:创建所有控件并加载 Screen1 */
lv_obj_t *btn = find_first_button(ui_Screen1); /* 屏幕上找按钮 */
if (btn != NULL) {
/* 同一个回调挂三种事件码,回调内部用事件码区分处理 */
lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_PRESSED, NULL);
lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, NULL);
lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_LONG_PRESSED, NULL);
printf("UI ready: button bound\n");
} else {
printf("UI ready: no button found on Screen1\n");
}
}
/* ============================================================================
* 第九部分:LCD 硬件初始化
* ========================================================================== */
static void lcd_init(void)
{
/* --- 第 1 步:初始化 SPI2 总线(控制器级) --- */
spi_bus_config_t bus = {
.mosi_io_num = PIN_NUM_MOSI,
.miso_io_num = PIN_NUM_MISO, /* -1 = 不使用 */
.sclk_io_num = PIN_NUM_SCLK,
.quadwp_io_num = -1, /* 不用四线模式 */
.quadhd_io_num = -1,
/* 最大单次传输 = 10 行像素 ×2字节。
* 决定 DMA 描述符预分配和 trans_size 拆分上限 */
.max_transfer_sz = LCD_WIDTH * 40 * sizeof(uint16_t),
};
/* SPI_DMA_CH_AUTO = 自动分配 DMA 通道(刷屏大数据必须走 DMA,
* 否则 40MHz 下 CPU 全耗在搬运上) */
ESP_ERROR_CHECK(spi_bus_initialize(LCD_HOST, &bus, SPI_DMA_CH_AUTO));
/* --- 第 2 步:在总线上挂"面板 IO"(协议层:命令/数据 + DC 脚管理) --- */
esp_lcd_panel_io_spi_config_t io_cfg = {
.cs_gpio_num = PIN_NUM_CS, /* 由 IO 层自动控制片选 */
.dc_gpio_num = PIN_NUM_DC, /* 由 IO 层按 cmd/param 自动拉低拉高 */
.spi_mode = 0, /* CPOL=0 CPHA=0(ST7789 要求) */
.pclk_hz = LCD_PCLK_HZ, /* 40MHz */
.trans_queue_depth = 8, /* 异步队列深度:允许排队 8 笔传输,
* lvgl 刷屏用异步方式提高吞吐 */
.lcd_cmd_bits = LCD_CMD_BITS, /* 命令 8bit */
.lcd_param_bits = LCD_PARAM_BITS, /* 参数 8bit */
};
ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)LCD_HOST,
&io_cfg, &s_lcd_io));
/* --- 第 3 步:创建 ST7789 面板驱动(厂商层:内部含初始化命令序列) --- */
esp_lcd_panel_dev_config_t panel_cfg = {
.reset_gpio_num = PIN_NUM_RST, /* 硬复位脚 */
.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_BGR, /* ★ 这块屏的像素字节序
* 实测要 BGR,配错红蓝互换 */
.bits_per_pixel = 16, /* RGB565,2 字节/像素 */
};
ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(s_lcd_io, &panel_cfg, &s_lcd));
/* --- 第 4 步:复位 → 跑初始化序列 → 反显 → 开显示 --- */
ESP_ERROR_CHECK(esp_lcd_panel_reset(s_lcd)); /* 硬复位脉冲+软件复位 */
ESP_ERROR_CHECK(esp_lcd_panel_init(s_lcd)); /* 发工厂初始化命令序列 */
esp_lcd_panel_invert_color(s_lcd, true); /* ★ 这块面板灰度反相
* (ST7789 常见,不反显
* 白色会显示成黑)*/
esp_lcd_panel_disp_on_off(s_lcd, true); /* 发 0x29 开显示 */
/* --- 第 5 步:背光(独立 GPIO,不属于 SPI 协议) --- */
gpio_set_direction(PIN_NUM_BK, GPIO_MODE_OUTPUT);
gpio_set_level(PIN_NUM_BK, 1); /* 拉高点亮 */
}
/* ============================================================================
* 第十部分:XPT2046 硬件初始化
* ========================================================================== */
static void xpt2046_init(void)
{
/* --- SPI3 总线:传输量小(每笔 24 时钟),禁用 DMA 省资源 --- */
spi_bus_config_t bus = {
.mosi_io_num = T_PIN_SDI,
.miso_io_num = T_PIN_SDO,
.sclk_io_num = T_PIN_CLK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
};
ESP_ERROR_CHECK(spi_bus_initialize(TOUCH_HOST, &bus, SPI_DMA_DISABLED));
/* --- 在 SPI3 上注册 XPT2046 设备 --- */
spi_device_interface_config_t dev = {
.clock_speed_hz = TOUCH_CLK_HZ, /* 1MHz */
.mode = 0, /* CPOL=0 CPHA=0(XPT2046 时序) */
.spics_io_num = T_PIN_CS, /* 硬件自动控制片选 */
.command_bits = 8, /* ★ 每次 事务硬件自动先发 8 位命令,
* 配合 xpt2046_read 的 .cmd 字段 */
.queue_size = 1, /* 异步队列深度(本工程只用轮询,
* 1 即可,是驱动要求的最低值) */
};
ESP_ERROR_CHECK(spi_bus_add_device(TOUCH_HOST, &dev, &s_touch_dev));
/* --- IRQ 脚配置:输入 + 上拉 + 下降沿触发 ---
* 上拉:未按下时 XPT2046 的 PENIRQ 是开漏输出,靠上拉保持高电平;
* intr_type 只是"预配置"中断类型,此时中断服务还没挂,
* 即使引脚抖动也不会有任何代码被执行------真正生效在 app_main 里
* gpio_isr_handler_add() 那一行 */
gpio_config_t io = {
.pin_bit_mask = 1ULL << T_PIN_IRQ,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.intr_type = GPIO_INTR_NEGEDGE,
};
ESP_ERROR_CHECK(gpio_config(&io));
}
/* ============================================================================
* 第十一部分:主入口(初始化顺序是刻意的,不要调整!)
* 任务(拿句柄) → LVGL/显示/输入 → 挂中断(最后) → UI
* ========================================================================== */
void app_main(void)
{
/* --- 0. 屏和触摸的底层硬件先就绪 --- */
lcd_init(); /* SPI2 总线 + ST7789 面板 + 背光 */
xpt2046_init(); /* SPI3 总线 + XPT2046 设备 + IRQ 脚配置(未挂 ISR) */
/* --- 1. 先创建触摸任务 ---
* 任务句柄写入 s_touch_task,中断才有"叫醒目标"。
* 优先级 5 > LVGL 任务默认 4:触摸醒来可抢占渲染,保证跟手;
* 栈 4096 字节:容纳 printf(约 1~2KB)+ 采样数组;
* 平时阻塞在等通知,CPU 占用 0,高优先级无副作用 */
xTaskCreate(touch_task, "touch", 4096, NULL, 5, &s_touch_task);
/* --- 2. 初始化 LVGL 移植层(创建 LVGL 服务任务+定时器) --- */
lvgl_port_cfg_t lvgl_cfg = ESP_LVGL_PORT_INIT_CONFIG(); /* 取默认配置 */
lvgl_cfg.task_stack = 8192; /* 默认栈偏小,SquareLine 生成的 UI
* 控件多、事件回调里有 printf,加大到 8KB */
ESP_ERROR_CHECK(lvgl_port_init(&lvgl_cfg));
/* --- 2.1 把 esp_lcd 面板注册成 LVGL 显示设备 --- */
const lvgl_port_display_cfg_t disp_cfg = {
.io_handle = s_lcd_io, /* 协议层句柄 */
.panel_handle = s_lcd, /* 面板层句柄(刷屏最终调 draw_bitmap) */
.buffer_size = LCD_WIDTH * 40, /* LVGL 绘制缓冲:40 行像素
* (240×40×2 = 19.2KB,内部 RAM 可承受)*/
.double_buffer = false, /* 单缓冲省内存;有 PSRAM 时可开双缓冲
* 消除撕裂 */
.trans_size = LCD_WIDTH * 10, /* 单缓冲拆成 10 行一笔经 DMA 发送,
* 缩短"绘制完→开始发送"的延迟 */
.hres = LCD_WIDTH,
.vres = LCD_HEIGHT,
.color_format = LV_COLOR_FORMAT_RGB565,
.rotation = { .swap_xy = false, .mirror_x = false, .mirror_y = false },
.flags = {
.buff_dma = true, /* ★ 缓冲必须进 DMA 可达内存,
* 否则 SPI DMA 搬不动 */
.buff_spiram = false, /* 不用 PSRAM(S3 的 PSRAM 不被
* LCD 外设 DMA 直接访问)*/
},
};
lv_display_t *disp = lvgl_port_add_disp(&disp_cfg);
/* --- 2.2 注册触摸输入设备 ---
* ★ 对 LVGL 对象的一切操作都要持锁(lvgl_port_lock),
* 因为 LVGL 任务可能同时在跑 lv_timer_handler */
lvgl_port_lock(0); /* 0 = 一直等到拿到锁 */
lv_indev_t *indev = lv_indev_create(); /* 创建输入设备 */
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); /* 触摸=指针类 */
lv_indev_set_read_cb(indev, touch_read_cb); /* 绑定"只读共享变量"的回调 */
lv_indev_set_display(indev, disp); /* 关联显示设备(坐标需换算) */
lvgl_port_unlock();
/* --- 3. 挂 GPIO 中断(此时触摸任务句柄已就绪,中断随时来都安全)--- */
ESP_ERROR_CHECK(gpio_install_isr_service(0)); /* 装 GPIO 中断服务框架
* (全程序只装一次,
* 重复装会报错) */
ESP_ERROR_CHECK(gpio_isr_handler_add(T_PIN_IRQ, touch_pen_isr, NULL));
/* ↑ 从这一刻起,手指按下就会真实触发中断 → 唤醒触摸任务 */
/* --- 3.1 兜底:挂中断瞬间 IRQ 已是低的情况 ---
* 场景:开机时手指恰按着屏 / IRQ 线卡在低电平。
* 下降沿中断只在"高→低跳变"时触发,线一直是低就不会再触发;
* 手动补发一个通知让任务进入"按住流程"处理掉这个状态,
* 否则必须松手再按才恢复 */
if (gpio_get_level(T_PIN_IRQ) == 0) {
xTaskNotifyGive(s_touch_task); /* 任务态版本的通知 API(非 ISR) */
}
/* --- 4. 最后创建 UI ---
* 放在挂中断之后:若 ui_init 耗时较长,期间用户按下屏幕也能被
* 正常采样(只是没有控件可响应);同时 ui_init 需要的显示/输入
* 设备此时都已注册完毕 */
lvgl_port_lock(0);
ui_setup(); /* ui_init() + 自动找按钮 + 绑定三种事件 */
lvgl_port_unlock();
printf("LVGL demo start (irq touch + SquareLine UI)\n");
/* app_main 返回后:主任务结束,但 LVGL 任务和触摸任务继续永续运行,
* 系统进入稳态:中断驱动采样 → 共享变量 → LVGL 轮询消费 */
}