[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 上给朋友发送一条消息,数据经历了什么?
-
你通过键盘输入消息,键盘作为输入设备,将输入的字符数据写入内存中 QQ 进程的地址空间;
-
CPU 从内存中读取消息数据,完成协议封装(比如 TCP/IP 协议),将封装后的数据写回内存;
-
网卡作为输出设备,从内存中读取封装好的数据包,通过网络发送到对方的电脑网卡;
-
对方的网卡作为输入设备,将接收到的数据包写入内存;
-
对方的 CPU 从内存中读取数据包,完成协议解析,提取出消息内容写回内存;
-
显示器作为输出设备,从内存中读取消息内容,渲染到对方的 QQ 聊天窗口中。
哪怕是发送文件,本质上也是一样的逻辑:先把磁盘中的文件数据加载到内存,再通过网卡发送出去,全程 CPU 不会直接操作磁盘和网卡,所有数据交互都以内存为核心中转站。
二、操作系统的核心定位:搞管理的软件
有了硬件基础,还需要操作系统来管理这些硬件资源,为上层应用提供稳定的运行环境。而进程,正是操作系统管理硬件资源的核心载体。
2.1 操作系统的定义与层级架构
操作系统(OS)是一套管理计算机软硬件资源的系统软件,分为狭义和广义两个维度:
-
狭义操作系统:特指 Linux 内核(Kernel),核心包括进程管理、内存管理、文件管理、驱动管理四大模块;
-
广义操作系统:内核 + 外壳程序(shell)、函数库(glibc)、预装系统级软件等一整套运行环境。
整个计算机系统的层级架构如下,从上到下分为三层,严格遵循 "用户不能直接操作硬件,必须通过操作系统内核" 的规则:
┌─────────────────────────────────┐
│ 用户层:应用程序、shell、库函数 │
├─────────────────────────────────┤
│ 系统调用接口:OS暴露的内核API │
├─────────────────────────────────┤
│ 操作系统内核:进程/内存/文件/驱动 │
├─────────────────────────────────┤
│ 驱动程序:硬件的专属控制程序 │
├─────────────────────────────────┤
│ 底层硬件:CPU、内存、磁盘、网卡等 │
└─────────────────────────────────┘
2.2 操作系统的核心设计思想:先描述,再组织
操作系统的核心功能是 "管理"------ 管理上百个进程、几十 GB 内存、成千上万的文件和硬件设备。而管理的核心方法论,只有 8 个字:先描述,再组织。
我们用一个生活中的例子理解:校长要管理全校上万名学生,不可能亲自盯着每个学生的一举一动。他会先做两件事:
-
描述:用学生信息表(结构体)定义学生的属性,包括学号、姓名、年龄、班级、成绩等,把一个活生生的学生,抽象成一组可管理的数据;
-
组织:用链表、数组、红黑树等数据结构,把所有学生的信息表组织起来,比如按班级分链表、按学号建索引。
后续所有的管理动作(增删改查、统计排名、评优处罚),都变成了对数据结构的操作,而不是直接面对每个学生。
操作系统管理硬件和进程,也是完全一样的逻辑:
-
描述 :用
struct结构体描述被管理对象,比如用task_struct描述进程、用mm_struct描述进程地址空间、用inode描述文件; -
组织 :用链表、红黑树等高效数据结构,把这些结构体组织起来,比如所有进程的
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 中查看进程有两种核心方式,也是我们日常调试最常用的:
-
通过
/proc文件系统查看/proc是一个虚拟文件系统,它以文件的形式,向用户层暴露内核中进程的所有信息。系统中每个运行的进程,都会在/proc下有一个以 PID 命名的文件夹,比如 PID 为 1 的进程,信息都保存在/proc/1/目录下。查看PID为1的进程的详细信息
ls /proc/1
查看进程的状态信息
cat /proc/1/status
查看进程的内存映射信息
cat /proc/1/maps
-
通过用户级工具查看 最常用的是
ps和top命令,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 时,内核会完成以下动作:
-
为子进程分配新的内存块和内核数据结构(task_struct);
-
将父进程的 task_struct 大部分内容拷贝到子进程中;
-
为子进程设置独立的 PID、PPID 等标识符;
-
将子进程添加到系统的进程双链表中;
-
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;
}
编译运行后,你会看到两个关键现象:
-
fork执行前的日志只打印了一次,因为 fork 之前只有父进程一个执行流; -
父子进程分别执行了对应的 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
这里有几个关键规则:
-
普通进程的 PRI 默认值是 80,NI 默认值是 0;
-
当 NI 为负值时,PRI (new) 会变小,进程优先级升高,能更早被执行;
-
当 NI 为正值时,PRI (new) 会变大,进程优先级降低;
-
普通用户只能设置 0~19 的正值 NI,降低进程优先级;只有 root 用户可以设置 - 20~19 的全范围 NI,提升进程优先级。
这里必须强调:nice 值不是进程优先级,它只是优先级的修正参数,不能直接等同于优先级。
5.1.2 调整进程优先级的方法
-
top 命令动态调整
执行top命令
top
进入top后,按r键,输入要调整的进程PID,再输入nice值,回车即可完成调整
-
命令行工具调整
-
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
-
系统调用函数调整 通过
getpriority和setpriority函数,可以在代码中获取和设置进程的 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 进程的四大核心特性
在学习调度机制之前,我们先明确进程的四个核心特性,这是理解调度的基础:
-
竞争性:系统中进程数量众多,而 CPU 资源有限,所以进程之间天然具有竞争属性。优先级的设计,就是为了让竞争更有序、更高效。
-
独立性:多进程运行时,每个进程独享系统资源,进程之间互不干扰。一个进程崩溃,不会影响其他进程的运行,这是由进程的虚拟地址空间保证的。
-
并行:多个进程在多个 CPU 核心上,同时、分别运行,这就是并行。比如 4 核 CPU,可以同一时间真正运行 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) 调度算法的执行流程
-
调度器遍历 active 数组的 bitmap,找到第一个置 1 的比特位,对应的就是当前最高的非空优先级队列;
-
从该队列中取出第一个进程,交给 CPU 执行,直到进程的时间片耗尽,或者主动让出 CPU;
-
时间片耗尽的进程,会被移动到 expired 数组中,重新计算时间片;
-
当 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 中最常见的三个环境变量:
-
PATH:命令的搜索路径。我们在 shell 中执行 ls、pwd 等命令,不需要写全路径,就是因为 PATH 中保存了这些命令所在的目录,shell 会在 PATH 的目录中依次查找对应的可执行文件。
-
HOME :用户的主工作目录。普通用户登录后,默认进入
/home/用户名/目录,root 用户默认进入/root/目录,就是由 HOME 环境变量指定的。 -
SHELL :当前使用的 shell 程序,默认值通常是
/bin/bash。
6.2 环境变量相关的常用命令
表格
| 命令 | 作用 | 示例 |
|---|---|---|
| echo | 查看指定环境变量的值 | echo $PATH、echo $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;
}
对应的,setenv和putenv函数可以用来设置环境变量,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 内核中,用两个核心结构体来描述虚拟地址空间:
-
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(环境变量起止)。 -
vm_area_struct:虚拟内存区域(VMA)描述符,描述虚拟地址空间中一个独立的内存区域。每个不同性质的内存段(代码段、数据段、堆、栈、共享库映射区),都对应一个vm_area_struct结构体。这些 VMA 结构体,会通过链表和红黑树两种方式组织起来:虚拟区较少时用单链表,虚拟区较多时用红黑树,保证查找效率。
7.3.2 页表与 MMU:虚拟地址到物理地址的转换
虚拟地址要能真正访问到物理内存,必须通过页表 完成地址转换,而地址转换的硬件单元,是 CPU 中的MMU(内存管理单元)。
整个转换过程如下:
-
进程执行时,CPU 访问的是虚拟地址;
-
虚拟地址被送入 MMU,MMU 根据页表,把虚拟地址转换成对应的物理地址;
-
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 调用时,内核的完整执行流程:
-
为子进程分配新的 task_struct 结构体,从父进程拷贝大部分内容;
-
为子进程分配新的 PID,设置 PPID 为父进程的 PID;
-
初始化子进程的内存管理结构,复制父进程的 mm_struct,构建子进程的页表,实现代码共享、数据写时拷贝;
-
复制父进程的文件描述符表,子进程会继承父进程打开的所有文件;
-
复制父进程的信号处理方式、环境变量、命名空间等信息;
-
将子进程添加到系统的进程双链表中,设置为 R 状态,等待调度;
-
fork 函数返回,父子进程开始被调度器调度执行。
8.1.2 fork 的常规用法
fork 有两个非常经典的应用场景:
-
父进程复制自己,父子进程同时执行不同的代码段:比如网络服务器,父进程监听客户端请求,当有新的请求到来时,fork 一个子进程来处理这个请求,父进程继续监听新的请求。
-
子进程执行一个全新的程序:子进程从 fork 返回后,调用 exec 系列函数,执行一个完全不同的程序,比如 shell 中执行命令,就是 fork 子进程后,调用 exec 执行对应的命令程序。
8.1.3 fork 调用失败的原因
fork 调用失败,通常只有两个原因:
-
系统中的进程数量已经达到了系统上限,无法创建新的进程;
-
实际用户的进程数超过了系统限制,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 进程退出的三大场景
-
代码运行完毕,结果正确:程序正常执行完成,返回正确的结果;
-
代码运行完毕,结果不正确:程序正常执行完成,但是执行结果不符合预期;
-
代码异常终止:程序运行过程中出现错误,被信号终止,比如段错误、除零错误等。
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 函数在终止进程之前,会完成一系列清理工作:
-
执行用户通过 atexit/on_exit 注册的清理函数;
-
刷新所有打开的文件流,把缓冲区中的数据写入文件,关闭所有打开的流;
-
调用_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 进程异常退出
进程异常退出,通常是两种情况:
-
进程收到了无法处理的信号,比如
kill -9发送的 SIGKILL 信号、段错误触发的 SIGSEGV 信号、除零错误触发的 SIGFPE 信号等; -
进程在调试过程中,收到了断点信号,被暂停终止。
8.3 进程等待
前面我们讲僵尸进程的时候提到过,子进程退出后,父进程必须调用 wait/waitpid 回收子进程的退出信息,否则子进程会变成僵尸进程,造成内存泄漏。这就是进程等待的核心作用。
8.3.1 进程等待的必要性
-
回收子进程资源,避免内存泄漏:这是最核心的作用,只有父进程调用 wait/waitpid,内核才会释放子进程的 task_struct 结构体;
-
获取子进程的退出状态,了解任务执行结果:父进程可以通过 wait/waitpid,获取子进程的退出码、终止信号,知道子进程是正常退出还是异常终止,执行结果是否正确;
-
保证进程的执行时序:父进程可以通过等待,保证子进程先执行完成,父进程再退出。
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)