简介:面向安卓底层驱动开发者的实操案例包,围绕驱动开发全流程展开,从设备识别、设备节点创建、中断处理、读写操作到设备控制与电源管理,并以触摸屏驱动为例演示初始化注销、中断上报、input子系统事件传递等实现要点,适合具备Linux内核基础、想通过具体案例上手驱动编写的工程师。压缩包为rar格式,共11个文件,涵盖C源码、Makefile/Kconfig构建配置、编译生成的.o目标文件以及用于编译加载的命令脚本,整体仅12KB,规模小巧但代码结构完整。已有865人学习下载。该案例清晰展示了驱动模块的代码组织方式,包括device_create创建设备节点、中断处理函数注册、用input_report_abs等上报坐标事件,同时通过Android.mk和Kconfig体现内核编译集成方法,对理解安卓硬件抽象层与内核驱动的衔接很有价值。
1. 需求先行:为什么一个普通的 Android 项目里也要写驱动
看到“Android 编写驱动程序案例”这个题目,很多人第一反应是:驱动不是内核工程师的活吗?做应用开发的人用得着碰这个?但真实项目里,这个需求经常绕不开。我在模拟项目X里接过一个任务,某厂商的板子要外接一个自定义传感器,芯片原厂只给了一份寄存器手册,没有任何现成的 HAL 和驱动,应用要读到传感器数据,就得从内核驱动开始一层层往上写。这个场景适合两类人:一类是做 Android 系统定制、嵌入式移植的毕设或课设,另一类是工作中要接非标外设但没拿到现成 BSP 的开发者。这套方案会带着你从零搭一个字符设备驱动,打通内核、HAL、JNI 到应用层的完整链路。
2. 总体架构设计:驱动模块、HAL 层与 JNI 的职责划分
2.1 Android 系统里驱动到底站在哪一层
Android 的软件栈往下拆,最底下是 Linux 内核,驱动就住在这一层,它直接跟硬件寄存器打交道,向上对用户空间暴露设备节点。用户态的应用不能直接碰内核地址空间,所以要访问外设必须经过一条固定的链路:应用进程通过 JNI 调用 Native 方法,Native 方法调用 HAL 层接口,HAL 层打开 /dev 下的设备节点,系统调用进入内核,最终落到驱动的 file_operations 函数里。
这里有个常见的认知误区:很多同学觉得写驱动就是写一个 .ko 文件,insmod 加载成功就完事了。但在 Android 上,内核态只是起点。如果驱动不暴露给上层,它就是个黑匣子,自己 printk 自己看,验收的时候拿不出应用层的效果。所以我会按真实产品的路径来做:驱动的职责是管硬件和提供读写接口,HAL 的职责是屏蔽设备节点细节,JNI 的职责是让 Java/Kotlin 能调到,应用层负责把数据显示出来。每一层只干一件事,哪一层出问题单独查哪一层。
2.2 设备类型选择:为什么选字符设备而不是其它
Linux 设备分字符设备、块设备、网络设备三类。传感器、GPIO、串口这类外设都属于字符设备,数据按字节流逐个读写,没有固定块大小,实现起来最简单。网络设备走的是 net_device 框架,块设备要走 request_queue,对课设来说复杂度明显不划算。这里我选字符设备,再配合 miscdevice 这个辅助框架来注册。
用 miscdevice 的好处是省去手动分配主设备号的麻烦,它内部以主设备号 10 注册,子设备号动态分配,自动在 /sys/class/misc 下生成类。对于测试设备这种杂项设备,这是最省事的路子。对比一下直接调 register_chrdev:需要自己选主设备号,可能跟系统里的冲突,还要手动创建 class 和设备节点。通常是拿到开发板先查 cat /proc/devices,挑一个没被占用的号,再搞 udev 规则。我一般在小项目里优先用 miscdevice,把精力省下来处理驱动逻辑。
2.3 分层模块划分与文件清单
整个方案按四层拆成独立模块,每层都有清晰的文件归位,写之前先把清单列出来:
| 层级 | 模块名 | 语言 | 关键文件 | 职责 |
|---|---|---|---|---|
| 内核 | 字符设备驱动 | C | virt_chrdev.c、Makefile | 实现读写逻辑,维护内核缓冲区 |
| HAL | 硬件抽象封装 | C | virt_device.c、virt_device.h | 封装 open/read/write/close |
| JNI | Native 方法桥接 | C | native_lib.cpp | 注册 JNI 方法,转换参数 |
| 应用 | 测试程序 | Kotlin | MainActivity.kt | 调用 Native 方法并展示数据 |
分层设计有一个实际好处:调试时能逐层定位。驱动加载失败,先看内核日志;驱动正常但设备节点打不开,问题在 HAL 之前的权限或节点路径;节点能打开但读到乱码,问题在驱动里的 copy_to_user。每一层都有一个明确的验证点,不会出现“应用读不到数据”就不知道从哪查起的情况。
3. 核心实现:从 file_operations 到应用层调用的完整代码
3.1 内核驱动:字符设备的核心逻辑
先写最底层的内核驱动。这个驱动的行为设计得很简单明确:驱动内部维护一个 4KB 的内核缓冲区,应用 write 时把数据存进去,read 时把数据取出来。设备加载后会出现 /dev/virt_chrdev 节点。核心代码如下:
#include <linux/module.h> #include <linux/miscdevice.h> #include <linux/fs.h> #include <linux/uaccess.h> #define BUF_SIZE 4096 static char device_buf[BUF_SIZE]; // 内核缓冲区,模拟硬件寄存器 static int buf_len = 0; // 当前有效数据长度 static DEFINE_MUTEX(dev_mutex); // 互斥锁,防止多进程并发写 static int virt_open(struct inode *inode, struct file *file) { pr_info("virt_chrdev: open called\n"); return 0; } static ssize_t virt_read(struct file *file, char __user *user_buf, size_t count, loff_t *offset) { int ret; mutex_lock(&dev_mutex); if (*offset >= buf_len) { // 已读到末尾,返回 0 表示 EOF mutex_unlock(&dev_mutex); return 0; } if (count > buf_len - *offset) count = buf_len - *offset; // 防止越界读 // copy_to_user 是关键:内核空间不能直接 memcpy 到用户空间 ret = copy_to_user(user_buf, device_buf + *offset, count); if (ret > 0) { pr_err("virt_chrdev: failed to copy %d bytes\n", ret); mutex_unlock(&dev_mutex); return -EFAULT; } *offset += count; mutex_unlock(&dev_mutex); return count; // 返回实际读取字节数 } static ssize_t virt_write(struct file *file, const char __user *user_buf, size_t count, loff_t *offset) { int ret; if (count > BUF_SIZE) // 单次写入不允许超过缓冲区 count = BUF_SIZE; mutex_lock(&dev_mutex); ret = copy_from_user(device_buf, user_buf, count); if (ret > 0) { pr_err("virt_chrdev: failed to copy from user\n"); mutex_unlock(&dev_mutex); return -EFAULT; } buf_len = count; *offset = 0; // 写入后重置读偏移 mutex_unlock(&dev_mutex); pr_info("virt_chrdev: received %zu bytes\n", count); return count; } static const struct file_operations virt_fops = { .owner = THIS_MODULE, .open = virt_open, .read = virt_read, .write = virt_write, }; static struct miscdevice virt_device = { .minor = MISC_DYNAMIC_MINOR, .name = "virt_chrdev", .fops = &virt_fops, }; static int __init virt_init(void) { int ret = misc_register(&virt_device); if (ret) { pr_err("virt_chrdev: register failed, ret=%d\n", ret); return ret; } pr_info("virt_chrdev: registered, node=/dev/virt_chrdev\n"); return 0; } static void __exit virt_exit(void) { misc_deregister(&virt_device); pr_info("virt_chrdev: unregistered\n"); } module_init(virt_init); module_exit(virt_exit); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("Android Virt Character Device Driver");这段代码有四个设计细节值得注意。第一,读写都加了 mutex 保护,因为真实场景里可能出现两个 App 同时打开设备的情况,不加锁内核缓冲区会被写乱。第二,read 函数里必须检查 *offset 与 buf_len 的关系,否则读越界会直接导致内核崩溃,这个错误在调试时极难查。第三,copy_to_user 和 copy_from_user 的返回值代表“没复制成功的字节数”,不是错误码,判断条件写反是初学最容易翻车的地方。第四,write 之后把 *offset 重置为 0,这样应用先写后读时直接从新数据开始读,行为可预测。
3.2 编译与加载:Makefile 和内核源码树匹配
驱动要编成 .ko 文件,必须有对应的内核源码树和交叉编译链。这块的 Makefile 是整个项目中第一个容易劝退人的点,写清楚如下:
obj-m := virt_chrdev.o KERNEL_DIR := /path/to/android/kernel CROSS_COMPILE := aarch64-linux-android- all: $(MAKE) ARCH=arm64 CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNEL_DIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean编译时 KERNEL_DIR 必须指向真机对应的内核源码,CROSS_COMPILE 指向 NDK 或平台自带的交叉编译器。Android 的 arm64 设备要用 aarch64 前缀的编译器,32 位设备用 arm-linux-androideabi-,用错编译器编出来的 .ko 在真机上会直接报 Exec format error。
编译完成后用 adb 把 .ko 推送到设备并加载:
adb push virt_chrdev.ko /data/local/tmp/ adb shell "su 0 insmod /data/local/tmp/virt_chrdev.ko" adb shell "cat /proc/devices | grep virt" adb shell "ls -l /dev/virt_chrdev"insmod 成功后会看到 ls /dev/virt_chrdev 出现。如果 /proc/devices 里有但节点没生成,说明 ueventd 没接管,需要手动 mknod,后面第 4 章会专门讲这个问题。
3.3 HAL 层封装:把设备节点藏起来
HAL 层存在的意义是不让上层代码到处写“打开 /dev 下的某个节点”这种容易写错的硬编码。封装一个简单的 C 接口,让 JNI 层只关心读和写:
// virt_device.h #ifndef VIRT_DEVICE_H #define VIRT_DEVICE_H ssize_t virt_device_read(char *buf, size_t len); ssize_t virt_device_write(const char *buf, size_t len); int virt_device_open(void); void virt_device_close(void); #endif// virt_device.c #include <fcntl.h> #include <unistd.h> #include <stdio.h> #include <string.h> #include "virt_device.h" #define DEVICE_PATH "/dev/virt_chrdev" static int dev_fd = -1; int virt_device_open(void) { if (dev_fd >= 0) return dev_fd; dev_fd = open(DEVICE_PATH, O_RDWR); if (dev_fd < 0) perror("open " DEVICE_PATH " failed"); return dev_fd; } ssize_t virt_device_read(char *buf, size_t len) { if (dev_fd < 0) return -1; return read(dev_fd, buf, len); } ssize_t virt_device_write(const char *buf, size_t len) { if (dev_fd < 0) return -1; return write(dev_fd, buf, len); } void virt_device_close(void) { if (dev_fd >= 0) { close(dev_fd); dev_fd = -1; } }HAL 层初看是多余的,直接让 JNI 打开设备节点不也行?但真做产品时这个封装有实际价值:第一,设备节点路径如果因为内核配置变化而改变,只改这一个文件;第二,可以在这一层加日志、加数据格式转换,方便后续调试和兼容多设备。
3.4 JNI 桥接与 Kotlin 调用
JNI 层负责把 C 接口导出给 Kotlin。用动态注册方式在 JNI_OnLoad 里绑定方法,编译成 .so 后由 App 加载。
#include <jni.h> #include <string> #include "virt_device.h" static jbyteArray nativeRead(JNIEnv *env, jobject thiz, jint len) { char buf[128] = {0}; int n = virt_device_read(buf, len > 127 ? 127 : len); jbyteArray result = env->NewByteArray(n > 0 ? n : 0); if (n > 0) env->SetByteArrayRegion(result, 0, n, (jbyte *)buf); return result; } static jint nativeWrite(JNIEnv *env, jobject thiz, jbyteArray data) { jsize len = env->GetArrayLength(data); char buf[128] = {0}; env->GetByteArrayRegion(data, 0, len, (jbyte *)buf); return virt_device_write(buf, len); } // JNI 方法绑定表 static const JNINativeMethod methods[] = { {"nativeRead", "([B)I", (void *)nativeWrite}, {"nativeRead", "()[B", (void *)nativeRead}, }; JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { JNIEnv *env = NULL; if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK) return JNI_ERR; jclass clazz = env->FindClass("com/example/virtdriver/NativeBridge"); env->RegisterNatives(clazz, methods, 2); return JNI_VERSION_1_6; }注意这里方法的签名要跟 Kotlin 声明的对应。Kotlin 侧定义一个 companion object,声明 external 函数并加载 so:
class NativeBridge { companion object { init { System.loadLibrary("virt_jni") } external fun nativeWrite(data: ByteArray): Int external fun nativeRead(): ByteArray } }到此整条链路就通了:Kotlin 调 nativeWrite 写入一串字符,内核驱动把数据存在缓冲区,再调 nativeRead 读回来,显示在界面上。
4. 常见问题排查:驱动开发最容易翻车的五个现场
4.1 insmod 报错 Unknown symbol,驱动和内核版本对不上
现象:insmod 时终端返回 “Unknown symbol in module” 或者 “no symbol version for module_layout”。
原因:驱动在内核源码树上编译时,会根据当时内核的 symbol 版本生成 CRC 校验信息。如果源码树版本和真机内核不一致,模块加载时内核发现符号校验不通过,直接拒绝加载。这是新手翻车率最高的问题,因为大家经常随便下个内核源码就开编。
解决:先看真机内核版本 uname -r,再确认 KERNEL_DIR 是不是对应这个版本的内核源码。如果是开发板,通常厂商会随 BSP 提供匹配的内核源码,不要自己从网上拉最新的主线内核编。版本对不上就换源码重新编,这一步没有捷径。
4.2 驱动加载成功但 /dev/virt_chrdev 节点没出现
现象:cat /proc/devices 能看到驱动注册的杂项设备,但 ls /dev/virt_chrdev 报 No such file or directory。
原因:Linux 桌面环境有 udev 自动创建设备节点,Android 上负责这个工作的是 ueventd,它只根据 ueventd.rc 里的规则创建节点。miscdevice 注册成功后内核会发 uevent,但如果系统的 ueventd.rc 没有配套规则,节点不会自动出现。
解决:最快的验证办法是手动创建节点。先用 cat /proc/misc 找到 virt_chrdev 对应的次设备号,再执行 mknod /dev/virt_chrdev c 10 该次设备号。驱动里用 MISC_DYNAMIC_MINOR 时次设备号是动态分配的,mknod 前要先读出来。手动节点能读写的话,再往 ueventd.rc 里加一条规则,或者干脆在 init.rc 里加一条服务开机后自动创建设备节点。
4.3 应用打开设备时报 Permission denied
现象:adb shell 下直接用 cat 读 /dev/virt_chrdev 能行,但 App 一跑就 Permission denied,或者 HAL 层 open 返回 -1。
原因:Android 的 SELinux 在 enforcing 模式下会拦截应用进程访问内核设备节点。普通应用根本没有设备节点的访问权限,即使 chmod 777 也不行,因为 SELinux 的 policy 比 DAC 权限更靠前。
解决:先把 superuser 跑起来,临时切换到 permissive 模式验证是不是 SELinux 的问题:setenforce 0。如果切换后 App 正常了,说明是 policy 缺失。调试阶段可以先 setenforce 0 顶着,交付前再补对应域的 te 规则。另外要确认设备节点的属组,把节点 chown 到 system 组,并在 App 的 AndroidManifest 里声明对应的权限,这是两个不同层面的问题,经常被混在一起。
4.4 read 读回来的是乱码或者全零
现象:应用 write 一段数据后立刻 read,拿到的字节跟写进去的完全不一样,或者全是 0x00。
原因:内核空间和用户空间的地址是不能直接互相访问的。如果驱动里用 memcpy 把内核缓冲区的数据直接拷给用户空间的指针,轻则读到空数据,重则内核 panic。另一个原因是 read 里没有处理好 *offset 的偏移,每次读都从 0 开始,第二次读就返回 0。
解决:严格检查驱动代码里是否用了 copy_to_user/copy_from_user,同时确认 read 里改变了 *offset 的值并在 EOF 时返回 0。我用过的血泪经验是在每次 open 时把缓冲区清零,这样每次测试的起点都是干净的,排除了“上次残留的数据干扰判断”的可能。
4.5 修改驱动后重新 insmod 报 File exists
现象:改了代码重新编译,insmod 时提示 File exists,设备节点也还在。
原因:第一次加载的模块没有卸载,同一个模块不能重复注册。内核里已经有一个 virt_chrdev 实例占着设备名。
解决:先 rmmod virt_chrdev,确认 /proc/devices 里没有记录了,再加载新的 .ko。如果 rmmod 提示 Device or resource busy,说明还有一个进程持有这个设备节点的 fd,最简单粗暴的方法是先把占用进程杀掉再卸载。为了排查方便,我习惯在驱动模块里把 /proc 里的信息打全,这样不用猜谁占着设备。
5. 联调验证:从内核日志到应用读写的全链路测试
5.1 内核侧验证:从 dmesg 和 /proc 确认状态
驱动是否加载,第一手信息在内核日志里,不要只看 insmod 有没有报错。加载后执行 dmesg | grep virt_chrdev,正常情况下应该看到两条关键日志:registered 和 open called。这两条日志分别证明模块加载成功、某进程已经成功 open 过设备节点。
接着用 procfs 验证注册信息:
adb shell su 0 cat /proc/devices | grep 10 adb shell su 0 cat /proc/misc | grep virt如果 /proc/misc 出现 virt_chrdev,说明 miscdevice 注册成功,可以拿到动态分配的子设备号做 mknod 或校验 ueventd 规则。
5.2 应用层读回环测试:先写后读
HAL、JNI 和驱动都就绪后,写一个最简单的回环测试用例:App 点击写入按钮,向驱动写入一串字符 “hello virt driver”,再点击读取按钮读出来。界面显示读取结果和原始输入比对。
测试用例可以用 Kotlin 写,核心逻辑如下:
val input = "hello virt driver".toByteArray(Charsets.UTF_8) val written = NativeBridge.nativeWrite(input) val output = NativeBridge.nativeRead() val result = output.toString(Charsets.UTF_8)预期结果是 result 与 input 完全一致。如果一致,证明整条应用层到内核的链路是通的。如果读出来为空,按第 4 章的顺序逐层排查。这里有一个坑要提前说:JNI 里 GetByteArrayRegion 的缓冲区大小如果比驱动缓冲区小,数据会被截断,所以 Native 侧的栈数组大小要和驱动侧一致,否则会出现“写 128 字节读出来只有 100 字节”这种诡异情况。
5.3 边界与压力测试:循环读写和多进程并发
单次读写通过不代表驱动在边界条件下不崩。我会在交付前强制跑一轮压力测试,按下面这组用例逐条过:
| 用例 | 操作 | 预期结果 |
|---|---|---|
| 空缓冲区读 | 加载后不写入直接 read | 返回 0 字节,不报错 |
| 大块写入 | 写入超过 4KB 的数据 | 只写入前 4KB,驱动不崩溃 |
| 连续读两次 | 连续调用 read 两次 | 第一次返回数据,第二次返回 0 |
| 多进程并发写 | 两个进程同时 write | 数据为后写入者的内容,缓冲区不脏 |
这组测试我用一个简单 shell 脚本循环执行 1000 次读写,同时后台跑 dmesg 监控有没有 panic 或 call trace。并发测试是最值得做的,因为我见过有同学写的驱动没有加锁,两个线程同时 write 时缓冲区内容互相覆盖,读出来的数据既不是 A 进程写的也不是 B 进程写的,这个问题不加锁是必现的。
5.4 驱动卸载与残留检查
测试完成的最后一步是卸载驱动,并确认没有任何残留。执行顺序不能反过来,否则会出现“驱动还占着设备名,下一个版本的模块加载不进去”的问题:
adb shell su 0 rmmod virt_chrdev adb shell su 0 cat /proc/devices | grep virt adb shell ls /dev/virt_chrdev卸载后 /proc/devices 不应再出现相关记录,设备节点如果还在,需要手动 rm 或等 ueventd 清掉。驱动卸载时没有释放 mutex 或没有取消注册 miscdevice,会导致第二次加载行为异常,所以卸载环节也必须作为验收步骤写入文档。
6. 验收与答辩:用什么标准证明这套驱动方案写完了
验收环节要有一个明确的检查清单,直接对着逐条打勾。我把这份方案的功能验收清单整理成了表格,答辩或交付时直接拿给导师或甲方看:
| 验收项 | 操作方式 | 通过标准 |
|---|---|---|
| 模块加载 | insmod 后 dmesg | 出现 registered 日志,/proc/misc 可见 |
| 设备节点 | ls -l /dev/virt_chrdev | 节点存在且主设备号为 10 |
| 读写功能 | App 写入并读取 "hello virt driver" | 读回内容完全一致 |
| 边界处理 | 连续两次 read | 第二次返回 0,App 不崩溃 |
| 并发安全 | 两进程同时 write | 数据不脏,无 panic |
| 干净卸载 | rmmod 后检查 proc | 无残留注册,可重新加载 |
答辩时的核心技巧不是背代码,而是能一句话讲清楚数据流。我通常这样准备:从应用点击按钮开始,数据依次经过 Kotlin 层 JNI 调用进入 Native 方法,Native 方法调用 HAL 封装,HAL 打开设备节点触发内核 open 回调,write 系统调用进入驱动的 virt_write,copy_from_user 把数据搬到内核缓冲区;读取时反向走一遍,copy_to_user 把数据搬回用户空间。这条链路能讲顺,比背一百行代码有说服力得多。
另外两个高频必问题提前准备。第一个是“为什么用 miscdevice 而不是 register_chrdev”,就答 miscdevice 自动管理次设备号、不需要单独建类,简化代码、适合单实例字符设备。第二个是“HAL 层是不是多余的”,就答 Android 系统架构里 HAL 是强制分层,它的存在让内核驱动只关心硬件,不关心上层谁能访问,是系统兼容性和稳定性的保障。
从那以后,我每次写完驱动都会强制跑一遍第 5 章的验证清单,从加载到卸载一条不落,尤其是“卸载后重新加载”这一条,已经帮我提前发现了两次资源没释放的隐患。这套既省心又能自证的流程,希望帮到你。
本文还有配套的精品资源,点击获取