动态内核:探索
虽然智能驱动程序应该能够自动检测它要查找的硬件,但自动检测并不总是最明智的实现方式,因为它可能难以设计。明智的做法是在加载时提供一种指定尽可能多细节的方法,以便在绞尽脑汁并决定实现自动检测之前测试您的驱动程序。此外,如果计算机中安装了“相似”的硬件,自动检测可能会失败。一个小项目可以完全避免自动检测。
为了在加载时配置驱动程序,我们将利用 insmod 将整数值分配给驱动程序中任意变量的功能。因此,我们将为每个可配置项声明一个(公共)整数变量,并确保默认值将触发自动检测。
在加载时配置多个板留给读者作为练习(在阅读 insmod 的手册页之后);此实现允许指定单个板:为了简单起见,其他板只能通过自动检测访问。
内核是一个复杂的应用程序,保持其命名空间尽可能整洁至关重要。这意味着尽可能使用私有符号,并为您定义的所有符号使用通用前缀。
生产环境将仅声明 init_module() 和 cleanup_module(),它们用于加载和卸载驱动程序,以及任何加载时配置变量。不需要其他任何东西是公共的,因为模块是通过指针而不是名称访问的。
但是,当您开发和测试代码时,您需要在公共符号表中包含您的函数和数据结构,以便使用您喜欢的调试工具访问它们。
实现这种双重需求的最简单方法是在名称中始终使用您自己的前缀,将您的所有符号声明为 Static(注意大写“S”),并在驱动程序的顶部包含以下五行
#ifdef DEBUG_modulename
# define Static /* nothing */
#else
# define Static static
#endif
因此,真正的静态符号(例如持久局部变量)可以声明为 static,而可调试符号则声明为 Static
在本页中,公开了初始化函数的完整代码。这是骨架代码,正如 skel 名称所暗示的那样:真实的设备通常具有略多于两个 I/O 端口。
这里要记住的最重要的事情是,每当您发现错误情况时,都要释放您已经请求的所有资源。这种任务可以通过(否则不受欢迎的)goto 语句很好地处理:通过跳转到函数的资源释放部分来避免代码重复,以防出错。
显示的代码片段接受主设备号、板的 I/O 端口的基地址和 IRQ 号的加载时配置。对于每个“可能的”板(在 I/O 空间中),都会调用自动检测函数。如果未检测到任何板,init_module() 将返回 -ENODEV 以告诉 insmod 没有设备。
有时,即使计算机中未安装硬件,也允许加载驱动程序是明智的。我实现了这样的代码,以便在家中开发我的大部分驱动程序。诀窍是拥有一个配置变量 (skel_fake),它允许您伪造一个不存在的板。您可以查看我自己的驱动程序中的实现。“伪造板”是在您获得硬件之前开始编写代码或测试对两个板的支持(即使您只拥有其中一个板)的强大方法。
cleanup_module() 的作用是关闭设备并释放 init_module() 分配的任何资源。我们的示例代码循环遍历板阵列,并释放 I/O 端口和 IRQ(如果有)。最后,释放主设备号。如果您运行的是最新的内核,则对 MOD_IN_USE 的初始检查是多余的,但将其放入生产代码中是明智之举,因为您的客户或用户可能正在运行旧的 Linux 内核。
init_module() 和 cleanup_module() 的示例代码如 清单 1 所示。前缀 skel_ 用于所有非本地名称。这里的代码非常简化,因为它缺少一些错误检查,这在生产质量的源代码中至关重要。
init_module() 调用函数 skel_find() 来执行检测板是否存在的脏活。该函数非常特定于设备,因为必须探测每个设备的特殊功能;因此,我不会尝试展示执行实际探测的代码,而只会展示 IRQ 自动检测。
不幸的是,某些外围设备无法告知它们配置为使用哪个 IRQ 线,因此迫使用户在 insmod 的命令行上写入 IRQ 号,或在软件本身中硬编码该号码。这两种方法都是不良做法,因为您只是无法插入板(在设置跳线之后)并加载驱动程序。自动检测这些设备的 IRQ 线的唯一方法是试错技术,当然,只有在可以指示硬件生成中断的情况下才可行。
清单 2 中的代码显示了 skel_find(),包括完整的 IRQ 自动检测。IRQ 处理的一些细节对于某些读者来说可能显得晦涩难懂,但它们将在下一篇文章中得到阐明。总而言之,此代码循环遍历每个可能的 IRQ 线,请求安装处理程序,并查看板是否实际生成了中断。
硬件结构中的字段 hwirq 表示 可用的 中断线,而字段 irq 仅在线路处于活动状态时(在 request_irq() 之后)才有效。正如上一期中解释的那样,当设备未使用时,保留 IRQ 线是没有意义的;这就是为什么使用两个字段的原因。
请注意,我编写此代码是为了解决我的某个硬件板的限制;如果您的硬件能够报告它将要使用的 IRQ 线,那么最好使用 该 信息。无论如何,如果您能够根据您的实际硬件对其进行定制,则该代码非常稳定。幸运的是,大多数优秀的硬件都能够报告其自身的配置。
在模块加载并且硬件被检测到之后,我们必须看看 如何 对设备进行操作。这意味着引入 fops 和 filp 的作用:这些小家伙是在将设备驱动程序与内核接口时使用的最重要的数据结构——实际上是变量名。
fops 是通常用于 struct file_operations 的名称。该结构是一个 跳转表(指向函数的指针结构),每个字段都引用对文件系统节点执行的不同操作之一(open()、read()、ioctl() 等)。
指向您自己的 fops 的指针通过 register_chrdev() 传递给内核,以便在对您的节点执行操作时调用您的函数。我们已经编写了该行代码,但没有显示实际的 fops。在这里
struct file_operations skel_fops { skel_lseek, skel_read, skel_write, NULL, /* skel_readdir */ skel_select, skel_ioctl, skel_mmap, skel_open, skel_close };
您的 fops 中的每个 NULL 条目都意味着您不会为您的设备提供该功能(在这方面 select 很特殊,但我不会对此进行扩展),每个非 NULL 条目都必须是指向为您的设备实现操作的函数的指针。实际上,结构中还有更多字段,但我们的示例将使用默认的 NULL 值(C 编译器用零字节填充不完整的结构,而不会发出任何警告)。如果您真的对它们感兴趣,可以查看 <linux/fs.h> 中结构的定义。filp 是通常用于内核传递给 fops 中任何函数的参数之一的名称,即 struct file *。file 结构用于保存有关“打开文件”的所有可用状态信息,从调用 open() 开始,到调用 close() 结束。如果设备被多次打开,则每个实例将使用不同的 filp:这意味着您需要使用自己的数据结构来保存有关您的设备的硬件信息。本期中的代码片段已经使用了 Skel_Hw 数组,以保存有关安装在同一计算机上的多个板的信息。那么,缺少的是一种将硬件信息嵌入到 file 结构中的方法,以便指示驱动程序对正确的设备进行操作。struct file 中的字段 private_data 正是为了完成该任务而存在的,并且是指向 void 的指针。当 skel_open() 被调用时,您将使 private_data 指向您的硬件信息结构。如果您需要为每个 filp 保留一些额外的私有信息(例如,如果两个设备节点以两种不同的方式访问相同的硬件),那么您将需要一个用于 private_data 的特定结构,该结构必须在打开时 kmalloc() 并关闭时 kfree()。我们稍后将看到的 open() 和 close() 的实现就是以这种方式工作的。
在上一篇文章中,我介绍了次设备号的概念,现在是时候扩展该主题了。
如果您的驱动程序管理多个设备,或单个设备但以不同的方式管理,您将在 /dev 目录中创建多个节点,每个节点都具有不同的次设备号。然后,当您的 open 函数被调用时,您可以检查正在打开的节点的次设备号,并采取适当的措施。
您的 open 和 close 函数的原型是
int skel_open (struct inode *inode, struct file *filp); void skel_close (struct inode *inode, struct file *filp);
次设备号(一个无符号值,当前为 8 位)可用作 MINOR(inode->i_rdev)。MINOR 宏和相关结构在 <linux/fs.h> 中定义,而 <linux/fs.h> 又包含在 <linux/sched.h> 中。
我们的 skel 代码 (清单 3) 将拆分次设备号,以便管理多个板(使用次设备的四位)和多种模式(使用剩余的四位)。为了保持简单,我们只编写用于两个板和两种模式的代码。使用以下宏
#define SKEL_BOARD(dev) (MINOR(dev)&0x0F) #define SKEL_MODE(dev) ((MINOR(dev)>>4)&0x0F)
节点将使用以下命令创建(在 skel_load 脚本中,请参阅上个月的文章)
mknod skel0 c $major 0 mknod skel0raw c $major 1 mknod skel1 c $major 16 mknod skel1raw c $major 17
但让我们回到代码。此 skel_open() 整理出次设备号,并将任何相关信息折叠到 filp 内部,以避免在调用 read() 或 write() 时产生进一步的开销。此目标是通过使用嵌入任何 filp 特定信息的 Skel_Clientdata 结构,并通过更改 filp 内 fops 的指针来实现的;即 filp->f_op。
更改 filp 中的值可能看起来是一种不良做法,而且通常是这样;但是,当文件操作相关时,这是一个聪明的想法。无论如何,f_op 字段指向一个静态对象,因此您可以轻松地修改它,只要它指向一个有效的结构;对文件的任何后续操作都将使用新的跳转表进行调度,从而避免大量的条件语句。内核内部使用此技术来实现使用单个主设备号的不同面向内存的设备。
open() 和 close() 的完整骨架代码如 清单 3 所示;clientdata 中的 flags 字段将在引入 ioctl() 时使用。
请注意,此处显示的 close() 函数应由两个 fops 引用。如果需要不同的 close() 实现,则必须复制此代码。
设备驱动程序应该是无策略的程序,因为策略选择最适合应用程序。实际上,分离策略和机制的习惯是 Unix 的优势之一。不幸的是,skel_open() 的实现导致了策略问题:允许多个并发打开是否正确?如果是,我如何在驱动程序中处理并发访问?
单次打开和多次打开都具有合理的优势。为 skel_open() 显示的代码实现了第三种解决方案,介于两者之间。
如果您选择实现单次打开设备,您将大大简化您的代码。不需要动态结构,因为静态结构就足够了;因此,没有因驱动程序而导致内存泄漏的风险。此外,您可以简化您的 select() 和数据收集实现,因为您始终确定单个进程正在收集您的数据。单次打开设备使用布尔变量来了解它是否繁忙,并在第二次调用 open 时返回 -EBUSY。您可以在内核中的 busmouse 驱动程序和 lp 驱动程序中看到此简化代码。
另一方面,多次打开设备实现起来稍微困难一些,但对于应用程序编写者来说功能更强大。例如,通过保持监视器在设备上持续运行的可能性,简化了应用程序的调试,而无需将其折叠到应用程序本身中。同样,您可以在应用程序运行时修改设备的行为,并将几个简单的脚本用作您的开发工具,而不是复杂的包罗万象的程序。由于分布式计算在当今很常见,如果您允许多次打开您的设备,您就准备好支持使用您的设备作为输入或输出外围设备的协作进程集群。
使用传统多次打开实现的缺点主要在于代码复杂性的增加。除了需要动态结构(如已显示的 private_data)之外,您还将面临真正的流式实现、缓冲区管理以及阻塞和非阻塞读取和写入的棘手问题;但这些主题将推迟到下个月的专栏。
在用户级别,多次打开的缺点是两个不协作的进程之间可能发生干扰:这类似于从另一个 tty 对 tty 进行 cat 操作——输入可能会传递到 shell 或 cat,并且您无法提前判断。[为了演示这一点,请尝试以下操作:启动两个 xterm 或登录到两个虚拟控制台。在一个控制台 (A) 上,运行 tty 命令,该命令会告诉您正在使用哪个 tty。在另一个控制台 (B) 上,键入 cat /dev/tty_of_A。现在转到 A 并正常键入。根据一些因素(包括您使用的 shell),它可能工作正常。但是,如果您运行 vi,您可能会看到您在 B 上键入的内容输出,并且您必须在 B 上键入 ^C 才能恢复您在 A 上的会话——ED]
多个不同的用户可以访问多次打开设备,但通常您不希望允许多个不同的用户同时访问该设备。解决此问题的一种方法是查看第一个打开设备的进程的 uid,并仅允许同一用户或 root 用户进一步打开。这在 skel 代码中未实现,但它就像检查 current->euid 一样简单,并在不匹配的情况下返回 -EBUSY。如您所见,此策略类似于用于 tty 的策略:登录将 tty 的所有者更改为刚刚登录的用户。
此处显示的 skel 实现是多次打开的实现,但有一个小的附加功能:它确保设备在首次打开时是“全新的”,并在最后一次关闭时关闭设备。
此实现对于那些很少访问的设备特别有用:如果帧采集器每天使用一次,我不希望继承上次使用时的奇怪设置。同样,我不希望通过持续抓取没人会使用的帧来磨损它。另一方面,启动和关闭是耗时的任务,特别是如果必须检测 IRQ,因此您可能不会为自己的驱动程序选择此策略。硬件结构中的字段 usecount 用于在首次打开时打开设备,并在最后一次关闭时关闭设备。相同的策略也适用于 IRQ 线:当设备未使用时,中断可供其他设备使用(如果它们共享这种友好的行为)。
此实现的缺点是设备上的电源循环开销(可能很长)以及无法使用一个程序配置设备以便与另一个程序一起使用。如果您需要设备中的持久状态,或者想要避免电源循环,您只需通过像下面这样愚蠢的命令来保持设备打开即可
sleep 1000000 < /dev/skel0 &
从上面的讨论中应该清楚地看到,open() 和 close() 语义的每种可能的实现都有其自身的特点,最佳选择取决于您的特定设备及其主要用途。除非项目很大,否则也可以考虑开发时间。这里的 skel 实现可能不是您的驱动程序的最佳选择:它仅作为示例案例,是几种不同可能性之一。附加信息
Alessandro Rubini (rubini@ipvvis.unipv.it) 偶然成为程序员,选择成为 Linuxer,Alessandro 正在攻读计算机科学博士学位,并在家中养育着两台小型 Linux 机器。他天性狂野,热爱徒步旅行、皮划艇和骑自行车。