Bootstrap

Linux进程地址空间

了解虚拟地址

在学习C语言的时候,经常会说到地址,这里的地址我们学习的时候就认为是内存中的地址。但是实际上,这个并不是内存的地址,只是有操作系统的存在,使用这个地址就能对特定的地址完成读写。这里的地址实际上是虚拟地址
假设内存是4G,那么对于用户就是下面的空间排布。每一个进程都认为自己有4G的内存可以使用。
在这里插入图片描述
这段空间中自下而上,地址是增长的,栈是向地址减小方向增长(栈是先使用高地址),而堆是向地址增长方向增长(堆是先使用低地址),堆栈之间的共享区,主要用来加载动态库。

验证地址空间的基本排布:

#include<stdio.h>
#include<stdlib.h>
int g_unval;//未初始化
int g_val = 100;//初始化
int main(int argc,char *argv[],char *env[])
{
    printf("code addr:           %p\n",main);//代码区起始地址
    const char* p = "hello bit";//p是指针变量(栈区),p指向字符常量h(字符常量区)
    printf("read only :          %p\n",p);
    printf("global val:          %p\n",&g_val);
    printf("global uninit val:   %p\n",&g_unval);
    char *q = (char *)malloc(10);
    printf("heap addr:           %p\n",q);
    
    printf("stack addr:          %p\n",&p);//p先定义,先入栈
    printf("stack addr:          %p\n",&q);
    
    printf("args addr            %p\n",argv[0]);//命令行参数
    printf("args addr            %p\n",argv[argc-1]);
    
    printf("env addr:            %p\n",env[0]);//环境变量
    return 0;
}

在这里插入图片描述
我们可以看到代码区的地址是最小的,这里就验证了地址空间的基本排布:p和q都是定义在栈区的,p先定义,先入栈,可以看到p的地址大于q,说明了栈是先使用高地址再使用低地址。

进程地址空间,会在进程的整个生命周期内一直存在,直到进程退出。这也就解释了全局变量为什么会一直存在,原因是未初始化数据,初始化数据,这些区域是一直存在的。

所有的可以看到的地址都是虚拟地址。

了解进程地址空间

下面我们通过一个代码来看一个现象,我们定义了一个全局变量,fork创建一个子进程,让父进程和子进程完成自己的任务,在子进程中定义count来计数,当子进程的打印任务进行到第五次时,让子进程将这个全局变量改成100:

#include<stdio.h>
#include<unistd.h>
int g_val = 0;
int main()
{
    printf("begin.....%d\n",g_val);
    pid_t id = fork();
    if(id==0)
    {
        //child
        int count = 0;
        while(1)
        {
    		printf("child: pid: %d,ppid: %d, g_val:%d, &g_val: %p\n",getpid(),getppid(),g_val,&g_val);
            sleep(1);
            count++;
            if(count == 5)
            {
                g_val = 100;
            }
        }
    }
    else if(id>0)
    {
        //father
        while(1)
        {
             printf("father: pod: %d,ppid: %d, g_val:%d, &g_val: %p\n",getpid(),getppid(),g_val,&g_val);
            sleep(1);
        }
    }
    else
    {
        //todo
    }
    return 0;
}

在这里插入图片描述
代码共享,所以看到前五次打印的g_val的地址都是一样的,这我们不意外,等到了第六次时,我们发现父进程g_val依然是0,子进程的g_val变成了100,因为我们将它改了,这也不意外,因为前面说了,父子进程之间代码共享,而数据是各自私有一份的(写时拷贝),但是令人奇怪的是地址竟然是一样的!
如果我们看到的地址,是物理地址,这种情况可不可能呢?
所有这就证明了我们所看到的地址不是物理地址,这种地址就是虚拟地址。但是我们的数据是存在内存中的,就是物理内存中。所有一定会存在操作系统将虚拟地址转化为物理地址。
所有我们写的代码在运行起来就变为了进程,所有程序地址叫做进程地址更为准确。每个进程都有自己的进程地址空间。

在Linux当中,进程地址空间本质上是一种数据结构,是多个区域的集合。
在Linux内核中,有这样一个结构体:struct mm_struct,在这个结构体去表示我们开始说的一个一个的区域呢?这样去表示:

struct mm_struct
{
    unsigned long code_start;//代码区
    unsigned long code_end;
    
    unsigned long init_start;//初始化区
    unsigned long init_end;
    
    unsigned long uninit_start;//未初始化区
    unsigned long uninit_end;
    
    unsigned long heap_start;//堆区
    unsigned long heap_end;
    
    unsigned long stack_start;//栈区
    unsigned long stack_end;
    //...等等
}

源码是这样的:
在这里插入图片描述

当每个区域的空间不够,就会修改对应的endstart
这些所有的结构体变量表示的范围就是进程地址空间。 每一个进程都会有一个这样的结构体变量,去控制它的地址空间范围。
每个进程的进程地址空间范围都是4G,假设内存是4G。都会认为自己独占内存。这也就说明了为什么会出现两个相同的地址,因为虚拟地址,进程可以使用任何一个在4G大小内的地址,这个地址可以一样,因为是两个进程。虚拟地址通过映射,映射到不同的物理地址。

操作系统为每一个进程创建一个地址空间,地址空间在操作系统内部,我们所看到的地址也都是地址空间的地址。每个进程的 task_struct 中存在指向其 mm_struct 结构体的指针

地址空间的本质就是操作系统给进程画的大饼。告诉进程有多少物理内存,然后所有的物理内存都可以使用,只是使用的是虚拟的,实际上操作系统在映射的时候就映射到实际的内存了。使用VS编程的时候使用的32位,这样虚拟地址就限制在了32位了。在实际映射的时候,再进行映射。

认识页表

虚拟地址和物理内存上的物理地址之间建立关系的东西叫做页表,页表用来映射虚拟地址和物理地址。
每个进程都有自己的进程地址空间,操作系统进行管理,地址空间本质上就是内核中的一个结构体对象。
我们的程序在编译为二进制的时候,就会存在各种各样的地址,每个函数有他的地址,每个变量也有它的地址。这些在编译的时候就已经存在。当进程的代码和数据加载到内存的时候,各种地址先加载到页表,然后页表再映射到物理内存。所有之前学习的地址,使用虚拟地址也是没错的,因为页表会帮我们映射到物理地址。而且我们也无法得到真正的物理地址。
在这里插入图片描述

写时拷贝

当我们创建一个子进程的时候,并不是所有的数据都会拷贝一份,而是按需申请。
创建子进程,子进程也有它的PCB,需要虚拟地址空间和页表。因为子进程的资源是拷贝的父进程的资源,子进程的页表同样也是拷贝的父进程的。所以父子进程打印的地址是一样的,实际指向的物理地址也是一样的。只有在子进程指向的地址要修改变量的时候,才会开辟一个新的空间,虚拟地址不变,重新映射一个新的物理地址。这叫写时拷贝

为什么不在刚创建进程的时候就拷贝一份呢?因为不是所有的资源都会修改的。大部分不修改的资源父子共享。在修改的时候再进行拷贝,达到有效节省空间的目的。

为什么要存在地址空间

  1. 让无序变得有序,让进程以统一的视角看待物理内存。
  2. 进程管理模块与内存管理模块之间进行结构

如果没有进程地址空间,进程直接访问物理内存,当进程退出时,内存管理需要尽快将该进程回收,在这个过程当中必须得保证内存管理得知道某个进程退出了,并且内存管理也得知道某个进程开始了,这样才能给他们及时的分配资源和回收资源,这就意味着内存管理和进程管理模块是强耦合的,也就是说内存管理和进程管理关系比较大,通过我们上面的理解,如果有了进程地址空间,当一个进程需要资源的时候,通过页表映射去要就可以了,内存管理就只需要知道哪些内存区域(配置)是无效的,哪些是有效的(被页表映射的就是有效的,没有被页表映射的就是无效的),当一个进程退出时,它的映射关系也就没了,此时没有了映射关系,物理内存这里就将该进程的数据设置为无效,所以第二个好处就是将内存管理和进程管理进行解耦,内存管理是怎么知道有效还是无效的呢?比如说在一块物理内存区域设置一个计数器count,当页表中有映射到这块区域时,count就++,当一个映射去掉时,就将count–,内存管理只需要检测这个count是不是0,如果为0,说明它是没人用的。

没有进程地址空间时,内存也可以和进程进行解耦,但是代码会设计的特别复杂,所以最终会有进程地址空间

  1. 在早些时候是没有地址空间的,就是进程的PCB直接管理进程地址空间的。

此时如果进程直接访问物理内存,如果指针越界了,一个进程的指针指向了另一个进程的代码和数据,那么进程的独立性,便无法保证,因为物理内存暴露,其中就有可能有恶意程序直接通过物理地址,进行内存数据的篡改,如果里面的数据有账号密码就可以改密码,即使操作系统不让改,也可以读取。

后来就发展出来了虚拟地址空间,那么虚拟地址空间如何避免这样的问题呢?

由上面我们所了解的知识,一个进程有它的task_struct,有地址空间,有页表,页表当中有虚拟地址和物理内存的映射关系,有了页表的存在,虚拟地址到物理地址的一个转化,由操作系统来完成的,同时也可以帮系统进行合法性检测。
而且mm_struct里面是地址范围,同时也在页表的左侧,当越界后无法映射到页表,自然无法访问物理内存。

我们写代码的时候肯定了解过指针越界,我们知道地址空间有各个区域,那么指针越界一定会出现错误吗?

不一定,越界可能他还是在自己的合法区域。比如他本来指向的是栈区,越界后它依然指向栈区,编译器的检查机制认为这是合法的,当你指针本来指向数据区,结果指针后来指向了字符常量区,编译器就会根据mm_struct里面的start,end区间来判断你有没有越界,此时发现你越界了就会报错了,这是其中的一种检查,第二种检查为:页表因为将每个虚拟地址的区域映射到了物理内存,其实页表也有一种权限管理,当你对数据区进行映射时,数据区是可以读写的,相应的在页表中的映射关系中的权限就是可读可写,但是当你对代码区和字符常量区进行映射时,因为这两个区域是只读的,相应的在页表中的映射关系中的权限就是只读,如果你对这段区域进行了写,通过页表当中的权限管理,操作系统就直接就将这个进程干掉。

进一步理解页表和写时拷贝

需要了解的是CPU中有特定的寄存器保存当前进程的页表的地址。CPU中存在一个模块叫MMU,MMU负责将虚拟地址结合页表转化为物理地址。

页表中存在一些标志位,这些标志位有读写权限,这就是为什么常量区无法进行写操作。还有标志进程是否在内存中。这个是否在内存中来说明是否是挂起态,如果是,那么映射的物理地址就是其他进程的,操作系统拒绝这个进程访问。
在这里插入图片描述
当发生权限冲突时,操作系统会进行检查。如上面的g_val,当父子进程使用一个变量的时候,页表中对应的地址的权限就变为了只读。此时如果进行修改,就会冲突。此时操作系统会进行检查,看是否要需要进行写时拷贝。
在这里插入图片描述

;