一、进程创建
fork函数初识
在Linux中,fork函数是非常重要的函数,它从已存在进程中创建一个新的进程,新进程为子进程,原进程为父进程。
fork函数在子进程中返回0,父进程中返回子进程PID,子进程创建失败返回-1(接下来会细讲)。
进程调用fork,当控制转移到内核中的fork代码后,内核做:
1.分配新的内存块和内核数据结构给子进程。
2.将父进程部分数据结构内容拷贝至子进程。
3.添加子进程到系统进程列表当中。
4.fork返回,开始调度器调度。
当运行到fork之后,父子进程代码共享。如下代码:
运行结果如下:
我们再来回顾一下整个过程:当fork函数还没执行时,由父进程执行第8行代码,此时Before由父进程单独打印,当运行fork函数后,子进程被创建出来,接下来的代码由父子进程分别运行,如果子进程创建成功,则跳过if语句,此时父子进程同时打印After,那么可以看到运行结果打印了两次After,那么fork之后,父进程和子进程谁先执行代码完全由调度器决定。
fork函数返回值
请问,为什么fork函数要给子进程返回0,给父进程返回子进程的PID?
一个父进程可以创建多个子进程,而一个子进程只能有一个父进程。因此对于子进程来说,父进程不需要被标识,对于父进程来说,子进程需要被标识,方便管理子进程。
为什么fork函数会有两个返回值?
fork函数内部会有很多个步骤,例如创建子进程的进程控制块,子进程的进程地址块,到最后一步才return pid;说明在return前子进程已经创建完成了,所以父子进程都会执行return,所以才会有两个返回值。
写时拷贝
当子进程最开始被创建的时候,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块内存,当父进程或者子进程需要修改数据时,才将父进程的数据在内存中拷贝一份,然后再进行修改,这就叫做写时拷贝。
也就是说,当父子进程其中一方需要进行数据修改时再进行拷贝的技术,称为写时拷贝技术。
fork常规用法
1.一个进程希望复制自己,使子进程同时执行不同的代码段。例如父进程等待客户端请求,生成子进程来处理请求,生成子进程来处理请求。
2.一个进程要执行一个不同的程序。例如子进程从fork返回之后,调用exec函数。
fork调用失败的原因
1.系统中有过多的进程,导致内存不足,从而创建失败。
2.实际用户的进程数超过限制,从而创建失败。
二、进程终止
进程退出场景
1.代码运行完毕,结果正确。
2.代码运行完毕,结果不正确。
3.代码异常终止。
进程退出码
写了这么多代码,我们都知道main函数是代码的入口,但实际上main函数只是用户级代码的入口,main函数也是被其他函数调用的,,例如再VS2013当中main函数就是被一个名为__taminCRTStartup的函数所调用,而__taminCRTStartup函数又是通过加载器被操作系统给调用的
既然main函数是间接性被操作系统所调用的,那么当main函数调用结束后就应该给操作系统返回相应的退出信息,而这个所谓的退出信息就是以退出码的形式作为main函数的返回值返回,我们一般以0表示代码成功执行完毕,以非0表示代码执行出现错误,这就是为什么要在main函数最后return 0的原因
代码运行起来就变成了进程,进程结束后main函数的返回值就是进程的进程退出码,我们可以使用echo $?命令查看最近一次进程退出的退出码信息。
例如下列代码:
代码运行结束后,可以使用 echo $? 查看该进程的进程退出码。
此时说明main函数执行完毕。
为什么用0表示执行成功,以非0表示代码执行错误?
因为代码成功只有一种情况,执行完毕成功,而错误有很多种原因,比如内存空间不足,非法访问以及栈溢出等等,我们可以用非0的数字分别表示代码执行错误的原因:
下面再来一段例子,我们输入错误的命令,可以查看到进程退出码为127。
进程正常退出
return退出
在main函数中使用return退出进程是最常用的方法。
exit函数
使用exit函数退出进程也是我们常用的方法,exit函数可以在代码中的任何地方退出进程,并且exit函数在退出进程前会做一系列工作:
1.执行用户通过atexit或on_exit定义的清理函数。
2.关闭所有打开流,所有的缓存数据均被写入。
3.调用_exit函数终止进程。
_exit函数
使用_exit函数退出进程的方法并不常用,_exit函数也可以在代码中任何地方退出进程,但是_exit函数会直接终止进程,并不会在退出进程前做任何收尾工作。
return、exit和_exit之间的联系
执行return 0等同于执行exit(0),因为调用main函数运行结束后,会将main函数的返回值当做exit的参数来调用exit函数。
使用exit函数退出进程前,exit函数会先执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再调用_exit函数终止进程。
进程异常退出
情况一:向进程发生信号导致进程异常退出。
在进程运行期间向进程发生kill -9信号使得进程异常退出,或是使用Ctrl+C使得进程异常退出等。
情况二:代码错误导致进程运行时异常退出。
代码当中存在野指针问题使得进程运行时异常退出,或是出现除0情况使得进程异常退出等。
三、进程等待
进程等待的必要性
1.子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏。
2.进程一旦变成僵尸进程,就算用kill -9命令也无法将其杀死,因为谁也无法杀死一个已经死去的进程。
3.对于一个进程来说,最关心自己的就是父进程,因为父进程需要知道自己派给子进程的任务完成情况。
4.父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息。
获取子进程status
接下来要讲到的wait和waitpid函数,都有一个status函数,这个函数是一个输出型参数,由操作系统进行填充。
如果对status参数传入NULL,表示不关心子进程的退出状态信息。否则,操作系统会通过该参数,将子进程的退出信息反馈给父进程。
status是一个int变量,但status不能简单的当做整形来看待,status的不同比特位所代表的信息不同,具体细节如下(只研究status低16比特位)
在status的低16比特位中,高8位表示退出状态,也就是退出码。进程若是被信号所杀,则低7位为终止信号,第8位表示core dump标志。
考虑到status的16位比特位,我们可以通过位操作得到status的进程退出码和退出信号。
exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F; //退出信号
在操作系统中,也提供了两个宏来获取退出码和退出信号:
WIFEXITED(status) :用于查看进程是否正确退出,检查是否收到信号。
WEXITSTATUS(status):用于获取进程的退出码。
exitNormal = WIFEXITED(status); //是否正常退出
exitCode = WEXITSTATUS(status); //获取退出码
需要注意的是:如果一个进程非正常退出,说明是被进程杀掉了,那么退出码就没有意义了。
进程等待的方法
wait函数
函数:pid_t wait(int * status)
作用:等待任意子进程
返回值:等待成功返回被等待进场的pid,等待失败返回-1
参数:输出型参数,获取子进程的退出状态,不关心可设置为NULL
例如下列例子:创建子进程后,父进程可以使用wait函数一直等待子进程,直到子进程退出后读取子进程的退出信息。
waitpid函数
函数: pid_t waitpid(pid_t pid,int* status,int otions)
作用:等待指定子进程或任意子进程
返回值:
1.等待成功返回被等待进程的pid
2.如果选项为WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0
3.如果调用中出错,返回-1,这是errno会被设置成相应的值以指示错误所在
参数:
1.pid:待等待子进程的pid,若设置为-1,则等待任意子进程。
2.status:输出型参数,获取子进程的退出状态,不关心可设置为NULL
3.option:当设置成WNOHANG时,若等待的子进程没有结束,则waitpid函数直接返回0,不会继续等待;若正常结束,则返回子进程的pid。
在下图也可以看到,使用kill命令杀掉进程的话,父进程也可以等待成功
多进程创建以及等待的代码模型
上面的例子都是父进程创建及等待一个子进程的,其实我们还可以同时创建多个子进程,然后让父进程等待子进程退出,这叫做多进程创建以及等待的代码模型。
例如,以下代码中同时创建了10个子进程,同时将子进程的pid放入到ds数组中,并将这10个子进程退出时的退出码设置为该子进程pid在数组idx中的下标,之后父进程再使用waitpid函数指定等待这10个子进程。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t idx[10];//用来保存子进程pid
for (int i = 0; i < 10; i++){
pid_t id = fork();
if (id == 0){
//child
printf("child process created successfully...PID:%d\n", getpid());
sleep(3);
exit(i);//将退出码设置为pid在数组的下标
}
//father
idx[i] = id;//将pid保存至数组
}
for (int i = 0; i < 10; i++){
int status = 0;
pid_t ret = waitpid(idx[i], &status, 0);//等待子进程返回的pid
if (ret >= 0){
//wait child success
printf("wiat child success..PID:%d\n", idx[i]);
if (WIFEXITED(status)){//如果退出正常
//exit normal
printf("exit code:%d\n", WEXITSTATUS(status));
}
else{//如果退出不正常
//signal killed
printf("killed by signal %d\n", status & 0x7F);
}
}
}
return 0;
}
基于非阻塞接口的轮询检测方案
上面的例子都是父进程在等待子进程却不能做别的事情,这叫做阻塞等待。
实际上我们可以让父进程不要一直等待子进程退出,即非阻塞等待。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if (id == 0){
//child
int count = 3;
while (count--){
printf("child do something...PID:%d, PPID:%d\n", getpid(), getppid());
sleep(3);
}
exit(0);//子进程退出
}
//father
while (1){
int status = 0;
pid_t ret = waitpid(id, &status, WNOHANG);//获取子进程返回pid
if (ret > 0){//子进程已经成功结束
printf("wait child success...\n");
printf("exit code:%d\n", WEXITSTATUS(status));
break;
}
else if (ret == 0){//子进程还未结束,父进程可以做别的事情
printf("father do other things...\n");
sleep(1);
}
else{//等待失败
printf("waitpid error...\n");
break;
}
}
return 0;
}
四、进程替换
替换原理
用fork创建子进程后,子进程执行的是和父进程相同的程序(但有可能执行不同的代码分支),若想让子进程执行另一个程序,往往需要调用一个exec函数。
当进程调用exec函数时,该进程的用户空间代码和数据完全被新程序替换,并从新程序的启动例程开始执行。
当进行进程程序替换时,有没有创建新的进程?
进程程序替换之后,该进程对应的PCB、进程地址空间以及页表等数据结构都没有发生改变,只是进程在物理内存当中的数据和代码发生了改变,所以并没有创建新的进程,而且进程程序替换前后该进程的pid并没有改变。
子进程进行进程程序替换后,会影响父进程的代码和数据吗?
子进程刚被创建时,与父进程共享代码和数据,但当子进程需要进行进程程序替换时,也就意味着子进程需要对其数据和代码进行写入操作,这时就需要将父子进程共享的代码和数据进行写时拷贝,此后父子进程的代码和数据也就分离了,因此不会影响。
替换函数
替换函数有六种以exec开头的函数,统称为exec函数:
一、int execl(const cahr *path, const char 8arg, ...);
第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。
例如,要执行ls程序:
execl("/usr/bin/ls", "ls", "-a", "-i", "-l", NULL);
二、int execlp(const char *file, const char *arg, ...);
第一个参数是要执行程序的名称,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。
例如,要执行ls程序:
execlp("ls", "ls", "-a", "-i", "-l", NULL);
三、int execle(const char *path, char *const arg, ...char *const envp[ ]);
第一个参数是要执行路径的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,以NULL结尾,第三个参数是你自己设置的环境变量。
例如,设置了MYVAL环境变量,在mycmd程序内部就可以使用该环境变量。
char *myenvp[] = {"MYVAL=2024",NULL};
execle("./mycmd","mycmd",NULL,myenvp);
四、int execv(const char *path, char *const argv[ ]);
第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。
例如,要执行的是ls程序。
char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execv("/usr/bin/ls", myargv);
五、int execvp(const char *file, char *const argv[ ]);
第一个参数是要执行程序的名字,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。
char* myargv[] = {"ls", "-a", "-i", "-l", NULL};
execvp("ls",myargv);
六、 int execve(const char *path, char *const argv[ ], char *const envp[ ]);
1
第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的表示你要如何执行这个程序,数组以NULL结尾,第三个参数是你自己设置的环境变量。
例如,设置了MYVAL变量,在mycmd程序内部就可以使用该环境变量。
char* myargv[] = { "mycmd", NULL };
char* myenvp[] = { "MYVAL=2021", NULL };
execve("./mycmd", myargv, myenvp);
函数解释
1、这些函数如果调用成功,那么加载指定的程序并从启动代码开始执行,不再返回。
2、如果调用出错,返回-1
所以, exec函数只要返回了,那么意味着调用失败。
命名理解
l(list):表示列表采用列表形式
v(vector):表示参数采用数组形式
p(path):表示能自动搜索环境变量PATH进行程序查找
e(env):表示可以传入自己设置的环境变量。
函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
execl | 列表 | 否 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 否 | 否,自己配置环境变量 |
execv | 数组 | 否 | 是 |
execvp | 数组 | 是 | 是 |
execve | 数组 | 否 | 否,自己配置环境变量 |
实际上,只有execve才是真正的系统调用,其它五个函数最终都调用的是execve,所以execve在man手册的第2节,而其他五个函数在man手册的第3节,也就是说其他五个函数实际上是对系统调用execve进行了封装,以满足不同用户的不同调用场景。
下图为exec函数间的关系:
制作一个简单的shell
shell就是一个命令行解释器,运行原理是:当有命令需要执行时,shell创建子进程,让子进程执行命令,而shell只需等待子进程退出即可。
所以shell的逻辑很简单,循环下列五个步骤即可:
1.获取命令行。
2.解析命令行。
3.创建子进程。
4.替换子进程。
5.等待子进程退出。
还记得我们知道了需要执行的程序和选项,需要调用什么函数吗:
int execvp(const char *file, char *const argv[ ]);
第一个参数是要执行程序的名字,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。
在制作前,我们先熟悉一下下列函数:
1.getuid():获取当前用户ID;
2.getpwuid(用户ID):获取用户的用户名、主目录、登录shell等信息,并返回一个指向passwd结构的指针,指针内部包含上述的信息。
3.gethostname(char *name, size_t len):获取当前进程所在主机的主机名,并将其存储在提供的name中。
4.gewcwd(char *path,):获取调用进程的当前工作目录的绝对路径,并将其存放在提供的path路径中。
5.strtok(char *str, const char *delim):用指定的delim来分割str字符串,第一次输入的str,第二次则需要改为NULL,返回值为被切割的字符串指针。
#include<stdio.h>
#include<pwd.h>
#include<string.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#define LEN 1024
#define NUM 32
int main()
{
char cmd[LEN];
char* myargv[NUM];
char hostname[32];
char pwd[128];
while(1)
{
//获取命令信息
struct passwd* pass = getpwuid(getuid());
gethostname(hostname,sizeof(hostname)-1);
getcwd(pwd,sizeof(pwd)-1);
int len = strlen(pwd);
char *p = pwd + len -1;
while(*p != '/')
{
--p;
}
p++;
//打印命令提示信息
printf("[%s@%s %s]$",pass->pw_name, hostname, p);
//读取命令
fgets(cmd,LEN,stdin);
cmd[strlen(cmd)-1] = '\0';
//拆分命令
myargv[0] = strtok(cmd," ");//先将指令拆分
//再将选项拆分
int i = 1;
while(myargv[i] = strtok(NULL," "))
{
++i;
}
//创建子进程并读取命令
pid_t id = fork();
if(id == 0)
{
execvp(myargv[0],myargv);
exit(1);
}
//shell
int status = 0;
pid_t ret = waitpid(id,&status,0);
if(ret > 0)
{
//每次执行完都打印退出码,让用户知道这是一个自制的简易shell
printf("exit code:%d\n",WEXITSTATUS(status));
}
}
return 0;
}
下面是程序运行结果:
可以看到带$结尾的就是我们的简易shell,我们只实现了部分功能,可以看到ls命令,自带shell时有颜色,而我们的简易shell并没有,但是这也已经很好了。