目录
一、重温fork函数
1.再次认识fork函数
调用接口:fork()
头文件:unistd.h
功能:创建一个子进程,给子进程返回0,父进程返回子进程pid
请看如下代码:
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("fork前当前进程的pid:%d\n", getpid());
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
printf("fork前当前进程的pid:%d,fork的返回值为:%d\n", getpid(), id);
return 0;
}
运行结果:
可以看到当前进程的pid为29271,fork执行后子进程被创建,子进程和父进程共用代码,子进程返回0,父进程返回子进程pid
2.进程控制的写时拷贝
书接上回,我们还是需要理解fork的两个返回值是如何产生的,这就涉及到写时拷贝。
每一个进程都有其虚拟地址空间,虚拟地址空间通过页表对应物理内存,数据储存在物理内存中。
一个进程被创建后,操作系统会为其创建PCB和虚拟地址空间,建立页表映射并把代码数据拷贝到物理内存。此时一个进程就开始运行了,在fork后子进程被创建,子进程也拥有父进程相同的数据结构,原进程成为父进程。在父子进程都没有对物理内存中的数据进行修改时,两个进程的数据通过页表都会指向一块物理内存。但是一旦父子进程有其一修改了数据,为了保证进程的独立性,操作系统就会给父子进程中修改数据的那一个开辟新的物理内存,将数据拷贝到新空间再修改,同时页表也会改变物理空间的指向。
3.fork调用失败的原因
fork调用失败一般有这两个原因:
(1)当前系统中的进程太多
(2)实际用户的进程数超过了限制
这段代码就是向你展示一个的用户是可以跑很多个进程的,但是还是不建议在机器上运行。因为这个程序会影响bash,导致系统出错。
#include <stdio.h>
#include <unistd.h>
int main()
{
int cnt = 0;
while(1)
{
int ret = fork();
if(ret < 0)
{
printf("fork error!, cnt: %d\n", cnt);
break;
}
else if(ret == 0)
{
//child
while(1) sleep(1);
}
//partent
cnt++;
}
return 0;
}
//运行结果:
//…………
//-bash: fork: retry: No child processes
//-bash: fork: Resource temporarily unavailable
//-bash-4.2$
二、进程终止
1.进程退出的方法
进程退出的方法包括正常终止和非正常终止。
正常退出:
(1)main函数中用return返回各种数字,其中返回0代表程序正确运行并退出,非零代表程序错误运行并退出
(2)在C/C++代码中使用C库函数:exit()
(3)使用系统调用:_exit
异常退出:
(1)Xshell按ctrl + c,给进程发送终止信号
(2)return+退出码,其实return n也等同于exit(n),因为main函数执行return指令时,返回值也会当做 exit的参数。通常,return返回0为正确,返回非零数字为错误。
2.退出码
我们之前在讲main函数时,总会在后面写上一个return 0;,当时只是告诉大家你虽然也可以返回其他数字,但是大家都返回0,这里我就要解释一下退出码的含义。
(1)代码运行完毕,结果正确 ———— return 0;
(2)代码运行完毕,结果不正确 ———— return 对应退出码;
(3)代码异常终止 ———— 退出码无意义
虽然不同的数字可以描述不同错误,对于计算机来说很好识别,但对于程序员来说,只给一个数字我们显然难以直观理解。所以在学习c语言的strerror就可以通过这个数字找到对应错误的字符串,约有134种标识:
————————————————
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
…………
129:Key was rejected by service
130:Owner died
131:State not recoverable
132:Operation not possible due to RF-kill
133:Memory page has hardware error
134:Unknown error 134
————————————————
我们运行下面一段代码:
我们可以用echo $?来显示上一个运行完毕的进程的退出码。
从这里我们看到我们的代码运行结束后退出码为1,因为函数计算的是1到99的和,是不等于1到100的和的,所以不正常运行返回1。但我们再次查看退出码时,因为上一次查看退出码的echo也是一个进程,这个进程是正常运行的,退出码为0
3.exit()和_exit()
exit()是一个C语言的库函数,而_exit()是一个系统调用,在本质上exit()是_exit()的封装,exit()会比_exit()多做一些事情。
我们可以尝试一下
#include<stdio.h>
int main()
{
printf("hello world");
exit(0);
}
//运行结果:前几秒不动,后几秒打印hello world
#include<stdio.h>
int main()
{
printf("hello world");
_exit(0);
}
//运行结果:不会打印hello world,过几秒程序直接退出
到这里我们认识到:exit函数终止进程也主动刷新缓冲区,_exit终止进程且不会刷新缓冲区。同时缓冲区也不集成在操作系统中。如果缓冲区真的在操作系统中,那么不管是exit还是_exit都会刷新缓冲区,(退出就要清除该进程的所有数据和代码,如果在操作系统内那缓冲区的内容也不能留下)而是在用户级的缓存区,后面的基础IO也会给大家讲解。
前面说到exit是_exit的封装,但exit在调用_exit之前,还做了其他工作:
(1)执行用户通过 atexit或on_exit定义的清理函数。
(2)关闭所有打开的流,所有的缓存数据均被写入
(3)调用_exit退出该进程
三、进程等待
1.什么是进程等待
之前在僵尸进程中讲到,子进程代码执行完毕时,父进程如果不回收子进程的资源,就可能造成子进程成为占用内存,不工作而且还不能被kill掉的僵尸进程,造成内存泄漏。
父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
2.进程等待的方式
(1)wait函数
调用接口:pid_t wait(int*status);
头文件:sys/types.h、sys/wait.h
参数:status是一个整型值,内部保存了进程运行的结果
功能:获取子进程退出状态,并保存在status变量中,不关心则可以传空指针。等待成功返回被等待进程pid,失败返回-1
在linux上运行如下代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 5;
while(cnt)//子进程睡眠一秒打印一次,共五次
{
printf("我是子进程: %d, 父进程: %d, cnt: %d\n", getpid(), getppid(), cnt--);
sleep(1);
}
exit(0);//退出
}
sleep(10); //父进程睡眠10秒
pid_t ret = wait(NULL);
if(id > 0)
{
printf("wait success: %d", ret);
}
}
执行结果:
(2)waitpid函数
调用接口:pid_ t waitpid(pid_t pid, int *status, int options);
头文件:sys/types.h、sys/wait.h
参数:pid表示需要等待的进程的pid值,status与上面相同,options表示当前waitpid函数可用的选项
功能:回收指定pid的进程数据
- pid可以传指定进程的pid,也可以传-1表示等待任意一个进程。
- status不能只是但触底看作一个32比特位整型值,它的不同位置都有它的作用,我们只看它的后16位,也就是次低位和低位。
- 首先明确一下什么是高位和低位:
例如:00000000 00000000 00000000 00000000
高位 次高位 次低位 低位
- 如果进程可以正常终止,status的次第八位开始到十六位结束,也就是从数字的次低位八位中,这八位数字用于保存退出状态的代码。
- 如果进程时直接被传入的信号干掉的,那么传递来的这个信号的代号会被保存在status的低位中,从左向右数第一位保存core dump标志(以后会讲),剩下的保存这个终止的信号代码。
在linux上运行如下代码:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 5;
while(cnt)
{
printf("我是子进程,我的pid为:%d,我的父进程pid为:%d,%d\n", getpid(), getppid(), cnt--);
sleep(1);
}
int* p = NULL;
*p = 10;
exit(0);
}
sleep(10);
int status = 0;
pid_t ret = waitpid(id, &status, 0);//options传0表示阻塞式等待
if(id > 0)
{
printf("等待成功,pid为%d的进程已回收信息,信号代码:%d,退出码:%d\n", ret, (status & 0x7f), (status >> 8) & 0xff);
}
sleep(5);
return 0;
}
(status & 0x7f):0x7f是二进制的1111111,将status的低位后七位按位与1111111即可得到退出状态码
(status >> 8) & 0xff):0xff是二进制的11111111,将status的次低位全部挪到低位再按位与1111111即可得到退出状态码
这里很明显看到程序收到信号退出,信号的代码为11表示野指针,下面的kill表格有对应,退出码也存在但是没有信息价值。
3.阻塞与非阻塞
如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。如果不存在该子进程,则立即出错返回。
说人话就是:阻塞等待就是父进程在子进程终止之前什么事情也不做,就干等着子进程工作。非阻塞等待就是父进程干活期间,过来看一眼,子进程如果还没终止父进程就自己干自己的事情,子进程终止了就回收子进程。由于我们应用中的非阻塞等待大部分不止一次,所以这种多次非阻塞等待也叫轮询。
在阻塞式等待时,父进程一直盯着子进程导致自己也无法进行下一步工作,浪费了父进程的时间。而轮询的非阻塞等待中父进程除了定期来查看子进程情况,剩下的时间还是可以干自己的事情的,这样就提高了效率。
#include <assert.h>
#define NUM 10
typedef void (*func_t)(); //函数指针
func_t Task[NUM];
void task1()
{
printf("running task1\n");
}
void task2()
{
printf("running task2\n");
}
void task3()
{
printf("running task3\n");
}
void loadTask()
{
memset(Task, 0, sizeof(handlerTask));
Task[0] = task1;
Task[1] = task2;
Task[2] = task3;
}
int main()
{
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
int cnt = 10;//子进程运行十秒
while(cnt)
{
printf("child running, pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt--);
sleep(1);
}
exit(10);
}
loadTask();
int status = 0;
while(1)
{
pid_t ret = waitpid(id, &status, WNOHANG);
//WNOHANG:表示非阻塞,子进程没有退出的时候父进程直接返回0并继续执行下面的代码
if(ret == 0)//子进程不断轮询等待
{
//waitpid调用成功而且子进程没有退出就继续非阻塞等待
printf("wait done, but child is running...., parent running other things\n");
for(i = 0; handlerTask[i] != NULL; i++)
{
handlerTask[i](); //运用回调函数,执行我们想让父进程在空闲时也做些事情
}
}
else if(ret > 0)
{
// 1.waitpid等待成功,子进程退出了
printf("wait success, exit code: %d, sig: %d\n", (status>>8)&0xFF, status & 0x7F);
break;
}
else
{
// waitpid调用失败
printf("waitpid call failed\n");
break;
}
sleep(1);
}
return 0;
}
执行结果:
四、进程程序替换
1.什么是进程替换
我们之前讲到过,子进程被创建时,父子进程虽然有各自的进程地址空间,但是它们虚拟地址指向的物理空间是相同的,所以父子进程都使用同一段代码。
那如果我们就不想让子进程执行父进程代码,而是执行其他代码。这个让指定的程序加载到内存并让指定的进程运行的过程就叫进程替换。
举个例子,像leetcode这些网站,在检测你写的代码的正确性时,就会创建一个子进程,让子进程去执行你的代码,这样即使你的代码写的有问题那也是子进程崩掉了,不影响父进程。
2.进程替换相关函数
用fork创建子进程后执行的是和父进程相同的代码,子进程往往要调用一种exec函数以执行另一个程序。当进程调用一类exec函数时,该进程的虚拟地址空间中的代码段和数据段会直接被新程序替换,执行新程序的代码。此时调用exec并不创建新进程,调用exec也不改变该进程的id。
(1)execl
调用接口:int execl(const char *path, const char argv, ...);
头文件:unistd.h
参数:path表示新程序在磁盘的地址,argv是一个可变参数列表,需要将我们执行这个程序时向控制台输入的东西以空格为分割一段一段字符串传参,最后以NULL结尾。
功能:通过程序位置和程序运行名加选项进行进程替换
(2)execlp
调用接口:int execlp(const char *file, const char *argv, ...);
头文件:unistd.h
参数:file表示新程序的文件名,argv与上面一样(注意传空指针),此时这个函数会在环境变量的位置中寻找相应可执行程序
功能:通过程序名和程序名加选项进行进程替换
(3)execle
调用接口:int execle(const char *path, const char *argv, char *const envp[]);
头文件:unistd.h
参数:path表示新程序在磁盘的地址,argv与上面一样(注意传空指针),最后一个参数指向一个环境变量数组,提供新执行程序的环境变量。
功能:通过程序位置、程序名加选项进行进程替换和环境变量表进行进程替换
(4)execv
调用接口:int execv(const char *path, char *const argv[]);
头文件:unistd.h
参数:path表示新程序在磁盘的地址,这里的argv就不太一样了,是一个指针数组,每一个指针指向一个字符串,最后的位置也必须是NULL。相当于C++中的上面的一段一段字符串变为string对象再按顺序插入到一个vector内。
功能:通过程序位置、程序名加选项组成的数组进行进程替换
(5)execvp
调用接口:int execvp(const char *file, char *const argv[]);
头文件:unistd.h
参数:file表示新程序的文件名,argv与上面的execv一样。
功能:通过程序名和程序名加选项进行进程替换和环境变量表进行进程替换
几个函数之间非常相似,没有太好的方法记忆,可以通过字母辅助记忆:
- 字母p表示该函数取文件名file作为参数,并且用环境变量寻找可执行文件,不需要传递它在磁盘的位置
- 字母l表示该函数取一个参数表,与字母v互斥
- 字母v表示该函数取一个数组,可以理解为vector
- 字母e表示该函数取环境变量envp[]数组
在linux执行如下代码:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
char* path = "usr/bin/ls";
char* arg[] = {"ls","-l","-a",(char*)0};
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork:");
exit(-1);
}
else if(id == 0)
{
execl(path, arg[0], arg[1], arg[2], NULL);//二选一
//execvp("ls", arg);//二选一
exit(0);
}
else
{
sleep(1);
printf("父进程已回收资源\n");
}
return 0;
}
运行结果如下:
五、实现一个简易的shell
1.获取命令行
我们在使用shell的时候,发现我们在输入命令是,前面会有:有用户名,版本,当前路径等信息,这里我们可以用printf等输出函数来直接写。(我这里就不去找内部的环境变量了)
比如:[example@-8-7-centos dir2]$
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
while(1)//命令的输入不止一次,必须是一个循环
{
printf("[用户@主机名 当前路径]$");
fflush(stdout);//及时刷新
}
return 0;
}
2.解析命令行
我们在输入命令时,可能不仅仅只是一段,比如说:"ls -a -l "。但是命令行解释器内部在解析指令时应该传递的是"ls" "-a" "-l"这样的多个个字符串,所以我们还需要以空格来分割字符串。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<sys/types.h>
#include<sys/wait.h>
//#define DEBUG
#define NUM 1024
#define OPTION 64
char command[NUM];//储存用户输入的指令
char* my_argv[OPTION];//指针数组,存储指令的各个部分
int main()
{
while(1)
{
//输出命令行
printf("[用户@主机名 当前路径]¥");
fflush(stdout);
//获取输入的内容
char* p = fgets(command, sizeof(command)-1, stdin);//最后输入的\n不读取
assert(p != NULL);//保证输入不为空
command[strlen(command)-1] = 0;//把command最后一位变为\0,让它成为C字符串
//字符串切割
my_argv[0] = strtok(command, " ");//第一个一定是对应指令的文件名,先切割下来
int i = 1;//从第二个开始
while(my_argv[i++] = strtok(NULL, " ")){}
//循环分段,传NULL表示接着上一次切割完的位置继续切割,不可分时会返回NULL退出循环
#ifdef DEBUG//做一个条件编译,查看代码的正确性
int j = 0;
for(j = 0 ; my_argv[j]; j++)
{
printf("myargv[%d]:%s\n", j, my_argv[j]);
}
#endif
}
return 0;
}
3.创建子进程和进程替换
为了不影响shell,我们将大部分指令的执行让子进程去完成,父进程只要阻塞等待子进程完成就好了。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<sys/types.h>
#include<sys/wait.h>
//#define DEBUG
#define NUM 1024
#define OPTION 64
char command[NUM];
char* my_argv[OPTION];
int lastcode = 0;//退出码
int lastsig = 0;//信号
int main()
{
while(1)
{
//输出命令行
printf("[用户@主机名 当前路径]¥");
fflush(stdout);
//获取输入的内容
char* p = fgets(command, sizeof(command)-1, stdin);//清除最后输入的\n
assert(p != NULL);
command[strlen(command)-1] = 0;//把command最后一位变成\0
//字符串切割
my_argv[0] = strtok(command, " ");//第一段一定是指令名,先切割下来
int i = 1;
if(my_argv[0] != NULL && strcmp(my_argv[0], "ls") == 0)
{
my_argv[i++] = (char*)"--color=auto";
}
while(my_argv[i++] = strtok(NULL, " ")){}
//循环分段,传NULL表示接着上一次切割完的位置继续切割,不可分时会返回NULL退出循环
#ifdef DEBUG
int j = 0;
for(j = 0 ; my_argv[j]; j++)
{
printf("myargv[%d]:%s\n", j, my_argv[j]);
}
#endif
//创建子进程运行相关可执行程序
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
execvp(my_argv[0], my_argv);
exit(1);
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);//子进程执行指令,父进程阻塞式等待
assert(ret > 0);//父进程必须等待子进程成功
lastcode = ((status>>8) & 0xFF);//将信号和退出码收集起来
lastsig = (status & 0x7F);
}
return 0;
}
4.特殊处理
(1)ls指令
我们执行的ls指令中不同的文件都有不同的颜色,所以对于ls我们可以加上一个“--color=auto”
//字符串切割
my_argv[0] = strtok(command, " ");//第一段一定是指令名,先切割下来
int i = 1;
if(my_argv[0] != NULL && strcmp(my_argv[0], "ls") == 0)//ls的特殊处理
{
my_argv[i++] = (char*)"--color=auto";
}
while(my_argv[i++] = strtok(NULL, " ")){}
(2)echo指令
首先我们需要了解什么是内嵌指令,内嵌指令就是一部分指令直接定义在父进程中并由父进程完成,不去创建子进程。这也是为什么在存储命令文件的地方我们找不到echo
if(my_argv[0] != NULL && my_argv[1] != NULL && strcmp(my_argv[0], "echo") == 0)
//输入不为空,选项不为空,命令式echo
{
if(strcmp(my_argv[1], "$?") == 0)
{
printf("%d, %d\n", lastcode, lastsig);//上次的退出码和信号
}
else
{
printf("%s\n", my_argv[1]);
}
continue;//运行完就重新开始循环
}
(3)cd指令
任何一个文件都会有其工作目录,进程进行的所有操作都在这个目录内执行。一般就是这个文件所在的目录,这个工作目录可以用chdir来修改。但是这个命令也不能用子进程执行,我们为什么能在linux中进入某个目录,就是因为我们改变了shell的工作目录。如果我们用子进程执行改变工作目录,那么改变的式子进程的工作目录,子进程改变完了又被回收了,父进程完全没动,所以cd也应该实现成内嵌命令。
if(my_argv[0] != NULL && strcmp(my_argv[0], "cd") == 0)
{
if(my_argv[1] != NULL)
{
chdir(my_argv[1]);
}
continue;
}
5.代码汇总
想当CV工程师的可以直接看这里了,当然,这只是一个非常简易的版本,相比我们使用的shell还差很多。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<string.h>
#include<assert.h>
#include<sys/types.h>
#include<sys/wait.h>
//#define DEBUG
#define NUM 1024
#define OPTION 64
char command[NUM];//储存用户输入的指令
char* my_argv[OPTION];
int lastcode = 0;
int lastsig = 0;
int main()
{
while(1)
{
//输出命令行
printf("[用户@主机名 当前路径]¥");
fflush(stdout);
//获取输入的内容
char* p = fgets(command, sizeof(command)-1, stdin);//清除最后输入的\n
assert(p != NULL);
command[strlen(command)-1] = 0;//把command最后一位编程\0
//字符串切割
my_argv[0] = strtok(command, " ");//第一段一定是指令名,先切割下来
int i = 1;
if(my_argv[0] != NULL && strcmp(my_argv[0], "ls") == 0)//ls的特殊处理
{
my_argv[i++] = (char*)"--color=auto";
}
while(my_argv[i++] = strtok(NULL, " ")){}
//循环分段,传NULL表示接着上一次切割完的位置继续切割,不可分时会返回NULL退出循环
#ifdef DEBUG
int j = 0;
for(j = 0 ; my_argv[j]; j++)
{
printf("myargv[%d]:%s\n", j, my_argv[j]);
}
#endif
if(my_argv[0] != NULL && strcmp(my_argv[0], "cd") == 0)
{
if(my_argv[1] != NULL)
{
chdir(my_argv[1]);
}
continue;
}
if(my_argv[0] != NULL && my_argv[1] != NULL && strcmp(my_argv[0], "echo") == 0)
{
if(strcmp(my_argv[1], "$?") == 0)
{
printf("%d, %d\n", lastcode, lastsig);
}
else
{
printf("%s\n", my_argv[1]);
}
continue;
}
//创建子进程运行相关可执行程序
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
execvp(my_argv[0], my_argv);
exit(1);
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);//子进程执行指令,父进程阻塞式等待
assert(ret > 0);
lastcode = ((status>>8) & 0xFF);
lastsig = (status & 0x7F);
}
return 0;
}