前言
内存区域划分:
在学习C/C++时我们都有接触过内存区域划分这个概念,也知道它表示的是程序加载到内存中不同的数据所分布的不同的区域,但是我们并不清楚它是什么东西,在哪里存储着,为什么要有它,它又是怎样实现的。今天我们就来解决这些疑惑。
一、是什么
进程地址空间是什么?
1.例子
我们先来看这样一个现象:
1 #include<stdio.h> 2 #include<unistd.h> 3 #include<stdlib.h> 4 int main() 5 { 6 pid_t pid = fork(); 7 int i = 10; 8 if(pid < 0)//函调用失败 9 { 10 perror("fork"); 11 exit(1); 12 } 13 else if(pid == 0)//子进程 14 { 15 int cnt = 0; 16 while(1) 17 { 18 printf("子进程,pid = %d, ppid = %d, i = %d, &i = %p\n",getpid(),getppid(),i,&i); 19 if(cnt == 5) 20 { 21 i = 5; 22 printf("子进程已更改全局变量……\n"); 23 } 24 cnt++; 25 sleep(1); 26 } 27 } 28 else 29 { 30 //父进程 31 while(1) 32 { 33 printf("父进程,pid = %d, ppid = %d, i = %d, &i = %p\n",getpid(),getppid(),i,&i); 34 sleep(2); 35 } 36 } 37 return 0; 38 }
我们发现在同一个地址空间的i变量,父进程和子进程访问时获得的值却不是相同的,这是什么原因呢?
首先,我们可以理解,父子进程的值不同是因为进程间具有独立性,但是这里的i的地址居然是相同的!!!我们可以先排除该地址是在物理磁盘上的地址的可能性,因为物理磁盘的同一个地址只能存唯一确定的一个值。
因此,这个地址只能是虚拟地址(线性地址)。在Linux中,特殊情况,我们将这种地址也成为逻辑地址。
2.感性的理解虚拟地址空间
从前有一个大富翁,他有10亿美元的资产。他有三个私生子,这三个私生子都不知道对方的存在,而大富翁也给他们画饼,说自己就他这一个儿子,自己走后,这10个亿都归他所有,因此他们都认为大富翁的10亿美元是被他们独占。当然大富翁也知道,这三个儿子虽然想着所有的钱都是他的,但是不可能一次性向大富翁要所有的钱,每次最多几十万、几百万的向大富翁要,所以大富翁画的饼从来没有被拆穿过。
我们的操作系统就相当于大富翁,而进程则相当于他的私生子,因为进程之间是相互独立的,因此每个进程都认为自己可以独占操作系统的所有资源,当然进程也不会一次性向申请操作系统申请操作系统的所有资源,一次只会申请一小部分资源。
为了给进程画饼(让进程认为自己独占操作系统资源),操作系统为每个进程都创建了独立的地址空间,地址空间的内容通过页表映射到物理内存中这样每个进程都能独立的运行。
3.现象的具体解释
父进程和子进程都有自己独立的进程地址空间,也有独立的页表结构。子进程由父进程创建,因此子进程的进程地址空间是拷贝父进程的进程地址空间。刚开始父子进程并未对进程地址空间做修改,因此i值在一开始指向同一个物理内存。
后来,子进程修改了i的值,操作系统通过页表映射发现i的值是两个进程共享的,操作系统为了保持进程的独立性,当子进程或者父进程任何一方尝试对共享的数据做写入,操作系统就会在物理内存上重新开辟一块新的内存空间拷贝原来的数据,然后修改映射关系,使其指向新的物理地址,再进行写入操作。整个修改的过程中,这些工作与父子进程的虚拟地址没有关系,只有底层经过页表映射到了新的物理地址,因此我们观察到的虚拟地址是相同的,但是内容却不同。
4.写时拷贝
父子进程中的任意一方试图对共享数据进行写入,操作系统就会先将原数据进行拷贝,然后改变要写入一方的页表映射,使它映射到新的物理内存中,然后再让进程进行写入的技术称为写时拷贝。
二、为什么
为什么存在进程地址空间?
- 保证了数据的安全性;
如果进程出现越界非法访问、非法写入,页表会对进程进行拦截。直接对物理内存进行访问,对于账号信息等数据是不安全的(可能出现:意外损坏数据或者恶意读取用户信息等问题)。 - 方便进程之间的数据代码的解耦,保证了进程的独立性;
一个进程对数据的修改不会对另一个进程造成影响,保证了进程的独立性。 - 让进程以统一的视角看待进程的代码和数据所在的各个区域,同时方便了编译器以统一视角编译代码。
可执行程序再被编译器编译的时候代码和数据再内存中已经有虚拟地址(在磁盘上的这种地址称为逻辑地址),也就是说操作系统和编译器都是遵守地址空间这一理论的。
在程序被加载到内存成为进程后,每个变量/函数都具备了物理地址。因此,我们现在有两套地址,一套是用于表示物理内存中代码和数据的物理地址;另一套是用于程序内部函数之间进行跳转的虚拟地址。
加载完毕后,代码的各个区域的地址,操作系统和编译器都已经知道了。进程被调度时,CPU拿到虚拟地址,经过地址空间的页表的映射,就能查到物理地址,通过物理地址访问到代码,然后执行。
CPU -> 虚拟地址 -> 页表 -> 物理地址 -> 执行。
也就是说明,CPU运行的整个过程中,CPU都没有见到物理地址,而是用虚拟地址运行程序。
对于磁盘内编译过的可执行程序中的地址不叫虚拟地址,而是叫做逻辑地址。当然对于Linux而言,虚拟地址、线性地址、逻辑地址都是一样的。
三、怎么办
- 操作系统要为每一个进程分配地址空间,那么操作系统是否要管理这些地址空间呢?当然是要管理的。
- 那么,操作系统怎么管理进程的地址空间?
说到管理,那就是管理数据,管理的方法是先描述,再组织。
首先,进程本身就是需要被管理的,操作系统管理它的方式是将进程的信息存入结构体PCB(即,task_struct)中,再用链式结构将每一个进程的PCB对象组织起来。
而地址空间也是需要用内核数据结构mm_struct进行管理,OS会为每一个进程创建一个mm_struct(结构体)对象,进行管理。该结构体对象保存在它所对应进程的PCB中。(PCB中的一个属性mm_struct) - 区域划分和调整
地址空间有很多区域:栈区、堆区、数据段、代码段等,那么进程地址空间是如何进行区域划分的呢?
举个简单的小栗子:
上小学的小蓝和小粉是同桌,小粉并不想和小蓝一起玩,因此将桌子上用一条“三八线”划分为了两个区域,左边属于小蓝,右边属于小粉两人约定不能过线(即,不能非法访问别人的区域)。
虚拟地址空间是连续的,因此将地址空间划分为不同区域的方法与上面例子的做法类似,我们用一个区域的起始地址start和终止地址end来调整和维护这一块区域。
struct mm_struct { uint32_t code_start,code_end; uint32_t data_start,data_end; uint32_t heap_start,heap_end; uint32_t stack_start,stack_end; }
所谓的区域调整,本质就是修改对应区域的start和end的值。
补充说明:
对于区域划分,进程地址空间的划分实际上是这样的:
0-3G是用户空间,命令行参数和环境变量是在用户空间,这也是为什么我们可以在main函数通过第三个参数env获取环境变量。3-4G是内核空间。
总结
以上就是今天要讲的内容,本文介绍了进程地址空间的相关概念。本文作者目前也是正在学习Linux相关的知识,如果文章中的内容有错误或者不严谨的部分,欢迎大家在评论区指出,也欢迎大家在评论区提问、交流。
最后,如果本篇文章对你有所启发的话,希望可以多多支持作者,谢谢大家!