【Linux】程序进程地址空间
程序地址空间
在Linux下,这种地址叫做 虚拟地址, 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理
问:
C/C++程序地址空间是内存吗?
-> 根本就不是内存! 是进程虚拟地址空间
堆栈相对而生,栈向下生长(在栈上的变量先定义的地址更大),堆向上生长
代码区: main函数就在代码区,函数名就是函数的地址
字符常量区:const char* str = "hello world"
str指向的字符串的起始地址在字符常量区,但是str本身是在栈区的,所以str才是字符常量区内的地址, &str是栈区内的地址
#include<stdio.h>
#include<stdlib.h>int g_unval;
int g_val = 100;int main(int argc,char* argv[],char* env[])
{const char* str = "hello world";printf("code addr:%p\n",main);//main函数就在代码区printf("string rdonly addr:%p\n",str);//字符常量区printf("init addr:%p\n",&g_val);//已初始化全局数据区printf("uninit addr:%p\n",&g_unval);//未初始化全局数据//堆区char* heap1 = (char*)malloc(10);char* heap2 = (char*)malloc(10); char* heap3 = (char*)malloc(10); char* heap4 = (char*)malloc(10); printf("heap1 addr:%p\n",heap1); printf("heap2 addr:%p\n",heap2); printf("heap3 addr:%p\n",heap3); printf("heap4 addr:%p\n",heap4); //栈区int a = 10; int b = 20; printf("stack addr:%p\n",&a); printf("stack addr:%p\n",&b); //命令行参数int i = 0for( i = 0; argv[i]; i++) { printf("argv[%d]:%p\n", i, argv[i]); }//环境变量for(i = 0; env[i]; i++){printf("env[%d]:%p\n", i, env[i]);}return 0;
}
栈区和堆区的地址值相差很大,说明中间有漏空 堆区和栈区相对而生,栈区:先使用高地址,再使用低地址
栈区是栈区,堆区是堆区 我们平常说的堆栈,实际是栈区
一个奇怪的现象:
我们发现,当数据发生修改的时候,对于同一个变量在父子进程当中,地址是相同的,但是值却是不同, 是什么原因呢?
前面我们已经知道:fork创建子进程时,父子默认情况共享数据,修改数据时,为了维护进程独立性,会发生写时拷贝,所以一个值不变,一个值发生改变, 这个可以理解.但是地址为什么会不变呢?
如果我们是在同一个物理地址处获取的值,那必定值是相同的,而现在在同一个地址处获取到的值却不同,这只能说明我们打印出来的地址绝对不是物理地址
实际上,我们在语言层面上打印出来的地址都不是物理地址,而是虚拟地址,而物理地址用户一概是看不到的,是由操作系统统一进行管理的,所以就算父子进程当中打印出来的全局变量的地址(虚拟地址)相同,但是两个进程当中全局变量的值却是不同的
- 虚拟地址和物理地址之间的转化由操作系统完成
- OS必须负责将 虚拟地址 转化成 物理地址
进程地址空间
我们之前将那张布局图称为程序地址空间实际上是不准确的,那张布局图实际上应该叫做进程地址空间, 进程地址空间本质上是内存中的一种内核数据结构,
-
每个进程都有一个地址空间,操作系统为每一个进程画了一个大饼,它们都认为自己在独占物理内存
-
系统中存在大量进程,需要管理地址空间,那么就需要先描述、再组织
-
进程地址空间本质上在内核中是一个数据类型 ,可以定义具体的进程地址空间变量,在Linux当中进程地址空间具体由结构体mm_struct实现
struct mm_struct {//进程地址空间 };
那我们是怎么使用struct结构体进行区域划分的?各个区域又是如何与物理内存建立关联的, 我们将实体物理内存抽象出一把尺子,上面的刻度相当于虚拟地址(地址空间进行区域划分时,对应的线性位置虚拟地址)
struct mm_struct
{unsigned int code_start; unsigned int code_start; unsigned int init_data_start;unsigned int init_data_end;unsigned int uninit_data_start;unsigned int uninit_data_end;//....unsigned int stack_start;unsigned int stack_end;
};
堆向上增长以及栈向下增长实际就是改变mm_struct当中堆和栈的边界start和end的值
每个进程都认为自己拥有4GB,都认为空间的划分是按照4GB来划分的.虽然这里只有start和end,但这是一个区间概念,每个进程都认为mm_struct
代表的是从0x00000000到0xFFFFFFFF整个内存
那么如何将虚拟地址和物理地址建立映射关系呢?通过查页表(页表+MMU硬件设备)
页表的作用:将虚拟地址转化为物理地址, 在上述图中,页表中表格的左部分是虚拟地址,右部分是物理地址
为什么要这样做:(为什么要有地址空间)
-
1. 通过添加一层软件层,完成有效的对进程操作内存的风险管理(权限管理),本质是为了保护物理内存各个进程的数据安全
-
类似于过年的压岁钱妈妈帮你收着,等你要用的时候,再来问我要,防止你乱花钱.对应到这里,中间层是有利于操作系统管理的,不是不给你,而是管控你的做法是否合适;如果没有中间层(OS),能直接访问物理地址,可能发生非法越界访问
-
const char* str = "hello" str[0] = 'a';//err,不可以修改
类似于:我们所知道的:常量字符串的内容不可以修改,本质上是因为,这里str指针指向的就是虚拟地址,解引用进行写入时,访问虚拟地址,要进行虚拟地址和物理地址的转化,然而OS只给你读®的权限,我们进行写入,进程就会崩溃掉
-
-
2.将内存申请和内存使用在时间上解耦.通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存操作和OS进行内存管理进行软件层面上的分离
- 比如我们在堆上申请一大块空间,但是我们可能暂时不会全部使用甚至暂时不用(有了空间,从来没有读写),在OS角度,这部分空间本来是可以给别人立马用的,却被闲置着.于是,OS在当你真的要使用时,再把空间开辟出来,建立映射关系,这叫做基于缺页中断进行物理内存申请.
- 再比如假如物理内存已经100%占满了,而你还要,那么OS执行内存管理算法,把某些进程闲置的空间置换到磁盘上,这样进程照样可以申请到内存.而这些都是我们用户在应用层根本感受不到,换句话说OS做的内存操作是透明的
-
3.站在CPU和应用层的角度,进程统一使用4GB的空间,且每个空间区域的相对位置是比较确定的
- 比如CPU寻找不同进程代码的第一行,如果直接访问物理内存,CPU会比较凌乱.有了虚拟地址空间,CPU能以统一的视角看待物理内存,不同的进程再通过的各自的页表,映射到不同的物理内存.同时,程序的代码和数据可以加载到内存的任意位置,大大减少内存管理的负担
所以我们回到最初的问题:相同的地址会打印出不同的值的原理:
子进程的创建是以父进程为模板的: 每个进程被创建时,其对应的进程控制块(task_struct)和进程地址空间(mm_struct)也会随之被创建,而操作系统可以通过进程的task_struct找到其mm_struct,因为task_struct当中有一个结构体指针存储的mm_struct的地址
当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间
只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改, 为了维护进程的独立性,子进程在更改时发生写时拷贝,即为子进程重新开辟一段物理空间,把值拷贝过来,再重新建立虚拟地址到物理地址的映射关系
所以打印的是一样的虚拟地址,而不同的值,是因为在物理内存上本来就是不同的变量.之前说的,父子进程的代码一般是共享的,也就是通过映射到同一段物理空间实现的. 之前说的,所有的只读数据一般可以只有一份,本质不是在语言上,而是在系统上,这样操作系统的维护成本是最低的,不同的虚拟地址映射到相同的物理地址上
例如:C语言时候的:
//str1和str2都指向同一块空间,只读数据一般可以只有一份,操纵系统只维护一份相同的内容
const char* str1 = "hello world";
const char* str2 = "hello world";
printf("%p\n",str1);//00007ff72e9d9000
printf("%p\n",str2);//00007ff72e9d9000
进程和程序有什么区别? 进程要包括描述进程的进程控制块PCB(task_struct)、进程虚拟地址空间(mm_struct)、页表、代码和数据
- 写时拷贝的好处:
进程具有独立性.多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程
- 为什么不在创建子进程的时候就进行数据的拷贝
子进程不一定会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配(延时分配),这样可以高效的使用内存空间
所有的只读数据一般可以只有一份,本质不是在语言上,而是在系统上,这样操作系统的维护成本是最低的,不同的虚拟地址映射到相同的物理地址上
- 代码会不会进行写时拷贝
多半情况是不会的,但是代码可以进行写时拷贝
例如在进行进程替换的时候,则需要进行代码的写时拷贝
- 进程地址空间存在意义
1、有了进程地址空间后,就不会有任何系统级别的越界问题存在了.例如进程1不会错误的访问到进程2的物理地址空间,因为你对某一地址空间进行操作之前需要先通过页表映射到物理内存,而页表只会映射属于你的物理内存.总的来说,虚拟地址和页表的配合使用,本质功能就是包含内存
2、有了进程地址空间后,每个进程都认为看得到都是相同的空间范围,包括进程地址空间的构成和内部区域的划分顺序等都是相同的,这样一来我们在编写程序的时候就只需关注虚拟地址,而无需关注数据在物理内存当中实际的存储位置
3、有了进程地址空间后,每个进程都认为自己在独占内存,这样能更好的完成进程的独立性以及合理使用内存空间(当实际需要使用内存空间的时候再在内存进行开辟),并能将进程调度与内存管理进行解耦或分离