1. 为什么说字符设备驱动是安卓驱动的灵魂
1.1 安卓系统里驱动到底站在哪一层
我在之前的系列文章里反复强调过一件事:安卓系统从上到下是一条完整的调用链,应用层跑在虚拟机上,框架层提供各种服务,再往下是系统服务和硬件抽象层,而真正跟硬件寄存器打交道的,是最底层的内核驱动。很多人学安卓驱动开发,一上来就盯着内核源码里那一堆晦涩的结构体和宏定义,其实最容易把人绕晕的,不是语法,而是不知道这个驱动在整个系统里到底扮演什么角色。
用一句俗话来说,安卓的内核本质上就是一个 Linux 内核,只不过针对移动场景做了大量定制和裁剪。也就是说,你在 Linux 下写字符设备驱动的那套基本功,放到安卓内核里百分之百适用。安卓系统里那些传感器、触摸屏、指纹、马达、闪光灯、GPS 模块,底层几乎都可以抽象成字符设备,也就是字节流式的读写接口。用户空间的某个服务打开一个设备文件,往里写几个字节的命令,驱动解析之后去操作寄存器,再把结果通过读接口返回给上层,整个链路就通了。
所以我才说字符设备驱动是安卓驱动的灵魂。它不一定是性能上最复杂的,但它是理解安卓底层工作原理的一把钥匙。你搞清楚了一个字符设备驱动怎么从无到有跑起来,后面再看 I2C 子系统、平台总线、输入子系统这些框架,都会觉得顺理成章,因为这些高级框架的底层仍然逃不出字符设备的那套核心机制。
这篇文章适合两类读者:一类是刚接触安卓驱动开发、对内核模块一头雾水的初学者,一类是在 Linux 下写过模块、但想快速了解安卓侧异同点的开发者。我会尽量把原理讲透,同时给一个真正能编译、能加载、能读写的极简驱动,让你能亲手跑起来。
1.2 字符设备驱动到底在解决什么问题
字符设备驱动在安卓系统中的定位,可以拆成三件事:把硬件能力暴露成文件接口、在用户态和内核态之间安全地搬运数据、以及管理硬件资源的生命周期。
第一件事最好理解。Linux 的设计哲学里有一句经典的话:一切皆文件。硬件设备在内核里注册成功后,会在/dev目录下出现一个节点,比如/dev/input/event0、/dev/i2c-1。上层应用不直接知道硬件地址怎么映射、寄存器怎么操作,它只需要像读写普通文件一样调用open、read、write、ioctl,驱动就会在背后替它完成所有硬件操作。这种抽象的价值在于,即便换了不同的硬件芯片,只要驱动接口保持一致,上层的软件几乎不用改。
第二件事是数据搬运。用户程序运行在用户空间,驱动运行在内核空间,两者之间有严格的内存隔离机制。驱动不能直接访问用户传进来的指针,反过来用户也不能随便拿到内核的内存地址。字符设备框架里专门提供了copy_to_user和copy_from_user这一类函数来做数据的安全拷贝。很多新手第一次写驱动时踩的坑就是这里:图省事直接解引用用户指针,结果触发内核异常。
第三件事是管理硬件资源。一个设备什么时候被打开、什么时候被关闭、多个进程同时访问时怎么处理、缓冲区怎么分配和释放、中断怎么注册和注销,这些都属于驱动要管的“家务事”。字符设备框架用file_operations结构体把这一系列操作规范化了,驱动开发者只需要按需实现其中一部分回调函数,内核就会在合适的时机调用它们。
1.3 三种设备类型的选型逻辑
Linux 内核把设备分成三大类:字符设备、块设备、网络设备。很多初学者分不清它们的区别,这里我打个比方。字符设备就像一扇门,你按顺序一个字节一个字节地进出,适合键盘、鼠标、传感器这类数据流;块设备像一个仓库,数据以固定大小的块为单位存取,适合硬盘这类需要随机读写的存储设备;网络设备则像一条管道,专门处理数据包的收发,走的是另一套socket接口。
| 设备类型 | 访问单位 | 典型例子 | 上层接口 |
|---|---|---|---|
| 字符设备 | 字节流 | 触摸屏、传感器、串口、GPIO | /dev下的文件节点 |
| 块设备 | 块(如 512B) | eMMC、UFS、SD 卡 | 文件系统层 |
| 网络设备 | 数据包 | WiFi、蓝牙、蜂窝 | socket接口 |
安卓设备里绝大多数的外设驱动,最终落地的形态都是字符设备或者字符设备的变体。比如触摸屏驱动,内部会注册成一个输入设备,但输入子系统本身也是基于字符设备框架构建的;I2C 控制器驱动会创建/dev/i2c-x节点,同样是字符设备。所以选择字符设备驱动作为安卓驱动开发的切入点是很有道理的,它覆盖面最广,也最能体现 Linux 驱动的通用思路。
2. 字符设备驱动框架核心机制拆解
2.1 设备号:内核怎么认设备
在驱动框里引入的第一个难点就是设备号。设备号本质上是一个编号,用来告诉内核你注册的这个设备是什么类型、是谁家的设备。它被拆成两部分:主设备号和次设备号。你可以把它们类比成小区里的楼栋号和房间号,主设备号用于标识设备对应的驱动,次设备号用于区分同一个驱动管理下的不同设备实例。
在内核中,设备号的类型是dev_t,本质上是一个 32 位的无符号整数。高 12 位用来表示主设备号,低 20 位用来表示次设备号,所以理论上主设备号范围是 0 到 4095,次设备号范围是 0 到 1048575。这里有一个新手经常困惑的点:为什么有时候代码里写MAJOR(dev)、MINOR(dev),有时候又写MKDEV(major, minor)?其实很简单:MAJOR和MINOR是解包函数,从一个完整的dev_t里分别取出主设备号和次设备号;MKDEV是打包函数,把两个号组合成一个dev_t。
举个例子。假设你想让某个驱动的主设备号是 247,次设备号是 3,那么组合出来的dev_t值就是:
dev_t dev = MKDEV(247, 3);这里 247 左移 20 位,再加上 3。这个组合出来的整数值可能在代码里看起来很大,但内核只关心它的结构,不关心它表面上的大小。我一开始调试的时候总想在日志里直接打印dev_t,打印出来的数字特别奇怪,后来才明白应该分别打印主号和次号。
设备号的获取有两种方式:静态申请和动态分配。静态申请就是自己指定一个主设备号,然后调用register_chrdev_region去内核里登记;动态分配则是调用alloc_chrdev_region,让内核自动帮你挑一个空闲的主设备号。我强烈建议你写学习 demo 的时候用动态分配,原因有两点:第一,你手动指定的主设备号很可能会跟系统里已有的设备冲突,一旦冲突驱动就加载失败;第二,内核已经提供了完善的动态分配机制,没必要给自己找麻烦。等后面做产品级的驱动时,如果硬件平台固定,你也可以在设备树或者板级文件中固定主设备号,但那是后话。
2.2 file_operations:驱动与用户态交互的入口
file_operations结构体是字符设备驱动最核心的数据结构,它定义了这个设备支持哪些操作。以我的实际经验来看,新手看这个结构体时最容易犯的错是试图搞懂所有成员,然后被那一大串函数指针吓退。其实没有必要,一个最简单的驱动只需要关心其中四五个回调就够了。
下面是我在一个极简驱动里用到的成员:
| 回调函数 | 触发时机 | 典型作用 |
|---|---|---|
open | 用户态调用open()时 | 初始化设备、增加引用计数、分配私有数据 |
read | 用户态调用read()时 | 把内核数据拷贝到用户缓冲区 |
write | 用户态调用write()时 | 从用户缓冲区接收数据并处理 |
release | 用户态调用close()时 | 释放资源、清理状态 |
这里有个细节值得多说两句:read和write回调的第二个参数是一个用户空间指针,类型上带着__user标记。这个标记是在提醒驱动开发者:这个地址不能在内核里直接访问,必须用专门的拷贝函数。我见过有人忽略这个标记,直接拿指针去memcpy,结果系统直接崩溃,日志里出现类似“unable to handle kernel paging request”的错误。你一旦看到这个报错,第一反应就应该是:是不是在内核态错误地访问了用户空间地址。
另外要记住一点:file_operations里的.owner一定要初始化为THIS_MODULE。这是告诉内核,这个操作表属于哪个模块,方便内核在卸载模块时做引用计数检查。漏掉这一步虽然不一定会立刻出错,但以后做模块热卸载时,很容易出现 “Device or resource busy” 的诡异问题。
2.3 inode 与 file:一次 open 背后的两套数据结构
很多驱动代码里会同时出现struct inode和struct file这两个参数,初学者往往分不清它们。我换个角度解释一下。inode代表的是这个设备文件本身的元数据,它在设备创建时生成,一直到设备删除时才销毁,可以理解为设备的“户口本”,同一个设备文件的inode永远只有一份。file则是每次open时由内核动态创建的,代表一次打开的文件描述符上下文,可以理解为“入场券”,每打开一次就新发一张。
理解这个区别对写驱动非常关键。假如你想在两次open之间共享某些状态,这些状态应该放在全局变量或者设备结构体里,而不是放在file里。反过来,如果你希望每次open都有独立的缓冲区、独立的数据游标,那这些数据就很适合挂在file->private_data上。
我在驱动里常用的套路是这样的:在open回调里通过kmalloc分配一个结构体,把设备相关的状态放进去,然后执行file->private_data = dev_state;,之后所有回调都可以从file->private_data把这个结构体取出来用。到了release回调里,再把它释放掉。这种做法清爽、不容易串数据,也很符合内核的开发习惯。
2.4 用户态与内核态数据拷贝的硬规则
这一节是驱动开发中事故率最高的地方,一定要单独拎出来讲。用户进程通过read、write系统调用进入内核时,内核会代表用户进程执行驱动代码,此时驱动拿到手的是用户空间的虚拟地址。这个地址在当前进程的页表里是有效的,但内核不能保证它指向的内存是已提交的、可读写的,更麻烦的是,内核自己有一套独立的内存映射,直接访问用户地址可能触发缺页异常,而且这个异常又无法在内核态安全处理。
所以内核提供了两个专用函数:
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n); unsigned long copy_from_user(void *to, const void __user *from, unsigned long n);这两个函数的返回值是“剩下没拷贝成功的字节数”。返回 0 表示全部拷贝成功,非 0 表示出错了。这个返回值的语义和常规的 C 函数不一样,很多新手在这里栽跟头:以为返回 0 是失败,返回非 0 是成功,结果判断逻辑写反了,程序行为完全不可控。
我建议你在驱动代码里养成一个习惯:拷贝完立刻检查返回值,如果非 0,直接返回-EFAULT给用户态。很多真实项目的驱动代码里都有类似这样的写法:
if (copy_to_user(buf, kernel_buffer, count)) { return -EFAULT; }用户态收到-EFAULT后,errno会被设置成EFAULT,程序员通过perror就能看到明确的错误信息。这个错误码就是告诉你:内核往用户空间拷贝数据时失败了,大概率是用户传了一个非法指针。
还有一个实用的小知识:copy_to_user和copy_from_user内部处理了缺页和调度的问题,所以在持锁状态下调用它们要格外小心,搞不好会带来死锁风险。规范的做法是先拷贝数据到临时内核缓冲区,再在锁内做处理,避免在持锁期间长时间睡眠。
3. 最简实战:从零写一个能读写的字符设备驱动
3.1 实验环境和准备
实战之前先明确一下实验环境。你不需要一台真实的安卓手机或开发板来做最基础的学习验证,普通的 Linux 机器就行,比如一台 Ubuntu 虚拟机、一块树莓派,或者某个 ARM 开发板都可以。重要的是内核头文件得装好,因为编译内核模块需要用到内核源码树里的头文件和生成文件。
我一般这么准备环境:先查看当前内核版本,然后安装对应的内核头文件包。以 Ubuntu 系为例:
uname -r sudo apt-get install linux-headers-$(uname -r)安装完成后,确认一下/lib/modules/$(uname -r)/build这个软链接存在,因为内核模块的 Makefile 会通过这个路径找到内核源码树。安卓内核的编译稍微特殊一点,因为你需要先编译出对应的内核对象树,这里先不展开。我的建议是:先在 PC 的 Linux 环境里跑通驱动逻辑,再把这套代码交叉编译到安卓内核里,这样排查问题时干扰因素最少。
3.2 编写最简字符驱动
下面就是核心代码。这个驱动不涉及任何实际硬件,它只在内存里维护一个缓冲区。用户往里面写什么,读出来的就是什么。代码虽然简单,但完整展示了一个字符设备驱动从注册到注销的全过程。
#include <linux/module.h> #include <linux/kernel.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> #include <linux/slab.h> #include <linux/uaccess.h> #define DEVICE_NAME "demo_dev" #define CLASS_NAME "demo_class" #define BUFFER_SIZE 1024 static dev_t dev_num; static struct cdev demo_cdev; static struct class *demo_class; static struct device *demo_device; static char *kernel_buffer; static int buffer_len; static int demo_open(struct inode *inode, struct file *filp) { pr_info("demo_dev: open\n"); return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { int ret; if (*offset >= buffer_len) return 0; if (count > buffer_len - *offset) count = buffer_len - *offset; ret = copy_to_user(buf, kernel_buffer + *offset, count); if (ret > 0) return -EFAULT; *offset += count; pr_info("demo_dev: read %zu bytes\n", count); return count; } static ssize_t demo_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { if (count > BUFFER_SIZE - 1) count = BUFFER_SIZE - 1; if (copy_from_user(kernel_buffer, buf, count)) return -EFAULT; kernel_buffer[count] = '\0'; buffer_len = count; *offset = 0; pr_info("demo_dev: write %zu bytes\n", count); return count; } static int demo_release(struct inode *inode, struct file *filp) { pr_info("demo_dev: release\n"); return 0; } static struct file_operations fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, .write = demo_write, .release = demo_release, }; static int __init demo_init(void) { int ret; /* Step 1: dynamically allocate device number */ ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME); if (ret < 0) { pr_err("demo_dev: failed to allocate device number\n"); return ret; } pr_info("demo_dev: major = %d, minor = %d\n", MAJOR(dev_num), MINOR(dev_num)); /* Step 2: init cdev and add to kernel */ cdev_init(&demo_cdev, &fops); demo_cdev.owner = THIS_MODULE; ret = cdev_add(&demo_cdev, dev_num, 1); if (ret < 0) { pr_err("demo_dev: failed to add cdev\n"); unregister_chrdev_region(dev_num, 1); return ret; } /* Step 3: create device class and device node */ demo_class = class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(demo_class)) { pr_err("demo_dev: failed to create class\n"); cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(demo_class); } demo_device = device_create(demo_class, NULL, dev_num, NULL, DEVICE_NAME); if (IS_ERR(demo_device)) { pr_err("demo_dev: failed to create device\n"); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(demo_device); } /* Step 4: allocate kernel buffer */ kernel_buffer = kzalloc(BUFFER_SIZE, GFP_KERNEL); if (!kernel_buffer) { device_destroy(demo_class, dev_num); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); return -ENOMEM; } pr_info("demo_dev: initialized successfully\n"); return 0; } static void __exit demo_exit(void) { kfree(kernel_buffer); device_destroy(demo_class, dev_num); class_destroy(demo_class); cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); pr_info("demo_dev: exited\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple character device driver for Android driver learning");我逐段说一下关键逻辑。
初始化函数demo_init是最值得反复看的。第一步调用alloc_chrdev_region动态申请设备号。注意它申请的次设备号数量是 1,范围从 0 开始,所以这个驱动只有一个设备节点。日志里打印出来的主设备号是内核动态分配的,不一定每次都一样,这不是 bug,是特性。
第二步cdev_init把file_operations结构体挂到cdev结构体上,然后cdev_add正式告诉内核“这个字符设备已经上线了”。从这一时刻开始,如果有用户态代码 open 对应的设备节点,内核就会进入我们已经注册的回调函数。
第三步很有意思:class_create和device_create负责帮你在/dev下创建设备节点。很多人第一次接触时不理解这一步,会问:设备节点不是用mknod命令手动创建的吗?确实可以手动建,但现代内核的做法是让设备模型体系自动处理,驱动只要创建 class 和 device,udev或者其他设备管理守护进程就会在系统里自动生成/dev/demo_dev节点。你可以在代码里看到,device_create的最后一个参数是设备节点的名字,它出来后,你再也不用关心mknod了。
最后一步分配内核缓冲区。我用kzalloc而不是kmalloc,是因为希望缓冲区初始状态下是全零的,避免读到垃圾数据。
3.3 编译、加载与验证
写驱动程序当然少不了 Makefile。这是一个最典型的内核模块 Makefile:
obj-m := demo_dev.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean这里obj-m := demo_dev.o的意思是把demo_dev.c编译成内核模块。-C指定进入内核源码树目录,M=$(PWD)指定当前目录是需要构建外部模块的目录。在终端里执行make,如果环境没问题,你会看到生成demo_dev.ko文件。
加载模块和执行验证的完整命令如下:
sudo insmod demo_dev.ko lsmod | grep demo_dev dmesg | tail -20 cat /proc/devices | grep demo_dev ls -l /dev/demo_dev正常情况下,dmesg的输出里会看到demo_dev: major = xxx, minor = 0和demo_dev: initialized successfully。/proc/devices里会出现demo_dev这一行,左边是主设备号,右边是设备名。/dev/demo_dev这个节点也已经自动生成。
我见过不少人在这一步卡住,现象是/dev/demo_dev不存在。排查思路不外乎几个:首先看dmesg里是否报了device_create失败;其次确认udev服务是否正常运行;最直接的办法是sudo mknod /dev/demo_dev c <主设备号> 0手动创建节点。手动创建的节点也能用,跟自动创建的在功能上没有区别。
3.4 用户态测试程序与运行效果
驱动本身测试起来也很简单。我写一个最基础的用户态测试程序:
#include <stdio.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <errno.h> int main(void) { int fd; char buf[128] = {0}; char *msg = "hello android driver"; fd = open("/dev/demo_dev", O_RDWR); if (fd < 0) { perror("open"); return -1; } if (write(fd, msg, strlen(msg) + 1) < 0) { perror("write"); close(fd); return -1; } if (read(fd, buf, sizeof(buf)) < 0) { perror("read"); close(fd); return -1; } printf("read from driver: %s\n", buf); close(fd); return 0; }编译运行之后,屏幕上会打印出read from driver: hello android driver。如果你用cat /dev/demo_dev,也能看到同样的字符串,因为cat会调用驱动的read回调,逻辑上跟你自己用 C 程序读没有区别。这个小小的测试程序本质上就模拟了安卓上层服务访问硬件设备的过程:打开设备、写入命令、读取结果。
测试文件里要注意一个细节:write时我传的是strlen(msg) + 1,把结尾的\0也写了进去。这样驱动缓冲区里保存的是一个完整的 C 字符串,后续read拿回来的数据再交给printf时不会出现越界。这个细节看似不起眼,但在真实项目的串口驱动、传感器驱动里经常因为少传一个字节导致解析错位。
4. 常见问题排查与调试技巧实录
4.1 dmesg 里常见的几类报错
写驱动的过程基本就是跟dmesg相爱相杀的过程。我把自己排查过的几类高频错误整理一下。
最常见的是设备号分配失败,日志里出现类似failed to allocate device number的信息。这种情况多见于你手动指定了主设备号,而这个号已经被系统里的其它驱动占用了。解决方法是改用alloc_chrdev_region,或者换一个空闲的主设备号。排查命令是cat /proc/devices,看看哪些号已经有人用了。
第二种常见错误是class_create失败。在新版本内核里,class_create的第一个参数必须是THIS_MODULE,同事经常会误传成一个 NULL,内核直接返回错误。这种错误日志可能不够直观,但IS_ERR(demo_class)的判断会拦住它。
第三种错误是访问用户指针导致的内核异常。日志里会出现类似BUG: unable to handle page fault for address的字段,地址是一个用户空间的虚拟地址。这种问题一旦出现,基本就是代码里没用copy_to_user/copy_from_user,直接对__user指针做了读写。排查的时候可以查看异常地址是不是落在某个进程的用户空间范围内。
4.2 常见故障速查表
| 故障现象 | 可能原因 | 排查和解决办法 |
|---|---|---|
insmod失败,提示/dev/demo_dev无法创建 | class_create或device_create返回错误 | 查看dmesg尾部日志,确认 class 名是否冲突 |
节点存在但open报 “Permission denied” | 设备节点权限不足 | sudo chmod 666 /dev/demo_dev临时修改,产品中通过 udev 规则设置权限 |
read返回 0,读不到数据 | 内部缓冲区为空或offset已经到末尾 | 确认write是否成功,确认buffer_len是否正确更新 |
write返回-EFAULT | 用户传的缓冲区非法或跨进程地址 | 检查用户态调用是否传了 NULL 或已释放的内存 |
模块无法卸载,提示Resource busy | 设备文件被某个进程占用了 | 找到占用进程lsof /dev/demo_dev,关闭相关程序后再rmmod |
4.3 调试三板斧:printk、/proc 节点与 strace
驱动调试的手段跟普通用户态开发很不一样。用户态你可以用 gdb 打断点,但内核态断点调试的代价太高,尤其对刚入门的开发者来说根本不现实。我实际用得最多的就是三个工具。
printk是最朴素的调试手段。在内核里打日志和在用户态用printf很像,区别在于日志级别需要斟酌。开发期间我直接用pr_info,加调试信息分类的时候才用pr_debug。这里有句话不吐不快:驱动调试日志不要写太多,尤其在中断上下文里打日志会直接拖垮系统实时性,等驱动稳定后记得删掉或者降到更低级别。
第二个是/proc和/sys下的虚拟文件。通过/proc/devices能实时看到当前系统里注册了哪些字符设备和对应的主设备号,通过/sys/class能看到设备模型的层次结构。排查设备节点问题的时候,先看这两个地方,基本能定位到是驱动没注册成功还是 udev 没生成节点。
第三个是strace,从用户态视角追踪系统调用。如果你怀疑驱动回调没有被正确触发,用strace cat /dev/demo_dev就能看到是否真的进入了open、read系统调用,返回了什么错误码。很多驱动上层对接的问题,其实上层协议解析错了,而不是驱动本身的问题,这种时候strace能帮你快速分清责任。
4.4 驱动生命周期里的对称性
写驱动时有一个非常容易被忽略的原则:初始化阶段申请了什么东西,清理阶段就必须按相反顺序释放什么东西。demo_init里依次做了设备号申请、cdev 添加、class 创建、device 创建、缓冲区分配;demo_exit里就对应做了缓冲区释放、device 销毁、class 销毁、cdev 删除、设备号归还。
为什么必须这样?因为内核里很多资源是有依赖关系的。比如你先销毁了 class,再销毁 device,那设备节点的清理就可能出现问题;你先释放了缓冲区,但又没有删除 cdev,那用户恰好在这时打开设备去读写,驱动就会访问一个空指针。这个原则看起来简单,实际操作中在错误处理分支里特别容易乱。
还有一个对称性的应用,就是模块加载失败时要注意“滚回去”清理前面已经申请成功的资源。我上面的代码里每一个goto风格错误处理做得很朴素,但核心逻辑是一样的:发现某一步失败,就把之前拿到的资源全部释放掉,绝不让内核留下半初始化状态的驱动。
5. 从最简驱动到真实业务驱动的扩展路径
5.1 从字符设备框架走向真实硬件
写完了 hello 级别的驱动,下一步自然要问:这个跟我手机里的传感器驱动、屏幕驱动到底有什么关系?
真实项目里的驱动几乎不会像 demo 这样在init里直接操作全局内存。它们通常会走上层框架,比如平台总线驱动模型。简单说,硬件信息(寄存器地址、中断号、引脚配置)放在设备树中描述,驱动通过匹配机制把自己的probe函数和设备节点绑在一起。probe被调用的时候,驱动才知道自己的硬件资源在哪里,然后做映射、注册中断、初始化外设,最后再注册成一个字符设备或者某个内核子系统里的节点。
这个扩展路径对今天的内容来说有点超纲,但核心思路是一致的:你依然要使用cdev、file_operations、设备号这些字符设备框架的零件,只是把硬件的初始化逻辑放到了probe里,把上层命令的解析放到ioctl回调里。
5.2 ioctl、阻塞与异步通知是后续重点
字符设备驱动能做的事情远不止读写数据。真实驱动里最常用的接口其实是ioctl,因为它允许用户态通过一条命令直接控制设备行为,比如设置采样率、校准参数、切换工作模式。ioctl回调本质上接收一个命令号和一个参数指针,驱动在命令号的分支里完成对应的操作。
另外两个躲不开的主题是阻塞和非阻塞。传感器驱动里,上层读取数据时如果没有新数据,驱动会让调用者睡眠等待,等中断到来后再唤醒它;按键驱动里,用户按一下键,驱动会通过异步通知机制主动告诉上层“有事件来了”。这些机制全部建立在字符设备框架之上,理解今天这章,再去看wait_queue、poll、fasync这些知识点会轻松很多。
5.3 我的个人体会
字符设备驱动这套东西,我前前后后写过很多次,每次回头看最初的代码都有一番感慨。它表面上只是一堆结构体和注册函数,但当你真正把它跑起来,看到用户态写进去的数据能原封不动地读回来,会有一种“系统底层被我打通了”的成就感。我建议任何想进入安卓驱动领域的人,都不要跳过这一步,哪怕只是花一个晚上,把这个最简驱动从编译到加载再到测试完整跑一遍,效果远胜于读十篇框架分析文章。
后续的系列里,我会继续沿着这条线往下写:从字符设备驱动到平台总线,从平台总线再到设备树,再到中断、内核线程、以及安卓特有的 HAL 层对接。有了今天的基座,后面每一步都会变得顺理成章。给初学者的一个实际建议是:把你手上的开发板打开,找到某个没人在意的 GPIO,写一个驱动去控制它的电平,再写个 App 通过节点操作它,这个小小的闭环走通了,你就算真正入门了安卓驱动开发。