手把手教你实现 LVGL 触摸屏校准:从零搭建 STM32 高精度 HMI 系统
你有没有遇到过这样的情况?明明手指点在按钮正中央,界面却“无动于衷”;或者轻轻一碰,弹出了完全不相关的菜单。这种“点不准”的体验,是嵌入式图形界面中最致命的用户体验杀手。
而问题的核心,往往不在显示,而在触摸输入的坐标映射失真。尤其当你使用的是电阻屏 + XPT2046 这类常见方案时,原始 ADC 值和屏幕像素之间几乎不可能天然对齐。
今天,我们就来彻底解决这个问题——手把手带你完成 LVGL 在 STM32 上的完整移植,并重点攻克触摸屏校准这一关键环节。不只是跑通 Demo,而是打造一个真正可用、精准、可量产的 HMI 系统。
为什么你需要触摸校准?
先别急着写代码。我们得搞清楚:为什么不能直接把 XPT2046 的读数缩放一下就当坐标用?
答案很简单:物理世界太“歪”。
- 屏幕贴装可能倾斜几度;
- 按压时压力分布不均导致采样偏移;
- 不同批次的触摸面板灵敏度存在差异;
- LCD 和触摸层之间存在微小错位(俗称“视差”)。
这些因素叠加起来,哪怕你做了x * 320 / 4095这样的线性映射,边缘区域误差也可能高达 30% 以上。
所以,我们必须引入动态校准机制:让用户在屏幕上点击几个已知位置的目标点,系统记录下此时的原始触控数据,然后通过数学方法建立精确的转换模型。
这就像给你的触摸屏做一次“视力矫正”。
LVGL 移植核心三步走
要让 LVGL 在 STM32 上跑起来,本质就是填好两个回调函数:
- 刷屏回调(
flush_cb):告诉 LVGL 如何把画好的内容送到显示屏; - 读取触摸回调(
read_cb):告诉 LVGL 当前有没有被点击、点在哪。
只要这两个接口打通,LVGL 内核就能自主运行 UI 逻辑。
第一步:初始化 LVGL 核心
#include "lvgl.h" static lv_disp_draw_buf_t draw_buf; static lv_color_t buf[LVGL_BUFFER_SIZE]; // 可选双缓冲:buf[2][...] static lv_disp_drv_t disp_drv; static lv_indev_drv_t indev_drv; void lvgl_init(void) { lv_init(); // 初始化帧缓冲 lv_disp_draw_buf_init(&draw_buf, buf, NULL, LVGL_BUFFER_SIZE); // 配置显示驱动 lv_disp_drv_init(&disp_drv); disp_drv.draw_buf = &draw_buf; disp_drv.flush_cb = lcd_flush; // 自定义刷屏函数 disp_drv.hor_res = 320; disp_drv.ver_res = 240; lv_disp_drv_register(&disp_drv); // 注册触摸设备 lv_indev_drv_init(&indev_drv); indev_drv.type = LV_INDEV_TYPE_POINTER; indev_drv.read_cb = touch_read; // 触摸数据获取函数 lv_indev_drv_register(&indev_drv); // 启动定时器处理 LVGL 任务(建议 5ms 中断一次) HAL_TIM_Base_Start_IT(&htim6); }✅ 关键点:
-lcd_flush是你驱动 ILI9341、ST7789 等 LCD 控制器的具体实现;
-touch_read是我们要重点打磨的部分,它决定了用户操作是否准确。
XPT2046 触摸驱动:不只是 SPI 通信
XPT2046 是典型的 SPI 接口电阻屏控制器,但它的工作方式有点特别:你需要主动切换测量模式(测 X 或测 Y),并读取对应的电压比例。
硬件连接要点
| STM32 | XPT2046 |
|---|---|
| SCK | DCLK |
| MISO | DOUT |
| MOSI | DIN (可悬空) |
| CS | PENIRQ (片选) |
| EXTI Line | IRQ |
注意:虽然 DIN 引脚存在,但通常只用于发送命令,实际数据由 DOUT 返回。因此只需配置为全双工或半双工接收即可。
基础读取函数实现
#define XPT2046_CMD_X (0b10010000) #define XPT2046_CMD_Y (0b11010000) static uint16_t xpt2046_read_adc(uint8_t cmd) { uint8_t tx = cmd, rx[2] = {0}; TP_CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, &tx, rx, 1, 10); HAL_Delay(1); // 等待 AD 转换启动 HAL_SPI_Receive(&hspi1, rx, 2, 10); TP_CS_HIGH(); return ((rx[0] << 5) | (rx[1] >> 3)); // 提取高12位 }这个函数能正确读出 0~4095 范围内的原始值。但还不能直接用!
校准前的准备:滤波 + 判稳
原始数据抖动严重,必须加滤波。否则还没开始校准,坐标就已经跳来跳去了。
推荐组合拳:中值滤波 + 滑动平均
#define FILTER_SIZE 5 typedef struct { uint16_t buf[FILTER_SIZE]; uint8_t index; } filter_ctx_t; uint16_t median_filter(filter_ctx_t *ctx, uint16_t val) { ctx->buf[ctx->index++] = val; if (ctx->index >= FILTER_SIZE) ctx->index = 0; uint16_t temp[FILTER_SIZE]; memcpy(temp, ctx->buf, sizeof(temp)); qsort(temp, FILTER_SIZE, sizeof(uint16_t), cmp_uint16); return temp[FILTER_SIZE / 2]; } uint16_t mean_filter(uint16_t *buf, int len) { uint32_t sum = 0; for (int i = 0; i < len; i++) sum += buf[i]; return sum / len; }有了滤波之后,再判断是否稳定按下也很重要。可以设置一个阈值,连续多次读数变化小于某个值才认为是有效触摸。
触摸屏校准算法详解:三点仿射变换
这才是重头戏。
我们希望找到这样一个公式:
$$
\begin{cases}
x_{lcd} = A \cdot x_{raw} + B \cdot y_{raw} + C \
y_{lcd} = D \cdot x_{raw} + E \cdot y_{raw} + F
\end{cases}
$$
这就是二维仿射变换,它可以纠正平移、旋转、缩放甚至轻微剪切变形。
只需要三个非共线点,就能唯一确定这六个系数。
收集校准样本
设计一个简单的 UI 流程:依次在屏幕左上、右下、顶部中心显示一个小十字靶标,提示用户点击。
每点击一次,保存一组数据:
typedef struct { int16_t raw_x, raw_y; int16_t lcd_x, lcd_y; } cal_point_t; cal_point_t calibration_points[3]; int current_point_index = 0; bool calibration_mode = false;当用户点击目标位置后,将当前滤波后的raw_x/raw_y和预设的lcd_x/lcd_y存入数组。
解算变换矩阵
利用线性代数求解方程组。以下是基于行列式的实现:
float cal_matrix[6]; // A, B, C, D, E, F bool compute_calibration_matrix(void) { int16_t x1 = calibration_points[0].raw_x, y1 = calibration_points[0].raw_y; int16_t x2 = calibration_points[1].raw_x, y2 = calibration_points[1].raw_y; int16_t x3 = calibration_points[2].raw_x, y3 = calibration_points[2].raw_y; int16_t u1 = calibration_points[0].lcd_x, v1 = calibration_points[0].lcd_y; int16_t u2 = calibration_points[1].lcd_x, v2 = calibration_points[1].lcd_y; int16_t u3 = calibration_points[2].lcd_x, v3 = calibration_points[2].lcd_y; float det = x1*(y2 - y3) - x2*(y1 - y3) + x3*(y1 - y2); if (fabsf(det) < 0.1f) return false; // 奇异矩阵,三点共线 float inv_det = 1.0f / det; cal_matrix[0] = (u1*(y2 - y3) - u2*(y1 - y3) + u3*(y1 - y2)) * inv_det; // A cal_matrix[1] = (u1*(x3 - x2) - u2*(x3 - x1) + u3*(x2 - x1)) * inv_det; // B cal_matrix[2] = (u1*(x2*y3 - x3*y2) - u2*(x1*y3 - x3*y1) + u3*(x1*y2 - x2*y1)) * inv_det; // C cal_matrix[3] = (v1*(y2 - y3) - v2*(y1 - y3) + v3*(y1 - y2)) * inv_det; // D cal_matrix[4] = (v1*(x3 - x2) - v2*(x3 - x1) + v3*(x2 - x1)) * inv_det; // E cal_matrix[5] = (v1*(x2*y3 - x3*y2) - v2*(x1*y3 - x3*y1) + v3*(x1*y2 - x2*y1)) * inv_det; // F return true; }计算完成后,你可以选择将cal_matrix保存到 Flash 或 EEPROM 中,下次开机直接加载,无需重复校准。
把校准融入touch_read回调
现在回到最开始的touch_read函数,让它支持校准模式与正常模式切换:
bool touch_read(lv_indev_drv_t *drv, lv_indev_data_t *data) { static int16_t last_x = 0, last_y = 0; uint16_t raw_x, raw_y; int16_t final_x, final_y; if (!TP_INT_READ()) { >#define CALIBRATION_ADDR (0x0807F000) // 最后一页 void save_calibration_to_flash(void) { HAL_FLASH_Unlock(); __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_OPERR | FLASH_FLAG_WRPERR); uint32_t addr = CALIBRATION_ADDR; for (int i = 0; i < 6; i++) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i*4, *((uint32_t*)&cal_matrix[i])); } HAL_FLASH_Lock(); } bool load_calibration_from_flash(void) { float *stored = (float*)CALIBRATION_ADDR; for (int i = 0; i < 6; i++) { if (isnan(stored[i])) return false; cal_matrix[i] = stored[i]; } g_calibration_valid = true; return true; }开机先尝试加载,失败则进入校准流程。
💡 秘籍2:快速验证校准效果
写一个测试函数,在屏幕上画出当前触摸点的小圆圈:
lv_obj_t *crosshair = lv_label_create(lv_scr_act()); lv_label_set_text(crosshair, "+"); lv_obj_set_style_text_font(crosshair, &lv_font_montserrat_20, 0); // 在 touch_read 更新后: lv_obj_align(crosshair, LV_ALIGN_TOP_LEFT,>