Linux驱动开发——字符设备驱动开发

1 概述

1.1 说明

本文是学习rk3568开发板驱动开发的记录,代码依托于rk3568开发板

1.2 字符设备介绍

字符设备是 Linux 驱动中最基本的一类设备驱动,字符设备就是一个一个字节,按照字节流进行读写操作的设备,读写数据是分先后顺序的。比如我们最常见的点灯、按键、IIC、SPI,LCD 等等都是字符设备,这些设备的驱动就叫做字符设备驱动。

Linux应用程序向下调用驱动程序流程如下:

在Linux中,一切皆是文件,驱动加载成功之后,会在dev目录下生成一个相应的文件,应用程序通过对这个名为"/dev/xxx"(xxx 是具体的驱动文件名字)的文件进行相应的操作即可实现对硬件的操作

应用程序运行在用户控件,Linux驱动属于内核的一部分,运行在内核空间。当我们在用户空间想要实现对内核的操作,比如使用 open 函数打开/dev/led 这个驱动,因为用

户空间不能直接对内核进行操作,因此必须使用一个叫做"系统调用"的方法来实现从用户空间"陷入"到内核空间,这样才能实现对底层驱动的操作。open、close、write 和 read 等这些函数是由 C 库提供的,在 Linux 系统中,系统调用作为 C 库的一部分,调用open函数的流程如下:

每一个系统调用,在驱动中都有与之对应的一个驱动函数,在Linux内核文件include/linux/fs.h中有个file_operations的接口提,定义了内核驱动操作函数集合。

1.3 file_operations定义

c 复制代码
struct file_operations {
	struct module *owner;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
	ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
	int (*iterate) (struct file *, struct dir_context *);
	int (*iterate_shared) (struct file *, struct dir_context *);
	__poll_t (*poll) (struct file *, struct poll_table_struct *);
	long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
	long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
	int (*mmap) (struct file *, struct vm_area_struct *);
	unsigned long mmap_supported_flags;
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *, fl_owner_t id);
	int (*release) (struct inode *, struct file *);
	int (*fsync) (struct file *, loff_t, loff_t, int datasync);
	int (*fasync) (int, struct file *, int);
	int (*lock) (struct file *, int, struct file_lock *);
	ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
	unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
	int (*check_flags)(int);
	int (*flock) (struct file *, int, struct file_lock *);
	ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
	ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
	int (*setlease)(struct file *, long, struct file_lock **, void **);
	long (*fallocate)(struct file *file, int mode, loff_t offset,
			  loff_t len);
	void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
	unsigned (*mmap_capabilities)(struct file *);
#endif
	ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
			loff_t, size_t, unsigned int);
	int (*clone_file_range)(struct file *, loff_t, struct file *, loff_t,
			u64);
	int (*dedupe_file_range)(struct file *, loff_t, struct file *, loff_t,
			u64);
	int (*fadvise)(struct file *, loff_t, loff_t, int);

	ANDROID_KABI_RESERVE(1);
	ANDROID_KABI_RESERVE(2);
	ANDROID_KABI_RESERVE(3);
	ANDROID_KABI_RESERVE(4);
} __randomize_layout;

常用的函数有:

  • owner 拥有该结构体的模块的指针,一般设置为 THIS_MODULE。
  • llseek 函数用于修改文件当前的读写位置。
  • read 函数用于读取设备文件。
  • write 函数用于向设备文件写入(发送)数据。
  • poll 是个轮询函数,用于查询设备是否可以进行非阻塞的读写。
  • unlocked_ioctl 函数提供对于设备的控制功能,与应用程序中的 ioctl 函数对应。
  • compat_ioctl 函数与 unlocked_ioctl 函数功能一样,区别在于在 64 位系统上,32 位的应用程序调用将会使用此函数。在 32 位的系统上运行 32 位的应用程序调用的是unlocked_ioctl。
  • mmap 函数用于将将设备的内存映射到进程空间中(也就是用户空间),一般帧缓冲设备会使用此函数,比如 LCD 驱动的显存,将帧缓冲(LCD 显存)映射到用户空间中以后应用程序就可以直接操作显存了,这样就不用在用户空间和内核空间之间来回复制。
  • open 函数用于打开设备文件。
  • release 函数用于释放(关闭)设备文件,与应用程序中的 close 函数对应。
  • fasync 函数用于刷新待处理的数据,用于将缓冲区中的数据刷新到磁盘中。

2 字符设备驱动开发步骤

2.1 驱动模块的加载和卸载

Linux驱动有两种运行模式,一种是直接编译进Linux内核中,内核启动的时候自动运行驱动程序。第二种就是将驱动编译成模块(Linux下模块扩展名为.ko),在Linux内核启动以后使用"modprobe"或者"insmod"命令加载。通常将其编译成模块进行调试,因为这样不用整编内核代码。当没有问题后可以考虑编译进内核。

模块有加载和卸载两种操作:

c 复制代码
module_init(xxx_init); //注册模块加载函数
module_exit(xxx_exit); //注册模块卸载函数

module_init 函数用来向 Linux 内核注册一个模块加载函数,参数 xxx_init 就是需要注册的具体函数,当使用"modprobe"命令加载驱动的时候,xxx_init 这个函数就会被调用。module_exit函数用来向 Linux 内核注册一个模块卸载函数,参数 xxx_exit 就是需要注册的具体函数,当使用"rmmod"命令卸载具体驱动的时候 xxx_exit 函数就会被调用

驱动加载和卸载模板代码如下:

c 复制代码
 /* 驱动入口函数 */
 static int __init xxx_init(void)
 {
 /* 入口函数具体内容 */
 return 0;
 }

 /* 驱动出口函数 */
 static void __exit xxx_exit(void)
 {
 /* 出口函数具体内容 */
 }

 /* 将上面两个函数指定为驱动的入口和出口函数 */
 module_init(xxx_init);
 module_exit(xxx_exit);

加载和卸载命令会调用以上的驱动函数。

有两种命令可以加载驱动模块:insmod和modprobe,二者的区别在于insmod只加载驱动模块,但是不会解决模块间的依赖关系,而modprobe可以解决模块间的依赖关系。

也有两种命令可以卸载驱动模块:rmmod和modprobe,一样的,rmmod直接卸载对应驱动模块,而modprobe可以卸载掉驱动模块所依赖的其他模块,前提是这些依赖模块已经没有被其他模块所使用,否则不能使用modprobe来卸载驱动模块。

加载和卸载命令如下:

c 复制代码
加载:
insmod drv.ko
modprobe drv.ko
卸载:
rmmod drv.ko
modprobe -r drv.ko

2.2 字符设备注册与注销

对于字符设备驱动而言,当驱动模块加载成功以后需要注册字符设备,同样,卸载驱动模块的时候也需要注销掉字符设备。

c 复制代码
static inline int register_chrdev(unsigned int major, const char *name,
				  const struct file_operations *fops)
{
	return __register_chrdev(major, 0, 256, name, fops);
}

static inline void unregister_chrdev(unsigned int major, const char *name)
{
	__unregister_chrdev(major, 0, 256, name);
}

以上两个函数就是用于字符设备注册和注销的函数

2.3 实现设备的具体操作函数

file_operations 结构体就是设备的具体操作函数,需要对这个结构体进行初始化,当应用层调用系统调用的时候,能够调用到驱动的对应函数。

1.能够对字符设备进行打开和关闭操作

设备打开和关闭是最基本的需求,需要实现file_operations 中的open和release两个函数

  1. 对字符设备进行读写操作

需要重写file_operations 中的write和read两个函数

2.4 添加license和作者信息

最后我们需要在驱动中加入 LICENSE 信息和作者信息,其中 LICENSE 是必须添加的,否则的话编译的时候会报错,作者信息可以添加也可以不添加。LICENSE 和作者信息的添加使用如下两个函数:

c 复制代码
MODULE_AUTHOR();
MODULE_LICENSE();

3 Linux设备号

3.1 设备号组成

为了方便管理,Linux 中每个设备都有一个设备号,设备号由主设备号和次设备号两部分组成,主设备号表示某一个具体的驱动,次设备号表示使用这个驱动的各个设备。Linux 提供了一个名为 dev_t 的数据类型表示设备号,dev_t 定义在文件 include/linux/types.h 里面,定义如下:

c 复制代码
typedef __u32 __kernel_dev_t;
typedef __kernel_dev_t dev_t;

dev_t是unsigned int类型的,是一个32为的数据类型,这32位分为主设备号和次设备号两个部分,高12位为主设备号,低20位为次设备号。因此 Linux 系统中主设备号范围为 0~4095,所以大家在选择主设备号的时候一定不要超过这个范围。在文件 include/linux/kdev_t.h 中提供了几个关于设备号

的操作函数(本质是宏),如下所示:

c 复制代码
#define MINORBITS 20
#define MINORMASK ((1U << MINORBITS) - 1)

#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))

3.2 设备号分配

3.2.1 静态分配设备号

本小节讲的设备号分配主要是主设备号的分配。前面讲解字符设备驱动的时候说过了,注册字符设备的时候需要给设备指定一个设备号,这个设备号可以是驱动开发者静态指定的一个设备号,比如 200 这个主设备号。有一些常用的设备号已经被 Linux 内核开发者给分配掉了,具体分配的内容可以查看文档 Documentation/devices.txt。并不是说内核开发者已经分配掉的主设备号我们就不能用了,具体能不能用还得看我们的硬件平台运行过程中有没有使用这个主设备号,使用"cat /proc/devices"命令即可查看当前系统中所有已经使用了的设备号。

3.2.2 动态分配设备号

静态分配设备号需要我们检查当前系统中所有被使用了的设备号,然后挑选一个没有使用的。而且静态分配设备号很容易带来冲突问题,Linux 社区推荐使用动态分配设备号,在注册字符设备之前先申请一个设备号,系统会自动给你一个没有被使用的设备号,这样就避免了冲突。卸载驱动的时候释放掉这个设备号即可,设备号的申请函数如下:

c 复制代码
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)

参数依次为:保存申请到的设备号;次设备号其实地址(这个函数申请一连串多个设备号,其中主设备号相同,次设备号不同,次设备号以baseminor为起始地址开始递增,一般为0);要申请的设备号数量;设备名字

注销字符设备之后要释放掉设备号,设备号释放函数如下:

c 复制代码
void unregister_chrdev_region(dev_t from, unsigned count)

参数依次为:要释放的设备号;从from开始,要释放的设备号数量

4 字符设备驱动实验

4.1 实验概述

创建一个chrdevbase虚拟设备,设备有两个缓冲区,一个读缓冲区,一个写缓冲区,两个缓冲区的大小都是100字节。应用程序可以向chrdevbase设备的写缓冲区中写入数据,从读缓冲区中读取数据。

4.2 代码编写

4.2.1 配置依赖头文件

首先,需要配置依赖的内核头文件的位置,VSCode中按下"Crtl+Shift+P"打开的控制台,然后输入

"C/C++: Edit configurations(JSON) ",打开 C/C++编辑配置文件

打开之后会自动在.vscode目录下生成一个名为 c_cpp_properties.json 的文件,需要在这个文件中添加依赖的头文件

c 复制代码
{
    "configurations": [
        {
            "name": "Linux",
            "includePath": [
                "${workspaceFolder}/**",
                "/home/alientek/code/rk3568_linux/kernel/arch/arm64/include/",
                "/home/alientek/code/rk3568_linux/kernel/include",
                "/home/alientek/code/rk3568_linux/kernel/arch/arm64/include/generated"
            ],
            "defines": [],
            "compilerPath": "/usr/bin/gcc",
            "cStandard": "c17",
            "cppStandard": "gnu++14",
            "intelliSenseMode": "linux-gcc-x64"
        }
    ],
    "version": 4
}

generated 文件夹必须先编译内核成功才会生成,并且需要确认自己的 SDK 路径

4.2.2 编写并编译驱动程序

以下是字符设备的驱动部分代码

c 复制代码
#include <linux/types.h>
#include <linux/kernel.h>
#include <linux/delay.h>
#include <linux/ide.h>
#include <linux/init.h>
#include <linux/module.h>

// 定义主设备号
#define CHRDEVBASE_MAJOR 200
// 定义设备名称
#define CHRDEVBASE_NAME "chrdevbase"

// 读写缓冲
static char readbuf[100];
static char writebuf[100];
// 内核设备返回数据
static char kernelData[] = {"kernel data!"};

// 设备open时callback,应用侧调用open打开设备时内核回调
static int chrdevbase_open(struct inode *inode, struct file *filp) {
    printk("chrdevbase open!\r\n");
    return 0;
}

// 设备read时callback,应用侧调用read读取设备时内核回调
static ssize_t chrdevbase_read(struct file *filp, char __user *buf, size_t cnt, loff_t *offt) {
    int retValue = 0;
    memcpy(readbuf, kernelData, sizeof(kernelData));
    // 将数据从内核空间拷贝到用户空间
    retValue = copy_to_user(buf, readbuf, cnt);
    if (retValue == 0) {
        printk("kernel senddata ok!\r\n");
    } else {
        printk("kernel senddata failed!\r\n");
    }
    return 0;
}

// 设备write时callback,应用侧调用write写入数据时内核回调
static ssize_t chrdevbase_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt) {
    int retValue = 0;
    // 将数据从用户空间拷贝到内核空间
    retValue = copy_from_user(writebuf, buf, cnt);
    if (retValue == 0) {
        printk("kernel recevdata:%s\r\n", writebuf);
    } else {
        printk("kernel recevdata failed!\r\n");
    }
    return 0;
}

// 设备close时callback,应用侧调用close关闭设备时内核回调
static int chrdevbase_release(struct inode *inode, struct file *filp) {
    printk("chrbasedev release!\r\n");
    return 0;
}

// 文件操作函数映射结构体
static struct file_operations chrdevbase_fops = {
    .owner = THIS_MODULE,
    .open = chrdevbase_open,
    .read = chrdevbase_read,
    .write = chrdevbase_write,
    .release = chrdevbase_release,
};

// 内核模块初始化回调
static int __init chrdevbase_init(void) {
    int retValue = 0;
    // 注册字符设备
    retValue = register_chrdev(CHRDEVBASE_MAJOR, CHRDEVBASE_NAME, &chrdevbase_fops);
    if (retValue < 0) {
        printk("chrdevbase driver register failed\r\n");
    }
    printk("chrdevbase_init()\r\n");
    return 0;
}

// 内核模块注销回调
static void __exit chrdevbase_exit(void) {
    unregister_chrdev(CHRDEVBASE_MAJOR, CHRDEVBASE_NAME);
    printk("chrdevbase_exit()\r\n");
}

// 注册内核模块的初始化回调函数和注销回调函数
module_init(chrdevbase_init);
module_exit(chrdevbase_exit);

// 添加license、作者和其他模块信息
MODULE_LICENSE("GPL");
MODULE_AUTHOR("ALIENTEK");
MODULE_INFO(intree, "Y");

这里定义主设备号为200,设备名称为chrdevbase,然后是根据file_operations来定义内核操作相关的函数,这里定义了open、read、write和realse四个函数,分别对应应用空间的open、read、write和close四个处理函数。

定义完成之后,定义了模块的初始化和退出函数,这两个函数在模块加载和移除时调用。

然后将模块初始化和退出函数注册到内核中。

最后定义了模块的一些通用信息,如license、作者和模块信息等。

解析来编写makefile文件

shell 复制代码
KERNELDIR := /home/alientek/code/rk3568_linux/kernel
CURRENT_PATH :=	$(shell pwd)
obj-m := chrdevbase.o

build: kernel_modules

kernel_modules:
	$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) modules
clean:
	$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) clean

然后执行编译命令

shell 复制代码
make ARCH=arm64

编译完成之后,会生成文件chrdevbase.ko

4.2.3 编写并编译驱动测试程序

c 复制代码
#include "stdio.h"
#include "unistd.h"
#include "sys/types.h"
#include "fcntl.h"
#include "stdlib.h"
#include "string.h"

static char userdata[] = {"user data!"};

int main(int argc, int *argv[]) {
    int fd, retValue;
    char *fileName;
    char readBuf[100], writeBuf[100];

    if (argc != 3) {
        printf("Error Usage!\r\n");
        return -1;
    }

    fileName = argv[1];
    fd = open(fileName, O_RDWR);
    if (fd < 0) {
        printf("Can't open file %s\r\n", fileName);
        return -1;
    }

    if (atoi(argv[2]) == 1) {
        retValue = read(fd, readBuf, 50);
        if (retValue < 0) {
            printf("read file %s failed!\r\n", fileName);
        } else {
            printf("read data:%s\r\n", readBuf);
        }
    }

    if (atoi(argv[2]) == 2){
        memcpy(writeBuf, userdata, sizeof(userdata));
        retValue = write(fd, writeBuf, 50);
        if (retValue < 0) {
            printf("write file %s failed!\r\n", fileName);
        }
    }

    retValue = close(fd);
    if (retValue < 0) {
        printf("Can't close file %s\r\n", fileName);
        return -1;
    }

    return 0;
}

这里没有多少逻辑,主要就是打开设备,读写设备,最后关闭设备。

编译测试应用程序

shell 复制代码
/opt/atk-dlrk356x-toolchain/bin/aarch64-buildroot-linux-gnu-gcc chrdevbaseApp.c -o chrdevbaseApp

使用atk提供的buildroot的编译工具链编译chrdevbaseApp.c成测试可执行程序chrdevbaseApp

4.3 测试驱动实验

4.3.1 push测试文件到设备

首先,将生成的内核模块chrdevbase.ko和驱动测试程序chrdevbaseApp推到设备中的/lib/modules/4.19.232目录。

其中4.19.232是内核版本号。

4.3.2 加载驱动模块

有两种方式加载驱动模块:insmod和modprobe,两者的区别就是modprobe可以加载依赖的模块,而insmod不会。

使用modprobe加载chrdevbase.ko的时候会有以下提示:

shell 复制代码
root@ATK-DLRK356X:/lib/modules/4.19.232# modprobe chrdevbase.ko
modprobe: FATAL: Module chrdevbase.ko not found in directory /lib/modules/4.19.232

modprobe 命令会在"/lib/modules/4.19.232"目录下解析 modules.dep 文件,modules.dep 文

件里面保存了要加载的.ko 模块,我们不用手动创建 modules.dep 这个文件,直接输入 depmod

命令即可自动生成 modules.dep,有些根文件系统可能没有 depmod 这个命令,如果没有这个命

令就只能重新配置 busybox,使能此命令,然后重新编译 busybox。输入"depmod"命令以后会

自动生成 modules.alias、modules.symbols 和 modules.dep 等等一些 modprobe 所需的文件,如下图所示:

然后重新加载驱动:

shell 复制代码
modprobe chrdevbase.ko

加载完成之后,通过串口可以看到内核打印

然后在设备中lsmod查看驱动模块,可以看到驱动已经加载成功

c 复制代码
cat /proc/devices

查看系统中当前的设备,可以看到设备已经存在,设备号为200

4.3.3 测试驱动模块

此时,驱动模块已经加载,但是驱动模块还没有与特定的设备节点进行绑定,测试应用程序还不能与之通信。首先我们需要创建设备节点并绑定设备。

shell 复制代码
mknod /dev/chrdevbase c 200 0

这里,mknod命令用于创建设备节点,/dev/chrdevbase就是创建的设备节点文件,c表示创建字符型设备,200是主设备号,0是次设备号。

创建设备节点完成之后,就可以与字符设备进行通信了。这里的设备节点相当于内核模块在用户空间的呈现,用于应用程序读写这个设备节点,就是操作对应内核模块注册的字符设备。

测试:
执行读操作

c 复制代码
./chrdevbaseApp /dev/chrdevbase 1

应用层读取到了内核的数据,并打印

这是内核的打印,分别对应应用层的open,read,close操作
执行写操作

c 复制代码
./chrdevbaseApp /dev/chrdevbase 2

内核接收到了应用层传下去的数据。

4.3.3 卸载驱动程序

卸载驱动程序是用rmmod命令

cpp 复制代码
rmmod chrdevbase


驱动卸载成功,lsmod查看没有chrdevbase这个模块了。同时内核打印显示也调用了exit函数。

相关推荐
tian2kong1 分钟前
Centos 7 修改YUM镜像源地址为阿里云镜像地址
linux·阿里云·centos
布鲁格若门5 分钟前
CentOS 7 桌面版安装 cuda 12.4
linux·运维·centos·cuda
Eternal-Student10 分钟前
【docker 保存】将Docker镜像保存为一个离线的tar归档文件
运维·docker·容器
C-cat.13 分钟前
Linux|进程程序替换
linux·服务器·microsoft
怀澈12215 分钟前
高性能服务器模型之Reactor(单线程版本)
linux·服务器·网络·c++
DC_BLOG17 分钟前
Linux-Apache静态资源
linux·运维·apache
学Linux的语莫18 分钟前
Ansible Playbook剧本用法
linux·服务器·云计算·ansible
码农小丘19 分钟前
一篇保姆式centos/ubuntu安装docker
运维·docker·容器
耗同学一米八1 小时前
2024 年河北省职业院校技能大赛网络建设与运维赛项样题二
运维·网络·mariadb
skywalk81631 小时前
树莓派2 安装raspberry os 并修改成固定ip
linux·服务器·网络·debian·树莓派·raspberry