操作系统学习笔记 · 第 31 课 · 综合实战——手写一个迷你 shell
这是全书的最后一课。前面三十课,你学了 fork/exec、管道、重定向、信号……现在,我们把它们合流,亲手用 C 写一个能跑的真 shell。你会发现:你天天敲的ls、cd、sleep 10 &,背后其实都是同一套机制。 写完它,你就真正"驾驭"了操作系统。
从"你敲的那条命令"说起
你敲下 ls -l | grep txt > out.txt,回车,结果出来了。这一瞬间,shell 在背后做了什么?
它把这条命令拆成:两个命令(ls -l、grep txt)、一个管道(|)、一个重定向(>),然后安排两个子进程协同完成。
这一课,我们把 shell 拆开看,再亲手拼一个。
31.1 shell 的主循环
一句话理解:shell 就是个 while(1) 循环——打印提示符 → 读一行 → 解析(分词/拆管道/拆重定向)→ fork 子进程 exec 执行 → 父进程等待(或后台)。所有 shell 的核心,都是一个无限循环:
while (1) {
打印提示符 "mysh> ";
读一行输入;
解析这行(拆成命令、管道、重定向);
fork 子进程去执行;
父进程 wait(前台)或不等(后台);
}就这五步,"读 → 解析 → 执行"。真 shell(bash)复杂得多,但骨架就是它。
31.2 命令执行:fork + exec
一句话理解:外部命令必须fork出子进程,子进程exec替换成目标程序,父进程waitpid收尸——这就是第 7 课内容的落地。
还记得第 7 课的 fork 和 exec 吗?这里是它们的实战:
- 外部命令(如
ls):fork出子进程,子进程execvp("ls", argv)把自己替换成ls,父进程waitpid等它结束; - 但
cd不能靠 fork——因为子进程chdir改了目录,对父进程无效(子进程一结束就消失)。
所以cd是内建命令:必须在 shell 自己进程里执行chdir,才能真的改变 shell 的工作目录。这正是第 25 课"内建 vs 外部命令"的底层原因。
31.3 管道 |
一句话理解:A | B用pipe()建一条数据通道,dup2把 A 的 stdout 接到管道写端、B 的 stdin 接到读端,两个子进程就这么串起来了。
ls | wc -l 怎么实现?三步:
pipe():建一条数据通道(一个读端、一个写端,第 9 课讲过);dup2:把ls的 stdout 指到管道写端(这样ls的输出不落屏幕,流进管道);- 把
wc的 stdin 指到管道读端(这样wc读的就是ls的输出)。
一句话:管道 = pipe 建通道 + dup2 改接口。 两个子进程,一个往管道写,一个从管道读。
31.4 重定向 > 和 <
一句话理解:>是open文件后dup2到 stdout,<同理到 stdin;2>是重定向 stderr(fd=2)。
echo hi > f.txt 怎么实现?
open("f.txt"):打开(或创建)文件,拿到一个 fd;dup2(fd, 1):把这个 fd 复制到 stdout(fd=1) 的位置;- 于是子进程的 stdout 指向了文件,
echo输出就写进了文件。
echo自己完全不知道发生了什么——它还是老老实实写 stdout,只是 stdout 已经被dup2换成了文件。这就是重定向的"魔法":改的是 fd 指向,不是程序本身。
31.5 后台任务 & 信号处理
一句话理解:命令结尾&让父进程不wait、立刻回到主循环;Ctrl+C(SIGINT)要发给前台进程组而不是 shell 自己,所以 shell 要做信号处理 + 进程组管理。
- 后台任务:
sleep 10 &里的&,就是让父进程不waitpid,立刻回到主循环、重新打印提示符。10 秒后,父进程收到 SIGCHLD(子进程结束信号)才知道它结束了; - 信号处理:你按
Ctrl+C(发 SIGINT),这个信号必须发给前台进程组,而不是 shell 自己——否则 shell 会被打断。所以真 shell 要做进程组管理(第 8 课信号、第 5 课进程的知识都用上了)。
动手实验:C 手写迷你 shell
// lesson31.c —— 迷你 shell:命令执行 + cd + 管道 + 重定向 + 后台
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <fcntl.h>
int main(void) {
char line[256];
while (1) {
printf("mysh> "); fflush(stdout);
if (!fgets(line, sizeof(line), stdin)) break;
line[strcspn(line, "\n")] = 0;
if (strlen(line) == 0) continue;
// 后台任务:结尾 & 去掉,父进程不 wait
int bg = 0;
int len = strlen(line);
if (line[len-1] == '&') { bg = 1; line[len-1] = 0; }
// 内建命令 cd(不能用 fork,否则改了子进程没用)
if (strncmp(line, "cd ", 3) == 0) {
if (chdir(line + 3) != 0) perror("cd");
continue;
}
if (strcmp(line, "exit") == 0) break;
pid_t pid = fork();
if (pid == 0) { // 子进程:拆命令执行
char *argv[64]; int argc = 0;
char *tok = strtok(line, " ");
while (tok && argc < 63) argv[argc++] = tok, tok = strtok(NULL, " ");
argv[argc] = NULL;
execvp(argv[0], argv); // 替换成目标程序
perror("execvp"); exit(1);
} else { // 父进程
if (!bg) waitpid(pid, NULL, 0); // 前台等,后台不等
}
}
return 0;
}编译运行:
gcc lesson31.c -o mysh && ./mysh试试这些命令:
mysh> ls
mysh> cd /tmp
mysh> sleep 10 &
mysh> ls -l
mysh> exit一句话理解:这个不到 50 行的 shell,已经能跑ls、cd、sleep 10 &、exit——fork + exec是它的灵魂。
扩展练习(把这门课的剩余知识也加进去):
- 加管道
|:pipe+dup2; - 加重定向
>:open+dup2。
深入点:两个进阶话题
① 进程组与会话
真 shell 用setpgid把每条管道的前台进程组交给终端,tcsetpgrp管理谁读终端输入,Ctrl+C才能精准发给"前台那组"——这是交互式 shell 的完整版。
② 作业控制
jobs、fg、bg、Ctrl+Z(SIGTSTP 暂停)本质是 shell 维护一张"作业表",记录每个后台任务的 pid 和状态——手写 shell 的终极挑战。
小结与思考题
这一课,我们把全书知识合流成了一个真 shell:
- 主循环:读 → 解析 → 执行,
while(1); - 命令执行:外部命令 fork+exec,
cd是内建; - 管道:pipe 建通道 + dup2 改接口;
- 重定向:open + dup2,改 fd 指向;
- 后台:
&让父进程不 wait,SIGCHLD 收尾。
留三个问题:
- 为什么
cd必须是内建命令? - 管道和重定向的本质分别是什么(用 dup2 说)?
sleep 10 &的&让父进程做了什么/不做什么?
系列结语
从第 1 课"操作系统是什么",走到第 31 课"手写 shell",你已经:
- 用 C 亲手摸过进程、线程、内存、文件、网络、容器;
- 用 Shell 写过自动化脚本、做过排障;
- 最后把它们拼成了一个能跑的命令解释器。
这就是"程序脚下的地基"。理解操作系统,你就真正驾驭了它——不再是"只会调 API"的人,而是知道每一行代码背后,内核在做什么的人。
祝贺你,走到这里。这门课结束了,但操作系统的大门,才刚刚为你打开。