Linux进程概念(下):进程地址空间
文章目录
- 程序地址空间回顾
- 虚拟地址
- 进程地址空间
- 页表
- 为什么需要进程地址空间?
- 页表扩展
程序地址空间回顾
在过去,我们见过这样的空间布局图:
可是,我们对它并不理解!
现在,我们在LInux环境下对该空间布局图验证:
#include<stdio.h>
#include <stdlib.h>int g_unval;
int g_val = 100;
int main(int argc, char* argv[], char* envp[])
{printf("code addr:%p\n", main);//代码区char* str = "hello world";printf("read only addr:%p\n", str);//只读常量区printf("init addr:%p\n", &g_val);//初始化数据printf("uninit addr:%p\n", &g_unval);//未初始化数据int* p =(int*)malloc(10);printf("heap addr:%p\n", p);//堆区printf("stack addr:%p\n",&str);//栈区printf("stack addr:%p\n",&p);//栈区for(int i=0; i < argc; i++){printf("args addr:%p\n", argv[i]);//命令行参数}int i=0;while(envp[i]){printf("env addr:p\n", envp[i]);//环境变量i++;}return 0;
}
可以发现,是完全吻合的
虚拟地址
先来看一段代码:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_val = 0;int main()
{pid_t id = fork();if(id < 0)// fork error{perror("fork");return 0;}else if(id == 0){//childprintf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);}else{ //parentprintf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);return 0;
}
输出结果:
parent[2995]: 0 : 0x80497d8
child[2996]: 0 : 0x80497d8
我们发现,输出出来的变量值和地址是一模一样的,很好理解,因为子进程按照父进程为模版,父子并没有对变量进行进行任何修改
可是,如果子进程对变量进行修改,会发生什么?
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_val = 0;int main()
{pid_t id = fork();if(id < 0)// fork error{perror("fork");return 0;}else if(id == 0){//child,子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读取g_val = 100;printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);}else{ //parentsleep(3);printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);return 0;
}
输出结果:
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8
奇了怪了,父子进程输出地址竟然是一致的,明明变量内容不一样!
根据这个现象,我们可以推出结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 它们地址值是一样的,说明该地址绝对不是真的地址(物理地址)!
其实我们平时写的C/C++代码,使用的指针里存储的地址,都不是真的地址(物理地址),在Linux地址下,这种地址叫做虚拟地址。
我们用户是看不到物理地址的,它由操作系统统一进行管理。
OS必须负责将 虚拟地址转化成物理地址 。
进程地址空间
所以之前说“程序的地址空间”是不准确的,准确的应该说成“进程地址空间”,那该如何理解呢?
进程地址空间本质上是内存中的一种内核数据结构,在Linux当中进程地址空间具体由结构体mm_struct实现。
进程地址空间就好比一把尺子,尺子的刻度范围是0x00000000到0xffffffff,每一个刻度就是一个虚拟地址,将尺子的划分区域,就得到了我们的代码区、堆区、栈区等等。
如何划分的呢?在mm_struct中定义区域的起始位置即可。
由于虚拟地址是由0x00000000到0xffffffff线性增长的,所以虚拟地址又叫做线性地址。
有了这些知识,我们就容易理解一些现象了:
- 堆向上增长以及栈向下增长实际就是改变mm_struct当中堆和栈的边界刻度。
- 我们生成的可执行程序实际上也被分为了各个区域,例如初始化区、未初始化区等。当该可执行程序运行起来时,操作系统则将对应的数据加载到对应内存当中即可,大大提高了操作系统的工作效率。而进行可执行程序的“分区”操作的实际上是编译器完成的,所以代码的优化级别是编译器说了算。
页表
每个进程被创建时,其对应的PCB(task_struct)和进程地址空间(mm_struct)也会随之被创建。而操作系统可以通过进程的task_struct找到其mm_struct,因为task_struct当中有一个结构体指针存储的是mm_struct的地址。
然而,mm_struct中存在一个指针:pgd(Page Global Directory)页表全局目录指针。
这个指针指向的正是页表,它的主要作用就是将进程地址空间当中的各个虚拟地址通过页表映射到物理内存的某个位置。
也就是说:OS就是通过页表,帮助进程将虚拟地址转化成物理地址的。
假设一个进程有一个全局变量g_val,当该进程创建子进程之后,父进程有自己的task_struct和mm_struct,子进程也有属于其自己的task_struct和mm_struct,父子进程的进程地址空间当中的各个虚拟地址分别通过页表映射到物理内存的某个位置,如下图:
当子进程刚刚被创建时,子进程和父进程的数据和代码是共享的,即父子进程的代码和数据通过页表映射到物理内存的同一块空间。
当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后再进行修改。
比如,如果子进程需要将全局变量g_val改为100,那么子进程的页表就会更改物理地址,在物理内存中开辟新的空间存储这个新值,如图:
这种在需要进行数据修改时再进行拷贝的技术,称为写时拷贝技术。
为什么数据要进行写时拷贝呢?
进程具有独立性。多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。
为什么不在创建子进程的时候就进行数据的拷贝?
子进程不一定会使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝,我们应该按需分配,在需要修改数据的时候再分配(延时分配),这样可以高效的使用内存空间。
为什么需要进程地址空间?
讲一个故事:
在美国有一个大富翁,这个大富翁有10亿美金的财产,他还有4个私生子(这4个私生子都以为自己是富翁唯一的儿子)。
大富翁对第一个私生子说:“你努力读博士,等未来这10亿是你的。”
大富翁对第二个私生子说:“你好好工作,等未来这10亿是你的。”
大富翁对第三个私生子说:“”你好好创业,等未来这10亿是你的。”
大富翁对第四个私生子说:“你好好上学,考个好大学,等未来这10亿是你的。”
这样一来,四个私生子都相信,它们未来会具有10个亿财产,然而,富翁只有一个10亿,大富翁给他们每个人都画了一张”大饼”
后来,他们都觉得:既然这10亿将来是我的,那我现在要一点来花也没啥问题吧。
于是他们都会时不时的向大富翁要钱,每次都要的不多,都在富翁的接受范围内,富翁都没有拒绝。
其实,这里的大富翁就是操作系统;私生子就是一个个的进程;10亿美金就是总的物理内存;“大饼”就是进程地址空间;向富翁要钱就是向操作系统要物理内存。
进程地址空间的作用显而易见了:
- 让进程以统一的视角看待内存(画大饼)
- 增加进程虚拟地址空间可以让我们访问内存的时候,增加一个装换的过程,在这个装换的过程中,可以对我们的寻址请求进行审查,所以一旦异常访问,会直接拦截,该请求不会到达物理内存,从而保护物理内存。
- 因为进程地址空间和页表的存在,使得进程管理模块和内存管理模块相互独立,互不影响,也就是说将进程管理模块和内存管理模块解耦了,提高了可维护性和可靠性(容错)。
现阶段我们重新对创建进程进行理解:
进程 = 内核数据结构(task_struct && mm_struct && 页表)+ 程序的代码和数据
页表扩展
其实页表内容不仅仅只有虚拟地址和物理地址,这里再补充两个标志位:权限位和存在位(有效位)。
虚拟地址 | 物理地址 | 权限位 | 存在位 | …… |
---|---|---|---|---|
权限位
权限位用来标记虚拟地址对应物理地址上的可读,可写权限。
通过权限位使得页表具有权限管理的能力,阻止用户的一些非法访问。
当进程要对一个位置进行修改的时候, 操作系统会拿着虚拟地址到页表上进行比对,如果发现进程要进行非法修改,就会直接将进程杀死。
现在我们容易理解一个问题:
代码区和字符常量区为什么不能修改?
因为代码区和字符常量区在页表中的权限是只读权限,操纵系统禁止进程对这些区域进行修改。
有效位
在计算机上同时有多个进程在跑,CPU通过并发的方式使得各个进程在一段时间内都能运行,每个进程只能在CPU上跑一会,这就意味着进程只能运行部分代码,处理部分数据,那么内存是如何加载进程的代码和信息的,如果直接将代码和数据全部直接加载到内存中必定会造成浪费,如果不是又是如何解决的呢?
为了防止内存空间的浪费,在页表上增加了一个有效位,用来记录进程要访问的数据是否已经加载到内存中了,如果没有加载到内存中,就会发生缺页中断让数据再加载到内存中来。
下篇预告:进程控制
有错误欢迎指出,万分感谢
创作不易,三连支持一下吧~
不见不散!