【Linux 系统编程】进程概念全景:从 PCB、/proc 到进程状态与虚拟地址空间

文章从“操作系统如何管理进程”出发,通过可复现的命令和 C 语言实验,梳理 PCB、PID/PPID、/proccwd/exechdirfork、进程状态、僵尸进程、环境变量以及虚拟地址空间。


一、主线:操作系统为什么需要“进程”

我们平时说“运行一个程序”,看起来只是输入一条命令:

./my_program

但是站在操作系统的角度,仅有磁盘上的程序文件还不够。系统必须知道:

  • 这个程序现在运行到哪条指令;
  • 它占用了哪些内存、打开了哪些文件;
  • 它的 PID、父进程、权限和当前状态是什么;
  • 它是否可以继续运行,应该获得多少 CPU 时间;
  • 它退出后,退出结果由谁接收。

这正是操作系统管理进程的基本思路:

  1. 先描述:用内核数据结构记录进程属性;
  2. 再组织:根据调度、父子关系、等待事件等需求,把这些数据结构组织起来;
  3. 最后管理:创建、调度、暂停、唤醒、终止和回收进程。

因此,一个更完整的理解是:

进程不是磁盘上的可执行文件,而是程序的一次动态执行实例。

在当前学习阶段,可以先记住下面这个模型:

进程 = 程序代码和数据 + 内核为该执行流维护的数据结构 + 运行时资源

程序是静态文件,进程是动态实体。同一个程序可以被运行多次,每次运行都会形成不同的进程,并拥有不同的 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:进程状态;
  • PRINI:调度优先级相关信息;
  • 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 暴露给用户态。它是一个伪文件系统:目录和文件看起来可以被 lscatreadlink 访问,但其中大部分内容由内核动态生成,并不是普通磁盘文件。

对于 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):

  1. 创建之初,父子页表可以暂时映射相同物理页;
  2. 页面被标记为适当的只读/COW 状态;
  3. 某一方尝试写入时触发缺页异常;
  4. 内核为写入方复制页面并修改映射。

因此,“父子进程数据各自独立”和“创建初期可能共享物理页”并不矛盾:前者说的是可观察语义,后者说的是内核优化。


七、进程状态: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 中的相关指针把“进程管理”与“内存管理”连接起来。

引入虚拟地址空间的意义主要有:

  1. 隔离与保护:普通进程不能随意访问其他进程或内核的物理内存;
  2. 统一视图:每个进程都看到相对规整、独立的地址布局;
  3. 解耦加载位置:程序使用虚拟地址,不必提前知道物理页放在哪里;
  4. 提高效率:支持按需分配、写时拷贝、文件映射等机制。

因此,进程独立性并不是一句抽象口号,而是由独立的内核状态、地址空间、页表和资源视图共同保证的。


十一、把整章知识连起来

学习完这一部分后,我对进程的理解可以概括为下面这条链:

程序被启动
   ↓
内核创建 task_struct 等数据结构
   ↓
建立地址空间、文件和信号等资源视图
   ↓
进程进入可调度状态,由调度器选择运行
   ↓
运行中可通过 ps 和 /proc 被观察
   ↓
进程可能睡眠、停止、继续或创建子进程
   ↓
退出时保留退出状态,等待父进程回收

这条链也回答了几个最初容易困惑的问题:

  • 程序和进程有什么区别? 前者是静态文件,后者是一次受内核管理的执行实例。
  • 操作系统怎样找到一个进程? 通过 PID 和对应的内核数据结构,而不是靠可执行文件名。
  • 为什么删除可执行文件后还能运行? 进程已有内存映射和内核引用,删除路径不等于撤销运行实例。
  • 为什么父子进程地址相同、值却不同? 地址是虚拟地址,各自页表可以映射不同物理页。
  • 为什么子进程退出后还可能存在? 父进程尚未读取其退出状态,进程暂时处于 Z 状态。

十二、复习检查清单

如果能够不看笔记回答下面的问题,这一节就算真正掌握了:

  1. 为什么说进程不等于可执行文件?
  2. task_struct 为什么是理解进程管理的核心?
  3. /proc/<pid>/cwd/proc/<pid>/exe 分别表示什么?
  4. chdir() 为什么不能改变父 Shell 的当前目录?
  5. fork() 为什么会出现两个返回值?
  6. R、S、D、T、Z 状态的区别是什么?
  7. 僵尸进程为什么必须由父进程回收?
  8. Shell 变量为什么要 export 后才能被子进程继承?
  9. 写时拷贝怎样同时保证效率和进程独立性?
  10. 虚拟地址空间怎样保护物理内存并解耦进程管理与内存管理?

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐