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

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

本节重点:


  • 学习进程创建,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 语言中指示函数调用失败时的错误码。它的声明通常包含在  头文件中。在 C 语言中,当一些函数调用失败时,它们通常会设置 errno 来指示错误的类型。errno 的值通常是一个整数,代表一种特定的错误。可以通过包含  头文件,并查看 errno 的值来获取函数调用失败的原因。在 C 语言中,errno 是一个全局变量,通常由 C 标准库或底层系统调用设置。因此,如果你自己编写的函数没有直接调用标准库函数或底层系统调用,它可能无法直接使用 errno 获取错误信息。


总结进程退出的场景:


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


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

signumber exit_code 进程状态
0 0 进程代码执行完,结果是正确的
0 1 进程代码执行完,结果是错误的
1 0 进程代码没有执行完,进程出异常了(此时结果无意义)
1 1 进程代码没有执行完,进程出异常了(此时结果无意义)


2.2.进程常见退出方法


2.2.1.正常终止(可以通过 echo $? 查看进程退出码):


  • 1. 从main返回(上面提到过,这里就不多解释了)


return退出 return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数。


  • 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不能释放,因为我们要获取进程的退出信息。


_exit函数

#include <unistd.h>
void _exit(int status);
参数:status 定义了进程的终止状态,父进程通过wait来获取该值


说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行$?发现返回值 是255。


exit函数

#include <unistd.h>
void exit(int status);
//exit最后也会调用_exit, 但在调用exit之前,还做了其他工作:


  • 1. 执行用户通过 atexit或on_exit定义的清理函数。
  • 2. 关闭所有打开的流,所有的缓存数据均被写入
  • 3. 调用_exit


2.2.2.异常退出:


  • ctrl + c,信号终止


三、进程等待


3.1.进程等待必要性


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


3.2.进程等待的方法


为什么要等待呢?


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

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


【打造你自己的Shell:编写定制化命令行体验】(二):https://developer.aliyun.com/article/1425817

相关文章
|
6月前
|
Shell
【打造你自己的Shell:编写定制化命令行体验】(四)
【打造你自己的Shell:编写定制化命令行体验】
|
6月前
|
存储 Unix Shell
【打造你自己的Shell:编写定制化命令行体验】(二)
【打造你自己的Shell:编写定制化命令行体验】
|
4月前
|
Java Shell Linux
【Linux】手把手教你做一个简易shell(命令行解释器)
【Linux】手把手教你做一个简易shell(命令行解释器)
79 0
|
5月前
|
监控 Unix Shell
探秘GNU/Linux Shell:命令行的魔法世界
探秘GNU/Linux Shell:命令行的魔法世界
|
6月前
|
Shell Linux
【linux课设】自主实现shell命令行解释器
【linux课设】自主实现shell命令行解释器
|
6月前
|
Shell
【shell】shell命令行放在变量中执行以及变量的常用方法
【shell】shell命令行放在变量中执行以及变量的常用方法
|
6月前
|
存储 Shell Linux
【Shell 命令集合 系统设置 】Linux 将参数作为命令行输入 eval命令 使用指南
【Shell 命令集合 系统设置 】Linux 将参数作为命令行输入 eval命令 使用指南
102 0
|
6月前
|
Shell Linux C语言
【打造你自己的Shell:编写定制化命令行体验】(三)
【打造你自己的Shell:编写定制化命令行体验】
|
6月前
|
Shell Linux
Linux之简单的Shell命令行解释器
Linux之简单的Shell命令行解释器
84 0
下一篇
无影云桌面