目录

Linux进程概念六之程序地址空间

Linux进程概念(六)之程序地址空间

程序地址空间

研究背景

​ kernel 2.6.32

​ 32位平台

程序地址空间回顾

空间布局图 – 地址空间

https://i-blog.csdnimg.cn/img_convert/198301b436e7c06ae70a7b3814a9ce8f.png

验证:

  1 #include<stdio.h>
  2 #include<stdlib.h>
  3 
  4 int g_val_1;
  5 int g_val_2=100;
  6 
  7 int main()
  8 {
  9     printf("code addr:%p\n",main);
 10     const char*str="hello world";
 11     printf("read only string addr:%p\n",str);
 12     printf("init global value addr:%p\n",&g_val_2);
 13     printf("uninit global value addr:%p\n",&g_val_1);
 14     char*mem=(char*)malloc(100);
 15     printf("heap addr:%p\n",mem);
 16     printf("stack addr:%p\n",&str);  
 17		
 18     return 0;                                                                 
 19 } 

https://i-blog.csdnimg.cn/img_convert/355de1376995a05d2939f89c86b1242f.png

栈区向 地址减小 方向增长,堆区向 地址增大 方向增长。(堆栈相对而生)

验证:

  1 #include<stdio.h>  
  2 #include<stdlib.h>  
  3   
  4 int g_val_1;  
  5 int g_val_2=100;  
  6   
  7 int main()  
  8 {  
  9     printf("code addr:%p\n",main);  
 10     const char*str="hello world";  
 11     printf("read only string addr:%p\n",str);  
 12     printf("init global value addr:%p\n",&g_val_2);  
 13     printf("uninit global value addr:%p\n",&g_val_1);  
 14     char*mem=(char*)malloc(100);  
 15     char*mem1=(char*)malloc(100);  
 16     char*mem2=(char*)malloc(100);  
 17     printf("heap addr:%p\n",mem);  
 18     printf("heap addr:%p\n",mem1);  
 19     printf("heap addr:%p\n",mem2);                                                   
 20     printf("stack addr:%p\n",&str);                               
 21     printf("stack addr:%p\n",&mem);                               
 22     int a;                         
 23     int b;                         
 24     int c;                         
 25     printf("stack addr:%p\n",&a);  
 26     printf("stack addr:%p\n",&b);  
 27     printf("stack addr:%p\n",&c);  
 28                
 29     return 0;  
 30 } 

https://i-blog.csdnimg.cn/img_convert/f9a48d6d9c5393c3fc37801bb4d5455c.png


验证一个语法问题:

static修饰的局部变量,编译的时候已经被编译到全局数据区了。

所以不会随着函数调用完毕而释放了,因为生命周期已经是全局变量的生命周期了。(但是作用域在函数里面)

  1 #include<stdio.h>
  2 #include<stdlib.h>
  3 
  4 int g_val_1;
  5 int g_val_2=100;
  6 
  7 int main()
  8 {
  9     printf("code addr:%p\n",main);
 10     const char*str="hello world";
 11     printf("read only string addr:%p\n",str);
 12     printf("init global value addr:%p\n",&g_val_2);
 13     printf("uninit global value addr:%p\n",&g_val_1);
 14     char*mem=(char*)malloc(100);
 15     char*mem1=(char*)malloc(100);
 16     char*mem2=(char*)malloc(100);
 17     printf("heap addr:%p\n",mem);
 18     printf("heap addr:%p\n",mem1);
 19     printf("heap addr:%p\n",mem2);
 20     printf("stack addr:%p\n",&str);
 21     printf("stack addr:%p\n",&mem);
 22     static int a=0;
 23     int b;
 24     int c;
 25     printf("a = addr:%p\n",&a);                                                       
 26     printf("stack addr:%p\n",&b);                                                     
 27     printf("stack addr:%p\n",&c);                                                     
 28                                                                                       
 29     return 0;                                                                         
 30 }  

https://i-blog.csdnimg.cn/img_convert/e02e42c6c6dca75c84a51fe5f16adbcd.png


验证命令行参数和环境变量在栈区之上:

    1 #include<stdio.h>
    2 #include<stdlib.h>
    3 #include <unistd.h>
    4 
    5 
    6 int g_val_1;
    7 int g_val_2=100;
    8 
    9 int main(int argc,char*argv[],char*env[])
   10 {
   11     printf("code addr:%p\n",main);
   12     const char*str="hello world";
   13     printf("read only string addr:%p\n",str);
   14     printf("init global value addr:%p\n",&g_val_2);
   15     printf("uninit global value addr:%p\n",&g_val_1);
   16     char*mem=(char*)malloc(100);             
   17     char*mem1=(char*)malloc(100);                  
   18     char*mem2=(char*)malloc(100);
   19     printf("heap addr:%p\n",mem);                    
   20     printf("heap addr:%p\n",mem1);
   21     printf("heap addr:%p\n",mem2);                                                 
   22     printf("stack addr:%p\n",&str);
   23     printf("stack addr:%p\n",&mem);
   24     static int a=0;               
   25     int b;          
   26     int c;      
   27     printf("a = addr:%p\n",&a);         
   28     printf("stack addr:%p\n",&b);
   29     printf("stack addr:%p\n",&c);
   30                                
   31     int i=0;                                             
   32     for(;argv[i];i++)                                    
   33     {                                                    
   34         printf("argv[%d]:%p\n",i,argv[i]);
   35     }                
   36     for(i=0;env[i];i++)
   37     {                                     
   38         printf("env[%d]:%p\n",i,env[i]);  
   39     }                  
   40
   41     return 0; 
   42 }   

https://i-blog.csdnimg.cn/img_convert/178b7d67740bfba4860f742ed2929966.png

子进程为什么可以继承父进程的环境变量?

命令行参数尤其是环境变量在栈区之上,

当创建一个子进程时,父进程已经把对应的环境变量信息加载了,

父进程的环境变量也是父进程地址空间上的数据,

父进程会有页表帮我们从虚拟到物理进行映射,

所以当我们创建子进程时,会拷贝父进程的页表,

会将环境变量相关的参数也建立好映射。

所以,不用传参数也就可以知道环境变量,

因为子进程可以通过页表找到对应的环境变量。


  1 #include<stdio.h>
  2 #include<stdlib.h>
  3 #include <unistd.h>
  4 
  5 int g_val=100;
  6 
  7 int main()
  8 {
  9     pid_t id=fork();
 10     if(id==0)
 11     {
 12         int cnt=5;
 13         while(1)
 14         {
 15             printf("I am child,pid: %d ,ppid: %d ,g_val: %d ,&g_val: %p\n",getpid(),getppid(),g_val,&g_val);
 16             sleep(1);
 17             if(cnt)cnt--;
 18             else
 19             {
 20                 g_val=200;
 21                 printf("child change g_val: 100->200\n");
 22                 cnt--;
 23             }                                                                         
 24         }
 25     }
 26     else
 27     {
 28         while(1)
 29         {
 30             printf("I am father,pid: %d ,ppid: %d ,g_val: %d ,&g_val: %p\n",getpid(),getppid(),g_val,&g_val);
 31             sleep(1);
 32         }
 33     }
 34 
 35 
 36     return 0;
 37 }

https://i-blog.csdnimg.cn/img_convert/7fa769b9da472e4cfaa40519969441e4.png

输出结果:

我们发现,父子进程,输出地址是一致的,但是变量内容不一样!

结论:

变量内容不一样,所以父子进程输出的变量绝对不是同一个变量

但地址值是一样的,说明,该地址绝对不是物理地址!

在Linux地址下,这种地址叫做 虚拟地址

我们在用C/C++语言所看到的地址,全部都是虚拟地址!

物理地址,用户一概看不到,由OS统一管理

OS必须负责将 虚拟地址 转化成 物理地址 。

如果变量的地址是物理地址,就不可能存在上面的现象!!!!!!!

所以绝对不是物理地址!!!!线性地址 && 虚拟地址!!!

所以我们平时C/C++用的指针,指针里面的地址,全部都不是物理地址!!!

进程地址空间

分页&虚拟地址空间

页表左侧是虚拟地址,页表右侧是物理地址。

(子进程修改数据一般只影响右侧的物理地址)

虚拟地址一样,但是物理地址不一样,

本质就是数据映射到了不同的物理地址。

子进程要去修改全局变量的值,先经过写时拷贝(由操作系统完成),

重新开辟空间,但在这个过程中,左侧的虚拟地址是0感知的,不会影响虚拟地址。

https://i-blog.csdnimg.cn/img_convert/f9726fc4fc431da1d88b301a7ffb5f52.png

说明:

上面的图就足矣说名问题,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了

不同的物理地址!

历史核心问题
pid_t id=fork();

if(id==0)
{
	//...
}
else if(id>0)
{
	//...
}

fork()进行返回的时候,就是向id值写入的过程。

fork往后一定有2个进程,2个进程都有一个id变量。

id变量的地址是虚拟地址对应的地址,写入发生写时拷贝。

子进程和父进程通过查自己的不同的页表,映射了不同的物理地址。


谈细节

1.地址空间究竟是什么?

以进程视角,对内存进行宏观划分。

什么叫做地址空间?

​ 要访问地址空间、页表等,父进程一定正在运行,

​ 所以CPU访问内存资源时,一定要知道内存的地址,所以我们要通过地址总线来访问内存。

​ 在32位计算机中,有32位的地址和数据总线,

​ 各个设备之间(CPU和内存、内存和外设等)要可以进行信息的交互。

​ CPU和内存之间的线->系统总线 | 内存和外设之间的线-> IO总线。

​ 共三类(地址总线、数据总线、控制总线)

地址空间

地址总线排列组合形成的地址范围 [0,2^32)

​ 每个字节都有对应的地址。这个空间可以被支配和使用。

​ 所谓进程地址空间,并不是物理地址,是虚拟和线性地址。

https://i-blog.csdnimg.cn/img_convert/a2f85ac2676155e8333b305d51415cad.png

如何理解地址空间上的区域划分?

​ 调整区域划分就是将start和end的整数值变大或者变小。

​ 小胖可以访问他自己范围内的空间区域.

在范围内,连续的空间中,每一个最小单元都可以有地址,这个地址可以直接被小胖使用。

https://i-blog.csdnimg.cn/img_convert/910b3c3b831c0bb2011a4986787405e2.png

所谓的进程地址空间,本质是描述一个进程可视范围的大小

地址空间内一定要存在各种区域划分,对线性地址进行start和end的划分。

地址空间本质是内核的一个数据结构对象,类似PCB,

地址空间也是要被操作系统管理的:先描述,再组织。

task_struct要指向mm_struct

mm_struct 默认划分的区域是4GB.

创建进程时如何初始化地址空间?

开始和结束和可执行程序的代码有关。

如:代码区程序变大,占据的地址空间的范围就不一样了。

可执行程序本身有支持构建地址空间的概念,

虚拟地址空间和编译器、可执行程序的格式、内容有关。

空间地址既和硬件有关,也和操作系统软件有关,也和编译器有关。

struct mm_struct
{
	long code_start,code_end;
	long readonly_start,readonly_end;
	long init_start,init_end;
	long uninit_start,uninit_end;
	long heap_start,heap_end;
	long stack_start,stack_end;
}

2.什么叫进程?why为什么要有地址空间?

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

1.让所有的进程以统一的视角看待内存结构。

(如果进程直接放在物理地址中,则进程PCB要记录自己的代码和数据的地址,

当挂起状态时,要把代码和数据换出,

换入时,可能代码和数据的物理地址改变了,PCB里面记录的地址也要改,

太麻烦了,所以用虚拟地址和页表映射物理空间)

可执行程序加载到内存里,是可以在任意物理地址空间的,

在页表上,会有对应的映射关系,在虚拟地址中,该在哪块就在哪块。

(无序变有序)代码在代码区找等。

2.增加进程虚拟地址空间可以让我们访问内存的时候,增加一个转换的过程,

在这个转换的过程中,可以对我们的寻址请求进行审查,所以一旦异常访问,直接拦截,

该请求不会到达物理内存,保护物理内存。(在现代操作系统中,现在所有的语言都看不到物理地址了)

3.因为有地址空间和页表的存在,将进程管理模块,和内存管理模块解耦合!!

(强耦合可能会导致内存管理影响进程调度)


3.页表

页表可以给我们提供很好的权限管理。

cr3保存当前进程的页表地址。

https://i-blog.csdnimg.cn/img_convert/8cd7fe2e1208bea8fa93c398cf0bcac9.png

定义char*的时候不能加const,否则就编译不过了。

因为const是在编译时期生效。

这个字符串是在字符常量区的,只能被读取不能被修改。

  1 #include<stdio.h>
  2 #include<stdlib.h>
  3 #include <unistd.h>
  4 
  5 int main()
  6 {
  7     char *s="hello world";
  8     *s='H';                                                                           
  9     return 0;                                                                
 10 }

程序不出意外的挂了。

https://i-blog.csdnimg.cn/img_convert/3aa1f7a2d181faea52dd41e8d002eaed.png

代码和字符常量区都是只读的,从磁盘拷贝到内存里的时候,

也就是在物理内存里没有只读只写这些概念,更没有权限控制这个概念。

物理内存想读就读,想写就写。

两个区域只读的根本原因:

页表的虚拟映射的标志位全都是只读。


进程是可以被挂起的!你怎么知道你的进程的代码和数据在不在内存呢???

共识:现代操作系统,几乎不做任何浪费空间和浪费时间的事情。

操作系统可以对大文件实现分批加载。

操作系统对可执行程序的加载: 惰性加载

(‌惰性加载(Lazy Loading)是一种设计模式,旨在优化性能和资源使用。

其核心思想是延迟资源的加载,直到实际需要时才进行加载,从而减少初始加载时间和资源消耗。)

https://i-blog.csdnimg.cn/img_convert/171669819cf6ecc2b7cf364563bcb99f.png

申请内存、释放内存、填充页表等当前进程不知道。

https://i-blog.csdnimg.cn/img_convert/5e65f9c5e945e64eb421f39501384a5f.png

进程管理和内存管理实现了软件层面上的解耦。

只要我们切换了进程的PCB,所匹配的地址空间一并切换(因为进程指向地址空间)

cr3寄存器数据进程的上下文,进程上下文一切换,页表就自动切换。


进程具有独立性!怎么做到的?

1.每个进程有PCB,有地址空间,有页表,所以在所有的内核数据结构上都是独立的。

2.曾今加载的代码和数据,只需要在页表层面上,虚拟地址可以完全一样,但物理地址可以完全不一样。

(只需要让页表映射到物理内存的不同区域,这样就解耦了)

加载到物理内存的什么位置,什么时候加载就不重要了。

因为我们有页表映射,所以可以在物理内存的任意地址存放,

左侧的虚拟地址可以把连续线性的地址空间呈现给对应的进程。

(把无序变有序)


当C/C++代码编译完之后,或者变成二进制文件之后,没有变量名这个概念了。

这些变量名最终都会被转化成地址,如:a 和 &a ,

最终转化为二进制是一样的概念,输出内容和地址以此区分。


在写C/C++代码的时候,我们是不知道操作系统做了这么多工作的,不影响代码的编译运行,

以上的一套,上层语言是不知道、也不关心的,是操作系统自动完成的。

任何语言编译好变成了进程,都会有地址空间。

补充知识

批量化注释和去注释

注释

Ctrl+v进入该模式 按h键或者l键左右移动,j键或者k键上下移动。

选完行之后,按shift+i

然后输入“//”

最后按Esc

去注释

Ctrl+v进入该模式 按h键或者l键左右移动,j键或者k键上下移动。

直接d删除注释。