【Linux 系统篇(十四)】进程 (二) :PCB、task_struct、fork系统调用

能从冰封的土地里
培育出十万朵怒放的蔷薇
Linux 系统篇(十四) —— PCB、task_struct、fork系统调用
文章目录
一、进程到底是什么?
1.1 课本上的定义 vs 内核眼中的定义
很多教材会告诉你:进程就是正在运行的程序,或者说是“程序的执行实例”。这个说法很直观,但不够精确。站在操作系统的角度,进程必须同时满足两个角色:
- 资源分配的基本单位:操作系统会把 CPU 时间、内存、I/O 设备等资源分配给进程。
- 调度的基本单位:CPU 到底去运行哪一个进程,是按照进程为单位来切换的。
用更现代一点的视角来看,进程可以拆成两部分:
进程 = 内核为该进程维护的数据结构(PCB) + 进程自己的代码和数据
这里的数据结构在 Linux 中就是 task_struct,我们马上会详细讲它。代码和数据则是指:可执行文件里的机器指令,以及程序运行时用到的全局变量、堆、栈等。
1.2 磁盘、内存、CPU 之间的协作关系
我们先看一个程序是怎么“活”起来的。假设你写了一个 C 程序,编译后得到一个可执行文件 a.out,此时它还安静地躺在磁盘上,只是一堆二进制数据。
当你执行它时:
- 加载到内存:操作系统会把磁盘上的代码和数据搬运到内存(DRAM) 中,因为 CPU 只能直接和内存打交道。
- 创建进程管理结构:内核会分配一个
task_struct,用来记录这个进程的一切信息。- CPU 开始执行:CPU 从内存中读取第一条指令,然后不断取下一条、执行、再取下一条……
简单来说:
- 磁盘:存放静态的程序文件。
- 内存:程序运行时所在的场所,代码和数据都要放到这里。
- CPU:只与内存交互,读取指令并执行,让进程真正“动”起来。
1.3 为什么进程之间不会互相破坏?
现代操作系统会给每个进程划分出一个独立的虚拟地址空间。也就是说,进程 A 觉得自己独占整个内存,进程 B 也觉得自己独占整个内存,它们看到的地址都是虚拟地址。真正访问物理内存时,硬件和内核会负责把虚拟地址翻译成不同的物理地址,确保两个进程的读写操作不会互相干扰。
二、描述进程的核心结构:PCB 与 Linux 的 task_struct
2.1 什么是 PCB?
操作系统管理任何对象都需要一个“档案”,对进程来说,这个档案就叫 进程控制块(Process Control Block,PCB)。所有和进程有关的信息都会记录在 PCB 里,比如:
- 这个进程的编号是多少?
- 它现在处于什么状态?
- 它被分配到了多少内存?
- 它接下来要执行哪条指令?
可以简单理解为:PCB 就是进程属性的集合,是进程在操作系统中的“身份证 + 户口本”。
2.2 Linux 中的 task_struct
在 Linux 内核里,PCB 的具体实现是一个巨大的结构体,名字叫 task_struct。它定义在 <linux/sched.h> 头文件中,包含了几十个甚至上百个字段。每当一个新的进程诞生,内核就会在内存中分配一个 task_struct 实例;当进程结束时,内核再回收这个结构体。
换句话说,在 Linux 中,我们看到的所有进程,本质上就是一个个 task_struct 对象 + 它们的代码和数据。
2.3 task_struct 里到底存了什么?
task_struct 的内容非常多,我们按功能分类来看。
(1)标识符(PID)
每个进程都有一个独一无二的整数 ID,叫做 进程标识符(PID)。此外还会记录 父进程的标识符(PPID),即创建当前进程的那个进程的 ID。
(2)状态
记录进程此刻的状态,比如:正在运行、可运行(就绪)、睡眠(等待某事件)、暂停、僵尸等。进程状态这部分我们下一篇会详细展开。
(3)优先级
进程有优先级,数值越高或越低代表它能获得更多或更少的 CPU 时间(具体看调度器实现)。task_struct 中会有多个字段描述动态优先级、静态优先级等,调度器根据这些值决定该让谁上 CPU。
(4)程序计数器(PC)
也叫 指令指针(IP)。它保存着进程下一条要执行的指令的内存地址。当 CPU 执行完一条指令后,就会从这个地址取出下一条指令继续执行。
(5)内存指针
记录进程的代码段、数据段、堆、栈等内存区域的地址。同时还包括共享内存区域的指针,比如动态链接库的映射。
(6)上下文数据
这是理解进程切换的关键。当 CPU 从当前进程切换到另一个进程时,必须把当前进程的一些“现场”保存起来,以便将来能够原封不动地恢复执行。这些现场就是 CPU 中各种寄存器的值,比如:
- 程序计数器(告诉 CPU 接下来执行哪条指令)
- 通用寄存器(比如 x86 的 EAX, EBX, ECX 等,存放中间计算结果)
- 栈指针(指向当前栈顶)
- 状态寄存器等。
类比——一个例子:
你正在解一道数学大题,桌上摊着草稿纸,你正算到第 3 步。突然老师叫你去办公室帮忙。为了回来能继续,你必须记下:“我正在做第 3 题,算到第 3 步,目前中间结果是 42。” 这些“记下的内容”就相当于进程的上下文。task_struct 中会有一块区域专门保存这些寄存器数据。当内核决定让这个进程再次运行时,就会把这些数据恢复进 CPU 的寄存器,进程就能从上次中断的地方无缝继续。
(7)I/O 状态信息
记录进程打开的文件列表、文件描述符表、正在进行的 I/O 请求、被分配的设备等。
(8)记账信息
记录进程已经消耗了多少 CPU 时间、累计运行了多久、一些资源限制等。这些信息可以用于资源审计、性能分析等。
(9)其他
还包含信号处理、定时器、命名空间、安全上下文等大量其他字段,随着后续学习会逐渐接触。
2.4 进程是如何被组织在一起的?
Linux 内核中有个很巧妙的设计:所有的 task_struct 都通过双向链表链接在一起。也就是说,内核维护着一个全局的任务链表,遍历这个链表就能访问到系统里每一个进程。task_struct 内部有 tasks 链表节点,通过 list_head 结构实现。

三、查看与管理
理论知识铺开后,我们用实际命令来感受一下进程的存在。
3.1 使用 ps 命令查看进程
Linux 下最常用的进程查看命令是 ps,其中 ps axj 可以列出系统中所有进程的详细信息:
ps axj
输出各列的含义包括 PID、PPID、状态、TTY、命令等。但直接运行会刷出一大屏信息,通常我们会结合管道和 grep 进行过滤。
例如,编写以下测试程序并编译:
#include <stdio.h>
#include <unistd.h>
int main()
{
while (1)
{
printf("我是一个进程!,我的pid: %d\n", getpid());
sleep(1);
}
return 0;
}
运行它,在另一个终端执行:
ps axj | grep test

但这样会连 grep 自身的进程也匹配进来。另一个做法是:
ps axj | grep test | grep -v grep

grep -v grep 表示剔除包含“grep”的行,只保留真正的目标进程。为了同时保留表头,可以加 head -1:
ps axj | head -1 && ps axj | grep test | grep -v grep

若想实时监控,可以用 while 循环配合 sleep 构建一个简单的监控脚本:
while :; do ps axj | head -1 && ps axj | grep test | grep -v grep; sleep 1; done

这样每隔一秒刷新一次,动态观察进程变化。按 Ctrl+C 退出循环。
3.2 认识 Bash 进程与 PID 的变化

执行上述监控时,你会看到每次启动 test,它的 PID 都在变化。但有一个进程的 PID 始终不变,查看就会发现:命令行解释器 bash 本身也是一个进程。
它会在终端显示类似 user@ubuntu:~$ 的提示符,并阻塞等待用户输入命令。操作系统会为每个登录用户启动一个 bash 进程(-bash 前面的横杠表示该 shell 是登录 shell)。
当我们 Ctrl+C 终止 test 再重新运行时,新的 PID 就不同了。还可以用 kill -9 <PID> 强制杀死进程,后续讲信号时再深入。
3.3 通过 /proc 虚拟文件系统查看进程
Linux 还提供了一种更底层的查看方式——/proc 目录。它是一个伪文件系统,里面全是内核动态生成的虚拟文件,每个正在运行的进程都以自己的 PID 命名一个目录。
例如,假设 test 的 PID 为 149617,我们可以进入该目录:
ls /proc/149617
你会看到大量文件和目录。

其中有两个比较重要的符号链接:

exe:指向该进程对应的可执行文件在磁盘上的完整路径。进程通过它知道自己“从哪里来”。即使删除了这个磁盘文件(一般情况下),进程已经加载到内存中,仍可继续运行,只是exe链接会变红并显示 “(deleted)”。cwd:指向进程的当前工作目录,
进程结束时,/proc/<PID> 目录会自动消失。
3.4 进程工作目录与 chdir 系统调用
当前工作目录(cwd)决定了进程使用相对路径时的参考起点。例如,你在代码中写:
FILE *fp = fopen("hello.txt", "w");
系统会拿 cwd 的路径与 "hello.txt" 拼接,得到完整的磁盘路径。这也就是为什么新建文件默认会出现在可执行文件同级目录——因为进程启动时的 cwd 往往继承了父进程(通常是 shell)当前所在的目录。
进程可以在运行期间通过 chdir 系统调用 动态改变自己的工作目录:
#include <unistd.h>
chdir("/home/wjy");
fopen("newfile.txt", "w"); // 这个文件将在 /home/wjy/ 下被创建
你可能会突然意识到:cd 命令本身正是 shell 内部封装了 chdir 实现的。因为 shell 也是一个进程,当你在终端输入 cd /tmp 时,shell 进程调用 chdir("/tmp") 修改了自己的 cwd,于是后续你执行命令时,进程创建后继承的 cwd 就是新的目录。
四、系统调用——获取进程标识符
ps 和 /proc 让我们从外部观察进程,现在我们要亲手在代码中获取进程信息,这会用到 系统调用。
4.1 系统调用在哪里?
在 Linux 中,系统调用是内核提供给用户空间的编程接口。当你需要做“特权操作”(如获取进程 ID、读写文件、创建进程)时,必须通过系统调用进入内核。
如何查阅某个函数是系统调用还是库函数?通过 man 手册即可:

其中 2 表示系统调用,3 表示库函数。我们即将使用的 getpid、getppid、fork 等都在第 2 章。
4.2 第一个系统调用:getpid
引入必要的头文件:
#include <stdio.h>
#include <unistd.h> // 包含各种系统调用函数原型(如 getpid、fork)
#include <sys/types.h> // 提供 pid_t 等数据类型定义
<unistd.h>:是访问许多 POSIX 系统调用的“门户”,getpid、sleep、fork等都声明在这里。<sys/types.h>:定义了pid_t等类型,虽然pid_t本质是整数,但使用标准类型更具可移植性。
编写程序:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
while (1)
{
sleep(1); // 让进程休眠 1 秒,便于我们观察
printf("我是一个进程!,我的pid: %d\n", getpid());
}
return 0;
}
getpid() 会直接向内核请求当前进程的唯一标识符(PID)。sleep(1) 则是让进程进入休眠状态。
4.3 获取父进程 ID:getppid
只需将上面代码中的 getpid() 换成 getppid() 即可:
printf("我是一个进程!,我的父进程pid: %d\n", getppid());
运行这个程序,你会看到它的父进程 PID 正是启动它的 bash 的 PID。
五、fork() 系统调用
fork 这个单词的意思是“分叉”,非常形象:一个进程走到 fork 之后,就会“分裂”成两个进程,各自走各自的路。
5.1 fork 函数的原型和基本用法
#include <unistd.h>
pid_t fork(void);
它没有参数,返回值非常特殊:
- 在父进程中:返回子进程的 PID(大于 0)。
- 在子进程中:返回 0。
- 创建失败:返回 -1,并且不会产生子进程。
先跑一个最简单的例子:
#include <stdio.h>
#include <unistd.h>
int main()
{
int ret = fork();
printf("hello proc: %d, ret: %d\n", getpid(), ret);
sleep(1);
return 0;
}
编译运行后你会发现,printf 那一行被打印了两次!可能看到类似:

为什么打印两次?因为 fork() 调用之后,系统里多了一个进程,这两个进程各自执行 printf。而 ret 的值在两个进程中不同,这就为分支逻辑提供了可能。
5.2 “调用一次,返回两次”到底是怎么回事?
这是 fork 最让人困惑的地方。我们平时调用一个函数,调用一次就返回一次。但 fork 确实返回了两次——分别在两个不同的进程中返回。
内核在实现 fork 时,大致做了以下事情:
- 创建新的 task_struct:内核为子进程分配一个全新的
task_struct,并复制父进程的大部分属性。- 复制内存空间:这里采用了“写时拷贝”技术,让父子进程共享物理内存页。
- 设置返回值:内核在子进程的返回路径上,将子进程的 fork 返回值设为 0;在父进程的返回路径上,将返回值设为子进程的 PID。
- 两个进程分别返回用户态:内核调度器接下来可能会先运行父进程,也可能先运行子进程。二者都从 fork 调用之后的下一行代码开始执行。
所以,表面上我们只调用了一次 fork,但内核实际上让两个进程都走完了这个系统调用的返回流程,所以才会有两个返回值。
为什么父进程得到子进程的 PID,而子进程得到 0?
- 父进程可能需要管理多个子进程,知道新创建的子进程的 PID 是必需的。
- 子进程完全可以自己通过
getpid()获取自己的 PID,不需要再从 fork 返回值中获取。返回 0 是一个约定,方便写 if 判断。
5.3 用 if 进行父子进程分流
实际编程中,我们一定会根据返回值来区分父子进程,让它们做不同的事情:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t ret = fork();
if (ret < 0)
{
perror("fork");
return 1;
}
else if (ret == 0)
{
// 子进程执行的代码
printf("I am child: pid=%d, ppid=%d, ret=%d\n", getpid(), getppid(), ret);
}
else
{
// 父进程执行的代码
printf("I am father: pid=%d, ppid=%d, ret=%d\n", getpid(), getppid(), ret);
}
sleep(1);
return 0;
}
运行后会发现,if 和 else if 两个分支都执行了(分别由两个进程执行),且输出中 ret 的值不同。

这就引出一个问题:
“一个变量
ret怎么能让if和else if同时成立?”
5.4 为什么一个变量能让 if 和 else if 同时成立?
有独立的执行流,独立的栈空间
在之前的认知里,if-else 分支永远互斥,一个变量不可能既大于 0 又等于 0。但在 Linux 系统编程中,这个规则被“进程”这个维度打破了。
代码是共享的,但执行流是独立的。 当 fork 完成后,系统中存在两个完全独立的执行流(父进程和子进程),它们虽然运行同一份代码,但各自维护自己的栈空间。
父进程中,fork 的返回值是子进程的 PID(大于 0),它执行到 if(ret > 0) 时判断为真,进入父进程分支。子进程中,内核将它的 fork 返回值设置为 0,它执行到 if(ret == 0) 时判断为真,进入子进程分支。
所以,并不是“在一个进程里同时执行了两个分支”,而是两个进程各自执行了属于自己的那个分支。由于它们的输出几乎同时出现在屏幕上,才给人一种“代码逻辑被违背”的错觉。
5.5 代码共享与写时拷贝
fork 之后,父子进程的代码是一样的吗?数据是共享的吗?
代码共享:是的,父子进程共享同一份代码段(只读)。因为代码段通常不可写,所以没有修改冲突的风险,内核让它们直接使用同一份物理内存,节省空间。
数据“私有一份”:fork 刚完成时,子进程的全局变量、堆、栈等数据段在物理内存上和父进程是共享的,但内核将这些页表标记为“只读”。一旦父进程或子进程中任意一方试图写入某个数据页,CPU 就会触发页保护异常。内核捕获异常后,发现这是因写时拷贝触发的,于是:
- 分配一块新的物理内存页。
- 把原页的内容完整复制过来。
- 修改触发异常的进程的页表,让它指向新页,并标记为可写。
- 另一个进程依然指向原来的只读页(如果后来它也要写,也会触发同样的拷贝流程)。
这样,只有真正被修改的数据页才会产生独立的副本,未修改的页面继续共享。
形象类比:父子两个人拿到了一本同样的教材(代码)和一本笔记本(数据)。笔记本书写时是“复写纸”模式——两人一开始看到的笔记内容完全相同,但笔记本表面封了一层不能写的膜。每当一个人要在笔记本上写字时,工作人员立刻给他复印一页新的,把原内容抄上去,让他在新页上写。另一个人完全不受影响。这样既避免了互相干扰,又不需要一开始就买两套本子。
结语:
今天的内容到这里就结束了,希望你能有所收获~
干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)