Android驱动开发全流程实战:从内核模块到应用层
简介面向Android系统底层开发者和Linux驱动入门者这份资源以触摸屏驱动为主线深入Android内核驱动编写场景帮助读者理解从硬件识别到上层输入事件上报的完整驱动开发流程。压缩包共11个文件核心源码包含freg.c和freg.h另有编译生成的freg.o、built-in.o目标文件以及Makefile、Kconfig、Android.mk、cmd、order等构建辅助文件整体仅12KB体积虽小但覆盖了驱动模块编译、链接与加载所需的关键要素。已有865人学习下载。资料提供字符设备驱动实例代码中展示了设备节点创建、中断处理注册、read/write操作、ioctl控制以及休眠唤醒等电源管理细节同时附有用户空间访问程序便于对照验证上层调用与底层驱动的交互方式对理解Android驱动架构、开发板驱动移植及内核模块调试均有实际参考价值。通过这份案例还可学习insmod/modprobe加载ko模块、设备文件访问、input子系统上报事件等实战技能适合一边对照内核源码一边动手编译实验。1. Android 编写驱动程序案例从内核到应用层的一条完整链路驱动开发在 Android 生态里一直带着点“玄学”气质——很多做应用层多年的开发者提到内核模块、HAL、JNI 这三层联动就觉得是个黑匣子不知道从哪下手。这份“Android编写驱动程序案例”恰恰就是把这条链路拆开了从 Linux 内核里的字符设备驱动到 Android 的 HAL 硬件抽象层再到 JNI 封装最后落到一个能点按钮的测试 App。它不是一本讲原理的书而是一套能编译、能加载、能跑通的工程源码。适合两类人一是做嵌入式或系统开发、需要给硬件写驱动的工程师二是做应用层但想往底层探一探的开发者——跟着这个案例走一遍你至少能搞懂一个按键按下之后数据是怎么从驱动设备节点一路送到 Java 层的。2. 技术栈与工程结构这套驱动源码到底包含哪些层2.1 技术选型为什么是字符设备驱动 HAL JNI 的组合Android 的上层应用不能直接 open 内核设备节点这是系统权限和架构设计共同决定的。常规做法是分层最底层是 Linux 内核里的字符设备驱动用 file_operations 结构体暴露 read、write、ioctl 等接口往上一层是 HAL 模块以 .so 形式存在封装对设备节点的访问再往上是 JNI 层把 HAL 的函数映射成 Java 方法最顶层才是 App。这个案例采用的正是这一套经典分层。字符设备驱动的好处是接口简单、内核自带机制完善适合教学HAL 层保证了上层代码不直接碰 /dev 节点符合 AOSP 的模块化规范JNI 则让 Java 层能像调普通方法一样触发底层读写。选这套组合而不是 netlink 或 sysfs原因在于它最能还原真实项目里外设驱动的接入路径——你换一个真实传感器流程几乎一模一样。2.2 目录结构拆解每个文件夹对应一条职责边界android-driver-demo/ ├── kernel/ │ └── hello_driver.c # 内核字符设备驱动源码 ├── hal/ │ ├── hello_hal.c # HAL 层实现封装 open/read/ioctl │ └── hello_hal.h ├── jni/ │ ├── com_example_hellojni.c # JNI 桥接层 │ └── Android.mk # NDK 构建脚本 ├── app/ │ ├── MainActivity.java # 测试界面 │ └── HelloJni.java # 加载 .so 并声明 native 方法 └── docs/ └── 编译说明.md从结构上能清楚看到职责边界kernel 目录只管内核态hal 目录只管用户态对设备节点的封装jni 目录只做 Java 与 C 之间的翻译app 目录负责界面呈现。这条链路每层只依赖下一层提供的接口不跨层调用这也是真实驱动项目的基本要求。2.3 数据流全景一个读操作穿越四层的过程从上层发起一次读取完整链路是这样走的MainActivity 调用 HelloJni.readData() → JNI 函数找到 HAL 模块的 hello_read() → HAL 层 open /dev/hello 设备节点 → 内核驱动里的 hello_read 函数执行 copy_to_user 把数据从内核缓冲区拷贝到用户空间 → 一路原路返回。每一层都在做自己职责内的事内核驱动处理硬件寄存器这个案例里用内存模拟HAL 层做设备节点的打开与关闭管理JNI 层做数据类型转换。理解了这条链路你就知道写驱动其实是在写每一层之间的“对齐”工作——函数名可以不一样但数据格式和语义必须一致。3. 核心代码实现从 file_operations 到 JNI 方法注册3.1 内核驱动模块字符设备的注册与 read 实现驱动最核心的部分在 kernel/hello_driver.c。这个模块在加载时注册一个字符设备卸载时注销并提供最基本的 open、read、release 三个操作。代码关键部分如下#include linux/module.h #include linux/kernel.h #include linux/fs.h #include linux/cdev.h #include linux/uaccess.h #define DEVICE_NAME hello #define BUF_SIZE 128 static char kernel_buf[BUF_SIZE] hello from kernel\n; static struct cdev hello_cdev; static dev_t hello_dev; static ssize_t hello_read(struct file *file, char __user *buf, size_t count, loff_t *offset) { size_t len strlen(kernel_buf); if (*offset len) return 0; // EOF if (count len - *offset) count len - *offset; if (copy_to_user(buf, kernel_buf *offset, count)) return -EFAULT; *offset count; return count; } static int hello_open(struct inode *inode, struct file *file) { pr_info(hello: device opened\n); return 0; } static const struct file_operations hello_fops { .owner THIS_MODULE, .open hello_open, .read hello_read, }; static int __init hello_init(void) { alloc_chrdev_region(hello_dev, 0, 1, DEVICE_NAME); cdev_init(hello_cdev, hello_fops); cdev_add(hello_cdev, hello_dev, 1); pr_info(hello: driver loaded, major%d\n, MAJOR(hello_dev)); return 0; } static void __exit hello_exit(void) { cdev_del(hello_cdev); unregister_chrdev_region(hello_dev, 1); pr_info(hello: driver unloaded\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL);这段代码里最需要留意的是 copy_to_user 这个函数。内核态不能直接访问用户态传入的缓冲区地址必须通过它做安全拷贝否则会产生地址校验漏洞。返回值的处理也讲究读到了数据返回实际字节数读到末尾返回 0 表示 EOF出错返回负的错误码。用户态程序判断“读完了”靠的就是这个 0。文件操作结构体 file_operations 是驱动与 VFS 层的协议每个字段对应一个系统调用没实现的可以填 NULL但 open 和 read 必须有。设备号的分配用的是 alloc_chrdev_region 动态分配这意味着主设备号由系统决定加载完驱动后需要看 dmesg 日志才能知道实际分配到多少。这是教学案例里的常见做法好处是避免手动指定设备号冲突坏处是设备节点需要手动创建。3.2 HAL 层封装隐藏设备节点细节HAL 层是 Android 系统里承上启下的关键模块。这段代码不用去实现 Android 标准的 hw_module_t 结构体那是给系统服务用的而是做一个轻量封装把内核设备节点的操作包成几个干净的函数// hal/hello_hal.c #include fcntl.h #include unistd.h #include stdio.h #include string.h #include errno.h #define DEVICE_PATH /dev/hello static int g_fd -1; int hello_hal_open(void) { if (g_fd 0) return 0; g_fd open(DEVICE_PATH, O_RDWR); if (g_fd 0) { printf(hal: open %s failed: %s\n, DEVICE_PATH, strerror(errno)); return -1; } return 0; } int hello_hal_read(char *buf, int size) { if (g_fd 0 || buf NULL || size 0) return -1; return read(g_fd, buf, size); } void hello_hal_close(void) { if (g_fd 0) { close(g_fd); g_fd -1; } }这个封装做了三件事一是用静态变量 g_fd 持有文件描述符避免每次读写都重新 open二是错误处理——open 失败时打印 errno 对应的错误字符串这在排查问题时非常有用三是参数校验的防御逻辑杜绝传 NULL 指针导致崩溃的可能性。真实项目里 HAL 层会做得更厚比如加 pthread_mutex 保证多线程安全、用 hw_get_module 机制做动态加载等。但这个案例的侧重点是让学生先看懂最小链路所以省掉了这些外围机制。3.3 JNI 桥接Java 方法名与 C 函数的映射规则JNI 层是这个工程里最容易“翻车”的部分因为它的方法注册方式有讲究。案例里用的是静态注册也就是函数名必须严格遵循 Java_包名_类名_方法名 的格式。看代码// jni/com_example_hellojni.c #include jni.h #include string.h // HAL 层函数声明 extern int hello_hal_open(void); extern int hello_hal_read(char *buf, int size); extern void hello_hal_close(void); JNIEXPORT jstring JNICALL Java_com_example_hellojni_HelloJni_readData(JNIEnv *env, jobject thiz) { char buf[128] {0}; int len; hello_hal_open(); len hello_hal_read(buf, sizeof(buf) - 1); hello_hal_close(); if (len 0) return (*env)-NewStringUTF(env, read failed); else return (*env)-NewStringUTF(env, buf); }映射规则的关键点是包名里的点号要写成下划线。如果 Java 层类名是 HelloJni、包名是 com.example.hellojni那么 C 函数名就是 Java_com_example_hellojni_HelloJni_readData。少一个下划线或多一个运行时就会报 UnsatisfiedLinkError这是 JNI 最常见的低级错误。注意 JNIEnv 指针的使用方式C 版本里 *env 是指向函数表的指针所以调用 NewStringUTF 得写成 (*env)-NewStringUTF(env, ...)不能漏掉第一层解引用。这一步写错编译能过但一运行就崩而且是段错误查起来比较费时。这个 JNI 函数没有做 Java 字符串和 C 字符串的复杂转换直接构造 jstring 返回因为数据本身是文本。如果是二进制数据就得用 jbyteArray 加 SetByteArrayRegion 来拷贝复杂度会上一截。3.4 Android.mk 构建脚本编译 .so 的关键配置JNI 层需要编成 .so 供 Java 层加载。用的构建系统是 NDK 的 ndk-buildAndroid.mk 文件内容如下LOCAL_PATH : $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE : libhello_jni LOCAL_SRC_FILES : com_example_hellojni.c LOCAL_C_INCLUDES : $(LOCAL_PATH)/../hal LOCAL_SHARED_LIBRARIES : liblog LOCAL_LDLIBS : -llog include $(BUILD_SHARED_LIBRARY)LOCAL_MODULE 决定了生成 .so 的名字这里定义成 libhello_jni最终产物就是 libhello_jni.so。LOCAL_SRC_FILES 只写了一.c 文件但注意它调用了 HAL 层的三个函数——这意味着 HAL 层的代码要么也编进这个 .so要么单独编一个 .so 并链接。案例里的做法通常是把 HAL 的 .c 文件直接加进 LOCAL_SRC_FILES 一起编省去动态链接的复杂度。这里有一个取舍真实项目里 HAL 通常独立成模块由系统加载但教学工程为了简化链路直接混合编译也是常见做法。代价是 .so 失去模块独立性但换取了一处编译一揽子解决的便利。4. 编译与运行从内核模块到 App 落地的完整操作4.1 内核模块的交叉编译指定内核源码树编译内核模块必须依赖对应版本的内核源码树——就是那个 .config 和编译产物都齐备的目录。假设你的 Android 设备内核版本是 5.10那你得先有一份编好且能用的内核源码。模块的 Makefile 写法如下obj-m : hello_driver.o KERNEL_DIR : /path/to/android-kernel CROSS_COMPILE : aarch64-linux-android- all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) ARCHarm64 \ CROSS_COMPILE$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) cleanKERNEL_DIR 必须指向已配置完成的内核源码树根目录ARCH 要根据设备架构填 arm64 或 arm。编译完成后会生成 hello_driver.ko。这里有个容易踩的坑如果 Android 设备的内核版本和你编译用的源码版本不一致insmod 时会报 invalid module format因为它会校验 vermagic 和符号版本信息。把 .ko 推送到设备上后用 insmod 加载adb push hello_driver.ko /data/local/tmp/ adb shell su insmod /data/local/tmp/hello_driver.ko # 查看内核日志确认主设备号 dmesg | grep hello加载成功后在 dmesg 里能看到 hello: driver loaded, majorxxx 的输出。下一步是手动创建设备节点用输出的主设备号配合从设备号 0mknod /dev/hello c major 0 chmod 666 /dev/hellochmod 666 这一步不能省否则 HAL 层以普通用户身份打开设备节点会被拒绝表现就是 open 返回 Permission denied。真实产品里会通过 ueventd 的 rc 文件自动创建设备节点并设置权限但手动操作更适合确认每一步发生了什么。4.2 JNI 与 App 编译ndk-build 和 Gradle 两边配合JNI 部分在项目根目录下执行ndk-build生成的 .so 会放在 libs/armeabi-v7a/ 或 libs/arm64-v8a/ 下。关键是 App 工程要能引用到这个 .so。把生成的 .so 拷贝到 app/src/main/jniLibs/arm64-v8a/ 目录下Android Studio 的 Gradle 构建会自动把它打进 APK。Java 层加载库的代码public class HelloJni { static { System.loadLibrary(hello_jni); } public static native String readData(); }加载库时传的是 libhello_jni.so 去除前缀 lib 和后缀 .so 之后的中间名。这个细节写错运行时会报 library not found。MainActivity 里的调用非常简单Button btn findViewById(R.id.btn_read); TextView tv findViewById(R.id.tv_result); btn.setOnClickListener(v - { String result HelloJni.readData(); tv.setText(result); });APK 安装后点按钮如果一切正常TextView 上会显示 hello from kernel。到这一步数据链路就完整贯通了Java → JNI → HAL → 驱动 → 返回。4.3 验证链路的几个关键观察点跑通之后你需要验证每一层都真的参与了而不是侥幸跳过了某层。第一个观察点是内核日志读操作发起后 dmesg 里会出现 hello: device opened这证明驱动确实被执行了。第二个观察点是设备节点操作前 /dev/hello 存在且权限为 666。第三个是确认 .so 是否真的打包进了 APK用 apkanalyzer 或直接解压 APK 查看 lib/arm64-v8a/ 下有没有 libhello_jni.so。5. 常见问题与避坑指南驱动开发中的典型故障5.1 insmod 失败提示 invalid module format现象insmod 时报错 insmod: failed to load kernel module: Invalid module format。原因模块编译用的内核源码树版本与设备上实际运行的内核版本不一致内核的 vermagic 校验没通过。Android 设备常刷第三方内核版本号很容易对不上。解决先确认设备内核版本uname -r 查看然后找对应版本的源码树重新编译。如果源码树是 Git 仓库git checkout 到匹配的 tag 再编。5.2 打开设备节点时提示 Permission denied现象HAL 层调用 open(/dev/hello, O_RDWR) 返回 -1errno 是 EACCES。原因设备节点创建后默认权限是 640属主是 root应用进程没有写权限。解决执行 chmod 666 /dev/hello。如果重启后节点权限重置需要在设备的 ueventd.rc 里添加 chmod 配置。手动调试阶段用命令行改最直接。5.3 App 闪退并报 UnsatisfiedLinkError现象MainActivity 加载时崩溃日志显示 java.lang.UnsatisfiedLinkError: No implementation found for void com.example.hellojni.HelloJni.readData()。原因JNI 函数名和 Java 声明不匹配。常见情况是包名带下划线时映射错位或者 Java 类名大小写不一致。解决先检查 C 函数名是否严格遵循 Java_包名_类名_方法名包名里的点换下划线。还不行就在 C 代码里临时加 printf 配合 logcat 查 JNI_OnLoad 有没有被调用。5.4 read 返回 0 但应用显示乱码现象TextView 显示的内容里混有乱码或者读出的是空字符串。原因JNI 层 NewStringUTF 要求 C 字符串以 \0 结尾而内核驱动返回的数据如果超过缓冲区长度最后一次 read 可能截断了字符串没有保留结尾符。解决JNI 层调用 read 时缓冲区大小减一并且在 read 后用 buf[len] \0 手动补上终止符。很多 C 开发者习惯性地写固定大小 buffer 然后直接当字符串用这是血泪教训。5.5 设备节点消失或设备号对不上现象重启之后 /dev/hello 不存在或者 dmesg 里显示的设备号和 mknod 时用对不上。原因alloc_chrdev_region 是动态分配主设备号每次重启都由内核重新分配没有固定。解决要么改成 register_chrdev_region 固定设备号要么在每次启动后根据 dmesg 输出重新 mknod。生产环境一般用静态注册或者写 init 脚本自动处理。6. 二次开发技巧把教学驱动改成你自己的硬件驱动想把这份代码改成真实硬件驱动核心工作量在三个地方硬件寄存器操作、上下层数据格式设计、多线程并发保护。第一步是替换内核驱动的 read 逻辑。教学代码里读的是内存字符串真实驱动里读的应该是硬件寄存器。比如接一个温度传感器read 函数里可能要做一次 i2c 或 spi 传输把拿到的裸数据拼成结构化样本返回。操作寄存器时需要用到 ioremap 映射物理地址以及 readl/writel 系列函数做内存映射 I/O。第二步是设计自定义的数据格式。教学例子里返回到用户态的是纯文本真实场景下最好是固定结构的二进制帧头部放魔数、数据长度、校验和后面放有效载荷。这样 HAL 层解析时能校验完整性不会因为数据错位导致上层读到垃圾值。JNI 层这时候也得从返回 jstring 改成返回 jbyteArray数据流就变成 Java 层再解析成业务对象。第三步是考虑并发访问。真实硬件可能被多个线程同时读这时候驱动里要加互斥锁HAL 层也要用 pthread_mutex 包住 open/read/close 的临界区。教学代码里 g_fd 是全局变量没有任何同步多线程场景下文件描述符可能被意外关闭。动手改的时候我一般会这样做先把 HAL 层函数名和 JNI 层方法名抄在一张纸上画好数据流往返的箭头确认每个函数参数和返回值类型对齐然后从内核往上层逐层改每改一层就编译验证一次而不是全部改完再调。内核驱动改了之后要重新 insmodJNI 改了之后要重新 ndk-build 打 APK逐层推进可以让出错时能快速定位到具体层。我早期在这个项目上栽过的跟头就是一次改三层然后一起调报错根本不知道看内核日志还是看 logcat。后来我强制自己按“驱动先单独验证 → HAL 再接 → JNI 再包 → UI 最后加”的顺序走基本一次过。希望帮到你——驱动开发没什么捷径但先把链路跑通一次后面接什么硬件都有底气。本文还有配套的精品资源点击获取