☰
Android驱动开发全流程实战:从内核模块到应用层
2026/10/19 7:47:22 网站建设 项目流程

简介:面向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-build,Android.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) ARCH=arm64 \ CROSS_COMPILE=$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

KERNEL_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, major=xxx 的输出。下一步是手动创建设备节点,用输出的主设备号配合从设备号 0:

mknod /dev/hello c <major> 0 chmod 666 /dev/hello

chmod 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) 返回 -1,errno 是 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 层方法名抄在一张纸上,画好数据流往返的箭头,确认每个函数参数和返回值类型对齐;然后从内核往上层逐层改,每改一层就编译验证一次,而不是全部改完再调。内核驱动改了之后要重新 insmod,JNI 改了之后要重新 ndk-build 打 APK,逐层推进可以让出错时能快速定位到具体层。

我早期在这个项目上栽过的跟头就是一次改三层然后一起调,报错根本不知道看内核日志还是看 logcat。后来我强制自己按“驱动先单独验证 → HAL 再接 → JNI 再包 → UI 最后加”的顺序走,基本一次过。希望帮到你——驱动开发没什么捷径,但先把链路跑通一次,后面接什么硬件都有底气。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询