动态内核:模块化设备驱动程序

作者:Alessandro Rubini

内核模块是近期 Linux 内核的一个伟大特性。尽管大多数用户认为模块只是通过在大多数时间将软盘驱动程序从内核中移除来释放一些内存的方式,但使用模块的真正好处是支持添加额外的设备而无需修补内核源代码。在接下来的几期《内核角落》中,Georg Zezschwitz 和我将尝试介绍编写强大模块的“艺术”——同时避免常见的设计错误。

什么是设备?

设备驱动程序是在计算机上运行的软件的最低级别,因为它直接绑定到设备的硬件特性。

实际上,“设备驱动程序”的概念非常抽象,内核可以被认为像是名为“计算机”的设备的庞大设备驱动程序。然而,通常情况下,您不会将计算机视为一个整体,而是一个配备外围设备的 CPU。因此,内核可以被视为在设备驱动程序之上运行的应用程序:每个驱动程序管理计算机的单个部分,而内核本身则在可用设备之上构建进程调度和文件系统访问。

参见图 1。

一些强制性驱动程序是“硬连线”到内核中的,例如处理器驱动程序和内存驱动程序;其他的则是可选的,计算机在有或没有它们的情况下都可以使用——尽管对于传统用户来说,没有控制台驱动程序和网络驱动程序的内核是毫无意义的。

上面的描述在某种程度上是过于简单化的,并且略带哲学意味。真正的驱动程序以复杂的方式交互,有时很难实现它们之间的清晰区分。

在 Unix 世界中,网络驱动程序和一些其他复杂的驱动程序属于内核,而 设备驱动程序 的名称则保留给属于以下三组的设备的低级软件接口

字符设备

那些可以被视为文件的设备,因为它们可以被读取和/或写入。控制台(即显示器和键盘)以及串行/并行端口是字符设备的示例。像 /dev/tty0 和 /dev/cua0 这样的文件提供了用户对设备的访问。字符设备通常只能按顺序访问。

块设备

历史上:只能以块大小(通常为 512 或 1024 字节)的倍数读取和写入的设备。这些是您可以挂载文件系统的设备,最值得注意的是磁盘。像 /dev/hda1 这样的文件提供了对设备的访问。块设备的块由 缓冲区缓存 缓存。Unix 提供了与块设备对应的未缓存字符设备,但 Linux 没有。

网络接口

网络接口不属于设备文件抽象。网络接口通过名称(例如 eth0 或 plip1)来标识,但它们不映射到文件系统。从理论上讲,这是可能的,但从编程和性能的角度来看是不切实际的;网络接口只能传输数据包,而文件抽象不能有效地管理像数据包这样的结构化数据。

上面的描述相当粗略,并且每个 Unix 版本在块设备的细节上都有所不同。实际上,这并没有太大的区别,因为这种区别仅在内核内部相关,我们不会详细讨论块驱动程序。

先前表示中缺少的是,内核还充当设备驱动程序的库;驱动程序从内核请求服务。您的模块将能够调用函数来执行内存分配、文件系统访问等等。

就可加载模块而言,这三种驱动程序类型中的任何一种都可以构建为模块。您也可以构建模块来实现文件系统,但这超出了我们的范围。

这些专栏将专注于字符设备驱动程序,因为特殊(或自制)硬件在大多数情况下都符合字符设备抽象。这三种类型之间只有一些区别,因此为了避免混淆,我们将仅介绍最常见的类型。

您可以在《Linux Journal》的第 9、10 和 11 期以及 Linux Kernel Hackers' Guide 中找到块驱动程序的介绍。尽管两者都有些过时,但结合这些专栏,它们应该为您提供足够的入门信息。

什么是模块?

模块是一个代码段,它将自身注册为内核的设备驱动程序,由内核调用以与设备通信,并反过来调用其他内核函数来完成其任务。模块利用“内核本身”和设备之间的清晰接口,这使得模块易于编写,并使内核源代码免于混乱。

模块必须编译为目标代码(不进行链接;将编译后的代码保留在 .o 文件中),然后使用 insmod 加载到正在运行的内核中。insmod 程序是一个 运行时链接器,它通过内核符号表将模块中任何未定义的符号解析为正在运行的内核中的地址。

这意味着您可以像编写传统的 C 语言程序一样编写模块,并且您可以调用您未定义的函数,就像您通常在应用程序中调用 printf()fopen() 一样。但是,您只能依赖于最少的外部函数集,这些函数是内核提供的 公共 函数。insmod 会将正确的内核空间地址放入您编译的模块中,无论您的代码在哪里调用内核函数,然后将模块插入到正在运行的 Linux 内核中。

如果您不确定内核函数是否是公共的,您可以查找其名称,可以在源文件 /usr/src/linux/kernel/ksyms.c 或运行时表 /proc/ksyms 中查找。

要使用 make 编译您的模块,您需要一个像下面这样简单的 Makefile

TARGET = myname

ifdef DEBUG
  # -O is needed, because of "extern inline"
  # Add -g if your gdp is patched and can use it
  CFLAGS = -O -DDEBUG_$(TARGET) -D__KERNEL__ -Wall
else
  CFLAGS = -O3 -D__KERNEL__ -fomit-frame-pointer
endif

all: $(TARGET).o

如您所见,构建模块不需要特殊的规则,只需要 CFLAGS 的正确值。我建议您在代码中包含调试支持,因为在没有补丁的情况下,gdb 无法利用 -g 标志为模块提供的符号信息,而模块是正在运行的内核的一部分。

调试支持通常意味着额外的代码来打印来自驱动程序内部的消息。使用 printk() 进行调试功能强大,替代方案是在内核上运行调试器,窥探 /dev/mem 以及其他极低级的技术。互联网上有一些工具可以帮助使用这些其他技术,但您需要精通 gdb 并能够阅读真正的内核代码才能从中受益。在撰写本文时,最有趣的工具是 kdebug-1.1,它允许您在正在运行的内核上使用 gdb,检查甚至更改内核数据结构(包括已加载的内核模块中的数据结构),同时内核仍在运行。Kdebug 可通过 ftp 从 sunsite.unc.edu 及其镜像站点上的 /pub/Linux/kernel 获取。

为了让事情更复杂一点,标准 printf() 函数的内核等效函数称为 printk(),因为它与 printf() 的工作方式不完全相同。在 1.3.37 之前,传统的 printk()/var/adm/messages 中生成行,而以后的内核会将它们转储到控制台。如果您想要静默日志记录(仅在消息文件中,通过 syslogd),您必须将符号 KERN_DEBUG 添加到格式字符串的前面。KERN_DEBUG 和类似的符号只是字符串,编译器会将它们连接到您的格式字符串中。这意味着您 不能KERN_DEBUG 和格式字符串之间放置逗号。这些符号可以在 <linux/kernel.h> 中找到,并在那里进行了文档说明。此外,printk() 不支持浮点格式。

请记住,syslog 会尽快写入消息文件,以便在系统崩溃时将所有消息保存在磁盘上。这意味着过度使用 printk 的模块会明显减慢速度,并在短时间内填满您的磁盘。

几乎任何模块的错误行为都会从内核生成一个 [cw]Oops[ecw] 消息。当内核从内核代码中获得异常时,就会发生 Oops。换句话说,Oops 相当于用户空间的段错误,尽管不会生成核心文件。结果通常是负责进程的突然销毁,以及消息文件中的几行低级信息。大多数 Oops 消息是取消引用 NULL 指针的结果。

这种处理灾难的方式是友好的,当您的代码出现故障时,您会喜欢它:大多数其他 Unix 系统会产生内核恐慌。但这并不意味着 Linux 永远不会恐慌。当您编写在进程上下文之外运行的函数时,例如在中断处理程序和定时器回调中,您必须准备好生成恐慌。

与 [cw]Oops[ecw] 消息一起包含的稀缺、几乎难以理解的信息表示代码发生故障时的处理器状态,可以用于了解错误发生的位置。一个名为 ksymoops 的工具能够从 oops 中打印出更易读的信息,前提是您手头有一个内核映射。该映射是在内核编译后留在 /usr/src/linux/System.map 中的内容。Ksymoops 在 util-linux-2.4 中分发,但在 2.5 中被删除,因为它已在 linux-1.3 开发期间包含在内核发行版中。

如果您真正理解 Oops 消息,您可以根据需要使用它,例如调用 gdb 离线反汇编整个负责的函数。如果您既不理解 Oops 也不理解 ksymoops 输出,您最好添加更多调试 printk() 代码,重新编译并重现该错误。

以下代码可以简化调试消息的管理。它必须驻留在模块的公共包含文件中,并且适用于内核代码(模块)和用户代码(应用程序)。但是请注意,此代码是 gcc 特有的。对于内核模块来说,这并不是什么大问题,内核模块无论如何都依赖于 gcc。此代码是由 Linus Torvalds 建议的,是对我以前的 ansi 兼容方法的增强。

#ifndef PDEBUG
#  ifdef DEBUG_modulename
#    ifdef __KERNEL__
#      define PDEBUG(fmt, args...) printk (KERN_DEBUG fmt , ## args)
#    else
#      define PDEBUG(fmt, args...) fprintf (stderr, fmt , ## args)
#    endif
#  else
#    define PDEBUG(fmt, args...)
#  endif
#endif

#ifndef PDEBUGG
#  define PDEBUGG(fmt, args...)
#endif

在此代码之后,模块中的每个 PDEBUG("any %i or %s...\n", i, s); 只有在代码使用 -DDEBUG_modulename 编译时才会导致打印消息,而带有相同参数的 PDEBUGG() 将扩展为无操作。在用户模式应用程序中,它的工作方式相同,只是消息打印到 stderr 而不是消息文件。

使用此代码,您可以通过删除或添加单个 G 字符来启用或禁用任何消息。

编写代码

让我们看一下模块内部必须包含哪种类型的代码。简单的答案是“您需要的任何代码”。实际上,您必须记住,模块是内核代码,并且必须符合与 Linux 其余部分的良好定义的接口。

通常,您从包含头文件开始。并且您开始受到约束:在包含任何头文件之前,您必须始终定义 __KERNEL__ 符号,除非它在您的 makefile 中定义,并且您 只能 包含与 <linux/*><asm/*> 层次结构相关的文件。当然,您可以包含模块特定的头文件,但永远不要包含库特定的文件,例如 <stdio.h><sys/time.h>

清单 1 中的代码片段表示典型字符驱动程序的源代码的第一行。如果您要编写模块,则从现有源代码中剪切并粘贴这些行会比从本文中手动复制它们更容易。

#define __KERNEL__         /* kernel code */

#define MODULE             /* always as a module */
#include <linux/module.h>  /* can't do without it */
#include <linux/version.h> /* and this too */

/*
 * Then include whatever header you need.
 * Most likely you need the following:
 */
#include <linux/types.h>   /* ulong and friends */
#include <linux/sched.h>   /* current, task_struct, other goodies */
#include <linux/fcntl.h>   /* O_NONBLOCK etc. */
#include <linux/errno.h>   /* return values */
#include <linux/ioport.h>  /* request_region() */
#include <linux/config.h>  /* system name and global items */
#include <linux/malloc.h>  /* kmalloc, kfree */

#include <asm/io.h>        /* inb() inw() outb() ... */
#include <asm/irq.h>       /* unreadable, but useful */

#include "modulename.h" /* your own material */

包含头文件后,接下来是实际代码。在讨论特定的驱动程序功能(大部分代码)之前,值得注意的是,存在两个模块特定的函数,必须定义它们才能加载模块

int init_module (void);
void cleanup_module (void);

第一个函数负责模块初始化(查找相关硬件并在相应的内核表中注册驱动程序),而第二个函数负责释放模块已分配的任何资源并从内核表中注销驱动程序。

如果这些函数不存在,insmod 将无法加载您的模块。

init_module() 函数在成功时返回 0,在失败时返回负值。cleanup_module() 函数返回 void,因为它仅在已知模块可卸载时才会被调用。内核模块会维护一个使用计数,并且只有在该计数器的值为 0 时才会调用 cleanup_module()(稍后会详细介绍)。

这两个函数的框架代码将在下一期中介绍。它们的设计对于模块的正确加载和卸载至关重要,并且必须处理一些细节。因此,在这里,我将向您介绍每个细节,以便下个月我可以介绍结构而无需解释所有细节。

获取主设备号

字符驱动程序和块驱动程序都必须在内核数组中注册自己;此步骤对于驱动程序的使用至关重要。在 init_module() 返回后,驱动程序的代码段是内核的一部分,除非驱动程序注册其功能,否则永远不会再次调用它。像大多数 Unix 版本一样,Linux 保留了一个设备驱动程序数组,每个驱动程序都由一个数字标识,该数字称为主设备号,它只不过是可用驱动程序数组中的索引。

设备的主设备号是设备文件的 ls -l 输出中出现的第一个数字。另一个是次设备号(您猜对了)。所有具有相同主设备号的设备(文件节点)都由相同的驱动程序代码提供服务。

显然,您的模块化驱动程序需要自己的主设备号。问题是内核当前使用静态数组来保存驱动程序信息,并且该数组小到只有 64 个条目(以前是 32 个,但在 1.2 内核开发期间由于缺少主设备号而增加)。

幸运的是,内核允许动态分配主设备号。调用函数

int register_chrdev(unsigned int major,
                    const char *name,
                    struct file_operations *fops);

将会在内核中注册您的字符驱动程序。第一个参数是您请求的数字或 0,在这种情况下执行动态分配。该函数返回小于 0 的数字以表示错误,返回 0 或更大的数字以表示成功完成。如果您请求动态分配的数字,则正返回值是分配给您的驱动程序的主设备号。name 参数是您的驱动程序的名称,它会出现在 /proc/devices 文件中。最后,fops 是用于调用驱动程序中所有其他函数的结构,稍后将对此进行描述。

对于自定义设备驱动程序,使用动态分配主设备号是明智的选择:您可以确保您的设备号与系统中的任何其他设备都不冲突——您可以确保 register_chrdev() 将会成功,除非您加载了太多设备以至于您已经用完了空闲设备号,这不太可能发生。

加载和卸载

由于主设备号记录在应用程序用于访问设备的文件系统节点内,因此动态分配主设备号意味着您不能一次创建节点,并将其永久保存在 /dev 中。您需要在每次加载模块时重新创建它们。

此页面中的脚本是我用于加载和卸载模块的脚本。稍作编辑即可适合您自己的模块:您只需要更改模块名称和设备名称即可。

mknod 命令创建一个具有给定主设备号和次设备号的设备节点(我将在下一期中讨论次设备号),而 chmod 命令为新设备提供所需的权限。

尽管你们中的一些人可能不喜欢在每次系统启动时创建(和更改权限),但这没有什么奇怪的。如果您担心成为 root 用户来执行此任务,请记住 insmod 本身必须以 root 权限发出。

加载脚本可以方便地称为 drvname_load,其中 drvname 是您用于标识驱动程序的前缀;与传递给 register_chrdrv()name 参数中使用的前缀相同。该脚本可以在驱动程序开发期间手动调用,并在模块安装后由 rc.local 调用。请记住,insmod 会在当前目录和安装目录(/lib/modules 中的某个位置)中查找要安装的模块。

如果您的模块依赖于其他模块,或者您的系统设置有些特殊,您可以调用 modprobe 而不是 insmod。modprobe 实用程序是 insmod 的改进版本,它管理模块依赖项和条件加载。该工具功能强大且文档齐全。如果您的驱动程序需要特殊的处理,您最好阅读手册页。

然而,在撰写本文时,没有标准工具可以处理为自动分配的主设备号生成设备节点,我甚至无法想象他们如何知道您的驱动程序的名称和次设备号。这意味着在任何情况下都需要自定义脚本。

这是 drvname_load

#!/bin/sh
# Install the drvname driver,
# including creating device nodes.

# FILE and DEV may be the same.
# The former is the object file to load,
# the latter is the official name within
#  the kernel.

FILE="drvname"
DEV="devicename"

/sbin/insmod -f $FILE $*  || \
 {echo "$DEV not inserted" ; exit 1}

# retrieve major just assigned
major=`grep $DEV /proc/devices | \
  awk "{print \\$1}"`

# make defice nodes
cd /dev
rm -f mynode0 mynode1

mknod mynode0 c $major 0
mknod mynode1 c $major 1

# edit this line to suit your needs
chmod go+rw mynode0 mynode1

drvname_unload

#!/bin/sh
# Unload the drvname driver

FILE="drvname"
DEV="devicename"

/sbin/rmmod $FILE $* || \
 {echo "$DEV not removed" ; exit 1}

# remove device nodes
cd /dev
rm -f mynode0 mynode1
分配资源

init_module() 的下一个重要任务是分配驱动程序正确运行所需的任何资源。我们将计算机的任何微小部分称为“资源”,其中“部分”是计算机物理部分的逻辑(或软件)表示。通常,驱动程序将请求内存、I/O 端口和 IRQ 线。

程序员熟悉请求内存。kmalloc() 函数可以做到这一点,您可以像使用 malloc() 一样使用它。相反,请求 I/O 端口是不寻常的。它们在那里,免费使用。没有与“段错误”等效的“I/O 端口错误”。但是,写入属于其他设备的 I/O 端口仍然可能导致系统崩溃。

Linux 对 I/O 端口实施的策略与用于内存的策略基本相同。唯一的真正区别在于,当您写入您未请求的端口地址时,CPU 不会生成异常。端口注册(如内存注册)也有助于内核的内务管理。

如果您曾经为分配给新购买的板卡的端口地址而挠头,您很快就会忘记这种感觉:cat /proc/ioportscat /proc/interrupts 将迅速揭示您自己硬件的秘密。

注册您使用的 I/O 端口比请求内存稍微复杂一些,因为您通常必须“探测”才能找出您的设备在哪里。为了避免“探测”其他设备已注册的端口,您可以调用 check_region() 来询问您正在考虑查找的区域是否已被声明。在您探测的每个区域执行此操作一次。找到设备后,使用 request_region() 函数来保留该区域。当您的设备被移除时,它应该调用 release_region() 来释放端口。以下是 <linux/ioports.h> 中的函数声明

int check_region(unsigned int from,
                 unsigned int extent);
void request_region(unsigned int from,
                    unsigned int extent,
                    const char *name);
void release_region(unsigned int from,
                    unsigned int extent);

from 参数是 I/O 端口的连续区域或范围的开始,extent 是区域中端口的数量,name 是驱动程序的名称。

如果您忘记注册您的 I/O 端口,除非您有两个行为不端的驱动程序,或者您需要信息来在您的计算机中安装新板卡,否则不会发生任何坏事。如果您忘记在卸载时释放端口,则任何后续访问 /proc/ioports 文件的程序都会“Oops”,因为驱动程序名称将引用未映射的内存。此外,您将无法再次加载您的驱动程序,因为您自己的端口不再可用。因此,您应该小心释放您的端口。

IRQ 线存在类似的分配策略(参见 <linux/sched.h>

int request_irq(uint irq,
           void (*handler)(int, struct pt_regs *),
           ulong flags, const char *name);
void free_irq(uint irq);

再次注意,name 是出现在 /proc/ 文件中的内容,因此应该更像是 myhardware 而不是 mydrv

如果您忘记注册 IRQ 线,则不会调用您的中断处理程序;如果您忘记注销,您将无法读取 /proc/interrupts。此外,如果板卡在您的处理程序卸载后继续生成 irq,则可能会发生一些奇怪的事情(我无法准确说明,因为它从未发生在我身上,我也不太可能尝试在这里记录它)。[我 认为 您会遇到内核恐慌,但我从未设法(或尝试)使其发生,无论是哪种方式 - ED]

我在这里想谈的最后一点是由 Linus 在 <linux/io.h> 中的注释引入的:您必须 查找 您的硬件。如果您想制作可用的驱动程序,您必须自动检测您的设备。如果您想将您的驱动程序分发给公众,自动检测至关重要,但不要称其为“即插即用”,因为现在这是一个商标。

硬件应检测 ioport 和 irq 号。如果板卡没有说明它将使用哪个 IRQ 线,您可以尝试一种试错技术——如果您小心地执行此操作,效果很好。该技术将在以后的文章中介绍。

当您知道设备的 irq 号时,您应该在从 module_init() 返回之前使用 free_irq() 释放它。您可以在实际打开设备时再次请求它。如果您保持对中断的控制,您将无法在其上多路复用硬件(并且 i386 的 IRQ 线太少,不允许浪费它们)。因此,我在同一个中断上运行 plip 和我的帧捕获器,而无需卸载任何模块——我一次只打开其中一个。

不幸的是,在某些罕见的情况下,自动检测将不起作用,因此您必须提供一种将有关端口和 irq 的信息传递给驱动程序的方法。探测通常只会在系统启动期间失败,此时第一个驱动程序可以访问多个未注册的设备,并且可能会将另一个设备误认为是它正在寻找的设备。有时探测设备可能会对另一个设备造成“破坏性”,从而阻止其未来的初始化。这两个问题都不应发生在模块上,模块最后出现,因此无法请求属于其他设备的端口。尽管如此,实现一种禁用自动检测并在驱动程序中强制值的方法是一项重要的功能。至少,它比自动检测更容易,并且可以在自动检测出现之前帮助您成功加载模块。

加载时配置将是下一期的第一个主题,届时将揭示 init_module()cleanup_module 的完整源代码。

附加信息

接下来几个月的 内核角落 专栏将介绍更多模块编写要点。代码示例可以在内核和您附近的 ftp 站点中找到。

特别是,我描述的内容基于我个人在设备驱动程序方面的经验:ceddrv-0.xxcxdrv-0.xx 都类似于我描述的代码。Georg Zezschwitz 和我编写了 ceddrv,它驱动实验室接口(A/D、D/A、铃声和口哨)。cxdrv 驱动程序更简单,驱动内存映射帧捕获器。这两个驱动程序的最新版本都可以在 ftp://iride.unipv.it/pub/linux 上公开 ftp 获取。ceddrv 也在 tsx-11.mit.edu 上,而 cxdev 在 sunsite.unc.edu 的 apps/video 中。

市面上有相当多的关于设备驱动程序的书籍,但它们通常过于特定于系统,并且描述了一个笨拙的接口——Linux 更容易。关于 Unix 内部原理和内核源代码的通用书籍是最好的老师。我建议您获取以下书籍之一

  • Maurice J. Bach,《UNIX 操作系统设计》,Prentice Hall,1986 年

  • Andrew S. Tanenbaum,《操作系统:设计与实现》,Prentice Hall,1987 年

  • Andrew S. Tanenbaum,《现代操作系统》,Prentice Hall,1992 年

Alessandro Rubini (rubini@foggy.systemy.it) 正在攻读计算机科学博士课程,并在家饲养了两台小型 Linux 机器。他天性狂野,热爱徒步旅行、划独木舟和骑自行车。

加载 Disqus 评论