PCI 热插拔驱动程序文件系统的工作原理

作者:Greg Kroah-Hartman

2001 年 5 月 14 日,H. Peter Anvin 在 linux-kernel 邮件列表中宣布:“Linus Torvalds 要求暂停分配新的设备号。他希望由此能够出现一种新的、更好的设备空间处理方法。”

Peter 是“Linux 分配名称和编号机构”,这意味着所有内核驱动程序作者都必须通过他来为其驱动程序获取主设备号和次设备号对。暂停分配新编号自然引起了关于这种“更好的方法”设备空间处理方式的大量讨论。其中一个出现的想法是创建一个驱动程序,该驱动程序可以实现一个文件系统来控制用户空间与驱动程序的交互。

在此期间,我正在清理 Compaq 为其服务器编写的 PCI 热插拔驱动程序。PCI 热插拔驱动程序允许您在机器运行时关闭 PCI 卡,拔出卡,更换为另一张卡,然后在主板上具有适当硬件的情况下重新启动该卡。这对于无法关闭但需要添加新网卡、移除故障设备和其他服务类型操作的服务器非常有用。

PCI 热插拔驱动程序最初编写为作为字符设备与用户空间交互;通过 ioctl 调用设备节点来关闭 PCI 插槽、启动 PCI 插槽、打开和关闭 PCI 插槽指示灯以及在设备上运行不同的制造测试。为了获取有关系统中不同 PCI 插槽数量以及插槽状态(电源和指示灯状态)的信息,使用了 /proc 目录树。此目录树是只读的。

当我致力于将 PCI 热插拔核心功能从 Compaq 驱动程序中分离出来,以便其他 PCI 热插拔驱动程序可以拥有通用的用户界面时,我意识到单个文件系统更适合既显示 PCI 插槽信息又允许用户控制。对驱动程序的所有信息和控制都将从一个地方处理,而不是拥有两种不同类型的接口。

PCI 热插拔驱动程序核心已从 2.4.15 版本合并到主内核树中,并且它导出一个名为 pcihpfs 的文件系统,用于控制驱动程序。当您挂载文件系统时,您会得到一个树,其中包含名为 3、4、5 等的目录,这些目录是 PCI 插槽的物理编号。可以读取每个插槽目录中的每个文件,以查找有关该插槽的信息位的值。“power”和“attention”文件可以写入以设置电源(0 或 1)或关注(0 或 1)值。“test”文件用于向硬件发送硬件测试命令。“adapter”文件检测该插槽中是否存在适配器,“latch”文件描述该插槽的物理闩锁(如果有)的位置。

因此,您可以启用插槽 5 的电源,使用命令:

echo 1 > 5/power

从 pcihpfs 根目录。如果插槽中存在 PCI 卡,则将为该卡执行整个 PCI 初始化序列,包括调用 /sbin/hotplug 并提供 PCI 信息,以便系统可以自动加载该设备的模块 [参见 Greg 在 2002 年 6 月刊 LJ 上的内核专栏]。

由于此文件系统,用户空间程序不必对字符设备进行特殊的 ioctl() 调用,从而允许用户访问更广泛的选项来控制其设备。

本文的其余部分描述了 PCI 热插拔核心如何实现基于 RAM 的文件系统,以及您如何为您的驱动程序执行相同的操作。

首先,您需要在驱动程序中声明文件系统。为此,请使用 DECLARE_FSTYPE 宏,该宏在 include/linux/file.h 文件中定义。pci_hotplug 驱动程序以下列方式使用 DECLARE_FSTYPE 宏:

static DECLARE_FSTYPE(pcihpfs_fs_type, "pcihpfs",
          pcihpfs_read_super, FS_SINGLE | FS_LITTER);

这将创建一个名为 pcihpfs_fs_type 的 struct file_system_type 类型的静态变量,并初始化该结构的一些字段。“name”字段设置为 pcihpfs,用户将在挂载我们的文件系统时使用它,因此请选择一个有意义且当前未被内核中任何其他文件系统使用的名称。我们将“flags”字段设置为 FS_SINGLE 和 FS_LITTER。

FS_SINGLE 表示,对于此文件系统,我们将只有一个超级块实例。因此,无论文件系统挂载在系统中的何处,所有挂载点都将指向文件系统中的同一位置(请记住,您可以将同一文件系统挂载在目录树中的不同点)。FS_LITTER 选项表示我们希望此文件系统将树保留在 dcache 中。设置此选项是因为我们的文件系统将完全驻留在 RAM 中,并且不会在任何物理设备(如磁盘)上备份数据。

pcihpfs_fs_type 的“read_super”字段指向内核想要读取我们的文件系统的超级块时将调用的函数。超级块是文件系统中用于描述整个文件系统的结构。当要求挂载文件系统时,内核将调用此函数。当调用此函数时,我们需要准确地告诉内核我们的文件系统是什么样的。

但在我们的文件系统可以挂载之前,我们需要告诉内核我们的文件系统存在。这可以通过简单地调用 register_filesystem() 并将我们的 file_system_type 作为唯一参数来完成。这在 pci_hotplug 模块的初始化函数中使用以下代码段完成:

dbg("registering filesystem.\n");
result = register_filesystem(&pcihpfs_fs_type);
if (result) {
  err("register_filesystem failed with %d\n", result);
  goto exit;
}

同样,当 pci_hotplug 模块正在关闭时,我们使用以下单行代码注销我们的文件系统类型:

unregister_filesystem(&pcihpfs_fs_type);

在我们注册文件系统后,我们希望创建一些虚拟文件,这些文件将允许用户读取和写入我们的驱动程序想要导出和更改的值。如果用户在他或她想要创建文件之前挂载文件系统,内核将已经在某个虚拟位置创建了文件系统。但是,很可能文件系统尚未挂载,在创建文件系统之后,我们需要让内核挂载文件系统,然后才能添加文件(否则我们的文件创建将失败,这将阻止任何人使用该文件)。

有两种不同的方法可以解决此问题。第一种方法是等到我们的文件系统真正挂载(当我们的 read_super 函数被调用时,我们知道这一点),然后创建我们的所有文件。此方法要求我们在挂载时完成大量工作,并始终注意我们的文件系统当前是否已挂载;请记住,我们需要在不同的时间点添加或删除文件。usbdevfs 文件系统(与 devfs 无关,只是名称相似但不幸)是实现此问题解决方案的文件系统的一个示例。

但是,我们不想不断跟踪我们的文件系统何时挂载,并且我们希望能够随时创建或删除文件。要执行第二种方法,我们需要告诉内核在内部挂载我们的文件系统。这解决了跟踪当前挂载状态的问题。列表 1 显示了我们如何实现这一点。

列表 1. 告诉内核在内部挂载文件系统

让我们逐步了解列表 1,以尝试理解它在做什么以及它是如何做的。这也是内核在多处理器机器上运行时如何执行正确锁定技术的一个很好的示例。

首先,我们使用以下行获取一个名为 mount_lock 的自旋锁:

spin_lock(&mount_lock);

如果我们的文件系统是正确执行此操作所需的示例,则此锁用于保护我们的内部计数。好的,之前我说过我们不想跟踪我们是否已挂载。相信我,这个简单的函数,加上一个简单的函数来卸载文件系统(稍后描述),比尝试确定我们是否已被用户挂载的选项更容易理解和使用。有关正确执行此操作所需的示例,请参见 2.4.18 及更早版本内核中的 drivers/usb/inode.c 中的代码。

在我们获取自旋锁后,检查我们的内部挂载变量是否已设置:

if (pcihpfs_mount) {
        mntget(pcihpfs_mount);
        ++pcihpfs_mount_count;
        spin_unlock (&mount_lock);
        goto go_ahead;
}

如果已设置,我们调用 mntget() 来递增我们的内部挂载计数;mntget() 是 include/linux/mount.h 文件中的一个简单的内联函数。然后我们递增我们的内部计数变量,解锁我们的自旋锁并跳转到函数末尾,因为我们完成了(是的,在内核中谨慎地使用 goto 是可以的)。

否则,我们尚未挂载此文件系统。因此,我们解锁我们的自旋锁:

spin_unlock (&mount_lock);

并调用 kern_mount 以在内部挂载我们的文件系统:

mnt = kern_mount (&pcihpfs_fs_type);
if (IS_ERR(mnt)) {
    err ("could not mount the fs...erroring out!\n");
    return -ENODEV;
}

我们解锁我们的自旋锁,因为 kern_mount() 函数可能需要很长时间,甚至可能导致内核休眠并调度另一个进程。请记住,如果 schedule() 可以在持有锁时被调用,则您不能持有自旋锁——如果您这样做,可能会发生非常糟糕的事情。

现在我们已经挂载了我们的文件系统,我们再次获取我们的自旋锁:

spin_lock (&mount_lock);

并检查我们的内部挂载变量是否仍然为零:

if (!pcihpfs_mount) {
        pcihpfs_mount = mnt;
        ++pcihpfs_mount_count;
        spin_unlock (&mount_lock);
        goto go_ahead;
}

“等等!”,您会说。“我们为什么要看 pcihpfs_mount?我们已经知道它设置为零;我们几行代码前就检查过了。为什么要再次检查?” 好吧,请记住我们提到的 kern_mount() 调用可能会休眠?如果我们的 kern_mount() 调用休眠,并且另一个进程通过相同的代码段(记住我们正在多处理器上运行,并且可能同时发生多个用户线程),那么它可能已经成功挂载了我们的文件系统并递增了 pcihpfs_mount 变量。因此,我们需要再次检查它。

因此,如果另一个进程没有通过并挂载我们的文件系统,我们将保存指向我们现在挂载的文件系统的指针,以供其他函数稍后使用,递增我们的内部计数,解锁我们的锁并退出。

但是,如果另一个进程已经挂载了我们的文件系统,那么我们执行:

mntget(pcihpfs_mount);
++pcihpfs_mount_count;
spin_unlock (&mount_lock);
mntput(mnt);

这与我们在函数开始时在相同情况下最初所做的操作相匹配。

卸载我们的文件系统的代码要简单得多:

static void remove_mount (void)
{
       struct vfsmount *mnt;
       spin_lock (&mount_lock);
       mnt = pcihpfs_mount;
       --pcihpfs_mount_count;
       if (!pcihpfs_mount_count)
              pcihpfs_mount = NULL;
       spin_unlock (&mount_lock);
       mntput(mnt);
       dbg("pcihpfs_mount_count = %d\n",
           pcihpfs_mount_count);
}

在此函数中,我们只需获取我们的锁(与我们挂载文件系统时使用的锁相同),减少文件系统挂载次数的计数(我们需要为每次挂载都卸载它),然后解锁我们的锁。然后,我们通过调用 mntput() 告诉内核我们想要卸载文件系统。

当内核想要挂载我们的文件系统时——虚拟地,因为我们调用了 kern_mount() 或因为用户首先挂载了它——我们的 pcihpfs_read_super() 函数会被调用。在其中,我们需要设置一些内核结构,这些结构描述了我们的文件系统的外观,并列出了在文件系统的生命周期内内核将调用的函数的位置。这是通过以下代码行完成的:

sb->s_blocksize = PAGE_CACHE_SIZE;
sb->s_blocksize_bits = PAGE_CACHE_SHIFT;
sb->s_magic = PCIHPFS_MAGIC;
sb->s_op = &pcihpfs_ops;

有了这个,我们声明我们的文件系统的块大小等于页面缓存大小;我们设置了文件系统的幻数(必须在系统中的所有文件系统中唯一),并指向我们的 super_operations 结构函数列表。

然后我们通过执行以下操作来初始化超级块的根 inode:

inode = pcihpfs_get_inode(sb, S_IFDIR | 0755, 0);
if (!inode) {
      dbg("%s: could not get inode!\n",__FUNCTION__);
      return NULL;
}

我们将在稍后描述 pcihpfs_get_inode() 的作用,但如果它成功,我们然后为我们刚刚创建的 inode 分配根目录项,并将该目录项保存在超级块结构中:

root = d_alloc_root(inode);
if (!root) {
     dbg("%s: could not get root dentry!\n",
         __FUNCTION__);
     iput(inode);
     return NULL;
}
sb->s_root = root;
return sb;

这就是初始化我们的超级块所需的一切,现在内核已经挂载了我们的文件系统。

pcihpfs_get_inode() 是我们需要为我们的文件系统创建的另一个函数。每当我们需要为我们的文件系统创建新 inode 时,都会调用它。列表 2 显示了 pci_hotplug 驱动程序使用什么来执行此操作。

列表 2. 创建新的 Inode

首先,我们调用内核 new_inode() 函数,以便创建和初始化新的 inode 结构。如果成功,我们然后继续填充许多字段,其中包含必要的信息。i_uid 和 i_gid 成员设置为当前进程的 uid 和 gid 值,确保任何有权创建 inode 的人都可以在以后访问它。i_atime、i_mtime 和 i_ctime 成员指的是 inode 的访问时间、上次修改时间和上次更改时间。我们将所有这些变量设置为当前时间。如果此 inode 是“常规”文件类型,那么我们将指向我们的 default_file_operations 集合,作为每当 inode 被操作时(open、write、read 等)应该调用的函数集合。如果此 inode 是目录 inode,我们将指向我们的默认目录 inode 函数集。如果 inode 既不是常规 inode 也不是目录 inode,那么我们让内核使用调用 init_special_inode() 来初始化它。

因此,现在文件系统已在内部挂载,我们如何创建一个用户可以读取和写入的文件?为此,我们调用我们的 fs_create_file() 函数,传入我们要创建的文件的名称、文件的模式、指向文件父目录的指针(如果此指针为 NULL,我们默认为文件系统的根目录)、指向我们要分配给此文件的数据 blob 的指针以及指向文件操作集的指针,该文件操作集将在访问文件时调用(请参见列表 3)。

列表 3. 创建用户可以读取和写入的文件

在这里,我们调用 pcihpfs_create_by_name 来获取一个新的目录项,其中包含所有指定的信息。在创建我们的新目录项后,我们保存我们的数据指针,并将目录项 file_operations 指向我们真正希望在访问此目录项的 inode 时调用的那个。

我们分配给 inode 的 struct file_operations 取决于我们创建的文件类型。对于“power”文件,它报告特定 PCI 插槽是打开还是关闭,并且还控制打开或关闭插槽,我们使用以下结构:

static struct file_operations power_file_operations = {
        read:         power_read_file,
        write:        power_write_file,
        open:         default_open,
};

这里有趣的函数是 power_read_file 和 power_write_file。这是每当从文件读取或写入文件时调用的函数。其他函数在对文件执行不同操作时调用。当调用 open() 时,内核调用 default_open;当调用 llseek 时,内核调用 default_file_lseek(),依此类推。

power_read_file() 是一个相当简单的函数。我们只想返回特定 PCI 插槽的当前电源状态。执行此操作的代码是:

page = (unsigned char *)__get_free_page(GFP_KERNEL);
if (!page)
        return -ENOMEM;
retval = get_power_status (slot, &value);
if (retval)
        goto exit;
len = sprintf (page, "%d\n", value);

此代码分配一块内存(一页),获取特定 PCI 插槽的电源状态(通过调用 get_power_status()),然后将此状态的字符串表示形式写入内存块。然后将内存块复制到用户空间。请记住,原始内存位于内核空间中;如果您希望用户能够看到内存,则需要调用:

if (copy_to_user (buf, page, len)) {
       retval = -EFAULT; goto exit;
}

其中 buf 是指向最初传递给 read() 调用的用户空间缓冲区的指针。因此,当用户发出命令:

cat /tmp/pcihpfs/slot2/power

结果是:

1

power_write_file() 函数同样简单。我们希望用户能够使用简单的 echo 命令控制 PCI 插槽的电源,例如:

echo 1 > /tmp/pcihpfs/slot3/power

打开系统中第三个 PCI 插槽的电源。为此,我们需要将传递给我们的值的字符串表示形式转换为二进制数,并确定要调用哪个特定于插槽的函数(参见列表 4)。

列表 4. 控制 PCI 插槽的电源

首先,我们创建一个比用户字符串大一个字节的缓冲区,并用零填充它。然后,我们将缓冲区从用户空间复制到我们的内核缓冲区,使用 simple_strtoul() 函数将其转换为二进制数,然后通过在指定的 PCI 插槽上调用 disable_slot() 或 enable_slot() 来对二进制数的值执行操作。

通过上面提到的这两个简单函数,我们现在创建了一个可以通过任何用户访问的驱动程序接口,而无需进行特殊的 ioctl 类型调用。

当驱动程序关闭时,它需要删除最初在文件系统中创建的所有文件,以便允许卸载文件系统并释放所有分配的内存。为此,它调用 fs_remove_file() 函数(参见列表 5)。

列表 5. 调用 fs_remove_file() 函数

此函数需要一个指向 fs_create_file 调用返回的目录项的指针。它确定目录项是否具有有效的父项,因为您需要目录项的父项才能删除它。然后,它调用内核 VFS 层以删除目录项(根据目录项是指目录还是文件,会进行不同的调用)。

我们已经描述了在驱动程序中实现文件系统所需的基本文件系统函数。为了更好地描述所有不同部分如何协同工作,请查看 Linux 内核树中 drivers/hotplug/pci_hotplug_core.c 文件中的代码。

本文基于 2.4 内核的必要条件。由于大多数 ramfs 函数的导出,2.5 内核应该使事情变得更容易。这将使基于 RAM 的文件系统之间能够进行更多代码共享,从而减少驱动程序作者必须做的工作量,并防止作者不正确地执行操作。

致谢

我要感谢 Pat Mochel 编写了 ddfs/driverfs 代码,许多 pcihpfs 代码最初都是基于此代码的。driverfs 是 2.5 内核中的一个新文件系统,它还将帮助驱动程序作者将特定于驱动程序的信息导出到用户空间,并提供所有设备的树,从而使电源管理工具更加容易。

我还要感谢 Al Viro 回答了许多与 VFS 相关的问题,并使文件系统能够以如此少量的代码编写。

资源

How the PCI Hot Plug Driver Filesystem Works
Greg Kroah-Hartman 目前是 Linux USB 和 PCI 热插拔内核维护者。他在 IBM 工作,从事各种与 Linux 内核相关的工作,可以通过 greg@kroah.com 联系他。
加载 Disqus 评论