【打造你自己的Shell:编写定制化命令行体验】

发布时间:2024年01月18日

本节重点:

  • 学习进程创建,fork/vfork

  • 学习到进程等待

  • 学习到进程程序替换, 微型shell,重新认识shell运行原理

  • 学习到进程终止,认识$?

一、进程创建

1.1.fork函数初识

在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。

#include <unistd.h>
pid_t fork(void);
返回值:子进程中返回0,父进程返回子进程id,出错返回-1

进程调用fork,当控制转移到内核中的fork代码后,内核做:

  • 分配新的内存块和内核数据结构给子进程
  • 将父进程部分数据结构内容拷贝至子进程
  • 添加子进程到系统进程列表当中?
  • fork返回,开始调度器调度

当一个进程调用fork之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以 开始它们自己的旅程,看如下程序。

int main( void )
{
    pid_t pid;
    printf("Before: pid is %d\n", getpid());
    if ( (pid=fork()) == -1 )perror("fork()"),exit(1);
    printf("After:pid is %d, fork return %d\n", getpid(), pid);
    sleep(1);
    return 0;
}
 
运行结果:
[root@localhost linux]# ./a.out
Before: pid is 43676
After:pid is 43676, fork return 43677
After:pid is 43677, fork return 0

这里看到了三行输出,一行before,两行after。进程43676先打印before消息,然后它有打印after。另一个after 消息有43677打印的。注意到进程43677没有打印before,为什么呢?如下图所示:

所以,fork之前父进程独立执行,此时的一行before是父进程执行的,fork之后,父子两个执行流分别执行第一个after同样是父进程执行的,但是第二个after是子进程执行的。注意,fork之后,谁先执行完全由调度器决定。

1.2.fork函数返回值

子进程返回0, 父进程返回的是子进程的pid。

1.3.写时拷贝

通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:

1.为什么要有写时拷贝?

写时拷贝(Copy-on-Write,简称COW)是一种优化策略,通常用于管理共享数据的副本。它的主要目的是在减少资源消耗的同时提高性能。写时拷贝的工作原理是延迟对象的复制,只有在有写操作发生时才执行实际的拷贝操作。

以下是一些使用写时拷贝的常见场景和优势:

  1. 节约内存: 写时拷贝允许多个进程或线程共享同一份数据副本,而不会立即复制整个数据结构。只有在某个进程试图修改数据时,才会为该进程创建数据的独立副本。这样可以在一定程度上减少内存的使用。

  2. 提高性能: 写时拷贝可以减少对共享数据的不必要复制,从而提高程序的性能。因为只有在写入操作时才进行实际的拷贝,读取操作则可以在多个进程之间共享相同的数据副本,减少了不必要的开销。

  3. 并发性: 写时拷贝使得多个进程可以同时读取相同的数据,而无需互斥地访问。只有在有写入操作时,才需要执行复制操作,并在新副本上执行写入。这有助于提高并发性能。

  4. 延迟复制: 写时拷贝的特点是在需要修改数据时才执行复制,而不是在创建副本时。这样可以延迟复制操作,避免在数据被多个进程只读访问时进行不必要的复制。

2.创建子进程的时候,为什么不直接把父进程的数据直接给子进程呢?

  • 父进程的数据可能很大,直接复制给子进程可能会产生大量的开销。而此时子进程又不会使用父进程的数据,此时拷贝就会造成大量消耗。

3.写时拷贝先开辟空间然后再把数据拷贝过去,我已经要开始写入的,直接把空间给我不就行了,反正你拷贝的数据待会我也要修改,还不如不用拷贝,直接叫写时申请不更好吗?

  • 写时拷贝并不是将父进程所有的数据都会进行覆盖,有可能父进程的某些原始数据子进程还会使用到,如果直接只给空间,此时可能无法使用父进程某些数据。

1.4.页表

页表除了虚拟地址和物理地址项,还存在权限一项。

页表的权限字段用于控制对虚拟页面的访问权限,确保系统的安全性和稳定性。这些权限通常包括以下几种:

  1. 读取权限(Read): 允许程序读取虚拟页面中的内容。如果一个进程尝试在没有读取权限的情况下读取该页面,会触发访问异常,通常导致程序终止或引发其他异常。

  2. 写入权限(Write): 允许程序修改虚拟页面中的内容。如果一个进程尝试在没有写入权限的情况下写入该页面,同样会触发异常。写权限的存在有助于保护内存不被未经授权的写操作破坏。

  3. 执行权限(Execute): 允许程序在虚拟页面上执行指令。这是为了防止一些安全漏洞,如缓冲区溢出攻击,通过禁止在某些内存区域执行代码,可以增加系统的安全性。

  4. 访问权限(Access): 这是一个综合了读、写、执行权限的控制。有时候,页表中的权限字段可能被设计为一个比特位组合,用于同时表示多种权限。

这些权限可以在页表的每一项中进行设置,以实现对虚拟内存的灵活控制。操作系统可以在不同的情况下设置不同的权限,以保障系统的稳定性和安全性。例如,操作系统可能会将一些关键的系统页面设置为只读或不可执行,以防止用户进程对其进行修改或执行。我们来看一段代码

#include <stdio.h>
int main()
{
    const char* str = "hello world!";
    *str = 'H';//将'h'改为'H',error
    return 0;
}

上面的代码我们在C语言阶段就学习过,上面的代码是运行错误的,上面的常量字符串是具有常性的,它在进程地址空间中的字符常量区,不能对常量数据做任何修改。常量区的代码为什么能保存常量行,为什么不能修改?因为上面代码中的str保存的是'hello world!'的起始地址,前面我们也提到过,它就是我们的虚拟的地址,当我们将'h'改为'H'时,此时就注定会发生从虚拟地址到物理地址的转化,们将'h'改为'H'就是写入的操作,此时我们页表全向项必须具有'w'权限,如果此时我们没有'w'权限,页表就不会形成映射关系,所以写入失败,所以页表有权限,常量区一般都是被映射到只读的物理内存页,于是他就有了语言当中的常量字符串是不能修改的结论的。

写时拷贝是如何做到的呢?当我们要进行写时拷贝之前,此时父子进程的数据段权限都是只读的,当我们要进行写时拷贝的时候,此时写入就会发生错误,此时就会出现缺页中断,然后操作系统就会删掉父子进程的数据段的只读权限,此时就能写入数据,并且重新形成映射关系,待写入操作完成之后,此时再回复父子进程的数据段的只读权限。

1.5.fork常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子 进程来处理请求。
  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

1.6.fork调用失败的原因

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

二、进程终止

2.1.进程退出场景

我们先来看一段代码

#include <stdio.h>
int main()
{
    int i = 1;
    int j = 2;
    int k = i + j;
    return 0;
}

我上面的代码如何判断程序是正常结束的呢?我们的程序没有打印结果,但是我们可以通过main函数的返回值进行判断,返回值为0,表示进程执行成功,非0,表示失败,所以main函数的返回值,叫做进程的退出码。一旦失败,我们就需要找到失败的原因,通过用不同的返回值数字,表示不同的失败原因。我们通过echo $?查看最近执行的一个程序的退出码。

为什么后两次的退出码是0呢?因为后两次运行的程序不再是我们的代码,而是echo $?,它的退出码是0,所以退出码的工作就是告诉父进程或者bash,我运行成功了或者失败了,但是我们作为学习者,我们肯定不仅仅想知道错误码,我们更想知道程序失败的原因,此时我们就更希望这个错误码能转化为错误描述,这个错误描述我们可以使用语言和系统自带的方法,进行转化,也可以自定义!

1.使用语言和系统自带的方法,进行转化

我们来使用一下,看看结果。

#include <stdio.h>
int main()
{
    int i = 0;
    for(i = 0; i < 200; i++)
    {
        printf("%d:%s\n",i,strerror(i));
    }
    return 0;
}

我们再来看一下运行结果:

从上面的图片我们可以知道,Linux中一共只有134个错误码(0-133),然后我们再看看错误码和错误信息对应。

2.自定义!

运行结果:

main函数return返回的时候,表示进程退出,return 退出码,可以设置退出码的字符串含义。而其他函数的返回值,只仅仅表示该函数结束,仅仅表示函数调用完毕!那么怎么看一个函数是否失败和失败的原因呢?

#include <stdio.h>
#include <errno.h>

int main()
{
    FILE* fp = fopen("1.txt","r");
    printf("%d:%s\n",errno,strerror(errno));
    return errno;
}

运行结果:

函数也具有和进程退出一样的具体的退出原因,我们把这个叫做错误码,可以通过errno获得。errno 是一个全局变量,通常用于在 C 语言中指示函数调用失败时的错误码。它的声明通常包含在 <errno.h> 头文件中。在 C 语言中,当一些函数调用失败时,它们通常会设置 errno 来指示错误的类型。errno 的值通常是一个整数,代表一种特定的错误。可以通过包含 <errno.h> 头文件,并查看 errno 的值来获取函数调用失败的原因。在 C 语言中,errno 是一个全局变量,通常由 C 标准库或底层系统调用设置。因此,如果你自己编写的函数没有直接调用标准库函数或底层系统调用,它可能无法直接使用 errno 获取错误信息。

总结进程退出的场景:

  1. 进程代码执行完,结果是正确的。
  2. 进程代码执行完,结果是错误的。
  3. 进程代码没有执行完,进程出异常了(本质是进程收到了异常信号 kill -l查看信号,每个信号都有不用的编号,不同的信号编号表明异常的原因)。

任何进程最终的执行情况,我们可以使用两个数字表明具体的执行情况:signumber和exit_code。

signumberexit_code进程状态
00进程代码执行完,结果是正确的
01进程代码执行完,结果是错误的
10进程代码没有执行完,进程出异常了(此时结果无意义)
11进程代码没有执行完,进程出异常了(此时结果无意义)

2.2.进程常见退出方法

2.2.1.正常终止(可以通过 echo $? 查看进程退出码):
  • 1. 从main返回(上面提到过,这里就不多解释了)
  • 2. 调用exit(3号手册 - C语言库函数)

我们来使用一下

运行结果:

那我们在其他函数调用exit函数接口,我们上面程序的死循环还能终止吗?

运行结果:

exit可以终止当前正在运行的进程,exit的参数就是退出码,在我们的进程代码中,任何地方调用exit接口,都表示进程退出!

  • 3. _exit(2号手册 - 系统调用)

我们来使用一下

?运行结果:

我们发现_exit功能和我们上面的exit一样,但是他们之间是有区别的。

所以exit会刷新所有标准 I/O 流的缓冲区,而_exit不能刷新所有标准 I/O 流的缓冲区。我们的进程是由操作系统终止的,但是这个终止是用户想要的,所以操作系统就必须为用户提供系统调用_exit,而一个用户想要终止进程,就必须调用系统调用接口,所以我们可以肯定的是exit(库函数)的底层实现是封装了_exit(系统调用)的。所以这些缓冲区是在用户空间(由标准 C 库实现)管理的,而不是在操作系统内核中。如果这些缓冲区是在操作系统内核,那么exit(库函数)和_exit(系统调用)都会刷新缓冲区的,操作系统不会做任何浪费空间,降低效率的事情,如果_exit不会刷新,那么操作系统就根本不会写入,如果写入到操作系统,操作系统一定会刷新缓冲区,事实上,并没有刷新,说明这些缓冲区肯定不是在操作系统内核中。那进程退出的时候,操作系统做了什么呢?释放进程地址空间,释放页表,释放代码和数据空间,但是进程的PCB不能释放,因为我们要获取进程的退出信息。

2.2.2.异常退出:
  • ctrl + c,信号终止

三、进程等待

3.1.进程等待必要性

  • 之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
  • 另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法 杀死一个已经死去的进程。
  • 最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对, 或者是否正常退出。
  • 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息(exit_code和exit_signal)。

3.2.进程等待的方法

为什么要等待呢?

1.父进程通过wait方式,回收子进程的资源(必然必须)

2.通过wait方式,获取子进程的退出信息(可选的)

3.2.1.wait方法
#include<sys/types.h>
#include<sys/wait.h>

pid_t wait(int*status);

功能:
    默认会进行阻塞等待,如果子进程不退出,wait会一直等待
    直到子进程退出,wait才会返回
    wait会等待父进程名下所有的子进程
返回值:
    成功返回被等待进程pid,失败返回-1。
参数:
    输出型参数,获取子进程退出状态,不关心则可以设置成为NULL

首先我们来验证一下wait确实能帮我们回收僵尸进程。

这个代码的含义是:这个程序会运行时间13秒,前5秒父子进程都在运行,再5秒,子进程处于僵尸状态,而父进程依然正常运行,随后父进程就开始回收,回收之后我们就可以看到僵尸状态不存在了,然后再过3秒,父进程就会退出。

fork之后,父子进程谁先运行我们是不确定的,这是由调度器决定,但是我们能确定谁先退出,父进程!

3.2.2.waitpid方法

我们创建一个子进程的目的是为了帮我们完成某些任务,所以我们就需要看子进程完成的情况如何,所以我们就需要获得子进程的退出信息,要获得子进程的退出信息,我们就要使用waitpid函数。

pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
    当正常返回的时候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。

我们来写个代码验证一下

?运行结果:

这里很奇怪,我们上面给我们的子进程设置的退出码是1,但是经过waitpit获取的退出码却是256,为什么?这是因为status保存了退出码和退出信号两个退出信息,即status有自己的格式。

  • 3.2.3.获取子进程status
  • wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
  • 如果传递NULL,表示不关心子进程的退出状态信息。
  • 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
  • status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位):

所以此时次八位存储了一个为1的比特位,表示此时的退出码是1,低八位的最高位我们暂时不使用,由于此时程序没有出现任何异常,程序是正常运行完毕的,所以此时信号编号为0,所以低七位的比特位都是0。那我们的代码要获取退出信息(exit_code和exit_signal),就要使用移位运算符。

?运行结果:

此时运行的结果就符合我们的预期效果,那如果我们想看到进程异常的信息编号呢?我们来修改一下我们的代码,我们让我们的子进程运行的时间更长一点,同时退出码设置为0。

待子进程运行了一段时间,我们使用kill -9杀掉我们的子进程。

进程异常的信息编号就是9,此时的代码没有跑完,而是受到异常的信号而终止运行了。如果使用kill -2呢?

此时的进程异常的信息编号就是2,使用 -2(SIGINT)允许进程优雅地处理终止信号并执行清理任务,而使用 -9(SIGKILL)会强制终止进程,而不给予其执行任何清理的机会。我们再来看看其他代码异常的情况

?运行结果:

在Linux中,异常信号是一种用于通知进程发生了某种事件或错误的机制。每个异常信号都有一个唯一的数字表示,其中8号异常信号对应的是SIGFPE(Floating Point Exception),表示浮点运算错误。当一个进程执行浮点运算时,如果发生了错误,比如试图除以零或者产生了不合法的操作数,就会触发SIGFPE信号。这通常表示程序在执行浮点运算时出现了异常情况,可能是由于代码错误或不当输入引起的。我们再来看看其他代码异常的情况

?运行结果:

在Linux系统中,信号是一种用于通知进程发生特定事件的软件中断。每个信号都有一个唯一的数字标识符,而11号信号对应的是SIGSEGV,也称为段错误(Segmentation Fault)。当进程尝试访问无效的内存地址或执行其他违反内存访问规则的操作时,操作系统会向该进程发送SIGSEGV信号。这通常是由于编程错误引起的,例如空指针引用或数组越界访问。难道我们每次获取子进程的退出结果都要进行位操作嘛?不需要,在Linux或类Unix系统中,当一个子进程终止时,父进程可以通过一些宏来检查子进程的终止状态。这些宏包括 WIFEXITEDWEXITSTATUS,用于判断子进程是否正常终止以及获取其退出码。

在Linux或类Unix系统中,当一个子进程终止时,父进程可以通过一些宏来检查子进程的终止状态。这些宏包括 WIFEXITEDWEXITSTATUS,用于判断子进程是否正常终止以及获取其退出码。

  1. WIFEXITED(status)

    • 如果这个宏的值为真(非零),则表示子进程正常终止。
    • 如果为假(零),则表示子进程非正常终止(比如被信号中断)。
    if (WIFEXITED(status)) {
        // 子进程正常终止
    } else {
        // 子进程非正常终止(异常终止)
    }
    
  2. WEXITSTATUS(status)

    • WIFEXITED(status) 为真时,可以使用 WEXITSTATUS 宏来提取子进程的退出码。
    • 退出码是子进程通过 exit 函数传递给父进程的值,通常用来指示进程的终止状态。退出码的范围通常是 0 到 255。
    if (WIFEXITED(status)) {
        int exit_status = WEXITSTATUS(status);
        // 使用 exit_status 来获取子进程的退出码
    }
    

这两个宏一般用于处理子进程的退出状态,以便父进程能够根据子进程的终止情况采取相应的措施。这样的处理在编写可靠的、能够应对子进程异常终止的父进程代码中非常重要。

问题:为什么获取子进程的退出信息要使用waitpid这个系统调用,然后还要使用宏获取exit_code和exit_signal,为什么不能直接使用定义exit_code和exit_signal呢?

这是因为我们在父进程两个全局变量exit_code和exit_signal时,当子进程退出时,子进程会对exit_code和exit_signal进行设置,以便父进程拿到,但是此时子进程对数据进行了修改,就会发生写时拷贝,此时父进程拿不到子进程的退出信息。因为进程具有独立性,所有的父子进程无法直接互相修改对方的数据之后,让对方看到,因为读取子进程退出信息,本质时读取内核数据!

在子进程运行期间,我们的父进程正在干嘛呢?父进程在进程阻塞等待,此时父进程什么也没干,那我们就可以让我们的父进程进行非阻塞等待。此时就要涉及到waitpid的第三个参数options

#include <sys/types.h>
#include <sys/wait.h>

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

其中,optionswaitpid 的第三个参数,用于指定一些选项,以影响等待子进程的行为。以下是 waitpid 可接受的一些选项:

  • WNOHANG

    • 如果指定了 WNOHANGwaitpid 将以非阻塞方式运行。即使没有子进程退出,它也会立即返回,而不会等待。
    • 如果子进程已经退出,waitpid 返回子进程的PID。如果没有子进程退出,返回0。

在上面的图片当中,张三一直打电话给李四的时候,此时张三什么事情都没有做,而张三在后面轮询期间多次给李四打电话的过程期间,期间每次打一个电话获得一次李四的答复,此时获得答复后张三就可以忙自己的事情,如此往复,张三整个期间就都能做很多的事情,同样的此时父进程就可以在等待子进程退出信息的过程中,可以做其他的事情,现在我们来验证一下。

?运行结果:

在我们当前的调度器,父进程是优先于子进程运行的,所以我们会看到父进程的打印输出语句先执行,这里最后输出了两句是因为在子进程在运行循环之后,我们让我们的子进程休眠了一秒,此时子进程的输出工作已经在前5秒已经输出完成了,在上面的例子中,我们明显的观察到了父进程在获取子进程的退出信息过程中,可以做其他的事情,具体可以做那些事呢?我们来举例一下。

?运行结果:

四、进程程序替换

4.1.直接说原理 -- 什么是程序替换

首先我们知道我们的程序,只能执行我们的代码,如果我们创建的子进程想执行其他程序的代码呢?此时就要使用我们的进程程序替换。

进程程序替换(Process Program Replacement)是指一个正在运行的进程,通过加载新的程序代码替代原有的程序代码。进程程序替换的优势在于,它允许一个进程在不产生新进程的情况下改变其执行的程序,从而实现动态的程序加载和切换,而这个替换的工作,存在很多的数据变更,而操作系统是软硬件资源的管理者,所以实现替换工作一定会有对应的系统调用接口。

4.2.直接写代码 -- 最简单的单进程的demo代码 -- exec函数的一个接口execl

int excel(const *path, const char *arg, ...)
  1. path: 要执行的程序的路径。这应该是一个以null结尾的字符串,表示要执行的可执行文件的路径。通常,你需要提供程序的绝对路径或相对路径。

  2. arg0, arg1, ..., argn: 这是可执行程序的命令行参数。它们是一个以NULL结尾的字符串列表,其中第一个参数arg0通常是可执行程序的名称。后面的参数是传递给程序的命令行参数。最后一个参数必须是NULL,它标志着参数列表的结束。

下面我就来写一个代码来使用一下excel接口。

?运行结果:

此时我们就使用了我们的程序执行了LInux特定的指令ls。但是我们发现我们程序输出的的时候没有输出exec end...,这就说明程序替换一旦成功,excel后续的代码就不再执行,因为被替换掉了,excel函数的返回值是一个整数。在正常情况下,excel函数不会返回,因为它会将当前进程的映像替换为新的程序。如果excel函数返回,这通常表示执行出现了错误。返回值为-1时,表示发生了错误。此时,可以通过查看全局变量errno来获取更多关于错误的信息。errno是一个在头文件 <errno.h> 中声明的全局变量,它包含了最后一次系统调用发生错误的错误代码,随后我们将ls的路径修改一下,看看输出的结果

?运行结果:

此时程序获取了我们的退出码,那说明excel函数运行肯定失败了。上面我们提到程序替换是没有产生新的进程的,那我们光说也不行呀!先介绍一下top指令。top 是一个在Linux系统下用于动态监视系统运行状态的命令行工具。它提供了实时的系统性能信息,包括 CPU 使用情况、内存使用情况、进程列表等。top 不会自动退出,它会一直运行,定期更新屏幕上的信息。下面代码展示。

?运行结果:

此时就验证了我们上面的结论。随后再提出一个问题:创建一个进程,是先创建pcb,还是先加载代码和数据了。这里我们可以通过一个实例说明,我们在高考完之后就会进入一个大写学,在被大学录取之后,是大学先获取我们的个人信息呢?还是我们要先到大学去呢?很明显,肯定是大学先获取我们的个人信息,因为在我们一进入大学之后,我们就知道我们当前所在的班级,以及每周要上什么课程,都已经被学校安排的明明白白了。所以创建一个进程也是如此,先创建我们的pcb,随后在加载我们的程序和代码。上面我们就提到进程程序替换是指一个正在运行的进程,通过加载新的程序代码替代原有的程序代码,所以程序替换做的本质工作就是加载

4.3.更改为多线程版本

我们来看一下让我们的子进程执行替换的工作

?运行结果:

我们为什么要使用子进程进行替换工作呢?而让我们的父进程去阻塞等待呢?因为父进程能获取到子进程的退出信息,能知道替换的工作执行结果。为什么替换工作为什么没有影响我们的父进程呢?因为进程具有独立性,代码和数据在进行加载的时候,此时就进行了修改,就会发生写时拷贝,随后数据和代码父子进程各种拥有一份。那shell是如何运行一个指令的呢?

  1. 创建新进程: 一旦找到了可执行文件,Shell会创建一个新的进程来执行该文件。这个新进程是原始Shell进程的子进程,bash会waitpid等待子进程的退出信息。

  2. 加载可执行文件: 新的进程会加载你输入的命令对应的可执行文件到内存中,也就是进程程序替换。

  3. 执行命令:?子进程进程开始执行加载的可执行文件。这是实际的命令运行阶段。

  4. 命令完成:子进程执行完毕,bash进程获取子进程的退出信息,随后子进程退出销毁。

4.4.学习各种exec的接口

4.4.1替换函数

其实有七种以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[]);

// 系统调用
int execve(const char *filename, char *const argv[], char *const envp[]);
4.4.2.函数解释
  • 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
  • 如果调用出错则返回-1
  • 所以exec函数只有出错的返回值而没有成功的返回值。
4.4.3.命名理解

这些函数原型看起来很容易混,但只要掌握了规律就很好记。

  • l(list) : 表示参数采用列表
  • v(vector) : 参数用数组
  • p(path) : 有p自动搜索环境变量PATH
  • e(env) : 表示自己维护环境变量

exec调用举例如下:

#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的,带p的
    execvpe("ps", argv, envp);
    
	// 带e的,需要自己组装环境变量
	execve("/bin/ps", argv, envp);
	exit(0);
}

题外话:exec*可以执行系统的指令(程序),那可以执行我们自己的程序吗?当然可以

文件扩展名 .cc 通常表示一个C++源代码文件。在编程领域,C++是一种通用的、面向对象的编程语言,而 .cc 扩展名通常用于标识C++源代码文件。其他常见的C++源代码文件扩展名还包括 .cpp.cxx。随后我们创建一个.cc的C++源代码文件

随后编译可以形成我们的可执行文件,然后我们运行一下。

所以今天我们就要在makefile里面一次形成两个可执行程序了,那怎么做呢?我们想使C程序把C++程序调起来。我们首先就要修改一下我们的makefile文件。

mytest:mytest.cc
    g++ -o $@ $^ std=c++11

myprocess:myprocess.c
    gcc -o $@ $^ 

.PHONY=clean
clean:
    rm -f myprocess mytest

然后我们make编译一下

只形成了一个可执行程序文件,而且是c++可执行程序,我们试着把make里面c语言提到最前面

myprocess:myprocess.c
    gcc -o $@ $^ 

mytest:mytest.cc
    g++ -o $@ $^ std=c++11

.PHONY=clean
clean:
    rm -f myprocess mytest

然后我们make编译一下,看看结果

此时依然之形成了一个可执行程序文件,只不过此时是c语言的可执行文件。为什么呢?因为makefile在从上到下扫描的时候,只会形成一个可执行程序。根据上面的结论,我们可以得出那个可执行程序放在最前面,那个可执行就会被执行,而后面的那个就不会执行。那如果我们想要通过makefile形成多个可执行程序呢?在Makefile中,如果你想生成多个可执行程序,可以在Makefile中定义多个目标(target)。每个目标对应一个可执行程序。以下是一个简单的例子:

.PHONY:all # 伪目标,总是被执行的
all: program1 program2 # all依赖于program1 program2
                       # 就要形成program1 program2
# 没有依赖方法
program1: source1.c
    gcc -o program1 source1.c

program2: source2.c
    gcc -o program2 source2.c

.PHONY=clean
clean:
    rm -f program1 program2

在这个例子中,all 是默认目标,它依赖于 program1program2 这两个目标。当你运行 make 命令时,它会首先构建 program1program2。

.PHONY:all
all:myprocess mytest

myprocess:myprocess.c
    gcc -o $@ $^ 

mytest:mytest.cc
    g++ -o $@ $^ std=c++11

.PHONY=clean
clean:
    rm -f myprocess mytest

此时我们再make编译一下我们的文件。

此时就形成了两个可执行程序了,接下里我们就使用C程序把C++程序调起来,直接看代码

上面我们才传入参数的时候带入了选项,我们想观察一下C++程序有没有接收到C语言程序传入的命令行参数,我们来修改C++程序代码

然后我们来make编译运行一下

这样我们就用了C语言代码调用了C++的代码。除了能调用C++代码,还能调用其他语言的代码吗?我们来试一下shell脚本,它的后缀名是.sh

#!/user/bin/bash

echo "hello shell"
echo "hello shell"
echo "hello shell"
echo "hello shell"
echo "hello shell"

然后我们再来运行一下shell脚本。

然后我们再来修改一下C语言代码,使其能调用我们的shell脚本

excel("/usr/bin/bash","bash","test.sh",NULL);

然后我们再运行一下

所以exec*可以执行系统的指令(程序),也可以执行我们自己写的程序(无论什么语言,只要能在Linux下运行即可)。但是为什么呢?因为所有的语言运行之后,都是进程,都拥有自己的代码和数据,而进程替换只有有代码和数据就可以替换!对于exce来说,只有数据和代码,没有语言的区别,它只看进程。我们上面的C++代码可以获取程序的命令行参数,那现在也就可以获取环境变量了,它的环境变量就可以从C语言程序中获取来,所有我们就可以研究带'e'的程序替换。直接看代码

然后我们运行就发现此时同为子进程的C++程序就能打印出父进程所有的环境变量。但是此时我们的C语言代码是没有传入环境参数的,说明没传它就默认能拿到环境变量,怎么做到的呢?如果我们导入一个其他的环境变量呢?它能拿到吗?我们导入一个环境变量导给bash。./myprocess的时候会创建一个子进程,myprocess就是bash的子进程,此时myprocess就能拿到父进程导入的环境变量,而我们的mprocess还创建了一个子进程,子进程完成的任务是进程替工作,但是它是子进程,它也会继承父进程的环境变量。

那为什么呢?因为命令行参数和环境变量都在进程地址空间,而创建的这个子进程会继承父进程的进程地址空间的,所以我们的子进程可以直接拿到父进程的环境变量,所以Linux系统默认可以通过地址空间继承的方式,让所有的子进程拿到环境变量。所以进程替换,不会替换环境变量数据

1.如果我们想让子进程继承全部的环境变量,直接就能拿到。

2.如果我们想单纯的新增呢?putebv

putenv函数是一个C标准库中的函数,用于设置环境变量。putenv谁调用这个函数,就把这个环境变量导入到谁的进程地址空间里。

此时putenv导入到myprocess程序里,所以bash进程不会拿到这个环境变量,而C++程序作为myprocess的子进程,会拿到这个环境变量,我们来看一下运行结果。

3.如果我们想设置全新的环境变量(覆盖方式)呢?使用exce带'e'的接口

?运行结果:

事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册第2节,其它函数在 man手册第3节。这些函数之间的关系如下图所示。 下图exec函数族 一个完整的例子:

五、自定义shell编写

用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左 向右移动。shell从用户读入字符串"ls"。shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结束。

然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。 所以要写一个shell,需要循环以下过程:

  • 1. 获取命令行
  • 2. 解析命令行
  • 3. 建立一个子进程(fork)
  • 4. 替换子进程(execvp)
  • 5. 父进程等待子进程退出(wait)

根据这些思路,和我们前面的学的技术,就可以自己来实现一个shell了。

我们每次使用滴时候都会看到[xyc@xyc-alicloud myshell]$,它是一个提示符包括[用户名@主机名 所处路径]$,然后再输入我们的指令,我们的指令其实是一个命令行字符串,所以首先我们就要输出提示符并获取用户输入的命令字符串"ls -a -l"。要获取我们的提示符的相关信息我们都可以调用我们的系统调用,它这里面都可以获取这些信息,比如我们的getcwd获取所处路径

但是现在我们不想使用系统调用呢?我们其实还可以使用环境变量,环境变量里面包含了提示符的信息,并且环境变量能随着用户和工作目录的改变而改变,这样就可以让我们的shell功能更加高效。

我们可以通过getenv获取这些环境变量。

然后我们来看代码

#include <stdio.h>
#include <stdlib.h>

const char* info[3];//全局数组

void getInfo()
{
  char* user = getenv("USER");
  if(user) info[0] = user;
  else info[0] = "None";
  
  char* hostname = getenv("HOSTNAME");
  if(hostname) info[1] = hostname;
  else info[1] = "None";

  char* pwd = getenv("PWD");
  if(pwd) info[2] = pwd;
  else info[2] = "None";
}


int main()
{
  //输出提示符并获取用户输入的命令字符串"ls -a -l"
  getInfo();
  printf("[%s@%s %s]$",info[0],info[1],info[2]);
  return 0;
}

运行结果:

然后我们再来获取用户的输入,先来使用我们的scanf函数,看看能不能使用?这里要注意我们自己写的shell删除键是ctrl + Backspace。

运行结果:

此时我们发现我们只能获取【ls】,剩余的字符不能获取,这是因为scanf默认输入的时候以空格或者换行为分隔符,当读取到空格或者换行的时候,就会认为当前的输入已经完成,所以这里我们就不使用scanf了,使用fgets函数。

运行结果:

此时确实获取了用户输入的字符串,但是为什么中间还多了一个空行呢?是不是因为我们打印输出的时候带了一个换行符呢?不是,这里有一个空行,说明我们换行了两次,这是因为用户在输入ls -a -l时还敲了一下回车,加上打印输出的时候带了一个换行符,所以这里会打印一个空行,那我们想要解决就要读取到用户的换行符,这里我们可以将\n修改为\0。

运行结果:

现在我们把上面的功能封装一下,命名为交互。

void interactive(char out[], int size)
{
 //输出提示符并获取用户输入的命令字符串"ls -a -l"
  getInfo();
  printf("[%s@%s %s]$",info[0],info[1],info[2]);
  
  //获取用户输入的字符串
  //scanf("%s",commandline);
  fgets(out,size,stdin);
  //将\n修改成\0
  out[strlen(out) - 1] = '\0';//也能处理空串的情况
}
int main()
{
  //1.打印命令行提示符,获取用户输入的命令字符串
  char commandline[SIZE];
  interactive(commandline, SIZE);
  return 0;
}

现在我们获取了用户输入的命令字符串,现在我们想要执行它,就必须要分隔字符串,这里可以使用strtok函数。

#include <string.h> char *strtok(char *str, const char *delim);

  • str 是要拆分的字符串,第一次调用时传入要拆分的字符串,之后传入 NULL 表示继续使用上一次拆分的字符串。
  • delim 是用来指定分隔符的字符串,即根据哪些字符来拆分字符串。

strtok 函数返回一个指向拆分后的子字符串的指针,如果没有找到子字符串,则返回 NULL。每次调用 strtok 都返回拆分后的一个子字符串,直到没有更多的子字符串为止。注意,strtok 在每次调用时会修改原始字符串,需要使用?NULL 继续拆分。

这里我们来解释一下我们上面的写法,第一次strtok分隔的字符【ls】存放到了argv[0]的位置,随后i就变为1,因为strtok在每次调用时会修改原始字符串,需要使用NULL继续拆分,随后进入while循环,argv[1]的位置就存放了【-a】,argv[2]的位置存放了【-l】,随后继续自增,然后此时就没有找到字符,由于这里是等号【=】,就将NULL返回给了argv[3],此时while循环的条件就不满足,循环也就退出了。

运行结果:

此时我们就分隔了我们的用户输入的命令行字符串了,这里我们也封装一下,命名为切割。

void Split(char in[])
{
  int i = 0;
  argv[i++] = strtok(in, " ");
  while(argv[i++] = strtok(NULL, " "));//这里写成=,不是==
}

int main()
{
  //1.打印命令行提示符,获取用户输入的命令字符串
  char commandline[SIZE];
  interactive(commandline, SIZE);
 
  //2.对命令行字符串进行分隔
  Split(commandline);
  int i = 0;
  for(i = 0; argv[i]; ++i)
  {
    printf("argv[%d]:%s\n", i, argv[i]);
  }
  return 0;
}

现在我们这里就要执行我们的命令了,这里我们不能直接调用我们的程序替换接口,而应该创建一个子进程让它去执行命令。

运行结果:

现在我们对执行过程也做一下封装,命名execute。

void Execute()
{
  pid_t id = fork();
  if(id == 0)
  {
    //子进程执行命令
    execvp(argv[0],argv);
    exit(1);
  }
  //父进程回收子进程
  waitpid(id,NULL,0);
}
int main()
{
  //1.打印命令行提示符,获取用户输入的命令字符串
  char commandline[SIZE];
  interactive(commandline, SIZE);
 
  //2.对命令行字符串进行分隔
  Split(commandline);
  //3.执行命令
  Execute();
  return 0;
}

但是我们发现我们的命令行只能做一次执行,所以这里我们想要多次循环就要使用循环。

运行结果:

此时我们的命令行解释器shell就可以一直运行,但是当我们运行cd命令为什么就跑不起来呢?

cd 是一个特殊的命令,它用于改变当前工作目录。然而,cd 命令通常需要由父进程直接来执行,而不能通过创建子进程的方式来运行。如果你的命令行解释器是通过创建子进程来执行用户输入的命令,那么 cd 命令可能无法按预期工作,因为它改变的是子进程的工作目录,而不影响父进程的工作目录。每个子进程都有自己的工作目录,改变其中一个不会影响其他进程。为了使 cd 命令在你的 shell 中生效,你可能需要在父进程中执行该命令,而不是创建一个子进程来执行。这样,cd 命令就能够改变你的 shell 进程的当前工作目录。

int BuildCmd()
{
  int ret = 0;
  //1.检查是否是内键命令,是1,否0
  if(strcmp(argv[0],"cd") == 0)
  {
    ret = 1;
    //执行cd命令
    char* target = argv[1];//cd xxx or cd
    if(!target) target = getenv("HOME"); 
    chdir(target);//更改当前进程的工作目录
  }
  return ret;
}
int main()
{
  while(1)
  {
    //1.打印命令行提示符,获取用户输入的命令字符串
    char commandline[SIZE];
    interactive(commandline, SIZE);
 
    //2.对命令行字符串进行分隔
    Split(commandline);
  
    //3.处理内键命令 - 由父进程执行
    int n = BuildCmd();
    if(n) continue;
    
    //4.执行命令
    Execute();
  }
  return 0;
}

运行结果:

上面已经可以做到目录的切换,但是还是有一点问题,首先是空串的处理,其次是命令行提示符哪里工作目录没有改变,这里是因为环境变量没有变化,虽然当前进程的工作目录发生了改变,但是我们的环境变量并没有更新。

运行结果:

但是我们发现我们这里的上级目录..没有处理好,在这里需要使用我们的getcwd函数。

我们先来看一下关于getcwd的使用。

运行结果:

默认情况下,我们的子进程会继承父进程的环境变量,随后我们更改当前工作目录为上级目录,便就能用getcwd获取到该目录,而不是我们的..了。

运行结果:

随后我们导入一下环境变量,进而用env查询一下,可是我们没有查到,为什么呢?

这是因为我们是在子进程中执行了export,只在子进程导入了环境变量,而后通过 env 在父进程中查询,是看不到这个环境变量的。所以导入环境变量这个过程我们也要让父进程执行。? ??

我们运行后导入我们的环境变量确实存在了,但是当我们再次执行其他命令的时候,再次env查询环境变量却有没有了,为什么呢?这是因为我们argv指向是用户输入的commandline分隔的字串,当我们执行后续指令,此时用户输入的命令就变化了,argv所指向的分隔的字串也发生了变化,此时环境变量就会被一直覆盖,所以我们就需要一个数组保存之前的导入的环境变量。

此时我们的环境变量就不会被覆盖了,然后我们再来执行我们的echo命令,发现也不能执行,说明echo也是内键命令,所以我们的代码也要修改,我们先来看看echo的特点。

echo       # 输出空行
echo $?    # 输出退出码
echo $HOME # 输出环境变量
echo ""    # 输出字符串

代码实现:

else if(strcmp(argv[0],"echo") == 0)
  {
    ret = 1;
    if(argv[1] == NULL)
    {
      printf("\n");//输出空行
    }
    else 
    {
      if(argv[1][0] == '$')
      {
        if(argv[1][1] == '?')
        {
          printf("%d\n",lastcode);//输出退出码
          lastcode = 0;
        }
        else
        {
          char* e = getenv(argv[1] + 1);
          if(e) printf("%s\n",e);
        }
      }
      else
      { 
        printf("%s\n",argv[1]);//输出字符串
      }
    }
  }

运行结果:

现在我们的shell功能已经齐全,但是我们发现没有颜色区别,我们再来修改一下

运行结果:

此时我们就拥有了我们的颜色。

但是此时我们的输出重定项和管道都不能使用,我们后面再添加。

整体shell实现代码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h> 
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

#define SIZE 1024
#define MAX_ARGC 64

const char* info[3];
char* argv[MAX_ARGC];//保存分隔字串
char pwd[SIZE];//更新环境变量

char env[SIZE];//环境变量表

int lastcode = 0;

void getInfo()
{
  char* user = getenv("USER");
  if(user) info[0] = user;
  else info[0] = "None";
  
  char* hostname = getenv("HOSTNAME");
  if(hostname) info[1] = hostname;
  else info[1] = "None";

  char* pwd = getenv("PWD");
  if(pwd) info[2] = pwd;
  else info[2] = "None";
}

int interactive(char out[], int size)
{
 //输出提示符并获取用户输入的命令字符串"ls -a -l"
  getInfo();
  printf("[%s@%s %s]$ ",info[0],info[1],info[2]);
  
  //获取用户输入的字符串
  //scanf("%s",commandline);
  fgets(out,size,stdin);
  //将\n修改成\0
  out[strlen(out) - 1] = '\0';//也能处理空串的情况
  
  return strlen(out);
}
void Split(char in[])
{
  int i = 0;
  argv[i++] = strtok(in, " ");
  while(argv[i++] = strtok(NULL, " "));//这里写成=,不是==
  if(strcmp(argv[0],"ls") == 0)
  {
    argv[i-1] = (char*)"--color";
    argv[i] = NULL;
  }
}

void Execute()
{
  pid_t id = fork();
  if(id == 0)
  {
    //子进程执行命令
    execvp(argv[0],argv);
    exit(1);
  }
  //父进程回收子进程
  int status = 0;
  pid_t rid = waitpid(id,&status,0);
  if(rid == id) lastcode = WEXITSTATUS(status);//获取进程退出码
}
int BuildCmd()
{
  int ret = 0;
  //1.检查是否是内键命令,是1,否0
  if(strcmp(argv[0],"cd") == 0)
  {
    ret = 1;
    //执行cd命令
    char* target = argv[1];//cd xxx or cd
    if(!target) target = getenv("HOME"); 
    chdir(target);//更改当前进程的工作目录
    char temp[1024];
    getcwd(temp,1024);//使用绝对路径
    snprintf(pwd,SIZE,"PWD=%s",temp);//更新环境变量
    putenv(pwd);
  }
  else if(strcmp(argv[0],"export") == 0)
  {
    ret = 1;
    if(argv[1]) 
    {
      strcpy(env,argv[1]);
      putenv(env);//导入环境变量
    }
  }
  else if(strcmp(argv[0],"echo") == 0)
  {
    ret = 1;
    if(argv[1] == NULL)
    {
      printf("\n");//输出空行
    }
    else 
    {
      if(argv[1][0] == '$')
      {
        if(argv[1][1] == '?')
        {
          printf("%d\n",lastcode);//输出退出码
          lastcode = 0;
        }
        else
        {
          char* e = getenv(argv[1] + 1);
          if(e) printf("%s\n",e);
        }
      }
      else
      { 
        printf("%s\n",argv[1]);//输出字符串
      }
    }
  }
  return ret;
}
int main()
{
  while(1)
  {
    //1.打印命令行提示符,获取用户输入的命令字符串
    char commandline[SIZE];
    int n = interactive(commandline, SIZE);
    if(!n) continue;
    //2.对命令行字符串进行分隔
    Split(commandline);
  
    //3.处理内键命令 - 由父进程执行
    n = BuildCmd();
    if(n) continue;
    
    //4.执行命令
    Execute();
  }
  return 0;
}

文章来源:https://blog.csdn.net/qq_64446981/article/details/135130914
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。