欢迎光临
我们一直在努力

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

在这里插入图片描述

大家好,欢迎来到 huangjin007_ 的博客

个人主页:huangjin007_
🔥
文章收录专栏:Linux 内功修炼手册(系统篇)

总会有一些坚持能从冰封的土地里培育出十万朵怒放的蔷薇


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

文章目录

  • Linux 系统篇(十四) —— PCB、task_struct、fork系统调用
    • 一、进程到底是什么?
      • 1.1 课本上的定义 vs 内核眼中的定义
      • 1.2 磁盘、内存、CPU 之间的协作关系
      • 1.3 为什么进程之间不会互相破坏?
    • 二、描述进程的核心结构:PCB 与 Linux 的 task_struct
      • 2.1 什么是 PCB?
      • 2.2 Linux 中的 task_struct
      • 2.3 task_struct 里到底存了什么?
        • (1)标识符(PID)
        • (2)状态
        • (3)优先级
        • (4)程序计数器(PC)
        • (5)内存指针
        • (6)上下文数据
        • (7)I/O 状态信息
        • (8)记账信息
        • (9)其他
      • 2.4 进程是如何被组织在一起的?
    • 三、查看与管理
      • 3.1 使用 ps 命令查看进程
      • 3.2 认识 Bash 进程与 PID 的变化
      • 3.3 通过 /proc 虚拟文件系统查看进程
      • 3.4 进程工作目录与 chdir 系统调用
    • 四、系统调用——获取进程标识符
      • 4.1 系统调用在哪里?
      • 4.2 第一个系统调用:getpid
      • 4.3 获取父进程 ID:getppid
    • 五、fork() 系统调用
      • 5.1 fork 函数的原型和基本用法
      • 5.2 “调用一次,返回两次”到底是怎么回事?
      • 5.3 用 if 进行父子进程分流
      • 5.4 为什么一个变量能让 if 和 else if 同时成立?
      • 5.5 代码共享与写时拷贝
    • 结语:

一、进程到底是什么?

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 就会触发页保护异常。内核捕获异常后,发现这是因写时拷贝触发的,于是:

  • 分配一块新的物理内存页。
  • 把原页的内容完整复制过来。
  • 修改触发异常的进程的页表,让它指向新页,并标记为可写。
  • 另一个进程依然指向原来的只读页(如果后来它也要写,也会触发同样的拷贝流程)。
  •   这样,只有真正被修改的数据页才会产生独立的副本,未修改的页面继续共享。

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


    结语:

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

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

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux 系统篇(十四)】进程 (二) :PCB、task_struct、fork系统调用
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址