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.rstDocumentation/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
2
3
4
5
6
7
8
struct cdev {
struct kobject kobj; /* 仅用于引用计数,不会注册到sysfs中 */
struct module *owner;
const struct file_operations *ops; /* 字符设备文件的回调函数 */
struct list_head list; /* 指向该cdev的inode链表 */
dev_t dev; /* (Major, Minor) */
unsigned int count;
} __randomize_layout;

我们可以发现它并没有包含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号从baseminorbaseminor + count - 1

注销则统一用unregister_chrdev_region函数完成。

申请设备号的过程最终由__register_chrdev_region函数实现,它实际上是在全局的哈希表chrdevs中添加了一个struct char_device_struct对象,具体的代码很直观这里不作展示:

1
2
3
4
5
6
7
8
static struct char_device_struct {
struct char_device_struct *next;
unsigned int major;
unsigned int baseminor;
int minorct;
char name[64]; /* register_chrdev_region/alloc_chrdev_region传入的name参数,用于/proc/devices输出信息 */
struct cdev *cdev; /* 只有旧式的register_chrdev/unregister_chrdev会用到 */
} *chrdevs[CHRDEV_MAJOR_HASH_SIZE];

这个简单的哈希表chrdevsmajor % 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_mapcdev对象注册到了struct kobj_map类型的全局对象cdev_map中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
{
int error;

p->dev = dev;
p->count = count;

error = kobj_map(cdev_map, dev, count, NULL,
exact_match, exact_lock, p);
if (error)
return error;

kobject_get(p->kobj.parent);

return 0;
}

也就是说,它并没有检查传入的设备号范围是否和已在chrdevs哈希表中注册的设备号范围冲突,这意味着程序的正确性依赖于程序员在cdev_add前对欲注册的设备号范围调用register_chrdev_region/alloc_chrdev_region。如果程序员没有按照Linux社区的约定,直接调用cdev_add,则可能发生意想不到的bug。

struct kobj_map又是一个哈希表,不过看起来更复杂一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct kobject *kobj_probe_t(dev_t, int *, void *);

struct kobj_map {
struct probe {
struct probe *next;
dev_t dev;
unsigned long range;
struct module *owner;
kobj_probe_t *get;
int (*lock)(dev_t, void *);
void *data;
} *probes[255];
struct mutex *lock;
};

我们可以通过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会被设置为devprobe对象中的index。

kobj_map/kobj_unmap/kobj_lookup的实现其实很粗糙(我甚至认为它有goto标签写串行的问题,这么多年竟然没人发现),它不会检查不同probe对象间设备号的重叠,从而在kobj_lookup时一个dev可能对应多个probe对象,最后只是取其中之一返回。因此,它能正常运作严重依赖于程序员注册的设备号范围互不重叠,即要求先申请分配设备号,再注册到kobj_map中。

cdev & inode

上面说到cdev和文件系统有关,那么它是如何和文件系统建立关系的呢,下面这个init_special_inode函数可以给我们一些提示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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))
; /* leave it no_open_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);
}

这实际上是为字符设备文件注册了默认的open回调函数,并设置了inode->i_rdev为字符设备的设备号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/*
* Dummy default file-operations: the only thing this does
* is contain the open that then fills in the correct operations
* depending on the special file...
*/
const struct file_operations def_chr_fops = {
.open = chrdev_open,
.llseek = noop_llseek,
};

/*
* Called every time a character special file is opened
*/
static int chrdev_open(struct inode *inode, struct file *filp)
{
const struct file_operations *fops;
struct cdev *p;
struct cdev *new = NULL;
int ret = 0;

spin_lock(&cdev_lock);
p = inode->i_cdev;
if (!p) {
struct kobject *kobj;
int idx;
spin_unlock(&cdev_lock);
kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);
if (!kobj)
return -ENXIO;
new = container_of(kobj, struct cdev, kobj);
spin_lock(&cdev_lock);
/* Check i_cdev again in case somebody beat us to it while
we dropped the lock. */
p = inode->i_cdev;
if (!p) {
inode->i_cdev = p = new;
list_add(&inode->i_devices, &p->list);
new = NULL;
} else if (!cdev_get(p))
ret = -ENXIO;
} else if (!cdev_get(p))
ret = -ENXIO;
spin_unlock(&cdev_lock);
cdev_put(new);
if (ret)
return ret;

ret = -ENXIO;
fops = fops_get(p->ops);
if (!fops)
goto out_cdev_put;

replace_fops(filp, fops);
if (filp->f_op->open) {
ret = filp->f_op->open(inode, filp);
if (ret)
goto out_cdev_put;
}

return 0;

out_cdev_put:
cdev_put(p);
return ret;
}

我们可以看到,在默认的open函数chrdev_open中,我们借助inode的i_rdevcdev_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
2
3
4
5
6
7
8
9
10
11
if (MAJOR(dev->devt)) {
error = device_create_file(dev, &dev_attr_dev);
if (error)
goto DevAttrError;

error = device_create_sys_dev_entry(dev);
if (error)
goto SysEntryError;

devtmpfs_create_node(dev);
}

只要注册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
2
3
4
5
6
7
8
9
10
11
struct miscdevice  {
int minor;
const char *name;
const struct file_operations *fops;
struct list_head list;
struct device *parent;
struct device *this_device;
const struct attribute_group **groups;
const char *nodename;
umode_t mode;
};

我们只需提供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
2
3
4
5
6
7
8
static const struct file_operations misc_fops = {
.owner = THIS_MODULE,
.open = misc_open,
.llseek = noop_llseek,
};

if (register_chrdev(MISC_MAJOR, "misc", &misc_fops))
goto fail_printk;

misc_register中,创建了一个Device,其devt为(MISC_MAJOR, minor),由Misc Device的groups提供Device的属性:

1
2
3
4
5
dev = MKDEV(MISC_MAJOR, misc->minor);

misc->this_device =
device_create_with_groups(misc_class, misc->parent, dev,
misc, misc->groups, "%s", misc->name);

这里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
2
3
4
5
6
7
8
9
10
11
/*
* Place the miscdevice in the file's
* private_data so it can be used by the
* file operations, including f_op->open below
*/
file->private_data = c;

err = 0;
replace_fops(file, new_fops);
if (file->f_op->open)
err = file->f_op->open(inode, file);

Block Device

TODO

不出意外估计是填不上这个坑了