1.进程创建
我们在之前的文章中介绍过进程创建的方法,可以通过系统调用接口fork来创建新的进程。
fork在创建完新的子进程之后,返回值是一个pid,对于父进程返回子进程的pid,对于子进程返回0。fork函数后父子进程共享代码,即二者执行的是同一份代码。不同的是对于数据二者相互独立各有一份,于是对于父进程的数据修改不会影响到子进程(在修改时进行写时拷贝将父子数据独立起来)。在fork函数内,进程已经被创建,进程之间相互独立,各自返回各自的返回值(父进程返回子进程PID,子进程返回0)并向下继续执行代码。因而两个进程进入到了不同的分支语句中。
因为有了进程和地址空间的基础知识的铺垫,我们来系统的梳理一下进程创建还有写时拷贝问题:
①在fork调用后,创建出一个新的进程,即现在存在两个进程,他们的关系是父子进程,通过fork的返回值来区分。
②一个进程在内存中的基本内容我们可以暂时简单简化地理解为:进程的PCB,PCB中圈定的一个进程地址空间,以及一个用于映射真实物理地址的页表。对于创建的子进程而言,它为了和父进程独立,所以将这三部分都拷贝了一份给自己。因此此时父进程和子进程的进程地址空间和页表是完全一致的,共用同一份物理内存中的代码和数据。
③在子进程拷贝页表时,会将所有内容修改为只读属性,这是为了写时拷贝做准备。
④当父子进程的一方想要对数据进行修改,则会通过页表映射修改物理内存内容,但是此时由于只读属性,触发了系统错误。于是发生了缺页中断,经过系统检测判定为要发生写时拷贝。于是就会申请内存、拷贝内容、修改页表,然后再恢复执行。
2.进程终止和等待
2.1 进程正常终止
进程的正常终止有着多种方式:
①在main函数中使用return语句,这样可以结束main函数从而结束进程,并返回状态码。
②void exit(int status)
exit是一个C标准库函数,它可以在代码的任何地方结束进程,并且会完成诸如刷新缓冲区、关闭文件描述符等清理操作,然后返回状态码。
③void _exit(int status)
_exit是一个系统调用接口,它用于直接终止进程,返回状态码,但不会完成清理操作。
辨析 :
对于以上三种退出方式,return用于函数返回,当作用于main函数时则会结束主进程,并返回一个值。在执行main函数中的return时,C语言标准库会隐式调用exit()函数来处理程序的退出。
exit则是可以用在代码的任何位置(和return相比,如果不在main函数中,return只能返回结束当前函数)来直接结束整个进程,与此同时会完成清理操作。
而_exit()和exit一样可以直接结束进程,但是不会完成如刷新缓冲区的清理操作。这是因为_ exit是一个比exit更底层的接口,而缓冲区作为语言缓冲区在其层次之上。如果使用_exit结束一个进程,会造成资源泄露,但是进程结束操作系统会自动完成进程的资源回收工作,所以实际不会出现资源泄露的问题。
2.2 进程等待
我们在之前介绍过,如果父进程不对结束的子进程进行处理,那么子进程将会成为僵尸进程,其PCB始终占据着内存,导致内存泄漏。这种情况直到父进程主动回收子进程,或者父进程结束后子进程变成孤儿进程,被1号进程领养后处理才结束。所以使用父进程妥善处理已经结束的子进程是很有必要的。
2.2.1 进程等待的方法
①pid_t wait(int *status);
wait函数帮助父进程获取子进程的退出信息,它会等待任意一个子进程结束,结束的子进程pid作为wait函数的返回值交给父进程,而退出码则会通过输出型参数status带回父进程。如果在调用wait函数时子进程并未退出,那么就会将父进程阻塞在其内部,直到子进程结束。
#include<cstdio>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>int main()
{pid_t id = fork();if(id==0){//子进程int cnn = 5;while(cnn--){printf("我是子进程,我的pid是%d\n",getpid());sleep(1);}exit(87);}else if(id>0){//父进程int status;printf("我是父进程,我的pid是%d\n",getpid());pid_t ret = wait(&status);if(ret > 0){printf("%d 成功退出,退出码为:%d\n",ret,WEXITSTATUS(status));}}return 0;
}
②pid_t waitpid(pid_t pid, int *status, int options);
另外一个函数接口waitpid相比于wait具有更加丰富的功能。
参数
pid:可以传递指定要等待进程的pid,或者也可传参为-1来等待任意一个子进程(和wait功能相同)。
status:同样为一个输出型参数,带出进程的退出信息。
options:选择等待的可选功能项。如传参为0,表示阻塞等待;传参为WNOHANG时表示非阻塞等待,此时需要自己调用非阻塞接口完成轮询检测。
返回值
>0 表示等待成功,返回值即为对应子进程的pid;
==0 表示等待成功,但是子进程暂未退出;
<0 则说明等待失败。
#include<cstdio>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>int main()
{pid_t id = fork();if(id==0){//子进程int cnn = 5;while(cnn--){printf("我是子进程,我的pid是%d\n",getpid());sleep(1);}exit(87);}else if(id>0){//父进程int status;printf("我是父进程,我的pid是%d\n",getpid());while(true){pid_t ret = waitpid(id,&status,WNOHANG);if(ret > 0){printf("%d 成功退出,退出码为:%d\n",ret,WEXITSTATUS(status));break;}else if(ret==0){printf("进程未退出\n");//其他工作sleep(2);}else {printf("等待失败");break;}}}return 0;
}

2.2.2 理解退出码
辨析错误码和退出码:
错误码我们过去经常见到,错误码通常是指errno变量中的值,它表示特定操作(如系统调用或库函数)发生错误的原因。errno是一个全局变量,当出现错误时会自动将错误码存储在errno中,不同的值代表着不同的错误信息。我们可以通过perror和strerror来查看错误信息。
perror:void perror(const char *s); 打印输入的参数字符串+此时errno对应的错误信息
strerror:char *strerror(int errnum); 打印指定错误码(传入参数)的错误信息
对于退出码,它是在进程结束后返回的一个退出状态信息,表示程序的执行结果,一般约定0为成功,而非0为出现错误,至于退出码和原因的对应关系没有固定的要求。
错误码是全局变量,是在进程执行出现错误后自动为errno赋值,其本质上还是进程自己内部的事情。而退出码则是子进程向父进程“汇报”的方式,是两个进程间的交互。退出码在子进程结束时通过main函数的return或者exit交付给父进程,父进程用wait的status接收。
status:
status并非一个简单的int类型的数字,对于不同的退出方式status的内容是不一样的,此处简单讨论status的低16位。
当程序是执行完成并退出时,视为正常终止,此时16位的高8位是真正的退出码,因此要拿到错误码需要对status右移8位,可以采取WEXITSTATUS(status)宏来优雅完成。因此可以看出退出码的范围是0~255的。
当程序异常,如被信号终止,那么此时仍然会记录退出信息,放在status中(如下图),具体细节将在信号部分系统解释。
3.进程程序替换
我们创建新的进程使用的都是fork,但是我们会发现fork创建的子进程和父进程执行的是同样的代码,区别仅仅是不同分支。为了使得子进程可以去执行新的程序,我们可以通过exec函数,将当前进程的代码和数据由新的程序进行替换,从而启动新的程序。
3.1 exec函数
exec函数分为如上几种,根据其名字我们就可以推断出其含义和使用方法。
l:表示列表list,即表示该接口的参数是以列表的形式(可变参数)传入的
v:与list相对,表示数组vector,即表示该接口的参数是以数组argv的方式传入的
p:表示可以不使用路径,具体要替换的可执行程序通过PATH环境变量寻找
e:表示可以使用新的环境变量,环境变量在参数最后由数组传入
对于exec的使用,还有一些要点需要强调:
(1)execl的基本使用方法
#include<cstdio>
#include<unistd.h>int main()
{execl("/usr/bin/ls","ls","-a","-l","--color");
}
exec函数本质是从磁盘中找到可执行程序,然后加载到内存中,覆盖调用exec函数的代码和数据,从而执行这个可执行程序。如上所示,path参数应该指定为具体替换的可执行程序,之后的可变参数即为执行这个可执行程序的参数,表示如何执行这个可执行程序。
如上这段代码就完成了ls程序的替换,后续的可变参数实际上也就是命令行参数了。
(2)execv的基本使用方法
#include<cstdio>
#include<unistd.h>int main()
{char* const argv[] = {(char*)"ls",(char*)"-a",(char*)"-l",(char*)"--color",nullptr};execv("/usr/bin/ls",argv);
}
对于vector的传参方式,需要使用一个数组,其中是命令行参数。和之前介绍过的命令行参数列表argv一样,最后一个元素一定是nullptr。
(3)execvpe的基本使用方法
#include<cstdio>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<stdlib.h>int main()
{pid_t id = fork();if(id>0){//子进程char* const argv[] = {(char*)"Show",nullptr};char* const env[] = {(char*)"ONE=1",(char*)"TWO=2",(char*)"THREE=3"};execvpe("./show",argv,env);exit(1);}pid_t rid = waitpid(id,nullptr,0);if(rid>0){printf("等待成功\n");}return 0;
}
我们在上述的代码中使用了execvep,所以需要手动将环境变量表传参,在正常情况下环境变量表是继承自父进程的,但是在这种手动传递的情况下,进程替换后的show程序(打印环境变量表内容)就拿到了我们提供的环境变量表,于是对于替换后的程序而言,环境变量表就是这个env了。
于是可以总结出,对于一个进程环境变量的来源:
①父进程创建子进程,子进程会拷贝父进程的环境变量表。
a.可以通过extern char** environ;声明环境变量表后访问环境变量表获取。
b.通过main函数的参数env也可以拿到环境变量的字符串数组,访问方式和参数列表相同。
c.可以通过系统调用接口getenv(环境变量名)的方法,获得指定环境变量的内容。
②在进程替换时,使用env参数传递新的环境变量表,这样替换后的进程拿到的环境变量表就是这张env了。
③通过putenv()函数也可以完成对环境变量的新增操作。
(4) 在(3)的代码中,我们还可以发现,实际上这段代码是一个多进程的方式进行的进程替换。fork出子进程后,exec进行写时拷贝,将子进程的页表指向的物理内存加载入show程序的代码数据,然后从新程序的main函数开始执行。
(5) 函数名中包括p的函数,表示可以不使用路径,具体要替换的可执行程序通过PATH环境变量寻找,于是有execl("ls","ls","-a","-l","--color"); 其中第一个ls是执行的程序,而第二个ls是命令行参数列表。
(6)我们发现exec函数都有返回值int,实际上当进程成功替换后并没有返回值,因为进程替换成功了代码数据全部被覆盖了。所以虽说返回-1是失败,实际上一旦返回了值就肯定失败了。
(7)对于如上所示的execl、execlp、execle、execv、execvp、execvpe都是被封装后的C标准库接口,都是真正的系统调用——execve封装过来的。根据传参方式和需求灵活选择即可。
4.shell模拟实现
shell其实就是一个命令解释器,是用户和计算机与计算机系统交互的一个途径。用户输入的指令被shell获取,然后进行处理解析,调用对应的程序。
我们一直在强调一件事,就是所有的指令实际都是可执行程序,可以通过which找到指令程序所在的路径。所以shell并不生产程序,它只是个程序的搬运工,是一个调用者。
考虑shell的工作模式,我们大致可以将其分为四步进行。
在此之前,需要先对环境变量进行初始化。我们在自己的shell中模拟了一个环境变量列表,在shell进程被父进程创建后,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; //命令行参数个数
char* genv[envnum]; //环境变量列表,以此自己定义的表来模拟,是为系统真正的环境变量表int lastcode = 0; //上个进程的退出码void InitEnv()
{extern char** environ;int i = 0;while(environ[i]){genv[i] = (char*)malloc(strlen(environ[i])+1);strncpy(genv[i], environ[i], strlen(environ[i])+1);i++;}genv[i] = nullptr;
}int main()
{InitEnv(); //初始化环境变量:从父shell中拷贝获取char buffer[basesize];while(true){// 1. 打印命令行提示符PrintCommandLine(); // 2. 获取用户命令if(!GetCommandLine(buffer)){continue;}// 3. 分析命令ParseCommandLine(buffer); if (BuildCommand()){continue;}// 4. 执行命令ExecuteCommand();}return 0;
}
4.1 打印命令行提示符
命令行提示字符串我们常见,实际上由用户名、主机名、工作路径组合而成。
注意点:
①对于用户名和主机名,我们可以直接从环境变量中获取,调用getenv()函数即可。
②对于工作路径,如果直接从环境变量获取,我们会发现在cd命令之后,取到的路径不发生任何改变。这是因为环境变量PWD并不会主动根据我们的工作路径的变化而变化,而是需要靠shell去维护的。当使用了cd命令(调用了chdir()函数),此时修改了工作路径,但这个工作路径的修改是发生在进程(也就是shell进程)的PCB中的。于是我们在获取工作路径时,需要通过getcwd()函数来获得此时真正的工作路径。
当获得了工作路径后,我们还需要对环境变量中PWD的更新负起责任,通过putenv接口即可修改环境变量表中的内容。但是我们自己实现的shell中,也模拟了一份环境变量表,这个表就需要自己手动修改环境变量了。
string GetUserName()
{string USER = getenv("USER");return USER.empty() ? "None" : USER;
}string GetHostName()
{string HOST = getenv("HOSTNAME");return HOST.empty() ? "None" : HOST;
}string GetPwd()
{//通过getcwd取得工作路径,然后以此为pwd//同时需要对环境变量表更新//putenv更新的是进程真正的环境变量表//自己创建的一个模拟的genv需要手动更新char pwd[basesize];char pwdenv[basesize];if(nullptr == getcwd(pwd, sizeof(pwd))) return "None";snprintf(pwdenv, sizeof(pwdenv),"PWD=%s", pwd);putenv(pwdenv); //int putenv(const char *string); 修改或新建环境变量int i = 0;while(strncmp("PWD=",genv[i],4)!=0) i++;strncpy(genv[i],pwdenv,strlen(pwdenv)+1);return pwd;//在这种情况下,是从环境变量中读取PWD,而cd命令使用chdir改变的是进程的工作路径,只修改了PCB中的cwd,而不修改环境变量表//string pwd = getenv("PWD");//return pwd.empty() ? "None" : pwd;
}string GetSimpleDir()
{string pwd = GetPwd();size_t pos = pwd.rfind("/");if(pos == string::npos)return pwd;if(pos == 0) //"/"return pwd;else return pwd.substr(pos+1);
}string MakeCommandLine()
{//[xlz44847@localhost home]$ //[用户名@主机名 工作目录]提示符char command_line[basesize];snprintf(command_line, basesize, "[%s@%s %s]# ",GetUserName().c_str(), GetHostName().c_str(), GetSimpleDir().c_str());return command_line;
}void PrintCommandLine()
{printf("%s", MakeCommandLine().c_str());fflush(stdout); //刷新缓冲区
}
4.2 接收用户输入命令
对于用户输入命令,使用fgets这个对空格符不敏感的接收方式来接收。
bool GetCommandLine(char* buffer)
{char* ret = fgets(buffer, basesize, stdin);//fgets在遇到换行符和文件结尾时停止,会读入换行符if(!ret)return false;buffer[strlen(buffer)-1] = '\0';if(strlen(buffer) == 0) return false;return true;
}
4.3 解析命令
对于用户输入命令,实际就是执行程序的命令行参数列表,所以我们要做的就是将这个命令字符串进行分割,组成一个命令行参数列表。
void ParseCommandLine(char* buffer)
{memset(gargv, 0, sizeof(gargv));gargc = 0;const char* sep = " ";gargv[gargc++] = strtok(buffer, sep);while((bool)(gargv[gargc++] = strtok(nullptr, sep)));gargc--;
}
4.4 执行命令
4.4.1 外部命令
执行一个命令,我们一般会通过创建子进程的方式来完成。使用execvpe进行进程替换,然后进行差错处理。
//外部命令由子进程执行
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)) //WIFEXITED:判断子进程是否正常退出//正常退出指通过exit或到达主程序结尾而结束,与之相对的是由信号进行终止{lastcode = WEXITSTATUS(status); //WEXITSTATUS:获得子进程的退出码}else {lastcode = 178;}return true;}return false;
}
4.4.2 内建命令
有一些命令交给子进程是无法完成任务的。比如cd指令修改shell进程的工作路径,这种指令实际上是对shell这个进程自身的变化操作。当创建子进程后自然无法对父进程进行任何操作了,所以这种命令需要shell自己调用函数来完成。
其中对于echo而言,将其作为内建命令的原因是因为echo $?打印上一次退出码,这种指令就无法通过子进程完成,因此也将其作为内建命令。
//內建命令shell来执行
bool BuildCommand()
{if(strcmp(gargv[0], "cd") == 0){if(gargc == 2){chdir(gargv[1]);lastcode = 0;}else {lastcode = 1;}return true;}else if(strcmp(gargv[0], "export") == 0){if(gargc == 2){int i = 0;while(genv[i++]);genv[i] = (char*)malloc(strlen(gargv[1])+1);strncpy(genv[i],gargv[1],strlen(gargv[1])+1);genv[++i] = nullptr;lastcode = 0;}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){if(gargv[1][0]=='$'){//echo $?if(gargv[1][1]=='?'){printf("%d\n",lastcode);lastcode = 0;}//echo $PATHelse {int i = 0;string cmp = gargv[1];cmp = cmp.substr(1);cmp += '=';while(strncmp(cmp.c_str(),genv[i],cmp.length())!=0) i++;printf("%s\n",&genv[i][cmp.length()]);lastcode = 0;}}//echo "xxx"else {printf("%s\n",gargv[1]);lastcode = 0;}}else {lastcode = 3;}return true;}return false;
}