14Exceptional Control Flow Exceptions and Process(异常控制流,异常和进程)

异常控制流

异常控制流出现的地方:
异常控制流(Exceptional Control Flow,ECF)是程序执行过程中由于某些特殊事件或条件而导致的控制流的改变。异常控制流通常出现在以下几种情况:

  • 硬件异常和中断:硬件异常是由处理器检测到的潜在错误条件,例如除以零、访问非法内存地址或浮点溢出。硬件中断是由外部设备(如鼠标、键盘或网络接口卡)发出的信号,通知处理器有待处理的事件。在这些情况下,处理器会暂停当前执行的指令序列,转而执行一个预先定义好的异常处理程序或中断处理程序。
  • 操作系统内核:操作系统内核在响应系统调用时,可能会改变控制流。例如,一个进程发出读取文件的系统调用时,操作系统可能需要将控制流从用户进程切换到内核态,处理文件读取请求,然后再将控制流切换回用户进程。
  • 信号(Signal):信号是一种软件异常控制流,用于在进程间传递通知。当一个进程接收到信号时,操作系统会中断当前进程的执行,转而执行与信号关联的信号处理函数。信号可以由其他进程发送,也可以由操作系统生成,例如当一个进程执行非法操作时(如访问非法内存地址)。
  • 异常处理语句:在高级编程语言中,异常控制流通常表现为异常处理语句(如 try-catch-finally 语句)。程序员可以使用这些语句来捕获和处理运行时可能发生的异常情况,例如文件读取错误、空指针引用或网络连接中断。
  • 线程同步和调度:在多线程环境中,线程调度和同步也可能导致异常控制流。当一个线程等待另一个线程释放互斥锁或资源时,线程调度器可能会将控制流切换到其他线程,直到资源可用。这种情况下,异常控制流体现在线程的切换与同步。
    异常控制流解决问题:
    异常控制流(Exception Control Flow)是一种程序执行过程中对异常事件进行处理的机制。异常事件是程序在运行过程中可能遇到的不寻常或非预期的情况,例如除以零、数组越界、文件读取错误等。为了确保程序能够更稳定地运行,开发者需要通过异常控制流来检测和处理这些异常情况。
    异常控制流解决问题的优势:
    (1)提高程序的稳定性:通过捕获和处理异常,程序可以在发生错误时继续运行,而不是直接崩溃。
    (2)提高程序的可维护性:集中处理异常使得代码更易于阅读和维护,有助于发现潜在的问题。
    (3)提高用户体验:异常处理可以让程序在发生错误时给用户提供更友好和详细的错误信息。

异常的定义

异常通常指与正常情况不同的、意外的或不寻常的情况或事件。在计算机编程中,异常通常是指程序运行时发生的错误或意外情况,例如系统崩溃、数据丢失或输入错误。

异常表

异常表指的是在计算机程序运行过程中,可能会发生的异常情况以及其对应的处理方式的一张表格。异常通常包括以下几个方面的内容:

  • 异常类型:列出可能发生的异常类型,如空指针异常、数组越界异常、文件未找到异常等
  • 异常信息:对每种异常类型进行详细描述,包括异常的产生原因、可能出现的场景
  • 处理方式:针对每种异常类型,列出程序应该如何处理异常情况,如捕获异常并给出提示信息、重新抛出异常、进行日志记录等
  • 代码示例:提供一些代码,展示如何捕获和处理异常。

异常表是程序员在开发过程中非常重要的工具,可以帮助他们即使识别和处理异常情况,保证程序的稳定性和健壮性

异常的分类方法

异常可以按照不同的方式进行分类,包括但不限于这几种:

  • 按照异常类型:例如运行时异常、检查时异常、系统异常等
  • 按照异常处理方式:例如捕获异常、抛出有异常、处理异常等
  • 按照异常产生时机:例如同步异常、异步异常等

同步异常和异步异常是按照异常产生时机的分类方式。同步异常是指在程序执行的过程中,出现了错误和异常情况,导致程序停止并抛出异常,需要立即处理。例如,一个除法操作中的分母为零异常,程序运行到该处时就会停止,并抛出异常信息,需要立即处理该异常情况。异步异常是指在程序运行过程中,出现了错误或异常的情况,但程序仍然能够继续运行,不会立即抛出异常信息,而是在某一个时刻由系统或程序自动处理。例如,一个网络请求可能会因为网络连接的问题导致异步异常,程序会尝试重新发送或者等待网络回复后再发送。

中断是另一种异常的处理方式。中断是一种异步异常,它是指当计算机处理某个任务时,发生了与该任务无关的事件,例如硬件故障或用户输入,计算机会暂停当前任务,转而处理这个事件,然后再返回原先的任务。中断通常由硬件设备或系统内部的程序发起,可以通过中断处理程序来处理。中断的处理方式使得计算机能够同时处理多个任务,提高计算机系统的效率和可靠性。

同步异常、陷阱和系统调用

同步异常、陷阱和系统调用时操作系统的三个重要的概念,他们都与计算机程序的执行过程有关。

同步异常是指在程序执行的过程中,出现了错误或异常情况,导致程序停止运行并抛出异常,需要立即处理。例如,除以零、访问非法内存地址等都会引起同步异常。同步异常通常由硬件或软件自动检测并处理,例如操作系统会向进程发送信号来通知发生了异常情况,进程需要根据信号类型进行相应的处理。

系统调用是用户程序向操作系统请求服务的接口,也是操作系统对外提供服务的接口。用户程序需要执行一些操作,例如读写文件、创建进程、网络通信等。但这些操作必须由操作系统来完成。因此,用户程序需要通过系统调用向操作系统发起请求,让操作系统代表用户程序执行相应的服务。系统调用通常是通过软件中断来实现的,即用户程序通过陷阱指令切换到内核态,然后执行相应的系统调用指令,操作系统在内核态完成相应的服务,并将结果返回给用户程序。

总的来说,同步异常、陷阱和系统调用都是计算机程序执行的过程中的异常机制和调用方式,他们在操作系统中扮演着重要的角色。

扫描二维码关注公众号,回复: 16964875 查看本文章

故障、页面缺失、保护故障

故障、页缺失和保护故障是计算机操作系统中常见的异常类型。
故障是指当程序执行时出现的异常情况,例如非法操作数、非法指令、非法内存访问等,这些异常情况通常由硬件检测引发的,操作系统需要处理这些异常情况。故障不同于错误,错误通常是由程序逻辑或设计上的问题导致的,例如除数为0、栈溢出等。

页缺失是指当程序需要访问的页面不在内存是,会引发的一种异常情况,操作系统会将需要访问的页面从磁盘中加载到内存中,然后再让程序访问该页面。如果内存中没有改页面,则会引发页缺失异常。操作系统需要将其该页面从磁盘中读取到内存中,并更新页表等数据结构,以便程序能够访问该页面。

保护故障是指程序试图访问受保护的资源,例如内核态的资源或其他进程的内存等,而没有获得足够的权限,这种情况会引发异常。操作系统需要检查程序的访问权限,如果没有足够的权限,会引发保护故障。

总的来说,故障、页缺失和保护故障都是操作系统中常见的异常类型。操作系统需要检测并处理这些异常情况,以保证系统的稳定性和安全性。
下面是一个无效内存引用的例子,假设我们有一个指向整型数组的指针,但该指针指向了未分配的内存地址:

#include <stdio.h>
#include <stdlib.h>
int main() {
    int *ptr;
    *ptr = 10; // 无效内存引用
    printf("%d\n", *ptr);
    return 0;
}

在该程序中,我们声明了一个指向整型数组的指针ptr,但没有为它分配任何内存空间。接下来,我们试图将整数10赋值给指针所指向的内存地址,这会导致一个无效内存引用的故障。最后,程序试图读取指针所指向的内存地址中的值并将其输出,但由于该内存地址未分配,程序将会崩溃并退出。
为了避免这种类型的故障,我们应该确保指针始终指向已分配的内存地址,并在使用指针之前对其进行初始化。可以使用malloc()函数在堆上分配一段内存来避免这种类型的故障,例如:

#include <stdio.h>
#include <stdlib.h>
int main() {
    int *ptr = (int *)malloc(sizeof(int));
    *ptr = 10; // 正确的内存引用
    printf("%d\n", *ptr);
    free(ptr);
    return 0;
}

在该程序中,我们使用malloc()函数在堆上分配了一个int类型大小的内存空间,并将指针ptr指向该内存地址。接下来,我们将整数10赋值给该内存地址,并输出该值。最后,我们使用free()函数释放该内存空间。这样就避免了无效内存引用的故障

故障的例子、无效内存的引用

故障(fault)指的是当程序试图执行一个无效的操作或访问一个无效的内存地址时,导致操作系统向该程序发送一个异常信号的情况。无效内存引用也是一种常见的故障,它发生在程序试图访问一个未分配的内存地址或已释放的内存地址时。

终止

终止是指程序或进程执行结束或被强制停止的过程。

程序执行结束时,会经历几个阶段。首先,操作系统会将程序加载到内存中,并为其分配资源或权限。然后,程序开始执行,直到完成它的任务或遇到异常情况。如果程序执行完成,它会自动终止并退出,释放掉占用的资源和权限。如果程序遇到异常情况,例如发生错误或出现了无法处理的异常,程序会自动终止并退出。

进程的终止可以分为正常的终止和异常的终止。正常的终止通常是指进程执行完它的任务并退出,操作系统会回收进程的资源和权限。异常终止是指进程遇到了无法处理的异常情况,例如访问非法内存地址、除以零等,操作系统需要强制终止进程并回收资源,以保证系统的稳定性和安全性。

总的来说,终止是程序或进程执行结束或被强制停止的过程。它是操作系统中非常重要的部分,操作系统需要确保程序和进程的正常终止,并及时收回他们占用的资源和权限,以保证系统的稳定性和高效性。

exit
在Unix/Linux中,一个进程可以通过调用exit()系统调用来终止自己。exit()函数将在进程中止时执行一些清理操作,并将进程的退出状态传递给其父进程。进程的退出状态通常用来表示进程的退出原因或者执行的结果。
在C语言中,exit()函数定义在stdlib.h头文件中,其原型如下:

void exit(int status);

其中,status是一个整数,表示进程的退出状态。如果status为0,则表示进程正常结束。其他的退出状态可以用来表示错误代码等信息。

值得注意的是,exit()函数不会直接终止进程,而是会将进程的退出状态传递给其父进程,由父进程决定是否终止进程。如果父进程没有等待子进程的退出状态,子进程可能会成为"僵尸进程",需要使用wait()或waitpid()等函数来等待子进程的终止状态并回收其资源。
下面是一个简单的示例程序,演示了如何使用exit()函数来终止进程:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    printf("before exit()\n");
    exit(0);  // 终止进程,并返回状态码0
    printf("after exit()\n");  // 这行代码不会被执行
    return 0;
}

在该示例程序中,当调用exit()函数时,进程将立即终止,并返回状态码0。因此,printf()函数的输出仅限于"before exit()","after exit()"不会被执行。

Linux/x86-64常用的系统调用

Linux/x86-64是一种常见的操作系统和计算机体系结构,它提供了许多系统调用供用户程序使用。下面是Linux/x86-64中常用的系统调用:

read():从文件描述符中读取数据。
write():将数据写入文件描述符。
open():打开一个文件。
close():关闭一个文件。
creat():创建一个文件。
unlink():删除一个文件。
mkdir():创建一个目录。
rmdir():删除一个目录。
chdir():改变当前工作目录。
getpid():获取当前进程的进程ID。
fork():创建一个新进程。
execve():执行一个新程序。
wait():等待一个子进程退出。
pipe():创建一个管道。
dup():复制一个文件描述符。
select():等待一组文件描述符上的I/O事件。
socket():创建一个套接字。
bind():将一个套接字绑定到一个地址。
listen():监听一个套接字。
accept():接受一个客户端连接。
connect():连接到一个远程主机。

这些系统调用可以通过C语言的标准库函数或系统调用库函数来调用。它们提供了一组基本的操作系统服务,用户程序可以通过它们来完成文件操作、进程管理、网络通信等功能。

syscall、open

syscall是Linux操作系统提供的系统调用机制,它允许用户程序直接调用操作系统内核中的功能。在Linux/x86-64中,syscall指令用于触发系统调用,该指令会将系统调用号和参数传递给操作系统内核,内核会根据系统调用号和参数执行相应的操作并返回结果给用户程序。
open是一个常用的系统调用,用于打开一个文件。在Linux/x86-64中,open系统调用的系统调用号为2,它有三个参数:
(1)const char *pathname:要打开的文件的路径名。
(2)int flags:打开文件的方式和标志位,例如O_RDONLY、O_WRONLY、O_RDWR等。
(3)mode_t mode:文件的权限,例如0666表示文件可读可写。

用户程序可以通过C语言的标准库函数open()来调用open系统调用,例如:

#include <fcntl.h>
int open(const char *pathname, int flags, mode_t mode);

该函数返回一个文件描述符,用于后续的文件读写操作。例如,要打开一个名为test.txt的文件并写入数据,可以按如下方式编写程序:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
    int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    write(fd, "Hello, World!", 13);
    close(fd);
    return 0;
}

该程序使用open()函数打开了test.txt文件,并以写入模式打开。如果打开文件失败,程序会打印出错误信息并退出。如果打开成功,程序会通过write()函数向文件中写入"Hello, World!"字符串,并关闭文件描述符。

进程

进程是计算机中正在运行的一个程序实例。在操作系统中,进程是资源分配和调度的基本单位,也可以看作是程序在计算机中的依次执行活动。一个进程可以包括多个线程,每个线程执行程序中的一部分任务。

进程拥有自己的内存空间、寄存器集合、程序计数器、打开的文件等系统资源,他们之间相互独立,互不干扰。操作系统通过进程调度算法来管理进程的运行,分配CPU时间片,以实现多任务并发执行。

每个进程都有一个唯一的进程标识符(Process Identifier,PID),用于区分不同的进程。当一个程序被启动时,操作系统会为它创建一个新的进程,并分配一个唯一的PID。

进程的状态可以分为三种:运行态(Running)、就绪态(Ready)和阻塞态(Blocked)。当进程正在执行时,它处于运行态;当进程等待某些事件的发生时,如等待输入输出、等待资源分配等,则处于阻塞态;当进程已经准备好运行,但还没有获得CPU时间片时,则处于就绪态。

进程间可以通过进程间通信机制(如管道、共享内存、消息队列等)来进行通信和数据共享。同时,操作系统也提供了一些进程管理的系统调用,如创建进程、销毁进程、等待进程结束等。

在这里插入图片描述
在这里插入图片描述
状态详解:

  • 运行态----当进程被分配到cpu并开始执行时,它就处于运行态.此时,进程正在执行指令,使用cpu资源,并且可以与其他进程竞争cpu时间片.在运行态中,进程可以执行任何可以在用户或内核模式下执行的操作,例如读写文件,发送方网络请求.
  • 阻塞态-----当进程等待某些事件的发生而无法继续执行时,它就处于阻塞态。例如,当进程等待IO操作完成、等待锁或信号量、等待另一个进程发送消息等时,它就处于阻塞态。在阻塞状态中,进程不会使用CPU资源,并且不会竞争CPU时间片。
  • 终止态------当进程完成其执行任务并退出时,它就处于终止态.在这个状态下,进程会释放其使用的所有资源,包括cpu时间,内存,文件描述,锁等等.终止的进程的信息会留在进程表中,以供父进程或其他进程查看进程的退出状态信息.
    这三种状态是操作系统中进程的基本状态,它们的转换和管理是操作系统中进程调度和资源管理的关键部分。

进程的两个抽象

  • 独占cpu和寄存器
    进程的两个抽象是指进程在操作系统中的两个最基本的抽象概念,分别是进程的执行状态和进程的地址空间。
    第一个抽象是指进程在运行时独占CPU和寄存器,可以看作是一个独立的执行单位。进程在执行是会占用cpu的时间片,通过cpu的切换来实现多任务并发执行。在进程运行期间CPU和寄存器都被进程独占,进程可以使用这些资源来执行自己的代码,读取和写入自己的数据。

  • 拥有自己的地址空间
    第二个抽象是指每个进程都拥有自己的地址空间,也称为虚拟地址空间,包括代码、数据和堆栈等。每个进程在运行时都有自己的代码、数据、堆栈等内容,而且每个进程的地址空间是相互独立的,互不干扰。操作系统通过虚拟内存管理机制来实现地址空间的分配和保护,使得每个进程都能独立运行,保证了系统的安全性和稳定性。

进程的地址空间是虚拟的,即进程认为自己独占了整个物理内存,但实际上它只能访问被操作系统分配给它的部分内存。这是由于现代操作系统采用了虚拟内存技术,将物理内存划分成许多大小相等的页面,每个页面都有一个虚拟地址和一个物理地址,操作系统通过映射表来管理虚拟地址和物理地址之间的对应关系。每个进程都有自己的映射表,使得进程在访问内存时只能访问自己的地址空间。

进程的地址空间通常包括以下几个部分:
1.代码段:存放程序的可执行代码。
2.数据段:存放程序中定义的全局变量和静态变量
3.堆:存放动态分配的内存,如使用malloc函数分配的内存
4.栈:存放函数调用构成中的局部变量和函数参数

top指令看进程
在 Linux 系统中,top 命令可以用于实时监测系统中的进程状态和资源占用情况,提供了一个交互式的终端界面,显示了各个进程的 CPU 占用率、内存使用情况、运行时间等信息。

要使用 top 命令,可以在终端中输入 top,然后按下回车键。在 top 命令的界面中,可以看到系统中所有运行的进程的列表,每个进程占据一行。每一行显示了进程的 PID(进程标识符)、进程所属的用户、进程的 CPU 占用率、内存使用情况等信息。

在 top 命令的界面中,可以使用一些快捷键来执行一些操作,比如:
P:按 CPU 占用率排序;
M:按内存使用情况排序;
k:杀死一个进程;
H:显示线程信息。

此外,还可以使用 top -p 命令来查看指定 PID 的进程信息。例如,要查看 PID 为 123 的进程信息,可以在终端中输入 top -p 123。

多进程管理共享,上下文切换是地址空间和寄存器的变化
在一个操作系统中,多个进程可能同时运行。这些进程之间可能需要共享一些资源,例如内存和文件等。为了保证进程间的安全和独立性,操作系统采用了多进程管理机制。

在多进程管理机制下,每个进程都有自己的独立地址空间和寄存器.地址空间是指进程可以使用的内存空间,包括代码\数据\栈堆等.寄存器则是cpu内部的一些寄存器,用于存储cpu执行指令时所需要的数据.

当操作系统需要切换进程时,会保存当前进程的地址空间和寄存器的状态.然后加载新进程的地址空间和寄存器的状态.这个这个过程称为上下文切换.在上下文切换中,操作系统需要做两件事情:
(1)保存当前进程的状态.操作系统需要保存当前的地址空间和寄存器状态,以便之后能够恢复到当前进程的执行状态.
(2)加载新进程的状态.操作系统需要加载新进程的地址空间和寄存器状态,以便让CPU开始执行新进程的代码.

在多进程管理机制下,不同进程之间的地址空间和寄存器状态是相互独立的.这样可以保证进程之间的数据不会相互干扰,提高了系统的安全性和稳定性.但是,由于上下文切换需要保存和恢复大量的状态信息,因此会带来一定的性能开销.

上下文切换的过程
上下文切换是指在操作系统中,由于多个进程或线程共享cpu资源.当一个进程或线程需要让出cpu资源时,操作系统需要保存当前进程或线程的上下文信息,切换到下一个进程或线程的上下文信息,然后重新启动cpu执行新的进程或线程.

上下文切换的过程通常包括以下步骤:
(1)当前进程或线程执行到了一个需要等待外部事件发生的操作,如 I/O 操作,或者执行了一个时间片结束的操作,需要让出 CPU 资源,操作系统就会触发上下文切换。
(2)操作系统会保存当前进程或线程的上下文信息,包括 CPU 寄存器的值、程序计数器、堆栈指针等等,这些信息被保存在操作系统内核的进程或线程控制块(PCB/TCB)中。
(3)操作系统会根据进程或线程调度算法选择下一个需要执行的进程或线程,将其上下文信息加载到 CPU 中。
(4)操作系统会将 CPU 控制权交给下一个进程或线程,CPU 开始执行新的进程或线程。
(5)如果之前被暂停的进程或线程仍然是就绪状态,它将被重新加入到可执行队列中等待下一次调度。
  上下文切换是一种非常耗费资源的操作,因为在保存和恢复进程或线程上下文的过程中,需要涉及到内存读写和 CPU 寄存器的操作,这些操作都需要消耗时间和 CPU 资源。因此,对于需要频繁切换的应用程序,上下文切换可能成为瓶颈,影响系统的性能。

** 进程的控制函数,getpid,getppid:**在Unix/Linux中,可以使用 getpid 函数来获取当前进程的进程ID,使用 getppid 函数来获取当前进程的父进程的进程ID。这两个函数是进程控制函数的基本组成部分之一,它们可以帮助我们识别和控制进程。

逻辑控制流

逻辑控制流是指程序在执行时所遵循的顺序和流程。在一个程序中,可能存在多个语句和操作,这些语句和操作之间的关系和顺序构成了程序的逻辑控制流。程序的逻辑控制流通常由以下几种结构组成:
(1)顺序结构:程序按照语句的顺序依次执行,没有任何分支或循环。这是最简单的程序结构,也是所有程序结构的基础。
(2)分支结构:程序根据条件执行不同的语句。分支结构通常由 if 语句和 switch 语句实现。
(3)循环结构:程序根据循环条件反复执行一段代码。循环结构通常由 while 语句、for 语句和 do-while 语句实现。
(4)跳转结构:程序可以在任意位置跳转到另一个位置继续执行。跳转结构通常由 goto 语句实现,但是在实际编程中不建议使用。
这些程序结构可以组合使用,形成更加复杂的程序流程。例如,可以在分支结构中嵌套循环结构,或者在循环结构中使用分支结构。在实际编程中,选择合适的程序结构组合,可以提高程序的效率和可读性。

并发

并发是指在同一时间内,多个任务或者进程都在执行.在操作系统中,常常需要同时处理多个任务或进程,这就需要使用并发记住来实现.

并发技术包括多进程,多线程,异步编程等步骤.多进程指的是在操作系统中同时运行多个独立的进程,每个进程都有自己的地址空间和寄存器状态.多线程则是在一个进程中同时运行多个独立的进程,每个线程都有自己的堆栈和寄存器状态,但是共享进程的地址空间.异步编程则是指使用异步回调和事件循环等技术,是现在单线程中处理多个任务的同时,保证响应速度和资源利用率.

并发技术的优点:可以提高系统的响应速度和资源利用率,是的多个任务可以同时运行,提高系统的效率和可靠性.但是并发技术也带来了一些问题,例如线程安全问题,死锁问题,竞态条件等.为了避免这些问题,需要使用一些并发编程的最佳实践和设计模式,如使用锁机制,避免共享数据等.

用户模式和内核模式

用户模式和内核模式是操作系统中两种不同的执行模型.

当程序运行在用户模式时,它只能访问受限资源,例如它自己在进程地址空间,而不能直接访问系统资源,如硬件设备和内核代码.当程序需要访问系统资源时,它必须通过系统调用进入内核模式,由内核完成相关操作.用户模式是一种安全机制,可以预防应用程序错误或恶意代码对系统造成损害.

内核模式也被称为特权模式或系统模式,因为在这种模式下,操作系统拥有对系统资源的完全控制权.内核模式下的代码可以直接访问所有硬件设备和内存空间,并执行特权指令.如设置中断向量表和操作cpu状态.由于内核模式下的代码可以访问系统资源,因此必须确保内核代码的正确性和安全性.

当一个进程发起系统调用时,他会从用户模式切换到内核模式,系统会在内核模式下执行相应的操作,并返回结果给用户模式的进程.这个过程称为上下文切换,也是操作系统中常见的一种机制.因为上下文切换涉及到cpu状态的切换和内存访问的切换.因此它的开销比较大,需要尽可能减少上下文切换的次数,以提高系统的性能.

中断在状态转换中的作用:
当应用程序在用户空间运行时,它只能访问自己的内存空间和CPU提供的有限资源。如果应用程序需要访问内核空间的资源,例如设备驱动程序或系统服务,它必须通过系统调用进入内核空间。这是因为内核空间包含了所有系统资源和硬件设备,它具有更高的权限和更多的特权。

在现代操作系统中,用户空间和内核空间是完全隔离的,不能直接访问对方的内存空间。因此,当应用程序需要访问内核空间时,它必须使用中断操作。中断是一种在CPU执行指令时暂停当前任务的机制,以响应硬件事件或软件请求。当应用程序调用系统调用时,它会触发一个中断,将CPU的控制权转移到内核空间。内核会执行所需的操作,然后返回结果给应用程序,并将控制权交还给应用程序。

因此,通过中断操作,应用程序可以安全地访问内核空间,而不会破坏系统的安全和稳定性。同时,内核可以对应用程序的请求进行安全验证和控制,以确保系统的安全和稳定性。

系统调用错误处理

在操作系统中,当应用程序发起系统调用时,可能发生错误.错误可能是由于应用程序本身的错误或操作系统的问题引起的,操作系统通常会返回一个错误的代码.以通知应用程序系统调用发生了什么问题.应用程序需要正确处理这些错误,以便恰当地相应错误情况.

系统调用的错误处理通常包括以下步骤:
(1)检查系统调用的返回值。系统调用通常会返回一个整数值,表示操作的结果或错误代码。应用程序需要检查这个返回值,以确定操作是否成功。
(2)如果系统调用返回一个错误代码,应用程序需要根据错误类型采取适当的措施。例如,如果打开文件失败,应用程序可以选择重新尝试打开文件,或者向用户显示错误消息。
(3)应用程序需要在代码中添加适当的错误处理代码。例如,如果打开文件失败,应用程序需要添加代码来关闭打开的文件描述符,以便在错误情况下释放资源。
(4)应用程序可以选择将错误消息记录到日志文件中,以便在后续排查错误时使用。
在处理系统调用错误时,应用程序需要仔细考虑错误类型和处理方式,以确保应用程序的正确性和稳定性。

错误报告功能(unix_error)

unix_error 是一个错误报告函数,它通常用于处理 UNIX 系统调用中的错误。它位于 CSAPP 案例中的 csapp.c 文件中,其主要功能是将 UNIX 系统调用的错误转换为人类可读的错误消息,并将错误信息打印到标准错误流(stderr)中。它的实现如下:

void unix_error(char *msg) /* UNIX-style error */
{
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
}

该函数接收一个字符串参数 msg,表示错误消息的前缀。它使用 strerror 函数将错误码 errno 转换为错误消息,并将前缀和错误消息一起打印到标准错误流中。最后,它调用 exit 函数退出程序。

例如,如果调用 open 函数打开文件时发生错误,可以使用 unix_error 函数报告错误消息:

int fd = open("nonexistent_file", O_RDONLY);
if (fd == -1) {
    unix_error("open error");
}

在上述代码中,如果打开文件失败,unix_error 函数将打印以下错误消息:

open error: No such file or directory

使用 unix_error 函数可以方便地处理 UNIX 系统调用中的错误,并提供更好的错误报告功能。

错误处理包装函数(Fork)

在操作系统中,错误处理是一个很重要的问题。为了方便错误处理,可以使用错误处理包装函数来包装系统调用,并检查它们是否成功。这些包装函数将系统调用的错误转换为返回值,以方便应用程序检查错误并采取适当的措施。
以下是一个错误处理包装函数 Fork 的示例,它封装了 fork 系统调用:

pid_t Fork(void) 
{
    pid_t pid;

    if ((pid = fork()) < 0)
        unix_error("Fork error");
    return pid;
}

该函数返回子进程的进程 ID。如果调用 fork 函数失败,该函数将打印错误消息并终止程序。
在使用 Fork 函数时,如果发生错误,它将抛出异常并终止程序。例如:

pid_t pid = Fork();
if (pid == 0) {  // Child process
    // ...
} else if (pid > 0) {  // Parent process
    // ...
} else {  // Error
    printf("Fork error\n");
    exit(1);
}

在上述代码中,如果调用 Fork 函数失败,程序将打印错误消息并终止。否则,它将分别在父进程和子进程中执行不同的操作。

fork创建子进程,返回两次 fork的例子
在Unix/Linux中,fork()是一个用于创建子进程的系统调用。调用fork()函数后,将会创建一个新的进程,这个进程就是原进程的子进程。这个子进程将会与父进程拥有相同的代码和数据,但是拥有不同的进程ID和系统资源(例如文件描述符、内存映射、定时器等)。fork()函数的原型如下:

#include <unistd.h>
pid_t fork(void);

其中,pid_t是一个整型数据类型,表示进程的ID。fork()函数将返回两次:在父进程中返回新的子进程的PID,在子进程中返回0。如果fork()函数调用失败,则返回一个负数。
下面是一个简单的示例程序,演示了如何使用fork()函数来创建子进程:

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();  // 创建子进程

    if (pid < 0) {  // 创建进程失败
        printf("fork error\n");
    } else if (pid == 0) {  // 子进程
        printf("child process: pid=%d\n", getpid());
    } else {  // 父进程
        printf("parent process: child pid=%d\n", pid);
    }

    return 0;
}

在该示例程序中,首先调用fork()函数来创建子进程。如果fork()函数成功创建了一个子进程,则在子进程中输出"child process: pid=XXX",其中XXX是子进程的进程ID;在父进程中输出"parent process: child pid=XXX",其中XXX是新创建子进程的进程ID。注意,父进程和子进程会执行相同的代码,但是它们的执行顺序和输出可能会有所不同。

需要注意的是,fork()函数会复制父进程的内存映像,包括所有打开的文件描述符、信号处理函数、虚拟内存等。因此,父进程和子进程之间不会共享任何变量或数据结构。如果需要在父进程和子进程之间共享数据,可以使用进程间通信(IPC)机制,例如管道、共享内存、消息队列等。

捕捉调用fork时发生的情况
用于捕捉调用fork时发生的情况,其中顶点表示语句的执行,边对应变量:
在这里插入图片描述
这个进程图描述了一个程序,该程序在开始时输出一条消息,然后调用fork创建一个子进程。在子进程中,程序输出一条消息,并通过调用exit()来结束子进程。在父进程中,程序输出一条消息,然后继续执行,并在最后输出一条消息。

在这个进程图中,顶点表示程序中的不同语句,边表示控制流。特别地,边上的标签表示与该边相关的变量或条件。在这个示例中,pid是一个变量,用于存储fork返回的值。如果pid等于0,则表示当前执行的是子进程,否则表示当前执行的是父进程。

进程图例子

下面是一个示例进程图,用于说明一个简单的进程,该进程会打印一条消息并等待用户的输入。当用户输入一个字符串后,进程会打印出该字符串并结束。
在这里插入图片描述
在这个进程图中,顶点表示程序中的不同语句,边表示控制流。在这个示例中,程序首先打印一条消息,然后等待用户的输入。输入完成后,程序会将输入的字符串存储在名为buffer的变量中,并打印出该字符串。最后,程序通过调用exit()来结束进程。

进程图中的箭头表示进程的控制流。这个示例虽然简单,但是它展示了进程图可以用于描述程序的控制流。通过将程序转换为进程图,我们可以更好地理解程序的执行过程,并更好地分析它的行为。

思考?

每个进程都有子进程和父进程吗?
是的,每个进程都会有一个父进程和可能会有一个或多个子进程,当一个进程调用fork()函数时,他就会创建一个新的子线程,并且这个子线程将会是当前进程的副本.这个子进程将会与父进程拥有相同的代码和数据,但是它们会拥有各自独立的地址空间。

子进程会从父进程继承一些属性,例如文件描述符、用户ID和组ID等等。子进程与父进程在创建时的状态会有些不同,其中最重要的是子进程会继承父进程的执行上下文。也就是说,当子进程开始运行时,它会从父进程的当前指令处开始执行,然后它会继续自己的执行路径。

一旦子进程创建成功后,父进程和子进程就会开始并行运行。它们拥有各自独立的执行上下文,所以它们的行为是相互独立的。当子进程完成后,它会向父进程发送一个信号,告诉父进程它已经结束了。父进程可以使用wait()或waitpid()等函数来等待子进程结束并检查它的状态,或者直接忽略子进程的退出信号。

总之,每个进程都有一个父进程和可能有一个或多个子进程。这些进程在创建时的状态可能有些不同,但它们是相互独立的,可以在系统中并行运行。

wait工作原理的简单示例

假设我们有一个父进程和两个子进程,子进程1会休眠2秒钟,子进程2会休眠3秒钟,然后结束。父进程要等待两个子进程都结束后才退出。这个过程可以使用wait()函数来实现,示例代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main()
{
    pid_t pid1, pid2;
    int status1, status2;

    pid1 = fork();
    if (pid1 == 0) {
        // 子进程1
        sleep(2);
        printf("Child process 1 is exiting.\n");
        exit(0);
    } else {
        pid2 = fork();
        if (pid2 == 0) {
            // 子进程2
            sleep(3);
            printf("Child process 2 is exiting.\n");
            exit(0);
        } else {
            // 父进程
            wait(&status1);
            printf("Child process 1 has exited with status %d.\n", status1);
            wait(&status2);
            printf("Child process 2 has exited with status %d.\n", status2);
            printf("Parent process is exiting.\n");
            exit(0);
        }
    }

    return 0;
}

在这个例子中,父进程首先fork()出两个子进程,然后通过wait()函数来等待子进程结束并回收它们的资源。当子进程结束时,它会向父进程发送一个信号,父进程在wait()函数中捕捉到这个信号,并从子进程那里获取到子进程的退出状态。

运行这个程序后,我们可以看到如下输出:
在这里插入图片描述

wait()函数
wait()函数是用来等待子进程结束并回收它们的资源的.当一个子进程结束后,它并不会立即从系统中消失,而是会成为一个"僵尸进程",这个进程的资源(包括内存,文件描述,状态等)仍然会被占用,但是它已经不能执行任何操作了.

父进程可以使用wait()函数来等待子进程结束,并回收子进程的资源,这样子进程就可以被完全从系统中清除掉.在父进程调用wait()函数之前,子进程的资源不会被回收,而会一直占用着系统的资源.

当父进程调用wait()函数时,它会阻塞并等待子进程结束.如果有多个子进程同时结束,wait()函数会返回其中一个子进程的状态,并将这个子进程的资源回收.如果父进程没有等待到任何子进程结束,wait()函数会一直阻塞,直到有子进程结束为止.

总之,wait()函数用来等待子进程结束并回收它们的资源。如果父进程没有回收子进程的资源,子进程就会变成僵尸进程,占用着系统的资源。

waitpid函数:
waitpid函数可以等待指定的子进程结束,并回收它的资源.它有三个参数:
pid:要等待的子进程的进程ID,如果为-1,则等待任何一个子进程结束。
status:保存子进程退出状态的指针。
options:选项参数。

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main()
{
    pid_t pid1, pid2;
    int status1, status2;

    pid1 = fork();
    if (pid1 < 0) {
        perror("fork");
        exit(EXIT_FAILURE);
    } else if (pid1 == 0) {
        // 子进程1
        printf("I am child process 1. My pid is %d.\n", getpid());
        sleep(2);
        exit(1);
    } else {
        pid2 = fork();
        if (pid2 < 0) {
            perror("fork");
            exit(EXIT_FAILURE);
        } else if (pid2 == 0) {
            // 子进程2
            printf("I am child process 2. My pid is %d.\n", getpid());
            sleep(4);
            exit(2);
        } else {
            // 父进程
            printf("I am parent process. My pid is %d.\n", getpid());
            printf("Waiting for child process %d and %d...\n", pid1, pid2);

            // 等待子进程1结束
            waitpid(pid1, &status1, 0);
            if (WIFEXITED(status1)) {
                printf("Child process %d terminated with exit status %d.\n", pid1, WEXITSTATUS(status1));
            } else {
                printf("Child process %d terminated abnormally.\n", pid1);
            }

            // 等待子进程2结束
            waitpid(pid2, &status2, 0);
            if (WIFEXITED(status2)) {
                printf("Child process %d terminated with exit status %d.\n", pid2, WEXITSTATUS(status2));
            } else {
                printf("Child process %d terminated abnormally.\n", pid2);
            }
        }
    }

    return 0;
}

该程序创建了两个子进程,分别等待2秒和4秒后退出。父进程使用waitpid函数等待子进程结束,并打印子进程的退出状态。输出如下:
在这里插入图片描述
可以看到,父进程先等待子进程1结束,然后等待子进程2结束。子进程的退出状态分别是1和2。

execve运行不同的程序
execve()是一个Linux/Unix系统调用, 可以被用来启动一个全新的程序并运行, 从而使当前进程的代码和数据被替换为新程序的代码和数据.这样可以用来实现不同程序的功能.
execve接收三个参数:
(1)path:指定要执行的程序的路径和名称。
(2)argv:是一个字符串数组,其中包含了程序的命令行参数。argv的第一个元素通常是程序的名称,后面的元素是程序的参数。
(3)envp:是一个字符串数组,其中包含了程序的环境变量。

execve会首先通过path指定的路径查找要执行的程序文件.如果找到了对应的文件,则用该文件替换当前进程,并开始执行新的程序.如果找不到对应的文件,则execve函数调用失败,并返回-1.
示例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    char *argv[] = {"/bin/ls", "-l", "/tmp", NULL};  // 要执行的程序和参数
    char *envp[] = {NULL};  // 环境变量
    if (execve("/bin/ls", argv, envp) == -1) {
        printf("execve failed\n");
        exit(1);
    }
    return 0;
}

需要注意的是,execve函数调用成功后,当前进程就会被替换为新的程序,因此在execve后面的代码不会被执行。如果要在新程序执行完成后继续执行原程序中的代码,可以在新程序中调用exit函数来结束程序,并在原程序中使用fork和waitpid等系统调用来等待新程序的执行结果。

新程序开始栈帧结构

在一个新程序被启动时,它的栈帧结构包含了新程序的入口点\命令行参数以及环境变量等信息.具体来说,栈帧结构的构成通常包括一下几个部分:
argc:一个整数,表示程序接收到的命令行参数的数量。
argv:一个指针数组,指向程序接收到的命令行参数的字符串。

int main(int argc, char *argv[]);

envp:一个指针数组,指向程序使用的环境变量。
返回地址:一个指向程序入口点的地址。
局部变量:程序在执行过程中可能会创建一些局部变量,这些变量会被存储在栈帧结构中。
这些信息会在新程序的栈帧结构中被依次存储。在程序执行的过程中,这些信息可以被访问和修改。一般来说,操作系统会负责创建和初始化新程序的栈帧结构,从而为新程序提供一个合适的执行环境。

僵尸进程

僵尸进程(Zombie Process)是指一个已经完成执行(已经退出)的进程,但是其父进程尚未调用wait()或waitpid()等系统调用来获取其终止状态,从而导致该进程的进程描述符仍然存在于系统中,但是已经无法执行任何操作,也无法被调度。

在Linux系统中,僵尸进程的进程状态标记为Z或Z+,可以通过执行ps命令来查看。僵尸进程不会占用太多系统资源,但是如果父进程一直不调用wait()等系统调用来回收该进程的资源,那么它们会逐渐积累,最终导致系统资源的浪费。

为了避免僵尸进程的产生,父进程在创建子进程后应该及时调用wait()或waitpid()等系统调用来获取子进程的退出状态。如果父进程无法及时调用这些系统调用,可以考虑使用信号处理程序或者使用守护进程等方式来解决。

系统安排init进程回收孤儿进程

当一个进程的父进程在它退出前先退出了,该进程就会成为孤儿进程(Orphan Process),也就是没有父进程的进程。在Linux系统中,这些孤儿进程会被系统自动分配给进程号为1的init进程作为它们的新的父进程,这样就避免了孤儿进程的存在。

init进程是Linux系统的第一个进程,它负责启动其他进程并监控它们的运行状态。当一个进程成为孤儿进程时,它会被系统重新分配给init进程作为它的父进程。这样,当init进程调用wait()或waitpid()等系统调用时,就能够获取并回收这些孤儿进程的资源,避免了系统资源的浪费。

需要注意的是,init进程只负责回收孤儿进程,对于僵尸进程则需要其父进程调用wait()等系统调用来进行回收,否则僵尸进程会一直存在,导致系统资源的浪费。
例子:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main() {
    pid_t pid;
    pid = fork();
    if (pid < 0) {
        printf("Fork Failed\n");
        exit(1);
    }
    else if (pid == 0) {
        printf("Child process\n");
        while (1) {
            sleep(1);  // 子进程不退出,一直在运行
        }
    }
    else {
        printf("Parent process\n");
        exit(0);  // 父进程退出
    }
    return 0;
}

在该程序中,父进程创建了子进程,并在创建后立即退出了,但是子进程仍然在运行。执行该程序时,可以通过执行ps命令查看进程状态,如下所示:
在这里插入图片描述

一个僵尸现象的例子:
假设有一个父进程和一个子进程,父进程创建了子进程,并等待子进程的结束时,它的进程描述符仍然存在于系统中,但是父进程尚未调用wait()或waitpid()等系统调用来获取它的终止状态。此时子进程就会成为一个僵尸进程。.

#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main() {
    pid_t pid;
    pid = fork();
    if (pid < 0) {
        printf("Fork Failed\n");
        exit(1);
    }
    else if (pid == 0) {
        printf("Child process\n");
        exit(0);
    }
    else {
        printf("Parent process\n");
        sleep(2);  // 等待子进程结束
        // 父进程未调用wait()或waitpid()等系统调用获取子进程终止状态
        printf("Parent process exit without calling wait()\n");
    }
    return 0;
}

在该程序中,父进程创建了子进程,并等待子进程结束。但是在父进程中并没有调用wait()或waitpid()等系统调用来获取子进程的终止状态,从而导致子进程成为了一个僵尸进程。执行该程序时,可以通过执行ps命令查看进程状态,如下所示:
在这里插入图片描述
可以看到,进程状态标记为Z或Z+,也就是僵尸进程。这种情况下,需要父进程调用wait()或waitpid()等系统调用来获取子进程的终止状态,从而回收它的资源。

猜你喜欢

转载自blog.csdn.net/m0_56898461/article/details/129941185