Linux进程概念六之程序地址空间
Linux进程概念(六)之程序地址空间
程序地址空间
研究背景
kernel 2.6.32
32位平台
程序地址空间回顾
空间布局图 – 地址空间
验证:
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 }
栈区向 地址减小 方向增长,堆区向 地址增大 方向增长。(堆栈相对而生)
验证:
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 }
验证一个语法问题:
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 }
验证命令行参数和环境变量在栈区之上:
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 }
子进程为什么可以继承父进程的环境变量?
命令行参数尤其是环境变量在栈区之上,
当创建一个子进程时,父进程已经把对应的环境变量信息加载了,
父进程的环境变量也是父进程地址空间上的数据,
父进程会有页表帮我们从虚拟到物理进行映射,
所以当我们创建子进程时,会拷贝父进程的页表,
会将环境变量相关的参数也建立好映射。
所以,不用传参数也就可以知道环境变量,
因为子进程可以通过页表找到对应的环境变量。
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 }
输出结果:
我们发现,父子进程,输出地址是一致的,但是变量内容不一样!
结论:
变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
但地址值是一样的,说明,该地址绝对不是物理地址!
在Linux地址下,这种地址叫做 虚拟地址
我们在用C/C++语言所看到的地址,全部都是虚拟地址!
物理地址,用户一概看不到,由OS统一管理
OS必须负责将 虚拟地址 转化成 物理地址 。
如果变量的地址是物理地址,就不可能存在上面的现象!!!!!!!
所以绝对不是物理地址!!!!线性地址 && 虚拟地址!!!
所以我们平时C/C++用的指针,指针里面的地址,全部都不是物理地址!!!
进程地址空间
分页&虚拟地址空间
页表左侧是虚拟地址,页表右侧是物理地址。
(子进程修改数据一般只影响右侧的物理地址)
虚拟地址一样,但是物理地址不一样,
本质就是数据映射到了不同的物理地址。
子进程要去修改全局变量的值,先经过写时拷贝(由操作系统完成),
重新开辟空间,但在这个过程中,左侧的虚拟地址是0感知的,不会影响虚拟地址。
说明:
上面的图就足矣说名问题,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了
不同的物理地址!
历史核心问题
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)
每个字节都有对应的地址。这个空间可以被支配和使用。
所谓进程地址空间,并不是物理地址,是虚拟和线性地址。
如何理解地址空间上的区域划分?
调整区域划分就是将start和end的整数值变大或者变小。
小胖可以访问他自己范围内的空间区域.
在范围内,连续的空间中,每一个最小单元都可以有地址,这个地址可以直接被小胖使用。
所谓的进程地址空间,本质是描述一个进程可视范围的大小
地址空间内一定要存在各种区域划分,对线性地址进行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保存当前进程的页表地址。
定义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 }
程序不出意外的挂了。
代码和字符常量区都是只读的,从磁盘拷贝到内存里的时候,
也就是在物理内存里没有只读只写这些概念,更没有权限控制这个概念。
物理内存想读就读,想写就写。
两个区域只读的根本原因:
页表的虚拟映射的标志位全都是只读。
进程是可以被挂起的!你怎么知道你的进程的代码和数据在不在内存呢???
共识:现代操作系统,几乎不做任何浪费空间和浪费时间的事情。
操作系统可以对大文件实现分批加载。
操作系统对可执行程序的加载: 惰性加载 。
(惰性加载(Lazy Loading)是一种设计模式,旨在优化性能和资源使用。
其核心思想是延迟资源的加载,直到实际需要时才进行加载,从而减少初始加载时间和资源消耗。)
申请内存、释放内存、填充页表等当前进程不知道。
进程管理和内存管理实现了软件层面上的解耦。
只要我们切换了进程的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删除注释。