简介面向RK3288嵌入式Android开发者的GPIO控制完整工程包解决在APK层通过JNI桥接C/C代码实现人体感应检测与门禁开关的核心需求。包体共68个文件1.73MB包含Java源码、C驱动源文件、JNI生成的头文件、编译产物so库、APK安装包以及Android工程配置与说明文档适合具备基础NDK开发经验、希望深入硬件控制层的学习者对照参考。现有1307人学习下载。资源价值在于提供了从内核驱动访问到JNI接口设计、再到Android应用事件回调的完整链路示例可直接查看gpio-control.c了解GPIO读写与设备节点操作方式结合GpioControl.java理解native方法与上层逻辑绑定配合8个so库文件可快速分析编译链接结构同时工程内含obj与bin目录便于对照中间与最终构建产物。该实践案例对理解Android底层硬件抽象、JNI线程与资源管理、以及物联网设备中GPIO控制方案均有直接借鉴意义。 做RK3288平台的时候最烦人的一件事就是上层APK想控制硬件引脚。你说一个跑着Android的设备要控制个LED、继电器、传感器总不能每次都写个内核驱动或者改dts吧但要直接在应用层操作GPIO又涉及权限、System.loadLibrary、寄存器映射这一堆事。这篇文章我直接把我这边的做法捋一遍RK3288上APK通过JNI调Native代码去操作GPIO从底层寻址原理到JNI层代码再到APK编译调试全部串起来讲清楚。这适合正在做RK3288、RK3399等Rockchip平台开发或者Android系统下想做硬件控制但又不想动内核层的朋友参考。1. 整体方案选型为什么大家都在用JNI控制GPIO1.1 这套方案到底解决什么问题RK3288这颗芯片在商业显示、工控一体机、智能终端里用得非常多跑Android系统很成熟。但Android本身是给手机设计的对硬件引脚的访问权限控制得非常严。普通应用根本没有权限直接去读写物理内存更不要说操作寄存器了。那实际项目里要控制GPIO怎么办大多数方案的落点就是应用层用Java写业务逻辑把底层的硬件操作通过JNI丢给C/C去做Native层直接访问物理地址。这么做的好处是显而易见的。比起去改内核、写驱动JNI方案不需要重新编译整个系统镜像调试周期短出了问题直接在应用层改甚至可以通过热更新把新的so文件推上去。对于方案商和集成商来说这种灵活性太重要了。更重要的是JNI方案的性能是足够的GPIO操作本身是纳秒级的寄存器读写JNI调用虽然有损耗但在控制LED、继电器、读取按键这种场景下完全感知不到延迟。1.2 GPIO控制的三种路线对比在实际项目中除了JNI还有两种常见的控制方式sysfs接口以及写Linux内核驱动。我做了个对比表格可以看得很直观方案实现难度权限要求灵活性适用场景sysfs/gpiod控制低需要给GPIO号一般需内核支持gpio export快速验证、调试脚本/dev/mem mmapJNI中需要root高可直接操作寄存器APK需直接控制硬件不改内核编写内核驱动高无需root高最稳定产品化量产需频繁操作sysfs方式在旧内核上确实能快速让GPIO亮起来写个echo命令到文件里就行。但在Android系统上sysfs对/dev下节点的权限管控同样严格而且RK3288的新内核已经逐步转向gpiod接口sysfs的export接口越来越不好用。内核驱动那条路最稳定但工作量摆在那里改一次dts或者驱动就要重新打包boot.img对于很多只做应用层的团队来说太沉重了。所以现实一点JNI直接读写/dev/mem就成了中间那条最务实的路不需要重新编译内核也不需要在应用层绕来绕去Native代码直接操作寄存器一句话够快、够直接、可控性强。注意直接读写/dev/mem本质上是应用层绕过内核做硬件操作不建议在量产产品里大面积使用但在方案预研、小批量设备、内部工具上这是效率和成本最佳的选择。2. RK3288的GPIO到底怎么寻址和操作2.1 先搞懂GPIO编号怎么算很多人一上来就找GPIO的寄存器地址结果被数据手册绕晕。RK3288的GPIO寻址其实有个非常标准的规律掌握了之后用哪个引脚心里就有数了。RK3288一共有GPIO0到GPIO8共9组GPIOBank每组下面分成A、B、C、D四个小组每组有8个引脚。所以我们平时说的GPIO7_A3它的全局编号计算方式是这样的bank编号 × 32 小组偏移 引脚编号其中A组偏移是0B组是8C组是16D组是24。拿GPIO7_A3举例GPIO7_A3 7 × 32 0 × 8 3 227而GPIO3_B5就是GPIO3_B5 3 × 32 1 × 8 5 109这个编号在设备树、内核日志和很多调试工具里都能看到JNI方案里我们直接用物理地址来操作这个编号主要用来跟我们自己的硬件原理图、接口表做对应人手一份对照表是非常必要的。2.2 RK3288的GPIO物理地址和寄存器结构每个GPIO Bank在RK3288的内存映射里都有一块独立的地址空间基地址如下GPIO Bank基地址GPIO00xFF710000GPIO10xFF720000GPIO20xFF730000GPIO30xFF740000GPIO40xFF750000GPIO50xFF760000GPIO60xFF770000GPIO70xFF780000GPIO80xFF790000从基地址开始后面的寄存器偏移量是有规律的。Rockchip的GPIO控制器一般这么排布偏移寄存器名作用0x0000GPIO_SWPORT_DR_L数据寄存器低16位写1输出高/写0输出低读引脚电平0x0004GPIO_SWPORT_DR_H数据寄存器高16位0x0008GPIO_SWPORT_DDR_L方向寄存器低16位1为输出0为输入0x000CGPIO_SWPORT_DDR_H方向寄存器高16位0x0050GPIO_EXT_PORT外部端口寄存器读取引脚实时电平这里有个很容易搞混的点GPIO_SWPORT_DR是数据寄存器写它控制输出电平但如果你配置的是输入模式想要读引脚电平强烈建议用GPIO_EXT_PORT因为数据寄存器读出来的可能是锁存值而外部端口寄存器读的是实时的物理电平尤其在按键检测、信号采集场景里这个区别直接决定代码靠不靠谱。2.3 权限问题为什么必须要有root到这里有人会问我APK里直接open(dev/mem)为什么打不开这在Android上太常见了。Android的SELinux策略默认是enforcing模式普通应用根本没有访问/dev/mem的权限。而且即使关了SELinux/dev/mem节点的权限也必须是root或者特定的system组才能读写。所以JNI控制GPIO这个方案成立的前提是设备必须能拿到root权限。RK3288的开发板、商显板绝大多数出厂固件就带着root或者至少开放了adb root和SELinux permissive模式。如果你的设备已经锁死了bootloader和SELinux那这条路基本走不通只能回到内核驱动方案。提醒一句我在项目里验证设备root权限最快的办法是adb shell su -c ls -l /dev/mem能正常列出节点就不用担心后面的权限问题。如果这里没有权限后面所有操作都白搭。3. 从零搭建JNI层环境配置与完整代码实现3.1 JNI环境配置NDK和CMake避坑JNI的第一步不是写代码而是把Android Studio的编译环境捋顺。RK3288这种32位和64位兼容的ARM平台最保险的做法是编译两种ABI的soarmeabi-v7a和arm64-v8a。安卓5.1及以上设备跑64位系统的时候如果APK里只有32位的so系统会强行按32位兼容模式加载性能会有点损耗但稳定。在build.gradle里配置NDK和CMake重点看这几个参数android { defaultConfig { externalNativeBuild { cmake { cppFlags arguments -DANDROID_TOOLCHAINclang } } ndk { abiFilters armeabi-v7a, arm64-v8a } } externalNativeBuild { cmake { path src/main/cpp/CMakeLists.txt } } }这里有个小坑很多老教程还在用ndk-build和Android.mk不是不能用但新版本NDK对CMake的支持明显更顺。而且javah生成头文件的流程在AS 3.0以后已经被自动化的JNI检测替代了手写头文件反而比生成更直观。3.2 Native端C代码映射物理地址操作寄存器核心的C代码其实不复杂就是打开/dev/mem用mmap映射一页物理内存然后往对应的寄存器偏移地址写值。直接给一份完整的gpio_control.c按RK3288的寄存器结构写的#include jni.h #include fcntl.h #include sys/mman.h #include unistd.h #include android/log.h #define LOG_TAG GPIO_JNI #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) // RK3288 GPIO结构每个Bank的大小按4KB对齐映射 #define GPIO_BANK_SIZE 0x1000 // 寄存器偏移 #define GPIO_SWPORT_DR_L 0x0000 #define GPIO_SWPORT_DR_H 0x0004 #define GPIO_SWPORT_DDR_L 0x0008 #define GPIO_SWPORT_DDR_H 0x000C #define GPIO_EXT_PORT 0x0050 static int dev_mem_fd -1; // 根据引脚编号计算bank基地址支持GPIO0~GPIO8 static unsigned long gpio_bank_base(int gpio) { unsigned long bases[] { 0xFF710000, 0xFF720000, 0xFF730000, 0xFF740000, 0xFF750000, 0xFF760000, 0xFF770000, 0xFF780000, 0xFF790000 }; int bank gpio / 32; if (bank 8) { return 0; } return bases[bank]; } // 映射某一组GPIO的寄存器页 static volatile void *map_bank(unsigned long base) { void *map_base mmap(NULL, GPIO_BANK_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, dev_mem_fd, base); if (map_base MAP_FAILED) { LOGE(mmap failed at base 0x%lx, errno%d, base, errno); return NULL; } return map_base; } static int ensure_mem_fd() { if (dev_mem_fd 0) { return 0; } dev_mem_fd open(/dev/mem, O_RDWR | O_SYNC); if (dev_mem_fd 0) { LOGE(open /dev/mem failed, errno%d, need root?, errno); return -1; } return 0; } // 设置GPIO为输出模式bank内的bit对应引脚编号低5位 static void gpio_set_output(volatile void *bank_base, int gpio) { int bit gpio 0x1F; unsigned short *ddr (unsigned short *)((char *)bank_base GPIO_SWPORT_DDR_L); unsigned int reg_val *(volatile unsigned int *)ddr; reg_val | (1U bit); *(volatile unsigned int *)ddr reg_val; } // 设置GPIO输出高/低电平 static void gpio_write(volatile void *bank_base, int gpio, int value) { int bit gpio 0x1F; unsigned int *dr (unsigned int *)((char *)bank_base GPIO_SWPORT_DR_L); unsigned int reg_val *dr; if (value) { reg_val | (1U bit); } else { reg_val ~(1U bit); } *dr reg_val; } JNIEXPORT jint JNICALL Java_com_example_gpio_GpioNative_setGpioOutput(JNIEnv *env, jobject thiz, jint gpio) { if (ensure_mem_fd() ! 0) { return -1; } unsigned long base gpio_bank_base(gpio); if (base 0) { return -1; } volatile void *bank map_bank(base); if (bank NULL) { return -1; } gpio_set_output(bank, gpio); munmap((void *)bank, GPIO_BANK_SIZE); return 0; } JNIEXPORT jint JNICALL Java_com_example_gpio_GpioNative_writeGpio(JNIEnv *env, jobject thiz, jint gpio, jint value) { if (ensure_mem_fd() ! 0) { return -1; } unsigned long base gpio_bank_base(gpio); if (base 0) { return -1; } volatile void *bank map_bank(base); if (bank NULL) { return -1; } gpio_write(bank, gpio, value); munmap((void *)bank, GPIO_BANK_SIZE); return 0; } JNIEXPORT jint JNICALL Java_com_example_gpio_GpioNative_readGpio(JNIEnv *env, jobject thiz, jint gpio) { if (ensure_mem_fd() ! 0) { return -1; } unsigned long base gpio_bank_base(gpio); if (base 0) { return -1; } volatile void *bank map_bank(base); if (bank NULL) { return -1; } int bit gpio 0x1F; unsigned int ext_val *(volatile unsigned int *)((char *)bank GPIO_EXT_PORT); int ret (ext_val bit) 1; munmap((void *)bank, GPIO_BANK_SIZE); return ret; }这是最核心的代码。简单解释一下几个设计取舍每次操作GPIO都做mmap和munmap短接、省内存但会慢一点。如果你有大量连续操作GPIO的需求可以把映射保持住进程退出或不再使用时再释放。O_SYNC打开/dev/mem很重要它告诉驱动不要走Cache缓冲直接同步到物理寄存器避免出现写了半天电平不变、或者读回来的数据是旧的这种诡异问题。方向寄存器DDR和输出数据寄存器DR都是32位寄存器但低16位和高16位分别对应A/B组和C/D组。我代码里直接用unsigned int整体读写然后按bit位操作这样对任意A/B/C/D组都通用。3.3 Java端调用简单到没什么技术含量Native层写好了Java端就非常简单其实就三步。第一步写一个GpioNative类声明native方法package com.example.gpio; public class GpioNative { static { System.loadLibrary(gpio_control); } public static native int setGpioOutput(int gpio); public static native int writeGpio(int gpio, int value); public static native int readGpio(int gpio); }第二步在Activity里调用int gpioNum 227; // GPIO7_A3 GpioNative.setGpioOutput(gpioNum); GpioNative.writeGpio(gpioNum, 1); // 拉高第三步编译的时候留意CMake把so命名为libgpio_control.soJava层loadLibrary(gpio_control)会自动查找这个名字不用写全名。如果你的CMake里没指定名字默认是lib项目名.so注意对应改一下。这里再补充一个细节JNI方法名必须和包名路径完全一致Java_com_example_gpio_GpioNative_xxx里的com_example_gpio就是Java包名com.example.gpio把点换成下划线。这个错了直接就是UnsatisfiedLinkError的闪退我们在项目里吃过好几次亏。4. APK集成编译与问题排查实录4.1 编译期那些常见的坑把Native代码集成到APK里编译和打包阶段你会遇到的最多的一个问题就是ABI不匹配。我们项目里最开始只编译了arm64-v8a结果测试机器上跑的其实是32位系统Android 5.1的RK3288很多固件是32位内核APK装上去直接闪退logcat里疯狂报UnsatisfiedLinkError原因是找不到libgpio_control.so。这个问题排查了快一个小时才反应过来是ABI不匹配。打那以后我学乖了凡是给RK3288这种老平台做东西armeabi-v7a永远放在第一位。还有个坑是NDK版本。新版本NDK默认用clang编译器对C代码的检查更严格如果你的Native代码是网上老教程抄来的有隐式函数声明或者类型不匹配编译直接warning都过不去。我的建议是直接用NDK r21或r23不用追最新稳定压倒一切。4.2 运行时问题排查速查表实际运行过程中我把遇到的问题整理成了速查表照着排查能省不少时间现象可能原因排查方法打开/dev/mem失败errno13权限不足SELinux拦截adb shell su确认权限临时setenforce 0测试mmap返回MAP_FAILED物理地址不对越界核对bank基地址确认引脚在GPIO0~GPIO8内写GPIO没反应引脚被复用处于其他功能模式检查iomux配置或者用cat /sys/kernel/debug/gpio确认占用读到的电平一直不对用了DR寄存器读数据改用EXT_PORT寄存器闪退logcat里是SIGSEGV空指针访问mmap失败后没判空检查所有异常分支是否有return不要拿NULL继续操作进程被杀提示permission denied系统签名应用调用有限制把APK做成system app或platform签名最后一个问题多说一句即使root了Android的高版本系统对应用访问/dev/mem也有额外的限制。如果设备跑的是Android 7以上建议把APK签名换成平台签名或者直接push到/system/app下面当系统应用用。4.3 用串口和示波器做最终验证在APK里点按钮控制GPIO光看代码觉得没问题还不够。我每次都是连上串口在串口终端里用cat /sys/kernel/debug/gpio来看引脚状态或者干脆直接上万用表量电平。这里有个从无数次翻车里总结出来的经验不要相信逻辑只相信示波器。有一次写一个控制继电器的功能Java层调JNI返回0一切正常但继电器就是不动作。查来查去发现是GPIO配置成了输出模式但没有先设置初始电平寄存器里的值刚好是0一上电就默认低电平而继电器是低电平触发的等于没控制。解决方法是每次初始化的顺序必须是先设方向、再设电平最后才切换工作模式。4.4 还有个隐藏坑Cache一致性问题用/dev/mem操作寄存器最容易被忽略的就是CPU的Cache一致性问题。ARM处理器有Cache和Write Buffer你自以为写入的数据可能还躺在Cache里没有真正到物理外设上。内核驱动里自带ioremap和wmb屏障来保证一致性但我们在应用层用mmap时我记得一定要用O_SYNC标志打开/dev/mem这等于告诉内核这块映射走的是uncached路径。如果你发现代码看着全对但GPIO就是不按预期动作十有八九是这里出了岔子。还有一个办法是操作完寄存器加一个__asm__ __volatile__(dmb)内存屏障强制CPU把写缓冲里的请求排空。在我实际写的JNI代码里每次寄存器写完之后我会加一条__asm__ __volatile__(dsb ::: memory);这一行差不多是我在RK平台JNI操作GPIO上踩过最深的一个坑之后补上的。加上它之后再也没出现过“明明拉了高电平引脚的波形就是慢半拍”的怪事。最后再分享一个我们在项目里一直沿用的思路JNI里只做最薄的一层寄存器读写封装把电源控制、继电器延时、按键消抖这种业务逻辑全部放到Java层去处理。这样Native层代码稳定不变Java层随便改不用重新编译so调试效率高很多。后来我们把GPIO操作封装成了一个通用的libgpio.so拿到RK3399、RV1126这些平台上一改基地址表就能直接跑复用性极好。JNI控制GPIO这条路最怕的就是想一次把所有功能做全我那会儿一开始也这么干的后来发现拆得越细后面越省事。本文还有配套的精品资源点击获取