Linux 内存管理笔记

Linux 内存管理笔记

tk_sky 186 2024-03-29

Linux 内存管理笔记

地址

虚拟地址

内存是比较宝贵的资源。为了充分利用和管理系统内存资源,linux 采用虚拟内存管理技术,让每个进程都有 4G (32位)的互不干涉的虚拟空间。进程初始化分配和操作都基于虚拟地址,只有当进程需要实际访问内存资源的时候才会建立虚拟地址和物理地址的映射,调入物理内存页。

使用虚拟地址的好处:

  • 用户不能直接访问物理地址,可以防止一些破坏性操作
  • 进程可以使用比实际物理内存更大的地址空间

其中,4GB 的虚拟内存被分为用户空间和内核空间。

物理地址

物理内存页对应实际的磁盘页。当进程需要实际访存的时候,内核会通过缺页异常调入物理内存页。

内核通过 MMU 内存管理单元来将虚拟地址转换为物理地址。虚拟地址分为段选择符和偏移量,通过分段机制对应到线性地址,然后又通过分页机制对应到具体的物理地址。

内核会把物理内存分成三个管理区:

  • ZONE_DMA:DMA的内存区域,一对一映射到内核的地址空间
  • ZONE_NORMAL:普通内存区,一对一映射到内核的地址空间
  • ZONE_HIGHMEM:高端内存区,不做直接映射,用于32位机子访问超过32位地址空间的内存

用户空间

用户空间是用户进程能够访问的空间,每个进程都有独立的用户空间。

进程占用的用户空间划分为五个区域:

  • 代码段:

​ 存放可执行文件的操作指令,是可执行文件在内存的镜像。只读。

  • 数据段:

    存放可执行文件的已初始化的全局变量,也就是静态分配的变量和全局变量。

  • BSS段:

    包含未初始化的全局变量,值置为0

  • 堆:

    存放进程运行中动态分配的内存段,大小不固定,可以动态扩展。使用 malloc/free 函数时会在堆中进行操作

  • 栈:

    栈用来存储程序的临时变量,也就是函数中定义的变量。除此之外还会用于传递函数的参数和返回值。因为先进先出的特点,非常适合保存和恢复现场。

上面五个区域中数据段、BSS段和堆是连续存储在内存中的,代码段和栈独立存放。其中堆在他们三个的连续段中在最上层,而栈就在堆的上层(高地址),堆向上扩展,栈向下扩展。区域划分的原则是将访问属性(可读/可写/...)相同的放在一起。

内核空间

内核空间是虚拟地址的高端内存地址空间,32位机器的4G虚拟内存中占1G,包含了内核镜像、物理页面表、驱动程序等。

直接映射区是从内核空间起始开始的,对32位机器来说最大896MB的区间,这里用来一对一线性连续地映射物理地址的前896MB,因此和实际物理地址的差距只有一个offset。

高端内存线性地址空间是更高128MB的空间,用来以非一对一的方式达到对整个物理地址范围的寻址。具体来说,就是要用的时候就临时借用一段逻辑地址,临时用它访问物理内存,之后归还。64位机器没有这个问题,因为线性地址足够大了。这里被分为动态内存映射区、永久内存映射区和固定映射区。

动态内存映射区是通过 vmalloc() 分配的,这个函数用于非连续内存和高端内存的分配。使用这个函数时映射会分配到这个区域。这个函数会为内核程序在内核空间中分配虚拟地址,通过页表将虚拟地址映射到分散的内存上。

持久内存映射区用来给 kmap() 手动请求的映射做分配,把一个已分配的 page 映射到这个空间来。对于不用的 page 要把映射及时释放。

除此之外还有一个固定内存映射区,用于内核映射一些特别用途的内存。

image-20240321105054655

用户空间内存管理

用户空间内存管理就是进程内存管理,管理对象是进程线性地址空间上的内存镜像,实际使用的是虚拟内存区域(VMA)。

对于用户空间,要管理代码段、数据段、BSS、堆、栈,内核将这些区域抽象成 vm_area_struct 内存管理对象。vm_area_struct 是描述进程地址空间的基本管理单元,对于一个进程来说使用链表来链接多个 vm_area_struct 从而关联多个内存区域,同时使用红黑树降低搜素耗时。

image-20240321104952883

进程内存的分配时机有 fork() 创建进程、execve() 载入程序、mmap() 映射文件、malloc/brk()动态内存分配等操作。这时进程申请和获得的都是虚拟内存,最终都会走到 do_mmap() 上来。

do_mmap 函数用于内核创建一个新的线性地址区间,要么创建一个新的VMA,要么和相邻的相同访问权限的区间合并。同理 do_ummap() 会销毁一个内存区域。

分配完成后进程获得的只有一个对新的线性地址区间的使用权,而实际的物理内存只有进程确实去访存时才会由请求页机制产生缺页异常,然后才去实际分配页面、建立页表。

内核空间内存管理

内核空间一大特点就是由 vmalloc 分配的动态内存映射区。使用 vmalloc 分配的特点是线性空间连续,但对应的物理空间不一定连续,物理页既可以是低端内存也可以是高端内存。

每个 vmalloc 分配的内核虚拟内存都对应一个 vm_struct 结构体,不同虚拟地址空间之间有 4K 大小的防越界空闲隔离区。

和用户空间的类似,这些虚拟地址到物理内存都需要经过内核页表才能转换为物理地址或物理页,并在发生缺页时才分配页面。

物理内存管理

我们说内存就是分段和分页管理,那么对物理内存就是分页 page 来管理。linux 把物理内存划分为 4K 为单位的(内存)页,也叫页框。

通过分页,可以显著降低管理的难度,针对小内存可以预先分配一页避免频繁申请,针对大内存可以拼凑多页而不必要求大块连续内存。但是分页以后仍然存在内碎片和外碎片的问题,需要页面管理算法来进行优化。

linux 引入伙伴算法,也就是嵌入式os的伙伴算法,和 golang 的用户态内存分配也有点像。伙伴算法把所有空闲的页框分组成11个链表,分别装大小为 1,2,4,8,...,1024 个连续 page 的页框块,最大1024也就意味着最大可以分配 4MB 的连续内存。因为任何大小都可以分为2的x次幂的和,所以任何大小的申请都不会产生外碎片。

例如,如果要申请4个 page 大小的块,就会在大小为4的链表里找,如果找到了就取出返回;如果没有,就会从大小为8的链表里取一个拆成两个大小为4的块,放入对应链表。回收时会判断两边是否有可以合并的块,可以合并就取下来放下一级链表。

除了伙伴算法,linux 还引入了 slab 分配器来处理重复使用的微小对象,作为对伙伴算法的补充,避免频繁分配内存-初始化-释放内存。对内核来说需要声明非常多的远小于一页空间的小对象,而文件描述符、任务描述结构体等既是小对象也是重复使用的对象。于是内核引入 slab 分配器,通过将内存按使用对象的不同划分成不同大小的空间,作为对这些对象的缓存使用。

slab 缓存的目标是 task_struct、file_struct 这些需要重复使用的小内核对象。每种这些对象都会有一个 slab 缓存池,缓存大量已经初始化的对象,每次申请时不再执行初始化,直接从缓存池里分配一个;当要释放时直接还给缓存池而不是还给伙伴系统,这样可以提高分配性能、避免内碎片。

slab 缓存实现的数据结构叫 kmem_cache,代表内核中一个类型对象的缓存。kmem_cache 用链表组合在一起就变成了内核的 cache_chain。

kmem_cache 通常是一段连续的内存块,包含了 full、partial、empty 三种 slab 的链表;slab 是 slab 分配器的最小单位,由一个(通常)或多个连续的物理页组成,根据是否有未分配的空间来决定放到哪个链表。

在分配时,如果 partial 链表还有未分配的空间,分配对象,若分配之后变满,slab 移动到 full 链表;如果 empty 链表还有未分配的空间,分配对象,移入 partial 或 full 链表;如果 empty 链表为空,请求伙伴系统分页,创建新的 slab。

用户空间内存分配

用户申请用户空间的虚拟内存时使用 malloc 函数(这个不是一个系统调用,是用户的C语言库函数),当申请小于 128K 的空间时使用的是 sbrk 或 brk 分配内存(推进堆的结束地址),大于 128K 时使用 mmap 申请。由于 brk/sbrk/mmap 是系统调用,如果每次申请内存都要产生系统调用开销、去做内核态用户态切换,就非常影响性能。而且,堆从低地址往高地址增长,如果低地址的内存没有被释放,刚地址的内存就不能被回收,容易产生内存碎片。

所以,malloc 采用的是内存池的方式,先申请一大块内存,然后分成功不同大小的内存块,在用户申请内存时直接从池里选择一块相近的内存块分配出去。malloc 函数是用户态的函数(库函数),所以应用程序调用 malloc 的时候其实不是每次都会涉及系统调用的。

image-20240322135345614

内核空间内存分配

内核空间内是通过两个函数 kmalloc 和 vmalloc 两个函数来分配不同映射区的虚拟内存。

kmalloc 用于分配虚拟范围在内核空间直接映射区的内存。kmalloc 一般用于分配以字节为单位的小块内存,其分配的物理内存一般是连续的。kmalloc 基于 slab 分配器。

vmalloc 分配的虚拟地址区间是动态内存映射区,一般用于分配大块的内存,其虚拟地址是连续的但是物理地址不一定连续。一般用于为某些IO驱动分配缓冲区或为内核模块分配空间等。

image-20240322142225683

总结

linux 通过引入虚拟内存将物理内存和进程地址空间隔离,使每个进程都有自己的虚拟地址空间,并可以访问大于实际物理内存的地址范围、防止破坏性操作。将虚拟地址空间和物理空间划分成固定大小的页面,作为内存分配和管理的基本单位。虚拟内存的实际页面可以延迟分配或置换到磁盘上。进程的虚拟内存被分为进程可访问的进程内存和内核使用的内核空间,用户空间分为代码段、数据段、BSS、堆和栈几个分段,堆和栈相对动态增长,用于动态扩展分配。对于物理内存管理,linux 使用伙伴系统对 page 按大小分组,减少了外碎片,同时引入 slab 分配器来根据对象的类别进行缓存,减少了重复使用的小对象的频繁分配回收,减少了内碎片。