3. 输入事件处理:触摸屏原理、坐标校准与事件队列

嵌入式GUI里,输入事件处理这块,说白了就是「人怎么跟机器说话」。触摸屏、按键、编码器,这些都是嘴巴。而事件队列,就是翻译官和传话筒。我做了这么多年嵌入式,见过太多因为输入处理没做好,导致整个界面卡死、误触、漂移的案例。今天咱们就把这块彻底拆开看看。

3.1 触摸屏原理:电阻与电容

触摸屏分两大类:电阻式和电容式。两者原理完全不同,应用场景也天差地别。

3.1.1 电阻触摸屏

电阻屏的结构很简单:两层透明导电薄膜,中间用微小的绝缘点隔开。你用手指或笔一按,上层薄膜就压下去,跟下层接触了。控制器检测到接触点的电压,就能算出坐标。

它的工作方式是这样的:

  • X轴测量:在左右两端加电压,从上下端读取分压值
  • Y轴测量:在上下两端加电压,从左右端读取分压值
  • 两个值组合起来,就是触摸点的坐标
我的经验:电阻屏最大的优点是「什么都能按」——手指、手套、笔帽、甚至指甲盖都行。我在做工业手持设备时,工人经常戴手套操作,电容屏根本没法用,只能上电阻屏。但缺点也很明显:透光率低、不耐刮、不支持多点触控。

3.1.2 电容触摸屏

电容屏的原理就优雅多了。它利用人体电场和屏幕表面的电极阵列形成耦合电容。手指靠近时,该点的电容值发生变化,控制器检测到变化量,就能定位。

电容屏有两种主流方案:

类型 原理 典型应用
表面电容式 四角加电压,检测电流变化 早期ATM机、工控屏
投射电容式 行列电极矩阵,检测互电容变化 智能手机、平板、现代GUI

投射电容式是目前的主流。它支持多点触控,透光率高,手感顺滑。但有个致命弱点:只能感应导电物体。你戴个厚手套,或者用笔尖去戳,它根本不搭理你。

注意:电容屏的灵敏度受温度、湿度、屏幕表面脏污影响很大。我曾经遇到一个项目,用户反映屏幕「乱跳」,排查了半天,结果是屏幕上有油污。清洁之后一切正常。所以做产品时,一定要考虑使用环境的清洁度。

3.2 触摸坐标校准

触摸屏和显示屏是两个独立的物理设备。触摸屏采集的是模拟电压值,显示屏显示的是像素坐标。两者之间需要建立映射关系。这个映射过程,就是校准。

为什么需要校准?因为触摸屏和显示屏的坐标系可能不重合:

  • 触摸屏的零点可能在左上角,也可能在左下角
  • 触摸屏的X轴和显示屏的X轴可能方向相反
  • 触摸屏的物理尺寸和显示屏的分辨率不成比例
  • 触摸屏本身有非线性误差

3.2.1 三点校准法

最常用的校准方法是三点校准。原理很简单:在屏幕上显示三个已知坐标的点,让用户去点。记录触摸屏返回的原始值,然后解一个线性方程组。

校准公式是这样的:

// 显示屏坐标 = A * 触摸屏原始值 + B
// 对于每个轴,需要两个参数
Xd = A * Xt + B
Yd = C * Yt + D

三个点可以解出四个参数。但实际中,我们通常用最小二乘法拟合,用更多的点来提高精度。

核心要点:校准不是一次性的。触摸屏的物理特性会随着温度、老化、甚至屏幕贴膜的变化而漂移。我建议在产品中保留校准入口,或者在每次系统启动时做一次快速自校准。

3.2.2 校准流程

  1. 在屏幕四个角附近显示校准点(通常用十字准星)
  2. 等待用户点击,记录触摸原始值
  3. 用最小二乘法计算映射参数
  4. 将参数保存到非易失性存储(如EEPROM、Flash)
  5. 每次触摸事件发生时,用参数实时转换坐标

嗯,这里要注意:校准点的顺序和位置会影响校准精度。我习惯把校准点放在屏幕边缘内侧10%的位置,太靠边容易点偏,太靠中间又覆盖不了全屏。

3.3 按键与编码器输入

触摸屏虽然方便,但在某些场景下,物理按键和编码器反而更可靠。比如盲操作、戴手套、或者需要精确调节的场景。

3.3.1 物理按键

按键处理的核心是「去抖」。机械按键在按下和释放的瞬间,会产生多次抖动。如果不处理,一次按下可能被识别成几十次。

去抖有两种方式:

  • 硬件去抖:用RC滤波电路或施密特触发器
  • 软件去抖:采样后延时10-20ms,再确认电平状态

我个人习惯用软件去抖。因为硬件去抖会增加BOM成本,而且RC参数一旦固定,很难适应不同按键的机械特性。软件去抖灵活,改个延时值就行。

// 软件去抖示例
uint8_t debounce(uint8_t pin) {
    uint8_t state = digitalRead(pin);
    delay(10);  // 等待10ms
    uint8_t new_state = digitalRead(pin);
    if (state == new_state) {
        return state;  // 稳定状态
    } else {
        return previous_state;  // 抖动,返回之前的状态
    }
}

3.3.2 旋转编码器

旋转编码器输出两路相位差90度的脉冲信号(A相和B相)。通过检测两路信号的相位关系,可以判断旋转方向。

判断方向的逻辑:

  • A相上升沿时,B相为高电平 → 顺时针
  • A相上升沿时,B相为低电平 → 逆时针

编码器的处理比按键复杂一些。因为它需要实时检测边沿变化,通常用外部中断来实现。

避坑指南:我曾经在一个项目里直接用轮询方式读编码器,结果在CPU忙的时候,编码器转快了就丢步。后来改成中断+环形缓冲区,问题才解决。编码器事件不能丢,因为用户旋转的每一格都是有意义的。

3.4 事件队列机制

好了,现在触摸屏、按键、编码器都能产生输入了。但问题来了:这些输入是异步的,随时可能发生。而GUI的绘制和逻辑处理是同步的,需要按顺序执行。怎么协调?

答案就是事件队列。

3.4.1 事件队列的结构

事件队列本质上是一个FIFO(先进先出)缓冲区。所有输入设备产生的事件,都先放进队列里。GUI主循环从队列头部取出事件,依次处理。

一个典型的事件结构体:

typedef struct {
    uint8_t type;      // 事件类型:触摸、按键、编码器
    uint8_t id;        // 设备ID:触摸屏0、按键1、编码器2
    uint16_t x;        // 触摸X坐标(如果是触摸事件)
    uint16_t y;        // 触摸Y坐标(如果是触摸事件)
    uint8_t value;     // 按键值或编码器增量
    uint32_t timestamp; // 事件发生的时间戳
} Event_t;

3.4.2 队列的实现方式

嵌入式系统资源有限,事件队列通常用环形缓冲区实现。这样不需要动态内存分配,也不会产生碎片。

#define EVENT_QUEUE_SIZE 32

typedef struct {
    Event_t buffer[EVENT_QUEUE_SIZE];
    uint8_t head;  // 写入位置
    uint8_t tail;  // 读取位置
    uint8_t count; // 当前事件数
} EventQueue_t;

// 入队
uint8_t enqueue(EventQueue_t *q, Event_t *e) {
    if (q->count >= EVENT_QUEUE_SIZE) {
        return 0;  // 队列满,丢弃
    }
    q->buffer[q->head] = *e;
    q->head = (q->head + 1) % EVENT_QUEUE_SIZE;
    q->count++;
    return 1;
}

// 出队
uint8_t dequeue(EventQueue_t *q, Event_t *e) {
    if (q->count == 0) {
        return 0;  // 队列空
    }
    *e = q->buffer[q->tail];
    q->tail = (q->tail + 1) % EVENT_QUEUE_SIZE;
    q->count--;
    return 1;
}

3.4.3 事件处理的流程

整个输入事件处理的流程,可以用下面这张图来概括:

输入事件处理流程图 触摸屏 物理按键 旋转编码器 中断服务程序 / 轮询采集 事件队列(环形缓冲区) FIFO 先进先出 事件分发器(根据类型路由) 触摸处理函数 按键处理函数 编码器处理函数 说明:所有输入设备产生的事件统一进入队列,GUI主循环按顺序取出并分发处理

3.4.4 队列的优先级问题

你想想看,如果触摸屏事件和按键事件同时发生,谁先处理?

一般来说,事件队列是公平的,谁先来谁先处理。但在某些场景下,我们需要优先级:

  • 紧急事件:比如「关机」按键,应该插队到最前面
  • 普通事件:触摸、编码器,按顺序处理
  • 低优先级事件:比如系统空闲时的自检事件,可以延后处理

实现优先级队列也不复杂。可以用多个队列,每个优先级一个。或者用一个队列,但事件结构体里加一个优先级字段,出队时按优先级排序。

我的建议:除非你的系统对实时性要求极高,否则用单队列就够了。多优先级队列会增加代码复杂度,而且调试起来很麻烦。我见过一个项目,用了三层优先级队列,结果一个bug查了两周,最后发现是优先级反转导致的。简单就是美。

3.5 本章小结

输入事件处理这块,核心就三件事:

  • 采集:从触摸屏、按键、编码器拿到原始信号
  • 转换:校准坐标、去抖、判断方向
  • 排队:用事件队列统一管理,按顺序处理

这三件事做好了,你的GUI就成功了一半。剩下的,就是怎么把这些事件翻译成界面上的动作了。嗯,那是后面章节的内容,咱们到时候再细聊。