兄弟们,做 RK3588 嵌入式 AI 开发的时候,是不是经常有这种感觉:NPU 跑 YOLOv8 的资料一抓一大把,模型部署、TensorRT 加速、RKNN 转换……这些都有教程。但真到了把设备做成一个能交互的实物时,反而卡住了——用户按键怎么接?按一下触发一次识别?USB 键盘插上去怎么读取按键?网上资料零散得让人头疼,而且大多只讲理论,不讲怎么落地。
这篇文章我就把“RK3588 用户按键 + USB 键盘编程”这条线完整串一遍,从硬件引脚怎么看、GPIO 事件怎么读、去抖怎么做,到 USB 键盘在 Linux 下怎么被识别、怎么用 libevdev 读取按键事件,最后落到一个实际场景:用按键触发 NPU 推理,用键盘切换不同的 AI 识别模式。整个过程从零开始,新手能跟着搭起来,有经验的开发者也能直接跳到自己关心的部分看代码和坑点。
1. RK3588 与输入设备:AI 开发里最容易忽略的一环
1.1 RK3588 到底是什么
RK3588 是瑞芯微推出的一款高性能八核 SoC,CPU 部分采用 4 个 Cortex-A76 大核加 4 个 Cortex-A55 小核的架构,主频最高能到 2.4GHz 左右。相比很多老款 ARM 开发板,它最突出的地方在于集成了一颗算力可达 6 TOPS 的 NPU,可以直接在板端跑 YOLOv8、ResNet、OCR 这类常见模型,不需要把数据传到云端,所以特别适合做边缘 AI 盒子、智能摄像头、机器人主控板、工业视觉检测设备这些场景。
瑞芯微官方提供了 RKNN-Toolkit2 工具链,可以把 PyTorch、ONNX、TensorFlow 等格式的模型转换成 RKNN 格式,再通过 RKNN Runtime 在 NPU 上加速推理。这也是近两年 RK3588 在嵌入式 AI 领域热度居高不下的核心原因。
1.2 为什么输入设备编程被严重低估
很多入门教程把 RK3588 当成一个“纯计算平台”,上来就教你跑模型,却忽略了任何 AI 设备都绕不开“人机交互”这四个字。你做一个边缘计算盒子,总得有个按钮让用户启动识别;你做一个机器人小车,总得有按键切换运行模式;你调试程序的时候,USB 键盘更是比网络 SSH 直连更直接、更稳定的交互方式。
用户按键和 USB 键盘的底层实现路径是不同的:
- 用户按键走的是 GPIO,属于 SoC 的通用输入输出引脚,读取的是电平变化,代码上要处理“按下”“释放”“去抖”这些事情。
- USB 键盘走的是 HID 协议,设备插入后由 Linux 内核的 usbhid 驱动接管,应用层通过输入子系统(input subsystem)读取标准化的事件,例如按键码 KEY_A、KEY_ENTER。
把这两类输入方式都掌握了,你在 RK3588 上的交互开发基本就通了。
1.3 本篇的学习路径
我按下面这条路线组织全文,你可以按顺序学,也可以直接跳章查代码:
- 环境准备与硬件接线,搞清楚 GPIO 编号和输入设备节点。
- 用户按键的 GPIO 编程,涉及 libgpiod 和事件去抖。
- USB 键盘的事件读取,涉及 evdev 和 libevdev。
- 把输入事件接入 AI 推理,做一个按键触发模型识别的小系统。
- 常见问题排查与工程化最佳实践。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与硬件连接
2.1 硬件清单
本文的实操部分需要以下硬件:
| 硬件 | 说明 |
|---|---|
| RK3588 开发板 | 香橙派 5、ROC-RK3588S-PC、飞凌 OK3588-C 等均可,系统需为 Ubuntu/Debian 类 Linux |
| 按键模块 | 自锁或不自锁轻触开关均可,推荐带 10kΩ 电阻的按键模块,接线更简单 |
| 杜邦线 | 母对母、公对母各备几根 |
| USB 键盘 | 标准 USB HID 键盘即可,有线无线无所谓 |
| TF 卡或 SSD | 用于刷写系统,建议 32GB 以上 |
| 电源 | 开发板要求一般为 12V/2A 或 12V/3A,按板子说明配置,不要用手机充电头凑合 |
如果你手头的板子和我列举的不完全一样,不要慌。RK3588 系列的 GPIO 控制器资源是统一的,只要系统能正常识别 gpiochip,原理和代码都可以复用,只是具体引脚编号不同。
2.2 系统与开发库安装
开发板刷好 Ubuntu 系统后,先更新软件源,再安装 GPIO 和输入子系统相关的开发库:
bash复制sudo apt update
sudo apt upgrade -y
sudo apt install -y gpiod libgpiod-dev libevdev-dev evtest build-essential git
这里逐个解释一下用到的工具和库:
gpiod:包含gpiodetect、gpioinfo、gpioget、gpioset等命令行工具,用来查看和操作 GPIO。libgpiod-dev:GPIO 字符设备接口的 C 库,替代老旧的 sysfs GPIO 接口。libevdev-dev:Linux 输入子系统(evdev)的封装库,读取键盘鼠标事件非常方便。evtest:命令行工具,可以直接查看/dev/input/eventX设备上报的事件,是排查 HID 设备问题的神器。build-essential:提供 gcc、make 等编译工具链。
如果你更喜欢在电脑上交叉编译,可以安装 aarch64-linux-gnu-gcc,但 RK3588 板子性能足够好,直接在板子上编译反而省事,所以本文统一采用板载编译。
2.3 查看 GPIO 控制器与引脚编号
RK3588 内部有 5 组 GPIO 控制器,分别是 GPIO0 到 GPIO4。每组控制器在 Linux 中通常注册为一个 gpiochip,但不是每组都有 32 个引脚,部分引脚被复用为其他功能。查看当前系统识别到的 GPIO 控制器:
bash复制gpiodetect
输出类似:
code复制gpiochip0 [rockchip-gpio0] (32 lines)
gpiochip1 [rockchip-gpio1] (32 lines)
gpiochip2 [rockchip-gpio2] (32 lines)
gpiochip3 [rockchip-gpio3] (32 lines)
gpiochip4 [rockchip-gpio4] (32 lines)
gpiochip5 [rk805-gpio] (1 lines)
再看每个 chip 里每条 line 的当前状态:
bash复制gpioinfo gpiochip0
输出会列出每个 line 的偏移量(0 到 31)、是否已被占用、当前电平方向等信息。
这里需要注意一个坑:不同开发板对 GPIO 引脚的丝印标注不同。有的板子丝印写着 GPIO1_A2,对应的内核编号可能不是简单的 32 + 2,因为 RK3588 的 bank 命名有 A/B/C/D 四组,每组 8 个引脚,内核 line 偏移量计算方式为:
text复制bank 内偏移量 = 组编号 × 8 + 引脚序号
例如 GPIO1_A2,就是 gpiochip1 的偏移量 0×8+2=2;GPIO2_C4,就是 gpiochip2 的偏移量 2×8+4=20。但具体到你的板子,丝印、原理图、内核设备树三者可能对不上,所以最稳妥的方式是先用 gpioinfo 确认物理引脚对应的 chip 和 line,也可以直接用 gpioset 对一个怀疑的引脚输出高电平,然后用万用表或 LED 验证。
3. 用户按键的 GPIO 编程基础
3.1 别再用了:sysfs GPIO 接口
在 Linux 4.8 之前,操作 GPIO 最常用的方式是 sysfs 接口,即访问 /sys/class/gpio/ 目录下的文件。你写 echo 38 > export,然后往 direction 文件写入 in,再读 value 文件获取电平。
这套接口的问题很明显:效率低、并发控制弱、还要在用户态反复读写文件,而且内核社区已经明确把它标记为 deprecated(废弃)。在较新的内核版本中,RK3588 的默认系统往往已经关闭了 CONFIG_GPIO_SYSFS,你根本找不到 /sys/class/gpio 目录。所以本文不再展开 sysfs 用法,直接用新标准的字符设备接口。
3.2 新一代操作方式:libgpiod
libgpiod 是 GPIO 字符设备接口的封装库,核心思路是把每个 GPIO 控制器看作一个 chip,把每个引脚看作一条 line。整个编程模型围绕以下对象:
| 对象 | 对应概念 |
|---|---|
| gpiod_chip | 一个 GPIO 控制器,对应 /dev/gpiochipN |
| gpiod_line | 控制器下的一条引脚 |
| gpiod_line_request | 请求占用某条引脚并设置方向、事件类型 |
命令行工具里也包含了最常用的操作。比如读取某个引脚的当前电平:
bash复制# 读取 gpiochip1 第 2 号引脚的当前电平
gpioget gpiochip1 2
输出 1 表示高电平,0 表示低电平。临时把一个引脚输出高电平驱动 LED:
bash复制# 输出方向,设置高电平
gpioset gpiochip1 2=1
但这些命令只是入门。真实项目中,我们需要在 C 程序里等待按键按下事件,而不是手动敲命令。下面进入代码实战。
3.3 轮询与中断事件的本质区别
很多新手会写一个死循环反复读取 GPIO 电平,这就是轮询(polling)。轮询简单,但有两个问题:一是 CPU 占用高,二是按键按下的时间很短,如果主线程正在跑 AI 推理,轮询跟不上就会丢事件。
更合理的做法是使用事件机制:内核检测到 GPIO 电平变化后,通过中断通知应用程序,应用用 poll() 或 select() 等待事件。这样 GPIO 空闲时 CPU 完全零占用,而且事件不会因为应用处理慢而丢失太多。libgpiod 提供的事件接口就是把这种内核机制封装给用户态使用。
4. 实战:用户按键检测与去抖程序
4.1 按键硬件接线
我以一个常见的轻触按键模块为例(模块上一般自带上拉电阻和指示灯):
text复制按键模块 RK3588 开发板
VCC --------> 3.3V
GND --------> GND
SIG --------> GPIO1_A2(示例引脚,请以你的板子为准)
这里使用的是“按下为低电平”的接法。因为 GPIO 内部配置为上拉输入,平时引脚为高电平,按下按键后信号被拉低,检测到下降沿就认为有按键动作。
接线提醒:先断电接线,不要带电插拔,RK3588 的 GPIO 耐压有限,误接 12V 有可能直接烧掉引脚甚至整个 SoC。这一点怎么强调都不过分。
4.2 编写 GPIO 事件读取程序
用 libgpiod 的 C API 编写一个按键事件监听程序。文件路径 ~/rk3588_ai_input/gpio_key.c:
c复制// 文件路径:~/rk3588_ai_input/gpio_key.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>
#include <gpiod.h>
// 示例引脚:gpiochip1 line 2,请根据你的板子调整
#define GPIO_CHIP_NAME "gpiochip1"
#define GPIO_LINE_OFFSET 2
int main(void)
{
struct gpiod_chip *chip;
struct gpiod_line *line;
struct pollfd pfd;
int ret;
chip = gpiod_chip_open_by_name(GPIO_CHIP_NAME);
if (!chip) {
perror("gpiod_chip_open_by_name");
return EXIT_FAILURE;
}
line = gpiod_chip_get_line(chip, GPIO_LINE_OFFSET);
if (!line) {
perror("gpiod_chip_get_line");
gpiod_chip_close(chip);
return EXIT_FAILURE;
}
// 请求 GPIO 引脚作为输入,并监听双边沿事件(上升沿和下降沿)
ret = gpiod_line_request_both_edges_events(line, "gpio-key-demo");
if (ret < 0) {
perror("gpiod_line_request_both_edges_events");
gpiod_chip_close(chip);
return EXIT_FAILURE;
}
printf("Waiting for GPIO %s line %d events...\n",
GPIO_CHIP_NAME, GPIO_LINE_OFFSET);
pfd.fd = gpiod_line_get_fd(line);
pfd.events = POLLIN;
for (;;) {
ret = poll(&pfd, 1, -1);
if (ret < 0) {
perror("poll");
break;
}
if (pfd.revents & POLLIN) {
struct gpiod_line_event ev;
ret = gpiod_line_event_read(line, &ev);
if (ret < 0) {
perror("gpiod_line_event_read");
continue;
}
if (ev.event_type == GPIOD_LINE_EVENT_RISING_EDGE) {
printf("[%ld.%06ld] Key released (high level)\n",
ev.ts.tv_sec, ev.ts.tv_nsec / 1000);
} else if (ev.event_type == GPIOD_LINE_EVENT_FALLING_EDGE) {
printf("[%ld.%06ld] Key pressed (low level)\n",
ev.ts.tv_sec, ev.ts.tv_nsec / 1000);
}
}
}
gpiod_line_release(line);
gpiod_chip_close(chip);
return EXIT_SUCCESS;
}
这段代码的核心逻辑:
- 打开
/dev/gpiochip1对应的 chip。 - 获取该 chip 上偏移量为 2 的 line。
- 调用
gpiod_line_request_both_edges_events请求同时监听上升沿和下降沿事件。 - 调用
gpiod_line_get_fd拿到该 line 的事件文件描述符,放进poll()里等待。 - 事件到达后用
gpiod_line_event_read读取事件结构体,其中包含事件类型和精确到纳秒的时间戳。
这里很关键的一个设计是:事件文件描述符被交给 poll() 管理,所以这个循环不会忙等空转,CPU 占用几乎为 0。
编译运行:
bash复制mkdir -p ~/rk3588_ai_input
cd ~/rk3588_ai_input
gcc -o gpio_key gpio_key.c -lgpiod
sudo ./gpio_key
按下按键,终端会输出类似内容:
text复制Waiting for GPIO gpiochip1 line 2 events...
[1719032400.123456] Key pressed (low level)
[1719032400.240102] Key released (high level)
Ctrl+C 退出程序。
4.3 按键去抖:为什么不能省
如果你按下按键,发现终端有时打印一次,有时打印两次甚至三次,这就是抖动(bounce)。机械按键在按下和释放瞬间,金属触点会因弹性碰撞产生多次通断,持续时间通常为 5ms 到 20ms 不等。如果不做处理,一次物理按压会被内核检测成多次跳变事件。
去抖思路一般是“事件到来后先不立即响应,而是等待一段时间,确认电平稳定后再输出”。下面在原有代码基础上增加一个简化去抖逻辑:
c复制// 文件路径:~/rk3588_ai_input/gpio_key_debounce.c
#include <stdio.h>
#include <stdlib.h>
#include <poll.h>
#include <gpiod.h>
#define GPIO_CHIP_NAME "gpiochip1"
#define GPIO_LINE_OFFSET 2
#define DEBOUNCE_MS 20
int main(void)
{
struct gpiod_chip *chip;
struct gpiod_line *line;
struct pollfd pfd;
int ret;
chip = gpiod_chip_open_by_name(GPIO_CHIP_NAME);
if (!chip) {
perror("gpiod_chip_open_by_name");
return EXIT_FAILURE;
}
line = gpiod_chip_get_line(chip, GPIO_LINE_OFFSET);
if (!line) {
perror("gpiod_chip_get_line");
gpiod_chip_close(chip);
return EXIT_FAILURE;
}
ret = gpiod_line_request_both_edges_events(line, "gpio-key-debounce");
if (ret < 0) {
perror("gpiod_line_request_both_edges_events");
gpiod_chip_close(chip);
return EXIT_FAILURE;
}
printf("Press key... (Ctrl+C to quit)\n");
pfd.fd = gpiod_line_get_fd(line);
pfd.events = POLLIN;
for (;;) {
ret = poll(&pfd, 1, -1);
if (ret < 0) {
perror("poll");
break;
}
if (pfd.revents & POLLIN) {
struct gpiod_line_event ev;
if (gpiod_line_event_read(line, &ev) < 0) {
perror("gpiod_line_event_read");
continue;
}
// 事件到达后,先等待 20ms,再确认当前电平
struct pollfd tmp_pfd;
tmp_pfd.fd = gpiod_line_get_fd(line);
tmp_pfd.events = POLLIN;
ret = poll(&tmp_pfd, 1, DEBOUNCE_MS);
if (ret > 0) {
// 20ms 内又来了新事件,说明是抖动,忽略本次
continue;
}
int val = gpiod_line_get_value(line);
if (ev.event_type == GPIOD_LINE_EVENT_FALLING_EDGE) {
printf("[%ld.%06ld] Key pressed, stable low\n",
ev.ts.tv_sec, ev.ts.tv_nsec / 1000);
} else if (ev.event_type == GPIOD_LINE_EVENT_RISING_EDGE) {
printf("[%ld.%06ld] Key released, stable high\n",
ev.ts.tv_sec, ev.ts.tv_nsec / 1000);
}
}
}
gpiod_line_release(line);
gpiod_chip_close(chip);
return EXIT_SUCCESS;
}
这个去抖版本的逻辑是:收到第一次事件后,用 poll() 带 20ms 超时再等一次事件。如果 20ms 内又出现新事件,说明这是触点抖动导致的不稳定过程,直接丢弃当前事件;如果 20ms 后电平稳定,再输出真正有效的按键事件。
实际产品中,更严谨的做法是在内核设备树里配置 debounce 属性,或者在硬件上并联一个 RC 滤波电路。但软件去抖只要参数合理,也能满足绝大多数交互需求。
4.4 编译与运行验证
bash复制gcc -o gpio_key_debounce gpio_key_debounce.c -lgpiod
sudo ./gpio_key_debounce
快速按下按键,正常预期是每次按压只输出一次“Key pressed”,松开只输出一次“Key released”。如果你看到一次按压输出多个事件,可以适当加大 DEBOUNCE_MS 的值,比如调到 30 或者 50。但也不要调得过大,否则会影响快速连按的响应。
5. USB 键盘 HID 事件读取
5.1 键盘在 Linux 中如何暴露
USB 键盘插入 RK3588 开发板后,由内核的 USB HID 驱动处理,设备会注册为一个 evdev 输入设备,应用层可以在 /dev/input/ 下看到对应的节点文件。常见节点包括:
/dev/input/event0到eventX/dev/input/js0(游戏手柄)/dev/input/mice(鼠标)
查看所有输入设备:
bash复制ls -l /dev/input/
cat /proc/bus/input/devices
/proc/bus/input/devices 输出里能看到每个设备的名字、物理连接路径、事件类型等信息。找到键盘对应的行,例如:
text复制I: Bus=0003 Vendor=1234 Product=5678 Version=0111
N: Name="USB Keyboard"
P: Phys=usb-0000:01:00.0-1.2/input0
H: Handlers=keyboard event2
B: EV=120013
其中 Handlers=keyboard event2 表示这个设备的事件节点是 /dev/input/event2。
先用 evtest 测试一下,确认键盘事件能正常上报:
bash复制sudo evtest /dev/input/event2
按下字母键 A,终端会输出类似:
text复制Event: time 1719032400.123456, type 4 (EV_MSC), code 4 (MSC_SCAN), value 30
Event: time 1719032400.123456, type 1 (EV_KEY), code 30 (KEY_A), value 1
Event: time 1719032400.123456, type 0 (EV_SYN), code 0 (SYN_REPORT), value 0
这三行对应一次完整的按键事件:
- EV_MSC 是扫描码,通常可以忽略。
- EV_KEY 是主事件,code 30 表示 KEY_A,value 1 表示按下;value 0 表示释放;value 2 表示长按自动重复。
- EV_SYN 是同步事件,告诉应用层上面几行属于同一组输入快照。
建议你现在就用 evtest 把代码和按键名对应关系记下来,因为后面写代码时要根据 key code 判断按了哪个键。
5.2 用 libevdev 读取键盘事件
直接读取 /dev/input/eventX 原始结构体也能实现,但 libevdev 帮你处理了设备能力查询、事件类型名称映射、版本兼容等问题,代码更简洁。下面写一个从键盘读取按键并打印的 C 程序:
c复制// 文件路径:~/rk3588_ai_input/usb_key.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <libevdev/libevdev.h>
int main(int argc, char *argv[])
{
struct libevdev *dev = NULL;
const char *path = "/dev/input/event2"; // 按你的实际设备节点修改
int fd;
int rc;
if (argc > 1) {
path = argv[1];
}
fd = open(path, O_RDONLY | O_NONBLOCK);
if (fd < 0) {
perror("open input device");
return EXIT_FAILURE;
}
rc = libevdev_new_from_fd(fd, &dev);
if (rc < 0) {
fprintf(stderr, "Failed to init libevdev: %s\n", libevdev_strerror(rc));
close(fd);
return EXIT_FAILURE;
}
printf("Device name: %s\n", libevdev_get_name(dev));
printf("Reading key events... (Ctrl+C to quit)\n");
for (;;) {
struct input_event ev;
rc = libevdev_next_event(dev, LIBEVDEV_READ_FLAG_NORMAL, &ev);
if (rc == LIBEVDEV_READ_STATUS_SUCCESS) {
if (ev.type == EV_KEY) {
const char *key_name = libevdev_event_code_get_name(EV_KEY, ev.code);
if (ev.value == 1) {
printf("Key down: %s (code %d)\n",
key_name ? key_name : "unknown", ev.code);
} else if (ev.value == 0) {
printf("Key up: %s (code %d)\n",
key_name ? key_name : "unknown", ev.code);
}
}
} else if (rc == LIBEVDEV_READ_STATUS_SYNC) {
// 设备事件队列同步,一般用于状态同步,这里忽略
} else if (rc == -EAGAIN) {
// 非阻塞模式下没有新事件,短暂让出 CPU
usleep(1000);
} else {
perror("libevdev_next_event");
break;
}
}
libevdev_free(dev);
close(fd);
return EXIT_SUCCESS;
}
编译运行:
bash复制gcc -o usb_key usb_key.c -levdev
sudo ./usb_key /dev/input/event2
按下键盘上的 A、B、Enter、方向键,终端会打印对应的 key name 和 code。这个打印结果后面会非常有用。
5.3 非阻塞读取与超时控制
上面代码故意用了 O_NONBLOCK,配合 usleep(1000) 在无事件时让出 CPU。这样做的好处是:如果程序里同时需要读取 GPIO 按键和 USB 键盘,可以放在同一个线程的循环里,通过 poll() 统一监听两路事件。
如果不需要同时监听多路事件,也可以改用阻塞模式直接等待,代码更简单。但在 AI 应用里,输入线程往往还需要接收退出命令、统计超时逻辑,所以非阻塞加 poll 的方式更通用。
6. 把输入事件接入 AI 推理任务
6.1 设计思路:按键触发,键盘切换模式
前面两章的代码都是独立的输入读取程序。现在进入真正的嵌入式 AI 开发场景:假设我们正在做一个 RK3588 边缘识别盒子,需要实现两个功能:
- 用户按下开发板上的物理按键,立即执行一次 YOLOv8 目标检测,打印检测结果。
- 用户通过 USB 键盘按下数字键 1/2/3,切换推理模式,例如 1 表示检测人、2 表示检测车、3 表示检测全部目标。
这里的关键设计是:输入事件和推理任务必须解耦。不要在 AI 推理的循环里面去轮询按键状态,否则模型单次推理可能耗时几十到几百毫秒,按键事件在推理期间到达但得不到及时响应,体验很差。正确的做法是用一个单独的输入线程负责监听 GPIO 和键盘,事件到来后通过全局变量或消息队列通知推理线程。
线程模型的简化示意图:
text复制输入线程(GPIO + 键盘)
|
| 事件到达
v
全局状态变量 / 线程安全队列
|
v
AI 推理线程(RKNN 推理循环)
|
v
串口 / 日志 / 屏幕输出结果
6.2 Python 快速方案:通过调用 C 程序实现
如果你不想用 C 从头写完整应用,还有一个更快的方案:底层的 GPIO 和键盘读取用 C 程序先测通,业务逻辑用 Python 实现,C 程序把事件写入标准输出或者命名管道,Python 用 subprocess 读取。
这种方案的优点:
- Python 调用 RKNN 推理库更简单,模型加载、后处理、显示都用现成库。
- 底层输入事件采集用 C 更稳定,不容易丢事件。
- 开发和迭代速度快,适合验证原型。
下面给一个 Python 版主控脚本的思路:
python复制# 文件路径:~/rk3588_ai_input/ai_controller.py
import subprocess
import threading
import queue
# 按键与键盘事件通过管道传给 Python
event_queue = queue.Queue()
def read_event_stream():
proc = subprocess.Popen(
["./event_bridge"], # 一个同时监听GPIO和键盘的C桥接程序
stdout=subprocess.PIPE,
text=True
)
for line in proc.stdout:
event_queue.put(line.strip())
def inference_loop():
while True:
try:
evt = event_queue.get(timeout=0.05)
except queue.Empty:
continue
if evt.startswith("KEY_PRESS"):
print("Trigger inference now")
# 在这里调用 RKNN 推理,例如 rknn.inference(inputs=...)
# 打印检测结果
elif evt.startswith("MODE_"):
mode = evt.split("_")[1]
print(f"Switch detection mode to {mode}")
# 更新全局推理模式
if __name__ == "__main__":
input_thread = threading.Thread(target=read_event_stream, daemon=True)
input_thread.start()
inference_loop()
这个脚本把输入采集与推理逻辑分离,event_bridge 可以理解为一个把前两章代码合并后的 C 程序,输出固定格式文本,例如:
text复制KEY_PRESS
KEY_RELEASE
MODE_1
MODE_2
MODE_3
这样做的好处是,AI 推理的代码不会再和 GPIO/键盘底层逻辑纠缠在一起,出问题也容易排查。
6.3 完整 C 事件桥接程序
为了方便理解,我把同时监听 GPIO 按键和 USB 键盘的核心框架写出来。这个程序使用 poll() 统一管理两个文件描述符:一个是 GPIO 事件 fd,一个是 evdev 键盘的 fd。
c复制// 文件路径:~/rk3588_ai_input/event_bridge.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <poll.h>
#include <string.h>
#include <gpiod.h>
#include <libevdev/libevdev.h>
#define GPIO_CHIP "gpiochip1"
#define GPIO_LINE 2
#define KBD_PATH "/dev/input/event2"
static int key_event_fd = -1;
static void handle_gpio_event(struct gpiod_line *line)
{
struct gpiod_line_event ev;
if (gpiod_line_event_read(line, &ev) < 0) {
return;
}
if (ev.event_type == GPIOD_LINE_EVENT_FALLING_EDGE) {
printf("KEY_PRESS\n");
fflush(stdout);
} else if (ev.event_type == GPIOD_LINE_EVENT_RISING_EDGE) {
printf("KEY_RELEASE\n");
fflush(stdout);
}
}
static void handle_keyboard_event(struct libevdev *dev)
{
struct input_event ev;
int rc = libevdev_next_event(dev, LIBEVDEV_READ_FLAG_NORMAL, &ev);
if (rc != LIBEVDEV_READ_STATUS_SUCCESS) {
return;
}
if (ev.type == EV_KEY && ev.value == 1) {
switch (ev.code) {
case KEY_1:
printf("MODE_1\n");
break;
case KEY_2:
printf("MODE_2\n");
break;
case KEY_3:
printf("MODE_3\n");
break;
default:
printf("KEY_UNKNOWN code=%d\n", ev.code);
break;
}
fflush(stdout);
}
}
int main(void)
{
struct gpiod_chip *chip;
struct gpiod_line *line;
struct libevdev *dev;
int kbd_fd;
int ret;
// 初始化 GPIO
chip = gpiod_chip_open_by_name(GPIO_CHIP);
if (!chip) {
perror("gpiod_chip_open_by_name");
exit(EXIT_FAILURE);
}
line = gpiod_chip_get_line(chip, GPIO_LINE);
if (!line) {
perror("gpiod_chip_get_line");
exit(EXIT_FAILURE);
}
ret = gpiod_line_request_both_edges_events(line, "event-bridge");
if (ret < 0) {
perror("gpiod_line_request_both_edges_events");
exit(EXIT_FAILURE);
}
key_event_fd = gpiod_line_get_fd(line);
// 初始化键盘 evdev
kbd_fd = open(KBD_PATH, O_RDONLY | O_NONBLOCK);
if (kbd_fd < 0) {
perror("open keyboard");
exit(EXIT_FAILURE);
}
ret = libevdev_new_from_fd(kbd_fd, &dev);
if (ret < 0) {
fprintf(stderr, "libevdev_new_from_fd: %s\n", libevdev_strerror(ret));
exit(EXIT_FAILURE);
}
printf("Event bridge ready. Press GPIO key or keyboard 1/2/3.\n");
struct pollfd fds[2];
fds[0].fd = key_event_fd;
fds[0].events = POLLIN;
fds[1].fd = kbd_fd;
fds[1].events = POLLIN;
for (;;) {
ret = poll(fds, 2, -1);
if (ret < 0) {
perror("poll");
break;
}
if (fds[0].revents & POLLIN) {
handle_gpio_event(line);
}
if (fds[1].revents & POLLIN) {
handle_keyboard_event(dev);
}
}
gpiod_line_release(line);
gpiod_chip_close(chip);
libevdev_free(dev);
close(kbd_fd);
return 0;
}
编译:
bash复制gcc -o event_bridge event_bridge.c -lgpiod -levdev
sudo ./event_bridge
按下物理按键和键盘数字键,观察输出。这个程序就是后面 Python 推理脚本的底层输入源。
6.4 与 RKNN 推理结合的运行效果
把 event_bridge 和 Python 推理脚本组合后,你的 RK3588 设备就变成了一个可交互的 AI 设备:
- 按一下物理按键,当前摄像头画面立刻送入 NPU 做一次 YOLOv8 检测,结果叠加到画面上。
- 按键盘
1,模型只检测 person 类,别的类别全部过滤掉。 - 按键盘
2,切换到车辆检测模式。 - 按键盘
3,恢复全类别检测。
这个架构的价值在于,它把“用户输入”和“AI 计算”彻底分层。以后你想把按键换成触摸屏,只需要替换输入层;想换更快的模型,只需要改推理层的 RKNN 模型加载,互不影响。
7. 常见问题与排查思路
把我在实际使用中遇到的典型问题整理成一个表格,方便你快速对照:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
gpiodetect 看不到 gpiochip |
内核 GPIO 驱动未加载,或设备树禁用了相应控制器 | 检查设备树中 gpio 节点状态;确认是否被其他外设复用;更新内核和设备树 |
| 按键无反应,程序一直等待 | 引脚编号配错,或按键接的是高电平触发 | 用 gpioget 手动读取引脚电平,按下按键观察电平是否变化;用 gpioinfo 核对偏移量 |
| 一次按键触发多次事件 | 缺少去抖,或去抖时间太短 | 增加软件去抖时间到 20~30ms;硬件上并联 104 电容 |
| 按键按下后系统误进入休眠或唤醒 | GPIO 被配置为唤醒源 | 检查设备树中 GPIO 的 wakeup-source 属性,按需移除 |
USB 键盘插入后无 /dev/input/eventX |
usbhid 驱动未加载,或键盘损坏 | dmesg 查看内核日志;lsusb 确认 USB 设备是否被识别;重新插拔 |
| evtest 能读到键盘事件,但自己的 C 程序读不到 | 设备节点被 evtest 独占,或程序打开的节点不对 | 先退出 evtest;用 cat /proc/bus/input/devices 确认键盘实际节点 |
| 键盘事件能收到,但没有 KEY_1/KEY_2 | 数字键在小键盘和主键盘的 code 不同,或键盘映射被修改 | 用 evtest 直接查看按键 code;小键盘 KEY_KP1 和主键盘 KEY_1 是不同 code |
| 推理期间按键响应明显变慢 | 输入线程和推理线程共用同一个 CPU 核,或输入事件在推理循环里轮询 | 把输入线程绑定到大核,推理线程绑定到小核或专用线程;改用事件驱动而非轮询 |
| poll 同时监听 GPIO 和键盘时,键盘事件偶尔丢失 | 键盘事件是上报到 evdev 环形缓冲区的,应用读取不及时会覆盖 | 提升输入线程优先级;尽快消费事件,不要在输入线程里做推理 |
编译程序时报找不到 gpiod.h 或 libevdev.h |
没有安装 libgpiod-dev 或 libevdev-dev |
执行 sudo apt install -y libgpiod-dev libevdev-dev |
最值得单独强调的一点是:先打日志,再谈功能。接入任何输入设备,第一步永远是用 evtest、gpioget、gpioinfo 这些系统工具确认设备本身能工作,然后再写业务代码。跳过这一步直接写大程序,最后往往要在“代码逻辑 bug、引脚配置 bug、驱动 bug”三个层面反复排查,效率非常低。
8. 工程化最佳实践与安全建议
8.1 GPIO 编号与引脚映射管理
不要把 GPIO 编号散落在 C 代码的 #define 里,尤其是产品还要适配不同批次的主板时。推荐的做法:
- 设备树中给 GPIO 定义别名,例如
gpio-key、gpio-led-red。 - 应用层读取设备树或配置文件,而不是硬编码 chip 和 line 号。
- 为不同型号主板维护独立的引脚映射表。
一个简单的映射表示例:
yaml复制# 文件路径:~/rk3588_ai_input/board_config.yaml
board_model: "rk3588-demo"
gpio:
user_key:
chip: "gpiochip1"
line: 2
active_low: true
keyboard:
device_path: "/dev/input/event2"
mode1_key: "KEY_1"
mode2_key: "KEY_2"
程序启动时读取配置,即使换了主板,也只改配置文件,不重新编译。
8.2 线程模型与实时性
RK3588 的 8 个核心不是完全对称的,4 个 A76 大核性能强,4 个 A55 小核更省电。在多线程程序设计时,可以把输入线程绑定到某个 A55 小核,因为输入事件频率低,不需要太高算力;把推理线程绑定到 A76 大核。这样既保证输入事件及时响应,又不让推理任务抢占输入线程的 CPU 时间。
pthread_setaffinity_np 可以设置线程 CPU 亲和性:
c复制// 示例:把当前线程绑定到 CPU 核 4(具体编号以系统为准)
#include <sched.h>
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(4, &cpuset);
pthread_setaffinity_np(pthread_self(), sizeof(cpuset), &cpuset);
需要注意的是,RK3588 的 CPU 核编号在不同内核版本上可能不同,有的从 0 到 7,有的启用了 big.LITTLE 调度后会绑定到不同 cpufreq 策略组。绑定之前先看 /sys/devices/system/cpu/online 确认可用核。
8.3 安全边界与权限管理
在 RK3588 上操作 GPIO 和读取 /dev/input/eventX 都需要 root 权限,但产品运行环境不应该一直用 root 跑业务。更规范的做法:
- 创建一个专用用户,比如
aiuser。 - 用 udev 规则给 GPIO 字符设备和 input 设备设置组权限。
- 服务由 systemd 托管,只授予最小权限。
在 /etc/udev/rules.d/99-rk3588-input.rules 中添加:
text复制KERNEL=="gpiochip*", GROUP="gpio", MODE="0660"
KERNEL=="event*", GROUP="input", MODE="0660"
然后把业务用户加入 gpio 和 input 组。
8.4 日志与可观测性
输入设备有个很恶心的特点:偶发问题很难复现。所以日志记录格外重要。
建议在业务代码里输出以下信息:
- 程序启动时打印读取到的板卡型号、GPIO chip/line、键盘设备节点。
- 每次按键事件打印时间戳和事件类型。
- 每次模式切换打印切换前后的模式值。
- 推理线程启动和结束打印耗时统计。
日志格式统一用时间戳加级别:
text复制[2025-06-20 10:15:30.123] [INFO ] board=rk3588-demo gpio=gpiochip1/2
[2025-06-20 10:15:31.002] [EVENT] KEY_PRESS ts=1719032131002
[2025-06-20 10:15:31.050] [INFO ] inference start mode=ALL
[2025-06-20 10:15:31.380] [INFO ] inference done cost=330ms objects=3
8.5 生产环境变更原则
如果你在生产环境的 RK3588 设备上修改 GPIO 配置、更新设备树、换系统镜像,务必遵循下面几条:
- 先在一台测试机上验证,确认引脚配置、驱动版本、应用代码全部正常后再批量升级。
- 改造前备份当前系统镜像,或者至少备份设备树文件和
/boot下的配置文件。 - GPIO 配置变更时,注意是否涉及摄像头、显示屏、串口等已占用的引脚,避免外设冲突。
- 涉及删除旧内核模块或覆盖板级配置时,加一层回滚机制,例如保留旧设备树文件。
- 如果通过远程方式升级设备,确认掉电后设备不会变砖,否则要设计恢复引导方案。
这些建议听起来很基础,但踩过坑的人都知道,RK3588 这类嵌入式板卡一旦在远程设备上配置坏了,恢复成本远高于普通的 x86 服务器。
8.6 功耗与散热
输入设备本身不耗多少电,但 RK3588 跑 AI 推理时发热明显。按键模块、USB 键盘这些都是低压外设,问题不大;要注意的是为开发板提供足够功率的电源。很多奇怪的“按键失灵”“USB 键盘随机断开”问题,最后查出来是电源供电不足导致的,尤其是同时接了屏幕、摄像头、4G 模组和键盘时。建议电源余量至少留 30%。
9. 总结与下一步学习路线
这篇文章从“RK3588 上输入设备怎么开发”这个问题出发,层层拆解了三条主线:用户按键的 GPIO 事件读取、USB 键盘的 HID/evdev 事件读取,以及如何把输入事件以事件驱动的方式接入 AI 推理。你可以照着上面的例子直接跑通一个最小系统,也可以复制 event_bridge 的思路扩展到自己的产品里。
接下来如果你想继续深入,建议按以下方向走:
- 了解 Linux input subsystem 的上层框架:从 evdev 到 input core,再到设备树中
gpio-keys驱动的注册过程。你会发现,内核其实自带按键驱动,设备树配置好之后,用户态拿到的是/dev/input/eventX,并不需要自己操作 GPIO。本文选择直接操作 GPIO,是为了让你理解底层原理,而真实产品里用内核驱动更稳定。 - 学习 RKNN 推理的完整流程:模型转换、量化、后处理、多线程推理,把文章末尾的推理占位代码补全为真正的 YOLOv8 检测。
- 尝试触摸屏和更复杂的 HID 设备:例如通过 USB 接入条形码扫描枪、RFID 读卡器,这类设备本质都是 HID 协议,读取方式和键盘几乎一样。
- 掌握交叉编译与镜像定制:如果产品最终要量产,肯定不能每台设备都现场编译代码,你需要配置交叉编译环境,并把编译好的程序打进根文件系统镜像。
动手是最好的学习方式。建议你今天就把开发板拿出来,先跑通 gpioget 读按键,再跑通 evtest 读键盘,剩下的代码慢慢往里加,一周后你会发现自己对 RK3588 的掌控力完全不一样了。
