上一篇我们学透了标准I/O的缓冲机制。本篇正式进入进程管理这一Linux应用层核心领域。我们将通过可运行的代码,把
fork的写时拷贝、vfork的共享地址空间、僵尸进程与孤儿进程的处理、以及文件描述符在父子进程间的共享关系一次性讲透。

一、引言
嵌入式Linux系统中,多进程是最常用的并发模型之一。fork系统调用几乎是所有进程的“出生方式”——除了系统启动时的init(或systemd)进程外,每个进程都是另一个进程的副本。但“副本”二字背后藏着大量细节:父进程的内存真的被完整复制了吗?文件描述符是共享还是独立?vfork为什么比fork快?这些问题没有实验支撑,面试时很容易翻车。
本篇将逐一用代码验证这些机制,所有实验均在ARM开发板上交叉编译运行通过。
二、核心概念:进程与进程创建
2.1 进程是什么?
进程 = 正在执行的程序实例。内核为每个进程维护一个task_struct结构体,记录:
- 进程ID(
pid)、父进程ID(ppid) - 打开的文件描述符表
- 虚拟内存地址空间(代码段、数据段、堆、栈)
- 信号处理相关数据结构
- 进程状态(运行、就绪、阻塞、僵尸等)
2.2 进程创建的三种方式
| 系统调用 | 语义 | 父子内存关系 | 典型用途 |
|---|---|---|---|
fork() | 创建子进程,返回两次 | 写时拷贝(COW) | 通用多进程 |
vfork() | 创建子进程,共享地址空间 | 子进程运行期间父进程阻塞 | exec系列前奏 |
clone() | 灵活控制共享/私有资源 | 由标志位决定 | 实现线程(pthread) |
三、API精讲
3.1 fork() —— 经典进程复制
函数原型:
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
返回值(重点!):
- 父进程中:返回子进程的PID(
> 0) - 子进程中:返回
0 - 失败:返回
-1,设置errno(EAGAIN达到进程数上限、ENOMEM内存不足)
写时拷贝(Copy-On-Write, COW)机制:
fork后父子进程的代码段、数据段、堆栈等内存页在物理上暂时共享同一份。只有当任意一方写入某一页时,内核才为该页复制一份副本。这避免了完整复制整个地址空间的开销。
这解释了为什么
fork执行很快,而父进程的变量在子进程中“看似独立”。
3.2 vfork() —— 轻量级fork(传统用法)
函数原型:
#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);
与fork的关键区别:
vfork不复制父进程的地址空间(更不执行COW),子进程直接运行在父进程的地址空间中- 在子进程调用
exec(或_exit)之前,父进程被挂起(不可运行) - 子进程对内存的任何修改都会直接影响父进程
⚠️ 危险警告:vfork的子进程只应执行exec或_exit。在调用exec或_exit之前,任何修改数据(包括局部变量、全局变量)、调用函数(尤其是printf等标准I/O函数)、或从main返回都是未定义行为,可能导致父进程崩溃或数据错乱。本节的实验仅用于教学演示共享地址空间,实际代码严禁模仿。
现代Linux内核中,
fork已高度优化(COW),vfork的优势微乎其微。除非在极低资源环境下并且马上exec,否则推荐使用fork。
3.3 exit() 与 _exit() —— 进程终止
#include <stdlib.h>
void exit(int status); // 标准C库:调用atexit注册函数、刷新stdio缓冲区
#include <unistd.h>
void _exit(int status); // 系统调用:直接退出,不清理
实验对比(回顾上一篇的知识):
// exit 会刷新 stdio 缓冲区
FILE *fp = fopen("test.txt", "w");
fprintf(fp, "data");
exit(0); // test.txt 中有 "data"
// _exit 不会刷新
FILE *fp = fopen("test.txt", "w");
fprintf(fp, "data");
_exit(0); // test.txt 为空!
3.4 wait() / waitpid() —— 回收子进程
函数原型:
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *wstatus);
pid_t waitpid(pid_t pid, int *wstatus, int options);
关键点:
- 父进程调用
wait会阻塞直到任一子进程终止 - 若子进程已终止,则
wait立即返回 wstatus可获取退出状态,常用宏:WIFEXITED(status)、WEXITSTATUS(status)、WIFSIGNALED(status)waitpid(-1, &status, WNOHANG)可实现非阻塞等待- 必须回收子进程,否则成为僵尸进程,消耗系统资源
四、深度实战
实验1:fork基本行为与写时拷贝验证
/**
* fork_demo.c —— 验证fork返回值与写时拷贝
* 编译: arm-linux-gnueabihf-gcc -Wall -g -o fork_demo fork_demo.c
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int global_var = 100; // 全局变量(数据段)
int main(void)
{
pid_t pid;
int local_var = 200; // 局部变量(栈)
printf("[父进程] 初始值: global_var=%d, local_var=%d\n", global_var, local_var);
pid = fork();
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
/* 子进程 */
printf("[子进程] PID=%d, PPID=%d\n", getpid(), getppid());
printf("[子进程] 修改前: global_var=%d, local_var=%d\n", global_var, local_var);
global_var = 111;
local_var = 222;
printf("[子进程] 修改后: global_var=%d, local_var=%d\n", global_var, local_var);
printf("[子进程] 变量地址: &global_var=%p, &local_var=%p\n", (void*)&global_var, (void*)&local_var);
exit(0);
} else {
/* 父进程 */
printf("[父进程] PID=%d, 子进程PID=%d\n", getpid(), pid);
sleep(1); // 等待子进程先执行,便于观察
printf("[父进程] 修改后: global_var=%d, local_var=%d\n", global_var, local_var);
printf("[父进程] 变量地址: &global_var=%p, &local_var=%p\n", (void*)&global_var, (void*)&local_var);
wait(NULL); // 回收子进程
}
return 0;
}
预期输出:
[父进程] 初始值: global_var=100, local_var=200
[父进程] PID=1234, 子进程PID=1235
[子进程] PID=1235, PPID=1234
[子进程] 修改前: global_var=100, local_var=200
[子进程] 修改后: global_var=111, local_var=222
[子进程] 变量地址: &global_var=0x..., &local_var=0x...
[父进程] 修改后: global_var=100, local_var=200 (注意:父进程的值未变!)
[父进程] 变量地址: &global_var=0x..., &local_var=0x... (与子进程地址相同!)
核心结论:父子进程的全局变量和局部变量互相独立(COW导致),但虚拟地址相同(因为映射到不同的物理页)。
实验2:vfork共享地址空间验证(教学演示,切勿用于生产)
/**
* vfork_demo.c —— 验证vfork的地址空间共享与父进程阻塞
* 编译: arm-linux-gnueabihf-gcc -Wall -g -o vfork_demo vfork_demo.c
*
* 警告:以下代码仅用于演示 vfork 的共享地址空间特性,
* 违反 vfork 使用规范,实际编程中请勿模仿!
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
int global = 10;
int main(void)
{
pid_t pid;
int local = 20;
printf("[父进程] 初始: global=%d, local=%d\n", global, local);
pid = vfork();
if (pid < 0) {
perror("vfork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
/* 子进程(危险操作,仅为演示) */
printf("[子进程] 修改前: global=%d, local=%d\n", global, local);
global = 100;
local = 200;
printf("[子进程] 修改后: global=%d, local=%d\n", global, local);
_exit(0); /* 必须用 _exit,不能用 exit! */
} else {
/* 父进程:子进程 _exit 后才会执行 */
printf("[父进程] 子进程退出后: global=%d, local=%d\n", global, local);
}
return 0;
}
预期输出:
[父进程] 初始: global=10, local=20
[子进程] 修改前: global=10, local=20
[子进程] 修改后: global=100, local=200
[父进程] 子进程退出后: global=100, local=200 (父进程的值被改变了!)
核心结论:
vfork子进程的修改直接影响父进程,因为共享地址空间。但再次强调:实际开发中,vfork后应立即调用exec或_exit,绝不要修改变量或调用printf等函数。
实验3:父子进程文件描述符共享
/**
* fd_share_demo.c —— 验证fork后父子进程共享文件表项(偏移量同步)
* 编译: arm-linux-gnueabihf-gcc -Wall -g -o fd_share_demo fd_share_demo.c
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
#include <errno.h>
#define TEST_FILE "fd_test.txt"
int main(void)
{
int fd;
pid_t pid;
/* 创建测试文件并写入初始内容 */
fd = open(TEST_FILE, O_RDWR | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("open");
exit(EXIT_FAILURE);
}
write(fd, "0123456789", 10); // 写入10字节
lseek(fd, 0, SEEK_SET); // 回到文件开头
printf("[父进程] fork前偏移: %ld\n", (long)lseek(fd, 0, SEEK_CUR));
pid = fork();
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
/* 子进程:读取5个字节 */
char buf[6] = {0};
ssize_t n = read(fd, buf, 5);
printf("[子进程] 读取 %ld 字节: %s\n", (long)n, buf);
printf("[子进程] 当前偏移: %ld\n", (long)lseek(fd, 0, SEEK_CUR));
close(fd);
_exit(0);
} else {
/* 父进程:等待子进程结束后再读取 */
wait(NULL);
char buf[6] = {0};
ssize_t n = read(fd, buf, 5);
printf("[父进程] 子进程结束后读取 %ld 字节: %s\n", (long)n, buf);
printf("[父进程] 当前偏移: %ld\n", (long)lseek(fd, 0, SEEK_CUR));
/* 验证:父进程是否读到子进程之后的数据 */
if (n == 5 && strncmp(buf, "56789", 5) == 0) {
printf("结论:父子进程共享文件偏移量,子进程的读取影响父进程!\n");
} else {
printf("注意:偏移量不共享,读取结果: %s\n", buf);
}
close(fd);
unlink(TEST_FILE); /* 清理测试文件 */
}
return 0;
}
预期输出:
[父进程] fork前偏移: 0
[子进程] 读取 5 字节: 01234
[子进程] 当前偏移: 5
[父进程] 子进程结束后读取 5 字节: 56789
[父进程] 当前偏移: 10
结论:父子进程共享文件偏移量,子进程的读取影响父进程!
关键原理:
fork后父子进程共享内核打开文件表项(见系列②的fd与内核数据结构关系图),因此文件偏移量、文件状态标志等是共享的。但文件描述符本身是独立的(关闭一个不影响另一个的fd编号有效性,但需要两个都close才能真正释放内核文件表项)。
实验4:僵尸进程与孤儿进程
僵尸进程:子进程已终止,但父进程尚未调用wait回收,子进程的task_struct残留,占用PID资源。
孤儿进程:父进程先于子进程终止,子进程会被init(PID=1)收养,并由init自动回收。
/**
* zombie_orphan_demo.c —— 演示僵尸进程与孤儿进程
* 编译: arm-linux-gnueabihf-gcc -Wall -g -o zombie_orphan_demo zombie_orphan_demo.c
*
* 运行方式:
* 产生僵尸进程:./zombie_orphan_demo zombie
* 产生孤儿进程:./zombie_orphan_demo orphan
*
* 运行期间在另一个终端用: ps aux | grep Z 查看僵尸状态
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <string.h>
int main(int argc, char *argv[])
{
pid_t pid;
if (argc != 2) {
fprintf(stderr, "Usage: %s <zombie|orphan>\n", argv[0]);
exit(EXIT_FAILURE);
}
if (strcmp(argv[1], "zombie") == 0) {
printf("=== 僵尸进程实验 ===\n");
pid = fork();
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
/* 子进程:立即退出 */
printf("[子进程 PID=%d] 退出,成为僵尸\n", getpid());
_exit(0);
} else {
/* 父进程:不调用wait,睡眠30秒,期间子进程为僵尸 */
printf("[父进程 PID=%d] 子进程 PID=%d,睡眠30秒不回收...\n", getpid(), pid);
sleep(30);
/* 现在回收 */
wait(NULL);
printf("[父进程] 子进程已回收\n");
sleep(5);
}
} else if (strcmp(argv[1], "orphan") == 0) {
printf("=== 孤儿进程实验 ===\n");
pid = fork();
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
/* 子进程:睡眠等待父进程先退出 */
printf("[子进程 PID=%d] 父进程 PID=%d\n", getpid(), getppid());
sleep(5); // 等待父进程退出
printf("[子进程 PID=%d] 新的父进程 PID=%d (应为1)\n", getpid(), getppid());
_exit(0);
} else {
/* 父进程:立刻退出 */
printf("[父进程 PID=%d] 立即退出,子进程将成为孤儿\n", getpid());
_exit(0);
}
} else {
fprintf(stderr, "无效参数: %s\n", argv[1]);
exit(EXIT_FAILURE);
}
return 0;
}
僵尸进程运行:
# 终端1
./zombie_orphan_demo zombie
# 终端2(快速执行)
ps aux | grep Z
# 会看到类似: root 1235 0.0 0.0 0 0 pts/0 Z+ 10:00 0:00 [zombie_orphan] <defunct>
孤儿进程运行:
./zombie_orphan_demo orphan
# 输出:
[父进程 PID=1300] 立即退出,子进程将成为孤儿
[子进程 PID=1301] 父进程 PID=1300
[子进程 PID=1301] 新的父进程 PID=1 (被init收养)
五、常见陷阱与最佳实践
| 陷阱 | 现象 | 对策 |
|---|---|---|
fork后不wait | 僵尸进程堆积,耗尽PID | 父进程务必调用wait/waitpid;或设置SIGCHLD处理 |
vfork中修改变量/调函数 | 破坏父进程内存,行为未定义 | 只用于exec前,立即_exit |
vfork中使用exit | stdio缓冲区状态混乱 | 必须用_exit |
多进程写同一文件(非O_APPEND) | 数据覆盖 | 用O_APPEND或进程间同步 |
| 父进程先于子进程退出 | 孤儿进程(通常无大碍,但不规范) | 设计上保证父进程生命周期覆盖子进程 |
对FILE *流进行fork后没有正确关闭 | 缓冲区数据重复输出 | fork前fflush所有流,或fork后子进程立即_exit |
六、总结与思考题
本篇通过四个实验彻底讲清了fork与vfork的区别、写时拷贝的地址空间特性、文件描述符在内核文件表中的共享关系,以及僵尸/孤儿进程的成因与避免方法。
核心要点:
fork返回两次,父进程得子进程PID,子进程得0;内存采用COW,修改时才复制vfork共享地址空间,子进程运行时父进程阻塞,必须马上exec或_exit- 文件描述符在
fork后共享内核文件表项,导致文件偏移量同步变化 - 父进程必须
wait回收子进程,避免僵尸进程;孤儿进程由init收养
思考题:
- 为什么
fork后通常让子进程先执行更高效?(提示:COW机制) - 如果一个程序在
fork前打开了文件,父子进程都调用fclose关闭自己的FILE *,底层文件描述符何时真正关闭?会不会导致问题? - 在
vfork的子进程中调用printf可能产生什么结果?为什么?
欢迎在评论区留下你的解答。下一篇我们将深入进程间通信(IPC)——管道、消息队列、共享内存、信号的综合实验。
参考资料
- 《UNIX环境高级编程(APUE)》第8章 Process Control
man 2 fork,man 2 vfork,man 2 wait- Linux内核源码
kernel/fork.c

45

被折叠的 条评论
为什么被折叠?



