HyperNews Linux KHG 讨论页面

设备驱动程序基础

我们将假设您决定不编写用户空间设备驱动程序,而是希望在内核中实现您的设备驱动程序。您可能需要编写两个文件,一个是.c文件,另一个是.h文件,并且可能还需要修改其他文件,如下所述。我们将把您的文件称为 foo.c 和 foo.h,您的驱动程序将是foo驱动程序。

命名空间

在编写任何代码之前,您需要做的第一件事是命名您的设备。此名称应为短字符串(可能为两个或三个字符)。例如,并行设备是 ``lp'' 设备,软盘是 ``fd'' 设备,而 SCSI 磁盘是 ``sd'' 设备。在编写驱动程序时,您将为函数命名,并在名称前加上您选择的字符串,以避免任何命名空间混淆。我们将您的前缀称为foo,并为您的函数命名,例如foo_read(), foo_write(),等等。

分配内存

内核中的内存分配与普通用户级程序中的内存分配略有不同。内核中没有像malloc()那样能够提供几乎无限量内存的函数,而是有一个kmalloc()函数,它有点不同

要释放使用kmalloc()分配的内存,请使用以下两个函数之一kfree()kfree_s()。这些函数也与free()在一些方面有所不同

有关kmalloc(), kfree()以及其他有用函数的更多信息,请参阅 支持函数

使用 kmalloc 时要谨慎。只使用你必须使用的。请记住,内核内存是不可交换的,因此在内核中分配额外的内存比在用户级程序中做更糟糕。只获取您需要的,并在完成后释放它,除非您要立即再次使用它。

字符设备与块设备

在所有 Unix 系统下,设备主要有两种类型:字符设备和块设备。字符设备是不执行缓冲的设备,块设备是通过缓存访问的设备。块设备必须是随机访问的,但字符设备不一定需要是,尽管有些是。文件系统只能挂载在块设备上。

字符设备的读取和写入使用两个函数foo_read()foo_write()read()write()调用在操作完成之前不会返回。相比之下,块设备甚至不实现read()write()函数,而是有一个历史上被称为“策略例程”的函数。读取和写入通过缓冲区缓存机制以及通用函数bread(), breada(),bwrite()完成。这些函数通过缓冲区缓存,因此可能会或可能不会实际调用策略例程,具体取决于请求的块是否在缓冲区缓存中(对于读取)或缓冲区缓存是否已满(对于写入)。请求可能是异步的breada()可以请求策略例程安排尚未请求的读取,并异步地在后台执行,希望以后会需要它们。

字符设备的源代码保存在 drivers/char/ 中,块设备的源代码保存在 drivers/block/ 中。它们具有相似的接口,并且非常相似,除了读取和写入。由于读取和写入的差异,初始化也不同,因为块设备必须注册策略例程,该例程的注册方式与字符设备驱动程序的foo_read()foo_write()例程不同。具体细节在 字符设备初始化块设备初始化 中讨论。

中断 vs. 轮询

硬件很慢。也就是说,在从您的普通设备获取信息所需的时间内,CPU 可以去做比等待繁忙但缓慢的设备更有用的事情。因此,为了避免一直忙等待,提供了中断,它可以中断正在发生的任何事情,以便操作系统可以执行某些任务并在不丢失信息的情况下返回到它正在做的事情。在理想的世界中,所有设备可能都通过使用中断来工作。但是,在 PC 或克隆机上,只有少数中断可供您的外围设备使用,因此某些驱动程序必须轮询硬件:询问硬件是否已准备好传输数据。不幸的是,这会浪费时间,但有时需要这样做。

某些硬件(如内存映射显示器)与机器的其余部分一样快,并且不会异步生成输出,因此即使提供了中断,中断驱动的驱动程序也会相当愚蠢。

在 Linux 中,许多驱动程序是中断驱动的,但有些不是,并且至少有一个可以是,并且可以在运行时来回切换。例如,lp设备(并行端口驱动程序)通常轮询打印机以查看打印机是否已准备好接受输出,如果打印机在未就绪阶段停留太久,驱动程序将休眠一段时间,然后稍后重试。这提高了系统性能。但是,如果您有提供中断的并行卡,则驱动程序将利用该中断,这通常会使性能更好。

中断驱动驱动程序和轮询驱动程序之间存在一些重要的编程差异。要理解这种差异,您必须稍微了解系统调用在 Unix 下是如何工作的。内核在 Unix 下不是一个单独的任务。相反,就好像每个进程都有内核的副本。当进程执行系统调用时,它不会将控制权转移到另一个进程,而是进程更改执行模式,并且被称为“处于内核模式”。在这种模式下,它执行被信任是安全的内核代码。

在内核模式下,进程仍然可以访问它之前在用户空间中执行的内存,这是通过一组宏get_fs_*()memcpy_fromfs()读取用户空间内存,以及put_fs_*()memcpy_tofs()写入用户空间内存来完成的。因为进程仍在运行,但处于不同的模式,所以数据放在内存中的什么位置或从哪里获取数据都没有问题。但是,当发生中断时,任何进程都可能正在运行,因此不能使用这些宏——如果使用它们,它们要么会覆盖正在运行进程的随机内存空间,要么会导致内核崩溃。

相反,在调度中断时,驱动程序还必须提供临时空间来放置信息,然后休眠。当驱动程序的中断驱动部分填满了临时空间时,它会唤醒进程,进程将信息从临时空间复制到进程的用户空间并返回。在块设备驱动程序中,此临时空间由缓冲区缓存机制自动提供,但在字符设备驱动程序中,驱动程序负责自行分配它。

休眠-唤醒机制

[首先概述休眠是如何使用的以及它的作用。这应该提及诸如所有在事件上休眠的进程一次都被唤醒,然后它们再次争夺事件等等...]

理解 Linux 休眠-唤醒机制的最好方法可能是阅读__sleep_on()函数的源代码,该函数用于实现sleep_on()interruptible_sleep_on()调用。

static inline void __sleep_on(struct wait_queue **p, int state)
{
    unsigned long flags;
    struct wait_queue wait = { current, NULL };

    if (!p)
        return;
    if (current == task[0])
        panic("task[0] trying to sleep");
    current->state = state;
    add_wait_queue(p, &wait);
    save_flags(flags);
    sti();
    schedule();
    remove_wait_queue(p, &wait);
    restore_flags(flags);
}

一个wait_queue是指向任务结构的循环链表,在<linux/wait.h>中定义为

struct wait_queue {
    struct task_struct * task;
    struct wait_queue * next;
};
stateTASK_INTERRUPTIBLETASK_UNINTERUPTIBLE之一,具体取决于休眠是否应可被诸如系统调用之类的东西中断。一般来说,如果设备是慢速设备,则休眠应该是可中断的;慢速设备包括可能无限期阻塞的设备,包括终端和网络设备或伪设备。

add_wait_queue()关闭中断(如果已启用),并将函数开头声明的新struct wait_queue添加到列表p。然后,它恢复原始中断状态(已启用或禁用),并返回。

save_flags()是一个宏,它将其参数中的进程标志保存起来。这样做是为了保留中断启用标志的先前状态。这样,稍后的restore_flags()可以恢复中断状态,无论是启用还是禁用。sti()然后允许中断发生,schedule()找到一个新的进程来运行,并切换到它。在状态更改为TASK_RUNNING之前,Schedule 不会选择此进程再次运行,状态更改通过在同一等待队列上调用的wake_up()或可能通过其他方式实现。p,或可能通过其他方式实现。

然后,进程从wait_queue中删除自身,使用restore_flags()恢复原始中断条件,并返回。

每当资源可能发生争用时,都需要有一个指向与该资源关联的wait_queue的指针。然后,每当确实发生争用时,每个发现自己被阻止访问资源的进程都会在该资源的wait_queue上休眠。当任何进程完成使用wait_queue存在的资源时,它应该唤醒可能在该wait_queue上休眠的进程,可能通过调用wake_up(),或者可能通过wake_up_interruptible().

如果您不明白为什么进程可能想要休眠,或者想要了解有关何时以及如何构建此休眠的更多详细信息,我强烈建议您购买 注释参考书目 中列出的操作系统教科书之一,并查找 互斥死锁

更高级的休眠

如果 Linux 中的sleep_on()/wake_up()机制不能满足您的设备驱动程序需求,您可以编写自己的sleep_on()wake_up()版本来满足您的需求。有关示例,请查看串行设备驱动程序 (drivers/char/serial.c) 中的函数block_til_ready(),其中在add_wait_queue()schedule().

之间必须完成很多工作。VFS

虚拟文件系统交换机,或 VFS,是允许 Linux 同时挂载许多不同文件系统的机制。在 Linux 的第一个版本中,所有文件系统访问都直接进入理解minix文件系统的例程。为了使编写其他文件系统成为可能,文件系统调用必须通过一个间接层,该层会将调用切换到正确文件系统的例程。这是通过一些可以处理一般情况的通用代码和一个指向处理特定情况的函数的指针结构来完成的。设备驱动程序编写者感兴趣的一个结构是file_operations结构。

来自 /usr/include/linux/fs.h

struct file_operations {
    int  (*lseek)   (struct inode *, struct file *, off_t, int);
    int  (*read)    (struct inode *, struct file *, char *, int);
    int  (*write)   (struct inode *, struct file *, char *, int);
    int  (*readdir) (struct inode *, struct file *, struct dirent *, int count);
    int  (*select)  (struct inode *, struct file *, int, select_table *);
    int  (*ioctl)   (struct inode *, struct file *, unsigned int, unsigned int);
    int  (*mmap)    (struct inode *, struct file *, unsigned long, size_t, int, unsigned long);
    int  (*open)    (struct inode *, struct file *);
    void (*release) (struct inode *, struct file *);
};
本质上,此结构构成了您可能必须编写以创建驱动程序的函数的部分列表。

本节详细介绍了file_operations结构中函数的操作和要求。它记录了这些函数接受的所有参数。[它还应详细说明所有默认值,并更仔细地涵盖可能的返回值。]

lseek()函数

当在表示您的设备的设备特殊文件上调用系统调用lseek()时,将调用此函数。理解系统调用lseek()的作用应该足以解释此函数,该函数移动到所需的偏移量。它接受以下四个参数

struct inode * inode
指向此设备的 inode 结构的指针。
struct file * file
指向此设备的文件结构的指针。
off_t offset
要移动到的起始偏移量。
int origin
0 = 从绝对偏移量 0(开头)获取偏移量。
1 = 从当前位置获取偏移量。
2 = 从结尾获取偏移量。
lseek()返回-errno错误时返回,否则返回 lseek 后的绝对位置(>= 0)。

如果没有lseek(),内核将采取默认操作,即修改file->f_pos元素。对于origin为 2,默认操作是返回-EINVAL,如果file->f_inode为 NULL,否则将其设置为file->f_posfile->f_inode->i_size + offset。因此,如果lseek()应该为您的设备返回错误,则您必须编写一个lseek()函数来返回该错误。

read()write()函数

读取和写入函数将字符串读取和写入到设备。如果在read()write()结构中没有与内核注册的file_operations函数,并且该设备是字符设备,则read()write()系统调用将分别返回-EINVAL。如果设备是块设备,则不应实现这些函数,因为 VFS 会通过缓冲区缓存路由请求,这将调用您的策略例程。readwrite函数接受以下参数

struct inode * inode
这是指向访问的设备特殊文件的 inode 的指针。由此,您可以根据 /usr/include/linux/fs.h 中约 100 行的struct inode声明执行多项操作。例如,您可以通过以下构造找到文件的次设备号unsigned int minor = MINOR(inode->i_rdev);MINOR宏的定义在中,以及许多其他有用的定义。阅读 fs.h 和一些设备驱动程序以获取更多详细信息,并参阅 支持函数 以获得简短描述。inode->i_mode可用于查找文件的模式,并且也有可用于此的宏。
struct file * file
指向此设备的文件结构的指针。
char * buf
这是要读取或写入的字符缓冲区。它位于用户空间内存中,因此必须使用 支持函数 中详细介绍的get_fs*(), put_fs*(),memcpy*fs()宏来访问。用户空间内存在中断期间不可访问,因此如果您的驱动程序是中断驱动的,您将必须将缓冲区的内容复制到队列中。
int count
这是buf中要读取或写入的字符计数。它是buf的大小,您可以通过它知道您已到达buf的末尾,因为buf不保证以 null 结尾。

readdir()函数

此函数是file_operations也被用于实现文件系统以及设备驱动程序的另一个产物。不要实现它。如果系统调用-ENOTDIR在您的设备特殊文件上调用,内核将返回readdir()

select()函数

select()函数通常对于字符设备最有用。它通常用于多路复用读取而无需轮询——应用程序调用select()系统调用,为其提供要监视的文件描述符列表,并且内核向程序报告哪个文件描述符唤醒了它。它也用作计时器。但是,设备驱动程序中的select()函数不是由系统调用select()直接调用的,因此file_operations select()只需要做几件事。它的参数是

struct inode * inode
指向此设备的 inode 结构的指针。
struct file * file
指向此设备的文件结构的指针。
int sel_type
要执行的选择类型
SEL_INread
SEL_OUTwrite
SEL_EX异常
select_table * wait
如果wait不为 NULL 并且选择未导致错误条件,select()应该使进程休眠,并安排在设备准备就绪时唤醒,通常通过中断。如果wait为 NULL,则驱动程序应快速查看设备是否准备就绪,即使未准备就绪也应返回。select_wait()函数已经完成了此操作。

如果调用程序想要等到它正在选择的设备之一可用于它感兴趣的操作,则进程将必须进入休眠状态,直到其中一个操作可用。但这需要使用sleep_on*()函数。而是使用select_wait()函数。(有关select_wait()函数的定义,请参阅 支持函数)。select_wait()将导致的休眠状态与sleep_on_interruptible()的休眠状态相同,实际上,wake_up_interruptible()用于唤醒进程。

但是,select_wait()不会立即使进程进入休眠状态。它直接返回,您编写的select()函数应随后返回。进程在系统调用sys_select()(最初调用您的select()函数)使用select_wait()函数提供的信息将进程置于休眠状态之前,不会进入休眠状态。select_wait()将进程添加到等待队列,但do_select()(从sys_select()调用)通过将进程状态更改为TASK_INTERRUPTIBLE并调用schedule().

来实际使进程进入休眠状态。select_wait()的第一个参数是应该用于wait_queue的相同sleep_on(),第二个参数是传递给您的select_tableselect()函数。

在以令人痛苦的细节解释了所有这些之后,以下是要遵循的两个规则

  1. 如果设备未准备好,请调用select_wait(),然后返回 0。
  2. 如果设备已准备好,则返回 1。

如果您提供select()函数,请不要通过设置current->timeout来提供超时,因为select()机制使用current->timeout,并且这两种方法不能共存,因为每个进程只有一个timeout。相反,请考虑使用计时器来提供超时。有关详细信息,请参阅 支持函数add_timer()函数的描述。

ioctl()函数

ioctl()函数处理 ioctl 调用。您的ioctl()函数的结构将是:首先进行错误检查,然后是一个巨大的(可能是嵌套的)switch 语句来处理所有可能的 ioctl。ioctl 号码作为cmd传递,ioctl 的参数作为arg传递。最好先了解ioctls应该如何工作,然后再创建它们。如果您不确定您的 ioctl,请不要羞于向了解它的人询问,原因有几个:您甚至可能不需要 ioctl 来实现您的目的,如果您确实需要 ioctl,则可能有更好的方法来完成它,而不是您想到的方法。由于 ioctl 是设备接口中最不规则的部分,因此可能需要最多的工作才能使这部分正确。花时间和精力使其正确。

您需要做的第一件事是查看 Documentation/ioctl-number.txt,阅读它,然后选择一个未使用的号码。然后从那里开始。

struct inode * inode
指向此设备的 inode 结构的指针。
struct file * file
指向此设备的文件结构的指针。
unsigned int cmd
这是 ioctl 命令。它通常用作 case 语句的 switch 变量。
unsigned int arg
这是命令的参数。这是用户定义的。由于它的大小与(void *)相同,因此可以用作指向用户空间的指针,像往常一样通过 fs 寄存器访问。
返回
-errno错误时返回
所有其他返回值都是用户定义的。
如果 Linux 中的ioctl()结构中的插槽未填写,VFS 将返回file_operations。但是,在所有情况下,如果-EINVALcmd之一FIOCLEX, FIONCLEX, FIONBIOFIOASYNC,将完成默认处理
FIOCLEX(0x5451)
设置 close-on-exec 位。
FIONCLEX(0x5450)
清除 close-on-exec 位。
FIONBIO(0x5421)
如果arg为非零,设置O_NONBLOCK,否则清除O_NONBLOCK.
FIOASYNC(0x5452)
如果arg为非零,设置O_SYNC,否则清除O_SYNC. O_SYNC尚未实现,但此处已记录并在内核中解析以确保完整性。
请注意,在创建自己的 ioctl 时,您必须避免这四个数字,因为如果它们冲突,VFS ioctl 代码会将它们解释为这四个之一,并采取相应的操作,从而导致难以追踪的错误。

mmap()函数

struct inode * inode
指向设备 inode 结构的指针。
struct file * file
指向设备文件结构的指针。
unsigned long addr
主内存中要mmap()到的地址的开头。
size_t len
mmap().
的内存长度。
int prot
以下之一PROT_READ
区域可以读取。PROT_WRITE
区域可以写入。PROT_EXEC
区域可以执行。PROT_NONE
区域无法访问。
unsigned long offmmap()文件中要到的偏移量。文件中的此地址将映射到地址.

addropen()函数

struct inode * inode
指向设备 inode 结构的指针。
struct file * file
指向设备文件结构的指针。

addr在设备特殊文件打开时调用。它是负责确保一致性的策略机制。如果只允许一个进程一次打开设备,则addr应锁定设备,使用任何适当的锁定机制,通常在某些状态变量中设置一个位以将其标记为繁忙。如果进程已在使用该设备(如果繁忙位已设置),则addr应返回-EBUSY。如果多个进程可以打开设备,则此函数负责设置在write()中未设置的任何必要队列。如果不存在此类设备,则返回addr应返回-ENODEV以指示这一点。成功返回 0。

open()仅当进程关闭其在文件上的最后一个打开的文件描述符时才调用。[我不确定这是真的;它可能在每次关闭时都被调用。] 如果设备已被标记为繁忙,则open()应在适当的情况下取消设置繁忙位。如果您需要清理kmalloc()的队列或重置设备以保持其完整性,则这是执行此操作的位置。如果未定义open()函数,则不调用任何函数。

init()函数

此函数实际上未包含在file_operations结构中,但您需要实现它,因为正是此函数首先向 VFS 注册file_operations结构——没有此函数,VFS 无法将任何请求路由到驱动程序。此函数在内核首次启动并配置自身时调用。然后,init 函数检测所有设备。您将必须从正确的位置调用您的init()函数:对于字符设备,这是 drivers/char/mem.c 中的chr_dev_init()

init()函数运行时,它通过调用正确的注册函数来注册您的驱动程序。对于字符设备,这是register_chrdev()。(有关注册函数的更多信息,请参阅 支持函数。)register_chrdev()接受三个参数:主设备号(一个 int)、设备的“名称”(一个字符串)和device_fops file_operations结构。

的地址。完成后,当访问字符或块特殊文件时,VFS 文件系统交换机将自动将调用(无论是哪个调用)路由到正确的函数(如果函数存在)。如果函数不存在,则 VFS 例程将采取一些默认操作。

init()函数通常显示有关驱动程序的一些信息,并且通常报告找到的所有硬件。所有报告都通过printk()函数。

Copyright (C) 1992, 1993, 1994, 1996 Michael K. Johnson, johnsonm@redhat.com。


消息

1. 问题: 为没有中断的设备使用 XX_select() 作者:Elwood Downey
2. 反馈: 找到 select() 问题的根源
3. 问题: 为什么 VFS 函数同时获取 structs inode 和 file? 作者:Reinhold J. Gerharz