内容介绍:
学习进程替换原理及使用,通过c可执行程序调用cpp、python、shell、java等其他语言可执行程序,模拟实现shell等!深入理解Linux操作系统进程替换机制,熟练掌握进程控制等。
进程内容回顾:
1.进程的写时拷贝回顾:
当我们创建一个子进程,子进程执行的是父进程的代码片段。父、子进程代码共享,if、else语句同时执行,通过同一个变量经过虚拟地址转化成物理地址,让父、子进程得到不同的值,从而判定让父、子进程执行不同的代码片段。
2.如何想让创建出来的子进程执行全新的程序呢?
在之前的学习中,我们知道是通过写时拷贝让子进程和父进程在数据上进行解耦,互相保证独立性,代码虽然不会写入,只进行读取。如果让父、子进程彻底分开就需要用到进程程序替换。
3.那么为什么要让子进程执行全新程序?
这是因为我们一般在服务器设计(例如Linux编程)的时候往往需要子进程完成两类事情:
(1)让子进程执行父进程的代码片段(服务器代码)
(2)让子进程执行磁盘中一个全新的程序(例如shell、让客户端执行对应的程序、通过我们自己的程序执行其他人写的不同语言的代码等)
接下来我们开始Linux进程程序替换机制的学习。
1.进程程序替换
**1.1替换原理 **
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
进程执行过程图示:
进程写时拷贝图示:
程序替换:
这个过程并没有创建新的进程!且将磁盘中的数据加载到内存(一个硬件—>另一个硬件)是由操作系统完成,通过系统调用,而我们用户只是调用系统接口。
**1.2替换函数 **
其实有七种以exec开头的函数,统称exec函数.
#include <unistd.h>` int execl(const char *path, const char *arg, ...); int execlp(const char *file, const char *arg, ...); int execle(const char *path, const char *arg, ...,char *const envp[]); int execv(const char *path, char *const argv[]); int execvp(const char *file, char *const argv[]); int execve(const char *path, char *const argv[], char *const envp[]);
如果想执行一个全新的程序(本质上就是磁盘上的文件),需要做哪几件事?
1.先找到程序在哪里? ---->程序在哪?
2.程序执行(可能会携带选项,也可能会不携带选项) ---->怎么执行?** 程序演示进程替换:**
**1.2.1 int execl(const char path, const char arg, ...);函数演示
1.创建文件演示exec的使用:
1.1进程替换执行 ls 指令:
#include<stdio.h> #include<unistd.h> int main() { printf("我是一个进程,我的pid:%d\n",getpid()); //ls -a -l execl("/usr/bin/ls","ls","-l","-a",NULL); printf("我执行完毕了,我的pid:%d\n",getpid()); return 0; }
1.2 进程替换执行top指令:
#include<stdio.h> #include<unistd.h> int main() { printf("我是一个进程,我的pid:%d\n",getpid()); //ls -a -l //execl("/usr/bin/ls","ls","-l","-a",NULL); //带选项 execl("/usr/bin/top","top",NULL); //不带选项 printf("我执行完毕了,我的pid:%d\n",getpid()); return 0; }
1.3 进程替换执行pwd 指令:
#include<stdio.h> #include<unistd.h> int main() { printf("我是一个进程,我的pid:%d\n",getpid()); //ls -a -l //execl("/usr/bin/ls","ls","-l","-a",NULL); //带选项 //execl("/usr/bin/top","top",NULL); //不带选项 execl("/usr/bin/pwd","pwd",NULL); //不带选项 printf("我执行完毕了,我的pid:%d\n",getpid()); return 0; }
观察以上例子,我们发现只执行了替换前的printf语句,而另一个语句未执行!
execl进程替换分析:(下图中有单词打错了,修正:excel—>execl)
替换成功:
替换失败:
替换演示:
#include<stdio.h> #include<unistd.h> #include<stdlib.h> #include<sys/wait.h> int main() { printf("我是父进程,我的pid:%d\n",getpid()); pid_t id=fork(); //创建子进程 if(id==0) { //子进程 //这里我们想让子进程执行全新的程序,以前是执行父进程代码片段 printf("我是子进程,我的pid: %d,我的ppid: %d\n",getpid(),getppid()); execl("/usr/bin/ls","ls","-l","-a",NULL); //带选项 exit(1); //只要执行了exit,意味着excel系列的替换函数失败了! } //父进程 int status = 0; int ret = waitpid(id,&status,0); if(ret==id) { sleep(2); printf("父进程等待成功!\n"); } //printf("我是一个进程,我的pid:%d\n",getpid()); //ls -a -l //int ret= execl("/usr/bin/lsdf","ls","-l","-a",NULL); //带选项 //execl("/usr/bin/top","top",NULL); //不带选项 //execl("/usr/bin/pwd","pwd",NULL); //不带选项 //printf("我执行完毕了,我的pid:%d,ret: %d\n",getpid(),ret); return 0; }
那么引入进程创建后,子进程执行程序替换,会不会影响父进程呢?
不会! 从以上演示看出:在子进程替换成功后,依旧打印了父进程中的“父进程等待成功!”,因为进程具有独立性!
**那们这是如何做到的? **
在之前的父、子进程中我们提到会共享代码,在数据层面发生写时拷贝。而当程序替换的时候,我们可以理解为父、子进程的代码和数据都发生了写时拷贝完成了彻底的分离!
1.2.2 int execv(const char *path, char *const argv[]);函数演示
execv函数演示:
#include<stdio.h> #include<unistd.h> #include<stdlib.h> #include<sys/wait.h> int main() { printf("我是父进程,我的pid:%d\n",getpid()); pid_t id=fork(); //创建子进程 if(id==0) { //子进程 //这里我们想让子进程执行全新的程序,以前是执行父进程代码片段 printf("我是子进程,我的pid: %d,我的ppid: %d\n",getpid(),getppid()); char *const argv_[] = {(char*)"ls",(char*)"-a",(char*)"-l",(char*)"-i",NULL}; execv("/usr/bin/ls",argv_); //execl("/usr/bin/ls","ls","-l","-a",NULL); //带选项 exit(1); //只要执行了exit,意味着excel系列的替换函数失败了! } //父进程 int status = 0; int ret = waitpid(id,&status,0); if(ret==id) { sleep(2); printf("父进程等待成功!\n"); } //printf("我是一个进程,我的pid:%d\n",getpid()); //ls -a -l //int ret= execl("/usr/bin/lsdf","ls","-l","-a",NULL); //带选项 //execl("/usr/bin/top","top",NULL); //不带选项 //execl("/usr/bin/pwd","pwd",NULL); //不带选项 //printf("我执行完毕了,我的pid:%d,ret: %d\n",getpid(),ret); return 0; }
1.2.3 int execlp(const char *file, const char *arg, ...);函数演示
execlp函数演示:
1.2.4 int execvp(const char *file, char *const argv[]);函数演示
execvp函数演示:
通过以上演示,目前我们演示的执行程序都是系统命令,如果我们要执行自己写的C/C++/python/java等程序呢?
如何能够执行其他语言写的程序?
这里我们先来复习以下makefile的知识:在之前的Makefile中,每次只能生成一个可执行程序,即使写了两个,也是默认生成第一个!那么如何生成两个及两个以上的可执行程序呢?
这里通过举个例子演示:
Makefile批量化生成可执行文件演示:
.PHONY:all all:myexec mytest myexec:myexec.c gcc -o $@ $^ mytest:mytest.cpp g++ -o $@ $^ .PHONY:clean clean: rm -f myexec mytest
使用自己写的程序调用其他语言的程序演示:
C语言调用C++可执行程序:
采用绝对路径
说明程序替换不挑程序,指令可以替换,自己的程序也可以替换!
上图采用绝对路径,这里也可以使用相对路径查找目标程序所在位置:
C语言调用Python可执行程序:
#!/usr/bin/python3 print("hello,python!") print("hello,python!") print("hello,python!") print("hello,python!") print("hello,python!")
#include<stdio.h> #include<unistd.h> #include<stdlib.h> #include<sys/wait.h> int main() { printf("我是父进程,我的pid:%d\n",getpid()); pid_t id=fork(); //创建子进程 if(id==0) { //子进程 //这里我们想让子进程执行全新的程序,以前是执行父进程代码片段 printf("我是子进程,我的pid: %d,我的ppid: %d\n",getpid(),getppid()); execl("/usr/bin/python3","python3","test.py",NULL); //execl("./mytest","mytest",NULL); //使用相对路径查找到目标程序所在位置 //execl("/home/study/lesson/lesson17/mytest","mytest",NULL); //使用绝对路径查找到目标程序所在位置 //char *const argv_[] = {(char*)"ls",(char*)"-a",(char*)"-l",(char*)"-i",NULL}; //execvp("ls",argv_); //execlp("ls","ls","-a","-l",NULL); //这里的两个ls,含义不一样! //execv("/usr/bin/ls",argv_); //execl("/usr/bin/ls","ls","-l","-a",NULL); //带选项 exit(1); //只要执行了exit,意味着excel系列的替换函数失败了! } //父进程 int status = 0; int ret = waitpid(id,&status,0); if(ret==id) { sleep(2); printf("父进程等待成功!\n"); } //printf("我是一个进程,我的pid:%d\n",getpid()); //ls -a -l //int ret= execl("/usr/bin/lsdf","ls","-l","-a",NULL); //带选项 //execl("/usr/bin/top","top",NULL); //不带选项 //execl("/usr/bin/pwd","pwd",NULL); //不带选项 //printf("我执行完毕了,我的pid:%d,ret: %d\n",getpid(),ret); return 0; }
C语言调用shell演示:
#!/usr/bin/bash cnt=0; while [ $cnt -le 10 ] do echo "hello,shell" let cnt++ done
任何程序都可以用系统级接口,只要能系统调用,就可以用系统级接口调用其他语言!
**1.2.5 int execle(const char path, const char arg,..., char * const envp[]);函数演示:
如果自定义环境变量呢?
#include<iostream> #include<stdlib.h> using namespace std; int main() { cout<<"PATH:"<<getenv("PATH")<<endl; cout<<"----------------------------------\n"<<endl; cout<<"MYPATH:"<<getenv("MYPATH")<<endl; cout<<"----------------------------------\n"<<endl; cout<<"hello,cpp!"<<endl; cout<<"hello,cpp!"<<endl; cout<<"hello,cpp!"<<endl; cout<<"hello,cpp!"<<endl; cout<<"hello,cpp!"<<endl; return 0; }
**1.3函数解释 **
这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
如果调用出错则返回-1,所以exec函数只有出错的返回值而没有成功的返回值。
**1.4命名理解 **
这些函数原型看起来很容易混,但只要掌握了规律就很好记。
- l(list) : 表示参数采用列表
- v(vector) : 参数用数组
- p(path) : 有p自动搜索环境变量PATH
- e(env) : 表示自己维护环境变量
#include <unistd.h> int main() { char *const argv[] = {"ps", "-ef", NULL}; char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL}; execl("/bin/ps", "ps", "-ef", NULL); // 带p的,可以使用环境变量PATH,无需写全路径 execlp("ps", "ps", "-ef", NULL); // 带e的,需要自己组装环境变量 execle("ps", "ps", "-ef", NULL, envp); execv("/bin/ps", argv); // 带p的,可以使用环境变量PATH,无需写全路径 execvp("ps", argv); // 带e的,需要自己组装环境变量 execve("/bin/ps", argv, envp); exit(0); }
事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册 第2节,其它函数在 man手册第3节。这些函数之间的关系如下图所示。
下图exec函数族 一个完整的例子:
2.模拟实现shell:
2.1 问题分析:
**我们可以综合前面的知识,做一个简易的shell **
考虑下面这个与shell典型的互动:
[root@localhost epoll]# ls client.cpp readme.md server.cpp utility.h [root@localhost epoll]# ps PID TTY TIME CMD 3451 pts/0 00:00:00 bash 3514 pts/0 00:00:00 ps
用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左向右移动。shell从用户读入字符串"ls"。shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结束。
然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。
所以要写一个shell,需要循环以下过程:
获取命令行
解析命令行
建立一个子进程(fork)
替换子进程(execvp)
父进程等待子进程退出(wait)
根据这些思路,和我们前面的学的技术,就可以自己来实现一个shell了。
实现代码:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <fcntl.h> #define MAX_CMD 1024 char command[MAX_CMD]; int do_face() { memset(command, 0x00, MAX_CMD); printf("minishell$ "); fflush(stdout); if (scanf("%[^\n]%*c", command) == 0) { getchar(); return -1; } return 0; } char **do_parse(char *buff) { int argc = 0; static char *argv[32]; char *ptr = buff; while(*ptr != '\0') { if (!isspace(*ptr)) { argv[argc++] = ptr; while((!isspace(*ptr)) && (*ptr) != '\0') { ptr++; } continue; } *ptr = '\0'; ptr++; } argv[argc] = NULL; return argv; } int do_exec(char *buff) { char **argv = {NULL}; int pid = fork(); if (pid == 0) { argv = do_parse(buff); if (argv[0] == NULL) { exit(-1); } execvp(argv[0], argv); }else { waitpid(pid, NULL, 0); } return 0; } int main(int argc, char *argv[]) { while(1) { if (do_face() < 0) continue; do_exec(command); } return 0; }
**在继续学习新知识前,我们来思考函数和进程之间的相似性 **
exec/exit就像call/return
一个C程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过call/return系统进行通信。
这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux鼓励将这种应用于程序之内的模式扩展到程序之间。如下图
一个C程序可以fork/exec另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过exit(n)来返回值。调用它的进程可以通过wait(&ret)来获取exit的返回值。
2.2 模拟实现myshell演示:
2.2.1 Makefile批量化替换指令演示:
在登录Linux后,我们发现会有一个命令行提示:
** 这里的属性是系统调用接口,可以网上搜索“Linux获取用户名的系统调用接口”。**
那么接下来我们编写程序模拟实现这个shell功能:
** 完整代码:**
/* *项目名称:模拟实现操作界面(即用户名 主机名 ...)待输入命令的shell脚本 *项目作者:新晓·故知 *时间:2022.10.01—2022.10.02 * * */ #include<stdio.h> #include<string.h> #include<stdlib.h> #include<unistd.h> #include<sys/wait.h> #include<sys/types.h> #define NUM 1024 #define SIZE 128 #define SEP " " //设置分隔符 char command_line[NUM]; //保存输入的字符串 char *command_args[SIZE]; //指针数组,将字符串切割成子串,将子串的首地址放到数组 int main() { //shell 操作界面等待的命令待显示,本质上就是一个死循环(先打印,再等待输入) while(1) { //用户名,主机名这些属性等其实是系统调用接口,这里我们先不关心获取这些属性的接口 //如果有时间深入研究,可以在网上搜素“Linux获取用户名的系统调用接口” //1.显示提示符 printf("[新晓故知@我的主机名 当前目录]# "); fflush(stdout); //刷新缓冲区 //2.获取用户输入 memset(command_line,'\0',sizeof(command_line)*sizeof(char)); fgets(command_line,NUM,stdin); //键盘,标准输入,stdin,获取到的是C风格的字符串,'\0' command_line[strlen(command_line)-1]='\0'; //清空\n (输入指令(字符串等)结束后,敲击回车键,而回车也是字符,但不可显) //3. "ls -a -l -i" —> "ls","-a","-l","-i"字符串切分 command_args[0] = strtok(command_line,SEP); //strtok切分字符串 int index = 1; // = 虽然报错,但是故意这么写的 // strtok 1.截取成功,返回字符串起始地址 2.截取失败,返回NULL // while(command_args[index++] = strtok(NULL,SEP)); // //for debug 仅用于测试是否截取成功 // for(int i=0;i<index;i++) // { // printf("%d : %s\n",i,command_args[i]); // } //4.TODO //这里不直接进行替换的原因是:一旦直接替换,那整个shell就会替换,原有的配置被更改 //交给子进程,子进程能够看到 //5.创建进程,执行 pid_t id=fork(); if(id == 0) { //子进程 //6.程序替换 execvp(command_args[0],command_args); //comman_args[0]就是我们要保存的名字 exit(1); //如果执行到这里,子进程一定替换失败,因为一旦替换成功,子进程就会执行新的程序去了 } int status = 0; pid_t ret=waitpid(id,&status,0); if(ret<0) { printf("等待子进程成功:signal: %d, return code: %d\n",status&0x7F,(status>>8)&0xFF); } } //end while return 0; }
理解内建命令,在学习环境变量的时候,还有一些命令并没有fork()创建子进程,其实是shell内部的命令!
创建子进程去执行命令,保证替换时不影响父进程,从而让父进程shell一直进行命令行解析。所以联想到我们在登录Linux时的提示符,其实是系统的shell程序在运行!
后记:
●由于作者水平有限,文章难免存在谬误之处,敬请读者斧正,俚语成篇,恳望指教!
——By 作者:新晓·故知
版权归原作者 新晓·故知 所有, 如有侵权,请联系我们删除。