进程管理6——进程地址空间

本文详细探讨了虚拟地址空间的概念、划分及其在进程管理中的作用。虚拟地址空间确保了进程的安全性和独立性,通过页表实现虚拟地址到物理地址的映射。在Linux系统中,进程的地址空间分为正文段、初始化数据、未初始化数据、堆、栈等区域,各区域有特定的用途。文章还解释了为何父子进程可以拥有相同的虚拟地址但内容不同,以及早期和现代计算机设计的区别,强调了虚拟地址空间在提高系统可维护性和效率方面的重要性。
摘要由CSDN通过智能技术生成

目录

由一个进程引入的虚拟地址空间

  一为什么会有虚拟地址空间

二 虚拟地址是如何划分的

 验证地址空间的划分

 三 什么是虚拟地址空间

论早期和现在计算机的设计

 如何理解mm_struct(虚拟地址空间)的区域划分

 如何理解映射关系?

对刚开始问题和fork两个返回值问题的解释

深入了解虚拟地址空间的产生以及页表的映射机制

四 虚拟地址空间这样设计有什么好处

1 安全

2 可维护性高

3高效

4 进程独立


由一个进程引入的虚拟地址空间

  如图所示,我们观察到,在test.c这个文件中,我们定义了一个全局变量g_val,用fork创建了子进程,最后我们发现,在同一个地址处的值值确实修改了啊。但是即使子进程中将全局变量修改了,依然不影响同一个地址处父进程中的这个值:子进程中g_val变成200之后,父进程依然是100!

  为什么父子进程对同一个全局变量的值做修改,为什么同一个地址处的父子进程会有不同的值?修改的时候是如何进行修改的?

  虽然我们还不甚理解,但是我们可以确定,此时的值肯定不是物理内存中的值。那这又是啥?

  这其实跟linux中的进程地址空间有很大的关系。这其实是一个虚拟地址。在linux中也可以被称作线性地址。

  一为什么会有虚拟地址空间

  Linux配合硬件,以软硬件结合的方式,创造出的一种OS层面的地址空间。这个软硬件结合的方式,如何结合的,之后我会做详细的介绍。

 为什么会有一个这样的概念呢?实际上也是为了统一。其实除了CPU,很多外部设备也有寄存器的,内存和外设的存储空间被统一编址,最终都被当做内存来看待。但是实际上,这些内存也有外设参与编址。因此,数据在写入的时候,可能看似是写在了所谓的”内存“,其实也有可能写在其他位置。但是从外部的视角看来,都是对内存的操作。

  这也就解释了为什么会有进程地址空间——内存并不是我们所理解的意义上的内存。对于在其他设备中也会进入读写,这些其他设备在内存中也会有对应的区域划分出来。对这些设备区域都进行划分的空间叫做虚拟地址空间。

二 虚拟地址是如何划分的

那么虚拟地址是如何进行划分的呢?以Linux为例:

先引入一个前置概念:语言编译之后就变成一个二进制的语言,当这个二进制语言被运行之后,编译出来的程序就变成一个进程了。因此我们执行打印语句的时候,本质上是进程在打印,打印出来的各种地址就是进程地址。因此我们编写一个程序,之后编译链接运行它,才方便观察进程的各种地址。

 验证地址空间的划分

既然要验证地址空间,就要考虑各个数据在哪个位置。可以根据不同的区域中具有代表性的数据来观察。

正文代码段:main函数。main函数就是函数,函数名就是地址,他所在的位置一定处在代码段。观察代码段打印main函数就行了。

正文代码区中有一块字符常量区:他是只读的和正文代码段是一样的,因此不能被改写。

“hello world”这种能够被直接编译的叫做字面常量。char *str =“hello”;在字符常量区。相当于往盒子里放东西。

和代码区地址很像:所有的字面常量,都会编码进代码的这个区域(read only string addr)

我们平时写程序读写变量,从来没有读写过代码。fork代码只读的,不可被写入,和字符常量是同一个属性,因为他们在同一个区域中。

字符常量区:只读 和正文代码段是一样的 因此不能被改写

全局变量充当初始化和未初始化数据所在的区域的数据。

初始化数据:全局的初始化数据 g_val=100

未初始化数据:g_unval;

堆区:

heap_mem是main函数定义的指针变量,本身是个变量,也要开辟空间,只不过需要放个地址就行了。他是指针,本质上就是一个地址。那么heap_mem保存的就是堆区的起始地址。不用再&heap_mem了。

共享区很难验证 暂时不验证

栈区:函数调用的过程,函数内所形成的变量:比如自动变量,临时变量,局部变量都是在函数内定义的变量。开辟空间实质都是在栈上开辟的。

栈区地址 heap_mem本质是在men函数函数内部定义的指针变量。除此之外,main函数是函数,调用的时候也开辟了其栈上的空间。

test属于临时变量,也可以打印出栈区的地址。

static,不管是C语言还是c++中用法:修饰临时变量,修饰全局变量,修饰函数。

后两者跨文件时候用的,暂时不考虑。

在函数内如果用static修饰,该变量只会被初始化一次,即使后续函数结束了,它依然存在。

作用域在本函数内有效,只在这里被访问。但它的生命周期是全局的。

实际上在编译器看来,static修饰的变量已经编译进了全局数据区,它随着函数调用结束依旧存在 ,但是只能在本函数内访问,所以生命周期会变成全局属性。

意义:static修饰的局部变量:将局部变量转换成全局变量

命令行参数和环境变量即在main后带上参数。之前有介绍。

命令行参数在靠近低地址的位置,先划分好,相对于环境变量是比较小的。

打印命令行参数的地址:一定要我们的程序在程序的上下文能够获取到的,存储在里面的内容,是每一个命令行参数本身的地址,而非数组的地址。

指针数组必须指向一个一个的环境变量字符串,但是最后一定会指向null,那么循环也就结束了

所以可以这样去编写我的.c和makefile文件。

我们可以观察到

正文代码数据并没有从0开始的

已初始化和未初始化的数据,都在初始化数据这个区域,但是各自有自己不同的位置。

堆区从上到下依次调用的,因此从上到下打印出来的地址也是从低地址到高地址的,即向上增长的。

同理,栈区也是依次调用,向下增长的。

栈区相比于其他的是一块很大的空间。他和堆区相差了很大。

用的xshell远程连接上了云服务器,是一个真x64的平台。并且他们中间存在一块共享区。

我们可以观察到:

程序运行后打印出来的地址是满足进程地址空间排布规律的

 总结:自底向上依次增大的地址空间排布。进程地址空间分为正文段,初始化数据区域,未初始化数据区域,堆区,栈区,命令行参数区,环境变量区,其中堆栈相对而生。

其他补充:

malloc用法是填一个值的大小,最后得到开辟后的空间的起始地址。一旦越界,程序崩溃了。

malloc知道自己申请了几个字节,使我们指定的。

但是free只传入对空间的起始地址,free怎么知道需要释放从地址开始的多少个字节?

实际上malloc(10)申请的时候会申请更多的字节,多出来的字节用于记录堆的属性信息:什么时间点申请,申请的空间大小是多少,堆区访问相关权限的数据信息。

他们也被称作cookie数据。

因此free的时候,只需要传入堆空间的起始地址,之后可以自动寻找了。

以上我们验证了地址空间的排布。

其实这一块地址空间分为用户空间和内核空间。

在32位平台下 一个进程的地址空间,她的取值范围是从全0 0x 0000 0000到0xffff ffff进行编址的

【0,3GB】用户空间

【3GB,4GB】内核空间

在linux和windows的划分

排布:按照上面的来进行排布的。但是具体会有一些小差异。

 上面的验证代码,在linux和windows下会跑出不一样的结果。

上述结论默认只在linux下有效。

windows本身,栈空间打印随机的,因为他比较注重用户安全。

所以地址空间的排布取决于编译器或者操作系统。我们如今讨论的是操作系统中的地址空间。

 三 什么是虚拟地址空间

操作系统为了更好地管理进程,给每一个进程都独立的拥有一块虚拟进程地址空间。她的本质上是一个数据结构,和特定的进程相关联,以便让操作系统对他实现管理。

这块内存并不是物理内存,只是操作系统给每个进程画的“饼”。

论早期和现在计算机的设计

为什么会这样设计呢?为什么不直接访问物理内存?

其实早期的计算机是这样设计的。但是有很多不好的地方。

磁盘将进程加载到内存中时候,直接加载到物理内存中。其中记录这每一个进程的起始地址和偏移量。

当CPU调度进程的时候,OS在内存中选择一个内存,交给CPU,访问的是物理内存。那么如果发生了野指针的问题,可能就直接访问到了别的进程的空间。又由于内存本身是可以被读写的,那么这样一来,就直接把其他进程的代码和数据都改了。

直接使用物理内存,特别不安全之外,还有内存碎片问题。

因此这时候的内存中的一个个进程不具有独立性,要是别人想读取你的密码也很容易,非常不安全。

这所有的原因都是因为我们直接使用了物理内存中的物理地址。

因此不能直接使用物理地址。

那之后的计算机是如何改进的呢?

每一个进程有自己独立的PCB结构体(内核数据结构进程控制块)

OS给每一个进程创建一个地址空间,也就是所谓的进程地址空间,他是一种虚拟地址空间,是内核中的一种数据结构。(mm_struct)

编址:0x0000 0000->0xffff ffff

编址不再使用物理内存。而是用这个虚拟内存。

如果当磁盘上有一个可执行程序,要运行的话,他会被编入到虚拟地址空间,之后通过一种映射机制,再来访问物理内存。虽然最终还是会访问物理地址。但是虚拟地址空间和映射机制:可以鉴别操作是正常还是非法操作。如果是非法操作就直接拒绝。相当于变相的保护了物理地址。

 如何理解mm_struct(虚拟地址空间)的区域划分

OS要对每一个进程做到管理,就要先描述再组织。每一个进程有自己的地址空间,他是PCB结构体中的一种数据结构(mm_struct),各个区域的划分,PCB中有对应的指针指向这个区域。本质上是指定这个区域的start和end,制定了这样的一个范围,就可以对各个区域进行划分了。但是这个区域并不是固定的,是动态变化的,只需要改变对应的start和end就可以实现范围的变化了。

可以理解成“三八线”。

struct mm_struct
{
	int code_strat;
	int code_end;

	int init_start;
	int init_end;

	int uninit_start;
	int uninit_end;
	……
};

 如何理解映射关系?

实质上是一种被OS维护的表结构。叫做页表。

地址空间和页表(用户级页表)是每一个进程都私有一份的。

如果有多个进程也是同理。那么就存在多个虚拟地址空间和页表。

我们只需要保证:每一个进程的页表映射的是物理内存的不同区域,就能做到进程之间不会互相干扰,进而保证进程的独立性。

页表维护映射关系,是用来维护虚拟地址和物理地址之间的关系的。

有些进程,甚至地址空间是完全一样的,但是页表是不一样的,他们被映射到物理内存的不同区域 可以保证具有独立性。

对刚开始问题和fork两个返回值问题的解释

这也可以解释我们刚开始的问题:

当我们刚开始创建只有父进程,接下来创建了子进程。父子进程被创建,子进程会继承大部分父进程的东西, 包括地址空间。但是有所继承有所修改,部分需要私有化或者个性化的属性修改,其他大部分是一样的。

那么页表中所存储的每一个进程的虚拟地址空间都是一样的,开始时全局变量 g_val的虚拟地址,就被映射到了这里。

因为父子进程的页表一样,所以映射关系指向的是同一个变量。虚拟地址空间中所处的位置是同样一个,所以他们的地址的是一样的,并且刚开始值也是一样的。当子进程尝试修改的时候,要保证进程的独立性。当os识别到子进程通过页表找到g_val想要去修改的话,os重新开辟一段空间,如果有必要,就拷贝相关的值,并且修改对应的映射关系,直接修改并且映射到新开辟的空间。那么子进程的值从100变成了200,完成修改。但是虚拟地址不被修改,虚拟地址是一样的,但是物理地址被映射到不同的区域,所以值是不一样的。这就导致了地址一样,内容不一样。

地址一样-》同一个虚拟地址 来源于各自的虚拟地址空间

内容不一样-》被映射到了不同的物理地址

最开始创建指向同一个位置,当修改时才发现地址不一样了,也就是说写时候才重新开辟一块空间,经过页表重新映射到新的物理内存中。这叫做写时拷贝。

我们也可以用写时拷贝这个现象解决之前fork为什么会有两个返回值的问题了:

return是个语句,被fork执行会被执行两次。本质就是对id进行写入,fork成功,return写入,父子进程都会执行is和else判断。

父子拿到各自对应的id,同一个变量内容不一样,return返回时候对id做写时拷贝。因此父子进程各自其实在物理内存中有属于自己的变量空间。

只不过在用户层,我们用同一个变量来标识了。即使用同一个虚拟地址。

深入了解虚拟地址空间的产生以及页表的映射机制

当我们的程序在编译的时候,形成可执行程序的过程中,虽然没有被加载到内存中的时候,我们程序内部有地址吗?

是有的。它叫做VMA 虚拟内存地址。为什么这么说呢?其实我们回忆一下编译链接的过程。

动静态链接本质上就是把我自己写的程序和库关联起来,说白了其实就是把我程序中的调用库函数的函数调用,编译之后就是符号表,填入对应的地址,链接就是把库中的地址拷贝到我的程序中,因此我就知道对应要链接哪个库了。

关于磁盘中的程序编址以及加载到物理内存中的编址

因此我们发现地址空间不要仅仅理解成是OS内部要遵守的,其实编译器也要遵守。

即编译器编译代码的时候,就已经给我们形成了各个区域:代码区,数据区,堆区,栈区,全局符号区…………并且采用和linux内核中一样的编址方式,给每一个变量每一行代码都进行了编址,因此程序在编译的时候,早已经就有了每一个字段,早就已经具备了一个虚拟地址。

你的程序在磁盘上形成可执行程序的时候,其实编译器已经按照从全0到全f已经进行了编址。

当程序加载的时候,不仅仅把代码和数据,加载进物理内存中去,虚拟地址也会加载进去。

自己写的程序内部用的不是物理地址。他是编译器对每一个程序都要编址后形成的虚拟地址。不仅标识了自己的空间,还可能在内部保存有其他跳转函数的地址。当程序被运行起来,放到物理内存中的时候,除了对应的物理地址,自己还包括了之前编译器形成的虚拟地址(也是要被编译进可执行程序的)

关于如何形成虚拟地址: 

当CPU运行一个程序,这个程序内部的地址,其实依旧用的是编译器编译好的虚拟地址。首先需要被加载到内存中,其次需要给进程构建对应的PCB结构体,他有自己对应的虚拟地址空间。由于OS采用和编译器同样的地址空间方案,因此可以用加载进去的各个程序来限制我的start和end。所以代码对应的区域就限定好了。

关于映射关系:

每一个进程都有自己独有的一份虚拟地址,他用起始和结束标识对应范围。每个变量都有自己的虚拟地址空间,因此我们可以在每个区域找到特定变量之后编址。把虚拟地址给到页表左侧,物理地址在右侧。此时就构建好了一个映射关系。

其他:

CPU运行进程根据页表读到某一行指令,这时候,该指令内部也有地址,指令内部的地址是虚拟地址。

因为OS对虚拟地址空间编址采用和编译器同样的方式,因此在磁盘上如何使用这个数据,在CPU读取到的也是这样的。

虚拟地址空间的形成编译器也要参与的。比如磁盘上写了一个test.c的程序编译形成mytest的可执行程序。

程序内部必须有地址,来标定自己代码中的逻辑关系,函数入口,调用的每一个函数位置……假设第一个函数在0x1处,里面存储了第二个函数的地址,用于跳转。第二个保存在0x10,里面存储了第三个函数的地址。 第三个是0x 100。因此除了这一行代码本身被标识了,每一个代码内部也保存了对应的地址。

也就是说,每个函数都有地址,函数之间有跳转关系,因此把内部调用的函数的地址放入其中,所以保存的也是虚拟地址。

关于地址空间和页表最开始的时候,数据从哪来?

页表映射最开始的时候数据从哪来的 堆区栈区是变化的 开始怎么设计

在编译好程序的时候 代码起始地址和最后的地址 整个代码区的起始和结束 填充到start 和end

堆区栈区没有就设置成0

可执行程序每一个变量和每一个函数都有地址 是编译器给我的 每一个变量和函数都有对应的地址 同样被加载到了对应的物理内存

到现在我们就可以更深入的理解挂起这个概念了。

磁盘加载到物理内存中也是要占空间的。那么如果将暂时不需要被调度的进程加载到了 物理内存,实际上就是对资源的一种浪费。因此加载的时候,我们是一部分一部分的进程加载的。同理,当我们暂时不需要调度这个进程的时候,也是一部分一部分地从内存中将对应的代码和数据取下来的。尽管如此,但是该进程依然被task_struct所维护着,这种在内存中没有对应的代码和数据,但是有自己的task_struct和页表关系的一种状态,就叫做挂起状态。

程序在宏观上如何加载?磁盘上有程序的代码和数据 执行该程序 ./a.exe 立马会变成进程

进程是什么:磁盘上的代码和数据+pcb结构体(内核数据结构 )

内核数据结构:在linux内核中,描述进程的mm_struct和进程地址空间和页表。

加载的本质就是创建进程,但是并不是必须把所有的程序的代码和数据加载到内存中,并且创建内核数据结构才能建立映射关系的。

在最极端的情况下,甚至只有内核结构被创建出来了 。代码和数据都没有。比如新建状态。

理论上,我们是可以实现对程序的分批加载的。

既然可以分批加载(换入) 那么其实也可以分批换出的。比如这个进程短时间不会再被执行了,那么他所对应的代码和数据就相当于占了位置也没有创造价值,就可以被换出了-》挂起。

全部换出了代码和数据,其实和新建的状态没啥区别了。

需要注意的是,页表映射的时候,可不仅仅映射的是内存,磁盘中的位置,也可以被映射。

四 虚拟地址空间这样设计有什么好处

1 安全

因为地址空间和页表是OS创建并且维护的,那么其中也就意味着,凡是想使用地址空间或者页表进行映射,也一定要在OS的监管之下来进行访问。因此也便保护了物理内存中的所有的合法数据包括各个进程,以及内核的有效相关数据。

页表是一种简单的数据结构 map或者哈希。

系统中存在大量的页表,怎么管理?先描述再组织。先写出对应的数据结构

凡是非法的访问或者映射,操作系统都会识别到,并且终止进程,比如代码区只能被读取,不能被写入。

也从一个侧面说明页表也维护了读写权限。

物理内存是可以被读和写的,不可被写入不是硬件层面不可被写入,而是通过软件的方式不让你写入。

识别到了异常,进程就崩溃, 退出了

谁让进程退出了?

OS是进程的管理者,进程退出时os杀掉了对应的进程。

因此在语言上出现了问题,是系统层面上把进程干掉了

OS如何识别到的呢?怎么终止?进程状态异常就被OS识别到了,会发送相应的信号终止。这一点之后会讲解。

为什么要有地址空间?有地址空间和页表的存在 可以对用户非法访问进行有效拦截

本质:有效地保护了物理内存。

2 可维护性高

因为有地址空间的存在,也因为有页表的映射的存在。我们的物理内存中可以对未来的数据进行任意位置的加载。只要最终能映射找到就行了。

内存的分配就可以和进程的管理做到没关系了。

物理内存的分配-》内存管理

PCB等管理-》进程管理

二者完成了解耦合

本质:减少模块和模块之间的关联性

耦合度越低,管理成本越低,可维护性越高

3高效

如果我申请了物理空间,但是如果不立马使用是对空间的浪费呢。

本质上,因为有地址空间的存在,所以上层申请空间其实是在地址空间上申请的物理内存,甚至可以一个字节都不给你分配。当你真正进行对物理地址空间访问的时候,才执行内存的相关管理算法来申请内存,并且构建页表映射关系 ,以至于让你进行内存的访问。

是由操作系统自动完成的,用户和进程完全是0感知的

技术:缺页中断

这种延迟分配和用时分配,对内存有效使用是几乎100%

内存使用率高了,那么整机的效率也高了。

4 进程独立

磁盘上的可执行程序可以加载到任意的物理内存,由于在物理内存理论上可以任意位置加载,那么物理内存中的几乎所有的数据和代码,在物理内存中是乱序的。

CPU直接访问物理内存,会增加很多的成本,但是因为页表的存在,可以将地址空间上的虚拟地址和物理地址进行映射,那么在进程视角,所有的内存分布,实质是有序的。

那么地址空间+页表的存在,可以将内存分布有序化。

结合第二条,进程要访问的物理内存中的数据和代码,可能目前并没有在物理内存中。

同样的,也可以让不同的进程,映射到不同的物理内存,很容易做到进程独立性的实现。进程的独立性可以通过地址空间+页表的方式实现。

因为有地址空间的存在,每一个进程都认为自己有4GB的物理内存空间,并且各个区域是有序的 进而可以通过页表映射到不同的区域,来实现进程的独立性。

每一个进程不知道也不需要其他进程的存在。

不知道其他进程的存在,并且该进程运行不受其他进程干扰,实现了进成独立。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值