欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Linux字符设备驱动

程序员文章站 2022-07-14 10:56:43
...

1. Linux设备类型

Linux内核中的设备可分为三类:字符设备、块设备和网络设备。
字符设备(Character device):适合面向字符的数据交换,因其数据传输量较低。对这种设备的读写是按字符进行的,而且这些字符是连续地形成一个数据流。他不具备缓冲区,所以对这种设备的读写是实时的,如终端、磁带机、串口、键盘等。
块设备(Block device):是一种具有一定结构的随机存取设备,对这种设备的读写是按固定大小的数据块进行的,他使用缓冲区来存放暂时的数据,待条件成熟后,从缓存一次性写入设备或从设备中一次性读出放入到缓冲区。块设备通常都是以安装文件系统的方式使用的——这也是块设备一般的访问方式。
每个字符设备和块设备都有与之对应的设备文件,这种文件并不指向磁盘或其他存储介质上的数据,只是用来建立与某个设备驱动程序的关联,让应用程序可以像访问普通文件一样来访问和操作设备,而无需过多关注设备之间的差异。正如下图所示:
Linux字符设备驱动

Linux驱动程序中字符设备和块设备的几点区别:

  • 字符设备只能以字节为最小单位访问,而块设备以固定长度的块为单位,例如512字节,1024字节等,即使只请求一个字节的数据,也会从设备中取出完整块的数据;
  • 块设备可以随机访问(在数据中的任何位置进行访问),但是字符设备不做要求(有些字符设备可以提供数据的随机访问,驱动程序可选择是否实现);
  • 块设备的读写会有大规模的缓存,已经读取的数据会保存在内存中,如果再次读取则直接从内存中获得,写入操作也使用了缓存以便延迟处理,减少了IO次数和占用的CPU时间。而字符设备每次的读写请求必须与设备交互才能完成,因此没有必要使用缓存。

网络设备(Network device):网络设备用于管理系统中的(物理或虚拟)网卡,处理网口上网络数据的收发,并提供协议栈和特定网卡之间关联的统一接口。和字串设备/块设备不同的是,网络设备在/dev下面不会有对应的设备文件,而是通过net_device结构来定义网卡提供的服务并可供用户程序读取和配置(如配置IP地址等)。和字符设备类似,网络设备不会关联到实际的存储介质或特定文件系统上。

2. 设备文件

2.1 文件属性

一个设备文件对应的设备并不是通过其文件名标识,而是通过文件的主、从设备号标识的。这些号码在系统中作为特别的文件属性管理。

aaa@qq.com:/bin# ls -l /dev/mtdblock* /dev/ttyS*
brw-r--r-- 1 root  root  31,  0 Jan  1  1970 /dev/mtdblock0
brw-r--r-- 1 root  root  31,  1 Jan  1  1970 /dev/mtdblock1
brw-r--r-- 1 root  root  31,  2 Jan  1  1970 /dev/mtdblock2
crw-rw-rw- 1 root  root  4,  64 Jan  1  1970 /dev/ttyS0
crw------- 1 root  root  4,  65 Aug 24 17:53 /dev/ttyS1

上面打印出了/dev中的几个设备文件,这些文件的属性和普通文件有两处很重要的差别:
 文件类型(访问权限之前的字母)是b或c,分别表示块设备和字符设备。
 设备文件没有文件长度,而增加了另外两个值:[主设备号, 从设备号],二者共同形成一个唯一的号码,内核可由此查找对应的设备驱动程序。

2.2 主从设备号

内核通过主从设备号来标识匹配的驱动程序。主设备号用于寻址设备驱动程序自身,系统中可能存在几个同样类型的设备,他们由同一个设备驱动程序管理,也就是说,一个主设备号对应一个驱动程序,一个次设备号对应驱动程序所实现的某个设备实例。例如上面的ttyS0和ttyS1两个设备的主设备号是同一个,而驱动程序管理的各个设备则通过不同的从设备号指定。
为驱动程序和设备分配的主从设备号,主要是通过一个半官方的组织管理,设备号的当前列表可以从http://www.lanana.org或内核源码中的Documentation/devices.txt中获取,而内核源码的

MAJOR(dev_t dev); //从dev_t中提取主设备号
MINOR(dev_t dev); //从dev_t中提取从设备号
MKDEV(int major, int minor); //根据主从设备号产生一个dev_t类型的值

3. 字符设备创建过程

3.1 管理字符设备

每个字符设备都有一个struct cdev实例与之对应,全局变量cdev_map是一个散列表,用来跟踪系统中所有的字符设备对象。(块设备也是这种做法,每个块设备的struct genhd实例都由全局变量bdev_map来跟踪维护。)
针对字符设备还有一个设备号数据库,即全局数组chrdevs[CHRDEV_MAJOR_HASH_SIZE]。仍然使用散列表来记录所有已分配的设备号范围,使用主设备作为散列键,散列方法很简单:(major % 255)。
数组的每个散列元素以及冲突链表的每个元素都是一个struct char_device_struct结构,定义如下:

static struct char_device_struct {
    struct char_device_struct *next;
    unsigned int major; //主设备号
    unsigned int baseminor; //子设备号的起始值
    int minorct; //子设备号的个数
    char name[64];
    struct cdev *cdev;      /* will die */
} *chrdevs[CHRDEV_MAJOR_HASH_SIZE];

结构体中的next指针指向冲突链表中的下一个元素。每个散列值的冲突链表由major从小到大排列,major相同的则由子设备号从小到大排列。不会也不允许存在设备号重叠的情况。

3.2 注册字符设备

通过MKDEV我们可以得到一个dev_t类型的设备编号,这个编号被作为要分配的设备编号范围的起始值,其次设备号通常为0。申请多个连续的设备编号的必要函数为:

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

这个函数有三个参数:first是设备编号范围的起始值,即上面通过MKDEV获得的dev_t类型的值,count是申请连续设备编号的个数,name是与该设备范围关联的设备名称,它将出现在/proc/devices和sysfs中。分配成功该函数返回0,失败返回错误码。
该函数将设备号 first ~ (first+count-1) 全部占为己有,字符设备的设备号数据库会将这些设备号记录为已分配,不能再被其他驱动程序申请使用。如果在申请过程中发现其中某些设备编号已经被分配过了(设备号重叠),register_chrdev_region会返回-EBUSY错误码。
对应的释放设备号的方法为:

void unregister_chrdev_region(dev_t from, unsigned count);

另外还有下面两个函数:

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name) [1]
unregister_chrdev_region(dev_t from, unsigned count) [2]

函数[1]可以动态分配主设备编号,当我们不确定要使用哪个主设备号时可以使用这个函数,该函数中basenimor和count用于请求次设备号,获取到的主设备号以及baseminor组成的dev_t存放在第一个参数dev中,同样的,分配成功该函数返回0,失败返回错误码。函数[2]用于归还设备编号。
注意alloc_chrdev_region只能申请一个major,而register_chrdev_region申请的设备号范围可以包含多个major,即同时申请多个major。

3.3 **字符设备

在获取了设备号范围后,需要将设备添加到系统的字符设备数据库(即上面讲到的全局变量cdev_map)中,以**设备。这就需要用cdev_init初始化一个struct cdev实例,并调用cdev_add添加到系统中(对应的删除方法为cdev_del())。

void cdev_init(struct cdev *cdev, const struct file_operations *fops);
int cdev_add(struct cdev *p, dev_t dev, unsigned count);

cdev_init的参数cdev即字符设备实例,可以静态定义或使用cdev_alloc()动态分配。参数fops指向与设备实际通信的函数集合,下面会讲到。

3.4 创建设备文件

用户程序通过设备文件名来操作设备(如open/read/write/ioctl/close),因此要将设备文件(在/dev下)创建好。
如果文件系统必须是只读的,则只能在打包镜像期间,通过mknod来预创建设备文件,mknod命令要指定设备类型、主从设备号和文件名。这种做法的问题是,所有设备文件都要手动创建,如果设备数非常多则是一件很无聊的事;其次,由于设备文件被放到磁盘文件系统中,如果一个设备已经不需要了,就会残留在系统中;最后,驱动程序必须使用mknod约定好的主从设备号来创建设备,降低了机动性。
可以使用udev机制来动态创建设备节点,它依赖于用户态程序udevd来监听内核的消息,并根据udev规则(/etc/udev/udev.conf中配置以及/etc/udev/rules.d/下的规则)创建设备文件。而/dev被挂载为tmpfs,这样在系统重启后,原有的设备文件被清空,由驱动程序更新。
在内核中注册并**设备后,要调用device_create(),该函数在/sys中注册相应设备,并发送一个add device的通知,udevd便可收到该通知并在/dev下创建设备文件。

struct device *device_create(struct class *class, struct device *parent, dev_t devt, void *drvdata, const char *fmt, ...);

另外,在嵌入式设备上,还有一个轻量级的工具mdev,机制和用法和udev类似,以及WRT系统上使用的hotplug2,有兴趣可以用用看。
其他接口
还有一个旧的注册字符设备的函数register_chrdev,这个函数从register到add一气呵成,不过新的代码不应该使用该函数,一来太自动化,驱动程序无法知道cdev的任何信息,而且该函数不支持大于255的子设备号。
可以把一些简单的字符设备驱动初始化为混杂驱动程序,所有混杂设备的主设备号都是10。混杂驱动程序只需调用misc_register()即可完成一个字符设备的完整注册过程,例如:

static struct miscdevice gpio_smi_dev = {
   .minor       = MISC_DYNAMIC_MINOR,
   .name        = "gpiosmi",
   .fops        = &gpiosmi_fops,
};

static int __init gpio_smi_init(void)
{
int ret;
ret = misc_register(&gpio_smi_dev);
    if(0 != ret)
    {
        return -1;
    }

    return 0;
}

static void __exit gpio_smi_exit(void)
{
    misc_deregister(&gpio_smi_dev);
}

module_init(gpio_smi_init);
module_exit(gpio_smi_exit);

在struct miscdevice结构体中,可以通过minor指定子设备号,或者指定为MISC_DYNAMIC_MINOR来让系统动态分配一个从设备号,分配好后会重新赋值给minor。
每一个混杂驱动程序自动出现在/sys/class/misc/目录下,而不必在驱动程序中再创建。

3.5 操作设备文件

关联到inode
我们说用户程序可以想操作普通文件一样来操作设备文件,那么每个设备文件肯定要关联到虚拟文件系统中的一个inode。

struct inode {
    umode_t     i_mode;
    ... ...
    dev_t           i_rdev;
  ... ...
    const struct file_operations    *i_fop;
    ... ...
    struct list_head    i_devices;
    union {
        ... ...
        struct block_device *i_bdev;
        struct cdev     *i_cdev;
    };
    ... ...
};

其中,i_mode存储了文件类型(普通文件、目录文件、字符设备、块设备、套接字等),而i_rdev中存储了主从设备号。i_fop是一组函数指针集合,包括许多文件操作(open/read/write等)来下发各种具体文件操作。内核根据inode表示字符设备还是块设备来使用i_bdev或i_cdev指向更多具体信息,i_devices是一个链表节点,以字符设备为例,cdev的list成员是一个链表,设备文件被打开一次,就会创建一个inode并插入到这个链表中,inode的i_devices成员即为链表元素。
寻找文件操作
在打开一个设备文件时,各文件系统的实现会调用init_special_inode函数为设备创建一个inode并设置默认的文件操作处理函数。

void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
{
    inode->i_mode = mode;
    if (S_ISCHR(mode)) {
        inode->i_fop = &def_chr_fops;
        inode->i_rdev = rdev;
    } else if (S_ISBLK(mode)) {
        inode->i_fop = &def_blk_fops;
        inode->i_rdev = rdev;
    } else if (S_ISFIFO(mode))
        inode->i_fop = &pipefifo_fops;
    else if (S_ISSOCK(mode))
        inode->i_fop = &bad_sock_fops;
    else
        printk(KERN_DEBUG "init_special_inode: bogus i_mode (%o) for"
                  " inode %s:%lu\n", mode, inode->i_sb->s_id,
                  inode->i_ino);
}
EXPORT_SYMBOL(init_special_inode);

可见对于所有的字符设备,VFS层的文件操作集合由def_chr_fops提供,其中打开文件的函数为chrdev_open(),该函数通过inode->i_cdev或inode->i_rdev获取到字符设备的struct cdev实例,接着调用特定于该设备的文件操作集合cdev-> ops的open方法(如果在该设备的驱动程序中有定义的话)。
额外说一下,为设备定义特定文件操作通常的做法是,首先为主设备号设置一个特定的文件操作集合(例如misc的所有设备都指向misc_fops),接下来如果某个设备需要对某些操作做补充,则定义特定于该从设备号的操作函数来覆盖原操作。通常要提供的操作有open/read/write/ unlocked_ioctl/release等。

4. 伪字符设备驱动

顺便介绍一些并没有关联到实际设备的字符驱动,例如/dev/null,/dev/random,他们可以提供一些简单常用的服务,下面这些字符设备的主设备号都是1(MEM_MAJOR),定义在drivers/char/mem.c中。
/dev/null
接收你不想在命令行上显示的数据,该设备的write不处理数据,只是返回写的长度。
/dev/zero
获取一串0字符,相当于将一段内存memset为’\0’。
/dev/mem
让我们可以直接操作或映射物理地址空间,可赋与mmap()并操作返回的区域。
/dev/random/dev/urandom
随机数发生器,从random读取的随机数的随机性要高于urandom。但random可能输出一定数量随机数后阻塞停止(可以在一个新终端操作键盘或鼠标补充熵池让random继续产生随机数)。