手写一个自己的 shell:从字符串解析到内建命令
进程控制系列的收官篇(2026-06-25 ~ 06-26)。
前几篇我们攒齐了零件:fork、exec、waitpid、chdir……
这一篇终于把它们组装成一台能跑的机器——myshell。
而且过程中踩的两个坑(strtok 的静态指针、chdir 不更新的环境变量),恰好把前面的底层知识全部串了一遍。
一、shell 的本质:一个死循环
剥掉所有包装,shell 干的事朴素得惊人:
while (1) {
打印提示符
读一行输入 ← fgets
切割成命令 + 参数 ← strtok
fork + exec 执行 ← fork/execvp/waitpid
记录退出码
}
输入若干字符串 → 切割 → 执行 → 循环,如此而已。所谓"命令行解释器",解释的就是这一串字符串。
二、两个必须搞清的函数
2.1 fgets():会连 \\n 一起读进来
char buf[1024];
fgets(buf, sizeof(buf), stdin);
两个关键行为:
这就是为什么 shell 命令解析时总要先处理掉行尾的 \\n。
2.2 strtok():快慢指针版本的切割
char *token = strtok(buf, " \\t\\n"); // 第一次:传源字符串
while (token) {
argv[i++] = token;
token = strtok(NULL, " \\t\\n"); // 后续:传 NULL 继续切
}
机理:strtok 内部用快慢指针扫描——快指针识别到分隔符,就把它就地改成 \\0,于是慢指针到快指针之间就成了一个 token;然后重复。
两个大坑,务必记牢:
| 一次只切一个 token | 它不会一次把整串切完,每次调用只切出一个 |
| 内部有静态指针 | Linux glibc 下:只要传入相同的虚拟地址,它就不会重新开始;Windows/VS 的实现:第一次必须传指针,之后必须传 NULL |
为什么会有这种差异?因为 strtok 把"上次切到哪儿了"记在一个静态变量里。这也是它线程不安全的根源(多线程应改用 strtok_r)。
顺便,这也解释了一个"变量作用域"的老问题:普通变量(栈帧里)只是"虚拟地址空间里加了个数字 + 页表挂个标记",作用域结束就销毁;而 static 变量在 mm_struct 的静态区占一个位置,页表标记代表它与进程同生命周期——所以 strtok 的静态指针能跨调用"记住"位置。
三、核心执行逻辑:fork + exec + waitpid
pid_t id = fork();
if (id == 0) {
/* 子进程:去执行命令 */
execvp(argv[0], argv);
exit(0); // ← 关键!exec 失败时兜底,绝不能让"假 shell"继续跑
} else {
int status;
waitpid(id, &status, 0); // 父进程等待并回收
}
exec 之后必跟 exit(0) 是新手最容易忘的一步:
exec 成功后不返回;但一旦 exec 失败(例如命令不存在),代码会继续往下执行——那时子进程就会继续跑"后面的逻辑",甚至把 -bash 的行为搞乱。所以必须用 exit(0) 立刻结束。
exec 的三要素,在我们的参数里一一对应:
| 1. 搜索路径 | 用带 p 的版本(execvp)就查 PATH,否则给全路径 |
| 2. 怎么启动 | l 形式(逐个列出)或 v 形式(char*[] 数组),末尾必须是 NULL |
| 3. 环境变量表 | 带 e 的版本可自己传,否则继承当前 environ |
至此,ls、pwd、cat 这些外部命令都能跑了。
四、坑一:cd 为什么不能交给子进程?
问题来了:cd 用上面的套路实现不了。为什么?
因为 cd 要改的是"当前 shell 自己"的目录,而我们把命令交给子进程去跑——子进程改了自己的 cwd,父进程(shell)毫不知情,改了等于没改。
由此引出内建命令(built-in command) 的概念:
必须由 shell 进程自己亲自执行、而不能委托给子进程的命令,就叫内建命令。
cd、echo $?、export 都是典型的内建命令。
4.1 cd 的本质:改 PCB 里的 cwd
chdir("/home/user"); // 系统调用,支持绝对路径和相对路径
- chdir 改的是调用它的那个进程的 PCB 里的 cwd(当前工作目录);
- 子进程的 PCB 是从父进程拷贝来的(含 cwd),所以子进程里的 chdir 影响不到父进程。
4.2 坑二:chdir 之后,printf("%s", getenv("PWD")) 为什么没变?
这是个经典现象,原因值得记牢:
环境变量 env 是"用户级数据",存在进程自己的内存里;操作系统不会因为你调用了 chdir 就去主动更新它。
具体拆开看:
- getenv() 虽然是系统调用,但它读的是内存里的那串字符串,不是内核的 cwd;
- 换句话说:PWD 这个环境变量与内核的 cwd 是两套东西,cwd 变了,内存里的 PWD 字符串不会自己跟着变。
解决方式(两个系统接口配合):
char cwd[1024];
getcwd(cwd, sizeof(cwd)); // ① 读当前进程 PCB 里的真实 cwd
putenv("PWD=…"); // ② 自己动手把环境变量更新掉
| chdir(path) | 写 | PCB 的 cwd |
| getcwd(buf, size) | 读 | PCB 的 cwd |
| getenv(name) | 读 | 进程内存里的环境变量字符串 |
| putenv(str) | 写 | 进程内存里的环境变量字符串 |
一句话对比:cwd 是内核态的事实,PWD 是用户态的副本——副本要自己同步。
五、坑三:echo $? 怎么实现?
$? 要显示上一条命令的退出码。要点:
exit_code = (status >> 8) & 0xFF; // 退出码
exit_sig = status & 0x7F; // 信号编号
一个闭环细节:exec 出来的子进程在自己退出时,退出码先写进它自己的 PCB;waitpid 把它取回父进程,父进程再存进全局变量——$? 的信息就这么一层层"接力"过来的。
六、变量与作用域:再回望一次底层
写 shell 时对"变量"的体感会突然清晰起来:
| 普通局部变量 | 栈帧里一个虚拟地址 + 页表标记 | 随函数/作用域结束而销毁 |
| static 变量 | mm_struct 静态区里占一个位置,页表标记"全局" | 与进程同生命周期——进程结束、PCB 回收,页表才消失 |
也因此,strtok 能靠一个静态指针"记住上次切到哪"——它是进程级的长寿变量。
C 语言的字符串约定也顺带复习:"p" 实际是 'p' + '\\0',比字符 'p' 多一个 \\0——所有字符串函数(fgets、strtok、getcwd)都建立在这个约定上。
七、小结:myshell 用到的系统接口清单
| 读取输入 | fgets |
| 切割解析 | strtok |
| 创建子进程 | fork |
| 执行程序 | execvp / execlp / execve |
| 回收子进程 & 拿退出码 | waitpid |
| 内建 cd | chdir |
| 读/写环境变量 | getenv / putenv |
| 读当前目录 | getcwd |
三条最值钱的收获:
写到这里,从 fork 的第一个 PCB 副本,到终端里那个等待你输入的提示符,整条链路终于完全由自己实现了。
本文基于 2026-06-25《自定义 shell1》、2026-06-26《自定义 shell2》两份笔记整理。
我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=ffv239lb5al



