9. 字符设备驱动实验案例

本章演示的实验适用于Lubancat_AW系列板卡。

9.1. 字符设备驱动程序实验

9.1.1. 实验代码讲解

本章的示例代码目录为: linux_driver/chardev_test/chardev/

注意

我们要向系统注册一个新的字符设备,需要这几样东西: 字符设备结构体cdev设备编号devno ,以及最最最重要的 操作方式结构体file_operations ,另外,该实验设备文件需要手动mknod创建。

下面,我们开始编写我们自己的字符设备驱动程序。

9.1.1.1. 内核模块框架

既然我们的设备程序是以内核模块的方式存在的,那么就需要先写出一个基本的内核框架,见如下所示。

内核模块加载函数(位于../linux_driver/chardev_test/chardev/chrdev.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
#define DEV_NAME            "EmbedCharDev"
#define DEV_CNT                 (1)
#define BUFF_SIZE               128
//定义字符设备的设备号
static dev_t devno;
//定义字符设备结构体chr_dev
static struct cdev chr_dev;
// 入口函数功能实现字符设备初始化
static int __init chrdev_init(void)
{
   int ret = 0;
   printk("chrdev init\n");
   //第一步
   //采用动态分配的方式,获取设备编号,次设备号为0,
   //设备名称为EmbedCharDev,可通过命令cat  /proc/devices查看
   //DEV_CNT为1,当前只申请一个设备编号
   ret = alloc_chrdev_region(&devno, 0, DEV_CNT, DEV_NAME);
   if(ret < 0){
      printk("fail to alloc devno\n");
      goto alloc_err;
   }
   //第二步
   //关联字符设备结构体cdev与文件操作结构体file_operations
   cdev_init(&chr_dev, &chr_dev_fops);
   //第三步
   //添加设备至cdev_map散列表中
   ret = cdev_add(&chr_dev, devno, DEV_CNT);
   if(ret < 0)
   {
      printk("fail to add cdev\n");
      goto add_err;
   }
   return 0;

add_err:
   //添加设备失败时,需要注销设备号
   unregister_chrdev_region(devno, DEV_CNT);
alloc_err:
   return ret;
}
module_init(chrdev_init);

模块的卸载函数就相对简单一下,只需要完成注销设备号,以及移除字符设备,如下所示。

内核模块卸载函数(位于../linux_driver/chardev_test/chardev/chrdev.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 出口函数功能实现
static void __exit chrdev_exit(void)
{
   printk("chrdev exit\n");
   // 注销设备号
   unregister_chrdev_region(devno, DEV_CNT);
   // 移除字符设备
   cdev_del(&chr_dev);
}
module_exit(chrdev_exit);

9.1.1.2. 文件操作方式的实现

下面,我们开始实现字符设备最重要的部分:文件操作方式结构体file_operations,见如下所示。

file_operations结构体(位于../linux_driver/chardev_test/chardev/chrdev.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#define BUFF_SIZE               128
//数据缓冲区
static char vbuf[BUFF_SIZE];
// 定义file_operations结构体
static struct file_operations  chr_dev_fops =
{
   .owner = THIS_MODULE,  //表示该文件的操作结构体所属的模块是当前的模块(所有者是这个模块)
   .open = chr_dev_open,       //应用层调用open函数,驱动层调用chr_dev_open函数
   .release = chr_dev_release, //应用层调用close函数,驱动层调用chr_dev_release函数
   .write = chr_dev_write,     //应用层调用write函数,驱动层调用chr_dev_write函数
   .read = chr_dev_read,       //应用层调用read函数,驱动层调用chr_dev_read函数
};

由于这个字符设备是一个虚拟的设备,与硬件并没有什么关联,因此,open函数与release直接返回0即可,我们重点关注write以及read函数的实现。

chr_dev_open函数与chr_dev_release函数(位于../linux_driver/chardev_test/chardev/chrdev.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// chr_dev_open函数定义
static int chr_dev_open(struct inode *inode, struct file *filp)
{
   printk("\nopen\n");
   return 0;
}
// chr_dev_release函数定义
static int chr_dev_release(struct inode *inode, struct file *filp)
{
   printk("\nrelease\n");
   return 0;
}

注解

我们在open函数与release函数中打印相关的调试信息。

chr_dev_write函数(位于../linux_driver/chardev_test/chardev/chrdev.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// chr_dev_write函数定义
static ssize_t chr_dev_write(struct file *filp, const char __user * buf, size_t count, loff_t *ppos)
{
   unsigned long p = *ppos;
   int ret;
   int tmp = count ;
   if(p > BUFF_SIZE)
      return 0;
   if(tmp > BUFF_SIZE - p)
      tmp = BUFF_SIZE - p;
   ret = copy_from_user(vbuf, buf, tmp);
   *ppos += tmp;
   return tmp;
}

注解

  • 第4行:变量p记录了当前文件的读写位置,

  • 第7-10行:如果超过了数据缓冲区的大小(128字节)的话,直接返回0。并且如果要读写的数据个数超过了数据缓冲区剩余的内容的话,则只读取剩余的内容。

  • 第11-12行:使用copy_from_user从用户空间拷贝tmp个字节的数据到数据缓冲区中,同时让文件的读写位置偏移同样的字节数。

chr_dev_read函数(位于../linux_driver/chardev_test/chardev/chrdev.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// chr_dev_read函数定义
static ssize_t chr_dev_read(struct file *filp, char __user * buf, size_t count, loff_t *ppos)
{
   unsigned long p = *ppos;
   int ret;
   int tmp = count ;
   if(p >= BUFF_SIZE)
      return 0;
   if(tmp > BUFF_SIZE - p)
      tmp = BUFF_SIZE - p;
   ret = copy_to_user(buf, vbuf+p, tmp);
   *ppos +=tmp;
   return tmp;
}

注解

该函数的实现与chr_dev_write函数类似,区别在于,使用copy_to_user从数据缓冲区拷贝tmp个字节的数据到用户空间中。

9.1.1.3. 简单测试程序

下面,我们开始编写应用程序,来读写我们的字符设备,如下所示。

main.c函数(位于../linux_driver/chardev_test/chardev/main.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
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
char *wbuf = "Hello World\n";
char rbuf[128];
int main(void)
{
   printf("EmbedCharDev test\n");
   //打开文件
   int fd = open("/dev/chrdev", O_RDWR);
   //写入数据
   write(fd, wbuf, strlen(wbuf));
   //写入完毕,关闭文件
   close(fd);
   //打开文件
   fd = open("/dev/chrdev", O_RDWR);
   //读取文件内容
   read(fd, rbuf, 128);
   //打印读取的内容
   printf("The content : %s \n", rbuf);
   //读取完毕,关闭文件
   close(fd);
   return 0;
}

应用层open函数分析:

 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
/********************************************************************************
*  @brief     open
*  @param     pathname     要打开的文件名
*  @param     flags        特殊常量
*  @param     mode         设定该文件的权限,创建文件时使用
*  @return    成功打开返回打开文件的文件描述符(int类型),失败返回-1.
*******************************************************************************/
int open(const char *pathname, int flags);
int open(const char *pathname,int flags,mode_t mode);
/***********************************************************************
*  flags的参数有:
*  O_RDONLY   只读打开
*  O_WRONLY   只写打开
*  O_RDWR     读,写打开(可读可写)
*  O_CREAT    如果文件不存在,则创建新文件(需要使用mode选项指明新文件的访问权限)
*  O_APPEND   追加写,写时追加到文件尾
*  ......
*  mode的参数有:
*  S_IRUSR    文件所有者有读(r)权限
*  S_IWUSR    文件所有者有写(w)权限
*  S_IRGRP    文件所属组有读(r)权限
*  S_IWGRP    文件所属组有写(w)权限
*  S_IROTH    文件所属other有读(r)权限
*  S_IWOTH    文件所属other有写(w)权限
*  ......
**********************************************************************/
// 例子:
int fd = open("/home/cat/open", O_CREAT|O_RDWR|O_TRUNC, S_IRUSR|S_IWUSR|S_IRGRP|S_IWGRP|S_IROTH|S_IWOTH);

9.1.2. 实验准备

获取内核模块源码,将配套代码 /linux_driver/chardev_test/chardev 解压到内核代码同级目录。

9.1.2.1. Makefile说明

Makefile(位于../linux_driver/chardev_test/chardev/Makefile)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
KERNEL_DIR=../../../kernel/
# 指定工具链并导出环境变量
ARCH=arm64
CROSS_COMPILE=aarch64-linux-gnu-
export  ARCH  CROSS_COMPILE
# 编译成模块的目标文件名。
obj-m := chrdev.o
# 编译成测试程序的目标文件名
out =  chrdev_test

all:
   $(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules
   $(CROSS_COMPILE)gcc -o $(out) main.c

.PHONY:clean
clean:
   $(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean
   rm $(out)

注解

Makefile与此前相比,增加了编译测试程序部分。

  • 第12行:交叉编译工具链编译模块

  • 第13行:交叉编译工具链编译测试程序。

9.1.2.2. 编译命令

 make
#编译成功后,实验目录下会生成两个名为"chrdev.ko"驱动模块文件和" chrdev_test"测试程序。

9.1.3. 程序运行结果

注意

编译结果需要传输的板卡上运行,可以通过SCP传输,也可以挂载NFS。

#SCP传输文件到鲁班猫板卡
scp chrdev.ko chrdev_test cat@192.168.103.108:/home/cat

在板卡上运行:

# 挂载模块
sudo insmod chrdev.ko
# 查看字符设备
cat /proc/devices
../../../_images/character_case01.png

注意

我们从/proc/devices文件中,可以看到我们注册的字符设备EmbedCharDev的主设备号为510,注意此主设备号下面会用到。

以root权限使用 mknod 命令来创建一个新的设备chrdev(设备节点/设备文件)。

# 创建设备chrdev,并指定主设备号为510,次设备号为0
mknod /dev/chrdev c 510 0
# mknod 命令的用法可到上一章了解

#查看
ls -l /dev/chrdev
../../../_images/character_case02.png

root权限下运行 chrdev_test 测试程序,效果见下图。

../../../_images/character_case03.png

实际上,我们也可以通过echo或者cat命令,来测试我们的设备驱动程序。

echo "EmbedCharDev test" > /dev/chrdev
# 如果没有获取su的权限 也可以这样使用
sudo sh -c "echo 'EmbedCharDev test' > /dev/chrdev"
# 查看设备内容
cat /dev/chrdev
../../../_images/character_case04.png

当我们不需要该内核模块的时候,我们可以执行以下命令:

# 卸载内核模块
rmmod chrdev.ko
# 删除设备文件
rm /dev/chrdev

9.2. 一个驱动支持多个设备

在Linux内核中,主设备号用于标识设备对应的驱动程序,告诉Linux内核使用哪一个驱动程序为该设备服务。但是,次设备号表示了同类设备的各个设备。每个设备的功能都是不一样的。如何能够用一个驱动程序去控制各种设备呢?

很明显,首先,我们可以根据次设备号,来区分各种设备;其次,就是前文提到过的file结构体的私有数据成员private_data。我们可以通过该成员来做文章,不难想到为什么只有open函数和close函数的形参才有file结构体,因为驱动程序第一个执行的是操作就是open,通过open函数就可以控制我们想要驱动的底层硬件。

9.2.1. 实验代码讲解

9.2.1.1. 实现方式一 管理各种的数据缓冲区

小技巧

将我们的上一节程序改善一下,生成了两个设备,各自管理各自的数据缓冲区。

本章的示例代码目录为:linux_driver/chardev_test/chardev_n1

chardev_n1.c修改部分(位于../linux_driver/chardev_test/chardev_n1/chardev_n1.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
 #define DEV_NAME      "EmbedCharDev"
 #define DEV_CNT           (2)
 #define BUFF_SIZE         128
//定义字符设备的设备号
static dev_t devno;
//定义字符设备结构体chr_dev
static struct cdev chr_dev;
//数据缓冲区
static char vbuf1[BUFF_SIZE];
static char vbuf2[BUFF_SIZE];

注解

  • 第2行:修改了宏定义DEV_CNT,将原本的个数1改为2,这样的话,我们的驱动程序便可以管理两个设备。

  • 第9-10行:修改为两个数据缓冲区。

chr_dev_open函数修改(位于../linux_driver/chardev_test/chardev_n1/chardev_n1.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
static int chr_dev_open(struct inode *inode, struct file *filp)
{
   printk("\nopen\n ");
   switch (MINOR(inode->i_rdev)) {
      case 0 : {
         filp->private_data = vbuf1;
         break;
      }
      case 1 : {
         filp->private_data = vbuf2;
         break;
      }
   }
   return 0;
}

注解

我们知道inode结构体中,对于设备文件的设备号会被保存到其成员i_rdev中。

  • 第4行:在chr_dev_open函数中,我们使用宏定义MINOR来获取该设备文件的次设备号,使用private_data指向各自的数据缓冲区。

  • 第5-12行:对于次设备号为0的设备,负责管理vbuf1的数据,对于次设备号为1的设备,则用于管理vbuf2的数据,这样就实现了同一个设备驱动,管理多个设备了。

接下来,我们的驱动只需要对private_data进行读写即可。

chr_dev_write函数(位于../linux_driver/chardev_test/chardev_n1/chardev_n1.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static ssize_t chr_dev_write(struct file *filp, const char __user * buf, size_t count, loff_t *ppos)
{
   unsigned long p = *ppos;
   int ret;
   char *vbuf = filp->private_data;
   int tmp = count ;
   if (p > BUFF_SIZE)
      return 0;
   if (tmp > BUFF_SIZE - p)
      tmp = BUFF_SIZE - p;
   ret = copy_from_user(vbuf, buf, tmp);
   *ppos += tmp;
   return tmp;
}

注解

可以看到,我们的chr_dev_write函数改动很小,只是增加了第5行的代码,将原先vbuf数据指向了private_data,这样的话,当我们往次设备号为0的设备写数据时,就会往vbuf1中写入数据。次设备号为1的设备写数据,也是同样的道理。

chr_dev_read函数(位于../linux_driver/chardev_test/chardev_n1/chardev_n1.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
static ssize_t chr_dev_read(struct file *filp, char __user * buf, size_t count, loff_t *ppos)
{
   unsigned long p = *ppos;
   int ret;
   int tmp = count ;
   char *vbuf = filp->private_data;
   if (p >= BUFF_SIZE)
      return 0;
   if (tmp > BUFF_SIZE - p)
      tmp = BUFF_SIZE - p;
   ret = copy_to_user(buf, vbuf+p, tmp);
   *ppos +=tmp;
   return tmp;
}

注解

同样的,chr_dev_read函数也只是增加了第6行的代码,将原先的vbuf指向了private_data成员。

9.2.1.2. 实现方式二 i_cdev变量

我们回忆一下,我们前面讲到的文件节点inode中的成员i_cdev,为了方便访问设备文件,在打开文件过程中,将对应的字符设备结构体cdev保存到该变量中,那么我们也可以通过该变量来做文章。

本章的示例代码目录为:linux_driver/chardev_test/chardev_n2/

定义设备(位于../linux_driver/chardev_test/chardev_n2/chardev_n2.c)
1
2
3
4
5
6
7
8
9
//虚拟字符设备
struct chr_dev{
    struct cdev dev;
    char vbuf[BUFF_SIZE];
};
//字符设备1
static struct chr_dev vcdev1;
//字符设备2
static struct chr_dev vcdev2;

注解

以上代码中定义了一个新的结构体struct chr_dev,它有两个结构体成员:字符设备结构体dev以及设备对应的数据缓冲区。使用新的结构体类型struct chr_dev定义两个虚拟设备vcdev1以及vcdev2。

chrdev_init函数(位于../linux_driver/chardev_test/chardev_n2/chardev_n2.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
static int __init chrdev_init(void)
{
    int ret;
    printk("chrdev init\n");
    ret = alloc_chrdev_region(&devno, 0, DEV_CNT,  DEV_NAME);
    if(ret < 0)
        goto alloc_err;

    //关联第一个设备:vdev1
    cdev_init(&vcdev1.dev, &chr_dev_fops);
    ret = cdev_add(&vcdev1.dev, devno+0, 1);
    if(ret < 0){
        printk("fail to add vcdev1 ");
        goto add_err1;
    }
    //关联第二个设备:vdev2
    cdev_init(&vcdev2.dev, &chr_dev_fops);
    ret = cdev_add(&vcdev2.dev, devno+1, 1);
    if(ret < 0){
        printk("fail to add vcdev2 ");
        goto add_err2;
    }
    return 0;
add_err2:
    cdev_del(&(vcdev1.dev));
add_err1:
    unregister_chrdev_region(devno, DEV_CNT);
alloc_err:
    return ret;

}

注解

chrdev_init函数的框架仍然没有什么变化。

  • 第10、17行:在添加字符设备时,使用cdev_add依次添加。

  • 第26-27行:当虚拟设备1添加失败时,直接返回的时候,只需要注销申请到的设备号即可。

  • 第24-27行:若虚拟设备2添加失败,则需要把虚拟设备1移除,再将申请的设备号注销。

  • 第24-29行:为顺序执行的,当add_err2触发时,会顺序执行add_err1和alloc_err。

chrdev_exit函数(位于../linux_driver/chardev_test/chardev_n2/chardev_n2.c)
1
2
3
4
5
6
7
 static void __exit chrdev_exit(void)
 {
     printk("chrdev exit\n");
     unregister_chrdev_region(devno, DEV_CNT);
     cdev_del(&(vcdev1.dev));
     cdev_del(&(vcdev2.dev));
 }

注解

chrdev_exit函数注销了申请到的设备号,使用cdev_del移除两个虚拟设备。

我们知道inode中的i_cdev成员保存了对应字符设备结构体的地址,但是我们的虚拟设备是把cdev封装起来的一个结构体,我们要如何能够得到虚拟设备的数据缓冲区呢?

为此,Linux提供了一个宏定义container_of,该宏可以根据结构体的某个成员的地址,来得到该结构体的地址。

注意

该宏需要三个参数,分别是代表结构体成员的真实地址,结构体的类型以及结构体成员的名字。

chr_dev_open以及chr_dev_release函数(位于../linux_driver/chardev_test/chardev_n2/chardev_n2.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
static int chr_dev_open(struct inode *inode, struct file *filp)
{
   printk("open\n");
   filp->private_data = container_of(inode->i_cdev, struct chr_dev, dev);
   return 0;
}
static int chr_dev_release(struct inode *inode, struct file *filp)
{
   printk("release\n");
   return 0;
}

注解

在chr_dev_open函数中,我们需要通过inode的i_cdev成员,来得到对应的虚拟设备结构体,并保存到文件指针filp的私有数据成员中。

假如,我们打开虚拟设备1,那么inode->i_cdev便指向了vcdev1的成员dev,利用container_of宏,我们就可以得到vcdev1结构体的地址,也就可以操作对应的数据缓冲区了。

chr_dev_read读函数与chr_dev_write写函数,和之前部分基本一致,这里不进行讲解。

9.2.2. 实验准备

分别获取两个种方式的内核模块源码,将使用配套代码 /linux_driver放到内核同级目录下。

9.2.2.1. Makefile说明

至于Makefile文件,仅修改了模块输出目标文件名,其余几乎与上一小节的一致,这里便不再罗列出来了。

9.2.2.2. 编译&传输给鲁班猫

在实验目录下输入如下命令来编译驱动模块:

make
#编译成功后,实验目录下会分别生成驱动模块文件
../../../_images/character_case05.png

传输给鲁班猫:

scp chardev_n2.ko ../chardev_n1/chardev_n1.ko cat@192.168.103.108:/home/cat

9.2.3. 程序运行结果

通过NFS或者SCP将编译好的驱动模块拷贝到开发板中

注意

下面的演示以方式1(chardev_n1.ko)演示为例,方式2的操作也类似。

下面我们使用cat以及echo命令,对我们的驱动程序进行测试。

# 加载模块
insmod chardev_n1.ko
# 查看字符设备,获得主设备号
cat /proc/devices
# 创建设备
sudo mknod /dev/chrdev1 c 510 0
sudo mknod /dev/chrdev2 c 510 1

#通过以上命令,完成了/dev/chrdev1和/dev/chrdev2设备的创建,下面进行读写测试。
# 写入
echo "hello world" > /dev/chrdev1
echo "123456" > /dev/chrdev2
#读取
cat /dev/chrdev1
cat /dev/chrdev2
../../../_images/character_case06.png

注意

可以看到设备chrdev1中保存了字符串“hello world”,而设备chrdev2中保存了字符串“123456”,因为源码中仅定义了两个设备,因此设备chrdev3会显示找不到设备/地址。

总结一下,一个驱动支持多个设备的具体实现方式的重点在于如何运用file的私有数据成员。

  • 第一种方法是通过将各自的数据缓冲区放到该成员中,在读写函数的时候,直接就可以对相应的数据缓冲区进行操作;

  • 第二种方法则是通过将我们的数据缓冲区和字符设备结构体封装到一起,由于文件结构体inode的成员i_cdev保存了对应字符设备结构体,使用container_of宏便可以获得封装后的结构体的地址,进而得到相应的数据缓冲区。

到这里,字符设备驱动就已经讲解完毕了。如果你发现自己有好多不理解的地方,学完本章之后,建议重新梳理一下整个过程,有助于加深对整个字符设备驱动框架的理解。