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 件事:
- 给子进程分配新的内存块和内核数据结构(task_struct、mm_struct 等);
- 把父进程部分数据结构内容拷贝给子进程(页表、文件描述符表等);
- 把子进程加入系统进程列表;
- fork 返回,开始调度器调度。
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 两种典型用法
- 父进程复制自己,让父子同时跑不同代码分支:比如 TCP 服务器父进程 accept,fork 出子进程处理连接;
- 子进程要跑一个全新程序:fork 后子进程立刻调 exec,把自己换成另一个程序(shell 就是这么干的)。
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:非阻塞,没子进程退就立刻返回 03.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; }逐行讲:
fork()出子进程;- 子进程立刻
execvpe(gargv[0], gargv, genv)——把自己换成gargv[0]这个程序,参数是gargv,环境变量是genv;- exec 成功就永远回不来了(跑到新程序里),失败才往下走
exit(1);- 父进程
waitpid(id, &status, 0)阻塞等指定子进程;- 用
WIFEXITED判断是不是正常退出,是就用WEXITSTATUS取退出码存进lastcode;- 被信号杀(比如子进程段错误)就记 100。
六、环境变量操作
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 后面紧跟的代码只在失败时执行。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2602_95649725/article/details/168125765








