虚拟内存的使用

虚拟内存的使用

内存映射

内存映射指的是Linux通过将一个虚拟内存区域和一个磁盘上的对象关联起来,从而初始化这片虚拟内存区域的内容

虚拟内存可以映射到两种类型的对象中的一种:

  • Linux文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行文件。文件区被分成页大小的片,每一片包含一个虚拟页面的初始内容。因为按需页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到CPU第一次引用到页面。如果区域比文件区大,那么就用零来填充区域剩下部分
  • 匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制0。CPU第一次引用到这个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过就把这个页面换出来,用二进制0覆盖牺牲页面并更新页表,将这个页面标记为驻留在内存中的。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制0的页

匿名文件:

文件名:无
路径:无
你在磁盘上找不到它
它只存在于内核的内存管理中
内容全是0

为什么不需要文件名?

  • 因为内容全是零,不需要存储到磁盘
  • 只是内核用来管理内存的一个”概念”

在内存映射中的使用场景

1.程序的BSS段

#include <stdio.h>

int global_array[1000000];  // 未初始化,4MB

int main() {
    global_array[0] = 10;  // 第一次访问时才分配物理内存
    return 0;
}

系统处理:

  • 编译后,这4MB不会占用可执行文件大小
  • 运行时,映射到匿名文件(全是0)
  • 只有访问时才分配真实内存

2.malloc分配的内存

char *buffer = malloc(10 * 1024 * 1024);  // 申请10MB
// 此时只是虚拟内存映射,没有真实物理内存

buffer[0] = 'A';  // 第一次访问,触发分配物理内存
buffer[1024] = 'B';  // 第二次访问,可能分配新页面

3.进程的栈

void function() {
    int local_var[1000];  // 局部变量
    // 栈空间也是通过匿名映射实现的
}

无论是普通文件映射还是匿名文件,一旦一个虚拟页面初始化了,它就在一个由内核维护的专门的交换文件/交换空间(swap file/swap space)之间换来换去。在任何时刻

交换空间都限制着当前允许着的进程能够分配的虚拟页面的总数

共享对象

一个对象(本身在磁盘上)可以被映射到虚拟内存的一个区域, 要么作为共享对象,要么作为私有对象

  • 对于共享对象:如果一个进程将一个共享对象映射到它的虚拟地址空间的一个区域内,那么这个进程对该区域的任何写操作,对于那些也把这个共享对象映射到它们虚拟内存的其他进程而言,也是可见的而且这些变化也会反映在磁盘上的原始对象中
  • 对于私有对象:对于一个映射到私有对象的区域做的改变,对于其他进程来说是不可见的,并且进程对这个区域所做的任何写操作都不会反映在磁盘上的对象中

共享对象的映射

Snipaste20260319111808png

如图当进程1把一个共享对象映射到它的虚拟内存的一个区域中,然后进程2页将同一个共享对象映射到它的地址空间。

因为每个对象都有一个唯一的文件名,内核可以迅速地判定进程1已经映射了这个对象,于是可以直接使进程2中的页表条目指向相应的物理页面。

即使对象被映射到了多个共享区域,物理内存中也只需要存放共享对象的一份数据

私有对象的映射

私有对象使用写时复制的技术被映射到虚拟内存中。

Snipaste20260319113526png

一个私有对象的开始生命周期方式基本上与共享对象相同,在物理内存中只保留由私有对象的一份副本

如图两个进程将一个私有对象映射到他们虚拟内存不同区域,但是共享这个对象的同一个物理副本。对于每个映射私有对象的进程,相应私有区域的页表条目被标记为只读,并且区域结构被标记为私有的写时复制

只要没有进程试图写自己的私有区域,它们就可以继续共享物理内存中对象的同一个副本。然而只要有一个进程试图写私有区域的某个页面,那么这个写操作就会触发一个保护故障

当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域中的一个页面而引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新副本,然后恢复这个页面的可写权限。当故障处理程序返回时,CPU重新执行这个写操作,现在在新创建的页面上这个写操作就可以正常执行了

注意:不管是原来的私有对象副本还是新创建的页面,都遵循”惰性分配”机制,也就是当进程不用这块区域时,操作系统是会把原本的副本和新创建的页面都移入磁盘的

进程2写页面 → 触发COW → 创建新物理页面
                    ↓
         这个新页面同样受页面置换算法管理
                    ↓
         如果长时间不访问 → 被换出到磁盘
                    ↓
         再次访问时 → 缺页中断 → 从磁盘换入

关键点:

  • 写时复制只是在需要写入时才创建物理副本
  • 这个副本一旦创建,就和其他普通页面一样管理
  • 可以被换出、换入,完全透明

总结: 写时复制优化的是”复制的时机”,而页面置换优化的是”物理内存的使用”,两者是正交的机制,共同协作来高效管理内存。

fork函数机制

通过上面的内存映射,在来看fork函数的机制就比较清晰了:

fork函数被当前进程调用时,内会会为新进程创建各种数据结构并给他分配一个唯一的PID。操作系统创建该新进程的虚拟内存时,直接复制了当前进程的mm_struct,区域结构和页表等,并且将两个进程的每个页面都标记为只读,并且将两个进程中的每个区域结构都标记为私有的写时复制

当fork在新进程中返回时,新进程现在的虚拟内存刚好和调用fork时存在的虚拟内存相同。当两个进程中的任一个在之后进行写操作时,写时复制机制会创建新页面

execve函数机制

当execve执行,加载并允许一个新的可执行文件时,会进行以下几个步骤:

  • 删除已存在的用户区域:删除当前进程虚拟地址的用户部分中已存在的结构
  • 映射私有区域:为新的程序的代码,数据,bss和栈区创建新的区域结构。所有这些新区域都是私有的,写时复制的。代码和数据区域被映射为可执行文件中的.text和.data段,bss段时请求二进制0的,映射到匿名文件。栈区,堆区也是请求二进制0的,初始长度为0
  • 映射共享区域:如果可执行文件和共享对象链接,那么这些对象都是动态链接到这个程序的,然后映射到用户虚拟地址空间中的共享区域内
  • 设置程序计数器(PC)execve最后做的就是设置当前进程上下文中的程序计数器,使之指向代码区域的入口点

当操作系统下一次调度这个进程时,它将从这个入口点开始执行,并根据需要换入代码和数据页面

Snipaste20260319140550png

mmap函数

Linux进程可以使用mmap函数来创建新的虚拟内存区域,并将对象映射到这些区域中。

#include <unistd.h>
#include <sys/mman.h>

void *mmap(void *start,size_t length,int port,int flags,int fd,off_t offset);
  • 若成功返回指向映射区域的指针,若失败则为MAP_FAILED(-1)

mmap函数要求内核创建一个新的虚拟内存区域,最好是从start开始的一个区域,并将文件描述符fd指定的对象的一个连续的片映射到这个新区域

  • 连续的对象片大小为length,从距离文件开始处偏移量为offset字节的地方开始。start地址仅仅是一个暗示,通常被定义为NULL
Snipaste20260319142313png
  • 参数prot包含描述新映射的虚拟内存区域的访问权限位(即在相应区域结构中的vm_prot位)
    • PROT_EXEC:这个区域的页面由可以被CPU执行的指令组成
    • PROT_READ:这个区域内的页面可读
    • PROT_WRITE:这个区域内的页面可写
    • PROT_NONE:这个区域内的页面不能被访问
  • 参数flags由描述被映射对象类型的位组成。
    • MAP_ANON:被映射的对象就是一个匿名对象,而相应的虚拟页面时请求二进制0的
    • MAP_PRIVATE:表示被映射的对象是一个私有的,写时复制的对象
    • MAP_SHARED:表示是一个共享对象

示例:

 /* 1. 打开文件 */ 
      fd = open("/dev/hello", O_RDWR); 
      if (fd == -1) 
      { 
              printf("can not open file /dev/hello\n"); 
              return -1; 
      } 

      /* 2. mmap 
       * MAP_SHARED  : 多个APP都调用mmap映射同一块内存时, 对内存的修改大家都可以看到。 
       *               就是说多个APP、驱动程序实际上访问的都是同一块内存 
       * MAP_PRIVATE : 创建一个copy on write的私有映射。 
       *               当APP对该内存进行修改时,其他程序是看不到这些修改的。 
       *               就是当APP写内存时, 内核会先创建一个拷贝给这个APP, 
       *               这个拷贝是这个APP私有的, 其他APP、驱动无法访问。 
       */ 
    buf =  mmap(NULL, 1024*8, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);  
    if (buf == MAP_FAILED) 
    { 
          printf("can not mmap file /dev/hello\n"); 
          return -1; 
    }

    strcpy(buf,"new");

    /* 4. read & compare */
    read(fd, str, 1024);  
    if (strcmp(buf, str) == 0)
    {
        /* 对于MAP_SHARED映射,APP写的数据驱动可见
         * APP和驱动访问的是同一个内存块
         */
        printf("compare ok!\n");
    }
    else
    {
        /* 对于MAP_PRIVATE映射,APP写数据时, 是写入原来内存块的"拷贝"
         */
        printf("compare err!\n");
        printf("str = %s!\n", str);  /* old */  //如果通过read函数操作,那么是通过驱动获取内核的空间
        printf("buf = %s!\n", buf);  /* new */    //通过映射的buf操作,那么就是内核直接帮忙进行操作
    }

munmap函数

#include <unistd.h>
#include <sys/mman.h>


int munmap(void *start,size_t length);

munmap函数删除从虚拟地址start开始的,由接下来length字节组成的区域。后续对已删除区域的引用会导致段错误

示例:使用mmap映射一个磁盘文件并将其输出打印:

#include <unistd.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <sys/stat.h>

int main(int argc,char **argv)
{
    //打开文件,用于获取文件描述符
    int fd = open(argv[1],O_RDWR);
    if(fd == -1)
    {
        perror("open");
        return -1;
    }

    //获取文件大小
    struct stat sb;
    fstat(fd,&sb);
    size_t size = sb.st_size;

    void *ptr = mmap(NULL,size,PROT_READ | PROT_WRITE,MAP_SHARED,fd,0);
    if(ptr == MAP_FAILED)
    {
        perror("mmap");
        return -1;
    }
    //映射内存后关闭文件并不会有影响
    close(fd);

    write(1,ptr,size);

    munmap(ptr,size);

    return 0;
}

动态内存分配

虽然可以使用mmapmunmap函数来创建和删除虚拟内存的区域,但是在很多时候使用动态内存分配器更方便,也具备更好的移植性

动态内存分配器维护着一个进程的叫做堆(heap) 的虚拟内存区域

堆区是一个请求二进制零的区域,他紧接着bss段之后开始,并向高地址增长。对于每个进程,内核维护着一个brk变量,它指向了堆的顶部

Snipaste20260319154754png

动态内存分配器将堆区是为一组不同大小的 块(block) 的集合来维护,每个块就是一个连续的虚拟内存片,要么是已分配要么是空闲

已分配的块显示地给应用程序使用,空闲块可用来分配,一个已分配块始终保持已分配状态直到它被释放(被应用程序显示释放或者被内存分配器自身隐式释放

分配器有两种基本风格,并且这两种风格都要求应用显示地分配块,不同之处是在于由哪个实体来负责释放已分配的块

  • 显示分配器:要求应用显示地释放任何已分配的块
  • 隐式分配器:要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块

malloc函数

#include <stdlib.h>

void *malloc(size_t size);
  • malloc函数返回一个指针,指向大小至少位size字节的内存块,这个块会为可能包含在这个块内的任何数据对象类型做对齐。实际中,对齐依赖于编译代码是在32位模式(gcc -m32)还是64位模式中运行。在32位模式中,malloc返回的块的地址总是8的倍数。在64位模式中,该地址总是16的倍数。
  • 如果malloc失败则返回空指针,并设置errno
  • malloc不会初始化返回的内存
  • 对于想要已初始化的动态内存可以使用calloc,它是malloc的瘦包装函数,将分配的内存初始化为0

sbrk函数

#include <stdlib.h>

void *sbrk(intptr_t incr);

sbrk函数通过将内核的brk指针增加incr拓展和收缩堆

  • 如果成功,则返回brk的旧值,否则返回-1,并将errno设置为ENOMEN
  • 如果incr为0,则返回当前brk
  • 如果incr为负数,那么就表示缩小堆,缩小后的堆是比原来小了incr绝对值个字节

free函数

#include <stdlib.h>

void free(void *ptr);
  • ptr是指向已分配块的起始位置,如果不是,free行为是未定义的
  • 调用free后要把指针置空

碎片

造成堆利用率很低的主要原因是一种称为碎片的现象,当虽然有未使用的内存但是不能用来满足分配请求时就会出现这种现象。碎片有两种形式:

  • 内部碎片:内部碎片时在一个已分配块比有效载荷大时发生的。比如内存对齐要求,使得实际申请的块比需要的块大,用来进行内存对齐 内部碎片的大小就是所有已分配块大小和有效载荷(实际申请大小)之差的和
  • 外部碎片是当空闲内存合计起来满足一个分配请求,但是没有一个单独的空闲块足够大可以来处理这个请求发生的。
上一篇