目录
[1. 设备号:字符设备的 "身份证号"](#1. 设备号:字符设备的 “身份证号”)
[2. file_operations 结构体:驱动的 "功能清单"](#2. file_operations 结构体:驱动的 “功能清单”)
[四、保姆级实战:写你的第一个 hello world 字符设备驱动](#四、保姆级实战:写你的第一个 hello world 字符设备驱动)
[步骤 1:创建驱动代码文件](#步骤 1:创建驱动代码文件)
[步骤 2:编写驱动代码,每一行都加了注释](#步骤 2:编写驱动代码,每一行都加了注释)
[步骤 3:编写 Makefile,把驱动编译进内核](#步骤 3:编写 Makefile,把驱动编译进内核)
[步骤 4:编译内核,生成新的 boot.img](#步骤 4:编译内核,生成新的 boot.img)
[步骤 5:烧录 boot.img,重启开发板](#步骤 5:烧录 boot.img,重启开发板)
[步骤 6:验证驱动是否加载成功](#步骤 6:验证驱动是否加载成功)
[步骤 7:测试驱动的 read 和 write 功能](#步骤 7:测试驱动的 read 和 write 功能)
【本文首发于 CSDN,作者:黒漂技术佬,未经授权禁止转载】
大家好,我是黒漂技术佬。前两篇我们搞懂了安卓驱动的核心架构和设备树,后台很多兄弟已经催更了:
"佬,理论都看懂了,什么时候带我们写第一个真正的驱动?我已经迫不及待了!"
来了来了!今天这篇,我们就正式进入实战环节,手把手带你写你的第一个安卓字符设备驱动:hello world 驱动。从核心原理、代码编写、编译配置、烧录加载,到最终测试验证,一步不落,零基础也能跟着写出来,彻底告别只会抄代码的脚本小子。
先划重点:字符设备驱动是安卓驱动开发的灵魂,90% 的外设驱动(LED、按键、传感器、触摸屏)都是基于字符设备框架实现的,把这个框架吃透,你就掌握了安卓驱动开发的 80% 核心能力。
开篇先搞懂:字符设备驱动的核心原理
上一篇我们讲过,Linux 里一切皆文件,字符设备就是按照字节流进行数据读写的设备,对应的驱动就是字符设备驱动。它的核心作用,就是把硬件的操作能力,封装成符合 Linux 文件操作规范的接口,让上层应用能像操作普通文件一样,操作硬件设备。
字符设备驱动的两个核心核心概念,小白必须懂
1. 设备号:字符设备的 "身份证号"
Linux 系统里,每一个字符设备,都有一个唯一的 "身份证号",就是设备号,用来区分不同的设备。设备号分为两部分:
- 主设备号:用来标识设备对应的驱动程序,内核里,同一个主设备号,对应同一个驱动程序。比如所有的 LED 驱动,都用同一个主设备号;
- 次设备号:用来区分同一个驱动下的不同设备。比如一个驱动控制了 3 个 LED 灯,就用 3 个不同的次设备号来区分。
设备号是一个 32 位的整数,高 12 位是主设备号,低 20 位是次设备号,内核提供了专门的宏来操作设备号:
MKDEV(major, minor):把主设备号和次设备号,合并成一个完整的设备号;MAJOR(dev):从完整的设备号里,提取主设备号;MINOR(dev):从完整的设备号里,提取次设备号。
设备号的两种申请方式,新手必记
- 静态申请 :自己指定一个主设备号,通过
register_chrdev_region()函数,向内核申请。优点是简单,缺点是如果指定的主设备号已经被内核里的其他驱动占用了,就会申请失败; - 动态申请 :不指定主设备号,通过
alloc_chrdev_region()函数,让内核自动给我们分配一个空闲的主设备号。优点是不会出现占用冲突,新手强烈推荐用动态申请,不用自己瞎指定。
2. file_operations 结构体:驱动的 "功能清单"
这是字符设备驱动的核心,没有之一!它本质上就是一个函数指针的集合,我们把自己写的驱动函数(open、read、write、ioctl),赋值给这个结构体里对应的函数指针,然后把这个结构体注册到内核里。
当上层应用对设备文件执行 open、read、write 这些操作的时候,内核就会自动调用这个结构体里,我们绑定的对应的驱动函数。
大白话理解:file_operations 结构体,就是我们给内核递的一张 "功能清单",告诉内核:我这个驱动,支持哪些操作,每个操作对应的函数是哪个。
入门必用的核心函数指针,每个都要懂
c
运行
static const struct file_operations hello_fops = {
.owner = THIS_MODULE, // 固定写法,告诉内核这个结构体属于哪个模块
.open = hello_open, // 上层应用open设备文件的时候,会调用这个函数
.read = hello_read, // 上层应用read设备文件的时候,会调用这个函数
.write = hello_write, // 上层应用write设备文件的时候,会调用这个函数
.release = hello_release,// 上层应用close设备文件的时候,会调用这个函数
};
每个函数的作用,我给你用大白话讲透:
- open 函数:设备打开函数,上层应用打开设备文件的时候,内核会自动调用这个函数。我们一般在这个函数里,做设备的初始化工作,比如硬件初始化、变量初始化、权限检查;
- read 函数:数据读取函数,上层应用要从设备里读取数据的时候,内核会调用这个函数。比如读取传感器的数据、读取按键的状态,都在这个函数里实现;
- write 函数:数据写入函数,上层应用要向设备里写入数据的时候,内核会调用这个函数。比如控制 LED 亮灭、设置电机转速,都在这个函数里实现;
- release 函数:设备关闭函数,上层应用关闭设备文件的时候,内核会调用这个函数。我们在这个函数里,做资源释放的工作,比如释放内存、关闭硬件。
二、用户空间与内核空间的数据交互,绝对不能踩的红线
上一篇前置知识里,我们讲过 Linux 把内存分为了用户空间和内核空间,两者不能直接互相访问内存。这里再给大家强调一遍,这是驱动开发的红线,踩了内核直接崩溃。
- 上层应用运行在用户空间,权限很低,不能直接访问内核空间的内存,也不能直接操作硬件;
- 驱动代码运行在内核空间,拥有最高权限,可以访问所有内存,操作硬件。
那上层应用和驱动之间,怎么传递数据呢?内核给我们提供了两个专门的函数,也是字符设备驱动里必用的两个函数:
- copy_from_user():把用户空间里的数据,拷贝到内核空间里。上层应用 write 数据给驱动的时候,用这个函数,把用户空间的写入数据,拷贝到驱动的内核缓冲区里;
- copy_to_user():把内核空间里的数据,拷贝到用户空间里。上层应用 read 数据的时候,用这个函数,把驱动里的数据,拷贝到用户空间的缓冲区里,返回给上层应用。
小白红线警告:
- 绝对不能直接对用户空间的指针进行解引用、读写操作!必须用上面两个函数拷贝数据,不然内核直接 Oops 崩溃;
- 绝对不能在这两个函数里传入空指针,必须先检查指针的合法性,不然内核直接崩溃。
三、字符设备驱动的完整生命周期
一个完整的字符设备驱动,从加载到卸载,有一个完整的生命周期,分为 5 个核心步骤,我给你按顺序讲透:
- 驱动模块加载 :用
insmod命令加载驱动模块,或者内核启动的时候自动加载驱动,执行驱动的入口函数; - 设备号申请与注册:在入口函数里,向内核申请设备号,初始化字符设备,把 file_operations 结构体注册到内核里;
- 设备节点创建 :内核自动(或者我们手动)在
/dev目录下,创建设备文件,上层应用就是通过这个文件,和驱动交互; - 设备操作:上层应用打开设备文件,执行 read、write、ioctl 操作,内核调用驱动里对应的函数,操作硬件;
- 驱动模块卸载 :用
rmmod命令卸载驱动模块,执行驱动的出口函数,释放申请的设备号、注销字符设备、释放所有资源。
四、保姆级实战:写你的第一个 hello world 字符设备驱动
讲完所有原理,我们正式开始写代码,基于 RK3568 平台,写一个最简单的 hello world 字符设备驱动,实现:
- 上层应用 read 设备文件的时候,返回给用户空间一串 "Hello World from RK3568 Android Driver!" 字符串;
- 上层应用 write 设备文件的时候,驱动把用户写入的字符串,通过内核日志打印出来;
- 支持 open 和 close 操作,打印对应的日志。
前置准备
- 已经搭建好 Ubuntu 开发环境,RK SDK 能正常编译;
- 开发板烧录、串口、ADB 调试正常;
- 已经掌握了上一篇的设备树基础。
步骤 1:创建驱动代码文件
-
进入 Ubuntu 虚拟机,打开终端,进入内核的字符设备驱动目录: bash
运行
cd ~/RK3568_Android11_SDK/kernel/drivers/char/ -
创建我们的驱动目录,专门存放我们自己写的驱动,方便管理: bash
运行
mkdir my_drivers cd my_drivers -
创建驱动代码文件
hello_drv.c:bash
运行
touch hello_drv.c
步骤 2:编写驱动代码,每一行都加了注释
用 vim 打开hello_drv.c,把下面的代码复制进去,每一行都有详细的注释,小白能看懂每一行的意义:
c
运行
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#include <linux/device.h>
// 驱动信息声明,必须加,不然编译会报警告
MODULE_LICENSE("GPL"); // 开源协议,必须是GPL,不然很多内核API用不了
MODULE_AUTHOR("黒漂技术佬");
MODULE_DESCRIPTION("RK3568 Android Hello World Character Device Driver");
MODULE_VERSION("1.0");
// 宏定义
#define HELLO_BUF_SIZE 1024 // 数据缓冲区大小
#define DEVICE_NAME "hello_drv" // 设备名,/dev/hello_drv
#define CLASS_NAME "hello_class" // 设备类名
// 全局变量定义
static dev_t hello_devno; // 设备号
static struct cdev hello_cdev; // 字符设备结构体
static struct class *hello_class; // 设备类,用于自动创建设备节点
static struct device *hello_device; // 设备结构体
static char hello_buf[HELLO_BUF_SIZE]; // 内核数据缓冲区
// ====================== 核心驱动函数实现 ======================
// open函数:上层应用open设备文件的时候,内核会调用这个函数
static int hello_open(struct inode *inode, struct file *filp)
{
printk("【hello_drv】设备被打开了\n");
return 0;
}
// read函数:上层应用read设备文件的时候,内核会调用这个函数
static ssize_t hello_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
int ret;
// 要返回给用户空间的字符串
char *str = "Hello World from RK3568 Android Driver!\n";
int len = strlen(str);
printk("【hello_drv】用户读取数据,请求长度:%zu\n", count);
// 处理读取长度,不能超过字符串长度
if (count > len)
count = len;
// 把内核空间的数据,拷贝到用户空间,核心函数
ret = copy_to_user(buf, str, count);
if (ret != 0) {
printk("【hello_drv】数据拷贝到用户空间失败\n");
return -EFAULT;
}
return count; // 返回成功读取的字节数
}
// write函数:上层应用write设备文件的时候,内核会调用这个函数
static ssize_t hello_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos)
{
int ret;
printk("【hello_drv】用户写入数据,写入长度:%zu\n", count);
// 处理写入长度,不能超过缓冲区大小
if (count > HELLO_BUF_SIZE - 1)
count = HELLO_BUF_SIZE - 1;
// 把用户空间的数据,拷贝到内核空间,核心函数
ret = copy_from_user(hello_buf, buf, count);
if (ret != 0) {
printk("【hello_drv】用户空间数据拷贝失败\n");
return -EFAULT;
}
// 给缓冲区加结束符,打印的时候不会乱码
hello_buf[count] = '\0';
printk("【hello_drv】用户写入的内容:%s\n", hello_buf);
return count; // 返回成功写入的字节数
}
// release函数:上层应用close设备文件的时候,内核会调用这个函数
static int hello_release(struct inode *inode, struct file *filp)
{
printk("【hello_drv】设备被关闭了\n");
return 0;
}
// file_operations结构体,给内核的功能清单
static const struct file_operations hello_fops = {
.owner = THIS_MODULE,
.open = hello_open,
.read = hello_read,
.write = hello_write,
.release = hello_release,
};
// ====================== 驱动入口和出口函数 ======================
// 驱动入口函数:加载驱动模块的时候,内核会调用这个函数
static int __init hello_drv_init(void)
{
int ret;
printk("【hello_drv】驱动开始加载\n");
// 1. 动态申请设备号,新手推荐,不会冲突
ret = alloc_chrdev_region(&hello_devno, 0, 1, DEVICE_NAME);
if (ret < 0) {
printk("【hello_drv】设备号申请失败,错误码:%d\n", ret);
return ret;
}
printk("【hello_drv】设备号申请成功,主设备号:%d,次设备号:%d\n",
MAJOR(hello_devno), MINOR(hello_devno));
// 2. 初始化字符设备,绑定file_operations结构体
cdev_init(&hello_cdev, &hello_fops);
hello_cdev.owner = THIS_MODULE;
// 3. 把字符设备注册到内核里
ret = cdev_add(&hello_cdev, hello_devno, 1);
if (ret < 0) {
printk("【hello_drv】字符设备注册失败\n");
goto err_cdev_add;
}
// 4. 创建设备类,用于自动创建设备节点,不用手动mknod
hello_class = class_create(THIS_MODULE, CLASS_NAME);
if (IS_ERR(hello_class)) {
ret = PTR_ERR(hello_class);
printk("【hello_drv】设备类创建失败\n");
goto err_class_create;
}
// 5. 创建设备,自动在/dev目录下生成hello_drv设备文件
hello_device = device_create(hello_class, NULL, hello_devno, NULL, DEVICE_NAME);
if (IS_ERR(hello_device)) {
ret = PTR_ERR(hello_device);
printk("【hello_drv】设备创建失败\n");
goto err_device_create;
}
printk("【hello_drv】驱动加载成功!\n");
return 0;
// 错误处理:前面的步骤失败了,要释放已经申请的资源,避免内存泄漏
err_device_create:
class_destroy(hello_class);
err_class_create:
cdev_del(&hello_cdev);
err_cdev_add:
unregister_chrdev_region(hello_devno, 1);
return ret;
}
// 驱动出口函数:卸载驱动模块的时候,内核会调用这个函数
static void __exit hello_drv_exit(void)
{
printk("【hello_drv】驱动开始卸载\n");
// 释放所有申请的资源,顺序和申请的时候反过来
device_destroy(hello_class, hello_devno);
class_destroy(hello_class);
cdev_del(&hello_cdev);
unregister_chrdev_region(hello_devno, 1);
printk("【hello_drv】驱动卸载成功!\n");
}
// 注册驱动入口和出口函数
module_init(hello_drv_init);
module_exit(hello_drv_exit);
步骤 3:编写 Makefile,把驱动编译进内核
我们需要写一个 Makefile,告诉内核编译系统,要编译我们的驱动代码。
-
在
my_drivers目录下,创建 Makefile 文件:bash
运行
touch Makefile -
打开 Makefile,写入下面的内容:
makefile
# 把hello_drv.c编译成hello_drv.o模块 obj-y += hello_drv.o这里的
obj-y,意思是把这个驱动编译进内核镜像里,内核启动的时候会自动加载这个驱动。如果是obj-m,就是把驱动编译成可动态加载的.ko 模块,后面会讲。 -
修改上一级目录的 Makefile,让编译系统能进入我们的 my_drivers 目录。返回上一级目录,修改 Makefile:
bash
运行
cd .. vim Makefile在 Makefile 的末尾,添加下面这一行:
makefile
obj-y += my_drivers/告诉内核编译系统,要编译 my_drivers 目录里的内容。
步骤 4:编译内核,生成新的 boot.img
驱动代码和 Makefile 都写好了,现在我们编译内核,把我们的驱动编译进去。
在 SDK 根目录下,执行下面的命令,编译内核:
bash
运行
cd ~/RK3568_Android11_SDK
./build.sh -CK
参数-C是编译内核,-K是打包生成 boot.img,执行完成后,终端没有报错,就说明编译成功了,我们的驱动已经被编译进内核里了。
步骤 5:烧录 boot.img,重启开发板
- 开发板连电脑,执行
adb reboot loader进入 Loader 模式; - 打开 RKDevTool,只勾选 boot 分区,路径选择新编译的
rockdev/Image-xxx/boot.img; - 点击执行,烧录完成后,开发板自动重启。
步骤 6:验证驱动是否加载成功
开发板重启完成后,我们通过串口或者 ADB,验证驱动是否正常加载:
-
进入开发板的 shell 终端,获取 root 权限: bash
运行
adb shell su -
查看驱动的内核日志,确认驱动加载成功: bash
运行
dmesg | grep hello_drv能看到驱动打印的 "【hello_drv】驱动加载成功!" 日志,就说明驱动已经被内核正常加载了。
-
查看设备文件,确认自动创建成功: bash
运行
ls -l /dev/hello_drv能看到
/dev/hello_drv设备文件,就说明设备节点创建成功了! -
查看设备号,确认和驱动里申请的一致: bash
运行
cat /proc/devices | grep hello_drv能看到对应的主设备号,和驱动里打印的一致,就完全没问题了。
步骤 7:测试驱动的 read 和 write 功能
现在我们来测试驱动的功能,看看能不能正常读写。
-
测试 read 功能,读取设备文件,看看能不能返回我们写的字符串: bash
运行
cat /dev/hello_drv终端输出
Hello World from RK3568 Android Driver!,就说明 read 功能完全正常! -
测试 write 功能,向设备文件写入字符串,看看驱动能不能正常接收并打印: bash
运行
echo "I am 黒漂技术佬, I am learning RK Android Driver!" > /dev/hello_drv执行完成后,查看内核日志:
bash
运行
dmesg | grep hello_drv能看到驱动打印出了我们写入的字符串,就说明 write 功能也完全正常!
恭喜你!你已经成功写出了你的第一个安卓字符设备驱动,并且完成了所有功能的测试,彻底告别了只会抄代码的脚本小子!
五、两种驱动加载方式的区别,新手该怎么选?
我们上面用的是编译进内核 的方式,还有一种是编译成动态加载模块的方式,我给你讲清楚两者的区别、优缺点,新手该怎么选。
表格
| 加载方式 | 实现方法 | 优点 | 缺点 | 新手推荐指数 |
|---|---|---|---|---|
| 编译进内核 | Makefile 里写 obj-y,编译进 boot.img | 内核启动自动加载,不用手动操作,适合量产产品 | 每次修改驱动代码,都要重新编译内核、烧录 boot.img,调试麻烦 | ★★★☆☆(最终量产用) |
| 动态加载模块 | Makefile 里写 obj-m,编译成.ko 文件,用 insmod 加载 | 每次修改代码,只需要编译驱动模块,不用编译整个内核,不用重启开发板,调试极其方便 | 系统重启后,模块就没了,需要重新加载,量产的时候需要做开机自动加载 | ★★★★★(新手调试必用) |
新手调试技巧:动态加载模块的编译方法
新手调试驱动的时候,强烈推荐用动态加载的方式,不用每次都编译内核、烧录、重启,效率提升 100 倍。这里给你简单讲一下方法:
-
把 Makefile 里的
obj-y += hello_drv.o改成obj-m += hello_drv.o; -
在内核源码目录下,执行模块编译命令: bash
运行
make M=drivers/char/my_drivers modules -
编译完成后,会在 my_drivers 目录下生成
hello_drv.ko模块文件; -
用 ADB 把这个.ko 文件推到开发板里: bash
运行
adb push hello_drv.ko /data/ -
进入开发板 shell,加载模块: bash
运行
insmod /data/hello_drv.ko -
卸载模块: bash
运行
rmmod hello_drv每次修改代码,只需要重新编译模块,推到开发板里,卸载旧模块,加载新模块就行,不用重启开发板,调试巨方便。
六、新手驱动开发常见报错排查方案
表格
| 报错现象 | 核心原因 | 解决方案 |
|---|---|---|
| 驱动加载成功,但是 /dev 目录下没有设备文件 | 设备类和设备创建失败,或者权限不足 | 1. 看 dmesg 日志,有没有设备创建失败的报错;2. 检查 class_create 和 device_create 的返回值;3. 确保驱动的 LICENSE 是 GPL |
| 执行 cat /dev/hello_drv,提示 Permission denied | 设备文件权限不足 | 执行chmod 777 /dev/hello_drv,给设备文件全开权限,测试用,量产要注意权限安全 |
| copy_from_user/copy_to_user 返回非 0,数据拷贝失败 | 用户空间的指针不合法,或者长度超出范围 | 1. 检查用户空间的 buf 指针是否为空;2. 检查 count 长度是否超出缓冲区大小;3. 确保用的是__user 修饰的用户空间指针 |
| 驱动加载的时候,提示 Device or resource busy | 主设备号被占用,或者设备名重复 | 1. 用动态申请设备号,别用静态申请;2. 检查设备名有没有和内核里已有的设备重复 |
| 内核直接 Oops 崩溃,系统重启 | 访问了空指针、非法内存,或者直接操作了用户空间指针 | 1. 看 Oops 日志,定位崩溃的代码行;2. 绝对不能直接解引用用户空间的指针,必须用 copy_from_user/copy_to_user;3. 检查所有指针是否为空,再操作 |
结尾说两句
恭喜你!跟着这篇文章走到这里,你已经成功写出了你的第一个安卓字符设备驱动,彻底掌握了字符设备驱动的核心框架,已经正式踏入了安卓驱动开发的大门。
从下一篇开始,我们进入第四卷的基础外设实战环节,从最常用的 GPIO 驱动开始,手把手带你写 LED 亮灭、按键读取的驱动,并且打通「内核驱动→HAL 层→JNI→安卓 App」的完整全链路,让你写的驱动,能真正在安卓 App 里调用。
我是黒漂技术佬,关注我,带你零基础入门 RK 安卓驱动开发,不踩坑。有任何驱动开发的问题,评论区留言,我都会一一回复。