4. 输入设备驱动移植:触摸屏驱动(I2C/SPI)、物理按键驱动、编码器驱动、输入事件队列设计

输入设备,说白了就是GUI跟用户交互的「手」和「脚」。没有它们,界面再漂亮也只是个花瓶。我这些年移植GUI,踩坑最多的往往不是渲染引擎,反而是输入设备——触摸屏点不准、按键抖动、编码器乱跳,这些问题能把一个好好的产品搞崩溃。

这一章,咱们就聚焦在嵌入式GUI里最常见的三种输入设备:触摸屏、物理按键、旋转编码器。我会把每种设备的驱动移植要点、中断处理、数据滤波都讲透,最后再统一设计一个输入事件队列,让GUI框架能优雅地消费这些事件。

4.1 触摸屏驱动移植(I2C/SPI)

触摸屏在嵌入式设备里基本是标配了。常见的接口就两种:I2C和SPI。I2C连线少,适合低速场景;SPI速度快,适合需要高刷新率的应用。

4.1.1 I2C触摸屏驱动移植

我以GT911这款电容触摸芯片为例。它用I2C接口,支持5点触控,在工控和消费电子里用得非常多。

驱动移植的核心就三步:初始化、读坐标、上报事件。

第一步:I2C初始化

你得先确保I2C控制器已经初始化好了。不同的MCU,I2C寄存器配置差别很大。我习惯把I2C底层操作抽象成几个函数指针,这样换平台时只需要改底层。

/* I2C底层操作结构体 */
typedef struct {
    void (*init)(void);
    int  (*read)(uint8_t dev_addr, uint8_t reg_addr, uint8_t *buf, uint16_t len);
    int  (*write)(uint8_t dev_addr, uint8_t reg_addr, uint8_t *buf, uint16_t len);
} i2c_bus_t;

/* GT911驱动结构体 */
typedef struct {
    i2c_bus_t *bus;
    uint8_t   addr;       /* 设备地址,通常是0x5D或0x14 */
    uint16_t  max_x;
    uint16_t  max_y;
    uint8_t   touch_points;
} gt911_t;

第二步:读取触摸坐标

GT911的数据寄存器从0x814E开始。每次读之前,先读0x814E这个状态寄存器,判断有没有触摸事件。然后批量读取坐标数据。

int gt911_read_point(gt911_t *tp, touch_point_t *point)
{
    uint8_t buf[7];
    int ret;

    /* 读状态寄存器,判断是否有触摸 */
    ret = tp->bus->read(tp->addr, 0x814E, buf, 1);
    if (ret != 0) return -1;

    if (!(buf[0] & 0x80)) {
        /* 没有触摸事件 */
        return 0;
    }

    /* 读取第一个触摸点的数据 */
    ret = tp->bus->read(tp->addr, 0x8150, buf, 7);
    if (ret != 0) return -1;

    point->x = ((uint16_t)buf[1] << 8) | buf[0];
    point->y = ((uint16_t)buf[3] << 8) | buf[2];
    point->pressure = buf[6];
    point->status = TOUCH_DOWN;

    /* 清状态寄存器,告诉芯片数据已读 */
    buf[0] = 0x00;
    tp->bus->write(tp->addr, 0x814E, buf, 1);

    return 1;
}

经验之谈:GT911有个坑——它的I2C地址可以通过配置引脚选择。我遇到过硬件工程师把地址引脚接反了,结果芯片地址变成0x14而不是默认的0x5D。排查了整整半天才发现。所以拿到新板子,第一件事就是确认设备地址对不对。

4.1.2 SPI触摸屏驱动移植

SPI接口的触摸屏,典型代表是TSC2046(或者它的兼容芯片ADS7846)。这类芯片用在电阻屏上比较多,工业设备里很常见。

SPI驱动移植的关键是时序。TSC2046的控制字节格式如下:

位 7 6 5 4 3 2 1 0
功能 S A2 A1 A0 MODE SER/DFR PD1 PD0

S位是起始位,必须为1。A2~A0是通道选择。MODE选择分辨率(12位或8位)。SER/DFR选择单端还是差分模式。

uint16_t tsc2046_read_adc(spi_bus_t *spi, uint8_t channel)
{
    uint8_t tx_buf[3] = {0};
    uint8_t rx_buf[3] = {0};
    uint16_t value;

    /* 构造控制字节:起始位1 + 通道 + 12位模式 + 差分 */
    tx_buf[0] = 0x80 | (channel << 4) | 0x0C;

    /* SPI传输3个字节 */
    spi->transfer(spi, tx_buf, rx_buf, 3);

    /* 数据在rx_buf[1]和rx_buf[2]中,12位有效 */
    value = ((uint16_t)(rx_buf[1] & 0x0F) << 8) | rx_buf[2];

    return value;
}

注意:SPI触摸屏的采样速率不能太低。我见过有人把SPI时钟设到100kHz,结果触摸响应慢得像蜗牛。建议SPI时钟至少1MHz以上,采样周期控制在10ms以内,否则用户体验会很差。

4.2 物理按键驱动

物理按键看着简单,但处理不好就是灾难。抖动、长按、组合键,每个都是坑。

4.2.1 按键消抖

机械按键按下和松开时,触点会弹跳,持续时间通常5~20ms。如果不做消抖,一次按下可能被识别成多次。

我常用的消抖方法是「延时采样法」:检测到电平变化后,等10~20ms再读一次,确认状态稳定。

typedef struct {
    uint8_t  pin_id;
    uint8_t  last_state;      /* 上一次稳定状态 */
    uint8_t  current_state;   /* 当前采样状态 */
    uint8_t  debounce_cnt;    /* 消抖计数器 */
    uint8_t  long_press_cnt;  /* 长按计数器 */
    uint8_t  event;           /* 产生的事件 */
} key_t;

void key_scan(key_t *key)
{
    uint8_t level = gpio_read(key->pin_id);

    if (level != key->current_state) {
        /* 电平变化,启动消抖 */
        key->current_state = level;
        key->debounce_cnt = 0;
    } else {
        /* 电平稳定,累加计数器 */
        if (key->debounce_cnt < DEBOUNCE_THRESHOLD) {
            key->debounce_cnt++;
        }
    }

    /* 消抖完成,判断按键事件 */
    if (key->debounce_cnt == DEBOUNCE_THRESHOLD) {
        if (key->last_state == KEY_RELEASE && level == KEY_PRESS) {
            key->event = KEY_DOWN;
        } else if (key->last_state == KEY_PRESS && level == KEY_RELEASE) {
            key->event = KEY_UP;
        }
        key->last_state = level;
    }
}

避坑指南:我曾经在一个项目里直接用硬件延时(delay_ms)做消抖,结果按键扫描阻塞了主循环,GUI界面卡顿严重。后来改成状态机+定时器轮询,问题才解决。记住:不要在中断或主循环里用阻塞延时做消抖。

4.2.2 长按与组合键

很多GUI需要长按触发特殊功能,比如音量键长按进入设置菜单。实现长按检测,就是在按键按下后开始计时,超过阈值就触发长按事件。

#define LONG_PRESS_MS  1000  /* 1秒算长按 */

void key_process(key_t *key, uint32_t tick_ms)
{
    if (key->event == KEY_DOWN) {
        key->long_press_cnt = tick_ms;
        key->event = KEY_NONE;
    }

    if (key->last_state == KEY_PRESS) {
        if ((tick_ms - key->long_press_cnt) > LONG_PRESS_MS) {
            key->event = KEY_LONG_PRESS;
            key->long_press_cnt = tick_ms;  /* 防止重复触发 */
        }
    }
}

组合键的处理,我一般用一个全局按键状态表,扫描时记录所有按键的当前状态,然后查表判断组合是否匹配。

4.3 编码器驱动

旋转编码器在音量调节、菜单选择这些场景里特别好用。它输出两路正交信号(A相和B相),通过相位差判断旋转方向。

4.3.1 正交信号解码

编码器的核心解码逻辑:检测A相和B相的边沿变化,根据变化顺序判断正反转。

typedef struct {
    uint8_t  pin_a;
    uint8_t  pin_b;
    int8_t   delta;       /* 本次扫描的增量:+1正转,-1反转,0无变化 */
    uint8_t  last_ab;     /* 上一次的AB相状态 */
} encoder_t;

void encoder_scan(encoder_t *enc)
{
    uint8_t a = gpio_read(enc->pin_a);
    uint8_t b = gpio_read(enc->pin_b);
    uint8_t current_ab = (a << 1) | b;
    uint8_t change = enc->last_ab ^ current_ab;

    if (change == 0) {
        enc->delta = 0;
        return;
    }

    /* 查表法判断方向 */
    static const int8_t dir_table[16] = {
        0,  1, -1,  0,   /* 00 -> 01, 10, 11 */
       -1,  0,  0,  1,   /* 01 -> 00, 10, 11 */
        1,  0,  0, -1,   /* 10 -> 00, 01, 11 */
        0, -1,  1,  0    /* 11 -> 00, 01, 10 */
    };

    uint8_t index = (enc->last_ab << 2) | current_ab;
    enc->delta = dir_table[index];
    enc->last_ab = current_ab;
}

我的习惯:编码器采样频率要足够高,至少是编码器最大旋转频率的4倍以上。否则会漏掉脉冲,导致旋转方向判断错误。我一般用1ms定时器扫描,对于大多数机械编码器足够了。

4.3.2 编码器加速

用户快速旋转编码器时,如果每次只加1,体验会很差。我通常会加一个加速算法:根据两次旋转的时间间隔,动态调整增量倍数。

int encoder_get_value(encoder_t *enc, uint32_t tick_ms)
{
    static uint32_t last_tick = 0;
    int delta = enc->delta;

    if (delta != 0) {
        uint32_t interval = tick_ms - last_tick;
        last_tick = tick_ms;

        /* 旋转间隔越短,增量倍数越大 */
        if (interval < 20) {
            delta *= 5;   /* 快速旋转,一次加5 */
        } else if (interval < 50) {
            delta *= 3;   /* 中速旋转,一次加3 */
        }
        /* 慢速旋转,保持1倍 */
    }

    return delta;
}

4.4 输入事件队列设计

好了,现在触摸屏、按键、编码器都能产生事件了。但问题来了——这些事件可能在不同中断或任务里产生,GUI主循环怎么优雅地消费它们?

答案就是:输入事件队列。

4.4.1 队列数据结构

我设计了一个环形缓冲区,支持多生产者(中断/任务)和单消费者(GUI主循环)。

#define EVENT_QUEUE_SIZE  32

typedef enum {
    EVENT_TOUCH_DOWN,
    EVENT_TOUCH_UP,
    EVENT_TOUCH_MOVE,
    EVENT_KEY_DOWN,
    EVENT_KEY_UP,
    EVENT_KEY_LONG_PRESS,
    EVENT_ENCODER_TURN,
} event_type_t;

typedef struct {
    event_type_t type;
    union {
        struct {
            uint16_t x;
            uint16_t y;
            uint8_t  pressure;
        } touch;
        struct {
            uint8_t  key_id;
        } key;
        struct {
            int8_t   delta;
        } encoder;
    } data;
} input_event_t;

typedef struct {
    input_event_t buffer[EVENT_QUEUE_SIZE];
    volatile uint16_t head;   /* 写入位置 */
    volatile uint16_t tail;   /* 读取位置 */
} event_queue_t;

4.4.2 入队与出队

入队操作要在中断里调用,所以必须保证原子性。我关中断来保护临界区。

int event_queue_push(event_queue_t *q, input_event_t *evt)
{
    uint16_t next;
    int ret = 0;

    __disable_irq();
    next = (q->head + 1) % EVENT_QUEUE_SIZE;
    if (next != q->tail) {
        q->buffer[q->head] = *evt;
        q->head = next;
        ret = 1;
    }
    __enable_irq();

    return ret;  /* 0表示队列满 */
}

int event_queue_pop(event_queue_t *q, input_event_t *evt)
{
    int ret = 0;

    if (q->tail != q->head) {
        *evt = q->buffer[q->tail];
        q->tail = (q->tail + 1) % EVENT_QUEUE_SIZE;
        ret = 1;
    }

    return ret;  /* 0表示队列空 */
}

注意:队列大小要合理。太小了容易丢事件,太大了浪费内存。32个事件对于大多数GUI应用足够了。如果触摸屏支持多点触控,建议把队列扩大到64。

4.4.3 GUI主循环消费事件

GUI主循环里,每次迭代都从队列里取事件,然后分发给对应的控件。

void gui_main_loop(void)
{
    input_event_t evt;

    while (1) {
        while (event_queue_pop(&g_event_queue, &evt)) {
            switch (evt.type) {
                case EVENT_TOUCH_DOWN:
                    gui_dispatch_touch_down(evt.data.touch.x,
                                            evt.data.touch.y);
                    break;
                case EVENT_TOUCH_UP:
                    gui_dispatch_touch_up(evt.data.touch.x,
                                          evt.data.touch.y);
                    break;
                case EVENT_KEY_DOWN:
                    gui_dispatch_key_down(evt.data.key.key_id);
                    break;
                case EVENT_ENCODER_TURN:
                    gui_dispatch_encoder(evt.data.encoder.delta);
                    break;
                default:
                    break;
            }
        }

        gui_refresh();  /* 刷新界面 */
    }
}

你看,这样一来,输入设备的驱动和GUI框架就解耦了。驱动只管往队列里塞事件,GUI只管从队列里取事件。两边互不干扰,维护起来也轻松。

4.5 本章知识体系

下面这张图,把本章的核心内容串起来了。你可以看到,三种输入设备各自独立工作,最终都汇聚到事件队列,由GUI主循环统一处理。

输入设备驱动与事件队列架构 触摸屏驱动 I2C / SPI 接口 物理按键驱动 消抖 / 长按 / 组合键 编码器驱动 正交解码 / 加速算法 输入事件队列 环形缓冲区 / 多生产者单消费者 GUI 主循环 事件分发 / 界面刷新 三种输入设备独立工作,通过统一事件队列与GUI框架解耦

嗯,这一章的内容就到这里。触摸屏、按键、编码器,每种设备都有自己的脾气。但只要把驱动写扎实了,事件队列设计好了,后面移植GUI框架就会顺畅很多。记住:输入设备是用户跟设备交互的桥梁,这块做不好,其他都是白搭。