Linux设备驱动


在Linux中,一切皆文件,包括设备。对设备使用接口的实现,其实就是对文件操作接口的实现。本博客将详述一个简单的读写设备驱动的编写,理解设备即文件的理念。

总所周知,Linux是整体式OS,采用模块的开发方式。当想添加什么新功能时,编写新的模块即可,设备也不例外。设备驱动程序的注册需要依赖模块,因此写驱动之前,要先明白如何编写模块。

编写Linux内核模块

这里编写一个简单的Linux内核,用来向内核的日志中打印不同的字符串。一个基础模块的编写不难,无非就是模块的加载函数和卸载函数,在Linux库中分别用module_init()module_exit来实现。前者为安装模块时执行的动作,后者为卸载模块时执行的动作。因此,只需要在这两个函数中进行输出即可,完整代码如下:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
//模块许可证声明
MODULE_LICENSE("Dual BSD/GPLi");
//模块加载函数
static int hello_init(void)
{
	printk(KERN_ALERT "Hello World enter\n");
return 0;
}
//模块卸载函数
static void hello_exit(void)
{
	    printk(KERN_ALERT "Hello World exit\n");
    return;
}
//模块的注册
module_init(hello_init);
module_exit(hello_exit);
//声明模块的作者
MODULE_AUTHOR("YeSiyuan");
//声明模块的描述
MODULE_DESCRIPTION("This is a simple example!\n");
//声明模块的别名
MODULE_ALIAS("A simplest example");

其中,printk为内核态的输出函数,用于将内容输出进内核日志,可以用dmesg查看日志。MODULE_LICENSE用于指定模块的许可证声明,这个是必须的,包括后面的设备驱动编写。MODULE_AUTHORMODULE_DESCRIPTIOMODULE_ALIAS用来声明作者、描述、别名,都是非必须的。

编写完成后,即可开始编译。编译需要用Linux内核本身的编译工具,还需要附加一下链接,因此为了编译方便,我们编写一个Makefile文件,内容如下:

obj-m += hello.o  # 要和编译的cpp文件一致
  
#generate the path
CURRENT_PATH:=$(shell pwd)
 
#the current kernel version number
LINUX_KERNEL:=$(shell uname -r)
 
#the absolute path--根据获取的内核版本拼装绝对路
LINUX_KERNEL_PATH:=/lib/modules/$(LINUX_KERNEL)/build/
 
#complie object
all:
        make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH)
#clean
		 make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) clean

在工作目录下执行命令make,它会自动找到该上述Makefile进行执行。编译完成后即可安装/卸载该模块。使用insmod hello.ko来安装,使用rmmod hello.ko来卸载,结果如下,注意要sudo


编写Linux设备驱动

这里将简单的编写一个Linux驱动程序,功能:write 几个整数进去,read 出其和。先要明白,在Linux中设备即文件,对设备驱动程序的实现实际上就是对文件操作各个接口的的实现。文件操作的结构定义在结构体file_operations中,其是一个函数指针的集合,其中比较重要的的为open()close()write(), read()。这里就重点实现这四者。

首先实现open,这个很简单,只需要printk一下就行:

int hello_open(struct inode * ip, struct file * fp)
{
    printk("%s : %d\n", __func__, __LINE__);
    
    /* 一般用来做初始化设备的操作 */
    return 0;
}

然后实现close,也只需要printk一下就行:

int hello_close(struct inode * ip, struct file * fp)
{
    printk("%s : %d\n", __func__, __LINE__);
    
    /* 一般用来做和open相反的操作,open申请资源,close释放资源 */
    return 0;
}

接着实现最重要的wirte函数和read函数。这里要求write函数能够写进去2个整数,然后read可以读回两数之和。需要注意的是,用户程序是工作在用户态的,而驱动程序是工作在内核态的,两者是不能直接进行数据通信的,必须借助一些通信函数才能实现,即copy_from_usercopy_to_user,前者将数据从用户空间拷贝到内核空间,后者将数据从内核空间拷贝到用户空间。因此,编写如下write函数:

ssize_t hello_write(struct file * fp, const char __user * buf, size_t count, loff_t * loff)
{
    int ret;
    
    /* 将用户需要的数据从用户空间copy到内核空间(buf) */
    printk("%s : %d\n", __func__, __LINE__);
    if ((ret = copy_from_user(data, buf, count)))
    {
        printk("copy_from_user err\n");
        return -1;
    }
    printk("data1 = %c\ndata2 = %c\n",data[0],data[1]);
    return count;
}

接下来开始编写read函数。需要注意的是,文件操作接口中write和read的缓冲区参数的是char*类型的,而求和是数字求和,因此要减去两个z字符‘0’。完成后,计算出两数之和,然后返回该数即可,代码如下:

ssize_t hello_read(struct file * fp, char __user * buf, size_t count, loff_t * loff)
{
    int ret;
    /* 将用户需要的数据从内核空间copy到用户空间(buf) */
    printk("%s : %d\n", __func__, __LINE__);
    if ((ret = copy_to_user(buf, data, count)))
    {
        printk("copy_to_user err\n");
        return -1;
    }
    int sum = data[0] + data[1] - '0' - '0';
    printk("sum is %d\n",sum);
    return sum;
}

编写完四个接口实现后,需要在file_operations结构体中对各接口进行注册,如下:

/* 分配file_operations结构体 */
struct file_operations hello_fops = {
    .owner = THIS_MODULE,
    .open  = hello_open,
    .release = hello_close,
    .read = hello_read,
    .write = hello_write8.	
};

最后,由于设备驱动程序也是借助了Linux的模块机制,添加设备也需要添加模块,因此还要实现init和exit即进行module_initmodule_exit。和上一个任务的init不同,该任务由于要新增设备,因此在init的实现中要使用MKDV生成设备号,并使用register_chrdev_region来注册设备字符设备。注册完之后,还要通过cdev_addcdev_init来分配、设置、注册cdev结构体,最终的init和exit代码为:

struct cdev cdev; 
static int hello_init(void)
{
    int ret;
    printk("%s : %d\n", __func__, __LINE__);
    
    /* 1. 生成并注册设备号 */
    devno = MKDEV(major, 0);
    ret  = register_chrdev_region(devno, 1, DEVNAME);
    if (ret != 0)
    {
        printk("%s : %d fail to register_chrdev_region\n", __func__, __LINE__);
        return -1;
    }
    
    /* 3. 分配、设置、注册cdev结构体 */
    cdev.owner = THIS_MODULE;
    ret = cdev_add(&cdev, devno, 1);
    cdev_init(&cdev, &hello_fops);
    if (ret < 0)
    {
        printk("%s : %d fail to cdev_add\n", __func__, __LINE__);
        return -1;
    }
    printk("Init success!\n");
    return 0;
}
 
static void hello_exit(void)
{
    printk("%s : %d\n", __func__, __LINE__);
      
    /* 释放资源 */
    cdev_del(&cdev);
    unregister_chrdev_region(devno, 1);
}
 
MODULE_LICENSE("GPL");
module_init(hello_init);
module_exit(hello_exit);

编写完设备驱动的代码后,同上一个任务,编写Makefile,模块名为ysy_dev,同cpp文件名:

KERNEL_PATH := /lib/modules/`uname -r`/build
PWD := $(shell pwd)
MODULE_NAME := ysy_dev
 
obj-m := $(MODULE_NAME).o
 
all:
	$(MAKE) -C $(KERNEL_PATH) M=$(PWD)
 
clean:
	rm -rf .*.cmd *.o *.mod.c *.order *.symvers *.tmp *.ko

注意,模块是模块,设备是设备,设备只是借助模块来注册和卸载,所以设备名不等同于模块名,根据驱动代码可知,设备名为ysy_device

使用make进行编译,然后使用insmod来安装该模块,之后使用cat /proc/devices来查看所有设备编号,发现设备安装成功,如下:

可以看到,设备安装成功,设备编号为255。但是,到该步为止只是将驱动安装到了内核中,如果想要访问驱动,必须先创建设备节点,通过设备节点来访问,输入命令:

sudo mknod /dev/ysy_device c 255 0

其中,c指字符设备,255是主编好,0是次编号,要和编写的设备对用。该命令会创建设备节点和设备挂钩,实际就是一个文件,位于/dev目录下。

至此,设备驱动与设备节点全部创建成功,可以开始测试。


设备测试

编写了的测试程序代码如下,会接收第1个参数作为设备路径,然后使用设备驱动将第2、3个参数写入内核日志,并返回两数之和。不解释了,直接上代码:

#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
 
int main(int argc, char * argv[])
{
    int fd;
    int ret;
    char buf[2];   // two num 
    buf[0] = argv[2][0];
    buf[1] = argv[3][0];
    
    /* 将要打开的文件的路径通过main函数的参数传入 */
    if (argc < 2){
        printf("Usage: %s <filename>\n", argv[0]);
        return -1;
    }
    fd = open(argv[1], O_RDWR);
    if (fd < 0){
        perror("fail to open file");
        return -1;
    }
 
    /* write data */
    ret = write(fd, buf, sizeof(buf));
	if (ret < 0){
        printf("read err!");
        return -1;
    }
    
    /* read data */
    ret = read(fd, buf, sizeof(buf));
    if (ret < 0){
        printf("read err!");
        return -1;
    }
    printf("sum = %d\n",ret);
    
    close(fd);
    return 0;
}

使命令sudo ./devTest.o /dev/ysy_deivce 3 4 来执行测试程序,打开设备驱动并计算3和4的和,并使用dmesg查看内核缓冲区,结果如下:

可以看到,不仅测试程序返回了正确的执行结果,驱动程序还在内核中输出了相关信息。包括,两个输入数据分别为3和4,计算的求和结果为7都成功打印在了内核缓冲区。


文章作者: SrcMiLe
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 SrcMiLe !
评论
 上一篇
Docker进阶(二): 启动进程 Docker进阶(二): 启动进程
众所周知,k8s中以Pod作为调度的基本单位,一个Pod中一般有多个Container。那为什么k8s不能像Docker一样以Container一样作为调度单位呢?那是因为,在Docker中,一个Container中最好只运行一个进程,这也
2022-03-06
下一篇 
k8s资源清单 k8s资源清单
搭建好k8s之后,就可以在集群中跑一些应用啦,不过在跑之前,要先知道几个概念。
  目录