程序地址空间

C/C++地址空间!

在先认识程序地址空间前我们要先认识一下C/C++的地址空间!

这就是C/C++的地址空间的结构!

image-20221220213733263

不过这个地址空间究竟是什么?——是内存吗?

==答案是错误的!这个地址空间其实不是内存!==

我们可以看一下

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 child ,pid = %d,ppid = %d,global_value = %d ,&global_valueo=%p\n",getpid(),getppid(),global_value,&global_value);       
            cnt++;    
            if(cnt ==10)                                                                         {    
                global_value = 300;    
                printf("child had change global_value!\n");    
            }
            sleep(1); 
        }    
    
    }    
    else    
    {    
        while(1)    
        {    
            printf("i am father ,pid = %d,ppid = %d,global_value = %d ,&global_valueo=%p\n",getpid(),getppid(),global_value,&global_value);    
            sleep(2);    
        }    
    }    
    return 0;    
}

fork的时候会生成一个子进程!因为进程要被系统管理!==所以子进程就要去拷贝父进程的内核数据结构(PCB)!和复制父进程的代码!==

image-20221220151848775

==怎么回事呢?为什么明明是同一个地址的变量却拥有两个值呢?多进程在读取同一个地址的时候怎么会出现不同的结果呢?==——如果值不同地址不同我们或许可以理解!因为进程之间是互相独立的!数据也应该是独立的!但是地址相同是怎么回事?

但是我们可以从这个现象中反推一个结论!——==这里打印出来的地址!绝对不是一个真实的物理地址!==

==我们也可以推出我们学的语言级别的基本地址(指针),也绝对不是对应的物理地址!==

我们以前看到的所有地址其实是一个虚拟地址!(又叫线性地址,linux下又叫逻辑地址!)

==所以C++的地址空间其实不是内存!我们是将其称之为虚拟地址空间!——也叫进程地址空间==

进程地址空间的概念

首先我们要对进程地址空间有个感性的认知!

我们要明白

==每一个的进程都认为自己是独占所有的系统资源的!==

但是我们都知道实际上不是的!像是进程切换的时候,进行上文保护然后休眠其实进程都是不知道的自己休眠的!进程始终的认为自己是可以任意的调用系统资源!

就像是有个同学在宿舍床上睡觉,只要不吵醒他,我们可以把他随意的挪动,甚至搬出去,只要在他起来前放回去!这个睡觉的同学会始终的认为自己是在床上的!始终都是在宿舍里面

==这就是设计进程的理念!==

为了能更好的认识这个概念!我们下面举个例子

有一个大富翁他资产100亿,他有很多的孩子,但是孩子之间都是彼此互不相认的,认为自己是独生子,而且孩子都有自己的工作!然后呢大富翁告诉每个孩子,只要他好好的工作,那么他的遗产就去全部给他(相当于给每个人都画了一张大饼),所以每个后代都认为自己将独占大富翁的所有家产!

image-20221220165132451

sun1,sun2,sun3都会因为各种原因向大富翁要钱!大富翁求他们的要求都是有求必应,但是可以肯定的是sun1,sun2,sun3肯定不会一次性一次性要100亿资产,只会小部分小部分的要

而这个我们将其换成操作系统

大富翁就是操作系统!100亿资产就是内存!

每个后代都是进程!——我们对系统要资源的时候,系统也都是有求必应

而且我们申请内存大小的时候也不会去一次性要所有的内存,我们只会一小部分一小部分的申请!

而大富翁给每个后代画的大饼就是——我们所说的==进程地址空间!==!——放在语言级别的就是虚拟地址空间!

==也可以说进程地址空间就是操作系统给每个进程画的大饼!==

image-20221220165931055

这种设计思想是来自现实

我们现实中也常常被画“大饼”,例如我们将钱放入银行,其实银行会降我们的钱拿去投资,放贷什么的之类的,我们账户上的数字就是银行给我们的大饼,因为我们大概率是不会一次性拿出所有的钱的!

所以银行最害怕的就是很多人一起取钱,因为这样子银行的实际资金就不够了!

那么如何画饼呢?

画饼的本质就是在大脑中构建一个蓝图——这就相当于==数据结构的对象!==

像是公司老板给某个员工画饼,说是你达成了什么,他就在什么时候给你什么奖励!

image-20221220172132280

每画一个饼就相当于多一个对象!像是这样

image-20221220172123290

但是如果画饼画太多!给每个人不同的饼也不好记!所以饼也要管理!

假如操作系统内有 500个进程!每个进程都有不同的进程地址空间!进程本身要被管理!

虚拟地址空间也是要被管理的!——操作系统通过先描述后组织来管理!

==所以地址空间的本质就是——内核的一种数据结构!mm_struct!==

mm_struct的成员

以32位计算机为例

首先我们要知道几件事

  1. 地址空间描述的基本空间大小是字节!
  2. 32为下 有2^32^个地址!
  3. 每个进程地址空间所占的空间范围为 1字节 ——4GB
  4. 每一个字节的都有唯一的地址对应!

地址本身最大的意义在于是它的唯一性!所以32位的bit刚刚好可以用来表示地址!

==所以用2^32^个数字来表示每个区域的过程称为编址==

从低地址的 0x00000000 - 0xFFFFFFFF

image-20221220175250994

==我们已经知道了虚拟地址空间不是真实的内存!那么我们应该怎么去理解虚拟地址空间上的区域呢?==

我们假设存在一个桌面和一个尺子!

我们使用尺子将桌子的分成一个个小单位,每个单位都标上不同的数字!

image-20221220202821787

此时我们就可以使用这些数字用来表达一个区域!

例如[0 ,8]就是一个区域![9,16]也是一个区域!==这样子我们就完成了对区域的划分!==

==我们调整区域也只要对数字进行加减即可!==例如我们将一半区域分给女生,一半区域分给男生

转化为计算机语言就是

image-20221220200516458

因为桌子的宽度是16所以这个结构体的默认总宽度也是16

这样我们就分别个给男生和女生划分的区域!

想要调整区域就是 对start end的值进行调整!

image-20221220200831507

每一个数字就代表了桌面的位置!每一个数字我们都可以当做是一个地址!

==而虚拟地址空间的区域就是这样表示的!==

==32位计算机里面就像是有了一个天然的尺子,虚拟地址空间的都默认了有2^32^个地址!单位是字节!==

所以mm_struct的成员我们就可以如下表示

struct mm_struct
{
    uin32_t code_start,code_end;//代码区
    uin32_t data_start,data_end;//数据区
    uin32_t heap_start,heap_end;//堆区
    uin32_t stack_start,stack_end;//栈区
    //.....
}

所以当给进程创建进程地址空间的时候实际上是在干嘛呢?

当一个进程开始的的时候,就会创建一个内核数据结构!系统会去申请一个空间(相当于malloc)!

然后开始去在这个数据结构里面去划分区域

image-20221220211326528

==所以这就是为什么每一个进程都认为自己独占所有的资源!因为虚拟地址空间就是默认(例如32位计算机)有2^32^个地址!==

==而这2^32^的每一个地址都是虚拟地址!==

这个mm对象就是操作系统画的大饼!有4G那么大!但是实际上并没有!

我们也可以实际看一下linux内核里面的struct_mm

image-20221220211901398

如何管理内存地址空间!

进程存在必然会存在一个内核数据结构(PCB)

而这个内核数据结构里面就存在一个指向struct_mm的指针!

即使通过这个task_struct来指向进程地址空间来管理的!

image-20221220213611016

linux内核PCB里面的指针

image-20221220212704459

进程地址空间和物理空间的对应!

我们已经知道了进程地址空间实际上不是真正的内存!而是一个数据结构!

那么我们数据是如何存在内存上面的呢?

我们的首先都知道,执行程序要加载首先要加载到磁盘上面!

image-20221221094515791

进程占内存一定的大小,占用的这一部分==内存区域==一定存在起始地址和结束地址!

==这个地址都是真实的物理地址!==

可是虚拟地址空间和真实物理地址是不同的!那么怎么让进程找到存在于内存的实际的代码呢?

==将虚拟地址空间和真实物理空间连接起来的就是——页表==

内存使用的时候在和磁盘进行输入输出的时候我们称为IO

==而这个IO的基本单位是4kb —— 4*1024bit——一共有4096个地址!==

image-20221221100000161

这个基本单位我们称之为page(页)

所以其实我们可以将内存当做是一个大数组!

struct page men[4GB/4KB];

==所以操作系统访问页里面的数据的时候只需要知道页的起始地址和其偏移量就可以知道其任意一个的地址的位置!==

那么虚拟地址是如何和物理地址通过页表连接起来的?

我们假设我们在堆上申请了一个空间!在里面保存了一个10的数据!

那么此时我们就会在进程地址空间里面修改堆的start和end的值来调整起始地址和结束地址!

image-20221221101511845

我们可以看到==通过页表,每一个虚拟地址和每一个的物理地址开始有了对应的关系!==

像是我们定义了一个char c = 10;

我们&c 得到的是c的虚拟地址!然后呢拿着这个虚拟地址去页表上找对应的物理地址!

最后在根据这个物理地址找到真实的内存空间最后进行读写!

==以上的所有步骤都是由操作系统替我们完成的==

页表实际是非常复杂的——实际是一个树状结构多级页表

==因为虚拟地址空间上的地址都是连续的!所以虚拟地址也会被叫做线性地址!==

现在我们将上面的举的大富翁的例子实例出来就是这样子

image-20221221104436181

为什么要有进程地址空间?

为什么不让进程直接访问物理空间?而是先访问虚拟地址空间再访问物理空间?绕了一大圈

  1. ==直接读取物理内存不利于数据安全!==——例如存在一个恶意进程,如果可以直接读取物理内存,只要把整个内存扫一遍,那么就可以读取到内存中的任意数据像是账号密码之类的!

  2. 如何进程直接访问物理内存那么一旦出现进程非法越界了该怎么办?image-20221221105006517==一旦我们对于其他的进程数据进行读写会很大概率会导致其他进程出错!==,如果存在了虚拟地址空间!当进程拿着一个非法的虚拟地址去页表进行读取!发现没有对应的物理地址的时候!==那么页表就可以直接建拦截!==(页表不仅仅是保存地址还有其他的功能),不允许访问物理地址!!——==所以因为有页表的存在,那么每个进程都只会映射到合法的内存中!==这样就可以不用担心物理内存被随意的读写了!像是老式计算机没有页表保护一旦出现越界访问可能就会导致整个系统崩溃!

  3. ==地址空间的存在,可以方便的进行进程和进程的数据代码的解耦,保质量进程独立性这样的特征!==

    这是什么意思我们要会过头看我们最开始的那个代码使用fork生成一个子进程,在地址相同的情况下却出现了两个值!

    现在我们都知道,这其实是因为这个两个进程有各自的进程地址空间,相同的只是虚拟地址!实际物理地址是不同的!

    但是其实在**一开始两个进程的虚拟地址空间指向的物理地址也是相同的!**那中间发生了什么?

    image-20221221155555780

    ==因为子进程是由父进程的创建的,所以基本上都是以父进程为模板进行创建的!==——所以那自然子进程的PCB和父进程的PCB也几乎都是一样的!它们的内存地址空间,页表也是几乎一样的!所以自然会指向同一块的物理地址!

    但是我们也知道!后面子进程发生了对于global_value的写入,如何继续这样下去就会影响到父进程!

    进程具有独立性!一个进程对被共享的数据进行修改!从而影响到了其他进程!那么就不可以称之为独立性了!

    例如父进程使用这个全局变量写了一个循环

    while(global_value == 100){//.......};
    

    那么一旦改变就会导致循环中断!

    ==所以一旦子进程对共享的数据进行写入!那么就会立刻发生写实拷贝!==

    image-20221221160259290

    操作系统会重新在物理内存上面开辟一个新的内部空间!然后将原来空间上面的所有数据都拷贝到这个新的内存空间上面来!

    最后将子进程的页表修改指向这个新的内存空间!让虚拟地址和新的物理地址重新映射

    后面子进程其实就是在新的内存空间里面进行修改!

    ==而且因为只是修改的页表的物理地址的一侧!对于虚拟地址是完全没有影响的!所以不存在空间改变后就无法使用的问题!==

    这就是为什么地址一样,但是值却不一样!

    ==这种任意一方尝试写入,操作系统先进行数据的拷贝,再更改页表的映射然后再让对应的进程进行修改的技术我们就称为写实拷贝!==——是操作系统自动完成的!

    操作系统为了保值进程的独立性,做了很多工作——通过地址空间,通过页表,让不同的进程映射到不同的物理内存处!

    ==同样的进程的的独立性也体现在有自己独立的内核数据结构!——独立的PCB,独立的地址空间!独立的页表!==

    ==通过写实拷贝来完成对于数据的独立!==

  4. 让进程进程以统一的视角来看待进程对应的代码和数据等个个区域方便使用!方便编译器也已统一的视角来进行编译代码!

一个负责编译,一个负责使用所以!编译完可以立刻使用!

重新认识进程地址空间!

我们上面说了那么多都是在内存里面进行谈论的!

那么有个问题==在还没有加载到内存里面的时候可执行程序里面存不存在地址呢?==

在我们编程写C语言代码的时候想要将其转换为可执行程序要经历——预处理——汇编——编译——链接——可执行程序!

image-20221221164328651

我们可以看一下反汇编后的结果!我们看到了什么?==没错是地址!==

也就是说在形成可执行程序之前!就已经存在地址了!

在链接时期也有所体现!

我们平时使用的C标准库stdio.h这个头文件!链接阶段就是将我们要用的库函数的地址填入我们的程序里面!

符号表的重定位也要用到地址!

==在可执行程序程序内存在被加载进内存之前就已经存在地址了!==——这个地址就是逻辑地址(虚拟地址/线性地址)!

==虚拟地址空间不止是OS要遵守对应的规则,编译器也要遵守对应的规则!==——编译器编译我们所写的代码的时候,==就是按照虚拟地址空间的方式对我们代码和数据进行编址!==

image-20221222103459222

像是代码区,数据区,==在磁盘里面就已经定好了虚拟地址是什么的样子!虚拟地址的值就已经是确定好的了!==——栈空间和堆空间因为是动态调整的所以没有

例如代码区!我们都知道调用函数需要使用到函数的地址!——在进程里面这个地址就是虚拟地址!

当可执行程序里面mian函数调用这个fun函数的时候!也只需要跳转到这个虚拟地址就可以!

**将这个可执行程序==加载到内存==的时候变成进程的时候!main函数调用这个fun函数!也是使用的是同样的虚拟地址!都不用改变!同样的逻辑fun函数调用这个变量a也是使用的是虚拟地址!——==是在进程内部使用的!==**!

image-20221222105012973

==要记住上面说的地址!是我们程序内部使用的地址!==——内部使用的地址都是虚拟地址(由32位编码而成)

进程内部进行代码跳转时,都是使用虚拟地址(32位进行编码)进行跳转!

而且当程序被加载到物理内存中的时候,该程序对应的指令和数据都==天然具有了物理地址!==

然后mm_struct可以根据==进程里面已经确定好的虚拟地址的值==来填充里面的里面的start和end的值,然后让==PCB指向该进程地址空间!==

image-20221222111356707

然后使用这些值来==对物理地址和虚拟地址使用页表进行映射!最后我们使用虚拟地址进行跳转也可以找到真正的代码所在的内存空间!==

所以此时的进程是有==两套地址!==

  1. 一个是物理地址用来表示代码和数据在内部中存放的位置!
  2. 一个是逻辑地址用来在程序内部进行跳转的时候使用的!

然后我们要开始使用CPU运行进程!但是使用CPU加载进程的时候,去寻找进程的代码使用的也不是物理地址!而是逻辑地址(虚拟地址)!

开始的时候CPU会将及进程地址空间里面的main函数的地址加载进CPU里面的eip寄存器,然后CPU根据这个地址去==查找页表==找到对应于的物理地址!访问相对应的物理内然后将里面的代码开始一行行的加载进CPU里面!

然后呢等CPU==运行到调用fun函数的指令==!!所以CPU就会将fun函数的==虚拟地址==加载进eip寄存器里面然后通过进程地址空间再通过页表去查找对应的物理地址所在!然后开始讲fun函数里面的代码一行行的加载进去!——==CPU读的都是指令!指令内部就存在地址(虚拟地址)!==

当fun函数要调用到变量a,那么首先CPU会先讲a的虚拟地址加载进cpu里面,然后经过进程地址空间,再到页表去找到对应的物理地址!然后找到物理内存上存放的变量a的数据!

这就是让进程进程以统一的视角来看待进程对应的代码和数据等个个区域!

无论是代码还是数据都是先加载指令,然后使用虚拟地址去页表找物理地址,去对应内部空间去拿数据/指令!

image-20221222112930483

==只要是执行进程里面的代码都是使用的是虚拟地址的逻辑!在通过页表映射找到代码的内存空间然后放进CPU里面执行!==

==这样就形成了一个闭环!==

==我们以前在编译器上经常能看见以32位,64位编译代码!现在我们也可以知道了!其实本质就是使用的虚拟地址的编址方式不同!==