Android驱动开发实战:从内核模块到JNI调用完整案例
简介这份资源面向具备一定Linux内核基础的Android驱动开发学习者围绕驱动编写案例展开帮助理解从设备识别、设备节点创建到中断处理、读写操作与电源管理的完整开发流程。压缩包共11个文件约12KB以c源码、h头文件、o目标文件为主并包含Android.mk、Makefile、Kconfig、modules.order及cmd编译命令记录覆盖驱动模块从源码到编译加载的关键环节。内容预览显示资源同时提供操作驱动的C程序与内核驱动源码便于对照理解用户空间与内核空间的交互方式。目前已有865人学习下载适合希望借助小型案例快速上手Android驱动开发、梳理编译配置与模块加载思路的开发者参考。1. 从应用层到内核层一份 Android 驱动编写案例能帮你打通哪几道墙很多人做 Android 开发写着写着就撞到一堵墙上层 App 调用ioctl或读写/dev节点时底层到底发生了什么这份 Android 编写驱动程序案例就是冲着这堵墙来的。它把从内核模块编译、设备节点注册、字符设备驱动框架到上层 JNI 调用、HAL 接口对接的完整链路拆开让你能亲手跑通一个最小可用的驱动。适合已经会写 Android App、但没碰过内核模块的开发者也适合想理解 Binder 之外另一种 IPC 路径的嵌入式方向从业者。案例本身不依赖特定硬件用虚拟设备就能验证这一点对没有开发板的读者很关键。2. 驱动骨架怎么搭从模块加载到字符设备注册2.1 为什么选字符设备驱动作为切入点Android 内核驱动分几大类字符设备、块设备、网络设备。这份案例选字符设备原因很实际——它接口最简单不需要处理块层调度也不用管网络协议栈能把注意力集中在「用户态怎么和内核态交换数据」这件事上。字符设备通过/dev下的节点暴露给用户态读写和ioctl三条路径就够覆盖大部分传感器、GPIO 控制类场景。Android 上跑驱动和标准 Linux 有个关键差异Android 的 HAL 层通常不直接 open/dev/xxx而是通过一个 native 服务或 JNI 中转。案例里保留了这条链路所以你能看到驱动节点权限、SELinux 策略、HAL 接口定义这几处和纯 Linux 驱动不同的地方。如果只写一个标准 Linux 字符设备驱动在 Android 上大概率会在权限或策略上翻车。选字符设备的另一个理由是调试成本低。dmesg看内核打印ls -l /dev看节点cat和echo就能做最原始的读写验证不需要额外工具链。对于第一次写驱动的人这种即时反馈很重要。2.2 模块初始化与设备注册的代码骨架案例的核心是一个file_operations结构体加register_chrdev调用。下面这段是驱动入口的典型写法我按案例结构整理过去掉了硬件相关部分保留通用骨架#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #define DEVICE_NAME demo_dev #define BUF_SIZE 1024 static int major_num; static struct cdev demo_cdev; static char kernel_buf[BUF_SIZE]; static int demo_open(struct inode *inode, struct file *file) { printk(KERN_INFO demo_dev: opened\n); return 0; } static ssize_t demo_read(struct file *file, char __user *buf, size_t len, loff_t *offset) { if (*offset BUF_SIZE) return 0; if (len BUF_SIZE - *offset) len BUF_SIZE - *offset; // copy_to_user 把内核缓冲区数据搬到用户态 if (copy_to_user(buf, kernel_buf *offset, len)) return -EFAULT; *offset len; return len; } static ssize_t demo_write(struct file *file, const char __user *buf, size_t len, loff_t *offset) { if (len BUF_SIZE) len BUF_SIZE; // copy_from_user 把用户态数据搬进内核缓冲区 if (copy_from_user(kernel_buf, buf, len)) return -EFAULT; return len; } static struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .read demo_read, .write demo_write, }; static int __init demo_init(void) { dev_t dev; alloc_chrdev_region(dev, 0, 1, DEVICE_NAME); major_num MAJOR(dev); cdev_init(demo_cdev, demo_fops); cdev_add(demo_cdev, dev, 1); printk(KERN_INFO demo_dev: registered major%d\n, major_num); return 0; } static void __exit demo_exit(void) { cdev_del(demo_cdev); unregister_chrdev_region(MKDEV(major_num, 0), 1); printk(KERN_INFO demo_dev: unloaded\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);逻辑说明alloc_chrdev_region让内核动态分配主设备号避免和已有驱动冲突cdev_init把file_operations绑到cdev上cdev_add完成注册。读写函数里必须用copy_to_user/copy_from_user不能直接解引用用户态指针这是内核态和用户态地址空间隔离的硬性要求。参数说明BUF_SIZE决定内核缓冲区大小案例里给 1024实际按传输数据量调整alloc_chrdev_region的第二个参数是起始次设备号第三个是数量单设备写 1 即可。MODULE_LICENSE(GPL)不能省否则内核会标记污染。2.3 编译与加载Makefile 和 insmod 的实际操作驱动编译需要内核头文件Android 下通常用对应设备的内核源码树。Makefile 写法如下obj-m demo_drv.o demo_drv-objs : demo_main.o KDIR : /path/to/android/kernel PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) cleanKDIR指向内核源码目录M$(PWD)告诉内核构建系统只编译当前目录的模块。编译产物是demo_drv.ko。加载用insmod demo_drv.ko卸载用rmmod demo_drv。加载后dmesg | tail应该能看到注册成功的打印cat /proc/devices里能找到对应的主设备号。节点创建需要手动做mknod /dev/demo_dev c major 0其中major从dmesg或/proc/devices里读。Android 上这一步常被 SELinux 拦住需要确认节点上下文是否正确否则上层打开会返回Permission denied。3. 打通上层调用JNI、HAL 与权限配置3.1 从 App 到驱动的三层调用链案例把调用链拆成三层App 层用 Java 调 native 方法JNI 层用 C 打开设备节点并读写驱动层处理实际数据。这种分层在 Android 里是标准做法因为 App 进程没有权限直接访问内核设备节点必须经过 native 层。JNI 侧的核心代码#include jni.h #include fcntl.h #include unistd.h #include sys/ioctl.h static int fd -1; JNIEXPORT jint JNICALL Java_com_example_demo_DriverNative_openDevice(JNIEnv *env, jobject thiz) { fd open(/dev/demo_dev, O_RDWR); if (fd 0) return -1; return 0; } JNIEXPORT jbyteArray JNICALL Java_com_example_demo_DriverNative_readData(JNIEnv *env, jobject thiz, jint len) { jbyteArray result (*env)-NewByteArray(env, len); jbyte *buf (*env)-GetByteArrayElements(env, result, NULL); // 从驱动节点读取数据到 JNI 缓冲区 read(fd, buf, len); (*env)-ReleaseByteArrayElements(env, result, buf, 0); return result; }逻辑说明open拿到文件描述符后后续read/write/ioctl都基于这个 fd。JNI 层负责在 Java 数组和 C 缓冲区之间转换GetByteArrayElements和ReleaseByteArrayElements成对出现漏掉释放会导致内存问题。参数说明O_RDWR表示读写模式如果驱动只支持读就改成O_RDONLY。read的返回值要检查小于 0 说明出错等于 0 说明读到末尾。3.2 SELinux 策略与节点权限的配置要点Android 上驱动节点默认对 App 不可见需要配 SELinux 策略。案例里涉及三个文件file_contexts给节点打标签demo.te定义域规则system_app.te或对应进程的域里加allow规则。典型策略片段# demo.te type demo_device, dev_type; allow system_app demo_device:chr_file { open read write ioctl };file_contexts里加一行/dev/demo_dev u:object_r:demo_device:s0。这两处配完App 进程才有权限打开节点。很多人在这一步卡住现象是open返回 -1errno是EACCES但ls -l看节点权限是 666看起来没问题——实际是 SELinux 在拦。提示调试阶段可以临时用setenforce 0关掉 SELinux 验证是不是策略问题但正式环境必须把策略配对不能靠关 SELinux 蒙混。3.3 用 ioctl 做双向通信的实操读写只能传数据流控制类命令得用ioctl。案例里定义了一个简单的命令码#define DEMO_IOC_MAGIC D #define DEMO_SET_MODE _IOW(DEMO_IOC_MAGIC, 1, int) #define DEMO_GET_MODE _IOR(DEMO_IOC_MAGIC, 2, int) static long demo_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { int mode; switch (cmd) { case DEMO_SET_MODE: if (copy_from_user(mode, (int __user *)arg, sizeof(int))) return -EFAULT; // 根据 mode 设置驱动内部状态 break; case DEMO_GET_MODE: mode 1; if (copy_to_user((int __user *)arg, mode, sizeof(int))) return -EFAULT; break; default: return -ENOTTY; } return 0; }_IOW和_IOR宏生成命令码方向位区分写和读。copy_from_user/copy_to_user同样不能省。上层 JNI 调用ioctl(fd, DEMO_SET_MODE, mode)即可。命令码的 magic 数字要保证在驱动内唯一避免和别的驱动冲突。4. 避坑与排查驱动加载失败、节点打不开、数据传输出错4.1 insmod 报错 Unknown symbol现象insmod返回Unknown symbol in moduledmesg里能看到具体符号名。原因驱动引用了内核未导出的符号或者编译时用的内核版本和运行设备的内核版本不一致。Android 设备内核版本碎片化严重拿 A 设备的内核源码编出来的模块放到 B 设备上大概率加载失败。解决确认KDIR指向的内核源码版本和设备uname -r一致用modinfo demo_drv.ko看 vermagic 是否匹配如果符号确实未导出改用内核已导出的 API 或把功能拆到内核内置。4.2 节点创建后 open 返回 Permission denied现象mknod创建了节点ls -l权限是crw-rw-rw-但 App 打开返回 -1errno为EACCES。原因SELinux 策略没配或者节点标签不对。Android 的 SELinux 默认拒绝所有未显式允许的访问文件权限位管不了这一层。解决用ls -Z /dev/demo_dev看节点标签确认和file_contexts里配的一致用dmesg | grep avc看拒绝日志日志里会写明哪个域访问哪个类型被拒按日志补allow规则。改完策略要重新编译sepolicy并刷入或者用restorecon刷新节点上下文。4.3 read 返回 -EFAULT 或数据乱码现象read系统调用返回 -1errno是EFAULT或者读出来的数据全是乱码。原因copy_to_user的地址参数传错或者用户态缓冲区大小和驱动里len的判断逻辑不匹配。另一个常见原因是*offset没更新导致重复读同一段数据。解决检查copy_to_user的第一个参数是不是用户态传进来的buf不要传内核地址检查read里对*offset的更新逻辑每次成功读取后要递增用printk打印实际拷贝长度和偏移量对比预期值。4.4 卸载模块时 rmmod 报 Device or resource busy现象rmmod demo_drv返回Device or resource busy模块卸不掉。原因还有进程持有设备节点的文件描述符没关闭或者cdev_del之前有未释放的资源。解决用lsof /dev/demo_dev找到占用进程并结束确认demo_exit里cdev_del和unregister_chrdev_region的调用顺序正确先删 cdev 再注销设备号如果模块引用计数不为零检查file_operations里有没有漏掉release实现。4.5 JNI 调用返回空或崩溃现象App 调 native 方法后直接闪退或者返回空数组。原因JNI 方法签名和 Java 层声明不匹配或者GetByteArrayElements之后没调ReleaseByteArrayElements导致 JNI 环境状态异常。解决用javap -s确认 Java 层 native 方法的签名JNI 函数名要严格对应确保每次Get都有对应的Release在 JNI 里加__android_log_print打印关键步骤定位崩溃点。5. 进阶验证用虚拟设备做回归测试与性能观察驱动写完不是能跑就行得能验证。案例里给了一个虚拟设备方案不接真实硬件用内核定时器模拟数据产生往kernel_buf里周期性写数据上层读到的就是模拟信号。这样可以在没有开发板的情况下做完整链路测试。具体做法是在demo_init里加一个timer_liststatic struct timer_list demo_timer; static void demo_timer_callback(struct timer_list *t) { static int counter 0; snprintf(kernel_buf, BUF_SIZE, tick:%d, counter); mod_timer(demo_timer, jiffies msecs_to_jiffies(1000)); } // 在 demo_init 末尾加 timer_setup(demo_timer, demo_timer_callback, 0); mod_timer(demo_timer, jiffies msecs_to_jiffies(1000));这样每秒更新一次缓冲区内容上层 App 每秒读一次就能看到变化。验证时重点看三件事数据是否按时更新、多次读写后偏移量是否正确复位、卸载模块时定时器有没有正确删除del_timer_sync不能漏否则卸载后定时器还在跑会出问题。性能观察方面用ftrace看read/write的调用耗时用perf看内核态 CPU 占用。如果copy_to_user的数据量大考虑改用mmap把内核缓冲区映射到用户态省掉一次拷贝。案例里没展开mmap但这是字符设备驱动优化的常见方向。我自己的习惯是每次改完驱动先dmesg -c清空日志加载模块跑一遍读写和 ioctl再看dmesg有没有异常打印最后rmmod确认能干净卸载。这套流程走完基本能挡住大部分低级问题。从那以后我每次写新驱动都强制走一遍这个回归清单省了很多事后排查的时间。希望帮到你。本文还有配套的精品资源点击获取