系统调用

作者:Michael K. Johnson

Linux 内核中的代码可以通过两种基本方式执行。一种是被中断调用,另一种是从用户程序调用(这是我在本专栏中必须说的“善意的谎言”)。用户程序通过系统调用来调用内核中的代码,这本质上是一种不寻常的函数调用。

当然,当用户代码调用特权内核代码时,内核必须非常仔细地检查其参数的有效性,以避免意外地造成任何类型的损害。如果代码对于除超级用户以外的任何人执行都不安全,那么也有例程来检查这一点。

在内核中

创建系统调用比创建普通的 C 语言函数要困难一些,但也不是太难。它肯定比在头文件中声明一个函数要复杂得多——对于系统调用,对头文件唯一需要的更改是 不是 函数声明。

您需要做的第一件事是修改内核中现有的文件,或者创建一个新的文件进行编译。如果您创建一个新文件,我们将假设您能够将其添加到适当的 Makefile 中,并为正在编写的代码使用正确的 #include 语句。您 需要 确保包含 <linux/errno.h>,因为系统调用需要能够返回错误代码,而这些错误代码都在 errno.h 中定义。

您需要创建一个名为 sys_name 的函数,其中 name 是您正在创建的系统调用的名称。该函数必须具有 asmlinkage int 的返回类型规范,并且可以有 0 到 5 个参数,包括 0 和 5。参数的大小都必须与 long 相同;它们不能是结构体。(或者,至少,不能是大于 long 的结构体。将结构体的大小设置为与 long 相同是不明智的,因为会对它们进行整数运算。“有符号”结构体是什么?如果您不想考虑这个问题,请不要使用小型结构体。实际上,根本不要使用它们。)

该函数将以 -ENAME 的形式返回错误。负数在返回时被视为错误值(稍后我们将看到原因),正数被视为正常返回值。这意味着在具有 32 位 long 值的系统上,只有 31 位可用于返回返回值。在像 Linux/Alpha 这样的 64 位系统上,只有 63 位可用。这使得很难将范围的高半部分的地址传递回用户程序。

有两种方法可以解决这个问题。一种方法是将函数的一个参数设为用户空间变量的地址,用于放置返回值。另一种方法是找到另一种返回错误的方法,并制定一种处理返回值的特殊方法。据我所知,第一种方法始终是首选,因此我将不解释第二种方法。

可能的错误

在从内核读取或写入用户程序中的任何区域之前,必须调用 verify_area() 函数。在 486 或 Pentium 上的正常使用中,它对于内核稳定性的重要性不如在 386 上(尽管它有助于更清晰地检测错误并避免进程在内核模式下崩溃),但在 386 上,它对于系统稳定性绝对至关重要,因为 386 在处于“supervisor”模式(内核在其中运行的模式)时不遵守内存保护。这意味着,例如,CPU 会很高兴地从内核写入到用户空间的只读内存。

verify_area() 函数接受三个变量。第一个是 VERIFY_READVERIFY_WRITE 之一。第二个是要验证的 当前用户程序 中的地址。第三个是您希望读取或写入的内存区域的长度。如果内存区域有效,则返回 0;如果内存无效,则返回 -EFAULT。一个常见的用法如下所示

int error;
error = verify_area(VERIFY_WRITE, buf, len);
if (error)
        return error;
...

请注意,verify_area 仅验证用户内存空间中的地址,而不是内核内存空间中的地址。内核空间中的内存永远不会被交换出去,并且始终是可读写的。在 i86 系列中,内核中使用 fs 段寄存器来选择当前进程的用户空间内存。其他架构对此的处理方式不同。此功能被抽象为一些有用的函数,如下所述。

如果您在投入任何资源之前尽可能多地进行测试,那么在编写系统调用时您的工作将会轻松得多。作为一般规则,测试按以下顺序进行

运行所有必要的 verify_area 测试。

以适当的顺序执行(几乎)所有其他测试,包括正常的权限测试。

如果合适,执行 suser()fsuser() 测试。这些测试应仅在其他测试成功后调用,因为 BSD 风格的 root 权限核算可能会在某个时候添加到内核中。请参阅 include/linux/kernel.h 中的注释。

suser() 函数用于确定进程是否具有执行大多数活动的 root 权限。但是,fsuser() 函数必须用于所有与文件系统相关的权限。这种差异允许服务器在不“成为”用户的情况下(即使是短暂地)假定用户的文件权限。这很重要,因为如果服务器交换 uid,以至于它“成为”用户哪怕只是一瞬间,用户也可能以各种方式干扰进程,从而可能在许多方面破坏安全性。通过简单地使用 fsuid 和 fsgid 函数,服务器可以避免这种安全噩梦。为了使此功能正常工作,所有内核文件系统权限测试都必须使用 fsuser() 函数来测试超级用户状态,并且必须查看 current->fsuidcurrent->fsgid 以获取文件系统对象的正常权限。(有关 current 指针的更多详细信息,请参阅 include/linux/sched.h 中 task_struct 的定义。)

需要此功能的程序的一个很好的例子是 nfs 服务器。早期版本的 nfs 服务器无法使用此功能(因为它当时还不存在),并且存在几个安全漏洞。最常见的麻烦是用户注意到他们可以杀死服务器。

在检查权限和任何其他可能的错误条件后,您可能希望实际完成一些工作。除非您只是想返回一个可以容纳在 31 位(或 Linux/Alpha 的 63 位)返回值中的值,否则您将需要写入到您在函数开始时使用 verify_area 函数检查的用户内存。您不能只是将指向用户空间内存的指针用作普通指针。相反,您必须使用一组特殊函数来访问它。如果您想读取任何用户空间内存以执行系统调用,则需要使用一组类似的函数来执行此操作。

在旧版本的 Linux(直到 1.2.x)中,您必须指定您正在进行的内存访问类型。有 6 个用于单内存访问的函数:get_fs_byteget_fs_wordget_fs_longput_fs_byteput_fs_wordput_fs_long。这些名称(以及将 fs 替换为 user 的名称)在新内核中仍然受支持,但从 Linux 1.3 开始,它们已被弃用。应改为使用 get_userput_user 函数。它们更易于阅读,并且在大多数情况下更易于使用,但由于它们依赖于传递给它们的指针类型,因此它们不能容忍马虎的指针使用。(这可能是一件好事,因为 Linux 现在可以在小端和大端计算机上运行,而大端计算机也不能容忍马虎的指针使用。)

内存块访问例程自最早版本以来一直保持不变,即使它们的名称仍然包含字母“fs”;memcpy_tofs 用于将内存块复制到用户空间,而 memcpy_fromfs 用于将用户内存块复制到内核空间中的内存。

所有内存访问例程都在 include/asm/segment.h 中定义——即使在没有分段的架构上也是如此。在所有非 Intel 架构上,这些函数本质上都是空函数,因为它们不实现分段。

到目前为止,您只是在内核中实现了一个新函数。仅仅在名称前面加上 sys_ 并不能使从用户代码调用该函数成为可能。

您需要在内核中进行两处添加。第一个是在 include/linux/unistd.h 中,就在末尾附近。您需要找到以 #define __NR 开头的最后一行,并添加您自己的

#define __NR_name     ###

其中 ### 是比前一个最后一个系统调用号大一的数字。在 1.2.9 版本中,这将是 141。

第二个更改必须在多个文件中进行,每个 Linux 运行的架构对应一个文件。每个文件 arch/*/kernel/entry.S 都需要在其系统调用表中添加一个条目。系统调用表保存在文件末尾,您只需在 .space 行之前的表末尾添加一个条目,并更改最末尾的 .space 公式以反映新的系统调用数。

调用您的系统调用

现在您 可以 从用户代码调用您的新函数,但是如何调用呢?您不能简单地声明 extern int sys_name(int arg); 并链接。相反,您必须 #include <unistd.h> 并使用适当的 syscallX() 宏,其中 X 是系统调用接受的参数数量。syscallX() 宏实际上在 include/asm/unistd.h 中定义,该文件由 <unistd.h> 自动包含。

如果您的系统调用声明为

asmlinkage int sys_name(void);

syscall0() 调用非常简单

_syscall0(int, name)

(注意前导下划线)。这由 C 预处理器转换为

int name(void)
{
long __res;
__asm__ volatile ("int $0x80"
        : "=a" (__res)
        : "0" (__NR_name));
if (__res >= 0)
        return (int) __res;
errno = -__res;
return -1;
}

在 Linux/i86 上。因为它使用汇编语言,所以在其他架构上会有所不同。幸运的是,这并不重要。重要的一点是它创建了一个名为 name 的函数,该函数生成一个中断(还记得关于中断的“善意的谎言”吗?系统调用也是中断)来调用系统调用,然后如果答案为正数,则返回结果;如果答案为负数(设置了高位),则返回 -1,并将 errno 设置为非负错误号。

如果您的函数有两个参数

asmlinkage int sys_name(int num, struct foo *bar);

您将改为使用这个

_syscall2(int, name, int, num, struct foo *, bar)

这将展开为

int name(int num, struct foo * bar)
{
long __res;
__asm__ volatile ("int $0x80"
        : "=a" (__res)
        : "0" (__NR_name),
          "b" ((long)(num)), "c" ((long)(bar)));
if (__res >= 0)
        return (int) __res;
errno = -__res;
return -1;
}

请注意指定宏参数的不寻常方式,其中返回类型和函数名称之后是每个系统调用参数的类型和名称的单独参数。弄清楚如何指定带有 1、3、4 或 5 个参数的系统调用留给读者作为练习。

对于好奇的人:在 Linux/i86 上还有另一种调用系统调用的方式。基于 iBCS2 的程序使用 lcall 7,0 指令而不是 int $0x80 指令来调用系统调用。lcall 指令比 int 指令花费的时间稍长,这就是为什么它是 Linux 上的默认系统调用机制,但两者都受支持。lcall 指令不完全是中断,尽管它的作用很像中断;从技术上讲,它是一个“调用门”。所以我的“善意的谎言”毕竟不是真正的谎言。

Michael K. JohnsonLinux Journal 的编辑,并在业余时间假装自己是 Linux 大师。可以通过电子邮件 johnsonm@ssc.com 与他联系。

加载 Disqus 评论