文章目录
一.进程创建
初识fork函数
在Linux中fork函数时非常重要的函数,它作用是从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
fork函数的返回值:子进程返回0,父进程返回的是子进程的pid,子进程创建失败返回1。
#include <unistd.h>
pid_t id = fork(void);
进程调用fork函数,当控制转移到内核中的fork代码后,内核做:
- 分配新的内存块和内核数据结构(主要有:链表、队列、映射和红黑树。)给子进程
- 将父进程部分数据结构内容拷贝至子进程
- 将子进程添加到系统进程列表中
- fork返回,开始调度器调度
运行结果:可以看到fork前只输出了一次,fork之后输出了两次,那是因为fork前的由父进程打印,fork之后的分别由父进程和子进程打印。也就是fork之前父进程独立运行,fork之后变成父进程和子进程两个指向执行流在执行。可得知当一个进程调用fork之后,就有两个二进制代码相同的进程,相当于子进程将父进程拷贝了一份,而且它们都运行到相同的地方,每个进程都将开始它们自己的旅程。(注意:fork之后,谁先执行完全由调度器决定)
fork函数返回值
我们前面直到了fork函数子进程返回0,父进程返回的是子进程的pid,子进程创建失败返回1。那么我有以下问题:
fork函数为什么给子进程返回0,给父进程返回的是子进程的PID?
一个父进程可以创建多个子进程,而一个子进程只能有一个父进程,因此对于子进程来说父进程是不需要被标识的;而对于父进程来说,子进程是需要标识的,因为1父进程创建子进程的目的是让其指向对于的任务,只有直到了各个子进程的PID,才能更有效率的工作。
为什么fork函数有两个返回值?
父进程调用fork函数后,为了创建子进程,fork函数内部会进行一系列复杂的操作,包括创建子进程PCB,虚拟地址空间,创建子进程对应的页表等等。子进程创建完毕后,操作系统还需要将子进程的进程控制块添加到系统进程列表中。
也就是说fork函数return之前,子进程就已经创建好了,这时由父子进程两个执行流,所以fork函数有两个返回值。
写时拷贝
当子进程刚刚被创建时,子进程和父进程共享数据代码,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。当任意一方尝试写入的时候就会发生写时拷贝。
为什么数据要进行写时拷贝?
因为进程具有独立性,多进程在运行时,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程的数据。
怎么理解虚拟内存地址空间?
1.因为早期的计算机运行方式是直接将程序运行在物理内存上。这就存在三个问题:
问题1. 地址空间不隔离
所有程序都直接访问物理内存,程序使用的物理空间不是相互隔离的。万一进程越界进行非法操作,这样是非常不安全的。
问题2. 内存使用效率低
没有有效的内存管理机制,通常执行一个程序时,监控程序需要将其整个程序装入内存然后开始执行。
问题3. 程序运行的地址不确定
因为每次需要装入运行时,我们都需要给他分配一块足够大的物理空间,而这个物理空间是不确定的。
解决方法:增加中间层,即虚拟内存地址空间
思想: 把程序给出的地址看作是一个虚拟地址,然后通过某种映射关系/方法,将这个虚拟地址转换成实际的物理地址。
这样,只要我们能够处理好虚拟地址和物理地址的映射过程,我们就可以保证任意一个程序所能访问的物理内存空间是互不相同的,以达到地址空间隔离的效果。虚拟内存地址空间的存在,可以更方便的进行进程和进程数据代码的解耦,保证了进程独立性这样的特征,并且可以让进程以统一的视角,来看待进程对应的代码和数据等各个区域,方便编译器也以统一的视角编译代码。(一套是物理内存地址,一套是程序之间实现跳转的虚拟地址)
fork常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
fork调用失败的原因
返回-1
- 系统中有太多的进程
- 实际用户的进程数超过了限制
二.进程终止
进程退出场景
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
进程退出码
我们都知道main函数是代码的入口,但实际上main函数只是用户级别代码的入口,main函数也是被其他函数调用的,例如在VS2013当中main函数就是被一个名为__tmainCRTStartup的函数所调用,而__tmainCRTStartup函数又是通过加载器被操作系统所调用的,也就是说main函数是间接性被操作系统所调用的。
既然main函数是间接性被操作系统所调用的,那么当main函数调用结束后就应该给操作系统返回相应的退出信息,而这个所谓的退出信息就是以退出码的形式作为main函数的返回值返回,我们一般以0表示代码成功执行完毕,以非0表示代码执行过程中出现错误,这就是为什么我们都在main函数的最后返回0的原因。
当我们的代码运行起来就变成了进程,当进程结束后main函数的返回值实际上就是该进程的进程退出码
观察这份代码,最后执行的结果是不等于5050的,所以main函数返回1,代表代买运行完毕,结果不正确。
我们可以使用echo $?命令查看最近一次进程退出的退出码信息
那么为什么要用0表示代码执行成功,以非0表示代码执行错误?
因为代码执行成功只有一种情况,成功了就是成功了,而代码执行错误却有多种原因,例如内存空间不足、非法访问以及栈溢出等等,我们就可以用这些非0的数字分别表示代码执行错误的原因。
C语言当中的strerror函数可以通过错误码,获取该错误码在C语言当中对应的错误信息:
实际上Linux中的ls、pwd等命令都是可执行程序,使用这些命令后我们也可以查看其对应的退出码。
可以看到,这些命令成功执行后,其退出码也是0。
进程常见退出方法
正常终止(可以通过 echo $? 查看进程退出码):
- 从main返回,return退出
- 调用exit
- _exit
异常退出:
- ctrl+c,信号终止
1.return
return是一种常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数,会将main的返回值当做 exit的参数。
2.exit
exit最后也会调用_exit, 但在调用exit之前,还做了其他工作:
- 执行用户通过 atexit或on_exit定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入
- 调用_exit
下列代码会刷新缓冲区,将缓冲区的数据输出,即能够输出hello world
3._exit
使用_exit函数退出进程的方法我们并不经常使用,_exit函数也可以在代码中的任何地方退出进程,但是_exit函数会直接终止进程,并不会在退出进程前会做任何收尾工作。
例如,上面的代码中使用_exit终止进程,则缓冲区当中的数据将不会被输出。
三.进程等待
进程等待的必要性
- 子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏。
- 进程一旦变成僵尸进程,那么就算是kill -9命令也无法将其杀死,因为谁也无法杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息。
获取子进程状态status
下面会介绍有关进程等待的两个函数wait和waitpid,它们都有一个status参数,改参数是一个输出型参数,由操作系统进行填充。如果对该参数传入NULL,表示不关心子进程的退出状态信息。反之,操作系统会通过该参数,将子进程的退出状态信息反馈给父进程。
如何理解参数status?
status实际上是一个整型变量,但是status不能当作整型里看待,因为status的不同的比特位所代表的信息不同(只研究低16位比特位)。在status的低16位比特位中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,低7位表示终止信号,而第8位是core dump标志。需要注意的是,当一个进程非正常退出时,说明该进程是被信号所杀,那么该进程的退出码也就没有意义了。
什么是终止信号?怎么理解?
当终止信号为0,说明程序正常终止,有对应的退出码。如果进程是被信号所杀,那么就有对应的终止信号(异常原因)。如下面讲解的wait和waitpid函数的代码,属于正常退出,所以打印出来的终止信号为0。那么如果我们在代码中设置一个除0(报浮点数错误)或者空指针(报段错误)的错误,程序就会异常退出,这时打印出来的终止信号就会对应错误打印如下表的数字。注意这时退出码无意义。
如何通过参数status获取进程的退出码和终止信号?
1 exitCode = (status>>8) & 0xFF;//退出码
2 exitSignal = status & 0x7F;//退出信号
进程等待的方法
wait方法
1 头文件
#include<sys/types.h>
#include<sys/wait.h>
2 函数原型
pid_t wait(int* status);
3 返回值
成功返回被等待进程pid,失败返回-1。
4 参数
输出型参数,获取子进程退出状态(退出码),不关心可以设置为NULL
我们来看以下代码:
我们用这个命令行脚本对进程状态信息进行监控:
[nan@VM-8-10-centos 进程等待]$ while :;do ps ajx | head -1 && ps axj | grep |grep -v grep; sleep 1;done
这时我们看到,监控进程得到的结果与预期相符,fork先创建子进程,在开始的10s内,子进程正常运行(S+状态),随后子进程退出,变成僵尸状态(Z),接下来10s,子进程都处于僵尸状态,然后父进程调用wait函数,将子进程回收,并且wait函数返回了被回收的子进程的PID,最后只剩下父进程为S+状态。
waitpid方法
1 函数原型:
pid_t waitpid(pid_t pid, int* status, int options);
2 返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
3 参数:
pid:
pid=-1,等待任一个子进程。与wait等效。
Pid>0.等待其进程ID与pid相等的子进程。
status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进
程的ID。
我们来看以下代码:
1 #include<stdio.h>
2 #include<sys/types.h>
3 #include<sys/wait.h>
4 #include<unistd.h>
5 #include<stdlib.h>
6 int main()
7 {
8 pid_t id = fork();
9 if (id == 0)
10 {
11 int cnt = 10;
12 while (cnt--)
13 {
14 printf("I am child...PID:%d, PPID:%d,cnt:%d\n", getpid(), getppid(),cnt);
15 sleep(1);
16 }
17 exit(10);//自己设置了一个退出码为10
18 }
19 //parent
20 //sleep(20);//父进程先不回收子进程,先让我们观察一下子进程的僵尸状态,再回收
21 //pid_t ret = wait(NULL);//等待成功返回子进程PID
22 int status=0;
23 pid_t ret = waitpid(id,&status,0);//这里0表示阻塞
24 if(id>0)
25 {
26 printf("wait success: %d,sig number: %d, child exit code: %d\n",ret,(status & 0x7F),(status>>8)&0xFF);//拿到终止信号 和 子进程的退出码
27 }
28 sleep(5);
29 return 0;
31 }
执行结果:进程正常退出,终止信号为0,子进程的退出码也如我们在代码中所设置的一样。
基于非阻塞接口的轮询检测方案
首先我们的明确什么是阻塞状态,什么是非阻塞状态?
在前面讲的代码中,如果子进程未退出,父进程就一直等待子进程退出,在父进程等待期间,父进程不能做其它事,这种等待叫做阻塞等待。
父进程不用等到子进程退出,父进程才退出,而是每过一段时间去检测一下子进程的状态,那么在子进程未退出时父进程就可以做其它事,当子进程退出时再读取子进程的退出信息,这种等待叫做非阻塞等待。
如何实现非阻塞等待?
实现方法很简单,前面我们学习了waitpid函数,waitpid函数是这样使用的:pid_t waitpid(pid_t pid, int* status, int options);
实现非阻塞等待,我们只需要向waitpid函数的第三个参数传入WNOHANG,这样的话,等待的子进程如果没有结束,waitpid函数将直接返回0,不予以等待。如果等待的子进程正常退出,则返回该子进程的pid。
代码实例:
1 #include<stdio.h>
2 #include<sys/types.h>
3 #include<sys/wait.h>
4 #include<unistd.h>
5 #include<stdlib.h>
6 int main()
7 {
8 pid_t id = fork();
9 if (id == 0)
10 {
11 //child
12 int cnt = 10;
13 while (cnt--)
14 {
15 printf("I am child...PID:%d, PPID:%d,cnt:%d\n", getpid(), getppid(),cnt);
16 sleep(1);
17 }
18 exit(10);//自己设置了一个退出码
19 }
20 //parent
21 //sleep(20);//父进程先不回收子进程,先让我们观察一下子进程的僵尸状态,再回收
22 //pid_t ret = wait(NULL);//等待成功返回子进程PID
23 int status=0;
24 while(1)
25 {
26 pid_t ret=waitpid(id,&status,WNOHANG);
27 if(ret==0)
28 {
29 //表示waitpid调用成功,但是子进程还没退出,父进程没有一直在等待,处于非阻塞状态
30 //子进程没有退出,waitpid没有等待失败,仅仅是检测到了子进程没有退出,可以多给李四打几次电话,检测他的状态准备好了没有
31 printf("wait done, but child is still running......\n");//这里父进程可以做其它事
32 }
33 else if(ret>0)
34 {
35 //waitpid调用成功&&子进程退出了
36 printf("wait success, exit code:%d, sig: %d\n",(status>>8)&0xFF,status&0x7F);
37 break;
38 }
39 else
40 {
41 //waitpid调用失败
42 printf("waitpid call failed\n");
43 break;
44 }
45 }
46 return 0;
四.进程程序替换
替换原理
1.创建子进程的目的?
执行父进程磁盘代码的一部分;
让子进程加载磁盘上指定的程序到内存中,执行新程序的的代码和数据。
2.程序替换的本质
将指定程序的代码和数据加载到指定的位置
3.进程替换的时候,有没有创建新的进程?
我们知道一个进程被创建出来,OS会给它分配进程PCB,mm_struct,页表等信息,同时会将程序的代码和数据加载到物理内存。要知道进程程序替换之后,该进程的PCB,进程地址空间,页表等信息都不会发生改变,仅仅是把一个新的程序的数据和代码替换了原来进程的代码和数据,只是物理内存当中的数据和代码发生了改变,所以并没有创建新的进程,而且进程程序替换前后该进程的pid也没有改变。
4.子进程进行进程程序替换后,会影响父进程的代码和数据吗?
子进程刚被创建时,与父进程共享代码和数据,但当子进程需要进行进程程序替换(调用exec函数)时,也就意味着子进程需要对其数据和代码进行写入操作,这时便需要将父子进程共享的代码和数据进行写时拷贝,此后父子进程的代码和数据分离,因此子进程进行进程程序替换后不会影响父进程的代码和数据。
替换函数
(1)exec函数说明
fork函数是用于创建一个子进程,该进程几乎是父进程的副本,而有时我们希望子进程去执行另外的程序,exec函数族就提供了一个在进程中启动另一个新的程序的执行方法。它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段,代码段和堆栈段,在执行完后,原调用进程的内容除了进程号外,其它全部的代码数据都被新程序替换了(注意PCB,页表,mm_struct等不被改变,只是被替换,不是创建新的进程),另外这里的可执行文件可以是二进制文件,也可以是Linux下任何可执行脚本文件。
(2)在Linux中使用exec函数族主要有以下两种情况:
当进程认为自己不能再为系统和用户做出任何贡献时,就可以调用exec函数族让自己重生;如果一个进程想执行另一个程序,那么它就可以调用fork函数新建一个进程,任何调用exec函数使子进程重生。
(3)替换函数有六种以exec开头的函数,它们统称为exec函数:
头文件:unistd.h
一. int execl(const char *path, const char *arg, ...);
第一个参数是要执行程序的路径(表示你要执行的程序在哪里?),第二个参数是可变参数列表(表示你要如何执行这个程序),你在命令行上怎么执行,就怎么传参,所以exe函数必须以NULL结尾。
使用实例:在linux下ls也是一个程序
运行结果:
问:为什么没有打印最后一句?
因为exec执行完后,代码已经全部被我们调用的新程序覆盖,开始执行新的程序的代码了,所以printf就无法执行了。
示范了一个例子,剩下5个exec函数的说明如下:
int execlp(const char *file, const char *arg, ...);
//带p的,可以使用环境变量PATH,无需写全路径
execlp("ls", "ls", "-a", "-i", "-l", NULL);
//带e的,需要自己组装环境变量
int execle(const char *path, const char *arg, ..., char *const envp[]);
char* myenvp[] = { "MYVAL=2021", NULL };//自己设置的环境变量
execle("./mycmd", "mycmd", NULL, myenvp);
int execv(const char *path, char *const argv[]);
char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execv("/usr/bin/ls", myargv);
int execvp(const char *file, char *const argv[]);
char* myargv[] = { "ls", "-a", "-i", "-l", NULL };
execvp("ls", myargv);
int execve(const char *path, char *const argv[], char *const envp[]);
char* myargv[] = { "mycmd", NULL };
char* myenvp[] = { "MYVAL=2021", NULL };
execve("./mycmd", myargv, myenvp);//设置了MYVAL环境变量,在mycmd程序内部就可以使用该环境变量。
函数解释
- exec函数族调用成功则加载新的程序从启动代码开始执行,不再返回。
- 调用出错,返回-1
- 所以exec函数只有出错的返回值而没有成功的返回值。
为什么exec调用成功不做任何返回,只有调用错误的时候才返回-1?
因为如果调用成功,新的程序会将原来的代码和数据覆盖,就和接下来的代码无关了,所以调用成功的返回值,用不上,毫无意义。即如果exec函数只要返回了,就一定出错。
命名理解
这六个exec系列函数的函数名都以exec开头,其后缀的含义如下:
- l(list):表示参数采用列表的形式,一一列出。
- v(vector):表示参数采用数组的形式。
- p(path):表示能自动搜索环境变量PATH,进行程序查找。
- e(env):表示可以传入自己设置的环境变量。
事实上,只有execve才是真正的系统调用,其它五个函数最终都是调用的execve,所以execve在man手册的第2节,而其它五个函数在man手册的第3节,也就是说其他五个函数实际上是对系统调用execve进行了封装,以满足不同用户的不同调用场景的。
五.做一个简易shell(命令行解释器)
原理:shell是命令行解释器,当有命令需要执行时,shell创建子进程,让子进程执行命令,而shell只需等待子进程退出即可。
实现思路:
获取命令行(fgets函数)。
解析命令行(strtok分割字符串)。
创建子进程(fork函数)。
替换子进程(exec函数族)。
等待子进程退出(waitpid函数)。
1 #include<stdio.h>
2 #include<unistd.h>
3 #include<assert.h>
4 #include<stdlib.h>
5 #include<string.h>
6 #include<sys/types.h>
7 #include<sys/wait.h>
8
9 #define MAX 1024//字符串的最大长度
10 #define ARGC 64//我们所设置的参数的最大个数
11 #define SEP " "//我们需要以空格为分隔符进行切割
12
13 //一般用户自定义的环境变量,在bash中要用户自己来进行维护,不要用一个经常被覆盖的缓冲区来保存环境变量
14
15
16 //切割函数
17 int split(char* commandstr,char* argv[])
18 {
19 assert(commandstr);
20 assert(argv);
21
22 //strtok的使用方式
23 argv[0] = strtok(commandstr," ");
24 if(argv[0]==NULL) return -1;
25 int i=1;
26 while((argv[i++] = strtok(NULL," ")));
27 //while(1)
28 //{
29 // argv[i]=strtok(NULL,SEP);
30 // if(argv[i]==NULL) break;//参数为NULL,表示切割完毕,退出循环
31 // i++;
32 //}
33 return 0;
34 }
35 //打印命令行的每个参数
36 void debugPrint(char* argv[])
37 {
38 int i=0;
39 for(i = 0;argv[i];i++)
40 {
41 printf("%d:%s\n",i,argv[i]);
42 }
43 }
44 //显示所有环境变量
45 void showEnv()
46 {
47 extern char ** environ;
48 int i=0;
49 for(i=0;environ[i];i++)
50 {
51 printf("%d:%s\n",i,environ[i]);
52 }
53 }
54 int main()
55 {
56 //用户自定义的环境变量自己维护
57 char myenv[32][256];
58 int env_index=0;
59 int last_exit_code=0;
60 while(1)
61 {
62 char commandstr[MAX] = {0};//用于存储我们在命令行输入的字符串,获取了字符串后才能进行解析
63 char* argv[ARGC] = {NULL};//存储所我们所切割的字符串参数
64 printf("[zxn@mymachine curpath]# ");
65 fflush(stdout);
66 //获取字符串到commandstr
67 char* s = fgets(commandstr,sizeof(commandstr),stdin);//会提取我们输入字符串结束时按下的换行
68 assert(s);
69 (void)s;//由于在某些环境中,如果我们定义了变量但是我们没有使用
70 //编译器是会报错的,所以这句代码是保证在release方式发布的时候,若去掉assert,就导致s没有被使用,而带来的编译告警,什么都没做,但充当了一次使用
71 commandstr[strlen(commandstr)-1] = '\0';//消除结尾的换行
72
73 //切割字符串
74 int n = split(commandstr,argv);
75 if(n!=0) continue;
76
77 //debugPrint(argv);
78 //version2:增加几个细节
79 //1.带颜色
80 if(strcmp(argv[0],"ls")==0)
81 {
82 int pos=0;
83 while(argv[pos]) pos++;
84 argv[pos++]=(char*)"--color=auto";
85 argv[pos]=NULL;//比较安全的做法
86 }
87 //cd ../cd /:让bash自己执行命令,我们称之为内建命令/内置命令
88 if(strcmp(argv[0],"cd")==0)
89 {
90 //我们在版本1上执行cd命令时,会发现cd到的路径都是父进程所在的文件路径
91 //这个路径不会发生变化,那是因为我们是使用子进程去执行这个命令的
92 //所以我们要让bash自己执行命令
93 if(argv[1]!=NULL) chdir(argv[1]);
94 continue;//chdir用于将当前工作目录更改为指定目录
95 }
96 //设置导入用户自定义环境变量
97 //不能之间调用putenv(argv[1]),因为会存在缓冲区的覆盖问题
98 //记住不要将环境变量放在一个会变化的数组中即可
99 //其实我们之前学习到的几乎所有的环境变量,都是内建命令
100 if(strcmp(argv[0],"export")==0)
101 {
102 if(argv[1]!=NULL)
103 {
104 strcpy(myenv[env_index],argv[1]);//将命令行参数的字符串全部拷贝到myenv中
105 putenv(myenv[env_index++]);
106 }
107 continue;
108
109 }
110 if(strcmp(argv[0],"env")==0)
111 {
112 showEnv();
113 continue;
114 }
115 if(strcmp(argv[0],"echo")==0)
116 {
117 //echo $PATH
118 const char* target_env=NULL;
119 if(argv[1][0]=='$')
120 {
121 if(argv[1][1] == '?')
122 {
123 printf("%d\n",last_exit_code);
124 continue;
125 }
126 else
127 target_env = getenv(argv[1]+1);//获取$之后的环境变量
128 }
129 if(target_env!=NULL)
130 printf("%s = %s\n",argv[1]+1,target_env);
131
132 continue;
133 }
134 //version1
135 pid_t id = fork();
136 assert(id>=0);
137 (void)id;
138
139 if(id==0)
140 {
141 //child
142 execvp(argv[0],argv);
143 exit(1);
144 }
145 int status=0;
146 pid_t ret = waitpid(id,&status,0);
147 //等待成功
148 if(ret>0)
149 {
150 last_exit_code = WEXITSTATUS(status);
151 }
152
153 //printf("%s\n",commandstr);
154 }
155 }