[TOC]
Linux 内存模型之基石:FLATMEM 深度解析
FLATMEM
是 Linux 内核中最基础、最常用,也是最高效的内存模型。正如其名,“平坦内存”模型假定系统的物理内存是一个单一、连续、不存在大间隙的地址空间。
一、 核心原理:mem_map
数组
FLATMEM
模型的全部精髓都围绕着一个核心数据结构:一个巨大的、全局的 struct page
数组,通常被称为 mem_map
。
什么是
struct page
?- 在 Linux 内核中,物理内存不是按字节管理的,而是按固定大小的块来管理的,这个块被称为页帧 (Page Frame)(通常是 4KB)。
- 内核为每一个物理页帧都分配了一个
struct page
描述符。这个结构体包含了该物理页帧的所有元数据,例如:它的引用计数、它是否是脏页、是否被锁定、属于哪个地址空间映射等。
mem_map
的作用FLATMEM
模型在内核启动的极早期,会分配一个足够大的连续内存区域,用来存放一个struct page
数组。这个数组的大小足以覆盖从物理地址0
(或某个起始偏移)到系统最大物理内存地址之间的所有页帧。- 这个
mem_map
数组就成为了一个全局的物理内存数据库。
核心转换机制
FLATMEM
的高效正源于其极其简单的地址转换逻辑。它在物理地址 (Physical Address)、页帧号 (Page Frame Number, PFN) 和struct page
描述符之间建立了直接、快速的算术映射关系:物理地址 -> PFN:
pfn = physical_address >> PAGE_SHIFT
(即physical_address / PAGE_SIZE
)
这是一个简单的位移操作。PFN ->
struct page*
:struct page* page = mem_map + (pfn - ARCH_PFN_OFFSET)
这是一个简单的数组索引操作!ARCH_PFN_OFFSET
是一个架构相关的偏移量,代表mem_map
所管理的第一个页帧的 PFN。struct page*
-> PFN:pfn = (page - mem_map) + ARCH_PFN_OFFSET
这同样是一个简单的指针减法操作。
总结一下:在 FLATMEM
模型中,给你任何一个物理地址,内核都可以通过几次算术运算,以 O(1) 的时间复杂度 快速定位到管理这个物理地址的 struct page
描述符。反之亦然。这种简单、直接的映射是其性能的保证。
二、 优点与适用场景
- 极高的效率: 所有的地址/PFN/page 转换都是简单的算术运算,几乎没有性能开销。这对于性能敏感的内存管理子系统至关重要。
- 逻辑简单: 实现非常直观,代码易于理解和维护。
- 广泛的适用性: 绝大多数消费级和服务器级计算机(非 NUMA 架构)、以及大量的嵌入式系统,其物理内存布局都是或近似是连续的。因此,
FLATMEM
是这些系统上最理想、最常见的选择。
三、 缺点与局限性
FLATMEM
的简单性也带来了其最大的缺点:缺乏灵活性和潜在的内存浪费。
巨大的内存开销:
mem_map
数组必须覆盖从内存起点到最大物理地址之间的整个地址空间范围,即使中间的某些地址范围没有插内存条。- 举例: 假设一个 64 位系统,最大支持 64GB 物理内存。每个
struct page
描述符大约需要 64 字节。那么mem_map
数组本身就需要占用:(64 * 1024^3 / 4096) * 64
字节 ≈ 512 MB
这 512MB 的内存必须在启动时被预留出来,用于存放mem_map
数组本身。如果系统实际只插了 16GB 内存,mem_map
仍然会按 64GB 的最大可能来分配(取决于具体的架构实现),造成浪费。
- 举例: 假设一个 64 位系统,最大支持 64GB 物理内存。每个
无法处理大的物理内存间隙 (Holes):
- 如果一个系统的物理内存布局是
[0-2GB]
和[8GB-10GB]
,中间有 6GB 的巨大空洞。FLATMEM
模型为了覆盖到 10GB,其mem_map
数组也必须覆盖整个[0-10GB]
的范围。那么对应于[2GB-8GB]
这个空洞的struct page
条目就完全被浪费了。
- 如果一个系统的物理内存布局是
不兼容高级内存架构:
- NUMA (Non-Uniform Memory Access): 在 NUMA 架构中,内存分布在不同的“节点 (Node)”上,访问不同节点的内存延迟不同。
FLATMEM
的单一连续模型无法描述这种节点化的、非均匀的内存布局。 - 内存热插拔 (Memory Hotplug):
FLATMEM
在启动时就固定了mem_map
的大小,无法动态地扩展以支持新插入的内存。
- NUMA (Non-Uniform Memory Access): 在 NUMA 架构中,内存分布在不同的“节点 (Node)”上,访问不同节点的内存延迟不同。
四、 FLATMEM 在内存模型演进中的位置
为了克服 FLATMEM
的局限性,Linux 内核引入了更复杂的内存模型:
DISCONTIGMEM
(非连续内存模型):- 这是早期的解决方案,主要为了支持 NUMA。它为每个不连续的内存区域(或 NUMA 节点)都维护一个独立的
mem_map
数组。 - 它的缺点是代码逻辑复杂,需要在不同节点之间进行转换,且已基本被淘汰。
- 这是早期的解决方案,主要为了支持 NUMA。它为每个不连续的内存区域(或 NUMA 节点)都维护一个独立的
SPARSEMEM
(稀疏内存模型):- 这是当前最先进、最灵活的模型,也是
DISCONTIGMEM
的现代替代品。 - 它的核心思想是:不再一次性分配一个巨大的
mem_map
数组。而是将整个物理地址空间划分为固定大小的块(section
,例如 128MB)。只有当一个section
中确实存在物理内存时,内核才会为这个section
分配一个mem_map
数组。 SPARSEMEM
完美地解决了内存空洞和内存热插拔的问题,同时也自然地支持 NUMA 架构。- 它唯一的缺点是,PFN 到
struct page
的转换不再是简单的数组索引,而需要先计算 PFN 属于哪个section
,再在该section
的mem_map
中索引,多了一步间接查找,理论上比FLATMEM
稍慢。 SPARSEMEM_VMEMMAP
: 这是对SPARSEMEM
的一个重要优化。它通过虚拟内存技术,将所有零散的mem_map
小数组映射到一个虚拟上连续的巨大地址空间中。这样一来,PFN 到struct page
的转换又变回了简单的算术运算,使得SPARSEMEM
几乎拥有了和FLATMEM
一样的高效率,同时保留了其灵活性。
- 这是当前最先进、最灵活的模型,也是
五、 总结
FLATMEM
是 Linux 内存管理的基础。它基于“物理内存是连续的”这一简单假设,通过一个全局的 mem_map
数组和高效的算术转换,提供了最快速、最直接的物理内存管理机制。
尽管它的刚性设计无法适应 NUMA 和内存热插拔等高级场景,但这并不妨碍它在绝大多数计算机和嵌入式设备上作为默认和最佳选择。理解 FLATMEM
的工作原理,是理解 Linux 为何以及如何演进出 SPARSEMEM
等更复杂内存模型的关键。
include/linux/mm_types.h
MM_MT_FLAGS 配置内存管理结构的多线程行为
1 | /* 组合多个标志,支持范围分配、外部锁保护和 RCU 同步机制 */ |
include/linux/mm.h
mm_zero_struct_page 设置结构体页面为零
1 | /* |
1. 减少函数调用开销
memset
是一个通用的库函数,通常需要处理多种情况(如不同的内存大小、对齐方式等),因此它的实现包含了额外的逻辑和分支判断。- 而
__mm_zero_struct_page
是针对特定结构体大小(如struct page
)优化的内联函数,直接展开为一系列赋值操作,避免了函数调用的开销。
2. 编译器优化
- 在
__mm_zero_struct_page
中,使用了switch
语句和fallthrough
关键字,编译器可以根据struct page
的大小生成最优的指令序列。 - 编译器可能会将连续的赋值操作合并为更高效的指令(如 SIMD 指令或块存储指令),从而进一步提升性能。
3. 避免通用性开销
memset
是为任意大小的内存块设计的,因此需要处理对齐、边界条件等问题。- 而
__mm_zero_struct_page
针对特定大小的struct page
进行了优化,避免了这些额外的处理。
4. 减少分支预测失败
memset
的实现可能包含多个分支(如处理小块内存和大块内存的不同路径),这些分支可能导致分支预测失败,影响性能。__mm_zero_struct_page
的实现是固定的赋值操作,没有额外的分支,减少了分支预测失败的可能性。
5. 内存对齐的优势
- 在
__mm_zero_struct_page
中,struct page
被转换为unsigned long
类型的指针,并按 64 位(或更大)对齐的块进行操作。这种对齐方式通常能更好地利用 CPU 的缓存和内存带宽。 memset
需要处理未对齐的情况,可能会引入额外的开销。
set_page_links 设置页面链接
1 | static inline void set_page_zone(struct page *page, enum zone_type zone) |
mm/init.c: Linux内存管理子系统的引导者与初始化核心
介绍
mm/init.c
是 Linux 内核中负责初始化整个内存管理(MM)子系统的关键文件。它在内核启动过程中扮演着“点火器”和“交接官”的角色。它在系统启动的极早期被调用,其核心任务是建立起所有高级内存管理机制(如伙伴系统、Slab 分配器、vmalloc 等),并从临时的引导内存分配器(memblock
)手中接管对所有物理内存的控制权。
一、 核心职责
mm/init.c
的工作是一次性的,但在内核的生命周期中至关重要。它的职责可以分解为以下几个关键步骤:
从引导内存分配器 (
memblock
) 过渡: 在mm_init
运行之前,内核使用一个非常简单、临时的memblock
分配器来管理内存。mm_init.c
的首要任务是初始化真正的、高效的内存管理数据结构,然后将memblock
管理的所有可用内存“释放”到新的管理体系中。初始化页区分配器 (Zone Allocator / Buddy System): 这是最核心的一步。它负责为系统的物理内存划分不同的页区 (Zones),并为每个页区建立伙伴系统(Buddy System)所需的数据结构(如
free_area
列表)。在此之后,内核才拥有了以页(PAGE_SIZE
)为单位进行高效分配和释放物理内存的能力。初始化 Slab/SLUB/SLOB 分配器: 伙伴系统只能分配整个页。为了高效地管理小于页的小块内存(供
kmalloc
使用),内核需要 Slab 分配器。mm/init.c
负责调用 Slab 分配器的初始化函数,而 Slab 分配器自身依赖于已经准备就绪的伙伴系统来获取大块内存。初始化
vmalloc
区域:vmalloc
用于分配虚拟地址连续但物理地址不一定连续的大块内存。mm/init.c
负责为其建立所需的数据结构和地址空间范围。释放初始化内存: 内核中大量的初始化代码和数据在系统启动完成后就不再需要了。
mm/init.c
包含了回收这部分内存的逻辑,将其交还给伙伴系统,从而为系统增加数兆字节的可用 RAM。
二、 核心函数深度解析
1. mm_init()
- 总指挥
这是 init/main.c
中的 start_kernel()
函数调用的顶层入口。它像一个总指挥,按严格的顺序协调调用其他初始化函数。
典型的 mm_init()
执行流程:
mem_init()
: 调用此函数来初始化伙伴系统。这是所有后续内存分配的基础。kmem_cache_init()
: 初始化 Slab/SLOB/SLUB 分配器。从此kmalloc
才真正可用。vmalloc_init()
: 初始化vmalloc
机制。- 其他初始化:可能还包括页表相关的初始化等。
这个函数标志着 Linux 从一个只能使用临时分配器的简单程序,转变为一个拥有复杂、动态内存管理能力的成熟操作系统内核。
2. mem_init()
- 伙伴系统的奠基者
这是 mm_init()
调用的第一个关键函数,也是 mm/init.c
中技术含量最高的部分之一。
它的核心任务: 将 memblock
报告的所有可用物理内存,逐一“注册”到伙伴系统的管理数据结构中。
执行步骤:
- 清空
mem_map
: 将FLATMEM
或SPARSEMEM
模型提供的struct page
数组(mem_map
)中与保留页(内核代码、已分配内存等)无关的条目清零。 - 计算页区边界: 内核将物理内存划分为不同的区域,称为“页区 (Zone)”,以应对不同的硬件限制:
ZONE_DMA/DMA32
: 用于只能对低地址内存进行 DMA 的老旧设备。ZONE_NORMAL
: 内核可以直接映射和访问的“常规”内存。ZONE_HIGHMEM
: (仅在 32 位系统上)超出内核直接映射范围的高端物理内存。ZONE_MOVABLE
: 用于可移动页,以减少内存碎片。mem_init()
会根据架构和内存大小计算出这些 Zone 的 PFN(页帧号)边界。
- 初始化
free_area
: 调用free_area_init_nodes()
或类似函数,为每个 NUMA 节点(在 UMA 系统上只有一个节点)的每个 Zone 初始化其free_area
数组。这个数组是伙伴系统的核心,包含了指向不同大小(order)的空闲内存块链表的指针。 - 释放内存到伙伴系统: 遍历
memblock.memory
列表(即所有可用物理内存段),对每一段内存,调用memblock_free()
或直接调用底层函数,将其以页为单位添加到对应 Zone 的free_area
中。这个过程就是伙伴系统“获得”其初始可用内存的过程。
最终结果: mem_init()
执行完毕后,alloc_pages()
和 __get_free_pages()
等基于伙伴系统的物理页分配函数就完全可用了。
3. free_initmem()
- “过河拆桥”的艺术
这是在内核启动后期(在 smp_init()
之后)被调用的一个函数,它体现了内核对内存资源利用的极致追求。
背景:
- 内核代码和数据中,有很大一部分被
__init
和__initdata
宏标记。 __init
: 标记一个函数是初始化函数。__initdata
: 标记一个变量是初始化数据。- 链接器会将所有被这些宏标记的代码和数据,都集中放置在一个特殊的、连续的内存区域(
.init
段)。
free_initmem()
的工作:
- 内核启动过程已经完成,所有标记为
__init
的函数都已被执行,所有标记为__initdata
的数据都已被使用。它们在内核的后续运行中永远不会再被访问。 free_initmem()
计算出这个.init
段的起始和结束物理地址。- 它像对待普通可用内存一样,将这整块内存(通常有好几 MB)释放回伙伴系统。
最终结果: 内核通过“丢弃”掉自己的初始化代码,回收了宝贵的内存资源,供系统运行时使用。
三、 在内核启动流程中的位置
mm/init.c
的代码位于一个承上启下的关键节点:
- 之前: 架构相关的汇编代码 (
head.S
) 已经完成了最基础的设置(如启用分页、建立临时页表)。memblock
分配器已经通过解析 Bootloader 传递的参数(如设备树、E820 表),建立了一个物理内存的初步布局图。 - 调用:
init/main.c
中的start_kernel()
函数,在完成了 Trap 初始化、调度器早期初始化等步骤后,调用mm_init()
。 - 之后: 完整的内存管理子系统上线。内核可以动态地分配和释放各种大小的内存,为设备驱动的加载、进程的创建等所有后续操作铺平了道路。
四、 总结
mm/init.c
是 Linux 内核从一个静态加载的程序转变为一个全功能动态操作系统的转换开关。它不是一个驱动,也不是一个持续运行的子系统,而是一个一次性的、至关重要的初始化模块。
它通过精心设计的顺序,用高级的内存管理机制替换掉临时的引导机制,并最终通过回收自身的初始化代码来最大化可用内存。理解 mm/init.c
的工作流程,是深入理解 Linux 内存管理如何从无到有建立起来的关键。
find_usable_zone_for_movable 查找可用于ZONE_MOVABLE页面的区域
- 按照从高到低的顺序查找可用于 ZONE_MOVABLE 的区域。
1 | /* |
mirrored_kernelcore 设置内核内存的镜像
- 该选项用于设置内核内存的镜像。
- 该选项在内核启动时解析,并在内存块初始化期间使用。
- 该选项的值可以是“mirror”或“mirror=0”。
- 该选项的默认值为“mirror=0”。
1 | bool mirrored_kernelcore __initdata_memblock; |
find_zone_movable_pfns_for_nodes 寻找可移动区域的页面
1 | static void __init find_zone_movable_pfns_for_nodes(void) |
calculate_node_totalpages 计算节点总页数
1 | static void __init calculate_node_totalpages(struct pglist_data *pgdat, |
pgdat_init_internals 初始化pgdat
1 | static void __meminit pgdat_init_internals(struct pglist_data *pgdat) |
free_area_init_core 初始化区域核心
1 | static void __meminit zone_init_internals(struct zone *zone, enum zone_type idx, int nid, |
free_area_init_node 初始化节点空闲区域
1 | static void __init free_area_init_node(int nid) |
calc_nr_kernel_pages 计算内核页面数量
- nr代表number数量的意思
1 | static unsigned long nr_kernel_pages __initdata; |
__init_single_page 初始化单个页面
1 | void __meminit __init_single_page(struct page *page, unsigned long pfn, |
memmap_init_range 初始化内存映射范围
- 此函数是Linux内核内存管理子系统在初始化阶段的核心部分。它的主要职责是为一段物理内存范围内的每一页(Page)初始化其对应的元数据结构(struct page)。这个过程是内核从早期引导内存管理器(memblock)过渡到最终的伙伴系统(Buddy System)分配器的关键步骤。
1 | /* |
memmap_init_zone_range 初始化内存映射区域
1 | static void __init memmap_init_zone_range(struct zone *zone, |
memmap_init 初始化内存映射
1 | static void __init memmap_init(void) |
free_area_init 初始化所有pg_data_t和区域数据
1 | /** |
alloc_large_system_hash 分配大型系统哈希
1 | /* |
mem_debugging_and_hardening_init 内存调试和硬化初始化
1 | DECLARE_STATIC_KEY_MAYBE(CONFIG_INIT_ON_ALLOC_DEFAULT_ON, init_on_alloc); |
report_meminit 报告内存初始化
1 | /* 报告此启动的内存自动初始化状态。 */ |
mm_core_init 设置内核内存分配器
1 | /* |
reserve_bootmem_region 标记由引导内存分配器(bootmem allocator)分配的内存区域中的页面为“保留”(PageReserved
- 这些页面在初始化阶段不会被普通内存管理机制使用,通常用于内核自身的用途或特殊的硬件需求。
1 | struct page *mem_map; |
memblock_free_pages 释放内存块
1 | void __init memblock_free_pages(struct page *page, unsigned long pfn, |
set_zone_contiguous 检查一个内存区域(zone)中的所有页块(pageblock)是否都存在对应的物理页帧
- set_zone_contiguous 是内存管理子系统中的一个函数。它的作用是检查一个内存区域(zone)中的所有页块(pageblock)是否都存在对应的物理页帧。如果整个区域的内存是物理上连续且没有“空洞”的,它就会将该区域的 contiguous 标志位设置为 true。这个标志对于需要大块连续物理内存的设备(如一些DMA设备)或巨页(HugeTLB)分配非常重要。
1 | // 设置一个内存区域(zone)的 contiguous 标志位。 |
page_alloc_sysctl_init
1 | // 定义一个用于页分配器sysctl接口的控制表。 |
page_alloc_init_late 对页分配器(Page Allocator)子系统进行的后期初始化函数
- page_alloc_init_late 是Linux内核在启动过程中,对页分配器(Page Allocator)子系统进行的后期初始化函数。它在核心的伙伴系统(Buddy System)已经可以工作之后被调用,用于完成一些依赖于完整内存信息或可以被推迟的初始化任务。
- 其主要工作包括:
- (可选)并行初始化页描述符:在拥有大量内存的系统上,通过创建内核线程来并行初始化struct page数组,以缩短启动时间。
- 打印内存信息:在内存信息稳定后,向内核日志打印最终的内存布局和使用情况。
- 释放早期内存管理器:丢弃memblock分配器自身使用的数据结构,回收这部分内存。
- 增强安全性:通过随机化空闲页链表来增加内存布局的不可预测性,抵御某些内存攻击。
- 完成最后的配置:初始化与页分配相关的sysctl接口,允许用户在运行时查看和调整内存管理参数。
1 | // 定义页分配器的后期初始化函数。 |
mm_sysfs_init: 初始化内存管理(MM)相关的sysfs接口
此函数在内核启动过程中被调用, 其唯一且核心的作用是在sysfs
文件系统中创建顶层的 /sys/kernel/mm/
目录。这个目录是一个命名空间, 作为所有与内核内存管理(Memory Management)相关的可调参数和统计信息文件的父容器。
1 | /* |