在 Linux 中 fork 函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。我们在前面的学习中也遇到过,所以在此简单介绍一下。
#include // 头文件
pid_t fork(void);
返回值:子进程中返回0,父进程返回子进程 id,出错返回 -1.
当进程调用 fork,控制转移到内核中的 fork 代码后,内核应该做:
当父进程创建子进程后,fork 之后父子进程代码共享,可以使用 if else
进行分流,让子进程和父进程执行不同的任务。
我们知道,当父进程创建子进程后,操作系统会将父进程的 pcb、进程地址空间、页表等拷贝一份给子进程;那么当子进程想要修改数据的时候,我们知道会发生写时拷贝,那么操作系统怎么会知道什么时候进行写入拷贝呢?操作系统又如何介入这个操作呢?
其实操作系统在父进程创建子进程之前,会将页表中的访问权限字段统一修改成只读,无论是地址空间中的哪个区域,都会改成只读,然后再创建子进程,这是为什么呢?
此时我们用户是不知道的,这是为了让操作系统发现我们要进行写时拷贝,此时我们子进程正在写入,但是这个区域是只读区域,页表转换会因为权限问题出错,操作系统就会介入这个过程;首先操作系统会检查这个区域是否真的是只读区域,还是在拷贝给子进程时自己修改成只读的区域,如果真的是只读区域,此时就会报错;但是操作系统如果检查出是自己修改的只读区域,就证明不是出错,就会触发进行重新申请内存,拷贝内容的策略机制。
可以结合下图进行理解:
子进程修改内容前:
子进程修改内容后:
在写时拷贝完成后,操作系统会将对应修改内容的页表中的访问权限字段修改成读写(rw),所以说,在子进程没有进行写入的时候,页表中的访问权限字段都是只读!
另外一个问题,操作系统进行写时拷贝时,为什么要进行拷贝呢?直接将数据写入不就好了吗?原因是因为我们可能不需要修改这个数据的所有内容,可能只需要修改一部分内容!
下面我们实现一个代码,创建一个多进程,代码如下:
1 #include
2 #include
3 #include
4
5 typedef void (*callback)(); // 函数指针
6
7 void work()
8 {
9 int cnt = 5;
10 while(cnt--)
11 {
12 printf("i am child process, pid:%d, ppid:%d\n", getpid(), getppid());
13 sleep(1);
14 }
15 }
16
17 void createSubProcess(int n, callback cb)
18 {
19 int i = 1;
20 for(; i <= n; i++)
21 {
22 sleep(1);
23 pid_t id = fork();
24 if(id == 0)
25 {
26 printf("create child success: %d\n", i);
27 cb(); // 回调函数的使用
28 exit(0); // 退出子进程
29 }
30 }
31 }
32
33 int main()
34 {
35 createSubProcess(5, work);
36
37 // 只有父进程会走到这
38 return 0;
39 }
如上,createSubProcess 是一个创建子进程的函数,我们可以通过传入参数 n,代表需要创建子进程的个数;cb,需要执行的函数,即子进程的任务,以达到我们的目的。
下面我们逐一分析上面进程退出的三种场景。
我们通常写代码中,main 函数都是要返回一个 int 类型的值的,如下:
int main()
{
return 0;
}
那么为什么需要返回一个值呢?因为 main 函数也是被调用的,被谁调用我们先不关心,重要的是我们需要把代码是否正常运行的结果返回,成功则返回 0,失败返回非 0.
下面我们尝试一下在 main 函数中返回非 0;其中我们的 main 函数是在一个程序中的,该程序运行起来就是一个进程,而且是 bash 的子进程,所以该进程最终会给 bash 返回 main 的返回值;我们可以使用指令 echo $?
查看最近一次进程退出的结果;如下段代码:
int main()
{
return 1;
}
其中 ?
中保存的是最近一个子进程执行完毕时的退出码,$
相当于解引用操作。
我们运行起来之后查看它的退出结果:
如上图就把 main 函数的退出结果打印出来了,其实这个结果就是 main 函数的退出码!所以 main 函数的返回值就是进程的退出码! 0代表成功,非0代表失败。
而退出码当中,0 代表成功,但是当退出码为非 0 的时候,"我们"需要关心它是为什么失败的,这个"我们"指的是父进程;所以这时候就应该有不同的数字表明不同的原因,比如 1 代表某种失败原因,2 也代表另一种失败原因… 所以每一个数字代表不同的错误,这就叫做退出码!
纯数字虽然能表明错误原因,但是不便于我们阅读,所以应该需要有一些能够将数字转换为退出码的字符串描述方案;所以系统默认已经为我们提供了一些接口,能够将数字转换为不同的出错原因,方便我们去查!当然我们也可以自定义去定义每个数字对应的错误原因是什么。而系统提供的接口就是 strerror()
,我们查看一下这个接口:
返回值则是对应退出码的字符串;下面我们打印一下这个接口中的字符串,如下段代码:
1 #include
2 #include
3 #include
4 #include
5
6 typedef void (*callback)();
7
8 int main()
9 {
10 int i = 0;
11 for(; i < 200; i++)
12 {
13 printf("%d: %s\n", i, strerror(i));
14 }
15 return 0;
16 }
因为里面的字符串太多,大家可以自行打印观察结果,其中里面一共有 134 个字符串,即每个数字对应的错误原因。
但是我们的 Linux 中并不使用系统提供的接口获取退出码的退出原因描述,而是使用自定义的退出原因描述。
我们在程序中可能会调用多个库函数或者接口,但调用它们的时候可能也会出错,出错的时候就会设置一个错误码,即 errno
,它会记录我们的程序中最后一次库函数或者系统接口出错的错误码;注意这个错误码是 C 语言为我们提供的。
那么错误码和退出码有什么关系呢?
它们的共同特点都是,当失败的时候,来衡量函数、进程出错时的出错详细原因。
例如下段代码:
1 #include
2 #include
3 #include
4 #include
5 #include
6
7
8
9 int main()
10 {
11 printf("before: %d\n", errno);
12 FILE *fp = fopen(".sadsadsa.txt", "r");
13 if(fp == NULL)
14 printf("after: %d, error string : %s\n", errno, strerror(errno));
15 return 0;
16 }
我们在代码中打开一个不存在的文件,肯定是会失败的,这时候我们就可以利用这个函数接口的错误码去找到对应的错误原因描述。
以上就是代码正常运行的情况,不管结果对不对。
首先我们要知道,代码异常终止其实是代码并没有跑完,退出码也就没有意义;所以我们在这先引出一下异常问题,简单介绍一下,后面我们会详细学习。
异常问题:
一旦我们的代码发生了异常,我们观察一下会发生什么现象,例如我们对 NULL 解引用修改,根据我们前面学的知识,NULL 是在 0 地址处的,也就是在代码区,不可被修改,所以这个代码是异常的,如下:
int main()
{
int* p = NULL;
*p = 10;
return 0;
}
我们运行一下观察会有什么现象:
如上图,系统给我们报了一个段错误;我们常说这样的现象叫做程序崩溃,但是本质上这个程序运行起来它就是一个进程,其实就是进程被异常终止了,那么这个进程是被谁杀掉的呢?进程异常是被操作系统杀掉的,因为操作系统是进程的管理者!那么操作系统是如何杀掉进程的呢?
当一个进程一旦出异常了,操作系统本质上是通过信号的方式杀掉进程的;我们以前学进程概念的时候学过使用 kill -9 + pid 杀掉进程,我们继续看看更多的信号,可以使用 kill -l
指令,如下:
其实当我们的进程出异常的时候,进程的异常信息会被操作系统检测到,进而被操作系统转化为信号然后把该进程杀掉的!例如我们上面那段代码中的段错误,是可以在上面的信号中找到的,例如下图:
如上图,11 号信号就是段错误对应的信号,也就是说该进程接收到操作系统给它发的 11 号信号而终止的!怎么证明呢?下面我们写一段正常的代码,然后在另外一个窗口给该进程发送对应的信号观察一下:
int main()
{
while(1)
{
printf("i am a normal process: %d\n", getpid());
sleep(1);
}
return 0;
}
例如上段代码是正常的代码,我们运行起来,在另外一个窗口给它发送 11 号信号:
如上图,正常运行的代码接收到 11 号信号确实会异常终止了。我们观察到对应的信号中没有 0 号信号,其实 0 号信号就是正常的情况。
所以一个进程首先要先检查代码是否异常终止了,是否异常终止只需要看有没有接收到信号即可,而接收信号无非就是接收一个数字;当代码没有异常正常运行时,我们就要看该进程的退出码,观察它是否正确运行,而退出码无非也是一个数字而已;所以一个进程是否能正常并且正确运行,父进程只需要观察这两个数字即可!即信号和退出码!
从上面的学习中我们知道,main 函数的返回值就是退出码,所以我们可以通过 main 函数直接返回从而进程退出,这个不多说;但是进程退出不能通过其它子函数返回,只能通过 main 函数返回。
exit 是库函数,也是退出进程的常见方法,它和 return 的使用差不多,直接在程序的任意位置使用,并在括号内填入退出码即可;下面看一段代码:
1 #include
2 #include
3 #include
4 #include
5
6 void func()
7 {
8 int cnt = 5;
9 while(cnt--)
10 {
11 printf("i am a process, pid: %d, ppid: %d\n", getpid(), getppid());
12 exit(7);
13 }
14 }
15
16 int main()
17 {
18 func();
19
20 return 0;
21 }
如上,程序应该是在调用 func 后执行一次 printf 后直接退出,我们观察结果是否是我们预期的结果:
如上图,确实是这样的,我们再观察一下退出码:
也确实是 7,所以 exit 的使用和 从 main 中 return 差不多。但是它与我们下面要介绍的 _exit 有区别。
_exit 是系统调用,_exit 也同样可以在程序的任意位置终止进程,我们先看一下使用:
1 #include
2 #include
3 #include
4 #include
5
6 void func()
7 {
8 int cnt = 5;
9 while(cnt--)
10 {
11 printf("i am a process, pid: %d, ppid: %d\n", getpid(), getppid());
12 _exit(7);
13 }
14 }
15
16 int main()
17 {
18 func();
19
20 return 0;
21 }
还是上面那段代码,我们将 exit 改成 _exit ,观察现象和它的退出码:
如上图,它是可以正常退出的;
退出码也是正常的;那么它和 exit 的区别在哪呢?下面我们将上面那段代码的 printf 中的换行符去掉,只改下面这一句,观察现象:
printf("i am a process, pid: %d, ppid: %d", getpid(), getppid());
此时没有打印出任何东西,我们再将代码中的 _exit 改回 exit 观察一下:
如上图,如果是 exit 的话没有换行符也是可以正常打印出结果的,这是为什么呢?
所以我们得出结论:exit 终止程序的时候,会自动刷新缓冲区,所以就会打印出我们要的结果;_exit 终止程序的时候,不会自动刷新缓冲区,它会直接退出进程,什么也不管,所以不会打印出结果。
进程等待就是通过 wait/waitpid
的方式,让父进程对子进程进行资源回收的等待过程。
进程等待的必要性有以下几点:
我们可以看一下 man 手册中的 wait,其中 wait 是系统调用,所以它所在的手册是 2 号手册,3 号手册是库函数,所以我们执行指令 man 2 wait
即可查看 wait 系统调用:
wait 的作用是等待父进程的任意一个子进程的退出。
其中 wait 中的参数 status 我们先不管,我们在下面介绍 waitpid
再介绍;下面我们看一下它的返回值:
如上,wait 的返回值:如果成功,返回的是退出的子进程的 pid;失败则返回 -1.
下面我们看一段代码,验证父进程是否会等待子进程并当子进程为僵尸状态时是否会回收子进程的资源:
#include
#include
#include
#include
#include
void worker()
{
int cnt = 5;
while(cnt--)
{
printf("i am child process, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
sleep(1);
}
}
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
worker();
exit(0);
}
else
{
sleep(10);
//father
pid_t rid = wait(NULL);
if(rid == id)
{
printf("wait success, pid: %d\n", getpid());
}
sleep(5);
}
return 0;
}
如上代码,我们先使用 fork 创建子进程,让子进程去执行 worker 方法,父进程则 sleep 上10秒,因为执行 worker 方法需要 5 秒,所以 5 秒后子进程会变成僵尸状态,因为此时父进程还在 sleep,sleep 过后我们的预期是父进程会回收子进程,最后再 sleep 上 5 秒,便于我们观察结果:
如上图,结果确实如此,当子进程变为僵尸状态父进程确实会回收子进程。
那么在子进程运行期间,父进程有没有调用 wait 呢?父进程在干什么呢?下面我们通过下面这段代码不再让父进程 sleep,验证一下:
#include
#include
#include
#include
#include
void worker()
{
int cnt = 5;
while(cnt--)
{
printf("i am child process, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
sleep(1);
}
}
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
worker();
exit(0);
}
else
{
printf("wait before\n");
//father
pid_t rid = wait(NULL);
printf("wait after\n");
if(rid == id)
{
printf("wait success, pid: %d\n", getpid());
}
sleep(5);
}
return 0;
}
执行结果如下:
如上图,我们得出结论:如果子进程根本就没有退出,父进程必须在 wait 上进行阻塞等待,直到子进程僵尸,wait 自动回收,再返回!
同时,一般而言谁先运行不知道,但是最后一般都是父进程最后退出。
我们先看一下 waitpid 的手册介绍:
如上图,waitpid 的第一个参数 pid 是指 waitpid 可以等待任意一个进程,如果 pid >= 0 ,则等待进程 id 为指定 pid 的进程;如果 pid == -1,则和 wait 一样,等待任意一个进程。其中 waitpid 的返回值和 wait 的返回值一模一样,大于0表示成功,返回的是等待的进程id;失败则返回小于 0 的数;第三个参数 options 我们暂时先不管,让它以默认的等待方式,即 0;第二个参数 status 稍后介绍。
下面我们使用 waitpid 替代 wait,再次演示一下上面的操作;其中第二个参数一样先设为空,因为我们还暂时还不关心它的退出结果;代码如下:
#include
#include
#include
#include
#include
void worker()
{
int cnt = 5;
while(cnt--)
{
printf("i am child process, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
sleep(1);
}
}
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
worker();
exit(0);
}
else
{
printf("wait before\n");
//father
pid_t rid = waitpid(id, NULL, 0);
printf("wait after\n");
if(rid == id)
{
printf("wait success, pid: %d\n", getpid());
}
sleep(5);
}
return 0;
}
结果如下:
如上图,结果也符合我们的预期。
接下来我们介绍一下 waitpid 的第二个参数 status; waitpid 的第二个参数 status 是一个输出型参数,我们通过 waitpid 的系统接口将这个参数传给操作系统,操作系统会将这个参数写入然后给我们返回这个进程的退出信息,我们可以根据以上结论尝试一下使用;其中代码如下,worker 还是上面的 worker:
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
worker();
exit(10);
}
else
{
printf("wait before\n");
//father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
printf("wait after\n");
if(rid == id)
{
printf("wait success, pid: %d, status: %d\n", getpid(), status);
}
sleep(5);
}
为了更明显地看到结果,我们将子进程的退出码改为 10,下面我们观察 status 返回的结果:
如上图,为什么退出信息不是 10 呢?为什么会是 2560 呢?下面我们就要介绍一下 status 的构成了;首先 status 是一个整数,它有 32 位比特位,它是根据 32 位比特位进行区域划分的,使用不同的比特位区域来表示不同的含义的!而我们在后续使用的时候,我们只考虑 status 比特位的低 16 位,其中它的区域划分如下图,将它归为两大类:
如上图,如果进程是正常终止,那么 0 ~ 6 位都是 0,表示没有接收到信号;8 ~ 15 位表示退出状态,即退出码;如果是被信号所杀,那么低位保存的是终止信号的信息,还有一个第 7 位表示 core dump 的标志位,我们先不管,以后再学。
这就是为什么我们将子进程的退出码设为 10,但是 status 整体打印出来是 2560 的原因了,我们可以按照上面的原理分析一下,因为我们上面的代码是正常退出的,并没有被信号所杀,所以按照第一种情况分析,如下图:
下面我们将代码改进一下,观察是否是像我们预期的结果一样:
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
worker();
exit(10);
}
else
{
printf("wait before\n");
//father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
printf("wait after\n");
if(rid == id)
{
printf("wait success, pid: %d, rpid: %d, exit sig: %d, exit code: %d\n", getpid(), rid, status&0x7f, (status)>>8&0xff);
}
sleep(5);
}
如上代码,如果我们需要打印 status 中的退出码,应该是要将 status 右移 8 位后按位与上 0xff(1111 1111),即可得到退出码;如果我们需要得到退出信号的信息,则直接按位与 0x7f(0) 就将除了低 7 位的其它位都清零了,只保留低 7 位;所以执行结果如下:
如上图,结果确实是我们预期的结果,即代码跑完,结果不正确。
如果被信号所杀的呢?下面我们也演示一下被信号所杀 status 的信息:
如上图,结果没有问题,exit sig 显示的是对应的信号编号。那为什么 exit code 是 0 呢?因为当代码异常了(被信号所杀),那么 exit code 就没有意义了,所以有可能是全 0,也有可能是随机值,但是已经没有意义了。
那么父进程是如何得知子进程的退出信息的呢?首先我们在用户层面调用 wait/waitpid 接口的时候,定义了一个 status 变量并将它传入接口中,操作系统内部会有一个指针指向 status;而数据和代码存在于进程 pcb 中,当数据和代码执行完,pcb 中有两个变量,分别是 exit_code 和 exit_signal,操作系统会将这两个变量通过位运算的方式写入到指向 status 的指针中,然后返回结果到用户层面,我们就能得到 status 了。
那父进程又是如何等待子进程的呢?首先每个进程 pcb 内部都有内置的等待队列,当父进程在等子进程时,其实就是将父进程的 pcb 链入子进程的等待队列里;当子进程退出时,操作系统就直接从子进程的等待队列里把父进程拿出来,然后调度父进程,就执行父进程对应的 wait/waitpid 了。
但是我们通过位运算得到的退出信息可读性不是很好,所以 Linux 也为我们提供了两个接口:
所以我们上面的代码可以使用上面两个接口改成如下:
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
worker();
exit(10);
}
else
{
printf("wait before\n");
//father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
printf("wait after\n");
if(rid == id)
{
if(WIFEXITED(status))
{
printf("child process normal quit, exit code: %d\n", WEXITSTATUS(status));
}
else
{
printf("child process quit except!\n");
}
}
sleep(5);
}
当代码正常退出时,如下:
下面我们写一段等待多个子进程的代码:
void worker(int num)
{
int cnt = 10;
while(cnt--)
{
printf("i am child process, pid: %d, ppid: %d, cnt: %d, num: %d\n", getpid(), getppid(), cnt, num);
sleep(1);
}
}
首先我们循环创建多个子进程,让子进程执行 worker 方法,再传入参数 i,是为了让每个子进程都有自己的编号,方便我们观察结果;最后父进程也是要循环进行等待的;所以代码如下:
int main()
{
for(int i = 0; i < n; i++)
{
pid_t id = fork();
if(id == 0)
{
worker();
exit(i);
}
}
// 等待多个子进程
for(int i = 0; i < n; i++)
{
int status = 0;
pid_t rid = waitpid(-1, &status, 0); // pid == -1,等待任意一个退出的子进程
if(rid > 0)
{
printf("wait child %d success, exit code: %d\n", rid, WEXITSTATUS(status));
}
}
return 0;
}
最后执行的结果会很多,因为创建了很多子进程,所以我们只看最终的等待结果,如下图:
如上图,我们是按循环顺序创建的子进程,为什么等待结果的退出码不是从 0 到 9 的呢?因为进程在调度运行的时候是没有规律的,完全由操作系统决定。
最后,为什么我们不用全局变量获取子进程的退出信息,而是用系统调用呢?原因是因为进程之间具有独立性,父进程是无法直接获取子进程的退出信息!
waitpid 的第三个参数 options 有两种状态,分别是:
0:阻塞等待
WNOHANG:等待的时候,以非阻塞的方式等待
阻塞式等待:子进程不退出,wait/waitpid 不返回;
非阻塞式等待:如果等待条件不满足,wait/waitpid 不阻塞,而是立即返回;
非阻塞等待中,当父进程检测到子进程还没就绪,即等待条件不满足时,往往要进行重复调用,重复检测子进程的状态,这也叫轮询;其中轮询 + 非阻塞方案叫做非阻塞轮询方案进行等待;那么这样的好处是什么呢?好处就是当父进程在等待的过程中,可以做一些自己的占据时间并不多的事情!而阻塞等待中父进程什么都做不了!
所以我们重新看 waitpid 的返回值,其中 > 0 是等待成功,子进程也退出了,返回的是子进程的 pid;== 0 等待是成功的,但是子进程还没有退出;< 0 是等待失败。
下面我们写一个以非阻塞方式等待的代码:
1 #include
2 #include
3 #include
4 #include
5 #include
6
7
8
9 #define TASK_NUM 5
10
11 // 定义一个函数指针
12 typedef void (*task_t)();
13
14 //
15 //父进程在等待时需要执行的任务
16 void download()
17 {
18 printf("this is a download task is rnning!\n");
19 }
20 void printLog()
21 {
22 printf("this is a write log task is rnning!\n");
23 }
24 void show()
25 {
26 printf("this is a show info task is rnning!\n");
27 }
28 ///
29
30
31 // 对函数指针数组进行初始化
32 void initTasks(task_t tasks[], int num)
33 {
34 for(int i = 0; i < num; i++) tasks[i] = NULL;
35 }
36
37 // 对函数指针数组进行添加任务
38 int addTask(task_t tasks[], task_t t)
39 {
40 int i = 0;
41 for(; i < TASK_NUM; i++)
42 {
43 if(tasks[i] == NULL)
44 {
45 tasks[i] = t;
46 return 1;
47 }
48 }
49 return 0;
50 }
51
52 // 执行任务
53 void executeTask(task_t tasks[], int num)
54 {
55 for(int i = 0; i < num; i++)
56 {
57 // 如果对应的函数指针不为空,就调用它
58 if(tasks[i]) tasks[i]();
59 }
60 }
61
62 // 子进程执行
63 void worker(int cnt)
64 {
65 printf("I am child, pid: %d, cnt: %d\n", getpid(), cnt);
66 }
67
68 int main()
69 {
70 task_t tasks[TASK_NUM];
71 initTasks(tasks, TASK_NUM);
72 addTask(tasks, download);
73 addTask(tasks, printLog);
74 addTask(tasks, show);
75
76 pid_t id = fork();
77 if(id == 0)
78 {
79 // child
80 int cnt = 10;
81 while(cnt)
82 {
83 worker(cnt);
84 sleep(2);
85 cnt--;
86 }
87
88 exit(0);
89 }
90
91 while(1)
92 {
93 //father
94 int status = 0;
95
96 // 非阻塞等待,可以让等待方在返回的时候,顺便做做自己的事情
97 pid_t rid = waitpid(id, &status, WNOHANG);
98 if(rid > 0)
99 {
100 // wait success, child quit now;
101 printf("child quit success, exit code: %d, exit signal: %d\n", (status>>8)&0xFF, status&0x7F);
102 break;
103 }
104 else if(rid == 0)
105 {
106 printf("##################################################\n");
107 // wait success, but child not quit
108 printf("child is alive, wait again, father do other thing....\n");
109 // 该函数内部,其实是回调式执行任务
110 executeTask(tasks, TASK_NUM); // 也可以在内部进行自己移除&&新增对应的任务
111 printf("##################################################\n");
112 }
113 else
114 {
115 // wait failed, child unknow
116 printf("wait failed!\n");
117 break;
118 }
119
120 sleep(1);
121 }
122 return 0;
123 }
如上就是以非阻塞方式等待子进程的代码,大家下去可以自行验证。
以前我们所创建的所有子进程,执行的代码,都是父进程代码的一部分;而从现在开始,我们可以做到让子进程执行新的程序,执行全新的代码和访问全新的数据,不再和父进程有关系!这就是进程程序替换。
首先我们先使用单进程熟悉一下程序替换,熟悉程序替换首先就要熟悉它的接口函数,而这种函数称为替换函数,我们可以使用指令 man execl
查看相关的函数:
如上图,有六种以 exec 开头的函数,统称 exec 函数。
下面我们先尝试使用一个 execl 函数,如下代码:
1 #include
2 #include
3
4 int main()
5 {
6 printf("pid: %d, exec command begin\n", getpid());
7 execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
8 printf("pid: %d, exec command end\n", getpid());
9 return 0;
10 }
执行结果如下:
如上图,我们的执行程序是变成了一个进程了的,因为在开始的时候有 pid,而后面使用了 execl
函数之后,我们的进程程序实际上是被 execl
括号内的程序替换了,所以没有执行到下一句的 printf 的语句打印。所以这就能充分说明了,我们可以使用 “语言” 调用其它程序!
下面我们开始介绍一下 execl 这个函数的参数:
其中 path 是我们需要替换的程序,想要找到这个程序,首先要找到程序文件的路径,所以第一个参数 path 是需要替换的程序的路径;arg 是如何执行的问题,我们在命令行怎么写,就将这个参数怎么传。我们可以看到后面还有一些 …,这就像printf 函数的可变参数一样,后面的参数可以传很多个,但是这里是需要我们传这个程序对应的选项,如 ls 指令后面可以跟很多选项,我们就以空格为分隔符,在 execl
中以字符串形式传入即可;最后必须以NULL结尾,表示参数传递完毕。
下面我们开始介绍 exec 系列函数的原理,首先我们的可执行程序运行起来,变成一个进程,生成 pcb、虚拟地址空间、页表等等,将我们程序的代码和数据映射到物理内存中,如下图:
当我们调用了 exec 系列的函数后,假设我们以上面的 ls 为例,当我们使用 ls 的程序替换我们的程序时,磁盘上的 ls 程序的数据和代码会替换我们原来程序在物理内存中的数据和代码,当 cpu 继续调度我们的进程时,就会执行 ls 的程序,所以我们原来程序不再会被执行;结合下图理解:
下面我们创建一个子进程来进行程序替换,如下代码:
1 #include
2 #include
3 #include
4 #include
5
6 int main()
7 {
8 pid_t id = fork();
9 if(id == 0)
10 {
11 // child
12 printf("pid: %d, exec command begin\n", getpid());
13 sleep(3);
14 execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
15 printf("pid: %d, exec command end\n", getpid());
16 }
17 else
18 {
19 // father
20 pid_t rid = waitpid(-1, NULL, 0);
21 if(rid > 0)
22 {
23 printf("success wait, rid: %d\n", rid);
24 }
25 }
26 return 0;
27 }
如上代码,我们让子进程进行程序替换,让父进程等待子进程,执行结果如下:
如上图,我们观察到子进程还是会继续进行程序替换,而父进程也成功回收了子进程。那么为什么子进程进行程序替换不会影响父进程呢?因为我们前面学过,进程之间具有独立性!
当父进程创建子进程后,父子进程共享代码和数据,但是当子进程进行程序替换的时候,物理内存的数据和代码会被修改覆盖,所以这时候会影响父进程,所以这时候会发生写时拷贝,将数据和代码拷贝一份给子进程后,将子进程的数据和代码替换即可,这时候再修改子进程页表的映射关系即可!不会影响父进程!
虽然我们懂了程序替换的原理,但是还是会延申一系列的问题,例如,子进程怎么知道要从替换的新程序的最开始执行呢?它怎么知道最开始执行的地方在哪里?
首先我们在编译形成可执行程序的时候,它不是杂乱无章的把代码和数据随便放的,而是有自己放置的规则的,也就是可执行程序是有自己的格式的;而在这个可执行程序的头部里面,有一个字段 entry
,这个字段存的就是可执行程序的入口地址;而我们在调用 exec 系列的函数的时候,它会直接获取这个可执行程序的头部信息 entry
.
而在每个进程中,都有一个程序计数器 eip,实质是个寄存器,寄存器虽然只有一个,但是寄存器中的内容是每个进程独有的,所以说每个进程中都私有一个 eip;当进程切换时它可以把自己的 eip 放上来,就可以知道当前自己执行到哪一行代码了,因为 eip 中存的是当前执行指令的下一条指令的地址;所以当进行程序替换的时候,子进程获取到新程序头部字段 entry
,将这个字段的地址填入到子进程的 eip 中,子进程就可以从新程序的入口开始执行了。
我们上面执行的单进程和多进程代码中,都没有看见结果打印 exec 之后的 printf 语句,这是为什么呢?原因就是只要 exec 系列函数替换成功了,eip 就会转换过去执行新程序的代码了,也就是说 exec 之后的代码都不会被执行了。
所以 exec 这样的函数,如果当前进程执行成功了,则后续代码没有机会执行了,因为被替换了!所以 exec 这样的函数只有失败的返回值,失败会返回 -1;没有成功的返回值!所以一般而言,exec 的返回值不用判断了,只要往后走,就是出错了!
我们上面也看到了程序替换的接口一共有六个,分别如下:
我们上面写的代码都是用的 execl
这个接口,下面我们重新开始认识一下它。
我们可以看到 exec 系列的接口中,所有的接口都是以 exec 开头的,其中 execl 后面的 l 代表什么呢?其实 l 代表 list,传参方式是以列表方式传参。 path 代表目标可执行程序的路径和文件名;arg 代表如何执行,即命令行怎么传我们就怎么传,但是这个参数传错了也不会有影响,因为这个接口设计的时候防止我们传参传错,会自动在路径文件名中查找正确的指令。
如上,execlp 这个接口只是第一个参数和 execl 不同,那么它的第一个参数代表什么呢?首先,execlp 比 execl 多了一个字母 p,这个字母 p 代表 PATH,表示我们要找到的目标可执行程序,但是 execlp 会自动的去环境变量 PATH 中根据 file 去寻找可执行程序;所以它的第一个参数 file 代表我们需要执行的程序。我们也可以按照上面的代码使用一下 execlp,如下代码:
1 #include
2 #include
3 #include
4 #include
5
6 int main()
7 {
8 pid_t id = fork();
9 if(id == 0)
10 {
11 // child
12 printf("pid: %d, exec command begin\n", getpid());
13 sleep(3);
14 execlp("ls", "ls", "-a", "-l", NULL);
15 printf("pid: %d, exec command end\n", getpid());
16 }
17 else
18 {
19 // father
20 pid_t rid = waitpid(-1, NULL, 0);
21 if(rid > 0)
22 {
23 printf("success wait, rid: %d\n", rid);
24 }
25 }
26 return 0;
27 }
运行结果如下:
如上图,execv 为什么叫 execv 呢?execv 后面的 v 我们可以理解成 vector,即一个数组,这个就要和第二个参数一起理解了;我们可以看到,第二个参数 argv 表示的是一个指针数组,其中 const 修饰的是指针指向的指向不能被修改,即第一个下标就只能指向第一个元素,第二个下标就只能指向第二个元素;这个指针数组就是指要将怎样执行,选项等全部放入这个指针数组传进去。我们也可以使用 execv 修改上面的代码,如下:
int main()
{
char *const argv[] = {
"ls",
"-a",
"-l",
NULL
};
pid_t id = fork();
if(id == 0)
{
// child
printf("pid: %d, exec command begin\n", getpid());
sleep(3);
execv("/usr/bin/ls", argv);
printf("pid: %d, exec command end\n", getpid());
}
else
{
// father
pid_t rid = waitpid(-1, NULL, 0);
if(rid > 0)
{
printf("success wait, rid: %d\n", rid);
}
}
return 0;
}
执行结果如下:
通过上面我们所学的知识,execvp 中的 v 和 p 我们都应该知道是什么意思了,v 可以理解成 vector;p 理解成 PATH;所以第一个参数就是传我们需要执行的程序;argv 就是传各种选项的指针数组。下面看代码演示:
int main()
{
char *const argv[] = {
"ls",
"-a",
"-l",
NULL
};
pid_t id = fork();
if(id == 0)
{
// child
printf("pid: %d, exec command begin\n", getpid());
sleep(3);
execvp(argv[0], argv);
printf("pid: %d, exec command end\n", getpid());
}
else
{
// father
pid_t rid = waitpid(-1, NULL, 0);
if(rid > 0)
{
printf("success wait, rid: %d\n", rid);
}
}
return 0;
}
我们传参可以直接传 argv 的第一个元素,因为第一个元素就是我们需要的可执行程序;执行结果如下:
学习了上面的四个接口,我们中途暂停一下,回顾一下我们的程序替换都是替换系统的程序,那么我们可以替换自己写的程序吗?下面我们验证一下,我们另外写一个 c++ 的代码:
1 #include
2
3 int main()
4 {
5 std::cout << "test cpp" << std::endl;
6 std::cout << "test cpp" << std::endl;
7 std::cout << "test cpp" << std::endl;
8 return 0;
9 }
随后我们使用 Makefile 生成可执行程序:
testcpp:testcpp.cc
g++ -o $@ $^ -std=c++11
mytest:mytest.c
gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
rm -f mytest testcpp
但是这样我们只能生成一套依赖关系,这里有两套依赖关系,那么如何生成两套依赖关系呢?可以像下面这样处理:
1 .PHONY:all
2 all:testcpp mytest
3
4 testcpp:testcpp.cc
5 g++ -o $@ $^ -std=c++11
6
7 mytest:mytest.c
8 gcc -o $@ $^ -std=c99
9 .PHONY:clean
10 clean:
11 rm -f mytest testcpp
我们 make 一下观察:
如上就可以编译两个可执行程序了。现在我们要在 c语言 的程序中替换 c++ 的程序,所以我们在 c 文件中作以下修改:
int main()
{
pid_t id = fork();
if(id == 0)
{
// child
printf("pid: %d, exec command begin\n", getpid());
execl("./testcpp", "testcpp", NULL);
printf("pid: %d, exec command end\n", getpid());
}
else
{
// father
pid_t rid = waitpid(-1, NULL, 0);
if(rid > 0)
{
printf("success wait, rid: %d\n", rid);
}
}
return 0;
}
其中 execl("./testcpp", "testcpp", NULL);
中第一个参数 "./testcpp"
表示在当前路径下找到可执行程序,所以第二个参数就可以不加 ./
了;下面我们执行一下观察结果:
如上,我们也可以替换我们自己写的程序;另外,我们还可以替换自己写的各种语言的程序,例如 pathon、java等都可以,因为这些程序运行起来都是进程,而 exec 这样的接口都是进行进程程序替换的!
在学习 execle
这个接口之前,我们先回顾一下以前学的环境变量;我们知道,子进程的环境变量是通过父进程继承下来的,因为环境变量存在于地址空间中,子进程会继承父进程的地址空间;那么父进程的环境变量是从哪里来的呢?答案是从 bash 中来的,因为我们在命令行所运行的程序的父进程都是 bash,所以我们运行的程序的环境变量也就是继承 bash 的!
下面我们介绍一个接口 putenv
,它可以使当前的进程导入环境变量:
那么我们下面开始验证一下,首先我们使用 export
指令将一个环境变量导入到 bash 的环境变量表中,如下图:
然后我们在 testcpp.cc 文件中打印环境变量表,然后在 mytest.c 中,用 testcpp.cc 替换子进程,观察子进程是否继承了父进程的环境变量表,如下图:
如上图,验证了我们的思想是正确的。
随后我们使用 putenv
在 mytest.c 这个父进程中导入环境变量,观察进行替换程序后的子进程也是否继承了父进程的环境变量表,如下代码:
int main()
{
char* env_val = "MY_ENV2=222222222222222222222";
putenv(env_val);
pid_t id = fork();
if(id == 0)
{
// child
printf("pid: %d, exec command begin\n", getpid());
execl("./testcpp", "testcpp", NULL);
printf("pid: %d, exec command end\n", getpid());
}
else
{
// father
pid_t rid = waitpid(-1, NULL, 0);
if(rid > 0)
{
printf("success wait, rid: %d\n", rid);
}
}
return 0;
}
运行后观察结果,我们确实也发现了我们导入父进程的环境变量,如下图:
所以我们得出一个结论:环境变量被子进程继承下去是一种默认行为,不受程序替换的影响;因为通过地址空间可以让子进程继承父进程的环境变量数据,程序替换只会替换新程序的代码和数据,环境变量不会被替换!
所以如果我们想将父进程的环境变量原封不动传给子进程可以用以上的方法;此外,还有另外一个方法,就是用 execle
这个接口,我们先看一下这个接口的介绍:
前两个参数我们已经很熟悉了,其中最后一个参数 envp 就是我们需要传的环境变量。我们将代码改成下面的代码:
int main()
{
pid_t id = fork();
if(id == 0)
{
// child
printf("pid: %d, exec command begin\n", getpid());
execle("./testcpp", "testcpp", "-a", "-b", NULL, environ);
printf("pid: %d, exec command end\n", getpid());
}
else
{
// father
pid_t rid = waitpid(-1, NULL, 0);
if(rid > 0)
{
printf("success wait, rid: %d\n", rid);
}
}
return 0;
}
运行之后结果:
如上图,和第一种方式一样,用 execle
接口我们也能看到子进程继承了父进程的环境变量表。
如果我们想传递我们自己定义的环境变量呢?我们可以写以下代码进行传递:
int main()
{
char* const my_env[] =
{
"MY_ENV1=111111111111111111111",
"MY_ENV2=222222222222222222222",
"MY_ENV3=333333333333333333333",
NULL
};
pid_t id = fork();
if(id == 0)
{
// child
printf("pid: %d, exec command begin\n", getpid());
execle("./testcpp", "testcpp", "-a", "-b", NULL, my_env);
printf("pid: %d, exec command end\n", getpid());
}
else
{
// father
pid_t rid = waitpid(-1, NULL, 0);
if(rid > 0)
{
printf("success wait, rid: %d\n", rid);
}
}
return 0;
}
执行结果如下,就将我们自己定义的环境变量传递给子进程了:
同时,通过我们传递自己的环境变量表可以得出一个结论:在使用 execle
接口时,环境变量的参数并不是以新增的形式传递给子进程,而是覆盖式传递!
那么我们想要新增呢?其实我们上面已经做过了,就是使用 putenv
的接口新增之后,传递给子进程!
所以通过上面,我们得出结论:程序替换可以将命令行参数和环境变量通过自己的参数,传递给被替换的程序的 main 函数中!
我们先看一下它的文档介绍:
通过上面的学习,我们已经知道 v、p、e 分别代表什么了,所以我们使用起来就不是问题了,这里就不作多介绍了。
通过手册发现,我们上面使用的6个接口都是 3号手册,即都是库函数的接口,其实它们都是通过一个系统调用 execve
封装过的,而这个 execve
所在的手册是 2号手册,即系统调用,如下图:
那么为什么要对这6个接口进行封装呢?原因是因为主要还是为了满足各种调用的场景。
现在我们利用当前所学的知识简单模拟实现一个我们自己的 shell 命令行,代码如下:
1 #include
2 #include
3 #include
4 #include
5 #include
6 #include
7
8 #define SEP " "
9 #define SIZE 64
10 #define NUM 1024
11
12 int lastcode = 0;
13 char cwd[1024];
14
15 const char* getUserName()
16 {
17 const char* name = getenv("USER");
18 if(name) return name;
19 else return "none";
20 }
21
22 const char* getHostName()
23 {
24 const char* hostname = getenv("HOSTNAME");
25 if(hostname) return hostname;
26 else return "none";
27 }
28
29 const char* getCwd()
30 {
31 const char* cwd = getenv("PWD");
32 if(cwd) return cwd;
33 else return "none";
34 }
35
36
37 int getUserCommand(char* command, int num)
38 {
39 printf("[%s@%s %s]# ", getUserName(), getHostName(), getCwd());
40 char* input = fgets(command, num, stdin); // 最后输入的是 \n
41 if(input == NULL) return -1;
42
43 command[strlen(command) - 1] = '\0';
44 return strlen(command);
45 }
46
47
48 void CommandSplit(char* in, char* out[])
49 {
50 int argc = 0;
51 out[argc++] = strtok(in, SEP);
52
53 // 截取以空格为分隔符的字符串放入out中,因为 in 字符最后以NULL结尾,所以当没截取到NULL时循环继续
54 // 其中 strtok 为截取字符串的库函数,第一个参数为需要截取的字符串,当设为NULL时,会继续扫描上一次成功调用函数的位置
W> 55 while(out[argc++] = strtok(NULL, SEP));
56 }
57
58
59 int excute(char* argv[])
60 {
61 pid_t id = fork();
62 if(id < 0) return -1;
63 else if(id == 0)
64 {
65 // child
66 // exec command
67 execvp(argv[0], argv);
68 exit(1);
69 }
70 else
71 {
72 // father
73 int status = 0;
74 pid_t rid = waitpid(id, &status, 0);
75
76 // 获取退出码
77 if(rid > 0)
78 {
79 lastcode = WEXITSTATUS(status);
80 }
81 }
82 return 0;
83 }
84
85 void cd(const char* path)
86 {
87 // chdir---更改工作路径的接口,谁调就更改谁的工作路径
88 chdir(path);
89 char tmp[1024];
90 // 获取进程当前所在的绝对工作路径
91 getcwd(tmp, sizeof tmp);
92 sprintf(cwd, "PWD=%s", tmp);
93 putenv(cwd);
94 }
95
96 // 重新理解内建命令,它其实就是 bash 自己执行的,类似与自己内部的一个函数!
97 // 返回1代表是内建命令,0表示不是内建命令
98 int isBuildin(char* argv[])
99 {
100 if(strcmp("cd", argv[0]) == 0)
101 {
102 char* path = NULL;
W>103 if(argv[1] == NULL) path = ".";
104 else path = argv[1];
105 cd(path);
106 return 1;
107 }
108 else if(strcmp("echo", argv[0]) == 0)
109 {
110 if(argv[1] == NULL)
111 {
112 printf("\n");
113 return 1;
114 }
115 if(*(argv[1]) == '$' && strlen(argv[1]) > 1)
116 {
117 char* val = argv[1] + 1;
118 if(strcmp(val, "?") == 0)
119 {
120 printf("%d\n", lastcode);
121 lastcode = 0;
122 }
123 else
124 {
125 const char* enval = getenv(val);
126 if(enval) printf("%s\n", enval);
127 else printf("\n");
128 }
129 return 1;
130 }
131
132 else
133 {
134 printf("%s\n", argv[1]);
135 return 1;
136 }
137
138 return 0;
139 }
140
141 // else if(0) {}
142 // ...
143
144 return 0;
145 }
146
147 int main()
148 {
149 char usercommand[NUM];
150 char* argv[SIZE];
151
152 while(1)
153 {
154 // 1、打印提示符并且获取用户命令字符串长度,如果小于等于0,没有意义,继续重新获取
155 int n = getUserCommand(usercommand, sizeof usercommand);
156 if(n <= 0) continue;
157
158 // 2、分割字符串 --- "ls -a -l" -> "ls" "-a" "-l"
159 CommandSplit(usercommand, argv);
160
161 // 3、判断并执行内建命令
162 n = isBuildin(argv);
163 if(n) continue;
164
165 // 4、执行对应的命令
166 excute(argv);
167 }
168
169 return 0;
170 }