Linux字符设备驱动框架

前言

作为操作系统厂商,避免不了与内核驱动开发打交道,很多人觉得内核驱动开发很神秘,今天借着分享的机会,手把手带领大家一起来写一个字符设备驱动。最终目的是,让未接触过驱动开发的同学,能熟悉掌握驱动开发基本操作,讲解linux字符设备驱动框架,并介绍在深度操作系统下进行驱动开发的基本方法。

一、基础知识储备

1.Linux设备驱动分为三类:

(1)字符设备驱动 (char device driver)

采用字节流方式访问的设备称为字符设备,常见的设备有:字符终端、串口等设备、触摸屏等

(2)块设备驱动 (block device driver)

该类设备通常在物理上不能按字节处理数据,只能通过一个或多个整块数据进行读、写、擦除等控制操作, 常见的块设有:磁盘、光盘、 USB存储设备、Nand Flash存储设备等。

(3)网络设备驱动 (network device driver)

网络设备是一类特殊设备,采用数据包传输方式访问设备,系统对该类设备提供对发送数据和接收数据的缓存,提供流量控制机制、对多协议的支持,一个接口通常是一个网络硬件设备,也可以是一个纯粹的网络软件设备,系统标准网络设备例如:回环接口

 

2.应用程序和内核以及硬件的关系如图1:屏幕快照 2019-01-27 17.12.47
图1

3.字符设备驱动工作流程如图2:
字符设备驱动工作流程
图2

4.其中涉及到重要的点有以下几个方面:

  • 设备号
  • 设备文件
  • 数据结构
  • 设备注册
  • 设备操作

下面分别描述:

(1)设备号:

设备号数据类型dev_t用于定义设备号,它本质上是一个unsigned int数据类型,高12位是主设备号,低20位是次设备号,设备内核为设备号数据类型提供一系列操作在<linux/kdev_t.h>中定义。

主设备号:用于标识设备类型

次设备号:用于标识同类型的不同设备个体,驱动程序依据该号码辨别具体操作的是哪个设备个体

设备号分配:可以静态分配和动态分配。

设备号分配方式 静态分配 动态分配
方法 Documentation/devices.txt文件列出本内核源代码已经被使用和可以使用的主设备号 在驱动模块被加载时向内核动态申请主设备号
好处 简单,驱动加载前已经知道设备号;可以提前创建设备文件 便于驱动推广,避免设备号冲突
缺点 保留的可用主设备号资源有限,该主设备号可能会引起设备号冲突,从而导致驱动程序无法加载 驱动程序被加载前设备号还没有分配,所以,无法知道设

备号,也就不能提前为设备创建设备文件,只能在驱动程序加载后,从/proc/devices文件中查询设备号

 

(2)设备文件:在linux系统上一个字符设备是作为文件去进行操作,如读、写、删除等,那么既然作为文件操作,就需要一个设备文件对应到设备,提供上层软件对文件进行操作的入口,通过操作该文件来操作相应的设备。

创建设备文件有如下两种方式:

设备文件创建方式 手动创建 自动分配
方法 mknod命令创建设备文件

$mknod devicefilename type major minor

例如:

mknod /dev/hellocdev c 222 0

内核提供了一组函数,可以用来在模块加载的时候在/dev目录下创建相应的设备节点,并在模块卸载时删除该设备节点
说明 devicefilename: 待创建的设备文件名称

type: 待创建设备文件类型,通常是字符设备c或块设备b

major: 待创建设备文件的主设备号

minor: 待创建设备文件的次设备号

在内核2.4版本中使用devfs_register

在内核2.6早期版本中使用class_device_register

在内核2.6.35中使用class_create和device_create

 

(3)字符设备相关数据结构:

重要数据结构 文件结构 struct file inode结构 struct inode 文件操作结构 struct file_operations 字符设备结构 struct cdev
作用 代表打开的设备文件 用于记录文件物理信息,不同于struct file,一个文件可以对应多个file结构,但是只有一个inode结构 本质上是一个函数指针的集合,这些函数定义了能够对设备进行的操作

 

内核使用该结构来表示一个字符设备
定义位置 <linux/fs.h> <linux/fs.h> <linux/fs.h> <linux/cdev.h>

 

(4)字符设备注册:内核在操作设备之前,必须分配和注册该字符设备结构体,并将它插入到设备驱动模型的数据结构中,并把该设备结构体和和设备文件连接起来。

设备驱动程序模型:简单点说是,内核提供了一些数据结构和辅助函数,为系统中所有的总线、设备、以及设备驱动程序提供一个统一的视图。

(5)字符设备操作,详细定义见<linux/fs.h>

该函数是设备文件上的第一个操作,不要求驱动程序一定要实现该函数;如果该项为NULL,设备的打开操作永远成功

int (*open)(struct inode *inode, struct file *filp);

该函数当设备文件被关闭时调用;open方法类似,release操作也可以没有

int (*release)(struct inode *inode, struct file *filp);

从设备中读取数据:

ssize_t (*read)(struct file *filp, char __user *buf, size_t count, loff_t *f_pos);

向设备变送数据:

ssize_t (*write)(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos);

其他的还有:

修改文件的当前读写位置,并将新位置作为返回值

loff_t (llseek)(struct file *filp, loff_t off, int whence);int (ioctl)(struct inode *inode, struct file *filp, unsigned int cmd, unsigned int arg);执行设备控制

对应select系统调用,查询设备状态:

unsigned int (*poll)(struct file *filp, struct poll_table_struct *pts);

该操作用来通知设备它的FASYNC标志的改变:

int (*fasync) (int fd, struct file *filp, int mode);

将设备内存映射到进程虚拟地址空间中:

int (*mmap)(struct file *filp, struct vm_area_struct vas);

二、在深度系统上进行驱动开发步骤

有了上述知识储备,我们可以编写自己的第一个字符设备驱动程序,该字符设备驱动只做一些打印工作,无具体功能,主要目的是能让大家了解开发步骤和过程。

1.环境准备

硬件环境:

cpu:Intel(R) Pentium(R) CPU G3260 @ 3.30GHz

内存:4G

软件环境:

操作系统:    deepin professional 15.5

kernel:          4.9.0-deepin9-amd64

gcc:             6.3.0

 

2.代码编写讲解:

先写一个最基础的版本,准备不支持自动创建设备节点,只能加载驱动后只能手动创建设备文件。

vi hellocdev.c

 
/***********

手动创建设备文件的字符设备编写流程:

步骤1.开始写设备初始化函数

步骤2.创建设备号,静态创建MKDEV(major,minor),并注册设备号

步骤3.定义一个字符设备结构体cdev结构

步骤4.模块加载时初始化cdev结构

通过cdev_init()

其中struct file_operations * fops,保存了该设备支持的系统调用

步骤5.向内核添加该字符设备,告诉内核该设备的具体信息。

cdev_add()

步骤6.初始化操作函数fops结构体

struct file_operations fops;

步骤7.卸载模块时删除模块,并取消注册设备号

cdev_del();

unregister_chrdev_region();

步骤8.编译测试

*************/

#include <linux/init.h>

#include <linux/module.h>

#include <linux/fs.h>

#include <linux/cdev.h>

//如下声明开源协议可以不要,但insmod会报警告module license 'unspecified' taints kernel.

MODULE_LICENSE("GPL");

//1.创建设备号

#define HELLOCDEV_MAJOR 222

#define HELLOCDEV_MINOR 0

#define HELLOCDEV_COUNT 2

//定义设备号变量

static dev_t dev = 0;

/***步骤3***定义一个字符设备结构体cdev结构,内核中用该结构来代表该字符设备*/

struct cdev hellocdev_cdev;

 

//***步骤6***初始化操作函数fops结构体,open,read和close

//定义用户态open()函数被调用时内核态执行的操作

int  hellocdev_open(struct inode *inode, struct file *filp)

{

printk("enter hellocdev_open!\n");

return 0;

}

//定义用户态read()函数被调用时内核态执行的操作

ssize_t hellocdev_read(struct file *filp, char __user *buf,

size_t count, loff_t *offset)

{

printk("enter hellocdev_read!\n");

return 0;

}

//定义用户态close()函数被调用时内核态执行的操作

int hellocdev_release(struct inode *inode, struct file *filp)

{

printk("enter hellocdev_release!\n");/*内核空间的打印函数,类似于标准c的printf.在dmesg中能看到该打印*/

return 0;

}

struct file_operations hellocdev_fops =

{

.owner = THIS_MODULE,//指向实现驱动模块的指针

.open  =  hellocdev_open,//open系统调用

.read  = hellocdev_read,//read系统调用

.release = hellocdev_release,//close系统调用

};

//***步骤1*** 模块初始化函数,驱动加载时调用,用于驱动模块初始化

int __init hellocdev_init(void)

{

printk("hellocdev init!\n");

int ret = 0;

//***步骤2***静态分配设备号, 并注册,注意:需要通过cat /proc/devices 查看设备号占用情况,比如我系统当前222主设备号就未占用*/

if(HELLOCDEV_MAJOR)

{

//分配设备号,查看宏定义就会知道事实上dev = HELLOCDEV_MAJOR<<20 + HELLOCDEV_MINOR;

dev = MKDEV(HELLOCDEV_MAJOR , HELLOCDEV_MINOR);

//注册设备号

ret = register_chrdev_region(dev, HELLOCDEV_COUNT, "hellocdev");

}

if(ret < 0)

{

printk("register_chrdev_region failed!\n");

return ret;

}

//***步骤4***初始化该设备结构体

cdev_init(&hellocdev_cdev, &hellocdev_fops);

//***步骤5***向内核添加该字符设备,告诉内核该设备的具体信息。

ret = cdev_add(&hellocdev_cdev, dev, HELLOCDEV_COUNT);

if(ret <0)

{

printk("cdev_add failed!\n");

return ret;

}

return 0;

}

//模块退出函数,驱动卸载时调用,用于驱动模块退出时做清理操作

void __exit hellocdev_exit(void)

{

/*从内核中删除cdev */

cdev_del(&hellocdev_cdev);

/*注销设备号*/

unregister_chrdev_region(dev, HELLOCDEV_COUNT);

printk("hellocdev removed\n");

}

//***步骤8*** 模块初始化函数声明,告诉内核驱动加载和卸载操作时执行哪个函数

//模块初始化函数声明,告诉内核驱动加载操作执行哪个函数

module_init(hellocdev_init);

//模块卸载函数声明,告诉内核卸载操作执行哪个函数

module_exit(hellocdev_exit);

 

/***********************************************************************/

3.编写驱动测试程序,打开该设备并执行调用read()函数120次,每次间隔1秒:

test.c

/*******************************************/

#include <stdio.h>

#include <fcntl.h>

#include <unistd.h>

int main(int argv,char ** args)

{

int fd = 0;

char ch = 120;

//如果判断传参是否正确,如果错误,提示正确用法

argv>1?printf("start open device\n"):printf("please input device name like : %s /dev/hellocdev* \n",args[0]);

fd = open(args[1],O_RDWR);

if(fd<0)

{     printf("open device %s failed\n",args[1]);

return 0;//设备文件打开失败直接退出

}

//每隔1秒读一次设备文件,其实就是调用了驱动定义的read()函数

while(ch--){

printf("user read %s,%d times\n",args[1],120-(int)ch);

read(fd,&ch,0);

sleep(1);

};

close(fd);

printf("user device closed\n");

return 0;

}

/*******************************************/

4.编译

(1)安装内核头文件:

sudo apt-get install linux-headers-4.9.0-deepin9-amd64

(2)编辑Makefile:

KERNELDIR = /usr/src/linux-headers-4.9.0-deepin9-amd64/

obj-m      += hellocdev.o

default:

$(MAKE) -C $(KERNELDIR) M=$(PWD)     modules

clean:

@rm -rf .o *.ko *.order *.sy .mod

(3)make

编译完成能看到hellocdev.ko文件就是编译好的驱动模块。

(4)加载模块:

加载驱动模块可以用insmod也能用modprobe,不过modprobe比较智能,它可以根据module的依赖性来自动加载依赖模块,insmod只能人为处理依赖,本驱动无依赖,所以使用insmod

$sudo insmod ./hellocdev.ko

$sudo dmesg

能看到有输出如下信息

hellocdev init!

insmod成功后,会存在一个文件/sys/module/hellocdev

(5)卸载模块:

$sudo rmmod hellocdev

$sudo dmesg

kernel: hellocdev removed

 

(6)测试验证设备驱动功能

先加载驱动模块,并创建设备文件,不然测试程序无法操作该设备。

$sudo insmod ./hellocdev.ko

手动创建节点:(222是代码里已经定义的主设备号,0是次设备号,本次测试也可是1,因为定义了可以有两个这种设备#define HELLOCDEV_COUNT 2)

$sudo mknod /dev/hellocdev c  222 0

$ ls /dev/hellocdev

/dev/hellocdev

 

$gcc test.c -o test

$sudo ./test /dev/hellocdev

start open device

user read /dev/hellocdev,1 times

user read /dev/hellocdev,2 times

user read /dev/hellocdev,3 times

… …

user read /dev/hellocdev,119 times

user read /dev/hellocdev,120 times

user device closed

 

此时另外开一个终端,输入如下命令能看到内核的read函数被调用

$sudo journalctl -f

1月 27 15:04:27 jesen-PC kernel: enter hellocdev_read!

1月 27 15:04:28 jesen-PC kernel: enter hellocdev_read!

1月 27 15:04:29 jesen-PC kernel: enter hellocdev_read!

… …

1月 27 15:05:49 jesen-PC kernel: enter hellocdev_read!

1月 27 15:05:50 jesen-PC kernel: enter hellocdev_release!

到此,程序运行结束

测试完成

$sudo rm /dev/hellocdev

$sudo rmmod hellocdev

以上是手动创建设备文件的开发和测试。

三.自动创建设备文件

上一部分介绍手动创建有缺点,需要先知道主设备号,而且多人写驱动时,有可能会重复使用一个主设备号,导致冲突,下面我将其改造成自动创建设备文件的,通过对比方便分析是如何自动创建设备文件。

1.代码编辑

将手动创建设备文件的开发目录拷贝一份,并进入该目录,编辑hellocdev.c为如下:


/*********** 自动创建设备文件的字符设备编写流程: 步骤1.开始写设备初始化函数 步骤2.创建设备号,动态创建,并注册设备号 步骤3.定义一个字符设备结构体cdev结构,声明设备类 步骤4.模块加载时初始化cdev结构 步骤5.向内核添加该字符设备,告诉内核该设备的具体信息。 步骤6.初始化操作函数fops结构体 步骤7.注册设备类 步骤8.注册设备,最终创建设备文件 步骤9.卸载模块时删除模块,并取消注册设备号 *************/ #include <linux/init.h> #include <linux/module.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/device.h> MODULE_LICENSE("GPL"); //1.创建设备号 #define HELLOCDEV_MAJOR 222 #define HELLOCDEV_MINOR 0 #define HELLOCDEV_COUNT 2 //定义设备号变量 static dev_t dev = 0; /***步骤3***定义一个字符设备结构体cdev结构,内核中用该结构来代表该字符设备*/ struct cdev hellocdev_cdev; struct class *dev_class = NULL; struct device *dev_device = NULL;   //***步骤6***初始化操作函数fops结构体,open,read和close //定义用户态open()函数被调用时内核态执行的操作 int  hellocdev_open(struct inode *inode, struct file *filp) { printk("enter hellocdev_open!\n"); return 0; } //定义用户态read()函数被调用时内核态执行的操作 ssize_t hellocdev_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { printk("enter hellocdev_read!\n"); return 0; } //定义用户态close()函数被调用时内核态执行的操作 int hellocdev_release(struct inode *inode, struct file *filp) { printk("enter hellocdev_release!\n");/*内核空间的打印函数,类似于标准c的printf.在dmesg中能看到该打印*/ return 0; } struct file_operations hellocdev_fops = { .owner = THIS_MODULE,//指向实现驱动模块的指针 .open  =  hellocdev_open,//open系统调用 .read  = hellocdev_read,//read系统调用 .release = hellocdev_release,//close系统调用 }; //***步骤1*** 模块初始化函数,驱动加载时调用,用于驱动模块初始化 int __init hellocdev_init(void) { int ret = 0; printk("hellocdevauto init!\n"); //***步骤2***动态分配设备号, 并注册*/ /*动态分配设备号*/ ret = alloc_chrdev_region(&dev,HELLOCDEV_MINOR,HELLOCDEV_COUNT, "hellocdev"); if(ret < 0) { printk("alloc_chrdev_region failed!\n"); return ret; } //***步骤4***初始化该设备结构体 cdev_init(&hellocdev_cdev, &hellocdev_fops); //***步骤5***向内核添加该字符设备,告诉内核该设备的具体信息。 ret = cdev_add(&hellocdev_cdev, dev, HELLOCDEV_COUNT); if(ret <0) { printk("cdev_add failed!\n"); return ret; }   /***步骤7***注册设备类*/ dev_class = class_create(THIS_MODULE, "hellocdev_class"); if(IS_ERR(dev_class)) { printk("class_create failed!\n"); ret = PTR_ERR(dev_class); return ret; } /***步骤8***注册设备,最终创建设备文件*/ dev_device = device_create(dev_class,NULL, dev, NULL, "hellocdev"); if(IS_ERR(dev_device)) { printk("device_create failed!\n"); ret = PTR_ERR(dev_device); return ret; } return 0; } //模块退出函数,驱动卸载时调用,用于驱动模块退出时做清理操作 void __exit hellocdev_exit(void) { //注意以下顺序 //销毁设备文件 device_destroy(dev_class, dev); //销毁设备类 class_destroy(dev_class); /*从内核中删除cdev */ cdev_del(&hellocdev_cdev); /*注销设备号*/ unregister_chrdev_region(dev, HELLOCDEV_COUNT); printk("hellocdevauto removed\n"); } //***步骤9*** 模块初始化函数声明,告诉内核驱动加载和卸载操作时执行哪个函数 //模块初始化函数声明,告诉内核驱动加载操作执行哪个函数 module_init(hellocdev_init); //模块卸载函数声明,告诉内核卸载操作执行哪个函数 module_exit(hellocdev_exit);   /***********************************************************************/

2.编译

make

3.加载驱动模块

$sudo insmod hellocdev.ko

此时,可以看到/dev/hellocdev设备文件被自动创建

4.卸载驱动模块

$sudo rmmod hellocdev

此时,可以看到/dev/hellocdev设备文件被自动删除

 

四.结语

今天分享了linux字符驱动设备开发的简要知识和流程,字符设备是相对比较简单的一类设备,驱动程序中完成的主要工作是申请和释放设备号,初始化、添加和删除cdev结构体,填充实现file_operations结构体中的read、write等函数是驱动设计的核心工作。

发表评论

电子邮件地址不会被公开。 必填项已用*标注