009-Linux程序地址空间

Linux程序地址空间

1. 通过代码看现象

一段简单的代码:

输出结果:

我们可以发现一个现象,开始时,子进程和父进程的g_val的值和地址都是一样的,值一样并不奇怪,因为子进程的数据继承自父进程,是从父进程拷贝来的,但是他们的数据的地址也是一样的,这并不符合我们进程的独立性,并且,子进程修改了g_val的值以后,子进程和父进程的值不一样的,但是它们的地址居然还是一样的??一个地址两个值??

这里我们几乎可以断定,这里显示的地址绝对不是实际上的物理地址,因为同一个物理地址不可能在同一个位置存两个值。

而这里的地址,我们称为虚拟地址

2. 引入虚拟地址

在我们前面的理解中,我们运行一个程序时,会将程序的代码和数据从磁盘加载到内存中,而进程的task_struct中会有指针指向代码和数据。

但是实际上在进程被创建时,还会配套的创建一个地址空间(进程地址空间)。

地址空间在32位和64位下范围是不一样的,这里以32位为例,也就是有4G的范围。

而在地址空间中,并不是存的实际的一个个数据,而是存着连续的虚拟地址,数据是存储在物理内存中的。实际上在地址空间和物理内存之间,还有一个页表,用于映射这里的虚拟地址和物理内存中的物理地址,我们的进程在获取数据时,将会拿到这里的虚拟地址,通过页表获取到实际的物理地址,然后拿着物理地址去物理内存中拿到对应的数据。

当父进程创建子进程时,子进程会继承父进程的代码和数据,代码是只读的,所以哪怕是同一份也没关系,但是继承的数据实际上就是拷贝的父进程的数据到子进程,包括地址空间和页表,子进程被创建的时候,也会创建一块新的地址空间和页表,然后将父进程的地址空间和页表的数据拷贝一份到子进程中,这样的话实际上子进程和父进程指向的还是同一份数据,用语言的话来讲,这就是浅拷贝。

但是这样的话父子进程不就共享一份数据了吗?这并不符合进程的独立性,但实际上如果父进程或子进程对数据进行写操作时,系统发现这块内存是由两个进程同时指向的,这时就会重新开辟一块空间,然后将写内容的进程的页表修改指向新的空间,然后让进程进行写入操作,如果一个数据没有进程进行新的写入那么父子进程将会指向同一块数据,这个操作也就是也就是写时拷贝,用这样的方法通过调整拷贝的时间顺序,达到有效节省空间的目的。

这样就能解释上面的代码,父子进程打印出来的地址,都只是虚拟地址,虚拟地址是子进程从父进程拷贝过来的,所以是一样的,而它们的虚拟地址映射的物理地址,实际上是不一样的。

3. 理解

3.1 理解地址空间

3.1.1 划分区域

通过上面的图中,我们可以看到,地址空间中是被划分了很多个不同的区域的。

地址空间中区域的划分是怎么表示的?实际上每块区域就是由两个指针表示,一个整数start表示这块区域的开始地址,一个整数end表示这块区域的结束地址。

区域大小的调整就是对于这些stact、end进行加减操作。

地址空间本质是内核的一个struct结构体(struct mm_struct),内部有很多属性,其中代表每一块区域的属性就是两个unsigned long类型的整数start和end,分别代表区域的开始和结束。

我们可以通过查看Linux内核的源码来证明(这里只截了一部分):

3.1.2 地址空间

上面说了,进程的地址空间有4G,但是实际并不是这个地址空间占了4G的内存,否则我们使用的电脑一般也就十几G或者几十G的内存,OS中几十上百的进程,这点内存根本不够霍霍的。

实际上4G的地址空间意味着,OS给进程划了一个4G的范围,告诉进程这4G的范围是你可以使用的(类似于你的老板画大饼),而这4G连续的内存就是进程的地址空间,但实际上大部分进程是用不掉这么多空间的。

本质上进程使用的这里面的空间还是通过页表来映射到物理内存上的。

3.2 地址空间存在的意义

如果一个进程没有自己的地址空间,那么进程内部的地址就是直接对应着真实的物理内存的。在这种情况下,如果我们的程序存在bug,发生了越界访问,直接修改了物理内存中其他进程的重要数据,那将是非常危险的,但是有了地址空间的话,哪怕越界,也是虚拟地址的越界,虚拟地址越界后,在页表中并不存在这个虚拟地址,OS也就会拦截这个请求,也就不会真的修改到物理内存中的数据。

并且,有了地址空间,进程的地址空间中的划分好区域的虚拟地址都是连续的,这就方便了进程对于这块虚拟内存的管理,如果没有地址空间,那么进程对应在内存中有可能就是很多碎片的内存,东一块西一块的,非常不利于管理。

现在有一个场景,假设在我们的程序中存在着3MB代码,然后,进程运行代码已经运行完了1MB,此时,OS中的内存不够用了,然后OS发现,这个进程中的1MB代码,是已经不会再使用了,占着空间也是浪费,然后就可以把这个已经运行完的代码进行释放或者唤出到磁盘,然后拿着这1MB内存干其他事情,但此时从进程看来,在它的地址空间中,这3MB代码块还是存在的,不会影响进程对应它虚拟内存的管理。

还有一个场景:有时我们写代码时,申请的内存可能并不会马上使用,此时这段内存空出来的时间,不就相当于浪费了的吗?此时OS为了提高内存的利用率,虽然进程申请了这一块空间,在页表的一侧已经填好了该虚拟地址,但是此时OS并没有实际分配物理内存给进程,但是从进程的视角看,这个内存已经申请成功了,直到进程使用这段内存时,OS才会实际的分配物理内存给这个进程(就类似于写时拷贝)。

【总结】

  • 拦截非法请求,保护物理内存

  • 让无序变有序,让进程以统一的视角看待物理内存以及自己运行的各个区域。

  • 进程管理模块和内存管理模块解耦

3.3 进一步理解页表和写时拷贝

那么OS怎么结合页表判断,哪个操作是实时分配内存,哪个操作是写时拷贝......

这就要说到我们的页表,实际上页表并不是我们想象的那么简单,在CPU内有一个MMU工作单元,作用是把寄存器中的虚拟地址结合页表转化成物理地址,页表中不仅仅存在着映射关系,还有各种标记位(比如记录当前虚拟地址是否对应着物理地址,此地址是否具有读写权限等)。

这也就能解释,我们之前说到的"挂起"操作,实际上就是把物理内存中的数据移动到磁盘后,将页表中对应的映射关系的属性修改为此地址不在物理内存中。

在C语言中我们曾经可能写过类似于这样的代码char *str = "hello world",而这个字符串是无法被修改的,那么怎么做到让他无法被修改,只要在页表中将这个字符串对应的映射关系的属性修改为只读权限就可以直接对写操作进行拦截。

而我们前面说的写实拷贝,就是在创建时,父子进程指向同一块数据,此时我们可以暂时理解为,OS将它们两个指向这块数据的权限变成了只读权限(实际上是通过一个叫做引用计数的东西来控制的),然后当发生写操作时, 发现这个是只读权限,原因是需要写时拷贝,然后就发生写时拷贝,然后再将它们两个的权限修改为读写权限。

【总结】当进程读写内存时OS识别到异常:

  1. 判断是否是因为数据不在物理内存导致的(挂起),如果是,发生缺页中断,重新开辟内存,唤入数据,建立映射。
  2. 判断是不是因为需要写实拷贝,如果是,发生写时拷贝。
  3. 如果都不是,才进行异常处理。

3.4 理解虚拟地址

所谓的虚拟地址空间,本质上就是一个结构体,里面包含了各种属性和区域划分,以及在为进程分配地址空间的时候,还会初始化一个页表。

在最开始时,地址空间页表中的数据从哪里来?

在我们编译好的程序中,没有加载到内存的时候,本身内部就有地址,我们可以通过反汇编来查看(反汇编指令:objdump -S 可执行程序),篇幅原因,只截了一部分:

这也就说明,程序里面本身就有地址,这些地址是在编译的过程中就给每个语句编址并划分好了区域的,而这些在可执行文件中的地址也就是虚拟地址(逻辑地址)。

回过头来解答上面的问题,在开始时,页表中的虚拟地址,就是从可执行程序中加载进去的,加载进去以后,再和新分配的物理地址建设映射关系。

而这些地址也是我们程序中代码的一部分,在执行这个程序时,如果碰到了类似call 0x12345678这样的指令,也就是执行0x12345678地址处的代码,而这个0x12345678就是虚拟地址,需要通过页表进行转换,然后执行物理地址处的代码。

相关推荐
苏宸啊1 小时前
进程的概念
linux
yuezhilangniao1 小时前
程序人生-杂谈-简单对比一下 学霸和linux科学设计
linux·程序人生·职场和发展
只想恰口饭1 小时前
程序人生-Hello’s P2P
linux·c语言·ubuntu
hoperest1 小时前
程序人生-Hello‘s P2P
linux·c语言·程序人生·ubuntu
quixoticalYan1 小时前
哈工大计算机系统大作业报告-程序人生-Hello’s P2P
linux·windows·程序人生·ubuntu·课程设计
czxyvX2 小时前
010-Linux内核进程调度队列(了解)
linux
一路往蓝-Anbo2 小时前
第 1 章:M33 领航——STM32MP257F-DK 硬件解密与启动逻辑重构
linux·stm32·嵌入式硬件·重构
暴力求解3 小时前
Linux--进程(四) 进程优先级与进程切换
linux·运维·服务器
Re_Virtual3 小时前
OpenEuler 20.03构建zabbix7.0 rpm包
linux·zabbix·openeuler