title: 进程管理 tags: [嵌入式Linux, Linux应用编程, 进程, fork, exec, 环境变量, 虚拟地址空间, 僵尸进程, 守护进程, 单例模式, IMX6ULL] created: 2026-09-18 updated: 2026-09-18
💡 关联知识:[[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/僵尸/孤儿)、进程之间是什么关系(进程组/会话),最后落地两个工程必备技能:守护进程与单例模式运行。
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()。
进程终止分正常终止与异常终止:
| 类别 | 方式 |
|---|---|
| 正常终止 | main() 中 return 返回;调用 exit();调用 _exit() 或 _Exit() |
| 异常终止 | 调用 abort();进程接收到某个信号(如 SIGKILL) |
#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() */
}
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() 区分父子进程、给父进程发信号、排查进程关系时都会用到。
每个进程都有一组环境变量,以字符串形式存储在字符串数组(环境列表)中,每个字符串是 name=value 形式,即"名称-值"成对集合。shell 下用 env 查看,用 export 添加/删除。
export LINUX_APP=123456 # 添加环境变量
export -n LINUX_APP # 删除环境变量
进程的环境变量从父进程继承而来:在 shell 下执行应用,该进程的环境变量就是从 shell 进程继承来的。新进程在创建之前,会继承父进程的环境变量副本。
环境变量存放在字符串数组中,全局变量 environ 指向它,声明即可使用:
extern char **environ;
int main(void)
{
int i;
for (i = 0; NULL != environ[i]; i++)
puts(environ[i]);
return 0;
}
通过元素是否为 NULL 判断数组是否到末尾。
#include <stdlib.h>
char *getenv(const char *name);
| 参数 | 含义 |
|---|---|
name |
要获取的环境变量名称 |
| 返回值 | 含义 |
|---|---|
| 存在 | 返回该环境变量值字符串的指针 |
| 不存在 | 返回 NULL |
注意:不应该修改 getenv() 返回的字符串,修改它意味着修改环境变量的值。需要修改应使用下面提供的函数。
#include <stdlib.h>
int putenv(char *string);
int setenv(const char *name, const char *value, int overwrite);
putenv():参数 string 是 name=value 形式的字符串;成功返回 0,失败返回非 0 并设置 errno。
重要细节:
putenv()会让environ中某元素直接指向该字符串,而不是副本。因此不能随意修改string指向的内容,string也不应是栈上分配的自动变量。
setenv():name 为变量名,value 为值,overwrite 决定变量已存在时是否覆盖。
| 参数 | 含义 |
|---|---|
name |
需要添加或修改的环境变量名称 |
value |
环境变量的值 |
overwrite |
环境变量已存在时:为 0 不改变现有值(本次调用无影响);非 0 则覆盖;不存在时都表示添加 |
| 返回值 | 含义 |
|---|---|
| 成功 | 返回 0 |
| 失败 | 返回 -1,并设置 errno |
setenv() 与 putenv() 的两个区别:
putenv() 不为 name=value 字符串分配内存;setenv() 会分配缓冲区并复制。setenv() 可通过 overwrite 控制"仅添加不覆盖",putenv() 无法控制。因此推荐使用 setenv(),用自动变量作参数也不会有问题。
#include <stdlib.h>
int unsetenv(const char *name); /* 从环境变量表移除 name */
int clearenv(void); /* 清空所有环境变量 */
也可以直接把全局变量赋值为 NULL 来清空:
environ = NULL;
clearenv() 内部的做法其实就是把 environ 赋值为 NULL。
内存泄漏提醒:
setenv()会为环境变量分配内存缓冲区;clearenv()并不知晓这些缓冲区的存在,无法释放它们。反复调用这两个函数的程序会不断产生内存泄漏。
执行程序时可在路径前直接以 name=value 形式添加环境变量,多个之间用空格分隔:
NAME=value ./app
环境变量常见用途之一是在 shell 中:HOME 表示用户家目录,USER 表示当前用户名,SHELL 表示 shell 解析器名称,PWD 表示当前所在目录。自己的应用程序同样可以使用进程环境变量(比如读取配置文件路径、日志级别等)。
历史沿袭至今,C 语言程序一直由以下几部分组成:
| 段 | 名称 | 内容 | 特性 |
| -- | ---- | ---- | ---- |
| 正文段 | 代码段 | CPU 执行的机器语言指令 | 只读,防止意外修改;可被多个进程共享 |
| 初始化数据段 | 数据段 | 显式初始化的全局变量和静态变量 | 加载时从可执行文件读取值 |
| 未初始化数据段 | bss 段 | 未显式初始化的全局变量和静态变量 | 程序执行前系统将其全部初始化为 0;可执行文件只记录位置与大小 |
| 栈 | stack | 函数内局部变量、函数调用保存的信息、实参、返回值 | 动态增长收缩,由栈帧组成 |
| 堆 | heap | 运行时动态分配的内存(如 malloc) | 可在运行时动态分配 |
bss 一词来源于早期汇编操作符,意思是"由符号开始的块"(block started by symbol)。
size 命令可查看二进制可执行文件的文本段、数据段、bss 段大小:
size app
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
这只是便于说明的典型方式,并不要求具体实现一定如此安排。
Linux 采用虚拟内存管理技术:每个进程都在自己独立的地址空间中运行。32 位系统中,每个进程的逻辑地址空间均为 4GB,按 3:1 比例分配:
虚拟地址通过硬件 MMU(内存管理单元) 映射到实际物理地址。建立映射后,对虚拟地址的读写就是对物理地址的读写。因此应用程序中读写的 0x80800000 并不对应硬件物理地址 0x80800000。
若没有虚拟地址机制,所有应用直接访问物理地址,会出现:
引入虚拟地址后带来的优点:
#include <unistd.h>
pid_t fork(void);
一个现有进程调用 fork() 创建新进程:调用者称为父进程,被创建者称为子进程。
理解 fork() 的关键:调用完成后存在两个进程,各自从 fork() 的返回处继续执行,因此 fork() 会返回两次——子进程返回一个值,父进程返回一个值。
| 返回位置 | 返回值 | 含义 |
|---|---|---|
| 父进程 | 子进程的 PID | 调用成功 |
| 子进程 | 0 | 调用成功 |
| 父进程 | -1(并设 errno) |
调用失败,不创建子进程 |
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(进程控制块),会被内核同等调度执行,参与到系统进程调度中。
调用 fork() 后,子进程获得父进程所有文件描述符的副本(创建方式类似 dup()),意味着父子进程对应的文件描述符指向相同的文件表,进而指向磁盘中相同的文件。因此:
O_APPEND:每次写入都从文件末尾接续。对应地,如果父进程 fork() 之后父子进程各自 open() 同一个文件,则两个文件描述符指向不同的文件表,各自有独立的偏移量,一方修改偏移量不影响另一方,写入数据会出现覆盖。
| 场景 | 文件描述符 | 文件表 | 偏移量 | 写入效果 |
|---|---|---|---|---|
open 后 fork |
子进程继承副本 | 同一份 | 共享 | 接续写 |
fork 后各自 open |
各进程独立 | 各一份 | 独立 | 可能覆盖 |
fork() 创建子进程处理请求,父进程继续等待下一个请求。fork() 创建子进程,子进程立即调用 exec 族函数去执行 app2,从 app2 的 main() 开始运行。#include <sys/types.h>
#include <unistd.h>
pid_t vfork(void);
fork() 会复制父进程数据段、堆、栈的大量内容,代价较大。若子进程 fork() 后立刻 exec,父进程的数据根本用不上,浪费时间和效率。vfork() 就是为"子进程立即执行 exec"这一场景专门设计的,效率高于 fork()。
vfork() 与 fork() 的两个主要区别:
exec 或 _exit 之前,在父进程的空间中运行、共享父进程内存。若子进程修改了父进程数据(除 vfork 返回值变量)、进行函数调用,或没有调用 exec/_exit 就返回,可能带来未知结果。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 或退出。
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: 被唤醒, 继续执行父进程工作
进程可以通过 fork() 或 vfork() 创建子进程。Linux 下所有进程都由其父进程创建:在 shell 终端执行 ./app,app 进程就是 shell 终端进程创建出来的。
最原始的父进程是 init 进程(PID 为 1):它是系统启动后运行的第一个进程,由内核启动,理论上没有父进程,管理着系统上所有其它进程。
进程的正常终止有多种方式(return、exit()、_exit()、_Exit()),异常终止也有多种(abort()、收到信号)。
_exit() / exit() 的 status 参数定义终止状态,父进程可用 wait() 获取。虽然 status 是 int,但仅有低 8 位表示终止状态。一般终止状态为 0 表示成功终止,非 0 表示执行中出现错误(如文件打开失败、读写失败)。
程序里一般使用 exit() 库函数而非 _exit() 系统调用。exit() 最终也会通过 _exit() 终止进程,但在此之前会完成:
atexit() 注册的);_exit() 系统调用。父子进程不应都使用 exit() 终止,只能一个用 exit()、另一个用 _exit()(一般子进程用 _exit()、父进程用 exit())。原因是 exit() 会刷新 stdio 缓冲区。
printf("Hello World!"); /* 注意:没有 \n */
fork();
/* 父进程和子进程都 exit(0) */
结果 "Hello World!" 被打印了两次。原因:
fork() 创建子进程时会复制这些缓冲区;printf() 时不会立即刷新;fork() 后子进程拷贝了含数据的缓冲区,父子调用 exit() 时都刷新各自缓冲区,于是打印两次。避免重复输出的方法:
printf 加 \n,或用会自动加换行符的 puts());fork() 前用 fflush() 刷新 stdio 缓冲区,或用 setvbuf() / setbuf() 关闭缓冲;_exit() 退出而非 exit(),退出时不刷新 stdio 缓冲区。很多应用设计中,父进程需要知道子进程何时终止及终止状态,并回收其资源。
#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 宏转换。
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 手册。
父进程与子进程生命周期往往不同,由此产生两种特殊进程。
孤儿进程:父进程先于子进程结束,子进程成为"孤儿"。Linux 中所有孤儿进程都自动成为 init 进程(PID 1)的子进程,getppid() 返回 1(图形界面环境下可能被 upstart 等进程收养,返回其它 PID;字符界面下为 init)。
僵尸进程:子进程先于父进程结束,而父进程还未来得及收尸,子进程就变成僵尸进程。父进程调用 wait()(或其变体)后,僵尸进程被内核彻底删除。如果父进程没有调用 wait() 就退出,init 进程会接管其子进程并自动调用 wait(),从而移除僵尸进程。
要点:
SIGKILL 也不行;wait() 回收。当发生以下情况时,父进程会收到 SIGCHLD 信号:
子进程终止是异步事件,父进程不能一直 wait() 阻塞或轮询,否则无法处理自己的事。SIGCHLD 默认处理方式是忽略,因此要捕获它、绑定处理函数,在处理函数中调用 wait() 收回子进程。
关键技巧:调用信号处理函数时会暂时把引发调用的信号加入进程信号掩码(除非 SA_NODEFER)。若在处理一个终止子进程时相继有两个子进程终止,即使产生两次 SIGCHLD,父进程也只能捕获一次,处理函数每次只调用一次 wait() 就会有"漏网之鱼"。
因此 SIGCHLD 处理函数的惯用写法是循环以非阻塞方式调用 waitpid(),直到再无终止的子进程:
while (waitpid(-1, NULL, WNOHANG) > 0)
continue;
返回 0 表示再无僵尸进程,返回 -1 表示有错误发生。并且应在创建任何子进程之前为 SIGCHLD 绑定处理函数。
stateDiagram-v2
[*] --> 就绪态: 创建进程
就绪态 --> 运行态: 被 CPU 调度
运行态 --> 就绪态: 时间片到
运行态 --> 等待态: 等待资源/IO (浅度/深度睡眠)
等待态 --> 就绪态: 条件成立
运行态 --> 暂停态: SIGSTOP / SIGTSTP
暂停态 --> 就绪态: SIGCONT
运行态 --> 僵尸态: 进程结束但父进程未收尸
僵尸态 --> [*]: 父进程 wait 回收
当子进程的工作不是运行父进程的代码段,而是运行另一个新程序时,用 exec 函数。
#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);
| 参数 | 含义 |
|---|---|
filename |
需要载入当前进程空间的新程序路径名,绝对路径或相对路径均可 |
argv |
传给新程序的命令行参数,字符串指针数组,以 NULL 结束,对应 main 的 argv;argv[0] 是新程序自身路径名 |
envp |
新程序的环境变量列表,字符串指针数组,以 NULL 结束,字符串格式为 name=value;对应新程序的 environ |
| 返回值 | 含义 |
|---|---|
| 成功 | 永不返回 |
| 失败 | 返回 -1,并设置 errno |
execve() 把新程序加载到进程内存空间,用新程序替换旧程序:栈、数据、堆会被新程序的相应部件替换,然后从新程序的 main() 开始执行。对它的成功调用永不返回,也无需检查返回值——一旦返回就表明发生错误。
execve() 是系统调用,也是 exec 族中的一员。基于它还提供了一系列库函数:execl()、execlp()、execle()、execv()、execvp()、execvpe()。通常把用这些函数加载外部新程序的过程称为 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);
可以直接在子进程分支写代码,但不够灵活、扩展性不够好。把子进程要运行的程序单独做成一个可执行文件,由 fork() + exec() 组合运行,结构更清晰。这也是 fork 的第二种使用场景:子进程从 fork() 返回后立即调用 exec 族函数。
#include <stdlib.h>
int system(const char *command);
| 参数 | 含义 |
|---|---|
command |
需要执行的 shell 命令字符串,如 "ls -al"、"echo HelloWorld" |
system() 内部通过 fork()、execl()、waitpid() 实现:先 fork() 创建子进程运行 shell,再由 shell 执行 command 指定的命令。
返回值:
command 为 NULL:shell 可用返回非 0,不可用返回 0;_exit(127) 终止;command 的 shell 进程的终止状态。优点是用起来方便,无需自己处理 fork、exec、waitpid、exit 等细节;代价是效率低:至少要创建两个进程(一个跑 shell,一个或多个跑解析出的命令),每条命令都会调用一次 exec。对效率有要求的程序不建议直接使用。
| 状态 | 含义 |
|---|---|
| 就绪态(Ready) | 满足被 CPU 调度的所有条件但尚未被调度执行,得到 CPU 就能直接运行 |
| 运行态 | 当前正在被 CPU 调度运行 |
| 僵尸态 | 进程已结束,但父进程还未给它"收尸"(即僵尸进程) |
| 可中断睡眠状态 | 浅度睡眠,可被信号唤醒 |
| 不可中断睡眠状态 | 深度睡眠,无法被信号唤醒,只能等条件成立 |
| 暂停态 | 进程暂停运行(不是终止),一般由信号暂停(如 SIGSTOP),可用 SIGCONT 恢复 |
浅度睡眠与深度睡眠统称为等待态(阻塞态):表示进程在等待某种条件成立后进入就绪态,处于等待态的进程无法参与系统调度。新创建的进程处于就绪态。
每个进程都有唯一的 PID,也有自己的生命周期与父进程,于是形成以 init 为根的进程家族树。子进程终止时父进程会得到通知并取得退出状态。进程间关系主要包括:无关系(相互独立)、父子进程关系、进程组、会话。
每个进程除了 PID、父进程 PID 外,还有进程组 ID(标识属于哪个进程组)。进程组是一个或多个进程的集合,这些进程彼此可能有父子、兄弟关系,或在功能上有联系。
进程组的意义是方便管理:若为了完成一个任务并发运行 100 个进程,没有进程组就要逐个终止;有了进程组,把这些进程设为同一组,终止整个进程组即可。
关于进程组:
默认情况下,新进程继承父进程的进程组 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。
会话是一个或多个进程组的集合:
Ctrl+C(SIGINT)、Ctrl+Z(SIGTSTP)、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() 是守护进程的关键一步。
守护进程(Daemon,也称精灵进程)是运行在后台的特殊进程,独立于控制终端,周期性地执行任务或等待处理事件。两个主要特点:
Linux 中大多数服务器就是用守护进程实现的(如 inetd、httpd),作业规划进程 crond 也是。守护进程名通常以 d 结尾。守护进程与终端无关联,自成进程组、自成会话,即 pid = gid = sid。
ps -ajx 查看:TTY 一栏为 ? 表示没有控制终端;COMMAND 用中括号 [] 括起来的是内核线程(在内核里创建,没有用户空间代码,通常以 k 开头表示 Kernel)。
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[守护进程主体循环]
fork() 后 exit()。这样做让 shell 认为命令已执行完毕;同时子进程有自己独立的 PID,保证子进程不是进程组组长,这是调用 setsid() 的先决条件。setsid() 使其创建新会话、成为会话首领与新进程组组长,且新会话没有控制终端。这一步有三个作用:摆脱原会话控制、摆脱原进程组控制、摆脱原控制终端控制。/ 作为守护进程当前目录(也可指定其它目录)。umask(0)。wait() 回收,子进程会变僵尸;若 wait() 又会增加负担、影响并发性能。把 SIGCHLD 处理方式设为 SIG_IGN,可让内核把僵尸进程转交 init 处理,既不产生僵尸进程、又省去回收开销。⚠️ 来源说明:以下"double-fork 双 fork"不属于《I.MX6U嵌入式Linux C应用编程指南》内容,为扩展知识。
标准做法中常再 fork() 一次(double fork):第一次 fork 后父进程退出,子进程 setsid() 成为会话首领;再 fork 一次并让第一个子进程退出,使最终的工作进程既不是会话首领、也不是进程组组长,从而永远无法重新获得控制终端(setsid() 要求调用者不是组长,因此工作进程无法再调用 setsid 获取终端)。教材采用单次 fork + setsid 的方式,对常规场景已经足够。
用户准备退出会话时,系统向该会话发 SIGHUP;会话把 SIGHUP 发给所有子进程,子进程收到后默认终止;当会话中所有进程都退出时,会话终止。
如果程序忽略 SIGHUP(signal(SIGHUP, SIG_IGN)),则不会随终端退出而退出——此时它已成为守护进程。因为控制终端只是会话中的一个进程,只有会话中所有进程退出,会话才结束。
有些程序不允许同时运行多个实例(如服务器、守护进程),这就是单例模式运行。常用两种实现思路。
程序正式运行前先判断特定文件是否存在:存在则说明进程已在运行,立即退出;不存在则创建文件,程序结束时删除。以 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) */
三个明显问题:
_exit() 退出时无法执行删除函数,文件残留;补救(exit() 代替 _exit()、把文件放 /tmp 使其随重启销毁、注册信号处理函数删文件)都不彻底——SIGKILL/SIGSTOP 无法被忽略或捕获。所以这种方法并不靠谱。
程序启动后先打开特定文件(一般用 O_WRONLY | O_CREAT,不存在则创建),然后尝试获取文件锁:
提示:当程序退出或文件关闭后,文件锁会自动解锁。
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 判断进程是否存在等)也可用,但最常用的还是文件锁。
#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>(open、O_RDWR)。完整编译:gcc -Wall -o proc_demo proc_demo.c
fork 分支:atexit() 注册的函数只在父进程 exit() 时被调用(子进程用 _exit 绕过),可对照观察。父进程用 waitpid() 回收并解析退出状态。exec 分支:execlp() 执行 ls -al,p 表示查 PATH,所以无需写 /bin/ls。exec 成功则不返回。env 分支:遍历 environ、getenv 读取、setenv 添加、unsetenv 删除,覆盖环境变量主要 API。za 分支:子进程先退出、父进程死循环不收尸,ps -aux 可看到子进程状态为 Z(zombie)。daemon 分支:严格按 7 步实现守护进程;运行后没有任何输出(已重定向到 /dev/null),用 ps -ajx 查看 TTY 为 ?。#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/ 可能无写权限,测试时可改用当前目录。
gcc -Wall -o proc_demo proc_demo.c
./proc_demo fork
./proc_demo exec
./proc_demo env
| 命令 | 用途 |
|---|---|
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 等) |
运行 ./proc_demo fork:子进程打印 fork 返回 0、父进程打印子进程 PID。再写两个程序分别测试"open 后 fork"(接续写)与"fork 后各自 open"(覆盖写),对照第 5.2 节的结论。
./proc_demo za &
ps -aux | grep proc_demo
预期看到子进程 STAT / 状态列为 Z(zombie)。它无法被 kill -9 杀死;kill 掉其父进程后,僵尸进程被 init 接管清理。
父进程先退出(休眠较短),子进程后退出(休眠较长)并在退出前打印 getppid(),观察其变为 init(字符界面下为 1)或图形界面下的收养进程。
./proc_demo daemon
ps -ajx | grep proc_demo # TTY 应为 ?
# 关闭当前终端后重新打开,进程依然存在
对比:普通前台进程在终端关闭后会随会话收到 SIGHUP 而终止。
| 维度 | 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 |
要点:
/var/run/ 的读写权限与根文件系统是否为只读;只读根文件系统时应改到可写分区(如 /tmp、/run)或 tmpfs。fork() 复制大量内存会显著增加内存压力,必要时应考虑线程方案(见下一篇线程同步)。fork() 为什么"返回两次"?父子进程返回值分别是什么?答案:fork() 会创建父进程的一个副本,调用完成后系统中存在两个进程,且都从 fork() 返回处继续执行,所以返回两次。父进程返回子进程的 PID(正数),用于标识它创建了哪个子进程;子进程返回 0,因为它不需要自己的 PID(可用 getpid() 获取);失败时仅在父进程返回 -1 并设置 errno,不创建子进程。程序用返回值区分父子:case 0 是子进程分支,default 是父进程分支。子进程拷贝父进程的数据段、堆、栈并继承文件描述符,但共享只读代码段。
fork() 和 vfork() 有什么区别?为什么现在不推荐 vfork()?答案:两者都创建子进程、返回值相同,区别有两点:① vfork() 不复制父进程地址空间,子进程在 exec/_exit 前直接在父进程空间中运行并共享内存;fork() 复制(现代内核用写时复制优化)。② vfork() 保证子进程先运行,子进程 exec 后父进程才可能被调度;fork() 父子执行顺序不确定,存在竞争条件。不推荐 vfork() 是因为:它共享地址空间,子进程若修改父进程数据、调用函数或未 exec/_exit 就返回,行为未定义,容易产生难以察觉的 bug;而现代 fork() 用写时复制后效率已大幅提高,除非速度绝对重要,否则应用 fork()。
答案:僵尸进程是子进程已终止、但父进程尚未调用 wait() 收尸的进程,它已释放大部分资源,只剩退出状态等信息留在内核进程表中;无法被任何信号(包括 SIGKILL)杀死,只能由父进程 wait() 回收,或父进程退出后由 init 接管回收;大量僵尸会填满内核进程表阻碍新进程创建。孤儿进程是父进程先于子进程退出,子进程被 init(PID 1)或图形界面下的收养进程接管,getppid() 返回 1(或收养者 PID)。处理方案:父进程必须监视子进程,用 wait()/waitpid() 回收;更优雅的是捕获 SIGCHLD,在处理函数中循环 while (waitpid(-1, NULL, WNOHANG) > 0) continue; 一次清理所有已终止子进程。
exit() 和 _exit() 有什么区别?为什么子进程一般用 _exit() 退出?答案:exit() 是 C 库函数,_exit() 是系统调用。exit() 最终也会通过 _exit() 终止进程,但在此之前会:① 调用 atexit() 注册的终止处理函数;② 刷新 stdio 流缓冲区。_exit() 则直接终止,不做这些。子进程一般用 _exit() 的原因是:fork() 会复制父进程的 stdio 缓冲区,若子进程调用 exit() 刷新缓冲区,会把从父进程复制来的、尚未刷新的数据重复输出(典型现象:printf("Hello World!") 不带换行符时被打印两次);而且 vfork 产生的子进程调用 exit() 还会刷新并关闭父进程的 stdio 缓冲区。因此约定子进程用 _exit()、父进程用 exit()。
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应用编程指南》第九章 进程