尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
从零手写字符设备驱动:安卓底层驱动的灵魂与实战
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下的文件节点块设备块如 512BeMMC、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 和 deviceudev或者其他设备管理守护进程就会在系统里自动生成/dev/demo_dev节点。你可以在代码里看到device_create的最后一个参数是设备节点的名字它出来后你再也不用关心mknod了。最后一步分配内核缓冲区。我用kzalloc而不是kmalloc是因为希望缓冲区初始状态下是全零的避免读到垃圾数据。3.3 编译、加载与验证写驱动程序当然少不了 Makefile。这是一个最典型的内核模块 Makefileobj-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关闭相关程序后再rmmod4.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 通过节点操作它这个小小的闭环走通了你就算真正入门了安卓驱动开发。
RELATED

相关推荐

CoppeliaSim+Python小车视觉追踪:从仿真到真机移植的完整实现指南

CoppeliaSim+Python小车视觉追踪:从仿真到真机移植的完整实现指南

简介:面向机器人仿真与控制学习的CoppeliaSim(原Vrep)Python小车追踪工程,聚焦如何在仿真环境中驱动小车实时追踪目标点,覆盖环境搭建、API调用、路径规划与控制算法联调等关键环节。压缩包共17个文件,包含…

📅 2026/10/21 3:55:53
Minimax Coding Plan 优惠获取全解析:邀请链接、权益码与9折实操指南

Minimax Coding Plan 优惠获取全解析:邀请链接、权益码与9折实操指南

最近有好几个朋友问我同一个问题:Minimax Coding Plan 的邀请链接到底从哪儿弄,权益码是不是真的能叠 9 折,这个专属优惠跟平时自己注册有啥区别。我当时为了搞清楚这套玩法,专门拿小号测了一遍,又在官方帮助文档里翻了…

📅 2026/10/21 3:55:53
Delphi 12串口通信控件nrCommLib Pro v9.54全源码解析

Delphi 12串口通信控件nrCommLib Pro v9.54全源码解析

简介:这是一套专为Delphi 12开发者提供的高性能串口通信控件库——nrCommLib Pro v9.54完整源码包,适用于工业自动化、仪器仪表、嵌入式设备联调等需稳定串口交互的中高级开发场景。资源共462个文件,涵盖95个Pascal源码(.pas&…

📅 2026/10/21 3:55:53
MORE NEWS

更多资讯

📰

阿里EDAS示例工程上云实战:本地启动、部署配置与灰度避坑指南

简介:这份资源是阿里EDAS(企业级分布式应用服务平台)的入门示例工程,面向希望学习微服务架构与云端应用部署的Java开发者,尤其适合刚接触EDAS、需要动手实践的中初级人员。压缩包共15个文件,以8个xml配置文…

📰

Redis哨兵实战:高可用架构部署、故障转移与踩坑经验

Redis 哨兵这套东西,我最早接触是在一个电商促销活动前夜。当时主从架构跑得好好的,结果主节点凌晨挂了,运维手动切了四十分钟,老板在群里连发十几条消息。从那以后我就把哨兵当成了高可用方案的标配。这篇文章不讲教科书定义&…

📰

C# WinForm Panel 边框颜色改不动?三种自绘方案一次讲清

简介:面向C# WinForm开发者的Panel控件美化方案,解决默认边框颜色与宽度不可直接调整的问题。资源通过继承Panel并重写OnPaint方法,以Graphics绘制自定义边框,提供BorderColor与BorderWidth两个扩展属性,示例代码清晰易…

📰

多模型接入太难了?用 litellm 统一 OpenAI 兼容格式,打造 LLM 网关

最近在处理一个多模型接入的需求时,我最大的感受是:真正让人崩溃的其实不是模型效果,而是调用方式五花八门。同样是聊天补全,有的需要messages数组,有的偏要prompt字符串;同样是工具调用,有的叫…

📰

2013-2024年 中国各省级数字经济指数数据(+代码+文献)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

Unity渲染纹理Camera.targetTexture全解析:从原理到小地图/监控/截图实战

1. 先说清楚:targetTexture到底是干什么的做Unity开发的人,早晚都会遇到摄像机目标渲染纹理(RenderTexture Camera.targetTexture)这个组合。我第一次接触它,是做一个设备监控类项目,需要在主界面里嵌一个…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬