欢迎光临
我们一直在努力

【Linux系统编程】(十八)手搓 Linux Shell 命令行解释器:从 0 到 1 打造属于你的终端神器


目录

前言

一、前置认知:什么是 Shell?它到底在做什么?

1.1 Shell 的本质:用户与内核之间的 “翻译官”

1.2 Shell 的核心工作流程:一个无限循环的 “命令处理机器”

完整工作流程拆解

可视化流程示意图

​编辑1.3 实现自主 Shell 的核心技术栈

二、从零开始:实现一个最基础的 Shell(支持外部命令执行)

2.1 步骤 1:搭建 Shell 的主循环框架

实战代码(my_shell_v1.c)

代码编译与执行

执行效果

核心知识点解析

2.2 步骤 2:实现命令行解析功能(拆分命令与参数)

实战代码(补充解析功能,my_shell_v2.c)

代码编译与执行

执行效果

核心知识点解析

2.3 步骤 3:实现外部命令执行(fork+exec+wait)

实战代码(补充执行功能,my_shell_v3.c)

代码编译与执行

执行效果

核心知识点解析

三、功能升级:添加内置命令支持(cd、exit、pwd)

3.1 内置命令的特点与实现思路

内置命令的核心特点

常见内置命令与对应的系统调用

实现思路

3.2 实战代码:实现 cd、exit、pwd 内置命令(my_shell_v4.c)

代码编译与执行

执行效果

核心知识点解析

四、进阶功能:支持后台执行(& 符号)

4.1 实战代码:添加后台执行支持(my_shell_v5.c)

代码编译与执行

执行效果

核心知识点解析

总结


前言

        在 Linux 的日常使用中,我们每天都在和 bash、zsh 这些 Shell 命令行解释器打交道。当你在终端输入ls -l、pwd、git clone这些命令时,是否曾好奇过:背后的 Shell 是如何 “读懂” 你的输入,又是如何将命令转化为实际的系统操作的?

        其实,Shell 命令行解释器并非什么神秘的 “黑盒”,它的核心工作流程清晰易懂,我们完全可以凭借对 Linux 进程控制(fork/exec/wait)、字符串处理、终端交互的理解,亲手打造一个属于自己的简易 Shell。

        本文将从 Shell 的核心工作原理入手,一步步拆解命令行解释器的实现细节,从最基础的命令读取与解析,到支持内置命令、后台执行、重定向等高级功能,最终完成一个可实际运行的自主 Shell 命令行解释器。全程结合 C 语言实战代码,语言生动易懂,逻辑层层递进,即使你是 Linux 编程新手,也能跟着本文一步步实现属于自己的终端神器。下面就让我们正式开始吧!


一、前置认知:什么是 Shell?它到底在做什么?

1.1 Shell 的本质:用户与内核之间的 “翻译官”

        Linux 内核是操作系统的核心,负责管理硬件资源、进程调度、内存管理等底层工作,但内核并不直接与用户交互 —— 用户无法直接向内核发送命令,而 Shell 就是连接用户与内核的 “桥梁” 和 “翻译官”。

        简单来说,Shell 是一个命令行解释器,它接收用户输入的命令行,对命令进行解析和处理,最终通过系统调用或启动子进程的方式,让内核执行对应的操作,并将执行结果反馈给用户。

        我们可以用一个生动的类比来理解:

  • 内核就像一家公司的 CEO,掌握着公司的所有核心资源和决策权,不直接对接普通员工(用户);
  • Shell 就像 CEO 的秘书,负责接收普通员工的请求(用户输入的命令),将请求整理、翻译为 CEO 能理解的语言(系统调用、子进程执行),再将 CEO 的处理结果(命令执行结果)反馈给员工。

1.2 Shell 的核心工作流程:一个无限循环的 “命令处理机器”

        无论是 bash 还是我们将要实现的简易 Shell,其核心工作流程都是一个无限循环,这个循环可以概括为 “读取→解析→执行→反馈” 四个步骤,直到用户输入退出命令(如exit)才会终止循环。

完整工作流程拆解

  • 读取(Read):打印命令行提示符(如[my_shell]$ ),接收用户从终端输入的命令行字符串(如ls -l /home);
  • 解析(Parse):对读取到的命令行字符串进行处理,包括去除前后空格、拆分命令与参数、识别特殊符号(如&后台执行、>重定向、|管道);
  • 执行(Execute):根据解析结果执行命令,分为两种情况:
    • 内置命令(如cd、exit、pwd):直接在 Shell 进程自身中执行,无需创建子进程;
    • 外部命令(如ls、cat、gcc):通过fork创建子进程,再通过exec函数族替换子进程程序,执行对应的外部命令,最后通过wait/waitpid等待子进程执行完成(后台执行除外);
  • 反馈(Feedback):将命令执行结果(正常输出或错误信息)打印到终端,然后回到循环开头,等待用户输入下一条命令。
  • 可视化流程示意图

    1.3 实现自主 Shell 的核心技术栈

            要完成一个可运行的 Shell 命令行解释器,我们需要用到以下几方面的技术知识,这些都是 Linux C 编程的核心内容:

  • 终端 I/O 操作:使用fgetsprintf等函数读取用户输入、打印输出,处理终端缓冲区、换行符等问题;
  • 字符串处理:使用strtokstrcpystrcmpmemmove等函数实现命令行的拆分、修剪、匹配;
  • 进程控制:fork创建子进程、exec函数族实现程序替换、wait/waitpid实现进程等待,处理僵尸进程;
  • 内置命令实现:直接在 Shell 进程中实现cdexitpwd等命令,调用对应的系统调用(如chdir实现cd);
  • 特殊功能支持(进阶):处理&后台执行、>/<重定向、|管道等特殊符号,涉及文件描述符操作(open、dup2、close)。
  • 二、从零开始:实现一个最基础的 Shell(支持外部命令执行)

            我们先从最简单的版本入手,实现一个具备 “读取→解析→执行外部命令” 核心功能的基础 Shell,暂不考虑内置命令、后台执行等高级功能,先搭建起 Shell 的整体框架。

    2.1 步骤 1:搭建 Shell 的主循环框架

            Shell 的核心是一个无限循环,我们先实现这个循环,包括打印提示符、读取用户输入、处理空命令等基础功能。

    实战代码(my_shell_v1.c)

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

    #define MAX_CMD_LEN 1024 // 定义命令行的最大长度,防止缓冲区溢出

    // 函数声明:修剪字符串前后的空格和换行符
    void trim_cmd(char *cmd);

    int main(void)
    {
    char cmd_buf[MAX_CMD_LEN]; // 存储用户输入的命令行缓冲区

    // Shell主循环:无限循环,直到用户输入exit退出
    while (1)
    {
    // 1. 打印命令行提示符,格式类似 [my_shell] $
    printf("[my_shell] $ ");
    fflush(stdout); // 强制刷新标准输出缓冲区,确保提示符立即显示

    // 2. 读取用户输入的命令行
    if (fgets(cmd_buf, MAX_CMD_LEN, stdin) == NULL)
    {
    // 读取失败(如Ctrl+D),直接退出循环
    perror("fgets read cmd failed");
    break;
    }

    // 3. 处理读取到的命令行:修剪前后空格和换行符
    trim_cmd(cmd_buf);

    // 4. 处理空命令(用户仅输入空格或回车)
    if (strlen(cmd_buf) == 0)
    {
    continue;
    }

    // 5. 后续步骤:解析命令、执行命令(后续补充)
    printf("你输入的命令是:%s\\n", cmd_buf);
    }

    printf("my_shell exit successfully\\n");
    return 0;
    }

    // 辅助函数:修剪字符串前后的空格、制表符、换行符
    void trim_cmd(char *cmd)
    {
    if (cmd == NULL || strlen(cmd) == 0)
    {
    return;
    }

    // 步骤1:修剪字符串开头的空白字符(空格、制表符)
    int start = 0;
    while (cmd[start] == ' ' || cmd[start] == '\\t' || cmd[start] == '\\n')
    {
    start++;
    }

    // 步骤2:修剪字符串结尾的空白字符(空格、制表符、换行符)
    int end = strlen(cmd) – 1;
    while (end >= start && (cmd[end] == ' ' || cmd[end] == '\\t' || cmd[end] == '\\n'))
    {
    end–;
    }

    // 步骤3:将修剪后的字符串重新拷贝到原缓冲区,添加字符串结束符
    if (start > end)
    {
    // 全是空白字符,置为空字符串
    cmd[0] = '\\0';
    }
    else
    {
    memmove(cmd, cmd + start, end – start + 1);
    cmd[end – start + 1] = '\\0';
    }
    }

    代码编译与执行

    # 编译C语言代码
    gcc my_shell_v1.c -o my_shell_v1

    # 执行编译后的Shell程序
    ./my_shell_v1

    执行效果

    [my_shell] $
    你输入的命令是:
    [my_shell] $ ls -l
    你输入的命令是:ls -l
    [my_shell] $ pwd
    你输入的命令是:pwd
    [my_shell] $ ^C

    核心知识点解析

  • fflush(stdout)的作用:标准输出(stdout)是行缓冲的,只有遇到换行符\\n或缓冲区满时才会刷新输出。我们的提示符没有换行符,因此需要用fflush(stdout)强制刷新缓冲区,确保提示符能立即显示在终端上。
  • fgets的注意事项:fgets会读取用户输入的换行符\\n并存储在缓冲区中,因此需要通过trim_cmd函数去除换行符,否则后续解析命令会出现问题。
  • 缓冲区大小限制:定义MAX_CMD_LEN限制命令行长度,防止用户输入过长命令导致缓冲区溢出,提高程序安全性。
  • 2.2 步骤 2:实现命令行解析功能(拆分命令与参数)

            用户输入的命令行是一个完整的字符串(如ls -l /home),我们需要将其拆分为命令名和参数列表(如{"ls", "-l", "/home", NULL}),以便后续传递给exec函数族执行。

            这里我们使用strtok函数实现字符串拆分,strtok可以按照指定的分隔符(这里是空格、制表符)拆分字符串,返回拆分后的子字符串指针。

    实战代码(补充解析功能,my_shell_v2.c)

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

    #define MAX_CMD_LEN 1024 // 命令行最大长度
    #define MAX_ARG_NUM 64 // 命令参数的最大个数

    // 函数声明
    void trim_cmd(char *cmd);
    int parse_cmd(char *cmd, char *argv[]);

    int main(void)
    {
    char cmd_buf[MAX_CMD_LEN];
    char *argv[MAX_ARG_NUM]; // 存储命令参数的数组,argv[0]是命令名

    while (1)
    {
    // 1. 打印提示符
    printf("[my_shell] $ ");
    fflush(stdout);

    // 2. 读取用户输入
    if (fgets(cmd_buf, MAX_CMD_LEN, stdin) == NULL)
    {
    perror("fgets read cmd failed");
    break;
    }

    // 3. 修剪命令行
    trim_cmd(cmd_buf);
    if (strlen(cmd_buf) == 0)
    {
    continue;
    }

    // 4. 解析命令行,拆分命令与参数
    int arg_count = parse_cmd(cmd_buf, argv);
    if (arg_count == 0)
    {
    continue;
    }

    // 5. 打印解析结果(测试用,后续替换为执行逻辑)
    printf("解析结果:命令名=%s,参数个数=%d\\n", argv[0], arg_count);
    printf("参数列表:");
    for (int i = 0; i < arg_count; i++)
    {
    printf("%s ", argv[i]);
    }
    printf("\\n");
    }

    printf("my_shell exit successfully\\n");
    return 0;
    }

    // 辅助函数:修剪字符串前后的空白字符
    void trim_cmd(char *cmd)
    {
    if (cmd == NULL || strlen(cmd) == 0)
    {
    return;
    }

    int start = 0;
    while (cmd[start] == ' ' || cmd[start] == '\\t' || cmd[start] == '\\n')
    {
    start++;
    }

    int end = strlen(cmd) – 1;
    while (end >= start && (cmd[end] == ' ' || cmd[end] == '\\t' || cmd[end] == '\\n'))
    {
    end–;
    }

    if (start > end)
    {
    cmd[0] = '\\0';
    }
    else
    {
    memmove(cmd, cmd + start, end – start + 1);
    cmd[end – start + 1] = '\\0';
    }
    }

    // 核心函数:解析命令行,拆分命令与参数
    // 参数:cmd-修剪后的命令行字符串,argv-存储参数的数组
    // 返回值:参数的个数
    int parse_cmd(char *cmd, char *argv[])
    {
    if (cmd == NULL || argv == NULL || strlen(cmd) == 0)
    {
    return 0;
    }

    int arg_count = 0; // 记录参数个数
    // 第一次调用strtok,传入命令行字符串和分隔符
    char *token = strtok(cmd, " \\t"); // 以空格和制表符作为分隔符

    while (token != NULL && arg_count < MAX_ARG_NUM – 1)
    {
    argv[arg_count++] = token; // 将拆分后的子字符串存入argv数组
    token = strtok(NULL, " \\t"); // 后续调用strtok,第一个参数传NULL
    }

    argv[arg_count] = NULL; // 关键:argv数组必须以NULL结尾,供exec函数使用

    return arg_count;
    }

    代码编译与执行

    gcc my_shell_v2.c -o my_shell_v2
    ./my_shell_v2

    执行效果

    [my_shell] $ ls -l /home
    解析结果:命令名=ls,参数个数=3
    参数列表:ls -l /home
    [my_shell] $ pwd
    解析结果:命令名=pwd,参数个数=1
    参数列表:pwd

    核心知识点解析

  • strtok函数的使用:strtok是字符串拆分的核心函数,第一次调用时传入要拆分的字符串和分隔符,后续调用时第一个参数传NULL,表示继续拆分上一次的字符串。需要注意的是,strtok会修改原字符串,将分隔符替换为\\0。
  • argv数组的格式要求:exec函数族要求参数数组argv必须以NULL结尾,这是因为exec函数需要通过NULL来判断参数列表的结束,否则会出现未知错误。
  • 参数个数限制:定义MAX_ARG_NUM限制参数个数,防止用户输入过多参数导致argv数组溢出,提高程序的健壮性。
  • 2.3 步骤 3:实现外部命令执行(fork+exec+wait)

            解析完命令与参数后,我们需要实现外部命令的执行逻辑。核心思路是:通过fork创建子进程,在子进程中调用execvp函数执行外部命令,在父进程中调用waitpid等待子进程执行完成,回收子进程资源,避免僵尸进程。

    实战代码(补充执行功能,my_shell_v3.c)

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

    #define MAX_CMD_LEN 1024 // 命令行最大长度
    #define MAX_ARG_NUM 64 // 命令参数的最大个数

    // 函数声明
    void trim_cmd(char *cmd);
    int parse_cmd(char *cmd, char *argv[]);

    int main(void)
    {
    char cmd_buf[MAX_CMD_LEN];
    char *argv[MAX_ARG_NUM];

    while (1)
    {
    // 1. 打印提示符
    printf("[my_shell] $ ");
    fflush(stdout);

    // 2. 读取用户输入
    if (fgets(cmd_buf, MAX_CMD_LEN, stdin) == NULL)
    {
    perror("fgets read cmd failed");
    break;
    }

    // 3. 修剪命令行
    trim_cmd(cmd_buf);
    if (strlen(cmd_buf) == 0)
    {
    continue;
    }

    // 4. 解析命令行
    int arg_count = parse_cmd(cmd_buf, argv);
    if (arg_count == 0)
    {
    continue;
    }

    // 5. 执行外部命令(fork+exec+wait)
    pid_t pid = fork(); // 创建子进程
    if (pid == -1)
    {
    // fork创建子进程失败
    perror("fork create child process failed");
    continue;
    }
    else if (pid == 0)
    {
    // 子进程:执行外部命令
    execvp(argv[0], argv);
    // 若execvp返回,说明执行失败(命令不存在、权限不足等)
    perror("execvp execute command failed");
    exit(EXIT_FAILURE); // 子进程执行失败,退出并返回非0状态码
    }
    else
    {
    // 父进程(Shell进程):等待子进程执行完成
    int status;
    pid_t ret_pid = waitpid(pid, &status, 0); // 阻塞等待子进程
    if (ret_pid == -1)
    {
    perror("waitpid wait child process failed");
    continue;
    }

    // 可选:打印子进程的退出状态(调试用)
    printf("子进程(PID:%d)执行完成,退出状态码:%d\\n", pid, WEXITSTATUS(status));
    }
    }

    printf("my_shell exit successfully\\n");
    return 0;
    }

    // 辅助函数:修剪字符串前后的空白字符
    void trim_cmd(char *cmd)
    {
    if (cmd == NULL || strlen(cmd) == 0)
    {
    return;
    }

    int start = 0;
    while (cmd[start] == ' ' || cmd[start] == '\\t' || cmd[start] == '\\n')
    {
    start++;
    }

    int end = strlen(cmd) – 1;
    while (end >= start && (cmd[end] == ' ' || cmd[end] == '\\t' || cmd[end] == '\\n'))
    {
    end–;
    }

    if (start > end)
    {
    cmd[0] = '\\0';
    }
    else
    {
    memmove(cmd, cmd + start, end – start + 1);
    cmd[end – start + 1] = '\\0';
    }
    }

    // 核心函数:解析命令行,拆分命令与参数
    int parse_cmd(char *cmd, char *argv[])
    {
    if (cmd == NULL || argv == NULL || strlen(cmd) == 0)
    {
    return 0;
    }

    int arg_count = 0;
    char *token = strtok(cmd, " \\t");

    while (token != NULL && arg_count < MAX_ARG_NUM – 1)
    {
    argv[arg_count++] = token;
    token = strtok(NULL, " \\t");
    }

    argv[arg_count] = NULL;

    return arg_count;
    }

    代码编译与执行

    gcc my_shell_v3.c -o my_shell_v3
    ./my_shell_v3

    执行效果

    [my_shell] $ ls -l
    总用量 24
    -rwxr-xr-x 1 root root 8944 1月 17 15:30 my_shell_v3
    -rw-r–r– 1 root root 2568 1月 17 15:29 my_shell_v3.c
    -rwxr-xr-x 1 root root 8832 1月 17 14:50 my_shell_v2
    -rw-r–r– 1 root root 1876 1月 17 14:49 my_shell_v2.c
    -rwxr-xr-x 1 root root 8704 1月 17 14:20 my_shell_v1
    -rw-r–r– 1 root root 1024 1月 17 14:19 my_shell_v1.c
    子进程(PID:12345)执行完成,退出状态码:0
    [my_shell] $ pwd
    /root/my_shell
    子进程(PID:12346)执行完成,退出状态码:0
    [my_shell] $ whoami
    root
    子进程(PID:12347)执行完成,退出状态码:0

    核心知识点解析

  • execvp函数的优势:我们选择execvp函数执行外部命令,因为它支持通过PATH环境变量查找命令,无需用户输入命令的完整路径(如ls而不是/bin/ls),这与 bash 的行为一致,提升了 Shell 的易用性。
  • 进程等待的必要性:父进程(Shell)通过waitpid阻塞等待子进程执行完成,不仅可以回收子进程资源,避免僵尸进程,还可以获取子进程的退出状态码,了解命令的执行结果(0 表示成功,非 0 表示失败)。
  • WEXITSTATUS宏的使用:waitpidstatus参数是一个位图,WEXITSTATUS宏用于提取子进程的正常退出状态码,只有当子进程正常退出时(WIFEXITED(status)为真),该宏的返回值才有效。
  • 三、功能升级:添加内置命令支持(cd、exit、pwd)

            在基础 Shell 中,我们可以执行lspwd等外部命令,但当我们尝试执行cd命令时,会发现无法正常工作:

    [my_shell] $ cd /home
    子进程(PID:12348)执行完成,退出状态码:0
    [my_shell] $ pwd
    /root/my_shell

            这是因为cd命令是内置命令,它需要修改 Shell 进程自身的当前工作目录,而如果通过fork创建子进程执行cd,只会修改子进程的工作目录,子进程退出后,父进程(Shell)的工作目录不会发生任何变化。

            因此,对于内置命令,我们需要在 Shell 进程自身中直接执行,无需创建子进程。

    3.1 内置命令的特点与实现思路

    内置命令的核心特点

  • 直接在 Shell 进程中执行,不创建子进程;
  • 通常用于修改 Shell 的自身状态(如cd修改工作目录、alias设置别名)或执行简单的辅助功能(如exit退出 Shell、echo打印输出);
  • 执行速度快,无需进行进程创建和程序替换的开销。
  • 常见内置命令与对应的系统调用

    内置命令功能描述对应的系统调用 / 实现方法
    exit 退出 Shell 直接终止主循环,调用exit函数
    cd 切换工作目录 调用chdir系统调用
    pwd 打印当前工作目录 调用getcwd系统调用
    echo 打印指定内容 直接使用printf函数

    实现思路

  • 在解析命令后,先判断命令是否为内置命令;
  • 如果是内置命令,调用对应的实现函数执行,执行完成后回到主循环;
  • 如果不是内置命令,再执行fork+exec+wait的外部命令执行逻辑。
  • 3.2 实战代码:实现 cd、exit、pwd 内置命令(my_shell_v4.c)

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

    #define MAX_CMD_LEN 1024 // 命令行最大长度
    #define MAX_ARG_NUM 64 // 命令参数的最大个数

    // 函数声明
    void trim_cmd(char *cmd);
    int parse_cmd(char *cmd, char *argv[]);
    int execute_builtin_cmd(char *argv[]); // 执行内置命令

    int main(void)
    {
    char cmd_buf[MAX_CMD_LEN];
    char *argv[MAX_ARG_NUM];

    while (1)
    {
    // 1. 打印提示符
    printf("[my_shell] $ ");
    fflush(stdout);

    // 2. 读取用户输入
    if (fgets(cmd_buf, MAX_CMD_LEN, stdin) == NULL)
    {
    perror("fgets read cmd failed");
    break;
    }

    // 3. 修剪命令行
    trim_cmd(cmd_buf);
    if (strlen(cmd_buf) == 0)
    {
    continue;
    }

    // 4. 解析命令行
    int arg_count = parse_cmd(cmd_buf, argv);
    if (arg_count == 0)
    {
    continue;
    }

    // 5. 先判断是否为内置命令,若是则直接执行
    if (execute_builtin_cmd(argv))
    {
    continue;
    }

    // 6. 非内置命令,执行外部命令(fork+exec+wait)
    pid_t pid = fork();
    if (pid == -1)
    {
    perror("fork create child process failed");
    continue;
    }
    else if (pid == 0)
    {
    execvp(argv[0], argv);
    perror("execvp execute command failed");
    exit(EXIT_FAILURE);
    }
    else
    {
    int status;
    pid_t ret_pid = waitpid(pid, &status, 0);
    if (ret_pid == -1)
    {
    perror("waitpid wait child process failed");
    continue;
    }
    }
    }

    printf("my_shell exit successfully\\n");
    return 0;
    }

    // 辅助函数:修剪字符串前后的空白字符
    void trim_cmd(char *cmd)
    {
    if (cmd == NULL || strlen(cmd) == 0)
    {
    return;
    }

    int start = 0;
    while (cmd[start] == ' ' || cmd[start] == '\\t' || cmd[start] == '\\n')
    {
    start++;
    }

    int end = strlen(cmd) – 1;
    while (end >= start && (cmd[end] == ' ' || cmd[end] == '\\t' || cmd[end] == '\\n'))
    {
    end–;
    }

    if (start > end)
    {
    cmd[0] = '\\0';
    }
    else
    {
    memmove(cmd, cmd + start, end – start + 1);
    cmd[end – start + 1] = '\\0';
    }
    }

    // 核心函数:解析命令行,拆分命令与参数
    int parse_cmd(char *cmd, char *argv[])
    {
    if (cmd == NULL || argv == NULL || strlen(cmd) == 0)
    {
    return 0;
    }

    int arg_count = 0;
    char *token = strtok(cmd, " \\t");

    while (token != NULL && arg_count < MAX_ARG_NUM – 1)
    {
    argv[arg_count++] = token;
    token = strtok(NULL, " \\t");
    }

    argv[arg_count] = NULL;

    return arg_count;
    }

    // 核心函数:执行内置命令
    // 返回值:1-是内置命令并执行完成,0-不是内置命令
    int execute_builtin_cmd(char *argv[])
    {
    if (argv == NULL || argv[0] == NULL)
    {
    return 0;
    }

    // 1. 实现exit命令:退出Shell
    if (strcmp(argv[0], "exit") == 0)
    {
    printf("Goodbye! my_shell exit now…\\n");
    exit(EXIT_SUCCESS); // 终止Shell进程
    }

    // 2. 实现pwd命令:打印当前工作目录
    if (strcmp(argv[0], "pwd") == 0)
    {
    char cwd[PATH_MAX]; // PATH_MAX定义在limits.h中,是系统最大路径长度
    if (getcwd(cwd, sizeof(cwd)) != NULL)
    {
    printf("%s\\n", cwd);
    }
    else
    {
    perror("getcwd get current working directory failed");
    }
    return 1;
    }

    // 3. 实现cd命令:切换工作目录
    if (strcmp(argv[0], "cd") == 0)
    {
    // 获取目标目录:cd后无参数,默认切换到用户主目录(HOME环境变量)
    char *target_dir = argv[1];
    if (target_dir == NULL)
    {
    target_dir = getenv("HOME"); // 获取HOME环境变量
    if (target_dir == NULL)
    {
    fprintf(stderr, "cd: no HOME environment variable found\\n");
    return 1;
    }
    }

    // 调用chdir系统调用切换工作目录
    if (chdir(target_dir) == -1)
    {
    perror("cd: change directory failed");
    }
    return 1;
    }

    // 4. 后续可扩展其他内置命令:echo、alias等
    // if (strcmp(argv[0], "echo") == 0) {…}

    // 不是内置命令,返回0
    return 0;
    }

    代码编译与执行

    gcc my_shell_v4.c -o my_shell_v4
    ./my_shell_v4

    执行效果

    [my_shell] $ pwd
    /root/my_shell
    [my_shell] $ cd /home
    [my_shell] $ pwd
    /home
    [my_shell] $ cd
    [my_shell] $ pwd
    /root
    [my_shell] $ exit
    Goodbye! my_shell exit now…
    my_shell exit successfully

    核心知识点解析

  • cd命令的实现:
    • 调用chdir系统调用切换工作目录,chdir的参数是目标目录的路径,成功返回 0,失败返回 – 1;
    • 处理cd无参数的情况:默认切换到用户的主目录,通过getenv("HOME")获取HOME环境变量的值,这与 bash 的行为一致。
  • pwd命令的实现:调用getcwd系统调用获取当前工作目录,getcwd会将当前工作目录的路径存入指定的缓冲区,成功返回缓冲区指针,失败返回 NULL。
  • exit命令的实现:直接调用exit(EXIT_SUCCESS)终止 Shell 进程,结束主循环,实现 Shell 的正常退出。
  • 内置命令的判断逻辑:在执行外部命令之前,先调用execute_builtin_cmd函数判断是否为内置命令,若是则直接执行并返回 1,跳过外部命令的执行逻辑;若不是则返回 0,继续执行外部命令。
  • 四、进阶功能:支持后台执行(& 符号)

            在 bash 中,我们可以在命令末尾添加&符号,让命令在后台执行,此时 Shell 不会阻塞等待命令执行完成,而是立即返回提示符,允许用户输入下一条命令。例如:

    [bash] $ sleep 10 &
    [1] 12349

            我们的简易 Shell 也可以实现这个功能,核心思路是:

  • 解析命令行时,判断命令末尾是否有&符号;
  • 若有&符号,将其从参数列表中移除,并标记为后台执行;
  • 执行外部命令时,后台执行无需调用waitpid阻塞等待,直接返回主循环;
  • 为了避免后台进程退出后成为僵尸进程,需要在 Shell 中处理子进程的退出信号(SIGCHLD)。
  • 4.1 实战代码:添加后台执行支持(my_shell_v5.c)

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

    #define MAX_CMD_LEN 1024 // 命令行最大长度
    #define MAX_ARG_NUM 64 // 命令参数的最大个数

    // 函数声明
    void trim_cmd(char *cmd);
    int parse_cmd(char *cmd, char *argv[], int *is_background);
    int execute_builtin_cmd(char *argv[]);
    void sigchld_handler(int signo); // 处理SIGCHLD信号,回收后台子进程

    int main(void)
    {
    char cmd_buf[MAX_CMD_LEN];
    char *argv[MAX_ARG_NUM];
    int is_background; // 标记是否为后台执行:1-后台执行,0-前台执行

    // 注册SIGCHLD信号处理函数,回收后台子进程,避免僵尸进程
    if (signal(SIGCHLD, sigchld_handler) == SIG_ERR)
    {
    perror("signal register SIGCHLD handler failed");
    exit(EXIT_FAILURE);
    }

    while (1)
    {
    // 1. 打印提示符
    printf("[my_shell] $ ");
    fflush(stdout);

    // 2. 读取用户输入
    if (fgets(cmd_buf, MAX_CMD_LEN, stdin) == NULL)
    {
    perror("fgets read cmd failed");
    break;
    }

    // 3. 修剪命令行
    trim_cmd(cmd_buf);
    if (strlen(cmd_buf) == 0)
    {
    continue;
    }

    // 4. 解析命令行(新增:判断是否为后台执行)
    is_background = 0;
    int arg_count = parse_cmd(cmd_buf, argv, &is_background);
    if (arg_count == 0)
    {
    continue;
    }

    // 5. 执行内置命令
    if (execute_builtin_cmd(argv))
    {
    continue;
    }

    // 6. 执行外部命令(支持前台/后台执行)
    pid_t pid = fork();
    if (pid == -1)
    {
    perror("fork create child process failed");
    continue;
    }
    else if (pid == 0)
    {
    // 子进程:执行外部命令
    execvp(argv[0], argv);
    perror("execvp execute command failed");
    exit(EXIT_FAILURE);
    }
    else
    {
    // 父进程(Shell进程):根据是否后台执行,决定是否等待
    if (is_background)
    {
    // 后台执行:打印后台进程信息,不阻塞等待
    printf("后台进程启动,PID:%d\\n", pid);
    }
    else
    {
    // 前台执行:阻塞等待子进程执行完成
    int status;
    pid_t ret_pid = waitpid(pid, &status, 0);
    if (ret_pid == -1)
    {
    perror("waitpid wait child process failed");
    continue;
    }
    }
    }
    }

    printf("my_shell exit successfully\\n");
    return 0;
    }

    // 辅助函数:修剪字符串前后的空白字符
    void trim_cmd(char *cmd)
    {
    if (cmd == NULL || strlen(cmd) == 0)
    {
    return;
    }

    int start = 0;
    while (cmd[start] == ' ' || cmd[start] == '\\t' || cmd[start] == '\\n')
    {
    start++;
    }

    int end = strlen(cmd) – 1;
    while (end >= start && (cmd[end] == ' ' || cmd[end] == '\\t' || cmd[end] == '\\n'))
    {
    end–;
    }

    if (start > end)
    {
    cmd[0] = '\\0';
    }
    else
    {
    memmove(cmd, cmd + start, end – start + 1);
    cmd[end – start + 1] = '\\0';
    }
    }

    // 核心函数:解析命令行,拆分命令与参数(新增:判断后台执行)
    // 参数:is_background-输出型参数,1-后台执行,0-前台执行
    int parse_cmd(char *cmd, char *argv[], int *is_background)
    {
    if (cmd == NULL || argv == NULL || is_background == NULL || strlen(cmd) == 0)
    {
    return 0;
    }

    int arg_count = 0;
    char *token = strtok(cmd, " \\t");

    while (token != NULL && arg_count < MAX_ARG_NUM – 1)
    {
    argv[arg_count++] = token;
    token = strtok(NULL, " \\t");
    }

    argv[arg_count] = NULL;

    // 判断是否为后台执行:最后一个参数是否为&
    if (arg_count > 0 && strcmp(argv[arg_count – 1], "&") == 0)
    {
    *is_background = 1;
    argv[arg_count – 1] = NULL; // 移除&符号,避免传递给exec函数
    arg_count–; // 参数个数减1
    }
    else
    {
    *is_background = 0;
    }

    return arg_count;
    }

    // 核心函数:执行内置命令
    int execute_builtin_cmd(char *argv[])
    {
    if (argv == NULL || argv[0] == NULL)
    {
    return 0;
    }

    // 1. 实现exit命令
    if (strcmp(argv[0], "exit") == 0)
    {
    printf("Goodbye! my_shell exit now…\\n");
    exit(EXIT_SUCCESS);
    }

    // 2. 实现pwd命令
    if (strcmp(argv[0], "pwd") == 0)
    {
    char cwd[PATH_MAX];
    if (getcwd(cwd, sizeof(cwd)) != NULL)
    {
    printf("%s\\n", cwd);
    }
    else
    {
    perror("getcwd get current working directory failed");
    }
    return 1;
    }

    // 3. 实现cd命令
    if (strcmp(argv[0], "cd") == 0)
    {
    char *target_dir = argv[1];
    if (target_dir == NULL)
    {
    target_dir = getenv("HOME");
    if (target_dir == NULL)
    {
    fprintf(stderr, "cd: no HOME environment variable found\\n");
    return 1;
    }
    }

    if (chdir(target_dir) == -1)
    {
    perror("cd: change directory failed");
    }
    return 1;
    }

    // 不是内置命令,返回0
    return 0;
    }

    // 信号处理函数:处理SIGCHLD信号,回收后台子进程(避免僵尸进程)
    void sigchld_handler(int signo)
    {
    (void)signo; // 消除未使用参数的警告

    // 循环调用waitpid,回收所有已退出的子进程(非阻塞模式)
    pid_t pid;
    int status;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
    {
    printf("后台进程(PID:%d)执行完成,已自动回收\\n", pid);
    }
    }

    代码编译与执行

    gcc my_shell_v5.c -o my_shell_v5
    ./my_shell_v5

    执行效果

    [my_shell] $ sleep 10 &
    后台进程启动,PID:12350
    [my_shell] $ pwd
    /root/my_shell
    [my_shell] $ ls -l
    总用量 32
    -rwxr-xr-x 1 root root 9152 1月 17 16:30 my_shell_v5
    -rw-r–r– 1 root root 3876 1月 17 16:29 my_shell_v5.c
    …(其他文件列表)
    后台进程(PID:12350)执行完成,已自动回收
    [my_shell] $ exit
    Goodbye! my_shell exit now…

    核心知识点解析

  • 后台执行的判断逻辑:在parse_cmd函数中,判断最后一个参数是否为&,若是则标记is_background为 1,并将&从参数列表中移除(避免传递给exec函数导致命令执行失败)。
  • SIGCHLD信号处理:当子进程退出时,内核会向父进程发送SIGCHLD信号。我们注册sigchld_handler信号处理函数,在函数中通过waitpid(-1, &status, WNOHANG)非阻塞地回收所有已退出的子进程,避免后台进程成为僵尸进程。
  • waitpid的非阻塞模式:waitpid的第三个参数设为WNOHANG时,若没有已退出的子进程,会立即返回 0,不会阻塞父进程。使用循环调用waitpid,可以确保回收所有已退出的子进程。
  • 后台进程的提示信息:前台执行时,父进程阻塞等待子进程;后台执行时,父进程不阻塞,直接打印后台进程的 PID,让用户了解后台进程的状态。

  • 总结

            这个 Shell 虽然简单,但已经具备了 bash 的核心工作流程,能够满足基本的终端使用需求,让我们深刻理解了 Shell 命令行解释器的底层工作原理。

            打造自主 Shell 的过程,是一个将 Linux C 编程知识融会贯通的过程。通过这个过程,我们不仅掌握了 Shell 的工作原理,还加深了对进程控制、字符串处理、信号处理等核心知识点的理解,为后续开发更复杂的 Linux 应用程序打下了坚实的基础。

            如果你在实现过程中遇到了问题,或者有更好的功能扩展思路,欢迎在评论区留言讨论!

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux系统编程】(十八)手搓 Linux Shell 命令行解释器:从 0 到 1 打造属于你的终端神器
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址