在嵌入式 Linux 开发中,输入设备(按键、触摸屏、键盘、鼠标等)是人机交互的核心载体。Linux 内核通过输入子系统将各类输入设备抽象为统一接口,开发者无需关注硬件细节,仅通过标准文件操作即可完成输入事件的读取与处理。本文基于完整的实战代码,从设备认知到高级事件处理,全方位讲解 Linux 输入系统的应用编程方法。
一、Linux 输入系统核心概念
1.1 输入子系统本质
Linux 输入子系统是内核层的统一框架,它屏蔽了不同输入设备(按键、触摸屏、鼠标等)的硬件差异,向上为用户空间提供标准化的事件接口。无论底层是何种硬件,应用层都通过相同的方式读取 "输入事件"。
1.2 设备节点
输入设备在用户空间以设备文件形式存在,核心节点路径:
- 通用事件节点:
/dev/input/eventX(X 为数字,如 event0、event1,对应不同输入设备) - 专用节点:
/dev/input/mouseX(鼠标)、/dev/input/keyboard(键盘)等
1.3 核心数据结构:input_event
所有输入事件均通过struct input_event结构体传递,定义在<linux/input.h>中:
cpp
struct input_event {
struct timeval time; // 事件时间戳(秒+微秒)
__u16 type; // 事件类型(如按键、坐标、同步)
__u16 code; // 事件编码(如具体按键、坐标轴)
__s32 value; // 事件值(如按键按下/松开、坐标数值)
};
1.4 常用事件类型速查表
| 事件类型 | 说明 | 典型应用场景 |
|---|---|---|
| EV_SYN | 同步事件(分隔事件组) | 事件组结束标记 |
| EV_KEY | 按键 / 开关事件 | 键盘按键、电源键 |
| EV_REL | 相对坐标事件 | 鼠标移动(相对位移) |
| EV_ABS | 绝对坐标事件 | 触摸屏(绝对坐标) |
| EV_MSC | 杂项事件 | 硬件扫描码 |
| EV_LED | LED 控制事件 | 键盘指示灯 |
| EV_REP | 重复事件 | 按键长按重复 |
二、第一步:识别输入设备(01_get_input_info.c)
在读取事件前,首先需要获取设备的基本信息(总线类型、厂商 ID、支持的事件类型),相当于给设备 "建档"。
2.1 核心功能与代码解析
cpp
#include <linux/input.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdio.h>
/* 用法:./01_get_input_info /dev/input/event0 */
int main(int argc, char **argv)
{
int fd;
int err;
int len;
int i, bit;
unsigned char byte;
struct input_id id; // 设备ID结构体
unsigned int evbit[2]; // 存储支持的事件类型位掩码
// 1. 参数校验
if (argc != 2) {
printf("Usage: %s <dev>\n", argv[0]);
return -1;
}
// 2. 打开输入设备(阻塞模式)
fd = open(argv[1], O_RDWR);
if (fd < 0) {
printf("open %s err\n", argv[1]);
return -1;
}
// 3. 获取设备ID信息(总线/厂商/产品/版本)
err = ioctl(fd, EVIOCGID, &id);
if (err == 0) {
printf("=== Device ID Info ===\n");
printf("bustype = 0x%x\n", id.bustype); // 总线类型(如USB、GPIO)
printf("vendor = 0x%x\n", id.vendor); // 厂商ID
printf("product = 0x%x\n", id.product); // 产品ID
printf("version = 0x%x\n", id.version); // 设备版本
}
// 4. 获取设备支持的事件类型
len = ioctl(fd, EVIOCGBIT(0, sizeof(evbit)), &evbit);
if (len > 0 && len <= sizeof(evbit)) {
printf("\n=== Supported Event Types ===\n");
// 事件类型名称映射(与内核定义对应)
char *ev_names[] = {
"EV_SYN ", "EV_KEY ", "EV_REL ", "EV_ABS ", "EV_MSC ", "EV_SW ",
"NULL ", "NULL ", "NULL ", "NULL ", "NULL ", "NULL ",
"NULL ", "NULL ", "NULL ", "NULL ", "NULL ", "EV_LED ",
"EV_SND ", "NULL ", "EV_REP ", "EV_FF ", "EV_PWR "
};
for (i = 0; i < len; i++) {
byte = ((unsigned char *)evbit)[i];
for (bit = 0; bit < 8; bit++) {
if (byte & (1 << bit)) {
int idx = i * 8 + bit;
if (idx < sizeof(ev_names)/sizeof(ev_names[0])) {
printf("%s ", ev_names[idx]);
}
}
}
}
printf("\n");
}
close(fd);
return 0;
}
2.2 关键 IOCTL 命令说明
| 命令 | 功能 | 入参 / 出参 |
|---|---|---|
EVIOCGID |
获取输入设备 ID 信息 | 出参:struct input_id |
EVIOCGBIT |
获取指定类型的事件支持位掩码 | 入参:事件类型、缓冲区大小;出参:位掩码缓冲区 |
EVIOCGKEY |
获取按键当前状态 | 出参:按键状态位掩码 |
EVIOCGABS |
获取绝对坐标(如触摸屏)参数 | 出参:struct input_absinfo |
2.3 编译与运行
# 编译
gcc 01_get_input_info.c -o 01_get_input_info
# 运行(需root权限,或给可执行文件加sudo)
sudo ./01_get_input_info /dev/input/event0
运行示例输出:
=== Device ID Info ===
bustype = 0x3
vendor = 0x1234
product = 0x5678
version = 0x100
=== Supported Event Types ===
EV_SYN EV_KEY EV_REL EV_MSC
三、第二步:基础事件读取(02_input_read.c)
掌握设备信息后,核心需求是读取输入事件。本示例实现阻塞式 和非阻塞式两种读取方式,对比其差异。
3.1 完整代码实现
cpp
#include <linux/input.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
/* 用法:
阻塞式:./02_input_read /dev/input/event0
非阻塞式:./02_input_read /dev/input/event0 noblock
*/
int main(int argc, char **argv)
{
int fd;
int len;
struct input_event event;
// 1. 参数校验
if (argc < 2) {
printf("Usage: %s <dev> [noblock]\n", argv[0]);
return -1;
}
// 2. 打开设备(区分阻塞/非阻塞)
if (argc == 3 && !strcmp(argv[2], "noblock")) {
fd = open(argv[1], O_RDWR | O_NONBLOCK); // 非阻塞模式
printf("Open device in NON-BLOCK mode\n");
} else {
fd = open(argv[1], O_RDWR); // 阻塞模式
printf("Open device in BLOCK mode\n");
}
if (fd < 0) {
printf("open %s err\n", argv[1]);
return -1;
}
// 3. 循环读取事件
printf("Start reading input events...\n");
while (1) {
len = read(fd, &event, sizeof(event));
// 读取成功(完整的input_event结构体)
if (len == sizeof(event)) {
printf("Event: type=0x%04x, code=0x%04x, value=0x%08x\n",
event.type, event.code, event.value);
}
// 读取失败/无数据
else {
// 非阻塞模式下无数据时len=-1,errno=EAGAIN
printf("Read error/No data, len=%d\n", len);
sleep(1); // 非阻塞模式下加延时,降低CPU占用
}
}
close(fd);
return 0;
}
3.2 阻塞 / 非阻塞模式对比
| 模式 | 打开方式 | 无数据时行为 | CPU 占用 | 适用场景 | |
|---|---|---|---|---|---|
| 阻塞式 | O_RDWR |
阻塞等待,直到有数据 | 极低 | 单任务场景(仅处理输入事件) | |
| 非阻塞式 | `O_RDWR | O_NONBLOCK` | 立即返回 - 1 | 极高(无延时) | 配合 poll/select 使用 |
3.3 关键注意点
- 非阻塞模式下,若不加
sleep,会导致循环疯狂执行,CPU 占用率接近 100%; read返回值等于sizeof(struct input_event)时,才表示读取到完整事件;- 事件读取后需解析
type/code/value:例如EV_KEY类型中,value=1表示按键按下,value=0表示松开。
四、第三步:优雅等待事件 ------poll 机制(03_input_read_poll.c)
非阻塞模式单独使用体验差,而poll机制可实现 "带超时的优雅等待":有事件时立即处理,无事件时休眠,既不阻塞也不浪费 CPU。
4.1 核心代码实现
cpp
#include <linux/input.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <poll.h>
/* 用法:./03_input_read_poll /dev/input/event0 */
int main(int argc, char **argv)
{
int fd;
int ret;
struct input_event event;
struct pollfd fds[1]; // poll监听的文件描述符集合
nfds_t nfds = 1; // 监听的fd数量
// 1. 参数校验
if (argc != 2) {
printf("Usage: %s <dev>\n", argv[0]);
return -1;
}
// 2. 非阻塞打开设备(必须!)
fd = open(argv[1], O_RDWR | O_NONBLOCK);
if (fd < 0) {
printf("open %s err\n", argv[1]);
return -1;
}
// 3. 循环监听+读取事件
while (1) {
// 配置poll监听参数
fds[0].fd = fd; // 监听的fd
fds[0].events = POLLIN; // 监听"可读事件"
fds[0].revents = 0; // 重置返回事件
// 调用poll,超时时间5000ms(5秒)
ret = poll(fds, nfds, 5000);
// 3.1 有事件可读
if (ret > 0) {
if (fds[0].revents & POLLIN) { // 确认是可读事件
// 循环读取所有缓存事件(避免数据积压)
while (read(fd, &event, sizeof(event)) == sizeof(event)) {
printf("Event: type=0x%04x, code=0x%04x, value=0x%08x\n",
event.type, event.code, event.value);
}
}
}
// 3.2 超时
else if (ret == 0) {
printf("Poll timeout (5s), no event\n");
}
// 3.3 出错
else {
printf("Poll error, ret=%d\n", ret);
break;
}
}
close(fd);
return 0;
}
4.2 poll 机制核心优势
- 低 CPU 占用:无事件时进程休眠,超时时间到或有事件时唤醒;
- 支持多设备监听 :只需扩展
struct pollfd数组,即可同时监听多个/dev/input/eventX; - 无 FD 数量限制:相比 select,poll 不限制监听的文件描述符数量;
- 无需重复初始化 :每次 poll 只需重置
revents,无需重新初始化整个 fd 集合。
五、第四步:经典多路复用 ------select 机制(04_input_read_select.c)
select是另一种经典的 I/O 多路复用机制,通过位图管理文件描述符,兼容性更广(适合跨平台场景)。
5.1 核心代码实现
cpp
#include <linux/input.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/time.h>
/* 用法:./04_input_read_select /dev/input/event0 */
int main(int argc, char **argv)
{
int fd;
int ret;
int nfds;
struct input_event event;
fd_set readfds; // select监听的可读fd集合
struct timeval tv; // 超时时间
// 1. 参数校验
if (argc != 2) {
printf("Usage: %s <dev>\n", argv[0]);
return -1;
}
// 2. 非阻塞打开设备
fd = open(argv[1], O_RDWR | O_NONBLOCK);
if (fd < 0) {
printf("open %s err\n", argv[1]);
return -1;
}
// 3. 循环监听+读取事件
while (1) {
// 3.1 设置超时时间(5秒)
tv.tv_sec = 5;
tv.tv_usec = 0;
// 3.2 初始化fd集合(每次都要重置!)
FD_ZERO(&readfds); // 清空集合
FD_SET(fd, &readfds); // 将目标fd加入集合
// 3.3 计算nfds(关键:最大fd + 1)
nfds = fd + 1;
// 3.4 调用select
ret = select(nfds, &readfds, NULL, NULL, &tv);
// 3.5 处理select返回结果
if (ret > 0) {
// 确认fd有可读事件
if (FD_ISSET(fd, &readfds)) {
// 循环读取所有事件
while (read(fd, &event, sizeof(event)) == sizeof(event)) {
printf("Event: type=0x%04x, code=0x%04x, value=0x%08x\n",
event.type, event.code, event.value);
}
}
} else if (ret == 0) {
printf("Select timeout (5s), no event\n");
} else {
printf("Select error, ret=%d\n", ret);
break;
}
}
close(fd);
return 0;
}
5.2 select vs poll 关键对比
| 特性 | select | poll |
|---|---|---|
| FD 管理方式 | 位图(固定大小) | 结构体数组(动态) |
| FD 数量限制 | 有(默认 1024) | 无 |
| 初始化开销 | 每次需重置整个集合 | 仅需重置 revents |
| 超时精度 | 微秒(tv_usec) | 毫秒 |
| 可移植性 | 更高(兼容 POSIX) | 稍低(Linux 为主) |
| 性能(多 FD) | 随 FD 数量增加下降快 | 性能稳定 |
六、第五步:异步通知 ------ 让设备主动 "喊" 你(05_input_read_fasync.c)
以上方式均为 "应用主动查询",而异步通知是 "设备主动通知":当有输入事件时,内核向应用发送SIGIO信号,应用在信号处理函数中读取事件,主循环可专注处理其他逻辑。
6.1 核心代码实现
cpp
#include <linux/input.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/fcntl.h>
int fd; // 全局fd,供信号处理函数访问
// 信号处理函数(SIGIO信号触发)
void input_sig_handler(int sig)
{
struct input_event event;
// 循环读取所有待处理事件(避免积压)
while (read(fd, &event, sizeof(event)) == sizeof(event)) {
printf("[SIGIO] Event: type=0x%04x, code=0x%04x, value=0x%08x\n",
event.type, event.code, event.value);
}
}
/* 用法:./05_input_read_fasync /dev/input/event0 */
int main(int argc, char **argv)
{
int flags;
// 1. 参数校验
if (argc != 2) {
printf("Usage: %s <dev>\n", argv[0]);
return -1;
}
// 2. 注册SIGIO信号处理函数
signal(SIGIO, input_sig_handler);
// 3. 非阻塞打开设备
fd = open(argv[1], O_RDWR | O_NONBLOCK);
if (fd < 0) {
printf("open %s err\n", argv[1]);
return -1;
}
// 4. 告诉内核:本进程接收该fd的SIGIO信号
fcntl(fd, F_SETOWN, getpid());
// 5. 启用异步通知(设置FASYNC标志)
flags = fcntl(fd, F_GETFL); // 获取当前文件状态标志
fcntl(fd, F_SETFL, flags | FASYNC); // 追加FASYNC标志
// 6. 主循环(处理其他业务逻辑)
printf("Main loop running... (pid=%d)\n", getpid());
int count = 0;
while (1) {
printf("Main loop: count=%d (doing other work)\n", count++);
sleep(2); // 模拟主循环业务逻辑
}
close(fd);
return 0;
}
6.2 异步通知核心步骤
- 注册信号处理函数 :通过
signal(SIGIO, handler)关联信号与处理逻辑; - 绑定进程 ID :通过
fcntl(fd, F_SETOWN, getpid())告诉内核,将该 fd 的异步信号发送给当前进程; - 启用 FASYNC :通过
fcntl设置FASYNC标志,内核会为该 fd 创建异步通知队列; - 信号处理 :有事件时内核发送
SIGIO,处理函数中读取并处理事件。
6.3 信号处理函数注意事项
- 示例中使用
printf仅为演示,生产环境中应避免在信号处理函数中调用非异步安全函数 (如printf、malloc、fopen等); - 推荐做法:信号处理函数仅设置 "事件就绪" 标志,主循环检测到标志后再读取事件;
- 必须非阻塞打开设备:信号处理函数中
read若为阻塞模式,可能导致进程卡死。
七、核心重难点解析
7.1 为什么读取事件要用while而非if?
cpp
// 正确✅
while (read(fd, &event, sizeof(event)) == sizeof(event)) { ... }
// 错误❌
if (read(fd, &event, sizeof(event)) == sizeof(event)) { ... }
原因:内核会缓存多个输入事件(如快速按多次按键),if只能读取一个事件,剩余事件会积压在缓冲区,导致后续事件延迟或丢失;while会一次性读取所有缓存事件,保证数据完整性。
7.2 为什么必须非阻塞打开设备(配合 poll/select/fasync)?
即使poll/select返回 "可读",也可能在read调用瞬间,事件被其他进程 / 线程读取,此时阻塞模式下read会一直等待,导致进程卡死;非阻塞模式下read会立即返回 - 1,避免卡死。
7.3 select 的nfds为什么是fd + 1?
这是 select 的历史设计缺陷:nfds不是监听的 FD 数量,而是 "最大 FD 值 + 1",内核通过该值确定需要遍历的位图长度。例如监听 fd=5,则nfds=6,内核会检查位图的前 6 位(0~5)。
7.4 如何解析具体事件(如按键、触摸屏坐标)?
- EV_KEY 事件 :
code为按键编码(如KEY_ENTER、KEY_POWER),value=1按下,value=0松开,value=2长按重复; - EV_ABS 事件 :
code为坐标轴(如ABS_X、ABS_Y),value为坐标值(需结合EVIOCGABS获取坐标范围); - EV_REL 事件 :
code为相对轴(如REL_X),value为相对位移(正数 / 负数表示方向); - EV_SYN 事件 :
code=SYN_REPORT表示一组事件结束,通常无需处理。
八、实战场景选型指南
| 应用场景 | 推荐方案 | 核心优势 |
|---|---|---|
| 单设备、单任务(仅处理输入) | 阻塞式 read | 代码最简单,CPU 占用最低 |
| 多设备监听(如同时监听按键 + 触摸屏) | poll | 无 FD 数量限制,性能稳定 |
| 跨平台兼容(如嵌入式 Linux + 桌面 Linux) | select | 兼容性最广 |
| 主循环需处理其他业务(如 UI、通信) | fasync(异步通知) | 低延迟响应,主循环无感知 |
| 高并发、大量 FD 监听 | poll | 性能优于 select |
九、总结与最佳实践
9.1 学习路径总结
- 设备认知(01):通过 IOCTL 获取设备信息,了解设备能力;
- 基础读取(02):理解阻塞 / 非阻塞差异,掌握事件读取基本方法;
- 优雅等待(03):poll 是嵌入式 Linux 输入处理的首选方案;
- 经典方案 (04):select 适合跨平台场景,需注意
nfds规则; - 异步通知(05):最高效的事件处理方式,适合复杂业务场景。
9.2 最佳实践
- 必选组合:非阻塞打开设备 + poll/select 读取事件,兼顾稳定性与性能;
- 事件读取 :始终用
while循环读取所有缓存事件,避免数据积压; - 信号处理:异步通知中,信号处理函数仅做 "事件标记",主循环处理具体逻辑;
- 权限处理 :输入设备节点通常属于
input组,可将应用加入input组,避免使用 root 权限; - 调试技巧 :通过
cat /proc/bus/input/devices查看系统所有输入设备信息,快速定位目标eventX。
通过本文的示例代码和解析,你已掌握 Linux 输入系统应用编程的全流程。在实际项目中,可根据场景选择合适的方案,结合内核提供的事件接口,快速实现按键、触摸屏、鼠标等输入设备的交互逻辑。