重要的数据结构
设备号注册只是驱动程序代码必须执行的许多任务中的第一个。 我们将很快查看其他重要的驱动程序组件,但首先需要另一个题外话。 大多数基本驱动程序操作涉及三个重要的内核数据结构,称为 file_operations、file 和 inode。 需要对这些结构有基本的熟悉才能做很多有趣的事情,因此我们现在将快速浏览一下它们中的每一个,然后再深入了解如何实现基本驱动程序操作的细节。
文件操作【File Operations】
到目前为止,我们已经保留了一些设备号供我们使用,但我们还没有将我们的驱动程序的任何操作连接【connect】到这些设备号上。 file_operations 结构是字符驱动程序如何设置此连接的。 该结构定义在 <linux/fs.h> 中,它是函数指针的集合。 每个打开的文件(在内部由一个 file
结构表示,我们将很快检查)都与其自己的一组函数相关联(通过包含一个名为 f_op 的字段,该字段指向 file_operations 结构)。 这些操作主要负责实现系统调用,因此被命名为 open, read, 等。 我们可以将文件【file】视为一个“对象”,将对其进行操作的函数视为其“方法”,使用面向对象的编程术语来表示对象声明的作用于其自身的操作。 这是我们在 Linux 内核中看到的面向对象编程的第一个标志,我们将在后面的章节中看到更多。
按照惯例,一个 file_operations 结构或指向一个 file_operations 结构的指针被称为 fops(或其他变体)。 该结构中的每个字段【field】必须指向实现特定操作的驱动程序中的函数,或者为不支持的操作保留 NULL。 当指定NULL指针时,内核的确切行为(其实就是缺省行为)对于每个函数都是不同的,正如本节后面的列表所示。
以下列表介绍了应用程序【application】可以在设备【device】上调用的所有操作。我们尽量保持列表简短,以便它可以用作参考,仅总结每个操作和使用NULL指针时的默认内核行为。
当您通读 file_operations 方法列表时,您会注意到许多参数包括字符串 _ _user。 该注释是一种文档形式,指出指针是不能直接解引用【dereferenced】的用户空间地址【user-space address】。 对于正常的编译,_ _user没有作用,但是可以被外部检查软件用来发现对用户空间地址的滥用。
本章的其余部分,在描述了其他一些重要的数据结构之后,解释了最重要的操作的作用,并提供了提示、注意事项和真实的代码示例。 我们将更复杂的操作的讨论推迟到后面的章节,因为我们还没有准备好深入探讨内存管理、阻塞操作和异步通知等主题。
第一个 file_operations 字段根本不是操作; 它是指向“拥有”该结构的模块的指针。 该字段用于防止模块在其操作正在使用时被卸载。 几乎所有时候,它都被简单地初始化为 THIS_MODULE,一个在 <linux/module.h> 中定义的宏。
loff_t (*llseek) (struct file *, loff_t, int);
llseek 方法用于更改文件中的当前读/写位置,并将新位置作为(正)返回值返回。 loff_t
参数是一个“长偏移量【long offset】”,即使在 32 位平台上也至少有 64 位宽。 错误【Errors】由负返回值表示。 如果此函数指针为 NULL,则 seek 调用将以潜在不可预测的方式修改 file
结构中的位置计数器【position counter】。
ssize_t (*read) (struct file *, char _ _user *, size_t, loff_t *);
用于从设备中检索数据。 此位置的空指针会导致 read 系统调用失败并显示 -EINVAL(“无效参数”)。 非负返回值表示成功读取的字节数。
ssize_t (*aio_read)(struct kiocb *, char _ _user *, size_t, loff_t);
启动异步读取——一种在函数返回之前可能无法完成的读取操作。 如果此方法为 NULL,则所有操作都将由(同步) read 处理。
ssize_t (*write) (struct file *, const char _ _user *, size_t, loff_t *);
向设备发送数据。 如果为 NULL,-EINVAL 返回给调用 write 系统调用的程序。 如果返回值非负,则表示成功写入的字节数。
ssize_t (*aio_write)(struct kiocb *, const char _ _user *, size_t, loff_t *);
在设备上启动异步写入。
int (*readdir) (struct file *, void *, filldir_t);
对于设备文件,该字段应该为 NULL; 它用于读取目录,仅对文件系统有用。
unsigned int (*poll) (struct file *, struct poll_table_struct *);
poll 方法是三个系统调用的后端:poll、epoll 和 select,它们都用于查询对一个或多个文件描述符的读取或写入是否会阻塞。 poll 方法应返回一个位掩码,指示是否可以进行非阻塞读取或写入,并且可能向内核提供可用于使调用进程休眠【sleep】直到 I/O 变为可能的信息。 如果驱动程序将其 poll 方法保留为 NULL,则假定设备可读可写且不会阻塞。
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
ioctl 系统调用提供了一种方法来发出特定于设备的命令(例如格式化软盘的磁道,既不是读也不是写)。 外,内核可以在不引用 fops
表的情况下识别一些 ioctl 命令。 如果设备不提供 ioctl 方法,则系统调用会为任何未预定义的请求返回错误(-ENOTTY
,“该设备没有这样的 ioctl”)。
int (*mmap) (struct file *, struct vm_area_struct *);
mmap 用于请求将设备内存映射到进程的地址空间。 如果此方法为 NULL,则 mmap 系统调用返回 -ENODEV
。
int (*open) (struct inode *, struct file *);
虽然这总是在设备文件上执行的第一个操作,但驱动程序不需要声明相应的方法。如果此条目为NULL,则打开设备总是成功,但不会通知您的驱动程序。
int (*flush) (struct file *);
当进程关闭设备的文件描述符副本时,将调用 flush 操作; 它应该执行(并等待)设备上任何未完成的操作。 这一定不能与用户程序请求的 fsync 操作混淆。 目前,很少有驱动程序使用 flush ; 例如,SCSI 磁带驱动程序使用它来确保所有写入的数据在设备关闭之前写入磁带。 如果 flush 为 NULL,内核将简单地忽略用户应用程序请求。
int (*release) (struct inode *, struct file *);
释放
file
结构时调用此操作。与 open 一样,release 可以为 NULL
int (*fsync) (struct file *, struct dentry *, int);
This method is the back end of the fsync system call, which a user calls to flush any pending data. If this pointer is NULL
, the system call returns -EINVAL
.
此方法是 fsync 系统调用的后端,用户调用它来刷新任何挂起【Pending】的数据。 如果此指针为 NULL,则系统调用返回 -EINVAL
。
int (*aio_fsync)(struct kiocb *, int);
fsync 方法的异步版本。
int (*fasync) (int, struct file *, int);
此操作用于通知设备其 FASYNC
标志发生变化。异步通知是一个高级主题,在第 6 章中进行了描述。如果驱动程序不支持异步通知,该字段可以为NULL。
int (*lock) (struct file *, int, struct file_lock *);
lock 方法用于实现文件锁定; 锁定是常规文件不可或缺的功能,但设备驱动程序几乎从未实现过。
ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t
*);
ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t
*);
这些方法实现分散/聚集【scatter/gather】读写操作。 应用程序偶尔需要执行涉及多个内存区域的单个读取或写入操作; 这些系统调用允许他们这样做而无需对数据进行额外的复制操作。 如果这些函数指针保留为 NULL,则改为调用 read 和 write 方法(可能不止一次)。
ssize_t (*sendfile)(struct file *, loff_t *, size_t, read_actor_t, void *);
此方法实现了 sendfile 系统调用的读取端,它通过最少的复制将数据从一个文件描述符移动到另一个文件描述符。 例如,需要通过网络连接发送文件内容的 Web 服务器使用它。 设备驱动程序通常将 sendfile 保留为 NULL。
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *
,int);
sendpage 是 sendfile 的另一半; 内核调用它来发送数据,一次一页地发送到相应的文件。 设备驱动程序通常不实现 sendpage。
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned
long, unsigned long, unsigned long);
此方法的目的是在进程的地址空间中找到合适的位置,以便映射到底层设备上的内存段中。 此任务通常由内存管理代码执行; 此方法的存在是为了允许驱动程序强制执行特定设备可能具有的任何对齐要求。 大多数驱动程序可以将此方法保留为 NULL。
int (*check_flags)(int)
此方法允许模块检查传递给 fcntl(F_SETFL...) 调用的标志【flag】。
int (*dir_notify)(struct file *, unsigned long);
当应用程序使用 fcntl 请求目录更改通知时调用此方法。 它仅对文件系统有用; 驱动程序不需要实现 dir_notify。
scull 设备驱动程序只实现最重要的设备方法。 它的file_operati file_operations
ons结构初始化如下:
struct file_operations scull_fops = {
.owner = THIS_MODULE,
.llseek = scull_llseek,
.read = scull_read,
.write = scull_write,
.ioctl = scull_ioctl,
.open = scull_open,
.release = scull_release,
};
此声明使用标准的 C 标记结构初始化语法【tagged structure initialization syntax】。 这种语法是首选,因为它使驱动程序在结构定义的变化中更具可移植性,并且可以说,使代码更紧凑和可读。 标记初始化允许重新排序结构成员; 在某些情况下,通过在同一硬件高速缓存行中放置指向经常访问的成员的指针,可以实现实质性的性能改进。
file 结构
在 <linux/fs.h> 中定义的 struct
file
是设备驱动程序中使用的第二重要的数据结构。 请注意,file
,与用户空间程序的 FILE
指针无关。 FILE
在 C 库中定义,从不会出现在内核代码中。 另一方面,struct
file
是一个内核结构,也从不会出现在用户程序中。
file
结构表示一个打开的文件(它不特定于设备驱动程序;系统中每个打开的文件在内核空间中都有一个与之关联的 struct
file
)。它由内核在 open 时创建,并传递给对该文件进行操作的任何函数,直到最后被关闭。 在文件的所有实例关闭后,内核释放数据结构。
在内核源代码中,指向struct
file
的指针通常称为 file
或者 filp
(“文件指针”)。 我们将始终使用 filp
以防止与结构体本身产生歧义。 因此, file
指的是结构体,而 filp
指的是指向该结构体的指针。
struct
file
最重要的字段都列举在这里。 与上一节一样,第一次阅读时可以跳过该列表。 然而,在本章后面,当我们面对一些真正的 C 代码时,我们将更详细地讨论这些字段。
文件模式通过位 FMODE_READ
和 FMODE_WRITE
将文件标识为可读或可写(或两者)。 您可能想在 open 或 ioctl 函数中检查该字段的读/写权限,但您不需要检查读写权限,因为内核会在调用您的方法之前进行检查。 。当文件没有被打开时,试图读或写这种类型的访问将被拒绝,而驱动程序甚至不知道它。
当前的读或写位置。 loff_t
在所有平台上都是 64 位(gcc 术语中的 long long)。 如果驱动程序需要知道文件中的当前位置,则可以读取该值,但通常不应更改它; read 和 write 应该使用他们收到的指针作为最后一个参数来更新该位置,而不是直接作用于 filp->f_pos
。 该规则的一个例外是 llseek 方法,该方法的目的是更改文件位置。
这些是文件标志【file flags】,例如 O_RDONLY、O_NONBLOCK 和 O_SYNC。 驱动程序应检查 O_NONBLOCK 标志以查看是否已请求非阻塞操作(我们在第 6.2.3 节中讨论非阻塞 I/O); 其他标志很少使用。 特别是,应使用 f_mode
而不是 f_flags
检查读/写权限。 所有标志都在 <linux/fcntl.h> 头文件中定义。
与文件关联的操作。 内核将指针赋值作为其 open 实现的一部分,然后在需要分派【dispacher】任何操作时读取它。 filp->f_op
中的值永远不会被内核保存以备后用; 这意味着您可以更改与您的文件相关联的文件操作,并且新绑定的方法将在您调用返回后立即生效。 例如,与主号 1 (例如/dev/null, /dev/zero等等)关联的 open 代码根据打开的次编号替换 filp->f_op
中的操作。 这种做法允许在同一个主号下实现多个行为,而不会在每次系统调用时引入开销。 替换文件操作的能力在内核中相当于面向对象编程中的“方法覆盖【method overriding】”。
void *private_data;
open 系统调用在调用驱动程序的 open 方法之前将此指针设置为 NULL。 您可以自由地使用或忽略该字段; 您可以使用该字段指向分配的数据,但是您必须记住在 file
结构被内核销毁之前在 release 方法中释放该内存。 private_data 是用于跨系统调用保存状态信息的有用资源,我们的大多数示例模块都使用它。
与文件关联的目录条目(dentry)结构。 设备驱动程序编写者通常不需要关心 dentry 结构,只需要像 filp->f_dentry->d_inode 那样
访问 inode 结构即可。
真正的数据结构里有更多的字段,但它们对设备驱动程序没有用。 我们可以安全地忽略这些字段,因为驱动程序从不创建 file
结构; 他们只能访问在别处创建的结构。
inode 结构
内核在内部使用 inode 结构来表示文件。 因此,它不同于表示一个打开的文件描述符的 file 结构。 可以有许多 file 结构代表在单个文件上的多个打开的描述符,但它们都指向单个 inode 结构。
inode 结构包含有关文件的大量信息,但是在驱动开发时只关注其中两个属性即可。
- dev_t i_rdev; 对于表示设备文件【device file】的 inode,该字段包含实际的设备编号【device number】
- struct cdev *i_cdev; struct cdev 是表示字符设备的内核内部结构; 当 inode 引用字符设备文件【char device file】时,该字段包含指向该结构的指针。
内核提供了两个宏方法用户获取某个 inode 上的主次版本号:
unsigned int iminor(struct inode *inode);
unsigned int imajor(struct inode *inode);
字符设备注册
正如我们提到的,内核在内部使用 struct
cdev
类型的结构表示字符设备。 在内核调用您设备的操作【operations】之前,您必须分配并注册一个或多个这样的结构。 为此,您的代码应包含 <linux/cdev.h> 头文件,其中定义了结构及其关联的辅助函数。
有两种方法可以分配和初始化这些结构。 如果您希望在运行时获得一个独立的 cdev 结构,您可以使用如下代码:
struct cdev *my_cdev = cdev_alloc( );
my_cdev->ops = &my_fops;
然而,您可能希望将 cdev 结构嵌入到您自己的特定于设备的结构中; 这就是 scull 所做的。 在这种情况下,您应该初始化已经分配的结构:
void cdev_init(struct cdev *cdev, struct file_operations *fops);
无论哪种方式,您都还有一个 struct cdev 字段需要初始化。 与 file_operations
结构一样,struct
cdev
有一个 owner
字段,应该设置为 THIS_MODULE
。
一旦设置了 cdev
结构,最后一步是通过以下调用将其告知内核:
int cdev_add(struct cdev *dev, dev_t num, unsigned int count);
这里 dev
是 cdev
结构体,num
是这个设备响应的第一个设备号, count
是应该与该设备关联的设备号的数量。 通常 count
都是一个,但在某些情况下,多个设备编号对应一个特定设备是有意义的。 例如,考虑 SCSI 磁带驱动程序,它允许用户空间通过为每个物理设备分配多个次要编号来选择操作模式(例如密度)。
使用 cdev_add 时需要记住几件重要的事情。 首先是这个调用可能会失败。 如果它返回负数错误代码,则您的设备尚未添加到系统中。 然而,它几乎总是成功,这引出了另一点:一旦 cdev_add 返回,您的设备就“激活”了,内核就可以调用它的操作了。 在您的驱动程序完全准备好处理设备上的操作之前,您不应该调用 cdev_add 。
要从系统中删除一个字符设备,调用:
void cdev_del(struct cdev *dev);
显然,在将 cdev
结构传递给 cdev_del 之后,您就不应该再访问它了。
scull中的设备注册
在内部,scull 以结构类型 struct
scull_dev
表示每个设备。 该结构定义为:
struct scull_dev {
struct scull_qset *data; /* Pointer to first quantum set */
int quantum; /* the current quantum size */
int qset; /* the current array size */
unsigned long size; /* amount of data stored here */
unsigned int access_key; /* used by sculluid and scullpriv */
struct semaphore sem; /* mutual exclusion semaphore */
struct cdev cdev; /* Char device structure */
};
我们将讨论这个结构中的各个字段,但是现在,我们要注意 cdev
, struct cdev
是将我们的设备连接到内核的接口。这个结构必须如上所述初始化并添加到系统中; 处理此任务的代码如下:
static void scull_setup_cdev(struct scull_dev *dev, int index)
{
int err, devno = MKDEV(scull_major, scull_minor + index);
cdev_init(&dev->cdev, &scull_fops);
dev->cdev.owner = THIS_MODULE;
dev->cdev.ops = &scull_fops;
err = cdev_add (&dev->cdev, devno, 1);
/* Fail gracefully if need be */
if (err)
printk(KERN_NOTICE "Error %d adding scull%d", err, index);
}
由于 cdev
结构嵌入在 struct
scull_dev
中,因此必须调用 cdev_init 来执行该结构的初始化。
The Older Way
如果你仔细研究 2.6 内核中的驱动程序代码,你可能会注意到相当多的字符驱动程序不使用我们刚才描述的 cdev
接口。 您看到的是尚未升级到 2.6 接口的旧代码。 既然代码是这样工作的,因此这种升级可能不会在很长一段时间内发生。 为了完整起见,我们描述了旧的字符设备注册接口,但新代码应该不会使用它; 这种机制可能会在未来的内核中消失。
注册字符设备驱动程序的经典方法是:
int register_chrdev(unsigned int major, const char *name,
struct file_operations *fops);
在这里,major
是感兴趣的主设备编号,name
是驱动程序的名称(它出现在 /proc/devices 中), fops
是默认的 file_operations 结构。 调用 register_chrdev 为给定的主要设备编号注册 0-255 的次要编号,并为每个设置一个默认的 cdev
结构。 使用此接口的驱动程序必须准备好处理所有 256 个次要号码(无论它们是否对应于真实设备)的公开调用,并且他们不能使用大于 255 的主要或次要号码。
如果您使用 register_chrdev,从系统中删除您的设备的正确函数是:
int unregister_chrdev(unsigned int major, const char *name);
其中,major
和 name
必须与传递给 register_chrdev 的相同,否则调用将失败。
open and release
现在我们已经快速了解了这些字段,我们开始在实际的 scull 函数中使用它们。
open方法
open 方法是为驱动程序提供的,用于为以后的操作做准备的任何初始化。 在大多数驱动程序中, open 应该执行以下任务:
- 检查特定于设备的错误(例如设备未就绪或类似的硬件问题)
- 如果是第一次打开设备,请初始化设备
- 如有需要的话,更新
f_op
指针 - 分配并填充要放入
filp->private_data
的任何数据结构
然而,业务的第一步通常是确定正在打开的是哪个设备。记住, open 方法的原型是:
int (*open)(struct inode *inode, struct file *filp);
inode 参数以其 i_cdev
字段的形式包含我们需要的信息,其中包含我们之前设置的 cdev
结构。 唯一的问题是我们通常不需要 cdev
结构本身,我们需要包含 cdev
结构的 scull_dev
结构。 C 语言让程序员可以玩各种花样来进行这种转换; 然而,编写此类技巧很容易出错,并且会导致其他人难以阅读和理解代码。 幸运的是,在这种情况下,内核黑客以 <linux/kernel.h> 中定义的 container_of 宏的形式为我们完成了棘手的事情:
container_of(pointer, container_type, container_field);
该宏在类型为 container_field
的结构中获取指向类型为 container_type
的字段的指针,并返回一个指向包含该结构体的指针。 在 scull_open 中,这个宏用于寻找合适的设备结构:
struct scull_dev *dev; /* device information */
dev = container_of(inode->i_cdev, struct scull_dev, cdev);
filp->private_data = dev; /* for other methods */
一旦它找到了 scull_dev
结构,scull 就会在 file
结构的 private_data
字段中存储一个指向它的指针,以便将来更容易地访问。
识别正在打开的设备的另一种方法是查看存储在 inode
结构中的次要编号。 如果您使用 register_chrdev 注册您的设备,则必须使用此技术。 请务必使用 iminor 从 inode
结构中获取次编号,并确保它对应于您的驱动程序实际准备处理的设备。
scull_open 的(稍微简化的)代码是:
int scull_open(struct inode *inode, struct file *filp)
{
struct scull_dev *dev; /* device information */
dev = container_of(inode->i_cdev, struct scull_dev, cdev);
filp->private_data = dev; /* for other methods */
/* now trim to 0 the length of the device if open was write-only */
if ( (filp->f_flags & O_ACCMODE) = = O_WRONLY) {
scull_trim(dev); /* ignore errors */
}
return 0; /* success */
}
代码看起来很稀疏,因为它在调用 open 时不执行任何特定的设备处理。 它不需要这样做,因为 scull 设备是全局的并且在设计上是持久的。 具体来说,没有诸如“首次打开时初始化设备”之类的操作,因为我们不保留 scull 的打开计数。
在设备上执行的唯一实际操作是在打开设备进行写入操作时将其截断为 0 的长度。这样做的原因是,按照设计,用较短的文件覆盖 scull 设备会导致较短的设备数据区域。这类似于打开一个常规文件进行写入时将其截断为零长度的方式。如果设备已打开以供读取,则该操作不执行任何操作。
稍后当我们查看其他 scull 个性的代码时,我们将看到真正的初始化是如何工作的。
release方法
release 方法的作用与 open 相反。 有时你会发现方法实现被称为 device
_close
而不是 device
_release
。 无论哪种方式,设备方法都应执行以下任务:
- 释放任何由 open 方法分配在
filp->private_data 中的内容
在最后关闭设备
scull 的基本形式没有硬件可以关闭,因此所需的代码很少:
int scull_release(struct inode *inode, struct file *filp)
{
return 0;
}
您可能想知道当设备文件【device file】的关闭次数多于打开次数时会发生什么。 毕竟, dup 和 fork 系统调用会在不调用 open 的情况下创建打开文件的副本; 然后在程序终止时关闭每个副本。 例如,大多数程序不会打开它们的 stdin 文件(或设备),但最终都会将其关闭。 驱动程序如何知道打开的设备文件何时真正关闭?
答案很简单:并非每次 close 系统调用都会导致调用 release 方法。 只有真正释放设备数据结构的调用才会调用该方法——因此得名。 内核保留一个计数器,记录文件结构被使用的次数。 fork 和 dup 都不会创建新的文件结构(只有 open 会这样做); 他们只是增加现有结构中的计数。 只有当文件结构的计数器降为 0 时,close 系统调用才会执行 release 方法,这种情况发生在结构被销毁时。 release 方法和 close 系统调用之间的这种关系保证了你的驱动程序对每个 open 只看到一个 release 调用。
请注意,每次应用程序调用 close 时都会调用 flush 方法。 然而,很少有驱动程序实现 flush ,因为通常在 close 时没有什么可执行的,除非涉及 release 。
正如您想象的那样,即使应用程序在没有显式关闭其打开的文件的情况下终止,前面的讨论也适用:内核在进程退出时通过内部使用 close 系统调用自动关闭任何文件。
scull的内存使用
在介绍 read 和 write 操作之前,我们最好看看 scull 是如何【how】以及为什么【why】进行内存分配的。 “how”是彻底理解代码所必需的,而“why”则说明了驱动程序编写者需要做出的选择,尽管 scull 绝对不是典型的设备。
本节只涉及 scull 中的内存分配策略,并没有展示编写真正的驱动程序所需的硬件管理技巧。 这些技巧在第 9 章和第 10 章中介绍。因此,如果您对了解面向内存的 scull 驱动程序的内部工作原理不感兴趣,可以跳过本节。
scull 使用的内存区域,也称为设备【 device 】,其长度是可变的。 你写得越多,它就增长的越长; 通过用较短的文件覆盖设备来执行修整【trimming】。
scull 驱动程序引入了两个用于在 Linux 内核中管理内存的核心函数。 这些函数定义在<linux/slab.h>中,它们是:
void *kmalloc(size_t size, int flags);
void kfree(void *ptr);
对 kmalloc 的调用试图分配 size
字节的内存; 返回值是指向该内存的指针,如果分配失败则返回 NULL
。 flags
参数用于描述应该如何分配内存; 我们将在第 8 章详细研究这些标志。现在,我们总是使用 GFP_KERNEL
。 分配的内存应使用 kfree 释放。 你不应该将任何不是从 kmalloc 获得的东西传递给 kfree 。 然而,将 NULL
指针传递给 kfree 是合法的。
kmalloc 不是分配大面积内存的最有效方法(参见第 8 章),因此为 scull 选择的实现并不是一个特别聪明的实现。 智能实现的源代码会更难阅读,本节的目的是展示 read 和 write 写,而不是内存管理。 这就是为什么代码只使用 kmalloc 和 kfree ,而使用整个页面的分配方案,尽管这种方法会更有效。
另一方面,出于哲学和实际原因,我们不想限制“设备”区域的大小。 从哲学上讲,对被管理的数据项施加任意限制总是一个坏主意。 实际上,scull 可用于临时占用系统内存,以便在低内存条件下运行测试。 运行此类测试可能有助于您了解系统的内部结构。 您可以使用命令 cp /dev/zero /dev/scull0 来使用 scull 占用所有实际 RAM,并且您可以使用 dd 程序来选择将多少数据复制到 scull 设备。
在 scull 中,每个设备都是一个指针链表,每个指针都指向一个 scull_dev
结构。 默认情况下,每个这样的结构可以通过一个中间指针数组,引用最多四百万字节。 发布版的源代码中使用一个包含 1000 个指针的数组,指向 4000 字节区域的。 我们将每个内存区域称为一个量子【 quantum 】,将数组(或其长度)称为一个量子集【 quantum set 】。 scull 设备及其内存区域如图 3-1 所示。
Figure 3-1. The layout of a scull device
选择的数字使得在 scull 中写入单个字节会消耗 8000 或 12000 字节的内存:其中 4000 用于量子,4000 或 8000 字节(取决于指针在目标平台上是用 32 位还是 64 位表示 )用于量子集。相反,如果要写入大量数据,那么链表的开销并不算太大。每 4 兆字节的数据只有一个列表元素,设备的最大大小受到计算机内存大小的限制。
为量子和量子集选择合适的值是一个策略问题,而不是机制问题,最佳大小取决于设备的使用方式。 因此,scull 驱动程序不应该强制使用任何特定的量子和量子集大小的值。 在 scull 中,用户可以通过几种方式更改该值:通过在编译时更改 scull.h 中的宏 SCULL_QUANTUM
和 SCULL_QSET
,通过在模块加载时设置整数值 scull_quantum
和 scull_qset
,或者在运行时使用 ioctl 更改当前值和默认值。
使用宏和整数值来允许编译时和加载时配置,这与选择设备主号的方式类似。我们将此技术用于驱动程序中的任意值或与策略相关的任何值。
剩下的唯一问题是如何选择默认数字。在这种特殊情况下,问题是在由半满的量子和量子集造成的内存浪费与量子和量子集较小时发生的分配、释放和指针链接的开销之间找到最佳平衡。 此外,还应考虑 kmalloc 的内部设计(不过,我们现在不讨论这个问题;kmalloc 的内部结构将在第 8 章探讨。)默认值的选择是基于这样的假设,即在测试时可能会向测scull 写入大量数据,尽管设备的正常使用很可能只会传输几 kb 的数据。
我们已经看到了在内部代表我们设备的 scull_dev
结构。 该结构的 quantum
和 qset
字段分别保存设备的量子大小 和量子集大小。 然而,实际数据由一个不同的结构跟踪,我们称之为 struct scull_qset
:
struct scull_qset {
void **data;
struct scull_qset *next;
};
下一个代码片段实际展示了如何使用 struct
scull_dev
和 struct
scull_qset
来保存数据。 函数 scull_trim 负责释放整个数据区域,并在打开文件进行写入时由 scull_open 调用。 它只是遍历列表并释放它找到的任何量子和量子集。
int scull_trim(struct scull_dev *dev)
{
struct scull_qset *next, *dptr;
int qset = dev->qset; /* "dev" is not-null */
int i;
for (dptr = dev->data; dptr; dptr = next) { /* all the list items */
if (dptr->data) {
for (i = 0; i < qset; i++)
kfree(dptr->data[i]);
kfree(dptr->data);
dptr->data = NULL;
}
next = dptr->next;
kfree(dptr);
}
dev->size = 0;
dev->quantum = scull_quantum;
dev->qset = scull_qset;
dev->data = NULL;
return 0;
}
scull_trim 也用在模块清理函数中,用于将 scull 使用的内存返还给系统。
read and write
read 和 write 方法都执行类似的任务,即从应用程序代码中复制数据和向应用程序代码复制数据。 因此,它们的原型非常相似,值得同时介绍它们:
ssize_t read(struct file *filp, char _ _user *buff,
size_t count, loff_t *offp);
ssize_t write(struct file *filp, const char _ _user *buff,
size_t count, loff_t *offp);
对于这两个方法, filp
是文件指针,count
是请求的数据传输的大小。 buff
参数指向保存要写入数据的用户缓冲区或应放置新读取数据的空缓冲区。 最后, offp
是指向“长偏移类型【long offset type】”对象的指针,该对象指示用户正在访问的文件位置。 返回值是一个“有符号整型【signed size typ】”; 它的使用将在后面讨论。
让我们重复一遍,读写方法的 buff
参数是一个用户空间指针。 因此,它不能被内核代码直接解引用。 这种限制基于下面几个原因:
-
根据您的驱动程序运行的体系结构以及内核的配置方式,用户空间指针在内核模式下运行时可能根本无效。 该地址可能没有映射,或者它可能指向其他一些随机数据。
-
即使指针在内核空间中确实意味着相同的东西,但是用户空间内存是分页【paged】的,并且在进行系统调用时,请求的内存可能并不驻留在 RAM 中。 尝试直接引用用户空间内存可能会产生页面错误【Page Fault】,这是内核代码不允许做的事情。 结果将是一个“oops”,这将导致发出系统调用的进程终止。
-
请求中的指针是由用户程序提供的,该程序可能存在错误或恶意。 如果您的驱动程序曾经盲目地解引用用户提供的指针,它提供了一个开放的入口,允许用户空间程序访问或覆盖系统中任何位置的内存。 如果您不想为危害用户系统的安全负责,您永远不能直接对用户空间指针执行直接解引用。