Linux 进程核心原理全解:从冯诺依曼体系到进程控制全链路深度剖析

前言

一、计算机底层基石:冯诺依曼体系结构

[1.1 冯诺依曼体系的核心组成](#1.1 冯诺依曼体系的核心组成)

[1.2 存储金字塔:解决速度与成本的矛盾](#1.2 存储金字塔:解决速度与成本的矛盾)

[1.3 实操理解:QQ 聊天的数据流走向](#1.3 实操理解:QQ 聊天的数据流走向)

二、操作系统的核心定位:搞管理的软件

[2.1 操作系统的定义与层级架构](#2.1 操作系统的定义与层级架构)

[2.2 操作系统的核心设计思想:先描述,再组织](#2.2 操作系统的核心设计思想:先描述,再组织)

[2.3 系统调用与库函数的区别](#2.3 系统调用与库函数的区别)

三、进程的核心概念:到底什么是进程?

[3.1 进程的官方定义与内核视角](#3.1 进程的官方定义与内核视角)

[3.2 进程控制块 PCB 与 task_struct](#3.2 进程控制块 PCB 与 task_struct)

[3.2.1 task_struct 的核心内容分类](#3.2.1 task_struct 的核心内容分类)

[3.2.2 进程的组织方式](#3.2.2 进程的组织方式)

[3.3 进程的基础操作:查看与标识](#3.3 进程的基础操作:查看与标识)

[3.3.1 查看进程](#3.3.1 查看进程)

[3.3.2 获取进程标识符](#3.3.2 获取进程标识符)

[3.4 进程创建:fork 函数初识](#3.4 进程创建:fork 函数初识)

[3.4.1 fork 函数的基本用法](#3.4.1 fork 函数的基本用法)

[3.4.2 fork 函数实操代码](#3.4.2 fork 函数实操代码)

[四、Linux 进程状态全解析](#四、Linux 进程状态全解析)

[4.1 Linux 内核定义的 7 种进程状态](#4.1 Linux 内核定义的 7 种进程状态)

[1. R 运行状态(running)](#1. R 运行状态(running))

[2. S 睡眠状态(sleeping)](#2. S 睡眠状态(sleeping))

[3. D 磁盘休眠状态(disk sleep)](#3. D 磁盘休眠状态(disk sleep))

[4. T 停止状态(stopped)](#4. T 停止状态(stopped))

[5. t 追踪停止状态(tracing stop)](#5. t 追踪停止状态(tracing stop))

[6. X 死亡状态(dead)](#6. X 死亡状态(dead))

[7. Z 僵尸状态(zombie)](#7. Z 僵尸状态(zombie))

[4.2 僵尸进程:形成原因、危害与解决方案](#4.2 僵尸进程:形成原因、危害与解决方案)

[4.2.1 僵尸进程的形成原因](#4.2.1 僵尸进程的形成原因)

[4.2.2 僵尸进程的代码复现](#4.2.2 僵尸进程的代码复现)

[4.2.3 僵尸进程的危害](#4.2.3 僵尸进程的危害)

[4.2.4 僵尸进程的解决方案](#4.2.4 僵尸进程的解决方案)

[4.3 孤儿进程](#4.3 孤儿进程)

[4.3.1 孤儿进程的形成原因](#4.3.1 孤儿进程的形成原因)

[4.3.2 孤儿进程的代码复现](#4.3.2 孤儿进程的代码复现)

[4.4 进程状态转换流程图](#4.4 进程状态转换流程图)

[五、进程优先级与 Linux 调度机制](#五、进程优先级与 Linux 调度机制)

[5.1 进程优先级的核心概念](#5.1 进程优先级的核心概念)

[5.1.1 PRI 与 NI:优先级的两个核心字段](#5.1.1 PRI 与 NI:优先级的两个核心字段)

[5.1.2 调整进程优先级的方法](#5.1.2 调整进程优先级的方法)

[5.2 进程的四大核心特性](#5.2 进程的四大核心特性)

[5.3 Linux 进程调度机制](#5.3 Linux 进程调度机制)

[5.3.1 Linux2.6 内核:O (1) 调度算法](#5.3.1 Linux2.6 内核:O (1) 调度算法)

[1. O (1) 调度算法的核心数据结构](#1. O (1) 调度算法的核心数据结构)

[2. O (1) 调度算法的执行流程](#2. O (1) 调度算法的执行流程)

[5.3.2 现代 Linux 内核:CFS 完全公平调度器](#5.3.2 现代 Linux 内核:CFS 完全公平调度器)

[5.4 进程切换的核心原理](#5.4 进程切换的核心原理)

六、环境变量全解

[6.1 环境变量的核心概念与常见环境变量](#6.1 环境变量的核心概念与常见环境变量)

[6.2 环境变量相关的常用命令](#6.2 环境变量相关的常用命令)

[实操示例:把自己的程序加入 PATH](#实操示例:把自己的程序加入 PATH)

[6.3 环境变量的组织方式](#6.3 环境变量的组织方式)

[6.4 代码中获取环境变量的三种方式](#6.4 代码中获取环境变量的三种方式)

[方式 1:通过 main 函数的第三个参数](#方式 1:通过 main 函数的第三个参数)

[方式 2:通过全局变量 environ](#方式 2:通过全局变量 environ)

[方式 3:通过系统调用 getenv/setenv](#方式 3:通过系统调用 getenv/setenv)

[6.5 环境变量的全局属性:被子进程继承](#6.5 环境变量的全局属性:被子进程继承)

七、进程地址空间与虚拟内存

[7.1 C 语言程序的内存布局](#7.1 C 语言程序的内存布局)

[7.2 虚拟地址 vs 物理地址](#7.2 虚拟地址 vs 物理地址)

[7.3 虚拟地址空间的底层实现](#7.3 虚拟地址空间的底层实现)

[7.3.1 核心数据结构](#7.3.1 核心数据结构)

[7.3.2 页表与 MMU:虚拟地址到物理地址的转换](#7.3.2 页表与 MMU:虚拟地址到物理地址的转换)

[7.4 为什么要有虚拟地址空间?](#7.4 为什么要有虚拟地址空间?)

[1. 内存安全,进程隔离](#1. 内存安全,进程隔离)

[2. 解耦内存管理,提升效率](#2. 解耦内存管理,提升效率)

[3. 统一进程的地址空间视图](#3. 统一进程的地址空间视图)

八、进程控制全链路实操

[8.1 进程创建:fork 与 vfork](#8.1 进程创建:fork 与 vfork)

[8.1.1 fork 函数的底层执行流程](#8.1.1 fork 函数的底层执行流程)

[8.1.2 fork 的常规用法](#8.1.2 fork 的常规用法)

[8.1.3 fork 调用失败的原因](#8.1.3 fork 调用失败的原因)

[8.1.4 vfork 函数:fork 的特殊版本](#8.1.4 vfork 函数:fork 的特殊版本)

[8.2 进程终止](#8.2 进程终止)

[8.2.1 进程退出的三大场景](#8.2.1 进程退出的三大场景)

[8.2.2 进程退出码](#8.2.2 进程退出码)

[8.2.3 进程正常退出的三种方式](#8.2.3 进程正常退出的三种方式)

[1. main 函数 return 返回](#1. main 函数 return 返回)

[2. exit 函数](#2. exit 函数)

[3. _exit 函数](#3. _exit 函数)

[8.2.4 进程异常退出](#8.2.4 进程异常退出)

[8.3 进程等待](#8.3 进程等待)

[8.3.1 进程等待的必要性](#8.3.1 进程等待的必要性)

[8.3.2 wait 函数](#8.3.2 wait 函数)

前言

在 Linux 操作系统中,一切皆文件,一切皆进程。进程是操作系统资源分配的最小单位,也是我们理解操作系统内核工作原理的核心入口。无论是日常执行的 shell 命令、运行的应用服务,还是内核本身的系统线程,本质上都是以进程的形态在系统中运行。

很多初学者在接触 Linux 进程时,总会陷入 "背概念、记 API" 的误区,却忽略了进程背后的设计哲学和底层实现逻辑。本文将从最基础的冯诺依曼体系结构出发,沿着操作系统的管理思想,一步步拆解进程的核心概念、状态管理、调度机制、地址空间,再到进程创建、终止、等待、程序替换的全链路控制,最终通过手写一个简易 shell 落地所有知识点。

本文所有内容均基于 Linux 内核源码设计思想,结合实操代码和底层原理展开,既适合零基础入门的同学建立完整的知识体系,也适合有一定基础的开发者查漏补缺,深入理解内核设计细节。

一、计算机底层基石:冯诺依曼体系结构

理解进程的第一步,是先搞懂进程运行的硬件基础 ------ 冯诺依曼体系结构。我们日常使用的笔记本、服务器,绝大多数都遵循这一体系规范,它定义了计算机硬件的核心组成和数据流动的基本规则。

1.1 冯诺依曼体系的核心组成

冯诺依曼体系将计算机硬件划分为五大核心单元,其结构和数据流向如下:

核心单元 包含组件 核心作用
输入设备 键盘、鼠标、网卡、磁盘、扫描仪等 向计算机输入原始数据,是数据进入系统的入口
存储器 特指内存(不包括磁盘等外存) 整个体系的核心中转站,保存程序运行的代码和数据
运算器 集成在 CPU 内部 完成算术运算、逻辑运算,是数据的计算单元
控制器 集成在 CPU 内部 控制程序指令的执行顺序,协调硬件间的交互
输出设备 显示器、打印机、网卡、磁盘等 将计算结果输出到外部,是数据流出系统的出口

这里必须划一个重点:在冯诺依曼体系中,CPU 能且只能直接对内存进行读写,无法直接访问外设(输入 / 输出设备);所有外设要输入或输出数据,也只能和内存直接打交道

很多人会疑惑:我往磁盘里写文件,难道不是 CPU 直接操作磁盘吗?本质上,这个过程是:CPU 先把要写入的数据从寄存器写入内存,再由控制器通知磁盘,磁盘从内存中读取对应数据并写入硬件;反之,读取磁盘文件时,也是磁盘先把数据加载到内存,CPU 再从内存中读取数据。

1.2 存储金字塔:解决速度与成本的矛盾

CPU 的运算速度远高于外设的读写速度,而内存的读写速度介于两者之间,冯诺依曼体系通过内存作为中转站,初步解决了 CPU 与外设的速度不匹配问题。而在实际的硬件设计中,为了进一步提升效率,又设计了多层存储结构,形成了存储金字塔:

层级 存储设备 速度 容量 每字节成本 核心作用
L0 CPU 寄存器 纳秒级 几十字节~几 KB 极高 保存当前正在运算的指令和数据
L1 CPU 一级缓存 1~3 纳秒 几十 KB 很高 保存核心热点数据,接近寄存器速度
L2 CPU 二级缓存 3~10 纳秒 几百 KB~ 几 MB 平衡速度与容量,承接 L1 缓存的压力
L3 CPU 三级缓存 10~30 纳秒 几 MB~ 几十 MB 中高 多核共享缓存,减少 CPU 访问内存的频率
L4 主存(内存 DRAM) 几十纳秒 几 GB~ 几百 GB 中等 冯诺依曼体系的核心,保存运行中的程序和数据
L5 本地磁盘(SSD/HDD) 微秒~毫秒级 几百 GB~ 几十 TB 持久化存储数据,断电不丢失
L6 远程网络存储 几十毫秒级 无限扩容 极低 分布式文件系统、云存储等远程存储

金字塔的核心规律是:越靠近 CPU 的存储设备,速度越快、容量越小、成本越高;越远离 CPU 的存储设备,速度越慢、容量越大、成本越低。而整个程序运行的过程,本质上就是数据不断从下层存储向上层加载,完成计算后再向下回写的过程。

1.3 实操理解:QQ 聊天的数据流走向

光理解概念不够,我们通过一个日常场景,彻底搞懂冯诺依曼体系下的数据流动:你在 QQ 上给朋友发送一条消息,数据经历了什么?

  1. 你通过键盘输入消息,键盘作为输入设备,将输入的字符数据写入内存中 QQ 进程的地址空间;

  2. CPU 从内存中读取消息数据,完成协议封装(比如 TCP/IP 协议),将封装后的数据写回内存;

  3. 网卡作为输出设备,从内存中读取封装好的数据包,通过网络发送到对方的电脑网卡;

  4. 对方的网卡作为输入设备,将接收到的数据包写入内存;

  5. 对方的 CPU 从内存中读取数据包,完成协议解析,提取出消息内容写回内存;

  6. 显示器作为输出设备,从内存中读取消息内容,渲染到对方的 QQ 聊天窗口中。

哪怕是发送文件,本质上也是一样的逻辑:先把磁盘中的文件数据加载到内存,再通过网卡发送出去,全程 CPU 不会直接操作磁盘和网卡,所有数据交互都以内存为核心中转站。

二、操作系统的核心定位:搞管理的软件

有了硬件基础,还需要操作系统来管理这些硬件资源,为上层应用提供稳定的运行环境。而进程,正是操作系统管理硬件资源的核心载体。

2.1 操作系统的定义与层级架构

操作系统(OS)是一套管理计算机软硬件资源的系统软件,分为狭义和广义两个维度:

  • 狭义操作系统:特指 Linux 内核(Kernel),核心包括进程管理、内存管理、文件管理、驱动管理四大模块;

  • 广义操作系统:内核 + 外壳程序(shell)、函数库(glibc)、预装系统级软件等一整套运行环境。

整个计算机系统的层级架构如下,从上到下分为三层,严格遵循 "用户不能直接操作硬件,必须通过操作系统内核" 的规则:

复制代码
┌─────────────────────────────────┐
│ 用户层:应用程序、shell、库函数  │
├─────────────────────────────────┤
│ 系统调用接口:OS暴露的内核API    │
├─────────────────────────────────┤
│ 操作系统内核:进程/内存/文件/驱动 │
├─────────────────────────────────┤
│ 驱动程序:硬件的专属控制程序      │
├─────────────────────────────────┤
│ 底层硬件:CPU、内存、磁盘、网卡等 │
└─────────────────────────────────┘

2.2 操作系统的核心设计思想:先描述,再组织

操作系统的核心功能是 "管理"------ 管理上百个进程、几十 GB 内存、成千上万的文件和硬件设备。而管理的核心方法论,只有 8 个字:先描述,再组织

我们用一个生活中的例子理解:校长要管理全校上万名学生,不可能亲自盯着每个学生的一举一动。他会先做两件事:

  1. 描述:用学生信息表(结构体)定义学生的属性,包括学号、姓名、年龄、班级、成绩等,把一个活生生的学生,抽象成一组可管理的数据;

  2. 组织:用链表、数组、红黑树等数据结构,把所有学生的信息表组织起来,比如按班级分链表、按学号建索引。

后续所有的管理动作(增删改查、统计排名、评优处罚),都变成了对数据结构的操作,而不是直接面对每个学生。

操作系统管理硬件和进程,也是完全一样的逻辑:

  1. 描述 :用struct结构体描述被管理对象,比如用task_struct描述进程、用mm_struct描述进程地址空间、用inode描述文件;

  2. 组织 :用链表、红黑树等高效数据结构,把这些结构体组织起来,比如所有进程的task_struct以双链表的形式存在内核中。

理解了 "先描述,再组织",你就理解了操作系统 90% 的设计逻辑,后续所有进程相关的知识点,都是围绕这个核心思想展开的。

2.3 系统调用与库函数的区别

操作系统内核不允许用户程序直接访问,它会对外暴露一套标准化的接口,供上层开发使用,这套接口就是系统调用(System Call)

系统调用是内核提供的最基础的功能,使用门槛较高,因此开发者会对部分系统调用进行封装,形成库函数(Library Function) ,方便上层用户二次开发。比如 C 语言的printf函数,底层就是封装了write系统调用;malloc函数底层封装了brk/mmap系统调用。

两者的核心区别如下:

特性 系统调用 库函数
所属层级 操作系统内核层,属于内核的一部分 用户层,运行在用户地址空间
运行状态 执行时会从用户态切换到内核态,完成后切回用户态 大部分时间运行在用户态,只有内部封装系统调用时会切换内核态
可移植性 强依赖操作系统,不同操作系统的系统调用接口不兼容 可移植性强,跨平台的库函数会适配不同系统的系统调用
功能粒度 功能基础、单一,只提供最核心的原子操作 功能更丰富,可组合多个系统调用完成复杂逻辑
调用开销 开销较大,涉及用户态与内核态的上下文切换 开销较小,无状态切换时只是普通函数调用

三、进程的核心概念:到底什么是进程?

有了前面的基础,我们终于可以回答核心问题:到底什么是进程?

3.1 进程的官方定义与内核视角

关于进程,有两个经典的定义:

  • 课本概念:进程是程序的一个执行实例,是正在执行的程序

  • 内核视角:进程是操作系统分配系统资源(CPU 时间、内存)的实体

而在 Linux 中,我们可以给出一个更精准、更落地的公式:

进程 = 内核数据结构 task_struct(PCB) + 进程对应的代码和数据

程序是存放在磁盘上的可执行文件,是静态的、死的;而进程是把程序加载到内存中运行起来的实例,是动态的、活的。同一个程序,可以同时启动多个进程,每个进程都有自己独立的内核数据结构和资源,互不干扰。

3.2 进程控制块 PCB 与 task_struct

操作系统要管理进程,必然要遵循 "先描述,再组织" 的思想。操作系统用来描述进程属性的结构体,叫做进程控制块(Process Control Block,简称 PCB)

PCB 是一个通用的概念,所有操作系统都有 PCB,而在 Linux 操作系统中,PCB 的具体实现就是 **task_struct结构体 **。它是 Linux 内核的核心数据结构,会被装载到内存中,包含了一个进程的所有属性信息。

3.2.1 task_struct 的核心内容分类

task_struct结构体的内容非常多,我们把核心字段分为 8 大类,每一类都对应着进程的一个核心属性:

分类 核心作用 包含的关键字段
标识符 唯一标识一个进程,区别于其他进程 PID(进程 ID)、PPID(父进程 ID)、UID/GID(用户 / 组 ID)
状态信息 标识进程当前的运行状态、退出信息 进程状态(R/S/D/T/Z 等)、退出码、退出信号
优先级 决定进程被 CPU 调度的先后顺序 PRI(进程优先级)、NI(nice 值)、调度策略
程序执行相关 保证进程指令的有序执行 程序计数器(PC,记录下一条要执行的指令地址)、内存指针(指向代码和数据的地址)
上下文数据 进程切换时保存的 CPU 现场 CPU 寄存器中的数据,进程切回时恢复现场
I/O 与文件信息 记录进程的 IO 操作和打开的文件 I/O 请求状态、分配的 I/O 设备、打开的文件描述符表
记账信息 统计进程的资源使用情况 CPU 使用时间总和、时钟数、内存占用、时间限制
内存管理 指向进程的地址空间 mm_struct指针,管理进程的虚拟地址空间
3.2.2 进程的组织方式

Linux 内核中,所有运行的进程,其task_struct结构体都以双向循环链表的形式组织在内核中。操作系统对进程的管理,就变成了对这个双链表的增删改查操作:

  • 创建进程:内核申请一块内存,初始化task_struct结构体,插入到链表中;

  • 终止进程:内核从链表中移除对应的task_struct,释放相关资源;

  • 调度进程:遍历链表,根据优先级、状态等信息,选择合适的进程交给 CPU 执行。

3.3 进程的基础操作:查看与标识

3.3.1 查看进程

Linux 中查看进程有两种核心方式,也是我们日常调试最常用的:

  1. 通过/proc文件系统查看 /proc是一个虚拟文件系统,它以文件的形式,向用户层暴露内核中进程的所有信息。系统中每个运行的进程,都会在/proc下有一个以 PID 命名的文件夹,比如 PID 为 1 的进程,信息都保存在/proc/1/目录下。

    查看PID为1的进程的详细信息

    ls /proc/1

    查看进程的状态信息

    cat /proc/1/status

    查看进程的内存映射信息

    cat /proc/1/maps

  2. 通过用户级工具查看 最常用的是pstop命令,ps用于查看进程的快照信息,top用于实时监控进程状态。

    查看系统所有进程的详细信息

    ps aux

    查看进程的父子关系、PID、PPID、优先级等信息

    ps axj

    过滤查看指定进程,比如test进程

    ps aux | grep test | grep -v grep

    实时监控进程状态

    top

3.3.2 获取进程标识符

Linux 提供了系统调用函数,让进程可以在代码中获取自己的 PID 和 PPID,函数定义如下:

复制代码
#include <sys/types.h>
#include <unistd.h>

// 获取当前进程的PID
pid_t getpid(void);
// 获取当前进程的父进程PID
pid_t getppid(void);

实操代码示例:

复制代码
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    printf("当前进程PID: %d\n", getpid());
    printf("当前进程的父进程PPID: %d\n", getppid());
    return 0;
}

编译运行后,你会发现父进程的 PPID,其实就是你当前执行程序的 bash 进程的 PID。

3.4 进程创建:fork 函数初识

在 Linux 中,创建一个新进程的核心方式,是通过fork系统调用。它会从一个已存在的进程(父进程)中,创建出一个新的进程(子进程)。

3.4.1 fork 函数的基本用法

函数定义:

复制代码
#include <unistd.h>
pid_t fork(void);

返回值规则是 fork 的核心,也是初学者最容易困惑的点:

  • 调用成功时,会给父进程返回子进程的 PID(大于 0 的整数)给子进程返回 0

  • 调用失败时,返回 - 1,并设置 errno。

很多人会问:为什么一个函数会有两个返回值?我当初刚接触这里的时候也卡了很久,其实核心逻辑很简单:当调用 fork 时,内核会完成以下动作:

  1. 为子进程分配新的内存块和内核数据结构(task_struct);

  2. 将父进程的 task_struct 大部分内容拷贝到子进程中;

  3. 为子进程设置独立的 PID、PPID 等标识符;

  4. 将子进程添加到系统的进程双链表中;

  5. fork 函数返回,调度器开始调度父子进程。

也就是说,fork 调用成功后,系统中会出现两个几乎完全一样的进程,父子进程会共享同一份代码,都从 fork 函数的返回处开始执行。父进程执行 fork 的返回逻辑,返回子进程的 PID;子进程也会执行 fork 的返回逻辑,返回 0,所以就出现了 "一个函数,两个返回值" 的现象。

3.4.2 fork 函数实操代码

fork 之后,我们通常会通过 if-else 分支,让父子进程执行不同的代码逻辑:

复制代码
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

int main()
{
    printf("fork执行前,当前进程PID: %d\n", getpid());
    
    pid_t ret = fork();
    if(ret < 0)
    {
        // fork调用失败
        perror("fork failed");
        exit(1);
    }
    else if(ret == 0)
    {
        // 子进程执行的分支
        printf("我是子进程,PID: %d,fork返回值: %d,我的父进程PPID: %d\n", 
               getpid(), ret, getppid());
    }
    else
    {
        // 父进程执行的分支
        printf("我是父进程,PID: %d,fork返回值: %d,我的子进程PID: %d\n", 
               getpid(), ret, ret);
    }

    sleep(1);
    return 0;
}

编译运行后,你会看到两个关键现象:

  1. fork执行前的日志只打印了一次,因为 fork 之前只有父进程一个执行流;

  2. 父子进程分别执行了对应的 if 分支,证明 fork 之后两个执行流分别独立执行。

这里还要提一个核心机制:写时拷贝。fork 之后,父子进程会共享同一份代码,数据在没有修改时也是共享的;当任意一方试图修改数据时,内核才会为修改的区域创建一份独立的副本,这就是写时拷贝。这个机制既保证了进程的独立性,又极大提升了 fork 的效率,我们在后面的虚拟地址空间部分,会详细拆解它的底层实现。

四、Linux 进程状态全解析

一个进程从创建到终止,会在不同的状态之间切换,理解进程状态,是我们排查进程卡死、僵尸进程、CPU 占用过高等问题的核心基础。

4.1 Linux 内核定义的 7 种进程状态

Linux 内核源码中,定义了 7 种进程状态,每种状态都对应着进程的一个运行阶段:

复制代码
static const char *const task_state_array[] = {
    "R (running)",		/* 0 */
    "S (sleeping)",		/* 1 */
    "D (disk sleep)",	/* 2 */
    "T (stopped)",		/* 4 */
    "t (tracing stop)",	/* 8 */
    "X (dead)",		    /* 16 */
    "Z (zombie)",		/* 32 */
};

我们逐个拆解每种状态的核心含义和场景:

1. R 运行状态(running)

核心定义:进程要么正在 CPU 上运行,要么位于运行队列中,等待被调度器调度执行。这里有一个非常关键的误区:很多人以为 R 状态就是进程正在 CPU 上运行,其实不是。在多核 CPU 中,同一时间能运行的进程数等于 CPU 核心数,其他等待执行的进程,只要在运行队列中,状态都是 R。

2. S 睡眠状态(sleeping)

核心定义:可中断睡眠状态,进程正在等待某个事件完成,事件完成后会被唤醒。处于 S 状态的进程,会被挂起等待,不占用 CPU 资源;可以通过信号唤醒,也可以在等待的事件完成后自动唤醒。我们系统中绝大多数进程,平时都处于 S 状态,比如 bash 进程等待用户输入、后台服务等待网络请求,都是 S 状态。

3. D 磁盘休眠状态(disk sleep)

核心定义 :不可中断睡眠状态,也叫深度睡眠状态,进程通常在等待硬件 IO 完成。和 S 状态最大的区别是:D 状态的进程不能被任何信号杀死 ,哪怕是kill -9也无能为力,只能等待 IO 操作完成,或者系统重启。这个状态的设计目的,是为了保证内核的 IO 操作能够顺利完成,防止进程被信号中断,导致磁盘数据和内核状态不一致。比如进程正在向磁盘写入大量数据,此时进程会进入 D 状态,直到写入完成。

4. T 停止状态(stopped)

核心定义 :进程被暂停运行,可以通过信号恢复执行。我们可以通过发送SIGSTOP信号让进程进入 T 状态,暂停运行;发送SIGCONT信号,让进程恢复运行,回到 R 状态。这个状态常用于进程调试,比如 gdb 调试时,断点处进程就会进入 T 状态。

5. t 追踪停止状态(tracing stop)

核心定义:进程被调试器追踪时的暂停状态,是 T 状态的一个细分场景。当进程被 ptrace 系统调用追踪时(比如 gdb 调试),每触发一个断点,进程就会进入 t 状态,等待调试器的下一步指令。

6. X 死亡状态(dead)

核心定义:进程最终的终止状态,只是一个瞬时返回状态,不会在任务列表中看到。进程彻底退出,所有资源被完全释放,内核会立刻销毁对应的 task_struct,这个状态转瞬即逝,我们用 ps 命令永远看不到 X 状态的进程。

7. Z 僵尸状态(zombie)

核心定义:进程已经退出,但是父进程没有读取到它的退出状态信息,导致进程的 task_struct 无法被释放,成为僵尸进程。这是我们必须重点关注的状态,也是面试的高频考点,下面单独详细拆解。

4.2 僵尸进程:形成原因、危害与解决方案

4.2.1 僵尸进程的形成原因

进程退出时,内核会释放进程的代码、数据、内存等资源,但是会保留进程的 task_struct 结构体,里面保存了进程的退出码、退出信号、资源使用统计等信息。内核需要把这些信息告诉父进程:"你交给我的任务,我完成得怎么样了"。只有当父进程通过wait/waitpid系统调用,读取了这些退出信息后,内核才会释放子进程的 task_struct 结构体,子进程才算彻底退出。

如果子进程已经退出,父进程还在运行,但是父进程没有读取子进程的退出状态,那么子进程就会一直处于 Z 状态,成为僵尸进程。

4.2.2 僵尸进程的代码复现
复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork failed");
        return 1;
    }
    else if(id > 0)
    {
        // 父进程:睡眠30秒,不处理子进程退出
        printf("父进程[%d]正在运行,不回收子进程\n", getpid());
        sleep(30);
    }
    else
    {
        // 子进程:睡眠5秒后直接退出
        printf("子进程[%d]即将退出,进入僵尸状态\n", getpid());
        sleep(5);
        exit(EXIT_SUCCESS);
    }
    return 0;
}

编译运行后,我们在另一个终端执行监控命令:

复制代码
while :; do ps aux | grep test | grep -v grep; sleep 1; done

可以看到,5 秒后子进程的状态变成了Z+,也就是僵尸状态,并且会一直持续到父进程退出。

4.2.3 僵尸进程的危害

很多初学者会觉得,僵尸进程已经不运行了,也不占用 CPU 和内存,能有什么危害?这里必须明确:僵尸进程会造成严重的内存泄漏

  • 进程的 task_struct 结构体本身就占用内存,只要进程处于 Z 状态,这个结构体就不会被释放;

  • 如果父进程创建了大量子进程,都不回收,就会有大量的 task_struct 驻留内存,耗尽系统的内存资源;

  • 僵尸进程的 PID 会一直被占用,而系统的 PID 数量是有上限的,大量僵尸进程会导致系统无法创建新的进程。

更关键的是,僵尸进程已经死亡,kill -9无法杀死它,因为你没办法杀死一个已经死去的进程。

4.2.4 僵尸进程的解决方案

核心解决方案只有一个:父进程必须主动回收子进程的退出信息 ,也就是通过wait/waitpid系统调用,我们会在进程控制部分详细讲解。如果父进程已经退出,僵尸进程会被 1 号 init/systemd 进程领养,init 进程会自动回收这些僵尸进程,释放资源。

4.3 孤儿进程

4.3.1 孤儿进程的形成原因

父进程先退出,子进程还在运行,这个子进程就成为了孤儿进程。Linux 内核中,所有孤儿进程都会被 1 号 init/systemd 进程领养,成为 1 号进程的子进程。当孤儿进程后续退出时,1 号进程会自动调用 wait 回收它的退出信息,所以孤儿进程不会变成僵尸进程,没有任何危害。

4.3.2 孤儿进程的代码复现
复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork failed");
        return 1;
    }
    else if(id == 0)
    {
        // 子进程:睡眠10秒,保证父进程先退出
        printf("我是子进程,PID: %d,初始PPID: %d\n", getpid(), getppid());
        sleep(10);
        printf("父进程退出后,我的PPID变成了: %d\n", getppid());
    }
    else
    {
        // 父进程:睡眠3秒后直接退出
        printf("我是父进程,PID: %d,即将退出\n", getpid());
        sleep(3);
        exit(EXIT_SUCCESS);
    }
    return 0;
}

编译运行后,你会看到:3 秒后父进程退出,10 秒后子进程打印的 PPID 变成了 1(systemd/init 进程),证明子进程被 1 号进程领养了。

4.4 进程状态转换流程图

我们把进程从创建到终止的所有状态转换,整理成一张完整的流程图,方便大家记忆:

复制代码
创建进程
   ↓
就绪态(R) ←─────────────────────┐
   ↓                              │
CPU调度,运行中(R)                │
   ↓                              │
┌──┴──────────────┐               │
│ 时间片耗尽      │ 等待事件发生  │
└─────────────────┘               │
   ↓                              │
等待事件 → 阻塞态(S/D) ───────────┘
   ↓
进程退出 → 僵尸态(Z) → 父进程回收 → 死亡态(X)
   ↓
收到SIGSTOP信号 → 停止态(T/t) → 收到SIGCONT信号 → 就绪态(R)

五、进程优先级与 Linux 调度机制

系统中的进程数量远多于 CPU 核心数,多个进程要竞争 CPU 资源,就需要有一套规则来决定:哪个进程先执行,哪个进程后执行,每个进程能执行多久。这套规则,就是进程优先级和调度机制。

5.1 进程优先级的核心概念

进程优先级,就是 CPU 分配资源的先后顺序。优先级越高的进程,越先被 CPU 调度执行,能获得更多的 CPU 时间片。在 Linux 多任务环境中,配置进程优先级,可以优化系统性能,让核心业务进程获得更多的 CPU 资源。

5.1.1 PRI 与 NI:优先级的两个核心字段

我们通过ps -l命令,可以看到进程的优先级相关字段:

复制代码
[whb@bite-alicloud ~]$ ps -l
F S   UID   PID  PPID  C PRI  NI ADDR SZ WCHAN  TTY          TIME CMD
0 S  1000 24278 24277  0  80   0 - 28919 do_wai pts/0    00:00:00 bash
0 R  1000 12572 24278  0  80   0 - 38328 -      pts/0    00:00:00 ps

其中两个核心字段:

  • PRI:进程的实际优先级,值越小,进程的优先级越高,越早被 CPU 执行;

  • NI:nice 值,进程优先级的修正数值,取值范围是 **-20 ~ 19**,一共 40 个级别。

两者的关系公式:

PRI(new) = PRI(old) + nice

这里有几个关键规则:

  1. 普通进程的 PRI 默认值是 80,NI 默认值是 0;

  2. 当 NI 为负值时,PRI (new) 会变小,进程优先级升高,能更早被执行;

  3. 当 NI 为正值时,PRI (new) 会变大,进程优先级降低;

  4. 普通用户只能设置 0~19 的正值 NI,降低进程优先级;只有 root 用户可以设置 - 20~19 的全范围 NI,提升进程优先级。

这里必须强调:nice 值不是进程优先级,它只是优先级的修正参数,不能直接等同于优先级

5.1.2 调整进程优先级的方法
  1. top 命令动态调整

    执行top命令

    top

    进入top后,按r键,输入要调整的进程PID,再输入nice值,回车即可完成调整

  2. 命令行工具调整

  • nice:启动进程时,直接指定 nice 值

    bash

    复制代码
    # 启动test程序,设置nice值为-10(需要root权限)
    nice -n -10 ./test
    # 启动test程序,设置nice值为5
    nice -n 5 ./test
  • renice:调整已经运行的进程的 nice 值

    bash

    复制代码
    # 把PID为1234的进程的nice值调整为-5
    renice -n -5 -p 1234
  1. 系统调用函数调整 通过getprioritysetpriority函数,可以在代码中获取和设置进程的 nice 值:

    #include <sys/time.h>
    #include <sys/resource.h>

    // 获取进程/进程组/用户的优先级
    int getpriority(int which, id_t who);
    // 设置进程/进程组/用户的优先级
    int setpriority(int which, id_t who, int prio);

5.2 进程的四大核心特性

在学习调度机制之前,我们先明确进程的四个核心特性,这是理解调度的基础:

  1. 竞争性:系统中进程数量众多,而 CPU 资源有限,所以进程之间天然具有竞争属性。优先级的设计,就是为了让竞争更有序、更高效。

  2. 独立性:多进程运行时,每个进程独享系统资源,进程之间互不干扰。一个进程崩溃,不会影响其他进程的运行,这是由进程的虚拟地址空间保证的。

  3. 并行:多个进程在多个 CPU 核心上,同时、分别运行,这就是并行。比如 4 核 CPU,可以同一时间真正运行 4 个进程。

  4. 并发:多个进程在一个 CPU 核心上,通过快速的进程切换,在一段时间内让多个进程都得以推进,这就是并发。我们平时说的 "单核同时运行多个程序",本质上就是并发,CPU 在多个进程之间快速切换,给用户一种 "同时运行" 的错觉。

5.3 Linux 进程调度机制

Linux 内核的调度器,经历了多个版本的迭代,我们重点讲解文档中提到的 Linux2.6 内核的 O (1) 调度算法,同时扩展现在主流的 CFS 完全公平调度器。

5.3.1 Linux2.6 内核:O (1) 调度算法

O (1) 调度算法的核心亮点是:无论系统中有多少个进程,选择下一个要执行的进程的时间复杂度都是常数 O (1),不会随着进程数量增加而变慢

1. O (1) 调度算法的核心数据结构

每个 CPU 核心都有一个独立的运行队列runqueue,里面包含两个核心的优先级数组:

  • active 数组:存放时间片还没有耗尽的进程,按照优先级排队;

  • expired 数组:存放时间片已经耗尽的进程,等待重新分配时间片。

每个优先级数组,都包含三个核心部分:

  • nr_active:数组中总共有多少个运行状态的进程;

  • bitmap:位图,用 5 个 32 位的比特位(共 140 位),标识对应优先级的队列是否为空,极大提升查找效率;

  • queue[140]:进程队列数组,数组下标就是优先级,每个元素是一个进程链表,相同优先级的进程按照 FIFO 规则排队。

Linux 中优先级分为 140 个级别:

  • 0~99:实时优先级,用于实时进程;

  • 100~139:普通优先级,对应 nice 值 - 20~19(nice=-20 对应优先级 100,nice=19 对应优先级 139)。

2. O (1) 调度算法的执行流程
  1. 调度器遍历 active 数组的 bitmap,找到第一个置 1 的比特位,对应的就是当前最高的非空优先级队列;

  2. 从该队列中取出第一个进程,交给 CPU 执行,直到进程的时间片耗尽,或者主动让出 CPU;

  3. 时间片耗尽的进程,会被移动到 expired 数组中,重新计算时间片;

  4. 当 active 数组中的所有进程都执行完毕,数组为空时,内核只需要交换 active 和 expired 两个指针的内容,expired 数组就变成了新的 active 数组,原来的 active 数组变成了空的 expired 数组,继续循环。

整个过程中,查找最高优先级进程的操作,通过 bitmap 只需要几次内存访问,和进程数量无关;数组交换更是指针操作,时间复杂度都是 O (1),这就是 O (1) 调度算法的核心精髓。

5.3.2 现代 Linux 内核:CFS 完全公平调度器

从 Linux2.6.23 版本开始,内核默认的调度器替换成了CFS(Completely Fair Scheduler,完全公平调度器),也是现在所有主流 Linux 发行版使用的调度器。

CFS 的核心设计思想是:让每个进程公平地分享 CPU 时间。它不再给进程分配固定的时间片,而是给每个进程定义一个 "虚拟运行时间(vruntime)",进程在 CPU 上运行的时间越长,vruntime 就越大;调度器永远选择 vruntime 最小的进程来执行。

CFS 用红黑树来组织进程,红黑树的 key 就是 vruntime,查找最小 vruntime 的进程的时间复杂度是 O (log n),虽然理论上比 O (1) 略高,但是在实际场景中,进程数量不会无限大,红黑树的性能完全足够,而且能实现更好的公平性,对交互式进程(比如桌面程序、shell)更加友好。

5.4 进程切换的核心原理

当调度器选择了一个新的进程执行时,就会发生进程切换(上下文切换)

CPU 内部的寄存器只有一套,但是每个进程都有自己独立的上下文数据。当进程 A 被切下 CPU 时,必须把当前 CPU 寄存器中的所有数据,保存到进程 A 的 task_struct 的上下文字段中;当进程 A 下次被调度执行时,再把保存的上下文数据重新加载到 CPU 寄存器中,恢复到之前的运行状态,继续执行。

这个保存和恢复的过程,就是进程上下文切换。它是有开销的:不仅要消耗 CPU 时间来保存和恢复数据,还会导致 CPU 缓存失效,频繁的上下文切换会严重降低系统性能。

六、环境变量全解

我们在 Linux 中执行命令、运行程序,都离不开环境变量的支持。环境变量是操作系统中用来指定运行环境的参数,它具有全局特性,能被子进程继承,是进程间传递配置信息的核心方式之一。

6.1 环境变量的核心概念与常见环境变量

环境变量本质上是操作系统提供的键值对,key 是环境变量名,value 是环境变量的值,用来配置系统的运行环境。

Linux 中最常见的三个环境变量:

  1. PATH:命令的搜索路径。我们在 shell 中执行 ls、pwd 等命令,不需要写全路径,就是因为 PATH 中保存了这些命令所在的目录,shell 会在 PATH 的目录中依次查找对应的可执行文件。

  2. HOME :用户的主工作目录。普通用户登录后,默认进入/home/用户名/目录,root 用户默认进入/root/目录,就是由 HOME 环境变量指定的。

  3. SHELL :当前使用的 shell 程序,默认值通常是/bin/bash

6.2 环境变量相关的常用命令

表格

命令 作用 示例
echo 查看指定环境变量的值 echo $PATHecho $HOME
export 设置 / 导出一个新的环境变量 export MYENV="hello world"
env 查看系统中所有的环境变量 env
unset 清除指定的环境变量 unset MYENV
set 查看所有本地定义的 shell 变量和环境变量 set
实操示例:把自己的程序加入 PATH

很多初学者会疑惑:为什么系统命令可以直接执行,自己写的程序必须加./才能执行?因为当前目录不在 PATH 环境变量中。我们可以通过以下命令,把程序所在目录加入 PATH:

复制代码
# 把当前目录加入PATH,临时生效,终端关闭后失效
export PATH=$PATH:./
# 此时再执行自己的程序,就不需要加./了
test

如果要永久生效,需要把 export 命令写入~/.bashrc/etc/profile文件中,重启终端或执行source命令即可。

6.3 环境变量的组织方式

Linux 中,每个进程都会收到一张环境表 ,环境表是一个字符指针数组,数组中的每个指针,都指向一个以\0结尾的环境变量字符串,数组的最后一个元素是 NULL。

环境表的结构如下:

复制代码
environ → [0] → "HOME=/home/whb\0"
          [1] → "PATH=/usr/bin:/bin\0"
          [2] → "SHELL=/bin/bash\0"
          [3] → "USER=whb\0"
          ...
          [n] → NULL

系统中定义了一个全局变量environ,它指向这个环境表的起始地址,我们可以通过这个变量,访问进程的所有环境变量。

6.4 代码中获取环境变量的三种方式

方式 1:通过 main 函数的第三个参数

main 函数有三个参数,第三个参数就是环境表,和命令行参数的 argv 数组格式完全一致:

复制代码
#include <stdio.h>

// argc: 命令行参数个数
// argv: 命令行参数数组
// env: 环境表数组
int main(int argc, char *argv[], char *env[])
{
    // 遍历环境表,打印所有环境变量
    for(int i = 0; env[i]; i++)
    {
        printf("%s\n", env[i]);
    }
    return 0;
}
方式 2:通过全局变量 environ

libc 库中定义了全局变量environ,它指向环境表,不需要在头文件中声明,使用时需要用 extern 关键字声明:

复制代码
#include <stdio.h>

int main(int argc, char *argv[])
{
    // 声明全局变量environ
    extern char **environ;
    
    // 遍历环境表
    for(int i = 0; environ[i]; i++)
    {
        printf("%s\n", environ[i]);
    }
    return 0;
}
方式 3:通过系统调用 getenv/setenv

前两种方式是遍历所有环境变量,而getenv函数可以根据环境变量名,精准获取对应的 value,是最常用的方式:

复制代码
#include <stdio.h>
#include <stdlib.h>

int main()
{
    // 获取PATH环境变量的值
    char *path = getenv("PATH");
    if(path != NULL)
    {
        printf("PATH: %s\n", path);
    }

    // 获取自定义环境变量MYENV
    char *myenv = getenv("MYENV");
    if(myenv != NULL)
    {
        printf("MYENV: %s\n", myenv);
    }
    else
    {
        printf("MYENV环境变量不存在\n");
    }

    return 0;
}

对应的,setenvputenv函数可以用来设置环境变量,unsetenv函数用来删除环境变量。

6.5 环境变量的全局属性:被子进程继承

环境变量有一个非常重要的特性:具有全局属性,可以被子进程继承下去

我们做一个测试:先在 shell 中导出一个自定义环境变量,再运行上面的程序,会发现程序能成功获取到这个环境变量。这是因为我们的程序是 shell 的子进程,shell 的环境变量被子进程继承了。

而如果我们只在 shell 中定义一个普通变量,不通过 export 导出,那么子进程是无法获取到的。因为普通变量是 shell 的本地变量,不会被子进程继承,只有通过 export 导出的环境变量,才会被放入环境表中,传递给子进程。

这个特性,也是 shell 中很多配置生效的核心原理,比如我们修改了 PATH,子进程都能继承到新的 PATH 值。

七、进程地址空间与虚拟内存

很多初学者在刚接触 fork 的时候,都会遇到一个 "反常识" 的现象:父子进程中,同一个变量的虚拟地址完全一样,但是变量的值却不一样。这背后,就是 Linux 的虚拟地址空间机制,也是操作系统内存管理的核心。

7.1 C 语言程序的内存布局

我们在学习 C 语言的时候,都见过这张经典的内存布局图,在 32 位 Linux 系统中,进程的 4GB 地址空间,被划分为多个区域,从低地址到高地址依次是:

内存区域 核心作用 地址增长方向
正文代码段 存放程序的可执行代码、只读常量,权限只读,不可修改 固定地址
初始化数据段 存放已经初始化的全局变量、静态变量 向上增长
未初始化数据段(BSS) 存放未初始化的全局变量、静态变量,默认初始化为 0 向上增长
堆区 动态内存分配的区域,malloc/new 申请的内存都在这里 从低地址向高地址增长
共享库映射区 存放动态链接库的映射,比如 libc.so 固定区域
栈区 存放函数的局部变量、函数参数、返回值,函数调用栈 从高地址向低地址增长
命令行参数 & 环境变量 存放 main 函数的 argc/argv 参数、环境变量字符串 高地址固定区域
内核空间 32 位系统中,高地址的 1GB 空间,属于内核,用户进程无法直接访问 高地址固定区域

我们可以通过代码,验证各个区域的地址分布:

复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

// 未初始化全局变量,BSS段
int g_unval;
// 已初始化全局变量,数据段
int g_val = 100;

int main(int argc, char *argv[], char *env[])
{
    // 只读常量,代码段
    const char *str = "hello world";
    // 静态变量,数据段
    static int test = 10;
    // 堆区动态内存
    char *heap_mem1 = (char*)malloc(10);
    char *heap_mem2 = (char*)malloc(10);
    char *heap_mem3 = (char*)malloc(10);

    printf("代码段地址(main函数): %p\n", main);
    printf("只读常量地址: %p\n", str);
    printf("已初始化全局变量地址: %p\n", &g_val);
    printf("静态变量地址: %p\n", &test);
    printf("未初始化全局变量地址: %p\n", &g_unval);
    printf("堆区地址1: %p\n", heap_mem1);
    printf("堆区地址2: %p\n", heap_mem2);
    printf("堆区地址3: %p\n", heap_mem3);
    printf("栈区地址1: %p\n", &heap_mem1);
    printf("栈区地址2: %p\n", &heap_mem2);
    printf("栈区地址3: %p\n", &heap_mem3);

    // 命令行参数和环境变量
    for(int i = 0; i < argc; i++)
    {
        printf("argv[%d]地址: %p\n", i, argv[i]);
    }
    for(int i = 0; env[i]; i++)
    {
        printf("env[%d]地址: %p\n", i, env[i]);
    }

    free(heap_mem1);
    free(heap_mem2);
    free(heap_mem3);
    return 0;
}

编译运行后,你会清晰地看到各个区域的地址分布,完全符合上面的布局规则:堆区地址依次递增,栈区地址依次递减。

7.2 虚拟地址 vs 物理地址

上面代码中打印的所有地址,都不是真正的物理内存地址,而是虚拟地址(也叫线性地址)

我们通过一个经典的 fork 代码,彻底理解虚拟地址:

复制代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

// 全局变量
int g_val = 0;

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork failed");
        return 0;
    }
    else if(id == 0)
    {
        // 子进程修改全局变量
        g_val = 100;
        printf("子进程:g_val = %d,地址 = %p\n", g_val, &g_val);
    }
    else
    {
        // 父进程等待子进程修改完成
        sleep(3);
        printf("父进程:g_val = %d,地址 = %p\n", g_val, &g_val);
    }

    sleep(1);
    return 0;
}

编译运行后,输出结果如下:

复制代码
子进程:g_val = 100,地址 = 0x80497e8
父进程:g_val = 0,地址 = 0x80497e8

这里出现了一个 "反常识" 的现象:同一个地址,存放了两个不同的值。这就证明了:我们在 C/C++ 中看到的所有地址,都不是物理地址,而是虚拟地址。同一个虚拟地址,在父子进程中,通过页表映射到了不同的物理内存地址,所以才会出现地址相同、值不同的现象。

7.3 虚拟地址空间的底层实现

7.3.1 核心数据结构

每个进程都有自己独立的虚拟地址空间,Linux 内核中,用两个核心结构体来描述虚拟地址空间:

  1. mm_struct :内存描述符,描述整个进程的虚拟地址空间,每个进程只有一个mm_struct,在进程的task_struct中,有一个指针指向它。mm_struct中记录了各个内存段的起始和结束地址:start_code(代码段起始)、end_code(代码段结束)、start_data(数据段起始)、end_data(数据段结束)、start_brk(堆区起始)、brk(堆区当前结束)、start_stack(栈区起始)、arg_start/arg_end(命令行参数起止)、env_start/env_end(环境变量起止)。

  2. vm_area_struct :虚拟内存区域(VMA)描述符,描述虚拟地址空间中一个独立的内存区域。每个不同性质的内存段(代码段、数据段、堆、栈、共享库映射区),都对应一个vm_area_struct结构体。这些 VMA 结构体,会通过链表和红黑树两种方式组织起来:虚拟区较少时用单链表,虚拟区较多时用红黑树,保证查找效率。

7.3.2 页表与 MMU:虚拟地址到物理地址的转换

虚拟地址要能真正访问到物理内存,必须通过页表 完成地址转换,而地址转换的硬件单元,是 CPU 中的MMU(内存管理单元)

整个转换过程如下:

  1. 进程执行时,CPU 访问的是虚拟地址;

  2. 虚拟地址被送入 MMU,MMU 根据页表,把虚拟地址转换成对应的物理地址;

  3. CPU 通过转换后的物理地址,访问真正的物理内存。

页表中不仅保存了虚拟地址和物理地址的映射关系,还保存了内存页的权限信息(可读、可写、可执行)。比如代码段的页表项,权限是只读 + 可执行,如果你尝试修改代码段的内容,MMU 会检测到权限违规,触发段错误(Segmentation Fault)。

而 fork 的写时拷贝机制,底层也是通过页表实现的:fork 之后,父子进程的页表,会把数据段的内存页设置为只读,此时父子进程共享同一块物理内存;当任意一方尝试修改数据时,CPU 会触发缺页中断,内核在中断处理函数中,为该内存页创建一份独立的物理副本,修改页表映射,把页的权限设置为可写,然后再完成修改操作。这就是写时拷贝的底层原理。

7.4 为什么要有虚拟地址空间?

如果程序直接访问物理内存,会出现很多问题,而虚拟地址空间的设计,完美解决了这些问题,核心优势有三点:

1. 内存安全,进程隔离

每个进程都有自己独立的虚拟地址空间和页表,进程 A 的虚拟地址,只能映射到进程 A 的物理内存,无法访问进程 B 的物理内存,更无法访问内核的物理内存。这就实现了进程之间的完全隔离,一个进程的崩溃、内存越界,不会影响其他进程和内核,极大提升了系统的稳定性和安全性。

如果没有虚拟地址,所有进程都直接访问物理内存,恶意程序可以随意修改其他进程的内存数据,甚至内核数据,系统会毫无安全性可言。

2. 解耦内存管理,提升效率

有了虚拟地址空间,进程的内存管理和物理内存的分配完全解耦:

  • 进程只需要关心自己的虚拟地址空间,不需要关心物理内存的实际位置,也不需要关心物理内存是否连续;

  • 内核可以通过页表,把分散的物理内存页,映射成进程的连续虚拟地址空间,完美解决了物理内存的碎片化问题;

  • 实现了延迟分配:进程调用 malloc 申请内存时,内核只是在虚拟地址空间中分配了一块区域,并没有分配实际的物理内存;只有当进程真正访问这块内存时,内核才会触发缺页中断,分配物理内存,构建页表映射。这极大提升了内存的使用率,避免了内存浪费。

3. 统一进程的地址空间视图

每个进程的虚拟地址空间布局都是统一的,比如 32 位系统中,代码段都从低地址开始,栈区都在高地址,内核空间都在最高的 1GB。这让编译器、链接器的设计变得非常简单,编译器不需要关心程序运行时的物理地址,只需要按照统一的虚拟地址空间布局,编译生成可执行文件即可。程序加载时,内核只需要为进程构建页表映射,就能正常运行。

八、进程控制全链路实操

前面我们讲了进程的核心概念,现在我们进入实操环节,完整讲解 Linux 进程控制的四大核心操作:进程创建、进程终止、进程等待、进程程序替换,最终通过手写一个简易 shell,把所有知识点落地。

8.1 进程创建:fork 与 vfork

8.1.1 fork 函数的底层执行流程

我们在前面已经初识了 fork 函数,这里再深入讲解 fork 调用时,内核的完整执行流程:

  1. 为子进程分配新的 task_struct 结构体,从父进程拷贝大部分内容;

  2. 为子进程分配新的 PID,设置 PPID 为父进程的 PID;

  3. 初始化子进程的内存管理结构,复制父进程的 mm_struct,构建子进程的页表,实现代码共享、数据写时拷贝;

  4. 复制父进程的文件描述符表,子进程会继承父进程打开的所有文件;

  5. 复制父进程的信号处理方式、环境变量、命名空间等信息;

  6. 将子进程添加到系统的进程双链表中,设置为 R 状态,等待调度;

  7. fork 函数返回,父子进程开始被调度器调度执行。

8.1.2 fork 的常规用法

fork 有两个非常经典的应用场景:

  1. 父进程复制自己,父子进程同时执行不同的代码段:比如网络服务器,父进程监听客户端请求,当有新的请求到来时,fork 一个子进程来处理这个请求,父进程继续监听新的请求。

  2. 子进程执行一个全新的程序:子进程从 fork 返回后,调用 exec 系列函数,执行一个完全不同的程序,比如 shell 中执行命令,就是 fork 子进程后,调用 exec 执行对应的命令程序。

8.1.3 fork 调用失败的原因

fork 调用失败,通常只有两个原因:

  1. 系统中的进程数量已经达到了系统上限,无法创建新的进程;

  2. 实际用户的进程数超过了系统限制,Linux 中可以通过ulimit -u命令查看普通用户允许创建的最大进程数。

8.1.4 vfork 函数:fork 的特殊版本

vfork 函数也是用来创建子进程,和 fork 的核心区别是:

  • vfork 创建的子进程,会完全共享父进程的地址空间,不会进行写时拷贝;

  • vfork 会保证子进程先运行,子进程调用 exec 或 exit 之后,父进程才会被调度运行。

vfork 的设计目的,是为了提升 fork 的效率,因为子进程创建后马上就会调用 exec 执行新程序,不需要复制父进程的地址空间。但是现在 fork 有了写时拷贝机制,vfork 的性能优势已经几乎没有了,而且 vfork 很容易出现问题,现在已经不推荐使用了。

8.2 进程终止

进程终止,本质上就是释放进程占用的系统资源,包括内核数据结构、代码、数据、内存、文件描述符等。

8.2.1 进程退出的三大场景
  1. 代码运行完毕,结果正确:程序正常执行完成,返回正确的结果;

  2. 代码运行完毕,结果不正确:程序正常执行完成,但是执行结果不符合预期;

  3. 代码异常终止:程序运行过程中出现错误,被信号终止,比如段错误、除零错误等。

8.2.2 进程退出码

进程正常退出时,会返回一个退出码,告诉父进程自己的执行结果。Linux 中约定:

  • 退出码 0:表示程序执行成功,结果正确;

  • 退出码非 0:表示程序执行失败,不同的数值对应不同的错误原因。

我们在 shell 中,可以通过echo $?命令,查看上一个执行的程序的退出码。

Linux Shell 中常见的退出码含义:

退出码 核心含义
0 命令 / 程序执行成功
1 通用错误,比如权限不足、非法操作
2 命令 / 参数使用不当
126 权限被拒绝,无法执行
127 未找到命令,PATH 路径错误
128+n 被信号 n 终止,比如 130=128+2(SIGINT,Ctrl+C 终止)
130 被 Ctrl+C 中断终止
143 被 SIGTERM 信号终止(默认终止信号)

注意:虽然退出码是 int 类型,但是只有低 8 位会被父进程获取,所以退出码的有效范围是 0~255,超过 255 的话,会对 256 取模。

8.2.3 进程正常退出的三种方式
1. main 函数 return 返回

这是最常见的退出方式,main 函数的 return n,本质上等同于执行 exit (n),因为调用 main 函数的运行时函数,会把 main 的返回值当做 exit 的参数。

注意:只有 main 函数的 return 会终止进程,普通函数的 return 只会结束函数的执行,不会终止进程。

2. ext 函数

exit 函数是 C 标准库提供的函数,用来终止进程,定义如下:

复制代码
#include <stdlib.h>
void exit(int status);

参数 status 就是进程的退出码,父进程可以通过 wait 获取。

exit 函数在终止进程之前,会完成一系列清理工作:

  1. 执行用户通过 atexit/on_exit 注册的清理函数;

  2. 刷新所有打开的文件流,把缓冲区中的数据写入文件,关闭所有打开的流;

  3. 调用_exit 系统调用,进入内核,释放进程资源,终止进程。

3. _exit 函数

_exit 函数是 Linux 系统调用,直接终止进程,定义如下:

复制代码
#include <unistd.h>
void _exit(int status);

和 exit 函数最大的区别是:_exit 不会做任何清理工作,直接进入内核,释放进程资源,终止进程。它不会执行用户注册的清理函数,不会刷新文件缓冲区,甚至不会刷新 printf 的输出缓冲区。

我们通过代码验证两者的区别:

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    // printf没有加\n,数据会保存在缓冲区中,不会立即输出
    printf("hello world");
    
    // 分别注释下面两行,查看运行结果
    exit(0);
    // _exit(0);
}

编译运行后,使用 exit 的版本会打印hello world,而使用_exit 的版本不会打印任何内容,因为_exit 不会刷新缓冲区,缓冲区中的数据直接被丢弃了。

8.2.4 进程异常退出

进程异常退出,通常是两种情况:

  1. 进程收到了无法处理的信号,比如kill -9发送的 SIGKILL 信号、段错误触发的 SIGSEGV 信号、除零错误触发的 SIGFPE 信号等;

  2. 进程在调试过程中,收到了断点信号,被暂停终止。

8.3 进程等待

前面我们讲僵尸进程的时候提到过,子进程退出后,父进程必须调用 wait/waitpid 回收子进程的退出信息,否则子进程会变成僵尸进程,造成内存泄漏。这就是进程等待的核心作用。

8.3.1 进程等待的必要性
  1. 回收子进程资源,避免内存泄漏:这是最核心的作用,只有父进程调用 wait/waitpid,内核才会释放子进程的 task_struct 结构体;

  2. 获取子进程的退出状态,了解任务执行结果:父进程可以通过 wait/waitpid,获取子进程的退出码、终止信号,知道子进程是正常退出还是异常终止,执行结果是否正确;

  3. 保证进程的执行时序:父进程可以通过等待,保证子进程先执行完成,父进程再退出。

8.3.2 wait 函数

wait 函数是最基础的进程等待函数,定义如下:

复制代码
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);

参数说明

  • status:输出型参数,操作系统会把子进程的退出状态信息填充到这个变量中;如果不关心子进程的退出状态,可以设置为 NULL。

返回值说明

  • 成功:返回被回收的子进程的 PID;

  • 失败:返回 - 1(比如当前进程没有任何子进程)。

核心特性 :wait 函数是阻塞等待,如果调用 wait 时,没有任何子进程退出,父进程会一直阻塞在这里,直到有子进程退出,完成回收后才会返回。

代码示例:

复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
    pid_t id = fork();
    if(id < 0)
相关推荐
skilllite作者1 小时前
SkillLite Rust 沙箱与 AI Agent 自进化实战指南
开发语言·人工智能·后端·架构·rust
Strange_Head1 小时前
补充知识点`makefile`、`config`、`GLP协议` 3/3 ——《驱动篇》《Linux历史发展》
linux·运维·服务器
.柒宇.1 小时前
prometheus-入门与安装
运维·服务器·prometheus·监控
QCzblack2 小时前
php-ser-libs
android·开发语言·php
Cando学算法2 小时前
回声服务器项目
linux·开发语言·c++·计算机网络·ubuntu
宝耶2 小时前
[特殊字符] 操作日志模块复习笔记
java·开发语言·jvm
不想写代码的星星2 小时前
我写的代码竟然敢和我比摸鱼?C++ 延迟计算那些事儿
c++
好好研究2 小时前
Java基础学习(十三):IO流基础
java·开发语言·学习·io流
maxchen.cn2 小时前
实时交互数字人解决方案深度剖析:以臻灵平台为例的商业价值与竞争力评估
大数据·人工智能