进程地址空间

🙊 测试代码 🙊

#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
 printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }
 else
 { //parent
 printf("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){
 perror("fork");
 return 0;
 }
 else if(id == 0){ //child,子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读取
 g_val=100;
 printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }else{ //parent
 sleep(3);
 printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
 }
 sleep(1);
 return 0;
}

输出结果如下:

//与环境相关,观察现象即可
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8

我们发现,父子进程输出地址是一致的,但是变量内容不一样。因为进程具有独立性,进程 = 内核数据结构 ( 如 task_struct 等 ) + 代码和数据。所以进程的数据结构和代码数据都具有独立性。为了保证数据独立性,发生了写时拷贝,但是发生写时拷贝打印出两个变量的地址为什么是一样的?

如果地址是物理地址,就不可能是两个地址指向同一块空间但是打印出来的值不一样。所以该地址一定不是物理地址。在语言层面用的地址 ( 指针 ) 不是物理地址,而是虚拟地址 / 线性地址

能得出如下结论:

1、变量内容不一样,所以父子进程输出的变量绝对不是同一个变量

2、但地址值是一样的,说明,该地址绝对不是物理地址

3、在 Linux 地址下,这种地址叫做虚拟地址

4、在用 C / C++ 语言所看到的地址,全部都是虚拟地址!物理地址用户一概看不到,由操作系统 ( OS ) 统一管理,操作系统 ( OS ) 必须负责将虚拟地址转化成物理地址

🙊 地址空间 🙊

💖 一个故事

有一个富翁,有 100 个亿的资产,这个富翁有三个私生子 abc,这三个私生子并不知道彼此的存在,富翁对 a 说去世以后将财产全部留给 a,富翁又对 b 说去世以后将财产全部留给 b,之后又对 c 承诺去世以后将财产分给 c

有一天 a 儿子向富翁借 10 w 买奢侈品,富翁很爽快的答应了,过一天 b 儿子向富翁要 100 w 做生意,富翁也很爽快的答应了,又过了一天 c 儿子向富翁要 50 亿,富翁骂骂咧咧的拒绝了。

其中富翁对应计算机的操作系统100 亿的资产就对应计算机的内存,而 abc 三个儿子对应着进程。富翁给三个儿子画的饼就叫做进程地址空间

在这里插入图片描述
思考一个问题:大富翁要不要把 “ ” 管理起来呢?

当然要管理,通过先描述再组织的方式进行管理。“ ” 对应进程地址空间,而进程地址空间本质就是一个内核数据结构 struct mm_struct { }

每个进程都有一个 task_struct 和地址空间 struct mm_struct,结构体 struct mm_struct 中包含了代码区数据区栈区堆区的起始地址和结束地址,起始地址和结束地址之间的空间就是各自的虚拟地址。

当一个进程创建时,操作系统会给进程申请结构体 struct mm_struct 并将每个区域划分好,并且进程的进程控制块里有指针 struct mm_struct* mm 指向申请的结构体。

在这里插入图片描述
访问代码的时候,需要先在线性地址空间中找。最终需要将虚拟地址空间中所有数据保存到物理内存中,就需要将虚拟地址转换成真实的物理地址。

地址空间是一个数据结构,那么其中的代码区、数据区、堆区该如何理解?我们再通过讲故事的方式去理解。

小花和小胖是同桌,他们的课桌划了一道三八线,那么这个三八线将桌子一分为二,三八线的本质就是区域划分,如果将画三八线的行为用计算机语言表述,就是一个 struct area 结构体。将桌子当成一个 1~100 的线性结构,只需要限定区域的 startend,就可以对桌子进行划分。

在这里插入图片描述
而地址空间就是一个线性区,内存需要进行编址,地址空间编址是从低地址到高地址,一般地址空间上的一个虚拟地址表示一个字节。了解冯诺依曼体系结构后知道输入、内存和外设之间的关系,他们都是独立的设备,而要使设备之间能够互相传递信息,需要用总线连接

cpu 和内存之间连接的总线叫系统总线,而内存和外设连接的总线叫 IO 总线,32 位计算机系统总线的根数先暂且理解为 32 根,cpu 和内存通过 32 根系统总线进行连接,因为计算机只识别二进制,同样系统总线上的光电信号也只有 01 两种状态,32 根系统总线一共可以有 2 ^ 32 种可能性。

cpu 通过寻址的方式访问物理内存,所以需要对内存进行编址,cpu 想寻找一个地址,就通过总线有效与否从内存中拿到地址和里面的数据。所以 32 根系统总线一共有 2 ^ 32 个地址,因为 cpu 寻址的最小单位是 1 字节,即最多 4GB 内存。

在这里插入图片描述
而地址空间是模拟内存的情况,所以地址空间的基本单位也是一个字节,同时地址空间也有自己的编址,编址范围为连续地址空间,每个地址空间的大小是 1 个字节。

在这里插入图片描述
考虑一个问题:

每个地址对应一个字节,比如在内存中存一个整数,而一个整数是四个字节,四个字节的起始地址就是整型变量的地址,按道理说每个字节都有自己的地址,一个整数四个字节就有四个地址,对整数取地址时拿到的整数地址值是最小一个字节的地址。系统如何知道从起始位置处需要读取几个字节?

因为除了起始地址外,还有类型存在,类型帮助计算机判断应该读取几个字节的地址。

地址空间是线性的,因为 cpu 寻址的最小单位是一个字节,所以每个地址对应一个字节。如果需要申请多个字节,就需要返回首地址,在应用层根据首地址确定内存中的起始位置,加上类型偏移量读取指定数据并提取。

如何理解线性空间的各种数据区域?

地址空间也是一种数据结构,空间范围为 4GB,通过以下方式描述各种区域:

struct mm_struct //4GB
{
	//代码区
	long code_start;
	long code_end;
	long init_start;
	long init_end;
	//...
	//堆区
	long brk_start;
	long brk_end;
	//栈区
	long stack_start;
	long stack_end;
}

为了在内核中描述地址空间, mm_struct 充满了大量的数据划分各区域。如果限定了区域,就是起始和结束的位置已经确定,那么区域之间的数据叫做虚拟地址或者线性地址。

在这里插入图片描述
故事继续,如果有一天,三八线被重新划分,不再是原来的五五分,而变成了而八分,这个重新画三八线改变区域的行为叫对做扩大区域 / 缩小区域。此时只需要简单的修改开始和结尾的区间就能进行区域的调整。所以堆区扩大或栈区缩小本质就是更改对应的 start / end 的数据。

在这里插入图片描述
地址空间只是内核上的数据结构,并不是真实的物理内存,数据和代码只能存在于内存中,task_struct 使用的是地址空间中的虚拟地址,找到地址的目的是找该地址所对应物理内存中的内容

所以在 Linux 中存在一个页表,页表也可将虚拟地址转换成物理地址。除了页表之外 cpu 中还有一个硬件叫做 MMU ( 内存管理单元 )。页表就相当于一个映射,左侧是虚拟地址,右侧是物理地址。

当上层通过虚拟地址访问特定区域时,由 cpu 根据页表将虚拟地址转换成物理地址读取里面的数据。
在这里插入图片描述

💖 页表

有一个父进程指向 mm_struct 地址空间,在 mm_struct 的全局数据区有一个数据存放在全局数据区,假设这个数据的虚拟地址为 0x11223344,而父进程在创建的时候,也会创建出一个页表结构。

此时创建一个子进程,子进程以父进程为模板,创建 task_struct 和地址空间以及维护子进程对应的页表结构

一般在创建子进程的时候,该子进程内核数据结构里面的属性字段绝大多数会继承自父进程。所以在同样地址处也有一个地址 0x11223344,并且映射到相同的地址。所以在子进程或父进程不修改变量的时候,父子的虚拟地址相同,且打印出相同的数值。

在这里插入图片描述
如果子进程对数据进行修改,因为进程具有独立性,子进程需要通过页表找到物理内存,在内存中重新申请一块空间将数据放入,然后再重新构造一下映射关系。
在这里插入图片描述
因为修改的时候是在物理内存上申请空间,所以只是修改页表的一侧。综上解释了地址相同内容不同的现象。

因为有虚拟地址空间页表的存在,进程并不关心将代码放到物理内存的哪个位置。因为进程知道代码或者初始化数据的虚拟地址范围,因为有页表做映射而不用关心具体内存中的位置。

我们将左侧称作进程管理、右侧称为内存管理

在这里插入图片描述

💖 缺页中断

malloc ( )mmap ( ) 等内存分配函数,在分配时只是建立了进程虚拟地址空间,并没有分配虚拟内存对应的物理内存,而进程真正需要向空间写入或其他操作的时候,操作系统再去物理内存申请空间并构建映射关系,操作系统的这种内部机制叫做缺页中断。

💖 页表的意义

1、防止地址随意访问,保护物理内存与其他进程。相当于进程和内存之间加了一层软件层,合法就转化,不合法就拦截。

2、将进程管理和内存管理解耦合。

💖 重新理解地址空间(难点)

当程序被编译的时候,没有被加载到内存,这个时候程序内部有没有地址?

答案是有地址,因为程序在磁盘中形成可执行文件的时候,就会有代码段、已初始化全局数据段、未初始化全局数据段等,这些数据段可以分批加载到对应的地址空间中。所以源代码在编译的时候,就是按照虚拟地址空间的方式编好了代码和数据对应的编址。

不要认为虚拟地址的策略只影响操作系统,还需要让编译器遵守这样的规则。可执行程序在被编译的时候,可执行程序内部使用的是虚拟地址,当可执行程序加载到内存时就可以在任意位置加载,加载进来以后形成虚拟地址和物理地址的映射。程序执行的过程如下图所示:

在这里插入图片描述
虚拟地址空间可以让进程以统一的视角,看待自己的代码和数据。

总结:

虚拟地址空间是在操作系统内部为进程创建出来的一种数据结构对象,可以让进程以统一的视角看待物理内存

因为有了虚拟地址空间的存在,可以让内存管理和进程管理相互独立

在磁盘中编译程序时,将程序以虚拟地址空间的方式整理好,加载到内存后,cpu 进行读取识别拿到的都是虚拟地址,再经过映射找到对应的指令,再读取下一条指令

虚拟地址空间可以保护物理内存、将进程管理和内存管理解耦合、让进程看待自己的数据和代码统一化

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值