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: Bus0011 Vendor0001 Product0001 Versionab41 N: NameAT Translated Set 2 keyboard P: Physisa0060/serio0/input0 S: Sysfs/devices/platform/i8042/serio0/input/input0 U: Uniq H: Handlerssysrq kbd event0 leds B: PROP0 B: EV120013 ... I: Bus0003 Vendor046d Productc077 Version0111 N: NameUSB Optical Mouse P: Physusb-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: Handlersmouse0 event5 B: PROP0 B: EV17 ...每个设备块里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_KEY1是按键EV_REL2是相对位移鼠标移动EV_ABS3是绝对位移触摸板。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_LEDLED 状态等。这样判定出来的活动才是真正的人为操作。4.3 多设备聚合键盘和鼠标要一起看一台机器上可能同时有多个键盘和鼠标比如笔记本自带键盘加外接 USB 键盘触摸板加外接鼠标。检测逻辑应该聚合所有输入设备只要任意一个设备在时间窗口内有有效事件就认为有活动。实现上可以用一个全局的最后活动时间戳每个设备读到有效事件时就更新这个时间戳。判定时只需要比较当前时间 - 最后活动时间和阈值的关系。这样无论用户用的是哪个设备都能正确反映整体活动状态。注意如果某个设备被拔掉它的 event 节点会消失读取会报错。程序必须能处理这种情况把该设备从监听列表里移除而不是直接崩溃。热插拔处理是长期运行程序必须考虑的问题。5. 一个可直接落地的 Python 检测方案5.1 环境准备与依赖安装先装 Python 的 evdev 库。Ubuntu 上可以用 apt也可以用 pipsudo 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] DescriptionInput Activity Monitor Aftermulti-user.target [Service] Typesimple Useryouruser Groupinput ExecStart/usr/bin/python3 /opt/input-monitor/monitor.py Restartalways RestartSec5 StandardOutputjournal StandardErrorjournal [Install] WantedBymulti-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就能实时看日志。Restartalways保证程序意外退出后自动拉起Groupinput解决权限问题不用把整个服务跑在 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 输入设备活动检测的需求了。