esp32-s3移植lvgl总结

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 轮询消费 */
}
相关推荐
恒锐丰科技林技术员2 小时前
EG1191 宽压降压 DC‑DC 芯片解析:高性价比工业电源方案
经验分享·嵌入式硬件·硬件工程
智购科技无人售货机工厂店2 小时前
2026自动售货机大屏交互设计原则:从信息层级到视觉动线的工程实践~YH
运维·python·单片机·嵌入式硬件·自动化·交互
艾芯微科技5 小时前
SKY13370‑374LF|0.5‑6GHz SPDT 射频开关,WLAN/LTE 射频前端实用器件
网络·单片机·嵌入式硬件·集成测试·51单片机
HUI-4747 小时前
EG1131|SOT23‑6 30V/1.5A 电流模式同步降压 DC‑DC,小体积消费类电源优选
嵌入式硬件·硬件工程
天空'之城7 小时前
单片机基础核心知识点汇总(三十)
单片机·嵌入式硬件
笨笨饿7 小时前
#137_死机恢复现场_Armino平台AP系统swd调试
git·python·stm32·单片机·嵌入式硬件·物联网·vim
linnux领域7 小时前
面包板供电“插哪都不亮“?一个暗断点引发的排查——硬件新手三板斧
单片机·嵌入式硬件·esp32·arduino·面包板·硬件入门
Jaixln_HRF7 小时前
芯维尔CN8010 1.2A/6V同步降压转换器芯片,集成350/230mΩ MOSFET与1.5MHz高频,用于汽车电子/IoT/便携仪器
嵌入式硬件·物联网·硬件工程
ACP广源盛139246256739 小时前
M6/M5 Pro Mac mini 端侧 AI 落地@ACP#IX6024 PCIe2.0 交换芯片在轻量化 AI 服务中的机会与应用场景
大数据·数据库·人工智能·嵌入式硬件·macos·开源