一、进程创建

目前学习到的进程创建的两种方式:

  1. 命令行启动命令(程序、指令等) 。
  2. 通过程序自身,调用 fork 函数创建出子进程。

1、fork 函数初识

在Linux中,fork函数是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。

返回值:
在子进程中返回0,父进程中返回子进程的PID,子进程创建失败返回-1。

进程调用fork,当控制转移到内核中的fork代码后,内核做:

  • 分配新的内存块和内核数据结构给子进程。
  • 将父进程部分数据结构内容拷贝至子进程。
  • 添加子进程到系统进程列表当中。
  • fork返回,开始调度器调度。

fork之后,父子进程代码共享。具体代码前面进程篇讲过,就不展示了。

但是我们要知道 fork之后,父进程和子进程谁先执行完全由调度器决定。

2、fork函数返回值

fork 函数为什么要给子进程返回0,给父进程返回子进程的PID?

一个父进程可以创建多个子进程,而一个子进程只能有一个父进程。因此,对于子进程来说,父进程是不需要被标识的;而对于父进程来说,子进程是需要被标识的,因为父进程创建子进程的目的是让其执行任务的,父进程只有知道了子进程的PID才能很好的对该子进程指派任务。

为什么fork函数有两个返回值?
父进程调用fork函数后,为了创建子进程,fork函数内部将会进行一系列操作,包括创建子进程的进程控制块、创建子进程的进程地址空间、创建子进程对应的页表等等。子进程创建完毕后,操作系统还需要将子进程的进程控制块添加到系统进程列表当中,此时子进程便创建完毕了。
在这里插入图片描述
也就是说,在fork函数内部执行return语句之前,子进程就已经创建完毕了,fork 函数中的 return 语句是被父子进程共享的,那么之后的 return 语句不仅父进程需要执行,子进程也同样需要执行,当 fork 返回时,会往变量 ret 中写入数据(如:pid_t ret = fork(); ),发生了 写时拷贝(具体在下一小节讲) ,导致 ret 有两份,分别被父子进程私有。(代码共享,数据各自私有)
在这里插入图片描述
变量名相同,有两个不同的值,本质是因为被映射到了不同的物理地址处。

3、写时拷贝

发生写实拷贝的流程:
在这里插入图片描述
在这里插入图片描述
这种在需要进行数据修改时再进行拷贝的技术,称为写时拷贝技术。

(1)为什么数据要进行写时拷贝?

进程具有独立性。多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。

(2)为什么不在创建子进程的时候就进行数据的拷贝?

子进程不一定会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配(延时分配),这样可以高效的使用内存空间。

(3)代码会不会进行写时拷贝?

90%的情况下是不会的,但这并不代表代码不能进行写时拷贝,例如在进行进程替换的时候,则需要进行代码的写时拷贝。

4、fork常规用法

(1)一个进程希望复制自己,使子进程同时执行不同的代码段。例如父进程等待客户端请求,生成子进程来处理请求。
(2)一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

5、fork调用失败的原因

fork函数创建子进程也可能会失败,有以下两种情况:

(1)系统中有太多的进程,内存空间不足,子进程创建失败。
(2)实际用户的进程数超过了限制,子进程创建失败。

二、进程终止

1、main 函数的返回值

我们在写 C/C++ 代码时,main 函数里面我们总是会返回 0,比如:

#include <stdio.h>
 
int main()
{
    printf("hello world\n");
    return 0;
}

为什么 main 函数中总是会返回 0 ( return 0; )呢?

  • main 函数中的这个返回值叫做:进程退出码,用来表示进程退出时,其执行结果是否正确。
  • 返回的 0 是给操作系统看的,来确认进程的执行结果是否正确。(0 通常表示成功)

用户可以通过命令 echo $? 查看最近一次执行的程序的 「进程退出码」,比如:

syc@VM-4-17-ubuntu:~/linux/lesson14$ ./process
hello bit
syc@VM-4-17-ubuntu:~/linux/lesson14$ echo $?
10

2、进程退出的几种情况

  • 代码跑完,结果正确。(退出码:0)
  • 代码跑完,结果不正确。(一般是代码逻辑有问题,但没有导致程序崩溃,退出码:非0)
  • 代码没跑完,程序非正常终止了。(这种情况下,退出码已经没有意义了,是由信号来终止,比如 ctrl+c)

3、进程退出码

父进程创建子进程的目的是为了让子进程给我们完成任务,父进程需要通过子进程的退出码知道子进程把任务完成的怎么样。

比如在生活中,网页打不开时,用户需要通过返回的一串错误代码得知网页出错的原因:
在这里插入图片描述
退出码可以人为的定义,也可以使用系统的错误码列表(错误码 (int) 与错误码描述 (string) 之间的映射表)

比如:C 语言库中提供一个接口strerror( ),可以把错误码转换成对应的错误码描述,errno是一个全局变量,把错误码转化成数字,程序如下:

#include <iostream>
#include <string>
#include <cstdio>
#include <string.h>
#include <errno.h>

int main()
{
	printf("before: errno : %d, errstring: %s\n", errno, strerror(errno));

	FILE *fp = fopen("./log.txt","r");
	if(fp == nullptr)
	{

		printf("after:errno : %d, errstring: %s\n", errno, strerror(errno));
		return errno;
	}

	return 10;
}

运行结果:
因为文件没有创建,所以报错

before: errno : 0, errstring: Success
after:errno : 2, errstring: No such file or directory

我们也可以按数字查看所有错误码,一共134个,后面都是unknown error:

#include <stdio.h>
#include <string.h> // strerror
 
int main()
{
    for (int i = 0; i < 10; i++)
    {
        printf("%d -- %s\n", i, strerror(i)); // char *strerror(int errnum);
    } 
    return 0;
}

运行结果:

syc@VM-4-17-ubuntu:~/linux/lesson14$ ./process
0 -- Success
1 -- Operation not permitted
2 -- No such file or directory
3 -- No such process
4 -- Interrupted system call
5 -- Input/output error
6 -- No such device or address
7 -- Argument list too long
8 -- Exec format error
9 -- Bad file descriptor

4、终止正常进程:return、exit、_exit

(1)return 退出

在main函数中使用return退出进程是我们常用的方法。

例如,在main函数最后使用return退出进程。

#include <stdio.h>

int main()
{

	printf("hello sy\n");
	return 0;
}

运行结果:
在这里插入图片描述

(2)exit函数

使用exit函数退出进程也是我们常用的方法,exit函数可以在代码中的任何地方退出进程,终止进程并且exit函数在退出进程前会做一系列工作:

执行用户通过atexit或on_exit定义的清理函数。
关闭所有打开的流,所有的缓存数据均被写入。
调用_exit函数终止进程。

例如,以下代码中exit终止进程前会将缓冲区当中的数据输出。

#include <iostream>
#include <string>
#include <cstdio>
#include <string.h>
#include <errno.h>
#include <cstdlib>

int fun()
{

	std::cout << "hello world" << std::endl;

	exit(100);
}

int main()
{

	fun();
	std::cout <<"进程正常退出"<<std::endl;
}

运行结果:
并没有执行进程正常退出,因为 exit 完就终止程序了,把 exit 换成 return 就可以执行"进程正常退出了"

syc@VM-4-17-ubuntu:~/linux/lesson14$ ./process
hello world
syc@VM-4-17-ubuntu:~/linux/lesson14$ echo $?
100

(3)_exit函数

使用_exit函数退出进程的方法我们并不经常使用,_exit函数也可以在代码中的任何地方退出进程,但是_exit函数会直接终止进程,并不会在退出进程前会做任何收尾工作。

例如,以下代码中使用_exit终止进程,则收尾工作:‘缓冲区当中的数据输出 ‘ 将不再执行 。我们不加endl来体现这个缓冲区数据不刷新

#include <iostream>
#include <string>
#include <cstdio>
#include <string.h>
#include <errno.h>
#include <cstdlib>
#include <unistd.h>
int fun()
{

	std::cout << "hello world" ;

	_exit(100);
}

int main()
{

	fun();
	std::cout <<"进程正常退出"<<std::endl;
}

执行结果:
在这里插入图片描述
若是exit,执行结果:
在这里插入图片描述
说明_exit的缓冲区当中的数据将不会被输出

(4)缓冲区

结构理解:
他们是上下层的关系,exit() 调用了_exit(),只不过把 exit() 封装到语言层了

在这里插入图片描述

我们所说的缓冲区一定不在操作系统内部,如果在操作系统内部,那么printf打印的字符串也在操作系统当中,那么无论是exit()还是_exit()都应该刷新缓冲区,可事实是只有exit()会刷新。所以这个缓冲区叫语言级缓冲区,c/c++提供的。

也就是语言在自己的库当中,当写字符串信息,还在库里面,然后他调用exit(),然后调用比如fflush(),把我们的数据从语言层刷新到操作系统内部,然后再刷新,才有显示的数据。假如调用_exit(),直接终止进程,数据还在上面,没有机会刷新,所以就看不到

三、进程等待

1、进程等待的必要性

  1. 子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏。
  2. 进程一旦变成僵尸进程,那么就算是kill -9命令也无法将其杀死,因为谁也无法杀死一个已经死去的进程。
  3. 对于一个进程来说,最关心自己的就是其父进程,因为父进程需要知道自己派给子进程的任务完成的如何。
  4. 父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息。

2、进程等待的方法

系统调用 wait,waitpid - 等待任意一个子进程改变状态,子进程终止时,函数才会返回。(其实就是等待进程由 R/S(运行/睡眠) 状态变成 Z(僵尸) 状态,然后父进程读取子进程的状态,操作系统回收子进程)


(1)wait 函数

函数原型: pid_t wait(int* status);
作用: 等待任意子进程。
返回值: 等待成功返回被等待进程的pid,等待失败返回-1。
参数: 输出型参数,获取子进程的退出状态,不关心可设置为NULL。

#include <sys/types.h>
#include <sys/wait.h>
 
pid_t wait(int *status);
/*
* wait() 系统调用:暂停正在调用进程的执行,直到它的一个子进程终止。
* 调用 wait(&status) 等价于 waitpid(-1, &status, 0);
*/

例如,创建子进程后,父进程可使用wait函数一直等待子进程,直到子进程退出后读取子进程的退出信息。

#include <iostream>
#include <string>
#include <cstdio>
#include <string.h>
#include <errno.h>
#include <cstdlib>      //exit
#include <unistd.h>     //fork,sleep,getpid,getppid
#include <sys/types.h>  //getpid,getppid
#include <sys/wait.h>   //wait
int main()
{

	pid_t id =fork();
	if(id < 0)
	{

		printf("errno: %d, errstring: %s\n",errno, strerror(errno));
		return errno;
	}
	else if(id == 0)
	{

		int cnt = 5;
		while(cnt)
		{

			printf("子 进 程 运 行 中,pid: %d\n",getpid());
			cnt--;
			sleep(1);
		} 
		exit(0);
	}
	else
	{
		sleep(10);
		pid_t rid = wait(nullptr);
		if(id > 0)
		{

			printf("wait sub process sucess, rid: %d\n", rid);
		}
		while(true)
		{

			printf("我 是 父 进 程 : pid:%d\n",getpid());
			sleep(1);
		}
	}
}

我们可以使用以下监控脚本对进程进行实时监控:

while :; do ps ajx | head -1 && ps ajx | grep process; sleep 1; done

这时我们可以看到,当子进程退出后,父进程读取了子进程的退出信息,子进程也就不会变成僵尸进程了。
在这里插入图片描述

(2)waitpid 函数

#include <sys/types.h>
#include <sys/wait.h>
 
pid_t waitpid(pid_t pid, int *status, int options);
/*
* waitpid() 系统调用:暂停正在调用进程的执行,直到 pid 参数指定的子进程改变状态。
* 默认情况下,waitpid() 仅等待终止的子进程,但此行为可以通过 options 参数进行修改,如下所述。
*/

函数原型: pid_t waitpid(pid_t pid, int* status, int options);

作用: 等待指定子进程或任意子进程。

参数: 有如下几种设置参数的方式。
a. pid:

  • pid = -1,等待任意一个子进程,与 wait 等效。
  • pid > 0,等待其进程 ID 与 pid 相等的子进程,即传入进程 ID,等待指定的子进程。

思考下,fork 函数在父进程中返回子进程的 ID,是为什么呢?为了方便父进程等待指定的子进程。

b. status: 输出型参数(即在函数内通过解引用拿到想要的内容)

  • NULL:表示不关心子进程的退出状态信息。
  • 宏函数 WIFEXITED(status):如果子进程正常终止,则返回 true。(查看进程是否是正常退出)
  • 宏函数 WEXITSTATUS(status):若 WIFEXITED 非零,说明子进程正常终止,返回子进程的退出码。(查看进程的退出码)

c. options:

  • 如果设为 0,默认是阻塞式等待,与 wait 等效。
  • 如果设为 WNOHANG:是非阻塞等待。wait no hang(机器卡主不动,叫hang住了)(因此WNOHANG意思是不要卡住)
    1.若 pid 指定的子进程没有结束,则 waitpid() 函数返回 0,不予以等待。(说明这一次等待失败了,需要再次等待)
    2.若正常结束,则返回该子进程的 ID。(说明等待成功了)
    注意:wait(&status) 等价于 waitpid(-1, &status, 0)。

返回值:

  1. 成功时,返回状态已更改的子进程 ID,
  2. 如果参数 options 指定了 WNOHANG(非阻塞等待),并且存在一个或多个由参数 pid 指定的子进程,尚未更改状态,则返回 0,轮询检测。
  3. 出错时,返回 -1。

(3)status 参数

wait 和 waitpid,都有一个 status 参数,该参数是一个输出型参数,由操作系统填充。

如果传递 NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。

status 不能简单的当作整型来看待,可以当作位图来看待,具体细节如图(只研究 status 低16比特位):

status 变量:
在这里插入图片描述
注:一般进程提前(异常)终止,本质是该进程收到了操作系统发送的信号。

所以:

  • 我们通过检测 status 参数的次低 8 位,可以得到该进程的退出码。
  • 我们通过检测 status 参数的低 7 位,可以获取子进程的终止信号,知道该进程是否被信号所杀,以及被哪个信号所杀。

信号是从 1 号开始的,没有 0 号。如果低 7 位全为 0,说明该进程一定是正常终止的,没有收到任何退出信号;如果 status 参数的低 7 位不为 0,说明该进程是被信号终止的。


(i) 获取子进程的退出码

通过对父进程中 waitpid 函数的第二个参数 status 进行操作,得到 status 次低 8 位的值,即子进程退出码:

(status >> 8) & 0xFF

比如下面代码:

#include <iostream>
#include <string>
#include <cstdio>
#include <string.h>
#include <errno.h>
#include <cstdlib>      //exit
#include <unistd.h>     //fork,sleep,getpid,getppid
#include <sys/types.h>  //getpid,getppid
#include <sys/wait.h>   //wait
int main()
{

	pid_t id =fork();
	if(id < 0)
	{

		printf("errno: %d, errstring: %s\n",errno, strerror(errno));
		return errno;
	}
	else if(id == 0)
	{

		int cnt = 5;
		while(cnt)
		{

			printf("子 进 程 运 行 中,pid: %d\n",getpid());
			cnt--;
			sleep(1);
		} 
		exit(123);
	}
	else
	{
		sleep(10);
		//pid_t rid = wait(nullptr);
		int status = 0;
		pid_t rid = waitpid(id, &status, 0);

		if(rid > 0)
		{
			printf("wait sub process success, rid: %d, status code: %d\n", rid, (status>>8) &0xFF);
		}
		else
		{
			perror("waitpid");
		}
		while(true)
		{

			printf("我 是 父 进 程 : pid:%d\n",getpid());
			sleep(1);
		}
	}
}

运行结果:
父进程通过 waitpid 函数的 status 参数拿到了子进程的退出码。
在这里插入图片描述
【思考】
为什么操作系统要通过 waitpid 函数的 status 参数把子进程的退出码反馈给父进程,而不是定义一个全局变量作为子进程的退出码,然后反馈给父进程呢?

因为进程具有独立性,用户数据被父子进程各自私有。

子进程的退出码是如何被填充到 waitpid 函数的 status 参数中的呢?

子进程的 task_struct 中保存的有子进程的退出信息,所以 wait / waitpid 函数通过子进程的 PCB 拿到退出码和终止信号,填充到 status 参数中。
在这里插入图片描述

(ii)获取子进程的终止信号

通过对父进程中 waitpid 函数的第二个参数 status 进行操作,得到 status 低 7 位的值,即子进程终止信号:
status & 0x7F

如下代码:

#include <iostream>
#include <string>
#include <cstdio>
#include <string.h>
#include <errno.h>
#include <cstdlib>      //exit
#include <unistd.h>     //fork,sleep,getpid,getppid
#include <sys/types.h>  //getpid,getppid
#include <sys/wait.h>   //wait
int main()
{

	pid_t id =fork();
	if(id < 0)
	{

		printf("errno: %d, errstring: %s\n",errno, strerror(errno));
		return errno;
	}
	else if(id == 0)
	{

		int cnt = 5;
		while(cnt)
		{

			printf("子 进 程 运 行 中,pid: %d\n",getpid());
			cnt--;
			sleep(1);
		} 
		exit(123);
	}
	else
	{
		sleep(10);
		//pid_t rid = wait(nullptr);
		int status = 0;
		pid_t rid = waitpid(id, &status, 0);

		if(rid > 0)
		{
			printf("wait sub process success, rid: %d, status code: %d, exit signal: %d\n", rid, (status>>8) &0xFF,status&0x7F);
		}
		else
		{
			perror("waitpid");
		}
		while(true)
		{

			printf("我 是 父 进 程 : pid:%d\n",getpid());
			sleep(1);
		}
	}
}

运行结果:
父进程通过 waitpid 函数的 status 参数拿到了子进程的终止信号。
在这里插入图片描述
我们也可以在子进程中增加点代码,故意伪造一个信号,改变进程的终止信号。

int *p = nullptr;
*p = 100;

运行结果:
在这里插入图片描述

终止信号的编号:

在这里插入图片描述
我们发现刚刚的信号是11,说明对应的是段错误,我们就可以知道是野指针问题了,就可以去代码中修改。

(iii)用子进程完成一个备份任务
#include <iostream>
#include <vector>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

enum{
	OK = 0,
	OPEN_FILE_ERROR,
};

const std::string gsep =" ";
std::vector<int> data;

int SaveBegin()
{
	std::string name = std::to_string(time(nullptr));
	name += ".backup";
	FILE *fp = fopen(name.c_str(),"w");
	if(fp == nullptr) return OPEN_FILE_ERROR;
	std::string dataStr;
	for(auto d : data)
	{
		dataStr += std::to_string(d);
		dataStr += gsep;
	}
	fputs(dataStr.c_str(),fp);
	fclose(fp);
	return OK;
}

void Save()
{
	pid_t id = fork();
	if(id == 0)
	{
		int code = SaveBegin();
		exit(code);
	}
	int status = 0;
	pid_t rid = waitpid(id,&status, 0);
	if(rid > 0)
	{
		int code = WEXITSTATUS(status);
		if(code == 0) printf("备 份 成 功 , exit code : %d\n", code);
		else printf("备 份 失 败 ,exit code : %d\n", code);
	}
	else
	{
		perror("waitpid");
	}
}
int main()
{
	int cnt = 1;
	while(true)
	{
		data.push_back(cnt++);
		sleep(1);
		if(cnt % 10 == 0)
		{
			Save();
		}
	}
}

运行结果:

在这里插入图片描述

(4)options 参数

waitpid 的两种等待方式:阻塞 & 非阻塞,由参数 options 决定

(i)阻塞等待

给 options 参数传 0,默认是阻塞式等待,与 wait 等效。

举例形容阻塞等待:

张三做菜缺酱油,叫李四去买,相当于张三调了一个买酱油的函数,若李四还没回来,则函数就没结束,而李四在买酱油期间,张三一直被卡住,不继续做菜。这就是「阻塞等待」。
注意:我们目前的大多数接口,都是阻塞函数(调用 --> 执行 --> 返回 --> 结束),因为都是单执行流,同时实现起来也比较简单。

进程的阻塞等待: 父进程中的 wait 和 waitpid 函数默认是阻塞调用,调用该函数后,只要子进程没有退出,父进程就得一直等,什么事情都做不了,直到子进程退出,函数才返回。

(ii)非阻塞等待

给 options 参数传 WNOHANG,是非阻塞等待。

举例形容非阻塞等待:

张三打电话问李四作业写完没,李四说没有,过了一会儿,张三又打电话问李四作业写完没,李四说没有……,张三多次打吗,9电话问李四作业写完没,直到李四作业写完,张三才会停止打电话。
上述例子的本质是,张三打电话不会把张三一直卡住,张三可以忙忙自己的事情,通过间隔多次打电话,检测李四的状态。张三每一次打电话,称之为「非阻塞等待」。多次打电话的过程,称之为「非阻塞轮询检测方案」。
为什么自然界一般选择非阻塞呢 —— 因为更加高效一些,不会一直卡在那里不做事。

进程的非阻塞等待: 调用方不需要一直等着,可以边轮询检测边做自己的事情。
想让父进程中的 waitpid 函数是非阻塞调用(即父进程边运行边调用),需要将函数的第三个参数设为 WNOHANG。
在这里插入图片描述
这里的失败,有两种情况:

  1. 并非真的等待失败,而是子进程此时的状态没有达到预期。
  2. 真的等待失败了。

父进程中 waitpid 函数如果是非阻塞调用,返回值有三种情况:

  1. 等待失败:此次等待失败,需要再次检测。
  2. 等待失败:真的失败。
  3. 等待成功:已经返回。

代码实现:进程的非阻塞等待方式:父进程可以做自己的事

//process.h
#include <iostream>
#include <string>
#include <cstdio>
#include <string.h>
#include <errno.h>
#include <cstdlib>      //exit
#include <unistd.h>     //fork,sleep,getpid,getppid
#include <sys/types.h>  //getpid,getppid
#include <sys/wait.h>   //wait
#include "task.h"
#include <functional>
#include <vector>

typedef std::function<void()> task_t;

void LoadTask(std::vector<task_t> &tasks)
{
	tasks.push_back(PrintLog);
	tasks.push_back(Download); 
	tasks.push_back(Backup);
}
int main()
{
	std::vector<task_t> tasks;
	LoadTask(tasks);
	pid_t id = fork();
	if(id == 0)
	{
		//child
		while(true)
		{
			printf("我 是 子 进 程 , pid:%d\n",getpid());
			sleep(1);
		}
		exit(0);
	}
	//father
	while(true)
	{
		sleep(1);
		pid_t rid = waitpid(id, nullptr, WNOHANG);
		if(rid > 0)
		{
			printf("等 待 子 进 程 %d 成 功 \n",rid);
			break;
		}
		else if(rid < 0)
		{ 
			printf("等 待 子 进 程 失 败\n");
			break;
		}
		else
		{
			//自 己 想 做 的 事
			printf("子 进 程 尚 未 退 出\n");
			for(auto &task : tasks)
			{
				task();
			}
		}
	}
}
//task.cc
#include <iostream>
#include "task.h"
void PrintLog()
{
	std::cout << "Print log task" << std::endl;

}

void Download()
{
	std::cout << "Download task" << std::endl;
}

void Backup()
{
	std::cout << "backup task" << std::endl;
}

//task.h
#pragma once
#include <iostream>
void PrintLog();

void Download();

void Backup();

运行结果:
在这里插入图片描述

四、进程的程序替换

1、前言

思考:什么是进程替换?
通过 exec* 函数,把磁盘中的其它程序(代码+数据)加载到内存中,替换当前进程的代码和数据,让页表重新构建映射关系,这期间不会创建新的进程。
在这里插入图片描述
思考:为什么要进程替换?

因为创建子进程的目的一般是这两个:

  • 执行父进程的部分代码,完成特定功能。
  • 执行其它新的程序。——> 需要进行「进程替换」,用新程序的代码和数据替换父进程的代码和数据,让子进程执行。

思考:操作系统是如何做到重新建立映射的呢?

操作系统可以对父进程的全部代码和数据进行写入,子进程会自动触发写时拷贝,开辟新的空间,再把磁盘中第三方程序的代码和数据写入到其中,子进程页表重新建立映射关系。
最终结果是:父进程指向自己的代码和数据,而子进程指向第三方程序的代码和数据。

思考:在进行程序替换的时候,有没有创建新的进程?

没有。进程的程序替换,不改变内核相关的数据结构,只修改部分的页表数据,将新程序的代码和数据加载带内存,重新构建映射关系,和父进程彻底脱离。

2、替换原理

用 fork 创建子进程后,执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用 exec 函数以执行另一个程序。

  • 当进程调用一种 exec 函数时,该进程的用户空间的代码和数据完全被磁盘中新程序的代码和数据替换,并更改页表的部分映射关系,但当前进程的内核相关的数据结构(PCB、地址空间等)不会发生改变。
  • 从新程序的启动例程开始执行。
  • 调用 exec 函数并不会创建新进程,所以调用 exec 函数前后,该进程的 id 并未改变。
  • 这样我们就可以不用去创建新的进程,而直接将磁盘上的可执行程序加载到内存中,进行执行。

3、如何替换:exec 系列函数

(1) 系统调用 execve 函数

功能:执行文件名 filename 指向的程序,文件名必须是一个二进制的 exe 可执行文件。

#include <unistd.h>
 
int execve(const char *filename, char *const argv[], char *const envp[]);

其实,只有 execve 是真正的系统调用,其它 6 个函数都是库函数,最终都是调用的 execve,所以 execve 在 man 手册的第 2 节,其它函数在 man 手册第 3 节。

(2)有 6 种 exec 系列的库函数

统称为 exec 函数,功能:执行文件

#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[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

exec 函数命名理解,这些函数原型看起来很容易混,但只要掌握了规律就很好记:

  • l (list):表示参数采用列表(可变参数列表)
  • v (vector):参数采用数组
  • p (path):自动在环境变量 PATH 中搜索可执行程序(不需要带可执行程序的路径)
  • e (env):可以传入默认的或者自定义的环境变量给目标可执行程序

在这里插入图片描述

(i)execl 函数
  • 这些函数如果调用成功,则加载新的程序从启动代码开始执行,不再返回。
  • 如果调用出错则返回 -1。
  • 所以 exec 函数只有出错的返回值而没有成功的返回值。

execl 函数介绍:

#include <unistd.h>
 
/*
* path: 要执行程序的路径,路径中要包括程序名,比如:usr/bin/ls
* arg: 要执行的程序名/命令名
* ...: 可变参数列表,必须以NULL结尾,表示参数传入完毕
*/
int execl(const char *path, const char *arg, ...);

在这里插入图片描述

execl 函数调用,举例如下(单个进程):

#include <stdio.h>
#include <unistd.h> // exec
 
int main()
{
    printf("my process begin...\n");
    execl("/usr/bin/ls", "ls", "-l", "-a", NULL); // 进程的程序替换
    printf("my process end...\n");
    return 0;
}

运行结果分析:
在这里插入图片描述

在这里插入图片描述
注意: 上述程序,因为只有一个进程,所以发生进程替换后,该进程自己就被替换了,不能去做自己的事情了。所以我们一般是让父进程创建子进程,让子进程通过进程替换,去执行其它程序,而父进程去检测执行结果和等待回收子进程。

#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
	pid_t id = fork();
	if(id == 0)
	{
		printf("i am child process,pid: %d\n",getpid());
		execl("/bin/ls", "ls", "-l", "-a", nullptr);
		exit(1);
	}
	printf("i am father process,pid: %d\n",getpid());
	int status = 0;
	pid_t rid = waitpid(id, &status, 0);
	if(rid > 0)
	{
		printf("father wait succes, ret: %d, code:%d, sig: %d\n",rid,(status >> 8) & 0xff, status & 0x7f); 
	}
	return 0;
}

运行结果:
在这里插入图片描述
总结:

调用 exec 函数,不用考虑当前进程的返回值,因为 exec 函数下面的代码不会被执行(因为当前进程的代码和数据已经被替换了)。所以如果当前进程返回了,则说明 exec 函数调用失败了。
exec 函数有点像特殊的加载器,把程序的代码数据加载到内存中,然后执行。

(ii)execv 函数

在功能上和 execl 没有任何区别,只在传参的方式上有区别。

#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
	pid_t id = fork();
	if(id == 0)
    {
		char *const argv[] = {
			(char*)"ls",
			(char*)"--color",
			(char*)"-a",
			(char*)"-l",
			nullptr
		};
		execv("/usr/bin/ls",argv);
		exit(1);
	}
	pid_t rid = waitpid(id, nullptr, 0);
	if(rid > 0)
	{
		printf("wait success!\n");

	}
	return 0;
}
(iii)execlp 函数

在功能上和 execl 没有任何区别,唯一区别是,只需要给出要执行程序的名称即可,自动去 PATH 中搜索,不需要给出绝对路径。

但是:只有系统的命令,或者自己的命令(前提是已经导入到 PATH 中了),才能够找到。

int main()
{
    pid_t id = fork();
 
    if (id == 0)
    {
        // child
        printf("I'm child process, pid: %d\n", getpid());
        execlp("ls", "ls", "-l", "-a", NULL); // 进程替换
        exit(1);
    }
    // father
	// ...
    return 0;
}
(iv)execvp 函数
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
	pid_t id = fork();
	if(id == 0)
    {
		char *const argv[] = {
			(char*)"ls",
			(char*)"--color",
			(char*)"-a",
			(char*)"-l",
			nullptr
		};
		//1.execvp("ls",argv);
		//2.
		execvp(argv[0],argv);
		exit(1);
	}
	pid_t rid = waitpid(id, nullptr, 0);
	if(rid > 0)
	{
		printf("wait success!\n");

	}
	return 0;
}
(v)execvpe 函数
//myexec.cc
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
	pid_t id = fork();
	if(id == 0)
    {
		char *const argv[] = {
			(char*)"other",
			nullptr
		};

		char *const env[] = {
			(char*)"HELLO=SYC",
			(char*)"HELLO=HJH",
			(char*)"HELLO=SY",
			nullptr
		};
		execvpe("./other",argv, env);
		exit(1);
	}

	pid_t rid = waitpid(id, nullptr, 0);
	if(rid > 0)
	{
		printf("wait success!\n");
	}
	return 0;
}
//other.c
#include <stdio.h>
extern char**environ;

int main()
{
	for(int i = 0; environ[i]; i++)
	{
		printf("env[%d]: %s\n", i, environ[i]);
	}
}

补:环境变量与exec相关

  • getenv — 获取环境变量
    在这里插入图片描述

  • putenv —更改或增加一个环境变量在这里插入图片描述

#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

const std::string myenv="HELLO=HHHHHHHHHHjhhhjhjhjhjh";
int main()
{
	putenv((char*)myenv.c_str());
	pid_t id = fork();
	if(id == 0)
    {
		char *const argv[] = {
			(char*)"other",
			nullptr
		};

		char *const env[] = {
			(char*)"HELLO=SYC",
			(char*)"HELLO=HJH",
			(char*)"HELLO=SY",
			nullptr
		};
		execl("./other","other", nullptr);
		exit(1);
	}

	pid_t rid = waitpid(id, nullptr, 0);
	if(rid > 0)
	{
		printf("wait success!\n");
	}
	return 0;
}

上述代码中用 putenv 导入一个环境变量

运行结果:
在这里插入图片描述

putenv 就是把内容导入到环境变量表中,因此也可以通过下述代码:

#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

const std::string myenv="HELLO=HHHHHHHHHHjhhhjhjhjhjh";
extern char **environ;
int main()
{
	putenv((char*)myenv.c_str());
	pid_t id = fork();
	if(id == 0)
    {
		char *const argv[] = {
			(char*)"other",
			nullptr
		};

		char *const env[] = {
			(char*)"HELLO=SYC",
			(char*)"HELLO=HJH",
			(char*)"HELLO=SY",
			nullptr
		};
		//execl("./other","other", nullptr);
		execvpe("./other", argv, environ);
		exit(1);
	}

	pid_t rid = waitpid(id, nullptr, 0);
	if(rid > 0)
	{
		printf("wait success!\n");
	}
	return 0;
}

五、模拟实现shell

我们可以综合前面的知识,做一个简易的shell

用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左
向右移动。shell从用户读入字符串"ls"。shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结
束。
在这里插入图片描述
然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序并等待这个进程结束。所以要写一个shell,需要循环以下过程:

  1. 获取命令行
  2. 解析命令行
  3. 建立一个子进程(fork)
  4. 替换子进程(execvp)
  5. 父进程等待子进程退出(wait)

根据这些思路,和我们前面的学的技术,就可以自己来实现一个shell了。
实现代码:

#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

using namespace std;

const int basesize = 1024;
const int argvnum = 64;
const int envnum = 64;
//全 局 的 命 令 行 参 数 表 
char *gargv[argvnum];
int gargc = 0;

//全 局 的 变 量 
int lastcode = 0;

//我 的 系 统 的 环 境 变 量 
char *genv[envnum];

//全 局 的 当 前 shell 工 作 路 径 
char pwd[basesize];
char pwdenv[basesize];
string GetEnvVar(const char* var)
{
	const char* value = getenv(var);
	return value ? string(value) : "None";
}

string GetUserName()
{
	
	return GetEnvVar("USER");
} 
 
string GetHostName()
{
	
	return GetEnvVar("HOSTNAME");
}
 
string GetPwd()
{
	if(nullptr == getcwd(pwd, sizeof(pwd))) return "None";
	snprintf(pwdenv, sizeof(pwdenv), "PWD=%s", pwd);

	putenv(pwdenv);
	return pwd;
	//return GetEnvVar("PWD");
}

string LastDir()
{
	string curr = GetPwd();
	if(curr == "/" || curr == "None") return curr;
	size_t pos = curr.rfind("/");
	if(pos == std::string::npos) return curr;
	
	return curr.substr(pos+1);
}
string MakeCommandLine()
{
	 char command_line[basesize];
	 snprintf(command_line, basesize, "[%s@%s %s]# ", GetUserName().c_str(), GetHostName().c_str(), LastDir().c_str());
	 return command_line;
}
 
void PrintCommandLine()
{
	printf("%s", MakeCommandLine().c_str());
	fflush(stdout);
}

bool GetCommandLine(char command_buffer[],int size)//2.获 取 用 户 命 令 
{
	char *result = fgets(command_buffer, size, stdin);
	if(!result)
	{
		return false;
	}
	command_buffer[strlen(command_buffer)-1] = 0;
	if(strlen(command_buffer) == 0) return false;
	return true;
}

void ParseCommandLine(char command_buffer[], int len)//3.分 析 命 令 
{
	(void)len;
	memset(gargv, 0, sizeof(gargv));
	gargc = 0;
	//"ls -a -l -n"
	const char *sep = " ";
	gargv[gargc++] = strtok(command_buffer, sep); 
	while((bool)(gargv[gargc++] = strtok(nullptr, sep)));
	gargc--;
}
						
void debug()
{
	printf("argc: %d\n",gargc);
	for(int i = 0; gargv[i]; i++)
	{
		printf("argv[%d]: %s\n", i, gargv[i]);
	}
}

bool ExecuteCommand()//执 行 命 令 
{
	pid_t id = fork();
	if(id < 0) return false;
	if(id == 0)
	{
		//子 进 程  
		execvpe(gargv[0], gargv, genv);
		exit(1);
	}
	int status = 0;
	pid_t rid = waitpid(id, &status, 0);
	if(rid > 0)
	{
		if(WIFEXITED(status))
		{
			lastcode = WEXITSTATUS(status);
		}
		else
		{
			lastcode = 3;
		}
		return true;
	}
	return false;
}

void AddEnv(const char *item)
{
	int index = 0;
	while(genv[index])
	{
		index++;
	}

	genv[index] = (char*)malloc(strlen(item)+1);
	strncpy(genv[index], item, strlen(item)+1);
	genv[++index] = nullptr;
} 

//shell自 己 执 行 命 令。本 质 是 shell 调 用 自 己 的 函 数 
bool CheckAndExecBulitCommand()
{
	if(strcmp(gargv[0], "cd") == 0)
	{
		//内 建 命 令 
		if(gargc == 2)//cd .. 等 操 作 都 是 占 两 个 位 置,否 则 就 是 false
		{
			chdir(gargv[1]); 
		}
		else
		{
			lastcode = 1;
		}
		return true;
	}
	else if(strcmp(gargv[0], "export") == 0) 
	{
		if(gargc == 2)
		{
			AddEnv(gargv[1]); 
		}
		else
		{
			lastcode = 2;
		}
		return true;
	}
	else if(strcmp(gargv[0], "env") == 0)
	{
		for(int i = 0;  genv[i]; i++)
		{
			printf("%s\n", genv[i]);
		}
		lastcode = 0;
		return true;
	}

	else if(strcmp(gargv[0], "echo") == 0)
	{
		if(gargc == 2)
		{
			// echo $?
			// echo hello
			if(gargv[1][0] == '$')
			{
				if(gargv[1][1] == '?')
				{
					printf("%d\n", lastcode);
					lastcode = 0;
				}
			}
			else
			{
				printf("%s\n", gargv[1]);
				lastcode = 0;
			}
		}
		else
		{
			lastcode = 3;
		}
		return true; 
	}
	return false;
}

//作 为 一个shell,获 取 环 境 变 量 应 该 从 系 的 配 置 来 
//我 们 今 天 就 直 接 从 父 shell中 获 取 环 境 变 量 
void InitEnv() 
{
	extern char **environ;
	int index = 0;
	while(environ[index])
	{
		genv[index] = (char*)malloc(strlen(environ[index])+1);
		strncpy(genv[index], environ[index], strlen(environ[index]+1));
		index++;
	}
	genv[index] = nullptr;
}

int main()
{
	InitEnv();
	char command_buffer[basesize];
	while(true)
	{
		PrintCommandLine();//1.命 令 行 提 示 符 
		//printf("\n");
		sleep(1);

		if(!GetCommandLine(command_buffer, basesize) )
		{
			continue;
		}//2.获 取 用 户 命 令   
	
		//printf("%s", command_buffer);
		
		ParseCommandLine(command_buffer, strlen(command_buffer));//3.分 析 指 令 
		
		if (CheckAndExecBulitCommand())
		{
			continue;
		}
		//debug();
		ExecuteCommand();//4.执 行 命 令 
	}
	return 0;
}

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部