【北京迅为】《iTOP-3588开发板系统编程手册》-第6章 进程-Linux系统分配的基本单位

RK3588是一款低功耗、高性能的处理器,适用于基于arm的PC和Edge计算设备、个人移动互联网设备等数字多媒体应用,RK3588支持8K视频编解码,内置GPU可以完全兼容OpenGLES 1.1、2.0和3.2。RK3588引入了新一代完全基于硬件的最大4800万像素ISP,内置NPU,支持INT4/INT8/INT16/FP16混合运算能力,支持安卓12和、Debian11、Build root、Ubuntu20和22版本登系统。了解更多信息可点击迅为官网   

【粉丝群】824412014

【实验平台】:迅为RK3588开发板

【内容来源】《iTOP-3588开发板系统编程手册》

【全套资料及网盘获取方式】联系淘宝客服加入售后技术支持群内下载

【视频介绍】:【强者之芯】 新一代AIOT高端应用芯片 iTOP -3588人工智能工业AI主板


第6章 进程-Linux系统分配的基本单位

在前面的几个章节中,我们分别对系统编程的一些基本知识、文件IO相关的系统调用、C语言库函数、目录IO、文件相关的属性以及系统中的IO缓存进行了讲解,相信大家已经对Linux系统编程拥有了自己的看法和见解,而在本章节将对Linux系统中的基本概念-进程进行讲解,下面让我们一起进入进程的学习吧!

6.1 程序和进程

学习前的问题:

  1. 什么是程序?
  2. 什么是进程?
  3. 进程和程序之间的关系是什么?

程序的概念相信大家已经并不陌生了,前面几个章节编写的代码以及编译生成的可执行文件都可以被称之为程序。程序通常由数据结构和算法两部分组成,数据结构和是指程序中用来组织和存储数据的方式,例如数组、链表、栈、队列等等,而算法是指程序中用来处理这些数据的具体方法,例如查找、排序、搜索、遍历等动作。

而当程序在Linux操作系统中运行起来之后就变成了进程。进程是Linux操作系统中的一种基本概念,他代表正在运行的一个程序实例,一个进程可以被视为一个独立的环境,他有自己的代码段、数据段、堆栈、文件描述符系统资源。

这里以停在路旁的汽车和在马路上行驶的汽车为例来进行类比,当汽车没有启动时会停靠在路边,这里的汽车就可以看作是一个程序,而当汽车发动了起来之后在马路上行驶,就由程序变成了进程,下面对程序和进行的关系进行陈述:

  1. 程序是静态的是一组指令的集合,而进程是程序的一次执行过程,是动态的。
  2. 程序存储在磁盘等外部存储设备上,而进程是运行在内存中的程序实例。
  3. 当一个程序被执行时,操作系统会为其创建一个进程,进程包含了程序代码、数据和执行状态等信息。
  4. 一个程序可以对应多个进程,例如一个程序可以在不同的时间、不同的输出下产生不同的进程。
  5. 进程可以同时执行多个程序,但一个程序一次只能被一个进行执行
  6. 进程可以互相通信,而不同程序之间通常是相互独立的

程序

进程

定义

一组指令的集合,静态的

程序的一次执行过程,动态的实体

存储

存储在外部设备上(例如磁盘)

在内存中运行实例

创建

需要被操作系统加载到内存中,操作系统会为其创建一个进程

由操作系统创建

包含

指令集合、静态的代码、数据

程序代码、数据、执行状态等信息

关系

一个程序可以对应多个进程

进程之间可以相互通信和协作,不同程序之间通常是相互独立的

执行

不具有执行能力的代码,需要被加载到内存中才能运行

具有执行能力的实体,可以同时执行多个程序,一个程序一次只能被一个进程执行

6.2 进程的创建

本小节代码在配套资料“iTOP-3588开发板\03_【iTOP-RK3588开发板】指南教程\03_系统编程配套程序\30”目录下,如下图所示:

在上一小节中讲解了程序和进程之间的关系,当程序在内存中运行后就变成了进程。而当一个程序要实现的功能很复杂或者需要并行处理时,一个进程就不能再满足我们的需求了,那在Linux操作系统中要如何进行进程的创建呢?就让我们一起进入本小节的学习吧!

学习前的疑问:

  1. 为什么要创建子进程?
  2. 如何对子进程进行创建?
  3. fork()函数和vfork()函数的区别?

首先对Linux操作系统中创建子进程的必要性进行阐述,具体内容如下:

(1):实现并发处理:通过创建多个子进程,可以实现并发处理,从而提高系统的处理能力和效率。每个子进程可以独立地处理不同的任务或请求,从而避免了单个进程阻塞的问题。

(2):保护父进程:当父进程执行某些危险操作时,如修改系统配置、执行危险命令等,可以创建子进程来执行这些操作。这样可以保护父进程不受到恶意代码或其他攻击的影响,保证系统的稳定性和安全性。

(3):资源隔离:每个进程都有自己独立的内存空间和资源,因此创建子进程可以实现资源隔离。当子进程需要占用大量资源时,不会影响到其他进程的正常运行。

(4):实现多任务处理:通过创建多个子进程,可以实现多任务处理,从而提高系统的效率和处理能力。不同的子进程可以同时执行不同的任务,从而提高整个系统的处理效率。

(5):进程间通信:创建子进程后,可以使用进程间通信机制(如管道、共享内存、信号量等)来实现进程间的数据交换和协同工作。

当然上述必要性之间并不是互相独立的,在实际的使用中它们可能会相互结合使用。到这里想必大家已经知道了子进程创建的必要性了吧,那在Linux系统中要如何对子进程进行创建呢,答案是使用fork()函数和vfork()函数。两个函数实现的功能相同,在这里首先对fork()函数进行介绍:

fork() 函数是 Linux 操作系统中常用的一个系统调用,用于创建一个新的进程,该进程与原进程(即父进程)具有相同的代码和数据空间,但拥有自己独立的内存空间、进程 ID 和文件描述符等。

fork()函数所使用的头文件和函数原型,如下所示:

所需头文件

函数原型

1

#include <unistd.h>

pid_t fork(void);

其中,pid_t 类型表示进程 ID,fork() 函数的调用结果有三种情况:

如果返回值为 0,则说明当前进程是子进程;

如果返回值大于 0,则说明当前进程是父进程,返回值为子进程的进程 ID;

如果返回值小于 0,则说明 fork() 函数调用失败。

为了加深大家对于fork()函数的理解,进行下面的实验:

实验步骤:

首先进入到ubuntu的终端界面输入以下命令来创建demo30_fork.c文件,如下图所示:

vim demo30_fork.c

 然后向该文件中添加以下内容:

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

int main() 
{
    pid_t pid;   // 定义一个变量 pid,用于存储子进程的 ID
    pid = fork();   // 调用 fork() 函数创建子进程

    if (pid == -1)  // 判断 fork() 函数是否调用成功
	{
        printf("fork error!\n");    // 输出错误信息
        exit(1);    // 退出程序,返回状态码 1
    } 
	else if (pid == 0)  // 判断当前进程是否为子进程
	{
        printf("child process, pid=%d\n", getpid());    // 输出子进程的 ID
    } 
	else    // 父进程执行这里
	{
        printf("parent process, pid=%d, child pid=%d\n", getpid(), pid);    // 输出父进程和子进程的 ID
    }

    return 0;   // 返回程序执行结果
}  

这段代码在第8行使用fork()函数创建一个子进程,子进程会复制原进程的代码段、数据段和执行状态等信息。

第10行到23行对fork()的返回值进行判断(getpid()函数的作用为获取当前进程pid),

如果调用失败(即返回值等于-1),输出错误信息并退出程序;如果返回值为0,表示当前进程为子进程,输出子进程的进程ID;如果返回值大于0,表示当前进程为父进程,输出父进程和子进程的进程ID。

上述代码展示了如何使用 fork() 函数创建子进程,并演示了如何使用 if 语句判断当前进程是父进程还是子进程及如何使用 getpid() 函数获取当前的进程ID。

保存退出之后,使用以下命令对demo30_fork.c进行编译,编译完成如下图所示:

gcc -o demo30_fork demo30_fork.c

然后使用命令“./demo30_fork”来运行,运行成功如下图所示: 

在调用fork()函数时,操作系统会将当前进程的所有资源复制一份给子进程,这个过程需要花费一定的时间。因此,为了避免不必要的延迟,fork()函数首先会执行父进程,父进程pid通过getpid()函数获取到其数值为3956,子进程的数值为fork()函数创建子进程成功后返回的(pid 只有在父进程中才能查看),其数值为3957。

至此,关于fork()函数的相关实验就完成了。

在使用fork()函数创建子进程时,子进程会完全复制父进程的地址空间,主要包括以下资源:

(1)程序代码段、数据段、堆和栈;

(2)文件描述符表,但是文件描述符所指向的文件对象并不会复制,而是共享;

(3)环境变量;

(4)信号处理函数;

(5)与进程相关的属性,如进程 ID、进程组 ID、进程优先级等。

但是在某些情况下,完全复制父进程的地址空间可能会导致资源浪费和性能问题,因此 Linux内核提供了写时复制技术(Copy-On-Write,简称 COW)。写时复制技术是一种内存管理技术,可以让子进程在需要修改父进程地址空间中的某些页时,将这些页复制到自己的地址空间中,而不是在创建子进程时就将整个地址空间进行复制。这种技术可以减少父进程和子进程之间的内存复制,从而提高程序的性能和效率。

需要注意的是,写时复制技术只对进程地址空间中的某些页进行复制,并不会对整个地址空间进行复制。因此,这种技术可以节省大量的系统资源和时间,特别是在父进程和子进程之间存在大量共享的情况下。同时,由于写时复制技术可以保证父进程和子进程之间的内存隔离性,因此可以提高程序的稳定性和安全性。

而在本小节最开始提到的vfork()函数是一种比较特殊的进程创建函数,它的作用是创建一个子进程,子进程与父进程共享同一个地址空间,包括程序代码、数据、堆栈等,只有在子进程调用exec()函数族时(关于exec()函数族会在下一小节进行讲解),新的程序代码和数据才会被加载到子进程的地址空间中。在vfork()函数调用成功之后,父进程和子进程共享同一个地址空间,但是子进程会阻塞父进程,直到它调用exec()函数(会在下一个小节对exec()函数进行讲解)或 _exit()函数为止。

对上述fork()函数的实验代码进行修改,将第8行的fork()函数替换为vfork()函数,修改完成如下所示:

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

int main() 
{
    pid_t pid;
    pid = vfork();  // 调用vfork()函数创建子进程

    if (pid == -1)  // 判断vfork()函数是否调用成功
    {
        printf("vfork error!\n");
        exit(1);
    }
    else if (pid == 0)  // 子进程执行这里
    {
        printf("child process, pid=%d\n", getpid());  // 输出子进程的进程ID
        _exit(0);  // 子进程执行完毕,调用_exit()函数退出程序
    }
    else  // 父进程执行这里
    {
        printf("parent process, pid=%d, child pid=%d\n", getpid(), pid);  // 输出父进程和子进程的进程ID
    }

    return 0;
}

保存退出之后,使用以下命令对demo31_vfork.c进行编译,编译完成如下图所示:

gcc -o demo31_vfork demo31_vfork.c

然后使用命令“./demo31_vfork”来运行,运行成功如下图所示: 

可以看到这次是先运行的子线程,子线程运行结束之后才会运行父进程。至此关于vfork()

函数的实验也就结束了。

6.3 程序的执行

本小节代码在配套资料“iTOP-3588开发板\03_【iTOP-RK3588开发板】指南教程\03_系统编程配套程序\32”目录下,如下图所示:

在上一小节中对fork()函数和vfork()函数创建新进程进行了学习,相应的实验示例只是在子进程中加入了进程号的打印,而在实际的使用中子进程往往会调用exec()函数用来执行和加载新的程序,以此来完成更多的功能。在本小节中将对exec()函数进行讲解。

学习前提出的问题:

  1. 在子进程中为何要使用exec()函数运行新程序。
  2. exec()函数要如何使用?

在Linux操作系统中,子进程的创建往往和exec函数一起使用,用来实现以下功能:

(1)首先使用fork函数或者vfork函数创建一个子进程,子进程拥有父进程的所有资源,包括代码、数据、堆栈等。如果子进程希望执行不同的程序或替换自己的代码段、数据段等资源,就需要调用exec函数了。

(2)调用exec函数后,会替换掉子进程原有的代码、数据等资源(即从父进程拷贝过来的一些列资源),从而实现新程序的执行。exec函数会将新程序加载到子进程的地址空间中,并将程序入口地址作为新的堆栈指针,然后跳转到程序入口地址开始执行。

通过上述这种方式,就可以在一个程序中创建多个进程,每个进程可以执行不同的程序或者执行相同的程序但是使用不同的参数。该方法被广泛应用于操作系统中的进程管理和多进程编程中。

上面的“exec()函数”这个名称并不正确,准确来说应该是exec()函数族,exec函数族是在Linux/Unix操作系统中用于执行新程序的函数族。这些函数以“exec”开头,后面跟有不同的字母或组合,如“execl”、“execv”、“execle”、“execlp”、“execvp”等。这些函数都有相似的功能。

这些函数族的共同特点是,它们都可以用来取代当前进程的映像,也就是说,用新程序替代当前程序,但不会改变当前进程的PID,文件描述符等信息。这些函数族的参数列表也有所不同,可以根据需要选择不同的函数。

下面列举一些常用的exec函数族函数:

函数名称

函数作用

execl

将新程序加载到当前进程空间中,替换原来的程序映像,并传递参数列表。

execle

与execl类似,但允许指定环境变量。

execlp

搜索环境变量PATH指定的目录,将新程序加载到当前进程空间中,并传递参数列表。

execv

将新程序加载到当前进程空间中,替换原来的程序映像,并传递参数列表。参数使用数组形式传递。

execvp

搜索环境变量PATH指定的目录,将新程序加载到当前进程空间中,并传递参数列表。参数使用数组形式传递。

由于上面的函数作用相同这里仅以execvp()函数为例进行讲解,如果有同学对其他函数感兴趣可以自行百度或者通过ubuntu自带的man命令查看相应的使用方法。execvp()函数使用的头文件和函数原型,如下所示:

所需头文件

函数原型

1

#include <unistd.h>

int execvp(const char *file, char *const argv[]);

函数调用成功后不会有返回值,调用失败返回-1,并设置 errno。

execvp()函数参数含义如下所示:

参数名称

参数含义

filename

参数 filename 指向需要载入当前进程空间的新程序的路径名,既可以是绝对路径、也可以是相对路径。

2

argv

参数 argv 则指定了传递给新程序的命令行参数。是一个字符串数组,该数组对应于 main(int argc,char *argv[])函数的第二个参数 argv,且格式也与之相同,是由字符串指针所组成的数组,以 NULL 结束。 argv[0]对应的便是新程序自身路径名。

为了让大家对execvp()函数理解的更通透,进行下面的实验:

实验要求:

使用了fork()和execvp()函数来创建一个子进程并在其中执行"ls -l"命令。

实验步骤:

首先进入到ubuntu的终端界面输入以下命令来创建demo32_execvp.c文件,如下图所示:

vim demo32_execvp.c

然后向该文件中添加以下内容: 

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

int main(int argc, char *argv[])
{
    pid_t pid = fork();  //创建一个子进程,并将子进程的PID存储在pid变量中

    if (pid == -1)  //如果fork()函数返回-1,则表示创建子进程失败
    {
        printf("Fork failed.\n");
        return 1;
    }
    else if (pid == 0)  //如果pid为0,则表示当前代码正在子进程中执行
    {
        //子进程中的代码
        char *args[] = {"ls", "-l", NULL};  //要执行的命令及其参数
        execvp(args[0], args);  //使用execvp()函数执行命令
        printf("This line will not be executed.\n");  //如果execvp()函数执行失败,这一行代码会被执行
        exit(0);  //退出子进程
    }
    else  //如果pid大于0,则表示当前代码正在父进程中执行
    {
        wait(NULL);  //父进程中调用wait()函数等待子进程结束
        printf("Child process has finished.\n");  //当子进程结束后,父进程输出一条消息
    }
    return 0;  //返回0表示程序正常结束
}

第18行创建了char类型的 *args数组,用来存放输入的命令,第19行使用execvp()函数执行新程序,所以并不会执行20行的打印程序和第21行的进程退出程序(关于进程的退出会在下一小节进行讲解),而在第26行父进程使用了wait()函数用来等待子进程结束,关于wait()函数相关的讲解放在了6.5小节,这里就不再赘述。

保存退出之后,使用以下命令对demo32_execvp.c进行编译,编译完成如下图所示:

gcc -o demo32_execvp demo32_execvp.c

然后使用命令“./demo32_execvp”来运行,运行成功如下图所示: 

可以看到程序运行成功之后,”ls -l”命令就成功运行了出来,子进程中的“This line will not be executed”字符串也并没有打印,至此关于程序的执行的内容就结束了。

6.4 进程的退出

在前面两个小节的实验代码中使用了_exit()函数和exit()函数退出当前进程,在相应代码的实现过程中大家有没有产生这样的疑问,为什么在进程的最后要执行进程退出这一动作呢?上面的两个函数在实际的使用中存在怎样的区别呢?带着这些疑问让我们开始本小节的学习吧。

首先对“进程执行完所有任务后退出进程”这一动作的必要性进行说明,具体原因如下:

(1)释放资源:当进程退出时,操作系统会回收进程占用的所有资源,包括内存、文件句柄等。如果进程不退出,它所占用的资源就不能被释放,这可能会导致系统出现资源短缺或者资源浪费的问题。

(2)防止僵尸进程:如果进程没有正常退出,它会变成一个僵尸进程。僵尸进程是一种已经结束了执行,但是它的父进程没有调用wait()或者waitpid()函数回收它的状态信息的进程(关于上述两个函数会在下一小节中进行讲解)。如果系统中存在大量的僵尸进程,它们会占用系统的资源,导致系统运行缓慢,严重时甚至会崩溃。

(3)程序正确性:进程的正常退出也是程序正确性的一部分。程序应该在完成所有的任务后,以正确的方式退出,这样可以保证程序的正确性和稳定性。

至此,退出进程的重要性就叙述完毕了,在Linux操作系统中通常使用C语言库函数exit()函数和系统调用_exit()来退出当前进程,首先对C语言库函数exit()进行讲解。

exit()函数使用的头文件和函数原型,如下所示:

所需头文件

函数原型

1

#include <stdlib.h>

void exit(int status);

调用exit()函数将导致当前进程终止,并将退出状态设为status。子进程的退出状态可以在父进程中通过wait()函数获取(关于wait()函数相关的内容会在下一小节中进行讲解)。程序终止时,它将执行一些清理工作,例如关闭所有打开的文件和释放所有动态分配的内存。在程序终止之前,可以使用atexit()函数注册一些终止处理程序,这些处理程序将在程序终止时被执行。

需要注意的是,exit()函数不会直接关闭文件描述符,也不会释放动态分配的内存。所以在调用exit()函数之前,可以使用fclose()函数关闭文件描述符,并使用free()函数释放动态分配的内存。另外,由于exit()函数是在终止程序之前执行清理工作的,因此它可能会导致一些不必要的延迟。

而_exit()是一个系统调用函数,用于立即终止一个进程并返回一个整数值给调用者。_exit()函数所需头文件和对应的函数原型如下所示:

所需头文件

函数原型

1

#include <unistd.h>

void _exit(int status);

_exit()是一个系统调用函数,它可以在不进行任何清理操作的情况下,立即终止进程并返回一个整数值给调用者,它的作用类似于在main()函数中使用return语句,但它可以在程序的任何地方调用。 

由于子进程是由父进程复制而来,子进程在退出时可能会继承父进程打开的文件描述符,如果子进程使用exit()函数,那么会触发文件描述符的关闭,导致父进程无法再次使用这些文件描述符。而_exit()函数会直接退出子进程,不会像exit()函数一样做一些额外的清理工作,因此,在子进程中使用_exit()函数可以避免这种问题,并且可以保证子进程退出时不会留下任何垃圾。

而在父进程中通常使用C语言库函数exit()来退出进程,父进程的主要任务是协调和控制子进程的执行,如果子进程没有按照预期执行,父进程可以使用exit()函数退出进程,以确保程序的正确性和稳定性。同时,父进程也需要在退出之前需要执行必要的清理操作,例如关闭文件、释放内存等。

最后对上述两个函数进行对比,具体信息如下图所示:

特点/区别

exit() C语言库函数

_exit()系统调用函数

作用

终止进程并返回到调用它的程序中

直接终止进程,不返回到调用它的程序中

调用位置

应该位于程序的最后一个语句或需要立即退出程序的地方

建议在子进程中使用

清理资源

会清理进程中未释放的资源,例如打开的文件和分配的内存等

不会清理资源

参数

status表示进程的退出状态,可以用来向父进程传递信息

status表示进程的退出状态,但不会向父进程传递信息

返回值

至此,关于进程退出相关的知识就讲解完成了,由于本小节知识与“6.5 等待进程中止”小节有连贯性,所以本小节就不再进行代码实验了,大家可以直接进入下一小节的学习。

6.5 等待子进程中止

本小节代码在配套资料“iTOP-3588开发板\03_【iTOP-RK3588开发板】指南教程\03_系统编程配套程序\33”目录下,如下图所示:

学习前的疑问:

1.父进程为什么要等待子进程中止?

2.wait()函数要怎样进行使用?

3.如何通过宏来解析子进程的返回状态?

在Linux操作系统中,通常使用wait()函数用来等待子进程中止,首先对wait()函数的作用进行陈述说明:

(1)通过wait()函数可以获取子进程的返回状态,从而判断子进程是否执行成功。

(2)wait()函数可以回收子进程的资源,防止子进程成为“僵尸进程”。当子进程终止时,其在进程表中的记录并不会立即被删除,而是需要等待父进程调用wait()函数来回收其资源。如果父进程没有及时回收,那么子进程的记录将一直保存在进程表中,成为“僵尸进程”,占用系统资源,降低系统的稳定性和性能(关于僵尸进程相关的内容会在下一章节中进行讲解)。

(3)wait()函数可以实现进程间的同步和通信。父进程可以通过wait()函数等待子进程完成某些任务,然后再继续执行自己的任务。此外,子进程可以通过exit()函数的返回值来向父进程传递处理结果或者状态。

wait()函数使用的头文件和函数原型,如下所示:

所需头文件

函数原型

1 

#include <sys/wait.h>

pid_t wait(int *status);

wait()函数的参数是一个指向整型变量的指针,它用于存储子进程的退出状态信息。在调用wait()函数后,该指针指向的整型变量将被赋值为子进程的退出状态。在父进程中,通常会定义一个整型变量来存储子进程的退出状态信息。在调用wait()函数时,将该整型变量的地址传递给wait()函数,wait()函数会将子进程的退出状态信息存储在该整型变量中,父进程就可以根据该信息判断子进程的运行结果是否成功。当该指针为空指针时,wait()函数就不会返回子进程的退出状态信息。wait()函数的返回值为子进程的进程ID,即pid。

wait()函数会阻塞当前进程,直到有一个子进程结束或者收到一个信号为止。如果在等待过程中收到了一个信号,则wait()函数会返回-1(即不是子进程结束而停止的wait()函数阻塞),并设置errno为EINTR。此时,父进程可以根据需要重新调用wait()函数或者进行其他处理。

另外,在多个子进程存在的情况下,wait()函数只能获取到其中一个子进程的退出状态,如果需要获取所有子进程的退出状态,则需要多次调用wait()函数。如果父进程不关心子进程的退出状态,可以使用waitpid()函数来回收子进程资源,该函数具有更加灵活的选项(本小节不会对该函数进行过多的讲解,有感兴趣的同学可以自行了解一下)。

需要注意的是,使用_exit()系统调用函数退出子进程,wait()函数也可以等待子进程退出,并且可以获取到子进程退出的状态,但是获取到的状态只能够说明子进程是否正常退出,并不能获取到退出状态码和传递的state。如果需要获取这些信息,只能够使用exit()函数来退出子进程。

在头文件<sys/wait.h>中,还定义了用来获取子进程终止状态的一些宏,这些宏定义主要用于解析子进程终止时返回的状态值,以获取子进程的退出状态码、终止信号等信息,部分宏定义和相应的功能解释如下所示:

宏定义

功能解释

WEXITSTATUS(status)

该宏用于获取子进程的退出状态。当WIFEXITED(status)为真时,使用该宏可以获取子进程的退出状态值,这个值在子进程调用exit()或_main()函数时被传递。

WTERMSIG(status)

该宏用于获取导致子进程终止的信号编号。当WIFSIGNALED(status)为真时,使用该宏可以获取导致子进程终止的信号编号,这个信号可能是一个致命的信号(如SIGKILL、SIGSEGV等)。

WIFEXITED(status)

该宏用于检测子进程是否正常终止(即通过调用exit()或_main()函数返回)。如果子进程正常终止,则该宏返回非零值。

WSTOPSIG(status)

该宏用于获取导致子进程暂停的信号编号。当WIFSTOPPED(status)为真时,使用该宏可以获取导致子进程暂停的信号编号,这个信号可能是一个暂停信号(如SIGSTOP、SIGTSTP等)。

WIFSIGNALED(status)

该宏用于检测子进程是否由于未捕获的信号而终止。如果子进程由于未捕获的信号而终止,则该宏返回非零值。

WIFSTOPPED(status)

该宏用于检测子进程是否暂停。如果子进程暂停,则该宏返回非零值。

WIFCONTINUED(status)

该宏用于检测子进程是否由于收到SIGCONT信号而恢复执行。如果子进程由于收到SIGCONT信号而恢复执行,则该宏返回非零值。

下面进行以下实验,巩固刚刚学到的知识。

实验步骤:

首先进入到ubuntu的终端界面输入以下命令来创建demo33_wait.c文件,如下图所示:

vim demo33_wait.c

然后向该文件中添加以下内容: 

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

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

    if (pid == 0) 
	{ // 这是子进程
        printf("Child process: my pid is %d\n", getpid());
        exit(42); // 子进程退出并返回状态码 42
    } 
	else if (pid > 0) 
	{ // 这是父进程
        printf("Parent process: my pid is %d and my child's pid is %d\n", getpid(), pid);
        int status;
        wait(&status); // 等待子进程退出,子进程的退出状态保存在 status 中
        if (WIFEXITED(status)) 
		{ // 子进程正常退出
            printf("Child process exited with status code %d\n", WEXITSTATUS(status)); //打印获取到的子进程退出状态码
        }
		exit(0);
    } 
	else 
	{ // fork函数创建子进程出错
        perror("fork failed");
        exit(-1);
    }

    return 0;
}

在子进程中,输出子进程的ID,然后调用 exit() 函数退出子进程,并返回状态码42。

在父进程中,输出父进程和子进程的ID,然后调用 wait() 函数等待子进程退出,并将子进程的退出状态保存在 status 变量中。接着,程序使用 WIFEXITED(status) 宏检查子进程是否正常退出,如果是,就使用 WEXITSTATUS(status) 宏获取子进程的退出状态码,并将其输出。

保存退出之后,使用以下命令对demo33_wait.c进行编译,编译完成如下图所示:

gcc -o demo33_wait demo33_wait.c

然后使用命令“./demo33_wait”来运行,运行成功如下图所示: 

可以看到程序运行成功之后,父进程和子进程相应的代码都会执行,最重要的是在父进程中正确的将子进程的退出状态值打印了出来,至此关于程序的执行的内容就结束了。

6.6 查看进程状态

相信经过了前面几个章节的学习,大家已经对进程有了初步的认识,而在本小节将对进程状态相关内容进行讲解,本小节要讲解的内容如下:

1.对Linux进程的五个状态进行讲解

2.对进程状态的查看命令ps、top进行讲解

3.对进程和系统信息的proc虚拟文件系统进行讲解

6.6.1 Linux进程状态

在Linux中,进程状态可以分为五个状态:运行状态、可中断睡眠状态、不可中断睡眠状态、停止状态和僵尸状态。这些状态通常与进程所处的上下文和 CPU 的状态密切相关。

(1)运行状态:运行状态指的是进程正在执行的状态,处于这种状态下的进程正在使用 CPU 进行计算工作,处于用户态或内核态。

(2)可中断睡眠状态:可中断睡眠状态是指进程因等待某个事件发生而暂时被挂起的状态,进程在这种状态下是可以被中断的,当等待的事件发生后,进程会被唤醒并转换为运行状态。可中断睡眠状态通常是通过系统调用而进入的,如等待用户输入、等待文件读写等等。

(3)不可中断睡眠状态:不可中断睡眠状态是指进程等待某个事件发生而暂时被挂起的状态,进程在这种状态下是不可被中断的,只能在事件发生或者系统出错时被唤醒。不可中断睡眠状态通常是由于进程等待硬件设备或某些资源的释放而进入的,如等待磁盘 I/O 操作完成、等待网络数据包到达等等。

(4)暂停状态:暂停状态是指进程被暂停运行的状态,可以通过系统调用(如 kill)或者信号(如 SIGSTOP)来将进程从运行状态转换到停止状态(关于kill 和信号相关的知识会在下一小节中进行讲解),也可以通过系统调用(如 wait)将一个子进程转换到停止状态。停止状态下的进程不会被调度执行,直到它被明确地唤醒为止。

(5)僵死状态:僵死状态是指进程已经终止,但是其父进程还没有调用 wait 或 waitpid 函数来获取其终止状态的状态。在 Linux 中,每个进程在终止时都会变成僵尸进程,直到其父进程获取其终止状态后才会被清除。

关于上面五个进程状态的相关转换关系,如下图所示:

需要注意的是,上图中的就绪态并不是进程的一种状态,而是指进程已经准备好运行,等待CPU资源的状态。在进程被调度器选中并开始占用CPU资源之前,进程不会被归为任何状态,只能算是就绪态。因此,就绪态并不是Linux中进程的一种标准状态,而是进程调度过程中的一个概念。

至此关于Linux进程状态的讲解就结束了,接下来将对进程状态的查看命令ps和top进行讲解。

6.6.2 ps命令

ps 命令是 Linux/Unix 操作系统中用于查看进程信息的命令,可以查看当前系统中正在运行的进程、进程的状态、进程的资源占用情况等信息。

ps 命令格式如下所示:

ps [options]

部分命令选项如下表所示:

options

参数讲解

a

显示所有进程信息,包括其他用户的进程

x

显示所有进程,包括没有控制终端的进程

u

显示用户及资源使用情况

e

显示所有进程信息,包括没有控制终端的进程,和x选项相比没有进程状态

f

以进程树的形式显示进程信息

w

使用宽输出格式,以便完整地显示进程信息

h

不显示标题行

o

自定义输出格式,可以指定输出的列和列之间的分隔符

上述命令选项可以组合使用,其中最常用的“ps -aux”,表示显示当前系统中所有进程的详细信息,包括进程的所有者、CPU 占用率、内存占用情况、进程状态等,在虚拟机ubuntu的终端输入后,如下图所示:

下面对第一行出现的关键字进行解释:

关键字

关键字含义

USER

进程的用户名。

PID

进程的ID号。

%CPU

进程占用CPU的使用率。

%MEM

进程占用内存的使用率。

VSZ

进程占用的虚拟内存大小(单位:KB)。

RSS

进程占用的实际内存大小(单位:KB)。

TTY

进程绑定的控制台。

STAT

进程状态,包括:

R:运行状态(Running)。

S:可中断睡眠状态(Sleeping)。

D:不可中断睡眠状态(Uninterruptible Sleep)。

T:停止状态(Stopped)。

Z:僵尸状态(Zombie)。

START

进程启动时间,格式为HH:MM或者年月日。

TIME

进程累计CPU占用时间,格式为分钟:秒钟。

COMMAND

进程启动的命令行,包括命令和参数。

这些关键字可以方便地了解进程的基本信息,同时也可以对进程进行监控和管理,例如通过CPU和内存占用率判断进程性能和资源消耗情况,通过进程状态判断进程运行情况,通过进程启动时间和累计CPU占用时间判断进程运行时间和占用资源情况等。

6.6.3 top命令

top是一个常用的Linux系统性能监控工具,可以实时查看系统的CPU、内存、I/O等资源的使用情况,同时还可以查看当前系统中运行的进程和它们的相关信息,是系统管理员和性能优化工程师必不可少的工具之一。

top命令的使用方式为在终端输入“top”,然后会显示一个实时更新的系统资源使用情况的监控面板,具体内容如下:

第一行:显示当前时间、系统运行时间、登录用户数量、系统平均负载(即过去1分钟、5分钟和15分钟的CPU使用率平均值)。

第二行:显示总共有多少个进程,以及其中有多少个运行状态、多少个休眠状态、多少个僵尸状态、多少个停止状态等。

第三行:显示系统CPU使用率的统计信息,包括用户空间占用率、内核空间占用率、空闲率等。

第四行:显示系统内存使用情况的统计信息,包括总内存、已使用内存、剩余内存、缓存占用内存等。

第五行至最后:显示各个进程的详细信息,包括进程ID、进程所有者、进程状态、占用CPU百分比、占用内存百分比、虚拟内存大小、实际内存大小、进程名称等。

在top的进程列表中,可以通过按键来进行不同的操作,例如:

按键

按键作用

P

按CPU使用率排序。

M

按内存使用率排序。

T

按进程运行时间排序。

k

杀死指定进程。

r

重新设定进程的优先级。

q

退出top。

top是一个非常强大的系统监控工具,可以帮助我们实时监测系统性能、定位性能瓶颈、杀死占用资源过多的进程等。但是需要注意的是,由于top是一个实时监控工具,它本身也需要占用系统资源,因此在使用时应避免对系统性能产生过大的影响。

当我们在使用ps命令和top命令时,实际上是在访问/proc文件系统中的这些文件和目录,从而获取系统的进程信息和资源使用情况,然后将这些信息格式化后展示给用户。因此,可以说ps命令和top命令是/proc文件系统的一种用户接口(关于proc文件系统的知识会在下一小节中进行讲解)。

6.6.4 proc虚拟文件系统

进程(process)是计算机中最基本的执行单位,每个进程都有自己的进程号(PID)以及一些相关的信息,例如进程的状态、内存使用情况、打开的文件等等,在Linux操作系统中,这些信息被组织在/proc虚拟文件系统中,以文件的形式呈现出来。/proc虚拟文件系统提供了一种机制,让用户和应用程序可以在运行时动态地获取进程和系统的信息,也可以修改内核参数,以及与其他系统工具的配合。下面我们将对/proc虚拟文件系统进行更加详细的介绍。

/proc是一个特殊的文件系统,被称为procfs,是一个伪文件系统(pseudo-filesystem)。procfs虚拟文件系统并不是在磁盘上存储的,而是在内存中动态生成的,它的内容是内核中运行进程和系统状态信息的快照,以及一些可以修改的内核参数设置。/proc目录下的文件和目录,其实就是内核中进程和系统信息的数据结构在用户空间的映射。虚拟机ubuntu的proc目录如下所示:

首先会看到一大堆以数字命令的文件夹,这些数字文件夹代表系统进程的PID。例如,/proc/1就代表着进程号为1的进程(init进程)。这个目录下有一些文件,列出了进程的相关信息,如下图所示:

下面对部分重要文件进行讲解概况:

文件名称

文件内容概述

cmdline

进程的完整命令行参数

cwd

进程当前的工作目录,也就是进程在哪个目录下运行

environ

进程的环境变量

exe

进程的可执行文件的绝对路径。例如:

fd

进程打开的所有文件描述符。每个文件描述符对应一个文件或者设备

maps

进程的虚拟内存映射信息。

status

进程的状态信息,例如进程的PID、父进程的PID、CPU占用率、内存使用情况等。

此外,/proc目录下还有一些文件和目录,它们提供了系统的一些状态信息,部分重要文件内容概述如下:

文件名称

文件内容概述

/proc/cpuinfo

包含有关CPU的详细信息,例如CPU型号、速度、缓存大小等。

/proc/meminfo

包含有关系统内存使用情况的信息,例如总内存量、可用内存量、缓存量等。

/proc/loadavg

包含有关系统负载的信息,包括最近1分钟、5分钟和15分钟的平均负载。

/proc/version

包含有关正在运行的内核版本的信息,例如版本号、编译日期等。

/proc/stat

该文件提供了有关CPU使用情况和系统各个进程的详细统计信息,例如CPU时间片的使用情况、进程数量、上下文切换次数等

/proc/filesystems

列出了当前系统支持的文件系统类型

/proc/net/tcp

该文件列出了当前系统上所有TCP连接的详细信息,包括本地地址、远程地址、状态等

/proc/net/udp

该文件列出了当前系统上所有UDP连接的详细信息,包括本地地址、远程地址、状态等

/proc/sys/kernel/hostname

该文件包含当前主机的主机名

例如,可以在/proc目录下使用“cat version”命令查看当前内核版本号,如下图所示:

可以在/proc目录下使用“cat meminfo”命令查看当前系统的内存使用情况的信息,如下图所示:

至此,关于proc虚拟文件系统的讲解到这里就完成了。/proc文件系统不仅可以用于系统调试和性能优化,还可以用于系统的监控和管理,比如可以使用shell脚本和工具来查看和监控系统资源的使用情况,或者使用一些系统工具来分析系统性能瓶颈,进而对系统进行优化和调整。需要注意的是,/proc文件系统中的某些信息是只读的,而其他的则是可写的。因此,在修改/proc文件系统中的信息时需要非常谨慎,避免对系统造成不必要的影响。

6.7 进程间通信:信号

相信大家到现在已经做过了相当多的实验,那大家有没有使用“ctrl + c”中止掉当前正在运行的程序呢,答案是肯定的。但是有没有同学思考过其中的原理呢?为什么使用“ctrl + c”可以中止掉当前正在运行的程序?还有没有类似功能的快捷键呢?本章节将会带给你答案。

通过在终端中按下“Ctrl+C”组合键来终止一个正在运行的程序时,实际上是向该进程发送了一个SIGINT信号,这个信号的默认行为是终止进程。这是一种在Linux中常用的信号机制,通过向进程发送不同的信号可以实现不同的操作。上述的“Ctrl+C”组合键操作就可以看作是进程间通信(IPC)

在Linux系统中提供了多种IPC机制,例如管道、共享内存、消息队列等等(上述机制会在之后的章节中进行讲解),而本小节要讲解的信号(signal)也是其中之一。信号是一种轻量级的IPC机制,可以用于在进程间传递简单的信息和通知。

6.7.1 kill命令

本小节代码在配套资料“iTOP-3588开发板\03_【iTOP-RK3588开发板】指南教程\03_系统编程配套程序\34”目录下,如下图所示:

kill 命令在 Linux 操作系统中用于向指定的进程发送信号,以达到控制进程行为的目的,其基本语法为:

kill [signal] PID

其中,signal 是要发送的信号名称或编号,PID 是要接收信号的进程 ID。

以下是 kill 命令的常用选项和参数:

常用参数

参数意义

-l

列出所有可用的信号。

-s

指定要发送的信号名称或编号,默认为 SIGTERM。

-<signal>

指定要发送的信号,信号可以使用名称或编号表示。

PID

要接收信号的进程 ID。

在虚拟机终端下,输入以下“kill -l”命令进行可用信号的查看,如下图所示:

下面对一些常用的信号进行解释,如下所示:

编号

信号名称

信号作用

1

SIGHUP

挂起信号,通常用于重新读取配置文件等操作

2

SIGINT

中断信号,通常由Ctrl+C发送

9

SIGKILL

强制杀死信号,无法被捕获、忽略或阻塞

15

SIGTERM

终止信号,可以被进程捕获、忽略或阻塞

20

SIGSTOP

暂停信号,可以被进程捕获、忽略或阻塞

18

SIGCONT

继续运行信号,用于从暂停状态恢复进程的运行

下面编写对应的测试程序demo34_kill.c,代码内容如下所示:

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

int main(char argc, int *argv[])
{
    int count; // 定义计数器
    printf("this pid is %d\n", getpid()); // 输出当前进程的pid
    while(1) // 进入循环,无限制地执行以下代码
    {
        sleep(1); // 等待1秒钟
        printf("the count is %d\n", count); // 输出计数器的值
        count++; // 计数器加1
    }
    return 0; // 返回0,表示程序正常结束
}

该程序的作用是输出当前进程的pid,并在一个无限循环中输出计数器的值。程序通过sleep(1)函数每隔1秒钟输出一次计数器的值。这里打印pid值是为了方便向该进程发送信号,从而测试kill命令,保存退出之后,使用以下命令进行程序的编译,编译完成如下图所示:

 gcc -o demo34_kill demo34_kill.c

然后使用“./demo34_kill”命令运行该程序,如下图所示: 

可以看到该进程的pid为3115,打开第二个终端输入以下命令向该进程发送暂停信号(也可以使用kill -SIGSTOP 3115命令,效果相同),如下图所示:

kill -20 3115   

回到进程终端,可以看到该进程已经被暂停了,如下图所示: 

然后在第二个终端中输入以下命令向暂停的信号发送继续运行信号(也可以使用kill -SIGCOUT 3115命令,效果相同),如下图所示:

kill -18 3115

可以看到进程就继续运行起来了,大家也可以试试向该进程发送其他的信号。至此关于kill函数相关的内容就讲解完成了。

6.7.2 signal函数

本小节代码在配套资料“iTOP-3588开发板\03_【iTOP-RK3588开发板】指南教程\03_系统编程配套程序\35”目录下,如下图所示:

 在Linux操作系统中,signal函数主要用于捕捉和处理进程接收到的信号。在任何需要处理信号的程序中,都可以使用signal函数。

通常情况下,可以在以下场景使用signal函数:

(1)在程序中需要捕获并处理系统发送的信号时,可以使用signal函数来注册信号处理函数。

(2)在程序中需要忽略某些信号时,可以使用signal函数来将对应的信号的处理方式设置为忽略。

(3)在程序中需要恢复某些信号的默认处理方式时,可以使用signal函数来将对应的信号的处理方式设置为默认处理方式。

signal函数所需要的头文件和函数原型如下图所示:

所需头文件

函数原型

1 

#include <signal.h>

void (*signal(int signum, void (*handler)(int)))(int);

其中,signum 参数表示需要设置处理函数的信号编号,handler 参数表示需要设置的处理函数。signal 函数在处理某个特定信号时有如下三种情况:

(1)当 handler 参数为 SIG_DFL 时,将信号的处理函数设置为系统默认处理函数,即将该信号的处理方式恢复为操作系统默认的处理方式;

(2)当 handler 参数为 SIG_IGN 时,将信号的处理函数设置为忽略信号,即忽略掉该信号;

(3)当 handler 参数为其他函数时,将该信号的处理函数设置为该函数。

需要注意的是,使用 signal 函数设置的信号处理函数是一次性的,即当处理函数被调用后,操作系统会自动将其重置为默认处理方式或忽略该信号。

下面编写对应的测试程序demo35_signal.c,代码内容如下所示:

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

void signal_handler(int sigal_num) // 定义信号处理函数signal_handler,接受信号编号为参数
{
    switch (sigal_num) // 根据不同的信号编号,执行不同的操作
    {
        case SIGHUP: // 挂起终端或控制进程已断开
            printf("收到信号:SIGHUP\n");
            break;
        case SIGINT: // 由CTRL+C发出的信号
            printf("收到信号:SIGINT\n");
            break;
        case SIGQUIT: // 由CTRL+\发出的信号
            printf("收到信号:SIGQUIT\n");
            break;
        default: // 未处理的信号
            perror("signal_handler");
            break;
    }
}

int main(void)
{
    int count; // 定义计数器
    // 注册信号处理函数
    signal(SIGHUP, signal_handler);
    signal(SIGINT, signal_handler);
    signal(SIGQUIT, signal_handler);                                                     
    printf("this pid is %d\n", getpid()); // 输出当前进程的pid

    while (1)
    {
        sleep(1); // 等待1秒钟
        printf("the count is %d\n", count); // 输出计数器的值
        count++; // 计数器加1
    }
    return 0;// 返回0,表示程序正常结束
}

在上一小节程序的前提下,添加了注册信号处理函数相关的部分,并对几种常见的信号进行处理,保存退出之后,使用以下命令对程序进行编译,如下图所示:

gcc -o demo35_signal demo35_signal.c

然后使用“./demo35_signal”命令运行该程序如下图所示: 

可以看到该进程的pid为3321,方便我们使用kill函数向该进程发送信号,从而对信号处理函数进行测试,来到第2个终端分别输入以下命令,向进程分别发送挂起信号、中断信号和

退出信号,如下图所示:

kill -1 3321

kill -2 3321

kill -3 3321

进程会调用相应的信号处理函数来处理上述三个信号,如下图所示: 

从上图可以了解到进程在收到上述三个信号之后,并没有执行挂起、中断和退出三个动作,至此关于signal函数相关的内容就讲解完成了。关于信号其他相关的内容会在之后的章节中进行讲解。

  • 21
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值