4、输入设备驱动移植:触摸屏(I2C/SPI)与物理按键的适配、lv_port_indev.c的配置、坐标校准与事件上报

好,这一章我们来聊聊输入设备。说白了,就是怎么让LVGL“摸得着”、“按得到”。

我刚开始做嵌入式UI那会儿,觉得显示搞定就万事大吉了。结果一上电,界面是出来了,但点不动、按不了——那叫一个尴尬。后来才明白,输入设备驱动移植,才是真正让UI“活起来”的关键一步。

这一章,我会把触摸屏(I2C/SPI)和物理按键的适配讲透。包括 lv_port_indev.c 怎么配、坐标怎么校准、事件怎么上报。嗯,都是实战中踩过的坑。

4.1 输入设备的两种主流方案

在低内存MCU上,常见的输入设备就两种:

  • 触摸屏:通过I2C或SPI接口读取触摸坐标。适合做交互复杂的界面。
  • 物理按键:通过GPIO检测电平变化。适合做菜单选择、确认返回。

我个人习惯是:如果屏幕大于2.8寸,优先用触摸屏。如果屏幕很小(比如1.3寸以下),物理按键更靠谱。为什么?小屏上手指一按,半个屏幕都挡住了,用户体验很差。

4.2 触摸屏驱动移植(以I2C为例)

触摸屏的驱动芯片很多,比如FT6236、GT911、CST816S。它们底层协议大同小异,核心就是:

  1. 初始化触摸芯片(配置寄存器)
  2. 周期性读取触摸坐标
  3. 上报给LVGL

这里我以I2C接口的FT6236为例,给你看一个精简的驱动框架。

4.2.1 硬件初始化

// 触摸芯片初始化
void touch_init(void)
{
    // 1. 初始化I2C外设
    i2c_init(I2C_PORT, 400000);  // 400kHz标准速率
    
    // 2. 复位触摸芯片(如果有RST引脚)
    gpio_set_level(RST_PIN, 0);
    delay_ms(10);
    gpio_set_level(RST_PIN, 1);
    delay_ms(50);
    
    // 3. 读取芯片ID,确认通信正常
    uint8_t chip_id = 0;
    i2c_read_reg(FT6236_ADDR, 0xA8, &chip_id, 1);
    if (chip_id != 0x11) {
        printf("Touch chip not found!\n");
        // 这里可以加错误处理
    }
}

我在项目中遇到过一个问题:I2C速率设得太高(超过400kHz),触摸芯片偶尔不响应。后来老老实实降到400kHz,再没出过事。嗯,有些芯片真的不能超频。

4.2.2 读取触摸坐标

// 读取触摸点
uint8_t touch_read_point(lv_coord_t *x, lv_coord_t *y)
{
    uint8_t buf[6] = {0};
    
    // 读取6个字节:状态 + 坐标
    i2c_read_regs(FT6236_ADDR, 0x02, buf, 6);
    
    // 解析触摸点数
    uint8_t touch_count = buf[0] & 0x0F;
    if (touch_count == 0) {
        return 0;  // 没有触摸
    }
    
    // 解析第一个触摸点的坐标
    *x = ((buf[1] & 0x0F) << 8) | buf[2];
    *y = ((buf[3] & 0x0F) << 8) | buf[4];
    
    return 1;  // 有触摸
}

你想想看,为什么这里要读6个字节?因为FT6236支持最多2点触摸,每个点占3字节。我们只取第一个点就够了。

4.3 物理按键适配

物理按键就简单多了。核心是:

  • 检测GPIO电平变化
  • 消抖处理
  • 映射成LVGL的按键事件
// 按键初始化
void btn_init(void)
{
    gpio_set_direction(BTN_UP_PIN, GPIO_MODE_INPUT);
    gpio_set_pull_mode(BTN_UP_PIN, GPIO_PULLUP_ONLY);
    
    gpio_set_direction(BTN_DOWN_PIN, GPIO_MODE_INPUT);
    gpio_set_pull_mode(BTN_DOWN_PIN, GPIO_PULLUP_ONLY);
    
    gpio_set_direction(BTN_ENTER_PIN, GPIO_MODE_INPUT);
    gpio_set_pull_mode(BTN_ENTER_PIN, GPIO_PULLUP_ONLY);
}

// 读取按键状态(带消抖)
uint8_t btn_read(uint8_t pin)
{
    if (gpio_get_level(pin) == 0) {  // 按下(低电平有效)
        delay_ms(20);  // 消抖
        if (gpio_get_level(pin) == 0) {
            return 1;  // 确认按下
        }
    }
    return 0;
}

我曾经犯过一个低级错误:消抖延时写成了200ms。结果按一下按键,界面跳了两下。后来改成20ms,刚刚好。消抖时间别太长,否则用户会觉得“这按键反应好慢”。

4.4 lv_port_indev.c 的配置

这是LVGL输入设备适配的核心文件。说白了,就是告诉LVGL:你的输入设备长什么样、怎么读数据。

4.4.1 触摸屏适配

// 触摸屏初始化回调
static void touchpad_init(void)
{
    touch_init();
}

// 触摸屏读取回调
static void touchpad_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
    static lv_coord_t last_x = 0;
    static lv_coord_t last_y = 0;
    
    lv_coord_t x, y;
    uint8_t touched = touch_read_point(&x, &y);
    
    if (touched) {
        // 坐标校准(后面会讲)
        data->point.x = calibrate_x(x);
        data->point.y = calibrate_y(y);
        data->state = LV_INDEV_STATE_PR;
        last_x = data->point.x;
        last_y = data->point.y;
    } else {
        data->point.x = last_x;
        data->point.y = last_y;
        data->state = LV_INDEV_STATE_REL;
    }
}

// 注册触摸屏输入设备
void lv_port_indev_init(void)
{
    static lv_indev_drv_t indev_drv;
    lv_indev_drv_init(&indev_drv);
    
    indev_drv.type = LV_INDEV_TYPE_POINTER;  // 指针类型
    indev_drv.read_cb = touchpad_read;       // 读取回调
    indev_drv.feedback_cb = NULL;            // 反馈回调(可选)
    
    lv_indev_t *indev = lv_indev_drv_register(&indev_drv);
}

这里有个细节:data->point.xdata->point.y 在没触摸时,最好保持上一次的值。为什么?因为LVGL内部会做“最后位置”的判断,如果突然变成(0,0),可能会触发意外的点击事件。

4.4.2 物理按键适配

// 按键读取回调
static void btn_read_cb(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
    // 检测哪个按键被按下
    if (btn_read(BTN_UP_PIN)) {
        data->key = LV_KEY_UP;
        data->state = LV_INDEV_STATE_PR;
    } else if (btn_read(BTN_DOWN_PIN)) {
        data->key = LV_KEY_DOWN;
        data->state = LV_INDEV_STATE_PR;
    } else if (btn_read(BTN_ENTER_PIN)) {
        data->key = LV_KEY_ENTER;
        data->state = LV_INDEV_STATE_PR;
    } else {
        data->state = LV_INDEV_STATE_REL;
    }
}

// 注册按键输入设备
void lv_port_indev_keypad_init(void)
{
    static lv_indev_drv_t indev_drv;
    lv_indev_drv_init(&indev_drv);
    
    indev_drv.type = LV_INDEV_TYPE_KEYPAD;  // 键盘类型
    indev_drv.read_cb = btn_read_cb;
    
    lv_indev_t *indev = lv_indev_drv_register(&indev_drv);
}
提示:触摸屏和物理按键可以同时注册。LVGL内部会分别处理。我做过一个项目,大屏用触摸,侧面留了3个物理按键做快捷操作,用户反馈很好。

4.5 坐标校准

触摸屏的原始坐标和屏幕显示坐标,往往不是一一对应的。原因很多:

  • 触摸屏和LCD贴合有偏差
  • ADC采样有非线性
  • 屏幕旋转后坐标轴变了

最简单的校准方法:两点校准法。

// 校准参数(通过两点校准计算得出)
#define TOUCH_MIN_X  200
#define TOUCH_MAX_X  3800
#define TOUCH_MIN_Y  300
#define TOUCH_MAX_Y  3700

#define LCD_WIDTH    320
#define LCD_HEIGHT   240

// X坐标校准
static lv_coord_t calibrate_x(uint16_t raw_x)
{
    // 线性映射
    int32_t x = (int32_t)(raw_x - TOUCH_MIN_X) * LCD_WIDTH;
    x = x / (TOUCH_MAX_X - TOUCH_MIN_X);
    
    // 边界裁剪
    if (x < 0) x = 0;
    if (x >= LCD_WIDTH) x = LCD_WIDTH - 1;
    
    return (lv_coord_t)x;
}

// Y坐标校准
static lv_coord_t calibrate_y(uint16_t raw_y)
{
    int32_t y = (int32_t)(raw_y - TOUCH_MIN_Y) * LCD_HEIGHT;
    y = y / (TOUCH_MAX_Y - TOUCH_MIN_Y);
    
    if (y < 0) y = 0;
    if (y >= LCD_HEIGHT) y = LCD_HEIGHT - 1;
    
    return (lv_coord_t)y;
}
注意:校准参数不要写死在代码里。我建议在量产时,每台设备单独校准,把参数存到Flash或EEPROM里。为什么?因为触摸屏的物理特性有离散性,同一批次的屏,参数可能差10%。

4.6 事件上报机制

LVGL的事件上报,其实就两个状态:

  • LV_INDEV_STATE_PR:按下
  • LV_INDEV_STATE_REL:释放

触摸屏上报的是坐标+状态。按键上报的是键值+状态。

LVGL内部会做事件处理:

  • 按下时,触发 LV_EVENT_PRESSING
  • 释放时,触发 LV_EVENT_RELEASED
  • 如果按下后短时间内释放,触发 LV_EVENT_SHORT_CLICKED
  • 如果按下后长时间不释放,触发 LV_EVENT_LONG_PRESSED

你不需要自己实现这些逻辑。LVGL都帮你做好了。你只需要保证:

  1. 按下时,data->state = LV_INDEV_STATE_PR
  2. 释放时,data->state = LV_INDEV_STATE_REL
  3. 坐标数据准确

就这么简单。

4.7 避坑指南

最后,分享几个我踩过的坑:

  • 触摸中断不要放在回调里:我曾经把触摸读取放在GPIO中断里,结果中断频率太高,系统直接卡死。正确的做法是:在LVGL的 lv_tick_handler 或者主循环里轮询。
  • I2C通信要加超时:触摸芯片偶尔会挂死。加个超时机制,超时后重新初始化I2C,能大大提高稳定性。
  • 物理按键不要用外部中断:低内存MCU的中断资源很宝贵。用轮询+消抖,完全够用。
  • 校准参数要留余量:触摸边缘的坐标可能不太准。校准后,留出5%的边界余量,用户体验会好很多。

好了,这一章就到这里。下一章我们会讲LVGL的显示驱动优化——怎么让界面跑得更流畅。嗯,那才是真正考验功底的地方。