目录
1. 程序地址空间回顾
1.1 虚拟地址
2.进程地址空间
分页&虚拟地址空间
引入新概念
解释上述关于同样的地址不同的变量值问题
回答一个历史遗留问题
编辑
3.虚拟内存管理
虚拟内存是什么
虚拟地址空间区域划分
为什么要有虚拟地址空间
1. 程序地址空间回顾
我们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;
}
结果:
$ ./a.out
code addr: 0x40055d
init global addr: 0x601034
uninit global addr: 0x601040
heap addr: 0x1791010
heap addr: 0x1791030
heap addr: 0x1791050
heap addr: 0x1791070
test static addr: 0x601038
stack addr: 0x7ffd0f9a4368
stack addr: 0x7ffd0f9a4360
stack addr: 0x7ffd0f9a4358
stack addr: 0x7ffd0f9a4350
read only string addr: 0x400800
argv[0]: 0x7ffd0f9a4811
env[0]: 0x7ffd0f9a4819
env[1]: 0x7ffd0f9a482e
env[2]: 0x7ffd0f9a4845
env[3]: 0x7ffd0f9a4850
env[4]: 0x7ffd0f9a4860
env[5]: 0x7ffd0f9a486e
env[6]: 0x7ffd0f9a4892
env[7]: 0x7ffd0f9a48a5
env[8]: 0x7ffd0f9a48ae
env[9]: 0x7ffd0f9a48f1
env[10]: 0x7ffd0f9a4e8d
env[11]: 0x7ffd0f9a4ea6
env[12]: 0x7ffd0f9a4f00
env[13]: 0x7ffd0f9a4f13
env[14]: 0x7ffd0f9a4f24
env[15]: 0x7ffd0f9a4f3b
env[16]: 0x7ffd0f9a4f43
env[17]: 0x7ffd0f9a4f52
env[18]: 0x7ffd0f9a4f5e
env[19]: 0x7ffd0f9a4f93
env[20]: 0x7ffd0f9a4fb6
env[21]: 0x7ffd0f9a4fd5
env[22]: 0x7ffd0f9a4fdf
可以看出栈区的存储是由高地址到低地址的,而堆区则相反是由低地址到高地址,它们中间的共享区我们后面再讲。
现在有个问题是:程序地址空间到底是不是内存呢?
先不着急回答,我们可以试验一下,先写一个程序:
定义一个全局变量,让子进程修改它的内容,我们知道如果子进程修改父进程的数据的话,会发生写时拷贝,在内存中另外开辟一个空间存储子进程修改过的数据,理论上来说,发生写时拷贝之后,父进程的这个变量地址与子进程的不一样,那么究竟是不是这样呢,我们来看结果:
我们发现它们的值虽然不一样,但是地址竟然一模一样。
1.1 虚拟地址
我们再看下一个例子:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ //child
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}else{ //parent
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
输出:
//与环境相关,观察现象即可
parent[2995]: 0 : 0x80497d8
child[2996]: 0 : 0x80497d8
我们发现,输出出来的变量值和地址是⼀模⼀样的,很好理解,因为⼦进程按照⽗进程为模版,⽗⼦并没有对变量进⾏进⾏任何修改。可是将代码稍加改动:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ //child,⼦进程肯定先跑完,也就是⼦进程先修改,完成之后,⽗进程再
读取
g_val=100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}else{ //parent
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
输出结果:
//与环境相关,观察现象即可
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8
我们发现,⽗⼦进程,输出地址是⼀致的,但是变量内容不⼀样!能得出如下结论:
(1)变量内容不⼀样,所以⽗⼦进程输出的变量绝对不是同⼀个变量。
(2)但地址值是⼀样的,说明,该地址绝对不是物理地址!
(3)在Linux地址下,这种地址叫做 虚拟地址。
(4)我们在⽤C/C++语⾔所看到的地址,全部都是虚拟地址!物理地址,用户⼀概看不到,由OS统⼀管理 。
OS必须负责将 虚拟地址 转化成 物理地址 。
2.进程地址空间
所以之前说‘程序的地址空间’是不准确的,准确的应该说成 进程地址空间 ,那该如何理解呢?
看图:
分页&虚拟地址空间
引入新概念
在系统中,每一个虚拟地址空间都指向一张叫“页表”的表,它又是干什么用的呢?页表是用来映射虚拟空间地址到物理地址的。现在我们可以给出几个结论,方便下面理解:
(1)一个进程对应一个虚拟地址空间。
(2)一个虚拟地址空间对应一张页表。
(3)页表是用来映射虚拟空间地址到物理地址的。
解释上述关于同样的地址不同的变量值问题
假设我们有一个进程,它有一个变量我们将它的值设置为100,这个变量会有两个地址,一个是虚拟空间地址,一个是物理地址,都放在页表中,当我们访问这个变量的地址时,通过页表中它的虚拟地址找到它的物理地址进行访问。这时我们创建一个子进程,子进程的数据和代码都会共享父进程的,而页表则是直接复制一张内容一模一样的,这样就发生了浅拷贝,子进程可以通过复制过来的页表的地址访问我们父进程定义的变量:
当子进程对这个变量修改数据时,系统会在物理内存中创建一块新的空间,然后将这块空间的地址替换子进程在页表中的这个变量的物理地址,到这一步,父子进程的这个变量的虚拟地址还是一模一样,而物理地址则发生了改变,这就是写时拷贝。
回答一个历史遗留问题
在前面的章节我们留下了一个问题没有解答,那就是下图中的第三问——为什么一个变量即大于0,又等于0?
答:有两个进程,有进程将id这个变量修改了之后,发生了写时拷贝,父和子进程拿着同样的虚拟地址在自己的页表中拿到了不同的物理地址进行访问!所以一个变量才会即大于0又等于0。
3.虚拟内存管理
虚拟内存是什么
描述linux下进程的地址空间的所有的信息的结构体是 mm_struct (内存描述符)。每个进程只有⼀个mm_struct结构,在每个进程的task_struct结构中,有⼀个指向该进程的结构。
在系统中,假设内存为4G,每个进程都认为自己有4个G的空间,像这样被划分好了各个区的内存:
但是我们总共只有四个G的内存,所以,上述每个进程都有4G内存空间是不可能的, 这些空间都是虚拟的,但是既然有这样的空间,就要管理起来,系统是怎样管理的呢?先描述,再组织。没错,这些进程的虚拟地址空间被系统使用结构体描述,使用某种数据结构将它们组织起来了。现在我们知道了,虚拟地址空间是一个个结构体对象,它名为mm_struct。
虚拟地址空间区域划分
每个虚拟地址空间有4个G的空间(实际并没有),也就是2的32次方个字节,系统通过数字来划分这些字节的数字来给虚拟地址空间的各个内存区划分区域:
在进程源码中找到mm_struct:
往下翻:
这就应证了我们上面所讲的。
每个进程中包含最多的信息就是这样使用两个数字来描述一块空间的开始和结束,假设有100个字节空间,张三和李四各分50个字节,我们通过以下方式来调整区域划分:
描述linux下进程的地址空间的所有的信息的结构体是 mm_struct (内存描述符)。每个进程只有⼀个mm_struct结构,在每个进程的task_struct结构中,有⼀个指向该进程的结构。
struct task_struct
{
/*...*/
struct mm_struct
*mm; //对于普通的⽤⼾进程来说该字段指向他的
虚拟地址空间的⽤⼾空间部分,对于内核线程来说这部分为NULL。
struct mm_struct
*active_mm; // 该字段是内核线程使⽤的。当该
进程是内核线程时,它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有,这是因为所
有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。
/*...*/
}
以说,mm_struct结构是对整个⽤⼾空间的描述。每⼀个进程都会有⾃⼰独⽴的mm_struct,这样
每⼀个进程都会有⾃⼰独⽴的地址空间才能互不⼲扰。先来看看由task_struct到mm_struct,进程的地址空间的分布情况:
定位mm_struct⽂件所在位置和task_struct所在路径是⼀样的,不过他们所在⽂件是不⼀样的,
mm_struct所在的⽂件是mm_types.h。
struct mm_struct
{
/*...*/
struct vm_area_struct *mmap;
/* 指向虚拟区间(VMA)链表 */
struct rb_root mm_rb;
/* red_black树 */
unsigned long task_size;
/*具有该结构体的进程的虚拟地址空间的⼤⼩*/
/*...*/
// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
/*...*/
}
那既然每⼀个进程都会有⾃⼰独⽴的mm_struct,操作系统肯定是要将这么多进程的mm_struct组织起来的!虚拟空间的组织⽅式有两种:
1. 当虚拟区较少时采取单链表,由mmap指针指向这个链表;
2. 当虚拟区间多时采取红⿊树进⾏管理,由mm_rb指向这棵树。
linux内核使⽤ vm_area_struct 结构来表⽰⼀个独⽴的虚拟内存区域(VMA),由于每个不同质的虚
拟内存区域功能和内部机制都不同,因此⼀个进程使⽤多个vm_area_struct结构来分别表⽰不同类型的虚拟内存区域。上⾯提到的两种组织⽅式使⽤的就是vm_area_struct结构来连接各个VMA,⽅便进程快速访问。
struct vm_area_struct {
unsigned long vm_start; //虚存区起始
unsigned long vm_end;
//虚存区结束
struct vm_area_struct *vm_next, *vm_prev;
//前后指针
struct rb_node vm_rb;
//红⿊树中的位置
unsigned long rb_subtree_gap;
struct mm_struct *vm_mm;
//所属的 mm_struct
pgprot_t vm_page_prot;
unsigned long vm_flags;
//标志位
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
struct list_head anon_vma_chain;
struct anon_vma *anon_vma;
const struct vm_operations_struct *vm_ops; //vma对应的实际操作
unsigned long vm_pgoff;
//⽂件映射偏移量
struct file * vm_file;
//映射的⽂件
void * vm_private_data;
//私有数据
atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMU
struct vm_region *vm_region;
/* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy;
/* NUMA policy for the VMA */
#endif
struct vm_userfaultfd_ctx vm_userfaultfd_ctx;
} __randomize_layout;
所以我们可以对上图在进⾏更细致的描述,如下图所⽰:
为什么要有虚拟地址空间
1.将“无序”变“有序”。由于物理内存是由诸多进程共享的,所以一个进程在物理内存的地址大概率不是有序的,使用虚拟地址空间,只需要将进程的各个地址保存到页表,再给它们映射有序的虚拟空间地址,这样我们就不用管物理地址是否连续了。
2.地址转换过程中,也可以对你的地址和操作进行合法性判定,进而保护物理内存。在页表中存在权限的概念,不同的区权限可能不同,假设我们运行一段代码:
char* str="helloworld";
str="H";
程序会崩溃,为什么呢?因为str存的是字符串常量,字符串常量在常量区,而常量区在页表中的权限是只读,代码在查找页表时,权限拦截了,操作与权限冲突,程序当然不能执行了。
3.让进程管理与内存管理进行一定程度的解耦合。
本章完。