02-进程管理.md 55 KB


title: 进程管理 tags: [嵌入式Linux, Linux应用编程, 进程, fork, exec, 环境变量, 虚拟地址空间, 僵尸进程, 守护进程, 单例模式, IMX6ULL] created: 2026-09-18 updated: 2026-09-18

pdf_ref: "《I.MX6U嵌入式Linux C应用编程指南V1.6》第九章 进程"

进程管理

💡 关联知识:[[02-进程线程与IPC/01-信号机制]]、[[02-进程线程与IPC/03-进程间通信]]、[[02-进程线程与IPC/04-线程与线程同步]];延伸阅读:[[Linux+C+C++技术体系梳理/2. Linux系统编程/5. 进程创建与管理]]、[[Linux+C+C++技术体系梳理/2. Linux系统编程/7. 进程控制]]、[[Linux+C+C++技术体系梳理/2. Linux系统编程/8. 守护进程]]

进程是 Linux 应用编程的核心对象:程序是磁盘上的静态文件,进程是程序被加载运行后的动态实例。本篇覆盖进程全生命周期——从哪里开始(main 由引导代码调用)、运行在什么内存与地址空间里、如何派生(fork/vfork)、如何替换(exec)、如何收尾(wait/僵尸/孤儿)、进程之间是什么关系(进程组/会话),最后落地两个工程必备技能:守护进程单例模式运行


1. 进程与程序

1.1 main() 函数由谁调用

C 语言程序总是从 main 开始执行:

int main(void)
int main(int argc, char *argv[])

需要向应用传参时选第二种。但 main() 并不是被"凭空"调用的:操作系统下的应用程序在运行 main() 之前,需要先执行一段引导代码,由引导代码去调用 main()。编写应用时不用管引导代码,链接时会由链接器把它链接进可执行文件。

当执行应用程序时,Linux 下输入可执行文件的相对路径或绝对路径即可运行(如 ./app/home/dt/app),也可在后面附加参数(./app arg1 arg2)。程序运行需要经过加载器:加载器负责把应用程序加载到内存中执行。

参数传递链路:

sequenceDiagram
    participant Shell as shell 进程
    participant Loader as 加载器
    participant Start as 引导代码
    participant Main as main()
    Shell->>Loader: 解析命令行参数并传递 (arg1, arg2)
    Loader->>Start: 加载程序并把参数交给引导代码
    Start->>Main: 调用 main(argc, argv)

即:shell 进程逐一解析命令行参数 → 传给加载器 → 加载器在加载应用时传给引导代码 → 引导代码调用 main() 时最终传递给 main()

1.2 程序如何结束

进程终止分正常终止与异常终止:

类别 方式
正常终止 main()return 返回;调用 exit();调用 _exit()_Exit()
异常终止 调用 abort();进程接收到某个信号(如 SIGKILL

1.3 注册进程终止处理函数 atexit()

#include <stdlib.h>

int atexit(void (*function)(void));
参数 含义
function 函数指针,指向注册的函数;该函数无参数、无返回值
返回值 含义
成功 返回 0
失败 返回非 0

注意:如果程序使用 _exit()_Exit() 终止进程而不是 exit(),则不会执行注册的终止处理函数。

#include <stdio.h>
#include <stdlib.h>

static void bye(void)
{
    puts("Goodbye!");
}

int main(int argc, char *argv[])
{
    if (atexit(bye)) {
        fprintf(stderr, "cannot set exit function\n");
        exit(-1);
    }
    exit(0);    /* 退出时会调用 bye() */
}

1.4 何为进程、进程号

  • 程序:一个可执行文件,存放在磁盘中,是静态概念。
  • 进程:可执行程序的实例,即文件被运行;是动态过程,从被加载运行开始,到运行结束终止,这就是进程的生命周期。

Linux 下每个进程都有一个进程号(PID),是正数,用于唯一标识系统中某个进程。ps 命令可查看进程号;kill() 等系统调用用 PID 标识目标进程。

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

pid_t getpid(void);   /* 获取本进程 PID */
pid_t getppid(void);  /* 获取父进程 PID */

两个函数都无需参数,返回对应进程号(pid_t 类型)。在用 fork() 区分父子进程、给父进程发信号、排查进程关系时都会用到。


2. 进程的环境变量

每个进程都有一组环境变量,以字符串形式存储在字符串数组(环境列表)中,每个字符串是 name=value 形式,即"名称-值"成对集合。shell 下用 env 查看,用 export 添加/删除。

export LINUX_APP=123456   # 添加环境变量
export -n LINUX_APP       # 删除环境变量

进程的环境变量从父进程继承而来:在 shell 下执行应用,该进程的环境变量就是从 shell 进程继承来的。新进程在创建之前,会继承父进程的环境变量副本。

2.1 访问环境变量:environ

环境变量存放在字符串数组中,全局变量 environ 指向它,声明即可使用:

extern char **environ;

int main(void)
{
    int i;
    for (i = 0; NULL != environ[i]; i++)
        puts(environ[i]);
    return 0;
}

通过元素是否为 NULL 判断数组是否到末尾。

2.2 getenv()

#include <stdlib.h>

char *getenv(const char *name);
参数 含义
name 要获取的环境变量名称
返回值 含义
存在 返回该环境变量值字符串的指针
不存在 返回 NULL

注意:不应该修改 getenv() 返回的字符串,修改它意味着修改环境变量的值。需要修改应使用下面提供的函数。

2.3 putenv() / setenv()

#include <stdlib.h>

int putenv(char *string);
int setenv(const char *name, const char *value, int overwrite);

putenv():参数 stringname=value 形式的字符串;成功返回 0,失败返回非 0 并设置 errno

重要细节putenv() 会让 environ 中某元素直接指向该字符串,而不是副本。因此不能随意修改 string 指向的内容,string 也不应是栈上分配的自动变量。

setenv()name 为变量名,value 为值,overwrite 决定变量已存在时是否覆盖。

参数 含义
name 需要添加或修改的环境变量名称
value 环境变量的值
overwrite 环境变量已存在时:为 0 不改变现有值(本次调用无影响);非 0 则覆盖;不存在时都表示添加
返回值 含义
成功 返回 0
失败 返回 -1,并设置 errno

setenv()putenv() 的两个区别:

  1. putenv() 不为 name=value 字符串分配内存;setenv() 会分配缓冲区并复制。
  2. setenv() 可通过 overwrite 控制"仅添加不覆盖",putenv() 无法控制。

因此推荐使用 setenv(),用自动变量作参数也不会有问题。

2.4 unsetenv() / clearenv()

#include <stdlib.h>

int unsetenv(const char *name);   /* 从环境变量表移除 name */
int clearenv(void);               /* 清空所有环境变量 */

也可以直接把全局变量赋值为 NULL 来清空:

environ = NULL;

clearenv() 内部的做法其实就是把 environ 赋值为 NULL

内存泄漏提醒setenv() 会为环境变量分配内存缓冲区;clearenv() 并不知晓这些缓冲区的存在,无法释放它们。反复调用这两个函数的程序会不断产生内存泄漏。

2.5 更简单的添加方式

执行程序时可在路径前直接以 name=value 形式添加环境变量,多个之间用空格分隔:

NAME=value ./app

2.6 环境变量的作用

环境变量常见用途之一是在 shell 中:HOME 表示用户家目录,USER 表示当前用户名,SHELL 表示 shell 解析器名称,PWD 表示当前所在目录。自己的应用程序同样可以使用进程环境变量(比如读取配置文件路径、日志级别等)。


3. 进程的内存布局

历史沿袭至今,C 语言程序一直由以下几部分组成:

| 段 | 名称 | 内容 | 特性 | | -- | ---- | ---- | ---- | | 正文段 | 代码段 | CPU 执行的机器语言指令 | 只读,防止意外修改;可被多个进程共享 | | 初始化数据段 | 数据段 | 显式初始化的全局变量和静态变量 | 加载时从可执行文件读取值 | | 未初始化数据段 | bss 段 | 未显式初始化的全局变量和静态变量 | 程序执行前系统将其全部初始化为 0;可执行文件只记录位置与大小 | | 栈 | stack | 函数内局部变量、函数调用保存的信息、实参、返回值 | 动态增长收缩,由栈帧组成 | | 堆 | heap | 运行时动态分配的内存(如 malloc) | 可在运行时动态分配 |

bss 一词来源于早期汇编操作符,意思是"由符号开始的块"(block started by symbol)。

size 命令可查看二进制可执行文件的文本段、数据段、bss 段大小:

size app

3.1 典型内存布局

flowchart TB
    subgraph HIGH["高地址"]
        STACK["栈 stack<br/>局部变量 / 返回地址 / 实参<br/>向低地址增长"]
        GAP1["(空闲)"]
        HEAP["堆 heap<br/>malloc 分配<br/>向高地址增长"]
        BSS[".bss<br/>未初始化全局/静态变量"]
        DATA[".data<br/>已初始化全局/静态变量"]
        TEXT[".text 正文段(只读)<br/>机器指令"]
    end
    STACK --- GAP1 --- HEAP --- BSS --- DATA --- TEXT

这只是便于说明的典型方式,并不要求具体实现一定如此安排。


4. 进程的虚拟地址空间

Linux 采用虚拟内存管理技术:每个进程都在自己独立的地址空间中运行。32 位系统中,每个进程的逻辑地址空间均为 4GB,按 3:1 比例分配:

  • 用户进程享有 3GB
  • 内核独自享有剩下的 1GB

虚拟地址通过硬件 MMU(内存管理单元) 映射到实际物理地址。建立映射后,对虚拟地址的读写就是对物理地址的读写。因此应用程序中读写的 0x80800000 并不对应硬件物理地址 0x80800000

4.1 为什么需要虚拟地址

若没有虚拟地址机制,所有应用直接访问物理地址,会出现:

  • 多个程序运行时,必须保证内存总量小于实际物理内存;
  • 内存使用效率低(空间不足时把程序在硬盘与内存间大量搬入搬出);
  • 进程地址空间不隔离(进程可修改其它进程甚至内核的数据,不安全);
  • 无法确定程序的链接地址(代码加载地址由系统随机分配,编译时无法确认)。

引入虚拟地址后带来的优点:

  • 进程与进程、进程与内核相互隔离:一个进程不能读取或修改另一个进程或内核的内存数据,提高安全性与稳定性。
  • 可共享内存:不同进程的虚拟地址空间可映射到相同的物理地址空间,这也是共享内存实现 IPC 的基础。
  • 便于实现内存保护机制:同一块共享内存,不同进程可采取不同保护措施(只读 / 可读可写)。
  • 编译应用时无需关心链接地址:不再要求链接地址与运行地址一致。

5. fork() 创建子进程

#include <unistd.h>

pid_t fork(void);

一个现有进程调用 fork() 创建新进程:调用者称为父进程,被创建者称为子进程

理解 fork() 的关键:调用完成后存在两个进程,各自从 fork() 的返回处继续执行,因此 fork() 会返回两次——子进程返回一个值,父进程返回一个值。

返回位置 返回值 含义
父进程 子进程的 PID 调用成功
子进程 0 调用成功
父进程 -1(并设 errno 调用失败,不创建子进程

5.1 子进程拷贝了什么

fork() 成功后,子进程是父进程的一个副本:

  • 拷贝父进程的数据段、堆、栈
  • 继承父进程打开的文件描述符
  • 父子进程不共享这些存储空间,是子进程对父进程相应部分的完全复制;fork() 后各进程均可修改自己的栈数据与堆变量,互不影响;
  • 代码段(文本段)是共享的:只读,内存中只存在一份。

    flowchart TD
    P["父进程<br/>代码段(只读,共享)<br/>数据段/堆/栈(副本)"] -->|fork| C["子进程<br/>独立 PCB / 独立 PID<br/>数据段/堆/栈副本"]
    P -->|fork返回子进程PID| R1[继续执行 fork 之后的代码]
    C -->|fork返回0| R2[继续执行 fork 之后的代码]
    

子进程被创建后便是一个独立进程:拥有自己的进程空间、系统内唯一的 PID、自己独立的 PCB(进程控制块),会被内核同等调度执行,参与到系统进程调度中。

5.2 父子进程间的文件共享

调用 fork() 后,子进程获得父进程所有文件描述符的副本(创建方式类似 dup()),意味着父子进程对应的文件描述符指向相同的文件表,进而指向磁盘中相同的文件。因此:

  • 若子进程更新了文件偏移量,这个改变也会影响父进程相应文件描述符的偏移量;
  • 父子进程都写同一个文件时,效果类似使用了 O_APPEND:每次写入都从文件末尾接续。

对应地,如果父进程 fork() 之后父子进程各自 open() 同一个文件,则两个文件描述符指向不同的文件表,各自有独立的偏移量,一方修改偏移量不影响另一方,写入数据会出现覆盖

场景 文件描述符 文件表 偏移量 写入效果
openfork 子进程继承副本 同一份 共享 接续写
fork 后各自 open 各进程独立 各一份 独立 可能覆盖

5.3 fork() 的使用场景

  1. 父进程希望子进程复制自己,使父子进程同时执行不同的代码段。网络服务进程常用:父进程等待客户端请求,收到请求后 fork() 创建子进程处理请求,父进程继续等待下一个请求。
  2. 一个进程要执行不同的程序:如 app1 中 fork() 创建子进程,子进程立即调用 exec 族函数去执行 app2,从 app2 的 main() 开始运行。

6. vfork()

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

pid_t vfork(void);

fork() 会复制父进程数据段、堆、栈的大量内容,代价较大。若子进程 fork() 后立刻 exec,父进程的数据根本用不上,浪费时间和效率。vfork() 就是为"子进程立即执行 exec"这一场景专门设计的,效率高于 fork()

vfork()fork() 的两个主要区别:

  1. 不复制父进程地址空间:子进程在调用 exec_exit 之前,在父进程的空间中运行、共享父进程内存。若子进程修改了父进程数据(除 vfork 返回值变量)、进行函数调用,或没有调用 exec/_exit 就返回,可能带来未知结果。
  2. 保证子进程先运行:子进程调用 exec 之后父进程才可能被调度运行。

实践建议vfork() 可能导致难以察觉的 bug,应尽量避免使用。现代 Linux 内核的 fork() 已采用写时复制(copy-on-write)技术,效率比早期实现高很多,除非速度绝对重要,否则应舍弃 vfork() 而使用 fork()

正式使用场合,一般应在子进程中立即调用 exec;如果 exec 失败,子进程应调用 _exit() 退出(vfork() 产生的子进程不应调用 exit,因为会导致对父进程 stdio 缓冲区的刷新和关闭)。

⚠️ 来源说明:以下"写时复制细节"不属于《I.MX6U嵌入式Linux C应用编程指南》内容,为扩展知识。

写时复制(COW)fork() 后父子进程的页表指向同一批物理页,这些页被标记为只读;任一进程尝试写入时触发缺页异常,内核才为该进程复制出独立的一份物理页。这样"只读共享、写时才复制",避免了 fork 时的大面积内存复制开销。vfork 则更极端——完全不复制,直接共享父进程地址空间,因此父进程会被挂起直到子进程 exec 或退出。


7. fork() 之后的竞争条件

fork() 后子进程成为独立进程,可被系统调度运行,父进程也继续被调度。无法确定父子谁先访问 CPU(多核上甚至可能同时各自访问一个 CPU),因此谁先运行、谁后运行是不确定的。

对于执行顺序有要求的程序,可能因竞争条件导致结果错误。解决办法是采用同步技术,例如用信号:

  • 希望子进程先运行:父进程在 fork() 前注册好信号处理函数,fork() 后父进程调用 sigsuspend(&wait_mask) 挂起阻塞,子进程完成工作后 kill(getppid(), SIGUSR1) 唤醒父进程。

    sequenceDiagram
    participant F as 父进程
    participant C as 子进程
    F->>F: sigaction 注册 SIGUSR1 处理函数
    F->>C: fork()
    F->>F: sigsuspend() 挂起
    C->>C: 先执行子进程工作
    C->>F: kill(getppid(), SIGUSR1)
    F->>F: 被唤醒, 继续执行父进程工作
    

8. 进程的诞生与终止

8.1 进程的诞生

进程可以通过 fork()vfork() 创建子进程。Linux 下所有进程都由其父进程创建:在 shell 终端执行 ./app,app 进程就是 shell 终端进程创建出来的。

最原始的父进程是 init 进程(PID 为 1):它是系统启动后运行的第一个进程,由内核启动,理论上没有父进程,管理着系统上所有其它进程。

8.2 进程的终止

进程的正常终止有多种方式(returnexit()_exit()_Exit()),异常终止也有多种(abort()、收到信号)。

_exit() / exit()status 参数定义终止状态,父进程可用 wait() 获取。虽然 statusint,但仅有低 8 位表示终止状态。一般终止状态为 0 表示成功终止,非 0 表示执行中出现错误(如文件打开失败、读写失败)。

程序里一般使用 exit() 库函数而非 _exit() 系统调用。exit() 最终也会通过 _exit() 终止进程,但在此之前会完成:

  1. 调用程序中注册的进程终止处理函数(如 atexit() 注册的);
  2. 刷新 stdio 流缓冲区;
  3. 执行 _exit() 系统调用。

8.3 exit() 与 _exit() 的 stdio 缓冲差异

父子进程不应都使用 exit() 终止,只能一个用 exit()、另一个用 _exit()(一般子进程用 _exit()、父进程用 exit())。原因是 exit() 会刷新 stdio 缓冲区。

printf("Hello World!");    /* 注意:没有 \n */
fork();
/* 父进程和子进程都 exit(0) */

结果 "Hello World!" 被打印了两次。原因:

  • 进程用户空间内存中维护了 stdio 缓冲区,fork() 创建子进程时会复制这些缓冲区;
  • 标准输出默认使用行缓冲,遇到换行符才立即显示。上面字符串没有换行符,printf() 时不会立即刷新;
  • fork() 后子进程拷贝了含数据的缓冲区,父子调用 exit() 时都刷新各自缓冲区,于是打印两次。

避免重复输出的方法:

  1. 对行缓冲设备加上换行符(printf\n,或用会自动加换行符的 puts());
  2. fork() 前用 fflush() 刷新 stdio 缓冲区,或用 setvbuf() / setbuf() 关闭缓冲;
  3. 子进程调用 _exit() 退出而非 exit(),退出时不刷新 stdio 缓冲区。

9. 监视子进程

很多应用设计中,父进程需要知道子进程何时终止及终止状态,并回收其资源。

9.1 wait()

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

pid_t wait(int *status);
参数 含义
status 存放子进程终止状态信息;可为 NULL,表示不接收
返回值 含义
成功 返回终止的子进程 PID
失败 返回 -1

wait() 执行的动作:

  • 若所有子进程都还在运行,wait() 一直阻塞,直到某个子进程终止;
  • 若进程没有子进程,wait() 返回 -1,errno 设为 ECHILD
  • 若调用前已有子进程终止,则不会阻塞,立即为其"收尸"、处理后事(一次 wait() 只处理一次)。

status 检查宏:

| 宏 | 含义 | | -- | ---- | | WIFEXITED(status) | 子进程正常终止时返回 true | | WEXITSTATUS(status) | 返回子进程退出状态(exit/_exit 指定的值) | | WIFSIGNALED(status) | 子进程被信号终止时返回 true | | WTERMSIG(status) | 返回导致子进程终止的信号编号 | | WCOREDUMP(status) | 子进程终止时产生核心转储文件时返回 true |

获取到的 status 并不是 exit() 时指定的状态,需要通过 WEXITSTATUS 宏转换。

9.2 waitpid()

wait() 的限制:

  • 无法等待某个特定子进程完成,只能按顺序等待下一个终止的子进程;
  • 子进程没终止时总是阻塞,无法非阻塞等待;
  • 只能发现被终止的子进程,对子进程因信号(如 SIGSTOP)而停止、或已停止的子进程收到 SIGCONT 后恢复的情况无能为力。

    #include <sys/types.h>
    #include <sys/wait.h>
    
    pid_t waitpid(pid_t pid, int *status, int options);
    
参数 含义
pid 取值含义见下表
status wait()status 意义相同
options 位掩码,见下表

pid 取值:

pid 取值 含义
pid > 0 等待进程号为 pid 的子进程
pid == 0 等待与调用进程同一进程组的所有子进程
pid < -1 等待进程组标识符与 \|pid\| 相等的所有子进程
pid == -1 等待任意子进程;wait(&status)waitpid(-1, &status, 0) 等价

options 标志:

标志 含义
WNOHANG 子进程没有状态改变时立即返回(非阻塞等待,可轮询);返回 0 表示没有发生改变
WUNTRACED 除返回终止子进程状态外,还返回因信号而停止的子进程状态
WCONTINUED 返回因收到 SIGCONT 而恢复运行的子进程状态

waitid()waitpid() 类似,提供更多扩展功能,可查阅 man 手册。

9.3 僵尸进程与孤儿进程

父进程与子进程生命周期往往不同,由此产生两种特殊进程。

孤儿进程:父进程先于子进程结束,子进程成为"孤儿"。Linux 中所有孤儿进程都自动成为 init 进程(PID 1)的子进程,getppid() 返回 1(图形界面环境下可能被 upstart 等进程收养,返回其它 PID;字符界面下为 init)。

僵尸进程:子进程先于父进程结束,而父进程还未来得及收尸,子进程就变成僵尸进程。父进程调用 wait()(或其变体)后,僵尸进程被内核彻底删除。如果父进程没有调用 wait() 就退出,init 进程会接管其子进程并自动调用 wait(),从而移除僵尸进程。

要点:

  • 僵尸进程无法通过信号杀死,连 SIGKILL 也不行;
  • 只能杀死僵尸进程的父进程(或等待其父进程终止),让 init 接管并清理;
  • 若系统存在大量僵尸进程,会填满内核进程表,阻碍新进程创建;
  • 程序设计一定要监视子进程状态变化,子进程终止就调用 wait() 回收。

9.4 SIGCHLD 信号

当发生以下情况时,父进程会收到 SIGCHLD 信号:

  • 父进程的某个子进程终止;
  • 父进程的某个子进程因收到信号而停止(暂停运行)或恢复。

子进程终止是异步事件,父进程不能一直 wait() 阻塞或轮询,否则无法处理自己的事。SIGCHLD 默认处理方式是忽略,因此要捕获它、绑定处理函数,在处理函数中调用 wait() 收回子进程。

关键技巧:调用信号处理函数时会暂时把引发调用的信号加入进程信号掩码(除非 SA_NODEFER)。若在处理一个终止子进程时相继有两个子进程终止,即使产生两次 SIGCHLD,父进程也只能捕获一次,处理函数每次只调用一次 wait() 就会有"漏网之鱼"。

因此 SIGCHLD 处理函数的惯用写法是循环以非阻塞方式调用 waitpid(),直到再无终止的子进程:

while (waitpid(-1, NULL, WNOHANG) > 0)
    continue;

返回 0 表示再无僵尸进程,返回 -1 表示有错误发生。并且应在创建任何子进程之前为 SIGCHLD 绑定处理函数。

9.5 进程状态转换图

stateDiagram-v2
    [*] --> 就绪态: 创建进程
    就绪态 --> 运行态: 被 CPU 调度
    运行态 --> 就绪态: 时间片到
    运行态 --> 等待态: 等待资源/IO (浅度/深度睡眠)
    等待态 --> 就绪态: 条件成立
    运行态 --> 暂停态: SIGSTOP / SIGTSTP
    暂停态 --> 就绪态: SIGCONT
    运行态 --> 僵尸态: 进程结束但父进程未收尸
    僵尸态 --> [*]: 父进程 wait 回收

10. 执行新程序:exec 族

当子进程的工作不是运行父进程的代码段,而是运行另一个新程序时,用 exec 函数。

10.1 execve()

#include <unistd.h>

int execve(const char *filename, char *const argv[], char *const envp[]);
参数 含义
filename 需要载入当前进程空间的新程序路径名,绝对路径或相对路径均可
argv 传给新程序的命令行参数,字符串指针数组,以 NULL 结束,对应 mainargvargv[0] 是新程序自身路径名
envp 新程序的环境变量列表,字符串指针数组,以 NULL 结束,字符串格式为 name=value;对应新程序的 environ
返回值 含义
成功 永不返回
失败 返回 -1,并设置 errno

execve() 把新程序加载到进程内存空间,用新程序替换旧程序:栈、数据、堆会被新程序的相应部件替换,然后从新程序的 main() 开始执行。对它的成功调用永不返回,也无需检查返回值——一旦返回就表明发生错误。

execve() 是系统调用,也是 exec 族中的一员。基于它还提供了一系列库函数:execl()execlp()execle()execv()execvp()execvpe()。通常把用这些函数加载外部新程序的过程称为 exec 操作

10.2 exec 库函数

#include <unistd.h>

extern char **environ;

int execl(const char *path, const char *arg, ... /* (char *) NULL */);
int execlp(const char *file, const char *arg, ... /* (char *) NULL */);
int execle(const char *path, const char *arg, ... /*, (char *) NULL, char * const envp[] */);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

区别归纳:

函数 参数形式 是否查 PATH 是否自定义环境变量
execl 可变参数列表,以 NULL 结尾 否(需路径)
execv argv 数组 否(需路径)
execlp 可变参数列表 (可只给文件名)
execvp argv 数组
execle 可变参数列表 + envp
execvpe argv 数组 + envp
  • l 表示 list(参数列表),v 表示 vector(数组):决定参数以可变参数还是字符串数组传递。
  • p 表示 PATH:允许只提供文件名,系统在 PATH 环境变量指定的目录中查找;也兼容相对/绝对路径。
  • e 表示 environment:可以指定自定义环境变量列表给新程序。

调用示例:

/* execv 传参 */
char *arg_arr[5];
arg_arr[0] = "./newApp";
arg_arr[1] = "Hello";
arg_arr[2] = "World";
arg_arr[3] = NULL;
execv("./newApp", arg_arr);

/* execl 传参 */
execl("./newApp", "./newApp", "Hello", "World", NULL);

/* execvpe 传参 */
char *env_arr[5] = {"NAME=app", "AGE=25", "SEX=man", NULL};
execvpe("./newApp", arg_arr, env_arr);

/* execle 传参 */
execle("./newApp", "./newApp", "Hello", "World", NULL, env_arr);

10.3 为什么要在子进程中 exec

可以直接在子进程分支写代码,但不够灵活、扩展性不够好。把子进程要运行的程序单独做成一个可执行文件,由 fork() + exec() 组合运行,结构更清晰。这也是 fork 的第二种使用场景:子进程从 fork() 返回后立即调用 exec 族函数。

10.4 system()

#include <stdlib.h>

int system(const char *command);
参数 含义
command 需要执行的 shell 命令字符串,如 "ls -al""echo HelloWorld"

system() 内部通过 fork()execl()waitpid() 实现:先 fork() 创建子进程运行 shell,再由 shell 执行 command 指定的命令。

返回值:

  • commandNULL:shell 可用返回非 0,不可用返回 0;
  • 无法创建子进程或无法获取子进程终止状态:返回 -1;
  • 子进程不能执行 shell:相当于子进程通过 _exit(127) 终止;
  • 全部系统调用成功:返回执行 command 的 shell 进程的终止状态。

优点是用起来方便,无需自己处理 forkexecwaitpidexit 等细节;代价是效率低:至少要创建两个进程(一个跑 shell,一个或多个跑解析出的命令),每条命令都会调用一次 exec。对效率有要求的程序不建议直接使用。


11. 进程状态与进程关系

11.1 进程的六种状态

状态 含义
就绪态(Ready) 满足被 CPU 调度的所有条件但尚未被调度执行,得到 CPU 就能直接运行
运行态 当前正在被 CPU 调度运行
僵尸态 进程已结束,但父进程还未给它"收尸"(即僵尸进程)
可中断睡眠状态 浅度睡眠,可被信号唤醒
不可中断睡眠状态 深度睡眠,无法被信号唤醒,只能等条件成立
暂停态 进程暂停运行(不是终止),一般由信号暂停(如 SIGSTOP),可用 SIGCONT 恢复

浅度睡眠与深度睡眠统称为等待态(阻塞态):表示进程在等待某种条件成立后进入就绪态,处于等待态的进程无法参与系统调度。新创建的进程处于就绪态。

11.2 进程关系

每个进程都有唯一的 PID,也有自己的生命周期与父进程,于是形成以 init 为根的进程家族树。子进程终止时父进程会得到通知并取得退出状态。进程间关系主要包括:无关系(相互独立)、父子进程关系、进程组、会话。

11.3 进程组

每个进程除了 PID、父进程 PID 外,还有进程组 ID(标识属于哪个进程组)。进程组是一个或多个进程的集合,这些进程彼此可能有父子、兄弟关系,或在功能上有联系。

进程组的意义是方便管理:若为了完成一个任务并发运行 100 个进程,没有进程组就要逐个终止;有了进程组,把这些进程设为同一组,终止整个进程组即可。

关于进程组:

  • 每个进程必定属于某一个进程组,且只能属于一个进程组;
  • 每个进程组有一个组长进程,组长进程的 ID 等于进程组 ID;
  • 在组长进程 ID 前加负号即可操作整个进程组;
  • 组长进程不能再创建新的进程组;
  • 只要进程组中还存在一个进程,进程组就存在,与组长进程是否终止无关;
  • 进程组的生命周期从被创建开始,到所有进程终止或离开该组;
  • 默认情况下,新进程继承父进程的进程组 ID。

    #include <unistd.h>
    
    pid_t getpgid(pid_t pid);   /* 获取指定进程的进程组 ID, pid 为 0 表示调用者 */
    pid_t getpgrp(void);        /* 获取调用者进程组 ID, 等价于 getpgid(0) */
    
    int setpgid(pid_t pid, pid_t pgid);  /* 加入现有进程组或创建新进程组 */
    int setpgrp(void);                   /* 等价于 setpgid(0, 0) */
    

setpgid() 把 pid 指定进程的进程组 ID 设为 pgid:若 pid == pgid,则该进程成为组长并创建新进程组;pid == 0 使用调用者 PID;pgid == 0 创建新进程组,由 pid 指定进程当组长。一个进程只能为自己或子进程设置进程组 ID,子进程调用 exec 后就不能再更改其进程组 ID。

11.4 会话

会话是一个或多个进程组的集合

  • 一个会话可包含一个或多个进程组,但只能有一个前台进程组,其它是后台进程组;
  • 每个会话有一个会话首领(leader),即创建会话的进程;
  • 会话可以有控制终端,也可以没有;有控制终端时也只能连接一个(通常是登录终端设备或伪终端设备);
  • 会话首领进程连接终端后,该终端成为会话的控制终端,首领进程称为控制进程
  • 终端上的输入和信号发送给会话的前台进程组中的所有进程,如 Ctrl+CSIGINT)、Ctrl+ZSIGTSTP)、Ctrl+\SIGQUIT)。

用户登录时一个新会话就开始了;打开多个终端窗口就是创建了多个终端会话。会话首领进程的进程组 ID 作为会话 ID(sid),默认情况下新进程继承父进程的会话 ID。

#include <unistd.h>

pid_t getsid(pid_t pid);   /* pid 为 0 返回调用者会话 ID */
pid_t setsid(void);        /* 创建新会话 */

若调用者不是进程组组长,setsid() 会创建一个新会话:调用者成为新会话首领,同时成为新进程组组长,且新会话没有控制终端。成功返回新会话 ID,失败返回 -1。

setsid() 是守护进程的关键一步。


12. 守护进程

12.1 何为守护进程

守护进程(Daemon,也称精灵进程)是运行在后台的特殊进程,独立于控制终端,周期性地执行任务或等待处理事件。两个主要特点:

  • 长期运行:一般在系统启动时开始运行,除非强行终止,直到系统关机都保持运行;不受用户登录注销影响。
  • 与控制终端脱离:普通进程从终端开始运行,依附于该终端(会话的控制终端);终端关闭时会话退出、由该终端运行的所有进程被终止。守护进程脱离终端并在后台运行,避免信息在终端显示,也不会被终端产生的信息打断。

Linux 中大多数服务器就是用守护进程实现的(如 inetdhttpd),作业规划进程 crond 也是。守护进程名通常以 d 结尾。守护进程与终端无关联,自成进程组、自成会话,即 pid = gid = sid

ps -ajx 查看:TTY 一栏为 ? 表示没有控制终端;COMMAND 用中括号 [] 括起来的是内核线程(在内核里创建,没有用户空间代码,通常以 k 开头表示 Kernel)。

12.2 编写守护进程的步骤

flowchart TD
    A[1. 创建子进程, 父进程 exit] --> B[2. 子进程 setsid 创建新会话]
    B --> C[3. chdir 将工作目录改为 /]
    C --> D[4. umask 0 重设文件权限掩码]
    D --> E[5. 关闭所有不再需要的文件描述符]
    E --> F[6. 把 0/1/2 重定向到 /dev/null]
    F --> G[7. 忽略 SIGCHLD 信号]
    G --> H[守护进程主体循环]
  1. 创建子进程、终止父进程:父进程 fork()exit()。这样做让 shell 认为命令已执行完毕;同时子进程有自己独立的 PID,保证子进程不是进程组组长,这是调用 setsid() 的先决条件。
  2. 子进程调用 setsid() 创建会话:因为子进程不是组长,setsid() 使其创建新会话、成为会话首领与新进程组组长,且新会话没有控制终端。这一步有三个作用:摆脱原会话控制、摆脱原进程组控制、摆脱原控制终端控制。
  3. 将工作目录更改为根目录:子进程继承父进程的当前工作目录,而运行中当前目录所在的文件系统不能被卸载。通常让 / 作为守护进程当前目录(也可指定其它目录)。
  4. 重设文件权限掩码 umask:子进程继承父进程的文件权限掩码,会给使用文件带来麻烦;把掩码设为 0,确保子进程有最大操作权限。用法:umask(0)
  5. 关闭不再需要的文件描述符:子进程继承的所有文件描述符都消耗系统资源,且可能导致所在文件系统无法卸载。关闭它们,使守护进程不再持有从父进程继承的文件描述符。
  6. 把文件描述符 0、1、2 定位到 /dev/null:使守护进程的输出无处显示,也无法从交互式用户处接收输入。
  7. 其它:忽略 SIGCHLD 信号:不是必须,但对并发服务器进程特别重要。服务器收到客户端请求时创建子进程处理,若父进程不 wait() 回收,子进程会变僵尸;若 wait() 又会增加负担、影响并发性能。把 SIGCHLD 处理方式设为 SIG_IGN,可让内核把僵尸进程转交 init 处理,既不产生僵尸进程、又省去回收开销。

⚠️ 来源说明:以下"double-fork 双 fork"不属于《I.MX6U嵌入式Linux C应用编程指南》内容,为扩展知识。

标准做法中常再 fork() 一次(double fork):第一次 fork 后父进程退出,子进程 setsid() 成为会话首领;再 fork 一次并让第一个子进程退出,使最终的工作进程既不是会话首领、也不是进程组组长,从而永远无法重新获得控制终端setsid() 要求调用者不是组长,因此工作进程无法再调用 setsid 获取终端)。教材采用单次 fork + setsid 的方式,对常规场景已经足够。

12.3 SIGHUP 信号

用户准备退出会话时,系统向该会话发 SIGHUP;会话把 SIGHUP 发给所有子进程,子进程收到后默认终止;当会话中所有进程都退出时,会话终止。

如果程序忽略 SIGHUPsignal(SIGHUP, SIG_IGN)),则不会随终端退出而退出——此时它已成为守护进程。因为控制终端只是会话中的一个进程,只有会话中所有进程退出,会话才结束。


13. 单例模式运行

有些程序不允许同时运行多个实例(如服务器、守护进程),这就是单例模式运行。常用两种实现思路。

13.1 通过文件存在与否判断(不推荐)

程序正式运行前先判断特定文件是否存在:存在则说明进程已在运行,立即退出;不存在则创建文件,程序结束时删除。以 O_RDONLY | O_CREAT | O_EXCL 打开文件,并用 atexit() 注册删除函数。

int fd = open("./testApp.lock", O_RDONLY | O_CREAT | O_EXCL, 0666);
if (-1 == fd) {
    fputs("不能重复执行该程序!\n", stderr);
    exit(-1);
}
atexit(delete_file);   /* remove(LOCK_FILE) */

三个明显问题:

  1. 程序用 _exit() 退出时无法执行删除函数,文件残留;
  2. 程序异常退出同样无法执行删除函数,文件残留;
  3. 计算机掉电关机,文件残留,重启后程序无法执行。

补救(exit() 代替 _exit()、把文件放 /tmp 使其随重启销毁、注册信号处理函数删文件)都不彻底——SIGKILL/SIGSTOP 无法被忽略或捕获。所以这种方法并不靠谱

13.2 使用文件锁(推荐)

程序启动后先打开特定文件(一般用 O_WRONLY | O_CREAT,不存在则创建),然后尝试获取文件锁

  • 获取成功:把 PID 写入文件,不关闭文件、不解锁,保证进程一直持有该锁;
  • 获取失败:说明程序已在运行,退出本次启动。

提示:当程序退出或文件关闭后,文件锁会自动解锁。flock() 产生的是咨询锁(建议性锁),不是强制性锁;fcntl()lockf() 也可实现上锁。

fd = open("./testApp.pid", O_WRONLY | O_CREAT, 0666);
if (-1 == fd) { perror("open error"); exit(-1); }

if (-1 == flock(fd, LOCK_EX | LOCK_NB)) {   /* 非阻塞互斥锁 */
    fputs("不能重复执行该程序!\n", stderr);
    close(fd);
    exit(-1);
}

ftruncate(fd, 0);            /* 文件截断为 0 */
sprintf(str, "%d\n", getpid());
write(fd, str, strlen(str)); /* 写入当前 PID */

这种机制在服务器程序中很常见。Linux 的 /var/run/ 目录下有很多 .pid 后缀文件,就是为保证程序单例运行而设计的;命名方式通常是"程序名 + .pid"(如 acpid.pid)。实现单例守护进程时,也应把该文件放到 /var/run/ 下并命名为 name.pid

其它方法(启动时用 ps 判断进程是否存在等)也可用,但最常用的还是文件锁


14. 完整可复制源码

14.1 进程全生命周期演示

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>

/* 进程终止处理函数,验证 atexit 只对 exit() 生效 */
static void bye(void)
{
    puts("bye: 进程正常终止处理函数被调用");
}

int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "用法: %s <fork|exec|env|za|daemon>\n", argv[0]);
        exit(-1);
    }

    /* ------------------------------------------------------------------
     * 1. fork:区分父子进程,父进程 wait 回收子进程
     * ------------------------------------------------------------------ */
    if (0 == strcmp(argv[1], "fork")) {
        pid_t pid;
        int status;

        atexit(bye);

        pid = fork();
        if (-1 == pid) {
            perror("fork error");
            exit(-1);
        }

        if (0 == pid) {
            /* 子进程:使用 _exit() 避免刷新父进程 stdio 缓冲区 */
            printf("这是子进程打印信息<pid: %d, 父进程 pid: %d>\n",
                   getpid(), getppid());
            sleep(1);
            _exit(7);
        } else {
            /* 父进程:阻塞等待子进程结束并回收 */
            printf("这是父进程打印信息<pid: %d, 子进程 pid: %d>\n",
                   getpid(), pid);

            if (waitpid(pid, &status, 0) == -1) {
                perror("waitpid error");
                exit(-1);
            }
            if (WIFEXITED(status))
                printf("子进程正常退出,退出状态: %d\n", WEXITSTATUS(status));
            else if (WIFSIGNALED(status))
                printf("子进程被信号终止,信号: %d\n", WTERMSIG(status));
        }

    /* ------------------------------------------------------------------
     * 2. exec:用 execlp 执行系统命令
     * ------------------------------------------------------------------ */
    } else if (0 == strcmp(argv[1], "exec")) {
        puts("即将 exec 执行 ls -al(成功则不返回)");
        execlp("ls", "ls", "-a", "-l", NULL);
        perror("execlp error");   /* 能执行到这里说明 exec 失败 */
        exit(-1);

    /* ------------------------------------------------------------------
     * 3. 环境变量:读取、添加、删除
     * ------------------------------------------------------------------ */
    } else if (0 == strcmp(argv[1], "env")) {
        extern char **environ;
        int i;
        const char *val;

        for (i = 0; NULL != environ[i]; i++)
            puts(environ[i]);

        val = getenv("HOME");
        printf("HOME = %s\n", val ? val : "(不存在)");

        if (setenv("MY_APP", "hello", 1) != 0) {
            perror("setenv error");
            exit(-1);
        }
        printf("MY_APP = %s\n", getenv("MY_APP"));

        if (unsetenv("MY_APP") != 0) {
            perror("unsetenv error");
            exit(-1);
        }
        printf("删除后 MY_APP = %s\n", getenv("MY_APP") ? "存在" : "(不存在)");

    /* ------------------------------------------------------------------
     * 4. 僵尸进程:子进程退出、父进程不收尸
     * ------------------------------------------------------------------ */
    } else if (0 == strcmp(argv[1], "za")) {
        pid_t pid = fork();

        if (-1 == pid) { perror("fork error"); exit(-1); }
        if (0 == pid) {
            printf("子进程<%d>即将退出,父进程不收尸将成为僵尸\n", getpid());
            sleep(1);
            _exit(0);
        }

        printf("父进程<%d>不调用 wait,子进程将变僵尸,用 'ps -aux' 查看 Z 状态\n",
               getpid());
        for ( ; ; )
            sleep(1);

    /* ------------------------------------------------------------------
     * 5. 守护进程:完整 7 步实现
     * ------------------------------------------------------------------ */
    } else if (0 == strcmp(argv[1], "daemon")) {
        pid_t pid;
        int i;

        /* 1. 创建子进程、父进程退出 */
        pid = fork();
        if (0 > pid) { perror("fork error"); exit(-1); }
        else if (0 < pid) exit(0);

        /* 2. 创建新会话、脱离控制终端 */
        if (0 > setsid()) { perror("setsid error"); exit(-1); }

        /* 3. 工作目录改为根目录 */
        if (0 > chdir("/")) { perror("chdir error"); exit(-1); }

        /* 4. 重设文件权限掩码 */
        umask(0);

        /* 5. 关闭所有文件描述符 */
        for (i = 0; i < sysconf(_SC_OPEN_MAX); i++)
            close(i);

        /* 6. 0/1/2 重定向到 /dev/null */
        open("/dev/null", O_RDWR);
        dup(0);
        dup(0);

        /* 7. 忽略 SIGCHLD 信号 */
        signal(SIGCHLD, SIG_IGN);

        /* 守护进程主体 */
        for ( ; ; ) {
            sleep(1);
            /* 实际守护进程在此处理周期性任务 */
        }

    } else {
        fprintf(stderr, "未知参数: %s\n", argv[1]);
        exit(-1);
    }

    exit(0);
}

编译 daemon 分支需要 #include <fcntl.h>openO_RDWR)。完整编译: gcc -Wall -o proc_demo proc_demo.c

14.2 源码逐段解释

  • fork 分支atexit() 注册的函数只在父进程 exit() 时被调用(子进程用 _exit 绕过),可对照观察。父进程用 waitpid() 回收并解析退出状态。
  • exec 分支execlp() 执行 ls -alp 表示查 PATH,所以无需写 /bin/ls。exec 成功则不返回。
  • env 分支:遍历 environgetenv 读取、setenv 添加、unsetenv 删除,覆盖环境变量主要 API。
  • za 分支:子进程先退出、父进程死循环不收尸,ps -aux 可看到子进程状态为 Z(zombie)。
  • daemon 分支:严格按 7 步实现守护进程;运行后没有任何输出(已重定向到 /dev/null),用 ps -ajx 查看 TTY?

14.3 单例模式运行(文件锁)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/file.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

#define LOCK_FILE "/var/run/proc_demo.pid"

int main(void)
{
    char str[20] = {0};
    int fd;

    /* 打开锁文件,不存在则创建 */
    fd = open(LOCK_FILE, O_WRONLY | O_CREAT, 0666);
    if (-1 == fd) {
        perror("open error");
        exit(-1);
    }

    /* 非阻塞方式获取互斥文件锁 */
    if (-1 == flock(fd, LOCK_EX | LOCK_NB)) {
        fputs("不能重复执行该程序!\n", stderr);
        close(fd);
        exit(-1);
    }

    puts("程序运行中...");

    ftruncate(fd, 0);                 /* 截断旧内容 */
    sprintf(str, "%d\n", getpid());
    write(fd, str, strlen(str));      /* 写入 PID */

    for ( ; ; )
        sleep(1);

    exit(0);
}

要点:进程退出或关闭 fd 后文件锁自动释放;flock 是建议性锁,需所有实例都遵守才有效。普通用户对 /var/run/ 可能无写权限,测试时可改用当前目录。


15. 实验步骤与调试方法

15.1 编译运行

gcc -Wall -o proc_demo proc_demo.c

./proc_demo fork
./proc_demo exec
./proc_demo env

15.2 常用调试命令

命令 用途
ps -aux / ps -ef 查看进程信息与 PID
ps -ajx 查看进程组、会话、TTY、状态(守护进程 TTY 为 ?
top 动态查看进程、CPU/内存占用
pstree -p 以树状显示进程父子关系
kill -9 <pid> 强制终止进程
size <可执行文件> 查看 text / data / bss 段大小
env 查看当前 shell 环境变量
strace ./app 跟踪系统调用(fork/exec/wait 等)

15.3 实验一:验证 fork 返回值与文件共享

运行 ./proc_demo fork:子进程打印 fork 返回 0、父进程打印子进程 PID。再写两个程序分别测试"open 后 fork"(接续写)与"fork 后各自 open"(覆盖写),对照第 5.2 节的结论。

15.4 实验二:观察僵尸进程

./proc_demo za &
ps -aux | grep proc_demo

预期看到子进程 STAT / 状态列为 Z(zombie)。它无法被 kill -9 杀死;kill 掉其父进程后,僵尸进程被 init 接管清理。

15.5 实验三:验证孤儿进程

父进程先退出(休眠较短),子进程后退出(休眠较长)并在退出前打印 getppid(),观察其变为 init(字符界面下为 1)或图形界面下的收养进程。

15.6 实验四:验证守护进程脱离终端

./proc_demo daemon
ps -ajx | grep proc_demo     # TTY 应为 ?
# 关闭当前终端后重新打开,进程依然存在

对比:普通前台进程在终端关闭后会随会话收到 SIGHUP 而终止。


16. 跨平台对比:IMX6ULL vs STM32 vs RK3568

维度 I.MX6ULL(本教程) STM32(裸机/HAL) RK3568(Linux)
进程模型 多进程,完整 fork/exec/wait 无进程概念,单主循环 + 中断 同 I.MX6ULL
并发方式 进程 + 线程 中断、状态机、RTOS 任务 进程 + 线程
内存模型 虚拟地址空间 + MMU(3:1) 直接物理地址,无 MMU(部分带 MPU) 虚拟地址空间 + MMU
环境变量 environ/getenv/setenv 无(编译期宏配置) 同 I.MX6ULL
可执行文件格式 ELF .hex/.bin(直接烧写 Flash) ELF
守护进程 标配,setsid + /var/run/*.pid 无对应概念(RTOS 常驻任务) 同 I.MX6ULL
回收子进程 wait/waitpid,防僵尸 同 I.MX6ULL
调试工具 ps/top/pstree/strace/size IDE 调试 + 逻辑分析仪 同 I.MX6ULL

要点:

  • STM32 裸机没有进程、虚拟内存、环境变量与 exec,它用 RTOS 任务或中断模拟"并发",资源由链接脚本静态规划。I.MX6ULL 与 RK3568 都是 Linux,本篇 API 通用。
  • 嵌入式设备上写守护进程,要注意 /var/run/ 的读写权限与根文件系统是否为只读;只读根文件系统时应改到可写分区(如 /tmp/run)或 tmpfs。
  • I.MX6ULL 这类板子资源有限(内存 512MB 级别),滥用 fork() 复制大量内存会显著增加内存压力,必要时应考虑线程方案(见下一篇线程同步)。

17. 面试精选(5 题)

Q1:fork() 为什么"返回两次"?父子进程返回值分别是什么?

答案fork() 会创建父进程的一个副本,调用完成后系统中存在两个进程,且都从 fork() 返回处继续执行,所以返回两次。父进程返回子进程的 PID(正数),用于标识它创建了哪个子进程;子进程返回 0,因为它不需要自己的 PID(可用 getpid() 获取);失败时仅在父进程返回 -1 并设置 errno,不创建子进程。程序用返回值区分父子:case 0 是子进程分支,default 是父进程分支。子进程拷贝父进程的数据段、堆、栈并继承文件描述符,但共享只读代码段。

Q2:fork()vfork() 有什么区别?为什么现在不推荐 vfork()

答案:两者都创建子进程、返回值相同,区别有两点:① vfork() 不复制父进程地址空间,子进程在 exec/_exit 前直接在父进程空间中运行并共享内存;fork() 复制(现代内核用写时复制优化)。② vfork() 保证子进程先运行,子进程 exec 后父进程才可能被调度;fork() 父子执行顺序不确定,存在竞争条件。不推荐 vfork() 是因为:它共享地址空间,子进程若修改父进程数据、调用函数或未 exec/_exit 就返回,行为未定义,容易产生难以察觉的 bug;而现代 fork() 用写时复制后效率已大幅提高,除非速度绝对重要,否则应用 fork()

Q3:什么是僵尸进程和孤儿进程?怎么处理?

答案僵尸进程是子进程已终止、但父进程尚未调用 wait() 收尸的进程,它已释放大部分资源,只剩退出状态等信息留在内核进程表中;无法被任何信号(包括 SIGKILL)杀死,只能由父进程 wait() 回收,或父进程退出后由 init 接管回收;大量僵尸会填满内核进程表阻碍新进程创建。孤儿进程是父进程先于子进程退出,子进程被 init(PID 1)或图形界面下的收养进程接管,getppid() 返回 1(或收养者 PID)。处理方案:父进程必须监视子进程,用 wait()/waitpid() 回收;更优雅的是捕获 SIGCHLD,在处理函数中循环 while (waitpid(-1, NULL, WNOHANG) > 0) continue; 一次清理所有已终止子进程。

Q4:exit()_exit() 有什么区别?为什么子进程一般用 _exit() 退出?

答案exit() 是 C 库函数,_exit() 是系统调用。exit() 最终也会通过 _exit() 终止进程,但在此之前会:① 调用 atexit() 注册的终止处理函数;② 刷新 stdio 流缓冲区。_exit() 则直接终止,不做这些。子进程一般用 _exit() 的原因是:fork() 会复制父进程的 stdio 缓冲区,若子进程调用 exit() 刷新缓冲区,会把从父进程复制来的、尚未刷新的数据重复输出(典型现象:printf("Hello World!") 不带换行符时被打印两次);而且 vfork 产生的子进程调用 exit() 还会刷新并关闭父进程的 stdio 缓冲区。因此约定子进程用 _exit()、父进程用 exit()

Q5:如何编写一个守护进程?为什么 setsid() 前必须先 fork()

答案:标准步骤:① fork() 子进程并让父进程 exit();② 子进程调用 setsid() 创建新会话、脱离控制终端;③ chdir("/") 避免占用可卸载文件系统;④ umask(0) 获得最大文件权限;⑤ 关闭所有继承的文件描述符;⑥ 把 0/1/2 重定向到 /dev/null;⑦ 将 SIGCHLD 设为 SIG_IGN,免去回收子进程的负担。之所以 setsid() 前要 fork(),是因为 setsid() 要求调用者不是进程组组长(否则返回失败);刚 fork() 出来的子进程继承了父进程的进程组 ID,但拥有自己独立的 PID,因此它不是组长,满足 setsid() 的调用条件。setsid() 后子进程成为新会话首领、新进程组组长,pid == gid == sid,且新会话没有控制终端。工程上还常用 double-fork:再 fork 一次确保最终进程不是会话首领,从此无法重新获得控制终端。


内容来源:《I.MX6U嵌入式Linux C应用编程指南》第九章 进程