【无标题】
【Linux 系统编程】进程概念全景:从 PCB、/proc 到进程状态与虚拟地址空间
文章从“操作系统如何管理进程”出发,通过可复现的命令和 C 语言实验,梳理 PCB、PID/PPID、
/proc、cwd/exe、chdir、fork、进程状态、僵尸进程、环境变量以及虚拟地址空间。
一、主线:操作系统为什么需要“进程”
我们平时说“运行一个程序”,看起来只是输入一条命令:
./my_program
但是站在操作系统的角度,仅有磁盘上的程序文件还不够。系统必须知道:
- 这个程序现在运行到哪条指令;
- 它占用了哪些内存、打开了哪些文件;
- 它的 PID、父进程、权限和当前状态是什么;
- 它是否可以继续运行,应该获得多少 CPU 时间;
- 它退出后,退出结果由谁接收。
这正是操作系统管理进程的基本思路:
- 先描述:用内核数据结构记录进程属性;
- 再组织:根据调度、父子关系、等待事件等需求,把这些数据结构组织起来;
- 最后管理:创建、调度、暂停、唤醒、终止和回收进程。
因此,一个更完整的理解是:
进程不是磁盘上的可执行文件,而是程序的一次动态执行实例。
在当前学习阶段,可以先记住下面这个模型:
进程 = 程序代码和数据 + 内核为该执行流维护的数据结构 + 运行时资源
程序是静态文件,进程是动态实体。同一个程序可以被运行多次,每次运行都会形成不同的进程,并拥有不同的 PID 和运行上下文。
二、PCB:操作系统描述进程的“档案”
2.1 什么是 PCB
操作系统把描述进程所需的信息集中在进程控制块中,即 PCB(Process Control Block)。Linux 内核中,与 PCB 对应的核心数据结构是 task_struct。
task_struct 中记录或关联的信息包括:
| 信息类别 | 典型内容 | 解决的问题 |
|---|---|---|
| 身份信息 | PID、PPID、线程组 ID | “它是谁、由谁创建” |
| 运行状态 | R、S、D、T、Z 等 | “它现在能否运行” |
| 调度信息 | 调度策略、优先级、运行时间 | “什么时候轮到它” |
| CPU 上下文 | 指令地址、寄存器现场 | “下次从哪里继续” |
| 内存信息 | 指向 mm_struct 的指针 |
“它能看到哪些虚拟内存” |
| 文件信息 | 指向文件描述符表的指针 | “它打开了哪些文件” |
| 文件系统信息 | 当前工作目录、根目录等 | “相对路径从哪里解析” |
| 信号信息 | 信号处理方式、阻塞集合 | “收到异步事件时怎么办” |
| 统计与退出信息 | CPU 时间、退出码 | “运行了多久、如何结束” |
可以把几个重要关系简化为:
task_struct
├── 身份、状态、调度与上下文
├── mm ──> mm_struct ──> 虚拟内存区域、页表
├── files ──> files_struct ──> 文件描述符表
├── fs ──> fs_struct ──> cwd、root 等
└── signal/sighand ──> 信号相关信息
需要注意,Linux 内核并不是只用“一条 PCB 链表”管理所有事情。一个 task_struct 会按不同目的加入任务链表、调度队列以及父子关系等结构。课堂上“描述起来、组织起来”的模型非常重要,但不能把真实内核实现过度简化成单一链表。
2.2 PC、EIP 与 RIP 到底是什么
课本常说“程序计数器 PC 保存下一条指令的地址”。在 x86 体系下,32 位通常称为 EIP,64 位称为 RIP。
它不是 C 语言中普通的“程序指针”,也不能简单理解为“指向哪个程序,哪个程序就在运行”。更准确地说:
CPU 执行某个进程时,指令指针寄存器保存该执行流接下来要执行的指令地址;发生进程切换时,操作系统保存当前寄存器现场,并恢复另一个进程先前保存的现场。
函数调用、返回、条件分支和循环,本质上都会影响指令执行的下一位置。
三、从用户态观察一个真实进程
3.1 获取 PID、PPID 和当前工作目录
先编写一个便于观察的程序:
// process_observe.c
#include <limits.h>
#include <stdio.h>
#include <unistd.h>
int main(void)
{
char cwd[PATH_MAX];
if (getcwd(cwd, sizeof(cwd)) == NULL) {
perror("getcwd");
return 1;
}
printf("pid = %ld\n", (long)getpid());
printf("ppid = %ld\n", (long)getppid());
printf("cwd = %s\n", cwd);
fflush(stdout);
while (1) {
sleep(1);
}
}
编译并放到后台运行:
gcc -Wall -Wextra -O2 process_observe.c -o process_observe
./process_observe &
pid=$!
getpid() 返回调用进程的 PID,getppid() 返回其父进程 PID。通过命令行启动程序时,父进程通常是当前 Shell;但不要把“父进程永远是 bash”当作定律,因为程序也可能由其他 Shell、服务管理器或另一个应用创建。
3.2 用 ps 查看进程
ps -o pid,ppid,stat,pri,ni,cmd -p "$pid"
其中:
PID:进程 ID;PPID:父进程 ID;STAT:进程状态;PRI、NI:调度优先级相关信息;CMD:命令行。
也可以使用课程中常见的命令:
ps aux
ps axj
如果使用 ps ... | grep process_observe,结果中可能还会出现 grep 自己,因为执行 grep 时它同样是一个进程。更直接的办法是使用 pgrep:
pgrep -a process_observe
3.3 正确理解 Ctrl+C 与 kill
- 在终端按
Ctrl+C,通常是终端驱动向前台进程组发送SIGINT。 kill PID默认发送SIGTERM,它是在“请求进程终止”,进程可以捕获或忽略该信号。kill -9 PID发送SIGKILL,进程不能捕获、阻塞或忽略它,应当只在普通终止无效时使用。
所以 kill 命令的核心含义是“发送信号”,并不只用于杀死进程。
四、/proc:内核提供的进程观察窗口
4.1 /proc 不是普通磁盘目录
Linux 会把很多内核和进程信息通过 /proc 暴露给用户态。它是一个伪文件系统:目录和文件看起来可以被 ls、cat、readlink 访问,但其中大部分内容由内核动态生成,并不是普通磁盘文件。
对于 PID 为 1234 的进程,其信息通常位于:
/proc/1234/
当进程退出并被回收后,对应 PID 目录会消失。观察前面的实验进程:
ls -l "/proc/$pid"
cat "/proc/$pid/status" | head
cat "/proc/$pid/stat"
tr '\0' '\n' < "/proc/$pid/environ" | head
status 更适合人阅读;stat 适合程序解析,但字段较多;environ 中各变量使用空字符 \0 分隔,所以这里用 tr 转成换行。
4.2 cwd:相对路径从哪里开始
readlink -f "/proc/$pid/cwd"
/proc/<pid>/cwd 是指向进程当前工作目录的符号链接。程序执行:
FILE *fp = fopen("result.txt", "w");
时,result.txt 不是相对于源代码所在位置,也不一定相对于可执行文件所在位置,而是相对于该进程的当前工作目录解析。
4.3 exe:进程从哪个可执行文件启动
readlink "/proc/$pid/exe"
/proc/<pid>/exe 通常指向启动该进程的可执行文件。做一个很有代表性的实验:
rm ./process_observe
readlink "/proc/$pid/exe"
ps -p "$pid"
常见现象是 exe 链接后出现 (deleted),但进程仍然运行。
为什么?“程序已经被复制到内存”是便于入门的说法,但还不够完整。进程启动后,内核已经建立可执行文件的内存映射,并持有相关内核对象的引用。rm 删除的是目录项;只要仍有引用,相关内容不会立刻以进程可见的方式消失,已有映射也可以继续使用。因此,删除磁盘路径不等于立即撤销正在运行的进程。
实验结束后清理进程:
kill "$pid"
wait "$pid" 2>/dev/null
五、chdir:改变的是“当前进程”的工作目录
下面的程序先打印当前目录,再切换目录并创建文件:
// change_dir.c
#include <limits.h>
#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
char before[PATH_MAX];
char after[PATH_MAX];
if (argc != 2) {
fprintf(stderr, "usage: %s <directory>\n", argv[0]);
return 1;
}
if (getcwd(before, sizeof(before)) == NULL) {
perror("getcwd before");
return 1;
}
if (chdir(argv[1]) == -1) {
perror("chdir");
return 1;
}
if (getcwd(after, sizeof(after)) == NULL) {
perror("getcwd after");
return 1;
}
FILE *fp = fopen("result.txt", "w");
if (fp == NULL) {
perror("fopen");
return 1;
}
fprintf(fp, "created by pid %ld\n", (long)getpid());
fclose(fp);
printf("before: %s\n", before);
printf("after : %s\n", after);
printf("created: %s/result.txt\n", after);
return 0;
}
gcc -Wall -Wextra change_dir.c -o change_dir
./change_dir /tmp
这里有一个很重要的边界:
chdir()只改变调用进程自己的工作目录,不能改变父进程 Shell 的工作目录。
这也解释了为什么 cd 必须是 Shell 内建命令。如果 Shell 额外创建一个子进程去执行外部 cd,改变的只会是子进程目录,子进程退出后,Shell 本身仍停留在原目录。
六、fork:一个进程如何变成两个执行流
6.1 fork 的基本现象
// fork_demo.c
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
pid_t ret = fork();
if (ret < 0) {
perror("fork");
return 1;
}
if (ret == 0) {
printf("child : pid=%ld, ppid=%ld, fork_ret=%ld\n",
(long)getpid(), (long)getppid(), (long)ret);
} else {
printf("parent: pid=%ld, child_pid=%ld, fork_ret=%ld\n",
(long)getpid(), (long)ret, (long)ret);
}
return 0;
}
gcc -Wall -Wextra fork_demo.c -o fork_demo
./fork_demo
fork() 调用一次,却会在父子两个执行流中各返回一次:
- 父进程中返回子进程 PID;
- 子进程中返回 0;
- 失败时只在原进程中返回 -1,并设置
errno。
fork() 之后,父子进程从同一代码位置继续向后执行,但调度顺序不确定,不能根据某一次输出顺序推断“父进程永远先运行”或“子进程永远先运行”。
6.2 代码共享,数据真的共享吗
父子进程创建之初拥有相似的地址空间内容。为了避免立刻复制全部物理内存,内核通常使用写时拷贝(Copy-On-Write,COW):
- 创建之初,父子页表可以暂时映射相同物理页;
- 页面被标记为适当的只读/COW 状态;
- 某一方尝试写入时触发缺页异常;
- 内核为写入方复制页面并修改映射。
因此,“父子进程数据各自独立”和“创建初期可能共享物理页”并不矛盾:前者说的是可观察语义,后者说的是内核优化。
七、进程状态:R 不等于正在占用 CPU
常见 Linux 进程状态可以这样理解:
| 状态 | 含义 | 关键理解 |
|---|---|---|
| R | Running/Runnable | 可能正在 CPU 上运行,也可能只是在运行队列中等待 |
| S | 可中断睡眠 | 等待事件,可被信号唤醒,最常见 |
| D | 不可中断睡眠 | 通常在等待 I/O,不容易被普通信号打断 |
| T | 停止 | 常由作业控制或 SIGSTOP 导致 |
| t | 跟踪停止 | 常见于调试、跟踪场景 |
| Z | 僵尸 | 已退出,但退出状态尚未被父进程读取 |
| X | 死亡 | 瞬时或内部状态,通常很难在 ps 中观察到 |
最容易误解的是 R:它表示“正在运行或具备运行条件”,不保证采样这一刻真的占用了 CPU。
7.1 僵尸进程为什么出现
子进程退出时,内核仍要保留少量信息,例如退出码和资源使用统计,供父进程通过 wait()/waitpid() 读取。如果父进程一直不读取,子进程就会保持 Z 状态。
// 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) {
printf("child %ld exits with code 42\n", (long)getpid());
fflush(stdout);
_exit(42);
}
printf("parent %ld: observe child %ld within 10 seconds\n",
(long)getpid(), (long)child);
sleep(10);
int status = 0;
if (waitpid(child, &status, 0) == -1) {
perror("waitpid");
return 1;
}
if (WIFEXITED(status)) {
printf("child exit code = %d\n", WEXITSTATUS(status));
}
return 0;
}
在 10 秒窗口内,用另一个终端观察:
ps -o pid,ppid,stat,cmd -p <child_pid>
调用 waitpid() 后,子进程的退出信息被读取,PCB 才能被彻底回收。少量僵尸进程占用资源不多,但如果服务程序长期创建子进程却不回收,进程表项会不断累积,最终影响系统创建新进程。
7.2 孤儿进程与僵尸进程不是一回事
- 僵尸进程:子进程已经退出,父进程尚未回收。
- 孤儿进程:父进程先退出,子进程还在运行。
孤儿进程会被系统中的子进程收养者接管,通常由 PID 1 或配置了 subreaper 的进程负责后续回收。不能简单把“孤儿”和“僵尸”当作同一种异常状态。
八、优先级、并发与上下文切换
系统中可运行进程通常多于 CPU 数量,因此进程具有竞争性。调度器需要决定下一步运行谁,并在合适的时机切换执行流。
8.1 nice 值不是优先级本身
可以用下面的命令观察:
ps -l
top
nice -n 10 ./my_program
renice 5 -p <pid>
普通进程的 nice 值通常范围为 -20 到 19。数值越小,一般表示获得更高 CPU 调度权重;但 nice 值是调度参数,不代表进程会立刻运行,更不表示可以突破权限和调度策略的约束。
8.2 并发与并行
- 并发:多个任务在一段时间内都得到推进,单核也能通过快速切换实现;
- 并行:多个任务在同一时刻真正运行在不同 CPU 核心上。
8.3 上下文切换保存了什么
进程被换下 CPU 时,内核需要保存寄存器、指令位置、栈相关状态等上下文;另一个进程运行前,再恢复它之前保存的上下文。切换让并发成为可能,但也有保存/恢复寄存器、调度决策以及缓存扰动等成本。
课件中的 Linux 2.6 O(1) 调度队列适合用来理解 active/expired 队列、位图和常数时间选取任务的思想。它属于特定历史阶段的实现模型,学习时应把“调度思想”和“当前内核具体实现”区分开来。
九、环境变量:进程运行环境的一部分
常见环境变量包括:
PATH:Shell 查找命令的路径列表;HOME:用户主目录;SHELL:登录 Shell 路径。
查看和设置环境变量:
echo "$PATH"
env
export COURSE=linux_system_programming
unset COURSE
程序中可以通过 getenv() 读取:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
const char *value = getenv("COURSE");
printf("COURSE=%s\n", value != NULL ? value : "<not set>");
return 0;
}
这里要区分 Shell 变量和环境变量:
COURSE=linux # 当前 Shell 变量,不一定进入子进程环境
export COURSE # 导出后,后续子进程才能继承
父进程创建子进程时,子进程继承的是环境内容的一个逻辑副本,并不是父子进程共享一张可以互相实时修改的全局表。子进程修改自己的环境变量,不会反向改变父进程环境。
PATH 也解释了一个常见问题:为什么 ls 可以直接运行,而自己的程序通常要写 ./a.out?因为 Shell 会在 PATH 指定的目录中搜索命令,而当前目录 . 通常不在 PATH 中。出于安全考虑,不建议随意把 . 放到 PATH 最前面。
十、虚拟地址空间:进程独立性的底座
10.1 一个看似矛盾的实验
// virtual_address.c
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static int g_value = 0;
int main(void)
{
pid_t child = fork();
if (child < 0) {
perror("fork");
return 1;
}
if (child == 0) {
g_value = 100;
printf("child: value=%d, address=%p\n",
g_value, (void *)&g_value);
return 0;
}
if (waitpid(child, NULL, 0) == -1) {
perror("waitpid");
return 1;
}
printf("parent: value=%d, address=%p\n",
g_value, (void *)&g_value);
return 0;
}
常见结果是:父子进程打印出的地址相同,变量值却不同。
这并不矛盾,因为程序打印的是虚拟地址。父子进程各有自己的地址空间和页表;相同的虚拟地址可以映射到不同物理页。子进程写入后触发写时拷贝,于是双方看到不同内容。
可以把关系简化为:
父进程虚拟地址 0x... ──> 父进程页表 ──> 物理页 A(值为 0)
子进程虚拟地址 0x... ──> 子进程页表 ──> 物理页 B(值为 100)
10.2 mm_struct 管理什么
Linux 通过 mm_struct 描述进程的用户态虚拟地址空间,其中会关联代码区、数据区、堆、共享映射、栈等虚拟内存区域。task_struct 中的相关指针把“进程管理”与“内存管理”连接起来。
引入虚拟地址空间的意义主要有:
- 隔离与保护:普通进程不能随意访问其他进程或内核的物理内存;
- 统一视图:每个进程都看到相对规整、独立的地址布局;
- 解耦加载位置:程序使用虚拟地址,不必提前知道物理页放在哪里;
- 提高效率:支持按需分配、写时拷贝、文件映射等机制。
因此,进程独立性并不是一句抽象口号,而是由独立的内核状态、地址空间、页表和资源视图共同保证的。
十一、把整章知识连起来
学习完这一部分后,我对进程的理解可以概括为下面这条链:
程序被启动
↓
内核创建 task_struct 等数据结构
↓
建立地址空间、文件和信号等资源视图
↓
进程进入可调度状态,由调度器选择运行
↓
运行中可通过 ps 和 /proc 被观察
↓
进程可能睡眠、停止、继续或创建子进程
↓
退出时保留退出状态,等待父进程回收
这条链也回答了几个最初容易困惑的问题:
- 程序和进程有什么区别? 前者是静态文件,后者是一次受内核管理的执行实例。
- 操作系统怎样找到一个进程? 通过 PID 和对应的内核数据结构,而不是靠可执行文件名。
- 为什么删除可执行文件后还能运行? 进程已有内存映射和内核引用,删除路径不等于撤销运行实例。
- 为什么父子进程地址相同、值却不同? 地址是虚拟地址,各自页表可以映射不同物理页。
- 为什么子进程退出后还可能存在? 父进程尚未读取其退出状态,进程暂时处于 Z 状态。
十二、复习检查清单
如果能够不看笔记回答下面的问题,这一节就算真正掌握了:
- 为什么说进程不等于可执行文件?
task_struct为什么是理解进程管理的核心?/proc/<pid>/cwd和/proc/<pid>/exe分别表示什么?chdir()为什么不能改变父 Shell 的当前目录?fork()为什么会出现两个返回值?- R、S、D、T、Z 状态的区别是什么?
- 僵尸进程为什么必须由父进程回收?
- Shell 变量为什么要
export后才能被子进程继承? - 写时拷贝怎样同时保证效率和进程独立性?
- 虚拟地址空间怎样保护物理内存并解耦进程管理与内存管理?
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)