ARM Linux 驱动开发学习之字符设备驱动开发

Linux 中的设备驱动程序是连接硬件设备和操作系统之间的重要桥梁,确保硬件设备能够在操作系统控制下正常工作。在 Linux 中,设备驱动主要分为三大类:字符设备驱动、块设备驱动和网络设备驱动。下面我将详细解释这三类驱动的特点、用途和区别。

  1. 字符设备驱动
    字符设备驱动程序主要用于那些按字符(byte)进行数据传输的设备,如键盘、鼠标、串口等。这类设备通常处理的数据量较小,数据传输以字节为单位,不需要缓冲区。字符设备的一个主要特点是它们通常支持顺序访问,不支持随机访问。
    字符设备的种类繁多,包括但不限于:
  • 简单的输入输出设备,如点灯、LED显示。
  • 通信接口设备,如串口、USB、I2C、SPI等。
  • 高级功能设备,如音频处理设备。
    字符设备驱动的复杂性在于需要处理各种不同类型的设备和通信协议,但每个设备的数据处理通常较为简单。
  1. 块设备驱动
    块设备驱动程序用于那些以数据块(block)为单位进行数据存储和传输的设备,如硬盘、固态硬盘、EMMC、NAND闪存、SD卡和U盘等。这类设备的特点是支持随机访问,数据通常以块(通常是几百到几千字节)为单位进行读写。
    块设备驱动的复杂性主要来自于需要管理的数据量大,以及数据的读写操作涉及到缓存管理、数据完整性保护和性能优化等方面。
  2. 网络设备驱动
    网络设备驱动程序用于处理网络通信的设备,包括有线网络设备(如以太网卡)和无线网络设备(如Wi-Fi、蓝牙适配器)。网络设备驱动的任务是实现数据包的发送和接收,以及与网络协议栈的交互。
    网络设备驱动的复杂性在于需要处理高速数据传输和网络协议的复杂性,以及在高负荷下保持稳定性和性能。
    设备的多重分类
    有些设备可能同时属于多个驱动类别。例如,USB Wi-Fi设备既是字符设备(因为它使用USB接口进行数据传输),也是网络设备(因为它执行网络通信任务)。这种设备需要同时实现两种类型的驱动接口。
    首先学习字符设备驱动开发。

字符设备驱动简介

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

在这里插入图片描述
使用 open 函数来打开文件/dev/led,使用完成以后使用 close 函数关闭/dev/led 这个文件。 open和 close 就是打开和关闭 led 驱动的函数,如果要点亮或关闭 led,那么就使用 write 函数来操作,也就是向此驱动写入数据,这个数据就是要关闭还是要打开 led 的控制参数。如果要获取led 灯的状态,就用 read 函数从驱动中读取相应的状态。
应用程序运行在用户空间,而 Linux 驱动属于内核的一部分,因此驱动运行于内核空间。当我们在用户空间想要实现对内核的操作,比如使用 open 函数打开/dev/led 这个驱动,因为用户空间不能直接对内核进行操作,因此必须使用一个叫做“系统调用”的方法来实现从用户空间“陷入” 到内核空间,这样才能实现对底层驱动的操作。 open、 close、 write 和 read 等这些函数是由 C 库提供的,在 Linux 系统中,系统调用作为 C 库的一部分。当我们调用 open 函数的时候流程如图所示:
在这里插入图片描述
比如应用程序中调用了 open 这个函数,那么在驱动程序中也得有一个名为 open 的函数。每一个系统调用,在驱动中都有与之对应的一个驱动函数,在 Linux 内核文件 include/linux/fs.h 中有个叫做 file_operations 的结构体,此结构体就是 Linux 内核驱动操作函数集合,内容如下所示:

1588 struct file_operations {
1589 struct module *owner;
1590 loff_t (*llseek) (struct file *, loff_t, int);
1591 ssize_t (*read) (struct file *, char __user *, size_t, loff_t
*);
1592 ssize_t (*write) (struct file *, const char __user *, size_t,
loff_t *);
1593 ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
1594 ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
1595 int (*iterate) (struct file *, struct dir_context *);
1596 unsigned int (*poll) (struct file *, struct poll_table_struct
*);
1597 long (*unlocked_ioctl) (struct file *, unsigned int, unsigned
long);
1598 long (*compat_ioctl) (struct file *, unsigned int, unsigned
long);
1599 int (*mmap) (struct file *, struct vm_area_struct *);
1600 int (*mremap)(struct file *, struct vm_area_struct *);
1601 int (*open) (struct inode *, struct file *);
1602 int (*flush) (struct file *, fl_owner_t id);
1603 int (*release) (struct inode *, struct file *);
1604 int (*fsync) (struct file *, loff_t, loff_t, int datasync);
1605 int (*aio_fsync) (struct kiocb *, int datasync);
1606 int (*fasync) (int, struct file *, int);
1607 int (*lock) (struct file *, int, struct file_lock *);
1608 ssize_t (*sendpage) (struct file *, struct page *, int, size_t,
loff_t *, int);
1609 unsigned long (*get_unmapped_area)(struct file *, unsigned long,
unsigned long, unsigned long, unsigned long);
1610 int (*check_flags)(int);
1611 int (*flock) (struct file *, int, struct file_lock *);
1612 ssize_t (*splice_write)(struct pipe_inode_info *, struct file *,
loff_t *, size_t, unsigned int);
1613 ssize_t (*splice_read)(struct file *, loff_t *, struct
pipe_inode_info *, size_t, unsigned int);
1614 int (*setlease)(struct file *, long, struct file_lock **, void
**);
1615 long (*fallocate)(struct file *file, int mode, loff_t offset,
1616 loff_t len);
1617 void (*show_fdinfo)(struct seq_file *m, struct file *f);
1618 #ifndef CONFIG_MMU
1619 unsigned (*mmap_capabilities)(struct file *);
1620 #endif
1621 };

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

字符设备驱动开发步骤

1、驱动模块的加载和卸载

Linux 内核启动以后使用“insmod”命令加载驱动模块。在调试驱动的时候一般都选择将其编译为模块,这样我们修改驱动以后只需要编译一下驱动代码即可,不需要编译整个 Linux 代码。而且在调试的时候只需要加载或者卸载驱动模块即可,不需要重启整个系统。
模块有加载和卸载两种操作,我们在编写驱动的时候需要注册这两种操作函数,模块的加载和卸载注册函数如下:

module_init(xxx_init); //注册模块加载函数
module_exit(xxx_exit); //注册模块卸载函数

module_init 函数用来向 Linux 内核注册一个模块加载函数,参数 xxx_init 就是需要注册的具体函数,当使用“insmod”命令加载驱动的时候, xxx_init 这个函数就会被调用。 module_exit()函数用来向 Linux 内核注册一个模块卸载函数,参数 xxx_exit 就是需要注册的具体函数,当使用“rmmod”命令卸载具体驱动的时候 xxx_exit 函数就会被调用。字符设备驱动模块加载和卸载模板如下所示:

1 /* 驱动入口函数 */
2 static int __init xxx_init(void)
3 {
4 /* 入口函数具体内容 */
5 return 0;
6 }
7 8
/* 驱动出口函数 */
9 static void __exit xxx_exit(void)
10 {
11 /* 出口函数具体内容 */
12 }
13
14 /* 将上面两个函数指定为驱动的入口和出口函数 */
15 module_init(xxx_init);
16 module_exit(xxx_exit);

第 2 行,定义了个名为 xxx_init 的驱动入口函数,并且使用了“__init”来修饰。
第 9 行,定义了个名为 xxx_exit 的驱动出口函数,并且使用了“__exit”来修饰。
第 15 行,调用函数 module_init 来声明 xxx_init 为驱动入口函数,当加载驱动的时候 xxx_init函数就会被调用。
第16行,调用函数module_exit来声明xxx_exit为驱动出口函数,当卸载驱动的时候xxx_exit函数就会被调用。

驱动编译完成以后扩展名为.ko,有两种命令可以加载驱动模块: insmod和modprobe, insmod是最简单的模块加载命令,此命令用于加载指定的.ko 模块,比如加载 drv.ko 这个驱动模块,命令如下:

insmod drv.ko

insmod 命令不能解决模块的依赖关系,比如 drv.ko 依赖 first.ko 这个模块,就必须先使用insmod 命令加载 first.ko 这个模块,然后再加载 drv.ko 这个模块。但是 modprobe 就不会存在这个问题, modprobe 会分析模块的依赖关系,然后会将所有的依赖模块都加载到内核中,因此modprobe 命令相比 insmod 要智能一些。 modprobe 命令主要智能在提供了模块的依赖性分析、错误检查、错误报告等功能,推荐使用 modprobe 命令来加载驱动。 modprobe 命令默认会去/lib/modules/目录中查找模块,比如本书使用的 Linux kernel 的版本号为 4.1.15,因此 modprobe 命令默认会到/lib/modules/4.1.15 这个目录中查找相应的驱动模块,一般自己制作的根文件系统中是不会有这个目录的,所以需要自己手动创建。
驱动模块的卸载使用命令“rmmod”即可,比如要卸载 drv.ko,使用如下命令即可:

rmmod drv.ko

也可以使用“modprobe -r”命令卸载驱动,比如要卸载 drv.ko,命令如下:

modprobe -r drv.ko

2、字符设备注册与注销

在Linux系统中,字符设备驱动的注册和注销是管理设备驱动生命周期的关键步骤。这些步骤确保了系统的稳定性、安全性以及资源的有效管理。
当一个字符设备驱动模块被加载到内核时,它必须注册自己以便内核和用户空间的应用程序能够访问该设备。注册过程通常涉及以下几个关键步骤:

  1. 分配设备号:设备号是识别设备的唯一标识,包括主设备号和次设备号。主设备号用来识别设备的类型或驱动程序,次设备号用于识别同一驱动下的不同设备实例。内核通过设备号来将设备文件与相应的驱动程序关联起来。

  2. 创建设备文件(通常由udev自动完成):设备文件(位于/dev目录下)为用户空间提供了访问设备的接口。这些文件是与设备驱动通信的桥梁。

  3. 注册设备驱动:这一步骤涉及将设备的操作函数(如open, read, write等)注册到内核中,这样当用户程序通过设备文件调用这些操作时,内核知道应该调用哪个驱动程序的哪个函数。

通过注册,驱动程序告诉内核:“我在这里,我知道如何处理这个设备的操作请求。”这样,当用户或其他系统程序尝试通过设备文件与设备通信时,内核能够正确地将请求路由到适当的驱动程序。

注销字符设备

当驱动模块被卸载时,必须注销其字符设备,这包括以下几个原因:

  1. 释放设备号:卸载驱动时释放其设备号,使得这些设备号可以被其他设备使用。这防止了设备号的浪费和冲突。

  2. 移除设备文件的关联:注销驱动程序时,应确保设备文件不再关联到已卸载的驱动。这防止了用户程序尝试使用不存在的驱动功能,从而可能导致系统错误或崩溃。

  3. 防止资源泄漏:驱动程序可能会在运行时分配内存或其他资源。注销设备时,应确保这些资源被正确释放,避免内存泄漏等问题。

  4. 保证系统的稳定性和安全性:如果驱动未正确注销,它可能还在响应系统调用,这可能导致不可预测的行为或系统崩溃。
    字符设备的注册和注销函数原型如下所示:

static inline int register_chrdev(unsigned int major, const char *name,const struct file_operations *fops)
static inline void unregister_chrdev(unsigned int major, const char *name)
register_chrdev 函数用于注册字符设备,此函数一共有三个参数,这三个参数的含义如下:
major: 主设备号, Linux 下每个设备都有一个设备号,设备号分为主设备号和次设备号两部分
name:设备名字,指向一串字符串。
fops: 结构体 file_operations 类型指针,指向设备的操作函数集合变量。
unregister_chrdev 函数用户注销字符设备,此函数有两个参数,这两个参数含义如下:
major: 要注销的设备对应的主设备号。
name: 要注销的设备对应的设备名
示例代码 40.2.2.1 加入字符设备注册和注销
1 static struct file_operations test_fops;
2 3
/* 驱动入口函数 */
4 static int __init xxx_init(void)
5 {
6 /* 入口函数具体内容 */
7 int retvalue = 0;
8 9
/* 注册字符设备驱动 */
10 retvalue = register_chrdev(200, "chrtest", &test_fops);
11 if(retvalue < 0){
12 /* 字符设备注册失败,自行处理 */
13 }
14 return 0;
15 }
16
17 /* 驱动出口函数 */
18 static void __exit xxx_exit(void)
19 {
20 /* 注销字符设备驱动 */
21 unregister_chrdev(200, "chrtest");
22 }
23
24 /* 将上面两个函数指定为驱动的入口和出口函数 */
25 module_init(xxx_init);
26 module_exit(xxx_exit);

第 1 行,定义了一个 file_operations 结构体变量 test_fops, test_fops 就是设备的操作函数集
合,只是此时我们还没有初始化 test_fops 中的 open、 release 等这些成员变量,所以这个操作函
数集合还是空的。
第 10 行,调用函数 register_chrdev 注册字符设备,主设备号为 200,设备名字为“chrtest”,设备操作函数集合就是第 1 行定义的 test_fops。要注意的一点就是,选择没有被使用的主设备号,输入命令“cat/proc/
devices”可以查看当前已经被使用掉的设备号。如下图所示:

在这里插入图片描述

第 21 行,调用函数 unregister_chrdev 注销主设备号为 200 的这个设备。

3 、实现设备的具体操作函数

1、对chrtest进行打开关闭操作

file_operations 结构体就是设备的具体操作函数。设备打开和关闭是最基本的要求,几乎所有的设备都得提供打开和关闭的功能。因此我们需要实现 file_operations 中的 open 和 release 这两个函数。

2、对 chrtest 进行读写操作

假设 chrtest 这个设备控制着一段缓冲区(内存),应用程序需要通过 read 和 write 这两个函数对 chrtest 的缓冲区进行读写操作。所以需要实现 file_operations 中的 read 和 write 这两个函数。

1 /* 打开设备 */
2 static int chrtest_open(struct inode *inode, struct file *filp)
3 {
4 		/* 用户实现具体功能 */
5 		return 0;
6 }
7 8
/* 从设备读取 */
9 static ssize_t chrtest_read(struct file *filp, char __user *buf,size_t cnt, loff_t *offt)
10 {
11 		/* 用户实现具体功能 */
12 		return 0;
13 }
14
15 /* 向设备写数据 */
16 static ssize_t chrtest_write(struct file *filp,const char __user *buf,size_t cnt, loff_t *offt)
17 {
18 		/* 用户实现具体功能 */
19 		return 0;
20 }
21
22 /* 关闭/释放设备 */
23 static int chrtest_release(struct inode *inode, struct file *filp)
24 {
25 		/* 用户实现具体功能 */
26 		return 0;
27 }
28
29 static struct file_operations test_fops = {
30 .owner = THIS_MODULE,
31 .open = chrtest_open,
32 .read = chrtest_read,
33 .write = chrtest_write,
34 .release = chrtest_release,
35 };
36
37 /* 驱动入口函数 */
38 static int __init xxx_init(void)
39 {
40 /* 入口函数具体内容 */
41 int retvalue = 0;
42
43 /* 注册字符设备驱动 */
44 retvalue = register_chrdev(200, "chrtest", &test_fops);
45 if(retvalue < 0){
46 /* 字符设备注册失败,自行处理 */
47 }
48 return 0;
49 }
50
51 /* 驱动出口函数 */
52 static void __exit xxx_exit(void)
53 {
54 /* 注销字符设备驱动 */
55 unregister_chrdev(200, "chrtest");
56 }
57
58 /* 将上面两个函数指定为驱动的入口和出口函数 */
59 module_init(xxx_init);
60 module_exit(xxx_exit);

3、 添加 LICENSE 和作者信息

LICENSE 是必须添加的,否则的话编译的时候会报错,作者信息可以添加也可以不添加。 LICENSE 和作者信息的添加使用如下两个函数:

MODULE_LICENSE() //添加模块 LICENSE 信息
MODULE_AUTHOR() //添加模块作者信息

加入 LICENSE 和作者信息,完成以后的内容如下:

62 MODULE_LICENSE("GPL");
63 MODULE_AUTHOR("name");

Linux 设备号

设备号的组成

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

12 typedef __u32 __kernel_dev_t;
......
15 typedef __kernel_dev_t dev_t;

可以看出 dev_t 是__u32 类型的,而__u32 定义在文件 include/uapi/asm-generic/int-ll64.h 里面,定义如下:

26 typedef unsigned int __u32;

其中高 12 位为主设备号, 低 20 位为次设备号。因此 Linux系统中主设备号范围为 0~4095,
include/linux/kdev_t.h 中提供了几个关于设备号的操作函数(本质是宏),如下所示:

 示例代码 40.3.3 设备号操作函数
6 #define MINORBITS 20
7 #define MINORMASK ((1U << MINORBITS) - 1)
8 9
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS))
10 #define MINOR(dev) ((unsigned int) ((dev) & MINORMASK))
11 #define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi))

设备号的分配

1、静态分配设备号
本小节讲的设备号分配主要是主设备号的分配。前面讲解字符设备驱动的时候说过了,注册字符设备的时候需要给设备指定一个设备号,这个设备号可以是驱动开发者静态的指定一个设备号,比如选择 200 这个主设备号。有一些常用的设备号已经被 Linux 内核开发者给分配掉了,具体分配的内容可以查看文档 Documentation/devices.txt。并不是说内核开发者已经分配掉的主设备号我们就不能用了,具体能不能用还得看我们的硬件平台运行过程中有没有使用这个主设备号,使用“cat /proc/devices”命令即可查看当前系统中所有已经使用了的设备号。
2、动态分配设备号
静态分配设备号需要我们检查当前系统中所有被使用了的设备号,然后挑选一个没有使用的。而且静态分配设备号很容易带来冲突问题, Linux 社区推荐使用动态分配设备号,在注册字符设备之前先申请一个设备号,系统会自动给你一个没有被使用的设备号,这样就避免了冲突。卸载驱动的时候释放掉这个设备号即可,设备号的申请函数如下:

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)
函数 alloc_chrdev_region 用于申请设备号,此函数有 4 个参数:
dev:保存申请到的设备号。
baseminor: 次设备号起始地址, alloc_chrdev_region 可以申请一段连续的多个设备号,这
些设备号的主设备号一样,但是次设备号不同,次设备号以 baseminor 为起始地址地址开始递
增。一般 baseminor 为 0,也就是说次设备号从 0 开始。
count: 要申请的设备号数量。
name:设备名字。
注销字符设备之后要释放掉设备号,设备号释放函数如下:
void unregister_chrdev_region(dev_t from, unsigned count)
此函数有两个参数:
from:要释放的设备号。
count: 表示从 from 开始,要释放的设备号数量。

实验程序编写

1、创建 VSCode 工程

在 Linux_Drivers 目录下新建一个名为 1_chrdevbase 的子目录来存放本实验所有文件,如图 所示:
在这里插入图片描述

2、添加linux源码的路径(根据自己板子linux源码的路径来)

打开 VSCode,按下“Crtl+Shift+P”打开 VSCode 的控制台,然后输入“C/C++: Edit configurations(JSON) ”,打开 C/C++编辑配置文件,
在这里插入图片描述

3、编写实验程序

新建 chrdevbase.c,然后在里面输入如下内容:

#include <linux/types.h>
#include <linux/kernel.h>
#include <linux/delay.h>
#include <linux/init.h>
#include <linux/module.h>
#include <linux/ide.h>

#define CHRDEVBASE_MAJOR 200 // 主设备号
#define CHRDEVBASE_NAME "chrdevbase"

static char readbuf[100];
static char writebuf[100];
static char kerneldata[] = {"kernel data!"};

/*
 * @description : 打开设备
 * @param – inode : 传递给驱动的 inode
 * @param - filp : 设备文件, file 结构体有个叫做 private_data 的成员变量
 * 一般在 open 的时候将 private_data 指向设备结构体。
 * @return : 0 成功;其他 失败
 */
static int chrdevbase_open(struct inode *inode, struct file *filp)
{

    return 0;
}
/*
 * @description : 从设备读取数据
 * @param - filp : 要打开的设备文件(文件描述符)
 * @param - buf : 返回给用户空间的数据缓冲区
 * @param - cnt : 要读取的数据长度
 * @param - offt : 相对于文件首地址的偏移
 * @return : 读取的字节数,如果为负值,表示读取失败
 */
static int chrdevbase_read(struct file *flip, 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("kerneldata senddate success\r\n");
    }
    else{
        printk("kerneldata senddate failed\r\n");
    }
    return 0;
}

/*
 * @description : 向设备写数据
 * @param - filp : 设备文件,表示打开的文件描述符
 * @param - buf : 要写给设备写入的数据
 * @param - cnt : 要写入的数据长度
 * @param - offt : 相对于文件首地址的偏移
 * @return : 写入的字节数,如果为负值,表示写入失败
 */
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");
	}
}
static int chrdevbase_release(struct inode *inode, struct file *filp)
{

    return 0;
}
static struct file_operations chrdevbase_fops = {
    .owner = THIS_MODULE,
    .open = chrdevbase_open,
    .read = chrdevbase_read,
    .write = chrdevbase_write,
    .release = chrdevbase_release,
};
/*
 * @description : 驱动入口函数
 * @param : 无
 * @return : 0 成功;其他 失败
 */
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("wyw");

输入的时候会显示某些头文件错误,不用理会先,因为vscode只是文本编辑器用来,编译程序还是要在命令行。
在这里插入图片描述
上面还需要注意的打印用的是printfk这个函数。这里使用了 printk 来输出信息,而不是 printf!因为在 Linux 内核中没有 printf 这个函数。 printk 相当于 printf 的孪生兄妹, printf运行在用户态, printk 运行在内核态。在内核中想要向控制台输出或显示一些内容,必须使用printk 这个函数。不同之处在于, printk 可以根据日志级别对消息进行分类,一共有 8 个消息级别,这 8 个消息级别定义在文件 include/linux/kern_levels.h 里面,定义如下:

#define KERN_SOH "\001"
#define KERN_EMERG KERN_SOH "0" /* 紧急事件,一般是内核崩溃 */
#define KERN_ALERT KERN_SOH "1" /* 必须立即采取行动 */
#define KERN_CRIT KERN_SOH "2" /* 临界条件,比如严重的软件或硬件错误*/
#define KERN_ERR KERN_SOH "3" /* 错误状态,一般设备驱动程序中使用
KERN_ERR 报告硬件错误 */
#define KERN_WARNING KERN_SOH "4" /* 警告信息,不会对系统造成严重影响 */
#define KERN_NOTICE KERN_SOH "5" /* 有必要进行提示的一些信息 */
#define KERN_INFO KERN_SOH "6" /* 提示性的信息 */
#define KERN_DEBUG KERN_SOH "7" /* 调试信息 */

printk(KERN_EMERG "gsmi: Log Shutdown Reason\n");上述代码就是设置“gsmi: Log Shutdown Reason\n”这行消息的级别为 KERN_EMERG。在具体的消息前面加上 KERN_EMERG 就可以将这条消息的级别设置为KERN_EMERG。用 printk 的 时 候 不 显 式 的 设 置 消 息 级 别 , 那 么 printk 将 会 采 用 默 认 级 别
MESSAGE_LOGLEVEL_DEFAULT, MESSAGE_LOGLEVEL_DEFAULT 默认为 4。在 include/linux/printk.h 中有个宏 CONSOLE_LOGLEVEL_DEFAULT,定义如下:

#define CONSOLE_LOGLEVEL_DEFAULT 7

CONSOLE_LOGLEVEL_DEFAULT 控制着哪些级别的消息可以显示在控制台上,此宏默认为 7,意味着只有优先级高于 7 的消息才能显示在控制台上。这个就是 printk 和 printf 的最大区别,可以通过消息级别来决定哪些消息可以显示在控制台上。默认消息级别为 4, 4 的级别比 7 高,所示直接使用 printk 输出的信息是可以显示在控制台上。

4、编写测试 APP

测试 APP 很简单通过输入相应的指令来对 chrdevbase 设备执行读或者写操作。在1_chrdevbase 目录中新建 chrdevbaseApp.c 文件,在此文件中输入如下内容:

#include "stdio.h"
#include "unistd.h"
#include "sys/types.h"
#include "sys/stat.h"
#include "fcntl.h"
#include "stdlib.h"
#include "string.h"
static char usrdata[] = {"usr data!"};
/*
24 * @description : main 主程序
25 * @param - argc : argv 数组元素个数
26 * @param - argv : 具体参数
27 * @return : 0 成功;其他 失败
28 */
int main(int argc, char *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, usrdata, sizeof(usrdata));
        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;
}

上面程序需要注意的是:
main函数可以通过两个参数argc和argv接收命令行输入的参数。这两个参数提供了一种机制来处理命令行输入,使得程序能够根据用户的输入执行不同的操作。
参数解释:
argc (Argument Count):argc表示传递给程序的命令行参数的数量。它是一个整数,表示包括程序名称在内的参数总数。当运行./chrdevbaseApp /dev/chrdevbase 1时,argc的值为3。
argv (Argument Vector):argv是一个字符串数组(实际上是指针的数组),每个元素指向一个参数字符串。argv[0]始终指向程序的名称,
argv[1]到argv[argc-1]指向其他参数。在上面的例子中,argv[0]是"./chrdevbaseApp",argv[1]是"/dev/chrdevbase",argv[2]是"1"。

5:、编译驱动程序和测试 APP

1、编译驱动程序
首先编译驱动程序,也就是 chrdevbase.c 这个文件,我们需要将其编译为.ko 模块,创建Makefile 文件,然后在其中输入如下内容:

KERNELDIR := /home/wyw/linux/IMX6ULL/linux/temp/linux-imxrel_imx_4.1.15_2.1.0_ga_alientek
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)

输入make编译,就会生成一个叫做 chrdevbaes.ko 的文件,此文件就是 chrdevbase 设备的驱动模块。至此, chrdevbase 设备的驱动就编译成功。
2、编译测试 APP
测试 APP 比较简单,只有一个文件,因此就不需要编写 Makefile 了,直接输入命令编译。
因为测试 APP 是要在 ARM 开发板上运行的,所以需要使用 arm-linux-gnueabihf-gcc 来编译,
输入如下命令:

arm-linux-gnueabihf-gcc chrdevbaseApp.c -o chrdevbaseApp

6、运行测试

1、加载驱动模块

采用tftp启动Linux系统以及NFS挂载根文件系统,设置好以后启动 Linux 系统,检查开发板根文件系统中有没有“/lib/modules/4.1.15”这个目录,如果没有的话自行创建。 注意,“/lib/modules/4.1.15”这个目录用来存放驱动模块,使用modprobe 命令加载驱动模块的时候,驱动模块要存放在此目录下。“/lib/modules”是通用的,不管你用的什么板子、什么内核,这部分是一样的。不一样的是后面的“4.1.15”,这里要根据你所使用的 Linux 内核版本来设置,比如 ALPHA 开发板现在用的是 4.1.15 版本的 Linux 内核,因此就是“/lib/modules/4.1.15”。如果你使用的其他版本内核,比如 5.14.31,那么就应该创建“/lib/modules/5.14.31”目录,否则 modprobe 命令无法加载驱动模块。
在这里插入图片描述
输入如下命令加载 chrdevbase.ko 驱动文件:insmod chrdevbase.ko或modprobe chrdevbase.ko
使用
在这里插入图片描述
有些根文件系统可能没有 depmod 这个命令,如果没有这个命令就只能重新配置busybox,使能此命令,然后重新编译 busybox。输入“depmod”命令以后会自动生成 modules.alias、modules.symbols 和 modules.dep 这三个文件,如图 所示:
在这里插入图片描述
可以看到“chrdevbase init!”这一行,这一行正是 chrdevbase.c 中模块入口函数 chrdevbase_init 输出的信息,说明模块加载成功。

2、创建设备节点文件

驱动加载成功需要在/dev 目录下创建一个与之对应的设备节点文件,应用程序就是通过操作这个设备节点文件来完成对具体设备的操作。输入如下命令创建/dev/chrdevbase 这个设备节点文件:

mknod /dev/chrdevbase c 200 0

其中“mknod”是创建节点命令,“/dev/chrdevbase”是要创建的节点文件,“c”表示这是个字符设备,“ 200”是设备的主设备号,“ 0”是设备的次设备号。创建完成以后就会存在/dev/chrdevbase 这个文件,可以使用“ls /dev/chrdevbase -l”命令查看,结果如图所示:
在这里插入图片描述
如果 chrdevbaseAPP 想要读写 chrdevbase 设备,直接对/dev/chrdevbase 进行读写操作即可。相于/dev/
chrdevbase 这个文件是 chrdevbase 设备在用户空间中的实现。前面一直说 Linux 下一切皆文件,包括设备也是文件,

3、 chrdevbase 设备操作测试

一切准备就绪,使用 chrdevbaseApp 软件操作 chrdevbase 这个设备,看看读写是否正常,首先进行读操作,输入如下命令:

./chrdevbaseApp /dev/chrdevbase 1

在这里插入图片描述
因为 chrdevbaseAPP 使用 read 函数从 chrdevbase 设备读取数据,因此 chrdevbase_read 函数就会执行。 chrdevbase_read 函数向 chrdevbaseAPP 发送“kerneldata!”数据, chrdevbaseAPP 接收到以后就打印出来,“read data:kernel data!”就是 chrdevbaseAPP打印出来的接收到的数据。 说明对 chrdevbase 的读操作正常,接下来测试对 chrdevbase 设备的写操作,输入如下命令:

./chrdevbaseApp /dev/chrdevbase 2

在这里插入图片描述
只有一行“kernel recevdata:usr data!”,这个是驱动程序中的 chrdevbase_write 函数输出的。chrdevbaseAPP 使用 write 函数向 chrdevbase 设备写入数据“usr data!”。 chrdevbase_write 函数接收到以后将其打印出来。说明对 chrdevbase 的写操作正常,既然读写都没问题,说明我们编写的 chrdevbase 驱动是没有问题的。

4、卸载驱动模块

如果不再使用某个设备的话可以将其驱动卸载掉,比如输入如下命令卸载掉 chrdevbase 这个设备:

rmmod chrdevbase.ko

rmmod chrdevbase.ko卸载对应的模块。

在这里插入图片描述

posted @ 2024-06-03 16:34  Bathwind_W  阅读(80)  评论(0编辑  收藏  举报