手搓一个 Shell——你手里已经有了所有积木

.
专栏:数据结构|Linux|C语言
路漫漫其修远兮,吾将上下而求索
文章目录

手搓一个 Shell——你手里已经有了所有积木
你已经有 fork、wait、exec 了
快速过一遍——只看和今天直接相关的结论。
创建子进程
pid_t id = fork();
fork 之后有两个执行流。父子代码共享,数据写时拷贝。返回值:父进程拿到子进程的 PID,子进程拿到 0。
创建子进程的目的只有两个:要么让子进程执行父进程代码的一部分(靠 if (id == 0) 分流),要么让子进程执行一个全新的程序。今天要用的主要是后者。
终止进程
进程终止三种情况:代码跑完结果对(退出码 0)、代码跑完结果不对(退出码非 0)、出异常(收到信号)。
退出码和退出信号塞在一个 32 位整数的低 16 位里。次低 8 位是退出码,低 7 位是退出信号。exit(1) 写进次低 8 位就是 1 << 8 = 256。
系统给了宏:WIFEXITED(status) 判断是否正常退出,WEXITSTATUS(status) 提取退出码。
等待子进程
pid_t rid = waitpid(id, &status, 0); // 阻塞等
pid_t rid = waitpid(id, &status, WNOHANG); // 非阻塞等
为什么必须等?因为不等会有僵尸——子进程退出后 PCB 保留着退出信息,父进程不来收就永远挂在那,内存泄漏。这是刚需。
waitpid 做了两件事:回收僵尸(释放 PCB),获取退出信息(把 PCB 里的 exit_code 拷贝到用户空间的 status 变量里)。原理上,它遍历子进程列表,检查 task_struct 的状态是不是 Z(僵尸),是 Z 就把退出信息拷出来,不是 Z 就根据第三个参数决定阻塞还是立刻返回。
程序替换
execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
execvp("ls", argv); // 带 p:自动搜 PATH;带 v:数组传参
exec 系列函数让当前进程加载磁盘上一个全新的程序,覆盖自己的代码段和数据段。PCB 不变,PID 不变,只换内容。
调用成功就不返回——新程序从 main 开始执行,原先 exec 后面的代码被覆盖了,永远不会执行。只有失败才返回 -1。所以正确写法是 execvp(...); exit(1);——能走到 exit 说明 exec 失败了。
七个 exec 函数,命名规则就四个字母:l(list,参数一个个传)、v(vector,数组传)、p(path,自动搜 PATH)、e(environment,自定义环境变量)。只有 execve 是真正的系统调用,其他六个是库函数封装,底层全部调 execve。
fork + exec = Bash 的骨架
创建子进程,让子进程 exec 新程序,父进程 waitpid 等子进程结束。这就叫"让子进程去干别的活,干完了告诉我干得怎么样"。进程独立性保证了子进程的 exec 不影响父进程——写时拷贝在 exec 触发时全面生效,代码段数据段全部分离。
命令行参数和环境变量是怎么传的
你程序的 main(int argc, char *argv[], char *env[]) 的三个参数不是凭空来的。argv 是父进程通过 exec 传进来的——你在 execl 里写的 "ls", "-a", "-l" 最终进了 argv。env 是父进程的 environ 表传进来的——不带 e 的 exec 函数自动把 extern char **environ 传给了 execve。
把这些积木拼起来
上面每一块你都见过。单独拆开看都不复杂。但拼在一起——一个等用户输入、解析命令、fork 子进程、exec 替换、等子进程退出的东西——就是一个 shell。
int main() {
Bash(); // 入口
return 0;
}
void Bash() {
while (1) {
// 1. 打印提示符
// 2. 读取用户输入
// 3. 解析字符串
// 4. 执行命令
}
}
真正的软件都是死循环。排序、查找这些程序跑完就退了——那是练习。QQ、微信、浏览器、终端、shell——你一开它就不退,除非你关掉。四步,循环到你 kill 它为止。
第一步:打印提示符
[dzh@centos code]#
三个字符串拼起来:用户名、主机名、当前目录。
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
static char username[32];
static char hostname[64];
static char cwd[256];
static void GetUserName() {
char *name = getenv("USER");
strcpy(username, name ? name : "None");
}
static void GetHostName() {
char *name = getenv("HOSTNAME");
strcpy(hostname, name ? name : "None");
}
static void PrintPrompt() {
GetUserName();
GetHostName();
GetCwdName();
printf("[%s@%s %s]# ", username, hostname, cwd);
fflush(stdout); // 没有 \n,必须手动刷新缓冲区
}
为什么用 getenv 而不是更底层的系统调用?因为这个阶段的目标是练习环境变量的使用——系统已经给你准备好了 USER、HOSTNAME、PWD,直接用 getenv 拿就行。不传 \n 的原因是提示符后面紧跟用户输入,不能换行。但没有 \n 意味着 stdio 缓冲区不会自动刷新——fflush(stdout) 强制刷出来。
路径截短:从绝对路径到目录名
系统 shell 只显示当前目录名,不显示完整路径。两个原因让我们一开始保留了完整路径:第一,让我们的 shell 和系统 shell 看起来不一样——后面演示内建命令的时候一眼能区分;第二,后面演示 cd 效果时完整路径更直观。
后来为了长得更像,还是截短了:
static void GetCwdName() {
char _cwd[256];
getcwd(_cwd, sizeof(_cwd)); // 系统调用,从 PCB 拿实时路径
if (strcmp(_cwd, "/") == 0) {
strcpy(cwd, _cwd); // 根目录不截
} else {
int end = strlen(_cwd) - 1;
while (end >= 0) {
if (_cwd[end] == '/') {
strcpy(cwd, &_cwd[end + 1]); // 从最后一个 '/' 之后截
break;
}
end--;
}
}
}
为什么后来换成 getcwd 而不是继续用 getenv("PWD")?因为 cd 切完路径之后,PWD 环境变量不会自动更新——它是 shell 自己维护的,不是内核维护的。真正的 Bash 在 cd 时会同步更新 PWD 环境变量。我们暂时不搞这个,直接用 getcwd 系统调用从 PCB 拿实时路径,一劳永逸。
第二步:读取用户输入
你敲 ls -a -l 回车。shell 读到的是 "ls -a -l\n"——一个字符串。
不能用 scanf
scanf("%s", buf) 以空白字符作为分隔符。"ls -a -l" 输进去,scanf 读到第一个空格就停了——只拿到 "ls","-a -l" 留在输入缓冲区里。你不知道用户输入了多少个词,没法提前准备够多的 %s 占位符。
fgets 拿整行
static char commandline[256];
static void GetCommandLine() {
if (fgets(commandline, sizeof(commandline), stdin) != NULL) {
commandline[strlen(commandline) - 1] = 0; // 干掉末尾的 \n
}
}
fgets 无论多少空格全读进来,包括回车键。最后一行把 \n 替换成 \0。
具体推导:输入 ls -a -l 再敲回车,fgets 读到的完整内容是 l s - a - l \n \0(六个字符)。strlen 返回 5(碰到 \0 之前)。commandline[5-1] 即 commandline[4] 就是 \n,把它改成 \0 之后字符串变成 "ls -a -l"——干净了。
如果用户只敲了回车没敲任何东西?fgets 读到的是 "\n\0"。strlen 等于 1(只有一个 \n)。commandline[1-1] 即 commandline[0],设成 \0——空字符串。后面解析代码判断为空直接 continue,不浪费 CPU。
退格键不管用
输入打错了想退格删掉——没反应。退格键在终端里的行为需要程序自己做键盘输入处理(读取每个按键,判断是不是退格键,手动从缓冲区移除)。我们没做。想删?按住 Ctrl 再按退格(Ctrl+Shift+Delete,取决于你的终端模拟器)。
第三步:拆字符串
"ls -a -l" 是一个字符串。但 execvp 要的是:
char *argv[] = {"ls", "-a", "-l", NULL};
需要拆成三个子字符串,地址放进指针数组,NULL 收尾。
strtok:带静态记忆的切割函数
#include <string.h>
static char *argv[64];
static int argc = 0;
static const char *sep = " ";
static void ParseCommandLine() {
// 清空上一轮解析的痕迹
argc = 0;
memset(argv, 0, sizeof(argv));
if (strlen(commandline) == 0)
return;
// 第一次调用:传字符串本身
argv[argc] = strtok(commandline, sep);
// 后续调用:传 NULL,继续切上一个字符串
while ((argv[++argc] = strtok(NULL, sep)));
}
strtok 按分隔符切字符串。分隔符可以是一个集合——传 " " 按空格切,传 " |&" 按空格、竖线、and 符号一起切。我们只需要空格。
第一次 strtok(commandline, " ") 切出 "ls",内部把 ls 后面的空格改成 \0,返回 "ls" 的起始地址,赋给 argv[0]。
然后 while 循环:strtok(NULL, " ")。参数为 NULL 的意思是"继续切上一个字符串剩下的部分"。切出 "-a",赋给 argv[1](argc 从 0 自增到 1)。再切 "-l",赋给 argv[2](argc 变成 2)。再切一次——没东西了,返回 NULL。
strtok 怎么记得上一个字符串?
一个函数调完了——栈帧销毁,局部变量都没了——下一次再调的时候它怎么还记得上次切到哪了?
strtok 内部用了 static 变量。 第一次调用时,它把字符串的起始地址和当前位置(快指针指向哪里、慢指针指向哪里)存在 static 局部变量里。Static 变量只初始化一次,函数退出后不释放,下次进来还能读。第二次调用发现参数是 NULL,就读取 static 变量里保存的"上次切到哪了",从那个位置继续往后找分隔符。
这也是为什么 strtok 不是线程安全的——多个线程同时调 strtok 会踩同一块 static 内存。
一行代码干了三件事
while ((argv[++argc] = strtok(NULL, sep)));
这一行同时完成:调 strtok、把返回值赋给 argv[++argc]、用赋值表达式的结果作为 while 条件。strtok 切完所有 token 返回 NULL——作为 while 条件为假,循环退出。而且 argv 最后一个有效元素后的下一个位置恰好是 NULL——不是巧合,是 strtok 的设计决定的。exec 系列函数恰好要求 argv 必须以 NULL 结尾。
小坑:argc 多计了一个
argv[argc] = strtok(...) 第一次的时候 argc 是 0。但 while 里用了 ++argc——先自增再赋值。切 "-a" 时 argc 变成 1,切 "-l" 时变成 2,切出 NULL 时变成 3。
输入是 ls -a -l 三个词,argc 变成了 3——包含尾部 NULL。遍历 argv 的时候用 for (; argv[i]; i++) 遇 NULL 就停,所以功能不受影响。如果你想让 argc 精确等于有效参数个数,把第一次改成 argv[argc++] = strtok(...) 然后 while 里维持 ++argc。
debug:验证解析
printf("argc: %d\n", argc);
for (int i = 0; argv[i]; i++)
printf("argv[%d]: %s\n", i, argv[i]);
输入 ls -a -l -b -c:
argc: 5
argv[0]: ls
argv[1]: -a
argv[2]: -l
argv[3]: -b
argv[4]: -c
对了。不管用户输入了多少选项,全部切出来。这就是为什么不用 scanf——你不知道有几个。
第四步:执行命令
解析完了。argv 表就绪。执行。
选哪个 exec?
- 用户输的是
ls,不是/usr/bin/ls——没带路径 → 带p(自动搜 PATH) - 参数在数组里 → 带
v(数组传参) - 环境变量用默认的 → 不带
e
结论:execvp。
#include <sys/wait.h>
void Execute() {
pid_t id = fork();
if (id < 0) {
perror("fork");
return;
} else if (id == 0) {
// 子进程:替换成用户要跑的程序
execvp(argv[0], argv);
exit(1); // execvp 成功不返回,能走到这里说明失败了
} else {
// 父进程:等子进程跑完,记录退出码
int status = 0;
pid_t rid = waitpid(id, &status, 0);
lastcode = WEXITSTATUS(status);
}
}
execvp(argv[0], argv)——第一个参数是"执行谁"("ls"),第二个参数是"怎么执行"(整个 argv 数组)。之前讲 exec 时说过:命令行怎么写,参数就怎么传。argv 恰好就是"命令行怎么写"的数组形式。
为什么不能让 shell 自己去 exec?
shell 自己调 execvp("ls", argv)——shell 自己的代码段和数据段就被 ls 覆盖了。while 循环、提示符、输入解析——全没了。ls 跑完,进程退出。shell 死了。
必须 fork 子进程,子进程 exec,父进程 wait。fork 之后子进程和父进程共享数据(argv/argc 是全局变量,只读不写就不触发写时拷贝),子进程能直接拿到父进程解析好的命令行参数。
防御:没命令就跳过
if (argc == 0)
continue; // 用户只敲了回车
cd 为什么不干活?
代码写到这里,shell 能跑。ls、pwd、top、python3 test.py 都正常。但 cd .. 不生效。
[/home/whb/code]# cd ..
[/home/whb/code]# pwd
/home/whb/code ← 没变!
[/home/whb/code]# ls
main.c myshell.c myshell.h Makefile ← ls 明明是好的
ls 能跑是因为子进程去跑的——子进程拿到 ls 的代码,执行,打印输出,退出。父进程不受影响。
cd 不生效也是因为子进程去跑的——子进程调了 cd,子进程把自己的当前工作目录切了。然后子进程退出。父进程(你的 shell)的路径纹丝不动。每个进程的当前工作目录记录在自己的 PCB 里,子进程改了子进程的,父进程的 PCB 不受影响。
cd 不能让子进程执行。它必须让 shell 自己执行。
内建命令 = shell 内部的函数
// 返回 1:是内建命令,已处理
// 返回 0:不是内建命令,继续走 fork+exec
int CheckBuiltinAndExecute() {
// cd
if (strcmp(argv[0], "cd") == 0) {
if (argc == 2)
chdir(argv[1]); // shell 自己切路径
return 1;
}
// 未来会有更多内建命令...
return 0;
}
chdir 是一个系统调用。谁调它,谁的当前工作目录就切。shell 自己调,shell 的路径就变了。你甚至找不到叫 cd 的二进制文件——/usr/bin/ 下面根本没有它。它只在 shell 的代码里。
主循环的处理逻辑:
while (1) {
PrintPrompt();
GetCommandLine();
ParseCommandLine();
if (argc == 0) continue;
if (CheckBuiltinAndExecute()) // 是内建命令 → 已处理,下一轮
continue;
Execute(); // 不是内建命令 → fork + execvp
}
判断一个命令是内建命令还是普通命令的标准:这个命令需不需要修改 shell 自己的状态。 需要——shell 自己调函数。不需要——fork 个子进程跑。
cd 改路径 → 自己调 chdir。echo $? 读 shell 内部的 lastcode → 自己打印。env 读 shell 维护的环境变量表 → 自己遍历。export 往环境变量表追加 → 自己追加。
系统 shell 的 cd 还支持不带参数(回 home)、cd -(回上次目录,需维护 OLDPWD)、cd ~(波浪号展开,需读 HOME)。这些都需要维护额外的环境变量。demo 不处理。
echo 和 $?
$ ls
$ echo $?
0
$? 是上一个命令的退出码,存在 shell 的全局变量里:
static int lastcode = 0;
父进程在 waitpid 之后更新它。现在把 echo 也做成内建命令:
else if (strcmp(argv[0], "echo") == 0) {
if (argc == 2) {
if (argv[1][0] == '$') {
// 以 $ 开头 → 查变量
if (strcmp(argv[1], "$?") == 0) {
printf("%d\n", lastcode);
lastcode = 0; // 查完清零
}
} else {
// 普通字符串,原样输出
printf("%s\n", argv[1]);
}
}
return 1;
}
lastcode = 0 在打印之后立即执行。因为 echo $? 本身也是一条命令——它执行完之后也有退出状态。不把 lastcode 清零,下一次 echo $? 就会打出"echo 自己的退出状态"而不是"上一条命令的退出状态"。系统 shell 也是这个行为。
echo 在系统里既有内建版本(shell 自带),也有二进制版本(/usr/bin/echo)。我们只做了最常见的内建逻辑。双引号包裹、转义字符、echo -n 这些选项——那是词法分析的工作,demo 不做。
环境变量:从系统 Bash 继承
环境变量表是一个 char **——字符串指针数组,以 NULL 结尾:
environ[0] → "USER=whb"
environ[1] → "HOME=/home/whb"
environ[2] → "PATH=/usr/bin:/bin"
environ[3] → NULL
你的 shell 进程启动时,系统 Bash(shell 的父进程)已经准备好了这张表,存在全局变量 extern char **environ 里。不需要自己解析配置文件——配置文件是 shell 脚本,要解析它相当于写一个脚本解释器。直接把 environ 拷一份过来:
static char **_environ = NULL;
static int envc = 0;
static void LoadEnv() {
extern char **environ;
for (envc = 0; environ[envc]; envc++)
_environ[envc] = environ[envc];
_environ[envc] = NULL;
}
拷贝的存储空间提前分配好:
void Bash() {
static char *env[64]; // 最多 64 个环境变量
_environ = env;
LoadEnv();
while (1) { /* ... */ }
}
为什么不直接用 environ?因为后面要用 export 往表里追加新变量——需要修改这张表。environ 是系统维护的,直接追加可能踩到不该踩的内存。自己拷一份,想怎么改怎么改。
env 命令:遍历表
else if (strcmp(argv[0], "env") == 0) {
for (int i = 0; i < envc; i++)
printf("%s\n", _environ[i]);
return 1;
}
env 说到底就是遍历那张字符串指针数组。和 ls 不一样——ls 是磁盘上的二进制文件,env 连文件都算不上。它只是 shell 读自己的数据。这就是它必须是内建命令的原因。
export 命令:追加环境变量
else if (strcmp(argv[0], "export") == 0) {
if (argc == 2) {
char *mem = (char *)malloc(strlen(argv[1]) + 1);
strcpy(mem, argv[1]);
_environ[envc++] = mem;
_environ[envc] = NULL; // 维持 NULL 结尾
}
return 1;
}
必须 malloc,不能直接 _environ[envc] = argv[1]。 argv[1] 指向的是 commandline 缓冲区里解析出来的临时子串——下次用户输入新命令,commandline 被 fgets 覆盖,argv[1] 指向的内容就变成新输入了。malloc 拷贝一份独立内存,环境变量表有自己的数据,不依赖临时缓冲区。
完整骨架
void Bash() {
// 环境变量表初始化
static char *env[64];
_environ = env;
LoadEnv();
while (1) {
// 第 1 步:打印提示符
PrintPrompt();
// 第 2 步:读取用户输入
GetCommandLine();
// 第 3 步:解析字符串 "ls -a -l" → argv[]
ParseCommandLine();
if (argc == 0) // 空输入 → 下一轮
continue;
// 第 4 步:检查并执行内建命令(cd / echo / env / export)
if (CheckBuiltinAndExecute())
continue; // 内建命令已处理 → 下一轮
// 第 5 步:不是内建命令 → fork + execvp
Execute();
}
}
CheckBuiltinAndExecute() 目前处理四个内建命令:cd(调 chdir)、echo(打印字符串或 $?)、env(遍历环境变量表)、export(追加环境变量)。每增加一个内建命令,在里面加一个 else if 分支。
跑起来的模样:
[whb@centos code]# ls
main.c myshell.c myshell.h Makefile
[whb@centos code]# cd ..
[whb@centos home]# pwd
/home/whb
[whb@centos home]# echo $?
0
[whb@centos home]# export myval=100
[whb@centos home]# env | grep myval
myval=100
一百多行,一个能跑的 shell。没有管道、重定向、信号处理、作业控制、别名、通配符、Tab 补全、历史命令、配置文件解析。但它的骨架——fork → exec → wait——和 Bash 完全一样。Bash 只是在这个骨架上长了更多的肉。
你敲 cd,shell 自己调 chdir。你敲 echo $?,shell 自己查 lastcode。你敲 env,shell 自己遍历 _environ 表。你敲 ls,shell fork 个子进程,子进程 execvp 换成 ls,父进程 waitpid 等着。
内建命令就是 shell 内部的函数。普通命令就是 fork + exec。 区分标准只有一个:需不需要动 shell 自己的状态。
export、env、echo $PATH——这三个"命令"本质上在操作同一张字符串指针数组。表在 shell 自己的内存里,除了 shell 自己谁也碰不到。所以它们全部是内建命令。
回到开头:为什么 ls 能用,cd 不能用?因为 cd 被子进程执行了。子进程 cut 了自己的路径,然后死了。父进程——你的 shell——站在原地,没动过。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)