虚拟内存空间

477 Views

观察下面这段代码

size_t all_size = 4000000000;
int *arr = (int*)malloc(all_size);
//memset(arr, 0, all_size);

目前为止程序是可以编译和运行的
但是当我们取消掉注释

memset(arr, 0, all_size)

现在的程序出现了
Segmentation fault (core dumped)
原因在于进程开始申请内存时,并不会立刻获得实际的物理内存,而是得到了一份虚拟内存,虚拟内存只会保留部分有用的信息,而只有在该内存被使用到的时候(例如初始化或者读写操作)才会真正的在物理内存中申请并占用

为什么不能直接申请物理内存?

  1. 如果所有的内存在声明时就直接从物理内存中申请占用了,即使一些暂时可能不需要的内存也被一次性占用,这会导致内存中大部分时间存放的几乎都是未被使用到的数据,大大降低了内存的可用性
  2. 直接针对物理内存操作,可能会影响到其他不相干的程序,降低程序的可维护性。例如进程A的一次栈溢出(例如数组越界)产生的脏数据写坏了另一个进程B的内存,导致进程B莫名其妙产生了程序异常。
  3. 进程的部分公有的属性(如库函数等)应当以共享的方式存放在内存中(每个进程都把相同的代码单独保存到自己管理的内存中是非常浪费的),直接使用物理内存难以表达和区分公有内存和私有内存

什么是虚拟内存

虚拟内存其实是磁盘中的一部分空间,它管理自己独立的内存地址,并保存虚拟内存地址到物理内存地址的映射关系,这个地址的寻址范围取决于机器的位数(如32位机器是4GB,64位机器是256TB)。因为每个进程的虚拟内存空间是完全独立的,所以它们都可以使用 0x0000000000000000 到 0x00007FFFFFFFFFFFF 代表他们所占用的全部内存,重复也不会相互影响

页表

虚拟内存的维护是基于名为页表的功能来完成的。

  1. 当一个程序启动时会生成一个初始的页表(PTE有效位全为0)
  2. 执行到诸如malloc等功能的函数,直接将页表对应的PTE置为1,同时对应地址填写为虚拟内存地址
  3. 执行到访问内存的操作时,如果该内存指向地址是虚拟内存地址,则会触发一个缺页中断,使用页面置换算法从物理内存中取出一块区域拷贝虚拟内存对应的内容,并将对应地址修改为实际的物理内存地址

权限管理

页表中实际还包含了一些标志位用来做权限的划分
SUP表示是否允许非内核态(用户模式)访问
READ表示是否允许读内存
WRITE表示是否允许写内存
从图中可以看出,想要使两个程序共享物理内存,只需要将他们页表中的某一项地址指向同一个物理内存地址(如PP6),再通过WRITE和READ规定谁是该内存的可改写者。
如果程序的某一次操作违背了权限,例如访问一个不可以READ的内存或者写一个不可以WRITE的内存(最常见的原因是程序的逻辑错误导致内存泄露或溢出),linux系统会产生一个段错误(segmention fault)

Linux虚拟内存

进程的虚拟内存分为两部分,分别是内核级虚拟内存和进程级虚拟内存

最顶部的0-0x40000000默认不使用
bss、text、data也就是各面经常提的常量区了,包含静态变量、常量、代码段等
从指针brk开始向上的一部分区域(低地址->高地址)为堆区
指针rsp向下的一部分区域(高地址->低地址)为栈区
共享库的内存映射区域,本质上和其他私有区域一样,但是在概念上只允许必须被需要多个进程间共享的内容使用,例如公共库

守护进程

为了后面代码的调试方便,需要涉及到守护进程的概念。守护进程指的是系统中不在前台运行的进程,他们在shell命令结束后依然可以长期在后台运行。
C++中一种最简易的守护进程实现方法为父进程fork一个子进程后退出,接下来的工作交由子进程继续处理

pid_t fpid;
fpid = fork();
if (fpid < 0)
{
    cout << "fork err "<< fpid << endl;
    return 0;
}
if (fpid > 0)
{
    cout << "i am father " << getpid() << ", son is " << fpid  << endl;
    return 0;
}
//now this is son progress

Linux进程地址分析

在Linux系统环境下,我们启动C++程序会得到对应进程的pid。在目录/proc/pid下会保存进程的运行信息,其中maps文件表示该进程的虚拟内存地址。
基于之前的守护进程代码,我们在子进程中添加栈变量和堆变量打印,并在最后添加while循环保证子进程常驻后台

int *malloc_brk_address = NULL;
int *malloc_mmap_address = NULL;
int stack_arr_address[10] = {};

malloc_brk_address = (int*)malloc(1024 * 128 - 1);
malloc_mmap_address = (int*)malloc(1024 * 128 + 1);

vector<int> my_vector;
my_vector.push_back(1);

cout << "stack_arr_address   " << stack_arr_address << endl;
cout << "stack_arr[end]      " << &stack_arr_address[9] << endl;
cout << "malloc_brk_address  " << malloc_brk_address << endl;
cout << "malloc_brk          " << &malloc_brk_address << endl;
cout << "malloc_mmap_address " << malloc_mmap_address << endl;
cout << "malloc_mmap         " << &malloc_mmap_address << endl;
cout << "vector_address      " << &my_vector << endl;
cout << "vector[0]           " << &my_vector[0] << endl;
//cout << "i am son" << getpid() << endl;
while(1)
{
    //keep running son progress
    sleep(10);
}

根据获得的子进程pid(这里我的pid是29301),使用命令

cat /proc/29031/maps

结合代码中打印的变量地址可以得到如下的分布表,各变量位于的虚拟内存区域已经用不同颜色对应标注出来

匿名映射

可以发现在堆[heap]和栈[stack]中分配的部分,他们的inode和设备号均为0,这是因为linux内存映射过程中使用了匿名映射的方法。匿名文件不是磁盘中实际存在的文件,它是由内核创建的,包含的全部是二进制零。

权限属性

/lib/x86_64-linux-gnu/ld-2.23.so文件在虚拟内存空间中出现了三次,他们唯一的不同在于权限,从上至下权限分别为
r-xp(可写 可执行 私有)
r--p(可写 私有)
rw-p(可写 可读 私有)
共享内存和私有内存的区别在于权限的最后一位是p(私有)还是s(共享)

内存申请

栈地址为7fffcdf57000-7fffcdf78000,代码中申请的临时变量如malloc头指针、vector指针都处于栈空间内,vector中具体的元素和malloc申请的空间都存放在堆中
为了保证可以使用++迭代器访问数组等内存连续数据,数组的头部元素分配在栈中的低地址,而尾部元素在高地址(参考stack_arr[end]和stack_arr_address)

Malloc和Free函数

从表中可以看出malloc会根据申请的空间大小自动调用不同的分配函数,当申请空间小于128k时调用brk,否则调用mmap(参考malloc_brk_addres和malloc_mmap_addres地址存在明显的差异)
brk会从堆顶空间区域申请和释放内存,而mmap会从堆和栈之间的这部分区域寻找空闲区并分配。申请内存时遵循内存对齐原则。
申请和释放内存的过程如下图

值得一提的是,使用mmap申请的内存在free时会使用mummap释放,但是brk申请的内存并没有真正的释放,而是仅仅清除了数据,此时brk指针依然指向堆的顶端

留下回复

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据