【Linux我做主】进程实践:手动实现Shell

进程实践:手动实现Shell

  • 手动实现Shell
  • github地址
  • [0. 前言](#0. 前言)
  • [一、Shell 基础架构与 Makefile 工程](#一、Shell 基础架构与 Makefile 工程)
    • [1. 创建工作目录与测试Makefile文件](#1. 创建工作目录与测试Makefile文件)
    • [2. Shell 基础架构](#2. Shell 基础架构)
  • 二、交互问题
    • [1. 命令行格式定制化](#1. 命令行格式定制化)
    • [2. 读取输入的命令](#2. 读取输入的命令)
    • [3. 将以上逻辑抽象为 interact 交互函数](#3. 将以上逻辑抽象为 interact 交互函数)
  • 三、字符串解析问题
    • [1. 字符串解析函数](#1. 字符串解析函数)
    • [2. 解析字符串后的执行逻辑](#2. 解析字符串后的执行逻辑)
  • 四、内建命令的执行
    • [1. 命令执行总体框架](#1. 命令执行总体框架)
    • [2. 内建命令 cd 的执行](#2. 内建命令 cd 的执行)
    • [3. 内建命令 export 的执行](#3. 内建命令 export 的执行)
    • [4. 内建命令 echo 的执行](#4. 内建命令 echo 的执行)
    • 内建命令的执行完整代码实现与执行结果
  • 五、普通命令的执行
  • 六、改进事项
    • [1. 进程替换出错时提示错误信息](#1. 进程替换出错时提示错误信息)
    • [3. 父进程等待结束后保存子进程的退出码](#3. 父进程等待结束后保存子进程的退出码)
    • [5. 为ls命令加上颜色高亮显示](#5. 为ls命令加上颜色高亮显示)
    • [6. shell 进程结束后释放环境变量表避免内存泄漏](#6. shell 进程结束后释放环境变量表避免内存泄漏)
  • 七、完整代码实现
  • 结语

手动实现Shell

github地址

有梦想的电信狗

0. 前言

在学习 Linux 系统编程的过程中,Shell 是一个无法绕开的核心组件。它既是我们日常与操作系统交互最频繁的工具,也是理解 Linux 体系结构、进程模型、环境变量管理、程序加载与替换机制等关键知识点的窗口。

然而,光使用 Shell 是远远不够的。
只有亲手实现一个 Shell,才能真正理解:

  • 为什么命令能够被识别?
  • 普通命令与内建命令有什么本质区别?
  • Shell 如何解析输入?参数是如何传递的?
  • 程序替换(exec)到底做了什么?
  • cd、export、echo 为什么不能交给子进程执行?
  • 环境变量是如何在多个进程间传递的?
  • 为什么每次 fork 之后都必须 wait?
  • 我们平时敲的 ls --color 背后发生了什么?

本篇文章将带你从零开始,不依赖任何复杂库,仅使用 C 语言与系统调用,手动实现一个具备基本功能的小型 Shell,包括:

  • 命令行提示符绘制
  • 字符串读取与解析
  • 内建命令实现(cd / echo / export)
  • 子进程创建与普通命令执行
  • 环境变量扩展
  • 错误处理与退出码管理
  • 内存管理与代码结构优化

每一个功能都遵循 从原理 → 代码 → 输出结果 的结构进行讲解,确保循序渐进、清晰易懂。

希望通过本文,你不仅能掌握构建一个 Shell 所需的全部知识链路,也能在系统编程能力、对 Linux 的理解深度等方面获得质的提升。


一、Shell 基础架构与 Makefile 工程

1. 创建工作目录与测试Makefile文件

  • myShell项目结构如下所有的文件都处于同一级目录
makefile 复制代码
# Makefile 文件的内容
myshell:myshell.c
	@gcc -o $@ $^ -std=c99

.PHONY:clean
clean:
	@rm -f myshell

单文件版本的Makefile极其简单,输入make指令,myShell正确生成


2. Shell 基础架构

基础架构代码如下

cpp 复制代码
int main()
{
    // shell 本质是一个死循环
    while (!quit)
    {
        // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
        
        // 2. 对输入的命令进行命令行解析,,拆分为 指令名 + 选项
				// 2.5 debug 时使用,验证解析出来的字符串是否正确

        // 3. 判断是否是内建命令,内建命令调用 Shell 内部的函数执行

        // 4. 非内建命令,fork 出子进程, 执行命令
    }
    return 0;
}

Shell 程序本质是一个死循环工作的基本流程如下
循环:读取 → 解析 → 执行 → 再次等待输入

  1. 打印提示符并读取输入
  2. 解析命令,对命令进行分割解析,并返回参数的个数
  3. 判断是否是内建命令 :内建命令直接在父进程 Shell 内部执行
  4. 如果不是,fork 创建子进程执行外部命令
  5. 重复直到 quit 被置为 true

二、交互问题

1. 命令行格式定制化

命令行格式为

  • 用户名@主机名:当前路径 命令行提示符($/#)
  • 因此我们需要在终端中打印出相应的命令行格式

代码如下

cpp 复制代码
#define LEFT "{"
#define RIGHT "}"
#define LABEL_ROOT "#"
#define LABEL_USER "$"

int quit = 0;

#define LINE_SIZE 1024

char pwd[LINE_SIZE];  // 存储当前路径


const char* getUserName()
{
    return getenv("USER");
}

const char* getHostName()
{
    return getenv("HOSTNAME");
}

void getPwd()
{
    getcwd(pwd, sizeof(pwd));
}

int main()
{
    while(!quit)
    {
        getPwd();
        if (strcmp(getUserName(), "root") == 0)
            printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_ROOT, getUserName(), pwd);
        else
            printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_USER, getUserName(), pwd);
        
        // ...
    }
}

PSprintf("aaa""bbb");输出的结果为"aaabbb",原因是C语言中相邻的字符串具有自动连接特性

因此我们可以使用宏定义拼接多个字符串实现更灵活的格式化输出

解释

根据命令行格式为用户名@主机名:当前路径 命令行提示符($/#),我们使用printf函数进行格式化打印

  • 宏定义 :定义命令行的显示格式,使用宏防止代码硬编码打印命令行时加上括号,与系统默认的命令行作区分

    cpp 复制代码
      #define LEFT "{"  		// 控制括号格式
      #define RIGHT "}"
      #define LABEL_ROOT "#"  // 控制不同用户的命令行提示符
      #define LABEL_USER "$"
      int quit = 0;  			// 全局控制,控制命令行的退出状态
      
      #define LINE_SIZE 1024  // 当前路径字符的最大个数

  • 工具函数 :用户名、主机名、当前工作路径我们都从环境变量中获取

    cpp 复制代码
      char pwd[LINE_SIZE];  // 全局变量 存储当前路径
      
      const char* getUserName() { return getenv("USER"); }
      
      const char* getHostName() { return getenv("HOSTNAME"); }
      
      void getPwd() { getcwd(pwd, sizeof(pwd)); }
    • 这里解释一下为什么我的主机名是写死的

      cpp 复制代码
        // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
        // 每次交互前 调用 getPwd 刷新当前路径,保证当前路径正确
        getPwd();
        if (strcmp(getUserName(), "root") == 0)
            printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_ROOT, getUserName(), pwd);
        else
            printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_USER, getUserName(), pwd);
      • 因为我使用的操作系统是 ubuntu22.04 ,环境变量表中没有 HOSTNAME 这一项,因此我只能硬编码。读者的操作系统中如果环境变量中有 HOSTNAME 字段的话,将主机名改为非硬编码更好,改为如下即可

      cpp 复制代码
        // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
        // 每次交互前 调用 getPwd 刷新当前路径,保证当前路径正确
        if (strcmp(getUserName(), "root") == 0)
            printf(LEFT "%s@%s:%s" RIGHT LABEL_ROOT, RIGHT LABEL_ROOT, getUserName(), pwd);
        else
            printf(LEFT "%s@%s:%s" RIGHT LABEL_ROOT, RIGHT LABEL_ROOT, getUserName(), pwd);

  • 为什么获取当前路径后要将其存在全局变量pwd中 ,这是为后文实现内建命令 cd 做的准备,后文解释

    cpp 复制代码
      char pwd[LINE_SIZE];  // 全局变量 存储当前路径
      void getPwd()
      {
          getcwd(pwd, sizeof(pwd));
      }

主函数和运行结果如下

cpp 复制代码
// 正确运行需要补充完整其他变量和函数
int main()
{
    // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
    getPwd();
    if (strcmp(getUserName(), "root") == 0)
        printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_ROOT, getUserName(), pwd);
    else
        printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_USER, getUserName(), pwd);

    // while (!quit)
    // {

    // }
    return 0;
}

2. 读取输入的命令

尝试使用scanf()函数对输入的指令进行读取,但无法实现


原因是

scanf 的输入结束规则由格式控制符决定,不是统一由空格或回车控制。

  • %d、%f:以"遇到不符合格式的字符"结束
  • %s:遇到空白字符结束(空格、回车、Tab)
  • %c:读取单字符,不跳过空白字符
  • %[ ] 族:可自定义结束条件

  • Linux 指令以空格作为分隔符 ,而**scanf()读取输入的字符串时,以空格、回车、Tab 作为单次输入的结束**,因此不能使用scanf()函数读取命令,这里介绍使用 fgets 解决 scanf 的问题
  • fgets 从特定的文件流中获取内容

    • char* s:获取的内容暂存的缓冲区

    • int size:缓冲区的大小

    • FILE* stream:文件对象

C语言程序中,会默认会为我们打开三个输入输出流

我们直接从 stdin 读取键盘输入


加上使用 fgets 读取输入的逻辑后如下

cpp 复制代码
#define LINE_SIZE 1024

int quit = 0;

char commandLine[LINE_SIZE];  // 存储读入的命令

int main()
{
    // shell 本质是一个死循环

    while (!quit)
    {
        // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
        getPwd();
        if (strcmp(getUserName(), "root") == 0)
            printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_ROOT, getUserName(), pwd);
        else
            printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_USER, getUserName(), pwd);

        char* str = fgets(commandLine, sizeof(commandLine), stdin);
        assert(str);  	// 即使直接输入回车,也输入了一个换行符,因此str不可能为空
		
        
        // 验证结果,看读入的字符串是否正确
        printf("test:: %s", str);

        // 2. 对输入的命令进行命令行解析,,拆分为 指令名 + 选项

        // 3. 判断输入的指令是否有效,无效时什么都不做

        // 4. 判断是否是内建命令,内建命令调用Shell 内部的函数执行

        // 5. 非内建命令,fork 出子进程, 执行命令
    }
    return 0;
}

解释仅仅解释新增部分

  • 存储读入的命令

    cpp 复制代码
      #define LINE_SIZE 1024			// 定义 输入命令 的最大字符数
      char commandLine[LINE_SIZE];  	// 存储读入的命令
  • 使用 fgets 读取输入,并验证结果

    cpp 复制代码
      // 使用 fgets 读取输入,将读到的字符串地址返回给 str
      char* str = fgets(commandLine, sizeof(commandLine), stdin);
      assert(str);  // 即使直接输入回车,也有一个字符,因此str不可能为空
      
      // 验证结果,看读入的字符串是否正确
      printf("%s", str);
    • fgets 将读入的字符串地址返回给 str即使直接输入回车,也有一个字符,因此str不可能为空 ,因此我们可以断言str非空 assert(str)再对读入的字符串进行检查

加入循环读取逻辑后,main函数运行结果如下

cpp 复制代码
int main()
{
    while (!quit)
    {
        // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
        getPwd();
        if (strcmp(getUserName(), "root") == 0)
            printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_ROOT, getUserName(), pwd);
        else
            printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_USER, getUserName(), pwd);

        char* str = fgets(commandLine, sizeof(commandLine), stdin);
        assert(str);  // 即使直接输入回车,也有一个字符,因此str不可能为空

        // 验证结果,看读入的字符串是否正确
        printf("test:: %s", str);
    }
    return 0;
}

由于我们在输入时,最后总是会键入回车(换行符),回车(换行符)也是一个字符。

而我们仅需要对字符串进行解析,无需对回车(换行符)进行解析,因此需要将换行符处理掉

  • assert(str)后添加一行代码:将末尾的换行符修改为'\0'
cpp 复制代码
// 下标 strlen(commandLine) 指向的为 '\0',再减一定位到 末尾的换行符
commandLine[strlen(commandLine) - 1] = '\0';

前后对比

  • 处理掉换行符之前 :默认有换行符
  • 处理掉换行符之后 :默认无换行符,读到的只有干净的字符串内容

3. 将以上逻辑抽象为 interact 交互函数

  • 我们可以将以上逻辑抽象为交互 interact 函数,仅用于完成交互逻辑
    • 这样有助于让 main 函数中的逻辑更加清晰 ,完成命令行的第一步工作------交互与读取输入
cpp 复制代码
void interact(char* cLine, int size)
{
    getPwd();
    if (strcmp(getUserName(), "root") == 0)
        printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_ROOT, getUserName(), pwd);
    else
        printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_USER, getUserName(), pwd);

    char* str = fgets(cLine, size, stdin);
    assert(str);  // 即使直接输入回车,也有一个字符,因此str不可能为空
                  // 就算直接输入回车,也输入了一个换行符,因此str一定不为空

    // 输入命令时,会将字符串 最后键入的回车也读入,我们不想读入回车
    // 下标 strlen(commandLine) 指向的为 '\0',再减一定位到 末尾的换行符     strlen(commandLine) - 1  最小值为 0
    cLine[strlen(cLine) - 1] = '\0';

    // 验证结果,看读入的字符串是否正确
    // printf("%s", str);
}
int main()
{
    // shell 本质是一个死循环

    while (!quit)
    {
        // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
        interact(commandLine, sizeof(commandLine));

        // 2. 对输入的命令进行命令行解析,,拆分为 指令名 + 选项

        // 3. 判断输入的指令是否有效,无效时什么都不做

        // 4. 判断是否是内建命令,内建命令调用Shell 内部的函数执行

        // 5. 非内建命令,fork 出子进程, 执行命令
    }
    return 0;
}

三、字符串解析问题

核心目标 :将输入的字符串解析为多个子字符串,即解析为命令行参数表,如:

"ls -a -l" 解析为 "ls" "-a" "-l" "NULL"将一个字符串指令,以空格为分隔符,解析为多个字符串存储在字符串指针数组中

  • 实现分割的方案有很多种,这里考虑使用 strtok 函数分割字符串

strtok简介

char *strtok(char *str, const char *delim);

  • char *str:要分割的字符串
  • const char *delim:分隔符
  • 返回分割出来的子串
  • 该函数调用一次,只会截取出一个子串

如果想对同一个字符串连续做切割,第一次需要传入字符串,后面需要传入NULL

对字符串 commandLine 做切割,并将各个 token 存储在 argv 字符串数组中


1. 字符串解析函数

字符串分割逻辑实现代码如下实现一个函数,仅用于分割字符串,并返回分割出的字符串个数

cpp 复制代码
#define ARGC_SIZE 32    // 规定命令的最大选项个数
#define DELIM " \t"		// 规定字符串解析时的 分隔符, 加入 \t 实现可以区分 tab 符

// 将分割出的字符串保存在字符串指针数组中,且最后一个为 NULL
// 最终返回 参数表中字符串的个数
int splitString(char* cLine, char* _argv[], int _max_args)
{
    if (_max_args <= 0)
        return 0;

    int i = 0;
    char* tok = strtok(cLine, DELIM);
    while (tok != NULL && i < _max_args - 1)  // 最后一个位置要置 NULL
    {
        _argv[i++] = tok;
        tok = strtok(NULL, DELIM);		// 接着从上次的位置继续分割
    }
    // 如果还有剩余 token 但超出 _argv 容量,应该决定如何处理(这里忽略多余的 token)
    _argv[i] = NULL;  // 保证以 NULL 结尾
    return i;         // 返回实际的 token 个数
}

字符串分割逻辑解释

  • 宏定义 :规定分割出的字符串的最大个数分隔符

    cpp 复制代码
      #define ARGC_SIZE 32   // 规定命令的选项个数
      #define DELIM " \t"		// 规定字符串解析时的 分隔符,实现可以区分 tab 符
  • 参数列表

    cpp 复制代码
      int splitString(char* cLine, char* _argv[], int _max_args);
    • char* cLine:待分割的字符串
    • char* _argv[]:分割出的字符串存储在字符串指针数组中
    • int _max_args:字符串指针数组最大可存储的 token 个数
  • 代码逻辑

    • commandLine 中存储的字符串按空格分隔后,存储在argv数组中 ,调用后返回分割出的字符串个数

      cpp 复制代码
        int argc = splitString(commandLine, argv, ARGC_SIZE);
    • 首先对 argv 的容量进行判断,argv 中存储容量 <= 0 时非法,直接返回

      cpp 复制代码
        if (_max_args <= 0)
            return 0;
    • 使用 strtok 分割字符串:第一次分割时需要传入字符串,后续使用 strtok 从上次的位置继续分割时,传参传入NULL

      cpp 复制代码
        int i = 0;
        char* tok = strtok(cLine, DELIM);	// 第一次分割后, tok 指向第一个空格后的字符串起始位置
        while (tok != NULL && i < _max_args - 1)  // 最后一个位置要置 NULL
        {
            _argv[i++] = tok;
            tok = strtok(NULL, DELIM);		// 接着从上次的位置继续分割
        }
      • 循环条件while (tok != NULL && i < _max_args - 1) // 最后一个位置要置 NULL的解释

        • tok != NULL:代表后续还有 token 未进行分割,才继续进行分割
        • i < _max_args - 1:代表 argv除最后一个位置外还有空闲位置时,才继续进行分割
    • 循环结束后的处理

      cpp 复制代码
        // 如果还有剩余 token 但超出 _argv 容量,应该决定如何处理(这里忽略多余的 token)
        _argv[i] = NULL;  // 保证以 NULL 结尾
        return i;         // 返回实际的 token 个数
      • 循环结束后有两种可能

        • 可能完成了对字符串的分割,tok 被返回了 NULL
        • 也可能下标 i 移动到了 argv 中的最后一个位置
      • 以上两种情况,i 均代表 argv 中最后一个 token 的下一个位置 ,需要置为NULL

      • 最终返回i,即为 argv 中 字符串的个数


2. 解析字符串后的执行逻辑

  • 交互读取字符串后,分割后将其存储在自定义的命令行参数表中
cpp 复制代码
// 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
interact(commandLine, sizeof(commandLine));

// 2. 对输入的命令进行命令行解析,,拆分为 指令名 + 选项
int argc = splitString(commandLine, argv, ARGC_SIZE);

// 3. 判断输入的指令是否有效,无效时什么都不做
if (argc == 0)
    continue;

// debug 验证解析出来的字符串
for (int i = 0; argv[i]; ++i)
            printf("[%d]->: %s\n", i, argv[i]);

接收返回的字符串的个数

  • 如果分割出的字符串个数为0:代表什么都没输入,继续循环交互即可
  • 如果分割出的字符串个数不为 :代表输入了相关的命令,对命令进行判断并执行

运行以下main函数逻辑输入的命令被正确读取

cpp 复制代码
int main()
{
    char* argv[ARGC_SIZE];
    // shell 本质是一个死循环

    while (!quit)
    {
        // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
        interact(commandLine, sizeof(commandLine));

        // 2. 对输入的命令进行命令行解析,,拆分为 指令名 + 选项
        int argc = splitString(commandLine, argv, ARGC_SIZE);

        // 3. 判断输入的指令是否有效,无效时什么都不做
        if (argc == 0)
            continue;
        // 验证解析出来的字符串
        for (int i = 0; argv[i]; ++i)
            printf("[%d]->: %s\n", i, argv[i]);

        // 4. 判断是否是内建命令,内建命令调用Shell 内部的函数执行

        // 5. 非内建命令,fork 出子进程, 执行命令
    }
    return 0;
}

四、内建命令的执行

通过前面进程环境变量 的学习,我们知道,Linux 中的命令分为普通命令内建命令

  • 普通命令 :由 shell 执行 fork 创建一个子进程,通过进程程序替换,由子进程单独执行 普通命令
  • 内建命令 :执行时不创建子进程,而是在 shell进程 内部直接执行本质是 shell 内部的一个函数或一段代码逻辑

1. 命令执行总体框架

交互读取命令行输入后,分割出的字符串个数不为 0 时,才执行命令

  • 优先判断是否是内建命令int isBuild = buildExecute(argc, argv); 通过返回值 isBuild 判断是否执行了内建命令

  • 如果是内建命令 :会在函数 buildExecute(argc, argv) 执行内建命令的逻辑

  • 如果不内建命令fork 出子进程,执行普通命令的逻辑

    cpp 复制代码
      // 非内建命令,fork 出子进程, 执行命令
      if (!isBuild)
          nomalExecute(argv);		// 执行普通命令的函数

以下为命令执行代码的总体框架

cpp 复制代码
char* argv[ARGC_SIZE];

// shell 本质是一个死循环
while (!quit)
{
    // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
    interact(commandLine, sizeof(commandLine));

    // 2. 对输入的命令进行命令行解析,,拆分为 指令名 + 选项
    int argc = splitString(commandLine, argv, ARGC_SIZE);

    // 3. 判断输入的指令是否有效,无效时什么都不做
    if (argc == 0)
        continue;

    // 4. 判断是否是内建命令,内建命令调用Shell 内部的函数执行
    int isBuild = buildExecute(argc, argv);

    // 5. 非内建命令,fork 出子进程, 执行命令
    if (!isBuild)
        nomalExecute(argv);
}

PS:buildExecute(argc, argv); 函数内部是通过 if else 逻辑判断执行哪个内建命令 的,因此我们要针对每一个内建命令,单独写一段 if else 逻辑

buildExecute 总体逻辑如下

  • 如果是内建命令 :逻辑执行完毕后 return 1
  • 如果不是内建命令 :不执行内建命令,直接 return 0
cpp 复制代码
int buildExecute(int _argc, char* _argv[])
{
    // 内建命令1
    if ()
    {
        return 1;
    }
    // 内建命令2
    else if ()
    {
        return 1;
    }
	// 内建命令3
    else if ()
    {
        return 1;
    }
	// 内建命令 ...
    
    // 单独给 ls 命令加颜色 无需 return 0
    if (strcmp(_argv[0], "ls") == 0)
    {
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL;
    }
    // 不匹配内建命令就 return 0  去执行普通命令
    return 0;
}

这里关于内建命令的执行,仅实现以下三个内建命令:

  • cd改变当前进程的路径
  • export向当前进程的环境变量表中增加数据
  • echo通过环境变量的Key获取环境变量的Val

其他内建命令读者可自行实现

关于内建命令:内建命令的执行不能让子进程去执行,原因在于(以cd命令举例)

  • 子进程执行 cd,只会改变子进程的当前所处路径,cd 的效果是要改变父进程的当前所处路径,应该让父进程去执行cd,因此不能用程序替换
  • 其他内建命令类似

2. 内建命令 cd 的执行

cpp 复制代码
char pwd[LINE_SIZE];  // 全局变量pwd, 存储当前路径
int buildExecute(int _argc, char* _argv[])
{
    // 处理内建命令 cd
    if (_argc <= 2 && strcmp(_argv[0], "cd") == 0)
    {
        if (_argc == 1)
            chdir(getenv("HOME"));
        else
            chdir(_argv[1]);

        // 通过环境变量获取路径比较麻烦,我们可以通过系统调用获取当前路径
        // chdir 后,当前路径确实改变了,但是环境变量表和命令行提示符中的路径没变,需要解决
        getPwd();  // 刷新当前路径
        // sprintf(getenv("PWD"), "%s", pwd);  // 将当前路径写入到环境变量
        setenv("PWD", pwd, 1);  // 将当前路径写入到环境变量

        return 1;
    }
    // ... 
}

执行 cd 命令时,可能的输入有以下两种情况

  • cd:只输入 cd 代表返回到当前用户的 home 目录此时 argc 的值为 1
  • cd <dir>cd 后面跟指定路径,代表切换到指定路径此时 argc 的值为 2

综上:内建命令 cd 的 argc 个数 <=2


实现 cd 的执行逻辑通过系统调用 chdir() 切换 shell 进程的当前工作目录,随后刷新 pwd

  • 通过字符串比较和参数个数锁定当前命令为cdif (_argc <= 2 && strcmp(_argv[0], "cd") == 0)

    • 参数个数为1时,返回到当前用户的 home 目录 ,否则切换到指定路径

    cpp 复制代码
      if (_argc == 1)
          chdir(getenv("HOME"));
      else
          chdir(_argv[1]);
  • 改变路径后,命令行提示符中显示的路径和环境变量表中的PWD均需要更新

    • 更新命令行提示符中显示的路径
      • getPwd();:该函数刷新当前路径存储在全局变量pwd中 ,路径改变后及时更新,保证下次交互时的路径都是最新的
    • 更新环境变量表中的PWD
      • setenv("PWD", pwd, 1);:将当前路径覆盖写入到环境变量PWD的值中
  • 执行完毕后 return 1


3. 内建命令 export 的执行

cpp 复制代码
// 全局变量  维护自定义环境变量表	
char* myenv[LINE_SIZE]; 	// 全局变量,初始时自动置为 NULL

// 处理内建命令 export  并实现自定义环境变量表
else if (_argc == 2 && strcmp(_argv[0], "export") == 0)
{
    for (int i = 0; i < LINE_SIZE; ++i)
    {
        if (myenv[i] == NULL)
        {
            myenv[i] = (char*) malloc(strlen(_argv[1]) + 1);
            if (myenv[i] == NULL)
            {
                perror("malloc fail");
                return 1;
            }

            strcpy(myenv[i], _argv[1]);

            if (putenv(myenv[i]) != 0)
                perror("putenv fail\n");

            break;
        }
    }

    return 1;
}

这里不能直接使用 putenv 的原因如下:

  • putenv 只是把环境变量字符串的地址 填入到系统环境变量表中
  • 我们的每次通过 export 输入的环境变量暂存在 char commandLine[LINE_SIZE] 命令行中的,和其他输入的命令共用一块空间
  • 输入其他命令时,会把之前的命令覆盖掉,环境变量也就消失了,因此要再单独存储一份环境变量,不能和命令公用一块空间

这里使用全局变量存储我们的环境变量char* myenv[LINE_SIZE];

  • 全局变量初始时自动置为NULL

实现 export 的执行逻辑将命令行中输入的环境变量拷贝到环境变量表的非空位置。

  • 通过字符串比较和参数个数锁定当前命令为 exportelse if (_argc == 2 && strcmp(_argv[0], "export") == 0)

    • export 命令的参数个数一定为两个
  • 遍历环境变量表,在表中找到一个 NULL 位置,用于存放环境变量

    cpp 复制代码
      for (int i = 0; i < LINE_SIZE; ++i)
      {
          if (myenv[i] == NULL)
          {
              myenv[i] = (char*) malloc(strlen(_argv[1]) + 1);
              if (myenv[i] == NULL)
              {
                  perror("malloc fail");
                  return 1;
              }
      
              strcpy(myenv[i], _argv[1]);
      
              if (putenv(myenv[i]) != 0)
                  perror("putenv fail\n");
      
              break;
          }
      }
    • 找到 NULL 位置后,malloc 空间,并判断 malloc 是否成功空间大小为字符串的长度+1 ,因为还要存储 '\0'

      • myenv[i] = (char*) malloc(strlen(_argv[1]) + 1);
    • 分配好内存后,将命令行中的字符串拷贝到自己维护的环境变量表中 strcpy(myenv[i], _argv[1]);

    • 将新的环境变量字符串的地址保存到 shell 进程的环境变量表中,保存后结束循环即可

      cpp 复制代码
        if (putenv(myenv[i]) != 0)
            perror("putenv fail\n");
        
        break;
  • 执行完毕后 return 1


4. 内建命令 echo 的执行

echo 命令需要实现的功能如下

  1. echo $?:获取上个子进程的退出码
  2. echo $valKey:通过该操作获取环境变量的相应值。如:echo $PATH可以获取到当前进程的 PATH 环境变量
  3. echo anyContent:该操作,echo 会在终端中原样输出字符串,只不过需要去掉字符串中的双引号

echo 的完整实现逻辑如下

cpp 复制代码
// 处理内建命令 echo
else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)
{
    // echo $? 获取上次进程的退出码
    // lastCode 保存了上个子进程退出时的退出码 可以让用户通过 echo $?获取
    if (strcmp(_argv[1], "$?") == 0)
    {
        printf("%d\n", lastCode);
        lastCode = 0;
    }
    // echo 环境变量和本地变量的情况 echo $
    else if (_argv[1][0] == '$')
    {
        // echo getenv获取不存在的环境变量时,会返回NULL
        // 如果不检测,会发生段错误
        char* val = getenv(_argv[1] + 1);
        if (val)
            printf("%s\n", val);
    }
    // echo 原样输出字符串的情况
    else
    {
        char* s = _argv[1];
        int len = strlen(s);

        if (len >= 2 && s[0] == '"' && s[len - 1] == '"')
        {
            s[len - 1] = '\0';  // 去掉尾部引号
            s++;                // 跳过首部引号
        }

        printf("%s\n", s);
    }
    return 1;
}

实现 echo 的执行逻辑实现 echo 的多个功能


通过字符串比较和参数个数锁定当前命令为 exportelse if (_argc == 2 && strcmp(_argv[0], "echo") == 0)

  • echo 命令的参数个数也一般为两个

实现 echo $valName 获取上个子进程的退出码的功能

cpp 复制代码
  int lastCode = 0; 	// 全局变量,保存每个子进程退出时的退出码
  
  // echo $? 获取上次进程的退出码
  // lastCode 保存了上个子进程退出时的退出码 可以让用户通过 echo $?获取
  if (strcmp(_argv[1], "$?") == 0)
  {
      printf("%d\n", lastCode);
      lastCode = 0;
  }
  • echo 的第二个选项_argv[1]"$?" 时,输出上个进程的退出码

实现 echo $valKey 获取环境变量的相应值的功能

  • **第二个参数的第一个字符为 ,代表要获取相应的环境变量 ∗ ∗ : ' a r g v [ 1 ] [ 0 ] = = ′ ,代表要获取相应的环境变量**:`_argv[1][0] == ' ,代表要获取相应的环境变量∗∗:'argv[1][0]==′'`

cpp 复制代码
  // echo 环境变量和本地变量的情况 echo $
  else if (_argv[1][0] == '$')
  {
      // echo getenv获取不存在的环境变量时,会返回NULL
      // 如果不检测,会发生段错误
      char* val = getenv(_argv[1] + 1);
      if (val)
          printf("%s\n", val);
  }
  • char* val = getenv(_argv[1] + 1);从环境变量表中获取环境变量 ,传入的字符串为_argv[1] + 1

    • _argv[1] 获取到的是字符串首元素的地址,_argv[1] + 1可以获取到 $ 后面的环境变量的 key 值

实现echo anyContent 在终端中原样输出去掉双引号的字符串的功能

cpp 复制代码
// echo 原样输出字符串的情况
else
{
    char* s = _argv[1];
    int len = strlen(s);

    if (len >= 2 && s[0] == '"' && s[len - 1] == '"')
    {
        s[len - 1] = '\0';  // 去掉尾部引号
        s++;                // 跳过首部引号
    }

    printf("%s\n", s);
}

原样输出字符串 _argv[1] 即可,下面进行去掉引号操作

  • 指针 s 指向字符串的第一个字符char* s = _argv[1]
  • 变量 len 保存字符串的长度int len = strlen(s);
  • 去掉引号需要确保字符串的首尾均是引号 if (len >= 2 && s[0] == '"' && s[len - 1] == '"')
    • 去掉尾部引号s[len - 1] = '\0';
    • 跳过首部引号s++;
  • 去掉引号后原样输出字符串即可printf("%s\n", s);

以上三个分支功能执行完一个后 return 1


内建命令的执行完整代码实现与执行结果

cpp 复制代码
int buildExecute(int _argc, char* _argv[])
{
    // 处理内建命令 cd
    if (_argc <= 2 && strcmp(_argv[0], "cd") == 0)
    {
        if (_argc == 1)
            chdir(getenv("HOME"));
        else
            chdir(_argv[1]);

        // 通过环境变量获取路径比较麻烦,我们可以通过系统调用获取当前路径
        // chdir 后,当前路径确实改变了,但是环境变量表和命令行提示符中的路径没变,需要解决
        getPwd();  // 刷新当前路径
        // sprintf(getenv("PWD"), "%s", pwd);  // 将当前路径写入到环境变量
        setenv("PWD", pwd, 1);  // 将当前路径写入到环境变量

        return 1;
    }
    // 处理内建命令 export  并实现自定义环境变量表
    else if (_argc == 2 && strcmp(_argv[0], "export") == 0)
    {
        // putenv 只是把环境变量字符串的地址 填入到系统环境变量表中
        // 我们的环境变量是放在   char commandLine[LINE_SIZE] 命令行中的,和其他解析后的命令共用一块空间
        // 输入其他命令时,会把之前保存的环境变量覆盖掉,因此要再单独存储一份环境变量

        for (int i = 0; i < LINE_SIZE; ++i)
        {
            if (myenv[i] == NULL)
            {
                myenv[i] = (char*) malloc(strlen(_argv[1]) + 1);
                if (myenv[i] == NULL)
                {
                    perror("malloc fail");
                    return 1;
                }

                strcpy(myenv[i], _argv[1]);

                if (putenv(myenv[i]) != 0)
                    perror("putenv fail\n");

                break;
            }
        }

        return 1;
    }
    // 处理内建命令 echo
    else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)
    {
        // echo $? 获取上次进程的退出码
        // lastCode 保存了上个子进程退出时的退出码 可以让用户通过 echo $?获取
        if (strcmp(_argv[1], "$?") == 0)
        {
            printf("%d\n", lastCode);
            lastCode = 0;
        }
        // echo 环境变量和本地变量的情况 echo $
        else if (_argv[1][0] == '$')
        {
            // echo getenv获取不存在的环境变量时,会返回NULL
            // 如果不检测,会发生段错误
            char* val = getenv(_argv[1] + 1);
            if (val)
                printf("%s\n", val);
        }
        // echo 原样输出字符串的情况
        else
        {
            char* s = _argv[1];
            int len = strlen(s);

            if (len >= 2 && s[0] == '"' && s[len - 1] == '"')
            {
                s[len - 1] = '\0';  // 去掉尾部引号
                s++;                // 跳过首部引号
            }

            printf("%s\n", s);
        }
        return 1;
    }

    // 单独给 ls 命令加颜色 无需 return 0
    if (strcmp(_argv[0], "ls") == 0)
    {
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL;
    }
    // 不匹配内建命令就 return 0  去执行普通命令
    return 0;
}

int main()
{
    char* argv[ARGC_SIZE];
    // shell 本质是一个死循环

    while (!quit)
    {
        // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
        interact(commandLine, sizeof(commandLine));

        // 2. 对输入的命令进行命令行解析,,拆分为 指令名 + 选项
        int argc = splitString(commandLine, argv, ARGC_SIZE);

        // 3. 判断输入的指令是否有效,无效时什么都不做
        if (argc == 0)
            continue;
        // 验证解析出来的字符串
        for (int i = 0; argv[i]; ++i)
            printf("[%d]->: %s\n", i, argv[i]);

        // 4. 判断是否是内建命令,内建命令调用Shell 内部的函数执行
        int isBuild = buildExecute(argc, argv);
		
        // 5. 非内建命令,fork 出子进程, 执行命令
    }
    return 0;
}

cd 和 echo 功能测试

export 功能测试

环境变量表中是否存在需要我们实现完普通命令的执行后才能验证出来


五、普通命令的执行

通过前面进程环境变量 的学习:普通命令 的执行由 shell 执行 fork 创建一个子进程,通过进程程序替换,由子进程单独执行 普通命令

根据以上原理实现的代码如下

cpp 复制代码
int lastCode = 0;		// 全局变量,保存子进程退出时的退出码

void nomalExecute(char* _argv[])
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork failed\n");
        return;  // 当shell fork 子进程失败时,代表shell 以及挂了,因此直接 return
    }
    // 子进程 进程程序替换,执行命令
    else if (id == 0)
    {
        // 程序替换有可能失败  exec 没有成功返回值,只有失败返回值
        execvp(_argv[0], _argv);

        // execvp 一旦执行成功,程序不会走到这里
        // 走到这里表示 execvp 执行失败
        perror("execvp");
        exit(EXIT_CODE);
    }
    // 父进程 等待子进程完成任务
    else
    {
        // 父进程等待
        int status = 0;                          // 获取子进程的退出状态
        pid_t retPid = waitpid(id, &status, 0);  // 等待子进程,传入status 阻塞等待
        if (retPid == id)
        {
            // 等待成功的处理逻辑
            lastCode = WEXITSTATUS(status);
        }
    }
}

解释

fork 创建子进程,判断子进程是否创建成功,id < 0 时子进程创建失败,进行错误处理

cpp 复制代码
  pid_t id = fork();
  if (id < 0)
  {
      perror("fork failed\n");
      return;  // 当shell fork 子进程失败时,代表shell 以及挂了,因此直接 return
  }

fork 函数在子进程中返回0,通过 execvp 函数进行程序替换,传参执行普通命令

cpp 复制代码
  // 子进程 进程程序替换,执行命令
  else if (id == 0)
  {
      // 程序替换有可能失败  exec 没有成功返回值,只有失败返回值
      execvp(_argv[0], _argv);
  
      // execvp 一旦执行成功,程序不会走到这里
      // 走到这里表示 execvp 执行失败
      perror("execvp");
      exit(EXIT_CODE);
  }
  • 注意
    • 程序替换有可能失败 exec 系列函数 没有成功返回值,只有失败返回值,执行失败后设置相应的进程退出码 exit(EXIT_CODE)

fork 函数在父进程中返回 子进程的 pid,父进程等待子进程执行完毕,执行完毕后获取子进程的退出状态以及退出码

cpp 复制代码
  // 父进程 等待子进程完成任务
  else
  {
      // 父进程等待
      int status = 0;                          // 获取子进程的退出状态
      pid_t retPid = waitpid(id, &status, 0);  // 等待子进程,传入status 阻塞等待
      if (retPid == id)
      {
          // 等待成功的处理逻辑
          lastCode = WEXITSTATUS(status);		// 保存子进程的退出码
      }
  }
  • 等待成功时返回所等待进程的 pid

main函数的逻辑

cpp 复制代码
int lastCode = 0;
int main()
{
    char* argv[ARGC_SIZE];
    // shell 本质是一个死循环

    while (!quit)
    {
        // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
        interact(commandLine, sizeof(commandLine));
        // 2. 对输入的命令进行命令行解析,,拆分为 指令名 + 选项
        int argc = splitString(commandLine, argv, ARGC_SIZE);

        // 3. 判断输入的指令是否有效,无效时什么都不做
        if (argc == 0)
            continue;

        // 4. 判断是否是内建命令,内建命令调用Shell 内部的函数执行
        int isBuild = buildExecute(argc, argv);

        // 5. 非内建命令,fork 出子进程, 执行命令
        if (!isBuild)
            nomalExecute(argv);
    }
    return 0;
}

六、改进事项

1. 进程替换出错时提示错误信息

  • exec 没有成功返回值,只有失败返回值,失败时使用 perror 提示错误信息
  • exit(EXIT_CODE); 使用指定的退出码退出
cpp 复制代码
#define EXIT_CODE 16  // 子进程替换时的 退出码

// 子进程 进程程序替换,执行命令
else if (id == 0)
{
    // 程序替换有可能失败  exec 没有成功返回值,只有失败返回值
    execvp(_argv[0], _argv);

    // execvp 一旦执行成功,程序不会走到这里
    // 走到这里表示 execvp 执行失败
    perror("execvp fail\n");
    exit(EXIT_CODE);	// 自定义执行错误时的退出码
}

3. 父进程等待结束后保存子进程的退出码

保存子进程的退出码,方便通过 echo $? 获取执行上一次操作的退出码

cpp 复制代码
int lastCode = 0;

// 父进程 等待子进程完成任务
else
{
    // 父进程等待
    int status = 0;                          // 获取子进程的退出状态
    pid_t retPid = waitpid(id, &status, 0);  // 等待子进程,传入status 阻塞等待
    if (retPid == id)
    {
        // 等待成功的处理逻辑
        lastCode = WEXITSTATUS(status);		// 保存子进程的退出码
    }
}

5. 为ls命令加上颜色高亮显示

  • 将以上逻辑加入到 内建命令的执行中即可
cpp 复制代码
// 单独给 ls 命令加颜色 无需 return 0
if (strcmp(_argv[0], "ls") == 0)
{
    _argv[_argc++] = "--color";
    _argv[_argc] = NULL;
}
// 不匹配内建命令就 return 0  去执行普通命令
  • 为ls命令加上颜色高亮显示的逻辑 :当输入的命令为 ls 时,向 ls 的参数表中添加一个选项 "--color" ,**之后再将下一个位置置为NULL**即可

6. shell 进程结束后释放环境变量表避免内存泄漏

cpp 复制代码
void destroyEnv()
{
    for (int i = 0; i < LINE_SIZE; ++i)
    {
        if (myenv[i])
        {
            free(myenv[i]);
            myenv[i] = NULL;
        }
    }
}

七、完整代码实现

cpp 复制代码
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

#define LEFT "{"  // 控制括号格式
#define RIGHT "}"
#define LABEL_ROOT "#"  // 控制不同用户的命令行提示符
#define LABEL_USER "$"
int quit = 0;  // 全局控制,控制命令行的退出状态

#define DELIM " \t"  // 分隔符加上tab

#define LINE_SIZE 1024

#define ARGC_SIZE 32  // 命令中字符的最大个数

#define EXIT_CODE 16  // 子进程替换时的 退出码

extern char** environ;

char commandLine[LINE_SIZE];  // 存储读入的命令

int lastCode = 0;

char pwd[LINE_SIZE];  // 存储当前路径

// export 环境变量的原理是
// putenv 只是把环境变量字符串的地址 填入到系统环境变量表中
// 我们的环境变量是放在   char commandLine[LINE_SIZE] 数组中的,和其他解析后的命令共用一块空间
// 输入其他命令时,会把之前保存的环境变量覆盖掉,因此要再单独存储一份环境变量

// 自定义环境变量表
char* myenv[LINE_SIZE];  // 自己实现的 环境变量表

// 自定义本地变量表     当前 shell 存储本地变量的功能尚未实现
char myVal[LINE_SIZE];  // 存储Shell本地变量

const char* getUserName()
{
    return getenv("USER");
}

const char* getHostName()
{
    return getenv("HOSTNAME");
}

void getPwd()
{
    getcwd(pwd, sizeof(pwd));
}

void interact(char* cLine, int size)
{
    getPwd();
    if (strcmp(getUserName(), "root") == 0)
        printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_ROOT, getUserName(), pwd);
    else
        printf(LEFT "%s@hcss-ecs-dfa9:%s" RIGHT LABEL_USER, getUserName(), pwd);

    char* str = fgets(cLine, size, stdin);
    assert(str);  // 即使直接输入回车,也有一个字符,因此str不可能为空
                  // 就算直接输入回车,也输入了一个换行符,因此str一定不为空

    // 输入命令时,会将字符串 最后键入的回车也读入,我们不想读入回车
    // 下标 strlen(commandLine) 指向的为 '\0',再减一定位到 末尾的换行符     strlen(commandLine) - 1  最小值为 0
    cLine[strlen(cLine) - 1] = '\0';

    // 验证结果,看读入的字符串是否正确
    // printf("%s", str);
}

int splitString(char* cLine, char* _argv[], int _max_args)
{
    if (_max_args <= 0)
        return 0;

    int i = 0;
    char* tok = strtok(cLine, DELIM);
    while (tok != NULL && i < _max_args - 1)  // 最后一个位置要置 NULL
    {
        _argv[i++] = tok;
        tok = strtok(NULL, DELIM);
    }
    // 如果还有剩余 token 但超出 _argv 容量,应该决定如何处理(这里忽略多余的 token)
    _argv[i] = NULL;  // 保证以 NULL 结尾
    return i;         // 返回实际的 token 个数
}

void nomalExecute(char* _argv[])
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork failed\n");
        return;  // 当shell fork 子进程失败时,代表shell 以及挂了,因此直接 return
    }
    // 子进程 进程程序替换,执行命令
    else if (id == 0)
    {
        // 程序替换有可能失败  exec 没有成功返回值,只有失败返回值
        execvp(_argv[0], _argv);

        // execvp 一旦执行成功,程序不会走到这里
        // 走到这里表示 execvp 执行失败
        perror("execvp");
        exit(EXIT_CODE);
    }
    // 父进程 等待子进程完成任务
    else
    {
        // 父进程等待
        int status = 0;                          // 获取子进程的退出状态
        pid_t retPid = waitpid(id, &status, 0);  // 等待子进程,传入status 阻塞等待
        if (retPid == id)
        {
            // 等待成功的处理逻辑
            lastCode = WEXITSTATUS(status);
        }
    }
}

int buildExecute(int _argc, char* _argv[])
{
    // 处理内建命令 cd
    if (_argc <= 2 && strcmp(_argv[0], "cd") == 0)
    {
        if (_argc == 1)
            chdir(getenv("HOME"));
        else
            chdir(_argv[1]);

        // 通过环境变量获取路径比较麻烦,我们可以通过系统调用获取当前路径
        // chdir 后,当前路径确实改变了,但是环境变量表和命令行提示符中的路径没变,需要解决
        getPwd();  // 刷新当前路径
        // sprintf(getenv("PWD"), "%s", pwd);  // 将当前路径写入到环境变量
        setenv("PWD", pwd, 1);  // 将当前路径写入到环境变量

        return 1;
    }
    // 处理内建命令 export  并实现自定义环境变量表
    else if (_argc == 2 && strcmp(_argv[0], "export") == 0)
    {
        // putenv 只是把环境变量字符串的地址 填入到系统环境变量表中
        // 我们的环境变量是放在   char commandLine[LINE_SIZE] 命令行中的,和其他解析后的命令共用一块空间
        // 输入其他命令时,会把之前保存的环境变量覆盖掉,因此要再单独存储一份环境变量

        for (int i = 0; i < LINE_SIZE; ++i)
        {
            if (myenv[i] == NULL)
            {
                myenv[i] = (char*) malloc(strlen(_argv[1]) + 1);
                if (myenv[i] == NULL)
                {
                    perror("malloc fail");
                    return 1;
                }

                strcpy(myenv[i], _argv[1]);

                if (putenv(myenv[i]) != 0)
                    perror("putenv fail\n");

                break;
            }
        }

        return 1;
    }
    // 处理内建命令 echo
    else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)
    {
        // echo $? 获取上次进程的退出码
        // lastCode 保存了上个子进程退出时的退出码 可以让用户通过 echo $?获取
        if (strcmp(_argv[1], "$?") == 0)
        {
            printf("%d\n", lastCode);
            lastCode = 0;
        }
        // echo 环境变量和本地变量的情况 echo $
        else if (_argv[1][0] == '$')
        {
            // echo getenv获取不存在的环境变量时,会返回NULL
            // 如果不检测,会发生段错误
            char* val = getenv(_argv[1] + 1);
            if (val)
                printf("%s\n", val);
        }
        // echo 原样输出字符串的情况
        else
        {
            char* s = _argv[1];
            int len = strlen(s);

            if (len >= 2 && s[0] == '"' && s[len - 1] == '"')
            {
                s[len - 1] = '\0';  // 去掉尾部引号
                s++;                // 跳过首部引号
            }

            printf("%s\n", s);

            // 以上是自己学习的去掉引号的方法
            // printf("%s\n", _argv[1]);
        }
        return 1;
    }

    // 单独给 ls 命令加颜色 无需 return 0
    if (strcmp(_argv[0], "ls") == 0)
    {
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL;
    }
    // 不匹配内建命令就 return 0  去执行普通命令
    return 0;
}

void destroyEnv()
{
    for (int i = 0; i < LINE_SIZE; ++i)
    {
        if (myenv[i])
        {
            free(myenv[i]);
            myenv[i] = NULL;
        }
    }
}

int main()
{
    char* argv[ARGC_SIZE];
    // shell 本质是一个死循环

    while (!quit)
    {
        // 1. 输出命令行提示符号进行交互,并读取输入的命令字符串
        interact(commandLine, sizeof(commandLine));
        // 2. 对输入的命令进行命令行解析,,拆分为 指令名 + 选项
        int argc = splitString(commandLine, argv, ARGC_SIZE);

        // 3. 判断输入的指令是否有效,无效时什么都不做
        if (argc == 0)
            continue;

        // 验证解析出来的字符串
        // for (int i = 0; argv[i]; ++i)
        //     printf("[%d]->: %s\n", i, argv[i]);

        // 4. 判断是否是内建命令,内建命令调用Shell 内部的函数执行
        int isBuild = buildExecute(argc, argv);

        // 5. 非内建命令,fork 出子进程, 执行命令
        if (!isBuild)
            nomalExecute(argv);
    }

    destroyEnv();
    return 0;
}

结语

通过从零实现一个简单 Shell,我们不仅复现了 Linux 用户日常最熟悉的工具之一,也逐层揭开了其背后涉及的 进程控制、程序替换、环境变量、文件系统与字符串解析机制

可以说,一个小小的 Shell,几乎串联起了 Linux 系统编程的整个知识体系。

在这次实践中,我们清晰地看到了:

  • fork + exec 如何赋予 Linux 强大的进程模型
  • waitpid 如何保证父子进程协作与资源回收
  • 环境变量表 如何被继承、修改、扩展
  • 内建命令 为什么必须在父进程执行
  • 字符串解析与参数传递 如何影响命令的执行逻辑
  • Shell 如何将一个输入字符串变成真正运行的程序
  • 甚至是我们每天看到的命令提示符,它的每个字符都是程序员手动绘制的

这些实现不仅让我们拥有了一个能真实工作的 Shell,更重要的是,它让整个 Linux 运行机制不再抽象,而是变得可触摸、可理解、可实验。

🌟 当你真正写过一个 Shell,你会发现:
Linux 其实没有那么神秘,它不过是一层层扎实的系统调用与数据结构。

这仅仅是起点。

你可以在这些基础上继续扩展:

  • 支持管道 |
  • 实现重定向 < > >>
  • 添加后台运行 &
  • 支持环境变量展开、命令历史等
  • 尝试实现 job control(bg/fg)
  • 甚至构建自己的 mini bash

每一步都将让你更接近一个真正的系统开发者。


所以,当我们登陆系统的时候,系统就是要启动一个shell进程

我们shel本身的环境变量是从哪里来的???

当用户登陆的时候,shell会读取用户目录下的.bash_profile文件,里面保存了导入环境变量的方式!!


以上就是本文的所有内容了,如果觉得文章对你有帮助,欢迎 点赞⭐收藏 支持!如有疑问或建议,请在评论区留言交流,我们一起进步

分享到此结束啦
一键三连,好运连连!

你的每一次互动,都是对作者最大的鼓励!


征程尚未结束,让我们在广阔的世界里继续前行! 🚀

相关推荐
stanleyrain2 小时前
C++中关于const的说明
开发语言·c++
ccmedu2 小时前
Docker 安装mysql8.0 主从同步
运维·docker·容器
一个不知名程序员www2 小时前
算法学习入门---stack(C++)
c++·算法
奺儿2 小时前
uniapp 小程序 报错 TypeError: Cannot convert undefined or null to object
小程序·uni-app
BUG_MeDe2 小时前
Linux 下VRF的简单应用
linux·运维·服务器
babytiger2 小时前
用python在服务器上开个可以输入帐号密码的代理服务器
运维·服务器
oioihoii2 小时前
MFC核心架构深度解析
c++·架构·mfc
Sleepy MargulisItG2 小时前
Linux 基础指令详解(常用)
linux
2501_915921432 小时前
从 HBuilder 到 App Store,uni-app 与 HBuilder 项目的 iOS 上架流程实战解析
android·ios·小程序·https·uni-app·iphone·webview