个人主页~
进程地址空间
- 一、程序地址空间
- 1、各内存区域的相对位置
- 2、引入父子进程问题
- 二、进程地址空间
- 1、页表
- 2、深入理解进程地址空间
- 3、进程地址空间这样组织的优势
- (一)让进程以一个统一的视角看待内存
- (二)保护物理内存
- (三)进程管理模块和内存管理模块低耦合
- 4、页表的其他内容
- 缺页中断
一、程序地址空间
1、各内存区域的相对位置
我记得在之前的博文中好像用编译器粗略定位过各个类型地址空间的位置,这里我们再验证一下它们的相对关系,这里是32位的机器,存储空间为2^32byte=4GB
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>int g_val_1;
int g_val_2 = 100;int main(int argc, char *argv[], char *env[])
{printf("code addr: %p\n", main);//代码段const char *str = "hello world";printf("read only string addr: %p\n", str);//只读数据段printf("init global value addr: %p\n", &g_val_2);//数据段(已初始化)printf("uninit global value addr: %p\n", &g_val_1);//BBS段(未初始化)char *mem = (char*)malloc(100);char *mem1 = (char*)malloc(100);char *mem2 = (char*)malloc(100);//malloc在堆上开辟空间printf("heap addr: %p\n", mem);printf("heap addr: %p\n", mem1);printf("heap addr: %p\n", mem2);//临时变量在栈上开辟空间printf("stack addr: %p\n", &str);printf("stack addr: %p\n", &mem);static int a = 0;int b;int c;//静态成员变量在数据段printf("a = stack addr: %p\n", &a);//临时变量在栈区printf("stack addr: %p\n", &b);printf("stack addr: %p\n", &c);//其实在栈区的最大地址处和内核空间的最小地址处之间还有一部分//用来存放我们的命令行和环境变量,且环境变量在大地址处int i = 0;for(; 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;
}
从图中我们可以看到,栈区和堆区是相对而生的,其中间有很大一部分的空间,在它们的中间还有一段内存映射段,这里我们后面结合后面的内容来解释
2、引入父子进程问题
test
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_val = 0;int main()
{pid_t id = fork();if(id < 0){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;
}
fork_test
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int g_val = 0;int main()
{pid_t id = fork();if(id < 0){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;
}
通过test进程现象我们发现,在这里我们的父子进程在访问我们的g_val值时访问同一个位置同一个值,但是在我们子进程对gal值进行修改然后再让父进程进行读取的时候,我们发现父进程读取的值依旧是原来的g_val值,而子进程读取的值已经是修改值,但地址还是相同的,地址相同怎么会读取到不同的值呢?首先我们可以肯定的是,这个地址一定不是物理地址!同一块物理地址访问到的值一定是一样的!
结合我们在前面讲到的,如果子进程修改了数据,我们会在另一块位置重新开辟一块空间用来存放子进程与父进程不同的这部分数据,这是为什么呢?这个实现的原理是什么呢?我们也可以肯定的是,这个变量,也就是这个数据,内容是不一样的,这是我们观察到的,父子进程输出的一定不是同一个变量!下面我们来讨论一下
二、进程地址空间
1、页表
我们在之前讲到的程序地址空间的说法其实是错误的,正确来说应该叫进程地址空间,上面我们所说的地址叫做虚拟地址,也叫做线性地址,既然叫做虚拟地址,那当然就不是真实的物理地址了,虚拟地址和物理地址存在映射关系,而承载他们映射关系的,就是页表
我整理了一下地址空间、页表和物理内存的关系如下图
在这个图中,我们把父子进程以及页表分开来画,因为它们是两个独立的进程,但是地址空间的这部分内容是共享的,也就是虚拟地址是相同的,我们不是复制出了两个地址空间,这里需要注意
内核空间中有父子进程的task_struct,它们里面有指向各自页表的指针
其中上方是父进程的地址空间,下方是子进程的地址空间,子进程直接复制父进程的地址空间,包括虚拟地址、页表等等变量都是相同的,类似于一个浅拷贝的过程,在子进程修改g_val变量时,子进程在物理内存上新开辟一块空间,用来存放与父进程数据不同的量,这个过程类似于memcpy的过程,创建并复制内容,然后再将g_val改成100,然后页表的物理地址指向该地址,这个过程是写时拷贝,我们前面提到过
其中MMU起到的作用是负责将进程虚拟地址转换为物理地址,当 CPU 需要访问内存时,会将虚拟地址发送给 MMU,MMU 根据页表等数据结构进行地址转换,是与页表息息相关的一个内存管理单元
2、深入理解进程地址空间
那看到这里有人问了,地址空间究竟是什么啊,我们为什么要进行这样的划分?
我们一直拿32位的计算机举例,因为它位数少,比64位的计算机简单一些,这里的32位计算机又指的是什么?
在 32 位计算机里,地址总线宽度是 32 位,也就是有 32 条线路,每条线路能通过高低电平的转换来实现0和1的变化,所以这 32 条线路能表示的不同地址组合数量为 2^32个,因为每个内存地址对应一个字节,所以 32 位计算机理论上能直接访问的内存空间大小就是 2 ^32字节,而2 ^32字节换算后等于 4GB,这就意味着 32 位计算机的 CPU 可以通过地址总线直接访问从 0 到 2 ^32 - 1地址范围内的 4GB 物理内存
我们的进程地址空间就在这样一个概念中展开,而地址空间的划分实际上是对该空间的一种组织,在正常运行的情况下互不影响
我们计算机中最小的存储单元就是字节byte,每个字节都会有一个地址,这个地址是可以直接被操作系统使用的,这是可以使用地址找到的最小单位,类似于bit这样的存储单元是没有地址的概念的
所以所谓的进程地址空间,本质上是一个描述进程可视范围的大小,地址空间内一定要存在各种区域的划分,只要对虚拟地址(线性地址)进行区域划分即可
这里要注意的是,栈的start是高地址处,其他用户空间都是start为低地址处
3、进程地址空间这样组织的优势
(一)让进程以一个统一的视角看待内存
我们以页表这样的形式用来过渡,保证了我们所访问的虚拟地址(线性地址)是线性的,我们的进程不管要做什么,我们只要知道它做的事情的性质,我们就知道它大概存储在哪个线性地址区域,并且因为有了页表的存在,我们不必再关心物理内存的实际布局以及其他进程的存在,我们本进程只做好本进程自己的事情就好了,其他的我并不关心
不同进程的虚拟地址空间是相互隔离的,一个进程无法直接访问另一个进程的虚拟地址空间,这就保证了进程之间的独立性和安全性,一个进程的错误或恶意操作不会影响到其他进程的正常运行
(二)保护物理内存
增加进程虚拟地址空间可以让我们访问内存的时候,增加一个转换的过程,在这个转换的过程中,可以对我们的寻址请求进行审查,所以如果访问异常,就可以直接拦截,请求不会到达物理内存,从而很好的保护了物理内存不被攻击
(三)进程管理模块和内存管理模块低耦合
我们通过页表这个结构,很好地将进程管理和内存管理解耦合,互不影响,我们进程所看到的只有虚拟地址,并不在乎物理地址如何如何,而我们的内存也不需要在乎有多少进程,进程的作用是什么,而是只在需要的时候开辟和回收空间就可以了,这样我们在进程出现问题的时候不会影响到内存管理,很好地阻断了可能出现的一系列崩盘的问题
4、页表的其他内容
页表除了我们上面提到的作用以外,还存在类似读写权限这样的功能,我们在之前学习的时候,我们知道在只读数据段中的数据是只可读不可写的,那么它相对应的映射到物理内存上,物理内存上又没有限制条件,它是怎么实现的只读呢?其实是页表的某一项属性控制了该变量的读写,分为不可读写、可读不可写、可写不可读、可读可写,在映射的同时将该性质传递回去,就只可读了
其他的还有对应代码和数据是否已经加载到内存等等一系列的其他属性
页表的本质属于进程的硬件上下文,在进程切换的时候会带走这些信息,被存储在CPU寄存器中,task_struct中有指向页表地址的指针
缺页中断
在虚拟内存系统里,程序运行时使用的是虚拟地址,虚拟地址空间会被划分为多个页面。物理内存则被划分为与虚拟页大小相同的页框。当程序访问一个虚拟地址,而该地址对应的页面不在物理内存中,也就是没有被加载到物理内存的页框里时,就会触发缺页中断,这是一种特殊的中断,它会暂停当前程序的执行,转而去处理页面加载的问题
进程刚开始运行时,它的代码和数据所在的页面可能都还没有被加载到物理内存中,当进程第一次访问某个页面时,就会因为该页面不在内存而产生缺页中断;或者由于物理内存资源有限,操作系统会使用页面置换算法将一些暂时不用的页面从物理内存换出到磁盘的交换空间,当进程后续又需要访问这些被换出的页面时,就会触发缺页中断
当缺页中断发生时,CPU 会保存当前进程的现场信息,包括程序计数器、寄存器等内容,以便在中断处理完成后能恢复进程的执行,操作系统根据引发缺页中断的虚拟地址,查找该页面在磁盘上的位置,这通常需要借助页表等数据结构来确定页面的磁盘地址,如果物理内存中有空闲的页框,操作系统会直接分配一个页框;若没有空闲页框,就需要使用页面置换算法选择一个当前在物理内存中的页面换出到磁盘,为即将要加载的页面腾出空间,然后发出磁盘 I/O 请求,将所需的页面从磁盘读取到分配好的物理页框中,页面加载完成后,操作系统会更新页表,将该虚拟页与新分配的物理页框建立映射关系,并设置相应的标志位,表示该页面现在已经在物理内存中,最后,操作系统恢复之前保存的进程现场,让进程从产生缺页中断的指令处继续执行
今日分享就到这里了~