linux驱动开发(二):Linux字符设备驱动程序(设备号、cdev、设备节点、file_operations)

Linux系统将设备分成字符设备、块设备、网络设备三类。


用户程序调用硬件的过程如下。

一、用户级、内核级和系统调用

Linux/Unix系统下的进程运行分为用户态进程态两种状态。我们的应用程序通常仅在用户态下运行,出于保护内核资源的需要,用户态下运行的程序在只能访问有限的资源,例如不能访问内核的数据结构和程序。

内核的一个重要功能就是协调和管理硬件资源,包括CPU、内存、I/O设备等,从而为上层运行的诸多应用程序提供更好的运行环境。因此,驱动程序通常都是在内核态下运行。我们的进程大多数时间都是运行在用户态下的,一旦它需要操作系统帮助它完成一些自己没有权限和能力完成的工作,就会切换到内核态。而系统调用就是进程主动申请从用户态切换到内核态的方式。

除了系统调用以外,发生异常或外围设备的中断也会让进程从用户态进入内核态。但是,系统调用是应用程序主动转入内核态,而异常和中断是被动转入内核态。
异常又称内中断,来源于操作系统的内部,通常是进程执行时引发了故障,如缺页、地址越界、算术溢出、除数为零等。异常产生后,操作系统会夺回内核的使用权,对异常进行处理。
外围设备的中断是指在外围设备在处理完用户要求的操作后,会向操作系统发出中断信号,此时操作系统会暂停执行当前正在执行的指令,转而处理中断信号。这个过程需要在内核态下进行,因此会由用户态进入内核态。

二、虚拟文件系统(VFS)

1. 什么是虚拟文件系统

我们在Unix/Linux系统下用户程序操作普通文件,即txt、pdf、mp4等,使用open函数打开文件,使用read、write函数对文件进行读写,使用close函数关闭文件。但除了普通文件外,Unix/Linux的创作者提出“一切皆文件”的思想,希望将目录、字符设备、块设备、套接字等也被等同于文件对待,使用普通文件使用相同的文件操作接口,如open、read、write,对这些设备进行操作。

虚拟文件系统(Virtual File System,VFS)是Linux系统中的一个软件抽象层,它就是实现上述功能的关键。它为用户空间的程序提供了文件系统接口,同时定义了所有文件系统都支持的基本的、概念上的接口和数据结构。例如,读写普通的文本文件和读写I/O设备的具体实现方法必然是不同的,但VFS提供了统一的接口read和write,开发人员需要编写这些接口的具体的不同的实现。因此,在VFS层和内核的其他部分看来,所有文件都只需调用read和write函数就可以完成读写功能,具体的实现过程它们并不关心。

2. 文件接口结构体file_operation

Linux内核定义了结构体file_operation,作为提供给VFS的文件接口。该结构体的定义如下。

include/linux/fs.h
---------------------------------------------------------------------------------------------------
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, 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 *);
    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);
    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);
};

file_operations的成员变量基本上都是函数指针,驱动开发者需要根据不同设备的要求,为这些函数指针编写具体的实现。以open函数为例,不同设备的打开方式不同,因此open函数的实现显然应该是不同的,但从VFS层面上看,都是只需要调用open函数就可以满足打开设备的需求。

三、设备号

1. 什么是设备号

Linux系统使用设备号来区分各个设备。设备号是一个32位的无符号整型数,它的高12位是主设备号,低20位是次设备号。

为什么要区分主设备号和次设备号呢?

举个例子,在一个硬件上可能有多个串口,系统会将每个串口都作为一个设备处理。但很多时候,这些串口发挥的作用是相同的,只有地址不同。我们希望这些串口都由同一个驱动管理,而不是为每个串口都写一个驱动程序,因此Linux将设备号分为主设备号和次设备号。我们将这些串口分配相同的主设备号和不同的次设备号,这样只要使用同一个驱动程序即可。

/proc/devices文件下记录着每个设备和它对应的设备号,注意,字符设备和块设备是分开独立编号的。(/proc目录用来存放系统进程运行时使用的文件)

2. 与设备号有关的宏

Linux内核定义了一些与设备号有关的宏,常见的宏如下。

#define MKDEV(major, minor) //将主设备号和次设备号拼接成设备号
#define MAJOR(devno) //从设备号中提取主设备号
#define MINOR(devno) //从设备号中提取次设备号

3. 分配和释放设备号的函数

register_chrdev_region函数用来将一系列字符设备号分配给字符设备。这一系列字符设备有相同的主设备号,次设备号是连续的。

int register_chrdev_region(dev_t from, unsigned count, const char *name)

各个参数的含义。

参数 含义
from 要分配的这一系列设备号的第一个设备号
count 分配的设备号的个数
name 设备的名字

返回值。设备分配成功时返回0,分配失败时,例如要分配的设备号已经被占用了,则返回一个负值的错误码。

unregister_chrdev_region函数用来将设备号释放掉。由于设备号是临界资源,同一个设备号不能被多个设备共有,因此设备使用结束后一定要及时释放,以免不必要的资源浪费。

void unregister_chrdev_region(dev_t from, unsigned count)

该函数参数的含义与register_chrdev_region参数的含义相同。

4. 设备号分配的原理

在Linux内核中定义了一个全局指针数组chrdevs,用来管理分配出去的设备号。chrdevs的每一个元素都是一个指向char_device_struct结构体的指针。它的具体定义如下。

fs/char_dev.c
-----------------------------------------------------------------------------------------------
#define CHRDEV_MAJOR_HASH_SIZE 255

static struct char_device_struct {
    struct char_device_struct *next;
    unsigned int major;
    unsigned int baseminor;
    int minorct;
    char name[64];
    struct cdev *cdev;
} *chrdevs[CHRDEV_MAJOR_HASH_SIZE];

每个成员的含义如下。

成员 含义
next 链表中指向下一个节点的指针
major 主设备号
baseminor 次设备号的第一个
minorct 申请的次设备号的数量
name 设备的名字
cdev 下文中会具体介绍cdev结构体

chrdevs实际上是一个哈希表,它的关键字为主设备号major,散列函数为index = major % 255。初始状态下,没有设备号被分配出去,chrdevs数组为空。一旦有设备号被分配,则首先为该设备创建一个char_device_struct结构体对象,然后根据主设备号计算得到散列结果,将结构体对象挂载在chrdevs中的对应位置上。如果该位置已经有节点,则根据次设备号由小到大的寻找到合适的位置,挂载在对应节点的next指针上,构成有序列表。

下面举个例子说明这个过程。

初始状态下的的chrdevs表,实际上是255个char_device_struct *类型指针。


使用register_chrdev_region函数,向系统注册一个主设备号major = 257、次设备号为0到3的设备、名称为“dev1”的设备,则需要首先创建一个char_device_struct结构体,并对结构体中的成员进行初始化,然后计算散列值i = 257 % 255 = 2,并让chrdevs[2]这个指针指向这个结构体。

如果我们再向系统注册一个主设备号为2、次设备号为0到1、名称为“dev2”的设备,则同样创建一个char_device_struct结构体并初始化。这次我们计算得到的散列值i = 2 % 255依旧为2,与上一个设备在哈希表上产生了冲突,这时结构体中的指针发挥了作用,我们将新结构体挂在老结构体的前面或后面,形成一个链表即可。挂载的位置由主设备号的大小决定,我们要求形成的链表是一个major由小到大排序的有序链表。

四、字符设备的内核抽象

1. 字符设备结构体cdev

内核为字符设备抽象出一个结构体cdev,定义如下:

struct cdev {
    struct kobject      kobj;
    struct module       *owner;
    const struct file_operation *ops;
    struct list_head    list;
    dev_t               dev;
    unsigned int        count;
};

每个成员的含义如下。

成员 含义
kobj 内嵌的内核对象,此处不展开讨论
owner 字符设备驱动程序所在的内核模块指针,此处不展开讨论
ops 提供给VFS的文件接口结构体
list 将系统中的字符设备形成链表
dev 字符设备的设备号
count 隶属于同一主设备号的次设备号的数量,表示当前设备驱动程序控制的设备的数量

2. 字符设备的初始化

Linux内核为初始化cdev对象提供了cdev_init函数,定义如下。

fs/char_dev.c
-----------------------------------------------------------------------------------------------
void cdev_init(struct cdev *cdev, const struct file_operations *fops)
{
    memset(cdev, 0, sizeof *cdev);
    INIT_LIST_HEAD(&cdev->list);
    kobject_init(&cdev->kobj, &ktype_cdev_default);
    cdev->ops = fops;
}

这个函数的作用就是针对一个cdev结构体里面的成员进行初始化,其中最重要的一步就是把cdev和fops连接在一起,因为每一个设备都会有一个自己的文件操作逻辑,即需要实现一个自己的file_operations结构体。

3. 向系统添加或删除字符设备

将字符设备加入到系统中,简单地说,就是将上文中我们初始化的cdev结构体添加到Linux系统维护的一个全局哈希链表cdev_map中,这样其他模块才能使用通过cdev_map找到它。Linux为了完成这个操作,提供了cdev_add函数,实现如下。

fs/char_dev.c
-----------------------------------------------------------------------------------------------
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
{
    p->dev = dev;
    p->count = count;
    return kobj_map(cdev_map, dev, count, NULL, exact_match, exact_lock, p);
}

函数参数的含义如下。

参数 含义
p 要加入系统的字符设备对象的指针
dev 该设备的设备号
count 被注册的设备数量

该函数的核心功能在kobj_map函数中实现,该函数的实现略微有些复杂,此处不再赘述,有兴趣的可以阅读附录

相应的,删除设备需要使用cdev_del函数。该函数的作用就是将对应的设备从系统中移除,即从cdev_map链表中删除节点并释放内存空间。其定义如下。

fs/char_dev.c
-----------------------------------------------------------------------------------------------
void cdev_del(struct cdev *p)
{
    cdev_unmap(p->dev, p->count);
    kobject_put(&p-> kobj);
}

五、设备节点

1. 什么是设备节点

设备节点也可以称为设备文件,是一种特殊类型的文件,其作用是沟通用户空间的应用程序和内核空间的驱动程序的节点。应用程序如果想使用驱动程序提供的服务,则必须经过设备节点实现。

2. 设备节点的创建

设备节点有静态创建和动态创建两种方式,我们这里只介绍静态创建方法。Linux系统使用mknod命令静态创建设备节点,该命令需要列出设备节点的设备节点名、主设备号和次设备号。通常情况下,Linux系统将所有设备节点都放在/dev目录下,因此我们也将设备节点创建在/dev目录下。该命令具体格式如下。

mknod /dev/<设备节点名> c <主设备号> <次设备号>

参数c表示节点的类型为字符设备。

mknod命令是通过调用mknod函数实现,它会通过系统调用sys_mknod进入内核空间,并生成一个inode。

inode是Linux文件管理系统维护的一个结构体,每一个文件(不限于设备节点)都会有一个自己inode,用来存储文件的静态信息,如文件访问权限、属主、组、大小、生成时间、访问时间、最后修改时间等。

由于inode结构体中的成员非常多,我们此处不再一一列举,只列举几个常用的。

struct inode {
    kuid_t      i_uid;   //属主的ID(UID)
    kgid_t      i_gid;   //属主的组ID(GID)
    loff_t      i_size;  //文件大小
    dev_t       i_rdev;
    struct cdev *i_cdev;
    ......
};

这里我们主要用到i_rdev和i_cdev两个成员,i_rdev是该设备的设备号,i_cdev是该设备的cdev结构体。

3. 设备节点的打开

在创建了设备节点和它的inode后,应用程序就可以像打开普通文件一样使用open函数打开设备节点。用户空间下的应用程序使用open系统调用,其函数原型如下。

int open(const char *filename, int flags, mode_t mode);

参数的含义

参数 含义
filename 要打开的文件的文件名
flags 该文件的打开模式或创建模式
mode 仅在创建一个新文件时使用,用于指定新建文件的访问权限
返回值 成功时返回该文件的文件描述符,失败时返回-1

文件描述符(File Discriptor, fd)本质是一个int型变量(非负整数),可以看作Linux系统为每个打开的文件分配的一个索引值。在后续对该文件的read、write、close等操作时,都要通过这个索引值,来找到这个打开的文件。

系统调用open通过层层复杂的机制,最终会调用到我们为该设备编写的file_operations结构体中的.open函数中,最终实现该设备的打开操作。

Linux会为每一个打开的文件维护一个file结构体,它在文件打开时被创建,直到该文件关闭时被释放。与inode结构体不同,一个文件只能有一个inode结构体,但如果它被同时打开很多次,那么Linux会为它创建多个file结构体,且file结构体最终指向同一个inode。

file结构体中的成员较多,下面列举一些常用的成员。

struct file {
    struct inode        *f_inode;
    const struct file_operations    *f_op;
    atomic_long_t       f_count;
    unsigned int        f_flags;
    fmode_t             f_mode;
    loff_t              f_pos;
    void                *private_data;
    ......
};

几个常用的成员及其含义如下。

成员 含义
f_op 与文件关联的各种操作file_operations
f_count 记录该文件对象被引用的次数,也就是有多少个进程正在使用该文件
f_flags 文件打开时指定的标志,驱动程序中常用O_NONBLOCK来检查是否是非阻塞请求
f_mode 文件的读写模式,通过置位FMODE_READ和FMODE_WRITE,来确定文件是可读、可写或者既可读又可写的
f_pos 文件当前的读写位置,本质是一个long long类型的值
private_data 用来保存自定义设备结构体的地址

以上就是字符设备中常用的数据结构和算法,在下一篇博客中,我会举一个实例,来使用上述内容完成一个应用程序与驱动程序交互的实例。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容