【Linux】进程概念全攻略:从 PCB、进程状态到调度与虚拟内存,一篇打通底层逻辑
【Linux】进程概念全攻略:从 PCB、进程状态到调度与虚拟内存,一篇打通底层逻辑
🔥 本文定位:面向已经掌握 Linux 基础命令与开发工具、准备学习系统编程的同学,从硬件与操作系统视角重新认识进程。
💡 学习目标:不仅知道 PID 和fork(),还要理解 PCB、状态转换、上下文切换、环境继承、写时拷贝与虚拟地址空间之间的联系。

文章目录
- 前言
- 一、理解进程之前:先看硬件和操作系统
- 二、操作系统怎样管理进程
- 三、什么是进程:程序运行起来还不够
- 四、fork 初识:一次调用为什么返回两次
- 五、Linux 进程状态:R、S、D、T、Z
- 六、僵尸进程与孤儿进程
- 七、优先级、调度与上下文切换
- 八、命令行参数与环境变量
- 九、进程地址空间与虚拟内存
- 十、综合实战:观察父子进程、环境继承与回收
- 十一、核心知识点与避坑指南
- 总结
前言
刚接触进程时,很多人会形成一个过于简单的认识:
进程 = 正在运行的程序
这句话没有错,却不足以解释下面这些现象:
- 一个程序为什么可以同时启动多次,并拥有不同 PID?
fork()明明只调用一次,为什么父子进程会得到不同返回值?- 进程睡眠时不占用 CPU,内核为什么还能找到并唤醒它?
- 子进程已经退出,为什么
ps中还会出现Z? - 父子进程打印出相同的变量地址,为什么变量值却能不同?
- 单核 CPU 同一时刻只能执行一个任务,多个进程为何看起来同时运行?
要回答这些问题,必须建立一条完整主线:
硬件负责执行指令
↓
操作系统描述并组织进程
↓
调度器决定谁获得 CPU
↓
进程状态记录当前能否运行
↓
虚拟内存提供独立的地址视图
本文示例建议在普通用户的独立目录中执行:
mkdir -p ~/linux-process-lab
cd ~/linux-process-lab
pwd
⚠️ 实验提醒:文中的僵尸进程示例只让状态短暂存在,并在父进程结束前完成回收。不要在生产服务器上批量创建进程、持续制造僵尸或随意调整关键服务的优先级。
一、理解进程之前:先看硬件和操作系统
1.1 冯·诺依曼体系的关键不是“五大部件”
通用计算机通常可以抽象为:
- 输入设备;
- 输出设备;
- 存储器;
- 运算器;
- 控制器。
运算器和控制器共同构成 CPU。学习进程时,更重要的不是背诵名称,而是理解数据流:
磁盘上的程序 ──加载──> 内存 ──取指/读写──> CPU
键盘、网卡等输入 ─────> 内存 ────────────> CPU
CPU 处理结果 ─────────> 内存 ────────────> 输出设备
忽略缓存与 DMA 等细节时,可以先建立一个简化模型:CPU 主要从内存取得指令与数据,程序必须先被加载到内存,才能真正进入执行阶段。
1.2 程序为什么不能随意操作硬件?
如果每个应用都可以直接修改物理内存、控制磁盘和配置网卡,那么一个程序的错误就可能破坏整个系统。操作系统位于应用与硬件之间,负责:
- 管理 CPU、内存、文件和设备;
- 隔离不同程序;
- 检查访问权限;
- 提供稳定的编程接口;
- 在多个任务之间分配资源。
1.3 系统调用和库函数不是一回事
应用程序通常不会直接编写进入内核的底层指令,而是使用库函数:
应用程序
↓
C 标准库 / 其他用户态库
↓
系统调用接口
↓
操作系统内核
↓
硬件
例如 printf() 是 C 标准库函数。它负责格式化输出和维护用户态缓冲区;真正把字节写向终端或文件,最终仍需要内核提供的写入能力。
因此:
库函数:更友好、更可移植的用户态接口
系统调用:进入内核、请求系统服务的受控边界
一个库函数可能使用系统调用,也可能完全在用户态完成;一个系统调用也可能被多个不同库函数封装。
二、操作系统怎样管理进程
2.1 管理的通用方法:先描述,再组织,最后决策
操作系统面对的不是一个进程,而是大量进程、文件、内存页和设备。高效管理通常遵循三步:
- 先描述:用数据结构记录对象属性;
- 再组织:用链表、树、哈希表或队列管理对象;
- 做决策:按照策略完成调度、分配、保护和回收。

以进程为例:
task_struct 描述一个进程
↓
内核数据结构组织大量进程
↓
调度器从可运行任务中选择下一个进程
2.2 “描述起来”为什么这么重要?
程序正在 CPU 上执行时,寄存器中保存了它的运行现场;程序等待磁盘时,内核需要知道它在等待什么;程序退出后,父进程还需要读取它的退出状态。
这些信息不能只存在于“正在运行”这个动作中,而要被持久地记录在内核数据结构里。否则进程一旦离开 CPU,系统就失去了恢复它的依据。
核心设计解读
操作系统管理的对象最终都会落到数据结构上。所谓“创建进程”,不仅是把代码加载到内存,还包括创建内核对象、建立资源关系并把任务加入适当的组织结构;所谓“删除进程”,也不仅是代码不再执行,还包括回收这些管理信息。
三、什么是进程:程序运行起来还不够
3.1 程序与进程的区别
| 对比项 | 程序 | 进程 |
|---|---|---|
| 存在形式 | 磁盘上的文件 | 内核管理的动态执行实体 |
| 是否执行 | 静态,不占用 CPU | 可能运行、等待、停止或退出 |
| 标识方式 | 文件路径、文件内容 | PID、状态、资源关系等 |
| 数量关系 | 一个程序文件 | 可以对应多个进程实例 |
| 生命周期 | 文件存在即可长期保留 | 从创建到退出、回收 |
同一个可执行文件可以启动多次:
./app &
./app &
./app &
ps -o pid,ppid,state,cmd -C app
三个进程执行相同代码,但它们拥有不同 PID、调度状态和地址空间视图。
3.2 从内核视角定义进程
可以先建立一个概念模型:
进程 = 被加载的程序代码和数据
+ 内核维护的 PCB
+ 资源与地址空间关系
PCB 是 Process Control Block,即进程控制块。Linux 使用 task_struct 描述任务。

task_struct 中典型信息可以分为:
| 类别 | 典型内容 | 解决的问题 |
|---|---|---|
| 身份与关系 | PID、线程组、父子关系、凭据 | 它是谁、属于谁 |
| 状态与信号 | 运行状态、退出信息、信号处理 | 它现在处于什么阶段 |
| CPU 上下文 | 寄存器、程序计数器等现场 | 离开 CPU 后怎样恢复 |
| 地址空间 | 代码、堆、栈和映射区域 | 它可以访问哪些虚拟地址 |
| 文件与 I/O | 打开文件、工作目录等 | 它正在使用哪些文件资源 |
| 调度与统计 | 策略、优先级、CPU 时间 | 何时获得处理器 |
💡
task_struct非常庞大,而且字段会随内核版本演进。学习时先掌握信息类别和管理目的,不要把某个版本的完整结构体当成固定 API 背诵。
3.3 PID、PPID 与进程关系
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
printf("pid=%ld, ppid=%ld\n",
(long)getpid(),
(long)getppid());
return 0;
}
编译运行:
gcc -Wall -Wextra pid_demo.c -o pid_demo
./pid_demo
- PID:当前进程标识符;
- PPID:创建或当前托管该进程的父进程标识符。
3.4 通过 ps 和 /proc 观察进程
ps -ef
ps aux
ps -o pid,ppid,state,pri,ni,cmd
top
Linux 还通过 /proc 虚拟文件系统暴露进程信息。假设目标 PID 为 1234:
cat /proc/1234/status
tr '\0' ' ' < /proc/1234/cmdline
readlink /proc/1234/exe
readlink /proc/1234/cwd
ls -l /proc/1234/fd
cat /proc/1234/maps
/proc/1234 不是磁盘上为每个进程永久保存的普通目录,而是内核状态的接口视图。进程退出后,对应 PID 目录也会消失。
四、fork 初识:一次调用为什么返回两次
4.1 fork() 创建新的执行流
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}
printf("pid=%ld, ppid=%ld, fork_ret=%ld\n",
(long)getpid(),
(long)getppid(),
(long)id);
return 0;
}
fork() 成功后,父子进程都会从调用点之后继续执行,因此后面的 printf() 会执行两次。
4.2 三种返回情况

| 返回位置 | fork() 返回值 |
含义 |
|---|---|---|
| 父进程 | 大于 0 |
新创建子进程的 PID |
| 子进程 | 等于 0 |
当前执行流是子进程 |
| 原进程 | 小于 0 |
创建失败,没有产生子进程 |
典型分流写法:
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
} else if (id == 0) {
/* child branch */
} else {
/* parent branch, id is child PID */
}
为什么父进程需要得到子进程 PID?因为父进程后续需要等待、管理或向特定子进程发送信号。
4.3 为什么一个变量能同时满足不同分支?
id 并不是一个被父子进程同时修改的共享普通变量。fork() 返回后,父子进程各自拥有独立的虚拟地址空间视图;它们在各自执行流中接收到不同返回值。
这也解释了下面的现象:
int g_value = 10;
pid_t id = fork();
if (id == 0) {
g_value = 100;
printf("child: value=%d, addr=%p\n", g_value, (void *)&g_value);
} else if (id > 0) {
printf("parent: value=%d, addr=%p\n", g_value, (void *)&g_value);
}
父子进程可能打印出相同的地址数字,却看到不同值。这个地址是虚拟地址,不是可以跨进程直接比较的物理位置。
4.4 写时拷贝让 fork() 不必立即复制全部内存
如果 fork() 一开始就复制父进程全部物理内存,创建成本会很高。实际系统通常使用 Copy-on-Write(COW):
fork 刚完成
↓
父子进程暂时共享只读物理页
↓
任意一方尝试写入
↓
触发保护异常,由内核复制物理页
↓
更新写入方页表,父子从此映射不同物理页
写时拷贝并不表示父子可以随意共享普通变量,而是对“创建后的物理复制时机”进行优化。
核心设计解读
fork() 的三个关键点必须同时理解:
- 父子拥有两个独立执行流;
- 返回值为父子提供分流依据;
- 虚拟内存和写时拷贝在保证隔离的同时降低创建成本。
只记住“调用一次返回两次”,就很难解释变量地址、数据独立和后续进程回收。
五、Linux 进程状态:R、S、D、T、Z
5.1 状态是在回答“当前能不能运行”
进程不可能始终占用 CPU。它可能等待输入、等待磁盘、被调试器暂停,或者已经退出但尚未回收。内核用状态记录当前阶段,并把进程放入相应组织结构。

5.2 常见状态字符
| 状态 | 含义 | 常见场景 |
|---|---|---|
R |
正在运行或处于可运行队列 | 正在占用 CPU,或等待被调度 |
S |
可中断睡眠 | 等待定时器、管道、终端输入等事件 |
D |
不可中断睡眠 | 等待某些关键内核或 I/O 操作完成 |
T |
被作业控制信号停止 | 收到 SIGSTOP 等 |
t |
被调试器跟踪停止 | 断点或跟踪事件 |
Z |
僵尸 | 已退出,等待父进程读取退出状态 |
X |
死亡状态 | 过渡状态,通常难以在用户态观察 |
⚠️
R不等于“此刻一定正在 CPU 上执行”。运行队列中已经具备运行条件、正在等待 CPU 的任务,也可能显示为R。
5.3 S 和 D 有什么区别?
S 是可中断睡眠,进程等待的事件尚未发生,但某些信号可以唤醒或打断等待。大多数正常等待都处于这一类。
D 是不可中断睡眠,进程位于内核中的某段关键等待路径。它通常不能按普通信号语义立即返回用户态处理信号。短暂 D 并不一定异常;如果大量进程长期处于 D,则应排查存储、网络文件系统、设备或内核路径。
5.4 怎样观察状态?
ps -o pid,ppid,state,wchan:24,cmd -p 1234
cat /proc/1234/status
top
ps 的 STAT 列可能出现 S+、Ssl 等组合。第一个大写字符通常是主状态,后面的符号是会话、前台进程组、多线程等附加标记。
5.5 停止与继续
在测试目录运行一个进程:
sleep 300 &
pid=$!
ps -o pid,ppid,state,cmd -p "$pid"
暂停它:
kill -STOP "$pid"
ps -o pid,ppid,state,cmd -p "$pid"
继续执行:
kill -CONT "$pid"
结束实验:
kill "$pid"
wait "$pid" 2>/dev/null
六、僵尸进程与孤儿进程
6.1 僵尸进程为什么存在?
子进程退出时,大部分资源会被释放,但下面的信息必须暂时保留:
- 正常退出码;
- 是否被信号终止;
- 资源使用统计;
- 供父进程确认子任务结果的状态。
父进程通过 wait() 或 waitpid() 读取这些信息。父进程尚未读取时,子进程处于 Z 状态。
子进程退出
↓
释放地址空间、打开文件等大部分资源
↓
保留 PID、退出状态和少量统计信息
↓
父进程 wait / waitpid
↓
剩余内核记录被回收
6.2 僵尸进程的危害应该怎样准确理解?
僵尸进程已经不再执行,也不再保留完整用户态地址空间。它主要占用 PID、进程表项和少量内核信息。
单个短暂僵尸通常不是系统故障,但如果父进程持续创建子进程且从不回收,就可能耗尽可用 PID 或进程表资源,最终导致新进程创建失败。
6.3 为什么 kill -9 对僵尸没有意义?
僵尸已经完成退出,没有可继续终止的执行流。即使向它发送强制终止信号,也不能替代父进程读取退出状态。
正确处理方式是:
- 修复父进程的
wait()/waitpid()逻辑; - 检查父进程是否卡死或忽略了
SIGCHLD; - 在无法修复的情况下,按业务规范重启或终止有缺陷的父进程,使子进程由系统重新托管并回收。
6.4 孤儿进程不是僵尸进程
父进程先退出、子进程仍在运行时,子进程成为孤儿:
孤儿进程:还可能正常运行,只是原父进程提前退出
僵尸进程:已经退出,只等待退出状态被读取
系统会把孤儿重新托管给具有收养职责的进程,例如 PID 1 或配置为 subreaper 的进程。之后由新的父进程负责回收其退出状态。
💡 现代服务管理和容器环境中,收养者不一定简单等同于“传统 init”。判断实际关系时应观察 PPID,而不是死记某一个进程名。
七、优先级、调度与上下文切换
7.1 并发、并行、竞争与独立
| 概念 | 含义 |
|---|---|
| 并发 | 多个任务在一段时间内交替推进 |
| 并行 | 多个任务在多个处理器上同一时刻执行 |
| 竞争 | 多个可运行任务争夺有限 CPU 或其他资源 |
| 独立 | 进程拥有相对独立的地址空间和资源视图 |
单核 CPU 也能并发,因为调度器在多个进程之间快速切换;并行则要求同时存在多个执行单元。
7.2 调度器解决什么问题?
当可运行进程数量大于 CPU 数量时,调度器必须决定:
- 接下来运行谁;
- 允许运行多久;
- 某个进程阻塞或唤醒后放到哪里;
- 怎样兼顾吞吐、交互延迟和公平性;
- 多核环境中怎样分配和迁移任务。
7.3 上下文切换为什么不可避免?

进程 P1 离开 CPU 前,内核需要保存它的程序计数器、栈指针和通用寄存器等现场;进程 P2 被选中后,再恢复 P2 上次保存的现场。
P1 运行
↓ 时间片耗尽、主动阻塞或被抢占
保存 P1 上下文
↓
调度器选择 P2
↓
恢复 P2 上下文
↓
P2 从此前位置继续运行
上下文切换会消耗 CPU 时间,还可能影响缓存和地址转换缓存。因此“创建更多进程”不一定提升性能,关键要看任务是 CPU 密集、I/O 密集,还是受其他资源限制。
7.4 PRI 和 NI 应该怎样理解?
ps -o pid,ppid,pri,ni,state,cmd
nice -n 10 ./app
renice 5 -p 1234
PRI:工具显示的调度优先级信息,具体语义与调度策略有关;NI:传统 nice 值,用于影响普通任务的调度竞争倾向;- nice 值常见范围是
-20到19; - nice 越大,通常越“谦让”;降低 nice 值、提高竞争优先级通常需要更高权限。
不要把下面的写法当成所有现代内核上的严格公式:
PRI(new) = PRI(old) + nice
它可以帮助入门理解“nice 会影响优先级”,但实际调度行为还取决于调度类别、内核实现、系统负载和 CPU 拓扑。
7.5 Linux 2.6 O(1) 调度器:适合学思想,不代表当前全貌
早期 Linux 2.6 使用过 O(1) 调度器。其经典模型包括:
每个 CPU 一条运行队列
↓
active:时间片尚未耗尽的任务
expired:时间片耗尽、等待重新分配的任务
↓
优先级数组 + 位图快速找到非空队列
↓
active 耗尽后交换 active / expired 指针
它说明了两个重要思想:
- 进程必须被组织进可高效查找的数据结构;
- 调度策略与查找机制可以分开设计。
但 O(1) 调度器是历史实现。现代通用 Linux 的调度器已经多次演进,内部结构和策略不能再用这张历史模型完整描述。学习内核源码时一定要先确认目标版本。
八、命令行参数与环境变量
8.1 argc 和 argv
#include <stdio.h>
int main(int argc, char *argv[])
{
for (int i = 0; i < argc; ++i) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
运行:
./args_demo hello Linux 42
argc:参数个数;argv:字符串指针数组;argv[0]:通常是启动程序时使用的名称或路径;argv[argc]:空指针,用于标记数组结束。
命令行选项本质上也是字符串。-l、--help 之所以具有特殊含义,是因为程序自己解析并定义了它们。
8.2 常见环境变量
| 变量 | 作用 |
|---|---|
PATH |
Shell 查找可执行命令的目录列表 |
HOME |
当前用户家目录 |
SHELL |
登录 Shell 路径 |
PWD |
当前工作目录 |
LANG |
语言、字符编码与区域设置 |
echo "$PATH"
printenv HOME
env
8.3 Shell 变量与环境变量
MY_ENV="hello"
这会创建当前 Shell 变量,但普通子进程通常还看不到它:
export MY_ENV
或者一步完成:
export MY_ENV="hello"
删除变量:
unset MY_ENV
8.4 环境变量怎样传给子进程?

创建子进程时,子进程获得创建时环境的副本。它可以修改自己的环境,但这种普通继承是单向的:
父进程环境 ──创建子进程──> 子进程环境副本
子进程修改自己的环境 ──X──> 不会自动反向修改父进程
这就是为什么脚本中执行 cd 或修改变量,通常不会改变调用它的父 Shell;而使用 source script.sh 时,命令是在当前 Shell 环境中执行,效果不同。
8.5 在 C 程序中读取环境变量
读取特定变量:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
const char *value = getenv("MY_ENV");
printf("MY_ENV=%s\n", value ? value : "(unset)");
return 0;
}
遍历环境表:
#include <stdio.h>
extern char **environ;
int main(void)
{
for (char **item = environ; *item != NULL; ++item) {
puts(*item);
}
return 0;
}
环境表可以概念化为:
environ
├──> "PATH=/usr/local/bin:/usr/bin:/bin\0"
├──> "HOME=/home/alice\0"
├──> "LANG=zh_CN.UTF-8\0"
└──> NULL
8.6 为什么自己的程序要写 ./app?
Shell 收到不含斜杠的命令名时,会按照 PATH 中的目录顺序搜索:
printf '%s\n' "$PATH" | tr ':' '\n'
当前目录通常不在 PATH 中,所以:
app # 在 PATH 中找名为 app 的程序
./app # 明确执行当前目录下的 app
不建议把 . 放到 PATH 前部。否则当前目录中的同名恶意程序可能抢在系统命令之前执行。
九、进程地址空间与虚拟内存
9.1 “程序地址空间”为什么不够准确?
磁盘上的程序文件本身没有正在扩展的堆、活动调用栈和运行时映射。真正拥有这些内容的是运行中的进程,因此更准确的说法是“进程虚拟地址空间”。
一个典型用户态地址空间可以概念化为:
高地址
+-------------------------+
| 命令行参数、环境变量、栈 |
| mmap 区域、共享库 |
| 空闲区域 |
| 堆 |
| BSS:未初始化全局数据 |
| Data:已初始化全局数据 |
| Text:代码与只读数据 |
+-------------------------+
低地址
具体布局受体系结构、链接方式、共享库和 ASLR 等影响,不能把示意图中的顺序和比例当成固定物理地址。
9.2 C 对象通常位于哪里?
#include <stdio.h>
#include <stdlib.h>
int g_uninitialized;
int g_initialized = 10;
int main(int argc, char *argv[])
{
static int static_value = 20;
int local_value = 30;
int *heap_value = malloc(sizeof(*heap_value));
printf("code : %p\n", (void *)main);
printf("initialized : %p\n", (void *)&g_initialized);
printf("uninitialized : %p\n", (void *)&g_uninitialized);
printf("static : %p\n", (void *)&static_value);
printf("heap : %p\n", (void *)heap_value);
printf("stack : %p\n", (void *)&local_value);
printf("argv[0] : %p\n", (void *)argv[0]);
free(heap_value);
return argc == 0;
}
这段程序用于观察区域相对关系,不要依赖某次输出的具体地址。启用 ASLR 后,不同运行之间的地址很可能变化。
9.3 相同虚拟地址为什么能保存不同数据?

父子进程都可能把 g_value 显示为虚拟地址 0x4000,但各自页表可以建立不同映射:
父进程:VA 0x4000 → 物理页 A → g_value = 10
子进程:VA 0x4000 → 物理页 B → g_value = 100
因此“地址数字相同”只表示它们位于各自虚拟地址空间中的相同位置,不表示最终访问同一物理字节。
9.4 页表和 MMU 做什么?
CPU 生成虚拟地址后,硬件内存管理单元(MMU)依据页表完成地址转换和权限检查:
虚拟页号 + 页内偏移
↓
查询页表 / 地址转换缓存
↓
物理页号 + 同一页内偏移
↓
访问物理内存
如果映射不存在或访问权限不满足,CPU 会触发异常,由内核判断:
- 是否可以按需分配物理页;
- 是否需要从文件或交换空间加载数据;
- 是否属于写时拷贝;
- 是否是非法访问,应向进程发送信号。
9.5 mm_struct 与 VMA
Linux 使用内存描述结构记录进程地址空间,VMA(Virtual Memory Area)描述具有相同属性的一段虚拟地址区间,例如:
- 代码与只读数据;
- 可写数据;
- 堆和栈;
- 共享库;
- 文件映射;
- 匿名映射。
task_struct
↓ mm
mm_struct:描述整个用户态地址空间
↓
多个 VMA:分别描述一段连续虚拟地址区间及其权限、映射来源
教材中常用链表、红黑树解释 VMA 的组织。不同内核版本的数据结构会演进,但核心职责不变:快速回答某个虚拟地址是否合法、具有什么权限、对应何种映射。
可以直接观察当前 Shell 的映射:
cat /proc/$$/maps
pmap $$
9.6 为什么一定要有虚拟内存?
如果程序直接操作物理地址,会面临:
- 一个进程可能随意破坏其他进程或内核;
- 程序每次装载位置不确定,链接和运行困难;
- 程序必须关心物理内存碎片和真实分布;
- 难以按需分配和延迟加载;
- 难以高效实现共享库、文件映射和写时拷贝。
虚拟内存带来的主要价值:
- 隔离与保护:进程不能仅凭普通用户态指针访问其他进程物理内存;
- 统一视图:每个进程看到近似连续、规则的虚拟地址布局;
- 管理解耦:进程管理不必依赖物理页的实际位置;
- 按需分配:申请虚拟空间不代表立即获得全部物理页;
- 高效共享:多个进程可以把不同虚拟地址映射到同一物理页;
- 写时拷贝:父子先共享,真正写入时再复制。
核心设计解读
虚拟地址空间不是“凭空制造更多内存”,而是一层受内核和硬件共同维护的地址抽象。它把进程使用内存的方式与物理页实际位置解耦,同时提供权限边界。交换空间和文件映射可以扩展后备存储能力,但不能让系统无成本地突破物理资源限制。
十、综合实战:观察父子进程、环境继承与回收
下面用一个程序同时验证:
- PID 与 PPID;
- 命令行参数;
- 环境变量继承;
fork()的返回值;- 父子变量的虚拟地址和值;
waitpid()回收和退出状态。
10.1 编写实验程序
process_lab.c:
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static int g_value = 10;
int main(int argc, char *argv[])
{
setvbuf(stdout, NULL, _IONBF, 0);
const char *lab_env = getenv("PROCESS_LAB_ENV");
printf("before fork: pid=%ld, ppid=%ld\n",
(long)getpid(),
(long)getppid());
printf("argc=%d, argv[0]=%s, env=%s\n",
argc,
argv[0],
lab_env ? lab_env : "(unset)");
pid_t child = fork();
if (child < 0) {
perror("fork");
return 1;
}
if (child == 0) {
g_value = 100;
printf("child : pid=%ld, ppid=%ld, fork_ret=0, "
"value=%d, addr=%p\n",
(long)getpid(),
(long)getppid(),
g_value,
(void *)&g_value);
_exit(7);
}
printf("parent: pid=%ld, child=%ld, fork_ret=%ld, "
"value=%d, addr=%p\n",
(long)getpid(),
(long)child,
(long)child,
g_value,
(void *)&g_value);
int status = 0;
if (waitpid(child, &status, 0) == -1) {
perror("waitpid");
return 1;
}
if (WIFEXITED(status)) {
printf("reaped child=%ld, exit_code=%d\n",
(long)child,
WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("reaped child=%ld, signal=%d\n",
(long)child,
WTERMSIG(status));
}
return 0;
}
10.2 编译运行
gcc -Wall -Wextra -g -O0 process_lab.c -o process_lab
export PROCESS_LAB_ENV="inherited-from-shell"
./process_lab alpha beta
可能看到:
before fork: pid=4200, ppid=1800
argc=3, argv[0]=./process_lab, env=inherited-from-shell
parent: pid=4200, child=4201, fork_ret=4201, value=10, addr=0x...
child : pid=4201, ppid=4200, fork_ret=0, value=100, addr=0x...
reaped child=4201, exit_code=7
父子输出顺序可能不同,这是正常调度结果。真正稳定的结论是:
- 父进程获得子进程 PID,子进程获得
0; - 子进程继承创建时的环境变量;
- 父子看到的
&g_value数字可能相同; - 子进程修改
g_value后,父进程仍保持原值; - 父进程通过
waitpid()读取到退出码7。
10.3 为什么使用 _exit()?
fork() 会复制用户态缓冲区状态。如果父子进程都调用 exit(),某些在 fork() 前尚未刷新的缓冲内容可能被重复刷新。
示例先用 setvbuf() 关闭 stdout 缓冲,再让子进程调用 _exit(),目的是让“进程创建和回收”成为观察重点。
10.4 一个可控的僵尸状态实验
zombie_demo.c:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
pid_t child = fork();
if (child < 0) {
perror("fork");
return 1;
}
if (child == 0) {
_exit(42);
}
printf("parent=%ld, child=%ld\n",
(long)getpid(),
(long)child);
printf("child remains zombie for about 10 seconds\n");
sleep(10);
int status = 0;
if (waitpid(child, &status, 0) == -1) {
perror("waitpid");
return 1;
}
printf("child reaped, exit_code=%d\n",
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
return 0;
}
终端一:
gcc -Wall -Wextra zombie_demo.c -o zombie_demo
./zombie_demo
终端二,在 10 秒内使用程序输出的 PID:
ps -o pid,ppid,state,cmd -p <parent-pid>,<child-pid>
回收前子进程显示 Z;父进程执行 waitpid() 后,对应 PID 记录消失。这个实验会自动完成回收,不会留下长期僵尸。
实战设计解读
综合实验把多个抽象概念映射到可观测证据:
| 概念 | 可观测证据 |
|---|---|
| 进程身份 | getpid()、getppid() |
| 父子分流 | fork() 返回值不同 |
| 环境继承 | 子进程可读取 PROCESS_LAB_ENV |
| 地址独立 | 地址数字可能相同,但变量值互不影响 |
| 退出状态 | _exit(7) 与 WEXITSTATUS(status) |
| 僵尸回收 | waitpid() 前为 Z,回收后 PID 消失 |
学习操作系统最有效的方式之一,就是把“结构和机制”转换成可重复观察的现象,而不是只记结论。
十一、核心知识点与避坑指南
11.1 进程等于可执行文件吗?
不等于。可执行文件是进程的代码与初始数据来源之一;进程还包含 PCB、地址空间、打开文件、调度状态和其他运行时资源。
11.2 R 状态是不是一定在 CPU 上运行?
不是。它也可能已经位于可运行队列中,具备执行条件但正在等待 CPU。
11.3 为什么 fork() 后不能依赖输出顺序?
父子进程都是独立可运行任务,谁先获得 CPU 由调度器决定。即使某次实验总是父进程先打印,也不能把它当成程序保证。
11.4 fork() 后父子是不是共享所有变量?
普通用户态变量通常位于各自虚拟地址空间中。开始时底层物理页可能因 COW 暂时共享,但一方写入后会获得独立物理页。需要真正共享数据,应使用管道、共享内存、套接字等进程间通信机制。
11.5 僵尸进程还能被 kill -9 杀掉吗?
不能用“再杀一次”解决。它已经退出,剩余的是等待父进程读取的内核记录。应让父进程执行 wait() / waitpid()。
11.6 孤儿进程一定由名为 init 的进程收养吗?
不一定。在不同服务、容器和 subreaper 配置下,实际收养者可能不同。可靠方法是查看进程 PPID 和运行环境。
11.7 nice 值越小,程序就一定先完成吗?
不一定。nice 影响普通任务竞争 CPU 的倾向,但完成时间还受 I/O、锁竞争、CPU 亲和性、调度类别和系统负载影响。
11.8 为什么调度器切换进程前要保存寄存器?
寄存器包含程序计数器、栈指针和中间计算结果。若不保存,进程再次运行时就不知道应该从哪条指令、哪个调用栈继续。
11.9 为什么 MY_ENV=value 后 C 程序读不到?
它可能只是当前 Shell 变量。需要导出:
export MY_ENV=value
然后再启动子进程。
11.10 为什么当前目录默认不放进 PATH?
主要是降低命令劫持风险。显式使用 ./app 能清楚表明“我要执行当前目录中的这个文件”。
11.11 malloc() 成功是否表示物理内存已经全部分配?
不一定。它首先获得的是进程虚拟地址空间中的可用区域;物理页可能在真正访问时按需建立。具体行为受分配器、内核策略、内存承诺和资源限制影响。
11.12 虚拟地址空间是不是让内存无限大?
不是。虚拟地址提供统一视图和映射能力,不能消除物理内存、交换空间、磁盘和系统策略的资源上限。资源不足时仍可能出现严重换页、分配失败或进程被终止。
11.13 教材中的内核结构为什么和当前源码不同?
内核持续演进。task_struct、调度队列和 VMA 组织方式都可能变化。学习时应分清:
- 稳定概念:描述、组织、调度、映射、保护;
- 版本实现:具体字段、数据结构和算法。
查源码前先确认内核版本,避免把 Linux 2.6 的历史实现当成现代内核细节。
总结
理解 Linux 进程,可以抓住四条主线:
- 进程是什么:程序代码和数据、PCB、资源关系共同组成动态执行实体;
- 进程能否运行:状态与等待队列记录它是可运行、睡眠、停止还是等待回收;
- 谁获得 CPU:调度器依据策略选择任务,上下文切换负责保存与恢复现场;
- 进程怎样使用内存:虚拟地址空间、页表和 VMA 提供隔离、映射与按需分配。
fork()、环境变量、僵尸进程和写时拷贝看似是不同主题,其实都建立在这套模型上:内核先描述并组织进程,再管理它的执行状态、资源关系和地址映射。
写在最后
建议你亲手完成文中的两个实验,并在运行期间同时观察:
ps -o pid,ppid,state,pri,ni,cmd
cat /proc/<pid>/status
cat /proc/<pid>/maps
当 PID、状态、退出码和地址映射能够与代码行为一一对应时,“进程”就不再是抽象定义,而会变成一套可观察、可验证的系统模型。
如果本文对你有帮助,欢迎点赞、收藏,也欢迎在评论区分享你在学习 fork() 或虚拟内存时遇到的疑问。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)