/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

fork/exit/wait/exec 全解 + 手写 mini- shell
一、进程创建:fork
1.1 fork 函数原型
#include <unistd.h>
pid_t fork(void);
- 子进程返回 0;
- 父进程返回子进程的 pid(>0);
- 失败返回 -1(设 errno)。
一个函数"返回两次"——因为 fork 成功后内核里出现了两个进程,父子各自从 fork 返回处继续跑,所以两个返回值分别属于两个进程。
1.2 fork 做了什么(内核视角)
调用 fork 后,内核做 4 件事:
int main() {
pid_t pid;
printf("Before: pid is %d\\n", getpid());
if ((pid = fork()) == -1) perror("fork"), exit(1);
printf("After: pid is %d, fork return %d\\n", getpid(), pid);
sleep(1);
return 0;
}
输出:
Before: pid is 43676
After: pid is 43676, fork return 43677 ← 父进程
After: pid is 43677, fork return 0 ← 子进程
关键理解:
- "Before" 只打印一次——fork 之前父进程独自跑;
- "After" 打印两次——fork 之后父子各跑一遍;
- 子进程不会打印 Before,因为它从 fork 返回处开始跑;
- fork 之后谁先跑由调度器决定,不保证顺序。
1.3 写时拷贝(Copy-On-Write, COW)
父子刚 fork 出来时,代码段、数据段、堆、栈都共享同一份物理内存(页表项标只读)。只有当任何一方试图写入时,内核才给那一页复制一份,再让写入方写自己那份。
1.4 fork 两种典型用法
1.5 fork 为什么会失败
- 系统中进程数太多(达到上限);
- 实际用户的进程数超过了 ulimit -u 限制。
二、进程终止
2.1 退出场景
- 代码跑完,结果正确;
- 代码跑完,结果不正确;
- 代码异常终止(段错误、Ctrl+C、被 kill)。
2.2 正常退出的三种方式
| main 里 return n | 最常见,等价于 exit(n) |
| exit(status) | 库函数,会冲刷缓冲区、执行清理函数 |
| _exit(status) | 系统调用,直接进内核,不做任何清理 |
异常退出:Ctrl+C(SIGINT)、kill 命令(SIGTERM/SIGKILL)、段错误。
2.3 退出码:echo $?
命令结束后用 echo $? 查看上一条命令的退出码:
| 0 | 成功 |
| 1 | 通用错误(如除 0、权限不够) |
| 2 | 命令用法不对 |
| 126 | 权限被拒绝 / 无法执行 |
| 127 | 命令找不到(PATH 错) |
| 128+n | 被信号杀死,n 是信号编号 |
| 130 | Ctrl+C(SIGINT,=128+2) |
| 143 | SIGTERM(=128+15) |
| 255 | 退出码超过 0~255,取模回来的 |
status 虽然是 int,但只有低 8 位会被父进程拿到。
_exit(-1) 在 echo $? 里看是 255(-1 的补码低 8 位全 1 = 255)。
2.4 exit vs _exit(重点区别)
正常运行的程序
│
├── exit(0):
│ ① 执行 atexit/on_exit 注册的清理函数
│ ② 冲刷 stdio 缓冲区、关闭打开的流
│ ③ 调 _exit(0) 进内核
│
└── _exit(0):
直接进内核,上面两步都不做
直观对比实验:
// 程序 A:printf 不带 \\n,用 exit
printf("hello");
exit(0);
// 输出:hello[root@localhost]# ← 缓冲区被冲刷,hello 打出来了
// 程序 B:printf 不带 \\n,用 _exit
printf("hello");
_exit(0);
// 输出:[root@localhost]# ← hello 没了!缓冲区没冲刷
因为 printf 是行缓冲,没遇到 \\n 时数据留在 stdio 缓冲区。
exit 会冲刷,_exit 直接走人——所以库函数退出用 exit,系统调用直接退出才用 _exit。
三、进程等待
3.1 为什么要 wait
子进程退出后,父进程如果不管它:
- 子进程会变成僵尸进程(Z):task_struct 还在、资源没回收,内存泄漏;
- 僵尸进程"刀枪不入",kill -9 也杀不掉(它已经死了,只是没人收尸);
- 父进程也需要知道"子任务干得怎么样"——成功了还是失败了。
所以父进程必须通过 wait/waitpid 回收子进程、拿退出状态。
3.2 wait 和 waitpid
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
// 成功返回被等待子进程 pid,失败 -1;status 是输出型参数,不关心就传 NULL
pid_t waitpid(pid_t pid, int *status, int options);
// pid = -1:等任意子进程(等价 wait)
// pid > 0:等指定 pid 的子进程
// options = 0:阻塞等待
// options = WNOHANG:非阻塞,没子进程退就立刻返回 0
3.3 status 不是普通整数,是位图
status 要当位图看,只看低 16 位:
正常终止: [高8位: 退出码] [低8位: 0]
被信号杀: [未用] [第8位: core dump标志] [低7位: 终止信号编号]
用宏来解析,别自己移位:
if (WIFEXITED(status)) { // 是否正常退出
int code = WEXITSTATUS(status); // 取退出码
} else {
int sig = status & 0x7F; // 被几号信号杀的
}
3.4 阻塞 vs 非阻塞等待
阻塞等待:
父进程卡在 waitpid,直到有子进程退出才回来——简单但父进程啥事也干不了。
非阻塞等待(WNOHANG + 轮询):
do {
ret = waitpid(-1, &status, WNOHANG);
if (ret == 0) { // 子进程还活着
do_other_thing(); // 父进程干自己的活
}
} while (ret == 0);
这就是"轮询 + 异步回收"——父进程一边等子进程,一边能干活。
四、程序替换:exec 系列
4.1 替换原理
fork 出来的子进程默认跑的还是父进程那份代码(只是走了 if==0 分支)。
如果想让子进程跑一个全新程序,就调 exec:
- exec 把磁盘上的 ELF 程序加载到当前进程地址空间;
- 代码段、数据段、堆、栈全部被替换;
- 从新程序的 _start 开始跑;
- PID 不变(还是原来那个进程,只是里面跑的程序换了);
- exec 不创建新进程。
4.2 六个 exec 函数
int execl (const char *path, const char *arg, …);
int execlp(const char *file, const char *arg, …);
int execle(const char *path, const char *arg, …, char *const envp[]);
int execv (const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);
命名规律(记住后缀就会用):
| l(list) | 参数一个个列出来(可变参数) |
| v(vector) | 参数放一个 char* argv[] 数组 |
| p(path) | 自动在 PATH 环境变量里搜,不用写全路径 |
| e(env) | 自己传环境变量数组,不继承当前的 |
| execl | 列表 | 要 | 是 |
| execlp | 列表 | 不要 | 是 |
| execle | 列表 | 要 | 自己传 |
| execv | 数组 | 要 | 是 |
| execvp | 数组 | 不要 | 是 |
| execve | 数组 | 要 | 自己传 |
char *const argv[] = {"ps", "-ef", NULL};
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
execl("/bin/ps", "ps", "-ef", NULL); // 写全路径,列表
execlp("ps", "ps", "-ef", NULL); // p 自动搜 PATH
execle("/bin/ps", "ps", "-ef", NULL, envp);
execv("/bin/ps", argv); // 数组
execvp("ps", argv);
execve("/bin/ps", argv, envp);
只有 execve 是真正的系统调用,其他 5 个都是库函数,最终都调 execve。
成功时 exec 不返回(直接去跑新程序了),失败才返回 -1。
手写 mini-shell
〇、先看整体流程
一、头文件和全局变量
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>
using namespace std;
const int basesize = 1024; // 命令行缓冲区大小
const int argvnum = 64; // argv 最多 64 个参数
const int envnum = 64; // 环境变量最多 64 个
// 命令行参数表:解析 "ls -a -l" 后变成 {"ls","-a","-l",NULL}
char *gargv[argvnum];
int gargc = 0;
// 上一条命令的退出码(echo $? 要用)
int lastcode = 0;
// 自己维护的环境变量表({"PATH=…","HOME=…",NULL})
char *genv[envnum];
// 当前 shell 工作路径
char pwd[basesize];
char pwdenv[basesize];
设计点:
- gargv 是个 char* 数组,末尾必须是 NULL(execvpe 靠 NULL 判断参数结束);
- lastcode 记录上一条命令退出码,echo $? 要打印它;
- genv 是 shell 自己的环境变量数组,初始化时从父进程继承,export 命令往里加。
二、提示符相关函数
2.1 取用户名、主机名
string GetUserName() {
string name = getenv("USER"); // 从环境变量取
return name.empty() ? "None" : name;
}
string GetHostName() {
string hostname = getenv("HOSTNAME");
return hostname.empty() ? "None" : hostname;
}
2.2 取当前工作目录
string GetPwd() {
if (nullptr == getcwd(pwd, sizeof(pwd)))
return "None";
// 同步把 PWD=xxx 写进环境变量
snprintf(pwdenv, sizeof(pwdenv), "PWD=%s", pwd);
putenv(pwdenv);
return pwd;
}
getcwd 把当前工作目录字符串填进 pwd 缓冲区。
同时把 PWD=xxx 写进环境变量,这样子进程 exec 后能拿到正确的 PWD。
2.3 取路径最后一段(提示符只显示目录名)
string LastDir() {
string curr = GetPwd();
if (curr == "/" || curr == "None") return curr;
// 从右往左找最后一个 '/'
size_t pos = curr.rfind("/");
if (pos == string::npos) return curr;
return curr.substr(pos + 1); // 截取 '/' 后面那截
}
比如 /home/whb/project,rfind 找到最后一个 / 在位置 10,substr(11) 取出 project。提示符只显示最后一级目录,和真 bash 一致。
2.4 拼提示符字符串
string MakeCommandLine() {
char command_line[basesize];
snprintf(command_line, basesize, "[%s@%s %s]# ",
GetUserName().c_str(),
GetHostName().c_str(),
LastDir().c_str());
return command_line;
}
void PrintCommandLine() {
printf("%s", MakeCommandLine().c_str());
fflush(stdout); // 关键:提示符不带 \\n,必须手动刷缓冲
}
为什么 fflush(stdout)?
因为 stdout 默认行缓冲,提示符结尾没有 \\n,不 flush 就卡在缓冲区里屏幕上看不到。真 bash 是用无缓冲方式写终端的。
三、读命令行
bool GetCommandLine(char command_buffer[], int size) {
// 用 fgets 读一整行(包含最后的 \\n)
char *result = fgets(command_buffer, size, stdin);
if (!result) return false;
// fgets 会把结尾的 \\n 也读进来,替换成 \\0
command_buffer[strlen(command_buffer) – 1] = 0;
if (strlen(command_buffer) == 0) return false; // 空行
return true;
}
注意点:
- fgets 读到 \\n 为止,会把 \\n 一起存进缓冲区,所以手动把它换成 \\0;
- 返回 false 时主循环 continue,重新打印提示符。
四、解析命令行:strtok 切分
void ParseCommandLine(char command_buffer[], int len) {
(void)len;
memset(gargv, 0, sizeof(gargv)); // 清空上一轮的 argv
gargc = 0;
const char *sep = " ";
// strtok 第一次传字符串,之后传 NULL 继续切
gargv[gargc++] = strtok(command_buffer, sep);
// 一直切到 NULL,最后一次 gargv[gargc] 被赋值为 NULL
while ((bool)(gargv[gargc++] = strtok(nullptr, sep)));
gargc–; // 上面 while 多自增了一次,减回来
}
举例:输入 "ls -a -l -n"
- 第一次 strtok 返回 "ls",gargv[0]="ls",gargc=1;
- 第二次返回 "-a",gargv[1]="-a",gargc=2;
- …
- 最后 strtok 返回 NULL,gargv[4]=NULL,gargc 多加了一次,减回 4。
关键:gargv[gargc]=NULL 这个结尾哨兵必须有,execvpe 靠它判断参数结束。
4.1 debug 打印
void debug() {
printf("argc: %d\\n", gargc);
for (int i = 0; gargv[i]; i++)
printf("argv[%d]: %s\\n", i, gargv[i]);
}
五、外部命令执行:ExecuteCommand(核心)
bool ExecuteCommand() {
pid_t id = fork();
if (id < 0) return false;
if (id == 0) {
// ===== 子进程 =====
// 1. 把自己替换成要执行的程序
// execvpe(path, argv[], envp[])
execvpe(gargv[0], gargv, genv);
// 只有 exec 失败才会走到这里
exit(1);
}
// ===== 父进程 =====
int status = 0;
pid_t rid = waitpid(id, &status, 0); // 阻塞等子进程
if (rid > 0) {
if (WIFEXITED(status)) {
lastcode = WEXITSTATUS(status); // 正常退出,记退出码
} else {
lastcode = 100; // 异常退出(被信号杀)
}
}
return true;
}
逐行讲:
六、环境变量操作
6.1 往 genv 数组追加一个环境变量
void AddEnv(const char *item) {
int index = 0;
// 先找到数组末尾(NULL 在哪)
while (genv[index]) index++;
// 分配内存拷贝 item("PATH=/bin:/usr/bin")
genv[index] = (char*)malloc(strlen(item) + 1);
strcpy(genv[index], item);
genv[++index] = nullptr; // 末尾补 NULL
}
环境变量数组的格式就是 "KEY=VALUE" 字符串,末尾 NULL。
export PATH=/bin:/usr/bin 命令就是把 "PATH=/bin:/usr/bin" 这样的字符串塞进 genv。
6.2 初始化环境变量:从父进程继承
void InitEnv() {
extern char **environ; // 全局变量:父进程的环境表
int index = 0;
while (environ[index]) {
genv[index] = (char*)malloc(strlen(environ[index]) + 1);
strcpy(genv[index], environ[index] + 1);
index++;
}
genv[index] = nullptr;
}
environ 是 libc 提供的全局变量,指向父进程传下来的环境表。
shell 启动时把它拷到自己的 genv 里,之后自己维护。
七、内建命令:CheckAndExecBuiltCommand
这是 shell 最精髓的部分——有些命令不能 fork 子进程跑,必须 shell 自己执行。
这里实现了 4 个:cd、export、env、echo。
bool CheckAndExecBuiltCommand() {
// ———- cd ———-
if (strcmp(gargv[0], "cd") == 0) {
if (gargc == 2) {
chdir(gargv[1]); // 改 shell 自己的工作目录
lastcode = 0;
} else {
lastcode = 1;
}
return true; // 告诉主循环:这是内建,别 fork
}
// ———- export ———-
else if (strcmp(gargv[0], "export") == 0) {
if (gargc == 2) {
AddEnv(gargv[1]); // 把 "KEY=VALUE" 加进 genv
lastcode = 0;
} else {
lastcode = 2;
}
return true;
}
// ———- env ———-
else if (strcmp(gargv[0], "env") == 0) {
for (int i = 0; genv[i]; i++)
printf("%s\\n", genv[i]);
lastcode = 0;
return true;
}
// ———- echo ———-
else if (strcmp(gargv[0], "echo") == 0) {
if (gargc == 2) {
// echo $? :打印上一条退出码
if (gargv[1][0] == '$') {
if (gargv[1][1] == '?') {
printf("%d\\n", lastcode);
lastcode = 0;
}
} else {
// echo hello:原样打印
printf("%s\\n", gargv[1]);
lastcode = 0;
}
} else {
lastcode = 3;
}
return true;
}
return false; // 不是内建命令,让主循环去 fork
}
逐个讲:
7.1 cd:为什么必须自己跑
chdir(gargv[1]);
如果 fork 子进程去 chdir,子进程改的是自己的工作目录,子进程一退出就没了,bash 的目录纹丝不动。
所以 cd 必须 shell 自己调 chdir(),改的是 shell 进程的目录。
下次子进程 exec 时,新进程继承 shell 的 cwd,所以"cd 一下再 ls"看到的就是新目录。
7.2 export:改环境变量
AddEnv(gargv[1]); // export MYVAR=hello
把 "MYVAR=hello" 这个字符串塞进 genv。
之后子进程 execvpe 时会把 genv 传给子进程,子进程就能 getenv("MYVAR") 拿到。
7.3 env:列出所有环境变量
直接遍历 genv 打印。
7.4 echo:特判 $?
if (gargv[1][0] == '$' && gargv[1][1] == '?')
printf("%d\\n", lastcode);
echo $? 不是外部命令,因为 $? 是 shell 自己的变量(上一条退出码),必须 shell 自己打印。
八、main 主循环
int main() {
InitEnv(); // 1. 从父进程继承环境变量
char command_buffer[basesize];
while (true) {
PrintCommandLine(); // 2. 打印提示符
if (!GetCommandLine(command_buffer, basesize)) // 3. 读一行
continue;
ParseCommandLine(command_buffer, strlen(command_buffer)); // 4. 解析
if (CheckAndExecBuiltCommand()) // 5. 内建命令自己跑
continue;
ExecuteCommand(); // 6. 外部命令 fork+exec+wait
}
return 0;
}
九、几个关键设计为什么这么写
| 提示符后 fflush(stdout) | stdout 行缓冲,提示符没 \\n,不 flush 屏幕上看不到 |
| fgets 后把 \\n 换成 \\0 | 否则 strtok 会把 \\n 当参数一部分 |
| gargv 末尾必须 NULL | execvpe 靠 NULL 判断参数结束 |
| lastcode 用全局变量 | echo $? 是 shell 自己的状态,不通过子进程拿 |
| cd/export/echo 不 fork | 它们要改 shell 自己的状态(cwd、环境变量、退出码) |
| 外部命令才 fork+exec | 它们是磁盘上的独立程序,必须换进程跑 |
| waitpid 而不是 wait | waitpid 可以指定 pid 等特定子进程,便于以后做多作业 |
十、一次命令的完整生命周期(以 ls -l 为例)
1. PrintCommandLine: 打印 [localhost myshell]#
2. GetCommandLine: fgets 读到 "ls -l",去掉 \\n
3. ParseCommandLine: gargv = {"ls","-l",NULL}, gargc=2
4. CheckAndExecBuiltCommand: strcmp 都不匹配,返回 false
5. ExecuteCommand:
fork()
├─ 子进程: execvpe("ls", {"ls","-l",NULL}, genv)
│ → 进程被替换成 /bin/ls
│ → ls 打印 -l 列表,exit(0)
└─ 父进程: waitpid(pid,&status,0) 阻塞
→ 子退出,status 拿到
→ lastcode = WEXITSTATUS(status) = 0
6. 回到 while(1),打印下一个提示符
十一、对比真 bash 还差什么
这个 mini-shell 已经能跑:
- ✅ 普通外部命令(ls、ps、pwd、whoami)
- ✅ 内建命令(cd、export、env、echo $?)
- ✅ 环境变量继承
- ✅ 退出码 echo $?
还没实现(留给后续进阶):
- ❌ 管道 |(要用 pipe() + fork 两个子进程 + dup2 重定向)
- ❌ 输出重定向 >、输入重定向 <(dup2 改标准文件描述符)
- ❌ 后台运行 &(waitpid 不给 WNOHANG 但不阻塞)
- ❌ 通配符 *(glob 展开)
- ❌ 脚本执行(sh test.sh)
十二、完整代码
/******************************************************************************
* mini_shell.cpp
* 一个最小可用的 Linux Shell:
* – 支持普通外部命令(ls / ps / whoami / pwd …)
* – 支持内建命令:cd / export / env / echo $? / exit
* – 支持环境变量继承与维护
* – 支持 echo $? 打印上一条命令退出码
* 编译:g++ -o mini_shell mini_shell.cpp -std=c++11
* 运行:./mini_shell
*****************************************************************************/
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>
using namespace std;
/* ====================== 全局配置 ====================== */
const int basesize = 1024; // 命令行缓冲区大小
const int argvnum = 64; // argv 最多参数个数
const int envnum = 64; // 环境变量最多个数
/* ====================== 全局状态 ====================== */
static char *gargv[argvnum]; // 命令行参数表,末尾必须为 NULL
static int gargc = 0; // 参数个数
static int lastcode = 0; // 上一条命令的退出码(echo $? 用)
static char *genv[envnum]; // shell 自己维护的环境变量表
static char pwd[basesize]; // 当前工作目录
static char pwdenv[basesize];// "PWD=xxx" 字符串
/* ====================== 提示符相关 ====================== */
static string GetUserName() {
const char *name = getenv("USER");
return name ? name : "None";
}
static string GetHostName() {
const char *host = getenv("HOSTNAME");
return host ? host : "None";
}
static string GetPwd() {
if (getcwd(pwd, sizeof(pwd)) == NULL) return "None";
snprintf(pwdenv, sizeof(pwdenv), "PWD=%s", pwd);
putenv(pwdenv);
return pwd;
}
static string LastDir() {
string curr = GetPwd();
if (curr == "/" || curr == "None") return curr;
size_t pos = curr.rfind("/");
if (pos == string::npos) return curr;
return curr.substr(pos + 1);
}
static string MakeCommandLine() {
char line[basesize];
snprintf(line, basesize, "[%s@%s %s]# ",
GetUserName().c_str(),
GetHostName().c_str(),
LastDir().c_str());
return line;
}
static void PrintCommandLine() {
printf("%s", MakeCommandLine().c_str());
fflush(stdout); // 必须 fflush,因为提示符没 \\n
}
/* ====================== 读命令行 ====================== */
static bool GetCommandLine(char buf[], int size) {
if (fgets(buf, size, stdin) == NULL) {
printf("\\n"); // Ctrl+D 退出
return false;
}
buf[strlen(buf) – 1] = '\\0'; // 去掉 fgets 读进来的 \\n
if (strlen(buf) == 0) return false;
return true;
}
/* ====================== 解析命令行 ====================== */
static void ParseCommandLine(char buf[]) {
memset(gargv, 0, sizeof(gargv));
gargc = 0;
const char *sep = " ";
gargv[gargc++] = strtok(buf, sep);
while ((gargv[gargc++] = strtok(NULL, sep)) != NULL)
;
gargc–;
}
/* ====================== 环境变量操作 ====================== */
static void InitEnv() {
extern char **environ;
int i = 0;
for (; environ[i] && i < envnum – 1; i++) {
genv[i] = (char *)malloc(strlen(environ[i]) + 1);
strcpy(genv[i], environ[i]);
}
genv[i] = NULL;
}
static void AddEnv(const char *item) {
int i = 0;
while (genv[i]) i++;
genv[i] = (char *)malloc(strlen(item) + 1);
strcpy(genv[i], item);
genv[++i] = NULL;
}
/* ====================== 外部命令执行 ====================== */
static void ExecuteCommand() {
pid_t id = fork();
if (id < 0) { perror("fork"); lastcode = 1; return; }
if (id == 0) {
// 子进程:替换成外部程序
execvpe(gargv[0], gargv, genv);
perror(gargv[0]); // exec 失败才会走到这
exit(127);
}
// 父进程:阻塞等子进程
int status = 0;
waitpid(id, &status, 0);
if (WIFEXITED(status))
lastcode = WEXITSTATUS(status);
else if (WIFSIGNALED(status))
lastcode = 128 + WTERMSIG(status); // 被信号杀
else
lastcode = 100;
}
/* ====================== 内建命令 ====================== */
static bool CheckAndExecBuiltCommand() {
/* exit:退出 shell */
if (strcmp(gargv[0], "exit") == 0) { printf("bye~\\n"); exit(0); }
/* cd:改 shell 自己的工作目录 */
if (strcmp(gargv[0], "cd") == 0) {
if (gargc == 2) {
if (chdir(gargv[1]) != 0) perror("cd");
} else {
chdir(getenv("HOME") ? getenv("HOME") : "/");
}
lastcode = 0;
return true;
}
/* export KEY=VALUE */
if (strcmp(gargv[0], "export") == 0) {
if (gargc == 2) { AddEnv(gargv[1]); lastcode = 0; }
else lastcode = 1;
return true;
}
/* env:列环境变量 */
if (strcmp(gargv[0], "env") == 0) {
for (int i = 0; genv[i]; i++) printf("%s\\n", genv[i]);
lastcode = 0;
return true;
}
/* echo:支持 echo $? / echo xxx */
if (strcmp(gargv[0], "echo") == 0) {
if (gargc == 2) {
if (gargv[1][0] == '$' && gargv[1][1] == '?')
printf("%d\\n", lastcode);
else
printf("%s\\n", gargv[1]);
} else {
printf("\\n");
}
lastcode = 0;
return true;
}
return false; // 不是内建,去 fork
}
/* ====================== 主循环 ====================== */
int main() {
InitEnv();
char cmd[basesize];
while (true) {
PrintCommandLine();
if (!GetCommandLine(cmd, basesize)) continue;
ParseCommandLine(cmd);
if (CheckAndExecBuiltCommand()) continue;
ExecuteCommand();
}
return 0;
}
面试题
Q1. 为什么 shell 读命令用 fgets 而不是 scanf?
A:
scanf 遇到空格就停,只能读一个词;ls -a -l 有空格,必须 fgets 读一整行,再自己 strtok 切分成 argv。
Q2. strtok 第一次和后续调用传参有什么区别?
A:
第一次传要切分的字符串地址;后续传 NULL,strtok 内部记住上次切到哪了。
切完返回 NULL 表示结束。
Q3. gargv 数组末尾为什么必须是 NULL?
A:
execvpe 的参数列表靠 NULL 判断结束。
它不知道你传了几个参数,只认到 NULL 为止。少了 NULL exec 会越界读内存。
Q4. 为什么 cd 不能做成外部命令?
A:
cd 要改变 shell 自己的工作目录。
如果 fork 子进程去 chdir,改的是子进程的 cwd,子进程一退出就没了,shell 的 cwd 不变。
所以 cd 必须 shell 进程自己调 chdir()。
Q5. export 为什么必须是内建命令?
A:
export 要修改 shell 自己的环境变量表(genv),之后子进程才能继承到新变量。
如果 fork 子进程去 export,改的是子进程的环境变量,子进程一退出啥也没留下,shell 自己的 genv 没变。
Q6. echo $? 为什么必须是内建?
A:
$? 是 shell 自己维护的"上一条命令退出码"变量,存在 shell 的 lastcode 里。
外部进程拿不到这个值,必须 shell 自己打印。
Q7. 子进程 execvpe 失败后为什么要 exit(1)?
A:
exec 成功就不会返回(跑到新程序里去了)。
只有失败才会返回 -1,这时候如果不 exit,子进程会继续跑 shell 自己的代码,导致 shell 行为异常。
所以 exec 失败必须立刻 exit。
Q8. waitpid 拿到 status 后为什么要用 WIFEXITED/WEXITSTATUS 宏,不直接 status>>8?
A:
status 是位图,正常退出高 8 位是退出码、被信号杀低 7 位是信号号。
直接 status>>8 在异常退出时会拿到错误值。
用 WIFEXITED 先判断是不是正常退出,再用 WEXITSTATUS 取码,可移植且安全。
Q9. 提示符后面为什么要 fflush(stdout)?
A:
stdout 默认行缓冲,遇到 \\n 才刷。
提示符结尾是 # 没有 \\n,不 fflush 就卡在 stdio 缓冲区里,屏幕上看不到提示符,但光标在等输入。
Q10. 这个 shell 里 cd .. && ls 能跑吗?
A:
不能。mini-shell 一次只处理一行命令,不支持 &&、管道、分号这些控制符。
真 bash 会先解析 && 拆成两条命令。
这个 mini-shell 会把整个 "cd .. && ls" 当成一个命令去 exec,找不到这个可执行文件就报错。
Q11. 想加一个 pwd 内建命令,怎么加?
A:
在 CheckAndExecBuiltCommand 里加一个 else if (strcmp(gargv[0],"pwd")==0) 分支,里面 printf("%s\\n", getenv("PWD")) 就行。
不用动 ExecuteCommand。
Q12. fork+exec 中间父进程可以做什么?
A:
这是 shell 实现重定向和管道的关键窗口。
比如要 ls > a.txt:fork 之后、exec 之前,子进程 close(1) 再 open("a.txt", O_WRONLY),这样标准输出被重定向到文件,再 exec ls,ls 的输出就写文件了。
shell 逐行精讲完毕。
为什么 cd 必须是内建命令?
cd 如果走 fork+exec:子进程 chdir 改的是子进程自己的工作目录,子进程退出后啥也没留下,bash 的目录根本没动。
所以 cd 必须由 shell 自己调用 chdir(),改的是 shell 自己的目录。
类似的内建命令还有:export(改 shell 自己的环境变量)、env、echo $?(读 shell 自己的 lastcode)。
函数调用 vs 进程调用的类比
| call 一个函数 | fork + exec 一个程序 |
| 函数 return n | 子进程 exit(n) |
| 主调函数拿返回值 | 父进程 wait(&status) 拿退出码 |
| 函数有自己的栈/局部变量 | 进程有自己的独立地址空间 |
一、fork 相关
Q1. fork 为什么有两个返回值?
A:
fork 成功后内核里出现两个进程,父子都从 fork 返回处继续执行。
父进程拿到的是子进程 pid(>0),子进程拿到的是 0。本质是同一段代码在两个地址空间里各执行了一遍 return 语句。
Q2. fork 之后父子谁先执行?
A:
不确定,由 CPU 调度器决定。代码不能假设顺序。
Q3. 什么是写时拷贝(COW)?为什么要有它?
A:
fork 时父子共享同一份物理内存页(页表标只读),任一方写入时内核才复制那一页。 这样 fork 不用立刻复制整个地址空间,快且省内存;同时父子物理上隔离,保证了进程独立性。
Q4. fork 失败的原因?
A:
① 系统进程数达到上限;② 当前用户进程数超过 ulimit -u 限制。
二、退出相关
Q5. exit 和 _exit 的区别?
A:
_exit 是系统调用,直接进内核,不做任何清理;
exit 是库函数,先执行 ① atexit/on_exit 注册的清理函数、② 冲刷 stdio 缓冲区、关闭流,最后才调 _exit。
所以 printf("hello");_exit(0); 可能什么都不打印,因为缓冲区没冲刷。
Q6. main 里 return n 和 exit(n) 一样吗?
A:
一样。启动函数会把 main 的返回值作为参数调 exit(n)。
Q7. echo $? 看 130 是什么意思?
A:
130 = 128 + 2,2 是 SIGINT。
表示上一条命令被 Ctrl+C 终止了。同理 143 = 128+15 = SIGTERM 终止。
Q8. 为什么 _exit(-1) 后 echo $? 是 255?
A:
退出码只有低 8 位被父进程拿到。
-1 的补码是全 1,低 8 位 = 0xFF = 255。
三、等待相关
Q9. 为什么父进程要 wait 子进程?不 wait 会怎样?
A:
否则子进程退出后变成僵尸进程(Z):task_struct 残留、资源不回收,造成内存泄漏;僵尸进程已经死了,kill -9 也杀不掉,只能靠父进程 wait 或父进程退出由 init 回收。
另外 wait 还能拿子进程退出状态。
Q10. wait 和 waitpid 的区别?
A:
wait 阻塞地等任意一个子进程;
waitpid 可以指定 pid(等某个具体子进程),第三个参数给 WNOHANG 可以非阻塞轮询。
wait 等价于 waitpid(-1, status, 0)。
Q11. WNOHANG 是什么?怎么用?
A:
非阻塞选项。
如果指定的子进程还没退出,waitpid 立刻返回 0,父进程可以去干别的事,下一轮再问。
典型用法是 do { ret = waitpid(-1,&st,WNOHANG); if(ret==0) do_other(); } while(ret==0)。
Q12. status 参数怎么用?能直接当整数看吗?
A:
不能。status 是位图:正常退出时高 8 位是退出码、低 7 位是 0;被信号杀时低 7 位是信号编号、第 8 位是 core dump 标志。要用 WIFEXITED(status) 判断是否正常退出,用 WEXITSTATUS(status) 取退出码。
四、exec 相关
Q13. exec 后 PID 变不变?创建新进程了吗?
A:
PID 不变,也不创建新进程。
exec 只是把当前进程的代码段/数据段/堆/栈替换成磁盘上新程序的内容,进程还是原来那个,只是里面跑的程序换了。
Q14. exec 函数名里 l/v/p/e 分别什么意思?
A:
l=list 参数列表(可变参数),v=vector 参数放数组,
p=path 自动搜 PATH 环境变量,e=env 自己传环境变量。
六个函数里只有 execve 是真系统调用,其他都是库函数封装。
Q15. exec 有返回值吗?
A:
成功时不返回(直接从新程序启动例程跑);
失败才返回 -1。所以 exec 后面紧跟的代码只在失败时执行。





