Linux的系统调用机制总结
参考:深入Linux系统调用:原理、机制与实战全解析 - 知乎
系统调用的定义
系统调用,顾名思义,说的是操作系统提供给用户程序调用的一组“特殊”接口。用户程序可以通过这组“特殊”接口来获得操作系统内核提供的服务,比如用户可以通过文件系统相关的调用请求系统打开文件、关闭文件或读写文件,可以通过时钟相关的系统调用获得系统时间或设置定时器等。
从逻辑上来说,系统调用可被看成是一个内核与用户空间程序交互的接口——它好比一个中间人,把用户进程的请求传达给内核,待内核把请求处理完毕后再将处理结果送回给用户空间。系统服务之所以需要通过系统调用来提供给用户空间的根本原因是为了对系统进行“保护”,因为我们知道Linux的运行空间分为内核空间与用户空间,它们各自运行在不同的级别中,逻辑上相互隔离。所以用户进程在通常情况下不允许访问内核数据,也无法使用内核函数,它们只能在用户空间操作用户数据,调用用户空间函数。
比如我们熟悉的“hello world”程序(执行时)就是标准的用户空间进程,它使用的打印函数printf就属于用户空间函数,打印的字符“hello word”字符串也属于用户空间数据。但是很多情况下,用户进程需要获得系统服务(调用系统程序),这时就必须利用系统提供给用户的“特殊接口”——系统调用,用户访问内核的路径是事先规定好的,只能从规定位置进入内核,而不准许肆意跳入内核。有了这样的陷入内核的统一访问路径限制才能保证内核安全无虞。
巨大的误区
以前,在学习open/read/write/close等接口时,老师们都说这是系统调用,然后fopen/fread/fwrite/fclose是C库函数,并且,查看系统调用手册时,使用man 2来查看,查看C库手册时,使用man 3来查看。
但是,直到今天我才发现,open/read/write/close这几个接口都是C库函数,属于标准的posix接口!!!!!!
以前都没想过这个问题,用户态是不能直接调用内核态的接口的,所以open接口肯定不是在内核实现的,那么只能是在用户态实现的,那么在哪实现的呢?就是C库提供的。
open()
、read()
、write()
等函数属于 C 标准库对系统调用的封装,而非系统调用本身。它们在内核与用户程序之间扮演中间层角色,主要目的是简化接口、处理错误和屏蔽底层架构差异。以
open()
为例说明下是如何封装的,并且是如何触发内核的系统调用的。C 库中的
open()
函数定义在<fcntl.h>
头文件中,其底层通过汇编触发系统调用:# glibc源码(sysdeps/unix/sysv/linux/x86_64/open.S) ENTRY (open)mov $__NR_open, %rax # 设置系统调用号(2)mov %rdi, %rsi # 参数1:pathnamemov %rsi, %rdx # 参数2:flagsmov %edx, %r10 # 参数3:mode(如果需要)syscall # 触发系统调用cmp $-4095, %rax # 检查是否为错误码jae SYSCALL_ERROR_LABEL # 如果是,跳转到错误处理ret # 否则返回结果 END (open)
内核中的
sys_open()
最终调用do_sys_open()
,包含严格的权限检查和资源分配:// 内核源码(fs/open.c) long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode) {// 1. 验证用户空间指针合法性if (!access_ok(VERIFY_READ, filename, PATH_MAX))return -EFAULT;// 2. 分配文件描述符int fd = get_unused_fd_flags(flags);if (fd < 0)return fd;// 3. 打开文件(调用具体文件系统的open函数)struct file *f = do_filp_open(dfd, filename, &op);if (IS_ERR(f)) {put_unused_fd(fd);return PTR_ERR(f);}// 4. 将文件与描述符关联fd_install(fd, f);return fd; // 返回文件描述符给用户空间 }
除了文件操作函数,许多 C 库函数都是系统调用的封装:
C 库函数 对应系统调用 功能 fork() sys_fork 创建进程 execve() sys_execve 执行程序 getpid() sys_getpid 获取进程 ID close() sys_close 关闭文件描述符 mmap() sys_mmap 内存映射 open既然是C库函数,那为什么man 2查看,而不是man 3查看?
man
手册的章节编号有明确的功能划分,核心规则是:
第 2 节:描述系统调用(内核提供的接口,如
sys_open
、sys_fork
)。第 3 节:描述库函数(用户态库提供的接口,与内核无直接关联,如
printf
、strcpy
)。但这里的 “系统调用” 并非仅指内核中的实现(如
sys_open
),而是包含所有直接封装系统调用的库函数—— 因为这些函数本质上是系统调用的 “代理”,功能完全依赖内核实现,与纯粹的用户态库函数(如字符串处理函数)有本质区别。
系统调用的实现原理
大体原理如下所示:
系统调用号与系统调用表
在 Linux 系统调用的实现过程中,系统调用号与系统调用表扮演着不可或缺的关键角色。
系统调用号,简单来说,是一个独一无二的标识符,就像每个人都有一个独特的身份证号码一样,每个系统调用都被赋予了一个唯一的系统调用号。在 x86 架构中,系统调用号通常是通过 eax 寄存器传递给内核的。在用户空间执行系统调用之前,会将对应的系统调用号存入 eax 寄存器,这样当系统进入内核态时,内核就能依据这个系统调用号,精准地知晓用户程序究竟请求的是哪一个系统调用。
以常见的文件操作相关系统调用为例,打开文件的系统调用 open,它拥有特定的系统调用号,当用户程序需要打开文件时,会将 open 系统调用对应的系统调用号存入 eax 寄存器,再发起系统调用,内核就能根据这个号码识别出用户的意图是打开文件。这种通过唯一编号来标识系统调用的方式,极大地提高了系统调用处理的效率和准确性,避免了因名称解析等复杂操作带来的性能损耗 。
而系统调用表,则是一个存储着系统调用函数指针的数组,它就像是一本精心编制的索引目录,数组的每个元素都是一个指向特定系统调用处理函数的指针。在 x86 架构下,系统调用表的定义和实现与具体的内核版本和架构相关。在 64 位系统中,系统调用表定义在arch/x86/kernel/syscall_64.c文件中 ,其数组名为sys_call_table,该数组的大小为__NR_syscall_max + 1,其中__NR_syscall_max是一个宏,在 64 位模式下,它的值为 542 ,这个宏定义于include/generated/asm-offsets.h文件,该文件是在 Kbuild 编译后生成的。系统调用表中的元素类型为sys_call_ptr_t,这是通过 typedef 定义的函数指针,它指向的是具体的系统调用处理函数。当内核接收到系统调用请求,并获取到系统调用号后,就会以这个系统调用号作为索引,迅速在系统调用表中找到对应的函数指针,进而调用相应的系统调用处理函数,执行具体的系统调用操作 。
假设系统调用号为n,那么系统调用表sys_call_table中第n个元素sys_call_table[n]就指向了处理该系统调用的函数。如果系统调用号为 1,对应sys_call_table[1],它指向的就是处理 write 系统调用的函数,当内核根据系统调用号 1 在表中找到这个指针并调用相应函数时,就能完成实际的文件写入操作。系统调用号与系统调用表的紧密配合,构成了 Linux 系统调用实现的重要基础,它们使得内核能够高效、准确地响应用户程序的各种系统调用请求,保障了系统的稳定运行和高效工作 。
系统调用处理程序
系统调用处理程序是系统调用实现过程中的核心环节,它负责处理用户程序发起的系统调用请求,执行相应的内核服务例程,并返回处理结果。当用户程序发起系统调用时,会触发软中断,从而进入内核态,开始执行系统调用处理程序。
系统调用处理程序的工作流程严谨而有序。当 CPU 响应软中断进入内核态后,首先会保存当前用户程序的寄存器状态。这一步至关重要,因为寄存器中存储着用户程序当前的执行状态和相关数据,保存这些寄存器状态就如同为用户程序的执行进度拍了一张 “快照”,以便在系统调用完成后能够准确地恢复到调用前的状态,继续执行用户程序。在 x86 架构中,通常会将寄存器的值压入到核心栈中,这些寄存器包括通用寄存器如 eax、ebx、ecx、edx 等,以及程序计数器(记录下一条要执行的指令地址)等关键寄存器。
保存完寄存器状态后,系统调用处理程序会根据用户程序传递过来的系统调用号,在系统调用表中查找对应的系统调用处理函数。这个查找过程就像是在一本索引清晰的大字典中查找特定的词条,系统调用号就是词条的索引,通过它能够快速定位到系统调用表中对应的函数指针,进而找到真正执行系统调用功能的处理函数。如果系统调用号为 5,表示打开文件的系统调用,处理程序就会根据这个 5 作为索引,在系统调用表中找到指向sys_open函数的指针,这个sys_open函数就是专门负责处理打开文件系统调用的函数 。
找到对应的处理函数后,系统调用处理程序就会调用该函数,执行相应的内核服务例程。在执行过程中,处理函数会根据系统调用的具体需求,访问和操作内核资源,完成用户程序请求的任务。在执行文件写入的系统调用时,处理函数会根据传递过来的文件描述符、数据缓冲区和数据长度等参数,在内核空间中进行实际的文件写入操作,访问底层的磁盘设备,将数据存储到指定的文件中 。
当内核服务例程执行完毕后,系统调用处理程序会将执行结果返回给用户程序。在返回之前,会先恢复之前保存的用户程序寄存器状态,就像把之前拍的 “快照” 重新还原,让 CPU 回到系统调用前的状态。然后,CPU 会从内核态切换回用户态,继续执行用户程序中系统调用之后的代码,将系统调用的执行结果传递给用户程序,用户程序就可以根据这个结果进行后续的处理 。
系统调用处理程序的工作流程确保了系统调用的安全、高效执行,它在用户程序与内核之间搭建起了一座可靠的桥梁,使得用户程序能够在不直接访问内核资源的情况下,通过系统调用获取内核提供的各种服务,保障了系统的稳定性和安全性 。
参数传递与返回值处理
在系统调用过程中,参数传递和返回值处理是两个关键环节,它们确保了用户程序与内核之间能够准确、有效地进行数据交互。
系统调用的参数传递方式与硬件架构密切相关。以常见的 x86 架构为例,在 32 位系统中,当用户程序发起系统调用时,参数通常通过寄存器来传递。具体来说,ebx、ecx、edx、esi 和 edi 这几个寄存器按照顺序存放前五个参数。如果系统调用需要传递六个或更多参数,由于寄存器数量有限,此时会用一个单独的寄存器(通常是 eax)存放指向所有这些参数在用户空间地址的指针,然后通过内存空间进行参数传递。在执行一个需要传递多个参数的文件写入系统调用时,前五个参数(如文件描述符、数据缓冲区指针、数据长度等)可能分别存放在 ebx、ecx、edx、esi 和 edi 寄存器中,如果还有其他参数,就会将这些参数在用户空间的地址存放在 eax 寄存器中,内核可以根据这个地址从用户空间获取完整的参数 。
在 64 位的 x86 架构系统中,参数传递规则有所不同。前 6 个整数或指针参数会在寄存器 RDI、RSI、RDX、RCX、R8、R9 中传递,对于嵌套函数,R10 用作静态链指针,其他参数则在堆栈上传递。这种参数传递方式充分利用了 64 位架构下寄存器数量增加的优势,提高了参数传递的效率和灵活性 。
关于系统调用的返回值,也有着明确的约定。在 Linux 系统中,通常用一个负的返回值来表明系统调用执行过程中出现了错误。返回值为 - 1 可能表示权限不足,-2 可能表示文件不存在等。不同的负值对应着不同的错误类型,这些错误类型的定义可以在errno.h头文件中找到。当用户程序接收到负的返回值时,可以通过查看errno变量的值来确定具体的错误原因,并且可以调用perror()库函数,将errno的值翻译成用户可以理解的错误字符串,以便进行错误处理 。
如果系统调用执行成功,返回值通常为正值或 0。对于一些返回数据的系统调用,如读取文件内容的系统调用,返回值可能是实际读取到的字节数;而对于一些只执行操作不返回具体数据的系统调用,成功时返回值可能为 0,表示操作顺利完成。在执行读取文件系统调用时,如果成功读取到数据,返回值就是实际读取的字节数,用户程序可以根据这个返回值来判断读取操作是否成功以及获取到的数据量 。
参数传递和返回值处理机制是系统调用实现的重要组成部分,它们确保了用户程序与内核之间能够准确地传递数据和信息,使得系统调用能够按照预期的方式执行,并将结果反馈给用户程序,为应用程序的正确运行提供了坚实的保障 。
Linux下系统调用的三种方法
以下对这三种方法进行说明
通过 glibc 提供的库函数
glibc 是 Linux 下使用的开源的标准 C 库,它是 GNU 发布的 libc 库,即运行时库。glibc 为程序员提供丰富的 API(Application Programming Interface),除了例如字符串处理、数学运算等用户态服务之外,最重要的是封装了操作系统提供的系统服务,即系统调用的封装。那么glibc提供的系统调用API与内核特定的系统调用之间的关系是什么呢?
通常情况,每个特定的系统调用对应了至少一个 glibc 封装的库函数,如系统提供的打开文件系统调用 sys_open 对应的是 glibc 中的 open 函数;
其次,glibc 一个单独的 API 可能调用多个系统调用,如 glibc 提供的 printf 函数就会调用如 sys_open、sys_mmap、sys_write、sys_close 等等系统调用;
另外,多个 API 也可能只对应同一个系统调用,如glibc 下实现的 malloc、calloc、free 等函数用来分配和释放内存,都利用了内核的 sys_brk 的系统调用。
举例来说,我们通过 glibc 提供的chmod 函数来改变文件 etc/passwd 的属性为 444:
#include <sys/types.h> #include <sys/stat.h> #include <errno.h> #include <stdio.h> int main() {int rc;rc = chmod("/etc/passwd", 0444);if (rc == -1)fprintf(stderr, "chmod failed, errno = %d\n", errno);elseprintf("chmod success!\n");return 0; }
在普通用户下编译运用,输出结果为:
chmod failed, errno = 1
上面系统调用返回的值为-1,说明系统调用失败,错误码为1,在 /usr/include/asm-generic/errno-base.h 文件中有如下错误代码说明:
#define EPERM 1 /* Operation not permitted */
即无权限进行该操作,我们以普通用户权限是无法修改 /etc/passwd 文件的属性的,结果正确。
使用 syscall 直接调用
使用上面的方法有很多好处,首先你无须知道更多的细节,如 chmod 系统调用号,你只需了解 glibc 提供的 API 的原型;其次,该方法具有更好的移植性,你可以很轻松将该程序移植到其他平台,或者将 glibc 库换成其它库,程序只需做少量改动。
但有点不足是,如果 glibc 没有封装某个内核提供的系统调用时,我就没办法通过上面的方法来调用该系统调用。如我自己通过编译内核增加了一个系统调用,这时 glibc 不可能有你新增系统调用的封装 API,此时我们可以利用 glibc 提供的syscall 函数直接调用。该函数定义在 unistd.h 头文件中,函数原型如下:
long int syscall (long int sysno, ...)
sysno 是系统调用号,每个系统调用都有唯一的系统调用号来标识。在 sys/syscall.h 中有所有可能的系统调用号的宏定义。
... 为剩余可变长的参数,为系统调用所带的参数,根据系统调用的不同,可带0~5个不等的参数,如果超过特定系统调用能带的参数,多余的参数被忽略。
返回值 该函数返回值为特定系统调用的返回值,在系统调用成功之后你可以将该返回值转化为特定的类型,如果系统调用失败则返回 -1,错误代码存放在 errno 中。
还以上面修改 /etc/passwd 文件的属性为例,这次使用 syscall 直接调用:
#include <stdio.h> #include <unistd.h> #include <sys/syscall.h> #include <errno.h> int main() {int rc;rc = syscall(SYS_chmod, "/etc/passwd", 0444);if (rc == -1)fprintf(stderr, "chmod failed, errno = %d\n", errno);elseprintf("chmod succeess!\n");return 0; }
在普通用户下编译执行,输出的结果与上例相同。
通过 int 指令陷入
如果我们知道系统调用的整个过程的话,应该就能知道用户态程序通过软中断指令int 0x80 来陷入内核态(在Intel Pentium II 又引入了sysenter指令),参数的传递是通过寄存器,eax 传递的是系统调用号,ebx、ecx、edx、esi和edi 来依次传递最多五个参数,当系统调用返回时,返回值存放在 eax 中。
仍然以上面的修改文件属性为例,将调用系统调用那段写成内联汇编代码:
#include <stdio.h> #include <sys/types.h> #include <sys/syscall.h> #include <errno.h> int main() {long rc;char *file_name = "/etc/passwd";unsigned short mode = 0444;asm("int $0x80": "=a" (rc): "0" (SYS_chmod), "b" ((long)file_name), "c" ((long)mode));if ((unsigned long)rc >= (unsigned long)-132) {errno = -rc;rc = -1;}if (rc == -1)fprintf(stderr, "chmode failed, errno = %d\n", errno);elseprintf("success!\n");return 0; }
如果 eax 寄存器存放的返回值(存放在变量 rc 中)在 -1~-132 之间,就必须要解释为出错码(在/usr/include/asm-generic/errno.h 文件中定义的最大出错码为 132),这时,将错误码写入 errno 中,置系统调用返回值为 -1;否则返回的是 eax 中的值。
上面程序在 32位Linux下以普通用户权限编译运行结果与前面两个相同!