LDD3 字符驱动设备
1. 主次设备号
在2.6中,对字符设备的访问使用过文件喜用内的设备名称进行的。 这些名称被称为特殊文件,设备文件,或者简单的称之为文件系统树的结点,它们通常位于/dev目录。
通常,主设备号用来标识对应的驱动程序。次设备号用于正确确定设备文件所指的设备。看着可能有些难以理解,主设备号对应的就是驱动程序的本身,或者说是“设备类型”。而次设备号对应的就是驱动程序管理的具体设备实例。
只要这些设备都由同一个驱动程序控制,它们就可以共享同一个主设备号,但每个设备会有不同的次设备号。一个驱动程序可以通过不同的次设备号,管理多个同类设备。
在内核中,dev_t
只是一个 typedef 出来的 无符号 32 位整数,用来存放“主号 + 次号”的位段组合。高 12 位 存放主设备号(Major)。低 20 位 存放次设备号(Minor)。include/linux/types.h
中定义如下:
1 | typedef u32 __kernel_dev_t; |
有两个专用的宏定义来从dev_t
获得设备号和将其转换为dev_t
:
1 |
- 获得和释放设备号
在建立一个字符设备之前,驱动程序首先要做的就是获得一个或者多个设备号。申请设备号分为静态和动态。
静态申请在驱动开发者提前知道(或自己指定)一个设备号,并告诉内核:“我要从 major=X、minor=Y 开始,连续拿 N 个号,请留给我。”内核只做冲突检查;如果该区间已被占用直接返回 -EBUSY
。接口如下:
int register_chrdev_region(dev_t first, unsigned count, const char *name);
first
由 MKDEV(major, first_minor)
拼好,是要分配设备编号范围的起始值。count
顾名思义是所请求的连续设备编号的个数(就是次设备号的个数)。name
是和编号范围关联的设备名称,会出现在虚拟文件系统/proc/devices和sysfs中。
而在我们不提前明确主设备号的情况下(这是大多数),那我们应该使用:
int alloc_chrdev_region(dev_t *dev, unsigned baseminor,unsigned count, const char *name);
成功时内核把选到的 dev_t
写回 *dev
,baseminor
只是“起始次设备号”,一般填 0。其余语义同上。
在动态分配设备号时,我们自然是 不能保证我们分配的主设备号始终一致,所以无法预先的创建设备节点。创建设备节点这一件事在新版内核中交给udev和设备树。
无论静态还是动态,卸载模块时都要对称调用:
void unregister_chrdev_region(dev_t from, unsigned count);
在LDD3中作者说:“分配主设备号的最佳方式是:默认采用动态分配,同时保留在加载甚至是编译时指定主设备号的余地,用一个全局变量保存所选择的设备号,可以在头文件来设置这个设备号,如设0既是“选择动态分配”,这样就可以在编译前修改宏定义来选择什么方法获得设备号。“形如如下代码:
1 | if (scull_major) { |
2.文件操作
注册了设备号,我么完成了驱动程序代码必须完成的工作中的第一步。
这些函数值是为驱动程序分配了设备编号,但是没告诉内核关于拿来这些编号要做什么。现在我们空有设备编号,但是尚未将任何驱动程序连接到这些编号。
- file_operations
file_operations
结构用来建立这个连接。在万物皆文件的linux中,file_operations
是 Linux 字符/块设备驱动、文件系统、内核子系统都要打交道的一张“回调函数表”。每个打开的文件,都要和这么一组函数关联。驱动程序把“用户对文件能做的所有动作”映射成“内核里对应的函数指针”。
用户空间 open/read/write/ioctl/close/mmap/poll/...
最终都会调到这张表里你实现的钩子。
这个结构中的每个字段,都指向驱动程序中实现特定操作的函数,对于不支持的操作,可以置为NULL,但是内核对这些null的处理不尽相同。p54-56总结了这些方法,这里不多赘述,列出一些常用的,建议配合unix环境高级编程食用。
1 | static const struct file_operations demo_fops = { |
file_operations
就是内核给你的一张 “文件操作清单”,把它填好、挂到 cdev
,用户空间的所有文件 API 便自动落到你写的钩子函数上。
当结构挂到设备上后,用户 open("/dev/mydev", ...)
就会调到 demo_open()
。
- file结构
这里的file和用户空间程序中的FILE没有任何关联,这里的struct file
是一个内核结构,代表一个打开的文件(不限于驱动程序,系统中每个打开的文件都在内核空间中有一个对应的file结构)。它由内核在open时创建,并传递在该文件上进行的所有函数,直到最后的close
函数。在文件的所有实例都被关闭之后,内核才会释放这个数据结构。filp
是指向file
结构的指针。
这里展示一部分的file结构:
1 |
|
驱动程序从自己填写file结构,而只是对别处创建的file结构进行访问。struct file
就是“一个打开的文件描述符在内核里的化身”;驱动通过它记录/传递“这一次会话”的所有现场信息,并确保多进程、多次打开互不干扰。
- inode结构
内核用inode
结构在内部表示文件,因此它和file
不同,file
是表示打开的文件描述符。对于单个文件,可能会有多个表示打开的文件描述符的file
结构,但他们都指向单个inode结构。
struct inode
是 Linux 内核中“文件本体”(而非一次打开会话)的核心元数据结构。一句话:只要文件存在,就有且仅有一个 inode;它被所有 struct file
共享,保存“文件是谁、什么样、存在哪”。忘了哪里有个图描述的很清楚,关于文件管理的,好像是unix环境高级编程,回头找出来插上。
虽然inode
包含了大量信息,但是在对驱动程序只关心下面几个:
字段 | 用途示例 |
---|---|
i_rdev |
字符/块设备节点:表示设备文件的inode结构 ,保存主/次设备号;驱动通过 iminor(inode) /imajor(inode) 宏提取。 |
i_cdev |
字符设备:注册 cdev_add() 后,内核把 struct cdev * 挂到 inode->i_cdev ,open() 时就能取回自己的设备结构体。 |
i_size |
普通文件:驱动若实现内存文件可随意修改;块设备文件通常由文件系统维护。 |
i_private |
文件系统私有数据;debugfs、procfs 常用。 |
struct inode
= 文件系统层面的“身份证”;
它记录了“文件是谁、有多长、权限、时间戳、设备号”等静态属性,供所有进程的所有打开会话共享,而真正的“打开会话状态”则放在 struct file
里。
3.开始写字符驱动
了解上面那么多了,我们也该开始我们的字符驱动了。
- cdev结构体
cdev(character device 的缩写)是 Linux 内核里“字符设备对象”的最小、最核心数据结构。他的作用就是将设备号联系到驱动实现的file_operations
,让内核在open/write/read的时候可以立即找到对应的函数回调。结构体如下:
1 |
|
关于注册字符设备的api:
阶段 | API | 作用 |
---|---|---|
初始化 | cdev_init(cdev, fops) |
把 file_operations 绑到 cdev 上 |
加入内核 | cdev_add(cdev, dev, count) |
告诉内核:设备号 dev 起 count 个次号,由 cdev->ops 服务 |
移除 | cdev_del(cdev) |
从内核字符设备表中摘除,防止再被 open |
分配/释放(可选) | cdev_alloc() / cdev_put() |
动态申请/释放 struct cdev (很少直接用,多数静态或嵌入自定义结构体) |
使用,将设备号和fops关联起来,并且通知内核。
1 | /* |
- 经典方法
- open方法
open ,顾名思义,是打开什么。open之前,内核完成了完成了“文件描述符 + inode + file 结构体”的准备工作。注意这个file是设备文件,当file->f_op->open(inode, file)时,第一次进入了驱动中,相当于对驱动做一个“打开操作”,实际是在打开前做检查,并将设备结构挂在到file->private_data中,open() 的作用不是“再打开一次文件”,而是“完成设备初始化并让文件与设备实例真正关联”,成功后返回0,表示设备真正可用。
LDD3中的说法:
- 检查设备特定的错误
- 如果设备时首次打开,则对其进行初始化
- 如有必要,更新f_op指针
- 分配并填写置于filp->private_data里的数据
方法原型:int (*open)(struct inode *inode, struct file *file);
在inode参数中的i_cdev
字段中包含着我们需要的信息,即字符设备在注册后,内核把 struct cdev *
挂到 inode->i_cdev
。但是我们通常并不需要cdev结构的本身,希望的得到包含cdev的scull_dev结构。在<linux/kernel.h>中有个宏:
1 |
|
这个宏的作用就是从结构体的 成员指针倒退结构体的首地址。工作原理(分两步):
offsetof(type, member)
求出成员member
在结构体中的字节偏移量。
内核定义:#define offsetof(TYPE, MEMBER) ((size_t)&((TYPE *)0)->MEMBER)
- 用成员地址 减去 该偏移量 → 得到结构体起始地址。
container_filed
就是我们需要的成员指针的字段类型,container_type
就是结构体的类型,而pointer就是那个成员指针。我们从pointer
中到推出结构体的首地址返回一个指针。典型场景就是file->private_data
指向 cdev
,反推出 my_device
。形象的说就是由子及父。
cdev 本身就是设备结构体里的一个成员,而 container_of
宏做的就是“从成员地址倒推整个结构体首地址”。
container_of(ptr, type, member)
的内部实现:
1 | (type *)((char *)(ptr) - offsetof(type, member)) |
ptr
是inode->i_cdev
——也就是&my_dev->cdev
的地址。offsetof(struct my_device, cdev)
给出cdev
在struct my_device
里的字节偏移。- 二者相减就得到
struct my_device
的首地址。
在找到这个结构后,我们就可以将指针保存到file结构的private_data结构中,方便以后丢该指针的访问。
LDD3中给出了scull_open的示例代码。
1 | 238int scull_open(struct inode *inode, struct file *filp) |
- release方法
1 | int (*release)(struct inode *inode, struct file *file); |
它在内核中的调用时机是 “最后一次 close() 把引用计数降到 0” 时触发,与驱动 open()
成对出现。
核心职责:把 open() 里申请/占用的资源全部归还,让设备回到“可被再次打开”的状态。
1 | 用户 close(fd) /* 可能有 dup、fork 导致多个 fd 指向同一 file */ |
与close的区别
项目 | 用户 close() | 驱动 release() |
---|---|---|
何时执行 | 每个 fd 关闭都执行 | 仅当 file 结构最后一次被释放 |
所在空间 | 系统调用 | 驱动回调 |
作用 | 释放 fd 表项 | 释放设备资源 |
驱动 release()
= “open() 的逆过程”
把申请的中断、DMA、GPIO、时钟、锁、内存全部归还,让设备回到“干净、可再次打开”的状态。