ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

【Linux】Linux系统下的程序地址空间

【Linux】Linux系统下的程序地址空间 前言前面已经接触过代码区、堆区和栈区也经常通过指针访问数据。但指针里面保存的地址真的是数据在物理内存中的位置吗本篇来详细的解释程序地址空间1.程序中的地址分布1.1地址空间的区域划分我们通常会把程序运行时用到的空间分成代码区、数据区、堆区、栈区等。不同区域存放的内容不同用途也不同。先把几个常见区域回顾一下区域主要内容代码区程序执行的机器指令只读数据区字符串常量等只读内容已初始化数据区具有非零初值的全局变量、静态变量等BSS区未显式初始化或零初始化的全局变量、静态变量等堆区动态内存分配常用的区域映射区共享库、文件映射、匿名映射等栈区函数调用相关信息以及需要存放在栈上的局部对象等在常见布局示意中代码和数据位于相对低的地址栈位于相对高的地址堆通常画成向高地址扩展栈通常画成向低地址扩展。命令行参数、环境变量的字符串在Linux程序启动时也会放在初始栈相关区域。常见的“4GB地址空间、用户空间3GB、内核空间1GB”描述的是传统32位x86 Linux的一种典型布局。按字节编址时32位地址可以表示2^32个地址对应4GiB的范围下面就以这个环境下作为例子。64位程序的地址范围和划分不同 具体可以查考传统x86地址空间说明1.2通过代码观察地址先用下面的代码观察函数、全局变量、动态申请的空间和局部变量等对象的地址#includestdio.h#includeunistd.h#includestdlib.hintg_unval;intg_val100;intmain(intargc,char*argv[],char*env[]){constchar*strhelloworld;printf(code addr: %p\n,main);printf(init global addr: %p\n,g_val);printf(uninit global addr: %p\n,g_unval);staticinttest10;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);printf(heap addr: %p\n,heap_mem1);printf(heap addr: %p\n,heap_mem2);printf(heap addr: %p\n,heap_mem3);printf(test static addr: %p\n,test);printf(stack addr: %p\n,heap_mem);printf(stack addr: %p\n,heap_mem1);printf(stack addr: %p\n,heap_mem2);printf(stack addr: %p\n,heap_mem3);printf(read only string addr: %p\n,str);for(inti0;iargc;i){printf(argv[%d]: %p\n,i,argv[i]);}for(inti0;env[i];i){printf(env[%d]: %p\n,i,env[i]);}return0;}这里按Linux/GCC的实验环境观察地址。下面是我的机器上的输出结果init global addr: 0x60103c uninit global addr: 0x601048 heap addr: 0xc1b010 heap addr: 0xc1b030 heap addr: 0xc1b050 heap addr: 0xc1b070 test static addr: 0x601040 stack addr: 0x7fffac322cc8 stack addr: 0x7fffac322cc0 stack addr: 0x7fffac322cb8 stack addr: 0x7fffac322cb0 read only string addr: 0x400820 argv[0]: 0x7fffac32477a env[0]: 0x7fffac324781 env[1]: 0x7fffac324797 env[2]: 0x7fffac3247ae env[3]: 0x7fffac3247b9 env[4]: 0x7fffac3247c9 env[5]: 0x7fffac3247d7 env[6]: 0x7fffac3247f9 env[7]: 0x7fffac32480c env[8]: 0x7fffac324815 env[9]: 0x7fffac324858 env[10]: 0x7fffac324df4 env[11]: 0x7fffac324e0d env[12]: 0x7fffac324e67 env[13]: 0x7fffac324e95 env[14]: 0x7fffac324eab env[15]: 0x7fffac324ebb env[16]: 0x7fffac324ec3 env[17]: 0x7fffac324ed2 env[18]: 0x7fffac324ede env[19]: 0x7fffac324f0d env[20]: 0x7fffac324f30 env[21]: 0x7fffac324fa2 env[22]: 0x7fffac324fc1 env[23]: 0x7fffac324fd7 env[24]: 0x7fffac324fe0可以看到代码和只读字符串的位置比较接近全局变量与静态变量位于另一段区域动态申请的空间和栈上的指针变量又处于不同位置。现在我们知道了这些地址大致分布在哪些区域但还有一个更重要的问题它们是物理地址吗2.虚拟地址2.1父子进程中的同一个全局变量下面再来看这个程序#includestdio.h#includeunistd.hintg_val1;intmain(){pid_tidfork();if(id0){while(1){printf(子进程g_val:%d, g_val:%p, pid:%d, ppid:%d\n,g_val,g_val,getpid(),getppid());sleep(1);g_val;}}else{while(1){printf(父进程g_val:%d, g_val:%p, pid:%d, ppid:%d\n,g_val,g_val,getpid(),getppid());sleep(1);}}return0;}这里以fork()创建子进程成功为前提。子进程进入id 0的分支打印以后不断递增g_val父进程只打印不修改这个变量。程序中的两个循环会持续运行观察完以后需要结束父子进程。前台运行这个示例时可以通过CtrlC结束同一前台进程组中的它们。先看运行结果图中父进程的g_val一直是1子进程则从1逐渐变成2、3、4……这说明子进程的修改没有影响父进程符合我们前面认识的进程独立性。但再看g_val双方输出的居然都是0x601054。如果把这个数直接当成同一处物理内存的位置就很难解释子进程已经修改了内容父进程怎么还在读原来的值问题不在数据而在于我们把地址的含义理解错了。这里打印的是虚拟地址不是物理地址。2.2虚拟地址与物理地址物理内存是机器实际提供的存储资源而虚拟地址空间是操作系统为程序运行提供的一套地址视图。它不是另一根内存条也不是提前给每个进程分出一整块同样大的物理内存。更准确地说我们讨论的是进程地址空间。磁盘上的程序文件本身不会在运行中访问变量真正执行代码、访问数据的是进程。一个程序可以运行多次形成不同进程它们的地址空间也可以彼此独立。可以把虚拟地址理解成各自房间里的编号。两个不同房间都可以有“1号柜子”但它们不是同一个柜子。只看编号相同不能就认定背后对应的是同一个存储位置。Linux通过页表建立虚拟地址与物理内存之间的映射。在分页模型中虚拟地址空间按页划分物理内存中对应的单位可以称为页框。地址转换要确定对应的物理页框再结合页内偏移找到具体位置。页表由操作系统建立和维护实际访问时由CPU中的MMU等硬件依据映射完成地址转换和权限检查。对于前面的父子进程双方虽然都拿着0x601054但会使用各自的地址映射。相同的虚拟地址完全可以对应不同的物理位置这样父进程读到自己的数据子进程读到自己修改后的数据就没有矛盾了。2.3写时拷贝那是不是fork()刚创建子进程就把父进程的所有物理内存完整复制了一遍呢也不是。对于普通的私有数据页Linux可以先让父子进程共享相同的物理页等某一方确实需要写入时再进行必要的分离。这就是**写时拷贝也叫写时复制。以前面的g_val为例可以把关键过程分成两步尚未写入时父子进程各自拥有地址映射但相关私有页可以暂时指向同一个物理页。为了防止直接修改共享的原页对相应映射的写入会受到保护。子进程尝试修改g_val时如果该页仍处于写时拷贝的共享状态就会触发写保护相关的异常。内核识别出这是允许处理的写时拷贝复制相关页面、更新子进程的映射再让写入继续。父进程仍然看到原来的数据。这么做的好处也很直接如果子进程根本不修改很多页面就没有必要提前把这些页面全部复制一遍能够减少创建进程时的复制开销和物理内存消耗。3.操作系统描述地址空间3.1task_struct与mm_struct前面认识进程时提到操作系统要管理进程就需要先描述它再把描述进程的数据组织起来。地址空间的管理也是同样的思路。Linux通过task_struct描述任务其中的mm指针可以关联到mm_struct。mm_struct是内存描述符用来记录用户地址空间的相关信息。这里先讨论前面这样的普通用户进程。fork()产生的父子进程具有分开的地址空间描述先看整体关系比如下面这些字段就是用地址边界描述区域字段含义start_code、end_code代码范围的起止位置start_data、end_data数据范围的起止位置start_brk、brk传统堆区域的起点和当前边界start_stack与初始栈位置有关的信息arg_start、arg_end命令行参数字符串的地址范围env_start、env_end环境变量字符串的地址范围这就像描述一张桌子上的两个区域不需要把桌子装进结构体只需要记录区域从哪里开始、到哪里结束。区域发生变化时相应的描述信息也要更新。3.2通过VMA管理不同区域只知道整个地址空间里大致有哪些区域还不够。不同范围可能有不同权限也可能来自不同文件或不同的匿名映射操作系统需要进一步描述这些信息。Linux使用vm_area_struct来描述一个虚拟内存区域通常简称为VMA。一个VMA表示一段虚拟地址连续、具有相同相关属性的范围。这里有几个关键成员vm_start和vm_end描述区域边界范围是[vm_start, vm_end)不包含末尾的vm_end。vm_flags记录区域的相关标志和访问属性。vm_mm指向这个区域所属的mm_struct。一个地址空间中通常会有多个VMA。它们负责描述不同范围再由mm_struct统一关联和管理。Linux内核的进程地址文档图中可以看到task_struct找到mm_struct以后还能继续找到多个区域描述。每个区域都有自己的边界和属性这也可以解释堆区为什么可以是离散的我们可用通过一个链表那样的形式给他管理起来。把它和前面的整体布局结合起来就是下面这张图我这个是比较老的版本链表便于按地址顺序遍历红黑树便于查找较新的内核会不同具体我也没有去了解这里先贴出两个查考文档感兴趣可以看看旧版组织方式、当前内核说明。常见的数据区域可以读写代码区域可以读取和执行实际权限取决于具体映射。代码段、数据段等用途划分。到这里就可以把几个概念分开了task_struct描述任务mm_struct描述用户地址空间VMA描述其中的一段段区域页表则把虚拟页和物理页联系起来。可以看到Linux内核的设计真的是处处体现出了软件工程美学具体的实现非常的复杂我也看了不少的文档和资料也翻过源代码但是看得真的是一头雾水只希望能从中成长起来吧4.为什么需要虚拟地址空间如果不经过虚拟地址这一层而是把程序需要的代码和数据直接按一整块连续物理内存来安排会有什么问题呢比如一个简化模型里程序A需要10M程序B需要110M就分别给它们划出对应大小的连续区域主要有一下三点原因第一隔离和保护进程的数据。如果任意程序都能随意读写物理内存就可能破坏其他进程甚至内核的数据。有了各自的地址空间和访问权限一个普通进程不能只拿到另一个进程输出的地址就直接访问那个进程中的对象。对没有合法映射的地址进行访问或者尝试写入只读映射会受到硬件和内核的检查。比如字符串常量通常放在只读区域强行修改它可能触发段错误。第二让虚拟布局与物理位置分开。对程序来说一段空间可以在虚拟地址上连续但它背后对应的物理页不需要在物理内存中也连成一整块。这样操作系统就可以使用分散的物理页不必为了程序看到的一段连续地址强行寻找同样大小的连续物理内存。程序也不需要知道每一页究竟放在了哪一个物理位置从而实现了解耦合。第三支持按需分配减少不必要的内存开销。申请一段虚拟空间不一定意味着对应的全部物理页都已经分配并装好数据。对于可以按需建立映射的区域真正访问时发生缺页内核再根据区域是否合法、访问权限是否允许等信息准备相应物理页或装入内容并更新映射。尤其是在资源紧张的时候就可以通过缺页的方式需要的时候再把资源加载到物理内存完
返回列表