9_【Linux】五、Linux 进程控制
「前言」文章的大致内容是进行创建、终止、等待。
一、进程创建
1.1 再谈 fork 函数
Linux中 fork 函数时非常重要的函数,它从已存在进程中创建一个新进程,新进程为子进程,而原进程为父进程,fork 函数在进程概念的篇章已经介绍过了,这里再谈 fork 函数,再次理解 fork函数。
man fork 查看 fork函数详细介绍。
fork 的返回值有两个:
- 创建子进程失败返回 -1
- 创建成功:a.给父进程返回子进程的
PIDb.给子进程返回 0
进程调用 fork函数,当控制转移到内核中的 fork代码后,内核做:
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程(第一点和第二点在初识进程地址空间已经详细解释)
- 添加子进程到系统进程列表当中
fork返回,开始调度器调度
fork 之后,父子进程代码共享。
测试代码:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("before fork pid: %d\n", getpid());
pid_t id = fork();
if(id == -1)
{
printf("fork error\n");
}
printf("after fork pid: %d, return val: %d\n", getpid(), id);
sleep(1);
return 0;
}
运行结果

这里可以看到,before fork pid 只输出了一次,而 after fork pid 输出了两次。其中,before fork pid 是由父进程打印的,而调用fork函数之后打印的两个 after fork pid,分别由父进程和子进程两个进程执行。也就是说,fork之前父进程独立执行,而 fork之后父子两个执行流分别执行,也就是父子进程代码共享。
虽然子进程是从
fork之后执行的,但全部代码都是父子进程共享的
注意:fork之后,父进程和子进程谁先执行完全由调度器决定
小提示:在编写 makefile 的时候,目标文件的依赖方法中,可以用 $@表示要形成的目标文件,即依赖关系中:左边的内容;用$^表示目标文件的依赖文件,即依赖关系中:右边的内容

1.2 fork 函数返回值问题
fork函数为什么要给子进程返回0,给父进程返回子进程的PID?
- 一个子进程永远只有一个父进程,但父进程可以拥有多个子进程。比如,一个孩子只有一个父亲,而父亲可以有多个孩子
- 进程多了就要有进程的标识符,没有事不行的。就好比一个父亲他有三个孩子,父亲想叫其中的一个孩子,得叫孩子的名字吧,不叫孩子怎么知道叫哪一个孩子,总不能说:孩子,你过来一下。这样叫哪知道是哪一个,同比进程也是如此,得有一个认得出你的标识符。给子进程返回 0,给父进程返回子进程的
PID就是类似情况
为什么fork函数有两个返回值?
- 因为存在两个进程(父进程和子进程),那么 fork 自然也就会被返回两次,每一个进程都要 return,所以 fork 函数有两个返回值(这里在地址空间也有介绍,这里简单说一下)
1.2 写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本
写时拷贝在进程地址空间也有详细介绍

当我们不修改数据时,父子进程的虚拟内存所对应的物理内存都是同一块物理地址(内存),当子进程的数据被修改,那么就会将子进程修改所对应数据的物理内存出进行写时拷贝,在物理内存中拷贝一份放在物理内存的另一块空间,将子进程虚拟内存与这个新的地址通过页表进行关联
为什么数据要进行写时拷贝?
- 进程具有独立性,多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程
1.3 fork 常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求
- 一个进程要执行一个不同的程序。例如子进程从
fork返回后,调用exec函数
1.4 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
二、进程终止
2.1 进程退出码
进程有创建,进程也有结束的时候,进程结束我们称为进程终止。在C/C++中,在 main 函数最后基本都会写上
return 0,对于这个返回值 0 我们称它为进程退出码
进程退出码有很多,每个进程退出码都有着自己的意义,进程退出码代表了进程为什么会退出,比如进程退出码 0 代表的意义就是进程正常退出,也就是代码正常执行完成
测试代码
#include<stdio.h>
int main()
{
printf("hello world\n");
return 0;
}
程序运行完了,怎么查看进程退出码?
当进程执行之完成可以通过一个命令查看具体的进程退出码,? 就是环境变量中的一个名字,$?就是获取相应的环境变量
echo $?

我们可以修改进程的退出码,进程退出码的意义也可以自己定义,不使用操作系统的那一套进程退出码
#include<stdio.h>
int main()
{
printf("hello world\n");
return 1;//我们假设进程退出码 1 ,是进程正常退出
//return 0;
}
echo $? 查看进程退出码

echo $? 命令只会记录最近一次的进程退出码(即 main函数的 return 返回值),而下一个为 0的原因就是echo本身也是一个进程,并且正确执行退出,因此显示的是0

如何设定 main函数的返回值?
- 如果不关心进程退出码,
return 0就行,如果要关心进程退出码,要返回特定的数据表明进程退出的情况和特定的错误(进程是正常退出还是非正常退出)
进程退出码一般使用
0表示成功,!0表示错误,!0具体是多少,就标定特定的错误
进程退出码都是数字,对计算机友好,但是对人不友好,所以退出码都要有对应的退出码的文字描述
strerror 这个函数就是把进程的退出码转换成文字描述

测试代码
#include<stdio.h>
#include<string.h>
int main()
{
int i = 0;
for(i; i < 200; ++i)
{
printf("%d: %s\n", i, strerror(i));
}
return 0;
}
运行结果


如图,只有0代表着success,其他的都对应不同的错误,并且有133个不同的错误,一共有134个进程退出码,就代表有134种不同的进程运行结果
2.2 进程退出场景
进程退出的场景分三类:
- 代码运行完毕,结果正确(进程退出码为
0) - 代码运行完毕,结果不正确(进程退出码
!0) - 代码没有跑完,异常终止(退出码无意义)
进程如何退出呢?接下来就来解释一下(前两种情况)
2.3 进程如何退出
-
main 函数的
return退出,这是最常用的一种方式 -
通过
exit函数退出
man 3 exit 查看一下,exit 是C语言的一个库函数,参数 status 就是当前进程的退出码

测试代码
#include<stdio.h>
#include<stdlib.h>
int main()
{
printf("hello\n");
exit(11);
printf("world\n");
return 0;
}
运行结果,到exit语句就会将进程结束,后面的代码也就不会再去执行了

查看退出码

(3)还有第三种,通过 _exit系统调用退出(了解)
man 2 _exit 查看

测试代码
#include<stdio.h>
#include<stdlib.h>
int main()
{
printf("hello\n");
_exit(15);
//exit(11);
printf("world\n");
return 0;
}
运行结果

结果发现
_exit()其也是和exit()一样的功能。事实上,_exit是系统调用的函数,也就是操作系统(OS)提供的,而exit()是库函数,库函数是 OS 之上的函数,exit底层实际上就是调用_exit,但二者之间也会有区别
二者的区别在刷新缓冲区上,将换行符去掉进行测试
测试代码
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
printf("hello world");
sleep(2);
exit(1);
return 0;
}
运行结果

进程结束后,会刷新缓冲区,打印的结果暂停2秒也会显示出来,下面看 _exit()
测试代码
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
printf("hello world");
sleep(2);
_exit(1);
//exit(1);
return 0;
}
运行结果

_exit没有打印出结果,也就是说 _exit并没有刷新缓冲区
因此
exit终止进程,主动刷新缓冲区_exit终止进程,不会刷新缓冲区
_exit() 是系统调用,而库函数 exit() 在系统调用之上, _exit() 不会刷新缓冲区,exit() 会刷新缓冲区,这也直接说明了缓冲区肯定在系统调用之上,也就是用户级缓冲区,缓冲区后序会详细解释


前面的三点都是进程的正常退出,最后一点是异常退出
(4)异常退出:通过 ctrl + c 终止进程,信号终止,如 kill -9
三、进程等待
3.1 进程等待必要性
进程等待的必要性:
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏
- 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的
kill -9也无能为力,因为谁也没有办法杀死一个已经死去的进程 - 最后,父进程派给子进程的任务完成的如何,我们需要知道,如:子进程运行完成,结果对还是不对,或者是否正常退出
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
总的来说,进程等待的意义就是:回收子进程资源,获取子进程退出信息,即通过进程等待的方式解决僵尸进程的问题
3.2 进程等待的方法
3.2.1 通过 wait 方法回收子进程
man 2 wait 查看 wait,wait 是一个系统调用,输出型参数,获取子进程退出状态,不关心则可以设置成为NULL,下面先使用第一个接口

返回值,等待成功返回子进程的PID,失败返回 -1
wait(): on success, returns the process ID of the terminated child; on error, -1 is returned.
测试代码,让子进程处于 Z状态5秒,父进程 10秒后醒来回收子进程
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0) // 子进程
{
int cnt = 5;
while (cnt)
{
printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt);
--cnt;
sleep(1);
}
exit(0); // 退出子进程
}
// 父进程
sleep(10); // 由于子进程没有被父进程回收会处于 5秒的 Z状态
pid_t ret = wait(NULL); // ret 用于接收 wait的返回值
if (id > 0)
{
printf("wait success: %d\n", ret);
}
sleep(5); // 不让父进程那么快退出,用于查看进程处于的状态
return 0;
}
监控脚本
while :; do ps axj | head -1 && ps axj | grep mytest | grep -v grep; sleep 1; done
运行结果

右侧执行脚本,左侧同时运行 mytest,发现当子进程正在执行时,子进程和父进程都处于 S 状态,当子进程执行完毕,没有被父进程回收时的那 5秒,子进程就变成了 Z 状态,当父进程执行时,通过调用 wait 将子进程回收,子进程就结束了,最后的5秒只剩下父进程处于S+状态,这就是父进程通过进程等待回收了僵尸进程(子进程)

3.2.2 通过 waitpid 获取子进程退出信息
man 2 waitpid 查看 waitpid,waitpid 是一个系统调用,下面使用第二个接口进行测试

#include<sys/types.h>
#include<sys/wait.h>
pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数:
pid:
Pid=-1,等待任一个子进程。与wait等效。
Pid>0.等待其进程ID与pid相等的子进程。
status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID
测试代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)//子进程
{
int cnt = 5;
while (cnt)
{
printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt);
--cnt;
sleep(1);
}
exit(10);//退出子进程
}
//父进程
sleep(10);//由于子进程没有被父进程回收会处于 5秒的 Z状态
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (id > 0)
{
printf("wait success: %d, status: %d\n", ret, status);
}
sleep(5);//不让父进程那么快退出,用于查看进程处于的状态
return 0;
}
运行结果

但是我们发现,status 不是我们想要的信息,所以 status 并不是整体使用的,status 有自己的位图结果,下面解释输出型参数 status 的使用
3.3 获取子进程 status
status 解释:
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充- 如果传递
NULL,表示不关心子进程的退出状态信息 - 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程
status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位)

对于 32个 bit 位在这里只有
16个 bit位是有意义的,进程正常终止0~7位返回 0代表正常的终止信号(返回0证明没有出问题),进程正常终止8~15位代表子进程对应的退出码进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是
core dump标志,后面的比特位不再使用,即没有意义
怎么获取这些有用的信息?答案是通过位操作符
exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F; //退出信号
把上面的代码进行修改

再运行程序,就可以获取子进程的信息了

(status >> 8) & 0xFF 和 status & 0x7F 太难记了,所以系统当中提供了两个宏来获取退出码和退出信号
exitNormal = WIFEXITED(status); //是否正常退出
exitCode = WEXITSTATUS(status); //获取退出码
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若 WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
修改代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)//子进程
{
int cnt = 5;
while (cnt)
{
printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt);
--cnt;
sleep(1);
}
exit(10);//退出子进程
}
//父进程
sleep(10);//由于子进程没有被父进程回收会处于 5秒的 Z状态
int status = 0;
pid_t ret = waitpid(id, &status, 0);
//判断子进程是否正常退出,正常退出为真
if (WIFEXITED(status))
{
//获取子进程退出码
printf("wait success: %d, exit child code: %d\n", ret, WEXITSTATUS(status));
// printf("wait success: %d, exit sign: %d, exit child code: %d\n", ret, (status&0x7F), ((status >> 8)&0xFF));
}
else
{
printf("wait failed\n");
}
sleep(5);//不让父进程那么快退出,用于查看进程处于的状态
return 0;
}
运行结果

3.4 再谈进程退出
- 子进程退出会变成僵尸进程,会把自己的退出结果写入到自己的 PCB 结构体中,在 Linux 下是
task_struct,子进程退出后task_struct不会立马释放,task_struct会等待父进程来取走子进程退出信息 wait/waitpid是一个系统调用,即以OS的身份进行,因此OS也有资格有能力去读取子进程的task_struct,因此wait/waitpid是从子进程的task_struct来获取子进程的退出信息的
3.5 进程的阻塞和非阻塞等待
上面的测试代码就是阻塞等待,所谓的阻塞等待就是:当子进程未退出时,父进程都在一直等待子进程退出,在等待期间,父进程不能做任何事情,这种等待叫做阻塞等待,也叫轮询阻塞等待
父进程不做任何事,一直等待子进程的退出,在此期间父进程会一直询问:子进程,你好了没?这种询问会一直询问到子进程忙完,也就是子进程退出,父进程的一直询问这种方式称为轮询检测
而父进程不是一直等到子进程退出,而是间隔一定时间去询问子进程,父进程在子进程未退出时可以做一些自己的事情,当子进程退出时再读取子进程的退出信息,这种等待方式叫做非阻塞等待,也叫非轮询阻塞等待
下面进行非阻塞等待代码测试
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0)//子进程
{
int cnt = 5;
while (cnt)
{
printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt);
--cnt;
sleep(1);
}
exit(10);//退出子进程
}
//父进程
int status = 0;
while (1)
{
pid_t ret = waitpid(id, &status, WNOHANG);//WHOHANG: 非阻塞-> 子进程没有退出,父进程检测的时候,立即返回
if (ret == 0)
{
//waitpid 调用成功 && 子进程没有退出
//子进程没有退出, waitpid 没有等待失败,仅仅是检测到了子进程没有退出
//
//执行父进程的代码
printf("wait done, but child is running...\n");
sleep(1);
}
else if (ret > 0)
{
// waitpid 等待成功 && 子进程退出了
printf("wait success: %d, exit sign: %d, exit child code: %d\n", ret, (status & 0x7F), ((status >> 8) & 0xFF));
break;
}
else
{
// waitpid 失败
printf("wait failed\n");
}
}
return 0;
}
运行结果

非阻塞等待有什么好处?
- 非阻塞等待不会占用父进程的所有精力,可以在轮询期间,执行别的代码
四、进程程序替换
4.1 创建子进程的目的
创建子进程的目的:
-
想让子进程执行父进程代码的一部分(执行父进程对应磁盘代码中的一部分)
-
想让子进程执行一个全新的程序(让子进程想办法加载磁盘是指定的程序,执行新程序的代码和数据,这就是进程的程序替换)
4.2 替换函数
替换函数有六种以exec开头的函数,它们统称为exec函数,这六种都是库函数,这些函数的作用是:将指定的程序加载到内存中,让指定的进程执行
man 3 execl 查看

#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
(1)int execl(const char *path, const char *arg, ...)
- 第一个参数是要执行程序的路径
- 第二个参数是可变参数列表,表示你要如何执行这个程序,并以
NULL结尾 ...是可变参数列表
(2) int execlp(const char *file, const char *arg, ...)
- 第一个参数是要执行程序的名字
- 第二个参数是可变参数列表,表示你要如何执行这个程序,并以
NULL结尾
(3)int execle(const char *path, const char *arg, ...,char *const envp[])
- 第一个参数是要执行程序的路径
- 第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾
- 第三个参数是你自己设置的环境变量
(4)int execv(const char *path, char *const argv[])
- 第一个参数是要执行程序的路径
- 第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以
NULL结尾
(5)int execvp(const char *file, char *const argv[])
- 第一个参数是要执行程序的名字
- 第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以
NULL结尾
第六个就不介绍了,都一样,下面这个是系统调用,上面 6 个库函数底层都是调用 execve 这个函数
int execve(const char *path, char *const argv[], char *const envp[]);
int execve(const char *path, char *const argv[], char *const envp[])
- 第一个参数是要执行程序的路径
- 第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以
NULL结尾 - 第三个参数是你自己设置的环境变量
4.3 替换函数解释
解释:
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回
- 如果调用出错则返回-1
- 所以
exec系列函数只有出错的返回值而没有成功的返回值
4.4 替换函数命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记
l(list) : 表示参数采用列表v(vector) : 参数用数组p(path) : 有p自动搜索环境变量PATHe(env) : 表示自己维护环境变量

4.5 替换函数测试
4.5.1 execl
int execl(const char *path, const char *arg, ...)
l(list) : 表示参数采用列表
- 第一个参数是要执行程序的路径
- 第二个参数是可变参数列表,表示你要如何执行这个程序,并以
NULL结尾, ...是可变参数列表- 这些函数作用是将指定的程序加载到内存中,让指定的进程执行
如何找到程序?
- 这是由第一个参数决定的,通过环境变量找到指定的程序
如何执行?
- 这个是由第二个参数决定的,通过相应的命令执行程序
下面假设替换 ls 这个程序,execl 这个函数第一个参数要带路径

测试代码
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("process is running...\n");
execl("/usr/bin/ls", "ls", NULL);//第一个参数是要执行哪个程序,第二个参数是你想怎么执行,以 NULL 结尾
printf("process is running...\n");
return 0;
}
运行结果

我们发现,程序确实被替换了,执行了 ls 这个程序,而且最后一句打印没有打印出来,对比 ls 命令执行的结果,二者无差异,只不过没有把颜色带上,加上颜色的参数就可以了。

exec系列的函数为什么没有成功返回值呢?
- 因为替换成功了,就和接下来的代码无关了,判断毫无意义,
exec系列函数只要返回了,一定是程序替换失败了。
程序执行完成后,最后一句为什么没有被打印?下面解释原理。
4.5.2 程序替换的原理

以上面代码为例,代码执行时,进程地址空间与物理内存与页表就会形成映射关系,当执行原有的代码时,执行第一个printf会照常打印,到了execl函数时,就会发生进程的程序替换,也就是说,我们所编写的代码会被execl函数所调用对应磁盘内部的代码和数据覆盖,即将指定程序的代码和数据覆盖原有的代码和数据,然后执行这个新的代码和数据,所以execl后面的printf没有打印。
当进行进程程序替换时,有没有创建新的进程?
- 进程程序替换之后,该进程对应的PCB、进程地址空间以及页表等数据结构都没有发生改变,只是进程在物理内存当中的数据和代码发生了改变,所以并没有创建新的进程,而且进程程序替换前后该进程的
pid并没有改变
程序替换一般都是用 fork 生成子进程,让子进程进行程序替换,上面的单进程例子是为了方便演示
下面使用子进程进行程序替换(双进程(父子进程)),函数依旧是 execl
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<assert.h>
int main()
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
//子进程
if (id == 0)
{
//类比:命令行怎么写,这里就怎么写
sleep(1);
execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);// --color=auto 是颜色高亮
exit(1);//这个代码执行了,就说明 excel 函数返回了,返回就意味程序替换失败了
}
//父进程
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0)
{
printf("wait success: %d, exit signal: %d, exit child code: %d\n", ret, status & 0x7F, (status >> 8) & 0xFF);
}
else
{
printf("wait failed\n");
}
return 0;
}
运行结果

结果还是一致的,用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。
当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动开始执行。调用exec并不创建新进程,所以调用exec前后该进程的pid并未改变。
进行程序替换时会发生写时拷贝,保证进程的独立性,不让子进程影响父进程

这就是程序替换的原理
4.5.3 execlp
int execlp(const char *file, const char *arg, ...)
l(list) : 表示参数采用列表p(path) : 有p自动搜索环境变量PATH
参数:
- 第一个参数是要执行程序的名字
- 第二个参数是可变参数列表,表示你要如何执行这个程序,并以
NULL结尾
测试代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<assert.h>
int main()
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
//子进程
if (id == 0)
{
//类比:命令行怎么写,这里就怎么写
sleep(1);
execlp("ls", "ls", "-a", "-l", "--color=auto", NULL);// --color=auto 是颜色高亮
//execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);// --color=auto 是颜色高亮
exit(1);//这个代码执行了,就说明 excel 函数返回了,返回就意味程序替换失败了
}
//父进程
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0)
{
printf("wait success: %d, exit signal: %d, exit child code: %d\n", ret, status & 0x7F, (status >> 8) & 0xFF);
}
else
{
printf("wait failed\n");
}
return 0;
}
运行结果

4.5.4 execlp
int execv(const char *path, char *const argv[])
v(vector) : 参数用数组
参数:
- 第一个参数是要执行程序的路径
- 第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
改一下代码就可以了

4.5.4 替换自己写的可执行程序
上面的几个调用方式,事实上我们所调用的都是系统程序,接下来就通过exec类的函数调用自己写的程序。
随便创建一个源文件:test.c

#include<stdio.h>
int main()
{
printf("我是另一个C程序!!\n");
printf("我是另一个C程序!!\n");
printf("我是另一个C程序!!\n");
printf("我是另一个C程序!!\n");
printf("我是另一个C程序!!\n");
printf("我是另一个C程序!!\n");
return 0;
}
makefile中也需要改成能够同时生成 myexec 和 mytest 的指令,对于makefile文件,只会生成第一个程序,因此需要修改 makefile 让它们可以同时生成
.PHONY:all
all: myexec mytest
myexec:exec.c
gcc -o $@ $^
mytest:test.c
gcc -o $@ $^
.PHONY:clean
clean:
rm -f myexec mytest
结果如下

因为自己写的程序不在环境变量里面,所以不能使用p(path) : 有p自动搜索环境变量PATH。
直接使用相对路径即可
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<assert.h>
int main()
{
printf("process is running...\n");
pid_t id = fork();
assert(id != -1);
//子进程
if (id == 0)
{
//类比:命令行怎么写,这里就怎么写
sleep(1);
execl("./mytest", "mytest", NULL);
exit(1);//这个代码执行了,就说明 excel 函数返回了,返回就意味程序替换失败了
}
//父进程
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0)
{
printf("wait success: %d, exit signal: %d, exit child code: %d\n", ret, status & 0x7F, (status >> 8) & 0xFF);
}
else
{
printf("wait failed\n");
}
return 0;
}
运行结果

对于这种调用方式,是没有语言之间的隔阂的,即我们可以通过C语言调用C++、Java、Python等等其他类型的语言,当然也可以反过来调。
也就是说程序替换,可以使用程序进行替换,也可以调用任何后端语言对应的可执行程序。
4.5.5 execle
int execle(const char *path, const char *arg, ...,char *const envp[])
- l(list) : 表示参数采用列表
- e(env) : 表示自己维护环境变量
参数:
- 第一个参数是要执行程序的路径
- 第二个参数是可变参数列表,表示你要如何执行这个程序,并以
NULL结尾 - 第三个参数是你自己设置的环境变量
直接使用 4.5.4 上面的代码,修改一下。
test.c

exec.c

运行结果

结果发现,系统内部的环境变量使用不了,我们自定义的就可以使用。这是因为我们的 execle 函数的最后一个参数的原因,最后的一个参数就是传入的环境变量,没有传入就不会使用,因此如果我们在exec.c中将最后一个位置的参数改成 environ(前面添加extern char** environ)的话,就会反过来:我们自定义的环境变量就不会生效,只有系统的才会生效。
但是我们想让两者同时生效,就要使用进程概念前面提到的函数:putenv。
man putenv 查看,putenv 是一个库函数,作用是把你自定义的环境变量导入环境变量中,让自定义的环境和系统的环境变量让两者同时生效。

再修改一下 exec.c 的代码

再次运行程序

这样就可以让自定义的环境和系统的环境变量让两者同时生效。
其他 exec 系列的函数不再演示,道理都一样。只有 execve 是真正的系统调用,其它六个函数最终都调用 execve,所以 execve在man手册 第2节,其它函数在man手册第3节。
4.5.6 exec 系列函数与 main 函数的相关问题
对于
execle函数和main函数,在进程调用的时候是谁先被调用?
exec先被调用。exec系列的函数的功能是将我们的程序加载到内存中!- 我们知道一个程序要想运行必须加载到内存中让CPU去执行,那程序是如何加载的?而对于
LinuxOS来说,程序加载是通过exec系列的函数加载到内存中的,因此Linux中的exec系列函数也被称为加载器。
程序是先加载呢?还是先执行
main呢?
- 毫无疑问,一定是先加载,所以,也就解释通了对于 exec系列的函数和 main函数,一定是 exec 系列的函数先被调用。
main也作为函数,也需要被传参,exec系列的函数和main函数的参数有什么关联呢?
main 函数本身自带三个参数,不过平时我们都不传参数
int main(int argc, char* argv[], char* env[]);
以execle为例,main函数的参数都是exec系列的函数传给 main函数的,他们的参数就是这种一一对应的映射关系!即 main函数被 exec调用

那对于exec系列中不带有 envp[] 参数的那些函数,照样能够拿到默认的环境变量,其实是 environ 通过地址空间的方式让子进程拿到的
下图exec函数族 一个完整的例子

程序替换中只有一个 execve系统调用,其他都是封装,目的是为了让我们有更多的选择
进程替换到此结束。
五、进程控制应用场景:模拟 shell命令行解释器
5.1 模拟 shell 版本1
shell也就是命令行解释器,其运行原理就是:当有命令需要执行时,shell创建子进程,让子进程执行命令,而shell只需等待子进程退出即可
其实shell需要执行的逻辑非常简单,其只需循环执行以下步骤:
- 获取命令行
- 解析命令行
- 创建子进程(fork)
- 替换子进程(execvp)
- 等待子进程退出(wait)
版本1
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#define NUM 1024 //一个命令最大长度
#define OPT_NUM 64 //一个命令最多选项
char lineCommend[NUM];
char* myargv[OPT_NUM];
int main()
{
while (1)
{
//打印输出提示符
printf("用户名@主机名 当前路径# ");
//刷新缓冲区
fflush(stdout);
//获取用户输入,自己输入的时候,按回车缓冲区里面会多一个 \n
char* s = fgets(lineCommend, sizeof(lineCommend) - 1, stdin);
if (s == NULL)
{
perror("fgets");
exit(-1);
}
//去掉自己输入的回车 \n
lineCommend[strlen(lineCommend) - 1] = 0;
//对输入的命令做字符串切割
//ps: 输入"ls -a -l -i" -> 切割成 "ls" "-a" "-l" "-i"
myargv[0] = strtok(lineCommend, " ");
int i = 1;
while (myargv[i++] = strtok(NULL, " "));
//创建子进程执行命令
pid_t id = fork();
if (id == -1)
{
perror("fork");
exit(-1);
}
else if (id == 0)
{
//子进程
execvp(myargv[0], myargv);
exit(1);
}
//父进程
waitpid(id, NULL, 0);
}
return 0;
}
运行结果,一个简易的shell就完成了。

但是这个简易的shell命令行解释器还有一个问题:就是返回上一级路径时,路径没有发生变化。

下面就来解决这个问题。
5.2 当前路径
什么是当前路径?
测试代码

执行这个程序并新建窗口进行观察
ls /proc/进程pid

以列表显示
ls /proc/进程pid -al

其中,
exe是指当前可执行程序在磁盘中的路径 ,而cwd(current working directory) 则是指 当前进程的工作目录,它就是我们平时所说的 当前路径
在 Linux 中,我们可以使用 chdir系统调用来改变进程的工作目录

也就是说,当前工作目录可以被改变,chdir的参数是写入你要修改当前工作目录的的路径 。
回到上面,为什么我们自己写的
shell,cd的时候路径没有变化呢?
myshell是通过创建子进程的方式去执行命令行中的各种指令的,也就是说,cd命令是由子进程去执行的,那么自然被改变也是子进程的工作目录,父进程的工作目录不受影响- 而当我们使用
pwd指令来查看当前路径时,cd指令对应的子进程已经执行完毕退出了,此时myshell又会给pwd创建一个新的子进程,且这个子进程的工作目录和父进程myshell相同,所以pwd打印出来的路径不变
知道原因后,我们只需要对命令行传入的指令进行判断,如果是 cd 指令,就使用 chdir将父进程的工作目录修改为指定的目录即可
修改代码
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#define NUM 1024 //一个命令最大长度
#define OPT_NUM 64 //一个命令最多选项
char lineCommend[NUM];
char* myargv[OPT_NUM];
int main()
{
while(1)
{
//打印输出提示符
printf("用户名@主机名 当前路径# ");
//刷新缓冲区
fflush(stdout);
//获取用户输入,自己输入的时候,按回车缓冲区里面会多一个 \n
char* s = fgets(lineCommend, sizeof(lineCommend)-1, stdin);
if(s == NULL)
{
perror("fgets");
exit(-1);
}
//去掉自己输入的回车 \n
lineCommend[strlen(lineCommend)-1] = 0;
//对输入的命令做字符串切割
//ps: 输入"ls -a -l -i" -> 切割成 "ls" "-a" "-l" "-i"
myargv[0] = strtok(lineCommend, " ");
int i = 1;
while(myargv[i++] = strtok(NULL, " "));
//如果是 cd 命令,不需要创建子进程,让 shell 自己执行对应的命令,本质就是执行系统接口
if(myargv[0] != NULL && strcmp(myargv[0], "cd") == 0)
{
if(myargv[1] != NULL)
chdir(myargv[1]);//改变父进程的工作目录
continue;//直接跳过此次循环,不再创建子进程
}
//创建子进程执行命令
pid_t id = fork();
if(id == -1)
{
perror("fork");
exit(-1);
}
else if(id == 0)
{
//子进程
execvp(myargv[0], myargv);
exit(1);
}
else if(id == 0)
{
//子进程
execvp(myargv[0], myargv);
exit(1);
}
//父进程
waitpid(id, NULL, 0);
}
return 0;
}
运行结果,可以使用 cd 命令改变路径了

5.3 内建/内置命令
Linux 中的命令一共分为两种 – 内建(内置)命令和外部命令:
- 内建命令是
shell程序的一部分,其功能实现在bash源代码中,不需要派生子进程来执行,也不需要借助外部程序文件来运行,而是由shell进程本身内部的逻辑来完成 - 外部命令则是通过创建子进程,然后进行进程程序替换,运行外部程序文件等方式来完成
上面的cd 命令就是一个内建命令,echo 也是一个内建命令,我们上面写的 shell 执行这个命令也有问题,也需要像 cd 命令一样去处理 。
--------------- END ---------------
「 作者 」 枫叶先生
「 更新 」 2023.1.9
「 声明 」 余之才疏学浅,故所撰文疏漏难免,
或有谬误或不准确之处,敬请读者批评指正。
9_【Linux】五、Linux 进程控制
http://114.132.213.38:6250/archives/dff1ca43-a349-4a4e-a5d9-d26f86f4bf70
评论