目录
一、进程创建
1.1 再谈 fork 函数
linux中 fork 函数时非常重要的函数,它从已存在进程中创建一个新进程,新进程为子进程,而原进程为父进程,fork 函数在进程概念的篇章已经介绍过了,这里再谈 fork 函数,再次理解 fork函数
man fork 查看 fork函数详细介绍
fork 的返回值有两个
- 创建子进程失败返回 -1
- 创建成功:a.给父进程返回子进程的PID b.给子进程返回 0
进程调用 fork函数,当控制转移到内核中的 fork代码后,内核做
- 分配新的内存块和内核数据结构给子进程
- 将父进程部分数据结构内容拷贝至子进程(第一点和第二点在进程地址空间已经详细解释)
- 添加子进程到系统进程列表当中
- fork返回,开始调度器调度
fork 之后,父子进程代码共享
测试代码:
intmain() { printf("before fork pid: %d\n", getpid()); pid_tid=fork(); if(id==-1) { printf("fork error\n"); } printf("after fork pid: %d, return val: %d\n", getpid(), id); sleep(1); return0; }
运行结果
这里可以看到,before fork pid 只输出了一次,而 after fork pid 输出了两次。其中,before fork pid 是由父进程打印的,而调用fork函数之后打印的两个 after fork pid,分别由父进程和子进程两个进程执行。也就是说,fork之前父进程独立执行,而 fork之后父子两个执行流分别执行,也就是父子进程代码共享
虽然子进程是从 fork 之后执行的,但全部代码都是父子进程共享的
注意:fork之后,父进程和子进程谁先执行完全由调度器决定
小提示:在编写 makefile 的时候,目标文件的依赖方法中,可以用 “$@” 表示要形成的目标文件,即依赖关系中 “:” 左边的内容;用 “$^” 表示目标文件的依赖文件,即依赖关系中 “:” 右边的内容
1.2 fork 函数返回值问题
fork函数为什么要给子进程返回0,给父进程返回子进程的PID?
一个子进程永远只有一个父进程,但父进程可以拥有多个子进程。比如,一个孩子只有一个父亲,而父亲可以有多个孩子。
进程多了就要有进程的标识符,没有事不行的。就好比一个父亲他有三个孩子,父亲想叫其中的一个孩子,得叫孩子的名字吧,不叫孩子怎么知道叫哪一个孩子,总不能说:孩子,你过来一下。这样叫哪知道是哪一个,同比进程也是如此,得有一个认得出你的标识符。给子进程返回 0,给父进程返回子进程的 PID就是类似情况
为什么fork函数有两个返回值?
因为存在两个进程(父进程和子进程),那么 fork 自然也就会被返回两次,每一个进程都要 return,所以 fork 函数有两个返回值。(这里在地址空间也有介绍,这里简单说一下)
1.2 写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本
写时拷贝在进程地址空间也有详细介绍
当我们不修改数据时,父子进程的虚拟内存所对应的物理内存都是同一块物理地址(内存),当子进程的数据被修改,那么就会将子进程修改所对应数据的物理内存出进行写时拷贝,在物理内存中拷贝一份放在物理内存的另一块空间,将子进程虚拟内存与这个新的地址通过页表进行关联
为什么数据要进行写时拷贝?
进程具有独立性,多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程
1.3 fork 常规用法
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
- 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数
1.4 fork调用失败的原因
- 系统中有太多的进程
- 实际用户的进程数超过了限制
二、进程终止
2.1 进程退出码
进程有创建,进程也有结束的时候,进程结束我们称为进程终止。在C/C++中,在 main 函数最后基本都会写上 return 0,对于这个返回值 0 我们称它为进程退出码
进程退出码有很多,每个进程退出码都有着自己的意义,进程退出码代表了进程为什么会退出,比如进程退出码 0 代表的意义就是进程正常退出,也就是代码正常执行完成
测试代码
intmain() { printf("hello world\n"); return0; }
程序运行完了,怎么查看进程退出码?
当进程执行之完成可以通过一个命令查看具体的进程退出码,? 就是环境变量中的一个名字,@?就是获取相应的环境变量
echo $?
我们可以修改进程的退出码,进程退出码的意义也可以自己定义,不使用操作系统的那一套进程退出码
intmain() { printf("hello world\n"); return1;//我们假设进程退出码 1 ,是进程正常退出 //vareturn 0; }
echo $? 查看进程退出码
echo $? 命令只会记录最近一次的进程退出码(即 main函数的 return 返回值),而下一个为 0的原因就是echo本身也是一个进程,并且正确执行退出,因此显示的是0
如何设定 main函数的返回值?
如果不关心进程退出码,return 0 就行,如果要关心进程退出码,要返回特定的数据表明进程退出的情况和特定的错误(进程是正常退出还是非正常退出)
进程退出码一般使用0表示成功,!0表示错误,!0具体是多少,就标定特定的错误
进程退出码都是数字,对计算机友好,但是对人不友好,所以退出码都要有对应的退出码的文字描述
strerror 这个函数就是把进程的退出码转换成文字描述
测试代码
intmain() { inti=0; for(i; i<200; ++i) { printf("%d: %s\n", i, strerror(i)); } return0; }
运行结果
如图,只有0代表着success,其他的都对应不同的错误,并且有133个不同的错误,一共有134个进程退出码,就代表有134种不同的进程运行结果
2.2 进程退出场景
进程退出的场景分三类:
- 代码运行完毕,结果正确(进程退出码为 0)
- 代码运行完毕,结果不正确(进程退出码 !0)
- 代码没有跑完,异常终止(退出码无意义)
进程如何退出呢?接下来就来解释一下(前两种情况)
2.3 进程如何退出
(1)main 函数的 return 退出,这是最常用的一种方式
(2)通过 exit 函数退出
man exit 查看一下,exit 是C语言的一个库函数,参数 status 就是当前进程的退出码
测试代码
intmain() { printf("hello\n"); exit(11); printf("world\n"); return0; }
运行结果,到exit语句就会将进程结束,后面的代码也就不会再去执行了
查看退出码
(3)通过 _exit 系统调用退出(了解)
man _exit 查看
测试代码
intmain() { printf("hello\n"); _exit(15); //exit(11); printf("world\n"); return0; }
运行结果
结果发现 _exit() 其也是和 exit() 一样的功能。事实上,_exit 是系统调用的函数,也就是操作系统(OS)提供的,而exit()是库函数,库函数是 OS 之上的函数,exit 底层实际上就是调用 _exit,但二者之间也会有区别
二者的区别在刷新缓冲区上,将换行符去掉进行测试
测试代码
intmain() { printf("hello world"); sleep(2); exit(1); return0; }
运行结果
进程结束后,会刷新缓冲区,打印的结果暂停2秒也会显示出来,下面看 _exit()
测试代码
intmain() { printf("hello world"); sleep(2); _exit(1); //exit(1); return0; }
运行结果
_exit 没有打印出结果,也就是说 _exit 并没有刷新缓冲区
因此
- exit终止进程,主动刷新缓冲区
- _exit终止进程,不会刷新缓冲区
_exit() 是系统调用,而库函数 exit() 在系统调用之上, _exit() 不会刷新缓冲区,exit() 会刷新缓冲区,这也直接说明了缓冲区肯定在系统调用之上,也就是用户级缓冲区,缓冲区后序会详细解释
前面的三点都是进程的正常退出,最后一点是异常退出
(4)异常退出:通过 ctrl + c 终止进程,信号终止,如 kill -9
三、进程等待
3.1 进程等待必要性
进程等待的必要性:
- 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
- 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
- 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
总的来说,进程等待的意义就是:回收子进程资源,获取子进程退出信息,即通过进程等待的方式解决僵尸进程的问题
3.2 进程等待的方法
3.2.1 通过 wait 方法回收子进程
man 2 wait 查看 wait,wait 是一个系统调用,输出型参数,获取子进程退出状态,不关心则可以设置成为NULL,下面先使用第一个接口
返回值,等待成功返回子进程的PID,失败返回 -1
测试代码,让子进程处于 Z状态5秒,父进程 10秒后醒来回收子进程
intmain() { pid_tid=fork(); if(id==0)//子进程 { intcnt=5; while(cnt) { printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt); --cnt; sleep(1); } exit(0);//退出子进程 } //父进程 sleep(10);//由于子进程没有被父进程回收会处于 5秒的 Z状态 pid_tret=wait(NULL);//ret 用于接收 wait的返回值 if(id>0) { printf("wait success: %d\n", ret); } sleep(5);//不让父进程那么快退出,用于查看进程处于的状态 return0; }
监控脚本
while :; dopsaxj|head-1&&psaxj|grepmytest|grep-vgrep; sleep1; done
运行结果
右侧执行脚本,左侧同时运行 mytest,发现当子进程正在执行时,子进程和父进程都处于 S 状态,当子进程执行完毕,没有被父进程回收时的那 5秒,子进程就变成了 Z 状态,当父进程执行时,通过调用 wait 将子进程回收,子进程就结束了,最后的5秒只剩下父进程处于S+状态,这就是父进程通过进程等待回收了僵尸进程(子进程)
3.2.2 通过 waitpid 获取子进程退出信息
man 2 waitpid 查看 waitpid,waitpid 是一个系统调用,下面使用第二个接口进行测试
pid_twaitpid(pid_tpid, int*status, intoptions); 返回值:当正常返回的时候waitpid返回收集到的子进程的进程ID;如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;参数:pid:Pid=-1,等待任一个子进程。与wait等效。Pid>0.等待其进程ID与pid相等的子进程。status: WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)options: WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID
测试代码
intmain() { pid_tid=fork(); if(id==0)//子进程 { intcnt=5; while(cnt) { printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt); --cnt; sleep(1); } exit(10);//退出子进程 } //父进程sleep(10);//由于子进程没有被父进程回收会处于 5秒的 Z状态intstatus=0; pid_tret=waitpid(id, &status, 0); if(id>0) { printf("wait success: %d, status: %d\n", ret, status); } sleep(5);//不让父进程那么快退出,用于查看进程处于的状态 return0; }
运行结果
但是我们发现,status 不是我们想要的信息,所以 status 并不是整体使用的,status 有自己的位图结果,下面解释输出型参数 status 的使用
3.3 获取子进程 status
status 解释:
- wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充
- 如果传递NULL,表示不关心子进程的退出状态信息
- 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程
- status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位)
对于 32个 bit 位在这里只有16个 bit位是有意义的,进程正常终止 0~7 位返回 0代表正常的终止信号(返回0证明没有出问题),进程正常终止 8~15 位代表子进程对应的退出码
进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump标志,后面的比特位不再使用,即没有意义
怎么获取这些有用的信息?答案是通过位操作符
exitCode= (status>>8) &0xFF; //退出码exitSignal=status&0x7F; //退出信号
把上面的代码进行修改
再运行程序,就可以获取子进程的信息了
(status >> 8) & 0xFF 和 status & 0x7F 太难记了,所以系统当中提供了两个宏来获取退出码和退出信号
exitNormal=WIFEXITED(status); //是否正常退出exitCode=WEXITSTATUS(status); //获取退出码WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
修改代码
intmain() { pid_tid=fork(); if(id==0)//子进程 { intcnt=5; while(cnt) { printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt); --cnt; sleep(1); } exit(10);//退出子进程 } //父进程 sleep(10);//由于子进程没有被父进程回收会处于 5秒的 Z状态 intstatus=0; pid_tret=waitpid(id, &status, 0); //判断子进程是否正常退出,正常退出为真 if(WIFEXITED(status)) { //获取子进程退出码 printf("wait success: %d, exit child code: %d\n", ret, WEXITSTATUS(status)); // printf("wait success: %d, exit sign: %d, exit child code: %d\n", ret, (status&0x7F), ((status >> 8)&0xFF)); } else { printf("wait failed\n"); } sleep(5);//不让父进程那么快退出,用于查看进程处于的状态 return0; }
运行结果
3.4 再谈进程退出
子进程退出会变成僵尸进程,会把自己的退出结果写入到自己的 PCB 结构体中,在 Linux 下是 task_struct,子进程退出后 task_struct 不会立马释放,task_struct 会等待父进程来取走子进程退出信息
wait/waitpid 是一个系统调用,即以OS的身份进行,因此OS也有资格有能力去读取子进程的 task_struct,因此 wait/waitpid 是从子进程的 task_struct 来获取子进程的退出信息的
3.5 进程的阻塞和非阻塞等待
上面的测试代码就是阻塞等待,所谓的阻塞等待就是:当子进程未退出时,父进程都在一直等待子进程退出,在等待期间,父进程不能做任何事情,这种等待叫做阻塞等待,也叫轮询阻塞等待
父进程不做任何事,一直等待子进程的退出,在此期间父进程会一直询问:子进程,你好了没?这种询问会一直询问到子进程忙完,也就是子进程退出,父进程的一直询问这种方式称为轮询检测
而父进程不是一直等到子进程退出,而是间隔一定时间去询问子进程,父进程在子进程未退出时可以做一些自己的事情,当子进程退出时再读取子进程的退出信息,这种等待方式叫做非阻塞等待,也叫非轮询阻塞等待
下面进行非阻塞等待代码测试
intmain() { pid_tid=fork(); if(id==0)//子进程 { intcnt=5; while(cnt) { printf("我是子进程, pid:%d, 父进程ppid:%d, cnt: %d\n", getpid(), getppid(), cnt); --cnt; sleep(1); } exit(10);//退出子进程 } //父进程 intstatus=0; while(1) { pid_tret=waitpid(id, &status, WNOHANG);//WHOHANG: 非阻塞-> 子进程没有退出,父进程检测的时候,立即返回 if(ret==0) { //waitpid 调用成功 && 子进程没有退出 //子进程没有退出, waitpid 没有等待失败,仅仅是检测到了子进程没有退出 ////执行父进程的代码printf("wait done, but child is running...\n"); sleep(1); } elseif(ret>0) { // waitpid 等待成功 && 子进程退出了printf("wait success: %d, exit sign: %d, exit child code: %d\n", ret, (status&0x7F), ((status>>8)&0xFF)); break; } else { // waitpid 失败printf("wait failed\n"); } } return0; }
运行结果
非阻塞等待有什么好处?
非阻塞等待不会占用父进程的所有精力,可以在轮询期间,执行别的代码
四、进程程序替换
4.1 创建子进程的目的
创建子进程的目的:
- 想让子进程执行父进程代码的一部分(执行父进程对应磁盘代码中的一部分)
- 想让子进程执行一个全新的程序(让子进程想办法加载磁盘是指定的程序,执行新程序的代码和数据,这就是进程的程序替换)
4.2 替换函数
替换函数有六种以exec开头的函数,它们统称为exec函数,这六种都是库函数,这些函数的作用是:将指定的程序加载到内存中,让指定的进程执行
man 3 execl 查看
intexecl(constchar*path, constchar*arg, ...); intexeclp(constchar*file, constchar*arg, ...); intexecle(constchar*path, constchar*arg, ...,char*constenvp[]); intexecv(constchar*path, char*constargv[]); intexecvp(constchar*file, char*constargv[]);
(1)int execl(const char *path, const char *arg, ...)
第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾,... 是可变参数列表
(2) int execlp(const char *file, const char *arg, ...)
第一个参数是要执行程序的名字,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾
(3)int execle(const char *path, const char *arg, ...,char *const envp[])
第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾,第三个参数是你自己设置的环境变量
(4)int execv(const char *path, char *const argv[])
第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
(5)int execvp(const char *file, char *const argv[])
第一个参数是要执行程序的名字,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
第六个就不介绍了,都一样,下面这个是系统调用,上面 6 个库函数底层都是调用 execve 这个函数
int execve(const char *path, char *const argv[], char *const envp[]);
int execve(const char *path, char *const argv[], char *const envp[])
第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾,第三个参数是你自己设置的环境变量
4.3 替换函数解释
解释:
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
- 如果调用出错则返回-1
- 所以 exec 系列函数只有出错的返回值而没有成功的返回值
4.4 替换函数命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记
- l(list) : 表示参数采用列表
- v(vector) : 参数用数组
- p(path) : 有p自动搜索环境变量PATH
- e(env) : 表示自己维护环境变量
4.5 替换函数测试
4.5.1 execl
int execl(const char *path, const char *arg, ...)
l(list) : 表示参数采用列表
第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾,... 是可变参数列表。这些函数作用是将指定的程序加载到内存中,让指定的进程执行
如何找到程序?
这是由第一个参数决定的,通过环境变量找到指定的程序
如何执行?
这个是由第二个参数决定的,通过相应的命令执行程序
下面假设替换 ls 这个程序,execl 这个函数第一个参数要带路径
测试代码
intmain() { printf("process is running...\n"); execl("/usr/bin/ls", "ls", NULL);//第一个参数是要执行哪个程序,第二个参数是你想怎么执行,以 NULL 结尾 printf("process is running...\n"); return0; }
运行结果
我们发现,程序确实被替换了,执行了 ls 这个程序,而且最后一句打印没有打印出来,对比 ls 命令执行的结果,二者无差异,只不过没有把颜色带上,加上颜色的参数就可以了
exec 系列的函数为什么没有成功返回值呢?
因为替换成功了,就和接下来的代码无关了,判断毫无意义,exec 系列函数只要返回了,一定是程序替换失败了
程序执行完成后,最后一句为什么没有被打印?下面解释原理
4.5.2 程序替换的原理
以上面代码为例,代码执行时,进程地址空间与物理内存与页表就会形成映射关系,当执行原有的代码时,执行第一个printf会照常打印,到了execl 函数时,就会发生进程的程序替换,也就是说,我们所编写的代码会被 execl 函数所调用对应磁盘内部的代码和数据覆盖,即将指定程序的代码和数据覆盖原有的代码和数据,然后执行这个新的代码和数据,所以 execl 后面的printf没有打印
当进行进程程序替换时,有没有创建新的进程?
进程程序替换之后,该进程对应的PCB、进程地址空间以及页表等数据结构都没有发生改变,只是进程在物理内存当中的数据和代码发生了改变,所以并没有创建新的进程,而且进程程序替换前后该进程的 pid 并没有改变
程序替换一般都是用 fork 生成子进程,让子进程进行程序替换,上面的单进程例子是为了方便演示
下面使用子进程进行程序替换(双进程(父子进程)),函数依旧是 execl
intmain() { printf("process is running...\n"); pid_tid=fork(); assert(id!=-1); //子进程 if(id==0) { //类比:命令行怎么写,这里就怎么写 sleep(1); execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);// --color=auto 是颜色高亮 exit(1);//这个代码执行了,就说明 excel 函数返回了,返回就意味程序替换失败了 } //父进程 intstatus=0; pid_tret=waitpid(id, &status, 0); if(ret>0) { printf("wait success: %d, exit signal: %d, exit child code: %d\n", ret, status&0x7F, (status>>8)&0xFF); } else { printf("wait failed\n"); } return0; }
运行结果
还是一致的,用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种 exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变
进行程序替换时会发生写时拷贝,保证进程的独立性,不让子进程影响父进程
这就是程序替换的原理
4.5.3 execlp
int execlp(const char *file, const char *arg, ...)
- l(list) : 表示参数采用列表
- p(path) : 有p自动搜索环境变量PATH
第一个参数是要执行程序的名字,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾
测试代码
intmain() { printf("process is running...\n"); pid_tid=fork(); assert(id!=-1); //子进程 if(id==0) { //类比:命令行怎么写,这里就怎么写 sleep(1); execlp("ls", "ls", "-a", "-l", "--color=auto", NULL);// --color=auto 是颜色高亮 //execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);// --color=auto 是颜色高亮 exit(1);//这个代码执行了,就说明 excel 函数返回了,返回就意味程序替换失败了 } //父进程 intstatus=0; pid_tret=waitpid(id, &status, 0); if(ret>0) { printf("wait success: %d, exit signal: %d, exit child code: %d\n", ret, status&0x7F, (status>>8)&0xFF); } else { printf("wait failed\n"); } return0; }
运行结果
4.5.4 execlp
int execv(const char *path, char *const argv[])
- v(vector) : 参数用数组
第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾
改一下代码就可以了
4.5.4 替换自己写的可执行程序
上面的几个调用方式,事实上我们所调用的都是系统程序,接下来就通过 exec 类的函数调用自己写的程序
随便创建一个源文件:test.c
intmain() { printf("我是另一个C程序!!\n"); printf("我是另一个C程序!!\n"); printf("我是另一个C程序!!\n"); printf("我是另一个C程序!!\n"); printf("我是另一个C程序!!\n"); printf("我是另一个C程序!!\n"); return0; }
makefile中也需要改成能够同时生成 myexec 和 mytest 的指令,对于makefile文件,只会生成第一个程序,因此需要修改 makefile 让它们可以同时生成
.PHONY:allall: myexecmytestmyexec:exec.cgcc-o$@$^mytest:test.cgcc-o$@$^.PHONY:cleanclean: rm-fmyexecmytest
结果如下
因为自己写的程序不在环境变量里面,所以不能使用 p(path) : 有p自动搜索环境变量PATH。直接使用相对路径即可
intmain() { printf("process is running...\n"); pid_tid=fork(); assert(id!=-1); //子进程 if(id==0) { //类比:命令行怎么写,这里就怎么写 sleep(1); execl("./mytest", "mytest", NULL); exit(1);//这个代码执行了,就说明 excel 函数返回了,返回就意味程序替换失败了 } //父进程 intstatus=0; pid_tret=waitpid(id, &status, 0); if(ret>0) { printf("wait success: %d, exit signal: %d, exit child code: %d\n", ret, status&0x7F, (status>>8)&0xFF); } else { printf("wait failed\n"); } return0; }
运行结果
对于这种调用方式,是没有语言之间的隔阂的,即我们可以通过C语言调用C++、Java、Python等等其他类型的语言,当然也可以反过来调。
也就是说程序替换,可以使用程序进行替换,也可以调用任何后端语言对应的可执行程序
4.5.5 execle
int execle(const char *path, const char *arg, ...,char *const envp[])
- l(list) : 表示参数采用列表
- e(env) : 表示自己维护环境变量
第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾,第三个参数是你自己设置的环境变量
直接使用 4.5.4 上面的代码,修改一下
test.c
exec.c
运行结果
结果发现,系统内部的环境变量使用不了,我们自定义的就可以使用。这是因为我们的 execle 函数的最后一个参数的原因,最后的一个参数就是传入的环境变量,没有传入就不会使用,因此如果我们在 exec.c 中将最后一个位置的参数改成 environ(前面添加extern char** environ)的话,就会反过来:我们自定义的环境变量就不会生效,只有系统的才会生效。
但是我们想让两者同时生效,就要使用进程概念前面提到的函数:putenv
man putenv 查看,putenv 是一个库函数,作用是把你自定义的环境变量导入环境变量中,让自定义的环境和系统的环境变量让两者同时生效
再修改一下 exec.c 的代码
再次运行程序
这样就可以让自定义的环境和系统的环境变量让两者同时生效
其他 exec 系列的函数不再演示,道理都一样。只有 execve 是真正的系统调用,其它六个函数最终都调用 execve,所以 execve在man手册 第2节,其它函数在man手册第3节
4.5.6 exec 系列函数与 main 函数的相关问题
对于execle函数和main函数,在进程调用的时候是谁先被调用?
exec先被调用。exec系列的函数的功能是将我们的程序加载到内存中!
我们知道一个程序要想运行必须加载到内存中让CPU去执行,那程序是如何加载的?而对于LinuxOS来说,程序加载是通过 exec系列的函数加载到内存中的,因此Linux中的exec系列函数也被称为加载器
程序是先加载呢?还是先执行main呢?
毫无疑问,一定是先加载,所以,也就解释通了对于 exec系列的函数和 main函数,一定是 exec 系列的函数先被调用
main 也作为函数,也需要被传参,exec 系列的函数和 main函数的参数有什么关联呢?
main 函数本身自带三个参数,不过平时我们都不传参数
int main(int argc, char* argv[], char* env[]);
以 execle 为例,main 函数的参数都是 exec 系列的函数传给 main函数的,他们的参数就是这种一一对应的映射关系!即 main函数被 exec调用
那对于 exec 系列中不带有 envp[] 参数的那些函数,照样能够拿到默认的环境变量,其实是 environ 通过地址空间的方式让子进程拿到的
下图exec函数族 一个完整的例子
程序替换中只有一个 execve 系统调用,其他都是封装,目的是为了让我们有更多的选择
进程替换到此结束
五、进程控制应用场景:模拟 shell命令行解释器
5.1 模拟 shell版本1
shell 也就是命令行解释器,其运行原理就是:当有命令需要执行时,shell 创建子进程,让子进程执行命令,而shell只需等待子进程退出即可
其实 shell需要执行的逻辑非常简单,其只需循环执行以下步骤:
- 获取命令行
- 解析命令行
- 创建子进程(fork)
- 替换子进程(execvp)
- 等待子进程退出(wait)
版本1
//一个命令最大长度 //一个命令最多选项 charlineCommend[NUM]; char*myargv[OPT_NUM]; intmain() { while(1) { //打印输出提示符 printf("用户名@主机名 当前路径# "); //刷新缓冲区 fflush(stdout); //获取用户输入,自己输入的时候,按回车缓冲区里面会多一个 \n char*s=fgets(lineCommend, sizeof(lineCommend)-1, stdin); if(s==NULL) { perror("fgets"); exit(-1); } //去掉自己输入的回车 \n lineCommend[strlen(lineCommend)-1] =0; //对输入的命令做字符串切割//ps: 输入"ls -a -l -i" -> 切割成 "ls" "-a" "-l" "-i"myargv[0] =strtok(lineCommend, " "); inti=1; while(myargv[i++] =strtok(NULL, " ")); //创建子进程执行命令pid_tid=fork(); if(id==-1) { perror("fork"); exit(-1); } elseif(id==0) { //子进程execvp(myargv[0], myargv); exit(1); } //父进程waitpid(id, NULL, 0); } return0; }
运行结果,一个简易的 shell 就完成了
但是这个简易的 shell命令行解释器还有一个问题:就是返回上一级路径时,路径没有发生变化
下面就来解决这个问题
5.2 当前路径
什么是当前路径?
测试代码
执行这个程序并新建窗口进行观察
ls /proc/进程pid
以列表显示
ls /proc/进程pid -al
其中,exe 是指当前可执行程序在磁盘中的路径 ,而 cwd (current working directory) 则是指 当前进程的工作目录,它就是我们平时所说的 当前路径
在 Linux 中,我们可以使用 chdir 系统调用来改变进程的工作目录vvvvvvvvvvvvvvvv
也就是说,当前工作目录可以被改变,chdir 的参数是写入你要修改当前工作目录的的路径
回到上面,为什么我们自己写的shell,cd 的时候路径没有变化呢?
myshell 是通过创建子进程的方式去执行命令行中的各种指令的,也就是说,cd 命令是由子进程去执行的,那么自然被改变也是子进程的工作目录,父进程的工作目录不受影响
而当我们使用 pwd 指令来查看当前路径时,cd 指令对应的子进程已经执行完毕退出了,此时 myshell 又会给 pwd 创建一个新的子进程,且这个子进程的工作目录和父进程 myshell 相同,所以 PWD 打印出来的路径不变
知道原因后,我们只需要对命令行传入的指令进行判断,如果是 cd 指令,就使用 chdir 将父进程的工作目录修改为指定的目录即可
修改代码
//一个命令最大长度//一个命令最多选项charlineCommend[NUM]; char*myargv[OPT_NUM]; intmain() { while(1) { //打印输出提示符printf("用户名@主机名 当前路径# "); //刷新缓冲区fflush(stdout); //获取用户输入,自己输入的时候,按回车缓冲区里面会多一个 \nchar*s=fgets(lineCommend, sizeof(lineCommend)-1, stdin); if(s==NULL) { perror("fgets"); exit(-1); } //去掉自己输入的回车 \nlineCommend[strlen(lineCommend)-1] =0; //对输入的命令做字符串切割//ps: 输入"ls -a -l -i" -> 切割成 "ls" "-a" "-l" "-i" myargv[0] =strtok(lineCommend, " "); inti=1; while(myargv[i++] =strtok(NULL, " ")); //如果是 cd 命令,不需要创建子进程,让 shell 自己执行对应的命令,本质就是执行系统接口if(myargv[0] !=NULL&&strcmp(myargv[0], "cd") ==0) { if(myargv[1] !=NULL) chdir(myargv[1]);//改变父进程的工作目录continue;//直接跳过此次循环,不再创建子进程 } //创建子进程执行命令pid_tid=fork(); if(id==-1) { perror("fork"); exit(-1); } elseif(id==0) { //子进程execvp(myargv[0], myargv); exit(1); } elseif(id==0) { //子进程execvp(myargv[0], myargv); exit(1); } //父进程waitpid(id, NULL, 0); } return0; }
运行结果,可以使用 cd 命令改变路径了
5.3 内建/内置命令
Linux 中的命令一共分为两种 – 内建(内置)命令和外部命令
内建命令是 shell 程序的一部分,其功能实现在 bash 源代码中,不需要派生子进程来执行,也不需要借助外部程序文件来运行,而是由 shell 进程本身内部的逻辑来完成
外部命令则是通过创建子进程,然后进行进程程序替换,运行外部程序文件等方式来完成
上面的 cd 命令就是一个内建命令,echo 也是一个内建命令,我们上面写的 shell 执行这个命令也有问题,也需要像 cd 命令一样去处理
----------------我是分割线---------------
文章到这里就结束了,进程控制这个篇章也完结了,下篇进入基础IO