Android 底层输入系统改造实录:把 gpio-keys "凭空捏造"成虚拟键盘

Android 底层输入系统改造实录:把 gpio-keys "凭空捏造"成虚拟键盘

在只有 7 个实体按键的 Android 工控设备上,没有 sendevent,没有 getevent,如何让它上层 App 识别出 D-Pad 方向键乃至字母输入?本文记录了一次完整的底层输入系统改造过程。


一、背景与硬件现状

设备型号:光速虚拟机

通过 getevent -pil 查看,系统只有 2 个输入设备

  • /dev/input/event0gpio-keys ------ 仅有 7 个按键
    KEY_HOMEKEY_MUTEKEY_VOLUMEDOWNKEY_VOLUMEUPKEY_POWERKEY_BACKKEY_APPSELECT
  • /dev/input/event1touch ------ 触摸屏

核心矛盾 :上层 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

当通过 DataOutputStreamsu 进程写入命令时,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 方向键和字母输入能力。

核心收获:

  1. 理解分层 :内核上报扫描码 → .kl 翻译为 Keycode → .kcm 翻译为字符。
  2. 善用 Root :有 Root 权限时,/dev/input/eventX 就是可以读写的普通文件;printf + dd 可以完美替代缺失的 sendevent
  3. 工程化思维 :复杂 Shell 操作通过"生成临时脚本"中转,远比在 Java 里拼字符串稳定;命令末尾的 \nDataOutputStream 驱动 Shell 执行的关键。
  4. 两步式注入printf 生成临时文件 → dd bs=24 写入设备,是底层注入最稳妥的方案。

相关推荐
plainGeekDev1 小时前
XML Shape/Selector → Kotlin 动态创建
android·java·kotlin
plainGeekDev1 小时前
Java 自定义 View → Kotlin 自定义 View
android·java·kotlin
码云骑士2 小时前
Android ART运作流程
android
万能小林子2 小时前
如何将网页在线转APP?5种打包工具对比速成指南(含在线/手机/电脑方案)
android·ios·uni-app·web app·wap2app·app打包·app封装
梅塔鲁2 小时前
Kotlin成安卓开发首选
android
zhangphil2 小时前
Android Coil 3 extend ImageRequest‘s custom method/function,Kotlin(2)
android·kotlin
诸神黄昏EX2 小时前
Android 性能优化【篇五:应用启动分析流程】
android
执念、坚持2 小时前
解决 vscode 中导入 android aosp 源码卡顿问题
android·ide·vscode
码云骑士2 小时前
Android ADB常用命令
android·adb