【Linux庖丁解牛】—程序地址空间【进程地址空间 | 虚拟地址空间】
1. 再谈空间分布图
我们之前在学C/C++的时候必然学过上面的空间分布图。
可是我们对他并不理解!这里先对其进行各区域分布验证:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
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);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("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)printf("stack addr: %p\n", &heap_mem3); //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修饰的test变量在函数内部,其作用域在函数内部,但生命周期是全局的。原因就在于test和我们定义在全局定义的global地址相近。
其他的堆区啊,栈区啊都没有什么问题。但这里我想问大家一个问题:程序地址空间是内存吗?
先说答案:不是内存!
其实程序地址空间更准确的说应该是:进程地址空间 | 虚拟地址空间。他是一个系统级的概念,而不是语言层的概念。我们以前叫他程序地址空间是为了我们在语言层学习的时候更加容易接受,但我们其实还未真正的理解何为进程地址空间!
你说不是内存就不是内存吗?下面我就来做一个实验证明进程地址空间不是真正的物理空间【内存】!
下面这段代码,我创建了一个子进程,我们前面已经知道了,虽然父子进程共享同一段代码和数据,但是当其中任何一方修改数据时,就会反生写时拷贝,为其中一方提供额外的一份数据!
所以我们运行下面的代码,父子进程中的gval是两份不同的数据,如果进程地址空间就是实际的物理空间,那么这两份数据的地址必然不会相同!
运行结果如下:
我们居然惊奇的发现两份数据的地址竟然一模一样!那么这就说明进程地址空间必然不是实际的物理空间了【内存】,而实际上,我们之前C/C++指针用到的地址都是虚拟地址。那程序地址空间到底是什么,而这两份数据的地址又为什么一样呢?
2. 引入新概念,简单解释以上问题
2.1 一个进程一个虚拟地址空间
虚拟地址空间在32位机器下有2^32个地址,也就是又4GB空间。其中有1GB空间是给内核使用的,另外3GB地址空间用户拿到地址就可以直接访问。
而64位机器下则有2^64个地址,16GB空间。
2.2一个进程一套页表
页表是用来做虚拟地址和物理地址映射的。
在上面的问题当中,gval在虚拟地址空间一定有个地址【假设该地址为0x111111】,而在内存当中也势必会有一个地址【我们假设为0x112233】。而每一个进程都会有一套对应的页表,页表当中一个虚拟地址映射一个内存地址,通过这样的方式,系统就可以通过虚拟地址找到内存当中真正存储数据的物理空间。
有了上面的理解,接下来我们就来理解一下父子进程关系中为什么在任何一方数据不发生变化时,父子进程会共享同一块数据?
因为每个进程都有一张页表,所以父进程在创建子进程时,子进程会浅拷贝一份父进程的页表信息,此时,这两份页表信息完全相同【即它们的虚拟地址和物理地址的映射关系完全相同】,所以它们的数据是共享的。
但是,当任何一方的数据发生修改时,操作系统先说你先等一下,此时,操作系统会在物理地址当中重新开辟一份空间【该空间存储修改后的数据】,假设是子进程修改数据,那么子进程的页表的物理地址就会指向新开辟的空间,而虚拟地址就不会发生变化,这就是写时拷贝。至此,我们也就明白了,为什么我们会看到不同的数据居然会指向同一个地址,因为我们看到的是虚拟地址,而它们的物理地址实际上是不一样的。【那我们能不能看到它们的物理地址呢,答案是不能!】
明白了以上的原理后,我们之前有一个无法解释的问题【为什么一个变量,即==0,又>0?导致if else同时成立???】,现在就可以解释了。
因为父子进程虽然执行同一份代码,但是它们在看自己的数据时,是通过自己的页表映射找到自己的数据【即使它们的变量名相同,但它们的实际存储的物理地址却是不相同的】。
3. 什么是进程虚拟地址空间
每个进程都有一个虚拟地址空间,为了管理这些虚拟地址空间,在操作系统中势必会有某种数据结构将这些虚拟地址空间管理起来。
这里就直接说了,在Linux中,某个进程的虚拟地址空间用mm_struct的结构体描述,在进程的task_struct中有一个指向mm_struct结构体的指针。
struct task_struct
{/*...*/struct mm_struct *mm; //对于普通的⽤⼾进程来说该字段指向他的
虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。 struct mm_struct *active_mm; // 该字段是内核线程使⽤的。当该
进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所
有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。 /*...*/
}
所以进程虚拟地址空间就是内核数据结构!
4. 如何描述一个进程虚拟地址空间
我们可以看到,在进程虚拟地址空间中划分了许多区域。那结构体mm_struct是如何做到这一点的呢?答案就是:我们只需要确认区域的开始和结束即可!
所以在结构体mm_struct中势必有许多整形类型的变量来记录这些值!事实也是如此!
struct mm_struct
{/*...*/struct vm_area_struct *mmap; /* 指向虚拟区间(VMA)链表 */ struct rb_root mm_rb; /* red_black树 */ unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的⼤⼩*/ /*...*/// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。 unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack;unsigned long arg_start, arg_end, env_start, env_end;/*...*/
}
而当系统要调整区域时,仅需对这些整形变量进行加减操作即可!
但是,当进程被调度时,task_struct被创建,其中的mm_struct也会先开辟空间,但是各区域中的初始化值是从哪里来的呢?
直说了:就是从进程加载到内存时,进行初始化!
我们知道进程虚拟空间中大多数区域都是连续的【数据段|代码段|栈区】,但是按照我们以前的理解,堆区开辟的空间并不是连续的,甚至有时候它们的地址相差很大。可是这和我们现在理解的进程虚拟空间矛盾啊!
其实并不矛盾,在mm_struct中维护了一个链表mmap,改链表的节点为vm_area_struct的结构体,每个结构体记录了每个区域的开始结束位置,因为堆区有多份【更细的划分】,所以有多个vm_area_struct的结构体维护了多个堆区!而在mm_struct中维护的是整体堆区的范围!
下图只画了一个堆区,但实际上堆区并不止一份!
5. 为什么要有进程虚拟地址空间和页表
1. 有了上面的理解,我们就知道进程的代码和数据在物理地址空间上是可以随意分布的,也就是无序的,但是通过页表的映射关系,我们就可以找到每个进程的代码和数据并且在虚拟地址空间中,这些地址是连续且有序的。
所以,进程虚拟地址空间和页表可以让地址从无序变有序!
2. 地址转换的过程中,也可以对你的地址和操作进行合法性判定,进而保护物理内存!
a.什么是野指针?【重新理解野指针】
指针就是地址,当指针【地址】在页表中无法找到时该指针也就成为了野指针,此时,系统可能就会杀掉有野指针的进程导致进程崩溃!
b. char *str=“helloworld"; *str='K';
但我们编译运行以上代码时,程序就会崩溃。以我们之前的理解,str在字符常量区,所以str不可修改。而现在我们终于可以从本质上理解它了:其实页表之中还有一个权限的标记位,在页表的地址映射关系中,有些地址是只读的,比如字符常量区,所以系统在对改地址进行操作时判断出我们要对该地址进行写的操作,与其权限矛盾,那么系统就会拦截我们写的行为从而导致进程崩溃!
3. 让进程和内存管理,进行一定程度的解耦合!
有了进程虚拟地址空间和页表,进程的调度和切换与数据代码加载到内存当中没有强相关!