最全Linux应用开发八股文(二)——进程(上)

你好,我是拉依达。

这是我的Linux应用开发八股文详细解析系列

本系列最开始是我在csdn上更新的文章全文总字数超3w字,现重新对内容进行整理,希望可以帮助到更多学习嵌入式的同学。

【下面是拉依达推荐学习相关专栏:】
一、Linux驱动学习专栏:拉依达的Linux驱动八股文 - 牛客网
二、Linux应用学习专栏:拉依达的Linux应用八股文 - 牛客网
【我的嵌入式学习和校招经验】 拉依达的嵌入式学习和秋招经验-CSDN博客
嵌入式学习规划/就业经验指导,可私信咨询

———————————————————————————————————————————————————

二、进程

2.1 进程控制

进程命令

  • 查看进程
$ ps aux
	- a: 查看所有终端的信息
	- u: 查看用户相关的信息
	- x: 显示和终端无关的进程信息
  • 杀死进程
# 无条件杀死进程, 进程ID通过 ps aux 可以查看
$ kill -9 进程ID
$ kill -SIGKILL 进程ID

进程创建

Linux 中进程 ID 为 pid_t 类型,其本质是一个正整数,通过上边的 ps aux 命令已经得到了验证。PID 为 1 的进程是 Linux 系统中创建的第一个进程。

  • 获取当前进程的进程 ID(PID)
#include <sys/types.h>
#include <unistd.h>
pid_t getpid(void);
  • 获取当前进程的父进程 ID(PPID)
#include <sys/types.h>
#include <unistd.h>
pid_t getppid(void);
  • 创建一个新的进程
#include <unistd.h>
pid_t fork(void);

fork () 调用成功之后,会返回两个值,父子进程的返回值是不同的。

该函数调用成功之后,从一个虚拟地址空间变成了两个虚拟地址空间,每个地址空间中都会将 fork() 的返回值记录下来,这就是为什么会得到两个返回值的原因。

  • 父进程的虚拟地址空间中将该返回值标记为一个大于 0 的数(其实记录的是子进程的进程 ID)
  • 子进程的虚拟地址空间中将该返回值标记 0
  • 在程序中需要通过 fork () 的返回值来判断当前进程是子进程还是父进程。

exec族

有时候需要通过现在运行的进程启动磁盘上的另一个可执行程序,也就是通过一个进程启动另一个进程,这种情况下我们可以使用 exec族函数,函数原型如下:

#include <unistd.h>

extern char **environ;
int execl(const char *path, const char *arg, ...
          /* (char  *) NULL */);
int execlp(const char *file, const char *arg, ...
           /* (char  *) NULL */);
int execle(const char *path, const char *arg, ...
           /*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
            char *const envp[]);

这些函数执行成功后不会返回,因为调用进程的实体,包括代码段,数据段和堆栈等都已经被新的内容取代(也就是说用户区数据基本全部被替换掉了),只留下进程 ID 等一些表面上的信息仍保持原样。只有调用失败了,它们才会返回一个 -1,从原程序的调用点接着往下执行。

execl函数

该函数可用于执行任意一个可执行程序,函数需要通过指定的文件路径才能找到这个可执行程序。

#include <unistd.h>
// 变参函数
int execl(const char *path, const char *arg, ...);

参数:

  • path: 要启动的可执行程序的路径,推荐使用绝对路径
  • arg: ps aux 查看进程的时候,启动的进程的名字,可以随意指定,一般和要启动的可执行程序名相同
  • ... : 要执行的命令需要的参数,可以写多个,最后以 NULL 结尾,表示参数指定完了。

返回值 :如果这个函数执行成功,没有返回值,如果执行失败,返回 -1

execlp函数

该函数常用于执行已经设置了环境变量的可执行程序,函数中的 p 就是 path,也是说这个函数会自动搜索系统的环境变量 PATH,因此使用这个函数执行可执行程序不需要指定路径,只需要指定出名字即可。 参数:

  • file: 可执行程序的名字
    • 在环境变量 PATH 中,可执行程序可以不加路径
    • 没有在环境变量 PATH 中,可执行程序需要指定绝对路径
  • arg: ps aux 查看进程的时候,启动的进程的名字,可以随意指定,一般和要启动的可执行程序名相同
  • ... : 要执行的命令需要的参数,可以写多个,最后以 NULL 结尾,表示参数指定完了。

返回值:如果这个函数执行成功,没有返回值,如果执行失败,返回 -1

结束进程

想要直接退出某个进程可以在程序的任何位置调用 exit() 或者_exit() 函数。函数的参数相当于退出码,如果参数值为 0 程序退出之后的状态码就是 0, 如果是 100 退出的状态码就是 100。

// 专门退出进程的函数, 在任何位置调用都可以
// 标准C库函数
#include <stdlib.h>
void exit(int status);

// Linux的系统函数
// 可以这么理解, 在linux中 exit() 函数 封装了 _exit()
#include <unistd.h>
void _exit(int status);

在 main 函数中直接使用 return 也可以退出进程,假如是在一个普通函数中调用 return 只能返回到调用者的位置,而不能退出进程

孤儿进程

在一个启动的进程中创建子进程,这时候父子进程同时运行,但是父进程由于某种原因先退出了,子进程还在运行,这时候这个子进程就可以被称之为孤儿进程(跟现实是一样的)。

操作系统当检测到某一个进程变成了孤儿进程,这时候系统中就会有一个固定的进程领养这个孤儿进程(有干爹了)。 这个领养孤儿进程的进程就是 init 进程(PID=1),如果有桌面终端,这个领养孤儿进程就是桌面进程。

系统为什么要领养这个孤儿进程呢? 在子进程退出的时候, 进程中的用户区可以自己释放, 但是进程内核区的pcb资源自己无法释放,必须要由父进程来释放子进程的pcb资源,孤儿进程被领养之后,这件事儿干爹就可以代劳了,这样可以避免系统资源的浪费。

僵尸进程

在一个启动的进程中创建子进程,父进程正常运行,子进程先与父进程结束,子进程无法释放自己的 PCB 资源,需要父进程来做这个件事儿,但是如果父进程也不管,这时候子进程就变成了僵尸进程。

僵尸进程已经死亡了,用户区资源已经被释放了,只是还占用着一些内核资源(PCB)。 僵尸进程的出现是由于这个已死亡的进程的父进程不作为造成的。

解决办法? 消灭僵尸进程的方法是,杀死这个僵尸进程的父进程,僵尸进程的父进程变成init ,这样僵尸进程的资源就被系统回收了。通过 kill -9 僵尸进程PID 的方式是不能消灭僵尸进程的,这个命令只对活着的进程有效,僵尸进程已经死了,鞭尸是不能解决问题的。

守护进程

护进程(Daemon Process),也就是通常说的 Daemon 进程(精灵进程),是 Linux 中的后台服务进程。它是一个生存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。一般采用以 d 结尾的名字。

如果要创建一个守护进程,标准步骤如下,:

  1. 创建子进程,让父进程退出
  • 因为父进程有可能是组长进程,不符合条件,也没有什么利用价值,退出即可
  • 子进程没有任何职务,目的是让子进程最终变成一个会话,最终就会得到守护进程
  1. 通过子进程创建新的会话,调用函数 setsid (),脱离控制终端,变成守护进程

  2. 改变当前进程的工作目录 (可选项,不是必须要做的)

  • 某些文件系统可以被卸载,比如: U 盘,移动硬盘,进程如果在这些目录中运行,运行期间这些设备被卸载了,运行的进程也就不能正常工作了。

  • 修改当前进程的工作目录需要调用函数 chdir()

    int chdir(const char *path);
    
  1. 重新设置文件的掩码 (可选项,不是必须要做的)
  • 掩码: umask, 在创建新文件的时候需要和这个掩码进行运算,去掉文件的某些权限

  • 设置掩码需要使用函数 umask()

    mode_t umask(mode_t mask);
    
  1. 关闭 / 重定向文件描述符 (不做也可以,但是建议做一下)
  • 启动一个进程,文件描述符表中默认有三个被打开了,对应的都是当前的终端文件

  • 因为进程通过调用 setsid () 已经脱离了当前终端,因此关联的文件描述符也就没用了,可以关闭

    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);
    
  • 重定向文件描述符 (和关闭二选一): 改变文件描述符关联的默认文件,让他们指向一个特殊的文件 /dev/null,只要把数据扔到这个特殊的设备文件中,数据被被销毁了

    int fd = open("/dev/null", O_RDWR);
    // 重定向之后, 这三个文件描述符就和当前终端没有任何关系了
    dup2(fd, STDIN_FILENO);
    dup2(fd, STDOUT_FILENO);
    dup2(fd, STDERR_FILENO);
    
  1. 根据实际需求在守护进程中执行某些特定的操作

进程回收

为了避免僵尸进程的产生,一般我们会在父进程中进行子进程的资源回收,回收方式有两种,一种是阻塞方式 wait(),一种是非阻塞方式 waitpid()。

进程回收——wait函数

// man 2 wait
#include <sys/wait.h>
pid_t wait(int *status);

参数:传出参数,通过传递出的信息判断回收的进程是怎么退出的,如果不需要该信息可以指定为 NULL。取出整形变量中的数据需要使用一些宏函数,具体操作方式如下:

  • WIFEXITED(status): 返回 1, 进程是正常退出的
  • WEXITSTATUS(status):得到进程退出时候的状态码,相当于 return 后边的数值,或者 exit () 函数的参数
  • WIFSIGNALED(status): 返回 1, 进程是被信号杀死了
  • WTERMSIG(status): 获得进程是被哪个信号杀死的,会得到信号的编号

返回值:

  • 成功:返回被回收的子进程的进程 ID
  • 失败: -1
    • 没有子进程资源可以回收了,函数的阻塞会自动解除,返回 - 1
    • 回收子进程资源的时候出现了异常

进程回收——waitpid函数

该函数可以控制回收子进程资源的方式是阻塞还是非阻塞,另外还可以通过该函数进行精准打击,可以精确指定回收某个或者某一类或者是全部子进程资源

// man 2 waitpid
#include <sys/wait.h>
// 这个函数可以设置阻塞, 也可以设置为非阻塞
// 这个函数可以指定回收哪些子进程的资源
pid_t waitpid(pid_t pid, int *status, int options);

参数:

  • pid:

    • -1:回收所有的子进程资源,和 wait () 是一样的,无差别回收,并不是一次性就可以回收多个,也是需要循环回收的
    • 大于0:指定回收某一个进程的资源 ,pid 是要回收的子进程的进程 ID
    • 0:回收当前进程组的所有子进程 ID
    • 小于 -1:pid 的绝对值代表进程组 ID,表示要回收这个进程组的所有子进程资源
  • status: NULL, 和 wait 的参数是一样的

  • options: 控制函数是阻塞还是非阻塞

    • 0: 函数是行为是阻塞的 ==> 和 wait 一样
    • WNOHANG: 函数是行为是非阻塞的

返回值:

  • 如果函数是非阻塞的,并且子进程还在运行,返回 0
  • 成功:得到子进程的进程 ID
  • 失败: -1
    • 没有子进程资源可以回收了,函数如果是阻塞的,阻塞会解除,直接返回 - 1
    • 回收子进程资源的时候出现了异常

2.2 管道

管道的是进程间通信(IPC - InterProcess Communication)的一种方式,管道的本质其实就是内核中的一块内存 (或者叫内核缓冲区),这块缓冲区中的数据存储在一个环形队列中,因为管道在内核里边,因此我们不能直接对其进行任何操作。

因为管道数据是通过队列来维护的,我们先来分析一个管道中数据的特点:

  • 管道对应的内核缓冲区大小是固定的,默认为 4k(也就是队列最大能存储 4k 数据)

  • 管道分为两部分:读端和写端(队列的两端),数据从写端进入管道,从读端流出管道。

  • 管道中的数据只能读一次,做一次读操作之后数据也就没有了(读数据相当于出队列)。

  • 管道是单工的:数据只能单向流动,数据从写端流向读端。

  • 对管道的操作(读、写)默认是阻塞的

    • 读管道:管道中没有数据,读操作被阻塞,当管道中有数据之后阻塞才能解除
    • 写管道:管道被写满了,写数据的操作被阻塞,当管道变为不满的状态,写阻塞解除

管道在内核中,不能直接对其进行操作,我们通过什么方式去读写管道呢? 其实管道操作就是文件 IO 操作,内核中管道的两端分别对应两个文件描述符,通过写端的文件描述符把数据写入到管道中,通过读端的文件描述符将数据从管道中读出来。

// 读管道
ssize_t read(int fd, void *buf, size_t count);
// 写管道的函数
ssize_t write(int fd, const void *buf, size_t count);

管道通信

假设父进通过一系列操作可以通过文件描述符表中的文件描述符 fd3 写管道,通过 fd4 读管道,然后再通过 fork() 创建出子进程,那么在父进程中被分配的文件描述符 fd3, fd4也就被拷贝到子进程中,子进程通过 fd3可以将数据写入到内核的管道中,通过fd4将数据从管道中读出来。

也就是说管道是独立于任何进程的,并且充当了两个进程用于数据通信的载体,只要两个进程能够得到同一个管道的入口和出口(读端和写端的文件描述符),那么他们之间就可以通过管道进行数据的交互。

管道的数据是单向流动的:

  • 操作管道的是两个进程, 进程A读管道, 需要关闭管道的写端, 进程B写管道, 需要关闭管道的读端
  • 如果不做上述的操作, 会对程序的结果造成阻塞, 对管道的操作无法结束
  • 双向通信建立两个管道

匿名管道

匿名管道只能实现有血缘关系的进程间通信,比如:父子进程,兄弟进程,爷孙进程,叔侄进程。

创建匿名管道的函数,函数原型如下:

#include <unistd.h>
// 创建一个匿名的管道, 得到两个可用的文件描述符
int pipe(int pipefd[2]);

参数:传出参数,需要传递一个整形数组的地址,数组大小为 2,也就是说最终会传出两个元素

  • pipefd[0]: 对应管道读端的文件描述符,通过它可以将数据从管道中读出
  • pipefd[1]: 对应管道写端的文件描述符,通过它可以将数据写入到管道中

返回值:成功返回 0,失败返回 -1

有名管道

有名管道拥有管道的所有特性,之所以称之为有名是因为管道在磁盘上有实体文件,文件类型为 p ,有名管道文件大小永远为 0,因为有名管道也是将数据存储到内存的缓冲区中,打开这个磁盘上的管道文件就可以得到操作有名管道的文件描述符,通过文件描述符读写管道存储在内核中的数据。

创建有名管道

  1. 命令
$ mkfifo 有名管道的名字
  1. 函数
#include <sys/types.h>
#include <sys/stat.h>
// int open(const char *pathname, int flags, mode_t mode);
int mkfifo(const char *pathname, mode_t mode);

参数:

  • pathname: 要创建的有名管道的名字
  • mode: 文件的操作权限,和 open () 的第三个参数一个作用,最终权限: (mode & ~umask)

返回值:创建成功返回 0,失败返回 -1

管道的读写行为

关于管道不管是有名的还是匿名的,在进行读写的时候,它们表现出的行为是一致的,下面是对其读写行为的总结:

  • 读管道,需要根据写端的状态进行分析:
    • 写端没有关闭 (操作管道写端的文件描述符没有被关闭)
      • 如果管道中没有数据 ==> 读阻塞 , 如果管道中被写入了数据,阻塞解除
      • 如果管道中有数据 ==> 不阻塞,管道中的数据被读完了,再继续读管道还会阻塞
    • 写端已经关闭了 (没有可用的文件描述符可以写管道了)
      • 管道中没有数据 ==> 读端解除阻塞,read 函数返回 0
      • 管道中有数据 ==> read 先将数据读出,数据读完之后返回 0, 不会阻塞了
  • 写管道,需要根据读端的状态进行分析:
    • 读端没有关闭
      • 如果管道有存储的空间,一直写数据
      • 如果管道写满了,写操作就阻塞,当读端将管道数据读走了,解除阻塞继续写
    • 读端关闭了,管道破裂 (异常), 进程直接退出
#嵌入式##校招##八股文##Linux##Linux应用开发#
拉依达的Linux应用八股文 文章被收录于专栏

你好,我是拉依达。 这是我的Linux应用开发八股文详细解析系列。 本系列最开始是我在csdn上更新的文章全文总字数超3w字,现重新对内容进行整理,希望可以帮助到更多学习嵌入式的同学。

全部评论

相关推荐

1 3 评论
分享
牛客网
牛客企业服务