目录
十、进程地址空间
10.1 回顾C/C++ 地址空间
10.2 测试
10.3 感性理解虚拟地址空间
10.4 如何画大饼?
10.5 如何理解区域划分和区域调整
10.6 虚拟地址空间、页表和物理地址
10.7 为什么存在地址空间
10.7.1 保证物理内存的安全性
10.7.2 保证进程的独立性
10.7.3 保证进程的统一性(难点)
十、进程地址空间
10.1 回顾C/C++ 地址空间
C/C++ 地址空间基本是下面这样子的,以 32 位的平台为例
这里的地址空间是什么?是物理地址吗?下面解释
10.2 测试
测试代码
#include<stdio.h>
#include<unistd.h>
int global_value = 1;
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("fork error\n");
}
else if(id == 0)
{
while(1)
{
printf("I am son process, pid:%d, ppid:%d | golbal_value:%d, &global_value:%p\n", getpid(), getppid(), global_value, &global_value);
sleep(1);
}
}
else
{
while(1)
{
printf("I am parent process, pid:%d, ppid:%d | golbal_value:%d, &global_value:%p\n", getpid(), getppid(), global_value, &global_value);
sleep(2);
}
}
return 0;
}
运行结果,global_value 的地址都相同,没毛病
下面稍微修改一下程序
测试代码
#include<stdio.h>
#include<unistd.h>
int global_value = 1;
int main()
{
pid_t id = fork();
if(id < 0)
{
printf("fork error\n");
}
else if(id == 0)
{
int cnt = 0;
while(1)
{
printf("I am son process, pid:%d, ppid:%d | golbal_value:%d, &global_value:%p\n", getpid(), getppid(), global_value, &global_value);
sleep(1);
if(cnt == 5)
{
global_value = 100;
printf("global_value 已发生改变\n");
}
cnt++;
}
}
else
{
while(1)
{
printf("I am parent process, pid:%d, ppid:%d | golbal_value:%d, &global_value:%p\n", getpid(), getppid(), global_value, &global_value);
sleep(2);
}
}
return 0;
}
运行,观察变化
我们观察发现,同一个地址,居然打出了两个不同的值,这是什么情况??
如果说我们是在同一个物理地址处获取的值,那必定是相同的,而现在在同一个地址处获取到的值却不同,这只能说明我们打印出来的地址绝对不是物理地址
以此推导,我们在学习各种语言所遇到的地址,并不是对应的物理地址,包括指针
那这个地址是什么地址?
我们在学习各种语言中所遇到的地址叫做虚拟地址,虚拟地址不是物理地址,虚拟地址也叫线性地址和逻辑地址
学习各种语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理OS必须负责将 虚拟地址 转化成 物理地址
10.3 感性理解虚拟地址空间
理念:进程会认为自己是独占系统资源的,事实上并不是
举个栗子:
一个富翁Peter,他有百亿美金,他在外面有三个私生子,son1、son2和 son3,三个私生子彼此之间都不知道各自的存在,son1、son2和 son3都认为他的父亲只有一个儿子,就是他们自己
Peter 对儿子们画了一个超级大饼:
Peter 对 son1 说:你管理好这个工厂,等我不在了,你就继承我的百亿美金
Peter 继续对 son2 说:你当好这个金融公司的CEO,等我不在了,你就继承我的百亿美金
Peter 又对 son3 说:你好好念书,等我不在了,你就继承我的百亿美金
这三个儿子非常高兴,son1 认为自己独占他爸的资产,son2 也认为自己独占他爸的资产,son3 也认为自己独占他爸的资产
儿子们想他老爸要钱,不会说:爸,你给 百亿美金我用用。即使儿子是这么说,他爸也不可能给的,儿子要钱了,Peter 只会给几百万,几千万,Peter不可能说把全部的资产全给儿子
这里的大富翁就相当于操作系统;他的百亿美金就相当于内存;给儿子们画的百亿美金大饼就相当于地址空间,准确的说是进程地址空间,它就是我们C/C++ 学习是所划分的地址空间(栈区、堆区...),而进程地址空间就是操作系统给进程画的大饼;儿子就相当于进程,儿子向老爸要的钱就相当于我们向内存申请内存或对象空间
儿子们向他老爸要百亿美金,他老爸可以直接拒绝,就好比你的内存有16G,进程A 一上来就申请个16G内存,操作系统直接拒绝了进程的请求,申请内存一般每次都是申请一点点:10kb,20kb,不可能说上来就直接申请16G的内存,操作系统也不会给你这么干
10.4 如何画大饼?
大富翁给儿子们画饼,儿子们肯定要记住是谁给他们画的饼,画的饼是什么样子的,儿子们脑中都有一个蓝图结构体,这个结构体里面包含了是谁给他们画的饼,画的饼是什么...等等
struct 蓝图
{
char* who;
char* when;
char* money;
//岗位
//...
}
画饼的本质:在大脑中构建一个蓝图,这种蓝图实际上是一种数据结构对象
再举个栗子:
假设某个公司的老板,他给公司内的500个员工画饼:你们好好干,等公司上市了,一人给...
员工是要被管理的,老板给员工画的饼也要被管理
这500个员工就相当于500个进程,老板给员工画饼就相当于操作系统给进程画的饼:进程地址空间;500个进程也要被管理, 进程地址空间也要被管理,如何管理?先描述,再组织
地址空间的本质:是内核的一种数据结构,这个数据结构叫 mm_struct
继续谈如何画饼
继续看 C/C++ 的地址空间,假设是在 32位平台下
地址空间描述的基本空间大小的单位是字节,32位下就有 2^32 个地址空间(字节),这些空间都是虚拟地址空间,这个虚拟地址空间就是进程地址空间, 2^32 个地址空间(字节) = 4GB的空间范围,每个字节都要有唯一的地址,这样下来给每个字节对应一个唯一的地址,这样地址就有了 2^32 个地址,地址最大的意义只要保证唯一性即可,怎么表示 2^32 个地址,32位的数据即可表示:32bit(unsigned int )
10.5 如何理解区域划分和区域调整
地址空间中有栈区、堆区、代码区...等等,那它们是如何划分的呢?
下面继续举栗子:
小明和小红是一年级的同学,他们互相是同桌,他们的课桌假设只有100cm,小红嫌小明老是占用她的课桌位置,于是与小明一起对课桌进行了区域划分,两人个占一半,小明的活动范围是 [0 ~ 50]cm,小红的活动范围是 [51 ~ 100]cm
如何描述小明和小红所划分的区域?如图
小明老是越过分割线,小明老是挨揍,于是小明就对小红说能不能重新划分一下区域,我们各自留下 5cm的缓冲空间,小红说可以,你可以适当在缓冲空间活动,但是不能超过我的分割线
多次对桌子分配的区域所进行的调整,就可以看成不断改变结构体的内部成员变量大小的过程
struct Destop d = {0, 50, 51, 100};//一开始
struct Destop d_new = {0, 45, 55, 100};//区域发生改变
struct Destop d_new1 = {0, 30, 31, 100};//区域再次发生改变
小明和小红进行的划分区域和区域调整,就相当于地址空间的区域划分和区域调整,小明和小红是一个具体的 mm_struct 结构体
虚拟地址空间的区域划分,实际上也就是相当于小明和小红进行区域划分,这个划分的结构体就是 struct mm_struct,它里面包含了对各个区进行划分的数据,进行划分的空间大小为 2^32 个字节,也就是 4GB 的空间大小(32位下)
struct mm_struct
{
uint32_t code_start, code_end;//代码区
uint32_t data_start, data_end;//数据区
uint32_t heap_start, heap_end;//堆区
uint32_t stack_start, stack_end;//栈区
//....
//...
};
划分好之后,小红再次对区域进行调整,这就相当于再次对虚拟地址空间划分进行调整,堆区和栈区的虚拟地址空间大小是可以被改变的,也就是再次进行空间调整,进行调整只需要改变 start和end 的范围。比如我们进行 malloc 或 new 开辟空间,实际上就是对 堆区或栈区 进行调整,增大堆区或栈区的空间范围,当我们 free 掉空间的时候,也就对应调整缩小 栈区或堆区的空间范围
我们看一眼 Linux 的部分 mm_struct 内核数据结构
这里面确实对各个区域进行了划分
这也证明了,我们之前一直所谈的C/C++地址空间这个叫法是个错误的,其实际上是进程的地址空间
10.6 虚拟地址空间、页表和物理地址
我们已经知道了 struct mm_struct *mm,这个 *mm指针就是指向 mm_struct 这个结构体
这是我们的内存,可执行程序要执行就先要加载到内存里,那么我们通常所说的物理地址也就是内存与磁盘经常会产生联系,即数据在内存与磁盘间传输的过程我们称为IO,IO的单位是 4KB,那么我们就将内存中 4KB的大小空间看成一个 page页,因此对于内存的数据来说,如果内存为4GB,那么我们可以把内存分割成 4GB/4KB 个 page页,即我们可以将内存想象为一个结构体数组:struct page mem[4GB/4KB],通过偏移量就可以访问内存中所有的page页,也就可以访问到内存的所有数据
进程地址空间 和 物理地址之间的关联
而对于这些虚拟的地址实际上作为数据来说,也需要存放在物理地址的某一个位置,因此这就会与内存产生关联。而虚拟地址与物理地址产生关联的媒介就这样产生了,我们将这个媒介称之为页表。(由于页表的内容过于复杂,在这里仅仅是引出这么个名词方便后续解释)
每一个虚拟地址都需要通过页表需要映射到物理内存上,一个页表中有两个地址(一个是虚拟地址,另一个是物理地址)也就是8个字节需要存储,那么储存这个页表所需要的空间为:2^32*8 = 32GB,内存压根存不下,内存只有 4GB,所以页表的存储形式不简单,而且极为复杂,因此关于页表的知识这里不谈,后续会讲
假设一个程序它定义了 int a = 100,我们对 a 进行取地址 &a,取到的地址就是虚拟地址,假设 a 的虚拟地址是 0x1234 5678,这个虚拟地址通过页表的映射,映射找到相应的物理地址,假设物理地址是 0x1111 2222
我们做的就是把可执行程序加载到内存,通过页表映射到内存等其他的所有工作,都是由操作系统自动帮你完成
多个进程运行,每个进程都认为自己占用 2^32 个地址 = 4GB,实际上操作系统并不允许任何一个进程完全占用所有的内存空间,而且进程是看不到物理内存的,只能通过页表取间接访问
10.7 为什么存在地址空间
10.7.1 保证物理内存的安全性
如果直接让进程访问物理内存,这是非常不安全的。比如,万一进程越界非法操作呢?有一个恶意进程扫描你的物理内存,读取你的隐私数据,账号密码...等等,所以进程直接访问物理内存是不安全的。
所以就需要一个虚拟地址空间,给进程啥闹腾,非法操作,野指针...随便让进程弄,这些非法进程非法访问物理内存或非法进行映射的时候,页表可以直接拦截你的非法操作,这个识别恶意进程和终止恶意进程都是由操作系统做的
至于怎么识别和怎么做,后面篇章会讲
10.7.2 保证进程的独立性
解释 10.2 的测试现象,同一个地址,打出了两个不同的值
相同地址下父进程和子进程的数值为什么不同?
我们都知道了子进程是以父进程为模板创建出来的
程序运行时,global_value = 100 被存放在了物理内存中,父进程和子进程都需要访问 global_value,于是 global_value 的虚拟地址空间中的地址就会通过页表映射到物理内存中,于是父进程和子进程就可以通过虚拟地址空间中的地址去访问 global_value,并且打印时父进程和子进程对应的 global_value 对应的虚拟地址也是相同的,因此开始时我们能看到父进程和子进程对应的 global_value 的数值和地址都相同
当子进程要改变 global_value 的值时,涉及到了写时拷贝和进程的独立性
进程是具有独立性的,一个进程对被共享的数据修改,如果影响了其他进程,就不能称之为独立性了,所以一个进程对被共享的数据修改不能影响其他进程
操作系统为了保证进程的独立性,操作系统做了很多工作:通过地址空间,通过页表,让不同的进程映射到不同的物理内存处
写时拷贝:
任何一方尝试写入数据,操作系统先进行数据拷贝,更改页表映射,然后再让进程进行修改。写时拷贝用于不同进程的数据进行分离,比如两个进程共享一个数据,其中一个进程要对共享的数据进行修改,一个进程仍然指向原有的物理地址,而修改共享数据的另一个进程则发生写时拷贝
所以,当子进程要改变 global_value 的值时,子进程会发生写时拷贝,操作系统就会将子进程页表与内存的物理地址之间的联系断开,并在物理内存的另一个位置将原来物理地址的数据拷贝过来,拷贝后再进行对值的修改,子进程页表与内存的物理地址之间的联系也将被修改,指向拷贝后的地址。
所以,当子进程要改变 global_value 的值,并不会影响到父进程的 global_value 的值,这个操作与虚拟地址也没有任何关系,因此我们所看到的子进程与父进程的虚拟地址仍是相同的地址,发生改变的只是物理地址
进程 = 内核数据结构 + 进程对应的代码和数据,内核数据结构是独立的,进程对应的代码和数据也是独立的,因此进程就是独立的
所以,进程地址空间的存在,可以更方便的进行 进程和进程的数据代码的解耦,从而保证了进程独立性的这种特征
10.7.3 保证进程的统一性(难点)
当我们写了一个可执行程序,当它加载到内存的时候,这个可执行程序的内部有地址吗?
答案是肯定有的,程序编译的过程为:预处理、编译、汇编、链接,程序在第二步编译的时候已经有了地址(在调试模式下,反汇编可以查看),最后一步链接才是生成可执行程序,所以在生成可执行程序的时候,可执行程序的内部已经有的地址。
这个地址叫逻辑地址,在Linux下,虚拟地址和逻辑地址是一样的,下面为了方便都叫虚拟地址
虚拟地址空间的规则只有操作系统会遵守吗?
当然不是,不仅操作系统需要遵守,编译器同样需要遵守!编译器在编译你的代码的时候,就是按照虚拟地址空间的方式进行对代码和数据进行编址的
上面说的地址,是我们程序内使用的地址
假设在32位平台下,也就是按照32位地址空间进行编址,假设磁盘中有一个可执行程序 my.exe,它里面有一个main 函数,还有一个func 函数,还有一个变量 a,main 函数调用这个 func 函数,func 函数里面使用了变量a,my.exe 是一个可执行程序,它的内部已经有了虚拟地址(逻辑地址),假设 a的地址是 0x1122,func() 函数的地址是 0x1111,main() 函数的地址是 0x2222
在编译时,main() 里面的 fun() 会通过虚拟地址跳转到定义的 fun()函数,当可执行程序加载到物理内存时,这个虚拟地址仍然存在,也就是程序内部使用的地址在加载到物理内存中时仍然存在。
执行程序加载到物理内存时,天然具备了一个外部的物理地址,可执行程序的内部也有一套虚拟地址,这样相当有了两套地址
也就是说,可执行程序加载到物理内存时,可执行程序内部有一套虚拟地址,外部有一套物理地址!!一套是程序内部互相跳转的虚拟地址,另一套是标识物理存在中代码和数据的地址
当 CPU 执行这段代码的指令的时候,程序内部的虚拟地址空间就被加载出来,*mm 指向这块虚拟空间 mm_struct,这个空间就有着这个程序的虚拟地址
CPU 读进来的指令,指令内部就有地址(虚拟地址)
那么当CPU的寄存器,比如 pc指针通过指令读取此代码时,指令内读出来的是物理地址还是虚拟地址呢?
一定是虚拟地址!因为指令的内部使用的那一套地址就是虚拟地址,虚拟地址再通过页表映射找到物理地址
当CPU再次读取指令,从main函数中出来再次调用fun()函数,出来的是物理地址还是虚拟地址呢?答案当然还是虚拟地址!原因与上述的理解相同
过上述的物理内存的映射与寄存器的读取,整个代码跳转的逻辑就那么一点点的转起来了,读取虚拟地址,再通过页表找到物理地址,这个过程确实很抽象
在这个过程中我们也发现 CPU 在根本接触不到物理地址,接触到的都是虚拟地址!
所以,地址空间的存在,可以让进程以统一的视角来看待进程对应的代码和数据等各个区域,方便使用。编译器也以统一的视角来进行编译代码(使用和编译的 统一是指虚拟地址空间的统一,因为规则一样,所以虚拟地址编完即可使用)
----------------我是分割线---------------
文章到这里就结束了,进程概念这个篇章也完结了,下篇进入进程控制