目录
一、进程创建
1.1 再谈 fork 函数
1.2 fork 函数返回值问题
1.2 写时拷贝
1.3 fork 常规用法
1.4 fork调用失败的原因
二、进程终止
2.1 进程退出码
2.2 进程退出场景
2.3 进程如何退出
三、进程等待
3.1 进程等待必要性
3.2 进程等待的方法
3.2.1 通过 wait 方法回收子进程
3.2.2 通过 waitpid 获取子进程退出信息
3.3 获取子进程 status
3.4 再谈进程退出
3.5 进程的阻塞和非阻塞等待
四、进程程序替换
4.1 创建子进程的目的
4.2 替换函数
4.3 替换函数解释
4.4 替换函数命名理解
4.5 替换函数测试
4.5.1 execl
4.5.2 程序替换的原理
4.5.3 execlp
4.5.4 execlp
4.5.4 替换自己写的可执行程序
4.5.5 execle
4.5.6 exec 系列函数与 main 函数的相关问题
五、进程控制应用场景:模拟 shell命令行解释器
5.1 模拟 shell 版本1
5.2 当前路径
5.3 内建/内置命令
一、进程创建
1.1 再谈 fork 函数
linux中 fork 函数时非常重要的函数,它从已存在进程中创建一个新进程,新进程为子进程,而原进程为父进程,fork 函数在进程概念的篇章已经介绍过了,这里再谈 fork 函数,再次理解 fork函数
man fork 查看 fork函数详细介绍
fork 的返回值有两个
- 创建子进程失败返回 -1
- 创建成功:a.给父进程返回子进程的PID b.给子进程返回 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 ,是进程正常退出
//vareturn 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 进程如何退出
(1)main 函数的 return 退出,这是最常用的一种方式
(2)通过 exit 函数退出
man 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 _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
测试代码,让子进程处于 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自动搜索环境变量PATH
- e(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前后该进程的id并未改变
进行程序替换时会发生写时拷贝,保证进程的独立性,不让子进程影响父进程
这就是程序替换的原理
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 命令一样去处理
----------------我是分割线---------------
文章到这里就结束了,进程控制这个篇章也完结了,下篇进入基础IO