1. 前言
进程是 Linux 系统编程的第一个核心概念,shell 执行命令、后台服务运行等日常操作底层都建立在进程机制之上。本章节主要目标:
- 理解程序与进程的区别,掌握 fork 创建进程的用法及写时拷贝机制。
- 理解僵尸进程的产生原因与危害,掌握 wait/waitpid 回收子进程的方法。
- 掌握进程退出(exit/_exit)与程序替换(exec 族)的原理。
- 理解 shell 执行命令的底层流程,并动手实现一个简易 shell。
2. 程序与进程
程序是磁盘上的一个静态文件,例如编译生成的 a.out,没有被加载运行时不会发生任何动作;进程是程序被加载到内存中运行之后的动态实体。
操作系统为了管理进程,会为每个进程维护一个PCB(进程控制块)。Linux 中的 PCB 是task_struct结构体,其中记录了进程编号(PID)、进程状态、父进程信息、打开的文件、虚拟地址空间映射等信息。
注意:PCB 存在则进程存在,PCB 被回收进程才真正消失。这一结论是后文理解僵尸进程的关键。
3. fork 系统调用
3.1 fork 的返回值
Linux 中创建新进程的系统调用是fork()。它的特点是调用一次,返回两次:fork 执行完成后,原进程分裂为父进程和子进程,两个进程都从 fork 的下一行继续执行,各自拿到一个返回值:
| 执行者 | fork 的返回值 | getpid() | getppid() |
|---|---|---|---|
| 父进程 | 子进程的 PID | 自身的 PID | 父进程的父进程 PID |
| 子进程 | 0 | 自身的 PID | 父进程的 PID |
内核为父子进程返回不同的值,目的是让同一段代码可以区分当前执行者的身份,从而进入不同的分支:
pid_t pid = fork(); if (pid == 0) { // 子进程执行的分支 } else if (pid > 0) { // 父进程执行的分支 }需要注意,代码中的变量pid只是接收 fork 返回值的普通变量;进程要获取自身的 PID,应使用getpid()。
3.2 实验:观察两个执行流
下面通过代码验证父子进程是两条独立的执行流:
// fork_demo.c #include <stdio.h> #include <unistd.h> #include <sys/types.h> int main() { pid_t id = fork(); if (id == 0) { while (1) { printf("我是子进程, pid=%d, 我的父进程=%d\n", getpid(), getppid()); sleep(1); } } else if (id > 0) { while (1) { printf("我是父进程, pid=%d, 我的子进程=%d\n", getpid(), id); sleep(1); } } return 0; }编译运行:
gcc fork_demo.c -o fork_demo ./fork_demo运行结果:
我是父进程, pid=1849, 我的子进程=1850 我是子进程, pid=1850, 我的父进程=1849 我是父进程, pid=1849, 我的子进程=1850 ...从结果可以观察到两个现象:
- fork 之前的代码只执行了一次,fork 之后的代码执行了两次,说明进程是在 fork 调用处发生分裂的。
- 两条 printf 的输出顺序不固定,说明父子进程是两条互相独立的执行流,调度先后由内核决定。
另开一个终端执行ps aux | grep fork_demo,可以看到两个同名进程同时存在。使用 Ctrl+C 结束时父子进程会一起退出,因为该信号会发送给前台进程组中的所有进程。
3.3 写时拷贝
fork 创建子进程时,子进程会获得父进程地址空间的副本,但 Linux 并不会真的把内存完整复制一份,而是采用**写时拷贝(Copy-On-Write)**机制:fork 之后父子进程先共享同一批物理内存页,只有当其中一方执行写操作时,内核才会把对应的页复制一份,此后双方各自独立。
经典验证现象:fork 之后父子各有一个全局变量g_val = 100,父进程将其修改为 200,子进程读到的仍然是 100,因为写入发生时对应的内存页已经被复制开了。
注意:面试中常问"fork 之后全局变量是否共享"。准确回答是:读操作时看起来共享(值相同),一旦有任意一方写入就会触发写时拷贝,从此各自独立。
4. 僵尸进程与进程等待
4.1 僵尸进程实验
先通过代码制造一个僵尸进程:
// zombie_demo.c #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() { pid_t id = fork(); if (id == 0) { printf("子进程 %d 跑 3 秒后退出\n", getpid()); sleep(3); exit(0); // 子进程退出 } sleep(30); // 父进程睡眠 30 秒,不回收子进程 return 0; }gcc zombie_demo.c -o zombie_demo ./zombie_demo & ps aux | grep zombie_demo子进程退出后,ps 中仍然可以看到它,状态为Z(zombie,僵尸)。此时对它执行kill -9 PID不会有任何效果,因为僵尸进程已经终止,没有代码可以执行,kill 无法杀死一个已经死亡的进程。
4.2 僵尸进程的原理与危害
进程退出时,代码和数据会被释放,但PCB 仍然保留在内核中。原因是子进程的退出信息(退出码、退出方式等)保存在 PCB 中,需要等待父进程读取:
- 父进程调用 wait 读取退出信息 → PCB 被释放,进程彻底消失
- 父进程一直不读取 → PCB 一直残留,形成僵尸进程
危害:PCB 占用内核内存,同时占用 PID 资源。如果系统中大量僵尸进程堆积,PID 耗尽后将无法创建新进程。
4.3 wait 与 waitpid
回收子进程的接口有两个:
#include <sys/types.h> #include <sys/wait.h> pid_t wait(int *status); // 阻塞等待任意一个子进程退出 pid_t waitpid(pid_t pid, int *status, int options); // 等待指定的子进程使用示例:
// wait_demo.c #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main() { pid_t id = fork(); if (id == 0) { sleep(3); exit(42); // 子进程带退出码 42 退出 } int status = 0; waitpid(id, &status, 0); // 父进程阻塞等待并回收子进程 if (WIFEXITED(status)) { printf("子进程正常退出,退出码=%d\n", WEXITSTATUS(status)); } return 0; }运行结果:
子进程正常退出,退出码=42此时再通过 ps 查看,僵尸进程已经不存在了。
4.4 status 宏
wait/waitpid 的status参数是一个位图,记录了子进程的退出方式,需要借助宏来解析:
| 宏 | 含义 |
|---|---|
WIFEXITED(status) | 子进程是否正常退出(exit 或 return) |
WEXITSTATUS(status) | 正常退出时,取出退出码 |
WIFSIGNALED(status) | 是否被信号杀死 |
WTERMSIG(status) | 被信号杀死时,取出信号编号 |
waitpid 的第三个参数传WNOHANG表示非阻塞轮询:没有等到立即返回 0,等到则返回子进程 PID,出错返回 -1。
4.5 孤儿进程
孤儿进程与僵尸进程相反:父进程先于子进程退出,子进程成为孤儿,其 PPID 会被修改为 1,由 init(1 号进程)收养。init 会定期回收退出的孤儿进程,因此孤儿进程没有危害。这也是守护进程(daemon)的实现思路。
5. 进程退出:exit 与 _exit
进程退出有两种常见方式,二者有明显区别:
// exit_demo.c #include <stdio.h> #include <stdlib.h> #include <unistd.h> int main() { printf("hello"); // 注意:没有换行符 _exit(42); // 换成 exit(42) 再运行一次对比 }- 使用
_exit(42)运行:屏幕上没有任何输出,echo $?输出 42 - 使用
exit(42)运行:屏幕上输出 hello
对比如下:
| exit | _exit | |
|---|---|---|
| 层级 | C 库函数 | 系统调用 |
| 退出前 | 刷新用户级缓冲区、执行清理动作 | 直接终止进程 |
main 函数中的return n等价于exit(n)。退出码通过echo $?查看,只有低 8 位有效,因此exit(-1)看到的值是 255。缓冲区的细节在下一篇基础 IO 中展开,这里先记住 exit 会刷新缓冲区。
6. 程序替换 exec
fork 创建的子进程与父进程运行同一份代码,实际场景中我们通常希望子进程去执行另一个程序,这就是 exec 族函数的功能。
6.1 三条核心性质
- PID 不变:exec 不创建新进程,只是用新程序的代码和数据完整替换当前进程的映像,PCB 保持原样。
- 成功不返回:替换成功后,原代码已经不存在,CPU 从新程序的入口开始执行。
- 失败返回 -1:只有替换失败才会继续执行 exec 之后的代码。
6.2 exec 家族
int execlp(const char *file, const char *arg, ...); int execvp(const char *file, char *const argv[]); int execve(const char *path, char *const argv[], char *const envp[]);命名规则:
l(list):参数逐个传递,以 NULL 结尾v(vector):参数放入数组传递p(path):自动在 PATH 环境变量中查找程序e(env):可以自定义传入环境变量表
注意:上述函数中只有 execve 是真正的系统调用,其余都是对它的库封装。
6.3 实验:观察程序替换
// exec_demo.c #include <stdio.h> #include <unistd.h> int main() { printf("变身前,我的 PID=%d\n", getpid()); execlp("ls", "ls", "-l", NULL); // 第一个 ls 是要运行的程序,第二个 ls 是 argv[0] printf("这行永远不会打印\n"); return 0; }运行结果:先打印一行 PID,然后直接输出ls -l的文件列表,"永远不会打印"一句没有输出,验证了"成功不返回"的性质。
7. shell 执行命令的原理
前面我们依次学习了三个核心能力:fork 创建子进程、exec 实现程序替换、wait/waitpid 回收子进程。把这三者组合起来,就是 bash 这类 shell 执行外部命令的底层流程:
输入 ls 回车后,bash 的执行流程: 1. fork() → 创建子进程(此时子进程运行的还是 bash 的代码副本) 2. 子进程 execlp("ls"…) → 代码被替换成 ls 程序 3. 父进程 waitpid(...) → bash 阻塞等待,命令执行完毕后回收并打印提示符7.1 实现一个简易 shell
根据上述流程,可以用二十行左右的代码实现一个简易 shell:
// mini_shell.c:支持执行不带参数的命令 #include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/wait.h> int main() { char cmd[256]; while (1) { printf("myshell> "); fflush(stdout); if (fgets(cmd, sizeof(cmd), stdin) == NULL) break; cmd[strlen(cmd) - 1] = 0; // 去掉 fgets 读入的换行符 if (strcmp(cmd, "exit") == 0) break; pid_t id = fork(); if (id == 0) { execlp(cmd, cmd, NULL); printf("%s: command not found\n", cmd); // exec 失败才会执行到这里 _exit(1); } wait(NULL); } return 0; }gcc mini_shell.c -o myshell ./myshell myshell> ls myshell> pwd myshell> exit可以看到,我们日常使用的 bash,其核心循环就是 fork + exec + wait 这一套流程。当然,真实的 bash 还要处理管道、重定向、环境变量等,这些在后续章节会逐一涉及。
8. 环境变量
8.1 PATH 与命令查找
在终端中输入ls可以直接运行,而自己编译的程序必须输入./a.out才能运行,原因是 bash 查找命令时并不是扫描整个磁盘,而是依次查找PATH 环境变量中登记的目录:
echo $PATH # /usr/local/bin:/usr/bin:/usr/sbin:... which ls # /usr/bin/ls系统命令都位于 PATH 登记的目录中,因此可以直接运行;当前目录默认不在 PATH 中,所以直接输入a.out会报 command not found,而./a.out给出了明确的路径,不需要查找 PATH。
注意:不把当前目录.加入 PATH 是出于安全考虑,防止误执行同名假冒命令。
8.2 本地变量与环境变量
MYENV=100 # 本地变量,仅当前 bash 进程可见 export MYENV=100 # 环境变量,会被子进程继承本地变量只在当前 shell 进程内有效,不会传给子进程;通过 export 登记到环境变量表之后,bash fork 出的所有子进程都能读取到该变量。
8.3 getenv
代码中获取环境变量使用getenv:
#include <stdio.h> #include <stdlib.h> // getenv 的头文件 int main() { char *my = getenv("MYENV"); if (my != NULL) printf("MYENV = %s\n", my); else printf("MYENV 不存在\n"); return 0; }找不到对应变量时返回 NULL。
9. 本章小结
本章的主线是进程的一生:fork 创建、exec 替换、exit 退出、wait 回收。核心知识点回顾:
- fork 调用一次返回两次,父进程得到子进程 PID,子进程得到 0;内存采用写时拷贝。
- 子进程退出而父进程未回收时形成僵尸进程,危害是占用内核内存与 PID,通过 wait/waitpid 解决。
- exit 退出前刷新用户级缓冲区,_exit 直接终止进程。
- exec 用新程序替换当前进程映像,PID 不变,成功不返回。
- shell 执行命令的核心流程是 fork + exec + wait。
下一篇介绍基础 IO:文件描述符 fd、重定向的原理与实现、用户级缓冲区。如果本篇文章有什么不足欢迎指正。