Linux进程概念
1. 进程概念
1.1 理解冯诺依曼体系解构
冯诺依曼体系解构五大核心:
-
运算器:负责算数运算(加减乘除)和逻辑运算(与或非)。
-
控制器:从内存中读取指令,并协调其他部件执行。
-
存储器:本质是内存,存储程序的指令和数据。
-
输入设备:将外部信息转换为计算机可处理的数据,如键盘、鼠标、网卡、磁盘等。
-
输出设备:将计算结果反馈给用户,如显示器、磁盘、网卡等。
运算器和控制器又统一称为中央处理器。
CPU获取、写入数据,只能从内存中来进行。如果没有内存,那么CPU只能和输入设备和输出设备去交互,而CPU的处理速度和输入输出设备的速度不在一个量级,这就会导致 木桶效应(一只木桶能装多少水,不取决于最长的那块木板,而取决于最短的那块。),计算机的效率就会完全取决于输入输出设备,所以内存是计算机中不可缺失的一部分,也是当代计算机性价比的产物。
从上述来看,引入内存好像还是没有解决问题。这里又会引出一个局部性原理的概念。
局部性原理分为两类:
-
时间局部性:如果一个数据或指令被访问,那么在不久的将来很有可能再次访问(循环中的变量、频繁调用的函数)。
- 场景:缓存热门数据,缓存最近访问被访问的数据。
-
空间局部性:如果一个数据或指令被访问,那么其附近的数据很可能在不久的将来也会被访问。(数组、顺序执行的指令)。
- 场景:CPU预加载相邻的数据到缓存。
木桶效应和局部性原理的结合,是计算机系统中解决CPU与I/O设备速度不匹配的问题的关键思想之一。
而现代计算中存在一个金字塔,依次是:寄存器、高速缓存L1、高速缓存L2、高速缓存L3、内存、外存(磁盘)、网盘等。从左到右效率和价格都是递减的。
我们可以在硬件上简单理解数据流动,当两台计算机在微信中相互发送消息,本质上是两台冯诺依曼体系解构在交互,微信是一个可执行程序,点开登录微信的操作,其实是将微信的数据加载到内存中,用户A从键盘中输入消息,微信在内存中获取键盘中的数据,CPU处理数据后,再写回内存当中,再由微信把数据交给输出设备网卡上,由网卡经过网络转交给用户B,当用户B收到数据后进行逆过程。
所以程序在运行之前是在磁盘中,软件运行,必须先加载到内存,这是因为体系结构规定的。而加载到内存的本质其实是把数据从一个设备"拷贝"到另一个设备当中。
1.2 理解操作系统(Operator System)
1.2.1 操作系统是什么?
操作系统:是一款进行软硬件管理的软件。
狭义上的OS:指的是操作系统内核,其中包括进程/线程管理、文件系统、内存管理、驱动管理等。
广义上的OS:指的是操作系统内核 + 外壳程序(Shell)、一些第三方库、预装的系统软件。
1.2.2 软硬件体系层状结构
操作系统的目的:对上,为用户程序(应用程序)提供一个良好的执行环境;对下,与硬件交互,管理所有的软硬件资源。
访问操作系统,必须使用系统调用,而一个程序如果想要访问硬件,必须贯穿软硬件体系结构!在用户层语言为我们提供的标准库,底层都是封装了系统调用接口,系统调用是因为操作系统不相信任何人操作内核,但还要向上为用户提供服务,那么用户和操作系统之间,就可以采用系统调用来进行数据的交互,本质是操作系统的封装。
操作系统的管理:管理是一个可以很抽象的词语,操作系统的管理本质上是对数据做管理,第一步需要先描述被管理的对象(struct),然后把被管理的对象在组织起来(数据结构),那么对软硬件的管理就转换成了对数据的管理。而数据是通过驱动程序的接口填充的。
2. 进程
2.1 怎么理解进程?
-
基础概念:内存中的可执行程序等。
-
内核观点:担当系统资源分配的实体。
-
进程 = 内核数据结构对象(task_struct) + 自己的代码和数据。
2.2 PCB(Process control block)
进程控制块(PCB)是内核用于管理进程的核心数据结构,是一个宽泛性的词语,适用于任意一款操作系统。而在Liunx中,PCB是task_struct。
task_struct主要包括:
-
标识符:描述本进程的唯一标识符,用来区别其他进程
-
状态:运行状态、睡眠状态、僵尸状态等
-
优先级:相对于其他进程的优先级
-
程序计数器:程序中即将被执行的下一条指令的地址
-
内存指针:指向程序代码和进程相关数据的指针
-
上下文数据:进程执行时处理器的寄存器中的数据
-
uid:(User ID),是OS为每个用户分配的唯一标识符,在权限管理机制中,区分不同用户的身份。
-
其他信息
内核中是以双向链表的方式来组织task_struct结构体的。
2.3 查看进程信息
2.3.1 ps
ps axj # 查看所有进程
ps axj | head -1 && ps axj | grep process-name | grep -v grep # 查看指定进程名的进程
-
a:显示所有用户的进程。默认情况下,ps只会显示当前终端的进程,a选项会显示所有用户的进程(包括其他终端和后台进程)
-
x:显示没有控制终端的进程,例如后台运行的守护进程
-
j:添加进程工作控制信息,包括进程组ID、会话ID、父进程ID,以及相关信息
-
u:以用户友好的格式显示进程信息,包括进程所有者、CPU和内存的使用情况等
grep - v grep:grep也是一个进程,使用grep命令查询进程也会把grep自己查询出来,因为grep查询进程中的关键字是你所查询进程的名字。
2.3.2 top
top
2.3.3 ls /proc
通过文件的方式查看内核中PCB的信息。
ls /proc # 里面中目录的名字都是以进程的PID命名
2.4 通过系统调用修改进程的工作目录
cwd(current work director)代表进程当前的工作目录。
头文件 | 系统调用 | 返回值 |
---|---|---|
#include <unistd.h> | int chdir(const char * path); | 成功0被返回,失败-1被返回 |
2.5 通过系统调用获取进程标识符
头文件 | 系统调用 | 返回值 |
---|---|---|
#include <sys/types.h> #include <unistd.h> | pid_t getpid(void); | 返回当前进程的进程标识符 |
#include <sys/types.h> #include <unistd.h> | pid_t getppid(void); | 返回当前进程的父进程标识符 |
2.6 通过系统调用创建子进程
头文件 | 系统调用 | 返回值 |
---|---|---|
#include <sys/types.h> #include <unistd.h> | pid_t fork(void); | 1. 给父进程返回子进程的pid 2.给子进程返回0 3.函数调用失败返回-1 |
fork有两个返回值。
父子进程代码共享,数据各自开辟空间,采用写时拷贝的方式。
fork之后通常要用if进行分流。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>int main () {printf("父进程开始运行,pid = %d\n" , getpid());pid_t id = fork();if (id < 0) {perror("fork");return -1;} else if (id == 0) {// child processwhile (1) {sleep(1);printf("我是一个子进程,我的id = %d,我的父进程id = %d\n" , getpid() , getppid());}} else {// parent processwhile (1) {sleep(1);printf("我是一个子进程,我的id = %d,我的父进程id = %d\n" , getpid() , getppid());}}return 0;
}
2.6.1 为什么fork给父子返回不同的返回值?
因为父子的关系是 1:n 的,父进程可能有多个子进程,所以父进程需要子进程的 pid 来管理;子进程只有一个唯一的父进程。
2.6.2 为什么一个函数会返回两次?
fork本质是一个函数,在内部的工作:
-
创建新的PCB 。
-
拷贝父进程的PCB给子进程(修改一些单独的数据)。
-
将子进程的PCB放入进程链表中甚至放入调度队列中。
所以当fork函数return时,函数中主体逻辑已经执行完成,代码已经开始分流,所以return语句也会执行两次。
2.6.3 为什么一个变量即等于0又可以大于0,导致if else同时成立?
每个进程访问的内存实际上是一个虚拟地址空间 。操作系统会为每个运行的进程分配一个独立的虚拟地址空间(也称为进程地址空间)。fork
之后,return
本质也是对一个变量做修改,虽然父子进程数据和代码是共享的,但是父子任意一方修改共享数据,操作系统会为其修改数据的一方分配新的物理内存,复制原数据到该物理内存中,再让修改者在新的物理内存中写入。这种机制叫做 写时拷贝。
进程具有独立性。
2.7 task_struct的缓存链表
task_struct是C语言中的结构体,操作系统每次创建进程都需要在内存中开辟空间,大量的申请释放内存会存在效率的问题,而现代操作系统几乎不会做浪费时间和空间的事情。
在内核中OS也会维持一个task_struct的缓存链表,当一个进程终止后,OS不会释放task_struct,而是链入缓存链表中,每次创建PCB时只需要从缓存链表中取,覆盖其对应的字段数据即可。
3. 进程状态
进程状态本质就是 tast_struct 数据结构中的一个整数。
Linux内核源码中的定义:
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 */
};
3.1 内核数据结构设计
PCB为什么既在进程链表中又在运行队列中?一个结构体节点可以在不同的数据结构中?
3.1.1 常规的双链表设计
struct list {int val;struct list* prev;struct list* next;
};
常规的双链表设计,在某些应用场景是满足不了需求的。比如需要一个节点既是链表节点又是队列节点,像这样的定义方式无法满足。
3.1.2 内核中嵌入子结构
在Linux系统内核中,task_struct
结构使用嵌入子结构的方式,来实现管理进程相关的多种数据结构(一个节点被多种数据结构管理),通过将链表节点和其它结构的指针嵌入到 task_struct
中,实现高效的多数据结构交互。
Linux内核定义了一个通用的双向链表节点结构 list_head
,仅包含前驱和后继指针。
struct list_head {struct list_head * next , *prev;
};
嵌入到 task_struct
,task_struct
中通过list_head
嵌入多个链表节点,比如:管理全局进程链表、管理子进程链表、运行队列等。
// 模拟Linux PCB数据结构设计
struct task_struct {int pid;int ppid;int status;// ...struct list_head process_links; // 管理进程的双链表// ...int x;int y;// ...struct list_head run_queue; // CPU运行队列// ...
};
int main () {// 初始化3个进程服务struct task_struct proc1 = {1 , 1 , 1 , {NULL , NULL} , 1 , 1 , {NULL , NULL}};struct task_struct proc2 = {2 , 2 , 2 , {NULL , NULL} , 2 , 2 , {NULL , NULL}};struct task_struct proc3 = {3 , 3 , 3 , {NULL , NULL} , 3 , 3 , {NULL , NULL}};// 连接proc1.process_links.next = &proc2.process_links;proc2.process_links.prev = &proc1.process_links;proc2.process_links.next = &proc3.process_links;proc3.process_links.prev = &proc2.process_links;proc1.process_links.prev = &proc3.process_links;proc3.process_links.next = &proc1.process_links;// 定义进程双向链表的头节点struct list_head* process_list_head; process_list_head = &proc1.process_links;// 定义运行队列双向链表的头节点struct list_head* run_queue_head;run_queue_head = &proc1.run_queue;return 0;
}
下图所示中,三个节点既在双向链表plh头指针中,又在运行队列rqh头指针中,在操作系统中,情况会更加复杂,所有节点都会在双向链表头指针中管理,其中运行的节点又会放在运行队列头指针管理连接起来,那么这只是进程链表和运行队列,如果还需要被其他数据结构管理呢?操作系统数据结构中的指针是网状的。Linux内核的设计通过混合数据结构和层级的划分实现高效的、可维护的系统。
常规的双向链表中的next指向的是下一个节点的起始地址,嵌入子结构中的next指向的是下一个节点中list_head成员地址,并不是头节点,所以无法直接访问当前节点内部成员。访问C语言中的结构体,要从结构体地址的起始处访问成员。
3.1.3 访问嵌入式子结构的成员
访问proc2的pid成员:
// 1.获得process_links成员相较于结构体对象起始地址的偏移量
size_t offset = (size_t)(&((struct task_struct*)0)->process_links);
// 2.使用proc2的process_links的地址 - 相较于起始地址的偏移量 = 起始地址
// process_list_head->next 是一个 struct list_head* 指针,而 offset 是 size_t 类型(代表偏移量字节数)
// 指针减法是按指针类型的大小为单位计算的,不是按字节,
// 例如,struct list_head* p; p - 1 会减去 sizeof(struct list_head) 字节,而不是 1 字节。所以需要转换为char* 再减去偏移量
int proc2_pid = ((struct task_struct*)((char*)(process_list_head->next) - offset))->pid;
注意:指针减法是按指针类型的大小为单位计算的,所以需要先将
process_list_head->next
转换为char*
(按字节计算),再减去偏移量
((struct task_struct*)((char*)(process_list_head->next) - (size_t)(&((struct task_struct*)0)->process_links)))->pid
3.2 进程状态
3.2.1 R(Running)
进程正在CPU上执行或在运行队列中等待CPU调度。
3.2.2 S(Sleeping)
进程正在等待某个事件完成(如I/O完成、用户输入等),这里的睡眠也可以叫做可中断睡眠。
3.2.3 D(Disk Sleep)
进程在等待不可中断的I/O,不能被信号终止。
3.2.4 T(Stopped)
进程被暂停,可以通过发送信号SIGSTOP暂停,也可以发送SIGCONT信号让进程继续运行。
3.2.5 X(Dead)
进程完全终止,瞬时状态。
3.2.6 Z(Zombie)
僵尸状态,当前进程已经运行结束,但是父进程并没有读取子进程的退出结果(正常退出/异常退出、结果正确/错误),导致子进程PCB需要一直在内存中维持。大量的僵尸进程会导致内存泄漏。
3.2.7 孤儿进程
父子进程关系中,如果父进程先退出,子进程被称为孤儿进程。孤儿进程会被1号systemd进程领养并回收。
3.2.8 挂起
-
进程 = 内核数据结构 + 自己的代码和数据
-
挂起:当系统内存资源严重不足时,OS可能会将处于等待状态且位于资源队列尾部的进程中的代码和数据唤出到磁盘swap分区中。当等待的资源就绪、进程被调度、内存压力缓解时,会被操作系统唤入。
3.3 进程优先级
进程优先级是得到CPU资源的先后顺序。优先级高的进程有优先执行的权力。而操作系统分为分时操作系统和实时操作系统。
-
分时操作系统:基于时间片的调度,追求公平,优先级可能会变化,但是变化幅度不能太大。
- 场景:Linux、Windows等
-
实时操作系统:强调优先级抢占式调度,确保优先级高的先响应。
- 场景:工业领域、自动驾驶等
3.3.1 查看进程优先级
ps -al | grep process-name
3.3.2 PRI与NI
-
PRI:进程优先级,值越小越早执行(默认80)。取值范围
[60 , 99]
,依赖nice值。 -
NI:nice值,进程优先级的修正值。取值范围
[-20 , 19]
,修改nice值,本质就是调整进程优先级。
真实优先级:PRI(默认80) + nice值。
每次修改nice值,都会以PRI默认值80为基准计算。
修改nice值: top -> r -> pid -> nice值;nice、renice命令、系统调用等。
3.4 进程补充概念
-
竞争性:系统进程数量众多,而CPU只有少量,甚至1个,所以进程之间是具有竞争性的。
-
独立性:进程具有独立性,一个进程不会影响其他进程的运行。
-
并行:多个进程在多个CPU下同时运行,被称为并行。
-
并发:多个进程在一个CPU下采取 进程切换 的方式,使多个进程得以推进,称为并发。
3.5 进程切换
进程切换 指的是 操作系统内核 将CPU的 执行权 从一个进程转移到另一个进程的过程。涉及到保存当前进程的上下文数据,并在下次切换时恢复进程的上下文数据。
进程切换的触发条件:分时操作系统中时间片耗尽、高优先级进程抢占等。
进程切换的核心是上下文切换。核心步骤:
- 将CPU寄存器(如pc、ebp、esp、eax…)中的数据保存当前进程的PCB中(TTS,任务状态段)。
- 从运行队列中选择下一个要运行的进程。
- 恢复当前进程的上下文数据,CPU从目标进程的PC(程序计数器)处继续执行。
PC指针:程序计数器,通常存储的是下一条待执行指令的地址。
可以使用一个标记位来标识当前进程是第一次运行的进程,还是已经调度过的进程。
3.6 Linux内核O(1)调度队列
3.6.1 伪代码
struct RunQueue{// ...*active;*expired;// 活跃队列nr_active;bitmap[5];queue[140];// 过期队列nr_active;bitmap[5];queue[140];// ...
};
3.6.2 说明
-
一个CPU拥有一个runqueue
- 如果有多个CPU就需要考虑负载均衡问题。
-
优先级(和queue数组下标对应)
-
普通优先级:100 ~ 139(和nice取值对应)。
-
实时优先级:0 ~ 99(不考虑)。
-
计算优先级:PRI(80)+NI(nice值)−PRImin(60)+100PRI(80) + NI(nice值) - PRI_{min}(60) + 100PRI(80)+NI(nice值)−PRImin(60)+100。
-
-
Linux内核中,存在一个
struct task_struct* current
变量,用于指向当前正在运行的进程。 -
活跃队列
-
正在参与调度或即将被调度的进程放在该队列。
-
nr_active:总共有多少个运行状态的进程。
-
queue[140]:数组的每个元素对应一个优先级进程队列,相同优先级按照FIFO规则进行排队,数组下标本质就是优先级。
-
bitmap[5]:位图,每一位与queue数组元素一一对应,提升非空队列的查找效率。
-
位图还是需要遍历140个bit,效率提高了吗?
-
由于绝大多数进程采用默认优先级,大规模 修改优先级的场景较少,因此该数组大部分元素通常为
NULL
。而bitmap
每一位对应一个优先级队列是否为空,通过按字节批量检查,若bitmap[i] == 0
(即8
个连续优先级队列均为空),仅当bitmap[i] != 0
,才需进一步检查。
-
-
-
过期队列
-
过期队列的结构和活跃队列一摸一样。
-
存放时间片已经耗尽的进程,成为新的活跃队列后重新分配时间片。
-
目的:实现公平的调度,避免高优先级的进程长期垄断CPU。
- 若采用 单队列数组
queue[140]
结构,且进程执行完毕后仍重新插入原优先级的尾部,则可能导致低优先级进程长期无法获得CPU调度。
- 若采用 单队列数组
-
-
active指针和expired指针
-
active指针永远指向活跃队列。
-
expired指针永远指向过期队列。
-
随着CPU的调度,活跃队列中的进程会越来越少,过期队列中的进程越来越多。
-
在合适时(活跃队列全为空时),调度器会交换active和expired指针(
swap(&active,&expired)
)。这样就会产生一个新的活跃队列。 -
struct rqs {nr_active;bitmap[5];queue[140]; }; struct rqs priority_array[2]; struct rqs* active = &priority_array[0]; struct rqs* expired = &priority_array[1]; swap(&active , &expired);
-
3.6.3 图示
4. 命令行参数和环境变量
在操作系统中,命令行参数和环境变量 是两个重要的进程执行上下文信息,他们由操作系统或bash传递给程序,影响程序的行为。
4.1 命令行参数
命令行参数是用户在启动程序时通过命令行的方式传递给程序的参数。
#include <stdio.h>int main(int argc, char *argv[]) {printf("程序名: %s\n", argv[0]); // argv[0] 是程序名printf("参数个数: %d\n", argc - 1);for (int i = 1; i < argc; i++) {printf("%s\n", argv[i]);}return 0;
}
运行方式:
./code arg1 arg2 arg3
输出:
程序名: ./code
参数个数: 3
arg1
arg2
arg3
main的命令行参数是实现程序不同子功能的方法。
Linux下的部分指令是通过C语言的命令行参数实现的。
4.2 环境变量
4.2.1 概念
环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数。环境变量是系统或用户定义的键值对(key=value)。环境变量通常具有某些特殊的用途。
常见的环境变量:
-
PATH:指定程序或命令的搜索路径。
-
HOME:指定用户的家目录。
-
USER:记录当前的用户。
-
其他环境变量。
Bash中,变量分为普通变量(本地变量)和环境变量(全局变量):
-
环境变量:具有全局属性,由bash或父进程传递,会被子进程继承(环境变量最初是通过系统配置文件导入的)。
-
本地变量:不会被子进程继承,只会在bash内部使用。
- 声明方式:
name=value
- 声明方式:
Bash中,命令的执行方式分为 外部命令(通过创建子进程执行) 和 内建命令(由bash亲自执行):
-
外部命令:通过创建子进程和程序替换实现的(
fork
+exec
)。 -
常见的外部命令:
ls , grep , cat , python , gcc
-
内建命令:通过bash自己内部实现的方法调用系统调用接口实现的。
-
常见的内建命令:
cd , export , echo
4.2.2 命令行操作环境变量
1️⃣ 显示所有环境变量:
env
2️⃣ 显示本地变量和环境变量:
set
3️⃣ 显示某个环境变量值:
echo $NAME # NAME 环境变量的名称
4️⃣ 设置和清除环境变量:
export key=value # 设置
unset key # 清除
4.2.3 程序中操作环境变量
1️⃣ main函数的第三个参数:
#include <stdio.h>int main(int argc, char *argv[], char *env[]) {for(int i = 0; env[i]; i++){printf("%s\n", env[i]);}return 0;
}
2️⃣ 通过全局变量 environ 获取:
#include <stdio.h>extern char** environ;
int main(int argc, char *argv[]) {for(int i = 0; environ[i]; i++){printf("%s\n", environ[i]);}return 0;
}
3️⃣ 通过系统调用获取:
#include <stdio.h>
#include <stdlib.h>int main() {printf("%s\n", getenv("PATH"));return 0;
}
4.2.4 配置PATH环境变量
在Linux中,PATH
环境变量决定了系统在哪些目录中查找可执行程序。通过配置 PATH
,使其包含自定义的路径,以便直接运行程序而无需输入完整路径。
1️⃣ 修改 ~/.profile
Shell配置文件。
// ubuntu
vim ~/.profile
2️⃣ 在文件末尾添加:
export PAHT=$PATH:/your/path
3️⃣ 然后重新加载或重启终端。
source ~/.profile
5. 进程地址空间(虚拟地址空间)第一讲
进程地址空间是操作系统为每个运行中的进程分配的虚拟内存视图,定义进程可以访问的内存范围。
-
32位系统和64位系统的区别之一是虚拟内存的地址编号是 000 ~ 2322^{32}232 或 $000 ~ 2642^{64}264 。
-
进程地址空间在内核中其实就是一个数据结构
mm_struct
。每个进程 只有⼀ 个mm_struct
结构,在每个进程task_struct
结构中,有⼀个指向该进程的mm_struct
的结构体指针。-
每个进程的虚拟地址空间(
mm_struct
)又由多个vm_area_struct
节点 组成,它们通过 红黑树(rb_tree) 和 链表 组织,用于高效管理不同内存区域。-
记录虚拟内存的起始和结束
-
记录控制内存的的访问权限
-
其它
-
-
mm_struct
:记录整个虚拟地址空间的全局信息,以及记录关键段的起始和结束地址(如代码段、数据段、堆、栈等)。 -
vm_area_struct
:描述进程地址空间中一段连续的虚拟内存区域。
-
-
虚拟地址是通过 页表 的映射转换为物理地址的。
-
重新理解什么是进程?
-
享受系统分配资源的实体。
-
进程 = 内核数据结构(task_struct + mm_struct + 页表)+ 自己的代码和数据。
-
验证虚拟地址的存在:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_val = 0;
int main() {pid_t id = fork();if(id < 0){perror("fork");return 0;} else if(id == 0) { //child,⼦进程肯定先跑完,也就是⼦进程先修改,完成之后,⽗进程再读取g_val=100;printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);}else { //parentsleep(3);printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);} sleep(1);return 0;
}
以上代码输出结果显示,地址是相同的,但是值却不同。
-
变量的内容不一样,所以父子进程的输出的变量绝对不是同一个变量。
-
地址值一样,说明该地址绝对不是物理地址。
解析:当父子进程修改共享数据时,会触发写时拷贝。因为进程具有独立性。
为什么需要虚拟地址空间?
物理内存的局限性:
-
程序直接使用物理地址时,内存会被分割成不连续的块,导致无法分配大块连续内存。
-
程序可以随意访问任何物理地址,可能破坏其他进程或内核数据。
虚拟内存的优势:
-
内存隔离和安全性
-
每个进程拥有独立的虚拟地址空间,从
0x000...
开始,仿佛独占整个内存。 -
进程无法直接访问其他进程或内核的物理内存(进程独立性)。
-
-
无序变有序
-
虚拟地址对程序呈现为连续的地址空间,实际物理内存可以是分散的。
-
解决物理内存碎片化问题。
-
-
内存扩展
-
虚拟地址空间可以大于物理内存,通过磁盘交换(Swap)实现无限内存假象。
-
按需加载:一个游戏程序可能申请 16GB 虚拟内存,但物理内存仅 8GB,未活跃数据会被换出到磁盘。
-
6. 补充
6.1 缺页中断(Page Fault)的本质与作用
缺页中断是 CPU 访问虚拟内存时,因目标页面无法直接访问而触发的一种硬件异常。它是操作系统实现虚拟内存管理的核心机制,使得程序可以按需使用物理内存,而非一次性加载全部数据。其核心逻辑是:当程序访问的虚拟内存页尚未映射到物理内存时,CPU 暂停当前指令,由操作系统介入处理,完成后恢复程序执行。
触发缺页中断的三大原因:
类型 | 触发条件 | 典型场景 |
---|---|---|
硬缺页(Major Fault) | 目标页不在物理内存中,需从磁盘(如文件或Swap分区)加载数据 | 程序启动时加载代码段、读取大文件 |
软缺页(Minor Fault) | 目标页已在物理内存中,但未与当前进程的页表关联(如共享库被其他进程提前加载) | 共享库映射 |
写时拷贝(COW Fault) | 进程尝试写入共享的只读页(如 fork() 后的内存修改) | 父子进程间修改共享数据 |
为什么需要缺页中断?
-
内存超售(Overcommit):程序可申请比物理内存更大的虚拟空间,实际使用时再分配物理页。
-
延迟加载:避免启动时加载全部代码/数据(如仅加载程序当前执行的函数)。
-
共享内存:多个进程共享同一物理页(如动态库
libc
)。 -
写时拷贝:
fork()
后父子进程共享内存,仅在修改时复制,减少开销。