从零实现Shell命令行解释器:原理与实战(附源码)
摘要:本文详细讲解如何用C语言实现一个简化版Shell命令行解释器。主要内容包括:1) Shell工作原理分析,重点阐述进程控制(fork/exec/wait)和命令解析流程;2) 关键技术实现:提示符生成、字符串分割(strtok)、环境变量处理、内建命令(cd/echo/env/export)与外部命令区分;3) 完整代码实现,涵盖命令获取、解析、执行全流程。通过该项目可深入理解操作系统进程模

🔥个人主页:Cx330🌸
❄️个人专栏:《C语言》《LeetCode刷题集》《数据结构-初阶》《C++知识分享》
《优选算法指南-必刷经典100题》《Linux操作系统》:从入门到入魔
🌟心向往之行必能至
🎥Cx330🌸的简介:

目录
AI创作者xAMA第二期,发布AI相关内容得积分,积分可兑换各种奖品哦~对AI感兴趣的朋友还可以来活动里和各位AI大佬们交流,很有价值的活动,感兴趣的朋友可以来参加哦

一、引言:为什么要自己写 Shell?
Shell 是操作系统的「命令行门面」,负责解析用户输入、执行命令并返回结果(比如 Linux 的 bash)。自主实现简化版 Shell,能帮你:
- 深入理解 进程控制(fork/exec/wait)、系统调用 和 IO 重定向 底层逻辑;
- 搞懂命令行参数解析、管道通信等核心机制;
- 摆脱「只会用命令」的黑盒认知,建立对操作系统的具象理解。
本文将用 C 语言实现一个支持「基础命令执行、内置命令、重定向」的迷你 Shell,全程拆解关键步骤。
二、自主实现shell命令行解释器预备
2.1 学习目标
- 要能处理普通命令
- 要能处理内建命令
- 要能帮助我们理解内建命令/本地变量/环境变量这些概念
- 要能帮助我们理解shell的运行原理
2.2 shell命令行原理
大家都知道,你打开了一个软件,只要不退出,他就一直在运行——死循环,例如:网易云音乐。
我们自主实现shell命令行就要依据上面的特性来考虑实现原理
shell的一个典型的互动
考虑下面这个与shell典型的互动:
[root@localhost epoll]# ls
client.cpp readme.md server.cpp utility.h
[root@localhost epoll]# ps
PID TTY TIME CMD
3451 pts/0 00:00:00 bash
3514 pts/0 00:00:00 ps
用下图的时间轴表示事件发生的顺序,时间从左向右推移。代表 shell 的方块标识为“sh”,并随时间从左向右移动。shell 读取用户输入的字符串“ls”,接着创建一个新进程,在该进程中运行 ls 程序,并等待该进程结束。

然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序并等待这个进程结束。
所以要写一个shell,需要循环以下过程:
| 步骤 | 描述 |
|---|---|
| 1 | 获取命令行 |
| 2 | 解析命令行 |
| 3 | 建立一个子进程 (fork) |
| 4 | 替换子进程 (execvp) |
| 5 | 父进程等待子进程退出 (wait) |
根据这些思路,和我们前面的学的技术,就可以自己来实现一个shell了。
三、自主实现shell命令行解释器细节
3.1 提示符(字符串)

这一串字符串就是提示符了,其实想要打印出来这个提示符,并不困难呢,
我们要做的就是:[获取用户名 + 主机名 + 工作目录]$ 即可
3.2 static的作用
-
修饰内部函数 / 全局变量:隐藏实现细节,避免命名冲突实现 shell 时会拆分出诸多辅助函数(如命令行解析、重定向处理、历史命令缓存、PID 管理等),这些函数仅在当前
.c文件内部调用,无需对外暴露。用static修饰后,它们会被限制在当前编译单元(文件)内可见,不会导出到全局符号表,既避免了与其他扩展模块(如 shell 插件)的命名冲突,也保证了 shell 核心逻辑的封装性。 -
修饰局部变量:保存跨调用的持久化状态,避免全局变量污染shell 运行过程中需要维护一些持续更新的状态(如历史命令的条数、上一条命令的退出状态码
$?、后台进程的统计信息、当前工作目录缓存等)。用static修饰局部变量后,该变量不会随函数栈帧销毁,只会初始化一次,且仅在该函数内可见 —— 既实现了跨函数调用的状态保存,又避免了使用全局变量带来的状态污染和误修改风险(比如多个函数同时操作全局状态的竞态问题)。 -
附加优化:减小可执行文件体积,提升编译链接效率
static修饰的符号(函数 / 变量)不会参与跨文件链接,编译器可对其进行更激进的优化(如内联展开),同时减少了全局符号表的冗余,最终能减小生成的 shell 可执行文件体积,提升编译和链接速度。

总结一下:
- 核心作用是封装内部实现(避免命名冲突)和保存持久化局部状态(替代全局变量)。
- 附加收益是优化编译链接,减小可执行文件体积
3.3 fgets函数解析
char *fgets(char *s, int size, FILE *stream);

| 参数 | 含义 |
|---|---|
s |
字符数组 / 缓冲区的指针,用于存储读取到的内容(最终会自动带上 \0 字符串结束符) |
| size | 最大读取字符数(关键:实际最多读取 num-1 个有效字符,预留 1 个位置给 \0) |
stream |
输入流,shell 场景中固定用 stdin(标准输入,对应键盘输入),也可用于读取文件 |
返回值说明
- 读取成功:返回指向
str的指针(和第一个参数值一致)。 - 读取失败 / 到达文件末尾(EOF,shell 中用户按
Ctrl+D触发):返回NULL。
核心读取规则(shell 场景关键)
- 停止条件:遇到「换行符
\n」「EOF」「已读取num-1个字符」三者之一即停止。 - 保留换行符:如果输入行长度不超过
num-1,\n会被一起存入str(shell 中需要手动去除该\n,否则会影响命令执行)。 - 自动补
\0:无论读取多少字符,都会在缓冲区末尾自动添加\0,保证是合法的 C 字符串。
示例:

3.4 切割字符串:strtok()
函数原型:
#include <string.h> // 必须包含的头文件
char *strtok(char *str, const char *delim);
首次使用:
strtok(commandline, " ") - 传入待分割字符串(第二个参数传空格)
后续继续切割:
如果我们待切割字符串还想要继续切割的话,此后使用 strtok() 函数,第一个参数就要传 NULL ,此时strtok就会自动判断上一次切割过后的位置,就会在上次切割的位置继续切割了
例如:
strtok(NULL,sep)——(sep是切割字符的集合)
示例:切割 "ls -a -l"
| 步骤 | 分析 |
| 1 | argv[0] = strtok("ls -a -l", " ") → "ls" |
| 2 | argv[1] = strtok(NULL, " ") → "-a" |
| 3 | argv[2] = strtok(NULL, " ") → "-l" |
| 4 | argv[3] = strtok(NULL, " ") → NULL(结束) |

3.5 获取环境变量:env

演示:

3.6 export

演示:
指令:export myval=100

3.7 本地变量:Loadenv


理清关系:
- 默认不带export,写到这个
local本地变量表里面; - 带了export,写到
env环境变量表里面
3.8 普通命令与内建命令(重点)
先明确核心定义(通俗理解)
- 内建命令(builtin command):直接内嵌在 shell 进程本身中的命令,没有独立的可执行文件,命令的执行逻辑是 shell 源码的一部分。
- 普通命令(外部命令):独立于 shell 之外的可执行程序,有自己的可执行文件(通常存放在
/bin、/usr/bin、/usr/local/bin等目录),与 shell 是两个独立的程序。
核心区别:
| 对比维度 | 内建命令(builtin) | 普通命令 |
| 执行主体 | 由 shell 自身进程(父进程)直接执行,无需创建新进程 | shell 先通过 fork() 创建子进程,再由子进程加载并执行对应的可执行文件 |
| 执行效率 | 极高,无进程创建 / 销毁开销,直接执行内部逻辑 | 较低,需经历「创建子进程→加载可执行文件→执行→子进程退出」流程,有明显开销 |
| 对 shell 环境的影响 | 可以直接修改当前 shell 的运行环境(因为在 shell 进程内执行) | 子进程有独立的进程环境,修改仅对自身有效,无法影响父进程(shell) |
| 查找方式 | shell 直接识别,无需依赖 PATH 环境变量 |
shell 需根据 PATH 环境变量列出的目录,查找对应的可执行文件 |
| 典型示例 | cd、pwd、export、exit、source(.)、history、alias |
ls、cp、mv、rm、ps、gcc、cat、mkdir |
内建命令检查函数 CheckBuiltinAndExecute()
如下图所示:

上图中 CheckBuiltinAndExecute() 这个函数实现了两个内建命令:
cd 命令:

-
使用
chdir()系统调用改变当前工作目录; -
必须在父进程中执行,因为子进程的目录改变不影响父进程
echo 命令:

-
特殊处理
$?:打印上一个命令的退出状态码(lastcode); -
其他情况直接输出参数。
3.9 which的细节
引入:which命令没查到的就是典型的内建命令
提问:其它的内建命令,像export,用which是查不到的,但是,cd、echo、env也是内建,为什么能够在磁盘上用which指令查到?

我们前面说内建命令是Shell自己实现的一个相当于成员函数的功能函数,由 Shell 进程自身执行(不创建子进程),那么就不可能能够用which指令查到,查到说明在磁盘上面存在这个文件。
这是因为Shell(比如bash)不仅是一个命令行解释器,同时也是一门语言——脚本语言。
之所以能够用which查到,是因为它们有两份,而且其中一份在磁盘上面真实存在!
3.10 实现不到的一些功能
别名、管道、重定向这些都是我们现在的Shell没办法实现的,比如别名,举个例子,“ll”是别名,但是我们的Shell是识别不出来ll的:

四、自主实现 shell 命令行解释器
myshell.h:
1 #pragma once
2
3 #include <stdio.h>
4
5 void Bash();
myshell.c:
#include "myshell.h"
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<sys/stat.h>
// 提示符相关
static char username[32];
static char hostname[64];
static char cwd[256];
static char commandline[256];
// 与命令行相关
static char *argv[64];
static int argc=0;
static const char *sep=" ";//分割符
// 与退出码有关
static int lastcode=0;
// 环境变量相关,应该由bash来维护,从系统配置文件读,直接从系统bash拷贝就可以了
//static char *env[64];
//static int envc=0;
//环境变量相关
char **_environ=NULL;
static int envc=0;
static void GetUserName()
{
char *_username=getenv("USER");
strcpy(username,(_username ? _username :"None"));
}
static void GetHostName()
{
char *_hostname=getenv("HOSTNAME");
strcpy(hostname,(_hostname ? _hostname :"None"));
}
static void GetCwdName()
{
//char *_cwd=getenv("PWD");
//strcpy(cwd,(_cwd ? _cwd :"None"));
char _cwd[256];
getcwd(_cwd,sizeof(_cwd));
if(strcmp(_cwd,"/")==0)
{
strcpy(cwd,_cwd);
}
else
{
int end=strlen(_cwd)-1;
while(end>=0)
{
if(_cwd[end]=='/')
{
// /home/yhr
strcpy(cwd,&_cwd[end]+1);
break;
}
end--;
}
}
}
static void PrintPrompt()
{
GetUserName();
GetHostName();
GetCwdName();
printf("[%s@%s %s]# ",username,hostname,cwd);
fflush(stdout);
}
static void GetCommandLine()
{
if(fgets(commandline,sizeof(commandline),stdin)!=NULL)
{
// "abcd\n"->"abcd"
commandline[strlen(commandline)-1]=0;
//printf("debug: %s\n",commandline);
}
}
static void ParseCommandLine()
{
//清空
argc=0;
memset(argv,0,sizeof(argv));
//判空
if(strlen(commandline)==0)
return;
//解析
argv[argc]=strtok(commandline,sep);
while((argv[++argc]=strtok(NULL,sep)));
// printf("argc: %d\n",argc);
// int i=0;
// for(;argv[i];i++)
// {
// printf("argv[%d]: %s\n",i,argv[i]);
// }
}
void Excute()
{
pid_t id=fork();
if(id<0)
{
perror("fork");
return;
}
else if(id==0)
{
//子进程
//程序替换
//cat file.c
//1.判断命令类型:cat
//2.获取用户身份,用户名,包括用户id
//3.获取文件的属性,用户,所属组,Mode
//4.权限管理
execvp(argv[0],argv);
exit(1);
}
else
{
//父进程
int status=0;
pid_t rid=waitpid(id,&status,0);
(void)rid;
lastcode=WEXITSTATUS(status);
}
}
// 1:yes
// 0:no,普通命令,让子进程执行
int CheckBuiltinAndExecute()
{
int ret=0;
if(strcmp(argv[0],"cd")==0)
{
//内建命令
ret=1;
// cd 默认有选项,就切换路径
if(argc==2)
{
chdir(argv[1]);
}
}
else if(strcmp(argv[0],"echo")==0)
{
ret=1;
if(argc==2)
{
// echo $?
// echo $PATH
// echo "hello world"
// echo helloworld
if(argv[1][0]=='$')
{
if(strcmp(argv[1],"$?")==0)
{
printf("%d\n",lastcode);
lastcode=0;
}
else
{
// env
}
}
else
{
printf("%s\n",argv[1]);
}
}
}
else if(strcmp(argv[0],"env")==0)
{
ret=1;
int i=0;
for(;i<envc;i++)
{
printf("%s\n",_environ[i]);
}
}
else if(strcmp(argv[0],"export")==0)
{
ret=1;
if(argc==2)
{
// export myval=100
char *mem=(char*)malloc(strlen(argv[1])+1);
strcpy(mem,argv[1]);
_environ[envc++]=mem;
_environ[envc]=NULL;
}
}
return ret;
}
static void LoadEnv()
{
extern char **environ;
for(envc=0;environ[envc];envc++)
{
_environ[envc]=environ[envc];
}
_environ[envc]=NULL;
}
void Bash()
{
// 环境变量相关,应该由bash来维护,从系统配置文件读,直接从系统bash拷贝就可以了
static char *env[64];
_environ=env;
static char *local[64];// a=100,b=200 本地变量
//第0步:获取环境变量
LoadEnv();
while(1)
{
//第1步:输出命令行
PrintPrompt();
//第2步:等待用户输入,获取用户输入
//char commandline[256];
GetCommandLine();
//第3步:解析字符串,"ls -a -l" -> "ls" "-a" "-l"
ParseCommandLine();
if(argc==0)
continue;
//第4步:有些命令,cd,echo,env,export等命令,不应该让子进程执行
//父进程bash自己执行,内建命令,bash内部的函数
if(CheckBuiltinAndExecute())
{
continue;
}
//第5步:执行命令
Excute();
}
}
main.c:
1 #include "myshell.h"
2
3 int main()
4 {
5 Bash();
6 return 0;
7 }
Makefile:
1 mybash:myshell.c main.c
2 gcc -o $@ $^
3 .PHONY:clean
4 clean:
5 rm -f mybash
五、总结
在继续学习新知识前,我们来思考函数和进程之间的相似性:
exec / exit就像call / return
一个C程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过call / return系统进行通信
这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux鼓励将这种应用于程序之内的模式扩展到程序之间。如下图所示:

一个C程序可以fork / exec另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过exit(n)来返回值。调用它的进程可以通过wait(&ret)来获取exit的返回值
自主实现 Shell 的核心是「进程控制」和「IO 管理」:
- 外部命令依赖 fork+exec,内置命令直接在父进程执行;
- 管道和重定向的本质是「文件描述符的重定向」;
- 从最简版本到功能完善,逐步拆解问题的思路适用于所有系统级编程。
通过这个项目,你不仅能掌握 Shell 的工作原理,更能深刻理解操作系统的进程模型和 IO 机制。
更多推荐



所有评论(0)