前言
进程控制是操作系统最核心的概念之一。你是否好奇过:当我们在终端输入一个命令,按下回车后,计算机内部到底发生了什么?Shell是如何创建新进程的?父进程和子进程之间又是什么关系?
本文将带你从理论到实践,彻底搞懂Linux进程控制的四大核心操作:进程创建、进程终止、进程等待、进程程序替换。最后,我们将手写一个迷你Shell,把所有知识点串起来。
一、进程创建:fork函数的奥秘
1.1 fork基础
在Linux中,fork是用于创建新进程的系统调用。新创建的进程称为子进程 ,原来的进程称为父进程
cpp
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
return 1;
} else if (pid == 0) {
// 子进程
printf("我是子进程,PID: %d,父进程PID: %d\n", getpid(), getppid());
} else {
// 父进程
printf("我是父进程,PID: %d,子进程PID: %d\n", getpid(), pid);
}
return 0;
}
运行结果:
cpp
我是父进程,PID: 43676,子进程PID: 43677
我是子进程,PID: 43677,父进程PID: 43676
1.2 fork的奇妙之处
fork函数有一个非常反直觉的特性:调用一次,返回两次。
-
在父进程中,
fork返回子进程的PID(正整数) -
在子进程中,
fork返回0 -
如果出错,返回-1
为什么要这样设计?
pid_t pid = fork();
if (pid == 0) {
// 子进程执行的代码
exec(...);
} else {
// 父进程执行的代码
wait(pid);
}
这种设计让父子进程可以通过返回值轻松区分自己,从而执行不同的代码逻辑。
1.3 写时拷贝技术
fork后,父子进程共享 同一份代码和数据(这是Linux的优化)。只有当某一方试图写入数据时,系统才会真正拷贝一份副本。
cpp
#include <stdio.h>
#include <unistd.h>
int global_var = 100;
int main() {
pid_t pid = fork();
if (pid == 0) {
global_var = 200; // 子进程修改,触发写时拷贝
printf("子进程: global_var = %d\n", global_var);
} else {
sleep(1);
printf("父进程: global_var = %d\n", global_var);
}
return 0;
}
运行结果:
子进程: global_var = 200
父进程: global_var = 100 // 父进程不受影响
写时拷贝的好处:
-
节省内存资源
-
提高fork执行效率
-
保证进程间的独立性
二、进程终止:进程的生命终点
2.1 进程退出的三种场景
-
代码运行完毕,结果正确 → 退出码0
-
代码运行完毕,结果错误 → 退出码非0
-
代码异常终止 → 被信号杀死(如段错误、Ctrl+C)
2.2 三种退出方式
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
// 方式1: main函数return
return 0;
// 方式2: exit() - 会执行清理工作
exit(0);
// 方式3: _exit() - 直接退出,不做清理
_exit(0);
}
2.3 exit和_exit的区别
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
printf("Hello, World!"); // 没有换行,数据还在缓冲区
exit(0); // 会刷新缓冲区,输出 "Hello, World!"
// _exit(0); // 不会刷新缓冲区,不输出任何内容
}
运行对比:
-
使用
exit(0):会输出"Hello, World!" -
使用
_exit(0):不会输出任何内容
2.4 退出码的意义
cpp
#include <stdio.h>
#include <string.h>
int main() {
// 查看退出码对应的含义
for (int i = 0; i <= 10; i++) {
printf("退出码 %d: %s\n", i, strerror(i));
}
return 0;
}

常见退出码:
| 退出码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 一般错误 |
| 2 | 命令使用不当 |
| 127 | 命令未找到 |
| 130 | Ctrl+C终止 |
三、进程等待:避免僵尸进程
3.1 为什么要等待子进程?
子进程退出后,会变成僵尸进程:
-
占用内核资源
-
无法被kill -9杀死
-
如果不回收,会导致内存泄漏
父进程通过wait或waitpid来:
-
回收子进程资源
-
获取子进程的退出状态
3.2 wait和waitpid的使用
函数原型:
cpp
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
区别:
| 函数 | 特点 |
|---|---|
| wait() | 阻塞等待任意一个子进程退出 |
| waitpid() | 可指定 PID 、可非阻塞、功能更强 |
int *status 一句话讲透
status 是用来存放「子进程退出状态信息」的 它是输出型参数,函数内部把子进程死活信息塞进这个变量里。
本质:
int status;
wait(&status); // 传地址进去
- 你定义一个普通整型变量
status - 把地址传给 wait /waitpid
- 系统自动把子进程退出信息写入这个变量
status 里面存了啥(二进制低 16 位)
高8位:正常退出码 exit(code)
低7位:杀死进程的信号值
第8位:core dump标志
- 怎么取出有用数据(只用系统宏,别手算)
cpp
// 1. 判断是否正常退出
WIFEXITED(status) 成立 = 正常退出
// 取出正常退出码
WEXITSTATUS(status)
// 2. 判断是否被信号杀死
WIFSIGNALED(status) 成立 = 异常死亡
// 取出杀死它的信号编号
WTERMSIG(status)
三种常用场景
-
子进程
exit(5)退出- WIFEXITED = 真
- WEXITSTATUS=5
-
子进程被
kill杀掉- WIFSIGNALED = 真
- WTERMSIG = 对应信号值
-
不需要获取退出信息直接传 NULL
wait(NULL);
waitpid(-1, NULL, 0);
wait /waitpid 返回值
一、wait () 返回值
只有 2 种情况
- > 0 → 成功,返回已退出子进程的 PID
- -1 → 失败(没有子进程、调用出错)永远不会返回 0
二、waitpid () 返回值(重点)
3 种情况
- > 0 → 成功,返回已退出子进程的 PID
- = 0 → 子进程还活着,未退出(只有 WNOHANG 非阻塞才会出现)
- -1 → 失败(无子进程、参数错误)
1. wait () 使用
功能
父进程阻塞 ,直到任意子进程退出。
代码示例
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("子进程开始执行,PID: %d\n", getpid());
sleep(3);
printf("子进程即将退出\n");
exit(42); // 返回退出码42
} else {
// 父进程
int status;
pid_t ret = wait(&status); // 阻塞等待
if (ret > 0 && (status & 0x7F) == 0) {
// 正常退出
printf("子进程正常退出,退出码: %d\n", (status >> 8) & 0xFF);
} else if (ret > 0) {
// 异常退出
printf("子进程异常退出,终止信号: %d\n", status & 0x7F);
}
}
return 0;
}

2. waitpid () 使用(重点)
3 个参数
-
pid
-1:等待任意子进程(等价 wait)>0:等待指定 PID的子进程
-
status:存储退出信息
-
options
0:阻塞等待WNOHANG:非阻塞等待(不卡住父进程)
示例 1:等价 wait ()
waitpid(-1, &status, 0);
示例 2:等待指定子进程
waitpid(pid, &status, 0);
示例 3:非阻塞等待(超级常用)
父进程不会卡住,可以一边做自己的事,一边检查子进程。
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
sleep(5);
exit(0);
} else {
int status;
pid_t ret;
// 非阻塞轮询
while ((ret = waitpid(pid, &status, WNOHANG)) == 0) {
printf("子进程还在运行,我可以做其他事情...\n");
sleep(1);
}
printf("子进程已退出\n");
}
return 0;
}
四、进程程序替换:让子进程"变身"
4.1 什么是程序替换?
fork创建的子进程默认执行父进程的代码。如果我们想让子进程执行一个全新的程序 (比如ls命令),就需要用到exec系列函数。
核心原理:
-
加载新程序到内存
-
替换当前进程的代码段和数据段
-
进程ID不变
4.2 exec函数族详解
cpp
#include <unistd.h>
// 参数使用列表
int execl(const char *path, const char *arg, ...);
// 参数使用数组
int execv(const char *path, char *const argv[]);
// 自动搜索PATH
int execlp(const char *file, const char *arg, ...);
// 带环境变量
int execle(const char *path, const char *arg, ..., char *const envp[]);
命名规律:
-
l(list):参数是列表形式 -
v(vector):参数是数组形式 -
p(path):自动搜索环境变量PATH -
e(env):可以传递环境变量
4.3 exec使用示例
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:执行ls命令
// 方式1: execl
execl("/bin/ls", "ls", "-l", "-a", NULL);
// 方式2: execlp(自动搜索PATH)
// execlp("ls", "ls", "-l", "-a", NULL);
// 方式3: execv
// char *argv[] = {"ls", "-l", "-a", NULL};
// execv("/bin/ls", argv);
// 如果exec成功,下面代码不会执行
perror("exec failed");
exit(1);
} else {
wait(NULL);
printf("子进程执行完毕\n");
}
return 0;
}
4.4 execve:真正的系统调用
其他5个exec函数最终都调用execve:
cpp
#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);
五、综合实战:手写一个迷你Shell
现在,我们把前面学到的所有知识综合起来,实现一个真正的命令行解释器!
5.1 Shell的工作原理
Shell的本质是一个无限循环:
-
显示提示符
-
读取用户输入
-
解析命令
-
创建子进程执行命令
-
等待子进程结束
-
回到步骤1
5.2 完整代码实现
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#define MAX_CMD_LEN 1024
#define MAX_ARG_NUM 64
// 全局命令行参数
char *g_argv[MAX_ARG_NUM];
int g_argc = 0;
int last_exit_code = 0;
// 显示提示符
void print_prompt() {
char cwd[256];
getcwd(cwd, sizeof(cwd));
char *user = getenv("USER");
char *host = getenv("HOSTNAME");
printf("[%s@%s %s]$ ", user ? user : "user",
host ? host : "localhost",
cwd);
fflush(stdout);
}
// 读取命令
int read_command(char *buf, int size) {
char *ret = fgets(buf, size, stdin);
if (!ret) return 0;
// 去掉末尾的换行符
buf[strlen(buf) - 1] = '\0';
return strlen(buf) > 0;
}
// 解析命令
void parse_command(char *cmd) {
g_argc = 0;
char *token = strtok(cmd, " ");
while (token && g_argc < MAX_ARG_NUM - 1) {
g_argv[g_argc++] = token;
token = strtok(NULL, " ");
}
g_argv[g_argc] = NULL;
}
// 内建命令:cd
int builtin_cd() {
if (g_argc != 2) {
printf("Usage: cd <directory>\n");
return 1;
}
if (chdir(g_argv[1]) != 0) {
perror("cd failed");
return 1;
}
return 0;
}
// 内建命令:exit
int builtin_exit() {
printf("Goodbye!\n");
exit(0);
}
// 内建命令:echo
int builtin_echo() {
for (int i = 1; i < g_argc; i++) {
// 处理 $? 获取上次退出码
if (g_argv[i][0] == '$' && g_argv[i][1] == '?') {
printf("%d", last_exit_code);
} else {
printf("%s", g_argv[i]);
}
if (i < g_argc - 1) printf(" ");
}
printf("\n");
return 0;
}
// 检查并执行内建命令
int execute_builtin() {
if (strcmp(g_argv[0], "cd") == 0) {
last_exit_code = builtin_cd();
return 1;
}
if (strcmp(g_argv[0], "exit") == 0) {
builtin_exit();
return 1;
}
if (strcmp(g_argv[0], "echo") == 0) {
last_exit_code = builtin_echo();
return 1;
}
return 0;
}
// 执行外部命令
void execute_external() {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
last_exit_code = 1;
return;
}
if (pid == 0) {
// 子进程:执行命令
execvp(g_argv[0], g_argv);
// 如果exec失败
printf("%s: command not found\n", g_argv[0]);
exit(127);
} else {
// 父进程:等待子进程
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
last_exit_code = WEXITSTATUS(status);
} else {
last_exit_code = 128 + WTERMSIG(status);
}
}
}
int main() {
char cmd_line[MAX_CMD_LEN];
printf("=== 欢迎使用迷你Shell ===\n");
printf("支持内建命令: cd, exit, echo\n");
printf("支持外部命令: ls, ps, etc.\n\n");
while (1) {
print_prompt();
if (!read_command(cmd_line, sizeof(cmd_line))) {
printf("\n");
continue;
}
if (strlen(cmd_line) == 0) continue;
parse_command(cmd_line);
// 先检查是否是内建命令
if (!execute_builtin()) {
// 否则作为外部命令执行
execute_external();
}
}
return 0;
}
5.4 代码核心解析
-
主循环:无限循环处理用户命令
-
命令解析 :使用
strtok分割命令字符串 -
内建命令 :
cd、exit、echo由Shell自己处理 -
外部命令 :
fork创建子进程,execvp执行命令 -
退出码 :通过
waitpid获取子进程退出状态