3、输入设备管理:触摸屏、按键矩阵、编码器与事件队列

输入设备,是嵌入式GUI和人交互的「手」。你想想看,用户点一下屏幕、按一个按键、旋一下编码器,这些物理动作怎么变成软件能理解的消息?这就是本章要聊的事。

我做了这么多年嵌入式,见过太多「硬件焊好了,软件跑不起来」的坑。说白了,输入设备管理不只是读个GPIO电平那么简单。你得考虑抖动、考虑并发、考虑不同设备的时序差异。嗯,咱们一个一个来拆。

3.1 触摸屏:电阻 vs 电容

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

特性 电阻式触摸屏 电容式触摸屏
工作原理 压力使上下两层导电膜接触,分压检测坐标 人体电容改变电极间电场,检测电荷变化
触摸方式 任何物体(手指、笔、戴手套) 仅导电物体(手指、电容笔)
多点触控 不支持(天生单点) 支持(2~10点)
耐用性 膜层易磨损,寿命短 玻璃面板,耐磨耐刮
成本 低 中高
典型应用 工业HMI、收银机、老式PDA 手机、平板、智能家居面板

核心区别一句话:电阻屏靠「压」,电容屏靠「摸」。如果你的产品需要戴手套操作(比如户外设备),电阻屏反而是更可靠的选择。

3.2 触摸屏驱动要点

不管哪种触摸屏,MCU读到的都是原始坐标数据。但原始数据不能直接用——你想想,手指按下去时会有抖动,ADC采样也有噪声。

我常用的处理流程:

  1. 采样:以50~100Hz频率读取触摸控制器(如XPT2046、FT5x06)的坐标寄存器。
  2. 滤波:用中值滤波或滑动平均去除毛刺。我习惯取5个样本排序后取中间3个平均,效果不错。
  3. 校准:将ADC值映射到屏幕像素坐标。电阻屏尤其需要三点或五点校准。
  4. 上报:生成触摸事件(按下、移动、抬起),推入事件队列。

避坑指南:我曾经在一个项目中,电容屏的I²C总线没做上拉电阻,导致触摸数据偶尔全0。排查了两天才发现——硬件上拉是必须的,别指望MCU内部弱上拉能扛住。

3.3 按键矩阵

当按键数量超过4~5个时,独立IO就不划算了。按键矩阵用「行扫描+列读取」的方式,用M+N个IO驱动M×N个按键。

典型4×4矩阵扫描流程:

// 伪代码:按键矩阵扫描
#define ROWS 4
#define COLS 4

uint8_t scan_keypad(void) {
    for (uint8_t col = 0; col < COLS; col++) {
        // 将当前列拉低,其他列输出高
        set_column(col, LOW);
        delay_us(10);  // 等待电平稳定
        
        // 读取所有行
        uint8_t row_data = read_rows();
        
        // 检测哪个行被按下
        for (uint8_t row = 0; row < ROWS; row++) {
            if ((row_data & (1 << row)) == 0) {
                // 检测到按键 (row, col) 被按下
                // 注意:需要去抖处理
                return (row << 4) | col;
            }
        }
        
        // 恢复该列输出高
        set_column(col, HIGH);
    }
    return KEY_NONE;
}

注意:按键矩阵最怕「鬼键」——当三个按键形成三角路径时,第四个键会被误读。解决办法是加二极管隔离,或者在软件中做「N键防冲突」检测。我一般建议硬件上直接串二极管,省心。

3.4 编码器

编码器(旋转编码器)在嵌入式GUI里越来越常见。音量旋钮、菜单滚轮、参数调节……一个编码器能替代好几个按键。

工作原理:编码器输出两路正交信号(A相和B相)。旋转方向由两路信号的相位差决定:A领先B是顺时针,B领先A是逆时针。

我常用的检测代码:

// 编码器状态机
typedef enum {
    IDLE,
    A_FIRST,
    B_FIRST
} EncoderState;

int8_t encoder_process(uint8_t a, uint8_t b) {
    static EncoderState state = IDLE;
    static int8_t count = 0;
    
    switch (state) {
        case IDLE:
            if (a && !b) state = A_FIRST;
            else if (b && !a) state = B_FIRST;
            break;
        case A_FIRST:
            if (b) { count++; state = IDLE; }  // 顺时针
            else if (!a) state = IDLE;          // 抖动,忽略
            break;
        case B_FIRST:
            if (a) { count--; state = IDLE; }   // 逆时针
            else if (!b) state = IDLE;
            break;
    }
    return count;
}

经验之谈:编码器也有抖动。我习惯在状态机里加一个5ms的去抖定时器,而不是用delay。这样不会阻塞主循环。

3.5 输入事件队列模型

好了,现在触摸屏、按键、编码器都能产生事件了。但问题来了——这些事件可能同时发生,而且处理速度不一样。怎么办?

答案就是:事件队列。所有输入设备只负责「产生事件」并推入队列,GUI主循环从队列里「消费事件」。这样就把输入采集和事件处理解耦了。

事件队列的核心设计:

  • 生产者:中断服务程序或定时轮询任务,将事件写入队列。
  • 消费者:GUI主循环,从队列头部取出事件并分发。
  • 队列结构:环形缓冲区(FIFO),避免动态内存分配。
  • 事件类型:触摸按下/移动/抬起、按键按下/释放、编码器旋转等。

下面是我画的一张事件队列架构图,你看一眼就明白了:

输入事件队列模型架构 触摸屏 按键矩阵 编码器 事件队列 环形缓冲区 (FIFO) 生产者写入尾部 事件分发器 消费者读取头部 调用回调函数 GUI 组件(按钮/滑块/列表) 关键设计原则: • 中断中只入队,不处理 • 队列长度要能抗住峰值事件率 典型事件结构体: type (触摸/按键/编码器) + code + value + timestamp 例如:{ TOUCH_DOWN, 120, 340, 0x1A2B3C }

3.6 事件队列的实现要点

实际写代码时,有几个地方容易翻车:

  • 队列长度:太小会丢事件,太大浪费RAM。我一般按「最大事件产生率 × 200ms」来估算。比如触摸屏每秒产生100个事件,队列长度给20就够。
  • 临界区保护:如果生产者在中断中,消费者在主循环,必须关中断或使用原子操作。我习惯用关中断的方式,简单可靠。
  • 事件优先级:有些场景需要高优先级事件插队(比如紧急停止按键)。可以在队列里预留一个「高优先级槽位」。

血的教训:我曾经在一个产品里,中断服务程序直接调用了GUI的绘图函数。结果死锁了——中断在等队列空,主循环在等中断返回。记住:中断里只做「入队」这件事,别碰任何GUI逻辑。

3.7 多输入设备的协同

一个产品往往同时有触摸屏、按键和编码器。它们之间怎么协同?

我的做法是:统一事件格式,统一队列入口。不管哪个设备产生的事件,都打包成同一个结构体,推入同一个队列。GUI主循环只认事件类型,不关心事件来源。

举个例子:

// 统一事件结构体
typedef struct {
    uint8_t  type;      // 事件类型:TOUCH / KEY / ENCODER
    uint8_t  code;      // 子类型:按下/释放/旋转
    uint16_t value;     // 坐标值、键值、旋转步数
    uint32_t timestamp; // 时间戳(用于长按检测)
} InputEvent;

// 所有输入设备共用同一个队列
QueueHandle_t g_input_queue;

这样做的好处很明显:新增一种输入设备时,只需要写它的驱动和事件打包逻辑,队列和分发部分完全不用改。这就是「面向接口编程」在嵌入式里的落地。

我的习惯:在调试阶段,我会在事件队列的入队和出队处各加一个GPIO翻转。用示波器一看,就知道事件有没有堆积、处理有没有延迟。这比printf高效多了。

好了,输入设备管理这块,核心就是三件事:读懂硬件、滤掉噪声、统一事件。下一层就是事件怎么驱动GUI组件更新——那是第四章的内容了。