资讯动态

Android驱动开发实战:从字符设备案例到加载验证全链路

发布时间:2026/10/24 7:41:34 来源:尧图企业网站定制
简介这份资源面向具备一定 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、writeioctl按需加。下面是从案例里抽出来的骨架去掉业务逻辑后保留关键调用#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) ARCHarm64 CROSS_COMPILEaarch64-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 显示 major240 minor0 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 和 devicestatic 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创建的节点默认权限跟随内核默认 umaskAndroid 上不会自动放宽。解决在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和自动加载配置顺序乱了排查成本翻倍。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑