ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RK3288 Android JNI控制GPIO完整实践:从APK到底层引脚

2026/9/9 23:37:28 拓冰建站 浏览量
RK3288 Android JNI控制GPIO完整实践:从APK到底层引脚 简介面向RK3288平台嵌入式开发者的完整JNI控制GPIO工程方案围绕人体感应与门禁控制场景给出从Java层native方法声明、C/C本地实现到Android应用事件处理的全链路代码实践适合对Android底层机制和硬件驱动开发有一定了解的工程师参考。压缩包为rar格式共68个文件约1.73MB内含4个java源文件、2个c源文件、8个so动态库、9张png图片、7个xml配置以及src、libs、gen等标准工程目录和apk安装包工程结构完整便于直接导入分析。已有1307人学习下载。读者可从中学到JNI本地方法的声明与实现细节、RK3288平台GPIO设备节点的访问方式以及APK如何感知人体触发事件并完成门禁控制涵盖从底层驱动到应用层的完整参考路径。 做RK3288安卓单板开发的都知道最磨人的往往不是业务逻辑本身而是应用层怎么和底层硬件打交道。我手头一个项目就是典型板子上几个外设要靠GPIO控制需求方要求必须用APK界面来操作说白了就是界面上放几个按钮按下后能拉高拉低引脚还得能把输入电平读回界面显示。这条链路从Java一路打到硬件中间绕不开JNI这也是嵌入式Android开发里出现频次最高的组合之一rk3288 apk jni gpio。这篇文章我把完整方案拆开讲包括GPIO编号怎么算、NDK环境怎么配、JNI层怎么写才能不踩坑、权限问题到底怎么破以及我在实测里遇到过的几个诡异问题希望能帮你直接跳过那些隐形的坑。1. 项目背景与整体设计思路1.1 需求场景APK、JNI、GPIO三者怎么串起来这个需求在RK3288方案里非常常见。RK3288是Cortex-A17四核芯片经常被拿来做商业显示、工控面板、智能终端之类的Linux/Android主板。外设无非就是LED、继电器、蜂鸣器、按键、光耦信号等等这些最终都落在GPIO上。Android应用跑在ART虚拟机里Java层是碰不到物理寄存器的更不能直接去操作内核管理的资源。GPIO引脚归内核管用户态程序想控制它必须通过内核暴露出来的接口。最常用的老牌接口就是sysfs在/sys/class/gpio目录下通过读写几个文件来控制引脚。但Java写不了这个或者说你总不能真的在Java里用Runtime.exec(echo 1 ...)去拼shell命令那种方案太脆我自己之前试着写过日志一大根本查不动而且每条命令都要拉起一个进程性能也是一言难尽。所以标准做法就是用JNI搭桥Java层调用native方法C/C代码拿到调用参数之后去操作sysfs或寄存器再把结果返回给Java层。数据流大概是这样的APK界面(Java) → JNI 方法调用 → C代码 → 打开/读写sysfs节点 → 内核GPIO子系统 → 引脚电平变化如果读取输入则反过来电平变化 → sysfs value文件 → C代码read → JNI返回 → Java界面刷新。这条链路只要打通一次任何RK3288项目的GPIO控制需求基本都能复用改改引脚编号和外设逻辑就完事。1.2 方案选型对比sysfs、mmap寄存器、字符设备驱动动手之前先别急着写代码控制方案得先定下来。RK3288或者任何Linux板子用户态控制GPIO都有这么几条路我放在一起对比一下控制方案原理权限要求性能适用场景sysfs读写/sys/class/gpio下的export、direction、value等文件需要root或system权限中等每次操作几十微秒到毫秒级官方3.10/4.4内核调试最方便代码最简单mmap /dev/mem用户态直接映射GPIO控制器物理地址操作SWPORT_DR/DDR等寄存器必须root高适合高频翻转需要模拟时序、高速PWM波形等场景内核字符设备驱动自己写驱动注册设备节点JNI层open/ioctl驱动里主动放开节点权限高产品量产、权限可控、最干净的形态结论很明确调试阶段强烈建议先用sysfs因为它直观、代码量小、出了问题好排查。等到产品量产需要严格权限管控的时候再上内核字符驱动。我这次项目选的也是sysfs下面所有代码都围绕这个方案。2. 环境准备与GPIO基础知识2.1 先算对GPIO编号否则后面全白搭RK3288的GPIO不是像单片机那样叫PA0/PB1而是分组管理的。芯片上有GPIO0到GPIO8共9组部分组在标准封装里可能没完全引出每组下面又分A/B/C/D四个小口每个小口8个引脚所以一个引脚的完整命名是GPIOx_Ay这种格式比如GPIO8_A5。在sysfs里每个引脚对应一个全局递增的逻辑编号计算规则很简单GPIOx_Ay x * 32 y GPIOx_By x * 32 8 y GPIOx_Cy x * 32 16 y GPIOx_Dy x * 32 24 y举个例子GPIO8_A5 就是8 * 32 5 261。操作的时候先echo 261 /sys/class/gpio/export之后就会生成/sys/class/gpio/gpio261/这个目录。不同板卡的BSP内核里gpiochip的base有可能不一样一般默认是0但保险起见拿到板子先看一眼adb root adb shell cat /sys/class/gpio/gpiochip*/base adb shell cat /sys/class/gpio/gpiochip*/labellabel字段会直接告诉你是哪个gpio controller对照芯片型号和编号base就能确认你的逻辑编号区间。2.2 NDK与交叉编译环境配置因为目标系统是RK3288默认的系统是32位的芯片是ARMv7架构所以在Android Studio里配置CMake的时候ABI一定要盯准。我当时用的是Android Studio配合CMake最省心。工程里只需要在build.gradle的defaultConfig里加一个过滤器android { defaultConfig { externalNativeBuild { cmake { // RK3288 是 32 位 ARM只编这一个 ABI 就够了 abiFilters armeabi-v7a } } } externalNativeBuild { cmake { path src/main/cpp/CMakeLists.txt version 3.22.1 } } }这里有个特别容易踩的坑如果不写abiFiltersAndroid Studio会默认把你在NDK里声明的所有ABI都编一遍其中就包括arm64-v8a。如果RK3288刷的是32位Android系统跑起来会直接报找不到库的错因为系统根本不加载arm64目录下的库。反过来如果板子刷的是64位系统你又只编了32位库那大部分情况下也能跑因为系统会兼容加载32位so但最好还是跟系统位数保持一致。如果不想用Android Studio纯命令行NDK也可以编export NDK/你的NDK路径 $NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/armv7a-linux-androideabi21-clang \ -shared -fPIC native_gpio.c -o libgpioctl.so2.3 权限是第一个大坑sysfs里的GPIO节点属主默认是root普通Android应用跑在独立的uid下直接写/sys/class/gpio/export大概率是Permission denied。哪怕跑adb shellshell用户能不能写也取决于固件里init.rc有没有主动chmod。我处理权限问题一般分这么几档调试阶段刷userdebug或eng固件adb root后整个adb shell是root应用这边先不纠结权限重点打通JNI链路。半产品化把APK预置为/system/app下的系统应用manifest里声明android:sharedUserIdandroid.uid.system这样APK以system uid运行访问sysfs基本畅通。量产级写内核驱动在驱动里创建/dev/gpio_ctl设备节点chmod 0666JNI层直接open设备节点然后ioctl权限最干净别人也碰不到。如果只是自己调试最简单的方式是先把APK装成普通应用然后在代码里用Runtime.getRuntime().exec(su -c ...)临时绕过root限制去验证业务逻辑。但这种方案上不了产品只能用来排错。3. 核心代码实现JNI层封装3.1 native层用C读写sysfs的完整实现先写一个最基础的C文件native_gpio.c核心就是四个操作导出、设方向、写电平、读电平。#include jni.h #include stdio.h #include stdlib.h #include string.h #include fcntl.h #include unistd.h #define SYSFS_GPIO_DIR /sys/class/gpio static int write_file(const char *path, const char *value) { int fd open(path, O_WRONLY); if (fd 0) { return -1; } int len strlen(value); int ret write(fd, value, len); close(fd); return (ret len) ? 0 : -1; } JNIEXPORT jint JNICALL Java_com_example_gpio_GpioControl_nativeGpioExport(JNIEnv *env, jobject thiz, jint gpio) { char buf[8]; snprintf(buf, sizeof(buf), %d, gpio); return write_file(SYSFS_GPIO_DIR /export, buf); } JNIEXPORT jint JNICALL Java_com_example_gpio_GpioControl_nativeGpioSetDir(JNIEnv *env, jobject thiz, jint gpio, jstring direction) { const char *dir_c (*env)-GetStringUTFChars(env, direction, NULL); char path[64]; snprintf(path, sizeof(path), SYSFS_GPIO_DIR /gpio%d/direction, gpio); int ret write_file(path, dir_c); (*env)-ReleaseStringUTFChars(env, direction, dir_c); return ret; } JNIEXPORT jint JNICALL Java_com_example_gpio_GpioControl_nativeGpioWrite(JNIEnv *env, jobject thiz, jint gpio, jint value) { char path[64]; snprintf(path, sizeof(path), SYSFS_GPIO_DIR /gpio%d/value, gpio); char buf[2] { (char)(0 value), \0 }; return write_file(path, buf); } JNIEXPORT jint JNICALL Java_com_example_gpio_GpioControl_nativeGpioRead(JNIEnv *env, jobject thiz, jint gpio) { char path[64]; snprintf(path, sizeof(path), SYSFS_GPIO_DIR /gpio%d/value, gpio); int fd open(path, O_RDONLY); if (fd 0) { return -1; } char buf[4] {0}; int ret read(fd, buf, sizeof(buf) - 1); close(fd); if (ret 0) { return -1; } return (buf[0] 1) ? 1 : 0; }这段代码的核心就是write_file这个辅助函数所有操作本质上都是打开文件中写入字符串。注意export和unexport只能各操作一次重复export会返回busy所以导出动作最好放在应用初始化时做一次。3.2 JNI函数绑定规则与动态注册上面用的方式叫静态绑定函数名必须严格按Java_包名_类名_方法名的格式来。比如Java层类路径是com.example.gpio.GpioControl方法叫nativeGpioWrite那么C函数名必须是Java_com_example_gpio_GpioControl_nativeGpioWrite包名里的点全部换成下划线C函数第二个参数jobject thiz对应Java实例方法如果Java方法声明为static这里就要换成jclass clazz搞错就直接UnsatisfiedLinkError这一点经常有人翻车。静态绑定虽然直观但工程大了函数命名会很啰嗦。更推荐动态注册在JNI_OnLoad里用RegisterNatives绑定省去一堆长名字还能在加载早期检查签名是否正确static const JNINativeMethod gMethods[] { {nativeGpioExport, (I)I, (void *)nativeGpioExport}, {nativeGpioSetDir, (ILjava/lang/String;)I, (void *)nativeGpioSetDir}, {nativeGpioWrite, (II)I, (void *)nativeGpioWrite}, {nativeGpioRead, (I)I, (void *)nativeGpioRead}, }; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { JNIEnv *env NULL; if ((*vm)-GetEnv(vm, (void **)env, JNI_VERSION_1_6) ! JNI_OK) { return JNI_ERR; } jclass clazz (*env)-FindClass(env, com/example/gpio/GpioControl); if (clazz NULL) { return JNI_ERR; } (*env)-RegisterNatives(env, clazz, gMethods, sizeof(gMethods) / sizeof(gMethods[0])); (*env)-DeleteLocalRef(env, clazz); return JNI_VERSION_1_6; }那四个native函数就不再需要那个超长的函数名了可以改成static jint nativeGpioExport(...)这种普通命名清晰得多。3.3 Java层封装与界面Java层的封装非常简单一个类搞定package com.example.gpio; public class GpioControl { static { System.loadLibrary(gpioctl); } public native int nativeGpioExport(int gpio); public native int nativeGpioSetDir(int gpio, String direction); public native int nativeGpioWrite(int gpio, int value); public native int nativeGpioRead(int gpio); }注意这里的静态代码块里库名是gpioctl对应生成出来的so文件名是libgpioctl.soSystem.loadLibrary自动补全前缀和后缀千万别写全名。MainActivity里用起来更直接。我以GPIO261GPIO8_A5控制一个LED为例public class MainActivity extends AppCompatActivity { private static final int LED_GPIO 261; private GpioControl gpio new GpioControl(); Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); // 初始化时导出引脚并设为输出 gpio.nativeGpioExport(LED_GPIO); gpio.nativeGpioSetDir(LED_GPIO, out); Button btnLedOn findViewById(R.id.btn_on); Button btnLedOff findViewById(R.id.btn_off); btnLedOn.setOnClickListener(v - new Thread(() - { gpio.nativeGpioWrite(LED_GPIO, 1); }).start()); btnLedOff.setOnClickListener(v - new Thread(() - { gpio.nativeGpioWrite(LED_GPIO, 0); }).start()); } }有个经验之谈sysfs的读写虽然只是文件操作但内核里有一堆状态机处理实际耗时会比想象中大特别是第一次export时可能要等gpiochip扫描完。如果放在主线程频繁点按钮容易出现卡顿或ANR。我习惯把GPIO操作丢到子线程哪怕是简单的new Thread都行。真正做产品时建议用线程池或HandlerThread统一串行处理避免多个按钮并发操作同一个引脚。4. 编译打包、联调与常见问题4.1 CMake构建和APK打包细节CMakeLists.txt放在src/main/cpp下内容如下cmake_minimum_required(VERSION 3.18.1) project(gpioctl) add_library( gpioctl SHARED native_gpio.c ) find_library(log-lib log) target_link_libraries( gpioctl ${log-lib} )编译时我用的是纯C文件没有依赖C运行时所以不会出现libc_shared.so缺失的问题。如果你在native层用了std::string之类的东西别忘了在CMake里把c_shared一起打包进APK否则运行时会报找不到共享库。编完APK之后先别急着往板子上装本地检查一下so有没有真的进包unzip -l app-debug.apk | grep gpioctl正常应该看到lib/armeabi-v7a/libgpioctl.so。如果这一行不存在基本就是abiFilters没配对或者sourceSets的jniLibs路径不对。安装运行adb install -r app-debug.apk adb shell am start -n com.example.gpio/.MainActivity4.2 常见报错与排查技巧速查表我把这个项目里最容易踩的坑整理成一张表全是实测过的问题现象原因解决方案运行时报Library gpioctl not foundso没有打进APK或ABI目录不匹配检查APK压缩包内是否有lib/armeabi-v7a/libgpioctl.so确认abiFilters运行时报UnsatisfiedLinkError: No implementation foundJNI函数名与Java包名/类名不匹配或static/实例方法签名不一致核对Java_包名_类名_方法名或改用动态注册nativeGpioExport返回-1对/sys/class/gpio/export没有写权限或引脚已经export先确认权限已导出则跳过export直接操作Java层读GPIO返回-1方向没设成in或者pin被其他驱动占用echo in direction后重新读查看cat /sys/kernel/debug/gpio点击按钮后LED没反应但代码不报错GPIO逻辑编号算错或引脚复用被其他模块占了重新核对公式用万用表实测引脚电平查看dts里gpio是否被pinctrl占用同一个引脚控制不稳定偶尔失效应用退出时没unexport导致下次export失败在onDestroy或服务停止时调用nativeGpioExport对应的unexport逻辑除了上面这些还有个小细节写direction时有时候内核里还没完全把gpio导出好紧接着写direction会返回ENOENT。可以在export之后sleep一下再继续或者直接循环重试几次代码层面扛得住。4.3 从裸引脚到能用的LED完整验证一遍我实际调试时不会一上来就写APK。最稳妥的顺序是先在adb shell里手工把每一步走通确认硬件没问题再进Java封装。第一步先验证硬件链路。用GPIO8_A5也就是说逻辑编号261adb root adb shell echo 261 /sys/class/gpio/export echo out /sys/class/gpio/gpio261/direction echo 1 /sys/class/gpio/gpio261/value如果LED亮万用表也能测到3.3V或对应电平说明芯片侧和sysfs链路都没有问题。这时候再看一眼开发板的原理图或者芯片TRM确认这个引脚的默认复用是不是GPIO功能有很多RK3288板卡的引脚默认被设置成了I2C或UART的复用不是说你echo 261就一定能当普通GPIO用。如果方向写不下去多半是pinctrl占用了引脚。第二步把同样的逻辑原封不动搬到C代码里先单独用一个测试程序跑通再封装成JNI函数。这一步主要是确认C代码的open/write流程没有逻辑问题。第三步回到Java层把按钮、回调、刷新逻辑串起来再验证一遍APK整体功能。这个三步走的方式能极大缩短调试时间因为每一层的问题都被控制在最小范围内。还有一个硬件层面的忠告RK3288的GPIO输出驱动能力有限直接驱动LED要加限流电阻直接推继电器基本不现实需要三极管或MOS管做开关。别把软件调好了最后硬件烧了。5. 再往深处走一步sysfs方案在大多数控制类场景里已经够用但如果你追求更极致的时序表现比如要模拟一段从一个引脚输出的高速脉冲sysfs的开销会大到离谱。那时候可以直接通过mmap把GPIO控制器的物理地址映射进用户态绕过文件系统操作直接写寄存器速度能快好几个数量级。RK3288的GPIO控制器寄存器在TRM里写得很清楚每个bank对应一段物理地址空间SWPORT_DR是数据寄存器SWPORT_DDR是方向寄存器思路跟单片机开发几乎一脉相承感兴趣可以自己翻手册研究。另外一个方向是做GPIO输入中断。sysfs的value文件天然支持poll/select可以在native层用poll()等边沿触发当前的中断状态变化会唤醒阻塞线程比Java层死循环轮询高效得多也更省电。最后再说一个我自己的习惯JNI层代码尽量控制在“薄封装”的粒度不要在C里写一堆业务逻辑。它的职责就是把Java的调用翻译成对内核接口的读写能跑通、好读、好定位问题就够了。这样哪一层出了问题你拿日志一眼就能看穿。RK3288这个平台很成熟GPIO控制本身不难真正让你加班到半夜的永远是权限、复用、编号这些看起来不起眼的小事。希望这篇记录能帮你把这些坑一次填平。本文还有配套的精品资源点击获取