进程地址空间

一、地址空间

目录

一、地址空间

二、虚拟地址

三、地址空间究竟是什么?

(1) 什么叫做地址空间?

(2)什么是地址空间上的区域划分?

(3)为什么要有地址空间?


1.地址空间

我们在写代码时候使用的空间叫做地址空间,地址空间是被划分成用户空间和内核空间,内核空间放的是操作系统的代码,用户空间主要分为代码区、字符串常量区、已初始化全局变量、未初始化全局变量、堆区、栈区这6个区域,放的是用户写的代码,环境变量也是在用户空间的,并且是从低地址到高地址依次增长的。

2.验证地址空间 

接下来我们验证一下地址空间的确是被划分成如上图所示的6个区域,代码区直接打印main函数的地址就可以,我们直接定义一下对应的变量,然后区它们的地址拿出来看看。

#include <stdio.h>
#include <stdlib.h>

int goal_1; // 未初始化全局变量
int goal_2 = 1; // 已初始化全局变量

int main()
{
    printf("代码区: %p\n", main);
    char* a = "abcdef";
    printf("字符串常量区: %p\n", a);
    printf("已初始化全局变量: %p\n", &goal_2);
    printf("未初始化全局变量: %p\n", &goal_1);
    int* p1 = (int*)malloc(sizeof(int));
    int* p2 = (int*)malloc(sizeof(int));
    int* p3 = (int*)malloc(sizeof(int));

    printf("------------------------------------------\n");
    printf("堆区: %p\n", p1);
    printf("堆区: %p\n", p2);
    printf("堆区: %p\n", p3);

    int b1;
    int b2;
    int b3;

    printf("栈区: %p\n", &b1);
    printf("栈区: %p\n", &b2);
    printf("栈区: %p\n", &b3);


    return 0;
}

我们可以看到地址空间的确是从代码区到栈区依次增长的,其中我们可以看到堆区和栈区中间有一大块的空间。并且堆区的地址是从下往上依次增涨的,栈区的地址是从上往下依次降低的,堆和栈相对而生。不管是已初始化的还是未初始化的全局变量都统称全局数据区,所以为啥全局变量不随函数的调用完毕而是放呢?因为在全局数据区。

那静态变量在哪里存着呢,静态变量在全局数据区存着。因此static修饰的局部变量编译的时候已经被编译到全局数据区了,所以它才不会随着函数的调用而释放。


接下来我们看一个现象,下面这段代码,创建了一个进程,让父进程打印全局变量goal的地址和数值,子进程也打印全局变量goal的地址和值,子进程每打印一次cnt--,当cnt为0的时候,把goal的值修改为200。

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>


int goal = 10;

int main()
{
   int id = fork();
   
   if(id == 0)
   {
    int cnt = 5;
     while(1)
     {
        printf("我是一个子进程, &goal: %p, goal: %d\n",&goal, goal);
        sleep(1);
        if(cnt) cnt--;
        else{
            goal = 200;
        }
     }  

     
   }
   else
   {
        while(1)
        {
            printf("我是一个父进程, &goal: %p, goal: %d\n",&goal, goal);
            sleep(1);
        }

   }

    return 0;
}

接下来看一下这段代码的执行结果。

我们可以看到父进程和子进程打印的goal地址都是一样的,并且刚开始值都是一样的,这没问题,然后当子进程修改了全局变量goal之后,父进程打印的还是10,子进程打印的是200,这很好理解,因为这里发生了写时拷贝呀,但是,不理解的是,这地址居然是相同的,同一个地址,为什么打印出来的结果是不一样的呢?只能得出一个结论,这个结论就是我们所使用的地址空间不是真实的物理地址,而是虚拟出来的虚拟地址或者叫线性地址。

二、虚拟地址

当我们在创建一个进程的时候,不仅会给我们创建 task_struct结构体,还会给我们创建一个进程的地址空间和页表,这个地址空间不是物理地址,而是虚拟的。页表是一个key:val式的映射关系,左侧是虚拟地址,右侧对应的是物理地址,通过页表来将虚拟地址映射到真实的物理地址上。

当父进程定义了一个全局变量,假设它的地址是0x112233,该地址就会被填入到页表的左侧,然后在系统当中就要为该变量开辟一块物理内存,然后将该变量的物理内存地址填入页表的右侧,假设是0x445566。当进程访问这个地址时,操作系统会自动查页表,将虚拟地址转换到对应的物理地址就可以访问到物理地址当中的内容。

因此,当父进程创建了一个子进程,子进程要创建对应的task_struct、地址空间、页表,不过此时子进程的地址空间和页表都是从父进程拷贝过来的,所以子进程的地址空间和父进程的地址空间指向的就是同样的物理内存,所以在最开始的时候,父进程和子进程的数据都是同享的。

然后当子进程修改父进程的数据的时候,要发生写时拷贝,子进程要修改的变量goal的地址是0x112233,然后子进程把该地址写入,然后操作系统根据写入的地址查页表,找到对应的物理地址,就发现你要访问的物理地址是和父进程共享的,在你写入之前会重新给你划分一块空间,然后把历史的值给你拷贝过来,之后把你页表以前映射的物理地址修改为现在新划分的物理地址,然后再把那块新空间的地址上的值进行修改,

上述的操作左侧的虚拟地址是0感知的,左侧的虚拟地址还是0x112233,这就是为什么在上面子进程和父进程打印的地址是一样的,但值确不一样的原因,本质是父进程和子进程根据虚拟地址查到的物理地址是不一样的,所以结果也不一样。

所以,为什么环境变量是被所有子进程共享的?因为环境变量在父进程的地址空间里,子进程创建的时候会把父进程的地址空间拷一份,所有子进程不就看到父进程的环境变量了吗?

三、地址空间究竟是什么?

(1) 什么叫做地址空间?

当进程被CPU调度的时候,CPU才会去执行代码访问数据,CPU要访问数据就要去访问内存,那CPU怎么访问内存呢?内存和CPU都是硬件,必须要通过一根根的线连接起来,CPU访问内存就是通过线给内存硬件冲电的过程,我们说计算机只认识二进制0、1,其实是只认识电平,在内存硬件里有一个32位的地址寄存器,CPU发来的电都会被转换成01存放到这个地址寄存器中,如果有电对应的比特位就是1,没电就是0,然后内存根据这个地址寄存器的地址来把这个地址对应的数据给CPU。数据的拷贝就是一个设备通过总线给另外一个设备冲放电的过程。

如果地址总线有32根,每一根只有0、1两种状态,所以有2的32次方种组合,内存寻址的单位是字节,所以就有2 ^ 32 * 1btye也就是4GB的内存空间。

所以地址空间就是电脑的地址总线排列组合形成的地址范围[0, 2^32]。超出了这个范围是访问不了的。

(2)什么是地址空间上的区域划分?

区域划分就是界定边界,哪一段范围内的地址是我的, 哪一段范围内的地址是你的。用计算机语言描述一下区域划分就是用struct定义一个结构体,结构体里面有两个字段,一个start,一个end,start记录开始地址,end记录结束地址,当你超过end,你就越界了。

所以,空间变大和变小,就是start和end的变大变小。

我们不仅要看到start和end,也要看到这之间的地址,在start和end之间的地址,都是可以被使用的,可以自由的访问。

所以地址空间是什么呢?地址空间,本质是一个描述进程可视范围的大小

地址空间也要在内核中创建一个数据结构管理起来,类似PCB一样。这个结构对象叫做 struct mm_struct。它里面管理了各个区域的起始地址和结束地址。

(3)为什么要有地址空间?

地址空间其实也是操作系统给每个进程画的一张大饼,虽然每个进程都能看到一个4GB的地址空间,但每个进程是不可能把地址空间用完了,就算你要4个G操作系统也不会给你。

理由1.让进程以统一的视角看待内存

有了地址空间,我们不需要知道对应的物理地址在哪里,物理地址可能根本不是连续的,而是东一个西一个,物理地址可以乱序随便去填,但是对应的虚拟地址是有序的,这就叫以统一的视角看待内存,如果一会可执行代码在西,一会可执行程序代码在东,这个进程想管理代码就乱完了。

理由2.增加进程虚拟地址空间可以让我们访问内存的时候,增加一个转换的过程,这个转换的过程中可以对寻址请求进行审查,所以一旦异常访问,直接拦截,该请求不会到达物理内存,从而保护物理内存。

如果没有地址地空间,我们直接访问内存,但是加上地址空间,你要访问内存,必须先经过映射。为啥要这么做呢?因为这么做可以避免进程非法访问。比如说这个进程要访问的内存根本就不属于你,然后你还要访问,如果没有地址空间,你访问不属于你的数据就可能影响其它进程,但是有地址空间就不一样了,地址空间先查页表,发现你要访问的内存根本就不在页表里,或者要访问的内存是只读的你却要写,从而直接拦截你的非法操作。 

那问题就来了,是如何对非法的请求进行拦截的呢?是通过页表的权限标记位。

1.页表的地址

页表要不要地址?当然要呀,那页表的地址能是虚拟的吗?不能,页表的地址必须是物理地址。那我到哪里去找页表的地址呢?CPU中有一个cr3寄存器,里面存放的是页表的起始地址。每个进程都有自己的页表地址。

2.页表的权限

页表怎么知道对应的物理地址是可读的还是可写的还是可执行的呢?所以页表条目中还要有标志位,来标记对应的物理内存是可读还是可写的。如果你要访问的地址是只读的,你却要写,操作系统会直接拦截你。

3.为啥对字符串常量区写会出错?

我们以前学C语言知道代码和字符常量区是只读的,如果是只能读的,那它第一次是咋写到内存中的呢?物理内存中根本没有只读只写的概念。字符串常量区只读是因为你查页表的时候权限是只读的,因此你根本不能访问,所以你想写的时候才会拦截你。

4.你怎么知道进程被挂起了?

进程是可以被挂起的,你怎么知道你的进程已经被挂起了?你怎么知道你的代码数据在不在内存呢?通过一个标记位。

操作系统几乎不会做任何浪费时间和浪费空间的事情。我们玩LOL、DATA2,黑神话,那些游戏十几个G,甚至上百个G,内存才16个G,因此不可能一次性全部加载到内存,也就是说只能分批加载,先加载上500M,先让你跑,然后其它内存被释放了在加载另外的部分。但是,即使你加载了500M空间,但代码是一行一行跑的,可能我短期只能只能用5M,剩下495M用不用提前加载到内存呢? 因为CPU时间片、进程调度,因此短期根本不能把你要加载的数据跑完,跑不完那剩下的空间是不会被使用的,我把空间给你,又不会被你使用,不就是一种时间和空间的浪费吗,因此操作系统采用的加载方式是惰性加载,我将来承诺给你这么大空间,但基本上是你用多少我给你多少,我不会上来就给你500M。

页表里面还会有一个标志位,来标志对应的代码和数据是否已经被加载到内存中

如果你要访问内存,先看标志位,如果标志位是1,表明你的代码和数据已经加载到内存,那直接访问对应的物理内存。如果标志位是0,表明你的代码和数据没有加载到内存,在磁盘里,此时会触发缺页中段——操作系统会在磁盘里找到你的代码,然后再物理地址中给你申请一份内存,把对应页表中的物理地址填上去,之后你在通过页表映射访问对应的物理内存。

写时拷贝其实就是缺页中断。

所以我是怎么知道进程是否被挂起了,代码数据在磁盘里呢?根据标志位。

5.进程再被创建的时候,是先创建内核数据结构?还是先加载对应的可执行程序呢?

先创建内核数据结构,可执行程序后面慢慢加载。

6.如何理解进程间的独立性?

进程之间的独立性体现在,每个进程都有自己的task_struct、地址空间、页表,即使刚开始子进程和父进程代码是共享的,但由于页表的转化,写实拷贝等存在,就可以保证每个进程的数据是自己私有的。

理由3:让进程管理和内存管理解耦

如果进程缺页中断要申请内存,重新映射页表,那请问申请那一块内存呢?物理内存这么大,哪一块给你呢?可执行程序加载哪一部分呢?加载到物理内存哪里呢?内存申请好,加载完成了,那页表什么时候填呢?怎么做呢?操作系统来做,上面的全部都属于内存管理模块。

进程知道内存怎么申请空间、释放空间什么的吗?根本不知道,也不需要知道,用就行了,内存不够缺页中断都由内存管理负责,这样就做到了进程管理和内存管理解耦。

是不是只有C/C++看不到物理内存呢?是所有的语言都看不到物理内存,不管什么JAVA、python、PHP,所有语言都看不到物理内存,只能看到虚拟内存。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值