嵌入式Linux应用开发系列④:进程管理——fork/vfork/写时拷贝与文件共享深度实验

上一篇我们学透了标准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,设置errnoEAGAIN达到进程数上限、ENOMEM内存不足)

写时拷贝(Copy-On-Write, COW)机制
fork后父子进程的代码段、数据段、堆栈等内存页在物理上暂时共享同一份。只有当任意一方写入某一页时,内核才为该页复制一份副本。这避免了完整复制整个地址空间的开销。

这解释了为什么fork执行很快,而父进程的变量在子进程中“看似独立”。


3.2 vfork() —— 轻量级fork(传统用法)

函数原型

#include <sys/types.h>
#include <unistd.h>

pid_t vfork(void);

与fork的关键区别

  1. vfork不复制父进程的地址空间(更不执行COW),子进程直接运行在父进程的地址空间中
  2. 在子进程调用exec(或_exit)之前,父进程被挂起(不可运行)
  3. 子进程对内存的任何修改都会直接影响父进程

⚠️ 危险警告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中使用exitstdio缓冲区状态混乱必须用_exit
多进程写同一文件(非O_APPEND数据覆盖O_APPEND或进程间同步
父进程先于子进程退出孤儿进程(通常无大碍,但不规范)设计上保证父进程生命周期覆盖子进程
FILE *流进行fork后没有正确关闭缓冲区数据重复输出forkfflush所有流,或fork后子进程立即_exit

六、总结与思考题

本篇通过四个实验彻底讲清了forkvfork的区别、写时拷贝的地址空间特性、文件描述符在内核文件表中的共享关系,以及僵尸/孤儿进程的成因与避免方法。

核心要点

  1. fork返回两次,父进程得子进程PID,子进程得0;内存采用COW,修改时才复制
  2. vfork共享地址空间,子进程运行时父进程阻塞,必须马上exec_exit
  3. 文件描述符在fork后共享内核文件表项,导致文件偏移量同步变化
  4. 父进程必须wait回收子进程,避免僵尸进程;孤儿进程由init收养

思考题

  1. 为什么fork后通常让子进程先执行更高效?(提示:COW机制)
  2. 如果一个程序在fork前打开了文件,父子进程都调用fclose关闭自己的FILE *,底层文件描述符何时真正关闭?会不会导致问题?
  3. vfork的子进程中调用printf可能产生什么结果?为什么?

欢迎在评论区留下你的解答。下一篇我们将深入进程间通信(IPC)——管道、消息队列、共享内存、信号的综合实验。


参考资料

  • 《UNIX环境高级编程(APUE)》第8章 Process Control
  • man 2 fork, man 2 vfork, man 2 wait
  • Linux内核源码 kernel/fork.c
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值