从底层原理出发,了解 Linux 内核之内存管理
本文讲解更加底层,基本都是从 Linux 内核出发,会更深入。所以当你都读完,然后再次审视这些功能的实现和设计时,我相信你会有种豁然开朗的感觉。
1、页
内核把物理页作为内存管理的基本单元。
尽管处理器的最小处理单位是字(或者字节),但是 MMU(内存管理单元,管理内存并把虚拟地址转换为物理地址的硬件)通常以页为单位进行处理。所以从虚拟内存看,页也是最小单元。
体系不同,支持的页大小不同。大多数 32 位体系结构支持 4KB 的页,而 64 位体系结构一般会支持 8KB 的页。
内核用 struct page 结构体表示系统中的每个页,包含很多项比如页的状态(有没有脏,有没有被锁定)、引用计数(-1 表示没有使用)等等。
page 结构和物理页相关,和虚拟内存无关。所以它的描述是短暂的,仅仅记录当前的使用状况,当然也不会描述其中的数据。
内核用这个结构来管理系统中所有的页,所以内核知道哪些页是空闲的,如果在使用中拥有者又是谁。
这个拥有者有四种:用户空间进程、动态分配内存的内核数据、静态内核代码以及页高速缓存。
2、区
有些页是有特定用途的。比如内存中有些页是专门用于 DMA 的。
内核使用区的概念将具有相似特性的页进行分组。区是一种逻辑上的分组的概念,而没有物理上的意义。
区的实际使用和分布是与体系结构相关的。在 x86 体系结构中主要分为 3 个区:ZONE_DMA,ZONE_NORMAL,ZONE_HIGHMEM。
ZONE_DMA 区中的页用来进行 DMA(直接内存访问)时使用。
ZONE_HIGHMEM 是高端内存,其中的页不能永久的映射到内核地址空间,也就是说,没有虚拟地址。
剩余的内存就属于 ZONE_NORMAL 区,叫低端内存。
不是所有体系都定义全部区,有些体系结构,比如 x86-64 可以映射和处理 64 位的内存空间,所以它没有 ZONE_HIGHMEM 区,所有的物理内存都都处于 ZONE_DMA 和 ZONE_NORMAL 区。
每个区都用结构体 struct zone 表示。
彻底掌握 Linux 内核内存管理,学习知识点内容:
第一天 剖析 Linux 内核内存管理(一)
1.1 内存泄漏/栈溢出
1.2 虚拟地址布局/内存映射
1.3 内存模型/页分配器
1.4 伙伴分配器/块分配器
第二天 剖析 Linux 内核内存管理(二)
2.1 kmalloc/vmalloc 系统调用
2.2 高速缓存/内存屏障
2.3 页表缓存/页回收机制
2.4 缺页中断/反碎片技术
Linux 内核内存管理直播训练营地址:
Linux内核内存管理专题训练营-学习视频ke.qq.com
3、接口
获得页
获得页使用的接口是 alloc_pages 函数与__get_free_page 函数。后者也是调用了前者,只不过在获得了 struct page 结构体后使用 page_address 函数获得了虚拟地址。
我们在使用这些接口获取页的时候可能会面对一个问题,我们获得的这些页若是给用户态用,虽然这些页中的数据都是随机产生的垃圾数据,不过,虽然概率很低,但是也有可能会包含某些敏感信息。所以,更谨慎些,我们可以将获得的页都填充为 0。这会用到 get_zeroed_page 函数。而这个函数又用到了__get_free_pages 函数。
所以这三个函数最终都是使用了 alloc_pages 函数。
释放页
当我们不再需要某些页时可以使用下面的函数释放它们:__free_pages(struct page *page, unsigned int order)free_pages(unsigned long addr, unsigned int order)free_page(unsigned long addr)
以上这些接口都是以页为单位进行内存分配与释放的。
kmalloc 与 vmalloc
在实际中内核需要的内存不一定是整个页,可能只是以字节为单位的一片区域。这两个函数就是实现这样的目的。
不同之处在于,kmalloc 分配的是虚拟地址连续,物理地址也连续的一片区域,vmalloc 分配的是虚拟地址连续,物理地址不一定连续的一片区域。
对应的释放内存的函数是 kfree 与 vfree。
4、slab 层
以页为最小单位分配内存对于内核管理系统中的物理内存来说的确比较方便,但内核自身最常使用的内存却往往是很小的内存块——比如存放文件描述符、进程描述符、虚拟内存区域描述符等行为所需的内存都远不及一页,一个整页中可以聚集多个这些小块内存。
为了满足内核对这种小内存块的需要,Linux 系统采用了一种被称为 slab 分配器(也称作 slab 层)的技术。slab 分配器的实现相当复杂,但原理不难,其核心思想就是“存储池”的运用。内存片段(小块内存)被看作对象,当被使用完后,并不直接释放而是被缓存到“存储池”里,留做下次使用,这无疑避免了频繁创建与销毁对象所带来的额外负载。
slab 分配器扮演了通用数据结构缓存层的角色。
slab 层把不同的对象划分为所谓高速缓存组,其中每个高速缓存组都存放不同类型的对象,每种对象对应一个高速缓存。
常见的高速缓存组有:进程描述符(task_struct 结构体),索引节点对象(struct inode),目录项对象(struct dentry),通用页对象等等。
这些高速缓存又被划分为 slab。slab 由一个或多个物理连续的页组成,一般仅仅由一页组成。每个高速缓存可以由多个 slab(页)组成。
每个高速缓存都使用 struct kmem_cache 结构表示,这个结构包含三个链表:slabs_full、slabs_partial 和 slabs_empty,均放在 kmem_list3 结构体内。这些链表的每个元素为 slab 描述符即 struct slab 结构体。
每个高速缓存需要创建新的 slab 即新的页,还是通过上面提到的__get_free_page()来实现的。通过最终调用 free_pages()释放内存页。
一个高速缓存的创建和销毁使用 kmem_cache_create 与 kmem_cache_destroy。
高速缓存中的对象的分配和释放使用 kmem_cache_alloc 与 kmem_cache_free。
从上看出,slab 层仍然是建立在页的基础之上,可以总结为 slab 层将 空闲页 分解成 众多相同长度的小块内存 以供 同类型的数据结构 使用。
5、进程地址空间
以上我们讲述了内核如何管理内存,内核内存分配机制包括了页分配器和 slab 分配器。内核除了管理本身的内存外,也必须管理用户空间中进程的内存。
我们称这个内存为进程地址空间,也就是系统中每个用户空间进程所看到的内存。Linux 系统采用虚拟内存技术,所有进程以虚拟方式共享内存。Linux 中主要采用分页机制而不是分段机制。
5.1 地址空间布局
进程内存区域可以包含各种内存对象,从下往上依次为:
(1)可执行文件代码的内存映射,称为代码段。只读可执行。
(2)可执行文件的已初始化全局变量的内存映射,称为数据段。后续都是可读写。
(3)包含未初始化的全局变量,就是 bass 段的零页的内存映射。
(4)堆区,动态内存分配区域;包括任何匿名的内存映射,比如 malloc 分配的内存。
(5)栈区,用于进程用户空间栈的零页内存映射,这里不要和进程内核栈混淆,进程的内核栈独立存在并由内核维护,因为内核管理着所有进程。所以内核管理着内核栈,内核栈管理着进程。
(6)其他可能存在的:内存映射文件;共享内存段;C 库或者动态链接库等共享库的代码段、数据段和 bss 也会被载入进程的地址空间。
这份是基于 Linux 内核 4.0 版本的内核学习路线思维导图,下面有 Linux 内核相关视频学习资料:
Linux 内核相关学习视频,清晰版导图可以点击:学习资料 获取
5.2 内存描述符
内核使用内存描述符 mm_struct 结构体表示进程的地址空间,该结构体包含了和进程地址空间有关的全部信息。
mmap 和 mm_rb 描述的对象是一样的:该地址空间中全部内存区域(all memory areas)。
mmap 是以链表的形式存放,而 mm_rb 是以红黑树存放,前者有利于遍历所有数据,而后者有利于快速搜索定位到某个地址。所有的 mm_struct 结构体都通过自身的 mmlist 域连接在一个双向链表中,该链表的首元素是 init_mm 内存描述符,它代表 init 进程的地址空间。
再往下看,可以看到地址空间几个区(堆栈)对应的变量的定义。
我们再回顾下在内核进程管理中,进程描述符 task_struct 是在内核空间中缓存,也就是我们上面描述的 slab 层。
而 task_struct 中有个 mm 域指向的就是该进程使用的内存描述符,再通过 current->mm 便可以指向当前进程的内存描述符。fork 函数利用 copy_mm()函数就实现了复制父进程的内存描述符,而子进程中的 mm_struct 结构体实际是通过文件 kernel/fork.c 中的 allocate_mm()宏从 mm_cachep slab 缓存中分配得到的。通常,每个进程都有唯一的 mm_struct 结构体。
因为进程描述符和进程的内存描述符都是处于 slab 层,所以它们元素的分配和释放都由 slab 分配器来管理。
5.3 虚拟内存区域
内存区域由 vm_area_struct 结构体描述,见上面的 mmap 域,内存区域在内核中也经常被称作虚拟内存区域(Virtual Memory Area,VMA)。
它描述了指定地址空间内连续区间上的一个独立内存范围。
内核将每个内存区域作为一个单独的内存对象管理,每个内存区域都拥有一致的属性。结构体如下:
每个内存描述符都对应于地址进程空间中的唯一区间。vm_mm 域指向和 VMA 相关的 mm_struct 结构体。
一个内存区域的地址范围是[vm_start, vm_end),vm_next 指向该进程的下一个内存区域。
两个独立的进程将同一个文件映射到各自的地址空间,它们分别都会有一个 vm_area_struct 结构体来标志自己的内存区域;但是如果两个线程共享一个地址空间,那么它们也同时共享其中的所有 vm_area_struct 结构体。
在上面的 vm_flags 域中存放的是 VMA 标志,标志了内存区域所包含的页面的行为和信息。和物理页访问权限不同,VMA 标志反映了内核处理页面所需要遵循的行为准则,而不是硬件要求。而且 vm_flags 同时包含了内存区域中每个页面的消息或者内存区域的整体信息,而不是具体的独立页面。如下表所述:
开头三个标志表示代码在该内存区域的可读、可写和可执行权限。
第四个标志 VM_SHARD 说明了该区域包含的映射是否可以在多进程间共享,如果被设置了,表示共享映射;否则未被设置,表示私有映射。
其中很多状态在实际使用中都非常有用。
5.4 mmap()和 do_mmap():创建地址空间
内核使用 do_mmap()函数创建一个新的线性地址空间。但如果创建的地址区间和一个已经存在的地址区间相邻,并且它们具有相同的访问权限的话,那么两个区间将合并为一个。如果不能合并,那么就确实需要创建一个新的 vma 了,但无论哪种情况,do_mmap()函数都会将一个地址区间加入到进程的地址空间中。这个函数定义在 linux/mm.h 中,如下
这个函数中由 file 指定文件,具体映射的是文件中从偏移 offset 处开始,长度为 len 字节的范围内的数据,如果 file 参数是 NULL 并且 offset 参数也是 0,那么就代表这次映射没有和文件相关,该情况被称作匿名映射(file-backed mapping)。如果指定了文件和偏移量,那么该映射被称为文件映射(file-backed mapping)。
其中参数 prot 指定内存区域中页面的访问权限:可读、可写、可执行。
flag 参数指定了 VMA 标志,这些标志指定类型并改变映射的行为,请见上一小节。
如果系统调用 do_mmap 的参数中有无效参数,那么它返回一个负值;否则,它会在虚拟内存中分配一个合适的新内存区域,如果有可能的话,将新区域和临近区域进行合并,否则内核从 vm_area_cachep 长字节(slab)缓存中分配一个 vm_area_struct 结构体,并且使用 vma_link()函数将新分配的内存区域添加到地址空间的内存区域链表和红黑树中,随后还要更新内存描述符中的 total_vm 域,然后才返回新分配的地址区间的初始地址。
在用户空间,我们可以通过 mmap()系统调用获取内核函数 do_mmap()的功能。
5.5 munmap()和 do_munmap():删除地址空间
do_mummp()函数从特定的进程地址空间中删除指定地址空间,该函数定义在文件 linux/mm.h 中,如下:
第一个参数指定要删除区域所在的地址空间,删除从地址 start 开始,长度为 len 字节的地址空间,如果成功,返回 0,否则返回负的错误码。
与之相对应的用户空间系统调用是 munmap,它是对 do_mummp()函数的一个简单封装。
5.6 malloc()的实现
我们知道 malloc()是 C 库中实现的。C 库对内存分配的管理还有 calloc()、realloc()、free()等函数。
事实上,malloc 函数是以 brk()或者 mmap()系统调用实现的。
brk 和 sbrk 主要的工作是实现虚拟内存到内存的映射。在 Linux 系统上,程序被载入内存时,内核为用户进程地址空间建立了代码段、数据段和堆栈段,在数据段与堆栈段之间的空闲区域用于动态内存分配。我们回到内存结构 mm_struct 中的成员变量 start_code 和 end_code 是进程代码段的起始和终止地址,start_data 和 end_data 是进程数据段的起始和终止地址,start_stack 是进程堆栈段起始地址,start_brk 是进程动态内存分配起始地址(堆的起始地址),还有一个 brk(堆的当前最后地址),就是动态内存分配当前的终止地址。所以 C 库的 malloc()在 Linux 上的基本实现是通过内核的 brk 系统调用。brk()是一个非常简单的系统调用,内核再执行 sys_brk()函数进行内存分配,只是简单地改变 mm_struct 结构的成员变量 brk 的值。而 sbrk 不是系统调用,是 C 库函数。系统调用通常提供一种最小功能,而库函数通常提供比较复杂的功能。
下面我们整理一下在进程空间堆中用 brk()方式进行动态内存分配的流程:
C 库函数 malloc()调用 Linux 系统调用函数 brk(),brk()执行系统调用陷入到内核,内核执行 sys_brk()函数,sys_brk()函数调用 do_brk()进行内存分配
malloc()---------->brk()-----|----->sys_brk()----------->do_brk()------------>vma_merge()/kmem_cache_zalloc()
用户空间------> | 内核空间
系统调用---------->
mmap()系统调用也可以实现动态内存分配功能,即 5.4 节我们提到的匿名映射。
那什么时候调用 brk(),什么时候调用 mmap()呢?通过阈值 M_MMAP_THRESHOLD 来决定。该值默认 128KB。可以通过 mallopt()来进行修改设置。
所以当需要分配的内存大于该阈值,选择 mmap();否则小于等于该阈值,选择 brk()分配。
最后,mmap 分配的内存在调用 munmap 后会立即返回给系统,而 brk/sbrk 而受 M_TRIM_THRESHOLD 的影响。该环境变量同样通过 mallopt()来设置,该值代表的意义是释放内存的最少字节数。
但 brk/sbrk 分配的内存是否立即归还给系统,不仅受 M_TRIM_THRESHOLD 的影响,还要看高地址端(brk 处)的内存是否已经释放:
假如依次 malloc 了 str1、str2、str3(str3 在最上端,结束地址为 brk),即使它们都是 brk/sbrk 分配的,如果没有释放 str3,只释放了 str1 和 str2,就算两者加起来超过了 M_TRIM_THRESHOLD,因为 str3 的存在,str1 和 str2 也不能立即归还可以系统,即这些内存都被 str3 给“拴住”了。
此时,str1 和 str2 的内存只是简单的标记为“未使用”,如果这两处内存是相邻的则会进行合并,这种算法也称为“伙伴内存算法(buddy memory allocation scheme)”。这种算法高速简单,但同时也会生成碎片。包括内碎片(一次分配内存不够整页,最后一页剩下的空间)和外碎片(多次或者反复分配造成中间的空闲页太小不够后续的一次分配)。
从上可以看出,在一定条件下,假如释放了 str3 的内存,堆的大小是可以紧缩的。
最后我们以一张图结束今天的主题,内存分配流程图:
评论