Linux Device Driver Part III. Block & Character Device
文中代码基于Linux 5.1 rc6版本
Major and Minor Number
我们知道,在类Unix操作系统中都有Major和Minor Number的概念,一对(Major, Minor)对应于一个设备。对于同一个设备,实际上可以通过mknod
命令创建任意多个特殊的设备文件(Device File),对这些设备文件的读写操作都会由同一个驱动处理。另外,设备被分为块设备(Block Device)和字符设备(Character Device),区分标准是块设备只能以固定大小的块(例如512B或4K)为单位读写数据,块设备和字符设备各自拥有一个命名空间(Namespace)。也就是说,对于同一对Major、Minor号,可以存在两个不同的设备文件,一个对应于块设备,另一个对应于字符设备。
在早期,Major号通常对应于一个驱动,而Minor号用于区分该驱动支持的不同型号的设备,但现在不同驱动也可以共用一个Major号,一个驱动也可以使用多个Major号,故此说法已不具参考意义。在Linux 2.6以前,不提供动态分配Major、Minor号的功能,当时所有设备的Major、Minor号由专门的机构LANANA(Linux Assigned Names and Numbers Authority)管理,LANANA在 www.lanana.org/docs/device-list/ 维护了一份列表。在Linux 2.6以后,内核提供了动态分配Major、Minor号的功能,不再需要专门维护一份列表,继承自LANANA的列表现在位于Linux内核中的Documentation/admin-guide/devices.rst
和Documentation/admin-guide/devices.txt
,而LANANA网站则不再维护。
在早期,Major和Minor Number各占8位,合起来可以用一个16位整数表示。随着设备的种类和数量越来越多,人们发现编号开始不够用了,于是自Linux 2.6起改为了Major为12位,Minor为20位,合起来用一个32位整数(dev_t
类型)表示。并且,为了将来的兼容性,禁止对dev_t
类型变量的直接操作,所有操作都要通过helper函数或宏进行。
Character Device
Overview
在Linux中,字符设备(Character Device)由struct cdev
表示:
1 | struct cdev { |
我们可以发现它并没有包含struct device
作为其成员,也就是说cdev
和Device Model实际上没有关系,或者说是通过dev_t
(Major、Minor号)间接建立的关系。实际上,传统的通过设备文件向用户态提供接口的方式,和Device Model提出后通过sysfs向用户态提供接口的方式,基本上是正交的两种实现驱动的方法。cdev
提供的仅是注册设备文件的功能,通过其ops
成员提供系统调用的回调,不包括注册Device Model中的Device的功能。
从历史的角度看,字符设备文件出现得更早,因此对于符合传统意义的「字符设备」的设备来说,其驱动仍有必要以字符设备文件的方式提供操作接口,但同时也应该在Device Model中注册一个Device,以确保系统中每一个设备都注册在了Device Model中。
Major & Minor Allocation
使用cdev的第一步,是申请设备号(即Major、Minor号),有静态分配和动态分配两种方式:
register_chrdev_region(dev_t from, unsigned count, const char *name)
静态分配从from
开始的count
个连续设备号alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)
:动态地分配一个Major号,Minor号从baseminor
到baseminor + count - 1
注销则统一用unregister_chrdev_region
函数完成。
申请设备号的过程最终由__register_chrdev_region
函数实现,它实际上是在全局的哈希表chrdevs
中添加了一个struct char_device_struct
对象,具体的代码很直观这里不作展示:
1 | static struct char_device_struct { |
这个简单的哈希表chrdevs
以major % CHRDEV_MAJOR_HASH_SIZE
为哈希函数,冲突以链表方式解决。我们可以通过/proc/devices
输出该哈希表中的所有对象。
cdev Registration
申请完设备号后,下一步就是创建并注册cdev
对象:
- 首先,通过
cdev_alloc
创建一个新的cdev
对象,或通过cdev_init
初始化一个静态分配的cdev
对象 - 然后,通过
cdev_add(cdev, dev, count)
将该cdev
对象绑定到[dev, dev + count - 1]
范围内的设备号
或者,也可以使用register_chrdev(major, name, fops)
一次性完成分配设备号(Major取传入的major
,Minor取0-255)、创建和注册cdev
对象几个步骤,其对应的注销函数为unregister_chrdev
。这种方式是Linux 2.6以前的旧式API,当时甚至还未发明cdev
数据结构,目前保留该函数仅是出于兼容目的。
有趣的是,cdev_add
实际上仅仅是调用了kobj_map
将cdev
对象注册到了struct kobj_map
类型的全局对象cdev_map
中:
1 | int cdev_add(struct cdev *p, dev_t dev, unsigned count) |
也就是说,它并没有检查传入的设备号范围是否和已在chrdevs
哈希表中注册的设备号范围冲突,这意味着程序的正确性依赖于程序员在cdev_add
前对欲注册的设备号范围调用register_chrdev_region/alloc_chrdev_region
。如果程序员没有按照Linux社区的约定,直接调用cdev_add
,则可能发生意想不到的bug。
struct kobj_map
又是一个哈希表,不过看起来更复杂一些:
1 | typedef struct kobject *kobj_probe_t(dev_t, int *, void *); |
我们可以通过kobj_map
函数构造一个probe
对象加入哈希表,通过kobj_unmap(map, dev, range)
删除一个probe
对象。每对dev, range
对应于一个probe
对象,通过kobj_lookup(map, dev, &index)
可以找到dev
所在的probe
对象,然后再利用probe->get(dev, &index, probe->data)
获得对应的kobject,最后该函数返回的是得到的kobject对象,另外index
会被设置为dev
在probe
对象中的index。
kobj_map/kobj_unmap/kobj_lookup
的实现其实很粗糙(我甚至认为它有goto标签写串行的问题,这么多年竟然没人发现),它不会检查不同probe
对象间设备号的重叠,从而在kobj_lookup
时一个dev
可能对应多个probe
对象,最后只是取其中之一返回。因此,它能正常运作严重依赖于程序员注册的设备号范围互不重叠,即要求先申请分配设备号,再注册到kobj_map
中。
cdev & inode
上面说到cdev
和文件系统有关,那么它是如何和文件系统建立关系的呢,下面这个init_special_inode
函数可以给我们一些提示:
1 | void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev) |
这实际上是为字符设备文件注册了默认的open回调函数,并设置了inode->i_rdev
为字符设备的设备号:
1 | /* |
我们可以看到,在默认的open函数chrdev_open
中,我们借助inode的i_rdev
在cdev_map
中找到了之前注册的cdev
对象。利用replace_fops
,我们将该字符设备文件的回调函数替换为了cdev->ops
,然后调用了cdev中注册的open函数。此外,我们还建立了cdev和inode之间的映射关系,inode->i_cdev
会指向其cdev
对象,而cdev->list
则是一个inode链表,通过inode->i_devices
将cdev下的inode串在了一起。
cdev & device model
现在再来看cdev如何与Device Model结合起来,考察device_add
函数中如下片段:
1 | if (MAJOR(dev->devt)) { |
只要注册Device时,设置了dev->devt
,就会执行如下操作:
- 创建
/sys/<device path>/dev
属性文件,其值为$major:$minor
- 创建link文件
/sys/<dev path>/$major:$minor
,指向/sys/<device path>
- 这里的
/sys/<dev path>
由device_to_dev_kobj
函数获得,优先选取dev->class->dev_kobj
目录,若dev
无class,则取sysfs_dev_char_kobj
即/sys/dev/char
- 这里的
- 调用
devtmpfs_create_node
在devtmpfs,即/dev/<path>
中创建一个设备文件- 若
dev->class == &block_class
,则创建块设备文件,否则创建字符设备文件 - 文件路径
<path>
的选取如下:- 优先使用
dev->type->devnode()
获取路径 - 若无此函数,则使用
dev->class->devnode()
获取路径 - 若两者都无,则使用device的名称作为路径,名称中的
!
视为/
- 优先使用
- 若
因此,我们只需要创建一个Device,为其设置devt
成员,然后注册该Device,就能自动在/dev/
目录下创建字符设备文件。理论上,不使用Device Model,只使用cdev并手动通过mknod
创建字符设备文件,也可以实现驱动的功能(如果没有暴露sysfs接口的需求),但现在通行的做法还是为每个字符设备注册一个Device。
内核提供了cdev_device_add(cdev, dev)
和cdev_device_del(cdev, dev)
来帮助我们一步实现注册/注销cdev和device,进一步简化了上述流程。
Misc Device
Misc Device是一种比直接使用cdev更简单的接口,其定义如下:
1 | struct miscdevice { |
我们只需提供minor
,然后调用misc_register(misc)
注册该Misc Device即可,最终会创建一个Major Number为MISC_MAJOR
(即10)、Minor Number为minor
的字符设备,其文件系统调用由Misc Device的fops
提供。下面分析这是如何实现的。
首先,在misc_init
这个subsys_initcall
中,我们为MISC_MAJOR
注册了256个cdev:
1 | static const struct file_operations misc_fops = { |
在misc_register
中,创建了一个Device,其devt
为(MISC_MAJOR
, minor
),由Misc Device的groups
提供Device的属性:
1 | dev = MKDEV(MISC_MAJOR, misc->minor); |
这里misc_class
对应目录为/sys/class/misc
,它负责提供this_device->class->devnode
,从而使devtmpfs中自动创建的字符设备文件的路径为/dev/<nodename>
,其中<nodename>
即Misc Device的nodename
成员。
现在再来考察misc_open
,它和chrdev_open
基本类似,也是将字符设备文件的回调函数替换成Misc Device的fops
,不过还额外将Misc Device设置为了file->private_data
,这样我们可以方便地从回调函数中直接获取到Misc Device对象。
1 | /* |
Block Device
TODO
不出意外估计是填不上这个坑了