进程之虚拟地址空间
本篇目标理解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; }输出结果解释1.正文代码区地址通常是最小的2.初始化数据存放的是程序中已明确初始化的全局变量和静态变量例如代码中的g_val和test的地址比较接近它们的地址紧随代码段之后虽然test是在函数内部定义的但因为加了static它并不在栈上而是在数据段。3.未初始化数据存放未初始化的数据例如代码中的g_unval地址通常略高于已初始化数据段。4.堆区:用于动态内存分配地址由低向高增长连续malloc了四次就会发现heap_mem到heap_mem3的地址是依次递增的。5.栈区用于存放局部变量、函数参数等地址由高向低增长这里的heap_mem本身是一个指针变量局部变量它存储在栈上而它指向的空间malloc出来的在堆上栈区的地址远远大于堆区且地址通常是依次递减的取决于编译器和系统实现。6.内核空间是内存最高处的一部分区域。注意如果是在main函数里的变量无static无malloc那无论是否初始化都在栈上但是如果是在全局里的变量/static修饰的变量就在初始化数据区/未初始化数据区而malloc出来的空间在堆区但是指针在栈区/初始化数据区/未初始化数据区这些容易混淆注意区分一下。那程序地址空间是物理内存吗答案是不是内存严格来说是进程地址空间虚拟地址空间与内存是有区别的并且它是一个系统层面的概念所以我们平时说的 0x08048000、栈地址、堆地址、.data等这些地址都不是真实物理内存条上的地址而是进程虚拟地址空间下面我们通过一个代码来验证一下。#includestdio.h #includeunistd.h int gval100; int main() { pid_t idfork(); if(id0) { // 子进程执行分支 while(1) { printf(子:gval:%d,gval:%p,pid:%d,ppid:%d\n,gval,gval,getpid(),getppid()); sleep(1); gval; } } else { // 父进程执行分支 while(1) { printf(父:gval:%d,gval:%p,pid:%d,ppid:%d\n,gval,gval,getpid(),getppid()); sleep(1); } } return 0; }输出结果解释可以看出一个是父进程一个是子进程两个进程中都有一个相同地址的gval如果是物理空间那子进程修改gval时父进程的gval为什么不改变呢这前后有点矛盾啊所以从这可以看出程序地址空间不是物理内存所以在Linux地址下这种地址叫做 虚拟地址我们在用C/C语言所看到的地址全部都是虚拟地址物理地址⽤户⼀概看不到由OS统⼀ 管理。2.虚拟内存那该如何理解呢看图2.1.分页虚拟地址空间解释每个进程都拥有独立的虚拟地址空间和专属页表虚拟地址空间提供程序运行所需的全部地址总大小为32位下4G宽度为1字节其中真正被使用的地址会在页表中建立一一对应的映射关系指向真实的物理内存地址。以上面的父子进程为例父进程先运行操作系统为父进程创建独立的虚拟地址空间专属的页表全局变量gval100的地址放在虚拟地址空间的数据段假设gval的虚拟地址是0x111111与之对应的物理内存空间地址为0x112233执行fork()创建子进程复制虚拟地址空间布局子进程的虚拟空间和父进程完全一模一样所以gval的虚拟地址还是0x111111复制页表子进程执行 gval由于进程互相独立触发写时拷贝操作系统为子进程分配一块新的物理页 B将老变量的内容拷贝到新的物理空间里面得到一个新的物理地址假设为为0x223344更新子进程的页表此时状态父进程虚拟0x111111→ 物理页 A值 100子进程虚拟 0x111111 → 物理页 B值 101结果如图所示结论一个进程一个虚拟地址空间一套页表页表用来做虚拟地址和物理地址的映射如何理解虚拟地址空间?以一个大富翁的例子为例假设大富翁手里实实在在攥着10 亿真金白银这是他全部的家产就像电脑里的物理内存。他有好多个私生子每个都要生活、要花钱、要占地盘。富翁不想让孩子们互相知道对方的存在更不想让他们因为抢钱打架、把家搞乱于是他给每个孩子都单独画了一片完整的家产蓝图对每个人都说家里这 10 亿全归你一个人用你想怎么花就怎么花整片地盘都是你的。每个孩子都信以为真以为自己独占全部家产各自按自己的账本记账、规划开销彼此完全看不见、也碰不到对方的钱。但实际上富翁在背后默默管着这 10 亿谁要用钱他就从真金白银里分一小块给那个人谁暂时不用他就先收起来给别人用他还严格划定界限绝不允许一个孩子乱动另一个孩子的东西哪怕两个孩子记的账一模一样对应到的也是完全不同的钱。这就像操作系统拿着真实物理内存给每个进程都提供一整套独立虚拟地址空间让进程以为自己独占所有内存实际由系统统一分配、映射、隔离保证程序之间互不干扰系统稳定安全。如图所示但是私生子要管理饼也是要管理的所以如何管理呢答案是先描述在组织所以描述linux下进程的虚拟地址空间的所有的信息的结构体是mm_struct内存描述符。每个进程只有⼀个mm_struct结构在每个进程的task_structt 结构中都有⼀个指向该进程的mm_struct结构体指针如代码所示struct task_struct { struct mm_struct*mm; //其他的内容 ... }2.2.区域管理既然虚拟内存空间是个结构体那么该如何描述虚拟地址空间呢1.先要理解什么是区域划分以小李与小李为例幼儿园里小李不爱干净、小李小李爱干净两人同用一张桌子就在桌面正中间画了一条直直的线把桌子分成左右两块区域左边归小李、右边归小美谁都不能越过线占对方的地方也不能把脏东西弄到对方区域里小李的桌子的开始到这条线就是小李的区域而这条线到小美桌子的结束就是小美的区域区域划分把一个整体空间分成互不干扰的独立部分。所以区域划分仅需要确定区域的开始和结束即可而小李为了管理自己的文具就把把桌面按用途分成铅笔区、橡皮区、尺子区每个区域标上一段专属刻度编号按号区分摆放不同文具这就是虚拟地址空间的区域编码。2.内核中见一下具体的图片见一下可以看出图中的start_code是代码区起始地址end_code是代码区结束地址通过区域的起止地址可以清晰划分内存区域用户态调用malloc/calloc/realloc/free时若堆空间不足或堆顶有连续空闲内存可回收系统就会调整对应堆区域的起止地址这就叫调整区域。结论mm_struct 中这些unsigned long类型的整型成员是对进程整个虚拟地址空间的顶层边界、各核心内存段的精准起止地址、布局基准的严格法定定义。但是这是就有人会问mm_struct光有起始地址也不够啊难道没有其他的东西管理这些区域吗答案是有的那既然每⼀个进程都会有自己独⽴的mm_struct 操作系统肯定是要将这么多进程的 mm_struct 组织起来的虚拟空间的组织方式有两种1.当虚拟区较少时采取单链表由mmap指针指向这个链表如图所示2.当虚拟区间多时采取红黑树进行管理由mm_rb指向这棵树。linux内核使用vm_area_struct 结构来表示⼀个独立的虚拟内存区域(VMA)由于每个不同的虚拟内存区域功能和内部机制都不同因此⼀个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。struct vm_area_struct { unsigned long vm_start; //虚存区起始 unsigned long vm_end; //虚存区结束 struct vm_area_struct *vm_next, *vm_prev; //前后指针 unsigned long vm_flags; //标志位 struct mm_struct *vm_mm; //所属的mm_struct //... }作用给每一段虚拟内存区域贴上完整身份证包括地址范围、权限、用途、类型、背后文件等让内核能正确管理处理这片空间。所以具体的内存如图所示结论mm_struct只做宏观、整体的边界划分不直接管理具体虚拟内存区域vm_area_structVMA做微观、具体、带属性的区域划分是真正直接管理进程虚拟内存空间的最小单元。3.为什么要有虚拟地址空间3.1.问题1.借助虚拟地址到物理内存空间的映射机制即便从磁盘加载到物理内存中的代码与数据在物理内存层面是无序排布的但通过页表建立映射后与物理地址对应的虚拟地址会在进程的虚拟地址空间中完成清晰的区域划分呈现出规整有序的状态,也就是将物理地址从无序变为有序。结论因为页表的映射的存在程序在物理内存中理论上就可以任意位置加载。它可以将地址空间上的虚拟地址和物理地址进行映射所以在进程视角所有的内存分布都可以是有序的。2.什么是野指针答案假设合法内存区域划定堆区虚拟地址范围为 0x112233440x44556677 栈区虚拟地址范围为 0X667788990x99112233我们要访问的目标访问地址 0X55667788 该地址落在堆区与栈区的地址间隙不在栈区、堆区等任何合法内存区域的范围内也没有被进程的任何VMA覆盖最终判定访问 0X55667788 的行为就是标准的野指针非法访问。另一个问题为什么char*strhelloworld*strH;这个在运行时在字符常量区写入就会崩溃这是因为对字符串常量区地址执行写操作时但是虚拟地址在页表中权限为只读所以查页表时被权限拦截了从而引发程序崩溃。结论用户访问数据时在进行由虚拟地址到物理地址的转换会对我们的虚拟地址和操作进行合理的判断进而保护我们的物理地址。3.因为有地址空间的存在所以我们在C、C语言上newmalloc空间的时候其实是在地址 空间上申请的物理内存可以甚至⼀个字节都不给你而当真正进行对物理地址空间访问的时候才执行内存的相关管理算法帮你申请内存构建页表映射关系延迟分配这是由操作系统⾃动完成⽤⼾包括进程完全0感知结论进程管理模块和内存管理模块就完成了解耦合3.2.补充的内容创建进程时先构建 task_structPCB和 mm_struct地址空间描述符这两个核心结构再根据可执行文件的代码段、数据段等信息初始化 mm_struct并在虚拟地址空间上规划出代码区、数据区、BSS、堆、栈等虚拟区域此时只划虚拟范围不分配物理内存。mm_struct 管理整个虚拟地址空间的全局信息再用多个 vm_area_structVMA把空间细分成一段段带属性的区间再根据 VMA 上的虚拟地址填到页表的虚拟地址一侧。进程真正读写某虚拟地址时若发现页表无对应物理映射就触发缺页异常内核再分配物理页、从磁盘加载代码 / 数据或执行写时复制最终完成虚拟地址到物理地址的映射绑定。例子创建进程时构建好task_struct和mm_struct在虚拟地址空间里直接划出一段2GB 大小的代码区 VMA。这一步只在操作系统层面标记这段虚拟地址是代码区一共 2GB完全不分配物理内存页表里只先填上这 2GB 对应的虚拟地址一侧。进程开始运行要执行代码、访问这段虚拟地址时发现页表里只有虚拟地址没有对应的物理地址映射于是触发缺页异常。操作系统处理缺页时一次性加载 500MB 的代码内容到物理内存然后把这 500MB 对应的虚拟地址和物理地址在页表里建立映射绑定。剩下还没访问到的1.5GB 代码依然只存在虚拟地址规划里物理内存没分配、页表没映射等到后续真正执行到那部分代码时再继续加载、继续建映射。此时我就可以澄清一些问题1.我们可以不加载代码和数据先创建task_structmm_structvm_area_struct页表等2.创建进程是先有task_structmm_structvm_area_struct页表等而不是先加载数据和代码到物理空间。