0%

Linux驱动学习日记(12) 字符设备驱动

应用程序和驱动的交互原理

  在 linux 中,驱动可以获取外设(传感器)数据、控制外设。Linux 驱动编译既要写一个驱动,也要写一个简单的测试应用程序,在 Linux 中,驱动和应用是完全分开的。
  Linux 中分用户空间和内核空间,Linux操作系统的内核和驱动程序运行在内核空间,应用程序运行在用户空间。应用程序想要访问内核资源,有三种方法:系统调用、异常(中断)、陷入。
  应用程序不会直接调用系统调用,而是通过 API 函数来间接调用系统,unix 类操作系统中最常用的编程接口是 POSIX。应用程序使用 open 函数打开一个设备文件。每个系统调用有一个调用号。

图 1 地址映射

字符设备驱动框架

  在 Linux 中一切皆文件,驱动设备表现就是一个 /dev/ 下的文件,比如 /dev/led,可以通过 open 打开设备,通过write函数向 /dev/led 写数据。编写设备驱动的时候也需要编写驱动对应的 open、close、write 函数。 (驱动最终是要被应用调用的,在写驱动的时候需要考虑应用开发的便利性。驱动是分驱动框架的,要按照驱动框架来编写。)

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

  字符设备驱动的编写主要就是驱动对应的 open、close、write 等函数,比如应用程序中调用了 open 这个函数,那么在驱动程序中也得有一个名为 open 的函数。每一个系统调用,在驱动中都有与之对应的一个驱动函数,在 Linux 内核文件 include/linux/fs.h 中有个叫做 file_operations 的结构体,此结构体就是 Linux 内核驱动操作函数集合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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 (*iopoll)(struct kiocb *kiocb, struct io_comp_batch *,
unsigned int flags);
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);
void (*splice_eof)(struct file *file);
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);
int (*uring_cmd)(struct io_uring_cmd *ioucmd, unsigned int issue_flags);
int (*uring_cmd_iopoll)(struct io_uring_cmd *, struct io_comp_batch *,
unsigned int poll_flags);
} __randomize_layout;

  在字符设备驱动开发中最常用的就是上面这些函数,关于其他的函数大家可以查阅相关文档。我们在字符设备驱动开发中最主要的工作就是实现上面这些函数,不一定全部都要实现,但是像 open、release、write、read 等都是需要实现的,当然了,具体需要实现哪些函数还是要看具体的驱动要求。

字符设备驱动开发步骤

驱动模块的加载和卸载

  Linux 驱动有两种运行方式,第一种就是将驱动编译进 Linux 内核中,这样当 Linux 内核启动的时候就会自动运行驱动程序。第二种就是将驱动编译成模块(Linux 下模块扩展名为.ko),在 Linux 内核启动以后使用“insmod”命令加载驱动模块。在调试驱动的时候一般都选择将其编译为模块,这样我们修改驱动以后只需要编译一下驱动代码即可,不需要编译整个 Linux 代码。而且在调试的时候只需要加载或者卸载驱动模块即可,不需要重启整个系统。总之,将驱动编译为模块最大的好处就是方便开发,当驱动开发完成,确定没有问题以后就可以将驱动编译进 Linux 内核中,当然也可以不编译进 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
3
4
5
6
7
8
9
10
11
12
13
static int __init xxx_init(void) 
{
/* 入口函数具体内容 */
return 0;
}
/* 驱动出口函数 */
static void __exit xxx_exit(void)
{
/* 出口函数具体内容 */
/* 将上面两个函数指定为驱动的入口和出口函数 */
}
module_init(xxx_init);
module_exit(xxx_exit);

第 1 行,定义了个名为 xxx_init 的驱动入口函数,并且使用了“__init”来修饰。
第 7 行,定义了个名为 xxx_exit 的驱动出口函数,并且使用了“__exit”来修饰。
第 12 行,调用函数 module_init 来声明 xxx_init 为驱动入口函数,当加载驱动的时候 xxx_init 函数就会被调用。
第 13 行,调用函数 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/m odules/<kernel-version>目录中查找模块,比如本书使用的 Linux kernel 的版本号为 4.1.15,因此 modprobe 命令默认会到/lib/modules/4.1.15 这个目录中查找相应的驱动模块,一般自己制作的根文件系统中是不会有这个目录的,所以需要自己手动创建。

  驱动模块的卸载使用命令“rmmod”即可,比如要卸载drv.ko,使用如下命令即可:

rmmod drv.ko

字符设备注册与注销

  对于字符设备驱动而言,当驱动模块加载成功以后需要注册字符设备,同样,卸载驱动模块的时候也需要注销掉字符设备。字符设备的注册和注销函数原型如下所示:

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:要注销的设备对应的设备名。

  一般字符设备的注册在驱动模块的入口函数xxx_init中进行,字符设备的注销在驱动模块的出口函数xxx_exit中进行。在上节示例代码中字符设备的注册和注销,内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static struct file_operations test_fops;

static int __init xxx_init(void)
{
/* 入口函数具体内容 */
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);

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

实现设备的具体操作函数

  file_operations 结构体就是设备的具体操作函数,在上述代码中我们定义了 file_operations 结构体类型的变量 test_fops,但是还没对其进行初始化,也就是初始化其中的 open、release、read 和 write 等具体的设备操作函数。本小节我们就完成变量 test_fops 的初始化,设置好针对 chrtest 设备的操作函数。在初始化 test_fops 之前我们要分析一下需求,也就是要对 chrtest 这个设备进行哪些操作,只有确定了需求以后才知道我们应该实现哪些操作函数。假设对 chrtest 这个设备有如下两个要求:
  1、能够对 chrtest 进行打开和关闭操作设备打开和关闭是最基本的要求,几乎所有的设备都得提供打开和关闭的功能。因此我们需要实现 file_operations 中的 open 和 release 这两个函数。
  2、对 chrtest 进行读写操作 假设 chrtest 这个设备控制着一段缓冲区(内存),应用程序需要通过 read chrtest 的缓冲区进行读写操作。所以需要实现 file_operations 中的 read 和 write 这两个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 打开设备
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)
{
// 用户实现具体功能
return 0;
}
// 向设备写入
static ssize_t chetest_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;
}

static struct file_operations test_fops = {
.owner = THIS_MODULE,
.open = chrtest_open,
.read = chrtest_read,
.write = chrtest_write,
.release = chrtest_release,
};

添加 LICENSE 和作者信息

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

MODULE_LICENSE("GPL")
MODULE_AUTHOR("HCW")

编写程序

编写设备驱动

  新建chrdevbase.c,写入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/slab.h>
#include <linux/device.h> // 可选,用于自动创建设备节点

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

static char readbuf[100]; // 读缓冲区
static char writebuf[100]; // 写缓冲区
static char kerneldata[] = {"kernel data!"};

// 打开设备
static int chrdevbase_open(struct inode *inode, struct file *filp)
{
return 0;
}
// 读取数据
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;
}
// 向设备写数据
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;
}

static int chrdevbase_release(struct inode *inode, struct file *filp)
{
return 0;
}

static struct file_operations test_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, &test_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);
}
module_init(chrdevbase_init);
module_exit(chrdevbase_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("HCW");

编写Makefile

1
2
3
4
5
6
7
8
KERNELDIR := /home/hcw/learn/linux-6.1.161
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

  参数 filp 有个叫做 private_data 的成员变量,private_data 是个 void 指针,一般在驱动中将 private_data 指向设备结构体,设备结构体会存放设备的一些属性。

  chrdevbase read函数,应用程序调用read函数从设备中读取数据的时候此函数会执行。参数buf是用户空间的内存,读取到的数据存储在buf中,参数cnt是要读取的字节数,参数oft是相对于文件首地址的偏移。kerneldata里面保存着用户空间要读取的数据,先将kerneldata数组中的数据拷贝到读缓冲区readbuf 中,通过函数copy_to_user将readbuf 中的数据复制到参数buf中。因为内核空间不能直接操作用户空间的内存,因此需要借助copy_to_user函数来完成内核空间的数据到用户空间的复制。copy_to_user函数原型如下:

static inline long copy_to_user(void __user *to, const void *from, unsigned long n)

  chrdevbase_write函数,应用程序调用write函数向设备写数据的时候此函数就会执行。参数buf就是应用程序要写入设备的数据,也是用户空间的内存,参数cnt是要写入的数据长度,参数offt是相对文件首地址的偏移。通过函数copy_from_user将 buf 中的数据复制到写缓冲区 writebuf 中,因为用户空间内存不能直接访问内核空间的内存,所以需要借助函数 copy_from_user 将用户空间的数据复制到writebuf这个内核空间中。

  在 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。

编写测试 APP

  新建 chrdevtestapp.c,输入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#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!"};

int main(int argc,char* argv[])
{
int fd, retvalue;
char *filename;
char readbuf[100], writebuf[100];

if(argc != 3)
{
printf("Error Usage!\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;
}

编译后运行测试

加载驱动模块

  驱动模块chrdevbase.ko和测试软件chrdevbaseAPP都已经准备好了,接下来就是运行测试。设置好以后启动 Linux 系统,检查开发板根文件系统中有没有“/lib/modules/6.1.161”这个 目录,如果没有的话自行创建。注意,“/lib/modules/4.1.15”这个目录用来存放驱动模块,使用 modprobe 命令加载驱动模块的时候,驱动模块要存放在此目录下。“/lib/modules”是通用的, 不管你用的什么板子、什么内核,这部分是一样的。不一样的是后面的“6.1.161”,这里要根据 你所使用的 Linux 内核版本来设置,比如开发板现在用的是 6.1.161 版本的 Linux 内核, 因此就是“/lib/modules/6.1.161”。如果你使用的其他版本内核,比如 5.14.31,那么就应该创建“/ lib/modules/5.14.31”目录,否则 modprobe 命令无法加载驱动模块。将刚刚编译好的驱动模块和可执行文件复制进此文件夹。
  使用 insmod 命令加载驱动,然后输入 cat /proc/devices 命令检查有无 chrdevbase,可以看到有。
  接下来创建设备节点文件,驱动加载成功需要在/dev 目录下创建一个与之对应的设备节点文件,应用程序就是通过操作这个设备节点文件来完成对具体设备的操作。输入如下命令创建 /dev/chrdevbase 这个设备节点文件:

mknod /dev/chrdevbase c 200 0

  其中“mknod”是创建节点命令,“/dev/chrdevbase”是要创建的节点文件,“c”表示这是个字符设备,“200”是设备的主设备号,“0”是设备的次设备号。创建完成以后就会存在/dev/chrdevbase这个文件,可以使用“1s /dev/chrdevbase -1”命令查看,结果如下图

图 2 创建设备节点

  现在我们输入如下命令运行测试 APP

./chrdevtestapp /dev/chrdevbase 1

  可以看到首先输出“kernel senddata ok!”这一行信息,这是驱动程序中 chrdevbase_read 函数输出的信息,因为 chrdevbaseAPP 使用 read 函数从 chrdevbase 设备读取数据,因此 chrdevbase_read 函数就会执行。chrdevbase_read 函数向 chrdevbaseAPP 发送“kernel data!”数据,chrdevbaseAPP 接收到以后就打印出来,“read data:kernel data!”就是 chrdevbaseAPP 打印出来的接收到的数据。说明对 chrdevbase 的读操作正常。

./chrdevtestapp /dev/chrdevbase 2

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

图 3 测试

  接下来可以卸载驱动模块,输入如下指令

rmmod chrdevbase.ko

  rmmod 只会卸载内核里的驱动代码,不会自动删除 /dev 里的设备节点,需要手动删除。

结语

  至此,chrdevbase 这个设备的整个驱动就验证完成了,驱动工作正常,以后的字符设备驱动实验基本都以此为蓝本。