一、工程基于:

https://blog.csdn.net/weixin_44386927/article/details/164758373?spm=1001.2014.3001.5502
之前的篇文章。

二、介绍

1.通过在屏幕上实现计算机功能

核心部分:
ui界面:

/*********************
 *      DEFINES
 *********************/
#define UI_BG_HEX       0x1E1E2E  /* 屏幕底色            */
#define UI_CARD_HEX     0x2A2A3C  /* 显示卡片底色        */
#define UI_DIGIT_HEX    0x2E3046  /* 数字键              */
#define UI_FUNC_HEX     0x3C3F58  /* 功能键 C / +/- / %  */
#define UI_OP_HEX       0xF5A623  /* 运算符键            */
#define UI_EQ_HEX       0x4C6FFF  /* 等号键              */

#define UI_TEXT_HEX     0xFFFFFF
#define UI_MUTED_HEX    0x8A90A6

#define UI_PAD          6         /* 屏幕四周留白        */
#define UI_GAP          5         /* 卡片与按键之间的缝  */

#define KEY_CLEAR       'c'
#define KEY_SIGN        's'
#define KEY_PERCENT     'p'

/**********************
 *      TYPEDEFS
 **********************/
typedef struct
{
    double acc;                 /* 左操作数(累加结果) */
    char   op;                  /* 待执行的运算符,0 表示无 */
    char   entry[20];           /* 当前输入 / 当前显示的数字文本 */
    bool   fresh;               /* true:下一个数字按键要覆盖 entry */
    bool   error;               /* true:显示 Error,需按 C 复位 */
} calc_ctx_t;

/**********************
 *  STATIC VARIABLES
 **********************/
static calc_ctx_t calc;
static lv_obj_t * expr_label = NULL;
static lv_obj_t * main_label = NULL;

/**********************
 *   STATIC FUNCTIONS
 **********************/

/* 把 double 转成便于阅读的字符串:最多 10 位有效数字 */
static void calc_format(double v, char * buf, size_t n)
{
    if (isnan(v) || isinf(v))
    {
        snprintf(buf, n, "Error");
        return;
    }

    snprintf(buf, n, "%.10g", v);
}

/* 重置计算器状态 */
static void calc_reset(void)
{
    calc.acc   = 0.0;
    calc.op    = 0;
    calc.fresh = true;
    calc.error = false;
    strcpy(calc.entry, "0");
}

/* 将数值写入 entry 文本 */
static void calc_store(double v)
{
    calc_format(v, calc.entry, sizeof(calc.entry));
    if (strcmp(calc.entry, "Error") == 0)
    {
        calc.error = true;
    }
}

/* 把状态刷新到界面 */
static void calc_refresh(void)
{
    char expr[40];

    /* 校准界面会先清空屏幕,此时标签已失效 */
    if ((expr_label == NULL) || (main_label == NULL))
    {
        return;
    }

    lv_label_set_text(main_label, calc.entry);

    if (calc.error || calc.op == 0)
    {
        expr[0] = '\0';
    }
    else
    {
        char acc_buf[24];
        calc_format(calc.acc, acc_buf, sizeof(acc_buf));
        snprintf(expr, sizeof(expr), "%s %c", acc_buf, calc.op);
    }
    lv_label_set_text(expr_label, expr);
}

/* 二元运算 */
static double calc_apply(char op, double a, double b)
{
    switch (op)
    {
    case '+':
        return a + b;
    case '-':
        return a - b;
    case '*':
        return a * b;
    case '/':
        return (b == 0.0) ? NAN : a / b;
    default:
        return b;
    }
}

/* 输入数字 0-9 */
static void calc_input_digit(char d)
{
    size_t len;

    if (calc.error)
    {
        calc_reset();
    }

    if (calc.fresh)
    {
        calc.entry[0] = d;
        calc.entry[1] = '\0';
        calc.fresh    = false;
    }
    else
    {
        len = strlen(calc.entry);
        if (len >= sizeof(calc.entry) - 1U)
        {
            return;
        }
        if (strchr(calc.entry, 'e') != NULL)
        {
            return;                          /* 科学计数法的结果不再追加 */
        }
        if ((len == 1U) && (calc.entry[0] == '0'))
        {
            calc.entry[0] = d;               /* 去掉多余前导 0 */
        }
        else
        {
            calc.entry[len]     = d;
            calc.entry[len + 1] = '\0';
        }
    }

    calc_refresh();
}

/* 输入小数点 */
static void calc_input_dot(void)
{
    size_t len;

    if (calc.error)
    {
        calc_reset();
    }

    if (calc.fresh)
    {
        strcpy(calc.entry, "0.");
        calc.fresh = false;
        calc_refresh();
        return;
    }

    if ((strchr(calc.entry, '.') == NULL) && (strchr(calc.entry, 'e') == NULL))
    {
        len = strlen(calc.entry);
        if (len < sizeof(calc.entry) - 1U)
        {
            calc.entry[len]     = '.';
            calc.entry[len + 1] = '\0';
            calc_refresh();
        }
    }
}

/* 输入运算符 + - * / */
static void calc_input_op(char op)
{
    double cur;

    if (calc.error)
    {
        return;
    }

    cur = atof(calc.entry);

    if ((calc.op != 0) && (!calc.fresh))
    {
        double result = calc_apply(calc.op, calc.acc, cur);
        calc.acc = result;
        calc_store(result);
        if (calc.error)
        {
            calc_refresh();
            return;
        }
    }
    else
    {
        calc.acc = cur;
    }

    calc.op    = op;
    calc.fresh = true;
    calc_refresh();
}

/* 按下等号 */
static void calc_equal(void)
{
    double cur;

    if (calc.error)
    {
        return;
    }

    cur = atof(calc.entry);

    if (calc.op != 0)
    {
        double result = calc_apply(calc.op, calc.acc, cur);
        calc.op  = 0;
        calc.acc = 0.0;
        calc_store(result);
    }
    else
    {
        calc.acc = cur;
    }

    calc.fresh = true;
    calc_refresh();
}

/* 正负号切换 */
static void calc_sign(void)
{
    size_t len;

    if (calc.error || (strcmp(calc.entry, "0") == 0))
    {
        return;
    }

    len = strlen(calc.entry);

    if (calc.entry[0] == '-')
    {
        memmove(calc.entry, calc.entry + 1, len);
    }
    else if (len < sizeof(calc.entry) - 1U)
    {
        memmove(calc.entry + 1, calc.entry, len + 1U);
        calc.entry[0] = '-';
    }
    else
    {
        return;
    }

    calc_refresh();
}

/* 百分比:当前值 / 100 */
static void calc_percent(void)
{
    if (calc.error)
    {
        return;
    }

    calc_store(atof(calc.entry) / 100.0);
    calc_refresh();
}

/* 所有按键共用的点击回调,user_data 即键值字符 */
static void key_event_cb(lv_event_t * e)
{
    char key = (char)(intptr_t)lv_event_get_user_data(e);

    if ((key >= '0') && (key <= '9'))
    {
        calc_input_digit(key);
        return;
    }

    switch (key)
    {
    case '.':
        calc_input_dot();
        break;
    case '+':
    case '-':
    case '*':
    case '/':
        calc_input_op(key);
        break;
    case '=':
        calc_equal();
        break;
    case KEY_CLEAR:
        calc_reset();
        calc_refresh();
        break;
    case KEY_SIGN:
        calc_sign();
        break;
    case KEY_PERCENT:
        calc_percent();
        break;
    default:
        break;
    }
}

/**********************
 *   触摸四点校准
 **********************/
#define CAL_POINT_COUNT  4U    /* 靶标个数            */
#define CAL_MARGIN       30    /* 靶标距屏幕边缘距离  */
#define CAL_SAMPLES      6U    /* 单点采样次数        */
#define CAL_DEBOUNCE     3U    /* 去抖次数(20ms/次) */

static lv_obj_t * cal_ring  = NULL;
static lv_obj_t * cal_bar_h = NULL;
static lv_obj_t * cal_bar_v = NULL;
static lv_obj_t * cal_tip   = NULL;
static lv_timer_t * cal_timer = NULL;

static uint16_t cal_raw[CAL_POINT_COUNT][2];
static int32_t  cal_px[CAL_POINT_COUNT];
static int32_t  cal_py[CAL_POINT_COUNT];

static uint8_t  cal_index;      /* 当前靶标序号            */
static uint8_t  cal_phase;      /* 0 等按下 / 1 采样 / 2 等抬起 */
static uint8_t  cal_debounce;
static uint8_t  cal_samples;
static uint32_t cal_sum_x;
static uint32_t cal_sum_y;

/* 把标定结果限制在 12 位 ADC 的有效范围内 */
static uint16_t cal_clamp(double value)
{
    if (value < 0.0)
    {
        return 0U;
    }
    if (value > 4095.0)
    {
        return 4095U;
    }

    return (uint16_t)(value + 0.5);
}

/* 把靶标移到第 cal_index 个位置并刷新提示文字 */
static void cal_move_target(void)
{
    char text[40];
    int32_t x = cal_px[cal_index];
    int32_t y = cal_py[cal_index];

    lv_obj_set_pos(cal_ring,  x - 28, y - 28);
    lv_obj_set_pos(cal_bar_h, x - 20, y - 2);
    lv_obj_set_pos(cal_bar_v, x - 2,  y - 20);

    snprintf(text, sizeof(text), "TAP THE CROSS  %u/%u",
             (unsigned)(cal_index + 1U), (unsigned)CAL_POINT_COUNT);
    lv_label_set_text(cal_tip, text);
}

/* 四点采集完成:解算标定参数、应用并返回计算器界面 */
static void cal_finish(void)
{
    XPT2046_Calibration cal;
    lv_display_t * disp = lv_display_get_default();
    int32_t scr_w = lv_display_get_horizontal_resolution(disp);
    int32_t scr_h = lv_display_get_vertical_resolution(disp);
    int32_t xl = CAL_MARGIN;
    int32_t xr = scr_w - 1 - CAL_MARGIN;
    int32_t yt = CAL_MARGIN;
    int32_t yb = scr_h - 1 - CAL_MARGIN;
    double r[CAL_POINT_COUNT][2];
    double h_left;
    double h_right;
    double v_top;
    double v_bottom;
    double slope;
    double raw_lo;
    double raw_hi;
    uint8_t swap;
    uint8_t i;

    for (i = 0U; i < CAL_POINT_COUNT; i++)
    {
        r[i][0] = (double)cal_raw[i][0];
        r[i][1] = (double)cal_raw[i][1];
    }

    /* 水平方向(左上->右上)哪个原始通道变化更大,它就是横轴 */
    {
        double dx = fabs(r[0][0] - r[1][0]) + fabs(r[2][0] - r[3][0]);
        double dy = fabs(r[0][1] - r[1][1]) + fabs(r[2][1] - r[3][1]);

        swap = (dx >= dy) ? 0U : 1U;
    }

    if (swap == 0U)
    {
        h_left   = (r[0][0] + r[2][0]) / 2.0;
        h_right  = (r[1][0] + r[3][0]) / 2.0;
        v_top    = (r[0][1] + r[1][1]) / 2.0;
        v_bottom = (r[2][1] + r[3][1]) / 2.0;
    }
    else
    {
        h_left   = (r[0][1] + r[2][1]) / 2.0;
        h_right  = (r[1][1] + r[3][1]) / 2.0;
        v_top    = (r[0][0] + r[1][0]) / 2.0;
        v_bottom = (r[2][0] + r[3][0]) / 2.0;
    }

    /* 两点几乎重合说明采样无效,重新采一轮 */
    if ((fabs(h_right - h_left) < 80.0) || (fabs(v_bottom - v_top) < 80.0))
    {
        cal_index    = 0U;
        cal_phase    = 0U;
        cal_debounce = 0U;
        cal_move_target();
        lv_label_set_text(cal_tip, "BAD DATA, TAP AGAIN");
        return;
    }

    /* 水平轴:由两个靶标点线性外推到屏幕左右边缘 */
    slope      = (h_right - h_left) / (double)(xr - xl);
    raw_lo     = h_left - (double)xl * slope;
    raw_hi     = h_left + (double)(scr_w - 1 - xl) * slope;
    cal.x_min  = cal_clamp(raw_lo);
    cal.x_max  = cal_clamp(raw_hi);

    /* 竖直轴 */
    slope      = (v_bottom - v_top) / (double)(yb - yt);
    raw_lo     = v_top - (double)yt * slope;
    raw_hi     = v_top + (double)(scr_h - 1 - yt) * slope;
    cal.y_min  = cal_clamp(raw_lo);
    cal.y_max  = cal_clamp(raw_hi);

    cal.swap_xy  = swap;
    cal.invert_x = 0U;
    cal.invert_y = 0U;

    XPT2046_SetCalibration(&cal);

    printf("\r\n==== XPT2046 calibration ====\r\n");
    printf("#define XPT2046_RAW_X_MIN %uU\r\n", (unsigned)cal.x_min);
    printf("#define XPT2046_RAW_X_MAX %uU\r\n", (unsigned)cal.x_max);
    printf("#define XPT2046_RAW_Y_MIN %uU\r\n", (unsigned)cal.y_min);
    printf("#define XPT2046_RAW_Y_MAX %uU\r\n", (unsigned)cal.y_max);
    printf("#define XPT2046_SWAP_XY %uU\r\n", (unsigned)cal.swap_xy);
    printf("#define XPT2046_INVERT_X %uU\r\n", (unsigned)cal.invert_x);
    printf("#define XPT2046_INVERT_Y %uU\r\n", (unsigned)cal.invert_y);
    printf("=============================\r\n");

    if (cal_timer != NULL)
    {
        lv_timer_delete(cal_timer);
        cal_timer = NULL;
    }
    cal_ring  = NULL;
    cal_bar_h = NULL;
    cal_bar_v = NULL;
    cal_tip   = NULL;

    ui_init();
}

/* 校准采样状态机,20ms 一次 */
static void cal_timer_cb(lv_timer_t * timer)
{
    uint16_t rx = 0U;
    uint16_t ry = 0U;
    uint8_t pressed;

    (void)timer;

    pressed = XPT2046_ReadRawPoint(&rx, &ry);

    switch (cal_phase)
    {
    case 3U:                            /* 刚进入:等长按的手指先离开 */
        if (pressed == 0U)
        {
            cal_phase    = 0U;
            cal_debounce = 0U;
        }
        break;

    case 0U:                            /* 等待一次新的按下 */
        if (pressed != 0U)
        {
            if (cal_debounce < CAL_DEBOUNCE)
            {
                cal_debounce++;
            }

            if (cal_debounce >= CAL_DEBOUNCE)
            {
                cal_phase   = 1U;
                cal_sum_x   = rx;
                cal_sum_y   = ry;
                cal_samples = 1U;
            }
        }
        else
        {
            cal_debounce = 0U;
        }
        break;

    case 1U:                            /* 累积采样 */
        if ((pressed != 0U) && (cal_samples < CAL_SAMPLES))
        {
            cal_sum_x += rx;
            cal_sum_y += ry;
            cal_samples++;

            if (cal_samples < CAL_SAMPLES)
            {
                break;
            }
        }

        /* 采样够了或者手指提前抬起,都记下这一点的平均值 */
        cal_raw[cal_index][0] = (uint16_t)(cal_sum_x / cal_samples);
        cal_raw[cal_index][1] = (uint16_t)(cal_sum_y / cal_samples);
        cal_index++;
        cal_phase = 2U;
        break;

    case 2U:                            /* 等手指抬起,避免一次点击采多点 */
        if (pressed == 0U)
        {
            cal_phase    = 0U;
            cal_debounce = 0U;

            if (cal_index >= CAL_POINT_COUNT)
            {
                cal_finish();
            }
            else
            {
                cal_move_target();
            }
        }
        break;

    default:
        break;
    }
}

/* 进入四点校准界面 */
static void touch_calibration_start(void)
{
    lv_display_t * disp = lv_display_get_default();
    lv_obj_t * scr = lv_screen_active();
    int32_t scr_w = lv_display_get_horizontal_resolution(disp);
    int32_t scr_h = lv_display_get_vertical_resolution(disp);
    uint8_t i;

    lv_obj_clean(scr);
    lv_obj_set_style_bg_color(scr, lv_color_hex(0x101828), 0);
    lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
    lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);

    /* 计算器的标签随 clean 一起被删掉了,置空避免后续误用 */
    expr_label = NULL;
    main_label = NULL;

    cal_px[0] = CAL_MARGIN;
    cal_py[0] = CAL_MARGIN;
    cal_px[1] = scr_w - 1 - CAL_MARGIN;
    cal_py[1] = CAL_MARGIN;
    cal_px[2] = CAL_MARGIN;
    cal_py[2] = scr_h - 1 - CAL_MARGIN;
    cal_px[3] = scr_w - 1 - CAL_MARGIN;
    cal_py[3] = scr_h - 1 - CAL_MARGIN;

    for (i = 0U; i < CAL_POINT_COUNT; i++)
    {
        cal_raw[i][0] = 0U;
        cal_raw[i][1] = 0U;
    }

    /* 靶标:一个圆环加十字线 */
    cal_ring = lv_obj_create(scr);
    lv_obj_remove_style_all(cal_ring);
    lv_obj_set_size(cal_ring, 56, 56);
    lv_obj_set_style_radius(cal_ring, LV_RADIUS_CIRCLE, 0);
    lv_obj_set_style_border_width(cal_ring, 2, 0);
    lv_obj_set_style_border_color(cal_ring, lv_color_hex(0x4C6FFF), 0);
    lv_obj_remove_flag(cal_ring, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_remove_flag(cal_ring, LV_OBJ_FLAG_CLICKABLE);

    cal_bar_h = lv_obj_create(scr);
    lv_obj_remove_style_all(cal_bar_h);
    lv_obj_set_size(cal_bar_h, 40, 4);
    lv_obj_set_style_bg_color(cal_bar_h, lv_color_white(), 0);
    lv_obj_set_style_bg_opa(cal_bar_h, LV_OPA_COVER, 0);
    lv_obj_remove_flag(cal_bar_h, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_remove_flag(cal_bar_h, LV_OBJ_FLAG_CLICKABLE);

    cal_bar_v = lv_obj_create(scr);
    lv_obj_remove_style_all(cal_bar_v);
    lv_obj_set_size(cal_bar_v, 4, 40);
    lv_obj_set_style_bg_color(cal_bar_v, lv_color_white(), 0);
    lv_obj_set_style_bg_opa(cal_bar_v, LV_OPA_COVER, 0);
    lv_obj_remove_flag(cal_bar_v, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_remove_flag(cal_bar_v, LV_OBJ_FLAG_CLICKABLE);

    cal_tip = lv_label_create(scr);
    lv_obj_set_style_text_font(cal_tip, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(cal_tip, lv_color_white(), 0);
    lv_obj_align(cal_tip, LV_ALIGN_CENTER, 0, 0);
    lv_obj_set_style_text_align(cal_tip, LV_TEXT_ALIGN_CENTER, 0);

    cal_index    = 0U;
    cal_phase    = 3U;              /* 先等长按的手指抬起 */
    cal_debounce = 0U;
    cal_samples  = 0U;
    cal_move_target();

    if (cal_timer != NULL)
    {
        lv_timer_delete(cal_timer);
    }
    cal_timer = lv_timer_create(cal_timer_cb, 20U, NULL);
}

/* 切换界面会删掉正在派发事件的按钮,交给 async 在本轮事件结束后执行 */
static void cal_start_async(void * unused)
{
    (void)unused;

    touch_calibration_start();
}

/* 长按 C 键进入校准 */
static void key_long_cb(lv_event_t * e)
{
    char key = (char)(intptr_t)lv_event_get_user_data(e);

    if (key == KEY_CLEAR)
    {
        (void)lv_async_call(cal_start_async, NULL);
    }
}

/* 长按顶部显示区也能进入校准(万一按键位置偏得厉害按不中 C) */
static void card_long_cb(lv_event_t * e)
{
    (void)e;

    (void)lv_async_call(cal_start_async, NULL);
}

/* 创建一个圆角触控按键,key 为键值字符 */
static lv_obj_t * key_create(lv_obj_t * parent, const char * txt,
                             uint32_t bg_hex, const lv_font_t * font,
                             char key)
{
    lv_obj_t * btn;
    lv_obj_t * lbl;
    lv_color_t bg = lv_color_hex(bg_hex);

    btn = lv_button_create(parent);
    lv_obj_remove_style_all(btn);
    lv_obj_set_style_bg_color(btn, bg, 0);
    lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(btn, 8, 0);
    lv_obj_set_style_bg_color(btn, lv_color_lighten(bg, 45), LV_STATE_PRESSED);
    lv_obj_remove_flag(btn, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_add_event_cb(btn, key_event_cb, LV_EVENT_CLICKED, (void *)(intptr_t)key);
    lv_obj_add_event_cb(btn, key_long_cb, LV_EVENT_LONG_PRESSED, (void *)(intptr_t)key);

    lbl = lv_label_create(btn);
    lv_label_set_text(lbl, txt);
    lv_obj_set_style_text_font(lbl, font, 0);
    lv_obj_set_style_text_color(lbl, lv_color_white(), 0);
    lv_obj_center(lbl);

    return btn;
}

/* 按键放入网格并自动拉伸填满格子 */
static void key_place(lv_obj_t * grid, int32_t row, int32_t col, int32_t span,
                      const char * txt, uint32_t bg_hex,
                      const lv_font_t * font, char key)
{
    lv_obj_t * btn = key_create(grid, txt, bg_hex, font, key);

    lv_obj_set_grid_cell(btn,
                         LV_GRID_ALIGN_STRETCH, col, span,
                         LV_GRID_ALIGN_STRETCH, row, 1);
}

/**********************
 *   GLOBAL FUNCTIONS
 **********************/
void ui_init(void)
{
    lv_display_t * disp;
    lv_obj_t * scr;
    lv_obj_t * card;
    lv_obj_t * keypad;
    int32_t scr_w;
    int32_t scr_h;
    int32_t card_h;
    int32_t keypad_y;
    int32_t keypad_h;

    /* 键盘 4 列 x 5 行 */
    static const int32_t col_dsc[] = {LV_GRID_FR(1), LV_GRID_FR(1),
                                      LV_GRID_FR(1), LV_GRID_FR(1),
                                      LV_GRID_TEMPLATE_LAST};
    static const int32_t row_dsc[] = {LV_GRID_FR(1), LV_GRID_FR(1),
                                      LV_GRID_FR(1), LV_GRID_FR(1),
                                      LV_GRID_FR(1), LV_GRID_TEMPLATE_LAST};

    if (cal_timer != NULL)
    {
        lv_timer_delete(cal_timer);
        cal_timer = NULL;
    }

    calc_reset();

    scr = lv_screen_active();
    lv_obj_clean(scr);                       /* 从校准界面返回时清掉旧对象 */
    lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG_HEX), 0);
    lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
    lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);

    disp    = lv_display_get_default();
    scr_w   = lv_display_get_horizontal_resolution(disp);
    scr_h   = lv_display_get_vertical_resolution(disp);
    card_h  = (scr_h * 26) / 100;                                /* 240 -> 62 */
    keypad_y = UI_PAD + card_h + UI_GAP;
    keypad_h = scr_h - keypad_y - UI_PAD;

    /* ---------- 显示卡片 ---------- */
    card = lv_obj_create(scr);
    lv_obj_remove_style_all(card);
    lv_obj_set_pos(card, UI_PAD, UI_PAD);
    lv_obj_set_size(card, scr_w - (2 * UI_PAD), card_h);
    lv_obj_set_style_bg_color(card, lv_color_hex(UI_CARD_HEX), 0);
    lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(card, 12, 0);
    lv_obj_set_style_pad_hor(card, 10, 0);
    lv_obj_set_style_pad_ver(card, 4, 0);
    lv_obj_set_style_pad_row(card, 0, 0);
    lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_flex_align(card, LV_FLEX_ALIGN_END,
                          LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_END);
    lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);

    /* 长按显示区进入触摸校准 */
    lv_obj_add_flag(card, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_add_event_cb(card, card_long_cb, LV_EVENT_LONG_PRESSED, NULL);

    /* 上行:算式,如 "12 +" */
    expr_label = lv_label_create(card);
    lv_label_set_text(expr_label, "");
    lv_obj_set_width(expr_label, LV_PCT(100));
    lv_obj_set_style_text_align(expr_label, LV_TEXT_ALIGN_RIGHT, 0);
    lv_obj_set_style_text_font(expr_label, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(expr_label, lv_color_hex(UI_MUTED_HEX), 0);

    /* 下行:当前输入或计算结果 */
    main_label = lv_label_create(card);
    lv_label_set_text(main_label, "0");
    lv_obj_set_width(main_label, LV_PCT(100));
    lv_label_set_long_mode(main_label, LV_LABEL_LONG_MODE_CLIP);
    lv_obj_set_style_text_align(main_label, LV_TEXT_ALIGN_RIGHT, 0);
    lv_obj_set_style_text_font(main_label, &lv_font_montserrat_28, 0);
    lv_obj_set_style_text_color(main_label, lv_color_hex(UI_TEXT_HEX), 0);

    /* ---------- 键盘区 ---------- */
    keypad = lv_obj_create(scr);
    lv_obj_remove_style_all(keypad);
    lv_obj_set_pos(keypad, UI_PAD, keypad_y);
    lv_obj_set_size(keypad, scr_w - (2 * UI_PAD), keypad_h);
    lv_obj_set_style_pad_column(keypad, UI_GAP, 0);
    lv_obj_set_style_pad_row(keypad, UI_GAP, 0);
    lv_obj_set_grid_dsc_array(keypad, col_dsc, row_dsc);
    lv_obj_remove_flag(keypad, LV_OBJ_FLAG_SCROLLABLE);

    /* 第 1 行:功能键 */
    key_place(keypad, 0, 0, 1, "C",    UI_FUNC_HEX, &lv_font_montserrat_20, KEY_CLEAR);
    key_place(keypad, 0, 1, 1, "+/-",  UI_FUNC_HEX, &lv_font_montserrat_14, KEY_SIGN);
    key_place(keypad, 0, 2, 1, "%",    UI_FUNC_HEX, &lv_font_montserrat_20, KEY_PERCENT);
    key_place(keypad, 0, 3, 1, "/",    UI_OP_HEX,   &lv_font_montserrat_20, '/');

    /* 第 2 行 */
    key_place(keypad, 1, 0, 1, "7",    UI_DIGIT_HEX, &lv_font_montserrat_20, '7');
    key_place(keypad, 1, 1, 1, "8",    UI_DIGIT_HEX, &lv_font_montserrat_20, '8');
    key_place(keypad, 1, 2, 1, "9",    UI_DIGIT_HEX, &lv_font_montserrat_20, '9');
    key_place(keypad, 1, 3, 1, "*",    UI_OP_HEX,    &lv_font_montserrat_20, '*');

    /* 第 3 行 */
    key_place(keypad, 2, 0, 1, "4",    UI_DIGIT_HEX, &lv_font_montserrat_20, '4');
    key_place(keypad, 2, 1, 1, "5",    UI_DIGIT_HEX, &lv_font_montserrat_20, '5');
    key_place(keypad, 2, 2, 1, "6",    UI_DIGIT_HEX, &lv_font_montserrat_20, '6');
    key_place(keypad, 2, 3, 1, "-",    UI_OP_HEX,    &lv_font_montserrat_20, '-');

    /* 第 4 行 */
    key_place(keypad, 3, 0, 1, "1",    UI_DIGIT_HEX, &lv_font_montserrat_20, '1');
    key_place(keypad, 3, 1, 1, "2",    UI_DIGIT_HEX, &lv_font_montserrat_20, '2');
    key_place(keypad, 3, 2, 1, "3",    UI_DIGIT_HEX, &lv_font_montserrat_20, '3');
    key_place(keypad, 3, 3, 1, "+",    UI_OP_HEX,    &lv_font_montserrat_20, '+');

    /* 第 5 行:0 占两格 */
    key_place(keypad, 4, 0, 2, "0",    UI_DIGIT_HEX, &lv_font_montserrat_20, '0');
    key_place(keypad, 4, 2, 1, ".",    UI_DIGIT_HEX, &lv_font_montserrat_20, '.');
    key_place(keypad, 4, 3, 1, "=",    UI_EQ_HEX,    &lv_font_montserrat_20, '=');

    calc_refresh();
}

效果展示:

在这里插入图片描述

2.通过屏幕进行调整led灯随频率闪烁变化

核心部分:

/*********************
 *      DEFINES
 *********************/
#define UI_BG_HEX       0x101828  /* 屏幕底色          */
#define UI_PANEL_HEX    0x1A2130  /* 面板 / 图表底色   */
#define UI_HEAD_HEX     0x1E2434  /* 顶栏底色          */
#define UI_BTN_HEX      0x2A3550  /* 普通按键          */
#define UI_TXT_HEX      0xFFFFFF
#define UI_MUTED_HEX    0x8A90A6
#define UI_ACCENT_HEX   0x33C6FF  /* 高亮 / 频谱柱     */
#define UI_OK_HEX       0x36D399  /* 运行中            */
#define UI_STOP_HEX     0xF87272  /* 已停止            */

#define UI_PAD          4
#define UI_GAP          4
#define UI_HEAD_H       24
#define UI_MAIN_H       92
#define UI_CARD_W       150
#define UI_SLIDER_H     28
#define UI_ROW_H        36

/* 滑杆刻度:滑杆值 = 频率(Hz) × SLIDER_SCALE,步进 0.1Hz */
#define SLIDER_SCALE    10
#define SLIDER_MIN      ((int32_t)(SW_FREQ_MIN_HZ * (float)SLIDER_SCALE + 0.5f))
#define SLIDER_MAX      ((int32_t)(SW_FREQ_MAX_HZ * (float)SLIDER_SCALE + 0.5f))

/* 频谱图:柱数 与 纵轴满量程 */
#define SPEC_BARS       24
#define SPEC_MAX_VAL    100

/* 频谱图内部布局:面板 = 上边距 + 图表 + 2px 间隔 + 频率刻度条 + 下边距 */
#define SPEC_PAD        4
#define SPEC_AXIS_H     20
#define SPEC_CHART_H    (UI_MAIN_H - (2 * SPEC_PAD) - SPEC_AXIS_H - 2)
#define SPEC_TICK_NUM   4     /* 频率刻度个数:0.1 / 1 / 10 / 100 Hz */

/**********************
 *  STATIC VARIABLES
 **********************/
static lv_obj_t * lbl_value = NULL;   /* 目标频率数值         */
static lv_obj_t * lbl_act   = NULL;   /* 顶栏实测频率         */
static lv_obj_t * led_out   = NULL;   /* 输出电平指示灯       */
static lv_obj_t * chart     = NULL;   /* 频谱图               */
static lv_chart_series_t * chart_ser = NULL;
static lv_obj_t * slider    = NULL;   /* 频率滑杆             */
static lv_obj_t * btn_run   = NULL;   /* 启动 / 停止          */
static lv_obj_t * lbl_run   = NULL;
static lv_obj_t * spec_marker = NULL; /* 当前频率在频谱上的指示线 */
static int32_t  spec_plot_w = 0;      /* 频谱绘图区宽度(像素)   */
static lv_timer_t * ui_timer = NULL;  /* 周期刷新定时器       */
static uint8_t  freq_lock   = 0u;     /* 防止滑杆回调递归      */

/**********************
 *   FORWARD DECLS
 **********************/
static void ui_set_freq(float hz);

/**********************
 *  STATIC FUNCTIONS
 **********************/

/* 刷新"目标频率"大号数字 */
static void ui_refresh_value(void)
{
    char buf[16];

    if (lbl_value == NULL)
    {
        return;
    }

    snprintf(buf, sizeof(buf), "%.1f", (double)switch_gen_get_freq());
    lv_label_set_text(lbl_value, buf);
}

/* 刷新启动/停止按键的外观 */
static void ui_refresh_run(void)
{
    if ((btn_run == NULL) || (lbl_run == NULL))
    {
        return;
    }

    if (switch_gen_get_enable() != 0u)
    {
        lv_label_set_text(lbl_run, "STOP");
        lv_obj_set_style_bg_color(btn_run, lv_color_hex(UI_STOP_HEX), 0);
    }
    else
    {
        lv_label_set_text(lbl_run, "START");
        lv_obj_set_style_bg_color(btn_run, lv_color_hex(UI_OK_HEX), 0);
    }
}

/* 设置频率(限幅 + 同步滑杆 + 刷新显示) */
static void ui_set_freq(float hz)
{
    int32_t sv;

    if (freq_lock != 0u)
    {
        return;
    }
    freq_lock = 1u;

    switch_gen_set_freq(hz);

    sv = (int32_t)(switch_gen_get_freq() * (float)SLIDER_SCALE + 0.5f);
    if (sv < SLIDER_MIN)
    {
        sv = SLIDER_MIN;
    }
    if (sv > SLIDER_MAX)
    {
        sv = SLIDER_MAX;
    }
    if (slider != NULL)
    {
        lv_slider_set_value(slider, sv, LV_ANIM_OFF);
    }

    ui_refresh_value();

    freq_lock = 0u;
}

/* 频率 -> 频谱绘图区横坐标(对数轴;第 i 根柱的有效中心 = (i + 0.5) / SPEC_BARS) */
static int32_t spec_x_of(float f, int32_t plot_w)
{
    float lmin = log10f(SW_FREQ_MIN_HZ);
    float bin  = (log10f(SW_FREQ_MAX_HZ) - lmin) / (float)(SPEC_BARS - 1);
    float i;
    int32_t x;

    if (plot_w <= 0)
    {
        return 0;
    }

    if (f < SW_FREQ_MIN_HZ)
    {
        f = SW_FREQ_MIN_HZ;
    }
    if (f > SW_FREQ_MAX_HZ)
    {
        f = SW_FREQ_MAX_HZ;
    }

    i = (log10f(f) - lmin) / bin;
    x = (int32_t)(((i + 0.5f) / (float)SPEC_BARS) * (float)plot_w + 0.5f);

    if (x < 0)
    {
        x = 0;
    }
    if (x > (plot_w - 1))
    {
        x = plot_w - 1;
    }

    return x;
}

/* 根据当前频率生成方波频谱:基波 + 奇次谐波各形成一个谱峰 */
static void spectrum_update(float f0)
{
    static int32_t vals[SPEC_BARS];
    float lmin = log10f(SW_FREQ_MIN_HZ);
    float lmax = log10f(SW_FREQ_MAX_HZ);
    float bin  = (lmax - lmin) / (float)(SPEC_BARS - 1);
    int32_t i;

    if ((chart == NULL) || (chart_ser == NULL))
    {
        return;
    }

    /* 当前频率指示线跟着频率一起移动 */
    if (spec_marker != NULL)
    {
        lv_obj_set_pos(spec_marker, spec_x_of(f0, spec_plot_w) - 1, 0);
    }

    for (i = 0; i < SPEC_BARS; i++)
    {
        float li  = lmin + bin * (float)i;
        float amp = 0.0f;
        int32_t n;

        for (n = 1; n <= 51; n += 2)
        {
            float fn = f0 * (float)n;
            float d;

            if (fn > (SW_FREQ_MAX_HZ * 1.5f))
            {
                break;
            }

            /* 相邻柱在对数频率轴上的距离,< 1 表示落在该柱的窗口内 */
            d = fabsf(li - log10f(fn)) / bin;
            if (d < 1.0f)
            {
                amp += (1.0f - d) / (float)n;
            }
        }

        vals[i] = (int32_t)(amp * (float)SPEC_MAX_VAL + 0.5f);
        if (vals[i] > SPEC_MAX_VAL)
        {
            vals[i] = SPEC_MAX_VAL;
        }
        if (vals[i] < 0)
        {
            vals[i] = 0;
        }
    }

    lv_chart_set_series_values(chart, chart_ser, vals, SPEC_BARS);
    lv_chart_refresh(chart);
}

/* 周期刷新:实测频率、指示灯、频谱图 */
static void ui_timer_cb(lv_timer_t * timer)
{
    char buf[24];
    uint8_t en;
    uint8_t out;
    float meas;
    float f0;

    (void)timer;

    if ((lbl_act == NULL) || (led_out == NULL))
    {
        return;
    }

    en   = switch_gen_get_enable();
    out  = switch_gen_get_output();
    meas = switch_gen_get_measured();

    if ((en != 0u) && (meas >= 0.05f))
    {
        snprintf(buf, sizeof(buf), "ACT %.1f Hz", (double)meas);
        f0 = meas;
    }
    else
    {
        snprintf(buf, sizeof(buf), "ACT --");
        f0 = switch_gen_get_freq();
    }
    lv_label_set_text(lbl_act, buf);

    /* 输出电平指示灯 */
    if (out != 0u)
    {
        lv_led_on(led_out);
    }
    else
    {
        lv_led_off(led_out);
    }

    spectrum_update(f0);
}

/* 步进按键:user_data = ±1(Hz) */
static void step_cb(lv_event_t * e)
{
    float step = (float)(intptr_t)lv_event_get_user_data(e);

    ui_set_freq(switch_gen_get_freq() + step);
}

/* 预设按键:user_data = 频率 × 10 */
static void preset_cb(lv_event_t * e)
{
    int32_t tenths = (int32_t)(intptr_t)lv_event_get_user_data(e);

    ui_set_freq((float)tenths / (float)SLIDER_SCALE);
}

/* 滑杆拖动 */
static void slider_cb(lv_event_t * e)
{
    (void)e;

    if (freq_lock != 0u)
    {
        return;
    }

    ui_set_freq((float)lv_slider_get_value(slider) / (float)SLIDER_SCALE);
}

/* 启动 / 停止 */
static void run_cb(lv_event_t * e)
{
    (void)e;

    switch_gen_set_enable((switch_gen_get_enable() != 0u) ? 0u : 1u);
    ui_refresh_run();
    ui_timer_cb(NULL);
}

/* 创建一个圆角按键,返回按键对象,文本作为其唯一子对象 */
static lv_obj_t * make_button(lv_obj_t * parent, const char * txt,
                              uint32_t bg_hex, const lv_font_t * font,
                              lv_event_cb_t cb, void * user_data)
{
    lv_obj_t * btn;
    lv_obj_t * lbl;
    lv_color_t bg = lv_color_hex(bg_hex);

    btn = lv_button_create(parent);
    lv_obj_remove_style_all(btn);
    lv_obj_set_style_bg_color(btn, bg, 0);
    lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(btn, 7, 0);
    lv_obj_set_style_bg_color(btn, lv_color_lighten(bg, 40), LV_STATE_PRESSED);
    lv_obj_remove_flag(btn, LV_OBJ_FLAG_SCROLLABLE);

    if (cb != NULL)
    {
        lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user_data);
    }

    lbl = lv_label_create(btn);
    lv_label_set_text(lbl, txt);
    lv_obj_set_style_text_font(lbl, font, 0);
    lv_obj_set_style_text_color(lbl, lv_color_hex(UI_TXT_HEX), 0);
    lv_obj_center(lbl);

    return btn;
}

/**********************
 *   触摸四点校准
 **********************/
#define CAL_POINT_COUNT  4U    /* 靶标个数            */
#define CAL_MARGIN       30    /* 靶标距屏幕边缘距离  */
#define CAL_SAMPLES      6U    /* 单点采样次数        */
#define CAL_DEBOUNCE     3U    /* 去抖次数(20ms/次) */

static lv_obj_t * cal_ring  = NULL;
static lv_obj_t * cal_bar_h = NULL;
static lv_obj_t * cal_bar_v = NULL;
static lv_obj_t * cal_tip   = NULL;
static lv_timer_t * cal_timer = NULL;

static uint16_t cal_raw[CAL_POINT_COUNT][2];
static int32_t  cal_px[CAL_POINT_COUNT];
static int32_t  cal_py[CAL_POINT_COUNT];

static uint8_t  cal_index;      /* 当前靶标序号            */
static uint8_t  cal_phase;      /* 0 等按下 / 1 采样 / 2 等抬起 */
static uint8_t  cal_debounce;
static uint8_t  cal_samples;
static uint32_t cal_sum_x;
static uint32_t cal_sum_y;

/* 把标定结果限制在 12 位 ADC 的有效范围内 */
static uint16_t cal_clamp(double value)
{
    if (value < 0.0)
    {
        return 0U;
    }
    if (value > 4095.0)
    {
        return 4095U;
    }

    return (uint16_t)(value + 0.5);
}

/* 把靶标移到第 cal_index 个位置并刷新提示文字 */
static void cal_move_target(void)
{
    char text[40];
    int32_t x = cal_px[cal_index];
    int32_t y = cal_py[cal_index];

    lv_obj_set_pos(cal_ring,  x - 28, y - 28);
    lv_obj_set_pos(cal_bar_h, x - 20, y - 2);
    lv_obj_set_pos(cal_bar_v, x - 2,  y - 20);

    snprintf(text, sizeof(text), "TAP THE CROSS  %u/%u",
             (unsigned)(cal_index + 1U), (unsigned)CAL_POINT_COUNT);
    lv_label_set_text(cal_tip, text);
}

/* 四点采集完成:解算标定参数、应用并返回主界面 */
static void cal_finish(void)
{
    XPT2046_Calibration cal;
    lv_display_t * disp = lv_display_get_default();
    int32_t scr_w = lv_display_get_horizontal_resolution(disp);
    int32_t scr_h = lv_display_get_vertical_resolution(disp);
    int32_t xl = CAL_MARGIN;
    int32_t xr = scr_w - 1 - CAL_MARGIN;
    int32_t yt = CAL_MARGIN;
    int32_t yb = scr_h - 1 - CAL_MARGIN;
    double r[CAL_POINT_COUNT][2];
    double h_left;
    double h_right;
    double v_top;
    double v_bottom;
    double slope;
    double raw_lo;
    double raw_hi;
    uint8_t swap;
    uint8_t i;

    for (i = 0U; i < CAL_POINT_COUNT; i++)
    {
        r[i][0] = (double)cal_raw[i][0];
        r[i][1] = (double)cal_raw[i][1];
    }

    /* 水平方向(左上->右上)哪个原始通道变化更大,它就是横轴 */
    {
        double dx = fabs(r[0][0] - r[1][0]) + fabs(r[2][0] - r[3][0]);
        double dy = fabs(r[0][1] - r[1][1]) + fabs(r[2][1] - r[3][1]);

        swap = (dx >= dy) ? 0U : 1U;
    }

    if (swap == 0U)
    {
        h_left   = (r[0][0] + r[2][0]) / 2.0;
        h_right  = (r[1][0] + r[3][0]) / 2.0;
        v_top    = (r[0][1] + r[1][1]) / 2.0;
        v_bottom = (r[2][1] + r[3][1]) / 2.0;
    }
    else
    {
        h_left   = (r[0][1] + r[2][1]) / 2.0;
        h_right  = (r[1][1] + r[3][1]) / 2.0;
        v_top    = (r[0][0] + r[1][0]) / 2.0;
        v_bottom = (r[2][0] + r[3][0]) / 2.0;
    }

    /* 两点几乎重合说明采样无效,重新采一轮 */
    if ((fabs(h_right - h_left) < 80.0) || (fabs(v_bottom - v_top) < 80.0))
    {
        cal_index    = 0U;
        cal_phase    = 0U;
        cal_debounce = 0U;
        cal_move_target();
        lv_label_set_text(cal_tip, "BAD DATA, TAP AGAIN");
        return;
    }

    /* 水平轴:由两个靶标点线性外推到屏幕左右边缘 */
    slope      = (h_right - h_left) / (double)(xr - xl);
    raw_lo     = h_left - (double)xl * slope;
    raw_hi     = h_left + (double)(scr_w - 1 - xl) * slope;
    cal.x_min  = cal_clamp(raw_lo);
    cal.x_max  = cal_clamp(raw_hi);

    /* 竖直轴 */
    slope      = (v_bottom - v_top) / (double)(yb - yt);
    raw_lo     = v_top - (double)yt * slope;
    raw_hi     = v_top + (double)(scr_h - 1 - yt) * slope;
    cal.y_min  = cal_clamp(raw_lo);
    cal.y_max  = cal_clamp(raw_hi);

    cal.swap_xy  = swap;
    cal.invert_x = 0U;
    cal.invert_y = 0U;

    XPT2046_SetCalibration(&cal);

    printf("\r\n==== XPT2046 calibration ====\r\n");
    printf("#define XPT2046_RAW_X_MIN %uU\r\n", (unsigned)cal.x_min);
    printf("#define XPT2046_RAW_X_MAX %uU\r\n", (unsigned)cal.x_max);
    printf("#define XPT2046_RAW_Y_MIN %uU\r\n", (unsigned)cal.y_min);
    printf("#define XPT2046_RAW_Y_MAX %uU\r\n", (unsigned)cal.y_max);
    printf("#define XPT2046_SWAP_XY %uU\r\n", (unsigned)cal.swap_xy);
    printf("#define XPT2046_INVERT_X %uU\r\n", (unsigned)cal.invert_x);
    printf("#define XPT2046_INVERT_Y %uU\r\n", (unsigned)cal.invert_y);
    printf("=============================\r\n");

    if (cal_timer != NULL)
    {
        lv_timer_delete(cal_timer);
        cal_timer = NULL;
    }
    cal_ring  = NULL;
    cal_bar_h = NULL;
    cal_bar_v = NULL;
    cal_tip   = NULL;

    ui_init();
}

/* 校准采样状态机,20ms 一次 */
static void cal_timer_cb(lv_timer_t * timer)
{
    uint16_t rx = 0U;
    uint16_t ry = 0U;
    uint8_t pressed;

    (void)timer;

    pressed = XPT2046_ReadRawPoint(&rx, &ry);

    switch (cal_phase)
    {
    case 3U:                            /* 刚进入:等长按的手指先离开 */
        if (pressed == 0U)
        {
            cal_phase    = 0U;
            cal_debounce = 0U;
        }
        break;

    case 0U:                            /* 等待一次新的按下 */
        if (pressed != 0U)
        {
            if (cal_debounce < CAL_DEBOUNCE)
            {
                cal_debounce++;
            }

            if (cal_debounce >= CAL_DEBOUNCE)
            {
                cal_phase   = 1U;
                cal_sum_x   = rx;
                cal_sum_y   = ry;
                cal_samples = 1U;
            }
        }
        else
        {
            cal_debounce = 0U;
        }
        break;

    case 1U:                            /* 累积采样 */
        if ((pressed != 0U) && (cal_samples < CAL_SAMPLES))
        {
            cal_sum_x += rx;
            cal_sum_y += ry;
            cal_samples++;

            if (cal_samples < CAL_SAMPLES)
            {
                break;
            }
        }

        /* 采样够了或者手指提前抬起,都记下这一点的平均值 */
        cal_raw[cal_index][0] = (uint16_t)(cal_sum_x / cal_samples);
        cal_raw[cal_index][1] = (uint16_t)(cal_sum_y / cal_samples);
        cal_index++;
        cal_phase = 2U;
        break;

    case 2U:                            /* 等手指抬起,避免一次点击采多点 */
        if (pressed == 0U)
        {
            cal_phase    = 0U;
            cal_debounce = 0U;

            if (cal_index >= CAL_POINT_COUNT)
            {
                cal_finish();
            }
            else
            {
                cal_move_target();
            }
        }
        break;

    default:
        break;
    }
}

/* 进入四点校准界面 */
static void touch_calibration_start(void)
{
    lv_display_t * disp = lv_display_get_default();
    lv_obj_t * scr = lv_screen_active();
    int32_t scr_w = lv_display_get_horizontal_resolution(disp);
    int32_t scr_h = lv_display_get_vertical_resolution(disp);
    uint8_t i;

    lv_obj_clean(scr);
    lv_obj_set_style_bg_color(scr, lv_color_hex(0x101828), 0);
    lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
    lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);

    /* 主界面的对象随 clean 一起被删掉了,置空并停掉刷新定时器 */
    lbl_value   = NULL;
    lbl_act     = NULL;
    led_out     = NULL;
    chart       = NULL;
    chart_ser   = NULL;
    spec_marker = NULL;
    slider      = NULL;
    btn_run     = NULL;
    lbl_run     = NULL;

    if (ui_timer != NULL)
    {
        lv_timer_delete(ui_timer);
        ui_timer = NULL;
    }

    cal_px[0] = CAL_MARGIN;
    cal_py[0] = CAL_MARGIN;
    cal_px[1] = scr_w - 1 - CAL_MARGIN;
    cal_py[1] = CAL_MARGIN;
    cal_px[2] = CAL_MARGIN;
    cal_py[2] = scr_h - 1 - CAL_MARGIN;
    cal_px[3] = scr_w - 1 - CAL_MARGIN;
    cal_py[3] = scr_h - 1 - CAL_MARGIN;

    for (i = 0U; i < CAL_POINT_COUNT; i++)
    {
        cal_raw[i][0] = 0U;
        cal_raw[i][1] = 0U;
    }

    /* 靶标:一个圆环加十字线 */
    cal_ring = lv_obj_create(scr);
    lv_obj_remove_style_all(cal_ring);
    lv_obj_set_size(cal_ring, 56, 56);
    lv_obj_set_style_radius(cal_ring, LV_RADIUS_CIRCLE, 0);
    lv_obj_set_style_border_width(cal_ring, 2, 0);
    lv_obj_set_style_border_color(cal_ring, lv_color_hex(0x4C6FFF), 0);
    lv_obj_remove_flag(cal_ring, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_remove_flag(cal_ring, LV_OBJ_FLAG_CLICKABLE);

    cal_bar_h = lv_obj_create(scr);
    lv_obj_remove_style_all(cal_bar_h);
    lv_obj_set_size(cal_bar_h, 40, 4);
    lv_obj_set_style_bg_color(cal_bar_h, lv_color_white(), 0);
    lv_obj_set_style_bg_opa(cal_bar_h, LV_OPA_COVER, 0);
    lv_obj_remove_flag(cal_bar_h, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_remove_flag(cal_bar_h, LV_OBJ_FLAG_CLICKABLE);

    cal_bar_v = lv_obj_create(scr);
    lv_obj_remove_style_all(cal_bar_v);
    lv_obj_set_size(cal_bar_v, 4, 40);
    lv_obj_set_style_bg_color(cal_bar_v, lv_color_white(), 0);
    lv_obj_set_style_bg_opa(cal_bar_v, LV_OPA_COVER, 0);
    lv_obj_remove_flag(cal_bar_v, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_remove_flag(cal_bar_v, LV_OBJ_FLAG_CLICKABLE);

    cal_tip = lv_label_create(scr);
    lv_obj_set_style_text_font(cal_tip, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(cal_tip, lv_color_white(), 0);
    lv_obj_align(cal_tip, LV_ALIGN_CENTER, 0, 0);
    lv_obj_set_style_text_align(cal_tip, LV_TEXT_ALIGN_CENTER, 0);

    cal_index    = 0U;
    cal_phase    = 3U;              /* 先等长按的手指抬起 */
    cal_debounce = 0U;
    cal_samples  = 0U;
    cal_move_target();

    if (cal_timer != NULL)
    {
        lv_timer_delete(cal_timer);
    }
    cal_timer = lv_timer_create(cal_timer_cb, 20U, NULL);
}

/* 切换界面会删掉正在派发事件的按钮,交给 async 在本轮事件结束后执行 */
static void cal_start_async(void * unused)
{
    (void)unused;

    touch_calibration_start();
}

/* 长按左侧目标频率卡片进入校准 */
static void card_long_cb(lv_event_t * e)
{
    (void)e;

    (void)lv_async_call(cal_start_async, NULL);
}

/**********************
 *   GLOBAL FUNCTIONS
 **********************/
void ui_init(void)
{
    lv_display_t * disp;
    lv_obj_t * scr;
    lv_obj_t * head;
    lv_obj_t * card;
    lv_obj_t * unit;
    lv_obj_t * row;
    lv_obj_t * spec_panel;
    lv_obj_t * spec_axis;
    int32_t scr_w;
    int32_t scr_h;
    int32_t spec_x;
    int32_t spec_w;
    int32_t plot_w;
    int32_t main_y;
    int32_t slider_y;
    int32_t row_y;
    int32_t run_y;

    /* 重新构建界面前,先清掉旧的定时器与对象指针 */
    if (cal_timer != NULL)
    {
        lv_timer_delete(cal_timer);
        cal_timer = NULL;
    }
    if (ui_timer != NULL)
    {
        lv_timer_delete(ui_timer);
        ui_timer = NULL;
    }
    cal_ring  = NULL;
    cal_bar_h = NULL;
    cal_bar_v = NULL;
    cal_tip   = NULL;
    lbl_value = NULL;
    lbl_act   = NULL;
    led_out   = NULL;
    chart     = NULL;
    chart_ser = NULL;
    spec_marker = NULL;
    slider    = NULL;
    btn_run   = NULL;
    lbl_run   = NULL;

    disp  = lv_display_get_default();
    scr   = lv_screen_active();
    scr_w = lv_display_get_horizontal_resolution(disp);
    scr_h = lv_display_get_vertical_resolution(disp);

    lv_obj_clean(scr);
    lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG_HEX), 0);
    lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
    lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);

    main_y   = UI_PAD + UI_HEAD_H + UI_GAP;
    slider_y = main_y + UI_MAIN_H + UI_GAP;
    row_y    = slider_y + UI_SLIDER_H + UI_GAP;
    run_y    = row_y + UI_ROW_H + UI_GAP;

    /* ---------- 顶栏 ---------- */
    head = lv_obj_create(scr);
    lv_obj_remove_style_all(head);
    lv_obj_set_pos(head, UI_PAD, UI_PAD);
    lv_obj_set_size(head, scr_w - (2 * UI_PAD), UI_HEAD_H);
    lv_obj_set_style_bg_color(head, lv_color_hex(UI_HEAD_HEX), 0);
    lv_obj_set_style_bg_opa(head, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(head, 8, 0);
    lv_obj_remove_flag(head, LV_OBJ_FLAG_SCROLLABLE);

    {
        lv_obj_t * title = lv_label_create(head);
        lv_label_set_text(title, "FREQ SWITCH");
        lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0);
        lv_obj_set_style_text_color(title, lv_color_hex(UI_TXT_HEX), 0);
        lv_obj_align(title, LV_ALIGN_LEFT_MID, 7, 0);
    }

    lbl_act = lv_label_create(head);
    lv_label_set_text(lbl_act, "ACT --");
    lv_obj_set_style_text_font(lbl_act, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(lbl_act, lv_color_hex(UI_ACCENT_HEX), 0);
    lv_obj_align(lbl_act, LV_ALIGN_RIGHT_MID, -7, 0);

    /* ---------- 左:目标频率卡片(长按进入触摸校准) ---------- */
    card = lv_obj_create(scr);
    lv_obj_remove_style_all(card);
    lv_obj_set_pos(card, UI_PAD, main_y);
    lv_obj_set_size(card, UI_CARD_W, UI_MAIN_H);
    lv_obj_set_style_bg_color(card, lv_color_hex(UI_PANEL_HEX), 0);
    lv_obj_set_style_bg_opa(card, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(card, 10, 0);
    lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_add_flag(card, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_add_event_cb(card, card_long_cb, LV_EVENT_LONG_PRESSED, NULL);

    led_out = lv_led_create(card);
    lv_obj_set_size(led_out, 14, 14);
    lv_obj_align(led_out, LV_ALIGN_TOP_RIGHT, -8, 8);
    lv_led_set_color(led_out, lv_color_hex(UI_OK_HEX));
    lv_led_off(led_out);
    /* 不要让指示灯挡住卡片的长按(进入触摸校准) */
    lv_obj_remove_flag(led_out, LV_OBJ_FLAG_CLICKABLE);

    {
        lv_obj_t * lb = lv_label_create(card);
        lv_label_set_text(lb, "TARGET");
        lv_obj_set_style_text_font(lb, &lv_font_montserrat_14, 0);
        lv_obj_set_style_text_color(lb, lv_color_hex(UI_MUTED_HEX), 0);
        lv_obj_align(lb, LV_ALIGN_TOP_MID, 0, 5);
    }

    lbl_value = lv_label_create(card);
    lv_label_set_text(lbl_value, "10.0");
    lv_obj_set_style_text_font(lbl_value, &lv_font_montserrat_28, 0);
    lv_obj_set_style_text_color(lbl_value, lv_color_hex(UI_TXT_HEX), 0);
    lv_obj_align(lbl_value, LV_ALIGN_CENTER, 0, 4);

    unit = lv_label_create(card);
    lv_label_set_text(unit, "Hz");
    lv_obj_set_style_text_font(unit, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(unit, lv_color_hex(UI_MUTED_HEX), 0);
    lv_obj_align(unit, LV_ALIGN_BOTTOM_MID, 0, -4);

    /* ---------- 右:频谱图(对数频率轴 0.1 ~ 100 Hz) ---------- */
    spec_x = UI_PAD + UI_CARD_W + UI_GAP;
    spec_w = scr_w - UI_PAD - spec_x;
    plot_w = spec_w - (2 * SPEC_PAD);
    spec_plot_w = plot_w;

    spec_panel = lv_obj_create(scr);
    lv_obj_remove_style_all(spec_panel);
    lv_obj_set_pos(spec_panel, spec_x, main_y);
    lv_obj_set_size(spec_panel, spec_w, UI_MAIN_H);
    lv_obj_set_style_bg_color(spec_panel, lv_color_hex(UI_PANEL_HEX), 0);
    lv_obj_set_style_bg_opa(spec_panel, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(spec_panel, 10, 0);
    lv_obj_remove_flag(spec_panel, LV_OBJ_FLAG_SCROLLABLE);

    chart = lv_chart_create(spec_panel);
    lv_obj_set_pos(chart, SPEC_PAD, SPEC_PAD);
    lv_obj_set_size(chart, plot_w, SPEC_CHART_H);
    lv_obj_set_style_bg_color(chart, lv_color_hex(0x141B28), 0);
    lv_obj_set_style_bg_opa(chart, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(chart, 4, 0);
    lv_obj_set_style_border_width(chart, 0, 0);
    lv_obj_set_style_pad_all(chart, 0, 0);
    lv_obj_set_style_line_color(chart, lv_color_hex(0x27324A), LV_PART_MAIN);
    lv_obj_remove_flag(chart, LV_OBJ_FLAG_SCROLLABLE);

    lv_chart_set_type(chart, LV_CHART_TYPE_BAR);
    lv_chart_set_point_count(chart, SPEC_BARS);
    lv_chart_set_axis_range(chart, LV_CHART_AXIS_PRIMARY_Y, 0, SPEC_MAX_VAL);
    lv_chart_set_div_line_count(chart, 3, 4);
    chart_ser = lv_chart_add_series(chart, lv_color_hex(UI_ACCENT_HEX),
                                    LV_CHART_AXIS_PRIMARY_Y);
    lv_chart_set_all_values(chart, chart_ser, 0);

    /* 当前频率指示线:竖直白线,随频率在频谱上左右移动 */
    spec_marker = lv_obj_create(chart);
    lv_obj_remove_style_all(spec_marker);
    lv_obj_set_size(spec_marker, 2, SPEC_CHART_H);
    lv_obj_set_pos(spec_marker, 0, 0);
    lv_obj_set_style_bg_color(spec_marker, lv_color_white(), 0);
    lv_obj_set_style_bg_opa(spec_marker, LV_OPA_70, 0);
    lv_obj_remove_flag(spec_marker, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_remove_flag(spec_marker, LV_OBJ_FLAG_CLICKABLE);

    /* 频率刻度条:在对数轴上标出 0.1 / 1 / 10 / 100 Hz 的位置 */
    spec_axis = lv_obj_create(spec_panel);
    lv_obj_remove_style_all(spec_axis);
    lv_obj_set_pos(spec_axis, SPEC_PAD, SPEC_PAD + SPEC_CHART_H + 2);
    lv_obj_set_size(spec_axis, plot_w, SPEC_AXIS_H);
    lv_obj_remove_flag(spec_axis, LV_OBJ_FLAG_SCROLLABLE);

    {
        /* 对数轴上的十进制倍数刻度 */
        static const float tick_f[SPEC_TICK_NUM] = {0.1f, 1.0f, 10.0f, 100.0f};
        static const char * tick_s[SPEC_TICK_NUM] = {"0.1", "1", "10", "100"};
        lv_obj_t * tick[SPEC_TICK_NUM];
        int32_t k;

        for (k = 0; k < SPEC_TICK_NUM; k++)
        {
            tick[k] = lv_label_create(spec_axis);
            lv_label_set_text(tick[k], tick_s[k]);
            lv_obj_set_style_text_font(tick[k], &lv_font_montserrat_14, 0);
            lv_obj_set_style_text_color(tick[k], lv_color_hex(UI_MUTED_HEX), 0);
        }

        /* 文字宽度要布局之后才知道,用它把标签中点对准刻度所在位置 */
        lv_obj_update_layout(spec_axis);

        for (k = 0; k < SPEC_TICK_NUM; k++)
        {
            int32_t w = lv_obj_get_width(tick[k]);
            int32_t x = spec_x_of(tick_f[k], plot_w) - (w / 2);

            if (x < 0)
            {
                x = 0;
            }
            if (x > (plot_w - w))
            {
                x = plot_w - w;
            }
            lv_obj_set_pos(tick[k], x,
                           (SPEC_AXIS_H - lv_obj_get_height(tick[k])) / 2);
        }
    }

    /* ---------- 频率滑杆 ---------- */
    slider = lv_slider_create(scr);
    lv_obj_set_pos(slider, UI_PAD, slider_y);
    lv_obj_set_size(slider, scr_w - (2 * UI_PAD), UI_SLIDER_H);
    lv_slider_set_range(slider, SLIDER_MIN, SLIDER_MAX);
    lv_obj_set_style_bg_color(slider, lv_color_hex(UI_BTN_HEX), LV_PART_MAIN);
    lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_MAIN);
    lv_obj_set_style_radius(slider, LV_RADIUS_CIRCLE, LV_PART_MAIN);
    lv_obj_set_style_bg_color(slider, lv_color_hex(UI_ACCENT_HEX), LV_PART_INDICATOR);
    lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_INDICATOR);
    lv_obj_set_style_radius(slider, LV_RADIUS_CIRCLE, LV_PART_INDICATOR);
    lv_obj_set_style_bg_color(slider, lv_color_hex(UI_TXT_HEX), LV_PART_KNOB);
    lv_obj_set_style_bg_opa(slider, LV_OPA_COVER, LV_PART_KNOB);
    lv_obj_set_style_radius(slider, LV_RADIUS_CIRCLE, LV_PART_KNOB);
    lv_obj_set_style_pad_all(slider, 4, LV_PART_KNOB);
    lv_obj_add_event_cb(slider, slider_cb, LV_EVENT_VALUE_CHANGED, NULL);

    /* ---------- 步进 / 预设按键 ---------- */
    row = lv_obj_create(scr);
    lv_obj_remove_style_all(row);
    lv_obj_set_pos(row, UI_PAD, row_y);
    lv_obj_set_size(row, scr_w - (2 * UI_PAD), UI_ROW_H);
    lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
    lv_obj_set_style_pad_column(row, UI_GAP, 0);
    lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);

    {
        lv_obj_t * b;

        b = make_button(row, "-", UI_BTN_HEX, &lv_font_montserrat_20,
                        step_cb, (void *)(intptr_t)(-1));
        lv_obj_set_flex_grow(b, 1);
        lv_obj_set_height(b, LV_PCT(100));

        b = make_button(row, "+", UI_BTN_HEX, &lv_font_montserrat_20,
                        step_cb, (void *)(intptr_t)(1));
        lv_obj_set_flex_grow(b, 1);
        lv_obj_set_height(b, LV_PCT(100));

        b = make_button(row, "1", UI_BTN_HEX, &lv_font_montserrat_14,
                        preset_cb, (void *)(intptr_t)(1 * SLIDER_SCALE));
        lv_obj_set_flex_grow(b, 1);
        lv_obj_set_height(b, LV_PCT(100));

        b = make_button(row, "5", UI_BTN_HEX, &lv_font_montserrat_14,
                        preset_cb, (void *)(intptr_t)(5 * SLIDER_SCALE));
        lv_obj_set_flex_grow(b, 1);
        lv_obj_set_height(b, LV_PCT(100));

        b = make_button(row, "10", UI_BTN_HEX, &lv_font_montserrat_14,
                        preset_cb, (void *)(intptr_t)(10 * SLIDER_SCALE));
        lv_obj_set_flex_grow(b, 1);
        lv_obj_set_height(b, LV_PCT(100));

        b = make_button(row, "50", UI_BTN_HEX, &lv_font_montserrat_14,
                        preset_cb, (void *)(intptr_t)(50 * SLIDER_SCALE));
        lv_obj_set_flex_grow(b, 1);
        lv_obj_set_height(b, LV_PCT(100));

        b = make_button(row, "100", UI_BTN_HEX, &lv_font_montserrat_14,
                        preset_cb, (void *)(intptr_t)(100 * SLIDER_SCALE));
        lv_obj_set_flex_grow(b, 1);
        lv_obj_set_height(b, LV_PCT(100));
    }

    /* ---------- 启动 / 停止 ---------- */
    btn_run = make_button(scr, "START", UI_OK_HEX, &lv_font_montserrat_20,
                          run_cb, NULL);
    lv_obj_set_pos(btn_run, UI_PAD, run_y);
    lv_obj_set_size(btn_run, scr_w - (2 * UI_PAD), scr_h - UI_PAD - run_y);
    lbl_run = lv_obj_get_child(btn_run, 0);

    /* ---------- 初始状态 ---------- */
    ui_set_freq(switch_gen_get_freq());
    ui_refresh_run();
    ui_timer = lv_timer_create(ui_timer_cb, 100U, NULL);
    ui_timer_cb(NULL);
}

效果展示:

在这里插入图片描述

3.通过屏幕上显示串口接收和串口输入输出通信交互

核心部分:

/*********************
 *      DEFINES
 *********************/
#define UI_BG_HEX       0x101828  /* 屏幕底色          */
#define UI_PANEL_HEX    0x1A2130  /* 面板底色          */
#define UI_HEAD_HEX     0x1E2434  /* 顶栏底色          */
#define UI_BTN_HEX      0x2A3550  /* 普通按键          */
#define UI_FIELD_HEX    0x141B28  /* 输入 / 显示区底色 */
#define UI_TXT_HEX      0xFFFFFF
#define UI_MUTED_HEX    0x8A90A6
#define UI_ACCENT_HEX   0x33C6FF  /* 高亮 / 计数       */
#define UI_OK_HEX       0x36D399  /* 发送按键          */
#define UI_STOP_HEX     0xF87272  /* 清空按键          */

#define UI_PAD          4
#define UI_GAP          4
#define UI_HEAD_H       20
#define UI_HEAD_Y       UI_PAD
#define UI_LOG_Y        (UI_HEAD_Y + UI_HEAD_H + UI_GAP)   /* 28 */
#define UI_LOG_H        56
#define UI_ROW_Y        (UI_LOG_Y + UI_LOG_H + UI_GAP)     /* 88 */
#define UI_ROW_H        26
#define UI_KB_Y         (UI_ROW_Y + UI_ROW_H + UI_GAP)     /* 118 */

#define UI_INPUT_W      174       /* 输入框宽度        */
#define UI_SEND_W       62        /* 发送按键宽度      */
#define UI_CLR_W        68        /* 清空按键宽度      */

#define RX_POLL_MS      50U       /* 接收轮询周期(ms)  */
#define LOG_MAX_CHARS   420U      /* 显示区缓存上限,超过先自动清空 */
#define INPUT_MAX_CHARS 32U       /* 单条发送内容上限  */

/**********************
 *  STATIC VARIABLES
 **********************/
static lv_obj_t * log_ta   = NULL;   /* 接收显示区(只读) */
static lv_obj_t * in_ta    = NULL;   /* 输入框             */
static lv_obj_t * kb       = NULL;   /* 软键盘             */
static lv_obj_t * lbl_rx   = NULL;   /* 顶栏接收计数       */
static lv_timer_t * ui_timer = NULL; /* 接收轮询定时器     */
static uint32_t log_len    = 0U;     /* 显示区当前字符数   */
static uint32_t rx_total   = 0U;     /* 累计接收字节数     */

/**********************
 *   FORWARD DECLS
 **********************/
static void ui_log_add(const char *txt);

/**********************
 *  STATIC FUNCTIONS
 **********************/

/* 清空接收显示区并复位字符计数 */
static void ui_log_clear(void)
{
    if (log_ta == NULL)
    {
        return;
    }

    lv_textarea_set_text(log_ta, "");
    log_len = 0U;
}

/* 追加一行文本到接收显示区;缓存接近上限时先自动清空 */
static void ui_log_add(const char *txt)
{
    uint32_t len;

    if ((log_ta == NULL) || (txt == NULL))
    {
        return;
    }

    len = (uint32_t)strlen(txt);
    if (len == 0U)
    {
        return;
    }

    /* 接收满:先自动清理缓存界面,再从空开始显示 */
    if ((log_len + len) > LOG_MAX_CHARS)
    {
        ui_log_clear();
    }

    /* 单条就超过上限:只保留尾部 */
    if (len > LOG_MAX_CHARS)
    {
        txt += (len - LOG_MAX_CHARS);
        len  = LOG_MAX_CHARS;
    }

    lv_textarea_add_text(log_ta, txt);
    log_len += len;

    /* 光标移到末尾并滚到底部,保证最新数据可见 */
    lv_textarea_set_cursor_pos(log_ta, LV_TEXTAREA_CURSOR_LAST);
    lv_obj_scroll_to_y(log_ta, LV_COORD_MAX, LV_ANIM_OFF);
}

/* 把原始接收字节过滤成可显示文本(\r 丢弃,\n 保留,其它控制符变 '.') */
static uint16_t rx_filter(const uint8_t *src, uint16_t n, char *dst)
{
    uint16_t i;
    uint16_t m = 0U;

    for (i = 0U; i < n; i++)
    {
        uint8_t c = src[i];

        if (c == (uint8_t)'\r')
        {
            continue;
        }
        if (c == (uint8_t)'\n')
        {
            dst[m++] = '\n';
            continue;
        }
        if (c == (uint8_t)'\t')
        {
            dst[m++] = ' ';
            continue;
        }
        if ((c < 0x20U) || (c == 0x7FU))
        {
            dst[m++] = '.';
            continue;
        }

        dst[m++] = (char)c;
    }

    dst[m] = '\0';
    return m;
}

/* 周期把串口1 收到的字节搬进显示区 */
static void ui_rx_timer_cb(lv_timer_t * timer)
{
    char  buf[40];
    char  cnt[16];
    uint8_t raw[32];
    uint16_t n;

    (void)timer;

    n = uart1_rx_read(raw, (uint16_t)sizeof(raw));
    while (n > 0U)
    {
        (void)rx_filter(raw, n, buf);
        ui_log_add(buf);

        rx_total += n;
        n = uart1_rx_read(raw, (uint16_t)sizeof(raw));
    }

    if (lbl_rx != NULL)
    {
        snprintf(cnt, sizeof(cnt), "RX %lu", (unsigned long)rx_total);
        lv_label_set_text(lbl_rx, cnt);
    }
}

/* 读取输入框内容,经串口1 发出并本地回显 */
static void ui_send_text(void)
{
    const char *txt;
    char echo[INPUT_MAX_CHARS + 8U];

    if (in_ta == NULL)
    {
        return;
    }

    txt = lv_textarea_get_text(in_ta);
    if ((txt == NULL) || (txt[0] == '\0'))
    {
        return;
    }

    uart1_send_string(txt);                     /* 发送输入内容        */
    uart1_send((const uint8_t *)"\r\n", 2U);    /* 补一个换行,便于对端按行解析 */

    snprintf(echo, sizeof(echo), "> %s\n", txt);/* 本地回显            */
    ui_log_add(echo);

    lv_textarea_set_text(in_ta, "");            /* 清空输入框          */
    lv_textarea_set_cursor_pos(in_ta, LV_TEXTAREA_CURSOR_LAST);
}

/* SEND 按键 */
static void send_cb(lv_event_t * e)
{
    (void)e;

    ui_send_text();
}

/* CLR 按键 */
static void clear_cb(lv_event_t * e)
{
    (void)e;

    ui_log_clear();
}

/* 软键盘:按 OK 键直接发送 */
static void kb_cb(lv_event_t * e)
{
    if (lv_event_get_code(e) == LV_EVENT_READY)
    {
        ui_send_text();
    }
}

/* 创建一个圆角按键,返回按键对象,文本作为其唯一子对象 */
static lv_obj_t * make_button(lv_obj_t * parent, const char * txt,
                              uint32_t bg_hex, const lv_font_t * font,
                              lv_event_cb_t cb, void * user_data)
{
    lv_obj_t * btn;
    lv_obj_t * lbl;
    lv_color_t bg = lv_color_hex(bg_hex);

    btn = lv_button_create(parent);
    lv_obj_remove_style_all(btn);
    lv_obj_set_style_bg_color(btn, bg, 0);
    lv_obj_set_style_bg_opa(btn, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(btn, 6, 0);
    lv_obj_set_style_bg_color(btn, lv_color_lighten(bg, 40), LV_STATE_PRESSED);
    lv_obj_remove_flag(btn, LV_OBJ_FLAG_SCROLLABLE);

    if (cb != NULL)
    {
        lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, user_data);
    }

    lbl = lv_label_create(btn);
    lv_label_set_text(lbl, txt);
    lv_obj_set_style_text_font(lbl, font, 0);
    lv_obj_set_style_text_color(lbl, lv_color_hex(UI_TXT_HEX), 0);
    lv_obj_center(lbl);

    return btn;
}

/**********************
 *   触摸四点校准
 **********************/
#define CAL_POINT_COUNT  4U    /* 靶标个数            */
#define CAL_MARGIN       30    /* 靶标距屏幕边缘距离  */
#define CAL_SAMPLES      6U    /* 单点采样次数        */
#define CAL_DEBOUNCE     3U    /* 去抖次数(20ms/次) */

static lv_obj_t * cal_ring  = NULL;
static lv_obj_t * cal_bar_h = NULL;
static lv_obj_t * cal_bar_v = NULL;
static lv_obj_t * cal_tip   = NULL;
static lv_timer_t * cal_timer = NULL;
static lv_timer_t * cal_exit_timer = NULL;   /* 校准完成后的返回延时 */
static lv_timer_t * boot_cal_timer = NULL;   /* 开机按住屏幕进校准的检测 */

static uint16_t cal_raw[CAL_POINT_COUNT][2];
static int32_t  cal_px[CAL_POINT_COUNT];
static int32_t  cal_py[CAL_POINT_COUNT];

static uint8_t  cal_index;      /* 当前靶标序号            */
static uint8_t  cal_phase;      /* 0 等按下 / 1 采样 / 2 等抬起 */
static uint8_t  cal_debounce;
static uint8_t  cal_samples;
static uint32_t cal_sum_x;
static uint32_t cal_sum_y;

/* 把标定结果限制在 12 位 ADC 的有效范围内 */
static uint16_t cal_clamp(double value)
{
    if (value < 0.0)
    {
        return 0U;
    }
    if (value > 4095.0)
    {
        return 4095U;
    }

    return (uint16_t)(value + 0.5);
}

/* 校准结果提示看够 0.7 秒后回主界面 */
static void cal_exit_cb(lv_timer_t * timer)
{
    lv_timer_delete(timer);
    cal_exit_timer = NULL;

    ui_init();
}

/* 把靶标移到第 cal_index 个位置并刷新提示文字 */
static void cal_move_target(void)
{
    char text[40];
    int32_t x = cal_px[cal_index];
    int32_t y = cal_py[cal_index];

    lv_obj_set_pos(cal_ring,  x - 28, y - 28);
    lv_obj_set_pos(cal_bar_h, x - 20, y - 2);
    lv_obj_set_pos(cal_bar_v, x - 2,  y - 20);

    snprintf(text, sizeof(text), "TAP THE CROSS  %u/%u",
             (unsigned)(cal_index + 1U), (unsigned)CAL_POINT_COUNT);
    lv_label_set_text(cal_tip, text);
}

/* 四点采集完成:解算标定参数、应用并返回主界面 */
static void cal_finish(void)
{
    XPT2046_Calibration cal;
    lv_display_t * disp = lv_display_get_default();
    int32_t scr_w = lv_display_get_horizontal_resolution(disp);
    int32_t scr_h = lv_display_get_vertical_resolution(disp);
    int32_t xl = CAL_MARGIN;
    int32_t xr = scr_w - 1 - CAL_MARGIN;
    int32_t yt = CAL_MARGIN;
    int32_t yb = scr_h - 1 - CAL_MARGIN;
    double r[CAL_POINT_COUNT][2];
    double h_left;
    double h_right;
    double v_top;
    double v_bottom;
    double slope;
    double raw_lo;
    double raw_hi;
    uint8_t swap;
    uint8_t saved;
    uint8_t i;

    for (i = 0U; i < CAL_POINT_COUNT; i++)
    {
        r[i][0] = (double)cal_raw[i][0];
        r[i][1] = (double)cal_raw[i][1];
    }

    /* 水平方向(左上->右上)哪个原始通道变化更大,它就是横轴 */
    {
        double dx = fabs(r[0][0] - r[1][0]) + fabs(r[2][0] - r[3][0]);
        double dy = fabs(r[0][1] - r[1][1]) + fabs(r[2][1] - r[3][1]);

        swap = (dx >= dy) ? 0U : 1U;
    }

    if (swap == 0U)
    {
        h_left   = (r[0][0] + r[2][0]) / 2.0;
        h_right  = (r[1][0] + r[3][0]) / 2.0;
        v_top    = (r[0][1] + r[1][1]) / 2.0;
        v_bottom = (r[2][1] + r[3][1]) / 2.0;
    }
    else
    {
        h_left   = (r[0][1] + r[2][1]) / 2.0;
        h_right  = (r[1][1] + r[3][1]) / 2.0;
        v_top    = (r[0][0] + r[1][0]) / 2.0;
        v_bottom = (r[2][0] + r[3][0]) / 2.0;
    }

    /* 两点几乎重合说明采样无效,重新采一轮 */
    if ((fabs(h_right - h_left) < 80.0) || (fabs(v_bottom - v_top) < 80.0))
    {
        cal_index    = 0U;
        cal_phase    = 0U;
        cal_debounce = 0U;
        cal_move_target();
        lv_label_set_text(cal_tip, "BAD DATA, TAP AGAIN");
        return;
    }

    /* 水平轴:由两个靶标点线性外推到屏幕左右边缘 */
    slope      = (h_right - h_left) / (double)(xr - xl);
    raw_lo     = h_left - (double)xl * slope;
    raw_hi     = h_left + (double)(scr_w - 1 - xl) * slope;
    cal.x_min  = cal_clamp(raw_lo);
    cal.x_max  = cal_clamp(raw_hi);

    /* 竖直轴 */
    slope      = (v_bottom - v_top) / (double)(yb - yt);
    raw_lo     = v_top - (double)yt * slope;
    raw_hi     = v_top + (double)(scr_h - 1 - yt) * slope;
    cal.y_min  = cal_clamp(raw_lo);
    cal.y_max  = cal_clamp(raw_hi);

    cal.swap_xy  = swap;
    cal.invert_x = 0U;
    cal.invert_y = 0U;

    XPT2046_SetCalibration(&cal);

    /* 新参数立刻生效;同时写进内部 Flash,下次上电 XPT2046_Init() 会自动读回。
       这样"触控偏移 -> 重新校准"一次就够了,不需要再靠串口打印数值回填宏。
       写失败不影响本次使用,只是掉电后会退回上一次保存的值。 */
    saved = XPT2046_CalibrationSave();

    if (cal_timer != NULL)
    {
        lv_timer_delete(cal_timer);
        cal_timer = NULL;
    }

    if (cal_tip != NULL)
    {
        lv_label_set_text(cal_tip, (saved != 0U) ? "CALIBRATED & SAVED"
                                                : "CALIBRATED (SAVE FAILED)");
    }

    /* 停一下让用户看清结果,再回主界面 */
    cal_exit_timer = lv_timer_create(cal_exit_cb, 700U, NULL);
}

/* 校准采样状态机,20ms 一次 */
static void cal_timer_cb(lv_timer_t * timer)
{
    uint16_t rx = 0U;
    uint16_t ry = 0U;
    uint8_t pressed;

    (void)timer;

    pressed = XPT2046_ReadRawPoint(&rx, &ry);

    switch (cal_phase)
    {
    case 3U:                            /* 刚进入:等长按的手指先离开 */
        if (pressed == 0U)
        {
            cal_phase    = 0U;
            cal_debounce = 0U;
        }
        break;

    case 0U:                            /* 等待一次新的按下 */
        if (pressed != 0U)
        {
            if (cal_debounce < CAL_DEBOUNCE)
            {
                cal_debounce++;
            }

            if (cal_debounce >= CAL_DEBOUNCE)
            {
                cal_phase   = 1U;
                cal_sum_x   = rx;
                cal_sum_y   = ry;
                cal_samples = 1U;
            }
        }
        else
        {
            cal_debounce = 0U;
        }
        break;

    case 1U:                            /* 累积采样 */
        if ((pressed != 0U) && (cal_samples < CAL_SAMPLES))
        {
            cal_sum_x += rx;
            cal_sum_y += ry;
            cal_samples++;

            if (cal_samples < CAL_SAMPLES)
            {
                break;
            }
        }

        /* 采样够了或者手指提前抬起,都记下这一点的平均值 */
        cal_raw[cal_index][0] = (uint16_t)(cal_sum_x / cal_samples);
        cal_raw[cal_index][1] = (uint16_t)(cal_sum_y / cal_samples);
        cal_index++;
        cal_phase = 2U;
        break;

    case 2U:                            /* 等手指抬起,避免一次点击采多点 */
        if (pressed == 0U)
        {
            cal_phase    = 0U;
            cal_debounce = 0U;

            if (cal_index >= CAL_POINT_COUNT)
            {
                cal_finish();
            }
            else
            {
                cal_move_target();
            }
        }
        break;

    default:
        break;
    }
}

/* 进入四点校准界面 */
static void touch_calibration_start(void)
{
    lv_display_t * disp = lv_display_get_default();
    lv_obj_t * scr = lv_screen_active();
    int32_t scr_w = lv_display_get_horizontal_resolution(disp);
    int32_t scr_h = lv_display_get_vertical_resolution(disp);
    uint8_t i;

    lv_obj_clean(scr);
    lv_obj_set_style_bg_color(scr, lv_color_hex(0x101828), 0);
    lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
    lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);

    /* 主界面的对象随 clean 一起被删掉了,置空并停掉刷新定时器 */
    log_ta = NULL;
    in_ta  = NULL;
    kb     = NULL;
    lbl_rx = NULL;

    if (ui_timer != NULL)
    {
        lv_timer_delete(ui_timer);
        ui_timer = NULL;
    }

    cal_px[0] = CAL_MARGIN;
    cal_py[0] = CAL_MARGIN;
    cal_px[1] = scr_w - 1 - CAL_MARGIN;
    cal_py[1] = CAL_MARGIN;
    cal_px[2] = CAL_MARGIN;
    cal_py[2] = scr_h - 1 - CAL_MARGIN;
    cal_px[3] = scr_w - 1 - CAL_MARGIN;
    cal_py[3] = scr_h - 1 - CAL_MARGIN;

    for (i = 0U; i < CAL_POINT_COUNT; i++)
    {
        cal_raw[i][0] = 0U;
        cal_raw[i][1] = 0U;
    }

    /* 靶标:一个圆环加十字线 */
    cal_ring = lv_obj_create(scr);
    lv_obj_remove_style_all(cal_ring);
    lv_obj_set_size(cal_ring, 56, 56);
    lv_obj_set_style_radius(cal_ring, LV_RADIUS_CIRCLE, 0);
    lv_obj_set_style_border_width(cal_ring, 2, 0);
    lv_obj_set_style_border_color(cal_ring, lv_color_hex(0x4C6FFF), 0);
    lv_obj_remove_flag(cal_ring, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_remove_flag(cal_ring, LV_OBJ_FLAG_CLICKABLE);

    cal_bar_h = lv_obj_create(scr);
    lv_obj_remove_style_all(cal_bar_h);
    lv_obj_set_size(cal_bar_h, 40, 4);
    lv_obj_set_style_bg_color(cal_bar_h, lv_color_white(), 0);
    lv_obj_set_style_bg_opa(cal_bar_h, LV_OPA_COVER, 0);
    lv_obj_remove_flag(cal_bar_h, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_remove_flag(cal_bar_h, LV_OBJ_FLAG_CLICKABLE);

    cal_bar_v = lv_obj_create(scr);
    lv_obj_remove_style_all(cal_bar_v);
    lv_obj_set_size(cal_bar_v, 4, 40);
    lv_obj_set_style_bg_color(cal_bar_v, lv_color_white(), 0);
    lv_obj_set_style_bg_opa(cal_bar_v, LV_OPA_COVER, 0);
    lv_obj_remove_flag(cal_bar_v, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_remove_flag(cal_bar_v, LV_OBJ_FLAG_CLICKABLE);

    cal_tip = lv_label_create(scr);
    lv_obj_set_style_text_font(cal_tip, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(cal_tip, lv_color_white(), 0);
    lv_obj_align(cal_tip, LV_ALIGN_CENTER, 0, 0);
    lv_obj_set_style_text_align(cal_tip, LV_TEXT_ALIGN_CENTER, 0);

    cal_index    = 0U;
    cal_phase    = 3U;              /* 先等长按的手指抬起 */
    cal_debounce = 0U;
    cal_samples  = 0U;
    cal_move_target();

    if (boot_cal_timer != NULL)
    {
        lv_timer_delete(boot_cal_timer);
        boot_cal_timer = NULL;
    }

    if (cal_timer != NULL)
    {
        lv_timer_delete(cal_timer);
    }
    cal_timer = lv_timer_create(cal_timer_cb, 20U, NULL);
}

/* 切换界面会删掉正在派发事件的按钮,交给 async 在本轮事件结束后执行 */
static void cal_start_async(void * unused)
{
    (void)unused;

    touch_calibration_start();
}

/* ---------------- 开机"救命"校准入口 ----------------
 * 标定偏移时可能连顶栏标题都点不中(点哪儿都跑偏),所以再给一条完全不
 * 依赖坐标的通路:上电后 8 秒内按住屏幕任意位置 0.8 秒,直接进四点校准。
 * 只在开机后检测一次,过了这个窗口就不再理会,免得正常使用时误触发。
 */
#define BOOT_CAL_WINDOW_MS 8000U
#define BOOT_CAL_HOLD_MS    800U

static uint8_t  boot_cal_armed = 0U;
static uint32_t boot_cal_deadline = 0U;
static uint32_t boot_cal_hold = 0U;

static void boot_cal_cb(lv_timer_t * timer)
{
    uint16_t raw_x = 0U;
    uint16_t raw_y = 0U;
    uint32_t now = lv_tick_get();

    if (now > boot_cal_deadline)
    {
        lv_timer_delete(timer);
        boot_cal_timer = NULL;
        return;
    }

    if (XPT2046_ReadRawPoint(&raw_x, &raw_y) != 0U)
    {
        if (boot_cal_hold == 0U)
        {
            boot_cal_hold = now;
        }
        else if ((uint32_t)(now - boot_cal_hold) >= BOOT_CAL_HOLD_MS)
        {
            boot_cal_armed = 2U;        /* 标记已用过,避免 ui_init 里再建定时器 */
            boot_cal_timer = NULL;
            lv_timer_delete(timer);
            (void)lv_async_call(cal_start_async, NULL);
        }
    }
    else
    {
        boot_cal_hold = 0U;
    }
}

/* 长按顶栏标题进入校准 */
static void title_long_cb(lv_event_t * e)
{
    (void)e;

    (void)lv_async_call(cal_start_async, NULL);
}

/**********************
 *   GLOBAL FUNCTIONS
 **********************/
void ui_init(void)
{
    lv_display_t * disp;
    lv_obj_t * scr;
    lv_obj_t * head;
    lv_obj_t * title;
    lv_obj_t * btn;
    int32_t scr_w;
    int32_t scr_h;
    int32_t field_w;

    /* 重新构建界面前,先清掉旧的定时器与对象指针 */
    if (cal_timer != NULL)
    {
        lv_timer_delete(cal_timer);
        cal_timer = NULL;
    }
    if (cal_exit_timer != NULL)
    {
        lv_timer_delete(cal_exit_timer);
        cal_exit_timer = NULL;
    }
    if (ui_timer != NULL)
    {
        lv_timer_delete(ui_timer);
        ui_timer = NULL;
    }
    cal_ring  = NULL;
    cal_bar_h = NULL;
    cal_bar_v = NULL;
    cal_tip   = NULL;
    log_ta    = NULL;
    in_ta     = NULL;
    kb        = NULL;
    lbl_rx    = NULL;
    log_len   = 0U;

    disp  = lv_display_get_default();
    scr   = lv_screen_active();
    scr_w = lv_display_get_horizontal_resolution(disp);
    scr_h = lv_display_get_vertical_resolution(disp);
    field_w = scr_w - (2 * UI_PAD);

    lv_obj_clean(scr);
    lv_obj_set_style_bg_color(scr, lv_color_hex(UI_BG_HEX), 0);
    lv_obj_set_style_bg_opa(scr, LV_OPA_COVER, 0);
    lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);

    /* ---------- 顶栏 ---------- */
    head = lv_obj_create(scr);
    lv_obj_remove_style_all(head);
    lv_obj_set_pos(head, UI_PAD, UI_HEAD_Y);
    lv_obj_set_size(head, field_w, UI_HEAD_H);
    lv_obj_set_style_bg_color(head, lv_color_hex(UI_HEAD_HEX), 0);
    lv_obj_set_style_bg_opa(head, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(head, 6, 0);
    lv_obj_remove_flag(head, LV_OBJ_FLAG_SCROLLABLE);

    /* 标题:长按进入触摸校准 */
    title = lv_label_create(head);
    lv_label_set_text(title, "UART1 LINK");
    lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(title, lv_color_hex(UI_TXT_HEX), 0);
    lv_obj_align(title, LV_ALIGN_LEFT_MID, 6, 0);
    lv_obj_add_flag(title, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_add_event_cb(title, title_long_cb, LV_EVENT_LONG_PRESSED, NULL);
    /* 顶栏整条也可以长按进校准:万一点不准标题,还有更大的目标可点 */
    lv_obj_add_flag(head, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_add_event_cb(head, title_long_cb, LV_EVENT_LONG_PRESSED, NULL);

    lbl_rx = lv_label_create(head);
    lv_label_set_text(lbl_rx, "RX 0");
    lv_obj_set_style_text_font(lbl_rx, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(lbl_rx, lv_color_hex(UI_ACCENT_HEX), 0);
    lv_obj_align(lbl_rx, LV_ALIGN_RIGHT_MID, -6, 0);

    /* ---------- 接收显示区(只读、可滚屏) ---------- */
    log_ta = lv_textarea_create(scr);
    lv_obj_set_pos(log_ta, UI_PAD, UI_LOG_Y);
    lv_obj_set_size(log_ta, field_w, UI_LOG_H);
    lv_textarea_set_one_line(log_ta, false);
    lv_textarea_set_max_length(log_ta, LOG_MAX_CHARS + 64U);
    lv_textarea_set_placeholder_text(log_ta, "RX DATA ...");
    lv_obj_set_style_text_font(log_ta, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(log_ta, lv_color_hex(UI_TXT_HEX), 0);
    lv_obj_set_style_text_align(log_ta, LV_TEXT_ALIGN_LEFT, 0);
    lv_obj_set_style_bg_color(log_ta, lv_color_hex(UI_FIELD_HEX), 0);
    lv_obj_set_style_bg_opa(log_ta, LV_OPA_COVER, 0);
    lv_obj_set_style_border_width(log_ta, 1, 0);
    lv_obj_set_style_border_color(log_ta, lv_color_hex(0x27324A), 0);
    lv_obj_set_style_radius(log_ta, 6, 0);
    lv_obj_set_style_pad_all(log_ta, 4, 0);
    lv_obj_set_style_bg_color(log_ta, lv_color_hex(UI_FIELD_HEX), LV_PART_MAIN);
    lv_obj_set_style_bg_opa(log_ta, LV_OPA_COVER, LV_PART_MAIN);
    lv_obj_remove_flag(log_ta, LV_OBJ_FLAG_CLICKABLE);   /* 只读:不接受点击编辑 */

    /* ---------- 输入框(软键盘输入) ---------- */
    in_ta = lv_textarea_create(scr);
    lv_obj_set_pos(in_ta, UI_PAD, UI_ROW_Y);
    lv_obj_set_size(in_ta, UI_INPUT_W, UI_ROW_H);
    lv_textarea_set_one_line(in_ta, true);
    lv_textarea_set_max_length(in_ta, INPUT_MAX_CHARS);
    lv_textarea_set_placeholder_text(in_ta, "input...");
    lv_obj_set_style_text_font(in_ta, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(in_ta, lv_color_hex(UI_TXT_HEX), 0);
    lv_obj_set_style_bg_color(in_ta, lv_color_hex(UI_FIELD_HEX), 0);
    lv_obj_set_style_bg_opa(in_ta, LV_OPA_COVER, 0);
    lv_obj_set_style_border_width(in_ta, 1, 0);
    lv_obj_set_style_border_color(in_ta, lv_color_hex(UI_ACCENT_HEX), 0);
    lv_obj_set_style_radius(in_ta, 6, 0);
    lv_obj_set_style_pad_all(in_ta, 4, 0);
    lv_obj_set_style_bg_color(in_ta, lv_color_hex(UI_FIELD_HEX), LV_PART_MAIN);
    lv_obj_set_style_bg_opa(in_ta, LV_OPA_COVER, LV_PART_MAIN);

    /* ---------- SEND / CLR 按键 ---------- */
    btn = make_button(scr, "SEND", UI_OK_HEX, &lv_font_montserrat_14,
                      send_cb, NULL);
    lv_obj_set_pos(btn, UI_PAD + UI_INPUT_W + UI_GAP, UI_ROW_Y);
    lv_obj_set_size(btn, UI_SEND_W, UI_ROW_H);

    btn = make_button(scr, "CLR", UI_STOP_HEX, &lv_font_montserrat_14,
                      clear_cb, NULL);
    lv_obj_set_pos(btn, UI_PAD + UI_INPUT_W + UI_GAP + UI_SEND_W + UI_GAP, UI_ROW_Y);
    lv_obj_set_size(btn, UI_CLR_W, UI_ROW_H);

    /* ---------- 软键盘 ---------- */
    kb = lv_keyboard_create(scr);
    /* 这里必须用 lv_obj_align 而不是 lv_obj_set_pos:
       lv_keyboard 的构造函数内部执行了 lv_obj_align(BOTTOM_MID, 0, 0),
       会给键盘留下 LV_STYLE_ALIGN = LV_ALIGN_BOTTOM_MID。
       而 lv_obj_set_pos() 只写 x/y、清不掉对齐方式,最终位置会按
       "底部居中 + (x,y)" 计算:y = 240 - 118 + 118 = 240,
       键盘整个被挪到屏幕下边缘之外 —— 现象就是"键盘创建了却看不见"。
       lv_obj_align(TOP_LEFT, UI_PAD, UI_KB_Y) 会同时设定对齐方式,
       结果就是左上角相对屏幕偏移 (4,118),和布局宏一致。 */
    lv_obj_align(kb, LV_ALIGN_TOP_LEFT, UI_PAD, UI_KB_Y);
    lv_obj_set_size(kb, field_w, scr_h - UI_PAD - UI_KB_Y);
    lv_keyboard_set_mode(kb, LV_KEYBOARD_MODE_TEXT_LOWER);
    lv_obj_set_style_pad_all(kb, 2, 0);
    lv_obj_set_style_pad_row(kb, 2, 0);
    lv_obj_set_style_pad_column(kb, 2, 0);
    lv_obj_set_style_bg_color(kb, lv_color_hex(UI_PANEL_HEX), 0);
    lv_obj_set_style_bg_opa(kb, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(kb, 6, 0);
    lv_obj_set_style_bg_color(kb, lv_color_hex(UI_BTN_HEX), LV_PART_ITEMS);
    lv_obj_set_style_bg_opa(kb, LV_OPA_COVER, LV_PART_ITEMS);
    lv_obj_set_style_text_color(kb, lv_color_hex(UI_TXT_HEX), LV_PART_ITEMS);
    lv_obj_set_style_radius(kb, 4, LV_PART_ITEMS);
    lv_obj_set_style_border_width(kb, 0, LV_PART_ITEMS);
    lv_obj_set_style_shadow_width(kb, 0, LV_PART_ITEMS);
    lv_obj_set_style_bg_color(kb, lv_color_hex(UI_ACCENT_HEX), LV_STATE_PRESSED | LV_PART_ITEMS);
    lv_obj_remove_flag(kb, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_add_event_cb(kb, kb_cb, LV_EVENT_READY, NULL);
    lv_keyboard_set_textarea(kb, in_ta);

    /* ---------- 初始状态 ---------- */
    ui_timer = lv_timer_create(ui_rx_timer_cb, RX_POLL_MS, NULL);
    rx_total = 0U;
    lv_label_set_text(lbl_rx, "RX 0");

    /* ---------- 开机"救命"校准检测(只做一次) ---------- */
    if (boot_cal_armed == 0U)
    {
        boot_cal_armed    = 1U;
        boot_cal_deadline = lv_tick_get() + BOOT_CAL_WINDOW_MS;
        boot_cal_hold     = 0U;
        boot_cal_timer    = lv_timer_create(boot_cal_cb, 20U, NULL);
    }
}

效果展示:

在这里插入图片描述

在这里插入图片描述

4.通过屏幕上调整定时器的频率和控制占空比的变化

对应到屏幕上显示对应占空比变化和频率实时变化
核心部分:

/**********************
 *   GLOBAL FUNCTIONS
 **********************/
/*********************
 *   PWM 页布局(TIM2_CH2 = PA1)
 *********************/
#define UI_PAGE_PWM     0U        /* 页面编号:PWM 设置页   */
#define UI_PAGE_UART    1U        /* 页面编号:串口终端页   */
#define UI_PAGE_TOUCH   2U        /* 页面编号:触摸自检页   */
#define UI_PAGE_ADC     3U        /* 页面编号:ADC 采集页   */
/**********************
 *  STATIC VARIABLES
 **********************/
static lv_obj_t * log_ta   = NULL;   /* 接收显示区(只读) */
static lv_obj_t * in_ta    = NULL;   /* 输入框             */
static lv_obj_t * kb       = NULL;   /* 软键盘             */
static lv_obj_t * lbl_rx   = NULL;   /* 顶栏接收计数       */
static lv_timer_t * ui_timer = NULL; /* 接收轮询定时器     */
static uint32_t log_len    = 0U;     /* 显示区当前字符数   */
static uint32_t rx_total   = 0U;     /* 累计接收字节数     */

static uint8_t ui_page     = UI_PAGE_PWM;   /* 当前页面编号 */

/* PWM 页的对象与状态 */
static lv_obj_t * pwm_lbl_f    = NULL;  /* F 频率              */
static lv_obj_t * pwm_lbl_d    = NULL;  /* D 占空比            */
static lv_obj_t * pwm_lbl_t    = NULL;  /* T 周期              */
static lv_obj_t * pwm_lbl_on   = NULL;  /* Ton 高电平时间      */
static lv_obj_t * pwm_btn_run  = NULL;  /* RUN / STOP 按钮     */
static lv_obj_t * pwm_wave     = NULL;  /* 方波折线            */
static lv_timer_t * pwm_timer  = NULL;  /* 数值/波形刷新定时器 */
static int32_t    pwm_wave_w   = 304;   /* 折线可用宽度(像素)  */
static lv_point_precise_t pwm_pts[1 + (PWM_WAVE_CYCLES * 4)];

/* PWM 页显示缓存:只有真正变化时才重绘。
   本机是单缓冲 + 阻塞刷屏,界面如果一直重绘,会把 LVGL 的触摸采样
   定时器挤到后面(表现为点不动、长按也进不了校准),所以必须做到
   "参数不变就一个像素都不刷"。 */
static uint8_t pwm_shown_valid = 0U;     /* 缓存是否有效(页面刚重建时无效) */
static float   pwm_shown_freq  = 0.0f;   /* 上次显示的频率 Hz  */
static float   pwm_shown_duty  = 0.0f;   /* 上次显示的占空比 % */
static uint8_t pwm_shown_run   = 0U;     /* 上次的启停状态     */

/* ---------------- 页面公共部分 ---------------- */

/* 释放当前页面的定时器与对象指针(切页 / 进校准前都要调) */
static void ui_page_release(void)
{
    uint8_t st_i;

    if (ui_timer != NULL)
    {
        lv_timer_delete(ui_timer);
        ui_timer = NULL;
    }
    if (pwm_timer != NULL)
    {
        lv_timer_delete(pwm_timer);
        pwm_timer = NULL;
    }
    if (st_timer != NULL)
    {
        lv_timer_delete(st_timer);
        st_timer = NULL;
    }
    /* 校准还挂着的两个定时器也一并收掉(正常情况下已经是 NULL) */
    if (cal_timer != NULL)
    {
        lv_timer_delete(cal_timer);
        cal_timer = NULL;
    }
    if (cal_exit_timer != NULL)
    {
        lv_timer_delete(cal_exit_timer);
        cal_exit_timer = NULL;
    }

    log_ta = NULL;
    in_ta  = NULL;
    kb     = NULL;
    lbl_rx = NULL;

    pwm_lbl_f   = NULL;
    pwm_lbl_d   = NULL;
    pwm_lbl_t   = NULL;
    pwm_lbl_on  = NULL;
    pwm_btn_run = NULL;
    pwm_wave    = NULL;

    pwm_shown_valid = 0U;      /* 页面重建后必须强制刷一次 */

    /* 自检页的标签随 lv_obj_clean 一起没了;文本缓存也清掉,
       这样下次重建时每一行都会重新写一遍(而不是被"内容没变"跳过)。 */
    for (st_i = 0U; st_i < ST_ROWS; st_i++)
    {
        st_line[st_i]   = NULL;
        st_cache[st_i][0] = '\0';
    }
    st_evt_lbl  = NULL;
    st_left_lbl = NULL;
}

/* 切换页面必须走 async:直接在按钮自己的事件里重建,会把正在派发事件的按钮删掉 */
static void page_switch_async(void * arg)
{
    ui_build_page((uint8_t)(intptr_t)arg);
}

static void ui_page_switch_cb(lv_event_t * e)
{
    (void)lv_async_call(page_switch_async, lv_event_get_user_data(e));
}

/* 顶栏:标题(长按进触摸校准)+ 右上角页面切换按钮 */
static lv_obj_t * ui_make_head(lv_obj_t * scr, const char * title_text,
                               const char * btn_text, uint8_t btn_page)
{
    lv_obj_t * head;
    lv_obj_t * title;
    lv_obj_t * btn;
    int32_t field_w = lv_display_get_horizontal_resolution(lv_display_get_default())
                      - (2 * UI_PAD);

    head = lv_obj_create(scr);
    lv_obj_remove_style_all(head);
    lv_obj_set_pos(head, UI_PAD, UI_HEAD_Y);
    lv_obj_set_size(head, field_w, UI_HEAD_H);
    lv_obj_set_style_bg_color(head, lv_color_hex(UI_HEAD_HEX), 0);
    lv_obj_set_style_bg_opa(head, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(head, 6, 0);
    lv_obj_remove_flag(head, LV_OBJ_FLAG_SCROLLABLE);

    title = lv_label_create(head);
    lv_label_set_text(title, title_text);
    lv_obj_set_style_text_font(title, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(title, lv_color_hex(UI_TXT_HEX), 0);
    lv_obj_align(title, LV_ALIGN_LEFT_MID, 6, 0);
    lv_obj_add_flag(title, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_add_event_cb(title, title_long_cb, LV_EVENT_LONG_PRESSED, NULL);
    /* 顶栏整条也可以长按进校准:万一点不准标题,还有更大的目标可点 */
    lv_obj_add_flag(head, LV_OBJ_FLAG_CLICKABLE);
    lv_obj_add_event_cb(head, title_long_cb, LV_EVENT_LONG_PRESSED, NULL);

    if (btn_text != NULL)
    {
        btn = make_button(head, btn_text, UI_BTN_HEX, &lv_font_montserrat_14,
                          ui_page_switch_cb, (void *)(intptr_t)btn_page);
        lv_obj_set_size(btn, 46, 16);
        lv_obj_align(btn, LV_ALIGN_RIGHT_MID, -3, 0);
    }

    return head;
}

/* ---------------- PWM 设置页(TIM2_CH2 = PA1) ---------------- */

/* 纳秒 -> ns / us / ms / s 文本(全部用整数运算,不依赖 %f) */
static void pwm_fmt_ns(char *buf, size_t n, uint32_t ns)
{
    if (ns < 1000U)
    {
        snprintf(buf, n, "%lu ns", (unsigned long)ns);
    }
    else if (ns < 1000000U)
    {
        snprintf(buf, n, "%lu.%03lu us",
                 (unsigned long)(ns / 1000U), (unsigned long)(ns % 1000U));
    }
    else if (ns < 1000000000U)
    {
        snprintf(buf, n, "%lu.%03lu ms",
                 (unsigned long)(ns / 1000000U),
                 (unsigned long)((ns / 1000U) % 1000U));
    }
    else
    {
        snprintf(buf, n, "%lu.%03lu s",
                 (unsigned long)(ns / 1000000000U),
                 (unsigned long)((ns / 1000000U) % 1000U));
    }
}

/* 频率 -> Hz / kHz 文本 */
static void pwm_fmt_freq(char *buf, size_t n, double hz)
{
    uint32_t v;

    if (hz < 1000.0)
    {
        v = (uint32_t)(hz * 10.0 + 0.5);              /* 0.1 Hz  */
        snprintf(buf, n, "%lu.%lu Hz",
                 (unsigned long)(v / 10U), (unsigned long)(v % 10U));
    }
    else
    {
        v = (uint32_t)(hz / 10.0 + 0.5);              /* 0.01 kHz */
        snprintf(buf, n, "%lu.%02lu kHz",
                 (unsigned long)(v / 100U), (unsigned long)(v % 100U));
    }
}

/* 方波示意图:按占空比铺 PWM_WAVE_CYCLES 个周期的高/低电平折线 */
static void pwm_wave_update(double duty)
{
    const int32_t y_hi = 8;
    const int32_t y_lo = 54;
    int32_t pw;
    int32_t on;
    int32_t k;
    int32_t x0;
    uint8_t idx = 0U;

    pw = (pwm_wave_w > 0) ? (pwm_wave_w / PWM_WAVE_CYCLES) : 0;

    if (duty < 0.0)   { duty = 0.0; }
    if (duty > 100.0) { duty = 100.0; }
    on = (int32_t)(((double)pw * duty) / 100.0 + 0.5);

    if ((pw <= 0) || (on <= 0))                        /* 0%:一条低电平横线 */
    {
        pwm_pts[0].x = 0;                   pwm_pts[0].y = y_lo;
        pwm_pts[1].x = (int32_t)pwm_wave_w; pwm_pts[1].y = y_lo;
        lv_line_set_points(pwm_wave, pwm_pts, 2U);
        return;
    }
    if (on >= pw)                                      /* 100%:一条高电平横线 */
    {
        pwm_pts[0].x = 0;                   pwm_pts[0].y = y_hi;
        pwm_pts[1].x = (int32_t)pwm_wave_w; pwm_pts[1].y = y_hi;
        lv_line_set_points(pwm_wave, pwm_pts, 2U);
        return;
    }

    pwm_pts[idx].x = 0;          pwm_pts[idx].y = y_lo; idx++;   /* 起点:低 */
    for (k = 0; k < PWM_WAVE_CYCLES; k++)
    {
        x0 = k * pw;
        pwm_pts[idx].x = x0;         pwm_pts[idx].y = y_hi; idx++;   /* 上升沿 */
        pwm_pts[idx].x = x0 + on;    pwm_pts[idx].y = y_hi; idx++;   /* 高电平 */
        pwm_pts[idx].x = x0 + on;    pwm_pts[idx].y = y_lo; idx++;   /* 下降沿 */
        pwm_pts[idx].x = x0 + pw;    pwm_pts[idx].y = y_lo; idx++;   /* 低电平 */
    }
    lv_line_set_points(pwm_wave, pwm_pts, idx);
}

/* 把当前 PWM 参数刷到界面:数值 + 方波示意图。
 * 参数没变就直接返回 —— 本机单缓冲阻塞刷屏,界面一直在重绘会把
 * LVGL 的触摸采样挤掉,所以静止时必须一次重绘都不产生。 */
static void pwm_page_update(void)
{
    char     num[24];
    char     buf[40];
    char     tbuf[24];
    float    freq;
    float    duty;
    double   period_ns;
    uint32_t d10;
    uint8_t  running;
    uint8_t  dirty_freq;
    uint8_t  dirty_duty;
    uint8_t  dirty_run;
    lv_obj_t * lbl;

    if ((pwm_lbl_f == NULL) || (pwm_wave == NULL))
    {
        return;
    }

    freq    = pwm_ch_get_real_freq();
    duty    = pwm_ch_get_real_duty();
    running = pwm_ch_is_running();

    if (pwm_shown_valid != 0U)
    {
        dirty_freq = (freq != pwm_shown_freq) ? 1U : 0U;
        dirty_duty = (duty != pwm_shown_duty) ? 1U : 0U;
        dirty_run  = (running != pwm_shown_run) ? 1U : 0U;

        if ((dirty_freq == 0U) && (dirty_duty == 0U) && (dirty_run == 0U))
        {
            return;                        /* 没变化:一个像素都不刷 */
        }
    }
    else
    {
        dirty_freq = 1U;
        dirty_duty = 1U;
        dirty_run  = 1U;
        pwm_shown_valid = 1U;
    }

    pwm_shown_freq = freq;
    pwm_shown_duty = duty;
    pwm_shown_run  = running;

    period_ns = 1000000000.0 / (double)((freq > 0.001f) ? freq : 0.001f);
    if (period_ns > 4294967000.0)
    {
        period_ns = 4294967000.0;
    }

    /* ---- 频率 / 周期 ---- */
    if (dirty_freq != 0U)
    {
        pwm_fmt_freq(num, sizeof(num), (double)freq);
        snprintf(buf, sizeof(buf), "F %s", num);
        lv_label_set_text(pwm_lbl_f, buf);

        pwm_fmt_ns(tbuf, sizeof(tbuf), (uint32_t)(period_ns + 0.5));
        snprintf(buf, sizeof(buf), "T %s", tbuf);
        lv_label_set_text(pwm_lbl_t, buf);
    }

    /* ---- 占空比 + 方波示意图 ---- */
    if (dirty_duty != 0U)
    {
        d10 = (uint32_t)((double)duty * 10.0 + 0.5);
        snprintf(buf, sizeof(buf), "D %lu.%lu %%",
                 (unsigned long)(d10 / 10U), (unsigned long)(d10 % 10U));
        lv_label_set_text(pwm_lbl_d, buf);

        pwm_wave_update((double)duty);
    }

    /* ---- 高电平时间:频率或占空比任一变化都要重算 ---- */
    if ((dirty_freq != 0U) || (dirty_duty != 0U))
    {
        pwm_fmt_ns(tbuf, sizeof(tbuf),
                   (uint32_t)((period_ns * (double)duty / 100.0) + 0.5));
        snprintf(buf, sizeof(buf), "Ton %s", tbuf);
        lv_label_set_text(pwm_lbl_on, buf);
    }

    /* ---- 波形灰/亮 + RUN/STOP 文字:只有启停变化才动 ---- */
    if (dirty_run != 0U)
    {
        lv_obj_set_style_line_color(pwm_wave,
                                    lv_color_hex((running != 0U) ? UI_ACCENT_HEX
                                                                 : UI_MUTED_HEX),
                                    0);

        if (pwm_btn_run != NULL)
        {
            lbl = lv_obj_get_child(pwm_btn_run, 0);
            if (lbl != NULL)
            {
                lv_label_set_text(lbl, (running != 0U) ? "STOP" : "RUN");
            }
        }
    }
}

static void pwm_timer_cb(lv_timer_t * timer)
{
    (void)timer;

    pwm_page_update();
}

/* 频率 -1k / -100 / +100 / +1k */
static void pwm_freq_cb(lv_event_t * e)
{
    int32_t step = (int32_t)(intptr_t)lv_event_get_user_data(e);

    pwm_ch_set_freq(pwm_ch_get_freq() + (float)step);
    pwm_page_update();
}

/* 占空比:user_data 以 0.1% 为单位(±10 = 1%,±100 = 10%) */
static void pwm_duty_cb(lv_event_t * e)
{
    int32_t step10 = (int32_t)(intptr_t)lv_event_get_user_data(e);

    pwm_ch_set_duty(pwm_ch_get_duty() + ((float)step10 / 10.0f));
    pwm_page_update();
}

/* 运行 / 停止 */
static void pwm_run_cb(lv_event_t * e)
{
    (void)e;

    pwm_ch_set_enable((pwm_ch_is_running() == 0U) ? 1U : 0U);
    pwm_page_update();
}

/* 回到默认:1kHz / 50% */
static void pwm_default_cb(lv_event_t * e)
{
    (void)e;

    pwm_ch_set_freq(PWM_CH_DEFAULT_FREQ_HZ);
    pwm_ch_set_duty(PWM_CH_DEFAULT_DUTY_PCT);
    pwm_ch_set_enable(1U);
    pwm_page_update();
}

static void ui_build_pwm(lv_obj_t * scr)
{
    static const char *const freq_txt[4]  = {"-1k", "-100", "+100", "+1k"};
    static const int32_t     freq_step[4] = {-1000, -100, 100, 1000};
    static const char *const duty_txt[4]  = {"-10%", "-1%", "+1%", "+10%"};
    static const int32_t     duty_step[4] = {-100, -10, 10, 100};

    lv_obj_t * info;
    lv_obj_t * panel;
    lv_obj_t * btn;
    int32_t field_w = lv_display_get_horizontal_resolution(lv_display_get_default())
                      - (2 * UI_PAD);
    int32_t btn_w = (field_w - (3 * UI_GAP)) / 4;
    int32_t big_w = (field_w - UI_GAP) / 2;
    int32_t i;

    (void)ui_make_head(scr, "PWM  TIM2_CH2 / PA1", "UART", UI_PAGE_UART);

    /* ---------- 数值:左边 F/T,右边 D/Ton ---------- */
    info = lv_obj_create(scr);
    lv_obj_remove_style_all(info);
    lv_obj_set_pos(info, UI_PAD, PWM_INFO_Y);
    lv_obj_set_size(info, field_w, PWM_INFO_H);
    lv_obj_remove_flag(info, LV_OBJ_FLAG_SCROLLABLE);

    pwm_lbl_f = lv_label_create(info);
    lv_obj_set_style_text_font(pwm_lbl_f, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(pwm_lbl_f, lv_color_hex(UI_ACCENT_HEX), 0);
    lv_obj_align(pwm_lbl_f, LV_ALIGN_TOP_LEFT, 0, 0);

    pwm_lbl_d = lv_label_create(info);
    lv_obj_set_style_text_font(pwm_lbl_d, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(pwm_lbl_d, lv_color_hex(UI_OK_HEX), 0);
    lv_obj_align(pwm_lbl_d, LV_ALIGN_TOP_RIGHT, 0, 0);

    pwm_lbl_t = lv_label_create(info);
    lv_obj_set_style_text_font(pwm_lbl_t, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(pwm_lbl_t, lv_color_hex(UI_MUTED_HEX), 0);
    lv_obj_align(pwm_lbl_t, LV_ALIGN_BOTTOM_LEFT, 0, 0);

    pwm_lbl_on = lv_label_create(info);
    lv_obj_set_style_text_font(pwm_lbl_on, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(pwm_lbl_on, lv_color_hex(UI_MUTED_HEX), 0);
    lv_obj_align(pwm_lbl_on, LV_ALIGN_BOTTOM_RIGHT, 0, 0);

    /* ---------- 方波示意图 ---------- */
    panel = lv_obj_create(scr);
    lv_obj_remove_style_all(panel);
    lv_obj_set_pos(panel, UI_PAD, PWM_WAVE_Y);
    lv_obj_set_size(panel, field_w, PWM_WAVE_H);
    lv_obj_set_style_bg_color(panel, lv_color_hex(UI_FIELD_HEX), 0);
    lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
    lv_obj_set_style_border_width(panel, 1, 0);
    lv_obj_set_style_border_color(panel, lv_color_hex(0x27324A), 0);
    lv_obj_set_style_radius(panel, 6, 0);
    lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE);

    pwm_wave_w = field_w - (2 * PWM_WAVE_PAD);
    pwm_wave   = lv_line_create(panel);
    lv_obj_remove_style_all(pwm_wave);
    lv_obj_set_pos(pwm_wave, PWM_WAVE_PAD, PWM_WAVE_PAD);
    lv_obj_set_style_line_width(pwm_wave, 2, 0);
    lv_obj_set_style_line_rounded(pwm_wave, true, 0);
    lv_obj_set_style_line_opa(pwm_wave, LV_OPA_COVER, 0);
    lv_obj_set_style_line_color(pwm_wave, lv_color_hex(UI_ACCENT_HEX), 0);

    /* ---------- 频率 / 占空比 加减按钮 ---------- */
    for (i = 0; i < 4; i++)
    {
        btn = make_button(scr, freq_txt[i], UI_BTN_HEX, &lv_font_montserrat_14,
                          pwm_freq_cb, (void *)(intptr_t)freq_step[i]);
        lv_obj_set_pos(btn, UI_PAD + (i * (btn_w + UI_GAP)), PWM_ROW1_Y);
        lv_obj_set_size(btn, btn_w, PWM_ROW_H);
    }
    for (i = 0; i < 4; i++)
    {
        btn = make_button(scr, duty_txt[i], UI_BTN_HEX, &lv_font_montserrat_14,
                          pwm_duty_cb, (void *)(intptr_t)duty_step[i]);
        lv_obj_set_pos(btn, UI_PAD + (i * (btn_w + UI_GAP)), PWM_ROW2_Y);
        lv_obj_set_size(btn, btn_w, PWM_ROW_H);
    }

    /* ---------- 运行 / 默认值 ---------- */
    pwm_btn_run = make_button(scr, "STOP", UI_BTN_HEX, &lv_font_montserrat_14,
                              pwm_run_cb, NULL);
    lv_obj_set_pos(pwm_btn_run, UI_PAD, PWM_ROW3_Y);
    lv_obj_set_size(pwm_btn_run, big_w, PWM_ROW3_H);

    btn = make_button(scr, "1kHz 50%", UI_BTN_HEX, &lv_font_montserrat_14,
                      pwm_default_cb, NULL);
    lv_obj_set_pos(btn, UI_PAD + big_w + UI_GAP, PWM_ROW3_Y);
    lv_obj_set_size(btn, big_w, PWM_ROW3_H);

    /* ---------- 先刷一次,再挂 100ms 周期刷新 ---------- */
    pwm_page_update();
    pwm_timer = lv_timer_create(pwm_timer_cb, PWM_REFRESH_MS, NULL);
}

效果展示:

在这里插入图片描述

5.通过对adc电位输入变化电平

在屏幕上显示adc 电压变化图
核心部分:

/*********************
 *   ADC 采集页布局(ADC1_INP10 = PC0)
 *********************/
#define ADC_INFO_Y      26        /* 采样信息 3 行(左 3 右 3) */
#define ADC_INFO_H      48
#define ADC_INFO_ROW_H  16        /* 单行高度 */
#define ADC_WAVE_Y      78        /* 波形图外框   */
#define ADC_WAVE_H      80
#define ADC_WAVE_PAD    4         /* 折线相对外框的内缩 */
#define ADC_ROW_Y       164       /* HOLD / CAL / CLR 按钮行 */
#define ADC_ROW_H       36
#define ADC_HINT_Y      206       /* 底部提示行   */
#define ADC_LBL_COUNT   6U        /* 信息标签个数 */
/* 波形刷新周期。304x72 的折线重绘一次约 41000 个像素,本机 SPI5 只有
   8.6MHz,也就是约 41ms 的刷屏时间;250ms 刷一次大约占掉 16% 的刷屏带宽,
   再快就会明显挤到触摸响应。想看"更新更快"可以把它调小,代价是界面变卡。 */
#define ADC_REFRESH_MS  250U

/* ADC 采集页的对象与状态。信息 6 个标签:下标偶数靠左、奇数靠右,
   每两个一行,共 3 行(通道 / 点数 / 当前值 / 采样率 / MIN,MAX / AVG,PP)。 */
static lv_obj_t * adc_lbl[ADC_LBL_COUNT] = {NULL, NULL, NULL, NULL, NULL, NULL};
static lv_obj_t * adc_btn_hold = NULL;   /* HOLD / RUN 按钮     */
static lv_obj_t * adc_wave     = NULL;   /* 采样波形折线        */
static lv_timer_t * adc_timer  = NULL;   /* 取帧 / 画波形定时器 */
static int32_t    adc_wave_w   = 304;    /* 折线可用宽度(像素)  */
static int32_t    adc_wave_h   = 72;     /* 折线可用高度(像素)  */
static lv_point_precise_t adc_pts[ADC_APP_FRAME_LEN];
static uint16_t   adc_frame[ADC_APP_FRAME_LEN];   /* 取出来的最近一整帧 */
static char       adc_cache[ADC_LBL_COUNT][32];   /* 上次显示过的文本   */

static uint8_t  adc_hold        = 0U;     /* 1 = 冻结显示(采集照常跑) */
static uint8_t  adc_shown_valid = 0U;     /* 缓存是否有效(页面刚重建时无效) */
static uint32_t adc_last_seq    = 0U;     /* 上次显示的那一帧的序号 */
static uint64_t adc_sum         = 0ULL;   /* 累计码值和(算平均值用) */
static uint32_t adc_count       = 0U;     /* 累计点数 */
static uint16_t adc_min         = 0xFFFFU;/* 累计最小值(原始码) */
static uint16_t adc_max         = 0U;     /* 累计最大值(原始码) */

/* ---------------- ADC 采集页(ADC1_INP10 / PC0) ----------------
 *
 * 数据源是 adc.c 里一直在跑的"连续转换 + 循环 DMA"采集流,这里只负责定时
 * 取最近一整帧、算统计、画波形。取帧的判据是"帧序号变了没有":序号没变说明
 * 还没采满新的一帧,这一轮一个像素都不刷(本机刷屏很贵,见 ADC_REFRESH_MS)。
 */

/* 累计统计复位:进页面、点 CLR、重新标定之后都调它 */
static void adc_stats_reset(void)
{
    adc_sum         = 0ULL;
    adc_count       = 0U;
    adc_min         = 0xFFFFU;
    adc_max         = 0U;
    adc_shown_valid = 0U;      /* 下一轮强制重刷,MIN/MAX/AVG 立刻归零 */
}

/* 文本没变就不 set_text:刷屏是单缓冲 + 异步 DMA,能省一次重绘是一次 */
static void adc_set_text(uint8_t idx, const char * txt)
{
    if ((idx >= ADC_LBL_COUNT) || (adc_lbl[idx] == NULL))
    {
        return;
    }

    if (strcmp(adc_cache[idx], txt) == 0)
    {
        return;
    }

    strncpy(adc_cache[idx], txt, sizeof(adc_cache[idx]) - 1U);
    adc_cache[idx][sizeof(adc_cache[idx]) - 1U] = '\0';
    lv_label_set_text(adc_lbl[idx], adc_cache[idx]);
}

/* 把一帧原始码画成折线:满量程画到顶,0 画到底,横向铺满整个波形区 */
static void adc_wave_update(const uint16_t * frame)
{
    int32_t i;

    if (adc_wave == NULL)
    {
        return;
    }

    for (i = 0; i < (int32_t)ADC_APP_FRAME_LEN; i++)
    {
        int32_t y = adc_wave_h - 1
                    - (int32_t)(((uint32_t)frame[i] * (uint32_t)adc_wave_h) / 65536U);

        if (y < 0)
        {
            y = 0;
        }
        else if (y > (adc_wave_h - 1))
        {
            y = adc_wave_h - 1;
        }

        adc_pts[i].x = ((int32_t)i * (adc_wave_w - 1)) / (int32_t)(ADC_APP_FRAME_LEN - 1U);
        adc_pts[i].y = y;
    }

    lv_line_set_points(adc_wave, adc_pts, ADC_APP_FRAME_LEN);
}

/* 周期刷新:取最新一整帧 -> 更新统计 -> 画波形 -> 刷文字 */
static void adc_page_update(lv_timer_t * timer)
{
    char buf[40];
    uint32_t seq;
    uint32_t i;
    uint32_t sum = 0U;
    uint32_t fmin = 0xFFFFFFFFU;
    uint32_t fmax = 0U;
    uint32_t sr;
    uint32_t win10;
    uint32_t mv10;
    uint32_t mv_min;
    uint32_t mv_max;
    uint32_t mv_avg;

    (void)timer;

    if (adc_lbl[0] == NULL)
    {
        return;
    }

    /* ---- 一路都不变的静态信息:文本相同就不会真的重绘 ---- */
    sr = (uint32_t)(adc_app_get_sample_rate() + 0.5f);
    if (sr == 0U)
    {
        sr = 1U;
    }
    win10 = (1000U * ADC_APP_FRAME_LEN * 10U) / sr;      /* 一帧时间,单位 0.1ms */

    snprintf(buf, sizeof(buf), "CH%u PC0/INP10", (unsigned)ADC_APP_CHANNEL_NUM);
    adc_set_text(0U, buf);

    snprintf(buf, sizeof(buf), "%upt %lu.%lums",
             (unsigned)ADC_APP_FRAME_LEN,
             (unsigned long)(win10 / 10U), (unsigned long)(win10 % 10U));
    adc_set_text(1U, buf);

    snprintf(buf, sizeof(buf), "SR %lu Hz", (unsigned long)sr);
    adc_set_text(3U, buf);

    if (adc_hold != 0U)
    {
        return;                     /* 冻结:数字和波形都停在上一次的样子 */
    }

    /* ---- 取最近一整帧;序号没变说明还没有新的一帧,这轮直接跳过不重绘 ---- */
    seq = adc_app_read_frame(adc_frame, ADC_APP_FRAME_LEN);
    if ((adc_shown_valid != 0U) && (seq == adc_last_seq))
    {
        return;
    }
    adc_shown_valid = 1U;
    adc_last_seq    = seq;

    /* ---- 这一帧的极值和 ---- */
    for (i = 0U; i < ADC_APP_FRAME_LEN; i++)
    {
        uint32_t v = (uint32_t)adc_frame[i];

        if (v < fmin)
        {
            fmin = v;
        }
        if (v > fmax)
        {
            fmax = v;
        }
        sum += v;
    }

    /* ---- 并进累计统计(点 CLR 归零):和用 64 位存,连续跑一天也不溢出 ---- */
    adc_sum   += (uint64_t)sum;
    adc_count += ADC_APP_FRAME_LEN;
    if ((uint16_t)fmin < adc_min)
    {
        adc_min = (uint16_t)fmin;
    }
    if ((uint16_t)fmax > adc_max)
    {
        adc_max = (uint16_t)fmax;
    }

    /* ---- 波形 ---- */
    adc_wave_update(adc_frame);

    /* ---- 文字 ---- */
    mv10   = adc_app_code_to_mv10(adc_frame[ADC_APP_FRAME_LEN - 1U]);
    mv_min = adc_app_code_to_mv(adc_min);
    mv_max = adc_app_code_to_mv(adc_max);
    mv_avg = adc_app_code_to_mv((uint16_t)(adc_sum / (uint64_t)adc_count));

    snprintf(buf, sizeof(buf), "V %lu.%lu mV",
             (unsigned long)(mv10 / 10U), (unsigned long)(mv10 % 10U));
    adc_set_text(2U, buf);

    snprintf(buf, sizeof(buf), "MIN %lu MAX %lu",
             (unsigned long)mv_min, (unsigned long)mv_max);
    adc_set_text(4U, buf);

    snprintf(buf, sizeof(buf), "AVG %lu PP %lu",
             (unsigned long)mv_avg, (unsigned long)(mv_max - mv_min));
    adc_set_text(5U, buf);
}

/* HOLD / RUN:只冻结显示,不停采集 —— 这样点一下 RUN 立刻就能接上最新数据,
   不用重新标定/重启 DMA。 */
static void adc_hold_cb(lv_event_t * e)
{
    lv_obj_t * btn = (lv_obj_t *)lv_event_get_target(e);
    lv_obj_t * lbl = (btn != NULL) ? lv_obj_get_child(btn, 0) : NULL;

    adc_hold = (uint8_t)((adc_hold == 0U) ? 1U : 0U);

    if (lbl != NULL)
    {
        lv_label_set_text(lbl, (adc_hold != 0U) ? "HOLD" : "RUN");
    }
}

/* CAL:重新做一次偏移标定,10 万周期级,实际不到 1ms,不会卡到界面 */
static void adc_cal_cb(lv_event_t * e)
{
    (void)e;

    adc_app_recalibrate();
    adc_stats_reset();
}

/* CLR:累计统计归零 */
static void adc_clr_cb(lv_event_t * e)
{
    (void)e;

    adc_stats_reset();
}

static void ui_build_adc(lv_obj_t * scr)
{
    /* 6 个标签的配色:通道 / 点数窗口 / 当前值 / 采样率 / MIN,MAX / AVG,PP */
    static const uint32_t lbl_hex[ADC_LBL_COUNT] =
    {
        UI_ACCENT_HEX, UI_MUTED_HEX, UI_TXT_HEX,
        UI_OK_HEX, UI_MUTED_HEX, UI_MUTED_HEX
    };
    lv_obj_t * info;
    lv_obj_t * panel;
    lv_obj_t * btn;
    lv_obj_t * hint;
    int32_t field_w = lv_display_get_horizontal_resolution(lv_display_get_default()) - (2 * UI_PAD);
    int32_t btn_w   = (field_w - (2 * UI_GAP)) / 3;
    int32_t i;

    (void)ui_make_head(scr, "ADC  ADC1_INP10 / PC0", "UART", UI_PAGE_UART);

    /* ---------- 采样信息:3 行,每行左一个右一个 ---------- */
    info = lv_obj_create(scr);
    lv_obj_remove_style_all(info);
    lv_obj_set_pos(info, UI_PAD, ADC_INFO_Y);
    lv_obj_set_size(info, field_w, ADC_INFO_H);
    lv_obj_remove_flag(info, LV_OBJ_FLAG_SCROLLABLE);

    for (i = 0; i < (int32_t)ADC_LBL_COUNT; i++)
    {
        adc_lbl[i] = lv_label_create(info);
        lv_obj_set_style_text_font(adc_lbl[i], &lv_font_montserrat_14, 0);
        lv_obj_set_style_text_color(adc_lbl[i], lv_color_hex(lbl_hex[i]), 0);
        lv_obj_align(adc_lbl[i],
                     ((i & 1) != 0) ? LV_ALIGN_TOP_RIGHT : LV_ALIGN_TOP_LEFT,
                     0, (i / 2) * ADC_INFO_ROW_H);
        adc_cache[i][0] = '\0';      /* 清掉缓存,保证本轮一定写一次 */
    }

    /* ---------- 波形图 ---------- */
    panel = lv_obj_create(scr);
    lv_obj_remove_style_all(panel);
    lv_obj_set_pos(panel, UI_PAD, ADC_WAVE_Y);
    lv_obj_set_size(panel, field_w, ADC_WAVE_H);
    lv_obj_set_style_bg_color(panel, lv_color_hex(UI_FIELD_HEX), 0);
    lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
    lv_obj_set_style_border_width(panel, 1, 0);
    lv_obj_set_style_border_color(panel, lv_color_hex(0x27324A), 0);
    lv_obj_set_style_radius(panel, 6, 0);
    lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE);

    /* 三条静态参考线(顶/中/底):只在建页面时画一次,不参与刷新 */
    for (i = 0; i < 3; i++)
    {
        lv_obj_t * ref = lv_obj_create(panel);
        lv_obj_remove_style_all(ref);
        lv_obj_set_size(ref, field_w - 10, 1);
        lv_obj_set_style_bg_color(ref, lv_color_hex(0x27324A), 0);
        lv_obj_set_style_bg_opa(ref, LV_OPA_COVER, 0);
        lv_obj_set_pos(ref, 5, (i * (ADC_WAVE_H - 4)) / 2);
        lv_obj_remove_flag(ref, LV_OBJ_FLAG_SCROLLABLE);
        lv_obj_remove_flag(ref, LV_OBJ_FLAG_CLICKABLE);
    }

    adc_wave_w = field_w - (2 * ADC_WAVE_PAD);
    adc_wave_h = ADC_WAVE_H - (2 * ADC_WAVE_PAD);

    adc_wave = lv_line_create(panel);
    lv_obj_remove_style_all(adc_wave);
    lv_obj_set_pos(adc_wave, ADC_WAVE_PAD, ADC_WAVE_PAD);
    lv_obj_set_style_line_width(adc_wave, 2, 0);
    lv_obj_set_style_line_color(adc_wave, lv_color_hex(UI_ACCENT_HEX), 0);
    lv_obj_set_style_line_opa(adc_wave, LV_OPA_COVER, 0);

    /* 先摆一条居中的平线占位:有数据之后马上会被整帧折线顶掉 */
    adc_pts[0].x = 0;
    adc_pts[0].y = adc_wave_h / 2;
    adc_pts[1].x = adc_wave_w - 1;
    adc_pts[1].y = adc_wave_h / 2;
    lv_line_set_points(adc_wave, adc_pts, 2U);

    /* ---------- HOLD / CAL / CLR ---------- */
    adc_btn_hold = make_button(scr, "RUN", UI_BTN_HEX, &lv_font_montserrat_14, adc_hold_cb, NULL);
    lv_obj_set_pos(adc_btn_hold, UI_PAD, ADC_ROW_Y);
    lv_obj_set_size(adc_btn_hold, btn_w, ADC_ROW_H);

    btn = make_button(scr, "CAL", UI_BTN_HEX, &lv_font_montserrat_14, adc_cal_cb, NULL);
    lv_obj_set_pos(btn, UI_PAD + btn_w + UI_GAP, ADC_ROW_Y);
    lv_obj_set_size(btn, btn_w, ADC_ROW_H);

    btn = make_button(scr, "CLR", UI_STOP_HEX, &lv_font_montserrat_14, adc_clr_cb, NULL);
    lv_obj_set_pos(btn, UI_PAD + (2 * (btn_w + UI_GAP)), ADC_ROW_Y);
    lv_obj_set_size(btn, btn_w, ADC_ROW_H);

    /* ---------- 底部提示(只能是 ASCII,字库里没有中文字形) ---------- */
    hint = lv_label_create(scr);
    lv_label_set_text(hint, "PC0 input 0 ~ VREF+ (3.3V max)");
    lv_obj_set_style_text_font(hint, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(hint, lv_color_hex(UI_MUTED_HEX), 0);
    lv_obj_set_pos(hint, UI_PAD + 2, ADC_HINT_Y);

    /* ---------- 先立刻刷一次,再挂周期刷新 ---------- */
    adc_page_update(NULL);
    adc_timer = lv_timer_create(adc_page_update, ADC_REFRESH_MS, NULL);
}


效果展示:

在这里插入图片描述

6.屏幕上展示正方体运动

可在屏幕上通过控制键,控制正方体转动
核心部分:

/*********************
 *   3D 立方体页布局与参数
 *********************/
#define CUBE_VIEW_X     4         /* 视口(立方体就画在这里面) */
#define CUBE_VIEW_Y     26
#define CUBE_VIEW_W     124
#define CUBE_VIEW_H     124

#define CUBE_PAD_X      134       /* 方向键区(3x3,中间那格放 SPD) */
#define CUBE_PAD_Y      26
#define CUBE_PAD_BTN_W  58
#define CUBE_PAD_BTN_H  40
#define CUBE_PAD_GAP    4
#define CUBE_PAD_COL_X(n)  (CUBE_PAD_X + ((n) * (CUBE_PAD_BTN_W + CUBE_PAD_GAP)))
#define CUBE_PAD_ROW_Y(n)  (CUBE_PAD_Y + ((n) * (CUBE_PAD_BTN_H + CUBE_PAD_GAP)))

#define CUBE_STATUS_Y   158       /* 姿态 / 档位 / 运行状态行 */
#define CUBE_BTN_Y      178       /* SPIN / HOME 一行 */
#define CUBE_BTN_H      34
#define CUBE_BTN_W      154
#define CUBE_HINT_Y     216       /* 底部提示行 */

#define CUBE_TICK_MS    50U       /* 按住方向键时的角度推进周期 */
#define CUBE_MAX_DT_MS  300U      /* 单次最多推进多少毫秒,掉帧时别一下跳太多 */
#define CUBE_TAP_MS     100.0f    /* 轻点一下按多少毫秒的量转,保证点得动 */

#define CUBE_SPD_COUNT  3U        /* 转速档位个数 */
#define CUBE_HOME_YAW   (-30.0f)  /* HOME 复位到的角度 */
#define CUBE_HOME_PITCH 22.0f

#define CUBE_D2R        0.017453292f   /* 度 -> 弧度 */
/* 世界单位 -> 像素。立方体顶点到中心的距离是 sqrt(3)=1.732,取 33 时最外角
   落在 33*1.732=57 像素上,视口半径有 62,任何角度都不会被裁掉。 */
#define CUBE_SCALE      33.0f
#define CUBE_LX         (-0.352f)      /* 光照方向(已归一化) */
#define CUBE_LY         0.553f
#define CUBE_LZ         0.754f
#define CUBE_AMBIENT    0.34f          /* 环境光占比,剩下的按法线夹角给 */
#define CUBE_DIFFUSE    0.66f
#define CUBE_FACE_HEX   0x2FB6F0       /* 面的基色 */
#define CUBE_EDGE_HEX   0xE8F8FF       /* 棱的颜色 */

#define CUBE_BIT_UP     0x01U          /* cube_keys 的位 */
#define CUBE_BIT_DOWN   0x02U
#define CUBE_BIT_LEFT   0x04U
#define CUBE_BIT_RIGHT  0x08U

/* ---------------- 3D 立方体页(屏幕方向键控制旋转) ----------------
 *
 * 本机没有 GPU,所以是纯软件 3D,但只做最少的事:
 *   1) 8 个顶点按 YAW(绕 Y) -> PITCH(绕 X) 旋转,正交投影到屏幕;
 *   2) 6 个面各拿"旋转后的外法线"判断朝向,nz <= 0 就是背面,直接不画
 *      —— 立方体是凸的,看得见的一定是正对着观察者的那 3 个面,
 *      既省掉了一半三角形,也省掉了排序;
 *   3) 每个可见面按法线和光线的夹角算明暗(面画成两个三角形填色),
 *      再把 4 条棱描一遍,棱角清楚。
 *
 * 填色用的是 lv_draw_triangle(),它跑在 LVGL 自己的绘制管线里、跟着局部
 * 刷新走,所以不需要自己开帧缓冲,也不用管刷屏的字节序。
 *
 * 瓶颈还是刷屏:124x124 一次要 ~28ms(SPI5 8.6MHz)。所以——
 *   - 角度按真实时间推进(掉帧不会忽快忽慢),但只在按键按着 / 自动旋转时
 *     才干活,松手后一帧都不刷;
 *   - 每帧只让"这次和上次的投影包围盒"失效,正对时只有 66x66,
 *     平均能省一半刷屏量。
 */

/* 立方体的 8 个顶点,边长 2,中心在原点 */
static const float cube_v[8][3] =
{
    {-1.0f, -1.0f, -1.0f}, { 1.0f, -1.0f, -1.0f}, { 1.0f,  1.0f, -1.0f}, {-1.0f,  1.0f, -1.0f},
    {-1.0f, -1.0f,  1.0f}, { 1.0f, -1.0f,  1.0f}, { 1.0f,  1.0f,  1.0f}, {-1.0f,  1.0f,  1.0f}
};

/* 6 个面:4 个顶点按"绕这个面走一圈"的顺序排,再配一条外法线 */
static const uint8_t cube_face_v[6][4] =
{
    {4U, 5U, 6U, 7U},   /* +Z 前 */
    {1U, 0U, 3U, 2U},   /* -Z 后 */
    {5U, 1U, 2U, 6U},   /* +X 右 */
    {0U, 4U, 7U, 3U},   /* -X 左 */
    {3U, 2U, 6U, 7U},   /* +Y 上 */
    {0U, 1U, 5U, 4U}    /* -Y 下 */
};
static const float cube_face_n[6][3] =
{
    { 0.0f,  0.0f,  1.0f},
    { 0.0f,  0.0f, -1.0f},
    { 1.0f,  0.0f,  0.0f},
    {-1.0f,  0.0f,  0.0f},
    { 0.0f,  1.0f,  0.0f},
    { 0.0f, -1.0f,  0.0f}
};

/* 旋转一个点:先绕 Y 转 yaw,再绕 X 转 pitch。
   顶点和法线共用这一份,保证两者永远是一致的。 */
static void cube_rotate(const float in[3], float cos_yaw, float sin_yaw,
                        float cos_pit, float sin_pit, float out[3])
{
    float xr = (in[0] * cos_yaw) + (in[2] * sin_yaw);
    float zr = (in[2] * cos_yaw) - (in[0] * sin_yaw);

    out[0] = xr;
    out[1] = (in[1] * cos_pit) - (zr * sin_pit);
    out[2] = (in[1] * sin_pit) + (zr * cos_pit);
}

/* 明暗系数 -> 0xRRGGBB:面的基色整体乘一遍,越亮越接近基色 */
static uint32_t cube_face_color(float k)
{
    uint32_t r = (uint32_t)((float)((CUBE_FACE_HEX >> 16) & 0xFFU) * k);
    uint32_t g = (uint32_t)((float)((CUBE_FACE_HEX >> 8) & 0xFFU) * k);
    uint32_t b = (uint32_t)((float)(CUBE_FACE_HEX & 0xFFU) * k);

    if (r > 255U) { r = 255U; }
    if (g > 255U) { g = 255U; }
    if (b > 255U) { b = 255U; }

    return (r << 16) | (g << 8) | b;
}

/* 把 8 个顶点按当前视角投影成屏幕坐标(画图和算包围盒共用这一份) */
static void cube_project(lv_point_precise_t p[8])
{
    lv_area_t coords;
    float cos_yaw, sin_yaw, cos_pit, sin_pit;
    float v[3];
    int32_t cx, cy;
    uint8_t i;

    lv_obj_get_coords(cube_view, &coords);
    cx = (coords.x1 + coords.x2) / 2;
    cy = (coords.y1 + coords.y2) / 2;

    cos_yaw = cosf(cube_yaw * CUBE_D2R);
    sin_yaw = sinf(cube_yaw * CUBE_D2R);
    cos_pit = cosf(cube_pitch * CUBE_D2R);
    sin_pit = sinf(cube_pitch * CUBE_D2R);

    for (i = 0U; i < 8U; i++)
    {
        cube_rotate(cube_v[i], cos_yaw, sin_yaw, cos_pit, sin_pit, v);
        /* 屏幕 y 向下,所以取负 */
        p[i].x = cx + (int32_t)(v[0] * CUBE_SCALE);
        p[i].y = cy - (int32_t)(v[1] * CUBE_SCALE);
    }
}

/* 投影后的包围盒,外扩 2 像素给 1 像素宽棱线和取整留余量 */
static void cube_bounds(lv_area_t * area)
{
    lv_point_precise_t p[8];
    int32_t x1 = 100000, y1 = 100000, x2 = -100000, y2 = -100000;
    uint8_t i;

    cube_project(p);

    for (i = 0U; i < 8U; i++)
    {
        if (p[i].x < x1) { x1 = p[i].x; }
        if (p[i].y < y1) { y1 = p[i].y; }
        if (p[i].x > x2) { x2 = p[i].x; }
        if (p[i].y > y2) { y2 = p[i].y; }
    }

    area->x1 = x1 - 2;
    area->y1 = y1 - 2;
    area->x2 = x2 + 2;
    area->y2 = y2 + 2;
}

/* 让"这次 + 上次"的包围盒失效。两帧取并集是必须的:
   不加上次的范围,上一帧露在新范围外面的那部分会留下残影。 */
static void cube_invalidate(void)
{
    lv_area_t now;
    lv_area_t both;

    if (cube_view == NULL)
    {
        return;
    }

    cube_bounds(&now);

    if ((cube_prev_area.x2 < cube_prev_area.x1) || (cube_prev_area.y2 < cube_prev_area.y1))
    {
        lv_obj_invalidate(cube_view);       /* 还没有"上次",整块重画一次 */
    }
    else
    {
        both.x1 = LV_MIN(cube_prev_area.x1, now.x1);
        both.y1 = LV_MIN(cube_prev_area.y1, now.y1);
        both.x2 = LV_MAX(cube_prev_area.x2, now.x2);
        both.y2 = LV_MAX(cube_prev_area.y2, now.y2);
        lv_obj_invalidate_area(cube_view, &both);
    }

    cube_prev_area = now;
}

/* 视口的 DRAW_MAIN:LVGL 先把底色/边框画完才会调到这里,
   所以三角形和棱直接往上画,就是压在背景之上的。 */
static void cube_draw_cb(lv_event_t * e)
{
    lv_layer_t * layer = lv_event_get_layer(e);
    lv_point_precise_t p[8];
    lv_draw_triangle_dsc_t tri;
    lv_draw_line_dsc_t line;
    float cos_yaw, sin_yaw, cos_pit, sin_pit;
    float v[3];
    float lit;
    uint8_t i, f;

    if ((cube_view == NULL) || (layer == NULL))
    {
        return;
    }

    cos_yaw = cosf(cube_yaw * CUBE_D2R);
    sin_yaw = sinf(cube_yaw * CUBE_D2R);
    cos_pit = cosf(cube_pitch * CUBE_D2R);
    sin_pit = sinf(cube_pitch * CUBE_D2R);

    cube_project(p);

    lv_draw_triangle_dsc_init(&tri);
    tri.opa = LV_OPA_COVER;

    lv_draw_line_dsc_init(&line);
    line.color = lv_color_hex(CUBE_EDGE_HEX);
    line.width = 1;
    line.opa = LV_OPA_COVER;

    for (f = 0U; f < 6U; f++)
    {
        const uint8_t * fv = cube_face_v[f];

        /* 拿法线判断这个面朝不朝着观察者 */
        cube_rotate(cube_face_n[f], cos_yaw, sin_yaw, cos_pit, sin_pit, v);
        if (v[2] <= 0.02f)
        {
            continue;                       /* 背面:看不见,不画 */
        }

        /* 法线和光线夹角越小越亮,面与面之间就有了立体感 */
        lit = (v[0] * CUBE_LX) + (v[1] * CUBE_LY) + (v[2] * CUBE_LZ);
        if (lit < 0.0f)
        {
            lit = 0.0f;
        }
        tri.color = lv_color_hex(cube_face_color(CUBE_AMBIENT + (CUBE_DIFFUSE * lit)));

        /* 投影后的面一定是平行四边形(凸的),拆两个三角形填掉 */
        tri.p[0] = p[fv[0]];
        tri.p[1] = p[fv[1]];
        tri.p[2] = p[fv[2]];
        lv_draw_triangle(layer, &tri);

        tri.p[0] = p[fv[0]];
        tri.p[1] = p[fv[2]];
        tri.p[2] = p[fv[3]];
        lv_draw_triangle(layer, &tri);

        /* 四条棱描一遍。这里刻意用 p1/p2 而不是 points 数组:
           只有 p1/p2 的线段不会去 lv_malloc,逐帧画也没有堆开销。 */
        for (i = 0U; i < 4U; i++)
        {
            line.p1 = p[fv[i]];
            line.p2 = p[fv[(i + 1U) & 3U]];
            lv_draw_line(layer, &line);
        }
    }
}

/* 角度变了:重画立方体 + 刷状态行 */
static void cube_update(void)
{
    char buf[48];

    if (cube_status != NULL)
    {
        snprintf(buf, sizeof(buf), "YAW %d  PITCH %d  SPD %d  %s",
                 (int)cube_yaw, (int)cube_pitch, (int)cube_spd_tab[cube_spd],
                 (cube_spin != 0U) ? "SPIN" : ((cube_keys != 0U) ? "TURN" : "IDLE"));
        lv_label_set_text(cube_status, buf);
    }

    cube_invalidate();
}

/* 按当前档位把角度推进 dt 毫秒对应的量(按住连转和自动旋转共用) */
static void cube_advance(float dt_ms)
{
    float step = (float)cube_spd_tab[cube_spd] * dt_ms / 1000.0f;

    /* 上/下让顶面、底面转过来;左/右绕着竖轴转 */
    if ((cube_keys & CUBE_BIT_UP) != 0U)    { cube_pitch += step; }
    if ((cube_keys & CUBE_BIT_DOWN) != 0U)  { cube_pitch -= step; }
    if ((cube_keys & CUBE_BIT_LEFT) != 0U)  { cube_yaw   += step; }
    if ((cube_keys & CUBE_BIT_RIGHT) != 0U) { cube_yaw   -= step; }

    /* 自动旋转:两个轴都给一点,看起来更像"翻滚"而不是原地打转 */
    if (cube_spin != 0U)
    {
        cube_yaw   += (step * 0.55f);
        cube_pitch += (step * 0.30f);
    }

    /* 角度收在 -180..180:连转很久也不会因为数值变大而丢精度 */
    while (cube_yaw > 180.0f)    { cube_yaw -= 360.0f; }
    while (cube_yaw < -180.0f)   { cube_yaw += 360.0f; }
    while (cube_pitch > 180.0f)  { cube_pitch -= 360.0f; }
    while (cube_pitch < -180.0f) { cube_pitch += 360.0f; }

    cube_update();
}

/* 方向键:按下开始转(同时立刻走一小步,轻点一下也有反应),松开就停 */
static void cube_key_cb(lv_event_t * e)
{
    uint8_t bit = (uint8_t)(uintptr_t)lv_event_get_user_data(e);

    if (lv_event_get_code(e) == LV_EVENT_PRESSED)
    {
        cube_keys |= bit;
        cube_tick  = lv_tick_get();
        cube_advance(CUBE_TAP_MS);
    }
    else
    {
        /* RELEASED / PRESS_LOST(手指滑出按键也会走这里) */
        cube_keys &= (uint8_t)(~bit);
        cube_update();
    }
}

/* 方向键挂三个事件:按下、松开、按下后滑出去 */
static void cube_bind_key(lv_obj_t * btn, uint8_t bit)
{
    lv_obj_add_event_cb(btn, cube_key_cb, LV_EVENT_PRESSED,    (void *)(uintptr_t)bit);
    lv_obj_add_event_cb(btn, cube_key_cb, LV_EVENT_RELEASED,   (void *)(uintptr_t)bit);
    lv_obj_add_event_cb(btn, cube_key_cb, LV_EVENT_PRESS_LOST, (void *)(uintptr_t)bit);
}

/* 周期推进:没在转就直接返回,一帧都不刷 */
static void cube_tick_cb(lv_timer_t * timer)
{
    uint32_t dt;

    (void)timer;

    if ((cube_view == NULL) || ((cube_keys == 0U) && (cube_spin == 0U)))
    {
        return;
    }

    dt = lv_tick_elaps(cube_tick);          /* 按真实间隔推进,掉帧也不忽快忽慢 */
    cube_tick = lv_tick_get();
    if (dt > CUBE_MAX_DT_MS)
    {
        dt = CUBE_MAX_DT_MS;                /* 卡了一下也别一次跳一大截 */
    }

    cube_advance((float)dt);
}

/* SPD:循环 30 / 60 / 120 度每秒 */
static void cube_spd_cb(lv_event_t * e)
{
    char buf[16];

    (void)e;

    cube_spd = (uint8_t)((cube_spd + 1U) % CUBE_SPD_COUNT);

    if (cube_btn_spd != NULL)
    {
        lv_obj_t * lbl = lv_obj_get_child(cube_btn_spd, 0);

        if (lbl != NULL)
        {
            snprintf(buf, sizeof(buf), "SPD %d", cube_spd_tab[cube_spd]);
            lv_label_set_text(lbl, buf);
        }
    }

    cube_tick = lv_tick_get();
    cube_update();
}

/* SPIN:开着的时候两个轴一起慢慢转,当展示用 */
static void cube_spin_cb(lv_event_t * e)
{
    lv_obj_t * btn = (lv_obj_t *)lv_event_get_target(e);
    lv_obj_t * lbl = (btn != NULL) ? lv_obj_get_child(btn, 0) : NULL;

    cube_spin = (uint8_t)((cube_spin == 0U) ? 1U : 0U);

    if (lbl != NULL)
    {
        lv_label_set_text(lbl, (cube_spin != 0U) ? "STOP" : "SPIN");
    }

    cube_tick = lv_tick_get();
    cube_update();
}

/* HOME:回到一开始那个"能同时看到三个面"的角度 */
static void cube_home_cb(lv_event_t * e)
{
    (void)e;

    cube_yaw   = CUBE_HOME_YAW;
    cube_pitch = CUBE_HOME_PITCH
    cube_update();
}

效果展示:

在这里插入图片描述

7.添加sd+fatfs系统,并在屏幕显示sd文件目录情况,移植fc模拟器,屏幕上添加虚拟按键。

(注意:游戏运行时注意nes文件大小,超过256kb的游戏无法运行。(原因硬件资源受限导致的))。
核心部分:内容较多仅介绍ui和主要文件结构,详细内容查阅源码。


#define SD_BR_ROOT        "0:/"   /* 起始目录:卡根 */
#define SD_BR_ROWS        8U      /* 一屏显示几行:240 高塞不下更多了 */
#define SD_BR_ROW_H       17
#define SD_BR_LIST_Y      42      /* 首行 y */
#define SD_BR_PATH_Y      26      /* 当前路径行 y */
#define SD_BR_STAT_Y      181     /* 统计/结果行 y */
#define SD_BR_BTN_Y       198
#define SD_BR_BTN_H       24
#define SD_BR_BTN_W       75      /* 4 个按钮刚好铺满 312 宽 */
#define SD_BR_HINT_Y      226
#define SD_BR_TICK_MS     200U
#define SD_BR_TXT_MAX     80U
#define SD_BR_STAT_MAX    56U

#define SD_BR_REQ_NONE    0U
#define SD_BR_REQ_RELIST  1U      /* 需要(挂载后)重新列当前目录 */
#define SD_BR_REQ_LAUNCH  2U      /* 加载并启动选中的 .nes */

static sd_dir_entry_t sd_br_ent[SD_DIR_MAX_ENTRIES];
static lv_obj_t *  sd_br_row[SD_BR_ROWS];
static int32_t     sd_br_row_map[SD_BR_ROWS];   /* 行 -> sd_br_ent 下标;-1 = 空行 */
static lv_obj_t *  sd_br_path = NULL;
static lv_obj_t *  sd_br_stat = NULL;
static lv_obj_t *  sd_br_hint = NULL;
static lv_timer_t * sd_br_timer = NULL;
static char        sd_br_path_str[SD_DIR_PATH_MAX];
static char        sd_br_launch[SD_DIR_PATH_MAX];
static char        sd_br_stat_str[SD_BR_STAT_MAX];
static uint32_t    sd_br_count;     /* 这次列目录实际拿到几条 */
static uint32_t    sd_br_total;     /* 目录里一共有几条(可能多于 count) */
static uint32_t    sd_br_page;
static uint8_t     sd_br_req = SD_BR_REQ_NONE;
static uint8_t     sd_br_mounted;

static void sd_br_refresh(void);

/* 状态行:走一句提示,顺带定颜色(绿=好、红=坏、蓝=进行中、灰=中性) */
static void sd_br_set_stat(const char * txt, uint32_t hex)
{
    if (sd_br_stat == NULL)
    {
        return;
    }
    (void)snprintf(sd_br_stat_str, sizeof(sd_br_stat_str), "%s", txt);
    lv_label_set_text(sd_br_stat, sd_br_stat_str);
    lv_obj_set_style_text_color(sd_br_stat, lv_color_hex(hex), 0);
}

#if 0   /* ---- 旧的自检页代码,保留备查(不参与编译)----
         * 上电自检已经取消,这一页换成了下面的目录浏览器。这段代码当初是用来
         * 定位"能读不能写"的(见本文件 890 行附近的说明),留着是为了哪天要
         * 回头对参数时能直接照着看,不影响编译产物。 */
/* 把上一次自检结果铺到 6 行上 */
static void sd_show_result(void)
{
    const sd_test_result_t * r = sd_last_result();
    char buf[48];

    /* 底部两行临时让给诊断信息(定位"能读不能写"用,问题解决后还原)。
       原来这里放的是 "write 128KB to 0:/SDTEST.BIN" / "then read back and compare"。
       diag1 = 绕开 FatFs 的原始块读写探针结果;
       diag2 = 探针里抓到的 HAL 错误码/状态,或者 FatFs 写失败瞬间的同款信息。 */
    if (sd_hint1 != NULL)
    {
        lv_label_set_text(sd_hint1, (r->diag1[0] != '\0') ? r->diag1
                                                         : "write 128KB to 0:/SDTEST.BIN");
    }
    if (sd_hint2 != NULL)
    {
        lv_label_set_text(sd_hint2, (r->diag2[0] != '\0') ? r->diag2
                                                         : "then read back and compare");
    }

    if (r->mount_ok == 0U)
    {
        (void)snprintf(buf, sizeof(buf), "MOUNT FAIL: %s", sd_fresult_str(r->fresult));
        sd_row_set(0U, buf);
        sd_row_set(1U, "no card / not FAT?");
        sd_row_set(2U, "-");
        sd_row_set(3U, "-");
        sd_row_set(4U, "-");
        sd_row_set(5U, "RESULT: FAIL");
        sd_row_set_color(5U, UI_STOP_HEX);
        return;
    }

    sd_row_set(0U, "MOUNT OK");

    (void)snprintf(buf, sizeof(buf), "%s  CAP %luMB",
                   sd_card_type_str(r->card_type), (unsigned long)r->card_mb);
    sd_row_set(1U, buf);

    if (r->write_ok != 0U)
    {
        (void)snprintf(buf, sizeof(buf), "WR %luKB OK  %lukB/s",
                       (unsigned long)(r->test_bytes / 1024U), (unsigned long)r->wr_kbps);
    }
    else
    {
        (void)snprintf(buf, sizeof(buf), "WR FAIL: %s", sd_fresult_str(r->fresult));
    }
    sd_row_set(2U, buf);

    if (r->verify_ok != 0U)
    {
        (void)snprintf(buf, sizeof(buf), "RD/VERIFY OK %lukB/s", (unsigned long)r->rd_kbps);
    }
    else if (r->mismatch_at != 0xFFFFFFFFU)
    {
        (void)snprintf(buf, sizeof(buf), "MISMATCH @ %lu", (unsigned long)r->mismatch_at);
    }
    else
    {
        (void)snprintf(buf, sizeof(buf), "RD FAIL: %s", sd_fresult_str(r->fresult));
    }
    sd_row_set(3U, buf);

    (void)snprintf(buf, sizeof(buf), "USED %lu%%  FREE %luMB",
                   (unsigned long)r->used_pct, (unsigned long)r->free_mb);
    sd_row_set(4U, buf);

    sd_row_set(5U, (r->verify_ok != 0U) ? "RESULT: PASS" : "RESULT: FAIL");
    sd_row_set_color(5U, (r->verify_ok != 0U) ? UI_OK_HEX : UI_STOP_HEX);
}

static void sd_btn_retest_cb(lv_event_t * e)
{
    (void)e;

    /* 只置标志,真正的读写放到定时器里跑,让 RUNNING 先画出来 */
    sd_row_set(0U, "RUNNING ...");
    sd_row_set(1U, "-");
    sd_row_set(2U, "-");
    sd_row_set(3U, "-");
    sd_row_set(4U, "-");
    sd_row_set(5U, "RESULT: ---");
    sd_row_set_color(5U, UI_ACCENT_HEX);

    sd_req = SD_REQ_TEST;
}

static void sd_btn_eject_cb(lv_event_t * e)
{
    (void)e;
    sd_req = SD_REQ_EJECT;
}

static void sd_tick_cb(lv_timer_t * t)
{
    uint8_t req = sd_req;

    (void)t;

    if (req == SD_REQ_NONE)
    {
        return;
    }
    sd_req = SD_REQ_NONE;

    if (req == SD_REQ_EJECT)
    {
        sd_unmount();
        sd_row_set(0U, "UNMOUNTED");
        sd_row_set(1U, "-");
        sd_row_set(2U, "-");
        sd_row_set(3U, "-");
        sd_row_set(4U, "-");
        sd_row_set(5U, "press RE-TEST");
        sd_row_set_color(5U, UI_MUTED_HEX);
        return;
    }

    (void)sd_selftest();
    sd_show_result();
}
#endif  /* ==================== 旧的自检页代码到此为止 ==================== */

/* ==================== SD 页:目录浏览器 ==================== */

/* 按 sd_br_count / sd_br_page 把 8 行和状态行刷一遍。
 * 只碰内存里的数组,不读卡,所以翻页和返回上级之后的刷新都是瞬时的。 */
static void sd_br_refresh(void)
{
    uint32_t i;
    uint32_t pages;
    uint32_t page;
    uint32_t first;
    char     buf[SD_BR_TXT_MAX];

    if (sd_br_path != NULL)
    {
        lv_label_set_text(sd_br_path, sd_br_path_str);
    }

    pages      = (sd_br_count == 0U) ? 1U : ((sd_br_count + SD_BR_ROWS - 1U) / SD_BR_ROWS);
    page       = (sd_br_page < pages) ? sd_br_page : (pages - 1U);
    sd_br_page = page;
    first      = page * SD_BR_ROWS;

    for (i = 0U; i < SD_BR_ROWS; i++)
    {
        uint32_t   idx = first + i;
        lv_obj_t * lbl;
        uint32_t   hex = UI_MUTED_HEX;
        int32_t    map = -1;

        if (sd_br_row[i] == NULL)
        {
            continue;
        }

        if (idx < sd_br_count)
        {
            map = (int32_t)idx;

            if (sd_br_ent[idx].is_dir != 0U)
            {
                /* 目录名后面补个 '/',一眼能看出是可以点进去的 */
                (void)snprintf(buf, sizeof(buf), "%s/", sd_br_ent[idx].name);
                hex = UI_ACCENT_HEX;
            }
            else
            {
                (void)snprintf(buf, sizeof(buf), "%s", sd_br_ent[idx].name);
                hex = (sd_br_ent[idx].is_nes != 0U) ? UI_OK_HEX : UI_MUTED_HEX;
            }
        }
        else
        {
            buf[0] = '\0';
        }

        sd_br_row_map[i] = map;

        lbl = lv_obj_get_child(sd_br_row[i], 0);
        if (lbl != NULL)
        {
            lv_label_set_text(lbl, buf);
            lv_obj_set_style_text_color(lbl, lv_color_hex(hex), 0);
        }

        if (map >= 0)
        {
            lv_obj_remove_flag(sd_br_row[i], LV_OBJ_FLAG_HIDDEN);
        }
        else
        {
            lv_obj_add_flag(sd_br_row[i], LV_OBJ_FLAG_HIDDEN);   /* 空行藏掉,也点不动 */
        }
    }

    /* 状态行:异常优先,正常时显示"第几条到第几条 / 共几条 + 页码" */
    if (sd_br_mounted == 0U)
    {
        sd_br_set_stat("NO CARD - press MOUNT", UI_STOP_HEX);
    }
    else if (sd_br_count == 0U)
    {
        sd_br_set_stat("empty dir", UI_MUTED_HEX);
    }
    else
    {
        uint32_t lo = first + 1U;
        uint32_t hi = first + SD_BR_ROWS;

        if (hi > sd_br_count)
        {
            hi = sd_br_count;
        }

        if (sd_br_total > sd_br_count)
        {
            /* 条目太多,只装下了前 SD_DIR_MAX_ENTRIES 条 —— 得说清楚,
               否则用户会以为后面的文件不见了 */
            (void)snprintf(sd_br_stat_str, sizeof(sd_br_stat_str),
                           "%lu-%lu of %lu (first %lu only)",
                           (unsigned long)lo, (unsigned long)hi,
                           (unsigned long)sd_br_total, (unsigned long)sd_br_count);
        }
        else
        {
            (void)snprintf(sd_br_stat_str, sizeof(sd_br_stat_str),
                           "%lu-%lu of %lu   page %lu/%lu",
                           (unsigned long)lo, (unsigned long)hi,
                           (unsigned long)sd_br_total,
                           (unsigned long)(page + 1U), (unsigned long)pages);
        }
        lv_label_set_text(sd_br_stat, sd_br_stat_str);
        lv_obj_set_style_text_color(sd_br_stat, lv_color_hex(UI_MUTED_HEX), 0);
    }
}

/* 真正去读卡:没挂载就先挂载,然后列当前目录。阻塞几百毫秒,只在定时器里调。 */
static void sd_br_do_list(void)
{
    sd_dir_list_result_t res;
    char                 buf[SD_BR_STAT_MAX];

    if (sd_br_mounted == 0U)
    {
        sd_br_mounted = (sd_ensure_mounted() == (uint8_t)FR_OK) ? 1U : 0U;
    }

    if (sd_br_mounted == 0U)
    {
        sd_br_count = 0U;
        sd_br_total = 0U;
        sd_br_refresh();   /* 这里会把状态行改成 NO CARD */
        return;
    }

    sd_dir_list(sd_br_path_str, sd_br_ent, SD_DIR_MAX_ENTRIES, &res);

    if (res.fresult != (uint8_t)FR_OK)
    {
        sd_br_count = 0U;
        sd_br_total = 0U;
        (void)snprintf(buf, sizeof(buf), "LIST FAIL: %s", sd_fresult_str(res.fresult));
        sd_br_refresh();
        sd_br_set_stat(buf, UI_STOP_HEX);
        return;
    }

    sd_br_count = res.count;
    sd_br_total = res.total;

    /* 目录可能变小了(比如刚从深层退回来),把页码夹回有效范围 */
    if ((sd_br_count == 0U) || ((sd_br_page * SD_BR_ROWS) >= sd_br_count))
    {
        sd_br_page = 0U;
    }

    sd_br_refresh();
}

/* 加载并启动选中的 .nes。nes_game_run() 会一直阻塞到游戏退出为止。 */
static void sd_br_do_launch(void)
{
    nes_game_status_t      st;
    const nes_rom_info_t * info;
    char                   buf[SD_BR_STAT_MAX];

    sd_br_set_stat("LOADING ...", UI_ACCENT_HEX);

    st = nes_game_run(sd_br_launch);

    if (st == NES_GAME_CORE_MISSING)
    {
        /* ROM 已经读进内存了,只是还没接模拟器核心 —— 把卡带信息摆出来,
           这本身就说明"读卡 + 解头 + 载入内存"这条路是通的。 */
        info = nes_game_info();
        if (info != NULL)
        {
            (void)snprintf(buf, sizeof(buf), "ROM ok  M%u %uK/%uK %lums  core absent",
                           (unsigned)info->mapper,
                           (unsigned)info->prg_kb,
                           (unsigned)info->chr_kb,
                           (unsigned long)nes_game_last_ms());
        }
        else
        {
            (void)snprintf(buf, sizeof(buf), "ROM ok, core absent");
        }
        sd_br_set_stat(buf, UI_ACCENT_HEX);
    }
    else if (st == NES_GAME_OK)
    {
        sd_br_set_stat("game exited", UI_OK_HEX);
    }
    else
    {
        (void)snprintf(buf, sizeof(buf), "FAIL: %s", nes_game_status_text(st));
        sd_br_set_stat(buf, UI_STOP_HEX);
    }

    /* 游戏期间屏幕被 NES 输出接管过,回来让 LVGL 整屏重画一遍;
       页面对象都还在(没被 lv_obj_clean),不用重建页面。
       这里必须用 lv_refr_now() 立刻刷完,不能只标记 dirty 等定时器:
       游戏跑了多久、lv_tick 就往前跳了多久,刷新定时器的周期判断这时
       已经不可靠,干等下去屏幕会一直停在游戏最后一帧。 */
    lv_obj_invalidate(lv_screen_active());
    lv_refr_now(lv_display_get_default());
}

/* 点某一行:目录就进去,.nes 就启动,其它文件只提示一句 */
static void sd_br_row_cb(lv_event_t * e)
{
    uint8_t row = (uint8_t)(intptr_t)lv_event_get_user_data(e);
    int32_t idx;
    char    tmp[SD_DIR_PATH_MAX];

    if (row >= SD_BR_ROWS)
    {
        return;
    }

    idx = sd_br_row_map[row];
    if (idx < 0)
    {
        return;   /* 空行 */
    }

    if (sd_path_join(sd_br_path_str, sd_br_ent[idx].name, tmp, sizeof(tmp)) == 0U)
    {
        sd_br_set_stat("path too long", UI_STOP_HEX);
        return;
    }

    if (sd_br_ent[idx].is_dir != 0U)
    {
        (void)snprintf(sd_br_path_str, sizeof(sd_br_path_str), "%s", tmp);
        sd_br_page = 0U;
        sd_br_req  = SD_BR_REQ_RELIST;
        sd_br_set_stat("READING ...", UI_ACCENT_HEX);
        return;
    }

    if (sd_br_ent[idx].is_nes != 0U)
    {
        (void)snprintf(sd_br_launch, sizeof(sd_br_launch), "%s", tmp);
        sd_br_req = SD_BR_REQ_LAUNCH;
        return;
    }

    sd_br_set_stat("not a .nes file", UI_MUTED_HEX);
}

/* 返回上级:根目录(0:/)的上级还是它自己,点了没反应也不会出错 */
static void sd_br_btn_up_cb(lv_event_t * e)
{
    char tmp[SD_DIR_PATH_MAX];

    (void)e;

    if (sd_path_parent(sd_br_path_str, tmp, sizeof(tmp)) == 0U)
    {
        return;
    }
    if (strcmp(tmp, sd_br_path_str) == 0)
    {
        return;   /* 已经在根了 */
    }

    (void)snprintf(sd_br_path_str, sizeof(sd_br_path_str), "%s", tmp);
    sd_br_page = 0U;
    sd_br_req  = SD_BR_REQ_RELIST;
    sd_br_set_stat("READING ...", UI_ACCENT_HEX);
}

static void sd_br_btn_prev_cb(lv_event_t * e)
{
    (void)e;

    if (sd_br_page > 0U)
    {
        sd_br_page--;
        sd_br_refresh();   /* 数据都在内存里,翻页不用重新读卡 */
    }
}

static void sd_br_btn_next_cb(lv_event_t * e)
{
    (void)e;

    if (((sd_br_page + 1U) * SD_BR_ROWS) < sd_br_count)
    {
        sd_br_page++;
        sd_br_refresh();
    }
}

static void sd_br_btn_mount_cb(lv_event_t * e)
{
    (void)e;

    /* 先真的卸载一次再挂:换过卡之后只有这样才会重新走一遍识别, */
    sd_unmount();
    sd_br_mounted = 0U;
    sd_br_req     = SD_BR_REQ_RELIST;
    sd_br_set_stat("MOUNTING ...", UI_ACCENT_HEX);
}

/* 定时器:所有会阻塞界面的活儿都在这里干(挂载、读目录、跑游戏) */
static void sd_br_tick_cb(lv_timer_t * t)
{
    uint8_t req = sd_br_req;

    (void)t;

    if (req == SD_BR_REQ_NONE)
    {
        return;
    }
    sd_br_req = SD_BR_REQ_NONE;

    if (req == SD_BR_REQ_LAUNCH)
    {
        sd_br_do_launch();
    }
    else
    {
        sd_br_do_list();
    }
}

/* 建一行:左对齐文字、按下变亮;行号塞进 user_data,点击时再换算成条目下标 */
static lv_obj_t * sd_br_make_row(lv_obj_t * scr, uint8_t row_idx, int32_t y, int32_t w)
{
    lv_obj_t * row = lv_button_create(scr);
    lv_obj_t * lbl;
    lv_color_t bg  = lv_color_hex(UI_FIELD_HEX);

    lv_obj_remove_style_all(row);
    lv_obj_set_pos(row, UI_PAD, y);
    lv_obj_set_size(row, w, SD_BR_ROW_H);
    lv_obj_set_style_bg_color(row, bg, 0);
    lv_obj_set_style_bg_opa(row, LV_OPA_COVER, 0);
    lv_obj_set_style_radius(row, 4, 0);
    lv_obj_set_style_bg_color(row, lv_color_lighten(bg, 40), LV_STATE_PRESSED);
    lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
    lv_obj_add_event_cb(row, sd_br_row_cb, LV_EVENT_CLICKED, (void *)(intptr_t)row_idx);

    lbl = lv_label_create(row);
    lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP);   /* 名字太长就截断,不换行 */
    lv_obj_set_width(lbl, w - 12);
    lv_obj_set_style_text_font(lbl, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(lbl, lv_color_hex(UI_MUTED_HEX), 0);
    lv_obj_align(lbl, LV_ALIGN_LEFT_MID, 6, 0);
    lv_label_set_text(lbl, "");

    return row;
}

static void ui_build_sd(lv_obj_t * scr)
{
    lv_display_t * disp = lv_display_get_default();
    int32_t scr_w   = lv_display_get_horizontal_resolution(disp);
    int32_t field_w = scr_w - (2 * UI_PAD);
    lv_obj_t * btn;
    uint8_t i;

    (void)ui_make_head(scr, "SD  FILES", "PWM", UI_PAGE_PWM);

    /* ---------- 当前路径 ---------- */
    if (sd_br_path_str[0] == '\0')
    {
        (void)snprintf(sd_br_path_str, sizeof(sd_br_path_str), "%s", SD_BR_ROOT);
    }
    sd_br_path = lv_label_create(scr);
    lv_label_set_long_mode(sd_br_path, LV_LABEL_LONG_CLIP);
    lv_obj_set_width(sd_br_path, field_w);
    lv_obj_set_style_text_font(sd_br_path, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(sd_br_path, lv_color_hex(UI_ACCENT_HEX), 0);
    lv_obj_set_pos(sd_br_path, UI_PAD + 2, SD_BR_PATH_Y);
    lv_label_set_text(sd_br_path, sd_br_path_str);

    /* ---------- 列表(8 行)---------- */
    for (i = 0U; i < SD_BR_ROWS; i++)
    {
        sd_br_row[i]     = sd_br_make_row(scr, i,
                                          SD_BR_LIST_Y + ((int32_t)i * SD_BR_ROW_H),
                                          field_w);
        sd_br_row_map[i] = -1;
    }

    /* ---------- 统计 / 结果行 ---------- */
    sd_br_stat = lv_label_create(scr);
    lv_label_set_long_mode(sd_br_stat, LV_LABEL_LONG_CLIP);
    lv_obj_set_width(sd_br_stat, field_w);
    lv_obj_set_style_text_font(sd_br_stat, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(sd_br_stat, lv_color_hex(UI_MUTED_HEX), 0);
    lv_obj_set_pos(sd_br_stat, UI_PAD + 2, SD_BR_STAT_Y);
    lv_label_set_text(sd_br_stat, "");

    /* ---------- 按钮:返回上级 / 上一页 / 下一页 / 重新挂载 ---------- */
    btn = make_button(scr, "UP", UI_BTN_HEX, &lv_font_montserrat_14,
                      sd_br_btn_up_cb, NULL);
    lv_obj_set_pos(btn, UI_PAD, SD_BR_BTN_Y);
    lv_obj_set_size(btn, SD_BR_BTN_W, SD_BR_BTN_H);

    btn = make_button(scr, "PREV", UI_BTN_HEX, &lv_font_montserrat_14,
                      sd_br_btn_prev_cb, NULL);
    lv_obj_set_pos(btn, UI_PAD + (SD_BR_BTN_W + UI_GAP), SD_BR_BTN_Y);
    lv_obj_set_size(btn, SD_BR_BTN_W, SD_BR_BTN_H);

    btn = make_button(scr, "NEXT", UI_BTN_HEX, &lv_font_montserrat_14,
                      sd_br_btn_next_cb, NULL);
    lv_obj_set_pos(btn, UI_PAD + (2 * (SD_BR_BTN_W + UI_GAP)), SD_BR_BTN_Y);
    lv_obj_set_size(btn, SD_BR_BTN_W, SD_BR_BTN_H);

    /* 换过卡要重新识别,得先真的卸载再挂载,所以这个键先调 sd_unmount() */
    btn = make_button(scr, "MOUNT", UI_OK_HEX, &lv_font_montserrat_14,
                      sd_br_btn_mount_cb, NULL);
    lv_obj_set_pos(btn, UI_PAD + (3 * (SD_BR_BTN_W + UI_GAP)), SD_BR_BTN_Y);
    lv_obj_set_size(btn, SD_BR_BTN_W, SD_BR_BTN_H);

    /* ---------- 操作提示 ---------- */
    sd_br_hint = lv_label_create(scr);
    lv_label_set_text(sd_br_hint, "tap dir to enter, .nes to play");
    lv_obj_set_style_text_font(sd_br_hint, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(sd_br_hint, lv_color_hex(UI_MUTED_HEX), 0);
    lv_obj_set_pos(sd_br_hint, UI_PAD + 2, SD_BR_HINT_Y);

    /* 先把空列表摆出来,真正的挂载和列目录交给定时器 ——
       定时器第一次触发在 200ms 后,这段时间足够 MOUNTING 画出来,
       用户不会看到一屏没反应的界面。 */
    sd_br_mounted = 0U;
    sd_br_count   = 0U;
    sd_br_total   = 0U;
    sd_br_page    = 0U;
    sd_br_req     = SD_BR_REQ_RELIST;

    sd_br_refresh();                          /* 行先清空、路径先显示 */
    sd_br_set_stat("MOUNTING ...", UI_ACCENT_HEX);

    sd_br_timer = lv_timer_create(sd_br_tick_cb, SD_BR_TICK_MS, NULL);
}



核心文件结构说明:
在这里插入图片描述

效果展示:

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

8.屏幕上添加视频播放器,播放sd卡上的视频。

由于文件较长,这里就不展示了。
介绍一下文件结构,以及如何使用
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

效果展示:

在这里插入图片描述
在这里插入图片描述

9.屏幕上添加dac功能,显示dac的输出波形,以及调整波形频率类似信号发生器。

核心部分:


static const char *const dac_wave_name[DAC_APP_WAVE_COUNT] =
{
    "SINE", "SQUARE", "TRI", "SAW"
};

/* 频率格式化:<1kHz 用 Hz(一位小数),>=1kHz 用 kHz(两位小数)。
   整数拆分自己拼,不走 float 版 printf —— 那会额外拉进来几 KB 库代码。 */
static void dac_fmt_freq(char * buf, size_t n, float hz)
{
    uint32_t v10 = (uint32_t)((hz * 10.0f) + 0.5f);       /* 0.1 Hz 为单位 */

    if (v10 < 10000U)                                     /* < 1000.0 Hz */
    {
        (void)snprintf(buf, n, "%lu.%lu Hz",
                       (unsigned long)(v10 / 10U), (unsigned long)(v10 % 10U));
    }
    else
    {
        uint32_t v100 = (uint32_t)((hz * 100.0f) + 0.5f); /* 0.01 Hz 为单位 */

        (void)snprintf(buf, n, "%lu.%02lu kHz",
                       (unsigned long)(v100 / 100000U),
                       (unsigned long)((v100 / 1000U) % 100U));
    }
}

/* 采样率(触发率)格式化:Hz / kHz / MHz 三档,从 256Hz 到 1.28MHz 都不会挤爆标签 */
static void dac_fmt_samp(char * buf, size_t n, float hz)
{
    if (hz < 1000.0f)
    {
        (void)snprintf(buf, n, "%lu Hz", (unsigned long)(hz + 0.5f));
    }
    else if (hz < 1000000.0f)
    {
        uint32_t v = (uint32_t)((hz / 100.0f) + 0.5f);    /* 0.1 kHz 为单位 */

        (void)snprintf(buf, n, "%lu.%lu kHz",
                       (unsigned long)(v / 10U), (unsigned long)(v % 10U));
    }
    else
    {
        uint32_t v = (uint32_t)((hz / 10000.0f) + 0.5f);  /* 0.01 MHz 为单位 */

        (void)snprintf(buf, n, "%lu.%02lu MHz",
                       (unsigned long)(v / 100U), (unsigned long)(v % 100U));
    }
}

/* 折线这一屏铺多少个周期:横轴是固定时间窗,所以周期数与频率成正比 ——
   频率高就密、频率低就疏,和示波器固定时基看波形的感觉一致。
   基准取 1kHz = DAC_WAVE_CYCLES 个周期;低频封底 1 个周期,不然整条线会拉成
   一条直线什么都看不出来;高频封顶,再密就糊成一团。 */
static float dac_wave_cycles(void)
{
    float cyc = (dac_app_get_freq() * (float)DAC_WAVE_CYCLES)
                / DAC_APP_DEFAULT_FREQ_HZ;

    if (cyc < DAC_WAVE_CYC_MIN)
    {
        cyc = DAC_WAVE_CYC_MIN;
    }
    else if (cyc > DAC_WAVE_CYC_MAX)
    {
        cyc = DAC_WAVE_CYC_MAX;
    }

    return cyc;
}

/* 波形展示框:把 DMA 正在搬的那张表画出来,横向铺 dac_wave_cycles() 个周期。
   取的就是硬件真正在用的同一份数据,所以框里看到的就是 PA5 上的形状。
   横轴按分段数均分,纵轴把 0..4095 的码值映射成"上高下低"的像素。 */
static void dac_wave_draw(void)
{
    int32_t  i;
    uint32_t span;

    if (dac_wave == NULL)
    {
        return;
    }

    /* 本屏要覆盖的码值个数 = 周期数 × 每周期点数;至少一个整周期,好让线闭上 */
    span = (uint32_t)((dac_wave_cycles() * (float)DAC_APP_WAVE_LEN) + 0.5f);

    if (span < DAC_APP_WAVE_LEN)
    {
        span = DAC_APP_WAVE_LEN;
    }

    for (i = 0; i < (int32_t)DAC_WAVE_POINTS; i++)
    {
        uint32_t idx  = ((uint32_t)i * span) / DAC_WAVE_SEG;
        uint32_t code = dac_app_wave_point(idx);

        dac_pts[i].x = (i * (dac_wave_w - 1)) / (int32_t)(DAC_WAVE_POINTS - 1U);
        dac_pts[i].y = (dac_wave_h - 1)
                       - (int32_t)((code * (uint32_t)(dac_wave_h - 1)) / DAC_APP_FULL_SCALE);
    }

    lv_line_set_points(dac_wave, dac_pts, DAC_WAVE_POINTS);
}

/* 把参数刷到界面。四个显示量一个都没变就直接返回 —— 本机是单缓冲 + 阻塞刷屏,
   静止的时候界面一次重绘都不该有,否则会把 LVGL 的触摸采样挤掉。 */
static void dac_page_update(void)
{
    float    freq    = dac_app_get_freq();
    uint8_t  wave    = dac_app_get_wave();
    uint8_t  running = dac_app_is_running();
    char     buf[40];
    uint32_t i;

    if ((dac_shown_valid != 0U) && (freq == dac_shown_freq)
        && (wave == dac_shown_wave) && (running == dac_shown_run))
    {
        return;
    }

    if (dac_lbl_f != NULL)
    {
        dac_fmt_freq(buf, sizeof(buf), freq);
        lv_label_set_text(dac_lbl_f, buf);
    }

    if (dac_lbl_w != NULL)
    {
        lv_label_set_text(dac_lbl_w, dac_wave_name[wave]);
    }

    if (dac_lbl_s != NULL)
    {
        dac_fmt_samp(buf, sizeof(buf), dac_app_get_sample_rate());
        lv_label_set_text(dac_lbl_s, buf);
    }

    /* 形状看波形类型、疏密看频率:两个里动一个就得重画折线 */
    if ((wave != dac_shown_wave) || (freq != dac_shown_freq))
    {
        dac_wave_draw();
    }

    /* 当前波形对应的那个按键点亮,一眼看出选中的是哪个 */
    for (i = 0U; i < DAC_APP_WAVE_COUNT; i++)
    {
        if (dac_btn_wave[i] != NULL)
        {
            lv_obj_set_style_bg_color(dac_btn_wave[i],
                                      lv_color_hex((i == wave) ? UI_ACCENT_HEX : UI_BTN_HEX),
                                      0);
        }
    }

    /* 按钮上写的是"按下去会发生什么":正在输出就显示 STOP。
       make_button 里标签是按钮的第一个子对象,直接拿它改文字。 */
    if (dac_btn_run != NULL)
    {
        lv_obj_t * lbl = lv_obj_get_child(dac_btn_run, 0);

        if (lbl != NULL)
        {
            lv_label_set_text(lbl, (running != 0U) ? "STOP" : "RUN");
        }
    }

    dac_shown_freq  = freq;
    dac_shown_wave  = wave;
    dac_shown_run   = running;
    dac_shown_valid = 1U;
}

/* 频率加减。低频段把 ±10 的步进自动收成 ±1:10Hz 往下,10 的步进一下就跨过去
   了,想停在 2Hz、5Hz 这种整数上根本停不住。 */
static void dac_freq_cb(lv_event_t * e)
{
    int32_t step = (int32_t)(intptr_t)lv_event_get_user_data(e);
    float   f    = dac_app_get_freq();

    if ((f <= 10.0f) && ((step == 10) || (step == -10)))
    {
        step = (step > 0) ? 1 : -1;
    }

    dac_app_set_freq(f + (float)step);
    dac_page_update();
}

static void dac_wave_cb(lv_event_t * e)
{
    dac_app_set_wave((uint8_t)(intptr_t)lv_event_get_user_data(e));
    dac_page_update();
}

static void dac_run_cb(lv_event_t * e)
{
    (void)e;

    dac_app_set_enable((dac_app_is_running() != 0U) ? 0U : 1U);
    dac_page_update();
}

/* 回到上电默认(1kHz 正弦):参数调乱了想快速回到一个已知状态时很有用 */
static void dac_default_cb(lv_event_t * e)
{
    (void)e;

    dac_app_set_wave(DAC_APP_DEFAULT_WAVE);
    dac_app_set_freq(DAC_APP_DEFAULT_FREQ_HZ);
    dac_app_set_enable(1U);
    dac_page_update();
}

static void dac_timer_cb(lv_timer_t * timer)
{
    (void)timer;

    dac_page_update();
}

static void ui_build_dac(lv_obj_t * scr)
{
    static const char *const freq_txt[4]  = {"-100", "-10", "+10", "+100"};
    static const int32_t     freq_step[4] = {-100, -10, 10, 100};

    lv_obj_t * info;
    lv_obj_t * panel;
    lv_obj_t * btn;
    int32_t field_w = lv_display_get_horizontal_resolution(lv_display_get_default())
                      - (2 * UI_PAD);
    int32_t btn_w = (field_w - (3 * UI_GAP)) / 4;
    int32_t big_w = (field_w - UI_GAP) / 2;
    int32_t i;
    char    buf[24];

    (void)ui_make_head(scr, "DAC  DAC1_OUT2 / PA5", "3D", UI_PAGE_CUBE);

    /* ---------- 参数:左上频率、右上波形名,下行采样率 / 点数 ---------- */
    info = lv_obj_create(scr);
    lv_obj_remove_style_all(info);
    lv_obj_set_pos(info, UI_PAD, DAC_INFO_Y);
    lv_obj_set_size(info, field_w, DAC_INFO_H);
    lv_obj_remove_flag(info, LV_OBJ_FLAG_SCROLLABLE);

    dac_lbl_f = lv_label_create(info);
    lv_obj_set_style_text_font(dac_lbl_f, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(dac_lbl_f, lv_color_hex(UI_ACCENT_HEX), 0);
    lv_obj_align(dac_lbl_f, LV_ALIGN_TOP_LEFT, 0, 0);

    dac_lbl_w = lv_label_create(info);
    lv_obj_set_style_text_font(dac_lbl_w, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(dac_lbl_w, lv_color_hex(UI_OK_HEX), 0);
    lv_obj_align(dac_lbl_w, LV_ALIGN_TOP_RIGHT, 0, 0);

    dac_lbl_s = lv_label_create(info);
    lv_obj_set_style_text_font(dac_lbl_s, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(dac_lbl_s, lv_color_hex(UI_MUTED_HEX), 0);
    lv_obj_align(dac_lbl_s, LV_ALIGN_BOTTOM_LEFT, 0, 0);

    dac_lbl_p = lv_label_create(info);
    lv_obj_set_style_text_font(dac_lbl_p, &lv_font_montserrat_14, 0);
    lv_obj_set_style_text_color(dac_lbl_p, lv_color_hex(UI_MUTED_HEX), 0);
    lv_obj_align(dac_lbl_p, LV_ALIGN_BOTTOM_RIGHT, 0, 0);
    (void)snprintf(buf, sizeof(buf), "%luPT 12bit", (unsigned long)DAC_APP_WAVE_LEN);
    lv_label_set_text(dac_lbl_p, buf);

    /* ---------- 波形展示框:底色 + 三条参考线 + 折线 ---------- */
    panel = lv_obj_create(scr);
    lv_obj_remove_style_all(panel);
    lv_obj_set_pos(panel, UI_PAD, DAC_WAVE_Y);
    lv_obj_set_size(panel, field_w, DAC_WAVE_H);
    lv_obj_set_style_bg_color(panel, lv_color_hex(UI_FIELD_HEX), 0);
    lv_obj_set_style_bg_opa(panel, LV_OPA_COVER, 0);
    lv_obj_set_style_border_width(panel, 1, 0);
    lv_obj_set_style_border_color(panel, lv_color_hex(0x27324A), 0);
    lv_obj_set_style_radius(panel, 6, 0);
    lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE);

    for (i = 0; i < 3; i++)          /* 满量程 / 中点 / 零,给幅度一个参照 */
    {
        lv_obj_t * ref = lv_obj_create(panel);

        lv_obj_remove_style_all(ref);
        lv_obj_set_size(ref, field_w - 20, 1);
        lv_obj_set_style_bg_color(ref, lv_color_hex(0x27324A), 0);
        lv_obj_set_style_bg_opa(ref, LV_OPA_COVER, 0);
        lv_obj_set_pos(ref, 10, ((DAC_WAVE_H - 4) * i) / 2);
        lv_obj_remove_flag(ref, LV_OBJ_FLAG_SCROLLABLE);
        lv_obj_remove_flag(ref, LV_OBJ_FLAG_CLICKABLE);
    }

    dac_wave_w = field_w - (2 * DAC_WAVE_PAD);
    dac_wave_h = DAC_WAVE_H - (2 * DAC_WAVE_PAD);
    dac_wave   = lv_line_create(panel);
    lv_obj_remove_style_all(dac_wave);
    lv_obj_set_pos(dac_wave, DAC_WAVE_PAD, DAC_WAVE_PAD);
    lv_obj_set_style_line_width(dac_wave, 2, 0);
    lv_obj_set_style_line_rounded(dac_wave, true, 0);
    lv_obj_set_style_line_opa(dac_wave, LV_OPA_COVER, 0);
    lv_obj_set_style_line_color(dac_wave, lv_color_hex(UI_ACCENT_HEX), 0);

    /* ---------- 频率加减 ---------- */
    for (i = 0; i < 4; i++)
    {
        btn = make_button(scr, freq_txt[i], UI_BTN_HEX, &lv_font_montserrat_14,
                          dac_freq_cb, (void *)(intptr_t)freq_step[i]);
        lv_obj_set_pos(btn, UI_PAD + (i * (btn_w + UI_GAP)), DAC_ROW1_Y);
        lv_obj_set_size(btn, btn_w, DAC_ROW_H);
    }

    /* ---------- 波形类型:选中的那个底色点亮,见 dac_page_update ---------- */
    for (i = 0; i < 4; i++)
    {
        dac_btn_wave[i] = make_button(scr, dac_wave_name[i], UI_BTN_HEX,
                                      &lv_font_montserrat_14, dac_wave_cb,
                                      (void *)(intptr_t)i);
        lv_obj_set_pos(dac_btn_wave[i], UI_PAD + (i * (btn_w + UI_GAP)), DAC_ROW2_Y);
        lv_obj_set_size(dac_btn_wave[i], btn_w, DAC_ROW_H);
    }

    /* ---------- RUN/STOP + 回到默认 ---------- */
    dac_btn_run = make_button(scr, "RUN", UI_OK_HEX, &lv_font_montserrat_14,
                              dac_run_cb, NULL);
    lv_obj_set_pos(dac_btn_run, UI_PAD, DAC_ROW3_Y);
    lv_obj_set_size(dac_btn_run, big_w, DAC_ROW3_H);

    btn = make_button(scr, "1kHz SINE", UI_BTN_HEX, &lv_font_montserrat_14,
                      dac_default_cb, NULL);
    lv_obj_set_pos(btn, UI_PAD + big_w + UI_GAP, DAC_ROW3_Y);
    lv_obj_set_size(btn, big_w, DAC_ROW3_H);

    /* ---------- 先按当前状态刷一次,再挂兜底刷新 ---------- */
    dac_shown_valid = 0U;        /* 页面重建过,显示缓存必须作废 */
    dac_page_update();
    dac_timer = lv_timer_create(dac_timer_cb, DAC_REFRESH_MS, NULL);
}

效果展示:

在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

10.源码分享:

https://gitee.com/c_y_l_N_H/lvglstm32h72.4tft-project

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐