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