【Linux】程序地址空间
程序地址空间其实是进程地址空间,也叫做虚拟地址空间,他是一个系统层面的概念,不是语言层的。
1.程序地址空间分布
先看一段代码,在32位机器下运行,Ubuntu环境下gcc编译时加上-m32选项,如果出现如下报错证明你还没安装32位编译库
安装命令:sudo apt-get install gcc-multilib g++-multilib libc6-dev-i386
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void test2()
{int y1;int y2;int y3;printf("stack addr:test2->y1:%p\n", &y1);printf("stack addr:test2->y2:%p\n", &y2);printf("stack addr:test2->y3:%p\n", &y3);
}void test1()
{int x1;int x2;int x3;printf("stack addr:test1->x1:%p\n", &x1);printf("stack addr:test1->x2:%p\n", &x2);printf("stack addr:test1->x3:%p\n", &x3);test2();
}int g_unval; //未初始化
int g_val = 100; //初始化
int main(int argc, char *argv[], char *env[])
{const char *str = "helloworld";printf("code addr: %p\n", main);printf("init global addr: %p\n", &g_val);printf("uninit global addr: %p\n", &g_unval);static int test = 10;char *heap_mem = (char*)malloc(10);char *heap_mem1 = (char*)malloc(10);char *heap_mem2 = (char*)malloc(10);char *heap_mem3 = (char*)malloc(10); test1();printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)printf("read only string addr: %p\n", str);for(int i = 0 ;i < argc; i++){printf("argv[%d]: %p\n", i, argv[i]);}for(int i = 0; env[i]; i++){printf("env[%d]: %p\n", i, env[i]);}return 0;
}
初始化和未初始化的全局变量的地址和被static修饰的变量的地址,两者特别接近,是挨着的,所以可以证明static变量就是全局变量,只不过这里的static变量只能在这个函数被访问罢了,但是static的生命周期是全局的,因为此时static已经被编到初始化数据区去了。
只读变量(常量)的地址和代码区的地址比较接近,因为编译器编码的时候就已经把常量编译到代码里去了,只不过有个专门的区域叫常量区。
栈区和堆区:堆区地址在增加,证明堆在向上增长;test1和test2两个函数要建立栈帧,test1先建立栈帧,所以test1里面的3个变量地址都高于test2里面的那三个变量地址,栈区地址在减小,证明栈是向下增长
(注意:在同一个函数也就是同一个栈帧里定义的变量,他们的地址不一定减小,同一个栈帧内部变量地址的增长顺序决于编译器)
并且栈区地址高于堆区,两个空间的地址相差特别远,两者之间有一大段的镂空空间,这个空间暂时不讨论。
命令行参数和环境变量的地址是这里最高的。
2.虚拟地址和虚拟地址空间
2.1 虚拟地址
演示代码如下:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int gval = 100;
int main()
{pid_t id = fork();if(id == 0){//childwhile(1){ printf("子进程 -> gval:%d, &gval:%p, pid:%d, ppid:%d\n", gval, &gval, getpid(), getppid());sleep(1);gval++;}}else{while(1){printf("父进程 -> gval:%d, &gval:%p, pid:%d, ppid:%d\n", gval, &gval, getpid(), getppid());sleep(1);}}return 0;
}
这个程序的结果最值得关注的地方就是,父进程和子进程里的gval地址相同。
这个代码结果证明:程序地址空间不是物理内存!所以这里的地址也就不是内存地址。
我们语言上用到的地址都不是物理地址,而是虚拟地址。
这个空间的宽度为1字节,在32位机器下,有个地址,在64位机器下有
个地址,在32位机器下,这个虚拟地址空间的容量为4GB,任何一个变量在内存中存在一个物理地址,假如这个变量gval的物理地址为0x123456,同时在地址空间上还存在一个虚拟地址,假设gval的起始虚拟地址为0x111111
在操作系统内,为每一个进程构建一张页表,这个页表一边填充的是变量的起始虚拟地址,一边填充的是物理地址
当进程想访问虚拟地址时,操作系统会查表页,通过虚拟地址找到物理地址,进而访问相应的变量,所以页表可以用来做虚拟地址和物理地址映射的。
这里虚拟地址空间宽度为1字节,拿int类型为例,int类型有4个字节,在取虚拟地址的时候取的是int的4个字节中值最小的那个字节,然后往后取地址,不同类型不同大小就会往后取不同个地址。
2.2 写时拷贝
一个进程,一个虚拟地址空间 ,所以父进程有子进程也要有,子进程的task_struct是拷贝的父进程的,它的虚拟地址空间也是拷贝的父进程的,并且页表也是拷贝的的父进程的,但是这里的拷贝是浅拷贝。
所以这就能解释为什么父子进程的全局变量地址是一样的,因为虚拟地址是一样的。
(父子进程的页表都指向同一个物理内存,全局变量默认被父子共享,变量是这样,代码什么的都是一个道理)
但是我们说过,进程具有独立性,所以当这个子进程要修改gval的时候,操作系统介入,在内存中重新开辟一个空间,就会有一个新的物理地址,把旧数据拷贝一下,并且更改子进程页表中的映射关系,这就是写时拷贝。
子进程要修改gval时,修改的就是新的物理空间的值。
此时父进程和子进程中gval的虚拟地址仍然相同,但是映射的物理地址就不同了,所以这就是为什么前面程序打印的gval值不同,但是地址却是相同的。
2.3 虚拟内存管理
虚拟地址空间就好像是老板给员工画的饼,这里操作系统就是老板,进程就是员工,饼画多了老板都不记得给谁画了什么饼,所以用结构体把员工对应的饼“管理”起来,这个虚拟地址空间它的本质是一个在操作系统内部给进程创建的结构体对象,叫mm_struct。
在虚拟地址空间里有很多区域,这些区域要进行划分,就是在mm_struct结构体里定义变量,然后定义这些区域开始和结束的虚拟地址,就能对虚拟地址空间进行划分。
对虚拟地址空间的划分进行调整就只需要对结构体内的变量加或减就行。
- 在虚拟地址空间中申请指定空间的大小,申请空间本质就是给mm_struct结构体里有关空间划分的变量赋初始值
- 加载程序,在内存中申请物理空间
- 物理空间和虚拟空间填充到页表,页表进行映射,就能让物理地址转化为虚拟地址,虚拟地址提供给上层用户
- 每⼀个进程都会有⾃⼰独⽴的 mm_struct , 这样每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。
每个区域都要有vm_area_struct 来真正表示各个区间的开始和结束,mm_struct表示整个虚拟地址空间的整体的描述。
堆区可能有多个,图上只画了一个,如果有多个堆区就有多个vm_area_struct
3.虚拟地址空间的意义
- “无序”变“有序”:虚拟地址通过页表的映射关系能找到物理地址,程序加载到物理内存时的物理地址是“无序”的,虚拟地址空间里有区域的划分,程序的虚拟地址看起来是“有序”的,让用户层面看起来这个程序的地址是有序的
- 对物理内存做保护:页表内还会存权限,如r、w、x权限,在地址转换中,对用户的地址和操作进行合法性判定,进而保护物理内存
如野指针问题,就是在页表中查不到对应的映射关系,有虚拟地址空间的话就能直接对请求进行驳回进而保护内存。
再比如下面的代码,C语言中说过str指向一个字符串常量时,这个str就不能被修改了,其实就是因为操作系统在查页表的时候,页表中对str的权限就是只读,页表权限拦截,转化失败,代码就不能正常运行。
char* str = "hello";
*str = 'H';
- 让 进程管理 和 内存管理 进行一定程度的解耦合
补充:
- 创建进程时,先有task_struct,mm_struct等,然后再加载代码和数据到内存
- 所以可以先不加载代码和数据,只有task_struct,mm_struct,页表等
- 这也就是进程状态中的挂起状态的表现
本次分享就到这里,我们下篇见~