一、 进程基础:从 PCB 到内存分布
1. 什么是进程?
进程是程序执行的过程。它不仅是硬盘上的二进制文件(代码),还需要 CPU 调度、内存资源、文件系统等配合。
Linux 内核通过 PCB(进程控制块,task_struct 结构体) 来管理进程。PCB 中记录了:PID、父进程 PID、当前工作路径、打开的文件描述符列表、信号处理、资源上限等核心信息。
2. 进程的内存分布
当我们运行一个可执行文件时,操作系统会为其分配 0~3G 的用户空间(内核空间占 3~4G):
-
代码段 (Code):只读,存放机器指令。
-
数据段 (Data/BSS):存放全局变量、静态变量。
-
堆区 (Heap):由程序员 malloc/free 管理,向高地址生长。
-
共享库映射区:存放 C 库函数等动态链接库。
-
栈区 (Stack):存放局部变量,默认 8MB,向低地址生长。
3. 进程的状态切换与 8 种退出方式
经典的进程状态包括:就绪态、运行态、阻塞态。在 Linux 内核中,还有 僵尸态 (Zombie) 和 暂停态 (Stopped)。
进程退出的 8 种情况:
main 函数中 return。
调用 exit()(C 库函数,会刷新 I/O 缓冲区,关闭流)。
调用 _exit() 或 _Exit()(系统调用,直接退出,不刷新缓冲区)。
主线程退出。
主线程调用 pthread_exit。
异常终止:调用 abort()。
接收到致命信号(如 kill -9)。
最后一个线程被 pthread_cancel。
二、 进程的创建:fork() 魔法与坑点
c
#include <unistd.h>
pid_t fork(void);
fork() 会克隆出一个子进程。它有一个神奇的特性:调用一次,返回两次。
-
在父进程中返回子进程的 PID(> 0)。
-
在子进程中返回 0。
-
失败返回 -1。
1. 底层机制:写时复制 (Copy-on-Write)
fork 刚创建时,父子进程的用户空间是完全共享的(只读)。一旦父进程或子进程尝试修改内存数据,内核会触发缺页异常,此时才会为修改方真正分配物理内存页。这极大提升了 fork 的效率。
代码实战:验证写时复制
c
#include <stdio.h>
#include <unistd.h>
int a = 10;
int main() {
pid_t pid = fork();
if (pid > 0) {
sleep(3);
printf("父进程: a = %d\\n", a); // 依然是 10
} else if (pid == 0) {
a = 50; // 触发写时复制,子进程开辟新内存页
printf("子进程: a = %d\\n", a);
}
return 0;
}
2. 致命坑点与隐藏大坑
-
Fork 炸弹:如果在 for 循环里 fork,子进程创建后必须 exit() 或 break。否则子进程也会继续循环去 fork,导致进程数呈指数级爆炸。
-
文件共享坑:如果在 fork 之前打开了文件,父子进程会共享同一个文件表项(包括文件偏移量)。一方读取后,另一方的偏移量也会跟着变。
-
缓冲区重复打印坑:如果在 fork 之前 printf 且没加 \\n(或者输出重定向到了文件),数据会滞留在父进程的标准 I/O 缓冲区里。fork 时子进程会复制这个缓冲区,最终导致父子进程各打印一次,数据翻倍。
三、 进程回收:wait() 与 waitpid() 深度剖析
子进程退出后,用户空间被释放,但内核里的 PCB 没有被销毁,变成僵尸进程。父进程必须对其进行回收。
1. wait() 函数底层原理与状态位图
c
#include <sys/wait.h>
pid_t wait(int *wstatus);
(1)状态字 wstatus 的二进制奥秘
wstatus 是一个 4 字节的 int,但 Linux 只使用了它的低 16 位。
-
高 8 位:存放正常退出时的退出码(如 exit(20) 中的 20)。
-
低 8 位:如果被信号杀死的,低 7 位存放信号编号,最低位是 core dump 标志(表示是否生成了核心转储文件)。
千万不能直接判断 wstatus == 20!因为退出码在高位,实际数值可能是 20 << 8 = 5120。必须使用系统宏:
-
WIFEXITED(wstatus):判断是否正常退出。
-
WEXITSTATUS(wstatus):获取正常退出的退出码。
-
WIFSIGNALED(wstatus):判断是否被信号异常终止。
-
WTERMSIG(wstatus):获取终止信号编号。
-
WCOREDUMP(wstatus):判断是否生成了 core 文件。
(2)被信号中断的坑(EINTR)
父进程在 wait 阻塞时,如果收到如 SIGALRM 等中断信号,wait 会提前返回 -1,且 errno = EINTR。此时子进程未结束,必须重新调用。
c
int status;
pid_t ret;
do {
ret = wait(&status);
} while (ret == -1 && errno == EINTR); // 被中断就重试
实战:阻塞等待与状态解析
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
void print_wait_status(int status) {
if (WIFEXITED(status)) {
printf("子进程正常退出,退出码: %d\\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("子进程被信号杀死,信号编号: %d\\n", WTERMSIG(status));
if (WCOREDUMP(status)) printf(" [注意] 进程死前生成了 core 文件\\n");
}
}
2. waitpid() 高级控制与作业控制
c
pid_t waitpid(pid_t pid, int *wstatus, int options);
waitpid 是 wait 的超集,支持精准定位和非阻塞。
(1)参数 pid:
-
pid > 0:精准等待指定的子进程。
-
pid == -1:等待任意子进程(等价于 wait)。
-
pid == 0:等待同进程组的任意子进程。
-
pid < -1:等待进程组 ID 等于 |pid| 的任意子进程。
(2)参数 options 与返回值:
-
0:默认阻塞。
-
WNOHANG:非阻塞模式。子进程还在跑则立即返回 0,绝不阻塞父进程。
-
WUNTRACED:如果子进程被暂停(如 SIGSTOP),也立即返回状态。
-
返回值:成功 >0,未结束 0(仅非阻塞),错误 -1。
实战 1:非阻塞轮询与防 CPU 空转
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t ret[5] = {0};
for (int i = 0; i < 5; i++) {
ret[i] = fork();
if (ret[i] == 0) {
sleep(rand() % 5 + 1); // 随机睡 1-5 秒
exit(0);
}
}
// 非阻塞轮询回收第二个子进程
while (1) {
pid_t recycle_pid = waitpid(ret[1], NULL, WNOHANG);
if (recycle_pid == ret[1]) {
printf("成功回收第二子进程 wait pid :%d\\n", recycle_pid);
break;
} else if (0 == recycle_pid) {
usleep(100000); // 防 CPU 空转,睡 100ms 再试
} else {
break;
}
}
return 0;
}
实战 2:利用 WUNTRACED 实现作业控制(Job Control)
当你在终端按下 Ctrl+Z,内核向进程发送 SIGTSTP 信号,进程进入 T (Stopped) 状态。父进程使用 WUNTRACED 就能捕捉到这个暂停事件。
c
// 子进程死循环,父进程 2秒后发 SIGSTOP 暂停它,再发 SIGCONT 恢复
if (fork() == 0) { while(1) sleep(1); }
sleep(2); kill(pid, SIGSTOP);
// 捕捉暂停
int status;
waitpid(pid, &status, WUNTRACED);
if (WIFSTOPPED(status)) printf("捕捉到暂停信号:%d\\n", WSTOPSIG(status));
实战 3:终极组合 SIGCHLD 信号 + 异步回收
不要让父进程傻傻阻塞在 wait 里。子进程状态改变时会给父进程发 SIGCHLD 信号,在处理函数里回收:
c
void sigchld_handler(int sig) {
int status;
pid_t pid;
// 必须用 while + WNOHANG 回收所有死去的子进程,防止信号丢失导致僵尸
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
printf("[异步] 回收子进程 PID: %d\\n", pid);
}
}
// 在 main 中注册:
// struct sigaction sa; sa.sa_handler = sigchld_handler; sa.sa_flags = SA_RESTART;
// sigaction(SIGCHLD, &sa, NULL);
四、 进程替换:exec 函数族与内存夺舍
如果说 fork 是“生娃”,exec 就是“夺舍”。调用 exec 后,当前进程的代码段、数据段、堆栈会被新程序完全覆盖,PID 保持不变。一旦成功,exec 后面的原代码永远不会执行。
1. 内存布局的变迁与保留
exec 执行成功时,内核会重建进程的内存。但以下属性被保留:
-
PID、PPID、进程组 ID。
-
当前工作目录。
-
打开的文件描述符(fd)(除非设置了 FD_CLOEXEC)。
-
信号掩码(但自定义信号处理函数重置为默认)。
2. 函数族与命名规则
| l (list) | 列表 | 参数逐个列出,必须以 NULL 结尾。 |
| v (vector) | 数组 | 参数打包进 char *argv[]。 |
| p (PATH) | 路径 | 自动去 $PATH 中查找,无需绝对路径。 |
| e (env) | 环境变量 | 显式传入自定义环境变量数组,而不是继承。 |
代码实战对比
c
// 1. execl: 全路径 + 列举
execl("/bin/ls", "ls", "-a", "-l", NULL);
// 2. execlp: PATH 查找 + 列举
execlp("ls", "ls", "-a", "-l", NULL);
// 3. execvp: PATH 查找 + 数组(最常用)
char *arg[] = {"ls", "-a", "-l", NULL};
execvp("ls", arg);
// 4. execle: 列表传参 + 自定义环境变量
char *my_env[] = {"PATH=/bin:/usr/bin", "MY_VAR=hello", NULL};
execle("/usr/bin/env", "env", NULL, my_env);
3. 必须警惕的夺舍坑点
-
坑点 1:成功不返回,失败必须处理
如果失败返回 -1,如果不处理,子进程会继续执行父进程原本的代码逻辑。c
if (fork() == 0) {
execlp("firefox", "firefox", NULL);
perror("启动失败"); // 失败才会走到这
exit(1); // 必须退出
} -
坑点 2:C标准库缓冲区的丢数据问题
如果在 exec 之前有 printf,且数据滞留在缓冲区(被重定向到文件时)。exec 把内存清空,数据直接丢失。
解决:在调用 exec 之前,显式刷新缓冲区:fflush(stdout); -
坑点 3:FD 泄漏与 FD_CLOEXEC
如果不关闭不相关的文件描述符,exec 后会泄漏给新程序。在 open 时加 O_CLOEXEC 标志,内核会自动在 exec 时帮你关闭该 FD。 -
坑点 4:为什么不能用 exec 执行 cd?
cd 是 Shell 内置命令。且在子进程中 cd 不影响父进程路径。改变目录必须用父进程调用 chdir()。
五、 终极封装:system 函数与返回值解包
system 封装了 fork + exec(/bin/sh -c) + waitpid,它会启动子进程调用 Shell 执行字符串。
c
int ret = system("ls -l no_such_file.txt");
返回值解包(非常重要):
如果 Shell 找不到命令,退出码通常是 127;如果能找到但无权限执行,通常是 126。
c
if (ret == -1) {
perror("system 创建子进程失败");
} else if (WIFEXITED(ret)) {
int exit_status = WEXITSTATUS(ret);
if (exit_status == 127) printf("系统找不到指定的命令\\n");
else if (exit_status == 0) printf("命令执行成功\\n");
else printf("命令异常退出,码:%d\\n", exit_status);
}
缺点:缺乏精细控制。如果是写高级程序,建议老老实实用 fork + execvp。
六、 终极实战:手写一个迷你 Shell
结合我们学过的所有知识:fork 生娃,execvp 替换,waitpid 收尸,再加上内置命令拦截。
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
#define MAX_ARGS 64
int main() {
char line[1024];
char *args[MAX_ARGS];
while (1) {
printf("mini-shell> ");
if (fgets(line, sizeof(line), stdin) == NULL) break;
line[strcspn(line, "\\n")] = 0; // 去除换行
if (strlen(line) == 0) continue;
// 解析参数
int argc = 0;
char *token = strtok(line, " ");
while (token != NULL && argc < MAX_ARGS – 1) {
args[argc++] = token;
token = strtok(NULL, " ");
}
args[argc] = NULL;
// 处理内置命令 (Built-in)
if (strcmp(args[0], "exit") == 0) break;
if (strcmp(args[0], "cd") == 0) {
// 必须由 Shell 父进程亲自切换
if (args[1] == NULL) chdir(getenv("HOME"));
else if (chdir(args[1]) != 0) perror("cd 失败");
continue; // 直接进入下一轮循环,不用 fork
}
// 外部命令:fork + exec
pid_t pid = fork();
if (pid == 0) {
// 子进程:替换为外部程序
execvp(args[0], args);
perror("执行失败");
exit(1); // exec 失败必须退出
} else if (pid > 0) {
// 父进程:等待子进程结束
waitpid(pid, NULL, 0);
} else {
perror("fork");
}
}
return 0;
}
总结
进程控制是 Linux 高级开发的基石。理解了状态字的 16 位位图,你就能看透进程的“死因报告”;掌握了 WNOHANG 与 SIGCHLD,你就掌握了异步并发控制的钥匙;吃透了 exec 与 system,你就真正理解了 Shell 甚至容器的底层运作方式。请务必将以上代码在你的 Linux 环境里亲手跑一遍,并结合 ps aux 观察状态切换!
网硕互联帮助中心





评论前必须登录!
注册