🛡️

身份验证

答对题目即可进入,验证你是谁

剩余尝试次数:3

Mini Shell — 构建简易命令行解释器

C语言学习教程

Mini Shell — 构建简易命令行解释器

这是你 C 语言学习之旅中第一个”完整项目”。我们将从零开始,一步一步构建一个能真正运行命令的 Shell。写完它,你会恍然大悟:原来 bashzsh 的核心原理就是这些!

先剧透一下:我们最终的程序将在 100 行以内实现一个迷你 Shell。你可以在同学面前炫耀:“我写了一个操作系统 Shell。“


1. 架构全景

在我们敲代码之前,先在大脑中建好蓝图。

Shell 的生命周期

         ┌──────────────┐
         │  显示提示符   │  ← "mysh> "
         └──────┬───────┘

         ┌──────────────┐
         │  读取用户输入  │  ← fgets()
         └──────┬───────┘

         ┌──────────────┐
         │  解析命令      │  ← strtok() 分割
         └──────┬───────┘

         ┌──────────────┐         ┌──────────────────┐
         │  内置命令?     │──是──→  │ 直接执行(cd等)  │
         └──────┬───────┘         └──────────────────┘
                │ 否                      │
                ↓                         │
         ┌──────────────┐                 │
         │  fork() 子进程 │                │
         └──────┬───────┘                 │
                ↓                         │
         ┌──────────────┐                 │
         │  execvp() 执行 │                │
         └──────┬───────┘                 │
                ↓                         │
         ┌──────────────┐                 │
         │  wait() 等待  │                 │
         └──────┬───────┘                 │
                ↓                         │
                └──────────←──────────────┘

                    ┌──────────────┐
                    │  循环回到顶部  │
                    └──────────────┘

核心系统调用关系

main()

  ├─ fgets()      ← 读标准输入
  ├─ strtok()     ← 拆分字符串
  ├─ fork()       ← 复制进程
  │    │
  │    ├─ [父进程] wait()    ← 等子进程结束
  │    │
  │    └─ [子进程] execvp()  ← 替换成目标程序
  │              │
  │              └─ (成功后不会返回)
  │              └─ (失败后 exit(1))

  └─ 循环

2. 分步实现

我们就按照上图的流程,一个模块一个模块地写。

步骤 1:最基本的骨架

先搞定”提示符 → 读输入 → 回显”这个循环。

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

#define MAX_CMD 1024

int main() {
    char cmd[MAX_CMD];

    while (1) {
        // 1. 显示提示符
        printf("mysh> ");
        fflush(stdout);   // ⚠️ 必须刷新!否则提示符不显示

        // 2. 读取一行输入
        if (fgets(cmd, MAX_CMD, stdin) == NULL) {
            // Ctrl+D (EOF)
            printf("\n");
            break;
        }

        // 3. 去掉末尾的换行符
        cmd[strcspn(cmd, "\n")] = '\0';

        // 4. 忽略空行
        if (strlen(cmd) == 0) continue;

        // (目前只是回显)
        printf("你输入了: [%s]\n", cmd);
    }

    return 0;
}

编译并测试:

$ gcc -o mysh step1.c
$ ./mysh
mysh> hello world
你输入了: [hello world]
mysh> ls -la
你输入了: [ls -la]
mysh>
( Ctrl+D 退出)

关键细节fflush(stdout) 是必须的!stdout 默认是行缓冲,没有换行就不会自动输出。不加这行的话,你可能会看到”先输入命令,提示符才出现”的诡异现象。

步骤 2:解析命令

strtok 把一行字符串拆成 “程序名 + 参数1 + 参数2 + …”。

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

#define MAX_CMD 1024
#define MAX_ARGS 128

void parse_command(char *cmd, char **args) {
    int i = 0;
    char *token = strtok(cmd, " \t");  // 空格和Tab都是分隔符
    while (token != NULL && i < MAX_ARGS - 1) {
        args[i++] = token;
        token = strtok(NULL, " \t");
    }
    args[i] = NULL;   // execvp 要求 args 以 NULL 结尾
}

int main() {
    char cmd[MAX_CMD];

    while (1) {
        printf("mysh> ");
        fflush(stdout);

        if (fgets(cmd, MAX_CMD, stdin) == NULL) break;
        cmd[strcspn(cmd, "\n")] = '\0';
        if (strlen(cmd) == 0) continue;

        char *args[MAX_ARGS];
        parse_command(cmd, args);

        // 打印解析结果
        printf("程序: %s\n", args[0]);
        for (int i = 1; args[i] != NULL; i++) {
            printf("  参数%d: %s\n", i, args[i]);
        }
    }

    return 0;
}
$ ./mysh
mysh> echo Hello    World
程序: echo
  参数1: Hello
  参数2: World

mysh> ls -la /tmp
程序: ls
  参数1: -la
  参数2: /tmp

步骤 3:fork + exec — 真正执行命令

这是 Shell 的”灵魂”部分。理解 fork 和 exec 的关系是理解操作系统进程模型的关键。

fork 的魔法:一个进程调用 fork(),瞬间变成两个一模一样的进程——父进程和子进程。唯一的区别是 fork() 的返回值:

exec 的变身:子进程调用 execvp()把自己完全替换成另一个程序。原来的代码被覆盖,新程序从 main() 开始运行。execvp 成功后,它后面的代码永远、永远、永远不会执行。

调用 fork() 之前:
    [父进程: mysh]

调用 fork() 之后:
    [父进程: mysh]  ←── fork() 返回子进程 PID
    [子进程: mysh]  ←── fork() 返回 0

子进程调 execvp("ls", args) 之后:
    [父进程: mysh]  ←── wait() 等待
    [子进程: ls]    ←── 完全变成了 ls 程序!

代码实现:

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

#define MAX_CMD 1024
#define MAX_ARGS 128

void parse_command(char *cmd, char **args) {
    int i = 0;
    char *token = strtok(cmd, " \t");
    while (token != NULL && i < MAX_ARGS - 1) {
        args[i++] = token;
        token = strtok(NULL, " \t");
    }
    args[i] = NULL;
}

int main() {
    char cmd[MAX_CMD];

    while (1) {
        printf("mysh> ");
        fflush(stdout);

        if (fgets(cmd, MAX_CMD, stdin) == NULL) break;
        cmd[strcspn(cmd, "\n")] = '\0';
        if (strlen(cmd) == 0) continue;

        char *args[MAX_ARGS];
        parse_command(cmd, args);

        // ======== 核心:fork + exec ========
        pid_t pid = fork();

        if (pid < 0) {
            perror("fork 失败");
            continue;
        }

        if (pid == 0) {
            // 我是子进程
            execvp(args[0], args);

            // 如果执行到这里,说明 execvp 失败了
            fprintf(stderr, "mysh: 命令未找到: %s\n", args[0]);
            exit(1);
        } else {
            // 我是父进程
            int status;
            waitpid(pid, &status, 0);
        }
    }

    return 0;
}
$ ./mysh
mysh> ls
Makefile  mysh  mysh.c  README.md
mysh> echo 你好世界
你好世界
mysh> pwd
/home/user/c-projects
mysh> date
2026年 06月 11日 星期四 15:30:42 CST
mysh>

太棒了!它能运行真正的命令了!但它还有一个致命问题,我们马上解决。

步骤 4:添加 cdexit 内置命令

你可能会发现:在当前版本中执行 cd /tmp 不会报错,但也无法真正切换目录。为什么?因为 cd 被执行在子进程中——子进程切换了目录然后退出,父进程(你的 Shell)的目录纹丝不动。

内置命令就是在 Shell 自己的进程里执行,不 fork 子进程的命令。

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

#define MAX_CMD 1024
#define MAX_ARGS 128

// ... parse_command 同上 ...

int execute_builtin(char **args) {
    if (strcmp(args[0], "exit") == 0) {
        printf("再见!👋\n");
        exit(0);
    }

    if (strcmp(args[0], "cd") == 0) {
        const char *dir = args[1] ? args[1] : getenv("HOME");
        if (dir == NULL) {
            fprintf(stderr, "cd: HOME 未设置\n");
        } else if (chdir(dir) != 0) {
            perror("cd");
        }
        return 1;   // 已处理
    }

    return 0;   // 不是内置命令
}

int main() {
    char cmd[MAX_CMD];

    while (1) {
        // 显示当前目录在提示符中
        char cwd[1024];
        if (getcwd(cwd, sizeof(cwd)) != NULL) {
            printf("mysh:%s$ ", cwd);
        } else {
            printf("mysh> ");
        }
        fflush(stdout);

        if (fgets(cmd, MAX_CMD, stdin) == NULL) break;
        cmd[strcspn(cmd, "\n")] = '\0';
        if (strlen(cmd) == 0) continue;

        char *args[MAX_ARGS];
        parse_command(cmd, args);

        // 先检查内置命令
        if (execute_builtin(args)) continue;

        // 外部命令:fork + exec
        pid_t pid = fork();
        if (pid < 0) {
            perror("fork 失败");
        } else if (pid == 0) {
            execvp(args[0], args);
            fprintf(stderr, "mysh: 命令未找到: %s\n", args[0]);
            exit(1);
        } else {
            waitpid(pid, NULL, 0);
        }
    }

    return 0;
}
mysh:/home/user$ cd /tmp
mysh:/tmp$ ls
temp_file.txt  socket.123
mysh:/tmp$ cd
mysh:/home/user$ exit
再见!👋

3. 进阶功能

基础版已经完成了。下面是一些让你 Shell 变强大的扩展。每个都是一个独立的挑战。

扩展 A:支持管道 |

核心思路:用 pipe() 创建管道,把前一个命令的输出连接到后一个命令的输入。

cmd1 | cmd2 的实现步骤:
1. pipe(fd)       ← 创建管道(fd[0]读端,fd[1]写端)
2. fork() 子进程1
   ├─ dup2(fd[1], STDOUT_FILENO)  ← 把标准输出重定向到管道写端
   ├─ close(fd[0]), close(fd[1])
   └─ execvp(cmd1)
3. fork() 子进程2
   ├─ dup2(fd[0], STDIN_FILENO)   ← 把标准输入重定向到管道读端
   ├─ close(fd[0]), close(fd[1])
   └─ execvp(cmd2)
4. close(fd[0]), close(fd[1])  ← 父进程关闭管道两端
5. wait() 两个子进程

关键代码片段:

int pipefd[2];
pipe(pipefd);

// 左边命令
pid_t pid1 = fork();
if (pid1 == 0) {
    dup2(pipefd[1], STDOUT_FILENO);
    close(pipefd[0]); close(pipefd[1]);
    execvp(cmd1[0], cmd1);
    exit(1);
}

// 右边命令
pid_t pid2 = fork();
if (pid2 == 0) {
    dup2(pipefd[0], STDIN_FILENO);
    close(pipefd[0]); close(pipefd[1]);
    execvp(cmd2[0], cmd2);
    exit(1);
}

close(pipefd[0]); close(pipefd[1]);
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);

扩展 B:支持重定向 ><

// 输出重定向: cmd > file.txt
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
close(fd);
execvp(cmd[0], cmd);

// 输入重定向: cmd < file.txt
int fd = open(filename, O_RDONLY);
dup2(fd, STDIN_FILENO);
close(fd);
execvp(cmd[0], cmd);

扩展 C:支持后台运行 &

int background = 0;
if (args[i-1] && strcmp(args[i-1], "&") == 0) {
    background = 1;
    args[i-1] = NULL;   // 去掉 &
}

// 在父进程中:
if (!background) {
    waitpid(pid, &status, 0);   // 前台:等待
} else {
    printf("[%d] 后台运行\n", pid);  // 后台:不等待
}

扩展 D:信号处理(Ctrl+C)

#include <signal.h>

// 全局信号处理函数
void sigint_handler(int sig) {
    printf("\n");           // 换行
    // 不退出!让 Shell 继续运行
}

int main() {
    signal(SIGINT, sigint_handler);   // 注册 Ctrl+C 处理器
    // ... 其余代码 ...
}

4. 测试策略

单元测试(针对解析器)

void test_parse() {
    char input[] = "echo hello world";
    char *args[10];
    parse_command(input, args);

    assert(strcmp(args[0], "echo") == 0);
    assert(strcmp(args[1], "hello") == 0);
    assert(strcmp(args[2], "world") == 0);
    assert(args[3] == NULL);
    printf("✓ parse_command 测试通过\n");
}

集成测试(自动化)

# test.sh — 自动化测试脚本
echo "=== 测试 1: 内置命令 cd ==="
./mysh << EOF
cd /tmp
pwd
EOF

echo "=== 测试 2: 外部命令 ==="
./mysh << EOF
echo "hello shell"
ls -1 | wc -l
EOF

echo "=== 测试 3: 错误处理 ==="
./mysh << EOF
this_command_does_not_exist
EOF

手动测试清单

测试项预期结果
空行回车什么都不做,显示新提示符
ls列出文件
cd /tmp 然后 pwd显示 /tmp
cd (无参数)回到 HOME 目录
exitShell 退出
连续多个空格正确解析("ls -la"
不存在的命令显示错误信息,Shell 不崩溃
Tab 键分隔能正确解析
超长输入不缓冲区溢出

5. 代码走读:完整版回顾

让我们把这个项目放在一起,看看最终的结构:

mysh.c 文件结构:
─────────────────────────────────────────
  行 1-10:    #include 和宏定义
  行 12-20:   parse_command() — 命令解析
  行 22-40:   execute_builtin() — 内置命令(cd, exit)
  行 42-80:   main()
                42-55: 提示符循环 + 输入读取
                56-60: 调用 parse_command
                61-63: 检查内置命令
                64-78: fork + exec + wait
─────────────────────────────────────────

你写了什么:不到 100 行代码。 你用了哪些知识:字符串处理、结构体、指针数组、进程管理、系统调用、错误处理、命令行参数。 你实现了什么:一个活的、能真正运行命令的 Shell。

如果把 bash 比作一辆汽车,你现在造出了一个能跑的小推车——引擎、轮子、方向盘都有,理解了全部基本原理。


6. 常见 Bug 和调试技巧

Bug 1:提示符不显示

症状:要先输入一行命令,提示符才出现。

原因printf("mysh> ") 后没有 fflush(stdout)

修复:在 printf 后立即加 fflush(stdout)

Bug 2:cd 不生效

症状:执行 cd /tmp 后,pwd 还是在原目录。

原因:你在子进程里 chdir 了。

修复cd 必须是内置命令。

Bug 3:僵尸进程

症状:用 ps 看到很多 <defunct> 进程。

原因:父进程没有 wait()

修复:确保每个子进程都有对应的 waitpid

调试技巧

# 编译时加调试信息
gcc -g -Wall -o mysh mysh.c

# 用 gdb 调试
gdb ./mysh

# 在代码中加调试输出
#ifdef DEBUG
    fprintf(stderr, "DEBUG: fork returned %d\n", pid);
#endif

编译调试版:

gcc -DDEBUG -o mysh-debug mysh.c

7. 学到的概念迁移

这个项目教会你的,远远不止写一个 Shell:

概念你用它做了什么实际应用
fork + exec运行外部命令所有守护进程、Web 服务器
内置命令cd, exitCLI 工具设计模式
管道 pipels | grep c微服务数据流
重定向 dup2cmd > file日志系统、数据导入
信号处理 signalCtrl+C 不退出优雅关闭服务器
waitpid等子进程结束任务调度器

你学的不是”怎么用 C 写玩具”,而是操作系统如何管理进程这一底层知识。


本章掌握清单

下一站

搞定了 Shell,你相当于理解了整个操作系统的”外壳”。下一章我们要实现 grepwc ——这些你每天用的文本处理工具。你不再是一个”用户”,而是一个”造工具的人”。准备好了吗?