第 7 篇 安卓驱动开发的灵魂:字符设备驱动框架,从原理到最简实战

目录

开篇先搞懂:字符设备驱动的核心原理

字符设备驱动的两个核心核心概念,小白必须懂

[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):从完整的设备号里,提取次设备号。
设备号的两种申请方式,新手必记
  1. 静态申请 :自己指定一个主设备号,通过register_chrdev_region()函数,向内核申请。优点是简单,缺点是如果指定的主设备号已经被内核里的其他驱动占用了,就会申请失败;
  2. 动态申请 :不指定主设备号,通过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设备文件的时候,会调用这个函数
};

每个函数的作用,我给你用大白话讲透:

  1. open 函数:设备打开函数,上层应用打开设备文件的时候,内核会自动调用这个函数。我们一般在这个函数里,做设备的初始化工作,比如硬件初始化、变量初始化、权限检查;
  2. read 函数:数据读取函数,上层应用要从设备里读取数据的时候,内核会调用这个函数。比如读取传感器的数据、读取按键的状态,都在这个函数里实现;
  3. write 函数:数据写入函数,上层应用要向设备里写入数据的时候,内核会调用这个函数。比如控制 LED 亮灭、设置电机转速,都在这个函数里实现;
  4. release 函数:设备关闭函数,上层应用关闭设备文件的时候,内核会调用这个函数。我们在这个函数里,做资源释放的工作,比如释放内存、关闭硬件。

二、用户空间与内核空间的数据交互,绝对不能踩的红线

上一篇前置知识里,我们讲过 Linux 把内存分为了用户空间和内核空间,两者不能直接互相访问内存。这里再给大家强调一遍,这是驱动开发的红线,踩了内核直接崩溃。

  • 上层应用运行在用户空间,权限很低,不能直接访问内核空间的内存,也不能直接操作硬件;
  • 驱动代码运行在内核空间,拥有最高权限,可以访问所有内存,操作硬件。

那上层应用和驱动之间,怎么传递数据呢?内核给我们提供了两个专门的函数,也是字符设备驱动里必用的两个函数:

  1. copy_from_user():把用户空间里的数据,拷贝到内核空间里。上层应用 write 数据给驱动的时候,用这个函数,把用户空间的写入数据,拷贝到驱动的内核缓冲区里;
  2. copy_to_user():把内核空间里的数据,拷贝到用户空间里。上层应用 read 数据的时候,用这个函数,把驱动里的数据,拷贝到用户空间的缓冲区里,返回给上层应用。

小白红线警告

  • 绝对不能直接对用户空间的指针进行解引用、读写操作!必须用上面两个函数拷贝数据,不然内核直接 Oops 崩溃;
  • 绝对不能在这两个函数里传入空指针,必须先检查指针的合法性,不然内核直接崩溃。

三、字符设备驱动的完整生命周期

一个完整的字符设备驱动,从加载到卸载,有一个完整的生命周期,分为 5 个核心步骤,我给你按顺序讲透:

  1. 驱动模块加载 :用insmod命令加载驱动模块,或者内核启动的时候自动加载驱动,执行驱动的入口函数;
  2. 设备号申请与注册:在入口函数里,向内核申请设备号,初始化字符设备,把 file_operations 结构体注册到内核里;
  3. 设备节点创建 :内核自动(或者我们手动)在/dev目录下,创建设备文件,上层应用就是通过这个文件,和驱动交互;
  4. 设备操作:上层应用打开设备文件,执行 read、write、ioctl 操作,内核调用驱动里对应的函数,操作硬件;
  5. 驱动模块卸载 :用rmmod命令卸载驱动模块,执行驱动的出口函数,释放申请的设备号、注销字符设备、释放所有资源。

四、保姆级实战:写你的第一个 hello world 字符设备驱动

讲完所有原理,我们正式开始写代码,基于 RK3568 平台,写一个最简单的 hello world 字符设备驱动,实现:

  • 上层应用 read 设备文件的时候,返回给用户空间一串 "Hello World from RK3568 Android Driver!" 字符串;
  • 上层应用 write 设备文件的时候,驱动把用户写入的字符串,通过内核日志打印出来;
  • 支持 open 和 close 操作,打印对应的日志。

前置准备

  1. 已经搭建好 Ubuntu 开发环境,RK SDK 能正常编译;
  2. 开发板烧录、串口、ADB 调试正常;
  3. 已经掌握了上一篇的设备树基础。

步骤 1:创建驱动代码文件

  1. 进入 Ubuntu 虚拟机,打开终端,进入内核的字符设备驱动目录: bash

    运行

    复制代码
    cd ~/RK3568_Android11_SDK/kernel/drivers/char/
  2. 创建我们的驱动目录,专门存放我们自己写的驱动,方便管理: bash

    运行

    复制代码
    mkdir my_drivers
    cd my_drivers
  3. 创建驱动代码文件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,告诉内核编译系统,要编译我们的驱动代码。

  1. my_drivers目录下,创建 Makefile 文件:

    bash

    运行

    复制代码
    touch Makefile
  2. 打开 Makefile,写入下面的内容:

    makefile

    复制代码
    # 把hello_drv.c编译成hello_drv.o模块
    obj-y += hello_drv.o

    这里的obj-y,意思是把这个驱动编译进内核镜像里,内核启动的时候会自动加载这个驱动。如果是obj-m,就是把驱动编译成可动态加载的.ko 模块,后面会讲。

  3. 修改上一级目录的 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,重启开发板

  1. 开发板连电脑,执行adb reboot loader进入 Loader 模式;
  2. 打开 RKDevTool,只勾选 boot 分区,路径选择新编译的rockdev/Image-xxx/boot.img
  3. 点击执行,烧录完成后,开发板自动重启。

步骤 6:验证驱动是否加载成功

开发板重启完成后,我们通过串口或者 ADB,验证驱动是否正常加载:

  1. 进入开发板的 shell 终端,获取 root 权限: bash

    运行

    复制代码
    adb shell
    su
  2. 查看驱动的内核日志,确认驱动加载成功: bash

    运行

    复制代码
    dmesg | grep hello_drv

    能看到驱动打印的 "【hello_drv】驱动加载成功!" 日志,就说明驱动已经被内核正常加载了。

  3. 查看设备文件,确认自动创建成功: bash

    运行

    复制代码
    ls -l /dev/hello_drv

    能看到/dev/hello_drv设备文件,就说明设备节点创建成功了!

  4. 查看设备号,确认和驱动里申请的一致: bash

    运行

    复制代码
    cat /proc/devices | grep hello_drv

    能看到对应的主设备号,和驱动里打印的一致,就完全没问题了。

步骤 7:测试驱动的 read 和 write 功能

现在我们来测试驱动的功能,看看能不能正常读写。

  1. 测试 read 功能,读取设备文件,看看能不能返回我们写的字符串: bash

    运行

    复制代码
    cat /dev/hello_drv

    终端输出Hello World from RK3568 Android Driver!,就说明 read 功能完全正常!

  2. 测试 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 倍。这里给你简单讲一下方法:

  1. 把 Makefile 里的obj-y += hello_drv.o改成obj-m += hello_drv.o

  2. 在内核源码目录下,执行模块编译命令: bash

    运行

    复制代码
    make M=drivers/char/my_drivers modules
  3. 编译完成后,会在 my_drivers 目录下生成hello_drv.ko模块文件;

  4. 用 ADB 把这个.ko 文件推到开发板里: bash

    运行

    复制代码
    adb push hello_drv.ko /data/
  5. 进入开发板 shell,加载模块: bash

    运行

    复制代码
    insmod /data/hello_drv.ko
  6. 卸载模块: 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 安卓驱动开发,不踩坑。有任何驱动开发的问题,评论区留言,我都会一一回复。

相关推荐
千里马学框架21 小时前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台21 小时前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone21 小时前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone1 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui