3、输入设备管理:触摸屏、按键矩阵、编码器与事件队列
输入设备,是嵌入式GUI和人交互的「手」。你想想看,用户点一下屏幕、按一个按键、旋一下编码器,这些物理动作怎么变成软件能理解的消息?这就是本章要聊的事。
我做了这么多年嵌入式,见过太多「硬件焊好了,软件跑不起来」的坑。说白了,输入设备管理不只是读个GPIO电平那么简单。你得考虑抖动、考虑并发、考虑不同设备的时序差异。嗯,咱们一个一个来拆。
3.1 触摸屏:电阻 vs 电容
触摸屏分两大类:电阻式和电容式。它们原理完全不同,适用场景也天差地别。
| 特性 | 电阻式触摸屏 | 电容式触摸屏 |
|---|---|---|
| 工作原理 | 压力使上下两层导电膜接触,分压检测坐标 | 人体电容改变电极间电场,检测电荷变化 |
| 触摸方式 | 任何物体(手指、笔、戴手套) | 仅导电物体(手指、电容笔) |
| 多点触控 | 不支持(天生单点) | 支持(2~10点) |
| 耐用性 | 膜层易磨损,寿命短 | 玻璃面板,耐磨耐刮 |
| 成本 | 低 | 中高 |
| 典型应用 | 工业HMI、收银机、老式PDA | 手机、平板、智能家居面板 |
核心区别一句话:电阻屏靠「压」,电容屏靠「摸」。如果你的产品需要戴手套操作(比如户外设备),电阻屏反而是更可靠的选择。
3.2 触摸屏驱动要点
不管哪种触摸屏,MCU读到的都是原始坐标数据。但原始数据不能直接用——你想想,手指按下去时会有抖动,ADC采样也有噪声。
我常用的处理流程:
- 采样:以50~100Hz频率读取触摸控制器(如XPT2046、FT5x06)的坐标寄存器。
- 滤波:用中值滤波或滑动平均去除毛刺。我习惯取5个样本排序后取中间3个平均,效果不错。
- 校准:将ADC值映射到屏幕像素坐标。电阻屏尤其需要三点或五点校准。
- 上报:生成触摸事件(按下、移动、抬起),推入事件队列。
避坑指南:我曾经在一个项目中,电容屏的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),避免动态内存分配。
- 事件类型:触摸按下/移动/抬起、按键按下/释放、编码器旋转等。
下面是我画的一张事件队列架构图,你看一眼就明白了:
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组件更新——那是第四章的内容了。