目录

前言

一、前置认知:什么是 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 -lpwdgit 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)才会终止循环。

完整工作流程拆解

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

可视化流程示意图

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

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

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

二、从零开始:实现一个最基础的 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

核心知识点解析

  1. fflush(stdout)的作用:标准输出(stdout)是行缓冲的,只有遇到换行符\n或缓冲区满时才会刷新输出。我们的提示符没有换行符,因此需要用fflush(stdout)强制刷新缓冲区,确保提示符能立即显示在终端上。
  2. fgets的注意事项fgets会读取用户输入的换行符\n并存储在缓冲区中,因此需要通过trim_cmd函数去除换行符,否则后续解析命令会出现问题。
  3. 缓冲区大小限制:定义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 

核心知识点解析

  1. strtok函数的使用strtok是字符串拆分的核心函数,第一次调用时传入要拆分的字符串和分隔符,后续调用时第一个参数传NULL,表示继续拆分上一次的字符串。需要注意的是,strtok会修改原字符串,将分隔符替换为\0
  2. argv数组的格式要求exec函数族要求参数数组argv必须以NULL结尾,这是因为exec函数需要通过NULL来判断参数列表的结束,否则会出现未知错误。
  3. 参数个数限制:定义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

核心知识点解析

  1. execvp函数的优势:我们选择execvp函数执行外部命令,因为它支持通过PATH环境变量查找命令,无需用户输入命令的完整路径(如ls而不是/bin/ls),这与 bash 的行为一致,提升了 Shell 的易用性。
  2. 进程等待的必要性:父进程(Shell)通过waitpid阻塞等待子进程执行完成,不仅可以回收子进程资源,避免僵尸进程,还可以获取子进程的退出状态码,了解命令的执行结果(0 表示成功,非 0 表示失败)。
  3. 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 内置命令的特点与实现思路

内置命令的核心特点

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

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

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

实现思路

  1. 在解析命令后,先判断命令是否为内置命令;
  2. 如果是内置命令,调用对应的实现函数执行,执行完成后回到主循环;
  3. 如果不是内置命令,再执行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

核心知识点解析

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

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

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

[bash] $ sleep 10 &
[1] 12349

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

  1. 解析命令行时,判断命令末尾是否有&符号;
  2. 若有&符号,将其从参数列表中移除,并标记为后台执行;
  3. 执行外部命令时,后台执行无需调用waitpid阻塞等待,直接返回主循环;
  4. 为了避免后台进程退出后成为僵尸进程,需要在 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...

核心知识点解析

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

总结

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

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

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

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐