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


    目录

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

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

    二 虚拟地址是如何划分的

     验证地址空间的划分

     三 什么是虚拟地址空间

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

     如何理解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就可以实现范围的变化了。

    可以理解成“三八线”。

    1. struct mm_struct
    2. {
    3. int code_strat;
    4. int code_end;
    5. int init_start;
    6. int init_end;
    7. int uninit_start;
    8. int uninit_end;
    9. ……
    10. };

     如何理解映射关系?

    实质上是一种被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的物理内存空间,并且各个区域是有序的 进而可以通过页表映射到不同的区域,来实现进程的独立性。

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

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

  • 相关阅读:
    一些计算机的冷知识,你都知道吗?
    继承的使用以及super关键字和重写以及Object类
    jenkins-自动化打包部署
    Chrome扩展的核心:manifest 文件(中)
    C# 窗体与子线程数据交互
    vue之搭建脚手架快速创建vue项目
    【spark】第二章——SparkCore之运行架构及核心编程
    洛谷 P2408 不同子串个数 题解
    元宇宙区块链协议Meta0宣布与Polygon建立合作关系
    数据结构算法合集——链表篇
  • 原文地址:https://blog.csdn.net/zhengyawen666/article/details/127097107