【Linux】进程控制

服务器 0

文章目录

  • 一、进程创建
    • 1、再谈 fork 函数
    • 2、fork 函数返回值
    • 3、写时拷贝
    • 4、fork 常规用法
    • 5、fork 调用失败原因
  • 二、进程终止
    • 1、进程退出码
    • 2、进程退出的情况
    • 3、进程退出的方法
  • 三、进程等待
    • 1、为什么要进行进程等待
    • 2、如何进行进程等待
      • (1)、wait 系统调用
      • (2)、status 位图结构
      • (3)、waitpid 系统调用
      • (4)、阻塞与非阻塞等待
    • 3、进程等待总结
  • 四、进程程序替换
    • 1、什么是进程程序替换
    • 2、进程程序替换的原理
    • 3、如何进行进程程序替换
      • (1)、exec 系列函数
      • (2)、函数命名理解
      • (3)、函数如何使用
  • 五、实现一个简易的 shell
    • 1、shell 的初步实现
    • 2、什么是当前路径
    • 3、内建命令/外部命令
    • 4、shell 的最终实现

一、进程创建

1、再谈 fork 函数

我们在 进程概念与进程状态 中对 fork 函数进行了初步的介绍与使用,在这里我们来详细的学习一下 fork 函数;fork 是 Linux 中非常重要的一个系统调用函数,它用于在当前进程下创建一个新的进程,新进程是当前进程的子进程;我们可以 man 2号手册来查看 fork 函数:image-20221120162339526

头文件:unistd.h    函数原型:pid_t fork(void)    函数功能:创建一个子进程    函数返回值:创建成功 -- 给父进程返回子进程的pid,给子进程返回0;创建失败 -- 给父进程返回-1,没有子进程被创建
#include <stdio.h>#include <unistd.h>#include <sys/types.h>int main() {    pid_t id = fork();  //创建子进程    if(id == -1) {        printf("fork fail/n");        return -1;    } else if (id == 0) {  //子进程        while(1) {            printf("子进程, pid:%d, ppid:%d, id:%d/n", getpid(), getppid(), id);            sleep(1);        }     } else {  //父进程         while(1) {            printf("父进程, pid:%d, ppid:%d, id:%d/n", getpid(), getppid(), id);            sleep(1);        }     }    return 0;}

image-20221120163457082

tips:我们在编写 makefile 的时候,目标文件的依赖方法中,可以用 “$@” 表示要形成的目标文件,即依赖关系中 “:” 左边的内容;用 “$^” 表示目标文件的依赖文件,即依赖关系中 “:” 右边的内容。image-20221120164313582

2、fork 函数返回值

学过 C/C++ 的时候同学都知道,一个函数最多只能有一个返回值,那么我们应该如何理解 fork 函数有两个返回值呢?

首先,fork 函数是一个系统调用,即 fork 函数是操作系统为我们提供的一个操作接口,那么 fork 函数肯定也是由操作系统实现的;所以当我们调用 fork 函数时,其实是 OS 在帮我们创建子进程;

其次,一个函数在正常执行的情况下,函数 return 之前函数的主体功能肯定已经被执行完了;对于 fork 函数来说,fork 函数的作用是创建子进程,所以 fork 在 return 之前就已经创建了子进程,那么此时就存在两个进程;

既然存在两个进程,那么 fork 自然也就会被返回两次 – 每一个进程都要 return,所以 fork 函数有两个返回值。

另外,为什么 fork 给父进程返回子进程的 pid,而给子进程返回0呢? – 因为一个父进程可能有多个子进程,而一个子进程只能有一个父进程,父进程需要子进程的 pid 来判别不同的子进程,而子进程则不需要判别父进程。

3、写时拷贝

在上一节 进程地址空间 中我们写了如下程序:

#include <stdio.h>#include <sys/types.h>#include <unistd.h>int g_val = 100;int main() {    int id = fork();    if(id < 0) {        perror("fork fail");        return 1;    } else if(id == 0) {        int cnt = 0;        while(1) {            if(cnt == 5) {                g_val = 200;                printf("子进程已经修改了全局变量.........................../n");            }            cnt++;            printf("我是子进程,pid:%d, ppid:%d, g_val:%d, &g_val:%p/n", getpid(), getppid(), g_val, &g_val);            sleep(1);        }    } else {        while(1) {            printf("我是父进程,pid:%d, ppid:%d, g_val:%d, &g_val:%p/n", getpid(), getppid(), g_val, &g_val);            sleep(1);        }    }    return 0;}

image-20221120170057078

我们发现,子进程和父进程中 g_val 变量的地址相同,但是值却不相同;我们现在知道 – OS会为每一个进程都创建一个进程地址空间以及页表,然后将通过页表将地址空间映射到物理内存;

对于父子进程来说,父进程和子进程共享代码和数据,但是为了保证进程的独立性,当其中一方想要修改数据时,就会发生 写时拷贝 – OS 会在物理内存中重新开辟一块空间,然后将原空间中的数据拷贝都新空间,再修改页表映射关系,最后再让进程修改对应的数据;

所以虽然表面上父子进程 g_val 的地址相同,但这只是虚拟地址相同,而物理地址并不相同,所以父子进程 g_val 的值也能够不同;对于接受 fork 返回值的变量 id 来说也一样,先进行 return 的进程会对 id 进行写时拷贝,所以对于父子进程来说,id 的值不同:image-20221120171057114

4、fork 常规用法

fork 一般应用于一下两种场景:

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段;例如,父进程等待客户端请求,生成子进程来处理请求;我们前面使用的 fork 都属于这种情况。
  • 一个进程要创建子进程来执行一个不同的程序;例如子进程从 fork 返回后,调用 exec 系列函数;这是我们下面要重点学习的内容。

5、fork 调用失败原因

如下两种原因可能会导致 fork 调用失败:

  • 系统中有太多的进程;
  • 实际用户的进程数超过了限制;

我们可以写一个死循环创建进程的程序来测试我们当前OS最多能创建多少个进程:

#include <stdio.h>#include <unistd.h>int main() {    int cnt = 0;    while(1) {        int id = fork();        if(id < 0) {  //进程创建失败            printf("fork fail, cnt:%d/n", cnt);            break;        } else if(id == 0) {  //子进程            printf("子进程持续创建中.../n");        }        cnt++;    }    return 0;}

image-20221120172017602

注:运行上面这个程序可能导致服务器或者虚拟机直接挂掉,虚拟机直接 shutdown 关机重启即可;服务器则需要到对应的服务器控制台进行重启。


二、进程终止

1、进程退出码

我们运行一个进程是为了让该进程完成某一项任务,而既然是完成任务,就需要对任务执行结果的正确性进行标定;进程退出码的作用就是就是标定一个进程执行结果是否正确,不同的退出码表示不同的执行结果,一般来说:

  • 0表示进程运行结果正确;
  • 非0表示运行结果错误;

对于非0来说,不同的数字有又对应着不同的错误,我们可以自己设定不同退出码所对应的错误信息,也可以使用系统提供的退出码映射关系:image-20221120180719685

在 Linux 中,存在一个变量 “?” – 该变量中始终保存着最近一个进程执行完成时的退出码,我们可以使用 “echo $?” 来查看最近一个进程的退出码:image-20221120181334021

注:由于 echo 本身也是一个可执行程序,我们使用 echo 查看 ? 时 echo 也会被运行,所以我们后面再次查看 $? 时,得到的结果是0。

2、进程退出的情况

进程退出时一共有如下三种情景:

  • 代码运行完毕且结果正确 – 此时退出码为0;
  • 代码运行完毕且结果不正确 – 此时退出码为非0;
  • 代码异常终止 – 此时退出码无意义。

3、进程退出的方法

进程退出有如下几种方法:

  • main 函数 return 返回;
  • 调用 exit 终止程序;
  • 调用 _exit 终止程序。

我们平时接触最多的就是通过 main 函数 return 返回来退出进程,但其实我们也可以通过库函数 exit 和系统调用 _exit 来直接终止进程;

库函数 exit

头文件:stdlib.h    函数原型:void exit(int status);status:status 定义了进程的终止状态,父进程通过wait来获取该值    函数功能:终止进程

image-20221120200722530

image-20221120201110482

可以看到,exit 会将我们的进程直接终止,无论程序代码是否执行完毕。

系统调用 _exit

头文件:unistd.h    函数原型:void _exit(int status);status:status 定义了进程的终止状态,父进程通过wait来获取该值    函数功能:终止进程

image-20221120201404035

image-20221120201518463

exit 和 _exit 的区别

关于 exit 和 _exit 的区别和联系,我们以一个例子说明:

#include <stdio.h>#include <unistd.h>#include <stdlib.h>int main() {    printf("hello linux");    exit(1);    //_exit(1);    printf("process is done.../n");    return 0;}

image-20221120201941814

image-20221120202033171

首先,由于 exit 是C语言库函数,而 _exit 是系统调用,所以可以肯定的是 exit 的底层是 _exit 函数,exit 是 _exit 的封装

其次,由于计算机体系结构的限制,CPU之和内存交互,所以数据会先被写入到缓冲区,待缓冲区刷新时才被打印到显示器上;而在上面的程序中,我们没用使用 ‘/n’ 进行行缓冲的刷新,可以看到,exit 最后打印了 “hello linux”,而 _exit 什么都没有打印;所以 exit 在终止程序后会刷新缓冲区,而 _exit 终止程序后不会刷新缓冲区

最后,由于 exit 的底层是 _exit,而 _exit 并不会刷新缓冲区,也可以反映出 缓冲区不在操作系统内部,而是在用户空间

上面三种进程退出的方法都是正常退出,但除了正常退出,进程也会异常退出,比如 Ctrl C 终止进程,或者程序中遇到除0,野指针,空指针解引用等问题:image-20221120203601561


三、进程等待

1、为什么要进行进程等待

我们创建一个进程的目的是为了让其帮我们完成某种任务,而既然是完成任务,进程在结束前就应该返回任务执行的结果,供父进程或者操作系统读取。

所以,一个进程在退出的时候,不能立即释放全部资源 – 对于进程的代码和数据,操作系统可以释放,因为该进程已经不会再被执行了,但是该进程的PCB应该保留,因为PCB中存放着该进程的各种状态代码,特别是退出状态代码。

对于父子进程来说,当子进程退出后,如果父进程不对子进程的退出状态进行读取,那么子进程就会变成 “僵尸进程”;而进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼” 的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程;进而就会造成内存泄漏;

所以,我们需要父进程对子进程进行 进程等待,获取子进程的退出信息,并让操作系统回收子进程资源 (释放子进程的 PCB)。

进程等待的本质

我们知道,子进程的退出信息是存放在子进程的 task_struct 中的,所以进程等待的本质就是从子进程 task_struct 中读取退出信息,然后保存到相应变量中去:image-20221120205225454

2、如何进行进程等待

在 Linux 下,我们一般通过以下两种系统调用来进行进程等待:

pid_t wait(int *status);pid_t waitpid(pid_t pid, int *status, int options);

wait 和 waitpid 都可以获取子进程的退出信息,并让操作系统回收子进程资源 (释放子进程的 PCB);

status:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL;

options:指定父进程等待方式 – 阻塞式等待和非阻塞式等待;

(1)、wait 系统调用

我们可以通过 wait 系统调用来进行进程等待:image-20221121082022706

头文件:sys/types.h	 sys/wait.h    函数原型:pid_t wait(int *status);status:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL;    返回值:成功返回被等待进程的pid,失败返回-1;

下面我们举例来演示 wait 的使用:

#include <stdio.h>#include <stdlib.h>#include <sys/types.h>#include <sys/wait.h>#include <unistd.h>int main() {    int id = fork();    if(id == -1) {        printf("fork error/n");        exit(-1);    } else if(id == 0) {  //子进程        int cnt = 5;        while(cnt--) {            printf("子进程, pid:%d, ppid:%d, cnt:%d/n", getpid(), getppid(), cnt);            sleep(1);        }        exit(1);    } else {  //父进程        sleep(10);        int status = 0;        pid_t ret = wait(&status);        if(ret == -1) {            printf("wait fail/n");            exit(1);        } else {            printf("wait success/n");        }        printf("exit code:%d/n", status);    }    return 0;}

我们可以通过一个监控脚本来检测子进程从创建到终止到被父进程回收的过程:

while :; do ps axj | head -1 && ps axj | grep process | grep -v grep; sleep 1; done

image-20221121100121575

可以看到,最开始父子进程都处于睡眠状态 S,之后子进程运行5s退出,此时由于父进程还要休眠5s,所以没有对子进程进行进程等待,所以子进程变成僵尸状态 D;5s过后,父进程使用 wait 系统调用对子进程进行进程等待,所以子进程由僵尸状态变为彻底死亡状态。

(2)、status 位图结构

在上面的例子中,子进程使用 exit 终止进程时返回的退出码是1,但是我们发现保存子进程退出信息的 status 的值非常奇怪,这是由于 status 的位图结构造成的;

wait 和 waitpid,都有一个 status 参数,该参数是一个输出型参数,由操作系统填充;

如果传递NULL,表示不关心子进程的退出状态信息;否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程;

status 不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究 status 低16比特位):image-20221121092535897

可以看到,status 低两个字节的内容被分成了两部分 – 第个一字节前七位表示退出信号,最后一位表示 core dump 标志;第二个字节表示退出状态,退出状态即代表进程退出时的退出码;

对于正常退出的程序来说,退出信号和 core dump 标志都为0,退出状态等于退出码;对于异常终止的程序来说,退出信号为不同终止原因对应的数字,退出状态未用,无意义。

所以 status 正确的读取方法如下:

printf("exit signal:%d,  exit code:%d /n", (status & 0x7f), (status >> 8 & 0xff)); 

其中,status 按位与上 0x7f 表示保留低七位,其余九位全部置为0,从而得到退出信号;

status 右移8位得到退出状态,再按位与上 0xff 是为了防止右移时高位补1的情况;

image-20221121100504478

WIFEXITED 与 WEXITSTATUS 宏

Linux 提供了 WIFEXITED 和 WEXITSTATUS 宏 来帮助我们获取 status 中的退出状态和退出信号,而不用我们自己去按位操作:

  • WIFEXITED (status):若子进程正常退出,返回真,否则返回假;(查看进程是否是正常退出)(wait if exited)
  • WEXITSTATUS (status):若 WIFEXITED 为真,提取子进程的退出状态;(查看进程的退出码)(wait exit status)
if(WIFEXITED(status)) {  //正常退出    printf("exit code:%d/n", WEXITSTATUS(status));} else {  //异常终止    printf("exit signal:%d/n",WIFEXITED(status));}

image-20221121101047265

(3)、waitpid 系统调用

我们也可以用 waitpid 来进行进程等待:image-20221121101823127

头文件:sys/types.h	 sys/wait.h    函数原型:pid_t waitpid(pid_t pid, int *status, int options);pid:Pid=-1,等待任意一个子进程,与wait等效;Pid>0.等待其进程id与pid相等的子进程;    status:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL;    options:等待方式,options=0,阻塞等待;options=WNOHANG,非阻塞等待;    返回值:waitpid调用成功时返回被等待进程的pid;如果设置了WNOHANG,且waitpid发现没有已退出的子进程可收集,则返回0;调用失败则返回-1

我们还是举例来说明 waitpid 的使用:

#include <stdio.h>#include <stdlib.h>#include <sys/types.h>#include <sys/wait.h>#include <unistd.h>int main() {    int id = fork();    if(id == -1) {        printf("fork error/n");        exit(-1);    } else if(id == 0) {  //子进程        int cnt = 5;        while(cnt--) {            printf("子进程, pid:%d, ppid:%d, cnt:%d/n", getpid(), getppid(), cnt);            sleep(1);        }        exit(1);    } else {  //父进程        sleep(10);        int status = 0;        pid_t ret = waitpid(id, &status, 0);  //阻塞等待        if(ret == -1) {            printf("wait fail/n");            exit(1);        } else {            printf("wait success/n");        }        printf("exit signal:%d,  exit code:%d/n", (status & 0x7f), (status >> 8 & 0xff));    }    return 0;}

image-20221121103555720

可以看到,waitpid 和 wait 还是有很大区别的 – waitpid 可以传递 id 来指定等待特定的子进程,也可以指定 options 来指明等待方式。

(4)、阻塞与非阻塞等待

waitpid 函数的第三个参数用于指定父进程的等待方式:image-20221121104204216

其中,options 为0代表阻塞式等待,options 为 WNOHANG 代表非阻塞式等待;

阻塞式等待即当父进程执行到 waitpid 函数时,如果子进程还没有退出,父进程就只能阻塞在 waitpid 函数,直到子进程退出,父进程通过 waitpid 读取退出信息后才能接着执行后面的语句;

而非阻塞式等待则不同,当父进程执行到 waitpid 函数时,如果子进程未退出,父进程会直接读取子进程的状态并返回,然后接着执行后面的语句,不会等待子进程退出。

轮询

轮询是指父进程在非阻塞式状态的前提下,以循环方式不断的对子进程进行进程等待,直到子进程退出。

#include <stdio.h>#include <stdlib.h>#include <sys/types.h>#include <sys/wait.h>#include <unistd.h>void task1() {    printf("task is running.../n");}void task2() {    printf("task is runnning.../n");}int main() {    int id = fork();    if(id == -1) {        printf("fork error/n");        exit(-1);    } else if(id == 0) {  //子进程        int cnt = 5;        while(cnt--) {            printf("子进程, pid:%d, ppid:%d, cnt:%d/n", getpid(), getppid(), cnt);            sleep(1);        }        exit(1);    } else {  //父进程        int status = 0;        while(1) {  //轮询            pid_t ret = waitpid(id, &status, WNOHANG);  //非阻塞式等待            if(ret == -1) {                printf("wait fail/n");  //调用失败                exit(1);            } else if(ret == 0){  //调用成功,但子进程未退出                printf("wait success, but child process not exit/n");                task1();  //执行其他命令                task2();            } else {  //调用成功,子进程退出                printf("wait success, and child exited/n");                break;            }            sleep(1);        }        if(WIFEXITED(status)) {  //正常退出            printf("exit code:%d/n", WEXITSTATUS(status));        } else {  //异常终止            printf("exit signal:%d/n",WIFEXITED(status));        }            }    return 0;}

image-20221121112338552

3、进程等待总结

  • 为了读取子进程的退出结果以及回收子进程资源,我们需要进行进程等待;
  • 进程等待的本质是父进程从子进程 task_struct 中读取退出信息,然后保存到 status 中;
  • 我们可以通过 wait 和 waitpid 系统调用进行进程等待;
  • status 参数是一个输出型参数,父进程通过 wait/waitpid 函数将子进程的退出信息写入到 status 中;
  • status 以位图方式存储,包括退出状态和退出信号,若退出信号不为0,则退出状态无效;
  • 我们可以使用系统提供的宏 WIFEXITED 和 WEXITSTATUS 来分别获取 status 中的退出状态和退出信号;
  • 进程等待的方式分为阻塞式等待与非阻塞式等待,阻塞式等待用0来标识,非阻塞式等待用宏 WNOHANG 来标识;
  • 由于非阻塞式等待不会等待子进程退出,所以我们需要以轮询的方式来不断获取子进程的退出信息。

四、进程程序替换

1、什么是进程程序替换

在上面进程创建中我们提到,fork 函数一般有两种用途 – 创建子进程来执行父进程的部分代码以及创建子进程来执行不同的程序,创建子进程来执行不同的程序就是进程程序替换。

进程程序替换是指父进程用 fork 创建子进程后,子进程通过调用 exec 系列函数来执行另一个程序;当进程调用某一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,然后从新程序的启动例程开始执行;

但是原进程的 task_struct 和 mm_struct 以及进程 id 都不会改变,页表可能会变;所以调用 exec 并不会创建新进程,而是让原进程去执行另外一个程序的代码和数据。

2、进程程序替换的原理

进程程序替换其实就是用新程序的代码和数据去替换原进程物理内存中的代码和数据,除了可能会改变原进程的页表映射之外,其他的内核数据都不变,比如 task_struct、mm_struct;图示如下:image-20221121160140224

3、如何进行进程程序替换

(1)、exec 系列函数

Linux 提供了一系列的 exec 函数来实现进程程序替换,其中包括六个库函数和一个系统调用:image-20221121153715742

image-20221121153740390

可以看到,实现进程程序替换的系统调用函数就一个 – execve,其他一系列的 exec 库函数都是为了满足不同的替换场景而对 execve 系统调用进行的封装,其底层还是 execve;接下来我们要重点学习的是这六个 exec 库函数。

库函数中有六种以 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 execvpe(const char *file, char *const argv[],char *const envp[]);

这些函数如果调用成功则加载新的程序并启动代码开始执行,不再返回;如果调用出错则返回-1;

注:exec 函数一旦调用成功,就代表着原程序的代码和数据已经被新程序替换掉了,也就是说,原程序后续的语句都不会再被执行了,所以 exec 调用成功后没有返回值,因为该返回值没有机会被使用;只有 exec 调用失败,原程序可以继续往下执行时,exec 返回值才会被使用。

(2)、函数命名理解

上面这六个库函数的函数原型看起来很容易混,但其实只要掌握了规律就很好记:

  • l (list):表示参数采用列表;
  • v (vector):表示参数采用数组;
  • p (path):表示系统会自动到环境变量PATH路径下搜索文件,即对于替换Linux指令相关程序时我们不用带路径;
  • e (env):表示自己维护环境变量;
-函数名参数格式-函数是否需要带路径是否使用当前环境变量
execl列表
execlp列表
execle列表否,需要自己组装环境变量
execv数组
execvp数组
execve数组否,需要自己组装环境变量

(3)、函数如何使用

我们想要执行一个程序,无非就两个步骤 – 一是找到该可执行程序;二是指定程序执行的方式;对于 exec 函数来说,“p” 和非 “P” 用来找到程序,“l” “v” 用来指定程序执行方式;“e” 用来指定环境变量。

execl && execlp

exec 函数的使用其实很简单,第一个参数为我们要替换的程序的路径,,如果该程序在PATH环境变量中,且 exec 函数带有 “p”,我们可以不带路径,只写函数名;

我们以Linux指令 “ls” 为例,我们知道,ls 是Linux中 “/usr/bin” 目录下的一个可执行程序,且该程序处于PATH环境变量中,那么如果我们要替换此程序,exec 函数的第一个参数如下:

execl("/usr/bin/ls", ...)  //execl 需要带路径execlp("ls", ...)  //execlp 可以不带路径

注意:带 “p” 的 exec 函数可以不带路径的前提是被替换程序处于PATH环境变量中,如果条件不成立,即使函数中有 “p”,我们仍然要带路径。

第二个参数为如何执行我们的程序,这里我们只需要记住:在 Linux 命令行中该程序如何执行我们就如何传参 即可;需要注意的是,命令行中多个指令是以空格为分隔的一整个字符串,而 exec 中我们需要对不同选项进行分割,即每一个选项都要单独分为一个字符串,所以可以看到 exec 函数中存在可变参数列表 “…”;同时,我们需要将最后一个可变参数设置为 NULL,表示传参完毕。

execl("/usr/bin/ls", "ls", "-a", "-l", NULL);  //命令行中怎么执行就如何传参execlp("ls", "ls", "-a", "-l", NULL);  //命令行:ls -a -l

注:Linux 中 ls 其实是使用 alias 命令设置别名的,所以我们执行 ls 的时候其实默认带了 “–color=auto” 选项,它让不同类型的文件带有不同的颜色。image-20221124091713554

所以 ls 在进程程序替换时如果我们想要让不同类型文件表现为不同颜色的话,我们需要显示传递 “–color=auto” 选项:

execl("/usr/bin/ls", "ls", "-a", "-l", "--color=auto", NULL);  execlp("ls", "ls", "-a", "-l", "--color=auto", NULL);  

下面我们以一个具体的例子来演示如何进行进程程序替换:

#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/types.h>#include <sys/wait.h>int main() {    pid_t id = fork();    if(id == -1) {        perror("fork");        return 1;    } else if (id == 0) {  //子进程        printf("pid: %d, child process is runnning.../n", getpid());        int ret = execl("/usr/bin/ls", "ls", "-l", "-a", "--color=auto", NULL);  //进程程序替换        if(ret == -1) {  //替换失败,以下语句可以被执行            printf("process exec failed/n");            exit(1);        }        printf("pid: %d, child process is done.../n", getpid());        return 0;    }    //父进程    int status = 0;    pid_t ret = waitpid(id, &status, 0);  //进程等待    if(ret == -1) {        perror("waitpid");        return 1;    } else {        printf("wait pid: %d, exit signal: %d, exit code: %d/n", ret, (status & 0x7f), (status >> 8 & 0xFF));    }    return 0;}

image-20221124103742410

可以看到,我们在命令行上使用 “ls -a -l” 和我们使用进程程序替换得到的结果是一样的。

execv && execvp

exec 函数中 “v” 代表参数采用数组的形式传递 – argv 是一个指针数组,数组里面的每一个元素都是指针,每一个指针都指向一个参数 (字符串),同样,最后一个元素指向 NULL,代表参数传递完毕;

我们还是以 ls 指令为例:

char* const argv[] = {  //存放参数的指针数组    (char*)"ls",    (char*)"-a",     (char*)"-l",    (char*)"--color=auto",    NULL };  execv("/usr/bin/ls", argv);execvp("ls", argv);

注:由于 “ls” “-a” 等字符串是常量字符串,而 argv 里面的参数是 char* const 而不是 const char* 的,所以这里我们需要强转一下,但是不强转也没大问题,毕竟我们不会修改它,只是会有一个警告。

image-20221124110943425

image-20221124111018006

execle && execvpe

exec 函数中 “e” 代表环境变量 – 和 argv 一样,envp 也是一个指针数组,数组里面的每个元素都是一个指针,指向一个环境变量 (字符串),我们可以显式初始化 envp 来传递我们自定义的环境变量,但是这也代表着我们放弃了系统环境变量;

char* const envp[] = {  //自定义环境变量     (char*)"MYENV=HELLO EXEC",    NULL};int ret = execle("./mybin", "./mybin", NULL, envp);

mybin.c:

#include <stdio.h>#include <stdlib.h>int main() {    printf("I am another C program/n");    printf("USER: %s/n", getenv("USER"));    printf("PWD: %s/n", getenv("PWD"));    printf("MYENV: %s/n", getenv("MYENV"));    return 0;}

myexec.c:

#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/types.h>#include <sys/wait.h>int main() {    pid_t id = fork();    if(id == -1) {        perror("fork");        return 1;    } else if (id == 0) {  //子进程        printf("pid: %d, child process is runnning.../n", getpid());                char* const envp[] = {  //自定义环境变量             (char*)"MYENV=HELLO EXEC",            NULL        };        int ret = execle("./mybin", "./mybin", NULL, envp);  //程序替换                if(ret == -1) {  //替换失败,以下语句可以被执行            printf("process exec failed/n");            exit(1);        }        printf("pid: %d, child process is done.../n", getpid());        return 0;    }    //父进程    int status = 0;    pid_t ret = waitpid(id, &status, 0);  //进程等待    if(ret == -1) {        perror("waitpid");        return 1;    } else {        printf("wait pid: %d, exit signal: %d, exit code: %d/n", ret, (status & 0x7f), (status >> 8 & 0xFF));    }    return 0;}

image-20221124212735865

可以看到,这里我们只获取到了自定义的环境变量 MYENV,而系统环境变量 USER 和 PWD 则是获取失败。

那么,如何同时获取到自定义环境变量和系统环境变量呢?答案是通过 putenv 函数将自定义环境变量导入到系统环境变量中,然后通过传递环境变量表 environ 实现:image-20221124213317905

extern char** environ;  //系统环境变量int put = putenv((char*)"MYENV=HELLO_EXECLE");  //导入自定义环境变量if(put != 0)  //导入失败返回非0    perror("putenv");int ret = execle("./mybin", "./mybin", NULL, environ); //传递环境变量

image-20221124215727585

image-20221124215759300


五、实现一个简易的 shell

1、shell 的初步实现

在学习了进程创建、进程终止、进程等待以及进程程序替换系列进程控制相关知识后,我们就可以自己实现一个简易的 shell 命令行解释器了;实现一个简易的 shell 大概可以分为如下几步:

  • 输出提示符;

  • 从终端获取命令行输入;

  • 解析命令行输入信息;

  • 创建子进程;

  • 进程程序替换;

  • 进程等待;

具体初步代码实现如下:

#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <sys/types.h>#include <sys/wait.h>#define NUM 1024    //一个命令的最大长度#define OPT_NUM 64  //一个命令的最多选项char lineCommand[NUM];char* argv[OPT_NUM];int main() {    while(1) {        //输出提示符        printf("[用户名@主机名 当前路径]$ ");        fflush(stdout);        //获取输入        char* ret = fgets(lineCommand, sizeof(lineCommand)-1, stdin);  //最后留一个位置来存放极端情况下的/0        if( ret == NULL ) {            perror("fgets");            exit(1);        }        lineCommand[strlen(lineCommand) - 1] = '/0';  //消除命令行中最后的换行符        //将输入解析为多个字符串存放到argv中,即字符串切割        argv[0] = strtok(lineCommand, " ");        int i = 1;        while(argv[i++] = strtok(NULL, " "));        //创建子进程        pid_t id = fork();        if(id == -1) {            perror("fork");            exit(1);        } else if (id == 0) {  //子进程            //进程程序替换            int ret = execvp(argv[0], argv);            if(ret == -1) {                  printf("No such file or directory/n");                exit(1);            }        } else {  //父进程            //进程等待            pid_t ret = waitpid(id, NULL, 0);            if(ret == -1){                perror("wait");                exit(1);            }        }     }    return 1;  //while循环正常情况下不会结束}

image-20221124230640814

如上,myshell 运行起来后能够完成 Linux 中的一些基本指令,但是我们发现,ls 没有颜色功能,我们可以在 myshell 对 ls 指令进行判断,然后手动为其加上 “–color=auto” 选项:

if(argv[0] != NULL && strcmp(argv[0], "ls") == 0)  //ls颜色显示{    argv[i++] = (char*)"--color=auto";}

image-20221124231144869

image-20221124231518838

2、什么是当前路径

我们运行上面的 myshell 可以发现,当我们 cd 更换路径后,pwd 命令还是显示原来的路径:image-20221124231951030

要理解并解决这个问题,我们首先要理解什么是当前路径;下面我举例说明:image-20221125003444255

可以看到,当 test 程序运行起来后,其在系统中一共有两个路径,其中 exe 路径是指 test 可执行程序在磁盘中的路径,而 cwd (current working directory) 则是指 当前进程的工作目录,它就是我们平时所说的 当前路径

在 Linux 中,我们可以使用 chdir 系统调用来改变进程的工作目录image-20221125004426980

image-20221125005736505

在理解了当前路径是当前进程的工作目录之后,我们就可以解释为什么我们的 shell 执行 cd 命令后目录不改变了:

myshell 是通过创建子进程的方式去执行命令行中的各种指令的,也就是说,cd 命令是由子进程去执行的,那么自然被改变也是子进程的工作目录,父进程的工作目录不受影响;

而当我们使用 PWD 指令来查看当前路径时,cd 指令对应的子进程已经执行完毕退出了,此时 myshell 又会给 PWD 创建一个新的子进程,且这个子进程的工作目录和父进程 myshell 相同,所以 PWD 打印出来的路径不变。

要想解决这个问题也很简单,我们只需要对命令行传入的指令进行判断,如果是 cd 指令,就使用 chdir 将父进程的工作目录修改为指定的目录即可:

if(myargv[0] != NULL && strcmp(myargv[0], "cd") == 0)  //cd改变父进程工作路径{    if(myargv[1] != NULL)         chdir(myargv[1]);  //myargv[1]中保存着指定路径    continue;  //下面的语句不需要再执行,因为cd的目的已经达到了,直接读取下一条指令}

image-20221125011652210

image-20221125011817484

3、内建命令/外部命令

Linux 中的命令一共分为两种 – 内建命令和外部命令:

内建命令是 shell 程序的一部分,其功能实现在 bash 源代码中,不需要派生子进程来执行,也不需要借助外部程序文件来运行,而是由 shell 进程本身内部的逻辑来完成;外部命令则是通过创建子进程,然后进行进程程序替换,运行外部程序文件等方式来完成。

我们可以使用 type 命令来区分 Linux 中的内置命令和外部命令:image-20221125092545719

其实我们上面对 cd 指令就是以内置命令的方式来处理的 – myshell 遇到 cd 命令时,由自己直接来改变进程工作目录,处理完毕直接 continue,而不会创建子进程;不过对于 pwd 指令我们并没有单独处理成内置命令。

同时,我们发现 echo 命令也是一个内置命令,这其实也很好的解释了 为什么 “echo $变量” 可以查看本地变量以及为什么 “echo $?” 可以获取最近一个进程的退出码 了:

虽然本地变量只在当前进程有效,但是使用 echo 查看本地变量时,shell 并不会创建子进程,而是直接在当前进程中查找,自然可以找到本地变量;

shell 可以通过进程等待的方式获取上一个子进程的退出状态,然后将其保存在 ? 变量中,当命令行输入 “echo $?” 时,直接输出 ? 变量中的内容,然后将 ? 置为0 (echo 正常退出的退出码),也不需要创建子进程。

自此,我们又可以为我们的 myshell 添加 echo 功能了:

int EXIT_CODE;  //退出码 -- 全局变量if(argv[0] != NULL && strcmp(argv[0], "echo") == 0)  //处理echo内建命令{    if(strcmp(argv[1], "$?") == 0){  //echo $?        printf("%d/n", EXIT_CODE);        EXIT_CODE = 0;    } else {  //echo $变量        printf("%s/n", argv[1]+1);    }    continue;}//fork后面的内容} else {  //父进程    int status = 0;    pid_t ret = waitpid(id, &status, 0);   //进程等待    EXIT_CODE = (status >> 8) & 0xFF;   //获取退出码    if(ret == -1){        perror("wait");        exit(1);    }} 

image-20221125101014082

4、shell 的最终实现

在完善了 ls 颜色高亮、cd 切换当前目录、echo 显示变量等细节后,一个简易的 shell 就被我们编写出来了,完整代码如下:

#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <sys/types.h>#include <sys/wait.h>#define NUM 1024    //一个命令的最大长度#define OPT_NUM 64  //一个命令的做多选项char lineCommand[NUM];char* argv[OPT_NUM];  int EXIT_CODE;  //保存进程退出码int main() {    while(1) {        //输出提示符        printf("[用户名@主机名 当前路径]$ ");        fflush(stdout);        //获取输入        char* ret = fgets(lineCommand, sizeof(lineCommand)-1, stdin);  //最后留一个位置来存放极端情况下的/0        if( ret == NULL ) {            perror("fgets");            exit(1);        }        lineCommand[strlen(lineCommand) - 1] = '/0';  //消除命令行中最后的换行符        //将输入解析为多个字符串存放到argv中,即字符串切割        argv[0] = strtok(lineCommand, " ");        int i = 1;        if(argv[0] != NULL && strcmp(argv[0], "ls") == 0)  //ls颜色显示        {            argv[i++] = (char*)"--color=auto";        }        while(argv[i++] = strtok(NULL, " "));        if(argv[0] != NULL && strcmp(argv[0], "cd") == 0)  //cd改变父进程工作路径        {            if(argv[1] != NULL)                 chdir(argv[1]);  //myargv[1]中保存着指定路径            continue;        }                if(argv[0] != NULL && strcmp(argv[0], "echo") == 0)  //处理echo内建命令        {            if(strcmp(argv[1], "$?") == 0){  //echo $?                printf("%d/n", EXIT_CODE);                EXIT_CODE = 0;            } else {  //echo $变量                printf("%s/n", argv[1]+1);            }            continue;        }        //创建子进程        pid_t id = fork();        if(id == -1) {            perror("fork");            exit(1);        } else if (id == 0) {  //子进程            int ret = execvp(argv[0], argv);  //进程程序替换            if(ret == -1) {                  printf("No such file or directory/n");                exit(1);            }        } else {  //父进程            int status = 0;            pid_t ret = waitpid(id, &status, 0);  //进程等待            EXIT_CODE = (status >> 8) & 0xFF;  //获取退出状态            if(ret == -1){                perror("wait");                exit(1);            }        }     }    return 1;  //while循环正常情况下不会结束}

也许您对下面的内容还感兴趣: