1.再谈fork
之前提到了fork之后对父子进程返回不同的id值,给父进程返回子进程的pid,给子进程返回0,所以对于一个id如何存储两个值的说法,在我们之前已经提到过了一个概念叫做写时拷贝,就是在子进程要想修改父进程的id变量是,操作系统就会给子进程重新划分一块空间,将父进程的空间中的内容拷贝给子进程,再让子进程去修改id值,今天我们就从内存角度深度理解这个过程!
2、程序地址空间
在我们之前学习C语言的时候大家应该都见过这张地址表。
在我们32位机器下,这张地址表一共有4GB的大小,因为我们的CPU有32根总线的话,它的寻址范围就是 ,所以能接受内存的最大大小就是4GB。
我们在学习C语言期间,经常可以提及到这些区域,有一个问题:上面这个图例里展示的是内存吗?
这里是地址空间,并不是内存。
这里的地址空间是进程地址空间,下面我们就讲解进程地址空间。
这段空间中自下而上,地址是增长的,栈是向地址减小方向增长(栈是先使用高地址),而堆是向地址增长方向增长(堆是先使用低地址),堆栈之间的共享区,主要用来加载动态库。
我们通过代码来验证一下
#include<stdio.h>
#include<stdlib.h>
int g_unval;//未初始化
int g_val = 100;//初始化
int main(int argc,char *argv[],char *env[])
{
printf("code addr: %p\n",main);//代码区起始地址
const char* p = "hello bit";//p是指针变量(栈区),p指向字符常量h(字符常量区)
printf("read only : %p\n",p);
printf("global val: %p\n",&g_val);
printf("global uninit val: %p\n",&g_unval);
char *q = (char *)malloc(10);
printf("heap addr: %p\n",q);
printf("stack addr: %p\n",&p);//p先定义,先入栈
printf("stack addr: %p\n",&q);
printf("args addr %p\n",argv[0]);//命令行参数
printf("args addr %p\n",argv[argc-1]);
printf("env addr: %p\n",env[0]);//环境变量
return 0;
}
我们可以看到代码区的地址是最小的,这里就验证了地址空间的基本排布:p和q都是定义在栈区的,p先定义,先入栈,可以看到p的地址大于q,说明了栈是先使用高地址再使用低地址。
这里我们首先得出两点结论:
1、进程地址空间不是内存
2、进程地址空间,会在进程的整个生命周期内一直存在,直到进程退出
这也就解释了全局变量为什么会一直存在,原因是未初始化数据,初始化数据都不在栈区/堆区,不会因为函数的调用而变化,这些区域是一直存在的
可能我们上面展示的并不明显,所以我们再来验证一下堆栈之间是使用共享栈
我们再来单独打印一些栈区地址:
#include<stdio.h> int main() { int a = 0; int b = 0; int c = 0; int d = 0; printf("栈区:%p\n",&a); printf("栈区:%p\n",&b); printf("栈区:%p\n",&c); printf("栈区:%p\n",&d); return 0; }
我们可以看到
栈区的地址是由高向低增长的
,这说明栈区是先使用高地址在使用低地址。这正是因为我们栈这个数据结构是需要压栈的,所以先进去的往往是在高地址处。我们再来打印一波堆区地址:
#include<stdio.h> int main() { char *mem = (char*)malloc(10); char *mem1 = (char*)malloc(10); char *mem2 = (char*)malloc(10); char *mem3 = (char*)malloc(10); printf("堆区:%p\n",mem); printf("堆区:%p\n",mem1); printf("堆区:%p\n",mem2); printf("堆区:%p\n",mem3); return 0; }
可以看到
堆区的地址是由低向高增长
,此时我们可以得到一个结论就是:堆栈之间是相对增长的。
对于中间这块共享区,我们在以后讲解动静态库的时候再来聊。
3、虚拟地址&&线性地址
为了更深入地理解,我们用做了这样一个测试:
#include <iostream>
#include <cstdio>
#include <unistd.h>
using namespace std;
int g_val = 100;//初始化变量
int main()
{
pid_t id = fork();
if (id == 0) // 子进程
{
int cnt = 3;
while (true)
{
printf("i am child, pid:%d,ppid:%d,g_val:%d,&g_val:%p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
if (cnt--);
else g_val = 200;
}
}
else
{
while (true)
{
printf("i am parent, pid:%d,ppid:%d,g_val:%d,&g_val:%p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
我们使用父子进程都去打印同一个全局变量的值和地址
可以看出的是,父子进程中对于同一个变量打印的地址是一样的,这是因为子进程以父进程为模板,因为都没有对数据进行修改,所以这里变量地址也是一样的。
我们再看一段代码,对上面的代码进行一些修改:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_val = 10;
int main()
{
int ret = fork();
if (ret < 0){
// error
perror("fork error");
return 0;
}
else if (ret == 0){
// child
int count = 0;
while (1){
printf("修改数据前:\n");
printf("I am child,pid:%d %d:%p\n", getpid(), g_val, &g_val);
sleep(1);
count++;
if (count == 3){
g_val = 20;// 子进程对数据进行修改
printf("修改数据后:\n");
}
}
else{
// parent
while (1){
printf("I am parent:pid:%d %d:%p\n", getpid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
我们在这里使用子进程去修改全局变量,父进程啥也不做
代码运行结果如下:
可以发现,子进程对数据进行修改后,父子进程打印g_val的值是不一样的,但是地址却是一样的。
分析结果得出的原因:
在fork创建子进程那一篇博客中,我们说过,父子进程共享代码和数据,但是如果有一方对数据做出了修改,那么修改方将堆被修改的这一份数据写实拷贝,这一份数据各自私有,所以根据我们现有的知识可以推断出变量g_val在父子进程中的地址应该是不一样的,但是测试发现他们的这个变量地址是一样的,但是内容却不一样。
难道对于同一块空间可以有两份不同的数据吗?
答案是否定的。
所以我们可以得出结论,我们上面看到的地址绝对不是物理地址。
事实上,在Linux地址下,这种地址叫做线性地址/虚拟地址(下面讨论)。
且平时我们在C/C++语言中指针里面的地址都不是物理地址,打印所看到的地址都是虚拟地址,物理地址对于我们用户是看不到的,由OS管理。
【注意】:以上代码都是在Linux下的结果,Windows下的vs中可能会有所不同。
3.1.初步理解这种现象——引入地址空间概念
在我们创建进程时,一旦进程的PCB(进程控制块)被创建出来,操作系统会为该进程创建一个叫做进程地址空间的东西,以使该进程能够更好地运行。
进程地址空间是啥?
就是上面我们讲的那个东西
1.这个进程地址空间有什么用?
我们上面说了进程地址空间就是一堆虚拟地址,是虚拟地址就得和真正的物理地址搭上关系
2.进程地址空间和物理地址是怎么联系的?
通过页表这个东西,
进程运行时,操作系统也会为这个进程创建一份页表
我这里简化一下页表的功能
页表就是一张表,程序可以通过这张表来从虚拟地址找对应的物理地址
页表的作用是将虚拟地址映射到物理地址,这是一种键值对(kv)的映射关系。左侧是虚拟地址,右侧是物理地址。
3.从虚拟地址映射到物理地址的全过程
除了内核创建PCB这样的内核结构之外,地址空间是操作系统为每个进程提供的独立空间,用于存储进程的代码、数据和各种系统资源。
线性或虚拟地址,这是我们在编程中经常使用的东西。为了使这些地址能被正确地使用,系统需要为每个进程构建一个名为页表的结构。页表的作用是将虚拟地址映射到物理地址,这是一种键值对(kv)的映射关系。左侧是虚拟地址,右侧是物理地址。
举个例子
假设我们定义了一个全局变量,并给它分配了一个地址,比如0x40405c。这个地址其实是我们上层代码使用的虚拟地址。当这个虚拟地址被填入页表的左侧时,系统就会为这个变量在内存中分配一段空间。这段空间在物理内存中也有自己的物理地址,比如0x11223344。
当我们尝试访问这个虚拟地址时,操作系统会自动根据页表将虚拟地址转换为物理地址。这样,我们就可以直接访问物理地址中的内容了。
简而言之,页表的作用就是将我们使用的虚拟地址转换成真正的物理地址,使我们能够正确地访问内存中的内容。
3.2.再次理解这种现象
在父进程创建子进程时,操作系统会为新创建的子进程创建一个内核的PCB结构。子进程会以父进程为模板初始化其内部的结构体对象。每个进程都需要一个地址空间,所以子进程也会复制父进程的地址空间。为了保持独立性,子进程具有独立的PCB和地址空间,虽然其中一些字段是从父进程继承下来的。
子进程在初始化数据区也有一个虚拟地址,比如0x40405c。子进程同样需要一个独立的页表结构。开始时,父子进程的大部分资源共享,它们的页表指向相同的物理地址。但当子进程尝试修改某个地址时,比如将g_val从100改为200,操作系统会识别到这个修改操作。
操作系统会在内存中为父子进程共享的数据重新划分空间,在物理内存中为这个变量开辟新的空间。这个新空间的物理地址会发生变化,比如变成0x00112233。然后,操作系统会将页表中的旧物理地址(比如0x11223344)改为新地址(比如0x00112233)。
这样,父子进程在物理内存上的值就被分开了。通过这种方式,子进程能够独立地运行,而不会受到父进程的影响。
所以子进程在打印时,显示的地址与父进程相同,这是因为子进程继承了父进程的地址空间,包括这个地址。
但在实际访问时,读取到的内容是不同的。
这是因为父进程通过页表找到的是物理内存中的某个地址,值为100,而子进程通过自己的页表找到的是物理内存中的另一个地址,值为200。
因此,虽然应用层面上看到的虚拟地址相同,但实际的值却是不一样的。
【注意】
- 写时拷贝 -——是由操作系统自己完成的。
- 页表左侧的虚拟地址对于右边物理内存重新开辟空间这个过程中是0感知的,不关心,所以不会影响到虚拟地址
我们现在就能知道写实拷贝的本质是什么了!!!!
- 写时拷贝的本质就是物理内存重新开辟空间,只更新子进程的页表的对应的物理地址,对应的虚拟地址不变
我们fork函数是怎么做到有两个返回值的?
- 返回值是同一个变量,虚拟地址相同,但是子进程的返回值和父进程的不一样,所以发生了写实拷贝,虚拟地址不变,更改了虚拟地址对应的物理地址
4、地址空间究竟是什么
我们来了解一下计算机里面的内存是怎么分配的?
计算机里的内存是通过地址总线来连接cpu
地址总线只有0,1两种情况,排列组合起来有种组合,有
,形成的地址范围是
所以每一块地址都是通过对应的数字来访问的
4.1地址空间上的区域划分是什么样的?
我们在上学期间,经常可能会和同桌画三八线,比如一张课桌是100cm,我们用一把尺子来划分区域,女孩的区域是0,50,男孩的区域是50,100,那么我们再计算机当中怎么描述这个事情呢?
我们可以这样定义:
struct area
{
unsigned long start;
unsigned long end;
};
struct area girl = {0,50};
struct area boy = {50,100};
此时我们就划分好了区域,这时,不管是男孩还是女孩,大脑里都有了这样的一个区域:
当女孩觉得自己活动范围不够,想扩大自己的区域时,就可以调整自己认为的[start,end],划分三八线的过程,就是划分区域的过程,调整区域的过程,本质就是调整自己认为的[start,end]
其中我们将桌子认为是物理内存,男孩和女孩认为是每一个进程,而男孩和女孩本质上都认为自己有一把尺子(脑海里的尺子),这把尺子就是进程地址空间,男孩想放自己的书包、铅笔等物品时,男孩就在自己的进程地址空间再划分区域放自己的物品。
那么如何划分进程地址空间的区域呢?
在Linux当中,进程地址空间本质上是一种数据结构,是多个区域的集合。
在Linux内核中,有这样一个结构体:struct mm_struct,在这个结构体就是我们开始说的进程地址空间:
struct mm_struct
{
unsigned long code_start;//代码区
unsigned long code_end;
unsigned long init_start;//初始化区
unsigned long init_end;
unsigned long uninit_start;//未初始化区
unsigned long uninit_end;
unsigned long heap_start;//堆区
unsigned long heap_end;
unsigned long stack_start;//栈区
unsigned long stack_end;
//...等等
}
在上面的例子中,男孩脑海里有一把尺子,想着自己拥有桌子的一半,女孩脑海里也有一把尺子,想着自己也拥有桌子的一半,而此时我们改变了:男孩和女孩关系比较好,不进行什么划分三八线,男孩脑海里有一把尺子,想着自己拥有0-100cm的桌子,女孩脑海里有一把尺子,想着自己也拥有0-100cm的桌子,他们在放东西时,只要记住了尺子的刻度就可以了。
4.2.深入理解地址空间划分
为了更深一步的理解进程地址空间,我们再来举一个例子:
比如有一个富豪,他拥有10个亿的身家,这个富豪有10个私生子,这10个私生子互相并不知道自己的存在,富豪对自己的每一个私生子都说孩子你好好学习,老爸现在有10个亿的家产,以后就全是你的,请问在这十个私生子的视觉来看,他们认为他们有多少的家产?
当然是10亿,当每个私生子向这个富豪要钱时,只要能接受,富豪肯定都会给,不能接受,富豪可以直接拒绝,在这个例子中富豪给每个私生子脑海里建立了虚拟的10个亿,此时每个私生子都认为自己有10个亿,每个人要的钱都是不一样的。
在这个例子中:富豪称之为操作系统,私生子称之为进程,富豪给私生子画的10亿家产,当前私生子的地址空间,对比言之:操作系统默认会给每个进程构成一个地址空间的概念 (32位下,地址空间是从000000…0000到FFFFFF…FFF) 4GB的空间,每个进程都认为自己有4GB的空间,每个进程都可以向内存申请空间,只要能接受都会给你,不能接受操作系统会直接拒绝,但是并没有什么影响,进程依旧认为自己有4GB的空间。
再回到男孩和女孩的例子,我们的进程地址空间就相当于是那把尺子,而尺子是有刻度的,进程地址空间也是从全000的地址到全FFF的地址,可以在这上面进行区域划分:比如代码区:[code_start,code_end],比如代码区的地址区间是这个:[0x10000,0x20000],那么区间的每一个地址单位就称为虚拟地址
总结:
- 进程地址空间本质是描述进程对内存可视范围的大小,进程地址空间一定要存在各种区域划分,对线性地址进行start和end
- 计算机为进程地址空间抽象出来的一个概念,内核:struct mm_struct,这样的每个进程,都认为自己独占系统内存资源(每个私生子都认为自己独占10亿家产),每个进程都有一个mm_struct,让进程让统一的视角看内存
- 地址空间区域划分本质:将线性地址空间划分成为一个一个的area,[start,end]。虚拟地址本质,在[start,end]之间的各个地址叫做虚拟地址
4.2.mm_struct——进程地址空间的具象化
进程空间本质是内核的一个数据结构,和PCB一样被操作系统先描述,后组织
在Linux内核中,有这样一个结构体:struct mm_struct,在这个结构体就是我们开始说的进程地址空间:
在结构体mm_struct当中,各个边界刻度之间的每一个刻度都代表一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立联系。由于虚拟地址是由0x00000000到0xffffffff线性增长的,所以虚拟地址又叫做线性地址。
- 事实上,每个进程被创建时,其对应的进程控制块(task_struct)和进程地址空间(mm_struct)也会随之被创建。
- 而操作系统可以通过进程的task_struct找到其mm_struct,因为task_struct当中有一个结构体指针存储的是mm_struct的地址。
例如,父进程有自己的task_struct和mm_struct,该父进程创建的子进程也有属于其自己的task_struct和mm_struct,父子进程的进程地址空间当中的各个虚拟地址分别通过页表映射到物理内存的某个位置,如下图:
而当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。
例如,子进程需要将全局变量g_val改为200,那么此时就在内存的某处存储g_val的新值,并且改变子进程当中g_val的虚拟地址通过页表映射后得到的物理地址即可。
4.3.为什么要有进程地址空间?
4.4.1.保护物理内存不受到任何进程内地址的直接访问,在虚拟地址到物理地址的转化过程中方便进行合法性校验
在早些时候是没有地址空间的:
如果进程直接访问物理内存,那么看到的地址就是物理地址,而语言中有指针,如果指针越界了,一个进程的指针指向了另一个进程的代码和数据,那么进程的独立性,便无法保证,因为物理内存暴露,其中就有可能有恶意程序直接通过物理地址,进行内存数据的篡改,如果里面的数据有账号密码就可以改密码,即使操作系统不让改,也可以读取。
后来就发展出来了虚拟地址空间,那么虚拟地址空间如何避免这样的问题呢?
由上面我们所了解的知识,一个进程有它的task_struct,有地址空间,有页表,页表当中有虚拟地址和物理内存的映射关系,有了页表的存在,虚拟地址到物理地址的一个转化,由操作系统来完成的,同时也可以帮系统进行合法性检测
我们在写代码的时候肯定了解过指针越界,我们知道地址空间有各个区域,那么指针越界一定会出现错误吗?
不一定,越界可能他还是在自己的合法区域。比如他本来指向的是栈区,越界后它依然指向栈区,编译器的检查机制认为这是合法的,当你指针本来指向数据区,结果指针后来指向了字符常量区,编译器就会根据mm_struct里面的start,end区间来判断你有没有越界,此时发现你越界了就会报错了,这是其中的一种检查,第二种检查为:页表因为将每个虚拟地址的区域映射到了物理内存,其实页表也有一种权限管理,当你对数据区进行映射时,数据区是可以读写的,相应的在页表中的映射关系中的权限就是可读可写,但是当你对代码区和字符常量区进行映射时,因为这两个区域是只读的,相应的在页表中的映射关系中的权限就是只读,如果你对这段区域进行了写,通过页表当中的权限管理,操作系统就直接就将这个进程干掉。
所以进程地址空间的存在也使得可以通过start和end以及页表的权限管理来判断指针是否合法访问
4.4.2.将内存管理和进程管理进行解耦
操作系统有四种核心管理:
- 1、进程管理
- 2、内存管理
- 3、驱动管理
- 4、文件管理
这里我们主要讲的是进程管理和内存管理:
如果没有进程地址空间,进程直接访问物理内存,当进程退出时,内存管理需要尽快将该进程回收。在这个过程当中必须得保证内存管理得知道某个进程退出了,并且内存管理也得知道某个进程开始了,这样才能给他们及时的分配资源和回收资源。这就意味着内存管理和进程管理模块是强耦合的,也就是说内存管理和进程管理关系比较大。
通过我们上面的理解,如果有了进程地址空间,当一个进程需要资源的时候,通过页表映射去要就可以了,内存管理就只需要知道哪些内存区域(配置)是无效的,哪些是有效的(被页表映射的就是有效的,没有被页表映射的就是无效的)。
当一个进程退出时,它的映射关系也就没了。此时没有了映射关系,物理内存这里就将该进程的数据设置为无效。所以第二个好处就是将内存管理和进程管理进行解耦,
内存管理是怎么知道有效还是无效的呢?
比如说在一块物理内存区域设置一个计数器count,当页表中有映射到这块区域时,count就++,当一个映射去掉时,就将count–,内存管理只需要检测这个count是不是0,如果为0,说明它是没人用的。
没有进程地址空间时,内存也可以和进程进行解耦,但是代码会设计的特别复杂,所以最终会有进程地址空间
内存管理是怎么将一些大型数据加载到物理内存的?
内存管理是通过延迟加载的方式加载到物理内存的,什么意思呢?
比如说你有一个16GB的大型进程,内存管理首先会给你加载小一部分先供你使用,当你使用完时,会先将进程置为睡眠状态,然后再加载一部分,然后将进程再唤醒,进程再继续使用就可以了。对于用户来说,唯一感觉到的是我的游戏运行的慢了。
4.4.3.让每个进程,以同样的方式(虚拟地址),看待代码和数据,明确程序运行的地址
目标文件和可执行程序,本身就已经被划分成为了一个个的区域:
磁盘上的可执行程序分区域的每个大小单位为4KB,每个这个大小的数据称为页帧,在物理内存中的每个大小单位也为4KB,每个这个大小的数据称为页框,那么为什么要分区域呢?
因为这可以方便生成可执行程序,在这之后其中有一个链接库的过程,如果可执行程序是乱的,那么这个链接过程非常困难,所以需要分好区域。由此进程地址空间才有了区域划分这样的概念,进程的地址空间连续化,也让顺序语句的执行成为了可能:当前语句的起始地址+当前代码的长度就等于下一条语句的地址。
如果没有进程地址空间,因为物理内存空余的地方不一定是连续的空间,可能是零散的,那么将可执行程序的数据加载到内存当中时,那么这些数据就是零散的放在各个位置,而这些位置我们又是不确定的,此时很难找到代码和数据的位置了。
进程地址空间的存在,进程地址空间又是进行区域划分的,通过页表的映射关系可以很好的找到物理内存,所以这也是存在地址空间的一个理由:让每个进程,以同样的方式(虚拟地址),明确程序运行的地址
回到最初的那个问题:为什么父进程和子进程的数据不一样,这个我们不意外,因为数据是私有的,但是地址却也是相同的,这是什么原因呢?到达这里我想这个问题已经显而易见了:
此时g_val的虚拟地址没有变化,而子进程的g_val的虚拟地址对物理内存地址的映射已经发生了变化,指向的数据区的g_val已经变为了100。
5、页表
5.1基本概念:
页表是一种特殊的数据结构,被放在系统空间的页表区,用于存放逻辑页与物理页帧的对应关系。每一个进程都拥有一个自己的页表,进程控制块(PCB)中有指向页表的指针。
页表的作用是记录虚拟内存地址到物理内存地址的映射关系,以实现内存管理。当程序访问一个虚拟内存地址时,操作系统会根据页表将该地址映射到相应的物理内存地址上。
5.2 CR3寄存器
每个正在执行的进程在CPU内部都有一个特定的寄存器,即Cr3寄存器(在x86体系结构中),用于存储其页表的起始地址。这个寄存器是管理性的,它确保了当前运行进程的页表地址始终可得。
因此,无论进程何时被切换走,我们都不必担心其页表地址的丢失。这是因为,在进程运行期间,Cr3寄存器中保存的页表地址本质上是该进程硬件上下文的一部分。
当进程不再运行,即被切换走时,它会带走其寄存器中的内容。当进程再次获得执行权时,它会恢复其原先保存在地址空间中的页表地址,从而能够重新访问自己的页表。这样,每个进程都能始终找到其对应的页表,确保地址转换的正确进行。
需要明确的是,Cr3寄存器中保存的页表起始地址是物理地址。在CPU需要访问进程的地址空间时,它会直接读取Cr3寄存器中的物理地址,并据此查找页表,实现地址的正确转换。这种机制确保了操作系统在硬件层面上能够高效地访问和管理进程的页表结构。
5.3 理解代码段和字符常量段是只读的
先看现象:
int main()
{
char *str = "hello world";
*str = 's';
printf("%s",str);
return 0;
}
这里可以看到程序直接挂掉了。
那么操作系统怎么知道我们要修改了只读常量区的数据呢?
当程序从磁盘加载到内存时,操作系统会负责将代码区和字符常量区等只读部分加载到相应的只读内存区域。虽然物理内存本身没有直接的只读或只写属性,但操作系统通过虚拟内存管理提供了这样的抽象。
CPU在访问内存时,实际上是通过访问页表来实现的。页表将虚拟地址映射到物理地址,同时页表中还包含了关于该内存页的访问权限等信息。对于只读的内存区域,页表中相应的条目会被标记为只读。
因此,当CPU试图修改只读内存区域时,操作系统会拦截这个操作,并直接杀掉这个进程。
5.3.页表常见问题
不同进程的虚拟地址可以完全一样吗?
答案是可以完全一样,因为每个进程都有各自的页表,每个进程都是独立的进行通过各自页表中虚拟地址和物理内存的映射关系去找代码和数据
那么不同进程的虚拟地址在页表中映射的物理地址可能会重吗?
答案是不会的,如果会重操作系统就挂掉了,有一种可能性会重,但这是我们可以刻意为之,比如创建子进程,让父子进程代码共享:
5.4 缺页中断
我们知道进程有挂起状态,那系统怎么知道的?
注意:我们现代操作系统,几乎不做任何浪费时间和浪费空间的事!
举个例子来
当我们玩一些特别大的游戏时,尽管游戏的体积可能达到几十个G,而我们计算机的物理内存可能只有4G或8G,但游戏仍然能够正常运行。这是因为操作系统采用了分批加载的策略来处理大文件。
操作系统不会一次性将整个游戏加载到内存中,因为这可能超出了内存的容量。相反,它会先将游戏的一部分数据,比如前1G或500M,加载到内存中供程序运行。当这部分内存被使用完毕后,操作系统会释放它,然后再加载下一批数据。
这种加载策略被称为惰性加载。对于可执行程序,操作系统在创建进程时,会先创建内核数据结构,包括为该进程维护的PCB(进程控制块)和地址空间页表等对应关系。然后,它才会开始逐步加载可执行程序。
在页表中,虚拟地址和物理地址的对应关系不是一开始就全部建立的。对于还未加载到内存中的部分,页表中的对应项会留空,并标记为指向磁盘中的特定地址。当程序试图访问这些地址时,会引发缺页中断。
在缺页中断发生时,操作系统会识别出所需的内存页或数据尚未加载到内存中。然后,它会在物理内存中为这部分数据申请空间,并将它从磁盘加载到内存中。加载完成后,操作系统会更新页表,将新加载的数据的地址填入对应的项中。
这个过程是自动完成的,对于用户来说是无感知的。因此,即使你的程序很快就启动了,但可执行程序可能并没有被完全加载到内存中。只有当程序实际需要使用某部分数据时,那部分数据才会被加载到内存中。这种按需加载的方式有效地利用了有限的内存资源,使得大程序也能在有限的内存空间中运行。
基本概念: 缺页中断,也被称为页缺失或Page fault,是计算机科学中的一个概念。当软件试图访问已映射在虚拟地址空间中,但是并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。
具体来说,进程线性地址空间里的页面不必常驻内存。在执行一条指令时,如果发现要访问的页没有在内存中(即存在位为0),那么会停止该指令的执行,并产生一个页不存在的异常,即缺页中断。此时,对应的故障处理程序会试图从外存加载该页到内存,以排除故障。如果加载成功,那么原先引起异常的指令就可以继续执行,而不再产生异常。
在虚拟存储系统中,缺页中断是实现内存管理、内存保护和内存扩充的重要手段。通过缺页中断,操作系统可以将不常用的页面换出到外存,从而为即将被访问的页面腾出空间。同时,缺页中断还可以用来实现内存保护,防止进程访问不属于它的内存空间。
虽然名为“中断”,但缺页中断并不总是意味着错误。在许多情况下,它是操作系统正常管理内存的一部分。然而,如果缺页中断频繁发生,可能会导致系统性能下降,因为每次中断都需要消耗一定的时间来处理。因此,在设计程序和系统时,需要尽量减少不必要的缺页中断。
进程在创建时,是先创建内核数据结构呢?还是先加载对应的可执行程序呢?
我们先创建内核数据结构,可持续程序可以后面慢慢加载
5.5.总结:
当我们通过虚拟地址转化尝试访问物理内存时,如果发现所需的物理内存并未在系统中(即内存尚未申请),这时就会触发一个缺页中断。随后,操作系统会负责重新申请内存、加载数据,并更新页表。这一系列的操作,包括内存的申请、数据的加载以及页表的更新,都是由操作系统的内存管理模块来完成的。
我们的进程根本不需要管理后面的内存的部分
在页表的结构中,左侧部分我们通常称之为进程管理相关的部分,而右侧则与内存管理紧密相关。由于有了页表和地址空间的存在,进程管理部分无需直接关心内存管理的细节。进程只需使用虚拟地址,若虚拟地址不足或不存在,操作系统会自动触发缺页中断,并调用内存管理功能进行处理。
因此,虚拟地址空间的存在在软件层面上实现了内存管理和进程管理的解耦,使得两者可以相对独立地工作,提高了系统的灵活性和可维护性。
学了这么多,我们对进程的认识就更深入一点了
我们来回答一下,
进程到底是什么?
进程=内核数据结构(task_struct和mm_struct和页表)+程序的代码和数据
进程为什么具有独立性?
因为每个进程都有自己的内核数据结构(task_struct和mm_struct和页表)+程序的代码和数据
即使是父子进程,也是如此