在这里插入图片描述

大家好,欢迎来到 huangjin007_ 的博客
个人主页:huangjin007_
🔥 文章收录专栏:Linux 内功修炼手册(系统篇)
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇

Linux 系统篇(十四) —— PCB、task_struct、fork系统调用


一、进程到底是什么?

1.1 课本上的定义 vs 内核眼中的定义

  很多教材会告诉你:进程就是正在运行的程序,或者说是“程序的执行实例”。这个说法很直观,但不够精确。站在操作系统的角度,进程必须同时满足两个角色:

  • 资源分配的基本单位:操作系统会把 CPU 时间、内存、I/O 设备等资源分配给进程。
  • 调度的基本单位:CPU 到底去运行哪一个进程,是按照进程为单位来切换的。

  用更现代一点的视角来看,进程可以拆成两部分:

进程 = 内核为该进程维护的数据结构(PCB) + 进程自己的代码和数据

  这里的数据结构在 Linux 中就是 task_struct,我们马上会详细讲它。代码和数据则是指:可执行文件里的机器指令,以及程序运行时用到的全局变量、堆、栈等。

1.2 磁盘、内存、CPU 之间的协作关系

  我们先看一个程序是怎么“活”起来的。假设你写了一个 C 程序,编译后得到一个可执行文件 a.out,此时它还安静地躺在磁盘上,只是一堆二进制数据。

  当你执行它时:

  1. 加载到内存:操作系统会把磁盘上的代码和数据搬运到内存(DRAM) 中,因为 CPU 只能直接和内存打交道。
  2. 创建进程管理结构:内核会分配一个 task_struct,用来记录这个进程的一切信息。
  3. 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 表示库函数。我们即将使用的 getpidgetppidfork 等都在第 2 章。

4.2 第一个系统调用:getpid

引入必要的头文件:

#include <stdio.h>
#include <unistd.h>    // 包含各种系统调用函数原型(如 getpid、fork)
#include <sys/types.h> // 提供 pid_t 等数据类型定义
  • <unistd.h>:是访问许多 POSIX 系统调用的“门户”,getpidsleepfork 等都声明在这里。
  • <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 时,大致做了以下事情:

  1. 创建新的 task_struct:内核为子进程分配一个全新的 task_struct,并复制父进程的大部分属性。
  2. 复制内存空间:这里采用了“写时拷贝”技术,让父子进程共享物理内存页。
  3. 设置返回值:内核在子进程的返回路径上,将子进程的 fork 返回值设为 0;在父进程的返回路径上,将返回值设为子进程的 PID。
  4. 两个进程分别返回用户态:内核调度器接下来可能会先运行父进程,也可能先运行子进程。二者都从 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 怎么能让 ifelse 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 就会触发页保护异常。内核捕获异常后,发现这是因写时拷贝触发的,于是:

  1. 分配一块新的物理内存页。
  2. 把原页的内容完整复制过来。
  3. 修改触发异常的进程的页表,让它指向新页,并标记为可写。
  4. 另一个进程依然指向原来的只读页(如果后来它也要写,也会触发同样的拷贝流程)。

  这样,只有真正被修改的数据页才会产生独立的副本,未修改的页面继续共享。

  形象类比:父子两个人拿到了一本同样的教材(代码)和一本笔记本(数据)。笔记本书写时是“复写纸”模式——两人一开始看到的笔记内容完全相同,但笔记本表面封了一层不能写的膜。每当一个人要在笔记本上写字时,工作人员立刻给他复印一页新的,把原内容抄上去,让他在新页上写。另一个人完全不受影响。这样既避免了互相干扰,又不需要一开始就买两套本子。


结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

Logo

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

更多推荐