【Linux系统】初识虚拟地址空间
文章目录
- 一、 程序地址空间回顾
- 1.程序地址空间各区域分布验证
- 2.引入虚拟地址概念
- 二、进程地址空间(虚拟地址空间)的管理
- 三、虚拟地址空间的作用
一、 程序地址空间回顾
1.程序地址空间各区域分布验证
#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("read only string addr: %p\n", str);static int test = 10; printf("init global addr: %p\n", &g_val);printf("test static addr: %p\n", &test); printf("uninit global addr: %p\n", &g_unval);char *heap_mem = (char*)malloc(10);char *heap_mem1 = (char*)malloc(10);char *heap_mem2 = (char*)malloc(10);printf("heap addr: %p\n", heap_mem); printf("heap addr: %p\n", heap_mem1); printf("heap addr: %p\n", heap_mem2); printf("stack addr: %p\n", &heap_mem); printf("stack addr: %p\n", &heap_mem1); printf("stack addr: %p\n", &heap_mem2); printf("argv[0]: %p\n", argv[0]);printf("env[0]: %p\n", env[0]); return 0;
}
这是在linux系统gcc编译器中运行的结果,在windows系统下的vs等编译器运行可能会有不同结果(因为windows系统下编译器会优化代码执行过程,从而影响结果):
code addr: 0x40057d
read only string addr: 0x400780
init global addr: 0x60103c
test static addr: 0x601040
uninit global addr: 0x601048
heap addr: 0x63e010
heap addr: 0x63e030
heap addr: 0x63e050
stack addr: 0x7ffd840398c0
stack addr: 0x7ffd840398b8
stack addr: 0x7ffd840398b0
argv[0]: 0x7ffd8403b803
env[0]: 0x7ffd8403b80f
各区域地址划分是符合规则的:
- 代码区(正文代码):存放可执行的代码(如:函数体的二进制代码)和 只读常量。它们的地址是最小的
- 静态区(初始化数据 和 未初始化数据):存放全局变量 和 静态数据。它们的地址只比代码区大(未初始化数据 地址高于 初始化数据)
- 堆区:使用malloc、calloc、realloc等函数动态开辟的空间。地址高于静态区,开辟空间时地址向上增长
- 栈区:栈区主要存放运行函数而分配的局部变量、函数参数、返回数据、返回地址等。地址高于堆区,申请空间时地址向下增长
- 命令行参数 和 环境变量区域:地址高于栈区
最后,以一个问题收尾,以上打印出来的所有地址都是真实对应的物理地址嘛?
答:这些地址全都不是真实的物理地址,而是虚拟地址(解释见下节)
2.引入虚拟地址概念
从示例出发,引出虚拟地址概念:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h> int g_val = 0; int main()
{ pid_t id = fork(); if(id == 0) { //child while(1) { printf("child[%d], g_val: %d, &g_val: %p\n", getpid(), g_val, &g_val); g_val++; sleep(1); } } else { //parent while(1) { printf("parent[%d], g_val: %d, &g_val: %p\n", getpid(), g_val, &g_val); sleep(1); } } return 0;
}
同一个地址,怎么可能会查出来不同的值?
这侧面证明了 用户在语言层面中使用的地址根本就不是物理地址,而是虚拟地址。
虚拟地址具体是指什么?
进程的pcb(task_struct)中存放着虚拟地址空间(详见 “进程地址空间的管理” )的起始地址,虚拟地址空间上的地址就是虚拟地址。
进程的代码和数据保存在内存中。进程在运行时,先找到虚拟地址,再通过页表把虚拟地址转换成物理地址,然后通过物理地址访问内存中存放的代码和数据。
解释以上代码运行结果中的现象:同一个地址,查出来不同的值
我们自己写的可执行程序(父进程)运行时,使用fork函数创建子进程, 创建出的子进程是以父进程为模板的,它的虚拟地址空间 和 页表是父进程的拷贝,它的pcb中内容与父进程大体相同,只修改了pid 和 ppid等少量属性。 因为子进程的虚拟地址空间 和 页表是父进程的拷贝,所以页表转换的物理地址 指向 父进程的代码和数据,刚创建出的子进程共享父进程的代码 和 数据。
子进程和父进程共享数据,直到发生数据写入,一方要修改数据时,为了维护进程间数据的独立性,一方修改数据,不能影响另一方,该数据会进行写实拷贝,对子进程和父进程的该数据进行分离,这样一方修改数据就不会影响另一方了。
比如:上述代码中,子进程刚创建出来的时候共享父进程的数据,子进程共享父进程的g_val变量,当子进程对g_val变量进行修改的时候,为了维护进程间数据的独立性,要对子进程和父进程的该数据进行分离,也就是对该数据进行写实拷贝,具体步骤就是 在内存开辟一个新空间,然后把该内容拷贝到新空间,最后将子进程页表中对应的物理地址修改成新空间的地址。 子进程的g_val变量 和 父进程的g_val变量分离,子进程对拷贝到新空间的g_val变量修改不会影响父进程的g_val变量。
需注意的是,写实拷贝的整个过程中,都不会对虚拟地址进行修改,修改的是虚拟地址对应的物理地址。 这就是同一个地址(虚拟地址),能查出来不同的值的原因:
子进程的页表是父进程的拷贝,子进程 和 父进程的g_val变量的虚拟地址是一样的。子进程修改g_val时,发生写实拷贝(修改的是虚拟地址对应的物理地址),这时子进程和父进程的g_val变量的 虚拟地址仍然是一样的,但它们的虚拟地址对应的物理地址是不同的,所以同一个地址(虚拟地址),能查出来不同的值(数据存在物理地址对应的空间中)
二、进程地址空间(虚拟地址空间)的管理
理解虚拟地址空间:
大富翁有很多的私生子,这些私生子彼此都不知道对方的存在。大富翁有十个亿的资产,他和每一个私生子都说:“我有10个亿的资产,等我去世之后,就由你继承这些资产”。大富翁给每一个私生子画大饼,于是每一个私生子都认为自己以后能独自拥有这10个亿的资产。
大富翁平时对每一个私生子的要钱申请基本有求必应,当然这些请求得在合理范围,比如一次要个几千、几万、 甚至几十万之类。如果私生子的要钱请求过于高,比如一次要几千万、一亿、十亿之类,大富翁会直接数落他一顿:“我还没死呢,你就想掏空我的财产”,然后驳回他的请求。
在linux系统下,其实大富翁就相当于操作系统(进行内存管理),私生子就相当于进程,操作系统给每一个进程画的大饼就叫虚拟地址空间(虚拟地址空间的地址编号 是和 内存的物理地址编号一样多的),相当于操作系统跟每一个进程都说:“你独自享有整个内存空间(其实是给每一个进程配一个虚拟地址空间来哄骗它们)”。实际上,进程运行过程中,每一次申请内存空间都不能过多,否则空间申请不会成功;每一个进程的代码和数据其实都只占据了内存空间中的一小部分。
虚拟地址空间的实质:
虚拟地址空间本质其实就是一个结构体(不够全面,后面补充虚拟地址空间本质):struct mm_struct
进程pcb(task_struct)中存放了指向 struct mm_struct 的指针
struct task_struct
{ ...struct mm_struct *mm; //对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分...
}
struct mm_struct
{...struct vm_area_struct *mmap; // 指向虚拟区间(VMA)链的开头...// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。 unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack, end_stack;unsigned long arg_start, arg_end, env_start, env_end;
}
虚拟地址空间中划分了很多分区,要想描述虚拟地址空间,就得描述出其中的每一个分区,struct mm_struct 就采取了记录每一个分区的起始和结束地址的方式 来划分出虚拟地址空间中的各个分区, 如下:
但实际上,只使用 struct mm_struct 是无法全面描述虚拟地址空间的。
注意到 struct mm_struct 中的struct vm_area_struct *mmap 指针变量还未被使用,要想全面描述虚拟地址空间,还得把这个指针变量使用起来。
先介绍一下 struct vm_area_struct :
struct vm_area_struct
{unsigned long vm_start; // 虚拟内存区域的起始unsigned long vm_end; // 虚拟内存区域的结束struct vm_area_struct *vm_next, *vm_prev; // 前后指针struct mm_struct *vm_mm; // 回指所属的 mm_structpgprot_t vm_page_prot; // 所属分区的权限...
}
linux内核使用 vm_area_struct 结构来表示⼀个独立的虚拟内存区域(VMA)。由于虚拟地址空间不同分区的虚拟内存区域功能和内部机制都不同,因此⼀个进程要使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。使用双链表管理多个vm_area_struct结构(描述一个进程不同类型的虚拟内存区域),struct mm_struct中的mmap指针指向这个双链表结构:
所以最终结论是:虚拟地址空间 = struct mm_struct + 内核数据结构(由 struct vm_area_struct 组成的双链表)
struct mm_struct 描述了虚拟地址空间的整理情况(虚拟地址空间各个区域的划分),由 struct vm_area_struct 组成的双链表 描述了 虚拟地址空间各个区域的详细信息(功能和内部机制),它们共同构建了虚拟地址空间。
三、虚拟地址空间的作用
- 增加了虚拟地址空间,虚拟地址就必须通过页表转换成物理地址才能去访存,在地址转换过程中进行安全审核(比如:地址、权限检查),变相保护了物理内存的安全
假如代码中使用的地址都是物理地址,那么如果进程A的代码中错误的使用了一个野指针,而这个野指针又指向另一个进程的数据,对野指针指向的数据进行修改。这样会出大问题,本来只是你一个进程出问题,又影响到其它进程,这不符合进程间的独立性原则。
所以用户在代码中使用的地址绝对不可以是物理地址,而是要使用虚拟地址,在页表进行虚拟地址向物理地址的转换过程中对这些不在页表中的野指针地址进行警告报错处理。
实际上,页表还有一列权限栏:
(1)char * str = “hello world”;
// "hello world"是字符串常量,保存在代码区(代码区的权限是只读),str指向该字符串常量起始地址
*str = ‘c’;
// 执行这句代码会报错,而且报的是运行时出错。解释:运行到这句代码时,要进行访存修改数据,虚拟地址 向 物理地址转换的过程中发现该地址对应的权限是只读,而此时要进行的操作是写操作,没有写权限,页表阻止这次地址转换,并进行报错处理。
(2)const char * str = “hello world”;
// 如果定义字符串常量时加了const修饰;再进行 *str = ‘c’ 操作时,编译器就会报编译时出错。解释:用const修饰,是告诉编译器这是不能修改的数据,编译器知道这个信息后,就会对这个数据的写入操作进行语法检查,编译器就能发现 *str = ‘c’ 操作的语法错误,语法检查是编译过程做的事,所以报的是编译时出错。
- 进程看待自己的代码和数据,全部都是"有序看待",这得益于虚拟地址空间的有序性
可执行程序执行时,它的代码和数据理论上可以加载到内存上的任意位置,这也就意味着实际上可执行程序的代码和数据在内存上的排布是随机的、无序的。
但其实有了虚拟地址空间之后,进程运行时,根本就不需要关心它的代码和数据到底存放在内存中的哪一个位置,进程只需要和虚拟地址空间打交道就行,而虚拟地址空间的排版是非常有规律的,代码地址存在代码区,局部变量地址存在栈区,动态开辟空间的地址存在堆区,得到这些虚拟地址后,后续操作由操作系统自动完成:页表将虚拟地址转换成物理地址,再进行访存。