【Linux系统】从零开始构建简易 Shell:从输入处理到命令执行的深度剖析

文章目录


前言

在操作系统的世界里,Shell 作为用户与系统交互的桥梁,扮演着至关重要的角色。无论是资深的开发者,还是对系统运维感兴趣的新手,了解 Shell 的工作原理和实现机制都能极大地加深对操作系统底层运行逻辑的理解。

本文将带领大家深入探索简易 Shell 的实现过程,从最基础的打印命令行提示符开始,逐步实现读取用户输入、切割指令、执行普通命令以及处理内建指令等核心功能。每一步都配有详细的代码解析和关键知识点说明,不仅让你知其然,更能知其所以然。通过阅读本文,你将掌握 Shell 实现的关键技术,理解其中涉及的编程技巧和系统调用原理,同时也能体会到从无到有构建一个实用工具的乐趣与成就感。


为了方便理解我把源代码发给大家。(单击蓝色字体)

一、打印命令行提示符

c 复制代码
#define MARK ":"
#define LABLE "$"
#define LINE_SIZE 1024

char commandline[LINE_SIZE]; // 用于存储输入的命令行
char pwd[LINE_SIZE]; // 当前工作目录

// 获取用户名
const char* getusername()
{
    return getenv("USER");
}

// 获取主机名
const char* gethostname()
{
    return getenv("HOSTNAME");
}

// 获取当前工作目录并进行格式化
void getpwd()
{
    getcwd(pwd, sizeof(pwd)); // 获取当前工作目录
    const char* home = getenv("HOME"); // 获取用户主目录

    if (home != NULL && strcmp(pwd, home) == 0)
    {
        // 如果当前目录等于用户主目录,则返回 "~"
        strcpy(pwd, "~");
    }
    else if (home != NULL && strncmp(pwd, home, strlen(home)) == 0)
    {
        // 如果当前目录是用户主目录的子目录,则替换主目录部分为 "~"
        static char relative_path[LINE_SIZE];
        snprintf(relative_path, sizeof(relative_path), "~%s", pwd + strlen(home));
        strcpy(pwd, relative_path);
    }
}

int main()
{
    getpwd();
    printf("%s@%s"MARK"%s"LABLE" ", getusername(), gethostname(), pwd);
    scanf("%s", commandline);                                                                               return 0;
}

代码功能概述

  1. 定义宏和变量:

    • MARKLABLE 定义了提示符中的分隔符 :$,用于提示符的格式化输出。
    • LINE_SIZE 设置了缓冲区大小(1024),用于存储命令行输入和当前工作目录。
    • 全局变量 commandlinepwd
      • commandline: 存储从用户输入读取的命令行。
      • pwd: 存储当前工作目录。
  2. 获取环境变量:

    • 函数 getusername()gethostname() 分别通过调用 getenv() 获取环境变量 USERHOSTNAME,用于表示用户名和主机名。
  3. 格式化当前工作目录:

    • 函数 getpwd()

      获取当前工作目录并将其格式化:

      • 如果当前目录是用户的主目录(HOME),用 ~ 替代完整路径。
      • 如果当前目录是主目录的子目录,则用 ~ 替代主目录部分。
      • 否则显示完整路径。
  4. 打印命令行提示符:

    • main() 函数中调用 getpwd() 获取当前工作目录,然后通过 printf()

      按以下格式打印提示符:

  5. 等待用户输入:

    • 程序通过 scanf() 等待用户输入命令,将输入存储到 commandline 中。

二、读取键盘输入的指令

c 复制代码
// 交互式模式,显示提示符并读取用户输入
void interact(char* cline, int size)
{
    getpwd(); // 获取当前工作目录
    printf("%s@%s"MARK"%s"LABLE" ", getusername(), gethostname(), pwd); // 显示提示符
    char* s = fgets(cline, size, stdin); // 读取用户输入
    assert(s); // 确保输入不为空
    printf("echo: %s\n", s); // 测试能否读取成功
    cline[strlen(cline)-1] = '\0'; // 去掉末尾的换行符
}

int main()
{
    while(1) // 循环运行直到用户退出
    {
        // 1. 获取用户输入的命令行
        interact(commandline, sizeof(commandline));
    }
    return 0; 
}

2.1 为什么不继续使用scanf()而换成了fgets()

scanf 通常用于格式化输入,但它在处理用户输入时存在一些显著的缺点:

  • scanf 默认以空格、制表符或换行符作为输入的分隔符,因此只能读取一个单词(或无空格的字符串)。
  • 在交互式 Shell 中,用户输入的命令往往由多个部分组成(如命令和参数),scanf 无法正确读取整行命令。
  • scanf 不会自动限制输入长度,如果用户输入超出缓冲区大小,就会导致缓冲区溢出,进而引发未定义行为甚至安全漏洞。

2.2 调试输出的意义

加调试输出 printf("echo: %s\n", s) 是为了测试程序的输入功能是否正常运行,具体验证以下几点:

  1. 提示符显示后,用户是否能输入数据
    • 如果 s == NULL,则说明 fgets() 未能成功读取输入(可能是因为输入错误或用户直接按下 Ctrl+D)。
  2. 输入是否被正确存储到缓冲区 cline
    • 打印输入内容以确保其正确性。
  3. 缓冲区大小是否足够
    • 如果输入过长,可能会导致缓冲区溢出或截断。

调试结果

2.3 为什么需要去掉换行符?

  • 用户通过键盘输入时,输入内容会带有换行符(\n),这是因为按下回车键会在输入的末尾自动添加一个换行符。

  • 例如,用户输入 ls -l 后,缓冲区中的数据实际是:

    复制代码
    ls -l\n\0
    • \n 是换行符。
    • \0 是字符串的终止符。
  • 在处理命令时,换行符通常是多余的:

    • 它会影响字符串的比较。例如,strcmp(command, "exit") 会返回不匹配,因为字符串实际是 "exit\n"
    • 如果直接打印字符串,换行符会造成多余的空行。
    • 某些函数(如文件名或路径相关函数)可能会因为换行符导致逻辑错误。

三、指令切割

c 复制代码
#define DELIM " "	// 分隔符
#define ARGC_SIZE 32

char *argv[ARGC_SIZE]; // 用于存储命令行参数

// 将输入的命令行分割为参数数组
int splitstring(char* cline, char* _argv[])
{
    int i = 0;
    argv[i++] = strtok(cline, DELIM); // 分割第一个参数
    while(_argv[i++] = strtok(NULL, DELIM)); // 分割剩余的参数
    return i - 1; // 返回参数个数
}

int main()
{
    while(1) // 循环运行直到用户退出
    {
        // 1. 获取用户输入的命令行
        interact(commandline, sizeof(commandline));

        // 2. 分割命令行字符串为指令和参数
        int argc = splitstring(commandline, argv);
        // 调试代码
		for(int i = 0; argv[i]; i++)
        {
			printf("[%d]->%s\n", i, argv[i]);
        }
        // 3. 如果没有输入指令(空行),跳过本次循环
        if(argc == 0) continue;
    }

    return 0; 
}

补充知识: strtok 的函数原型

c 复制代码
char *strtok(char *str, const char *delim);
  • 参数:
    1. char *str:
      • 第一次调用时传入需要分割的字符串。
      • 后续调用传入 NULL 表示继续上一次的分割。
    2. const char *delim:
      • 一个以 \0 结尾的字符串,表示分割的分隔符集合(例如,空格、逗号等)。
  • 返回值:
    • 成功:返回一个指向分割后的子字符串(token)的指针。
    • 失败:如果没有更多的子字符串可以返回,则返回 NULL

注意点 :循环结束时,i 的值比实际的参数个数多 1(因为最后一次分割返回 NULL)。

因此,用 i - 1 表示参数的实际个数。

调试结果

四、普通命令的执行

c 复制代码
#define EXIT_CODE 44

extern char **environ; // 环境变量
int lastcode = 0; // 上次命令的退出状态
int quit = 0; // 是否退出

// 执行外部命令
void normalExcute(char* _argv[])
{
    pid_t id = fork(); // 创建子进程
    if(id < 0)
    {
        perror("fork"); // 创建失败,打印错误信息
        return;
    }
    if(id == 0) // 子进程
    {
        execvpe(_argv[0], _argv, environ);
        exit(EXIT_CODE);
    }
    else // 父进程
    {
        int status = 0;
        waitpid(id, &status, 0); // 等待子进程完成
        if (WIFEXITED(status)) // 检查子进程是否正常退出
        {
            lastcode = WEXITSTATUS(status); // 获取子进程的退出状态
        }
    }
}

int main()
{
    while(!quit) // 循环运行直到用户退出
    {
        // 1. 获取用户输入的命令行
        interact(commandline, sizeof(commandline));

        // 2. 分割命令行字符串为指令和参数
        int argc = splitstring(commandline, argv);

        // 3. 如果没有输入指令(空行),跳过本次循环
        if(argc == 0) continue;

        // 4.执行普通外部指令
        normalExcute(argv);
    }

    return 0; 
}

代码功能概述

功能:执行外部命令并处理子进程的退出状态。

关键点

  1. fork 创建子进程
  2. execvpe 替换子进程执行映像
  3. waitpid 等待子进程结束并处理退出状态

结果 :命令执行结果的退出码存储在 lastcode 中供后续使用。

调试结果

五、内建指令执行

c 复制代码
char myenv[LINE_SIZE]; // 用于存储导出的环境变量

// 构建内置命令
int buildCommand(char* _argv[], int _argc)
{
    // 内置命令:cd
    if(_argc == 2 && strcmp(_argv[0], "cd") == 0)
    {
        getpwd();
        chdir(_argv[1]); // 改变工作目录
        sprintf(getenv("PWD"), "%s", pwd); // 更新环境变量PWD
        return 1; // 返回已处理标志
    }
    // 内置命令:export
    else if(_argc == 2 && strcmp(_argv[0], "export") == 0)
    {
        strcpy(myenv, _argv[1]); // 保存环境变量
        putenv(myenv); // 设置环境变量
        return 1; // 返回已处理标志
    }
    // 内置命令:echo
    else if(_argc == 2 && strcmp(_argv[0], "echo") == 0)
    {
        if(strcmp(_argv[1], "$?") == 0) // 显示上一个命令的退出状态
        {
            printf("%d\n", lastcode);
            lastcode = 0; // 重置退出状态
        }
        else if(*_argv[1] == '$') // 显示环境变量的值
        {
            char* val = getenv(_argv[1] + 1);
            if(val) printf("%s\n", val);
        }
        else printf("%s\n", _argv[1]); // 直接打印参数
        return 1; // 返回已处理标志
    }
    // 自动为 ls 添加 --color 参数
    if(strcmp(_argv[0], "ls") == 0)
    {
        _argv[_argc++] = "--color";
        _argv[_argc] = NULL; // 确保参数数组以 NULL 结束
    }

    return 0; // 返回未处理标志
}

int main()
{
    while(!quit) // 循环运行直到用户退出
    {
        // 1. 获取用户输入的命令行
        interact(commandline, sizeof(commandline));

        // 2. 分割命令行字符串为指令和参数
        int argc = splitstring(commandline, argv);

        // 3. 如果没有输入指令(空行),跳过本次循环
        if(argc == 0) continue;

        // 4. 尝试执行内置命令
        int n = buildCommand(argv, argc);

        // 5. 如果不是内置命令,执行普通外部指令
        if(!n) normalExcute(argv);
    }

    return 0; 
}

代码功能概述

该函数 buildCommand 的功能是处理内置命令 ,包括 cdexportecho,并对特定外部命令(如 ls)添加额外参数。如果输入的命令属于内置命令范围,函数会执行相应逻辑并返回已处理标志;否则返回未处理标志,交由其他部分(如外部命令执行器)处理。

  1. 处理内置命令:cd

    c 复制代码
    if (_argc == 2 && strcmp(_argv[0], "cd") == 0)
    • 检查用户输入的命令是否是 cd,且参数个数为 2(命令名 + 目标目录)。
    • 功能:
      • 使用 chdir 改变当前工作目录为用户指定的路径(_argv[1])。
      • 更新环境变量 PWD,同步当前工作目录的变化。
    • 实现思路:要注意 cd 命令是由 bash 本身去做,而不是创建一个子进程去做,故而需要改变的是当前可执行程序的工作目录,并且需要将环境变量中的 PWD 改变。
    • 测试:
  2. 处理内置命令:export

    c 复制代码
    else if (_argc == 2 && strcmp(_argv[0], "export") == 0)
    • 检查用户输入的命令是否是 export,且参数个数为 2。
    • 功能:
      • 设置环境变量,将用户输入的 变量=值 格式字符串存储到全局变量 myenv
      • 调用 putenv 将该变量添加到环境变量中。
    • 实现思路:我们输入的环境变量实际上是保存在commandline当中,只要当下一次输入指令,上一次定义的环境变量就会被清空。putenv 添加环境变量,并不是把对应的字符串深拷贝到系统的环境变量表当中,而是把该字符串的地址保存在系统的环境变量表中(浅拷贝)。因此我们要确保保存环境变量字符串的那个地址里的环境变量不会被修改,所以我们需要为用户输入的环境变量,也就是那一串字符串单独开辟一块空间进行存储,保证在内次重新输入指令的时候,不会影响到之前用户添加的环境变量。所以我们需要定义一个二维数组用于存储导出的环境变量(这里只简单地分配了一维数组)。
    • 测试:


      注意看,连续两次的写入导致第一次的定义的环境变量被覆盖了。
  3. 处理内置命令:echo

    c 复制代码
    else if (_argc == 2 && strcmp(_argv[0], "echo") == 0)
    • 检查用户输入的命令是否是 echo,且参数个数为 2。
    • 功能:
      • 如果参数为 "$?",显示上一个命令的退出状态(从全局变量 lastcode 获取)。
      • 如果参数以 $ 开头,显示对应环境变量的值。
      • 否则,直接打印参数内容。
    • 测试:


      故意写成 ll (没有定义的),导致子进程退出,退出码刚好是44。
  4. 处理外部命令:自动为 ls 添加 --color 参数

    c 复制代码
    if (strcmp(_argv[0], "ls") == 0)c
    • 检查用户输入的命令是否是 ls
    • 功能:
      • 自动为命令添加 --color 参数,用于增强可读性(适用于 Linux 的 ls 命令)。
      • 确保参数数组以 NULL 结束。
    • 测试:

      总结:说了这么久的环境变量,那么请问我们登录的时候,系统中的 shell 的环境变量又是从哪里来的呢?答案是 Bash。那么 Bash 的环境变量又是从何而来?当然是系统自带的目录文件中写入的。

结语

通过以上对简易 Shell 实现过程的详细讲解,相信大家对 Shell 的工作流程和实现细节已经有了较为全面的认识。从命令行提示符的设计,到输入指令的处理,再到不同类型命令的执行,每一个环节都凝聚着操作系统与编程的智慧。

虽然本文实现的 Shell 只是一个简化版本,但其中涉及的技术和思想为进一步探索更复杂、功能更强大的 Shell,乃至深入理解操作系统的运行机制奠定了坚实的基础。希望大家能将所学应用到实际开发或探索中,不断挖掘操作系统的奥秘。如果在阅读过程中有任何疑问或想法,欢迎在评论区交流分享,也别忘了点赞、收藏并持续关注后续更多精彩的技术内容!

天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连 支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!

相关推荐
Ronin-Lotus2 分钟前
图像处理篇---opencv实现坐姿检测
图像处理·人工智能·python·opencv
努力的小帅7 分钟前
c++——二叉树进阶
开发语言·数据结构·c++·学习·算法·面试
Dxy12393102167 分钟前
Python+OpenCV打造AR/VR基础框架:从原理到实战的全链路解析
python·opencv
大G哥8 分钟前
19_大模型微调和训练之-基于LLamaFactory+LoRA微调LLama3
人工智能·pytorch·python·深度学习·计算机视觉
朱剑君9 分钟前
排序算法——计数排序
数据结构·算法·排序算法
loveLifeLoveCoding10 分钟前
springboot 加载 tomcat 源码追踪
java·spring boot·spring
幽络源小助理14 分钟前
SpringBoot框架开发网络安全科普系统开发实现
java·spring boot·后端·spring·web安全
朱剑君17 分钟前
排序算法——总结
数据结构·算法·排序算法
CoderJia程序员甲19 分钟前
AI 入门资源:微软 AI-For-Beginners 项目指南
人工智能·microsoft·ai·ai编程
盘古信息IMS31 分钟前
富乐德传感技术&盘古信息 | 锚定“未来工厂”新坐标,开启传感器制造行业数字化转型新征程
大数据·人工智能·制造