虚拟地址空间:操作系统给进程画的“亿元大饼”

74 阅读7分钟

1. C/C++内存空间布局

通过下面代码打印的地址,可以看到地址分布大概如下面图片所示,栈和堆相对而生,在不同系统下验证可能会有不同结果,但打印出来的地址真的是物理内存么?并不是,而是进程地址空间,也叫做虚拟地址空间

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

int g_unval;
int g_val = 100;

int main(int argc, char* argv[], char* env[])
{
    const char* str = "helloworld";
    printf("code addr: %p\n", main);
    printf("init global addr: %p\n", &g_val);
    printf("uninit global addr: %p\n", &g_unval);

    static int test = 10;
    char* heap_mem = (char*)malloc(10);
    char* heap_mem1 = (char*)malloc(10);
    char* heap_mem2 = (char*)malloc(10);
    char* heap_mem3 = (char*)malloc(10);

    printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
    printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
    printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
    printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)

    printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
    printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)

    return 0;
}

image.png

image.png

2. 虚拟地址

从下面的代码就可以验证,fork之后,代码和数据共享。

#include <stdio.h>
#include <unistd.h>

int gval = 100;

int main()
{
    __pid_t id = fork();
    if(id == 0)
    {
        while(1)
        {
            printf("我是子进程, pid,%d, ppid:%d, gval:%d, &gval:%p\n", getpid(), getppid(), gval, &gval);
            sleep(1);
        }
    }
    else
    {
        while(1)
        {
            printf("我是父进程, pid,%d, ppid:%d, gval:%d, &gval:%p\n", getpid(), getppid(), gval, &gval);
            sleep(1);
        }
    }

    return 0;
}

image.png

下面对代码进行一点修改,子进程对全局变量gval进行修改。

#include <stdio.h>
#include <unistd.h>

int gval = 100;

int main()
{
    __pid_t id = fork();
    if(id == 0)
    {
        while(1)
        {
            printf("我是子进程, pid,%d, ppid:%d, gval:%d, &gval:%p\n", getpid(), getppid(), gval, &gval);
            sleep(1);
            gval++;
        }
    }
    else
    {
        while(1)
        {
            printf("我是父进程, pid,%d, ppid:%d, gval:%d, &gval:%p\n", getpid(), getppid(), gval, &gval);
            sleep(1);
        }
    }

    return 0;
}

image.png

那么问题来了,为什么父子进程同时读取同一个变量,甚至地址都是相同的,但值却是不同的呢?到这里虽然还不清楚为什么会有这种情况,但是可以肯定:这个地址根本不是物理内存的地址!而是虚拟地址

3. 进程地址空间

当代码和数据从磁盘加载到内存时,操作系统不仅仅会为进程创建task_struct,还会创建一个虚拟地址空间(地址从全0到全F)虚拟地址通过页表(负责虚拟地址和物理地址之间映射)物理内存建立映射关系

前面文章提到过:子进程创建是以父进程为模板。父进程通过fork函数创建子进程,于是子进程就拷贝了一份父进程的虚拟地址空间与页表(类似浅拷贝)。所以父子进程通过页表的映射关系,找到的是同一块物理内存(这就是为什么fork之后代码和数据共享),所以在上面没有更改gval前,打印出来的值是一样的。

但是上面的第二段代码中,子进程对gval修改后,打印出来的值不同,但是地址却是相同的,这是因为:进程之间具有独立性,一个进程的运行不能影响另一个进程,即使是父子进程。基于这个原则,子进程在对gval进行修改时,操作系统会先在物理内存开辟出一块大小一样的空间,拷贝内容,然后更改子进程虚拟地址空间到物理内存的映射关系,这个操作就叫做写时拷贝

这就是为什么会出现打印出来的值不同,但是地址是相同的这种情况,因为是虚拟地址相同,内容被映射到了不同的物理地址!并且以上的操作全部由操作系统完成,用户感觉不到。

image.png

通过下面的小故事来理解一下虚拟地址空间:

有一个身价10亿的富豪,他有着三个私生子,并且四个人之间都不知道对方的存在,于是大富豪对他们每个人都说了同样的话:“你好好努力,等我走之后10个亿全都由你继承。” 然后某一天:

  • 私生子1:父亲,我公司现在遇到了一些状况,需要一些钱周转,能把10个亿给我吗?
  • 富豪:我还没走呢你就惦记上了!我最多给你100万!
  • 私生子2:父亲,我现在需要交学费,可以给我点钱吗?
  • 富豪:没问题!
  • 私生子3:父亲,我需要买房子,可以给我拿一些钱吗?
  • 富豪:没问题!

所以富豪其实是给每一个孩子都画了一张大饼,因为知道他们不会一次性要走所有钱,但他们每个人仍然觉得自己已经拥有10亿了。

在这里富豪就是操作系统,给孩子们画的10个亿大饼就是虚拟地址空间,而每一个私生子就是进程。

随着进程越来越多,“大饼”(虚拟地址空间)越来越多,自然也需要管理起来,那么怎么管理?先描述,再组织。所以虚拟地址空间本质上是一个内核数据结构。要理解这个数据结构就需要继续往下理解空间划分。

4. 理解空间划分

大多数人小时候都和同桌划过“三八线”,这个行为就是划分区域。假设桌子长度是100,那么划分区域用数字表示就是[0,49],[50,99],用代码表示就如下。所以区域划分其实只要有线性空间的一段开始地址和结束地址就可表明一段范围

struct area
{
    int start;
    int end;
};

struct destop
{
    struct area me;
    struct area deskmate;
};

int main()
{
    struct destop t = { {0, 49}, {50, 99} };
    return 0;
}

并且虚拟地址空间是一个数据结构,即使不知道内部如何实现,但也可以知道:结构内部一定有着大量的 “start”和“end” 用来表示栈、堆等不同区域。即使需要扩大,也只需减少/增加“start”和“end” 的数字即可。

struct task_struct内部有一个结构体指针(struct mm_struct* mm),这就是虚拟地址空间。下面是Linux内核源码中的struct mm_struct的定义,圈起来的部分可以清楚看到各个区域的空间划分。在大多数语言中的空间划分(目录1中的内存空间布局图)其实就是这个结构。

image.png

5. 初识页表权限

通过上面了解到虚拟地址到物理地址是靠页表来映射的,在页表中其实还有一个权限的概念。

在学习语言的时候,都知道字符串具有常量属性无法修改,如果通过代码来看(目录1)会发现,字符串的地址和代码区的地址是相近的,所以在编译的时候,字符串是在代码区被编译的。

而在页表中代码区的虚拟地址到物理地址的权限是只读,所以为什么字符串无法修改?真正的原因就是页表的权限不允许。

image.png

既然有页表进行权限的约束,为什么像C语言等还要提供const关键字呢?其实const是用来约束编译器的,让编译器在编译代码的时候进行检查,如果发生写入操作,那么就会发生编译报错。反之不加const,在运行时进行写入操作,就会发生运行报错。

6. 为什么要有虚拟地址空间

  1. 因为有了虚拟地址,就必须转换为物理地址。设想一下如果没有虚拟地址,程序中的指针但凡出了错误就会可能影响到其他进程,就不能保证进程独立性,而通过虚拟地址到物理地址转换时进行安全审核(页表),就变相的保护了物理内存的安全。
  2. 将“无序”变“有序”,理论上可执行程序可以在物理内存的任意位置加载,但是进程站在虚拟地址空间来看代码和数据,全部都是“有序”,按各个区域划分好的,根据上面页表画的图来看就是左边是有序,右边可以随意映射到不同的物理内存处。