Linux 进程控制

Linux 进程控制 --- 从 fork 到手写 Shell

前言

在 Linux 的世界里,进程是一切的核心。我们启动一个程序、执行一条命令、运行一个脚本,背后都是进程在运作。但进程不是凭空出现的 --- 它由一个已存在的进程创建,执行特定的代码,最终退出并被回收。这四个环节 --- 创建、执行、等待、终止 --- 构成了 Linux 进程控制的完整闭环。

实际上,理解了这四个环节,你就理解了操作系统是如何管理用户程序的。更深一层说,Linux 将函数调用的思想 --- 调用、传参、执行、返回 --- 扩展到了进程之间:fork+exec 类比 call,exit 类比 return,wait 类比获取返回值。这种设计哲学贯穿整个 Linux 系统编程,我们在文章最后还会回到这个视角。

这篇文章会从 fork 的底层原理讲起,一路深入到写时拷贝、僵尸进程、exec 函数族,最后带你手写一个微型 Shell。读完你会发现,原来 Shell 的工作原理如此简单。


1. 进程创建:fork 深度解析

1.1 fork 函数初识

在 Linux 中,fork 是创建新进程的唯一方式。它从当前进程"分叉"出一个子进程,子进程几乎是父进程的完整副本。

c 复制代码
#include <unistd.h>

pid_t fork(void);
// 返回值:子进程中返回 0,父进程返回子进程 pid,出错返回 -1

看到这个函数签名,你可能会疑惑:一个函数为什么会有两个返回值? 而且为什么子进程返回 0,父进程返回子进程的 pid?

实际上,当我们调用 fork() 时,控制权转移到内核的 fork 代码中。内核做的事情大致如下:

  1. 为子进程分配新的 task_struct(PCB)和内核栈
  2. 将父进程的 PCB 部分内容拷贝到子进程(包括文件描述符表、信号处理方式等)
  3. 为子进程构建页表,让它"看起来"拥有和父进程一样的地址空间(这里先记住是"看起来",后面 COW 会详细讲)
  4. 将子进程加入系统进程列表,纳入调度器的管理范围
  5. fork 返回,开始调度 --- 此时父子进程都从 fork 的返回点继续执行

关键就在第 5 步:内核在 fork 返回时,分别给父子进程设置了不同的返回值。对于父进程,fork 返回子进程的 pid(一个大于 0 的整数);对于子进程,fork 返回 0。于是,父子进程虽然共享同一份代码,但通过 if-else 判断返回值就能走向不同的分支。

说白了,fork 在"返回"这个动作上做了手脚 --- 它让同一个函数调用在两个进程中产生了不同的返回值。这其实也是为什么我们写代码时 if (pid == 0)else 分支分别对应子进程和父进程。

那为什么子进程返回 0,而不是别的数字?实际上,一个进程可以有多个子进程,但子进程只有一个父进程。子进程返回 0 意味着"我是子进程",而父进程拿到的是子进程的具体 pid,方便后续用 waitpid 精确管理某个子进程。如果子进程也返回自己的 pid,那它还得再调一次 getpid() 才知道自己是谁,不够优雅。

来看一个简单的例子:

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void)
{
    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;
}

运行结果:

复制代码
[tys@localhost linux]$ ./a.out
Before: pid is 43676
After:pid is 43676, fork return 43677
After:pid is 43677, fork return 0

这里输出了三行 --- 一行 Before,两行 After。进程 43676(父进程)先打印了 Before,然后打印了 After,fork 返回了 43677(子进程的 pid)。进程 43677(子进程)只打印了 After,fork 返回了 0。子进程没有打印 Before,因为子进程从 fork 返回处开始执行,不会重新执行 fork 之前的代码。

fork 之后谁先执行?完全由调度器决定,没有任何保证。所以如果你的代码依赖父子进程的执行顺序,那是不可靠的。

1.2 写时拷贝(Copy-On-Write)

前面说 fork 后子进程"看起来"拥有和父进程一样的地址空间,为什么要加"看起来"?

际上,现代 Linux 在 fork 时并不会真的把父进程的整个物理内存拷贝一份给子进程。那样做太慢了,也太浪费内存 --- 想想看,如果子进程 fork 后立刻 exec 加载新程序,那辛苦拷贝的几百 MB 数据岂不是全部白费?

于是有了 写时拷贝(Copy-On-Write, COW) 这个技术。fork 刚完成时,父子进程的页表指向同一块物理内存 ,但页表项被标记为只读。当任意一方试图写入时,CPU 触发缺页中断,内核这时才真正复制物理页,并修改页表让双方各自拥有独立的可写页面(其实就是运用修改时的报错,当做写实拷贝的触发条件)。

阶段 页表映射 物理内存 页表项权限
fork 刚完成 父子指向同一物理页 共享一份 只读(R/O)
任一进程写入前 共享映射 共享 只读
写入触发缺页中断 内核复制物理页,各自映射 分离为两份 读写(R/W)

COW 有两个核心价值:

  • 加速 fork 执行:fork 时只需复制父进程的页表(而非整个物理内存),数据结构小、速度快
  • 节省内存:父子共享的只读页面(如代码段)永远不需要复制;即使对于数据段,也只有真正被写入的页面才会触发复制

这其实也是为什么 fork 后父子进程得以彻底分离 --- COW 在必要时才完成物理内存的分离,是一种延迟申请技术,可以提高整机内存的使用率。有了 COW 的存在,父子进程的独立性才真正有了技术保证。

1.3 fork 的常规用法

fork 主要有两种典型使用场景:

场景一:父子执行不同代码分支。 父进程希望复制自己,然后父子各自执行不同的代码段。典型的例子是网络服务器 --- 父进程负责监听客户端请求,收到请求后 fork 一个子进程来处理请求,父进程继续监听。这种模式下,父子进程共享代码段但通过返回值走不同的分支。

场景二:子进程执行另一个程序。 子进程从 fork 返回后,立刻调用 exec 函数加载新程序。这是 Shell 执行命令的方式 --- 我们后面会详细讲。这种模式下,COW 的价值体现得最明显:fork 时几乎不复制数据,子进程 exec 时直接替换整个地址空间,之前共享的页面不需要复制。

fork 也有可能调用失败,主要原因是:

  • 系统中进程数量太多,达到了上限
  • 当前用户的实际进程数超过了限制

1.4 进程地址空间回顾

由之前的学习我们可以知道,每个进程都有自己的虚拟地址空间。典型布局如下:

fork 时,子进程会获得这份地址空间的"副本" --- 在 COW 的加持下,实际上只是页表级别的复制,物理内存仍然共享,直到某一方写入了数据。


2. 进程与终止

2.1 进程退出方式

进程退出的本质是释放系统资源 --- 释放进程申请的内核数据结构、代码和数据。

退出方式分成以下三种:

1.代码运行完毕,结果正确

2.代码运行完毕,结果不正确

3.异常退出

一般情况下,我们可以通过echo $? 用来查看最近的一个进程退出时的退出码

正常终止

正常终止可以通过 echo $? 查看退出码。有三种方式:

1. 从 main 函数 return

c 复制代码
int main()
{
    return 0;  // 等同于 exit(0)
}

执行 return n 等同于执行 exit(n),因为调用 main 的运行时函数会将 main 的返回值当作 exit 的参数。

2. 调用 exit 函数

c 复制代码
#include <unistd.h>

void exit(int status);

exit:括号中可以填入自己设定的错误码,调用以后会直接退出当前进程,相当于 main 函数中的 return 0

exit 最后也会调用 _exit,但在那之前还做了额外工作:

  1. 执行用户通过 atexiton_exit 注册的清理函数
  2. 关闭所有打开的流,将所有缓冲数据写入
  3. 调用 _exit

3. 调用 _exit 函数

c 复制代码
#include <unistd.h>

void _exit(int status);
// 参数:status 定义了进程的终止状态,但仅有低 8 位可以被父进程获取
// 所以 _exit(-1) 时,$? 显示的是 255

_exit 直接进入内核,不做任何用户态清理。来看看 exit 和 _exit 的关键区别:

c 复制代码
// 使用 exit --- 缓冲区会被刷新
int main()
{
    printf("hello");
    exit(0);
}
// 运行结果:
// [tys@localhost linux]$ ./a.out
// hello[tys@localhost linux]$

// 使用 _exit --- 缓冲区不会被刷新
int main()
{
    printf("hello");
    _exit(0);
}
// 运行结果:
// [tys@localhost linux]$ ./a.out
// [tys@localhost linux]$

第一段代码,printf("hello") 没有换行符,数据留在了 stdio 缓冲区。exit(0) 会关闭所有流,缓冲区被刷新,所以 "hello" 被打印了。第二段代码,_exit(0) 直接进入内核,缓冲区数据直接丢弃,所以什么都没输出。

这个实验告诉我们:exit 退出的时候会刷新缓冲区,_exit 不会。一般情况下用 exit,只有在 fork 后的子进程中,如果 exec 失败了,才会用 _exit 避免重复清理。

实际上exit属于是C语言提供的接口,而_exit是系统提供的接口,exit 的底层实际上就是_exit关系如下图所示:

所以所谓的缓冲区是由 C 语言提供的缓冲区,而不是操作系统提供的

异常终止

异常终止通常由信号触发,比如 ctrl + c(SIGINT)、kill -9(SIGKILL)、段错误(SIGSEGV),野指针等。

要注意异常终止的时候,退出码是没有意义的

退出码机制

退出码可以告诉我们最后一次执行的命令的状态。基本规则是:程序返回 0 表示执行成功,返回非 0 表示失败,不同的退出码表明了不同的错误原因,当进程结束的时候,退出码会写入到task_struct中,以方便父进程查看子进程退出时的错误信息。当进程因为错误信息退出的时候,操作系统会把进程中对应的errno设置成相应的号码,于是我们便可以使用strerror()函数查看,具体每一个错误码对应什么含义?。

Linux Shell 中的主要退出码:

退出码 含义
0 命令执行成功
1 一般性错误,"不被允许的操作"
2 命令使用错误(如参数错误)
130 被 SIGINT(ctrl+c)终止,130 = 128 + 2
143 被 SIGTERM 终止,143 = 128 + 15

被信号终止时,退出码 = 128 + 信号编号。可以用 strerror 函数根据退出码获取描述信息。


3. 进程等待:wait 与 waitpid

3.1 为什么需要进程等待

由之前的僵尸进程分析我们可以知道,子进程退出后如果父进程不管不顾,就会造成僵尸进程,进而造成内存泄漏。僵尸进程占着 PCB 槽位不释放,而且一旦变成僵尸状态,就刀枪不入 --- kill -9 也无能为力,因为谁也杀不死一个已经死去的进程。

另外,父进程派给子进程的任务完成得怎么样,也需要知道。子进程是正常退出还是异常退出?退出码是什么?这些信息都需要父进程通过进程等待的方式获取。

于是,父进程通过 wait/waitpid 来做三件事:

  • 回收子进程资源(释放 PCB,这个是主要目的)
  • 获取子进程退出信息(正常/异常、退出码)
  • 保持一定程度的同步(子进程在 wait 之前退出,wait 立即返回;子进程还在运行,wait 就阻塞等待有子进程结束时就返回)

3.2 wait 方法

c 复制代码
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);
// 返回值:成功返回被等待子进程的 pid,失败返回 -1
// 参数:输出型参数,获取子进程退出状态,不关心可以设为 NULL

wait 是最简单的等待方式 --- 阻塞等待任意一个子进程退出。如果当前没有子进程,wait 立即返回 -1(出错),wait的作用是,当它检测到有任何一个子进程返回的时候,它就会返回这个进程的PID,。

3.3 waitpid 方法

waitpid 提供了更精细的控制:

c 复制代码
pid_t waitpid(pid_t pid, int *status, int options);
// 返回值:
//   正常返回时,返回收集到的子进程 pid
//   设置了 WNOHANG 且没有子进程退出时,返回 0
//   出错返回 -1
// 参数 pid:
//   pid = -1:等待任意子进程,与 wait 等效
//   pid > 0:等待指定 pid 的子进程
// 参数 options:
//   0:阻塞等待(默认)
//   WNOHANG:非阻塞等待

3.4 status 的位图解析

进程的退出码只有在进程代码执行完正常退出时才有意义,当代码遇到异常退出时,进程的退出码是没有任何意义的。

wait 和 waitpid 的 status 参数是一个输出型参数,由操作系统填充。但status 不能简单当成一个整数来看,它其实是一个位图,只研究低 16 位:

  • 低 8 位(bit 0-7):终止信号编号。正常退出时为 0,被信号杀死时为信号编号
  • 高 8 位(bit 8-15):退出码。只有正常退出时有效

这也就是为什么 _exit(-1)$? 显示 255 --- 因为 -1 的低 8 位就是 255。

系统提供了几个宏来方便地解析 status:

作用
WIFEXITED(status) 若子进程正常终止,返回真
WEXITSTATUS(status) 若 WIFEXITED 为真,提取退出码(高 8 位)
WIFSIGNALED(status) 若子进程被信号终止,返回真
WTERMSIG(status) 若 WIFSIGNALED 为真,提取信号编号

来看一个完整的测试代码:

c 复制代码
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    pid_t pid;
    if ((pid = fork()) == -1) {
        perror("fork");
        exit(1);
    }

    if (pid == 0) {
        // 子进程
        sleep(20);
        exit(10);
    } else {
        // 父进程
        int st;
        int ret = wait(&st);

        // 正常退出:低 7 位为 0
        if (ret > 0 && (st & 0x7F) == 0) {
            printf("child exit code: %d\n", (st >> 8) & 0xFF);
        } else if (ret > 0) {
            // 异常退出:低 7 位是信号编号
            printf("sig code: %d\n", st & 0x7F);
        }
    }
    return 0;
}

运行结果:

复制代码
# 等 20 秒正常退出
[tys@localhost linux]$ ./a.out
child exit code: 10

# 在另一个终端 kill 掉子进程
[tys@localhost linux]$ ./a.out &
[tys@localhost linux]$ kill -9 <子进程pid>
sig code: 9

第一个结果,子进程正常退出,低 7 位为 0,高 8 位提取出退出码 10。第二个结果,子进程被 SIGKILL(信号 9)杀死,低 7 位是 9。

既然如此,父进程具体是怎么拿到子进程的exit_code,以及exit_signal的呢?

实际上是父亲成将输出型参数status交给由操作系统提供的接口 waitpid(),然后由于当进程结束的时候,它会将自己的退出码以及退出信号存在自己的 PCB 中,此时操作系统就可以将子进程 PCB 中的退出码以及退出信号和 status做相反的位运算以及或运算,此时成功的就把子进程的退出码和退出信号给到了父进程。

3.5 阻塞等待 vs 非阻塞等待

阻塞等待:父进程调用 waitpid 时,如果子进程还在运行,父进程就卡住不动,直到子进程退出。这是最简单的同步方式。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main()
{
    pid_t pid = fork();
    if (pid < 0) {
        printf("fork error\n");
        return 1;
    } else if (pid == 0) {
        // 子进程
        printf("child is run, pid is : %d\n", getpid());
        sleep(5);
        exit(257);  // 退出码 257,低 8 位是 1
    } else {
        // 父进程
        int status = 0;
        pid_t ret = waitpid(-1, &status, 0);  // 阻塞等待
        printf("this is test for wait\n");
        if (WIFEXITED(status) && ret == pid) {
            printf("wait child 5s success, child return code is :%d.\n",
                   WEXITSTATUS(status));
        } else {
            printf("wait child failed, return.\n");
            return 1;
        }
    }
    return 0;
}

运行结果:

复制代码
[tys@localhost linux]$ ./a.out
child is run, pid is : 45110
this is test for wait
wait child 5s success, child return code is :1.

父进程阻塞等待了 5 秒,子进程退出码虽然是 257,但 WEXITSTATUS 提取的是低 8 位,所以拿到的是 1。

非阻塞等待:父进程调用 waitpid 时,如果子进程还没退出,waitpid 立即返回 0,父进程可以继续做自己的事情,然后再次轮询。这种模式下,父进程在等待期间可以执行其他任务。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>

typedef void (*handler_t)();
std::vector<handler_t> handlers;

void fun_one() { printf("这是一个临时任务1\n"); }
void fun_two() { printf("这是一个临时任务2\n"); }

void Load() {
    handlers.push_back(fun_one);
    handlers.push_back(fun_two);
}

void handler() {
    if (handlers.empty())
        Load();
    for (auto iter : handlers)
        iter();
}

int main()
{
    pid_t pid = fork();
    if (pid < 0) {
        printf("fork error\n");
        return 1;
    } else if (pid == 0) {
        // 子进程
        printf("child is run, pid is : %d\n", getpid());
        sleep(5);
        exit(1);
    } else {
        // 父进程
        int status = 0;
        pid_t ret = 0;
        do {
            ret = waitpid(-1, &status, WNOHANG);  // 非阻塞轮询
            if (ret == 0) {
                printf("child is running\n");
            }
            handler();  // 等待期间执行其他任务
        } while (ret == 0);

        if (WIFEXITED(status) && ret == pid) {
            printf("wait child 5s success, child return code is :%d.\n",
                   WEXITSTATUS(status));
        } else {
            printf("wait child failed, return.\n");
            return 1;
        }
    }
    return 0;
}

这个例子在轮询等待子进程的同时,周期性地执行 handler() 中的临时任务。非阻塞等待的好处是父进程不会闲着,可以利用等待时间做有意义的事情。


4. 进程程序替换:exec 函数族

4.1 见识一下程序替换

运行如下代码:

c 复制代码
#include<stdio.h>                                                                                                                                                               
  2 #include<stdlib.h>
  3 #include<unistd.h>
  4 #include<sys/types.h>
  5 
  6 int main()
  7 {
  8     printf("程序开始!\n");
  9     execl("/usr/bin/ls","ls","-al",NULL);
 10     printf("程序结束!\n");
 11 }

运行结果如下:

由图片中的结果可以看出来,当程序运行到 excel 函数的时候程序被替换了,接下来是执行ls指令的内容,这种exec*类函数的作用就是进行程序替换接下来我们详细讲述一下程序替换

4.2 替换原理

fork 之后,父子执行的是同一份代码的不同分支。但如果子进程想执行一个全新的程序呢?比如你在 Shell 里敲 ls,Shell 需要启动 ls 这个程序,而不是执行 Shell 自己的代码。

这时候就需要进程程序替换 --- 通过 exec 函数族,将磁盘上的一个全新程序(代码和数据)加载到当前进程的地址空间中,替换掉原来的内容。

exce类函数的工作原理就是当程序调用这个函数的时候,他就会把这个子进程的代码和数据替换掉成exec 函数中要执行的代码和数据如下图所示:

并且exec*类函数是没有返回值的,因为当程序调用这个函数之后,进程的代码和数据会被新程序完全替代,所以根本不会有返回值。如果遇到有返回值,那么就说明程序替换失败了。

关键点:exec 不创建新进程。调用 exec 前后,进程的 PID 不变,但地址空间里的代码和数据完全被新程序替换。exec 调用成功后,从新程序的启动例程开始执行,原来的代码不再执行,所以 exec 函数没有成功的返回值 --- 只有出错时返回 -1。

4.3 exec 函数族命名规则

exec 一共有六个变体,看起来容易混,但掌握了命名规律就很好记:

c 复制代码
#include <unistd.h>

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) 参数以列表形式传递 execl("/bin/ls", "ls", "-l", NULL)
v (vector) 参数以数组形式传递 execv("/bin/ls", argv)
p (PATH) 自动在 PATH 环境变量中搜索 可以写 "ls" 而不是 "/bin/ls"
e (env) 自定义环境变量 传递 envp[] 数组

六个函数之间的关系:只有 execve 是真正的系统调用,其他五个函数最终都调用 execve。所以 execve 在 man 手册第 2 节,其他在第 3 节。

来看一个完整的例子:

c 复制代码
#include <unistd.h>
#include <stdlib.h>

int main()
{
    char *const argv[] = {"ps", "-ef", NULL};
    char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};

    // 带 p 的,可以使用环境变量 PATH,无需写全路径
    execlp("ps", "ps", "-ef", NULL);

    // 不带 p 的,需要写全路径
    execl("/bin/ps", "ps", "-ef", NULL);

    // 带 e 的,需要自己组装环境变量
    execle("ps", "ps", "-ef", NULL, envp);

    // 参数用数组传递
    execv("/bin/ps", argv);

    // 带 p + 数组
    execvp("ps", argv);

    // 带 e + 数组,真正的系统调用
    execve("/bin/ps", argv, envp);

    exit(0);
}

我们以execvpe举个例子,详细讲解一下。

首先我们可以执行以下代码:

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

//char *newnew = (char*)"myVAL=66666666";
char *const addenv[] = {
    (char *const)"MYVAL=123456789",
    (char *const)"MYVAL1=123456789",
    (char *const)"MYVAL2=123456789",
    NULL
};

int main()
{
    printf("我的程序要运行了!\n");

    if(fork() == 0)
    {
        printf("I am Child, My Pid Is: %d\n", getpid());
        sleep(1);

        char *const argv[] = {
            (char*const)"other",
            (char*const)"-a",
            (char*const)"-b",
            (char*const)"-c",
            (char*const)"-d",
            NULL
        };

        for(int i = 0; addenv[i]; i++)
        {
            putenv(addenv[i]);
        }
        extern char **environ;
        execvpe("./other", argv, environ);
        
    }

    waitpid(-1, NULL, 0);
    printf("我的程序运行完毕了\n");
    //return 0;
}
cpp 复制代码
#include <iostream>
#include <cstdio>
#include <unistd.h>

int main(int argc, char *argv[], char *env[])
{
    std::cout << "hello C++, My Pid Is: " << getpid() << std::endl;

    for(int i = 0; i < argc; i++)
    {
        printf("argv[%d]: %s\n", i, argv[i]);
    }

    printf("\n");

    for(int i = 0; env[i]; i++)
    {
        printf("env[%d]: %s\n", i, env[i]);
    }

    return 0;
}

程序运行结果如下:

又如代码中所示,我们在程序替换的时候需要传入命令行参数、命令行参数表以及环境变量表,从这里我们终于知道了,Bash 是如何将命令行参数表和环境变量表传给子进程的,实际上就是父进程通过调用exec类函数,将命令行参数表和环境变量表传给对应的子进程。

以上代码中还有一个接口学要介绍一下名为putenv()起作用就是将新的环境变量导入到调用这个函数的进程中

4.4 fork + exec 经典模式

为什么 Shell 执行命令时一定是 fork 然后再 exec,而不是直接 exec?如果直接 exec,Shell 进程本身就会被 ls 替换掉,执行完 ls 退出了,Shell 也就没了。所以必须先 fork 一个子进程,让子进程去 exec,父进程(Shell)继续等待下一条命令。

这就是 Linux 进程控制中最经典的组合:fork 创建新进程,exec 赋予新程序,wait 回收资源。三者配合,构成了 Shell 执行命令的完整流程。


5. 实战:手写一个微型 Shell

5.1 Shell 的工作原理

考虑下面这个与 Shell 典型的互动:

复制代码
[tys@localhost epoll]$ ls
client.cpp  readme.md  server.cpp  utility.h
[tys@localhost epoll]$ ps
  PID TTY          TIME CMD
 3451 pts/0    00:00:00 bash
 3514 pts/0    00:00:00 ps

Shell 的工作流程用时间轴来表示就是:Shell 从用户读入字符串 "ls",然后建立一个新的子进程,在子进程中运行 ls 程序,同时 Shell 自己等待那个进程结束。然后 Shell 读取新的一行输入,再建立新的子进程,运行 ps 程序,再等待它结束。

于是,写一个 Shell 就是循环执行以下过程:

  1. 获取命令行输入
  2. 解析命令行(分割为命令名和参数)
  3. 创建子进程(fork)
  4. 子进程替换程序(execvp)
  5. 父进程等待子进程退出(wait)

5.2 完整实现代码

下面是一个完整的微型 Shell 实现,支持外部命令执行、内建命令(cd、export、env、echo $?)、命令行提示符:

c 复制代码
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#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;
const int envnum = 64;

// 全局的命令行参数表
char *gargv[argvnum];
int gargc = 0;

// 全局变量:记录上一条命令的退出码
int lastcode = 0;

// 我的系统的环境变量
char *genv[envnum];

// 全局的当前 shell 工作路径
char pwd[basesize];
char pwdenv[basesize];

// 跳过空格宏
#define TrimSpace(pos) do {                     \
    while (isspace(*pos)) {                     \
        pos++;                                  \
    }                                           \
} while (0)

// 获取用户名
string GetUserName()
{
    string name = getenv("USER");
    return name.empty() ? "None" : name;
}

// 获取主机名
string GetHostName()
{
    string hostname = getenv("HOSTNAME");
    return hostname.empty() ? "None" : hostname;
}

// 获取当前工作目录
string GetPwd()
{
    if (nullptr == getcwd(pwd, sizeof(pwd)))
        return "None";
    // 同步更新环境变量 PWD
    snprintf(pwdenv, sizeof(pwdenv), "PWD=%s", pwd);
    putenv(pwdenv);
    return pwd;
}

// 获取当前路径的最后一级目录名
string LastDir()
{
    string curr = GetPwd();
    if (curr == "/" || curr == "None")
        return curr;
    // /home/tys/XXX → XXX
    size_t pos = curr.rfind("/");
    if (pos == std::string::npos)
        return curr;
    return curr.substr(pos + 1);
}

// 构造命令行提示符:[tys@localhost myshell]#
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;
}

// 1. 打印命令行提示符
void PrintCommandLine()
{
    printf("%s", MakeCommandLine().c_str());
    fflush(stdout);  // 确保提示符立即显示
}

// 2. 获取用户输入的命令行
bool GetCommandLine(char command_buffer[], int size)
{
    // fgets 读取一行,包含换行符
    char *result = fgets(command_buffer, size, stdin);
    if (!result) {
        return false;
    }
    // 去掉末尾的换行符
    command_buffer[strlen(command_buffer) - 1] = 0;
    if (strlen(command_buffer) == 0)
        return false;
    return true;
}

// 3. 解析命令行:用空格分割,存入 gargv 数组
void ParseCommandLine(char command_buffer[], int len)
{
    (void)len;
    memset(gargv, 0, sizeof(gargv));
    gargc = 0;

    // "ls -a -l -n" → gargv[0]="ls", gargv[1]="-a", ...
    const char *sep = " ";
    gargv[gargc++] = strtok(command_buffer, sep);
    // 注意这里是 = 赋值,不是 == 比较
    while ((bool)(gargv[gargc++] = strtok(nullptr, sep)));
    gargc--;  // 回退多出来的一个 NULL
}

// 4. 执行外部命令:fork 子进程,子进程 execvp,父进程 waitpid
bool ExecuteCommand()
{
    pid_t id = fork();
    if (id < 0)
        return false;
    if (id == 0) {
        // 子进程:加载并执行新程序
        execvpe(gargv[0], gargv, genv);
        // execvp 成功不会返回,走到这里说明执行失败
        exit(1);
    }

    // 父进程:等待子进程结束
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0) {
        if (WIFEXITED(status)) {
            lastcode = WEXITSTATUS(status);  // 记录退出码,供 echo $? 使用
        } else {
            lastcode = 100;  // 异常退出
        }
        return true;
    }
    return false;
}

// 添加环境变量到 genv 数组
void AddEnv(const char *item)
{
    int index = 0;
    while (genv[index]) {
        index++;
    }
    genv[index] = (char *)malloc(strlen(item) + 1);
    strncpy(genv[index], item, strlen(item) + 1);
    genv[++index] = nullptr;
}

// 检查和执行内建命令:由 shell 自己执行,不 fork 子进程
bool CheckAndExecBuiltCommand()
{
    // cd 命令:改变当前工作目录
    if (strcmp(gargv[0], "cd") == 0) {
        if (gargc == 2) {
            chdir(gargv[1]);
            lastcode = 0;
        } else {
            lastcode = 1;
        }
        return true;
    }
    // export 命令:添加环境变量
    else if (strcmp(gargv[0], "export") == 0) {
        if (gargc == 2) {
            AddEnv(gargv[1]);
            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) {
            if (gargv[1][0] == '$') {
                if (gargv[1][1] == '?') {
                    printf("%d\n", lastcode);
                    lastcode = 0;
                }
            } else {
                printf("%s\n", gargv[1]);
                lastcode = 0;
            }
        } else {
            lastcode = 3;
        }
        return true;
    }
    return false;  // 不是内建命令
}

// 初始化环境变量:从父 shell 继承
void InitEnv()
{
    extern char **environ;
    int index = 0;
    while (environ[index]) {
        genv[index] = (char *)malloc(strlen(environ[index]) + 1);
        strncpy(genv[index], environ[index], strlen(environ[index]) + 1);
        index++;
    }
    genv[index] = nullptr;
}

int main()
{
    InitEnv();
    char command_buffer[basesize];

    while (true) {
        // 1. 打印提示符
        PrintCommandLine();

        // 2. 获取用户输入
        if (!GetCommandLine(command_buffer, basesize)) {
            continue;
        }

        // 3. 解析命令行
        ParseCommandLine(command_buffer, strlen(command_buffer));

        // 4. 先检查是否是内建命令
        if (CheckAndExecBuiltCommand()) {
            continue;
        }

        // 5. 不是内建命令,fork + exec 执行外部命令
        ExecuteCommand();
    }
    return 0;
}

5.3 代码走读

这个微型 Shell 虽然只有 200 多行,但完整实现了 Shell 的核心逻辑。来看看几个关键设计:

命令行提示符MakeCommandLine() 构造了 [tys@localhost myshell]# 这样的提示符,通过 GetUserName()GetHostName()LastDir() 动态获取用户名、主机名和当前目录。

命令行解析ParseCommandLine()strtok 按空格分割命令行字符串,存入全局的 gargv 数组。注意 while 条件里的 = 是刻意写的赋值语句,不是比较 --- strtok 返回 NULL 时赋值结果转 bool 为 false,循环自然结束。

内建命令 vs 外部命令 :cd 之所以不能由子进程执行,是因为它要改变的是 Shell 自身的工作目录。如果 fork 子进程去 chdir,改变的只是子进程的目录,Shell 自己的目录不变。同样,export 添加环境变量、env 显示环境变量,都需要在 Shell 进程中直接操作。所以 CheckAndExecBuiltCommand() 在 fork 之前就拦截了这些命令。

环境变量继承InitEnv() 通过 extern char **environ 获取父进程(真实 Shell)的环境变量,拷贝到自己的 genv 数组中。子进程 execvp 时通过 genv 传递环境变量。

exit 码跟踪lastcode 记录了最后一条命令的退出码,echo $? 时输出。如果命令异常退出(被信号杀死),lastcode 设为 100 作为统一标记。


6. 延伸:exec/exit 与 call/return 的类比

在继续学习新知识之前,我们来思考一个有趣的类比 --- 函数和进程之间的相似性。

一个 C 程序由很多函数组成。一个函数调用另一个函数,传递参数,被调用的函数执行操作,然后返回一个值。每个函数有自己私有的局部变量,不同的函数通过 call/return 机制进行通信。这种通过参数和返回值在拥有私有数据的函数间通信的模式,是结构化程序设计的基石。

Linux 把这种思想扩展到了进程之间:

函数调用 进程控制
call 调用函数 fork + exec 创建并执行新程序
传参 通过 argv / envp 传递参数
函数执行 进程执行
return 返回值 exit(n) 退出并返回状态码
调用者获取返回值 wait(&status) 获取退出码

一个 C 程序可以 fork/exec 另一个程序,传给它参数。这个被调用的程序执行操作,然后通过 exit(n) 返回值。调用它的进程通过 wait(&status) 获取 exit 的返回值。

这其实也是为什么 Linux 系统编程的学习路径如此自然 --- 本质上,你只是在用进程间通信的思维重演你早已熟悉的函数调用模式。


总结

回到文章开头提出的核心理念:Linux 进程控制的四个环节 --- 创建、执行、等待、终止 --- 构成了一个完整的闭环。

  • fork 创建:唯一创建新进程的方式,通过写时拷贝实现高效的父子分离
  • exec 替换:不创建新进程,而是赋予进程全新的代码和数据
  • wait 回收:父进程获取子进程退出状态,防止僵尸进程,回收系统资源
  • exit 退出:释放资源,返回退出码,通知父进程

这四个环节通过 fork+exec → wait → exit 的经典组合,完成了进程从生到死的完整生命周期。而写时拷贝作为贯穿始终的设计哲学,在 fork 阶段以"延迟复制"的方式保证了效率,在有 exec 的场景下价值尤为突出。

最后,手写 Shell 是检验进程控制知识的最佳实践。当你把 fork、exec、wait 组合成一个可交互的命令行解释器时,你对进程控制的理解会从"知道"变成"会用"。下一步,你可以在这基础上继续深入:信号处理、进程间通信(管道、共享内存、消息队列)、多线程编程。每个主题都会让你对 Linux 的理解更上一层楼。

相关推荐
雨辰AI1 小时前
openGauss 生产运维避坑指南|适配信创项目改造核心难点
java·运维·后端
小波波啊1 小时前
nginx 转发超时故障排查实录
运维·nginx
2401_865382501 小时前
信息化建设项目申报建设费时运维费能否一起报送?
运维
九硕智慧建筑一体化厂家1 小时前
大型建筑集群智慧管控升级!IBMS系统实现多系统一体化融合管理
运维·人工智能·笔记·智慧城市
HAHAXX81 小时前
低代码 & 无代码 RPA 项目实施:对接大模型接口的部署与运维要点
运维·低代码·rpa
啦啦啦啦啦zzzz2 小时前
时间轮定时器
linux·服务器·网络·c++·定时器
ITyunwei09872 小时前
从 ITIL 视角量化-第三集:工单系统如何成为 ITSM 治理抓手?架构+量化
运维·网络·企业微信
paopao_djshddhdj2 小时前
钉钉培训系统功能与收费详解:企业数字化培训的优选方案
运维·钉钉
神威难绷泪2 小时前
Linux应用软件编程:目录IO framebuffer
linux