
🎬 个人主页:HABuo
📖 个人专栏:《C++系列》 《Linux系列》《数据结构》《C语言系列》《Python系列》《YOLO系列》
⛰️ 如果再也不能见到你,祝你早安,午安,晚安

目录
前言 :
上篇博客我们介绍了进程切换、环境变量相关知识,本篇博客将带领你领略Linux头一座大山:虚拟地址空间,可能内容比较难理解,但是不要害怕,我都理解了你还害怕什么,经过本篇博客的学习之后,在C/C++中动不动什么栈区什么代码区这区那区的,这些都是在哪,用来干啥的,就全都通透了!所以慢慢来,别着急!
本章重点:
本篇文章着重讲解进程中的虚拟地址和物理地址的关系,了解虚拟地址的内核本质是什么 ,以及页表和物理地址的映射逻辑和写时拷贝的具体体现,期间我们将回答三个问题:
什么是地址空间?地址空间是如何设计的?为什么要有地址空间?
📚一、语言层级的地址空间
C/C++程序员认为,程序的内存分布是这样的:

需要注意:
- 堆是向上增长的,栈是向下增长的。
- 在写C程序时定义的常量字符串实际上在上面区域的正文代码区,因为它和正文代码区紧挨着,所以常量区的字符串不允许修改!(只不过这种是传统UNIX术语中所提到的,现代术语进行了更精确的划分,如下)
cpp
高地址
┌─────────────────┐
│ 栈(stack) │ ← 局部变量
├─────────────────┤
│ ↓ │
│ (共享区) │
│ ↑ │
├─────────────────┤
│ 堆(heap) │ ← malloc分配
├─────────────────┤
│ .bss │ ← 未初始化静态/全局
├─────────────────┤
│ .data │ ← 已初始化静态/全局
├─────────────────┤
│ .rodata │ ← 常量字符串等
├─────────────────┤
│ .text │ ← 代码段
└─────────────────┘
低地址
- 第二点是,栈虽然说是向下增长的但是栈中的数组,结构体等结构的地址是向上增长的,比如说开辟数组时,开辟十个空间,那么数组中第一个元素在空间的最下面,也就是地址最低处,然后依次往上放后面的元素。
上述理解是我们在学习C/C++时所知道的,我们认为程序的地址就是上述的地址,但对吗?从来没人给我们说对不对,所有人都是硬着头皮说,我们也硬着头皮去接受,但是今天我就告诉你,错的,啊?不能你说错就错,那我前面的努力学习算什么!别急,见下面的例子你就知道是对是错!
cpp
#include<stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include<stdlib.h>
int tmp = 10;
int main()
{
printf("fork之前,tmp: %d,&tmp:%p\n", tmp, &tmp);
int id=fork();
if(id==0)//子进程执行的代码
{
tmp=20;
while(1)
{
printf("子进程,tmp: %d,&tmp: %p\n",tmp,&tmp);
sleep(1);
}
}
if(id>1)//父进程执行的代码
{
while(1)
{
printf("父进程,tmp: %d,&tmp: %p\n",tmp,&tmp);
sleep(1);
}
}
return 0;
}

代码很好理解,但是聪明的你很快的看到了一个细思极恐的事情,当子进程把tmp的值更改后,竟然同一个地址出现了不同的值,啊?这不跟我扯呢,C/C++中我们都知道地址具有唯一性,那这个.......,现象不用质疑,因此我们前面学的知识要推翻了,所以今天这篇博客你看的值了!让我们来探究一下到底为什么!
📚二、虚拟地址空间
上面我们知道,C/C++中语言层级的地址是不对的,我确确实实在编译器当中能见到这个地址,你给我说不对,那这个地址是什么地址?事实上它是虚拟地址,下面我们就来认识一下它,我们先从一个例子开始:
有一个富豪,它有10亿资产,由于年轻时比较浪,所以他有四个私生子,这四个私生子并不知道彼此的存在,私生子A是个医生,私生子B是个企业家,私生子C是个街头混混,私生子D是个学生,富豪分别对小A,B,C,D说:
(1),小A啊,你要是努力做个医生,以后我的10亿美金都是你的了
(2),小B啊,要是你把你的公司运作的很好,以后我的10亿美金就是你的了
(3),小C啊...小D啊...

故事还没结束,有一天,A说:老爸我要买医疗器械,钱呢你也别以后了你直接把这10亿美金直接给我,你以后就安享天年就ok了,他老爹心想,我还没死呢,就开始这样计划了,滚犊子!但是又不能不给,毕竟是事业,富豪就给了儿子A10万美金,小C又说:老爸,给我两千美金吧,我吃不起饭了,富豪一听就把钱打过去了,所以我们知道,这四个人都可以用10亿美金以内的钱,但是永远用不到10亿美金!
现在列出人物和地址的对应关系:
- 富豪对应操作系统
- 10亿美金对应物理地址
- 私生子对应每一个进程
- 富豪画的饼对应虚拟地址空间
因此我们理解了,虚拟地址空间是什么,虚拟地址空间就是操作系统为每个进程画的饼,让每个进程都以为我是独占所有内存的,但是操作系统也知道进程有多少能耐,给你一个你以为的所有内存,反正是取之不竭用之不尽你就用去吧,因此在每个进程看来内存就我自己用。
那紧接着问题就来了,每个进程都有那么一个虚拟地址空间,也就是大饼,你说老板今天给你说升总监,明天给你说升大堂经理,你肯定会说老板啊,你昨天明明说的是总监啊!老板尴尬了,是吗?因此这些大饼要不要管理起来呢?答案是,当然要的,饼不管理起来,就乱套了,老板的信用值也就无了!
因此,为了管理进程的虚拟地址空间,操作系统仍然用先描述再组织的方法建立结构体,因而管理进程的虚拟地址空间也就变成了对数据结构的管理!这样的结构体是mm_struct

📚三、虚拟地址空间的设计
我们从上面的了解中清楚虚拟地址空间无非就是栈区、堆区等等这区那区的,那抽象出一个结构体,我们在结构体当中如何表示呢?请看下图:

所以程序地址空间无非就是各个区域的结合,然而各个区域的划分无非就是两个整数begin和end,一个在区域的开头,一个在区域的结尾
cpp
struct mm_struct
{
int code_start;//代码区起始
int code_end;//代码区结束
int init_start;//初始化区起始
int init_end;//初始化区结束
int heap_start;//堆区起始
int heap_end;//堆区结束
......
其他属性
};
那栈区、堆区在动态调整的时候该如何做呢?很简单!所谓的区域范围变化,``实际上就是对start和end做加减!
📚四、虚拟地址与物理地址之间的关联
我们现在知道虚拟地址不是真实的地址,那真实的地址叫什么?叫物理地址!它们之间的联系**本质就是一种映射关系!**
现代计算机使用以下方法解决问题:OS为每一个进程配对一个虚拟地址空间和一张
页表,要访问物理地址时,需要先在页表进行映射,若访问的是非法地址,则会在页表层阻止你的访问!

所以为什么一个地址会有两个值?现在我们就能回答这个问题了:
1.子进程被创建的时候会按照父进程的模板创建对应的PCB和虚拟地址空间
2.任何一方想要改变共享内存,操作系统先把对应的数据拷贝一份,然后更改对应的页表映射关系,再把对应的值更改,因此从始至终虚拟地址空间的地址并没有发生改变。这个过程也叫写时拷贝

📚五、为什么要有虚拟地址空间
有效的保护物理内存
上面我们提到,虚拟地址是通过页表映射到物理地址的,页表就是充当这个保护的角色,当有非法访问的时候,直接不能映射,也即是访问不到内存!
使OS的耦合度更低,保证进程的独立性
因为有地址空间和页表的存在,物理内存可以不关心数据的类型,可以直接对它进行加载,这样物理内存的分配就可以和进程的管理分开来,做到它们并没有任何关系,所以内存管理模块和进程管理模块就完成了解耦合的操作!操作系统,为了保证进程的独立性,做了很多的工作:通过地址空间,通过页表,让不同的进程,晚射到不同的物理内存处。
所以独立性体现在:
1.进程与进程之间都有自己独属的PCB和虚拟地址空间、页表
2.一定层面上代码和数据也是独立的(可以共享但是代码运行起来不涉及更改)
进程=PCB(独立)+代码数据(独立),因而进程当然是独立的!
让进程以统一的视角,来看待进程对应的代码和数据等各个区域,方便使用编译器也以统一的视角来进行编译代码
1.可执行程序内部就是按照虚拟地址空间那套方法已经编过址了(栈空间堆空间是没的,这两个是在运行时动态生成的)(这个地址一般称为逻辑地址)
2.可执行程序加载到内存,函数或者全局变量等天然的也具有了物理地址
3.进程虚拟地址空间会借用第一步骤中生成的虚拟地址
4.CPU读取的时候读取的正是虚拟地址空间中的虚拟地址,通过页表映射到内存中的物理地址,内存中的代码内部跳转的仍然是虚拟地址,这些虚拟地址再经CPU读取找虚拟地址空间中的内容经页表再去找对应的物理地址。整体流程见下图:

📚六、总结
本篇博客我们了解了进程地址空间,清楚了在C/C++语言层级中,编译器所体现的地址到底是什么。它与实际存放数据的物理地址又是什么关系。
小结一下:
- 我们由现象(同一地址打印出不同的内容)推出语言层级的地址不是物理地址、
- 进程地址空间就是mm_struct(饼)
- mm_struct里面包含了进程地址空间的属性信息(供操作系统维护管理)
- 虚拟地址与物理地址通过页表建立映射关系
- 为什么要有进程地址空间:
bash
三点:
①有效的保护物理内存(页表对非法内容不建立映射)
②使OS的耦合度更低,保证进程的独立性(写时拷贝是一种具体体现)
③让进程以统一的视角,来看待进程对应的代码和数据等各个区域
方便使用编译器也以统一的视角来进行编译代码(磁盘中的可执行内部已经存在地址)
