当前位置: 首页 > 科技观察

了解Linux内存管理,只有这篇文章

时间:2023-03-21 13:36:51 科技观察

内存管理应该是Linux内核中非常重要的一个子系统。之前一直在想怎么写一篇Linux内存管理的文章。因为内容太多太复杂,我想真的是一个简单易懂又不失专业性的考试。了解内部管理的实现原理,对内核开发者和应用程序开发者都有很大的帮助。本文也致力于用通俗易懂的语言带领大家理解内存管理的原理,当然一些理论知识也是少不了的。我们的目的不是探索理论,而是更全面地理解原理。必要时,我们会深入理论,窥视理论知识背后。进程和内存我们都知道进程需要内存才能运行。主要用于存放从存储介质(磁盘/闪存/...)加载的程序代码和进程运行所需的数据内容。在我的另一篇如何深入理解堆和栈的文章中,对进程的组成有讲解。对于一个进程,会有5个不同的数据段。代码段(文本):代码段用于存放可执行文件的运行指令,也就是说存放可执行程序在内存中的图像。代码段不允许修改,所以只能进行读操作,不允许写操作。数据段(data):数据段主要用来存放初始化的全局变量,也就是存放程序静态分配的变量(静态分配内存是指编译器在编译程序时根据源程序分配内存.动态分配内存是程序编译完成后,运行时调用runtime库函数分配内存,静态分配是在程序运行前进行的,所以速度快,效率高,但局限性大。动态分配是在程序运行时进行的正在运行,所以速度较慢。但灵活性高。)和全局变量。bss段:bss段包含程序中未初始化的全局变量,所有bss段会在内存中被统一清除。(引申:这就是为什么未初始化的全局变量会被清除的原因)堆(heap):堆用于存放进程动态分配的内存,其大小不固定。详见如何深入理解堆和栈:栈用来存放临时变量,即C程序中{}中的变量,不包括static声明的变量(虽然static是局部的变量,它的作用范围在{},但它的生命周期是整个程序的生命周期,存放在数据段)。当程序调用函数时,参数过多的函数会通过栈将参数压入栈中,调用完成后,函数的返回值也会通过栈返回。从这个意义上说,我们可以把栈看成是一块存储和交换临时数据的内存区域。具体可以参考《如何深入理解堆和栈》一文。通过程序对内存的不同使用,分为以上五个不同的段。这些段在内存中是如何组织的?看下图:从图中不难发现,栈似乎是挨着的。向下“长”(在i386架构中栈向下,堆向上),向上“长”一个,比较生。但是你不用担心他们会撞到对方,因为他们之间的距离真的很远。从用户态到内核态,我们使用的内存形式的变化:逻辑地址通过段机制转换为线性地址;通过页机制将线性地址转换为物理地址。(但要知道虽然Linux系统保留了段机制,但是所有程序的段地址都固定为0-4G,所以虽然逻辑地址和线性地址是两个不同的地址空间,但是Linux中的逻辑地址只是等于线性地址,它们的值相同)。沿着这个思路,我们研究的主要问题将集中在以下几个问题上。进程地址空间是如何管理的?进程地址如何映射物理内存?物理内存是如何管理的?让我们来看看。进程地址空间现代操作系统基本上都使用虚拟内存管理技术。当然,作为高级操作系统的Linux也不例外。每个进程都有自己的进程地址空间。这个空间是一个4G的线性虚拟空间。用户态暴露的是虚拟地址,根本看不到物理地址,也不需要关心物理地址。使用这种虚拟地址方式可以保护内存资源,起到隔离的作用。而对于用户程序来说,大小始终为4G,代码段的地址可以在程序编译时确定。我们应该知道三件事:4G进程地址空间被人为地分成了两部分——用户空间和内核空间。用户空间从0到3G(0xC0000000),内核空间占用3G到4G。通常情况下,用户进程只能访问用户空间的虚拟地址,不能访问内核空间的虚拟地址。只有用户进程在进行系统调用(在内核态代表用户进程执行)时才能访问内核空间。每当进程切换时,用户空间发生变化,内核空间被内核映射。它不随过程而改变。内核空间有自己对应的页表(init_mm.pgd),用户进程有自己的页表。每个进程的用户空间都是独立的。进程内存管理进程内存管理的对象是进程线性地址空间上的内存映像。这些内存映像实际上是进程使用的虚拟内存区域(内存区域)。进程虚拟空间是一个32位或64位“平面”(独立连续范围)地址空间(空间的确切大小取决于体系结构)。统一管理这么大的平面空间实属不易。为了方便管理,虚拟空间被划分为许多大小可变(但必须是4096的倍数)的内存区域,这些内存区域就像进程线性地址中的停车位一样有序排列。这些区域的划分原则是“将具有一致访问属性的地址空间存储在一起”,这里所谓的访问属性是指“可读、可写、可执行等”。如果想查看某个进程占用的内存区域,可以使用命令cat/proc//maps获取(pid为进程号),会发现如下列表:08048000-08049000r-xp0000000003:03439029/home/mm/src/example08049000-0804a000rw-p0000000003:03439029/home/mm/src/example…………bfffe000-c0000000rwxpf??fff00000:000每行数据格式如下:(内存区)start-end访问权限偏移majordevicenumber:Minordevicenumberi-nodefile.注意:你一定会发现进程空间只包含三个内存区。好像没有上面说的heap、bss等。事实上,情况并非如此。程序内存段和进程地址空间中的内存区是一种模糊对应关系,也就是说,堆,bss,数据段(已初始化)都由进程空间中的数据段内存区来表示。Linux内核中表示内存区域的数据结构是vm_area_struct,内核将每一个内存区域作为一个单独的内存对象进行管理。面向对象的方式使得VMA结构可以表示各种类型的内存区域,包括内存映射文件和进程用户空间栈等,而且对这些区域的操作方式也各不相同。vm_area_strcut的结构比较复杂,详细结构请参考相关资料。此处仅对其组织方法做一点补充说明。vm_area_struct是描述进程地址空间的基本管理单元。对于一个进程来说,往往需要多个内存区域来描述它的虚拟空间。如何关联这些不同的内存区域呢?大家可能会想到用链表。确实,vm_area_struct结构体确实是以链表的形式链接起来的,但是为了方便查找,内核将内存区域组织成红黑树的形式(之前的内核使用的是平衡树)来减少搜索时间。两种并存的组织形式并不冗余:链表用于需要遍历所有节点的时候,红黑树适合在地址空间中定位特定的内存区域。内核使用这两种数据结构来实现对内存区域的各种操作的高性能。下图反映了进程地址空间的管理模型:进程的地址空间对应的描述结构是“内存描述结构”,它代表了进程的整个地址空间,包括了与进程相关的所有信息地址空间,当然包括进程的内存区域。进程内存是如何分配和回收的?我们知道的一些系统调用,比如创建进程fork()、程序加载execve()、映射文件mmap()、动态内存分配brk()等,都需要为进程分配内存。但是此时进程获取的并不是实际的物理内存,而是虚拟内存。其实它只代表内核中的“内存区”。进程对内存区域的分配最终是在内核中的do_mmap()函数上进行的(brk除外)。内核使用do_mmap()函数创建一个新的线性地址范围。然后将地址范围添加到进程的地址空间,可能会创建一个新区域或扩展现有内存区域。当然,对应内存区域的释放是使用函数do_ummap()。内存如何由虚变实?从上面我们看到,进程可以直接操作的地址都是虚拟地址。当一个进程需要内存时,它从内核得到的只是一块虚拟内存区域,而不是实际的物理地址。进程并没有得到物理内存(物理页——页的概念请参考硬件基础章节),你得到的只是一个新的线性地址范围的使用权。在实际物理内存中,只有当进程真正访问到新获取的虚拟地址时,“页面请求机制”才会产生“pagefault”异常,然后进入分配实际页面的函数。这个异常是虚拟内存机制存在的基本保证——它会告诉内核实际为进程分配物理页并创建相应的页表,之后虚拟地址才真正映射到系统的物理内存.(当然,如果页面被换出到磁盘,也会出现pagefault异常,不过此时不需要建立页表。)这种请求页面机制延迟了页面的分配,直到无法再被推迟,并不急于分配所有的Everythingisdoneonce(这个思路有点像设计模式中的代理模式(proxy))。之所以能做到这一点,是利用了内存访问的“局部性原则”。请求页面的好处是节省空闲内存并提高系统的吞吐量。更清楚的了解请求页面机制,可以阅读《深入理解linux内核》一书。这里需要说明一下对内存区结构的nopage操作。当被访问的进程虚拟内存实际上没有分配页面时,会调用这个操作来分配一个实际的物理页面,并为该页面创建一个页表项。在最后一个示例中,我们将演示如何使用此方法。如何管理物理内存?虽然应用程序操作的对象是映射到物理内存的虚拟内存,但是处理器直接操作物理内存。所以当应用程序访问虚拟地址时,必须先将虚拟地址转换为物理地址,然后处理器才能解析地址访问请求。地址转换工作需要通过查询页表来完成。简而言之,地址转换需要将虚拟地址分成段,让虚拟地址的每一段作为索引指向页表,页表入口指向下一级页表或指向最终的物理页面。每个进程都有自己的页表。进程描述符的pgd字段指向进程的页全局目录。我们借用《linux设备驱动程序》的一张图,大致看一下进程地址空间和物理页的转换关系。上述过程说起来容易做起来难。因为在虚拟地址映射到页面之前必须先分配物理页面——也就是说必须先从内核获取空闲页面,并且必须建立页表。下面介绍一下内核管理物理内存的机制。Linux内核通过分页机制管理物理内存,将整个内存分成无数个4k(在i386架构中)的页,所以内存分配和回收的基本单位就是内存页。使用分页管理有助于灵活分配内存地址,因为在分配时不需要有一大块连续的内存,系统可以将需要的内存从一页收集到另一页供进程使用。即便如此,实际上系统在使用内存时还是倾向于分配连续的内存块,因为分配连续内存时不需要改变页表,所以可以降低TLB的刷新率(频繁刷新会大大降低访问速度).鉴于上述要求,为了尽量减少内核分配物理页时的不连续性,采用“伙伴”关系来管理空闲页。您应该熟悉伙伴关系分配算法。不明白的可以查阅相关资料。在这里你只需要明白Linux中空闲页的组织和管理是利用伙伴关系的,所以空闲页的分配也需要遵循伙伴关系,最小单位只能是2的幂的页大小。基本在内核中分配空闲页面的函数是get_free_page/get_free_pages,它们要么分配单个页面,要么分配指定的页面(2、4、8...512页)。注意:get_free_page在内核分配内存,不同于用户空间的malloc。Malloc使用堆动态分配并实际调用brk()系统调用。这个调用的作用是扩大或缩小进程的堆空间(会修改进程的brk域)。如果现有的内存区域不足以容纳堆空间,则相应的内存区域将按页面大小的倍数进行扩展或收缩,但brk值不会按页面大小的倍数进行修改,而是根据实际情况进行修改要求。因此,malloc可以在用户空间以字节为单位分配内存,但内核在内部还是会以页为单位进行分配。另外需要说明的是,物理页在系统中是用页结构structpage来描述的,系统中的所有页都存储在数组mem_map[]中,每个页(free或non-free)在系统可以通过这个数组找到。其中的空闲页面可以通过上述合作组织的空闲页面列表(free_area[MAX_ORDER])来索引。什么是平板?内核管理系统中物理内存以页为最小单位分配内存确实更方便,但内核本身最常使用的内存往往是一个非常小(远小于页)的内存块———例如,存储文件描述符、进程描述符和虚拟内存区域描述符所需的内存不到一页。用于存储描述符的内存与页面相比就像面包屑和面包。多个这样的小内存块可以聚集在一个完整的页面中;这些小内存块的生成/销毁频率与面包屑一样频繁。为了满足内核对这么小的内存块的需求,Linux系统使用了一种叫做slaballocator的技术。Slab分配器的实现比较复杂,但是原理并不难。核心思想是使用“存储池”。内存碎片(小内存块)被视为对象。当它们用完后,并不直接释放,而是缓存在“存储池”中,以备下次使用。这无疑避免了对象的频繁创建和销毁。到额外的负载。Slab技术不仅避免了内部内存碎片带来的不便)(引入Slab分配器的主要目的是减少对伙伴系统分配算法的调用次数——频繁的分配和回收必然导致内存碎片——很难寻找大块连续可用内存),并且可以很好地利用硬件缓存来提高访问速度。Slab并不是一种没有伙伴关系而独立存在的内存分配方式。Slab仍然是基于页面的。换句话说,Slab将页面(来自合作伙伴管理的免费页面列表)切碎成许多小的内存。slab中用于分配、对象分配和销毁的块使用kmem_cache_alloc和kmem_cache_free。kmalloc()实验室分配器不仅用于存储特定于内核的结构,还用于处理内核对小块内存的请求。当然,鉴于Slab分配器的特点,一般来说,内核程序中对小于一页的小块内存的请求是通过Slab分配器提供的接口Kmalloc完成的(虽然它可以分配32到131072字节内存)。从内核内存分配的角度来看,kmalloc可以看作是对get_free_page(s)的有效补充,内存分配粒度更加灵活。有兴趣的可以到/proc/slabinfo中查找内核执行站点使用的各种slab信息统计,在这里可以看到系统中所有slab的使用信息。从信息中可以看出,除了特殊结构使用的slab之外,系统中还有大量为Kmalloc准备的Slab(其中有一部分是为dma准备的)。无论是partnership还是slab技术,内存管理从理论上讲,目的基本一致。都是为了防止“碎片化”。但是分片又分为外部分片和内部分片。contiguous)不得不为其分配一大片连续的内存,造成空间的浪费;外部碎片是指系统虽然有足够的内存,但却是零散的碎片,无法满足大“连续内存”的需求。任何一种碎片都是系统有效使用内存的障碍。slab分配器使得一个页中包含的许多小内存块可以独立分配和使用,避免了内部碎片,节省了空闲内存。分配不是盲目的,而是按大小排序,但合伙只是减少了外部碎片,并没有完全消除。自己算出在多次页面分配后剩余的可用内存。因此,避免外部碎片的最终思路还是落在了如何利用不连续的内存块组合成“看似很大的内存块”——这里的情况与用户空间虚拟内存的分配很相似。记忆在逻辑上是连续的。实际上,到物理内存的映射不一定是连续的。Linux内核借用了这项技术,让内核程序在内核地址空间分配虚拟地址,也使用页表(kernelpagetables)将虚拟地址映射到分散的内存页。这样就完美的解决了内核内存使用中的外部碎片问题。内核提供了vmalloc函数来分配内核虚拟内存。这个函数不同于kmalloc。它可以分配比Kmalloc大得多的内存空间(可以远大于128K,但必须是页大小的倍数),但是相比于Kmalloc,Vmalloc需要重新映射内核虚拟地址,必须更新内核页表,所以分配效率较低(以空间换时间)。vmalloc分配的内核虚拟内存和kmalloc/get_free_page分配的内核虚拟内存位于不同的区间,不会重叠。因为内核虚拟空间是由分区管理的,各司其职。进程空间的地址分布从0到3G(实际上是到PAGE_OFFSET,等于0x86中的0xC0000000),从3G到vmalloc_start的地址是物理内存映射区(这个区域包括内核映像,物理页表mem_map等)比如我使用的系统内存是64M(有free可以看出),那么(3G——3G+64M)这块内存应该映射到物理内存,vmalloc_start的位置应该是near3G+64M(说“near”是因为物理内存映射区和vmalloc_start之间会有8M的空隙,防止跳转),vmalloc_end的位置接近4G(“close”是因为系统会预留一个128k最后一个位置的专用页映射区),还有可能是高端内存映射区,这些都是细节,这里不纠结)。内存分配的模糊轮廓get_free_page或Kmalloc函数分配的连续内存被困在物理映射区,所以内核虚拟地址和它们返回的实际物理地址只是一个偏移量(PAGE_OFFSET),你可以很容易地设置它是转换成物理内存地址,内核还提供了virt_to_phys()函数,将内核虚拟空间中物理映射区的地址转换成物理地址。要知道,物理内存映射区中的地址是有序对应内核页表的,系统中的每个物理页都可以找到其对应的内核虚拟地址(在物理内存映射区中)。vmalloc分配的地址限制在vmalloc_start和vmalloc_end之间。vmalloc分配的每一块内核虚拟内存都对应一个vm_struct结构(不要和vm_area_struct混淆,vm_area_struct是进程虚拟内存区的结构),不同的内核虚拟地址之间用4k空闲区隔开,防止交叉border——见下文)。和进程虚拟地址的特性一样,这些虚拟地址与物理内存没有简单的位移关系,必须经过内核页表才能转换为物理地址或物理页。它们可能还没有被映射,物理页面实际上是在发生页面错误时分配的。