Linux 进程概念

进程是 Linux 系统编程的核心概念。后续学习进程控制、信号、线程、进程间通信和网络编程,都建立在进程模型之上。

原笔记中的 20 张技术配图尚未随仓库迁移。为避免线上出现坏图,当前已暂时隐藏图片引用;原文件名保留在源码注释中,补回资源后即可恢复。

本文按照下面的逻辑展开:

  1. 冯诺依曼体系解释程序为什么必须进入内存;
  2. 操作系统解释 Linux 如何管理软硬件资源;
  3. PCB 解释操作系统如何描述和组织进程;
  4. 进程状态与调度解释多个进程如何共享 CPU;
  5. 环境变量解释进程运行环境如何传递;
  6. 虚拟地址空间解释进程独立性与写时拷贝。

1. 冯诺依曼体系结构

1.1 计算机的基本组成

经典冯诺依曼体系主要包含四类部件:

  • 输入设备:键盘、鼠标、网卡等;
  • 输出设备:显示器、打印机、网卡等;
  • 存储器:这里主要指内存;
  • 中央处理器:由运算器、控制器和寄存器等部分组成。

磁盘、网卡、键盘和显示器相对于 CPU 与内存都属于外设。其中,磁盘和网卡既可以输入数据,也可以输出数据。

各部件的速度和作用可以简单概括为:

部件 主要作用 速度特征 是否掉电丢失
CPU 执行指令、处理数据 最快 寄存器内容会丢失
内存 暂存正在运行的代码和数据 较快 是
磁盘等外设 长期保存或交换数据 相对较慢 通常不会

CPU 只能执行自身指令集中的机器指令。C/C++ 源代码经过预处理、编译、汇编和链接,最终被转换为 CPU 能够执行的二进制程序。

编译器的重要作用,就是把高级语言翻译成目标平台能够执行的机器指令,并组织为可执行文件。

1.2 为什么需要内存

CPU 的执行速度远高于磁盘、网卡等外设。如果 CPU 每执行一条指令都直接等待慢速外设,整机效率会非常低。因此,正在使用的代码和数据需要先进入内存,再由 CPU 访问。

从学习模型上看,可以把数据流理解为:

1
2
读数据:外设 → 内存 → CPU
写数据:CPU → 内存 → 外设

严格来说,现代计算机还包含缓存、设备控制器、DMA、内存映射 I/O 等机制,因此“CPU 只和内存打交道”是一种便于入门的简化表达。它强调的核心是:CPU 执行的指令和主要数据必须处于可被处理器访问的地址空间中。

1.3 程序为什么要加载到内存

写好的程序最初只是磁盘上的可执行文件。程序运行时,操作系统会为它创建进程,并把可执行文件中的代码、数据和所需动态库映射到进程的虚拟地址空间。

这里不一定会一次性把整个程序全部读入物理内存。Linux 通常采用按需分页:进程真正访问某个页面时,内核才把对应内容调入内存。

因此,更准确的表述是:

程序运行时,其代码和数据必须被映射到进程地址空间;真正使用的页面会按需进入物理内存。

1.4 本节小结

  • 可执行程序是存放在磁盘上的文件;
  • CPU 执行的是机器指令;
  • 程序运行前,操作系统必须建立进程及其地址空间;
  • 内存承担 CPU 与慢速外设之间的缓冲和运行时存储作用。

2. 操作系统如何管理资源

2.1 操作系统是什么

操作系统是一套管理软硬件资源、向用户和应用程序提供运行环境的系统软件。

它主要承担以下职责:

  • 进程管理;
  • 内存管理;
  • 文件系统管理;
  • 设备与驱动管理;
  • 网络与安全管理;
  • 向上提供系统调用接口。

可以用三个问题理解操作系统:

问题 回答
操作系统是什么 管理软硬件资源的系统软件
为什么要管理 为应用程序提供稳定、安全、高效的运行环境
如何管理 先描述对象,再使用数据结构组织对象

2.2 管理的本质:先描述,后组织

操作系统不会通过“直接观察”管理每一个硬件或进程,而是维护这些对象对应的数据。

管理通常分为两步:

  1. 先描述:使用结构体或类记录对象属性;
  2. 后组织:使用链表、队列、树、哈希表等数据结构管理大量对象。

例如,设备可以被抽象为类似下面的数据结构:

1
2
3
4
5
struct device {
int type;
int status;
struct device *next;
};

操作系统对设备的管理,最终就转化为对这些内核对象的创建、查找、更新和删除。

这个思想同样适用于进程:操作系统先使用 PCB 描述一个进程,再通过各种队列和数据结构组织所有 PCB。

2.3 用户态、内核态与系统调用

为了保护内核和其他进程,普通应用程序不能随意访问内核数据或直接操作硬件。应用程序需要通过系统调用请求内核服务。

1
2
3
4
5
6
7
8
9
用户
↓
Shell / 图形界面 / C、C++ 库
↓
系统调用接口
↓
Linux 内核:进程、内存、文件、网络、驱动管理
↓
硬件

常见系统调用包括:

  • 进程:fork、execve、waitpid、exit;
  • 文件:open、read、write、close;
  • 网络:socket、bind、listen、accept;
  • 内存:mmap、brk。

glibc 等 C 库通常会对系统调用进行封装。并不是每个库函数都会进入内核,但凡涉及受保护资源的操作,最终通常需要系统调用参与。

2.4 本节小结

  • 操作系统通过管理数据来管理资源;
  • 管理方法可以概括为“先描述,后组织”;
  • 应用程序通过系统调用请求内核服务;
  • 用户态与内核态的隔离提高了系统安全性和稳定性。

3. 进程的基本概念

3.1 程序与进程的区别

程序是磁盘上的静态可执行文件,进程是程序的一次动态执行过程。

一个进程不仅包含代码和数据,还包含操作系统维护的大量运行信息,例如:

  • PID 和父进程 PID;
  • 进程状态和调度信息;
  • 虚拟地址空间;
  • 打开的文件描述符;
  • 当前工作目录;
  • 信号相关信息;
  • CPU 执行上下文;
  • 用户身份和权限信息。

因此,可以使用下面的关系帮助理解:

1
进程 = 内核中的进程描述信息 + 虚拟地址空间中的代码和数据 + 其他运行资源

原来常见的“进程 = PCB + 磁盘代码”过于简化,因为程序运行后,代码会被映射进虚拟地址空间,进程还会拥有文件、信号、内核栈等资源。

3.2 PCB 与 task_struct

PCB 是 Process Control Block(进程控制块)的缩写。在 Linux 中,描述任务的核心内核结构是 task_struct。

下面的代码仅用于展示概念,并不是某个具体 Linux 版本中 task_struct 的完整定义:

1
2
3
4
5
6
7
8
9
10
11
struct task_struct {
pid_t pid; // 进程 ID
long state; // 进程状态
int prio; // 调度优先级
struct mm_struct *mm; // 虚拟地址空间
struct files_struct *files; // 打开的文件
struct task_struct *parent; // 父进程
struct list_head children; // 子进程链表
struct thread_struct thread; // CPU 上下文等架构相关信息
struct sched_entity se; // 调度实体
};

创建进程时,内核会创建并初始化相应的进程描述对象。调度、状态切换、信号处理和资源回收等操作,本质上都需要更新这些内核数据。

3.3 PID、PPID 与 /proc

常用接口:

1
2
3
4
5
#include <sys/types.h>
#include <unistd.h>

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

常用观察命令:

1
2
3
4
echo $$
ps -ef
ps -eo pid,ppid,stat,pri,ni,comm
pstree -p

**ps -ef **

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ps -ef  // process status every process  full format / full-format listing

UID PID PPID C STIME TTY TIME CMD
root 1 0 0 10:00 ? 00:00:02 /sbin/init
lic 3256 2145 0 14:20 pts/0 00:00:00 ./server

UID 用户
PID 当前进程ID
PPID 父进程ID
C CPU使用情况
STIME 启动时间
TTY 终端
TIME CPU累计时间
CMD 完整启动命令

ps aux

1
2
3
4
5
6
7
8
9
10
11
ps aux  // process status all user-oriented  no controlling terminal

USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
lic 3256 2.1 1.3 200000 30000 ? Sl 14:20 0:03 ./server

%CPU CPU占用率
%MEM 内存占用率
VSZ 虚拟内存大小
RSS 实际占用物理内存
STAT 进程状态
COMMAND 启动命令

Linux 的 /proc 是一个虚拟文件系统,可以通过 /proc/<PID>/ 查看进程信息:

1
2
3
4
cat /proc/$$/status
cat /proc/$$/cmdline
cat /proc/$$/maps
ls -l /proc/$$/fd

**/proc 中的内容并不是普通磁盘文件,而是内核按照当前系统状态动态生成的。**

3.4 使用 fork 创建子进程

函数原型:

1
2
3
4
#include <sys/types.h>
#include <unistd.h>

pid_t fork(void);

**fork 调用一次,却会在父子两个进程中分别返回:**

执行流 fork 返回值
父进程 子进程 PID,大于 0
子进程 0
创建失败 -1,并设置 errno

父进程需要获得子进程 PID,方便后续管理和回收;子进程只需要通过返回值 0 判断“自己是子进程”。

fork 之后:

  • 父子进程从 fork 返回处继续执行;
  • 父子进程具有独立的执行流;
  • 谁先运行由调度器决定,不能依赖输出顺序;
  • 初始地址空间内容相同,但逻辑上相互独立;
  • Linux 通常使用写时拷贝,避免立刻复制全部物理页面。

3.5 fork 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
pid_t id = fork();
if (id < 0)
{
perror("fork");
return 1;
}

if (id == 0)
{
printf("child: pid=%d, ppid=%d, fork_ret=%d\n",
getpid(), getppid(), id);
return 0;
}

printf("parent: pid=%d, child_pid=%d, fork_ret=%d\n",
getpid(), id, id);

if (waitpid(id, NULL, 0) < 0)
{
perror("waitpid");
return 1;
}

return 0;
}

编译并运行:

1
2
gcc fork_demo.c -o fork_demo
./fork_demo

调用系统接口时,不仅要知道参数,还要确认成功返回什么、失败返回什么,以及失败时 errno 的含义。


4. Linux 进程状态

4.1 状态不是“程序运行到哪一行”

进程状态是 PCB 中的属性,用来描述进程当前是否可运行、在等待什么资源,以及调度器应该如何处理它。

从通用操作系统理论看,经常会出现运行、就绪、阻塞、停止和终止等状态。Linux 对这些状态有自己的具体表示。

4.2 Linux 常见状态

使用下面的命令可以查看状态:

1
ps -eo pid,ppid,stat,comm

STAT 列常见字符如下:

状态 含义 说明
R Running / Runnable 正在 CPU 上运行,或位于运行队列中等待运行
S Interruptible Sleep 可中断睡眠,通常在等待定时器、管道、网络等事件
D Uninterruptible Sleep 不可中断睡眠,通常在等待某些内核或 I/O 操作完成
T Stopped 被作业控制信号停止
t Tracing Stop 被调试器跟踪而停止
Z Zombie 已退出,但退出状态尚未被父进程回收
X Dead 已死亡,通常很难在用户态观察到

需要特别注意:

R 不等于“此刻一定正在 CPU 上执行”。只要进程已经具备运行条件并处于运行队列,也可以显示为 R。

4.3 运行队列与等待队列

可运行任务会进入调度器管理的运行队列。现代 Linux 的具体调度结构比普通链表复杂,但入门时可以先理解为“调度器从可运行任务集合中选择下一个任务”。

阻塞任务并不是统一放入一个全局阻塞队列,而是进入与等待事件相关的等待队列:

1
2
3
4
等待键盘输入 → 相关设备或终端的等待队列
等待网络数据 → 套接字相关等待队列
等待子进程 → 子进程退出事件的等待队列
等待定时器 → 定时器相关等待结构

事件到达后,内核唤醒任务,使它重新具备运行条件。

4.4 R 与 S 状态实验

CPU 密集型循环通常容易表现为 R:

1
2
3
4
5
6
7
int main(void)
{
volatile unsigned long value = 0;
while (1) {
++value;
}
}

包含 sleep 的程序大部分时间在等待定时器,通常表现为 S:

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <unistd.h>

int main(void)
{
while (1) {
puts("working...");
sleep(1);
}
}

观察命令:

1
watch -n 0.5 'ps -eo pid,ppid,stat,comm | grep process_name'

4.5 停止与继续

推荐使用信号名称,而不是直接记数字:

1
2
3
4
kill -STOP <PID>  # 停止进程,通常显示 T
kill -CONT <PID> # 继续执行
kill -TERM <PID> # 请求进程正常终止
kill -KILL <PID> # 强制终止,无法被捕获或忽略

其中,SIGSTOP 和 SIGKILL 不能被进程捕获、阻塞或忽略。SIGTERM 更适合常规退出,因为程序有机会清理资源。

4.6 D 状态

D 表示不可中断睡眠,常见于进程等待某些底层 I/O 或内核操作完成时。

处于 D 状态的进程不会立刻响应普通信号,甚至 SIGKILL 也要等它从不可中断等待中返回后才能生效。这不代表进程永远无法终止,而是内核暂时不能安全地中断当前操作。

如果大量进程长期处于 D 状态,应检查磁盘、网络文件系统、设备驱动或存储故障。

4.7 僵尸进程

子进程退出后,内核需要暂时保存它的退出码和资源使用信息,等待父进程读取。此时子进程已经不再执行,但进程表中仍保留一个条目,这就是僵尸进程。

1
2
3
4
5
6
7
子进程退出
↓
内核保留退出状态
↓
父进程调用 wait / waitpid
↓
僵尸条目被彻底回收

下面的代码会暂时产生僵尸进程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}

if (id == 0) {
printf("child %d exits\n", getpid());
exit(7);
}

printf("parent %d does not wait temporarily\n", getpid());
sleep(30);
return 0;
}

在父进程睡眠期间,可以使用下面的命令观察 Z 状态:

1
ps -o pid,ppid,stat,comm -p <父进程PID>,<子进程PID>

解决方法是在父进程中调用 wait 或 waitpid。如果父进程长期不回收大量子进程,就可能耗尽 PID 或进程表资源。

4.8 孤儿进程与守护进程

孤儿进程是父进程先退出、子进程仍在运行的进程。它会被 init 进程、systemd 或被设置为 subreaper 的进程接管,后续退出状态由新的父进程回收。

**守护进程(daemon)**是长期在后台提供服务的进程,例如 sshd。守护进程通常会脱离控制终端并建立合适的运行环境,但“孤儿进程”与“守护进程”不是同一个概念。


5. 优先级、并发与进程切换

5.1 进程优先级

CPU 时间是有限资源。当可运行进程多于逻辑 CPU 数量时,调度器需要决定谁先运行以及运行多久。

查看优先级:

1
2
ps -eo pid,ppid,stat,pri,ni,comm
top

普通进程常见的 nice 值范围是 -20 到 19:

  • nice 值越小,进程获得更高调度权重;
  • nice 值越大,进程越“谦让”;
  • 普通用户通常只能调低自己的调度优先级,即增大 nice 值;
  • 修改为更高优先级通常需要相应权限。

常用命令:

1
2
nice -n 10 ./program
renice -n 5 -p <PID>

PRI 的展示方式会受内核调度类别和工具实现影响,不应把某个固定公式当作所有 Linux 版本和所有调度策略的通用规则。

5.2 进程的四个基本特征

概念 含义
竞争性 多个进程竞争 CPU、内存、文件和设备等资源
独立性 一个进程通常不能直接读写另一个进程的地址空间
并发 多个任务在一段时间内交替推进
并行 多个任务在同一时刻由不同执行单元真正同时执行

单核 CPU 可以通过快速切换实现并发,多核 CPU 可以让多个任务并行执行。

“8 核 16 线程”通常表示 8 个物理核心向系统暴露 16 个逻辑 CPU。超线程能够提高某些负载的吞吐量,但两个逻辑 CPU 会共享同一物理核心的部分资源,不能简单等同于 16 个完整物理核心。

5.3 什么是进程上下文

进程在 CPU 上执行时,会在寄存器和内核数据结构中形成继续执行所必需的状态,例如:

  • 程序计数器 PC;
  • 栈指针 SP;
  • 通用寄存器;
  • 状态寄存器;
  • 内核栈相关状态;
  • 调度和信号相关信息;
  • 浮点与 SIMD 寄存器状态。

寄存器硬件由当前 CPU 共享,但寄存器中保存的值属于当前执行任务的上下文。

5.4 进程切换

当时间片耗尽、进程主动阻塞或更高优先级任务被唤醒时,内核可能切换任务:

1
2
3
4
5
6
7
保存当前进程上下文
↓
调度器选择下一个可运行任务
↓
切换必要的内核和地址空间状态
↓
恢复新任务上下文并继续执行

进程切换可能涉及:

类别 可能保存或切换的内容
CPU 上下文 PC、SP、通用寄存器、标志寄存器
内核执行状态 内核栈和调度相关信息
地址空间 页表或地址空间标识;同进程线程切换时通常不需要更换地址空间
FPU / SIMD 浮点和向量寄存器
缓存与 TLB 可能受到间接影响,具体与架构和地址空间标识机制有关

上下文切换本身不完成业务工作,而且可能影响 CPU 缓存和 TLB,因此切换过于频繁会带来性能开销。


6. 环境变量与命令行参数

6.1 环境变量是什么

环境变量是进程环境中的字符串键值对,用来向程序传递配置信息。

常见变量:

变量 作用
PATH Shell 查找外部命令时使用的目录列表
HOME 当前用户主目录
USER / LOGNAME 用户名信息
PWD Shell 维护的当前目录字符串
LANG 语言和字符集设置
LD_LIBRARY_PATH 动态库额外查找路径,使用时需要谨慎

查看环境变量:

1
2
3
4
env
printenv
echo "$PATH"
printenv HOME

6.2 Shell 变量与环境变量

在 Bash 中:

1
name=lic

这首先创建的是 Shell 变量,只在当前 Shell 中可用。执行:

1
export name

才会把它标记为环境变量,使后续创建的子进程能够继承。

也可以一步完成:

1
export name=lic

删除变量:

1
unset name

环境变量不是“全系统唯一的全局变量”。每个进程都有自己的环境表;子进程通常在创建时获得父进程环境的副本,之后双方修改互不自动同步。

6.3 修改 PATH

临时追加命令目录:

1
export PATH="$PATH:/home/lic/lesson11"

Shell 通常根据 PATH 从左到右查找外部命令。排查命令来源时,type 比单纯使用 which 更全面,因为它还能识别别名、函数和内建命令:

1
2
type ls
type cd

Shell 启动时读取哪些配置文件,取决于它是登录 Shell、交互式 Shell 还是非交互式 Shell。常见文件包括 /etc/profile、~/.bash_profile、~/.profile 和 ~/.bashrc,不能简单认为每次启动 Bash 都一定读取同一个文件。

6.4 PWD 与当前工作目录

进程的当前工作目录由内核维护。ls 在没有路径参数时,是根据进程的当前工作目录访问文件,并不是依赖 PWD 环境变量才能工作。

PWD 是 Shell 维护的便捷字符串,通常与当前工作目录一致,但它不等同于内核中的目录对象。

6.5 C 语言读取环境变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
#include <stdlib.h>

static void print_env(const char *name)
{
const char *value = getenv(name);
printf("%s=%s\n", name, value == NULL ? "<not set>" : value);
}

int main(void)
{
print_env("USER");
print_env("HOME");
print_env("PWD");
print_env("PATH");
return 0;
}

修改当前进程环境可以使用 setenv、unsetenv 或 putenv。通常优先考虑语义更清晰的 setenv:

1
2
setenv("MY_VALUE", "123", 1);
unsetenv("MY_VALUE");

6.6 命令行参数

标准 C 的 main 函数可以接收命令行参数:

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main(int argc, char *argv[])
{
for (int i = 0; i < argc; ++i) {
printf("argv[%d]=%s\n", i, argv[i]);
}
return 0;
}
  • argc 表示参数个数;
  • argv 是参数字符串数组;
  • argv[0] 通常是程序名或启动程序时使用的路径。

对于复杂选项,应考虑使用 getopt 或 getopt_long,而不是手写大量 strcmp 分支。

6.7 环境变量表的组织

在常见 Unix/Linux 实现中,可以通过 envp 或 environ 遍历环境表:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

extern char **environ;

int main(void)
{
for (int i = 0; environ[i] != NULL; ++i) {
printf("%s\n", environ[i]);
}
return 0;
}

环境表可以理解为以空指针结尾的字符串指针数组,每个字符串通常采用 NAME=VALUE 形式。

6.8 为什么 cd、export 必须是内建命令

如果 Shell 通过创建子进程执行 cd,被改变的只会是子进程自己的当前工作目录,父 Shell 不会受到影响。因此,cd 必须由当前 Shell 自己执行。

同理,export 和 unset 需要修改当前 Shell 的环境,也必须由 Shell 内部完成。


7. 进程虚拟地址空间

7.1 从一个 fork 实验开始

下面的代码中,父子进程分别修改同一个全局变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int g_value = 100;

int main(void)
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return 1;
}

if (id == 0) {
g_value = 1;
printf("child: value=%d, address=%p\n",
g_value, (void *)&g_value);
return 0;
}

g_value = 2;
printf("parent: value=%d, address=%p\n",
g_value, (void *)&g_value);

waitpid(id, NULL, 0);
return 0;
}

父子进程通常会打印相同的地址,却得到不同的值。

原因是 C/C++ 程序打印出来的指针通常是虚拟地址。父子进程可以拥有数值相同的虚拟地址,但通过各自的页表映射到不同的物理页面。

7.2 写时拷贝

fork 刚完成时,父子进程的许多虚拟页面可以暂时映射到相同的物理页面,并被标记为只读。

当父进程或子进程尝试写入时,会触发页故障,内核再复制对应页面并更新写入方的页表,这就是 Copy-On-Write(写时拷贝)。

1
2
3
4
5
6
7
8
fork 后:
父虚拟页 ─┐
├→ 同一物理页(只读共享)
子虚拟页 ─┘

某一方写入后:
父虚拟页 → 物理页 A
子虚拟页 → 物理页 B

写时拷贝既保证了进程独立性,又减少了 fork 时不必要的内存复制。

7.3 地址空间的区域划分

典型 Linux 用户进程的虚拟地址空间包含:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
高地址
┌──────────────────────┐
│ 内核空间(用户态不可直接访问) │
├──────────────────────┤
│ 栈:局部变量、函数调用信息 │ ↓
├──────────────────────┤
│ mmap 区:动态库、文件映射等 │
├──────────────────────┤
│ 堆:动态内存 │ ↑
├──────────────────────┤
│ BSS:未初始化或零初始化全局数据 │
├──────────────────────┤
│ 数据段:已初始化全局数据 │
├──────────────────────┤
│ 只读数据与代码段 │
└──────────────────────┘
低地址

这是一种概念布局。实际地址受体系结构、ELF 类型、PIE、ASLR、动态链接器和内核配置影响。

Linux 使用 mm_struct 等内核结构描述进程地址空间,并使用虚拟内存区域记录各段范围和访问权限。通常使用左闭右开区间 [start, end) 表示地址范围。

需要注意:

  • malloc 可能扩展传统堆,也可能使用 mmap;
  • free 不保证立即把虚拟地址或物理内存归还给内核;
  • 栈增长通常由缺页机制和资源限制共同管理;
  • 线程通常共享所属进程的地址空间。

7.4 页与页表

虚拟内存以页为基本管理单位。常见页大小是 4 KiB,但系统也可能使用其他基础页或大页。

页表负责建立虚拟页到物理页框的映射,并保存权限信息:

1
2
3
4
5
虚拟地址
↓
页表查询与权限检查
↓
物理地址

页表不仅完成地址转换,还可以记录:

  • 页面是否存在;
  • 是否允许读、写、执行;
  • 用户态是否有权访问;
  • 页面是否被访问或修改;
  • 写时拷贝等机制所需的状态。

7.5 虚拟地址空间的优点

1. 提高安全性

进程不能直接随意访问物理内存。页表权限可以阻止越界、非法写入和用户态访问内核页面。

2. 保证进程独立性

每个进程拥有独立的虚拟地址空间。即使虚拟地址数值相同,也可以映射到不同物理内存。

3. 提供统一的地址视图

编译器、链接器和程序可以按照统一的地址空间模型组织代码和数据,不必直接管理物理内存布局。

4. 支持按需加载和内存映射

程序页面、动态库和文件可以按需映射,未访问的页面不必立刻占用物理内存。

5. 支持共享

不同进程可以把同一个动态库或共享内存映射到各自地址空间,在保证隔离的同时实现高效共享。

7.6 虚拟地址、物理地址与可执行文件地址

  • 虚拟地址:进程和 CPU 指令通常使用的地址;
  • 物理地址:实际物理内存位置;
  • 文件偏移:代码或数据在 ELF 文件中的位置。

链接器会按照 ELF 和目标平台规则组织段与重定位信息。非 PIE 可执行文件通常具有相对固定的链接虚拟地址;PIE 程序可以在加载时结合 ASLR 选择基址。因此,“虚拟地址就是编译阶段已经完全固定的地址”并不适用于所有程序。

常用观察命令:

1
2
3
4
5
file ./a.out
readelf -h ./a.out
readelf -l ./a.out
cat /proc/<PID>/maps
pmap <PID>

8. 全文总结

8.1 一条主线串联全文

1
2
3
4
5
6
7
8
9
10
11
12
13
程序是磁盘上的可执行文件
↓
运行程序时,操作系统创建进程
↓
task_struct 等内核对象描述进程
↓
虚拟地址空间组织代码、数据、堆、栈和映射区
↓
页表把虚拟地址转换为物理地址并检查权限
↓
调度器根据状态和调度策略选择进程使用 CPU
↓
进程阻塞、唤醒、切换,最终退出并由父进程回收

8.2 核心概念速查

概念 核心结论
程序 磁盘上的静态可执行文件
进程 程序的一次动态执行过程及其全部运行资源
PCB 操作系统描述和管理进程的数据结构;Linux 核心结构是 task_struct
fork 创建子进程;父进程返回子 PID,子进程返回 0
R 正在运行或具备运行条件
S 可中断睡眠,等待事件
D 不可中断睡眠,通常等待底层内核或 I/O 操作
Z 已退出但尚未被父进程回收
孤儿进程 父进程先退出,子进程被其他进程接管
并发 多个任务在一段时间内交替推进
并行 多个任务在同一时刻真正同时执行
上下文切换 保存当前任务状态并恢复另一个任务状态
环境变量 每个进程持有的运行环境键值对,可在创建子进程时继承
虚拟地址 进程看到和使用的地址,由页表映射到物理地址
写时拷贝 父子进程先共享页面,写入时再复制

8.3 最容易混淆的五组概念

  1. 程序与进程:程序是文件,进程是运行中的实例;
  2. 运行与可运行:R 既可能正在执行,也可能正在运行队列等待;
  3. 僵尸与孤儿:僵尸已经退出但未回收,孤儿仍在运行但父进程先退出;
  4. 并发与并行:并发强调交替推进,并行强调同一时刻执行;
  5. 虚拟地址与物理地址:指针通常是虚拟地址,页表负责映射物理内存。

8.4 建议掌握的验证命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 进程与父子关系
ps -ef
ps -eo pid,ppid,stat,pri,ni,comm
pstree -p

# 当前 Shell PID
echo $$

# 进程状态与资源
cat /proc/<PID>/status
cat /proc/<PID>/maps
ls -l /proc/<PID>/fd

# 环境变量
env
printenv
type ls

# 可执行文件与地址信息
file ./a.out
readelf -h ./a.out
readelf -l ./a.out

8.5 最终结论

学习 Linux 进程时,不要把知识点孤立记忆。应当始终抓住三个核心问题:

  1. 操作系统如何描述进程?——使用 task_struct 等内核数据结构;
  2. 操作系统如何组织和调度进程?——使用运行队列、等待队列和调度策略;
  3. 操作系统如何保证进程独立?——使用虚拟地址空间、页表和权限控制。

只要把“PCB、进程状态、调度、环境变量和虚拟地址空间”串成一条完整主线,后续学习进程控制、信号、线程和进程间通信就会更加顺畅。