Brilliantwxx头像
【Linux】 进程控制:fork/exit/wait/exec 全解 + 手写自主shell封面图

【Linux】 进程控制:fork/exit/wait/exec 全解 + 手写自主shell


  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 件事:

  1. 给子进程分配新的内存块和内核数据结构(task_struct、mm_struct 等);
  2. 把父进程部分数据结构内容拷贝给子进程(页表、文件描述符表等);
  3. 把子进程加入系统进程列表;
  4. 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 两种典型用法

  1. 父进程复制自己,让父子同时跑不同代码分支:比如 TCP 服务器父进程 accept,fork 出子进程处理连接;
  2. 子进程要跑一个全新程序: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 是信号编号
130Ctrl+C(SIGINT,=128+2)
143SIGTERM(=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;
}

逐行讲:

  1. fork() 出子进程;
  2. 子进程立刻 execvpe(gargv[0], gargv, genv)——把自己换成 gargv[0] 这个程序,参数是 gargv,环境变量是 genv;
  3. exec 成功就永远回不来了(跑到新程序里),失败才往下走 exit(1);
  4. 父进程 waitpid(id, &status, 0) 阻塞等指定子进程;
  5. 用 WIFEXITED 判断是不是正常退出,是就用 WEXITSTATUS 取退出码存进 lastcode;
  6. 被信号杀(比如子进程段错误)就记 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 末尾必须 NULLexecvpe 靠 NULL 判断参数结束
lastcode 用全局变量echo $? 是 shell 自己的状态,不通过子进程拿
cd/export/echo 不 fork它们要改 shell 自己的状态(cwd、环境变量、退出码)
外部命令才 fork+exec它们是磁盘上的独立程序,必须换进程跑
waitpid 而不是 waitwaitpid 可以指定 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

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--