
🎬 个人主页 :艾莉丝努力练剑
❄专栏传送门 :《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录》
《Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享》
⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平
🎬 艾莉丝的简介:

文章目录
- 前情提示
- [5 ~> 程序 / 进程虚拟地址空间](#5 ~> 程序 / 进程虚拟地址空间)
-
- [5.4 虚拟地址的概念](#5.4 虚拟地址的概念)
- [5.5 直接提出虚拟地址空间以及周边概念,直接解决历史问题](#5.5 直接提出虚拟地址空间以及周边概念,直接解决历史问题)
-
- [5.5.1 虚拟内存和虚拟地址](#5.5.1 虚拟内存和虚拟地址)
- [5.5.2 写时拷贝](#5.5.2 写时拷贝)
- [5.5.3 页表](#5.5.3 页表)
- [5.5.4 进程的几个特征](#5.5.4 进程的几个特征)
- [5.5.5 梳理:进程具有独立性](#5.5.5 梳理:进程具有独立性)
- [5.5.6 思维导图](#5.5.6 思维导图)
- [5.6 理解虚拟地址空间](#5.6 理解虚拟地址空间)
-
- [5.6.1 什么是虚拟地址空间](#5.6.1 什么是虚拟地址空间)
- [5.6.2 区域划分问题](#5.6.2 区域划分问题)
- [5.6.3 虚拟地址空间是怎么实现的?](#5.6.3 虚拟地址空间是怎么实现的?)
- [5.7 为什么要有虚拟地址空间?](#5.7 为什么要有虚拟地址空间?)
-
- [5.7.1 理由1:(有了虚拟地址空间)可以更好地进行对内存进行保护](#5.7.1 理由1:(有了虚拟地址空间)可以更好地进行对内存进行保护)
- [5.7.2 理由2:虚拟地址空间让进程的代码和数据在空间排布上,从无序变成了有序!](#5.7.2 理由2:虚拟地址空间让进程的代码和数据在空间排布上,从无序变成了有序!)
- [5.7.3 理由3:进程管理模块和内存管理模块就完成了解耦合](#5.7.3 理由3:进程管理模块和内存管理模块就完成了解耦合)
- [5.8 知识点回归](#5.8 知识点回归)
-
- [5.8.1 虚拟地址部分](#5.8.1 虚拟地址部分)
- [5.8.2 进程地址空间部分](#5.8.2 进程地址空间部分)
- [5.8.3 三个小故事](#5.8.3 三个小故事)
- [5.8.4 初识虚拟内存管理](#5.8.4 初识虚拟内存管理)
- [5.8.5 为什么要有虚拟地址空间?](#5.8.5 为什么要有虚拟地址空间?)
- [5.9 补充点](#5.9 补充点)
- 结尾

前情提示

5 ~> 程序 / 进程虚拟地址空间
5.4 虚拟地址的概念
c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
int g_val = 0;
while(1)
{
if(id < 0)
{
perror("fork");
return 0;
}
else if(id == 0)
{
//child
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
g_val++;
sleep(1);
}
else
{
//parent
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
双进程,两个死循环同时跑,id即== 0,又> 0,这就是原因------


打印出来的g_val(全局变量)的地址居然是一样的!我们不是修改了子进程的g_val吗?为什么g_val(全局变量)的地址还是一样的,值却不是一样的------子进程是2,父进程还是0------我们由此可以得出一个结论:
由此可见, C / C++中我们看到的地址绝对不是物理地址!而是
虚拟地址!
虚拟地址是OS提供的,不影响上层的语言,所以,以前的代码在虚拟地址上面还是能跑的,uu们不用担心。
5.5 直接提出虚拟地址空间以及周边概念,直接解决历史问题
5.5.1 虚拟内存和虚拟地址
我们假设是32位系统(方便理解),32位就是有32个比特位(一个地址用32位比特位表示),2 ^ 32个字节就可以表示2 ^ 32地址。
怎么算的呢?2 ^ 32 * 1byte = 4GB。
这里提一嘴,虚拟内存是4GB,但是 物理内存一般小于等于4GB。
访问内存(访存)的最小单位是什么?字节 。潜台词就是:每个字节都有地址------地址和字节绑定。
每个字节都会形成一个地址。
位图也是最小以字节为单位。
5.5.2 写时拷贝

这里的写时拷贝我们在Linux阶段已经不是第一次见了,最近一次就是在介绍fork的时候------

艾莉丝当时对fork发出了灵魂三问,不知道uu们记不记得啦。
5.5.3 页表

作用:虚拟地址和物理地址具有映射关系,页表的工作就是完成虚拟地址到物理地址的转换。
页表就是在保存映射,本质是地址。
5.5.4 进程的几个特征
PCB结构体、虚拟地址空间、页表等等。
5.5.5 梳理:进程具有独立性
父进程是子进程的模版(代码共享,数据独立)------

发生了地址级别的浅拷贝------拷贝了页表------父子进程执行同一个地址。
进程具有独立性------父、子这里只读不回产生影响,如果子进程修改呢?

我们一张图搞定 【进程具有独立性】:

结合我们C语言和C++中学到的动态开辟malloc、new开辟空间,请问一下,是申请了之后立刻就在物理内存中开辟出新的空间来吗?肯定不是的,这样一时半会儿用不上却早早申请好了,占着茅坑不拉屎,妥妥是资源的浪费。什么时候要用,什么时候再开辟。我们先在虚拟地址空间里面标记一下,但是此时并没有真的在物理内存上面开辟空间,等到物理地址通过页表的转换实现和虚拟地址的映射之后,我们再在物理地址空间上面开辟空间。
这里不仅是为了提高空间资源的利用率,这里的开辟空间要先在虚拟地址空间上,再通过页表映射到实际的物理内存空间上这个过程还会和后面我们要介绍的老系统直接访问物理内存的安全问题以及虚拟地址带来的安全性会挂钩。
5.5.6 思维导图
如下图所示------

5.6 理解虚拟地址空间

5.6.1 什么是虚拟地址空间
如下图所示,我们以一位资产超过10亿美刀、拥有众多私生子的北美大富翁为例,来理解一下什么是虚拟地址空间。

这就是OS让私生子------进程------产生了一个错觉:"我独占资源",至于为什么,我们后面介绍。
画饼,饼本身要不要被管理?打个比方,你是一个公司老总,给每个员工画大饼,这个干好了当项目经理,那个完成了就把下一个项目交给他------如果不管理,大饼搞错了怎么办?比如明明许诺的是项目,却让他当了项目经理,明明是让他当项目经理,最后却让他去负责项目,这样可以吗?当然不行。因此,我们可以得出一个结论:
- 我们私生子要管理,虚拟地址空间(画饼)也要管理!
给每个员工、私生子画的大饼不一样!
这个例子很有延伸价值,我们后面介绍到进程申请空间需求拦截的时候也会再提到。
5.6.2 区域划分问题
如下图所示------

扩展 / 缩小区域,就是把区域开始和结束的数字改大或者改小一点。
5.6.3 虚拟地址空间是怎么实现的?
关于这个问题,我们就要结合一下Linux的内核源码了------

- 1、地址区间,虚拟地址在内核中可以直接使用unsignedlong类型进行标识。
- 2、start_data,end_data:限定一段区域内------任意一个地址,都是当前进程可以直接访问的!
- 3、代码区、数据区的大小由谁来决定?
由你的可执行程序的代码和数据大小决定!
代码在物理内存上占据了一些物理地址,这样代码区也有了虚拟地址,两者一一对应,通过虚表映射,OS自动去对应。
OS给每个进程画了一个空间大小为4GB的大饼。
5.7 为什么要有虚拟地址空间?

为什么我们前面观察到:父进程和子进程对于全局变量g_val,父进程不动,子进程每次循环会让g_val++,但是最终的结果却是------地址一样(现在我们知道这是虚拟地址),值不一样。
那么,为什么要有虚拟地址空间嘞?理由有三点,如下所示------

5.7.1 理由1:(有了虚拟地址空间)可以更好地进行对内存进行保护
父进程是子进程的模版,这里是浅拷贝的,子进程继承父进程的大部分。
权限标识:比如w(可写)、r(只读)这些,如下图,进程是"w"要修改,虚表中对应的权限标识是"r",进程这里这个不合理的请求被OS识别到了,OS直接拦截了进程的请求。
- 一定程度上体现了(有了虚拟地址空间)可以更好地进行对内存进行保护。

这里的MMU(memory manager unit)是一种硬件,叫做 "内存管理单元"------集成到了CPU上,而查虚表------把虚拟地址转换成物理地址------就是通过硬件完成的。
为什么说可以更好地对内存进行保护?进程访问物理内存(划重点),相当于多了一次转换(查虚表)的过程。通过下面的示例,我们来进一步认识理解一下这句话------

"妈妈"会拦截"我"的一些不合理的要求,即OS有效拦截进程的无理要求(野指针,乱指,在页表上面查不到),进程被拦截。
5.7.2 理由2:虚拟地址空间让进程的代码和数据在空间排布上,从无序变成了有序!

(有了虚拟地址空间的存在)每个进程都认为自己的区域是这样划分的------

只不过是有的大有的小。
其实就相当于画大饼:认为自己可以独占内存。
5.7.3 理由3:进程管理模块和内存管理模块就完成了解耦合

安全风险是最大的问题:

像这样,进程管理模块和内存管理模块就完成了解耦合------

左边是从地址空间上的角度,右边是OS给的。
5.8 知识点回归
至于页表什么的我们后面再说。
5.8.1 虚拟地址部分
我们先用这样一段代码感受了一下:
c
#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;
}
我们运行一下,观察发现:
bash
//与环境相关,观察现象即可
parent[2995]: 0 : 0x80497d8
child[2996]: 0 : 0x80497d8
我们发现,输出出来的变量值和地址是一模一样的,很好理解呀------因为子进程按照父进程为模版,父子并没有对变量进行进行任何修改。可是当我们将代码再稍加改动:
c
#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;
}
我们再运行一下------
bash
//与环境相关,观察现象即可
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8
我们发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
- 但地址值是一样的,说明,该地址绝对不是物理地址!
- 在Linux地址下,这种地址叫做
虚拟地址。 - 我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一进行管理。
OS必须负责将虚拟地址转化成物理地址。
5.8.2 进程地址空间部分
所以之前在上篇博客里面说【程序的地址空间】是不准确的,准确的应该说成【进程地址空间】,那该如何理解呢?如下图所示------
分⻚和虚拟地址空间,这个分页我们还没有介绍,下次再介绍。

说明什么?上图就足矣说明问题:同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址(页表的工作就是将虚拟空间地址转换成物理空间地址)!
5.8.3 三个小故事
注意:我们需要赶紧过渡一下啦!
如何理解虚拟地址空间?------北美大富翁的例子------

富翁给四个私生子都画了10个亿这个大饼,每个人的大饼都是10个亿,难道说富翁有40亿美刀?不是的。10亿就相当于OS给各个进程画的一个大饼------这4个G的物理内存都是你进程独有的------实际上是不是呢?肯定不是这样的,既然是大饼,就是半真半假的,富翁给私生子救急用的钱------比如给私生子1炒股票的钱、给私生子2读博士的钱、给私生子3买包包的钱,
如何理解区域划分?------38线的例子------
上学时候有没有和同桌划过"三八线",艾莉丝曾经被女同桌划过三八线------还因为写作业胳膊越界被揍过。。。

三八线的本质不就是区域划分嘛!

结合前面北美大富翁的例子,如果大富翁许诺私生子4,说"只要你好好学习,这10个亿都是你的!",私生子4是个中学生,正是好面子的时候,在学校吹牛说:"我有十个亿",人家不相信,他回家管老爹要10个亿,大富翁当然不可能会给他,这就是拦截了私生子4的不合理的请求------和这里我们举的妈妈的例子是一个道理。
5.8.4 初识虚拟内存管理
描述linux下进程的地址空间的所有的信息的结构体是mm_struct(内存描述符)。每个进程只有一个mm_struct结构,在每个进程的task_struct结构中,有一个指向该进程的mm_struct结构体指针。
c
struct task_struct
{
/*...*/
struct mm_struct* mm; //对于普通的用户进程来说该字段指向他的虚拟地址空间的用户空间部分,对于内核线程来说这部分为NULL。
struct mm_struct* active_mm; // 该字段是内核线程使用的。
// 当该进程是内核线程时,它的mm字段为NULL,表示没有内存地址空间,
// 可也并不是真正的没有,这是因为所有进程关于内核的映射都是一样的,内核线程可以使用任意进程的地址空间。
/*...*/
}
可以说,mm_struct结构是对整个用户空间的描述。每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰。先来看看由task_struct到mm_struct,进程的地址空间的分布情况:

定位mm_struct文件所在位置和task_struct所在路径是一样的,不过他们所在文件是不一样的,mm_struct所在的文件是mm_types.h。
c
struct mm_struct
{
/*...*/
struct vm_area_struct* mmap; /* 指向虚拟区间(VMA)链表 */
struct rb_root mm_rb; /* red_black树 */
unsigned long task_size; /*具有该结构体的进程的虚拟地址空间的大小*/
/*...*/
// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
/*...*/
}
那既然每一个进程都会有自己独立的mm_struct,操作系统肯定是要将这么多进程的mm_struct组织起来的!
虚拟空间的组织方式有两种:
- 当虚拟区较少时采取单链表,由mmap指针指向这个链表;
- 当虚拟区间多时采取红黑树进行管理,由mm_rb指向这棵树。
linux内核使用vm_area_struct结构来表示一个独立的虚拟内存区域(VMA),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。上面提到的两种组织方式使用的就是vm_area_struct结构来连接各个VMA,方便进程快速访问。
c
struct vm_area_struct {
unsigned long vm_start; //虚存区起始
unsigned long vm_end; //虚存区结束
struct vm_area_struct* vm_next, * vm_prev; //前后指针
struct rb_node vm_rb; //红⿊树中的位置
unsigned long rb_subtree_gap;
struct mm_struct* vm_mm; //所属的 mm_struct
pgprot_t vm_page_prot;
unsigned long vm_flags; //标志位
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
struct list_head anon_vma_chain;
struct anon_vma* anon_vma;
const struct vm_operations_struct* vm_ops; //vma对应的实际操作
unsigned long vm_pgoff; //文件映射偏移量
struct file* vm_file; //映射的文件
void* vm_private_data; //私有数据
atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMU
struct vm_region* vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy* vm_policy; /* NUMA policy for the VMA */
#endif
struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;
所以我们可以对上图在进行更细致的描述,如下图所示:


5.8.5 为什么要有虚拟地址空间?
这个问题其实可以转化为:如果程序直接可以操作物理内存会造成什么问题?
在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。
那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存的呢?例如某台计算机总的内存大小是128M,现在同时运行两个程序A和B,A需占用内存10M,B需占用内存110。计算机在给程序分配内存时会采取这样的方法:先将内存中的前10M分配给程序A,接着再从内存中剩余的118M中划分出110M分配给程序B。(如下图所示)

这种分配方法可以保证程序A和程序B都能运行,但是这种简单的内存分配策略问题很多。

存在这么多问题,有了虚拟地址空间和分页机制就能解决了吗?当然能够解决!

保护了物理内存中的所有的合法数据进程管理模块和内存管理模块就完成了解耦合进程视角所有的内存分布都可以是有序
5.9 补充点

碰到这种堆区不连续、中间有一块空间被free掉的情况怎么办?用 链表!

我们看这张图会更加明确一点------

完整的思维导图如下所示------

结尾
uu们,本文的内容到这里就全部结束了,艾莉丝在这里再次感谢您的阅读!
结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!
往期回顾:
【Linux进程(六)】程序地址空间深度实证:从内存布局验证到虚拟化理解的基石
🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡 ૮₍ ˶ ˊ ᴥ ˋ˶₎ა
