目录
一、程序地址空间回顾&&进程地址空间了解
在学习 C/C++ 时,我们知道内存会被分为几个区域:栈区、堆区、全局/静态区、代码区、字符常量区等。但这仅仅是在语言层面上的理解,是远远不够的。
程序是静态概念,是永久性软件资源;而进程是动态概念,是动态生亡的暂存性资源。所以他们的地址也不是同一个概念。
1、进程地址空间布局图
如下空间布局图,请问这是物理内存吗?
不是,下图是进程地址空间。
结论:
- 进程地址空间不是物理内存。(具体看第二大点分析)。
- 进程地址空间会在进程的整个生命周期内一直存在,直到进程退出。
这也就解释了为什么全局/静态变量的生命周期是整个程序,因为全局/静态变量是随着进程一直存在的
2、验证地址空间的基本排布
// checkarea.c
#include <stdio.h>
#include <stdlib.h> // malloc
int g_unval; // 未初始化数据区
int g_val = 10; // 已初始化数据区
int main(int argc, char* argv[], char* env[])
{
printf("code addr : %p\n", main); // 代码区
printf("\n");
const char *p = "hello";
printf("read only : %p\n", p); // 字符常量区(只读)
printf("\n");
printf("global val : %p\n", &g_val); // 已初始化数据区
printf("global uninit val: %p\n", &g_unval); // 未初始化数据区
printf("\n");
char *phead = (char*)malloc(1);
printf("head addr : %p\n", phead); // 堆区(向上增长)
printf("\n");
printf("stack addr : %p\n", &p); // 栈区(向下增长)
printf("stack addr : %p\n", &phead); // 栈区
printf("\n");
printf("arguments addr : %p\n", argv[0]); // 命令行参数(第一个参数)
printf("arguments addr : %p\n", argv[argc-1]); // 命令行参数(最后一个参数)
printf("\n");
printf("environ addr : %p\n", env[0]); // 环境变量
return 0;
}
运行结果:
二、进程地址空间的引入
先来写一个代码,定义一个全局变量gval,然后子进程的gval++,父进程的gval不变
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int gval = 100;
int main()
{
printf("我是一个进程,pid:%d, ppid: %d\n",getpid(),getppid());
pid_t id = fork();
if(id == 0)
{
while(1)
{
printf("我是子进程,pid:%d,ppid: %d, gval: %d, &gval: %p\n",getpid(), getppid(), gval, &gval);
gval++;
sleep(1);
}
}
else
{
while(1)
{
printf("我是父进程,pid: %d,ppid: %d,gval: %d, &gval: %p\n",getpid(),getppid(),gval,&gval);
sleep(1);
}
}
}
运行结果:
我们可以发现,子进程与父进程打印出来的 gval 的值不同,但是地址值是一样的,说明绝对不是物理地址。因为在同一物理地址处,不可能读取出两个不同的值。我们把这个地址叫做虚拟地址/线性地址。
三、理解进程地址空间
1、举例
假设有一个富豪只有10亿,但他有四个私生子,所以他和每个私生子都说给他们十亿来骗他们。
类比到计算机中:
富豪 —— 操作系统
私生子 —— 进程
富豪给私生子画的 10 亿家产的饼 —— 进程的地址空间
通过上述例子,可以得出结论:
- 操作系统默认会给每个进程构建一个地址空间的概念(比如在 32 位下,把物理内存资源抽象成了从 0x00000000 ~ 0xFFFFFFFF 共 4G 的一个线性的虚拟地址空间)
- 假设系统中有 10 个进程,每个进程都会认为自己有 4G 的物理内存资源。(这里可以理解成 OS 在画大饼)
2、认识进程地址空间
在 Linux 中,进程地址空间其实是内核中的一种数据结构对象。
在 Linux 中,OS 除了会为每个进程创建对应的 PCB(即 struct task_struct 结构体),还会创建对应的进程地址空间,即内核中的 struct mm_struct 结构体。
空间的本质无非就是多个区域(栈、堆…)的集合。
那么在 struct mm_struct 结构体中,OS 是如何表述(划分)这些区域的呢?
定义 start 和 end 变量来表示每个区域起始和结束的虚拟地址。然后通过设置这些 start 和 end 的值,对抽象出的这个线性的虚拟地址空间(在 32 位下,是从 0x00000000 ~ 0xFFFFFFFF 共 4G)进行区域划分。
3、什么是进程地址空间
进程地址空间:
(1)进程地址空间究竟是什么?
- 进程地址空间的本质:操作系统让进程看待物理内存的方式,这是抽象出来的一个概念。进程地址空间是内核中的一种数据结构,即 struct mm_struct 结构体。由 OS 给每个进程创建,这样每个进程都认为自己独占系统内存资源。
- 划分区域的本质:把线性的地址空间划分成了一个个的区域,通过设置结构体内的 start 和 end 的值来表示区域的起始和结束。(比如栈区和堆区的增长)
(2)为什么要进行区域划分呢?
- 可以通过 [start, end] 进行初步判断访问某个虚拟地址时,是否越界访问了。
- 因为可执行程序在磁盘中是被划分成一个个的区域存储起来的,所以进程的地址空间才有了区域划分这样的概念,方便进程找到代码和数据。
(3)虚拟地址的本质
每个区域 [start, end] 之间的各个地址就是虚拟地址,之间的虚拟地址是连续的。
(4)地址空间和物理内存之间的关系
虚拟地址和物理地址之间是通过页表来完成映射的。
举例解释
我们来举一个例子描述一下虚拟地址与物理地址是怎么联动的:
假设物理地址中存在一个全局变量 gval ,假设他的物理地址是:0x1234,他是真实存在的。gval在进程地址空间,即虚拟地址处,属于初始化数据区,假设他的起始地址是0x1111,两个地址将会放入 页表 中构建映射关系。同时,给用户返回的也是0x1111这个虚拟地址。后面记录gval = 100,这个动作会被编译器解释成向一个0x1111的地址写入一个整数100。那么当前进程会根据0x1111这个地址查找进程对应的页表,根据虚拟地址的映射的物理地址,找到物理内存中的变量gval,把空间写成100,这样完成了一个linux虚拟内存的管理方案。
(5)写时拷贝
假如创建了一个子进程,PCB会继承父进程的struct task_struc,而每个进程都有自己的虚拟地址空间,因此子进程也有自己的虚拟地址空间,而这个虚拟地址空间本质上是一个结构体,结构体里有很多属性值需要被填充,子进程不是加载进来的,而是新创建的,因此子进程的虚拟地址空间也需要以父进程为模板。因此子进程有个0x1111的地址也可以直接被上层使用。然后子进程再继承页表,所以父进程和子进程指向同样的区域。不修改代码时,数据是共享的,即会看到同样的gval变量。但假设子进程或父进程要对变量进行修改,要进行写入,而进程具有独立性,当你一旦要写入,系统会做类似操作:重新生成一份内存空间,把gavl拷贝下来,再修改一下页表的映射关系,把右边改成新生成的空间的地址。
补:重新理解进程
进程 = 内核数据结构(taskl_struct/mm_struct/页表)+自己的代码和数据。
进程的独立性:内核数据结构各自私有一份,代码和数据也是独立的。
(6)页表
(i)标记位
页表做映射,可以理解成1对1的哈希表。除了左侧的虚拟地址和右侧的物理地址,他也有许多的标记位,至少有4-5个,我们先来讲几种。
- 第一种,rwx:
我们把右侧理解成两个 unsigned long 的整数,第一个整数表示物理地址,另一个整数还有很多比特位,我们拿三个表示指向的目标内存的rwx(读写执行权限 )。当操作系统想进行写入时候,需要页表进行映射时候,先检查映射条目的权限如果有w(写权限),那才可以映射,那就把物理地址提取出来,允许你去访问 - 第二种,isexists :
表示你所指向的内存空间是否在内存当中。当你访问一个数据:如果在内存中,就转物理去访问了;如果不在内存,可能1:还没有被加载,可能2:在之前被切换出去了,如果是没有在内存里,但要访问了,那么系统会把数据从外设换入,重新填充页表并把新加载数据返回上去
(ii)字符串常量修改后程序崩溃的原因
上面的str在栈上,所以在栈区开辟了一个空间,后面的“hello bit”这个字符串常量在字符常量区,一般在已初始化数据区和代码区,而常量是只读的,尝试写入,进程就会崩溃了。操作系统是进程真正的管理者,进程崩溃了一定和操作系统有关。
而常量是只读的,是因为字符串常量所在在的区域对应的权限是r,就不让写入,rw才能写入。所以当尝试写入时,权限是只读的,操作系统会把进程结束。
但是编译器可以无法识别这个问题,所以编译器给我们引入了关键字 const。
4、存在进程地址空间的原因
直接让进程去访问物理内存不行吗?
- 早期,操作系统是没有进程地址空间的,这就导致物理内存暴露,恶意程序可以直接通过物理地址来进行内存数据的读取,甚至篡改。
- 后来,随着操作系统的发展迭代,有了进程地址空间(虚拟地址),由操作系统完成虚拟地址和物理地址之间的转化。
为什么还要存在地址空间呢?
(1)有效的保护物理内存
因为地址空间和页表是 OS 创建并维护的,也就意味着凡是想使用地址空间和页表进行映射,也就一定要在 OS 的监督之下来进行访问,也保护了物理内存中的所有合法数据,包括各个进程,以及内核的相关有效数据。
在进程内不能非法访问或映射,因为 OS 会进行合法性检测,如果非法则终止进程。
通过页表中的权限属性,来判断当前访问的地址是否合法。页表完成了虚拟地址到物理地址之间的映射,而页表中除了有基本的映射关系之外,还可以进行读写等权限相关的管理。
上文中的常量字符串就是一个例子:发现该地址处只有读权限,说明非法访问了,页表拒绝转换,OS 直接终止进程。
(2)将内存管理模块和进程管理模块在系统层面上进行解耦合。
操作系统的核心功能:内存管理、进程管理、文件管理、驱动管理。
- 没有进程地址空间时,内存管理必须得知道所有的进程的生命状态(创建、退出等)才能为每个进程分配和释放相关内存资源。所以内存管理模块和进程管理模块是强耦合的。
- 而现在有了进程地址空间,内存管理只需要知道哪些内存区域(page)是被页表映射的(已使用),哪些是没有被页表映射的(未使用),不需要知道每个进程的生命状态。当进程管理想要申请内存资源时,让内存管理通过页表建立映射即可;想要释放内存资源时,通过页表取消映射即可。解耦的本质也就是减少模块与模块之间的关联性,所以就是将内存管理模块和进程管理模块进行解耦了。
在物理内存中,是否可以对未来的数据进行任意位置的加载?
可以。我们通过页表进行映射,因此时间复杂度基本都是O(1),加载到哪里都基本上是O(1),所以任意位置不影响效率。进程是以统一的视角看待物理内存,因此随便物理内存里怎么放,在进程看来,代码区都在一起,堆区都在一起,栈区都在一起,统一的地址空间,即使通过页表映射到不同位置,这些区域还是按照一定规则统一呈现的,这也是上面虚拟内存存在的原因二简单的解释方法。
物理内存的分配可以和进程的管理做到没有关系。这也是上面讲的解耦合。
在 C/C++ 语言上 new/malloc 出一块新的空间时,本质是在哪里申请空间的呢?
虚拟地址空间。
如果申请了空间,但不立马使用这块空间, 是不是对空间造成了浪费呢?
是的。
所以本质上,(因为有地址空间的存在,所以上层申请空间,缺页中断:其实是在地址空间上申请的,物理内存可以甚至一个字节都不给。而当我们真正进行对物理地址空间访问时,才执行内存的相关管理算法来申请内存,构建页表映射关系)然后再进行内存的访问。
括号内的部分完全由 OS 自动完成,用户,包括进程完全 0 感知。
在分配内存时采用延迟分配的策略来提高整机的效率。(几乎内存的有效使用率是 100%)
(3)通过页表映射到不同的有序区域来实现进程的独立性。
- 在进程的视角,所有的内存分别都可以是有序的。
- 让每个进程以同样的方式来看待代码和数据。(这样对于进程的设计是非常好的)
可执行程序,在磁盘中是被划分成一个个的区域存储起来的(比如代码 .txt、已初始化数据 .data、未初始化数据 .bss 等等)。
因为可执行程序形成时,有一个链接的过程,会把用户代码和库的代码合并在一起,把用户数据和库的数据合并在一起。否则可执行程序的代码和数据如果是混着存放在一起的,会导致链接过程变得很复杂。所以进程的地址空间才有了区域划分这样的概念,方便进程找到代码和数据。
分析:
如图,代码被零散的加载到了内存的各个位置。如果直接让进程去找到代码是非常困难的,尤其是找到代码的起始和结束位置。所以我们在进程的地址空间中划分出一个个区域,再通过页表把内存中的各个位置的代码给整合到一起,使代码的物理地址变成线性的虚拟地址了。然后进程通过其对应地址空间中的代码区(区域中虚拟地址是连续的)可以很方便的找到代码。同时 CPU 也方便执行代码(虚拟地址是连续的,这样 PC 指针才能进行加 1 的操作,得到下一条指今的地址,CPU 才能从上到下顺序执行指令)。
- 地址空间 + 页表的存在可以将内存分布有序化。
- 结合(2),进程要访问物理内存中的数据和代码,可能目前并没有在物理内存中。同样的,也可以让不同的进程映射到不同的物理内存,便很容易做到进程独立性的实现。
- 进程的独立性可以通过进程空间 + 页表的方式实现。
好处:
不用在物理内存中找一块连续的区域。因此回答了在物理内存中,不能对未来的数据进行任意位置的加载
站在进程的角度,所有进程的代码(二进制指令)存放的区域,虚拟地址是连续的,可以被顺序执行。(即使物理内存上有可能不连续)
5、进程空间地址 mm_struct 的初始化方式
我们先可以用 readelf -S process 查看各个区域的的大小信息。
可执行程序编译的时候,各个区域的大小信息已经有了,可执行程序可以分段,同时也包含属性(比如代码区大小多少,从哪到哪)。所以加载程序时,可以不加载代码和数据,但是内部要维护可执行程序的属性,用这些可执行程序的属性来初始化地址空间 mm_struct 各个区域的大小。
对于整个地址空间来讲,一部分是由上面所说的可执行程序来的,一部分是操作系统动态创建的,如栈区,堆区。程序运行时,函数被调用,这时操作系统才给你创建栈帧。
补:堆区空间扩展的本质:虚拟地址扩大,需要访问时,操作系统会给你进行内存二次申请
6、全局性
我们之前学c语言时了解到,全局变量或者字符常量具有全局性,在程序运行期间都有效,为什么他们在程序运行期间具有全局性呢?
因为这些变量保存在地址空间的未初始化数据区,已初始化数据区和字符常量区的,随着进程一直存在,一旦映射好了,就不会变了,不像堆区栈区可以申请释放,因此在进程运行期间一直存在,他们对应的是全局变量,地址都能被所有函数,类等都能看到。所以之前是语法层面的全局性,今天我们修正一下为在进程运行期间一直具有全局性。
四、重新理解什么是挂起
进程和程序有什么区别呢?
- 加载的本质就是创建进程。
那么是否必须立刻将所有程序的代码和数据加载到内存中,并创建内核数据结构建立映射关系?
不是。
如果在最极端的情况下,只有内核结构被创建出来了(新建状态)。当真正被调度/执行代码时,才把外设加载内存里,然后再执行代码。
- 理论上,可以实现对程序的分批加载。
如果物理内存只有 4G,有一个游戏 16G,能否运行?
可以运行。
CPU 无论运行多大的程序,都需要从头到尾执行每一行指令。即使物理内存有 32G,也不会一次性把 16G 的程序加载进来(因为内存资源还需要分配给其它进程),而是采用延时加载。比如先加载 200M 进来,执行完了再覆盖式的加载 200M 进来,然后再执行。所以如果物理内存比较小,用户可能会感到游戏卡顿。
- 加载的本质就是换入的过程。
既然可以分批加载,那可以分批换出吗?
可以。
甚至这个进程短时间不会再被执行,比如挂起 / 阻塞。
- 也就相当于其对应的代码和数据占着空间却不创造价值,所以 OS 就可以将它换出,一旦被换出,那么此时这个进程就叫被挂起。