Linux内核机制——内存管理
Linux与RTOS的一大区别在于是否具备虚拟内存地址到物理地址映射转换的能力。这个能力依赖于CPU硬件架构设计中的MMU(内存管理单元)。MMU会自动将进程申请访问的虚拟地址转换为实际的物理内存地址,并检查访问权限。下面将宏观介绍一下Linux内存管理大体涉及的机制。
1、虚拟内存申请
MMU是进行虚拟地址到物理地址的转换,那么在此之前就需要了解Linux的用户态和内核态分别是依靠什么进行虚拟内存管理的分配的。
Linux的内存管理可以分为用户态和内核态两方面来介绍。 用户态的内存申请接口使用malloc,其底层使用brk或者mmap系统调用(根据申请内存大小的不同,有所 区别。小内存使用brk,大内存使用mmap)。
对比项 | brk | mmap |
---|---|---|
功能 | 扩展进程的堆(Heap)空间,通过调整 break 指针(堆的结束地址)来分配内存。 | 在进程虚拟地址空间中创建新的内存映射区域,可映射文件、设备或分配匿名内存。 |
操作对象 | 堆空间(属于进程数据段的一部分)。 | 任意空闲的虚拟地址区域(通常在堆和栈之间,或指定地址范围)。 |
内存区域类型 | 连续的堆内存,地址空间在逻辑上是堆的延伸。 | 可分配单个或多个不连续的内存区域,每个区域通过独立的映射描述符管理。 |
分配方式 | 线性扩展堆空间,break 指针向高地址移动,分配的内存属于堆的一部分。 | 在虚拟地址空间中查找空闲区域,通过映射(文件 / 匿名)创建新的内存区域,地址可指定或由系统选择。 |
释放方式 | 只能通过降低 break 指针释放内存,且释放的内存必须是堆顶的连续区域(无法释放中间块),可能导致内存碎片。 | 使用 munmap 释放任意已映射的区域,可独立释放多个不连续的内存块,无碎片问题。 |
内存映射类型 | 无文件映射,仅用于匿名内存分配(堆内存)。 | 支持 文件映射(如 open 打开的文件)或 匿名映射(MAP_ANONYMOUS 标志)。 |
物理内存分配 | 与 mmap 相同,均为 延迟分配:虚拟内存分配后,物理内存仅在第一次访问时通过缺页中断分配。 | 同上。 |
适用场景 | - 小内存分配(通常小于 1MB) - 频繁分配 / 释放小块内存(如堆上的动态数据) | - 大内存分配(通常大于 1MB) - 文件映射(如动态链接库、内存映射文件) - 共享内存( MAP_SHARED )- 栈扩展、设备映射等 |
碎片问题 | 容易产生 内部碎片(堆空间释放不连续时,中间空闲块无法利用)。 | 无碎片问题,每个映射区域独立管理,释放后地址空间可被其他映射复用。 |
分配粒度 | 依赖系统实现,通常以 字节 为单位,但实际受限于页大小(如 4KB),不足一页时按页分配。 | 必须按 页大小(PAGE_SIZE )对齐,分配的最小单位为一个页(4KB、8KB 等,取决于架构)。 |
系统调用参数 | brk(const void *addr) :通过指定 break 指针目标地址来扩展 / 收缩堆。 | mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset) :需指定映射大小、权限、标志、文件描述符(可选)、偏移量等。 |
返回值 | 成功时返回新的 break 地址,失败返回 -1 。 | 成功时返回映射区域的起始虚拟地址,失败返回 MAP_FAILED ((void *)-1 )。 |
典型使用 | malloc 底层对小内存分配的实现(通过 brk 扩展堆)。 | malloc 底层对大内存分配的实现(通过 mmap 分配匿名内存)、shmget (共享内存)、动态链接库加载(dlopen )等。 |
内核实现复杂度 | 简单,仅需维护 break 指针和堆空间状态。 | 复杂,需管理虚拟内存区域(vm_area_struct )、页表映射、文件锁(若映射文件)等。 |
内存访问局部性 | 堆内存通常集中在低地址,局部性较好(适合小数据频繁访问)。 | 映射区域位置灵活,可能分布在高地址(如匿名映射)或跨地址空间,局部性取决于具体场景。 |
而内核态的内存申请使用vmalloc或kmalloc,内核对象(小内存申请)申请使用kmalloc或者专门的对象创建函数,其底层调用的slab分配器。 (slab分配器就是从伙伴系统申请页之后,进一步根据不同的对象进行细粒度的内存分配。)slab会为每个不同大小的内核对象设置不同的页进行分配。 抽象示意图如下:
┌───────────────┐│ 内核空间 │├───────────────┤│ 伙伴系统 │ (分配整页内存)├───────────────┤│ Slab 分配器 │├────┬───────────┤│ │ │┌───▼───┐ ┌───▼───┐ ┌───▼───┐│ Cache A │ │ Cache B │ │ Cache C │ (每个缓存对应一种对象类型)├────┬────┤ ├────┬────┤ ├────┬────┤│ Slab │ │ Slab │ │ Slab │├────┼────┤ ├────┼────┤ ├────┼────┤│ 对象1 │ │ 对象1 │ │ 对象1 │ (每个 slab 包含多个同类型对象)│ 对象2 │ │ 对象2 │ │ 对象3 ││ ... │ │ ... │ │ ... │└────┴────┘ └────┴────┘ └────┴────┘▲│ (空闲对象通过链表管理,分配/释放直接操作链表)┌───────────┼───────────┐│ 分配:从空闲链表取对象 ││ 释放:将对象放回链表 │└───────────────────────┘
而vmalloc适用于大内存分配,其底层依靠伙伴系统进行物理内存页的分配。 kmalloc和vmalloc的区别在于, vmalloc是虚拟内存连续,但是物理内存不连续。 而kmalloc是物理内存和虚拟内存都连续。 因此对于一些硬件的内存申请,比如DMA等,就需要连续的物理内存空间使用kmalloc进行分配。 而对于一些内核模块加载等大内存需要的场景使用vmalloc。
对比项 | kmalloc | vmalloc |
---|---|---|
分配的内存连续性 | 物理地址和虚拟地址都是连续的 | 虚拟地址连续,但物理地址不连续 |
适用场景 | 适用于分配较小的、需要物理连续内存的场景,例如 DMA 操作、内核数据结构等。 | 适用于分配较大的内存块,对物理内存连续性要求不高的情况,如临时缓冲区。 |
分配大小限制 | 通常用于分配小于一页(一般为 4KB 或 8KB)到几页大小的内存。分配大内存时效率较低且可能失败。 | 可以分配比kmalloc 大得多的内存,理论上可分配接近系统可用内存总量的大小。 |
性能 | 速度快,因为物理地址连续,内存访问效率高,且分配和释放操作简单。 | 性能相对较低,由于物理地址不连续,需要通过页表映射来访问,会增加额外的开销。 |
分配和释放函数 | 使用kmalloc 分配,kfree 释放。 | 使用vmalloc 分配,vfree 释放。 |
分配失败处理 | 分配失败时通常返回NULL ,调用者需要检查返回值并进行相应处理。 | 同样,分配失败返回NULL ,调用者要检查并处理错误情况。 |
分配上下文 | 可在进程上下文和中断上下文中使用,但在中断上下文中使用时需要使用GFP_ATOMIC 标志以避免睡眠。 | 一般在进程上下文中使用,因为分配过程可能会睡眠。 |
内存管理机制 | 基于 slab 分配器或伙伴系统,slab 分配器负责管理小内存块,伙伴系统管理大内存块。 | 基于页表机制,通过修改页表来建立虚拟地址到物理地址的映射。 |
2、虚拟地址到物理地址的转换
以上分配机制(各种malloc)都处理的虚拟内存空间。 在linux中是依靠MMU实现了虚拟地址到物理地址空间的转换,以及访问权限的检查(用户态和内核态实现的基础)。用户态程序如果想要访问内核态的数据则只能通过系统调用,否则权限检查不通过,直接抛出page_fault。
在用户空间中(低3GB空间),进程通过malloc申请到虚拟内存后,此时并不会立刻调用伙伴系统分配具体的物理页, 而是在等到进程实际对虚拟内存进行访问之后, MMU检查到此虚拟地址没有对应的物理地址,此时抛出缺页异常, 由内核处理该异常,使用伙伴系统分配对应大小的物理内存,并更新页表项建立映射。
2.1、页表结构
在32位的cpu中(32位地址总线),一般使用二级页表,即页目录和页表来存储整个页表项的映射关系。因为一级页表每个进程需要完整开辟4MB的空间,这个空间消耗是不可接收的。 而二级页表只用存储对应页表和页目录,还未分配的页表不用开辟物理空间,从而大大节约了物理内存。 其中如果通过申请的虚拟地址得到对应的页目录索引和页表项? 在32位cpu架构中,前10位用来存储页目录的索引, 中间10位用来存储页表项的索引,最后12位用来存储基于这一页的偏移地址,从而根据对应的物理页帧和其中的偏移地址得到真实数据。
部分 | 位数范围(从高位到低位) | 位数 | 名称 | 功能描述 |
---|---|---|---|---|
页目录索引 | 31 位~22 位(最高 10 位) | 10 位 | Page Directory Index | 用于定位 页目录表(Page Directory Table) 中的条目,每个条目指向一个页表的基址。 |
页表项索引 | 21 位~12 位(中间 10 位) | 10 位 | Page Table Index | 在页目录项指向的页表中,定位 页表项(Page Table Entry),每个条目指向一个物理页的基址(帧号)。 |
页内偏移 | 11 位~0 位(最低 12 位) | 12 位 | Page Offset | 在物理页内的字节偏移量(页大小为 \(2^{12} = 4KB\)),与物理页基址组合得到最终物理地址。 |
2.2、页表交换机制
在虚拟地址到物理地址转换的过程中,除了未分配物理页的pagefault,还有一种情况是该物理页被移出物理内存了,被移到了磁盘上。 这种情况主要是因为所有进程是共享一个物理内存的,因此会造成物理页帧不够存放的情况。此时就需要通过一些策略(比如LRU,最久未使用)把一些页帧swap(交换)出去,从而给需要使用的物理页帧腾出空间。
在MMU中,还设计有一个机制加快虚拟页到物理页的转换,就是TLB结构。这个结构缓存了最近使用的虚拟页到物理页的转换条目, 在访问虚拟地址时,虚拟地址进MMU后先和TLB中的转换项进行比较,如果命中那么直接返回物理地址。(缓存就会带来数据一致性的问题,要注意对应的解决方案)
┌───────────┐│ CPU │└─────┬─────┘│ 发送虚拟地址▼┌───────────┐│ MMU │├─────┬─────┤│ │ ││ ┌───▼───┐ ││ │ TLB │ ││ └───┬───┘ ││ │ ││ ┌──▼──┐ ││ │页表 │ ││ └──┬──┘ ││ │ │└─────┴─────┘│ 返回物理地址▼┌───────────┐│ 内存 │
3、伙伴系统介绍
伙伴系统是物理内存的页分配管理算法。其机制是将物理内存管理成2的n次方。并由free_page[x]把空闲页链接起来。 x就标识当前空闲内存块中有2的x次方个页。在得到页申请的时候会选择一个合适大小的内存块分配出去。 如果当前没有对应大小的内存块, 则找更大的内存块,然后不断除2进行内存块拆分,就是伙伴的分离。 直到分离到所需大小的内存块为止。 当合并的时候会根据有无伙伴进行内存块合并。 比如2^x和2^x大小的内存块就会合并成2^(2x)大小的内存块。
项目 | 详情 |
---|---|
基本原理 | 将物理内存按 2 的 n 次方大小的页块进行管理,通过free_page[x] 链表管理空闲页块,其中 x 表示空闲内存块包含 \(2^x\) 个页。 |
分配流程 | 1. 收到页申请时,查找free_page[x] 链表(x 满足 \(2^x\) 为所需内存块大小),若有合适大小的空闲内存块,直接分配。 2. 若当前没有对应大小的内存块,则查找更大的内存块,对其进行拆分(伙伴分离)。不断将大内存块拆分为两个 \(2^{x - 1}\) 大小的内存块,直到得到所需大小的内存块。 |
释放流程 | 1. 释放内存块时,检查其伙伴块(具有相同大小且地址相邻的内存块)是否空闲。 2. 若伙伴块空闲,则将两者合并成一个 \(2^{x+1}\) 大小的内存块,继续检查新合并内存块的伙伴块是否空闲,重复合并过程,直到无法合并为止。 |
数据结构 | 维护多个空闲链表free_page[x] ,每个链表存储相同大小( \(2^x\) 个页)的空闲内存块。 |
优点 | 1. 有效减少外部碎片,因为合并机制可将相邻空闲块合并成大的连续块。 2. 分配和释放操作速度较快,基于链表和简单的拆分、合并逻辑。 |
缺点 | 1. 存在内部碎片,分配的内存块大小只能是 2 的幂次方,可能导致分配的内存大于实际需求。 2. 对于非 2 的幂次方大小的内存请求,可能会造成一定的浪费。 |
4、多核/多CPU注意事项
在多核 / 多 CPU(SMP 架构)环境下,内存管理会因多个核心同时访问共享数据结构(如伙伴系统的空闲页链表、slab 分配器的缓存元数据)而面临临界区访问冲突,可能导致数据不一致、双重分配等问题,同时 NUMA 架构下跨节点内存访问延迟和缓存一致性也需处理。为此需引入自旋锁、互斥量、原子操作、禁止内核抢占等同步机制,以确保共享资源的互斥访问和操作原子性,平衡正确性与并发性能。