系统调用是如何工作的?

44
我了解到每个用户进程拥有一个地址空间,其中包含有效的内存位置,该进程可以引用。我知道进程可以调用系统调用并向其传递参数,就像任何其他库函数一样。这似乎表明所有系统调用都在进程地址空间中共享内存等,但也许这只是一个错觉,因为在高级编程语言中,系统调用看起来像任何其他函数,当进程调用它时。
但是,现在让我更深入地分析一下发生了什么。编译器如何编译系统调用?它可能会将进程提供的系统调用名称和参数推送到堆栈中,然后将汇编指令例如“TRAP”放置在程序中--基本上是调用软件中断的汇编指令。
硬件执行这个TRAP汇编指令,首先将模式位从用户切换到内核,然后将代码指针设置为中断服务例程的开始位置。从此处开始,在内核模式下执行ISR,该ISR从堆栈中提取参数(这是可能的,因为内核可以访问任何内存位置,甚至是由用户进程拥有的内存位置),执行系统调用,并最终释放CPU,再次切换模式位,用户进程从离开的地方继续执行。
我的理解正确吗?
下面是我理解的简单图示: enter image description here
6个回答

17

你的理解很接近,关键在于大多数编译器不会直接写入系统调用,因为程序调用的函数(例如getpid(2)chdir(2)等)实际上是由标准C库提供的。标准C库包含系统调用的代码,无论它是通过INT 0x80还是SYSENTER进行调用。做出系统调用而没有库来完成这项工作的程序是奇怪的。(即使perl提供了一个可以直接进行系统调用的syscall()函数!令人发狂,对吧?)

接下来是内存。操作系统内核有时可以轻松访问用户进程内存空间。当然,保护模式是不同的,必须将用户提供的数据“复制”到内核的受保护地址空间中,以防止在“系统调用正在执行”期间修改用户提供的数据:

static int do_getname(const char __user *filename, char *page)
{
    int retval;
    unsigned long len = PATH_MAX;

    if (!segment_eq(get_fs(), KERNEL_DS)) {
        if ((unsigned long) filename >= TASK_SIZE)
            return -EFAULT;
        if (TASK_SIZE - (unsigned long) filename < PATH_MAX)
            len = TASK_SIZE - (unsigned long) filename;
    }

    retval = strncpy_from_user(page, filename, len);
    if (retval > 0) {
        if (retval < len)
            return 0;
        return -ENAMETOOLONG;
    } else if (!retval)
        retval = -ENOENT;
    return retval;
}

这不是一个系统调用本身,而是由系统调用函数调用的一个辅助函数,它将文件名复制到内核地址空间中。它检查整个文件名是否在用户数据范围内,调用从用户空间复制字符串的函数,并在返回之前执行一些合理性检查。

get_fs()和类似的函数是Linux x86根源的遗留物。这些函数对所有架构都有工作实现,但名称仍然过时。

所有额外的段处理是因为内核和用户空间可能共享某些可用地址空间。在32位平台上(数字易于理解),内核通常具有1GB虚拟地址空间,用户进程通常具有3GB虚拟地址空间。

当进程调用内核时,内核将“修正”页面表权限,以允许其访问整个范围,并获得预填充的TLB条目,以访问用户提供的内存。非常成功。但是,当内核必须切换回用户空间时,它必须刷新TLB以删除对内核地址空间页面的缓存特权。

但是,1GB虚拟地址空间不足以容纳巨型机器上的所有内核数据结构。维护缓存文件系统和块设备驱动程序、网络堆栈以及系统中所有进程的内存映射的元数据可能需要大量数据。

因此,不同的“分割”可用:两个gigs用于用户,两个gigs用于内核,一个gig用于用户,三个gigs用于内核等。随着内核空间的增加,用户进程空间将减少。因此,有一个4:4内存分割,为用户进程提供四GB,为内核提供四GB,并且内核必须操作段描述符才能访问用户内存。在进入和退出系统调用时刷新TLB,这是一个相当大的速度惩罚。但它可以让内核维护更大的数据结构。

64位平台的更大页面表和地址范围可能会使所有前述内容看起来都有点古老了。我真心希望如此。


在带有ASID的TLB上,刷新不应该是必要的。但除此之外,这是一个非常全面的答案。 - ninjalj
@ninjalj,哦,那会非常有帮助。让我猜猜,它们主要在PAE系统或64位平台上可用? :) - sarnold
它们至少在MIPS上可用。 - ninjalj

10
是的,你基本上理解得很对。不过有一个细节,当编译器编译系统调用时,它会使用系统调用的编号而不是名称。例如,这里是一个Linux系统调用列表(虽然是旧版本,但概念仍然相同)。

5

实际上,你调用的是C运行时库。不是编译器插入TRAP,而是C库将TRAP包装成库调用。你对此的理解是正确的。


请问您能否详细说明一下什么是“C运行时库”?此外,用户程序是否可以直接调用系统调用而不经过任何库?是这样吗? - xyz
@p2pnode:C运行时库是C程序通常链接的库,在Unix中通常称为“libc”。是的,程序可以直接调用系统调用。 - ninjalj
如果程序可以直接调用系统调用,那么这些调用将如何编译?在这种情况下,C运行时库似乎没有任何作用..? - xyz
2
@p2pnode:你需要编写内嵌汇编语句来调用系统调用。 - ninjalj

3

通常情况下,普通程序不会“编译系统调用”。对于每个系统调用,通常有一个相应的用户空间库函数(在类Unix系统上通常实现在libc中)。例如,mkdir()函数将其参数转发到mkdir系统调用。

在GNU系统上(我猜其他系统也是一样),mkdir()函数使用了一个syscall()函数。syscall函数/宏通常是用C实现的。例如,请查看sysdeps/unix/sysv/linux/i386/sysdep.h中的INTERNAL_SYSCALLsysdeps/unix/sysv/linux/i386/sysdep.S中的syscall(glibc)。

现在,如果您查看sysdeps/unix/sysv/linux/i386/sysdep.h,您会发现内核调用是通过ENTER_KERNEL完成的,这在历史上是通过i386 CPU中的中断0x80来调用的。现在它调用一个函数(我猜它是在linux-gate.so中实现的,这是由内核映射的虚拟SO文件,它包含了为您的CPU类型进行系统调用的最有效方式)。


啊哈!这就是我花了半个小时寻找的缺失链接。 :D - sarnold

3
如果您想直接从程序中执行系统调用,可以轻松地实现。这是与平台相关的,但假设您想要从文件中读取内容。每个系统调用都有一个号码。在这种情况下,您将read_from_file系统调用的编号放置在寄存器EAX中。系统调用的参数放置在不同的寄存器或堆栈中(取决于系统调用)。在正确填充寄存器的数据并准备好执行系统调用后,您执行指令INT 0x80(取决于体系结构)。 该指令是一种中断,导致控制转移到操作系统。然后,操作系统在寄存器EAX中识别系统调用号码,相应地执行并将控制权返回给执行系统调用的进程。
系统调用的使用方式易于更改,并且取决于所选平台。通过使用提供易于接口的库来进行这些系统调用,您可以使程序更加独立于平台,并且代码将更加易读和快速编写。考虑直接在高级语言中实现系统调用。您需要类似内联汇编的东西来确保数据被放置在正确的寄存器中。

0

是的,你的理解完全正确,C程序可以调用直接系统调用,当该系统调用发生时,它可以是一系列调用,直到汇编Trap。我认为你的理解能够极大地帮助新手。请查看这段代码,在其中我调用了“system”系统调用。

#include < stdio.h  >    
#include < stdlib.h >    
int main()    
{    
    printf("Running ps with "system" system call ");    
    system("ps ax");    
    printf("Done.\n");    
    exit(0);    
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接