第 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 安卓驱动开发,不踩坑。有任何驱动开发的问题,评论区留言,我都会一一回复。

相关推荐
阿拉斯攀登2 小时前
第 1 篇 入坑不亏!瑞芯微 RK 平台 + 安卓驱动开发,小白全维度扫盲
android·驱动开发·rk3568·嵌入式驱动
阿拉斯攀登2 小时前
第 2 篇 小白前置知识急救包!RK 安卓驱动开发必备知识点,一篇补全
c语言·嵌入式·rk3568·安卓驱动
Android系统攻城狮2 小时前
Android tinyalsa深度解析之pcm_params_get调用流程与实战(一百六十二)
android·pcm·tinyalsa·android hal·audio hal
zh路西法2 小时前
【C语言简明教程提纲】(四):结构体与文件定义和操作
android·c语言·redis
常利兵2 小时前
Jetpack Compose 1.8 新特性来袭,打造丝滑开发体验
android
牢七3 小时前
百家cms 审计 未完成
android·ide·android studio
hjxu20163 小时前
【 MySQL 速记5】插入
android·数据库·mysql
aq55356006 小时前
MySQL-触发器(TRIGGER)
android·数据库·mysql
一起搞IT吧6 小时前
Android功耗系列专题理论之十六:功耗不同阶段&不同模块分析说明
android·c++·智能手机·性能优化