ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Ubuntu下检测鼠标键盘活动状态:从事件读取到长期稳定运行

Ubuntu下检测鼠标键盘活动状态:从事件读取到长期稳定运行

1. 为什么要在 Ubuntu 上盯住鼠标键盘的"沉默"

很多人第一次听到"检测鼠标键盘是否活动"这个需求,第一反应是:这有什么难的,插上不就能用吗?但真正在 Ubuntu 上做过长期运行设备、无人值守终端、公共展示机或者自动化测试平台的人都知道,输入设备的"沉默"才是最难缠的问题。鼠标不动、键盘不响应,屏幕还亮着,系统看起来一切正常,可实际上人已经没法操作了。这种"假活"状态如果没人发现,轻则影响使用体验,重则让整台设备变成摆设。

我在几个不同场景里都遇到过这个需求。一个是实验室里的公共查询终端,放在走廊里供人自助使用,结果某天发现鼠标指针卡在屏幕角落,键盘敲了也没反应,但系统日志里没有任何报错。另一个是自动化测试机,跑完一轮脚本后需要确认输入设备是否还处于可用状态,否则下一轮测试的模拟输入会全部失效。还有一个是长时间运行的展示设备,需要在不重启的前提下判断输入设备是否"还活着",以便触发告警或者自动恢复。

这些场景的共同点是:不能只靠"设备节点存在"来判断,因为/dev/input/event*文件在设备无响应时依然存在;也不能只靠"进程还在跑"来判断,因为负责读取输入的进程可能已经卡死。真正需要的是从事件层面去感知:最近一段时间内,到底有没有真实的鼠标移动、按键按下、滚轮滚动等输入事件产生。

这篇文章会从最基础的设备识别讲起,一步步拆到事件读取、活动判定、阈值设计、误报处理,最后给出一个可以直接抄作业的完整方案。不管你是刚接触 Linux 输入子系统的新手,还是已经用过evtest、libinput的老手,都能从中找到能直接落地的细节。全文基于 Ubuntu 20.04 和 22.04 实测,其他发行版思路一致,只是包管理和设备路径略有差异。

2. 先搞清楚 Ubuntu 把鼠标键盘藏在了哪里

2.1/dev/input/目录下的设备节点分布

在 Ubuntu 上,所有输入设备都会以字符设备的形式出现在/dev/input/目录下。你直接ls一下,通常会看到类似这样的内容:

ls -l /dev/input/ crw-rw---- 1 root input 13, 64 Jan 1 00:00 event0 crw-rw---- 1 root input 13, 65 Jan 1 00:00 event1 crw-rw---- 1 root input 13, 66 Jan 1 00:00 event2 crw-rw---- 1 root input 13, 67 Jan 1 00:00 event3 crw-rw---- 1 root input 13, 68 Jan 1 00:00 event4 crw-rw---- 1 root input 13, 69 Jan 1 00:00 event5 crw-rw---- 1 root input 13, 63 Jan 1 00:00 mice crw-rw---- 1 root input 13, 32 Jan 1 00:00 mouse0 crw-rw---- 1 root input 13, 33 Jan 1 00:00 mouse1

这里有几个关键点需要说清楚。event*是通用输入事件接口,鼠标、键盘、触摸板、电源按钮、甚至某些传感器都会挂在这里。mouse*是较老的鼠标专用接口,mice是所有鼠标的聚合接口。现代 Ubuntu 上,推荐统一走event*,因为它的信息最全,能区分事件类型和具体设备。

但问题来了:event0到底是键盘还是鼠标?光看编号是看不出来的。设备编号在每次启动、每次插拔后都可能变化,所以绝对不能把event0硬编码成键盘。我见过有人写脚本直接读event0,结果换了一台机器后读到的却是电源按钮,整个逻辑全乱套。

2.2 用/proc/bus/input/devices建立设备与功能的映射

要准确知道每个event*对应什么设备,最可靠的方法是读/proc/bus/input/devices。这个文件把每个输入设备的名称、物理路径、唯一标识和对应的事件节点都列了出来。内容大概长这样:

I: Bus=0011 Vendor=0001 Product=0001 Version=ab41 N: Name="AT Translated Set 2 keyboard" P: Phys=isa0060/serio0/input0 S: Sysfs=/devices/platform/i8042/serio0/input/input0 U: Uniq= H: Handlers=sysrq kbd event0 leds B: PROP=0 B: EV=120013 ... I: Bus=0003 Vendor=046d Product=c077 Version=0111 N: Name="USB Optical Mouse" P: Phys=usb-0000:00:14.0-1/input0 S: Sysfs=/devices/pci0000:00/0000:00:14.0/usb1/1-1/1-1:1.0/input/input5 U: Uniq= H: Handlers=mouse0 event5 B: PROP=0 B: EV=17 ...

每个设备块里,N: Name=是设备名称,H: Handlers=里包含了这个设备对应的event节点。键盘的 Handlers 里通常有kbd和event0,鼠标的 Handlers 里通常有mouse0和event5。通过解析这个文件,就能动态建立"设备类型 → event 节点"的映射,而不是靠猜。

我一般会写一个解析函数,把每个设备的 Name、Handlers、EV 能力位都提取出来。EV 能力位里的EV_KEY、EV_REL、EV_ABS能进一步帮助判断设备类型:键盘通常有EV_KEY和EV_LED,鼠标通常有EV_KEY、EV_REL,触摸板则可能有EV_ABS。这样即使设备名称被改得面目全非,也能靠能力位兜底识别。

2.3 权限问题:为什么普通用户读不了 event 节点

默认情况下,/dev/input/event*的权限是crw-rw----,属主 root,属组 input。普通用户不在 input 组里,直接打开会报Permission denied。解决办法有两个:一是把运行检测程序的用户加入 input 组,二是用 udev 规则调整权限。前者简单,后者更规范。

把用户加入 input 组:

sudo usermod -aG input $USER

执行完需要重新登录才生效。验证方法是groups命令里能看到 input。如果不想改用户组,也可以临时用sudo跑,但长期运行的服务不建议一直用 root,权限太大反而增加风险。

注意:把用户加入 input 组意味着这个用户可以读取所有输入设备的事件,包括键盘输入。在多人共用的机器上要谨慎评估,避免键盘输入被非预期地捕获。

3. 读取输入事件的三种技术路线对比

3.1 直接读/dev/input/event*:最底层也最灵活

最直接的方式就是用open()打开 event 节点,然后read()固定长度的input_event结构体。这个结构体在 Linux 内核头文件里定义,包含时间戳、事件类型、事件码和值:

struct input_event { struct timeval time; __u16 type; __u16 code; __s32 value; };

在 64 位系统上,这个结构体通常是 24 字节。读到的type表示事件大类,比如EV_KEY(1)是按键,EV_REL(2)是相对位移(鼠标移动),EV_ABS(3)是绝对位移(触摸板)。code是具体编码,比如REL_X、REL_Y、KEY_A。value是具体数值。

这种方式的优点是完全可控:你能精确知道每一个事件是什么、来自哪个设备、什么时间发生。缺点是需要自己处理阻塞、非阻塞、超时、设备热插拔等细节。如果直接用阻塞读,程序会一直卡在read()上,没法做其他事情;如果用非阻塞读,又要处理EAGAIN。所以实际工程里通常会配合select()、poll()或epoll()来做多路复用和超时控制。

3.2 用evtest做快速验证:排查阶段的好帮手

在写代码之前,我强烈建议先用evtest确认设备到底有没有在产生事件。安装很简单:

sudo apt install evtest

然后列出设备:

sudo evtest

它会打印出所有可选的 event 设备,你输入编号后,它就会实时显示该设备产生的事件。移动鼠标时能看到EV_REL REL_X、EV_REL REL_Y,按键时能看到EV_KEY KEY_*。如果移动鼠标但evtest里毫无反应,那说明问题在更底层——可能是驱动、USB 连接或者硬件本身,这时候再怎么写检测程序都是白费。

evtest的局限是它是交互式的,不适合放进自动化脚本。但作为排查工具,它能帮你快速区分"设备没事件"和"程序读不到事件"这两类完全不同的问题。

3.3 借助libinput或 Python 的evdev:开发效率优先

如果不想用 C 直接啃结构体,Python 的evdev库是个很好的选择。它把底层细节封装好了,几行代码就能遍历设备、读取事件:

import evdev devices = [evdev.InputDevice(path) for path in evdev.list_devices()] for dev in devices: print(dev.path, dev.name, dev.phys)

读取事件也很直观:

for event in dev.read_loop(): if event.type == evdev.ecodes.EV_KEY: print("按键事件", event.code, event.value) elif event.type == evdev.ecodes.EV_REL: print("相对位移", event.code, event.value)

libinput则是更上层的库,它会把原始事件加工成"手势""点击"等语义化事件,适合做触摸板手势识别。但如果目标只是判断"有没有活动",用libinput有点杀鸡用牛刀,而且它的调试工具libinput debug-events需要 root 权限,部署时反而麻烦。

三条路线的对比如下:

路线优点缺点适用场景
直接读 event最灵活、无额外依赖需处理阻塞/超时/热插拔嵌入式、长期运行服务
evtest零代码、直观交互式、不适合自动化排查验证阶段
Python evdev开发快、可读性好需装 Python 库脚本、原型、中小型项目

我的建议是:排查用 evtest,落地用 Python evdev 或 C 直接读。如果设备资源紧张、要求极致轻量,就上 C;如果追求开发效率和可维护性,Python evdev 完全够用。

4. 活动判定的核心逻辑:从"有事件"到"算活跃"

4.1 时间窗口与阈值:多长算"沉默"

检测的核心其实就一句话:在最近 N 秒内,有没有收到过有效的输入事件。这里的 N 就是时间窗口,也叫静默阈值。N 设得太小,正常思考停顿都会被误判为"不活动";N 设得太大,设备真出问题了也要等很久才发现。

我在不同场景下的取值经验是这样的:

  • 公共查询终端:60 到 120 秒。用户可能在看内容、思考,不会一直动鼠标。
  • 自动化测试机:5 到 15 秒。测试过程中输入应该是连续的,长时间没事件基本就是异常。
  • 展示设备:300 秒以上。展示内容可能几分钟才需要一次交互。

阈值没有万能值,必须结合业务场景。一个实用的做法是把阈值做成可配置参数,而不是写死在代码里。这样同一套程序换个场景只需改配置,不用重新编译或改代码。

4.2 区分"有效活动"和"噪声事件"

并不是所有事件都代表"人在操作"。有些设备会周期性上报一些无关事件,比如某些鼠标会定时发送心跳、某些键盘会报告 LED 状态变化。如果把这些也算作活动,检测就会永远显示"活跃",失去意义。

我的做法是只把特定类型的事件计入活动:

  • EV_KEY且value == 1(按下)或value == 2(长按重复):键盘按键、鼠标按键。
  • EV_REL且code是REL_X或REL_Y且value != 0:鼠标移动。
  • EV_REL且code是REL_WHEEL:滚轮滚动。

明确排除EV_SYN(同步事件,每次读取都会伴随)、EV_MSC(杂项)、EV_LED(LED 状态)等。这样判定出来的"活动"才是真正的人为操作。

4.3 多设备聚合:键盘和鼠标要一起看

一台机器上可能同时有多个键盘和鼠标,比如笔记本自带键盘加外接 USB 键盘,触摸板加外接鼠标。检测逻辑应该聚合所有输入设备:只要任意一个设备在时间窗口内有有效事件,就认为"有活动"。

实现上可以用一个全局的"最后活动时间戳",每个设备读到有效事件时就更新这个时间戳。判定时只需要比较当前时间 - 最后活动时间和阈值的关系。这样无论用户用的是哪个设备,都能正确反映整体活动状态。

注意:如果某个设备被拔掉,它的 event 节点会消失,读取会报错。程序必须能处理这种情况,把该设备从监听列表里移除,而不是直接崩溃。热插拔处理是长期运行程序必须考虑的问题。

5. 一个可直接落地的 Python 检测方案

5.1 环境准备与依赖安装

先装 Python 的 evdev 库。Ubuntu 上可以用 apt,也可以用 pip:

sudo apt install python3-evdev

或者:

pip3 install evdev

装完后验证一下:

python3 -c "import evdev; print(evdev.__version__)"

能打印版本号就说明装好了。如果报权限错误,检查当前用户是否在 input 组里。

5.2 完整代码结构与关键注释

下面这个脚本实现了完整的多设备活动检测,包含热插拔处理、超时判定和状态输出。我把它拆成几个部分讲,完整代码可以直接复制运行。

import evdev import select import time import sys # 配置:静默阈值(秒) IDLE_THRESHOLD = 60 # 计入活动的按键事件码集合(可按需扩展) ACTIVE_KEY_CODES = None # None 表示所有按键都算 def is_active_event(event): """判断一个事件是否代表有效的人为活动""" if event.type == evdev.ecodes.EV_KEY: # 按键按下或长按重复 return event.value in (1, 2) if event.type == evdev.ecodes.EV_REL: if event.code in (evdev.ecodes.REL_X, evdev.ecodes.REL_Y): return event.value != 0 if event.code == evdev.ecodes.REL_WHEEL: return event.value != 0 return False def find_input_devices(): """找出所有键盘和鼠标类设备""" result = [] for path in evdev.list_devices(): try: dev = evdev.InputDevice(path) except (PermissionError, OSError): continue caps = dev.capabilities() # 有 EV_KEY 或 EV_REL 能力的都纳入监听 if evdev.ecodes.EV_KEY in caps or evdev.ecodes.EV_REL in caps: result.append(dev) return result def main(): devices = find_input_devices() if not devices: print("未找到任何输入设备,请检查权限") sys.exit(1) print("监听设备:") for d in devices: print(f" {d.path} {d.name}") last_active = time.time() fd_map = {d.fd: d for d in devices} while True: # 用 select 做超时等待,1 秒轮询一次 r, _, _ = select.select(list(fd_map.keys()), [], [], 1.0) for fd in r: dev = fd_map[fd] try: for event in dev.read(): if is_active_event(event): last_active = time.time() except OSError: # 设备被拔出 print(f"设备移除:{dev.path}") del fd_map[fd] idle = time.time() - last_active if idle >= IDLE_THRESHOLD: print(f"[告警] 已静默 {idle:.0f} 秒,超过阈值 {IDLE_THRESHOLD} 秒") # 这里可以触发告警、重启服务、写日志等 last_active = time.time() # 避免重复告警 if __name__ == "__main__": main()

这段代码的关键设计点有几个。第一,用select而不是阻塞读,这样既能同时监听多个设备,又能定期检查静默时间。第二,is_active_event严格过滤事件类型,避免噪声干扰。第三,捕获OSError处理设备拔出,保证程序不会因为热插拔崩溃。第四,告警后重置last_active,避免每秒都刷告警。

5.3 实测中的意外情况与处理

实测下来有几个坑值得单独说。第一个是触摸板的手掌误触。有些触摸板在手掌靠近时会持续上报微小位移,虽然value不为零,但并不是真实操作。如果阈值设得小,这种噪声会导致永远不触发告警。解决办法是给位移加一个最小阈值,比如abs(event.value) >= 2才算有效移动。

第二个是键盘的自动重复。按住一个键不放,内核会以固定频率上报value == 2的重复事件。这算不算活动?我的判断是算,因为确实有人在操作。但如果你的场景要求"必须松开再按才算新活动",那就只认value == 1。

第三个是虚拟输入设备。某些远程桌面、虚拟机或者自动化工具会创建虚拟键盘鼠标,它们也会出现在/dev/input/里。如果不想把这些算进去,可以在find_input_devices里根据设备名称或物理路径过滤掉。

6. 阈值调优与误报处理:让检测真正可靠

6.1 如何用日志反推合理的静默阈值

阈值不是拍脑袋定的,最好用真实数据反推。我的做法是先把阈值设得很大(比如 600 秒),让程序跑一整天,记录每次"从活动到静默"的间隔分布。然后看这些间隔的中位数和最大值,把阈值定在略大于正常最大间隔的位置。

具体操作是在代码里加一行日志,每次检测到活动时打印距上次活动的时间差:

gap = time.time() - last_active if gap > 1: # 忽略连续事件 print(f"活动间隔:{gap:.1f} 秒")

跑一天后把日志里的间隔值导出来,用简单的统计就能看出正常范围。这个方法比凭感觉定阈值靠谱得多,尤其适合用户行为不确定的公共设备。

6.2 分级告警:从提醒到自动恢复

单一阈值往往不够用。更实用的做法是分级处理:静默超过阈值 1 时记录日志,超过阈值 2 时发提醒,超过阈值 3 时触发自动恢复(比如重启输入相关服务、重新加载驱动,甚至重启设备)。

LEVEL1 = 60 # 记录 LEVEL2 = 180 # 提醒 LEVEL3 = 600 # 自动恢复 if idle >= LEVEL3: trigger_recovery() elif idle >= LEVEL2: send_alert() elif idle >= LEVEL1: log_warning()

分级的好处是避免"一有问题就大动干戈",给用户和系统都留出缓冲。很多情况下,短暂的静默只是用户离开了一会儿,并不需要立即干预。

6.3 避免"永远活跃"和"永远沉默"两个极端

检测程序最怕两种极端。一种是永远活跃:因为把噪声事件也算进去了,或者某个设备在疯狂上报,导致告警永远不触发。另一种是永远沉默:因为设备识别错了,或者权限不对根本没读到事件,导致一直告警。

排查这两种情况的方法很直接。对于"永远活跃",用evtest逐个设备看,找出那个一直在上报的设备,然后决定是过滤它还是修它。对于"永远沉默",先确认find_input_devices找到了几个设备,再确认is_active_event有没有被触发,最后确认权限。我一般会在程序启动时打印设备列表和权限状态,出问题时一眼就能看出是哪一环断了。

7. 长期运行服务的工程化细节

7.1 用 systemd 托管检测程序

脚本写好后,如果要在生产环境长期跑,最好用 systemd 托管,而不是nohup或者screen。systemd 能自动重启崩溃的程序、管理日志、控制启动顺序。一个最小的 service 文件长这样:

[Unit] Description=Input Activity Monitor After=multi-user.target [Service] Type=simple User=youruser Group=input ExecStart=/usr/bin/python3 /opt/input-monitor/monitor.py Restart=always RestartSec=5 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target

放到/etc/systemd/system/input-monitor.service,然后:

sudo systemctl daemon-reload sudo systemctl enable input-monitor sudo systemctl start input-monitor

用journalctl -u input-monitor -f就能实时看日志。Restart=always保证程序意外退出后自动拉起,Group=input解决权限问题,不用把整个服务跑在 root 下。

7.2 资源占用与性能考量

有人担心一直监听输入事件会不会很吃资源。实测下来,用select加 1 秒超时的方案,CPU 占用基本在 0.1% 以下,内存也就几 MB。因为大部分时间程序都在select上等待,不消耗 CPU。真正需要注意的是事件风暴:某些设备故障时会疯狂上报事件,导致read()循环停不下来。可以在每次循环里限制最多读取的事件数量,比如一次最多读 100 个,剩下的留到下一轮,避免单个设备拖垮整个程序。

7.3 日志轮转与告警去重

长期运行的程序日志会不断增长,必须配置轮转。如果用 systemd 的 journal,它自带轮转和大小限制,一般不用额外处理。如果程序自己写文件日志,就要配合logrotate。告警去重也很重要:同一个静默状态不要反复告警,我一般是在触发告警后设置一个"已告警"标志,直到检测到新的活动才清除。这样既不会漏报,也不会刷屏。

8. 几个容易踩的坑和我的实际体会

第一个坑是把设备节点当成永久有效。event5这次是鼠标,下次重启可能就变成别的设备了。任何硬编码 event 编号的做法都是不可靠的,必须动态解析/proc/bus/input/devices或者用 evdev 的能力位来识别。

第二个坑是忽略权限导致静默失败。程序没报错,但就是读不到事件,最后发现是权限问题。我的习惯是在程序启动时主动检查每个设备的可读性,读不到就明确打印警告,而不是默默跳过。

第三个坑是阈值一刀切。不同设备、不同用户、不同时间段的行为差异很大,固定阈值往往顾此失彼。如果条件允许,可以考虑用滑动窗口统计历史活动频率,动态调整阈值,虽然复杂一些,但适应性更好。

第四个坑是忘记处理设备热插拔。USB 设备被拔掉再插上,event 节点会变,旧的 fd 会失效。程序必须能检测到设备消失并重新扫描,否则插拔一次就再也检测不到了。

我在实际使用中最大的体会是:检测逻辑本身不难,难的是让它长期稳定运行。把权限、热插拔、日志、告警去重这些工程细节处理好,比纠结用什么算法重要得多。先跑起来,再用真实数据调阈值,最后用 systemd 托管,这套流程走下来,基本就能覆盖绝大多数 Ubuntu 输入设备活动检测的需求了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进