一、字符设备驱动简介
字符设备是 Linux 驱动中最基本的一类设备驱动,字符设备就是一个一个字节,按照字节流进行读写操作的设备,读写数据是分先后顺序的。比如我们最常见的点灯、按键、 IIC、 SPI,LCD 等等都是字符设备,这些设备的驱动就叫做字符设备驱动。
Linux 应用程序对驱动程序的调用:
驱动加载成功以后会在“/dev”目录下生成一个相应的文件,应用程序通过对这个名为“/dev/xxx” (xxx 是具体的驱动文件名字)的文件进行相应的操作即可实现对硬件的操作。比如现在有个叫做/dev/led 的驱动文件,此文件是 led 灯的驱动文件。应用程序使用 open 函数来打开文件/dev/led,使用完成以后使用 close 函数关闭/dev/led 这个文件。 open和 close 就是打开和关闭 led 驱动的函数,如果要点亮或关闭 led,那么就使用 write 函数来操作,也就是向此驱动写入数据,这个数据就是要关闭还是要打开 led 的控制参数。
应用程序运行在用户空间,而 Linux 驱动属于内核的一部分,因此驱动运行于内核空间。当我们在用户空间想要实现对内核的操作,比如使用 open 函数打开/dev/led 这个驱动,因为用户空间不能直接对内核进行操作,因此必须使用一个叫做“系统调用”的方法来实现从用户空间“陷入”到内核空间,这样才能实现对底层驱动的操作。 open、 close、 write 和 read 等这些函数是由 C 库提供的,在 Linux 系统中,系统调用作为 C 库的一部分。调用 open 函数的时候流程如下:
应用程序使用到的函数在具体驱动程序中都有与之对应的函数,比如应用程序中调用了 open 这个函数,那么在驱动程序中也得有一个名为 open 的函数。
在 Linux 内核文件 include/linux/fs.h 中有个叫做 file_operations 的结构体,此结构体就是 Linux 内核驱动操作函数集合,内容如下所示:
struct file_operations {
struct module *owner; // owner 拥有该结构体的模块的指针,一般设置为 THIS_MODULE
loff_t (*llseek) (struct file *, loff_t, int); // llseek 函数用于修改文件当前的读写位置
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); // read 函数用于读取设备文件
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 (*iopoll)(struct kiocb *kiocb, bool spin);
int (*iterate) (struct file *, struct dir_context *);
int (*iterate_shared) (struct file *, struct dir_context *);
__poll_t (*poll) (struct file *, struct poll_table_struct *); // poll 是个轮询函数,用于查询设备是否可以进行非阻塞的读写
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long); // unlocked_ioctl 函数提供对于设备的控制功能,与应用程序中的 ioctl 函数对应
long (*compat_ioctl) (struct file *, unsigned int, unsigned long); // compat_ioctl 函数与 unlocked_ioctl 函数功能一样,区别在于在 64 位系统上,32 位的应用程序调用将会使用此函数
int (*mmap) (struct file *, struct vm_area_struct *); // mmap 函数用于将将设备的内存映射到进程空间中(也就是用户空间),一般帧缓冲设备会使用此函数,比如 LCD 驱动的显存,将帧缓冲(LCD 显存)映射到用户空间中以后应用程序就可以直接操作显存了,这样就不用在用户空间和内核空间之间来回复制。
unsigned long mmap_supported_flags;
int (*open) (struct inode *, struct file *); // open 函数用于打开设备文件
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *); // release 函数用于释放(关闭)设备文件,与应用程序中的 close 函数对应
int (*fsync) (struct file *, loff_t, loff_t, int datasync); // fasync 函数用于刷新待处理的数据,用于将缓冲区中的数据刷新到磁盘中
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);
loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in, struct file *file_out, loff_t pos_out, loff_t len, unsigned int remap_flags);
int (*fadvise)(struct file *, loff_t, loff_t, int);
} __randomize_layout;
二、字符设备驱动开发步骤
1. 驱动模块的加载和卸载
Linux 驱动有两种运行方式,第一种就是将驱动编译进 Linux 内核中,这样当 Linux 内核启动的时候就会自动运行驱动程序。第二种就是将驱动编译成模块(Linux 下模块扩展名为.ko),在Linux 内核启动以后使用“modprobe”或者“insmod”命令加载驱动模块。这两个命令的区别是 modprobe 会自动解析和加载模块及其依赖项,insmod 会提供基础的手动加载功能,适用于特定场景。在调试驱动的时候一般都选择将其编译为模块,这样我们修改驱动以后只需要编译一下驱动代码即可,不需要编译整个 Linux 代码。而且在调试的时候只需要加载或者卸载驱动模块即可,不需要重启整个系统。总之,将驱动编译为模块最大的好处就是方便开发,当驱动开发完成,确定没有问题以后就可以将驱动编译进 Linux 内核中,当然也可以不编译进Linux 内核中。
模块有加载和卸载两种操作,我们在编写驱动的时候需要注册这两种操作函数,模块的加载和卸载注册函数如下:
module_init(xxx_init); // 注册模块加载函数
module_exit(xxx_exit); // 注册模块卸载函数
module_init 函数用来向 Linux 内核注册一个模块加载函数,参数 xxx_init 就是需要注册的具体函数,当使用“modprobe”命令加载驱动的时候, xxx_init 这个函数就会被调用。 module_exit函数用来向 Linux 内核注册一个模块卸载函数,参数 xxx_exit 就是需要注册的具体函数,当使用“rmmod”命令卸载具体驱动的时候 xxx_exit 函数就会被调用。字符设备驱动模块加载和卸载模板如下所示:
/* 驱动入口函数 */
static int __init xxx_init(void) // 定义了一个静态的、返回整数类型的 xxx_init 函数,并使用 __init 宏进行修饰;__init:一个宏,用于将该函数标记为内核初始化函数。这意味着该函数只在模块加载时执行,并且在加载完成后会被自动从内存中删除,以释放资源
{
/* 入口函数具体内容 */
return 0;
}
/* 驱动出口函数 */
static void __exit xxx_exit(void) // 定义了一个静态的、无返回值的 xxx_exit 函数,并使用 __exit 宏进行修饰;__exit:一个宏,用于将该函数标记为内核退出函数。这意味着该函数只在模块卸载时执行,在卸载完成后会被自动从内存中删除
{
/* 出口函数具体内容 */
}
/* 将上面两个函数指定为驱动的入口和出口函数 */
module_init(xxx_init); // 函数 module_init 来声明 xxx_init 为驱动入口函数,当加载驱动的时候 xxx_init函数就会被调用
module_exit(xxx_exit); // 调用函数module_exit来声明xxx_exit为驱动出口函数,当卸载驱动的时候xxx_exit函数就会被调用
驱动编译完成以后扩展名为.ko。比如加载 drv.ko这个驱动模块:
/* 加载驱动推荐使用 modprobe,因为它提供模块的依赖性分析、错误检查、错误报告等功能 */
modprobe -a drv // 加载 drv.ko 驱动模块
/* 卸载驱动推荐使用rmmod,因为 modprobe 卸载驱动模块所依赖的其他模块,但有时候我们也用到了这些模块 */
rmrod drv.ko // 卸载 drv.ko 驱动模块
2. 字符设备注册与注销
对于字符设备驱动而言,当驱动模块加载成功以后需要注册字符设备,同样,卸载驱动模块的时候也需要注销掉字符设备。字符设备的注册和注销函数原型如下所示:
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)
/*
static:这里的 static 是静态函数,作用域为当前文件,只能被当前文件的其他函数调用
inline:是建议编译器对函数进行内联展开。内联展开是指在函数调用的地方直接将函数的代码插入,而不是通过函数调用来执行代码。这样可以减少函数调用的开销
register_chrdev 函数用于注册字符设备,此函数一共有三个参数:
major:主设备号,Linux 下每一个设备都有一个设备号,设备号分为住设备号和次设备号
name:设备名字,指向一串字符串
*fops:结构体 file_operations 类型指针,指向设备的操作函数集合变量
unregister_chrdev 函数用户注销字符设备,此函数有两个参数:
major:要注销的设备的主设备号
name:要注销的设备的设备名字
*/
一般字符设备的注册在驱动模块的入口函数 xxx_init 中进行,字符设备的注销在驱动模块的出口函数 xxx_exit 中进行:
static struct file_operations test_fops; // 定义一个 file_operations 结构体变量 test_fops,test_fops 就是设备操作函数集合,我们现在没有初始化成员变量,所以这个函数集合是空的
/* 驱动入口函数 */
static int __init xxx_init(void)
{
/* 入口函数具体内容 */
int retvalue = 0;
/* 注册字符设备驱动 */
retvalue = register_chrdev(200, "chrtest", &test_fops); // 主设备号为 200,设备名字为“chrtest”,设备操作函数集合"test_fops",选择没有被使用的主设备号,输入命令“cat /proc/devices”可以查看当前已经被使用掉的设备号
if (retvalue < 0) {
/* 字符设备注册失败,自行处理 */
}
return 0;
}
/* 驱动出口函数 */
static void __exit xxx_exit(void)
{
/* 注销字符设备驱动 */
unregister_chrdev(200, "chrtest"); // 调用函数 unregister_chrdev 注销主设备号为 200 的这个设备
}
/* 将上面两个函数指定为驱动的入口和出口函数 */
module_init(xxx_init);
module_exit(xxx_exit);
3. 实现设备的具体操作函数
① 能对 chrtest 进行打开和关闭
设备打开和关闭是最基本的要求,几乎所有的设备都得提供打开和关闭的功能。因此我们需要实现 file_operations 中的 open 和 release 这两个函数。
② 对 chrtest 进行读写操作
假设 chrtest 这个设备控制着一段缓冲区(内存),应用程序需要通过 read 和 write 这两个函数对 chrtest 的缓冲区进行读写操作,代码如下:
/* 打开设备 */
static int chrtest_open(struct inode *inode, struct file *filp)
{
/* 用户实现具体功能 */
return 0;
}
/* 从设备读取 */
static ssize_t chrtest_read(struct file *filp, char __user *buf, size_t cnt, loff_t *offt) // __user:标记用户空间指针
{
/* 用户实现具体功能 */
return 0;
}
/* 向设备写数据 */
static ssize_t chrtest_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt)
{
/* 用户实现具体功能 */
return 0;
}
/* 关闭/释放设备 */
static int chrtest_release(struct inode *inode, struct file *filp)
{
/* 用户实现具体功能 */
return 0;
}
// 加入 test_fops 这个结构体变量的初始化操作
static struct file_operations test_fops =
{
.owner = THIS_MODULE,
.open = chrtest_open,
.read = chrtest_read,
.write = chrtest_write,
.release = chrtest_release,
};
/* 驱动入口函数 */
static int __init xxx_init(void)
{
/* 入口函数具体内容 */
int retvalue = 0;
/* 注册字符设备驱动 */
retvalue = register_chrdev(200, "chrtest", &test_fops);
if (retvalue < 0) {
/* 字符设备注册失败,自行处理 */
}
return 0;
}
/* 驱动出口函数 */
static void __exit xxx_exit(void)
{
/* 注销字符设备驱动 */
unregister_chrdev(200, "chrtest");
}
/* 将上面两个函数指定为驱动的入口和出口函数 */
module_init(xxx_init);
module_exit(xxx_exit);
一开始编写了四个函数: chrtest_open、 chrtest_read、chrtest_write 和 chrtest_release。这四个函数就是 chrtest 设备的 open、 read、 write 和 release 操作函数。
4. 添加 LICENSE 和作者信息
最后需要在驱动中加入 LICENSE 信息和作者信息,其中 LICENSE 是必须添加的,否则的话编译的时候会报错,作者信息可以添加也可以不添加。 LICENSE 和作者信息的添加使用如下两个函数:
MODULE_LICENSE() // 添加模块 LICENSE 信息
MODULE_AUTHOR() // 添加模块作者信息
完整字符设备驱动模块大致内容如下:
/* 打开设备 */
static int chrtest_open(struct inode *inode, struct file *filp)
{
/* 用户实现具体功能 */
return 0;
}
/* 从设备读取 */
static ssize_t chrtest_read(struct file *filp, char __user *buf, size_t cnt, loff_t *offt) // __user:标记用户空间指针
{
/* 用户实现具体功能 */
return 0;
}
/* 向设备写数据 */
static ssize_t chrtest_write(struct file *filp, const char __user *buf, size_t cnt, loff_t *offt)
{
/* 用户实现具体功能 */
return 0;
}
/* 关闭/释放设备 */
static int chrtest_release(struct inode *inode, struct file *filp)
{
/* 用户实现具体功能 */
return 0;
}
// 加入 test_fops 这个结构体变量的初始化操作
static struct file_operations test_fops =
{
.owner = THIS_MODULE,
.open = chrtest_open,
.read = chrtest_read,
.write = chrtest_write,
.release = chrtest_release,
};
/* 驱动入口函数 */
static int __init xxx_init(void)
{
/* 入口函数具体内容 */
int retvalue = 0;
/* 注册字符设备驱动 */
retvalue = register_chrdev(200, "chrtest", &test_fops);
if (retvalue < 0)
{
/* 字符设备注册失败,自行处理 */
}
return 0;
}
/* 驱动出口函数 */
static void __exit xxx_exit(void)
{
/* 注销字符设备驱动 */
unregister_chrdev(200, "chrtest");
}
/* 将上面两个函数指定为驱动的入口和出口函数 */
module_init(xxx_init);
module_exit(xxx_exit);
/* 添加license信息和作者信息*/
MODULE_LICENSE("GPL"); // 添加 LICENSE 信息
MODULE_AUTHOR("luoxuesong"); // 添加作者信息
三、Linux 设备号
1. 设备号组成
设备号由主设备号和次设备号两部分组成,主设备号表示某一个具体的驱动,次设备号表示使用这个驱动的各个设备。 Linux 提供了一个名为 dev_t 的数据类型表示设备号, dev_t 定义在文件 include/linux/types.h 里面,定义如下:
typedef __u32 __kernel_dev_t;
......
typedef __kernel_dev_t dev_t;
可以看出 dev_t 是__u32 类型的,而__u32 定义在文件 include/uapi/asm-generic/int-ll64.h 里面,定义如下:
typedef unsigned int __u32;
dev_t 其实就是 unsigned int 类型,是一个 32 位的数据类型。这 32 位的数据构成了主设备号和次设备号两部分,其中高 12 位为主设备号,低 20 位为次设备号。因此 Linux系统中主设备号范围为 0~4095,不能超过这个范围。在文件 include/linux/kdev_t.h 中提供了几个关于设备号的操作函数(本质是宏):
#define MINORBITS 20 // 次设备号位数,一共20位
#define MINORMASK ((1U << MINORBITS) - 1) // 次设备号掩码
#define MAJOR(dev) ((unsigned int) ((dev) >> MINORBITS)) // 从 dev_t 获取主设备号,将 dev_t 右移20位
#define MINOR(dev) ((unsigned int) ((dev) & MINORMASK)) // 从 dev_t 获取次设备号,取 dev_t 低20位
#define MKDEV(ma,mi) (((ma) << MINORBITS) | (mi)) // 将给定的主设备号和次设备号的值组合成 dev_t 类型的设备号
2. 设备号分配
① 静态分配设备号
注册字符设备的时候需要给设备指定一个设备号,设备号可以是驱动开发者自己去指定,比如之前的 200 。用 cat/proc/devices 查看设备号,看哪些被用过了。
② 动态分配设备号
静态分配设备号容易出现冲突问题,推荐使用动态分配设备号,在注册字符设备之前先申请一个设备号,系统会自动给你一个没有被使用过的设备号,避免冲突。
设备号 申请 函数如下:
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name);
/*
dev:保存申请到的设备号
baseminor:次设备号起始地址,这个函数可以申请一段连续的多个设备号,这些设备号的主设备号一样,但是次设备号不同,次设备号以 baseminor 为起始地址地址开始递增。一般 baseminor 为 0,也就是说次设备号从 0 开始
count:要申请设备号的个数
name:设备名字
*/
设备号 释放 函数如下:
void unregister_chrdev_region(dev_t from, unsigned count);
/*
from:要释放的设备
count:从from开始,需要释放的设备号数量
*/
四、chrdevbase 字符设备驱动开发实验
chrdevbase 是一个虚拟设备。chrdevbase 设备有两个缓冲区,一个读缓冲区,一个写缓冲区,这两个缓冲区的大小都为 100 字节。在应用程序中可以向 chrdevbase 设备的写缓冲区中写入数据,从读缓冲区中读取数据。
1. 程序编写
应用程序调用 open 函数打开 chrdevbase 这个设备,打开以后可以使用 write 函数向chrdevbase 的写缓冲区 writebuf 中写入数据(不超过 100 个字节),也可以使用 read 函数读取读缓冲区 readbuf 中的数据操作,操作完成以后应用程序使用 close 函数关闭 chrdevbase 设备(打开,读取,写入,关闭)。
① 创建 VScode 工程
创建 1_chrdevbase 文件夹存放这次实验所有文件。
新建 VScode 工程,新建 chrdevbase.c 文件。
② 添加头文件路径
因为是编写 Linux 驱动,因此会用到 Linux 源码中的函数。我们需要在 VSCode 中添加 Linux源码中的头文件路径。 在 VScode 按下 SHIFT+CTRL+P,输入 C/C++: Edit configurations(JSON) 。
③ 编写程序
#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!"};
/*
* @description : 打开设备
* @param – inode : 传递给驱动的 inode
* @param - filp : 设备文件, file 结构体有个叫做 private_data 的成员变量,一般在 open 的时候将 private_data 指向设备结构体。
* @return : 0 成功;其他 失败
*/
static int chrdevbase_open(struct inode *inode, struct file *filp)
{
// printk("chrdevbase open!\r\b"); // printf运行在用户态, printk 运行在内核态。
return 0;
}
/*
* @description : 从设备读取数据
* @param - filp : 要打开的设备文件(文件描述符)
* @param - buf : 返回给用户空间的数据缓冲区
* @param - cnt : 要读取的数据长度
* @param - offt : 相对于文件首地址的偏移
* @return : 读取的字节数,如果为负值,表示读取失败
*/
static ssize_t chrdevbase_read(struct file *filp, char __user *buf, size_t cnt, loff_t *offt) // ssize_t:函數返回的字节数或错误码 // // 应用程序调用 read 函数从设备中读取数据的时候此函数会执行
{
int retvalue = 0;
/* 向用户空间发送数据 */ // memcpy() 将根据指定的大小复制 kerneldata 中的内容,并将其粘贴到 readbuf 中,其中有sizeof(kerneldata)个字节
memcpy(readbuf, kerneldata, sizeof(kerneldata)); // kerneldata 是里面保存着用户空间要读取的数据
retvalue = copy_to_user(buf, readbuf, cnt); // 因为内核空间不能直接操作用户空间的内存,因此需要借助 copy_to_user 函数来完成内核空间的数据到用户空间的复制。
if (retvalue == 0) {
printk("kernel senddata ok!\r\n");
} else {
printk("kernel senddata failed!\r\n");
}
// printk("chrdevbase read!\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;
/* 接收用户空间传递给内核的数据并且打印出来 */ // 参数 buf 就是应用程序写入设备的数据
retvalue = copy_from_user(writebuf, buf, cnt); // 因为用户空间内存不能直接访问内核空间的内存,所以需要借助函数 copy_from_user 将用户空间的数据复制到 writebuf 这个内核空间中
if (retvalue == 0) {
printk("kernel recevdata:%s\r\n", writebuf);
} else {
printk("kernel recevdata failed!\r\n");
}
// printk("chrdevbase write!\r\n");
return 0;
}
/*
* @description : 关闭/释放设备
* @param - filp : 要关闭的设备文件(文件描述符)
* @return : 0 成功;其他 失败
*/
static int chrdevbase_release(struct inode *inode,struct file *filp) // 应用程序调用 close 关闭设备文件的时候此函数会执行
{
// printk("chrdevbase 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,
};
/*
* @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");
}
return 0;
}
/*
* @description : 驱动出口函数
* @param : 无
* @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);
/*
* LICENSAE和作者信息
*/
MODULE_LICENSE("GPL");
MODULE_AUTHOR("ALIENTEK");
MODULE_INFO(intree, "Y"); // 如果不加就会有“loading out-of-treemodule taints kernel.”这个警告
其中 printf运行在用户态, printk 运行在内核态,并且 printk 可以根据日志级别进行分类,一共有八个级别,在文件的 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" /* 调试信息
其中 0 的优先级最高,7 的优先级最低。比如:
printk(KERN_EMERG "gsmi: Log Shutdown Reason\n");
在具体的消息前面加上 KERN_ENMERG 可以将这条消息几倍设置为 KERN_ENMERG 。如果在使用 printk 的时候不显示设置消息级别,则会采用默认级别 MESSAGE_LOGLEVEL_DEFAULT,并且在 include/linux/printk.h 中有个宏 CONSOLE_LOGLEVEL_DEFAULT 有这样的定义:
#define CONSOLE_LOGLEVEL_DEFAULT CONFIG_CONSOLE_LOGLEVEL_DEFAULT
MESSAGE_LOGLEVEL_DEFAULT 和 CONFIG_CONSOLE_LOGLEVEL_DEFAULT 是通过内核图形化界面配置的:
-> Kernel hacking
-> printk and dmesg options
-> (7) Default console loglevel (1-15) // 设置默认终端消息级别
-> (4) Default message log level (1-7) // 设置默认消息级别
CONSOLE_LOGLEVEL_DEFAULT 控制着哪些级别的消息可以显示在控制台上,此宏默认为 7,意味着只有优先级高于 7 的消息才能显示在控制台上。 默认消息级别为 4, 4 的级别比 7 高,所示直接使用 printk 输出的信息是可以显示在控制台上的。
2. 编写APP
① C 库文件操作基本函数
编写测试 APP 就是编写 Linux 应用,需要用到 C 库里面和文件操作有关的一些函数,比如open、 read、 write 和 close 这四个函数。
1、open 函数
/*
* @param - pathname : 要打开的设备或者文件名
* @param - flags : 文件打开模式,以下三种模式必选其一:
O_RDONLY 只读模式
O_WRONLY 只写模式
O_RDWR 读写模式
* @return : 如果文件打开成功的话返回文件的文件描述符
*/
int open(const char *pathname, int flags);
2、read 函数
/*
* @param - fd : 要读取的文件描述符,读取文件之前要先用 open 函数打开文件, open 函数打开文件成功以后会得到文件描述符
* @param - buf : 数据读取到此 buf 中
* @param - count : 读取的数据长度,即字节数
* @return : 读取成功的话返回读取到的字节数;如果返回 0 表示读取到了文件末尾;如果返回负值,表示读取失败
*/
ssize_t read(int fd, void *buf, size_t count);
3、write 函数
/*
* @param - fd : 要写操作的文件描述符,读取文件之前要先用 open 函数打开文件, open 函数打开文件成功以后会得到文件描述符
* @param - buf : 要写入的数据
* @param - count : 写入的数据长度,即字节数
* @return : 写入成功的话返回读取到的字节数;如果返回 0 表示没有写入任何数据;如果返回负值,表示写入失败
*/
ssize_t write(int fd, const void *buf, size_t count);
4、close 函数
/*
* @param - fd : 要关闭的文件描述符
* @return : 0 表示关闭成功,负值表示关闭失败
*/
int close(int fd);
② 编写测试 APP 程序
测试 APP 运行在用户空间。测试 APP 通过输入相应的指令来对 chrdevbase 设备执行读或者写操作。在1_chrdevbase 目录中新建 chrdevbaseApp.c 文件,在此文件中输入如下内容:
#include "stdio.h" // 提供了输入输出函数的声明,如 printf() 和 scanf()
#include "unistd.h" // 提供了对操作系统的访问和操作,如进程控制、文件操作等
#include "sys/types.h" // 定义了一些基本的数据类型,如文件描述符类型 int、进程 ID pid_t 等
#include "sys/stat.h" // 包含了文件状态的获取和设置函数,如 stat() 和 chmod()
#include "fcntl.h" // 定义了一些文件控制相关的常量和函数,如文件打开方式的设置和文件锁定操作
#include "stdlib.h" // 提供了一些通用的函数和宏定义,如内存分配函数 malloc() 和随机数生成函数 rand()
#include "string.h" // 提供了一些字符串处理函数的声明,如字符串复制函数 strcpy() 和字符串比较函数 strcmp()
static char usrdata[] = {"usr data!"}; // 要向 chrdevbase 设备写入的数据
/*
* @description : main 主程序
* @param - argc : argv 数组元素个数
* @param - argv : 具体参数
* @return : 0 成功;其他 失败
*/
int main(int argc, char *argv[])
{
int fd, retvalue;
char *filename;
char readbuf[100], writebuf[100];
/*
比如输入命令:./chrdevbaseApp /dev/chrdevbase 1
这个命令有3个参数,“./chrdevbaseApp”、“/dev/chrdevbase”和“1”,这三个参数分别对应 argv[0]、 argv[1]和 argv[2]。
第一个参数是运行 chrdevbaseAPP 软件,第二个参数表示测试 APP 要打开/dev/chrdevbase这个设备,第三个参数就是要执行的操作, 1表示从chrdevbase中读取数据, 2 表示向 chrdevbase 写数据。
*/
if(argc != 3) // 判断参数是否有 3 个,不足3个表示测试 APP 用法错误
{
printf("Error Usage!\r\n");
return -1;
}
filename = argv[1];
/* 打开驱动文件 */
fd = open(filename, O_RDWR); //调用C库中的open打开设备文件/dev/chrdevbase
if(fd < 0){
printf("Can't open file %s\r\n", filename);
return -1;
}
if(atoi(argv[2]) == 1) // atoi:将字符串格式的数字转换成真实的数字
{
/* 从驱动文件读取数据 */
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;
}
3. 编译驱动程序和测试 APP
① 编译驱动程序
首先编译驱动程序,也就是 chrdevbase.c 这个文件,我们需要将其编译为.ko 模块,创建Makefile 文件 :
KERNELDIR = /home/alientek/linux/atk-mpl/linux/my_linux/linux-5.4.31 # Linux内核源码路径
CURRENT_PATH = $(shell pwd) # 获取当前所处路径
obj-m := chardevbase.o # 将 chrdevbase.c 编译为 chrdevbase.ko 模块
build: kernel_modules
kernel_modules:
$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(CURRENT_PATH) clean
# modules 表示编译模块, -C 表示将当前的工作目录切换到指定目录中,也就是 KERNERLDIR 目录。 M 表示模块源码目录,“make modules”命令中加入 M=dir 以后程序会自动到指定的 dir 目录中读取模块的源码并将其编译为.ko 文件
编译成功以后就会生成一个叫做 chrdevbaes.ko 的文件,此文件就是 chrdevbase 设备的驱动模块。
② 编译测试 APP
因为测试 APP 是要在 ARM 开发板上运行的,所以需要使用 arm-linux-gnueabihf-gcc 来编译:
arm-none-linux-gnueabihf-gcc chrdevbaseApp.c -o chrdevbaseApp
4. 运行测试
① 加载驱动模块
Linux 系统选择通过 TFTP 从网络启动,并且使用 NFS 挂载网络根文件系统。
// uboot 中 bootcmd 环境变量的值为:
setevn bootcmd tftp c2000000 uImage;tftp c4000000 stm32mp157d-atk.dtb;bootm c2000000 - c4000000
// bootargs 环境变量的值为:
setenv bootargs aconsole=ttySTM0,115200 root=/dev/nfs nfsroot=192.168.1.100:/home/alientek/linux/nfs/rootfs,proto=tcp rw ip=192.168.1.106:192.168.1.100:192.168.1.1:255.255.255.0::eth0:off
检查开发板根文件系统中有没有“/lib/modules/5.4.31”这个目录,这个目录需要自己创建。现在需要将 chrdevbase.ko 和 chrdevbaseAPP 复制到 rootfs/lib/modules/5.4.31 目录中 :
cd /linux/atk-mpl/Drivers/1_chrdevbase
sudo cp chrdevbase.ko chrdevbaseApp /home/alientek/linux/nfs/rootfs/lib/modules/5.4.31/ -f
有moules.xx文件是因为输入了命令:depmod ,它可以自动生成 modules.dep 文件。输入命令加载驱动文件:
modprobe chrdevbase
lsmod // 这个命令可以查看当前系统中存在的模块
输入 cat/proc/devices 就可以看到设备号为 200 的chrdevbase 设备。
② 创建设备节点文件
驱动加载成功需要在/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 设备在用户空间中的实现。
③ chrdevbase 设备操作测试
使用 chrdevbaseApp 软件操作 chrdevbase 这个设备,看看读写是否正常,首先进行读操作,输入如下命令:
cd /
cd /lib/modules/5.4.31
./chrdevbaseApp /dev/chrdevbase 1
输出了 kernel data! ,因为 chrdevbaseAPP 使用 read 函数从 chrdevbase 设备读取数据,因此 chrdevbase_read 函数就会执行。 chrdevbase_read 函数向 chrdevbaseAPP 发送“kerneldata!”数据, chrdevbaseAPP 接收到以后就打印出来,“read data:kernel data!”就是 chrdevbaseAPP打印出来的接收到的数据。继续测试写操作。
/chrdevbaseApp /dev/chrdevbase 2
输出了“kernel recevdata:usr data!”,这个是驱动程序中的 chrdevbase_write 函数输出的。chrdevbaseAPP 使用 write 函数向 chrdevbase 设备写入数据“usr data!”。 chrdevbase_write 函数接收到以后将其打印出来。
④ 卸载驱动模块
如果不再使用某个设备可以将其卸载:
rmmod chrdevbase
# 之后可以用lsmod查看模块是否存在