虚拟地址空间:揭秘Linux内存

文章目录
- 前言
- 虚拟地址空间
- 虚拟地址
- 现象
- 概念
- 页表
- 作用
前言
在学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)printf("read only string addr: %p\n", str);for(int i = 0 ;i < argc; i++){printf("argv[%d]: %p\n", i, argv[i]);}for(int i = 0; env[i]; i++){printf("env[%d]: %p\n", i, env[i]);}return 0;
}
运行的结果如下所示:

对比不同变量在内存中的分布,地址空间的布局与分布图相符。
同时,我们可以发现,堆区向地址增大的方向增长,栈区向地址减小的方向增长。以栈为例子,开辟空间时,得到的地址是众多字节中最小的一个,也就是说,开辟空间时,地址向下增长,但是,访问时,是向上访问的,访问多少字节由类型来决定,如下图所示:

堆和栈是相对而生的。一个向上增长,另一个向下增长。
以上就是虚拟地址空间的一个简单介绍。
虚拟地址
现象
之前我们了解过进程的相关概念,我们来看以下一个程序:
#include <stdio.h>
#include <unistd.h> int g_val = 100; int main()
{ printf("g_val: %d, &g_val:%p\n",g_val, &g_val); pid_t id = fork(); if(id == 0) { while(1) { printf("I am a child process,pid:%d,ppid:%d,g_val: %d, &g_val:%p\n",getpid(),getppid(),g_val, &g_val); sleep(1); g_val++;} } else { while(1) { printf("I am a parent process,pid:%d,ppid:%d,g_val: %d, &g_val:%p\n",getpid(),getppid(),g_val, &g_val); sleep(1); } }
}
以上代码的运行结果如下:

因为父子进程具有独立性,子进程对变量进行修改,父进程对应的变量没有变化。但是我们发现,父子进程变量的地址空间是一样的,这也就说明,这里显示的地址不是物理地址,而是虚拟地址。
每次运行程序的时候,都会将程序转化为进程,就会有task_struct,操作系统加载代码和数据时,会加载到对应的物理内存中。操作系统会为进程创建一个虚拟地址空间,虚拟地址对应的范围为000…00~FFF…FF,也会在虚拟地址空间上为变量和数据开辟空间。同时,操作系统也会创建一张页表,页表的主要作用是实现虚拟地址到物理地址的映射。用户所得到的,是对应的虚拟地址,操作系统会通过页表映射到对应的物理地址中。
fork之后,创建了一个子进程,子进程创建的时候,是以父进程为模板的,而进程=内核数据结构+代码和数据,所以子进程也会拷贝一份虚拟地址空间和页表。因此,虚拟地址空间和页表,每一个进程各自有一套,同时,子进程会拷贝父进程的页表,这也是父子进程共享代码和数据,但是进程具有独立性的原因。 如下图所示:

父子进程任何一个进程尝试对共享的变量进行修改时,不能直接修改,而要发生"写时拷贝",会先将原数据在物理内存中重新开辟空间并进行拷贝,同时操作系统会修改页表中的映射关系。如下图所示:

而真实的物理地址,被操作系统隐藏起来了。
之前我们使用fork()函数创建进程时,会用一个变量来接收函数的返回值,但是可以根据同一个变量接收两个不同的返回值,这也是因为操作系统将虚拟地址映射为不同的物理地址的原因。
概念
之前我们说过,操作系统管理数据的方式是"先描述,再组织"。同样的,操作系统对虚拟地址空间的管理原则也是如此,操作系统中,描述Linux下进程的地址空间的所有信息的结构体是mm_struct(内存描述符)。该结构体中规定了各区域开始的地方和结束的地方,像栈和堆这样的空间,若对应的空间有变化,对结构体当中的成员属性进行修改即可。
页表
页表除了上文提到的虚拟地址和物理地址之外,还包括很多标志位,比如权限位,通过权限位,就可以控制对应的变量的读写权限。同时还有一个权限位表示映射的物理数据是否在内存中,1表示存在,可以访问;0表示不存在,证明对应的内存数据被挂起到磁盘的swap分区中。其它更详细的过程和更多的标志位会在后面逐步介绍。
在Linux系统中,创建一个进程,是先创建内核的数据结构,然后再加载代码和数据,但是,如果此时不着急执行这个进程,可以在需要执行代码和访问数据的时候,再进行加载数据,这种现象称为懒加载。当我们用malloc函数申请空间时,本质是在虚拟地址空间的堆区申请,申请的时候,操作系统会在页表虚拟地址处填上对应的虚地址,但是实际的物理地址,可以先不填写,在真正用的时候动态申请。这种只申请虚拟地址空间,在真正使用再申请空间的过程,叫做缺页中断引起的内存二次申请,这个后面也会逐步介绍。
作用
使用虚拟地址和虚拟地址空间,主要有以下几个作用:
- 控制进程的行为,拦截进程的非法行为,进一步保护物理内存
- 有了虚拟地址空间和页表,可将进程的内存空间布局,无序变为有序
- 使进程管理和内存管理模块解耦合
