在实际嵌入式开发和Linux系统底层调试中,经常会遇到一类需求:让系统“凭空多出一个键盘”。不是插一个USB键盘,也不是用蓝牙配对,而是由软件直接模拟出一个完整的HID键盘设备,让操作系统、上层应用都认为真的有这么个硬件接在总线上。这个需求听起来有点绕,但落地场景非常多:无人值守设备需要远程输入指令、自动化测试模拟真实按键、键盘映射工具改写输入来源、甚至KVM切换器的软件端实现。这次我基于VHF框架(Virtual HID Framework)把虚拟HID键盘完整做了一遍,把硬件时序、HID报告、内核事件通道这一整条链路都梳理清楚了,这篇文章就围绕整个实现过程展开,适合正在做虚拟输入设备、嵌入式外设模拟、又不想从头撸内核模块的开发者参考。
1. 为什么需要“软件造一个键盘”
1.1 虚似HID设备的典型使用场景
“用软件造一个键盘”听起来像炫技,实际上是个很刚需的功能。我最早接触这个需求是在一个远程运维项目上,设备部署在机房,没有接显示器也没有接键盘,出问题之后只能靠网络通道远程登录查看,但有一部分老旧的底层固件只能在启动阶段响应键盘中断,没有预留网络恢复接口,这时候就需要一个能在系统层面模拟键盘、把按键事件“喂”给固件的手段。
类似的场景还有很多。比如做Android TV或车机系统的自动化测试,需要在开机引导阶段模拟遥控器按键,但测试机上根本没有物理遥控器;再比如做一些辅助输入设备,把游戏手柄的按键翻译成键盘事件,或者把触控手势映射成快捷键,这些都需要一个稳定、低延迟、可编程控制的虚拟输入源。
VHF框架就是干这个的。它把Linux内核提供的HID虚拟化能力封装成了用户态API,开发者不需要写内核模块,也不需要手工构造复杂的uhid事件结构,只要调用几个接口就能注册一个虚拟键盘设备,然后像写串口一样把按键数据“发”出去。这个思路和虚拟声卡、虚拟显示器是一模一样的:软件层构造一个符合标准协议逻辑的设备,驱动层按协议识别,应用层完全感知不到差异。
1.2 与物理键盘、uinput、USB Gadget的方案对比
做虚拟输入设备,业界其实有几种不同的路线,我用表格把它们的核心差异整理出来了:
| 方案 | 设备形态 | 对上层应用可见性 | 依赖的硬件 | 开发复杂度 | 典型用途 |
|---|---|---|---|---|---|
| 物理键盘 | 真实USB/PS2设备 | 完全一致 | 需硬件 | 低 | 传统人机输入 |
| Linux uinput | 虚拟输入设备 | /dev/input/eventX | 无 | 中 | 键鼠模拟、宏命令 |
| USB Gadget | 模拟USB外设 | 对Host端是真实USB口 | 需目标板支持USB Device模式 | 高 | 嵌入式USB外设模拟 |
| VHF/UHID方案 | 虚拟HID设备 | /dev/hidrawX、/dev/input/eventX | 无 | 中 | 用户态HID模拟 |
很多开发者第一反应是用uinput,因为它简单,打开/dev/uinput、写几个struct input_event就能模拟按键。但uinput有个关键限制:它生成的只是一个“输入子系统的虚拟设备”,虽然应用层能用,但它不经过HID协议栈,也不会主动产生HID报告。如果你的目标是让另一个进程通过/dev/hidraw读到一份标准HID键盘报告,或者要同时模拟一套带特殊Usage Page的厂商自定义设备,uinput就力不从心了。
USB Gadget方案倒是能产生真实USB信号,但它要求硬件本身支持USB Device角色,很多开发板并不具备这个条件,而且要处理USB枚举、端点配置、描述符切换这些底层细节,调试周期很长。VHF这类用户态虚拟HID方案正好卡在中间:不需要额外硬件,却能生成完整的HID设备描述和报告,兼顾了灵活性和真实性,而且项目调试起来非常直观——出错最多就是用户态逻辑,很少需要重新编译内核。
2. 搞懂HID协议,才能写出能用的虚拟键盘
2.1 HID设备的三张“身份证”:Descriptor、Report、Usage
在没有真正接触HID协议之前,我也觉得虚拟键盘就是“模拟按下一个键”这么简单,后来才发现,一个键盘能被系统识别,关键在于三样东西:HID描述符(Descriptor)、报告(Report)、用法(Usage)。
HID描述符是一份设备能力的“介绍信”,它告诉操作系统这个设备是什么、有哪些端口、报告长什么样。键盘的标准描述符通常包含一个或多个报告描述符,每个报告描述符定义了输入报告的结构:哪些bit代表修饰键(Shift、Ctrl、Alt)、哪些bit代表保留字节、哪些bit用于存放按下的普通按键键码。
Usage大概是HID里最容易误解的概念。它不是“按键值”,而是“用途标识”,比如Generic Desktop页面下的Keyboard(Usage ID 0x06),以及Keyboard页面下的各个按键(a键是0x04,b键是0x05)。在HID规范里,绝大多数键鼠用的都是Usage ID,而不是Linux内核对按键用的那些常量,这两者之间的映射关系本身就有一张庞大的表。
这里给出一份最简键盘HID报告描述符,它对应的是标准六键无冲键盘(6KRO)的经典结构:
// 标准键盘报告描述符(6键无冲) static const unsigned char keyboard_report_desc[] = { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x06, // Usage (Keyboard) 0xA1, 0x01, // Collection (Application) 0x05, 0x07, // Usage Page (Keyboard) 0x19, 0xE0, // Usage Minimum (Left Control) 0x29, 0xE7, // Usage Maximum (Right GUI) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1) 0x95, 0x08, // Report Count (8) 0x81, 0x02, // Input (Data, Variable, Absolute) 0x95, 0x01, // Report Count (1) 0x75, 0x08, // Report Size (8) 0x81, 0x01, // Input (Constant) - 保留字节 0x95, 0x05, // Report Count (5) 0x75, 0x01, // Report Size (1) 0x05, 0x08, // Usage Page (LEDs) 0x19, 0x01, // Usage Minimum (Num Lock) 0x29, 0x05, // Usage Maximum (Scroll Lock) 0x91, 0x02, // Output (Data, Variable, Absolute) 0x95, 0x01, // Report Count (1) 0x75, 0x03, // Report Size (3) 0x91, 0x01, // Output (Constant) 0x95, 0x06, // Report Count (6) 0x75, 0x08, // Report Size (8) 0x15, 0x00, // Logical Minimum (0) 0x25, 0xFF, // Logical Maximum (255) 0x05, 0x07, // Usage Page (Keyboard) 0x19, 0x00, // Usage Minimum (Reserved) 0x29, 0x65, // Usage Maximum (Application) 0x81, 0x00, // Input (Data, Array) 0xC0 // End Collection };这份描述符里有几个地方非常关键。第一个是修饰键字节,用8个bit表示左Ctrl、左Shift、左Alt、左GUI、右Ctrl等,任何组合都能表达。第二个是保留字节,固定填0。第三个是一个8bit数组,最多容纳6个同时按下的普通按键。一旦看懂这三块,后续注入按键事件时就非常清晰:每次按键报告就是8字节,第1字节是修饰键位图,第2字节是保留位,第3到第8字节存放HID键码。
2.2 虚拟HID在Linux内核中的实际工作链路
VHF框架能够实现虚拟HID键盘,底层依赖的是Linux内核的UHID驱动机制。UHID全称是User-space HID,设计目标就是让用户态进程能够直接创建和管理HID设备,设备创建后,内核的HID核心层会把这个设备纳入子系统管理,应用层通过/dev/hidrawX或/dev/input/eventX访问它。
从数据流上看,整个链路可以概括成:用户态程序构造HID报告 -> 写入UHID设备节点 -> 内核HID核心解析 -> 分发到输入子系统或HIDRAW子系统 -> 上层应用读取。这个过程有一个极大的好处:不需要修改内核,所有逻辑都在用户态完成,出问题之后可以直接用gdb调试,也可以用strace看系统调用,开发效率比写内核模块高一个数量级。
VHF框架在中间扮演的是“封装层”的角色,它内部处理了UHID设备的打开、ioctl调用、事件缓冲管理,对外暴露的API很简洁。如果脱离VHF直接操作UHID,开发者要自己搞定UHID_CREATE、UHID_DESTROY、UHID_INPUT等一系列ioctl,还要处理struct uhid_create_req的字段填充,细节非常多。VHF把这些脏活全部处理掉之后,业务代码只需要关心:注册设备、发送报告、注销设备。
3. 基于VHF框架搭建虚拟键盘的完整过程
3.1 环境准备:内核配置、依赖库与权限设置
开始写代码之前,先把运行环境确认透彻。虚拟HID设备离不开UHID内核模块,内核通常在CONFIG_HID和CONFIG_UHID两个配置下编译。可以用zcat /proc/config.gz | grep UHID命令快速检查,如果发现没有开启,需要重新编译内核或者更换支持的内核,这一步没做,后面一切免谈。
驱动模块确认后,还需要确保/dev/uhid节点存在并有可写权限。多数发行版在加载uhid模块后会自动创建设备节点,默认归属于root用户。在开发调试阶段,最简单的办法是直接以root用户运行程序,但如果是长期运行的业务服务,建议写一条udev规则,让指定用户组拥有访问权。我踩过权限这个坑,第一天折腾了很久,最后发现只是当前用户没有设备节点访问权限。
VHF框架本身的引入很简单,因为它就是一个用户态库。可以从官方仓库拉取源码,在设备上编译安装,编译时只依赖Linux的标准头文件和libc。整个安装过程大概是这样:
# 加载UHID内核模块 modprobe uhid # 检查设备节点是否生成 ls -l /dev/uhid # 编译安装VHF框架 ./autogen.sh ./configure --prefix=/usr/local make -j4 sudo make install # 确认库文件 ldconfig -p | grep vhf3.2 注册虚拟HID设备的整体流程
虚拟HID设备的注册流程是骨架,后面所有按键注入都是基于这个设备实例完成的。VHF框架把设备生命周期分成了初始化、注册、事件循环、注销四个阶段,逻辑非常清晰。
初始化阶段需要创建一个vhf_device句柄,并填充设备描述信息。框架会自动把描述符内容透传给内核UHID,因此描述符数组就是设备能否被正确识别的关键。注册阶段框架会向内核发送UHID_CREATE消息,内核返回成功之后,系统里就会出现一个全新的HID设备。
核心代码大致是这样的:
#include <vhf.h> #include <string.h> // 一、创建设备句柄 vhf_device_t *dev = vhf_device_new(); if (!dev) { fprintf(stderr, "create vhf device failed\n"); return -1; } // 二、填充设备描述信息 vhf_device_set_name(dev, "virtual HID keyboard demo"); vhf_device_set_bus(dev, BUS_USB); vhf_device_set_vendor_id(dev, 0x1234); vhf_device_set_product_id(dev, 0x5678); vhf_device_set_version(dev, 0x0100); // 三、设置键盘报告描述符 vhf_device_set_report_desc(dev, keyboard_report_desc, sizeof(keyboard_report_desc));设备名、厂商ID、产品ID这些信息其实是给系统识别用的,应用层读取设备属性时能看到。我这里用的0x1234、0x5678是测试用的厂商号,实际产品化的时候需要申请或使用自己的ID。
注册动作完成后,VHF框架内部会创建一个事件线程,负责读取内核反馈的事件。需要特别理解的是:虚拟HID设备注册成功后,内核会向用户态发送UHID_STARTUP事件,表示设备已经启动,这时设备才能被应用程序打开;在这之前如果急着发送输入报告,内核会直接丢弃。VHF框架在vhf_device_register返回后并不代表设备已经就绪,需要等vhf_device_started返回真才能继续。
完整的注册流程可以这样组织:
// 注册设备 int rc = vhf_device_register(dev); if (rc < 0) { fprintf(stderr, "register vhf device failed\n"); vhf_device_free(dev); return -2; } // 等待内核确认设备启动 int retry = 100; while (retry-- > 0 && !vhf_device_started(dev)) { usleep(100 * 1000); } if (!vhf_device_started(dev)) { fprintf(stderr, "vhf device not started\n"); vhf_device_destroy(dev); vhf_device_free(dev); return -3; } printf("virtual keyboard is ready\n");这里我特意加了轮询等待,第一次写的时候没有等待就发送报告,结果按键事件确实发出去了,但系统根本没识别到键盘,排查了很久。后来仔细看内核日志,发现UHID设备创建失败,原因就是设备未启动就抢先发送了输入报告。
3.3 按键注入的两种写法
设备注册成功之后,注入按键的方法就非常直接了。在HID协议层面,键盘一次按键对应一次状态变化,本质上就是把8字节的报告写到设备文件里。VHF框架提供了两个层面的封装:一个是直接发送HID报告,另一个是更贴近应用层的按键事件API。
直接发送HID报告的方式适合对协议控制要求极高的场景,代码是这样的:
// 准备一份8字节的键盘HID报告 unsigned char report[8] = {0}; // 按下字母a,HID键码是0x04 report[2] = 0x04; vhf_device_send_report(dev, report, sizeof(report)); // 稍后释放按键:所有普通键清零,保留键码清空 memset(report, 0, sizeof(report)); vhf_device_send_report(dev, report, sizeof(report));直接发送报告的好处是透明,缺点是容易写错。比如同时按a和b,需要把HID键码0x04和0x05同时填入report[2]、report[3];而如果填错了位置,或者忘记清除上一次的键码,就会出现键粘连的问题。
VHF框架还提供了一组按键级API,它在内部帮你维护报告缓冲区,自动处理按键的按下、释放,甚至组合键的修饰状态。抽空试过之后发现,这种方式更适合大多数业务开发:
// 按下a键 vhf_keyboard_press_key(dev, VHF_KEY_A); // 同时按下Ctrl+C,先按下修饰键,再按c键 vhf_keyboard_set_modifier(dev, VHF_MOD_LEFT_CTRL, 1); vhf_keyboard_press_key(dev, VHF_KEY_C); // 释放所有按键 vhf_keyboard_release_all(dev);这两种API的使用场景有明确区别。直接发报告适合做极低层协议测试、协议抓包验证;按键级API适合做业务级功能。我推荐在实际项目里优先用按键级API,因为代码可读性好,而且可以避免很多自己手动维护报告状态的问题。
3.4 事件循环与设备注销
虚拟键盘注册成功后,程序需要进入一个事件处理循环,持续监听来自设备的事件。这些事件包括:设备打开、设备关闭、输出报告、设备停止等。VHF框架在内部帮我们处理了绝大部分,但对于输出报告这类事件,用户态还是可以获得回调,从而实现对Caps Lock、Num Lock这些LED状态的响应。
事件循环的核心在于不能阻塞。如果注册事件处理器时使用的是阻塞模式,主线程会被卡住,按键注入类功能就只能由其他线程执行;如果使用独立事件线程,主线程就可以专心处理业务流程。VHF框架支持两种模式,我习惯用独立线程,然后把按键注入逻辑放在业务的队列里。
最后是设备注销,这个相对简单。调用vhf_device_destroy之后,内核会销毁HID设备,/dev/hidrawX节点和/dev/input/eventX节点也会随之消失。需要注意在注销前先释放所有按键,避免系统留下一个“永远按着”的按键状态。
// 释放所有按键 vhf_keyboard_release_all(dev); // 销毁设备 vhf_device_destroy(dev); // 释放句柄 vhf_device_free(dev);4. 键码映射与组合键处理:这是整个项目最磨人的细节
4.1 HID键码、Linux键码与ASCII码三者的区别
很多人第一次写虚拟键盘都会犯同一个错误:把ASCII码当成键码直接填进HID报告。比如按字母a,直觉上是发送0x61(ASCII 'a'),但在HID键盘协议里,字母a的键码是0x04。这两个数之间的关系没有任何规律可循,完全是由HID Usage Table定义死的。
到了Linux应用层,按键值的编码又变了一套规则。Linux输入子系统的KEY_A常量对应的是数值30,跟HID键码0x04也完全不同。VHF框架在设计时直接屏蔽了这层差异,它内部维护了一张完整的映射表,用户态传的是VHF_KEY_A这样一个语义化枚举,框架负责翻译成HID键码。但如果你是直接发送HID报告,就必须自己对整套编码体系有清晰认知。
下面列几个常用键的对照关系,方便快速感知这种差异:
| 按键 | HID键码 (十六进制) | Linux KEY_常量 | ASCII码 |
|---|---|---|---|
| a | 0x04 | KEY_A (30) | 0x61 |
| b | 0x05 | KEY_B (48) | 0x62 |
| c | 0x06 | KEY_C (46) | 0x63 |
| 1 | 0x1E | KEY_1 (2) | 0x31 |
| Enter | 0x28 | KEY_ENTER (28) | 0x0D |
| Space | 0x2C | KEY_SPACE (57) | 0x20 |
看到这组数字就应该明白,靠ASCII转HID键码是行不通的。哪怕只是一个小工具,也要维护一张完整的映射表,不然几十个键之后就会乱套。
4.2 修饰键的状态管理与组合键处理
组合键是虚拟键盘实现里的重灾区。真实按键时代理层从内核拿到的是高电平信号,按下的瞬间和抬起瞬间都清清楚楚,但虚拟HID键盘要自己管理这套状态机。
修饰键严格按照bit位来管理,第0个bit是左Ctrl,第1个bit是左Shift,第2个bit是左Alt,第3个bit是左GUI,第4到第7位分别对应右侧的修饰键。必须用位运算来维护这个字节,不能简单用赋值,否则很容易覆盖掉其他修饰键的状态:
// 同时按下Ctrl和Shift,再按a unsigned char modifiers = 0; modifiers |= (1 << 0); // Left Ctrl modifiers |= (1 << 1); // Left Shift report[0] = modifiers; report[2] = 0x04; // a键 vhf_device_send_report(dev, report, sizeof(report));释放的时候要特别注意顺序。很多设备端代码处理组合键是直接memset整个报告,这在大部分场景下没问题,但如果在释放按键时仍然想保留某个修饰键(比如让Ctrl保持按住),就必须分别清除普通按键字段和修饰键字段。试过把两者混在一起处理,结果按下Ctrl再按c时,会因为中途清空了Ctrl而产生一次错误的按键事件。
还有个小细节是多键同按的处理。标准键盘报告最多同时报告6个普通键,超过6个会出现“按键抖动”,即最早按下的键被新的键顶掉。如果你的应用场景需要同时按超过6个键,就必须调整HID报告描述符,把最后的数组长度加长,或者改成每个按键单独报告的Bitmap模式。VHF框架默认支持标准6KRO,扩展起来也比较容易,改一下描述符里的Report Count字段即可。
4.3 按键状态复用与无冲报告的坑
虚拟键盘一个很容易忽略的问题是:系统什么时候认为一个键被释放了?对真实键盘来说,松开按键的瞬间,键盘会把那个键从报告里移除,然后发送新报告。如果虚拟键盘发出一个包含a键的报告之后,连续多次发送同样的报告,系统会认为a一直处于按下状态,持续产生键入事件。这是个深坑,尤其在做自动化输入时,如果不注意清理,输入框里会出现无数个连续的a。
正确做法是每次按键事件之后都发送一份空报告,表示按键抬起。VHF框架的按键级API内部会做这件事,直接发报告就需要自己在代码里保证。写过一套自动化脚本,在循环里反复注入”a键按下”的报告,忘了发释放报告,结果输入框里刷了几百个a字符,当场就懵了。
5. 排查实录与避坑指南
5.1 设备无法识别的几种典型情况
我把实际开发中遇到的高频问题整理成表格,按优先级列出,方便快速定位:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
/dev/uhid不存在 | uhid模块未加载 | modprobe uhid,然后ls -l /dev/uhid |
| 设备注册失败,错误码EACCES | 用户对设备节点无权限 | root运行或用udev规则授权 |
| 设备注册成功但没有出现在系统 | 报告描述符无效 | 对比标准键盘描述符,检查Usage Page |
| 设备出现但应用层打不开 | 设备未完成STOP/START流程 | 确认vhf_device_started为真 |
| 键盘能识别但按键无效 | HID键码填错 | 用evtest或hidrd-convert验证报告 |
有个特别隐蔽的问题是:在同一台主机上运行多个虚拟HID设备时,每个设备的物理路径和节点号会变动,VHF框架内部要处理好设备节点的动态获取。如果代码里写死了/dev/hidraw0,在多设备场景下会指向错误设备。正确做法是通过设备粒度来定位,而不是靠节点序号。
5.2 按键无响应与响应延迟的排查思路
按键无响应的排查顺序通常是这样:先用evtest工具查看/dev/input/eventX节点是否收到事件,再看/dev/hidrawX节点是否能读到HID报告。如果evtest能看到事件,说明HID报告解析正常、输入子系统分发正常,问题多半在系统上层;如果hidraw都读不到数据,那问题出在报告发送链路。
响应延迟问题则多半和发送机制有关。VHF框架默认建议在独立线程或独立进程里维护一个按键队列,按键事件先入队,再由后台线程统一发送。一定要避免在中断上下文或对时间极端敏感的函数里直接调用发送接口,因为IO写入并不是无锁的,极端情况下会阻塞几百微秒。
我实测过连续快速输入时,200Hz的发送频率完全没有压力,单线程就能稳定跑满。如果你用到了更高的频率,关键是控制发送间隔,不能盲目while循环刷报告。曾经按照最大速率连续发送按键报告,结果系统负载飙升,输入反而卡顿。原因是内核在处理HID设备输入时也有事件队列,报告发送过快会导致队列堆积。
5.3 设备占用、权限与多实例并发的经验
虚拟HID设备在生命周期内只能被一个进程持有。如果你启动了两个程序同时尝试注册同一个VHF设备,第二个程序会拿到EBUSY错误。多人协作开发时,这种资源冲突经常发生。解决方案是注册前先检查设备是否已存在,或者给不同实例设置不同的vendor/product ID。
权限管理方面,真实项目中不能一直用root运行。我踩过坑之后,直接写了一条udev规则,允许输入组用户访问HID设备节点,这样业务进程就能降权运行:
KERNEL=="uhid", MODE="0660", GROUP="input"这条规则让/dev/uhid设备的用户组变为input,组内用户就能正常读写。生产环境下再配合systemd服务配置,把运行用户指定为input组,整个服务就能安全地非root运行了。
5.4 调试三板斧:dmesg、evtest、hidrd-convert
排查问题最有效的三个工具,我现在已经离不开它们。
第一个是dmesg,内核日志。UHID创建、销毁、报告发送失败都会在内核日志里留下记录。我有一次设备注册失败,用户态程序什么错误都没报,dmesg里直接显示uhid: cannot create HID device,一下就把方向指到了描述符问题。
第二个是evtest,验证输入事件的利器。它能列出所有输入设备,并能监听实时事件。执行evtest /dev/input/eventX之后按下虚拟键盘,屏幕上会滚动显示keycode和状态,整个输入通路是否正常一眼便知。
第三个是hidrd-convert,它是把二进制HID报告描述符转换成可读文本格式的工具。描述符写错是最难排查的问题,因为语法上完全合法,但系统确实不识别。用hidrd-convert把描述符转成文本,对比HID规范检查每一个Usage,比看十六进制数组高效得多。
这三个工具配合起来,基本上90%的问题都能在十分钟内定位。并且它们都是Linux发行版自带的工具,不需要额外搭建环境。
6. 后续可以继续扩展的方向
虚拟HID键盘做完之后,我最大的感受是:VHF框架不只适合做键盘,只要理解了HID描述符和报告的概念,鼠标、触摸屏、游戏手柄、多媒体按键都能用同一套方式实现。框架层面也支持多设备管理,注册一个键盘的同时再注册一个鼠标,两者互不干扰,非常适合KVM类应用的软件端改造。
如果之后要把这块内容落地到更严苛的场景,有几个方向值得继续深入。比如把按键事件通过消息队列从远程传输过来,做成网络输入设备;或者把虚拟HID设备包装成系统服务,让它常驻后台、开机自启;再或者研究一下蓝牙HID和USB Gadget模式,这样虚拟键盘就不局限于本地系统内部,而是能被宿主机、手机、另一块开发板识别。按现有的设计方案,这几条路都不需要推翻重来,框架的接口已经足够平滑地承接。
最后分享一个个人很受益的小经验:整个项目里不要试图一次性写完整再调试,一定要先注册一个空设备,跑通最小路径之后再逐步增加键码映射、组合键、事件队列。第一次实现的时候我直接铺开了完整代码,结果键码映射、报告清除、修饰键管理好几个问题同时爆发,排查过程非常痛苦。把链路拆成注册、单键、组合键、批量输入四个阶段,每个阶段验证完再进入下一阶段,实际花的时间反而最少。