shell 模拟实现

摘要

一、前言:为什么要自己实现一个 Shell?

在学习 Linux 的过程中,我们几乎每天都在使用 Shell。

复制代码
ls
cd
grep
ps
cat
make
gcc

这些命令早已熟悉到不能再熟悉。但很少有人会停下来思考一个问题:

这些命令是如何被执行的?为什么输入一行字符串,系统就能创建进程、建立管道、完成重定向?Shell 到底做了什么?

大多数人把 Shell 当作 "理所当然的存在",却从未真正理解它。

而事实上:

Shell 是 Linux 用户态编程最核心的一块拼图。

如果说文件 I/O 让你理解了 "数据如何流动",进程控制让你理解了 "程序如何运行",那么 Shell 则是把所有机制整合起来的"调度中心"。

1.1、Shell 本质是什么?

从本质上看,Shell 是一个用户态程序。它完成的工作非常朴素:

  1. 读取用户输入
  2. 解析命令
  3. 创建子进程
  4. 执行程序
  5. 等待结果
  6. 再次进入循环

这是一个典型的 REPL(Read--Eval--Print Loop)模型:

复制代码
while (true)
    读取输入
    解析
    执行

听起来很简单。

但当你深入实现时,你会发现:

  • 命令如何拆分参数?
  • 重定向如何修改文件描述符?
  • 管道如何连接多个进程?
  • 为什么 cd 不能用 exec 执行?
  • Ctrl+C 为什么能终止前台进程?
  • 后台任务如何避免僵尸进程?

这些问题的背后,是 Linux 最核心的一批系统调用:

  • fork
  • exec
  • wait
  • pipe
  • dup2
  • open
  • close
  • signal
  • setpgid
  • tcsetpgrp

换句话说:

实现一个 Shell,本质上是在系统调用层面重构 Linux 用户态执行模型。

1.2、为什么要自己实现一个 Shell?

很多人会问:

既然系统已经有 Bash,为什么还要重复造轮子?

原因很简单:因为 Bash 太复杂,你永远无法从源码中 "整体理解"。

Bash 源码体量巨大,涉及:

  • 语法树
  • 脚本解释器
  • 模块化加载
  • 历史记录
  • 扩展机制

对于初学者来说几乎无法系统掌握。

很多人学 Linux:

  • 会写 fork
  • 会写 exec
  • 会用 dup2
  • 会用 pipe

但这些知识往往是 "割裂的"。真正的理解,不来自于看书,而来自于:

把这些 API 组合起来,写出一个完整系统。

而自己实现一个 Mini Shell,你会:

  • 真正理解 fork 的执行语义
  • 理解 exec 如何替换进程映像
  • 理解文件描述符表的结构
  • 理解管道的内核缓冲机制
  • 理解前台进程组与信号传播
  • 理解为什么必须关闭无用 fd

这是 "系统认知" 的跃迁。

1.3、本文目标:实现一个真正可用的 Mini Shell

这不是一个 "玩具示例"。本文将从 0 开始,构建一个具备真实功能的 Shell,支持:

  • 普通命令执行
  • 内建命令(cd、exit、export)
  • 输入输出重定向(> >> <)
  • 多级管道(|)
  • 后台执行(&)
  • 环境变量展开($HOME)
  • 信号处理(Ctrl+C)
  • 僵尸进程回收
  • 简单作业控制

并且:

  • 所有核心代码完整给出
  • 每一行关键系统调用逐行解析
  • 每一个设计选择解释原因
  • 每一个坑点给出解决方案

目标不是 "写出来能跑"。

而是:

写出来之后,你能够从内核视角理解整个执行流程。

我们不会停留在:

复制代码
system("ls");

这种伪实现。

我们要从 0 开始,用 C 语言一步步构建一个真正可运行的 Shell。

1.4、你将掌握什么?

当你完整读完并实现本文内容,你将真正掌握:

进程控制模型

  • fork 的写时复制
  • 父子进程调度
  • wait 与僵尸进程

程序加载机制

  • exec 如何加载 ELF
  • PATH 查找逻辑

文件描述符体系

  • STDIN / STDOUT / STDERR
  • dup2 的本质
  • 重定向如何改变 fd 表

管道与 IPC

  • pipe 的实现原理
  • 多级管道的构建方式
  • 为什么必须关闭无用端

信号与终端控制

  • SIGINT 的传播机制
  • 前台进程组
  • 作业控制基础

这已经是:

Linux 用户态开发的核心能力全覆盖。

1.5、这篇文章适合谁?

这篇文章适合:

  • 已学习完 Linux 基础 I/O
  • 已掌握 fork / exec / wait
  • 想真正理解系统调用背后的执行逻辑
  • 准备 C/C++ Linux 方向面试
  • 想写出高质量系统级博客的人

如果你只停留在 "能用命令" 的阶段,这篇文章会带你进入:

"理解命令如何被执行" 的阶段。

1.6、我们将如何构建它?

本文采用渐进式构建方式:

  1. 先实现最小可执行 Shell
  2. 再加入内建命令
  3. 再加入重定向
  4. 再加入管道
  5. 再加入后台执行
  6. 再处理信号
  7. 最终扩展作业控制

每一步都能独立运行。

每一步都在增强能力。

最终,我们会得到一个结构清晰、可扩展、可维护的 Mini Shell。

1.7、从 "会用" 到 "会实现"

很多人使用 Linux 十年,却从未想过:

Shell 其实只是一个普通程序。

当你亲手写出它,你会突然意识到:

Linux 并没有魔法。

所有的一切,不过是:

  • 进程
  • 文件描述符
  • 系统调用

而 Shell,只是把它们优雅地组织在一起。

在上一篇文章中,我们已经完成了 Linux 基础 I/O 的深入学习。

现在,是时候进入更高维度的整合阶段。

接下来,我们将:

从 0 构建一个属于我们自己的 Linux Shell。

当你真正理解 Shell,你才真正理解 Linux。

二、Shell 的整体架构设计

在正式开始编码之前,我们必须先回答一个问题:

一个 Shell 在结构上应该如何设计?

很多人写 Shell 时,会直接在 main() 里堆代码:

  • fgets
  • strtok
  • fork
  • exec
  • wait

看似可以运行,但随着功能增加(重定向、管道、信号、后台执行),代码会迅速失控。

因此,在实现之前,我们必须进行完整的架构设计。

2.1、Shell 的本质:一个持续运行的调度程序

从宏观角度看,Shell 是一个典型的 REPL 程序:

复制代码
REPL = Read → Eval → Print → Loop

用代码抽象就是:

复制代码
while (1) {
    read_input();
    parse_command();
    execute_command();
}

但真正的 Shell 远不止三行代码。

我们将其拆解为四个核心阶段:

  1. 输入阶段(Input)
  2. 解析阶段(Parser)
  3. 执行阶段(Executor)
  4. 控制阶段(Control)

它们构成了 Shell 的核心执行链。

2.2、Shell 执行流程总览

一个完整命令的生命周期如下:

复制代码
用户输入
   ↓
读取一行字符串
   ↓
词法分析(分词)
   ↓
语法解析(识别管道、重定向、后台符号)
   ↓
构建命令结构体
   ↓
判断是否内建命令
   ↓
如果是 → 内建执行
如果不是 → fork + exec
   ↓
处理重定向
   ↓
处理管道
   ↓
父进程等待 or 后台运行
   ↓
回到循环

这是一个 "流水线式架构"。每一步都应该是一个独立模块。

2.3、模块化设计

为了保证可读性和可扩展性,我们采用模块化设计。

推荐项目结构:

复制代码
myshell/
├── main.c          // 主循环
├── input.c         // 输入模块
├── parser.c        // 解析模块
├── executor.c      // 执行模块
├── builtin.c       // 内建命令
├── redirect.c      // 重定向处理
├── pipe.c          // 管道处理
├── signal.c        // 信号处理
├── job.c           // 作业控制
├── shell.h         // 公共结构
└── Makefile

模块职责划分:

模块 职责
input 读取命令
parser 分析字符串,构建结构
executor 负责 fork / exec
builtin 内建命令
redirect fd 重定向
pipe 多级管道
signal 信号处理
job 后台任务

这才是一个 "工程级 Shell" 的结构。

2.4、核心数据结构设计

Shell 的核心不是 fork,而是 "命令结构的抽象"。

我们必须定义一个可以表达:

  • 普通命令
  • 重定向
  • 管道
  • 后台执行

的结构体。

单个命令结构

复制代码
typedef struct command {
    char **argv;              // 参数数组
    int argc;                 // 参数数量

    char *input_redirect;     // 输入重定向文件
    char *output_redirect;    // 输出重定向文件
    int append;               // 是否追加模式

    int background;           // 是否后台执行

    struct command *next;     // 指向下一个管道命令
} command_t;

设计说明:

argv

用于 execvp:

复制代码
execvp(argv[0], argv);

重定向字段

支持:

复制代码
ls > file.txt
cat < file.txt
echo hi >> file.txt

next 指针

用于支持:

复制代码
ls | grep txt | wc -l

本质上:

  • 每一个管道是一个 command
  • 通过链表串联

这是一种 "线性 AST" 的简化模型。

2.5、为什么使用链表结构?

考虑命令:

复制代码
cmd1 | cmd2 | cmd3 | cmd4

如果我们用数组存储,需要动态扩容。

但链表天然适合表示 "管道链":

复制代码
cmd1 → cmd2 → cmd3 → cmd4

每个节点代表一个独立进程。

执行时:

  • 当前命令创建 pipe
  • fork 子进程
  • 连接前后 fd
  • 移动到 next

这是最清晰的执行模型。

2.6、Shell 的主循环设计

我们设计 main.c:

复制代码
int main() {
    init_shell();

    while (1) {
        print_prompt();

        char *line = read_line();
        if (!line) continue;

        command_t *cmd = parse_line(line);
        if (!cmd) continue;

        execute_command(cmd);

        free_command(cmd);
    }

    return 0;
}

注意几个关键点:

  • 每轮循环必须释放内存
  • 解析与执行必须分离
  • 不允许在 main 中写系统调用逻辑

2.7、执行模型设计

执行逻辑必须分两种情况:

2.7.1、情况 1:内建命令

复制代码
cd
exit
export

必须在父进程执行。

因为:

  • 改变当前目录
  • 修改环境变量

如果 fork 后执行,父进程不会改变。

2.7.2、情况 2:外部命令

执行流程:

复制代码
fork()
    ↓
子进程:
    - 处理重定向
    - 处理管道
    - execvp()

父进程:
    - 判断是否后台
    - wait 或 不 wait

这是 Shell 的核心执行逻辑。

2.8、管道执行架构设计

多级管道执行逻辑:

复制代码
int prev_fd = -1;

while (cmd) {
    int pipefd[2];

    if (cmd->next)
        pipe(pipefd);

    pid = fork();

    if (pid == 0) {
        if (prev_fd != -1)
            dup2(prev_fd, STDIN_FILENO);

        if (cmd->next)
            dup2(pipefd[1], STDOUT_FILENO);

        execvp(...);
    }

    close unused fd;

    prev_fd = pipefd[0];
    cmd = cmd->next;
}

这是整个 Shell 的 "心脏代码"。

2.9、信号架构设计

Shell 必须处理:

  • SIGINT(Ctrl+C)
  • SIGCHLD(子进程退出)

设计原则:

  • 父进程忽略 SIGINT
  • 子进程恢复默认
  • 使用 waitpid(-1, WNOHANG) 回收僵尸

2.10、内存管理设计

必须考虑:

  • argv 动态分配
  • 链表释放
  • 重定向字符串释放

否则循环运行会内存泄漏。

2.11、扩展性设计

我们的结构支持未来扩展:

  • 支持 &&
  • 支持 ||
  • 支持 子 shell ()
  • 支持 脚本解析
  • 支持 历史记录

因为我们已经抽象出 command_t。

2.12、整体架构图

复制代码
                +------------------+
                |      main        |
                +------------------+
                        |
                        v
                +------------------+
                |     input        |
                +------------------+
                        |
                        v
                +------------------+
                |     parser       |
                +------------------+
                        |
                        v
                +------------------+
                |    executor      |
                +------------------+
                   /           \
                  v             v
          +-----------+   +-----------+
          |  builtin  |   | fork/exec |
          +-----------+   +-----------+

2.13、小结

在正式写代码之前,我们完成了:

  • 执行流程设计
  • 模块划分
  • 数据结构抽象
  • 执行模型构建
  • 管道设计
  • 信号架构
  • 扩展预留

这一步的意义远比写代码重要。

因为:

写代码是实现细节

架构设计是工程能力

接下来,我们将进入真正的实现阶段。

相关推荐
mCell5 小时前
如何零成本搭建个人站点
前端·程序员·github
mCell5 小时前
为什么 Memo Code 先做 CLI:以及终端输入框到底有多难搞
前端·设计模式·agent
恋猫de小郭6 小时前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
少云清6 小时前
【安全测试】2_客户端脚本安全测试 _XSS和CSRF
前端·xss·csrf
银烛木6 小时前
黑马程序员前端h5+css3
前端·css·css3
m0_607076606 小时前
CSS3 转换,快手前端面试经验,隔壁都馋哭了
前端·面试·css3
听海边涛声6 小时前
CSS3 图片模糊处理
前端·css·css3
IT、木易6 小时前
css3 backdrop-filter 在移动端 Safari 上导致渲染性能急剧下降的优化方案有哪些?
前端·css3·safari
0思必得06 小时前
[Web自动化] Selenium无头模式
前端·爬虫·selenium·自动化·web自动化
anOnion7 小时前
构建无障碍组件之Dialog Pattern
前端·html·交互设计