内容


pseudo 详解,第 2 部分

内部原理

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: pseudo 详解,第 2 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:pseudo 详解,第 2 部分

敬请期待该系列的后续内容。

概述

本文是 pseudo 实用工具系列的第 2 部分,它更加全面地阐述了技术细节。

您可以只阅读源代码,不一定非得阅读本文。不过,由于源代码充满了许多有趣的特殊案例,这可能不易理解,因此本文更加详细地解释它们的工作原理。

首先理解 pseudo 的工作原理

您肯定碰到过这种情形,有人叫您不要做某事,因为它是 “没有先例的行为”,或者您真的不需要了解内幕。这就是例外。在很多情况下,pseudo 要做的事情是有目的地滥用主机系统的 C 实现。可移植性问题在合理的范围内,但就现在的代码格式而言,在一些 Linux™ 架构上运行可能会出现问题,不过它能够在 32 位和 64 位的 Linux 上良好地运行。此外,最近我们已成功地 Mac OS X 上使用它(至少是局部上)。

进一步阐述动态链接

pseudo 重新定义了 open(2)。这是一个非常令人惊奇的概念,在证明其可行性之后,您会感到更加惊讶。当有一个动态链接到 C 库的程序时,您认为是 “系统调用” 的大部分东西实际上是调用 C 库中的包装器的函数。这些包装器可能非常普通,或者它们通过完成一些实际的设置工作来处理特殊的情况,或者在将控制权转移给内核之前以特定的方式将事情安排就绪。

这很麻烦,因为它使得拦截实际的原始系统调用操作变得更加困难。您可以进行拦截(查看 strace(1) 获得关于此的一些信息),但很困难,并且需要一个独立的进程来运行您的进程(与调试器很像)。这需要一定的开销,因此拦截一点都不便宜。所以没有拦截系统调用,而是转而拦截实际参与的函数调用。

如果库位于 LD_PRELOAD 中,它将在主库加载之后加载,并且在显式链接到目标库的库加载之前加载。通过查看给定库的 ldd 输出可以看到,它显示运行时正常加载的库;所有这些库都在 LD_PRELOAD 列出的库加载之后方才加载。这些细节在使用不同的动态装载器的 Mac OS X 上会不一样,但让人吃惊的是,其他行为都是一样的。

对于动态链接,多个库定义相同的符号没有问题。“第一个” 定义符号的库将被使用。现在,在一些系统上这意味着获得了顶级的符号。不过,在 Linux 和其他一些系统上,还提供能够请求使用 “下一个” 符号的额外特性。可以调用 dlsym(RTLD_NEXT, "open")

在一个函数中进行调用之后,如果成功的话,这个调用处理库中找到的该函数的第一个副本。(如果想要找到来自特定库函数副本的地址,必须使用 dlopen() 打开该库,并使用生成的处理程序而不是 RTLD_NEXT)。在 pseudo 中,为 dlsym(RTLD_NEXT, "open") 返回的地址储存在名为 real_open() 的函数指针中。

RTLD_NEXT 特性是一个扩展,其他系统可能没有该特性,或者使用不同的方式实现它。在 Linux 中,该特性专门用于创建包装器。

与用户程序冲突

当查找一个符号时,在主要的可执行映像之后仍会查找 LD_PRELOAD 库。在开发期间,在一段很短的时间内用到了名为 getvar()setvar() 的函数。不过,这导致了不可思议的行为;当运行的可执行文件为 awk 实用工具时,这两个函数不能正常工作。通过重命名它们解决了问题。这让我更加坚信,pseudo 必须努力尝试避免发生命名冲突。

由于类似的命名冲突,代码中的某些地方不得已地保留了专门的临时解决办法;在其中一个目标系统上 pseudo 必须持续运行,/usr/bin/findregcomp()regexec() 提供了自己的本地实现,这恰好与 C 库中的版本不兼容。我仍然将存在隐患的调用替换成亲自编写的字符串操作,因为它们仅和少数特定的正则表达式一起使用。

直接和变体系统调用

许多发起系统调用的 glibc 内部特性都将系统调用直接实现为内联的,而不是通过标准的 “包装器” 进行调用。例如,如果调用 fopen(3),可能在库中没有任何入口点来调用 open(2) 的变体。由于这个原因,pseudo 必须拦截 fopen(3) 并对生成的文件使用 FILE *,以找出与给定名称相关联的文件描述符(如果有的话)。这在不跟踪名称的 fakeroot 中是不需要的,因此不用关心文件打开的问题。

此外,我还看见了变体。许多系统调用有一个以上的变体。例如,要拦截对 open(2) 的调用,我必须拦截以下系统调用:

      open64
      __openat_2
      __openat64_2
      openat64
      openat
      open

它们中的每个都可以被至少一个程序生成,并且该程序将其作为创建文件的唯一方式。未能拦截这些调用很容易导致生成本该已经创建的并且为虚拟 “ root ” 所有的文件,但它们实际上并没有记录下来。

真正发生的情况

现在,让我们讨论一下通过包装函数进行调用期间会发生什么。尽管这里未讨论到某些特殊案例和异常,这就是基本的设计。

当应用程序 stat(2) 时会发生什么呢?glibc 将把该调用指向使用名称类似于 __xstat() 的包装器函数。不过,由于 pseudo 库提供了该函数,最终会调用到 pseudo 库中名为 __xstat() 的函数。这个函数反过来确保设置了 pseudo 包装器,以及可以访问底层的系统调用,然后它再运行包装器函数。包装器函数将执行它认为需要的操作,以假装执行了正确的底层系统调用。对于 __xstat(),这意味着包装器实际使用 real___xstat() 来获取描述文件的 struct stat 缓冲,该文件用于将查询发送到服务器。

发送到服务器的查询经过一段客户端代码,该代码以 pseudo 内部使用的格式组装消息,包括完全规范化的路径名和来自底层 real___xstat() 调用的信息。服务器接收消息,并搜索数据库查找匹配条目。如果它找到匹配的条目,将使用已记录的模式、所有者和组填充消息,并返回一条表示操作成功的消息。如果它没找到匹配条目,则返回一条表示操作失败的消息。

客户端代码将该消息传递给包装器函数。如果消息表明成功,来自返回消息的信息将填充到 struct stat 对象,从而使用来自虚拟文件系统的记录数据取代真实的数据。返回代码被传递回到包装器,后者再将其传递回到您自己的代码。在等待一段比预想稍长的时间之后,将获得来自 stat(2) 调用的返回结果,它提供的信息与运行真实的 root 特权时得到的信息完全一样。

包装器的结构

对于每个拦截到的调用,都有一个名字、签名都与所拦截到的调用一样的对应包装器。基础的包装器表示为 pseudocode [sic.],大概类似于 清单 1

清单 1. 取代系统调用
int
fchmod(int fd, mode_t mode) {
    int rc;
    if (!setup_wrappers()) {
        errno = ENOSYS;
        return -1;
    }
    if (already_in_pseudo) {
        rc = real_fchmod(fd, mode);
    } else {
        rc = wrap_fchmod(fd, mode);
    }
    return rc;
}

在这个例子中,wrap_fchmod() 是另一个在 pseudo 内部定义的函数,它实际提供了修改后的 fchmod() 的实现。与之相反的是,real_fchmod() 是一个由 setup_wrappers() 填充的函数指针setup_wrappers() 函数仅进行一次设置;然后,它设置一个静态变量来表明已经完成,从而使未来的调用能够快速返回。

“already_in_pseudo” 测试提出了另一个顾虑;如果在给定调用的实现期间 pseudo 需要发起其他系统调用,会发生什么问题呢?它不想让这些调用被拦截,因此当将要执行可能需要此类处理的事件时,它将设置 already_in_pseudo 标记(实际上读作 “antimagic”)。(实际上,它是一个计数器,尽管到目前为止其计数从未超过 1)。

wrap_fchmod() 函数分成两部分;一个通用的开头和结尾,和一个用于为该特定函数获取代码的 #include;每个函数的内容都储存在一个名为 “guts” 的子目录中,该目录用来存储包装代码。开头和结尾提供标准的设置。清单 2 显示了 wrap_fchmod() 的当前实现:

清单 2. 样例包装器
static int
wrap_fchmod(int fd, mode_t mode) {
    int rc = -1;

#include "guts/fchmod.c"

    return rc;
}

如果您看见有经验的程序员编写一个用于命名 .c 文件的 #include 指令,那么这将是为数不多的一次。这个设计允许在这些函数中完成额外的工作,并且不再多个函数实现之间重复。例如,对于使用可选参数的函数,wrap_*() 函数将提取可选的参数。这是 pseudo 的众多未严格定义的事情之一,因为这是无条件完成的,并且在没有传递额外的参数时调用 va_arg() 是一个错误。清单 3 显示了 wrap_open() 函数:

清单 3. 带有可选参数的包装器
static int
wrap_open(const char *path, int flags, ... /* mode_t mode */) {
    int rc = -1;
    va_list ap;
    mode_t mode;

    va_start(ap, flags);
    mode = va_arg(ap, mode_t);
    va_end(ap);

#include "guts/open.c"

    return rc;
}

通过这种方式,内部函数能够无条件地引用 “mode” 参数,而无需使用 va_*() 宏。(我们不能由此将 open() 声明为使用 3 个参数,因为该声明与标准的头部冲突)。

路径、锁和信号

还记得我曾经说过包装器开拓了一条特殊的路吗?从此开始有三大主要变化。首先,路径在包装器中是标准化的。在最初的实现中,pseudo 并没有真正规范化路径,它仅处理 ... 路径条目。这导致出现涉及到路径中的符号链接的偶然性问题,因此现在 pseudo 完全规范化路径(大部分情况下;当 pseudo 包装操作链接的函数时,该名称的最后部分是并没有废除引用)。由于一贯是这样实施的,通常也是在包装器内部完成的。

当参数出现以 “path” 结尾的名称时将自动调用规范化代码,这在当时看起来是很聪明的做法(现在我已经后悔了)。这意味着在 pseudo 中定义的少数函数的参数名与手册页中的规范化名字不同。对于许多 *at() 调用而言,比如 openat(2),存在一个额外的 “flags” 参数,它告知调用是否遵循符号链接。路径名规范化程序也接受这样的参数。默认情况下,如果函数有名为 flags 的参数,该参数将传递到规范化程序。这一决定在当时看来也算是很聪明的,但导致了一些奇怪的 bug(比如和普通的 open(2) 调用一起使用时,该调用也有一个带有不同语义的 “flags” 参数)。

我们为多线程程序添加锁。在最初的设计中,由于没有锁,一个线程在另一个线程与服务器对话时发起的系统调用都会绕过该 pseudo 设计。现在有一个锁机制来确保每次仅能有一个线程经过任何已包装的调用。

这将导致第三个问题:为信号处理程序接收信号的程序调用已包装的调用。为了处理该问题,pseudo 在启动包装器时阻塞某些信号,并在包装器结束时解除阻塞。这能很好地发挥作用,但面对一个致命的 bug 时却无能为力。因为是在包装器结束时解除信号的阻塞,并且 pseudo 包装 execve(2),因此 execve(2) 调用的一个新进程可能会在这些信号受阻时开始,而一些程序可能从未重置或修复它们的信号。这也被修正。

最终得到的包装器函数由于太长而不作为内联代码例子显示,但它能够出色地执行。您可以在 Github 上的 pseudo 源代码树上看到它(见 参考资料)。

深入了解 guts

guts 目录中的实现可能非常琐碎。例如,清单 4 拥有为 acct(2) 系统调用提供的实现:

清单 4. guts 的最简单例子
/*
 * Copyright (c) 2010 Wind River Systems; see
 * guts/COPYRIGHT for information.
 *
 * static int
 * wrap_acct(const char *path) {
 *      int rc = -1;
 */

    rc = real_acct(path);

/*      return rc;
 * }
 */

注意注释代码和包装器之间的相似之处;这让您感觉到将要写什么样的代码。不过,有些代码要复杂得多。guts 代码必须指明是否需要请求服务器,如果是,则指明需要对什么进行请求。在某些情况下,底层的系统调用完全被省略掉,或者使用完全不同的调用取代。例如,mnkod(2) 系统调用是通过创建一个纯文本文件来实现,然后在 pseudo 数据库中记录所请求的文件类型。

必须采取措施

如果包装器函数需要记录一个动作,或者需要查询 pseudo 数据库,它将使用 pseudo_client_op() 函数来执行操作。这个函数的签名如 清单 5 所示。

清单 5. pseudo_client_op() 的签名
pseudo_msg_t *
pseudo_client_op(
    op_id_t op,
    int access,
    int fd,
    int dirfd,
    const char *path,
    const struct stat64 *buf,
    ...);

“...” 是一个历史怪象。在重命名操作受到支持之前,该函数仅接受固定的参数。添加重命名操作在那时就像是添加最后一个可选参数(否则可能应为 const char *oldpath)一样,算是很好的主意。直到现在我仍然无法确保那是不是正确的调用。

op_id_t 是一个枚举类型,它包含一系列受支持的操作,比如 OP_CHROOT, OP_OPENOP_CLOSEOP_CHMOD。“access” 参数表示访问的类型(读、写或执行)。“fd” 参数是被操作的文件描述符。“dirfd” 参数最初用于规范化和 *at() 系统调用(比如 openat(2))一起使用的路径。从那之后,路径规范化就淡化了 pseudo_client_op(),现在该字段仅用于处理 OP_DUP 操作,比如对 dup(2) 的调用。现在这个名称明显是错误的。我将在空闲时间纠正它。路径名是对被包装的调用可用的名称,而 stat64 缓冲是关于被操作的文件的统计信息。

在许多特定的案例中,这些字段中的一些被设置为 sentinel 值,因为它们没有与特定的操作相关。

一些消息完全在客户端进行处理。例如,客户端维护一个关于文件描述符的当前路径的表,从而实现将路径和操作(比如 fchmod(2))一起发送到服务器。OP_CLOSE 操作更新这个内部表,但没有被发送到服务器。仅当操作与数据库进行交互或者被记录时,才被发送到服务器。(在未来的修订中,我希望更加清晰地划分这两个问题)。

实际将消息发送到服务器

在客户端和服务器之间的 IPC 通过 UNIX®-域套接字来完成。如果该套接字不存在,或者不能打开,客户端将尝试不停地访问服务器。消息包含一个标准头部和可选的路径信息。消息有许多类型,比如 PSEUDO_MSG_PINGPSEUDO_MSG_OPPSEUDO_MSG_ACKPSEUDO_MSG_OP 消息类型表示一个操作,比如 OP_CHMODOP_STAT,被提交到服务器。客户端编写一条到该套接字的消息,服务器处理该消息并编写类型为 PSEUDO_MSG_ACK 的响应。引用一个文件的消息一般包含文件的文件系统模式(包括权限和文件类型)、设备、inode 号和路径名。对于有两个路径需要发送的项,那么在第一个路径之后有一个 null 字节,其后紧跟第二个路径,并且消息的 pathlen 字段被设置为总长度。

服务器对入站请求执行多项健康检查。它首先查看是否有一个与来自请求的路径、设备和 inode 号相匹配的数据库条目。如果没有,它将检查针对路径的匹配条目和针对设备和 inode 号的匹配条目。如果这些匹配,但精确的匹配失败,那么通常报告一个诊断结果。(这不适用于诸如重命名等希望出现名称不匹配的情况)。如果文件类型不匹配,并且属于意外不匹配,比如一个是目录而另一个不是目录,那么将记录诊断结果并销毁现有的数据库条目。不过,在文件系统包含纯文本文件时文件类型将不匹配并且服务器将记录设备节点,这不会产生不良影响;这就是 pseudo 模拟设备节点的创建方式。

后续内容

通常,我从错误中学习到的东西比首次成功学得的东西更多。因此,在本系列的下一篇文章中,我将仔细分析自从这个项目开始后所碰到的一些有趣 bug 和失败,以及我们如何进行处理并从中学习。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Open source, Linux
ArticleID=682031
ArticleTitle=pseudo 详解,第 2 部分: 内部原理
publish-date=06222011