【Linux】进程地址空间

进程地址空间

在进行编写代码的时候,数据的地址都保存在进程地址空间中,下面使用一段代码进行验证一下:

#include<stdio.h>

int g_val1;
int g_val2 = 10;

int main()
{       
        // 代码区
        printf("code addr: %p\n", main);
        // 只读数据区
        const char* str = "hello linux";
        printf("read only string addr: %p\n", str);
        // 初始化数据区
        printf("init global value addr: %p\n", &g_val2);
        // 未初始化数据区
        printf("uninit global value addr: %p\n", &g_val1);
        // 堆区
        char *mem = (char*)malloc(100);
        printf("heap addr: %p\n", mem);
        // 栈区
        printf("stack addr: %p", &str);
        return 0;
}  

实际中不管是代码、只读数据、初始化数据、未初始化数据、堆区和栈区都是根据进程地址空间的所排布的。

【注意】

  • 一般来说,数据都是向上增长,只有栈是向下增长(地址逐渐减小),堆和栈是想向增长。
  • 未初始化数据区和初始化数据区保存是独立保存的,所以只有在进程结束的时候,将进程地址空间释放,才会释放全局数据
  • static修饰的局部变量,编译的时候已经被编译到全局数据区了
int g_val = 10;

int main()
{       
    pid_t id = fork();
    if(id == 0)
    {       
          // 子进程
          while(1)
          {       
                printf("I am child, pid : %d, ppid : %d, g_val : %d, &g_val : %p\n", getpid(), getppid(), g_val, &g_val);
                 sleep(1);
           }
     }
     else    
     {       
                
          // 父进程
           while(1)
           {       
                 printf("I am father, pid : %d, ppid : %d, g_val : %d, &g_val : %p\n", getpid(), getppid(), g_val, &g_val);
                 sleep(1);
            }
     }
        
     return 0;

}

通过对比父子进程之间的全局数据的地址,发现二者使用的是同一块地址。

修改子进程中的g_val之后

结论:如果变量的地址是物理地址,是绝对不可能存在上面的现象的,所以变量的地址绝对不是物理地址,我们一般将进程地址空间称为线性地址或者虚拟地址。

【注意】在编写C/C++代码时,使用的指针,保存的地址都不是物理地址,而是虚拟地址。

地址空间的概念

  • 每一个进程在创建出来的时候,操作系统都会给这个进程创建一个属于自己的进程地址空间。
  • 进程地址空间以32位比特为准,低地址是0000 0000,高地址是FFFF FFFF,平时在编码的时候,使用的地址是进程地址空间中的地址。
  • 每一个进程地址空间是操作系统为每一个进程创建的结构体对象,这个进程的PCB中保存着指向进程地址空间的指针。
  • 在系统层面,操作系统会为每一个进程地址空间和物理内存之间创建一个页表,这个页表的作用简单来说就是虚拟地址和物理地址的相互映射。
  • 在代码层面创建的一个变量,其地址是进程地址空间的地址,操作系统将进程地址空间的地址放在页表中,系统再在物理内存中开辟出一份真正的物理内存,这个开辟的物理内存存在着自己的物理内存,系统通过页表将进程地址和物理内存的地址进行相互映射。
  • 当操作系统需要查找某一个变量的数据时,操作系统会通过进程地址空间中保存的地址,与在页表中找到和该虚拟地址存在映射关系的物理地址,从而找到对应的数据。
  • 当父进程创建子进程的时候,系统创建子进程,一定会为这个子进程创建一个新的进程地址空间,这个进程地址空间是拷贝父进程的进程地址空间,所有父子进程拥有相同的代码和数据。
  • 为了保证每一个进程都具有独立性,子进程会拥有属于自己的PCB结构和新的进程地址空间,但是防止进程过度使用物理内存中的空间,子进程在创建出来的时候会指向父进程的页表,当需要访问某个数据的时候,会通过父进程的页表进行在物理内存中访问。而当子进程要修改某个数据的时候,子进程会在自己新的空页表中存放新的映射关系,操作系统会重新在物理内存中开辟一段空间——写时拷贝【所以,父子进程在拥有相同的全局变量的时候,没有修改的时候会保存相同的数据,而当子进程修改全局变量的数据的时候,父进程没有修改,而子进程修改成功】
  • 页表在进行映射关系的时候,当子进程需要修改物理内存中的数据的时候,会在物理内存中重新开辟空间,但是在这个过程中,左侧的虚拟地址是0感知的,修改物理地址并不会影响页表左侧的进程地址空间【所以,父子进程拥有不同的全局变量,但是全局变量拥有相同的地址空间,这个相同的地址空间也就是虚拟地址空间】

进程地址空间相关的细节


问题1:地址空间是什么?

【答】:在32位计算机中,有32位的地址和数据总线,每一根地址总线只有0和1两种选项,一共有32根,组合起来就是2的32次方种组合,也就是2的32次方*1字节——4GB的内容。

        所谓的地址空间就是地址总线排列组合形成地址范围[0,2^32],也就是4GB


问题2:如何理解地址空间上的区域划分?

【答】:地址空间上的区域划分本质就是结构体种保存着起始地址,结尾地址。当需要调整地址空间的时候,将起始地址和结尾地址进行更改即可。使用地址空间的时候,不仅仅要看到用结构体划分出来的地址空间的范围,而在范围内连续的空间内,每一个最小单位都可以有地址,这个地址可以直接被使用。

        总结:所谓的进程地址空间,本质是一个描述进程可视范围的大小。地址空间内一定要存在各种区域划分,在线性地址结构体中的begin和end即可。

进程地址空间在代码层面本质是内核的一个数据结构的对象,类似于PCB一样,地址空间也是要被操作系统管理的——先描述再组织。

mm_struct结构是对整个用户空间的描述。每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰


问题3:进程和进程地址空间的关系?

【答】:每一份进程地址空间都有4GB的内存,而系统中存在大量的进程,显然不可能每一个进程都会在物理内存中直接占用4GB的内存。而进程地址空间类似于是给进程画的一个饼,让每一个进程都认为自己拥有4GB的内存,但是只有存储数据的时候,才会使用物理地址的空间。

问题4:为什么要有地址空间?

【答】:

  • 让所有进程以统一的视角看待内存。
  • 进程访问内存的时候,需要通过进程地址空间+页表对物理内存的映射来获取数据。由于进程地址空间的存在,当存在越界访问,进程退出等异常现象,进程地址空间和页表会对访问进行检查,可以有效拦截不安全的事件。增加进程虚拟地址空间可以让用户访问内存的时候,增加一个转换的过程,在这个过程中,可以对用户的寻址请求进行审查,所以一旦异常访问,直接进行拦截,该请求不会到达物理内存,从而达到保护物理内存。
  • 因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行解耦合。

页表初始

系统为了进程虚拟地址和物理地址之间的映射,会对进程地址空间和物理内存之间的页表进行维护。

  • 页表的起始地址保存在CPU中的cr3寄存器中,该寄存器保存的内容属于进程的硬件上下文
  • 页表条目中存在一个标志位,来表示映射的物理内容是只读,还是可以进行读写操作。当CPU需要访问某个数据的时候,会先到进程地址空间查找虚拟地址,再通过CPU的寄存器找到页表,通过页表之间的映射关系中的标志位是可读还是可写的,然后系统会通过检查来进行权限管理。【为什么字符常量区的数据是只读的?物理内存没有读和写的概念,当字符常量第一次保存在物理内存中的时候,页表通过虚拟地址和物理地址之间建立映射关系,将字符常量的标志位修改成只读】
  • 页表也存在一个标志位,来表示程序是否加载到内存中。

进程中存在挂起,挂起时操作系统会将内存中的数据和代码转移到磁盘中,当进程再次处于运行队列时,会将这些代码和数据放回内存中,但是Linux操作系统中没有挂起这个状态,我们是如何得知代码和数据是否在内存中?


【共识】现代操作系统中几乎不会做出任何浪费时间和空间的事情。


实际使用计算机的时候,我们的操作系统会对大文件实现分批加载, 在操作系统中加载代码和数据使用的是惰性加载的方式,也就是在页表中的虚拟地址中保存着数据,而物理地址中没有数据。当系统需要访问这块物理地址的时候,会通过页表中的一个标识位(0,1)来判断,如果数据没有加载到内存,系统会触发缺页中断。也就是说,如果操作系统发现缺页中断,操作系统会在磁盘中将对应的代码和数据拷贝到对应的内存中,将这部分的地址保存在页表中。子进程写时拷贝也属于缺页中断的一种

进程在被创建的时候,会先创建内核数据结构,对应的可执行程序并不会先加载到内存中。

由于页表的存在和地址空间的存在,可以让进程的管理,不需要关系右边进程的管理。页表的存在将进程管理和内存管理实现了软件层面的解耦。

再次总结认识进程


进程=内核数据结构(task_struct &&页表)+程序的代码和数据


进程在切换的时候,需要切换PCB、进程地址空间和页表,当切换PCB的时候,进程的地址空间的地址保存在PCB结构体中,页表的起始地址保存在cr3寄存器中,cr3寄存器中的数据属于进程的上下文,也会保存在PCB中。归根结底,进程切换只需要将寄存器中的上下文一切换,所有都会切换。


进程具有独立性


  1. 进程有属于自己的PCB、进程地址空间、页表等,所以在内核层面上是独立的。
  2. 也体现在物理内存中加载的代码和数据,虚拟地址可以完全一样,而物理地址完全不一样,使用页表将虚拟地址和物理相互映射,将代码进行解耦

直接使用物理内存的缺点

安全风险

        每一个进程都可以访问任意的内存空间,意味着每一个进程都能够去写系统相关的内存区域,如果这个进程是一个木马病毒,就可能随意的修改内存空间,从而直接导致设备直接瘫痪。

地址不明确

        代码编译完成后的程序是存放在硬盘上的,当运行的时候,需要将程序搬到内存中去运行,如果直接使用物理地址,会导致无法确定内存现在使用到哪里,也就是说拷贝的实际内存地址每一次运行都是不确定的。

效率低下

        如果直接使用物理内存,一个进程就是作为一个整体(内存块)操作的,如果出现物理内存不够用的时候,会将进程中的代码和数据存放在磁盘的交换分区中,以腾出内存,但是如果是物理内存,就需要将整个进程一起拷贝走,这样会导致在内存和磁盘之间拷贝时间太长,效率低下。

进程地址空间和页表的优点

  1.  地址空间和页表是操作系统创建并维护的,意味着需要使用地址空间和页表进行映射也需要在操作系统的监管下进行访问。这也会顺便保护物理内存中的所有的合法数据,包括每个进程以及内核的相关有效数据。
  2. 因为有地址空间的存在和页表的映射的存在,物理内存中可以对未来的数据进行任意位置的加载。物理内存的分配和进程的管理就可以分开进行,进程管理模块和内存管理模块形成解耦合
  3. 由于页表的映射存在,程序在物理内存中理论上就可以任意位置加载。可以将进程地址空间上的虚拟地址和物理地址进行映射,在进程视角所有的内存分布都可
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值