简介:这份资源面向具备一定 Linux 内核基础、希望入门 Android 驱动开发的开发者,以触摸屏驱动等典型案例为线索,梳理从设备识别、设备节点创建、中断处理到读写操作、设备控制与电源管理的完整开发流程,帮助读者理解用户空间驱动与内核空间驱动的分工,以及驱动模块的编译与加载方式。压缩包共 11 个文件,约 12KB,以 c 源码与 h 头文件为核心,配合 o 目标文件、Android.mk、Makefile、Kconfig 等构建配置,以及 cmd、order 等编译中间产物,完整呈现一个驱动模块从源码到可加载模块的目录组织。资源已有 865 人学习下载,适合作为驱动开发入门的对照样例,读者可据此了解驱动源码结构、构建脚本写法与模块编译产物的对应关系,为后续实践打下基础。
1. 从一份 Android 驱动案例包说起:它到底能跑通什么
很多人第一次碰 Android 驱动开发,卡住的不是 C 语言,也不是内核 API,而是“从哪下手”。源码树动辄几十 GB,一个字符设备驱动写完了不知道往哪塞,编译出来了不知道往哪加载,加载了又不知道用户态怎么调。这份 Android 编写驱动程序案例包,解决的就是这条链路:它把内核模块的骨架、编译脚本、用户态测试程序、以及 Android 侧加载与验证的步骤串成了一条可复现的线。适合两类人:一是做过 Linux 字符设备驱动、想迁移到 Android 环境的工程师;二是需要给定制板卡加一个简单外设节点、又不想从零翻内核文档的从业者。它不教你写复杂总线驱动,但能把“驱动怎么写、怎么编、怎么验”这三件事讲透,让你少走几段弯路。
2. 驱动案例的骨架拆解:内核模块、编译脚本与用户态测试
2.1 一个最小字符设备驱动长什么样
案例包里的驱动主体是一个标准的 Linux 内核模块,走的是字符设备路线。选字符设备而不是平台设备或 misc 设备,理由很实际:字符设备接口简单,open/read/write/ioctl四个入口就能覆盖大部分验证场景,不需要设备树配合,也不依赖具体硬件总线,拿来练手和做原型最合适。misc 设备虽然注册更省事,但主设备号固定、次设备号自动分配,反而让初学者看不清设备号是怎么来的。
驱动的核心结构分三块:模块初始化、模块退出、文件操作集合。初始化里做两件事——申请设备号、注册字符设备;退出里反向释放。文件操作集合里至少实现open、release、read、write,ioctl按需加。下面是从案例里抽出来的骨架,去掉业务逻辑后保留关键调用:
#include <linux/module.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/uaccess.h> #define DEV_NAME "demo_chrdev" #define BUF_SIZE 256 static dev_t dev_num; // 设备号:主+次 static struct cdev demo_cdev; // 字符设备结构 static char kbuf[BUF_SIZE]; // 内核侧缓冲区 static int kbuf_len = 0; static int demo_open(struct inode *inode, struct file *file) { printk(KERN_INFO "demo_chrdev: open\n"); return 0; } static ssize_t demo_read(struct file *file, char __user *ubuf, size_t count, loff_t *ppos) { int ret; if (*ppos >= kbuf_len) return 0; // 读到末尾 if (count > kbuf_len - *ppos) count = kbuf_len - *ppos; ret = copy_to_user(ubuf, kbuf + *ppos, count); // 内核->用户 if (ret) return -EFAULT; *ppos += count; return count; } static ssize_t demo_write(struct file *file, const char __user *ubuf, size_t count, loff_t *ppos) { if (count > BUF_SIZE) count = BUF_SIZE; if (copy_from_user(kbuf, ubuf, count)) // 用户->内核 return -EFAULT; kbuf_len = count; return count; } static int demo_release(struct inode *inode, struct file *file) { printk(KERN_INFO "demo_chrdev: release\n"); return 0; } static const struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, .write = demo_write, .release = demo_release, }; static int __init demo_init(void) { int ret; ret = alloc_chrdev_region(&dev_num, 0, 1, DEV_NAME); // 动态申请设备号 if (ret < 0) return ret; cdev_init(&demo_cdev, &demo_fops); demo_cdev.owner = THIS_MODULE; ret = cdev_add(&demo_cdev, dev_num, 1); if (ret < 0) { unregister_chrdev_region(dev_num, 1); return ret; } printk(KERN_INFO "demo_chrdev: major=%d minor=%d\n", MAJOR(dev_num), MINOR(dev_num)); return 0; } static void __exit demo_exit(void) { cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");几个参数值得单独说。alloc_chrdev_region用动态方式拿设备号,避免和系统里已有设备撞号,这是案例里推荐的做法;如果板卡环境要求固定主设备号,才换成register_chrdev_region并自己指定。copy_to_user和copy_from_user的返回值必须判,返回非零说明用户态地址不可访问,直接返回-EFAULT,不要继续往下走。kbuf_len记录有效数据长度,read里靠*ppos判断是否读到末尾,这是字符设备读语义的标准写法,漏了会导致cat命令一直读不到 EOF。
2.2 编译脚本怎么和 Android 内核对上
驱动写完了,编译是第二道坎。案例包里给了两套编译方式:一套是独立 Makefile,用make -C <内核路径> M=<模块路径> modules的方式外部编译;另一套是塞进内核源码树,走obj-m或obj-y。前者适合快速迭代,后者适合最终集成。独立编译的 Makefile 大致是这样:
# 指向目标板 Android 内核源码目录(已编译过一次,有 .config) KDIR ?= /path/to/android-kernel PWD := $(shell pwd) obj-m := demo_chrdev.o all: $(MAKE) -C $(KDIR) M=$(PWD) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules clean: $(MAKE) -C $(KDIR) M=$(PWD) cleanKDIR必须指向已经执行过make且生成.config的内核树,否则编译到一半会报找不到include/generated/autoconf.h。ARCH和CROSS_COMPILE要和目标板一致,案例默认给的是 arm64 交叉工具链,32 位板子改成arm和arm-linux-gnueabi-。这里有个容易忽略的点:Android 内核的编译产物和纯 Linux 内核不完全一样,部分版本会开启模块签名校验,外部编译出来的.ko加载时可能被拒,后面避坑章节会细说。
编译成功后得到demo_chrdev.ko,用modinfo看一眼 vermagic 是否和目标内核版本匹配:
modinfo demo_chrdev.ko | grep vermagic # 输出示例:vermagic: 5.10.0 SMP preempt mod_unload aarch64vermagic 里的内核版本、SMP、preempt 标志必须和目标板uname -r完全一致,差一个字符都加载不上。这是外部编译最常见的翻车点,不是代码问题,是环境没对齐。
2.3 用户态测试程序与加载验证
驱动加载后,用户态要能打开设备节点、读写数据,才算真正跑通。案例里的测试程序走标准文件 IO,不依赖任何 Android 特有库,所以既能编成 Android 可执行文件推到板子上跑,也能在主机上对着模拟节点验证逻辑:
#include <stdio.h> #include <fcntl.h> #include <unistd.h> #include <string.h> int main(void) { int fd = open("/dev/demo_chrdev", O_RDWR); if (fd < 0) { perror("open"); return -1; } const char *msg = "hello android driver"; if (write(fd, msg, strlen(msg)) < 0) { perror("write"); close(fd); return -1; } lseek(fd, 0, SEEK_SET); // 复位读位置 char buf[128] = {0}; ssize_t n = read(fd, buf, sizeof(buf) - 1); if (n < 0) { perror("read"); close(fd); return -1; } printf("read back: %s\n", buf); close(fd); return 0; }lseek复位读位置这一步不能省,因为read依赖*ppos,写完不复位直接读会读到末尾返回 0,很多人第一次测就卡在这,以为驱动写坏了。设备节点/dev/demo_chrdev需要手动创建,用mknod指定主设备号和次设备号,主设备号从dmesg里驱动打印的日志拿:
# 板子上执行,假设 dmesg 显示 major=240 minor=0 mknod /dev/demo_chrdev c 240 0 chmod 666 /dev/demo_chrdevAndroid 环境里/dev通常是tmpfs,重启后节点消失,所以案例建议把mknod写进启动脚本,或者改用uevent让系统自动创建节点。手动mknod适合调试阶段,正式集成还是走 uevent 更稳。
3. 交叉编译与加载链路:从 .ko 到 /dev 节点
3.1 工具链与内核头文件的对齐
交叉编译驱动,工具链版本和内核头文件必须来自同一套环境。案例里强调的做法是:直接用目标板 Android 源码里预编译好的工具链,路径一般在prebuilts/gcc/linux-x86/aarch64/下,不要用主机上随手装的gcc-aarch64-linux-gnu。原因很直接,内核模块对编译器版本敏感,GCC 大版本不一致时,生成的目标文件可能带不兼容的段属性,加载时报invalid module format。
对齐步骤可以按这个顺序走:先确认目标内核版本uname -r,再在源码树里找到对应分支,执行一次完整内核编译生成.config和Module.symvers,最后用同一套工具链编模块。Module.symvers这个文件经常被忽略,它记录了内核导出符号的 CRC 校验值,外部模块编译时如果找不到它,会警告Symbol version dump not found,加载时符号解析可能失败。案例包里把Module.symvers的拷贝步骤写进了 Makefile 注释,算是把血泪经验固化下来了。
3.2 insmod 失败时先看 dmesg 而不是猜
insmod返回错误码时,终端只给一行insmod: failed to load demo_chrdev.ko,信息量几乎为零。真正的线索在dmesg里,内核会把加载失败的原因打出来。常见几类:Unknown symbol说明依赖的符号没导出或没先加载依赖模块;invalid module format是 vermagic 或编译器不匹配;Operation not permitted多半是模块签名校验没过。案例里的排查顺序是固定的:先dmesg | tail -20看最后一段内核日志,再modinfo核对 vermagic,最后确认lsmod里依赖模块是否已加载。这个顺序能覆盖八成加载失败场景,比反复重编省时间。
3.3 用 uevent 自动建节点替代手动 mknod
手动mknod在调试时够用,但设备号是动态分配的,每次重启可能变,脚本里写死主设备号迟早出事。正规做法是让内核通过 uevent 通知用户态,由ueventd根据设备属性自动创建节点。字符设备要支持这个机制,需要在驱动里创建对应的 class 和 device:
static struct class *demo_class; /* 在 demo_init 的 cdev_add 成功之后追加 */ demo_class = class_create(THIS_MODULE, "demo"); if (IS_ERR(demo_class)) { cdev_del(&demo_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(demo_class); } device_create(demo_class, NULL, dev_num, NULL, DEV_NAME);对应地,demo_exit里要先device_destroy再class_destroy,顺序反了会留下悬空节点。Android 的ueventd会读取内核发出的DEVNAME和MAJOR/MINOR,在/dev下建好节点并套用ueventd.rc里的权限规则。如果节点建出来了但权限不对,应用层打不开,问题不在驱动,在ueventd.rc里补一行chmod和chown即可。这一步是 Android 和纯 Linux 差异比较明显的地方,纯 Linux 靠 udev 规则,Android 靠 ueventd 配置,规则文件位置和语法都不一样,照搬 Linux 的 udev 写法在 Android 上不生效。
4. 避坑与排查:五个真实翻车现场
4.1 加载报 invalid module format
现象:insmod直接失败,dmesg显示demo_chrdev: version magic '5.10.0 SMP preempt mod_unload aarch64' should be '5.10.0 SMP preempt mod_unload aarch64 ',两串看着一样却报错。原因通常是 vermagic 里藏了不可见差异,比如内核配置里的CONFIG_MODULE_UNLOAD或CONFIG_PREEMPT标志不同,或者编译时LOCALVERSION带了额外后缀。解决:用modinfo -F vermagic和目标板/proc/version逐字符比对,确认内核.config和编译模块时用的是同一份,必要时在 Makefile 里显式传KBUILD_EXTRA_SYMBOLS和相同的LOCALVERSION。
4.2 设备节点建了但 open 返回 Permission denied
现象:/dev/demo_chrdev存在,ls -l看权限是crw-------,普通用户和应用进程打不开。原因:device_create创建的节点默认权限跟随内核默认 umask,Android 上不会自动放宽。解决:在ueventd.rc里加规则,例如/dev/demo_chrdev 0666 root root,或者调试阶段临时chmod 666。注意 Android 高版本对/dev下节点权限管控更严,SELinux 还会拦一道,dmesg或logcat里能看到avc: denied记录,需要补对应的file_contexts和te规则,这一步在案例包里只提了方向,具体策略要按目标系统版本调。
4.3 read 一直返回 0 或阻塞不返回
现象:测试程序write成功,read立刻返回 0,或者干脆卡住。原因分两种:一是没复位*ppos,读位置停在末尾,返回 0 是正确行为;二是驱动里read实现成了阻塞等待,但没实现poll或唤醒逻辑,用户态就卡死。解决:测试程序里write后加lseek(fd, 0, SEEK_SET);驱动侧如果要做阻塞读,必须配wait_event_interruptible加对应的wake_up,并在release里处理未完成的等待,否则进程退出时内核可能报警告。
4.4 编译通过但 insmod 报 Unknown symbol
现象:.ko编译无警告,加载时dmesg报Unknown symbol copy_to_user或类似。原因:模块依赖的内核符号没有导出,或者依赖的模块没先加载。copy_to_user这类基础符号一般都在内核里导出,报这个多半是编译时用的内核树和运行内核不是同一份,符号表对不上。解决:确认KDIR指向的源码树版本与目标板uname -r一致,检查Module.symvers是否来自同一次内核编译,必要时先insmod依赖模块再加载当前模块。
4.5 重启后驱动不自动加载
现象:手动insmod一切正常,重启后设备节点消失、功能失效。原因:Android 不会自动加载任意.ko,需要显式配置。解决:把模块放进/vendor/lib/modules/或对应分区,在init.rc或vendor下的初始化脚本里加insmod语句,并确保模块文件权限和 SELinux 上下文正确。案例包里给了一个init脚本片段作为参考,但不同 Android 版本的init语法有差异,老版本用insmod命令,新版本更推荐modprobe配合modules.dep,照抄前先确认目标系统版本。
5. 进阶验证:用 ioctl 做参数下发与状态回读
基础读写跑通后,案例包里还有一个ioctl的扩展练习,用来做参数下发和状态回读。这是驱动从“能跑”到“能用”的分水岭:真实外设往往需要配置寄存器、查询状态,光靠read/write传字节流不够表达。ioctl的关键在于命令号的编码规则,不能随手定一个数字,要用_IOR、_IOW、_IOWR宏按方向、类型、序号生成,避免和系统已有命令冲突。
/* 驱动侧:定义命令与数据结构 */ #define DEMO_MAGIC 'D' struct demo_cfg { int mode; int timeout_ms; }; #define DEMO_SET_CFG _IOW(DEMO_MAGIC, 1, struct demo_cfg) #define DEMO_GET_CFG _IOR(DEMO_MAGIC, 2, struct demo_cfg) static long demo_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { struct demo_cfg cfg; switch (cmd) { case DEMO_SET_CFG: if (copy_from_user(&cfg, (void __user *)arg, sizeof(cfg))) return -EFAULT; /* 这里落实际的寄存器配置或状态保存 */ printk(KERN_INFO "set mode=%d timeout=%d\n", cfg.mode, cfg.timeout_ms); break; case DEMO_GET_CFG: cfg.mode = 1; cfg.timeout_ms = 500; if (copy_to_user((void __user *)arg, &cfg, sizeof(cfg))) return -EFAULT; break; default: return -ENOTTY; // 未知命令必须返回这个 } return 0; }_IOW表示用户态写、驱动读,_IOR反过来,方向搞反不会编译报错,但运行时的参数拷贝方向就错了,属于典型的“编译通过、逻辑翻车”。default分支返回-ENOTTY是约定,用户态据此判断命令不支持。用户态调用时把结构体地址传进去即可,注意 32 位用户态和 64 位内核混跑时结构体对齐可能不一致,案例里建议结构体成员统一用固定宽度类型,避免long在不同位宽下大小变化。
验证ioctl是否生效,除了看返回值,还要看dmesg里驱动打印的参数是否和用户态传入的一致。我一般会在用户态程序里把传入值和回读值都打印出来,两边对不上就说明拷贝方向或结构体定义有问题。这套“定义命令、实现分支、双向验证”的流程走顺了,后面加真实外设的寄存器操作就是往里填业务逻辑的事。
从那以后我每次拿到一个新的驱动案例包,都会先把modinfo的 vermagic 和目标板uname -r对一遍,再跑最小读写,最后才碰ioctl和自动加载配置,顺序乱了排查成本翻倍。希望帮到你。
本文还有配套的精品资源,点击获取