本文是Linux系统编程中进程控制模块的全面总结,涵盖进程创建、终止、等待、程序替换及Shell实现,包含大量代码示例、图解和面试常考点。无论你是初学者还是准备校招,这篇笔记都值得收藏。
📌 目录
- 进程创建
- fork函数详解
- 写时拷贝技术
- vfork补充
- 进程终止
- 退出场景与退出码
- exit vs _exit vs return
- 进程等待
- 为什么必须等待
- wait/waitpid完全解析
- status位图结构(重点)
- 阻塞与非阻塞等待
- 进程程序替换
- 替换原理
- exec族函数大全(6个+记忆口诀)
- 环境变量传递
- 实战:手写迷你Shell
- 整体框架
- 内建命令实现
- 完整代码
- 总结与易错点
1. 进程创建
1.1 fork函数初识
在Linux中,fork是用于创建新进程的核心系统调用。它从已存在的进程中复制出一个几乎完全相同的副本 :原进程称为父进程 ,新进程称为子进程。
c
#include <unistd.h>
pid_t fork(void);
// 返回值:子进程中返回0,父进程中返回子进程的PID,出错返回-1
经典代码示例:
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t pid;
printf("Before fork, my pid: %d\n", getpid());
pid = fork();
if (pid == -1) {
perror("fork");
exit(1);
}
if (pid == 0) {
// 子进程
printf("Child: pid = %d, fork() returned %d, my parent = %d\n",
getpid(), pid, getppid());
} else {
// 父进程
printf("Parent: pid = %d, fork() returned %d, child pid = %d\n",
getpid(), pid, pid);
}
printf("这句代码父子进程都会执行,pid=%d\n", getpid());
return 0;
}
运行结果(可能顺序不同):
Before fork, my pid: 1234
Parent: pid = 1234, fork() returned 1235, child pid = 1235
Child: pid = 1235, fork() returned 0, my parent = 1234
这句代码父子进程都会执行,pid=1234
这句代码父子进程都会执行,pid=1235
三个经典疑问解答:
| 问题 | 解答 |
|---|---|
| 为什么要给子进程返回0,父进程返回子进程PID? | 一个父进程可以有多个子进程,父进程需要知道每个子进程的ID以便管理;子进程只需知道自己是否为子进程,用0标识简单高效。 |
| 为什么fork会有两个返回值? | fork调用时,内核先复制PCB,然后两个进程都从fork函数返回。父进程返回子进程PID,子进程返回0。 |
| 为什么一个变量(pid)既能等于0又能大于0? | 父子进程有各自独立的地址空间,pid是各自栈上的变量,互不影响。父进程中pid=1235,子进程中pid=0。 |
1.2 写时拷贝(Copy-On-Write)
核心思想: fork之后,父子进程共享同一份代码和数据(只读)。只有当某一方试图修改数据时,内核才为修改方分配新的物理页面,复制原内容,这种"延迟复制"技术称为写时拷贝。
图示:

写时拷贝的优点:
- 避免不必要的复制,提高fork效率
- 最大程度节省物理内存
- 保证了进程的独立性------一个进程崩溃不会影响另一个
1.3 fork的常规用法与失败原因
常规用法:
- 并行处理:父进程等待请求,子进程处理具体任务(如Web服务器)
- 执行新程序:子进程fork后立即调用exec族函数执行另一个程序
fork失败原因:
- 系统进程数达到上限(
cat /proc/sys/kernel/pid_max查看) - 当前用户进程数超过限制(
ulimit -u)
1.4 vfork补充(面试常考)
vfork也是创建子进程,但有以下区别:
| 特性 | fork | vfork |
|---|---|---|
| 地址空间 | 写时拷贝,独立 | 共享父进程地址空间(不拷贝页表) |
| 执行顺序 | 父子进程调度器决定 | 子进程先运行,直到调用exit或exec,父进程才恢复 |
| 性能 | 较慢(但现代写时拷贝已优化) | 更快(适合立即exec的场景) |
| 现代用途 | 普遍使用 | 已不推荐,基本被fork替代 |
c
// vfork示例:子进程必须立即exec或exit
if (vfork() == 0) {
execl("/bin/ls", "ls", NULL);
exit(0); // 如果exec失败,必须exit
}
// 父进程在这里继续执行
2. 进程终止
2.1 退出场景
| 场景 | 说明 | 举例 |
|---|---|---|
| 代码运行完毕,结果正确 | 正常退出,退出码0 | return 0; |
| 代码运行完毕,结果错误 | 正常退出,退出码非0 | return 1;(如文件不存在) |
| 代码异常终止 | 被信号杀死 | 段错误(SIGSEGV)、Ctrl+C(SIGINT) |
2.2 常见退出方法
正常终止(3种):
c
int main() {
// 1. main函数return
return 0;
// 2. 调用exit() - 标准库函数
exit(0);
// 3. 调用_exit() - 系统调用
_exit(0);
}
异常终止:
Ctrl + C→ 发送SIGINTkill -9 pid→ 发送SIGKILL- 野指针/除零等 → 发送SIGSEGV/SIGFPE
2.3 exit vs _exit vs return ------ 核心区别
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
printf("Hello"); // 没有换行符,数据在缓冲区
exit(0); // 会刷新缓冲区,输出"Hello"
// _exit(0); // 不会刷新缓冲区,无输出
// return 0; // 同exit(0),也会刷新缓冲区
}
区别总结:
| 函数 | 类型 | 刷新缓冲区 | 执行清理函数(atexit) | 关闭流 |
|---|---|---|---|---|
return |
C语言关键字 | ✅ | ✅ | ✅ |
exit() |
标准库函数 | ✅ | ✅ | ✅ |
_exit() |
系统调用 | ❌ | ❌ | ❌ |
💡 注意 :只有
main函数中的return才表示进程退出。其他函数的return只是函数返回,不会终止进程。
2.4 退出码与echo $?
在Shell中执行程序后,echo $?可以查看上一个程序的退出状态。
bash
$ ./a.out
$ echo $?
0 # 正常退出
$ ls /notexist
ls: cannot access /notexist: No such file or directory
$ echo $?
2 # ls命令返回2表示错误
常用退出码含义表:
| 退出码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 通用错误(如权限不足、除零) |
| 2 | 命令使用不当(参数错误) |
| 126 | 命令不可执行(权限拒绝) |
| 127 | 命令未找到 |
| 128+n | 被信号n终止(如130=SIGINT, 137=SIGKILL) |
c
// 获取退出码对应的描述
#include <string.h>
printf("%s\n", strerror(1)); // 输出 "Operation not permitted"
3. 进程等待
3.1 为什么要等待子进程?(必考)
- 避免僵尸进程 :子进程退出后,如果父进程不调用
wait,子进程的PCB(进程控制块)无法释放,变成僵尸进程(Z),造成内存泄漏。 - 无法杀死僵尸 :
kill -9对僵尸进程无效,因为它们已经"死"了,只留下一个空壳PCB。 - 获取退出信息:父进程需要知道子进程的任务完成情况(成功/失败/被信号杀死)。
bash
# 查看僵尸进程
ps aux | grep defunct
3.2 wait和waitpid详解
c
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
wait函数
- 阻塞等待任意一个子进程退出
- 等价于
waitpid(-1, &status, 0)
waitpid参数详解
① pid参数取值规则:
| pid值 | 含义 |
|---|---|
-1 |
等待任意子进程(同wait) |
>0 |
等待进程ID等于pid的指定子进程 |
0 |
等待与父进程同进程组的任意子进程 |
< -1 |
等待进程组ID等于pid的任意子进程 |
② status ------ 输出型参数,存储退出状态
status是32位整型,只使用低16位,位图结构如下:

- 正常终止:低7位全0,高8位存储退出码
- 被信号杀死:低7位存储终止信号编号,高8位无意义
③ 宏函数(推荐使用,避免手动位运算):
| 宏函数 | 作用 |
|---|---|
WIFEXITED(status) |
判断是否正常退出 |
WEXITSTATUS(status) |
获取退出码(需先WIFEXITED为真) |
WIFSIGNALED(status) |
判断是否被信号终止 |
WTERMSIG(status) |
获取终止信号的编号 |
④ options ------ 等待模式:
| 选项 | 含义 |
|---|---|
0 |
阻塞等待:子进程不退出,父进程一直挂起 |
WNOHANG |
非阻塞等待:若子进程未退出,立即返回0 |
3.3 阻塞等待 vs 非阻塞等待(代码对比)
阻塞等待示例:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:睡5秒后退出
sleep(5);
printf("Child exit\n");
return 42;
} else {
int status;
printf("Parent waiting...\n");
pid_t ret = waitpid(pid, &status, 0); // 阻塞等待
if (WIFEXITED(status)) {
printf("Child exited normally, code=%d\n", WEXITSTATUS(status));
}
}
return 0;
}
非阻塞等待示例(父进程可做其他事):
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
sleep(5);
return 10;
} else {
int status;
pid_t ret;
int count = 0;
while (1) {
ret = waitpid(pid, &status, WNOHANG); // 非阻塞
if (ret == 0) {
printf("Child still running... (%d)\n", ++count);
// 这里可以做其他事情
sleep(1);
} else if (ret == pid) {
if (WIFEXITED(status))
printf("Child exit code: %d\n", WEXITSTATUS(status));
break;
} else {
perror("waitpid");
break;
}
}
}
return 0;
}
输出:
Child still running... (1)
Child still running... (2)
Child still running... (3)
Child still running... (4)
Child still running... (5)
Child exit code: 10
3.4 完整status解析示例
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:被信号杀死
int *p = NULL;
*p = 100; // 段错误,SIGSEGV
exit(0);
} else {
int status;
wait(&status);
if (WIFEXITED(status)) {
printf("Normal exit, code=%d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("Killed by signal %d (%s)\n",
WTERMSIG(status), strsignal(WTERMSIG(status)));
}
}
return 0;
}
4. 进程程序替换
4.1 替换原理
核心概念: 程序替换是指在进程中执行一个全新的程序 ,替换掉当前进程的代码段、数据段、堆栈,但进程的PCB(包括PID)不变。

重要特性:
- 调用成功没有返回值(因为当前进程已经被替换)
- 失败返回-1
- 不会创建新进程,只是"变身"
4.2 exec族函数大全(6个)
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 | 参数以列表形式传递(逐个写出) |
| v | vector | 参数以数组(向量)传递 |
| p | path | 自动搜索PATH环境变量,只需写程序名 |
| e | env | 可以自定义环境变量(最后一个参数传递envp数组) |
💡 注意 :只有
execve是真正的系统调用 ,其他5个都是库函数,最终都会调用execve。
详细示例
c
#include <unistd.h>
#include <stdio.h>
int main() {
// 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);
// 4. execvp: 数组传参,自动搜索PATH
execvp("ls", argv);
// 5. execle: 列表传参,自定义环境变量
char *envp[] = {"PATH=/bin", "HOME=/root", NULL};
execle("/bin/ls", "ls", "-l", NULL, envp);
// 6. execve: 系统调用,数组+自定义环境变量
execve("/bin/ls", argv, envp);
perror("exec failed"); // 只有exec失败才会执行到这里
return 1;
}
4.3 环境变量继承与传递
子进程默认继承父进程的环境变量:
c
#include <stdio.h>
#include <unistd.h>
int main() {
extern char **environ; // 全局环境变量表
printf("Parent env: %s\n", getenv("MY_VAR")); // 假设父shell没有这个变量
if (fork() == 0) {
// 子进程会继承父进程的环境变量
printf("Child env: %s\n", getenv("MY_VAR")); // 可能输出null
execlp("env", "env", NULL); // 打印所有环境变量
}
return 0;
}
通过execle传递自定义环境变量:
c
#include <unistd.h>
int main() {
char *envp[] = {
"MY_VAR=hello_from_exec",
"PATH=/usr/bin",
NULL
};
execle("/bin/echo", "echo", "MY_VAR=$MY_VAR", NULL, envp);
// 输出:MY_VAR=hello_from_exec
return 0;
}
4.4 常见错误与最佳实践
❌ 错误1:exec后还写代码
c
if (fork() == 0) {
execlp("ls", "ls", NULL);
printf("This will never print if exec succeeds\n"); // 永远不会执行
}
✅ 正确:处理exec失败情况
c
if (fork() == 0) {
if (execlp("ls", "ls", NULL) == -1) {
perror("execlp");
exit(1); // exec失败必须退出,否则会继续执行父进程的代码
}
}
❌ 错误2:忘记参数列表以NULL结尾
c
execlp("ls", "ls", "-l"); // 错误,缺少NULL
execlp("ls", "ls", "-l", NULL); // 正确
5. 实战:手写迷你Shell
5.1 设计原理
一个Shell的核心工作流程是无限循环:

5.2 内建命令 vs 外部命令
| 类型 | 示例 | 为什么必须Shell自己执行 |
|---|---|---|
| 内建命令 | cd, export, exit |
需要改变Shell自身状态(如工作目录、环境变量) |
| 外部命令 | ls, ps, grep |
由子进程执行即可 |
5.3 完整代码实现
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <ctype.h>
#define MAX_CMD_LEN 1024
#define MAX_ARGS 64
// 全局变量:上一次命令的退出码
int last_exit_code = 0;
// 获取提示符(简化版:显示当前目录)
void print_prompt() {
char cwd[256];
getcwd(cwd, sizeof(cwd));
printf("[myshell %s]$ ", cwd);
fflush(stdout);
}
// 读取用户输入
char* read_command(char *buf, size_t size) {
if (fgets(buf, size, stdin) == NULL)
return NULL;
// 去掉末尾换行符
buf[strlen(buf) - 1] = '\0';
return buf;
}
// 解析命令:将字符串分割成 argv 数组
int parse_command(char *cmd, char *argv[]) {
int argc = 0;
char *token = strtok(cmd, " ");
while (token != NULL && argc < MAX_ARGS - 1) {
argv[argc++] = token;
token = strtok(NULL, " ");
}
argv[argc] = NULL;
return argc;
}
// 执行外部命令
void exec_external(char *argv[]) {
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return;
}
if (pid == 0) {
// 子进程:执行命令
execvp(argv[0], argv);
// 如果exec失败
perror("execvp");
exit(1);
} else {
// 父进程:等待子进程结束
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
last_exit_code = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
printf("Child killed by signal %d\n", WTERMSIG(status));
last_exit_code = 128 + WTERMSIG(status);
}
}
}
// 内建命令:cd
void builtin_cd(char *path) {
if (path == NULL) {
path = getenv("HOME");
}
if (chdir(path) != 0) {
perror("cd");
last_exit_code = 1;
} else {
last_exit_code = 0;
// 更新 PWD 环境变量
char cwd[256];
getcwd(cwd, sizeof(cwd));
setenv("PWD", cwd, 1);
}
}
// 内建命令:export
void builtin_export(char *arg) {
if (arg == NULL) {
printf("Usage: export VAR=value\n");
last_exit_code = 1;
return;
}
if (putenv(arg) != 0) {
perror("export");
last_exit_code = 1;
} else {
last_exit_code = 0;
}
}
// 内建命令:echo(支持 $?)
void builtin_echo(char *argv[]) {
if (argv[1] == NULL) {
printf("\n");
return;
}
// 检查是否为 $?
if (strcmp(argv[1], "$?") == 0) {
printf("%d\n", last_exit_code);
} else {
// 简单打印所有参数
for (int i = 1; argv[i] != NULL; i++) {
printf("%s%c", argv[i], (argv[i+1] == NULL) ? '\n' : ' ');
}
}
last_exit_code = 0;
}
// 检查并执行内建命令,返回1表示已处理,0表示需要外部执行
int run_builtin(char *argv[], int argc) {
if (strcmp(argv[0], "cd") == 0) {
builtin_cd(argv[1]);
return 1;
} else if (strcmp(argv[0], "export") == 0) {
builtin_export(argv[1]);
return 1;
} else if (strcmp(argv[0], "echo") == 0) {
builtin_echo(argv);
return 1;
} else if (strcmp(argv[0], "exit") == 0) {
exit(0);
}
return 0;
}
int main() {
char cmd_line[MAX_CMD_LEN];
char *argv[MAX_ARGS];
while (1) {
print_prompt();
if (read_command(cmd_line, sizeof(cmd_line)) == NULL) {
printf("\n");
break;
}
if (strlen(cmd_line) == 0)
continue;
int argc = parse_command(cmd_line, argv);
if (argc == 0)
continue;
// 先检查内建命令
if (run_builtin(argv, argc)) {
continue;
}
// 执行外部命令
exec_external(argv);
}
return 0;
}
5.4 编译与运行
bash
$ gcc -o myshell myshell.c
$ ./myshell
[myshell /home/user]$ ls -l
total 32
-rwxr-xr-x 1 user user 18000 Jan 1 12:00 myshell
...
[myshell /home/user]$ cd /tmp
[myshell /tmp]$ pwd
/tmp
[myshell /tmp]$ echo $?
0
[myshell /tmp]$ export MYVAR=hello
[myshell /tmp]$ echo $MYVAR
hello
[myshell /tmp]$ exit
6. 总结与易错点
6.1 核心知识点速查表
| 知识点 | 关键点 | 面试高频 |
|---|---|---|
| fork返回值 | 父进程返回子进程PID,子进程返回0 | ⭐⭐⭐⭐⭐ |
| 写时拷贝 | fork后不立即复制物理内存,写时才复制 | ⭐⭐⭐⭐ |
| 僵尸进程 | 子进程退出,父进程未wait,PCB无法释放 | ⭐⭐⭐⭐⭐ |
| 孤儿进程 | 父进程先退出,子进程被init收养 | ⭐⭐⭐ |
| waitpid | 可指定等待特定子进程,支持非阻塞 | ⭐⭐⭐⭐ |
| status解析 | 低7位存信号,高8位存退出码 | ⭐⭐⭐⭐ |
| exec族 | l/v/p/e区分,只有execve是系统调用 | ⭐⭐⭐⭐⭐ |
| 环境变量 | 子进程默认继承父进程环境 | ⭐⭐⭐ |
6.2 最常见的5个错误
-
忘记wait导致僵尸进程
c// 错误 fork(); // 父进程什么都不做直接退出 → 子进程变孤儿,但不僵尸(被init收养) // 但如果父进程长期运行不wait,就会产生僵尸 // 正确 pid = fork(); if (pid > 0) wait(NULL); -
exec后忘记处理失败
c// 错误 if (fork() == 0) { execlp("ls", "ls", NULL); // 如果exec失败,子进程还会执行后面的代码 exit(0); // 应该立即退出 } -
对status进行错误位运算
c// 错误 printf("exit code: %d\n", status >> 8); // 没有屏蔽高位符号位 // 正确 printf("exit code: %d\n", (status >> 8) & 0xFF); // 更好:使用宏 printf("exit code: %d\n", WEXITSTATUS(status)); -
vfork中子进程修改父进程变量
cint x = 10; if (vfork() == 0) { x = 20; // 危险!会修改父进程的x exit(0); } printf("%d\n", x); // 输出20,不是10 -
exec参数列表忘记NULL结尾
cexeclp("ls", "ls", "-l"); // 错误:可能读取随机内存 execlp("ls", "ls", "-l", NULL); // 正确
6.3 面试加分回答
Q: fork和vfork的区别?
fork使用写时拷贝技术,父子进程地址空间独立;vfork共享地址空间,且保证子进程先运行直到调用exec/exit。现代系统中vfork已不常用,因为fork的写时拷贝足够高效。
Q: 如何避免僵尸进程?
- 父进程主动调用wait/waitpid回收;2. 父进程忽略SIGCHLD信号(
signal(SIGCHLD, SIG_IGN));3. 父进程先退出,让子进程被init收养。
Q: 为什么exec后进程的PID不变?
exec只是替换进程的代码和数据,不创建新的PCB,因此PID保持不变。可以理解为进程"变身"成了另一个程序。
📚 参考阅读
man 2 fork,man 2 wait,man 3 exec- 《Unix环境高级编程》第7-8章
- Linux内核源码:
kernel/fork.c,fs/exec.c
如果觉得本文对你有帮助,欢迎点赞收藏⭐,你的支持是我写作的动力!
本文原创,转载请注明出处。如有错误,欢迎指正。