Android 底层输入系统改造实录:把 gpio-keys "凭空捏造"成虚拟键盘
在只有 7 个实体按键的 Android 工控设备上,没有
sendevent,没有getevent,如何让它上层 App 识别出 D-Pad 方向键乃至字母输入?本文记录了一次完整的底层输入系统改造过程。
一、背景与硬件现状
设备型号:光速虚拟机
通过 getevent -pil 查看,系统只有 2 个输入设备:
/dev/input/event0:gpio-keys------ 仅有 7 个按键
KEY_HOME、KEY_MUTE、KEY_VOLUMEDOWN、KEY_VOLUMEUP、KEY_POWER、KEY_BACK、KEY_APPSELECT/dev/input/event1:touch------ 触摸屏
核心矛盾 :上层 App 需要响应方向键(D-Pad),但硬件层面根本没有这些按键。系统也没有预装 sendevent调试工具。
二、Android 输入系统的三层架构
要改造输入,必须先理清事件流转路径:
scss
Linux 内核 (input_event)
↓
/dev/input/eventX
↓
Android EventHub / InputReader
↓
├─ .kl 文件 (KeyLayout) : 扫描码 → Android Keycode
└─ .kcm 文件 (KeyCharacterMap): Keycode → 字符输出
↓
上层 App (KeyEvent)
关键认知:
.kl决定系统"认不认识"这个按键(动作层)。.kcm决定按键能不能"打出字"(字符层)。- 两者都在
/system/usr/下,按设备名 或 Vendor/Product ID 匹配。
因为设备名是 gpio-keys,系统会自动加载:
/system/usr/keylayout/gpio-keys.kl/system/usr/keychars/gpio-keys.kcm(如果存在;否则回退到Generic.kcm)
三、实战:修改系统映射文件
3.1 重新挂载 /system
bash
mount -o rw,remount /system
如果报错,尝试:
bash
mount -o rw,remount /
# 或
mount -o rw,remount -t ext4 /dev/block/bootdevice/by-name/system /system
3.2 修改 KeyLayout (.kl)
在 /system/usr/keylayout/gpio-keys.kl 末尾追加映射:
bash
echo "key 204 DPAD_UP" >> /system/usr/keylayout/gpio-keys.kl
echo "key 205 DPAD_DOWN" >> /system/usr/keylayout/gpio-keys.kl
echo "key 206 DPAD_LEFT" >> /system/usr/keylayout/gpio-keys.kl
echo "key 207 DPAD_RIGHT" >> /system/usr/keylayout/gpio-keys.kl
echo "key 208 DPAD_CENTER" >> /system/usr/keylayout/gpio-keys.kl
注意 :扫描码是十进制 。204~208 是我们"凭空捏造"的未使用编号。
3.3 创建 KeyCharacterMap (.kcm)
如果是方向键/功能键 ,不需要打出字符,但 .kcm 文件必须存在(哪怕内容极简),否则系统可能回退到不兼容的 Generic 映射:
bash
cat << 'EOF' > /system/usr/keychars/gpio-keys.kcm
type FULL
key DPAD_UP { }
key DPAD_DOWN { }
key DPAD_LEFT { }
key DPAD_RIGHT { }
key DPAD_CENTER { }
EOF
如果需要字母输入(如 W、A、S、D),则必须声明字符映射:
kcm
key W {
label: 'W'
base: 'w'
shift, capslock: 'W'
}
3.4 权限与重启
bash
chmod 644 /system/usr/keylayout/gpio-keys.kl
chmod 644 /system/usr/keychars/gpio-keys.kcm
reboot
.kl 和 .kcm 只在系统启动时由 InputReader 加载一次,修改后必须重启。
四、底层注入:没有 sendevent 怎么办?
很多精简系统会裁剪 sendevent / getevent,但它们本质上只是读写 /dev/input/eventX 的用户层工具。只要有 Root 权限,完全可以自己构造二进制数据注入。
4.1 input_event 结构体(ARM64)
在 64 位 Android 设备上,该结构体为 24 字节:
| 字段 | 大小 | 说明 |
|---|---|---|
time (tv_sec + tv_usec) |
16 字节 | 时间戳,通常填 0 |
type |
2 字节 | 事件类型:0x0001 = EV_KEY |
code |
2 字节 | 扫描码(小端序) |
value |
4 字节 | 0x00000001 = 按下,0x00000000 = 松开 |
4.2 使用 printf 注入(推荐)
以扫描码 204(十六进制 0xCC,小端序 Ì )为例:
按下:
bash
printf '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xCC\x00\x01\x00\x00\x00' > /dev/input/event0
松开:
bash
printf '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xCC\x00\x00\x00\x00\x00' > /dev/input/event0
如果 printf 直接写设备无效 ,改用"先写临时文件,再 dd 写入设备"的两步式方案。
4.3 两步式:printf 生成文件 + dd 写入设备(最稳定)
某些设备上,printf 直接重定向到设备节点会被内核拒绝或缓冲异常。最稳妥的做法是先把二进制数据写入临时文件,再用 dd 的块设备写入方式刷进去。
每个按键操作包含 2 个 input_event (48 字节):EV_KEY + EV_SYN。
按下(以 DPAD_UP 204 为例):
bash
printf '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xCC\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' > /data/local/tmp/key_press.bin
dd if=/data/local/tmp/key_press.bin of=/dev/input/event0 bs=24
松开:
bash
printf '\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\xCC\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' > /data/local/tmp/key_release.bin
dd if=/data/local/tmp/key_release.bin of=/dev/input/event0 bs=24
其他按键替换表:
| 按键 | 扫描码 | 十六进制 (code) |
|---|---|---|
| DPAD_UP | 204 | 0xcc |
| DPAD_DOWN | 205 | 0xcd |
| DPAD_LEFT | 206 | 0xce |
| DPAD_RIGHT | 207 | 0xcf |
| DPAD_CENTER | 208 | 0xd0 |
注意 :
bs=24必须严格匹配 64 位input_event结构体大小。如果目标设备是 32 位系统,时间戳只有 8 字节,总长度为 16 字节,需要重新生成数据。
4.4 关于 cat /dev/input/event0 > /dev/null &
某些设备的内核驱动有电源管理逻辑:当没有任何进程 open 该节点时,设备处于休眠,直接写入的事件可能被丢弃。后台挂一个 cat 可以保持引用计数 > 0,"唤醒"设备通道。但部分工控板驱动简单,无此限制,可直接注入。
五、Java 工程化:一键生成注入命令
将底层注入封装为 Java 工具类,传入扫描码和按下状态,直接返回可在 su Shell 中执行的命令字符串。
5.1 命令生成工具类
java
public class InputEventCommand {
/**
* 生成 input_event 注入命令(两步式:printf 生成文件 + dd 写入设备)
*
* @param keyCode 扫描码 (如 204~208)
* @param press true=按下, false=松开
* @return 包含 [printf命令, dd命令] 的字符串数组,末尾带 \n
*/
public static String[] getCommands(int keyCode, boolean press) {
byte[] data = buildInputEvent(keyCode, press);
StringBuilder hex = new StringBuilder();
for (byte b : data) {
hex.append(String.format("\x%02x", b & 0xFF));
}
String file = press ? "/data/local/tmp/key_press.bin"
: "/data/local/tmp/key_release.bin";
return new String[] {
String.format("printf '%s' > %s\n", hex.toString(), file),
String.format("dd if=%s of=/dev/input/event0 bs=24\n", file)
};
}
private static byte[] buildInputEvent(int keyCode, boolean press) {
// 64位 Linux input_event: 24 bytes × 2 个事件 = 48 bytes
byte[] buf = new byte[48];
int p = 0;
// ---- Event 1: EV_KEY ----
// time: 16 bytes (zeros)
for (int i = 0; i < 16; i++) buf[p++] = 0;
// type: 1 (EV_KEY) little-endian
buf[p++] = 0x01; buf[p++] = 0x00;
// code: keyCode little-endian
buf[p++] = (byte) (keyCode & 0xFF);
buf[p++] = (byte) ((keyCode >> 8) & 0xFF);
// value: 1=按下, 0=松开 little-endian
int val = press ? 1 : 0;
buf[p++] = (byte) (val & 0xFF);
buf[p++] = (byte) ((val >> 8) & 0xFF);
buf[p++] = (byte) ((val >> 16) & 0xFF);
buf[p++] = (byte) ((val >> 24) & 0xFF);
// ---- Event 2: EV_SYN ----
// time: 16 bytes (zeros)
for (int i = 0; i < 16; i++) buf[p++] = 0;
// type: 0 (EV_SYN)
buf[p++] = 0x00; buf[p++] = 0x00;
// code: 0 (SYN_REPORT)
buf[p++] = 0x00; buf[p++] = 0x00;
// value: 0
buf[p++] = 0x00; buf[p++] = 0x00;
buf[p++] = 0x00; buf[p++] = 0x00;
return buf;
}
}
5.2 为什么命令末尾必须带 \n?
当通过 DataOutputStream 向 su 进程写入命令时,Shell 以换行符 作为命令结束标志。如果末尾没有 \n,命令会停留在缓冲区,永远不会执行。
java
// 正确:带换行符,Shell 立即执行
os.writeBytes("dd if=/data/local/tmp/key_press.bin of=/dev/input/event0 bs=24\n");
os.flush();
// 错误:没有换行符,命令挂起
os.writeBytes("dd if=/data/local/tmp/key_press.bin of=/dev/input/event0 bs=24");
5.3 使用示例
java
// 获取 DPAD_UP 按下的两条命令
String[] pressCmds = InputEventCommand.getCommands(204, true);
// 在已打开的 su 进程中执行
DataOutputStream os = new DataOutputStream(suProcess.getOutputStream());
os.writeBytes(pressCmds[0]); // printf 生成文件
os.writeBytes(pressCmds[1]); // dd 写入设备
os.flush();
// ... 延时一段时间后 ...
// 获取松开命令并执行
String[] releaseCmds = InputEventCommand.getCommands(204, false);
os.writeBytes(releaseCmds[0]);
os.writeBytes(releaseCmds[1]);
os.flush();
六、Java 一键检测与修复系统映射
将第三章的 Shell 操作封装为 Java 工具类,供 App 首次启动时自动修复。
6.1 检测是否需要修复
利用 su -c 执行单条命令,通过进程退出码判断:
java
public static boolean needsFix() {
try {
Process p = Runtime.getRuntime().exec(
new String[]{"su", "-c", "grep -q 'key 204.*DPAD_UP' /system/usr/keylayout/gpio-keys.kl"}
);
return p.waitFor() != 0; // 找不到则返回 true(需要修复)
} catch (Exception e) {
return true;
}
}
6.2 执行修复并重启
修复涉及多行文件写入,最佳实践是生成临时 Shell 脚本后执行:
java
public static boolean fixDpadMapping() {
Process su = null;
try {
su = Runtime.getRuntime().exec("su");
DataOutputStream os = new DataOutputStream(su.getOutputStream());
BufferedReader is = new BufferedReader(new InputStreamReader(su.getInputStream()));
String script = "/data/local/tmp/fix_dpad.sh";
os.writeBytes("rm -f " + script + "\n");
os.writeBytes("echo '#!/system/bin/sh' > " + script + "\n");
os.writeBytes("echo 'mount -o rw,remount /system' >> " + script + "\n");
// 追加 .kl 映射
os.writeBytes("echo 'echo "key 204 DPAD_UP" >> /system/usr/keylayout/gpio-keys.kl' >> " + script + "\n");
os.writeBytes("echo 'echo "key 205 DPAD_DOWN" >> /system/usr/keylayout/gpio-keys.kl' >> " + script + "\n");
os.writeBytes("echo 'echo "key 206 DPAD_LEFT" >> /system/usr/keylayout/gpio-keys.kl' >> " + script + "\n");
os.writeBytes("echo 'echo "key 207 DPAD_RIGHT" >> /system/usr/keylayout/gpio-keys.kl' >> " + script + "\n");
os.writeBytes("echo 'echo "key 208 DPAD_CENTER" >> /system/usr/keylayout/gpio-keys.kl' >> " + script + "\n");
// 生成 .kcm
os.writeBytes("echo 'cat > /system/usr/keychars/gpio-keys.kcm << "EOF"' >> " + script + "\n");
os.writeBytes("echo 'type FULL' >> " + script + "\n");
os.writeBytes("echo 'key DPAD_UP { }' >> " + script + "\n");
os.writeBytes("echo 'key DPAD_DOWN { }' >> " + script + "\n");
os.writeBytes("echo 'key DPAD_LEFT { }' >> " + script + "\n");
os.writeBytes("echo 'key DPAD_RIGHT { }' >> " + script + "\n");
os.writeBytes("echo 'key DPAD_CENTER { }' >> " + script + "\n");
os.writeBytes("echo 'EOF' >> " + script + "\n");
os.writeBytes("echo 'chmod 644 /system/usr/keylayout/gpio-keys.kl' >> " + script + "\n");
os.writeBytes("echo 'chmod 644 /system/usr/keychars/gpio-keys.kcm' >> " + script + "\n");
os.writeBytes("echo 'echo FIX_DONE' >> " + script + "\n");
os.writeBytes("chmod 755 " + script + "\n");
os.writeBytes("sh " + script + "\n");
os.flush();
// 等待脚本完成信号
String line;
while ((line = is.readLine()) != null) {
if ("FIX_DONE".equals(line.trim())) break;
}
os.writeBytes("reboot\n");
os.flush();
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
} finally {
if (su != null) su.destroy();
}
}
6.3 调用逻辑
java
if (KeyMappingFixer.needsFix()) {
KeyMappingFixer.fixDpadMapping(); // 修复后自动重启
} else {
// 已修复,直接通过底层注入发送事件
}
七、关键踩坑与认知纠正
7.1 getevent -p 不显示新按键,是正常的
getevent -p 读取的是内核驱动声明 的能力列表(Bitmask)。.kl 和 .kcm 是 Android 框架层的软件配置,修改它们不会改变内核声明。
我们注入的 204~208 原本不在内核列表中,但部分系统对 Root 直接写入节点的事件网开一面,不做严格校验,因此依然能成功流转到上层。
7.2 input keyevent vs 底层注入
input keyevent 19:走 Android 高层通道,直接注入KEYCODE_DPAD_UP,不经过.kl映射,也不需要扫描码。printf > /dev/input/event0/dd:走内核底层通道,必须提供正确的扫描码,由系统根据.kl翻译成 Keycode。
如果目的是"让 App 以为硬件按下了方向键",两者均可;如果目的是测试自定义映射是否生效,必须用底层注入。
7.3 文件权限必须严格
.kl 和 .kcm 如果不是 644,InputReader 在加载时可能会静默忽略,导致映射不生效。这是最容易被遗漏的细节。
7.4 为什么用 dd 而不是直接 printf > /dev/input/event0
在某些内核版本或设备驱动上,printf 直接重定向到字符设备节点会被当作文本流处理,导致字节对齐错误或缓冲问题。dd 以块设备方式写入,明确指定 bs=24,能确保每次恰好写入一个完整的 input_event 结构体,稳定性更高。
八、总结
通过本次改造,我们完成了一件看似不可能的事:
在硬件层面只有 7 个按键、且没有
sendevent的 Android 设备上,利用.kl+.kcm映射 +printf/dd底层二进制注入,成功"凭空捏造"出了 D-Pad 方向键和字母输入能力。
核心收获:
- 理解分层 :内核上报扫描码 →
.kl翻译为 Keycode →.kcm翻译为字符。 - 善用 Root :有 Root 权限时,
/dev/input/eventX就是可以读写的普通文件;printf+dd可以完美替代缺失的sendevent。 - 工程化思维 :复杂 Shell 操作通过"生成临时脚本"中转,远比在 Java 里拼字符串稳定;命令末尾的
\n是DataOutputStream驱动 Shell 执行的关键。 - 两步式注入 :
printf生成临时文件 →dd bs=24写入设备,是底层注入最稳妥的方案。