Linux 系统调用解析:用户与内核交互的纽带

在Linux操作系统的广袤天地里,系统调用宛如一座连接用户程序与内核的关键桥梁。当你在终端敲下指令,或是启动各类应用程序,背后都有系统调用忙碌的身影。它不仅是内核为用户空间提供服务的核心接口,更是深入理解 Linux 运行机制的一把重要钥匙。将操作系统比作一座精密运转的工厂,内核无疑是掌控全局的中枢,而系统调用则是连接生产线(用户程序)与中枢的通道。用户程序借助它向内核发起服务请求,无论是文件操作、进程管理,还是内存分配等复杂任务,系统调用都扮演着无可替代的角色。

从本质而言,系统调用是内核精心提供的接口,常以 C 函数的形式呈现,极大地方便了开发者调用系统功能。在 Linux 系统中,它更是构建起用户程序安全访问硬件资源的关键通道。由于硬件资源由内核底层操作,若用户程序直接访问,极易引发系统不稳定甚至崩溃。而系统调用如同忠诚的门卫,有效保障了系统的安全与稳定。内核借助它合理调度 CPU 时间,让多个进程能够和谐协同工作,显著提升系统并发处理能力,确保系统始终流畅运行。可以说,掌握系统调用,就能更好地与 Linux 交互,充分释放系统潜能。

Part1Linux 系统调用是什么?

1.1系统调用的定义

在 Linux 操作系统中,系统调用是用户程序与内核交互的关键接口,是用户程序访问内核服务的唯一合法途径。内核作为操作系统的核心,负责管理系统的硬件资源和提供各种基本服务,如进程管理、内存管理、文件系统管理等。然而,用户程序不能直接访问内核的资源和服务,必须通过系统调用这个桥梁来请求内核执行特定的操作。

可以将系统调用类比为一个公司的客户服务热线。用户程序就像是公司的客户,有各种需求;而内核则像是公司内部的核心业务部门,负责处理各种关键事务。客户不能直接进入公司内部指挥业务部门工作,而是需要拨打客户服务热线(系统调用),向接线员(内核接口)提出请求,接线员再将请求传达给相应的业务部门(内核服务)进行处理 。

1.2系统调用的重要性

系统调用在操作系统中占据着举足轻重的地位,是操作系统提供服务和实现功能的基础机制,在资源管理、进程控制、文件操作、设备管理等众多方面发挥着关键作用。

在资源管理方面,系统调用允许用户程序请求内核分配和管理系统资源,如内存、CPU 时间片、文件描述符等。通过系统调用,内核可以根据系统的整体状态和资源使用情况,合理地分配资源,确保各个进程能够公平、有效地使用资源,避免资源冲突和浪费。比如,当一个程序需要使用内存时,它会通过系统调用向内核请求分配一定大小的内存空间,内核会在内存管理模块中进行相应的处理,为程序分配合适的内存块,并返回一个指向该内存块的指针,供程序使用。

进程控制是操作系统的核心功能之一,系统调用为进程的创建、执行、终止和等待等操作提供了接口。例如,fork () 系统调用用于创建一个新的进程,它是一个与父进程几乎完全相同的子进程,拥有自己独立的进程标识符(PID)。父进程可以通过 wait () 或 waitpid () 系统调用等待子进程结束,并获取子进程的退出状态,以便进行后续的处理。这些系统调用使得开发者能够方便地控制进程的生命周期,实现多进程编程,提高程序的并发处理能力。

文件操作是日常计算机使用中最常见的任务之一,系统调用为文件的打开、关闭、读取、写入、定位等操作提供了统一的接口。应用程序通过 open () 系统调用打开一个文件,并获得一个文件描述符,后续对文件的所有操作都通过这个文件描述符进行。write () 系统调用用于向文件中写入数据,read () 系统调用则用于从文件中读取数据。这些系统调用屏蔽了底层文件系统的复杂性,使得开发者可以专注于文件操作的逻辑,而不必关心具体的文件存储和访问方式。

在设备管理方面,系统调用为应用程序提供了访问硬件设备的能力。无论是磁盘、打印机、网络接口等设备,都可以通过相应的系统调用来进行控制和交互。例如,ioctl () 系统调用常用于对设备进行控制操作,如设置设备的参数、获取设备的状态信息等。通过系统调用,应用程序可以与各种硬件设备进行通信,实现设备的驱动和管理,使得操作系统能够充分发挥硬件设备的功能。

1.3为什么需要系统调用

linux内核中设置了一组用于实现系统功能的子程序,称为系统调用。系统调用和普通库函数调用非常相似,只是系统调用由操作系统核心提供,运行于内核态,而普通的函数调用由函数库或用户自己提供,运行于用户态。

一般的,进程是不能访问内核的。它不能访问内核所占内存空间也不能调用内核函数。CPU硬件决定了这些(这就是为什么它被称作“保护模式”)。

为了和用户空间上运行的进程进行交互,内核提供了一组接口。透过该接口,应用程序可以访问硬件设备和其他操作系统资源。这组接口在应用程序和内核之间扮演了使者的角色,应用程序发送各种请求,而内核负责满足这些请求(或者让应用程序暂时搁置)。实际上提供这组接口主要是为了保证系统稳定可靠,避免应用程序肆意妄行,惹出大麻烦。

系统调用在用户空间进程和硬件设备之间添加了一个中间层,该层主要作用有三个:

它为用户空间提供了一种统一的硬件的抽象接口。比如当需要读些文件的时候,应用程序就可以不去管磁盘类型和介质,甚至不用去管文件所在的文件系统到底是哪种类型。系统调用保证了系统的稳定和安全。作为硬件设备和应用程序之间的中间人,内核可以基于权限和其他一些规则对需要进行的访问进行裁决。举例来说,这样可以避免应用程序不正确地使用硬件设备,窃取其他进程的资源,或做出其他什么危害系统的事情。每个进程都运行在虚拟系统中,而在用户空间和系统的其余部分提供这样一层公共接口,也是出于这种考虑。如果应用程序可以随意访问硬件而内核又对此一无所知的话,几乎就没法实现多任务和虚拟内存,当然也不可能实现良好的稳定性和安全性。在Linux中,系统调用是用户空间访问内核的惟一手段;除异常和中断外,它们是内核惟一的合法入口。

Part2用户空间与内核空间

对 32 位操作系统而言,它的寻址空间(虚拟地址空间,或叫线性地址空间)为 4G(2的32次方)。也就是说一个进程的最大地址空间为 4G。操作系统的核心是内核(kernel),它独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证内核的安全,现在的操作系统一般都强制用户进程不能直接操作内核。具体的实现方式基本都是由操作系统将虚拟地址空间划分为两部分,一部分为内核空间,另一部分为用户空间。针对 Linux 操作系统而言,最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF)由内核使用,称为内核空间。而较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF)由各个进程使用,称为用户空间。

图片

2.1用户空间

用户空间是应用程序运行的区域,它为应用程序提供了一个相对独立和受保护的执行环境。在 32 位的 Linux 系统中,通常将虚拟地址空间的低 3GB 划分为用户空间,每个用户进程都拥有自己独立的用户空间,这使得不同进程之间的地址空间相互隔离,一个进程的崩溃不会影响其他进程的正常运行,就像一个个独立的小房间,每个房间里的活动互不干扰 。

用户空间的应用程序具有受限的访问权限,它们无法直接操作硬件资源,如CPU、内存、I/O设备等。这是因为硬件资源的直接操作需要较高的权限,而用户空间的应用程序运行在较低的特权级别(如 x86 架构中的 Ring 3),以防止应用程序对系统造成破坏。如果把操作系统比作一个大型工厂,硬件资源就是工厂里的核心生产设备,而用户空间的应用程序就像是工厂里的普通工人,他们不能随意操作核心生产设备,必须通过特定的流程(系统调用)向管理层(内核)提出申请 。

2.2内核空间

内核空间是操作系统内核的执行区域,是操作系统的核心部分,负责管理系统的硬件资源和提供各种基本服务。在 32 位的 Linux 系统中,虚拟地址空间的高 1GB 通常被划分为内核空间,所有进程共享这部分内核空间。内核空间就像是工厂的管理层,拥有最高的权限,可以直接访问和控制所有硬件资源,执行特权指令。

内核空间拥有完全控制硬件资源的权限,它可以直接操作 CPU、内存、I/O 设备等,负责进程调度、内存管理、文件系统管理、设备驱动等关键功能。例如,在内核空间中,内核可以根据进程的优先级和资源需求,合理地分配 CPU 时间片,确保各个进程能够公平地使用 CPU 资源;内核还负责管理物理内存和虚拟内存之间的映射关系,为进程分配和回收内存;此外,内核还通过设备驱动程序与各种硬件设备进行交互,实现对硬件设备的控制和管理。

2.3为什么需要区分内核空间与用户空间

在 CPU 的所有指令中,有些指令是非常危险的,如果错用,将导致系统崩溃,比如清内存、设置时钟等。如果允许所有的程序都可以使用这些指令,那么系统崩溃的概率将大大增加。所以,CPU 将指令分为特权指令和非特权指令,对于那些危险的指令,只允许操作系统及其相关模块使用,普通应用程序只能使用那些不会造成灾难的指令。比如 Intel 的 CPU 将特权等级分为 4 个级别:Ring0~Ring3。

其实 Linux 系统只使用了 Ring0 和 Ring3 两个运行级别(Windows 系统也是一样的)。当进程运行在 Ring3 级别时被称为运行在用户态,而运行在 Ring0 级别时被称为运行在内核态。

当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。

在内核态下,进程运行在内核地址空间中,此时 CPU 可以执行任何指令。运行的代码也不受任何的限制,可以自由地访问任何有效地址,也可以直接进行端口的访问。

在用户态下,进程运行在用户地址空间中,被执行的代码要受到 CPU 的诸多检查,它们只能访问映射其地址空间的页表项中规定的在用户态下可访问页面的虚拟地址,且只能对任务状态段(TSS)中 I/O 许可位图(I/O Permission Bitmap)中规定的可访问端口进行直接访问。

对于以前的 DOS 操作系统来说,是没有内核空间、用户空间以及内核态、用户态这些概念的。可以认为所有的代码都是运行在内核态的,因而用户编写的应用程序代码可以很容易的让操作系统崩溃掉。

对于 Linux 来说,通过区分内核空间和用户空间的设计,隔离了操作系统代码(操作系统的代码要比应用程序的代码健壮很多)与应用程序代码。即便是单个应用程序出现错误也不会影响到操作系统的稳定性,这样其它的程序还可以正常的运行(Linux 可是个多任务系统啊!)。

所以,区分内核空间和用户空间本质上是要提高操作系统的稳定性及可用性

Part3系统调用的执行过程

Linux 系统调用的执行过程涉及多个关键步骤,从用户程序发起调用,到内核进行处理,再到最终返回结果给用户程序,每一步都至关重要。接下来,我们将详细探讨 Linux 系统调用的执行过程。

3.1准备阶段

在用户程序调用系统调用之前,首先需要设置相关参数,这些参数包括系统调用号以及其他与具体系统调用相关的参数。系统调用号是一个唯一的标识符,用于标识不同的系统调用。每个系统调用都被分配了一个特定的编号,就像图书馆里的每一本书都有一个唯一的编号一样,方便快速查找和识别 。

在 x86 架构中,系统调用号通常通过 EAX 寄存器传递给内核。例如,对于常见的 read 系统调用,其系统调用号为 3(在不同架构和内核版本中可能会有所不同)。除了系统调用号,还需要传递其他参数,如 read 系统调用需要传递文件描述符(通过 EBX 寄存器传递)、缓冲区指针(通过 ECX 寄存器传递)和读取的字节数(通过 EDX 寄存器传递) 。

3.2触发系统调用

用户程序设置好参数后,需要通过特定的指令来触发系统调用,从而进入内核态执行相应的操作。在 x86 架构中,传统上常用的指令是 int 0x80,这是一条软中断指令。当 CPU 执行到 int 0x80 指令时,会产生一个软件中断,从而触发系统调用机制。随着技术的发展,x86 架构从内核 2.6.11 版本开始引入了 syscall 指令,它比 int 0x80 指令具有更高的性能。syscall 指令通过特定的寄存器(如 RAX、RDI、RSI 等)来传递系统调用号和参数,能够更高效地实现从用户态到内核态的切换 。

除了 x86 架构,其他架构也有各自对应的触发系统调用的方式。在 ARM 架构中,通常使用 svc(Supervisor Call)指令来触发系统调用,该指令会导致处理器进入特权模式,从而执行内核中的系统调用处理程序 。

3.3进入内核态

当用户程序触发系统调用后,CPU 会从用户态切换到内核态,这是一个关键的状态转换过程。在这个过程中,CPU 会保存当前用户程序的上下文信息,包括寄存器的值、程序计数器(PC)等,以便在系统调用完成后能够恢复用户程序的执行。

CPU 通过中断向量表来确定系统调用处理程序的入口地址。中断向量表是一个存储中断处理程序入口地址的表格,每个中断向量对应一个特定的中断类型。对于系统调用,会有一个特定的中断向量(如 x86 架构中 int 0x80 对应的中断向量),CPU 根据这个中断向量在中断向量表中查找对应的系统调用处理程序的入口地址,然后跳转到该地址开始执行内核代码 。

3.4参数检查与处理

在内核开始执行系统调用之前,需要对用户程序传递过来的参数进行合法性检查,这是确保系统安全性和稳定性的重要环节。内核会检查参数的类型、范围、有效性等,以防止用户程序传递非法参数导致系统崩溃或出现安全漏洞。

对于涉及指针的参数,内核会检查指针是否指向合法的内存地址,并且该内存地址是否属于用户程序的地址空间,防止用户程序通过传递非法指针来访问内核空间或其他进程的内存空间。对于文件描述符参数,内核会检查文件描述符是否有效,是否指向一个已经打开的文件,以及当前进程是否具有对该文件的相应操作权限 。

3.5执行系统调用函数

内核完成参数检查后,会根据系统调用号从系统调用表中找到对应的系统调用函数,并执行该函数。系统调用表是一个存储系统调用函数指针的数组,每个系统调用号对应数组中的一个元素,该元素指向相应的系统调用函数。

在 x86 架构的 32 位系统中,系统调用表位于 arch/x86/kernel/syscall_table_32.S 文件中;在 64 位系统中,系统调用表位于 arch/x86/entry/syscalls/syscall_64.tbl 文件中。例如,对于 read 系统调用,内核会在系统调用表中找到对应的函数指针,然后调用该函数来执行实际的读取操作。在执行过程中,内核会根据用户传递的参数,如文件描述符、缓冲区指针和读取字节数,来完成文件读取任务,并将读取的数据存储到用户指定的缓冲区中 。

3.6返回用户态

当系统调用函数执行完成后,内核会将结果返回给用户程序,并将 CPU 从内核态切换回用户态。内核将系统调用的返回值存储在特定的寄存器中(如 x86 架构中通过 EAX 寄存器返回),然后恢复之前保存的用户程序的上下文信息,包括寄存器的值、程序计数器等。

CPU 根据恢复的程序计数器的值,跳转到用户程序中系统调用指令的下一条指令处继续执行,从而完成系统调用的整个过程。如果系统调用执行过程中发生错误,内核会返回一个错误码,用户程序可以通过检查返回值来判断系统调用是否成功,并根据错误码进行相应的错误处理 。

Part4Linux下系统调用的三种方法

4.1通过 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); else printf("chmod success!\n"); return 0; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.

在普通用户下编译运用,输出结果为:

复制
chmod failed, errno = 11.

上面系统调用返回的值为-1,说明系统调用失败,错误码为1,在 /usr/include/asm-generic/errno-base.h 文件中有如下错误代码说明:

复制
#define EPERM 1 /* Operation not permitted */1.

即无权限进行该操作,我们以普通用户权限是无法修改 /etc/passwd 文件的属性的,结果正确。

4.2使用 syscall 直接调用

使用上面的方法有很多好处,首先你无须知道更多的细节,如 chmod 系统调用号,你只需了解 glibc 提供的 API 的原型;其次,该方法具有更好的移植性,你可以很轻松将该程序移植到其他平台,或者将 glibc 库换成其它库,程序只需做少量改动。

但有点不足是,如果 glibc 没有封装某个内核提供的系统调用时,我就没办法通过上面的方法来调用该系统调用。如我自己通过编译内核增加了一个系统调用,这时 glibc 不可能有你新增系统调用的封装 API,此时我们可以利用 glibc 提供的syscall 函数直接调用。该函数定义在 unistd.h 头文件中,函数原型如下:

复制
long int syscall (long int sysno, ...)1.

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); else printf("chmod succeess!\n"); return 0; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.

在普通用户下编译执行,输出的结果与上例相同。

4.3通过 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); else printf("success!\n"); return 0; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.

如果 eax 寄存器存放的返回值(存放在变量 rc 中)在 -1~-132 之间,就必须要解释为出错码(在/usr/include/asm-generic/errno.h 文件中定义的最大出错码为 132),这时,将错误码写入 errno 中,置系统调用返回值为 -1;否则返回的是 eax 中的值。

上面程序在 32位Linux下以普通用户权限编译运行结果与前面两个相同!

Part5系统调用的案例分析

5.1Linux下系统调用的实现

Linux下的系统调用是通过0x80实现的,但是我们知道操作系统会有多个系统调用(Linux下有319个系统调用),而对于同一个中断号是如何处理多个不同的系统调用的?最简单的方式是对于不同的系统调用采用不同的中断号,但是中断号明显是一种稀缺资源,Linux显然不会这么做;还有一个问题就是系统调用是需要提供参数,并且具有返回值的,这些参数又是怎么传递的?也就是说,对于系统调用我们要搞清楚两点:

1. 系统调用的函数名称转换。2. 系统调用的参数传递。

首先看第一个问题。实际上,Linux中处理系统调用的方式与中断类似。每个系统调用都有相应的系统调用号作为唯一的标识,内核维护一张系统调用表,表中的元素是系统调用函数的起始地址,而系统调用号就是系统调用在调用表的偏移量。在进行系统调用是只要指定对应的系统调用号,就可以明确的要调用哪个系统调用,这就完成了系统调用的函数名称的转换。举例来说,Linux中fork的调用号是2(具体定义,在我的计算机上是在/usr/include/asm/unistd_32.h,可以通过find / -name unistd_32.h -print查找)

复制
[cpp] view plain copy #ifndef _ASM_X86_UNISTD_32_H #define _ASM_X86_UNISTD_32_H /* * This file contains the system call numbers. */ #define __NR_restart_syscall 0 #define __NR_exit 1 #define __NR_fork 2 #define __NR_read 3 #define __NR_write 4 #define __NR_open 51.2.3.4.5.6.7.8.9.10.11.12.13.14.

Linux中是通过寄存器%eax传递系统调用号,所以具体调用fork的过程是:将2存入%eax中,然后进行系统调用,伪代码:

复制
[plain] view plain copy mov eax, 2 int 0x801.2.3.

对于参数传递,Linux是通过寄存器完成的。Linux最多允许向系统调用传递6个参数,分别依次由%ebx,%ecx,%edx,%esi,%edi和%ebp这个6个寄存器完成。比如,调用exit(1),伪代码是:

复制
[plain] view plain copy mov eax, 2 mov ebx, 1 int 0x801.2.3.4.

因为exit需要一个参数1,所以这里只需要使用ebx。这6个寄存器可能已经被使用,所以在传参前必须把当前寄存器的状态保存下来,待系统调用返回后再恢复,这个在后面栈切换再具体讲。

Linux中,在用户态和内核态运行的进程使用的栈是不同的,分别叫做用户栈和内核栈,两者各自负责相应特权级别状态下的函数调用。当进行系统调用时,进程不仅要从用户态切换到内核态,同时也要完成栈切换,这样处于内核态的系统调用才能在内核栈上完成调用。系统调用返回时,还要切换回用户栈,继续完成用户态下的函数调用。

寄存器%esp(栈指针,指向栈顶)所在的内存空间叫做当前栈,比如%esp在用户空间则当前栈就是用户栈,否则是内核栈。栈切换主要就是%esp在用户空间和内核空间间的来回赋值。在Linux中,每个进程都有一个私有的内核栈,当从用户栈切换到内核栈时,需完成保存%esp以及相关寄存器的值(%ebx,%ecx...)并将%esp设置成内核栈的相应值。

而从内核栈切换会用户栈时,需要恢复用户栈的%esp及相关寄存器的值以及保存内核栈的信息。一个问题就是用户栈的%esp和寄存器的值保存到什么地方,以便于恢复呢?答案就是内核栈,在调用int指令机型系统调用后会把用户栈的%esp的值及相关寄存器压入内核栈中,系统调用通过iret指令返回,在返回之前会从内核栈弹出用户栈的%esp和寄存器的状态,然后进行恢复。

相信大家一定听过说,系统调用很耗时,要尽量少用。通过上面描述系统调用的实现原理,大家也应该知道这其中的原因了。

第一,系统调用通过中断实现,需要完成栈切换。第二,使用寄存器传参,这需要额外的保存和恢复的过程。

5.2文件操作相关系统调用

在 Linux 系统中,文件操作是日常使用和开发中极为常见的任务,而 open、read、write、close 等系统调用则是实现文件操作的核心工具。

open 系统调用用于打开或创建一个文件,它的函数原型为int open(const char *pathname, int flags, mode_t mode);。其中,pathname是要打开或创建的文件的路径名;flags参数用于指定文件的打开方式,比如O_RDONLY表示以只读方式打开,O_WRONLY表示以只写方式打开,O_RDWR则表示以读写方式打开,还有一些可选的标志位,如O_CREAT表示如果文件不存在则创建新文件,O_APPEND表示以追加方式写入文件等;mode参数在创建新文件时用于指定文件的访问权限,如S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH表示文件所有者具有读写权限,同组用户和其他用户具有读权限 。

read 系统调用用于从文件中读取数据,函数原型是ssize_t read(int fd, void *buf, size_t count);,fd是文件描述符,它是 open 系统调用成功返回的一个非负整数,用于标识打开的文件;buf是用于存储读取数据的缓冲区指针;count表示期望读取的字节数,该函数返回实际读取到的字节数 。

write 系统调用则用于向文件中写入数据,其函数原型为ssize_t write(int fd, const void *buf, size_t count);,参数含义与 read 类似,fd为文件描述符,buf是要写入数据的缓冲区指针,count是要写入的字节数,返回值是实际写入的字节数 。

close 系统调用用于关闭一个打开的文件,函数原型为int close(int fd);,fd为要关闭的文件描述符,关闭成功返回 0,失败返回 - 1 。

以下是一个简单的 C 语言代码示例,展示了如何使用这些系统调用实现文件的读取和写入操作:

复制
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <fcntl.h> #include <string.h> #define BUFFER_SIZE 1024 int main() { int source_fd, destination_fd; ssize_t bytes_read, bytes_written; char buffer[BUFFER_SIZE]; // 打开源文件,以只读方式 source_fd = open("source.txt", O_RDONLY); if (source_fd == -1) { perror("无法打开源文件"); return 1; } // 创建目标文件,以读写方式,如果文件不存在则创建,权限设置为所有者可读可写,其他用户可读 destination_fd = open("destination.txt", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH); if (destination_fd == -1) { perror("无法创建目标文件"); close(source_fd); return 1; } // 从源文件读取数据并写入目标文件 while ((bytes_read = read(source_fd, buffer, BUFFER_SIZE)) > 0) { bytes_written = write(destination_fd, buffer, bytes_read); if (bytes_written != bytes_read) { perror("写入目标文件失败"); close(source_fd); close(destination_fd); return 1; } } if (bytes_read == -1) { perror("读取源文件失败"); } // 关闭文件 close(source_fd); close(destination_fd); return 0; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.

在这个示例中,首先使用 open 系统调用以只读方式打开名为source.txt的源文件,如果打开失败,通过perror函数输出错误信息并返回 1。接着,使用 open 系统调用以读写方式创建名为destination.txt的目标文件,如果文件已存在则截断文件内容,如果创建失败同样输出错误信息并关闭已打开的源文件后返回 1 。

然后,通过一个循环,使用 read 系统调用从源文件中读取数据到缓冲区buffer中,每次最多读取BUFFER_SIZE个字节。只要读取到的数据长度大于 0,就使用 write 系统调用将缓冲区中的数据写入目标文件。如果写入的字节数与读取的字节数不一致,说明写入失败,输出错误信息并关闭两个文件后返回 1 。

如果在读取过程中bytes_read等于 - 1,说明读取失败,输出错误信息。最后,使用 close 系统调用分别关闭源文件和目标文件,完成文件操作 。

通过这个示例,我们可以清晰地看到 open、read、write、close 系统调用在文件读写操作中的具体应用和执行流程,它们相互配合,实现了高效、准确的文件数据传输和管理 。

5.3进程管理相关系统调用

在 Linux 系统中,进程管理是操作系统的核心功能之一,fork、exec、wait 等系统调用在进程的创建、执行和等待过程中发挥着关键作用。

fork 系统调用用于创建一个新的进程,称为子进程,它的函数原型为pid_t fork(void);。调用 fork 后,系统会创建一个与原进程(父进程)几乎完全相同的子进程,子进程复制了父进程的代码段、数据段、堆栈段等资源。但父子进程也有一些不同之处,它们拥有不同的进程 ID(PID),通过getpid()函数可以获取当前进程的 PID,通过getppid()函数可以获取父进程的 PID 。fork 函数的返回值非常特殊,在父进程中,返回值是新创建子进程的 PID;在子进程中,返回值为 0;如果创建子进程失败,返回值为 - 1 。

exec 系统调用并不是一个单独的函数,而是一组函数,如execl、execv、execle、execve等,它们的主要作用是在当前进程中启动另一个程序。当进程调用 exec 函数时,会用新的程序替换当前进程的正文、数据、堆和栈段,也就是说,当前进程会被新的程序完全取代,从新程序的main函数开始执行。由于 exec 并不创建新进程,所以前后的进程 ID 并未改变 。

wait 系统调用用于等待子进程的结束,并获取子进程的退出状态,函数原型为pid_t wait(int *status);。status是一个指向整数的指针,用于存储子进程的退出状态信息。调用 wait 后,父进程会阻塞,直到有一个子进程结束,此时 wait 返回结束子进程的 PID,并将子进程的退出状态存储在status指向的变量中 。

下面通过一个简单的代码示例来说明进程创建和父子进程的执行流程:

复制
#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #include <stdlib.h> int main() { pid_t pid; int status; // 创建子进程 pid = fork(); if (pid == -1) { perror("fork失败"); exit(1); } else if (pid == 0) { // 子进程执行的代码 printf("我是子进程,我的PID是 %d,父进程的PID是 %d\n", getpid(), getppid()); // 子进程执行另一个程序,这里以执行ls命令为例 execl("/bin/ls", "ls", "-l", NULL); perror("execl失败"); exit(1); } else { // 父进程执行的代码 printf("我是父进程,我的PID是 %d,子进程的PID是 %d\n", getpid(), pid); // 父进程等待子进程结束 wait(&status); printf("子进程已结束,退出状态为 %d\n", WEXITSTATUS(status)); } return 0; }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.

在这个示例中,首先调用 fork 系统调用创建子进程。如果 fork 返回 - 1,说明创建子进程失败,通过perror函数输出错误信息并调用exit函数退出程序 。

如果 fork 返回 0,说明当前是子进程,子进程打印自己的 PID 和父进程的 PID,然后调用execl函数执行/bin/ls -l命令,列出当前目录下的文件详细信息。如果execl执行失败,同样输出错误信息并退出 。

如果 fork 返回一个大于 0 的值,说明当前是父进程,父进程打印自己的 PID 和子进程的 PID,然后调用 wait 系统调用等待子进程结束。当子进程结束后,wait 返回,父进程获取子进程的退出状态,并打印子进程已结束以及其退出状态 。

通过这个示例,我们可以清楚地看到 fork、exec、wait 系统调用在进程管理中的协同工作,fork 用于创建新进程,exec 用于在子进程中执行新程序,wait 用于父进程等待子进程结束并获取其退出状态,它们共同构成了 Linux 系统强大的进程管理机制 。

THE END