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

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 方向键和字母输入能力。

核心收获:

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

相关推荐
三少爷的鞋14 小时前
AI 时代,我们都将成为通才型开发者:只懂 Android,已经不够了
android
Dovis(誓平步青云)18 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
晚风叙码20 小时前
MySQL 数据类型详解:从数值到字符串,一篇讲透
android·mysql·adb
传奇开心果编程20 小时前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
事圆则缓1 天前
Android AOSP 定制常见概念:源码目录、系统镜像与刷机流程
android
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第2课 Compose 基础语法
android·windows·学习·ui·ios·kotlin·composer